morfeo 0.2.1 → 0.3.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/bases/2d/Mundo.tsx +43 -0
- package/bases/2d/guion.mjs +32 -0
- package/bases/3d/Mundo.tsx +45 -0
- package/bases/3d/guion.mjs +26 -0
- package/bin/morfeo.mjs +21 -3
- package/index.d.ts +27 -5
- package/lib/config.mjs +47 -10
- package/lib/entorno.mjs +22 -2
- package/lib/estructura.mjs +15 -4
- package/lib/guion.mjs +2 -2
- package/lib/hoja.mjs +3 -0
- package/lib/nuevo-video.mjs +65 -0
- package/lib/palabras.mjs +79 -0
- package/lib/render.mjs +81 -16
- package/lib/whisper.mjs +47 -3
- package/package.json +4 -3
- package/plantilla/morfeo.config.mjs +3 -1
- package/python/musica.py +40 -7
- package/python/preparar.py +10 -1
- package/remotion/kit/datos.tsx +180 -0
- package/remotion/kit/diorama.tsx +489 -0
- package/remotion/kit/escenarios2d.tsx +250 -0
- package/remotion/kit/index.ts +9 -1
- package/remotion/kit/mundo3d.tsx +251 -0
- package/remotion/kit/personas.tsx +439 -0
- package/remotion/kit/tres.tsx +126 -10
- package/remotion/motor/Video.tsx +13 -7
- package/remotion/motor/capas.tsx +29 -2
- package/remotion/motor/fichas.tsx +255 -0
- package/remotion/motor/modelo3d.tsx +1 -1
- package/remotion/motor/subtitulos.tsx +2 -2
- package/remotion/motor/tema.ts +20 -1
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
// Mundo 2D de __ID__ (2160×1920): un escenario del kit + personajes que reaccionan a la historia.
|
|
2
|
+
// Recibe t (segundos del video), cuando('palabra'), escena (1, 2, 3...), tEscena, inicios y camara (para paralaje).
|
|
3
|
+
// Piezas: Persona2D, Multitud, Globo · CiudadNoche, Cuarto, Campo, Calle, Oficina, Mar · Filtros, ConoLuz, Paralaje.
|
|
4
|
+
import React from 'react';
|
|
5
|
+
import {CiudadNoche, ConoLuz, Filtros, Globo, Persona2D, type Camara} from 'morfeo/kit';
|
|
6
|
+
|
|
7
|
+
type Props = {t: number; cuando: (en: unknown) => number; escena?: number; tEscena?: number; inicios?: number[]; camara?: Camara};
|
|
8
|
+
|
|
9
|
+
const ANCHO = 2160;
|
|
10
|
+
const SUELO = 1500;
|
|
11
|
+
|
|
12
|
+
export default function __COMPONENTE__({t, cuando, escena = 1, inicios = [0], camara}: Props) {
|
|
13
|
+
const ini = (n: number) => inicios[n - 1] ?? 1e9;
|
|
14
|
+
// la vecina entra corriendo en la escena 2 y se detiene junto al protagonista
|
|
15
|
+
const k = Math.max(0, Math.min(1, (t - ini(2)) / 1.6));
|
|
16
|
+
const vecinaX = 2050 - 520 * (1 - (1 - k) ** 2);
|
|
17
|
+
return (
|
|
18
|
+
<svg width={ANCHO} height={1920} viewBox={`0 0 ${ANCHO} 1920`} style={{overflow: 'visible', display: 'block'}}>
|
|
19
|
+
<Filtros />
|
|
20
|
+
<CiudadNoche t={t} camara={camara} ancho={ANCHO} suelo={SUELO} />
|
|
21
|
+
{/* protagonista: despierta asustado y señala cuando aparece la vecina */}
|
|
22
|
+
<Persona2D
|
|
23
|
+
x={900}
|
|
24
|
+
y={SUELO + 40}
|
|
25
|
+
alto={560}
|
|
26
|
+
t={t}
|
|
27
|
+
ropa="#2F6FE0"
|
|
28
|
+
peinado="corto"
|
|
29
|
+
expresion="sorpresa"
|
|
30
|
+
estados={[{en: ini(2), expresion: 'miedo', pose: 'señalar'}, {en: ini(4), expresion: 'triste', pose: 'parado'}]}
|
|
31
|
+
semilla={1}
|
|
32
|
+
/>
|
|
33
|
+
{/* la vecina con su linterna */}
|
|
34
|
+
{escena >= 2 ? (
|
|
35
|
+
<g>
|
|
36
|
+
<ConoLuz id="linterna" x={vecinaX - 150} y={SUELO - 300} ancho={420} largo={900} angulo={-70} fuerza={0.5} />
|
|
37
|
+
<Persona2D x={vecinaX} y={SUELO + 50} alto={520} t={t} ropa="#E86AA6" piel="#C98E66" pelo="#4A2C17" peinado="largo" pose={k < 1 ? 'caminar' : 'parado'} expresion="miedo" voltear semilla={2} />
|
|
38
|
+
<Globo x={vecinaX - 60} y={SUELO - 520} texto="¡No hay luz en ningún lado!" t={t} en={cuando('linterna')} hasta={ini(3)} lado="izq" ancho={420} />
|
|
39
|
+
</g>
|
|
40
|
+
) : null}
|
|
41
|
+
</svg>
|
|
42
|
+
);
|
|
43
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
// __ID__ · base 2D (creada con `npx morfeo nuevo-video __ID__ --look 2d`). Ya renderiza: cámbiale el texto, el mundo
|
|
2
|
+
// (escenas/__COMPONENTE__.tsx) y las tomas. Estructura (npx morfeo revisar __ID__): gancho → contexto → pregunta →
|
|
3
|
+
// revelación → cierre que pide algo. `toma` = punto del mundo (2160×1920) que queda al centro y cuánto acerca.
|
|
4
|
+
export default {
|
|
5
|
+
estilo: 'historia',
|
|
6
|
+
voice: 'narrador',
|
|
7
|
+
music: 'lofi',
|
|
8
|
+
title: 'Título para publicar 🌙',
|
|
9
|
+
descripcion: 'Una línea que resuma el video.',
|
|
10
|
+
tags: ['curiosidades', 'historias'],
|
|
11
|
+
tema: {fondo: {tipo: 'color', color: '#070A1F'}, particulas: {tipo: 'ninguna'}},
|
|
12
|
+
mundo: [{tipo: 'componente', nombre: '__COMPONENTE__', x: 0, y: 960, ancla: 'izquierda', entrada: 'ninguna'}],
|
|
13
|
+
scenes: [
|
|
14
|
+
// 1 · gancho: segunda persona, el sujeto ya en pantalla
|
|
15
|
+
{say: 'Imagina que te despiertas y toda tu ciudad está a oscuras.', claves: ['oscuras'], rotulo: '¿TODO *APAGADO*?', rotuloEn: 'ciudad',
|
|
16
|
+
toma: {x: 1080, y: 900, zoom: 1, a: {x: 900, y: 1150, zoom: 1.25}},
|
|
17
|
+
capas: [{tipo: 'accion', texto: 'Te despiertas', x: 540, y: 1480, en: 'despiertas'}]},
|
|
18
|
+
// 2 · contexto con nombre pegado al personaje
|
|
19
|
+
{say: 'Tu vecina Rosa sale corriendo con una linterna.', claves: ['linterna'],
|
|
20
|
+
toma: {x: 1460, y: 1150, zoom: 1.4},
|
|
21
|
+
capas: [{tipo: 'nombre', texto: 'Rosa', sub: 'tu vecina · 48 años', x: 540, y: 620, en: 'Rosa'}]},
|
|
22
|
+
// 3 · pregunta antes de revelar
|
|
23
|
+
{say: '¿Sabes cuánto aguanta una ciudad sin luz?', rotulo: '¿CUÁNTO *AGUANTA*?',
|
|
24
|
+
toma: {x: 1080, y: 760, zoom: 0.95, duracion: 0.9}},
|
|
25
|
+
// 4 · revelación con un número que cuenta
|
|
26
|
+
{say: 'Unas setenta y dos horas, antes de quedarse sin agua.', show: 'Unas 72 horas, antes de quedarse sin agua.', claves: ['agua'],
|
|
27
|
+
toma: {x: 900, y: 1120, zoom: 1.6},
|
|
28
|
+
capas: [{tipo: 'dato', valor: 72, de: 0, unidad: 'horas', x: 540, y: 600, en: '72', duracion: 1.4, tam: 96}]},
|
|
29
|
+
// 5 · cierre que pide una acción concreta
|
|
30
|
+
{type: 'cta', pide: 'comenta', palabra: 'LUZ', sub: 'si te ha pasado', say: 'Escribe luz si alguna vez te pasó.', toma: {x: 1080, y: 760, zoom: 1}},
|
|
31
|
+
],
|
|
32
|
+
};
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
// Mundo 3D de __ID__: un solo Lienzo3D para todo el video (Chrome aguanta pocos contextos WebGL).
|
|
2
|
+
// TOMAS = una toma de cámara por escena: pos (dónde está), mirar (a dónde mira) y `a` (a dónde se mueve en la escena).
|
|
3
|
+
// En vertical el cuadro es angosto: para un plano general pon la cámara en diagonal, no de frente.
|
|
4
|
+
// Piezas: Lienzo3D, Persona3D, Ciudad, Camino, Coche, Cuarto3D, Terreno, Tubo, Arbol, Casa, Mesa, Pila, Bloque, Objeto3D.
|
|
5
|
+
import React from 'react';
|
|
6
|
+
import {Arbol, Camino, Ciudad, Coche, Lienzo3D, Persona3D, type Toma3D} from 'morfeo/kit';
|
|
7
|
+
|
|
8
|
+
type Props = {t: number; cuando: (en: unknown) => number; escena?: number; tEscena?: number; inicios?: number[]};
|
|
9
|
+
|
|
10
|
+
const TOMAS: Toma3D[] = [
|
|
11
|
+
// 1 · gancho: la calle con el coche, la cámara baja hacia la gente
|
|
12
|
+
{pos: [-7, 5, 12], mirar: [0, 1, 0], a: {pos: [-5, 3.4, 9]}},
|
|
13
|
+
// 2 · a nivel de calle, la gente caminando
|
|
14
|
+
{pos: [3.5, 1.9, 7], mirar: [0, 1.2, 1.5], a: {pos: [2.5, 1.8, 6]}},
|
|
15
|
+
// 3 · la pregunta: primer plano del protagonista
|
|
16
|
+
{pos: [0.6, 1.9, 4.6], mirar: [0, 1.65, 2], fov: 32, a: {pos: [0.5, 1.85, 4]}},
|
|
17
|
+
// 4 · se abre: toda la calle desde arriba
|
|
18
|
+
{pos: [-9, 9, 13], mirar: [0, 0.5, -2], a: {pos: [-7, 8, 11]}},
|
|
19
|
+
// 5 · cierre
|
|
20
|
+
{pos: [0, 12, 16], mirar: [0, 4, -4]},
|
|
21
|
+
];
|
|
22
|
+
|
|
23
|
+
export default function __COMPONENTE__({t, escena = 1, tEscena = 0, inicios = [0]}: Props) {
|
|
24
|
+
const ini = (n: number) => inicios[n - 1] ?? 1e9;
|
|
25
|
+
// el coche cruza en la escena 1 y desaparece (se encoge) al final de esa escena
|
|
26
|
+
const kCoche = Math.max(0, Math.min(1, (t - ini(1)) / Math.max(0.5, ini(2) - ini(1))));
|
|
27
|
+
const escalaCoche = escena === 1 ? 1 - Math.max(0, kCoche - 0.75) * 4 : 0;
|
|
28
|
+
const caminan = escena >= 2;
|
|
29
|
+
return (
|
|
30
|
+
<Lienzo3D tomas={TOMAS} escena={escena} tEscena={tEscena} inicios={inicios} luz="dia" fondo="#BFE6FF" niebla={[30, 80]}>
|
|
31
|
+
<Ciudad t={t} pos={[0, 0, -3]} filas={4} columnas={6} />
|
|
32
|
+
<Camino largo={50} ancho={5} />
|
|
33
|
+
{escalaCoche > 0.01 ? <Coche pos={[-6 + kCoche * 10, 0, 1.4]} escala={escalaCoche} t={t} /> : null}
|
|
34
|
+
<Persona3D pos={[0, 0, 2]} t={t} expresion={escena === 3 ? 'pensativo' : escena >= 4 ? 'sorpresa' : 'neutral'} pose={caminan && escena !== 3 ? 'caminar' : 'parado'} ropa="#F07A3A" semilla={1} />
|
|
35
|
+
{caminan
|
|
36
|
+
? [-1.6, 1.4, -0.6, 2.2].map((x, i) => (
|
|
37
|
+
<Persona3D key={i} pos={[x, 0, -1 - i * 1.6 + ((t * 0.8) % 3)]} t={t} pose="caminar" ropa={['#2F6FE0', '#2FA36B', '#8B5BD6', '#E04A4A'][i]} pelo={['#2B1B12', '#E6C36B', '#4A2C17', '#1A1A1A'][i]} semilla={i + 2} />
|
|
38
|
+
))
|
|
39
|
+
: null}
|
|
40
|
+
{[-4.2, 4.2].map((x, i) => (
|
|
41
|
+
<Arbol key={i} pos={[x, 0, 2]} semilla={i} />
|
|
42
|
+
))}
|
|
43
|
+
</Lienzo3D>
|
|
44
|
+
);
|
|
45
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
// __ID__ · base 3D (creada con `npx morfeo nuevo-video __ID__ --look 3d`). Ya renderiza: cámbiale el texto y el mundo
|
|
2
|
+
// (escenas/__COMPONENTE__.tsx, donde están las tomas de cámara 3D, una por escena). Estructura (npx morfeo revisar __ID__):
|
|
3
|
+
// gancho → contexto → pregunta → revelación → cierre que pide algo.
|
|
4
|
+
export default {
|
|
5
|
+
estilo: '3d',
|
|
6
|
+
voice: 'narradora',
|
|
7
|
+
music: 'chillpop',
|
|
8
|
+
title: 'Título para publicar 🏙️',
|
|
9
|
+
descripcion: 'Una línea que resuma el video.',
|
|
10
|
+
tags: ['quepasariasi', 'curiosidades'],
|
|
11
|
+
mundo: [{tipo: 'componente', nombre: '__COMPONENTE__', x: 540, y: 960, entrada: 'ninguna'}],
|
|
12
|
+
scenes: [
|
|
13
|
+
// 1 · gancho: "¿y si...?" con el sujeto en pantalla
|
|
14
|
+
{say: '¿Y si todos los coches de tu ciudad desaparecieran mañana?', claves: ['coches'], rotulo: '¿SIN *COCHES*?', rotuloEn: 'desaparecieran'},
|
|
15
|
+
// 2 · contexto
|
|
16
|
+
{say: 'Las calles se llenarían de gente caminando.', claves: ['caminando'],
|
|
17
|
+
capas: [{tipo: 'accion', texto: 'Sales a caminar', x: 540, y: 560, en: 'gente'}]},
|
|
18
|
+
// 3 · pregunta antes de revelar
|
|
19
|
+
{say: '¿Cuánto tardarías en llegar al trabajo?', rotulo: '¿CUÁNTO *TARDAS*?'},
|
|
20
|
+
// 4 · revelación con un número que cuenta
|
|
21
|
+
{say: 'En promedio, tres veces más que hoy.', claves: ['tres'],
|
|
22
|
+
capas: [{tipo: 'dato', valor: 3, de: 1, unidad: 'veces más', x: 540, y: 560, en: 'tres', duracion: 1.2, tam: 96}]},
|
|
23
|
+
// 5 · cierre que pide una acción concreta
|
|
24
|
+
{type: 'cta', pide: 'comparte', a: 'el que va a todos lados en coche', say: 'Mándaselo al que va a todos lados en coche.'},
|
|
25
|
+
],
|
|
26
|
+
};
|
package/bin/morfeo.mjs
CHANGED
|
@@ -13,12 +13,14 @@ 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
17
|
doctor Revisa qué está instalado y qué falta
|
|
17
18
|
|
|
18
19
|
Videos
|
|
19
20
|
lista Muestra los videos del proyecto y cuáles ya están hechos
|
|
20
21
|
render [id...] Genera los videos que faltan (o los que indiques)
|
|
21
22
|
--todos regenera todos
|
|
23
|
+
--solo-fallidos reintenta solo los que fallaron en el último lote
|
|
22
24
|
--gpu usa la tarjeta de video (más rápido; si falla, quítalo)
|
|
23
25
|
revisar [id...] Revisa la estructura (gancho, ritmo, preguntas, cierre) con reglas de videos virales
|
|
24
26
|
hoja <id> Hoja de contacto (PNG con 15 cuadros) para revisar un video de un vistazo
|
|
@@ -51,7 +53,7 @@ function opciones(argv) {
|
|
|
51
53
|
const [k, v] = a.slice(2).split('=');
|
|
52
54
|
const clave = k.replace(/-([a-z])/g, (_, c) => c.toUpperCase());
|
|
53
55
|
const sig = argv[i + 1];
|
|
54
|
-
const conValor = ['proyecto', 'desde', 'id', 'voz', 'duracion', 'idioma', 'estilo', 'tono', 'variante', 'fuente', 'musica', 'titulo', 'modelo', 'puerto', 'hasta', 'cols', 'filas'];
|
|
56
|
+
const conValor = ['proyecto', 'desde', 'id', 'voz', 'duracion', 'idioma', 'estilo', 'tono', 'variante', 'fuente', 'musica', 'titulo', 'modelo', 'puerto', 'hasta', 'cols', 'filas', 'look'];
|
|
55
57
|
if (v != null) op[clave] = v;
|
|
56
58
|
else if (conValor.includes(clave) && sig != null && !sig.startsWith('--')) op[clave] = argv[++i];
|
|
57
59
|
else op[clave] = true;
|
|
@@ -83,6 +85,18 @@ async function main() {
|
|
|
83
85
|
case 'nuevo':
|
|
84
86
|
case 'new':
|
|
85
87
|
return nuevo(pos[0], op);
|
|
88
|
+
case 'nuevo-video': {
|
|
89
|
+
const {nuevoVideo} = await import('../lib/nuevo-video.mjs');
|
|
90
|
+
const look = op.look === true || op.look == null ? '2d' : String(op.look).toLowerCase();
|
|
91
|
+
const r = nuevoVideo({proyecto, id: pos[0], look});
|
|
92
|
+
const rel = (f) => path.relative(process.cwd(), f);
|
|
93
|
+
console.log(`Listo (${look}):
|
|
94
|
+
${rel(r.guion)} el guion: texto, tomas, rótulos y capas
|
|
95
|
+
${rel(r.escena)} el mundo (${r.componente}, ya registrado en morfeo.config.mjs)
|
|
96
|
+
|
|
97
|
+
Pruébalo: npx morfeo render ${pos[0]} y revísalo: npx morfeo hoja ${pos[0]}`);
|
|
98
|
+
return;
|
|
99
|
+
}
|
|
86
100
|
case 'lista':
|
|
87
101
|
case 'ls': {
|
|
88
102
|
const {lista} = await import('../lib/render.mjs');
|
|
@@ -95,7 +109,7 @@ async function main() {
|
|
|
95
109
|
case 'render': {
|
|
96
110
|
if (op.gpu) process.env.MORFEO_GL = 'angle';
|
|
97
111
|
const {render} = await import('../lib/render.mjs');
|
|
98
|
-
await render({proyecto, ids: pos, todos: !!op.todos || pos[0] === 'todos'});
|
|
112
|
+
await render({proyecto, ids: pos, todos: !!op.todos || pos[0] === 'todos', soloFallidos: !!op.soloFallidos});
|
|
99
113
|
return;
|
|
100
114
|
}
|
|
101
115
|
case 'revisar': {
|
|
@@ -168,6 +182,7 @@ async function main() {
|
|
|
168
182
|
serie: 'series con personaje y capítulos: HUD (días, barras), cartas VS, "comenta", subtítulos en bloque',
|
|
169
183
|
infografia: 'infografía plana: medidores, barras, globos, pantalla dividida, rayos X',
|
|
170
184
|
'3d': 'escenas 3D con recorrido de cámara (tu .glb), una palabra grande a la vez',
|
|
185
|
+
maqueta: 'diorama 3D low-poly con corte de tierra: una palabra al centro, etiquetas con flecha, cortes rápidos (kit diorama)',
|
|
171
186
|
minimal: 'oscuro y limpio, para lo que sea',
|
|
172
187
|
};
|
|
173
188
|
for (const e of ESTILOS) console.log(` ${e.padEnd(16)} ${desc[e] ?? ''}`);
|
|
@@ -221,7 +236,7 @@ async function guion(pos, op, proyecto) {
|
|
|
221
236
|
console.log(`Revísalo y edítalo si quieres; luego: npx morfeo render ${id}`);
|
|
222
237
|
}
|
|
223
238
|
|
|
224
|
-
function nuevo(carpeta, op) {
|
|
239
|
+
async function nuevo(carpeta, op) {
|
|
225
240
|
if (!carpeta) throw new ErrorMorfeo('Dime el nombre de la carpeta: npx morfeo nuevo mi-canal');
|
|
226
241
|
const destino = path.resolve(carpeta);
|
|
227
242
|
if (fs.existsSync(destino) && fs.readdirSync(destino).length) throw new ErrorMorfeo(`La carpeta ${carpeta} ya existe y no está vacía.`);
|
|
@@ -233,6 +248,9 @@ function nuevo(carpeta, op) {
|
|
|
233
248
|
const nombre = path.basename(destino).toLowerCase().replace(/[^a-z0-9-_]/g, '-');
|
|
234
249
|
const pkg = path.join(destino, 'package.json');
|
|
235
250
|
fs.writeFileSync(pkg, fs.readFileSync(pkg, 'utf8').replace('__NOMBRE__', nombre).replace('__VERSION__', `^${VERSION}`));
|
|
251
|
+
// dos videos de ejemplo con mundo y la estructura completa (los mismos que `morfeo nuevo-video`)
|
|
252
|
+
const {nuevoVideo} = await import('../lib/nuevo-video.mjs');
|
|
253
|
+
for (const look of ['2d', '3d']) nuevoVideo({proyecto: destino, id: `ejemplo-${look}`, look});
|
|
236
254
|
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`);
|
|
237
255
|
}
|
|
238
256
|
|
package/index.d.ts
CHANGED
|
@@ -21,7 +21,7 @@ interface EscenaBase {
|
|
|
21
21
|
audio?: string;
|
|
22
22
|
}
|
|
23
23
|
|
|
24
|
-
export type Estilo = 'datos-curiosos' | 'explicativo' | 'documental' | 'ilustrado' | 'historia' | 'mesa' | 'serie' | 'infografia' | '3d' | 'minimal' | (string & {});
|
|
24
|
+
export type Estilo = 'datos-curiosos' | 'explicativo' | 'documental' | 'ilustrado' | 'historia' | 'mesa' | 'serie' | 'infografia' | '3d' | 'maqueta' | 'minimal' | (string & {});
|
|
25
25
|
|
|
26
26
|
/** Objetos 3D incluidos para capas {tipo: 'modelo', forma}: mejor opción que un emoji. */
|
|
27
27
|
export type Objeto3D = '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';
|
|
@@ -63,7 +63,8 @@ interface CapaBase {
|
|
|
63
63
|
sombra?: boolean | string;
|
|
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
68
|
/** Cambios en el tiempo (ideal para el HUD): los números se animan desde el valor anterior. */
|
|
68
69
|
cambios?: ({en: Momento | {escena: number}} & Record<string, unknown>)[];
|
|
69
70
|
}
|
|
@@ -84,8 +85,16 @@ export type Capa = CapaBase &
|
|
|
84
85
|
| {tipo: 'terminal'; lineas: (string | {texto: string; en?: Momento; salida?: boolean; color?: string})[]; titulo?: string; ancho?: number; tam?: number; fondo?: string; velocidad?: number}
|
|
85
86
|
| {tipo: 'nombre'; texto: string; sub?: string; tam?: number; fondo?: string; colorTexto?: string; borde?: string}
|
|
86
87
|
| {tipo: 'accion'; texto: string; tam?: number; color?: string; fondo?: string; colorTexto?: string}
|
|
88
|
+
/** Tarjetas de datos (estilo maqueta): se llenan o palomean solas desde `en`. */
|
|
89
|
+
| {tipo: 'reloj'; de?: number; a?: number; max?: number; segmentos?: [number, number, string][]; etiqueta?: string | false; tam?: number; color?: string; duracion?: number}
|
|
90
|
+
| {tipo: 'cronometro'; de?: number; a?: number; max?: number; unidad?: 'seg' | 'min'; tam?: number; color?: string; duracion?: number}
|
|
91
|
+
| {tipo: 'termometro'; valor: number; de?: number; min?: number; max?: number; unidad?: string; alto?: number; color?: string; decimales?: number; mostrar?: boolean}
|
|
92
|
+
| {tipo: 'recipientes'; n?: number; valor?: number; forma?: 'garrafon' | 'vaso'; total?: string; color?: string; tamPieza?: number; duracion?: number}
|
|
93
|
+
| {tipo: 'ficha'; titulo?: string; foto?: string; campos: [string, string][]; ancho?: number; color?: string; fondoFoto?: string}
|
|
94
|
+
| {tipo: 'checklist'; items: (string | {texto: string; en?: Momento; pendiente?: boolean})[]; titulo?: string; tam?: number; color?: string; cada?: number}
|
|
95
|
+
| {tipo: 'mapa'; ruta?: [number, number][]; distancia?: string; ancho?: number; color?: string; fondo?: string; inclinar?: number; duracion?: number}
|
|
87
96
|
| {tipo: 'cursor'; a: [number, number]; clic?: Momento; duracion?: number; tam?: number; color?: string}
|
|
88
|
-
| {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'; alinear?: 'center' | 'left' | 'right'; interlineado?: number; escribir?: boolean | number; subrayado?: boolean}
|
|
97
|
+
| {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}
|
|
89
98
|
| {tipo: 'contador'; de?: number; a: number; prefijo?: string; sufijo?: string; decimales?: number; miles?: string; decimal?: string; duracion?: number; curva?: 'suave' | 'lineal' | 'exponencial'; formato?: 'grande' | 'lcd' | 'chip'; tam?: number; color?: string; fondo?: string}
|
|
90
99
|
| {tipo: 'chip'; texto: string; unidad?: string; tam?: number; color?: string; fondo?: string; borde?: string}
|
|
91
100
|
| {tipo: 'etiqueta'; texto: string; tam?: number; color?: string}
|
|
@@ -144,6 +153,9 @@ interface EscenaMotorBase extends EscenaBase {
|
|
|
144
153
|
rotulo?: string;
|
|
145
154
|
rotuloEn?: Momento;
|
|
146
155
|
rotuloTam?: number;
|
|
156
|
+
/** 'normal' | 'contorno' (blanco con contorno, clave en acento) | 'pastilla' (caja oscura inclinada: ¡CUIDADO!). Por defecto, el del estilo. */
|
|
157
|
+
rotuloEstilo?: 'normal' | 'contorno' | 'pastilla';
|
|
158
|
+
rotuloInclinar?: number;
|
|
147
159
|
/** Palabras clave de la escena para los subtítulos (la que lleva caja o color de acento en cada frase). */
|
|
148
160
|
claves?: string[];
|
|
149
161
|
/** Ajustes del tema solo para esta escena. */
|
|
@@ -171,7 +183,7 @@ export interface Tema {
|
|
|
171
183
|
colores: {texto: string; acento: string; acento2: string; suave: string; tarjeta: string; tarjetaTexto: string; borde: string};
|
|
172
184
|
fuentes: {titulo: string; texto: string; mono: string};
|
|
173
185
|
tituloMayusculas: boolean;
|
|
174
|
-
subtitulos: {tipo: 'karaoke' | 'seleccion' | 'pop' | 'bloque' | 'palabra' | 'caja' | 'ninguno'; y: number; tam: number; color: string; pendiente: string; resalte: string; maxPalabras: number; mayusculas?: boolean; contorno?: number; fondo?: string};
|
|
186
|
+
subtitulos: {tipo: 'karaoke' | 'seleccion' | 'pop' | 'bloque' | 'palabra' | 'caja' | 'ninguno'; y: number; tam: number; color: string; pendiente: string; resalte: string; maxPalabras: number; mayusculas?: boolean | 'inicial'; contorno?: number; fondo?: string};
|
|
175
187
|
transicion: Transicion;
|
|
176
188
|
/** Ritmo de las animaciones: duraciones (s) y curva de las entradas. */
|
|
177
189
|
movimiento?: {entrada?: number; salida?: number; transicion?: number; curva?: 'suave' | 'resorte' | 'seca'};
|
|
@@ -182,6 +194,8 @@ export interface Tema {
|
|
|
182
194
|
desenfoqueBordes?: number;
|
|
183
195
|
vinetaColor?: string;
|
|
184
196
|
};
|
|
197
|
+
/** Rótulo de cada escena: estilo 'normal' | 'contorno' | 'pastilla', inclinación, altura (px) y tamaño. */
|
|
198
|
+
rotulo?: {estilo?: 'normal' | 'contorno' | 'pastilla'; inclinar?: number; y?: number; tam?: number};
|
|
185
199
|
marca: 'esquina' | 'arriba' | 'ninguna';
|
|
186
200
|
zoomLento: number;
|
|
187
201
|
}
|
|
@@ -233,6 +247,13 @@ export interface Video {
|
|
|
233
247
|
mundo?: Capa[];
|
|
234
248
|
/** Pastilla fija arriba para series: 'GYM CON CIENCIA · PARTE 5'. */
|
|
235
249
|
serie?: string;
|
|
250
|
+
/** Calidad de este video (ver `render.calidad`). */
|
|
251
|
+
calidad?: 'alta' | 'media' | 'ligera';
|
|
252
|
+
/** Para publicar: descripción y etiquetas (van a salida/<id>.txt junto con el título). */
|
|
253
|
+
descripcion?: string;
|
|
254
|
+
tags?: string[];
|
|
255
|
+
/** 'largo' = explicador de 90–130 s (lo asume el estilo maqueta): cambia las reglas de `morfeo revisar`. */
|
|
256
|
+
formato?: 'corto' | 'largo';
|
|
236
257
|
/** Ajustes del estilo para este video (colores, fondo, subtítulos...). */
|
|
237
258
|
tema?: Partial<Tema> & Record<string, unknown>;
|
|
238
259
|
/** Título para publicar. */
|
|
@@ -286,7 +307,8 @@ export interface Config {
|
|
|
286
307
|
/** Carpeta de los .mp4 (por defecto ./salida). */
|
|
287
308
|
salida?: string;
|
|
288
309
|
/** gl: por defecto 'angle' (GPU) en Mac con Apple Silicon y 'swangle' (CPU) en lo demás; si la GPU falla, reintenta con CPU. */
|
|
289
|
-
|
|
310
|
+
/** `calidad` alta = CRF 18, media = 21, ligera = 24 (sin decirlo: media con `mundo`, alta sin él). `crf` la fuerza. `consejos: false` apaga la revisión de estructura. */
|
|
311
|
+
render?: {crf?: number; calidad?: 'alta' | 'media' | 'ligera'; consejos?: boolean; gl?: 'swangle' | 'angle' | 'egl' | 'swiftshader' | 'vulkan'; navegador?: string | null; concurrencia?: number | null; miniatura?: boolean};
|
|
290
312
|
}
|
|
291
313
|
|
|
292
314
|
export declare function defineConfig(config: Config): Config;
|
package/lib/config.mjs
CHANGED
|
@@ -22,8 +22,8 @@ const parecidas = (nombre, lista, n = 3) => {
|
|
|
22
22
|
export const TIPOS_CLASICOS = ['title', 'year', 'text', 'icon', 'compare', 'card', 'dialog', 'aliens', 'memory', 'morph', 'cta'];
|
|
23
23
|
export const TIPOS_MOTOR = ['capas', 'titular', 'dato', 'lista', 'telefono', 'comparar', 'eleccion', 'cta'];
|
|
24
24
|
export const TIPOS_ESCENA = [...new Set([...TIPOS_CLASICOS, ...TIPOS_MOTOR])];
|
|
25
|
-
export const ESTILOS = ['datos-curiosos', 'explicativo', 'documental', 'ilustrado', 'historia', 'mesa', 'serie', 'infografia', '3d', 'minimal'];
|
|
26
|
-
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'];
|
|
25
|
+
export const ESTILOS = ['datos-curiosos', 'explicativo', 'documental', 'ilustrado', 'historia', 'mesa', 'serie', 'infografia', '3d', 'maqueta', 'minimal'];
|
|
26
|
+
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'];
|
|
27
27
|
export const ESCENARIOS = ['bosque', 'cuarto', 'departamento', 'tunel', 'pasillo'];
|
|
28
28
|
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'];
|
|
29
29
|
export const PROVEEDORES_VOZ = ['kokoro', 'elevenlabs', 'openai'];
|
|
@@ -109,7 +109,7 @@ export async function cargarProyecto(proyecto = process.cwd()) {
|
|
|
109
109
|
const estilosPropios = cfg.estilos ?? {};
|
|
110
110
|
const estilo = cfg.estilo ?? 'datos-curiosos';
|
|
111
111
|
const assetsDir = path.resolve(proyecto, cfg.assets ?? 'assets');
|
|
112
|
-
const ctx = {proyecto, marca, voces: cfg.voces ?? {}, musicas: cfg.musicas ?? {}, errores, estilo, estilosPropios, assetsDir, personajes: cfg.personajes ?? {}};
|
|
112
|
+
const ctx = {proyecto, marca, voces: cfg.voces ?? {}, musicas: cfg.musicas ?? {}, errores, estilo, estilosPropios, assetsDir, personajes: cfg.personajes ?? {}, componentes: Object.keys(cfg.componentes ?? {})};
|
|
113
113
|
if (!ESTILOS.includes(estilo) && !estilosPropios[estilo]) errores.push(`estilo "${estilo}" no existe. Opciones: ${[...ESTILOS, ...Object.keys(estilosPropios)].join(', ')}`);
|
|
114
114
|
const componentes = {};
|
|
115
115
|
for (const [nombre, ruta] of Object.entries(cfg.componentes ?? {})) {
|
|
@@ -149,7 +149,10 @@ export async function cargarProyecto(proyecto = process.cwd()) {
|
|
|
149
149
|
personajes: cfg.personajes ?? {},
|
|
150
150
|
componentes,
|
|
151
151
|
render: {
|
|
152
|
-
crf: r.crf ??
|
|
152
|
+
crf: r.crf ?? null, // null = según `calidad`
|
|
153
|
+
// alta = CRF 18, media = 21, ligera = 24. Sin decirlo: media en videos con `mundo` (la cámara siempre se mueve
|
|
154
|
+
// y a CRF 18 pesan ~6 Mbps), alta en los demás. Cada video puede pedir la suya con `calidad`.
|
|
155
|
+
calidad: r.calidad ?? null,
|
|
153
156
|
gl: process.env.MORFEO_GL || process.env.REMOTION_GL || r.gl || glPorDefecto(),
|
|
154
157
|
navegador: process.env.MORFEO_NAVEGADOR || r.navegador || null,
|
|
155
158
|
concurrencia: Number(process.env.MORFEO_CONCURRENCIA) || r.concurrencia || null, // null = Morfeo elige según la máquina y el modo (ver render.mjs)
|
|
@@ -198,19 +201,31 @@ function revisarArchivos(x, assetsDir, err, ruta = '') {
|
|
|
198
201
|
}
|
|
199
202
|
}
|
|
200
203
|
|
|
201
|
-
|
|
204
|
+
const esNum = (x) => typeof x === 'number' && Number.isFinite(x);
|
|
205
|
+
|
|
206
|
+
function validarCapas(capas, donde, err, comps) {
|
|
202
207
|
if (!Array.isArray(capas)) return err(`${donde}: \`capas\` debe ser una lista`);
|
|
203
208
|
capas.forEach((c, i) => {
|
|
204
209
|
const d = `${donde}, capa ${i + 1}`;
|
|
205
210
|
if (!c || typeof c !== 'object') return err(`${d} debe ser un objeto`);
|
|
206
211
|
if (!TIPOS_CAPA.includes(c.tipo)) return err(`${d}: tipo "${c.tipo}" no existe. Opciones: ${TIPOS_CAPA.join(', ')}`);
|
|
212
|
+
if (c.tipo === 'componente') {
|
|
213
|
+
if (!c.nombre) err(`${d}: falta \`nombre\` (el componente registrado en morfeo.config.mjs → componentes)`);
|
|
214
|
+
else if (comps && !comps.includes(c.nombre))
|
|
215
|
+
err(`${d}: el componente "${c.nombre}" no está en morfeo.config.mjs → componentes${comps.length ? ` (hay: ${comps.join(', ')})` : ''}`);
|
|
216
|
+
}
|
|
217
|
+
if (c.tipo === 'medida' && (!Array.isArray(c.de) || !Array.isArray(c.a))) err(`${d}: \`medida\` necesita \`de: [x, y]\` y \`a: [x, y]\``);
|
|
218
|
+
if (c.tipo === 'trazo' && (!Array.isArray(c.puntos) || c.puntos.length < 2)) err(`${d}: \`trazo\` necesita \`puntos\` con al menos 2 [x, y]`);
|
|
219
|
+
if (c.tipo === 'terminal' && !Array.isArray(c.lineas)) err(`${d}: \`terminal\` necesita \`lineas\` (lista de textos)`);
|
|
220
|
+
if (['nombre', 'accion', 'sello', 'fuente', 'seccion'].includes(c.tipo) && (c.texto == null || c.texto === '')) err(`${d}: falta \`texto\``);
|
|
221
|
+
if (c.tipo === 'dato' && c.valor == null) err(`${d}: falta \`valor\``);
|
|
207
222
|
if ((c.tipo === 'imagen' || c.tipo === 'video') && !c.src && !c.generar) err(`${d}: falta \`src\` (archivo en assets/) o \`generar\` (descripción para la IA)`);
|
|
208
223
|
if (c.tipo === 'recurso') {
|
|
209
224
|
if (!c.buscar) err(`${d}: falta \`buscar\` (qué objeto: 'tubería', 'casa', 'pizza'...)`);
|
|
210
225
|
if (c.estilo && !['2d', '3d', 'realista'].includes(c.estilo)) err(`${d}: estilo "${c.estilo}" no existe. Usa '2d', '3d' o 'realista'`);
|
|
211
226
|
}
|
|
212
|
-
if (c.capas) validarCapas(c.capas, d, err);
|
|
213
|
-
if (c.capa) validarCapas([c.capa], d, err);
|
|
227
|
+
if (c.capas) validarCapas(c.capas, d, err, comps);
|
|
228
|
+
if (c.capa) validarCapas([c.capa], d, err, comps);
|
|
214
229
|
});
|
|
215
230
|
}
|
|
216
231
|
|
|
@@ -294,7 +309,23 @@ function validarVideo(id, v, ctx) {
|
|
|
294
309
|
? `${n}: type "${s.type}" es del estilo "datos-curiosos"; en "${estilo}" usa: ${TIPOS_MOTOR.join(', ')}`
|
|
295
310
|
: `${n}: type "${s.type}" no existe. Opciones: ${permitidos.join(', ')}`,
|
|
296
311
|
);
|
|
297
|
-
if (s.capas != null) validarCapas(s.capas, `${n}`, err);
|
|
312
|
+
if (s.capas != null) validarCapas(s.capas, `${n}`, err, ctx.componentes);
|
|
313
|
+
if (s.rotulo != null && typeof s.rotulo !== 'string') err(`${n}: \`rotulo\` debe ser un texto corto ('¿CUÁNTO *SUBIÓ*?')`);
|
|
314
|
+
if (s.claves != null && (!Array.isArray(s.claves) || s.claves.some((k) => typeof k !== 'string'))) err(`${n}: \`claves\` debe ser una lista de palabras (['cerebro'])`);
|
|
315
|
+
if (s.toma != null) {
|
|
316
|
+
const tm = s.toma;
|
|
317
|
+
if (typeof tm !== 'object' || Array.isArray(tm)) err(`${n}: \`toma\` debe ser un objeto {x, y, zoom}`);
|
|
318
|
+
else {
|
|
319
|
+
for (const k of ['x', 'y', 'zoom', 'giro', 'duracion', 'deriva']) if (tm[k] != null && !esNum(tm[k])) err(`${n}: \`toma.${k}\` debe ser un número`);
|
|
320
|
+
if (tm.zoom != null && tm.zoom <= 0) err(`${n}: \`toma.zoom\` debe ser mayor que 0`);
|
|
321
|
+
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`);
|
|
322
|
+
}
|
|
323
|
+
if (!v.mundo?.length) err(`${n}: \`toma\` mueve la cámara del \`mundo\`, pero el video no tiene \`mundo\``);
|
|
324
|
+
}
|
|
325
|
+
if (s.type === 'cta' && s.pide != null) {
|
|
326
|
+
if (!['comenta', 'comparte'].includes(s.pide)) err(`${n}: \`pide\` debe ser 'comenta' o 'comparte'`);
|
|
327
|
+
if (s.pide === 'comenta' && s.palabra != null && typeof s.palabra !== 'string') err(`${n}: \`palabra\` debe ser un texto ('PASO')`);
|
|
328
|
+
}
|
|
298
329
|
const say = s.say ?? (s.type === 'cta' ? ctx.marca.cta : null);
|
|
299
330
|
if (!say || typeof say !== 'string') err(`${n}: falta \`say\` (lo que dice la voz${s.audio ? '; con `audio` sirve para los subtítulos' : ''})`);
|
|
300
331
|
for (const k of ['at', 'click']) if (s[k] != null && typeof s[k] !== 'string') err(`${n}: \`${k}\` debe ser una palabra del texto`);
|
|
@@ -312,8 +343,14 @@ function validarVideo(id, v, ctx) {
|
|
|
312
343
|
let voice = null;
|
|
313
344
|
if (v.voice != null) voice = normalizarVoz(v.voice, ctx, err);
|
|
314
345
|
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)');
|
|
315
|
-
if (v.hud != null) validarCapas(v.hud, 'hud', err);
|
|
316
|
-
if (v.mundo != null)
|
|
346
|
+
if (v.hud != null) validarCapas(v.hud, 'hud', err, ctx.componentes);
|
|
347
|
+
if (v.mundo != null) {
|
|
348
|
+
validarCapas(v.mundo, 'mundo', err, ctx.componentes);
|
|
349
|
+
if (clasico) err('`mundo` es del motor de capas: usa un estilo como ilustrado, 3d o mesa (no "datos-curiosos")');
|
|
350
|
+
}
|
|
351
|
+
if (v.serie != null && typeof v.serie !== 'string') err("`serie` debe ser un texto ('GYM CON CIENCIA · PARTE 5')");
|
|
352
|
+
if (v.calidad != null && !['alta', 'media', 'ligera'].includes(v.calidad)) err("`calidad` debe ser 'alta', 'media' o 'ligera'");
|
|
353
|
+
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'])");
|
|
317
354
|
// archivos de assets/ que piden las escenas y el HUD: mejor avisar ahora que a mitad del render
|
|
318
355
|
revisarArchivos({scenes, hud: v.hud, mundo: v.mundo, tema: v.tema}, ctx.assetsDir, err);
|
|
319
356
|
revisarPersonajes({scenes, hud: v.hud, mundo: v.mundo}, ctx.personajes, err);
|
package/lib/entorno.mjs
CHANGED
|
@@ -33,11 +33,31 @@ export const PY_MAX = [3, 13];
|
|
|
33
33
|
|
|
34
34
|
export const pythonVenv = () => (ES_WIN ? path.join(VENV, 'Scripts', 'python.exe') : path.join(VENV, 'bin', 'python'));
|
|
35
35
|
|
|
36
|
-
const
|
|
36
|
+
const sha = (s) => crypto.createHash('sha1').update(s).digest('hex').slice(0, 12);
|
|
37
|
+
// la huella ignora saltos de línea y espacios: el mismo requirements.txt llega con CRLF (git en Windows) o LF (npm),
|
|
38
|
+
// y antes eso hacía creer que faltaba `npx morfeo instalar` al cambiar entre una copia del repo y el paquete
|
|
39
|
+
const huellaRequisitos = () =>
|
|
40
|
+
sha(
|
|
41
|
+
fs
|
|
42
|
+
.readFileSync(REQUISITOS, 'utf8')
|
|
43
|
+
.split(/\r?\n/)
|
|
44
|
+
.map((l) => l.trim())
|
|
45
|
+
.filter((l) => l && !l.startsWith('#'))
|
|
46
|
+
.join('\n'),
|
|
47
|
+
);
|
|
48
|
+
const huellaVieja = () => [sha(fs.readFileSync(REQUISITOS)), sha(fs.readFileSync(REQUISITOS, 'utf8').replace(/\r\n/g, '\n')), sha(fs.readFileSync(REQUISITOS, 'utf8').replace(/\n/g, '\r\n'))];
|
|
37
49
|
const MARCA_VENV = () => path.join(VENV, '.morfeo-listo');
|
|
38
50
|
export const venvListo = () => {
|
|
39
51
|
try {
|
|
40
|
-
|
|
52
|
+
if (!fs.existsSync(pythonVenv())) return false;
|
|
53
|
+
const marca = fs.readFileSync(MARCA_VENV(), 'utf8').trim();
|
|
54
|
+
if (marca === huellaRequisitos()) return true;
|
|
55
|
+
// marcas de versiones anteriores (huella del archivo tal cual, con LF o CRLF): se aceptan y se actualizan
|
|
56
|
+
if (huellaVieja().includes(marca)) {
|
|
57
|
+
marcarVenvListo();
|
|
58
|
+
return true;
|
|
59
|
+
}
|
|
60
|
+
return false;
|
|
41
61
|
} catch {
|
|
42
62
|
return false;
|
|
43
63
|
}
|
package/lib/estructura.mjs
CHANGED
|
@@ -18,6 +18,12 @@
|
|
|
18
18
|
// - Series: una pastilla fija arriba ("TITO DESDE CERO · EP. 1", "GYM CON CIENCIA · PARTE 5") y contadores persistentes.
|
|
19
19
|
// - Cierre con una acción concreta, no solo "sígueme": elegir A o B en comentarios, "escribe PASO si entendiste",
|
|
20
20
|
// "mándaselo al que dice que...", cuenta regresiva para la próxima parte.
|
|
21
|
+
//
|
|
22
|
+
// Formato largo "explicador de maqueta" (2026-11, 4 videos de 102–132 s: castor, tinaco, buey, caseta de cobro):
|
|
23
|
+
// - Un solo diorama 3D low-poly con corte de tierra; 24–25 cortes por minuto (uno cada ~2,4 s) entre ángulos del
|
|
24
|
+
// mismo mundo. Una palabra a la vez al centro, rótulos con contorno, etiquetas con flecha pegadas a los objetos.
|
|
25
|
+
// - Gancho "Imagina que eres un castor…" o "¿Cómo se hace una caseta de cobro?"; cierre "¡Felicidades, eres…!" o un
|
|
26
|
+
// VS de "ya lo sabía / no lo sabía". Aquí 90–130 s está bien: el mundo y los cortes sostienen la atención.
|
|
21
27
|
|
|
22
28
|
/** Las reglas, en el orden en que se le explican a la IA y a quien escribe un guion. */
|
|
23
29
|
export const REGLAS = [
|
|
@@ -30,7 +36,7 @@ export const REGLAS = [
|
|
|
30
36
|
{id: 'escalada', regla: 'Que suba la apuesta', como: 'Cada escena más grave, más grande o más rara que la anterior; lo más fuerte cerca del final, no al principio.'},
|
|
31
37
|
{id: 'cierre', regla: 'Cierre con una acción concreta', como: 'Pide algo fácil y específico: elegir A o B en comentarios, escribir una palabra ("escribe PASO"), mandárselo a alguien ("mándaselo al que dice que…") o esperar la parte 2. "Sígueme" solo no basta.'},
|
|
32
38
|
{id: 'bucle', regla: 'Final que conecta con el inicio', como: 'La última frase puede regresar a la primera imagen o dejar una pregunta abierta, para que se vuelva a ver.'},
|
|
33
|
-
{id: 'duracion', regla: 'Entre 30 y 65 segundos', como: 'La mayoría de los de referencia dura ~1 minuto. Más de 75 s solo si es una serie
|
|
39
|
+
{id: 'duracion', regla: 'Entre 30 y 65 segundos (o 90–130 en maqueta)', como: 'La mayoría de los de referencia dura ~1 minuto. Más de 75 s solo si es una serie, una historia o un explicador de maqueta (estilo maqueta: 90–130 s con un corte de cámara cada 2–3 s dentro del mismo mundo).'},
|
|
34
40
|
];
|
|
35
41
|
|
|
36
42
|
/** Las reglas como texto para el prompt de la IA. */
|
|
@@ -38,7 +44,7 @@ export const REGLAS_TEXTO = `Estructura (medida en videos virales; síguela):
|
|
|
38
44
|
${REGLAS.map((r, i) => `${i + 1}. ${r.regla}: ${r.como}`).join('\n')}`;
|
|
39
45
|
|
|
40
46
|
const palabrasDe = (s) => String(s ?? '').trim().split(/\s+/).filter(Boolean);
|
|
41
|
-
const GANCHOS = /^(
|
|
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;
|
|
42
48
|
|
|
43
49
|
/**
|
|
44
50
|
* Revisa la estructura de un video y devuelve consejos (no bloquea el render).
|
|
@@ -54,6 +60,8 @@ export function revisarEstructura(v, d) {
|
|
|
54
60
|
if (!cuerpo.length) return out;
|
|
55
61
|
const primera = cuerpo[0];
|
|
56
62
|
const clasico = (v.estilo ?? 'datos-curiosos') === 'datos-curiosos';
|
|
63
|
+
// formato largo: explicador de maqueta (o `formato: 'largo'` en el video)
|
|
64
|
+
const largo = v.estilo === 'maqueta' || v.formato === 'largo';
|
|
57
65
|
|
|
58
66
|
// 1) gancho
|
|
59
67
|
const dicho = String(primera.say ?? '').trim();
|
|
@@ -67,7 +75,9 @@ export function revisarEstructura(v, d) {
|
|
|
67
75
|
const es = d?.escenas?.filter((e) => e.tipo !== 'cta') ?? [];
|
|
68
76
|
if (es.length) {
|
|
69
77
|
const dur = d.duracion;
|
|
70
|
-
if (dur >
|
|
78
|
+
if (largo && dur > 140) add('duracion', 'idea', `Dura ${Math.round(dur)} s: los explicadores de maqueta de referencia van de 100 a 130 s.`);
|
|
79
|
+
else if (largo && dur < 60) add('duracion', 'idea', `Dura ${Math.round(dur)} s: en maqueta los de referencia duran 100–130 s; hay espacio para otra pregunta y su respuesta.`);
|
|
80
|
+
else if (!largo && dur > 75) add('duracion', 'idea', `Dura ${Math.round(dur)} s: los de referencia rondan 60 s. Si no es una serie o una historia, recorta (o usa estilo maqueta para un explicador largo).`);
|
|
71
81
|
if (dur < 20) add('duracion', 'idea', `Dura ${Math.round(dur)} s: un poco más de desarrollo (una pregunta y su respuesta) suele retener mejor.`);
|
|
72
82
|
const e0 = es[0];
|
|
73
83
|
if (e0 && e0.habla?.[0] > 0.5) add('gancho', 'aviso', `La voz tarda ${e0.habla[0].toFixed(1)} s en empezar: debe arrancar de inmediato.`);
|
|
@@ -77,7 +87,8 @@ export function revisarEstructura(v, d) {
|
|
|
77
87
|
// algo que cambie a media escena: capas con `en`, cámara con rango, toma con movimiento, o un mundo vivo
|
|
78
88
|
const capas = s.capas ?? [];
|
|
79
89
|
const cambia = capas.some((c) => c.en != null && c.en !== 0 && c.en !== 'inicio') || Array.isArray(s.camara?.zoom) || s.toma?.a || capas.some((c) => c.mover || c.cambios);
|
|
80
|
-
if (
|
|
90
|
+
if (largo && voz > 4.5) add('ritmo', 'idea', `Escena ${i + 1}: ${voz.toFixed(1)} s en una sola toma; en maqueta la cámara corta cada 2–3 s. Pártela en dos tomas.`);
|
|
91
|
+
else if (voz > 6 && !cambia && !v.mundo?.length) add('ritmo', 'aviso', `Escena ${i + 1}: ${voz.toFixed(1)} s de voz sin que nada cambie. Pártela en dos o haz que algo entre a media frase.`);
|
|
81
92
|
else if (voz > 9) add('ritmo', 'idea', `Escena ${i + 1}: ${voz.toFixed(1)} s de voz en una sola escena; en los de referencia ninguna idea pasa de ~5 s.`);
|
|
82
93
|
});
|
|
83
94
|
}
|
package/lib/guion.mjs
CHANGED
|
@@ -160,7 +160,7 @@ export async function esquemaMotor() {
|
|
|
160
160
|
}
|
|
161
161
|
|
|
162
162
|
/** De la salida de la IA (motor de capas) al formato de escenas de morfeo. */
|
|
163
|
-
function aEscenaMotor(e, conImagenes) {
|
|
163
|
+
export function aEscenaMotor(e, conImagenes) {
|
|
164
164
|
const out = {type: e.type, say: e.say};
|
|
165
165
|
if (e.show && e.show !== e.say) out.show = e.show;
|
|
166
166
|
const si = (k, v) => v != null && v !== '' && (out[k] = v);
|
|
@@ -206,7 +206,7 @@ function aEscenaMotor(e, conImagenes) {
|
|
|
206
206
|
}
|
|
207
207
|
|
|
208
208
|
/** De la salida plana de la IA al formato de escenas de morfeo. */
|
|
209
|
-
function aEscena(e) {
|
|
209
|
+
export function aEscena(e) {
|
|
210
210
|
const c = (x) => COLORES[x] ?? x;
|
|
211
211
|
const out = {type: e.type, say: e.say};
|
|
212
212
|
if (e.show && e.show !== e.say) out.show = e.show;
|
package/lib/hoja.mjs
CHANGED
|
@@ -66,6 +66,9 @@ function leerPng(b, w, h) {
|
|
|
66
66
|
* @returns {string} ruta del PNG
|
|
67
67
|
*/
|
|
68
68
|
export function hoja({video, salida, cols = 5, filas = 3, desde = 0, hasta, ancho = 216}) {
|
|
69
|
+
// rutas absolutas: ffmpeg corre desde la carpeta de sus DLL, así que una ruta relativa no se encontraría
|
|
70
|
+
video = path.resolve(video);
|
|
71
|
+
if (salida) salida = path.resolve(salida);
|
|
69
72
|
if (!fs.existsSync(video)) throw new ErrorMorfeo(`No encuentro ${video}. Renderízalo primero: npx morfeo render`);
|
|
70
73
|
const {ffmpeg, ffprobe, cwd} = binarios();
|
|
71
74
|
const sonda = spawnSync(ffprobe, ['-v', 'error', '-select_streams', 'v:0', '-show_entries', 'stream=width,height:format=duration', '-of', 'csv=p=0', video], {encoding: 'utf8', cwd});
|