@ignix-social/editor 0.1.0 → 0.1.1

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.
@@ -1,7 +1,7 @@
1
1
  #!/usr/bin/env node
2
2
  const major = Number(process.versions.node.split('.')[0]);
3
3
  if (major < 22) {
4
- console.error(`Ignix Editor necesita Node 22 o superior (tienes ${process.versions.node}). Instala la versión LTS desde nodejs.org.`);
4
+ console.error(`Ignix Editor needs Node 22 or newer (this is ${process.versions.node}). Install the LTS from nodejs.org.`);
5
5
  process.exit(1);
6
6
  }
7
7
  const { main } = await import('../src/cli.mjs');
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ignix-social/editor",
3
- "version": "0.1.0",
3
+ "version": "0.1.1",
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",
@@ -11,11 +11,11 @@ Commands (`npx @ignix-social/editor <cmd>` if `ignix-editor` is not on the PATH)
11
11
 
12
12
  | Command | What it does |
13
13
  |---|---|
14
- | `ignix-editor doctor` | Node 22+, ffmpeg, whisper, H.264 encoder. Run it first. |
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
16
  | `ignix-editor transcribe <file> --lang es [--script "…"] -o words.json` | Words with start/end seconds, on this machine. |
17
17
  | `ignix-editor check [dir]` | HTML well-formed, no network assets, media rules, layout/contrast/motion audit. |
18
- | `ignix-editor snapshot [dir] --at 0,1.5,3` | PNG frames into `<dir>/snapshots/` to LOOK at. |
18
+ | `ignix-editor snapshot [dir] --at 0,1.5,3` | PNG frames into `<dir>/snapshots/` to LOOK at. `[dir]` defaults to the current folder. |
19
19
  | `ignix-editor render [dir] [-o out.mp4]` | Renders to `<dir>/renders/<dir>.mp4`, audio normalised to −14 LUFS. |
20
20
  | `ignix-editor ffmpeg …` / `ffprobe …` | The editor's own ffmpeg (do not rely on a system one). |
21
21
 
@@ -123,6 +123,7 @@ Re-run it whenever the voice or the cuts change; the animation code reads `windo
123
123
  - Centring with CSS `transform: translate(-50%,-50%)` on an element you also transform from JS fights; centre with flex/inset instead.
124
124
  - Emojis render with the operating system's emoji font (different design on other machines).
125
125
  - An element that ends past `data-duration` is cut; a timeline shorter than it holds the last frame.
126
+ - `check` also reads HTML comments: never leave example `<video>`/`<audio>` tags inside `<!-- -->` (it reports their files as missing).
126
127
  - `check` reports layout overlaps: real ones (a zoomed window under the header) must be fixed; intentional ones get `data-layout-allow-overlap` / `data-layout-allow-overflow` on the element.
127
128
 
128
129
  ## 7. Review before showing anything
package/src/audio.mjs CHANGED
@@ -4,7 +4,7 @@ import { spawnSync } from 'node:child_process';
4
4
 
5
5
  function ffmpegRun(ffmpeg, args) {
6
6
  const r = spawnSync(ffmpeg, ['-nostdin', '-hide_banner', ...args], { encoding: 'utf8', maxBuffer: 64 * 1024 * 1024 });
7
- if (r.status !== 0) throw new Error(`ffmpeg falló: ${(r.stderr || r.error?.message || '').slice(-400)}`);
7
+ if (r.status !== 0) throw new Error(`ffmpeg failed: ${(r.stderr || r.error?.message || '').slice(-400)}`);
8
8
  return r.stderr; // ffmpeg informa por stderr
9
9
  }
10
10
 
package/src/binaries.mjs CHANGED
@@ -28,7 +28,7 @@ const binDir = () => join(HOME, 'bin', PLATFORM, manifest.version);
28
28
  export function findTool(name) {
29
29
  const t = TOOLS[name];
30
30
  const fromEnv = process.env[t.env];
31
- if (fromEnv) return existsSync(fromEnv) ? { path: fromEnv, source: 'env' } : { path: null, source: 'env', error: `${t.env} apunta a ${fromEnv}, que no existe` };
31
+ if (fromEnv) return existsSync(fromEnv) ? { path: fromEnv, source: 'env' } : { path: null, source: 'env', error: `${t.env} points to ${fromEnv}, which does not exist` };
32
32
  const cached = join(binDir(), t.file);
33
33
  if (existsSync(cached)) return { path: cached, source: 'cache' };
34
34
  const entry = manifest.platforms[PLATFORM]?.[name];
@@ -42,15 +42,15 @@ export async function ensureTool(name, log = () => {}) {
42
42
  if (found.error) throw new Error(found.error);
43
43
  const entry = manifest.platforms[PLATFORM]?.[name];
44
44
  if (!entry) {
45
- throw new Error(`${name} todavía no está publicado para ${PLATFORM}. Mientras tanto, apunta ${TOOLS[name].env} a un ${TOOLS[name].file} local.`);
45
+ throw new Error(`${name} is not available for ${PLATFORM} yet. Meanwhile, point ${TOOLS[name].env} to a local ${TOOLS[name].file}.`);
46
46
  }
47
47
  const url = `${manifest.baseUrl}/${manifest.version}/${PLATFORM}/${entry.file}`;
48
- log(`Descargando ${name} (${(entry.size / 1e6).toFixed(0)} MB)…`);
48
+ log(`Downloading ${name} (${(entry.size / 1e6).toFixed(0)} MB)…`);
49
49
  const res = await fetch(url);
50
- if (!res.ok) throw new Error(`No se pudo descargar ${name}: ${res.status} ${url}`);
50
+ if (!res.ok) throw new Error(`Could not download ${name}: ${res.status} ${url}`);
51
51
  const buf = Buffer.from(await res.arrayBuffer());
52
52
  const sha = createHash('sha256').update(buf).digest('hex');
53
- if (sha !== entry.sha256) throw new Error(`El ${name} descargado no coincide con su huella (sha256). No se usa.`);
53
+ if (sha !== entry.sha256) throw new Error(`The downloaded ${name} does not match its SHA-256. Not using it.`);
54
54
  const dir = binDir();
55
55
  mkdirSync(dir, { recursive: true });
56
56
  const dest = join(dir, TOOLS[name].file);
@@ -69,14 +69,14 @@ export const WHISPER_MODELS = {
69
69
 
70
70
  export async function ensureWhisperModel(model = 'medium', log = () => {}) {
71
71
  const m = WHISPER_MODELS[model];
72
- if (!m) throw new Error(`Modelo desconocido: ${model}. Opciones: ${Object.keys(WHISPER_MODELS).join(', ')}`);
72
+ if (!m) throw new Error(`Unknown model: ${model}. Options: ${Object.keys(WHISPER_MODELS).join(', ')}`);
73
73
  const dir = join(HOME, 'models');
74
74
  const dest = join(dir, m.file);
75
75
  if (existsSync(dest)) return dest;
76
76
  mkdirSync(dir, { recursive: true });
77
- log(`Descargando el modelo de transcripción ${model} (${(m.size / 1e9).toFixed(1)} GB, solo la primera vez)…`);
77
+ log(`Downloading the ${model} transcription model (${(m.size / 1e9).toFixed(1)} GB, first time only)…`);
78
78
  const res = await fetch(`https://huggingface.co/ggerganov/whisper.cpp/resolve/main/${m.file}`);
79
- if (!res.ok) throw new Error(`No se pudo descargar el modelo ${model}: ${res.status}`);
79
+ if (!res.ok) throw new Error(`Could not download the ${model} model: ${res.status}`);
80
80
  await pipeline(Readable.fromWeb(res.body), createWriteStream(`${dest}.part`));
81
81
  renameSync(`${dest}.part`, dest);
82
82
  return dest;
package/src/cli.mjs CHANGED
@@ -1,7 +1,9 @@
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
+ // Los mensajes van en inglés, como el manual y la skill: los lee sobre todo el agente.
3
4
  import { spawnSync } from 'node:child_process';
4
5
  import { cpSync, existsSync, mkdirSync, readFileSync, rmSync, symlinkSync, writeFileSync } from 'node:fs';
6
+ import { createRequire } from 'node:module';
5
7
  import { homedir } from 'node:os';
6
8
  import { basename, dirname, join, resolve } from 'node:path';
7
9
  import { fileURLToPath } from 'node:url';
@@ -17,12 +19,21 @@ const ENGINE = join(PKG_DIR, 'vendor', 'hyperframes', 'bin', 'hyperframes.mjs');
17
19
  const UPSTREAM = JSON.parse(readFileSync(join(PKG_DIR, 'vendor', 'hyperframes', 'UPSTREAM.json'), 'utf8'));
18
20
  export const VIDEOS_DIR = join(homedir(), 'ignix-videos');
19
21
 
22
+ // Las dependencias pueden estar en <paquete>/node_modules o, según cómo instale npm,
23
+ // al lado del paquete (npm install --prefix …): se resuelven como lo haría Node.
24
+ const requireHere = createRequire(import.meta.url);
25
+ const depDir = (name) => dirname(requireHere.resolve(`${name}/package.json`));
26
+
20
27
  const log = (...a) => console.error(...a);
21
28
  const fail = (msg) => {
22
29
  log(`✗ ${msg}`);
23
30
  process.exit(1);
24
31
  };
25
32
 
33
+ // Opciones del motor que llevan valor detrás: `snapshot --at 0,1.5` sin carpeta no debe
34
+ // tomar «0,1.5» por la carpeta del proyecto.
35
+ const ENGINE_VALUE_FLAGS = new Set(['--at', '--frames', '--timeout', '--angle', '--zoom', '--zoom-scale', '--against', '--describe', '-w', '--workers', '-q', '--quality', '-f', '--fps', '--format', '--crf', '--video-bitrate', '--resolution', '--variables', '--variables-file', '-c', '--composition', '--port']);
36
+
26
37
  // ── argumentos: posicionales, nuestras opciones y el resto pasa al motor tal cual
27
38
  function parseArgs(argv, ours) {
28
39
  const pos = [];
@@ -36,9 +47,10 @@ function parseArgs(argv, ours) {
36
47
  if (ours[key] === 'bool') opts[key] = m?.[2] !== 'false';
37
48
  else opts[key] = m?.[2] ?? argv[++i];
38
49
  } else if (key && key.startsWith('no-') && key.slice(3) in ours && ours[key.slice(3)] === 'bool') opts[key.slice(3)] = false;
39
- else if (a.startsWith('-')) rest.push(a);
40
- else if (rest.length && rest[rest.length - 1].startsWith('-') && !rest[rest.length - 1].includes('=') && pos.length >= 1) rest.push(a);
41
- else pos.push(a);
50
+ else if (a.startsWith('-')) {
51
+ rest.push(a);
52
+ if (ENGINE_VALUE_FLAGS.has(a) && i + 1 < argv.length) rest.push(argv[++i]);
53
+ } else pos.push(a);
42
54
  }
43
55
  return { pos, opts, rest };
44
56
  }
@@ -80,13 +92,13 @@ function validateProject(dir) {
80
92
  const issues = projectHtml(dir).flatMap(validateHtml);
81
93
  for (const i of issues) log(`${i.level === 'error' ? '✗' : '⚠'} ${i.rule} ${i.file.replace(dir + '/', '')}:${i.line} — ${i.msg}`);
82
94
  const errors = issues.filter((i) => i.level === 'error').length;
83
- log(errors ? `✗ ${errors} error(es) de Ignix Editor` : `✓ Ignix Editor: HTML y recursos correctos${issues.length ? ` (${issues.length} aviso(s))` : ''}`);
95
+ log(errors ? `✗ ${errors} Ignix Editor error(s)` : `✓ Ignix Editor: HTML and assets OK${issues.length ? ` (${issues.length} warning(s))` : ''}`);
84
96
  return errors;
85
97
  }
86
98
 
87
99
  const projectDir = (p) => {
88
100
  const dir = resolve(p ?? '.');
89
- if (!existsSync(join(dir, 'index.html'))) fail(`No hay index.html en ${dir}. ¿Es la carpeta del proyecto? (créala con: ignix-editor init <nombre>)`);
101
+ if (!existsSync(join(dir, 'index.html'))) fail(`No index.html in ${dir}. Is this the project folder? (create one with: ignix-editor init <name>)`);
90
102
  return dir;
91
103
  };
92
104
 
@@ -94,38 +106,53 @@ const projectDir = (p) => {
94
106
  const commands = {
95
107
  async doctor() {
96
108
  const major = Number(process.versions.node.split('.')[0]);
97
- log(`${major >= 22 ? '✓' : '✗'} Node ${process.versions.node}${major >= 22 ? '' : ' — hace falta Node 22 o superior'}`);
98
- log(`✓ Ignix Editor ${PKG.version} · motor HyperFrames ${UPSTREAM.version} (parches: ${UPSTREAM.patches.join(', ')})`);
99
- log(` Plataforma ${PLATFORM} · datos en ${HOME}`);
109
+ log(`${major >= 22 ? '✓' : '✗'} Node ${process.versions.node}${major >= 22 ? '' : ' — Node 22 or newer is required'}`);
110
+ log(`✓ Ignix Editor ${PKG.version} · HyperFrames engine ${UPSTREAM.version} (patches: ${UPSTREAM.patches.join(', ')})`);
111
+ log(` Platform ${PLATFORM} · data in ${HOME}`);
100
112
  let ok = major >= 22;
113
+ // doctor deja la máquina lista: descarga lo que falte en vez de solo avisar.
101
114
  for (const name of Object.keys(TOOLS)) {
102
- const f = findTool(name);
103
- const line = f.path ? `✓ ${name}: ${f.path}${f.source === 'env' ? ' (variable de entorno)' : ''}` : f.error ? `✗ ${name}: ${f.error}` : f.source === 'download' ? `• ${name}: se descargará la primera vez` : `✗ ${name}: aún no publicado para ${PLATFORM} (apunta ${TOOLS[name].env} a uno local)`;
104
- log(line);
105
- if (!f.path && f.source !== 'download' && name !== 'whisper') ok = false;
115
+ const before = findTool(name);
116
+ if (before.path) {
117
+ log(`✓ ${name}: ${before.path}${before.source === 'env' ? ' (environment variable)' : ''}`);
118
+ continue;
119
+ }
120
+ try {
121
+ const path = await ensureTool(name, log);
122
+ log(`✓ ${name}: ${path} (downloaded)`);
123
+ } catch (e) {
124
+ log(`✗ ${name}: ${e.message}`);
125
+ if (name !== 'whisper') ok = false;
126
+ }
106
127
  }
107
- if (findTool('ffmpeg').path) {
108
- const enc = spawnSync(findTool('ffmpeg').path, ['-hide_banner', '-encoders'], { encoding: 'utf8' }).stdout ?? '';
128
+ const ff = findTool('ffmpeg').path;
129
+ if (ff) {
130
+ const enc = spawnSync(ff, ['-hide_banner', '-encoders'], { encoding: 'utf8' }).stdout ?? '';
109
131
  const h264 = /\blibx264\b/.test(enc) ? 'libx264' : /\bh264_videotoolbox\b/.test(enc) ? 'VideoToolbox' : null;
110
- log(`${h264 ? '✓' : '✗'} codificador H.264: ${h264 ?? 'ninguno (hace falta libx264 o VideoToolbox)'}`);
132
+ log(`${h264 ? '✓' : '✗'} H.264 encoder: ${h264 ?? 'none (needs libx264 or VideoToolbox)'}`);
111
133
  if (!h264) ok = false;
112
134
  }
135
+ log(ok ? '✓ Ready.' : '✗ Not ready: see the lines above.');
113
136
  process.exit(ok ? 0 : 1);
114
137
  },
115
138
 
116
139
  async init(argv) {
117
140
  const { pos, opts } = parseArgs(argv, { dir: 'str' });
118
141
  const name = pos[0];
119
- if (!name || !/^[\w.-]+$/.test(name)) fail('Uso: ignix-editor init <nombre> [--dir carpeta] (nombre sin espacios)');
142
+ if (!name || !/^[\w.-]+$/.test(name)) fail('Usage: ignix-editor init <name> [--dir folder] (name without spaces)');
120
143
  const dir = resolve(opts.dir ?? join(VIDEOS_DIR, name));
121
- if (existsSync(dir)) fail(`${dir} ya existe. Elige otro nombre o trabaja dentro de ese proyecto.`);
144
+ if (existsSync(dir)) fail(`${dir} already exists. Pick another name or work inside that project.`);
145
+ // Todo se prepara antes de crear la carpeta: si algo falta, no queda un proyecto a medias.
146
+ const gsap = join(depDir('gsap'), 'dist', 'gsap.min.js');
147
+ const fonts = join(depDir('@fontsource/montserrat'), 'files');
148
+ if (!existsSync(gsap) || !existsSync(fonts)) fail('Ignix Editor is missing GSAP or its fonts. Reinstall it (npm install --prefix ~/.ignix-editor/cli @ignix-social/editor).');
122
149
  cpSync(join(PKG_DIR, 'templates', 'blank'), dir, { recursive: true });
123
150
  mkdirSync(join(dir, 'assets', 'fonts'), { recursive: true });
124
- cpSync(join(PKG_DIR, 'node_modules', 'gsap', 'dist', 'gsap.min.js'), join(dir, 'assets', 'gsap.min.js'));
151
+ cpSync(gsap, join(dir, 'assets', 'gsap.min.js'));
125
152
  for (const w of [700, 800, 900]) {
126
153
  for (const sub of ['latin', 'latin-ext']) {
127
154
  const f = `montserrat-${sub}-${w}-normal.woff2`;
128
- cpSync(join(PKG_DIR, 'node_modules', '@fontsource', 'montserrat', 'files', f), join(dir, 'assets', 'fonts', f));
155
+ cpSync(join(fonts, f), join(dir, 'assets', 'fonts', f));
129
156
  }
130
157
  }
131
158
  cpSync(join(PKG_DIR, 'src', 'runtime', 'ignix-frame.js'), join(dir, 'ignix-frame.js'));
@@ -139,7 +166,7 @@ const commands = {
139
166
  const dir = projectDir(pos[0]);
140
167
  const errors = validateProject(dir);
141
168
  const { build, snapped } = prepare(dir);
142
- if (snapped) log(`• ${snapped} tiempo(s) ajustado(s) al fotograma`);
169
+ if (snapped) log(`• ${snapped} time(s) snapped to the nearest frame`);
143
170
  const { env } = await engineEnv();
144
171
  const code = runEngine(['check', build, ...rest], env, dir);
145
172
  process.exit(errors ? 1 : code);
@@ -158,19 +185,19 @@ const commands = {
158
185
  async render(argv) {
159
186
  const { pos, opts, rest } = parseArgs(argv, { output: 'str', lufs: 'str', normalize: 'bool', force: 'bool' });
160
187
  const dir = projectDir(pos[0]);
161
- if (validateProject(dir) && !opts.force) fail('Arregla los errores de arriba antes de renderizar (o --force si sabes lo que haces).');
188
+ if (validateProject(dir) && !opts.force) fail('Fix the errors above before rendering (or --force if you know what you are doing).');
162
189
  const { build, snapped } = prepare(dir);
163
- if (snapped) log(`• ${snapped} tiempo(s) ajustado(s) al fotograma`);
190
+ if (snapped) log(`• ${snapped} time(s) snapped to the nearest frame`);
164
191
  const { env, ffmpeg, ffprobe } = await engineEnv();
165
192
  const output = resolve(opts.output ?? join(dir, 'renders', `${basename(dir)}.mp4`));
166
193
  mkdirSync(dirname(output), { recursive: true });
167
194
  const raw = join(build, 'raw.mp4');
168
195
  const code = runEngine(['render', build, '-o', raw, ...rest], env, dir);
169
- if (code !== 0 || !existsSync(raw)) fail('El render falló (mira los mensajes de arriba).');
196
+ if (code !== 0 || !existsSync(raw)) fail('The render failed (see the messages above).');
170
197
  if (opts.normalize !== false && hasAudio(ffprobe, raw)) {
171
198
  const lufs = Number(opts.lufs ?? -14);
172
199
  const r = normalizeLoudness(ffmpeg, raw, output, { lufs });
173
- log(`✓ Volumen: ${r.before.toFixed(1)} → ${loudness(ffmpeg, output)?.toFixed(1)} LUFS`);
200
+ log(`✓ Loudness: ${r.before.toFixed(1)} → ${loudness(ffmpeg, output)?.toFixed(1)} LUFS`);
174
201
  } else cpSync(raw, output);
175
202
  console.log(output);
176
203
  },
@@ -180,14 +207,14 @@ const commands = {
180
207
  const dir = projectDir(pos[0]);
181
208
  const { build } = prepare(dir);
182
209
  const { env } = await engineEnv();
183
- log('Vista previa de la copia de trabajo: si cambias el proyecto, vuelve a lanzar este comando.');
210
+ log('Previewing the working copy: if you change the project, run this command again.');
184
211
  process.exit(runEngine(['preview', build, ...rest], env, dir));
185
212
  },
186
213
 
187
214
  async transcribe(argv) {
188
215
  const { pos, opts } = parseArgs(argv, { lang: 'str', model: 'str', script: 'str', output: 'str', threads: 'str' });
189
216
  const input = pos[0] && resolve(pos[0]);
190
- if (!input || !existsSync(input)) fail('Uso: ignix-editor transcribe <audio|vídeo> [--lang es] [--model medium] [--script "texto dicho"] [-o palabras.json]');
217
+ if (!input || !existsSync(input)) fail('Usage: ignix-editor transcribe <audio|video> [--lang es] [--model medium] [--script "what was said"] [-o words.json]');
191
218
  const ffmpeg = await ensureTool('ffmpeg', log);
192
219
  const ffprobe = await ensureTool('ffprobe', log);
193
220
  const whisper = await ensureTool('whisper', log);
@@ -198,8 +225,10 @@ const commands = {
198
225
  words = alignToScript(opts.script, words, dur);
199
226
  }
200
227
  const json = JSON.stringify({ language: opts.lang ?? 'es', words }, null, 1);
201
- if (opts.output) writeFileSync(resolve(opts.output), json);
202
- else console.log(json);
228
+ if (opts.output) {
229
+ mkdirSync(dirname(resolve(opts.output)), { recursive: true });
230
+ writeFileSync(resolve(opts.output), json);
231
+ } else console.log(json);
203
232
  },
204
233
 
205
234
  async ffmpeg(argv) {
@@ -214,38 +243,38 @@ const commands = {
214
243
 
215
244
  async skills(argv) {
216
245
  const { pos, opts } = parseArgs(argv, { dir: 'str' });
217
- if (pos[0] !== 'add') fail('Uso: ignix-editor skills add [--dir ~/.claude/skills]');
246
+ if (pos[0] !== 'add') fail('Usage: ignix-editor skills add [--dir ~/.claude/skills]');
218
247
  const target = resolve(opts.dir ?? join(homedir(), '.claude', 'skills'), 'ignix-editor');
219
248
  rmSync(target, { recursive: true, force: true });
220
249
  cpSync(join(PKG_DIR, 'skills', 'ignix-editor'), target, { recursive: true });
221
- log(`✓ Skill instalada en ${target}`);
250
+ log(`✓ Skill installed in ${target}`);
222
251
  },
223
252
 
224
253
  async version() {
225
- console.log(`ignix-editor ${PKG.version} (motor HyperFrames ${UPSTREAM.version})`);
254
+ console.log(`ignix-editor ${PKG.version} (HyperFrames engine ${UPSTREAM.version})`);
226
255
  },
227
256
 
228
257
  async help() {
229
- console.log(`Ignix Editor ${PKG.version} — vídeo con HTML, renderizado en tu ordenador.
230
-
231
- ignix-editor doctor comprueba Node, ffmpeg, whisper y el codificador
232
- ignix-editor init <nombre> crea ~/ignix-videos/<nombre> listo para trabajar
233
- ignix-editor check [carpeta] HTML, recursos, maqueta, movimiento y contraste
234
- ignix-editor snapshot [carpeta] --at 0,2.5,5 fotogramas sueltos para revisar
235
- ignix-editor render [carpeta] [-o salida.mp4] [--lufs -14] [--no-normalize]
236
- ignix-editor preview [carpeta] vista previa en el navegador
237
- ignix-editor transcribe <fichero> [--lang es] [--script "texto"] [-o palabras.json]
238
- ignix-editor ffmpeg|ffprobe <args> el ffmpeg de Ignix Editor
239
- ignix-editor skills add instala la skill para Claude
240
-
241
- Basado en HyperFrames (Apache-2.0) de HeyGen, Inc. Ver NOTICE.`);
258
+ console.log(`Ignix Editor ${PKG.version} — video from HTML, rendered on your own computer.
259
+
260
+ ignix-editor doctor checks Node and the encoder, downloads ffmpeg and whisper if missing
261
+ ignix-editor init <name> creates ~/ignix-videos/<name>, ready to work in
262
+ ignix-editor check [folder] HTML, assets, layout, motion and contrast
263
+ ignix-editor snapshot [folder] --at 0,2.5,5 single frames to look at (into <folder>/snapshots)
264
+ ignix-editor render [folder] [-o out.mp4] [--lufs -14] [--no-normalize]
265
+ ignix-editor preview [folder] live preview in the browser
266
+ ignix-editor transcribe <file> [--lang es] [--script "text"] [-o words.json]
267
+ ignix-editor ffmpeg|ffprobe <args> Ignix Editor's own ffmpeg
268
+ ignix-editor skills add installs the skill for Claude
269
+
270
+ The folder defaults to the current one. Based on HyperFrames (Apache-2.0) by HeyGen, Inc. See NOTICE.`);
242
271
  },
243
272
  };
244
273
 
245
274
  export async function main(argv) {
246
275
  const [cmd = 'help', ...rest] = argv;
247
276
  const fn = commands[cmd === '--version' || cmd === '-v' ? 'version' : cmd === '--help' || cmd === '-h' ? 'help' : cmd];
248
- if (!fn) fail(`Comando desconocido: ${cmd}. Usa: ignix-editor help`);
277
+ if (!fn) fail(`Unknown command: ${cmd}. See: ignix-editor help`);
249
278
  try {
250
279
  await fn(rest);
251
280
  } catch (e) {
package/src/prepare.mjs CHANGED
@@ -60,7 +60,7 @@ export function prepare(projectDir) {
60
60
  mirror(projectDir, build);
61
61
 
62
62
  const root = join(build, 'index.html');
63
- if (!existsSync(root)) throw new Error(`No hay index.html en ${projectDir}`);
63
+ if (!existsSync(root)) throw new Error(`No index.html in ${projectDir}`);
64
64
  const fps = Number(readFileSync(root, 'utf8').match(/data-fps="(\d+(?:\.\d+)?)"/)?.[1] ?? 30);
65
65
 
66
66
  let snapped = 0;
@@ -69,6 +69,13 @@ export function prepare(projectDir) {
69
69
  if (changes) writeFileSync(f, html);
70
70
  snapped += changes;
71
71
  }
72
+ // IgnixFrame.run registra la línea de tiempo al ejecutarse, pero el lint del motor busca
73
+ // el registro LITERAL en el HTML: sin él da «missing_timeline_registry» (un error que,
74
+ // además, apaga las comprobaciones de maquetación y contraste). Se lo damos literal.
75
+ const rootHtml = readFileSync(root, 'utf8');
76
+ if ((/IgnixFrame/.test(rootHtml) || /ignix-frame\.js/.test(rootHtml)) && !/window\.__timelines\s*(=|\[|\.|\?\?=)/.test(rootHtml)) {
77
+ writeFileSync(root, rootHtml.replace(/<\/body>/i, '<script>window.__timelines = window.__timelines || {};</script>\n</body>'));
78
+ }
72
79
  cpSync(RUNTIME, join(build, 'ignix-frame.js'));
73
80
  return { build, fps, snapped };
74
81
  }
@@ -14,7 +14,7 @@ export function transcribe({ ffmpeg, whisper, model, input, language = 'es', thr
14
14
  try {
15
15
  const wav = join(tmp, 'audio.wav');
16
16
  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
- if (a.status !== 0) throw new Error(`No se pudo leer el audio de ${input}: ${a.stderr.slice(-300)}`);
17
+ if (a.status !== 0) throw new Error(`Could not read the audio of ${input}: ${a.stderr.slice(-300)}`);
18
18
  const out = join(tmp, 'out');
19
19
  const args = ['-m', model, '-l', language, '-ml', '1', '-sow', '-oj', '-of', out, '-t', String(threads), '-f', wav];
20
20
  const run = (extra) => spawnSync(whisper, [...extra, ...args], { encoding: 'utf8', maxBuffer: 64 * 1024 * 1024 });
@@ -23,7 +23,7 @@ export function transcribe({ ffmpeg, whisper, model, input, language = 'es', thr
23
23
  const intelMac = process.platform === 'darwin' && process.arch === 'x64';
24
24
  let w = run(intelMac ? ['-ng'] : []);
25
25
  if (w.status !== 0 && !intelMac) w = run(['-ng']);
26
- if (w.status !== 0) throw new Error(`whisper falló: ${(w.stderr || '').slice(-400)}`);
26
+ if (w.status !== 0) throw new Error(`whisper failed: ${(w.stderr || '').slice(-400)}`);
27
27
  const j = JSON.parse(readFileSync(`${out}.json`, 'utf8'));
28
28
  const words = [];
29
29
  for (const seg of j.transcription ?? []) {
package/src/validate.mjs CHANGED
@@ -28,10 +28,10 @@ function balance(src) {
28
28
  if (VOID.has(tag) || m[2] === '/' || ['html', 'head', 'body', 'svg', 'path'].includes(tag)) continue;
29
29
  if (m[0].startsWith('</')) {
30
30
  if (stack.length && stack[stack.length - 1].tag === tag) stack.pop();
31
- else issues.push({ level: 'error', line: lineOf(src, m.index), rule: 'html_unmatched_close', msg: `</${tag}> sin su etiqueta de apertura${stack.length ? ` (está abierta <${stack[stack.length - 1].tag}> de la línea ${stack[stack.length - 1].line})` : ''}. Saca de su sitio todo lo que viene detrás.` });
31
+ else issues.push({ level: 'error', line: lineOf(src, m.index), rule: 'html_unmatched_close', msg: `</${tag}> has no matching opening tag${stack.length ? ` (<${stack[stack.length - 1].tag}> from line ${stack[stack.length - 1].line} is open)` : ''}. Everything after it moves out of place.` });
32
32
  } else stack.push({ tag, line: lineOf(src, m.index) });
33
33
  }
34
- for (const open of stack) issues.push({ level: 'error', line: open.line, rule: 'html_unclosed', msg: `<${open.tag}> sin cerrar.` });
34
+ for (const open of stack) issues.push({ level: 'error', line: open.line, rule: 'html_unclosed', msg: `<${open.tag}> is never closed.` });
35
35
  return issues;
36
36
  }
37
37
 
@@ -42,7 +42,7 @@ export function validateHtml(file) {
42
42
  parse(src, {
43
43
  sourceCodeLocationInfo: true,
44
44
  onParseError: (e) => {
45
- if (!BENIGN.has(e.code)) issues.push({ level: 'error', line: e.startLine, rule: `html_${e.code}`, msg: `HTML mal formado (${e.code}).` });
45
+ if (!BENIGN.has(e.code)) issues.push({ level: 'error', line: e.startLine, rule: `html_${e.code}`, msg: `Malformed HTML (${e.code}).` });
46
46
  },
47
47
  });
48
48
  issues.push(...balance(src));
@@ -50,22 +50,22 @@ export function validateHtml(file) {
50
50
  for (const s of src.matchAll(/<script\b[^>]*>([\s\S]*?)<\/script>/gi)) {
51
51
  const body = s[1];
52
52
  if (/createElement\(\s*['"`](video|audio)['"`]/.test(body) || /<(video|audio)\b/.test(body)) {
53
- issues.push({ level: 'error', line: lineOf(src, s.index), rule: 'media_from_js', msg: 'Hay <video>/<audio> creados desde JavaScript. El motor solo ve los que están escritos en el HTML: genera el HTML antes (un script de preparación) en vez de crearlos al cargar.' });
53
+ issues.push({ level: 'error', line: lineOf(src, s.index), rule: 'media_from_js', msg: '<video>/<audio> created from JavaScript. The engine only sees media written in the HTML: write the HTML beforehand (a build script) instead of creating them at load time.' });
54
54
  }
55
55
  }
56
56
  for (const m of src.matchAll(/<(script|link|img|video|audio|source)\b[^>]*\b(src|href)\s*=\s*["'](https?:)?\/\/[^"']+["']/gi)) {
57
57
  const tag = m[1].toLowerCase();
58
58
  const font = /fonts\.(googleapis|gstatic)\.com|use\.typekit|fonts\.bunny/.test(m[0]);
59
- issues.push({ level: (tag === 'script' || tag === 'link') && !font ? 'warning' : 'error', line: lineOf(src, m.index), rule: 'network_asset', msg: `<${tag}> carga algo de internet. Descárgalo a assets/: el render no debe depender de la red.` });
59
+ issues.push({ level: (tag === 'script' || tag === 'link') && !font ? 'warning' : 'error', line: lineOf(src, m.index), rule: 'network_asset', msg: `<${tag}> loads something from the internet. Download it into assets/: the render must not depend on the network.` });
60
60
  }
61
61
  for (const m of src.matchAll(/@import\s+url\(\s*["']?https?:|url\(\s*["']?https?:\/\/fonts\./gi)) {
62
- issues.push({ level: 'error', line: lineOf(src, m.index), rule: 'network_font', msg: 'Fuente cargada de internet: llega tarde en algunos fotogramas. Usa @font-face con un fichero en assets/fonts/.' });
62
+ issues.push({ level: 'error', line: lineOf(src, m.index), rule: 'network_font', msg: 'Font loaded from the internet: it arrives late in some frames. Use @font-face with a file in assets/fonts/.' });
63
63
  }
64
64
  for (const m of src.matchAll(/\bid\s*=\s*["']([^"']+)["']/g)) {
65
- if (RESERVED_IDS.has(m[1])) issues.push({ level: 'error', line: lineOf(src, m.index), rule: 'reserved_id', msg: `id="${m[1]}" lo trata el motor de forma especial (cambia la fuente). Usa otro nombre, p. ej. "subs".` });
65
+ if (RESERVED_IDS.has(m[1])) issues.push({ level: 'error', line: lineOf(src, m.index), rule: 'reserved_id', msg: `id="${m[1]}" is special to the engine (it changes the font). Use another name, e.g. "subs".` });
66
66
  }
67
67
  for (const m of src.matchAll(/<audio\b(?![^>]*\bid\s*=)[^>]*>/gi)) {
68
- issues.push({ level: 'error', line: lineOf(src, m.index), rule: 'audio_without_id', msg: '<audio> sin id: el mezclador lo ignora y se queda sin sonido.' });
68
+ issues.push({ level: 'error', line: lineOf(src, m.index), rule: 'audio_without_id', msg: '<audio> without an id: the mixer ignores it and it stays silent.' });
69
69
  }
70
70
  return issues.map((i) => ({ ...i, file }));
71
71
  }
@@ -1,36 +1,33 @@
1
1
  <!DOCTYPE html>
2
- <html lang="es">
2
+ <html lang="en">
3
3
  <head>
4
4
  <meta charset="UTF-8" />
5
5
  <meta name="viewport" content="width=1080, height=1920" />
6
6
  <script src="assets/gsap.min.js"></script>
7
7
  <script src="ignix-frame.js"></script>
8
8
  <style>
9
- /* Fuentes SIEMPRE locales (assets/fonts): las de internet llegan tarde en algunos fotogramas. */
9
+ /* Fonts are ALWAYS local (assets/fonts): web fonts arrive late in some frames. */
10
10
  @font-face { font-family: 'Montserrat'; font-weight: 700; font-display: block; src: url('assets/fonts/montserrat-latin-700-normal.woff2') format('woff2'); }
11
11
  @font-face { font-family: 'Montserrat'; font-weight: 800; font-display: block; src: url('assets/fonts/montserrat-latin-800-normal.woff2') format('woff2'); }
12
12
  @font-face { font-family: 'Montserrat'; font-weight: 900; font-display: block; src: url('assets/fonts/montserrat-latin-900-normal.woff2') format('woff2'); }
13
13
  * { margin: 0; padding: 0; box-sizing: border-box; }
14
14
  html, body { width: 1080px; height: 1920px; overflow: hidden; background: #121313; }
15
15
  #root { position: relative; width: 100%; height: 100%; overflow: hidden; background: #121313; font-family: 'Montserrat', sans-serif; color: #f5f5f5; }
16
- /* Zona segura de un reel: columna central de ~820 px, libre de los 250 px de arriba y los 450 de abajo. */
16
+ /* Reel safe area: the central ~820 px column, clear of the top 250 px and the bottom 450 px. */
17
17
  #title { position: absolute; left: 130px; width: 820px; top: 760px; text-align: center; font-size: 110px; font-weight: 900; line-height: 1.02; text-transform: uppercase;
18
18
  -webkit-text-stroke: 12px #000; paint-order: stroke fill; text-shadow: 0 8px 0 rgba(0,0,0,.5); }
19
19
  </style>
20
20
  </head>
21
21
  <body>
22
- <!-- data-duration en segundos; cámbialo a la duración real (la de la voz + un respiro). -->
22
+ <!-- data-duration in seconds: set it to the real length (the voice plus a short tail). -->
23
23
  <div id="root" data-composition-id="__ID__" data-width="1080" data-height="1920" data-duration="5" data-fps="30">
24
- <div id="title">Hola</div>
25
- <!-- Vídeo y audio SIEMPRE escritos aquí (no creados desde JS), cada uno con su id:
26
- <video id="clip1" src="assets/toma.mp4" muted playsinline data-start="0" data-duration="3" data-media-start="1.5" data-track-index="0"></video>
27
- <audio id="voz1" src="assets/voz-1.wav" data-start="0" data-duration="2.4" data-track-index="1" data-volume="1"></audio>
28
- -->
24
+ <div id="title">Hello</div>
25
+ <!-- Video and audio go here, written in the HTML (never created from JS), each with its own id: see the ignix-editor skill. -->
29
26
  </div>
30
27
  <script>
31
28
  const { run, lerp, spring, ease } = IgnixFrame;
32
29
  const title = document.getElementById('title');
33
- // Todo lo que se ve depende SOLO de t (segundos). Nada de medir la página aquí dentro.
30
+ // Everything visible depends ONLY on t (seconds). Never measure the page in here.
34
31
  run({ id: '__ID__', duration: 5, fps: 30 }, (t) => {
35
32
  const p = spring(t, { stiffness: 220, damping: 14 });
36
33
  title.style.transform = `scale(${0.6 + 0.4 * p})`;