morfeo 0.3.4 → 0.5.0
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/bases/biografia/Mundo.tsx +35 -0
- package/bases/biografia/guion.mjs +34 -0
- package/bases/construiste/Mundo.tsx +273 -0
- package/bases/construiste/guion.mjs +84 -0
- package/bases/escalada/Mundo.tsx +45 -0
- package/bases/escalada/guion.mjs +24 -0
- package/bases/mito/Mundo.tsx +55 -0
- package/bases/mito/guion.mjs +29 -0
- package/bases/serie/guion.mjs +32 -0
- package/bin/morfeo.mjs +36 -9
- package/index.d.ts +34 -1
- package/lib/calidad.mjs +74 -0
- package/lib/config.mjs +38 -6
- package/lib/estilo-pagina.mjs +174 -0
- package/lib/estilo-propio.mjs +70 -0
- package/lib/estructura.mjs +12 -3
- package/lib/formulas.mjs +179 -0
- package/lib/guion.mjs +91 -5
- package/lib/hoja.mjs +17 -2
- package/lib/nuevo-video.mjs +12 -3
- package/lib/palabras.mjs +2 -2
- package/lib/render.mjs +33 -4
- package/package.json +1 -1
- package/plantilla/AGENTS.md +54 -0
- package/plantilla/CLAUDE.md +5 -0
- package/python/musica.py +87 -1
- package/python/preparar.py +1 -1
- package/remotion/Short.tsx +10 -2
- package/remotion/kit/armar.tsx +245 -0
- package/remotion/kit/index.ts +1 -0
- package/remotion/motor/Video.tsx +50 -16
- package/remotion/motor/capas.tsx +2 -0
- package/remotion/motor/historias.tsx +483 -0
- package/remotion/motor/portada.tsx +44 -0
- package/remotion/motor/tierra-datos.ts +2 -0
- package/remotion/piezas.tsx +3 -3
- package/remotion/tipos.ts +4 -0
package/lib/render.mjs
CHANGED
|
@@ -213,7 +213,9 @@ export async function preparar({proyecto = process.cwd(), ids, log = console.log
|
|
|
213
213
|
* @param {{proyecto?: string, ids?: string[], todos?: boolean, log?: (m: string) => void, alProgreso?: (id: string, p: number) => void}} o
|
|
214
214
|
* @returns {Promise<{id: string, mp4: string, jpg: string | null, segundos: number}[]>}
|
|
215
215
|
*/
|
|
216
|
-
export async function render({proyecto = process.cwd(), ids, todos = false, soloFallidos = false, log = console.log, alProgreso} = {}) {
|
|
216
|
+
export async function render({proyecto = process.cwd(), ids, todos = false, soloFallidos = false, previa = null, log = console.log, alProgreso} = {}) {
|
|
217
|
+
// previa: solo el gancho (las primeras escenas, hasta `previa` segundos o ~8 s) en "<título> (previa).mp4", sin miniatura
|
|
218
|
+
if (previa && !ids?.length) throw new ErrorMorfeo('Dime qué video: npx morfeo previa mi-video');
|
|
217
219
|
cargarEnv(proyecto);
|
|
218
220
|
const p = await cargarProyecto(proyecto);
|
|
219
221
|
const loteArchivo = path.join(p.trabajo, 'ultimo-lote.json');
|
|
@@ -266,7 +268,10 @@ export async function render({proyecto = process.cwd(), ids, todos = false, solo
|
|
|
266
268
|
const t = Date.now();
|
|
267
269
|
try {
|
|
268
270
|
const inputProps = {videos: {[id]: datos[id]}, marca, ...extras};
|
|
269
|
-
const mp4 = salidaDe(p, id);
|
|
271
|
+
const mp4 = previa ? salidaDe(p, id).replace(/\.mp4$/, ' (previa).mp4') : salidaDe(p, id);
|
|
272
|
+
// tramo de la previa: hasta el final de la 2.ª escena (gancho + la que sigue), entre 4 y 8 s, o lo que se pida
|
|
273
|
+
const d0 = datos[id];
|
|
274
|
+
const finPrevia = typeof previa === 'number' ? previa : Math.min(8, Math.max(4, (d0.escenas[1] ?? d0.escenas[0]).t0 + (d0.escenas[1] ?? d0.escenas[0]).dur));
|
|
270
275
|
let gl = p.render.gl;
|
|
271
276
|
let concurrencia = concurrenciaBase;
|
|
272
277
|
let composition;
|
|
@@ -289,6 +294,7 @@ export async function render({proyecto = process.cwd(), ids, todos = false, solo
|
|
|
289
294
|
jpegQuality: 92,
|
|
290
295
|
concurrency: concurrencia,
|
|
291
296
|
outputLocation: mp4,
|
|
297
|
+
...(previa ? {frameRange: [0, Math.min(composition.durationInFrames - 1, Math.round(finPrevia * composition.fps))]} : {}),
|
|
292
298
|
onProgress: ({progress}) => {
|
|
293
299
|
alProgreso?.(id, progress);
|
|
294
300
|
// con terminal: cada 10 % en la misma línea; sin terminal (logs, CI): cada 25 %
|
|
@@ -314,7 +320,7 @@ export async function render({proyecto = process.cwd(), ids, todos = false, solo
|
|
|
314
320
|
}
|
|
315
321
|
}
|
|
316
322
|
let jpg = null;
|
|
317
|
-
if (p.render.miniatura) {
|
|
323
|
+
if (p.render.miniatura && !previa) {
|
|
318
324
|
// miniatura: cuando la primera escena ya está completa (o en `miniaturaEn`), sin subtítulos
|
|
319
325
|
jpg = salidaDe(p, id, 'jpg');
|
|
320
326
|
const d = datos[id];
|
|
@@ -324,6 +330,17 @@ export async function render({proyecto = process.cwd(), ids, todos = false, solo
|
|
|
324
330
|
const props = {...inputProps, miniatura: true};
|
|
325
331
|
await renderStill({serveUrl, composition: {...composition, props: {...composition.props, miniatura: true}}, inputProps: props, browserExecutable, chromiumOptions: {gl}, frame, imageFormat: 'jpeg', jpegQuality: 92, output: jpg});
|
|
326
332
|
}
|
|
333
|
+
// portada: el mismo cuadro con el título grande y la marca (`portada: 'texto'` o el título sin emojis; false = sin portada)
|
|
334
|
+
const vid = p.videos[id];
|
|
335
|
+
if (!previa && p.render.portada !== false && vid.portada !== false && jpg) {
|
|
336
|
+
const textoPortada = typeof vid.portada === 'string' ? vid.portada : String(vid.title ?? id).replace(/[\p{Extended_Pictographic}\uFE0F\u200D]/gu, '').replace(/\s*[|·].*$/, '').trim();
|
|
337
|
+
const d = datos[id];
|
|
338
|
+
const e0 = d.escenas[0];
|
|
339
|
+
const seg = vid.miniaturaEn ?? Math.min(e0.dur - 0.15, Math.max(1.4, e0.habla[1] * 0.85));
|
|
340
|
+
const frame = Math.max(0, Math.min(composition.durationInFrames - 1, Math.round(seg * composition.fps)));
|
|
341
|
+
const props = {...inputProps, portada: textoPortada};
|
|
342
|
+
await renderStill({serveUrl, composition: {...composition, props: {...composition.props, portada: textoPortada}}, inputProps: props, browserExecutable, chromiumOptions: {gl}, frame, imageFormat: 'jpeg', jpegQuality: 92, output: salidaDe(p, id, 'jpg').replace(/\.jpg$/, ' (portada).jpg')});
|
|
343
|
+
}
|
|
327
344
|
const segundos = Math.round((Date.now() - t) / 1000);
|
|
328
345
|
if (process.stdout.isTTY) process.stdout.write('\r');
|
|
329
346
|
log(`[${i + 1}/${listos.length}] ${id}: listo en ${segundos} s → ${path.relative(p.proyecto, mp4)} `);
|
|
@@ -337,6 +354,7 @@ export async function render({proyecto = process.cwd(), ids, todos = false, solo
|
|
|
337
354
|
} finally {
|
|
338
355
|
soltar();
|
|
339
356
|
}
|
|
357
|
+
if (previa) return hechos;
|
|
340
358
|
return terminarLote(p, loteArchivo, hechos, fallidos, log);
|
|
341
359
|
}
|
|
342
360
|
|
|
@@ -438,7 +456,18 @@ function escribirResumen(p) {
|
|
|
438
456
|
const v = p.videos[f.id];
|
|
439
457
|
const tags = (v.tags ?? []).map((x) => `#${String(x).replace(/^#/, '').replace(/\s+/g, '')}`).join(' ');
|
|
440
458
|
const creditos = f.creditos?.length ? ['', 'Créditos:', ...f.creditos.map((c) => `- ${c}`)] : [];
|
|
441
|
-
const
|
|
459
|
+
const fuentes = (v.fuentes ?? []).map((x) => (typeof x === 'string' ? `- ${x}` : `- ${x.texto ?? ''}${x.url ? ` · ${x.url}` : ''}`));
|
|
460
|
+
const ganchos = (v.hooks ?? []).map((h, i) => `${i + 1}. ${h}`);
|
|
461
|
+
const lineas = [
|
|
462
|
+
v.title ?? f.id,
|
|
463
|
+
'',
|
|
464
|
+
...(v.descripcion ? [v.descripcion, ''] : []),
|
|
465
|
+
...(tags ? [tags] : []),
|
|
466
|
+
...(fuentes.length ? ['', 'Fuentes:', ...fuentes] : []),
|
|
467
|
+
...creditos,
|
|
468
|
+
// lo de abajo no va en el post: ganchos para probar otra versión del inicio
|
|
469
|
+
...(ganchos.length ? ['', '———', 'Ganchos de reserva (para otra versión):', ...ganchos] : []),
|
|
470
|
+
];
|
|
442
471
|
fs.writeFileSync(salidaDe(p, f.id, 'txt'), lineas.join('\n').trim() + '\n');
|
|
443
472
|
}
|
|
444
473
|
if (conCreditos.length)
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "morfeo",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.5.0",
|
|
4
4
|
"description": "Genera videos verticales animados (Remotion + Three.js) con voz, música y subtítulos, desde un guion en código o escrito por IA.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "SEE LICENSE IN LICENSE",
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
# Instrucciones para la IA (Claude Code, Codex u otra)
|
|
2
|
+
|
|
3
|
+
Este es un proyecto de **Morfeo**: videos verticales animados (Shorts, TikTok, Reels) desde código. Tú eres quien
|
|
4
|
+
los hace: la persona te pide un tema y tú le devuelves el video terminado. Habla con ella en su idioma, sin jerga
|
|
5
|
+
técnica. Nunca publiques nada por ella: le entregas los archivos y ella decide dónde subirlos.
|
|
6
|
+
|
|
7
|
+
Comandos de referencia: `npx morfeo ayuda`. Tipos de todo lo que existe: `node_modules/morfeo/index.d.ts`.
|
|
8
|
+
|
|
9
|
+
## Cuando diga «Configúrame» (o es la primera vez)
|
|
10
|
+
|
|
11
|
+
1. Revisa que haya Node 20+ (`node -v`). Si falta, dile cómo instalarlo y espera.
|
|
12
|
+
2. `npm install` en esta carpeta.
|
|
13
|
+
3. `npx morfeo instalar --si` (Python, voces gratis, navegador; tarda unos minutos la primera vez). Si algo falla,
|
|
14
|
+
lee el mensaje, arréglalo y vuelve a correrlo. `npx morfeo doctor` dice qué falta.
|
|
15
|
+
4. `npx morfeo estilo`: abre una página para que elija nombre, siglas, colores, letra, voz, ritmo y frase de cierre.
|
|
16
|
+
Espera a que diga que guardó (crea `estilo.json`).
|
|
17
|
+
5. Si quiere su voz de ElevenLabs: que pegue su clave en `.env` como `ELEVENLABS_API_KEY=…` (cópialo de `.env.example`;
|
|
18
|
+
nunca escribas tú la clave ni la pongas en el chat). Para investigar con Claude desde la terminal, `ANTHROPIC_API_KEY`.
|
|
19
|
+
6. Haz un video de prueba (`npx morfeo render ejemplo-2d`) y dile dónde quedó.
|
|
20
|
+
|
|
21
|
+
## Cuando diga «Hazme un video sobre…»
|
|
22
|
+
|
|
23
|
+
1. **Investiga.** Busca los datos en la web y quédate solo con lo que puedas respaldar con una fuente (medio,
|
|
24
|
+
institución, URL). Si no puedes buscar, usa `npx morfeo guion "tema" --investigar` (necesita `ANTHROPIC_API_KEY`).
|
|
25
|
+
Si dudas de una cifra, dila en general o quítala.
|
|
26
|
+
2. **Elige la fórmula** que mejor le queda al tema: `npx morfeo formulas` (construiste, mito, misterio, serie,
|
|
27
|
+
biografía, experimento, demuéstralo, roast…). Si hay una base para esa fórmula, empieza de ahí:
|
|
28
|
+
`npx morfeo nuevo-video <id> --formula <fórmula>` (bases: serie, mito, escalada, biografia, construiste; o
|
|
29
|
+
`--look 2d` / `--look 3d`). Si no, `npx morfeo guion "tema" --formula <fórmula>` o escribe el guion a mano en
|
|
30
|
+
`guiones/<id>.mjs` (mira los ejemplos de `guiones/`).
|
|
31
|
+
3. **Escribe el guion** siguiendo la fórmula y estas reglas:
|
|
32
|
+
- Gancho en los primeros 2 s, en segunda persona o con una cifra imposible. Nada de "hoy te voy a contar".
|
|
33
|
+
- Una idea por escena (2–5 s de voz). Pregunta antes de revelar. Sube la apuesta hacia el final.
|
|
34
|
+
- `say` es lo que dice la voz (cifras con letra); `show` el subtítulo con cifras.
|
|
35
|
+
- Rótulos cortos en 2–4 escenas (`rotulo`, con `rotuloEstilo` sello/paso/pixel si va), `claves` en cada escena.
|
|
36
|
+
- Cierre que pide algo: `{type: 'cta', pide: 'comenta', palabra: 'X'}` o `pide: 'comparte', a: 'tu amigo que…'`.
|
|
37
|
+
- `title`, `descripcion`, `tags`, `hooks` (3 ganchos de reserva) y `fuentes` (cada dato con su fuente).
|
|
38
|
+
4. **Enséñale el guion** (texto de cada escena, en limpio) y espera su visto bueno o sus cambios.
|
|
39
|
+
5. **Enséñale el gancho:** `npx morfeo previa <id>` renderiza solo los primeros segundos. Si no le convence, cámbialo
|
|
40
|
+
(tiene 3 ganchos de reserva en `hooks`).
|
|
41
|
+
6. **Renderiza:** `npx morfeo render <id>`.
|
|
42
|
+
7. **Revisa antes de entregar:**
|
|
43
|
+
- `npx morfeo revisar <id>`: estructura, que la voz haya dicho el guion y que nada quede bajo los botones de la app.
|
|
44
|
+
- `npx morfeo hoja <id> --zona`: mira la hoja de contacto (es una imagen); lo pintado en rojo es lo que tapan las apps.
|
|
45
|
+
- Si algo se ve mal (texto encimado, objeto cortado, cuadro vacío), arréglalo y vuelve a renderizar.
|
|
46
|
+
8. **Entrega:** en `salida/` quedan `<título>.mp4`, `<título>.jpg` (miniatura), `<título> (portada).jpg` y
|
|
47
|
+
`<título>.txt` (texto del post, etiquetas, fuentes y ganchos de reserva). Dile eso y nada más.
|
|
48
|
+
|
|
49
|
+
## Reglas
|
|
50
|
+
|
|
51
|
+
- No inventes datos. No copies videos de otros: se toma la forma, no el contenido.
|
|
52
|
+
- Emojis solo como último recurso; mejor objetos 2D/3D (`{tipo: 'recurso', buscar: 'volcán', estilo: '3d'}`).
|
|
53
|
+
- Un render a la vez. Si un render falla, lee el error (Morfeo explica qué falta) y corrígelo.
|
|
54
|
+
- No toques `node_modules/`. Lo de la persona vive en `guiones/`, `escenas/`, `assets/`, `estilo.json` y `morfeo.config.mjs`.
|
package/python/musica.py
CHANGED
|
@@ -179,11 +179,97 @@ def efecto(nombre, rng):
|
|
|
179
179
|
x = 0.22 * pop(180, 90, 0.16)
|
|
180
180
|
add(x, 0.1 * lp(rng.standard_normal(int(0.06 * SR)), 1200), 0)
|
|
181
181
|
return x
|
|
182
|
+
if nombre in EFECTOS_EXTRA: return EFECTOS_EXTRA[nombre](rng)
|
|
182
183
|
return 0.14 * pop()
|
|
183
184
|
|
|
184
185
|
|
|
186
|
+
def _env(n, ataque=0.004, cola=6.0):
|
|
187
|
+
t = np.arange(n) / SR
|
|
188
|
+
return np.minimum(1, t / ataque) * np.exp(-t * cola)
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
def _tono(f, d, cola=8.0, armonicos=(1,)):
|
|
192
|
+
n = int(d * SR); t = np.arange(n) / SR
|
|
193
|
+
x = sum(np.sin(2 * np.pi * f * h * t) / (i + 1) for i, h in enumerate(armonicos))
|
|
194
|
+
return x * _env(n, 0.003, cola)
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
def _teclas(rng):
|
|
198
|
+
"""Ráfaga de teclas de máquina: 7 clics secos con espaciado irregular."""
|
|
199
|
+
x = np.zeros(int(0.9 * SR)); t = 0.0
|
|
200
|
+
for _ in range(7):
|
|
201
|
+
c = hp(rng.standard_normal(int(0.012 * SR)), 2500) * _env(int(0.012 * SR), 0.0005, 300)
|
|
202
|
+
add(x, 0.14 * c, t); t += 0.07 + rng.random() * 0.07
|
|
203
|
+
return x
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
def _radio(rng):
|
|
207
|
+
"""Estática de radio con un pitido de sintonía."""
|
|
208
|
+
n = int(0.7 * SR)
|
|
209
|
+
ruido = hp(lp(rng.standard_normal(n), 3500), 400) * 0.25 * np.minimum(1, np.arange(n) / (0.03 * SR)) * np.linspace(1, 0.2, n)
|
|
210
|
+
add(ruido, 0.08 * _tono(1200, 0.25, 4), 0.2)
|
|
211
|
+
return ruido
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
def _obturador(rng):
|
|
215
|
+
"""Obturador de cámara: dos clics mecánicos."""
|
|
216
|
+
x = np.zeros(int(0.25 * SR))
|
|
217
|
+
for t, f in ((0.0, 1800), (0.07, 1300)):
|
|
218
|
+
c = lp(hp(rng.standard_normal(int(0.02 * SR)), 900), f * 3) * _env(int(0.02 * SR), 0.0004, 220)
|
|
219
|
+
add(x, 0.5 * c, t)
|
|
220
|
+
return x
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
def _notificacion(rng):
|
|
224
|
+
"""Notificación del teléfono: dos notas que suben."""
|
|
225
|
+
x = np.zeros(int(0.5 * SR))
|
|
226
|
+
add(x, 0.18 * _tono(nf(84), 0.25, 14, (1, 2)), 0)
|
|
227
|
+
add(x, 0.18 * _tono(nf(91), 0.35, 10, (1, 2)), 0.11)
|
|
228
|
+
return x
|
|
229
|
+
|
|
230
|
+
|
|
231
|
+
def _latido(rng):
|
|
232
|
+
"""Latido: dos golpes graves (lub-dub)."""
|
|
233
|
+
x = np.zeros(int(0.7 * SR))
|
|
234
|
+
add(x, 0.5 * _tono(55, 0.18, 22), 0)
|
|
235
|
+
add(x, 0.35 * _tono(48, 0.16, 24), 0.2)
|
|
236
|
+
return lp(x, 300)
|
|
237
|
+
|
|
238
|
+
|
|
239
|
+
def _alarma(rng):
|
|
240
|
+
"""Alarma: tres pitidos cuadrados."""
|
|
241
|
+
x = np.zeros(int(0.9 * SR))
|
|
242
|
+
for i in range(3):
|
|
243
|
+
n = int(0.16 * SR); t = np.arange(n) / SR
|
|
244
|
+
b = np.sign(np.sin(2 * np.pi * 1600 * t)) * 0.09 * np.minimum(1, (n - np.arange(n)) / (0.01 * SR))
|
|
245
|
+
add(x, lp(b, 5000), i * 0.27)
|
|
246
|
+
return x
|
|
247
|
+
|
|
248
|
+
|
|
249
|
+
def _reloj(rng):
|
|
250
|
+
"""Tic-tac de reloj: tic agudo y tac grave."""
|
|
251
|
+
x = np.zeros(int(1.0 * SR))
|
|
252
|
+
for i in range(2):
|
|
253
|
+
add(x, 0.45 * tick(0.02), i * 0.5)
|
|
254
|
+
add(x, 0.3 * lp(tick(0.025), 1500), i * 0.5 + 0.25)
|
|
255
|
+
return x
|
|
256
|
+
|
|
257
|
+
|
|
258
|
+
def _caja(rng):
|
|
259
|
+
"""Caja registradora: campanita y cajón que se abre."""
|
|
260
|
+
x = np.zeros(int(0.9 * SR))
|
|
261
|
+
add(x, 0.25 * bell(nf(96), 0.7), 0.0)
|
|
262
|
+
cajon = lp(rng.standard_normal(int(0.25 * SR)), 900) * _env(int(0.25 * SR), 0.01, 10) * 0.25
|
|
263
|
+
add(x, cajon, 0.12)
|
|
264
|
+
return x
|
|
265
|
+
|
|
266
|
+
|
|
267
|
+
EFECTOS_EXTRA = {"teclas": _teclas, "radio": _radio, "obturador": _obturador, "notificacion": _notificacion, "latido": _latido, "alarma": _alarma, "reloj": _reloj, "caja": _caja}
|
|
268
|
+
|
|
269
|
+
|
|
185
270
|
# sonido por defecto según cómo entra o qué es la capa (sonido: false lo apaga; sonido: "x" lo cambia)
|
|
186
|
-
SONIDO_TIPO = {"sello": "golpe", "dato": "tick", "contador": "tick", "nombre": "pop", "accion": "pop", "chip": "pop", "insignia": "pop"
|
|
271
|
+
SONIDO_TIPO = {"sello": "golpe", "dato": "tick", "contador": "tick", "nombre": "pop", "accion": "pop", "chip": "pop", "insignia": "pop",
|
|
272
|
+
"bloqueo": "notificacion", "llamada": "notificacion", "vigilancia": "radio", "terminal": "teclas", "calendario": "reloj", "fecha": "golpe", "noticia": "radio", "recuento": "tick", "cronometro": "reloj"}
|
|
187
273
|
SONIDO_ENTRADA = {"rebote": "pop", "caer": "golpe", "deslizar-izq": "whoosh", "deslizar-der": "whoosh", "deslizar-arriba": "whoosh", "deslizar-abajo": "whoosh", "girar": "whoosh"}
|
|
188
274
|
|
|
189
275
|
|
package/python/preparar.py
CHANGED
|
@@ -264,7 +264,7 @@ def preparar(job):
|
|
|
264
264
|
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])
|
|
265
265
|
for ws in WORDS for c in trozos(ws)]
|
|
266
266
|
datos = dict(id=vid, titulo=v.get("title", vid), indice=job.get("indice", 0), duracion=round(sum(durs), 3),
|
|
267
|
-
estilo=v.get("estilo", "datos-curiosos"), tema=v.get("tema") or {}, hud=v.get("hud") or [], mundo=v.get("mundo") or [], serie=v.get("serie"),
|
|
267
|
+
estilo=v.get("estilo", "datos-curiosos"), tema=v.get("tema") or {}, hud=v.get("hud") or [], mundo=v.get("mundo") or [], lugares=v.get("lugares") or {}, serie=v.get("serie"),
|
|
268
268
|
audio=f"v/{vid}/mix.wav", whisper=reales, escenas=escenas, subtitulos=subs)
|
|
269
269
|
json.dump(datos, open(os.path.join(outdir, "datos.json"), "w", encoding="utf-8"), ensure_ascii=False, indent=1)
|
|
270
270
|
print(f" tiempos por palabra: {'Whisper' if reales else 'reparto por frase'} ({reales}/{len(TIM)} escenas)", flush=True)
|
package/remotion/Short.tsx
CHANGED
|
@@ -27,10 +27,11 @@ import {MARCA_VACIA, type Datos, type Escena, type Marca as TipoMarca} from './t
|
|
|
27
27
|
import {Capas} from './motor/capas';
|
|
28
28
|
import {capasDe} from './motor/plantillas';
|
|
29
29
|
import {temaDe} from './motor/tema';
|
|
30
|
+
import {Portada} from './motor/portada';
|
|
30
31
|
import {VideoConAudio} from './motor/Video';
|
|
31
32
|
import {loadFont} from '@remotion/fonts';
|
|
32
33
|
|
|
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
|
+
export type PropsShort = {id: string; videos?: Record<string, Datos>; marca?: TipoMarca; guias?: boolean; miniatura?: boolean; portada?: string; fuentes?: {familia: string; url: string}[]; estilos?: Record<string, any>};
|
|
34
35
|
|
|
35
36
|
const ADELANTO_GANCHO = 0.4; // el gancho arranca ya animado: el fotograma 0 nunca está vacío
|
|
36
37
|
const CIERRE_BUCLE = 0.5; // al final se funde con el fotograma 0 para que el video se repita sin corte
|
|
@@ -246,7 +247,7 @@ export const Fotograma: React.FC<{d: Datos; T: number; guias?: boolean; miniatur
|
|
|
246
247
|
);
|
|
247
248
|
};
|
|
248
249
|
|
|
249
|
-
export const Short: React.FC<PropsShort> = ({id, videos, marca, guias, miniatura, fuentes, estilos}) => {
|
|
250
|
+
export const Short: React.FC<PropsShort> = ({id, videos, marca, guias, miniatura, portada, fuentes, estilos}) => {
|
|
250
251
|
const frame = useCurrentFrame();
|
|
251
252
|
const [listo, setListo] = useState(false);
|
|
252
253
|
const [handle] = useState(() => delayRender('Cargando fuentes'));
|
|
@@ -259,6 +260,13 @@ export const Short: React.FC<PropsShort> = ({id, videos, marca, guias, miniatura
|
|
|
259
260
|
const d = videos?.[id];
|
|
260
261
|
if (!d) throw new Error(`El video "${id}" no está preparado. Corre: npx morfeo render ${id}`);
|
|
261
262
|
if (!listo) return null;
|
|
263
|
+
if (portada)
|
|
264
|
+
return (
|
|
265
|
+
<AbsoluteFill>
|
|
266
|
+
{d.estilo && d.estilo !== 'datos-curiosos' ? <VideoConAudio d={d} frame={frame} marca={{...MARCA_VACIA, ...marca}} estilos={estilos} miniatura sinMarca /> : <Fotograma d={d} T={frame / FPS} miniatura />}
|
|
267
|
+
<Portada texto={portada} marca={{...MARCA_VACIA, ...marca}} />
|
|
268
|
+
</AbsoluteFill>
|
|
269
|
+
);
|
|
262
270
|
if (d.estilo && d.estilo !== 'datos-curiosos') return <VideoConAudio d={d} frame={frame} marca={{...MARCA_VACIA, ...marca}} estilos={estilos} miniatura={miniatura} />;
|
|
263
271
|
const T = frame / FPS;
|
|
264
272
|
const kb = clamp((T - (d.duracion - CIERRE_BUCLE)) / CIERRE_BUCLE);
|
|
@@ -0,0 +1,245 @@
|
|
|
1
|
+
// Piezas 3D para los formatos "construiste un X" y "escalada absurda" (un objeto que suma partes escena por escena),
|
|
2
|
+
// para multitudes ("30 000 hombres") y para la mano en primera persona que aprieta un control o sostiene algo.
|
|
3
|
+
// Todo depende de `t` (segundos) y de props; nada de useFrame.
|
|
4
|
+
//
|
|
5
|
+
// <Aparece t={t} en={cuando('ruedas')} desde="arriba"><Ruedas /></Aparece> (cae, rebota y suena por su cuenta)
|
|
6
|
+
// <Multitud3D t={t} n={400} ancho={16} fondo={10} colores={['#2F6FE0', '#F2B632']} destacar={[0, 7]} />
|
|
7
|
+
// <Mano3D t={t} pos={[0.5, -1.2, 3]} sostiene="control" aprieta={cuando('botón')} />
|
|
8
|
+
import React, {useMemo} from 'react';
|
|
9
|
+
import * as THREE from 'three';
|
|
10
|
+
import {azarN} from './ilustrado';
|
|
11
|
+
import {grupo, type Pieza, type V3} from './tres';
|
|
12
|
+
|
|
13
|
+
const clamp01 = (x: number) => Math.max(0, Math.min(1, x));
|
|
14
|
+
const suave = (x: number) => {
|
|
15
|
+
const c = clamp01(x);
|
|
16
|
+
return c * c * (3 - 2 * c);
|
|
17
|
+
};
|
|
18
|
+
/** Rebote al caer: llega en 0.55 del tramo, rebota dos veces y se queda. */
|
|
19
|
+
const caida = (k: number) => {
|
|
20
|
+
if (k >= 1) return 1;
|
|
21
|
+
if (k < 0.55) return (k / 0.55) ** 2;
|
|
22
|
+
const r = (k - 0.55) / 0.45;
|
|
23
|
+
return 1 - Math.abs(Math.sin(r * Math.PI * 2)) * (1 - r) * 0.12;
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
const Mate: React.FC<{c: string; r?: number}> = ({c, r = 0.75}) => <meshStandardMaterial color={c} roughness={r} flatShading />;
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Una parte que llega en su momento: cae desde arriba con rebote (`desde: 'arriba'`), crece desde cero (`'crece'`),
|
|
30
|
+
* entra de lado (`'izq'`/`'der'`) o sube del suelo (`'abajo'`). Antes de `en` no se ve. `dur` en segundos, `altura` de caída.
|
|
31
|
+
*/
|
|
32
|
+
export const Aparece: React.FC<Pieza & {t: number; en: number; desde?: 'arriba' | 'crece' | 'izq' | 'der' | 'abajo'; dur?: number; altura?: number; hasta?: number; children: React.ReactNode}> = ({t, en, desde = 'arriba', dur = 0.55, altura = 4, hasta, children, ...p}) => {
|
|
33
|
+
if (t < en) return null;
|
|
34
|
+
const k = clamp01((t - en) / dur);
|
|
35
|
+
const fuera = hasta != null ? suave((t - hasta) / 0.35) : 0;
|
|
36
|
+
if (fuera >= 1) return null;
|
|
37
|
+
const g = grupo(p);
|
|
38
|
+
let [x, y, z] = g.position;
|
|
39
|
+
let esc = g.scale * (1 - fuera);
|
|
40
|
+
if (desde === 'arriba') y += (1 - caida(k)) * altura;
|
|
41
|
+
else if (desde === 'abajo') y -= (1 - suave(k)) * altura;
|
|
42
|
+
else if (desde === 'izq') x -= (1 - suave(k)) * altura;
|
|
43
|
+
else if (desde === 'der') x += (1 - suave(k)) * altura;
|
|
44
|
+
else esc *= k < 1 ? Math.sin(suave(k) * Math.PI * 0.62) / Math.sin(Math.PI * 0.62) : 1;
|
|
45
|
+
// pequeño aplastamiento al tocar el suelo
|
|
46
|
+
const apl = desde === 'arriba' && k > 0.5 && k < 0.8 ? Math.sin(((k - 0.5) / 0.3) * Math.PI) * 0.12 : 0;
|
|
47
|
+
return (
|
|
48
|
+
<group position={[x, y, z]} rotation={g.rotation} scale={[esc * (1 + apl * 0.5), esc * (1 - apl), esc * (1 + apl * 0.5)]}>
|
|
49
|
+
{children}
|
|
50
|
+
</group>
|
|
51
|
+
);
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Multitud de figuras simples (cuerpo + cabeza) en una rejilla con algo de desorden, en un solo `instancedMesh` (miles
|
|
56
|
+
* sin costo). `n`, `ancho`×`fondo` (unidades), `colores` del cuerpo, `destacar: [índices]` en `colorDestacado`, `brinca`
|
|
57
|
+
* para que salten. Aparece de adelante hacia atrás desde `en`.
|
|
58
|
+
*/
|
|
59
|
+
export const Multitud3D: React.FC<Pieza & {t: number; n?: number; ancho?: number; fondo?: number; colores?: string[]; colorDestacado?: string; destacar?: number[]; brinca?: boolean; en?: number; altura?: number}> = ({t, n = 300, ancho = 14, fondo = 10, colores = ['#2F6FE0', '#3D7DF0', '#2A5FC8'], colorDestacado = '#F2B632', destacar = [], brinca = false, en = 0, altura = 1.1, ...p}) => {
|
|
60
|
+
const cols = Math.max(1, Math.round(Math.sqrt((n * ancho) / fondo)));
|
|
61
|
+
const filas = Math.ceil(n / cols);
|
|
62
|
+
const datos = useMemo(() => {
|
|
63
|
+
let semilla = n * 3 + 7;
|
|
64
|
+
const r = () => azarN(semilla++);
|
|
65
|
+
return Array.from({length: n}, (_, i) => {
|
|
66
|
+
const c = i % cols;
|
|
67
|
+
const f = Math.floor(i / cols);
|
|
68
|
+
const x = (c / Math.max(1, cols - 1) - 0.5) * ancho + (r() - 0.5) * (ancho / cols) * 0.5;
|
|
69
|
+
const z = (f / Math.max(1, filas - 1) - 0.5) * fondo + (r() - 0.5) * (fondo / filas) * 0.5;
|
|
70
|
+
return {x, z, fase: r() * 6.28, escala: 0.9 + r() * 0.2, color: destacar.includes(i) ? colorDestacado : colores[Math.floor(r() * colores.length)], fila: f};
|
|
71
|
+
});
|
|
72
|
+
}, [n, cols, filas, ancho, fondo, colores.join(), colorDestacado, destacar.join()]);
|
|
73
|
+
const cuerpo = useMemo(() => new THREE.CapsuleGeometry(0.22 * altura, 0.5 * altura, 3, 6), [altura]);
|
|
74
|
+
const cabeza = useMemo(() => new THREE.IcosahedronGeometry(0.2 * altura, 0), [altura]);
|
|
75
|
+
const m = useMemo(() => new THREE.Matrix4(), []);
|
|
76
|
+
const color = useMemo(() => new THREE.Color(), []);
|
|
77
|
+
const refC = React.useRef<THREE.InstancedMesh>(null);
|
|
78
|
+
const refH = React.useRef<THREE.InstancedMesh>(null);
|
|
79
|
+
// las matrices se escriben en cada render (depende de t), no en un efecto
|
|
80
|
+
const aplicar = () => {
|
|
81
|
+
if (!refC.current || !refH.current) return;
|
|
82
|
+
datos.forEach((d, i) => {
|
|
83
|
+
const k = clamp01((t - en - d.fila * 0.04) / 0.35);
|
|
84
|
+
const s = d.escala * (k < 1 ? caida(k) : 1);
|
|
85
|
+
const salto = brinca ? Math.max(0, Math.sin(t * 6 + d.fase)) * 0.25 : 0;
|
|
86
|
+
m.compose(new THREE.Vector3(d.x, (0.47 * altura + salto) * s, d.z), new THREE.Quaternion(), new THREE.Vector3(s, s, s));
|
|
87
|
+
refC.current!.setMatrixAt(i, m);
|
|
88
|
+
refC.current!.setColorAt(i, color.set(d.color));
|
|
89
|
+
m.compose(new THREE.Vector3(d.x, (1.05 * altura + salto) * s, d.z), new THREE.Quaternion(), new THREE.Vector3(s, s, s));
|
|
90
|
+
refH.current!.setMatrixAt(i, m);
|
|
91
|
+
refH.current!.setColorAt(i, color.set('#F2C9A4'));
|
|
92
|
+
});
|
|
93
|
+
refC.current.instanceMatrix.needsUpdate = true;
|
|
94
|
+
refH.current.instanceMatrix.needsUpdate = true;
|
|
95
|
+
if (refC.current.instanceColor) refC.current.instanceColor.needsUpdate = true;
|
|
96
|
+
if (refH.current.instanceColor) refH.current.instanceColor.needsUpdate = true;
|
|
97
|
+
};
|
|
98
|
+
React.useLayoutEffect(aplicar);
|
|
99
|
+
return (
|
|
100
|
+
<group {...grupo(p)}>
|
|
101
|
+
<instancedMesh ref={refC} args={[cuerpo, undefined, n]} castShadow>
|
|
102
|
+
<meshStandardMaterial roughness={0.7} flatShading />
|
|
103
|
+
</instancedMesh>
|
|
104
|
+
<instancedMesh ref={refH} args={[cabeza, undefined, n]} castShadow>
|
|
105
|
+
<meshStandardMaterial roughness={0.7} flatShading />
|
|
106
|
+
</instancedMesh>
|
|
107
|
+
</group>
|
|
108
|
+
);
|
|
109
|
+
};
|
|
110
|
+
|
|
111
|
+
/** Control remoto o de portón: caja oscura con botones. */
|
|
112
|
+
const Control: React.FC<{apretado: number}> = ({apretado}) => (
|
|
113
|
+
<group>
|
|
114
|
+
<mesh castShadow>
|
|
115
|
+
<boxGeometry args={[0.55, 0.95, 0.16]} />
|
|
116
|
+
<Mate c="#26282E" />
|
|
117
|
+
</mesh>
|
|
118
|
+
<mesh position={[0, 0.2, 0.09 - apretado * 0.03]}>
|
|
119
|
+
<cylinderGeometry args={[0.13, 0.13, 0.06, 16]} />
|
|
120
|
+
<meshStandardMaterial color="#E5484D" roughness={0.4} emissive="#E5484D" emissiveIntensity={apretado * 0.8} />
|
|
121
|
+
</mesh>
|
|
122
|
+
{[-0.13, 0.13].map((x) => (
|
|
123
|
+
<mesh key={x} position={[x, -0.15, 0.09]} rotation={[Math.PI / 2, 0, 0]}>
|
|
124
|
+
<cylinderGeometry args={[0.07, 0.07, 0.04, 12]} />
|
|
125
|
+
<Mate c="#D7D9DE" />
|
|
126
|
+
</mesh>
|
|
127
|
+
))}
|
|
128
|
+
</group>
|
|
129
|
+
);
|
|
130
|
+
|
|
131
|
+
/** Teléfono: losa negra con pantalla encendida. */
|
|
132
|
+
const Movil: React.FC<{pantalla?: string}> = ({pantalla = '#3A6FF2'}) => (
|
|
133
|
+
<group>
|
|
134
|
+
<mesh castShadow>
|
|
135
|
+
<boxGeometry args={[0.55, 1.1, 0.08]} />
|
|
136
|
+
<Mate c="#111214" r={0.4} />
|
|
137
|
+
</mesh>
|
|
138
|
+
<mesh position={[0, 0, 0.045]}>
|
|
139
|
+
<planeGeometry args={[0.48, 1.0]} />
|
|
140
|
+
<meshStandardMaterial color={pantalla} emissive={pantalla} emissiveIntensity={0.7} />
|
|
141
|
+
</mesh>
|
|
142
|
+
</group>
|
|
143
|
+
);
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* Mano en primera persona (entra desde abajo del cuadro): palma, cuatro dedos que rodean lo que sostiene y pulgar que
|
|
147
|
+
* aprieta en `aprieta` (segundos). `sostiene: 'control' | 'movil' | 'nada'`, `piel`, `manga` (color de la manga).
|
|
148
|
+
* Ponla cerca de la cámara (2–4 u) y abajo del centro.
|
|
149
|
+
*/
|
|
150
|
+
export const Mano3D: React.FC<Pieza & {t: number; sostiene?: 'control' | 'movil' | 'nada'; aprieta?: number; piel?: string; manga?: string; en?: number; pantalla?: string}> = ({t, sostiene = 'control', aprieta, piel = '#E8B48F', manga = '#2F6FE0', en = 0, pantalla, ...p}) => {
|
|
151
|
+
const entra = suave((t - en) / 0.45);
|
|
152
|
+
const dt = aprieta != null ? t - aprieta : -1;
|
|
153
|
+
const apretado = dt >= 0 && dt < 0.5 ? Math.sin((dt / 0.5) * Math.PI) : 0;
|
|
154
|
+
const g = grupo(p);
|
|
155
|
+
const [x, y, z] = g.position;
|
|
156
|
+
const tiembla = Math.sin(t * 2.1) * 0.01;
|
|
157
|
+
return (
|
|
158
|
+
<group position={[x, y - (1 - entra) * 1.6 + tiembla, z]} rotation={g.rotation} scale={g.scale}>
|
|
159
|
+
{/* manga y muñeca */}
|
|
160
|
+
<mesh position={[0, -0.95, -0.05]} rotation={[0.25, 0, 0]} castShadow>
|
|
161
|
+
<cylinderGeometry args={[0.24, 0.3, 0.9, 8]} />
|
|
162
|
+
<Mate c={manga} />
|
|
163
|
+
</mesh>
|
|
164
|
+
<mesh position={[0, -0.45, 0]} castShadow>
|
|
165
|
+
<cylinderGeometry args={[0.17, 0.2, 0.3, 8]} />
|
|
166
|
+
<Mate c={piel} />
|
|
167
|
+
</mesh>
|
|
168
|
+
{/* palma detrás del objeto */}
|
|
169
|
+
<mesh position={[0, -0.05, -0.14]} castShadow>
|
|
170
|
+
<boxGeometry args={[0.6, 0.62, 0.2]} />
|
|
171
|
+
<Mate c={piel} />
|
|
172
|
+
</mesh>
|
|
173
|
+
{/* lo que sostiene */}
|
|
174
|
+
<group position={[0, 0.15, 0]}>{sostiene === 'control' ? <Control apretado={apretado} /> : sostiene === 'movil' ? <Movil pantalla={pantalla} /> : null}</group>
|
|
175
|
+
{/* dedos que rodean por la derecha */}
|
|
176
|
+
{[0.22, 0.02, -0.18, -0.36].map((yy, i) => (
|
|
177
|
+
<mesh key={i} position={[0.33, yy + 0.1, 0.02]} rotation={[0, 0, 0.1]} castShadow>
|
|
178
|
+
<boxGeometry args={[0.14, 0.15, 0.24]} />
|
|
179
|
+
<Mate c={piel} />
|
|
180
|
+
</mesh>
|
|
181
|
+
))}
|
|
182
|
+
{/* pulgar: baja sobre el botón al apretar */}
|
|
183
|
+
<group position={[-0.32, -0.05, 0.05]} rotation={[0, 0, -0.9 + apretado * 0.35]}>
|
|
184
|
+
<mesh position={[0, 0.25, 0.1 - apretado * 0.04]} castShadow>
|
|
185
|
+
<boxGeometry args={[0.16, 0.42, 0.16]} />
|
|
186
|
+
<Mate c={piel} />
|
|
187
|
+
</mesh>
|
|
188
|
+
</group>
|
|
189
|
+
</group>
|
|
190
|
+
);
|
|
191
|
+
};
|
|
192
|
+
|
|
193
|
+
/** Partes listas para armar objetos que crecen: rueda, motor y asiento (low-poly, se combinan con Aparece). */
|
|
194
|
+
export const Rueda: React.FC<Pieza & {radio?: number; t?: number; gira?: number; color?: string}> = ({radio = 0.35, t = 0, gira = 0, color = '#1E1F24', ...p}) => (
|
|
195
|
+
<group {...grupo(p)}>
|
|
196
|
+
<group rotation={[0, 0, -t * gira]}>
|
|
197
|
+
<mesh rotation={[Math.PI / 2, 0, 0]} castShadow>
|
|
198
|
+
<cylinderGeometry args={[radio, radio, radio * 0.6, 14]} />
|
|
199
|
+
<Mate c={color} r={0.9} />
|
|
200
|
+
</mesh>
|
|
201
|
+
<mesh rotation={[Math.PI / 2, 0, 0]} position={[0, 0, 0.01]}>
|
|
202
|
+
<cylinderGeometry args={[radio * 0.5, radio * 0.5, radio * 0.62, 8]} />
|
|
203
|
+
<Mate c="#B9BEC8" r={0.5} />
|
|
204
|
+
</mesh>
|
|
205
|
+
</group>
|
|
206
|
+
</group>
|
|
207
|
+
);
|
|
208
|
+
|
|
209
|
+
export const Motor: React.FC<Pieza & {t?: number; color?: string}> = ({t = 0, color = '#F2B632', ...p}) => {
|
|
210
|
+
const vibra = Math.sin(t * 60) * 0.01;
|
|
211
|
+
return (
|
|
212
|
+
<group {...grupo(p)} position={[(p.pos?.[0] ?? 0) + vibra, p.pos?.[1] ?? 0, p.pos?.[2] ?? 0]}>
|
|
213
|
+
<mesh castShadow>
|
|
214
|
+
<boxGeometry args={[0.7, 0.5, 0.5]} />
|
|
215
|
+
<Mate c={color} />
|
|
216
|
+
</mesh>
|
|
217
|
+
<mesh position={[0, 0.32, 0]} castShadow>
|
|
218
|
+
<boxGeometry args={[0.4, 0.14, 0.34]} />
|
|
219
|
+
<Mate c="#2A2C33" />
|
|
220
|
+
</mesh>
|
|
221
|
+
<mesh position={[0.42, 0.1, 0]} rotation={[0, 0, Math.PI / 2]} castShadow>
|
|
222
|
+
<cylinderGeometry args={[0.06, 0.06, 0.2, 8]} />
|
|
223
|
+
<Mate c="#6B7080" />
|
|
224
|
+
</mesh>
|
|
225
|
+
</group>
|
|
226
|
+
);
|
|
227
|
+
};
|
|
228
|
+
|
|
229
|
+
export const Asiento: React.FC<Pieza & {color?: string}> = ({color = '#E5484D', ...p}) => (
|
|
230
|
+
<group {...grupo(p)}>
|
|
231
|
+
<mesh position={[0, 0, 0]} castShadow>
|
|
232
|
+
<boxGeometry args={[0.6, 0.14, 0.55]} />
|
|
233
|
+
<Mate c={color} />
|
|
234
|
+
</mesh>
|
|
235
|
+
<mesh position={[0, 0.32, -0.24]} castShadow>
|
|
236
|
+
<boxGeometry args={[0.6, 0.6, 0.12]} />
|
|
237
|
+
<Mate c={color} />
|
|
238
|
+
</mesh>
|
|
239
|
+
<mesh position={[0, -0.25, 0]} castShadow>
|
|
240
|
+
<cylinderGeometry args={[0.05, 0.05, 0.4, 6]} />
|
|
241
|
+
<Mate c="#6B7080" />
|
|
242
|
+
</mesh>
|
|
243
|
+
</group>
|
|
244
|
+
);
|
|
245
|
+
|
package/remotion/kit/index.ts
CHANGED