@ignix-social/editor 0.1.5 → 0.1.6

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/NOTICE CHANGED
@@ -83,3 +83,10 @@ PP-OCRv3 text detector (ONNX from OpenCV Zoo, opencv/text_detection_ppocr@c299e8
83
83
  CLIP ViT-B/32 (openai/clip-vit-base-patch32; ONNX export and tokenizer files from
84
84
  Xenova/clip-vit-base-patch32@d15189d) — MIT License, Copyright (c) 2021 OpenAI.
85
85
  The vision model is stored in two parts that Ignix Editor joins and checks.
86
+
87
+ Inter (font, from @fontsource/inter) — SIL Open Font License 1.1, Copyright (c) 2016
88
+ The Inter Project Authors. Copied into the projects Ignix Creator's templates render
89
+ (src/creator), unmodified.
90
+
91
+ Ignix Creator's composition templates (src/creator) were ported from Ignix Creator's own
92
+ code (Copyright (c) 2026 Ignix); no Remotion code is included.
package/README.md CHANGED
@@ -8,7 +8,7 @@ npx @ignix-social/editor init my-reel # creates ~/ignix-videos/my-reel
8
8
  npx @ignix-social/editor render ~/ignix-videos/my-reel
9
9
  ```
10
10
 
11
- Commands: `doctor`, `init`, `check`, `snapshot`, `render`, `preview`, `transcribe`, `faces`, `ffmpeg`, `ffprobe`, `skills add`, `serve`, `help`.
11
+ Commands: `doctor`, `init`, `check`, `snapshot`, `render`, `preview`, `transcribe`, `faces`, `ffmpeg`, `ffprobe`, `skills add`, `fetch`, `serve`, `creator-job`, `help`.
12
12
 
13
13
  - Everything runs locally; your footage never leaves your machine.
14
14
  - ffmpeg (GPL build, without libx264) and whisper.cpp are downloaded once per machine into `~/.ignix-editor`, checked against their SHA-256. Chrome for rendering is downloaded once from Google's official channel.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ignix-social/editor",
3
- "version": "0.1.5",
3
+ "version": "0.1.6",
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",
@@ -27,6 +27,7 @@
27
27
  "test": "node --test test/*.test.mjs"
28
28
  },
29
29
  "dependencies": {
30
+ "@fontsource/inter": "^5.3.0",
30
31
  "@fontsource/montserrat": "^5.3.0",
31
32
  "@hono/node-server": "^2.0.5",
32
33
  "@puppeteer/browsers": "^3.2.2",
@@ -113,3 +113,16 @@ export async function session(name, log = () => {}) {
113
113
  }
114
114
 
115
115
  export const RUNTIME_VERSION = manifest.runtimeVersion;
116
+
117
+ /** Lo que falta del análisis en este ordenador, con su tamaño de descarga (sin descargar nada). */
118
+ export function analysisMissing() {
119
+ const out = [];
120
+ const rt = manifest.runtime[PLATFORM];
121
+ const rtDir = join(HOME, 'runtime', `onnxruntime-${manifest.runtimeVersion}`, PLATFORM);
122
+ if (!process.env.IGNIX_EDITOR_ORT && rt && !existsSync(join(rtDir, 'onnxruntime-node', 'package.json'))) out.push({ item: 'runtime', size: rt.size });
123
+ for (const [name, entry] of Object.entries(manifest.models)) {
124
+ if (process.env.IGNIX_EDITOR_MODELS) continue;
125
+ if (!existsSync(join(HOME, 'models', 'analysis', manifest.version, entry.file))) out.push({ item: name, size: entry.size });
126
+ }
127
+ return out;
128
+ }
package/src/binaries.mjs CHANGED
@@ -3,7 +3,7 @@
3
3
  // descarga desde media.ignix.social con su sha256 comprobado. Chrome lo gestiona el motor
4
4
  // (descarga oficial de Google la primera vez).
5
5
  import { createHash } from 'node:crypto';
6
- import { chmodSync, createWriteStream, existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from 'node:fs';
6
+ import { chmodSync, createReadStream, createWriteStream, existsSync, mkdirSync, readFileSync, renameSync, rmSync, writeFileSync } from 'node:fs';
7
7
  import { Readable } from 'node:stream';
8
8
  import { pipeline } from 'node:stream/promises';
9
9
  import { homedir } from 'node:os';
@@ -62,9 +62,9 @@ export async function ensureTool(name, log = () => {}) {
62
62
 
63
63
  /** Modelos de whisper (ggml, MIT), en ~/.ignix-editor/models. */
64
64
  export const WHISPER_MODELS = {
65
- medium: { file: 'ggml-medium.bin', size: 1533763059 },
66
- small: { file: 'ggml-small.bin', size: 487601967 },
67
- base: { file: 'ggml-base.bin', size: 147951465 },
65
+ medium: { file: 'ggml-medium.bin', size: 1533763059, sha256: '6c14d5adee5f86394037b4e4e8b59f1673b6cee10e3cf0b11bbdbee79c156208' },
66
+ small: { file: 'ggml-small.bin', size: 487601967, sha256: '1be3a9b2063867b937e64e2ec7483364a79917e157fa98c5d94b5c1fffea987b' },
67
+ base: { file: 'ggml-base.bin', size: 147951465, sha256: '60ed5bc3dd14eea856493d334349b405782ddcaf0028d4b5df4088345fba2efe' },
68
68
  };
69
69
 
70
70
  export async function ensureWhisperModel(model = 'medium', log = () => {}) {
@@ -78,6 +78,19 @@ export async function ensureWhisperModel(model = 'medium', log = () => {}) {
78
78
  const res = await fetch(`https://huggingface.co/ggerganov/whisper.cpp/resolve/main/${m.file}`);
79
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
+ // El sha256 es el que publica Hugging Face para ese fichero (su oid de LFS).
82
+ const h = createHash('sha256');
83
+ await pipeline(createReadStream(`${dest}.part`), h);
84
+ if (h.digest('hex') !== m.sha256) {
85
+ rmSync(`${dest}.part`, { force: true });
86
+ throw new Error(`The downloaded ${model} model does not match its SHA-256. Not using it.`);
87
+ }
81
88
  renameSync(`${dest}.part`, dest);
82
89
  return dest;
83
90
  }
91
+
92
+ /** Ruta del modelo de whisper si ya está descargado (sin descargar nada). */
93
+ export const whisperModelPath = (model) => (WHISPER_MODELS[model] ? join(HOME, 'models', WHISPER_MODELS[model].file) : null);
94
+
95
+ /** Tamaño de descarga de un binario en esta plataforma (0 si no hay descarga). */
96
+ export const toolSize = (name) => manifest.platforms[PLATFORM]?.[name]?.size ?? 0;
package/src/cli.mjs CHANGED
@@ -10,15 +10,16 @@ import { fileURLToPath } from 'node:url';
10
10
  import { HOME, PLATFORM, TOOLS, ensureTool, ensureWhisperModel, findTool } from './binaries.mjs';
11
11
  import { linkMedia, prepare, projectHtml } from './prepare.mjs';
12
12
  import { validateHtml } from './validate.mjs';
13
- import { loudness, hasAudio, normalizeLoudness } from './audio.mjs';
13
+ import { engineEnv, renderProject, runEngine } from './engine.mjs';
14
14
  import { alignToScript, transcribe } from './transcribe.mjs';
15
15
  import { faces as detectFaces } from './analysis/detect.mjs';
16
16
  import { frames, probe } from './analysis/media.mjs';
17
17
  import { serve } from './analysis/serve.mjs';
18
+ import { creatorJob } from './creator/job.mjs';
19
+ import { fetchAll } from './fetch.mjs';
18
20
 
19
21
  const PKG_DIR = join(dirname(fileURLToPath(import.meta.url)), '..');
20
22
  const PKG = JSON.parse(readFileSync(join(PKG_DIR, 'package.json'), 'utf8'));
21
- const ENGINE = join(PKG_DIR, 'vendor', 'hyperframes', 'bin', 'hyperframes.mjs');
22
23
  const UPSTREAM = JSON.parse(readFileSync(join(PKG_DIR, 'vendor', 'hyperframes', 'UPSTREAM.json'), 'utf8'));
23
24
  export const VIDEOS_DIR = join(homedir(), 'ignix-videos');
24
25
 
@@ -58,38 +59,6 @@ function parseArgs(argv, ours) {
58
59
  return { pos, opts, rest };
59
60
  }
60
61
 
61
- // ── el motor
62
- async function engineEnv() {
63
- const env = {
64
- ...process.env,
65
- HYPERFRAMES_NO_TELEMETRY: '1',
66
- DO_NOT_TRACK: '1',
67
- HYPERFRAMES_NO_UPDATE_CHECK: '1',
68
- HYPERFRAMES_NO_AUTO_INSTALL: '1',
69
- HYPERFRAMES_SKIP_SKILLS: '1',
70
- HYPERFRAMES_FONT_CACHE_DIR: join(HOME, 'cache', 'fonts'),
71
- };
72
- const ffmpeg = await ensureTool('ffmpeg', log);
73
- const ffprobe = await ensureTool('ffprobe', log);
74
- env.HYPERFRAMES_FFMPEG_PATH = ffmpeg;
75
- env.HYPERFRAMES_FFPROBE_PATH = ffprobe;
76
- const whisper = findTool('whisper');
77
- if (whisper.path) env.HYPERFRAMES_WHISPER_PATH = whisper.path;
78
- // Por si alguna parte del motor llama a «ffmpeg» por su nombre: los nuestros van primero en el PATH.
79
- const shim = join(HOME, 'shim', PLATFORM);
80
- rmSync(shim, { recursive: true, force: true });
81
- mkdirSync(shim, { recursive: true });
82
- linkMedia(ffmpeg, join(shim, TOOLS.ffmpeg.file));
83
- linkMedia(ffprobe, join(shim, TOOLS.ffprobe.file));
84
- env.PATH = `${shim}${process.platform === 'win32' ? ';' : ':'}${env.PATH ?? ''}`;
85
- return { env, ffmpeg, ffprobe };
86
- }
87
-
88
- function runEngine(args, env, cwd) {
89
- const r = spawnSync(process.execPath, [ENGINE, ...args], { stdio: 'inherit', env, cwd });
90
- return r.status ?? 1;
91
- }
92
-
93
62
  // ── comprobaciones propias
94
63
  function validateProject(dir) {
95
64
  const issues = projectHtml(dir).flatMap(validateHtml);
@@ -172,7 +141,7 @@ const commands = {
172
141
  const errors = validateProject(dir);
173
142
  const { build, snapped } = prepare(dir);
174
143
  if (snapped) log(`• ${snapped} time(s) snapped to the nearest frame`);
175
- const { env } = await engineEnv();
144
+ const { env } = await engineEnv(log);
176
145
  const code = runEngine(['check', build, ...rest], env, dir);
177
146
  process.exit(errors ? 1 : code);
178
147
  },
@@ -181,7 +150,7 @@ const commands = {
181
150
  const { pos, rest } = parseArgs(argv, { output: 'str' });
182
151
  const dir = projectDir(pos[0]);
183
152
  const { build } = prepare(dir);
184
- const { env } = await engineEnv();
153
+ const { env } = await engineEnv(log);
185
154
  const out = resolve(dir, 'snapshots');
186
155
  rmSync(out, { recursive: true, force: true });
187
156
  process.exit(runEngine(['snapshot', build, '-o', out, '--describe', 'false', ...rest], env, dir));
@@ -191,19 +160,10 @@ const commands = {
191
160
  const { pos, opts, rest } = parseArgs(argv, { output: 'str', lufs: 'str', normalize: 'bool', force: 'bool' });
192
161
  const dir = projectDir(pos[0]);
193
162
  if (validateProject(dir) && !opts.force) fail('Fix the errors above before rendering (or --force if you know what you are doing).');
194
- const { build, snapped } = prepare(dir);
195
- if (snapped) log(`• ${snapped} time(s) snapped to the nearest frame`);
196
- const { env, ffmpeg, ffprobe } = await engineEnv();
197
163
  const output = resolve(opts.output ?? join(dir, 'renders', `${basename(dir)}.mp4`));
198
- mkdirSync(dirname(output), { recursive: true });
199
- const raw = join(build, 'raw.mp4');
200
- const code = runEngine(['render', build, '-o', raw, ...rest], env, dir);
201
- if (code !== 0 || !existsSync(raw)) fail('The render failed (see the messages above).');
202
- if (opts.normalize !== false && hasAudio(ffprobe, raw)) {
203
- const lufs = Number(opts.lufs ?? -14);
204
- const r = normalizeLoudness(ffmpeg, raw, output, { lufs });
205
- log(`✓ Loudness: ${r.before.toFixed(1)} → ${loudness(ffmpeg, output)?.toFixed(1)} LUFS`);
206
- } else cpSync(raw, output);
164
+ const r = await renderProject(dir, output, { lufs: opts.lufs, normalize: opts.normalize, engineArgs: rest, log });
165
+ if (r.snapped) log(`• ${r.snapped} time(s) snapped to the nearest frame`);
166
+ if (r.loudness) log(`✓ Loudness: ${r.loudness.before.toFixed(1)} → ${r.loudness.after?.toFixed(1)} LUFS`);
207
167
  console.log(output);
208
168
  },
209
169
 
@@ -211,7 +171,7 @@ const commands = {
211
171
  const { pos, rest } = parseArgs(argv, {});
212
172
  const dir = projectDir(pos[0]);
213
173
  const { build } = prepare(dir);
214
- const { env } = await engineEnv();
174
+ const { env } = await engineEnv(log);
215
175
  log('Previewing the working copy: if you change the project, run this command again.');
216
176
  process.exit(runEngine(['preview', build, ...rest], env, dir));
217
177
  },
@@ -284,6 +244,27 @@ const commands = {
284
244
  } else console.log(json);
285
245
  },
286
246
 
247
+ // El proceso de render de Ignix Creator: una carpeta de trabajo, eventos JSON (ADR-0023 E2).
248
+ async ['creator-job'](argv) {
249
+ await creatorJob(argv);
250
+ },
251
+
252
+ // Todo lo que se descarga, de una vez (Ignix Creator lo lanza al instalar el motor).
253
+ async fetch(argv) {
254
+ const { opts } = parseArgs(argv, { json: 'bool', whisper: 'str', analysis: 'bool' });
255
+ try {
256
+ const r = await fetchAll({ whisper: opts.whisper ?? 'small', analysis: opts.analysis !== false, json: !!opts.json, log });
257
+ if (opts.json) process.stdout.write(`${JSON.stringify({ t: 'done', ...r })}\n`);
258
+ else log(r.downloaded.length ? `✓ Downloaded ${r.downloaded.join(', ')}` : '✓ Everything was already here');
259
+ process.exit(0);
260
+ } catch (e) {
261
+ log(`${e?.stack ?? e}`);
262
+ const code = e?.code === 'invalid_args' ? 'invalid_args' : /SHA-256/.test(e?.message ?? '') ? 'integrity_failed' : 'network';
263
+ if (opts.json) process.stdout.write(`${JSON.stringify({ t: 'error', code })}\n`);
264
+ process.exit(1);
265
+ }
266
+ },
267
+
287
268
  // Servicio local para Ignix Creator: líneas JSON por stdin/stdout (ADR-0023).
288
269
  async serve() {
289
270
  await serve({ version: PKG.version, engine: UPSTREAM.version });
@@ -325,6 +306,7 @@ const commands = {
325
306
  ignix-editor faces <video> [--every 0.2] [-o faces.json] where the face is, smoothed
326
307
  ignix-editor ffmpeg|ffprobe <args> Ignix Editor's own ffmpeg
327
308
  ignix-editor skills add installs the skill for Claude
309
+ ignix-editor fetch [--whisper small] [--no-analysis] downloads everything up front (each file SHA-256-checked)
328
310
  ignix-editor serve local service for Ignix Creator (JSON lines on stdin/stdout)
329
311
 
330
312
  The folder defaults to the current one. Based on HyperFrames (Apache-2.0) by HeyGen, Inc. See NOTICE.`);
@@ -0,0 +1,344 @@
1
+ // Una composición de Ignix Creator (su CompositionSpec ya validado) → un proyecto de Ignix Editor
2
+ // listo para renderizar: los medios en assets/ con sus tiempos escritos en el HTML (vídeos,
3
+ // audios, imágenes), las fuentes, la tabla de anchos de carácter para ajustar textos y el
4
+ // dibujante de las plantillas (runtime/creator.js). ADR-0023 E2.
5
+ import { spawnSync } from 'node:child_process';
6
+ import { cpSync, existsSync, mkdirSync, writeFileSync } from 'node:fs';
7
+ import { dirname, extname, join } from 'node:path';
8
+ import { fileURLToPath } from 'node:url';
9
+ import { createRequire } from 'node:module';
10
+ import * as fontkit from 'fontkit';
11
+ import { linkMedia } from '../prepare.mjs';
12
+ import { INTER_DIR, INTER_FACES } from './fit.mjs';
13
+ import { probe } from '../analysis/media.mjs';
14
+
15
+ const HERE = dirname(fileURLToPath(import.meta.url));
16
+ const requireHere = createRequire(import.meta.url);
17
+ const PKG_DIR = join(HERE, '..', '..');
18
+ export const TRANSITION_SEC = 0.4;
19
+ const FORMAT_SIZE = { '9:16': [1080, 1920], '1:1': [1080, 1080], '4:5': [1080, 1350], '16:9': [1920, 1080] };
20
+ const SAFE = { '9:16': { top: 250, bottom: 440, side: 96 }, '4:5': { top: 110, bottom: 150, side: 90 }, '1:1': { top: 90, bottom: 110, side: 90 }, '16:9': { top: 80, bottom: 110, side: 140 } };
21
+ const PALETTES = {
22
+ clean: { bg: ['#f8f5f1', '#ece4da'], text: '#17110d', muted: '#5b4d42', overlay: '255,255,255', glow: 0.25 },
23
+ bold: { bg: ['#000000', '#101010'], text: '#ffffff', muted: '#d0d0d0', overlay: '0,0,0', glow: 0.22 },
24
+ ember: { bg: ['#140c08', '#2b1409'], text: '#fff7f0', muted: '#e2cfbf', overlay: '10,6,4', glow: 0.55 },
25
+ };
26
+ const DUCKED = 0.22;
27
+ const DUCK_RAMP_SEC = 0.18;
28
+
29
+ // ── tiempos (los mismos que Creator) ─────────────────────────────────────────
30
+ export function talkTimeline(segments, fps = 30) {
31
+ let at = 0;
32
+ return segments.map((s) => {
33
+ const frames = Math.max(1, Math.round((s.srcOut - s.srcIn) * fps));
34
+ const r = { srcIn: s.srcIn, srcOut: s.srcOut, startFrame: at, frames, outStart: at / fps };
35
+ at += frames;
36
+ return r;
37
+ });
38
+ }
39
+ export function totalSeconds(c) {
40
+ if (c.talk) {
41
+ const last = talkTimeline(c.talk.segments).at(-1);
42
+ return last ? last.outStart + last.frames / 30 : 0;
43
+ }
44
+ let total = 0;
45
+ c.scenes.forEach((s, i) => {
46
+ total += s.durationSec;
47
+ if (i > 0 && s.transition !== 'cut') total -= TRANSITION_SEC;
48
+ });
49
+ return Math.max(0, total);
50
+ }
51
+ export function sceneStarts(c) {
52
+ const starts = [];
53
+ let t = 0;
54
+ c.scenes.forEach((s, i) => {
55
+ if (i > 0 && s.transition !== 'cut') t -= TRANSITION_SEC;
56
+ starts.push(t);
57
+ t += s.durationSec;
58
+ });
59
+ return starts;
60
+ }
61
+ export function speechIntervals(c) {
62
+ const out = c.voice.map((v) => ({ start: v.startSec, end: v.startSec + v.durationSec }));
63
+ if (c.talk && c.talk.volume > 0) out.push({ start: 0, end: totalSeconds(c) });
64
+ let t = 0;
65
+ c.scenes.forEach((s, i) => {
66
+ if (i > 0 && s.transition !== 'cut') t -= TRANSITION_SEC;
67
+ if (s.visual.kind === 'video' && s.visual.withSound && s.visual.volume > 0) out.push({ start: t, end: t + s.durationSec });
68
+ t += s.durationSec;
69
+ });
70
+ out.sort((a, b) => a.start - b.start);
71
+ const merged = [];
72
+ for (const x of out) {
73
+ const last = merged.at(-1);
74
+ if (last && x.start <= last.end + 0.25) last.end = Math.max(last.end, x.end);
75
+ else merged.push({ ...x });
76
+ }
77
+ return merged;
78
+ }
79
+
80
+ /** El volumen de la música en el instante t: fundidos y bajada bajo la voz (como Creator). */
81
+ export function musicLevel(spec, total, t) {
82
+ const a = spec.audio;
83
+ const lin = (x, [x0, x1], [y0, y1]) => (x <= x0 ? y0 : x >= x1 ? y1 : y0 + ((y1 - y0) * (x - x0)) / (x1 - x0));
84
+ const inS = Math.max(1 / 30, a.fadeInSec);
85
+ const outS = Math.max(1 / 30, a.fadeOutSec);
86
+ const fade = Math.min(lin(t, [0, inS], [0, 1]), lin(t, [total - outS, total], [1, 0]));
87
+ let duck = 1;
88
+ if (a.duck) {
89
+ for (const iv of speechIntervals(spec)) {
90
+ if (t < iv.start - DUCK_RAMP_SEC || t > iv.end + DUCK_RAMP_SEC * 2) continue;
91
+ const down = lin(t, [iv.start - DUCK_RAMP_SEC, iv.start], [1, DUCKED]);
92
+ const up = lin(t, [iv.end, iv.end + DUCK_RAMP_SEC * 2], [DUCKED, 1]);
93
+ duck = Math.min(duck, Math.max(down, t >= iv.end ? up : DUCKED));
94
+ }
95
+ }
96
+ return a.volume * duck * fade;
97
+ }
98
+
99
+ // ── HTML ─────────────────────────────────────────────────────────────────────
100
+ const esc = (s) => String(s).replace(/&/g, '&amp;').replace(/"/g, '&quot;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
101
+ const attrs = (o) =>
102
+ Object.entries(o)
103
+ .filter(([, v]) => v !== undefined && v !== null && v !== false)
104
+ .map(([k, v]) => (v === true ? k : `${k}="${esc(v)}"`))
105
+ .join(' ');
106
+ const css = (o) =>
107
+ Object.entries(o)
108
+ .map(([k, v]) => `${k.replace(/[A-Z]/g, (m) => `-${m.toLowerCase()}`)}:${v}`)
109
+ .join(';');
110
+ const n6 = (x) => +Number(x).toFixed(6);
111
+
112
+ /** Puntos de volumen como automatización del motor (t desde el inicio del propio clip). */
113
+ function lane(points) {
114
+ return JSON.stringify({ version: 1, lanes: [{ target: 'volume', points: points.map(([t, v]) => ({ t: n6(t), v: +Number(v).toFixed(4) })) }] });
115
+ }
116
+ /** Fundidos de 8 ms en cada corte (que no chasque). */
117
+ const clickless = (dur, v = 1) => lane([[0, 0], [0.008, v], [Math.max(0.009, dur - 0.016), v], [dur, 0]]);
118
+
119
+ /** Anchos de carácter (a 1 px) de Inter 400/600/800 para todo el texto de la pieza. */
120
+ function glyphTable(texts) {
121
+ const chars = new Set('?.,0123456789 VS✓“'.split(''));
122
+ for (const t of texts) for (const ch of `${t}${t.toUpperCase()}`) chars.add(ch);
123
+ const out = {};
124
+ for (const w of [400, 600, 800]) {
125
+ const f = fontkit.openSync(join(INTER_DIR, `inter-latin-${w}-normal.woff2`));
126
+ const ext = fontkit.openSync(join(INTER_DIR, `inter-latin-ext-${w === 600 ? 400 : w}-normal.woff2`));
127
+ out[w] = {};
128
+ for (const ch of chars) {
129
+ const cp = ch.codePointAt(0);
130
+ const font = f.hasGlyphForCodePoint(cp) ? f : ext.hasGlyphForCodePoint(cp) ? ext : f;
131
+ out[w][ch] = +(font.glyphForCodePoint(cp).advanceWidth / font.unitsPerEm).toFixed(5);
132
+ }
133
+ }
134
+ return out;
135
+ }
136
+ function textsOf(spec) {
137
+ const t = [spec.brand.name ?? '', spec.brand.handle ?? ''];
138
+ for (const s of spec.scenes) {
139
+ const x = s.text;
140
+ if (!x) continue;
141
+ t.push(x.headline, x.body ?? '', x.kicker ?? '', ...x.items, ...x.lines.map((l) => l.text), ...x.data.flatMap((d) => [d.label, d.display ?? '']));
142
+ if (x.compare) t.push(x.compare.left, x.compare.right, ...x.compare.rows.flatMap((r) => [r.a, r.b]));
143
+ }
144
+ for (const c of spec.captions) t.push(c.text, ...c.words.map((w) => w.text));
145
+ if (spec.talk) for (const o of spec.talk.overlays) t.push(o.text);
146
+ return t;
147
+ }
148
+
149
+ /**
150
+ * Prepara el proyecto en `dir`. `media` = { [assetRef]: { path, kind, w?, h? } } (los ficheros
151
+ * del trabajo). Devuelve la duración y el id de la composición.
152
+ */
153
+ export function buildProject({ spec, media, dir, ffmpeg, ffprobe }) {
154
+ const [W, H] = FORMAT_SIZE[spec.format];
155
+ const fps = spec.fps;
156
+ const total = totalSeconds(spec);
157
+ const duration = +(Math.max(1, Math.round(total * fps)) / fps).toFixed(6);
158
+ const id = 'creator';
159
+ mkdirSync(join(dir, 'assets', 'fonts'), { recursive: true });
160
+ mkdirSync(join(dir, 'assets', 'media'), { recursive: true });
161
+ // Medios: un enlace por uso simultáneo (el motor no deja el mismo fichero dos veces a la vez).
162
+ let k = 0;
163
+ const durations = new Map();
164
+ const place = (ref, tag = '') => {
165
+ const m = media[ref];
166
+ if (!m || !existsSync(m.path)) throw new Error(`missing input ${ref}`);
167
+ const name = `assets/media/m${++k}${tag}${extname(m.path).toLowerCase()}`;
168
+ linkMedia(m.path, join(dir, name));
169
+ return name;
170
+ };
171
+ const lengthOf = (ref) => {
172
+ if (!durations.has(ref)) durations.set(ref, probe(ffprobe, media[ref].path).duration);
173
+ return durations.get(ref);
174
+ };
175
+ let track = 0;
176
+ const body = [];
177
+ const data = {
178
+ id,
179
+ fps,
180
+ width: W,
181
+ height: H,
182
+ duration,
183
+ style: spec.style,
184
+ pal: PALETTES[spec.style] ?? PALETTES.ember,
185
+ accent: spec.brand.accent,
186
+ brand: { name: spec.brand.name ?? null, handle: spec.brand.handle ?? null },
187
+ safe: SAFE[spec.format],
188
+ transitionSec: TRANSITION_SEC,
189
+ scenes: [],
190
+ captions: spec.captions,
191
+ captionStyle: spec.captionStyle,
192
+ captionPosition: spec.captionPosition,
193
+ captionColor: spec.captionColor ?? null,
194
+ talk: null,
195
+ glyphs: glyphTable(textsOf(spec)),
196
+ };
197
+ const fillCss = { position: 'absolute', left: '0', top: '0', width: '100%', height: '100%' };
198
+
199
+ // ── la charla (el vídeo de la persona)
200
+ if (spec.talk) {
201
+ const talk = spec.talk;
202
+ const timeline = talkTimeline(talk.segments, fps);
203
+ const contain = talk.fit === 'contain';
204
+ const anchor = !contain && talk.anchorX !== undefined ? `${Math.round(talk.anchorX * 1000) / 10}% 50%` : '50% 50%';
205
+ const src = place(talk.asset);
206
+ const under = contain ? place(talk.asset, 'u') : null;
207
+ const voiceSrc = talk.voice ? place(talk.voice) : null;
208
+ const segs = [];
209
+ const vids = [];
210
+ timeline.forEach((seg, i) => {
211
+ const dur = n6(seg.frames / fps);
212
+ const start = n6(seg.startFrame / fps);
213
+ if (under) vids.push(`<video ${attrs({ id: `talk-u${i}`, src: under, muted: true, playsinline: true, 'data-start': start, 'data-duration': dur, 'data-media-start': n6(seg.srcIn), 'data-track-index': track++ })} style="${css({ ...fillCss, objectFit: 'cover', filter: 'blur(20px) brightness(0.8) saturate(1.15)', transform: 'scale(1.2)' })}"></video>`);
214
+ const keepSound = talk.volume > 0 && !voiceSrc;
215
+ vids.push(`<video ${attrs({ id: `talk-v${i}`, src, muted: !keepSound, playsinline: true, 'data-has-audio': keepSound ? 'true' : null, 'data-volume': keepSound ? talk.volume : null, 'data-automation': keepSound ? clickless(dur, 1) : null, 'data-start': start, 'data-duration': dur, 'data-media-start': n6(seg.srcIn), 'data-track-index': track++ })} style="${css({ ...fillCss, objectFit: contain ? 'contain' : 'cover', objectPosition: anchor })}"></video>`);
216
+ if (voiceSrc && talk.volume > 0)
217
+ vids.push(`<audio ${attrs({ id: `talk-a${i}`, src: voiceSrc, 'data-start': start, 'data-duration': dur, 'data-media-start': n6(Math.max(0, seg.srcIn - talk.voiceStart)), 'data-volume': 1, 'data-automation': clickless(dur, talk.volume), 'data-track-index': track++ })}></audio>`);
218
+ segs.push({ ...seg, videoId: `talk-v${i}` });
219
+ });
220
+ body.push(`<div id="talk" style="${css({ ...fillCss, background: '#000', overflow: 'hidden', zIndex: '1' })}"><div id="talk-zoom" style="${css(fillCss)}">${vids.join('')}</div></div>`);
221
+ const broll = talk.broll.map((b, i) => {
222
+ const holderId = `br${i}`;
223
+ const mediaId = `br${i}-m`;
224
+ const file = place(b.asset, `b${i}`);
225
+ const dur = n6(b.endSec - b.startSec);
226
+ const pic = { width: '100%', height: '100%', objectFit: 'cover', display: 'block' };
227
+ const el =
228
+ b.kind === 'video'
229
+ ? `<video ${attrs({ id: mediaId, src: file, muted: true, playsinline: true, 'data-start': n6(b.startSec), 'data-duration': dur, 'data-media-start': n6(b.srcIn), 'data-track-index': track++ })} style="${css(pic)}"></video>`
230
+ : `<img ${attrs({ id: mediaId, src: file, alt: '' })} style="${css(pic)}">`;
231
+ if (b.mode === 'pip') {
232
+ const safe = SAFE[spec.format];
233
+ return `<div id="${holderId}" style="${css({ ...fillCss, display: 'none', flexDirection: 'column', alignItems: 'flex-end', paddingTop: `${safe.top + 20}px`, paddingRight: `${safe.side}px`, boxSizing: 'border-box', zIndex: '50' })}"><div id="${holderId}-card" style="${css({ width: '46%', aspectRatio: '4 / 5', borderRadius: '28px', overflow: 'hidden', border: '6px solid #fff', outline: `3px solid ${spec.brand.accent}`, boxShadow: '0 22px 50px rgba(0,0,0,0.5)' })}">${el}</div></div>`;
234
+ }
235
+ return `<div id="${holderId}" style="${css({ ...fillCss, display: 'none', overflow: 'hidden', background: '#000', zIndex: '50' })}">${el}</div>`;
236
+ });
237
+ body.push(...broll);
238
+ body.push(`<div id="overlays" style="${css({ ...fillCss, zIndex: '60', pointerEvents: 'none' })}"></div>`);
239
+ data.talk = { fit: talk.fit, track: talk.track, zooms: talk.zooms, overlays: talk.overlays, timeline: segs, broll: talk.broll.map((b, i) => ({ ...b, holderId: `br${i}`, mediaId: `br${i}-m` })) };
240
+ }
241
+
242
+ // ── escenas
243
+ const starts = sceneStarts(spec);
244
+ spec.scenes.forEach((sc, i) => {
245
+ const v = sc.visual;
246
+ const start = n6(starts[i]);
247
+ const dur = n6(Math.max(1, Math.round(sc.durationSec * fps)) / fps);
248
+ let inner = '';
249
+ const vis = { kind: v.kind };
250
+ if (v.kind === 'image' || v.kind === 'video') {
251
+ const contain = v.fit === 'contain';
252
+ const fit = { width: '100%', height: '100%', objectFit: contain ? 'contain' : 'cover', display: 'block', ...(!contain && v.anchorX !== undefined ? { objectPosition: `${Math.round(v.anchorX * 1000) / 10}% 50%` } : {}) };
253
+ const under = { width: '100%', height: '100%', objectFit: 'cover', filter: 'blur(38px) brightness(0.55)', transform: 'scale(1.15)', display: 'block' };
254
+ const mediaId = `sc${i}-m`;
255
+ if (v.kind === 'image') {
256
+ const file = place(v.asset);
257
+ inner = `${contain ? `<img src="${file}" alt="" style="${css({ ...fillCss, ...under })}">` : ''}<div style="${css({ ...fillCss, overflow: 'hidden' })}"><img id="${mediaId}" src="${file}" alt="" style="${css(fit)}"></div>`;
258
+ vis.motion = v.motion;
259
+ } else {
260
+ const file = place(v.asset);
261
+ const keep = v.withSound && v.volume > 0;
262
+ const tm = { 'data-start': start, 'data-duration': dur, 'data-media-start': n6(v.startSec) };
263
+ if (contain) inner += `<video ${attrs({ src: place(v.asset, 'u'), muted: true, playsinline: true, ...tm, 'data-track-index': track++ })} style="${css({ ...fillCss, ...under })}"></video>`;
264
+ inner += `<div style="${css({ ...fillCss, overflow: 'hidden' })}"><video ${attrs({ id: mediaId, src: file, muted: !keep, playsinline: true, 'data-has-audio': keep ? 'true' : null, 'data-volume': keep ? 1 : null, 'data-automation': keep ? clickless(dur, v.volume) : null, ...tm, 'data-track-index': track++ })} style="${css(fit)}"></video></div>`;
265
+ }
266
+ vis.mediaId = mediaId;
267
+ } else if (v.kind === 'screen') {
268
+ const file = place(v.asset);
269
+ const m = media[v.asset];
270
+ vis.imgId = `sc${i}-m`;
271
+ vis.mediaId = vis.imgId;
272
+ Object.assign(vis, { frame: v.frame, move: v.move, focus: v.focus, pointer: v.pointer, w: m.w ?? null, h: m.h ?? null });
273
+ inner = `<img id="${vis.imgId}" src="${file}" alt="" style="display:block">`;
274
+ }
275
+ body.push(`<div id="scene${i}" style="${css({ ...fillCss, overflow: 'hidden', display: 'none', background: data.pal.bg[0] })}">${inner}</div>`);
276
+ data.scenes.push({ start, durationSec: sc.durationSec, transition: sc.transition, visual: vis, text: sc.text });
277
+ });
278
+
279
+ body.push(`<div id="captions" style="${css({ ...fillCss, display: 'flex', flexDirection: 'column', zIndex: '80', boxSizing: 'border-box', pointerEvents: 'none' })}"></div>`);
280
+ if (spec.progressBar) body.push(`<div id="progress" style="${css({ position: 'absolute', top: '0', left: '0', height: '8px', width: '0', background: spec.brand.accent, opacity: '0.9', zIndex: '90' })}"></div>`);
281
+
282
+ // ── sonido: música, voces y efectos
283
+ if (spec.audio && media[spec.audio.asset]) {
284
+ const file = place(spec.audio.asset);
285
+ const len = lengthOf(spec.audio.asset);
286
+ const dur = n6(Math.min(duration, len));
287
+ const keys = new Set([0, spec.audio.fadeInSec, duration - spec.audio.fadeOutSec, dur]);
288
+ if (spec.audio.duck) for (const iv of speechIntervals(spec)) for (const x of [iv.start - DUCK_RAMP_SEC, iv.start, iv.end, iv.end + DUCK_RAMP_SEC * 2]) keys.add(x);
289
+ const points = [...keys].filter((x) => x >= 0 && x <= dur).sort((a, b) => a - b).map((x) => [x, musicLevel(spec, duration, x)]);
290
+ body.push(`<audio ${attrs({ id: 'music', src: file, 'data-start': 0, 'data-duration': dur, 'data-volume': 1, 'data-automation': lane(points), 'data-track-index': track++ })}></audio>`);
291
+ }
292
+ spec.voice.forEach((v, i) => {
293
+ let file = place(v.asset);
294
+ let len = lengthOf(v.asset);
295
+ if (v.rate && v.rate !== 1) {
296
+ // Más rápida sin cambiar el tono (una línea doblada que tiene que caber en su hueco).
297
+ const out = `assets/media/voice${i}-x${v.rate}.wav`;
298
+ const r = spawnSync(ffmpeg, ['-nostdin', '-loglevel', 'error', '-y', '-i', join(dir, file), '-af', `atempo=${v.rate}`, join(dir, out)], { encoding: 'utf8' });
299
+ if (r.status !== 0) throw new Error(`voice rate: ${r.stderr.slice(-200)}`);
300
+ file = out;
301
+ len /= v.rate;
302
+ }
303
+ const dur = n6(Math.min(len, duration - v.startSec));
304
+ if (dur > 0.02) body.push(`<audio ${attrs({ id: `voice${i}`, src: file, 'data-start': n6(v.startSec), 'data-duration': dur, 'data-volume': 1, 'data-automation': clickless(dur, v.volume), 'data-track-index': track++ })}></audio>`);
305
+ });
306
+ spec.sfx.forEach((s, i) => {
307
+ const file = place(s.asset);
308
+ const dur = n6(Math.min(lengthOf(s.asset), duration - s.atSec));
309
+ if (dur > 0.02) body.push(`<audio ${attrs({ id: `sfx${i}`, src: file, 'data-start': n6(s.atSec), 'data-duration': dur, 'data-volume': 1, 'data-automation': lane([[0, s.volume], [dur, s.volume]]), 'data-track-index': track++ })}></audio>`);
310
+ });
311
+
312
+ // ── fuentes, motor del fotograma y dibujante
313
+ for (const f of INTER_FACES) cpSync(join(INTER_DIR, f.file), join(dir, 'assets', 'fonts', f.file));
314
+ cpSync(join(PKG_DIR, 'src', 'runtime', 'ignix-frame.js'), join(dir, 'ignix-frame.js'));
315
+ cpSync(join(HERE, 'runtime', 'creator.js'), join(dir, 'creator.js'));
316
+ // GSAP donde lo haya dejado npm (dentro del paquete o al lado, según cómo se instaló).
317
+ cpSync(join(dirname(requireHere.resolve('gsap/package.json')), 'dist', 'gsap.min.js'), join(dir, 'assets', 'gsap.min.js'), { dereference: true });
318
+ const faces = INTER_FACES.map((f) => `@font-face{font-family:'Inter';font-weight:${f.weight};font-display:block;src:url('assets/fonts/${f.file}') format('woff2');${f.range ? `unicode-range:${f.range};` : ''}}`).join('\n');
319
+ const html = `<!DOCTYPE html>
320
+ <html lang="en">
321
+ <head>
322
+ <meta charset="UTF-8">
323
+ <meta name="viewport" content="width=${W}, height=${H}">
324
+ <script src="assets/gsap.min.js"></script>
325
+ <script src="ignix-frame.js"></script>
326
+ <style>
327
+ ${faces}
328
+ *{margin:0;padding:0;box-sizing:border-box}
329
+ html,body{width:${W}px;height:${H}px;overflow:hidden;background:${data.pal.bg[0]}}
330
+ #root{position:relative;width:100%;height:100%;overflow:hidden;background:${data.pal.bg[0]};font-family:'Inter',sans-serif}
331
+ </style>
332
+ </head>
333
+ <body>
334
+ <div id="root" data-composition-id="${id}" data-width="${W}" data-height="${H}" data-duration="${duration}" data-fps="${fps}" data-layout-allow-overlap data-layout-allow-overflow>
335
+ ${body.join('\n')}
336
+ </div>
337
+ <script>window.CREATOR = ${JSON.stringify(data).replace(/</g, '\\u003c')};</script>
338
+ <script src="creator.js"></script>
339
+ </body>
340
+ </html>
341
+ `;
342
+ writeFileSync(join(dir, 'index.html'), html);
343
+ return { duration, id, width: W, height: H };
344
+ }
@@ -0,0 +1,58 @@
1
+ // Ajuste de textos de las plantillas de Creator con las medidas REALES de la fuente (Inter),
2
+ // al generar el proyecto (en Node): el dibujante nunca mide la página. Mismas reglas que en
3
+ // Creator: el tamaño más grande (de `max` hacia abajo, de 4 en 4) cuyas líneas caben en `width`
4
+ // y en `maxLines`; si no, el mínimo tal cual.
5
+ import { createRequire } from 'node:module';
6
+ import { dirname, join } from 'node:path';
7
+ import * as fontkit from 'fontkit';
8
+
9
+ const require = createRequire(import.meta.url);
10
+ export const INTER_DIR = join(dirname(require.resolve('@fontsource/inter/package.json')), 'files');
11
+
12
+ // Las mismas caras que cargaba Creator: 400, 600 y 800 (y latin-ext 400 y 800). Un 700 o un 900
13
+ // se dibuja con la 800 (la más cercana por encima que hay), así que se mide con ella.
14
+ export const INTER_FACES = [
15
+ { weight: 400, file: 'inter-latin-400-normal.woff2' },
16
+ { weight: 600, file: 'inter-latin-600-normal.woff2' },
17
+ { weight: 800, file: 'inter-latin-800-normal.woff2' },
18
+ { weight: 400, file: 'inter-latin-ext-400-normal.woff2', range: 'U+0100-024F' },
19
+ { weight: 800, file: 'inter-latin-ext-800-normal.woff2', range: 'U+0100-024F' },
20
+ ];
21
+
22
+ const fonts = new Map();
23
+ function fontFor(weight) {
24
+ const w = Number(weight) >= 700 ? 800 : Number(weight) >= 600 ? 600 : 400;
25
+ if (!fonts.has(w)) fonts.set(w, fontkit.openSync(join(INTER_DIR, `inter-latin-${w}-normal.woff2`)));
26
+ return fonts.get(w);
27
+ }
28
+
29
+ /** Ancho en px de `text` a `size` px, con el espaciado de letras de las plantillas (−0,02 em). */
30
+ export function measure(text, size, weight, letterSpacingEm = -0.02) {
31
+ const f = fontFor(weight);
32
+ const run = f.layout(text);
33
+ return (run.advanceWidth / f.unitsPerEm) * size + letterSpacingEm * size * [...text].length;
34
+ }
35
+
36
+ export function fitLines(text, { width, max, min, maxLines, weight }) {
37
+ const words = String(text).split(/\s+/).filter(Boolean);
38
+ for (let size = max; size >= min; size -= 4) {
39
+ const lines = [];
40
+ let current = '';
41
+ let ok = true;
42
+ for (const word of words) {
43
+ if (measure(word, size, weight) > width) {
44
+ ok = false;
45
+ break;
46
+ }
47
+ const next = current ? `${current} ${word}` : word;
48
+ if (measure(next, size, weight) <= width) current = next;
49
+ else {
50
+ lines.push(current);
51
+ current = word;
52
+ }
53
+ }
54
+ if (current) lines.push(current);
55
+ if (ok && lines.length <= maxLines) return { size, lines };
56
+ }
57
+ return { size: min, lines: [text] };
58
+ }