@ignix-social/editor 0.1.23 → 0.1.24

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.23",
3
+ "version": "0.1.24",
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",
@@ -13,7 +13,7 @@ Commands. `ignix-editor` means the copy the Ignix playbook installed: `~/.ignix-
13
13
  |---|---|
14
14
  | `ignix-editor doctor` | Node 22+ and the H.264 encoder; downloads its own ffmpeg and whisper the first time. Run it first. |
15
15
  | `ignix-editor init <name>` | Creates `~/ignix-videos/<name>` with a working skeleton, GSAP, local fonts and `ignix-frame.js`. |
16
- | `ignix-editor transcribe <file> --lang es [--script "…"] -o words.json` | Words with start/end seconds, on this machine. |
16
+ | `ignix-editor transcribe <file> --lang es [--script "…"] -o words.json` | Words with start/end seconds, plus `pauses` ([start, end] of every silence ≥0.35 s, for cutting silences), on this machine. Over 2 min it prints whisper's progress; without a GPU it takes about as long as the audio. |
17
17
  | `ignix-editor faces <video> -o work/faces.json` | Where the face is over time (macOS and Windows; the first run downloads the face detector): a smoothed path `{t, cx, cy, size}` (0–1, top-left origin) to frame the person. |
18
18
  | `ignix-editor pick-audio [dir] --kind music\|sfx --query "inspiring" [--order trending]` | Music (or a specific sound effect) from Pixabay, chosen by the USER: opens Pixabay's search in their browser, waits for their download, copies it into `assets/` and notes its source in `work/audio-sources.json`. Tell the user first that you opened Pixabay for them to pick one. Never fetch pixabay.com yourself: it blocks robots. |
19
19
  | `ignix-editor giphy --search "thumbs up" [--kind gifs\|stickers\|clips]` · `ignix-editor giphy <link> [dir]` | GIFs, stickers and clips from GIPHY, chosen by the USER: the first opens GIPHY's search in their browser; they copy the link of the one they like (Share → Copy link) and the second brings it into `assets/`: a GIF as MP4, a clip as MP4 with its audio, a sticker as a transparent frame sheet (see section 2, GIPHY stickers). |
package/src/cli.mjs CHANGED
@@ -12,7 +12,7 @@ import { HOME, PLATFORM, TOOLS, ensureTool, ensureWhisperModel, findTool } from
12
12
  import { linkMedia, prepare, projectHtml } from './prepare.mjs';
13
13
  import { validateHtml } from './validate.mjs';
14
14
  import { engineEnv, renderProject, runEngine } from './engine.mjs';
15
- import { alignToScript, transcribe } from './transcribe.mjs';
15
+ import { alignToScript, fitToPauses, transcribe } from './transcribe.mjs';
16
16
  import { faces as detectFaces } from './analysis/detect.mjs';
17
17
  import { frames, probe } from './analysis/media.mjs';
18
18
  import { serve } from './analysis/serve.mjs';
@@ -215,16 +215,21 @@ const commands = {
215
215
  const ffprobe = await ensureTool('ffprobe', log);
216
216
  const whisper = await ensureTool('whisper', log);
217
217
  const model = await ensureWhisperModel(opts.model ?? 'medium', log);
218
- let words = transcribe({ ffmpeg, whisper, model, input, language: opts.lang ?? 'es', threads: opts.threads });
219
- if (opts.script) {
220
- const dur = Number(spawnSync(ffprobe, ['-v', 'error', '-show_entries', 'format=duration', '-of', 'csv=p=0', input], { encoding: 'utf8' }).stdout.trim());
221
- words = alignToScript(opts.script, words, dur);
222
- }
223
- const json = JSON.stringify({ language: opts.lang ?? 'es', words }, null, 1);
218
+ const dur = Number(spawnSync(ffprobe, ['-v', 'error', '-show_entries', 'format=duration', '-of', 'csv=p=0', input], { encoding: 'utf8' }).stdout.trim()) || 0;
219
+ const mmss = (x) => `${Math.floor(x / 60)}:${String(Math.round(x % 60)).padStart(2, '0')}`;
220
+ const t0 = Date.now();
221
+ // Aviso de duración ANTES de empezar: sin GPU, una charla larga tarda más o menos lo que dura.
222
+ if (dur > 120) log(`Transcribing ${mmss(dur)} of audio with the ${basename(model).replace(/^ggml-|\.bin$/g, '')} model. Without a GPU this can take about as long as the audio itself; whisper prints its progress below.`);
223
+ const r = transcribe({ ffmpeg, whisper, model, input, language: opts.lang ?? 'es', threads: opts.threads, progress: dur > 120 });
224
+ let words = r.words;
225
+ if (opts.script) words = alignToScript(opts.script, words, dur);
226
+ words = fitToPauses(words, r.pauses);
227
+ const json = JSON.stringify({ language: opts.lang ?? 'es', duration: +dur.toFixed(3), words, pauses: r.pauses }, null, 1);
224
228
  if (opts.output) {
225
229
  mkdirSync(dirname(resolve(opts.output)), { recursive: true });
226
230
  writeFileSync(resolve(opts.output), json);
227
231
  } else console.log(json);
232
+ log(`✓ ${words.length} words and ${r.pauses.length} pauses (≥0.35 s) in ${mmss((Date.now() - t0) / 1000)}${opts.output ? ` → ${opts.output}` : ''}`);
228
233
  },
229
234
 
230
235
  // Música o efectos de Pixabay elegidos por la persona (src/pick-audio.mjs): abre la
@@ -296,7 +301,7 @@ const commands = {
296
301
  if (r.sheet) {
297
302
  const sh = r.sheet;
298
303
  log(`✓ sticker → ${r.file}: ${sh.frames} frames in a ${sh.cols}×${sh.rows} transparent sheet, ${sh.frameWidth}×${sh.frameHeight} each, ${sh.fps} fps (${sh.durationSec} s loop). Animate it from t as the skill shows (GIPHY section).`);
299
- } else log(`✓ ${r.kind} → ${r.file}${r.hasAudio ? ' (with its audio)' : ''}`);
304
+ } else log(`✓ ${r.kind} → ${r.file}${r.kind === 'clip' ? (r.hasAudio ? ' (with its audio)' : ' (this clip has no sound: treat it as a muted clip)') : ''}`);
300
305
  if (opts.json) console.log(JSON.stringify(r));
301
306
  },
302
307
 
package/src/giphy.mjs CHANGED
@@ -67,7 +67,8 @@ export function giphySearchUrl(query, kind = 'gifs') {
67
67
 
68
68
  /** Qué fichero traer de media.giphy.com para cada tipo (y el de reserva). */
69
69
  export function giphyRenditions(kind) {
70
- if (kind === 'clip') return [['giphy480p.mp4', 'mp4'], ['giphy.mp4', 'mp4']];
70
+ // El de 360p es el que lleva el audio; giphy480p.mp4 dejó de servirse (404, 2026-10-08).
71
+ if (kind === 'clip') return [['giphy360p.mp4', 'mp4'], ['giphy.mp4', 'mp4']];
71
72
  if (kind === 'sticker') return [['giphy.gif', 'gif']];
72
73
  return [['giphy.mp4', 'mp4'], ['giphy.gif', 'gif']];
73
74
  }
@@ -93,21 +94,21 @@ export function gifToSheet({ ffmpeg, ffprobe, gif, out }) {
93
94
 
94
95
  /**
95
96
  * El tipo cuando el enlace no lo dice (el del ARCHIVO, …/giphy.gif): con él un clip se traía
96
- * como GIF, sin audio. Solo los clips tienen giphy480p.mp4, y la página dice si es sticker.
97
+ * como GIF, sin audio. La página lo dice: los clips traen su bloque "video" y los stickers
98
+ * is_sticker.
97
99
  */
98
100
  export async function resolveGiphyKind(id, fromLink) {
99
101
  if (fromLink) return fromLink;
100
102
  if (!ID.test(id)) return 'gif';
101
- const clip = await fetch(`https://media.giphy.com/media/${id}/giphy480p.mp4`, { method: 'HEAD', signal: AbortSignal.timeout(8000) })
102
- .then((r) => r.ok)
103
- .catch(() => false);
104
- if (clip) return 'clip';
105
103
  const page = await fetch(`https://giphy.com/gifs/${id}`, { headers: { 'user-agent': 'Mozilla/5.0 (compatible; IgnixEditor)' }, signal: AbortSignal.timeout(8000) })
106
104
  .then((r) => (r.ok ? r.text() : ''))
107
105
  .catch(() => '');
108
106
  const html = page.replace(/\\"/g, '"');
109
107
  const at = html.indexOf(`"id":"${id}"`);
110
- return at >= 0 && /"is_sticker":true/.test(html.slice(at, at + 8000)) ? 'sticker' : 'gif';
108
+ if (at < 0) return 'gif';
109
+ const next = html.slice(at + 10).search(/"id":"[A-Za-z0-9]{8,40}"/);
110
+ const own = html.slice(at, next < 0 ? at + 8000 : at + 10 + next);
111
+ return /"video":\{/.test(own) ? 'clip' : /"is_sticker":true/.test(own) ? 'sticker' : 'gif';
111
112
  }
112
113
 
113
114
  /** Trae el elegido a `<project>/assets/` y apunta su origen en `work/media-sources.json`. */
@@ -143,7 +144,12 @@ export async function importGiphy({ link, project, kind: forced, ffmpeg, ffprobe
143
144
  /* fichero roto: se empieza de nuevo */
144
145
  }
145
146
  writeFileSync(log, JSON.stringify([...prev, { source: 'giphy', kind, id: parsed.id, link: String(link), file: dest, at: new Date().toISOString() }], null, 2));
146
- return { id: parsed.id, kind, file: dest, hasAudio: kind === 'clip' && file.includes('480p'), ...(sheet ? { sheet } : {}) };
147
+ // Audio de verdad, no supuesto: hay clips mudos («Take My Money»).
148
+ const hasAudio =
149
+ kind === 'clip' && ffprobe
150
+ ? spawnSync(ffprobe, ['-v', 'error', '-select_streams', 'a', '-show_entries', 'stream=index', '-of', 'csv=p=0', dest], { encoding: 'utf8' }).stdout.trim() !== ''
151
+ : false;
152
+ return { id: parsed.id, kind, file: dest, hasAudio, ...(sheet ? { sheet } : {}) };
147
153
  }
148
154
  throw new Error(`Could not download that ${kind} from GIPHY${lastErr ? `: ${lastErr.message}` : ''}.`);
149
155
  }
@@ -46,19 +46,60 @@ function whisperTimeoutMs(wavPath) {
46
46
  } catch {
47
47
  /* sin tamaño: el mínimo */
48
48
  }
49
- return Math.max(10 * 60_000, seconds * 6 * 1000);
49
+ // ENTERO: spawnSync rechaza un timeout con decimales («The value of "timeout" is out of
50
+ // range… Received 13158197.625») y eso rompía cualquier audio de más de 100 s (2026-10-08).
51
+ return Math.ceil(Math.max(10 * 60_000, seconds * 6 * 1000));
52
+ }
53
+
54
+ /**
55
+ * Pausas del audio (ffmpeg silencedetect): [[inicio, fin], …] en segundos. Sirven para el
56
+ * paso de silencios y para que ninguna palabra se estire sobre una pausa: whisper con -ml 1
57
+ * hace que cada palabra acabe donde empieza la siguiente («claro.» de 0,76 s, «1.600,» de 1,6 s).
58
+ */
59
+ export function detectPauses(ffmpeg, input, { noiseDb = -35, minSec = 0.35 } = {}) {
60
+ const r = spawnSync(ffmpeg, ['-nostdin', '-hide_banner', '-i', input, '-vn', '-af', `silencedetect=noise=${noiseDb}dB:d=${minSec}`, '-f', 'null', '-'], { encoding: 'utf8', maxBuffer: 64 * 1024 * 1024 });
61
+ const out = [];
62
+ let start = null;
63
+ for (const line of (r.stderr || '').split('\n')) {
64
+ const a = line.match(/silence_start: (-?[\d.]+)/);
65
+ if (a) start = Math.max(0, Number(a[1]));
66
+ const b = line.match(/silence_end: ([\d.]+)/);
67
+ if (b && start !== null) {
68
+ out.push([+start.toFixed(3), +Number(b[1]).toFixed(3)]);
69
+ start = null;
70
+ }
71
+ }
72
+ return out;
73
+ }
74
+
75
+ /** Recorta cada palabra a la voz: ni empieza ni acaba dentro de una pausa (si le queda voz). */
76
+ export function fitToPauses(words, pauses) {
77
+ if (!pauses?.length) return words;
78
+ return words.map((w) => {
79
+ let { s, e } = w;
80
+ for (const [ps, pe] of pauses) {
81
+ if (ps >= e || pe <= s) continue;
82
+ if (ps > s && ps - s >= 0.08) e = Math.min(e, ps); // se estiraba sobre la pausa
83
+ else if (pe < e && e - pe >= 0.08) s = Math.max(s, pe); // empezaba dentro de la pausa
84
+ }
85
+ return { ...w, s: +s.toFixed(3), e: +e.toFixed(3) };
86
+ });
50
87
  }
51
88
 
52
89
  function whisperFailure(w, timeoutMs) {
53
90
  if (w.error?.code === 'ETIMEDOUT') {
54
91
  return new Error(`whisper did not finish in ${Math.round(timeoutMs / 60_000)} min and was stopped. Close other heavy programs and run the command again, or use a smaller model (--model small).`);
55
92
  }
56
- return new Error(`whisper failed: ${(w.stderr || '').slice(-400)}`);
93
+ return new Error(`whisper failed (exit ${w.status ?? w.error?.code ?? '?'}): ${(w.stderr || '').slice(-400)}`);
57
94
  }
58
95
 
59
96
  const norm = (s) => s.toLowerCase().normalize('NFD').replace(/[̀-ͯ]/g, '').replace(/[^\p{L}\p{N}]/gu, '');
60
97
 
61
- export function transcribe({ ffmpeg, whisper, model, input, language = 'es', threads }) {
98
+ /**
99
+ * Palabras y pausas. Con `progress`, whisper escribe su avance («progress = 40%») en la
100
+ * consola: en una transcripción larga, sin eso nadie distingue «va lento» de «se ha colgado».
101
+ */
102
+ export function transcribe({ ffmpeg, whisper, model, input, language = 'es', threads, progress = false }) {
62
103
  const ws = whisperWorkspace(model);
63
104
  const tmp = ws.tmp;
64
105
  try {
@@ -67,8 +108,10 @@ export function transcribe({ ffmpeg, whisper, model, input, language = 'es', thr
67
108
  if (a.status !== 0) throw new Error(`Could not read the audio of ${input}: ${a.stderr.slice(-300)}`);
68
109
  const out = join(tmp, 'out');
69
110
  const args = ['-m', ws.modelArg, '-l', language, '-ml', '1', '-sow', '-oj', '-of', ws.arg(out), '-t', String(whisperThreads(threads)), '-f', ws.arg(wav)];
111
+ if (progress) args.unshift('-np', '-pp');
70
112
  const timeout = whisperTimeoutMs(wav);
71
- const run = (extra) => spawnSync(whisper, [...extra, ...args], { encoding: 'utf8', maxBuffer: 64 * 1024 * 1024, cwd: ws.cwd, timeout });
113
+ const stdio = progress ? ['ignore', 'pipe', 'inherit'] : undefined;
114
+ const run = (extra) => spawnSync(whisper, [...extra, ...args], { encoding: 'utf8', maxBuffer: 64 * 1024 * 1024, cwd: ws.cwd, timeout, stdio });
72
115
  // En Mac Intel la GPU (Metal) se cuelga con whisper: CPU directamente. En el resto, GPU y,
73
116
  // si falla, otra vez por CPU.
74
117
  const intelMac = process.platform === 'darwin' && process.arch === 'x64';
@@ -82,7 +125,7 @@ export function transcribe({ ffmpeg, whisper, model, input, language = 'es', thr
82
125
  if (!t || /^\[.*\]$/.test(t)) continue; // [MÚSICA], [MUSIC]…
83
126
  words.push({ t, s: seg.offsets.from / 1000, e: seg.offsets.to / 1000 });
84
127
  }
85
- return words;
128
+ return { words, pauses: detectPauses(ffmpeg, wav) };
86
129
  } finally {
87
130
  rmSync(tmp, { recursive: true, force: true });
88
131
  }