@ignix-social/editor 0.1.5 → 0.1.7
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 +7 -0
- package/README.md +1 -1
- package/package.json +2 -1
- package/patches/index.mjs +12 -0
- package/src/analysis/runtime.mjs +13 -0
- package/src/binaries.mjs +17 -4
- package/src/cli.mjs +31 -49
- package/src/creator/build.mjs +344 -0
- package/src/creator/fit.mjs +58 -0
- package/src/creator/job.mjs +163 -0
- package/src/creator/render.mjs +22 -0
- package/src/creator/runtime/creator.js +1026 -0
- package/src/engine.mjs +98 -0
- package/src/fetch.mjs +30 -0
- package/vendor/hyperframes/UPSTREAM.json +2 -1
- package/vendor/hyperframes/dist/chunk-FCSIGXXP.js +1 -1
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.
|
|
3
|
+
"version": "0.1.7",
|
|
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",
|
package/patches/index.mjs
CHANGED
|
@@ -88,4 +88,16 @@ export const patches = [
|
|
|
88
88
|
replacement: ' return null; // ignix-editor: sin aviso de la app de escritorio\n if (ready && (installed ?? desktopInstalled({ platform, env })))',
|
|
89
89
|
}),
|
|
90
90
|
},
|
|
91
|
+
{
|
|
92
|
+
id: 'chrome-first-launch',
|
|
93
|
+
// Antes de renderizar, el motor pregunta `chrome --version` con 5 s de margen. La
|
|
94
|
+
// primera vez que se abre una copia recién instalada, macOS la revisa entera: 4,7 s
|
|
95
|
+
// medidos en un Mac Intel, y más con el equipo ocupado. El primer render tras instalar
|
|
96
|
+
// fallaba con «Chrome cannot start». 60 s; las demás veces responde en 0,05 s.
|
|
97
|
+
apply: (dist) =>
|
|
98
|
+
replaceAnchor(dist, {
|
|
99
|
+
anchor: 'runCancellableProcess(executablePath, ["--version"], {\n signal,\n timeoutMs: 5e3,',
|
|
100
|
+
replacement: 'runCancellableProcess(executablePath, ["--version"], {\n signal,\n timeoutMs: 6e4, // ignix-editor: primera apertura de una copia nueva',
|
|
101
|
+
}),
|
|
102
|
+
},
|
|
91
103
|
];
|
package/src/analysis/runtime.mjs
CHANGED
|
@@ -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 {
|
|
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
|
-
|
|
199
|
-
|
|
200
|
-
|
|
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, '&').replace(/"/g, '"').replace(/</g, '<').replace(/>/g, '>');
|
|
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
|
+
}
|