morfeo 0.3.4 → 0.4.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/bases/biografia/Mundo.tsx +35 -0
- package/bases/biografia/guion.mjs +34 -0
- package/bases/construiste/Mundo.tsx +273 -0
- package/bases/construiste/guion.mjs +84 -0
- package/bases/escalada/Mundo.tsx +45 -0
- package/bases/escalada/guion.mjs +24 -0
- package/bases/mito/Mundo.tsx +55 -0
- package/bases/mito/guion.mjs +29 -0
- package/bases/serie/guion.mjs +32 -0
- package/bin/morfeo.mjs +14 -6
- package/index.d.ts +26 -0
- package/lib/config.mjs +16 -4
- package/lib/estructura.mjs +9 -3
- package/lib/formulas.mjs +179 -0
- package/lib/guion.mjs +29 -3
- package/lib/nuevo-video.mjs +12 -3
- package/lib/palabras.mjs +1 -1
- package/package.json +1 -1
- package/python/preparar.py +1 -1
- package/remotion/kit/armar.tsx +245 -0
- package/remotion/kit/index.ts +1 -0
- package/remotion/motor/Video.tsx +45 -11
- package/remotion/motor/capas.tsx +2 -0
- package/remotion/motor/historias.tsx +440 -0
- package/remotion/motor/tierra-datos.ts +2 -0
- package/remotion/tipos.ts +1 -0
package/bin/morfeo.mjs
CHANGED
|
@@ -13,7 +13,7 @@ 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
|
|
16
|
+
nuevo-video <id> Crea un video desde una base que ya renderiza: --look 2d | 3d | serie | biografia | escalada | mito | construiste
|
|
17
17
|
doctor Revisa qué está instalado y qué falta
|
|
18
18
|
|
|
19
19
|
Videos
|
|
@@ -33,6 +33,8 @@ Videos
|
|
|
33
33
|
Guiones
|
|
34
34
|
guion "tema" La IA (Claude) escribe un video completo sobre el tema
|
|
35
35
|
guion --desde f.txt Convierte tu guion en escenas (con IA; --sin-ia: una escena por frase)
|
|
36
|
+
--formula <id> una fórmula de historia (construiste, serie, mito, misterio…)
|
|
37
|
+
formulas Las fórmulas de historia medidas en videos de referencia
|
|
36
38
|
--estilo documental estilo visual (por defecto, el del proyecto)
|
|
37
39
|
--imagenes pide ilustraciones con IA (necesita OPENAI_API_KEY)
|
|
38
40
|
--id nombre --voz em_alex --duracion 40 --idioma "inglés" --tono "humor"
|
|
@@ -53,7 +55,7 @@ function opciones(argv) {
|
|
|
53
55
|
const [k, v] = a.slice(2).split('=');
|
|
54
56
|
const clave = k.replace(/-([a-z])/g, (_, c) => c.toUpperCase());
|
|
55
57
|
const sig = argv[i + 1];
|
|
56
|
-
const conValor = ['proyecto', 'desde', 'id', 'voz', 'duracion', 'idioma', 'estilo', 'tono', 'variante', 'fuente', 'musica', 'titulo', 'modelo', 'puerto', 'hasta', 'cols', 'filas', 'look'];
|
|
58
|
+
const conValor = ['proyecto', 'desde', 'id', 'voz', 'duracion', 'idioma', 'estilo', 'tono', 'variante', 'fuente', 'musica', 'titulo', 'modelo', 'puerto', 'hasta', 'cols', 'filas', 'look', 'formula'];
|
|
57
59
|
if (v != null) op[clave] = v;
|
|
58
60
|
else if (conValor.includes(clave) && sig != null && !sig.startsWith('--')) op[clave] = argv[++i];
|
|
59
61
|
else op[clave] = true;
|
|
@@ -87,12 +89,11 @@ async function main() {
|
|
|
87
89
|
return nuevo(pos[0], op);
|
|
88
90
|
case 'nuevo-video': {
|
|
89
91
|
const {nuevoVideo} = await import('../lib/nuevo-video.mjs');
|
|
90
|
-
const look = op.look === true || op.look == null ? '2d' : String(op.look).toLowerCase();
|
|
92
|
+
const look = typeof op.formula === 'string' ? op.formula.toLowerCase() : op.look === true || op.look == null ? '2d' : String(op.look).toLowerCase();
|
|
91
93
|
const r = nuevoVideo({proyecto, id: pos[0], look});
|
|
92
94
|
const rel = (f) => path.relative(process.cwd(), f);
|
|
93
95
|
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
|
+
${rel(r.guion)} el guion: texto, tomas, rótulos y capas${r.escena ? `\n ${rel(r.escena)} el mundo (${r.componente}, ya registrado en morfeo.config.mjs)` : ''}
|
|
96
97
|
|
|
97
98
|
Pruébalo: npx morfeo render ${pos[0]} y revísalo: npx morfeo hoja ${pos[0]}`);
|
|
98
99
|
return;
|
|
@@ -191,6 +192,13 @@ Pruébalo: npx morfeo render ${pos[0]} y revísalo: npx morfeo hoja ${pos[0]
|
|
|
191
192
|
console.log('\nÚsalo con estilo: "documental" en el video o en morfeo.config.mjs. Crea los tuyos con estilos: {...} en el config.');
|
|
192
193
|
return;
|
|
193
194
|
}
|
|
195
|
+
case 'formulas':
|
|
196
|
+
case 'formula': {
|
|
197
|
+
const {FORMULAS} = await import('../lib/formulas.mjs');
|
|
198
|
+
for (const f of FORMULAS) console.log(`${f.id.padEnd(14)} ${f.nombre} (${f.duracion[0]}–${f.duracion[1]} s · ${f.estilos.join(', ')})\n${' '.repeat(15)}${f.para}\n${' '.repeat(15)}Gancho: ${f.gancho}\n`);
|
|
199
|
+
console.log('Úsala: npx morfeo guion "tu tema" --formula construiste (o formula: \'construiste\' en el guion para que `revisar` la revise)');
|
|
200
|
+
return;
|
|
201
|
+
}
|
|
194
202
|
case 'guion':
|
|
195
203
|
case 'script':
|
|
196
204
|
return guion(pos, op, proyecto);
|
|
@@ -225,7 +233,7 @@ async function guion(pos, op, proyecto) {
|
|
|
225
233
|
if (texto && op.sinIa) r = guionSinIA(texto, {id: op.id, voz: op.voz, musica: op.musica, titulo: op.titulo, estilo});
|
|
226
234
|
else {
|
|
227
235
|
console.log(texto ? 'Claude está convirtiendo tu guion en escenas...' : `Claude está escribiendo un video sobre: ${tema}...`);
|
|
228
|
-
r = await guionConIA({tema, texto, idioma: op.idioma, duracion: op.duracion ? Number(op.duracion) : undefined, tono: op.tono, estilo, imagenes: !!op.imagenes, voz: op.voz, modelo: op.modelo});
|
|
236
|
+
r = await guionConIA({tema, texto, formula: op.formula, idioma: op.idioma, duracion: op.duracion ? Number(op.duracion) : undefined, tono: op.tono, estilo, imagenes: !!op.imagenes, voz: op.voz, modelo: op.modelo});
|
|
229
237
|
}
|
|
230
238
|
if (op.id) r.id = op.id;
|
|
231
239
|
const {cargarProyecto} = await import('../lib/config.mjs');
|
package/index.d.ts
CHANGED
|
@@ -69,6 +69,9 @@ interface CapaBase {
|
|
|
69
69
|
cambios?: ({en: Momento | {escena: number}} & Record<string, unknown>)[];
|
|
70
70
|
}
|
|
71
71
|
|
|
72
|
+
/** Mitad de una pantalla dividida: sus capas usan coordenadas de 1080×1920 recortadas a su mitad. */
|
|
73
|
+
export interface Mitad {etiqueta?: string; color?: string; fondo?: string; capas?: Capa[]}
|
|
74
|
+
|
|
72
75
|
export type Capa = CapaBase &
|
|
73
76
|
(
|
|
74
77
|
| {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}}
|
|
@@ -92,6 +95,16 @@ export type Capa = CapaBase &
|
|
|
92
95
|
| {tipo: 'recipientes'; n?: number; valor?: number; forma?: 'garrafon' | 'vaso'; total?: string; color?: string; tamPieza?: number; duracion?: number}
|
|
93
96
|
| {tipo: 'ficha'; titulo?: string; foto?: string; campos: [string, string][]; ancho?: number; color?: string; fondoFoto?: string}
|
|
94
97
|
| {tipo: 'checklist'; items: (string | {texto: string; en?: Momento; pendiente?: boolean})[]; titulo?: string; tam?: number; color?: string; cada?: number}
|
|
98
|
+
| {tipo: 'bloqueo'; hora?: string; fecha?: string; /** Año abajo de la hora; [2026, 2031] salta de uno a otro en `anioEn`. */ anio?: number | string | (number | string)[]; anioEn?: Momento; notificaciones?: {app?: string; titulo?: string; texto?: string; en?: Momento}[]; fondo?: string; ancho?: number}
|
|
99
|
+
| {tipo: 'llamada'; estado?: 'entrante' | 'curso' | 'terminada'; nombre?: string; numero?: string; aviso?: string | false; /** Segundos con los que arranca el contador (curso) o duración final (terminada). */ desde?: number; duracion?: number; fondo?: string; ancho?: number}
|
|
100
|
+
| {tipo: 'recuento'; cabecera?: string; titulo?: string; opciones: {texto: string; valor: number; sub?: string}[]; color?: string; ancho?: number}
|
|
101
|
+
| {tipo: 'capitulo'; serie?: string; capitulo?: string; pregunta?: string; opciones?: {texto: string; sub?: string}[]; pie?: string; siguiente?: string; horario?: string; color?: string; ancho?: number}
|
|
102
|
+
| {tipo: 'vigilancia'; hora?: string; fecha?: string; etiqueta?: string; zoom?: number; mira?: boolean; miraX?: number; miraY?: number; color?: string}
|
|
103
|
+
| {tipo: 'cintillo'; nombre: string; cargo?: string; color?: string; tam?: number}
|
|
104
|
+
| {tipo: 'fecha'; anio?: number; de?: number; texto?: string; pie?: string; color?: string; tam?: number}
|
|
105
|
+
| {tipo: 'calendario'; titulo?: string; de?: number; a?: number; color?: string; tam?: number; duracion?: number}
|
|
106
|
+
| {tipo: 'dividida'; izq?: Mitad; der?: Mitad; arriba?: Mitad; abajo?: Mitad; vertical?: boolean; fondo?: string; linea?: string}
|
|
107
|
+
| {tipo: 'tierra'; lat: number; lon: number; etiqueta?: string; tam?: number; color?: string; girar?: boolean}
|
|
95
108
|
| {tipo: 'mapa'; ruta?: [number, number][]; distancia?: string; ancho?: number; color?: string; fondo?: string; inclinar?: number; duracion?: number}
|
|
96
109
|
| {tipo: 'cursor'; a: [number, number]; clic?: Momento; duracion?: number; tam?: number; color?: string}
|
|
97
110
|
| {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}
|
|
@@ -176,6 +189,8 @@ interface EscenaMotorBase extends EscenaBase {
|
|
|
176
189
|
modoEn?: Momento;
|
|
177
190
|
/** Palabra o segundos en que vuelve a la imagen normal. */
|
|
178
191
|
modoHasta?: Momento;
|
|
192
|
+
/** Lugar (de `lugares` del video) donde pasa esta escena. */
|
|
193
|
+
lugar?: string;
|
|
179
194
|
/** Invertir la imagen en el modo (fondo claro → oscuro). Por defecto sí en looks claros (3d, maqueta, explicativo, infografia, minimal). */
|
|
180
195
|
modoInvertir?: boolean;
|
|
181
196
|
/** Palabras clave de la escena para los subtítulos (la que lleva caja o color de acento en cada frase). */
|
|
@@ -267,6 +282,12 @@ export interface Video {
|
|
|
267
282
|
* El componente recibe además `escena` (1, 2, 3...), `tEscena`, `inicios` y `camara` (para paralaje).
|
|
268
283
|
*/
|
|
269
284
|
mundo?: Capa[];
|
|
285
|
+
/**
|
|
286
|
+
* Varios mundos en un video (biografías, historias que cambian de sitio): `{cocina: [...], boda: [...]}`. Cada escena
|
|
287
|
+
* elige uno con `lugar: 'boda'` (o sigue en el anterior). Cada lugar tiene su propia cámara; al cambiar, corta o usa
|
|
288
|
+
* la `transicion` de la escena.
|
|
289
|
+
*/
|
|
290
|
+
lugares?: Record<string, Capa[]>;
|
|
270
291
|
/** Pastilla fija arriba para series: 'GYM CON CIENCIA · PARTE 5'. */
|
|
271
292
|
serie?: string;
|
|
272
293
|
/** Calidad de este video (ver `render.calidad`). */
|
|
@@ -274,6 +295,11 @@ export interface Video {
|
|
|
274
295
|
/** Para publicar: descripción y etiquetas (van a salida/<id>.txt junto con el título). */
|
|
275
296
|
descripcion?: string;
|
|
276
297
|
tags?: string[];
|
|
298
|
+
/**
|
|
299
|
+
* Fórmula de historia (ver `npx morfeo formulas`): 'construiste' | 'recomendable' | 'escalada' | 'supervivencia' |
|
|
300
|
+
* 'experimento' | 'misterio' | 'demuestralo' | 'mito' | 'roast' | 'serie' | 'biografia'. `revisar` da consejos propios.
|
|
301
|
+
*/
|
|
302
|
+
formula?: 'construiste' | 'recomendable' | 'escalada' | 'supervivencia' | 'experimento' | 'misterio' | 'demuestralo' | 'mito' | 'roast' | 'serie' | 'biografia';
|
|
277
303
|
/** 'largo' = explicador de 90–130 s (lo asume el estilo maqueta): cambia las reglas de `morfeo revisar`. */
|
|
278
304
|
formato?: 'corto' | 'largo';
|
|
279
305
|
/** Ajustes del estilo para este video (colores, fondo, subtítulos...). */
|
package/lib/config.mjs
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
// Carga y valida morfeo.config.mjs del proyecto del usuario.
|
|
2
|
+
import {IDS_FORMULA} from './formulas.mjs';
|
|
2
3
|
import fs from 'node:fs';
|
|
3
4
|
import path from 'node:path';
|
|
4
5
|
import {pathToFileURL} from 'node:url';
|
|
@@ -25,7 +26,7 @@ export const TIPOS_ESCENA = [...new Set([...TIPOS_CLASICOS, ...TIPOS_MOTOR])];
|
|
|
25
26
|
export const ROTULOS = ['normal', 'contorno', 'pastilla', 'sello', 'paso', 'pixel'];
|
|
26
27
|
export const MODOS = ['rayosx', 'plano', 'holograma'];
|
|
27
28
|
export const ESTILOS = ['datos-curiosos', 'explicativo', 'documental', 'ilustrado', 'historia', 'mesa', 'serie', 'infografia', '3d', 'maqueta', 'minimal'];
|
|
28
|
-
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'];
|
|
29
|
+
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', 'bloqueo', 'llamada', 'recuento', 'capitulo', 'vigilancia', 'cintillo', 'fecha', 'calendario', 'dividida', 'tierra'];
|
|
29
30
|
export const ESCENARIOS = ['bosque', 'cuarto', 'departamento', 'tunel', 'pasillo'];
|
|
30
31
|
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'];
|
|
31
32
|
export const PROVEEDORES_VOZ = ['kokoro', 'elevenlabs', 'openai'];
|
|
@@ -228,6 +229,10 @@ function validarCapas(capas, donde, err, comps) {
|
|
|
228
229
|
if (!c.buscar) err(`${d}: falta \`buscar\` (qué objeto: 'tubería', 'casa', 'pizza'...)`);
|
|
229
230
|
if (c.estilo && !['2d', '3d', 'realista'].includes(c.estilo)) err(`${d}: estilo "${c.estilo}" no existe. Usa '2d', '3d' o 'realista'`);
|
|
230
231
|
}
|
|
232
|
+
if (c.tipo === 'dividida') for (const lado of ['izq', 'der', 'arriba', 'abajo']) if (c[lado]?.capas) validarCapas(c[lado].capas, `${d}, ${lado}`, err, comps);
|
|
233
|
+
if (c.tipo === 'cintillo' && !c.nombre) err(`${d}: \`cintillo\` necesita \`nombre\``);
|
|
234
|
+
if (c.tipo === 'llamada' && c.estado != null && !['entrante', 'curso', 'terminada'].includes(c.estado)) err(`${d}: estado "${c.estado}" no existe (entrante, curso o terminada)`);
|
|
235
|
+
if (c.tipo === 'tierra' && (!esNum(c.lat) || !esNum(c.lon))) err(`${d}: \`tierra\` necesita \`lat\` y \`lon\` (números)`);
|
|
231
236
|
if (c.capas) validarCapas(c.capas, d, err, comps);
|
|
232
237
|
if (c.capa) validarCapas([c.capa], d, err, comps);
|
|
233
238
|
});
|
|
@@ -315,6 +320,7 @@ function validarVideo(id, v, ctx) {
|
|
|
315
320
|
);
|
|
316
321
|
if (s.capas != null) validarCapas(s.capas, `${n}`, err, ctx.componentes);
|
|
317
322
|
if (s.rotulo != null && typeof s.rotulo !== 'string') err(`${n}: \`rotulo\` debe ser un texto corto ('¿CUÁNTO *SUBIÓ*?')`);
|
|
323
|
+
if (s.lugar != null && !(v.lugares && typeof v.lugares === 'object' && v.lugares[s.lugar])) err(`${n}: el lugar "${s.lugar}" no está en \`lugares\`${v.lugares && typeof v.lugares === 'object' ? ` (hay: ${Object.keys(v.lugares).join(', ')})` : ''}`);
|
|
318
324
|
if (s.rotuloEstilo != null && !ROTULOS.includes(s.rotuloEstilo)) err(`${n}: rotuloEstilo "${s.rotuloEstilo}" no existe. Opciones: ${ROTULOS.join(', ')}`);
|
|
319
325
|
if (s.rotuloPaso != null && s.rotuloEstilo !== 'paso') err(`${n}: \`rotuloPaso\` es para rotuloEstilo: 'paso'`);
|
|
320
326
|
if (s.modo != null) {
|
|
@@ -329,7 +335,7 @@ function validarVideo(id, v, ctx) {
|
|
|
329
335
|
if (tm.zoom != null && tm.zoom <= 0) err(`${n}: \`toma.zoom\` debe ser mayor que 0`);
|
|
330
336
|
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`);
|
|
331
337
|
}
|
|
332
|
-
if (!v.mundo?.length) err(`${n}: \`toma\` mueve la cámara del \`mundo\`, pero el video no tiene \`mundo\``);
|
|
338
|
+
if (!v.mundo?.length && !v.lugares) err(`${n}: \`toma\` mueve la cámara del \`mundo\`, pero el video no tiene \`mundo\` ni \`lugares\``);
|
|
333
339
|
}
|
|
334
340
|
if (s.type === 'cta' && s.pide != null) {
|
|
335
341
|
if (!['comenta', 'comparte'].includes(s.pide)) err(`${n}: \`pide\` debe ser 'comenta' o 'comparte'`);
|
|
@@ -353,17 +359,23 @@ function validarVideo(id, v, ctx) {
|
|
|
353
359
|
if (v.voice != null) voice = normalizarVoz(v.voice, ctx, err);
|
|
354
360
|
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)');
|
|
355
361
|
if (v.hud != null) validarCapas(v.hud, 'hud', err, ctx.componentes);
|
|
362
|
+
if (v.lugares != null) {
|
|
363
|
+
if (typeof v.lugares !== 'object' || Array.isArray(v.lugares) || !Object.keys(v.lugares).length) err("`lugares` debe ser un objeto con un mundo por nombre: {cocina: [...], boda: [...]}");
|
|
364
|
+
else for (const [nombre, capas] of Object.entries(v.lugares)) validarCapas(capas, `lugares.${nombre}`, err, ctx.componentes);
|
|
365
|
+
if (clasico) err('`lugares` es del motor de capas: usa un estilo como ilustrado, 3d o mesa (no "datos-curiosos")');
|
|
366
|
+
}
|
|
356
367
|
if (v.mundo != null) {
|
|
357
368
|
validarCapas(v.mundo, 'mundo', err, ctx.componentes);
|
|
358
369
|
if (clasico) err('`mundo` es del motor de capas: usa un estilo como ilustrado, 3d o mesa (no "datos-curiosos")');
|
|
359
370
|
}
|
|
371
|
+
if (v.formula != null && !IDS_FORMULA.includes(v.formula)) err(`\`formula\` "${v.formula}" no existe. Opciones: ${IDS_FORMULA.join(', ')} (npx morfeo formulas)`);
|
|
360
372
|
if (v.serie != null && typeof v.serie !== 'string') err("`serie` debe ser un texto ('GYM CON CIENCIA · PARTE 5')");
|
|
361
373
|
if (v.title != null && typeof v.title !== 'string') err('`title` debe ser un texto (también es el nombre del archivo del video)');
|
|
362
374
|
if (v.calidad != null && !['alta', 'media', 'ligera'].includes(v.calidad)) err("`calidad` debe ser 'alta', 'media' o 'ligera'");
|
|
363
375
|
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'])");
|
|
364
376
|
// archivos de assets/ que piden las escenas y el HUD: mejor avisar ahora que a mitad del render
|
|
365
|
-
revisarArchivos({scenes, hud: v.hud, mundo: v.mundo, tema: v.tema}, ctx.assetsDir, err);
|
|
366
|
-
revisarPersonajes({scenes, hud: v.hud, mundo: v.mundo}, ctx.personajes, err);
|
|
377
|
+
revisarArchivos({scenes, hud: v.hud, mundo: v.mundo, lugares: v.lugares, tema: v.tema}, ctx.assetsDir, err);
|
|
378
|
+
revisarPersonajes({scenes, hud: v.hud, mundo: v.mundo, lugares: v.lugares}, ctx.personajes, err);
|
|
367
379
|
revisarEscenarios({scenes, hud: v.hud, tema: v.tema}, err);
|
|
368
380
|
return {...v, estilo, voice, music: normalizarMusica(v.music, ctx, err), scenes};
|
|
369
381
|
}
|
package/lib/estructura.mjs
CHANGED
|
@@ -43,8 +43,10 @@ export const REGLAS = [
|
|
|
43
43
|
export const REGLAS_TEXTO = `Estructura (medida en videos virales; síguela):
|
|
44
44
|
${REGLAS.map((r, i) => `${i + 1}. ${r.regla}: ${r.como}`).join('\n')}`;
|
|
45
45
|
|
|
46
|
+
import {formulaDe, revisarFormula} from './formulas.mjs';
|
|
47
|
+
|
|
46
48
|
const palabrasDe = (s) => String(s ?? '').trim().split(/\s+/).filter(Boolean);
|
|
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;
|
|
49
|
+
const GANCHOS = /^(¿|¡|imagina|la mayoría|la mayoria|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;
|
|
48
50
|
|
|
49
51
|
/**
|
|
50
52
|
* Revisa la estructura de un video y devuelve consejos (no bloquea el render).
|
|
@@ -61,7 +63,8 @@ export function revisarEstructura(v, d) {
|
|
|
61
63
|
const primera = cuerpo[0];
|
|
62
64
|
const clasico = (v.estilo ?? 'datos-curiosos') === 'datos-curiosos';
|
|
63
65
|
// formato largo: explicador de maqueta (o `formato: 'largo'` en el video)
|
|
64
|
-
const
|
|
66
|
+
const formula = formulaDe(v.formula);
|
|
67
|
+
const largo = v.estilo === 'maqueta' || v.formato === 'largo' || (formula?.duracion[1] ?? 0) > 90;
|
|
65
68
|
|
|
66
69
|
// 1) gancho
|
|
67
70
|
const dicho = String(primera.say ?? '').trim();
|
|
@@ -100,7 +103,7 @@ export function revisarEstructura(v, d) {
|
|
|
100
103
|
|
|
101
104
|
// 4) cierre
|
|
102
105
|
const fin = escenas.at(-1);
|
|
103
|
-
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 ?? '');
|
|
106
|
+
const pideAlgo = escenas.some((s) => s.type === 'eleccion') || (fin?.type === 'cta' && fin.pide) || /comenta|escribe|mándaselo|mandaselo|envíaselo|enviaselo|comparte|elige|parte 2|siguiente parte|capítulo/i.test(cuerpo.at(-1)?.say ?? '');
|
|
104
107
|
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).");
|
|
105
108
|
|
|
106
109
|
// 5) habla de tú
|
|
@@ -108,6 +111,9 @@ export function revisarEstructura(v, d) {
|
|
|
108
111
|
const n = palabrasDe(todo).length;
|
|
109
112
|
const tu = (todo.match(/\b(tú|tu|tus|te|ti|contigo|imagina|imaginas|tienes|eres|puedes|sabes|sabías|crees|conoces|harías|pagarías)\b/gi) ?? []).length;
|
|
110
113
|
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.');
|
|
114
|
+
// 6) lo propio de la fórmula del video (lib/formulas.mjs)
|
|
115
|
+
for (const t of revisarFormula(v)) add('formula', 'idea', t);
|
|
116
|
+
// "envíaselo a…" también es un cierre que pide algo
|
|
111
117
|
return out;
|
|
112
118
|
}
|
|
113
119
|
|
package/lib/formulas.mjs
ADDED
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
// Fórmulas de historia medidas en los videos de referencia (referencias/videos-ejemplo.md). Una sola fuente para:
|
|
2
|
+
// el prompt de `morfeo guion` (la IA elige una o se le pide con --formula), `morfeo revisar` (consejos propios de cada
|
|
3
|
+
// fórmula) y `morfeo formulas` (la lista). Cada una dice cómo arranca, cómo avanza, cómo cierra y qué piezas le quedan.
|
|
4
|
+
|
|
5
|
+
/** @typedef {{id: string, nombre: string, para: string, esqueleto: string[], gancho: string, cierre: string, duracion: [number, number], estilos: string[], piezas: string, revisar?: (v: any, texto: string) => string[]}} Formula */
|
|
6
|
+
|
|
7
|
+
const dice = (texto, re) => re.test(texto);
|
|
8
|
+
|
|
9
|
+
/** @type {Formula[]} */
|
|
10
|
+
export const FORMULAS = [
|
|
11
|
+
{
|
|
12
|
+
id: 'construiste',
|
|
13
|
+
nombre: 'Construiste un X',
|
|
14
|
+
para: 'explicar cómo funciona una obra, máquina o instalación (túnel, inodoro, portón eléctrico, semáforo)',
|
|
15
|
+
esqueleto: ['Arranca por lo obvio: "Si haces un hoyo en la montaña, tienes un túnel…"', 'Falla por algo concreto ("pero el techo plano se derrumba")', 'Lo arreglas con la pieza real ("le das forma de arco")', 'Repite falla → arreglo 4 a 7 veces; cada arreglo es una pieza nueva en pantalla', 'Remate: "¡Felicidades, construiste un túnel!"'],
|
|
16
|
+
gancho: '"Si a tu portón le pones un motor, ya no te bajas a abrir… pero no sabe cuándo parar."',
|
|
17
|
+
cierre: '"¡Felicidades, construiste X!" y "comenta qué construimos en el próximo".',
|
|
18
|
+
duracion: [90, 120],
|
|
19
|
+
estilos: ['maqueta', '3d'],
|
|
20
|
+
piezas: 'un solo mundo (Maqueta con corte de tierra), Aparece por pieza en su palabra, etiquetas 3D, rótulo paso con número, modo plano para ver por dentro',
|
|
21
|
+
revisar: (v, texto) => {
|
|
22
|
+
const out = [];
|
|
23
|
+
if (!dice(texto, /felicidades|construiste|inventaste|has (?:construido|inventado)/i)) out.push('Fórmula "construiste": cierra con "¡Felicidades, construiste X!" (es el remate que esperan).');
|
|
24
|
+
const pero = (texto.match(/\bpero\b/gi) ?? []).length;
|
|
25
|
+
if (pero < 3) out.push(`Fórmula "construiste": solo ${pero} "pero". La cadena falla → arreglo necesita 4–7 rondas ("…pero se derrumba, así que…").`);
|
|
26
|
+
return out;
|
|
27
|
+
},
|
|
28
|
+
},
|
|
29
|
+
{
|
|
30
|
+
id: 'recomendable',
|
|
31
|
+
nombre: 'Lo recomendable es X, y no [absurdo]',
|
|
32
|
+
para: 'humor con datos: consejos cotidianos contra la exageración más absurda (prensa de 500 t para un zancudo)',
|
|
33
|
+
esqueleto: ['"¿Sabías que para X lo recomendable es Y…"', '"…y no [la exageración con cifra]: [consecuencia absurda]"', 'Tres rondas iguales con objetos distintos (cifra en tarjeta cada vez)', 'Remate corto que vuelve al principio'],
|
|
34
|
+
gancho: '"¿Sabías que si quieres matar un zancudo, lo recomendable es usar la mano y no una prensa de 500 toneladas?"',
|
|
35
|
+
cierre: 'Remate de una línea ("La próxima, con cuchara") o "síguenos si quieres más así".',
|
|
36
|
+
duracion: [45, 60],
|
|
37
|
+
estilos: ['3d', 'datos-curiosos'],
|
|
38
|
+
piezas: 'subtítulo palabra con coloresClave, dato/chip con la cifra exagerada, sello para la consecuencia',
|
|
39
|
+
revisar: (v, texto) => ((texto.match(/\by no\b/gi) ?? []).length < 2 ? ['Fórmula "recomendable": repite "lo recomendable es… y no…" al menos 3 veces; la gracia está en el patrón.'] : []),
|
|
40
|
+
},
|
|
41
|
+
{
|
|
42
|
+
id: 'escalada',
|
|
43
|
+
nombre: 'Escalada absurda',
|
|
44
|
+
para: 'un objeto cotidiano que crece pieza por pieza porque cada solución causa otro problema (la mochila que termina con reactor)',
|
|
45
|
+
esqueleto: ['Problema cotidiano y su arreglo simple ("le pones dos ruedas")', '"Pero [alguien/algo] causa otro problema, así que le metes…"', 'Cada escena suma una pieza visible y más grande', 'Remate: "Felicidades, has inventado una mochila básica"'],
|
|
46
|
+
gancho: '"Tu mochila pesa más que tu abuela, así que le pones dos ruedas."',
|
|
47
|
+
cierre: '"Felicidades, has inventado [algo que ya existe]" y "envíaselo a tu amigo que…".',
|
|
48
|
+
duracion: [40, 55],
|
|
49
|
+
estilos: ['3d'],
|
|
50
|
+
piezas: 'Aparece (cae y rebota) por cada pieza en su palabra, Persona3D reaccionando, sello por golpe cómico',
|
|
51
|
+
revisar: (v, texto) => (!dice(texto, /así que|entonces/i) ? ['Fórmula "escalada": encadena con "así que…" / "entonces le…": cada arreglo provoca el siguiente.'] : []),
|
|
52
|
+
},
|
|
53
|
+
{
|
|
54
|
+
id: 'supervivencia',
|
|
55
|
+
nombre: 'Regla de supervivencia',
|
|
56
|
+
para: 'qué hacer ante un peligro con dos variantes que se confunden (oso negro / grizzly, medusa, rayo)',
|
|
57
|
+
esqueleto: ['"Si te pasa X: con uno haz A y con el otro nunca. Si te equivocas…"', 'Presenta el primero con cifras (peso, velocidad) y qué hacer', 'Presenta el segundo y por qué es al revés', 'Cómo distinguirlos (una sola seña visual)', 'Cierre en suspenso o pregunta'],
|
|
58
|
+
gancho: '"Si te ataca un oso, con uno tienes que hacerte el muerto, y con el otro, nunca."',
|
|
59
|
+
cierre: 'Suspenso ("porque cuando el próximo oso cargue contra ti…") o "comenta cuál sabías".',
|
|
60
|
+
duracion: [45, 60],
|
|
61
|
+
estilos: ['ilustrado', 'documental'],
|
|
62
|
+
piezas: 'chips de cifra pegados al animal, comparar/dividida A | B, contorno sobre la seña que los distingue',
|
|
63
|
+
},
|
|
64
|
+
{
|
|
65
|
+
id: 'experimento',
|
|
66
|
+
nombre: 'Experimento mental con contador',
|
|
67
|
+
para: 'dilemas y "¿qué pasa si…?" que se resuelven contando (un céntimo que se duplica, 10 años de un hábito)',
|
|
68
|
+
esqueleto: ['Dilema o pregunta con cifra ("¿Un millón ahora o un céntimo que se duplica 30 días?")', 'Casi todos eligen mal', 'Contador por días/años con saltos (Día 1, 7, 20…)', 'Giro en los últimos días', 'Resultado con número grande'],
|
|
69
|
+
gancho: '"Un millón de euros ahora mismo, o un céntimo que se duplica cada día durante 30 días."',
|
|
70
|
+
cierre: '"Así que ya sabes qué decir cuando alguien te pregunte" o pregunta A/B.',
|
|
71
|
+
duracion: [45, 60],
|
|
72
|
+
estilos: ['mesa', 'infografia', '3d'],
|
|
73
|
+
piezas: 'contador lcd, Pila que crece, calendario que avanza, rótulo pixel para el resultado',
|
|
74
|
+
revisar: (v, texto) => (!dice(texto, /\bd[ií]a\b|\baños?\b|\bmes(es)?\b/i) ? ['Fórmula "experimento": marca el tiempo en voz ("día siete…", "diez años después") y con un contador o calendario.'] : []),
|
|
75
|
+
},
|
|
76
|
+
{
|
|
77
|
+
id: 'misterio',
|
|
78
|
+
nombre: 'Misterio con giro',
|
|
79
|
+
para: 'historias reales o inventadas con un hecho raro que escala y se explica al final',
|
|
80
|
+
esqueleto: ['Hecho raro en una frase ("Un hombre se colaba cada noche y movía todo 2,5 cm")', 'Escala por días ("DÍA 8", "DÍA 12") con la tensión de quien lo vive', 'Consecuencia (venden la casa, llaman a la policía)', 'Giro final que reordena todo (el comprador era él)'],
|
|
81
|
+
gancho: '"Un hombre se colaba cada noche en una casa y movía cada objeto dos centímetros a la izquierda."',
|
|
82
|
+
cierre: 'El giro es el cierre; nada de "sígueme" largo.',
|
|
83
|
+
duracion: [50, 65],
|
|
84
|
+
estilos: ['historia', 'ilustrado'],
|
|
85
|
+
piezas: 'siluetas, calendario o tarjeta "DÍA 8", vigilancia, medida para los centímetros',
|
|
86
|
+
},
|
|
87
|
+
{
|
|
88
|
+
id: 'demuestralo',
|
|
89
|
+
nombre: 'Te lo demuestro',
|
|
90
|
+
para: 'algo que el espectador puede probar con su cuerpo o su casa en ese momento',
|
|
91
|
+
esqueleto: ['"Hay algo que tu cuerpo no puede hacer y te lo voy a demostrar"', 'Instrucciones paso a paso al espectador (pon, dobla, levanta)', 'Lo que pasa ("¿No sube?")', 'Explicación con rayos X / plano', '"Tu cuerpo viene así de fábrica"'],
|
|
92
|
+
gancho: '"Hay un dedo de tu mano que no puedes levantar, y te lo voy a demostrar."',
|
|
93
|
+
cierre: '"Envíaselo a un amigo y que lo intente."',
|
|
94
|
+
duracion: [30, 45],
|
|
95
|
+
estilos: ['ilustrado', 'explicativo'],
|
|
96
|
+
piezas: 'modo rayosx en la explicación, trazo/contorno sobre la parte, cierre comparte',
|
|
97
|
+
revisar: (v) => (!(v.scenes ?? []).some((s) => s.modo) ? ['Fórmula "demuéstralo": la explicación luce con `modo: \'rayosx\'` en la palabra donde se ve "por dentro".'] : []),
|
|
98
|
+
},
|
|
99
|
+
{
|
|
100
|
+
id: 'mito',
|
|
101
|
+
nombre: 'Mito desmentido con comparación',
|
|
102
|
+
para: 'creencias comunes que tienen truco (la cinta de correr "miente", el microondas, la sal)',
|
|
103
|
+
esqueleto: ['Afirmación tajante ("La cinta te miente")', 'Por qué (la física o la biología en una frase)', 'La prueba (estudio o medida)', 'Comparación lado a lado', 'Matiz honesto', 'CTA a quien cree el mito'],
|
|
104
|
+
gancho: '"Diez kilómetros en la cinta y diez en la calle: mismo reloj, y no hiciste lo mismo."',
|
|
105
|
+
cierre: '"Envíaselo a ese amigo que dice que…"',
|
|
106
|
+
duracion: [50, 65],
|
|
107
|
+
estilos: ['infografia', 'explicativo'],
|
|
108
|
+
piezas: 'dividida A | B, medidor, barras, sellos "TE MIENTE" / "PILLADO", modo plano',
|
|
109
|
+
},
|
|
110
|
+
{
|
|
111
|
+
id: 'roast',
|
|
112
|
+
nombre: 'Roast con analogía animal',
|
|
113
|
+
para: 'conductas de la gente explicadas con la biología de un animal (el amigo tacaño y el pavo real)',
|
|
114
|
+
esqueleto: ['Pregunta provocadora sobre un conocido ("¿Por qué tu amigo el rata…?")', '"Es lo mismo que le pasa a un [animal]"', 'Explicación biológica con imágenes del animal', 'Vuelta al amigo con la analogía', 'CTA para mandárselo a ese amigo'],
|
|
115
|
+
gancho: '"¿Por qué tu amigo el rata no te invita ni un café, pero a su novia le paga las vacaciones?"',
|
|
116
|
+
cierre: '"Envíaselo a tu amigo el rata y que esta vez el café lo pague él."',
|
|
117
|
+
duracion: [45, 60],
|
|
118
|
+
estilos: ['ilustrado', 'mesa'],
|
|
119
|
+
piezas: 'mismos objetos que vuelven (cartera, café), lugares distintos por tramo, cierre comparte',
|
|
120
|
+
revisar: (v) => {
|
|
121
|
+
const cta = (v.scenes ?? []).find((s) => s.type === 'cta');
|
|
122
|
+
return cta && cta.pide !== 'comparte' ? ['Fórmula "roast": cierra con `pide: \'comparte\'` y un destinatario concreto ("tu amigo el rata").'] : [];
|
|
123
|
+
},
|
|
124
|
+
},
|
|
125
|
+
{
|
|
126
|
+
id: 'serie',
|
|
127
|
+
nombre: 'Serie interactiva (elige tú)',
|
|
128
|
+
para: 'historias por capítulos donde el público decide qué pasa (llamadas del futuro, vuelos perdidos)',
|
|
129
|
+
esqueleto: ['Recuento de lo que votaron ("La mayoría de ustedes votó…") si no es el capítulo 1', 'El capítulo en tiempo real: hora en pantalla, teléfono, noticias', 'Escalada hasta una decisión', 'Cliffhanger con dos opciones A/B', '"Comenta tu opción; la más votada gana. Capítulo 3 mañana 15:30"'],
|
|
130
|
+
gancho: '"Anoche te llamó tu propio número y te dijo que no tomaras el metro."',
|
|
131
|
+
cierre: 'Capa `capitulo` con las dos opciones y el horario, o escena `eleccion`.',
|
|
132
|
+
duracion: [70, 115],
|
|
133
|
+
estilos: ['historia', 'serie'],
|
|
134
|
+
piezas: 'serie (pastilla fija), recuento, bloqueo, llamada, noticia, vigilancia, capitulo',
|
|
135
|
+
revisar: (v) => {
|
|
136
|
+
const ultima = (v.scenes ?? []).at(-1);
|
|
137
|
+
const tieneFinal = (v.scenes ?? []).some((s) => s.type === 'eleccion' || (s.capas ?? []).some((c) => c.tipo === 'capitulo'));
|
|
138
|
+
const out = [];
|
|
139
|
+
if (!tieneFinal) out.push('Fórmula "serie": termina con una decisión A/B (capa `capitulo` o escena `eleccion`): es lo que trae comentarios.');
|
|
140
|
+
if (!v.serie) out.push("Fórmula \"serie\": pon `serie: 'MI SERIE · CAP. 2'` para la pastilla fija arriba.");
|
|
141
|
+
if (ultima && ultima.type === 'cta' && !ultima.pide) out.push('Fórmula "serie": el cierre debe pedir que comenten su opción, no solo "sígueme".');
|
|
142
|
+
return out;
|
|
143
|
+
},
|
|
144
|
+
},
|
|
145
|
+
{
|
|
146
|
+
id: 'biografia',
|
|
147
|
+
nombre: 'Biografía con secreto',
|
|
148
|
+
para: 'la vida de una persona o empresa contada año por año, con un escándalo o secreto como gancho',
|
|
149
|
+
esqueleto: ['Gancho de escándalo ("Al jefe no lo descubrió su esposa: lo descubrieron los espías")', 'Origen: lugar y año (globo con pin)', 'Ascenso año por año (tarjetas de año, cintillos con nombres)', 'El secreto con pruebas (documentos, vigilancia)', 'Final abierto'],
|
|
150
|
+
gancho: '"Al jefe de [empresa] no lo descubrió su esposa: lo descubrieron los espías."',
|
|
151
|
+
cierre: 'Final abierto ("Le dijeron que no.") o "sígueme para la parte 2".',
|
|
152
|
+
duracion: [90, 180],
|
|
153
|
+
estilos: ['documental', '3d'],
|
|
154
|
+
piezas: 'lugares (un set por época), tierra con pin, fecha, cintillo, calendario, vigilancia, ficha',
|
|
155
|
+
revisar: (v) => (!v.lugares ? ['Fórmula "biografía": usa `lugares` (un set por época o sitio) y `lugar` por escena: un solo fondo se siente pobre en 2–3 minutos.'] : []),
|
|
156
|
+
},
|
|
157
|
+
];
|
|
158
|
+
|
|
159
|
+
export const IDS_FORMULA = FORMULAS.map((f) => f.id);
|
|
160
|
+
export const formulaDe = (id) => FORMULAS.find((f) => f.id === id) ?? null;
|
|
161
|
+
|
|
162
|
+
/** Texto de las fórmulas para el prompt de la IA. */
|
|
163
|
+
export function formulasParaIA(id) {
|
|
164
|
+
const lista = id ? [formulaDe(id)].filter(Boolean) : FORMULAS;
|
|
165
|
+
return lista
|
|
166
|
+
.map(
|
|
167
|
+
(f) =>
|
|
168
|
+
`### ${f.id}: ${f.nombre}\nPara: ${f.para}.\nEsqueleto:\n${f.esqueleto.map((p, i) => ` ${i + 1}. ${p}`).join('\n')}\nGancho de ejemplo: ${f.gancho}\nCierre: ${f.cierre}\nDuración: ${f.duracion[0]}–${f.duracion[1]} s.`,
|
|
169
|
+
)
|
|
170
|
+
.join('\n\n');
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/** Consejos propios de la fórmula del video (`formula: 'construiste'`). */
|
|
174
|
+
export function revisarFormula(v) {
|
|
175
|
+
const f = formulaDe(v?.formula);
|
|
176
|
+
if (!f?.revisar) return [];
|
|
177
|
+
const texto = (v.scenes ?? []).map((s) => s.say ?? '').join(' ');
|
|
178
|
+
return f.revisar(v, texto);
|
|
179
|
+
}
|
package/lib/guion.mjs
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
// Guiones: de un tema (la IA escribe todo) o de tu propio texto (la IA lo convierte en escenas,
|
|
2
2
|
// o sin IA: una escena por frase). El resultado es un video listo para editar en videos/<id>.json.
|
|
3
|
+
import {FORMULAS, IDS_FORMULA, formulaDe, formulasParaIA} from './formulas.mjs';
|
|
3
4
|
import fs from 'node:fs';
|
|
4
5
|
import os from 'node:os';
|
|
5
6
|
import path from 'node:path';
|
|
@@ -120,8 +121,17 @@ Además, en cualquier escena:
|
|
|
120
121
|
Varía los tipos. "at", "check" y "en" deben ser palabras que aparezcan tal cual en "say".
|
|
121
122
|
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
|
|
|
124
|
+
- "rotulo_estilo": cómo se ve el rótulo: "sello" (sello de goma inclinado para un golpe: "¡SIN LUZ!", "¡TE MIENTE!"),
|
|
125
|
+
"paso" (titular de paso con número en "rotulo_paso", para explicar en orden), "pixel" (caja de videojuego para
|
|
126
|
+
resultados: "RESULTADO", "-20% RIESGO"), "pastilla" (aviso: "¡CUIDADO!") o null (el del estilo).
|
|
127
|
+
- "modo": "rayosx" (ver por dentro: cuerpo, máquina), "plano" (cómo está hecho) u "holograma" (datos, cerebro) en la
|
|
128
|
+
escena que lo explica, con "modo_en" = la palabra donde cambia; null en las demás. Úsalo en 1–3 escenas como máximo.
|
|
129
|
+
|
|
123
130
|
${REGLAS_TEXTO}
|
|
124
131
|
|
|
132
|
+
Fórmulas de historia (elige la que mejor le quede al tema y escribe en "formula" su id; síguela de principio a fin):
|
|
133
|
+
${formulasParaIA()}
|
|
134
|
+
|
|
125
135
|
Diccionario de recursos por tema (usa estas palabras en "recurso"):
|
|
126
136
|
${Object.entries(listaTemas()).map(([t, v]) => `- ${t}: ${v.join(', ')}`).join('\n')}`;
|
|
127
137
|
|
|
@@ -153,10 +163,14 @@ export async function esquemaMotor() {
|
|
|
153
163
|
pregunta: z.string().nullable(),
|
|
154
164
|
opciones: z.array(z.object({texto: z.string(), sub: z.string(), objeto: z.enum(OBJETOS).nullable(), emoji: z.string().nullable()})).nullable(),
|
|
155
165
|
rotulo: z.string().nullable(),
|
|
166
|
+
rotulo_estilo: z.enum(['sello', 'paso', 'pixel', 'pastilla']).nullable(),
|
|
167
|
+
rotulo_paso: z.number().nullable(),
|
|
168
|
+
modo: z.enum(['rayosx', 'plano', 'holograma']).nullable(),
|
|
169
|
+
modo_en: z.string().nullable(),
|
|
156
170
|
claves: z.array(z.string()).nullable(),
|
|
157
171
|
});
|
|
158
172
|
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});
|
|
173
|
+
return z.object({id: z.string(), title: z.string(), formula: z.enum(IDS_FORMULA), music: z.enum(MUSICAS), scenes: z.array(escena), cierre});
|
|
160
174
|
}
|
|
161
175
|
|
|
162
176
|
/** De la salida de la IA (motor de capas) al formato de escenas de morfeo. */
|
|
@@ -165,6 +179,14 @@ export function aEscenaMotor(e, conImagenes) {
|
|
|
165
179
|
if (e.show && e.show !== e.say) out.show = e.show;
|
|
166
180
|
const si = (k, v) => v != null && v !== '' && (out[k] = v);
|
|
167
181
|
si('rotulo', e.rotulo);
|
|
182
|
+
if (e.rotulo && e.rotulo_estilo) {
|
|
183
|
+
out.rotuloEstilo = e.rotulo_estilo;
|
|
184
|
+
if (e.rotulo_estilo === 'paso' && e.rotulo_paso != null) out.rotuloPaso = e.rotulo_paso;
|
|
185
|
+
}
|
|
186
|
+
if (e.modo) {
|
|
187
|
+
out.modo = e.modo;
|
|
188
|
+
si('modoEn', e.modo_en);
|
|
189
|
+
}
|
|
168
190
|
if (e.claves?.length) out.claves = e.claves;
|
|
169
191
|
switch (e.type) {
|
|
170
192
|
case 'titular':
|
|
@@ -266,9 +288,13 @@ export async function guionConIA(o) {
|
|
|
266
288
|
? `Convierte este guion en escenas. Respeta su contenido y su orden; puedes partir frases largas.\n\n<guion>\n${o.texto}\n</guion>`
|
|
267
289
|
: `Escribe un video sobre: ${o.tema}`;
|
|
268
290
|
// ~45 s por defecto: los videos de referencia rondan el minuto y ninguna idea pasa de ~5 s (lib/estructura.mjs)
|
|
269
|
-
const
|
|
291
|
+
const forzada = o.formula ? formulaDe(o.formula) : null;
|
|
292
|
+
if (o.formula && !forzada) throw new ErrorMorfeo(`La fórmula "${o.formula}" no existe. Opciones: ${IDS_FORMULA.join(', ')}`);
|
|
293
|
+
// con fórmula, la duración es la de sus videos de referencia (el punto medio de su rango)
|
|
294
|
+
const dur = o.duracion ?? (forzada ? Math.round((forzada.duracion[0] + forzada.duracion[1]) / 2) : 45);
|
|
270
295
|
const extra = [`Idioma: ${idioma}.`, `Duración aproximada: ${dur} segundos (unas ${Math.round(dur / 4)} escenas de 3 a 5 segundos).`];
|
|
271
296
|
if (o.tono) extra.push(`Tono: ${o.tono}.`);
|
|
297
|
+
if (forzada) extra.push(`Fórmula: usa "${forzada.id}" (${forzada.nombre}) y su esqueleto tal cual.`);
|
|
272
298
|
const motor = o.estilo && o.estilo !== 'datos-curiosos';
|
|
273
299
|
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.'}`);
|
|
274
300
|
|
|
@@ -295,7 +321,7 @@ export async function guionConIA(o) {
|
|
|
295
321
|
const scenes = g.scenes.map((e) => aEscenaMotor(e, !!o.imagenes));
|
|
296
322
|
const c = g.cierre;
|
|
297
323
|
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]}};
|
|
324
|
+
return {id: slug(g.id || g.title), video: {estilo: o.estilo, formula: forzada?.id ?? g.formula, title: g.title, voice: o.voz ?? 'em_alex', music: g.music, scenes: [...scenes, cta]}};
|
|
299
325
|
}
|
|
300
326
|
const scenes = g.scenes.map(aEscena);
|
|
301
327
|
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/nuevo-video.mjs
CHANGED
|
@@ -5,7 +5,14 @@ import path from 'node:path';
|
|
|
5
5
|
import {ErrorMorfeo} from './config.mjs';
|
|
6
6
|
import {PAQUETE} from './entorno.mjs';
|
|
7
7
|
|
|
8
|
-
|
|
8
|
+
/** Bases disponibles: cada carpeta de bases/ con un guion.mjs (y un Mundo.tsx si lleva mundo propio). */
|
|
9
|
+
export const LOOKS = (() => {
|
|
10
|
+
try {
|
|
11
|
+
return fs.readdirSync(path.join(PAQUETE, 'bases')).filter((d) => fs.existsSync(path.join(PAQUETE, 'bases', d, 'guion.mjs'))).sort();
|
|
12
|
+
} catch {
|
|
13
|
+
return ['2d', '3d'];
|
|
14
|
+
}
|
|
15
|
+
})();
|
|
9
16
|
|
|
10
17
|
/** "mi-video_2" -> "MundoMiVideo2" */
|
|
11
18
|
export const nombreComponente = (id) =>
|
|
@@ -41,7 +48,7 @@ export function registrarEnConfig(texto, nombre, ruta) {
|
|
|
41
48
|
|
|
42
49
|
/**
|
|
43
50
|
* @param {{proyecto: string, id: string, look?: string, carpetaGuiones?: string}} o
|
|
44
|
-
* @returns {{guion: string, escena: string, componente: string}}
|
|
51
|
+
* @returns {{guion: string, escena: string | null, componente: string | null}}
|
|
45
52
|
*/
|
|
46
53
|
export function nuevoVideo({proyecto, id, look = '2d', carpetaGuiones}) {
|
|
47
54
|
if (!id || !/^[a-z0-9][a-z0-9-_]*$/i.test(id)) throw new ErrorMorfeo('Dame un id corto sin espacios: npx morfeo nuevo-video mi-video --look 2d');
|
|
@@ -52,12 +59,14 @@ export function nuevoVideo({proyecto, id, look = '2d', carpetaGuiones}) {
|
|
|
52
59
|
const dirGuiones = carpetaGuiones ?? path.join(proyecto, 'guiones');
|
|
53
60
|
const guion = path.join(dirGuiones, `${id}.mjs`);
|
|
54
61
|
const escena = path.join(proyecto, 'escenas', `${componente}.tsx`);
|
|
55
|
-
for (const f of [guion, escena]) if (fs.existsSync(f)) throw new ErrorMorfeo(`Ya existe ${path.relative(proyecto, f)}; elige otro id.`);
|
|
62
|
+
for (const f of [guion, escena]) if (f && fs.existsSync(f)) throw new ErrorMorfeo(`Ya existe ${path.relative(proyecto, f)}; elige otro id.`);
|
|
56
63
|
const base = path.join(PAQUETE, 'bases', look);
|
|
57
64
|
const poner = (s) => s.replaceAll('__ID__', id).replaceAll('__COMPONENTE__', componente);
|
|
58
65
|
fs.mkdirSync(dirGuiones, {recursive: true});
|
|
59
66
|
fs.mkdirSync(path.dirname(escena), {recursive: true});
|
|
60
67
|
fs.writeFileSync(guion, poner(fs.readFileSync(path.join(base, 'guion.mjs'), 'utf8')));
|
|
68
|
+
// bases sin mundo propio (solo capas): no hay componente que copiar ni registrar
|
|
69
|
+
if (!fs.existsSync(path.join(base, 'Mundo.tsx'))) return {guion, escena: null, componente: null};
|
|
61
70
|
fs.writeFileSync(escena, poner(fs.readFileSync(path.join(base, 'Mundo.tsx'), 'utf8')));
|
|
62
71
|
const nuevo = registrarEnConfig(fs.readFileSync(config, 'utf8'), componente, `./escenas/${componente}.tsx`);
|
|
63
72
|
if (nuevo) fs.writeFileSync(config, nuevo);
|
package/lib/palabras.mjs
CHANGED
|
@@ -70,7 +70,7 @@ export function revisarPalabras(v, d) {
|
|
|
70
70
|
avisos.push(`escena ${i + 1}, ${ruta}: "${p}" no está en ${s.show ? 'el subtítulo (`show`)' : 'lo que dice la voz'} ("${texto}"); se usará la mitad de la frase`);
|
|
71
71
|
});
|
|
72
72
|
// hud y mundo usan las palabras de todo el video
|
|
73
|
-
for (const [k, lista] of [['hud', v.hud], ['mundo', v.mundo]]) {
|
|
73
|
+
for (const [k, lista] of [['hud', v.hud], ['mundo', v.mundo], ...Object.entries(v.lugares ?? {}).map(([n, l]) => [`lugares.${n}`, l])]) {
|
|
74
74
|
const momentos = [];
|
|
75
75
|
momentosDe(lista ?? [], k, momentos);
|
|
76
76
|
for (const [ruta, p] of momentos) if (!laDice(p, todas)) avisos.push(`${ruta}: "${p}" no aparece en ninguna escena`);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "morfeo",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.4.0",
|
|
4
4
|
"description": "Genera videos verticales animados (Remotion + Three.js) con voz, música y subtítulos, desde un guion en código o escrito por IA.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "SEE LICENSE IN LICENSE",
|
package/python/preparar.py
CHANGED
|
@@ -264,7 +264,7 @@ def preparar(job):
|
|
|
264
264
|
subs = [dict(a=round(c[0][1], 3), b=round(c[-1][2], 3), w=[[x[0].upper(), round(x[1], 3), round(x[2], 3)] for x in c])
|
|
265
265
|
for ws in WORDS for c in trozos(ws)]
|
|
266
266
|
datos = dict(id=vid, titulo=v.get("title", vid), indice=job.get("indice", 0), duracion=round(sum(durs), 3),
|
|
267
|
-
estilo=v.get("estilo", "datos-curiosos"), tema=v.get("tema") or {}, hud=v.get("hud") or [], mundo=v.get("mundo") or [], serie=v.get("serie"),
|
|
267
|
+
estilo=v.get("estilo", "datos-curiosos"), tema=v.get("tema") or {}, hud=v.get("hud") or [], mundo=v.get("mundo") or [], lugares=v.get("lugares") or {}, serie=v.get("serie"),
|
|
268
268
|
audio=f"v/{vid}/mix.wav", whisper=reales, escenas=escenas, subtitulos=subs)
|
|
269
269
|
json.dump(datos, open(os.path.join(outdir, "datos.json"), "w", encoding="utf-8"), ensure_ascii=False, indent=1)
|
|
270
270
|
print(f" tiempos por palabra: {'Whisper' if reales else 'reparto por frase'} ({reales}/{len(TIM)} escenas)", flush=True)
|