@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.
@@ -0,0 +1,1026 @@
1
+ /*
2
+ * creator.js — las plantillas de Ignix Creator (ADR-0014/0015/0019/0021/0022) dibujadas por
3
+ * Ignix Editor, fotograma a fotograma. Porta las plantillas que Creator tenía en Remotion: las
4
+ * mismas reglas, tamaños, colores y movimientos, escritos como funciones del tiempo.
5
+ *
6
+ * Lo prepara `src/creator/build.mjs`: los medios (<img>, <video>, <audio>) ya vienen en el HTML
7
+ * con sus tiempos; aquí se crean los textos y las formas y, en cada fotograma, se calcula todo
8
+ * a partir del instante (nada de estado entre fotogramas, nada medido en la página: el ancho de
9
+ * los caracteres llega en una tabla).
10
+ */
11
+ (function () {
12
+ const D = window.CREATOR;
13
+ const fps = D.fps;
14
+ const W = D.width;
15
+ const H = D.height;
16
+ const portrait = H > W;
17
+ const accent = D.accent;
18
+ const pal = D.pal;
19
+ const style = D.style;
20
+ const safe = D.safe;
21
+ const FONT = 'Inter';
22
+
23
+ // ── utilidades (las de Remotion, escritas aquí) ────────────────────────────
24
+ const clamp = (x, a, b) => Math.min(b, Math.max(a, x));
25
+ function interpolate(x, input, output, opts = {}) {
26
+ const [a, b] = input;
27
+ const [c, d] = output;
28
+ let p = (x - a) / (b - a || 1e-9);
29
+ if ((opts.left ?? 'extend') === 'clamp' && p < 0) p = 0;
30
+ if ((opts.right ?? 'extend') === 'clamp' && p > 1) p = 1;
31
+ if (opts.easing) p = opts.easing(clamp(p, 0, 1)) + (p < 0 ? p : p > 1 ? p - 1 : 0);
32
+ return c + (d - c) * p;
33
+ }
34
+ const lerpC = (x, input, output, easing) => interpolate(x, input, output, { left: 'clamp', right: 'clamp', easing });
35
+
36
+ // Muelle amortiguado (masa, rigidez, amortiguación) de 0 a 1, en forma cerrada.
37
+ function springRaw(t, { damping = 10, mass = 1, stiffness = 100 } = {}) {
38
+ if (t <= 0) return 0;
39
+ const w0 = Math.sqrt(stiffness / mass);
40
+ const z = damping / (2 * Math.sqrt(stiffness * mass));
41
+ if (z < 1) {
42
+ const wd = w0 * Math.sqrt(1 - z * z);
43
+ return 1 - Math.exp(-z * w0 * t) * (Math.cos(wd * t) + ((z * w0) / wd) * Math.sin(wd * t));
44
+ }
45
+ // Amortiguación crítica o mayor: Remotion la dibuja como crítica (medido en sus renders:
46
+ // con damping 200 llega al 95 % en ~0,5 s, no en segundos).
47
+ return 1 - Math.exp(-w0 * t) * (1 + w0 * t);
48
+ }
49
+ const settle = new Map();
50
+ function settleTime(config) {
51
+ const key = JSON.stringify(config);
52
+ if (!settle.has(key)) {
53
+ let t = 0;
54
+ let last = 0;
55
+ for (; t < 20; t += 1 / 120) {
56
+ if (Math.abs(1 - springRaw(t, config)) < 0.005) {
57
+ if (t - last > 0.25) break;
58
+ } else last = t;
59
+ }
60
+ settle.set(key, Math.max(1 / 30, last));
61
+ }
62
+ return settle.get(key);
63
+ }
64
+ /** spring({ frame, fps, config, durationInFrames }) de Remotion. */
65
+ function spring(frame, config = {}, durationInFrames) {
66
+ if (frame <= 0) return 0;
67
+ let t = frame / fps;
68
+ if (durationInFrames) t *= settleTime(config) / (durationInFrames / fps);
69
+ return springRaw(t, config);
70
+ }
71
+ function bezier(x1, y1, x2, y2) {
72
+ return (t) => {
73
+ let u = t;
74
+ for (let i = 0; i < 10; i++) {
75
+ const x = 3 * (1 - u) ** 2 * u * x1 + 3 * (1 - u) * u * u * x2 + u ** 3 - t;
76
+ const dx = 3 * (1 - u) ** 2 * x1 + 6 * (1 - u) * u * (x2 - x1) + 3 * u * u * (1 - x2);
77
+ if (Math.abs(dx) < 1e-6) break;
78
+ u = clamp(u - x / dx, 0, 1);
79
+ }
80
+ return 3 * (1 - u) ** 2 * u * y1 + 3 * (1 - u) * u * u * y2 + u ** 3;
81
+ };
82
+ }
83
+
84
+ // ── texto: medidas de la tabla y el mismo ajuste que Creator ───────────────
85
+ const advance = (ch, weight) => {
86
+ const w = Number(weight) >= 700 ? 800 : Number(weight) >= 600 ? 600 : 400;
87
+ const t = D.glyphs[w];
88
+ return t[ch] ?? t['?'] ?? 0.6;
89
+ };
90
+ const textWidth = (s, size, weight) => {
91
+ let w = 0;
92
+ for (const ch of s) w += advance(ch, weight);
93
+ return w * size - 0.02 * size * [...s].length;
94
+ };
95
+ function fitLines(text, { width, max, min, maxLines, weight }) {
96
+ const words = String(text).split(/\s+/).filter(Boolean);
97
+ for (let size = max; size >= min; size -= 4) {
98
+ const lines = [];
99
+ let cur = '';
100
+ let ok = true;
101
+ for (const word of words) {
102
+ if (textWidth(word, size, weight) > width) {
103
+ ok = false;
104
+ break;
105
+ }
106
+ const next = cur ? `${cur} ${word}` : word;
107
+ if (textWidth(next, size, weight) <= width) cur = next;
108
+ else {
109
+ lines.push(cur);
110
+ cur = word;
111
+ }
112
+ }
113
+ if (cur) lines.push(cur);
114
+ if (ok && lines.length <= maxLines) return { size, lines };
115
+ }
116
+ return { size: min, lines: [text] };
117
+ }
118
+ const cleanWord = (w) => w.toLowerCase().replace(/[^\p{L}\p{N}]/gu, '');
119
+
120
+ // ── DOM ─────────────────────────────────────────────────────────────────────
121
+ function h(tag, css = {}, children = []) {
122
+ const el = document.createElement(tag);
123
+ Object.assign(el.style, css);
124
+ for (const c of [].concat(children)) {
125
+ if (c === null || c === undefined || c === false) continue;
126
+ el.append(typeof c === 'string' ? document.createTextNode(c) : c);
127
+ }
128
+ return el;
129
+ }
130
+ const fill = (css = {}) => h('div', { position: 'absolute', left: '0', top: '0', width: '100%', height: '100%', display: 'flex', flexDirection: 'column', ...css });
131
+ const $ = (id) => document.getElementById(id);
132
+ const px = (n) => `${n}px`;
133
+
134
+ // ── fondo de marca animado ─────────────────────────────────────────────────
135
+ function backdrop(parent, seed, variant) {
136
+ const root = fill({ background: `linear-gradient(${150 + (variant % 4) * 25}deg, ${pal.bg[0]} 0%, ${pal.bg[1]} 100%)` });
137
+ const size = Math.max(W, H);
138
+ const a = h('div', { position: 'absolute', width: px(size * 0.95), height: px(size * 0.95), borderRadius: '50%', background: `radial-gradient(circle, ${accent} 0%, transparent 62%)`, opacity: String(pal.glow * 0.55), filter: 'blur(40px)' });
139
+ const b = h('div', { position: 'absolute', width: px(size * 0.8), height: px(size * 0.8), borderRadius: '50%', background: `radial-gradient(circle, ${accent} 0%, transparent 60%)`, opacity: String(pal.glow * 0.35), filter: 'blur(50px)' });
140
+ root.append(a, b);
141
+ parent.append(root);
142
+ const ya = [0.22, 0.7, 0.35, 0.55][variant % 4];
143
+ const yb = [0.78, 0.25, 0.85, 0.15][variant % 4];
144
+ return (frame) => {
145
+ const t = frame / 30;
146
+ const ka = 0.5 + 0.18 * Math.sin(t * 0.6 + seed);
147
+ const kb = 0.5 + 0.2 * Math.cos(t * 0.45 + seed * 1.7);
148
+ a.style.left = px(W * ka - size * 0.47);
149
+ a.style.top = px(H * ya - size * 0.47);
150
+ b.style.left = px(W * (1 - kb) - size * 0.4);
151
+ b.style.top = px(H * yb - size * 0.4);
152
+ };
153
+ }
154
+
155
+ // ── medios de una escena (foto o vídeo; vienen del HTML) ───────────────────
156
+ function motionTransform(motion, p) {
157
+ switch (motion) {
158
+ case 'zoom_out':
159
+ return `scale(${interpolate(p, [0, 1], [1.14, 1.02])})`;
160
+ case 'pan_left':
161
+ return `scale(1.14) translateX(${interpolate(p, [0, 1], [3, -3])}%)`;
162
+ case 'pan_right':
163
+ return `scale(1.14) translateX(${interpolate(p, [0, 1], [-3, 3])}%)`;
164
+ case 'still':
165
+ return 'scale(1.02)';
166
+ default:
167
+ return `scale(${interpolate(p, [0, 1], [1.02, 1.14])})`;
168
+ }
169
+ }
170
+
171
+ // ── etiquetas pequeñas y piezas comunes ────────────────────────────────────
172
+ function kicker(label, size) {
173
+ const filled = style === 'bold';
174
+ const el = h('div', { display: 'inline-block', fontFamily: FONT, fontWeight: '800', fontSize: px(size), letterSpacing: '0.1em', textTransform: 'uppercase', color: filled ? '#000' : accent, background: filled ? accent : 'transparent', border: filled ? 'none' : `3px solid ${accent}`, borderRadius: '999px', padding: '0.28em 0.8em', marginBottom: px(size * 0.7), whiteSpace: 'nowrap' }, [label]);
175
+ return {
176
+ el,
177
+ update: (f) => {
178
+ const s = spring(f, { damping: 200 });
179
+ el.style.opacity = String(s);
180
+ el.style.transform = `translateY(${interpolate(s, [0, 1], [-14, 0])}px)`;
181
+ },
182
+ };
183
+ }
184
+ const stagger = (count, dur, start, max) => (count <= 1 ? max : Math.max(4, Math.min(max, Math.floor((dur * 0.62 - start) / (count - 1)))));
185
+ const frameBox = (align = 'flex-start') => fill({ padding: `${safe.top}px ${safe.side}px ${safe.bottom}px`, justifyContent: 'center', alignItems: align, fontFamily: FONT, boxSizing: 'border-box' });
186
+ const scrim = (parent, on) => on && parent.append(fill({ background: `rgba(${pal.overlay},0.72)` }));
187
+ const linesBlock = (lines, css) => lines.map((l) => h('div', { whiteSpace: 'nowrap', ...css }, [l]));
188
+
189
+ // ── statement: titular cinético ────────────────────────────────────────────
190
+ function statement(parent, text, onMedia, ordinal, dur) {
191
+ const boxWidth = W - safe.side * 2;
192
+ const isHook = text.role === 'hook';
193
+ const isCta = text.role === 'cta';
194
+ const compact = text.scale === 'compact';
195
+ const maxSize = compact ? (portrait ? 70 : 58) : Math.round((portrait ? 118 : 96) * (isHook ? 1.08 : isCta ? 1 : 0.9));
196
+ const textW = onMedia ? boxWidth - 84 : boxWidth;
197
+ const headline = style === 'bold' ? text.headline.toUpperCase() : text.headline;
198
+ const fitted = fitLines(headline, { width: textW, max: maxSize, min: 44, maxLines: compact ? 2 : portrait ? 5 : 3, weight: '800' });
199
+ const body = text.body ? fitLines(text.body, { width: textW, max: portrait ? 52 : 44, min: 30, maxLines: 4, weight: '400' }) : null;
200
+ const highlights = new Set(text.highlight.map(cleanWord));
201
+ const justify = text.position === 'top' ? 'flex-start' : text.position === 'bottom' ? 'flex-end' : 'center';
202
+ const o = pal.overlay;
203
+ const scrimBg = compact
204
+ ? onMedia
205
+ ? `linear-gradient(180deg, rgba(${o},0.55) 0%, rgba(${o},0) 30%)`
206
+ : 'none'
207
+ : onMedia
208
+ ? text.position === 'top'
209
+ ? `linear-gradient(180deg, rgba(${o},0.94) 0%, rgba(${o},0.8) 38%, rgba(${o},0) 72%)`
210
+ : text.position === 'bottom'
211
+ ? `linear-gradient(0deg, rgba(${o},0.95) 0%, rgba(${o},0.82) 40%, rgba(${o},0) 74%)`
212
+ : `linear-gradient(0deg, rgba(${o},0.6), rgba(${o},0.6)), radial-gradient(ellipse at center, rgba(${o},0.55) 0%, rgba(${o},0) 75%)`
213
+ : 'none';
214
+ const root = fill({ background: scrimBg });
215
+ const inner = fill({ padding: `${safe.top}px ${safe.side}px ${safe.bottom}px`, justifyContent: justify, alignItems: isCta ? 'center' : 'flex-start', textAlign: isCta ? 'center' : 'left', fontFamily: FONT, boxSizing: 'border-box' });
216
+ const panel = h('div', { width: px(boxWidth), boxSizing: 'border-box', ...(onMedia || compact ? { background: `rgba(${pal.overlay},0.84)`, borderRadius: '28px', padding: compact ? '22px 30px 26px' : '34px 38px 38px', borderLeft: `8px solid ${accent}` } : {}) });
217
+ const ups = [];
218
+ if (text.kicker) {
219
+ const k = kicker(text.kicker, portrait ? 38 : 30);
220
+ panel.append(k.el);
221
+ ups.push(k.update);
222
+ } else if (ordinal) {
223
+ const od = h('div', { fontWeight: '800', fontSize: px(portrait ? 40 : 32), letterSpacing: '0.12em', color: accent, marginBottom: '14px' }, [ordinal]);
224
+ panel.append(od);
225
+ ups.push((f) => (od.style.opacity = String(lerpC(f, [0, 8], [0, 1]))));
226
+ }
227
+ let wordIndex = 0;
228
+ const spans = [];
229
+ for (const line of fitted.lines) {
230
+ const row = h('div', { fontWeight: '800', fontSize: px(fitted.size), lineHeight: '1.06', letterSpacing: '-0.02em', color: pal.text, whiteSpace: 'nowrap', textShadow: onMedia ? '0 4px 24px rgba(0,0,0,0.45)' : 'none' });
231
+ for (const word of line.split(' ')) {
232
+ const hl = highlights.has(cleanWord(word));
233
+ const sp = h('span', { display: 'inline-block', marginRight: '0.24em', color: hl ? (style === 'bold' ? '#000' : accent) : pal.text, ...(hl && style === 'bold' ? { background: accent, padding: '0 0.12em', borderRadius: '8px' } : {}), ...(hl && style === 'clean' ? { boxShadow: `inset 0 -0.18em 0 ${accent}55` } : {}) }, [word]);
234
+ spans.push({ el: sp, i: wordIndex++ });
235
+ row.append(sp);
236
+ }
237
+ panel.append(row);
238
+ }
239
+ const totalWords = fitted.lines.join(' ').split(' ').length;
240
+ let bodyEl = null;
241
+ if (body) {
242
+ bodyEl = h('div', { marginTop: px(fitted.size * 0.35), fontWeight: '400', fontSize: px(body.size), lineHeight: '1.3', color: onMedia ? pal.text : pal.muted }, [body.lines.join(' ')]);
243
+ panel.append(bodyEl);
244
+ }
245
+ let pill = null;
246
+ const handle = D.brand.handle ?? D.brand.name ?? null;
247
+ if (isCta && handle) {
248
+ pill = h('div', { display: 'inline-block', marginTop: px(fitted.size * 0.6), padding: '0.45em 1.1em', borderRadius: '999px', background: accent, color: '#fff', fontWeight: '600', fontSize: px(portrait ? 44 : 36) }, [handle]);
249
+ panel.append(pill);
250
+ }
251
+ inner.append(panel);
252
+ root.append(inner);
253
+ parent.append(root);
254
+ return (f0) => {
255
+ const f = f0 + (text.enter === 'hold' ? 90 : 0);
256
+ for (const u of ups) u(f);
257
+ if (onMedia || compact) {
258
+ panel.style.opacity = String(lerpC(f, [0, 7], [0, 1]));
259
+ panel.style.transform = `translateY(${lerpC(f, [0, 7], [14, 0])}px)`;
260
+ }
261
+ for (const { el, i } of spans) {
262
+ const s = spring(f - 4 - i * 3, isHook ? { damping: 12, mass: 0.6 } : { damping: 200 });
263
+ el.style.opacity = String(interpolate(s, [0, 1], [0, 1]));
264
+ el.style.transform = `translateY(${interpolate(s, [0, 1], [fitted.size * 0.45, 0])}px) scale(${isHook ? lerpC(s, [0, 1], [0.86, 1]) : 1})`;
265
+ }
266
+ const bodyIn = spring(f - 10 - totalWords * 3, { damping: 200 });
267
+ if (bodyEl) {
268
+ bodyEl.style.opacity = String(bodyIn);
269
+ bodyEl.style.transform = `translateY(${interpolate(bodyIn, [0, 1], [18, 0])}px)`;
270
+ }
271
+ if (pill) {
272
+ pill.style.opacity = String(bodyIn);
273
+ pill.style.transform = `scale(${1 + 0.035 * Math.sin(f / 6)})`;
274
+ }
275
+ };
276
+ }
277
+
278
+ // ── big_number ─────────────────────────────────────────────────────────────
279
+ function bigNumber(parent, text, onMedia) {
280
+ const root = fill();
281
+ scrim(root, onMedia);
282
+ const fr = frameBox('center');
283
+ const boxWidth = W - safe.side * 2;
284
+ const fitted = fitLines(text.headline, { width: boxWidth, max: portrait ? 360 : 260, min: 96, maxLines: 2, weight: '800' });
285
+ const label = text.body ? fitLines(text.body, { width: boxWidth, max: portrait ? 60 : 48, min: 34, maxLines: 3, weight: '600' }) : null;
286
+ const wrap = h('div', { textAlign: 'center', width: px(boxWidth) });
287
+ const ups = [];
288
+ if (text.kicker) {
289
+ const k = kicker(text.kicker, portrait ? 38 : 30);
290
+ wrap.append(k.el);
291
+ ups.push(k.update);
292
+ }
293
+ const nums = linesBlock(fitted.lines, { fontWeight: '800', fontSize: px(fitted.size), lineHeight: '0.98', letterSpacing: '-0.04em', color: style === 'clean' ? pal.text : accent, textShadow: style === 'ember' ? `0 0 ${Math.round(fitted.size / 4)}px ${accent}66` : 'none' });
294
+ wrap.append(...nums);
295
+ let bar = null;
296
+ if (style === 'clean') {
297
+ bar = h('div', { height: '10px', margin: `${fitted.size * 0.12}px auto 0`, background: accent, borderRadius: '5px' });
298
+ wrap.append(bar);
299
+ }
300
+ let lab = null;
301
+ if (label) {
302
+ lab = h('div', { marginTop: px(fitted.size * 0.18), fontWeight: '600', fontSize: px(label.size), lineHeight: '1.25', color: pal.text }, [label.lines.join(' ')]);
303
+ wrap.append(lab);
304
+ }
305
+ fr.append(wrap);
306
+ root.append(fr);
307
+ parent.append(root);
308
+ return (f) => {
309
+ for (const u of ups) u(f);
310
+ const pop = spring(f - 3, { damping: 11, mass: 0.7 });
311
+ const labelIn = spring(f - 14, { damping: 200 });
312
+ for (const n of nums) {
313
+ n.style.opacity = String(interpolate(pop, [0, 1], [0, 1]));
314
+ n.style.transform = `scale(${interpolate(pop, [0, 1], [0.6, 1])})`;
315
+ }
316
+ if (bar) bar.style.width = px(boxWidth * 0.3 * labelIn);
317
+ if (lab) {
318
+ lab.style.opacity = String(labelIn);
319
+ lab.style.transform = `translateY(${interpolate(labelIn, [0, 1], [16, 0])}px)`;
320
+ }
321
+ };
322
+ }
323
+
324
+ // ── list ───────────────────────────────────────────────────────────────────
325
+ function list(parent, text, onMedia, dur) {
326
+ const root = fill();
327
+ scrim(root, onMedia);
328
+ const fr = frameBox();
329
+ const boxWidth = W - safe.side * 2;
330
+ const head = fitLines(style === 'bold' ? text.headline.toUpperCase() : text.headline, { width: boxWidth, max: portrait ? 92 : 76, min: 44, maxLines: 3, weight: '800' });
331
+ const itemSize = portrait ? 50 : 40;
332
+ const gap = stagger(text.items.length, dur, 16, 14);
333
+ const wrap = h('div', { width: px(boxWidth) });
334
+ const ups = [];
335
+ if (text.kicker) {
336
+ const k = kicker(text.kicker, portrait ? 36 : 28);
337
+ wrap.append(k.el);
338
+ ups.push(k.update);
339
+ }
340
+ const headEl = h('div', { fontWeight: '800', fontSize: px(head.size), lineHeight: '1.05', letterSpacing: '-0.02em', color: pal.text, textTransform: style === 'bold' ? 'uppercase' : 'none', marginBottom: px(head.size * 0.55) }, linesBlock(head.lines, {}));
341
+ wrap.append(headEl);
342
+ const rows = text.items.map((item, i) => {
343
+ const lines = fitLines(item, { width: boxWidth - itemSize * 2.2, max: itemSize, min: 30, maxLines: 2, weight: '600' });
344
+ const row = h('div', { display: 'flex', alignItems: 'center', gap: px(itemSize * 0.5), marginBottom: px(itemSize * 0.42), padding: `${itemSize * 0.34}px ${itemSize * 0.44}px`, borderRadius: '22px', background: style === 'clean' ? 'rgba(0,0,0,0.05)' : `rgba(255,255,255,${style === 'bold' ? '0.08' : '0.07'})`, borderLeft: style === 'clean' ? `6px solid ${accent}` : 'none' }, [
345
+ h('div', { flex: 'none', width: px(itemSize * 1.2), height: px(itemSize * 1.2), borderRadius: '50%', background: accent, color: style === 'bold' ? '#000' : '#fff', fontWeight: '800', fontSize: px(itemSize * 0.62), display: 'flex', alignItems: 'center', justifyContent: 'center' }, [style === 'clean' ? String(i + 1) : '✓']),
346
+ h('div', { fontWeight: '600', fontSize: px(lines.size), lineHeight: '1.18', color: pal.text }, [lines.lines.join(' ')]),
347
+ ]);
348
+ wrap.append(row);
349
+ return row;
350
+ });
351
+ fr.append(wrap);
352
+ root.append(fr);
353
+ parent.append(root);
354
+ return (f) => {
355
+ for (const u of ups) u(f);
356
+ const headIn = spring(f, { damping: 200 });
357
+ headEl.style.opacity = String(headIn);
358
+ headEl.style.transform = `translateY(${interpolate(headIn, [0, 1], [24, 0])}px)`;
359
+ rows.forEach((row, i) => {
360
+ const s = spring(f - 16 - i * gap, { damping: 16, mass: 0.6 });
361
+ row.style.opacity = String(s);
362
+ row.style.transform = `translateX(${interpolate(s, [0, 1], [-60, 0])}px)`;
363
+ });
364
+ };
365
+ }
366
+
367
+ // ── chat: un móvil con un comentario y los DM que abre ─────────────────────
368
+ function avatar(size, label, bg) {
369
+ return h('div', { flex: 'none', width: px(size), height: px(size), borderRadius: '50%', background: bg, color: '#fff', fontWeight: '800', fontSize: px(size * 0.45), display: 'flex', alignItems: 'center', justifyContent: 'center' }, [label]);
370
+ }
371
+ function svg(markup, css) {
372
+ const box = h('div', css);
373
+ box.innerHTML = markup;
374
+ return box;
375
+ }
376
+ function chat(parent, text, onMedia, dur) {
377
+ const root = fill();
378
+ scrim(root, onMedia);
379
+ const fr = frameBox('center');
380
+ const boxWidth = W - safe.side * 2;
381
+ const light = style === 'clean';
382
+ const head = fitLines(style === 'bold' ? text.headline.toUpperCase() : text.headline, { width: boxWidth, max: portrait ? 72 : 56, min: 40, maxLines: 2, weight: '800' });
383
+ const kickerSize = portrait ? 32 : 26;
384
+ const headBlock = head.size * 1.08 * head.lines.length + (text.kicker ? kickerSize * 2.2 : 0) + 36;
385
+ const phoneW = Math.min(boxWidth, portrait ? 900 : 640);
386
+ const phoneMax = H - safe.top - safe.bottom - headBlock;
387
+ const fs = portrait ? 46 : 32;
388
+ const name = D.brand.handle ?? D.brand.name ?? null;
389
+ const comment = text.lines.find((l) => l.from === 'comment');
390
+ const dms = text.lines.filter((l) => l.from !== 'comment');
391
+ const steps = (comment ? 1 : 0) + dms.length;
392
+ const gap = stagger(steps, dur, 10, 22);
393
+ const at = (step) => 10 + step * gap;
394
+ const screen = light ? '#ffffff' : '#0d0d0f';
395
+ const ink = light ? '#111111' : '#f5f5f5';
396
+ const sub = light ? '#6b6b6b' : '#9a9a9f';
397
+ const theirBubble = light ? '#efefef' : '#262629';
398
+ const ups = [];
399
+ const top = h('div', { width: px(boxWidth), textAlign: 'center' });
400
+ if (text.kicker) {
401
+ const k = kicker(text.kicker, kickerSize);
402
+ top.append(k.el);
403
+ ups.push(k.update);
404
+ }
405
+ const headEl = h('div', { fontWeight: '800', fontSize: px(head.size), lineHeight: '1.08', letterSpacing: '-0.02em', color: pal.text, textTransform: style === 'bold' ? 'uppercase' : 'none', marginBottom: '36px' }, linesBlock(head.lines, {}));
406
+ top.append(headEl);
407
+ const phone = h('div', { width: px(phoneW), maxHeight: px(phoneMax), minHeight: px(Math.min(phoneMax, phoneW * 0.72)), borderRadius: '64px', border: `14px solid ${light ? '#1a1a1a' : '#2a2a2e'}`, background: screen, boxSizing: 'border-box', overflow: 'hidden', boxShadow: style === 'ember' ? `0 30px 120px ${accent}55, 0 0 0 2px ${accent}33` : '0 30px 80px rgba(0,0,0,0.45)', display: 'flex', flexDirection: 'column', textAlign: 'left' });
408
+ const header = h('div', { display: 'flex', alignItems: 'center', gap: '18px', padding: '34px 30px 22px', borderBottom: `1px solid ${light ? '#e6e6e6' : '#222226'}` }, [
409
+ avatar(fs * 1.6, (name ?? '•').replace('@', '').charAt(0).toUpperCase(), accent),
410
+ h('div', { display: 'flex', alignItems: 'center', gap: '12px' }, [name ? h('div', { color: ink, fontWeight: '800', fontSize: px(fs * 0.9) }, [name]) : null, h('div', { width: '14px', height: '14px', borderRadius: '50%', background: '#2fd158' })]),
411
+ ]);
412
+ const thread = h('div', { flex: '1', padding: '30px 30px 40px', display: 'flex', flexDirection: 'column', gap: '18px' });
413
+ phone.append(header, thread);
414
+ let commentEl = null;
415
+ if (comment) {
416
+ commentEl = h('div', { display: 'flex', gap: '16px', alignItems: 'flex-start', padding: '20px 22px', borderRadius: '26px', background: light ? '#f6f6f6' : '#18181b', border: `2px solid ${accent}`, marginBottom: '10px' }, [
417
+ avatar(fs * 1.3, '', 'linear-gradient(135deg, #f9ce34, #ee2a7b, #6228d7)'),
418
+ h('div', {}, [svg(`<svg width="${fs * 0.8}" height="${fs * 0.8}" viewBox="0 0 24 24" style="display:block;margin-bottom:6px"><path d="M20.7 16.3A9.5 9.5 0 1 0 17 20l4 1.2-1.3-4.9Z" fill="none" stroke="${sub}" stroke-width="2.2" stroke-linejoin="round"/></svg>`, {}), h('div', { color: ink, fontSize: px(fs), fontWeight: '800', lineHeight: '1.2' }, [comment.text])]),
419
+ ]);
420
+ thread.append(commentEl);
421
+ }
422
+ const bubbles = dms.map((line, i) => {
423
+ const mine = line.from === 'you';
424
+ const row = h('div', { display: 'flex', justifyContent: mine ? 'flex-end' : 'flex-start' });
425
+ const bubble = h('div', { maxWidth: '80%', padding: '16px 24px', borderRadius: '34px', borderBottomRightRadius: mine ? '10px' : '34px', borderBottomLeftRadius: mine ? '34px' : '10px', background: mine ? accent : theirBubble, color: mine ? (style === 'bold' ? '#000' : '#fff') : ink, fontSize: px(fs), fontWeight: '600', lineHeight: '1.25', transformOrigin: mine ? 'right bottom' : 'left bottom' });
426
+ const words = document.createTextNode(line.text);
427
+ const dotColor = style === 'bold' ? '#000' : '#fff';
428
+ const dots = h('div', { display: 'flex', gap: px(fs * 0.35), padding: `${fs * 0.2}px 0` }, [0, 1, 2].map(() => h('div', { width: px(fs * 0.5), height: px(fs * 0.5), borderRadius: '50%', background: dotColor })));
429
+ bubble.append(words);
430
+ row.append(bubble);
431
+ thread.append(row);
432
+ return { row, bubble, words, dots, mine, step: (comment ? 1 : 0) + i };
433
+ });
434
+ fr.append(top, phone);
435
+ root.append(fr);
436
+ parent.append(root);
437
+ return (f) => {
438
+ for (const u of ups) u(f);
439
+ headEl.style.opacity = String(spring(f, { damping: 200 }));
440
+ const phoneIn = spring(f - 2, { damping: 18, mass: 0.8 });
441
+ phone.style.opacity = String(phoneIn);
442
+ phone.style.transform = `translateY(${interpolate(phoneIn, [0, 1], [120, 0])}px)`;
443
+ if (commentEl) {
444
+ const s = spring(f - at(0), { damping: 14, mass: 0.6 });
445
+ commentEl.style.opacity = String(s);
446
+ commentEl.style.transform = `scale(${interpolate(s, [0, 1], [0.8, 1])})`;
447
+ }
448
+ for (const b of bubbles) {
449
+ const typingFrom = at(b.step) - Math.min(12, gap - 2);
450
+ const typing = b.mine && f >= typingFrom && f < at(b.step);
451
+ b.row.style.display = f < typingFrom ? 'none' : 'flex';
452
+ const s = spring(f - at(b.step), { damping: 15, mass: 0.55 });
453
+ b.bubble.style.opacity = String(typing ? 1 : s);
454
+ b.bubble.style.transform = typing ? 'none' : `scale(${interpolate(s, [0, 1], [0.7, 1])})`;
455
+ if (typing) {
456
+ if (b.words.parentNode) b.bubble.replaceChild(b.dots, b.words);
457
+ [...b.dots.children].forEach((d, i) => (d.style.opacity = String(0.35 + 0.65 * Math.max(0, Math.sin((f - i * 3) / 3)))));
458
+ } else if (b.dots.parentNode) b.bubble.replaceChild(b.words, b.dots);
459
+ }
460
+ };
461
+ }
462
+
463
+ // ── explicativos: pasos, gráfico, comparación, cita ────────────────────────
464
+ const ease200 = (f, delay = 0, damping = 200) => spring(f - delay, { damping });
465
+ function headline(textStr, color, width, max) {
466
+ const shown = style === 'bold' ? textStr.toUpperCase() : textStr;
467
+ const fitted = fitLines(shown, { width, max, min: 40, maxLines: 3, weight: '800' });
468
+ const el = h('div', { fontWeight: '800', fontSize: px(fitted.size), lineHeight: '1.05', letterSpacing: '-0.02em', color, marginBottom: px(fitted.size * 0.6) }, linesBlock(fitted.lines, {}));
469
+ return {
470
+ el,
471
+ update: (f) => {
472
+ const s = ease200(f);
473
+ el.style.opacity = String(s);
474
+ el.style.transform = `translateY(${interpolate(s, [0, 1], [24, 0])}px)`;
475
+ },
476
+ };
477
+ }
478
+ const panelBg = () => (style === 'clean' ? 'rgba(0,0,0,0.05)' : `rgba(255,255,255,${style === 'bold' ? '0.08' : '0.07'})`);
479
+ const onAccentInk = () => (style === 'bold' ? '#000' : '#fff');
480
+ function explainShell(parent, text, onMedia, maxHead) {
481
+ const root = fill();
482
+ scrim(root, onMedia);
483
+ const fr = frameBox();
484
+ const box = W - safe.side * 2;
485
+ const wrap = h('div', { width: px(box) });
486
+ const ups = [];
487
+ if (text.kicker) {
488
+ const k = kicker(text.kicker, portrait ? 36 : 28);
489
+ wrap.append(k.el);
490
+ ups.push(k.update);
491
+ }
492
+ if (maxHead) {
493
+ const hd = headline(text.headline, pal.text, box, maxHead);
494
+ wrap.append(hd.el);
495
+ ups.push(hd.update);
496
+ }
497
+ fr.append(wrap);
498
+ root.append(fr);
499
+ parent.append(root);
500
+ return { wrap, box, ups };
501
+ }
502
+ function steps(parent, text, onMedia, dur) {
503
+ const { wrap, box, ups } = explainShell(parent, text, onMedia, portrait ? 84 : 70);
504
+ const dot = portrait ? 96 : 66;
505
+ const itemSize = portrait ? 62 : 42;
506
+ const gap = stagger(text.items.length, dur, 14, 18);
507
+ const rowGap = dot * 0.55;
508
+ const list = h('div', { position: 'relative' });
509
+ const rows = text.items.map((item, i) => {
510
+ const fitted = fitLines(item, { width: box - dot * 1.6, max: itemSize, min: 28, maxLines: 2, weight: '700' });
511
+ const last = i === text.items.length - 1;
512
+ const line = last ? null : h('div', { position: 'absolute', left: px(dot / 2 - 4), top: px(dot), width: '8px', background: accent, opacity: '0.55', borderRadius: '3px' });
513
+ const circle = h('div', { flex: 'none', width: px(dot), height: px(dot), borderRadius: '50%', background: accent, color: onAccentInk(), fontWeight: '900', fontSize: px(dot * 0.48), display: 'flex', alignItems: 'center', justifyContent: 'center' }, [String(i + 1)]);
514
+ const label = h('div', { alignSelf: 'center', fontWeight: '700', fontSize: px(fitted.size), lineHeight: '1.15', color: pal.text }, [fitted.lines.join(' ')]);
515
+ const row = h('div', { display: 'flex', gap: px(dot * 0.45), position: 'relative', marginBottom: px(rowGap) }, [line, circle, label]);
516
+ list.append(row);
517
+ return { line, circle, label, i };
518
+ });
519
+ wrap.append(list);
520
+ return (f) => {
521
+ for (const u of ups) u(f);
522
+ for (const r of rows) {
523
+ const s = ease200(f, 14 + r.i * gap, 14);
524
+ if (r.line) r.line.style.height = `calc(${lerpC(f, [14 + r.i * gap, 14 + (r.i + 1) * gap], [0, 1]) * 100}% - ${dot - rowGap}px)`;
525
+ r.circle.style.transform = `scale(${interpolate(s, [0, 1], [0.2, 1])})`;
526
+ r.circle.style.opacity = String(s);
527
+ r.label.style.opacity = String(s);
528
+ r.label.style.transform = `translateX(${interpolate(s, [0, 1], [50, 0])}px)`;
529
+ }
530
+ };
531
+ }
532
+ const numberFmt = (v) => (v >= 100 ? Math.round(v).toLocaleString('es-ES') : String(Math.round(v * 10) / 10).replace('.', ','));
533
+ function chart(parent, text, onMedia, dur) {
534
+ const { wrap, box, ups } = explainShell(parent, text, onMedia, portrait ? 80 : 66);
535
+ const rowsData = text.data;
536
+ const top = Math.max(...rowsData.map((r) => r.value), 1);
537
+ const best = rowsData.findIndex((r) => r.value === top);
538
+ const barH = portrait ? 100 : 60;
539
+ const labelSize = portrait ? 48 : 32;
540
+ const gap = stagger(rowsData.length, dur, 12, 8);
541
+ const rows = rowsData.map((r, i) => {
542
+ const fitted = fitLines(r.label, { width: box, max: labelSize, min: 24, maxLines: 1, weight: '600' });
543
+ const bar = h('div', { height: px(barH), borderRadius: px(barH / 2), background: i === best ? accent : panelBg().replace(/0\.0\d\)$/, '0.22)'), boxShadow: i === best ? `0 0 ${pal.glow * 40}px ${accent}` : 'none' });
544
+ const value = h('div', { fontWeight: '900', fontSize: px(barH * 0.72), color: i === best ? accent : pal.text, whiteSpace: 'nowrap', fontVariantNumeric: 'tabular-nums' }, ['']);
545
+ const row = h('div', { marginBottom: px(barH * 0.5) }, [h('div', { fontWeight: '600', fontSize: px(fitted.size), color: pal.muted, marginBottom: '8px' }, [fitted.lines[0]]), h('div', { display: 'flex', alignItems: 'center', gap: '18px' }, [bar, value])]);
546
+ wrap.append(row);
547
+ return { row, bar, value, r, i };
548
+ });
549
+ return (f) => {
550
+ for (const u of ups) u(f);
551
+ for (const x of rows) {
552
+ const grow = ease200(f, 12 + x.i * gap, 22);
553
+ const share = Math.max(0.04, x.r.value / top);
554
+ x.row.style.opacity = String(Math.min(1, grow * 2));
555
+ x.bar.style.width = `${share * grow * 78}%`;
556
+ x.value.textContent = x.r.display ?? numberFmt(x.r.value * grow);
557
+ }
558
+ };
559
+ }
560
+ function compare(parent, text, onMedia, dur) {
561
+ const c = text.compare;
562
+ const { wrap, box, ups } = explainShell(parent, text, onMedia, portrait ? 78 : 64);
563
+ if (!c) return (f) => ups.forEach((u) => u(f));
564
+ const col = (box - 40) / 2;
565
+ const size = portrait ? 50 : 34;
566
+ const gap = stagger(c.rows.length, dur, 22, 14);
567
+ const titleOf = (t) => fitLines(t.toUpperCase(), { width: col - size * 1.4, max: size * 0.9, min: 20, maxLines: 2, weight: '800' });
568
+ const lt = titleOf(c.left);
569
+ const rt = titleOf(c.right);
570
+ const vs = h('div', { fontWeight: '900', fontSize: px(size * 0.8), color: accent }, ['VS']);
571
+ wrap.append(
572
+ h('div', { display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '22px' }, [
573
+ h('div', { width: px(col), fontWeight: '800', fontSize: px(lt.size), lineHeight: '1.05', color: pal.muted, letterSpacing: '0.04em' }, [lt.lines.join(' ')]),
574
+ vs,
575
+ h('div', { width: px(col), fontWeight: '800', fontSize: px(rt.size), lineHeight: '1.05', color: accent, letterSpacing: '0.04em', textAlign: 'right' }, [rt.lines.join(' ')]),
576
+ ]),
577
+ );
578
+ const cell = (t, right) => {
579
+ const fitted = fitLines(t, { width: col - 48, max: size, min: 22, maxLines: 3, weight: right ? '700' : '600' });
580
+ return h('div', { width: px(col), padding: '24px 28px', borderRadius: '20px', background: right ? accent : panelBg(), color: right ? onAccentInk() : pal.muted, fontWeight: right ? '700' : '600', fontSize: px(fitted.size), lineHeight: '1.18', boxSizing: 'border-box' }, [fitted.lines.join(' ')]);
581
+ };
582
+ const rows = c.rows.map((r, i) => {
583
+ const a = cell(r.a, false);
584
+ const b = cell(r.b, true);
585
+ wrap.append(h('div', { display: 'flex', justifyContent: 'space-between', marginBottom: '18px' }, [a, b]));
586
+ return { a, b, i };
587
+ });
588
+ return (f) => {
589
+ for (const u of ups) u(f);
590
+ const v = ease200(f, 10, 12);
591
+ vs.style.transform = `scale(${interpolate(v, [0, 1], [0.3, 1])})`;
592
+ vs.style.opacity = String(v);
593
+ for (const r of rows) {
594
+ const s = ease200(f, 22 + r.i * gap, 16);
595
+ const s2 = ease200(f, 22 + r.i * gap + 6, 16);
596
+ r.a.style.opacity = String(s);
597
+ r.a.style.transform = `translateX(${interpolate(s, [0, 1], [-60, 0])}px)`;
598
+ r.b.style.opacity = String(s2);
599
+ r.b.style.transform = `translateX(${interpolate(s2, [0, 1], [60, 0])}px)`;
600
+ }
601
+ };
602
+ }
603
+ function quote(parent, text, onMedia, dur) {
604
+ const { wrap, box } = explainShell(parent, text, onMedia, 0);
605
+ const fitted = fitLines(text.headline, { width: box, max: portrait ? 88 : 70, min: 40, maxLines: 5, weight: '800' });
606
+ const words = fitted.lines.flatMap((l, li) => l.split(' ').map((w) => ({ w, li })));
607
+ const per = Math.max(2, Math.min(5, Math.floor((dur * 0.5) / Math.max(1, words.length))));
608
+ const mark = h('div', { fontWeight: '900', fontSize: px(fitted.size * 2.6), lineHeight: '0.8', color: accent, transformOrigin: 'left top', marginBottom: px(fitted.size * 0.1) }, ['“']);
609
+ const body = h('div', { fontWeight: '800', fontSize: px(fitted.size), lineHeight: '1.12', color: pal.text, letterSpacing: '-0.015em' });
610
+ const spans = [];
611
+ fitted.lines.forEach((l, li) => {
612
+ const row = h('div', { whiteSpace: 'nowrap' });
613
+ for (const x of words.filter((x) => x.li === li)) {
614
+ const sp = h('span', { display: 'inline-block', marginRight: '0.26em' }, [x.w]);
615
+ spans.push(sp);
616
+ row.append(sp);
617
+ }
618
+ body.append(row);
619
+ });
620
+ wrap.append(mark, body);
621
+ let by = null;
622
+ let rule = null;
623
+ if (text.body) {
624
+ rule = h('div', { height: '5px', background: accent, borderRadius: '3px' });
625
+ by = h('div', { display: 'flex', alignItems: 'center', gap: '20px', marginTop: px(fitted.size * 0.6) }, [rule, h('div', { fontWeight: '600', fontSize: px(Math.max(28, fitted.size * 0.42)), color: pal.muted }, [text.body])]);
626
+ wrap.append(by);
627
+ }
628
+ return (f) => {
629
+ const m = ease200(f, 0, 12);
630
+ mark.style.opacity = String(m);
631
+ mark.style.transform = `scale(${interpolate(m, [0, 1], [0.4, 1])})`;
632
+ spans.forEach((sp, k) => {
633
+ const s = ease200(f, 8 + k * per, 200);
634
+ sp.style.opacity = String(s);
635
+ sp.style.transform = `translateY(${interpolate(s, [0, 1], [16, 0])}px)`;
636
+ });
637
+ if (by) {
638
+ const b = ease200(f, 8 + words.length * per, 200);
639
+ by.style.opacity = String(b);
640
+ rule.style.width = px(interpolate(b, [0, 1], [0, 70]));
641
+ }
642
+ };
643
+ }
644
+ const LAYOUTS = { big_number: bigNumber, list, chat, steps, chart, compare, quote };
645
+
646
+ // ── pantalla de producto ───────────────────────────────────────────────────
647
+ function screenScene(parent, sc, seed, variant, dur) {
648
+ const v = sc.visual;
649
+ const update = backdrop(parent, seed, variant);
650
+ const iw = v.w ?? 1080;
651
+ const ih = v.h ?? 1920;
652
+ const ratio = ih / iw;
653
+ const chrome = v.frame === 'browser' ? 54 : 0;
654
+ const bezel = v.frame === 'phone' ? 26 : 0;
655
+ const maxW = v.frame === 'phone' ? W * 0.74 : W * 0.9;
656
+ const maxH = H * (v.frame === 'phone' ? 0.8 : 0.66);
657
+ let sw = maxW - bezel * 2;
658
+ let sh = sw * ratio;
659
+ let crop = false;
660
+ if (v.frame === 'phone') {
661
+ if (sh > sw * 2.05) {
662
+ crop = true;
663
+ sh = sw * 2.05;
664
+ }
665
+ if (sh + bezel * 2 > maxH) {
666
+ const k = (maxH - bezel * 2) / sh;
667
+ sh *= k;
668
+ sw *= k;
669
+ }
670
+ } else if (v.move === 'scroll' && sh + chrome > maxH) {
671
+ crop = true;
672
+ sh = maxH - chrome;
673
+ } else if (sh + chrome > maxH) {
674
+ const k = (maxH - chrome) / sh;
675
+ sh *= k;
676
+ sw *= k;
677
+ }
678
+ const boxW = sw + bezel * 2;
679
+ const boxH = sh + bezel * 2 + chrome;
680
+ const boxX = (W - boxW) / 2;
681
+ const boxY = (H - boxH) / 2 + (v.frame === 'phone' ? 0 : -H * 0.03);
682
+ const picX = boxX + bezel;
683
+ const picY = boxY + bezel + chrome;
684
+ const drawnH = crop ? sw * ratio : sh;
685
+ const radius = v.frame === 'phone' ? 64 : 22;
686
+ const cam = fill({ transformOrigin: '0 0' });
687
+ const box = h('div', { position: 'absolute', left: px(boxX), top: px(boxY), width: px(boxW), height: px(boxH), borderRadius: px(radius), background: v.frame === 'phone' ? '#0b0b0c' : '#ffffff', boxShadow: '0 40px 120px rgba(0,0,0,0.55), 0 0 0 2px rgba(255,255,255,0.06)', overflow: 'hidden' });
688
+ if (v.frame === 'browser') box.append(h('div', { height: px(chrome), background: '#ececec', display: 'flex', alignItems: 'center', gap: '12px', paddingLeft: '22px' }, [...['#ff5f57', '#febc2e', '#28c840'].map((c) => h('div', { width: '16px', height: '16px', borderRadius: '8px', background: c })), h('div', { marginLeft: '18px', height: '26px', width: px(boxW * 0.5), borderRadius: '13px', background: '#ffffff' })]));
689
+ const win = h('div', { position: 'absolute', left: px(bezel), top: px(bezel + chrome), width: px(sw), height: px(sh), overflow: 'hidden', borderRadius: px(v.frame === 'phone' ? radius - bezel : 0), background: '#fff' });
690
+ const img = $(v.imgId);
691
+ Object.assign(img.style, { position: 'absolute', left: '0', top: '0', width: px(sw), height: px(drawnH), display: 'block' });
692
+ win.append(img);
693
+ box.append(win);
694
+ if (v.frame === 'phone') box.append(h('div', { position: 'absolute', top: px(bezel + 14), left: px(boxW / 2 - 70), width: '140px', height: '34px', borderRadius: '17px', background: '#0b0b0c' }));
695
+ cam.append(box);
696
+ const f0 = v.focus;
697
+ const focus = f0 && v.move === 'focus' ? { x: picX + f0.x * sw, y: picY + f0.y * drawnH, w: f0.w * sw, h: f0.h * drawnH } : null;
698
+ let ring = null;
699
+ if (focus) {
700
+ ring = h('div', { position: 'absolute', left: px(focus.x - 10), top: px(focus.y - 10), width: px(focus.w + 20), height: px(focus.h + 20), borderRadius: '16px', boxSizing: 'border-box' });
701
+ cam.append(ring);
702
+ }
703
+ parent.append(cam);
704
+ let tapEl = null;
705
+ let pointerEl = null;
706
+ if (v.pointer && focus) {
707
+ tapEl = h('div', { position: 'absolute', borderRadius: '50%', border: `6px solid ${accent}`, boxSizing: 'border-box' });
708
+ pointerEl = svg('<svg width="72" height="72" viewBox="0 0 24 24"><path d="M4 2 L4 19 L8.6 14.8 L11.6 21.6 L14.6 20.3 L11.7 13.6 L18 13.6 Z" fill="#ffffff" stroke="#111111" stroke-width="1.3" stroke-linejoin="round"/></svg>', { position: 'absolute', width: '72px', height: '72px', filter: 'drop-shadow(0 6px 10px rgba(0,0,0,0.45))' });
709
+ parent.append(tapEl, pointerEl);
710
+ }
711
+ const d = Math.max(1, dur);
712
+ const t0 = Math.round(d * 0.22);
713
+ const t1 = Math.round(d * 0.55);
714
+ const ease = bezier(0.45, 0, 0.2, 1);
715
+ const scrollMax = crop ? drawnH - sh : 0;
716
+ return (f) => {
717
+ update(f);
718
+ const enter = spring(f, { damping: 200 }, 14);
719
+ const scroll = v.move === 'scroll' ? lerpC(f, [Math.round(d * 0.12), Math.round(d * 0.9)], [0, scrollMax], ease) : 0;
720
+ img.style.top = px(-scroll);
721
+ let s = 1;
722
+ let tx = 0;
723
+ let ty = 0;
724
+ if (focus) {
725
+ const S = Math.max(1, Math.min(2.6, (0.84 * W) / focus.w, (0.5 * H) / focus.h));
726
+ const cx = focus.x + focus.w / 2;
727
+ const cy = focus.y + focus.h / 2;
728
+ const p = lerpC(f, [t0, t1], [0, 1], ease);
729
+ s = 1 + (S - 1) * p;
730
+ tx = (W / 2 - S * cx) * p;
731
+ ty = (H * 0.46 - S * cy) * p;
732
+ } else if (v.move !== 'scroll') {
733
+ s = interpolate(f, [0, d], [1, 1.06]);
734
+ tx = (W / 2) * (1 - s);
735
+ ty = (H / 2) * (1 - s);
736
+ }
737
+ cam.style.transform = `translate(${tx}px, ${ty + (1 - enter) * 60}px) scale(${s})`;
738
+ cam.style.opacity = String(enter);
739
+ if (ring) {
740
+ const r = lerpC(f, [t1, t1 + 8], [0, 1]);
741
+ const pulse = 0.5 + 0.5 * Math.sin((f - t1) / 5);
742
+ ring.style.display = r > 0 ? 'block' : 'none';
743
+ ring.style.border = `${(6 / s).toFixed(2)}px solid ${accent}`;
744
+ ring.style.boxShadow = `0 0 ${Math.round(18 + 22 * pulse)}px ${accent}`;
745
+ ring.style.opacity = String(r);
746
+ }
747
+ if (pointerEl) {
748
+ const fx = s * (focus.x + focus.w / 2) + tx;
749
+ const fy = s * (focus.y + focus.h / 2) + ty;
750
+ const travel = lerpC(f, [Math.round(d * 0.3), t1], [0, 1], ease);
751
+ const pxp = interpolate(travel, [0, 1], [W * 0.86, fx]);
752
+ const pyp = interpolate(travel, [0, 1], [H * 0.86, fy]);
753
+ const tap = lerpC(f, [t1 + 2, t1 + 16], [0, 1]);
754
+ const shown = f >= Math.round(d * 0.3) - 4;
755
+ pointerEl.style.display = shown ? 'block' : 'none';
756
+ pointerEl.style.left = px(pxp - 10);
757
+ pointerEl.style.top = px(pyp - 6);
758
+ pointerEl.style.transform = `scale(${tap > 0 && tap < 0.4 ? 0.88 : 1})`;
759
+ tapEl.style.display = shown && tap > 0 && tap < 1 ? 'block' : 'none';
760
+ Object.assign(tapEl.style, { left: px(fx - 60 * tap), top: px(fy - 60 * tap), width: px(120 * tap), height: px(120 * tap), opacity: String(1 - tap) });
761
+ }
762
+ };
763
+ }
764
+
765
+ // ── escenas con transiciones ───────────────────────────────────────────────
766
+ const TR = Math.round(D.transitionSec * fps);
767
+ const directed = D.scenes.some((x) => x.text && (x.text.kicker || x.text.layout !== 'statement'));
768
+ const points = directed ? 0 : D.scenes.filter((x) => x.text && x.text.role === 'point').length;
769
+ const scenes = D.scenes.map((sc, i) => {
770
+ const el = $(`scene${i}`);
771
+ const frames = Math.max(1, Math.round(sc.durationSec * fps));
772
+ const startF = Math.round(sc.start * fps);
773
+ const seed = i * 1.3;
774
+ let mediaUp = null;
775
+ const v = sc.visual;
776
+ if (v.kind === 'screen') mediaUp = screenScene(el, sc, seed, i, frames);
777
+ else if (v.kind === 'backdrop' || !v.mediaId) {
778
+ const holder = fill();
779
+ el.prepend(holder);
780
+ mediaUp = backdrop(holder, seed, i);
781
+ } else if (v.kind === 'image') {
782
+ const img = $(v.mediaId);
783
+ mediaUp = (f) => (img.style.transform = motionTransform(v.motion, Math.min(1, f / Math.max(1, frames))));
784
+ }
785
+ let textUp = null;
786
+ if (sc.text) {
787
+ const layer = fill();
788
+ el.append(layer);
789
+ const onMedia = v.kind !== 'backdrop' && !!v.mediaId;
790
+ const ordinal = sc.text.role === 'point' && points > 1 ? String(D.scenes.slice(0, i + 1).filter((x) => x.text && x.text.role === 'point').length).padStart(2, '0') : null;
791
+ const make = LAYOUTS[sc.text.layout];
792
+ textUp = make ? make(layer, sc.text, onMedia, frames) : statement(layer, sc.text, onMedia, ordinal, frames);
793
+ }
794
+ return { el, frames, startF, transition: i > 0 ? sc.transition : 'cut', mediaUp, textUp };
795
+ });
796
+
797
+ function drawScenes(frame) {
798
+ scenes.forEach((s, i) => {
799
+ const local = frame - s.startF;
800
+ const next = scenes[i + 1];
801
+ const visible = local >= 0 && local < s.frames;
802
+ s.el.style.display = visible ? 'block' : 'none';
803
+ if (!visible) return;
804
+ // Entrada (esta escena) y salida (la siguiente entra encima): transiciones de 0,4 s.
805
+ let opacity = 1;
806
+ let transform = 'none';
807
+ let clip = 'none';
808
+ if (s.transition !== 'cut' && local < TR) {
809
+ const p = local / TR;
810
+ if (s.transition === 'fade') opacity = p;
811
+ else if (s.transition === 'slide_up') transform = `translateY(${(1 - p) * 100}%)`;
812
+ else if (s.transition === 'wipe') clip = `inset(0 ${(1 - p) * 100}% 0 0)`;
813
+ }
814
+ if (next && next.transition !== 'cut' && frame >= next.startF) {
815
+ const p = (frame - next.startF) / TR;
816
+ // Remotion: en un fundido solo se funde la que entra; en un deslizamiento la que sale sube con ella.
817
+ if (next.transition === 'slide_up') transform = `translateY(${-p * 100}%)`;
818
+ }
819
+ s.el.style.opacity = String(opacity);
820
+ s.el.style.transform = transform;
821
+ s.el.style.clipPath = clip;
822
+ s.el.style.zIndex = String(10 + i);
823
+ if (s.mediaUp) s.mediaUp(local);
824
+ if (s.textUp) s.textUp(local);
825
+ });
826
+ }
827
+
828
+ // ── subtítulos ─────────────────────────────────────────────────────────────
829
+ const OUTLINE = ['0 0 3px #000', '2px 2px 0 #000', '-2px 2px 0 #000', '2px -2px 0 #000', '-2px -2px 0 #000', '0 8px 22px rgba(0,0,0,0.6)'].join(', ');
830
+ function luminance(hex) {
831
+ const n = parseInt(hex.slice(1), 16);
832
+ const ch = [(n >> 16) & 255, (n >> 8) & 255, n & 255].map((v) => {
833
+ const c = v / 255;
834
+ return c <= 0.03928 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4;
835
+ });
836
+ return 0.2126 * ch[0] + 0.7152 * ch[1] + 0.0722 * ch[2];
837
+ }
838
+ function wordsOf(cue) {
839
+ if (cue.words.length) return cue.words.map((w) => ({ text: w.text, start: w.startSec, end: w.endSec }));
840
+ const parts = cue.text.split(/\s+/).filter(Boolean);
841
+ const weight = parts.map((p) => Math.max(2, p.length));
842
+ const total = weight.reduce((a, b) => a + b, 0);
843
+ const span = cue.endSec - cue.startSec;
844
+ let t = cue.startSec;
845
+ return parts.map((p, i) => {
846
+ const d = (span * weight[i]) / total;
847
+ const w = { text: p, start: t, end: t + d };
848
+ t += d;
849
+ return w;
850
+ });
851
+ }
852
+ function pagesOf(words, max) {
853
+ const pages = [];
854
+ let cur = [];
855
+ words.forEach((w, i) => {
856
+ cur.push(w);
857
+ const next = words[i + 1];
858
+ const pause = next ? next.start - w.end > 0.3 : true;
859
+ if (cur.length >= max || /[.,!?;:…]$/.test(w.text) || pause) {
860
+ pages.push(cur);
861
+ cur = [];
862
+ }
863
+ });
864
+ if (cur.length) pages.push(cur);
865
+ return pages;
866
+ }
867
+ const capLayer = $('captions');
868
+ const capAccent = D.captionColor ?? accent;
869
+ const capOnAccent = luminance(capAccent) > 0.55 ? '#111' : '#fff';
870
+ function drawCaptions(frame) {
871
+ if (!capLayer) return;
872
+ const t = frame / fps;
873
+ const caps = D.captions;
874
+ const cue = caps.find((c) => t >= c.startSec && t < c.endSec + 0.05) ?? caps.find((c, i) => {
875
+ const next = caps[i + 1];
876
+ return t >= c.endSec && t < c.endSec + 0.7 && (!next || t < next.startSec);
877
+ });
878
+ capLayer.replaceChildren();
879
+ if (!cue) return;
880
+ const boxWidth = W - safe.side * 2;
881
+ const emphasis = new Set(cue.emphasis.flatMap((e) => e.split(/\s+/)).map(cleanWord));
882
+ const middle = D.captionPosition === 'middle' || cue.hero;
883
+ Object.assign(capLayer.style, { justifyContent: middle ? 'center' : 'flex-end', alignItems: 'center', paddingBottom: px(middle ? 0 : safe.bottom - 40) });
884
+ if (D.captionStyle === 'box' && !cue.hero) {
885
+ capLayer.append(h('div', { maxWidth: px(boxWidth), fontFamily: FONT, fontWeight: '800', fontSize: px(portrait ? 50 : 42), lineHeight: '1.22', color: '#fff', textAlign: 'center', background: 'rgba(0,0,0,0.66)', padding: '12px 24px', borderRadius: '16px' }, [cue.text]));
886
+ return;
887
+ }
888
+ const words = wordsOf(cue);
889
+ const hero = cue.hero;
890
+ const pages = pagesOf(words, hero ? 5 : D.captionStyle === 'pop' ? 2 : 4);
891
+ const page = pages.find((p) => t < p[p.length - 1].end + 0.04) ?? pages[pages.length - 1] ?? [];
892
+ if (!page.length) return;
893
+ const big = D.captionStyle === 'pop' || hero;
894
+ const text = page.map((w) => w.text.toUpperCase()).join(' ');
895
+ const fitted = fitLines(text, { width: boxWidth, max: hero ? (portrait ? 150 : 110) : portrait ? (big ? 124 : 88) : big ? 96 : 68, min: 44, maxLines: hero ? 3 : 2, weight: '800' });
896
+ const enter = spring(frame - Math.round(page[0].start * fps), { damping: 14, mass: 0.5 });
897
+ const box = h('div', { maxWidth: px(boxWidth), textAlign: 'center', fontFamily: FONT, ...(big ? {} : { background: 'rgba(8,8,10,0.62)', borderRadius: '26px', padding: '14px 22px 18px' }), transform: `scale(${interpolate(enter, [0, 1], [big ? 0.7 : 0.9, 1])})`, opacity: String(interpolate(enter, [0, 1], [0, 1])) });
898
+ let wi = 0;
899
+ for (const line of fitted.lines) {
900
+ const row = h('div', { whiteSpace: 'nowrap', lineHeight: '1.1' });
901
+ for (const token of line.split(' ')) {
902
+ const w = page[wi++];
903
+ const active = w ? t >= w.start && t < w.end + 0.06 : false;
904
+ const spoken = w ? t >= w.start : false;
905
+ const hl = w ? emphasis.has(cleanWord(w.text)) : false;
906
+ const pop = w ? spring(frame - Math.round(w.start * fps), { damping: 10, mass: 0.4 }) : 1;
907
+ row.append(h('span', { display: 'inline-block', margin: '0 0.12em', fontWeight: '800', fontSize: px(fitted.size), letterSpacing: '-0.01em', color: big ? (active || hl ? capAccent : '#fff') : active ? capOnAccent : '#fff', opacity: String(big && !hero ? (spoken ? 1 : 0) : hero && !spoken ? 0.4 : 1), padding: active && !big ? '0 0.14em' : '0', borderRadius: '12px', background: active && !big ? capAccent : 'transparent', transform: `scale(${active ? interpolate(pop, [0, 1], [0.86, big ? 1.08 : 1.04]) : 1})`, textShadow: active && !big && capOnAccent !== '#fff' ? 'none' : OUTLINE }, [token]));
908
+ }
909
+ box.append(row);
910
+ }
911
+ capLayer.append(box);
912
+ }
913
+
914
+ // ── el vídeo de la persona (charla) ────────────────────────────────────────
915
+ const talk = D.talk;
916
+ function trackAt(track, t) {
917
+ const first = track[0];
918
+ if (!first) return 0.5;
919
+ if (t <= first.t) return first.x;
920
+ for (let i = 1; i < track.length; i++) {
921
+ const a = track[i - 1];
922
+ const b = track[i];
923
+ if (t <= b.t) {
924
+ const k = b.t > a.t ? (t - a.t) / (b.t - a.t) : 1;
925
+ return a.x + (b.x - a.x) * k * k * (3 - 2 * k);
926
+ }
927
+ }
928
+ return track[track.length - 1].x;
929
+ }
930
+ function zoomAt(t) {
931
+ const z = talk.zooms.find((x) => t >= x.startSec && t < x.endSec);
932
+ if (!z) return { scale: 1, x: 0.5, y: 0.5 };
933
+ const inSec = z.ease === 'punch' ? 3 / fps : 0.6;
934
+ const outSec = z.ease === 'punch' ? 3 / fps : 0.35;
935
+ const span = z.endSec - z.startSec;
936
+ const k = Math.min(lerpC(t - z.startSec, [0, Math.min(inSec, span / 2)], [0, 1]), lerpC(z.endSec - t, [0, Math.min(outSec, span / 2)], [0, 1]));
937
+ return { scale: 1 + (z.scale - 1) * k, x: z.x, y: z.y };
938
+ }
939
+ const keepTogether = (text) => text.replace(/\s+([€$£%»!?:;”])/g, ' $1').replace(/([«¿¡“])\s+/g, '$1 ');
940
+ const OUT2 = ['0 0 4px #000', '3px 3px 0 #000', '-3px 3px 0 #000', '3px -3px 0 #000', '-3px -3px 0 #000', '0 10px 26px rgba(0,0,0,0.55)'].join(', ');
941
+ const overlayEls = talk
942
+ ? talk.overlays.map((raw) => {
943
+ const o = { ...raw, text: keepTogether(raw.text) };
944
+ const position = o.kind === 'end' ? 'bottom' : o.kind === 'hook' ? 'top' : o.position;
945
+ const pad = position === 'top' ? { paddingTop: px(safe.top + (o.kind === 'hook' ? 10 : 30)) } : position === 'bottom' ? { paddingBottom: px(safe.bottom + 240) } : { paddingBottom: px(H * 0.1) };
946
+ const holder = fill({ justifyContent: position === 'top' ? 'flex-start' : position === 'bottom' ? 'flex-end' : 'center', alignItems: 'center', boxSizing: 'border-box', zIndex: '60', ...pad });
947
+ const boxCss = { maxWidth: `calc(100% - ${safe.side * 2}px)`, fontFamily: FONT, textAlign: 'center' };
948
+ const css = {
949
+ hook: { ...boxCss, fontWeight: '900', fontSize: '78px', lineHeight: '1.05', color: '#fff', background: accent, padding: '18px 30px 20px', borderRadius: '22px', textTransform: 'uppercase', boxShadow: '0 16px 40px rgba(0,0,0,0.4)' },
950
+ number: { ...boxCss, fontWeight: '900', fontSize: '230px', lineHeight: '0.95', color: accent, textShadow: OUT2, webkitTextStroke: '6px #fff', paintOrder: 'stroke fill' },
951
+ keyword: { ...boxCss, fontWeight: '900', fontSize: '120px', lineHeight: '1', color: '#fff', textTransform: 'uppercase', textShadow: OUT2 },
952
+ emoji: { fontSize: '190px', lineHeight: '1', filter: 'drop-shadow(0 12px 26px rgba(0,0,0,0.45))' },
953
+ end: { ...boxCss, fontWeight: '900', fontSize: '66px', lineHeight: '1.1', color: '#fff', background: accent, padding: '22px 36px', borderRadius: '26px', boxShadow: '0 18px 44px rgba(0,0,0,0.45)' },
954
+ label: { ...boxCss, fontWeight: '900', fontSize: '62px', lineHeight: '1.1', color: '#fff', background: 'rgba(0,0,0,0.72)', border: `4px solid ${accent}`, padding: '14px 28px', borderRadius: '999px' },
955
+ }[o.kind] ?? {};
956
+ const body = h('div', css, [o.kind === 'label' ? o.text.toUpperCase() : o.text]);
957
+ holder.append(body);
958
+ $('overlays').append(holder);
959
+ return { o, holder, body };
960
+ })
961
+ : [];
962
+ function drawTalk(frame) {
963
+ if (!talk) return;
964
+ const t = frame / fps;
965
+ const z = zoomAt(t);
966
+ const zoomEl = $('talk-zoom');
967
+ zoomEl.style.transform = `scale(${z.scale.toFixed(4)})`;
968
+ zoomEl.style.transformOrigin = `${(z.x * 100).toFixed(2)}% ${(z.y * 100).toFixed(2)}%`;
969
+ if (talk.fit !== 'contain' && talk.track.length) {
970
+ for (const seg of talk.timeline) {
971
+ if (frame < seg.startFrame || frame >= seg.startFrame + seg.frames) continue;
972
+ const local = frame - seg.startFrame;
973
+ $(seg.videoId).style.objectPosition = `${(trackAt(talk.track, seg.srcIn + local / fps) * 100).toFixed(2)}% 50%`;
974
+ }
975
+ }
976
+ for (const x of overlayEls) {
977
+ const { o, holder, body } = x;
978
+ const on = t >= o.startSec && t < o.endSec;
979
+ holder.style.display = on ? 'flex' : 'none';
980
+ if (!on) continue;
981
+ const local = Math.round((t - o.startSec) * fps);
982
+ const pop = spring(local, { damping: 10, stiffness: 190 });
983
+ const soft = spring(local, { damping: 18, stiffness: 120 });
984
+ holder.style.opacity = String(lerpC((o.endSec - t) * fps, [0, 5], [0, 1]));
985
+ body.style.transform = {
986
+ hook: `translateY(${((1 - soft) * -120).toFixed(1)}px) scale(${(0.85 + 0.15 * pop).toFixed(3)}) rotate(${((1 - pop) * -3).toFixed(2)}deg)`,
987
+ number: `scale(${(0.35 + 0.65 * pop).toFixed(3)}) rotate(${((1 - pop) * 8).toFixed(2)}deg)`,
988
+ keyword: `scale(${(1.7 - 0.7 * pop).toFixed(3)})`,
989
+ emoji: `translateY(${(Math.sin(local / 9) * 10).toFixed(1)}px) scale(${pop.toFixed(3)}) rotate(${((1 - pop) * -18 + Math.sin(local / 7) * 4).toFixed(2)}deg)`,
990
+ end: `translateY(${((1 - soft) * 160).toFixed(1)}px)`,
991
+ label: `scale(${pop.toFixed(3)})`,
992
+ }[o.kind];
993
+ if (o.kind === 'keyword') body.style.opacity = String(Math.min(1, local / 2));
994
+ }
995
+ for (const b of talk.broll) {
996
+ const el = $(b.holderId);
997
+ const from = Math.round(b.startSec * fps);
998
+ const frames = Math.max(1, Math.round((b.endSec - b.startSec) * fps));
999
+ const local = frame - from;
1000
+ const on = local >= 0 && local < frames;
1001
+ el.style.display = on ? (b.mode === 'pip' ? 'flex' : 'block') : 'none';
1002
+ if (!on) continue;
1003
+ const pop = spring(local, { damping: 13, stiffness: 170 });
1004
+ const fadeOut = lerpC(local, [frames - 4, frames], [1, 0]);
1005
+ const pic = $(b.mediaId);
1006
+ if (b.kind === 'image') pic.style.transform = `scale(${interpolate(local, [0, frames], [1.02, 1.1]).toFixed(4)})`;
1007
+ if (b.mode === 'pip') {
1008
+ const card = $(`${b.holderId}-card`);
1009
+ card.style.transform = `scale(${pop.toFixed(3)}) rotate(${((1 - pop) * 6 + 2).toFixed(2)}deg)`;
1010
+ card.style.opacity = String(fadeOut);
1011
+ } else {
1012
+ el.style.opacity = String(Math.min(fadeOut, lerpC(local, [0, 2], [0, 1])));
1013
+ el.style.transform = `scale(${lerpC(local, [0, 4], [1.12, 1]).toFixed(4)})`;
1014
+ }
1015
+ }
1016
+ }
1017
+
1018
+ const bar = $('progress');
1019
+ const totalFrames = Math.max(1, Math.round(D.duration * fps));
1020
+ IgnixFrame.run({ id: D.id, duration: D.duration, fps }, (t, frame) => {
1021
+ drawTalk(frame);
1022
+ drawScenes(frame);
1023
+ drawCaptions(frame);
1024
+ if (bar) bar.style.width = `${(100 * Math.min(1, frame / Math.max(1, totalFrames - 1))).toFixed(3)}%`;
1025
+ });
1026
+ })();