@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/README.md +161 -0
- package/package.json +45 -0
- package/src/descriptor.ts +134 -0
- package/src/ffmpeg.ts +298 -0
- package/src/index.ts +346 -0
- package/src/search.ts +164 -0
- package/src/store.ts +259 -0
- package/src/trace-moe.ts +368 -0
package/README.md
ADDED
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
# @aphrody/frames
|
|
2
|
+
|
|
3
|
+
Retrouver **d'où vient une image** : quel épisode, quelle seconde. Index local
|
|
4
|
+
image par image, avec trace.moe en recours.
|
|
5
|
+
|
|
6
|
+
```bash
|
|
7
|
+
bxc frames index ~/videos/inazuma-s1e01.mp4 --season 1 --episode 1
|
|
8
|
+
bxc frames search capture.jpg
|
|
9
|
+
# 96.1% Inazuma Eleven S1E1 2:41.000 → 2:44.000
|
|
10
|
+
```
|
|
11
|
+
|
|
12
|
+
## Pourquoi un index local
|
|
13
|
+
|
|
14
|
+
Trois façons de répondre à la question existaient déjà. Mesurées sur Inazuma
|
|
15
|
+
Eleven le 3 septembre 2026 :
|
|
16
|
+
|
|
17
|
+
| | couverture Inazuma Eleven | quota | ce qui sort de la machine |
|
|
18
|
+
| --- | --- | --- | --- |
|
|
19
|
+
| [trace.moe](https://trace.moe) | partielle : 125 fichiers pour la série d'origine (AniList 5231), 47 pour GO, 51 Chrono Stone, 43 Galaxy, 25 Ares, 48 Orion — rien en VF | 100 recherches / 24 h, **1 requête à la fois** | l'image entière (ou 33 entiers, voir plus bas) |
|
|
20
|
+
| [fancaps.net](https://fancaps.net/anime/) | **aucune** — la lettre « I » du catalogue liste 95 séries, pas une seule Inazuma | pas d'API publique, seulement des scrapers tiers | l'URL consultée |
|
|
21
|
+
| index local (ce paquet) | ce qu'on lui donne — les 412 épisodes VF du catalogue IETV, par exemple | aucun | **rien** |
|
|
22
|
+
|
|
23
|
+
L'index public est excellent là où il est complet, et muet ailleurs : les VF,
|
|
24
|
+
les diffusions récentes, les films, les extraits — tout ce qu'il n'a jamais
|
|
25
|
+
indexé. Un index local coûte 32 Mo pour un catalogue entier et répond en une
|
|
26
|
+
demi-seconde ; il n'y a pas de raison de s'en priver.
|
|
27
|
+
|
|
28
|
+
### Ce que valent les deux, mesuré
|
|
29
|
+
|
|
30
|
+
- **Une vraie trame d'épisode → trace.moe la reconnaît parfaitement** :
|
|
31
|
+
similarité **0,9919** en envoyant l'image, **0,9920** en n'envoyant que le
|
|
32
|
+
descripteur, horodatage juste à 0,3 s près, 125 ms de latence.
|
|
33
|
+
- **Une vignette YouTube brandée ne marche pas** : sur 10 requêtes construites
|
|
34
|
+
à partir des vignettes officielles de la chaîne IETV (bandeau de saison,
|
|
35
|
+
drapeau, logo, numéro d'épisode), 4 seulement retrouvaient la bonne série et
|
|
36
|
+
**une seule** dépassait le seuil de 0,90. Recadrer pour retirer les
|
|
37
|
+
incrustations n'arrange rien : le descripteur est une grille 8×8 sur l'image
|
|
38
|
+
entière, donc rogner déplace tout. Il faut une capture plein cadre.
|
|
39
|
+
- **L'index local**, sur les mêmes images : 0,966 pour la trame correspondante,
|
|
40
|
+
et il trouve ce que l'index public n'a pas.
|
|
41
|
+
|
|
42
|
+
### Débits mesurés (VPS 2 cœurs, ffmpeg 8.0.1)
|
|
43
|
+
|
|
44
|
+
| | valeur |
|
|
45
|
+
| --- | --- |
|
|
46
|
+
| indexation d'un épisode de 24 min à 1 img/s | 10,7 s (**135× le temps réel**) |
|
|
47
|
+
| idem à 4 img/s | 9,4 s (154×, le décodage domine, pas l'extraction) |
|
|
48
|
+
| poids de l'index | 65 à 82 o par trame — **92 Ko** l'épisode à 1 img/s |
|
|
49
|
+
| catalogue complet simulé (412 épisodes, 593 280 trames) | **32,5 Mo** |
|
|
50
|
+
| recherche exhaustive dans ces 593 280 trames | **~500 ms** |
|
|
51
|
+
|
|
52
|
+
## Prérequis
|
|
53
|
+
|
|
54
|
+
`ffmpeg` et `ffprobe` dans le `PATH` (ou `FfmpegDeps.ffmpeg` / `.ffprobe`).
|
|
55
|
+
C'est la seule dépendance externe : le descripteur, la base et la recherche
|
|
56
|
+
sont en TypeScript pur.
|
|
57
|
+
|
|
58
|
+
## CLI
|
|
59
|
+
|
|
60
|
+
```bash
|
|
61
|
+
bxc frames index <video...> # indexe (ffmpeg décode en flux, rien sur disque)
|
|
62
|
+
bxc frames search <image> # local d'abord, trace.moe si le local ne sait pas
|
|
63
|
+
bxc frames vector <image> # les 33 coefficients, en base64 — partageable sans l'image
|
|
64
|
+
bxc frames list # médias indexés
|
|
65
|
+
bxc frames stats # taille et couverture de l'index
|
|
66
|
+
bxc frames quota # quota trace.moe de cette machine
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
Options utiles : `--fps` (trames indexées par seconde, défaut 1), `--db`
|
|
70
|
+
(emplacement de l'index, défaut `~/.cache/bxc/frames.db`, ou `BXC_FRAMES_DB`),
|
|
71
|
+
`--local` / `--remote`, `--at 12:34` pour prendre la trame d'une vidéo comme
|
|
72
|
+
requête, `--json`.
|
|
73
|
+
|
|
74
|
+
```bash
|
|
75
|
+
# indexer une saison entière
|
|
76
|
+
for f in ~/videos/inazuma-s1e*.mp4; do bxc frames index "$f" --season 1 --fps 2; done
|
|
77
|
+
|
|
78
|
+
# d'où vient cette capture ?
|
|
79
|
+
bxc frames search capture.png --limit 3
|
|
80
|
+
|
|
81
|
+
# à quelle seconde de l'épisode 12 se trouve ce plan ?
|
|
82
|
+
bxc frames search plan.jpg --local --limit 1
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
## API
|
|
86
|
+
|
|
87
|
+
```ts
|
|
88
|
+
import { FrameSearch } from "@aphrody/frames";
|
|
89
|
+
|
|
90
|
+
const frames = new FrameSearch({ indexPath: "~/.cache/bxc/frames.db" });
|
|
91
|
+
|
|
92
|
+
await frames.indexVideo("ep01.mkv", { fps: 2, title: "Inazuma Eleven", season: 1, episode: 1 });
|
|
93
|
+
|
|
94
|
+
const found = await frames.search("capture.jpg");
|
|
95
|
+
// { origin: "local" | "remote", matches: [{ title, episode, fromMs, atMs, toMs, similarity }] }
|
|
96
|
+
```
|
|
97
|
+
|
|
98
|
+
Briques séparées si la façade ne convient pas :
|
|
99
|
+
`@aphrody/frames/descriptor` (extraction, distance), `/extract` (ffmpeg),
|
|
100
|
+
`/store` (index SQLite), `/search` (recherche + regroupement en scènes),
|
|
101
|
+
`/trace-moe` (client de l'API publique).
|
|
102
|
+
|
|
103
|
+
## Similarité
|
|
104
|
+
|
|
105
|
+
Le score suit l'échelle de trace.moe : **au-dessous de 0,90, le résultat est
|
|
106
|
+
probablement faux**, quelle que soit sa place au classement. Il est dérivé de
|
|
107
|
+
la distance MPEG-7 par `1 − d / 100`, où 100 vient de mesures sur des trames
|
|
108
|
+
d'anime décodées par ce module :
|
|
109
|
+
|
|
110
|
+
| distance | ce que c'est |
|
|
111
|
+
| --- | --- |
|
|
112
|
+
| 0 – 10 | la même image (ré-encodée, redimensionnée) |
|
|
113
|
+
| 10 – 30 | le même plan, à une seconde près |
|
|
114
|
+
| > 34 | deux images sans rapport |
|
|
115
|
+
|
|
116
|
+
La taille de vignette n'influe pas : sur une trame d'épisode, le descripteur
|
|
117
|
+
est **identique** entre 64 et 512 pixels de côté (il ne garde que la moyenne de
|
|
118
|
+
64 blocs). D'où le décodage en 128×128 par défaut — cent fois moins de pixels
|
|
119
|
+
que la pleine résolution, pour le même vecteur.
|
|
120
|
+
|
|
121
|
+
## Confidentialité
|
|
122
|
+
|
|
123
|
+
C'est la raison d'être du chemin local. Chercher une image, c'est révéler ce
|
|
124
|
+
qu'on regarde ; un service tiers qui reçoit la capture apprend l'image *et*
|
|
125
|
+
l'intention.
|
|
126
|
+
|
|
127
|
+
- **Index local** : rien ne quitte la machine.
|
|
128
|
+
- **Recours distant** : `searchByVector` n'envoie que les **33 entiers** du
|
|
129
|
+
descripteur (28 caractères en base64), jamais l'image — et c'est aussi le
|
|
130
|
+
chemin le plus rapide, le serveur n'ayant rien à télécharger ni décoder. Même
|
|
131
|
+
précision mesurée : 0,9920 contre 0,9919 en téléversant le fichier.
|
|
132
|
+
- La clé d'API éventuelle part en en-tête `x-trace-key`, jamais dans l'URL.
|
|
133
|
+
- `bxc frames vector capture.jpg` donne de quoi faire chercher quelqu'un
|
|
134
|
+
d'autre à votre place, sans lui montrer l'image.
|
|
135
|
+
|
|
136
|
+
## Limites connues
|
|
137
|
+
|
|
138
|
+
- **Le descripteur est global** : une incrustation, un bandeau, un recadrage ou
|
|
139
|
+
une bordure changent le vecteur. Une capture plein cadre marche, une vignette
|
|
140
|
+
brandée non (mesuré plus haut). `cutBorders` côté trace.moe ne retire que les
|
|
141
|
+
bandes noires.
|
|
142
|
+
- **Deux plans quasi identiques** (un ciel, un fondu au noir, un écran blanc)
|
|
143
|
+
se ressemblent forcément : le score sera élevé et le résultat arbitraire.
|
|
144
|
+
- **La recherche est exhaustive** : linéaire en nombre de trames. Une demi-
|
|
145
|
+
seconde pour 600 000 trames ; au-delà de quelques millions, il faudra un
|
|
146
|
+
index approché.
|
|
147
|
+
- **L'échantillonnage borne la précision** : à 1 img/s, l'horodatage est juste
|
|
148
|
+
à la seconde. Monter à 4 img/s coûte 4× l'espace, pas le temps d'indexation.
|
|
149
|
+
- **trace.moe** : 100 recherches par 24 h sans clé, une seule à la fois (une
|
|
150
|
+
deuxième requête simultanée est refusée avec le même code HTTP qu'un quota
|
|
151
|
+
épuisé — le client distingue les deux avec le quota connu).
|
|
152
|
+
|
|
153
|
+
## Tests
|
|
154
|
+
|
|
155
|
+
```bash
|
|
156
|
+
bun test packages/frames # 61 cas, sans ffmpeg, sans réseau
|
|
157
|
+
```
|
|
158
|
+
|
|
159
|
+
Le processus ffmpeg et le `fetch` sont injectables : les tests vérifient les
|
|
160
|
+
arguments de commande, rejouent un flux `rawvideo` factice (y compris coupé au
|
|
161
|
+
milieu d'une trame) et pilotent l'horloge pour les reprises et les budgets.
|
package/package.json
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@aphrody/frames",
|
|
3
|
+
"version": "0.2.0",
|
|
4
|
+
"description": "Index et recherche image par image d'épisodes d'anime — descripteur MPEG-7 ColorLayout local, compatible trace.moe",
|
|
5
|
+
"publishConfig": {
|
|
6
|
+
"access": "public"
|
|
7
|
+
},
|
|
8
|
+
"type": "module",
|
|
9
|
+
"main": "./src/index.ts",
|
|
10
|
+
"types": "./src/index.ts",
|
|
11
|
+
"exports": {
|
|
12
|
+
".": "./src/index.ts",
|
|
13
|
+
"./descriptor": "./src/descriptor.ts",
|
|
14
|
+
"./extract": "./src/extract.ts",
|
|
15
|
+
"./store": "./src/store.ts",
|
|
16
|
+
"./search": "./src/search.ts",
|
|
17
|
+
"./trace-moe": "./src/trace-moe.ts"
|
|
18
|
+
},
|
|
19
|
+
"scripts": {
|
|
20
|
+
"typecheck": "tsc --noEmit",
|
|
21
|
+
"test": "bun test"
|
|
22
|
+
},
|
|
23
|
+
"keywords": [
|
|
24
|
+
"anime",
|
|
25
|
+
"frame",
|
|
26
|
+
"color-layout",
|
|
27
|
+
"mpeg-7",
|
|
28
|
+
"reverse-image-search",
|
|
29
|
+
"trace.moe"
|
|
30
|
+
],
|
|
31
|
+
"dependencies": {
|
|
32
|
+
"trace.moe-id": "^2.0.0"
|
|
33
|
+
},
|
|
34
|
+
"files": [
|
|
35
|
+
"src/",
|
|
36
|
+
"!src/**/*.test.ts",
|
|
37
|
+
"!src/**/*.spec.ts",
|
|
38
|
+
"README.md",
|
|
39
|
+
"LICENSE"
|
|
40
|
+
],
|
|
41
|
+
"engines": {
|
|
42
|
+
"bun": ">=1.3.14"
|
|
43
|
+
},
|
|
44
|
+
"license": "Apache-2.0"
|
|
45
|
+
}
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
// SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
/**
|
|
3
|
+
* Descripteur d'image : MPEG-7 ColorLayout, 33 coefficients.
|
|
4
|
+
*
|
|
5
|
+
* C'est le même descripteur que celui utilisé par trace.moe, et volontairement :
|
|
6
|
+
* un vecteur extrait ici s'interroge indifféremment sur l'index **local**
|
|
7
|
+
* (`store.ts` + `search.ts`) ou sur l'API distante (`trace-moe.ts`, paramètre
|
|
8
|
+
* `?vector=`). L'extraction et l'encodage viennent de `trace.moe-id` (MIT,
|
|
9
|
+
* sans dépendance) pour que les vecteurs restent bit-à-bit compatibles ; ce
|
|
10
|
+
* module n'ajoute que ce qui manque côté index :
|
|
11
|
+
*
|
|
12
|
+
* - {@link packVector} / {@link unpackVector} — 33 octets, la forme stockée en
|
|
13
|
+
* base (un coefficient tient sur 6 bits au plus, donc sur un octet).
|
|
14
|
+
* - {@link colorLayoutDistance} — la métrique MPEG-7 sur `Uint8Array`, sans
|
|
15
|
+
* allocation, pour balayer des centaines de milliers de trames.
|
|
16
|
+
* - {@link similarityFromDistance} — la conversion en score 0..1 comparable à
|
|
17
|
+
* celui que renvoie trace.moe (échelle calibrée, cf. {@link DISTANCE_SCALE}).
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
import { ColorLayout, type PixelData } from "trace.moe-id";
|
|
21
|
+
|
|
22
|
+
/** Nombre de coefficients d'un vecteur ColorLayout. */
|
|
23
|
+
export const CL_DIMS = 33;
|
|
24
|
+
|
|
25
|
+
/** Coefficients de luminance (1 DC + 20 AC), en tête du vecteur. */
|
|
26
|
+
export const CL_Y_COUNT = 21;
|
|
27
|
+
|
|
28
|
+
/** Coefficients par plan de chrominance (1 DC + 5 AC), Cb puis Cr. */
|
|
29
|
+
export const CL_C_COUNT = 6;
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Poids MPEG-7 de la distance : les trois premiers coefficients de chaque plan
|
|
33
|
+
* pèsent plus que les suivants (ils portent la structure globale de l'image).
|
|
34
|
+
*/
|
|
35
|
+
const W_Y = [2, 2, 2] as const;
|
|
36
|
+
const W_CB = [2, 1, 1] as const;
|
|
37
|
+
const W_CR = [4, 2, 2] as const;
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Distance au-delà de laquelle deux images n'ont plus rien à voir.
|
|
41
|
+
*
|
|
42
|
+
* Mesurée, pas devinée (cf. `README.md`, section « Similarité ») : sur des
|
|
43
|
+
* trames d'anime décodées par ce module, la même image ré-encodée reste sous
|
|
44
|
+
* 10, deux trames de la même scène tiennent sous 30, et deux images sans
|
|
45
|
+
* rapport se placent entre 34 et 79. L'échelle 100 fait donc tomber le seuil
|
|
46
|
+
* « probablement faux » au même endroit que celui de trace.moe (0,90).
|
|
47
|
+
*
|
|
48
|
+
* Ne sert qu'à afficher un score lisible : le classement des résultats, lui,
|
|
49
|
+
* ne dépend que de la distance.
|
|
50
|
+
*/
|
|
51
|
+
export const DISTANCE_SCALE = 100;
|
|
52
|
+
|
|
53
|
+
/** Un vecteur ColorLayout, tel que produit par {@link extractColorLayout}. */
|
|
54
|
+
export type ColorLayoutVector = number[];
|
|
55
|
+
|
|
56
|
+
/** Image décodée en pixels bruts, telle qu'attendue par l'extracteur. */
|
|
57
|
+
export type { PixelData };
|
|
58
|
+
|
|
59
|
+
/** Extrait le vecteur 33 coefficients d'une image décodée. */
|
|
60
|
+
export function extractColorLayout(image: PixelData): ColorLayoutVector {
|
|
61
|
+
return ColorLayout.extract(image);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** Encode un vecteur en chaîne base64 URL-safe (28 caractères), forme acceptée par api.trace.moe. */
|
|
65
|
+
export function encodeVector(vector: ColorLayoutVector): string {
|
|
66
|
+
return ColorLayout.encode(vector);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** Décode une chaîne base64 URL-safe en vecteur. */
|
|
70
|
+
export function decodeVector(hash: string): ColorLayoutVector {
|
|
71
|
+
return ColorLayout.decode(hash);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Forme stockée : 33 octets. Chaque coefficient est déjà quantifié sur 5 ou
|
|
76
|
+
* 6 bits par l'extracteur, un octet suffit donc et la distance se calcule
|
|
77
|
+
* directement sur le buffer lu en base, sans reconstruire de tableau.
|
|
78
|
+
*/
|
|
79
|
+
export function packVector(vector: ColorLayoutVector): Uint8Array {
|
|
80
|
+
if (vector.length !== CL_DIMS) {
|
|
81
|
+
throw new Error(`vecteur ColorLayout attendu de ${CL_DIMS} coefficients, reçu ${vector.length}`);
|
|
82
|
+
}
|
|
83
|
+
const out = new Uint8Array(CL_DIMS);
|
|
84
|
+
for (let i = 0; i < CL_DIMS; i++) {
|
|
85
|
+
const v = vector[i] ?? 0;
|
|
86
|
+
out[i] = v < 0 ? 0 : v > 255 ? 255 : v;
|
|
87
|
+
}
|
|
88
|
+
return out;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** Inverse de {@link packVector}. */
|
|
92
|
+
export function unpackVector(bytes: Uint8Array): ColorLayoutVector {
|
|
93
|
+
if (bytes.length !== CL_DIMS) {
|
|
94
|
+
throw new Error(`buffer de ${CL_DIMS} octets attendu, reçu ${bytes.length}`);
|
|
95
|
+
}
|
|
96
|
+
return Array.from(bytes);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Distance MPEG-7 entre deux vecteurs : somme des racines des écarts pondérés
|
|
101
|
+
* de chaque plan (Y, Cb, Cr). Accepte indifféremment les deux représentations
|
|
102
|
+
* pour qu'un vecteur fraîchement extrait se compare à une ligne de la base
|
|
103
|
+
* sans conversion intermédiaire.
|
|
104
|
+
*/
|
|
105
|
+
export function colorLayoutDistance(
|
|
106
|
+
a: ArrayLike<number>,
|
|
107
|
+
b: ArrayLike<number>,
|
|
108
|
+
): number {
|
|
109
|
+
let sumY = 0;
|
|
110
|
+
for (let i = 0; i < CL_Y_COUNT; i++) {
|
|
111
|
+
const d = a[i] - b[i];
|
|
112
|
+
sumY += (W_Y[i] ?? 1) * d * d;
|
|
113
|
+
}
|
|
114
|
+
let sumCb = 0;
|
|
115
|
+
let sumCr = 0;
|
|
116
|
+
for (let i = 0; i < CL_C_COUNT; i++) {
|
|
117
|
+
const cb = a[CL_Y_COUNT + i] - b[CL_Y_COUNT + i];
|
|
118
|
+
sumCb += (W_CB[i] ?? 1) * cb * cb;
|
|
119
|
+
const j = CL_Y_COUNT + CL_C_COUNT + i;
|
|
120
|
+
const cr = a[j] - b[j];
|
|
121
|
+
sumCr += (W_CR[i] ?? 1) * cr * cr;
|
|
122
|
+
}
|
|
123
|
+
return Math.sqrt(sumY) + Math.sqrt(sumCb) + Math.sqrt(sumCr);
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* Score 0..1 dérivé de la distance, sur la même échelle que la similarité
|
|
128
|
+
* renvoyée par trace.moe : au-dessous de 0,90 le résultat est probablement
|
|
129
|
+
* faux, quelle que soit sa place au classement.
|
|
130
|
+
*/
|
|
131
|
+
export function similarityFromDistance(distance: number): number {
|
|
132
|
+
const s = 1 - distance / DISTANCE_SCALE;
|
|
133
|
+
return s < 0 ? 0 : s > 1 ? 1 : s;
|
|
134
|
+
}
|
package/src/ffmpeg.ts
ADDED
|
@@ -0,0 +1,298 @@
|
|
|
1
|
+
// SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
/**
|
|
3
|
+
* Décodage : la seule dépendance externe du paquet est `ffmpeg`.
|
|
4
|
+
*
|
|
5
|
+
* Une vidéo n'est jamais chargée en mémoire ni recopiée sur disque : ffmpeg
|
|
6
|
+
* décode en flux, redimensionne chaque trame à une vignette carrée et l'écrit
|
|
7
|
+
* en `rawvideo` sur sa sortie standard, que {@link iterateFrames} découpe au
|
|
8
|
+
* fur et à mesure. Indexer un épisode coûte donc la taille d'**une** vignette
|
|
9
|
+
* en mémoire, quelle que soit la durée.
|
|
10
|
+
*
|
|
11
|
+
* La vignette est carrée à dessein : le descripteur ColorLayout découpe l'image
|
|
12
|
+
* en 8×8 blocs et n'en garde que la moyenne, donc écraser le rapport d'aspect
|
|
13
|
+
* vers un multiple de 8 revient exactement à moyenner les blocs de l'image
|
|
14
|
+
* d'origine — et coûte cent fois moins cher que de décoder en pleine
|
|
15
|
+
* résolution.
|
|
16
|
+
*
|
|
17
|
+
* Tout ce qui touche au processus passe par {@link FfmpegDeps.spawn} : les
|
|
18
|
+
* tests construisent et vérifient les arguments, et rejouent un flux factice,
|
|
19
|
+
* sans que ffmpeg soit installé.
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
import type { PixelData } from "./descriptor.ts";
|
|
23
|
+
|
|
24
|
+
/** Le strict minimum d'un processus, pour que les tests puissent en simuler un. */
|
|
25
|
+
export interface SpawnedProcess {
|
|
26
|
+
stdout: ReadableStream<Uint8Array> | null;
|
|
27
|
+
stderr?: ReadableStream<Uint8Array> | null;
|
|
28
|
+
exited: Promise<number>;
|
|
29
|
+
kill?(): void;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** Lance une commande et rend ses flux. */
|
|
33
|
+
export type Spawner = (cmd: readonly string[]) => SpawnedProcess;
|
|
34
|
+
|
|
35
|
+
/** Points d'injection : binaires et lanceur de processus. */
|
|
36
|
+
export interface FfmpegDeps {
|
|
37
|
+
/** Chemin du binaire ffmpeg (défaut : `ffmpeg` dans le `PATH`). */
|
|
38
|
+
ffmpeg?: string;
|
|
39
|
+
/** Chemin du binaire ffprobe (défaut : `ffprobe` dans le `PATH`). */
|
|
40
|
+
ffprobe?: string;
|
|
41
|
+
/** Lanceur de processus (défaut : `Bun.spawn`). */
|
|
42
|
+
spawn?: Spawner;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** Options d'échantillonnage d'une vidéo. */
|
|
46
|
+
export interface FrameStreamOptions {
|
|
47
|
+
/** Trames extraites par seconde de vidéo (défaut : 1). */
|
|
48
|
+
fps?: number;
|
|
49
|
+
/** Côté de la vignette carrée décodée, en pixels (défaut : 128). */
|
|
50
|
+
size?: number;
|
|
51
|
+
/** Début de la plage à décoder, en millisecondes. */
|
|
52
|
+
startMs?: number;
|
|
53
|
+
/** Fin de la plage à décoder, en millisecondes. */
|
|
54
|
+
endMs?: number;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** Une trame décodée et son horodatage dans la vidéo. */
|
|
58
|
+
export interface DecodedFrame {
|
|
59
|
+
/** Rang de la trame dans le flux échantillonné, à partir de 0. */
|
|
60
|
+
index: number;
|
|
61
|
+
/** Position dans la vidéo, en millisecondes. */
|
|
62
|
+
tMs: number;
|
|
63
|
+
/** Pixels RGB de la vignette. */
|
|
64
|
+
pixels: PixelData;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** Ce que `ffprobe` sait dire d'un média avant de le décoder. */
|
|
68
|
+
export interface MediaInfo {
|
|
69
|
+
durationMs: number;
|
|
70
|
+
width: number;
|
|
71
|
+
height: number;
|
|
72
|
+
/** Cadence d'origine, en images par seconde (0 si inconnue). */
|
|
73
|
+
fps: number;
|
|
74
|
+
codec: string;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
const DEFAULT_SIZE = 128;
|
|
78
|
+
const DEFAULT_FPS = 1;
|
|
79
|
+
|
|
80
|
+
function defaultSpawn(cmd: readonly string[]): SpawnedProcess {
|
|
81
|
+
return Bun.spawn([...cmd], { stdout: "pipe", stderr: "pipe", stdin: "ignore" });
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** Millisecondes → `HH:MM:SS.mmm`, la forme que ffmpeg accepte sans ambiguïté. */
|
|
85
|
+
export function timecode(ms: number): string {
|
|
86
|
+
const clamped = Math.max(0, Math.round(ms));
|
|
87
|
+
const h = Math.floor(clamped / 3_600_000);
|
|
88
|
+
const m = Math.floor((clamped % 3_600_000) / 60_000);
|
|
89
|
+
const s = Math.floor((clamped % 60_000) / 1000);
|
|
90
|
+
const milli = clamped % 1000;
|
|
91
|
+
return `${String(h).padStart(2, "0")}:${String(m).padStart(2, "0")}:${String(s).padStart(2, "0")}.${String(milli).padStart(3, "0")}`;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** Arguments de la commande d'échantillonnage — isolés pour être testables. */
|
|
95
|
+
export function buildFrameArgs(
|
|
96
|
+
source: string,
|
|
97
|
+
opts: FrameStreamOptions = {},
|
|
98
|
+
deps: FfmpegDeps = {},
|
|
99
|
+
): string[] {
|
|
100
|
+
const fps = opts.fps ?? DEFAULT_FPS;
|
|
101
|
+
const size = opts.size ?? DEFAULT_SIZE;
|
|
102
|
+
const args = [deps.ffmpeg ?? "ffmpeg", "-v", "error", "-nostdin"];
|
|
103
|
+
// `-ss` avant `-i` : ffmpeg saute directement à la position demandée au
|
|
104
|
+
// lieu de décoder tout ce qui précède.
|
|
105
|
+
if (opts.startMs) args.push("-ss", timecode(opts.startMs));
|
|
106
|
+
args.push("-i", source);
|
|
107
|
+
if (opts.endMs !== undefined) {
|
|
108
|
+
args.push("-t", timecode(opts.endMs - (opts.startMs ?? 0)));
|
|
109
|
+
}
|
|
110
|
+
args.push(
|
|
111
|
+
"-an",
|
|
112
|
+
"-sn",
|
|
113
|
+
"-vf",
|
|
114
|
+
`fps=${fps},scale=${size}:${size}:flags=area`,
|
|
115
|
+
"-pix_fmt",
|
|
116
|
+
"rgb24",
|
|
117
|
+
"-f",
|
|
118
|
+
"rawvideo",
|
|
119
|
+
"-",
|
|
120
|
+
);
|
|
121
|
+
return args;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/** Arguments de la commande d'inspection — isolés pour être testables. */
|
|
125
|
+
export function buildProbeArgs(source: string, deps: FfmpegDeps = {}): string[] {
|
|
126
|
+
return [
|
|
127
|
+
deps.ffprobe ?? "ffprobe",
|
|
128
|
+
"-v",
|
|
129
|
+
"error",
|
|
130
|
+
"-select_streams",
|
|
131
|
+
"v:0",
|
|
132
|
+
"-show_entries",
|
|
133
|
+
"stream=width,height,r_frame_rate,codec_name:format=duration",
|
|
134
|
+
"-of",
|
|
135
|
+
"json",
|
|
136
|
+
source,
|
|
137
|
+
];
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/** Lit la sortie JSON de ffprobe. Tolère les champs absents : un flux peut tout ignorer sauf sa taille. */
|
|
141
|
+
export function parseProbe(json: string): MediaInfo {
|
|
142
|
+
const parsed = JSON.parse(json) as {
|
|
143
|
+
streams?: Array<{
|
|
144
|
+
width?: number;
|
|
145
|
+
height?: number;
|
|
146
|
+
r_frame_rate?: string;
|
|
147
|
+
codec_name?: string;
|
|
148
|
+
}>;
|
|
149
|
+
format?: { duration?: string };
|
|
150
|
+
};
|
|
151
|
+
const stream = parsed.streams?.[0] ?? {};
|
|
152
|
+
const [num, den] = (stream.r_frame_rate ?? "0/1").split("/");
|
|
153
|
+
const denominator = Number(den) || 1;
|
|
154
|
+
const duration = Number(parsed.format?.duration ?? 0);
|
|
155
|
+
return {
|
|
156
|
+
durationMs: Number.isFinite(duration) ? Math.round(duration * 1000) : 0,
|
|
157
|
+
width: stream.width ?? 0,
|
|
158
|
+
height: stream.height ?? 0,
|
|
159
|
+
fps: Number(num) / denominator || 0,
|
|
160
|
+
codec: stream.codec_name ?? "",
|
|
161
|
+
};
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
async function drain(stream: ReadableStream<Uint8Array> | null | undefined): Promise<string> {
|
|
165
|
+
if (!stream) return "";
|
|
166
|
+
return await new Response(stream).text();
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/** Inspecte un média (durée, dimensions, cadence) sans le décoder. */
|
|
170
|
+
export async function probeMedia(source: string, deps: FfmpegDeps = {}): Promise<MediaInfo> {
|
|
171
|
+
const spawn = deps.spawn ?? defaultSpawn;
|
|
172
|
+
const proc = spawn(buildProbeArgs(source, deps));
|
|
173
|
+
const [out, err, code] = await Promise.all([
|
|
174
|
+
drain(proc.stdout),
|
|
175
|
+
drain(proc.stderr),
|
|
176
|
+
proc.exited,
|
|
177
|
+
]);
|
|
178
|
+
if (code !== 0) {
|
|
179
|
+
throw new Error(`ffprobe a échoué (${code}) sur ${source}: ${err.trim() || "sans message"}`);
|
|
180
|
+
}
|
|
181
|
+
return parseProbe(out);
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/**
|
|
185
|
+
* Échantillonne une vidéo et rend ses trames une par une.
|
|
186
|
+
*
|
|
187
|
+
* Le flux `rawvideo` n'a ni en-tête ni séparateur : chaque trame occupe
|
|
188
|
+
* exactement `size × size × 3` octets, et son horodatage se déduit de son rang
|
|
189
|
+
* puisque le filtre `fps` produit une cadence constante.
|
|
190
|
+
*/
|
|
191
|
+
export async function* iterateFrames(
|
|
192
|
+
source: string,
|
|
193
|
+
opts: FrameStreamOptions = {},
|
|
194
|
+
deps: FfmpegDeps = {},
|
|
195
|
+
): AsyncGenerator<DecodedFrame> {
|
|
196
|
+
const fps = opts.fps ?? DEFAULT_FPS;
|
|
197
|
+
const size = opts.size ?? DEFAULT_SIZE;
|
|
198
|
+
const startMs = opts.startMs ?? 0;
|
|
199
|
+
const frameBytes = size * size * 3;
|
|
200
|
+
const spawn = deps.spawn ?? defaultSpawn;
|
|
201
|
+
const proc = spawn(buildFrameArgs(source, opts, deps));
|
|
202
|
+
if (!proc.stdout) throw new Error("ffmpeg n'a pas ouvert de sortie standard");
|
|
203
|
+
|
|
204
|
+
const errPromise = drain(proc.stderr);
|
|
205
|
+
const reader = proc.stdout.getReader();
|
|
206
|
+
let pending = new Uint8Array(0);
|
|
207
|
+
let index = 0;
|
|
208
|
+
try {
|
|
209
|
+
for (;;) {
|
|
210
|
+
const { done, value } = await reader.read();
|
|
211
|
+
if (value && value.length) {
|
|
212
|
+
const merged = new Uint8Array(pending.length + value.length);
|
|
213
|
+
merged.set(pending);
|
|
214
|
+
merged.set(value, pending.length);
|
|
215
|
+
pending = merged;
|
|
216
|
+
let offset = 0;
|
|
217
|
+
while (pending.length - offset >= frameBytes) {
|
|
218
|
+
const data = pending.subarray(offset, offset + frameBytes);
|
|
219
|
+
offset += frameBytes;
|
|
220
|
+
yield {
|
|
221
|
+
index,
|
|
222
|
+
tMs: startMs + Math.round((index * 1000) / fps),
|
|
223
|
+
pixels: { data, width: size, height: size, channels: 3 },
|
|
224
|
+
};
|
|
225
|
+
index++;
|
|
226
|
+
}
|
|
227
|
+
pending = offset ? pending.slice(offset) : pending;
|
|
228
|
+
}
|
|
229
|
+
if (done) break;
|
|
230
|
+
}
|
|
231
|
+
} finally {
|
|
232
|
+
reader.releaseLock();
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
const code = await proc.exited;
|
|
236
|
+
if (code !== 0) {
|
|
237
|
+
const err = await errPromise;
|
|
238
|
+
throw new Error(`ffmpeg a échoué (${code}) sur ${source}: ${err.trim() || "sans message"}`);
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
/** Arguments du décodage d'une image unique — isolés pour être testables. */
|
|
243
|
+
export function buildStillArgs(
|
|
244
|
+
source: string,
|
|
245
|
+
opts: { atMs?: number; size?: number } = {},
|
|
246
|
+
deps: FfmpegDeps = {},
|
|
247
|
+
): string[] {
|
|
248
|
+
const size = opts.size ?? DEFAULT_SIZE;
|
|
249
|
+
const args = [deps.ffmpeg ?? "ffmpeg", "-v", "error", "-nostdin"];
|
|
250
|
+
if (opts.atMs) args.push("-ss", timecode(opts.atMs));
|
|
251
|
+
args.push(
|
|
252
|
+
"-i",
|
|
253
|
+
source,
|
|
254
|
+
"-an",
|
|
255
|
+
"-sn",
|
|
256
|
+
"-vf",
|
|
257
|
+
`scale=${size}:${size}:flags=area`,
|
|
258
|
+
"-frames:v",
|
|
259
|
+
"1",
|
|
260
|
+
"-pix_fmt",
|
|
261
|
+
"rgb24",
|
|
262
|
+
"-f",
|
|
263
|
+
"rawvideo",
|
|
264
|
+
"-",
|
|
265
|
+
);
|
|
266
|
+
return args;
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
/**
|
|
270
|
+
* Décode une seule image : un fichier JPEG/PNG, ou la trame d'une vidéo à la
|
|
271
|
+
* position `atMs`. C'est le chemin d'une requête de recherche.
|
|
272
|
+
*
|
|
273
|
+
* Une image fixe n'a pas de durée : le filtre `fps` d'{@link iterateFrames} ne
|
|
274
|
+
* produirait rien sur un JPEG. D'où une commande distincte, bornée par
|
|
275
|
+
* `-frames:v 1`.
|
|
276
|
+
*/
|
|
277
|
+
export async function decodeStill(
|
|
278
|
+
source: string,
|
|
279
|
+
opts: { atMs?: number; size?: number } = {},
|
|
280
|
+
deps: FfmpegDeps = {},
|
|
281
|
+
): Promise<PixelData> {
|
|
282
|
+
const size = opts.size ?? DEFAULT_SIZE;
|
|
283
|
+
const frameBytes = size * size * 3;
|
|
284
|
+
const spawn = deps.spawn ?? defaultSpawn;
|
|
285
|
+
const proc = spawn(buildStillArgs(source, opts, deps));
|
|
286
|
+
if (!proc.stdout) throw new Error("ffmpeg n'a pas ouvert de sortie standard");
|
|
287
|
+
const errPromise = drain(proc.stderr);
|
|
288
|
+
const raw = new Uint8Array(await new Response(proc.stdout).arrayBuffer());
|
|
289
|
+
const code = await proc.exited;
|
|
290
|
+
if (code !== 0) {
|
|
291
|
+
const err = await errPromise;
|
|
292
|
+
throw new Error(`ffmpeg a échoué (${code}) sur ${source}: ${err.trim() || "sans message"}`);
|
|
293
|
+
}
|
|
294
|
+
if (raw.length < frameBytes) {
|
|
295
|
+
throw new Error(`aucune image décodable dans ${source}`);
|
|
296
|
+
}
|
|
297
|
+
return { data: raw.subarray(0, frameBytes), width: size, height: size, channels: 3 };
|
|
298
|
+
}
|