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.
- package/README.md +1 -0
- package/bin/morfeo.mjs +26 -1
- package/index.d.ts +13 -4
- package/lib/config.mjs +38 -3
- package/lib/diccionario.mjs +132 -0
- package/lib/guion.mjs +25 -7
- package/lib/recursos.mjs +310 -0
- package/lib/render.mjs +29 -3
- package/package.json +1 -1
- package/plantilla/videos.mjs +1 -0
- package/remotion/motor/Video.tsx +1 -1
- package/remotion/motor/capas.tsx +10 -0
- package/remotion/motor/escenarios/Bosque.tsx +157 -0
- package/remotion/motor/escenarios/Cuarto.tsx +103 -0
- package/remotion/motor/escenarios/Noche.tsx +163 -0
- package/remotion/motor/escenarios/index.tsx +26 -0
- package/remotion/motor/fondo.tsx +16 -4
- package/remotion/motor/modelo3d.tsx +51 -6
- package/remotion/motor/objetos3d.tsx +548 -0
- package/remotion/motor/plantillas.tsx +7 -3
- package/remotion/motor/subtitulos.tsx +2 -2
- package/remotion/motor/tema.ts +5 -4
- package/remotion/motor/widgets.tsx +1 -1
package/lib/recursos.mjs
ADDED
|
@@ -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
|
|
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]) =>
|
|
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.
|
|
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",
|
package/plantilla/videos.mjs
CHANGED
|
@@ -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
|
package/remotion/motor/Video.tsx
CHANGED
|
@@ -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} />
|
package/remotion/motor/capas.tsx
CHANGED
|
@@ -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
|
+
}
|