@ignix-social/editor 0.1.18 → 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.18",
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,26 +4,77 @@
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';
8
- import { join } from 'node:path';
7
+ import { availableParallelism, tmpdir } from 'node:os';
8
+ import { basename, dirname, join, relative } from 'node:path';
9
+
10
+ /**
11
+ * Dónde trabaja whisper. En Windows, whisper.cpp NO abre rutas con caracteres fuera del ASCII
12
+ * (pasó con «C:\Users\José María»: el modelo, el audio y la salida cuelgan todos de esa
13
+ * carpeta, %TEMP% incluido). Así que se le ejecuta DESDE la carpeta del modelo y se le pasan
14
+ * solo rutas relativas, que son ASCII: el modelo por su nombre y el audio y la salida en una
15
+ * subcarpeta temporal de ahí. ffmpeg y el arranque del proceso sí entienden Unicode.
16
+ */
17
+ function whisperWorkspace(model) {
18
+ if (process.platform === 'win32') {
19
+ try {
20
+ const cwd = dirname(model);
21
+ const tmp = mkdtempSync(join(cwd, 'asr-'));
22
+ return { tmp, cwd, modelArg: basename(model), arg: (p) => relative(cwd, p) };
23
+ } catch {
24
+ /* carpeta del modelo sin permiso de escritura: como en el resto de sistemas */
25
+ }
26
+ }
27
+ return { tmp: mkdtempSync(join(tmpdir(), 'ignix-editor-asr-')), cwd: undefined, modelArg: model, arg: (p) => p };
28
+ }
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
+ }
9
58
 
10
59
  const norm = (s) => s.toLowerCase().normalize('NFD').replace(/[̀-ͯ]/g, '').replace(/[^\p{L}\p{N}]/gu, '');
11
60
 
12
- export function transcribe({ ffmpeg, whisper, model, input, language = 'es', threads = 8 }) {
13
- const tmp = mkdtempSync(join(tmpdir(), 'ignix-editor-asr-'));
61
+ export function transcribe({ ffmpeg, whisper, model, input, language = 'es', threads }) {
62
+ const ws = whisperWorkspace(model);
63
+ const tmp = ws.tmp;
14
64
  try {
15
65
  const wav = join(tmp, 'audio.wav');
16
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' });
17
67
  if (a.status !== 0) throw new Error(`Could not read the audio of ${input}: ${a.stderr.slice(-300)}`);
18
68
  const out = join(tmp, 'out');
19
- const args = ['-m', model, '-l', language, '-ml', '1', '-sow', '-oj', '-of', out, '-t', String(threads), '-f', wav];
20
- const run = (extra) => spawnSync(whisper, [...extra, ...args], { encoding: 'utf8', maxBuffer: 64 * 1024 * 1024 });
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 });
21
72
  // En Mac Intel la GPU (Metal) se cuelga con whisper: CPU directamente. En el resto, GPU y,
22
73
  // si falla, otra vez por CPU.
23
74
  const intelMac = process.platform === 'darwin' && process.arch === 'x64';
24
75
  let w = run(intelMac ? ['-ng'] : []);
25
- if (w.status !== 0 && !intelMac) w = run(['-ng']);
26
- 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);
27
78
  const j = JSON.parse(readFileSync(`${out}.json`, 'utf8'));
28
79
  const words = [];
29
80
  for (const seg of j.transcription ?? []) {
@@ -74,24 +125,26 @@ export function alignToScript(script, tokens, end) {
74
125
  * Transcripción completa para Creator: idioma detectado (si no se da), cada palabra con su
75
126
  * confianza (media de sus tokens) y las frases (cortadas en un punto o en una pausa).
76
127
  */
77
- export function transcribeDetailed({ ffmpeg, whisper, model, input, language = null, threads = 8, maxWords = 6000, maxSegments = 800 }) {
78
- const tmp = mkdtempSync(join(tmpdir(), 'ignix-editor-asr-'));
128
+ export function transcribeDetailed({ ffmpeg, whisper, model, input, language = null, threads, maxWords = 6000, maxSegments = 800 }) {
129
+ const ws = whisperWorkspace(model);
130
+ const tmp = ws.tmp;
79
131
  try {
80
132
  const wav = join(tmp, 'audio.wav');
81
133
  const a = spawnSync(ffmpeg, ['-nostdin', '-hide_banner', '-loglevel', 'error', '-y', '-i', input, '-vn', '-ar', '16000', '-ac', '1', '-c:a', 'pcm_s16le', wav], { encoding: 'utf8' });
82
134
  if (a.status !== 0) throw new Error(`Could not read the audio of ${input}: ${a.stderr.slice(-300)}`);
83
135
  const duration = statSync(wav).size > 44 ? (statSync(wav).size - 44) / 32000 : 0;
84
136
  const out = join(tmp, 'out');
85
- const args = ['-m', model, '-l', language || 'auto', '-ml', '1', '-sow', '-ojf', '-of', out, '-t', String(threads), '-f', wav];
86
- const run = (extra) => spawnSync(whisper, [...extra, ...args], { encoding: 'utf8', maxBuffer: 64 * 1024 * 1024 });
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 });
87
140
  const intelMac = process.platform === 'darwin' && process.arch === 'x64';
88
141
  let w = run(intelMac ? ['-ng'] : []);
89
142
  let device = intelMac ? 'cpu' : 'gpu';
90
- if (w.status !== 0 && !intelMac) {
143
+ if (w.status !== 0 && !intelMac && w.error?.code !== 'ETIMEDOUT') {
91
144
  w = run(['-ng']);
92
145
  device = 'cpu';
93
146
  }
94
- if (w.status !== 0) throw new Error(`whisper failed: ${(w.stderr || '').slice(-400)}`);
147
+ if (w.status !== 0) throw whisperFailure(w, timeout);
95
148
  const j = JSON.parse(readFileSync(`${out}.json`, 'utf8'));
96
149
  const words = [];
97
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
+ }