@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ignix-social/editor",
3
- "version": "0.1.11",
3
+ "version": "0.1.13",
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,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(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);
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(CAPTION_BAND.y0 * f.h) * f.w;
147
- const c1 = Math.round(CAPTION_BAND.y1 * f.h) * f.w;
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, STILL_FAIL_SEC).filter(
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
@@ -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);