morfeo 0.3.1 → 0.3.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 CHANGED
@@ -1,6 +1,6 @@
1
1
  # Morfeo
2
2
 
3
- > **Documentación: [ureckchan.github.io/morfeo](https://ureckchan.github.io/morfeo/)** · Ejemplos: [github.com/UreckChan/morfeo](https://github.com/UreckChan/morfeo)
3
+ > **Documentación: [morfeo-docs-two.vercel.app](https://morfeo-docs-two.vercel.app/)** · Ejemplos: [github.com/UreckChan/morfeo](https://github.com/UreckChan/morfeo) · Versiones: [historial](https://morfeo-docs-two.vercel.app/historial)
4
4
 
5
5
  Genera videos verticales animados (Shorts, TikTok, Reels) desde código: escribes las escenas y Morfeo agrega
6
6
  la voz, la música, los efectos de sonido, los subtítulos, las animaciones y la miniatura.
package/bin/morfeo.mjs CHANGED
@@ -138,7 +138,9 @@ Pruébalo: npx morfeo render ${pos[0]} y revísalo: npx morfeo hoja ${pos[0]
138
138
  const p = await cargarProyecto(proyecto);
139
139
  const num = (v) => (v == null || v === true ? undefined : Number(v));
140
140
  for (const id of pos) {
141
- const video = /\.(mp4|mov|webm)$/i.test(id) ? path.resolve(id) : path.join(p.salida, `${id}.mp4`);
141
+ const {salidaDe, ordenarSalida} = await import('../lib/salida.mjs');
142
+ ordenarSalida(p);
143
+ const video = /\.(mp4|mov|webm)$/i.test(id) ? path.resolve(id) : salidaDe(p, id);
142
144
  console.log(hoja({video, cols: num(op.cols), filas: num(op.filas), desde: num(op.desde), hasta: num(op.hasta)}));
143
145
  }
144
146
  return;
package/lib/config.mjs CHANGED
@@ -158,6 +158,8 @@ export async function cargarProyecto(proyecto = process.cwd()) {
158
158
  concurrencia: Number(process.env.MORFEO_CONCURRENCIA) || r.concurrencia || null, // null = Morfeo elige según la máquina y el modo (ver render.mjs)
159
159
  miniatura: r.miniatura ?? true,
160
160
  consejos: r.consejos ?? true,
161
+ // 'titulo' (por defecto): salida/<Título del video>.mp4, listo para subir · 'id': salida/<id>.mp4
162
+ nombreArchivo: r.nombreArchivo ?? 'titulo',
161
163
  },
162
164
  };
163
165
  }
@@ -349,6 +351,7 @@ function validarVideo(id, v, ctx) {
349
351
  if (clasico) err('`mundo` es del motor de capas: usa un estilo como ilustrado, 3d o mesa (no "datos-curiosos")');
350
352
  }
351
353
  if (v.serie != null && typeof v.serie !== 'string') err("`serie` debe ser un texto ('GYM CON CIENCIA · PARTE 5')");
354
+ if (v.title != null && typeof v.title !== 'string') err('`title` debe ser un texto (también es el nombre del archivo del video)');
352
355
  if (v.calidad != null && !['alta', 'media', 'ligera'].includes(v.calidad)) err("`calidad` debe ser 'alta', 'media' o 'ligera'");
353
356
  if (v.tags != null && (!Array.isArray(v.tags) || v.tags.some((x) => typeof x !== 'string'))) err("`tags` debe ser una lista de textos (['curiosidades', 'ciencia'])");
354
357
  // archivos de assets/ que piden las escenas y el HUD: mejor avisar ahora que a mitad del render
@@ -106,7 +106,7 @@ export function revisarEstructura(v, d) {
106
106
  // 5) habla de tú
107
107
  const todo = cuerpo.map((s) => s.say ?? '').join(' ');
108
108
  const n = palabrasDe(todo).length;
109
- const tu = (todo.match(/\b(tú|tu|tus|te|ti|contigo|imagina|tienes|eres|puedes|sabes)\b/gi) ?? []).length;
109
+ const tu = (todo.match(/\b(tú|tu|tus|te|ti|contigo|imagina|imaginas|tienes|eres|puedes|sabes|sabías|crees|conoces|harías|pagarías)\b/gi) ?? []).length;
110
110
  if (n > 40 && tu === 0) add('tu', 'idea', 'No le habla al espectador ni una vez: una frase en segunda persona ("imagina que tú…") lo mete en la historia.');
111
111
  return out;
112
112
  }
package/lib/render.mjs CHANGED
@@ -6,7 +6,7 @@ import fs from 'node:fs';
6
6
  import {createRequire} from 'node:module';
7
7
  import path from 'node:path';
8
8
  import {cargarProyecto, ErrorMorfeo} from './config.mjs';
9
- import {ENTRADA_REMOTION, FUENTES, MODELOS, PY_DIR, modelosListos, pythonVenv, venvListo} from './entorno.mjs';
9
+ import {ENTRADA_REMOTION, FUENTES, MODELOS, PAQUETE, PY_DIR, modelosListos, pythonVenv, venvListo} from './entorno.mjs';
10
10
  import {asegurarNavegador} from './navegador.mjs';
11
11
  import {generarImagenes} from './imagenes.mjs';
12
12
  import {ponerPoses, prepararPersonajes} from './personajes.mjs';
@@ -15,6 +15,7 @@ import {sintetizarVoces} from './voces.mjs';
15
15
  import {transcribir, whisperListo} from './whisper.mjs';
16
16
  import {consejosEnTexto, revisarEstructura} from './estructura.mjs';
17
17
  import {revisarPalabras} from './palabras.mjs';
18
+ import {ordenarSalida, salidaDe} from './salida.mjs';
18
19
 
19
20
  const require = createRequire(import.meta.url);
20
21
 
@@ -71,7 +72,13 @@ function huellaMotor() {
71
72
  }
72
73
 
73
74
  // Remotion calcula la clave del caché a partir de la configuración: meter la huella ahí la invalida
74
- const conHuella = (config) => ({...config, name: `morfeo-${huellaMotor()}`});
75
+ // También: los componentes del proyecto pueden importar 'three', 'react' o '@react-three/fiber' aunque no estén instalados
76
+ // en el proyecto (llegan como dependencias de morfeo; con `npm i ../morfeo` o pnpm no quedan en su node_modules).
77
+ const conHuella = (config) => ({
78
+ ...config,
79
+ name: `morfeo-${huellaMotor()}`,
80
+ resolve: {...config.resolve, modules: [...(config.resolve?.modules ?? ['node_modules']), path.join(PAQUETE, 'node_modules')]},
81
+ });
75
82
 
76
83
  /**
77
84
  * Entrada de Remotion. Sin componentes propios es la del paquete; con ellos, Morfeo escribe
@@ -128,13 +135,13 @@ export const datosDe = (publico, id) => {
128
135
  return fs.existsSync(f) ? JSON.parse(fs.readFileSync(f, 'utf8')) : null;
129
136
  };
130
137
 
131
- const salidaDe = (p, id) => path.join(p.salida, `${id}.mp4`);
132
138
 
133
139
  /** [{id, titulo, hecho}] en el orden del proyecto. */
134
140
  export async function lista({proyecto = process.cwd()} = {}) {
135
141
  cargarEnv(proyecto);
136
142
  const p = await cargarProyecto(proyecto);
137
- return Object.entries(p.videos).map(([id, v]) => ({id, titulo: v.title ?? id, hecho: fs.existsSync(salidaDe(p, id))}));
143
+ ordenarSalida(p);
144
+ return Object.entries(p.videos).map(([id, v]) => ({id, titulo: v.title ?? id, archivo: path.basename(salidaDe(p, id)), hecho: fs.existsSync(salidaDe(p, id))}));
138
145
  }
139
146
 
140
147
  /** Corre python/preparar.py con el trabajo indicado; su salida va directo a la terminal. */
@@ -218,6 +225,8 @@ export async function render({proyecto = process.cwd(), ids, todos = false, solo
218
225
  return [];
219
226
  }
220
227
  }
228
+ // archivos ya hechos con el id o con un título viejo → su nombre actual (sin volver a renderizar)
229
+ ordenarSalida(p, (m) => log(m));
221
230
  let lista = ids?.length ? ids : todos ? Object.keys(p.videos) : Object.keys(p.videos).filter((id) => !fs.existsSync(salidaDe(p, id)));
222
231
  const malos = lista.filter((id) => !p.videos[id]);
223
232
  if (malos.length) throw new ErrorMorfeo(`No existen: ${malos.join(', ')}. Usa: npx morfeo lista`);
@@ -236,90 +245,97 @@ export async function render({proyecto = process.cwd(), ids, todos = false, solo
236
245
  }
237
246
 
238
247
  if (!listos.length) return terminarLote(p, loteArchivo, [], fallidos, log);
239
- log('Empaquetando la animación...');
240
- const {bundle} = await import('@remotion/bundler');
241
- const {renderMedia, renderStill, selectComposition} = await import('@remotion/renderer');
242
- const entrada = entradaDe(p);
243
- const serveUrl = await bundle({entryPoint: entrada, publicDir: publico, rootDir: entrada === ENTRADA_REMOTION ? path.dirname(ENTRADA_REMOTION) : p.proyecto, webpackOverride: conHuella});
244
- fs.mkdirSync(p.salida, {recursive: true});
245
- const chromiumOptions = {gl: p.render.gl};
246
- const browserExecutable = p.render.navegador ?? (await asegurarNavegador(log));
248
+ // un solo render a la vez en esta computadora: dos Chrome usando la GPU al mismo tiempo dejaban en algunos
249
+ // cuadros pedazos de texto de cuadros anteriores (rótulos o subtítulos fantasma)
247
250
  const hechos = [];
248
- // cuadros en paralelo: con GPU aguanta más; por software cada pestaña come mucha CPU y memoria
249
- const nucleos = os.cpus().length;
250
- const concurrenciaBase = p.render.concurrencia ?? (p.render.gl === 'angle' ? Math.max(2, Math.min(10, Math.floor(nucleos * 0.75))) : Math.max(1, Math.min(3, Math.floor(nucleos / 4))));
251
- const sinTTY = !process.stdout.isTTY;
252
- for (const [i, id] of listos.entries()) {
253
- const t = Date.now();
254
- try {
255
- const inputProps = {videos: {[id]: datos[id]}, marca, ...extras};
256
- const mp4 = salidaDe(p, id);
257
- let gl = p.render.gl;
258
- let concurrencia = concurrenciaBase;
259
- let composition;
260
- // hasta 3 intentos: si el navegador truena, se baja la concurrencia y, con GPU, se pasa a software
261
- for (let intento = 1; ; intento++) {
262
- const chromium = {gl};
263
- let ultimo = -1;
251
+ const soltar = await tomarCandado(log);
252
+ try {
253
+ log('Empaquetando la animación...');
254
+ const {bundle} = await import('@remotion/bundler');
255
+ const {renderMedia, renderStill, selectComposition} = await import('@remotion/renderer');
256
+ const entrada = entradaDe(p);
257
+ const serveUrl = await bundle({entryPoint: entrada, publicDir: publico, rootDir: entrada === ENTRADA_REMOTION ? path.dirname(ENTRADA_REMOTION) : p.proyecto, webpackOverride: conHuella});
258
+ fs.mkdirSync(p.salida, {recursive: true});
259
+ const chromiumOptions = {gl: p.render.gl};
260
+ const browserExecutable = p.render.navegador ?? (await asegurarNavegador(log));
261
+ // cuadros en paralelo: con GPU aguanta más; por software cada pestaña come mucha CPU y memoria
262
+ const nucleos = os.cpus().length;
263
+ const concurrenciaBase = p.render.concurrencia ?? (p.render.gl === 'angle' ? Math.max(2, Math.min(10, Math.floor(nucleos * 0.75))) : Math.max(1, Math.min(3, Math.floor(nucleos / 4))));
264
+ const sinTTY = !process.stdout.isTTY;
265
+ for (const [i, id] of listos.entries()) {
266
+ const t = Date.now();
264
267
  try {
265
- composition = await selectComposition({serveUrl, id, inputProps, browserExecutable, chromiumOptions: chromium});
266
- await renderMedia({
267
- serveUrl,
268
- composition,
269
- inputProps,
270
- browserExecutable,
271
- chromiumOptions: chromium,
272
- codec: 'h264',
273
- crf: crfDe(p, id),
274
- audioBitrate: '192k',
275
- imageFormat: 'jpeg',
276
- jpegQuality: 92,
277
- concurrency: concurrencia,
278
- outputLocation: mp4,
279
- onProgress: ({progress}) => {
280
- alProgreso?.(id, progress);
281
- // con terminal: cada 10 % en la misma línea; sin terminal (logs, CI): cada 25 %
282
- const paso = sinTTY ? 4 : 10;
283
- const n = Math.floor(progress * paso);
284
- if (n !== ultimo) {
285
- if (sinTTY) log(`[${i + 1}/${listos.length}] ${id}: video ${Math.round((n / paso) * 100)}%`);
286
- else process.stdout.write(`\r[${i + 1}/${listos.length}] ${id}: video ${n * 10}% `);
287
- }
288
- ultimo = n;
289
- },
290
- });
291
- chromiumOptions.gl = gl;
292
- break;
268
+ const inputProps = {videos: {[id]: datos[id]}, marca, ...extras};
269
+ const mp4 = salidaDe(p, id);
270
+ let gl = p.render.gl;
271
+ let concurrencia = concurrenciaBase;
272
+ let composition;
273
+ // hasta 3 intentos: si el navegador truena, se baja la concurrencia y, con GPU, se pasa a software
274
+ for (let intento = 1; ; intento++) {
275
+ const chromium = {gl};
276
+ let ultimo = -1;
277
+ try {
278
+ composition = await selectComposition({serveUrl, id, inputProps, browserExecutable, chromiumOptions: chromium});
279
+ await renderMedia({
280
+ serveUrl,
281
+ composition,
282
+ inputProps,
283
+ browserExecutable,
284
+ chromiumOptions: chromium,
285
+ codec: 'h264',
286
+ crf: crfDe(p, id),
287
+ audioBitrate: '192k',
288
+ imageFormat: 'jpeg',
289
+ jpegQuality: 92,
290
+ concurrency: concurrencia,
291
+ outputLocation: mp4,
292
+ onProgress: ({progress}) => {
293
+ alProgreso?.(id, progress);
294
+ // con terminal: cada 10 % en la misma línea; sin terminal (logs, CI): cada 25 %
295
+ const paso = sinTTY ? 4 : 10;
296
+ const n = Math.floor(progress * paso);
297
+ if (n !== ultimo) {
298
+ if (sinTTY) log(`[${i + 1}/${listos.length}] ${id}: video ${Math.round((n / paso) * 100)}%`);
299
+ else process.stdout.write(`\r[${i + 1}/${listos.length}] ${id}: video ${n * 10}% `);
300
+ }
301
+ ultimo = n;
302
+ },
303
+ });
304
+ chromiumOptions.gl = gl;
305
+ break;
306
+ } catch (e) {
307
+ const msg = String(e?.message ?? e);
308
+ const truena = /crash|Session closed|Target closed|Protocol error|WebGL|context lost|browser has disconnected|Navigation failed/i.test(msg);
309
+ if (!truena || intento >= 3) throw e;
310
+ if (gl === 'angle' && /WebGL|context/i.test(msg)) gl = 'swangle';
311
+ else if (concurrencia > 1) concurrencia = Math.max(1, Math.floor(concurrencia / 2));
312
+ else if (gl === 'angle') gl = 'swangle';
313
+ log(`\n el navegador falló; reintento con ${gl === 'angle' ? 'GPU' : 'CPU'} y ${concurrencia} cuadro(s) a la vez...`);
314
+ }
315
+ }
316
+ let jpg = null;
317
+ if (p.render.miniatura) {
318
+ // miniatura: cuando la primera escena ya está completa (o en `miniaturaEn`), sin subtítulos
319
+ jpg = salidaDe(p, id, 'jpg');
320
+ const d = datos[id];
321
+ const e0 = d.escenas[0];
322
+ const seg = p.videos[id].miniaturaEn ?? Math.min(e0.dur - 0.15, Math.max(1.4, e0.habla[1] * 0.85));
323
+ const frame = Math.max(0, Math.min(composition.durationInFrames - 1, Math.round(seg * composition.fps)));
324
+ const props = {...inputProps, miniatura: true};
325
+ await renderStill({serveUrl, composition: {...composition, props: {...composition.props, miniatura: true}}, inputProps: props, browserExecutable, chromiumOptions: {gl}, frame, imageFormat: 'jpeg', jpegQuality: 92, output: jpg});
326
+ }
327
+ const segundos = Math.round((Date.now() - t) / 1000);
328
+ if (process.stdout.isTTY) process.stdout.write('\r');
329
+ log(`[${i + 1}/${listos.length}] ${id}: listo en ${segundos} s → ${path.relative(p.proyecto, mp4)} `);
330
+ hechos.push({id, mp4, jpg, segundos});
293
331
  } catch (e) {
294
- const msg = String(e?.message ?? e);
295
- const truena = /crash|Session closed|Target closed|Protocol error|WebGL|context lost|browser has disconnected|Navigation failed/i.test(msg);
296
- if (!truena || intento >= 3) throw e;
297
- if (gl === 'angle' && /WebGL|context/i.test(msg)) gl = 'swangle';
298
- else if (concurrencia > 1) concurrencia = Math.max(1, Math.floor(concurrencia / 2));
299
- else if (gl === 'angle') gl = 'swangle';
300
- log(`\n el navegador falló; reintento con ${gl === 'angle' ? 'GPU' : 'CPU'} y ${concurrencia} cuadro(s) a la vez...`);
332
+ if (process.stdout.isTTY) process.stdout.write('\n');
333
+ fallidos.push({id, error: mensajeDe(e)});
334
+ log(`[${i + 1}/${listos.length}] ${id}: ✘ ${mensajeDe(e)}`);
301
335
  }
302
336
  }
303
- let jpg = null;
304
- if (p.render.miniatura) {
305
- // miniatura: cuando la primera escena ya está completa (o en `miniaturaEn`), sin subtítulos
306
- jpg = path.join(p.salida, `${id}.jpg`);
307
- const d = datos[id];
308
- const e0 = d.escenas[0];
309
- const seg = p.videos[id].miniaturaEn ?? Math.min(e0.dur - 0.15, Math.max(1.4, e0.habla[1] * 0.85));
310
- const frame = Math.max(0, Math.min(composition.durationInFrames - 1, Math.round(seg * composition.fps)));
311
- const props = {...inputProps, miniatura: true};
312
- await renderStill({serveUrl, composition: {...composition, props: {...composition.props, miniatura: true}}, inputProps: props, browserExecutable, chromiumOptions: {gl}, frame, imageFormat: 'jpeg', jpegQuality: 92, output: jpg});
313
- }
314
- const segundos = Math.round((Date.now() - t) / 1000);
315
- if (process.stdout.isTTY) process.stdout.write('\r');
316
- log(`[${i + 1}/${listos.length}] ${id}: listo en ${segundos} s → ${path.relative(p.proyecto, mp4)} `);
317
- hechos.push({id, mp4, jpg, segundos});
318
- } catch (e) {
319
- if (process.stdout.isTTY) process.stdout.write('\n');
320
- fallidos.push({id, error: mensajeDe(e)});
321
- log(`[${i + 1}/${listos.length}] ${id}: ✘ ${mensajeDe(e)}`);
322
- }
337
+ } finally {
338
+ soltar();
323
339
  }
324
340
  return terminarLote(p, loteArchivo, hechos, fallidos, log);
325
341
  }
@@ -332,6 +348,48 @@ export function crfDe(p, id) {
332
348
  return CRF[v.calidad ?? p.render.calidad ?? (v.mundo?.length ? 'media' : 'alta')] ?? 18;
333
349
  }
334
350
 
351
+ /**
352
+ * Candado de render por computadora (~/.morfeo/render.lock con el pid). Si otro render de Morfeo está corriendo,
353
+ * espera a que termine; si el proceso del candado ya no existe, lo toma. Devuelve la función que lo suelta.
354
+ */
355
+ async function tomarCandado(log) {
356
+ const {HOME} = await import('./entorno.mjs');
357
+ const archivo = path.join(HOME, 'render.lock');
358
+ fs.mkdirSync(HOME, {recursive: true});
359
+ const vivo = (pid) => {
360
+ try {
361
+ process.kill(pid, 0);
362
+ return true;
363
+ } catch (e) {
364
+ return e.code === 'EPERM';
365
+ }
366
+ };
367
+ let avisado = false;
368
+ for (;;) {
369
+ try {
370
+ fs.writeFileSync(archivo, String(process.pid), {flag: 'wx'});
371
+ break;
372
+ } catch (e) {
373
+ if (e.code !== 'EEXIST') throw e;
374
+ const pid = Number(fs.readFileSync(archivo, 'utf8')) || 0;
375
+ if (!pid || pid === process.pid || !vivo(pid)) {
376
+ fs.rmSync(archivo, {force: true});
377
+ continue;
378
+ }
379
+ if (!avisado) log(`Otro render de Morfeo está usando la tarjeta de video (proceso ${pid}); espero a que termine...`);
380
+ avisado = true;
381
+ await new Promise((r) => setTimeout(r, 2000));
382
+ }
383
+ }
384
+ const soltar = () => {
385
+ try {
386
+ if (Number(fs.readFileSync(archivo, 'utf8')) === process.pid) fs.rmSync(archivo, {force: true});
387
+ } catch {}
388
+ };
389
+ process.once('exit', soltar);
390
+ return soltar;
391
+ }
392
+
335
393
  /** Primera línea útil de un error (sin la pila de Remotion o de Python). */
336
394
  const mensajeDe = (e) => String(e?.message ?? e).split('\n').find((l) => l.trim()) ?? 'error desconocido';
337
395
 
@@ -370,18 +428,18 @@ function escribirResumen(p) {
370
428
  .filter(([id]) => fs.existsSync(salidaDe(p, id)))
371
429
  .map(([id, v]) => {
372
430
  const c = creditosDe(p, id);
373
- return {id, titulo: v.title ?? id, archivo: `${id}.mp4`, duracion: datosDe(publico, id)?.duracion ?? null, ...(c.length ? {creditos: c} : {}), ...(v.meta ? {meta: v.meta} : {})};
431
+ return {id, titulo: v.title ?? id, archivo: path.basename(salidaDe(p, id)), duracion: datosDe(publico, id)?.duracion ?? null, ...(c.length ? {creditos: c} : {}), ...(v.meta ? {meta: v.meta} : {})};
374
432
  });
375
433
  fs.writeFileSync(path.join(p.salida, 'resumen.json'), JSON.stringify(filas, null, '\t') + '\n');
376
434
  // créditos para la descripción del video (las licencias CC BY piden mencionar al autor)
377
435
  const conCreditos = filas.filter((f) => f.creditos?.length);
378
- // <id>.txt: lo que se copia al publicar (título, descripción, etiquetas y créditos)
436
+ // <título>.txt: lo que se copia al publicar (título, descripción, etiquetas y créditos)
379
437
  for (const f of filas) {
380
438
  const v = p.videos[f.id];
381
439
  const tags = (v.tags ?? []).map((x) => `#${String(x).replace(/^#/, '').replace(/\s+/g, '')}`).join(' ');
382
440
  const creditos = f.creditos?.length ? ['', 'Créditos:', ...f.creditos.map((c) => `- ${c}`)] : [];
383
441
  const lineas = [v.title ?? f.id, '', ...(v.descripcion ? [v.descripcion, ''] : []), ...(tags ? [tags] : []), ...creditos];
384
- fs.writeFileSync(path.join(p.salida, `${f.id}.txt`), lineas.join('\n').trim() + '\n');
442
+ fs.writeFileSync(salidaDe(p, f.id, 'txt'), lineas.join('\n').trim() + '\n');
385
443
  }
386
444
  if (conCreditos.length)
387
445
  fs.writeFileSync(path.join(p.salida, 'creditos.md'), ['# Créditos de recursos', '', ...conCreditos.flatMap((f) => [`## ${f.titulo}`, ...f.creditos.map((c) => `- ${c}`), ''])].join('\n'));
package/lib/salida.mjs ADDED
@@ -0,0 +1,84 @@
1
+ // Nombres de los archivos terminados. Por defecto, el título del video: YouTube, TikTok e Instagram toman el nombre
2
+ // del archivo como título al subirlo, así que no hay que escribirlo otra vez. `render.nombreArchivo: 'id'` vuelve a
3
+ // usar el id. Los videos hechos con el id (versiones anteriores) se renombran solos, sin volver a renderizar.
4
+ import fs from 'node:fs';
5
+ import path from 'node:path';
6
+
7
+ const EXTENSIONES = ['mp4', 'jpg', 'txt'];
8
+ // nombres que Windows no permite como archivo
9
+ const RESERVADOS = /^(con|prn|aux|nul|com\d|lpt\d)$/i;
10
+
11
+ /** Título → nombre de archivo válido en Windows, Mac y Linux (conserva acentos y emojis). */
12
+ export function nombreSeguro(titulo, respaldo = 'video') {
13
+ let s = String(titulo ?? '')
14
+ .normalize('NFC')
15
+ .replace(/[<>:"/\\|?*\u0000-\u001F]/g, ' ') // prohibidos en Windows
16
+ .replace(/\s+/g, ' ')
17
+ .trim()
18
+ .replace(/[. ]+$/g, ''); // Windows no acepta punto ni espacio al final
19
+ // largo máximo prudente (cuenta caracteres, no bytes; los emojis cuentan como uno)
20
+ const chars = [...s];
21
+ if (chars.length > 90) s = chars.slice(0, 90).join('').replace(/[. ]+$/g, '');
22
+ if (!s || RESERVADOS.test(s)) s = respaldo;
23
+ return s;
24
+ }
25
+
26
+ /**
27
+ * {id: nombre base} para todos los videos del proyecto. Dos videos con el mismo título se distinguen con " (2)",
28
+ * en el orden del proyecto, así que el nombre de cada uno no cambia entre renders.
29
+ */
30
+ export function nombresDe(p) {
31
+ const porId = {};
32
+ const usados = new Set();
33
+ for (const [id, v] of Object.entries(p.videos)) {
34
+ const base = p.render?.nombreArchivo === 'id' ? id : nombreSeguro(v?.title, id);
35
+ let nombre = base;
36
+ for (let n = 2; usados.has(nombre.toLowerCase()); n++) nombre = `${base} (${n})`;
37
+ usados.add(nombre.toLowerCase());
38
+ porId[id] = nombre;
39
+ }
40
+ return porId;
41
+ }
42
+
43
+ /** Ruta de un archivo terminado: salidaDe(p, 'mi-video') → salida/<Título>.mp4 */
44
+ export const salidaDe = (p, id, ext = 'mp4') => path.join(p.salida, `${nombresDe(p)[id] ?? id}.${ext}`);
45
+
46
+ /**
47
+ * Pone los archivos ya hechos bajo su nombre actual: los que tenían el id (versiones anteriores) o un título viejo
48
+ * (registrado en .morfeo/archivos.json). Así cambiar el título renombra el video sin volver a renderizarlo.
49
+ */
50
+ export function ordenarSalida(p, log = () => {}) {
51
+ const registro = path.join(p.trabajo, 'archivos.json');
52
+ let antes = {};
53
+ try {
54
+ antes = JSON.parse(fs.readFileSync(registro, 'utf8'));
55
+ } catch {}
56
+ const ahora = nombresDe(p);
57
+ if (fs.existsSync(p.salida)) {
58
+ for (const [id, nombre] of Object.entries(ahora)) {
59
+ for (const viejo of [antes[id], id]) {
60
+ if (!viejo || viejo === nombre) continue;
61
+ const de = path.join(p.salida, `${viejo}.mp4`);
62
+ const a = path.join(p.salida, `${nombre}.mp4`);
63
+ if (!fs.existsSync(de) || fs.existsSync(a)) continue;
64
+ for (const ext of EXTENSIONES) {
65
+ const d = path.join(p.salida, `${viejo}.${ext}`);
66
+ if (fs.existsSync(d)) fs.renameSync(d, path.join(p.salida, `${nombre}.${ext}`));
67
+ }
68
+ // la hoja de contacto también sigue al video
69
+ const hoja = path.join(p.salida, `${viejo}-hoja.png`);
70
+ if (fs.existsSync(hoja)) fs.renameSync(hoja, path.join(p.salida, `${nombre}-hoja.png`));
71
+ log(` ${viejo}.mp4 → ${nombre}.mp4`);
72
+ break;
73
+ }
74
+ }
75
+ }
76
+ // resumen.json apunta a los archivos: que siga a los nombres nuevos
77
+ const resumen = path.join(p.salida, 'resumen.json');
78
+ try {
79
+ const filas = JSON.parse(fs.readFileSync(resumen, 'utf8'));
80
+ if (Array.isArray(filas)) fs.writeFileSync(resumen, JSON.stringify(filas.map((f) => (ahora[f.id] ? {...f, archivo: `${ahora[f.id]}.mp4`} : f)), null, '\t') + '\n');
81
+ } catch {}
82
+ fs.mkdirSync(p.trabajo, {recursive: true});
83
+ fs.writeFileSync(registro, JSON.stringify(ahora, null, '\t'));
84
+ }
package/package.json CHANGED
@@ -1,11 +1,11 @@
1
1
  {
2
2
  "name": "morfeo",
3
- "version": "0.3.1",
3
+ "version": "0.3.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",
7
7
  "author": "Uriel Gómez",
8
- "homepage": "https://ureckchan.github.io/morfeo/",
8
+ "homepage": "https://morfeo-docs-two.vercel.app/",
9
9
  "repository": {
10
10
  "type": "git",
11
11
  "url": "git+https://github.com/UreckChan/morfeo.git"
@@ -85,16 +85,21 @@ const Camara: React.FC<{estado: Estado}> = ({estado}) => {
85
85
 
86
86
  const Entorno: React.FC<{fuerza: number}> = ({fuerza}) => {
87
87
  const {gl, scene} = useThree();
88
- useEffect(() => {
88
+ // useLayoutEffect, no useEffect: con useEffect el primer cuadro de cada pestaña de Chrome salía sin reflejos
89
+ // (más oscuro). La textura se crea una vez; la intensidad (que cambia con la hora) se ajusta en cada cuadro.
90
+ useLayoutEffect(() => {
89
91
  const pmrem = new THREE.PMREMGenerator(gl);
90
92
  const tex = pmrem.fromScene(new RoomEnvironment(), 0.04).texture;
91
93
  scene.environment = tex;
92
- scene.environmentIntensity = fuerza;
93
94
  return () => {
95
+ scene.environment = null;
94
96
  tex.dispose();
95
97
  pmrem.dispose();
96
98
  };
97
- }, [gl, scene, fuerza]);
99
+ }, [gl, scene]);
100
+ useLayoutEffect(() => {
101
+ scene.environmentIntensity = fuerza;
102
+ });
98
103
  return null;
99
104
  };
100
105
 
@@ -96,16 +96,21 @@ const Glb: React.FC<{src: string; levantar?: boolean}> = ({src, levantar}) => {
96
96
  /** Reflejos de un cuarto iluminado: sin esto, los metales (monedas, lingotes) se ven negros. */
97
97
  const Entorno: React.FC<{fuerza: number}> = ({fuerza}) => {
98
98
  const {gl, scene} = useThree();
99
- useEffect(() => {
99
+ // useLayoutEffect, no useEffect: con useEffect el primer cuadro de cada pestaña de Chrome salía sin reflejos
100
+ // (más oscuro). La textura se crea una vez; la intensidad (que cambia con la hora) se ajusta en cada cuadro.
101
+ useLayoutEffect(() => {
100
102
  const pmrem = new THREE.PMREMGenerator(gl);
101
103
  const tex = pmrem.fromScene(new RoomEnvironment(), 0.04).texture;
102
104
  scene.environment = tex;
103
- scene.environmentIntensity = fuerza;
104
105
  return () => {
106
+ scene.environment = null;
105
107
  tex.dispose();
106
108
  pmrem.dispose();
107
109
  };
108
- }, [gl, scene, fuerza]);
110
+ }, [gl, scene]);
111
+ useLayoutEffect(() => {
112
+ scene.environmentIntensity = fuerza;
113
+ });
109
114
  return null;
110
115
  };
111
116