morfeo 0.6.2 → 0.6.4

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
@@ -80,6 +80,47 @@ const conHuella = (config) => ({
80
80
  resolve: {...config.resolve, modules: [...(config.resolve?.modules ?? ['node_modules']), path.join(PAQUETE, 'node_modules')]},
81
81
  });
82
82
 
83
+ const PREFIJO_BUNDLE = 'remotion-webpack-bundle-';
84
+ const esBundleTemporal = (dir) => path.basename(dir).startsWith(PREFIJO_BUNDLE) && fs.realpathSync(path.dirname(dir)) === fs.realpathSync(os.tmpdir());
85
+
86
+ /** Bundles que dejó un render interrumpido (o versiones anteriores de Morfeo, que no los borraban): fuera si tienen más de un día. */
87
+ function limpiarBundlesViejos() {
88
+ try {
89
+ const limite = Date.now() - 24 * 3600 * 1000;
90
+ for (const n of fs.readdirSync(os.tmpdir())) {
91
+ if (!n.startsWith(PREFIJO_BUNDLE)) continue;
92
+ const dir = path.join(os.tmpdir(), n);
93
+ if (fs.statSync(dir).mtimeMs < limite) fs.rmSync(dir, {recursive: true, force: true});
94
+ }
95
+ } catch {}
96
+ }
97
+
98
+ /**
99
+ * Webpack guarda un caché (~300 MB) por cada huella del motor y de la configuración, y no borra los anteriores:
100
+ * tras varias actualizaciones eran varios GB. Se quedan los dos más recientes.
101
+ */
102
+ function podarCacheWebpack(raiz) {
103
+ try {
104
+ let dir = raiz;
105
+ while (!fs.existsSync(path.join(dir, 'node_modules', '.cache', 'webpack'))) {
106
+ const arriba = path.dirname(dir);
107
+ if (arriba === dir) return;
108
+ dir = arriba;
109
+ }
110
+ const base = path.join(dir, 'node_modules', '.cache', 'webpack');
111
+ for (const grupo of fs.readdirSync(base)) {
112
+ if (!grupo.startsWith('remotion-')) continue;
113
+ const g = path.join(base, grupo);
114
+ const viejos = fs
115
+ .readdirSync(g)
116
+ .map((n) => ({d: path.join(g, n), m: fs.statSync(path.join(g, n)).mtimeMs}))
117
+ .sort((x, y) => y.m - x.m)
118
+ .slice(2);
119
+ for (const v of viejos) fs.rmSync(v.d, {recursive: true, force: true});
120
+ }
121
+ } catch {}
122
+ }
123
+
83
124
  /**
84
125
  * Entrada de Remotion. Sin componentes propios es la del paquete; con ellos, Morfeo escribe
85
126
  * .morfeo/entrada.tsx que los importa y los registra antes de arrancar.
@@ -230,7 +271,7 @@ export function usaWebGL(p, id) {
230
271
  * @param {{proyecto?: string, ids?: string[], todos?: boolean, log?: (m: string) => void, alProgreso?: (id: string, p: number) => void}} o
231
272
  * @returns {Promise<{id: string, mp4: string, jpg: string | null, segundos: number}[]>}
232
273
  */
233
- export async function render({proyecto = process.cwd(), ids, todos = false, soloFallidos = false, previa = null, animatica = false, escenas = null, log = console.log, alProgreso} = {}) {
274
+ 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
275
  // animática: todo el video a 1/3 de tamaño y menor calidad (para aprobar tomas y ritmo en segundos)
235
276
  // escenas: [a, b] (1 = la primera) solo ese tramo, en tamaño completo
236
277
  const parcial = !!(animatica || escenas);
@@ -250,15 +291,37 @@ export async function render({proyecto = process.cwd(), ids, todos = false, solo
250
291
  }
251
292
  // archivos ya hechos con el id o con un título viejo → su nombre actual (sin volver a renderizar)
252
293
  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)));
294
+ let lista = ids?.length ? ids : todos || cambiados ? Object.keys(p.videos) : Object.keys(p.videos).filter((id) => !fs.existsSync(salidaDe(p, id)));
254
295
  const malos = lista.filter((id) => !p.videos[id]);
255
296
  if (malos.length) throw new ErrorMorfeo(`No existen: ${malos.join(', ')}. Usa: npx morfeo lista`);
256
297
  if (!lista.length) {
257
298
  log('No hay videos pendientes. Para regenerar: npx morfeo render --todos (o npx morfeo render <id>)');
258
299
  return [];
259
300
  }
301
+ // --cambiados: solo los videos cuyo guion o componentes del proyecto cambiaron desde su último render. Se decide
302
+ // con el guion, antes de preparar el audio (prepararlo para 30 videos tarda más que renderizar uno).
303
+ // La versión de Morfeo no cuenta (rehacería todo en cada actualización): para eso está --todos.
304
+ const huellas = leerHuellas(p);
305
+ const delProyecto = parcial || previa ? '' : huellaProyecto(p);
306
+ 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);
307
+ if (cambiados && !ids?.length) {
308
+ const antes = lista.length;
309
+ // la primera vez no hay huella de los videos ya hechos: se dan por buenos y se anota la de ahora
310
+ let nuevas = 0;
311
+ for (const id of lista)
312
+ if (huellas[id] == null && fs.existsSync(salidaDe(p, id))) {
313
+ huellas[id] = huellaDe(id);
314
+ nuevas++;
315
+ }
316
+ if (nuevas) guardarHuellas(p, huellas);
317
+ lista = lista.filter((id) => huellas[id] !== huellaDe(id) || !fs.existsSync(salidaDe(p, id)));
318
+ log(lista.length ? `${lista.length} de ${antes} video(s) cambiaron: ${lista.join(', ')}` : `Ningún video cambió desde su último render (${antes} al día).`);
319
+ if (!lista.length) return [];
320
+ }
260
321
  if (!whisperListo()) log('(Whisper no está instalado: subtítulos repartidos por frase. Para tiempos exactos: npx morfeo instalar --whisper)');
322
+ const tPrep = Date.now();
261
323
  const {publico, marca, extras, datos, fallidos} = await preparar({proyecto, ids: lista, log, _p: p, continuar: true});
324
+ const segPrep = (Date.now() - tPrep) / 1000;
262
325
  const listos = lista.filter((id) => datos[id]);
263
326
  // estructura: consejos medidos en videos virales (no detienen el render; se apagan con render.consejos: false)
264
327
  if (p.render.consejos !== false)
@@ -272,17 +335,25 @@ export async function render({proyecto = process.cwd(), ids, todos = false, solo
272
335
  // cuadros pedazos de texto de cuadros anteriores (rótulos o subtítulos fantasma)
273
336
  const hechos = [];
274
337
  const soltar = await tomarCandado(log);
338
+ let serveUrl;
275
339
  try {
276
340
  log('Empaquetando la animación...');
277
341
  const {bundle} = await import('@remotion/bundler');
278
342
  const {renderMedia, renderStill, selectComposition} = await import('@remotion/renderer');
279
343
  const entrada = entradaDe(p);
280
- const serveUrl = await bundle({entryPoint: entrada, publicDir: publico, rootDir: entrada === ENTRADA_REMOTION ? path.dirname(ENTRADA_REMOTION) : p.proyecto, webpackOverride: conHuella});
344
+ const tBundle = Date.now();
345
+ limpiarBundlesViejos();
346
+ const raizBundle = entrada === ENTRADA_REMOTION ? path.dirname(ENTRADA_REMOTION) : p.proyecto;
347
+ serveUrl = await bundle({entryPoint: entrada, publicDir: publico, rootDir: raizBundle, webpackOverride: conHuella});
348
+ podarCacheWebpack(raizBundle);
349
+ const segBundle = (Date.now() - tBundle) / 1000;
281
350
  fs.mkdirSync(p.salida, {recursive: true});
282
351
  const chromiumOptions = {gl: p.render.gl};
283
352
  const browserExecutable = p.render.navegador ?? (await asegurarNavegador(log));
284
353
  // cuadros en paralelo: con GPU aguanta más; por software cada pestaña come mucha CPU y memoria
285
354
  const nucleos = os.cpus().length;
355
+ // MORFEO_CONCURRENCIA=n: fija los cuadros en paralelo sin tocar el config (para medir en tu máquina)
356
+ if (process.env.MORFEO_CONCURRENCIA) p.render.concurrencia = Math.max(1, Number(process.env.MORFEO_CONCURRENCIA) || 1);
286
357
  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
358
  const sinTTY = !process.stdout.isTTY;
288
359
  for (const [i, id] of listos.entries()) {
@@ -317,6 +388,8 @@ export async function render({proyecto = process.cwd(), ids, todos = false, solo
317
388
  audioBitrate: '192k',
318
389
  imageFormat: 'jpeg',
319
390
  jpegQuality: 92,
391
+ // 'faster': ~10 % menos tiempo que 'medium' y, con el mismo CRF, sin diferencia visible
392
+ x264Preset: 'faster',
320
393
  concurrency: concurrencia,
321
394
  outputLocation: mp4,
322
395
  ...(previa ? {frameRange: [0, Math.min(composition.durationInFrames - 1, Math.round(finPrevia * composition.fps))]} : {}),
@@ -346,6 +419,7 @@ export async function render({proyecto = process.cwd(), ids, todos = false, solo
346
419
  log(`\n el navegador falló; reintento con ${gl === 'angle' ? 'GPU' : 'CPU'} y ${concurrencia} cuadro(s) a la vez...`);
347
420
  }
348
421
  }
422
+ const segVideo = (Date.now() - t) / 1000;
349
423
  let jpg = null;
350
424
  if (p.render.miniatura && !previa && !parcial) {
351
425
  // miniatura: cuando la primera escena ya está completa (o en `miniaturaEn`), sin subtítulos
@@ -371,7 +445,13 @@ export async function render({proyecto = process.cwd(), ids, todos = false, solo
371
445
  const segundos = Math.round((Date.now() - t) / 1000);
372
446
  if (process.stdout.isTTY) process.stdout.write('\r');
373
447
  log(`[${i + 1}/${listos.length}] ${id}: listo en ${segundos} s → ${path.relative(p.proyecto, mp4)} `);
448
+ // desglose para saber qué optimizar (MORFEO_TIEMPOS=1): audio y empaquetado son de todo el lote
449
+ 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
450
  hechos.push({id, mp4, jpg, segundos});
451
+ if (!previa && !parcial) {
452
+ huellas[id] = huellaDe(id);
453
+ guardarHuellas(p, huellas);
454
+ }
375
455
  } catch (e) {
376
456
  if (process.stdout.isTTY) process.stdout.write('\n');
377
457
  fallidos.push({id, error: mensajeDe(e)});
@@ -379,12 +459,47 @@ export async function render({proyecto = process.cwd(), ids, todos = false, solo
379
459
  }
380
460
  }
381
461
  } finally {
462
+ // el bundle es una copia de la animación y de public/ (cientos de MB) en la carpeta temporal: Remotion no la borra
463
+ if (serveUrl && esBundleTemporal(serveUrl)) fs.rmSync(serveUrl, {recursive: true, force: true});
382
464
  soltar();
383
465
  }
384
466
  if (previa || parcial) return hechos;
385
467
  return terminarLote(p, loteArchivo, hechos, fallidos, log);
386
468
  }
387
469
 
470
+ // Huella de cada video al renderizarlo (.morfeo/huellas.json): con ella `render --cambiados` sabe cuáles rehacer.
471
+ const archivoHuellas = (p) => path.join(p.trabajo, 'huellas.json');
472
+ function leerHuellas(p) {
473
+ try {
474
+ return JSON.parse(fs.readFileSync(archivoHuellas(p), 'utf8'));
475
+ } catch {
476
+ return {};
477
+ }
478
+ }
479
+ function guardarHuellas(p, h) {
480
+ fs.mkdirSync(p.trabajo, {recursive: true});
481
+ fs.writeFileSync(archivoHuellas(p), JSON.stringify(h, null, '\t'));
482
+ }
483
+ /** Componentes y config del proyecto (tsx/ts/jsx y morfeo.config.mjs): si cambian, cambia cómo se ve el video. */
484
+ function huellaProyecto(p) {
485
+ const partes = [];
486
+ const recorrer = (dir, n = 0) => {
487
+ if (n > 4) return;
488
+ for (const e of fs.readdirSync(dir, {withFileTypes: true}).sort((a, b) => a.name.localeCompare(b.name))) {
489
+ if (e.name.startsWith('.') || e.name === 'node_modules' || e.name === 'guiones') continue;
490
+ const f = path.join(dir, e.name);
491
+ if (path.resolve(f) === path.resolve(p.salida)) continue;
492
+ if (e.isDirectory()) recorrer(f, n + 1);
493
+ else if (/\.(tsx?|jsx)$/.test(e.name) || e.name === 'morfeo.config.mjs') {
494
+ const st = fs.statSync(f);
495
+ partes.push(`${e.name}:${st.size}:${Math.round(st.mtimeMs)}`);
496
+ }
497
+ }
498
+ };
499
+ recorrer(p.proyecto);
500
+ return crypto.createHash('sha1').update(partes.join('|')).digest('hex').slice(0, 8);
501
+ }
502
+
388
503
  const CRF = {alta: 18, media: 21, ligera: 24};
389
504
  /** Cuadros [inicio, fin] de las escenas a..b (1 = la primera). */
390
505
  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.4",
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
  }