@ignix-social/editor 0.1.0 → 0.1.2

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.2",
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,12 @@ 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
+ | `ignix-editor faces <video> -o work/faces.json` | Where the face is over time (macOS, Apple Vision): a smoothed path `{t, cx, cy, size}` (0–1, top-left origin) to frame the person. |
17
18
  | `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. |
19
+ | `ignix-editor snapshot [dir] --at 0,1.5,3` | PNG frames into `<dir>/snapshots/` to LOOK at. `[dir]` defaults to the current folder. |
19
20
  | `ignix-editor render [dir] [-o out.mp4]` | Renders to `<dir>/renders/<dir>.mp4`, audio normalised to −14 LUFS. |
20
21
  | `ignix-editor ffmpeg …` / `ffprobe …` | The editor's own ffmpeg (do not rely on a system one). |
21
22
 
@@ -94,16 +95,26 @@ Decide the zones once per video (e.g. header y 270–440, stage y 460–1300, ca
94
95
  - Transcribe on the machine: `ignix-editor transcribe assets/take.mp4 --lang es -o work/words.json`. Show the user the text and fix obvious mishearings (names, brands) without rewriting what they said.
95
96
  - Cuts: one `<video>` per kept range, edge to edge (`data-start` of the next = previous start + duration). Build a map from source time to edited time and pass every word time, zoom, overlay and sound through it.
96
97
  - Voice clean-up before building: `ignix-editor ffmpeg -i take.mp4 -vn -ac 1 -ar 48000 -af "highpass=f=80,acompressor=threshold=-22dB:ratio=3:attack=5:release=120:makeup=2" assets/voice.wav`, then one `<audio>` per kept range with `data-media-start` and 8 ms fade lanes, and the videos `muted`.
97
- - Reframing a horizontal source to 9:16 and following the face: a fixed card/frame element with `overflow:hidden`; inside it a wrapper sized like the card, `transform-origin:0 0`, moved with `translate(-x·z, -y·z) scale(z)` per frame from a face path (one point per frame, smoothed). Zooms multiply `z`.
98
+ - Reframing a horizontal source to 9:16 and following the face: `ignix-editor faces assets/take.mp4 -o work/faces.json` gives a smoothed path (`cx`, `cy` = centre of the face, `size` = its height, all 0–1). Map it through your cut map like the words. Then a fixed card/frame element with `overflow:hidden`; inside it a wrapper sized like the card, `transform-origin:0 0`, moved with `translate(-x·z, -y·z) scale(z)` per frame from that path (interpolate between samples). Zooms multiply `z`.
99
+ - The cover is the first frame: the face AND the hook. If the person is not in shot yet in the first second (sitting down, walking in), use a still of their face from later (`ignix-editor ffmpeg -ss <t> -i take.mp4 -frames:v 1 assets/cover.jpg`) under the hook for ~1 s, with a slow push, then cut to the footage.
100
+ - The same file in two places at once (main shot + a small picture-in-picture window): give the second one its own copy (`cp take.mp4 take-pip.mp4`); `check` flags two simultaneous clips of one file.
98
101
  - Blurred background of the same footage: a second set of the same `<video>` elements behind, in a wrapper with `filter: blur(60px) brightness(.32)` and `transform: scale(1.25)`. (Heavy without a GPU: on a machine without one, prefer a pre-blurred copy made with ffmpeg.)
99
102
  - Long sources (lives, podcasts) are fine: the editor seeks straight to `data-media-start`.
100
103
 
104
+ ### The bundled ffmpeg (LGPL)
105
+
106
+ `ignix-editor ffmpeg` is a licence-clean build: everything you need for audio and simple video prep, but no GPL parts.
107
+ - Encode H.264 with `-c:v h264_videotoolbox -b:v 10M -allow_sw 1` (there is no `libx264` and no `-crf`). AAC audio: `-c:a aac -b:a 192k`.
108
+ - Present: `scale`, `crop`, `pad`, `overlay`, `trim`/`atrim`, `concat`, `gblur`, `avgblur`, `curves`, `lutyuv`, `colorbalance`, `colorchannelmixer`, `fade`, `afade`, `highpass`, `lowpass`, `acompressor`, `loudnorm`, `ebur128`, `silencedetect`, `amix`, `volume`.
109
+ - Missing (GPL): `eq` (use `curves`/`lutyuv` for contrast and warmth), `boxblur` (use `gblur`), `delogo` (cover a burned-in label with a cropped, `gblur`red patch of the same frame placed by `overlay`, or with a card in the HTML).
110
+ - Grading and blur are usually easier in the HTML anyway (CSS `filter: contrast() saturate() blur()`).
111
+
101
112
  ## 4. Audio and captions for reels from scratch
102
113
 
103
114
  - One `<audio>` per voice line (Ignix `generate_voiceover`), placed back to back with ~0.25 s of air: the scenes follow the voice.
104
115
  - Word times: `ignix-editor transcribe assets/vo/l1.wav --lang es --script "<the exact line>" -o work/l1.json`. With `--script` the words are the script's and the times are whisper's (whisper mishears and sometimes drops the last words; the script wins).
105
116
  - Anchor every gesture (click, pop, highlight, cut) to a word time: `cue = line.start + word.s`. Sound effects start on their gesture.
106
- - Captions: groups of 2–3 words that never cross the end of a line; current word in the brand accent; font size shrinks for long groups so they fit the ~820 px column.
117
+ - Captions: groups of 2–3 words that never cross the end of a line; current word in the brand accent; font size shrinks for long groups so they fit the ~820 px column. Keep a real gap between words (`gap: 0 0.3em` on the flex row): if the active word grows (`scale`), it eats a smaller gap and «EDITORES DE» reads «EDITORESDE» — grow it at most ~5 %, or only change its colour.
107
118
  - The final mix is normalised to −14 LUFS by `render` (`--lufs` to change, `--no-normalize` to skip).
108
119
 
109
120
  ## 5. Generate the HTML from data
@@ -123,7 +134,8 @@ Re-run it whenever the voice or the cuts change; the animation code reads `windo
123
134
  - Centring with CSS `transform: translate(-50%,-50%)` on an element you also transform from JS fights; centre with flex/inset instead.
124
135
  - Emojis render with the operating system's emoji font (different design on other machines).
125
136
  - An element that ends past `data-duration` is cut; a timeline shorter than it holds the last frame.
126
- - `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.
137
+ - `check` also reads HTML comments: never leave example `<video>`/`<audio>` tags inside `<!-- -->` (it reports their files as missing).
138
+ - `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. A title split in lines (one element per line) is reported line against line: put `data-layout-allow-overlap` on EACH line, not only on the container.
127
139
 
128
140
  ## 7. Review before showing anything
129
141
 
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,10 @@
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';
5
+ import { createHash } from 'node:crypto';
4
6
  import { cpSync, existsSync, mkdirSync, readFileSync, rmSync, symlinkSync, writeFileSync } from 'node:fs';
7
+ import { createRequire } from 'node:module';
5
8
  import { homedir } from 'node:os';
6
9
  import { basename, dirname, join, resolve } from 'node:path';
7
10
  import { fileURLToPath } from 'node:url';
@@ -17,12 +20,21 @@ const ENGINE = join(PKG_DIR, 'vendor', 'hyperframes', 'bin', 'hyperframes.mjs');
17
20
  const UPSTREAM = JSON.parse(readFileSync(join(PKG_DIR, 'vendor', 'hyperframes', 'UPSTREAM.json'), 'utf8'));
18
21
  export const VIDEOS_DIR = join(homedir(), 'ignix-videos');
19
22
 
23
+ // Las dependencias pueden estar en <paquete>/node_modules o, según cómo instale npm,
24
+ // al lado del paquete (npm install --prefix …): se resuelven como lo haría Node.
25
+ const requireHere = createRequire(import.meta.url);
26
+ const depDir = (name) => dirname(requireHere.resolve(`${name}/package.json`));
27
+
20
28
  const log = (...a) => console.error(...a);
21
29
  const fail = (msg) => {
22
30
  log(`✗ ${msg}`);
23
31
  process.exit(1);
24
32
  };
25
33
 
34
+ // Opciones del motor que llevan valor detrás: `snapshot --at 0,1.5` sin carpeta no debe
35
+ // tomar «0,1.5» por la carpeta del proyecto.
36
+ 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']);
37
+
26
38
  // ── argumentos: posicionales, nuestras opciones y el resto pasa al motor tal cual
27
39
  function parseArgs(argv, ours) {
28
40
  const pos = [];
@@ -36,9 +48,10 @@ function parseArgs(argv, ours) {
36
48
  if (ours[key] === 'bool') opts[key] = m?.[2] !== 'false';
37
49
  else opts[key] = m?.[2] ?? argv[++i];
38
50
  } 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);
51
+ else if (a.startsWith('-')) {
52
+ rest.push(a);
53
+ if (ENGINE_VALUE_FLAGS.has(a) && i + 1 < argv.length) rest.push(argv[++i]);
54
+ } else pos.push(a);
42
55
  }
43
56
  return { pos, opts, rest };
44
57
  }
@@ -80,13 +93,13 @@ function validateProject(dir) {
80
93
  const issues = projectHtml(dir).flatMap(validateHtml);
81
94
  for (const i of issues) log(`${i.level === 'error' ? '✗' : '⚠'} ${i.rule} ${i.file.replace(dir + '/', '')}:${i.line} — ${i.msg}`);
82
95
  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))` : ''}`);
96
+ log(errors ? `✗ ${errors} Ignix Editor error(s)` : `✓ Ignix Editor: HTML and assets OK${issues.length ? ` (${issues.length} warning(s))` : ''}`);
84
97
  return errors;
85
98
  }
86
99
 
87
100
  const projectDir = (p) => {
88
101
  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>)`);
102
+ 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
103
  return dir;
91
104
  };
92
105
 
@@ -94,38 +107,53 @@ const projectDir = (p) => {
94
107
  const commands = {
95
108
  async doctor() {
96
109
  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}`);
110
+ log(`${major >= 22 ? '✓' : '✗'} Node ${process.versions.node}${major >= 22 ? '' : ' — Node 22 or newer is required'}`);
111
+ log(`✓ Ignix Editor ${PKG.version} · HyperFrames engine ${UPSTREAM.version} (patches: ${UPSTREAM.patches.join(', ')})`);
112
+ log(` Platform ${PLATFORM} · data in ${HOME}`);
100
113
  let ok = major >= 22;
114
+ // doctor deja la máquina lista: descarga lo que falte en vez de solo avisar.
101
115
  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;
116
+ const before = findTool(name);
117
+ if (before.path) {
118
+ log(`✓ ${name}: ${before.path}${before.source === 'env' ? ' (environment variable)' : ''}`);
119
+ continue;
120
+ }
121
+ try {
122
+ const path = await ensureTool(name, log);
123
+ log(`✓ ${name}: ${path} (downloaded)`);
124
+ } catch (e) {
125
+ log(`✗ ${name}: ${e.message}`);
126
+ if (name !== 'whisper') ok = false;
127
+ }
106
128
  }
107
- if (findTool('ffmpeg').path) {
108
- const enc = spawnSync(findTool('ffmpeg').path, ['-hide_banner', '-encoders'], { encoding: 'utf8' }).stdout ?? '';
129
+ const ff = findTool('ffmpeg').path;
130
+ if (ff) {
131
+ const enc = spawnSync(ff, ['-hide_banner', '-encoders'], { encoding: 'utf8' }).stdout ?? '';
109
132
  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)'}`);
133
+ log(`${h264 ? '✓' : '✗'} H.264 encoder: ${h264 ?? 'none (needs libx264 or VideoToolbox)'}`);
111
134
  if (!h264) ok = false;
112
135
  }
136
+ log(ok ? '✓ Ready.' : '✗ Not ready: see the lines above.');
113
137
  process.exit(ok ? 0 : 1);
114
138
  },
115
139
 
116
140
  async init(argv) {
117
141
  const { pos, opts } = parseArgs(argv, { dir: 'str' });
118
142
  const name = pos[0];
119
- if (!name || !/^[\w.-]+$/.test(name)) fail('Uso: ignix-editor init <nombre> [--dir carpeta] (nombre sin espacios)');
143
+ if (!name || !/^[\w.-]+$/.test(name)) fail('Usage: ignix-editor init <name> [--dir folder] (name without spaces)');
120
144
  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.`);
145
+ if (existsSync(dir)) fail(`${dir} already exists. Pick another name or work inside that project.`);
146
+ // Todo se prepara antes de crear la carpeta: si algo falta, no queda un proyecto a medias.
147
+ const gsap = join(depDir('gsap'), 'dist', 'gsap.min.js');
148
+ const fonts = join(depDir('@fontsource/montserrat'), 'files');
149
+ 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
150
  cpSync(join(PKG_DIR, 'templates', 'blank'), dir, { recursive: true });
123
151
  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'));
152
+ cpSync(gsap, join(dir, 'assets', 'gsap.min.js'));
125
153
  for (const w of [700, 800, 900]) {
126
154
  for (const sub of ['latin', 'latin-ext']) {
127
155
  const f = `montserrat-${sub}-${w}-normal.woff2`;
128
- cpSync(join(PKG_DIR, 'node_modules', '@fontsource', 'montserrat', 'files', f), join(dir, 'assets', 'fonts', f));
156
+ cpSync(join(fonts, f), join(dir, 'assets', 'fonts', f));
129
157
  }
130
158
  }
131
159
  cpSync(join(PKG_DIR, 'src', 'runtime', 'ignix-frame.js'), join(dir, 'ignix-frame.js'));
@@ -139,7 +167,7 @@ const commands = {
139
167
  const dir = projectDir(pos[0]);
140
168
  const errors = validateProject(dir);
141
169
  const { build, snapped } = prepare(dir);
142
- if (snapped) log(`• ${snapped} tiempo(s) ajustado(s) al fotograma`);
170
+ if (snapped) log(`• ${snapped} time(s) snapped to the nearest frame`);
143
171
  const { env } = await engineEnv();
144
172
  const code = runEngine(['check', build, ...rest], env, dir);
145
173
  process.exit(errors ? 1 : code);
@@ -158,19 +186,19 @@ const commands = {
158
186
  async render(argv) {
159
187
  const { pos, opts, rest } = parseArgs(argv, { output: 'str', lufs: 'str', normalize: 'bool', force: 'bool' });
160
188
  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).');
189
+ if (validateProject(dir) && !opts.force) fail('Fix the errors above before rendering (or --force if you know what you are doing).');
162
190
  const { build, snapped } = prepare(dir);
163
- if (snapped) log(`• ${snapped} tiempo(s) ajustado(s) al fotograma`);
191
+ if (snapped) log(`• ${snapped} time(s) snapped to the nearest frame`);
164
192
  const { env, ffmpeg, ffprobe } = await engineEnv();
165
193
  const output = resolve(opts.output ?? join(dir, 'renders', `${basename(dir)}.mp4`));
166
194
  mkdirSync(dirname(output), { recursive: true });
167
195
  const raw = join(build, 'raw.mp4');
168
196
  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).');
197
+ if (code !== 0 || !existsSync(raw)) fail('The render failed (see the messages above).');
170
198
  if (opts.normalize !== false && hasAudio(ffprobe, raw)) {
171
199
  const lufs = Number(opts.lufs ?? -14);
172
200
  const r = normalizeLoudness(ffmpeg, raw, output, { lufs });
173
- log(`✓ Volumen: ${r.before.toFixed(1)} → ${loudness(ffmpeg, output)?.toFixed(1)} LUFS`);
201
+ log(`✓ Loudness: ${r.before.toFixed(1)} → ${loudness(ffmpeg, output)?.toFixed(1)} LUFS`);
174
202
  } else cpSync(raw, output);
175
203
  console.log(output);
176
204
  },
@@ -180,14 +208,14 @@ const commands = {
180
208
  const dir = projectDir(pos[0]);
181
209
  const { build } = prepare(dir);
182
210
  const { env } = await engineEnv();
183
- log('Vista previa de la copia de trabajo: si cambias el proyecto, vuelve a lanzar este comando.');
211
+ log('Previewing the working copy: if you change the project, run this command again.');
184
212
  process.exit(runEngine(['preview', build, ...rest], env, dir));
185
213
  },
186
214
 
187
215
  async transcribe(argv) {
188
216
  const { pos, opts } = parseArgs(argv, { lang: 'str', model: 'str', script: 'str', output: 'str', threads: 'str' });
189
217
  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]');
218
+ if (!input || !existsSync(input)) fail('Usage: ignix-editor transcribe <audio|video> [--lang es] [--model medium] [--script "what was said"] [-o words.json]');
191
219
  const ffmpeg = await ensureTool('ffmpeg', log);
192
220
  const ffprobe = await ensureTool('ffprobe', log);
193
221
  const whisper = await ensureTool('whisper', log);
@@ -198,8 +226,57 @@ const commands = {
198
226
  words = alignToScript(opts.script, words, dur);
199
227
  }
200
228
  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);
229
+ if (opts.output) {
230
+ mkdirSync(dirname(resolve(opts.output)), { recursive: true });
231
+ writeFileSync(resolve(opts.output), json);
232
+ } else console.log(json);
233
+ },
234
+
235
+ // Dónde está la cara a lo largo del vídeo, para encuadrar en vertical siguiéndola.
236
+ // macOS: Apple Vision desde un Swift propio (src/native/faces.swift), compilado una vez.
237
+ async faces(argv) {
238
+ const { pos, opts } = parseArgs(argv, { every: 'str', output: 'str', smooth: 'str' });
239
+ const input = pos[0] && resolve(pos[0]);
240
+ if (!input || !existsSync(input)) fail('Usage: ignix-editor faces <video> [--every 0.2] [--smooth 1] [-o work/faces.json]');
241
+ if (process.platform !== 'darwin') fail('faces needs macOS for now. Elsewhere, frame the person by hand from stills (snapshot) or keep a centred crop.');
242
+ const src = join(PKG_DIR, 'src', 'native', 'faces.swift');
243
+ const hash = createHash('sha256').update(readFileSync(src)).digest('hex').slice(0, 12);
244
+ const bin = join(HOME, 'bin', PLATFORM, `faces-${hash}`);
245
+ if (!existsSync(bin)) {
246
+ mkdirSync(dirname(bin), { recursive: true });
247
+ log('Compiling the face tracker (first time only)…');
248
+ const c = spawnSync('xcrun', ['swiftc', '-O', '-parse-as-library', src, '-o', bin], { encoding: 'utf8' });
249
+ if (c.status !== 0) fail(`Could not compile the face tracker. It needs Apple's Command Line Tools (xcode-select --install).\n${(c.stderr || c.error?.message || '').slice(-400)}`);
250
+ }
251
+ const every = Math.max(0.05, Number(opts.every ?? 0.2));
252
+ const r = spawnSync(bin, [input, String(every)], { encoding: 'utf8', maxBuffer: 256 * 1024 * 1024 });
253
+ if (r.status !== 0) fail(`Face tracking failed: ${(r.stderr || '').slice(-400)}`);
254
+ const raw = JSON.parse(r.stdout);
255
+ const S = raw.samples;
256
+ // Huecos sin cara: la posición conocida más cercana; si no hay ninguna, el centro.
257
+ const known = S.map((x, i) => (x.w ? i : -1)).filter((i) => i >= 0);
258
+ const filled = S.map((x, i) => {
259
+ if (x.w) return x;
260
+ if (!known.length) return { t: x.t, x: 0.4, y: 0.3, w: 0.2, h: 0.2 };
261
+ const k = known.reduce((a, b) => (Math.abs(b - i) < Math.abs(a - i) ? b : a));
262
+ return { ...S[k], t: x.t };
263
+ });
264
+ // Suavizado: media móvil de ~1 s (configurable) sobre el centro y el tamaño.
265
+ const half = Math.max(1, Math.round(Number(opts.smooth ?? 1) / every / 2));
266
+ const avg = (i, f) => {
267
+ let sum = 0, n = 0;
268
+ for (let j = Math.max(0, i - half); j <= Math.min(filled.length - 1, i + half); j++) (sum += f(filled[j])), n++;
269
+ return +(sum / n).toFixed(4);
270
+ };
271
+ const path = filled.map((x, i) => ({ t: x.t, cx: avg(i, (y) => y.x + y.w / 2), cy: avg(i, (y) => y.y + y.h / 2), size: avg(i, (y) => y.h) }));
272
+ const out = { source: input, width: raw.width, height: raw.height, duration: raw.duration, every, found: known.length, samples: S.length, path };
273
+ const json = JSON.stringify(out);
274
+ log(`✓ Face found in ${known.length}/${S.length} samples${known.length < S.length ? ' (gaps filled with the nearest position)' : ''}`);
275
+ if (opts.output) {
276
+ mkdirSync(dirname(resolve(opts.output)), { recursive: true });
277
+ writeFileSync(resolve(opts.output), json);
278
+ console.log(resolve(opts.output));
279
+ } else console.log(json);
203
280
  },
204
281
 
205
282
  async ffmpeg(argv) {
@@ -214,38 +291,39 @@ const commands = {
214
291
 
215
292
  async skills(argv) {
216
293
  const { pos, opts } = parseArgs(argv, { dir: 'str' });
217
- if (pos[0] !== 'add') fail('Uso: ignix-editor skills add [--dir ~/.claude/skills]');
294
+ if (pos[0] !== 'add') fail('Usage: ignix-editor skills add [--dir ~/.claude/skills]');
218
295
  const target = resolve(opts.dir ?? join(homedir(), '.claude', 'skills'), 'ignix-editor');
219
296
  rmSync(target, { recursive: true, force: true });
220
297
  cpSync(join(PKG_DIR, 'skills', 'ignix-editor'), target, { recursive: true });
221
- log(`✓ Skill instalada en ${target}`);
298
+ log(`✓ Skill installed in ${target}`);
222
299
  },
223
300
 
224
301
  async version() {
225
- console.log(`ignix-editor ${PKG.version} (motor HyperFrames ${UPSTREAM.version})`);
302
+ console.log(`ignix-editor ${PKG.version} (HyperFrames engine ${UPSTREAM.version})`);
226
303
  },
227
304
 
228
305
  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.`);
306
+ console.log(`Ignix Editor ${PKG.version} — video from HTML, rendered on your own computer.
307
+
308
+ ignix-editor doctor checks Node and the encoder, downloads ffmpeg and whisper if missing
309
+ ignix-editor init <name> creates ~/ignix-videos/<name>, ready to work in
310
+ ignix-editor check [folder] HTML, assets, layout, motion and contrast
311
+ ignix-editor snapshot [folder] --at 0,2.5,5 single frames to look at (into <folder>/snapshots)
312
+ ignix-editor render [folder] [-o out.mp4] [--lufs -14] [--no-normalize]
313
+ ignix-editor preview [folder] live preview in the browser
314
+ ignix-editor transcribe <file> [--lang es] [--script "text"] [-o words.json]
315
+ ignix-editor faces <video> [--every 0.2] [-o faces.json] where the face is, smoothed (macOS)
316
+ ignix-editor ffmpeg|ffprobe <args> Ignix Editor's own ffmpeg
317
+ ignix-editor skills add installs the skill for Claude
318
+
319
+ The folder defaults to the current one. Based on HyperFrames (Apache-2.0) by HeyGen, Inc. See NOTICE.`);
242
320
  },
243
321
  };
244
322
 
245
323
  export async function main(argv) {
246
324
  const [cmd = 'help', ...rest] = argv;
247
325
  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`);
326
+ if (!fn) fail(`Unknown command: ${cmd}. See: ignix-editor help`);
249
327
  try {
250
328
  await fn(rest);
251
329
  } catch (e) {
@@ -0,0 +1,60 @@
1
+ // faces — dónde está la cara en cada momento de un vídeo (Apple Vision, macOS 13+).
2
+ // Uso: faces <vídeo> <cada_segundos> → JSON por stdout:
3
+ // { "duration": s, "width": px, "height": px, "samples": [ { "t": s, "x":…, "y":…, "w":…, "h":… } | { "t": s } ] }
4
+ // x, y, w, h normalizados 0–1 con origen ARRIBA a la izquierda (como CSS). Sin cara → solo "t".
5
+ import AVFoundation
6
+ import Foundation
7
+ import Vision
8
+
9
+ @main
10
+ struct Faces {
11
+ static func main() async {
12
+ let args = CommandLine.arguments
13
+ guard args.count >= 2 else {
14
+ FileHandle.standardError.write("usage: faces <video> [every_seconds]\n".data(using: .utf8)!)
15
+ exit(2)
16
+ }
17
+ let url = URL(fileURLWithPath: args[1])
18
+ let every = args.count >= 3 ? (Double(args[2]) ?? 0.2) : 0.2
19
+ let asset = AVURLAsset(url: url)
20
+ do {
21
+ let duration = try await asset.load(.duration).seconds
22
+ guard let track = try await asset.loadTracks(withMediaType: .video).first else {
23
+ FileHandle.standardError.write("no video track\n".data(using: .utf8)!)
24
+ exit(1)
25
+ }
26
+ let size = try await track.load(.naturalSize).applying(try await track.load(.preferredTransform))
27
+ let gen = AVAssetImageGenerator(asset: asset)
28
+ gen.appliesPreferredTrackTransform = true
29
+ gen.requestedTimeToleranceBefore = CMTime(value: 1, timescale: 30)
30
+ gen.requestedTimeToleranceAfter = CMTime(value: 1, timescale: 30)
31
+ gen.maximumSize = CGSize(width: 960, height: 960)
32
+
33
+ var samples: [[String: Double]] = []
34
+ var t = 0.0
35
+ while t < duration {
36
+ var sample: [String: Double] = ["t": (t * 1000).rounded() / 1000]
37
+ if let (image, _) = try? await gen.image(at: CMTime(seconds: t, preferredTimescale: 600)) {
38
+ let req = VNDetectFaceRectanglesRequest()
39
+ try? VNImageRequestHandler(cgImage: image, options: [:]).perform([req])
40
+ // La cara más grande: la de quien habla a cámara.
41
+ if let f = (req.results ?? []).max(by: { $0.boundingBox.width * $0.boundingBox.height < $1.boundingBox.width * $1.boundingBox.height }) {
42
+ let b = f.boundingBox // Vision: origen ABAJO a la izquierda
43
+ sample["x"] = b.minX
44
+ sample["y"] = 1 - b.maxY
45
+ sample["w"] = b.width
46
+ sample["h"] = b.height
47
+ }
48
+ }
49
+ samples.append(sample)
50
+ t += every
51
+ }
52
+ let out: [String: Any] = ["duration": duration, "width": abs(size.width), "height": abs(size.height), "samples": samples]
53
+ let data = try JSONSerialization.data(withJSONObject: out)
54
+ FileHandle.standardOutput.write(data)
55
+ } catch {
56
+ FileHandle.standardError.write("faces failed: \(error)\n".data(using: .utf8)!)
57
+ exit(1)
58
+ }
59
+ }
60
+ }
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})`;