morfeo 0.3.4 → 0.5.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
@@ -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
@@ -33,6 +35,10 @@ Videos
33
35
  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)
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)
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
36
42
  --estilo documental estilo visual (por defecto, el del proyecto)
37
43
  --imagenes pide ilustraciones con IA (necesita OPENAI_API_KEY)
38
44
  --id nombre --voz em_alex --duracion 40 --idioma "inglés" --tono "humor"
@@ -53,7 +59,7 @@ function opciones(argv) {
53
59
  const [k, v] = a.slice(2).split('=');
54
60
  const clave = k.replace(/-([a-z])/g, (_, c) => c.toUpperCase());
55
61
  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'];
62
+ const conValor = ['proyecto', 'desde', 'id', 'voz', 'duracion', 'idioma', 'estilo', 'tono', 'variante', 'fuente', 'musica', 'titulo', 'modelo', 'puerto', 'hasta', 'cols', 'filas', 'look', 'formula'];
57
63
  if (v != null) op[clave] = v;
58
64
  else if (conValor.includes(clave) && sig != null && !sig.startsWith('--')) op[clave] = argv[++i];
59
65
  else op[clave] = true;
@@ -87,12 +93,11 @@ async function main() {
87
93
  return nuevo(pos[0], op);
88
94
  case 'nuevo-video': {
89
95
  const {nuevoVideo} = await import('../lib/nuevo-video.mjs');
90
- const look = op.look === true || op.look == null ? '2d' : String(op.look).toLowerCase();
96
+ const look = typeof op.formula === 'string' ? op.formula.toLowerCase() : op.look === true || op.look == null ? '2d' : String(op.look).toLowerCase();
91
97
  const r = nuevoVideo({proyecto, id: pos[0], look});
92
98
  const rel = (f) => path.relative(process.cwd(), f);
93
99
  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)
100
+ ${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
101
 
97
102
  Pruébalo: npx morfeo render ${pos[0]} y revísalo: npx morfeo hoja ${pos[0]}`);
98
103
  return;
@@ -112,6 +117,14 @@ Pruébalo: npx morfeo render ${pos[0]} y revísalo: npx morfeo hoja ${pos[0]
112
117
  await render({proyecto, ids: pos, todos: !!op.todos || pos[0] === 'todos', soloFallidos: !!op.soloFallidos});
113
118
  return;
114
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
+ }
115
128
  case 'revisar': {
116
129
  const {cargarProyecto} = await import('../lib/config.mjs');
117
130
  const {revisarEstructura, consejosEnTexto, REGLAS} = await import('../lib/estructura.mjs');
@@ -141,7 +154,7 @@ Pruébalo: npx morfeo render ${pos[0]} y revísalo: npx morfeo hoja ${pos[0]
141
154
  const {salidaDe, ordenarSalida} = await import('../lib/salida.mjs');
142
155
  ordenarSalida(p);
143
156
  const video = /\.(mp4|mov|webm)$/i.test(id) ? path.resolve(id) : salidaDe(p, id);
144
- 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}));
145
158
  }
146
159
  return;
147
160
  }
@@ -191,6 +204,20 @@ Pruébalo: npx morfeo render ${pos[0]} y revísalo: npx morfeo hoja ${pos[0]
191
204
  console.log('\nÚsalo con estilo: "documental" en el video o en morfeo.config.mjs. Crea los tuyos con estilos: {...} en el config.');
192
205
  return;
193
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
+ }
214
+ case 'formulas':
215
+ case 'formula': {
216
+ const {FORMULAS} = await import('../lib/formulas.mjs');
217
+ 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`);
218
+ console.log('Úsala: npx morfeo guion "tu tema" --formula construiste (o formula: \'construiste\' en el guion para que `revisar` la revise)');
219
+ return;
220
+ }
194
221
  case 'guion':
195
222
  case 'script':
196
223
  return guion(pos, op, proyecto);
@@ -225,7 +252,7 @@ async function guion(pos, op, proyecto) {
225
252
  if (texto && op.sinIa) r = guionSinIA(texto, {id: op.id, voz: op.voz, musica: op.musica, titulo: op.titulo, estilo});
226
253
  else {
227
254
  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});
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});
229
256
  }
230
257
  if (op.id) r.id = op.id;
231
258
  const {cargarProyecto} = await import('../lib/config.mjs');
@@ -253,7 +280,7 @@ async function nuevo(carpeta, op) {
253
280
  // dos videos de ejemplo con mundo y la estructura completa (los mismos que `morfeo nuevo-video`)
254
281
  const {nuevoVideo} = await import('../lib/nuevo-video.mjs');
255
282
  for (const look of ['2d', '3d']) nuevoVideo({proyecto: destino, id: `ejemplo-${look}`, look});
256
- 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`);
257
284
  }
258
285
 
259
286
  main().catch((e) => {
package/index.d.ts CHANGED
@@ -64,11 +64,14 @@ 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
  }
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,17 @@ 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; /** 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}
95
109
  | {tipo: 'mapa'; ruta?: [number, number][]; distancia?: string; ancho?: number; color?: string; fondo?: string; inclinar?: number; duracion?: number}
96
110
  | {tipo: 'cursor'; a: [number, number]; clic?: Momento; duracion?: number; tam?: number; color?: string}
97
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}
@@ -176,6 +190,8 @@ interface EscenaMotorBase extends EscenaBase {
176
190
  modoEn?: Momento;
177
191
  /** Palabra o segundos en que vuelve a la imagen normal. */
178
192
  modoHasta?: Momento;
193
+ /** Lugar (de `lugares` del video) donde pasa esta escena. */
194
+ lugar?: string;
179
195
  /** Invertir la imagen en el modo (fondo claro → oscuro). Por defecto sí en looks claros (3d, maqueta, explicativo, infografia, minimal). */
180
196
  modoInvertir?: boolean;
181
197
  /** Palabras clave de la escena para los subtítulos (la que lleva caja o color de acento en cada frase). */
@@ -267,6 +283,12 @@ export interface Video {
267
283
  * El componente recibe además `escena` (1, 2, 3...), `tEscena`, `inicios` y `camara` (para paralaje).
268
284
  */
269
285
  mundo?: Capa[];
286
+ /**
287
+ * Varios mundos en un video (biografías, historias que cambian de sitio): `{cocina: [...], boda: [...]}`. Cada escena
288
+ * elige uno con `lugar: 'boda'` (o sigue en el anterior). Cada lugar tiene su propia cámara; al cambiar, corta o usa
289
+ * la `transicion` de la escena.
290
+ */
291
+ lugares?: Record<string, Capa[]>;
270
292
  /** Pastilla fija arriba para series: 'GYM CON CIENCIA · PARTE 5'. */
271
293
  serie?: string;
272
294
  /** Calidad de este video (ver `render.calidad`). */
@@ -274,6 +296,17 @@ export interface Video {
274
296
  /** Para publicar: descripción y etiquetas (van a salida/<id>.txt junto con el título). */
275
297
  descripcion?: string;
276
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})[];
305
+ /**
306
+ * Fórmula de historia (ver `npx morfeo formulas`): 'construiste' | 'recomendable' | 'escalada' | 'supervivencia' |
307
+ * 'experimento' | 'misterio' | 'demuestralo' | 'mito' | 'roast' | 'serie' | 'biografia'. `revisar` da consejos propios.
308
+ */
309
+ formula?: 'construiste' | 'recomendable' | 'escalada' | 'supervivencia' | 'experimento' | 'misterio' | 'demuestralo' | 'mito' | 'roast' | 'serie' | 'biografia';
277
310
  /** 'largo' = explicador de 90–130 s (lo asume el estilo maqueta): cambia las reglas de `morfeo revisar`. */
278
311
  formato?: 'corto' | 'largo';
279
312
  /** Ajustes del estilo para este video (colores, fondo, subtítulos...). */
@@ -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,4 +1,6 @@
1
1
  // Carga y valida morfeo.config.mjs del proyecto del usuario.
2
+ import {IDS_FORMULA} from './formulas.mjs';
3
+ import {aplicarEstiloPropio, leerEstiloPropio} from './estilo-propio.mjs';
2
4
  import fs from 'node:fs';
3
5
  import path from 'node:path';
4
6
  import {pathToFileURL} from 'node:url';
@@ -25,7 +27,7 @@ export const TIPOS_ESCENA = [...new Set([...TIPOS_CLASICOS, ...TIPOS_MOTOR])];
25
27
  export const ROTULOS = ['normal', 'contorno', 'pastilla', 'sello', 'paso', 'pixel'];
26
28
  export const MODOS = ['rayosx', 'plano', 'holograma'];
27
29
  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'];
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'];
29
31
  export const ESCENARIOS = ['bosque', 'cuarto', 'departamento', 'tunel', 'pasillo'];
30
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'];
31
33
  export const PROVEEDORES_VOZ = ['kokoro', 'elevenlabs', 'openai'];
@@ -79,6 +81,12 @@ export async function cargarProyecto(proyecto = process.cwd()) {
79
81
  let cfg = await importar(archivo);
80
82
  if (typeof cfg === 'function') cfg = await cfg();
81
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
+ }
82
90
 
83
91
  let videos = cfg.videos ?? (fs.existsSync(path.join(proyecto, 'videos.mjs')) ? './videos.mjs' : './guiones');
84
92
  const carpetaGuiones = typeof videos === 'string' && fs.existsSync(path.resolve(proyecto, videos)) && fs.statSync(path.resolve(proyecto, videos)).isDirectory()
@@ -102,6 +110,9 @@ export async function cargarProyecto(proyecto = process.cwd()) {
102
110
  redes: m.redes ?? '',
103
111
  seguir: m.seguir ?? '+ SEGUIR',
104
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)} : {}),
105
116
  etiqueta: m.etiqueta ?? 'DATO CURIOSO',
106
117
  cta: m.cta ?? (m.nombre ? `Sígueme en ${m.nombre} para más.` : 'Sígueme para más.'),
107
118
  };
@@ -111,7 +122,7 @@ export async function cargarProyecto(proyecto = process.cwd()) {
111
122
  const estilosPropios = cfg.estilos ?? {};
112
123
  const estilo = cfg.estilo ?? 'datos-curiosos';
113
124
  const assetsDir = path.resolve(proyecto, cfg.assets ?? 'assets');
114
- 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 ?? {})};
115
126
  if (!ESTILOS.includes(estilo) && !estilosPropios[estilo]) errores.push(`estilo "${estilo}" no existe. Opciones: ${[...ESTILOS, ...Object.keys(estilosPropios)].join(', ')}`);
116
127
  const componentes = {};
117
128
  for (const [nombre, ruta] of Object.entries(cfg.componentes ?? {})) {
@@ -160,6 +171,7 @@ export async function cargarProyecto(proyecto = process.cwd()) {
160
171
  concurrencia: Number(process.env.MORFEO_CONCURRENCIA) || r.concurrencia || null, // null = Morfeo elige según la máquina y el modo (ver render.mjs)
161
172
  miniatura: r.miniatura ?? true,
162
173
  consejos: r.consejos ?? true,
174
+ portada: r.portada ?? true,
163
175
  // 'titulo' (por defecto): salida/<Título del video>.mp4, listo para subir · 'id': salida/<id>.mp4
164
176
  nombreArchivo: r.nombreArchivo ?? 'titulo',
165
177
  },
@@ -228,6 +240,11 @@ function validarCapas(capas, donde, err, comps) {
228
240
  if (!c.buscar) err(`${d}: falta \`buscar\` (qué objeto: 'tubería', 'casa', 'pizza'...)`);
229
241
  if (c.estilo && !['2d', '3d', 'realista'].includes(c.estilo)) err(`${d}: estilo "${c.estilo}" no existe. Usa '2d', '3d' o 'realista'`);
230
242
  }
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'}]`);
245
+ if (c.tipo === 'cintillo' && !c.nombre) err(`${d}: \`cintillo\` necesita \`nombre\``);
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)`);
247
+ if (c.tipo === 'tierra' && (!esNum(c.lat) || !esNum(c.lon))) err(`${d}: \`tierra\` necesita \`lat\` y \`lon\` (números)`);
231
248
  if (c.capas) validarCapas(c.capas, d, err, comps);
232
249
  if (c.capa) validarCapas([c.capa], d, err, comps);
233
250
  });
@@ -298,7 +315,9 @@ function validarVideo(id, v, ctx) {
298
315
  const err = (m) => ctx.errores.push(`${id}: ${m}`);
299
316
  if (!v || typeof v !== 'object') return err('debe ser un objeto'), null;
300
317
  if (!Array.isArray(v.scenes) || !v.scenes.length) return err('falta `scenes` (lista de escenas)'), null;
301
- 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);
302
321
  const clasico = estilo === 'datos-curiosos';
303
322
  if (!ESTILOS.includes(estilo) && !ctx.estilosPropios[estilo]) err(`estilo "${estilo}" no existe. Opciones: ${[...ESTILOS, ...Object.keys(ctx.estilosPropios)].join(', ')}`);
304
323
  const permitidos = clasico ? TIPOS_ESCENA : TIPOS_MOTOR;
@@ -315,6 +334,7 @@ function validarVideo(id, v, ctx) {
315
334
  );
316
335
  if (s.capas != null) validarCapas(s.capas, `${n}`, err, ctx.componentes);
317
336
  if (s.rotulo != null && typeof s.rotulo !== 'string') err(`${n}: \`rotulo\` debe ser un texto corto ('¿CUÁNTO *SUBIÓ*?')`);
337
+ 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
338
  if (s.rotuloEstilo != null && !ROTULOS.includes(s.rotuloEstilo)) err(`${n}: rotuloEstilo "${s.rotuloEstilo}" no existe. Opciones: ${ROTULOS.join(', ')}`);
319
339
  if (s.rotuloPaso != null && s.rotuloEstilo !== 'paso') err(`${n}: \`rotuloPaso\` es para rotuloEstilo: 'paso'`);
320
340
  if (s.modo != null) {
@@ -329,7 +349,7 @@ function validarVideo(id, v, ctx) {
329
349
  if (tm.zoom != null && tm.zoom <= 0) err(`${n}: \`toma.zoom\` debe ser mayor que 0`);
330
350
  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
351
  }
332
- if (!v.mundo?.length) err(`${n}: \`toma\` mueve la cámara del \`mundo\`, pero el video no tiene \`mundo\``);
352
+ if (!v.mundo?.length && !v.lugares) err(`${n}: \`toma\` mueve la cámara del \`mundo\`, pero el video no tiene \`mundo\` ni \`lugares\``);
333
353
  }
334
354
  if (s.type === 'cta' && s.pide != null) {
335
355
  if (!['comenta', 'comparte'].includes(s.pide)) err(`${n}: \`pide\` debe ser 'comenta' o 'comparte'`);
@@ -350,20 +370,32 @@ function validarVideo(id, v, ctx) {
350
370
  // la voz sintética solo hace falta si alguna escena no trae su propia grabación
351
371
  const necesitaVoz = scenes.some((s) => s && !s.audio);
352
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};
353
376
  if (v.voice != null) voice = normalizarVoz(v.voice, ctx, err);
354
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)');
355
378
  if (v.hud != null) validarCapas(v.hud, 'hud', err, ctx.componentes);
379
+ if (v.lugares != null) {
380
+ 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: [...]}");
381
+ else for (const [nombre, capas] of Object.entries(v.lugares)) validarCapas(capas, `lugares.${nombre}`, err, ctx.componentes);
382
+ if (clasico) err('`lugares` es del motor de capas: usa un estilo como ilustrado, 3d o mesa (no "datos-curiosos")');
383
+ }
356
384
  if (v.mundo != null) {
357
385
  validarCapas(v.mundo, 'mundo', err, ctx.componentes);
358
386
  if (clasico) err('`mundo` es del motor de capas: usa un estilo como ilustrado, 3d o mesa (no "datos-curiosos")');
359
387
  }
388
+ if (v.formula != null && !IDS_FORMULA.includes(v.formula)) err(`\`formula\` "${v.formula}" no existe. Opciones: ${IDS_FORMULA.join(', ')} (npx morfeo formulas)`);
360
389
  if (v.serie != null && typeof v.serie !== 'string') err("`serie` debe ser un texto ('GYM CON CIENCIA · PARTE 5')");
361
390
  if (v.title != null && typeof v.title !== 'string') err('`title` debe ser un texto (también es el nombre del archivo del video)');
362
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://…'}]");
363
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'])");
364
396
  // 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);
397
+ revisarArchivos({scenes, hud: v.hud, mundo: v.mundo, lugares: v.lugares, tema: v.tema}, ctx.assetsDir, err);
398
+ revisarPersonajes({scenes, hud: v.hud, mundo: v.mundo, lugares: v.lugares}, ctx.personajes, err);
367
399
  revisarEscenarios({scenes, hud: v.hud, tema: v.tema}, err);
368
400
  return {...v, estilo, voice, music: normalizarMusica(v.music, ctx, err), scenes};
369
401
  }
@@ -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
+ }