morfeo 0.4.0 → 0.5.1

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/README.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # Morfeo
2
2
 
3
- > **Documentación: [morfeo-docs-two.vercel.app](https://morfeo-docs-two.vercel.app/)** · Ejemplos: [github.com/UreckChan/morfeo](https://github.com/UreckChan/morfeo) · Versiones: [historial](https://morfeo-docs-two.vercel.app/historial)
3
+ > **Documentación: [morfeo.ureck.dev](https://morfeo.ureck.dev/)** · Ejemplos: [github.com/UreckChan/morfeo](https://github.com/UreckChan/morfeo) · Versiones: [historial](https://morfeo.ureck.dev/historial)
4
4
 
5
5
  Genera videos verticales animados (Shorts, TikTok, Reels) desde código: escribes las escenas y Morfeo agrega
6
6
  la voz, la música, los efectos de sonido, los subtítulos, las animaciones y la miniatura.
package/bin/morfeo.mjs CHANGED
@@ -22,8 +22,10 @@ Videos
22
22
  --todos regenera todos
23
23
  --solo-fallidos reintenta solo los que fallaron en el último lote
24
24
  --gpu usa la tarjeta de video (más rápido; si falla, quítalo)
25
- revisar [id...] Revisa la estructura (gancho, ritmo, preguntas, cierre) con reglas de videos virales
25
+ previa <id> Solo el gancho (~8 s) para aprobarlo antes del render completo: --hasta 6
26
+ revisar [id...] Revisa la estructura (gancho, ritmo, preguntas, cierre), que la voz diga el guion y la zona segura
26
27
  hoja <id> Hoja de contacto (PNG con 15 cuadros) para revisar un video de un vistazo
28
+ --zona pinta en rojo lo que tapan los botones de TikTok, Reels y Shorts
27
29
  --desde 8 --hasta 11 solo ese tramo (segundos) --cols 5 --filas 3
28
30
  studio Abre el editor en el navegador para ver y ajustar en vivo
29
31
  estilos Lista los estilos visuales disponibles
@@ -34,7 +36,9 @@ Guiones
34
36
  guion "tema" La IA (Claude) escribe un video completo sobre el tema
35
37
  guion --desde f.txt Convierte tu guion en escenas (con IA; --sin-ia: una escena por frase)
36
38
  --formula <id> una fórmula de historia (construiste, serie, mito, misterio…)
39
+ --investigar busca cada dato en la web con su fuente antes de escribir (van al texto del post)
37
40
  formulas Las fórmulas de historia medidas en videos de referencia
41
+ estilo Abre una página para crear tu estilo: nombre, siglas, colores, letra, voz, ritmo y cierre
38
42
  --estilo documental estilo visual (por defecto, el del proyecto)
39
43
  --imagenes pide ilustraciones con IA (necesita OPENAI_API_KEY)
40
44
  --id nombre --voz em_alex --duracion 40 --idioma "inglés" --tono "humor"
@@ -113,6 +117,14 @@ Pruébalo: npx morfeo render ${pos[0]} y revísalo: npx morfeo hoja ${pos[0]
113
117
  await render({proyecto, ids: pos, todos: !!op.todos || pos[0] === 'todos', soloFallidos: !!op.soloFallidos});
114
118
  return;
115
119
  }
120
+ case 'previa': {
121
+ if (op.gpu) process.env.MORFEO_GL = 'angle';
122
+ const {render} = await import('../lib/render.mjs');
123
+ const seg = op.hasta != null && op.hasta !== true ? Number(op.hasta) : true;
124
+ await render({proyecto, ids: pos, previa: seg});
125
+ console.log('Si el gancho te gusta: npx morfeo render ' + pos.join(' '));
126
+ return;
127
+ }
116
128
  case 'revisar': {
117
129
  const {cargarProyecto} = await import('../lib/config.mjs');
118
130
  const {revisarEstructura, consejosEnTexto, REGLAS} = await import('../lib/estructura.mjs');
@@ -142,7 +154,7 @@ Pruébalo: npx morfeo render ${pos[0]} y revísalo: npx morfeo hoja ${pos[0]
142
154
  const {salidaDe, ordenarSalida} = await import('../lib/salida.mjs');
143
155
  ordenarSalida(p);
144
156
  const video = /\.(mp4|mov|webm)$/i.test(id) ? path.resolve(id) : salidaDe(p, id);
145
- console.log(hoja({video, cols: num(op.cols), filas: num(op.filas), desde: num(op.desde), hasta: num(op.hasta)}));
157
+ console.log(hoja({video, cols: num(op.cols), filas: num(op.filas), desde: num(op.desde), hasta: num(op.hasta), zona: !!op.zona}));
146
158
  }
147
159
  return;
148
160
  }
@@ -192,6 +204,13 @@ Pruébalo: npx morfeo render ${pos[0]} y revísalo: npx morfeo hoja ${pos[0]
192
204
  console.log('\nÚsalo con estilo: "documental" en el video o en morfeo.config.mjs. Crea los tuyos con estilos: {...} en el config.');
193
205
  return;
194
206
  }
207
+ case 'estilo': {
208
+ const {abrirEditorDeEstilo} = await import('../lib/estilo-pagina.mjs');
209
+ const {buscarConfig} = await import('../lib/config.mjs');
210
+ if (!buscarConfig(proyecto)) throw new ErrorMorfeo('Corre esto dentro de un proyecto de Morfeo (npx morfeo nuevo mi-canal).');
211
+ await abrirEditorDeEstilo({proyecto, puerto: op.puerto ? Number(op.puerto) : 4321, abrir: !op.sinAbrir});
212
+ return;
213
+ }
195
214
  case 'formulas':
196
215
  case 'formula': {
197
216
  const {FORMULAS} = await import('../lib/formulas.mjs');
@@ -233,7 +252,7 @@ async function guion(pos, op, proyecto) {
233
252
  if (texto && op.sinIa) r = guionSinIA(texto, {id: op.id, voz: op.voz, musica: op.musica, titulo: op.titulo, estilo});
234
253
  else {
235
254
  console.log(texto ? 'Claude está convirtiendo tu guion en escenas...' : `Claude está escribiendo un video sobre: ${tema}...`);
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});
255
+ r = await guionConIA({tema, texto, investigar: !!op.investigar, 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});
237
256
  }
238
257
  if (op.id) r.id = op.id;
239
258
  const {cargarProyecto} = await import('../lib/config.mjs');
@@ -261,7 +280,7 @@ async function nuevo(carpeta, op) {
261
280
  // dos videos de ejemplo con mundo y la estructura completa (los mismos que `morfeo nuevo-video`)
262
281
  const {nuevoVideo} = await import('../lib/nuevo-video.mjs');
263
282
  for (const look of ['2d', '3d']) nuevoVideo({proyecto: destino, id: `ejemplo-${look}`, look});
264
- console.log(`Proyecto creado en ${carpeta}/\n\nSiguientes pasos:\n cd ${carpeta}\n npm install\n npx morfeo instalar (solo la primera vez en esta computadora)\n npx morfeo render`);
283
+ console.log(`Proyecto creado en ${carpeta}/\n\nCon tu IA: abre la carpeta en Claude Code o Codex y escribe «Configúrame»; luego «Hazme un video sobre…».\n\nA mano:\n cd ${carpeta}\n npm install\n npx morfeo instalar (solo la primera vez en esta computadora)\n npx morfeo estilo (tu nombre, colores, letra y voz)\n npx morfeo render`);
265
284
  }
266
285
 
267
286
  main().catch((e) => {
package/index.d.ts CHANGED
@@ -64,7 +64,7 @@ interface CapaBase {
64
64
  z?: number;
65
65
  /** Efecto de sonido al aparecer. */
66
66
  /** Efecto al aparecer. Sin decirlo, suena según cómo entra (rebote, caer, deslizar) o qué es (sello, dato, chip…); `false` lo apaga. */
67
- sonido?: 'pop' | 'whoosh' | 'tick' | 'glitch' | 'ding' | 'golpe' | false;
67
+ sonido?: 'pop' | 'whoosh' | 'tick' | 'glitch' | 'ding' | 'golpe' | 'teclas' | 'radio' | 'obturador' | 'notificacion' | 'latido' | 'alarma' | 'reloj' | 'caja' | false;
68
68
  /** Cambios en el tiempo (ideal para el HUD): los números se animan desde el valor anterior. */
69
69
  cambios?: ({en: Momento | {escena: number}} & Record<string, unknown>)[];
70
70
  }
@@ -104,7 +104,8 @@ export type Capa = CapaBase &
104
104
  | {tipo: 'fecha'; anio?: number; de?: number; texto?: string; pie?: string; color?: string; tam?: number}
105
105
  | {tipo: 'calendario'; titulo?: string; de?: number; a?: number; color?: string; tam?: number; duracion?: number}
106
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}
107
+ | {tipo: 'tierra'; lat: number; lon: number; etiqueta?: string; tam?: number; color?: string; girar?: boolean; /** Después del pin, vuela hacia el sitio: el globo crece × este valor. */ acercar?: number; duracionVuelo?: number}
108
+ | {tipo: 'linea'; hitos: {anio?: number | string; titulo?: string; texto?: string; en?: Momento}[]; color?: string; ancho?: number; paso?: number}
108
109
  | {tipo: 'mapa'; ruta?: [number, number][]; distancia?: string; ancho?: number; color?: string; fondo?: string; inclinar?: number; duracion?: number}
109
110
  | {tipo: 'cursor'; a: [number, number]; clic?: Momento; duracion?: number; tam?: number; color?: string}
110
111
  | {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}
@@ -295,6 +296,12 @@ export interface Video {
295
296
  /** Para publicar: descripción y etiquetas (van a salida/<id>.txt junto con el título). */
296
297
  descripcion?: string;
297
298
  tags?: string[];
299
+ /** Texto grande de la portada (salida/<título> (portada).jpg); por defecto el título sin emojis. `*palabra*` en color. false = sin portada. */
300
+ portada?: string | false;
301
+ /** Ganchos de reserva: otras primeras frases para probar otra versión (van al final de salida/<título>.txt). */
302
+ hooks?: string[];
303
+ /** Fuentes de los datos del video (van en salida/<título>.txt): 'INEGI 2024' o {texto, url}. */
304
+ fuentes?: (string | {texto?: string; url?: string})[];
298
305
  /**
299
306
  * Fórmula de historia (ver `npx morfeo formulas`): 'construiste' | 'recomendable' | 'escalada' | 'supervivencia' |
300
307
  * 'experimento' | 'misterio' | 'demuestralo' | 'mito' | 'roast' | 'serie' | 'biografia'. `revisar` da consejos propios.
@@ -0,0 +1,74 @@
1
+ // Revisión automática de lo que no se ve a simple vista:
2
+ // - voz = guion: con Whisper, compara lo que la voz dijo de verdad con el `say` de cada escena (la voz sintética a
3
+ // veces se come palabras, lee mal cifras o siglas).
4
+ // - zona segura: lo que queda debajo de los botones y textos de TikTok, Reels y Shorts (columna derecha, pie con
5
+ // el nombre y la descripción, barra de arriba). Coordenadas en 1080×1920.
6
+ import {normalizar} from './palabras.mjs';
7
+
8
+ const NUMEROS = /^(uno|una|dos|tres|cuatro|cinco|seis|siete|ocho|nueve|diez|once|doce|trece|catorce|quince|dieci\w*|veint\w*|treinta|cuarenta|cincuenta|sesenta|setenta|ochenta|noventa|cien|ciento|\w*cientos|mil|millon\w*|porciento|ciento)$/;
9
+
10
+ /** Zonas que tapan las apps (unión aproximada de TikTok, Instagram Reels y YouTube Shorts). */
11
+ export const ZONAS = [
12
+ {id: 'derecha', nombre: 'botones de la derecha (me gusta, comentar, compartir)', x0: 930, y0: 760, x1: 1080, y1: 1660},
13
+ {id: 'pie', nombre: 'nombre de la cuenta y descripción', x0: 0, y0: 1640, x1: 1080, y1: 1920},
14
+ {id: 'arriba', nombre: 'barra de arriba (Siguiendo / Para ti)', x0: 0, y0: 0, x1: 1080, y1: 150},
15
+ ];
16
+
17
+ /** Medida aproximada de una capa: su caja centrada en (x, y). */
18
+ function caja(c) {
19
+ const ancho = Number(c.ancho ?? c.tam ?? 300);
20
+ const alto = Number(c.alto ?? c.tam ?? ancho * 0.4);
21
+ const x = Number(c.x ?? 540);
22
+ const y = Number(c.y ?? 960);
23
+ return {x0: x - ancho / 2, y0: y - alto / 2, x1: x + ancho / 2, y1: y + alto / 2, x, y};
24
+ }
25
+ const pisa = (b, z, margen = 0.25) => {
26
+ const ix = Math.max(0, Math.min(b.x1, z.x1) - Math.max(b.x0, z.x0));
27
+ const iy = Math.max(0, Math.min(b.y1, z.y1) - Math.max(b.y0, z.y0));
28
+ const area = Math.max(1, (b.x1 - b.x0) * (b.y1 - b.y0));
29
+ return (ix * iy) / area > margen;
30
+ };
31
+
32
+ // capas que llenan la pantalla o viven en el mundo: no se revisan
33
+ const LLENAS = new Set(['particulas', 'escenario', 'vigilancia', 'dividida', 'componente', 'grupo', 'escena3d']);
34
+
35
+ /**
36
+ * Capas cuyo texto o dato queda debajo de la interfaz de las apps.
37
+ * @returns {{id: string, nivel: 'aviso' | 'idea', texto: string}[]}
38
+ */
39
+ export function revisarZona(v) {
40
+ const out = [];
41
+ (v.scenes ?? []).forEach((s, i) => {
42
+ for (const c of s.capas ?? []) {
43
+ if (!c || LLENAS.has(c.tipo) || c.enMundo || c.x == null || c.y == null) continue;
44
+ const b = caja(c);
45
+ for (const z of ZONAS) if (pisa(b, z)) out.push({id: 'zona', nivel: 'aviso', texto: `Escena ${i + 1}: la capa ${c.tipo}${c.texto ? ` "${String(c.texto).slice(0, 24)}"` : ''} (x ${c.x}, y ${c.y}) queda bajo ${z.nombre}. Muévela al centro.`});
46
+ }
47
+ if (s.rotuloY != null && (s.rotuloY > 1600 || s.rotuloY < 160)) out.push({id: 'zona', nivel: 'aviso', texto: `Escena ${i + 1}: el rótulo (y ${s.rotuloY}) queda bajo la interfaz de la app.`});
48
+ });
49
+ const sy = v.tema?.subtitulos?.y;
50
+ if (sy != null && (sy > 1600 || sy < 160)) out.push({id: 'zona', nivel: 'aviso', texto: `Los subtítulos (y ${sy}) quedan bajo la interfaz de la app: entre 200 y 1550 se leen en todas.`});
51
+ return out;
52
+ }
53
+
54
+ /**
55
+ * Compara lo que la voz dijo (palabras de Whisper en datos.json) con el `say` de cada escena. Solo con tiempos reales.
56
+ * @returns {{id: string, nivel: 'aviso' | 'idea', texto: string}[]}
57
+ */
58
+ export function revisarVoz(v, d) {
59
+ if (!d?.escenas || !d.whisper) return [];
60
+ const out = [];
61
+ (v.scenes ?? []).forEach((s, i) => {
62
+ const e = d.escenas[i];
63
+ if (!e?.palabras?.length || s.audio || !s.say) return;
64
+ const esperadas = String(s.say).split(/\s+/).map(normalizar).filter((w) => w.length > 2);
65
+ if (esperadas.length < 3) return;
66
+ const dichas = e.palabras.map(([w]) => normalizar(w));
67
+ // Whisper escribe las cifras con números ("33"): las palabras de número cuentan como dichas si hay cifras
68
+ const hayCifras = dichas.some((x) => /\d/.test(x));
69
+ const faltan = esperadas.filter((w) => !(hayCifras && NUMEROS.test(w)) && !dichas.some((x) => x.startsWith(w.slice(0, 5)) || w.startsWith(x.slice(0, 5))));
70
+ if (faltan.length / esperadas.length > 0.25)
71
+ out.push({id: 'voz', nivel: 'aviso', texto: `Escena ${i + 1}: la voz no dijo bien ${faltan.length} de ${esperadas.length} palabras (${faltan.slice(0, 4).join(', ')}). Escucha esa escena; escribe las cifras y siglas con letras en \`say\`.`});
72
+ });
73
+ return out;
74
+ }
package/lib/config.mjs CHANGED
@@ -1,5 +1,6 @@
1
1
  // Carga y valida morfeo.config.mjs del proyecto del usuario.
2
2
  import {IDS_FORMULA} from './formulas.mjs';
3
+ import {aplicarEstiloPropio, leerEstiloPropio} from './estilo-propio.mjs';
3
4
  import fs from 'node:fs';
4
5
  import path from 'node:path';
5
6
  import {pathToFileURL} from 'node:url';
@@ -26,7 +27,7 @@ export const TIPOS_ESCENA = [...new Set([...TIPOS_CLASICOS, ...TIPOS_MOTOR])];
26
27
  export const ROTULOS = ['normal', 'contorno', 'pastilla', 'sello', 'paso', 'pixel'];
27
28
  export const MODOS = ['rayosx', 'plano', 'holograma'];
28
29
  export const ESTILOS = ['datos-curiosos', 'explicativo', 'documental', 'ilustrado', 'historia', 'mesa', 'serie', 'infografia', '3d', 'maqueta', 'minimal'];
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'];
30
+ 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', 'linea'];
30
31
  export const ESCENARIOS = ['bosque', 'cuarto', 'departamento', 'tunel', 'pasillo'];
31
32
  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'];
32
33
  export const PROVEEDORES_VOZ = ['kokoro', 'elevenlabs', 'openai'];
@@ -80,6 +81,12 @@ export async function cargarProyecto(proyecto = process.cwd()) {
80
81
  let cfg = await importar(archivo);
81
82
  if (typeof cfg === 'function') cfg = await cfg();
82
83
  if (!cfg || typeof cfg !== 'object') throw new ErrorMorfeo(`${path.basename(archivo)} debe exportar un objeto (export default defineConfig({...}))`);
84
+ // estilo.json (de `morfeo estilo`): nombre, siglas, colores, letra, voz, ritmo y cierre encima del config
85
+ try {
86
+ cfg = aplicarEstiloPropio(cfg, leerEstiloPropio(proyecto), proyecto);
87
+ } catch (e) {
88
+ throw new ErrorMorfeo(e.message);
89
+ }
83
90
 
84
91
  let videos = cfg.videos ?? (fs.existsSync(path.join(proyecto, 'videos.mjs')) ? './videos.mjs' : './guiones');
85
92
  const carpetaGuiones = typeof videos === 'string' && fs.existsSync(path.resolve(proyecto, videos)) && fs.statSync(path.resolve(proyecto, videos)).isDirectory()
@@ -103,6 +110,9 @@ export async function cargarProyecto(proyecto = process.cwd()) {
103
110
  redes: m.redes ?? '',
104
111
  seguir: m.seguir ?? '+ SEGUIR',
105
112
  siguiendo: m.siguiendo ?? 'SIGUIENDO',
113
+ ...(m.siglas ? {siglas: String(m.siglas)} : {}),
114
+ ...(m.color ? {color: String(m.color)} : {}),
115
+ ...(m.letra ? {letra: String(m.letra)} : {}),
106
116
  etiqueta: m.etiqueta ?? 'DATO CURIOSO',
107
117
  cta: m.cta ?? (m.nombre ? `Sígueme en ${m.nombre} para más.` : 'Sígueme para más.'),
108
118
  };
@@ -112,7 +122,7 @@ export async function cargarProyecto(proyecto = process.cwd()) {
112
122
  const estilosPropios = cfg.estilos ?? {};
113
123
  const estilo = cfg.estilo ?? 'datos-curiosos';
114
124
  const assetsDir = path.resolve(proyecto, cfg.assets ?? 'assets');
115
- const ctx = {proyecto, marca, voces: cfg.voces ?? {}, musicas: cfg.musicas ?? {}, errores, estilo, estilosPropios, assetsDir, personajes: cfg.personajes ?? {}, componentes: Object.keys(cfg.componentes ?? {})};
125
+ const ctx = {proyecto, marca, propio: cfg.__estiloPropio ?? null, voces: cfg.voces ?? {}, musicas: cfg.musicas ?? {}, errores, estilo, estilosPropios, assetsDir, personajes: cfg.personajes ?? {}, componentes: Object.keys(cfg.componentes ?? {})};
116
126
  if (!ESTILOS.includes(estilo) && !estilosPropios[estilo]) errores.push(`estilo "${estilo}" no existe. Opciones: ${[...ESTILOS, ...Object.keys(estilosPropios)].join(', ')}`);
117
127
  const componentes = {};
118
128
  for (const [nombre, ruta] of Object.entries(cfg.componentes ?? {})) {
@@ -161,6 +171,7 @@ export async function cargarProyecto(proyecto = process.cwd()) {
161
171
  concurrencia: Number(process.env.MORFEO_CONCURRENCIA) || r.concurrencia || null, // null = Morfeo elige según la máquina y el modo (ver render.mjs)
162
172
  miniatura: r.miniatura ?? true,
163
173
  consejos: r.consejos ?? true,
174
+ portada: r.portada ?? true,
164
175
  // 'titulo' (por defecto): salida/<Título del video>.mp4, listo para subir · 'id': salida/<id>.mp4
165
176
  nombreArchivo: r.nombreArchivo ?? 'titulo',
166
177
  },
@@ -230,6 +241,7 @@ function validarCapas(capas, donde, err, comps) {
230
241
  if (c.estilo && !['2d', '3d', 'realista'].includes(c.estilo)) err(`${d}: estilo "${c.estilo}" no existe. Usa '2d', '3d' o 'realista'`);
231
242
  }
232
243
  if (c.tipo === 'dividida') for (const lado of ['izq', 'der', 'arriba', 'abajo']) if (c[lado]?.capas) validarCapas(c[lado].capas, `${d}, ${lado}`, err, comps);
244
+ if (c.tipo === 'linea' && (!Array.isArray(c.hitos) || !c.hitos.length)) err(`${d}: \`linea\` necesita \`hitos\`: [{anio: 1963, texto: '…', en: 'palabra'}]`);
233
245
  if (c.tipo === 'cintillo' && !c.nombre) err(`${d}: \`cintillo\` necesita \`nombre\``);
234
246
  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
247
  if (c.tipo === 'tierra' && (!esNum(c.lat) || !esNum(c.lon))) err(`${d}: \`tierra\` necesita \`lat\` y \`lon\` (números)`);
@@ -303,7 +315,9 @@ function validarVideo(id, v, ctx) {
303
315
  const err = (m) => ctx.errores.push(`${id}: ${m}`);
304
316
  if (!v || typeof v !== 'object') return err('debe ser un objeto'), null;
305
317
  if (!Array.isArray(v.scenes) || !v.scenes.length) return err('falta `scenes` (lista de escenas)'), null;
306
- const estilo = v.estilo ?? ctx.estilo;
318
+ // con estilo.json, un video viejo de escenas clásicas (title, text, card…) sin `estilo` sigue en su estilo de antes
319
+ const deClasicas = v.scenes.some((s) => s && TIPOS_CLASICOS.includes(s.type) && s.type !== 'cta' && !TIPOS_MOTOR.includes(s.type));
320
+ const estilo = v.estilo ?? (ctx.propio && deClasicas ? ctx.propio.estiloAnterior ?? 'datos-curiosos' : ctx.estilo);
307
321
  const clasico = estilo === 'datos-curiosos';
308
322
  if (!ESTILOS.includes(estilo) && !ctx.estilosPropios[estilo]) err(`estilo "${estilo}" no existe. Opciones: ${[...ESTILOS, ...Object.keys(ctx.estilosPropios)].join(', ')}`);
309
323
  const permitidos = clasico ? TIPOS_ESCENA : TIPOS_MOTOR;
@@ -356,6 +370,9 @@ function validarVideo(id, v, ctx) {
356
370
  // la voz sintética solo hace falta si alguna escena no trae su propia grabación
357
371
  const necesitaVoz = scenes.some((s) => s && !s.audio);
358
372
  let voice = null;
373
+ // sin `voice`, la del estilo propio (estilo.json)
374
+ if (v.voice == null && ctx.propio?.voz) v = {...v, voice: ctx.propio.voz};
375
+ if (v.speed == null && ctx.propio?.velocidad) v = {...v, speed: ctx.propio.velocidad};
359
376
  if (v.voice != null) voice = normalizarVoz(v.voice, ctx, err);
360
377
  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)');
361
378
  if (v.hud != null) validarCapas(v.hud, 'hud', err, ctx.componentes);
@@ -372,6 +389,9 @@ function validarVideo(id, v, ctx) {
372
389
  if (v.serie != null && typeof v.serie !== 'string') err("`serie` debe ser un texto ('GYM CON CIENCIA · PARTE 5')");
373
390
  if (v.title != null && typeof v.title !== 'string') err('`title` debe ser un texto (también es el nombre del archivo del video)');
374
391
  if (v.calidad != null && !['alta', 'media', 'ligera'].includes(v.calidad)) err("`calidad` debe ser 'alta', 'media' o 'ligera'");
392
+ if (v.portada != null && v.portada !== false && typeof v.portada !== 'string') err('`portada` debe ser un texto (el título grande de la portada) o false');
393
+ if (v.hooks != null && (!Array.isArray(v.hooks) || v.hooks.some((x) => typeof x !== 'string'))) err("`hooks` debe ser una lista de textos (ganchos de reserva)");
394
+ if (v.fuentes != null && (!Array.isArray(v.fuentes) || v.fuentes.some((x) => typeof x !== 'string' && !(x && typeof x === 'object' && (x.texto || x.url))))) err("`fuentes` debe ser una lista: ['INEGI 2024'] o [{texto: 'INEGI', url: 'https://…'}]");
375
395
  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'])");
376
396
  // archivos de assets/ que piden las escenas y el HUD: mejor avisar ahora que a mitad del render
377
397
  revisarArchivos({scenes, hud: v.hud, mundo: v.mundo, lugares: v.lugares, tema: v.tema}, ctx.assetsDir, err);
@@ -0,0 +1,174 @@
1
+ // `morfeo estilo`: abre una página en tu computadora para crear el estilo del canal (nombre, siglas, colores, letra,
2
+ // voz, ritmo y cierre) con una vista previa en vivo. Al guardar escribe estilo.json en el proyecto y baja la letra
3
+ // elegida a fuentes/. No sale nada de tu computadora salvo la descarga de la letra (Google Fonts, licencia libre).
4
+ import fs from 'node:fs';
5
+ import http from 'node:http';
6
+ import path from 'node:path';
7
+ import {spawn} from 'node:child_process';
8
+ import {ARCHIVO_ESTILO, LETRAS, RITMOS, leerEstiloPropio} from './estilo-propio.mjs';
9
+ import {ESTILOS} from './config.mjs';
10
+
11
+ const VOCES_KOKORO = {
12
+ 'Narrador (mezcla)': [['em_alex', 0.6], ['am_michael', 0.4]],
13
+ 'Narradora (mezcla)': [['ef_dora', 0.6], ['af_heart', 0.4]],
14
+ 'Alex (hombre, español)': 'em_alex',
15
+ 'Santa (hombre, español)': 'em_santa',
16
+ 'Dora (mujer, español)': 'ef_dora',
17
+ };
18
+
19
+ const esc = (s) => String(s ?? '').replace(/[&<>"]/g, (c) => ({'&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;'})[c]);
20
+
21
+ function pagina(actual) {
22
+ const a = actual ?? {};
23
+ const vozActual = typeof a.voz === 'object' && a.voz?.proveedor === 'elevenlabs' ? 'eleven' : JSON.stringify(a.voz ?? VOCES_KOKORO['Narrador (mezcla)']);
24
+ const familias = Object.keys(LETRAS).filter((f) => f !== 'Montserrat').map((f) => f.replace(/ /g, '+')).join('&family=');
25
+ return `<!doctype html><html lang="es"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
26
+ <title>Tu estilo · Morfeo</title>
27
+ <link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=${familias}&family=Montserrat:wght@500;700;900&display=swap">
28
+ <style>
29
+ :root{--fondo:#0f0d15;--panel:#17151f;--linea:rgba(241,236,228,.12);--tinta:#f1ece4;--tenue:#a49dae;--acento:${esc(a.colores?.acento ?? '#FFD400')}}
30
+ *{box-sizing:border-box}body{margin:0;background:var(--fondo);color:var(--tinta);font-family:Montserrat,system-ui,sans-serif}
31
+ main{max-width:1100px;margin:0 auto;padding:40px 24px 80px;display:grid;grid-template-columns:1fr 360px;gap:48px}
32
+ h1{font-weight:900;font-size:34px;margin:0 0 6px;letter-spacing:-.02em}p.sub{color:var(--tenue);margin:0 0 30px}
33
+ fieldset{border:1px solid var(--linea);border-radius:16px;padding:18px 20px 20px;margin:0 0 18px}
34
+ legend{font-size:12px;letter-spacing:.14em;text-transform:uppercase;color:var(--tenue);padding:0 8px}
35
+ label{display:block;font-size:14px;font-weight:700;margin:12px 0 6px}
36
+ input[type=text],select{width:100%;background:var(--panel);border:1px solid var(--linea);color:var(--tinta);border-radius:10px;padding:11px 12px;font:inherit}
37
+ .fila{display:grid;grid-template-columns:1fr 1fr;gap:14px}.colores{display:flex;gap:12px;align-items:center}
38
+ input[type=color]{width:52px;height:40px;border:none;background:none;padding:0;cursor:pointer}
39
+ .ritmos{display:flex;gap:8px}.ritmos label{flex:1;margin:0;text-align:center;border:1px solid var(--linea);border-radius:10px;padding:10px;cursor:pointer;font-weight:600}
40
+ .ritmos input{display:none}.ritmos input:checked+span{color:var(--acento)}.ritmos label:has(input:checked){border-color:var(--acento)}
41
+ button{margin-top:10px;background:var(--acento);color:#111;border:none;border-radius:12px;padding:14px 26px;font:900 16px Montserrat;cursor:pointer}
42
+ .nota{font-size:13px;color:var(--tenue);margin-top:6px}
43
+ .tel{position:sticky;top:30px;width:360px;aspect-ratio:9/16;border-radius:34px;padding:9px;background:#0b0a10;box-shadow:0 30px 60px rgba(0,0,0,.5),inset 0 0 0 2px #2a2733}
44
+ .pantalla{position:relative;width:100%;height:100%;border-radius:27px;overflow:hidden;background:radial-gradient(ellipse at 50% 30%,#2b2f55,#0c0d1a)}
45
+ .marca{position:absolute;left:16px;top:44px;display:flex;align-items:center;gap:8px;font-weight:800;font-size:14px}
46
+ .av{width:34px;height:34px;border-radius:50%;border:2px solid #fff;display:grid;place-items:center;font-weight:900;font-size:13px;background:linear-gradient(var(--acento),#111);color:#fff}
47
+ .rotulo{position:absolute;left:18px;right:18px;top:38%;text-align:center;font-size:42px;line-height:1;text-transform:uppercase;color:#fff;-webkit-text-stroke:2px #000;paint-order:stroke fill}
48
+ .rotulo b{color:var(--acento);font-weight:inherit}
49
+ .subs{position:absolute;left:0;right:0;top:62%;text-align:center;font-weight:800;font-size:17px;color:#fff}.subs span{background:var(--sub,#4C8DFF);padding:1px 6px;border-radius:4px}
50
+ .cierre{position:absolute;left:18px;right:18px;bottom:40px;text-align:center;font-size:13px;color:var(--tenue)}
51
+ .ok{display:none;margin-top:14px;padding:12px 14px;border-radius:10px;background:rgba(48,164,108,.15);color:#7ee0a8;font-weight:700}
52
+ @media (max-width:860px){main{grid-template-columns:1fr}.tel{position:relative;top:0;margin:0 auto}}
53
+ </style></head><body><main>
54
+ <form id="f">
55
+ <h1>Tu estilo</h1><p class="sub">Así se verán y sonarán todos tus videos. Puedes cambiarlo cuando quieras.</p>
56
+ <fieldset><legend>Canal</legend>
57
+ <div class="fila"><div><label>Nombre</label><input type="text" name="nombre" value="${esc(a.nombre ?? '')}" placeholder="Datos Rápidos"></div>
58
+ <div><label>Siglas (sin logo)</label><input type="text" name="siglas" maxlength="3" value="${esc(a.siglas ?? '')}" placeholder="DR"></div></div>
59
+ <label>Redes (salen en el cierre)</label><input type="text" name="redes" value="${esc(a.redes ?? '')}" placeholder="TikTok @datosrapidos · YT @datosrapidos">
60
+ </fieldset>
61
+ <fieldset><legend>Cómo se ve</legend>
62
+ <label>Estilo base</label><select name="base">${ESTILOS.filter((e) => e !== 'datos-curiosos').map((e) => `<option ${e === (a.base ?? 'documental') ? 'selected' : ''}>${e}</option>`).join('')}</select>
63
+ <label>Colores</label><div class="colores"><input type="color" name="acento" value="${esc(a.colores?.acento ?? '#FFD400')}"><span class="nota">acento (cifras, palabra clave, siglas)</span>
64
+ <input type="color" name="subtitulo" value="${esc(a.colores?.subtitulo ?? '#4C8DFF')}"><span class="nota">caja del subtítulo</span></div>
65
+ <label>Letra de títulos</label><select name="letra">${Object.keys(LETRAS).map((f) => `<option ${f === (a.letra ?? 'Anton') ? 'selected' : ''}>${f}</option>`).join('')}</select>
66
+ </fieldset>
67
+ <fieldset><legend>Cómo suena</legend>
68
+ <label>Voz</label><select name="voz">${Object.entries(VOCES_KOKORO).map(([n, v]) => `<option value='${esc(JSON.stringify(v))}' ${JSON.stringify(v) === vozActual ? 'selected' : ''}>${n} · gratis</option>`).join('')}<option value="eleven" ${vozActual === 'eleven' ? 'selected' : ''}>Mi voz de ElevenLabs</option></select>
69
+ <div id="eleven" style="display:${vozActual === 'eleven' ? 'block' : 'none'}"><label>ID de la voz en ElevenLabs</label><input type="text" name="elevenId" value="${esc(a.voz?.voz ?? '')}" placeholder="21m00Tcm4TlvDq8ikWAM"><p class="nota">La clave va en el .env del proyecto: ELEVENLABS_API_KEY=…</p></div>
70
+ <label>Ritmo</label><div class="ritmos">${Object.keys(RITMOS).map((r) => `<label><input type="radio" name="ritmo" value="${r}" ${r === (a.ritmo ?? 'natural') ? 'checked' : ''}><span>${r === 'agil' ? 'Ágil' : r[0].toUpperCase() + r.slice(1)}</span></label>`).join('')}</div>
71
+ <label>Frase de cierre</label><input type="text" name="cierre" value="${esc(a.cierre ?? '')}" placeholder="Guárdalo para cuando te pregunten.">
72
+ </fieldset>
73
+ <button type="submit">Guardar estilo</button><div class="ok" id="ok"></div>
74
+ </form>
75
+ <div><div class="tel"><div class="pantalla"><div class="marca"><div class="av" id="pav">M</div><span id="pnom">Mi Canal</span></div>
76
+ <div class="rotulo" id="prot">MIL <b>TRESCIENTOS</b> METROS</div><div class="subs">a casi <span>mil trescientos</span> metros</div><div class="cierre" id="pcie"></div></div></div></div>
77
+ </main>
78
+ <script>
79
+ const f=document.getElementById('f');
80
+ function vista(){const d=new FormData(f);document.documentElement.style.setProperty('--acento',d.get('acento'));document.querySelector('.subs').style.setProperty('--sub',d.get('subtitulo'));
81
+ document.getElementById('pnom').textContent=d.get('nombre')||'Mi Canal';document.getElementById('pav').textContent=(d.get('siglas')||(d.get('nombre')||'M')[0]).toUpperCase();
82
+ document.getElementById('prot').style.fontFamily='"'+d.get('letra')+'"';document.getElementById('pcie').textContent=d.get('cierre')||'';
83
+ document.getElementById('eleven').style.display=d.get('voz')==='eleven'?'block':'none';}
84
+ f.addEventListener('input',vista);vista();
85
+ f.addEventListener('submit',async(e)=>{e.preventDefault();const d=Object.fromEntries(new FormData(f));const r=await fetch('/guardar',{method:'POST',headers:{'content-type':'application/json'},body:JSON.stringify(d)});
86
+ const t=await r.text();const ok=document.getElementById('ok');ok.style.display='block';ok.textContent=t;});
87
+ </script></body></html>`;
88
+ }
89
+
90
+ /** Arma estilo.json desde el formulario. */
91
+ export function estiloDesdeFormulario(d) {
92
+ let voz;
93
+ if (d.voz === 'eleven') voz = {proveedor: 'elevenlabs', voz: String(d.elevenId ?? '').trim()};
94
+ else {
95
+ try {
96
+ voz = JSON.parse(d.voz);
97
+ } catch {
98
+ voz = 'em_alex';
99
+ }
100
+ }
101
+ return {
102
+ nombre: String(d.nombre ?? '').trim(),
103
+ siglas: String(d.siglas ?? '').trim().toUpperCase().slice(0, 3),
104
+ redes: String(d.redes ?? '').trim(),
105
+ base: ESTILOS.includes(d.base) ? d.base : 'documental',
106
+ colores: {acento: d.acento || '#FFD400', subtitulo: d.subtitulo || '#4C8DFF'},
107
+ letra: LETRAS[d.letra] !== undefined ? d.letra : 'Anton',
108
+ voz,
109
+ ritmo: RITMOS[d.ritmo] ? d.ritmo : 'natural',
110
+ cierre: String(d.cierre ?? '').trim(),
111
+ };
112
+ }
113
+
114
+ /** Baja la letra a fuentes/ del proyecto (si no viene ya en el paquete o ya está). */
115
+ export async function bajarLetra(proyecto, letra) {
116
+ const ruta = LETRAS[letra];
117
+ if (!ruta) return null;
118
+ const destino = path.join(proyecto, 'fuentes', `${letra.replace(/\s+/g, '')}.ttf`);
119
+ if (fs.existsSync(destino)) return destino;
120
+ const r = await fetch(`https://raw.githubusercontent.com/google/fonts/main/${ruta}`);
121
+ if (!r.ok) throw new Error(`no pude bajar la letra ${letra} (${r.status})`);
122
+ fs.mkdirSync(path.dirname(destino), {recursive: true});
123
+ fs.writeFileSync(destino, Buffer.from(await r.arrayBuffer()));
124
+ return destino;
125
+ }
126
+
127
+ export function abrirNavegador(url) {
128
+ const cmd = process.platform === 'darwin' ? 'open' : process.platform === 'win32' ? 'cmd' : 'xdg-open';
129
+ const args = process.platform === 'win32' ? ['/c', 'start', '', url] : [url];
130
+ try {
131
+ spawn(cmd, args, {stdio: 'ignore', detached: true}).unref();
132
+ } catch {}
133
+ }
134
+
135
+ /** Levanta la página en localhost y espera a que se guarde (o a Ctrl+C). */
136
+ export function abrirEditorDeEstilo({proyecto, puerto = 4321, log = console.log, abrir = true}) {
137
+ return new Promise((resolve, reject) => {
138
+ const srv = http.createServer(async (req, res) => {
139
+ try {
140
+ if (req.method === 'GET' && (req.url === '/' || req.url?.startsWith('/?'))) {
141
+ res.writeHead(200, {'content-type': 'text/html; charset=utf-8'});
142
+ return res.end(pagina(leerEstiloPropio(proyecto)));
143
+ }
144
+ if (req.method === 'POST' && req.url === '/guardar') {
145
+ let cuerpo = '';
146
+ for await (const trozo of req) cuerpo += trozo;
147
+ const e = estiloDesdeFormulario(JSON.parse(cuerpo));
148
+ let aviso = '';
149
+ try {
150
+ await bajarLetra(proyecto, e.letra);
151
+ } catch (err) {
152
+ aviso = ` (la letra no se pudo bajar: ${err.message}; se usará Anton)`;
153
+ e.letra = 'Anton';
154
+ }
155
+ fs.writeFileSync(path.join(proyecto, ARCHIVO_ESTILO), JSON.stringify(e, null, '\t') + '\n');
156
+ log(`Guardado ${ARCHIVO_ESTILO}${aviso}. Ya puedes cerrar la página.`);
157
+ res.writeHead(200, {'content-type': 'text/plain; charset=utf-8'});
158
+ res.end(`Listo: tus videos ya salen con este estilo${aviso}. Vuelve a la terminal o a tu IA y pide un video.`);
159
+ setTimeout(() => srv.close(() => resolve(e)), 300);
160
+ return;
161
+ }
162
+ res.writeHead(404).end();
163
+ } catch (err) {
164
+ res.writeHead(500, {'content-type': 'text/plain; charset=utf-8'}).end(String(err.message));
165
+ }
166
+ });
167
+ srv.on('error', reject);
168
+ srv.listen(puerto, '127.0.0.1', () => {
169
+ const url = `http://localhost:${puerto}/`;
170
+ log(`Abre ${url} para crear tu estilo (Ctrl+C para salir).`);
171
+ if (abrir) abrirNavegador(url);
172
+ });
173
+ });
174
+ }
@@ -0,0 +1,70 @@
1
+ // Estilo propio del canal (estilo.json en la carpeta del proyecto, lo escribe `morfeo estilo`): nombre, siglas,
2
+ // colores, letra, voz, ritmo y frase de cierre. Se aplica al cargar el proyecto, encima de morfeo.config.mjs:
3
+ // todos los videos salen así salvo que uno diga otra cosa (su `estilo`, `voice` o `speed`).
4
+ import fs from 'node:fs';
5
+ import path from 'node:path';
6
+
7
+ export const ARCHIVO_ESTILO = 'estilo.json';
8
+
9
+ /** Letras para títulos (OFL/Apache, del repositorio de Google Fonts). Las dos primeras ya vienen en el paquete. */
10
+ export const LETRAS = {
11
+ Anton: null,
12
+ Montserrat: null,
13
+ 'Archivo Black': 'ofl/archivoblack/ArchivoBlack-Regular.ttf',
14
+ 'Bebas Neue': 'ofl/bebasneue/BebasNeue-Regular.ttf',
15
+ 'Luckiest Guy': 'apache/luckiestguy/LuckiestGuy-Regular.ttf',
16
+ Bangers: 'ofl/bangers/Bangers-Regular.ttf',
17
+ 'Lilita One': 'ofl/lilitaone/LilitaOne-Regular.ttf',
18
+ 'Russo One': 'ofl/russoone/RussoOne-Regular.ttf',
19
+ 'Titan One': 'ofl/titanone/TitanOne-Regular.ttf',
20
+ 'Black Ops One': 'ofl/blackopsone/BlackOpsOne-Regular.ttf',
21
+ 'Rubik Mono One': 'ofl/rubikmonoone/RubikMonoOne-Regular.ttf',
22
+ 'Passion One': 'ofl/passionone/PassionOne-Black.ttf',
23
+ };
24
+
25
+ /** Ritmo → velocidad de la voz y duración de entradas/transiciones. */
26
+ export const RITMOS = {
27
+ pausado: {velocidad: 1.05, movimiento: {entrada: 0.6, salida: 0.45, transicion: 0.45, curva: 'suave'}},
28
+ natural: {velocidad: 1.2, movimiento: {entrada: 0.45, salida: 0.35, transicion: 0.35, curva: 'suave'}},
29
+ agil: {velocidad: 1.32, movimiento: {entrada: 0.3, salida: 0.25, transicion: 0.25, curva: 'seca'}},
30
+ };
31
+
32
+ export function leerEstiloPropio(proyecto) {
33
+ const f = path.join(proyecto, ARCHIVO_ESTILO);
34
+ if (!fs.existsSync(f)) return null;
35
+ try {
36
+ return JSON.parse(fs.readFileSync(f, 'utf8'));
37
+ } catch (e) {
38
+ throw new Error(`${ARCHIVO_ESTILO} no es un JSON válido: ${e.message}`);
39
+ }
40
+ }
41
+
42
+ /**
43
+ * Mezcla el estilo propio en la configuración (devuelve una copia). El estilo queda como `mi-estilo`, heredando de
44
+ * `base`, y es el estilo por defecto del proyecto.
45
+ */
46
+ export function aplicarEstiloPropio(cfg, e, proyecto) {
47
+ if (!e) return cfg;
48
+ const out = {...cfg, marca: {...(cfg.marca ?? {})}, voces: {...(cfg.voces ?? {})}, estilos: {...(cfg.estilos ?? {})}, fuentes: {...(cfg.fuentes ?? {})}};
49
+ if (e.nombre) out.marca.nombre = e.nombre;
50
+ if (e.siglas) out.marca.siglas = e.siglas;
51
+ if (e.colores?.acento) out.marca.color = e.colores.acento;
52
+ if (e.cierre) out.marca.cta = e.cierre;
53
+ if (e.letra) out.marca.letra = e.letra;
54
+ if (e.redes) out.marca.redes = e.redes;
55
+ const ritmo = RITMOS[e.ritmo] ?? RITMOS.natural;
56
+ const tema = {base: e.base ?? cfg.estilo ?? 'documental', movimiento: ritmo.movimiento};
57
+ if (e.colores?.acento) tema.colores = {acento: e.colores.acento, ...(e.colores.acento2 ? {acento2: e.colores.acento2} : {})};
58
+ if (e.colores?.subtitulo) tema.subtitulos = {resalte: e.colores.subtitulo};
59
+ if (e.letra && e.letra !== 'Montserrat') {
60
+ tema.fuentes = {titulo: e.letra};
61
+ // las letras descargadas viven en fuentes/ del proyecto (Anton viene en el paquete)
62
+ const archivo = path.join(proyecto, 'fuentes', `${e.letra.replace(/\s+/g, '')}.ttf`);
63
+ if (LETRAS[e.letra] && fs.existsSync(archivo)) out.fuentes[e.letra] = archivo;
64
+ }
65
+ out.estilos['mi-estilo'] = tema;
66
+ out.estilo = 'mi-estilo';
67
+ if (e.voz) out.voces['mi-voz'] = e.voz;
68
+ out.__estiloPropio = {voz: e.voz ? 'mi-voz' : null, velocidad: ritmo.velocidad, estiloAnterior: cfg.estilo ?? 'datos-curiosos'};
69
+ return out;
70
+ }
@@ -43,6 +43,7 @@ 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 {revisarVoz, revisarZona} from './calidad.mjs';
46
47
  import {formulaDe, revisarFormula} from './formulas.mjs';
47
48
 
48
49
  const palabrasDe = (s) => String(s ?? '').trim().split(/\s+/).filter(Boolean);
@@ -65,6 +66,9 @@ export function revisarEstructura(v, d) {
65
66
  // formato largo: explicador de maqueta (o `formato: 'largo'` en el video)
66
67
  const formula = formulaDe(v.formula);
67
68
  const largo = v.estilo === 'maqueta' || v.formato === 'largo' || (formula?.duracion[1] ?? 0) > 90;
69
+ // a qué se compara: los explicadores de maqueta (100–130 s) o la duración de la fórmula (biografía 90–180 s…)
70
+ const enLargo = v.estilo === 'maqueta' || !formula ? 'en maqueta' : `en la fórmula "${formula.id}"`;
71
+ const rangoLargo = v.estilo === 'maqueta' || !formula ? [100, 130] : formula.duracion;
68
72
 
69
73
  // 1) gancho
70
74
  const dicho = String(primera.say ?? '').trim();
@@ -78,8 +82,8 @@ export function revisarEstructura(v, d) {
78
82
  const es = d?.escenas?.filter((e) => e.tipo !== 'cta') ?? [];
79
83
  if (es.length) {
80
84
  const dur = d.duracion;
81
- if (largo && dur > 140) add('duracion', 'idea', `Dura ${Math.round(dur)} s: los explicadores de maqueta de referencia van de 100 a 130 s.`);
82
- else if (largo && dur < 60) add('duracion', 'idea', `Dura ${Math.round(dur)} s: en maqueta los de referencia duran 100–130 s; hay espacio para otra pregunta y su respuesta.`);
85
+ if (largo && dur > rangoLargo[1] + 10) add('duracion', 'idea', `Dura ${Math.round(dur)} s: ${enLargo} los de referencia van de ${rangoLargo[0]} a ${rangoLargo[1]} s.`);
86
+ else if (largo && dur < 60) add('duracion', 'idea', `Dura ${Math.round(dur)} s: ${enLargo} los de referencia duran ${rangoLargo[0]}–${rangoLargo[1]} s; hay espacio para otra pregunta y su respuesta.`);
83
87
  else if (!largo && dur > 75) add('duracion', 'idea', `Dura ${Math.round(dur)} s: los de referencia rondan 60 s. Si no es una serie o una historia, recorta (o usa estilo maqueta para un explicador largo).`);
84
88
  if (dur < 20) add('duracion', 'idea', `Dura ${Math.round(dur)} s: un poco más de desarrollo (una pregunta y su respuesta) suele retener mejor.`);
85
89
  const e0 = es[0];
@@ -90,7 +94,7 @@ export function revisarEstructura(v, d) {
90
94
  // algo que cambie a media escena: capas con `en`, cámara con rango, toma con movimiento, o un mundo vivo
91
95
  const capas = s.capas ?? [];
92
96
  const cambia = capas.some((c) => c.en != null && c.en !== 0 && c.en !== 'inicio') || Array.isArray(s.camara?.zoom) || s.toma?.a || capas.some((c) => c.mover || c.cambios);
93
- if (largo && voz > 4.5) add('ritmo', 'idea', `Escena ${i + 1}: ${voz.toFixed(1)} s en una sola toma; en maqueta la cámara corta cada 2–3 s. Pártela en dos tomas.`);
97
+ if (largo && voz > 4.5) add('ritmo', 'idea', `Escena ${i + 1}: ${voz.toFixed(1)} s en una sola toma; ${enLargo} la cámara corta cada 2–3 s. Pártela en dos tomas.`);
94
98
  else if (voz > 6 && !cambia && !v.mundo?.length) add('ritmo', 'aviso', `Escena ${i + 1}: ${voz.toFixed(1)} s de voz sin que nada cambie. Pártela en dos o haz que algo entre a media frase.`);
95
99
  else if (voz > 9) add('ritmo', 'idea', `Escena ${i + 1}: ${voz.toFixed(1)} s de voz en una sola escena; en los de referencia ninguna idea pasa de ~5 s.`);
96
100
  });
@@ -109,10 +113,12 @@ export function revisarEstructura(v, d) {
109
113
  // 5) habla de tú
110
114
  const todo = cuerpo.map((s) => s.say ?? '').join(' ');
111
115
  const n = palabrasDe(todo).length;
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;
116
+ const tu = (todo.match(/(?<!\p{L})(tú|tu|tus|te|ti|contigo|imagina|imaginas|tienes|eres|puedes|sabes|sabías|crees|conoces|harías|pagarías|jugabas|tuviste|tuyo|tuya)(?!\p{L})/giu) ?? []).length;
113
117
  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
118
  // 6) lo propio de la fórmula del video (lib/formulas.mjs)
115
119
  for (const t of revisarFormula(v)) add('formula', 'idea', t);
120
+ // 7) lo que no se ve a simple vista: la voz dijo lo del guion y nada queda bajo los botones de las apps
121
+ out.push(...revisarZona(v), ...revisarVoz(v, d));
116
122
  // "envíaselo a…" también es un cierre que pide algo
117
123
  return out;
118
124
  }