@ignix-social/editor 0.1.3 → 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/src/cli.mjs CHANGED
@@ -2,21 +2,24 @@
2
2
  // debajo con nuestro entorno: sin telemetría, sin actualizaciones solas, con nuestros binarios.
3
3
  // Los mensajes van en inglés, como el manual y la skill: los lee sobre todo el agente.
4
4
  import { spawnSync } from 'node:child_process';
5
- import { createHash } from 'node:crypto';
6
- import { cpSync, existsSync, mkdirSync, readFileSync, rmSync, symlinkSync, writeFileSync } from 'node:fs';
5
+ import { cpSync, existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
7
6
  import { createRequire } from 'node:module';
8
7
  import { homedir } from 'node:os';
9
8
  import { basename, dirname, join, resolve } from 'node:path';
10
9
  import { fileURLToPath } from 'node:url';
11
10
  import { HOME, PLATFORM, TOOLS, ensureTool, ensureWhisperModel, findTool } from './binaries.mjs';
12
- import { prepare, projectHtml } from './prepare.mjs';
11
+ import { linkMedia, prepare, projectHtml } from './prepare.mjs';
13
12
  import { validateHtml } from './validate.mjs';
14
- import { loudness, hasAudio, normalizeLoudness } from './audio.mjs';
13
+ import { engineEnv, renderProject, runEngine } from './engine.mjs';
15
14
  import { alignToScript, transcribe } from './transcribe.mjs';
15
+ import { faces as detectFaces } from './analysis/detect.mjs';
16
+ import { frames, probe } from './analysis/media.mjs';
17
+ import { serve } from './analysis/serve.mjs';
18
+ import { creatorJob } from './creator/job.mjs';
19
+ import { fetchAll } from './fetch.mjs';
16
20
 
17
21
  const PKG_DIR = join(dirname(fileURLToPath(import.meta.url)), '..');
18
22
  const PKG = JSON.parse(readFileSync(join(PKG_DIR, 'package.json'), 'utf8'));
19
- const ENGINE = join(PKG_DIR, 'vendor', 'hyperframes', 'bin', 'hyperframes.mjs');
20
23
  const UPSTREAM = JSON.parse(readFileSync(join(PKG_DIR, 'vendor', 'hyperframes', 'UPSTREAM.json'), 'utf8'));
21
24
  export const VIDEOS_DIR = join(homedir(), 'ignix-videos');
22
25
 
@@ -56,38 +59,6 @@ function parseArgs(argv, ours) {
56
59
  return { pos, opts, rest };
57
60
  }
58
61
 
59
- // ── el motor
60
- async function engineEnv() {
61
- const env = {
62
- ...process.env,
63
- HYPERFRAMES_NO_TELEMETRY: '1',
64
- DO_NOT_TRACK: '1',
65
- HYPERFRAMES_NO_UPDATE_CHECK: '1',
66
- HYPERFRAMES_NO_AUTO_INSTALL: '1',
67
- HYPERFRAMES_SKIP_SKILLS: '1',
68
- HYPERFRAMES_FONT_CACHE_DIR: join(HOME, 'cache', 'fonts'),
69
- };
70
- const ffmpeg = await ensureTool('ffmpeg', log);
71
- const ffprobe = await ensureTool('ffprobe', log);
72
- env.HYPERFRAMES_FFMPEG_PATH = ffmpeg;
73
- env.HYPERFRAMES_FFPROBE_PATH = ffprobe;
74
- const whisper = findTool('whisper');
75
- if (whisper.path) env.HYPERFRAMES_WHISPER_PATH = whisper.path;
76
- // Por si alguna parte del motor llama a «ffmpeg» por su nombre: los nuestros van primero en el PATH.
77
- const shim = join(HOME, 'shim', PLATFORM);
78
- rmSync(shim, { recursive: true, force: true });
79
- mkdirSync(shim, { recursive: true });
80
- symlinkSync(ffmpeg, join(shim, TOOLS.ffmpeg.file));
81
- symlinkSync(ffprobe, join(shim, TOOLS.ffprobe.file));
82
- env.PATH = `${shim}${process.platform === 'win32' ? ';' : ':'}${env.PATH ?? ''}`;
83
- return { env, ffmpeg, ffprobe };
84
- }
85
-
86
- function runEngine(args, env, cwd) {
87
- const r = spawnSync(process.execPath, [ENGINE, ...args], { stdio: 'inherit', env, cwd });
88
- return r.status ?? 1;
89
- }
90
-
91
62
  // ── comprobaciones propias
92
63
  function validateProject(dir) {
93
64
  const issues = projectHtml(dir).flatMap(validateHtml);
@@ -129,8 +100,10 @@ const commands = {
129
100
  const ff = findTool('ffmpeg').path;
130
101
  if (ff) {
131
102
  const enc = spawnSync(ff, ['-hide_banner', '-encoders'], { encoding: 'utf8' }).stdout ?? '';
132
- const h264 = /\blibx264\b/.test(enc) ? 'libx264' : /\bh264_videotoolbox\b/.test(enc) ? 'VideoToolbox' : null;
133
- log(`${h264 ? '✓' : '✗'} H.264 encoder: ${h264 ?? 'none (needs libx264 or VideoToolbox)'}`);
103
+ // Lo mismo que acepta el motor (con el parche mf-encoder): libx264, VideoToolbox (Mac) o
104
+ // Media Foundation (Windows); NVIDIA/Intel/AMD si el ffmpeg los trae.
105
+ const h264 = /\blibx264\b/.test(enc) ? 'libx264' : /\bh264_videotoolbox\b/.test(enc) ? 'VideoToolbox' : /\bh264_mf\b/.test(enc) ? 'Media Foundation' : /\bh264_(nvenc|qsv|amf)\b/.test(enc) ? 'GPU' : null;
106
+ log(`${h264 ? '✓' : '✗'} H.264 encoder: ${h264 ?? 'none (needs libx264, VideoToolbox or Media Foundation)'}`);
134
107
  if (!h264) ok = false;
135
108
  }
136
109
  log(ok ? '✓ Ready.' : '✗ Not ready: see the lines above.');
@@ -168,7 +141,7 @@ const commands = {
168
141
  const errors = validateProject(dir);
169
142
  const { build, snapped } = prepare(dir);
170
143
  if (snapped) log(`• ${snapped} time(s) snapped to the nearest frame`);
171
- const { env } = await engineEnv();
144
+ const { env } = await engineEnv(log);
172
145
  const code = runEngine(['check', build, ...rest], env, dir);
173
146
  process.exit(errors ? 1 : code);
174
147
  },
@@ -177,7 +150,7 @@ const commands = {
177
150
  const { pos, rest } = parseArgs(argv, { output: 'str' });
178
151
  const dir = projectDir(pos[0]);
179
152
  const { build } = prepare(dir);
180
- const { env } = await engineEnv();
153
+ const { env } = await engineEnv(log);
181
154
  const out = resolve(dir, 'snapshots');
182
155
  rmSync(out, { recursive: true, force: true });
183
156
  process.exit(runEngine(['snapshot', build, '-o', out, '--describe', 'false', ...rest], env, dir));
@@ -187,19 +160,10 @@ const commands = {
187
160
  const { pos, opts, rest } = parseArgs(argv, { output: 'str', lufs: 'str', normalize: 'bool', force: 'bool' });
188
161
  const dir = projectDir(pos[0]);
189
162
  if (validateProject(dir) && !opts.force) fail('Fix the errors above before rendering (or --force if you know what you are doing).');
190
- const { build, snapped } = prepare(dir);
191
- if (snapped) log(`• ${snapped} time(s) snapped to the nearest frame`);
192
- const { env, ffmpeg, ffprobe } = await engineEnv();
193
163
  const output = resolve(opts.output ?? join(dir, 'renders', `${basename(dir)}.mp4`));
194
- mkdirSync(dirname(output), { recursive: true });
195
- const raw = join(build, 'raw.mp4');
196
- const code = runEngine(['render', build, '-o', raw, ...rest], env, dir);
197
- if (code !== 0 || !existsSync(raw)) fail('The render failed (see the messages above).');
198
- if (opts.normalize !== false && hasAudio(ffprobe, raw)) {
199
- const lufs = Number(opts.lufs ?? -14);
200
- const r = normalizeLoudness(ffmpeg, raw, output, { lufs });
201
- log(`✓ Loudness: ${r.before.toFixed(1)} → ${loudness(ffmpeg, output)?.toFixed(1)} LUFS`);
202
- } 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`);
203
167
  console.log(output);
204
168
  },
205
169
 
@@ -207,7 +171,7 @@ const commands = {
207
171
  const { pos, rest } = parseArgs(argv, {});
208
172
  const dir = projectDir(pos[0]);
209
173
  const { build } = prepare(dir);
210
- const { env } = await engineEnv();
174
+ const { env } = await engineEnv(log);
211
175
  log('Previewing the working copy: if you change the project, run this command again.');
212
176
  process.exit(runEngine(['preview', build, ...rest], env, dir));
213
177
  },
@@ -232,27 +196,28 @@ const commands = {
232
196
  } else console.log(json);
233
197
  },
234
198
 
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.
199
+ // Dónde está la cara a lo largo del vídeo, para encuadrar en vertical siguiéndola. YuNet sobre
200
+ // onnxruntime (igual en Mac y en Windows); el motor y el modelo se descargan la primera vez.
237
201
  async faces(argv) {
238
202
  const { pos, opts } = parseArgs(argv, { every: 'str', output: 'str', smooth: 'str' });
239
203
  const input = pos[0] && resolve(pos[0]);
240
204
  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
- }
205
+ const ffmpeg = await ensureTool('ffmpeg', log);
206
+ const ffprobe = await ensureTool('ffprobe', log);
251
207
  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;
208
+ const info = probe(ffprobe, input);
209
+ const S = [];
210
+ await frames(ffmpeg, input, {
211
+ fps: 1 / every,
212
+ max: 640,
213
+ width: info.width,
214
+ height: info.height,
215
+ onFrame: async (f) => {
216
+ const big = (await detectFaces(f, log)).sort((a, b) => b.w * b.h - a.w * a.h)[0];
217
+ S.push(big ? { t: +f.t.toFixed(3), x: big.x, y: big.y, w: big.w, h: big.h } : { t: +f.t.toFixed(3), x: 0, y: 0, w: 0, h: 0 });
218
+ },
219
+ });
220
+ const raw = { width: info.width, height: info.height, duration: info.duration };
256
221
  // Huecos sin cara: la posición conocida más cercana; si no hay ninguna, el centro.
257
222
  const known = S.map((x, i) => (x.w ? i : -1)).filter((i) => i >= 0);
258
223
  const filled = S.map((x, i) => {
@@ -279,6 +244,32 @@ const commands = {
279
244
  } else console.log(json);
280
245
  },
281
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
+
268
+ // Servicio local para Ignix Creator: líneas JSON por stdin/stdout (ADR-0023).
269
+ async serve() {
270
+ await serve({ version: PKG.version, engine: UPSTREAM.version });
271
+ },
272
+
282
273
  async ffmpeg(argv) {
283
274
  const r = spawnSync(await ensureTool('ffmpeg', log), argv, { stdio: 'inherit' });
284
275
  process.exit(r.status ?? 1);
@@ -312,9 +303,11 @@ const commands = {
312
303
  ignix-editor render [folder] [-o out.mp4] [--lufs -14] [--no-normalize]
313
304
  ignix-editor preview [folder] live preview in the browser
314
305
  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)
306
+ ignix-editor faces <video> [--every 0.2] [-o faces.json] where the face is, smoothed
316
307
  ignix-editor ffmpeg|ffprobe <args> Ignix Editor's own ffmpeg
317
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)
310
+ ignix-editor serve local service for Ignix Creator (JSON lines on stdin/stdout)
318
311
 
319
312
  The folder defaults to the current one. Based on HyperFrames (Apache-2.0) by HeyGen, Inc. See NOTICE.`);
320
313
  },
@@ -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
+ }