morfeo 0.3.4 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/bin/morfeo.mjs CHANGED
@@ -13,7 +13,7 @@ Primeros pasos
13
13
  instalar Prepara tu computadora: Python, voces, navegador y (opcional) Whisper
14
14
  --si responde "sí" a todo --whisper / --sin-whisper
15
15
  nuevo <carpeta> Crea un proyecto de ejemplo listo para renderizar
16
- nuevo-video <id> Crea un video desde una base que ya renderiza (guion + mundo): --look 2d | 3d
16
+ nuevo-video <id> Crea un video desde una base que ya renderiza: --look 2d | 3d | serie | biografia | escalada | mito | construiste
17
17
  doctor Revisa qué está instalado y qué falta
18
18
 
19
19
  Videos
@@ -33,6 +33,8 @@ Videos
33
33
  Guiones
34
34
  guion "tema" La IA (Claude) escribe un video completo sobre el tema
35
35
  guion --desde f.txt Convierte tu guion en escenas (con IA; --sin-ia: una escena por frase)
36
+ --formula <id> una fórmula de historia (construiste, serie, mito, misterio…)
37
+ formulas Las fórmulas de historia medidas en videos de referencia
36
38
  --estilo documental estilo visual (por defecto, el del proyecto)
37
39
  --imagenes pide ilustraciones con IA (necesita OPENAI_API_KEY)
38
40
  --id nombre --voz em_alex --duracion 40 --idioma "inglés" --tono "humor"
@@ -53,7 +55,7 @@ function opciones(argv) {
53
55
  const [k, v] = a.slice(2).split('=');
54
56
  const clave = k.replace(/-([a-z])/g, (_, c) => c.toUpperCase());
55
57
  const sig = argv[i + 1];
56
- const conValor = ['proyecto', 'desde', 'id', 'voz', 'duracion', 'idioma', 'estilo', 'tono', 'variante', 'fuente', 'musica', 'titulo', 'modelo', 'puerto', 'hasta', 'cols', 'filas', 'look'];
58
+ const conValor = ['proyecto', 'desde', 'id', 'voz', 'duracion', 'idioma', 'estilo', 'tono', 'variante', 'fuente', 'musica', 'titulo', 'modelo', 'puerto', 'hasta', 'cols', 'filas', 'look', 'formula'];
57
59
  if (v != null) op[clave] = v;
58
60
  else if (conValor.includes(clave) && sig != null && !sig.startsWith('--')) op[clave] = argv[++i];
59
61
  else op[clave] = true;
@@ -87,12 +89,11 @@ async function main() {
87
89
  return nuevo(pos[0], op);
88
90
  case 'nuevo-video': {
89
91
  const {nuevoVideo} = await import('../lib/nuevo-video.mjs');
90
- const look = op.look === true || op.look == null ? '2d' : String(op.look).toLowerCase();
92
+ const look = typeof op.formula === 'string' ? op.formula.toLowerCase() : op.look === true || op.look == null ? '2d' : String(op.look).toLowerCase();
91
93
  const r = nuevoVideo({proyecto, id: pos[0], look});
92
94
  const rel = (f) => path.relative(process.cwd(), f);
93
95
  console.log(`Listo (${look}):
94
- ${rel(r.guion)} el guion: texto, tomas, rótulos y capas
95
- ${rel(r.escena)} el mundo (${r.componente}, ya registrado en morfeo.config.mjs)
96
+ ${rel(r.guion)} el guion: texto, tomas, rótulos y capas${r.escena ? `\n ${rel(r.escena)} el mundo (${r.componente}, ya registrado en morfeo.config.mjs)` : ''}
96
97
 
97
98
  Pruébalo: npx morfeo render ${pos[0]} y revísalo: npx morfeo hoja ${pos[0]}`);
98
99
  return;
@@ -191,6 +192,13 @@ Pruébalo: npx morfeo render ${pos[0]} y revísalo: npx morfeo hoja ${pos[0]
191
192
  console.log('\nÚsalo con estilo: "documental" en el video o en morfeo.config.mjs. Crea los tuyos con estilos: {...} en el config.');
192
193
  return;
193
194
  }
195
+ case 'formulas':
196
+ case 'formula': {
197
+ const {FORMULAS} = await import('../lib/formulas.mjs');
198
+ for (const f of FORMULAS) console.log(`${f.id.padEnd(14)} ${f.nombre} (${f.duracion[0]}–${f.duracion[1]} s · ${f.estilos.join(', ')})\n${' '.repeat(15)}${f.para}\n${' '.repeat(15)}Gancho: ${f.gancho}\n`);
199
+ console.log('Úsala: npx morfeo guion "tu tema" --formula construiste (o formula: \'construiste\' en el guion para que `revisar` la revise)');
200
+ return;
201
+ }
194
202
  case 'guion':
195
203
  case 'script':
196
204
  return guion(pos, op, proyecto);
@@ -225,7 +233,7 @@ async function guion(pos, op, proyecto) {
225
233
  if (texto && op.sinIa) r = guionSinIA(texto, {id: op.id, voz: op.voz, musica: op.musica, titulo: op.titulo, estilo});
226
234
  else {
227
235
  console.log(texto ? 'Claude está convirtiendo tu guion en escenas...' : `Claude está escribiendo un video sobre: ${tema}...`);
228
- r = await guionConIA({tema, texto, idioma: op.idioma, duracion: op.duracion ? Number(op.duracion) : undefined, tono: op.tono, estilo, imagenes: !!op.imagenes, voz: op.voz, modelo: op.modelo});
236
+ r = await guionConIA({tema, texto, formula: op.formula, idioma: op.idioma, duracion: op.duracion ? Number(op.duracion) : undefined, tono: op.tono, estilo, imagenes: !!op.imagenes, voz: op.voz, modelo: op.modelo});
229
237
  }
230
238
  if (op.id) r.id = op.id;
231
239
  const {cargarProyecto} = await import('../lib/config.mjs');
package/index.d.ts CHANGED
@@ -69,6 +69,9 @@ interface CapaBase {
69
69
  cambios?: ({en: Momento | {escena: number}} & Record<string, unknown>)[];
70
70
  }
71
71
 
72
+ /** Mitad de una pantalla dividida: sus capas usan coordenadas de 1080×1920 recortadas a su mitad. */
73
+ export interface Mitad {etiqueta?: string; color?: string; fondo?: string; capas?: Capa[]}
74
+
72
75
  export type Capa = CapaBase &
73
76
  (
74
77
  | {tipo: 'imagen'; src?: string; generar?: string; ancho?: number; alto?: number; redondo?: boolean; radio?: number; cambiarA?: {src: string; en?: Momento; efecto?: 'fundido' | 'barrido' | 'circulo'; duracion?: number}}
@@ -92,6 +95,16 @@ export type Capa = CapaBase &
92
95
  | {tipo: 'recipientes'; n?: number; valor?: number; forma?: 'garrafon' | 'vaso'; total?: string; color?: string; tamPieza?: number; duracion?: number}
93
96
  | {tipo: 'ficha'; titulo?: string; foto?: string; campos: [string, string][]; ancho?: number; color?: string; fondoFoto?: string}
94
97
  | {tipo: 'checklist'; items: (string | {texto: string; en?: Momento; pendiente?: boolean})[]; titulo?: string; tam?: number; color?: string; cada?: number}
98
+ | {tipo: 'bloqueo'; hora?: string; fecha?: string; /** Año abajo de la hora; [2026, 2031] salta de uno a otro en `anioEn`. */ anio?: number | string | (number | string)[]; anioEn?: Momento; notificaciones?: {app?: string; titulo?: string; texto?: string; en?: Momento}[]; fondo?: string; ancho?: number}
99
+ | {tipo: 'llamada'; estado?: 'entrante' | 'curso' | 'terminada'; nombre?: string; numero?: string; aviso?: string | false; /** Segundos con los que arranca el contador (curso) o duración final (terminada). */ desde?: number; duracion?: number; fondo?: string; ancho?: number}
100
+ | {tipo: 'recuento'; cabecera?: string; titulo?: string; opciones: {texto: string; valor: number; sub?: string}[]; color?: string; ancho?: number}
101
+ | {tipo: 'capitulo'; serie?: string; capitulo?: string; pregunta?: string; opciones?: {texto: string; sub?: string}[]; pie?: string; siguiente?: string; horario?: string; color?: string; ancho?: number}
102
+ | {tipo: 'vigilancia'; hora?: string; fecha?: string; etiqueta?: string; zoom?: number; mira?: boolean; miraX?: number; miraY?: number; color?: string}
103
+ | {tipo: 'cintillo'; nombre: string; cargo?: string; color?: string; tam?: number}
104
+ | {tipo: 'fecha'; anio?: number; de?: number; texto?: string; pie?: string; color?: string; tam?: number}
105
+ | {tipo: 'calendario'; titulo?: string; de?: number; a?: number; color?: string; tam?: number; duracion?: number}
106
+ | {tipo: 'dividida'; izq?: Mitad; der?: Mitad; arriba?: Mitad; abajo?: Mitad; vertical?: boolean; fondo?: string; linea?: string}
107
+ | {tipo: 'tierra'; lat: number; lon: number; etiqueta?: string; tam?: number; color?: string; girar?: boolean}
95
108
  | {tipo: 'mapa'; ruta?: [number, number][]; distancia?: string; ancho?: number; color?: string; fondo?: string; inclinar?: number; duracion?: number}
96
109
  | {tipo: 'cursor'; a: [number, number]; clic?: Momento; duracion?: number; tam?: number; color?: string}
97
110
  | {tipo: 'texto'; texto: string; tam?: number; color?: string; acento?: string; fuente?: 'titulo' | 'texto' | 'mono' | (string & {}); peso?: number; mayusculas?: boolean; contorno?: number; colorContorno?: string; fondo?: string; relleno?: string; radio?: number; maxAncho?: number | 'auto'; /** Achica la letra si no cabe (palabra más larga o más de `maxLineas` renglones). Por defecto sí. */ ajustar?: boolean; maxLineas?: number; alinear?: 'center' | 'left' | 'right'; interlineado?: number; escribir?: boolean | number; subrayado?: boolean}
@@ -176,6 +189,8 @@ interface EscenaMotorBase extends EscenaBase {
176
189
  modoEn?: Momento;
177
190
  /** Palabra o segundos en que vuelve a la imagen normal. */
178
191
  modoHasta?: Momento;
192
+ /** Lugar (de `lugares` del video) donde pasa esta escena. */
193
+ lugar?: string;
179
194
  /** Invertir la imagen en el modo (fondo claro → oscuro). Por defecto sí en looks claros (3d, maqueta, explicativo, infografia, minimal). */
180
195
  modoInvertir?: boolean;
181
196
  /** Palabras clave de la escena para los subtítulos (la que lleva caja o color de acento en cada frase). */
@@ -267,6 +282,12 @@ export interface Video {
267
282
  * El componente recibe además `escena` (1, 2, 3...), `tEscena`, `inicios` y `camara` (para paralaje).
268
283
  */
269
284
  mundo?: Capa[];
285
+ /**
286
+ * Varios mundos en un video (biografías, historias que cambian de sitio): `{cocina: [...], boda: [...]}`. Cada escena
287
+ * elige uno con `lugar: 'boda'` (o sigue en el anterior). Cada lugar tiene su propia cámara; al cambiar, corta o usa
288
+ * la `transicion` de la escena.
289
+ */
290
+ lugares?: Record<string, Capa[]>;
270
291
  /** Pastilla fija arriba para series: 'GYM CON CIENCIA · PARTE 5'. */
271
292
  serie?: string;
272
293
  /** Calidad de este video (ver `render.calidad`). */
@@ -274,6 +295,11 @@ export interface Video {
274
295
  /** Para publicar: descripción y etiquetas (van a salida/<id>.txt junto con el título). */
275
296
  descripcion?: string;
276
297
  tags?: string[];
298
+ /**
299
+ * Fórmula de historia (ver `npx morfeo formulas`): 'construiste' | 'recomendable' | 'escalada' | 'supervivencia' |
300
+ * 'experimento' | 'misterio' | 'demuestralo' | 'mito' | 'roast' | 'serie' | 'biografia'. `revisar` da consejos propios.
301
+ */
302
+ formula?: 'construiste' | 'recomendable' | 'escalada' | 'supervivencia' | 'experimento' | 'misterio' | 'demuestralo' | 'mito' | 'roast' | 'serie' | 'biografia';
277
303
  /** 'largo' = explicador de 90–130 s (lo asume el estilo maqueta): cambia las reglas de `morfeo revisar`. */
278
304
  formato?: 'corto' | 'largo';
279
305
  /** Ajustes del estilo para este video (colores, fondo, subtítulos...). */
package/lib/config.mjs CHANGED
@@ -1,4 +1,5 @@
1
1
  // Carga y valida morfeo.config.mjs del proyecto del usuario.
2
+ import {IDS_FORMULA} from './formulas.mjs';
2
3
  import fs from 'node:fs';
3
4
  import path from 'node:path';
4
5
  import {pathToFileURL} from 'node:url';
@@ -25,7 +26,7 @@ export const TIPOS_ESCENA = [...new Set([...TIPOS_CLASICOS, ...TIPOS_MOTOR])];
25
26
  export const ROTULOS = ['normal', 'contorno', 'pastilla', 'sello', 'paso', 'pixel'];
26
27
  export const MODOS = ['rayosx', 'plano', 'holograma'];
27
28
  export const ESTILOS = ['datos-curiosos', 'explicativo', 'documental', 'ilustrado', 'historia', 'mesa', 'serie', 'infografia', '3d', 'maqueta', 'minimal'];
28
- export const TIPOS_CAPA = ['imagen', 'video', 'texto', 'contador', 'chip', 'etiqueta', 'numero', 'tarjeta', 'telefono', 'forma', 'icono', 'emoji', 'marca', 'particulas', 'grupo', 'modelo', 'componente', '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'];
29
+ export const TIPOS_CAPA = ['imagen', 'video', 'texto', 'contador', 'chip', 'etiqueta', 'numero', 'tarjeta', 'telefono', 'forma', 'icono', 'emoji', 'marca', 'particulas', 'grupo', 'modelo', 'componente', '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'];
29
30
  export const ESCENARIOS = ['bosque', 'cuarto', 'departamento', 'tunel', 'pasillo'];
30
31
  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'];
31
32
  export const PROVEEDORES_VOZ = ['kokoro', 'elevenlabs', 'openai'];
@@ -228,6 +229,10 @@ function validarCapas(capas, donde, err, comps) {
228
229
  if (!c.buscar) err(`${d}: falta \`buscar\` (qué objeto: 'tubería', 'casa', 'pizza'...)`);
229
230
  if (c.estilo && !['2d', '3d', 'realista'].includes(c.estilo)) err(`${d}: estilo "${c.estilo}" no existe. Usa '2d', '3d' o 'realista'`);
230
231
  }
232
+ if (c.tipo === 'dividida') for (const lado of ['izq', 'der', 'arriba', 'abajo']) if (c[lado]?.capas) validarCapas(c[lado].capas, `${d}, ${lado}`, err, comps);
233
+ if (c.tipo === 'cintillo' && !c.nombre) err(`${d}: \`cintillo\` necesita \`nombre\``);
234
+ if (c.tipo === 'llamada' && c.estado != null && !['entrante', 'curso', 'terminada'].includes(c.estado)) err(`${d}: estado "${c.estado}" no existe (entrante, curso o terminada)`);
235
+ if (c.tipo === 'tierra' && (!esNum(c.lat) || !esNum(c.lon))) err(`${d}: \`tierra\` necesita \`lat\` y \`lon\` (números)`);
231
236
  if (c.capas) validarCapas(c.capas, d, err, comps);
232
237
  if (c.capa) validarCapas([c.capa], d, err, comps);
233
238
  });
@@ -315,6 +320,7 @@ function validarVideo(id, v, ctx) {
315
320
  );
316
321
  if (s.capas != null) validarCapas(s.capas, `${n}`, err, ctx.componentes);
317
322
  if (s.rotulo != null && typeof s.rotulo !== 'string') err(`${n}: \`rotulo\` debe ser un texto corto ('¿CUÁNTO *SUBIÓ*?')`);
323
+ if (s.lugar != null && !(v.lugares && typeof v.lugares === 'object' && v.lugares[s.lugar])) err(`${n}: el lugar "${s.lugar}" no está en \`lugares\`${v.lugares && typeof v.lugares === 'object' ? ` (hay: ${Object.keys(v.lugares).join(', ')})` : ''}`);
318
324
  if (s.rotuloEstilo != null && !ROTULOS.includes(s.rotuloEstilo)) err(`${n}: rotuloEstilo "${s.rotuloEstilo}" no existe. Opciones: ${ROTULOS.join(', ')}`);
319
325
  if (s.rotuloPaso != null && s.rotuloEstilo !== 'paso') err(`${n}: \`rotuloPaso\` es para rotuloEstilo: 'paso'`);
320
326
  if (s.modo != null) {
@@ -329,7 +335,7 @@ function validarVideo(id, v, ctx) {
329
335
  if (tm.zoom != null && tm.zoom <= 0) err(`${n}: \`toma.zoom\` debe ser mayor que 0`);
330
336
  if (tm.a != null) for (const k of ['x', 'y', 'zoom', 'giro']) if (tm.a[k] != null && !esNum(tm.a[k])) err(`${n}: \`toma.a.${k}\` debe ser un número`);
331
337
  }
332
- if (!v.mundo?.length) err(`${n}: \`toma\` mueve la cámara del \`mundo\`, pero el video no tiene \`mundo\``);
338
+ if (!v.mundo?.length && !v.lugares) err(`${n}: \`toma\` mueve la cámara del \`mundo\`, pero el video no tiene \`mundo\` ni \`lugares\``);
333
339
  }
334
340
  if (s.type === 'cta' && s.pide != null) {
335
341
  if (!['comenta', 'comparte'].includes(s.pide)) err(`${n}: \`pide\` debe ser 'comenta' o 'comparte'`);
@@ -353,17 +359,23 @@ function validarVideo(id, v, ctx) {
353
359
  if (v.voice != null) voice = normalizarVoz(v.voice, ctx, err);
354
360
  else if (necesitaVoz) err('falta `voice` (p. ej. "em_alex", una mezcla [["em_alex", 0.6], ["am_michael", 0.4]], un .npy, o `audio` en cada escena)');
355
361
  if (v.hud != null) validarCapas(v.hud, 'hud', err, ctx.componentes);
362
+ if (v.lugares != null) {
363
+ if (typeof v.lugares !== 'object' || Array.isArray(v.lugares) || !Object.keys(v.lugares).length) err("`lugares` debe ser un objeto con un mundo por nombre: {cocina: [...], boda: [...]}");
364
+ else for (const [nombre, capas] of Object.entries(v.lugares)) validarCapas(capas, `lugares.${nombre}`, err, ctx.componentes);
365
+ if (clasico) err('`lugares` es del motor de capas: usa un estilo como ilustrado, 3d o mesa (no "datos-curiosos")');
366
+ }
356
367
  if (v.mundo != null) {
357
368
  validarCapas(v.mundo, 'mundo', err, ctx.componentes);
358
369
  if (clasico) err('`mundo` es del motor de capas: usa un estilo como ilustrado, 3d o mesa (no "datos-curiosos")');
359
370
  }
371
+ if (v.formula != null && !IDS_FORMULA.includes(v.formula)) err(`\`formula\` "${v.formula}" no existe. Opciones: ${IDS_FORMULA.join(', ')} (npx morfeo formulas)`);
360
372
  if (v.serie != null && typeof v.serie !== 'string') err("`serie` debe ser un texto ('GYM CON CIENCIA · PARTE 5')");
361
373
  if (v.title != null && typeof v.title !== 'string') err('`title` debe ser un texto (también es el nombre del archivo del video)');
362
374
  if (v.calidad != null && !['alta', 'media', 'ligera'].includes(v.calidad)) err("`calidad` debe ser 'alta', 'media' o 'ligera'");
363
375
  if (v.tags != null && (!Array.isArray(v.tags) || v.tags.some((x) => typeof x !== 'string'))) err("`tags` debe ser una lista de textos (['curiosidades', 'ciencia'])");
364
376
  // archivos de assets/ que piden las escenas y el HUD: mejor avisar ahora que a mitad del render
365
- revisarArchivos({scenes, hud: v.hud, mundo: v.mundo, tema: v.tema}, ctx.assetsDir, err);
366
- revisarPersonajes({scenes, hud: v.hud, mundo: v.mundo}, ctx.personajes, err);
377
+ revisarArchivos({scenes, hud: v.hud, mundo: v.mundo, lugares: v.lugares, tema: v.tema}, ctx.assetsDir, err);
378
+ revisarPersonajes({scenes, hud: v.hud, mundo: v.mundo, lugares: v.lugares}, ctx.personajes, err);
367
379
  revisarEscenarios({scenes, hud: v.hud, tema: v.tema}, err);
368
380
  return {...v, estilo, voice, music: normalizarMusica(v.music, ctx, err), scenes};
369
381
  }
@@ -43,8 +43,10 @@ export const REGLAS = [
43
43
  export const REGLAS_TEXTO = `Estructura (medida en videos virales; síguela):
44
44
  ${REGLAS.map((r, i) => `${i + 1}. ${r.regla}: ${r.como}`).join('\n')}`;
45
45
 
46
+ import {formulaDe, revisarFormula} from './formulas.mjs';
47
+
46
48
  const palabrasDe = (s) => String(s ?? '').trim().split(/\s+/).filter(Boolean);
47
- const GANCHOS = /^(¿|¡|imagina|y si|cómo|como se|si tu|si te|si un|qué pasaría|que pasaria|este es|esta es|esto es|nadie|nunca|todos|a \S+ |hay |tu |tú |te |tienes|cuando)/i;
49
+ const GANCHOS = /^(¿|¡|imagina|la mayoría|la mayoria|y si|cómo|como se|si tu|si te|si un|qué pasaría|que pasaria|este es|esta es|esto es|nadie|nunca|todos|a \S+ |hay |tu |tú |te |tienes|cuando)/i;
48
50
 
49
51
  /**
50
52
  * Revisa la estructura de un video y devuelve consejos (no bloquea el render).
@@ -61,7 +63,8 @@ export function revisarEstructura(v, d) {
61
63
  const primera = cuerpo[0];
62
64
  const clasico = (v.estilo ?? 'datos-curiosos') === 'datos-curiosos';
63
65
  // formato largo: explicador de maqueta (o `formato: 'largo'` en el video)
64
- const largo = v.estilo === 'maqueta' || v.formato === 'largo';
66
+ const formula = formulaDe(v.formula);
67
+ const largo = v.estilo === 'maqueta' || v.formato === 'largo' || (formula?.duracion[1] ?? 0) > 90;
65
68
 
66
69
  // 1) gancho
67
70
  const dicho = String(primera.say ?? '').trim();
@@ -100,7 +103,7 @@ export function revisarEstructura(v, d) {
100
103
 
101
104
  // 4) cierre
102
105
  const fin = escenas.at(-1);
103
- const pideAlgo = escenas.some((s) => s.type === 'eleccion') || (fin?.type === 'cta' && fin.pide) || /comenta|escribe|mándaselo|mandaselo|comparte|elige|parte 2|siguiente parte/i.test(cuerpo.at(-1)?.say ?? '');
106
+ const pideAlgo = escenas.some((s) => s.type === 'eleccion') || (fin?.type === 'cta' && fin.pide) || /comenta|escribe|mándaselo|mandaselo|envíaselo|enviaselo|comparte|elige|parte 2|siguiente parte|capítulo/i.test(cuerpo.at(-1)?.say ?? '');
104
107
  if (!pideAlgo) add('cierre', 'idea', "El cierre solo pide seguir. Pide algo concreto: {type: 'cta', pide: 'comenta', palabra: 'PASO'}, pide: 'comparte' o una escena 'eleccion' (A o B).");
105
108
 
106
109
  // 5) habla de tú
@@ -108,6 +111,9 @@ export function revisarEstructura(v, d) {
108
111
  const n = palabrasDe(todo).length;
109
112
  const tu = (todo.match(/\b(tú|tu|tus|te|ti|contigo|imagina|imaginas|tienes|eres|puedes|sabes|sabías|crees|conoces|harías|pagarías)\b/gi) ?? []).length;
110
113
  if (n > 40 && tu === 0) add('tu', 'idea', 'No le habla al espectador ni una vez: una frase en segunda persona ("imagina que tú…") lo mete en la historia.');
114
+ // 6) lo propio de la fórmula del video (lib/formulas.mjs)
115
+ for (const t of revisarFormula(v)) add('formula', 'idea', t);
116
+ // "envíaselo a…" también es un cierre que pide algo
111
117
  return out;
112
118
  }
113
119
 
@@ -0,0 +1,179 @@
1
+ // Fórmulas de historia medidas en los videos de referencia (referencias/videos-ejemplo.md). Una sola fuente para:
2
+ // el prompt de `morfeo guion` (la IA elige una o se le pide con --formula), `morfeo revisar` (consejos propios de cada
3
+ // fórmula) y `morfeo formulas` (la lista). Cada una dice cómo arranca, cómo avanza, cómo cierra y qué piezas le quedan.
4
+
5
+ /** @typedef {{id: string, nombre: string, para: string, esqueleto: string[], gancho: string, cierre: string, duracion: [number, number], estilos: string[], piezas: string, revisar?: (v: any, texto: string) => string[]}} Formula */
6
+
7
+ const dice = (texto, re) => re.test(texto);
8
+
9
+ /** @type {Formula[]} */
10
+ export const FORMULAS = [
11
+ {
12
+ id: 'construiste',
13
+ nombre: 'Construiste un X',
14
+ para: 'explicar cómo funciona una obra, máquina o instalación (túnel, inodoro, portón eléctrico, semáforo)',
15
+ esqueleto: ['Arranca por lo obvio: "Si haces un hoyo en la montaña, tienes un túnel…"', 'Falla por algo concreto ("pero el techo plano se derrumba")', 'Lo arreglas con la pieza real ("le das forma de arco")', 'Repite falla → arreglo 4 a 7 veces; cada arreglo es una pieza nueva en pantalla', 'Remate: "¡Felicidades, construiste un túnel!"'],
16
+ gancho: '"Si a tu portón le pones un motor, ya no te bajas a abrir… pero no sabe cuándo parar."',
17
+ cierre: '"¡Felicidades, construiste X!" y "comenta qué construimos en el próximo".',
18
+ duracion: [90, 120],
19
+ estilos: ['maqueta', '3d'],
20
+ piezas: 'un solo mundo (Maqueta con corte de tierra), Aparece por pieza en su palabra, etiquetas 3D, rótulo paso con número, modo plano para ver por dentro',
21
+ revisar: (v, texto) => {
22
+ const out = [];
23
+ if (!dice(texto, /felicidades|construiste|inventaste|has (?:construido|inventado)/i)) out.push('Fórmula "construiste": cierra con "¡Felicidades, construiste X!" (es el remate que esperan).');
24
+ const pero = (texto.match(/\bpero\b/gi) ?? []).length;
25
+ if (pero < 3) out.push(`Fórmula "construiste": solo ${pero} "pero". La cadena falla → arreglo necesita 4–7 rondas ("…pero se derrumba, así que…").`);
26
+ return out;
27
+ },
28
+ },
29
+ {
30
+ id: 'recomendable',
31
+ nombre: 'Lo recomendable es X, y no [absurdo]',
32
+ para: 'humor con datos: consejos cotidianos contra la exageración más absurda (prensa de 500 t para un zancudo)',
33
+ esqueleto: ['"¿Sabías que para X lo recomendable es Y…"', '"…y no [la exageración con cifra]: [consecuencia absurda]"', 'Tres rondas iguales con objetos distintos (cifra en tarjeta cada vez)', 'Remate corto que vuelve al principio'],
34
+ gancho: '"¿Sabías que si quieres matar un zancudo, lo recomendable es usar la mano y no una prensa de 500 toneladas?"',
35
+ cierre: 'Remate de una línea ("La próxima, con cuchara") o "síguenos si quieres más así".',
36
+ duracion: [45, 60],
37
+ estilos: ['3d', 'datos-curiosos'],
38
+ piezas: 'subtítulo palabra con coloresClave, dato/chip con la cifra exagerada, sello para la consecuencia',
39
+ revisar: (v, texto) => ((texto.match(/\by no\b/gi) ?? []).length < 2 ? ['Fórmula "recomendable": repite "lo recomendable es… y no…" al menos 3 veces; la gracia está en el patrón.'] : []),
40
+ },
41
+ {
42
+ id: 'escalada',
43
+ nombre: 'Escalada absurda',
44
+ para: 'un objeto cotidiano que crece pieza por pieza porque cada solución causa otro problema (la mochila que termina con reactor)',
45
+ esqueleto: ['Problema cotidiano y su arreglo simple ("le pones dos ruedas")', '"Pero [alguien/algo] causa otro problema, así que le metes…"', 'Cada escena suma una pieza visible y más grande', 'Remate: "Felicidades, has inventado una mochila básica"'],
46
+ gancho: '"Tu mochila pesa más que tu abuela, así que le pones dos ruedas."',
47
+ cierre: '"Felicidades, has inventado [algo que ya existe]" y "envíaselo a tu amigo que…".',
48
+ duracion: [40, 55],
49
+ estilos: ['3d'],
50
+ piezas: 'Aparece (cae y rebota) por cada pieza en su palabra, Persona3D reaccionando, sello por golpe cómico',
51
+ revisar: (v, texto) => (!dice(texto, /así que|entonces/i) ? ['Fórmula "escalada": encadena con "así que…" / "entonces le…": cada arreglo provoca el siguiente.'] : []),
52
+ },
53
+ {
54
+ id: 'supervivencia',
55
+ nombre: 'Regla de supervivencia',
56
+ para: 'qué hacer ante un peligro con dos variantes que se confunden (oso negro / grizzly, medusa, rayo)',
57
+ esqueleto: ['"Si te pasa X: con uno haz A y con el otro nunca. Si te equivocas…"', 'Presenta el primero con cifras (peso, velocidad) y qué hacer', 'Presenta el segundo y por qué es al revés', 'Cómo distinguirlos (una sola seña visual)', 'Cierre en suspenso o pregunta'],
58
+ gancho: '"Si te ataca un oso, con uno tienes que hacerte el muerto, y con el otro, nunca."',
59
+ cierre: 'Suspenso ("porque cuando el próximo oso cargue contra ti…") o "comenta cuál sabías".',
60
+ duracion: [45, 60],
61
+ estilos: ['ilustrado', 'documental'],
62
+ piezas: 'chips de cifra pegados al animal, comparar/dividida A | B, contorno sobre la seña que los distingue',
63
+ },
64
+ {
65
+ id: 'experimento',
66
+ nombre: 'Experimento mental con contador',
67
+ para: 'dilemas y "¿qué pasa si…?" que se resuelven contando (un céntimo que se duplica, 10 años de un hábito)',
68
+ esqueleto: ['Dilema o pregunta con cifra ("¿Un millón ahora o un céntimo que se duplica 30 días?")', 'Casi todos eligen mal', 'Contador por días/años con saltos (Día 1, 7, 20…)', 'Giro en los últimos días', 'Resultado con número grande'],
69
+ gancho: '"Un millón de euros ahora mismo, o un céntimo que se duplica cada día durante 30 días."',
70
+ cierre: '"Así que ya sabes qué decir cuando alguien te pregunte" o pregunta A/B.',
71
+ duracion: [45, 60],
72
+ estilos: ['mesa', 'infografia', '3d'],
73
+ piezas: 'contador lcd, Pila que crece, calendario que avanza, rótulo pixel para el resultado',
74
+ revisar: (v, texto) => (!dice(texto, /\bd[ií]a\b|\baños?\b|\bmes(es)?\b/i) ? ['Fórmula "experimento": marca el tiempo en voz ("día siete…", "diez años después") y con un contador o calendario.'] : []),
75
+ },
76
+ {
77
+ id: 'misterio',
78
+ nombre: 'Misterio con giro',
79
+ para: 'historias reales o inventadas con un hecho raro que escala y se explica al final',
80
+ esqueleto: ['Hecho raro en una frase ("Un hombre se colaba cada noche y movía todo 2,5 cm")', 'Escala por días ("DÍA 8", "DÍA 12") con la tensión de quien lo vive', 'Consecuencia (venden la casa, llaman a la policía)', 'Giro final que reordena todo (el comprador era él)'],
81
+ gancho: '"Un hombre se colaba cada noche en una casa y movía cada objeto dos centímetros a la izquierda."',
82
+ cierre: 'El giro es el cierre; nada de "sígueme" largo.',
83
+ duracion: [50, 65],
84
+ estilos: ['historia', 'ilustrado'],
85
+ piezas: 'siluetas, calendario o tarjeta "DÍA 8", vigilancia, medida para los centímetros',
86
+ },
87
+ {
88
+ id: 'demuestralo',
89
+ nombre: 'Te lo demuestro',
90
+ para: 'algo que el espectador puede probar con su cuerpo o su casa en ese momento',
91
+ esqueleto: ['"Hay algo que tu cuerpo no puede hacer y te lo voy a demostrar"', 'Instrucciones paso a paso al espectador (pon, dobla, levanta)', 'Lo que pasa ("¿No sube?")', 'Explicación con rayos X / plano', '"Tu cuerpo viene así de fábrica"'],
92
+ gancho: '"Hay un dedo de tu mano que no puedes levantar, y te lo voy a demostrar."',
93
+ cierre: '"Envíaselo a un amigo y que lo intente."',
94
+ duracion: [30, 45],
95
+ estilos: ['ilustrado', 'explicativo'],
96
+ piezas: 'modo rayosx en la explicación, trazo/contorno sobre la parte, cierre comparte',
97
+ revisar: (v) => (!(v.scenes ?? []).some((s) => s.modo) ? ['Fórmula "demuéstralo": la explicación luce con `modo: \'rayosx\'` en la palabra donde se ve "por dentro".'] : []),
98
+ },
99
+ {
100
+ id: 'mito',
101
+ nombre: 'Mito desmentido con comparación',
102
+ para: 'creencias comunes que tienen truco (la cinta de correr "miente", el microondas, la sal)',
103
+ esqueleto: ['Afirmación tajante ("La cinta te miente")', 'Por qué (la física o la biología en una frase)', 'La prueba (estudio o medida)', 'Comparación lado a lado', 'Matiz honesto', 'CTA a quien cree el mito'],
104
+ gancho: '"Diez kilómetros en la cinta y diez en la calle: mismo reloj, y no hiciste lo mismo."',
105
+ cierre: '"Envíaselo a ese amigo que dice que…"',
106
+ duracion: [50, 65],
107
+ estilos: ['infografia', 'explicativo'],
108
+ piezas: 'dividida A | B, medidor, barras, sellos "TE MIENTE" / "PILLADO", modo plano',
109
+ },
110
+ {
111
+ id: 'roast',
112
+ nombre: 'Roast con analogía animal',
113
+ para: 'conductas de la gente explicadas con la biología de un animal (el amigo tacaño y el pavo real)',
114
+ esqueleto: ['Pregunta provocadora sobre un conocido ("¿Por qué tu amigo el rata…?")', '"Es lo mismo que le pasa a un [animal]"', 'Explicación biológica con imágenes del animal', 'Vuelta al amigo con la analogía', 'CTA para mandárselo a ese amigo'],
115
+ gancho: '"¿Por qué tu amigo el rata no te invita ni un café, pero a su novia le paga las vacaciones?"',
116
+ cierre: '"Envíaselo a tu amigo el rata y que esta vez el café lo pague él."',
117
+ duracion: [45, 60],
118
+ estilos: ['ilustrado', 'mesa'],
119
+ piezas: 'mismos objetos que vuelven (cartera, café), lugares distintos por tramo, cierre comparte',
120
+ revisar: (v) => {
121
+ const cta = (v.scenes ?? []).find((s) => s.type === 'cta');
122
+ return cta && cta.pide !== 'comparte' ? ['Fórmula "roast": cierra con `pide: \'comparte\'` y un destinatario concreto ("tu amigo el rata").'] : [];
123
+ },
124
+ },
125
+ {
126
+ id: 'serie',
127
+ nombre: 'Serie interactiva (elige tú)',
128
+ para: 'historias por capítulos donde el público decide qué pasa (llamadas del futuro, vuelos perdidos)',
129
+ esqueleto: ['Recuento de lo que votaron ("La mayoría de ustedes votó…") si no es el capítulo 1', 'El capítulo en tiempo real: hora en pantalla, teléfono, noticias', 'Escalada hasta una decisión', 'Cliffhanger con dos opciones A/B', '"Comenta tu opción; la más votada gana. Capítulo 3 mañana 15:30"'],
130
+ gancho: '"Anoche te llamó tu propio número y te dijo que no tomaras el metro."',
131
+ cierre: 'Capa `capitulo` con las dos opciones y el horario, o escena `eleccion`.',
132
+ duracion: [70, 115],
133
+ estilos: ['historia', 'serie'],
134
+ piezas: 'serie (pastilla fija), recuento, bloqueo, llamada, noticia, vigilancia, capitulo',
135
+ revisar: (v) => {
136
+ const ultima = (v.scenes ?? []).at(-1);
137
+ const tieneFinal = (v.scenes ?? []).some((s) => s.type === 'eleccion' || (s.capas ?? []).some((c) => c.tipo === 'capitulo'));
138
+ const out = [];
139
+ if (!tieneFinal) out.push('Fórmula "serie": termina con una decisión A/B (capa `capitulo` o escena `eleccion`): es lo que trae comentarios.');
140
+ if (!v.serie) out.push("Fórmula \"serie\": pon `serie: 'MI SERIE · CAP. 2'` para la pastilla fija arriba.");
141
+ if (ultima && ultima.type === 'cta' && !ultima.pide) out.push('Fórmula "serie": el cierre debe pedir que comenten su opción, no solo "sígueme".');
142
+ return out;
143
+ },
144
+ },
145
+ {
146
+ id: 'biografia',
147
+ nombre: 'Biografía con secreto',
148
+ para: 'la vida de una persona o empresa contada año por año, con un escándalo o secreto como gancho',
149
+ esqueleto: ['Gancho de escándalo ("Al jefe no lo descubrió su esposa: lo descubrieron los espías")', 'Origen: lugar y año (globo con pin)', 'Ascenso año por año (tarjetas de año, cintillos con nombres)', 'El secreto con pruebas (documentos, vigilancia)', 'Final abierto'],
150
+ gancho: '"Al jefe de [empresa] no lo descubrió su esposa: lo descubrieron los espías."',
151
+ cierre: 'Final abierto ("Le dijeron que no.") o "sígueme para la parte 2".',
152
+ duracion: [90, 180],
153
+ estilos: ['documental', '3d'],
154
+ piezas: 'lugares (un set por época), tierra con pin, fecha, cintillo, calendario, vigilancia, ficha',
155
+ revisar: (v) => (!v.lugares ? ['Fórmula "biografía": usa `lugares` (un set por época o sitio) y `lugar` por escena: un solo fondo se siente pobre en 2–3 minutos.'] : []),
156
+ },
157
+ ];
158
+
159
+ export const IDS_FORMULA = FORMULAS.map((f) => f.id);
160
+ export const formulaDe = (id) => FORMULAS.find((f) => f.id === id) ?? null;
161
+
162
+ /** Texto de las fórmulas para el prompt de la IA. */
163
+ export function formulasParaIA(id) {
164
+ const lista = id ? [formulaDe(id)].filter(Boolean) : FORMULAS;
165
+ return lista
166
+ .map(
167
+ (f) =>
168
+ `### ${f.id}: ${f.nombre}\nPara: ${f.para}.\nEsqueleto:\n${f.esqueleto.map((p, i) => ` ${i + 1}. ${p}`).join('\n')}\nGancho de ejemplo: ${f.gancho}\nCierre: ${f.cierre}\nDuración: ${f.duracion[0]}–${f.duracion[1]} s.`,
169
+ )
170
+ .join('\n\n');
171
+ }
172
+
173
+ /** Consejos propios de la fórmula del video (`formula: 'construiste'`). */
174
+ export function revisarFormula(v) {
175
+ const f = formulaDe(v?.formula);
176
+ if (!f?.revisar) return [];
177
+ const texto = (v.scenes ?? []).map((s) => s.say ?? '').join(' ');
178
+ return f.revisar(v, texto);
179
+ }
package/lib/guion.mjs CHANGED
@@ -1,5 +1,6 @@
1
1
  // Guiones: de un tema (la IA escribe todo) o de tu propio texto (la IA lo convierte en escenas,
2
2
  // o sin IA: una escena por frase). El resultado es un video listo para editar en videos/<id>.json.
3
+ import {FORMULAS, IDS_FORMULA, formulaDe, formulasParaIA} from './formulas.mjs';
3
4
  import fs from 'node:fs';
4
5
  import os from 'node:os';
5
6
  import path from 'node:path';
@@ -120,8 +121,17 @@ Además, en cualquier escena:
120
121
  Varía los tipos. "at", "check" y "en" deben ser palabras que aparezcan tal cual en "say".
121
122
  No escribas la escena de cierre: va en "cierre" (lo que pide al final: "comenta" una "palabra", "comparte" con "a" quién se lo mandan, o "sigue"), con su "say".
122
123
 
124
+ - "rotulo_estilo": cómo se ve el rótulo: "sello" (sello de goma inclinado para un golpe: "¡SIN LUZ!", "¡TE MIENTE!"),
125
+ "paso" (titular de paso con número en "rotulo_paso", para explicar en orden), "pixel" (caja de videojuego para
126
+ resultados: "RESULTADO", "-20% RIESGO"), "pastilla" (aviso: "¡CUIDADO!") o null (el del estilo).
127
+ - "modo": "rayosx" (ver por dentro: cuerpo, máquina), "plano" (cómo está hecho) u "holograma" (datos, cerebro) en la
128
+ escena que lo explica, con "modo_en" = la palabra donde cambia; null en las demás. Úsalo en 1–3 escenas como máximo.
129
+
123
130
  ${REGLAS_TEXTO}
124
131
 
132
+ Fórmulas de historia (elige la que mejor le quede al tema y escribe en "formula" su id; síguela de principio a fin):
133
+ ${formulasParaIA()}
134
+
125
135
  Diccionario de recursos por tema (usa estas palabras en "recurso"):
126
136
  ${Object.entries(listaTemas()).map(([t, v]) => `- ${t}: ${v.join(', ')}`).join('\n')}`;
127
137
 
@@ -153,10 +163,14 @@ export async function esquemaMotor() {
153
163
  pregunta: z.string().nullable(),
154
164
  opciones: z.array(z.object({texto: z.string(), sub: z.string(), objeto: z.enum(OBJETOS).nullable(), emoji: z.string().nullable()})).nullable(),
155
165
  rotulo: z.string().nullable(),
166
+ rotulo_estilo: z.enum(['sello', 'paso', 'pixel', 'pastilla']).nullable(),
167
+ rotulo_paso: z.number().nullable(),
168
+ modo: z.enum(['rayosx', 'plano', 'holograma']).nullable(),
169
+ modo_en: z.string().nullable(),
156
170
  claves: z.array(z.string()).nullable(),
157
171
  });
158
172
  const cierre = z.object({pide: z.enum(['comenta', 'comparte', 'sigue']), say: z.string(), palabra: z.string().nullable(), a: z.string().nullable()});
159
- return z.object({id: z.string(), title: z.string(), music: z.enum(MUSICAS), scenes: z.array(escena), cierre});
173
+ return z.object({id: z.string(), title: z.string(), formula: z.enum(IDS_FORMULA), music: z.enum(MUSICAS), scenes: z.array(escena), cierre});
160
174
  }
161
175
 
162
176
  /** De la salida de la IA (motor de capas) al formato de escenas de morfeo. */
@@ -165,6 +179,14 @@ export function aEscenaMotor(e, conImagenes) {
165
179
  if (e.show && e.show !== e.say) out.show = e.show;
166
180
  const si = (k, v) => v != null && v !== '' && (out[k] = v);
167
181
  si('rotulo', e.rotulo);
182
+ if (e.rotulo && e.rotulo_estilo) {
183
+ out.rotuloEstilo = e.rotulo_estilo;
184
+ if (e.rotulo_estilo === 'paso' && e.rotulo_paso != null) out.rotuloPaso = e.rotulo_paso;
185
+ }
186
+ if (e.modo) {
187
+ out.modo = e.modo;
188
+ si('modoEn', e.modo_en);
189
+ }
168
190
  if (e.claves?.length) out.claves = e.claves;
169
191
  switch (e.type) {
170
192
  case 'titular':
@@ -266,9 +288,13 @@ export async function guionConIA(o) {
266
288
  ? `Convierte este guion en escenas. Respeta su contenido y su orden; puedes partir frases largas.\n\n<guion>\n${o.texto}\n</guion>`
267
289
  : `Escribe un video sobre: ${o.tema}`;
268
290
  // ~45 s por defecto: los videos de referencia rondan el minuto y ninguna idea pasa de ~5 s (lib/estructura.mjs)
269
- const dur = o.duracion ?? 45;
291
+ const forzada = o.formula ? formulaDe(o.formula) : null;
292
+ if (o.formula && !forzada) throw new ErrorMorfeo(`La fórmula "${o.formula}" no existe. Opciones: ${IDS_FORMULA.join(', ')}`);
293
+ // con fórmula, la duración es la de sus videos de referencia (el punto medio de su rango)
294
+ const dur = o.duracion ?? (forzada ? Math.round((forzada.duracion[0] + forzada.duracion[1]) / 2) : 45);
270
295
  const extra = [`Idioma: ${idioma}.`, `Duración aproximada: ${dur} segundos (unas ${Math.round(dur / 4)} escenas de 3 a 5 segundos).`];
271
296
  if (o.tono) extra.push(`Tono: ${o.tono}.`);
297
+ if (forzada) extra.push(`Fórmula: usa "${forzada.id}" (${forzada.nombre}) y su esqueleto tal cual.`);
272
298
  const motor = o.estilo && o.estilo !== 'datos-curiosos';
273
299
  if (motor) extra.push(`Estilo visual: ${o.estilo}.${o.imagenes ? ' Se pueden generar ilustraciones (usa imagen_ia en las escenas capas).' : ' No hay ilustraciones: usa emoji en las escenas capas.'}`);
274
300
 
@@ -295,7 +321,7 @@ export async function guionConIA(o) {
295
321
  const scenes = g.scenes.map((e) => aEscenaMotor(e, !!o.imagenes));
296
322
  const c = g.cierre;
297
323
  const cta = !c || c.pide === 'sigue' ? {type: 'cta', ...(c?.say ? {say: c.say} : {})} : {type: 'cta', pide: c.pide, say: c.say, ...(c.palabra ? {palabra: c.palabra} : {}), ...(c.a ? {a: c.a} : {})};
298
- return {id: slug(g.id || g.title), video: {estilo: o.estilo, title: g.title, voice: o.voz ?? 'em_alex', music: g.music, scenes: [...scenes, cta]}};
324
+ return {id: slug(g.id || g.title), video: {estilo: o.estilo, formula: forzada?.id ?? g.formula, title: g.title, voice: o.voz ?? 'em_alex', music: g.music, scenes: [...scenes, cta]}};
299
325
  }
300
326
  const scenes = g.scenes.map(aEscena);
301
327
  if (scenes[0]?.type !== 'title') scenes.unshift({type: 'title', say: scenes[0]?.say ?? g.title, rows: [[g.title.toUpperCase().slice(0, 14), '#FFD400', 200]], icon: 'question'});
@@ -5,7 +5,14 @@ import path from 'node:path';
5
5
  import {ErrorMorfeo} from './config.mjs';
6
6
  import {PAQUETE} from './entorno.mjs';
7
7
 
8
- export const LOOKS = ['2d', '3d'];
8
+ /** Bases disponibles: cada carpeta de bases/ con un guion.mjs (y un Mundo.tsx si lleva mundo propio). */
9
+ export const LOOKS = (() => {
10
+ try {
11
+ return fs.readdirSync(path.join(PAQUETE, 'bases')).filter((d) => fs.existsSync(path.join(PAQUETE, 'bases', d, 'guion.mjs'))).sort();
12
+ } catch {
13
+ return ['2d', '3d'];
14
+ }
15
+ })();
9
16
 
10
17
  /** "mi-video_2" -> "MundoMiVideo2" */
11
18
  export const nombreComponente = (id) =>
@@ -41,7 +48,7 @@ export function registrarEnConfig(texto, nombre, ruta) {
41
48
 
42
49
  /**
43
50
  * @param {{proyecto: string, id: string, look?: string, carpetaGuiones?: string}} o
44
- * @returns {{guion: string, escena: string, componente: string}}
51
+ * @returns {{guion: string, escena: string | null, componente: string | null}}
45
52
  */
46
53
  export function nuevoVideo({proyecto, id, look = '2d', carpetaGuiones}) {
47
54
  if (!id || !/^[a-z0-9][a-z0-9-_]*$/i.test(id)) throw new ErrorMorfeo('Dame un id corto sin espacios: npx morfeo nuevo-video mi-video --look 2d');
@@ -52,12 +59,14 @@ export function nuevoVideo({proyecto, id, look = '2d', carpetaGuiones}) {
52
59
  const dirGuiones = carpetaGuiones ?? path.join(proyecto, 'guiones');
53
60
  const guion = path.join(dirGuiones, `${id}.mjs`);
54
61
  const escena = path.join(proyecto, 'escenas', `${componente}.tsx`);
55
- for (const f of [guion, escena]) if (fs.existsSync(f)) throw new ErrorMorfeo(`Ya existe ${path.relative(proyecto, f)}; elige otro id.`);
62
+ for (const f of [guion, escena]) if (f && fs.existsSync(f)) throw new ErrorMorfeo(`Ya existe ${path.relative(proyecto, f)}; elige otro id.`);
56
63
  const base = path.join(PAQUETE, 'bases', look);
57
64
  const poner = (s) => s.replaceAll('__ID__', id).replaceAll('__COMPONENTE__', componente);
58
65
  fs.mkdirSync(dirGuiones, {recursive: true});
59
66
  fs.mkdirSync(path.dirname(escena), {recursive: true});
60
67
  fs.writeFileSync(guion, poner(fs.readFileSync(path.join(base, 'guion.mjs'), 'utf8')));
68
+ // bases sin mundo propio (solo capas): no hay componente que copiar ni registrar
69
+ if (!fs.existsSync(path.join(base, 'Mundo.tsx'))) return {guion, escena: null, componente: null};
61
70
  fs.writeFileSync(escena, poner(fs.readFileSync(path.join(base, 'Mundo.tsx'), 'utf8')));
62
71
  const nuevo = registrarEnConfig(fs.readFileSync(config, 'utf8'), componente, `./escenas/${componente}.tsx`);
63
72
  if (nuevo) fs.writeFileSync(config, nuevo);
package/lib/palabras.mjs CHANGED
@@ -70,7 +70,7 @@ export function revisarPalabras(v, d) {
70
70
  avisos.push(`escena ${i + 1}, ${ruta}: "${p}" no está en ${s.show ? 'el subtítulo (`show`)' : 'lo que dice la voz'} ("${texto}"); se usará la mitad de la frase`);
71
71
  });
72
72
  // hud y mundo usan las palabras de todo el video
73
- for (const [k, lista] of [['hud', v.hud], ['mundo', v.mundo]]) {
73
+ for (const [k, lista] of [['hud', v.hud], ['mundo', v.mundo], ...Object.entries(v.lugares ?? {}).map(([n, l]) => [`lugares.${n}`, l])]) {
74
74
  const momentos = [];
75
75
  momentosDe(lista ?? [], k, momentos);
76
76
  for (const [ruta, p] of momentos) if (!laDice(p, todas)) avisos.push(`${ruta}: "${p}" no aparece en ninguna escena`);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "morfeo",
3
- "version": "0.3.4",
3
+ "version": "0.4.0",
4
4
  "description": "Genera videos verticales animados (Remotion + Three.js) con voz, música y subtítulos, desde un guion en código o escrito por IA.",
5
5
  "type": "module",
6
6
  "license": "SEE LICENSE IN LICENSE",
@@ -264,7 +264,7 @@ def preparar(job):
264
264
  subs = [dict(a=round(c[0][1], 3), b=round(c[-1][2], 3), w=[[x[0].upper(), round(x[1], 3), round(x[2], 3)] for x in c])
265
265
  for ws in WORDS for c in trozos(ws)]
266
266
  datos = dict(id=vid, titulo=v.get("title", vid), indice=job.get("indice", 0), duracion=round(sum(durs), 3),
267
- estilo=v.get("estilo", "datos-curiosos"), tema=v.get("tema") or {}, hud=v.get("hud") or [], mundo=v.get("mundo") or [], serie=v.get("serie"),
267
+ estilo=v.get("estilo", "datos-curiosos"), tema=v.get("tema") or {}, hud=v.get("hud") or [], mundo=v.get("mundo") or [], lugares=v.get("lugares") or {}, serie=v.get("serie"),
268
268
  audio=f"v/{vid}/mix.wav", whisper=reales, escenas=escenas, subtitulos=subs)
269
269
  json.dump(datos, open(os.path.join(outdir, "datos.json"), "w", encoding="utf-8"), ensure_ascii=False, indent=1)
270
270
  print(f" tiempos por palabra: {'Whisper' if reales else 'reparto por frase'} ({reales}/{len(TIM)} escenas)", flush=True)