@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 +1 -1
- package/src/cli.mjs +2 -1
- package/src/creator/build.mjs +1 -0
- package/src/engine.mjs +8 -1
- package/src/giphy.mjs +21 -2
- package/src/prepare.mjs +1 -0
- package/src/transcribe.mjs +67 -14
- package/src/win-fs-preload.mjs +4 -0
- package/src/win-fs.mjs +40 -0
package/package.json
CHANGED
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:
|
|
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);
|
package/src/creator/build.mjs
CHANGED
|
@@ -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
|
|
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';
|
package/src/transcribe.mjs
CHANGED
|
@@ -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
|
|
13
|
-
const
|
|
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',
|
|
20
|
-
const
|
|
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
|
|
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
|
|
78
|
-
const
|
|
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',
|
|
86
|
-
const
|
|
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
|
|
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)…
|
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
|
+
}
|