morfeo 0.6.1 → 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];
@@ -87,7 +87,7 @@ export type Capa = CapaBase &
87
87
  | {tipo: 'fuente'; texto: string; etiqueta?: string; tam?: number; color?: string; colorTexto?: string}
88
88
  | {tipo: 'seccion'; texto: string; tam?: number; color?: string}
89
89
  | {tipo: 'insignia'; texto: string | number; tam?: number; fondo?: string; colorTexto?: string; inclinar?: number}
90
- | {tipo: 'sello'; texto: string; tam?: number; color?: string; inclinar?: number}
90
+ | {tipo: 'sello'; texto: string; tam?: number; color?: string; inclinar?: number; /** caja detrás (sobre fondos claros) */ fondo?: string}
91
91
  | {tipo: 'terminal'; lineas: (string | {texto: string; en?: Momento; salida?: boolean; color?: string})[]; titulo?: string; ancho?: number; tam?: number; fondo?: string; velocidad?: number}
92
92
  | {tipo: 'nombre'; texto: string; sub?: string; tam?: number; fondo?: string; colorTexto?: string; borde?: string}
93
93
  | {tipo: 'accion'; texto: string; tam?: number; color?: string; fondo?: string; colorTexto?: string}
@@ -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/calidad.mjs CHANGED
@@ -149,7 +149,9 @@ export async function revisarCuadros(mp4, d) {
149
149
  // medido: escenas sin nada sobre fondo oscuro → media 13–15 y la cubeta más oscura con 97 % del cuadro;
150
150
  // tele o pared pegadas a la cámara → desviación 12–16 con media > 130; los videos buenos del canal: desviación ≥ 27
151
151
  negro: (s) => (s.media < 16 && s.desv < 9) || (s.media < 25 && s.dom > 0.9),
152
- tapado: (s) => (s.dom > 0.58 && s.luzDom > 45) || (s.desv < 18 && s.media > 60),
152
+ // (el segundo caso pide además un color dominante: una escena de día con cielo pastel también tiene desviación
153
+ // baja, ~17, pero su color más repetido no pasa del 25 %)
154
+ tapado: (s) => (s.dom > 0.58 && s.luzDom > 45) || (s.desv < 18 && s.media > 60 && s.dom > 0.4),
153
155
  plano: (s) => s.desv < 14 && s.media >= 25 && s.media <= 60,
154
156
  };
155
157
  const TEXTO = {
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.1",
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',
@@ -686,7 +872,8 @@ const AMBIENTES: Record<NombreAmbiente, Def> = {
686
872
  espalda: {pos: [2.3, 1.9, 1.3], mirar: [-0.2, 1.35, -3.4], fov: 46, a: {pos: [2.15, 1.85, 1.0]}},
687
873
  },
688
874
  orden: ['general', 'persona', 'maquina', 'espalda', 'pasillo'],
689
- ordenFondo: ['general', 'espalda', 'pasillo', 'maquina'],
875
+ // general y espalda dejan a la persona al centro, justo donde caen las capas 2D
876
+ ordenFondo: ['pasillo', 'maquina'],
690
877
  anclas: {maquina: [0.6, 1.42, -3.0], persona: [0.6, 1.6, -0.9], neon: [2.6, 2.9, -3.88], pasillo: [-4.4, 1.4, -0.6]},
691
878
  persona: {pos: [0.6, 0, -0.95], rot: [0, 180, 0], pose: 'parado'},
692
879
  Set: SetArcade,
@@ -730,6 +917,44 @@ const AMBIENTES: Record<NombreAmbiente, Def> = {
730
917
  persona: {pos: [-1.6, 0, -0.88], rot: [0, 180, 0], pose: 'sentado'},
731
918
  Set: SetOficina,
732
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
+ },
733
958
  };
734
959
 
735
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
  );
@@ -413,7 +413,8 @@ const MarcaEsquina: React.FC<{tema: Tema}> = ({tema}) => {
413
413
  };
414
414
 
415
415
  export const VideoMotor: React.FC<{d: Datos; T: number; estilos?: Record<string, any>; miniatura?: boolean; sinMarca?: boolean}> = ({d, T, estilos, miniatura, sinMarca}) => {
416
- const tema = temaDe(d.estilo, d.tema, estilos);
416
+ const conAmbiente = !!d.mundo?.some((c: any) => c.tipo === 'ambiente');
417
+ const tema = temaDe(d.estilo, d.tema, estilos, conAmbiente);
417
418
  const n = d.escenas.length;
418
419
  let si = d.escenas.findIndex((e) => T < e.t0 + e.dur);
419
420
  if (si < 0) si = n - 1;
@@ -473,6 +474,8 @@ export const VideoMotor: React.FC<{d: Datos; T: number; estilos?: Record<string,
473
474
  <Mundo d={d} si={si} t={t} T={T} tema={tema} />
474
475
  </AbsoluteFill>
475
476
  ) : null}
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.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}
476
479
  {enTransicion && prev ? (
477
480
  <AbsoluteFill style={estiloPrevio}>
478
481
  <EscenaVista e={prev} t={prev.dur + t} tema={tema} semilla={semilla} sinFondo={conMundo} />
@@ -204,6 +204,7 @@ export const Anotacion: React.FC<{c: Capa; ctx: CtxEscena}> = ({c, ctx}) => {
204
204
  border: `${Math.max(4, tam * 0.1)}px solid ${color}`,
205
205
  outline: `${Math.max(2, tam * 0.04)}px solid ${color}`,
206
206
  outlineOffset: tam * 0.1,
207
+ background: c.fondo,
207
208
  borderRadius: tam * 0.2,
208
209
  padding: `${tam * 0.12}px ${tam * 0.42}px`,
209
210
  fontFamily: T.fuentes.titulo,
@@ -11,21 +11,57 @@
11
11
  import type {Marca} from '../tipos';
12
12
  import type {Capa} from './capas';
13
13
  import type {Tema} from './tema';
14
+ import {ancho} from '../estilo';
15
+
16
+ /** Renglones que ocupa un texto al partirse por palabras en `maxAncho` (como lo hace el navegador). */
17
+ const renglonesDe = (texto: string, fuente: string, tam: number, maxAncho: number, mayus?: boolean) => {
18
+ const palabras = (mayus ? texto.toUpperCase() : texto).split(/\s+/).filter(Boolean);
19
+ let n = 1;
20
+ let linea = '';
21
+ for (const w of palabras) {
22
+ const prueba = linea ? `${linea} ${w}` : w;
23
+ if (linea && ancho(prueba, fuente, tam, 800) > maxAncho) {
24
+ n++;
25
+ linea = w;
26
+ } else linea = prueba;
27
+ }
28
+ return n;
29
+ };
14
30
 
15
31
  type P = Record<string, any>;
16
32
 
17
33
  export const PLANTILLAS = ['capas', 'titular', 'dato', 'lista', 'telefono', 'comparar', 'eleccion', 'cta'];
18
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
+ */
19
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[] {
20
50
  const extra: Capa[] = p.capas ?? [];
21
51
  switch (tipo) {
22
52
  case 'titular': {
23
53
  const y = p.y ?? 560;
24
54
  const out: Capa[] = [];
25
- if (p.etiqueta) out.push({tipo: 'etiqueta', texto: p.etiqueta, x: 80, y: y - 92, ancla: 'izquierda', entrada: 'aparecer'});
26
- out.push({tipo: 'texto', texto: p.texto ?? '', x: 80, y, ancla: 'izquierda', alinear: 'left', tam: p.tam ?? 104, maxAncho: p.numero != null ? 800 : 920, entrada: 'subir', en: p.en});
55
+ const tam = p.tam ?? 104;
56
+ const maxAncho = p.numero != null ? 800 : 920;
57
+ // el texto va centrado en `y`: si se parte en 2+ renglones crece hacia arriba y abajo, así que la etiqueta
58
+ // y el subtítulo se separan medio renglón por cada renglón extra (ajuste por palabras con la fuente real)
59
+ const renglones = renglonesDe(String(p.texto ?? '').replace(/\*/g, ''), tema.fuentes.titulo, tam, maxAncho, tema.tituloMayusculas);
60
+ const extraY = (renglones - 1) * tam * 0.525;
61
+ if (p.etiqueta) out.push({tipo: 'etiqueta', texto: p.etiqueta, x: 80, y: y - 92 - extraY, ancla: 'izquierda', entrada: 'aparecer'});
62
+ out.push({tipo: 'texto', texto: p.texto ?? '', x: 80, y, ancla: 'izquierda', alinear: 'left', tam, maxAncho, entrada: 'subir', en: p.en});
27
63
  if (p.numero != null) out.push({tipo: 'insignia', texto: String(p.numero), x: 1000, y, ancla: 'derecha', entrada: 'rebote', en: 0.15});
28
- if (p.sub) out.push({tipo: 'texto', texto: p.sub, fuente: 'texto', tam: 44, peso: 600, color: tema.colores.suave, x: 80, y: y + 110, ancla: 'izquierda', alinear: 'left', entrada: 'aparecer', en: 0.3});
64
+ if (p.sub) out.push({tipo: 'texto', texto: p.sub, fuente: 'texto', tam: 44, peso: 600, color: tema.colores.suave, x: 80, y: y + 110 + extraY, ancla: 'izquierda', alinear: 'left', entrada: 'aparecer', en: 0.3});
29
65
  return [...out, ...extra];
30
66
  }
31
67
  case 'dato': {
@@ -122,12 +158,16 @@ export function capasDe(tipo: string, p: P, tema: Tema, marca: Marca): Capa[] {
122
158
  const out: Capa[] = [{tipo: 'marca', tam: 210, x: 540, y: 640, entrada: 'rebote'}];
123
159
  if (p.pide === 'comenta') {
124
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;
125
165
  out.push(
126
- {tipo: 'texto', texto: p.texto ?? `Escribe *${palabra}*`, x: 540, y: 900, tam: 110, maxAncho: 940, entrada: 'subir', en: 0.2},
127
- ...(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}] : []),
128
168
  // caja de comentario que se escribe sola y se envía
129
- {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}]},
130
- {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},
131
171
  );
132
172
  } else {
133
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
@@ -252,13 +254,28 @@ export const mezclar = <T,>(a: T, b: any): T => {
252
254
  return out;
253
255
  };
254
256
 
255
- /** Tema final: estilo base (o uno propio del config, que puede heredar con `base`) + ajustes del video. */
256
- export function temaDe(estilo: string | undefined, ajustes: Record<string, any> | undefined, propios: Record<string, any> = {}): Tema {
257
+ /**
258
+ * Estilos de papel o cielo (texto oscuro) sobre un ambiente 3D de noche: el texto pasa a claro para que se lea.
259
+ * Los ajustes `tema` del video se aplican después, así que siguen mandando.
260
+ */
261
+ const SOBRE_NOCHE = {
262
+ textura: 'ninguna',
263
+ colores: {texto: '#FFFFFF', suave: 'rgba(255,255,255,0.6)', tarjeta: '#1C1D24', tarjetaTexto: '#FFFFFF', borde: '#FFFFFF'},
264
+ subtitulos: {color: '#FFFFFF', pendiente: 'rgba(255,255,255,0.5)'},
265
+ };
266
+
267
+ /**
268
+ * Tema final: estilo base (o uno propio del config, que puede heredar con `base`) + ajustes del video.
269
+ * `oscuro`: el video corre sobre un ambiente de noche (capa `ambiente` en el mundo).
270
+ */
271
+ export function temaDe(estilo: string | undefined, ajustes: Record<string, any> | undefined, propios: Record<string, any> = {}, oscuro = false): Tema {
257
272
  const resolver = (nombre: string, n = 0): Tema => {
258
273
  if (ESTILOS[nombre]) return ESTILOS[nombre];
259
274
  const p = propios[nombre];
260
275
  if (p && n < 5) return mezclar(resolver(p.base ?? 'minimal', n + 1), p);
261
276
  return ESTILOS.minimal;
262
277
  };
263
- return mezclar(resolver(estilo ?? 'minimal'), ajustes ?? {});
278
+ const base = resolver(estilo ?? 'minimal');
279
+ const tema = mezclar(oscuro && base.colores.texto !== '#FFFFFF' ? mezclar(base, SOBRE_NOCHE) : base, ajustes ?? {});
280
+ return oscuro ? {...tema, enAmbiente: true} : tema;
264
281
  }