morfeo 0.6.2 → 0.6.3

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/bin/morfeo.mjs CHANGED
@@ -21,6 +21,8 @@ Videos
21
21
  render [id...] Genera los videos que faltan (o los que indiques)
22
22
  --todos regenera todos
23
23
  --solo-fallidos reintenta solo los que fallaron en el último lote
24
+ --cambiados solo los que cambiaron (guion o tus componentes) desde su último render
25
+ --revisar al terminar cada uno, saca su hoja y lo revisa (un solo paso en vez de tres)
24
26
  --escenas 5-7 solo esas escenas (para corregir una toma sin rehacer todo) + su hoja
25
27
  --gpu usa la tarjeta de video (más rápido; si falla, quítalo)
26
28
  previa <id> Solo el gancho (~8 s) para aprobarlo antes del render completo: --hasta 6
@@ -119,10 +121,18 @@ Pruébalo: npx morfeo render ${pos[0]} y revísalo: npx morfeo hoja ${pos[0]
119
121
  const {render} = await import('../lib/render.mjs');
120
122
  const escenas = op.escenas != null && op.escenas !== true ? String(op.escenas).split(/[-,]/).map(Number) : null;
121
123
  if (escenas && (escenas.some((x) => !Number.isInteger(x) || x < 1) || pos.length !== 1)) throw new ErrorMorfeo('Uso: npx morfeo render mi-video --escenas 5 (o 5-7)');
122
- const hechos = await render({proyecto, ids: pos, todos: !!op.todos || pos[0] === 'todos', soloFallidos: !!op.soloFallidos, escenas: escenas ? [escenas[0], escenas[escenas.length - 1]] : null});
124
+ const hechos = await render({proyecto, ids: pos[0] === 'todos' ? [] : pos, todos: !!op.todos || pos[0] === 'todos', cambiados: !!op.cambiados, soloFallidos: !!op.soloFallidos, escenas: escenas ? [escenas[0], escenas[escenas.length - 1]] : null});
123
125
  if (escenas) {
124
126
  const {hoja} = await import('../lib/hoja.mjs');
125
127
  for (const h of hechos ?? []) console.log(hoja({video: h.mp4, cols: 5, filas: 2}));
128
+ } else if (op.revisar) {
129
+ const {hoja} = await import('../lib/hoja.mjs');
130
+ const {cargarProyecto} = await import('../lib/config.mjs');
131
+ const p = await cargarProyecto(proyecto);
132
+ for (const h of hechos ?? []) {
133
+ console.log(hoja({video: h.mp4}));
134
+ await revisarVideo(p, h.id);
135
+ }
126
136
  }
127
137
  return;
128
138
  }
@@ -145,25 +155,13 @@ Pruébalo: npx morfeo render ${pos[0]} y revísalo: npx morfeo hoja ${pos[0]
145
155
  }
146
156
  case 'revisar': {
147
157
  const {cargarProyecto} = await import('../lib/config.mjs');
148
- const {revisarEstructura, consejosEnTexto, REGLAS} = await import('../lib/estructura.mjs');
158
+ const {REGLAS} = await import('../lib/estructura.mjs');
149
159
  const p = await cargarProyecto(proyecto);
150
160
  const ids = pos.length ? pos : Object.keys(p.videos);
151
- const fs = await import('node:fs');
152
161
  let total = 0;
153
162
  for (const id of ids) {
154
163
  if (!p.videos[id]) throw new ErrorMorfeo(`No existe el video ${id}. Usa: npx morfeo lista`);
155
- // tiempos reales si ya se preparó la voz (después de un render); si no, solo el texto
156
- const f = path.join(p.trabajo, 'public', 'v', id, 'datos.json');
157
- const d = fs.existsSync(f) ? JSON.parse(fs.readFileSync(f, 'utf8')) : null;
158
- const c = revisarEstructura(p.videos[id], d);
159
- // con el MP4 ya hecho: cuadros negros, tapados, planos o quietos (--sin-cuadros lo salta)
160
- if (!op.sinCuadros) {
161
- const {salidaDe} = await import('../lib/salida.mjs');
162
- const {revisarCuadros} = await import('../lib/calidad.mjs');
163
- c.push(...(await revisarCuadros(salidaDe(p, id), d)));
164
- }
165
- total += c.length;
166
- console.log(c.length ? `${id}${d ? '' : ' (sin tiempos: renderízalo para revisar el ritmo)'}\n${consejosEnTexto(c)}` : `${id} ✔`);
164
+ total += await revisarVideo(p, id, !!op.sinCuadros);
167
165
  }
168
166
  if (total) console.log(`\nReglas completas: ${REGLAS.map((r) => r.regla.toLowerCase()).join(' · ')}`);
169
167
  return;
@@ -256,6 +254,24 @@ Pruébalo: npx morfeo render ${pos[0]} y revísalo: npx morfeo hoja ${pos[0]
256
254
  }
257
255
  }
258
256
 
257
+ /** Revisa un video (estructura + cuadros del MP4 si existe), lo imprime y devuelve cuántos avisos tuvo. */
258
+ async function revisarVideo(p, id, sinCuadros = false) {
259
+ const fs = await import('node:fs');
260
+ const {revisarEstructura, consejosEnTexto} = await import('../lib/estructura.mjs');
261
+ // tiempos reales si ya se preparó la voz (después de un render); si no, solo el texto
262
+ const f = path.join(p.trabajo, 'public', 'v', id, 'datos.json');
263
+ const d = fs.existsSync(f) ? JSON.parse(fs.readFileSync(f, 'utf8')) : null;
264
+ const c = revisarEstructura(p.videos[id], d);
265
+ // con el MP4 ya hecho: cuadros negros, tapados, planos o quietos (--sin-cuadros lo salta)
266
+ if (!sinCuadros) {
267
+ const {salidaDe} = await import('../lib/salida.mjs');
268
+ const {revisarCuadros} = await import('../lib/calidad.mjs');
269
+ c.push(...(await revisarCuadros(salidaDe(p, id), d)));
270
+ }
271
+ console.log(c.length ? `${id}${d ? '' : ' (sin tiempos: renderízalo para revisar el ritmo)'}\n${consejosEnTexto(c)}` : `${id} ✔`);
272
+ return c.length;
273
+ }
274
+
259
275
  async function guion(pos, op, proyecto) {
260
276
  const {cargarEnv} = await import('../lib/render.mjs');
261
277
  const {guionConIA, guionSinIA, guardarVideo} = await import('../lib/guion.mjs');
package/index.d.ts CHANGED
@@ -1,7 +1,7 @@
1
1
  // Tipos de Morfeo: dan autocompletado en morfeo.config.mjs y videos.mjs (también en JS).
2
2
 
3
3
  /** '#RRGGBB' o [r, g, b] */
4
- export type Ambiente = 'cuarto-gamer' | 'sala' | 'arcade' | 'taller' | 'oficina';
4
+ export type Ambiente = 'cuarto-gamer' | 'sala' | 'arcade' | 'taller' | 'oficina' | 'tienda' | 'calle';
5
5
  /** Postproceso 3D: 'cine' (foco + brillo + viñeta), 'miniatura' (tilt-shift), 'suave', false o ajustes propios. */
6
6
  export type Post3D = 'cine' | 'miniatura' | 'suave' | false | {foco?: boolean | number; brillo?: number; vinieta?: number; miniatura?: number};
7
7
  export type Color = string | [number, number, number];
@@ -297,7 +297,7 @@ export interface Video {
297
297
  * El componente recibe además `escena` (1, 2, 3...), `tEscena`, `inicios` y `camara` (para paralaje).
298
298
  */
299
299
  /**
300
- * Ambiente 3D listo como mundo del video: 'cuarto-gamer' | 'sala' | 'arcade' (o {nombre, persona, post}).
300
+ * Ambiente 3D listo como mundo del video: 'cuarto-gamer' | 'sala' | 'arcade' | 'taller' | 'oficina' | 'tienda' | 'calle' (o {nombre, persona, post}).
301
301
  * Sirve en cualquier estilo del motor de capas; cada escena elige su `plano` o toma otro ángulo sola.
302
302
  */
303
303
  ambiente?: Ambiente | {nombre: Ambiente; persona?: boolean | {ropa?: string; pelo?: string; piel?: string}; reaccionar?: boolean; post?: Post3D};
package/lib/config.mjs CHANGED
@@ -28,7 +28,7 @@ export const ROTULOS = ['normal', 'contorno', 'pastilla', 'sello', 'paso', 'pixe
28
28
  export const MODOS = ['rayosx', 'plano', 'holograma'];
29
29
  export const ESTILOS = ['datos-curiosos', 'explicativo', 'documental', 'ilustrado', 'historia', 'mesa', 'serie', 'infografia', '3d', 'maqueta', 'minimal'];
30
30
  export const TIPOS_CAPA = ['imagen', 'video', 'texto', 'contador', 'chip', 'etiqueta', 'numero', 'tarjeta', 'telefono', 'forma', 'icono', 'emoji', 'marca', 'particulas', 'grupo', 'modelo', 'componente', 'ambiente', 'barra', 'medidor', 'votacion', 'estado', 'noticia', 'chat', 'onda', 'globo', 'seguir', 'personaje', 'escena3d', 'ventana', 'escenario', 'recurso', 'dato', 'medida', 'trazo', 'contorno', 'fuente', 'seccion', 'insignia', 'sello', 'cursor', 'terminal', 'nombre', 'accion', 'reloj', 'cronometro', 'termometro', 'recipientes', 'ficha', 'checklist', 'mapa', 'bloqueo', 'llamada', 'recuento', 'capitulo', 'vigilancia', 'cintillo', 'fecha', 'calendario', 'dividida', 'tierra', 'linea'];
31
- export const AMBIENTES = ['cuarto-gamer', 'sala', 'arcade', 'taller', 'oficina'];
31
+ export const AMBIENTES = ['cuarto-gamer', 'sala', 'arcade', 'taller', 'oficina', 'tienda', 'calle'];
32
32
  export const ESCENARIOS = ['bosque', 'cuarto', 'departamento', 'tunel', 'pasillo'];
33
33
  export const FORMAS_3D = ['moneda', 'billete', 'lingote', 'cubo', 'esfera', 'corazon', 'estrella', 'rayo', 'diamante', 'trofeo', 'cohete', 'foco', 'reloj', 'planeta', 'arbol', 'nube', 'regalo', 'llave', 'candado', 'casa', 'libro', 'globo', 'medalla', 'escudo', 'pin', 'tuberia', 'muro', 'viga', 'columna', 'escalera', 'losa', 'tanque', 'suelo'];
34
34
  export const PROVEEDORES_VOZ = ['kokoro', 'elevenlabs', 'openai'];
@@ -129,7 +129,7 @@ export function revisarEstructura(v, d) {
129
129
  const capas = (s.capas ?? []).filter(Boolean);
130
130
  if (!conMundo && (s.type ?? 'capas') === 'capas' && !capas.length && !s.rotulo) {
131
131
  vacias++;
132
- add('vacia', 'aviso', `Escena ${i + 1}: solo el subtítulo sobre el fondo. Agrega una capa o dale al video un \`ambiente: 'cuarto-gamer'\` (o 'sala', 'arcade', 'taller', 'oficina').`);
132
+ add('vacia', 'aviso', `Escena ${i + 1}: solo el subtítulo sobre el fondo. Agrega una capa o dale al video un \`ambiente: 'cuarto-gamer'\` (o 'sala', 'arcade', 'taller', 'oficina', 'tienda', 'calle').`);
133
133
  }
134
134
  if (capas.length && capas.every((c) => SOLO_TEXTO.has(c.tipo))) soloTexto++;
135
135
  for (const c of capas) tipos.set(c.tipo, (tipos.get(c.tipo) ?? 0) + 1);
package/lib/render.mjs CHANGED
@@ -230,7 +230,7 @@ export function usaWebGL(p, id) {
230
230
  * @param {{proyecto?: string, ids?: string[], todos?: boolean, log?: (m: string) => void, alProgreso?: (id: string, p: number) => void}} o
231
231
  * @returns {Promise<{id: string, mp4: string, jpg: string | null, segundos: number}[]>}
232
232
  */
233
- export async function render({proyecto = process.cwd(), ids, todos = false, soloFallidos = false, previa = null, animatica = false, escenas = null, log = console.log, alProgreso} = {}) {
233
+ export async function render({proyecto = process.cwd(), ids, todos = false, cambiados = false, soloFallidos = false, previa = null, animatica = false, escenas = null, log = console.log, alProgreso} = {}) {
234
234
  // animática: todo el video a 1/3 de tamaño y menor calidad (para aprobar tomas y ritmo en segundos)
235
235
  // escenas: [a, b] (1 = la primera) solo ese tramo, en tamaño completo
236
236
  const parcial = !!(animatica || escenas);
@@ -250,15 +250,37 @@ export async function render({proyecto = process.cwd(), ids, todos = false, solo
250
250
  }
251
251
  // archivos ya hechos con el id o con un título viejo → su nombre actual (sin volver a renderizar)
252
252
  ordenarSalida(p, (m) => log(m));
253
- let lista = ids?.length ? ids : todos ? Object.keys(p.videos) : Object.keys(p.videos).filter((id) => !fs.existsSync(salidaDe(p, id)));
253
+ let lista = ids?.length ? ids : todos || cambiados ? Object.keys(p.videos) : Object.keys(p.videos).filter((id) => !fs.existsSync(salidaDe(p, id)));
254
254
  const malos = lista.filter((id) => !p.videos[id]);
255
255
  if (malos.length) throw new ErrorMorfeo(`No existen: ${malos.join(', ')}. Usa: npx morfeo lista`);
256
256
  if (!lista.length) {
257
257
  log('No hay videos pendientes. Para regenerar: npx morfeo render --todos (o npx morfeo render <id>)');
258
258
  return [];
259
259
  }
260
+ // --cambiados: solo los videos cuyo guion o componentes del proyecto cambiaron desde su último render. Se decide
261
+ // con el guion, antes de preparar el audio (prepararlo para 30 videos tarda más que renderizar uno).
262
+ // La versión de Morfeo no cuenta (rehacería todo en cada actualización): para eso está --todos.
263
+ const huellas = leerHuellas(p);
264
+ const delProyecto = parcial || previa ? '' : huellaProyecto(p);
265
+ const huellaDe = (id) => crypto.createHash('sha1').update(delProyecto).update(JSON.stringify(p.videos[id], (k, v) => (typeof v === 'function' ? String(v) : v))).digest('hex').slice(0, 16);
266
+ if (cambiados && !ids?.length) {
267
+ const antes = lista.length;
268
+ // la primera vez no hay huella de los videos ya hechos: se dan por buenos y se anota la de ahora
269
+ let nuevas = 0;
270
+ for (const id of lista)
271
+ if (huellas[id] == null && fs.existsSync(salidaDe(p, id))) {
272
+ huellas[id] = huellaDe(id);
273
+ nuevas++;
274
+ }
275
+ if (nuevas) guardarHuellas(p, huellas);
276
+ lista = lista.filter((id) => huellas[id] !== huellaDe(id) || !fs.existsSync(salidaDe(p, id)));
277
+ log(lista.length ? `${lista.length} de ${antes} video(s) cambiaron: ${lista.join(', ')}` : `Ningún video cambió desde su último render (${antes} al día).`);
278
+ if (!lista.length) return [];
279
+ }
260
280
  if (!whisperListo()) log('(Whisper no está instalado: subtítulos repartidos por frase. Para tiempos exactos: npx morfeo instalar --whisper)');
281
+ const tPrep = Date.now();
261
282
  const {publico, marca, extras, datos, fallidos} = await preparar({proyecto, ids: lista, log, _p: p, continuar: true});
283
+ const segPrep = (Date.now() - tPrep) / 1000;
262
284
  const listos = lista.filter((id) => datos[id]);
263
285
  // estructura: consejos medidos en videos virales (no detienen el render; se apagan con render.consejos: false)
264
286
  if (p.render.consejos !== false)
@@ -277,12 +299,16 @@ export async function render({proyecto = process.cwd(), ids, todos = false, solo
277
299
  const {bundle} = await import('@remotion/bundler');
278
300
  const {renderMedia, renderStill, selectComposition} = await import('@remotion/renderer');
279
301
  const entrada = entradaDe(p);
302
+ const tBundle = Date.now();
280
303
  const serveUrl = await bundle({entryPoint: entrada, publicDir: publico, rootDir: entrada === ENTRADA_REMOTION ? path.dirname(ENTRADA_REMOTION) : p.proyecto, webpackOverride: conHuella});
304
+ const segBundle = (Date.now() - tBundle) / 1000;
281
305
  fs.mkdirSync(p.salida, {recursive: true});
282
306
  const chromiumOptions = {gl: p.render.gl};
283
307
  const browserExecutable = p.render.navegador ?? (await asegurarNavegador(log));
284
308
  // cuadros en paralelo: con GPU aguanta más; por software cada pestaña come mucha CPU y memoria
285
309
  const nucleos = os.cpus().length;
310
+ // MORFEO_CONCURRENCIA=n: fija los cuadros en paralelo sin tocar el config (para medir en tu máquina)
311
+ if (process.env.MORFEO_CONCURRENCIA) p.render.concurrencia = Math.max(1, Number(process.env.MORFEO_CONCURRENCIA) || 1);
286
312
  const concurrenciaBase = p.render.concurrencia ?? (p.render.gl === 'angle' ? Math.max(2, Math.min(10, Math.floor(nucleos * 0.75))) : Math.max(1, Math.min(3, Math.floor(nucleos / 4))));
287
313
  const sinTTY = !process.stdout.isTTY;
288
314
  for (const [i, id] of listos.entries()) {
@@ -317,6 +343,8 @@ export async function render({proyecto = process.cwd(), ids, todos = false, solo
317
343
  audioBitrate: '192k',
318
344
  imageFormat: 'jpeg',
319
345
  jpegQuality: 92,
346
+ // 'faster': ~10 % menos tiempo que 'medium' y, con el mismo CRF, sin diferencia visible
347
+ x264Preset: 'faster',
320
348
  concurrency: concurrencia,
321
349
  outputLocation: mp4,
322
350
  ...(previa ? {frameRange: [0, Math.min(composition.durationInFrames - 1, Math.round(finPrevia * composition.fps))]} : {}),
@@ -346,6 +374,7 @@ export async function render({proyecto = process.cwd(), ids, todos = false, solo
346
374
  log(`\n el navegador falló; reintento con ${gl === 'angle' ? 'GPU' : 'CPU'} y ${concurrencia} cuadro(s) a la vez...`);
347
375
  }
348
376
  }
377
+ const segVideo = (Date.now() - t) / 1000;
349
378
  let jpg = null;
350
379
  if (p.render.miniatura && !previa && !parcial) {
351
380
  // miniatura: cuando la primera escena ya está completa (o en `miniaturaEn`), sin subtítulos
@@ -371,7 +400,13 @@ export async function render({proyecto = process.cwd(), ids, todos = false, solo
371
400
  const segundos = Math.round((Date.now() - t) / 1000);
372
401
  if (process.stdout.isTTY) process.stdout.write('\r');
373
402
  log(`[${i + 1}/${listos.length}] ${id}: listo en ${segundos} s → ${path.relative(p.proyecto, mp4)} `);
403
+ // desglose para saber qué optimizar (MORFEO_TIEMPOS=1): audio y empaquetado son de todo el lote
404
+ if (process.env.MORFEO_TIEMPOS) log(` tiempos: audio ${segPrep.toFixed(1)} s (lote) · empaquetar ${segBundle.toFixed(1)} s (lote) · video ${segVideo.toFixed(1)} s · miniatura y portada ${(segundos - segVideo).toFixed(1)} s`);
374
405
  hechos.push({id, mp4, jpg, segundos});
406
+ if (!previa && !parcial) {
407
+ huellas[id] = huellaDe(id);
408
+ guardarHuellas(p, huellas);
409
+ }
375
410
  } catch (e) {
376
411
  if (process.stdout.isTTY) process.stdout.write('\n');
377
412
  fallidos.push({id, error: mensajeDe(e)});
@@ -385,6 +420,39 @@ export async function render({proyecto = process.cwd(), ids, todos = false, solo
385
420
  return terminarLote(p, loteArchivo, hechos, fallidos, log);
386
421
  }
387
422
 
423
+ // Huella de cada video al renderizarlo (.morfeo/huellas.json): con ella `render --cambiados` sabe cuáles rehacer.
424
+ const archivoHuellas = (p) => path.join(p.trabajo, 'huellas.json');
425
+ function leerHuellas(p) {
426
+ try {
427
+ return JSON.parse(fs.readFileSync(archivoHuellas(p), 'utf8'));
428
+ } catch {
429
+ return {};
430
+ }
431
+ }
432
+ function guardarHuellas(p, h) {
433
+ fs.mkdirSync(p.trabajo, {recursive: true});
434
+ fs.writeFileSync(archivoHuellas(p), JSON.stringify(h, null, '\t'));
435
+ }
436
+ /** Componentes y config del proyecto (tsx/ts/jsx y morfeo.config.mjs): si cambian, cambia cómo se ve el video. */
437
+ function huellaProyecto(p) {
438
+ const partes = [];
439
+ const recorrer = (dir, n = 0) => {
440
+ if (n > 4) return;
441
+ for (const e of fs.readdirSync(dir, {withFileTypes: true}).sort((a, b) => a.name.localeCompare(b.name))) {
442
+ if (e.name.startsWith('.') || e.name === 'node_modules' || e.name === 'guiones') continue;
443
+ const f = path.join(dir, e.name);
444
+ if (path.resolve(f) === path.resolve(p.salida)) continue;
445
+ if (e.isDirectory()) recorrer(f, n + 1);
446
+ else if (/\.(tsx?|jsx)$/.test(e.name) || e.name === 'morfeo.config.mjs') {
447
+ const st = fs.statSync(f);
448
+ partes.push(`${e.name}:${st.size}:${Math.round(st.mtimeMs)}`);
449
+ }
450
+ }
451
+ };
452
+ recorrer(p.proyecto);
453
+ return crypto.createHash('sha1').update(partes.join('|')).digest('hex').slice(0, 8);
454
+ }
455
+
388
456
  const CRF = {alta: 18, media: 21, ligera: 24};
389
457
  /** Cuadros [inicio, fin] de las escenas a..b (1 = la primera). */
390
458
  function tramoEscenas(d, [a, b], composition) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "morfeo",
3
- "version": "0.6.2",
3
+ "version": "0.6.3",
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",
@@ -43,8 +43,9 @@ la persona te corrija algo que deba valer para los siguientes videos, ofrécele
43
43
  (tiene 3 ganchos de reserva en `hooks`).
44
44
  6. **Aprueba tomas y ritmo barato:** `npx morfeo animatica <id>` (todo el video a 1/3 de tamaño + su hoja). Si una
45
45
  toma está mal, arréglala y vuelve a pedir solo esas escenas: `npx morfeo render <id> --escenas 5-6`.
46
- 7. **Renderiza:** `npx morfeo render <id>`.
47
- 8. **Revisa antes de entregar:**
46
+ 7. **Renderiza y revisa en un paso:** `npx morfeo render <id> --revisar` (video + hoja + revisión). Tras cambiar varios
47
+ guiones, `npx morfeo render --cambiados --revisar` rehace solo los que cambiaron.
48
+ 8. **Revisa antes de entregar** (si no usaste `--revisar`):
48
49
  - `npx morfeo revisar <id>`: estructura, que la voz haya dicho el guion, que nada quede bajo los botones de la app,
49
50
  el riesgo de "diapositiva" y, en el MP4, cuadros negros, tapados (cámara pegada a algo), lisos o quietos.
50
51
  - `npx morfeo hoja <id> --zona`: mira la hoja de contacto (es una imagen); lo pintado en rojo es lo que tapan las apps.
package/python/musica.py CHANGED
@@ -335,6 +335,18 @@ def cama(nombre, N, rng):
335
335
  n = int(0.012 * SR); g = int(rng.random() * (N - n))
336
336
  x[g:g + n] += 0.02 * hp(rng.standard_normal(n), 2500) * np.exp(-np.arange(n) / (0.002 * SR))
337
337
  return x
338
+ if nombre == "tienda":
339
+ x = lp(hp(rng.standard_normal(N), 250), 1300) * 0.025 * (0.6 + 0.4 * np.abs(np.sin(2 * np.pi * 0.6 * t))) + zumbido * 0.7
340
+ for _ in range(int(N / SR / 2)): # la tele de demostración: blips lejanos
341
+ add(x, 0.01 * lp(_tono(nf(74 + int(rng.random() * 12)), 0.1, 20), 2500), rng.random() * (N / SR - 0.2))
342
+ return x
343
+ if nombre == "calle":
344
+ viento = lp(rng.standard_normal(N), 350) * 0.05 * (0.6 + 0.4 * np.sin(2 * np.pi * 0.11 * t))
345
+ x = lluvia(0.06) + viento
346
+ for _ in range(max(1, int(N / SR / 9))): # un coche que pasa a lo lejos
347
+ d = 3.0; n = int(d * SR); tt = np.arange(n) / SR
348
+ add(x, 0.035 * lp(rng.standard_normal(n), 500) * np.sin(np.pi * tt / d) ** 2, rng.random() * max(0.1, N / SR - d))
349
+ return x
338
350
  if nombre == "arcade":
339
351
  x = lp(rng.standard_normal(N), 700) * 0.05 + zumbido
340
352
  for _ in range(int(N / SR * 3)): # maquinitas sonando por todos lados
@@ -13,7 +13,7 @@ import {Persona3D} from './mundo3d';
13
13
  import type {Expresion, Pose} from './personas';
14
14
  import {Bloque, Lienzo3D, type Etiqueta3D, type Post3D, type Toma3D, type V3} from './tres';
15
15
 
16
- export type NombreAmbiente = 'cuarto-gamer' | 'sala' | 'arcade' | 'taller' | 'oficina';
16
+ export type NombreAmbiente = 'cuarto-gamer' | 'sala' | 'arcade' | 'taller' | 'oficina' | 'tienda' | 'calle';
17
17
  export type Reaccion = {en: number; expresion?: Expresion; pose?: Pose};
18
18
 
19
19
  type Def = {
@@ -627,6 +627,192 @@ const SetOficina: React.FC<{t: number}> = ({t}) => (
627
627
  </group>
628
628
  );
629
629
 
630
+
631
+ /** Caja de juego de frente (portada de color con una franja), para exhibidores. */
632
+ const CajaJuego: React.FC<{pos: V3; i: number; rot?: V3}> = ({pos, i, rot}) => {
633
+ const c = ['#E5484D', '#3D7BFF', '#F2B632', '#30A46C', '#8B5BD6', '#FF8A3D'][i % 6];
634
+ return (
635
+ <group position={pos} rotation={rot ? [(rot[0] * Math.PI) / 180, (rot[1] * Math.PI) / 180, (rot[2] * Math.PI) / 180] : undefined}>
636
+ <Bloque pos={[0, 0, 0]} tam={[0.26, 0.36, 0.04]} color={c} />
637
+ <Bloque pos={[0, 0.13, 0.022]} tam={[0.26, 0.06, 0.004]} color="#F4F1EA" />
638
+ <Bloque pos={[0, -0.03, 0.022]} tam={[0.12, 0.12, 0.004]} color={hsl(0.13 * i + 0.4, 0.6, 0.72)} />
639
+ </group>
640
+ );
641
+ };
642
+
643
+ /** Tienda de videojuegos: pared de juegos, mostrador con caja, tele de demostración, isla de ofertas y neón. */
644
+ const SetTienda: React.FC<{t: number}> = ({t}) => (
645
+ <group>
646
+ <Cuarto ancho={9} fondo={7} alto={3.4} pared="#232A44" lateral="#1D233A" piso="#3A3340" />
647
+ {/* pared de juegos: dos filas de estantes */}
648
+ {[-3.2, -1.55, 0.1].map((x, i) => (
649
+ <React.Fragment key={x}>
650
+ <Estante pos={[x, 0.75, -3.25]} semilla={i * 7} />
651
+ <Estante pos={[x, 2.05, -3.25]} semilla={i * 7 + 3} />
652
+ </React.Fragment>
653
+ ))}
654
+ <Emisivo pos={[-1.55, 3.05, -3.36]} tam={[4.9, 0.05, 0.04]} color="#7FE3FF" fuerza={1.4} />
655
+ <pointLight position={[-1.55, 2.9, -2.6]} color="#7FE3FF" intensity={1.2} distance={5} decay={1.6} />
656
+ {/* tele de demostración y letrero de neón (un control) */}
657
+ <Tele3D pos={[2.8, 1.95, -3.32]} ancho={1.3} t={t} fase={2} luz={1.6} />
658
+ <group position={[2.8, 2.95, -3.36]}>
659
+ {[
660
+ [0, 0.16, 0.9, 0],
661
+ [0, -0.16, 0.9, 0],
662
+ [-0.45, 0, 0.32, 90],
663
+ [0.45, 0, 0.32, 90],
664
+ ].map(([x, y, l, r], i) => (
665
+ <Emisivo key={i} pos={[x, y, 0]} tam={[l, 0.05, 0.04]} rot={[0, 0, (r * Math.PI) / 180]} color="#FF3B6B" fuerza={2.1 * (Math.sin(t * 11) > -0.9 ? 1 : 0.6)} />
666
+ ))}
667
+ <Emisivo pos={[-0.22, 0, 0]} tam={[0.16, 0.05, 0.04]} color="#FFD23D" fuerza={2} />
668
+ <Emisivo pos={[-0.22, 0, 0]} tam={[0.05, 0.16, 0.04]} color="#FFD23D" fuerza={2} />
669
+ <Emisivo pos={[0.2, 0.03, 0]} tam={[0.06, 0.06, 0.04]} color="#3DB8FF" fuerza={2} />
670
+ <Emisivo pos={[0.3, -0.04, 0]} tam={[0.06, 0.06, 0.04]} color="#3DDC84" fuerza={2} />
671
+ <pointLight position={[0, 0, 0.6]} color="#FF3B6B" intensity={1.6} distance={4} />
672
+ </group>
673
+ {/* mostrador con caja registradora y vitrina */}
674
+ <Bloque pos={[2.6, 0.5, -1.2]} tam={[2.2, 1.0, 0.8]} color="#4A3B5C" />
675
+ <Bloque pos={[2.6, 1.02, -1.2]} tam={[2.3, 0.05, 0.9]} color="#E9E1D2" />
676
+ <Emisivo pos={[2.6, 0.55, -0.79]} tam={[1.9, 0.5, 0.02]} color="#9AD7FF" fuerza={0.5} />
677
+ {[0, 1, 2, 3].map((i) => (
678
+ <Bloque key={i} pos={[1.9 + i * 0.46, 0.5, -0.76]} tam={[0.3, 0.12, 0.04]} color={['#EDEDED', '#2B2B33', '#E5484D', '#3D7BFF'][i]} />
679
+ ))}
680
+ <Bloque pos={[3.1, 1.2, -1.25]} tam={[0.5, 0.3, 0.45]} color="#2B2B33" />
681
+ <Emisivo pos={[3.1, 1.42, -1.1]} tam={[0.36, 0.2, 0.03]} rot={[-0.5, 0, 0]} color="#3DDC84" fuerza={1.2 + 0.2 * Math.sin(t * 5)} />
682
+ <Bloque pos={[2.0, 1.14, -1.2]} tam={[0.3, 0.18, 0.2]} color="#EDEDED" radio={0.03} />
683
+ {/* isla de ofertas */}
684
+ <Bloque pos={[-2.6, 0.45, 0.6]} tam={[1.5, 0.9, 0.8]} color="#4A3B5C" />
685
+ {[0, 1, 2, 3].map((i) => (
686
+ <CajaJuego key={i} pos={[-3.15 + i * 0.37, 1.07, 0.6]} i={i + 2} rot={[-18, 0, 0]} />
687
+ ))}
688
+ <Emisivo pos={[-2.6, 0.5, 1.01]} tam={[0.9, 0.3, 0.02]} color="#FFD23D" fuerza={1.1} />
689
+ <pointLight position={[-2.4, 2.4, 1.4]} color="#FFE2B8" intensity={1.8} distance={4.5} decay={1.5} />
690
+ {/* póster y tapete */}
691
+ <Poster pos={[-4.38, 2.0, -1.2]} tipo="nave" rot={[0, 90, 0]} />
692
+ <mesh rotation={[-Math.PI / 2, 0, 0]} position={[0.4, 0.006, -0.6]}>
693
+ <circleGeometry args={[1.5, 28]} />
694
+ <meshStandardMaterial color="#5B4B8A" roughness={0.95} />
695
+ </mesh>
696
+ {/* luces del techo */}
697
+ {[-2.4, 1.2].map((x) => (
698
+ <group key={x} position={[x, 3.3, -1]}>
699
+ <Emisivo pos={[0, 0, 0]} tam={[1.6, 0.04, 0.14]} color="#FFE9C4" fuerza={1.4} />
700
+ <pointLight position={[0, -0.3, 0]} intensity={2.3} distance={7.5} decay={1.4} color="#FFE2B8" />
701
+ </group>
702
+ ))}
703
+ <Polvo centro={[0, 1.8, -1.2]} tam={[6, 2.4, 3]} t={t} n={30} />
704
+ </group>
705
+ );
706
+
707
+ /** Calle de noche con lluvia: fachadas con ventanas, un local con vitrina y neón, farola, banca y coche. */
708
+ const SetCalle: React.FC<{t: number}> = ({t}) => (
709
+ <group>
710
+ {/* asfalto, banqueta y línea de la calle */}
711
+ <mesh rotation={[-Math.PI / 2, 0, 0]} position={[0, 0, 1]} receiveShadow>
712
+ <planeGeometry args={[40, 16]} />
713
+ <meshStandardMaterial color="#14161E" roughness={0.35} metalness={0.3} />
714
+ </mesh>
715
+ <Bloque pos={[0, 0.07, -1.3]} tam={[40, 0.14, 3.6]} color="#3B3D48" rugosidad={0.9} />
716
+ {[-9, -6, -3, 0, 3, 6, 9].map((x) => (
717
+ <Bloque key={x} pos={[x, 0.006, 3.2]} tam={[1.4, 0.01, 0.14]} color="#D8D2B8" />
718
+ ))}
719
+ {/* charcos que reflejan el neón */}
720
+ {[
721
+ [-1.2, 1.4, 1.1],
722
+ [1.8, 2.4, 0.8],
723
+ [-3.6, 3.6, 0.7],
724
+ ].map(([x, z, r], i) => (
725
+ <mesh key={i} rotation={[-Math.PI / 2, 0, 0]} position={[x, 0.004, z]} scale={[1, 0.55, 1]}>
726
+ <circleGeometry args={[r, 20]} />
727
+ <meshStandardMaterial color="#232A4A" roughness={0.05} metalness={0.9} emissive="#FF3B6B" emissiveIntensity={0.05 + 0.03 * Math.sin(t * 3 + i)} />
728
+ </mesh>
729
+ ))}
730
+ {/* fachadas */}
731
+ {[
732
+ [-5.6, 7.5, 5, '#1C2033'],
733
+ [0, 5.2, 5.4, '#2A2338'],
734
+ [5.8, 8.6, 5.6, '#1A1E2E'],
735
+ ].map(([x, alto, ancho, color], k) => (
736
+ <group key={k}>
737
+ <Bloque pos={[x as number, (alto as number) / 2, -4.4]} tam={[ancho as number, alto as number, 2.6]} color={color as string} rugosidad={0.95} />
738
+ {Array.from({length: 4 * Math.floor(((alto as number) - 2.6) / 1.15)}, (_, i) => {
739
+ const col = i % 4;
740
+ const fila = Math.floor(i / 4);
741
+ const prendida = azarN(k * 31 + i * 7.3) > 0.45;
742
+ const parpadea = k === 0 && i === 5 ? 0.6 + 0.4 * (Math.sin(t * 9) > 0.2 ? 1 : 0.3) : 1;
743
+ return <Emisivo key={i} pos={[(x as number) - (ancho as number) / 2 + 0.85 + col * (((ancho as number) - 1.7) / 3), 3.4 + fila * 1.15, -3.08]} tam={[0.62, 0.72, 0.03]} color={prendida ? (azarN(i * 3.1 + k) > 0.6 ? '#9AD7FF' : '#FFD98A') : '#141826'} fuerza={prendida ? 0.9 * parpadea : 0.05} />;
744
+ })}
745
+ </group>
746
+ ))}
747
+ {/* local: vitrina encendida, puerta, toldo y letrero */}
748
+ <Emisivo pos={[-0.6, 1.35, -3.08]} tam={[2.6, 1.7, 0.04]} color="#E0A050" fuerza={0.5} />
749
+ {[0, 1, 2, 3, 4, 5, 6, 7].map((i) => (
750
+ <CajaJuego key={i} pos={[-1.5 + (i % 4) * 0.6, i < 4 ? 0.95 : 1.6, -3.02]} i={i} />
751
+ ))}
752
+ <Bloque pos={[-0.6, 1.28, -3.03]} tam={[2.6, 0.04, 0.12]} color="#3A2F4E" />
753
+ <Bloque pos={[-0.6, 0.38, -3.02]} tam={[2.7, 0.5, 0.1]} color="#3A2F4E" />
754
+ <Bloque pos={[1.6, 1.15, -3.06]} tam={[0.95, 2.1, 0.08]} color="#4A3B5C" />
755
+ <Emisivo pos={[1.6, 1.5, -3.01]} tam={[0.6, 0.9, 0.02]} color="#FFC56B" fuerza={0.6} />
756
+ <Bloque pos={[0.1, 2.45, -2.75]} rot={[18, 0, 0]} tam={[4.4, 0.08, 0.9]} color="#C62E3A" />
757
+ <group position={[0.1, 3.05, -3.04]}>
758
+ {[-1.2, -0.6, 0, 0.6, 1.2].map((x, i) => (
759
+ <Emisivo key={i} pos={[x, 0, 0]} tam={[0.42, 0.34, 0.05]} color={['#FF3B6B', '#FFD23D', '#3DDC84', '#3DB8FF', '#B98BFF'][i]} fuerza={2 * (Math.floor(t * 2.5) % 6 === i ? 0.45 : 1)} />
760
+ ))}
761
+ <pointLight position={[0, 0, 1]} color="#FF6B9A" intensity={2.2} distance={6} decay={1.5} />
762
+ </group>
763
+ <pointLight position={[-0.6, 1.4, -2.2]} color="#FFC56B" intensity={2.4} distance={5.5} decay={1.5} />
764
+ {/* farola */}
765
+ <Bloque pos={[3.0, 1.8, -0.1]} tam={[0.1, 3.6, 0.1]} color="#2B2F3A" metal={0.5} />
766
+ <Bloque pos={[2.7, 3.58, -0.1]} tam={[0.7, 0.07, 0.1]} color="#2B2F3A" metal={0.5} />
767
+ <Emisivo pos={[2.45, 3.5, -0.1]} tam={[0.3, 0.08, 0.16]} color="#FFE2A8" fuerza={2.4} />
768
+ <pointLight position={[2.45, 3.2, -0.1]} color="#FFD9A0" intensity={3} distance={8} decay={1.4} castShadow />
769
+ {/* banca, bote e hidrante */}
770
+ <Bloque pos={[-2.9, 0.5, -2.3]} tam={[1.5, 0.08, 0.45]} color="#8A6A48" />
771
+ <Bloque pos={[-2.9, 0.85, -2.5]} tam={[1.5, 0.4, 0.06]} color="#8A6A48" />
772
+ {[-0.6, 0.6].map((l) => (
773
+ <Bloque key={l} pos={[-2.9 + l, 0.3, -2.3]} tam={[0.08, 0.36, 0.4]} color="#2B2F3A" />
774
+ ))}
775
+ <mesh position={[-4.3, 0.55, -2.3]}>
776
+ <cylinderGeometry args={[0.24, 0.2, 0.8, 12]} />
777
+ <meshStandardMaterial color="#2F5D4A" roughness={0.8} />
778
+ </mesh>
779
+ <mesh position={[4.2, 0.42, -0.4]}>
780
+ <cylinderGeometry args={[0.13, 0.15, 0.56, 10]} />
781
+ <meshStandardMaterial color="#C62E3A" roughness={0.6} />
782
+ </mesh>
783
+ {/* coche estacionado */}
784
+ <group position={[-3.4, 0, 1.5]}>
785
+ <Bloque pos={[0, 0.5, 0]} tam={[2.5, 0.5, 1.1]} color="#2D6BFF" radio={0.08} />
786
+ <Bloque pos={[-0.1, 0.95, 0]} tam={[1.3, 0.42, 1.0]} color="#1E2A44" radio={0.08} />
787
+ <Emisivo pos={[1.26, 0.52, 0.36]} tam={[0.03, 0.12, 0.2]} color="#FFF2C8" fuerza={0.5} />
788
+ <Emisivo pos={[1.26, 0.52, -0.36]} tam={[0.03, 0.12, 0.2]} color="#FFF2C8" fuerza={0.5} />
789
+ <Emisivo pos={[-1.26, 0.55, 0.36]} tam={[0.03, 0.1, 0.22]} color="#FF3B30" fuerza={0.9} />
790
+ <Emisivo pos={[-1.26, 0.55, -0.36]} tam={[0.03, 0.1, 0.22]} color="#FF3B30" fuerza={0.9} />
791
+ {[
792
+ [-0.8, 0.56],
793
+ [0.8, 0.56],
794
+ [-0.8, -0.56],
795
+ [0.8, -0.56],
796
+ ].map(([x, z], i) => (
797
+ <mesh key={i} position={[x, 0.26, z]} rotation={[Math.PI / 2, 0, 0]}>
798
+ <cylinderGeometry args={[0.26, 0.26, 0.16, 14]} />
799
+ <meshStandardMaterial color="#111216" roughness={0.9} />
800
+ </mesh>
801
+ ))}
802
+ </group>
803
+ <pointLight position={[-3.2, 2.6, 3.4]} color="#8FA8FF" intensity={1.6} distance={6} decay={1.5} />
804
+ {/* luna y lluvia (entre las fachadas y las cámaras: una gota pegada al lente se ve como una barra) */}
805
+ <mesh position={[-7, 12, -16]}>
806
+ <sphereGeometry args={[1.1, 18, 14]} />
807
+ <meshStandardMaterial color="#F4F1DE" emissive="#F4F1DE" emissiveIntensity={0.9} toneMapped={false} />
808
+ </mesh>
809
+ {Array.from({length: 90}, (_, i) => {
810
+ const y = 6 - ((t * (5.5 + azarN(i * 1.9) * 2.5) + azarN(i * 4.7) * 6) % 6);
811
+ return <Emisivo key={i} pos={[(azarN(i * 2.3) - 0.5) * 12, y, -2.2 + azarN(i * 5.9) * 3.5]} tam={[0.012, 0.22, 0.012]} color="#BFD4FF" fuerza={0.55} />;
812
+ })}
813
+ </group>
814
+ );
815
+
630
816
  const AMBIENTES: Record<NombreAmbiente, Def> = {
631
817
  'cuarto-gamer': {
632
818
  luz: 'noche',
@@ -731,6 +917,44 @@ const AMBIENTES: Record<NombreAmbiente, Def> = {
731
917
  persona: {pos: [-1.6, 0, -0.88], rot: [0, 180, 0], pose: 'sentado'},
732
918
  Set: SetOficina,
733
919
  },
920
+ tienda: {
921
+ luz: 'noche',
922
+ fondo: '#0C0E1A',
923
+ niebla: [10, 24],
924
+ post: 'cine',
925
+ planos: {
926
+ general: {pos: [3.3, 2.6, 3.7], mirar: [-0.4, 1.3, -2.2], fov: 50, a: {pos: [2.8, 2.4, 3.1]}},
927
+ persona: {pos: [1.7, 1.6, -2.85], mirar: [0.4, 1.4, -1.0], fov: 44, a: {pos: [1.6, 1.58, -2.7]}},
928
+ estantes: {pos: [-1.3, 1.7, -0.3], mirar: [-1.5, 1.5, -3.3], fov: 46, a: {pos: [-1.5, 1.7, -0.6]}},
929
+ mostrador: {pos: [1.0, 1.9, 0.9], mirar: [2.7, 1.15, -1.2], fov: 46, a: {pos: [1.2, 1.85, 0.6]}},
930
+ tele: {pos: [2.1, 1.9, -0.1], mirar: [2.8, 2.15, -3.35], fov: 44, a: {pos: [2.2, 1.9, -0.4]}},
931
+ isla: {pos: [-1.1, 1.9, 2.4], mirar: [-2.6, 0.95, 0.6], fov: 44, a: {pos: [-1.3, 1.85, 2.1]}},
932
+ },
933
+ orden: ['general', 'persona', 'estantes', 'mostrador', 'tele', 'isla'],
934
+ ordenFondo: ['estantes', 'mostrador', 'tele', 'isla'],
935
+ anclas: {estantes: [-1.5, 1.6, -3.2], mostrador: [2.6, 1.2, -1.2], caja: [3.1, 1.4, -1.2], tele: [2.8, 1.95, -3.3], isla: [-2.6, 1.1, 0.6], persona: [0.4, 1.6, -1.0]},
936
+ persona: {pos: [0.4, 0, -1.0], rot: [0, 180, 0], pose: 'parado'},
937
+ Set: SetTienda,
938
+ },
939
+ calle: {
940
+ luz: 'noche',
941
+ fondo: '#0A1024',
942
+ niebla: [14, 38],
943
+ post: 'cine',
944
+ planos: {
945
+ general: {pos: [3.6, 2.3, 4.8], mirar: [-0.2, 1.7, -2.6], fov: 52, a: {pos: [3.0, 2.2, 4.2]}},
946
+ persona: {pos: [2.45, 1.6, -2.6], mirar: [1.3, 1.4, -0.6], fov: 44, a: {pos: [2.35, 1.58, -2.45]}},
947
+ vitrina: {pos: [-1.7, 1.7, 2.3], mirar: [-0.5, 1.85, -3], fov: 50, a: {pos: [-1.6, 1.7, 1.9]}},
948
+ edificios: {pos: [-1.6, 1.3, 3.0], mirar: [-4.6, 4.6, -3], fov: 52, a: {pos: [-1.8, 1.35, 2.7]}},
949
+ coche: {pos: [-0.3, 1.5, 4.7], mirar: [-3.4, 0.8, 1.5], fov: 50, a: {pos: [-0.5, 1.5, 4.4]}},
950
+ farola: {pos: [0.9, 1.1, 1.8], mirar: [2.6, 3.1, -0.4], fov: 52, a: {pos: [1.0, 1.15, 1.5]}},
951
+ },
952
+ orden: ['general', 'persona', 'vitrina', 'edificios', 'coche', 'farola'],
953
+ ordenFondo: ['vitrina', 'edificios', 'coche', 'farola'],
954
+ anclas: {vitrina: [-0.6, 1.4, -3.0], letrero: [0.1, 3.05, -3.0], farola: [2.45, 3.5, -0.1], coche: [-3.4, 0.9, 1.5], edificios: [-5.6, 5, -3.1], persona: [1.3, 1.6, -0.6]},
955
+ persona: {pos: [1.3, 0, -0.6], rot: [0, 180, 0], pose: 'parado'},
956
+ Set: SetCalle,
957
+ },
734
958
  };
735
959
 
736
960
  export const NOMBRES_AMBIENTES = Object.keys(AMBIENTES) as NombreAmbiente[];
@@ -270,14 +270,14 @@ const Post: React.FC<{post: Post3D; estado: Estado}> = ({post, estado}) => {
270
270
  const dist = Math.hypot(estado.pos[0] - estado.mirar[0], estado.pos[1] - estado.mirar[1], estado.pos[2] - estado.mirar[2]);
271
271
  const rango = typeof o.foco === 'number' ? o.foco : Math.max(1.2, dist * 0.55);
272
272
  const efectos: React.ReactElement[] = [];
273
- if (o.foco) efectos.push(<DepthOfField key="f" target={estado.mirar} worldFocusRange={rango} bokehScale={4} resolutionScale={0.6} />);
273
+ if (o.foco) efectos.push(<DepthOfField key="f" target={estado.mirar} worldFocusRange={rango} bokehScale={4} resolutionScale={0.5} />);
274
274
  if (o.miniatura) efectos.push(<TiltShift2 key="m" blur={0.12 * o.miniatura} taper={0.6} start={[0.5, 0.0]} end={[0.5, 1.0]} />);
275
275
  if (o.brillo) efectos.push(<Bloom key="b" intensity={o.brillo} luminanceThreshold={0.85} luminanceSmoothing={0.2} mipmapBlur />);
276
276
  if (o.vinieta) efectos.push(<Vignette key="v" offset={0.3} darkness={o.vinieta} />);
277
277
  if (!efectos.length) return null;
278
278
  return (
279
279
  <>
280
- <EffectComposer multisampling={4}>{efectos}</EffectComposer>
280
+ <EffectComposer multisampling={2}>{efectos}</EffectComposer>
281
281
  <RedibujarAlMontar />
282
282
  </>
283
283
  );
@@ -475,7 +475,7 @@ export const VideoMotor: React.FC<{d: Datos; T: number; estilos?: Record<string,
475
475
  </AbsoluteFill>
476
476
  ) : null}
477
477
  {/* un ambiente puede ser claro (oficina): sombra arriba, donde van subtítulos y titular, para que el texto blanco se lea (en el cierre, todo el cuadro) */}
478
- {conAmbiente ? <AbsoluteFill style={{background: e.tipo === 'cta' ? 'rgba(0,0,0,0.42)' : 'linear-gradient(180deg, rgba(0,0,0,0.5) 0%, rgba(0,0,0,0.38) 30%, rgba(0,0,0,0) 46%)'}} /> : null}
478
+ {conAmbiente ? <AbsoluteFill style={{background: e.tipo === 'cta' ? 'rgba(0,0,0,0.55)' : 'linear-gradient(180deg, rgba(0,0,0,0.5) 0%, rgba(0,0,0,0.38) 30%, rgba(0,0,0,0) 46%)'}} /> : null}
479
479
  {enTransicion && prev ? (
480
480
  <AbsoluteFill style={estiloPrevio}>
481
481
  <EscenaVista e={prev} t={prev.dur + t} tema={tema} semilla={semilla} sinFondo={conMundo} />
@@ -32,7 +32,21 @@ type P = Record<string, any>;
32
32
 
33
33
  export const PLANTILLAS = ['capas', 'titular', 'dato', 'lista', 'telefono', 'comparar', 'eleccion', 'cta'];
34
34
 
35
+ /**
36
+ * Capas de una plantilla. Sobre un ambiente 3D el fondo cambia de toma en toma (ventanal naranja, pared gris), así
37
+ * que los textos sueltos (sin caja ni contorno propio) llevan contorno oscuro y los chicos una sombra más fuerte.
38
+ */
35
39
  export function capasDe(tipo: string, p: P, tema: Tema, marca: Marca): Capa[] {
40
+ const capas = capasPlantilla(tipo, p, tema, marca);
41
+ if (!tema.enAmbiente) return capas;
42
+ return capas.map((c) => {
43
+ if (c.tipo !== 'texto' || c.fondo || c.contorno != null) return c;
44
+ if (c.fuente === 'texto' || c.fuente === 'mono') return {...c, sombra: c.sombra ?? '0 2px 6px rgba(0,0,0,0.85), 0 0 18px rgba(0,0,0,0.6)'};
45
+ return {...c, contorno: 8, colorContorno: '#0A0A0E'};
46
+ });
47
+ }
48
+
49
+ function capasPlantilla(tipo: string, p: P, tema: Tema, marca: Marca): Capa[] {
36
50
  const extra: Capa[] = p.capas ?? [];
37
51
  switch (tipo) {
38
52
  case 'titular': {
@@ -144,12 +158,16 @@ export function capasDe(tipo: string, p: P, tema: Tema, marca: Marca): Capa[] {
144
158
  const out: Capa[] = [{tipo: 'marca', tam: 210, x: 540, y: 640, entrada: 'rebote'}];
145
159
  if (p.pide === 'comenta') {
146
160
  const palabra = String(p.palabra ?? 'SÍ').toUpperCase();
161
+ // si "Escribe PALABRA" se parte en dos renglones, el texto crece hacia abajo (arriba está el avatar) y
162
+ // lo demás baja con él
163
+ const textoCta = String(p.texto ?? `Escribe *${palabra}*`);
164
+ const bajaCta = (renglonesDe(textoCta.replace(/\*/g, ''), tema.fuentes.titulo, 110, 940, tema.tituloMayusculas) - 1) * 110 * 0.525;
147
165
  out.push(
148
- {tipo: 'texto', texto: p.texto ?? `Escribe *${palabra}*`, x: 540, y: 900, tam: 110, maxAncho: 940, entrada: 'subir', en: 0.2},
149
- ...(p.sub ? [{tipo: 'texto', texto: p.sub, fuente: 'texto', tam: 44, peso: 700, color: tema.colores.suave, x: 540, y: 1010, entrada: 'aparecer', en: 0.4, mayusculas: false}] : []),
166
+ {tipo: 'texto', texto: textoCta, x: 540, y: 900 + bajaCta, tam: 110, maxAncho: 940, entrada: 'subir', en: 0.2},
167
+ ...(p.sub ? [{tipo: 'texto', texto: p.sub, fuente: 'texto', tam: 44, peso: 700, color: tema.colores.suave, x: 540, y: 1010 + bajaCta * 2, entrada: 'aparecer', en: 0.4, mayusculas: false}] : []),
150
168
  // caja de comentario que se escribe sola y se envía
151
- {tipo: 'terminal', x: 540, y: 1220, ancho: 760, tam: 46, titulo: 'comentario', fondo: caja, colorTexto: letra, en: 0.5, entrada: 'subir', color: tema.colores.acento, lineas: [{texto: palabra, en: 0.9}]},
152
- {tipo: 'sello', texto: 'ENVIADO', x: 760, y: 1360, en: 2.1, color: tema.colores.acento, tam: 44},
169
+ {tipo: 'terminal', x: 540, y: 1220 + bajaCta * 2, ancho: 760, tam: 46, titulo: 'comentario', fondo: caja, colorTexto: letra, en: 0.5, entrada: 'subir', color: tema.colores.acento, lineas: [{texto: palabra, en: 0.9}]},
170
+ {tipo: 'sello', texto: 'ENVIADO', x: 760, y: 1360 + bajaCta * 2, en: 2.1, color: tema.colores.acento, tam: 44},
153
171
  );
154
172
  } else {
155
173
  out.push(
@@ -31,6 +31,8 @@ export type Tema = {
31
31
  };
32
32
  fuentes: {titulo: string; texto: string; mono: string};
33
33
  tituloMayusculas: boolean;
34
+ /** El video corre sobre una capa `ambiente` (lo pone el motor): los textos de las plantillas llevan contorno. */
35
+ enAmbiente?: boolean;
34
36
  subtitulos: {
35
37
  /**
36
38
  * karaoke: frase arriba, lo dicho en un color y lo pendiente tenue · seleccion: igual, pero solo la palabra clave
@@ -274,5 +276,6 @@ export function temaDe(estilo: string | undefined, ajustes: Record<string, any>
274
276
  return ESTILOS.minimal;
275
277
  };
276
278
  const base = resolver(estilo ?? 'minimal');
277
- return mezclar(oscuro && base.colores.texto !== '#FFFFFF' ? mezclar(base, SOBRE_NOCHE) : base, ajustes ?? {});
279
+ const tema = mezclar(oscuro && base.colores.texto !== '#FFFFFF' ? mezclar(base, SOBRE_NOCHE) : base, ajustes ?? {});
280
+ return oscuro ? {...tema, enAmbiente: true} : tema;
278
281
  }