@aphrody/frames 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/index.ts ADDED
@@ -0,0 +1,346 @@
1
+ // SPDX-License-Identifier: Apache-2.0
2
+ /**
3
+ * `@aphrody/frames` — retrouver une image dans un anime, image par image.
4
+ *
5
+ * Deux façons de répondre à « d'où vient cette capture ? » coexistent ici :
6
+ *
7
+ * - **l'index local** : on indexe soi-même les épisodes qu'on possède, une
8
+ * empreinte de 33 octets par trame échantillonnée. Aucun quota, aucune
9
+ * limite de débit, aucune image envoyée à un tiers, et surtout : ça marche
10
+ * sur ce que les index publics n'ont pas — les VF, les séries récentes, les
11
+ * films, tout ce qui n'a jamais été indexé ailleurs ;
12
+ * - **trace.moe** : 1,7 milliard de trames déjà indexées, mais 100 recherches
13
+ * par jour et rien pour ce qu'il ne connaît pas.
14
+ *
15
+ * {@link FrameSearch} enchaîne les deux : local d'abord, distant seulement si
16
+ * le local ne répond pas — et alors sous forme de **vecteur**, pas d'image
17
+ * (33 entiers partent, la capture reste sur la machine).
18
+ */
19
+
20
+ export {
21
+ CL_DIMS,
22
+ CL_C_COUNT,
23
+ CL_Y_COUNT,
24
+ DISTANCE_SCALE,
25
+ colorLayoutDistance,
26
+ decodeVector,
27
+ encodeVector,
28
+ extractColorLayout,
29
+ packVector,
30
+ similarityFromDistance,
31
+ unpackVector,
32
+ type ColorLayoutVector,
33
+ type PixelData,
34
+ } from "./descriptor.ts";
35
+
36
+ export {
37
+ buildFrameArgs,
38
+ buildProbeArgs,
39
+ buildStillArgs,
40
+ decodeStill,
41
+ iterateFrames,
42
+ parseProbe,
43
+ probeMedia,
44
+ timecode,
45
+ type DecodedFrame,
46
+ type FfmpegDeps,
47
+ type FrameStreamOptions,
48
+ type MediaInfo,
49
+ type SpawnedProcess,
50
+ type Spawner,
51
+ } from "./ffmpeg.ts";
52
+
53
+ export {
54
+ FrameIndex,
55
+ defaultIndexPath,
56
+ type FrameRow,
57
+ type IndexStats,
58
+ type MediaMeta,
59
+ type MediaRow,
60
+ } from "./store.ts";
61
+
62
+ export {
63
+ SCENE_MAX_SPAN_MS,
64
+ SCENE_TOLERANCE,
65
+ formatTimecode,
66
+ searchIndex,
67
+ type SceneMatch,
68
+ type SearchOptions,
69
+ } from "./search.ts";
70
+
71
+ export {
72
+ TRACE_MOE_ENDPOINT,
73
+ TraceMoeClient,
74
+ TraceMoeError,
75
+ type AnilistInfo,
76
+ type TraceMoeErrorKind,
77
+ type TraceMoeOptions,
78
+ type TraceMoeQuota,
79
+ type TraceMoeResponse,
80
+ type TraceMoeResult,
81
+ type TraceMoeSearchOptions,
82
+ } from "./trace-moe.ts";
83
+
84
+ import { extractColorLayout, packVector, type ColorLayoutVector } from "./descriptor.ts";
85
+ import { decodeStill, iterateFrames, probeMedia, type FfmpegDeps } from "./ffmpeg.ts";
86
+ import { FrameIndex, type MediaMeta } from "./store.ts";
87
+ import { searchIndex, type SceneMatch, type SearchOptions } from "./search.ts";
88
+ import {
89
+ TraceMoeClient,
90
+ type TraceMoeOptions,
91
+ type TraceMoeResult,
92
+ type TraceMoeSearchOptions,
93
+ } from "./trace-moe.ts";
94
+
95
+ /** Seuil au-dessous duquel un résultat est réputé faux, des deux côtés. */
96
+ export const CONFIDENCE_THRESHOLD = 0.9;
97
+
98
+ /** Réglages d'une indexation. */
99
+ export interface IndexVideoOptions {
100
+ /** Trames indexées par seconde de vidéo (défaut : 1). */
101
+ fps?: number;
102
+ /** Côté de la vignette décodée (défaut : 128). */
103
+ size?: number;
104
+ title?: string;
105
+ season?: number | null;
106
+ episode?: number | null;
107
+ /** Réindexer même si le média est déjà présent avec la même cadence. */
108
+ force?: boolean;
109
+ /** Appelé tous les `progressEvery` trames — pour un affichage de progression. */
110
+ onProgress?: (indexed: number, tMs: number) => void;
111
+ progressEvery?: number;
112
+ }
113
+
114
+ /** Bilan d'une indexation. */
115
+ export interface IndexVideoResult {
116
+ mediaId: number;
117
+ frames: number;
118
+ durationMs: number;
119
+ /** Vrai si le média était déjà indexé et n'a pas été retouché. */
120
+ skipped: boolean;
121
+ }
122
+
123
+ /** Un résultat, quelle que soit sa provenance. */
124
+ export interface UnifiedMatch {
125
+ title: string;
126
+ episode: number | number[] | null;
127
+ season: number | null;
128
+ fromMs: number;
129
+ atMs: number;
130
+ toMs: number;
131
+ similarity: number;
132
+ /** Source locale (chemin du média) ou nom de fichier côté trace.moe. */
133
+ source: string;
134
+ /** Identifiant AniList, uniquement pour un résultat distant. */
135
+ anilist?: number;
136
+ /** Aperçus fournis par trace.moe, valables 5 minutes. */
137
+ preview?: { image: string; video: string };
138
+ }
139
+
140
+ /** Réponse de {@link FrameSearch.search}. */
141
+ export interface UnifiedSearch {
142
+ origin: "local" | "remote";
143
+ matches: UnifiedMatch[];
144
+ /** Renseigné après un appel distant. */
145
+ quota?: { used: number; total: number };
146
+ }
147
+
148
+ /** Mode de recherche : local seul, distant seul, ou local puis distant. */
149
+ export type SearchMode = "auto" | "local" | "remote";
150
+
151
+ /** Réglages de {@link FrameSearch}. */
152
+ export interface FrameSearchOptions {
153
+ index?: FrameIndex;
154
+ indexPath?: string;
155
+ ffmpeg?: FfmpegDeps;
156
+ traceMoe?: TraceMoeClient | TraceMoeOptions;
157
+ /**
158
+ * Côté de la vignette décodée, en pixels (défaut : 128).
159
+ *
160
+ * Une seule valeur pour l'indexation et pour les requêtes : deux tailles
161
+ * différentes donnent des descripteurs légèrement différents, donc des
162
+ * distances qui ne veulent plus rien dire.
163
+ */
164
+ size?: number;
165
+ }
166
+
167
+ /**
168
+ * Façade : indexe des vidéos, cherche en local, retombe sur trace.moe.
169
+ */
170
+ export class FrameSearch {
171
+ public readonly index: FrameIndex;
172
+ public readonly traceMoe: TraceMoeClient;
173
+ /** Côté de la vignette décodée, partagé par l'indexation et les requêtes. */
174
+ public readonly size: number;
175
+ private readonly ffmpeg: FfmpegDeps;
176
+
177
+ constructor(opts: FrameSearchOptions = {}) {
178
+ this.index = opts.index ?? new FrameIndex(opts.indexPath);
179
+ this.ffmpeg = opts.ffmpeg ?? {};
180
+ this.size = opts.size ?? 128;
181
+ this.traceMoe =
182
+ opts.traceMoe instanceof TraceMoeClient
183
+ ? opts.traceMoe
184
+ : new TraceMoeClient(opts.traceMoe ?? {});
185
+ }
186
+
187
+ /**
188
+ * Indexe une vidéo : ffmpeg la décode en flux, chaque trame devient
189
+ * 33 octets. Rien n'est écrit sur disque à part la base.
190
+ */
191
+ async indexVideo(source: string, opts: IndexVideoOptions = {}): Promise<IndexVideoResult> {
192
+ const fps = opts.fps ?? 1;
193
+ const size = opts.size ?? this.size;
194
+ const existing = this.index.findMedia(source);
195
+ if (existing && existing.frameCount > 0 && existing.fps === fps && !opts.force) {
196
+ return {
197
+ mediaId: existing.id,
198
+ frames: existing.frameCount,
199
+ durationMs: existing.durationMs,
200
+ skipped: true,
201
+ };
202
+ }
203
+
204
+ let durationMs = 0;
205
+ try {
206
+ durationMs = (await probeMedia(source, this.ffmpeg)).durationMs;
207
+ } catch {
208
+ // Un flux sans durée annoncée s'indexe quand même : on la déduira
209
+ // du dernier horodatage.
210
+ }
211
+
212
+ const meta: MediaMeta = {
213
+ source,
214
+ title: opts.title ?? source.split("/").pop() ?? source,
215
+ season: opts.season ?? null,
216
+ episode: opts.episode ?? null,
217
+ durationMs,
218
+ fps,
219
+ };
220
+ const mediaId = this.index.upsertMedia(meta);
221
+ this.index.clearFrames(mediaId);
222
+
223
+ const every = opts.progressEvery ?? 250;
224
+ let count = 0;
225
+ let lastT = 0;
226
+ const batch: Array<{ tMs: number; vector: Uint8Array }> = [];
227
+ for await (const frame of iterateFrames(source, { fps, size }, this.ffmpeg)) {
228
+ batch.push({ tMs: frame.tMs, vector: packVector(extractColorLayout(frame.pixels)) });
229
+ lastT = frame.tMs;
230
+ count++;
231
+ if (batch.length >= 1000) {
232
+ this.index.insertFrames(mediaId, batch.splice(0, batch.length));
233
+ }
234
+ if (opts.onProgress && count % every === 0) opts.onProgress(count, frame.tMs);
235
+ }
236
+ if (batch.length) this.index.insertFrames(mediaId, batch);
237
+ if (!durationMs && lastT) {
238
+ this.index.upsertMedia({ ...meta, durationMs: lastT });
239
+ durationMs = lastT;
240
+ }
241
+ return { mediaId, frames: count, durationMs, skipped: false };
242
+ }
243
+
244
+ /** Décode une image (ou une trame de vidéo) et rend son descripteur. */
245
+ async vectorOf(
246
+ source: string,
247
+ opts: { atMs?: number; size?: number } = {},
248
+ ): Promise<ColorLayoutVector> {
249
+ const pixels = await decodeStill(
250
+ source,
251
+ { size: opts.size ?? this.size, atMs: opts.atMs },
252
+ this.ffmpeg,
253
+ );
254
+ return extractColorLayout(pixels);
255
+ }
256
+
257
+ /** Recherche dans l'index local. */
258
+ searchLocal(vector: ColorLayoutVector, opts: SearchOptions = {}): SceneMatch[] {
259
+ return searchIndex(this.index, vector, opts);
260
+ }
261
+
262
+ /**
263
+ * Recherche sur trace.moe **par vecteur** : la capture ne quitte jamais la
264
+ * machine, seuls 33 entiers partent.
265
+ */
266
+ async searchRemote(
267
+ vector: ColorLayoutVector,
268
+ opts: TraceMoeSearchOptions = {},
269
+ ): Promise<UnifiedSearch> {
270
+ const response = await this.traceMoe.searchByVector(vector, { anilistInfo: true, ...opts });
271
+ return {
272
+ origin: "remote",
273
+ matches: response.result.map(toUnified),
274
+ quota: { used: response.quotaUsed, total: response.quota },
275
+ };
276
+ }
277
+
278
+ /**
279
+ * Cherche d'où vient une image. En mode `auto`, l'index local répond seul
280
+ * s'il est sûr de lui ; sinon la question part chez trace.moe.
281
+ */
282
+ async search(
283
+ source: string,
284
+ opts: {
285
+ mode?: SearchMode;
286
+ limit?: number;
287
+ atMs?: number;
288
+ threshold?: number;
289
+ anilistID?: number;
290
+ } = {},
291
+ ): Promise<UnifiedSearch> {
292
+ const mode = opts.mode ?? "auto";
293
+ const threshold = opts.threshold ?? CONFIDENCE_THRESHOLD;
294
+ const vector = await this.vectorOf(source, { atMs: opts.atMs });
295
+
296
+ if (mode !== "remote") {
297
+ const local = this.searchLocal(vector, { limit: opts.limit ?? 5 });
298
+ const best = local[0];
299
+ if (mode === "local" || (best && best.similarity >= threshold)) {
300
+ return {
301
+ origin: "local",
302
+ matches: local.map((m) => ({
303
+ title: m.title,
304
+ episode: m.episode,
305
+ season: m.season,
306
+ fromMs: m.fromMs,
307
+ atMs: m.atMs,
308
+ toMs: m.toMs,
309
+ similarity: m.similarity,
310
+ source: m.source,
311
+ })),
312
+ };
313
+ }
314
+ }
315
+ return await this.searchRemote(vector, { anilistID: opts.anilistID });
316
+ }
317
+
318
+ close(): void {
319
+ this.index.close();
320
+ }
321
+ }
322
+
323
+ function toUnified(result: TraceMoeResult): UnifiedMatch {
324
+ const anilist = typeof result.anilist === "number" ? { id: result.anilist } : result.anilist;
325
+ const title =
326
+ typeof result.anilist === "number"
327
+ ? result.filename
328
+ : (result.anilist.title?.romaji ??
329
+ result.anilist.title?.english ??
330
+ result.anilist.title?.native ??
331
+ result.filename);
332
+ return {
333
+ title,
334
+ episode: result.episode ?? null,
335
+ season: null,
336
+ fromMs: Math.round(result.from * 1000),
337
+ atMs: Math.round(result.at * 1000),
338
+ toMs: Math.round(result.to * 1000),
339
+ similarity: result.similarity,
340
+ source: result.filename,
341
+ anilist: anilist.id,
342
+ preview: { image: result.image, video: result.video },
343
+ };
344
+ }
345
+
346
+ export default FrameSearch;
package/src/search.ts ADDED
@@ -0,0 +1,164 @@
1
+ // SPDX-License-Identifier: Apache-2.0
2
+ /**
3
+ * Recherche dans l'index local.
4
+ *
5
+ * Le balayage est exhaustif : chaque trame indexée est comparée à la requête.
6
+ * À 33 octets par trame, un catalogue complet d'anime tient dans quelques
7
+ * dizaines de méga-octets et se parcourt en quelques centaines de
8
+ * millisecondes — pour un résultat *exact*, là où un index approché échange de
9
+ * la justesse contre une vitesse dont on n'a pas besoin ici.
10
+ *
11
+ * Un résultat n'est pas une trame mais une **scène** : la meilleure trame,
12
+ * puis son voisinage tant que l'image reste la même (cf. {@link SCENE_TOLERANCE}).
13
+ * C'est ce qui donne le `from`/`at`/`to` attendu par quiconque a déjà utilisé
14
+ * l'API de trace.moe.
15
+ */
16
+
17
+ import { colorLayoutDistance, similarityFromDistance } from "./descriptor.ts";
18
+ import type { FrameIndex, MediaRow } from "./store.ts";
19
+
20
+ /**
21
+ * Écart de distance toléré, autour de la meilleure trame, pour considérer
22
+ * qu'on est toujours dans la même scène. Mesuré sur des trames d'anime : à
23
+ * l'intérieur d'un plan l'écart reste sous 30, deux images sans rapport
24
+ * dépassent 34.
25
+ */
26
+ export const SCENE_TOLERANCE = 15;
27
+
28
+ /** Demi-largeur maximale d'une scène, en millisecondes. */
29
+ export const SCENE_MAX_SPAN_MS = 30_000;
30
+
31
+ /** Une scène trouvée dans l'index local. */
32
+ export interface SceneMatch {
33
+ mediaId: number;
34
+ source: string;
35
+ title: string;
36
+ season: number | null;
37
+ episode: number | null;
38
+ /** Début de la scène, en millisecondes. */
39
+ fromMs: number;
40
+ /** Position de la trame la plus proche, en millisecondes. */
41
+ atMs: number;
42
+ /** Fin de la scène, en millisecondes. */
43
+ toMs: number;
44
+ /** Durée du média, en millisecondes (0 si inconnue). */
45
+ durationMs: number;
46
+ distance: number;
47
+ /** Score 0..1 ; en dessous de 0,90 le résultat est probablement faux. */
48
+ similarity: number;
49
+ }
50
+
51
+ /** Réglages d'une recherche locale. */
52
+ export interface SearchOptions {
53
+ /** Nombre maximal de scènes rendues (défaut : 5). */
54
+ limit?: number;
55
+ /** Score minimal retenu (défaut : 0 — tout est rendu, à charge de juger). */
56
+ minSimilarity?: number;
57
+ /** Restreindre la recherche à un média. */
58
+ mediaId?: number;
59
+ }
60
+
61
+ interface Best {
62
+ tMs: number;
63
+ distance: number;
64
+ }
65
+
66
+ /**
67
+ * Compare la requête à toutes les trames indexées et rend les meilleures
68
+ * scènes, de la plus proche à la plus lointaine.
69
+ */
70
+ export function searchIndex(
71
+ index: FrameIndex,
72
+ query: ArrayLike<number>,
73
+ opts: SearchOptions = {},
74
+ ): SceneMatch[] {
75
+ const limit = opts.limit ?? 5;
76
+ const minSimilarity = opts.minSimilarity ?? 0;
77
+
78
+ // Une seule passe : la meilleure trame de chaque média.
79
+ const best = new Map<number, Best>();
80
+ for (const frame of index.iterateFrames(opts.mediaId)) {
81
+ const distance = colorLayoutDistance(query, frame.vector);
82
+ const current = best.get(frame.mediaId);
83
+ if (!current || distance < current.distance) {
84
+ best.set(frame.mediaId, { tMs: frame.tMs, distance });
85
+ }
86
+ }
87
+
88
+ const ranked = [...best.entries()]
89
+ .sort((a, b) => a[1].distance - b[1].distance)
90
+ .slice(0, limit);
91
+
92
+ const matches: SceneMatch[] = [];
93
+ for (const [mediaId, hit] of ranked) {
94
+ const similarity = similarityFromDistance(hit.distance);
95
+ if (similarity < minSimilarity) continue;
96
+ const media = index.getMedia(mediaId);
97
+ if (!media) continue;
98
+ const { fromMs, toMs } = expandScene(index, media, query, hit);
99
+ matches.push({
100
+ mediaId,
101
+ source: media.source,
102
+ title: media.title,
103
+ season: media.season ?? null,
104
+ episode: media.episode ?? null,
105
+ fromMs,
106
+ atMs: hit.tMs,
107
+ toMs,
108
+ durationMs: media.durationMs,
109
+ distance: hit.distance,
110
+ similarity,
111
+ });
112
+ }
113
+ return matches;
114
+ }
115
+
116
+ /**
117
+ * Étend la meilleure trame en scène : on s'éloigne de part et d'autre tant que
118
+ * les trames voisines restent proches de la requête *et* contiguës — un trou
119
+ * dans l'échantillonnage signale un plan différent, pas une scène plus longue.
120
+ */
121
+ function expandScene(
122
+ index: FrameIndex,
123
+ media: MediaRow,
124
+ query: ArrayLike<number>,
125
+ hit: Best,
126
+ ): { fromMs: number; toMs: number } {
127
+ const intervalMs = media.fps > 0 ? Math.round(1000 / media.fps) : 1000;
128
+ const maxGap = intervalMs * 1.5;
129
+ const threshold = hit.distance + SCENE_TOLERANCE;
130
+ const window = index.framesBetween(
131
+ media.id,
132
+ hit.tMs - SCENE_MAX_SPAN_MS,
133
+ hit.tMs + SCENE_MAX_SPAN_MS,
134
+ );
135
+ const pivot = window.findIndex((f) => f.tMs === hit.tMs);
136
+ if (pivot < 0) return { fromMs: hit.tMs, toMs: hit.tMs };
137
+
138
+ let fromMs = hit.tMs;
139
+ for (let i = pivot - 1; i >= 0; i--) {
140
+ const frame = window[i];
141
+ if (fromMs - frame.tMs > maxGap) break;
142
+ if (colorLayoutDistance(query, frame.vector) > threshold) break;
143
+ fromMs = frame.tMs;
144
+ }
145
+ let toMs = hit.tMs;
146
+ for (let i = pivot + 1; i < window.length; i++) {
147
+ const frame = window[i];
148
+ if (frame.tMs - toMs > maxGap) break;
149
+ if (colorLayoutDistance(query, frame.vector) > threshold) break;
150
+ toMs = frame.tMs;
151
+ }
152
+ return { fromMs, toMs };
153
+ }
154
+
155
+ /** `123456` → `2:03.456`, pour l'affichage d'un horodatage de scène. */
156
+ export function formatTimecode(ms: number): string {
157
+ const total = Math.max(0, Math.round(ms));
158
+ const h = Math.floor(total / 3_600_000);
159
+ const m = Math.floor((total % 3_600_000) / 60_000);
160
+ const s = Math.floor((total % 60_000) / 1000);
161
+ const milli = total % 1000;
162
+ const head = h > 0 ? `${h}:${String(m).padStart(2, "0")}` : String(m);
163
+ return `${head}:${String(s).padStart(2, "0")}.${String(milli).padStart(3, "0")}`;
164
+ }