@ignix-social/editor 0.1.19 → 0.1.20

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.19",
3
+ "version": "0.1.20",
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",
package/src/cli.mjs CHANGED
@@ -1,6 +1,7 @@
1
1
  // ignix-editor — CLI de Ignix Editor. El motor (HyperFrames, en vendor/) corre siempre por
2
2
  // debajo con nuestro entorno: sin telemetría, sin actualizaciones solas, con nuestros binarios.
3
3
  // Los mensajes van en inglés, como el manual y la skill: los lee sobre todo el agente.
4
+ import './win-fs-preload.mjs'; // Windows: fs.cpSync con rutas no ASCII (win-fs.mjs)
4
5
  import { spawnSync } from 'node:child_process';
5
6
  import { cpSync, existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
6
7
  import { createRequire } from 'node:module';
@@ -213,7 +214,7 @@ const commands = {
213
214
  const ffprobe = await ensureTool('ffprobe', log);
214
215
  const whisper = await ensureTool('whisper', log);
215
216
  const model = await ensureWhisperModel(opts.model ?? 'medium', log);
216
- let words = transcribe({ ffmpeg, whisper, model, input, language: opts.lang ?? 'es', threads: Number(opts.threads ?? 8) });
217
+ let words = transcribe({ ffmpeg, whisper, model, input, language: opts.lang ?? 'es', threads: opts.threads });
217
218
  if (opts.script) {
218
219
  const dur = Number(spawnSync(ffprobe, ['-v', 'error', '-show_entries', 'format=duration', '-of', 'csv=p=0', input], { encoding: 'utf8' }).stdout.trim());
219
220
  words = alignToScript(opts.script, words, dur);
@@ -2,6 +2,7 @@
2
2
  // listo para renderizar: los medios en assets/ con sus tiempos escritos en el HTML (vídeos,
3
3
  // audios, imágenes), las fuentes, la tabla de anchos de carácter para ajustar textos y el
4
4
  // dibujante de las plantillas (runtime/creator.js). ADR-0023 E2.
5
+ import '../win-fs-preload.mjs'; // Windows: fs.cpSync con rutas no ASCII (win-fs.mjs)
5
6
  import { spawnSync } from 'node:child_process';
6
7
  import { cpSync, existsSync, mkdirSync, writeFileSync } from 'node:fs';
7
8
  import { dirname, extname, join } from 'node:path';
package/src/engine.mjs CHANGED
@@ -1,10 +1,11 @@
1
1
  // El motor (HyperFrames, en vendor/) con nuestro entorno: sin telemetría, sin actualizaciones
2
2
  // solas, con nuestros binarios; y el render completo de un proyecto (copia de trabajo, motor,
3
3
  // volumen a −14 LUFS). Lo usan el CLI y el servicio para Creator.
4
+ import './win-fs-preload.mjs'; // Windows: fs.cpSync con rutas no ASCII (win-fs.mjs)
4
5
  import { spawn, spawnSync } from 'node:child_process';
5
6
  import { cpSync, existsSync, mkdirSync, rmSync } from 'node:fs';
6
7
  import { dirname, join } from 'node:path';
7
- import { fileURLToPath } from 'node:url';
8
+ import { fileURLToPath, pathToFileURL } from 'node:url';
8
9
  import { HOME, PLATFORM, TOOLS, ensureTool, findTool } from './binaries.mjs';
9
10
  import { linkMedia, prepare } from './prepare.mjs';
10
11
  import { hasAudio, loudness, normalizeLoudness } from './audio.mjs';
@@ -22,6 +23,12 @@ export async function engineEnv(log = () => {}) {
22
23
  HYPERFRAMES_SKIP_SKILLS: '1',
23
24
  HYPERFRAMES_FONT_CACHE_DIR: join(HOME, 'cache', 'fonts'),
24
25
  };
26
+ // En Windows, el motor y todo proceso de Node que lance cargan antes el arreglo de
27
+ // fs.cpSync para rutas no ASCII (win-fs.mjs).
28
+ if (process.platform === 'win32') {
29
+ const preload = pathToFileURL(join(PKG_DIR, 'src', 'win-fs-preload.mjs')).href;
30
+ env.NODE_OPTIONS = `${env.NODE_OPTIONS ?? ''} --import="${preload}"`.trim();
31
+ }
25
32
  const ffmpeg = await ensureTool('ffmpeg', log);
26
33
  const ffprobe = await ensureTool('ffprobe', log);
27
34
  env.HYPERFRAMES_FFMPEG_PATH = ffmpeg;
package/src/giphy.mjs CHANGED
@@ -72,7 +72,6 @@ export function giphyRenditions(kind) {
72
72
  return [['giphy.mp4', 'mp4'], ['giphy.gif', 'gif']];
73
73
  }
74
74
 
75
- /** Trae el elegido a `<project>/assets/` y apunta su origen en `work/media-sources.json`. */
76
75
  /**
77
76
  * Un GIF con transparencia → una tira PNG (cols × rows fotogramas) + sus medidas.
78
77
  * `ffmpeg`/`ffprobe` son los del Editor.
@@ -92,10 +91,30 @@ export function gifToSheet({ ffmpeg, ffprobe, gif, out }) {
92
91
  return { sheet: out, frames, cols, rows, fps, durationSec: Math.round(durationSec * 100) / 100, frameWidth: Number(st.width), frameHeight: Number(st.height) };
93
92
  }
94
93
 
94
+ /**
95
+ * 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
+ */
98
+ export async function resolveGiphyKind(id, fromLink) {
99
+ if (fromLink) return fromLink;
100
+ 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
+ const page = await fetch(`https://giphy.com/gifs/${id}`, { headers: { 'user-agent': 'Mozilla/5.0 (compatible; IgnixEditor)' }, signal: AbortSignal.timeout(8000) })
106
+ .then((r) => (r.ok ? r.text() : ''))
107
+ .catch(() => '');
108
+ const html = page.replace(/\\"/g, '"');
109
+ const at = html.indexOf(`"id":"${id}"`);
110
+ return at >= 0 && /"is_sticker":true/.test(html.slice(at, at + 8000)) ? 'sticker' : 'gif';
111
+ }
112
+
113
+ /** Trae el elegido a `<project>/assets/` y apunta su origen en `work/media-sources.json`. */
95
114
  export async function importGiphy({ link, project, kind: forced, ffmpeg, ffprobe }) {
96
115
  const parsed = parseGiphyLink(link);
97
116
  if (!parsed) throw new Error('That is not a GIPHY link. Copy the link of the GIF, sticker or clip from giphy.com.');
98
- const kind = forced ?? parsed.kind ?? 'gif';
117
+ const kind = forced ?? (await resolveGiphyKind(parsed.id, parsed.kind));
99
118
  const assets = join(project, 'assets');
100
119
  mkdirSync(assets, { recursive: true });
101
120
  let lastErr = null;
package/src/prepare.mjs CHANGED
@@ -3,6 +3,7 @@
3
3
  // con 4 decimales, 97/30 = 3.2333 daba el fotograma 96 en vez del 97);
4
4
  // · ignix-frame.js de esta versión del editor, siempre al día.
5
5
  // El proyecto del usuario no se toca.
6
+ import './win-fs-preload.mjs'; // Windows: fs.cpSync con rutas no ASCII (win-fs.mjs)
6
7
  import { copyFileSync, cpSync, existsSync, linkSync, mkdirSync, readFileSync, readdirSync, realpathSync, rmSync, statSync, symlinkSync, writeFileSync } from 'node:fs';
7
8
  import { dirname, join } from 'node:path';
8
9
  import { fileURLToPath } from 'node:url';
@@ -4,7 +4,7 @@
4
4
  // y a veces se come el final de una frase.
5
5
  import { spawnSync } from 'node:child_process';
6
6
  import { mkdtempSync, readFileSync, rmSync, statSync } from 'node:fs';
7
- import { tmpdir } from 'node:os';
7
+ import { availableParallelism, tmpdir } from 'node:os';
8
8
  import { basename, dirname, join, relative } from 'node:path';
9
9
 
10
10
  /**
@@ -27,9 +27,38 @@ function whisperWorkspace(model) {
27
27
  return { tmp: mkdtempSync(join(tmpdir(), 'ignix-editor-asr-')), cwd: undefined, modelArg: model, arg: (p) => p };
28
28
  }
29
29
 
30
+ /**
31
+ * Hilos para whisper: nunca más que núcleos. Con más hilos que núcleos (p. ej. -t 8 en un
32
+ * portátil de 2 o 4) el whisper.cpp que publicamos (sin OpenMP) se queda bloqueado PARA
33
+ * SIEMPRE, sin error (Windows en CI y en una VM, 2026-10-08).
34
+ */
35
+ export function whisperThreads(requested) {
36
+ const cores = Math.max(1, availableParallelism());
37
+ const n = Number(requested);
38
+ return Math.max(1, Math.min(Number.isFinite(n) && n > 0 ? n : 8, cores));
39
+ }
40
+
41
+ /** Tiempo máximo de whisper: generoso (un PC lento va a ~1× tiempo real), pero nunca infinito. */
42
+ function whisperTimeoutMs(wavPath) {
43
+ let seconds = 0;
44
+ try {
45
+ seconds = Math.max(0, (statSync(wavPath).size - 44) / 32000);
46
+ } catch {
47
+ /* sin tamaño: el mínimo */
48
+ }
49
+ return Math.max(10 * 60_000, seconds * 6 * 1000);
50
+ }
51
+
52
+ function whisperFailure(w, timeoutMs) {
53
+ if (w.error?.code === 'ETIMEDOUT') {
54
+ 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
+ }
56
+ return new Error(`whisper failed: ${(w.stderr || '').slice(-400)}`);
57
+ }
58
+
30
59
  const norm = (s) => s.toLowerCase().normalize('NFD').replace(/[̀-ͯ]/g, '').replace(/[^\p{L}\p{N}]/gu, '');
31
60
 
32
- export function transcribe({ ffmpeg, whisper, model, input, language = 'es', threads = 8 }) {
61
+ export function transcribe({ ffmpeg, whisper, model, input, language = 'es', threads }) {
33
62
  const ws = whisperWorkspace(model);
34
63
  const tmp = ws.tmp;
35
64
  try {
@@ -37,14 +66,15 @@ export function transcribe({ ffmpeg, whisper, model, input, language = 'es', thr
37
66
  const a = spawnSync(ffmpeg, ['-nostdin', '-hide_banner', '-loglevel', 'error', '-y', '-i', input, '-vn', '-ar', '16000', '-ac', '1', '-c:a', 'pcm_s16le', wav], { encoding: 'utf8' });
38
67
  if (a.status !== 0) throw new Error(`Could not read the audio of ${input}: ${a.stderr.slice(-300)}`);
39
68
  const out = join(tmp, 'out');
40
- const args = ['-m', ws.modelArg, '-l', language, '-ml', '1', '-sow', '-oj', '-of', ws.arg(out), '-t', String(threads), '-f', ws.arg(wav)];
41
- const run = (extra) => spawnSync(whisper, [...extra, ...args], { encoding: 'utf8', maxBuffer: 64 * 1024 * 1024, cwd: ws.cwd });
69
+ const args = ['-m', ws.modelArg, '-l', language, '-ml', '1', '-sow', '-oj', '-of', ws.arg(out), '-t', String(whisperThreads(threads)), '-f', ws.arg(wav)];
70
+ const timeout = whisperTimeoutMs(wav);
71
+ const run = (extra) => spawnSync(whisper, [...extra, ...args], { encoding: 'utf8', maxBuffer: 64 * 1024 * 1024, cwd: ws.cwd, timeout });
42
72
  // En Mac Intel la GPU (Metal) se cuelga con whisper: CPU directamente. En el resto, GPU y,
43
73
  // si falla, otra vez por CPU.
44
74
  const intelMac = process.platform === 'darwin' && process.arch === 'x64';
45
75
  let w = run(intelMac ? ['-ng'] : []);
46
- if (w.status !== 0 && !intelMac) w = run(['-ng']);
47
- if (w.status !== 0) throw new Error(`whisper failed: ${(w.stderr || '').slice(-400)}`);
76
+ if (w.status !== 0 && !intelMac && w.error?.code !== 'ETIMEDOUT') w = run(['-ng']);
77
+ if (w.status !== 0) throw whisperFailure(w, timeout);
48
78
  const j = JSON.parse(readFileSync(`${out}.json`, 'utf8'));
49
79
  const words = [];
50
80
  for (const seg of j.transcription ?? []) {
@@ -95,7 +125,7 @@ export function alignToScript(script, tokens, end) {
95
125
  * Transcripción completa para Creator: idioma detectado (si no se da), cada palabra con su
96
126
  * confianza (media de sus tokens) y las frases (cortadas en un punto o en una pausa).
97
127
  */
98
- export function transcribeDetailed({ ffmpeg, whisper, model, input, language = null, threads = 8, maxWords = 6000, maxSegments = 800 }) {
128
+ export function transcribeDetailed({ ffmpeg, whisper, model, input, language = null, threads, maxWords = 6000, maxSegments = 800 }) {
99
129
  const ws = whisperWorkspace(model);
100
130
  const tmp = ws.tmp;
101
131
  try {
@@ -104,16 +134,17 @@ export function transcribeDetailed({ ffmpeg, whisper, model, input, language = n
104
134
  if (a.status !== 0) throw new Error(`Could not read the audio of ${input}: ${a.stderr.slice(-300)}`);
105
135
  const duration = statSync(wav).size > 44 ? (statSync(wav).size - 44) / 32000 : 0;
106
136
  const out = join(tmp, 'out');
107
- const args = ['-m', ws.modelArg, '-l', language || 'auto', '-ml', '1', '-sow', '-ojf', '-of', ws.arg(out), '-t', String(threads), '-f', ws.arg(wav)];
108
- const run = (extra) => spawnSync(whisper, [...extra, ...args], { encoding: 'utf8', maxBuffer: 64 * 1024 * 1024, cwd: ws.cwd });
137
+ const args = ['-m', ws.modelArg, '-l', language || 'auto', '-ml', '1', '-sow', '-ojf', '-of', ws.arg(out), '-t', String(whisperThreads(threads)), '-f', ws.arg(wav)];
138
+ const timeout = whisperTimeoutMs(wav);
139
+ const run = (extra) => spawnSync(whisper, [...extra, ...args], { encoding: 'utf8', maxBuffer: 64 * 1024 * 1024, cwd: ws.cwd, timeout });
109
140
  const intelMac = process.platform === 'darwin' && process.arch === 'x64';
110
141
  let w = run(intelMac ? ['-ng'] : []);
111
142
  let device = intelMac ? 'cpu' : 'gpu';
112
- if (w.status !== 0 && !intelMac) {
143
+ if (w.status !== 0 && !intelMac && w.error?.code !== 'ETIMEDOUT') {
113
144
  w = run(['-ng']);
114
145
  device = 'cpu';
115
146
  }
116
- if (w.status !== 0) throw new Error(`whisper failed: ${(w.stderr || '').slice(-400)}`);
147
+ if (w.status !== 0) throw whisperFailure(w, timeout);
117
148
  const j = JSON.parse(readFileSync(`${out}.json`, 'utf8'));
118
149
  const words = [];
119
150
  let aside = false; // dentro de una acotación: [MÚSICA], (upbeat music)…
@@ -0,0 +1,4 @@
1
+ // Se carga con `node --import` en el proceso del motor (ver win-fs.mjs).
2
+ import { installWindowsFsPatch } from './win-fs.mjs';
3
+
4
+ installWindowsFsPatch();
package/src/win-fs.mjs ADDED
@@ -0,0 +1,40 @@
1
+ // fs.cpSync de Node 22 en Windows (su versión nativa en C++) rompe con rutas fuera del
2
+ // ASCII: con «C:\Users\José María» el proceso muere en seco (0xC0000409) o «copia» a una
3
+ // ruta mal convertida y el fichero no aparece. Le pasa a `init`, a `skill` y al motor al
4
+ // compilar el proyecto para renderizar. En Windows se cambia por una copia en JavaScript
5
+ // (fichero a fichero, que sí respeta Unicode) para el Editor y, vía --import, para el motor.
6
+ import fs from 'node:fs';
7
+ import { syncBuiltinESMExports } from 'node:module';
8
+ import { dirname, join } from 'node:path';
9
+
10
+ /** cpSync en JavaScript: recursive, dereference, filter(src, dest) y force (por defecto sí). */
11
+ export function safeCpSync(src, dest, opts = {}) {
12
+ const { recursive = false, dereference = false, filter, force = true } = opts;
13
+ if (filter && !filter(String(src), String(dest))) return;
14
+ const st = dereference ? fs.statSync(src) : fs.lstatSync(src);
15
+ if (st.isDirectory()) {
16
+ if (!recursive) throw Object.assign(new Error(`Recursive option is required to copy a directory: ${src}`), { code: 'ERR_FS_EISDIR' });
17
+ fs.mkdirSync(dest, { recursive: true });
18
+ for (const name of fs.readdirSync(src)) safeCpSync(join(String(src), name), join(String(dest), name), opts);
19
+ return;
20
+ }
21
+ if (st.isSymbolicLink()) {
22
+ if (!force && fs.existsSync(dest)) return;
23
+ fs.rmSync(dest, { force: true });
24
+ fs.mkdirSync(dirname(String(dest)), { recursive: true });
25
+ fs.symlinkSync(fs.readlinkSync(src), dest);
26
+ return;
27
+ }
28
+ if (!force && fs.existsSync(dest)) return;
29
+ fs.mkdirSync(dirname(String(dest)), { recursive: true });
30
+ fs.copyFileSync(src, dest);
31
+ }
32
+
33
+ let installed = false;
34
+ /** En Windows, cambia fs.cpSync por safeCpSync también para quien lo importó por nombre. */
35
+ export function installWindowsFsPatch() {
36
+ if (installed || process.platform !== 'win32') return;
37
+ installed = true;
38
+ fs.cpSync = safeCpSync;
39
+ syncBuiltinESMExports();
40
+ }