morfeo 0.1.2 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/bin/morfeo.mjs CHANGED
@@ -20,6 +20,9 @@ Videos
20
20
  render [id...] Genera los videos que faltan (o los que indiques)
21
21
  --todos regenera todos
22
22
  --gpu usa la tarjeta de video (más rápido; si falla, quítalo)
23
+ revisar [id...] Revisa la estructura (gancho, ritmo, preguntas, cierre) con reglas de videos virales
24
+ hoja <id> Hoja de contacto (PNG con 15 cuadros) para revisar un video de un vistazo
25
+ --desde 8 --hasta 11 solo ese tramo (segundos) --cols 5 --filas 3
23
26
  studio Abre el editor en el navegador para ver y ajustar en vivo
24
27
  estilos Lista los estilos visuales disponibles
25
28
  recursos temas Diccionario de objetos 2D/3D por temática
@@ -48,7 +51,7 @@ function opciones(argv) {
48
51
  const [k, v] = a.slice(2).split('=');
49
52
  const clave = k.replace(/-([a-z])/g, (_, c) => c.toUpperCase());
50
53
  const sig = argv[i + 1];
51
- const conValor = ['proyecto', 'desde', 'id', 'voz', 'duracion', 'idioma', 'estilo', 'tono', 'variante', 'fuente', 'musica', 'titulo', 'modelo', 'puerto'];
54
+ const conValor = ['proyecto', 'desde', 'id', 'voz', 'duracion', 'idioma', 'estilo', 'tono', 'variante', 'fuente', 'musica', 'titulo', 'modelo', 'puerto', 'hasta', 'cols', 'filas'];
52
55
  if (v != null) op[clave] = v;
53
56
  else if (conValor.includes(clave) && sig != null && !sig.startsWith('--')) op[clave] = argv[++i];
54
57
  else op[clave] = true;
@@ -95,6 +98,37 @@ async function main() {
95
98
  await render({proyecto, ids: pos, todos: !!op.todos || pos[0] === 'todos'});
96
99
  return;
97
100
  }
101
+ case 'revisar': {
102
+ const {cargarProyecto} = await import('../lib/config.mjs');
103
+ const {revisarEstructura, consejosEnTexto, REGLAS} = await import('../lib/estructura.mjs');
104
+ const p = await cargarProyecto(proyecto);
105
+ const ids = pos.length ? pos : Object.keys(p.videos);
106
+ const fs = await import('node:fs');
107
+ let total = 0;
108
+ for (const id of ids) {
109
+ if (!p.videos[id]) throw new ErrorMorfeo(`No existe el video ${id}. Usa: npx morfeo lista`);
110
+ // tiempos reales si ya se preparó la voz (después de un render); si no, solo el texto
111
+ const f = path.join(p.trabajo, 'public', 'v', id, 'datos.json');
112
+ const d = fs.existsSync(f) ? JSON.parse(fs.readFileSync(f, 'utf8')) : null;
113
+ const c = revisarEstructura(p.videos[id], d);
114
+ total += c.length;
115
+ console.log(c.length ? `${id}${d ? '' : ' (sin tiempos: renderízalo para revisar el ritmo)'}\n${consejosEnTexto(c)}` : `${id} ✔`);
116
+ }
117
+ if (total) console.log(`\nReglas completas: ${REGLAS.map((r) => r.regla.toLowerCase()).join(' · ')}`);
118
+ return;
119
+ }
120
+ case 'hoja': {
121
+ const {cargarProyecto} = await import('../lib/config.mjs');
122
+ const {hoja} = await import('../lib/hoja.mjs');
123
+ if (!pos.length) throw new ErrorMorfeo('¿De cuál video? npx morfeo hoja <id>');
124
+ const p = await cargarProyecto(proyecto);
125
+ const num = (v) => (v == null || v === true ? undefined : Number(v));
126
+ for (const id of pos) {
127
+ const video = /\.(mp4|mov|webm)$/i.test(id) ? path.resolve(id) : path.join(p.salida, `${id}.mp4`);
128
+ console.log(hoja({video, cols: num(op.cols), filas: num(op.filas), desde: num(op.desde), hasta: num(op.hasta)}));
129
+ }
130
+ return;
131
+ }
98
132
  case 'studio': {
99
133
  const {studio} = await import('../lib/render.mjs');
100
134
  return studio({proyecto, puerto: op.puerto});
package/index.d.ts CHANGED
@@ -28,7 +28,7 @@ export type Objeto3D = 'corazon' | 'estrella' | 'rayo' | 'diamante' | 'trofeo' |
28
28
 
29
29
  /** Momento: segundos desde que empieza la escena, una palabra de `say` ("orcas"), "voz", "fin", o "palabra+0.3". */
30
30
  export type Momento = number | string;
31
- export type Entrada = 'escala' | 'rebote' | 'aparecer' | 'subir' | 'caer' | 'girar' | 'deslizar-izq' | 'deslizar-der' | 'deslizar-arriba' | 'deslizar-abajo' | 'ninguna';
31
+ export type Entrada = 'escala' | 'rebote' | 'aparecer' | 'subir' | 'caer' | 'girar' | 'deslizar-izq' | 'deslizar-der' | 'deslizar-arriba' | 'deslizar-abajo' | 'ninguna' | 'enfocar';
32
32
 
33
33
  interface CapaBase {
34
34
  /** Centro en el lienzo 1080×1920 (dentro de un grupo o teléfono: relativo a su centro). */
@@ -52,6 +52,14 @@ interface CapaBase {
52
52
  latir?: boolean;
53
53
  voltear?: boolean;
54
54
  desenfoque?: number;
55
+ /** Curva de la entrada de esta capa (por defecto, la del estilo). */
56
+ curva?: 'suave' | 'resorte' | 'seca';
57
+ duracionEntrada?: number;
58
+ /** Sigue a otra capa: `x`, `y` pasan a ser la distancia al centro de la capa con ese `id`. */
59
+ sigue?: string;
60
+ id?: string;
61
+ /** En un video con `mundo`: la capa vive en el mundo (coordenadas del mundo) y la cámara la mueve con él. */
62
+ enMundo?: boolean;
55
63
  sombra?: boolean | string;
56
64
  z?: number;
57
65
  /** Efecto de sonido al aparecer. */
@@ -64,6 +72,19 @@ export type Capa = CapaBase &
64
72
  (
65
73
  | {tipo: 'imagen'; src?: string; generar?: string; ancho?: number; alto?: number; redondo?: boolean; radio?: number; cambiarA?: {src: string; en?: Momento; efecto?: 'fundido' | 'barrido' | 'circulo'; duracion?: number}}
66
74
  | {tipo: 'video'; src: string; ancho?: number; alto?: number; radio?: number}
75
+ /** Anotaciones: señalan y explican lo que hay en pantalla. `medida` y `trazo` usan coordenadas del lienzo. */
76
+ | {tipo: 'dato'; valor: number | string; unidad?: string; de?: number; duracion?: number; decimales?: number; tam?: number; color?: string; fondo?: string}
77
+ | {tipo: 'medida'; de: [number, number]; a: [number, number]; texto?: string; unidad?: string; tam?: number; color?: string; grosor?: number; tope?: number; separacion?: number; duracion?: number}
78
+ | {tipo: 'trazo'; puntos: [number, number][]; flecha?: boolean; punteado?: boolean | number; color?: string; grosor?: number; duracion?: number; velocidad?: number}
79
+ | {tipo: 'contorno'; forma?: 'rect' | 'circulo' | 'aro'; ancho?: number; alto?: number; radio?: number; color?: string; grosor?: number; punteado?: boolean; relleno?: string; velocidad?: number; duracion?: number}
80
+ | {tipo: 'fuente'; texto: string; etiqueta?: string; tam?: number; color?: string; colorTexto?: string}
81
+ | {tipo: 'seccion'; texto: string; tam?: number; color?: string}
82
+ | {tipo: 'insignia'; texto: string | number; tam?: number; fondo?: string; colorTexto?: string; inclinar?: number}
83
+ | {tipo: 'sello'; texto: string; tam?: number; color?: string; inclinar?: number}
84
+ | {tipo: 'terminal'; lineas: (string | {texto: string; en?: Momento; salida?: boolean; color?: string})[]; titulo?: string; ancho?: number; tam?: number; fondo?: string; velocidad?: number}
85
+ | {tipo: 'nombre'; texto: string; sub?: string; tam?: number; fondo?: string; colorTexto?: string; borde?: string}
86
+ | {tipo: 'accion'; texto: string; tam?: number; color?: string; fondo?: string; colorTexto?: string}
87
+ | {tipo: 'cursor'; a: [number, number]; clic?: Momento; duracion?: number; tam?: number; color?: string}
67
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}
68
89
  | {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}
69
90
  | {tipo: 'chip'; texto: string; unidad?: string; tam?: number; color?: string; fondo?: string; borde?: string}
@@ -77,7 +98,7 @@ export type Capa = CapaBase &
77
98
  | {tipo: 'marca'; tam?: number}
78
99
  | {tipo: 'particulas'; particulas?: 'burbujas' | 'polvo' | 'estrellas' | 'nieve' | 'confeti'; cantidad?: number; color?: string}
79
100
  | {tipo: 'grupo'; capas?: Capa[]; capa?: Capa; repetir?: number; disposicion?: 'circulo' | 'fila' | 'columna' | 'cuadricula' | 'pila' | 'aleatorio'; radio?: number; espacio?: number; espacioV?: number; columnas?: number; orientar?: boolean | number; retraso?: number; area?: [number, number]; girarAzar?: number; desorden?: number}
80
- | {tipo: 'modelo'; src?: string; forma?: 'moneda' | 'billete' | 'cubo' | 'esfera' | 'lingote' | Objeto3D; color?: string; metal?: number; rugosidad?: number; ancho?: number; alto?: number; tamano?: number; rotacion?: [number, number, number]; girar3d?: [number, number, number]; cantidad?: number; crecer?: boolean; duracion?: number; distancia?: number; camaraY?: number; luz?: number; reflejos?: number; /** tuberia */ puntos?: [number, number, number][]; radio?: number; material?: 'pvc' | 'cobre' | 'acero' | 'vidrio'; agua?: boolean; velocidad?: number; colorAgua?: string; gotas?: number; /** muro */ columnas?: number; filas?: number; colorLadrillo?: string; colorMezcla?: string; /** viga */ largo?: number; vertical?: boolean; /** escalera */ escalones?: number; /** suelo */ capas?: [string, number][]}
101
+ | {tipo: 'modelo'; src?: string; forma?: 'moneda' | 'billete' | 'cubo' | 'esfera' | 'lingote' | Objeto3D; color?: string; metal?: number; rugosidad?: number; ancho?: number; alto?: number; tamano?: number; rotacion?: [number, number, number]; girar3d?: [number, number, number]; cantidad?: number; crecer?: boolean; duracion?: number; distancia?: number; camaraY?: number; luz?: number; reflejos?: number; /** levanta hacia la cámara los modelos planos acostados (por defecto en recursos) */ levantar?: boolean; /** tuberia */ puntos?: [number, number, number][]; radio?: number; material?: 'pvc' | 'cobre' | 'acero' | 'vidrio'; agua?: boolean; velocidad?: number; colorAgua?: string; gotas?: number; /** muro */ columnas?: number; filas?: number; colorLadrillo?: string; colorMezcla?: string; /** viga */ largo?: number; vertical?: boolean; /** escalera */ escalones?: number; /** suelo */ capas?: [string, number][]}
81
102
  | {tipo: 'componente'; nombre: string; props?: Record<string, unknown>}
82
103
  | {tipo: 'personaje'; nombre: string; pose?: string; poses?: {en: Momento; pose: string}[]; hablar?: boolean; ancho?: number}
83
104
  | {tipo: 'escena3d'; src: string; camara?: {en?: Momento; pos: [number, number, number]; mirar?: [number, number, number]; fov?: number}[]; animacion?: boolean | string | string[]; velocidad?: number; mostrar?: {nodo: string; en: Momento}[]; ocultar?: {nodo: string; en: Momento}[]; fondo?: string | null; niebla?: [number, number]; luz?: number; luzAmbiente?: number; sol?: [number, number, number]; reflejos?: number; sombras?: boolean; ancho?: number; alto?: number}
@@ -90,7 +111,7 @@ export type Capa = CapaBase &
90
111
  | {tipo: 'onda'; barras?: number; ancho?: number; alto?: number; color?: string; siempre?: boolean}
91
112
  | {tipo: 'globo'; texto: string; cola?: 'abajo' | 'arriba' | 'izq' | 'der' | 'ninguna'; fondo?: string; color?: string; borde?: string; tam?: number; maxAncho?: number}
92
113
  | {tipo: 'seguir'; usuario?: string; texto?: string; pulsar?: number}
93
- | {tipo: 'recurso'; buscar: string; estilo?: '2d' | '3d' | 'realista'; variante?: 'color' | 'clay' | 'glass' | 'kawaii' | 'retro' | 'pastel' | 'sticker' | 'skeuo' | 'plush' | 'anime' | 'luxe' | 'chrome' | 'liquid' | 'bauhaus' | 'brutal' | 'gothic' | 'linea'; fuente?: 'kenney' | 'polyhaven' | 'iconify' | 'polypizza'; indice?: number; color?: string; ancho?: number; alto?: number; girar3d?: [number, number, number]; rotacion?: [number, number, number]}
114
+ | {tipo: 'recurso'; buscar: string; estilo?: '2d' | '3d' | 'realista'; variante?: 'color' | 'clay' | 'glass' | 'kawaii' | 'retro' | 'pastel' | 'sticker' | 'skeuo' | 'plush' | 'anime' | 'luxe' | 'chrome' | 'liquid' | 'bauhaus' | 'brutal' | 'gothic' | 'linea' | 'plano'; fuente?: 'kenney' | 'polyhaven' | 'iconify' | 'polypizza'; indice?: number; color?: string; ancho?: number; alto?: number; girar3d?: [number, number, number]; rotacion?: [number, number, number]}
94
115
  | {tipo: 'escenario'; nombre: Escenario; parte?: 'fondo' | 'frente'; [opcion: string]: unknown}
95
116
  | {tipo: 'ventana'; diametro?: number; src?: string; capas?: Capa[]; borde?: 'metal' | 'ninguno' | (string & {}); grosor?: number; remaches?: number | false; zoom?: number | [number, number]; fondo?: string}
96
117
  );
@@ -105,12 +126,26 @@ export type FondoEscena =
105
126
  /** Escenarios ilustrados incluidos (animados). bosque: paneo, luna {cara, cantar, bajar, a}; cuarto: dia, parte; tunel: llegar; pasillo: golpe. */
106
127
  export type Escenario = 'bosque' | 'cuarto' | 'departamento' | 'tunel' | 'pasillo';
107
128
 
129
+ export type Transicion = 'corte' | 'fundido' | 'deslizar' | 'zoom' | 'barrido' | 'empuje' | 'mascara' | 'destello';
130
+
108
131
  interface EscenaMotorBase extends EscenaBase {
109
132
  capas?: Capa[];
110
133
  fondo?: FondoEscena;
111
134
  /** Cámara: número fijo o [inicio, fin] a lo largo de la escena. */
112
135
  camara?: {zoom?: number | [number, number]; x?: number | [number, number]; y?: number | [number, number]; en?: number; duracion?: number; origen?: [number, number]; temblor?: {fuerza?: number; duracion?: number}};
113
- transicion?: 'corte' | 'fundido' | 'deslizar' | 'zoom';
136
+ transicion?: Transicion;
137
+ /**
138
+ * En un video con `mundo`: a dónde mira la cámara en esta escena. `x`, `y` = punto del mundo que queda al centro.
139
+ * La cámara viaja desde donde terminó la escena anterior (`duracion` s; `corte: true` salta) y luego deriva
140
+ * despacio (`deriva`: cuánto acerca, 0.05 por defecto) o se mueve hasta `a`. Sin `toma` conserva la anterior.
141
+ */
142
+ toma?: {x?: number; y?: number; zoom?: number; giro?: number; duracion?: number; corte?: boolean; deriva?: number; a?: {x?: number; y?: number; zoom?: number; giro?: number}};
143
+ /** Rótulo grande y corto que resume el momento (segundo nivel de texto): '¿CUÁNTO *SUBIÓ*?'. `rotuloEn`: palabra o segundos. */
144
+ rotulo?: string;
145
+ rotuloEn?: Momento;
146
+ rotuloTam?: number;
147
+ /** Palabras clave de la escena para los subtítulos (la que lleva caja o color de acento en cada frase). */
148
+ claves?: string[];
114
149
  /** Ajustes del tema solo para esta escena. */
115
150
  tema?: Partial<Tema>;
116
151
  }
@@ -124,7 +159,7 @@ export type EscenaMotor =
124
159
  | (EscenaMotorBase & {type: 'telefono'; src?: string; capasPantalla?: Capa[]; texto?: string; ancho?: number; y?: number})
125
160
  | (EscenaMotorBase & {type: 'comparar'; a: string | {texto: string; imagen?: string; color?: string}; b: string | {texto: string; imagen?: string; color?: string}; at?: string})
126
161
  | (EscenaMotorBase & {type: 'eleccion'; pregunta?: string; opciones: (string | {texto: string; sub?: string; objeto?: Objeto3D; emoji?: string; icono?: Icono; imagen?: string; color?: string; fondo?: string; en?: Momento})[]; formato?: 'cartas' | 'botones'; comenta?: string | false; enComenta?: Momento; y?: number})
127
- | (Partial<EscenaMotorBase> & {type: 'cta'});
162
+ | (Partial<EscenaMotorBase> & {type: 'cta'; /** Pide una acción concreta en vez de solo seguir. */ pide?: 'comenta' | 'comparte'; /** comenta: la palabra a escribir. */ palabra?: string; /** comparte: a quién se lo mandan. */ a?: string; texto?: string; sub?: string});
128
163
 
129
164
  export interface Tema {
130
165
  fondo: FondoEscena;
@@ -136,8 +171,17 @@ export interface Tema {
136
171
  colores: {texto: string; acento: string; acento2: string; suave: string; tarjeta: string; tarjetaTexto: string; borde: string};
137
172
  fuentes: {titulo: string; texto: string; mono: string};
138
173
  tituloMayusculas: boolean;
139
- subtitulos: {tipo: 'karaoke' | 'pop' | 'bloque' | 'palabra' | 'ninguno'; y: number; tam: number; color: string; pendiente: string; resalte: string; maxPalabras: number; mayusculas?: boolean; contorno?: number; fondo?: string};
140
- transicion: 'corte' | 'fundido' | 'deslizar' | 'zoom';
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};
175
+ transicion: Transicion;
176
+ /** Ritmo de las animaciones: duraciones (s) y curva de las entradas. */
177
+ movimiento?: {entrada?: number; salida?: number; transicion?: number; curva?: 'suave' | 'resorte' | 'seca'};
178
+ /** Acabado sobre todo el cuadro: tinte, cono de luz y desenfoque de bordes. */
179
+ post?: {
180
+ tinte?: {color: string; fuerza?: number; modo?: 'multiply' | 'screen' | 'overlay' | 'soft-light'};
181
+ luz?: {x?: number; y?: number; ancho?: number; largo?: number; color?: string; fuerza?: number; angulo?: number};
182
+ desenfoqueBordes?: number;
183
+ vinetaColor?: string;
184
+ };
141
185
  marca: 'esquina' | 'arriba' | 'ninguna';
142
186
  zoomLento: number;
143
187
  }
@@ -181,6 +225,14 @@ export interface Video {
181
225
  estilo?: Estilo;
182
226
  /** Capas fijas encima de todo el video (día, barras, hora...); se actualizan con `cambios`. */
183
227
  hud?: Capa[];
228
+ /**
229
+ * Mundo del video: capas (normalmente un `componente` tuyo más grande que el cuadro) que duran todo el video.
230
+ * La cámara las recorre con la `toma` de cada escena, en lugar de cambiar de fondo en cada una.
231
+ * El componente recibe además `escena` (1, 2, 3...), `tEscena`, `inicios` y `camara` (para paralaje).
232
+ */
233
+ mundo?: Capa[];
234
+ /** Pastilla fija arriba para series: 'GYM CON CIENCIA · PARTE 5'. */
235
+ serie?: string;
184
236
  /** Ajustes del estilo para este video (colores, fondo, subtítulos...). */
185
237
  tema?: Partial<Tema> & Record<string, unknown>;
186
238
  /** Título para publicar. */
package/lib/config.mjs CHANGED
@@ -23,7 +23,7 @@ export const TIPOS_CLASICOS = ['title', 'year', 'text', 'icon', 'compare', 'card
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
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'];
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'];
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'];
@@ -32,11 +32,12 @@ export const MUSICAS = ['lofi', 'lofi2', 'chillpop', 'synth', 'house', 'ballad',
32
32
  export class ErrorMorfeo extends Error {}
33
33
 
34
34
  /**
35
- * WebGL por defecto: en Mac con Apple Silicon la tarjeta de video (angle) es estable y ~4 veces más rápida;
36
- * en lo demás, por software (swangle), que funciona en cualquier computadora. Si angle falla, el render
37
- * reintenta solo con swangle.
35
+ * WebGL por defecto: en Mac con Apple Silicon y en Windows la tarjeta de video (angle) es ~4 veces más rápida
36
+ * (Windows con RTX 3060: 23 s vs 91 s); en lo demás, por software (swangle), que funciona en cualquier
37
+ * computadora. Si angle falla, el render reintenta solo con swangle.
38
38
  */
39
- export const glPorDefecto = () => (process.platform === 'darwin' && process.arch === 'arm64' ? 'angle' : 'swangle');
39
+ export const glPorDefecto = () =>
40
+ (process.platform === 'darwin' && process.arch === 'arm64') || process.platform === 'win32' ? 'angle' : 'swangle';
40
41
 
41
42
  /** Una carpeta de guiones: cada <id>.json o <id>.mjs es un video. */
42
43
  async function leerCarpeta(dir) {
@@ -151,8 +152,9 @@ export async function cargarProyecto(proyecto = process.cwd()) {
151
152
  crf: r.crf ?? 18,
152
153
  gl: process.env.MORFEO_GL || process.env.REMOTION_GL || r.gl || glPorDefecto(),
153
154
  navegador: process.env.MORFEO_NAVEGADOR || r.navegador || null,
154
- concurrencia: r.concurrencia ?? null, // null = Morfeo elige según la máquina y el modo (ver render.mjs)
155
+ concurrencia: Number(process.env.MORFEO_CONCURRENCIA) || r.concurrencia || null, // null = Morfeo elige según la máquina y el modo (ver render.mjs)
155
156
  miniatura: r.miniatura ?? true,
157
+ consejos: r.consejos ?? true,
156
158
  },
157
159
  };
158
160
  }
@@ -311,9 +313,10 @@ function validarVideo(id, v, ctx) {
311
313
  if (v.voice != null) voice = normalizarVoz(v.voice, ctx, err);
312
314
  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)');
313
315
  if (v.hud != null) validarCapas(v.hud, 'hud', err);
316
+ if (v.mundo != null) validarCapas(v.mundo, 'mundo', err);
314
317
  // archivos de assets/ que piden las escenas y el HUD: mejor avisar ahora que a mitad del render
315
- revisarArchivos({scenes, hud: v.hud, tema: v.tema}, ctx.assetsDir, err);
316
- revisarPersonajes({scenes, hud: v.hud}, ctx.personajes, err);
318
+ revisarArchivos({scenes, hud: v.hud, mundo: v.mundo, tema: v.tema}, ctx.assetsDir, err);
319
+ revisarPersonajes({scenes, hud: v.hud, mundo: v.mundo}, ctx.personajes, err);
317
320
  revisarEscenarios({scenes, hud: v.hud, tema: v.tema}, err);
318
321
  return {...v, estilo, voice, music: normalizarMusica(v.music, ctx, err), scenes};
319
322
  }
@@ -19,7 +19,7 @@ export const TEMAS = {
19
19
  casa: ['house', 'home'], cuarto: ['bedroom', 'room'], cama: ['bed'], sillon: ['sofa', 'couch', 'armchair'], sofa: ['sofa', 'couch'],
20
20
  silla: ['chair'], mesa: ['table'], escritorio: ['desk'], lampara: ['lamp'], librero: ['bookcase', 'shelf'], refrigerador: ['fridge', 'refrigerator'],
21
21
  estufa: ['stove', 'oven'], horno: ['oven'], lavabo: ['sink', 'bathroom sink'], fregadero: ['kitchen sink', 'sink'], inodoro: ['toilet'], banera: ['bathtub'],
22
- regadera: ['shower'], espejo: ['mirror'], puerta: ['door'], ventana: ['window'], televisor: ['television', 'tv'], planta: ['plant', 'potted plant'],
22
+ regadera: ['shower'], espejo: ['mirror'], puerta: ['door'], ventana: ['window'], televisor: ['television', 'tv', 'monitor'], television: ['television', 'tv', 'monitor'], tele: ['television', 'tv', 'monitor'], tv: ['tv', 'television', 'monitor'], planta: ['plant', 'potted plant'],
23
23
  alfombra: ['rug'], ropero: ['wardrobe', 'closet'], buro: ['nightstand'], cajon: ['drawer', 'cabinet'], lavadora: ['washer', 'washing machine'], microondas: ['microwave'], licuadora: ['blender'], ventilador: ['fan'], almohada: ['pillow'], cortina: ['curtain'], reloj: ['clock'], llave: ['key'], candado: ['padlock', 'lock'], cocina: ['kitchen'], bano: ['bathroom'],
24
24
  },
25
25
  construccion: {
@@ -69,7 +69,8 @@ export const TEMAS = {
69
69
  tecnologia: {
70
70
  computadora: ['computer', 'laptop'], laptop: ['laptop'], celular: ['phone', 'smartphone'], telefono: ['phone'], robot: ['robot'], chip: ['chip', 'microchip'],
71
71
  bateria: ['battery'], enchufe: ['plug', 'socket'], foco: ['light bulb', 'bulb'], camara: ['camera'], audifonos: ['headphones'], microfono: ['microphone'],
72
- control: ['gamepad', 'controller'], consola: ['console', 'game console'], antena: ['antenna'], satelite: ['satellite'], dron: ['drone'], servidor: ['server'],
72
+ control: ['gamepad', 'controller'], consola: ['game console', 'console', 'gamepad'], consola_portatil: ['handheld console', 'nintendo switch', 'tablet', 'gamepad'],
73
+ tablet: ['tablet'], monitor: ['monitor', 'screen'], pantalla: ['screen', 'monitor'], disco: ['disc', 'cd', 'compact disc'], cd: ['disc', 'cd', 'compact disc'], dvd: ['disc', 'dvd'], memoria_usb: ['usb', 'flash drive'], antena: ['antenna'], satelite: ['satellite'], dron: ['drone'], servidor: ['server'],
73
74
  wifi: ['wifi'], nube_de_datos: ['cloud computing', 'cloud'], codigo: ['code'], teclado: ['keyboard'], raton_pc: ['computer mouse'],
74
75
  },
75
76
  espacio: {
@@ -97,6 +98,8 @@ export const TEMAS = {
97
98
  juegos: {
98
99
  videojuego: ['video game', 'gamepad'], dado: ['dice'], carta: ['playing card', 'card'], ajedrez: ['chess'], moneda_juego: ['coin'], corona: ['crown'], espada: ['sword'],
99
100
  escudo: ['shield'], cofre_juego: ['chest'], gema: ['gem', 'crystal'], pocion: ['potion'], bandera: ['flag'], meta: ['finish line', 'flag'],
101
+ cartucho: ['game cartridge', 'cartridge', 'sd card', 'floppy disk'], joystick: ['joystick', 'gamepad'], arcade: ['arcade machine', 'arcade', 'gamepad'], jefe_final: ['monster', 'dragon', 'skull'],
102
+ vida: ['heart', 'life'], nivel: ['level', 'block', 'platform'], bloque: ['block', 'cube', 'brick'], llave_juego: ['key'], hongo: ['mushroom'], estrella_juego: ['star'],
100
103
  },
101
104
  fiestas: {
102
105
  regalo: ['gift', 'present'], globo: ['balloon'], pastel_cumpleanos: ['birthday cake', 'cake'], arbol_navidad: ['christmas tree'], nieve: ['snow', 'snowman'],
@@ -0,0 +1,104 @@
1
+ // Estructura base de un video corto que retiene: reglas medidas en 19 videos de referencia (virales y bien hechos),
2
+ // una revisión automática de cada guion y el texto que se le da a la IA cuando escribe guiones.
3
+ //
4
+ // Mediciones (2026-11, 19 videos de 38 a 179 s, la mayoría ~55–65 s):
5
+ // - Todos arrancan DENTRO de la escena: el sujeto ya está en pantalla en el cuadro 1 y la voz empieza de inmediato.
6
+ // Ninguno abre con un título, un logo o una presentación.
7
+ // - Primeras palabras = gancho en segunda persona o con una cifra imposible: "Imagina que te despierta una alerta
8
+ // nuclear", "¿Y si tu nariz creciera cada vez que respiras?", "Si tu pareja te dice que ya no siente lo mismo",
9
+ // "A diez abuelitos de noventa los pusieron a levantar pesas", "Este es Alberto. Pero en el pueblo todos le...".
10
+ // - Ritmo: los de cortes rápidos cambian de plano cada 2,5–5 s (11–22 cortes por minuto; uno llega a 1,5 s).
11
+ // Los de mundo continuo casi no cortan (1–5 por minuto) pero la cámara nunca se queda quieta y entra algo nuevo
12
+ // (etiqueta, dato, personaje) cada 2–4 s.
13
+ // - Texto en dos niveles: subtítulo chico (lo que se dice) + un rótulo grande con la idea del momento
14
+ // ("¿CUÁNTO SUBIÓ?", "¡CASI EL TRIPLE!", "SALIR DE CASA", "DÍA 21").
15
+ // - Preguntas antes de cada revelación ("¿Para qué?", "¿Cuánto subió?", "¿...o músculo?") y la respuesta con un número
16
+ // que cuenta o un golpe visual.
17
+ // - Datos y nombres pegados a lo que señalan: "DOÑA ROSA · la mamá, 48 años", "▶ Te despiertas", "EL CABLE", "57 toneladas".
18
+ // - Series: una pastilla fija arriba ("TITO DESDE CERO · EP. 1", "GYM CON CIENCIA · PARTE 5") y contadores persistentes.
19
+ // - Cierre con una acción concreta, no solo "sígueme": elegir A o B en comentarios, "escribe PASO si entendiste",
20
+ // "mándaselo al que dice que...", cuenta regresiva para la próxima parte.
21
+
22
+ /** Las reglas, en el orden en que se le explican a la IA y a quien escribe un guion. */
23
+ export const REGLAS = [
24
+ {id: 'gancho', regla: 'Gancho en los primeros 2 segundos', como: 'La primera frase pone al espectador dentro ("Imagina que…", "¿Y si tu…?", "Si tu…") o suelta una cifra o situación imposible. El sujeto ya está en pantalla desde el cuadro 1. Nada de títulos, logos ni "hoy te voy a contar".'},
25
+ {id: 'tu', regla: 'Habla de tú', como: 'Segunda persona y presente: "te despiertas", "abres la puerta". Si es una historia, un nombre propio en la primera frase ("Este es Alberto").'},
26
+ {id: 'ritmo', regla: 'Algo nuevo cada 2–4 segundos', como: 'Escenas de 2 a 5 s de voz (una idea por escena). Si la escena dura más, que algo entre a media frase (un dato, una etiqueta, un movimiento de cámara).'},
27
+ {id: 'pregunta', regla: 'Pregunta antes de revelar', como: 'Antes de cada dato fuerte, la pregunta que el espectador se haría ("¿Cuánto subió?"); la respuesta llega en la escena siguiente con un número que cuenta o un golpe visual.'},
28
+ {id: 'rotulo', regla: 'Texto en dos niveles', como: 'El subtítulo dice lo que se oye; un rótulo grande y corto (2–4 palabras) resume el momento: "¿CUÁNTO SUBIÓ?", "SALIR DE CASA", "DÍA 21".'},
29
+ {id: 'etiquetas', regla: 'Datos pegados a las cosas', como: 'Nombres y cifras junto a lo que señalan (nombre de personaje con su rol, "▶ acción", "57 toneladas" junto a la ballena), no en un letrero aparte.'},
30
+ {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
+ {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
+ {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.'},
34
+ ];
35
+
36
+ /** Las reglas como texto para el prompt de la IA. */
37
+ export const REGLAS_TEXTO = `Estructura (medida en videos virales; síguela):
38
+ ${REGLAS.map((r, i) => `${i + 1}. ${r.regla}: ${r.como}`).join('\n')}`;
39
+
40
+ 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;
42
+
43
+ /**
44
+ * Revisa la estructura de un video y devuelve consejos (no bloquea el render).
45
+ * @param {object} v el video ya validado (scenes, mundo, serie...)
46
+ * @param {object} [d] datos.json de ese video (tiempos reales de la voz); sin él solo se revisa el texto
47
+ * @returns {{id: string, nivel: 'aviso' | 'idea', texto: string}[]}
48
+ */
49
+ export function revisarEstructura(v, d) {
50
+ const out = [];
51
+ const add = (id, nivel, texto) => out.push({id, nivel, texto});
52
+ const escenas = (v.scenes ?? []).filter(Boolean);
53
+ const cuerpo = escenas.filter((s) => s.type !== 'cta');
54
+ if (!cuerpo.length) return out;
55
+ const primera = cuerpo[0];
56
+ const clasico = (v.estilo ?? 'datos-curiosos') === 'datos-curiosos';
57
+
58
+ // 1) gancho
59
+ const dicho = String(primera.say ?? '').trim();
60
+ if (!GANCHOS.test(dicho) && !/\d/.test(primera.show ?? '') && !/\?/.test(dicho))
61
+ add('gancho', 'idea', `La primera frase ("${dicho.slice(0, 50)}${dicho.length > 50 ? '…' : ''}") no engancha de entrada: prueba "Imagina que…", "¿Y si tu…?" o una cifra imposible.`);
62
+ if (!clasico && !v.mundo?.length && ['titular', 'dato', 'lista'].includes(primera.type) && !(primera.capas ?? []).length)
63
+ add('gancho', 'aviso', 'El video abre con un letrero sin imagen. En los videos de referencia el sujeto ya está en pantalla desde el primer cuadro.');
64
+ if (/^(hola|hoy|bienvenid|en este video|te voy a)/i.test(dicho)) add('gancho', 'aviso', 'No abras presentando ("Hoy te voy a contar…"): entra directo a la situación.');
65
+
66
+ // 2) ritmo y duración (con los tiempos reales si ya hay voz)
67
+ const es = d?.escenas?.filter((e) => e.tipo !== 'cta') ?? [];
68
+ if (es.length) {
69
+ 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.`);
71
+ 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
+ const e0 = es[0];
73
+ 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.`);
74
+ es.forEach((e, i) => {
75
+ const s = cuerpo[i] ?? {};
76
+ const voz = (e.habla?.[1] ?? 0) - (e.habla?.[0] ?? 0);
77
+ // algo que cambie a media escena: capas con `en`, cámara con rango, toma con movimiento, o un mundo vivo
78
+ const capas = s.capas ?? [];
79
+ 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.`);
81
+ 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
+ });
83
+ }
84
+
85
+ // 3) preguntas y rótulos
86
+ const preguntas = cuerpo.slice(1).filter((s) => /\?/.test(s.say ?? '') || /\?/.test(s.rotulo ?? ''));
87
+ if (cuerpo.length >= 4 && !preguntas.length) add('pregunta', 'idea', 'Ninguna pregunta a media historia: antes del dato más fuerte pon la pregunta que se haría el espectador ("¿Cuánto subió?").');
88
+ if (!clasico && cuerpo.length >= 4 && !cuerpo.some((s) => s.rotulo || s.type === 'titular')) add('rotulo', 'idea', 'Sin rótulos: un `rotulo` corto en 2–3 escenas clave ("¿CUÁNTO SUBIÓ?") da el segundo nivel de texto que usan los de referencia.');
89
+
90
+ // 4) cierre
91
+ const fin = escenas.at(-1);
92
+ const pideAlgo = escenas.some((s) => s.type === 'eleccion') || (fin?.type === 'cta' && fin.pide) || /comenta|escribe|mándaselo|mandaselo|comparte|elige|parte 2|siguiente parte/i.test(cuerpo.at(-1)?.say ?? '');
93
+ if (!pideAlgo) add('cierre', 'idea', "El cierre solo pide seguir. Pide algo concreto: {type: 'cta', pide: 'comenta', palabra: 'PASO'}, pide: 'comparte' o una escena 'eleccion' (A o B).");
94
+
95
+ // 5) habla de tú
96
+ const todo = cuerpo.map((s) => s.say ?? '').join(' ');
97
+ const n = palabrasDe(todo).length;
98
+ const tu = (todo.match(/\b(tú|tu|tus|te|ti|contigo|imagina|tienes|eres|puedes|sabes)\b/gi) ?? []).length;
99
+ 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.');
100
+ return out;
101
+ }
102
+
103
+ /** Consejos en texto para la terminal. */
104
+ export const consejosEnTexto = (lista) => lista.map((c) => ` ${c.nivel === 'aviso' ? '!' : '·'} ${c.texto}`).join('\n');
package/lib/guion.mjs CHANGED
@@ -5,6 +5,7 @@ import os from 'node:os';
5
5
  import path from 'node:path';
6
6
  import {ErrorMorfeo, MUSICAS} from './config.mjs';
7
7
  import {listaTemas} from './diccionario.mjs';
8
+ import {REGLAS_TEXTO} from './estructura.mjs';
8
9
 
9
10
  export const MODELO_GUION = 'claude-opus-5-5';
10
11
  export const TIPOS_IA = ['title', 'year', 'text', 'icon', 'compare', 'card', 'dialog'];
@@ -27,7 +28,7 @@ export const slug = (s) => {
27
28
  return corto.slice(0, Math.max(corto.lastIndexOf('-'), 12)).replace(/-+$/, '') || 'video';
28
29
  };
29
30
 
30
- const INSTRUCCIONES = `Eres guionista de videos cortos verticales (YouTube Shorts / TikTok / Reels) de 25 a 45 segundos.
31
+ const INSTRUCCIONES = `Eres guionista de videos cortos verticales (YouTube Shorts / TikTok / Reels) de 30 a 60 segundos.
31
32
  Escribes en el idioma que se te pida, con frases cortas, naturales y fáciles de decir en voz alta.
32
33
 
33
34
  Un video es una lista de escenas. Cada escena tiene "say": lo que dice la voz (una o dos frases cortas).
@@ -35,7 +36,7 @@ Los números y años en "say" van ESCRITOS CON LETRAS ("mil novecientos ochenta"
35
36
  en ese caso pon en "show" la misma frase con cifras ("1980") para el subtítulo. Si no hay números, "show" es null.
36
37
 
37
38
  Tipos de escena (elige el que mejor ilustre cada frase y varíalos):
38
- - title: el gancho, SIEMPRE la primera. "rows": 2-3 filas de texto grande; la última es el remate (color amarillo o rojo). "icon": un ícono.
39
+ - title: el gancho, SIEMPRE la primera. "say" es la frase gancho (ver Estructura); "rows": 2-3 filas con lo esencial del gancho; la última es el remate (color amarillo o rojo). "icon": un ícono.
39
40
  - year: una fecha clave. "pre": contexto corto (p. ej. "JAPÓN"), "year": el año como número, "sub": qué pasó (2-3 palabras).
40
41
  - text: 1-3 filas de texto grande con la idea clave.
41
42
  - icon: un ícono grande con "label" (2-3 palabras) y "color".
@@ -47,7 +48,9 @@ Tipos de escena (elige el que mejor ilustre cada frase y varíalos):
47
48
  Reglas de texto en pantalla: MAYÚSCULAS, máximo 14 letras por fila, tamaños ("size") entre 110 y 260 (más corto = más grande).
48
49
  Colores disponibles: blanco, amarillo, rojo, verde, cian. Usa blanco para lo normal y un color para lo importante.
49
50
  "at" y "click" deben ser palabras que aparezcan tal cual en "say".
50
- No incluyas la escena final de "sígueme": morfeo la agrega sola.`;
51
+ No incluyas la escena final de "sígueme": morfeo la agrega sola; la última escena tuya pide algo concreto (comentar una palabra, elegir, mandárselo a alguien).
52
+
53
+ ${REGLAS_TEXTO}`;
51
54
 
52
55
  /** Esquema plano (todas las claves presentes, null cuando no aplican) para la salida estructurada. */
53
56
  export async function esquema() {
@@ -93,7 +96,7 @@ const OBJETOS = ['corazon', 'estrella', 'rayo', 'diamante', 'trofeo', 'cohete',
93
96
 
94
97
  const ESTILOS_MOTOR = ['explicativo', 'documental', 'ilustrado', 'historia', 'mesa', 'minimal'];
95
98
 
96
- const INSTRUCCIONES_MOTOR = `Eres guionista y director de arte de videos cortos verticales (YouTube Shorts / TikTok / Reels) de 25 a 60 segundos.
99
+ const INSTRUCCIONES_MOTOR = `Eres guionista y director de arte de videos cortos verticales (YouTube Shorts / TikTok / Reels) de 30 a 60 segundos.
97
100
  Escribes en el idioma que se te pida, con frases cortas y naturales para decir en voz alta.
98
101
  Los subtítulos ya salen solos arriba: en pantalla pon solo lo que ILUSTRA la frase, no la repitas.
99
102
 
@@ -111,8 +114,13 @@ Tipos de escena:
111
114
  4. "emoji" SOLO si nada de lo anterior encaja (es el último recurso),
112
115
  más un "chip" opcional (dato corto como "×5" o "230 dB") que aparece en la palabra "en".
113
116
  - eleccion: final abierto para que el público comente: "pregunta" y 2 "opciones" ({texto, sub, objeto}; emoji solo si ningún objeto encaja). Úsala al final en series o historias.
117
+ Además, en cualquier escena:
118
+ - "rotulo": 2–4 palabras grandes que resumen el momento ("¿CUÁNTO *SUBIÓ*?", "SALIR DE CASA"), con la clave entre *asteriscos*. Úsalo en 2–4 escenas, sobre todo en preguntas y revelaciones; null en las demás.
119
+ - "claves": 1–2 palabras de "say" que se resaltan en el subtítulo (la idea de la frase), o null.
114
120
  Varía los tipos. "at", "check" y "en" deben ser palabras que aparezcan tal cual en "say".
115
- No incluyas la escena final de "sígueme": morfeo la agrega sola.
121
+ No escribas la escena de cierre: va en "cierre" (lo que pide al final: "comenta" una "palabra", "comparte" con "a" quién se lo mandan, o "sigue"), con su "say".
122
+
123
+ ${REGLAS_TEXTO}
116
124
 
117
125
  Diccionario de recursos por tema (usa estas palabras en "recurso"):
118
126
  ${Object.entries(listaTemas()).map(([t, v]) => `- ${t}: ${v.join(', ')}`).join('\n')}`;
@@ -144,8 +152,11 @@ export async function esquemaMotor() {
144
152
  en: z.string().nullable(),
145
153
  pregunta: z.string().nullable(),
146
154
  opciones: z.array(z.object({texto: z.string(), sub: z.string(), objeto: z.enum(OBJETOS).nullable(), emoji: z.string().nullable()})).nullable(),
155
+ rotulo: z.string().nullable(),
156
+ claves: z.array(z.string()).nullable(),
147
157
  });
148
- return z.object({id: z.string(), title: z.string(), music: z.enum(MUSICAS), scenes: z.array(escena)});
158
+ const cierre = z.object({pide: z.enum(['comenta', 'comparte', 'sigue']), say: z.string(), palabra: z.string().nullable(), a: z.string().nullable()});
159
+ return z.object({id: z.string(), title: z.string(), music: z.enum(MUSICAS), scenes: z.array(escena), cierre});
149
160
  }
150
161
 
151
162
  /** De la salida de la IA (motor de capas) al formato de escenas de morfeo. */
@@ -153,6 +164,8 @@ function aEscenaMotor(e, conImagenes) {
153
164
  const out = {type: e.type, say: e.say};
154
165
  if (e.show && e.show !== e.say) out.show = e.show;
155
166
  const si = (k, v) => v != null && v !== '' && (out[k] = v);
167
+ si('rotulo', e.rotulo);
168
+ if (e.claves?.length) out.claves = e.claves;
156
169
  switch (e.type) {
157
170
  case 'titular':
158
171
  si('texto', e.texto ?? '');
@@ -252,7 +265,9 @@ export async function guionConIA(o) {
252
265
  const pedido = o.texto
253
266
  ? `Convierte este guion en escenas. Respeta su contenido y su orden; puedes partir frases largas.\n\n<guion>\n${o.texto}\n</guion>`
254
267
  : `Escribe un video sobre: ${o.tema}`;
255
- const extra = [`Idioma: ${idioma}.`, `Duración aproximada: ${o.duracion ?? 35} segundos (unas ${Math.round((o.duracion ?? 35) / 4.5)} escenas).`];
268
+ // ~45 s por defecto: los videos de referencia rondan el minuto y ninguna idea pasa de ~5 s (lib/estructura.mjs)
269
+ const dur = o.duracion ?? 45;
270
+ const extra = [`Idioma: ${idioma}.`, `Duración aproximada: ${dur} segundos (unas ${Math.round(dur / 4)} escenas de 3 a 5 segundos).`];
256
271
  if (o.tono) extra.push(`Tono: ${o.tono}.`);
257
272
  const motor = o.estilo && o.estilo !== 'datos-curiosos';
258
273
  if (motor) extra.push(`Estilo visual: ${o.estilo}.${o.imagenes ? ' Se pueden generar ilustraciones (usa imagen_ia en las escenas capas).' : ' No hay ilustraciones: usa emoji en las escenas capas.'}`);
@@ -278,7 +293,9 @@ export async function guionConIA(o) {
278
293
  if (!g) throw new ErrorMorfeo('Claude no devolvió un guion válido; intenta otra vez.');
279
294
  if (motor) {
280
295
  const scenes = g.scenes.map((e) => aEscenaMotor(e, !!o.imagenes));
281
- return {id: slug(g.id || g.title), video: {estilo: o.estilo, title: g.title, voice: o.voz ?? 'em_alex', music: g.music, scenes: [...scenes, {type: 'cta'}]}};
296
+ const c = g.cierre;
297
+ const cta = !c || c.pide === 'sigue' ? {type: 'cta', ...(c?.say ? {say: c.say} : {})} : {type: 'cta', pide: c.pide, say: c.say, ...(c.palabra ? {palabra: c.palabra} : {}), ...(c.a ? {a: c.a} : {})};
298
+ return {id: slug(g.id || g.title), video: {estilo: o.estilo, title: g.title, voice: o.voz ?? 'em_alex', music: g.music, scenes: [...scenes, cta]}};
282
299
  }
283
300
  const scenes = g.scenes.map(aEscena);
284
301
  if (scenes[0]?.type !== 'title') scenes.unshift({type: 'title', say: scenes[0]?.say ?? g.title, rows: [[g.title.toUpperCase().slice(0, 14), '#FFD400', 200]], icon: 'question'});
package/lib/hoja.mjs ADDED
@@ -0,0 +1,98 @@
1
+ // `morfeo hoja <id>`: hoja de contacto (PNG con cuadros repartidos en el tiempo) para revisar un video de un vistazo.
2
+ // El ffmpeg que trae Remotion viene recortado (sin los filtros `tile` ni `fps`), así que cada cuadro se pide como
3
+ // PNG sin predicción por una tubería y la hoja se arma aquí con zlib.
4
+ import {spawnSync} from 'node:child_process';
5
+ import fs from 'node:fs';
6
+ import {createRequire} from 'node:module';
7
+ import path from 'node:path';
8
+ import zlib from 'node:zlib';
9
+ import {ErrorMorfeo} from './config.mjs';
10
+
11
+ const require = createRequire(import.meta.url);
12
+
13
+ /** Rutas de ffmpeg y ffprobe: las del paquete de Remotion de esta plataforma; si no, las del PATH. */
14
+ function binarios() {
15
+ const exe = process.platform === 'win32' ? '.exe' : '';
16
+ const libc = process.platform === 'linux' ? (process.report?.getReport?.().header?.glibcVersionRuntime ? '-gnu' : '-musl') : '';
17
+ const sufijo = process.platform === 'win32' ? '-msvc' : libc;
18
+ try {
19
+ const dir = path.dirname(require.resolve(`@remotion/compositor-${process.platform}-${process.arch}${sufijo}/package.json`));
20
+ if (fs.existsSync(path.join(dir, `ffmpeg${exe}`))) return {ffmpeg: path.join(dir, `ffmpeg${exe}`), ffprobe: path.join(dir, `ffprobe${exe}`), cwd: dir};
21
+ } catch {}
22
+ return {ffmpeg: 'ffmpeg', ffprobe: 'ffprobe', cwd: undefined};
23
+ }
24
+
25
+ const CRC = (() => {
26
+ const t = new Uint32Array(256);
27
+ for (let n = 0; n < 256; n++) {
28
+ let c = n;
29
+ for (let k = 0; k < 8; k++) c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1;
30
+ t[n] = c >>> 0;
31
+ }
32
+ return t;
33
+ })();
34
+ const crc32 = (b) => {
35
+ let c = 0xffffffff;
36
+ for (let i = 0; i < b.length; i++) c = CRC[(c ^ b[i]) & 255] ^ (c >>> 8);
37
+ return (c ^ 0xffffffff) >>> 0;
38
+ };
39
+ const trozo = (tipo, datos) => {
40
+ const cab = Buffer.alloc(8);
41
+ cab.writeUInt32BE(datos.length, 0);
42
+ cab.write(tipo, 4, 'latin1');
43
+ const fin = Buffer.alloc(4);
44
+ fin.writeUInt32BE(crc32(Buffer.concat([cab.subarray(4), datos])), 0);
45
+ return Buffer.concat([cab, datos, fin]);
46
+ };
47
+
48
+ /** PNG RGB de 8 bits sin predicción -> píxeles crudos (alto × ancho × 3). */
49
+ function leerPng(b, w, h) {
50
+ const idat = [];
51
+ for (let p = 8; p + 8 <= b.length; ) {
52
+ const largo = b.readUInt32BE(p);
53
+ if (b.toString('latin1', p + 4, p + 8) === 'IDAT') idat.push(b.subarray(p + 8, p + 8 + largo));
54
+ p += 12 + largo;
55
+ }
56
+ const crudo = zlib.inflateSync(Buffer.concat(idat));
57
+ const fila = 1 + w * 3;
58
+ if (crudo.length !== fila * h) throw new Error('cuadro con tamaño inesperado');
59
+ const out = Buffer.alloc(w * h * 3);
60
+ for (let y = 0; y < h; y++) crudo.copy(out, y * w * 3, y * fila + 1, (y + 1) * fila);
61
+ return out;
62
+ }
63
+
64
+ /**
65
+ * @param {{video: string, salida?: string, cols?: number, filas?: number, desde?: number, hasta?: number, ancho?: number}} op
66
+ * @returns {string} ruta del PNG
67
+ */
68
+ export function hoja({video, salida, cols = 5, filas = 3, desde = 0, hasta, ancho = 216}) {
69
+ if (!fs.existsSync(video)) throw new ErrorMorfeo(`No encuentro ${video}. Renderízalo primero: npx morfeo render`);
70
+ const {ffmpeg, ffprobe, cwd} = binarios();
71
+ 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});
72
+ if (sonda.status !== 0) throw new ErrorMorfeo(`No pude leer ${video} (¿falta ffprobe?)`);
73
+ const [dim, dur] = sonda.stdout.trim().split(/\r?\n/);
74
+ const [vw, vh] = dim.split(',').map(Number);
75
+ const fin = hasta ?? Number(dur);
76
+ const w = ancho - (ancho % 2);
77
+ const h = Math.round((w * vh) / vw / 2) * 2;
78
+ const n = cols * filas;
79
+ const W = cols * w;
80
+ const H = filas * h;
81
+ const lienzo = Buffer.alloc(H * (1 + W * 3)); // cada fila empieza con el byte de filtro 0
82
+ for (let i = 0; i < n; i++) {
83
+ const t = desde + ((fin - desde) * (i + 0.5)) / n;
84
+ const r = spawnSync(ffmpeg, ['-v', 'error', '-ss', t.toFixed(3), '-i', video, '-frames:v', '1', '-vf', `scale=${w}:${h}`, '-f', 'image2pipe', '-c:v', 'png', '-pred', 'none', '-pix_fmt', 'rgb24', '-'], {maxBuffer: 64 * 1024 * 1024, cwd});
85
+ if (r.status !== 0 || !r.stdout?.length) continue;
86
+ const px = leerPng(r.stdout, w, h);
87
+ const x0 = (i % cols) * w;
88
+ const y0 = Math.floor(i / cols) * h;
89
+ for (let y = 0; y < h; y++) px.copy(lienzo, (y0 + y) * (1 + W * 3) + 1 + x0 * 3, y * w * 3, (y + 1) * w * 3);
90
+ }
91
+ const ihdr = Buffer.alloc(13);
92
+ ihdr.writeUInt32BE(W, 0);
93
+ ihdr.writeUInt32BE(H, 4);
94
+ ihdr.set([8, 2, 0, 0, 0], 8);
95
+ const destino = salida ?? video.replace(/\.[^.]+$/, '') + '-hoja.png';
96
+ fs.writeFileSync(destino, Buffer.concat([Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]), trozo('IHDR', ihdr), trozo('IDAT', zlib.deflateSync(lienzo, {level: 6})), trozo('IEND', Buffer.alloc(0))]));
97
+ return destino;
98
+ }
package/lib/imagenes.mjs CHANGED
@@ -62,6 +62,7 @@ export async function generarImagenes(video, cfgImagenes, cacheDir, assetsDir, l
62
62
  };
63
63
  const scenes = recorrer(video.scenes);
64
64
  const hud = recorrer(video.hud ?? []);
65
+ const mundo = recorrer(video.mundo ?? []);
65
66
  if (!pendientes.length) return video;
66
67
  const cfg = cfgImagenes ?? {};
67
68
  const proveedor = cfg.proveedor ?? 'openai';
@@ -95,5 +96,5 @@ export async function generarImagenes(video, cfgImagenes, cacheDir, assetsDir, l
95
96
  if (x.__src) out.src = x.__src.ruta;
96
97
  return out;
97
98
  };
98
- return {...video, scenes: fijar(scenes), hud: fijar(hud)};
99
+ return {...video, scenes: fijar(scenes), hud: fijar(hud), mundo: fijar(mundo)};
99
100
  }
@@ -90,5 +90,5 @@ export function ponerPoses(video, mapa) {
90
90
  }
91
91
  return o;
92
92
  };
93
- return {...video, scenes: recorrer(video.scenes), hud: recorrer(video.hud ?? [])};
93
+ return {...video, scenes: recorrer(video.scenes), hud: recorrer(video.hud ?? []), mundo: recorrer(video.mundo ?? [])};
94
94
  }