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.
@@ -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
- 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
  }
@@ -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
- 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};
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 ?? 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)
@@ -198,19 +201,31 @@ function revisarArchivos(x, assetsDir, err, ruta = '') {
198
201
  }
199
202
  }
200
203
 
201
- function validarCapas(capas, donde, err) {
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) validarCapas(v.mundo, 'mundo', err);
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 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
  }
@@ -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 o una historia que lo sostiene.'},
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 = /^(¿|imagina|y si|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;
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 > 75) add('duracion', 'idea', `Dura ${Math.round(dur)} s: los de referencia rondan 60 s. Si no es una serie o una historia, recorta.`);
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 (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.`);
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});