morfeo 0.0.0-stage → 0.1.1

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.
Files changed (51) hide show
  1. package/LICENSE +22 -0
  2. package/README.md +210 -2
  3. package/assets/fuentes/Anton.ttf +0 -0
  4. package/assets/fuentes/JetBrainsMono.ttf +0 -0
  5. package/assets/fuentes/Montserrat.ttf +0 -0
  6. package/bin/morfeo.mjs +184 -0
  7. package/bin/postinstall.mjs +10 -0
  8. package/index.d.ts +250 -0
  9. package/lib/config.mjs +284 -0
  10. package/lib/entorno.mjs +93 -0
  11. package/lib/guion.mjs +313 -0
  12. package/lib/imagenes.mjs +99 -0
  13. package/lib/index.mjs +15 -0
  14. package/lib/instalar.mjs +193 -0
  15. package/lib/navegador.mjs +64 -0
  16. package/lib/personajes.mjs +94 -0
  17. package/lib/render.mjs +312 -0
  18. package/lib/voces.mjs +122 -0
  19. package/lib/whisper.mjs +45 -0
  20. package/package.json +84 -5
  21. package/plantilla/assets/.gitkeep +0 -0
  22. package/plantilla/env.example +7 -0
  23. package/plantilla/escenas/Barras.tsx +15 -0
  24. package/plantilla/gitignore +4 -0
  25. package/plantilla/guiones/cachalote.mjs +24 -0
  26. package/plantilla/guiones/tutorial.mjs +16 -0
  27. package/plantilla/morfeo.config.mjs +43 -0
  28. package/plantilla/package.json +13 -0
  29. package/plantilla/videos.mjs +25 -0
  30. package/python/musica.py +221 -0
  31. package/python/preparar.py +268 -0
  32. package/python/requirements.txt +5 -0
  33. package/remotion/Root.tsx +35 -0
  34. package/remotion/Short.tsx +278 -0
  35. package/remotion/escenas.tsx +430 -0
  36. package/remotion/estilo.ts +73 -0
  37. package/remotion/iconos.ts +326 -0
  38. package/remotion/index.ts +4 -0
  39. package/remotion/motor/Video.tsx +121 -0
  40. package/remotion/motor/capas.tsx +622 -0
  41. package/remotion/motor/fondo.tsx +148 -0
  42. package/remotion/motor/modelo3d.tsx +252 -0
  43. package/remotion/motor/plantillas.tsx +125 -0
  44. package/remotion/motor/registro.ts +11 -0
  45. package/remotion/motor/subtitulos.tsx +137 -0
  46. package/remotion/motor/tema.ts +207 -0
  47. package/remotion/motor/util.ts +66 -0
  48. package/remotion/motor/widgets.tsx +248 -0
  49. package/remotion/piezas.tsx +181 -0
  50. package/remotion/tipos.ts +43 -0
  51. package/remotion/tres/Capa3D.tsx +153 -0
@@ -0,0 +1,268 @@
1
+ """Prepara el audio y los tiempos de un video para Remotion (lo llama morfeo, no se usa a mano).
2
+
3
+ python preparar.py <trabajo.json>
4
+
5
+ trabajo.json = {"id", "video" (la especificación), "indice", "publico" (carpeta public de Remotion),
6
+ "modelos" (carpeta con kokoro-v1.0.onnx y voices-v1.0.bin), "soloVoz"}
7
+ Escribe en <publico>/v/<id>/: voz.wav, tiempos.json, mix.wav y datos.json.
8
+ Si existe whisper.json en esa carpeta, los subtítulos usan los tiempos reales de cada palabra.
9
+ """
10
+ import difflib, hashlib, json, os, re, sys, unicodedata
11
+ import numpy as np
12
+
13
+ D = os.path.dirname(os.path.abspath(__file__))
14
+ sys.path.insert(0, D)
15
+
16
+ SR_VOZ = 24000
17
+ VERSION_VOZ = 2 # súbelo si cambias la cadena de voz para regenerar todas
18
+ VELOCIDAD = 1.2
19
+
20
+
21
+ # ---------------- voz ----------------
22
+ def _biquad_peak(f, g_db, q, sr):
23
+ a = 10 ** (g_db / 40); w = 2 * np.pi * f / sr; al = np.sin(w) / (2 * q)
24
+ b = [1 + al * a, -2 * np.cos(w), 1 - al * a]; aa = [1 + al / a, -2 * np.cos(w), 1 - al / a]
25
+ return np.array(b) / aa[0], np.array(aa) / aa[0]
26
+
27
+
28
+ def cadena_locutor(x, sr=SR_VOZ):
29
+ """La misma cadena de "voz de locutor" del motor original, sin depender de ffmpeg:
30
+ highpass 70 Hz, +3 dB en 180 Hz, -2 dB en 3.5 kHz, compresor 3:1, eco leve y -16 LUFS."""
31
+ from scipy.signal import butter, lfilter, sosfilt
32
+ import pyloudnorm as pyln
33
+ x = sosfilt(butter(2, 70, "high", fs=sr, output="sos"), x)
34
+ for f, g, q in ((180, 3, 1.0), (3500, -2, 1.5)):
35
+ b, a = _biquad_peak(f, g, q, sr); x = lfilter(b, a, x)
36
+ # compresor: umbral -20 dB, 3:1, ataque 5 ms, liberación 80 ms
37
+ thr = 10 ** (-20 / 20); ra = np.exp(-1 / (0.005 * sr)); rr = np.exp(-1 / (0.080 * sr))
38
+ env = np.empty_like(x); e = 0.0
39
+ for i, v in enumerate(np.abs(x)):
40
+ e = ra * e + (1 - ra) * v if v > e else rr * e + (1 - rr) * v
41
+ env[i] = e
42
+ over = np.maximum(env / thr, 1.0)
43
+ x = x * over ** (1 / 3 - 1)
44
+ # eco muy leve (aecho 0.8:0.5:30:0.12)
45
+ dly = int(0.030 * sr); y = x * 0.8; y[dly:] += x[:-dly] * 0.12; x = y * 0.5
46
+ # normalización a -16 LUFS
47
+ lufs = pyln.Meter(sr).integrated_loudness(x)
48
+ x = x * 10 ** ((-16 - lufs) / 20)
49
+ return np.clip(x, -1, 1)
50
+
51
+
52
+ def _huella_archivo(p):
53
+ try: st = os.stat(p); return [os.path.basename(p), st.st_size, int(st.st_mtime)]
54
+ except OSError: return [p, 0, 0]
55
+
56
+
57
+ def clave_voz(v):
58
+ voces = v.get("voice") or []
59
+ txt = json.dumps([VERSION_VOZ, voces, [_huella_archivo(r) for r, _ in voces if r.endswith(".npy")],
60
+ v.get("speed", VELOCIDAD), v.get("lang", "es"),
61
+ [(s["say"], s.get("show"), _huella_archivo(s["audio"]) if s.get("audio") else None) for s in v["scenes"]]],
62
+ ensure_ascii=False)
63
+ return hashlib.sha1(txt.encode()).hexdigest()[:16]
64
+
65
+
66
+ def leer_audio(ruta, sr):
67
+ """Lee una grabación (wav/flac/ogg/mp3 con soundfile; si falla, con ffmpeg) y la deja mono a `sr` Hz."""
68
+ import soundfile as sf
69
+ from scipy.signal import resample_poly
70
+ try:
71
+ x, sr0 = sf.read(ruta, always_2d=True)
72
+ except Exception:
73
+ import shutil, subprocess, tempfile
74
+ if not shutil.which("ffmpeg"):
75
+ raise SystemExit(f"No pude leer {ruta}. Conviértelo a WAV o instala ffmpeg.")
76
+ with tempfile.TemporaryDirectory() as td:
77
+ tmp = os.path.join(td, "a.wav")
78
+ subprocess.run(["ffmpeg", "-y", "-loglevel", "error", "-i", ruta, tmp], check=True)
79
+ x, sr0 = sf.read(tmp, always_2d=True)
80
+ x = x.mean(axis=1)
81
+ if sr0 != sr:
82
+ from math import gcd
83
+ g = gcd(int(sr), int(sr0)); x = resample_poly(x, sr // g, sr0 // g)
84
+ return x.astype(np.float64)
85
+
86
+
87
+ def hacer_voz(v, outdir, modelos):
88
+ """Voz de cada escena: su grabación (`audio`) o Kokoro (`voice`: nombres de voz o .npy, mezclados por peso)."""
89
+ import soundfile as sf
90
+ spec = v["scenes"]
91
+ k = voice = None
92
+ if any(not sc.get("audio") for sc in spec):
93
+ from kokoro_onnx import Kokoro
94
+ k = Kokoro(os.path.join(modelos, "kokoro-v1.0.onnx"), os.path.join(modelos, "voices-v1.0.bin"))
95
+ partes = []
96
+ for ref, w in v["voice"]:
97
+ estilo = np.load(ref) if ref.endswith(".npy") else k.get_voice_style(ref)
98
+ partes.append(w * estilo)
99
+ voice = sum(partes)
100
+ PAD = 0.2; out, meta, t = [], [], 0.0
101
+ for i, sc in enumerate(spec):
102
+ if sc.get("audio"):
103
+ s = leer_audio(sc["audio"], SR_VOZ)
104
+ else:
105
+ s, _ = k.create(sc["say"], voice=voice, speed=v.get("speed", VELOCIDAD), lang=v.get("lang", "es"))
106
+ idx = np.where(np.abs(s) > 0.01)[0]
107
+ if len(idx): s = s[max(idx[0] - 200, 0): idx[-1] + 600]
108
+ lead = 0.12 if i else 0.25
109
+ seg = np.concatenate([np.zeros(int(lead * SR_VOZ)), s, np.zeros(int(PAD * SR_VOZ))]); out.append(seg)
110
+ meta.append({"i": i, "text": sc.get("show", sc["say"]), "say": sc["say"], "start": round(t + lead, 3),
111
+ "speech": round(len(s) / SR_VOZ, 3), "dur": round(len(seg) / SR_VOZ, 3), "t0": round(t, 3)})
112
+ t += len(seg) / SR_VOZ
113
+ sf.write(os.path.join(outdir, "voz.wav"), cadena_locutor(np.concatenate(out)).astype(np.float32), SR_VOZ)
114
+ json.dump({"clave": clave_voz(v), "escenas": meta}, open(os.path.join(outdir, "tiempos.json"), "w", encoding="utf-8"), ensure_ascii=False, indent=1)
115
+ wj = os.path.join(outdir, "whisper.json")
116
+ if os.path.exists(wj): os.remove(wj) # la voz cambió: la transcripción vieja ya no sirve
117
+
118
+
119
+ def voz_vigente(v, outdir):
120
+ tj = os.path.join(outdir, "tiempos.json")
121
+ if not os.path.exists(tj) or not os.path.exists(os.path.join(outdir, "voz.wav")): return False
122
+ return json.load(open(tj, encoding="utf-8")).get("clave") == clave_voz(v)
123
+
124
+
125
+ # ---------------- tiempos por palabra ----------------
126
+ def norm(w):
127
+ w = unicodedata.normalize("NFD", w.lower())
128
+ w = "".join(c for c in w if unicodedata.category(c) != "Mn")
129
+ return re.sub(r"[^a-z0-9]", "", w)
130
+
131
+
132
+ def reparto(words, a, b):
133
+ """Reparte el intervalo [a, b] entre las palabras según su largo (el método del motor original)."""
134
+ wts = [len(w) + 2 for w in words]; tot = sum(wts) or 1; t = a; out = []
135
+ for w, k in zip(words, wts):
136
+ dd = (b - a) * k / tot; out.append([w, t, t + dd]); t += dd
137
+ return out
138
+
139
+
140
+ def palabras_whisper(path):
141
+ """Une los tokens de Whisper en palabras: un token sin espacio inicial continúa la palabra anterior."""
142
+ out = []
143
+ for c in json.load(open(path)):
144
+ txt = c["text"]
145
+ if not txt.strip() or txt.strip().startswith("["): continue
146
+ a, b = c["startMs"] / 1000, c["endMs"] / 1000
147
+ if out and not txt.startswith(" "): out[-1][0] += txt; out[-1][2] = b
148
+ else: out.append([txt.strip(), a, b])
149
+ return [w for w in out if norm(w[0])]
150
+
151
+
152
+ def alinear(say_words, ww, a, b):
153
+ """Empata las palabras del guion con las de Whisper. Devuelve (tiempos por palabra, aciertos) o (None, 0)."""
154
+ cand = [w for w in ww if w[2] > a - 0.15 and w[1] < b + 0.15]
155
+ if not cand: return None, 0
156
+ sm = difflib.SequenceMatcher(None, [norm(w) for w in say_words], [norm(w[0]) for w in cand], autojunk=False)
157
+ t = [None] * len(say_words); hits = 0
158
+ for i, j, n in sm.get_matching_blocks():
159
+ for k in range(n):
160
+ t[i + k] = (max(a, cand[j + k][1]), min(b, cand[j + k][2])); hits += 1
161
+ if hits < max(1, 0.5 * len(say_words)): return None, 0
162
+ # huecos: se rellenan repartiendo entre los vecinos conocidos
163
+ i = 0
164
+ while i < len(t):
165
+ if t[i] is None:
166
+ j = i
167
+ while j < len(t) and t[j] is None: j += 1
168
+ lo = t[i - 1][1] if i else a; hi = t[j][0] if j < len(t) else b
169
+ for (w, x, y), k in zip(reparto(say_words[i:j], lo, max(lo + 0.05, hi)), range(i, j)): t[k] = (x, y)
170
+ i = j
171
+ else: i += 1
172
+ for k in range(1, len(t)): # que nunca vayan hacia atrás
173
+ if t[k][0] < t[k - 1][0]: t[k] = (t[k - 1][0], max(t[k - 1][0], t[k][1]))
174
+ return t, hits
175
+
176
+
177
+ def tiempos_palabras(seg, ww):
178
+ """Tiempos de las palabras del subtítulo (show). Con Whisper: reales; sin él: reparto por frase."""
179
+ a, b = seg["start"], seg["start"] + seg["speech"]
180
+ show = seg["text"].split(); say = seg["say"].split()
181
+ if not ww: return reparto(show, a, b), False
182
+ # Whisper a veces escribe los números con cifras ("1889") y a veces con letras: se prueba contra los dos textos
183
+ t_show, h_show = alinear(show, ww, a, b)
184
+ ts, h_say = alinear(say, ww, a, b)
185
+ if t_show is not None and h_show >= h_say: return [[w, x, y] for w, (x, y) in zip(show, t_show)], True
186
+ if ts is None: return reparto(show, a, b), False
187
+ sm = difflib.SequenceMatcher(None, [norm(w) for w in say], [norm(w) for w in show], autojunk=False)
188
+ out = [None] * len(show)
189
+ for tag, i1, i2, j1, j2 in sm.get_opcodes():
190
+ if tag == "equal":
191
+ for k in range(j2 - j1): out[j1 + k] = [show[j1 + k], *ts[i1 + k]]
192
+ elif j2 > j1: # p. ej. "mil novecientos ochenta" -> "1980"
193
+ lo = ts[i1][0] if i1 < i2 else (ts[i1 - 1][1] if i1 else a)
194
+ hi = ts[i2 - 1][1] if i1 < i2 else (ts[i1][0] if i1 < len(ts) else b)
195
+ for k, (w, x, y) in enumerate(reparto(show[j1:j2], lo, max(lo + 0.05, hi))): out[j1 + k] = [w, x, y]
196
+ return out, True
197
+
198
+
199
+ def trozos(words):
200
+ """Subtítulos de 1 a 3 palabras (máx. 14 letras), cortando en la puntuación."""
201
+ out, cur = [], []
202
+ for w in words:
203
+ cur.append(w)
204
+ if len(cur) == 3 or sum(len(x[0]) for x in cur) > 14 or w[0][-1] in ".,:?": out.append(cur); cur = []
205
+ if cur: out.append(cur)
206
+ return out
207
+
208
+
209
+ # ---------------- datos para Remotion ----------------
210
+ CLASICOS = {"title", "year", "text", "icon", "compare", "card", "dialog", "aliens", "memory", "morph", "cta"}
211
+
212
+
213
+ def hexc(c): return "#%02X%02X%02X" % tuple(c)
214
+
215
+
216
+ def colores(x):
217
+ """Colores como [r, g, b] (o tupla) pasan a "#RRGGBB"; los "#hex" se quedan igual."""
218
+ if isinstance(x, (list, tuple)) and len(x) == 3 and all(isinstance(v, int) and not isinstance(v, bool) and 0 <= v <= 255 for v in x): return hexc(x)
219
+ if isinstance(x, (list, tuple)): return [colores(v) for v in x]
220
+ if isinstance(x, dict): return {k: colores(v) for k, v in x.items()}
221
+ return x
222
+
223
+
224
+ def preparar(job):
225
+ import musica
226
+ vid, v = job["id"], job["video"]; spec = v["scenes"]
227
+ outdir = os.path.join(job["publico"], "v", vid); os.makedirs(outdir, exist_ok=True)
228
+ if not voz_vigente(v, outdir):
229
+ print(" voz...", flush=True); hacer_voz(v, outdir, job["modelos"])
230
+ if job.get("soloVoz"): return
231
+ TIM = json.load(open(os.path.join(outdir, "tiempos.json"), encoding="utf-8"))["escenas"]
232
+ wj = os.path.join(outdir, "whisper.json"); ww = palabras_whisper(wj) if os.path.exists(wj) else None
233
+ WORDS, reales = [], 0
234
+ for seg in TIM:
235
+ w, ok = tiempos_palabras(seg, ww); WORDS.append(w); reales += ok
236
+
237
+ def ev(si, prefix):
238
+ seg = TIM[si]
239
+ for w, a, b in WORDS[si]:
240
+ if w.lower().strip("¿?.,:¡!").startswith(prefix.lower()): return a - seg["t0"]
241
+ return seg["speech"] * 0.5
242
+
243
+ durs = [seg["dur"] + (0.6 if i == len(TIM) - 1 else 0) for i, seg in enumerate(TIM)]
244
+ musica.mix(vid, v.get("music"), outdir, TIM, spec, durs, ev) # -> mix.wav (voz + música con ducking + efectos)
245
+
246
+ escenas = []
247
+ for si, (seg, sp) in enumerate(zip(TIM, spec)):
248
+ # los tipos clásicos aceptan colores (r, g, b); los del motor de capas usan "#hex" tal cual
249
+ conv = colores if sp["type"] in CLASICOS else (lambda x: x)
250
+ p = {k: conv(val) for k, val in sp.items() if k not in ("say", "show", "audio")}
251
+ evs = {k: round(ev(si, sp[k]), 3) for k in ("at", "click") if sp.get(k)}
252
+ escenas.append(dict(tipo=sp["type"], p=p, t0=seg["t0"], dur=round(durs[si], 3),
253
+ habla=[round(seg["start"] - seg["t0"], 3), round(seg["start"] - seg["t0"] + seg["speech"], 3)], ev=evs,
254
+ palabras=[[w, round(a - seg["t0"], 3), round(b - seg["t0"], 3)] for w, a, b in WORDS[si]]))
255
+ subs = [dict(a=round(c[0][1], 3), b=round(c[-1][2], 3), w=[[x[0].upper(), round(x[1], 3), round(x[2], 3)] for x in c])
256
+ for ws in WORDS for c in trozos(ws)]
257
+ datos = dict(id=vid, titulo=v.get("title", vid), indice=job.get("indice", 0), duracion=round(sum(durs), 3),
258
+ estilo=v.get("estilo", "datos-curiosos"), tema=v.get("tema") or {}, hud=v.get("hud") or [],
259
+ audio=f"v/{vid}/mix.wav", whisper=reales, escenas=escenas, subtitulos=subs)
260
+ json.dump(datos, open(os.path.join(outdir, "datos.json"), "w", encoding="utf-8"), ensure_ascii=False, indent=1)
261
+ print(f" tiempos por palabra: {'Whisper' if reales else 'reparto por frase'} ({reales}/{len(TIM)} escenas)", flush=True)
262
+
263
+
264
+ if __name__ == "__main__":
265
+ try: sys.stdout.reconfigure(encoding="utf-8", errors="replace")
266
+ except Exception: pass
267
+ if len(sys.argv) != 2: sys.exit(__doc__)
268
+ preparar(json.load(open(sys.argv[1], encoding="utf-8")))
@@ -0,0 +1,5 @@
1
+ kokoro-onnx==0.6.1
2
+ soundfile
3
+ numpy
4
+ scipy
5
+ pyloudnorm
@@ -0,0 +1,35 @@
1
+ // Registra una composición por cada video preparado. Los datos llegan como inputProps
2
+ // (morfeo render / morfeo studio los pasan): {videos: {id: datos}, marca}.
3
+ import React from 'react';
4
+ import {AbsoluteFill, Composition, getInputProps} from 'remotion';
5
+ import {FPS, H, W} from './estilo';
6
+ import {Short} from './Short';
7
+ import {MARCA_VACIA, type PropsMorfeo} from './tipos';
8
+
9
+ const SinVideos: React.FC = () => (
10
+ <AbsoluteFill style={{background: '#120a24', color: '#fff', fontSize: 56, fontFamily: 'sans-serif', padding: 80, justifyContent: 'center', textAlign: 'center'}}>
11
+ Todavía no hay videos preparados. Corre: npx morfeo render
12
+ </AbsoluteFill>
13
+ );
14
+
15
+ export const Root: React.FC = () => {
16
+ const {videos = {}, marca = MARCA_VACIA, fuentes = [], estilos = {}} = getInputProps() as Partial<PropsMorfeo>;
17
+ const ids = Object.keys(videos);
18
+ if (!ids.length) return <Composition id="sin-videos" component={SinVideos} durationInFrames={60} fps={FPS} width={W} height={H} />;
19
+ return (
20
+ <>
21
+ {ids.map((id) => (
22
+ <Composition
23
+ key={id}
24
+ id={id}
25
+ component={Short}
26
+ durationInFrames={Math.ceil(videos[id].duracion * FPS)}
27
+ fps={FPS}
28
+ width={W}
29
+ height={H}
30
+ defaultProps={{id, videos, marca, guias: false, fuentes, estilos}}
31
+ />
32
+ ))}
33
+ </>
34
+ );
35
+ };
@@ -0,0 +1,278 @@
1
+ // Composición de un short: fondo, escena actual, subtítulos, marca, efectos y audio.
2
+ // Todo se dibuja con <Fotograma T={segundos} />, una función pura del tiempo.
3
+ import React, {useEffect, useState} from 'react';
4
+ import {AbsoluteFill, Audio, continueRender, delayRender, random, staticFile, useCurrentFrame} from 'remotion';
5
+ import {ESCENAS, capa3D} from './escenas';
6
+ import {Capa3D} from './tres/Capa3D';
7
+ import {
8
+ AMARILLO,
9
+ ANTON,
10
+ BLANCO,
11
+ FPS,
12
+ H,
13
+ SEGURO,
14
+ SUB_ANCHO,
15
+ SUB_Y,
16
+ W,
17
+ ancho,
18
+ backOut,
19
+ clamp,
20
+ easeOut,
21
+ fondoDe,
22
+ fuentesListas,
23
+ mezclaColor,
24
+ } from './estilo';
25
+ import {Avatar, MarcaContexto, Resplandor, Texto, useMarca} from './piezas';
26
+ import {MARCA_VACIA, type Datos, type Escena, type Marca as TipoMarca} from './tipos';
27
+ import {Capas} from './motor/capas';
28
+ import {capasDe} from './motor/plantillas';
29
+ import {temaDe} from './motor/tema';
30
+ import {VideoConAudio} from './motor/Video';
31
+ import {loadFont} from '@remotion/fonts';
32
+
33
+ export type PropsShort = {id: string; videos?: Record<string, Datos>; marca?: TipoMarca; guias?: boolean; miniatura?: boolean; fuentes?: {familia: string; url: string}[]; estilos?: Record<string, any>};
34
+
35
+ const ADELANTO_GANCHO = 0.4; // el gancho arranca ya animado: el fotograma 0 nunca está vacío
36
+ const CIERRE_BUCLE = 0.5; // al final se funde con el fotograma 0 para que el video se repita sin corte
37
+
38
+ // ---------- fondo: degradado + halos (CSS) y debajo de todo el texto, la capa 3D (Three.js) ----------
39
+ const Fondo: React.FC<{T: number; d: Datos; e: Escena; t: number; salida: number}> = ({T, d, e, t, salida}) => {
40
+ const [arriba, abajo, glow] = fondoDe(d.indice);
41
+ const {iconos, halos} = capa3D(e, t);
42
+ return (
43
+ <AbsoluteFill style={{background: `linear-gradient(${arriba}, ${abajo})`}}>
44
+ <div
45
+ style={{
46
+ position: 'absolute',
47
+ left: -200,
48
+ top: 300,
49
+ width: W + 400,
50
+ height: 1200,
51
+ background: `radial-gradient(ellipse closest-side, ${glow} 0%, transparent 100%)`,
52
+ opacity: 0.45,
53
+ }}
54
+ />
55
+ {halos.map((h, i) => (
56
+ <Resplandor key={i} cx={h.cx} cy={h.cy} rx={h.r} ry={h.r} color={h.color} fuerza={h.fuerza * (1 - salida)} />
57
+ ))}
58
+ <Capa3D T={T} indice={d.indice} glow={glow} niebla={mezclaColor(arriba, abajo, 0.5)} iconos={iconos} salida={salida} />
59
+ </AbsoluteFill>
60
+ );
61
+ };
62
+
63
+ // ---------- subtítulos estilo TikTok ----------
64
+ const Subtitulos: React.FC<{T: number; d: Datos}> = ({T, d}) => {
65
+ const subs = d.subtitulos;
66
+ let cur = -1;
67
+ subs.forEach((c, i) => {
68
+ const sig = i + 1 < subs.length ? subs[i + 1].a : c.b + 0.3;
69
+ if (c.a <= T && T < Math.min(sig, c.b + 0.35)) cur = i;
70
+ });
71
+ if (cur < 0) return null;
72
+ const c = subs[cur];
73
+ const gap = 40;
74
+ let size = 118;
75
+ const total = (z: number) => c.w.reduce((a, [w]) => a + ancho(w, ANTON, z), 0) + gap * (c.w.length - 1);
76
+ while (total(size) > SUB_ANCHO && size > 40) size -= 6;
77
+ const pop = 1 + 0.18 * (1 - easeOut((T - c.a) / 0.12));
78
+ // solo una palabra amarilla a la vez: la que se está diciendo
79
+ const activas = c.w.map(([, a, b], i) => a <= T && T < (i + 1 < c.w.length ? c.w[i + 1][1] : b + 0.05));
80
+ // el "pop" también separa las palabras, para que no se encimen mientras crecen
81
+ const anchos = c.w.map(([w], i) => ancho(w, ANTON, size) * pop * (activas[i] ? 1.12 : 1));
82
+ const sep = gap * pop;
83
+ let x = W / 2 - (anchos.reduce((a, b) => a + b, 0) + sep * (c.w.length - 1)) / 2;
84
+ return (
85
+ <>
86
+ {c.w.map(([w], i) => {
87
+ const cx = x + anchos[i] / 2;
88
+ x += anchos[i] + sep;
89
+ return <Texto key={i} x={cx} y={SUB_Y} s={w} size={size} color={activas[i] ? AMARILLO : BLANCO} stroke={10} escala={pop * (activas[i] ? 1.12 : 1)} />;
90
+ })}
91
+ </>
92
+ );
93
+ };
94
+
95
+ // ---------- marca fija ----------
96
+ const Marca: React.FC = () => {
97
+ const marca = useMarca();
98
+ if (!marca.nombre && !marca.logo) return null;
99
+ const av = 92;
100
+ const aro = 5;
101
+ const total = av + aro * 2;
102
+ return (
103
+ <div style={{position: 'absolute', left: 44, top: 110, height: total}}>
104
+ <div
105
+ style={{
106
+ position: 'absolute',
107
+ left: total / 2,
108
+ top: 12,
109
+ height: total - 16,
110
+ paddingLeft: total / 2 + 20,
111
+ paddingRight: 40,
112
+ borderRadius: 40,
113
+ background: 'rgba(0,0,0,0.59)',
114
+ display: 'flex',
115
+ alignItems: 'center',
116
+ fontFamily: ANTON,
117
+ fontSize: 52,
118
+ color: BLANCO,
119
+ whiteSpace: 'pre',
120
+ }}
121
+ >
122
+ {marca.nombre || ' '}
123
+ </div>
124
+ <Avatar x={0} y={0} size={av} aro={aro} />
125
+ </div>
126
+ );
127
+ };
128
+
129
+ // ---------- destello cálido en el remate ----------
130
+ const Destello: React.FC<{t: number; semilla: number}> = ({t, semilla}) => {
131
+ const k = clamp(t / 1.1);
132
+ if (k >= 1) return null;
133
+ const a = Math.sin(Math.PI * k);
134
+ const x = 20 + 60 * random(`l${semilla}`);
135
+ return (
136
+ <AbsoluteFill
137
+ style={{
138
+ mixBlendMode: 'screen',
139
+ opacity: 0.75 * a,
140
+ background: `radial-gradient(ellipse 70% 45% at ${x}% ${30 + 40 * k}%, rgba(255,190,60,0.9), transparent 70%), radial-gradient(ellipse 55% 35% at ${100 - x}% ${80 - 30 * k}%, rgba(255,90,40,0.7), transparent 70%)`,
141
+ }}
142
+ />
143
+ );
144
+ };
145
+
146
+ // ---------- glitch: franjas desplazadas ----------
147
+ const Glitch: React.FC<{children: React.ReactNode; fuerza: number; semilla: string}> = ({children, fuerza, semilla}) => (
148
+ <>
149
+ {children}
150
+ {fuerza > 0 &&
151
+ Array.from({length: 6}, (_, i) => {
152
+ const y = 300 + random(`${semilla}y${i}`) * 850;
153
+ const h = 10 + random(`${semilla}h${i}`) * 30;
154
+ const dx = (random(`${semilla}x${i}`) * 2 - 1) * 40;
155
+ return (
156
+ <AbsoluteFill key={i} style={{clipPath: `inset(${y}px 0 ${H - y - h}px 0)`, transform: `translateX(${dx}px)`}}>
157
+ {children}
158
+ </AbsoluteFill>
159
+ );
160
+ })}
161
+ </>
162
+ );
163
+
164
+ // ---------- una escena con su entrada y salida ----------
165
+ const CapaEscena: React.FC<{e: Escena; si: number; t: number; d: Datos; ultima: boolean}> = ({e, si, t, d, ultima}) => {
166
+ const marca = useMarca();
167
+ const Clasica = ESCENAS[e.tipo];
168
+ const resplandor = fondoDe(d.indice)[2];
169
+ // tipos del motor de capas (titular, dato, capas...) también funcionan en el estilo clásico
170
+ const Comp: React.FC<{e: Escena; t: number; si: number; resplandor: string}> =
171
+ Clasica ??
172
+ (({e: ee, t: tt}) => (
173
+ <Capas
174
+ capas={capasDe(ee.tipo, ee.p, temaDe('datos-curiosos', d.tema), marca)}
175
+ ctx={{t: tt, dur: ee.dur, habla: ee.habla, palabras: ee.palabras ?? [], tema: temaDe('datos-curiosos', d.tema), semilla: `${d.id}-${si}`}}
176
+ />
177
+ ));
178
+ const dibuja = (tt: number) => <Comp e={e} t={tt} si={si} resplandor={resplandor} />;
179
+ // salida rápida hacia arriba con desenfoque, justo cuando suena el whoosh
180
+ const ks = ultima ? 0 : clamp((t - (e.dur - 0.12)) / 0.12);
181
+ // "motion blur" de entrada: estela de los fotogramas anteriores mientras todo rebota
182
+ const estela = t < 0.25 && si > 0;
183
+ let contenido = (
184
+ <>
185
+ {estela && (
186
+ <>
187
+ <AbsoluteFill style={{opacity: 0.18}}>{dibuja(t - 2 / 60)}</AbsoluteFill>
188
+ <AbsoluteFill style={{opacity: 0.35}}>{dibuja(t - 1 / 60)}</AbsoluteFill>
189
+ </>
190
+ )}
191
+ <AbsoluteFill>{dibuja(t)}</AbsoluteFill>
192
+ </>
193
+ );
194
+ if (e.tipo === 'card' && e.ev.at !== undefined) {
195
+ const k = t - e.ev.at;
196
+ contenido = (
197
+ <Glitch fuerza={k >= 0 && k < 0.25 ? 1 : 0} semilla={`g${si}-${Math.floor(t * FPS)}`}>
198
+ {contenido}
199
+ </Glitch>
200
+ );
201
+ }
202
+ return (
203
+ <AbsoluteFill style={ks > 0 ? {transform: `translateY(${-70 * ks}px) scale(${1 + 0.05 * ks})`, opacity: 1 - 0.7 * ks, filter: `blur(${8 * ks}px)`} : undefined}>
204
+ {contenido}
205
+ </AbsoluteFill>
206
+ );
207
+ };
208
+
209
+ // ---------- guías de zonas seguras (solo en el Studio) ----------
210
+ const Guias: React.FC = () => (
211
+ <AbsoluteFill style={{pointerEvents: 'none'}}>
212
+ {[
213
+ {left: 0, top: 0, width: W, height: SEGURO.arriba},
214
+ {left: 0, top: H - SEGURO.abajo, width: W, height: SEGURO.abajo},
215
+ {left: W - SEGURO.derecha, top: 0, width: SEGURO.derecha, height: H},
216
+ {left: 0, top: 0, width: SEGURO.izquierda, height: H},
217
+ ].map((s, i) => (
218
+ <div key={i} style={{position: 'absolute', ...s, background: 'rgba(255,0,80,0.28)'}} />
219
+ ))}
220
+ </AbsoluteFill>
221
+ );
222
+
223
+ export const Fotograma: React.FC<{d: Datos; T: number; guias?: boolean; miniatura?: boolean}> = ({d, T, guias, miniatura}) => {
224
+ const n = d.escenas.length;
225
+ let si = n - 1;
226
+ for (let i = 0; i < n; i++) if (T < d.escenas[i].t0 + d.escenas[i].dur) {
227
+ si = i;
228
+ break;
229
+ }
230
+ const e = d.escenas[si];
231
+ const t = T - e.t0 + (si === 0 ? ADELANTO_GANCHO : 0);
232
+ // zoom rápido al empezar cada escena
233
+ const z = t < 0.2 ? 1 + 0.08 * (1 - easeOut(t / 0.2)) : 1;
234
+ const remate = si === n - 2 && e.tipo !== 'cta';
235
+ return (
236
+ <AbsoluteFill style={{overflow: 'hidden', background: '#000'}}>
237
+ <AbsoluteFill style={z !== 1 ? {transform: `scale(${z})`} : undefined}>
238
+ <Fondo T={T} d={d} e={e} t={t} salida={si === n - 1 ? 0 : clamp((t - (e.dur - 0.12)) / 0.12)} />
239
+ <CapaEscena e={e} si={si} t={t} d={d} ultima={si === n - 1} />
240
+ {remate && <Destello t={t} semilla={d.indice} />}
241
+ {miniatura ? null : <Subtitulos T={T} d={d} />}
242
+ </AbsoluteFill>
243
+ {e.tipo !== 'cta' && <Marca />}
244
+ {guias && <Guias />}
245
+ </AbsoluteFill>
246
+ );
247
+ };
248
+
249
+ export const Short: React.FC<PropsShort> = ({id, videos, marca, guias, miniatura, fuentes, estilos}) => {
250
+ const frame = useCurrentFrame();
251
+ const [listo, setListo] = useState(false);
252
+ const [handle] = useState(() => delayRender('Cargando fuentes'));
253
+ useEffect(() => {
254
+ Promise.all([fuentesListas, ...(fuentes ?? []).map((f) => loadFont({family: f.familia, url: staticFile(f.url)}))]).then(() => {
255
+ setListo(true);
256
+ continueRender(handle);
257
+ });
258
+ }, [handle]);
259
+ const d = videos?.[id];
260
+ if (!d) throw new Error(`El video "${id}" no está preparado. Corre: npx morfeo render ${id}`);
261
+ if (!listo) return null;
262
+ if (d.estilo && d.estilo !== 'datos-curiosos') return <VideoConAudio d={d} frame={frame} marca={{...MARCA_VACIA, ...marca}} estilos={estilos} miniatura={miniatura} />;
263
+ const T = frame / FPS;
264
+ const kb = clamp((T - (d.duracion - CIERRE_BUCLE)) / CIERRE_BUCLE);
265
+ return (
266
+ <MarcaContexto.Provider value={{...MARCA_VACIA, ...marca}}>
267
+ <AbsoluteFill>
268
+ <Fotograma d={d} T={T} guias={guias} miniatura={miniatura} />
269
+ {kb > 0 && (
270
+ <AbsoluteFill style={{opacity: backOut(kb, 0)}}>
271
+ <Fotograma d={d} T={0} />
272
+ </AbsoluteFill>
273
+ )}
274
+ <Audio src={staticFile(d.audio)} />
275
+ </AbsoluteFill>
276
+ </MarcaContexto.Provider>
277
+ );
278
+ };