@ignix-social/editor 0.1.11 → 0.1.13
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 +28 -13
- package/src/creator/job.mjs +48 -3
package/package.json
CHANGED
package/src/analysis/review.mjs
CHANGED
|
@@ -40,6 +40,18 @@ 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
|
+
* Sin calibrar con vídeos reales todavía: por eso más holgado.
|
|
47
|
+
*/
|
|
48
|
+
const WIDE = {
|
|
49
|
+
stillFailSec: 6,
|
|
50
|
+
gapWarnSec: 1,
|
|
51
|
+
captionBand: { y0: 0.8, y1: 0.98 },
|
|
52
|
+
safe: { x0: 0.05, x1: 0.95, y0: 0.06, y1: 0.92 },
|
|
53
|
+
};
|
|
54
|
+
|
|
43
55
|
function gray(rgb, n) {
|
|
44
56
|
const g = new Uint8Array(n);
|
|
45
57
|
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 +82,12 @@ function background(rgb, w, h) {
|
|
|
70
82
|
}
|
|
71
83
|
|
|
72
84
|
/** Qué parte de la zona segura ocupa algo distinto del fondo (0..1). */
|
|
73
|
-
export function fillOf(rgb, w, h) {
|
|
85
|
+
export function fillOf(rgb, w, h, safe = SAFE) {
|
|
74
86
|
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(
|
|
87
|
+
const x0 = Math.round(safe.x0 * w);
|
|
88
|
+
const x1 = Math.round(safe.x1 * w);
|
|
89
|
+
const y0 = Math.round(safe.y0 * h);
|
|
90
|
+
const y1 = Math.round(safe.y1 * h);
|
|
79
91
|
let on = 0;
|
|
80
92
|
let n = 0;
|
|
81
93
|
for (let y = y0; y < y1; y++) {
|
|
@@ -129,6 +141,9 @@ const r1 = (x) => Math.round(x * 10) / 10;
|
|
|
129
141
|
export async function reviewVideo({ ffmpeg, ffprobe, path, log = () => {} }) {
|
|
130
142
|
const info = probe(ffprobe, path);
|
|
131
143
|
if (!info.hasVideo || !(info.duration > 0)) throw new Error(`${path} has no video`);
|
|
144
|
+
const wide = info.width > info.height;
|
|
145
|
+
const band = wide ? WIDE.captionBand : CAPTION_BAND;
|
|
146
|
+
const stillFail = wide ? WIDE.stillFailSec : STILL_FAIL_SEC;
|
|
132
147
|
const steps = [];
|
|
133
148
|
const fills = [];
|
|
134
149
|
let prev = null;
|
|
@@ -143,14 +158,14 @@ export async function reviewVideo({ ffmpeg, ffprobe, path, log = () => {} }) {
|
|
|
143
158
|
const n = f.w * f.h;
|
|
144
159
|
const g = gray(f.rgb, n);
|
|
145
160
|
if (prev) {
|
|
146
|
-
const c0 = Math.round(
|
|
147
|
-
const c1 = Math.round(
|
|
161
|
+
const c0 = Math.round(band.y0 * f.h) * f.w;
|
|
162
|
+
const c1 = Math.round(band.y1 * f.h) * f.w;
|
|
148
163
|
let c = 0;
|
|
149
164
|
for (let i = 0; i < n; i++) if ((i < c0 || i >= c1) && Math.abs(g[i] - prev[i]) > 16) c++;
|
|
150
165
|
steps.push({ t: f.t, frac: c / (n - (c1 - c0)) });
|
|
151
166
|
}
|
|
152
167
|
prev = g;
|
|
153
|
-
if (k++ % 5 === 0) fills.push(fillOf(f.rgb, f.w, f.h));
|
|
168
|
+
if (k++ % 5 === 0) fills.push(fillOf(f.rgb, f.w, f.h, wide ? WIDE.safe : SAFE));
|
|
154
169
|
},
|
|
155
170
|
});
|
|
156
171
|
const windowSec = 0.05;
|
|
@@ -159,11 +174,11 @@ export async function reviewVideo({ ffmpeg, ffprobe, path, log = () => {} }) {
|
|
|
159
174
|
const m = await measureVideo({ ffmpeg, ffprobe, path, log: () => {} }).catch(() => null);
|
|
160
175
|
|
|
161
176
|
// La última quietud, si llega al final, es la tarjeta de cierre: se le deja más.
|
|
162
|
-
const stills = stillRuns(steps,
|
|
177
|
+
const stills = stillRuns(steps, stillFail).filter(
|
|
163
178
|
(s) => !(info.duration - s.end < 0.3 && s.end - s.start < END_HOLD_SEC),
|
|
164
179
|
);
|
|
165
180
|
const fill = fills.length ? fills.reduce((a, b) => a + b, 0) / fills.length : null;
|
|
166
|
-
const gaps = db ? speechGaps(db, windowSec, GAP_WARN_SEC) : [];
|
|
181
|
+
const gaps = db ? speechGaps(db, windowSec, wide ? WIDE.gapWarnSec : GAP_WARN_SEC) : [];
|
|
167
182
|
const movingShare = steps.length ? steps.filter((s) => s.frac >= MOVE_FRAC).length / steps.length : null;
|
|
168
183
|
|
|
169
184
|
const issues = [];
|
|
@@ -172,14 +187,14 @@ export async function reviewVideo({ ffmpeg, ffprobe, path, log = () => {} }) {
|
|
|
172
187
|
level: info.duration > LONG_SEC ? 'warn' : 'fail',
|
|
173
188
|
code: 'still',
|
|
174
189
|
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.`,
|
|
190
|
+
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
191
|
});
|
|
177
192
|
}
|
|
178
193
|
if (fill !== null && fill < FILL_WARN) {
|
|
179
194
|
issues.push({
|
|
180
195
|
level: fill < FILL_FAIL ? 'fail' : 'warn',
|
|
181
196
|
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
|
|
197
|
+
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
198
|
});
|
|
184
199
|
}
|
|
185
200
|
for (const gp of gaps) {
|
|
@@ -187,7 +202,7 @@ export async function reviewVideo({ ffmpeg, ffprobe, path, log = () => {} }) {
|
|
|
187
202
|
level: 'warn',
|
|
188
203
|
code: 'speech_gap',
|
|
189
204
|
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).`,
|
|
205
|
+
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${wide ? '; ~0.8 s only at a chapter change' : ''}).`,
|
|
191
206
|
});
|
|
192
207
|
}
|
|
193
208
|
// Calibrado con los reels de Fran (2026-10-06): todos los buenos cortan 3,5-10 veces por
|
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);
|