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.
@@ -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 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);
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
- await renderMedia({
215
- serveUrl,
216
- composition,
217
- inputProps,
218
- browserExecutable,
219
- chromiumOptions,
220
- codec: 'h264',
221
- crf: p.render.crf,
222
- audioBitrate: '192k',
223
- imageFormat: 'jpeg',
224
- jpegQuality: 92,
225
- concurrency: p.render.concurrencia,
226
- outputLocation: mp4,
227
- onProgress: ({progress}) => {
228
- alProgreso?.(id, progress);
229
- const n = Math.floor(progress * 10);
230
- if (n !== ultimo && process.stdout.isTTY) process.stdout.write(`\r[${i + 1}/${lista.length}] ${id}: video ${n * 10}% `);
231
- ultimo = n;
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: el gancho ya completo
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
- await renderStill({serveUrl, composition, inputProps, browserExecutable, chromiumOptions, frame: Math.min(36, composition.durationInFrames - 1), imageFormat: 'jpeg', jpegQuality: 92, output: jpg});
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]) => ({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
+ });
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.0",
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.com/UreckChan/morfeo#readme",
8
+ "homepage": "https://ureckchan.github.io/morfeo/",
9
9
  "repository": {
10
10
  "type": "git",
11
11
  "url": "git+https://github.com/UreckChan/morfeo.git"
@@ -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 @@ 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} />
@@ -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>}> = ({d, T, estilos}) => {
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>}> = ({d, frame, marca, estilos}) => (
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>