morfeo 0.3.0 → 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 +1 -1
- package/bases/2d/Mundo.tsx +43 -0
- package/bases/2d/guion.mjs +32 -0
- package/bases/3d/Mundo.tsx +45 -0
- package/bases/3d/guion.mjs +26 -0
- package/bin/morfeo.mjs +23 -4
- package/index.d.ts +10 -3
- package/lib/config.mjs +48 -8
- package/lib/entorno.mjs +22 -2
- package/lib/estructura.mjs +1 -1
- package/lib/guion.mjs +2 -2
- package/lib/hoja.mjs +3 -0
- package/lib/nuevo-video.mjs +65 -0
- package/lib/palabras.mjs +79 -0
- package/lib/render.mjs +211 -88
- package/lib/salida.mjs +84 -0
- package/lib/whisper.mjs +47 -3
- package/package.json +5 -4
- package/plantilla/morfeo.config.mjs +3 -1
- package/python/musica.py +40 -7
- package/python/preparar.py +10 -1
- package/remotion/kit/datos.tsx +180 -0
- package/remotion/kit/escenarios2d.tsx +250 -0
- package/remotion/kit/index.ts +9 -2
- package/remotion/kit/mundo3d.tsx +251 -0
- package/remotion/kit/personas.tsx +439 -0
- package/remotion/kit/tres.tsx +11 -6
- package/remotion/motor/capas.tsx +27 -2
- package/remotion/motor/modelo3d.tsx +8 -3
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';
|
|
@@ -14,6 +14,8 @@ import {resolverRecursos} from './recursos.mjs';
|
|
|
14
14
|
import {sintetizarVoces} from './voces.mjs';
|
|
15
15
|
import {transcribir, whisperListo} from './whisper.mjs';
|
|
16
16
|
import {consejosEnTexto, revisarEstructura} from './estructura.mjs';
|
|
17
|
+
import {revisarPalabras} from './palabras.mjs';
|
|
18
|
+
import {ordenarSalida, salidaDe} from './salida.mjs';
|
|
17
19
|
|
|
18
20
|
const require = createRequire(import.meta.url);
|
|
19
21
|
|
|
@@ -70,7 +72,13 @@ function huellaMotor() {
|
|
|
70
72
|
}
|
|
71
73
|
|
|
72
74
|
// Remotion calcula la clave del caché a partir de la configuración: meter la huella ahí la invalida
|
|
73
|
-
|
|
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
|
+
});
|
|
74
82
|
|
|
75
83
|
/**
|
|
76
84
|
* Entrada de Remotion. Sin componentes propios es la del paquete; con ellos, Morfeo escribe
|
|
@@ -127,13 +135,13 @@ export const datosDe = (publico, id) => {
|
|
|
127
135
|
return fs.existsSync(f) ? JSON.parse(fs.readFileSync(f, 'utf8')) : null;
|
|
128
136
|
};
|
|
129
137
|
|
|
130
|
-
const salidaDe = (p, id) => path.join(p.salida, `${id}.mp4`);
|
|
131
138
|
|
|
132
139
|
/** [{id, titulo, hecho}] en el orden del proyecto. */
|
|
133
140
|
export async function lista({proyecto = process.cwd()} = {}) {
|
|
134
141
|
cargarEnv(proyecto);
|
|
135
142
|
const p = await cargarProyecto(proyecto);
|
|
136
|
-
|
|
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))}));
|
|
137
145
|
}
|
|
138
146
|
|
|
139
147
|
/** Corre python/preparar.py con el trabajo indicado; su salida va directo a la terminal. */
|
|
@@ -142,14 +150,16 @@ function python(trabajo) {
|
|
|
142
150
|
stdio: 'inherit',
|
|
143
151
|
env: {...process.env, PYTHONUTF8: '1', PYTHONIOENCODING: 'utf-8'},
|
|
144
152
|
});
|
|
153
|
+
if (r.status === 10) return 'falta-whisper';
|
|
145
154
|
if (r.status !== 0) throw new ErrorMorfeo(`Falló la preparación del audio (código ${r.status}). Revisa el mensaje de arriba.`);
|
|
155
|
+
return 'listo';
|
|
146
156
|
}
|
|
147
157
|
|
|
148
158
|
/**
|
|
149
159
|
* Prepara (audio + tiempos) los videos indicados. Devuelve {publico, marca, datos: {id: datos}}.
|
|
150
160
|
* @param {{proyecto?: string, ids?: string[], log?: (m: string) => void}} o
|
|
151
161
|
*/
|
|
152
|
-
export async function preparar({proyecto = process.cwd(), ids, log = console.log, _p} = {}) {
|
|
162
|
+
export async function preparar({proyecto = process.cwd(), ids, log = console.log, _p, continuar = false} = {}) {
|
|
153
163
|
cargarEnv(proyecto);
|
|
154
164
|
const p = _p ?? (await cargarProyecto(proyecto));
|
|
155
165
|
if (!venvListo() || !modelosListos()) throw new ErrorMorfeo('Falta preparar tu computadora. Corre una vez: npx morfeo instalar');
|
|
@@ -161,10 +171,12 @@ export async function preparar({proyecto = process.cwd(), ids, log = console.log
|
|
|
161
171
|
const trabajos = path.join(p.trabajo, 'trabajos');
|
|
162
172
|
const creditos = {};
|
|
163
173
|
fs.mkdirSync(trabajos, {recursive: true});
|
|
174
|
+
const fallidos = [];
|
|
164
175
|
for (const [i, id] of (ids ?? todos).entries()) {
|
|
165
176
|
const original = p.videos[id];
|
|
166
177
|
if (!original) throw new ErrorMorfeo(`No existe el video "${id}". Usa: npx morfeo lista`);
|
|
167
178
|
log(`[${i + 1}/${(ids ?? todos).length}] ${id}: audio...`);
|
|
179
|
+
try {
|
|
168
180
|
const conRecursos = await resolverRecursos(ponerPoses(original, poses), path.join(publico, 'assets'), log);
|
|
169
181
|
creditos[id] = conRecursos.creditos;
|
|
170
182
|
guardarCreditos(p, id, conRecursos.creditos);
|
|
@@ -172,17 +184,28 @@ export async function preparar({proyecto = process.cwd(), ids, log = console.log
|
|
|
172
184
|
const video = await sintetizarVoces(id, conImagenes, path.join(p.trabajo, 'cache', 'voces'), log);
|
|
173
185
|
const job = path.join(trabajos, `${id}.json`);
|
|
174
186
|
const base = {id, video, indice: todos.indexOf(id), publico, modelos: MODELOS};
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
187
|
+
// una sola pasada de Python cuando la voz y su transcripción ya están en caché (lo normal al volver a renderizar);
|
|
188
|
+
// si la voz es nueva, Python sale con 10, se transcribe con Whisper y se vuelve a llamar
|
|
189
|
+
fs.writeFileSync(job, JSON.stringify({...base, conWhisper}));
|
|
190
|
+
if (python(job) === 'falta-whisper') {
|
|
178
191
|
log(' whisper...');
|
|
179
|
-
|
|
192
|
+
try {
|
|
193
|
+
await transcribir(path.join(publico, 'v', id), (video.lang ?? 'es').slice(0, 2));
|
|
194
|
+
} catch (e) {
|
|
195
|
+
log(` (Whisper falló: ${mensajeDe(e)}; subtítulos repartidos por frase)`);
|
|
196
|
+
}
|
|
197
|
+
fs.writeFileSync(job, JSON.stringify(base));
|
|
198
|
+
python(job);
|
|
180
199
|
}
|
|
181
|
-
fs.writeFileSync(job, JSON.stringify(base));
|
|
182
|
-
python(job);
|
|
183
200
|
datos[id] = datosDe(publico, id);
|
|
201
|
+
for (const a of revisarPalabras(original, datos[id])) log(` ! ${a}`);
|
|
202
|
+
} catch (e) {
|
|
203
|
+
if (!continuar) throw e;
|
|
204
|
+
fallidos.push({id, error: mensajeDe(e)});
|
|
205
|
+
log(` ✘ ${id}: ${mensajeDe(e)}`);
|
|
206
|
+
}
|
|
184
207
|
}
|
|
185
|
-
return {p, publico, marca, extras, datos, creditos};
|
|
208
|
+
return {p, publico, marca, extras, datos, creditos, fallidos};
|
|
186
209
|
}
|
|
187
210
|
|
|
188
211
|
/**
|
|
@@ -190,9 +213,20 @@ export async function preparar({proyecto = process.cwd(), ids, log = console.log
|
|
|
190
213
|
* @param {{proyecto?: string, ids?: string[], todos?: boolean, log?: (m: string) => void, alProgreso?: (id: string, p: number) => void}} o
|
|
191
214
|
* @returns {Promise<{id: string, mp4: string, jpg: string | null, segundos: number}[]>}
|
|
192
215
|
*/
|
|
193
|
-
export async function render({proyecto = process.cwd(), ids, todos = false, log = console.log, alProgreso} = {}) {
|
|
216
|
+
export async function render({proyecto = process.cwd(), ids, todos = false, soloFallidos = false, log = console.log, alProgreso} = {}) {
|
|
194
217
|
cargarEnv(proyecto);
|
|
195
218
|
const p = await cargarProyecto(proyecto);
|
|
219
|
+
const loteArchivo = path.join(p.trabajo, 'ultimo-lote.json');
|
|
220
|
+
if (soloFallidos) {
|
|
221
|
+
const previo = fs.existsSync(loteArchivo) ? JSON.parse(fs.readFileSync(loteArchivo, 'utf8')) : {fallidos: []};
|
|
222
|
+
ids = previo.fallidos.map((f) => f.id).filter((id) => p.videos[id]);
|
|
223
|
+
if (!ids.length) {
|
|
224
|
+
log('El último lote no tuvo videos fallidos.');
|
|
225
|
+
return [];
|
|
226
|
+
}
|
|
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));
|
|
196
230
|
let lista = ids?.length ? ids : todos ? Object.keys(p.videos) : Object.keys(p.videos).filter((id) => !fs.existsSync(salidaDe(p, id)));
|
|
197
231
|
const malos = lista.filter((id) => !p.videos[id]);
|
|
198
232
|
if (malos.length) throw new ErrorMorfeo(`No existen: ${malos.join(', ')}. Usa: npx morfeo lista`);
|
|
@@ -201,94 +235,175 @@ export async function render({proyecto = process.cwd(), ids, todos = false, log
|
|
|
201
235
|
return [];
|
|
202
236
|
}
|
|
203
237
|
if (!whisperListo()) log('(Whisper no está instalado: subtítulos repartidos por frase. Para tiempos exactos: npx morfeo instalar --whisper)');
|
|
204
|
-
const {publico, marca, extras, datos} = await preparar({proyecto, ids: lista, log, _p: p});
|
|
238
|
+
const {publico, marca, extras, datos, fallidos} = await preparar({proyecto, ids: lista, log, _p: p, continuar: true});
|
|
239
|
+
const listos = lista.filter((id) => datos[id]);
|
|
205
240
|
// estructura: consejos medidos en videos virales (no detienen el render; se apagan con render.consejos: false)
|
|
206
241
|
if (p.render.consejos !== false)
|
|
207
|
-
for (const id of
|
|
242
|
+
for (const id of listos) {
|
|
208
243
|
const c = revisarEstructura(p.videos[id], datos[id]);
|
|
209
244
|
if (c.length) log(` ${id}: ${c.length} consejo(s) de estructura\n${consejosEnTexto(c)}`);
|
|
210
245
|
}
|
|
211
246
|
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
const entrada = entradaDe(p);
|
|
216
|
-
const serveUrl = await bundle({entryPoint: entrada, publicDir: publico, rootDir: entrada === ENTRADA_REMOTION ? path.dirname(ENTRADA_REMOTION) : p.proyecto, webpackOverride: conHuella});
|
|
217
|
-
fs.mkdirSync(p.salida, {recursive: true});
|
|
218
|
-
const chromiumOptions = {gl: p.render.gl};
|
|
219
|
-
const browserExecutable = p.render.navegador ?? (await asegurarNavegador(log));
|
|
247
|
+
if (!listos.length) return terminarLote(p, loteArchivo, [], fallidos, 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)
|
|
220
250
|
const hechos = [];
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
const
|
|
227
|
-
const
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
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();
|
|
236
267
|
try {
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
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});
|
|
265
331
|
} catch (e) {
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
if (gl === 'angle' && /WebGL|context/i.test(msg)) gl = 'swangle';
|
|
270
|
-
else if (concurrencia > 1) concurrencia = Math.max(1, Math.floor(concurrencia / 2));
|
|
271
|
-
else if (gl === 'angle') gl = 'swangle';
|
|
272
|
-
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)}`);
|
|
273
335
|
}
|
|
274
336
|
}
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
337
|
+
} finally {
|
|
338
|
+
soltar();
|
|
339
|
+
}
|
|
340
|
+
return terminarLote(p, loteArchivo, hechos, fallidos, log);
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
const CRF = {alta: 18, media: 21, ligera: 24};
|
|
344
|
+
/** CRF del video: `crf` explícito > `calidad` del video > `render.calidad` > media con mundo, alta sin él. */
|
|
345
|
+
export function crfDe(p, id) {
|
|
346
|
+
if (p.render.crf != null) return p.render.crf;
|
|
347
|
+
const v = p.videos[id] ?? {};
|
|
348
|
+
return CRF[v.calidad ?? p.render.calidad ?? (v.mundo?.length ? 'media' : 'alta')] ?? 18;
|
|
349
|
+
}
|
|
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));
|
|
285
382
|
}
|
|
286
|
-
const segundos = Math.round((Date.now() - t) / 1000);
|
|
287
|
-
if (process.stdout.isTTY) process.stdout.write('\r');
|
|
288
|
-
log(`[${i + 1}/${lista.length}] ${id}: listo en ${segundos} s → ${path.relative(p.proyecto, mp4)} `);
|
|
289
|
-
hechos.push({id, mp4, jpg, segundos});
|
|
290
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
|
+
|
|
393
|
+
/** Primera línea útil de un error (sin la pila de Remotion o de Python). */
|
|
394
|
+
const mensajeDe = (e) => String(e?.message ?? e).split('\n').find((l) => l.trim()) ?? 'error desconocido';
|
|
395
|
+
|
|
396
|
+
/** Resumen del lote: lo guarda (para --solo-fallidos), lo imprime y marca la salida con error si algo falló. */
|
|
397
|
+
function terminarLote(p, archivo, hechos, fallidos, log) {
|
|
291
398
|
escribirResumen(p);
|
|
399
|
+
fs.mkdirSync(path.dirname(archivo), {recursive: true});
|
|
400
|
+
fs.writeFileSync(archivo, JSON.stringify({fecha: new Date().toISOString(), hechos: hechos.map((h) => h.id), fallidos}, null, '\t'));
|
|
401
|
+
if (fallidos.length) {
|
|
402
|
+
log(`\n${hechos.length} hecho(s), ${fallidos.length} con error:`);
|
|
403
|
+
for (const f of fallidos) log(` ✘ ${f.id}: ${f.error}`);
|
|
404
|
+
log('Corrígelos y vuelve a intentar solo esos con: npx morfeo render --solo-fallidos');
|
|
405
|
+
process.exitCode = 1;
|
|
406
|
+
}
|
|
292
407
|
return hechos;
|
|
293
408
|
}
|
|
294
409
|
|
|
@@ -313,11 +428,19 @@ function escribirResumen(p) {
|
|
|
313
428
|
.filter(([id]) => fs.existsSync(salidaDe(p, id)))
|
|
314
429
|
.map(([id, v]) => {
|
|
315
430
|
const c = creditosDe(p, id);
|
|
316
|
-
return {id, titulo: v.title ?? id, archivo:
|
|
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} : {})};
|
|
317
432
|
});
|
|
318
433
|
fs.writeFileSync(path.join(p.salida, 'resumen.json'), JSON.stringify(filas, null, '\t') + '\n');
|
|
319
434
|
// créditos para la descripción del video (las licencias CC BY piden mencionar al autor)
|
|
320
435
|
const conCreditos = filas.filter((f) => f.creditos?.length);
|
|
436
|
+
// <título>.txt: lo que se copia al publicar (título, descripción, etiquetas y créditos)
|
|
437
|
+
for (const f of filas) {
|
|
438
|
+
const v = p.videos[f.id];
|
|
439
|
+
const tags = (v.tags ?? []).map((x) => `#${String(x).replace(/^#/, '').replace(/\s+/g, '')}`).join(' ');
|
|
440
|
+
const creditos = f.creditos?.length ? ['', 'Créditos:', ...f.creditos.map((c) => `- ${c}`)] : [];
|
|
441
|
+
const lineas = [v.title ?? f.id, '', ...(v.descripcion ? [v.descripcion, ''] : []), ...(tags ? [tags] : []), ...creditos];
|
|
442
|
+
fs.writeFileSync(salidaDe(p, f.id, 'txt'), lineas.join('\n').trim() + '\n');
|
|
443
|
+
}
|
|
321
444
|
if (conCreditos.length)
|
|
322
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'));
|
|
323
446
|
}
|
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/lib/whisper.mjs
CHANGED
|
@@ -8,7 +8,30 @@ import {WHISPER, pythonVenv} from './entorno.mjs';
|
|
|
8
8
|
const VERSION_CPP = '1.7.6';
|
|
9
9
|
export const MODELO_WHISPER = 'small';
|
|
10
10
|
|
|
11
|
-
|
|
11
|
+
/** Ruta del ejecutable donde @remotion/install-whisper-cpp lo va a buscar (whisper-cli desde 1.7.4). */
|
|
12
|
+
const ejecutable = () => path.join(WHISPER, 'build', 'bin', process.platform === 'win32' ? 'whisper-cli.exe' : 'whisper-cli');
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* En Windows, @remotion/install-whisper-cpp 4.0.541 descomprime los binarios en `Release/` pero luego busca
|
|
16
|
+
* `build/bin/whisper-cli.exe`: sin esto `transcribe` revienta con ENOENT (y tumba el proceso, porque el error sale
|
|
17
|
+
* de un evento sin manejar). Se copian los .exe y .dll a donde los busca.
|
|
18
|
+
*/
|
|
19
|
+
function acomodarWindows() {
|
|
20
|
+
if (process.platform !== 'win32' || fs.existsSync(ejecutable())) return;
|
|
21
|
+
const release = path.join(WHISPER, 'Release');
|
|
22
|
+
if (!fs.existsSync(path.join(release, 'whisper-cli.exe'))) return;
|
|
23
|
+
const destino = path.dirname(ejecutable());
|
|
24
|
+
fs.mkdirSync(destino, {recursive: true});
|
|
25
|
+
for (const f of fs.readdirSync(release)) if (/\.(exe|dll)$/i.test(f)) fs.copyFileSync(path.join(release, f), path.join(destino, f));
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export const whisperListo = () => {
|
|
29
|
+
if (!fs.existsSync(path.join(WHISPER, `ggml-${MODELO_WHISPER}.bin`)) || !fs.existsSync(path.join(WHISPER, '.listo'))) return false;
|
|
30
|
+
try {
|
|
31
|
+
acomodarWindows();
|
|
32
|
+
} catch {}
|
|
33
|
+
return fs.existsSync(ejecutable());
|
|
34
|
+
};
|
|
12
35
|
|
|
13
36
|
/** Herramientas que whisper.cpp necesita para compilarse (en Windows se descarga ya compilado). */
|
|
14
37
|
export function faltaParaWhisper() {
|
|
@@ -20,15 +43,36 @@ export function faltaParaWhisper() {
|
|
|
20
43
|
const compilado = () => fs.existsSync(path.join(WHISPER, 'build', 'bin', process.platform === 'win32' ? 'whisper-cli.exe' : 'whisper-cli'));
|
|
21
44
|
|
|
22
45
|
export async function instalarWhisper() {
|
|
46
|
+
// en Windows los binarios vienen en Release/: se acomodan antes de decidir si la carpeta quedó a medias
|
|
47
|
+
try {
|
|
48
|
+
acomodarWindows();
|
|
49
|
+
} catch {}
|
|
23
50
|
// Un intento anterior que falló a medias deja la carpeta sin el ejecutable, y Remotion se niega a seguir: se limpia.
|
|
24
51
|
if (fs.existsSync(WHISPER) && !compilado()) fs.rmSync(WHISPER, {recursive: true, force: true});
|
|
25
52
|
try {
|
|
26
|
-
|
|
53
|
+
// en Mac/Linux compila (su salida sirve si falla); en Windows solo descarga
|
|
54
|
+
await installWhisperCpp({to: WHISPER, version: VERSION_CPP, printOutput: process.platform !== 'win32'});
|
|
55
|
+
acomodarWindows();
|
|
27
56
|
} catch (e) {
|
|
28
57
|
if (!compilado()) fs.rmSync(WHISPER, {recursive: true, force: true});
|
|
29
58
|
throw e;
|
|
30
59
|
}
|
|
31
|
-
|
|
60
|
+
// progreso en una sola línea con terminal; sin terminal (logs), cada 25 %: antes imprimía cientos de líneas
|
|
61
|
+
let ultimo = -1;
|
|
62
|
+
await downloadWhisperModel({
|
|
63
|
+
model: MODELO_WHISPER,
|
|
64
|
+
folder: WHISPER,
|
|
65
|
+
printOutput: false,
|
|
66
|
+
onProgress: (bajado, total) => {
|
|
67
|
+
const pct = total ? Math.floor((bajado / total) * 100) : 0;
|
|
68
|
+
if (process.stdout.isTTY) process.stdout.write(`\r modelo de Whisper: ${pct}% `);
|
|
69
|
+
else if (Math.floor(pct / 25) !== ultimo) console.log(` modelo de Whisper: ${pct}%`);
|
|
70
|
+
ultimo = Math.floor(pct / 25);
|
|
71
|
+
},
|
|
72
|
+
});
|
|
73
|
+
if (process.stdout.isTTY) process.stdout.write('\n');
|
|
74
|
+
acomodarWindows();
|
|
75
|
+
if (!fs.existsSync(ejecutable())) throw new Error(`Whisper se descargó pero no encuentro ${ejecutable()}. Borra ${WHISPER} y vuelve a correr: npx morfeo instalar --whisper`);
|
|
32
76
|
fs.writeFileSync(path.join(WHISPER, '.listo'), VERSION_CPP);
|
|
33
77
|
}
|
|
34
78
|
|
package/package.json
CHANGED
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "morfeo",
|
|
3
|
-
"version": "0.3.
|
|
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://
|
|
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"
|
|
@@ -45,6 +45,7 @@
|
|
|
45
45
|
"python/requirements.txt",
|
|
46
46
|
"assets",
|
|
47
47
|
"plantilla",
|
|
48
|
+
"bases",
|
|
48
49
|
"index.d.ts",
|
|
49
50
|
"LICENSE",
|
|
50
51
|
"README.md"
|
|
@@ -55,8 +56,8 @@
|
|
|
55
56
|
"scripts": {
|
|
56
57
|
"postinstall": "node bin/postinstall.mjs",
|
|
57
58
|
"typecheck": "tsc --noEmit",
|
|
58
|
-
"test": "node --test test
|
|
59
|
-
"prepublishOnly": "tsc --noEmit && node bin/morfeo.mjs -v"
|
|
59
|
+
"test": "node --test \"test/*.test.mjs\"",
|
|
60
|
+
"prepublishOnly": "tsc --noEmit && npm test && node bin/morfeo.mjs -v"
|
|
60
61
|
},
|
|
61
62
|
"dependencies": {
|
|
62
63
|
"@anthropic-ai/sdk": "^1.6.0",
|
|
@@ -3,7 +3,8 @@ import {defineConfig} from 'morfeo';
|
|
|
3
3
|
|
|
4
4
|
export default defineConfig({
|
|
5
5
|
// Estilo visual por defecto (cada video puede cambiarlo con estilo: '...'):
|
|
6
|
-
// datos-curiosos, explicativo, documental, ilustrado, historia, mesa, serie, infografia, 3d, minimal (npx morfeo estilos)
|
|
6
|
+
// datos-curiosos, explicativo, documental, ilustrado, historia, mesa, serie, infografia, 3d, maqueta, minimal (npx morfeo estilos)
|
|
7
|
+
// Para empezar un video con mundo 2D o 3D: npx morfeo nuevo-video mi-video --look 2d
|
|
7
8
|
estilo: 'datos-curiosos',
|
|
8
9
|
|
|
9
10
|
marca: {
|
|
@@ -17,6 +18,7 @@ export default defineConfig({
|
|
|
17
18
|
// Alias para no repetir: úsalos como voice: 'narrador' o music: 'mi-cancion'
|
|
18
19
|
voces: {
|
|
19
20
|
narrador: [['em_alex', 0.6], ['am_michael', 0.4]], // mezcla de voces Kokoro (gratis, en tu computadora)
|
|
21
|
+
narradora: [['ef_dora', 0.6], ['af_heart', 0.4]],
|
|
20
22
|
// eleven: {proveedor: 'elevenlabs', voz: 'TU_VOICE_ID'}, // necesita ELEVENLABS_API_KEY en .env
|
|
21
23
|
// openai: {proveedor: 'openai', voz: 'nova'}, // necesita OPENAI_API_KEY en .env
|
|
22
24
|
},
|