@ignix-social/editor 0.1.6 → 0.1.8

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.
Files changed (34) hide show
  1. package/NOTICE +7 -0
  2. package/licenses/torch_audioset-MIT.txt +21 -0
  3. package/package.json +1 -1
  4. package/patches/index.mjs +41 -0
  5. package/skills/ignix-editor/SKILL.md +3 -0
  6. package/src/analysis/audio-events.mjs +223 -0
  7. package/src/analysis/audioset-classes.json +1 -0
  8. package/src/analysis/runtime.mjs +9 -2
  9. package/src/analysis/serve.mjs +10 -0
  10. package/src/analysis/style.mjs +381 -0
  11. package/src/binaries.json +14 -0
  12. package/src/cli.mjs +25 -0
  13. package/src/runtime/ignix-frame.js +1 -1
  14. package/src/validate.mjs +10 -0
  15. package/vendor/hyperframes/UPSTREAM.json +3 -1
  16. package/vendor/hyperframes/dist/checkBrowser-RPMO4OW5.js +5 -5
  17. package/vendor/hyperframes/dist/chunk-2YN64IN5.js +1 -1
  18. package/vendor/hyperframes/dist/chunk-4PFZPP2N.js +3 -3
  19. package/vendor/hyperframes/dist/chunk-4THIAPBL.js +1 -1
  20. package/vendor/hyperframes/dist/chunk-EOVYQ3IG.js +1 -1
  21. package/vendor/hyperframes/dist/chunk-FCSIGXXP.js +1 -1
  22. package/vendor/hyperframes/dist/chunk-HWKCIWQT.js +2 -2
  23. package/vendor/hyperframes/dist/chunk-K2FTXJYH.js +6 -6
  24. package/vendor/hyperframes/dist/chunk-L3YOGIPG.js +1 -1
  25. package/vendor/hyperframes/dist/chunk-S7NPLFWV.js +1 -1
  26. package/vendor/hyperframes/dist/chunk-XUXGIA7H.js +1 -1
  27. package/vendor/hyperframes/dist/chunk-YQZNILVH.js +1 -1
  28. package/vendor/hyperframes/dist/chunk-Z6B37SNM.js +1 -1
  29. package/vendor/hyperframes/dist/chunk-ZMXUQLI2.js +1 -1
  30. package/vendor/hyperframes/dist/cli.js +1 -1
  31. package/vendor/hyperframes/dist/hyperframe-runtime.js +4 -4
  32. package/vendor/hyperframes/dist/hyperframe.manifest.json +1 -1
  33. package/vendor/hyperframes/dist/hyperframe.runtime.iife.js +4 -4
  34. package/vendor/hyperframes/dist/hyperframes-player.global.js +1 -1
@@ -0,0 +1,381 @@
1
+ // `ignix-editor style measure`: el estilo de edición de alguien, MEDIDO en 2–3 vídeos suyos
2
+ // (ADR-0023 E3). Solo lo que se puede medir en el ordenador sin dudar: ritmo de corte,
3
+ // subtítulos (cuánto, dónde, qué tamaño), encuadre y zooms, movimiento, color, volumen y, si
4
+ // se pide, la velocidad del habla. Lo que hay que VER (tipografía, tono, tipo de gancho) lo
5
+ // añade quien mira los fotogramas: el Claude del cliente o el director de Creator.
6
+ //
7
+ // La salida lleva números (para comparar y aplicar) y un resumen en frases (para el prompt).
8
+ import { loudness } from '../audio.mjs';
9
+ import { transcribeDetailed } from '../transcribe.mjs';
10
+ import { faces as detectFaces, text as detectText } from './detect.mjs';
11
+ import { spawnSync } from 'node:child_process';
12
+ import { mkdirSync } from 'node:fs';
13
+ import { join } from 'node:path';
14
+ import { frames, probe } from './media.mjs';
15
+ import { classify, soundProfile } from './audio-events.mjs';
16
+ import { motionOf } from './montage.mjs';
17
+ import { cuts } from './sound.mjs';
18
+
19
+ export const STYLE_VERSION = 1;
20
+ export const MAX_STYLE_VIDEOS = 5;
21
+ const MAX_SEC = 600;
22
+ const FPS = 2;
23
+
24
+ const median = (xs) => {
25
+ const v = xs.filter((x) => Number.isFinite(x)).sort((a, b) => a - b);
26
+ if (!v.length) return null;
27
+ const m = Math.floor(v.length / 2);
28
+ return v.length % 2 ? v[m] : (v[m - 1] + v[m]) / 2;
29
+ };
30
+ const r2 = (x) => (x == null ? null : Math.round(x * 100) / 100);
31
+ const mode = (xs) => {
32
+ const c = new Map();
33
+ for (const x of xs.filter(Boolean)) c.set(x, (c.get(x) ?? 0) + 1);
34
+ return [...c.entries()].sort((a, b) => b[1] - a[1])[0]?.[0] ?? null;
35
+ };
36
+
37
+ export function aspectOf(w, h) {
38
+ const r = w / h;
39
+ const known = [['9:16', 9 / 16], ['4:5', 4 / 5], ['1:1', 1], ['16:9', 16 / 9]];
40
+ return known.sort((a, b) => Math.abs(a[1] - r) - Math.abs(b[1] - r))[0][0];
41
+ }
42
+
43
+ /** Dónde va el texto: por el centro vertical de sus cajas (0 arriba, 1 abajo). */
44
+ export function positionOf(y) {
45
+ if (y == null) return null;
46
+ return y < 0.3 ? 'top' : y < 0.55 ? 'middle' : y < 0.8 ? 'lower third' : 'bottom';
47
+ }
48
+
49
+ /** Tamaño de letra por la altura de una línea respecto al alto del vídeo. */
50
+ export function textSizeOf(h) {
51
+ if (h == null) return null;
52
+ return h < 0.03 ? 'small' : h < 0.055 ? 'medium' : 'large';
53
+ }
54
+
55
+ /** Encuadre por la altura de la cara respecto al alto del vídeo. */
56
+ export function framingOf(h) {
57
+ if (h == null) return null;
58
+ return h >= 0.28 ? 'close-up' : h >= 0.14 ? 'medium' : 'wide';
59
+ }
60
+
61
+ /**
62
+ * Diferencia media entre muestras a medio segundo (0–255). Medido (2026-10-06): una cabeza
63
+ * que habla a cámara 13,6; un Reel de producto con cortes cada 7 s 13,5; uno de barbería con
64
+ * cortes cada 2,5 s 20,9; alguien andando hacia la cámara 45,2.
65
+ */
66
+ export function energyOf(motion) {
67
+ if (motion == null) return null;
68
+ return motion < 15 ? 'calm' : motion < 25 ? 'moderate' : 'high';
69
+ }
70
+
71
+ /** Saturación y brillo (0..1) de un color. */
72
+ function hsv(r, g, b) {
73
+ const mx = Math.max(r, g, b);
74
+ const mn = Math.min(r, g, b);
75
+ return { s: mx ? (mx - mn) / mx : 0, v: mx / 255 };
76
+ }
77
+ const hex = (r, g, b) => `#${[r, g, b].map((c) => Math.round(c).toString(16).padStart(2, '0')).join('')}`;
78
+
79
+ /**
80
+ * Zooms y punch-ins: la cara cambia de tamaño un 20 % o más de un segundo al siguiente sin
81
+ * un corte entre medias (con corte es otro plano). Un salto así en un segundo es de edición;
82
+ * alguien que se acerca a la cámara cambia más despacio.
83
+ */
84
+ export function countZooms(samples, cutTimes) {
85
+ let n = 0;
86
+ for (let i = 1; i < samples.length; i++) {
87
+ const a = samples[i - 1];
88
+ const b = samples[i];
89
+ if (b.t - a.t > 1.01) continue;
90
+ if (cutTimes.some((c) => c > a.t && c <= b.t)) continue;
91
+ if (Math.abs(b.h - a.h) / a.h >= 0.2) n += 1;
92
+ }
93
+ return n;
94
+ }
95
+
96
+ /**
97
+ * ¿Ha cambiado el texto en pantalla entre dos muestras? Cambia si menos de la mitad de las
98
+ * cajas de una tienen pareja en la otra (mismo sitio y altura parecida).
99
+ */
100
+ export function textChanged(a, b) {
101
+ if (!a.length && !b.length) return false;
102
+ if (!a.length || !b.length) return true;
103
+ const same = (x, y) => Math.abs(x.x - y.x) < 0.05 && Math.abs(x.y - y.y) < 0.03 && Math.abs(x.w - y.w) < 0.08 && Math.abs(x.h - y.h) / Math.max(x.h, y.h) < 0.3;
104
+ const matched = a.filter((x) => b.some((y) => same(x, y))).length;
105
+ return matched / Math.max(a.length, b.length) < 0.5;
106
+ }
107
+
108
+ /** Fotogramas para MIRAR el estilo (tipografía, tipo de animación): 4 por vídeo. */
109
+ function stillsOf(ffmpeg, path, duration, dir, n) {
110
+ mkdirSync(dir, { recursive: true });
111
+ const out = [];
112
+ for (const [k, f] of [0.1, 0.35, 0.6, 0.85].entries()) {
113
+ const file = join(dir, `video${n}-${k + 1}.jpg`);
114
+ const r = spawnSync(ffmpeg, ['-nostdin', '-loglevel', 'error', '-y', '-ss', (duration * f).toFixed(2), '-i', path, '-frames:v', '1', '-vf', 'scale=540:-2', '-q:v', '4', file]);
115
+ if (r.status === 0) out.push(file);
116
+ }
117
+ return out;
118
+ }
119
+
120
+ /**
121
+ * Mide un vídeo. `speech`: transcribe para la velocidad del habla (más lento). `stills`: carpeta
122
+ * donde dejar fotogramas para mirar lo que no se mide.
123
+ */
124
+ export async function measureVideo({ ffmpeg, ffprobe, path, speech = null, stills = null, index = 1, log = () => {} }) {
125
+ const info = probe(ffprobe, path);
126
+ if (!info.hasVideo || !(info.duration > 0)) throw new Error(`${path} has no video`);
127
+ const duration = Math.min(info.duration, MAX_SEC);
128
+ const cutTimes = await cuts(ffmpeg, path, { minSec: 0.3, maxSec: MAX_SEC });
129
+ const bounds = [0, ...cutTimes, duration];
130
+ const shots = bounds.slice(1).map((t, i) => t - bounds[i]).filter((s) => s > 0.05);
131
+
132
+ const text = [];
133
+ const textSec = new Set();
134
+ const faceSamples = [];
135
+ let checked = 0;
136
+ let motionSum = 0;
137
+ let motionN = 0;
138
+ let prev = null;
139
+ const colors = new Map();
140
+ let sat = 0;
141
+ let val = 0;
142
+ let warm = 0;
143
+ let px = 0;
144
+ let k = 0;
145
+ let prevText = [];
146
+ let textSamples = 0;
147
+ let textChanges = 0;
148
+ await frames(ffmpeg, path, {
149
+ fps: FPS,
150
+ max: 480,
151
+ width: info.width,
152
+ height: info.height,
153
+ end: duration,
154
+ onFrame: async (f) => {
155
+ if (prev) {
156
+ motionSum += motionOf(prev, f.rgb, f.w, f.h).motion;
157
+ motionN += 1;
158
+ }
159
+ prev = f.rgb;
160
+ // Color: una de cada 16 muestras de píxel, cuantizado a 4 bits por canal.
161
+ for (let i = 0; i < f.w * f.h; i += 16) {
162
+ const r = f.rgb[i * 3];
163
+ const g = f.rgb[i * 3 + 1];
164
+ const b = f.rgb[i * 3 + 2];
165
+ const key = ((r >> 4) << 8) | ((g >> 4) << 4) | (b >> 4);
166
+ const bin = colors.get(key) ?? { n: 0, r: 0, g: 0, b: 0 };
167
+ bin.n += 1;
168
+ bin.r += r;
169
+ bin.g += g;
170
+ bin.b += b;
171
+ colors.set(key, bin);
172
+ const c = hsv(r, g, b);
173
+ sat += c.s;
174
+ val += c.v;
175
+ warm += (r - b) / 255;
176
+ px += 1;
177
+ }
178
+ // Texto en cada muestra (para saber cada cuánto cambia: palabra a palabra o frases).
179
+ const boxes = await detectText(f, log);
180
+ textSamples += 1;
181
+ if (textSamples > 1 && textChanged(prevText, boxes)) textChanges += 1;
182
+ prevText = boxes;
183
+ // Caras una vez por segundo (la primera muestra de cada segundo).
184
+ if (k++ % FPS !== 0) return true;
185
+ const sec = Math.floor(f.t);
186
+ checked += 1;
187
+ if (boxes.length) {
188
+ textSec.add(sec);
189
+ for (const b of boxes) text.push(b);
190
+ }
191
+ const fs = await detectFaces(f, log);
192
+ const big = fs.sort((a, b) => b.w * b.h - a.w * a.h)[0];
193
+ if (big) faceSamples.push({ t: f.t, h: big.h, y: big.y + big.h / 2 });
194
+ return true;
195
+ },
196
+ });
197
+
198
+ // Paleta: los colores más frecuentes (la media real de cada grupo), sin casi-negros —lo
199
+ // oscuro ya lo dice el brillo— y sin repetir tonos casi iguales.
200
+ const palette = [];
201
+ for (const bin of [...colors.values()].sort((a, b) => b.n - a.n)) {
202
+ const c = [bin.r / bin.n, bin.g / bin.n, bin.b / bin.n];
203
+ if (Math.max(...c) < 40) continue;
204
+ if (palette.every((p) => Math.abs(p[0] - c[0]) + Math.abs(p[1] - c[1]) + Math.abs(p[2] - c[2]) > 80)) palette.push(c);
205
+ if (palette.length === 5) break;
206
+ }
207
+
208
+ let speechOut = null;
209
+ if (speech) {
210
+ const t = transcribeDetailed({ ffmpeg, whisper: speech.whisper, model: speech.model, input: path, language: speech.language ?? null });
211
+ const words = t.words ?? [];
212
+ const spoken = words.length ? words[words.length - 1].end - words[0].start : 0;
213
+ speechOut = {
214
+ language: t.language ?? null,
215
+ wordsPerMinute: spoken > 5 ? Math.round((words.length / spoken) * 60) : null,
216
+ firstWordSec: words.length ? r2(words[0].start) : null,
217
+ };
218
+ }
219
+
220
+ // Qué suena (YAMNet): música, voz y efectos con su familia. Un efecto mezclado muy por
221
+ // debajo de una voz continua no se distingue (medido): cuenta lo que se OYE.
222
+ let sound = null;
223
+ if (info.hasAudio) {
224
+ const patches = await classify(ffmpeg, path, { maxSec: MAX_SEC, log }).catch((e) => {
225
+ log(`Could not classify the sound of ${path}: ${e.message}`);
226
+ return null;
227
+ });
228
+ if (patches) sound = soundProfile(patches, { duration, cutTimes });
229
+ }
230
+
231
+ const heights = text.map((b) => b.h);
232
+ const ys = text.map((b) => b.y + b.h / 2);
233
+ const faceH = median(faceSamples.map((s) => s.h));
234
+ return {
235
+ path,
236
+ format: { width: info.width, height: info.height, aspect: aspectOf(info.width, info.height) },
237
+ durationSec: r2(info.duration),
238
+ shots: {
239
+ count: shots.length,
240
+ medianSec: r2(median(shots)),
241
+ meanSec: r2(shots.reduce((a, b) => a + b, 0) / Math.max(1, shots.length)),
242
+ cutsPerMinute: r2((cutTimes.length / duration) * 60),
243
+ firstCutSec: cutTimes.length ? cutTimes[0] : null,
244
+ },
245
+ captions: {
246
+ coverage: checked ? r2(textSec.size / checked) : 0,
247
+ position: positionOf(median(ys)),
248
+ size: textSizeOf(median(heights)),
249
+ changesPerMinute: r2((textChanges / duration) * 60),
250
+ },
251
+ framing: {
252
+ facePresence: checked ? r2(faceSamples.length / checked) : 0,
253
+ faceHeight: r2(faceH),
254
+ shot: framingOf(faceH),
255
+ zoomsPerMinute: r2((countZooms(faceSamples, cutTimes) / duration) * 60),
256
+ },
257
+ motion: { value: motionN ? r2(motionSum / motionN) : null, energy: energyOf(motionN ? motionSum / motionN : null) },
258
+ color: {
259
+ palette: palette.map((c) => hex(...c)),
260
+ saturation: px ? r2(sat / px) : null,
261
+ brightness: px ? r2(val / px) : null,
262
+ warmth: px ? r2(warm / px) : null,
263
+ },
264
+ audio: {
265
+ lufs: info.hasAudio ? loudness(ffmpeg, path) : null,
266
+ speech: speechOut,
267
+ sound: sound && { musicShare: sound.musicShare, speechShare: sound.speechShare, sfxPerMinute: sound.sfxPerMinute, sfxOnCutsShare: sound.sfxOnCutsShare, sfxTypes: sound.sfxTypes },
268
+ },
269
+ ...(stills ? { stills: stillsOf(ffmpeg, path, duration, stills, index) } : {}),
270
+ };
271
+ }
272
+
273
+ /** Junta las medidas de varios vídeos: medianas para los números, lo más repetido para el resto. */
274
+ export function combine(videos) {
275
+ const m = (f) => r2(median(videos.map(f)));
276
+ const style = {
277
+ format: { aspect: mode(videos.map((v) => v.format.aspect)) },
278
+ durationSec: m((v) => v.durationSec),
279
+ shots: {
280
+ medianSec: m((v) => v.shots.medianSec),
281
+ cutsPerMinute: m((v) => v.shots.cutsPerMinute),
282
+ firstCutSec: m((v) => v.shots.firstCutSec),
283
+ },
284
+ captions: {
285
+ coverage: m((v) => v.captions.coverage),
286
+ position: mode(videos.map((v) => v.captions.position)),
287
+ size: mode(videos.map((v) => v.captions.size)),
288
+ changesPerMinute: m((v) => v.captions.changesPerMinute),
289
+ },
290
+ framing: {
291
+ facePresence: m((v) => v.framing.facePresence),
292
+ shot: mode(videos.map((v) => v.framing.shot)),
293
+ zoomsPerMinute: m((v) => v.framing.zoomsPerMinute),
294
+ },
295
+ motion: { energy: mode(videos.map((v) => v.motion.energy)) },
296
+ color: {
297
+ palette: [...new Set(videos.flatMap((v) => v.color.palette.slice(0, 3)))].slice(0, 6),
298
+ saturation: m((v) => v.color.saturation),
299
+ brightness: m((v) => v.color.brightness),
300
+ warmth: m((v) => v.color.warmth),
301
+ },
302
+ audio: {
303
+ lufs: m((v) => v.audio.lufs),
304
+ wordsPerMinute: m((v) => v.audio.speech?.wordsPerMinute),
305
+ language: mode(videos.map((v) => v.audio.speech?.language)),
306
+ musicShare: m((v) => v.audio.sound?.musicShare),
307
+ sfxVideos: videos.filter((v) => v.audio.sound?.sfxTypes?.length).length,
308
+ sfxPerMinute: m((v) => v.audio.sound?.sfxPerMinute),
309
+ sfxOnCutsShare: m((v) => v.audio.sound?.sfxOnCutsShare),
310
+ // Las familias que se oyen en cualquiera de los vídeos, de más a menos.
311
+ sfxTypes: (() => {
312
+ const n = {};
313
+ for (const v of videos) for (const t of v.audio.sound?.sfxTypes ?? []) n[t.family] = (n[t.family] ?? 0) + t.count;
314
+ return Object.entries(n).sort((a, b) => b[1] - a[1]).map(([family]) => family);
315
+ })(),
316
+ },
317
+ };
318
+ return style;
319
+ }
320
+
321
+ /** El estilo en frases, para leerlo una persona o un modelo. Solo lo medido. */
322
+ export function describe(s, n) {
323
+ const out = [];
324
+ const lead = n > 1 ? `Measured on ${n} videos.` : 'Measured on 1 video (2–3 give a steadier style).';
325
+ out.push(lead);
326
+ if (s.format.aspect) out.push(`Format ${s.format.aspect}${s.durationSec ? `, about ${Math.round(s.durationSec)} s long` : ''}.`);
327
+ if (s.shots.medianSec != null)
328
+ out.push(`A cut every ${s.shots.medianSec} s (${s.shots.cutsPerMinute} per minute)${s.shots.firstCutSec != null ? `; the first cut at ${s.shots.firstCutSec} s` : ''}.`);
329
+ if (s.captions.coverage != null)
330
+ out.push(
331
+ s.captions.coverage >= 0.15
332
+ ? `Text on screen ${Math.round(s.captions.coverage * 100)} % of the time${s.captions.position ? `, ${s.captions.position}` : ''}${s.captions.size ? `, ${s.captions.size}` : ''}${s.captions.changesPerMinute != null ? `; it changes ${s.captions.changesPerMinute} times a minute (${s.captions.changesPerMinute >= 40 ? 'word by word' : s.captions.changesPerMinute >= 15 ? 'phrase by phrase' : 'few, long-lasting texts'})` : ''}.`
333
+ : 'Almost no text on screen.',
334
+ );
335
+ if (s.framing.facePresence != null)
336
+ out.push(
337
+ s.framing.facePresence >= 0.3
338
+ ? `A face in ${Math.round(s.framing.facePresence * 100)} % of the video${s.framing.shot ? `, framed ${s.framing.shot}` : ''}; ${s.framing.zoomsPerMinute ?? 0} zooms or punch-ins per minute.`
339
+ : 'Mostly without a face on screen.',
340
+ );
341
+ if (s.motion.energy) out.push(`${s.motion.energy[0].toUpperCase()}${s.motion.energy.slice(1)} motion.`);
342
+ if (s.color.palette.length)
343
+ out.push(
344
+ `Colours ${s.color.palette.join(', ')}; ${s.color.saturation >= 0.45 ? 'saturated' : s.color.saturation <= 0.2 ? 'muted' : 'natural'}, ${s.color.brightness >= 0.6 ? 'bright' : s.color.brightness <= 0.35 ? 'dark' : 'balanced'}, ${s.color.warmth >= 0.05 ? 'warm' : s.color.warmth <= -0.05 ? 'cool' : 'neutral'} grade.`,
345
+ );
346
+ if (s.audio.lufs != null) out.push(`Mixed at about ${Math.round(s.audio.lufs)} LUFS.`);
347
+ if (s.audio.musicShare != null)
348
+ out.push(s.audio.musicShare >= 0.2 ? `Music under ${Math.round(s.audio.musicShare * 100)} % of the video.` : 'Little or no music.');
349
+ if (s.audio.sfxPerMinute != null) {
350
+ const types = s.audio.sfxTypes?.length ? s.audio.sfxTypes.join(', ') : null;
351
+ out.push(
352
+ !types
353
+ ? 'No sound effects heard (one mixed far under a continuous voice cannot be told apart).'
354
+ : s.audio.sfxPerMinute > 0
355
+ ? `Sound effects heard: ${types} — about ${s.audio.sfxPerMinute} a minute${s.audio.sfxOnCutsShare ? `, on ${Math.round(s.audio.sfxOnCutsShare * 100)} % of the cuts` : ''}.`
356
+ : `Sound effects in only ${s.audio.sfxVideos} of ${n} videos (${types}); mostly without.`,
357
+ );
358
+ }
359
+ if (s.audio.wordsPerMinute != null) out.push(`Speech at ${s.audio.wordsPerMinute} words per minute${s.audio.language ? ` (${s.audio.language})` : ''}.`);
360
+ return out;
361
+ }
362
+
363
+ /** Mide 1–5 vídeos y devuelve el estilo (números + frases) y cada vídeo por separado. */
364
+ export async function measureStyle({ ffmpeg, ffprobe, paths, speech = null, stills = null, log = () => {} }) {
365
+ if (!Array.isArray(paths) || !paths.length || paths.length > MAX_STYLE_VIDEOS) throw new Error(`style measure takes 1 to ${MAX_STYLE_VIDEOS} videos`);
366
+ // Todo comprobado antes de cargar un modelo: salir con un error con onnxruntime cargado
367
+ // aborta el proceso en vez de devolver el mensaje.
368
+ for (const path of paths) {
369
+ const info = probe(ffprobe, path);
370
+ if (!info.hasVideo || !(info.duration > 0)) throw new Error(`${path} has no video: style measure needs the videos themselves`);
371
+ }
372
+ const videos = [];
373
+ for (const path of paths) {
374
+ log(`Measuring ${path}…`);
375
+ videos.push(await measureVideo({ ffmpeg, ffprobe, path, speech, stills, index: videos.length + 1, log }));
376
+ }
377
+ const measured = combine(videos);
378
+ const out = { version: STYLE_VERSION, videos: videos.length, measured, summary: describe(measured, videos.length), perVideo: videos };
379
+ if (stills) out.stills = videos.flatMap((v) => v.stills ?? []);
380
+ return out;
381
+ }
package/src/binaries.json CHANGED
@@ -109,6 +109,20 @@
109
109
  "label": "the text detector",
110
110
  "source": "opencv/text_detection_ppocr@c299e81 (Apache-2.0)"
111
111
  },
112
+ "yamnet": {
113
+ "file": "yamnet.onnx",
114
+ "size": 166464,
115
+ "sha256": "d4459cb7fb460e44252ac0408b3718c02ec1286c467a14abaf2ef412fbc0194c",
116
+ "label": "the sound classifier",
117
+ "source": "qualcomm/YamNet v0.63.0, ONNX float (port of torch_audioset, MIT; AudioSet classes from tensorflow/models, Apache-2.0)",
118
+ "extra": [
119
+ {
120
+ "file": "yamnet.data",
121
+ "size": 14915108,
122
+ "sha256": "0b4de0b14bf2b528d51e3cd84e2d3e2ae826b942fbec6001c654605f1f2ceb60"
123
+ }
124
+ ]
125
+ },
112
126
  "clip_vision": {
113
127
  "file": "clip-vit-b32-vision.onnx",
114
128
  "size": 351685709,
package/src/cli.mjs CHANGED
@@ -17,6 +17,7 @@ import { frames, probe } from './analysis/media.mjs';
17
17
  import { serve } from './analysis/serve.mjs';
18
18
  import { creatorJob } from './creator/job.mjs';
19
19
  import { fetchAll } from './fetch.mjs';
20
+ import { measureStyle } from './analysis/style.mjs';
20
21
 
21
22
  const PKG_DIR = join(dirname(fileURLToPath(import.meta.url)), '..');
22
23
  const PKG = JSON.parse(readFileSync(join(PKG_DIR, 'package.json'), 'utf8'));
@@ -196,6 +197,29 @@ const commands = {
196
197
  } else console.log(json);
197
198
  },
198
199
 
200
+ // El estilo de edición de alguien, medido en 2–3 vídeos suyos (ADR-0023 E3).
201
+ async style(argv) {
202
+ const [sub, ...rest] = argv;
203
+ const { pos, opts } = parseArgs(rest, { speech: 'bool', lang: 'str', model: 'str', output: 'str', stills: 'str' });
204
+ const inputs = pos.map((p) => resolve(p));
205
+ if (sub !== 'measure' || !inputs.length || inputs.length > 5 || inputs.some((p) => !existsSync(p))) {
206
+ fail('Usage: ignix-editor style measure <video> [<video> …] (1–5, best 2–3) [--speech] [--lang es] [--stills work/style] [-o style.json]');
207
+ }
208
+ const ffmpeg = await ensureTool('ffmpeg', log);
209
+ const ffprobe = await ensureTool('ffprobe', log);
210
+ const speech = opts.speech
211
+ ? { whisper: await ensureTool('whisper', log), model: await ensureWhisperModel(opts.model ?? 'small', log), language: opts.lang ?? null }
212
+ : null;
213
+ const style = await measureStyle({ ffmpeg, ffprobe, paths: inputs, speech, stills: opts.stills ? resolve(opts.stills) : null, log });
214
+ const json = JSON.stringify(style, null, 1);
215
+ if (opts.output) {
216
+ mkdirSync(dirname(resolve(opts.output)), { recursive: true });
217
+ writeFileSync(resolve(opts.output), json);
218
+ log(style.summary.join('\n'));
219
+ console.log(resolve(opts.output));
220
+ } else console.log(json);
221
+ },
222
+
199
223
  // Dónde está la cara a lo largo del vídeo, para encuadrar en vertical siguiéndola. YuNet sobre
200
224
  // onnxruntime (igual en Mac y en Windows); el motor y el modelo se descargan la primera vez.
201
225
  async faces(argv) {
@@ -304,6 +328,7 @@ const commands = {
304
328
  ignix-editor preview [folder] live preview in the browser
305
329
  ignix-editor transcribe <file> [--lang es] [--script "text"] [-o words.json]
306
330
  ignix-editor faces <video> [--every 0.2] [-o faces.json] where the face is, smoothed
331
+ ignix-editor style measure <video>… [--speech] [--stills dir] [-o style.json] someone's editing style, measured on 2–3 of their videos
307
332
  ignix-editor ffmpeg|ffprobe <args> Ignix Editor's own ffmpeg
308
333
  ignix-editor skills add installs the skill for Claude
309
334
  ignix-editor fetch [--whisper small] [--no-analysis] downloads everything up front (each file SHA-256-checked)
@@ -72,7 +72,7 @@
72
72
 
73
73
  // Registra la línea de tiempo: un único reloj que llama a render(t, fotograma).
74
74
  function run({ id, duration, fps = 30 }, render) {
75
- if (!window.gsap) throw new Error('ignix-frame: carga GSAP antes que este fichero');
75
+ if (!window.gsap) throw new Error('ignix-frame: GSAP is not loaded. Put <script src="assets/gsap.min.js"></script> right above <script src="ignix-frame.js"></script>.');
76
76
  if (!id) throw new Error('ignix-frame: falta id (el data-composition-id del nodo raíz)');
77
77
  const last = Math.max(0, Math.round(duration * fps) - 1);
78
78
  const draw = (sec) => {
package/src/validate.mjs CHANGED
@@ -58,6 +58,16 @@ export function validateHtml(file) {
58
58
  const font = /fonts\.(googleapis|gstatic)\.com|use\.typekit|fonts\.bunny/.test(m[0]);
59
59
  issues.push({ level: (tag === 'script' || tag === 'link') && !font ? 'warning' : 'error', line: lineOf(src, m.index), rule: 'network_asset', msg: `<${tag}> loads something from the internet. Download it into assets/: the render must not depend on the network.` });
60
60
  }
61
+ // ignix-frame.js necesita GSAP cargado ANTES (un agente reescribió el index.html sin él y el
62
+ // render se quedó quieto: 2026-10-06).
63
+ const scripts = [...src.matchAll(/<script\b[^>]*\bsrc\s*=\s*["']([^"']+)["']/gi)].map((m) => ({ src: m[1], at: m.index }));
64
+ const frame = scripts.find((x) => /(^|\/)ignix-frame\.js$/.test(x.src));
65
+ if (frame) {
66
+ const gsap = scripts.find((x) => /gsap(\.min)?\.js$/i.test(x.src));
67
+ if (!gsap || gsap.at > frame.at) {
68
+ issues.push({ level: 'error', line: lineOf(src, frame.at), rule: 'gsap_before_frame', msg: `ignix-frame.js needs GSAP loaded first. Put <script src="assets/gsap.min.js"></script> right above <script src="ignix-frame.js"></script> (as \`ignix-editor init\` writes it).` });
69
+ }
70
+ }
61
71
  for (const m of src.matchAll(/@import\s+url\(\s*["']?https?:|url\(\s*["']?https?:\/\/fonts\./gi)) {
62
72
  issues.push({ level: 'error', line: lineOf(src, m.index), rule: 'network_font', msg: 'Font loaded from the internet: it arrives late in some frames. Use @font-face with a file in assets/fonts/.' });
63
73
  }
@@ -8,6 +8,8 @@
8
8
  "no-telemetry",
9
9
  "no-auto-update",
10
10
  "mf-encoder",
11
- "no-desktop-promo"
11
+ "no-desktop-promo",
12
+ "chrome-first-launch",
13
+ "log-prefix"
12
14
  ]
13
15
  }
@@ -115,7 +115,7 @@ async function preResolveHostileMediaProxies(projectDir, html, autoProxyOverride
115
115
  codecMap = await scanProjectMediaCodecMap(projectDir, [{ html }]);
116
116
  } catch (err) {
117
117
  console.error(
118
- `[hyperframes] media proxy pre-resolve: scan failed (${normalizeErrorMessage(err)})`
118
+ `[ignix-editor] media proxy pre-resolve: scan failed (${normalizeErrorMessage(err)})`
119
119
  );
120
120
  return;
121
121
  }
@@ -135,7 +135,7 @@ async function preResolveHostileMediaProxies(projectDir, html, autoProxyOverride
135
135
  );
136
136
  const failed = results.filter((result) => result.status === "rejected").length;
137
137
  console.error(
138
- `[hyperframes] media proxy pre-resolve: ${results.length - failed}/${results.length} ready, ${failed} failed (${Date.now() - startedAt}ms)`
138
+ `[ignix-editor] media proxy pre-resolve: ${results.length - failed}/${results.length} ready, ${failed} failed (${Date.now() - startedAt}ms)`
139
139
  );
140
140
  }
141
141
  async function runBrowserCheck(project, options, motion, runGrid) {
@@ -226,9 +226,9 @@ async function captureFindingCrops(project, options, requests) {
226
226
  await server.close();
227
227
  }
228
228
  }
229
- var MEDIA_PROXY_MARKER_PREFIX = "[hyperframes] runtime_media_proxy_";
230
- var MEDIA_PROXY_UNAVAILABLE_MARKER = "[hyperframes] runtime_media_proxy_unavailable";
231
- var WEB_AUDIO_BYPASS_MARKER = "[hyperframes] runtime_web_audio_bypass";
229
+ var MEDIA_PROXY_MARKER_PREFIX = "[ignix-editor] runtime_media_proxy_";
230
+ var MEDIA_PROXY_UNAVAILABLE_MARKER = "[ignix-editor] runtime_media_proxy_unavailable";
231
+ var WEB_AUDIO_BYPASS_MARKER = "[ignix-editor] runtime_web_audio_bypass";
232
232
  var WEBGPU_RUNTIME_FAILURE = /\b(?:GPUValidationError|GPUOutOfMemoryError|GPUInternalError)\b|WebGPU uncaptured error|(?:destroyed\b.*\b(?:GPU )?(?:resource|buffer|texture)\b.*\bsubmit)|(?:(?:GPU )?(?:resource|buffer|texture)\b.*\bdestroyed\b.*\bsubmit)/i;
233
233
  function isWebGpuRuntimeFailure(text) {
234
234
  return WEBGPU_RUNTIME_FAILURE.test(text);
@@ -4402,7 +4402,7 @@ function swallow(label, error) {
4402
4402
  }
4403
4403
  }
4404
4404
  if (w.__hfDebug || w.__HYPERFRAMES_DEBUG) {
4405
- console.debug(`[hyperframes] ${label} swallowed:`, error);
4405
+ console.debug(`[ignix-editor] ${label} swallowed:`, error);
4406
4406
  }
4407
4407
  }
4408
4408
 
@@ -226,7 +226,7 @@ async function findFromHyperframesCache() {
226
226
  const code = err?.code;
227
227
  const suffix = code ? ` (${code})` : "";
228
228
  console.warn(
229
- `[hyperframes] Browser cache read failed${suffix}: ${normalizeErrorMessage(err)}. Falling back to system Chrome or a fresh download.`
229
+ `[ignix-editor] Browser cache read failed${suffix}: ${normalizeErrorMessage(err)}. Falling back to system Chrome or a fresh download.`
230
230
  );
231
231
  installed = [];
232
232
  }
@@ -318,7 +318,7 @@ function warnSystemFallbackOnce(executablePath) {
318
318
  if (isHeadlessShellBinary(executablePath)) return;
319
319
  _warnedSystemFallback = true;
320
320
  console.warn(
321
- `[hyperframes] Using system Chrome at ${executablePath}; HeadlessExperimental.beginFrame is unavailable in regular Chrome builds, so the perf-optimized capture path falls back to screenshot mode. Install chrome-headless-shell for the optimized path:
321
+ `[ignix-editor] Using system Chrome at ${executablePath}; HeadlessExperimental.beginFrame is unavailable in regular Chrome builds, so the perf-optimized capture path falls back to screenshot mode. Install chrome-headless-shell for the optimized path:
322
322
  npx @puppeteer/browsers install chrome-headless-shell
323
323
  (Or set HYPERFRAMES_BROWSER_PATH to point at an existing chrome-headless-shell binary.)`
324
324
  );
@@ -514,7 +514,7 @@ async function downloadBrowser(options) {
514
514
  runInstall,
515
515
  clearStaging,
516
516
  (err) => console.warn(
517
- `[hyperframes] Downloaded browser archive was corrupt (${normalizeErrorMessage(err)}); re-downloading.`
517
+ `[ignix-editor] Downloaded browser archive was corrupt (${normalizeErrorMessage(err)}); re-downloading.`
518
518
  )
519
519
  );
520
520
  return { executablePath: moveStagedInstallIntoCache(stagingDir, staged), source: "download" };
@@ -53,7 +53,7 @@ async function maybePromptRenderFeedback(opts) {
53
53
  config.lastFeedbackPromptAt = config.renderSuccessCount;
54
54
  writeConfig(config);
55
55
  console.log(
56
- c.dim(" [hyperframes] ") + c.dim("Agent feedback: ") + c.accent('hyperframes feedback --rating <0-10> --comment "..."')
56
+ c.dim(" [ignix-editor] ") + c.dim("Agent feedback: ") + c.accent('hyperframes feedback --rating <0-10> --comment "..."')
57
57
  );
58
58
  return;
59
59
  }
@@ -39,7 +39,7 @@ function createFileAtomically(filePath, content, operations = fs) {
39
39
  try {
40
40
  operations.unlinkSync(tempPath);
41
41
  } catch (error) {
42
- console.warn(`[hyperframes] created ${filePath} but could not remove ${tempPath}: ${error}`);
42
+ console.warn(`[ignix-editor] created ${filePath} but could not remove ${tempPath}: ${error}`);
43
43
  }
44
44
  });
45
45
  }
@@ -211,7 +211,7 @@ async function chromeLaunchOutcome(executablePath, found, signal) {
211
211
  try {
212
212
  const { stdout } = await runCancellableProcess(executablePath, ["--version"], {
213
213
  signal,
214
- timeoutMs: 5e3,
214
+ timeoutMs: 6e4, // ignix-editor: primera apertura de una copia nueva
215
215
  maxBufferBytes: 64 * 1024
216
216
  });
217
217
  return { ...found, versionMajor: extractMajorVersion(stdout) };