morfeo 0.2.1 → 0.3.1
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/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 +21 -3
- package/index.d.ts +27 -5
- package/lib/config.mjs +47 -10
- package/lib/entorno.mjs +22 -2
- package/lib/estructura.mjs +15 -4
- 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 +81 -16
- package/lib/whisper.mjs +47 -3
- package/package.json +4 -3
- 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/diorama.tsx +489 -0
- package/remotion/kit/escenarios2d.tsx +250 -0
- package/remotion/kit/index.ts +9 -1
- package/remotion/kit/mundo3d.tsx +251 -0
- package/remotion/kit/personas.tsx +439 -0
- package/remotion/kit/tres.tsx +126 -10
- package/remotion/motor/Video.tsx +13 -7
- package/remotion/motor/capas.tsx +29 -2
- package/remotion/motor/fichas.tsx +255 -0
- package/remotion/motor/modelo3d.tsx +1 -1
- package/remotion/motor/subtitulos.tsx +2 -2
- package/remotion/motor/tema.ts +20 -1
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
// `morfeo nuevo-video <id> --look 2d|3d`: crea guiones/<id>.mjs y escenas/Mundo<Id>.tsx desde una base que ya
|
|
2
|
+
// renderiza (carpeta bases/ del paquete) y registra el componente en morfeo.config.mjs → componentes.
|
|
3
|
+
import fs from 'node:fs';
|
|
4
|
+
import path from 'node:path';
|
|
5
|
+
import {ErrorMorfeo} from './config.mjs';
|
|
6
|
+
import {PAQUETE} from './entorno.mjs';
|
|
7
|
+
|
|
8
|
+
export const LOOKS = ['2d', '3d'];
|
|
9
|
+
|
|
10
|
+
/** "mi-video_2" -> "MundoMiVideo2" */
|
|
11
|
+
export const nombreComponente = (id) =>
|
|
12
|
+
'Mundo' +
|
|
13
|
+
String(id)
|
|
14
|
+
.normalize('NFD')
|
|
15
|
+
.replace(/[̀-ͯ]/g, '')
|
|
16
|
+
.split(/[^A-Za-z0-9]+/)
|
|
17
|
+
.filter(Boolean)
|
|
18
|
+
.map((p) => p[0].toUpperCase() + p.slice(1))
|
|
19
|
+
.join('');
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Agrega {Nombre: './escenas/Nombre.tsx'} a `componentes` del config (o crea la clave). Devuelve el texto nuevo,
|
|
23
|
+
* o null si ya estaba registrado.
|
|
24
|
+
*/
|
|
25
|
+
export function registrarEnConfig(texto, nombre, ruta) {
|
|
26
|
+
if (new RegExp(`\\b${nombre}\\s*:`).test(texto)) return null;
|
|
27
|
+
const entrada = `${nombre}: '${ruta}'`;
|
|
28
|
+
const m = texto.match(/componentes\s*:\s*\{/);
|
|
29
|
+
if (m) {
|
|
30
|
+
const i = m.index + m[0].length;
|
|
31
|
+
const vacio = /^\s*\}/.test(texto.slice(i));
|
|
32
|
+
return texto.slice(0, i) + (vacio ? entrada : `${entrada}, `) + texto.slice(i);
|
|
33
|
+
}
|
|
34
|
+
// sin `componentes`: se agrega antes del cierre de defineConfig({...}) / export default {...}
|
|
35
|
+
const fin = texto.lastIndexOf('}');
|
|
36
|
+
if (fin < 0) throw new ErrorMorfeo('No pude entender morfeo.config.mjs para registrar el componente; agrégalo a mano en `componentes`.');
|
|
37
|
+
const antes = texto.slice(0, fin).replace(/\s*$/, '');
|
|
38
|
+
const coma = antes.endsWith(',') || antes.endsWith('{') ? '' : ',';
|
|
39
|
+
return `${antes}${coma}\n\tcomponentes: {${entrada}},\n${texto.slice(fin)}`;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* @param {{proyecto: string, id: string, look?: string, carpetaGuiones?: string}} o
|
|
44
|
+
* @returns {{guion: string, escena: string, componente: string}}
|
|
45
|
+
*/
|
|
46
|
+
export function nuevoVideo({proyecto, id, look = '2d', carpetaGuiones}) {
|
|
47
|
+
if (!id || !/^[a-z0-9][a-z0-9-_]*$/i.test(id)) throw new ErrorMorfeo('Dame un id corto sin espacios: npx morfeo nuevo-video mi-video --look 2d');
|
|
48
|
+
if (!LOOKS.includes(look)) throw new ErrorMorfeo(`--look debe ser ${LOOKS.join(' o ')}`);
|
|
49
|
+
const config = ['morfeo.config.mjs', 'morfeo.config.js'].map((f) => path.join(proyecto, f)).find((f) => fs.existsSync(f));
|
|
50
|
+
if (!config) throw new ErrorMorfeo(`No encontré morfeo.config.mjs en ${proyecto}. Crea un proyecto con: npx morfeo nuevo mi-canal`);
|
|
51
|
+
const componente = nombreComponente(id);
|
|
52
|
+
const dirGuiones = carpetaGuiones ?? path.join(proyecto, 'guiones');
|
|
53
|
+
const guion = path.join(dirGuiones, `${id}.mjs`);
|
|
54
|
+
const escena = path.join(proyecto, 'escenas', `${componente}.tsx`);
|
|
55
|
+
for (const f of [guion, escena]) if (fs.existsSync(f)) throw new ErrorMorfeo(`Ya existe ${path.relative(proyecto, f)}; elige otro id.`);
|
|
56
|
+
const base = path.join(PAQUETE, 'bases', look);
|
|
57
|
+
const poner = (s) => s.replaceAll('__ID__', id).replaceAll('__COMPONENTE__', componente);
|
|
58
|
+
fs.mkdirSync(dirGuiones, {recursive: true});
|
|
59
|
+
fs.mkdirSync(path.dirname(escena), {recursive: true});
|
|
60
|
+
fs.writeFileSync(guion, poner(fs.readFileSync(path.join(base, 'guion.mjs'), 'utf8')));
|
|
61
|
+
fs.writeFileSync(escena, poner(fs.readFileSync(path.join(base, 'Mundo.tsx'), 'utf8')));
|
|
62
|
+
const nuevo = registrarEnConfig(fs.readFileSync(config, 'utf8'), componente, `./escenas/${componente}.tsx`);
|
|
63
|
+
if (nuevo) fs.writeFileSync(config, nuevo);
|
|
64
|
+
return {guion, escena, componente};
|
|
65
|
+
}
|
package/lib/palabras.mjs
ADDED
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
// Momentos por palabra ("en: 'tubería'") que la voz nunca dice: en el video caen "a media frase" sin avisar.
|
|
2
|
+
// Esto los busca con las mismas reglas que `cuando()` de remotion/motor/util.ts y avisa antes de renderizar.
|
|
3
|
+
|
|
4
|
+
/** Igual que `norm` de remotion/motor/util.ts: sin acentos, minúsculas, solo letras y números. */
|
|
5
|
+
export const normalizar = (s) =>
|
|
6
|
+
String(s)
|
|
7
|
+
.normalize('NFD')
|
|
8
|
+
.replace(/[̀-ͯ]/g, '')
|
|
9
|
+
.toLowerCase()
|
|
10
|
+
.replace(/[^a-z0-9]/g, '');
|
|
11
|
+
|
|
12
|
+
/** "palabra+0.3" -> "palabra"; null si no es un momento por palabra (número, inicio, voz, fin). */
|
|
13
|
+
export function palabraDe(en) {
|
|
14
|
+
if (typeof en !== 'string') return null;
|
|
15
|
+
const m = en.match(/^(.*?)([+-]\d+(?:\.\d+)?)?$/);
|
|
16
|
+
const base = (m?.[1] ?? en).trim();
|
|
17
|
+
if (!base || ['inicio', 'voz', 'fin'].includes(base)) return null;
|
|
18
|
+
return base;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** ¿La voz dice una palabra que empiece así? (la misma regla que `cuando`: "orca" encuentra "orcas"). */
|
|
22
|
+
export const laDice = (palabra, dichas) => {
|
|
23
|
+
const n = normalizar(palabra);
|
|
24
|
+
return !n || dichas.some((w) => normalizar(w).startsWith(n));
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
const CLAVES = ['en', 'hasta', 'check', 'clic', 'rotuloEn'];
|
|
28
|
+
|
|
29
|
+
/** Recorre una capa (y lo que lleva dentro) juntando [ruta, palabra]. */
|
|
30
|
+
function momentosDe(x, ruta, out) {
|
|
31
|
+
if (Array.isArray(x)) return x.forEach((v, i) => momentosDe(v, `${ruta}[${i}]`, out));
|
|
32
|
+
if (!x || typeof x !== 'object') return;
|
|
33
|
+
for (const k of CLAVES) {
|
|
34
|
+
const p = palabraDe(x[k]);
|
|
35
|
+
if (p) out.push([`${ruta}.${k}`, p]);
|
|
36
|
+
}
|
|
37
|
+
// lo que vive dentro de una capa: sus capas, su capa repetida, cambios, poses, items, líneas, movimientos
|
|
38
|
+
for (const k of ['capas', 'capa', 'cambios', 'poses', 'items', 'lineas', 'mover', 'escalar', 'opciones', 'capasPantalla'])
|
|
39
|
+
if (x[k] != null) momentosDe(x[k], `${ruta}.${k}`, out);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Avisos de momentos por palabra que no están en lo que dice la voz.
|
|
44
|
+
* @param {object} v video validado (scenes, hud, mundo)
|
|
45
|
+
* @param {object} d datos.json del video (palabras por escena)
|
|
46
|
+
* @returns {string[]}
|
|
47
|
+
*/
|
|
48
|
+
export function revisarPalabras(v, d) {
|
|
49
|
+
if (!d?.escenas) return [];
|
|
50
|
+
const avisos = [];
|
|
51
|
+
const todas = d.escenas.flatMap((e) => (e.palabras ?? []).map(([w]) => w));
|
|
52
|
+
(v.scenes ?? []).forEach((s, i) => {
|
|
53
|
+
const e = d.escenas[i];
|
|
54
|
+
if (!s || !e) return;
|
|
55
|
+
const dichas = (e.palabras ?? []).map(([w]) => w);
|
|
56
|
+
if (!dichas.length) return;
|
|
57
|
+
const momentos = [];
|
|
58
|
+
// los de la escena misma (rotuloEn, at/click de las clásicas) y los de sus capas
|
|
59
|
+
for (const k of ['rotuloEn', 'at', 'click']) {
|
|
60
|
+
const p = palabraDe(s[k]);
|
|
61
|
+
if (p) momentos.push([k, p]);
|
|
62
|
+
}
|
|
63
|
+
momentosDe(s.capas ?? [], 'capas', momentos);
|
|
64
|
+
momentosDe(s.items ?? [], 'items', momentos);
|
|
65
|
+
momentosDe(s.opciones ?? [], 'opciones', momentos);
|
|
66
|
+
// con `show`, los momentos se buscan en las palabras del subtítulo (las cifras quedan como "1.560.000")
|
|
67
|
+
const texto = String(s.show ?? s.say ?? '').slice(0, 60);
|
|
68
|
+
for (const [ruta, p] of momentos)
|
|
69
|
+
if (!laDice(p, dichas))
|
|
70
|
+
avisos.push(`escena ${i + 1}, ${ruta}: "${p}" no está en ${s.show ? 'el subtítulo (`show`)' : 'lo que dice la voz'} ("${texto}"); se usará la mitad de la frase`);
|
|
71
|
+
});
|
|
72
|
+
// hud y mundo usan las palabras de todo el video
|
|
73
|
+
for (const [k, lista] of [['hud', v.hud], ['mundo', v.mundo]]) {
|
|
74
|
+
const momentos = [];
|
|
75
|
+
momentosDe(lista ?? [], k, momentos);
|
|
76
|
+
for (const [ruta, p] of momentos) if (!laDice(p, todas)) avisos.push(`${ruta}: "${p}" no aparece en ninguna escena`);
|
|
77
|
+
}
|
|
78
|
+
return avisos;
|
|
79
|
+
}
|
package/lib/render.mjs
CHANGED
|
@@ -14,6 +14,7 @@ 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';
|
|
17
18
|
|
|
18
19
|
const require = createRequire(import.meta.url);
|
|
19
20
|
|
|
@@ -142,14 +143,16 @@ function python(trabajo) {
|
|
|
142
143
|
stdio: 'inherit',
|
|
143
144
|
env: {...process.env, PYTHONUTF8: '1', PYTHONIOENCODING: 'utf-8'},
|
|
144
145
|
});
|
|
146
|
+
if (r.status === 10) return 'falta-whisper';
|
|
145
147
|
if (r.status !== 0) throw new ErrorMorfeo(`Falló la preparación del audio (código ${r.status}). Revisa el mensaje de arriba.`);
|
|
148
|
+
return 'listo';
|
|
146
149
|
}
|
|
147
150
|
|
|
148
151
|
/**
|
|
149
152
|
* Prepara (audio + tiempos) los videos indicados. Devuelve {publico, marca, datos: {id: datos}}.
|
|
150
153
|
* @param {{proyecto?: string, ids?: string[], log?: (m: string) => void}} o
|
|
151
154
|
*/
|
|
152
|
-
export async function preparar({proyecto = process.cwd(), ids, log = console.log, _p} = {}) {
|
|
155
|
+
export async function preparar({proyecto = process.cwd(), ids, log = console.log, _p, continuar = false} = {}) {
|
|
153
156
|
cargarEnv(proyecto);
|
|
154
157
|
const p = _p ?? (await cargarProyecto(proyecto));
|
|
155
158
|
if (!venvListo() || !modelosListos()) throw new ErrorMorfeo('Falta preparar tu computadora. Corre una vez: npx morfeo instalar');
|
|
@@ -161,10 +164,12 @@ export async function preparar({proyecto = process.cwd(), ids, log = console.log
|
|
|
161
164
|
const trabajos = path.join(p.trabajo, 'trabajos');
|
|
162
165
|
const creditos = {};
|
|
163
166
|
fs.mkdirSync(trabajos, {recursive: true});
|
|
167
|
+
const fallidos = [];
|
|
164
168
|
for (const [i, id] of (ids ?? todos).entries()) {
|
|
165
169
|
const original = p.videos[id];
|
|
166
170
|
if (!original) throw new ErrorMorfeo(`No existe el video "${id}". Usa: npx morfeo lista`);
|
|
167
171
|
log(`[${i + 1}/${(ids ?? todos).length}] ${id}: audio...`);
|
|
172
|
+
try {
|
|
168
173
|
const conRecursos = await resolverRecursos(ponerPoses(original, poses), path.join(publico, 'assets'), log);
|
|
169
174
|
creditos[id] = conRecursos.creditos;
|
|
170
175
|
guardarCreditos(p, id, conRecursos.creditos);
|
|
@@ -172,17 +177,28 @@ export async function preparar({proyecto = process.cwd(), ids, log = console.log
|
|
|
172
177
|
const video = await sintetizarVoces(id, conImagenes, path.join(p.trabajo, 'cache', 'voces'), log);
|
|
173
178
|
const job = path.join(trabajos, `${id}.json`);
|
|
174
179
|
const base = {id, video, indice: todos.indexOf(id), publico, modelos: MODELOS};
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
180
|
+
// una sola pasada de Python cuando la voz y su transcripción ya están en caché (lo normal al volver a renderizar);
|
|
181
|
+
// si la voz es nueva, Python sale con 10, se transcribe con Whisper y se vuelve a llamar
|
|
182
|
+
fs.writeFileSync(job, JSON.stringify({...base, conWhisper}));
|
|
183
|
+
if (python(job) === 'falta-whisper') {
|
|
178
184
|
log(' whisper...');
|
|
179
|
-
|
|
185
|
+
try {
|
|
186
|
+
await transcribir(path.join(publico, 'v', id), (video.lang ?? 'es').slice(0, 2));
|
|
187
|
+
} catch (e) {
|
|
188
|
+
log(` (Whisper falló: ${mensajeDe(e)}; subtítulos repartidos por frase)`);
|
|
189
|
+
}
|
|
190
|
+
fs.writeFileSync(job, JSON.stringify(base));
|
|
191
|
+
python(job);
|
|
180
192
|
}
|
|
181
|
-
fs.writeFileSync(job, JSON.stringify(base));
|
|
182
|
-
python(job);
|
|
183
193
|
datos[id] = datosDe(publico, id);
|
|
194
|
+
for (const a of revisarPalabras(original, datos[id])) log(` ! ${a}`);
|
|
195
|
+
} catch (e) {
|
|
196
|
+
if (!continuar) throw e;
|
|
197
|
+
fallidos.push({id, error: mensajeDe(e)});
|
|
198
|
+
log(` ✘ ${id}: ${mensajeDe(e)}`);
|
|
199
|
+
}
|
|
184
200
|
}
|
|
185
|
-
return {p, publico, marca, extras, datos, creditos};
|
|
201
|
+
return {p, publico, marca, extras, datos, creditos, fallidos};
|
|
186
202
|
}
|
|
187
203
|
|
|
188
204
|
/**
|
|
@@ -190,9 +206,18 @@ export async function preparar({proyecto = process.cwd(), ids, log = console.log
|
|
|
190
206
|
* @param {{proyecto?: string, ids?: string[], todos?: boolean, log?: (m: string) => void, alProgreso?: (id: string, p: number) => void}} o
|
|
191
207
|
* @returns {Promise<{id: string, mp4: string, jpg: string | null, segundos: number}[]>}
|
|
192
208
|
*/
|
|
193
|
-
export async function render({proyecto = process.cwd(), ids, todos = false, log = console.log, alProgreso} = {}) {
|
|
209
|
+
export async function render({proyecto = process.cwd(), ids, todos = false, soloFallidos = false, log = console.log, alProgreso} = {}) {
|
|
194
210
|
cargarEnv(proyecto);
|
|
195
211
|
const p = await cargarProyecto(proyecto);
|
|
212
|
+
const loteArchivo = path.join(p.trabajo, 'ultimo-lote.json');
|
|
213
|
+
if (soloFallidos) {
|
|
214
|
+
const previo = fs.existsSync(loteArchivo) ? JSON.parse(fs.readFileSync(loteArchivo, 'utf8')) : {fallidos: []};
|
|
215
|
+
ids = previo.fallidos.map((f) => f.id).filter((id) => p.videos[id]);
|
|
216
|
+
if (!ids.length) {
|
|
217
|
+
log('El último lote no tuvo videos fallidos.');
|
|
218
|
+
return [];
|
|
219
|
+
}
|
|
220
|
+
}
|
|
196
221
|
let lista = ids?.length ? ids : todos ? Object.keys(p.videos) : Object.keys(p.videos).filter((id) => !fs.existsSync(salidaDe(p, id)));
|
|
197
222
|
const malos = lista.filter((id) => !p.videos[id]);
|
|
198
223
|
if (malos.length) throw new ErrorMorfeo(`No existen: ${malos.join(', ')}. Usa: npx morfeo lista`);
|
|
@@ -201,14 +226,16 @@ export async function render({proyecto = process.cwd(), ids, todos = false, log
|
|
|
201
226
|
return [];
|
|
202
227
|
}
|
|
203
228
|
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});
|
|
229
|
+
const {publico, marca, extras, datos, fallidos} = await preparar({proyecto, ids: lista, log, _p: p, continuar: true});
|
|
230
|
+
const listos = lista.filter((id) => datos[id]);
|
|
205
231
|
// estructura: consejos medidos en videos virales (no detienen el render; se apagan con render.consejos: false)
|
|
206
232
|
if (p.render.consejos !== false)
|
|
207
|
-
for (const id of
|
|
233
|
+
for (const id of listos) {
|
|
208
234
|
const c = revisarEstructura(p.videos[id], datos[id]);
|
|
209
235
|
if (c.length) log(` ${id}: ${c.length} consejo(s) de estructura\n${consejosEnTexto(c)}`);
|
|
210
236
|
}
|
|
211
237
|
|
|
238
|
+
if (!listos.length) return terminarLote(p, loteArchivo, [], fallidos, log);
|
|
212
239
|
log('Empaquetando la animación...');
|
|
213
240
|
const {bundle} = await import('@remotion/bundler');
|
|
214
241
|
const {renderMedia, renderStill, selectComposition} = await import('@remotion/renderer');
|
|
@@ -222,8 +249,9 @@ export async function render({proyecto = process.cwd(), ids, todos = false, log
|
|
|
222
249
|
const nucleos = os.cpus().length;
|
|
223
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))));
|
|
224
251
|
const sinTTY = !process.stdout.isTTY;
|
|
225
|
-
for (const [i, id] of
|
|
252
|
+
for (const [i, id] of listos.entries()) {
|
|
226
253
|
const t = Date.now();
|
|
254
|
+
try {
|
|
227
255
|
const inputProps = {videos: {[id]: datos[id]}, marca, ...extras};
|
|
228
256
|
const mp4 = salidaDe(p, id);
|
|
229
257
|
let gl = p.render.gl;
|
|
@@ -242,7 +270,7 @@ export async function render({proyecto = process.cwd(), ids, todos = false, log
|
|
|
242
270
|
browserExecutable,
|
|
243
271
|
chromiumOptions: chromium,
|
|
244
272
|
codec: 'h264',
|
|
245
|
-
crf: p
|
|
273
|
+
crf: crfDe(p, id),
|
|
246
274
|
audioBitrate: '192k',
|
|
247
275
|
imageFormat: 'jpeg',
|
|
248
276
|
jpegQuality: 92,
|
|
@@ -254,8 +282,8 @@ export async function render({proyecto = process.cwd(), ids, todos = false, log
|
|
|
254
282
|
const paso = sinTTY ? 4 : 10;
|
|
255
283
|
const n = Math.floor(progress * paso);
|
|
256
284
|
if (n !== ultimo) {
|
|
257
|
-
if (sinTTY) log(`[${i + 1}/${
|
|
258
|
-
else process.stdout.write(`\r[${i + 1}/${
|
|
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}% `);
|
|
259
287
|
}
|
|
260
288
|
ultimo = n;
|
|
261
289
|
},
|
|
@@ -285,10 +313,39 @@ export async function render({proyecto = process.cwd(), ids, todos = false, log
|
|
|
285
313
|
}
|
|
286
314
|
const segundos = Math.round((Date.now() - t) / 1000);
|
|
287
315
|
if (process.stdout.isTTY) process.stdout.write('\r');
|
|
288
|
-
log(`[${i + 1}/${
|
|
316
|
+
log(`[${i + 1}/${listos.length}] ${id}: listo en ${segundos} s → ${path.relative(p.proyecto, mp4)} `);
|
|
289
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
|
+
}
|
|
290
323
|
}
|
|
324
|
+
return terminarLote(p, loteArchivo, hechos, fallidos, log);
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
const CRF = {alta: 18, media: 21, ligera: 24};
|
|
328
|
+
/** CRF del video: `crf` explícito > `calidad` del video > `render.calidad` > media con mundo, alta sin él. */
|
|
329
|
+
export function crfDe(p, id) {
|
|
330
|
+
if (p.render.crf != null) return p.render.crf;
|
|
331
|
+
const v = p.videos[id] ?? {};
|
|
332
|
+
return CRF[v.calidad ?? p.render.calidad ?? (v.mundo?.length ? 'media' : 'alta')] ?? 18;
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
/** Primera línea útil de un error (sin la pila de Remotion o de Python). */
|
|
336
|
+
const mensajeDe = (e) => String(e?.message ?? e).split('\n').find((l) => l.trim()) ?? 'error desconocido';
|
|
337
|
+
|
|
338
|
+
/** Resumen del lote: lo guarda (para --solo-fallidos), lo imprime y marca la salida con error si algo falló. */
|
|
339
|
+
function terminarLote(p, archivo, hechos, fallidos, log) {
|
|
291
340
|
escribirResumen(p);
|
|
341
|
+
fs.mkdirSync(path.dirname(archivo), {recursive: true});
|
|
342
|
+
fs.writeFileSync(archivo, JSON.stringify({fecha: new Date().toISOString(), hechos: hechos.map((h) => h.id), fallidos}, null, '\t'));
|
|
343
|
+
if (fallidos.length) {
|
|
344
|
+
log(`\n${hechos.length} hecho(s), ${fallidos.length} con error:`);
|
|
345
|
+
for (const f of fallidos) log(` ✘ ${f.id}: ${f.error}`);
|
|
346
|
+
log('Corrígelos y vuelve a intentar solo esos con: npx morfeo render --solo-fallidos');
|
|
347
|
+
process.exitCode = 1;
|
|
348
|
+
}
|
|
292
349
|
return hechos;
|
|
293
350
|
}
|
|
294
351
|
|
|
@@ -318,6 +375,14 @@ function escribirResumen(p) {
|
|
|
318
375
|
fs.writeFileSync(path.join(p.salida, 'resumen.json'), JSON.stringify(filas, null, '\t') + '\n');
|
|
319
376
|
// créditos para la descripción del video (las licencias CC BY piden mencionar al autor)
|
|
320
377
|
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)
|
|
379
|
+
for (const f of filas) {
|
|
380
|
+
const v = p.videos[f.id];
|
|
381
|
+
const tags = (v.tags ?? []).map((x) => `#${String(x).replace(/^#/, '').replace(/\s+/g, '')}`).join(' ');
|
|
382
|
+
const creditos = f.creditos?.length ? ['', 'Créditos:', ...f.creditos.map((c) => `- ${c}`)] : [];
|
|
383
|
+
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');
|
|
385
|
+
}
|
|
321
386
|
if (conCreditos.length)
|
|
322
387
|
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
388
|
}
|
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,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "morfeo",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.1",
|
|
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",
|
|
@@ -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
|
},
|
package/python/musica.py
CHANGED
|
@@ -175,20 +175,50 @@ def efecto(nombre, rng):
|
|
|
175
175
|
if nombre == "tick": return 0.4 * tick()
|
|
176
176
|
if nombre == "glitch": return 0.3 * glitch(rng)
|
|
177
177
|
if nombre == "ding": return 0.2 * bell(nf(88), 0.8)
|
|
178
|
+
if nombre == "golpe":
|
|
179
|
+
x = 0.22 * pop(180, 90, 0.16)
|
|
180
|
+
add(x, 0.1 * lp(rng.standard_normal(int(0.06 * SR)), 1200), 0)
|
|
181
|
+
return x
|
|
178
182
|
return 0.14 * pop()
|
|
179
183
|
|
|
180
184
|
|
|
185
|
+
# sonido por defecto según cómo entra o qué es la capa (sonido: false lo apaga; sonido: "x" lo cambia)
|
|
186
|
+
SONIDO_TIPO = {"sello": "golpe", "dato": "tick", "contador": "tick", "nombre": "pop", "accion": "pop", "chip": "pop", "insignia": "pop"}
|
|
187
|
+
SONIDO_ENTRADA = {"rebote": "pop", "caer": "golpe", "deslizar-izq": "whoosh", "deslizar-der": "whoosh", "deslizar-arriba": "whoosh", "deslizar-abajo": "whoosh", "girar": "whoosh"}
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
def sonido_de(c):
|
|
191
|
+
s = c.get("sonido")
|
|
192
|
+
if s is False or s == "ninguno": return None
|
|
193
|
+
if s: return s
|
|
194
|
+
return SONIDO_TIPO.get(c.get("tipo")) or SONIDO_ENTRADA.get(c.get("entrada"))
|
|
195
|
+
|
|
196
|
+
|
|
181
197
|
def capas_con_sonido(sp):
|
|
182
|
-
"""
|
|
198
|
+
"""[(capa, efecto, explícito)] de la escena y de sus grupos; las de enMundo y las del HUD no suenan solas."""
|
|
183
199
|
out, pila = [], list(sp.get("capas") or [])
|
|
184
200
|
while pila:
|
|
185
201
|
c = pila.pop(0)
|
|
186
202
|
if not isinstance(c, dict): continue
|
|
187
|
-
|
|
203
|
+
ef = sonido_de(c)
|
|
204
|
+
if ef: out.append((c, ef, bool(c.get("sonido"))))
|
|
188
205
|
pila.extend(c.get("capas") or [])
|
|
189
206
|
return out
|
|
190
207
|
|
|
191
208
|
|
|
209
|
+
def momento(en, ev, si, m):
|
|
210
|
+
"""Segundos (relativos a la escena) de `en`: número, palabra, "palabra+0.3", "inicio", "voz" o "fin" (como cuando() del motor)."""
|
|
211
|
+
if en is None: return 0.3
|
|
212
|
+
if isinstance(en, (int, float)): return float(en)
|
|
213
|
+
import re
|
|
214
|
+
g = re.match(r"^(.*?)([+-]\d+(?:\.\d+)?)?$", str(en))
|
|
215
|
+
base, ajuste = (g.group(1) or "").strip(), float(g.group(2) or 0)
|
|
216
|
+
if base in ("", "inicio"): return ajuste
|
|
217
|
+
if base == "voz": return m["start"] - m["t0"] + ajuste
|
|
218
|
+
if base == "fin": return m["start"] - m["t0"] + m["speech"] + ajuste
|
|
219
|
+
return ev(si, base) + ajuste
|
|
220
|
+
|
|
221
|
+
|
|
192
222
|
def mix(vid, style, outdir, TIM, SPEC, durs, ev):
|
|
193
223
|
"""Mezcla voz + música (con ducking) + efectos y escribe mix.wav. style=None: sin música."""
|
|
194
224
|
dur = sum(durs); N = int(dur * SR); rng = np.random.default_rng(11)
|
|
@@ -206,11 +236,14 @@ def mix(vid, style, outdir, TIM, SPEC, durs, ev):
|
|
|
206
236
|
if ty == "text" and sp.get("strike"):
|
|
207
237
|
for i in range(len(sp["rows"])): add(sfx, 0.2 * whoosh(rng, 0.15), t0 + m["speech"] * 0.55 + i * 0.12)
|
|
208
238
|
if ty == "cta": add(sfx, 0.4 * tick(), t0 + 1.6)
|
|
209
|
-
# motor de capas: cada capa (
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
239
|
+
# motor de capas: cada capa suena cuando entra (las explícitas siempre; las automáticas, más bajas y sin amontonarse)
|
|
240
|
+
if sp.get("rotulo"): add(sfx, 0.5 * efecto("whoosh", rng), t0 + momento(sp.get("rotuloEn", 0.12), ev, si, m) - 0.05)
|
|
241
|
+
usados = []
|
|
242
|
+
for capa, ef, explicito in sorted(capas_con_sonido(sp), key=lambda x: momento(x[0].get("en"), ev, si, m)):
|
|
243
|
+
tc = t0 + momento(capa.get("en"), ev, si, m)
|
|
244
|
+
if not explicito and any(abs(tc - u) < 0.25 for u in usados): continue
|
|
245
|
+
usados.append(tc)
|
|
246
|
+
add(sfx, (1.0 if explicito else 0.6) * efecto(ef, rng), tc)
|
|
214
247
|
voice, _ = sf.read(os.path.join(outdir, "voz.wav"))
|
|
215
248
|
voice = resample_poly(voice, 147, 80)
|
|
216
249
|
v = np.zeros(N); v[: min(N, len(voice))] = voice[:N]
|
package/python/preparar.py
CHANGED
|
@@ -228,16 +228,25 @@ def preparar(job):
|
|
|
228
228
|
if not voz_vigente(v, outdir):
|
|
229
229
|
print(" voz...", flush=True); hacer_voz(v, outdir, job["modelos"])
|
|
230
230
|
if job.get("soloVoz"): return
|
|
231
|
+
# pidió Whisper y la transcripción no existe (voz nueva): se sale con 10 para que morfeo transcriba y vuelva a llamar
|
|
232
|
+
if job.get("conWhisper") and not os.path.exists(os.path.join(outdir, "whisper.json")): sys.exit(10)
|
|
231
233
|
TIM = json.load(open(os.path.join(outdir, "tiempos.json"), encoding="utf-8"))["escenas"]
|
|
232
234
|
wj = os.path.join(outdir, "whisper.json"); ww = palabras_whisper(wj) if os.path.exists(wj) else None
|
|
233
235
|
WORDS, reales = [], 0
|
|
234
236
|
for seg in TIM:
|
|
235
237
|
w, ok = tiempos_palabras(seg, ww); WORDS.append(w); reales += ok
|
|
236
238
|
|
|
239
|
+
def norm(x):
|
|
240
|
+
# igual que norm() de remotion/motor/util.ts: sin acentos, minúsculas, solo letras y números
|
|
241
|
+
import unicodedata
|
|
242
|
+
x = unicodedata.normalize("NFD", str(x))
|
|
243
|
+
return "".join(ch for ch in x if not unicodedata.combining(ch)).lower().translate({ord(c): None for c in "¿?.,:;¡!\"'«»()"}).replace(" ", "")
|
|
244
|
+
|
|
237
245
|
def ev(si, prefix):
|
|
238
246
|
seg = TIM[si]
|
|
247
|
+
p = norm(prefix)
|
|
239
248
|
for w, a, b in WORDS[si]:
|
|
240
|
-
if w
|
|
249
|
+
if norm(w).startswith(p): return a - seg["t0"]
|
|
241
250
|
return seg["speech"] * 0.5
|
|
242
251
|
|
|
243
252
|
durs = [seg["dur"] + (0.6 if i == len(TIM) - 1 else 0) for i, seg in enumerate(TIM)]
|