@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.
- package/NOTICE +7 -0
- package/licenses/torch_audioset-MIT.txt +21 -0
- package/package.json +1 -1
- package/patches/index.mjs +41 -0
- package/skills/ignix-editor/SKILL.md +3 -0
- package/src/analysis/audio-events.mjs +223 -0
- package/src/analysis/audioset-classes.json +1 -0
- package/src/analysis/runtime.mjs +9 -2
- package/src/analysis/serve.mjs +10 -0
- package/src/analysis/style.mjs +381 -0
- package/src/binaries.json +14 -0
- package/src/cli.mjs +25 -0
- package/src/runtime/ignix-frame.js +1 -1
- package/src/validate.mjs +10 -0
- package/vendor/hyperframes/UPSTREAM.json +3 -1
- package/vendor/hyperframes/dist/checkBrowser-RPMO4OW5.js +5 -5
- package/vendor/hyperframes/dist/chunk-2YN64IN5.js +1 -1
- package/vendor/hyperframes/dist/chunk-4PFZPP2N.js +3 -3
- package/vendor/hyperframes/dist/chunk-4THIAPBL.js +1 -1
- package/vendor/hyperframes/dist/chunk-EOVYQ3IG.js +1 -1
- package/vendor/hyperframes/dist/chunk-FCSIGXXP.js +1 -1
- package/vendor/hyperframes/dist/chunk-HWKCIWQT.js +2 -2
- package/vendor/hyperframes/dist/chunk-K2FTXJYH.js +6 -6
- package/vendor/hyperframes/dist/chunk-L3YOGIPG.js +1 -1
- package/vendor/hyperframes/dist/chunk-S7NPLFWV.js +1 -1
- package/vendor/hyperframes/dist/chunk-XUXGIA7H.js +1 -1
- package/vendor/hyperframes/dist/chunk-YQZNILVH.js +1 -1
- package/vendor/hyperframes/dist/chunk-Z6B37SNM.js +1 -1
- package/vendor/hyperframes/dist/chunk-ZMXUQLI2.js +1 -1
- package/vendor/hyperframes/dist/cli.js +1 -1
- package/vendor/hyperframes/dist/hyperframe-runtime.js +4 -4
- package/vendor/hyperframes/dist/hyperframe.manifest.json +1 -1
- package/vendor/hyperframes/dist/hyperframe.runtime.iife.js +4 -4
- 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:
|
|
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
|
}
|
|
@@ -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
|
-
`[
|
|
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
|
-
`[
|
|
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 = "[
|
|
230
|
-
var MEDIA_PROXY_UNAVAILABLE_MARKER = "[
|
|
231
|
-
var WEB_AUDIO_BYPASS_MARKER = "[
|
|
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);
|
|
@@ -226,7 +226,7 @@ async function findFromHyperframesCache() {
|
|
|
226
226
|
const code = err?.code;
|
|
227
227
|
const suffix = code ? ` (${code})` : "";
|
|
228
228
|
console.warn(
|
|
229
|
-
`[
|
|
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
|
-
`[
|
|
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
|
-
`[
|
|
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(" [
|
|
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(`[
|
|
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:
|
|
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) };
|