@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ignix-social/editor",
3
- "version": "0.1.11",
3
+ "version": "0.1.14",
4
4
  "description": "Ignix Editor: render HTML video compositions (reels, edits, clips) on your own computer. Based on HyperFrames.",
5
5
  "license": "Apache-2.0",
6
6
  "type": "module",
@@ -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(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);
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(CAPTION_BAND.y0 * f.h) * f.w;
147
- const c1 = Math.round(CAPTION_BAND.y1 * f.h) * f.w;
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, STILL_FAIL_SEC).filter(
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 {
@@ -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}, output {file}, done, error {code}. SIGTERM, o perder
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);