morfeo 0.1.0 → 0.1.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # Morfeo
2
2
 
3
- > Galería de estilos y ejemplos: [github.com/UreckChan/morfeo](https://github.com/UreckChan/morfeo).
3
+ > **Documentación: [ureckchan.github.io/morfeo](https://ureckchan.github.io/morfeo/)** · Ejemplos: [github.com/UreckChan/morfeo](https://github.com/UreckChan/morfeo)
4
4
 
5
5
  Genera videos verticales animados (Shorts, TikTok, Reels) desde código: escribes las escenas y Morfeo agrega
6
6
  la voz, la música, los efectos de sonido, los subtítulos, las animaciones y la miniatura.
@@ -21,6 +21,7 @@ la voz, la música, los efectos de sonido, los subtítulos, las animaciones y la
21
21
 
22
22
  - **Voces:** Kokoro (gratis, en tu computadora, más de 50 voces y mezclas), ElevenLabs, OpenAI, tu propia
23
23
  grabación por escena o cualquier servicio que conectes con una función.
24
+ - **Objetos 2D y 3D:** una biblioteca con diccionario temático en español (`{tipo: 'recurso', buscar: 'tubería', estilo: '3d'}`) que busca en Kenney, Poly Haven e Iconify (miles de modelos e ilustraciones libres), más objetos 3D y piezas de construcción hechos en código (tuberías con agua, muros, vigas…) y escenarios ilustrados animados. Los emojis quedan como último recurso.
24
25
  - **Música:** 7 estilos generados sin derechos de autor, o tu propia pista (mp3, wav, flac, ogg). Baja sola cuando habla la voz.
25
26
  - **Guiones:** los escribes tú, o Claude escribe el video completo a partir de un tema o de tu texto.
26
27
  - **Subtítulos:** palabra por palabra; con Whisper quedan sincronizados con la voz real.
package/bin/morfeo.mjs CHANGED
@@ -22,6 +22,8 @@ Videos
22
22
  --gpu usa la tarjeta de video (más rápido; si falla, quítalo)
23
23
  studio Abre el editor en el navegador para ver y ajustar en vivo
24
24
  estilos Lista los estilos visuales disponibles
25
+ recursos temas Diccionario de objetos 2D/3D por temática
26
+ recursos buscar "x" Busca un objeto (--3d, --realista, --2d --variante clay)
25
27
 
26
28
  Guiones
27
29
  guion "tema" La IA (Claude) escribe un video completo sobre el tema
@@ -46,7 +48,7 @@ function opciones(argv) {
46
48
  const [k, v] = a.slice(2).split('=');
47
49
  const clave = k.replace(/-([a-z])/g, (_, c) => c.toUpperCase());
48
50
  const sig = argv[i + 1];
49
- const conValor = ['proyecto', 'desde', 'id', 'voz', 'duracion', 'idioma', 'estilo', 'tono', 'musica', 'titulo', 'modelo', 'puerto'];
51
+ const conValor = ['proyecto', 'desde', 'id', 'voz', 'duracion', 'idioma', 'estilo', 'tono', 'variante', 'fuente', 'musica', 'titulo', 'modelo', 'puerto'];
50
52
  if (v != null) op[clave] = v;
51
53
  else if (conValor.includes(clave) && sig != null && !sig.startsWith('--')) op[clave] = argv[++i];
52
54
  else op[clave] = true;
@@ -97,6 +99,29 @@ async function main() {
97
99
  const {studio} = await import('../lib/render.mjs');
98
100
  return studio({proyecto, puerto: op.puerto});
99
101
  }
102
+ case 'recursos':
103
+ case 'recurso': {
104
+ const [sub, ...resto] = pos;
105
+ const {buscarRecursos, VARIANTES_2D} = await import('../lib/recursos.mjs');
106
+ const {listaTemas, traducir} = await import('../lib/diccionario.mjs');
107
+ if (sub === 'temas' || !sub) {
108
+ for (const [t, terminos] of Object.entries(listaTemas())) console.log(` ${t.padEnd(14)} ${terminos.map((x) => x.replace(/_/g, ' ')).join(', ')}`);
109
+ console.log(`\nEstilos: 2d (variantes: ${Object.keys(VARIANTES_2D).join(', ')}), 3d, realista`);
110
+ console.log('Busca con: npx morfeo recursos buscar "tubería" --3d (o --2d --variante clay, o --realista)');
111
+ return;
112
+ }
113
+ if (sub !== 'buscar') throw new ErrorMorfeo('Uso: npx morfeo recursos temas | npx morfeo recursos buscar "término" [--2d|--3d|--realista] [--variante clay]');
114
+ const termino = resto.join(' ').trim();
115
+ if (!termino) throw new ErrorMorfeo('¿Qué busco? npx morfeo recursos buscar "tubería"');
116
+ const estilo = op['2d'] ? '2d' : op.realista ? 'realista' : '3d';
117
+ const t = traducir(termino);
118
+ console.log(`Buscando "${termino}" (${estilo}${op.variante ? ' ' + op.variante : ''}) → ${t.buscar.join(', ')}${t.tema ? ` · tema ${t.tema}` : ''}\n`);
119
+ const r = await buscarRecursos(termino, {estilo, variante: op.variante, fuente: op.fuente});
120
+ if (!r.length) return console.log('Sin resultados. Prueba otra palabra u otro estilo.');
121
+ r.slice(0, 15).forEach((x, i) => console.log(` ${String(i).padStart(2)} ${x.nombre.padEnd(30).slice(0, 30)} ${x.fuente.padEnd(10)} ${String(x.coleccion).padEnd(26).slice(0, 26)} ${x.licencia}`));
122
+ console.log(`\nÚsalo: {tipo: 'recurso', buscar: '${termino}', estilo: '${estilo}'${op.variante ? `, variante: '${op.variante}'` : ''}} (indice: n para elegir otro)`);
123
+ return;
124
+ }
100
125
  case 'estilos': {
101
126
  const {ESTILOS} = await import('../lib/config.mjs');
102
127
  const desc = {
package/index.d.ts CHANGED
@@ -23,6 +23,9 @@ interface EscenaBase {
23
23
 
24
24
  export type Estilo = 'datos-curiosos' | 'explicativo' | 'documental' | 'ilustrado' | 'historia' | 'mesa' | 'serie' | 'infografia' | '3d' | 'minimal' | (string & {});
25
25
 
26
+ /** Objetos 3D incluidos para capas {tipo: 'modelo', forma}: mejor opción que un emoji. */
27
+ export type Objeto3D = 'corazon' | 'estrella' | 'rayo' | 'diamante' | 'trofeo' | 'cohete' | 'foco' | 'reloj' | 'planeta' | 'arbol' | 'nube' | 'regalo' | 'llave' | 'candado' | 'casa' | 'libro' | 'globo' | 'medalla' | 'escudo' | 'pin' | 'tuberia' | 'muro' | 'viga' | 'columna' | 'escalera' | 'losa' | 'tanque' | 'suelo';
28
+
26
29
  /** Momento: segundos desde que empieza la escena, una palabra de `say` ("orcas"), "voz", "fin", o "palabra+0.3". */
27
30
  export type Momento = number | string;
28
31
  export type Entrada = 'escala' | 'rebote' | 'aparecer' | 'subir' | 'caer' | 'girar' | 'deslizar-izq' | 'deslizar-der' | 'deslizar-arriba' | 'deslizar-abajo' | 'ninguna';
@@ -74,7 +77,7 @@ export type Capa = CapaBase &
74
77
  | {tipo: 'marca'; tam?: number}
75
78
  | {tipo: 'particulas'; particulas?: 'burbujas' | 'polvo' | 'estrellas' | 'nieve' | 'confeti'; cantidad?: number; color?: string}
76
79
  | {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}
77
- | {tipo: 'modelo'; src?: string; forma?: 'moneda' | 'billete' | 'cubo' | 'esfera' | 'lingote'; 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}
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][]}
78
81
  | {tipo: 'componente'; nombre: string; props?: Record<string, unknown>}
79
82
  | {tipo: 'personaje'; nombre: string; pose?: string; poses?: {en: Momento; pose: string}[]; hablar?: boolean; ancho?: number}
80
83
  | {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}
@@ -87,13 +90,20 @@ export type Capa = CapaBase &
87
90
  | {tipo: 'onda'; barras?: number; ancho?: number; alto?: number; color?: string; siempre?: boolean}
88
91
  | {tipo: 'globo'; texto: string; cola?: 'abajo' | 'arriba' | 'izq' | 'der' | 'ninguna'; fondo?: string; color?: string; borde?: string; tam?: number; maxAncho?: number}
89
92
  | {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]}
94
+ | {tipo: 'escenario'; nombre: Escenario; parte?: 'fondo' | 'frente'; [opcion: string]: unknown}
95
+ | {tipo: 'ventana'; diametro?: number; src?: string; capas?: Capa[]; borde?: 'metal' | 'ninguno' | (string & {}); grosor?: number; remaches?: number | false; zoom?: number | [number, number]; fondo?: string}
90
96
  );
91
97
 
92
98
  export type FondoEscena =
93
99
  | {tipo: 'color'; color: string}
94
100
  | {tipo: 'degradado'; colores: string[]; angulo?: number}
95
101
  | {tipo: 'imagen'; src?: string; generar?: string; oscurecer?: number; desenfoque?: number}
96
- | {tipo: 'video'; src: string; oscurecer?: number};
102
+ | {tipo: 'video'; src: string; oscurecer?: number}
103
+ | {tipo: 'escenario'; nombre: Escenario; oscurecer?: number; [opcion: string]: unknown};
104
+
105
+ /** Escenarios ilustrados incluidos (animados). bosque: paneo, luna {cara, cantar, bajar, a}; cuarto: dia, parte; tunel: llegar; pasillo: golpe. */
106
+ export type Escenario = 'bosque' | 'cuarto' | 'departamento' | 'tunel' | 'pasillo';
97
107
 
98
108
  interface EscenaMotorBase extends EscenaBase {
99
109
  capas?: Capa[];
@@ -109,11 +119,11 @@ interface EscenaMotorBase extends EscenaBase {
109
119
  export type EscenaMotor =
110
120
  | (EscenaMotorBase & {type?: 'capas'})
111
121
  | (EscenaMotorBase & {type: 'titular'; texto: string; etiqueta?: string; numero?: number | string; sub?: string; y?: number; tam?: number})
112
- | (EscenaMotorBase & {type: 'dato'; valor: number; de?: number; prefijo?: string; sufijo?: string; decimales?: number; curva?: 'suave' | 'lineal' | 'exponencial'; formato?: 'grande' | 'lcd' | 'chip'; texto?: string; imagen?: string | {generar: string}; tam?: number; y?: number; en?: Momento; duracion?: number})
122
+ | (EscenaMotorBase & {type: 'dato'; valor: number; de?: number; prefijo?: string; sufijo?: string; decimales?: number; curva?: 'suave' | 'lineal' | 'exponencial'; formato?: 'grande' | 'lcd' | 'chip'; texto?: string; objeto?: Objeto3D; imagen?: string | {generar: string}; tam?: number; y?: number; en?: Momento; duracion?: number})
113
123
  | (EscenaMotorBase & {type: 'lista'; titulo?: string; cabecera?: string; items: (string | {texto: string; check?: Momento | boolean})[]; ancho?: number; y?: number})
114
124
  | (EscenaMotorBase & {type: 'telefono'; src?: string; capasPantalla?: Capa[]; texto?: string; ancho?: number; y?: number})
115
125
  | (EscenaMotorBase & {type: 'comparar'; a: string | {texto: string; imagen?: string; color?: string}; b: string | {texto: string; imagen?: string; color?: string}; at?: string})
116
- | (EscenaMotorBase & {type: 'eleccion'; pregunta?: string; opciones: (string | {texto: string; sub?: string; emoji?: string; icono?: Icono; imagen?: string; color?: string; fondo?: string; en?: Momento})[]; formato?: 'cartas' | 'botones'; comenta?: string | false; enComenta?: Momento; y?: number})
126
+ | (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})
117
127
  | (Partial<EscenaMotorBase> & {type: 'cta'});
118
128
 
119
129
  export interface Tema {
@@ -182,6 +192,8 @@ export interface Video {
182
192
  /** Idioma de Kokoro: "es", "en-us", "en-gb", "fr-fr", "it", "pt-br", "ja", "cmn", "hi". */
183
193
  lang?: string;
184
194
  scenes: Escena[];
195
+ /** Segundo del video para la miniatura (por defecto, cuando la primera escena ya está completa). Sale sin subtítulos. */
196
+ miniaturaEn?: number;
185
197
  /** Datos libres que se copian a salida/resumen.json. */
186
198
  meta?: Record<string, unknown>;
187
199
  }
@@ -221,6 +233,7 @@ export interface Config {
221
233
  musicas?: Record<string, Musica>;
222
234
  /** Carpeta de los .mp4 (por defecto ./salida). */
223
235
  salida?: string;
236
+ /** gl: por defecto 'angle' (GPU) en Mac con Apple Silicon y 'swangle' (CPU) en lo demás; si la GPU falla, reintenta con CPU. */
224
237
  render?: {crf?: number; gl?: 'swangle' | 'angle' | 'egl' | 'swiftshader' | 'vulkan'; navegador?: string | null; concurrencia?: number | null; miniatura?: boolean};
225
238
  }
226
239
 
package/lib/config.mjs CHANGED
@@ -5,16 +5,39 @@ import {pathToFileURL} from 'node:url';
5
5
 
6
6
  export const NOMBRES_CONFIG = ['morfeo.config.mjs', 'morfeo.config.js'];
7
7
 
8
+ // Voces de Kokoro v1.0 (archivo voices-v1.0.bin)
9
+ export const VOCES_KOKORO = 'af_alloy af_aoede af_bella af_heart af_jessica af_kore af_nicole af_nova af_river af_sarah af_sky am_adam am_echo am_eric am_fenrir am_liam am_michael am_onyx am_puck am_santa bf_alice bf_emma bf_isabella bf_lily bm_daniel bm_fable bm_george bm_lewis ef_dora em_alex em_santa ff_siwis hf_alpha hf_beta hm_omega hm_psi if_sara im_nicola jf_alpha jf_gongitsune jf_nezumi jf_tebukuro jm_kumo pf_dora pm_alex pm_santa zf_xiaobei zf_xiaoni zf_xiaoxiao zf_xiaoyi zm_yunjian zm_yunxi zm_yunxia zm_yunyang'.split(' ');
10
+
11
+ /** Las voces más parecidas a un nombre mal escrito (para sugerir). */
12
+ const parecidas = (nombre, lista, n = 3) => {
13
+ const d = (a, b) => {
14
+ const m = Array.from({length: a.length + 1}, (_, i) => [i, ...Array(b.length).fill(0)]);
15
+ for (let j = 1; j <= b.length; j++) m[0][j] = j;
16
+ for (let i = 1; i <= a.length; i++) for (let j = 1; j <= b.length; j++) m[i][j] = Math.min(m[i - 1][j] + 1, m[i][j - 1] + 1, m[i - 1][j - 1] + (a[i - 1] === b[j - 1] ? 0 : 1));
17
+ return m[a.length][b.length];
18
+ };
19
+ return [...lista].sort((a, b) => d(nombre, a) - d(nombre, b)).slice(0, n);
20
+ };
21
+
8
22
  export const TIPOS_CLASICOS = ['title', 'year', 'text', 'icon', 'compare', 'card', 'dialog', 'aliens', 'memory', 'morph', 'cta'];
9
23
  export const TIPOS_MOTOR = ['capas', 'titular', 'dato', 'lista', 'telefono', 'comparar', 'eleccion', 'cta'];
10
24
  export const TIPOS_ESCENA = [...new Set([...TIPOS_CLASICOS, ...TIPOS_MOTOR])];
11
25
  export const ESTILOS = ['datos-curiosos', 'explicativo', 'documental', 'ilustrado', 'historia', 'mesa', 'serie', 'infografia', '3d', 'minimal'];
12
- 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'];
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'];
27
+ export const ESCENARIOS = ['bosque', 'cuarto', 'departamento', 'tunel', 'pasillo'];
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'];
13
29
  export const PROVEEDORES_VOZ = ['kokoro', 'elevenlabs', 'openai'];
14
30
  export const MUSICAS = ['lofi', 'lofi2', 'chillpop', 'synth', 'house', 'ballad', 'trap'];
15
31
 
16
32
  export class ErrorMorfeo extends Error {}
17
33
 
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.
38
+ */
39
+ export const glPorDefecto = () => (process.platform === 'darwin' && process.arch === 'arm64' ? 'angle' : 'swangle');
40
+
18
41
  /** Una carpeta de guiones: cada <id>.json o <id>.mjs es un video. */
19
42
  async function leerCarpeta(dir) {
20
43
  const out = {};
@@ -84,7 +107,8 @@ export async function cargarProyecto(proyecto = process.cwd()) {
84
107
  const errores = [];
85
108
  const estilosPropios = cfg.estilos ?? {};
86
109
  const estilo = cfg.estilo ?? 'datos-curiosos';
87
- const ctx = {proyecto, marca, voces: cfg.voces ?? {}, musicas: cfg.musicas ?? {}, errores, estilo, estilosPropios};
110
+ const assetsDir = path.resolve(proyecto, cfg.assets ?? 'assets');
111
+ const ctx = {proyecto, marca, voces: cfg.voces ?? {}, musicas: cfg.musicas ?? {}, errores, estilo, estilosPropios, assetsDir, personajes: cfg.personajes ?? {}};
88
112
  if (!ESTILOS.includes(estilo) && !estilosPropios[estilo]) errores.push(`estilo "${estilo}" no existe. Opciones: ${[...ESTILOS, ...Object.keys(estilosPropios)].join(', ')}`);
89
113
  const componentes = {};
90
114
  for (const [nombre, ruta] of Object.entries(cfg.componentes ?? {})) {
@@ -125,14 +149,53 @@ export async function cargarProyecto(proyecto = process.cwd()) {
125
149
  componentes,
126
150
  render: {
127
151
  crf: r.crf ?? 18,
128
- gl: process.env.MORFEO_GL || process.env.REMOTION_GL || r.gl || 'swangle',
152
+ gl: process.env.MORFEO_GL || process.env.REMOTION_GL || r.gl || glPorDefecto(),
129
153
  navegador: process.env.MORFEO_NAVEGADOR || r.navegador || null,
130
- concurrencia: r.concurrencia ?? null,
154
+ concurrencia: r.concurrencia ?? null, // null = Morfeo elige según la máquina y el modo (ver render.mjs)
131
155
  miniatura: r.miniatura ?? true,
132
156
  },
133
157
  };
134
158
  }
135
159
 
160
+ /** Capas {tipo: 'personaje'} que piden un personaje (o una pose) que no está en el config. */
161
+ function revisarPersonajes(x, personajes, err) {
162
+ if (Array.isArray(x)) return x.forEach((v) => revisarPersonajes(v, personajes, err));
163
+ if (!x || typeof x !== 'object') return;
164
+ if (x.tipo === 'personaje') {
165
+ const def = personajes[x.nombre];
166
+ if (!def) err(`el personaje "${x.nombre}" no existe. Defínelo en morfeo.config.mjs → personajes. Hay: ${Object.keys(personajes).join(', ') || 'ninguno'}`);
167
+ else {
168
+ const poses = Object.keys(def.poses ?? {});
169
+ for (const p of [x.pose, ...(x.poses ?? []).map((k) => k?.pose)].filter(Boolean))
170
+ if (!poses.includes(p)) err(`el personaje "${x.nombre}" no tiene la pose "${p}". Tiene: ${poses.join(', ')}`);
171
+ }
172
+ }
173
+ for (const v of Object.values(x)) if (v && typeof v === 'object') revisarPersonajes(v, personajes, err);
174
+ }
175
+
176
+ /** Fondos y capas {tipo: 'escenario'} con un nombre que no existe. */
177
+ function revisarEscenarios(x, err) {
178
+ if (Array.isArray(x)) return x.forEach((v) => revisarEscenarios(v, err));
179
+ if (!x || typeof x !== 'object') return;
180
+ if (x.tipo === 'escenario' && !ESCENARIOS.includes(x.nombre)) err(`el escenario "${x.nombre}" no existe. Hay: ${ESCENARIOS.join(', ')}`);
181
+ if (x.tipo === 'modelo' && x.forma && !x.src && !FORMAS_3D.includes(x.forma)) err(`la forma 3D "${x.forma}" no existe. Hay: ${FORMAS_3D.join(', ')}`);
182
+ if (x.objeto && typeof x.objeto === 'string' && !FORMAS_3D.includes(x.objeto)) err(`el objeto 3D "${x.objeto}" no existe. Hay: ${FORMAS_3D.join(', ')}`);
183
+ for (const v of Object.values(x)) if (v && typeof v === 'object') revisarEscenarios(v, err);
184
+ }
185
+
186
+ const CAMPOS_ARCHIVO = new Set(['src', 'imagen']);
187
+ /** Busca `src`/`imagen` que apunten a assets/ y avisa de los que no existen. */
188
+ function revisarArchivos(x, assetsDir, err, ruta = '') {
189
+ if (Array.isArray(x)) return x.forEach((v, i) => revisarArchivos(v, assetsDir, err, `${ruta}[${i}]`));
190
+ if (!x || typeof x !== 'object') return;
191
+ for (const [k, v] of Object.entries(x)) {
192
+ if (CAMPOS_ARCHIVO.has(k) && typeof v === 'string' && !/^(https?:|data:)/.test(v)) {
193
+ const rel = v.replace(/^\.?\/?(assets\/)?/, '');
194
+ if (!fs.existsSync(path.join(assetsDir, rel))) err(`no existe assets/${rel} (lo pide ${ruta.replace(/^\./, '') || 'una capa'}.${k}). Ponlo en la carpeta assets/ o usa generar: 'descripción' para la IA`);
195
+ } else if (v && typeof v === 'object') revisarArchivos(v, assetsDir, err, `${ruta}.${k}`);
196
+ }
197
+ }
198
+
136
199
  function validarCapas(capas, donde, err) {
137
200
  if (!Array.isArray(capas)) return err(`${donde}: \`capas\` debe ser una lista`);
138
201
  capas.forEach((c, i) => {
@@ -140,6 +203,10 @@ function validarCapas(capas, donde, err) {
140
203
  if (!c || typeof c !== 'object') return err(`${d} debe ser un objeto`);
141
204
  if (!TIPOS_CAPA.includes(c.tipo)) return err(`${d}: tipo "${c.tipo}" no existe. Opciones: ${TIPOS_CAPA.join(', ')}`);
142
205
  if ((c.tipo === 'imagen' || c.tipo === 'video') && !c.src && !c.generar) err(`${d}: falta \`src\` (archivo en assets/) o \`generar\` (descripción para la IA)`);
206
+ if (c.tipo === 'recurso') {
207
+ if (!c.buscar) err(`${d}: falta \`buscar\` (qué objeto: 'tubería', 'casa', 'pizza'...)`);
208
+ if (c.estilo && !['2d', '3d', 'realista'].includes(c.estilo)) err(`${d}: estilo "${c.estilo}" no existe. Usa '2d', '3d' o 'realista'`);
209
+ }
143
210
  if (c.capas) validarCapas(c.capas, d, err);
144
211
  if (c.capa) validarCapas([c.capa], d, err);
145
212
  });
@@ -178,7 +245,13 @@ function normalizarVoz(voz, ctx, err, profundidad = 0) {
178
245
  if (!/\.npy$/i.test(abs)) err(`la voz ${ref} debe ser un archivo .npy (estilo de voz Kokoro, forma 510×1×256)`);
179
246
  else if (!fs.existsSync(abs)) err(`no existe el archivo de voz ${ref}`);
180
247
  out.push([abs, peso]);
181
- } else out.push([ref, peso]);
248
+ } else {
249
+ if (!VOCES_KOKORO.includes(ref)) {
250
+ const alias = Object.keys(ctx.voces);
251
+ err(`la voz "${ref}" no existe. ¿Quisiste decir ${parecidas(ref, [...alias, ...VOCES_KOKORO]).map((x) => `"${x}"`).join(', ')}? En español: em_alex, ef_dora, em_santa`);
252
+ }
253
+ out.push([ref, peso]);
254
+ }
182
255
  }
183
256
  return out;
184
257
  }
@@ -211,7 +284,8 @@ function validarVideo(id, v, ctx) {
211
284
  const scenes = v.scenes.map((s0, i) => {
212
285
  const n = `escena ${i + 1}`;
213
286
  if (!s0 || typeof s0 !== 'object') return err(`${n} debe ser un objeto`), s0;
214
- const s = {...s0, type: s0.type ?? (s0.capas ? 'capas' : undefined)};
287
+ // sin `type`: escena de capas (con o sin capas: el fondo y los subtítulos mientras habla)
288
+ const s = {...s0, type: s0.type ?? 'capas'};
215
289
  if (!permitidos.includes(s.type))
216
290
  err(
217
291
  TIPOS_CLASICOS.includes(s.type)
@@ -237,5 +311,9 @@ function validarVideo(id, v, ctx) {
237
311
  if (v.voice != null) voice = normalizarVoz(v.voice, ctx, err);
238
312
  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)');
239
313
  if (v.hud != null) validarCapas(v.hud, 'hud', err);
314
+ // 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);
317
+ revisarEscenarios({scenes, hud: v.hud, tema: v.tema}, err);
240
318
  return {...v, estilo, voice, music: normalizarMusica(v.music, ctx, err), scenes};
241
319
  }
@@ -0,0 +1,129 @@
1
+ // Diccionario temático: términos en español → búsquedas en inglés para las bibliotecas de recursos.
2
+ // Sirve para {tipo: 'recurso', buscar: 'tubería'} y para que la IA elija ilustraciones según la temática.
3
+ // Cada término puede tener varias búsquedas (de la más específica a la más general).
4
+ // Si un término no está, se busca tal cual (y en inglés si viene en inglés).
5
+
6
+ export const TEMAS = {
7
+ objetos: {
8
+ barril: ['barrel'], caja: ['box', 'crate'], bolsa: ['bag'], maleta: ['suitcase'], paraguas: ['umbrella'], sombrero: ['hat'], gorra: ['cap'],
9
+ lentes: ['glasses'], zapato: ['shoe'], camisa: ['shirt'], reloj_de_pulsera: ['watch'], anillo: ['ring'], bolso: ['handbag', 'bag'], cubeta: ['bucket'],
10
+ escoba: ['broom'], tijeras: ['scissors'], cuerda: ['rope'], cadena: ['chain'], iman: ['magnet'], brujula: ['compass'], mapa: ['map'], sobre: ['envelope'],
11
+ carta_correo: ['letter', 'envelope'], paquete: ['package', 'parcel', 'box'], etiqueta: ['tag', 'label'], campana: ['bell'], trompeta: ['trumpet'],
12
+ guitarra: ['guitar'], piano: ['piano'], tambor: ['drum'], pincel: ['paintbrush', 'brush'], pintura: ['paint'], cuadro: ['painting', 'picture frame'],
13
+ vela_aromatica: ['candle'], jarron: ['vase'], maceta: ['pot', 'plant pot'], tetera: ['teapot'], olla: ['pot', 'cooking pot'], sarten: ['pan', 'frying pan'],
14
+ cuchara: ['spoon'], tenedor: ['fork'], frasco: ['jar'], lata: ['can'], extintor: ['fire extinguisher'], botiquin: ['first aid kit'], escalera_mano: ['ladder'],
15
+ silla_ruedas: ['wheelchair'], muleta: ['crutch'], carrito: ['cart', 'shopping cart'], tienda_campana: ['tent'], fogata: ['campfire'], hacha: ['axe'],
16
+ arco: ['bow'], flecha: ['arrow'], bote_basura: ['trash can', 'bin'], periodico: ['newspaper'], revista: ['magazine'], boleto: ['ticket'], pasaporte: ['passport'],
17
+ },
18
+ hogar: {
19
+ casa: ['house', 'home'], cuarto: ['bedroom', 'room'], cama: ['bed'], sillon: ['sofa', 'couch', 'armchair'], sofa: ['sofa', 'couch'],
20
+ silla: ['chair'], mesa: ['table'], escritorio: ['desk'], lampara: ['lamp'], librero: ['bookcase', 'shelf'], refrigerador: ['fridge', 'refrigerator'],
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'],
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
+ },
25
+ construccion: {
26
+ tuberia: ['pipe', 'pipes'], tubo: ['pipe'], valvula: ['valve'], llave_de_agua: ['faucet', 'tap'], drenaje: ['drain', 'sewer'], manguera: ['hose'],
27
+ ladrillo: ['brick'], muro: ['wall', 'brick wall'], pared: ['wall'], viga: ['beam', 'girder'], columna: ['column', 'pillar'], escalera: ['ladder', 'stairs'],
28
+ andamio: ['scaffold'], cemento: ['cement', 'concrete'], concreto: ['concrete'], grua: ['crane'], excavadora: ['excavator', 'digger'], casco: ['helmet', 'hard hat'],
29
+ martillo: ['hammer'], taladro: ['drill'], llave_inglesa: ['wrench'], desarmador: ['screwdriver'], sierra: ['saw'], pala: ['shovel'], carretilla: ['wheelbarrow'],
30
+ cono: ['traffic cone', 'cone'], barrera: ['barrier'], tunel: ['tunnel'], puente: ['bridge'], tanque: ['tank'], cable: ['cable', 'wire'], tornillo: ['screw', 'bolt'],
31
+ molino: ['windmill', 'building mill', 'mill'], granja: ['farm', 'barn'], granero: ['barn'], pozo: ['well'], muelle: ['dock', 'pier'], mina: ['mine'], castillo_piedra: ['castle'],
32
+ herramienta: ['tool', 'toolbox'], caja_de_herramientas: ['toolbox'], valla: ['fence'], cerca: ['fence'],
33
+ },
34
+ ciudad: {
35
+ edificio: ['building', 'skyscraper'], rascacielos: ['skyscraper'], tienda: ['shop', 'store'], calle: ['road', 'street'], semaforo: ['traffic light'],
36
+ farola: ['street light', 'lamp post'], banca: ['bench'], parada: ['bus stop'], hidrante: ['fire hydrant', 'hydrant'], basura: ['trash can', 'garbage'],
37
+ buzon: ['mailbox'], hospital_edificio: ['hospital'], escuela_edificio: ['school'], iglesia: ['church'], mercado: ['market'], torre: ['tower'], fuente: ['fountain'], letrero: ['sign', 'billboard'], estacion: ['station'], metro: ['subway', 'train'],
38
+ },
39
+ transporte: {
40
+ auto: ['car'], carro: ['car'], coche: ['car'], camion: ['truck'], autobus: ['bus'], ambulancia: ['ambulance'], patrulla: ['police car', 'police'],
41
+ bomberos: ['firetruck', 'fire truck'], taxi: ['taxi'], moto: ['motorcycle', 'motorbike'], bicicleta: ['bicycle', 'bike'], tren: ['train'], avion: ['airplane', 'plane'],
42
+ barco: ['boat', 'ship'], cohete: ['rocket'], helicoptero: ['helicopter'], submarino: ['submarine'],
43
+ },
44
+ naturaleza: {
45
+ arbol: ['tree'], pino: ['pine tree', 'pine'], flor: ['flower'], pasto: ['grass'], roca: ['rock', 'stone'], piedra: ['stone', 'rock'], montana: ['mountain'],
46
+ hongo: ['mushroom'], hoja: ['leaf'], tronco: ['log', 'tree trunk'], arbusto: ['bush', 'shrub'], cactus: ['cactus'], nube: ['cloud'], sol: ['sun'], luna: ['moon'],
47
+ lluvia: ['rain'], rayo: ['lightning', 'thunder'], fuego: ['fire', 'campfire'], agua: ['water'], rio: ['river'], lago: ['lake'], isla: ['island'], volcan: ['volcano'],
48
+ },
49
+ oceano: {
50
+ ballena: ['whale'], tiburon: ['shark'], pez: ['fish'], pulpo: ['octopus'], medusa: ['jellyfish'], delfin: ['dolphin'], tortuga: ['turtle'], cangrejo: ['crab'],
51
+ concha: ['shell', 'seashell'], coral: ['coral'], ancla: ['anchor'], faro: ['lighthouse'], burbuja: ['bubble'],
52
+ },
53
+ animales: {
54
+ perro: ['dog'], gato: ['cat'], oso: ['bear'], leon: ['lion'], tigre: ['tiger'], elefante: ['elephant'], caballo: ['horse'], vaca: ['cow'], cerdo: ['pig'],
55
+ oveja: ['sheep'], gallina: ['chicken'], pato: ['duck'], conejo: ['rabbit', 'bunny'], raton: ['mouse'], mono: ['monkey'], pajaro: ['bird'], buho: ['owl'],
56
+ serpiente: ['snake'], arana: ['spider'], abeja: ['bee'], mariposa: ['butterfly'], dinosaurio: ['dinosaur'], zorro: ['fox'], lobo: ['wolf'], pinguino: ['penguin'],
57
+ },
58
+ comida: {
59
+ manzana: ['apple'], platano: ['banana'], pizza: ['pizza'], hamburguesa: ['burger', 'hamburger'], papas: ['fries', 'french fries'], pan: ['bread'],
60
+ queso: ['cheese'], huevo: ['egg'], pastel: ['cake'], galleta: ['cookie'], helado: ['ice cream'], cafe: ['coffee', 'coffee cup'], taza: ['cup', 'mug'],
61
+ dona: ['donut', 'doughnut'], tacos: ['taco'], sandia: ['watermelon'], uva: ['grapes'], fresa: ['strawberry'], zanahoria: ['carrot'], tomate: ['tomato'],
62
+ carne: ['meat', 'steak'], pollo: ['chicken leg', 'chicken'], pescado: ['fish'], sopa: ['soup', 'bowl'], ramen: ['ramen', 'noodles'], refresco: ['soda', 'can'],
63
+ botella: ['bottle'], vaso: ['glass'], plato: ['plate'], cubiertos: ['cutlery', 'fork'],
64
+ },
65
+ dinero: {
66
+ dinero: ['money', 'cash'], moneda: ['coin'], billete: ['banknote', 'money'], cartera: ['wallet'], banco: ['bank'], tarjeta: ['credit card'], alcancia: ['piggy bank'],
67
+ oro: ['gold', 'gold bar'], diamante: ['diamond', 'gem'], grafica: ['chart', 'graph'], cofre: ['chest', 'treasure chest'], tesoro: ['treasure'], caja_fuerte: ['safe'],
68
+ },
69
+ tecnologia: {
70
+ computadora: ['computer', 'laptop'], laptop: ['laptop'], celular: ['phone', 'smartphone'], telefono: ['phone'], robot: ['robot'], chip: ['chip', 'microchip'],
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'],
73
+ wifi: ['wifi'], nube_de_datos: ['cloud computing', 'cloud'], codigo: ['code'], teclado: ['keyboard'], raton_pc: ['computer mouse'],
74
+ },
75
+ espacio: {
76
+ planeta: ['planet'], estrella: ['star'], astronauta: ['astronaut'], ovni: ['ufo'], galaxia: ['galaxy'], meteorito: ['meteor', 'asteroid'], telescopio: ['telescope'],
77
+ nave: ['spaceship', 'spacecraft'], tierra: ['earth', 'globe'], marte: ['mars'], cometa: ['comet'],
78
+ },
79
+ cuerpo: {
80
+ corazon: ['heart'], cerebro: ['brain'], pulmon: ['lungs'], hueso: ['bone'], craneo: ['skull'], diente: ['tooth'], ojo: ['eye'], mano: ['hand'], musculo: ['muscle'],
81
+ sangre: ['blood', 'blood drop'], adn: ['dna'], virus: ['virus'], bacteria: ['bacteria'], pastilla: ['pill', 'medicine'], jeringa: ['syringe'], hospital: ['hospital'],
82
+ doctor: ['doctor'], termometro: ['thermometer'], estetoscopio: ['stethoscope'], cama_de_hospital: ['hospital bed'],
83
+ },
84
+ escuela: {
85
+ libro: ['book'], cuaderno: ['notebook'], lapiz: ['pencil'], pluma: ['pen'], mochila: ['backpack'], pizarron: ['blackboard', 'chalkboard'], regla: ['ruler'],
86
+ calculadora: ['calculator'], globo_terraqueo: ['globe'], microscopio: ['microscope'], matraz: ['flask', 'lab flask'], diploma: ['diploma'], birrete: ['graduation cap'],
87
+ },
88
+ deporte: {
89
+ balon: ['ball', 'football'], futbol: ['soccer ball', 'football'], basquetbol: ['basketball'], beisbol: ['baseball'], tenis: ['tennis'], trofeo: ['trophy'],
90
+ medalla: ['medal'], pesa: ['dumbbell'], bicicleta_estatica: ['exercise bike'], caminadora: ['treadmill'], silbato: ['whistle'], cronometro: ['stopwatch'],
91
+ golf: ['golf'], patineta: ['skateboard'], carrera: ['race', 'racing'],
92
+ },
93
+ misterio: {
94
+ fantasma: ['ghost'], calavera: ['skull'], tumba: ['grave', 'tombstone'], ataud: ['coffin'], vela: ['candle'], linterna: ['lantern', 'flashlight'], lupa: ['magnifying glass'],
95
+ huella: ['footprint', 'fingerprint'], cuchillo: ['knife'], arana_red: ['spider web'], murcielago: ['bat'], calabaza: ['pumpkin'], castillo: ['castle'], llave_antigua: ['old key', 'key'],
96
+ },
97
+ juegos: {
98
+ videojuego: ['video game', 'gamepad'], dado: ['dice'], carta: ['playing card', 'card'], ajedrez: ['chess'], moneda_juego: ['coin'], corona: ['crown'], espada: ['sword'],
99
+ escudo: ['shield'], cofre_juego: ['chest'], gema: ['gem', 'crystal'], pocion: ['potion'], bandera: ['flag'], meta: ['finish line', 'flag'],
100
+ },
101
+ fiestas: {
102
+ regalo: ['gift', 'present'], globo: ['balloon'], pastel_cumpleanos: ['birthday cake', 'cake'], arbol_navidad: ['christmas tree'], nieve: ['snow', 'snowman'],
103
+ munneco_nieve: ['snowman'], fuegos_artificiales: ['fireworks'], confeti: ['confetti'], mascara: ['mask'],
104
+ },
105
+ };
106
+
107
+ /** Quita acentos, ñ → n, minúsculas y espacios → _ (las claves del diccionario están así). */
108
+ export const clave = (s) =>
109
+ String(s)
110
+ .normalize('NFD')
111
+ .replace(/[̀-ͯ]/g, '')
112
+ .toLowerCase()
113
+ .trim()
114
+ .replace(/\s+/g, '_');
115
+
116
+ const INDICE = new Map();
117
+ for (const [tema, terminos] of Object.entries(TEMAS)) for (const [t, q] of Object.entries(terminos)) if (!INDICE.has(t)) INDICE.set(t, {tema, termino: t, buscar: q});
118
+
119
+ /** Búsquedas en inglés para un término en español (o el término tal cual si no está). */
120
+ export function traducir(termino) {
121
+ const k = clave(termino);
122
+ if (INDICE.has(k)) return INDICE.get(k);
123
+ // plural simple: "tuberías" → "tuberia", "árboles" → "arbol"
124
+ for (const s of [k.replace(/es$/, ''), k.replace(/s$/, '')]) if (INDICE.has(s)) return INDICE.get(s);
125
+ return {tema: null, termino: k, buscar: [String(termino).trim()]};
126
+ }
127
+
128
+ /** Lista de temas con sus términos (para `morfeo recursos temas` y para la IA). */
129
+ export const listaTemas = () => Object.fromEntries(Object.entries(TEMAS).map(([t, v]) => [t, Object.keys(v)]));
package/lib/guion.mjs CHANGED
@@ -4,6 +4,7 @@ import fs from 'node:fs';
4
4
  import os from 'node:os';
5
5
  import path from 'node:path';
6
6
  import {ErrorMorfeo, MUSICAS} from './config.mjs';
7
+ import {listaTemas} from './diccionario.mjs';
7
8
 
8
9
  export const MODELO_GUION = 'claude-opus-5-5';
9
10
  export const TIPOS_IA = ['title', 'year', 'text', 'icon', 'compare', 'card', 'dialog'];
@@ -13,14 +14,18 @@ export const ICONOS = [
13
14
  ];
14
15
  const COLORES = {blanco: '#FFFFFF', amarillo: '#FFD400', rojo: '#FF4050', verde: '#5ADC6E', cian: '#50DCFF'};
15
16
 
16
- export const slug = (s) =>
17
- s
17
+ /** id corto para el archivo: sin acentos, en minúsculas, hasta ~38 letras y sin cortar palabras. */
18
+ export const slug = (s) => {
19
+ const base = s
18
20
  .normalize('NFD')
19
- .replace(/[̀-ͯ]/g, '')
21
+ .replace(/[\u0300-\u036f]/g, '')
20
22
  .toLowerCase()
21
23
  .replace(/[^a-z0-9]+/g, '-')
22
- .replace(/^-+|-+$/g, '')
23
- .slice(0, 40) || 'video';
24
+ .replace(/^-+|-+$/g, '');
25
+ if (base.length <= 38) return base || 'video';
26
+ const corto = base.slice(0, 39);
27
+ return corto.slice(0, Math.max(corto.lastIndexOf('-'), 12)).replace(/-+$/, '') || 'video';
28
+ };
24
29
 
25
30
  const INSTRUCCIONES = `Eres guionista de videos cortos verticales (YouTube Shorts / TikTok / Reels) de 25 a 45 segundos.
26
31
  Escribes en el idioma que se te pida, con frases cortas, naturales y fáciles de decir en voz alta.
@@ -83,6 +88,9 @@ export async function esquema() {
83
88
  });
84
89
  }
85
90
 
91
+ // objetos 3D del catálogo de Morfeo (remotion/motor/objetos3d.tsx)
92
+ const OBJETOS = ['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'];
93
+
86
94
  const ESTILOS_MOTOR = ['explicativo', 'documental', 'ilustrado', 'historia', 'mesa', 'minimal'];
87
95
 
88
96
  const INSTRUCCIONES_MOTOR = `Eres guionista y director de arte de videos cortos verticales (YouTube Shorts / TikTok / Reels) de 25 a 60 segundos.
@@ -95,11 +103,19 @@ Tipos de escena:
95
103
  - dato: un número que cuenta hasta "valor" (con "prefijo"/"sufijo", p. ej. sufijo " m"), "texto" corto debajo. "curva": "exponencial" si crece muchísimo.
96
104
  - lista: tarjeta con "titulo" y 2-4 "items"; cada item se palomea cuando la voz dice su palabra "check".
97
105
  - comparar: "a" contra "b" (textos cortos); "at" es la palabra de "say" en la que aparece "b".
98
- - capas: una ilustración. Usa "emoji" (un emoji grande que represente la idea) o, si se pide arte, "imagen_ia" (descripción breve de UN objeto o personaje aislado),
106
+ - capas: una ilustración, en este orden de preferencia:
107
+ 1. "recurso": un objeto concreto de la biblioteca (palabra del diccionario de abajo, según la temática del video), con "recurso_estilo"
108
+ ("3d" low-poly, "realista" o "2d"); usa el mismo estilo en todo el video.
109
+ 2. "objeto": un objeto 3D simbólico del catálogo (corazón, trofeo, cohete...).
110
+ 3. "imagen_ia" si se pide arte (descripción breve de UN objeto o personaje aislado).
111
+ 4. "emoji" SOLO si nada de lo anterior encaja (es el último recurso),
99
112
  más un "chip" opcional (dato corto como "×5" o "230 dB") que aparece en la palabra "en".
100
- - eleccion: final abierto para que el público comente: "pregunta" y 2 "opciones" ({texto, sub, emoji}). Úsala al final en series o historias.
113
+ - 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.
101
114
  Varía los tipos. "at", "check" y "en" deben ser palabras que aparezcan tal cual en "say".
102
- No incluyas la escena final de "sígueme": morfeo la agrega sola.`;
115
+ No incluyas la escena final de "sígueme": morfeo la agrega sola.
116
+
117
+ Diccionario de recursos por tema (usa estas palabras en "recurso"):
118
+ ${Object.entries(listaTemas()).map(([t, v]) => `- ${t}: ${v.join(', ')}`).join('\n')}`;
103
119
 
104
120
  export async function esquemaMotor() {
105
121
  const {z} = await import('zod');
@@ -119,12 +135,15 @@ export async function esquemaMotor() {
119
135
  a: z.string().nullable(),
120
136
  b: z.string().nullable(),
121
137
  at: z.string().nullable(),
138
+ recurso: z.string().nullable(),
139
+ recurso_estilo: z.enum(['3d', 'realista', '2d']).nullable(),
140
+ objeto: z.enum(OBJETOS).nullable(),
122
141
  emoji: z.string().nullable(),
123
142
  imagen_ia: z.string().nullable(),
124
143
  chip: z.string().nullable(),
125
144
  en: z.string().nullable(),
126
145
  pregunta: z.string().nullable(),
127
- opciones: z.array(z.object({texto: z.string(), sub: z.string(), emoji: z.string()})).nullable(),
146
+ opciones: z.array(z.object({texto: z.string(), sub: z.string(), objeto: z.enum(OBJETOS).nullable(), emoji: z.string().nullable()})).nullable(),
128
147
  });
129
148
  return z.object({id: z.string(), title: z.string(), music: z.enum(MUSICAS), scenes: z.array(escena)});
130
149
  }
@@ -156,12 +175,15 @@ function aEscenaMotor(e, conImagenes) {
156
175
  break;
157
176
  case 'eleccion':
158
177
  si('pregunta', e.pregunta);
159
- out.opciones = (e.opciones ?? []).slice(0, 3);
178
+ out.opciones = (e.opciones ?? []).slice(0, 3).map(({texto, sub, objeto, emoji}) => ({texto, sub, ...(objeto ? {objeto} : emoji ? {emoji} : {})}));
160
179
  break;
161
180
  case 'capas': {
162
181
  const capas = [];
163
- if (e.imagen_ia && conImagenes) capas.push({tipo: 'imagen', generar: e.imagen_ia, ancho: 640, x: 540, y: 1000, entrada: 'escala', flotar: true});
164
- else capas.push({tipo: 'emoji', texto: e.emoji || '✨', tam: 420, x: 540, y: 1000, entrada: 'rebote', flotar: 16});
182
+ // recurso de la biblioteca > objeto 3D > imagen con IA > emoji (último recurso)
183
+ if (e.recurso) capas.push({tipo: 'recurso', buscar: e.recurso, estilo: e.recurso_estilo ?? '3d', x: 540, y: 1000, entrada: 'rebote', flotar: 10});
184
+ else if (e.objeto) capas.push({tipo: 'modelo', forma: e.objeto, ancho: 680, alto: 680, x: 540, y: 1000, girar3d: [0, 40, 0], entrada: 'rebote', flotar: 12});
185
+ else if (e.imagen_ia && conImagenes) capas.push({tipo: 'imagen', generar: e.imagen_ia, ancho: 640, x: 540, y: 1000, entrada: 'escala', flotar: true});
186
+ else capas.push({tipo: 'modelo', forma: 'estrella', ancho: 600, alto: 600, x: 540, y: 1000, girar3d: [0, 40, 0], entrada: 'rebote'});
165
187
  if (e.chip) capas.push({tipo: 'chip', texto: e.chip, x: 760, y: 1280, en: e.en ?? 'fin-0.5', entrada: 'rebote', sonido: 'pop'});
166
188
  out.capas = capas;
167
189
  break;