@ignix-social/editor 0.1.10 → 0.1.11
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/package.json +1 -1
- package/skills/ignix-editor/SKILL.md +6 -2
- package/src/analysis/review.mjs +216 -0
- package/src/cli.mjs +35 -2
package/package.json
CHANGED
|
@@ -139,8 +139,12 @@ Re-run it whenever the voice or the cuts change; the animation code reads `windo
|
|
|
139
139
|
|
|
140
140
|
## 7. Review before showing anything
|
|
141
141
|
|
|
142
|
-
`check`, `snapshot` and `render` start a headless Chrome. A sandboxed shell can block it (Codex: `MachPortRendezvousServer: Permission denied`, or `Chrome cannot start`): run those commands with the host's escalated permissions, outside its sandbox
|
|
142
|
+
`check`, `snapshot` and `render` start a headless Chrome. A sandboxed shell can block it (Codex: `MachPortRendezvousServer: Permission denied`, or `Chrome cannot start`): first run those commands with the host's escalated permissions, outside its sandbox. If the host still blocks it, stop and tell the user how to allow it in their app, then retry when they say it is done:
|
|
143
|
+
- ChatGPT / Codex app: Settings → General → Permissions → turn on "Full access" and confirm; then, in the chat, open the approvals selector under the message box and choose "Full access" (labels follow the app's language; they can switch back to "Ask for approval" after the render).
|
|
144
|
+
- Claude Code: approve the command when asked; if its sandbox blocks Chrome, allow that command outside the sandbox (or turn the sandbox off with `/sandbox`).
|
|
145
|
+
|
|
146
|
+
Only if the app offers no way, give the user ONE line to paste into Terminal, e.g. `cd ~/ignix-videos/<name> && ~/.ignix-editor/cli/node_modules/.bin/ignix-editor render`, never the contents of a script file (a `#!` line breaks zsh), then continue when they say it finished.
|
|
143
147
|
|
|
144
148
|
1. `ignix-editor check` → 0 errors.
|
|
145
149
|
2. `ignix-editor snapshot --at <every ~0.5 s and every cue>` and LOOK at the frames (or tile them: `ignix-editor ffmpeg -pattern_type glob -i 'snapshots/frame-*.png' -vf "scale=270:480,tile=8x2" sheet.jpg`). Check: hook readable at frame 0, nothing still for >1.5 s, every named thing pointed at on its word, captions never over what is pointed at, nothing outside the safe zone.
|
|
146
|
-
3. `ignix-editor render` → open the MP4's first frame and a few cues again
|
|
150
|
+
3. `ignix-editor render` → it reviews the MP4 by itself and prints `✓ Review: ready to show` or `✗ Review: NOT READY` with what to fix and where (stretches where nothing on the stage moves, a mostly empty frame, silences between voice lines, no change of scene, no sound effects). NOT READY means it is not finished: fix every ✗, render again, and only then show it. The `!` lines are worth fixing too when it is quick. (`ignix-editor review <mp4>` runs the same check on any video.) Then open the MP4's first frame and a few cues again, and deliver as the playbook says.
|
|
@@ -0,0 +1,216 @@
|
|
|
1
|
+
import { frames, levels, probe } from './media.mjs';
|
|
2
|
+
import { measureVideo } from './style.mjs';
|
|
3
|
+
|
|
4
|
+
// REVISIÓN DE UN REEL ANTES DE ENSEÑARLO (2026-10-06). El manual pide «algo cambia cada
|
|
5
|
+
// 1-1,5 s», «llena el plano», «voz sin huecos», «efectos que signifiquen algo»… y un agente
|
|
6
|
+
// con prisa no lo mira: en la prueba de Fran con ChatGPT salió un carrusel de cinco tarjetas
|
|
7
|
+
// pequeñas, quietas 3-4 s cada una, con 0,8 s de silencio entre frases. Esto lo MIDE en el
|
|
8
|
+
// MP4 y dice qué arreglar y dónde, para que no dependa de que el agente se fije.
|
|
9
|
+
//
|
|
10
|
+
// Medidas (sobre el vídeo ya renderizado, sin saber cómo se hizo):
|
|
11
|
+
// quieto — tramos en los que el escenario no cambia: diferencia entre fotogramas
|
|
12
|
+
// consecutivos por debajo de lo que mueve una palabra de subtítulo al
|
|
13
|
+
// encenderse. Los subtítulos solos NO cuentan como movimiento.
|
|
14
|
+
// lleno — qué parte de la zona segura ocupa algo distinto del fondo.
|
|
15
|
+
// huecos — silencios entre frases de la voz.
|
|
16
|
+
// cortes y efectos — los de `style measure` (cambios de escena, efectos oídos).
|
|
17
|
+
|
|
18
|
+
const FPS = 10;
|
|
19
|
+
/** Fracción de píxeles que tiene que cambiar entre dos fotogramas para contar como movimiento. */
|
|
20
|
+
export const MOVE_FRAC = 0.006;
|
|
21
|
+
/** Tramo quieto que ya es un fallo (el manual dice ~1,5 s; margen de sobra: con 2-2,5 s
|
|
22
|
+
* fallaban reels buenos de Fran en pausas que nadie nota). En vídeos de más de
|
|
23
|
+
* {@link LONG_SEC} es solo un aviso: ahí se explica más despacio. */
|
|
24
|
+
export const STILL_FAIL_SEC = 3;
|
|
25
|
+
/** La tarjeta final puede quedarse quieta algo más (CTA que se lee). */
|
|
26
|
+
export const END_HOLD_SEC = 3.5;
|
|
27
|
+
export const LONG_SEC = 35;
|
|
28
|
+
/** Franja de los subtítulos (el manual los pone abajo en la columna segura): no cuenta como
|
|
29
|
+
* movimiento, porque un carrusel quieto con subtítulos palabra a palabra «se mueve». */
|
|
30
|
+
const CAPTION_BAND = { y0: 0.6, y1: 0.8 };
|
|
31
|
+
/** Por debajo de esta ocupación media de la zona segura, el plano está medio vacío. */
|
|
32
|
+
// Calibrado con reels que Fran da por buenos (2026-10-06): 0,28-0,80; el carrusel flojo, 0,19.
|
|
33
|
+
export const FILL_FAIL = 0.22;
|
|
34
|
+
export const FILL_WARN = 0.25;
|
|
35
|
+
/** Silencio entre dos frases que ya se nota (el manual pide ≤ 0,3 s). */
|
|
36
|
+
export const GAP_WARN_SEC = 0.5;
|
|
37
|
+
const SILENCE_DB = -45;
|
|
38
|
+
|
|
39
|
+
// Zona segura de un 9:16 de 1080×1920 (la del manual): columna central de ~820 px, sin los
|
|
40
|
+
// ~250 px de arriba ni los ~450 de abajo.
|
|
41
|
+
const SAFE = { x0: 0.12, x1: 0.88, y0: 0.13, y1: 0.765 };
|
|
42
|
+
|
|
43
|
+
function gray(rgb, n) {
|
|
44
|
+
const g = new Uint8Array(n);
|
|
45
|
+
for (let i = 0; i < n; i++) g[i] = (rgb[i * 3] * 77 + rgb[i * 3 + 1] * 150 + rgb[i * 3 + 2] * 29) >> 8;
|
|
46
|
+
return g;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** El color de fondo: la mediana de los bordes del fotograma. */
|
|
50
|
+
function background(rgb, w, h) {
|
|
51
|
+
const rs = [];
|
|
52
|
+
const gs = [];
|
|
53
|
+
const bs = [];
|
|
54
|
+
const take = (x, y) => {
|
|
55
|
+
const i = (y * w + x) * 3;
|
|
56
|
+
rs.push(rgb[i]);
|
|
57
|
+
gs.push(rgb[i + 1]);
|
|
58
|
+
bs.push(rgb[i + 2]);
|
|
59
|
+
};
|
|
60
|
+
for (let x = 0; x < w; x += 2) {
|
|
61
|
+
take(x, 0);
|
|
62
|
+
take(x, h - 1);
|
|
63
|
+
}
|
|
64
|
+
for (let y = 0; y < h; y += 2) {
|
|
65
|
+
take(0, y);
|
|
66
|
+
take(w - 1, y);
|
|
67
|
+
}
|
|
68
|
+
const med = (a) => a.sort((p, q) => p - q)[a.length >> 1];
|
|
69
|
+
return [med(rs), med(gs), med(bs)];
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** Qué parte de la zona segura ocupa algo distinto del fondo (0..1). */
|
|
73
|
+
export function fillOf(rgb, w, h) {
|
|
74
|
+
const [br, bg, bb] = background(rgb, w, h);
|
|
75
|
+
const x0 = Math.round(SAFE.x0 * w);
|
|
76
|
+
const x1 = Math.round(SAFE.x1 * w);
|
|
77
|
+
const y0 = Math.round(SAFE.y0 * h);
|
|
78
|
+
const y1 = Math.round(SAFE.y1 * h);
|
|
79
|
+
let on = 0;
|
|
80
|
+
let n = 0;
|
|
81
|
+
for (let y = y0; y < y1; y++) {
|
|
82
|
+
for (let x = x0; x < x1; x++) {
|
|
83
|
+
const i = (y * w + x) * 3;
|
|
84
|
+
if (Math.abs(rgb[i] - br) + Math.abs(rgb[i + 1] - bg) + Math.abs(rgb[i + 2] - bb) > 60) on++;
|
|
85
|
+
n++;
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
return n ? on / n : 0;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** Tramos seguidos de pasos sin movimiento, en segundos [{start, end}]. */
|
|
92
|
+
export function stillRuns(steps, minSec) {
|
|
93
|
+
const out = [];
|
|
94
|
+
let from = null;
|
|
95
|
+
for (let i = 0; i <= steps.length; i++) {
|
|
96
|
+
const still = i < steps.length && steps[i].frac < MOVE_FRAC;
|
|
97
|
+
if (still && from === null) from = i;
|
|
98
|
+
if (!still && from !== null) {
|
|
99
|
+
const start = steps[from].t - 1 / FPS;
|
|
100
|
+
const end = steps[i - 1].t;
|
|
101
|
+
if (end - start >= minSec) out.push({ start, end });
|
|
102
|
+
from = null;
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
return out;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/** Silencios dentro de la voz (entre la primera y la última ventana con sonido). */
|
|
109
|
+
export function speechGaps(db, windowSec, minSec) {
|
|
110
|
+
const loud = db.map((v) => v > SILENCE_DB);
|
|
111
|
+
const first = loud.indexOf(true);
|
|
112
|
+
const last = loud.lastIndexOf(true);
|
|
113
|
+
if (first < 0) return [];
|
|
114
|
+
const out = [];
|
|
115
|
+
let from = null;
|
|
116
|
+
for (let i = first; i <= last; i++) {
|
|
117
|
+
if (!loud[i] && from === null) from = i;
|
|
118
|
+
if (loud[i] && from !== null) {
|
|
119
|
+
const sec = (i - from) * windowSec;
|
|
120
|
+
if (sec >= minSec) out.push({ start: from * windowSec, end: i * windowSec });
|
|
121
|
+
from = null;
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
return out;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
const r1 = (x) => Math.round(x * 10) / 10;
|
|
128
|
+
|
|
129
|
+
export async function reviewVideo({ ffmpeg, ffprobe, path, log = () => {} }) {
|
|
130
|
+
const info = probe(ffprobe, path);
|
|
131
|
+
if (!info.hasVideo || !(info.duration > 0)) throw new Error(`${path} has no video`);
|
|
132
|
+
const steps = [];
|
|
133
|
+
const fills = [];
|
|
134
|
+
let prev = null;
|
|
135
|
+
let k = 0;
|
|
136
|
+
log('Reviewing motion and framing…');
|
|
137
|
+
await frames(ffmpeg, path, {
|
|
138
|
+
fps: FPS,
|
|
139
|
+
max: 240,
|
|
140
|
+
width: info.width,
|
|
141
|
+
height: info.height,
|
|
142
|
+
onFrame: (f) => {
|
|
143
|
+
const n = f.w * f.h;
|
|
144
|
+
const g = gray(f.rgb, n);
|
|
145
|
+
if (prev) {
|
|
146
|
+
const c0 = Math.round(CAPTION_BAND.y0 * f.h) * f.w;
|
|
147
|
+
const c1 = Math.round(CAPTION_BAND.y1 * f.h) * f.w;
|
|
148
|
+
let c = 0;
|
|
149
|
+
for (let i = 0; i < n; i++) if ((i < c0 || i >= c1) && Math.abs(g[i] - prev[i]) > 16) c++;
|
|
150
|
+
steps.push({ t: f.t, frac: c / (n - (c1 - c0)) });
|
|
151
|
+
}
|
|
152
|
+
prev = g;
|
|
153
|
+
if (k++ % 5 === 0) fills.push(fillOf(f.rgb, f.w, f.h));
|
|
154
|
+
},
|
|
155
|
+
});
|
|
156
|
+
const windowSec = 0.05;
|
|
157
|
+
const db = await levels(ffmpeg, path, { windowSec }).catch(() => null);
|
|
158
|
+
log('Reviewing text and sound…');
|
|
159
|
+
const m = await measureVideo({ ffmpeg, ffprobe, path, log: () => {} }).catch(() => null);
|
|
160
|
+
|
|
161
|
+
// La última quietud, si llega al final, es la tarjeta de cierre: se le deja más.
|
|
162
|
+
const stills = stillRuns(steps, STILL_FAIL_SEC).filter(
|
|
163
|
+
(s) => !(info.duration - s.end < 0.3 && s.end - s.start < END_HOLD_SEC),
|
|
164
|
+
);
|
|
165
|
+
const fill = fills.length ? fills.reduce((a, b) => a + b, 0) / fills.length : null;
|
|
166
|
+
const gaps = db ? speechGaps(db, windowSec, GAP_WARN_SEC) : [];
|
|
167
|
+
const movingShare = steps.length ? steps.filter((s) => s.frac >= MOVE_FRAC).length / steps.length : null;
|
|
168
|
+
|
|
169
|
+
const issues = [];
|
|
170
|
+
for (const s of stills) {
|
|
171
|
+
issues.push({
|
|
172
|
+
level: info.duration > LONG_SEC ? 'warn' : 'fail',
|
|
173
|
+
code: 'still',
|
|
174
|
+
at: [r1(s.start), r1(s.end)],
|
|
175
|
+
text: `Nothing on the stage moves from ${r1(s.start)} s to ${r1(s.end)} s (${r1(s.end - s.start)} s; captions alone do not count). Add a camera move, an element entering or a highlight at least every 1-1.5 s.`,
|
|
176
|
+
});
|
|
177
|
+
}
|
|
178
|
+
if (fill !== null && fill < FILL_WARN) {
|
|
179
|
+
issues.push({
|
|
180
|
+
level: fill < FILL_FAIL ? 'fail' : 'warn',
|
|
181
|
+
code: 'empty_frame',
|
|
182
|
+
text: `The frame is mostly empty: only ${Math.round(fill * 100)} % of the safe area has something on it. Make the stage (phone, screen, card, image) about 85 % of the safe column's width and most of its height.`,
|
|
183
|
+
});
|
|
184
|
+
}
|
|
185
|
+
for (const gp of gaps) {
|
|
186
|
+
issues.push({
|
|
187
|
+
level: 'warn',
|
|
188
|
+
code: 'speech_gap',
|
|
189
|
+
at: [r1(gp.start), r1(gp.end)],
|
|
190
|
+
text: `${r1(gp.end - gp.start)} s of silence between two voice lines at ${r1(gp.start)} s. Lay the lines back to back (≤ 0.3 s).`,
|
|
191
|
+
});
|
|
192
|
+
}
|
|
193
|
+
// Calibrado con los reels de Fran (2026-10-06): todos los buenos cortan 3,5-10 veces por
|
|
194
|
+
// minuto; el carrusel flojo, ninguna (solo fundidos entre tarjetas).
|
|
195
|
+
if (m?.shots?.cutsPerMinute === 0 && info.duration > 10) {
|
|
196
|
+
issues.push({ level: 'warn', code: 'no_cuts', text: 'No real change of scene anywhere: it plays as one slow sequence of fades. Change scene with cuts or quick whips, and move a camera inside each scene.' });
|
|
197
|
+
}
|
|
198
|
+
const sfx = m?.audio?.sound?.sfxPerMinute;
|
|
199
|
+
if (sfx === 0 && info.duration > 8) {
|
|
200
|
+
issues.push({ level: 'warn', code: 'no_sfx', text: 'No sound effect is heard. A 20 s reel usually needs 4-8, on the moments that matter (hook, scene change, the key click or reveal, the CTA).' });
|
|
201
|
+
}
|
|
202
|
+
return {
|
|
203
|
+
path,
|
|
204
|
+
durationSec: r1(info.duration),
|
|
205
|
+
ready: !issues.some((i) => i.level === 'fail'),
|
|
206
|
+
measured: {
|
|
207
|
+
movingShare: movingShare === null ? null : Math.round(movingShare * 100) / 100,
|
|
208
|
+
longestStillSec: r1(Math.max(0, ...stillRuns(steps, 0).map((s) => s.end - s.start))),
|
|
209
|
+
fill: fill === null ? null : Math.round(fill * 100) / 100,
|
|
210
|
+
textSize: m?.captions?.size ?? null,
|
|
211
|
+
sfxPerMinute: sfx ?? null,
|
|
212
|
+
cutsPerMinute: m?.shots?.cutsPerMinute ?? null,
|
|
213
|
+
},
|
|
214
|
+
issues,
|
|
215
|
+
};
|
|
216
|
+
}
|
package/src/cli.mjs
CHANGED
|
@@ -18,6 +18,7 @@ import { serve } from './analysis/serve.mjs';
|
|
|
18
18
|
import { creatorJob } from './creator/job.mjs';
|
|
19
19
|
import { fetchAll } from './fetch.mjs';
|
|
20
20
|
import { measureStyle } from './analysis/style.mjs';
|
|
21
|
+
import { reviewVideo } from './analysis/review.mjs';
|
|
21
22
|
|
|
22
23
|
const PKG_DIR = join(dirname(fileURLToPath(import.meta.url)), '..');
|
|
23
24
|
const PKG = JSON.parse(readFileSync(join(PKG_DIR, 'package.json'), 'utf8'));
|
|
@@ -158,16 +159,41 @@ const commands = {
|
|
|
158
159
|
},
|
|
159
160
|
|
|
160
161
|
async render(argv) {
|
|
161
|
-
const { pos, opts, rest } = parseArgs(argv, { output: 'str', lufs: 'str', normalize: 'bool', force: 'bool' });
|
|
162
|
+
const { pos, opts, rest } = parseArgs(argv, { output: 'str', lufs: 'str', normalize: 'bool', force: 'bool', review: 'bool' });
|
|
162
163
|
const dir = projectDir(pos[0]);
|
|
163
164
|
if (validateProject(dir) && !opts.force) fail('Fix the errors above before rendering (or --force if you know what you are doing).');
|
|
164
165
|
const output = resolve(opts.output ?? join(dir, 'renders', `${basename(dir)}.mp4`));
|
|
165
166
|
const r = await renderProject(dir, output, { lufs: opts.lufs, normalize: opts.normalize, engineArgs: rest, log });
|
|
166
167
|
if (r.snapped) log(`• ${r.snapped} time(s) snapped to the nearest frame`);
|
|
167
168
|
if (r.loudness) log(`✓ Loudness: ${r.loudness.before.toFixed(1)} → ${r.loudness.after?.toFixed(1)} LUFS`);
|
|
169
|
+
// La revisión va SIEMPRE detrás del render (salvo --no-review): un agente con prisa se
|
|
170
|
+
// salta un paso aparte, pero no puede no ver esto. El MP4 queda hecho igualmente.
|
|
171
|
+
if (opts.review !== false) {
|
|
172
|
+
const ffmpeg = await ensureTool('ffmpeg', log);
|
|
173
|
+
const ffprobe = await ensureTool('ffprobe', log);
|
|
174
|
+
await reviewVideo({ ffmpeg, ffprobe, path: output, log })
|
|
175
|
+
.then((rv) => printReview(rv))
|
|
176
|
+
.catch((e) => log(`(The review could not run: ${e.message}. Check the frames yourself.)`));
|
|
177
|
+
}
|
|
168
178
|
console.log(output);
|
|
169
179
|
},
|
|
170
180
|
|
|
181
|
+
// Revisa un MP4 ya hecho: quietud, plano vacío, huecos en la voz, cortes y efectos.
|
|
182
|
+
// Sale con código 1 si no está listo para enseñarlo.
|
|
183
|
+
async review(argv) {
|
|
184
|
+
const { pos, opts } = parseArgs(argv, { json: 'bool' });
|
|
185
|
+
const input = pos[0] && resolve(pos[0]);
|
|
186
|
+
if (!input || !existsSync(input)) fail('Usage: ignix-editor review <video.mp4> [--json]');
|
|
187
|
+
const ffmpeg = await ensureTool('ffmpeg', log);
|
|
188
|
+
const ffprobe = await ensureTool('ffprobe', log);
|
|
189
|
+
const rv = await reviewVideo({ ffmpeg, ffprobe, path: input, log });
|
|
190
|
+
if (opts.json) console.log(JSON.stringify(rv, null, 1));
|
|
191
|
+
else printReview(rv, console.log);
|
|
192
|
+
// exitCode y no process.exit: cortar el proceso con onnxruntime cargado lo tumba
|
|
193
|
+
// («mutex lock failed», código 134).
|
|
194
|
+
if (!rv.ready) process.exitCode = 1;
|
|
195
|
+
},
|
|
196
|
+
|
|
171
197
|
async preview(argv) {
|
|
172
198
|
const { pos, rest } = parseArgs(argv, {});
|
|
173
199
|
const dir = projectDir(pos[0]);
|
|
@@ -329,7 +355,8 @@ const commands = {
|
|
|
329
355
|
ignix-editor init <name> creates ~/ignix-videos/<name>, ready to work in
|
|
330
356
|
ignix-editor check [folder] HTML, assets, layout, motion and contrast
|
|
331
357
|
ignix-editor snapshot [folder] --at 0,2.5,5 single frames to look at (into <folder>/snapshots)
|
|
332
|
-
ignix-editor render [folder] [-o out.mp4] [--lufs -14] [--no-normalize]
|
|
358
|
+
ignix-editor render [folder] [-o out.mp4] [--lufs -14] [--no-normalize] [--no-review]
|
|
359
|
+
ignix-editor review <video.mp4> [--json] is it ready to show? stillness, empty frame, voice gaps, cuts, sound effects
|
|
333
360
|
ignix-editor preview [folder] live preview in the browser
|
|
334
361
|
ignix-editor transcribe <file> [--lang es] [--script "text"] [-o words.json]
|
|
335
362
|
ignix-editor faces <video> [--every 0.2] [-o faces.json] where the face is, smoothed
|
|
@@ -343,6 +370,12 @@ The folder defaults to the current one. Open source (Apache-2.0); credits in NOT
|
|
|
343
370
|
},
|
|
344
371
|
};
|
|
345
372
|
|
|
373
|
+
function printReview(rv, out = log) {
|
|
374
|
+
out(rv.ready ? '✓ Review: ready to show.' : '✗ Review: NOT READY. Fix every ✗ below, render again, and only then show it to the user.');
|
|
375
|
+
for (const i of rv.issues) out(` ${i.level === 'fail' ? '✗' : '!'} ${i.text}`);
|
|
376
|
+
if (rv.ready && rv.issues.length) out(' (! = worth fixing if it is quick.)');
|
|
377
|
+
}
|
|
378
|
+
|
|
346
379
|
export async function main(argv) {
|
|
347
380
|
const [cmd = 'help', ...rest] = argv;
|
|
348
381
|
const fn = commands[cmd === '--version' || cmd === '-v' ? 'version' : cmd === '--help' || cmd === '-h' ? 'help' : cmd];
|