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 +2 -1
- package/bin/morfeo.mjs +26 -1
- package/index.d.ts +17 -4
- package/lib/config.mjs +84 -6
- package/lib/diccionario.mjs +129 -0
- package/lib/guion.mjs +34 -12
- package/lib/recursos.mjs +299 -0
- package/lib/render.mjs +87 -27
- package/package.json +2 -2
- package/plantilla/videos.mjs +1 -0
- package/remotion/Short.tsx +6 -6
- package/remotion/motor/Video.tsx +8 -6
- package/remotion/motor/capas.tsx +88 -3
- 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 +18 -7
- package/remotion/motor/modelo3d.tsx +4 -1
- package/remotion/motor/objetos3d.tsx +548 -0
- package/remotion/motor/plantillas.tsx +10 -4
- package/remotion/motor/tema.ts +14 -8
- package/remotion/motor/widgets.tsx +5 -5
package/lib/recursos.mjs
ADDED
|
@@ -0,0 +1,299 @@
|
|
|
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
|
+
};
|
|
43
|
+
|
|
44
|
+
async function pedirJSON(url, opciones = {}) {
|
|
45
|
+
let res;
|
|
46
|
+
try {
|
|
47
|
+
res = await fetch(url, {...opciones, headers: {...UA, ...(opciones.headers ?? {})}});
|
|
48
|
+
} catch (e) {
|
|
49
|
+
throw new ErrorMorfeo(`No pude conectar con ${new URL(url).host}: ${e.message}`);
|
|
50
|
+
}
|
|
51
|
+
if (!res.ok) throw new ErrorMorfeo(`${new URL(url).host} respondió ${res.status} para ${url}`);
|
|
52
|
+
return res.json();
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
async function bajar(url, destino) {
|
|
56
|
+
if (fs.existsSync(destino) && fs.statSync(destino).size > 0) return destino;
|
|
57
|
+
const res = await fetch(url, {headers: UA});
|
|
58
|
+
if (!res.ok) throw new ErrorMorfeo(`No pude descargar ${url} (${res.status})`);
|
|
59
|
+
fs.mkdirSync(path.dirname(destino), {recursive: true});
|
|
60
|
+
fs.writeFileSync(destino + '.part', Buffer.from(await res.arrayBuffer()));
|
|
61
|
+
fs.renameSync(destino + '.part', destino);
|
|
62
|
+
return destino;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** Índice en caché (se renueva cada semana; si no hay internet, usa el que haya). */
|
|
66
|
+
async function indice(nombre, obtener) {
|
|
67
|
+
const f = path.join(BIBLIOTECA, `${nombre}.json`);
|
|
68
|
+
if (fs.existsSync(f) && Date.now() - fs.statSync(f).mtimeMs < UNA_SEMANA) return JSON.parse(fs.readFileSync(f, 'utf8'));
|
|
69
|
+
try {
|
|
70
|
+
const datos = await obtener();
|
|
71
|
+
fs.mkdirSync(BIBLIOTECA, {recursive: true});
|
|
72
|
+
fs.writeFileSync(f, JSON.stringify(datos));
|
|
73
|
+
return datos;
|
|
74
|
+
} catch (e) {
|
|
75
|
+
if (fs.existsSync(f)) return JSON.parse(fs.readFileSync(f, 'utf8'));
|
|
76
|
+
throw e;
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** "bathroomSinkSquare" / "Barrel_01" → ["bathroom", "sink", "square", "barrel"] */
|
|
81
|
+
const palabras = (s) =>
|
|
82
|
+
String(s)
|
|
83
|
+
.replace(/([a-z])([A-Z])/g, '$1 $2')
|
|
84
|
+
.toLowerCase()
|
|
85
|
+
.split(/[^a-z]+/)
|
|
86
|
+
.filter(Boolean);
|
|
87
|
+
|
|
88
|
+
/** Singular aproximado en inglés: "pipes" → "pipe", "boxes" → "box", "glass" se queda. */
|
|
89
|
+
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);
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Puntaje de un nombre contra una búsqueda en inglés. Todas las palabras buscadas deben aparecer
|
|
93
|
+
* (plural = singular); gana el nombre corto cuyo sustantivo principal (la última palabra) es lo buscado.
|
|
94
|
+
*/
|
|
95
|
+
function puntaje(nombrePalabras, consulta) {
|
|
96
|
+
const q = palabras(consulta).map(singular);
|
|
97
|
+
const n = nombrePalabras.map(singular);
|
|
98
|
+
if (!q.length || !n.length) return 0;
|
|
99
|
+
let p = 0;
|
|
100
|
+
for (const w of q) {
|
|
101
|
+
if (n.includes(w)) p += 10;
|
|
102
|
+
else if (w.length >= 4 && n.some((x) => x.length >= 4 && (x.startsWith(w) || w.startsWith(x)))) p += 4;
|
|
103
|
+
else return 0;
|
|
104
|
+
}
|
|
105
|
+
if (n[n.length - 1] === q[q.length - 1]) p += 6; // "modular pipe" > "pipe wrench" al buscar "pipe"
|
|
106
|
+
return p - n.length * 0.6;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// ---------------------------------------------------------------- Kenney (3D low-poly, CC0)
|
|
110
|
+
const KENNEY_REPO = 'ETdoFresh/kenney.nl';
|
|
111
|
+
const kenneyIndice = () =>
|
|
112
|
+
indice('kenney', async () => {
|
|
113
|
+
const arbol = await pedirJSON(`https://api.github.com/repos/${KENNEY_REPO}/git/trees/master?recursive=1`);
|
|
114
|
+
return arbol.tree
|
|
115
|
+
.filter((n) => /\.glb$/i.test(n.path))
|
|
116
|
+
.map((n) => ({ruta: n.path, nombre: n.path.split('/').pop().replace(/\.glb$/i, ''), kit: n.path.split('/')[0]}));
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
async function buscarKenney(consultas) {
|
|
120
|
+
const idx = await kenneyIndice();
|
|
121
|
+
const out = [];
|
|
122
|
+
for (const [i, q] of consultas.entries())
|
|
123
|
+
for (const m of idx) {
|
|
124
|
+
const p = puntaje(palabras(m.nombre), q);
|
|
125
|
+
if (p > 0) out.push({fuente: 'kenney', id: m.ruta, nombre: m.nombre, coleccion: m.kit, licencia: 'CC0', puntaje: p - i * 3, tipo: 'modelo'});
|
|
126
|
+
}
|
|
127
|
+
return out;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
async function bajarKenney(r) {
|
|
131
|
+
const url = `https://raw.githubusercontent.com/${KENNEY_REPO}/master/${r.id.split('/').map(encodeURIComponent).join('/')}`;
|
|
132
|
+
const local = path.join(BIBLIOTECA, 'kenney', r.id.replace(/[^A-Za-z0-9._/-]/g, '_'));
|
|
133
|
+
await bajar(url, local);
|
|
134
|
+
return {archivos: [local], principal: local, credito: `${r.nombre} · Kenney (kenney.nl) · CC0`};
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
// ---------------------------------------------------------------- Poly Haven (3D realista, CC0)
|
|
138
|
+
const polyhavenIndice = () => indice('polyhaven', () => pedirJSON('https://api.polyhaven.com/assets?t=models'));
|
|
139
|
+
|
|
140
|
+
async function buscarPolyhaven(consultas) {
|
|
141
|
+
const idx = await polyhavenIndice();
|
|
142
|
+
const out = [];
|
|
143
|
+
for (const [i, q] of consultas.entries())
|
|
144
|
+
for (const [id, v] of Object.entries(idx)) {
|
|
145
|
+
const nombre = palabras(`${v.name} ${id}`);
|
|
146
|
+
const extra = palabras([...(v.tags ?? []), ...(v.categories ?? [])].join(' '));
|
|
147
|
+
const p = Math.max(puntaje(nombre, q), puntaje([...nombre, ...extra], q) - 6);
|
|
148
|
+
if (p > 0) out.push({fuente: 'polyhaven', id, nombre: v.name, coleccion: (v.categories ?? [])[0] ?? '', licencia: 'CC0', puntaje: p - i * 3, tipo: 'modelo'});
|
|
149
|
+
}
|
|
150
|
+
return out;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
async function bajarPolyhaven(r, resolucion = '1k') {
|
|
154
|
+
const f = await pedirJSON(`https://api.polyhaven.com/files/${encodeURIComponent(r.id)}`);
|
|
155
|
+
const g = f.gltf?.[resolucion] ?? f.gltf?.['1k'] ?? Object.values(f.gltf ?? {})[0];
|
|
156
|
+
if (!g?.gltf) throw new ErrorMorfeo(`Poly Haven no tiene glTF para ${r.id}`);
|
|
157
|
+
const dir = path.join(BIBLIOTECA, 'polyhaven', r.id);
|
|
158
|
+
const principal = await bajar(g.gltf.url, path.join(dir, path.basename(new URL(g.gltf.url).pathname)));
|
|
159
|
+
const archivos = [principal];
|
|
160
|
+
for (const [rel, info] of Object.entries(g.gltf.include ?? {})) archivos.push(await bajar(info.url, path.join(dir, rel)));
|
|
161
|
+
return {archivos, principal, base: dir, credito: `${r.nombre} · Poly Haven (polyhaven.com) · CC0`};
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
// ---------------------------------------------------------------- Iconify (2D)
|
|
165
|
+
async function buscarIconify(consultas, variante = 'color') {
|
|
166
|
+
const sets = VARIANTES_2D[variante] ?? VARIANTES_2D.color;
|
|
167
|
+
const out = [];
|
|
168
|
+
for (const [i, q] of consultas.entries()) {
|
|
169
|
+
const r = await pedirJSON(`https://api.iconify.design/search?query=${encodeURIComponent(q)}&limit=48&prefixes=${sets.join(',')}`);
|
|
170
|
+
for (const [j, icono] of (r.icons ?? []).entries()) {
|
|
171
|
+
const [prefijo, nombre] = icono.split(':');
|
|
172
|
+
const col = r.collections?.[prefijo] ?? {};
|
|
173
|
+
const p = puntaje(palabras(nombre), q);
|
|
174
|
+
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});
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
return out;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
async function bajarIconify(r, color) {
|
|
181
|
+
const [prefijo, nombre] = r.id.split(':');
|
|
182
|
+
const params = r.mono ? `?height=512&color=${encodeURIComponent(color ?? '#ffffff')}` : '?height=512';
|
|
183
|
+
const local = path.join(BIBLIOTECA, 'iconify', prefijo, `${nombre}${r.mono ? '-' + (color ?? '#ffffff').replace('#', '') : ''}.svg`);
|
|
184
|
+
await bajar(`https://api.iconify.design/${prefijo}/${nombre}.svg${params}`, local);
|
|
185
|
+
return {archivos: [local], principal: local, credito: `${nombre} · ${r.coleccion} (Iconify) · ${r.licencia}`};
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
// ---------------------------------------------------------------- Poly Pizza (3D, opcional con clave)
|
|
189
|
+
async function buscarPolypizza(consultas) {
|
|
190
|
+
const clave = process.env.POLYPIZZA_API_KEY;
|
|
191
|
+
if (!clave) return [];
|
|
192
|
+
const out = [];
|
|
193
|
+
for (const [i, q] of consultas.entries()) {
|
|
194
|
+
const r = await pedirJSON(`https://api.poly.pizza/v1/search/${encodeURIComponent(q)}?limit=24`, {headers: {'X-Auth-Token': clave}});
|
|
195
|
+
const lista = Array.isArray(r) ? r : r.results ?? r.Results ?? [];
|
|
196
|
+
for (const [j, m] of lista.entries())
|
|
197
|
+
if (m.Download)
|
|
198
|
+
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});
|
|
199
|
+
}
|
|
200
|
+
return out;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
async function bajarPolypizza(r) {
|
|
204
|
+
const local = path.join(BIBLIOTECA, 'polypizza', `${String(r.id).replace(/[^A-Za-z0-9_-]/g, '_')}.glb`);
|
|
205
|
+
await bajar(r.url, local);
|
|
206
|
+
const cc0 = /cc0|public/i.test(r.licencia);
|
|
207
|
+
return {archivos: [local], principal: local, credito: cc0 ? `${r.nombre} · Poly Pizza · CC0` : `"${r.nombre}" by ${r.autor} (poly.pizza) ${r.licencia}`};
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
// ---------------------------------------------------------------- búsqueda y descarga
|
|
211
|
+
/** Orden de fuentes por estilo. */
|
|
212
|
+
const FUENTES = {'3d': ['kenney', 'polypizza', 'polyhaven'], realista: ['polyhaven', 'polypizza', 'kenney'], '2d': ['iconify']};
|
|
213
|
+
|
|
214
|
+
/**
|
|
215
|
+
* Busca un término en las bibliotecas.
|
|
216
|
+
* @returns {Promise<Array<{fuente, id, nombre, coleccion, licencia, tipo}>>} ordenados del mejor al peor
|
|
217
|
+
*/
|
|
218
|
+
export async function buscarRecursos(termino, {estilo = '3d', variante, fuente} = {}) {
|
|
219
|
+
const {buscar} = traducir(termino);
|
|
220
|
+
const fuentes = fuente ? [fuente] : FUENTES[estilo] ?? FUENTES['3d'];
|
|
221
|
+
const todos = [];
|
|
222
|
+
for (const f of fuentes) {
|
|
223
|
+
try {
|
|
224
|
+
const r = f === 'kenney' ? await buscarKenney(buscar) : f === 'polyhaven' ? await buscarPolyhaven(buscar) : f === 'polypizza' ? await buscarPolypizza(buscar) : await buscarIconify(buscar, variante);
|
|
225
|
+
// el orden de las fuentes pesa: la primera fuente con resultados buenos gana
|
|
226
|
+
todos.push(...r.map((x) => ({...x, puntaje: x.puntaje - fuentes.indexOf(f) * 6})));
|
|
227
|
+
} catch (e) {
|
|
228
|
+
if (fuente) throw e; // con fuente explícita, el error importa; si no, se prueba la siguiente
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
const vistos = new Set();
|
|
232
|
+
return todos
|
|
233
|
+
.sort((a, b) => b.puntaje - a.puntaje)
|
|
234
|
+
.filter((r) => (vistos.has(r.fuente + r.id) ? false : vistos.add(r.fuente + r.id)));
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
/** Descarga un resultado. Devuelve {principal, archivos, base?, credito}. */
|
|
238
|
+
export async function bajarRecurso(r, {color} = {}) {
|
|
239
|
+
if (r.fuente === 'kenney') return bajarKenney(r);
|
|
240
|
+
if (r.fuente === 'polyhaven') return bajarPolyhaven(r);
|
|
241
|
+
if (r.fuente === 'polypizza') return bajarPolypizza(r);
|
|
242
|
+
return bajarIconify(r, color);
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
/**
|
|
246
|
+
* Cambia cada {tipo: 'recurso'} del video por una capa de modelo o imagen ya descargada en public/assets/biblioteca.
|
|
247
|
+
* Devuelve {video, creditos}.
|
|
248
|
+
*/
|
|
249
|
+
export async function resolverRecursos(video, assetsPublico, log = console.log) {
|
|
250
|
+
const creditos = new Set();
|
|
251
|
+
const resolver = async (c) => {
|
|
252
|
+
const {tipo, buscar, estilo = '3d', variante, fuente, indice: n = 0, color, ...resto} = c;
|
|
253
|
+
if (!buscar) throw new ErrorMorfeo('Una capa {tipo: "recurso"} necesita `buscar` (por ejemplo, buscar: "tubería")');
|
|
254
|
+
let res = await buscarRecursos(buscar, {estilo, variante, fuente});
|
|
255
|
+
// sin resultados en ese estilo: se intenta en los otros antes de fallar (3d → realista → 2d)
|
|
256
|
+
if (!res.length && !fuente)
|
|
257
|
+
for (const otro of ['3d', 'realista', '2d'].filter((e) => e !== estilo)) {
|
|
258
|
+
res = await buscarRecursos(buscar, {estilo: otro, variante});
|
|
259
|
+
if (res.length) {
|
|
260
|
+
log(` "${buscar}" no está en ${estilo}; uso ${otro}`);
|
|
261
|
+
break;
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
if (!res.length)
|
|
265
|
+
throw new ErrorMorfeo(
|
|
266
|
+
`No encontré "${buscar}" (${estilo}${variante ? ' ' + variante : ''}). Prueba un sinónimo, escríbelo en inglés (buscar: 'barrel'), otro estilo ('2d', '3d', 'realista'), o mira npx morfeo recursos buscar "${buscar}"`,
|
|
267
|
+
);
|
|
268
|
+
const r = res[Math.min(n, res.length - 1)];
|
|
269
|
+
const d = await bajarRecurso(r, {color});
|
|
270
|
+
creditos.add(d.credito);
|
|
271
|
+
// se copia a public/assets/biblioteca/<fuente>/... conservando la carpeta (los glTF de Poly Haven traen texturas aparte)
|
|
272
|
+
const base = d.base ?? path.dirname(d.principal);
|
|
273
|
+
for (const a of d.archivos) {
|
|
274
|
+
const destino = path.join(assetsPublico, 'biblioteca', path.relative(BIBLIOTECA, a));
|
|
275
|
+
if (!fs.existsSync(destino)) {
|
|
276
|
+
fs.mkdirSync(path.dirname(destino), {recursive: true});
|
|
277
|
+
fs.copyFileSync(a, destino);
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
const src = path.relative(path.join(assetsPublico), path.join(assetsPublico, 'biblioteca', path.relative(BIBLIOTECA, d.principal))).split(path.sep).join('/');
|
|
281
|
+
log(` recurso "${buscar}" → ${r.nombre} (${r.fuente})`);
|
|
282
|
+
return r.tipo === 'modelo'
|
|
283
|
+
? // modelos externos: cámara más cerca y menos luz ambiente para que no se vean chicos ni deslavados
|
|
284
|
+
{tipo: 'modelo', src, ancho: 620, alto: 620, girar3d: [0, 30, 0], distancia: 4.3, camaraY: 0.9, luzAmbiente: 0.25, reflejos: 0.45, luz: 1.6, ...resto}
|
|
285
|
+
: {tipo: 'imagen', src, ancho: 460, sombra: true, ...resto};
|
|
286
|
+
void base;
|
|
287
|
+
};
|
|
288
|
+
const recorrer = async (x) => {
|
|
289
|
+
if (Array.isArray(x)) return Promise.all(x.map(recorrer));
|
|
290
|
+
if (!x || typeof x !== 'object') return x;
|
|
291
|
+
if (x.tipo === 'recurso') return resolver(x);
|
|
292
|
+
const out = {};
|
|
293
|
+
for (const [k, v] of Object.entries(x)) out[k] = await recorrer(v);
|
|
294
|
+
return out;
|
|
295
|
+
};
|
|
296
|
+
const scenes = await recorrer(video.scenes);
|
|
297
|
+
const hud = await recorrer(video.hud ?? []);
|
|
298
|
+
return {video: {...video, scenes, hud}, creditos: [...creditos]};
|
|
299
|
+
}
|
package/lib/render.mjs
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
// Render: audio (Python) -> datos -> video (Remotion). También lista y Studio.
|
|
2
2
|
import {spawn, spawnSync} from 'node:child_process';
|
|
3
3
|
import crypto from 'node:crypto';
|
|
4
|
+
import os from 'node:os';
|
|
4
5
|
import fs from 'node:fs';
|
|
5
6
|
import {createRequire} from 'node:module';
|
|
6
7
|
import path from 'node:path';
|
|
@@ -9,6 +10,7 @@ import {ENTRADA_REMOTION, FUENTES, MODELOS, PY_DIR, modelosListos, pythonVenv, v
|
|
|
9
10
|
import {asegurarNavegador} from './navegador.mjs';
|
|
10
11
|
import {generarImagenes} from './imagenes.mjs';
|
|
11
12
|
import {ponerPoses, prepararPersonajes} from './personajes.mjs';
|
|
13
|
+
import {resolverRecursos} from './recursos.mjs';
|
|
12
14
|
import {sintetizarVoces} from './voces.mjs';
|
|
13
15
|
import {transcribir, whisperListo} from './whisper.mjs';
|
|
14
16
|
|
|
@@ -156,12 +158,16 @@ export async function preparar({proyecto = process.cwd(), ids, log = console.log
|
|
|
156
158
|
const datos = {};
|
|
157
159
|
const poses = await prepararPersonajes(p, path.join(publico, 'assets'), log);
|
|
158
160
|
const trabajos = path.join(p.trabajo, 'trabajos');
|
|
161
|
+
const creditos = {};
|
|
159
162
|
fs.mkdirSync(trabajos, {recursive: true});
|
|
160
163
|
for (const [i, id] of (ids ?? todos).entries()) {
|
|
161
164
|
const original = p.videos[id];
|
|
162
165
|
if (!original) throw new ErrorMorfeo(`No existe el video "${id}". Usa: npx morfeo lista`);
|
|
163
166
|
log(`[${i + 1}/${(ids ?? todos).length}] ${id}: audio...`);
|
|
164
|
-
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);
|
|
165
171
|
const video = await sintetizarVoces(id, conImagenes, path.join(p.trabajo, 'cache', 'voces'), log);
|
|
166
172
|
const job = path.join(trabajos, `${id}.json`);
|
|
167
173
|
const base = {id, video, indice: todos.indexOf(id), publico, modelos: MODELOS};
|
|
@@ -175,7 +181,7 @@ export async function preparar({proyecto = process.cwd(), ids, log = console.log
|
|
|
175
181
|
python(job);
|
|
176
182
|
datos[id] = datosDe(publico, id);
|
|
177
183
|
}
|
|
178
|
-
return {p, publico, marca, extras, datos};
|
|
184
|
+
return {p, publico, marca, extras, datos, creditos};
|
|
179
185
|
}
|
|
180
186
|
|
|
181
187
|
/**
|
|
@@ -205,37 +211,70 @@ export async function render({proyecto = process.cwd(), ids, todos = false, log
|
|
|
205
211
|
const chromiumOptions = {gl: p.render.gl};
|
|
206
212
|
const browserExecutable = p.render.navegador ?? (await asegurarNavegador(log));
|
|
207
213
|
const hechos = [];
|
|
214
|
+
// cuadros en paralelo: con GPU aguanta más; por software cada pestaña come mucha CPU y memoria
|
|
215
|
+
const nucleos = os.cpus().length;
|
|
216
|
+
const concurrenciaBase = p.render.concurrencia ?? (p.render.gl === 'angle' ? Math.max(2, Math.min(6, Math.floor(nucleos / 2))) : Math.max(1, Math.min(3, Math.floor(nucleos / 4))));
|
|
217
|
+
const sinTTY = !process.stdout.isTTY;
|
|
208
218
|
for (const [i, id] of lista.entries()) {
|
|
209
219
|
const t = Date.now();
|
|
210
220
|
const inputProps = {videos: {[id]: datos[id]}, marca, ...extras};
|
|
211
|
-
const composition = await selectComposition({serveUrl, id, inputProps, browserExecutable, chromiumOptions});
|
|
212
|
-
let ultimo = -1;
|
|
213
221
|
const mp4 = salidaDe(p, id);
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
222
|
+
let gl = p.render.gl;
|
|
223
|
+
let concurrencia = concurrenciaBase;
|
|
224
|
+
let composition;
|
|
225
|
+
// hasta 3 intentos: si el navegador truena, se baja la concurrencia y, con GPU, se pasa a software
|
|
226
|
+
for (let intento = 1; ; intento++) {
|
|
227
|
+
const chromium = {gl};
|
|
228
|
+
let ultimo = -1;
|
|
229
|
+
try {
|
|
230
|
+
composition = await selectComposition({serveUrl, id, inputProps, browserExecutable, chromiumOptions: chromium});
|
|
231
|
+
await renderMedia({
|
|
232
|
+
serveUrl,
|
|
233
|
+
composition,
|
|
234
|
+
inputProps,
|
|
235
|
+
browserExecutable,
|
|
236
|
+
chromiumOptions: chromium,
|
|
237
|
+
codec: 'h264',
|
|
238
|
+
crf: p.render.crf,
|
|
239
|
+
audioBitrate: '192k',
|
|
240
|
+
imageFormat: 'jpeg',
|
|
241
|
+
jpegQuality: 92,
|
|
242
|
+
concurrency: concurrencia,
|
|
243
|
+
outputLocation: mp4,
|
|
244
|
+
onProgress: ({progress}) => {
|
|
245
|
+
alProgreso?.(id, progress);
|
|
246
|
+
// con terminal: cada 10 % en la misma línea; sin terminal (logs, CI): cada 25 %
|
|
247
|
+
const paso = sinTTY ? 4 : 10;
|
|
248
|
+
const n = Math.floor(progress * paso);
|
|
249
|
+
if (n !== ultimo) {
|
|
250
|
+
if (sinTTY) log(`[${i + 1}/${lista.length}] ${id}: video ${Math.round((n / paso) * 100)}%`);
|
|
251
|
+
else process.stdout.write(`\r[${i + 1}/${lista.length}] ${id}: video ${n * 10}% `);
|
|
252
|
+
}
|
|
253
|
+
ultimo = n;
|
|
254
|
+
},
|
|
255
|
+
});
|
|
256
|
+
chromiumOptions.gl = gl;
|
|
257
|
+
break;
|
|
258
|
+
} catch (e) {
|
|
259
|
+
const msg = String(e?.message ?? e);
|
|
260
|
+
const truena = /crash|Session closed|Target closed|Protocol error|WebGL|context lost|browser has disconnected|Navigation failed/i.test(msg);
|
|
261
|
+
if (!truena || intento >= 3) throw e;
|
|
262
|
+
if (gl === 'angle' && /WebGL|context/i.test(msg)) gl = 'swangle';
|
|
263
|
+
else if (concurrencia > 1) concurrencia = Math.max(1, Math.floor(concurrencia / 2));
|
|
264
|
+
else if (gl === 'angle') gl = 'swangle';
|
|
265
|
+
log(`\n el navegador falló; reintento con ${gl === 'angle' ? 'GPU' : 'CPU'} y ${concurrencia} cuadro(s) a la vez...`);
|
|
266
|
+
}
|
|
267
|
+
}
|
|
234
268
|
let jpg = null;
|
|
235
269
|
if (p.render.miniatura) {
|
|
236
|
-
// miniatura:
|
|
270
|
+
// miniatura: cuando la primera escena ya está completa (o en `miniaturaEn`), sin subtítulos
|
|
237
271
|
jpg = path.join(p.salida, `${id}.jpg`);
|
|
238
|
-
|
|
272
|
+
const d = datos[id];
|
|
273
|
+
const e0 = d.escenas[0];
|
|
274
|
+
const seg = p.videos[id].miniaturaEn ?? Math.min(e0.dur - 0.15, Math.max(1.4, e0.habla[1] * 0.85));
|
|
275
|
+
const frame = Math.max(0, Math.min(composition.durationInFrames - 1, Math.round(seg * composition.fps)));
|
|
276
|
+
const props = {...inputProps, miniatura: true};
|
|
277
|
+
await renderStill({serveUrl, composition: {...composition, props: {...composition.props, miniatura: true}}, inputProps: props, browserExecutable, chromiumOptions: {gl}, frame, imageFormat: 'jpeg', jpegQuality: 92, output: jpg});
|
|
239
278
|
}
|
|
240
279
|
const segundos = Math.round((Date.now() - t) / 1000);
|
|
241
280
|
if (process.stdout.isTTY) process.stdout.write('\r');
|
|
@@ -247,12 +286,33 @@ export async function render({proyecto = process.cwd(), ids, todos = false, log
|
|
|
247
286
|
}
|
|
248
287
|
|
|
249
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
|
+
|
|
250
303
|
function escribirResumen(p) {
|
|
251
304
|
const publico = path.join(p.trabajo, 'public');
|
|
252
305
|
const filas = Object.entries(p.videos)
|
|
253
306
|
.filter(([id]) => fs.existsSync(salidaDe(p, id)))
|
|
254
|
-
.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
|
+
});
|
|
255
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'));
|
|
256
316
|
}
|
|
257
317
|
|
|
258
318
|
/** Abre Remotion Studio con los videos ya preparados (para verlos y ajustar en vivo). */
|
package/package.json
CHANGED
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "morfeo",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.2",
|
|
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",
|
|
7
7
|
"author": "Uriel Gómez",
|
|
8
|
-
"homepage": "https://github.
|
|
8
|
+
"homepage": "https://ureckchan.github.io/morfeo/",
|
|
9
9
|
"repository": {
|
|
10
10
|
"type": "git",
|
|
11
11
|
"url": "git+https://github.com/UreckChan/morfeo.git"
|
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/Short.tsx
CHANGED
|
@@ -30,7 +30,7 @@ import {temaDe} from './motor/tema';
|
|
|
30
30
|
import {VideoConAudio} from './motor/Video';
|
|
31
31
|
import {loadFont} from '@remotion/fonts';
|
|
32
32
|
|
|
33
|
-
export type PropsShort = {id: string; videos?: Record<string, Datos>; marca?: TipoMarca; guias?: boolean; fuentes?: {familia: string; url: string}[]; estilos?: Record<string, any>};
|
|
33
|
+
export type PropsShort = {id: string; videos?: Record<string, Datos>; marca?: TipoMarca; guias?: boolean; miniatura?: boolean; fuentes?: {familia: string; url: string}[]; estilos?: Record<string, any>};
|
|
34
34
|
|
|
35
35
|
const ADELANTO_GANCHO = 0.4; // el gancho arranca ya animado: el fotograma 0 nunca está vacío
|
|
36
36
|
const CIERRE_BUCLE = 0.5; // al final se funde con el fotograma 0 para que el video se repita sin corte
|
|
@@ -220,7 +220,7 @@ const Guias: React.FC = () => (
|
|
|
220
220
|
</AbsoluteFill>
|
|
221
221
|
);
|
|
222
222
|
|
|
223
|
-
export const Fotograma: React.FC<{d: Datos; T: number; guias?: boolean}> = ({d, T, guias}) => {
|
|
223
|
+
export const Fotograma: React.FC<{d: Datos; T: number; guias?: boolean; miniatura?: boolean}> = ({d, T, guias, miniatura}) => {
|
|
224
224
|
const n = d.escenas.length;
|
|
225
225
|
let si = n - 1;
|
|
226
226
|
for (let i = 0; i < n; i++) if (T < d.escenas[i].t0 + d.escenas[i].dur) {
|
|
@@ -238,7 +238,7 @@ export const Fotograma: React.FC<{d: Datos; T: number; guias?: boolean}> = ({d,
|
|
|
238
238
|
<Fondo T={T} d={d} e={e} t={t} salida={si === n - 1 ? 0 : clamp((t - (e.dur - 0.12)) / 0.12)} />
|
|
239
239
|
<CapaEscena e={e} si={si} t={t} d={d} ultima={si === n - 1} />
|
|
240
240
|
{remate && <Destello t={t} semilla={d.indice} />}
|
|
241
|
-
<Subtitulos T={T} d={d} />
|
|
241
|
+
{miniatura ? null : <Subtitulos T={T} d={d} />}
|
|
242
242
|
</AbsoluteFill>
|
|
243
243
|
{e.tipo !== 'cta' && <Marca />}
|
|
244
244
|
{guias && <Guias />}
|
|
@@ -246,7 +246,7 @@ export const Fotograma: React.FC<{d: Datos; T: number; guias?: boolean}> = ({d,
|
|
|
246
246
|
);
|
|
247
247
|
};
|
|
248
248
|
|
|
249
|
-
export const Short: React.FC<PropsShort> = ({id, videos, marca, guias, fuentes, estilos}) => {
|
|
249
|
+
export const Short: React.FC<PropsShort> = ({id, videos, marca, guias, miniatura, fuentes, estilos}) => {
|
|
250
250
|
const frame = useCurrentFrame();
|
|
251
251
|
const [listo, setListo] = useState(false);
|
|
252
252
|
const [handle] = useState(() => delayRender('Cargando fuentes'));
|
|
@@ -259,13 +259,13 @@ export const Short: React.FC<PropsShort> = ({id, videos, marca, guias, fuentes,
|
|
|
259
259
|
const d = videos?.[id];
|
|
260
260
|
if (!d) throw new Error(`El video "${id}" no está preparado. Corre: npx morfeo render ${id}`);
|
|
261
261
|
if (!listo) return null;
|
|
262
|
-
if (d.estilo && d.estilo !== 'datos-curiosos') return <VideoConAudio d={d} frame={frame} marca={{...MARCA_VACIA, ...marca}} estilos={estilos} />;
|
|
262
|
+
if (d.estilo && d.estilo !== 'datos-curiosos') return <VideoConAudio d={d} frame={frame} marca={{...MARCA_VACIA, ...marca}} estilos={estilos} miniatura={miniatura} />;
|
|
263
263
|
const T = frame / FPS;
|
|
264
264
|
const kb = clamp((T - (d.duracion - CIERRE_BUCLE)) / CIERRE_BUCLE);
|
|
265
265
|
return (
|
|
266
266
|
<MarcaContexto.Provider value={{...MARCA_VACIA, ...marca}}>
|
|
267
267
|
<AbsoluteFill>
|
|
268
|
-
<Fotograma d={d} T={T} guias={guias} />
|
|
268
|
+
<Fotograma d={d} T={T} guias={guias} miniatura={miniatura} />
|
|
269
269
|
{kb > 0 && (
|
|
270
270
|
<AbsoluteFill style={{opacity: backOut(kb, 0)}}>
|
|
271
271
|
<Fotograma d={d} T={0} />
|
package/remotion/motor/Video.tsx
CHANGED
|
@@ -5,7 +5,7 @@ import {Avatar, MarcaContexto, useMarca} from '../piezas';
|
|
|
5
5
|
import {FPS, W, clamp, easeIO, easeOut, lerp} from '../estilo';
|
|
6
6
|
import type {Datos, Escena} from '../tipos';
|
|
7
7
|
import {Capas} from './capas';
|
|
8
|
-
import {FondoTema, Vineta} from './fondo';
|
|
8
|
+
import {FondoTema, Textura, Vineta} from './fondo';
|
|
9
9
|
import {capasDe} from './plantillas';
|
|
10
10
|
import {Subtitulos} from './subtitulos';
|
|
11
11
|
import {mezclar, temaDe, type Tema} from './tema';
|
|
@@ -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} />
|
|
@@ -66,7 +66,7 @@ const MarcaEsquina: React.FC<{tema: Tema}> = ({tema}) => {
|
|
|
66
66
|
);
|
|
67
67
|
};
|
|
68
68
|
|
|
69
|
-
export const VideoMotor: React.FC<{d: Datos; T: number; estilos?: Record<string, any
|
|
69
|
+
export const VideoMotor: React.FC<{d: Datos; T: number; estilos?: Record<string, any>; miniatura?: boolean}> = ({d, T, estilos, miniatura}) => {
|
|
70
70
|
const tema = temaDe(d.estilo, d.tema, estilos);
|
|
71
71
|
const n = d.escenas.length;
|
|
72
72
|
let si = d.escenas.findIndex((e) => T < e.t0 + e.dur);
|
|
@@ -100,19 +100,21 @@ export const VideoMotor: React.FC<{d: Datos; T: number; estilos?: Record<string,
|
|
|
100
100
|
<AbsoluteFill style={estiloActual}>
|
|
101
101
|
<EscenaVista e={e} t={t} tema={tema} semilla={semilla} />
|
|
102
102
|
</AbsoluteFill>
|
|
103
|
+
{/* la textura va quieta, fuera de la cámara: si se moviera con el zoom, cada cuadro sería distinto y el video pesaría 10 veces más */}
|
|
104
|
+
{tema.textura && tema.textura !== 'ninguna' ? <Textura tipo={tema.textura} /> : null}
|
|
103
105
|
<Vineta fuerza={tema.vineta ?? 0} />
|
|
104
106
|
{d.hud?.length ? <Hud d={d} T={T} tema={tema} /> : null}
|
|
105
|
-
{e.tipo !== 'cta' ? <Subtitulos tema={e.p.tema ? mezclar(tema, e.p.tema) : tema} palabras={e.palabras ?? []} t={t} dur={e.dur} /> : null}
|
|
107
|
+
{e.tipo !== 'cta' && !miniatura ? <Subtitulos tema={e.p.tema ? mezclar(tema, e.p.tema) : tema} palabras={e.palabras ?? []} t={t} dur={e.dur} /> : null}
|
|
106
108
|
{e.tipo !== 'cta' ? <MarcaEsquina tema={tema} /> : null}
|
|
107
109
|
</AbsoluteFill>
|
|
108
110
|
);
|
|
109
111
|
};
|
|
110
112
|
|
|
111
113
|
/** Componente raíz de un video del motor de capas (con su audio). */
|
|
112
|
-
export const VideoConAudio: React.FC<{d: Datos; frame: number; marca: any; estilos?: Record<string, any
|
|
114
|
+
export const VideoConAudio: React.FC<{d: Datos; frame: number; marca: any; estilos?: Record<string, any>; miniatura?: boolean}> = ({d, frame, marca, estilos, miniatura}) => (
|
|
113
115
|
<MarcaContexto.Provider value={marca}>
|
|
114
116
|
<AbsoluteFill>
|
|
115
|
-
<VideoMotor d={d} T={frame / FPS} estilos={estilos} />
|
|
117
|
+
<VideoMotor d={d} T={frame / FPS} estilos={estilos} miniatura={miniatura} />
|
|
116
118
|
<Audio src={staticFile(d.audio)} />
|
|
117
119
|
</AbsoluteFill>
|
|
118
120
|
</MarcaContexto.Provider>
|