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 CHANGED
@@ -1,6 +1,6 @@
1
1
  # Morfeo
2
2
 
3
- > **Documentación: [ureckchan.github.io/morfeo](https://ureckchan.github.io/morfeo/)** · Ejemplos: [github.com/UreckChan/morfeo](https://github.com/UreckChan/morfeo)
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 video = /\.(mp4|mov|webm)$/i.test(id) ? path.resolve(id) : path.join(p.salida, `${id}.mp4`);
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
- sonido?: 'pop' | 'whoosh' | 'tick' | 'glitch' | 'ding';
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
- render?: {crf?: number; gl?: 'swangle' | 'angle' | 'egl' | 'swiftshader' | 'vulkan'; navegador?: string | null; concurrencia?: number | null; miniatura?: boolean};
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 ?? 18,
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
- function validarCapas(capas, donde, err) {
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) validarCapas(v.mundo, 'mundo', err);
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 huellaRequisitos = () => crypto.createHash('sha1').update(fs.readFileSync(REQUISITOS)).digest('hex').slice(0, 12);
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
- return fs.existsSync(pythonVenv()) && fs.readFileSync(MARCA_VENV(), 'utf8') === huellaRequisitos();
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
  }
@@ -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
+ }
@@ -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
+ }