morfeo 0.1.1 → 0.1.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,310 @@
1
+ // Biblioteca de recursos 2D y 3D: busca un término (en español) en bibliotecas abiertas, lo descarga
2
+ // una sola vez a ~/.morfeo/biblioteca y lo convierte en una capa de imagen o de modelo.
3
+ //
4
+ // {tipo: 'recurso', buscar: 'tubería', estilo: '3d'} 3D low-poly (Kenney; Poly Pizza si hay clave)
5
+ // {tipo: 'recurso', buscar: 'silla', estilo: 'realista'} 3D realista con texturas (Poly Haven)
6
+ // {tipo: 'recurso', buscar: 'cohete', estilo: '2d', variante: 'clay'} ilustración 2D (Iconify)
7
+ //
8
+ // Fuentes y licencias:
9
+ // Kenney (espejo github.com/ETdoFresh/kenney.nl) CC0 · ~1.400 modelos .glb
10
+ // Poly Haven (polyhaven.com) CC0 · ~500 modelos glTF con texturas · "Powered by Poly Haven"
11
+ // Iconify (iconify.design) MIT / Apache / CC0 / CC BY según el set · 200 mil+ íconos
12
+ // Poly Pizza (poly.pizza, opcional) CC0 / CC-BY · 10 mil+ modelos · necesita POLYPIZZA_API_KEY
13
+ import fs from 'node:fs';
14
+ import path from 'node:path';
15
+ import {ErrorMorfeo} from './config.mjs';
16
+ import {HOME} from './entorno.mjs';
17
+ import {traducir} from './diccionario.mjs';
18
+
19
+ export const BIBLIOTECA = path.join(HOME, 'biblioteca');
20
+ const UA = {'user-agent': 'morfeo (https://github.com/UreckChan/morfeo)'};
21
+ const UNA_SEMANA = 7 * 24 * 3600 * 1000;
22
+
23
+ // Variantes 2D: cada una es un set (o grupo de sets) de Iconify con la misma línea gráfica.
24
+ export const VARIANTES_2D = {
25
+ color: ['fluent-color', 'glyphs-poly', 'icon-park', 'marketeq', 'withicons-suite'],
26
+ clay: ['withicons-clay'],
27
+ glass: ['withicons-glass'],
28
+ kawaii: ['withicons-kawaii'],
29
+ retro: ['withicons-retro'],
30
+ pastel: ['withicons-pastel'],
31
+ sticker: ['withicons-sticker'],
32
+ skeuo: ['withicons-skeuo'],
33
+ plush: ['withicons-plush'],
34
+ anime: ['withicons-anime'],
35
+ luxe: ['withicons-luxe'],
36
+ chrome: ['withicons-chrome'],
37
+ liquid: ['withicons-liquid'],
38
+ bauhaus: ['withicons-bauhaus'],
39
+ brutal: ['withicons-brutal'],
40
+ gothic: ['withicons-gothic'],
41
+ linea: ['ph', 'mdi', 'iconoir', 'tabler', 'lucide'],
42
+ // ilustración plana con muchísimos animales y objetos: último respaldo 2D antes de pasar a 3D
43
+ plano: ['fluent-emoji-flat', 'noto'],
44
+ };
45
+
46
+ async function pedirJSON(url, opciones = {}) {
47
+ let res;
48
+ try {
49
+ res = await fetch(url, {...opciones, headers: {...UA, ...(opciones.headers ?? {})}});
50
+ } catch (e) {
51
+ throw new ErrorMorfeo(`No pude conectar con ${new URL(url).host}: ${e.message}`);
52
+ }
53
+ if (!res.ok) throw new ErrorMorfeo(`${new URL(url).host} respondió ${res.status} para ${url}`);
54
+ return res.json();
55
+ }
56
+
57
+ async function bajar(url, destino) {
58
+ if (fs.existsSync(destino) && fs.statSync(destino).size > 0) return destino;
59
+ const res = await fetch(url, {headers: UA});
60
+ if (!res.ok) throw new ErrorMorfeo(`No pude descargar ${url} (${res.status})`);
61
+ fs.mkdirSync(path.dirname(destino), {recursive: true});
62
+ fs.writeFileSync(destino + '.part', Buffer.from(await res.arrayBuffer()));
63
+ fs.renameSync(destino + '.part', destino);
64
+ return destino;
65
+ }
66
+
67
+ /** Índice en caché (se renueva cada semana; si no hay internet, usa el que haya). */
68
+ async function indice(nombre, obtener) {
69
+ const f = path.join(BIBLIOTECA, `${nombre}.json`);
70
+ if (fs.existsSync(f) && Date.now() - fs.statSync(f).mtimeMs < UNA_SEMANA) return JSON.parse(fs.readFileSync(f, 'utf8'));
71
+ try {
72
+ const datos = await obtener();
73
+ fs.mkdirSync(BIBLIOTECA, {recursive: true});
74
+ fs.writeFileSync(f, JSON.stringify(datos));
75
+ return datos;
76
+ } catch (e) {
77
+ if (fs.existsSync(f)) return JSON.parse(fs.readFileSync(f, 'utf8'));
78
+ throw e;
79
+ }
80
+ }
81
+
82
+ /** "bathroomSinkSquare" / "Barrel_01" → ["bathroom", "sink", "square", "barrel"] */
83
+ const palabras = (s) =>
84
+ String(s)
85
+ .replace(/([a-z])([A-Z])/g, '$1 $2')
86
+ .toLowerCase()
87
+ .split(/[^a-z]+/)
88
+ .filter(Boolean);
89
+
90
+ /** Singular aproximado en inglés: "pipes" → "pipe", "boxes" → "box", "glass" se queda. */
91
+ const singular = (w) => (w.length > 4 && /(ches|shes|xes|sses)$/.test(w) ? w.slice(0, -2) : w.length > 3 && /[^s]s$/.test(w) ? w.slice(0, -1) : w);
92
+
93
+ /**
94
+ * Puntaje de un nombre contra una búsqueda en inglés. Todas las palabras buscadas deben aparecer
95
+ * (plural = singular); gana el nombre corto cuyo sustantivo principal (la última palabra) es lo buscado.
96
+ */
97
+ function puntaje(nombrePalabras, consulta) {
98
+ const q = palabras(consulta).map(singular);
99
+ const n = nombrePalabras.map(singular);
100
+ if (!q.length || !n.length) return 0;
101
+ let p = 0;
102
+ for (const w of q) {
103
+ if (n.includes(w)) p += 10;
104
+ else if (w.length >= 4 && n.some((x) => x.length >= 4 && (x.startsWith(w) || w.startsWith(x)))) p += 4;
105
+ else return 0;
106
+ }
107
+ if (n[n.length - 1] === q[q.length - 1]) p += 6; // "modular pipe" > "pipe wrench" al buscar "pipe"
108
+ return p - n.length * 0.6;
109
+ }
110
+
111
+ // ---------------------------------------------------------------- Kenney (3D low-poly, CC0)
112
+ const KENNEY_REPO = 'ETdoFresh/kenney.nl';
113
+ const kenneyIndice = () =>
114
+ indice('kenney', async () => {
115
+ const arbol = await pedirJSON(`https://api.github.com/repos/${KENNEY_REPO}/git/trees/master?recursive=1`);
116
+ return arbol.tree
117
+ .filter((n) => /\.glb$/i.test(n.path))
118
+ .map((n) => ({ruta: n.path, nombre: n.path.split('/').pop().replace(/\.glb$/i, ''), kit: n.path.split('/')[0]}));
119
+ });
120
+
121
+ async function buscarKenney(consultas) {
122
+ const idx = await kenneyIndice();
123
+ const out = [];
124
+ for (const [i, q] of consultas.entries())
125
+ for (const m of idx) {
126
+ const p = puntaje(palabras(m.nombre), q);
127
+ if (p > 0) out.push({fuente: 'kenney', id: m.ruta, nombre: m.nombre, coleccion: m.kit, licencia: 'CC0', puntaje: p - i * 3, tipo: 'modelo'});
128
+ }
129
+ return out;
130
+ }
131
+
132
+ async function bajarKenney(r) {
133
+ const url = `https://raw.githubusercontent.com/${KENNEY_REPO}/master/${r.id.split('/').map(encodeURIComponent).join('/')}`;
134
+ const local = path.join(BIBLIOTECA, 'kenney', r.id.replace(/[^A-Za-z0-9._/-]/g, '_'));
135
+ await bajar(url, local);
136
+ return {archivos: [local], principal: local, credito: `${r.nombre} · Kenney (kenney.nl) · CC0`};
137
+ }
138
+
139
+ // ---------------------------------------------------------------- Poly Haven (3D realista, CC0)
140
+ const polyhavenIndice = () => indice('polyhaven', () => pedirJSON('https://api.polyhaven.com/assets?t=models'));
141
+
142
+ async function buscarPolyhaven(consultas) {
143
+ const idx = await polyhavenIndice();
144
+ const out = [];
145
+ for (const [i, q] of consultas.entries())
146
+ for (const [id, v] of Object.entries(idx)) {
147
+ const nombre = palabras(`${v.name} ${id}`);
148
+ const extra = palabras([...(v.tags ?? []), ...(v.categories ?? [])].join(' '));
149
+ const p = Math.max(puntaje(nombre, q), puntaje([...nombre, ...extra], q) - 6);
150
+ if (p > 0) out.push({fuente: 'polyhaven', id, nombre: v.name, coleccion: (v.categories ?? [])[0] ?? '', licencia: 'CC0', puntaje: p - i * 3, tipo: 'modelo'});
151
+ }
152
+ return out;
153
+ }
154
+
155
+ async function bajarPolyhaven(r, resolucion = '1k') {
156
+ const f = await pedirJSON(`https://api.polyhaven.com/files/${encodeURIComponent(r.id)}`);
157
+ const g = f.gltf?.[resolucion] ?? f.gltf?.['1k'] ?? Object.values(f.gltf ?? {})[0];
158
+ if (!g?.gltf) throw new ErrorMorfeo(`Poly Haven no tiene glTF para ${r.id}`);
159
+ const dir = path.join(BIBLIOTECA, 'polyhaven', r.id);
160
+ const principal = await bajar(g.gltf.url, path.join(dir, path.basename(new URL(g.gltf.url).pathname)));
161
+ const archivos = [principal];
162
+ for (const [rel, info] of Object.entries(g.gltf.include ?? {})) archivos.push(await bajar(info.url, path.join(dir, rel)));
163
+ return {archivos, principal, base: dir, credito: `${r.nombre} · Poly Haven (polyhaven.com) · CC0`};
164
+ }
165
+
166
+ // ---------------------------------------------------------------- Iconify (2D)
167
+ async function buscarIconify(consultas, variante = 'color') {
168
+ const sets = VARIANTES_2D[variante] ?? VARIANTES_2D.color;
169
+ const out = [];
170
+ for (const [i, q] of consultas.entries()) {
171
+ const r = await pedirJSON(`https://api.iconify.design/search?query=${encodeURIComponent(q)}&limit=48&prefixes=${sets.join(',')}`);
172
+ for (const [j, icono] of (r.icons ?? []).entries()) {
173
+ const [prefijo, nombre] = icono.split(':');
174
+ const col = r.collections?.[prefijo] ?? {};
175
+ const p = puntaje(palabras(nombre), q);
176
+ out.push({fuente: 'iconify', id: icono, nombre, coleccion: col.name ?? prefijo, licencia: col.license?.title ?? '?', puntaje: (p || 1) - i * 3 - j * 0.05, tipo: 'imagen', mono: !col.palette});
177
+ }
178
+ }
179
+ return out;
180
+ }
181
+
182
+ async function bajarIconify(r, color) {
183
+ const [prefijo, nombre] = r.id.split(':');
184
+ const params = r.mono ? `?height=512&color=${encodeURIComponent(color ?? '#ffffff')}` : '?height=512';
185
+ const local = path.join(BIBLIOTECA, 'iconify', prefijo, `${nombre}${r.mono ? '-' + (color ?? '#ffffff').replace('#', '') : ''}.svg`);
186
+ await bajar(`https://api.iconify.design/${prefijo}/${nombre}.svg${params}`, local);
187
+ return {archivos: [local], principal: local, credito: `${nombre} · ${r.coleccion} (Iconify) · ${r.licencia}`};
188
+ }
189
+
190
+ // ---------------------------------------------------------------- Poly Pizza (3D, opcional con clave)
191
+ async function buscarPolypizza(consultas) {
192
+ const clave = process.env.POLYPIZZA_API_KEY;
193
+ if (!clave) return [];
194
+ const out = [];
195
+ for (const [i, q] of consultas.entries()) {
196
+ const r = await pedirJSON(`https://api.poly.pizza/v1/search/${encodeURIComponent(q)}?limit=24`, {headers: {'X-Auth-Token': clave}});
197
+ const lista = Array.isArray(r) ? r : r.results ?? r.Results ?? [];
198
+ for (const [j, m] of lista.entries())
199
+ if (m.Download)
200
+ out.push({fuente: 'polypizza', id: m.ID, nombre: m.Title, coleccion: m.Creator?.Username ?? '', licencia: m.Licence ?? m.License ?? '?', puntaje: 8 - i * 3 - j * 0.1, tipo: 'modelo', url: m.Download, autor: m.Creator?.Username});
201
+ }
202
+ return out;
203
+ }
204
+
205
+ async function bajarPolypizza(r) {
206
+ const local = path.join(BIBLIOTECA, 'polypizza', `${String(r.id).replace(/[^A-Za-z0-9_-]/g, '_')}.glb`);
207
+ await bajar(r.url, local);
208
+ const cc0 = /cc0|public/i.test(r.licencia);
209
+ return {archivos: [local], principal: local, credito: cc0 ? `${r.nombre} · Poly Pizza · CC0` : `"${r.nombre}" by ${r.autor} (poly.pizza) ${r.licencia}`};
210
+ }
211
+
212
+ // ---------------------------------------------------------------- búsqueda y descarga
213
+ /** Orden de fuentes por estilo. */
214
+ const FUENTES = {'3d': ['kenney', 'polypizza', 'polyhaven'], realista: ['polyhaven', 'polypizza', 'kenney'], '2d': ['iconify']};
215
+
216
+ /**
217
+ * Busca un término en las bibliotecas.
218
+ * @returns {Promise<Array<{fuente, id, nombre, coleccion, licencia, tipo}>>} ordenados del mejor al peor
219
+ */
220
+ export async function buscarRecursos(termino, {estilo = '3d', variante, fuente} = {}) {
221
+ const {buscar} = traducir(termino);
222
+ const fuentes = fuente ? [fuente] : FUENTES[estilo] ?? FUENTES['3d'];
223
+ const todos = [];
224
+ for (const f of fuentes) {
225
+ try {
226
+ const r = f === 'kenney' ? await buscarKenney(buscar) : f === 'polyhaven' ? await buscarPolyhaven(buscar) : f === 'polypizza' ? await buscarPolypizza(buscar) : await buscarIconify(buscar, variante);
227
+ // el orden de las fuentes pesa: la primera fuente con resultados buenos gana
228
+ todos.push(...r.map((x) => ({...x, puntaje: x.puntaje - fuentes.indexOf(f) * 6})));
229
+ } catch (e) {
230
+ if (fuente) throw e; // con fuente explícita, el error importa; si no, se prueba la siguiente
231
+ }
232
+ }
233
+ const vistos = new Set();
234
+ return todos
235
+ .sort((a, b) => b.puntaje - a.puntaje)
236
+ .filter((r) => (vistos.has(r.fuente + r.id) ? false : vistos.add(r.fuente + r.id)));
237
+ }
238
+
239
+ /** Descarga un resultado. Devuelve {principal, archivos, base?, credito}. */
240
+ export async function bajarRecurso(r, {color} = {}) {
241
+ if (r.fuente === 'kenney') return bajarKenney(r);
242
+ if (r.fuente === 'polyhaven') return bajarPolyhaven(r);
243
+ if (r.fuente === 'polypizza') return bajarPolypizza(r);
244
+ return bajarIconify(r, color);
245
+ }
246
+
247
+ /**
248
+ * Cambia cada {tipo: 'recurso'} del video por una capa de modelo o imagen ya descargada en public/assets/biblioteca.
249
+ * Devuelve {video, creditos}.
250
+ */
251
+ export async function resolverRecursos(video, assetsPublico, log = console.log) {
252
+ const creditos = new Set();
253
+ const resolver = async (c) => {
254
+ const {tipo, buscar, estilo = '3d', variante, fuente, indice: n = 0, color, ...resto} = c;
255
+ if (!buscar) throw new ErrorMorfeo('Una capa {tipo: "recurso"} necesita `buscar` (por ejemplo, buscar: "tubería")');
256
+ let res = await buscarRecursos(buscar, {estilo, variante, fuente});
257
+ // sin resultados: primero se prueba el mismo estilo con otra línea gráfica (2d glass → 2d color → otras
258
+ // variantes), y luego los otros estilos. Así un 2D no termina siendo un modelo realista si hay otro 2D.
259
+ if (!res.length && !fuente) {
260
+ const intentos =
261
+ estilo === '2d'
262
+ ? [...Object.keys(VARIANTES_2D).filter((v) => v !== (variante ?? 'color') && v !== 'linea').map((v) => ['2d', v]), ['3d'], ['realista']]
263
+ : [...['3d', 'realista'].filter((e) => e !== estilo).map((e) => [e]), ['2d', variante]];
264
+ for (const [otro, otraVariante] of intentos) {
265
+ res = await buscarRecursos(buscar, {estilo: otro, variante: otraVariante});
266
+ if (res.length) {
267
+ log(` "${buscar}" no está en ${estilo}${variante ? ' ' + variante : ''}; uso ${otro}${otraVariante && otro === '2d' ? ' ' + otraVariante : ''}`);
268
+ break;
269
+ }
270
+ }
271
+ }
272
+ // nada en ninguna biblioteca: se avisa y la capa se omite (el video se hace igual)
273
+ if (!res.length) {
274
+ log(` ⚠ No encontré "${buscar}" en ninguna biblioteca; omito esa capa. Prueba un sinónimo o escríbelo en inglés: npx morfeo recursos buscar "${buscar}"`);
275
+ return null;
276
+ }
277
+ const r = res[Math.min(n, res.length - 1)];
278
+ const d = await bajarRecurso(r, {color});
279
+ creditos.add(d.credito);
280
+ // se copia a public/assets/biblioteca/<fuente>/... conservando la carpeta (los glTF de Poly Haven traen texturas aparte)
281
+ const base = d.base ?? path.dirname(d.principal);
282
+ for (const a of d.archivos) {
283
+ const destino = path.join(assetsPublico, 'biblioteca', path.relative(BIBLIOTECA, a));
284
+ if (!fs.existsSync(destino)) {
285
+ fs.mkdirSync(path.dirname(destino), {recursive: true});
286
+ fs.copyFileSync(a, destino);
287
+ }
288
+ }
289
+ const src = path.relative(path.join(assetsPublico), path.join(assetsPublico, 'biblioteca', path.relative(BIBLIOTECA, d.principal))).split(path.sep).join('/');
290
+ log(` recurso "${buscar}" → ${r.nombre} (${r.fuente})`);
291
+ return r.tipo === 'modelo'
292
+ ? // modelos externos: cámara más cerca y menos luz ambiente para que no se vean chicos ni deslavados
293
+ {tipo: 'modelo', src, ancho: 620, alto: 620, girar3d: [0, 30, 0], levantar: true, distancia: 4.3, camaraY: 0.9, luzAmbiente: 0.25, reflejos: 0.45, luz: 1.6, ...resto}
294
+ : {tipo: 'imagen', src, ancho: 460, sombra: true, ...resto};
295
+ void base;
296
+ };
297
+ const recorrer = async (x) => {
298
+ if (Array.isArray(x)) return (await Promise.all(x.map(recorrer))).filter((v) => v !== null);
299
+ if (!x || typeof x !== 'object') return x;
300
+ if (x.tipo === 'recurso') return resolver(x);
301
+ const out = {};
302
+ for (const [k, v] of Object.entries(x)) out[k] = await recorrer(v);
303
+ // un grupo cuya capa repetida no se encontró tampoco se dibuja
304
+ if (x.tipo === 'grupo' && x.capa && out.capa === null) return null;
305
+ return out;
306
+ };
307
+ const scenes = await recorrer(video.scenes);
308
+ const hud = await recorrer(video.hud ?? []);
309
+ return {video: {...video, scenes, hud}, creditos: [...creditos]};
310
+ }
package/lib/render.mjs CHANGED
@@ -10,6 +10,7 @@ import {ENTRADA_REMOTION, FUENTES, MODELOS, PY_DIR, modelosListos, pythonVenv, v
10
10
  import {asegurarNavegador} from './navegador.mjs';
11
11
  import {generarImagenes} from './imagenes.mjs';
12
12
  import {ponerPoses, prepararPersonajes} from './personajes.mjs';
13
+ import {resolverRecursos} from './recursos.mjs';
13
14
  import {sintetizarVoces} from './voces.mjs';
14
15
  import {transcribir, whisperListo} from './whisper.mjs';
15
16
 
@@ -157,12 +158,16 @@ export async function preparar({proyecto = process.cwd(), ids, log = console.log
157
158
  const datos = {};
158
159
  const poses = await prepararPersonajes(p, path.join(publico, 'assets'), log);
159
160
  const trabajos = path.join(p.trabajo, 'trabajos');
161
+ const creditos = {};
160
162
  fs.mkdirSync(trabajos, {recursive: true});
161
163
  for (const [i, id] of (ids ?? todos).entries()) {
162
164
  const original = p.videos[id];
163
165
  if (!original) throw new ErrorMorfeo(`No existe el video "${id}". Usa: npx morfeo lista`);
164
166
  log(`[${i + 1}/${(ids ?? todos).length}] ${id}: audio...`);
165
- const conImagenes = await generarImagenes(ponerPoses(original, poses), p.imagenes, path.join(p.trabajo, 'cache', 'imagenes'), path.join(publico, 'assets'), log);
167
+ const conRecursos = await resolverRecursos(ponerPoses(original, poses), path.join(publico, 'assets'), log);
168
+ creditos[id] = conRecursos.creditos;
169
+ guardarCreditos(p, id, conRecursos.creditos);
170
+ const conImagenes = await generarImagenes(conRecursos.video, p.imagenes, path.join(p.trabajo, 'cache', 'imagenes'), path.join(publico, 'assets'), log);
166
171
  const video = await sintetizarVoces(id, conImagenes, path.join(p.trabajo, 'cache', 'voces'), log);
167
172
  const job = path.join(trabajos, `${id}.json`);
168
173
  const base = {id, video, indice: todos.indexOf(id), publico, modelos: MODELOS};
@@ -176,7 +181,7 @@ export async function preparar({proyecto = process.cwd(), ids, log = console.log
176
181
  python(job);
177
182
  datos[id] = datosDe(publico, id);
178
183
  }
179
- return {p, publico, marca, extras, datos};
184
+ return {p, publico, marca, extras, datos, creditos};
180
185
  }
181
186
 
182
187
  /**
@@ -281,12 +286,33 @@ export async function render({proyecto = process.cwd(), ids, todos = false, log
281
286
  }
282
287
 
283
288
  /** <salida>/resumen.json: título, archivo y duración de cada video hecho (útil para publicar). */
289
+ /** Créditos de los recursos de cada video (.morfeo/creditos/<id>.json), para el resumen y el archivo de créditos. */
290
+ function guardarCreditos(p, id, lista) {
291
+ const dir = path.join(p.trabajo, 'creditos');
292
+ fs.mkdirSync(dir, {recursive: true});
293
+ fs.writeFileSync(path.join(dir, `${id}.json`), JSON.stringify(lista ?? []));
294
+ }
295
+ const creditosDe = (p, id) => {
296
+ try {
297
+ return JSON.parse(fs.readFileSync(path.join(p.trabajo, 'creditos', `${id}.json`), 'utf8'));
298
+ } catch {
299
+ return [];
300
+ }
301
+ };
302
+
284
303
  function escribirResumen(p) {
285
304
  const publico = path.join(p.trabajo, 'public');
286
305
  const filas = Object.entries(p.videos)
287
306
  .filter(([id]) => fs.existsSync(salidaDe(p, id)))
288
- .map(([id, v]) => ({id, titulo: v.title ?? id, archivo: `${id}.mp4`, duracion: datosDe(publico, id)?.duracion ?? null, ...(v.meta ? {meta: v.meta} : {})}));
307
+ .map(([id, v]) => {
308
+ const c = creditosDe(p, id);
309
+ return {id, titulo: v.title ?? id, archivo: `${id}.mp4`, duracion: datosDe(publico, id)?.duracion ?? null, ...(c.length ? {creditos: c} : {}), ...(v.meta ? {meta: v.meta} : {})};
310
+ });
289
311
  fs.writeFileSync(path.join(p.salida, 'resumen.json'), JSON.stringify(filas, null, '\t') + '\n');
312
+ // créditos para la descripción del video (las licencias CC BY piden mencionar al autor)
313
+ const conCreditos = filas.filter((f) => f.creditos?.length);
314
+ if (conCreditos.length)
315
+ fs.writeFileSync(path.join(p.salida, 'creditos.md'), ['# Créditos de recursos', '', ...conCreditos.flatMap((f) => [`## ${f.titulo}`, ...f.creditos.map((c) => `- ${c}`), ''])].join('\n'));
290
316
  }
291
317
 
292
318
  /** Abre Remotion Studio con los videos ya preparados (para verlos y ajustar en vivo). */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "morfeo",
3
- "version": "0.1.1",
3
+ "version": "0.1.3",
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",
@@ -10,6 +10,7 @@ const C = '#50DCFF'; // cian
10
10
 
11
11
  export default defineVideos({
12
12
  cielo: {
13
+ estilo: 'datos-curiosos', // sus escenas (title, text, icon...) son del estilo clásico
13
14
  title: '¿Por qué el cielo es azul? 🌤️ | Dato curioso',
14
15
  voice: 'narrador', // alias de morfeo.config.mjs (o directo: 'em_alex')
15
16
  music: 'lofi', // lofi, lofi2, chillpop, synth, house, ballad, trap, un archivo './musica/x.mp3' o false
@@ -30,7 +30,7 @@ const EscenaVista: React.FC<{e: Escena; t: number; tema: Tema; semilla: string}>
30
30
  return (
31
31
  <AbsoluteFill>
32
32
  <AbsoluteFill style={{transform: `scale(${1 + (zoom - 1) * 0.5}) translate(${-cx * 0.3}px, ${-cy * 0.3}px)`}}>
33
- <FondoTema tema={temaEsc} fondo={p.fondo} t={t + e.t0} semilla={semilla} />
33
+ <FondoTema tema={temaEsc} fondo={p.fondo} t={t + e.t0} semilla={semilla} ctx={ctx} />
34
34
  </AbsoluteFill>
35
35
  <AbsoluteFill style={{transform: `scale(${zoom}) translate(${-cx + temblor}px, ${-cy}px)`, transformOrigin: `${540 + (cam.origen?.[0] ?? 0)}px ${960 + (cam.origen?.[1] ?? 0)}px`}}>
36
36
  <Capas capas={capasDe(e.tipo, p, temaEsc, marca)} ctx={ctx} />
@@ -7,6 +7,7 @@ import {H, W, backOut, clamp, easeIO, easeOut, lerp} from '../estilo';
7
7
  import {azar, cuando, formatoNumero, partesResaltadas, recurso, type CtxEscena} from './util';
8
8
  import {Particulas} from './fondo';
9
9
  import {Escena3D, Modelo3D} from './modelo3d';
10
+ import {Escenario} from './escenarios';
10
11
  import {componente, nombresComponentes} from './registro';
11
12
  import {Barra, Chat, Estado, Globo, ImagenCambia, Medidor, Noticia, Onda, Seguir, Votacion} from './widgets';
12
13
 
@@ -219,6 +220,15 @@ const Contenido: React.FC<{c: Capa; ctx: CtxEscena}> = ({c, ctx}) => {
219
220
  );
220
221
  case 'grupo':
221
222
  return <Grupo c={c} ctx={ctx} />;
223
+ case 'escenario': {
224
+ const {tipo, nombre, x, y, en, hasta, entrada, salida, z, ...props} = c;
225
+ const t0 = cuando(en, ctx, 0);
226
+ return (
227
+ <div style={{width: W, height: H}}>
228
+ <Escenario nombre={nombre} {...props} t={ctx.t - t0} dur={ctx.dur} cuando={(e: unknown) => cuando(e, ctx) - t0} />
229
+ </div>
230
+ );
231
+ }
222
232
  case 'escena3d':
223
233
  return <Escena3D c={c} ctx={ctx} />;
224
234
  case 'personaje':
@@ -0,0 +1,157 @@
1
+ // Bosque de noche ilustrado, 1080×1920, por capas: cielo, estrellas, luna con halo, montañas,
2
+ // tres filas de pinos, colina con pasto y luciérnagas. Con `paneo` las capas se mueven a distinta
3
+ // velocidad (paralaje). La luna puede tener cara y cantar: luna: {cara: true, cantar: 'palabra', bajar: 'palabra'}.
4
+ import React from 'react';
5
+
6
+ type Props = {
7
+ t: number;
8
+ dur: number;
9
+ cuando: (en: unknown) => number;
10
+ paneo?: number;
11
+ luna?: {x?: number; y?: number; cara?: boolean; cantar?: string; bajar?: string; a?: [number, number]};
12
+ luciernagas?: boolean;
13
+ };
14
+
15
+ const W = 1080;
16
+ const H = 1920;
17
+ const clamp = (x: number) => Math.max(0, Math.min(1, x));
18
+ const suave = (x: number) => {
19
+ const c = clamp(x);
20
+ return c * c * (3 - 2 * c);
21
+ };
22
+ const azar = (n: number) => {
23
+ const x = Math.sin(n * 127.1) * 43758.5453;
24
+ return x - Math.floor(x);
25
+ };
26
+
27
+ /** Fila de pinos como un solo trazado (más liviano que muchos elementos). */
28
+ function pinos(base: number, alto: number, ancho: number, n: number, semilla: number) {
29
+ let d = '';
30
+ for (let i = 0; i < n; i++) {
31
+ const cx = (i / (n - 1)) * (W + 400) - 200 + (azar(semilla + i) - 0.5) * ancho * 0.6;
32
+ const h = alto * (0.75 + azar(semilla + i * 3) * 0.5);
33
+ const w = ancho * (0.8 + azar(semilla + i * 7) * 0.4);
34
+ // tres pisos de ramas
35
+ for (let p = 0; p < 3; p++) {
36
+ const top = base - h + p * h * 0.26;
37
+ const bot = base - h * 0.32 + p * h * 0.22;
38
+ const ww = w * (0.55 + p * 0.25);
39
+ d += `M${cx} ${top} L${cx + ww / 2} ${bot} L${cx - ww / 2} ${bot} Z `;
40
+ }
41
+ d += `M${cx - w * 0.06} ${base - h * 0.12} h${w * 0.12} v${h * 0.14} h${-w * 0.12} Z `;
42
+ }
43
+ return d;
44
+ }
45
+
46
+ export function Bosque({t, dur, cuando, paneo = 0, luna = {}, luciernagas = true}: Props) {
47
+ const k = suave(t / Math.max(1, dur));
48
+ const p = (f: number) => -paneo * f * k; // paralaje: lo lejano se mueve menos
49
+ // luna: posición (puede bajar) y canto
50
+ const lx0 = luna.x ?? 800;
51
+ const ly0 = luna.y ?? 470;
52
+ const kb = luna.bajar ? suave((t - cuando(luna.bajar)) / 1.8) : 0;
53
+ const lx = lx0 + ((luna.a?.[0] ?? lx0) - lx0) * kb;
54
+ const ly = ly0 + ((luna.a?.[1] ?? ly0) - ly0) * kb;
55
+ const canta = luna.cantar ? t >= cuando(luna.cantar) : false;
56
+ const boca = canta ? 6 + Math.abs(Math.sin(t * 9)) * 14 : 0;
57
+
58
+ return (
59
+ <svg width={W} height={H} viewBox={`0 0 ${W} ${H}`}>
60
+ <defs>
61
+ <linearGradient id="cielo" x1="0" y1="0" x2="0" y2="1">
62
+ <stop offset="0" stopColor="#0B1033" />
63
+ <stop offset="0.5" stopColor="#252063" />
64
+ <stop offset="0.78" stopColor="#5B3C7E" />
65
+ <stop offset="1" stopColor="#8E5A86" />
66
+ </linearGradient>
67
+ <radialGradient id="halo">
68
+ <stop offset="0" stopColor="rgba(255,240,200,0.55)" />
69
+ <stop offset="1" stopColor="rgba(255,240,200,0)" />
70
+ </radialGradient>
71
+ <linearGradient id="colina" x1="0" y1="0" x2="0" y2="1">
72
+ <stop offset="0" stopColor="#1F3B3A" />
73
+ <stop offset="1" stopColor="#0E1E22" />
74
+ </linearGradient>
75
+ </defs>
76
+ <rect width={W} height={H} fill="url(#cielo)" />
77
+ {/* estrellas que titilan */}
78
+ {Array.from({length: 70}, (_, i) => {
79
+ const x = azar(i) * W;
80
+ const y = azar(i + 100) * 1100;
81
+ const r = 1.2 + azar(i + 200) * 2.4;
82
+ const o = 0.35 + 0.65 * Math.abs(Math.sin(t * (1 + azar(i + 300) * 2) + i));
83
+ return <circle key={i} cx={x + p(0.05)} cy={y} r={r} fill="#FFFFFF" opacity={o} />;
84
+ })}
85
+ {/* luna */}
86
+ <g transform={`translate(${lx + p(0.1)} ${ly})`}>
87
+ <circle r={260} fill="url(#halo)" />
88
+ <circle r={110} fill="#FFF4D6" />
89
+ <circle cx={-34} cy={-26} r={16} fill="rgba(210,190,150,0.45)" />
90
+ <circle cx={38} cy={30} r={22} fill="rgba(210,190,150,0.4)" />
91
+ <circle cx={18} cy={-48} r={9} fill="rgba(210,190,150,0.4)" />
92
+ {luna.cara ? (
93
+ <g>
94
+ {canta ? (
95
+ <>
96
+ <path d="M-42 -8 Q-30 -18 -18 -8" stroke="#6B5236" strokeWidth={6} fill="none" strokeLinecap="round" />
97
+ <path d="M18 -8 Q30 -18 42 -8" stroke="#6B5236" strokeWidth={6} fill="none" strokeLinecap="round" />
98
+ </>
99
+ ) : (
100
+ <>
101
+ <circle cx={-30} cy={-10} r={8} fill="#6B5236" />
102
+ <circle cx={30} cy={-10} r={8} fill="#6B5236" />
103
+ </>
104
+ )}
105
+ <ellipse cx={0} cy={34} rx={canta ? 14 : 16} ry={canta ? boca : 4} fill={canta ? '#7A3B3B' : '#6B5236'} />
106
+ <circle cx={-58} cy={18} r={12} fill="rgba(255,150,150,0.45)" />
107
+ <circle cx={58} cy={18} r={12} fill="rgba(255,150,150,0.45)" />
108
+ </g>
109
+ ) : null}
110
+ </g>
111
+ {/* notas musicales cuando canta */}
112
+ {canta
113
+ ? Array.from({length: 6}, (_, i) => {
114
+ const fase = ((t - cuando(luna.cantar) + i * 0.45) % 2.7) / 2.7;
115
+ const x = lx - 160 - fase * 260 + Math.sin(fase * 9 + i) * 40;
116
+ const y = ly + 60 + fase * 420;
117
+ return (
118
+ <text key={i} x={x} y={y} fontSize={70} fill="#FFE9A8" opacity={Math.sin(fase * Math.PI)} fontFamily="sans-serif">
119
+ {i % 2 ? '♪' : '♫'}
120
+ </text>
121
+ );
122
+ })
123
+ : null}
124
+ {/* montañas lejanas */}
125
+ <path transform={`translate(${p(0.15)} 0)`} d="M-200 1250 L80 980 L300 1150 L560 920 L820 1120 L1040 960 L1300 1180 L1300 1500 L-200 1500 Z" fill="#2E2A5A" opacity={0.9} />
126
+ {/* pinos lejanos, medios y cercanos */}
127
+ <path transform={`translate(${p(0.3)} 0)`} d={pinos(1330, 300, 150, 12, 1)} fill="#263A55" />
128
+ <path transform={`translate(${p(0.55)} 0)`} d={pinos(1420, 380, 200, 9, 20)} fill="#1A2E3F" />
129
+ {/* colina */}
130
+ <path transform={`translate(${p(0.8)} 0)`} d="M-300 1500 Q300 1380 700 1460 Q1050 1520 1400 1440 L1400 1920 L-300 1920 Z" fill="url(#colina)" />
131
+ {/* pasto */}
132
+ <g transform={`translate(${p(0.8)} 0)`}>
133
+ {Array.from({length: 60}, (_, i) => {
134
+ const x = -200 + i * 26;
135
+ const y = 1470 + Math.sin(i * 0.7) * 22;
136
+ const viento = Math.sin(t * 2 + i * 0.4) * 5;
137
+ return <path key={i} d={`M${x} ${y + 30} Q${x + viento} ${y + 10} ${x + 4 + viento * 1.6} ${y - 6}`} stroke="#2E5A4E" strokeWidth={5} fill="none" strokeLinecap="round" />;
138
+ })}
139
+ </g>
140
+ <path transform={`translate(${p(1)} 0)`} d={pinos(1950, 620, 320, 5, 40)} fill="#0C1A24" />
141
+ {/* luciérnagas */}
142
+ {luciernagas
143
+ ? Array.from({length: 14}, (_, i) => {
144
+ const x = azar(i + 500) * W + Math.sin(t * 0.8 + i) * 40;
145
+ const y = 1250 + azar(i + 600) * 450 + Math.cos(t * 0.6 + i) * 30;
146
+ const o = 0.3 + 0.7 * Math.abs(Math.sin(t * 1.7 + i * 2));
147
+ return (
148
+ <g key={i} opacity={o}>
149
+ <circle cx={x} cy={y} r={14} fill="rgba(255,236,120,0.25)" />
150
+ <circle cx={x} cy={y} r={4} fill="#FFF3A0" />
151
+ </g>
152
+ );
153
+ })
154
+ : null}
155
+ </svg>
156
+ );
157
+ }