Files
SubMiner/src/main/character-dictionary-runtime/image-lookup.ts
T

274 lines
7.8 KiB
TypeScript

import * as fs from 'fs';
import * as path from 'path';
import type { CharacterNameImage } from '../../types';
import { readCachedSnapshots } from './cache';
import type {
CharacterDictionaryGlossaryEntry,
CharacterDictionarySnapshot,
CharacterDictionarySnapshotImage,
CharacterDictionaryTermEntry,
} from './types';
const CHARACTER_IMAGE_PATH_PATTERN = /^img\/m\d+-c\d+\.[a-z0-9]+$/i;
type StructuredContentNode = {
tag?: unknown;
path?: unknown;
alt?: unknown;
title?: unknown;
content?: unknown;
};
function normalizeLookupTerm(term: string): string {
return term.trim();
}
function normalizeLookupMediaId(mediaId: unknown): number | null {
if (typeof mediaId !== 'number' || !Number.isFinite(mediaId)) {
return null;
}
const normalized = Math.floor(mediaId);
return normalized > 0 ? normalized : null;
}
function getSnapshotsDir(outputDir: string): string {
return path.join(outputDir, 'snapshots');
}
function getImageMimeType(imagePath: string, dataBase64: string): string {
const signature = Buffer.from(dataBase64.slice(0, 64), 'base64');
if (
signature.length >= 8 &&
signature[0] === 0x89 &&
signature[1] === 0x50 &&
signature[2] === 0x4e &&
signature[3] === 0x47
) {
return 'image/png';
}
if (
signature.length >= 12 &&
signature.subarray(0, 4).toString('ascii') === 'RIFF' &&
signature.subarray(8, 12).toString('ascii') === 'WEBP'
) {
return 'image/webp';
}
if (
signature.length >= 6 &&
(signature.subarray(0, 6).toString('ascii') === 'GIF89a' ||
signature.subarray(0, 6).toString('ascii') === 'GIF87a')
) {
return 'image/gif';
}
if (signature.length >= 3 && signature[0] === 0xff && signature[1] === 0xd8) {
return 'image/jpeg';
}
if (
signature.length >= 12 &&
signature.subarray(4, 8).toString('ascii') === 'ftyp' &&
signature.subarray(8, 12).toString('ascii') === 'avif'
) {
return 'image/avif';
}
const ext = path.extname(imagePath).toLowerCase();
if (ext === '.jpg' || ext === '.jpeg') return 'image/jpeg';
if (ext === '.png') return 'image/png';
if (ext === '.webp') return 'image/webp';
if (ext === '.gif') return 'image/gif';
if (ext === '.avif') return 'image/avif';
return 'image/jpeg';
}
function buildImageByPath(
images: ReadonlyArray<CharacterDictionarySnapshotImage>,
): Map<string, CharacterDictionarySnapshotImage> {
const imageByPath = new Map<string, CharacterDictionarySnapshotImage>();
for (const image of images) {
if (image.path && image.dataBase64) {
imageByPath.set(image.path, image);
}
}
return imageByPath;
}
function findCharacterImageNode(value: unknown): StructuredContentNode | null {
if (Array.isArray(value)) {
for (const item of value) {
const found = findCharacterImageNode(item);
if (found) return found;
}
return null;
}
if (!value || typeof value !== 'object') {
return null;
}
const node = value as StructuredContentNode;
if (
node.tag === 'img' &&
typeof node.path === 'string' &&
CHARACTER_IMAGE_PATH_PATTERN.test(node.path)
) {
return node;
}
return findCharacterImageNode(node.content);
}
function findCharacterImageNodeInGlossary(
glossary: ReadonlyArray<CharacterDictionaryGlossaryEntry>,
): StructuredContentNode | null {
for (const entry of glossary) {
const found = findCharacterImageNode(entry);
if (found) return found;
}
return null;
}
function createCharacterNameImage(
entry: CharacterDictionaryTermEntry,
imageByPath: ReadonlyMap<string, CharacterDictionarySnapshotImage>,
): CharacterNameImage | null {
const term = normalizeLookupTerm(entry[0]);
if (!term) {
return null;
}
const imageNode = findCharacterImageNodeInGlossary(entry[5]);
const imagePath = typeof imageNode?.path === 'string' ? imageNode.path : '';
const image = imageByPath.get(imagePath);
if (!image) {
return null;
}
const rawAlt =
typeof imageNode?.alt === 'string'
? imageNode.alt
: typeof imageNode?.title === 'string'
? imageNode.title
: term;
const alt = rawAlt.trim() || term;
return {
src: `data:${getImageMimeType(image.path, image.dataBase64)};base64,${image.dataBase64}`,
alt,
};
}
function appendSnapshotImages(
index: Map<string, CharacterNameImage>,
snapshot: CharacterDictionarySnapshot,
): void {
const imageByPath = buildImageByPath(snapshot.images);
for (const entry of snapshot.termEntries) {
const term = normalizeLookupTerm(entry[0]);
if (!term || index.has(term)) {
continue;
}
const image = createCharacterNameImage(entry, imageByPath);
if (image) {
index.set(term, image);
}
}
}
export function snapshotHasCharacterNameImages(snapshot: CharacterDictionarySnapshot): boolean {
const imageByPath = buildImageByPath(snapshot.images);
return snapshot.termEntries.some(
(entry) => createCharacterNameImage(entry, imageByPath) !== null,
);
}
function getSnapshotDirectorySignature(outputDir: string): string {
let entries: fs.Dirent[] = [];
try {
entries = fs.readdirSync(getSnapshotsDir(outputDir), { withFileTypes: true });
} catch {
return '';
}
const parts: string[] = [];
for (const entry of entries) {
if (!entry.isFile() || !/^anilist-\d+\.json$/.test(entry.name)) {
continue;
}
const snapshotPath = path.join(getSnapshotsDir(outputDir), entry.name);
try {
const stat = fs.statSync(snapshotPath);
parts.push(`${entry.name}:${stat.mtimeMs}:${stat.size}`);
} catch {
// Ignore files that disappear during refresh; next lookup will rebuild.
}
}
return parts.sort().join('|');
}
export function buildCharacterNameImageIndexFromSnapshots(
outputDir: string,
): Map<string, CharacterNameImage> {
const index = new Map<string, CharacterNameImage>();
for (const snapshot of readCachedSnapshots(outputDir)) {
appendSnapshotImages(index, snapshot);
}
return index;
}
export function createCharacterDictionaryImageLookup(deps: {
userDataPath?: string;
outputDir?: string;
getCurrentMediaId?: () => number | null | undefined;
}): {
get: (term: string, mediaId?: number | null) => CharacterNameImage | null;
invalidate: () => void;
} {
const outputDir =
deps.outputDir ??
(deps.userDataPath ? path.join(deps.userDataPath, 'character-dictionaries') : '');
let signature: string | null = null;
let index = new Map<string, CharacterNameImage>();
let indexByMediaId = new Map<number, Map<string, CharacterNameImage>>();
function refreshIfNeeded(): void {
if (!outputDir) {
index = new Map<string, CharacterNameImage>();
indexByMediaId = new Map<number, Map<string, CharacterNameImage>>();
signature = '';
return;
}
const nextSignature = getSnapshotDirectorySignature(outputDir);
if (nextSignature === signature) {
return;
}
signature = nextSignature;
index = new Map<string, CharacterNameImage>();
indexByMediaId = new Map<number, Map<string, CharacterNameImage>>();
for (const snapshot of readCachedSnapshots(outputDir)) {
appendSnapshotImages(index, snapshot);
const mediaIndex = new Map<string, CharacterNameImage>();
appendSnapshotImages(mediaIndex, snapshot);
if (mediaIndex.size > 0) {
indexByMediaId.set(snapshot.mediaId, mediaIndex);
}
}
}
return {
get(term: string, mediaId?: number | null): CharacterNameImage | null {
const normalizedTerm = normalizeLookupTerm(term);
if (!normalizedTerm) {
return null;
}
refreshIfNeeded();
const scopedMediaId = normalizeLookupMediaId(mediaId ?? deps.getCurrentMediaId?.() ?? null);
if (scopedMediaId !== null) {
return indexByMediaId.get(scopedMediaId)?.get(normalizedTerm) ?? null;
}
return index.get(normalizedTerm) ?? null;
},
invalidate(): void {
signature = null;
},
};
}