@ignix-social/editor 0.1.11 → 0.1.14
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/src/analysis/review.mjs +31 -14
- package/src/creator/job.mjs +48 -3
package/package.json
CHANGED
package/src/analysis/review.mjs
CHANGED
|
@@ -40,6 +40,20 @@ const SILENCE_DB = -45;
|
|
|
40
40
|
// ~250 px de arriba ni los ~450 de abajo.
|
|
41
41
|
const SAFE = { x0: 0.12, x1: 0.88, y0: 0.13, y1: 0.765 };
|
|
42
42
|
|
|
43
|
+
/**
|
|
44
|
+
* Lo que cambia en un vídeo HORIZONTAL (YouTube, 2026-10-06): su manual pide un cambio cada
|
|
45
|
+
* ~3-5 s (no 1-1,5), subtítulos abajo del todo y ~0,8 s de aire al cambiar de capítulo.
|
|
46
|
+
* Calibrado con un youtuber que Fran da por bueno (36 min, 2026-10-06): plano al 89 %, quietud
|
|
47
|
+
* máxima 4,1 s, 8 cortes/min y ~26 pausas naturales de 1-2 s al hablar → los huecos avisan desde
|
|
48
|
+
* 2,5 s, y «sin efectos» no se dice (bajo una voz continua no se distinguen).
|
|
49
|
+
*/
|
|
50
|
+
const WIDE = {
|
|
51
|
+
stillFailSec: 6,
|
|
52
|
+
gapWarnSec: 2.5,
|
|
53
|
+
captionBand: { y0: 0.8, y1: 0.98 },
|
|
54
|
+
safe: { x0: 0.05, x1: 0.95, y0: 0.06, y1: 0.92 },
|
|
55
|
+
};
|
|
56
|
+
|
|
43
57
|
function gray(rgb, n) {
|
|
44
58
|
const g = new Uint8Array(n);
|
|
45
59
|
for (let i = 0; i < n; i++) g[i] = (rgb[i * 3] * 77 + rgb[i * 3 + 1] * 150 + rgb[i * 3 + 2] * 29) >> 8;
|
|
@@ -70,12 +84,12 @@ function background(rgb, w, h) {
|
|
|
70
84
|
}
|
|
71
85
|
|
|
72
86
|
/** Qué parte de la zona segura ocupa algo distinto del fondo (0..1). */
|
|
73
|
-
export function fillOf(rgb, w, h) {
|
|
87
|
+
export function fillOf(rgb, w, h, safe = SAFE) {
|
|
74
88
|
const [br, bg, bb] = background(rgb, w, h);
|
|
75
|
-
const x0 = Math.round(
|
|
76
|
-
const x1 = Math.round(
|
|
77
|
-
const y0 = Math.round(
|
|
78
|
-
const y1 = Math.round(
|
|
89
|
+
const x0 = Math.round(safe.x0 * w);
|
|
90
|
+
const x1 = Math.round(safe.x1 * w);
|
|
91
|
+
const y0 = Math.round(safe.y0 * h);
|
|
92
|
+
const y1 = Math.round(safe.y1 * h);
|
|
79
93
|
let on = 0;
|
|
80
94
|
let n = 0;
|
|
81
95
|
for (let y = y0; y < y1; y++) {
|
|
@@ -129,6 +143,9 @@ const r1 = (x) => Math.round(x * 10) / 10;
|
|
|
129
143
|
export async function reviewVideo({ ffmpeg, ffprobe, path, log = () => {} }) {
|
|
130
144
|
const info = probe(ffprobe, path);
|
|
131
145
|
if (!info.hasVideo || !(info.duration > 0)) throw new Error(`${path} has no video`);
|
|
146
|
+
const wide = info.width > info.height;
|
|
147
|
+
const band = wide ? WIDE.captionBand : CAPTION_BAND;
|
|
148
|
+
const stillFail = wide ? WIDE.stillFailSec : STILL_FAIL_SEC;
|
|
132
149
|
const steps = [];
|
|
133
150
|
const fills = [];
|
|
134
151
|
let prev = null;
|
|
@@ -143,14 +160,14 @@ export async function reviewVideo({ ffmpeg, ffprobe, path, log = () => {} }) {
|
|
|
143
160
|
const n = f.w * f.h;
|
|
144
161
|
const g = gray(f.rgb, n);
|
|
145
162
|
if (prev) {
|
|
146
|
-
const c0 = Math.round(
|
|
147
|
-
const c1 = Math.round(
|
|
163
|
+
const c0 = Math.round(band.y0 * f.h) * f.w;
|
|
164
|
+
const c1 = Math.round(band.y1 * f.h) * f.w;
|
|
148
165
|
let c = 0;
|
|
149
166
|
for (let i = 0; i < n; i++) if ((i < c0 || i >= c1) && Math.abs(g[i] - prev[i]) > 16) c++;
|
|
150
167
|
steps.push({ t: f.t, frac: c / (n - (c1 - c0)) });
|
|
151
168
|
}
|
|
152
169
|
prev = g;
|
|
153
|
-
if (k++ % 5 === 0) fills.push(fillOf(f.rgb, f.w, f.h));
|
|
170
|
+
if (k++ % 5 === 0) fills.push(fillOf(f.rgb, f.w, f.h, wide ? WIDE.safe : SAFE));
|
|
154
171
|
},
|
|
155
172
|
});
|
|
156
173
|
const windowSec = 0.05;
|
|
@@ -159,11 +176,11 @@ export async function reviewVideo({ ffmpeg, ffprobe, path, log = () => {} }) {
|
|
|
159
176
|
const m = await measureVideo({ ffmpeg, ffprobe, path, log: () => {} }).catch(() => null);
|
|
160
177
|
|
|
161
178
|
// La última quietud, si llega al final, es la tarjeta de cierre: se le deja más.
|
|
162
|
-
const stills = stillRuns(steps,
|
|
179
|
+
const stills = stillRuns(steps, stillFail).filter(
|
|
163
180
|
(s) => !(info.duration - s.end < 0.3 && s.end - s.start < END_HOLD_SEC),
|
|
164
181
|
);
|
|
165
182
|
const fill = fills.length ? fills.reduce((a, b) => a + b, 0) / fills.length : null;
|
|
166
|
-
const gaps = db ? speechGaps(db, windowSec, GAP_WARN_SEC) : [];
|
|
183
|
+
const gaps = db ? speechGaps(db, windowSec, wide ? WIDE.gapWarnSec : GAP_WARN_SEC) : [];
|
|
167
184
|
const movingShare = steps.length ? steps.filter((s) => s.frac >= MOVE_FRAC).length / steps.length : null;
|
|
168
185
|
|
|
169
186
|
const issues = [];
|
|
@@ -172,14 +189,14 @@ export async function reviewVideo({ ffmpeg, ffprobe, path, log = () => {} }) {
|
|
|
172
189
|
level: info.duration > LONG_SEC ? 'warn' : 'fail',
|
|
173
190
|
code: 'still',
|
|
174
191
|
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.`,
|
|
192
|
+
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 ${wide ? '3-5' : '1-1.5'} s.`,
|
|
176
193
|
});
|
|
177
194
|
}
|
|
178
195
|
if (fill !== null && fill < FILL_WARN) {
|
|
179
196
|
issues.push({
|
|
180
197
|
level: fill < FILL_FAIL ? 'fail' : 'warn',
|
|
181
198
|
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
|
|
199
|
+
text: `The frame is mostly empty: only ${Math.round(fill * 100)} % of the safe area has something on it. ${wide ? 'Make the scene fill the frame (full-frame scenes, footage, big recreated screens).' : 'Make the stage (phone, screen, card, image) about 85 % of the safe column\'s width and most of its height.'}`,
|
|
183
200
|
});
|
|
184
201
|
}
|
|
185
202
|
for (const gp of gaps) {
|
|
@@ -187,7 +204,7 @@ export async function reviewVideo({ ffmpeg, ffprobe, path, log = () => {} }) {
|
|
|
187
204
|
level: 'warn',
|
|
188
205
|
code: 'speech_gap',
|
|
189
206
|
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)
|
|
207
|
+
text: `${r1(gp.end - gp.start)} s of silence between two voice lines at ${r1(gp.start)} s. ${wide ? 'Cut the pause, or keep it only where it lands a point or changes chapter.' : 'Lay the lines back to back (≤ 0.3 s).'}`,
|
|
191
208
|
});
|
|
192
209
|
}
|
|
193
210
|
// Calibrado con los reels de Fran (2026-10-06): todos los buenos cortan 3,5-10 veces por
|
|
@@ -196,7 +213,7 @@ export async function reviewVideo({ ffmpeg, ffprobe, path, log = () => {} }) {
|
|
|
196
213
|
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
214
|
}
|
|
198
215
|
const sfx = m?.audio?.sound?.sfxPerMinute;
|
|
199
|
-
if (sfx === 0 && info.duration > 8) {
|
|
216
|
+
if (sfx === 0 && info.duration > 8 && !wide) {
|
|
200
217
|
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
218
|
}
|
|
202
219
|
return {
|
package/src/creator/job.mjs
CHANGED
|
@@ -8,11 +8,13 @@
|
|
|
8
8
|
// → <dir>/video.mp4 (composición) o <dir>/t<n>_<k>.jpg (miniaturas)
|
|
9
9
|
//
|
|
10
10
|
// Informa por stdout con líneas JSON cerradas: progress {stage, fraction}, verified
|
|
11
|
-
// {durationSec, width, height, audio},
|
|
11
|
+
// {durationSec, width, height, audio}, reviewed {ready, issues: [{level, code, at?}]} (0.1.12),
|
|
12
|
+
// output {file}, done, error {code}. SIGTERM, o perder
|
|
12
13
|
// el proceso padre, cancela.
|
|
13
|
-
import { spawnSync } from 'node:child_process';
|
|
14
|
+
import { spawn, spawnSync } from 'node:child_process';
|
|
14
15
|
import { existsSync, lstatSync, mkdirSync, mkdtempSync, readFileSync, renameSync, rmSync } from 'node:fs';
|
|
15
|
-
import { basename, isAbsolute, join, relative } from 'node:path';
|
|
16
|
+
import { basename, dirname, isAbsolute, join, relative } from 'node:path';
|
|
17
|
+
import { fileURLToPath } from 'node:url';
|
|
16
18
|
import { HOME, ensureTool } from '../binaries.mjs';
|
|
17
19
|
import { renderProject } from '../engine.mjs';
|
|
18
20
|
import { probe } from '../analysis/media.mjs';
|
|
@@ -124,6 +126,9 @@ async function composition(job, inputs, signal, browser, workers) {
|
|
|
124
126
|
const info = probe(ffprobe, out);
|
|
125
127
|
if (!info.hasVideo || info.duration <= 0) fail('verify_failed');
|
|
126
128
|
emit({ t: 'verified', durationSec: +info.duration.toFixed(3), width: info.width, height: info.height, audio: info.hasAudio });
|
|
129
|
+
if (signal.aborted) fail('cancelled');
|
|
130
|
+
const reviewed = await reviewOf(out, signal);
|
|
131
|
+
if (reviewed) emit(reviewed);
|
|
127
132
|
renameSync(out, join(job, 'video.mp4'));
|
|
128
133
|
} finally {
|
|
129
134
|
rmSync(dir, { recursive: true, force: true });
|
|
@@ -131,6 +136,46 @@ async function composition(job, inputs, signal, browser, workers) {
|
|
|
131
136
|
}
|
|
132
137
|
}
|
|
133
138
|
|
|
139
|
+
const CLI = join(dirname(fileURLToPath(import.meta.url)), '..', '..', 'bin', 'ignix-editor.mjs');
|
|
140
|
+
const ISSUE_CODES = new Set(['still', 'empty_frame', 'speech_gap', 'no_cuts', 'no_sfx']);
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* La misma revisión que hace `render` (quietud, plano vacío, huecos de voz, cortes, efectos), en
|
|
144
|
+
* un proceso aparte: carga onnxruntime, y este proceso termina con process.exit, que con
|
|
145
|
+
* onnxruntime cargado lo tumba. Si la revisión no puede correr, el vídeo sale igual, sin ella.
|
|
146
|
+
* Solo códigos y segundos: los textos los pone Creator en el idioma de quien lo usa.
|
|
147
|
+
*/
|
|
148
|
+
function reviewOf(file, signal) {
|
|
149
|
+
if (signal.aborted) return Promise.resolve(null);
|
|
150
|
+
return new Promise((resolve) => {
|
|
151
|
+
const p = spawn(process.execPath, [CLI, 'review', file, '--json'], { stdio: ['ignore', 'pipe', 'pipe'] });
|
|
152
|
+
let out = '';
|
|
153
|
+
let err = '';
|
|
154
|
+
p.stdout.on('data', (d) => (out += d));
|
|
155
|
+
p.stderr.on('data', (d) => (err = (err + d).slice(-400)));
|
|
156
|
+
const stop = () => p.kill('SIGKILL');
|
|
157
|
+
const timer = setTimeout(stop, 180_000);
|
|
158
|
+
signal.addEventListener('abort', stop, { once: true });
|
|
159
|
+
const finish = () => {
|
|
160
|
+
clearTimeout(timer);
|
|
161
|
+
signal.removeEventListener('abort', stop);
|
|
162
|
+
try {
|
|
163
|
+
const rv = JSON.parse(out);
|
|
164
|
+
const issues = (rv.issues ?? [])
|
|
165
|
+
.filter((i) => ISSUE_CODES.has(i.code) && (i.level === 'fail' || i.level === 'warn'))
|
|
166
|
+
.slice(0, 20)
|
|
167
|
+
.map((i) => ({ level: i.level, code: i.code, ...(Array.isArray(i.at) ? { at: [+i.at[0], +i.at[1]] } : {}) }));
|
|
168
|
+
resolve({ t: 'reviewed', ready: rv.ready === true, issues });
|
|
169
|
+
} catch {
|
|
170
|
+
if (!signal.aborted) process.stderr.write(`review skipped: ${err.trim()}\n`);
|
|
171
|
+
resolve(null);
|
|
172
|
+
}
|
|
173
|
+
};
|
|
174
|
+
p.on('close', finish);
|
|
175
|
+
p.on('error', finish);
|
|
176
|
+
});
|
|
177
|
+
}
|
|
178
|
+
|
|
134
179
|
export async function creatorJob(argv) {
|
|
135
180
|
const at = (k) => {
|
|
136
181
|
const i = argv.indexOf(k);
|