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.
- package/LICENSE +22 -0
- package/README.md +210 -2
- package/assets/fuentes/Anton.ttf +0 -0
- package/assets/fuentes/JetBrainsMono.ttf +0 -0
- package/assets/fuentes/Montserrat.ttf +0 -0
- package/bin/morfeo.mjs +184 -0
- package/bin/postinstall.mjs +10 -0
- package/index.d.ts +250 -0
- package/lib/config.mjs +284 -0
- package/lib/entorno.mjs +93 -0
- package/lib/guion.mjs +313 -0
- package/lib/imagenes.mjs +99 -0
- package/lib/index.mjs +15 -0
- package/lib/instalar.mjs +193 -0
- package/lib/navegador.mjs +64 -0
- package/lib/personajes.mjs +94 -0
- package/lib/render.mjs +312 -0
- package/lib/voces.mjs +122 -0
- package/lib/whisper.mjs +45 -0
- package/package.json +84 -5
- package/plantilla/assets/.gitkeep +0 -0
- package/plantilla/env.example +7 -0
- package/plantilla/escenas/Barras.tsx +15 -0
- package/plantilla/gitignore +4 -0
- package/plantilla/guiones/cachalote.mjs +24 -0
- package/plantilla/guiones/tutorial.mjs +16 -0
- package/plantilla/morfeo.config.mjs +43 -0
- package/plantilla/package.json +13 -0
- package/plantilla/videos.mjs +25 -0
- package/python/musica.py +221 -0
- package/python/preparar.py +268 -0
- package/python/requirements.txt +5 -0
- package/remotion/Root.tsx +35 -0
- package/remotion/Short.tsx +278 -0
- package/remotion/escenas.tsx +430 -0
- package/remotion/estilo.ts +73 -0
- package/remotion/iconos.ts +326 -0
- package/remotion/index.ts +4 -0
- package/remotion/motor/Video.tsx +121 -0
- package/remotion/motor/capas.tsx +622 -0
- package/remotion/motor/fondo.tsx +148 -0
- package/remotion/motor/modelo3d.tsx +252 -0
- package/remotion/motor/plantillas.tsx +125 -0
- package/remotion/motor/registro.ts +11 -0
- package/remotion/motor/subtitulos.tsx +137 -0
- package/remotion/motor/tema.ts +207 -0
- package/remotion/motor/util.ts +66 -0
- package/remotion/motor/widgets.tsx +248 -0
- package/remotion/piezas.tsx +181 -0
- package/remotion/tipos.ts +43 -0
- 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,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
|
+
};
|