@ignix-social/editor 0.1.2 → 0.1.5

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.
@@ -101,12 +101,11 @@ Decide the zones once per video (e.g. header y 270–440, stage y 460–1300, ca
101
101
  - Blurred background of the same footage: a second set of the same `<video>` elements behind, in a wrapper with `filter: blur(60px) brightness(.32)` and `transform: scale(1.25)`. (Heavy without a GPU: on a machine without one, prefer a pre-blurred copy made with ffmpeg.)
102
102
  - Long sources (lives, podcasts) are fine: the editor seeks straight to `data-media-start`.
103
103
 
104
- ### The bundled ffmpeg (LGPL)
104
+ ### The bundled ffmpeg
105
105
 
106
- `ignix-editor ffmpeg` is a licence-clean build: everything you need for audio and simple video prep, but no GPL parts.
106
+ `ignix-editor ffmpeg` is a full build for video prep (GPL, without libx264).
107
107
  - Encode H.264 with `-c:v h264_videotoolbox -b:v 10M -allow_sw 1` (there is no `libx264` and no `-crf`). AAC audio: `-c:a aac -b:a 192k`.
108
- - Present: `scale`, `crop`, `pad`, `overlay`, `trim`/`atrim`, `concat`, `gblur`, `avgblur`, `curves`, `lutyuv`, `colorbalance`, `colorchannelmixer`, `fade`, `afade`, `highpass`, `lowpass`, `acompressor`, `loudnorm`, `ebur128`, `silencedetect`, `amix`, `volume`.
109
- - Missing (GPL): `eq` (use `curves`/`lutyuv` for contrast and warmth), `boxblur` (use `gblur`), `delogo` (cover a burned-in label with a cropped, `gblur`red patch of the same frame placed by `overlay`, or with a card in the HTML).
108
+ - Filters include `scale`, `crop`, `pad`, `overlay`, `trim`/`atrim`, `concat`, `eq` (contrast, brightness, saturation), `curves`, `lutyuv`, `colorbalance`, `boxblur`, `gblur`, `delogo` (hide a burned-in label: `delogo=x=..:y=..:w=..:h=..`), `hqdn3d` (denoise), `fade`, `afade`, `highpass`, `lowpass`, `acompressor`, `loudnorm`, `ebur128`, `silencedetect`, `amix`, `volume`.
110
109
  - Grading and blur are usually easier in the HTML anyway (CSS `filter: contrast() saturate() blur()`).
111
110
 
112
111
  ## 4. Audio and captions for reels from scratch
@@ -0,0 +1,142 @@
1
+ // CLIP ViT-B/32 (MIT; el mismo modelo que usaba Creator) en onnxruntime: imágenes y frases en
2
+ // el mismo espacio, para elegir el plano que enseña lo que dice la voz. Coseno imagen↔texto:
3
+ // ≈0,18 sin relación, ≈0,3 cuando la imagen lo muestra.
4
+ import { readFileSync } from 'node:fs';
5
+ import { resizeBicubic, toCHW } from './image.mjs';
6
+ import { ensureModel, loadOrt, session } from './runtime.mjs';
7
+
8
+ const MEAN = [0.48145466, 0.4578275, 0.40821073];
9
+ const STD = [0.26862954, 0.26130258, 0.27577711];
10
+ const SOT = 49406;
11
+ const EOT = 49407;
12
+ const MAX_TOKENS = 77;
13
+
14
+ // ── Tokenizador BPE de CLIP (bytes → unicode, fusiones por rango, «</w>» al final de palabra)
15
+ let tok = null;
16
+ async function tokenizer(log) {
17
+ if (tok) return tok;
18
+ const vocab = JSON.parse(readFileSync(await ensureModel('clip_vocab', log), 'utf8'));
19
+ const lines = readFileSync(await ensureModel('clip_merges', log), 'utf8').split('\n');
20
+ const ranks = new Map();
21
+ lines.slice(1).forEach((l, i) => {
22
+ if (l.trim()) ranks.set(l.trim(), i);
23
+ });
24
+ const byteMap = bytesToUnicode();
25
+ const cache = new Map();
26
+ const bpe = (token) => {
27
+ if (cache.has(token)) return cache.get(token);
28
+ let word = [...token.slice(0, -1), token.slice(-1) + '</w>'];
29
+ if (token.length === 1) word = [token + '</w>'];
30
+ for (;;) {
31
+ let best = null;
32
+ let bestRank = Infinity;
33
+ for (let i = 0; i < word.length - 1; i++) {
34
+ const r = ranks.get(`${word[i]} ${word[i + 1]}`);
35
+ if (r !== undefined && r < bestRank) {
36
+ bestRank = r;
37
+ best = i;
38
+ }
39
+ }
40
+ if (best === null) break;
41
+ const a = word[best];
42
+ const b = word[best + 1];
43
+ const merged = [];
44
+ for (let i = 0; i < word.length; i++) {
45
+ if (i < word.length - 1 && word[i] === a && word[i + 1] === b) {
46
+ merged.push(a + b);
47
+ i++;
48
+ } else merged.push(word[i]);
49
+ }
50
+ word = merged;
51
+ }
52
+ cache.set(token, word);
53
+ return word;
54
+ };
55
+ const PAT = /<\|startoftext\|>|<\|endoftext\|>|'s|'t|'re|'ve|'m|'ll|'d|[\p{L}]+|[\p{N}]|[^\s\p{L}\p{N}]+/gu;
56
+ tok = (text) => {
57
+ const clean = text.normalize('NFC').replace(/\s+/g, ' ').trim().toLowerCase();
58
+ const ids = [SOT];
59
+ for (const m of clean.matchAll(PAT)) {
60
+ const mapped = [...Buffer.from(m[0], 'utf8')].map((b) => byteMap[b]).join('');
61
+ for (const piece of bpe(mapped)) {
62
+ const id = vocab[piece];
63
+ if (id !== undefined) ids.push(id);
64
+ }
65
+ }
66
+ return [...ids.slice(0, MAX_TOKENS - 1), EOT];
67
+ };
68
+ return tok;
69
+ }
70
+
71
+ function bytesToUnicode() {
72
+ const bs = [];
73
+ for (let b = 33; b <= 126; b++) bs.push(b);
74
+ for (let b = 161; b <= 172; b++) bs.push(b);
75
+ for (let b = 174; b <= 255; b++) bs.push(b);
76
+ const cs = [...bs];
77
+ let n = 0;
78
+ for (let b = 0; b < 256; b++) {
79
+ if (!bs.includes(b)) {
80
+ bs.push(b);
81
+ cs.push(256 + n);
82
+ n++;
83
+ }
84
+ }
85
+ const map = {};
86
+ bs.forEach((b, i) => (map[b] = String.fromCodePoint(cs[i])));
87
+ return map;
88
+ }
89
+
90
+ const unit = (v) => {
91
+ let s = 0;
92
+ for (const x of v) s += x * x;
93
+ const n = Math.sqrt(s) || 1;
94
+ return Float32Array.from(v, (x) => x / n);
95
+ };
96
+
97
+ /** Vectores unitarios (512) de cada frase. */
98
+ export async function embedTexts(texts, log) {
99
+ const t = await tokenizer(log);
100
+ const s = await session('clip_text', log);
101
+ const { Tensor } = await loadOrt(log);
102
+ const out = [];
103
+ for (const text of texts) {
104
+ const ids = t(text);
105
+ const res = await s.run({ input_ids: new Tensor('int64', BigInt64Array.from(ids.map(BigInt)), [1, ids.length]) });
106
+ out.push(unit(res.text_embeds.data));
107
+ }
108
+ return out;
109
+ }
110
+
111
+ /** Vector unitario de una imagen (RGB intercalado w×h), preparada como CLIP. */
112
+ export async function embedImage(frame, log) {
113
+ const s = await session('clip_vision', log);
114
+ const { Tensor } = await loadOrt(log);
115
+ // Lado corto a 224 (bicúbico, como PIL) y recorte central de 224×224.
116
+ const k = 224 / Math.min(frame.w, frame.h);
117
+ const W = Math.max(224, Math.round(frame.w * k));
118
+ const H = Math.max(224, Math.round(frame.h * k));
119
+ const big = resizeBicubic(frame.rgb, frame.w, frame.h, W, H);
120
+ const x0 = Math.floor((W - 224) / 2);
121
+ const y0 = Math.floor((H - 224) / 2);
122
+ const crop = new Uint8Array(224 * 224 * 3);
123
+ for (let y = 0; y < 224; y++) crop.set(big.subarray(((y0 + y) * W + x0) * 3, ((y0 + y) * W + x0 + 224) * 3), y * 224 * 3);
124
+ const input = new Tensor('float32', toCHW(crop, 224, 224, { scale: 1 / 255, mean: MEAN, std: STD }), [1, 3, 224, 224]);
125
+ const res = await s.run({ pixel_values: input });
126
+ return unit(res.image_embeds.data);
127
+ }
128
+
129
+ export const dot = (a, b) => {
130
+ let s = 0;
131
+ for (let i = 0; i < a.length; i++) s += a[i] * b[i];
132
+ return s;
133
+ };
134
+
135
+ /** Media de varios vectores, unitaria (un plano = sus fotogramas juntos). */
136
+ export function pool(vecs) {
137
+ const out = new Float32Array(vecs[0].length);
138
+ for (const v of vecs) for (let i = 0; i < v.length; i++) out[i] += v[i];
139
+ return unit(out);
140
+ }
141
+
142
+ export { tokenizer as clipTokenizer };
@@ -0,0 +1,185 @@
1
+ // Detectores sobre onnxruntime, iguales en Mac y en Windows:
2
+ // · personas por el CUERPO (YOLOX-S, Apache-2.0): alguien que mira abajo o de espaldas sigue
3
+ // siendo alguien. El mismo detector que usa el trabajador del recast en la nube, para que los
4
+ // dos lados cuenten las mismas personas.
5
+ // · caras (YuNet, MIT) con sus cinco puntos (ojos, nariz, boca): sirven para encuadrar y para
6
+ // saber si una cara se ve de FRENTE (el parecido de un recast depende de eso).
7
+ // · texto quemado (detector de PP-OCRv3, Apache-2.0): letras sobre la persona salen
8
+ // garabateadas en un recast.
9
+ // Todas las cajas salen normalizadas (0..1) respecto al fotograma.
10
+ import { letterbox, nms, resize, toCHW } from './image.mjs';
11
+ import { session } from './runtime.mjs';
12
+
13
+ const S = 640;
14
+
15
+ // ── YOLOX
16
+ const ANCHORS = (() => {
17
+ const g = [];
18
+ for (const st of [8, 16, 32]) {
19
+ const n = S / st;
20
+ for (let y = 0; y < n; y++) for (let x = 0; x < n; x++) g.push([x, y, st]);
21
+ }
22
+ return g;
23
+ })();
24
+ export const PERSON_MIN_SCORE = 0.4;
25
+
26
+ /** Salida cruda de YOLOX (8400 × 85) → cajas de persona en píxeles del fotograma. */
27
+ export function decodeYolox(out, ratio, width, height, minScore = PERSON_MIN_SCORE) {
28
+ const found = [];
29
+ for (let i = 0; i < ANCHORS.length; i++) {
30
+ const o = i * 85;
31
+ const score = out[o + 4] * out[o + 5]; // un objeto, y ese objeto es una persona
32
+ if (score < minScore) continue;
33
+ const [gx, gy, st] = ANCHORS[i];
34
+ const cx = (out[o] + gx) * st;
35
+ const cy = (out[o + 1] + gy) * st;
36
+ const w = Math.exp(out[o + 2]) * st;
37
+ const h = Math.exp(out[o + 3]) * st;
38
+ const x0 = Math.max(0, (cx - w / 2) / ratio);
39
+ const y0 = Math.max(0, (cy - h / 2) / ratio);
40
+ const x1 = Math.min(width, (cx + w / 2) / ratio);
41
+ const y1 = Math.min(height, (cy + h / 2) / ratio);
42
+ if (x1 > x0 && y1 > y0) found.push({ x: x0, y: y0, w: x1 - x0, h: y1 - y0, score });
43
+ }
44
+ return nms(found, 0.5);
45
+ }
46
+
47
+ export async function people(frame, log) {
48
+ const s = await session('yolox', log);
49
+ const { rgb, r } = letterbox(frame.rgb, frame.w, frame.h, S, 114);
50
+ const input = new (await tensorClass(log))('float32', toCHW(rgb, S, S), [1, 3, S, S]);
51
+ const res = await s.run({ [s.inputNames[0]]: input });
52
+ const out = res[s.outputNames[0]].data;
53
+ return decodeYolox(out, r, frame.w, frame.h).map((b) => norm(b, frame));
54
+ }
55
+
56
+ // ── YuNet
57
+ export const FACE_MIN_SCORE = 0.7;
58
+
59
+ /** Salidas de YuNet → caras {x,y,w,h,score,kps:[[x,y]×5]} en píxeles del fotograma. */
60
+ export function decodeYunet(out, ratio, width, height, minScore = FACE_MIN_SCORE) {
61
+ const found = [];
62
+ for (const st of [8, 16, 32]) {
63
+ const cls = out[`cls_${st}`];
64
+ const obj = out[`obj_${st}`];
65
+ const box = out[`bbox_${st}`];
66
+ const kps = out[`kps_${st}`];
67
+ const cols = S / st;
68
+ for (let i = 0; i < cls.length; i++) {
69
+ const score = Math.sqrt(Math.min(1, Math.max(0, cls[i])) * Math.min(1, Math.max(0, obj[i])));
70
+ if (score < minScore) continue;
71
+ const c = i % cols;
72
+ const r = Math.floor(i / cols);
73
+ const cx = (c + box[i * 4]) * st;
74
+ const cy = (r + box[i * 4 + 1]) * st;
75
+ const w = Math.exp(box[i * 4 + 2]) * st;
76
+ const h = Math.exp(box[i * 4 + 3]) * st;
77
+ const pts = [];
78
+ for (let k = 0; k < 5; k++) pts.push([((kps[i * 10 + 2 * k] + c) * st) / ratio, ((kps[i * 10 + 2 * k + 1] + r) * st) / ratio]);
79
+ const x0 = Math.max(0, (cx - w / 2) / ratio);
80
+ const y0 = Math.max(0, (cy - h / 2) / ratio);
81
+ const x1 = Math.min(width, (cx + w / 2) / ratio);
82
+ const y1 = Math.min(height, (cy + h / 2) / ratio);
83
+ if (x1 > x0 && y1 > y0) found.push({ x: x0, y: y0, w: x1 - x0, h: y1 - y0, score, kps: pts });
84
+ }
85
+ }
86
+ return nms(found, 0.3);
87
+ }
88
+
89
+ /**
90
+ * ¿Se ve la cara de frente? Con los cinco puntos de YuNet: la nariz cae entre los dos ojos y a
91
+ * la misma distancia de cada uno, y los ojos no están demasiado juntos para el tamaño de la cara
92
+ * (de perfil se juntan). Umbrales medidos con los clips del recast (ADR-0023 E1).
93
+ */
94
+ export function isFrontal(face, { maxSkew = FRONTAL_MAX_SKEW, minEyes = FRONTAL_MIN_EYES } = {}) {
95
+ const [re, le, nose] = face.kps;
96
+ const eyes = Math.hypot(le[0] - re[0], le[1] - re[1]);
97
+ if (eyes < minEyes * face.w) return false;
98
+ const dl = Math.hypot(nose[0] - le[0], nose[1] - le[1]);
99
+ const dr = Math.hypot(nose[0] - re[0], nose[1] - re[1]);
100
+ return Math.abs(dl - dr) / (dl + dr) <= maxSkew;
101
+ }
102
+ // Medido (2026-10-06) con seis clips del recast: buenos 83–94 % de muestras de frente,
103
+ // malos 14–37 %; con los umbrales de Creator (≥50 %, ≥110 px) separan igual que el Haar de antes.
104
+ export const FRONTAL_MAX_SKEW = 0.2;
105
+ export const FRONTAL_MIN_EYES = 0.3;
106
+
107
+ export async function faces(frame, log) {
108
+ const s = await session('yunet', log);
109
+ // YuNet espera BGR en 0..255 (como lo prepara OpenCV), en un cuadrado de 640.
110
+ const { rgb, r } = letterbox(frame.rgb, frame.w, frame.h, S, 0);
111
+ const input = new (await tensorClass(log))('float32', toCHW(rgb, S, S, { bgr: true }), [1, 3, S, S]);
112
+ const res = await s.run({ [s.inputNames[0]]: input });
113
+ const out = Object.fromEntries(s.outputNames.map((n) => [n, res[n].data]));
114
+ return decodeYunet(out, r, frame.w, frame.h).map((f) => ({
115
+ ...norm(f, frame),
116
+ frontal: isFrontal(f),
117
+ kps: f.kps.map(([x, y]) => [x / frame.w, y / frame.h]),
118
+ // Tamaño de la cara en píxeles del ORIGINAL, no del fotograma de trabajo.
119
+ px: f.h * (frame.scale ?? 1),
120
+ }));
121
+ }
122
+
123
+ // ── PP-OCR (detector de texto DB)
124
+ const OCR_MEAN = [123.675, 116.28, 103.53];
125
+ const OCR_STD = [0.229, 0.224, 0.225];
126
+
127
+ /** Mapa de probabilidad del detector DB → cajas de texto en coordenadas del mapa. */
128
+ export function decodeDb(prob, W, H, { bin = 0.3, minScore = 0.5, unclip = 2.0, max = 200 } = {}) {
129
+ const seen = new Uint8Array(W * H);
130
+ const boxes = [];
131
+ const stack = [];
132
+ for (let start = 0; start < W * H && boxes.length < max; start++) {
133
+ if (seen[start] || prob[start] < bin) continue;
134
+ // Componente conexa (4 vecinos) de píxeles sobre el umbral.
135
+ let x0 = W, y0 = H, x1 = 0, y1 = 0, sum = 0, n = 0;
136
+ stack.push(start);
137
+ seen[start] = 1;
138
+ while (stack.length) {
139
+ const p = stack.pop();
140
+ const x = p % W;
141
+ const y = (p - x) / W;
142
+ sum += prob[p];
143
+ n += 1;
144
+ if (x < x0) x0 = x;
145
+ if (x > x1) x1 = x;
146
+ if (y < y0) y0 = y;
147
+ if (y > y1) y1 = y;
148
+ for (const q of [x > 0 ? p - 1 : -1, x < W - 1 ? p + 1 : -1, y > 0 ? p - W : -1, y < H - 1 ? p + W : -1]) {
149
+ if (q >= 0 && !seen[q] && prob[q] >= bin) {
150
+ seen[q] = 1;
151
+ stack.push(q);
152
+ }
153
+ }
154
+ }
155
+ const w = x1 - x0 + 1;
156
+ const h = y1 - y0 + 1;
157
+ if (Math.min(w, h) < 3 || sum / n < minScore) continue;
158
+ // El detector DB «encoge» el texto: se agranda la caja en área·ratio/perímetro.
159
+ const d = (w * h * unclip) / (2 * (w + h));
160
+ boxes.push({ x: Math.max(0, x0 - d), y: Math.max(0, y0 - d), w: Math.min(W, x1 + 1 + d) - Math.max(0, x0 - d), h: Math.min(H, y1 + 1 + d) - Math.max(0, y0 - d), score: sum / n });
161
+ }
162
+ return boxes;
163
+ }
164
+
165
+ export async function text(frame, log) {
166
+ const s = await session('ppocr', log);
167
+ const [W, H] = frame.h > frame.w ? [416, 736] : [736, 416];
168
+ const small = resize(frame.rgb, frame.w, frame.h, W, H);
169
+ // Como lo prepara OpenCV para este modelo: canales en orden BGR, medias y escalas por posición.
170
+ const input = new (await tensorClass(log))('float32', toCHW(small, W, H, { bgr: true, mean: OCR_MEAN, std: OCR_STD.map((v) => v * 255) }), [1, 3, H, W]);
171
+ const res = await s.run({ [s.inputNames[0]]: input });
172
+ const prob = res[s.outputNames[0]].data;
173
+ return decodeDb(prob, W, H)
174
+ .slice(0, 40)
175
+ .map((b) => ({ x: b.x / W, y: b.y / H, w: Math.min(1, b.w / W), h: Math.min(1, b.h / H) }));
176
+ }
177
+
178
+ // ── utilidades
179
+ const norm = (b, frame) => ({ x: b.x / frame.w, y: b.y / frame.h, w: b.w / frame.w, h: b.h / frame.h, score: +b.score.toFixed(3) });
180
+
181
+ let Tensor = null;
182
+ async function tensorClass(log) {
183
+ if (!Tensor) Tensor = (await import('./runtime.mjs').then((m) => m.loadOrt(log))).Tensor;
184
+ return Tensor;
185
+ }
@@ -0,0 +1,123 @@
1
+ // Operaciones de imagen en RGB (uint8, intercalado) para preparar la entrada de los modelos.
2
+
3
+ /** Reescalado bilineal de un RGB intercalado. */
4
+ export function resize(rgb, w, h, W, H) {
5
+ const out = new Uint8Array(W * H * 3);
6
+ const sx = w / W;
7
+ const sy = h / H;
8
+ for (let y = 0; y < H; y++) {
9
+ const fy = Math.max(0, (y + 0.5) * sy - 0.5);
10
+ const y0 = Math.min(h - 1, Math.floor(fy));
11
+ const y1 = Math.min(h - 1, y0 + 1);
12
+ const dy = fy - y0;
13
+ for (let x = 0; x < W; x++) {
14
+ const fx = Math.max(0, (x + 0.5) * sx - 0.5);
15
+ const x0 = Math.min(w - 1, Math.floor(fx));
16
+ const x1 = Math.min(w - 1, x0 + 1);
17
+ const dx = fx - x0;
18
+ const o = (y * W + x) * 3;
19
+ for (let c = 0; c < 3; c++) {
20
+ const a = rgb[(y0 * w + x0) * 3 + c];
21
+ const b = rgb[(y0 * w + x1) * 3 + c];
22
+ const d = rgb[(y1 * w + x0) * 3 + c];
23
+ const e = rgb[(y1 * w + x1) * 3 + c];
24
+ out[o + c] = Math.round((a * (1 - dx) + b * dx) * (1 - dy) + (d * (1 - dx) + e * dx) * dy);
25
+ }
26
+ }
27
+ }
28
+ return out;
29
+ }
30
+
31
+ // Núcleo cúbico de Keys (a = -0.5), el del reescalado «bicúbico» de PIL que usa CLIP.
32
+ const cubic = (x) => {
33
+ const a = -0.5;
34
+ x = Math.abs(x);
35
+ if (x <= 1) return ((a + 2) * x - (a + 3)) * x * x + 1;
36
+ if (x < 2) return (((x - 5) * x + 8) * x - 4) * a;
37
+ return 0;
38
+ };
39
+
40
+ /** Reescalado bicúbico separable con antialias al reducir (como PIL). */
41
+ export function resizeBicubic(rgb, w, h, W, H) {
42
+ const pass = (src, sw, sh, dw, horizontal) => {
43
+ const len = horizontal ? sw : sh;
44
+ const out = new Float32Array(horizontal ? dw * sh * 3 : sw * dw * 3);
45
+ const scale = len / dw;
46
+ const support = 2 * Math.max(1, scale);
47
+ const fscale = Math.max(1, scale);
48
+ for (let i = 0; i < dw; i++) {
49
+ const center = (i + 0.5) * scale;
50
+ const lo = Math.max(0, Math.floor(center - support));
51
+ const hi = Math.min(len, Math.ceil(center + support));
52
+ const ws = [];
53
+ let sum = 0;
54
+ for (let j = lo; j < hi; j++) {
55
+ const wgt = cubic((j + 0.5 - center) / fscale);
56
+ ws.push(wgt);
57
+ sum += wgt;
58
+ }
59
+ for (let k = 0; k < ws.length; k++) ws[k] /= sum || 1;
60
+ const lines = horizontal ? sh : sw;
61
+ for (let l = 0; l < lines; l++) {
62
+ for (let c = 0; c < 3; c++) {
63
+ let acc = 0;
64
+ for (let j = lo, k = 0; j < hi; j++, k++) {
65
+ const idx = horizontal ? (l * sw + j) * 3 + c : (j * sw + l) * 3 + c;
66
+ acc += src[idx] * ws[k];
67
+ }
68
+ const o = horizontal ? (l * dw + i) * 3 + c : (i * sw + l) * 3 + c;
69
+ out[o] = acc;
70
+ }
71
+ }
72
+ }
73
+ return out;
74
+ };
75
+ const a = pass(rgb, w, h, W, true); // W × h
76
+ const b = pass(a, W, h, H, false); // W × H
77
+ const out = new Uint8Array(W * H * 3);
78
+ for (let i = 0; i < out.length; i++) out[i] = Math.max(0, Math.min(255, Math.round(b[i])));
79
+ return out;
80
+ }
81
+
82
+ /** Tensor CHW float32 con normalización por canal (`mean`, `std` en la escala dada). */
83
+ export function toCHW(rgb, w, h, { scale = 1, mean = [0, 0, 0], std = [1, 1, 1], bgr = false } = {}) {
84
+ const out = new Float32Array(3 * w * h);
85
+ const plane = w * h;
86
+ for (let i = 0; i < plane; i++) {
87
+ for (let c = 0; c < 3; c++) {
88
+ const src = rgb[i * 3 + (bgr ? 2 - c : c)];
89
+ out[c * plane + i] = (src * scale - mean[c]) / std[c];
90
+ }
91
+ }
92
+ return out;
93
+ }
94
+
95
+ /** Pega la imagen arriba a la izquierda de un lienzo `S`×`S` relleno de `fill` (letterbox). */
96
+ export function letterbox(rgb, w, h, S, fill = 114) {
97
+ const r = Math.min(S / w, S / h);
98
+ const nw = Math.max(1, Math.round(w * r));
99
+ const nh = Math.max(1, Math.round(h * r));
100
+ const small = resize(rgb, w, h, nw, nh);
101
+ const out = new Uint8Array(S * S * 3).fill(fill);
102
+ for (let y = 0; y < nh; y++) out.set(small.subarray(y * nw * 3, (y + 1) * nw * 3), y * S * 3);
103
+ return { rgb: out, r };
104
+ }
105
+
106
+ /** Supresión de no máximos sobre cajas {x,y,w,h,score}. */
107
+ export function nms(boxes, iou = 0.5) {
108
+ const sorted = [...boxes].sort((a, b) => b.score - a.score);
109
+ const keep = [];
110
+ for (const b of sorted) {
111
+ if (keep.every((k) => overlap(k, b) <= iou)) keep.push(b);
112
+ }
113
+ return keep;
114
+ }
115
+
116
+ function overlap(a, b) {
117
+ const x0 = Math.max(a.x, b.x);
118
+ const y0 = Math.max(a.y, b.y);
119
+ const x1 = Math.min(a.x + a.w, b.x + b.w);
120
+ const y1 = Math.min(a.y + a.h, b.y + b.h);
121
+ const inter = Math.max(0, x1 - x0) * Math.max(0, y1 - y0);
122
+ return inter / (a.w * a.h + b.w * b.h - inter || 1);
123
+ }
@@ -0,0 +1,152 @@
1
+ // Leer vídeo, fotos y audio con el ffmpeg del Editor: datos del fichero, fotogramas
2
+ // muestreados en RGB (para los modelos) y el sonido como muestras (para medirlo).
3
+ import { spawn, spawnSync } from 'node:child_process';
4
+
5
+ /** Duración, fotogramas por segundo, tamaño y si tiene audio. */
6
+ export function probe(ffprobe, path) {
7
+ const r = spawnSync(ffprobe, ['-v', 'error', '-print_format', 'json', '-show_format', '-show_streams', path], { encoding: 'utf8', maxBuffer: 16 * 1024 * 1024 });
8
+ if (r.status !== 0) throw new Error(`Could not read ${path}: ${(r.stderr || '').slice(-200)}`);
9
+ const j = JSON.parse(r.stdout);
10
+ const v = (j.streams ?? []).find((s) => s.codec_type === 'video');
11
+ const rate = (s) => {
12
+ const [a, b] = String(s ?? '0/1').split('/').map(Number);
13
+ return b ? a / b : 0;
14
+ };
15
+ // Vídeo grabado con el móvil en vertical: el giro va en los metadatos.
16
+ const rot = Math.abs(Number(v?.tags?.rotate ?? (v?.side_data_list ?? []).find((d) => d.rotation !== undefined)?.rotation ?? 0)) % 180 === 90;
17
+ return {
18
+ duration: Number(j.format?.duration ?? v?.duration ?? 0) || 0,
19
+ fps: rate(v?.avg_frame_rate) || rate(v?.r_frame_rate) || 30,
20
+ width: rot ? v?.height ?? 0 : v?.width ?? 0,
21
+ height: rot ? v?.width ?? 0 : v?.height ?? 0,
22
+ hasVideo: !!v,
23
+ hasAudio: (j.streams ?? []).some((s) => s.codec_type === 'audio'),
24
+ };
25
+ }
26
+
27
+ /** Tamaño de trabajo que cabe en `max` px de lado manteniendo la proporción (par). */
28
+ export function fitSize(w, h, max) {
29
+ const s = Math.min(1, max / Math.max(w, h));
30
+ return { w: Math.max(2, Math.round((w * s) / 2) * 2), h: Math.max(2, Math.round((h * s) / 2) * 2) };
31
+ }
32
+
33
+ /**
34
+ * Fotogramas del vídeo a `fps` por segundo (o uno, si es una foto), en RGB y a un tamaño de
35
+ * trabajo de como mucho `max` px. Llama a `onFrame({ t, w, h, rgb })` en orden; si devuelve
36
+ * `false`, para. `start`/`end` acotan un tramo (segundos).
37
+ */
38
+ export function frames(ffmpeg, path, { fps = 2, max = 640, width, height, start = 0, end = null, still = false, onFrame }) {
39
+ const { w, h } = fitSize(width, height, max);
40
+ const args = ['-nostdin', '-hide_banner', '-loglevel', 'error'];
41
+ if (start > 0) args.push('-ss', start.toFixed(3));
42
+ args.push('-i', path);
43
+ if (end !== null) args.push('-t', Math.max(0.05, end - start).toFixed(3));
44
+ const vf = still ? `scale=${w}:${h}` : `fps=${fps},scale=${w}:${h}`;
45
+ args.push('-an', '-vf', vf, ...(still ? ['-frames:v', '1'] : []), '-f', 'rawvideo', '-pix_fmt', 'rgb24', 'pipe:1');
46
+ const size = w * h * 3;
47
+ return new Promise((resolve, reject) => {
48
+ const p = spawn(ffmpeg, args, { stdio: ['ignore', 'pipe', 'pipe'] });
49
+ let buf = Buffer.alloc(0);
50
+ let k = 0;
51
+ let stopped = false;
52
+ let err = '';
53
+ let chain = Promise.resolve();
54
+ p.stderr.on('data', (d) => (err = (err + d).slice(-400)));
55
+ p.stdout.on('data', (d) => {
56
+ if (stopped) return;
57
+ buf = buf.length ? Buffer.concat([buf, d]) : d;
58
+ while (buf.length >= size && !stopped) {
59
+ const rgb = buf.subarray(0, size);
60
+ buf = buf.subarray(size);
61
+ // El fps de ffmpeg toma el fotograma de cada tramo de 1/fps: su instante es el centro.
62
+ const t = still ? 0 : start + (k + 0.5) / fps;
63
+ k += 1;
64
+ const copy = Buffer.from(rgb);
65
+ chain = chain.then(async () => {
66
+ if (stopped) return;
67
+ if ((await onFrame({ t, w, h, rgb: copy })) === false) {
68
+ stopped = true;
69
+ p.kill();
70
+ }
71
+ });
72
+ // Con muchos fotogramas en cola, que ffmpeg espere (memoria acotada).
73
+ p.stdout.pause();
74
+ chain.then(() => p.stdout.resume(), () => p.stdout.resume());
75
+ }
76
+ });
77
+ p.on('error', reject);
78
+ p.on('close', (code) => {
79
+ chain.then(() => {
80
+ if (code !== 0 && !stopped && k === 0) reject(new Error(`Could not read the frames of ${path}: ${err.trim()}`));
81
+ else resolve({ count: k, w, h });
82
+ }, reject);
83
+ });
84
+ });
85
+ }
86
+
87
+ /**
88
+ * El sonido como muestras float32 mono a `rate` Hz, en bloques. `start`/`end` acotan un tramo.
89
+ * Devuelve false si el fichero no tiene audio.
90
+ */
91
+ export function pcm(ffmpeg, path, { rate = 16000, start = 0, end = null, onSamples }) {
92
+ const args = ['-nostdin', '-hide_banner', '-loglevel', 'error'];
93
+ if (start > 0) args.push('-ss', start.toFixed(3));
94
+ args.push('-i', path);
95
+ if (end !== null) args.push('-t', Math.max(0.05, end - start).toFixed(3));
96
+ args.push('-vn', '-ac', '1', '-ar', String(rate), '-f', 'f32le', 'pipe:1');
97
+ return new Promise((resolve, reject) => {
98
+ const p = spawn(ffmpeg, args, { stdio: ['ignore', 'pipe', 'pipe'] });
99
+ let carry = Buffer.alloc(0);
100
+ let err = '';
101
+ let any = false;
102
+ p.stderr.on('data', (d) => (err = (err + d).slice(-400)));
103
+ p.stdout.on('data', (d) => {
104
+ any = true;
105
+ const all = carry.length ? Buffer.concat([carry, d]) : d;
106
+ const n = Math.floor(all.length / 4);
107
+ const copy = new Float32Array(n);
108
+ for (let i = 0; i < n; i++) copy[i] = all.readFloatLE(i * 4);
109
+ carry = all.subarray(n * 4);
110
+ try {
111
+ onSamples(copy);
112
+ } catch (e) {
113
+ p.kill();
114
+ reject(e);
115
+ }
116
+ });
117
+ p.on('error', reject);
118
+ p.on('close', (code) => {
119
+ if (!any && /does not contain any stream|Output file .* does not contain|matches no streams/i.test(err)) resolve(false);
120
+ else if (code !== 0 && !any) reject(new Error(`Could not read the audio of ${path}: ${err.trim()}`));
121
+ else resolve(true);
122
+ });
123
+ });
124
+ }
125
+
126
+ /** Niveles en dBFS de ventanas de `windowSec` (RMS). */
127
+ export async function levels(ffmpeg, path, { windowSec = 0.05, start = 0, end = null, maxSec = 3600 } = {}) {
128
+ const rate = 16000;
129
+ const win = Math.round(rate * windowSec);
130
+ const out = [];
131
+ let acc = 0;
132
+ let n = 0;
133
+ let seen = 0;
134
+ const ok = await pcm(ffmpeg, path, {
135
+ rate,
136
+ start,
137
+ end,
138
+ onSamples: (x) => {
139
+ seen += x.length;
140
+ if (seen > maxSec * rate) throw new Error('too long');
141
+ for (let i = 0; i < x.length; i++) {
142
+ acc += x[i] * x[i];
143
+ if (++n === win) {
144
+ out.push(10 * Math.log10(acc / win + 1e-12));
145
+ acc = 0;
146
+ n = 0;
147
+ }
148
+ }
149
+ },
150
+ });
151
+ return ok ? out : null;
152
+ }