morfeo 0.3.0 → 0.3.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +1 -1
- 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 +23 -4
- package/index.d.ts +10 -3
- package/lib/config.mjs +48 -8
- package/lib/entorno.mjs +22 -2
- package/lib/estructura.mjs +1 -1
- 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 +211 -88
- package/lib/salida.mjs +84 -0
- package/lib/whisper.mjs +47 -3
- package/package.json +5 -4
- 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/escenarios2d.tsx +250 -0
- package/remotion/kit/index.ts +9 -2
- package/remotion/kit/mundo3d.tsx +251 -0
- package/remotion/kit/personas.tsx +439 -0
- package/remotion/kit/tres.tsx +11 -6
- package/remotion/motor/capas.tsx +27 -2
- package/remotion/motor/modelo3d.tsx +8 -3
package/README.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# Morfeo
|
|
2
2
|
|
|
3
|
-
> **Documentación: [
|
|
3
|
+
> **Documentación: [morfeo-docs-two.vercel.app](https://morfeo-docs-two.vercel.app/)** · Ejemplos: [github.com/UreckChan/morfeo](https://github.com/UreckChan/morfeo) · Versiones: [historial](https://morfeo-docs-two.vercel.app/historial)
|
|
4
4
|
|
|
5
5
|
Genera videos verticales animados (Shorts, TikTok, Reels) desde código: escribes las escenas y Morfeo agrega
|
|
6
6
|
la voz, la música, los efectos de sonido, los subtítulos, las animaciones y la miniatura.
|
|
@@ -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': {
|
|
@@ -124,7 +138,9 @@ async function main() {
|
|
|
124
138
|
const p = await cargarProyecto(proyecto);
|
|
125
139
|
const num = (v) => (v == null || v === true ? undefined : Number(v));
|
|
126
140
|
for (const id of pos) {
|
|
127
|
-
const
|
|
141
|
+
const {salidaDe, ordenarSalida} = await import('../lib/salida.mjs');
|
|
142
|
+
ordenarSalida(p);
|
|
143
|
+
const video = /\.(mp4|mov|webm)$/i.test(id) ? path.resolve(id) : salidaDe(p, id);
|
|
128
144
|
console.log(hoja({video, cols: num(op.cols), filas: num(op.filas), desde: num(op.desde), hasta: num(op.hasta)}));
|
|
129
145
|
}
|
|
130
146
|
return;
|
|
@@ -222,7 +238,7 @@ async function guion(pos, op, proyecto) {
|
|
|
222
238
|
console.log(`Revísalo y edítalo si quieres; luego: npx morfeo render ${id}`);
|
|
223
239
|
}
|
|
224
240
|
|
|
225
|
-
function nuevo(carpeta, op) {
|
|
241
|
+
async function nuevo(carpeta, op) {
|
|
226
242
|
if (!carpeta) throw new ErrorMorfeo('Dime el nombre de la carpeta: npx morfeo nuevo mi-canal');
|
|
227
243
|
const destino = path.resolve(carpeta);
|
|
228
244
|
if (fs.existsSync(destino) && fs.readdirSync(destino).length) throw new ErrorMorfeo(`La carpeta ${carpeta} ya existe y no está vacía.`);
|
|
@@ -234,6 +250,9 @@ function nuevo(carpeta, op) {
|
|
|
234
250
|
const nombre = path.basename(destino).toLowerCase().replace(/[^a-z0-9-_]/g, '-');
|
|
235
251
|
const pkg = path.join(destino, 'package.json');
|
|
236
252
|
fs.writeFileSync(pkg, fs.readFileSync(pkg, 'utf8').replace('__NOMBRE__', nombre).replace('__VERSION__', `^${VERSION}`));
|
|
253
|
+
// dos videos de ejemplo con mundo y la estructura completa (los mismos que `morfeo nuevo-video`)
|
|
254
|
+
const {nuevoVideo} = await import('../lib/nuevo-video.mjs');
|
|
255
|
+
for (const look of ['2d', '3d']) nuevoVideo({proyecto: destino, id: `ejemplo-${look}`, look});
|
|
237
256
|
console.log(`Proyecto creado en ${carpeta}/\n\nSiguientes pasos:\n cd ${carpeta}\n npm install\n npx morfeo instalar (solo la primera vez en esta computadora)\n npx morfeo render`);
|
|
238
257
|
}
|
|
239
258
|
|
package/index.d.ts
CHANGED
|
@@ -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
|
}
|
|
@@ -93,7 +94,7 @@ export type Capa = CapaBase &
|
|
|
93
94
|
| {tipo: 'checklist'; items: (string | {texto: string; en?: Momento; pendiente?: boolean})[]; titulo?: string; tam?: number; color?: string; cada?: number}
|
|
94
95
|
| {tipo: 'mapa'; ruta?: [number, number][]; distancia?: string; ancho?: number; color?: string; fondo?: string; inclinar?: number; duracion?: number}
|
|
95
96
|
| {tipo: 'cursor'; a: [number, number]; clic?: Momento; duracion?: number; tam?: number; color?: string}
|
|
96
|
-
| {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}
|
|
97
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}
|
|
98
99
|
| {tipo: 'chip'; texto: string; unidad?: string; tam?: number; color?: string; fondo?: string; borde?: string}
|
|
99
100
|
| {tipo: 'etiqueta'; texto: string; tam?: number; color?: string}
|
|
@@ -246,6 +247,11 @@ export interface Video {
|
|
|
246
247
|
mundo?: Capa[];
|
|
247
248
|
/** Pastilla fija arriba para series: 'GYM CON CIENCIA · PARTE 5'. */
|
|
248
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[];
|
|
249
255
|
/** 'largo' = explicador de 90–130 s (lo asume el estilo maqueta): cambia las reglas de `morfeo revisar`. */
|
|
250
256
|
formato?: 'corto' | 'largo';
|
|
251
257
|
/** Ajustes del estilo para este video (colores, fondo, subtítulos...). */
|
|
@@ -301,7 +307,8 @@ export interface Config {
|
|
|
301
307
|
/** Carpeta de los .mp4 (por defecto ./salida). */
|
|
302
308
|
salida?: string;
|
|
303
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. */
|
|
304
|
-
|
|
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};
|
|
305
312
|
}
|
|
306
313
|
|
|
307
314
|
export declare function defineConfig(config: Config): Config;
|
package/lib/config.mjs
CHANGED
|
@@ -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,12 +149,17 @@ 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)
|
|
156
159
|
miniatura: r.miniatura ?? true,
|
|
157
160
|
consejos: r.consejos ?? true,
|
|
161
|
+
// 'titulo' (por defecto): salida/<Título del video>.mp4, listo para subir · 'id': salida/<id>.mp4
|
|
162
|
+
nombreArchivo: r.nombreArchivo ?? 'titulo',
|
|
158
163
|
},
|
|
159
164
|
};
|
|
160
165
|
}
|
|
@@ -198,19 +203,31 @@ function revisarArchivos(x, assetsDir, err, ruta = '') {
|
|
|
198
203
|
}
|
|
199
204
|
}
|
|
200
205
|
|
|
201
|
-
|
|
206
|
+
const esNum = (x) => typeof x === 'number' && Number.isFinite(x);
|
|
207
|
+
|
|
208
|
+
function validarCapas(capas, donde, err, comps) {
|
|
202
209
|
if (!Array.isArray(capas)) return err(`${donde}: \`capas\` debe ser una lista`);
|
|
203
210
|
capas.forEach((c, i) => {
|
|
204
211
|
const d = `${donde}, capa ${i + 1}`;
|
|
205
212
|
if (!c || typeof c !== 'object') return err(`${d} debe ser un objeto`);
|
|
206
213
|
if (!TIPOS_CAPA.includes(c.tipo)) return err(`${d}: tipo "${c.tipo}" no existe. Opciones: ${TIPOS_CAPA.join(', ')}`);
|
|
214
|
+
if (c.tipo === 'componente') {
|
|
215
|
+
if (!c.nombre) err(`${d}: falta \`nombre\` (el componente registrado en morfeo.config.mjs → componentes)`);
|
|
216
|
+
else if (comps && !comps.includes(c.nombre))
|
|
217
|
+
err(`${d}: el componente "${c.nombre}" no está en morfeo.config.mjs → componentes${comps.length ? ` (hay: ${comps.join(', ')})` : ''}`);
|
|
218
|
+
}
|
|
219
|
+
if (c.tipo === 'medida' && (!Array.isArray(c.de) || !Array.isArray(c.a))) err(`${d}: \`medida\` necesita \`de: [x, y]\` y \`a: [x, y]\``);
|
|
220
|
+
if (c.tipo === 'trazo' && (!Array.isArray(c.puntos) || c.puntos.length < 2)) err(`${d}: \`trazo\` necesita \`puntos\` con al menos 2 [x, y]`);
|
|
221
|
+
if (c.tipo === 'terminal' && !Array.isArray(c.lineas)) err(`${d}: \`terminal\` necesita \`lineas\` (lista de textos)`);
|
|
222
|
+
if (['nombre', 'accion', 'sello', 'fuente', 'seccion'].includes(c.tipo) && (c.texto == null || c.texto === '')) err(`${d}: falta \`texto\``);
|
|
223
|
+
if (c.tipo === 'dato' && c.valor == null) err(`${d}: falta \`valor\``);
|
|
207
224
|
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
225
|
if (c.tipo === 'recurso') {
|
|
209
226
|
if (!c.buscar) err(`${d}: falta \`buscar\` (qué objeto: 'tubería', 'casa', 'pizza'...)`);
|
|
210
227
|
if (c.estilo && !['2d', '3d', 'realista'].includes(c.estilo)) err(`${d}: estilo "${c.estilo}" no existe. Usa '2d', '3d' o 'realista'`);
|
|
211
228
|
}
|
|
212
|
-
if (c.capas) validarCapas(c.capas, d, err);
|
|
213
|
-
if (c.capa) validarCapas([c.capa], d, err);
|
|
229
|
+
if (c.capas) validarCapas(c.capas, d, err, comps);
|
|
230
|
+
if (c.capa) validarCapas([c.capa], d, err, comps);
|
|
214
231
|
});
|
|
215
232
|
}
|
|
216
233
|
|
|
@@ -294,7 +311,23 @@ function validarVideo(id, v, ctx) {
|
|
|
294
311
|
? `${n}: type "${s.type}" es del estilo "datos-curiosos"; en "${estilo}" usa: ${TIPOS_MOTOR.join(', ')}`
|
|
295
312
|
: `${n}: type "${s.type}" no existe. Opciones: ${permitidos.join(', ')}`,
|
|
296
313
|
);
|
|
297
|
-
if (s.capas != null) validarCapas(s.capas, `${n}`, err);
|
|
314
|
+
if (s.capas != null) validarCapas(s.capas, `${n}`, err, ctx.componentes);
|
|
315
|
+
if (s.rotulo != null && typeof s.rotulo !== 'string') err(`${n}: \`rotulo\` debe ser un texto corto ('¿CUÁNTO *SUBIÓ*?')`);
|
|
316
|
+
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'])`);
|
|
317
|
+
if (s.toma != null) {
|
|
318
|
+
const tm = s.toma;
|
|
319
|
+
if (typeof tm !== 'object' || Array.isArray(tm)) err(`${n}: \`toma\` debe ser un objeto {x, y, zoom}`);
|
|
320
|
+
else {
|
|
321
|
+
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`);
|
|
322
|
+
if (tm.zoom != null && tm.zoom <= 0) err(`${n}: \`toma.zoom\` debe ser mayor que 0`);
|
|
323
|
+
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`);
|
|
324
|
+
}
|
|
325
|
+
if (!v.mundo?.length) err(`${n}: \`toma\` mueve la cámara del \`mundo\`, pero el video no tiene \`mundo\``);
|
|
326
|
+
}
|
|
327
|
+
if (s.type === 'cta' && s.pide != null) {
|
|
328
|
+
if (!['comenta', 'comparte'].includes(s.pide)) err(`${n}: \`pide\` debe ser 'comenta' o 'comparte'`);
|
|
329
|
+
if (s.pide === 'comenta' && s.palabra != null && typeof s.palabra !== 'string') err(`${n}: \`palabra\` debe ser un texto ('PASO')`);
|
|
330
|
+
}
|
|
298
331
|
const say = s.say ?? (s.type === 'cta' ? ctx.marca.cta : null);
|
|
299
332
|
if (!say || typeof say !== 'string') err(`${n}: falta \`say\` (lo que dice la voz${s.audio ? '; con `audio` sirve para los subtítulos' : ''})`);
|
|
300
333
|
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 +345,15 @@ function validarVideo(id, v, ctx) {
|
|
|
312
345
|
let voice = null;
|
|
313
346
|
if (v.voice != null) voice = normalizarVoz(v.voice, ctx, err);
|
|
314
347
|
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)
|
|
348
|
+
if (v.hud != null) validarCapas(v.hud, 'hud', err, ctx.componentes);
|
|
349
|
+
if (v.mundo != null) {
|
|
350
|
+
validarCapas(v.mundo, 'mundo', err, ctx.componentes);
|
|
351
|
+
if (clasico) err('`mundo` es del motor de capas: usa un estilo como ilustrado, 3d o mesa (no "datos-curiosos")');
|
|
352
|
+
}
|
|
353
|
+
if (v.serie != null && typeof v.serie !== 'string') err("`serie` debe ser un texto ('GYM CON CIENCIA · PARTE 5')");
|
|
354
|
+
if (v.title != null && typeof v.title !== 'string') err('`title` debe ser un texto (también es el nombre del archivo del video)');
|
|
355
|
+
if (v.calidad != null && !['alta', 'media', 'ligera'].includes(v.calidad)) err("`calidad` debe ser 'alta', 'media' o 'ligera'");
|
|
356
|
+
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
357
|
// archivos de assets/ que piden las escenas y el HUD: mejor avisar ahora que a mitad del render
|
|
318
358
|
revisarArchivos({scenes, hud: v.hud, mundo: v.mundo, tema: v.tema}, ctx.assetsDir, err);
|
|
319
359
|
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
|
@@ -106,7 +106,7 @@ export function revisarEstructura(v, d) {
|
|
|
106
106
|
// 5) habla de tú
|
|
107
107
|
const todo = cuerpo.map((s) => s.say ?? '').join(' ');
|
|
108
108
|
const n = palabrasDe(todo).length;
|
|
109
|
-
const tu = (todo.match(/\b(tú|tu|tus|te|ti|contigo|imagina|tienes|eres|puedes|sabes)\b/gi) ?? []).length;
|
|
109
|
+
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
110
|
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.');
|
|
111
111
|
return out;
|
|
112
112
|
}
|
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});
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
// `morfeo nuevo-video <id> --look 2d|3d`: crea guiones/<id>.mjs y escenas/Mundo<Id>.tsx desde una base que ya
|
|
2
|
+
// renderiza (carpeta bases/ del paquete) y registra el componente en morfeo.config.mjs → componentes.
|
|
3
|
+
import fs from 'node:fs';
|
|
4
|
+
import path from 'node:path';
|
|
5
|
+
import {ErrorMorfeo} from './config.mjs';
|
|
6
|
+
import {PAQUETE} from './entorno.mjs';
|
|
7
|
+
|
|
8
|
+
export const LOOKS = ['2d', '3d'];
|
|
9
|
+
|
|
10
|
+
/** "mi-video_2" -> "MundoMiVideo2" */
|
|
11
|
+
export const nombreComponente = (id) =>
|
|
12
|
+
'Mundo' +
|
|
13
|
+
String(id)
|
|
14
|
+
.normalize('NFD')
|
|
15
|
+
.replace(/[̀-ͯ]/g, '')
|
|
16
|
+
.split(/[^A-Za-z0-9]+/)
|
|
17
|
+
.filter(Boolean)
|
|
18
|
+
.map((p) => p[0].toUpperCase() + p.slice(1))
|
|
19
|
+
.join('');
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Agrega {Nombre: './escenas/Nombre.tsx'} a `componentes` del config (o crea la clave). Devuelve el texto nuevo,
|
|
23
|
+
* o null si ya estaba registrado.
|
|
24
|
+
*/
|
|
25
|
+
export function registrarEnConfig(texto, nombre, ruta) {
|
|
26
|
+
if (new RegExp(`\\b${nombre}\\s*:`).test(texto)) return null;
|
|
27
|
+
const entrada = `${nombre}: '${ruta}'`;
|
|
28
|
+
const m = texto.match(/componentes\s*:\s*\{/);
|
|
29
|
+
if (m) {
|
|
30
|
+
const i = m.index + m[0].length;
|
|
31
|
+
const vacio = /^\s*\}/.test(texto.slice(i));
|
|
32
|
+
return texto.slice(0, i) + (vacio ? entrada : `${entrada}, `) + texto.slice(i);
|
|
33
|
+
}
|
|
34
|
+
// sin `componentes`: se agrega antes del cierre de defineConfig({...}) / export default {...}
|
|
35
|
+
const fin = texto.lastIndexOf('}');
|
|
36
|
+
if (fin < 0) throw new ErrorMorfeo('No pude entender morfeo.config.mjs para registrar el componente; agrégalo a mano en `componentes`.');
|
|
37
|
+
const antes = texto.slice(0, fin).replace(/\s*$/, '');
|
|
38
|
+
const coma = antes.endsWith(',') || antes.endsWith('{') ? '' : ',';
|
|
39
|
+
return `${antes}${coma}\n\tcomponentes: {${entrada}},\n${texto.slice(fin)}`;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* @param {{proyecto: string, id: string, look?: string, carpetaGuiones?: string}} o
|
|
44
|
+
* @returns {{guion: string, escena: string, componente: string}}
|
|
45
|
+
*/
|
|
46
|
+
export function nuevoVideo({proyecto, id, look = '2d', carpetaGuiones}) {
|
|
47
|
+
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');
|
|
48
|
+
if (!LOOKS.includes(look)) throw new ErrorMorfeo(`--look debe ser ${LOOKS.join(' o ')}`);
|
|
49
|
+
const config = ['morfeo.config.mjs', 'morfeo.config.js'].map((f) => path.join(proyecto, f)).find((f) => fs.existsSync(f));
|
|
50
|
+
if (!config) throw new ErrorMorfeo(`No encontré morfeo.config.mjs en ${proyecto}. Crea un proyecto con: npx morfeo nuevo mi-canal`);
|
|
51
|
+
const componente = nombreComponente(id);
|
|
52
|
+
const dirGuiones = carpetaGuiones ?? path.join(proyecto, 'guiones');
|
|
53
|
+
const guion = path.join(dirGuiones, `${id}.mjs`);
|
|
54
|
+
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.`);
|
|
56
|
+
const base = path.join(PAQUETE, 'bases', look);
|
|
57
|
+
const poner = (s) => s.replaceAll('__ID__', id).replaceAll('__COMPONENTE__', componente);
|
|
58
|
+
fs.mkdirSync(dirGuiones, {recursive: true});
|
|
59
|
+
fs.mkdirSync(path.dirname(escena), {recursive: true});
|
|
60
|
+
fs.writeFileSync(guion, poner(fs.readFileSync(path.join(base, 'guion.mjs'), 'utf8')));
|
|
61
|
+
fs.writeFileSync(escena, poner(fs.readFileSync(path.join(base, 'Mundo.tsx'), 'utf8')));
|
|
62
|
+
const nuevo = registrarEnConfig(fs.readFileSync(config, 'utf8'), componente, `./escenas/${componente}.tsx`);
|
|
63
|
+
if (nuevo) fs.writeFileSync(config, nuevo);
|
|
64
|
+
return {guion, escena, componente};
|
|
65
|
+
}
|
package/lib/palabras.mjs
ADDED
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
// Momentos por palabra ("en: 'tubería'") que la voz nunca dice: en el video caen "a media frase" sin avisar.
|
|
2
|
+
// Esto los busca con las mismas reglas que `cuando()` de remotion/motor/util.ts y avisa antes de renderizar.
|
|
3
|
+
|
|
4
|
+
/** Igual que `norm` de remotion/motor/util.ts: sin acentos, minúsculas, solo letras y números. */
|
|
5
|
+
export const normalizar = (s) =>
|
|
6
|
+
String(s)
|
|
7
|
+
.normalize('NFD')
|
|
8
|
+
.replace(/[̀-ͯ]/g, '')
|
|
9
|
+
.toLowerCase()
|
|
10
|
+
.replace(/[^a-z0-9]/g, '');
|
|
11
|
+
|
|
12
|
+
/** "palabra+0.3" -> "palabra"; null si no es un momento por palabra (número, inicio, voz, fin). */
|
|
13
|
+
export function palabraDe(en) {
|
|
14
|
+
if (typeof en !== 'string') return null;
|
|
15
|
+
const m = en.match(/^(.*?)([+-]\d+(?:\.\d+)?)?$/);
|
|
16
|
+
const base = (m?.[1] ?? en).trim();
|
|
17
|
+
if (!base || ['inicio', 'voz', 'fin'].includes(base)) return null;
|
|
18
|
+
return base;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** ¿La voz dice una palabra que empiece así? (la misma regla que `cuando`: "orca" encuentra "orcas"). */
|
|
22
|
+
export const laDice = (palabra, dichas) => {
|
|
23
|
+
const n = normalizar(palabra);
|
|
24
|
+
return !n || dichas.some((w) => normalizar(w).startsWith(n));
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
const CLAVES = ['en', 'hasta', 'check', 'clic', 'rotuloEn'];
|
|
28
|
+
|
|
29
|
+
/** Recorre una capa (y lo que lleva dentro) juntando [ruta, palabra]. */
|
|
30
|
+
function momentosDe(x, ruta, out) {
|
|
31
|
+
if (Array.isArray(x)) return x.forEach((v, i) => momentosDe(v, `${ruta}[${i}]`, out));
|
|
32
|
+
if (!x || typeof x !== 'object') return;
|
|
33
|
+
for (const k of CLAVES) {
|
|
34
|
+
const p = palabraDe(x[k]);
|
|
35
|
+
if (p) out.push([`${ruta}.${k}`, p]);
|
|
36
|
+
}
|
|
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'])
|
|
39
|
+
if (x[k] != null) momentosDe(x[k], `${ruta}.${k}`, out);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Avisos de momentos por palabra que no están en lo que dice la voz.
|
|
44
|
+
* @param {object} v video validado (scenes, hud, mundo)
|
|
45
|
+
* @param {object} d datos.json del video (palabras por escena)
|
|
46
|
+
* @returns {string[]}
|
|
47
|
+
*/
|
|
48
|
+
export function revisarPalabras(v, d) {
|
|
49
|
+
if (!d?.escenas) return [];
|
|
50
|
+
const avisos = [];
|
|
51
|
+
const todas = d.escenas.flatMap((e) => (e.palabras ?? []).map(([w]) => w));
|
|
52
|
+
(v.scenes ?? []).forEach((s, i) => {
|
|
53
|
+
const e = d.escenas[i];
|
|
54
|
+
if (!s || !e) return;
|
|
55
|
+
const dichas = (e.palabras ?? []).map(([w]) => w);
|
|
56
|
+
if (!dichas.length) return;
|
|
57
|
+
const momentos = [];
|
|
58
|
+
// los de la escena misma (rotuloEn, at/click de las clásicas) y los de sus capas
|
|
59
|
+
for (const k of ['rotuloEn', 'at', 'click']) {
|
|
60
|
+
const p = palabraDe(s[k]);
|
|
61
|
+
if (p) momentos.push([k, p]);
|
|
62
|
+
}
|
|
63
|
+
momentosDe(s.capas ?? [], 'capas', momentos);
|
|
64
|
+
momentosDe(s.items ?? [], 'items', momentos);
|
|
65
|
+
momentosDe(s.opciones ?? [], 'opciones', momentos);
|
|
66
|
+
// con `show`, los momentos se buscan en las palabras del subtítulo (las cifras quedan como "1.560.000")
|
|
67
|
+
const texto = String(s.show ?? s.say ?? '').slice(0, 60);
|
|
68
|
+
for (const [ruta, p] of momentos)
|
|
69
|
+
if (!laDice(p, dichas))
|
|
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
|
+
});
|
|
72
|
+
// hud y mundo usan las palabras de todo el video
|
|
73
|
+
for (const [k, lista] of [['hud', v.hud], ['mundo', v.mundo]]) {
|
|
74
|
+
const momentos = [];
|
|
75
|
+
momentosDe(lista ?? [], k, momentos);
|
|
76
|
+
for (const [ruta, p] of momentos) if (!laDice(p, todas)) avisos.push(`${ruta}: "${p}" no aparece en ninguna escena`);
|
|
77
|
+
}
|
|
78
|
+
return avisos;
|
|
79
|
+
}
|