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.
@@ -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,8 +43,11 @@ 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';
47
+ import {formulaDe, revisarFormula} from './formulas.mjs';
48
+
46
49
  const palabrasDe = (s) => String(s ?? '').trim().split(/\s+/).filter(Boolean);
47
- const GANCHOS = /^(¿|¡|imagina|y si|cómo|como se|si tu|si te|si un|qué pasaría|que pasaria|este es|esta es|esto es|nadie|nunca|todos|a \S+ |hay |tu |tú |te |tienes|cuando)/i;
50
+ const GANCHOS = /^(¿|¡|imagina|la mayoría|la mayoria|y si|cómo|como se|si tu|si te|si un|qué pasaría|que pasaria|este es|esta es|esto es|nadie|nunca|todos|a \S+ |hay |tu |tú |te |tienes|cuando)/i;
48
51
 
49
52
  /**
50
53
  * Revisa la estructura de un video y devuelve consejos (no bloquea el render).
@@ -61,7 +64,8 @@ export function revisarEstructura(v, d) {
61
64
  const primera = cuerpo[0];
62
65
  const clasico = (v.estilo ?? 'datos-curiosos') === 'datos-curiosos';
63
66
  // formato largo: explicador de maqueta (o `formato: 'largo'` en el video)
64
- const largo = v.estilo === 'maqueta' || v.formato === 'largo';
67
+ const formula = formulaDe(v.formula);
68
+ const largo = v.estilo === 'maqueta' || v.formato === 'largo' || (formula?.duracion[1] ?? 0) > 90;
65
69
 
66
70
  // 1) gancho
67
71
  const dicho = String(primera.say ?? '').trim();
@@ -100,7 +104,7 @@ export function revisarEstructura(v, d) {
100
104
 
101
105
  // 4) cierre
102
106
  const fin = escenas.at(-1);
103
- const pideAlgo = escenas.some((s) => s.type === 'eleccion') || (fin?.type === 'cta' && fin.pide) || /comenta|escribe|mándaselo|mandaselo|comparte|elige|parte 2|siguiente parte/i.test(cuerpo.at(-1)?.say ?? '');
107
+ const pideAlgo = escenas.some((s) => s.type === 'eleccion') || (fin?.type === 'cta' && fin.pide) || /comenta|escribe|mándaselo|mandaselo|envíaselo|enviaselo|comparte|elige|parte 2|siguiente parte|capítulo/i.test(cuerpo.at(-1)?.say ?? '');
104
108
  if (!pideAlgo) add('cierre', 'idea', "El cierre solo pide seguir. Pide algo concreto: {type: 'cta', pide: 'comenta', palabra: 'PASO'}, pide: 'comparte' o una escena 'eleccion' (A o B).");
105
109
 
106
110
  // 5) habla de tú
@@ -108,6 +112,11 @@ export function revisarEstructura(v, d) {
108
112
  const n = palabrasDe(todo).length;
109
113
  const tu = (todo.match(/\b(tú|tu|tus|te|ti|contigo|imagina|imaginas|tienes|eres|puedes|sabes|sabías|crees|conoces|harías|pagarías)\b/gi) ?? []).length;
110
114
  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.');
115
+ // 6) lo propio de la fórmula del video (lib/formulas.mjs)
116
+ for (const t of revisarFormula(v)) add('formula', 'idea', t);
117
+ // 7) lo que no se ve a simple vista: la voz dijo lo del guion y nada queda bajo los botones de las apps
118
+ out.push(...revisarZona(v), ...revisarVoz(v, d));
119
+ // "envíaselo a…" también es un cierre que pide algo
111
120
  return out;
112
121
  }
113
122
 
@@ -0,0 +1,179 @@
1
+ // Fórmulas de historia medidas en los videos de referencia (referencias/videos-ejemplo.md). Una sola fuente para:
2
+ // el prompt de `morfeo guion` (la IA elige una o se le pide con --formula), `morfeo revisar` (consejos propios de cada
3
+ // fórmula) y `morfeo formulas` (la lista). Cada una dice cómo arranca, cómo avanza, cómo cierra y qué piezas le quedan.
4
+
5
+ /** @typedef {{id: string, nombre: string, para: string, esqueleto: string[], gancho: string, cierre: string, duracion: [number, number], estilos: string[], piezas: string, revisar?: (v: any, texto: string) => string[]}} Formula */
6
+
7
+ const dice = (texto, re) => re.test(texto);
8
+
9
+ /** @type {Formula[]} */
10
+ export const FORMULAS = [
11
+ {
12
+ id: 'construiste',
13
+ nombre: 'Construiste un X',
14
+ para: 'explicar cómo funciona una obra, máquina o instalación (túnel, inodoro, portón eléctrico, semáforo)',
15
+ esqueleto: ['Arranca por lo obvio: "Si haces un hoyo en la montaña, tienes un túnel…"', 'Falla por algo concreto ("pero el techo plano se derrumba")', 'Lo arreglas con la pieza real ("le das forma de arco")', 'Repite falla → arreglo 4 a 7 veces; cada arreglo es una pieza nueva en pantalla', 'Remate: "¡Felicidades, construiste un túnel!"'],
16
+ gancho: '"Si a tu portón le pones un motor, ya no te bajas a abrir… pero no sabe cuándo parar."',
17
+ cierre: '"¡Felicidades, construiste X!" y "comenta qué construimos en el próximo".',
18
+ duracion: [90, 120],
19
+ estilos: ['maqueta', '3d'],
20
+ piezas: 'un solo mundo (Maqueta con corte de tierra), Aparece por pieza en su palabra, etiquetas 3D, rótulo paso con número, modo plano para ver por dentro',
21
+ revisar: (v, texto) => {
22
+ const out = [];
23
+ if (!dice(texto, /felicidades|construiste|inventaste|has (?:construido|inventado)/i)) out.push('Fórmula "construiste": cierra con "¡Felicidades, construiste X!" (es el remate que esperan).');
24
+ const pero = (texto.match(/\bpero\b/gi) ?? []).length;
25
+ if (pero < 3) out.push(`Fórmula "construiste": solo ${pero} "pero". La cadena falla → arreglo necesita 4–7 rondas ("…pero se derrumba, así que…").`);
26
+ return out;
27
+ },
28
+ },
29
+ {
30
+ id: 'recomendable',
31
+ nombre: 'Lo recomendable es X, y no [absurdo]',
32
+ para: 'humor con datos: consejos cotidianos contra la exageración más absurda (prensa de 500 t para un zancudo)',
33
+ esqueleto: ['"¿Sabías que para X lo recomendable es Y…"', '"…y no [la exageración con cifra]: [consecuencia absurda]"', 'Tres rondas iguales con objetos distintos (cifra en tarjeta cada vez)', 'Remate corto que vuelve al principio'],
34
+ gancho: '"¿Sabías que si quieres matar un zancudo, lo recomendable es usar la mano y no una prensa de 500 toneladas?"',
35
+ cierre: 'Remate de una línea ("La próxima, con cuchara") o "síguenos si quieres más así".',
36
+ duracion: [45, 60],
37
+ estilos: ['3d', 'datos-curiosos'],
38
+ piezas: 'subtítulo palabra con coloresClave, dato/chip con la cifra exagerada, sello para la consecuencia',
39
+ revisar: (v, texto) => ((texto.match(/\by no\b/gi) ?? []).length < 2 ? ['Fórmula "recomendable": repite "lo recomendable es… y no…" al menos 3 veces; la gracia está en el patrón.'] : []),
40
+ },
41
+ {
42
+ id: 'escalada',
43
+ nombre: 'Escalada absurda',
44
+ para: 'un objeto cotidiano que crece pieza por pieza porque cada solución causa otro problema (la mochila que termina con reactor)',
45
+ esqueleto: ['Problema cotidiano y su arreglo simple ("le pones dos ruedas")', '"Pero [alguien/algo] causa otro problema, así que le metes…"', 'Cada escena suma una pieza visible y más grande', 'Remate: "Felicidades, has inventado una mochila básica"'],
46
+ gancho: '"Tu mochila pesa más que tu abuela, así que le pones dos ruedas."',
47
+ cierre: '"Felicidades, has inventado [algo que ya existe]" y "envíaselo a tu amigo que…".',
48
+ duracion: [40, 55],
49
+ estilos: ['3d'],
50
+ piezas: 'Aparece (cae y rebota) por cada pieza en su palabra, Persona3D reaccionando, sello por golpe cómico',
51
+ revisar: (v, texto) => (!dice(texto, /así que|entonces/i) ? ['Fórmula "escalada": encadena con "así que…" / "entonces le…": cada arreglo provoca el siguiente.'] : []),
52
+ },
53
+ {
54
+ id: 'supervivencia',
55
+ nombre: 'Regla de supervivencia',
56
+ para: 'qué hacer ante un peligro con dos variantes que se confunden (oso negro / grizzly, medusa, rayo)',
57
+ esqueleto: ['"Si te pasa X: con uno haz A y con el otro nunca. Si te equivocas…"', 'Presenta el primero con cifras (peso, velocidad) y qué hacer', 'Presenta el segundo y por qué es al revés', 'Cómo distinguirlos (una sola seña visual)', 'Cierre en suspenso o pregunta'],
58
+ gancho: '"Si te ataca un oso, con uno tienes que hacerte el muerto, y con el otro, nunca."',
59
+ cierre: 'Suspenso ("porque cuando el próximo oso cargue contra ti…") o "comenta cuál sabías".',
60
+ duracion: [45, 60],
61
+ estilos: ['ilustrado', 'documental'],
62
+ piezas: 'chips de cifra pegados al animal, comparar/dividida A | B, contorno sobre la seña que los distingue',
63
+ },
64
+ {
65
+ id: 'experimento',
66
+ nombre: 'Experimento mental con contador',
67
+ para: 'dilemas y "¿qué pasa si…?" que se resuelven contando (un céntimo que se duplica, 10 años de un hábito)',
68
+ esqueleto: ['Dilema o pregunta con cifra ("¿Un millón ahora o un céntimo que se duplica 30 días?")', 'Casi todos eligen mal', 'Contador por días/años con saltos (Día 1, 7, 20…)', 'Giro en los últimos días', 'Resultado con número grande'],
69
+ gancho: '"Un millón de euros ahora mismo, o un céntimo que se duplica cada día durante 30 días."',
70
+ cierre: '"Así que ya sabes qué decir cuando alguien te pregunte" o pregunta A/B.',
71
+ duracion: [45, 60],
72
+ estilos: ['mesa', 'infografia', '3d'],
73
+ piezas: 'contador lcd, Pila que crece, calendario que avanza, rótulo pixel para el resultado',
74
+ revisar: (v, texto) => (!dice(texto, /\bd[ií]a\b|\baños?\b|\bmes(es)?\b/i) ? ['Fórmula "experimento": marca el tiempo en voz ("día siete…", "diez años después") y con un contador o calendario.'] : []),
75
+ },
76
+ {
77
+ id: 'misterio',
78
+ nombre: 'Misterio con giro',
79
+ para: 'historias reales o inventadas con un hecho raro que escala y se explica al final',
80
+ esqueleto: ['Hecho raro en una frase ("Un hombre se colaba cada noche y movía todo 2,5 cm")', 'Escala por días ("DÍA 8", "DÍA 12") con la tensión de quien lo vive', 'Consecuencia (venden la casa, llaman a la policía)', 'Giro final que reordena todo (el comprador era él)'],
81
+ gancho: '"Un hombre se colaba cada noche en una casa y movía cada objeto dos centímetros a la izquierda."',
82
+ cierre: 'El giro es el cierre; nada de "sígueme" largo.',
83
+ duracion: [50, 65],
84
+ estilos: ['historia', 'ilustrado'],
85
+ piezas: 'siluetas, calendario o tarjeta "DÍA 8", vigilancia, medida para los centímetros',
86
+ },
87
+ {
88
+ id: 'demuestralo',
89
+ nombre: 'Te lo demuestro',
90
+ para: 'algo que el espectador puede probar con su cuerpo o su casa en ese momento',
91
+ esqueleto: ['"Hay algo que tu cuerpo no puede hacer y te lo voy a demostrar"', 'Instrucciones paso a paso al espectador (pon, dobla, levanta)', 'Lo que pasa ("¿No sube?")', 'Explicación con rayos X / plano', '"Tu cuerpo viene así de fábrica"'],
92
+ gancho: '"Hay un dedo de tu mano que no puedes levantar, y te lo voy a demostrar."',
93
+ cierre: '"Envíaselo a un amigo y que lo intente."',
94
+ duracion: [30, 45],
95
+ estilos: ['ilustrado', 'explicativo'],
96
+ piezas: 'modo rayosx en la explicación, trazo/contorno sobre la parte, cierre comparte',
97
+ revisar: (v) => (!(v.scenes ?? []).some((s) => s.modo) ? ['Fórmula "demuéstralo": la explicación luce con `modo: \'rayosx\'` en la palabra donde se ve "por dentro".'] : []),
98
+ },
99
+ {
100
+ id: 'mito',
101
+ nombre: 'Mito desmentido con comparación',
102
+ para: 'creencias comunes que tienen truco (la cinta de correr "miente", el microondas, la sal)',
103
+ esqueleto: ['Afirmación tajante ("La cinta te miente")', 'Por qué (la física o la biología en una frase)', 'La prueba (estudio o medida)', 'Comparación lado a lado', 'Matiz honesto', 'CTA a quien cree el mito'],
104
+ gancho: '"Diez kilómetros en la cinta y diez en la calle: mismo reloj, y no hiciste lo mismo."',
105
+ cierre: '"Envíaselo a ese amigo que dice que…"',
106
+ duracion: [50, 65],
107
+ estilos: ['infografia', 'explicativo'],
108
+ piezas: 'dividida A | B, medidor, barras, sellos "TE MIENTE" / "PILLADO", modo plano',
109
+ },
110
+ {
111
+ id: 'roast',
112
+ nombre: 'Roast con analogía animal',
113
+ para: 'conductas de la gente explicadas con la biología de un animal (el amigo tacaño y el pavo real)',
114
+ esqueleto: ['Pregunta provocadora sobre un conocido ("¿Por qué tu amigo el rata…?")', '"Es lo mismo que le pasa a un [animal]"', 'Explicación biológica con imágenes del animal', 'Vuelta al amigo con la analogía', 'CTA para mandárselo a ese amigo'],
115
+ gancho: '"¿Por qué tu amigo el rata no te invita ni un café, pero a su novia le paga las vacaciones?"',
116
+ cierre: '"Envíaselo a tu amigo el rata y que esta vez el café lo pague él."',
117
+ duracion: [45, 60],
118
+ estilos: ['ilustrado', 'mesa'],
119
+ piezas: 'mismos objetos que vuelven (cartera, café), lugares distintos por tramo, cierre comparte',
120
+ revisar: (v) => {
121
+ const cta = (v.scenes ?? []).find((s) => s.type === 'cta');
122
+ return cta && cta.pide !== 'comparte' ? ['Fórmula "roast": cierra con `pide: \'comparte\'` y un destinatario concreto ("tu amigo el rata").'] : [];
123
+ },
124
+ },
125
+ {
126
+ id: 'serie',
127
+ nombre: 'Serie interactiva (elige tú)',
128
+ para: 'historias por capítulos donde el público decide qué pasa (llamadas del futuro, vuelos perdidos)',
129
+ esqueleto: ['Recuento de lo que votaron ("La mayoría de ustedes votó…") si no es el capítulo 1', 'El capítulo en tiempo real: hora en pantalla, teléfono, noticias', 'Escalada hasta una decisión', 'Cliffhanger con dos opciones A/B', '"Comenta tu opción; la más votada gana. Capítulo 3 mañana 15:30"'],
130
+ gancho: '"Anoche te llamó tu propio número y te dijo que no tomaras el metro."',
131
+ cierre: 'Capa `capitulo` con las dos opciones y el horario, o escena `eleccion`.',
132
+ duracion: [70, 115],
133
+ estilos: ['historia', 'serie'],
134
+ piezas: 'serie (pastilla fija), recuento, bloqueo, llamada, noticia, vigilancia, capitulo',
135
+ revisar: (v) => {
136
+ const ultima = (v.scenes ?? []).at(-1);
137
+ const tieneFinal = (v.scenes ?? []).some((s) => s.type === 'eleccion' || (s.capas ?? []).some((c) => c.tipo === 'capitulo'));
138
+ const out = [];
139
+ if (!tieneFinal) out.push('Fórmula "serie": termina con una decisión A/B (capa `capitulo` o escena `eleccion`): es lo que trae comentarios.');
140
+ if (!v.serie) out.push("Fórmula \"serie\": pon `serie: 'MI SERIE · CAP. 2'` para la pastilla fija arriba.");
141
+ if (ultima && ultima.type === 'cta' && !ultima.pide) out.push('Fórmula "serie": el cierre debe pedir que comenten su opción, no solo "sígueme".');
142
+ return out;
143
+ },
144
+ },
145
+ {
146
+ id: 'biografia',
147
+ nombre: 'Biografía con secreto',
148
+ para: 'la vida de una persona o empresa contada año por año, con un escándalo o secreto como gancho',
149
+ esqueleto: ['Gancho de escándalo ("Al jefe no lo descubrió su esposa: lo descubrieron los espías")', 'Origen: lugar y año (globo con pin)', 'Ascenso año por año (tarjetas de año, cintillos con nombres)', 'El secreto con pruebas (documentos, vigilancia)', 'Final abierto'],
150
+ gancho: '"Al jefe de [empresa] no lo descubrió su esposa: lo descubrieron los espías."',
151
+ cierre: 'Final abierto ("Le dijeron que no.") o "sígueme para la parte 2".',
152
+ duracion: [90, 180],
153
+ estilos: ['documental', '3d'],
154
+ piezas: 'lugares (un set por época), tierra con pin, fecha, cintillo, calendario, vigilancia, ficha',
155
+ revisar: (v) => (!v.lugares ? ['Fórmula "biografía": usa `lugares` (un set por época o sitio) y `lugar` por escena: un solo fondo se siente pobre en 2–3 minutos.'] : []),
156
+ },
157
+ ];
158
+
159
+ export const IDS_FORMULA = FORMULAS.map((f) => f.id);
160
+ export const formulaDe = (id) => FORMULAS.find((f) => f.id === id) ?? null;
161
+
162
+ /** Texto de las fórmulas para el prompt de la IA. */
163
+ export function formulasParaIA(id) {
164
+ const lista = id ? [formulaDe(id)].filter(Boolean) : FORMULAS;
165
+ return lista
166
+ .map(
167
+ (f) =>
168
+ `### ${f.id}: ${f.nombre}\nPara: ${f.para}.\nEsqueleto:\n${f.esqueleto.map((p, i) => ` ${i + 1}. ${p}`).join('\n')}\nGancho de ejemplo: ${f.gancho}\nCierre: ${f.cierre}\nDuración: ${f.duracion[0]}–${f.duracion[1]} s.`,
169
+ )
170
+ .join('\n\n');
171
+ }
172
+
173
+ /** Consejos propios de la fórmula del video (`formula: 'construiste'`). */
174
+ export function revisarFormula(v) {
175
+ const f = formulaDe(v?.formula);
176
+ if (!f?.revisar) return [];
177
+ const texto = (v.scenes ?? []).map((s) => s.say ?? '').join(' ');
178
+ return f.revisar(v, texto);
179
+ }
package/lib/guion.mjs CHANGED
@@ -1,5 +1,6 @@
1
1
  // Guiones: de un tema (la IA escribe todo) o de tu propio texto (la IA lo convierte en escenas,
2
2
  // o sin IA: una escena por frase). El resultado es un video listo para editar en videos/<id>.json.
3
+ import {FORMULAS, IDS_FORMULA, formulaDe, formulasParaIA} from './formulas.mjs';
3
4
  import fs from 'node:fs';
4
5
  import os from 'node:os';
5
6
  import path from 'node:path';
@@ -120,8 +121,17 @@ Además, en cualquier escena:
120
121
  Varía los tipos. "at", "check" y "en" deben ser palabras que aparezcan tal cual en "say".
121
122
  No escribas la escena de cierre: va en "cierre" (lo que pide al final: "comenta" una "palabra", "comparte" con "a" quién se lo mandan, o "sigue"), con su "say".
122
123
 
124
+ - "rotulo_estilo": cómo se ve el rótulo: "sello" (sello de goma inclinado para un golpe: "¡SIN LUZ!", "¡TE MIENTE!"),
125
+ "paso" (titular de paso con número en "rotulo_paso", para explicar en orden), "pixel" (caja de videojuego para
126
+ resultados: "RESULTADO", "-20% RIESGO"), "pastilla" (aviso: "¡CUIDADO!") o null (el del estilo).
127
+ - "modo": "rayosx" (ver por dentro: cuerpo, máquina), "plano" (cómo está hecho) u "holograma" (datos, cerebro) en la
128
+ escena que lo explica, con "modo_en" = la palabra donde cambia; null en las demás. Úsalo en 1–3 escenas como máximo.
129
+
123
130
  ${REGLAS_TEXTO}
124
131
 
132
+ Fórmulas de historia (elige la que mejor le quede al tema y escribe en "formula" su id; síguela de principio a fin):
133
+ ${formulasParaIA()}
134
+
125
135
  Diccionario de recursos por tema (usa estas palabras en "recurso"):
126
136
  ${Object.entries(listaTemas()).map(([t, v]) => `- ${t}: ${v.join(', ')}`).join('\n')}`;
127
137
 
@@ -153,10 +163,14 @@ export async function esquemaMotor() {
153
163
  pregunta: z.string().nullable(),
154
164
  opciones: z.array(z.object({texto: z.string(), sub: z.string(), objeto: z.enum(OBJETOS).nullable(), emoji: z.string().nullable()})).nullable(),
155
165
  rotulo: z.string().nullable(),
166
+ rotulo_estilo: z.enum(['sello', 'paso', 'pixel', 'pastilla']).nullable(),
167
+ rotulo_paso: z.number().nullable(),
168
+ modo: z.enum(['rayosx', 'plano', 'holograma']).nullable(),
169
+ modo_en: z.string().nullable(),
156
170
  claves: z.array(z.string()).nullable(),
157
171
  });
158
172
  const cierre = z.object({pide: z.enum(['comenta', 'comparte', 'sigue']), say: z.string(), palabra: z.string().nullable(), a: z.string().nullable()});
159
- return z.object({id: z.string(), title: z.string(), music: z.enum(MUSICAS), scenes: z.array(escena), cierre});
173
+ return z.object({id: z.string(), title: z.string(), formula: z.enum(IDS_FORMULA), music: z.enum(MUSICAS), descripcion: z.string(), tags: z.array(z.string()), hooks: z.array(z.string()), scenes: z.array(escena), cierre});
160
174
  }
161
175
 
162
176
  /** De la salida de la IA (motor de capas) al formato de escenas de morfeo. */
@@ -165,6 +179,14 @@ export function aEscenaMotor(e, conImagenes) {
165
179
  if (e.show && e.show !== e.say) out.show = e.show;
166
180
  const si = (k, v) => v != null && v !== '' && (out[k] = v);
167
181
  si('rotulo', e.rotulo);
182
+ if (e.rotulo && e.rotulo_estilo) {
183
+ out.rotuloEstilo = e.rotulo_estilo;
184
+ if (e.rotulo_estilo === 'paso' && e.rotulo_paso != null) out.rotuloPaso = e.rotulo_paso;
185
+ }
186
+ if (e.modo) {
187
+ out.modo = e.modo;
188
+ si('modoEn', e.modo_en);
189
+ }
168
190
  if (e.claves?.length) out.claves = e.claves;
169
191
  switch (e.type) {
170
192
  case 'titular':
@@ -244,6 +266,51 @@ export function aEscena(e) {
244
266
  return out;
245
267
  }
246
268
 
269
+ /**
270
+ * Investiga el tema en la web antes de escribir (búsqueda del servidor de Claude): devuelve notas con cada dato y su
271
+ * fuente, y la lista de fuentes que de verdad se consultaron (las URLs de los resultados de búsqueda citados).
272
+ * @returns {Promise<{notas: string, fuentes: {texto: string, url: string}[]}>}
273
+ */
274
+ export async function investigar(client, Anthropic, tema, {idioma = 'español de México', modelo = MODELO_GUION, log = () => {}} = {}) {
275
+ const messages = [
276
+ {
277
+ role: 'user',
278
+ content: `Investiga para un video corto vertical sobre: ${tema}
279
+
280
+ Busca en la web y junta 8 a 12 datos concretos, sorprendentes y verificables (cifras, fechas, nombres, lugares), cada uno
281
+ con la fuente donde lo leíste (nombre del medio o institución y URL). Prefiere fuentes primarias u oficiales. Si dos
282
+ fuentes no coinciden, dilo. No inventes nada que no esté en una fuente.
283
+ Responde en ${idioma} con una lista: "- dato · fuente (URL)".`,
284
+ },
285
+ ];
286
+ const vistas = new Map();
287
+ let texto = '';
288
+ // la búsqueda corre en el servidor; si el turno se pausa (pause_turn), se reenvía para que continúe
289
+ for (let vuelta = 0; vuelta < 6; vuelta++) {
290
+ const res = await client.messages.create({
291
+ model: modelo,
292
+ max_tokens: 16000,
293
+ tools: [{type: 'web_search_20260209', name: 'web_search', max_uses: 8}],
294
+ messages,
295
+ });
296
+ for (const b of res.content) {
297
+ if (b.type === 'web_search_tool_result' && Array.isArray(b.content)) for (const r of b.content) if (r.url) vistas.set(r.url, r.title ?? r.url);
298
+ if (b.type === 'text') {
299
+ texto += b.text;
300
+ for (const c of b.citations ?? []) if (c.url) vistas.set(c.url, c.title ?? vistas.get(c.url) ?? c.url);
301
+ }
302
+ }
303
+ if (res.stop_reason === 'refusal') throw new ErrorMorfeo('Claude no quiso investigar ese tema.');
304
+ if (res.stop_reason !== 'pause_turn') break;
305
+ log(' (sigue buscando…)');
306
+ messages.push({role: 'assistant', content: res.content});
307
+ }
308
+ if (!texto.trim()) throw new ErrorMorfeo('La investigación no devolvió datos; intenta otra vez o sin --investigar.');
309
+ // solo las fuentes que aparecen en las notas (las que de verdad respaldan un dato)
310
+ const fuentes = [...vistas].filter(([url]) => texto.includes(url) || texto.includes(new URL(url).hostname)).map(([url, titulo]) => ({texto: titulo, url}));
311
+ return {notas: texto.trim(), fuentes: fuentes.length ? fuentes : [...vistas].slice(0, 8).map(([url, titulo]) => ({texto: titulo, url}))};
312
+ }
313
+
247
314
  /**
248
315
  * Pide a Claude un video completo.
249
316
  * @param {{tema?: string, texto?: string, idioma?: string, duracion?: number, tono?: string, estilo?: string, imagenes?: boolean, voz?: any, modelo?: string}} o
@@ -261,14 +328,33 @@ export async function guionConIA(o) {
261
328
  throw new ErrorMorfeo('Falta la dependencia @anthropic-ai/sdk (npm install @anthropic-ai/sdk zod)');
262
329
  }
263
330
  const client = new Anthropic();
331
+ // --investigar: primero los datos con su fuente (búsqueda web), luego el guion se escribe solo con esos datos
332
+ let investigacion = null;
333
+ if (o.investigar && !o.texto) {
334
+ (o.log ?? console.log)('Investigando el tema en la web (cada dato con su fuente)...');
335
+ try {
336
+ investigacion = await investigar(client, Anthropic, o.tema, {idioma: o.idioma ?? 'español de México', modelo: o.modelo ?? MODELO_GUION, log: o.log ?? console.log});
337
+ } catch (e) {
338
+ if (e instanceof ErrorMorfeo) throw e;
339
+ if (e instanceof Anthropic.APIError) throw new ErrorMorfeo(`La búsqueda web falló (${e.status}): ${e.message}. Intenta sin --investigar.`);
340
+ throw e;
341
+ }
342
+ }
264
343
  const idioma = o.idioma ?? 'español de México';
265
- const pedido = o.texto
344
+ const pedidoBase = o.texto
266
345
  ? `Convierte este guion en escenas. Respeta su contenido y su orden; puedes partir frases largas.\n\n<guion>\n${o.texto}\n</guion>`
267
346
  : `Escribe un video sobre: ${o.tema}`;
347
+ const pedido = investigacion
348
+ ? `${pedidoBase}\n\nUsa SOLO estos datos investigados (no agregues cifras que no estén aquí):\n<investigacion>\n${investigacion.notas}\n</investigacion>`
349
+ : pedidoBase;
268
350
  // ~45 s por defecto: los videos de referencia rondan el minuto y ninguna idea pasa de ~5 s (lib/estructura.mjs)
269
- const dur = o.duracion ?? 45;
351
+ const forzada = o.formula ? formulaDe(o.formula) : null;
352
+ if (o.formula && !forzada) throw new ErrorMorfeo(`La fórmula "${o.formula}" no existe. Opciones: ${IDS_FORMULA.join(', ')}`);
353
+ // con fórmula, la duración es la de sus videos de referencia (el punto medio de su rango)
354
+ const dur = o.duracion ?? (forzada ? Math.round((forzada.duracion[0] + forzada.duracion[1]) / 2) : 45);
270
355
  const extra = [`Idioma: ${idioma}.`, `Duración aproximada: ${dur} segundos (unas ${Math.round(dur / 4)} escenas de 3 a 5 segundos).`];
271
356
  if (o.tono) extra.push(`Tono: ${o.tono}.`);
357
+ if (forzada) extra.push(`Fórmula: usa "${forzada.id}" (${forzada.nombre}) y su esqueleto tal cual.`);
272
358
  const motor = o.estilo && o.estilo !== 'datos-curiosos';
273
359
  if (motor) extra.push(`Estilo visual: ${o.estilo}.${o.imagenes ? ' Se pueden generar ilustraciones (usa imagen_ia en las escenas capas).' : ' No hay ilustraciones: usa emoji en las escenas capas.'}`);
274
360
 
@@ -278,7 +364,7 @@ export async function guionConIA(o) {
278
364
  model: o.modelo ?? MODELO_GUION,
279
365
  max_tokens: 16000,
280
366
  system: motor ? INSTRUCCIONES_MOTOR : INSTRUCCIONES,
281
- messages: [{role: 'user', content: `${pedido}\n\n${extra.join('\n')}\nEn "id" pon un identificador corto en minúsculas sin espacios. En "title" el título para publicar (con un emoji). En "music" el estilo de música que mejor le quede.`}],
367
+ messages: [{role: 'user', content: `${pedido}\n\n${extra.join('\n')}\nEn "id" pon un identificador corto en minúsculas sin espacios. En "title" el título para publicar (con un emoji). En "music" el estilo de música que mejor le quede. En "descripcion" el texto del post (1–2 frases). En "tags" 3–5 etiquetas sin #. En "hooks" 3 primeras frases alternativas al gancho, distintas entre sí, para probar otra versión.`}],
282
368
  output_config: {format: zodOutputFormat(motor ? await esquemaMotor() : await esquema())},
283
369
  });
284
370
  } catch (e) {
@@ -295,7 +381,7 @@ export async function guionConIA(o) {
295
381
  const scenes = g.scenes.map((e) => aEscenaMotor(e, !!o.imagenes));
296
382
  const c = g.cierre;
297
383
  const cta = !c || c.pide === 'sigue' ? {type: 'cta', ...(c?.say ? {say: c.say} : {})} : {type: 'cta', pide: c.pide, say: c.say, ...(c.palabra ? {palabra: c.palabra} : {}), ...(c.a ? {a: c.a} : {})};
298
- return {id: slug(g.id || g.title), video: {estilo: o.estilo, title: g.title, voice: o.voz ?? 'em_alex', music: g.music, scenes: [...scenes, cta]}};
384
+ return {id: slug(g.id || g.title), video: {estilo: o.estilo, formula: forzada?.id ?? g.formula, title: g.title, descripcion: g.descripcion, tags: g.tags, hooks: g.hooks, ...(investigacion?.fuentes.length ? {fuentes: investigacion.fuentes} : {}), voice: o.voz ?? 'em_alex', music: g.music, scenes: [...scenes, cta]}};
299
385
  }
300
386
  const scenes = g.scenes.map(aEscena);
301
387
  if (scenes[0]?.type !== 'title') scenes.unshift({type: 'title', say: scenes[0]?.say ?? g.title, rows: [[g.title.toUpperCase().slice(0, 14), '#FFD400', 200]], icon: 'question'});
package/lib/hoja.mjs CHANGED
@@ -1,6 +1,7 @@
1
1
  // `morfeo hoja <id>`: hoja de contacto (PNG con cuadros repartidos en el tiempo) para revisar un video de un vistazo.
2
2
  // El ffmpeg que trae Remotion viene recortado (sin los filtros `tile` ni `fps`), así que cada cuadro se pide como
3
3
  // PNG sin predicción por una tubería y la hoja se arma aquí con zlib.
4
+ import {ZONAS} from './calidad.mjs';
4
5
  import {spawnSync} from 'node:child_process';
5
6
  import fs from 'node:fs';
6
7
  import {createRequire} from 'node:module';
@@ -62,10 +63,11 @@ function leerPng(b, w, h) {
62
63
  }
63
64
 
64
65
  /**
65
- * @param {{video: string, salida?: string, cols?: number, filas?: number, desde?: number, hasta?: number, ancho?: number}} op
66
+ * @param {{video: string, salida?: string, cols?: number, filas?: number, desde?: number, hasta?: number, ancho?: number, zona?: boolean}} op
67
+ * zona: pinta en rojo lo que tapan los botones de TikTok/Reels/Shorts
66
68
  * @returns {string} ruta del PNG
67
69
  */
68
- export function hoja({video, salida, cols = 5, filas = 3, desde = 0, hasta, ancho = 216}) {
70
+ export function hoja({video, salida, cols = 5, filas = 3, desde = 0, hasta, ancho = 216, zona = false}) {
69
71
  // rutas absolutas: ffmpeg corre desde la carpeta de sus DLL, así que una ruta relativa no se encontraría
70
72
  video = path.resolve(video);
71
73
  if (salida) salida = path.resolve(salida);
@@ -87,6 +89,19 @@ export function hoja({video, salida, cols = 5, filas = 3, desde = 0, hasta, anch
87
89
  const r = spawnSync(ffmpeg, ['-v', 'error', '-ss', t.toFixed(3), '-i', video, '-frames:v', '1', '-vf', `scale=${w}:${h}`, '-f', 'image2pipe', '-c:v', 'png', '-pred', 'none', '-pix_fmt', 'rgb24', '-'], {maxBuffer: 64 * 1024 * 1024, cwd});
88
90
  if (r.status !== 0 || !r.stdout?.length) continue;
89
91
  const px = leerPng(r.stdout, w, h);
92
+ if (zona) {
93
+ // tinte rojo en las zonas que tapan las apps (escala de 1080×1920 al tamaño del cuadro)
94
+ const ex = w / 1080;
95
+ const ey = h / 1920;
96
+ for (const z of ZONAS)
97
+ for (let y = Math.floor(z.y0 * ey); y < Math.min(h, Math.ceil(z.y1 * ey)); y++)
98
+ for (let x = Math.floor(z.x0 * ex); x < Math.min(w, Math.ceil(z.x1 * ex)); x++) {
99
+ const k = (y * w + x) * 3;
100
+ px[k] = Math.round(px[k] * 0.55 + 255 * 0.45);
101
+ px[k + 1] = Math.round(px[k + 1] * 0.55);
102
+ px[k + 2] = Math.round(px[k + 2] * 0.55);
103
+ }
104
+ }
90
105
  const x0 = (i % cols) * w;
91
106
  const y0 = Math.floor(i / cols) * h;
92
107
  for (let y = 0; y < h; y++) px.copy(lienzo, (y0 + y) * (1 + W * 3) + 1 + x0 * 3, y * w * 3, (y + 1) * w * 3);
@@ -5,7 +5,14 @@ import path from 'node:path';
5
5
  import {ErrorMorfeo} from './config.mjs';
6
6
  import {PAQUETE} from './entorno.mjs';
7
7
 
8
- export const LOOKS = ['2d', '3d'];
8
+ /** Bases disponibles: cada carpeta de bases/ con un guion.mjs (y un Mundo.tsx si lleva mundo propio). */
9
+ export const LOOKS = (() => {
10
+ try {
11
+ return fs.readdirSync(path.join(PAQUETE, 'bases')).filter((d) => fs.existsSync(path.join(PAQUETE, 'bases', d, 'guion.mjs'))).sort();
12
+ } catch {
13
+ return ['2d', '3d'];
14
+ }
15
+ })();
9
16
 
10
17
  /** "mi-video_2" -> "MundoMiVideo2" */
11
18
  export const nombreComponente = (id) =>
@@ -41,7 +48,7 @@ export function registrarEnConfig(texto, nombre, ruta) {
41
48
 
42
49
  /**
43
50
  * @param {{proyecto: string, id: string, look?: string, carpetaGuiones?: string}} o
44
- * @returns {{guion: string, escena: string, componente: string}}
51
+ * @returns {{guion: string, escena: string | null, componente: string | null}}
45
52
  */
46
53
  export function nuevoVideo({proyecto, id, look = '2d', carpetaGuiones}) {
47
54
  if (!id || !/^[a-z0-9][a-z0-9-_]*$/i.test(id)) throw new ErrorMorfeo('Dame un id corto sin espacios: npx morfeo nuevo-video mi-video --look 2d');
@@ -52,12 +59,14 @@ export function nuevoVideo({proyecto, id, look = '2d', carpetaGuiones}) {
52
59
  const dirGuiones = carpetaGuiones ?? path.join(proyecto, 'guiones');
53
60
  const guion = path.join(dirGuiones, `${id}.mjs`);
54
61
  const escena = path.join(proyecto, 'escenas', `${componente}.tsx`);
55
- for (const f of [guion, escena]) if (fs.existsSync(f)) throw new ErrorMorfeo(`Ya existe ${path.relative(proyecto, f)}; elige otro id.`);
62
+ for (const f of [guion, escena]) if (f && fs.existsSync(f)) throw new ErrorMorfeo(`Ya existe ${path.relative(proyecto, f)}; elige otro id.`);
56
63
  const base = path.join(PAQUETE, 'bases', look);
57
64
  const poner = (s) => s.replaceAll('__ID__', id).replaceAll('__COMPONENTE__', componente);
58
65
  fs.mkdirSync(dirGuiones, {recursive: true});
59
66
  fs.mkdirSync(path.dirname(escena), {recursive: true});
60
67
  fs.writeFileSync(guion, poner(fs.readFileSync(path.join(base, 'guion.mjs'), 'utf8')));
68
+ // bases sin mundo propio (solo capas): no hay componente que copiar ni registrar
69
+ if (!fs.existsSync(path.join(base, 'Mundo.tsx'))) return {guion, escena: null, componente: null};
61
70
  fs.writeFileSync(escena, poner(fs.readFileSync(path.join(base, 'Mundo.tsx'), 'utf8')));
62
71
  const nuevo = registrarEnConfig(fs.readFileSync(config, 'utf8'), componente, `./escenas/${componente}.tsx`);
63
72
  if (nuevo) fs.writeFileSync(config, nuevo);
package/lib/palabras.mjs CHANGED
@@ -35,7 +35,7 @@ function momentosDe(x, ruta, out) {
35
35
  if (p) out.push([`${ruta}.${k}`, p]);
36
36
  }
37
37
  // lo que vive dentro de una capa: sus capas, su capa repetida, cambios, poses, items, líneas, movimientos
38
- for (const k of ['capas', 'capa', 'cambios', 'poses', 'items', 'lineas', 'mover', 'escalar', 'opciones', 'capasPantalla'])
38
+ for (const k of ['capas', 'capa', 'cambios', 'poses', 'items', 'lineas', 'mover', 'escalar', 'opciones', 'capasPantalla', 'hitos', 'notificaciones'])
39
39
  if (x[k] != null) momentosDe(x[k], `${ruta}.${k}`, out);
40
40
  }
41
41
 
@@ -70,7 +70,7 @@ export function revisarPalabras(v, d) {
70
70
  avisos.push(`escena ${i + 1}, ${ruta}: "${p}" no está en ${s.show ? 'el subtítulo (`show`)' : 'lo que dice la voz'} ("${texto}"); se usará la mitad de la frase`);
71
71
  });
72
72
  // hud y mundo usan las palabras de todo el video
73
- for (const [k, lista] of [['hud', v.hud], ['mundo', v.mundo]]) {
73
+ for (const [k, lista] of [['hud', v.hud], ['mundo', v.mundo], ...Object.entries(v.lugares ?? {}).map(([n, l]) => [`lugares.${n}`, l])]) {
74
74
  const momentos = [];
75
75
  momentosDe(lista ?? [], k, momentos);
76
76
  for (const [ruta, p] of momentos) if (!laDice(p, todas)) avisos.push(`${ruta}: "${p}" no aparece en ninguna escena`);