@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/NOTICE +37 -3
- package/README.md +3 -2
- package/licenses/onnxruntime-MIT.txt +21 -0
- package/licenses/yunet-MIT.txt +21 -0
- package/package.json +2 -1
- package/patches/index.mjs +38 -0
- package/scripts/build-analysis-runtime.mjs +45 -0
- package/scripts/build-ffmpeg-windows.sh +44 -0
- package/src/analysis/clip.mjs +142 -0
- package/src/analysis/detect.mjs +185 -0
- package/src/analysis/image.mjs +123 -0
- package/src/analysis/media.mjs +152 -0
- package/src/analysis/montage.mjs +356 -0
- package/src/analysis/runtime.mjs +128 -0
- package/src/analysis/serve.mjs +149 -0
- package/src/analysis/sound.mjs +99 -0
- package/src/binaries.json +142 -6
- package/src/binaries.mjs +17 -4
- package/src/cli.mjs +64 -71
- 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/src/prepare.mjs +13 -2
- package/src/transcribe.mjs +65 -1
- package/vendor/hyperframes/UPSTREAM.json +1 -0
- package/vendor/hyperframes/dist/chunk-K2FTXJYH.js +16 -2
- package/vendor/hyperframes/dist/chunk-TVCRUG2S.js +1 -1
- package/vendor/hyperframes/dist/render-LNO62LEL.js +1 -1
- package/src/native/faces.swift +0 -60
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 {
|
|
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 {
|
|
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
|
-
|
|
133
|
-
|
|
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
|
-
|
|
195
|
-
|
|
196
|
-
|
|
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
|
-
//
|
|
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
|
-
|
|
242
|
-
const
|
|
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
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
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
|
|
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, '&').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
|
+
}
|
|
@@ -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
|
+
}
|