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/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 lineas = [v.title ?? f.id, '', ...(v.descripcion ? [v.descripcion, ''] : []), ...(tags ? [tags] : []), ...creditos];
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.4",
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`.
@@ -0,0 +1,5 @@
1
+ # Instrucciones para Claude Code
2
+
3
+ Las instrucciones de este proyecto están en AGENTS.md (las mismas para cualquier IA):
4
+
5
+ @AGENTS.md
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
 
@@ -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)
@@ -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
+
@@ -12,3 +12,4 @@ export * from './mundo3d';
12
12
  export * from './datos';
13
13
  export * from './diorama';
14
14
  export {clamp, easeIO, easeOut, easeOutExpo, backOut, resorte, ruido, lerp, mezclaColor} from '../estilo';
15
+ export * from './armar';