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/bases/biografia/Mundo.tsx +35 -0
- package/bases/biografia/guion.mjs +34 -0
- package/bases/construiste/Mundo.tsx +273 -0
- package/bases/construiste/guion.mjs +84 -0
- package/bases/escalada/Mundo.tsx +45 -0
- package/bases/escalada/guion.mjs +24 -0
- package/bases/mito/Mundo.tsx +55 -0
- package/bases/mito/guion.mjs +29 -0
- package/bases/serie/guion.mjs +32 -0
- package/bin/morfeo.mjs +36 -9
- package/index.d.ts +34 -1
- package/lib/calidad.mjs +74 -0
- package/lib/config.mjs +38 -6
- package/lib/estilo-pagina.mjs +174 -0
- package/lib/estilo-propio.mjs +70 -0
- package/lib/estructura.mjs +12 -3
- package/lib/formulas.mjs +179 -0
- package/lib/guion.mjs +91 -5
- package/lib/hoja.mjs +17 -2
- package/lib/nuevo-video.mjs +12 -3
- package/lib/palabras.mjs +2 -2
- package/lib/render.mjs +33 -4
- package/package.json +1 -1
- package/plantilla/AGENTS.md +54 -0
- package/plantilla/CLAUDE.md +5 -0
- package/python/musica.py +87 -1
- package/python/preparar.py +1 -1
- package/remotion/Short.tsx +10 -2
- package/remotion/kit/armar.tsx +245 -0
- package/remotion/kit/index.ts +1 -0
- package/remotion/motor/Video.tsx +50 -16
- package/remotion/motor/capas.tsx +2 -0
- package/remotion/motor/historias.tsx +483 -0
- package/remotion/motor/portada.tsx +44 -0
- package/remotion/motor/tierra-datos.ts +2 -0
- package/remotion/piezas.tsx +3 -3
- package/remotion/tipos.ts +4 -0
|
@@ -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
|
+
}
|
package/lib/estructura.mjs
CHANGED
|
@@ -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
|
|
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
|
|
package/lib/formulas.mjs
ADDED
|
@@ -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
|
|
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
|
|
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);
|
package/lib/nuevo-video.mjs
CHANGED
|
@@ -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
|
-
|
|
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`);
|