morfeo 0.3.0 → 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 +20 -3
- package/index.d.ts +10 -3
- package/lib/config.mjs +45 -8
- package/lib/entorno.mjs +22 -2
- 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/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 +3 -3
- package/remotion/motor/capas.tsx +27 -2
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.
|
|
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)]
|
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
// Gráficas 2D que se dibujan solas (van DENTRO de un <svg>): barras, línea, comparación y lupa.
|
|
2
|
+
// `t` es el tiempo y `en` el momento en que empiezan (segundos); todo se anima con el tiempo.
|
|
3
|
+
//
|
|
4
|
+
// <GraficaBarras x={140} y={700} ancho={800} alto={500} t={t} en={1} datos={[['0 m', 100], ['1 m', 50], ['2 m', 25]]} />
|
|
5
|
+
// <GraficaLinea x={140} y={700} ancho={800} alto={420} t={t} puntos={[100, 50, 25, 12]} etiquetas={['0', '1', '2', '3']} />
|
|
6
|
+
// <Comparacion x={540} y={1300} t={t} a={['ANTES', 10]} b={['DESPUÉS', 27.4]} unidad="kg" />
|
|
7
|
+
// <Lupa x={540} y={900} radio={220} t={t}> ...lo que se ve aumentado... </Lupa>
|
|
8
|
+
import React from 'react';
|
|
9
|
+
|
|
10
|
+
const k = (t: number, en: number, dur: number) => {
|
|
11
|
+
const x = Math.max(0, Math.min(1, (t - en) / dur));
|
|
12
|
+
return 1 - (1 - x) ** 3;
|
|
13
|
+
};
|
|
14
|
+
const fmt = (v: number, dec = 0) => v.toLocaleString('es-MX', {minimumFractionDigits: dec, maximumFractionDigits: dec});
|
|
15
|
+
|
|
16
|
+
type Comun = {x: number; y: number; t: number; en?: number; color?: string; texto?: string; fuente?: string};
|
|
17
|
+
|
|
18
|
+
/** Barras horizontales: [etiqueta, valor]. La más grande ocupa todo el ancho; el valor cuenta mientras crece. */
|
|
19
|
+
export const GraficaBarras: React.FC<Comun & {ancho?: number; alto?: number; datos: [string, number][]; unidad?: string; colores?: string[]; decimales?: number; maximo?: number}> = ({
|
|
20
|
+
x,
|
|
21
|
+
y,
|
|
22
|
+
t,
|
|
23
|
+
en = 0,
|
|
24
|
+
ancho = 820,
|
|
25
|
+
alto = 480,
|
|
26
|
+
datos,
|
|
27
|
+
unidad = '',
|
|
28
|
+
color = '#F2B632',
|
|
29
|
+
colores,
|
|
30
|
+
texto = '#FFFFFF',
|
|
31
|
+
fuente = 'JetBrains Mono, monospace',
|
|
32
|
+
decimales = 0,
|
|
33
|
+
maximo,
|
|
34
|
+
}) => {
|
|
35
|
+
const max = maximo ?? Math.max(...datos.map(([, v]) => v), 1);
|
|
36
|
+
const fila = alto / datos.length;
|
|
37
|
+
const etiqueta = 150;
|
|
38
|
+
return (
|
|
39
|
+
<g transform={`translate(${x} ${y})`}>
|
|
40
|
+
{datos.map(([e, v], i) => {
|
|
41
|
+
const p = k(t, en + i * 0.18, 0.9);
|
|
42
|
+
const w = (ancho - etiqueta - 150) * (v / max) * p;
|
|
43
|
+
return (
|
|
44
|
+
<g key={i} transform={`translate(0 ${i * fila})`} opacity={Math.min(1, p * 4)}>
|
|
45
|
+
<text x={etiqueta - 20} y={fila * 0.42} textAnchor="end" dominantBaseline="middle" fontFamily={fuente} fontSize={fila * 0.32} fill={texto} opacity={0.75}>
|
|
46
|
+
{e}
|
|
47
|
+
</text>
|
|
48
|
+
<rect x={etiqueta} y={fila * 0.18} width={Math.max(4, w)} height={fila * 0.48} rx={fila * 0.1} fill={colores?.[i] ?? color} />
|
|
49
|
+
<text x={etiqueta + w + 16} y={fila * 0.42} dominantBaseline="middle" fontFamily={fuente} fontWeight={700} fontSize={fila * 0.32} fill={texto}>
|
|
50
|
+
{fmt(v * p, decimales)}
|
|
51
|
+
{unidad}
|
|
52
|
+
</text>
|
|
53
|
+
</g>
|
|
54
|
+
);
|
|
55
|
+
})}
|
|
56
|
+
</g>
|
|
57
|
+
);
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
/** Línea que se dibuja de izquierda a derecha, con puntos y etiquetas en el eje. */
|
|
61
|
+
export const GraficaLinea: React.FC<Comun & {ancho?: number; alto?: number; puntos: number[]; etiquetas?: string[]; minimo?: number; maximo?: number; relleno?: boolean; dur?: number}> = ({
|
|
62
|
+
x,
|
|
63
|
+
y,
|
|
64
|
+
t,
|
|
65
|
+
en = 0,
|
|
66
|
+
ancho = 820,
|
|
67
|
+
alto = 420,
|
|
68
|
+
puntos,
|
|
69
|
+
etiquetas = [],
|
|
70
|
+
color = '#F07A3A',
|
|
71
|
+
texto = '#FFFFFF',
|
|
72
|
+
fuente = 'JetBrains Mono, monospace',
|
|
73
|
+
minimo,
|
|
74
|
+
maximo,
|
|
75
|
+
relleno = true,
|
|
76
|
+
dur = 1.4,
|
|
77
|
+
}) => {
|
|
78
|
+
const mn = minimo ?? Math.min(0, ...puntos);
|
|
79
|
+
const mx = maximo ?? Math.max(...puntos);
|
|
80
|
+
const px = (i: number) => (i / Math.max(1, puntos.length - 1)) * ancho;
|
|
81
|
+
const py = (v: number) => alto - ((v - mn) / Math.max(1e-6, mx - mn)) * alto;
|
|
82
|
+
const p = k(t, en, dur);
|
|
83
|
+
const d = puntos.map((v, i) => `${i ? 'L' : 'M'} ${px(i)} ${py(v)}`).join(' ');
|
|
84
|
+
const largo = puntos.slice(1).reduce((s, v, i) => s + Math.hypot(px(i + 1) - px(i), py(v) - py(puntos[i])), 0);
|
|
85
|
+
const id = `linea-${Math.round(x)}-${Math.round(y)}`;
|
|
86
|
+
return (
|
|
87
|
+
<g transform={`translate(${x} ${y})`}>
|
|
88
|
+
<line x1={0} y1={alto} x2={ancho} y2={alto} stroke={texto} strokeOpacity={0.35} strokeWidth={3} />
|
|
89
|
+
<line x1={0} y1={0} x2={0} y2={alto} stroke={texto} strokeOpacity={0.35} strokeWidth={3} />
|
|
90
|
+
{relleno ? (
|
|
91
|
+
<>
|
|
92
|
+
<defs>
|
|
93
|
+
<linearGradient id={id} x1="0" y1="0" x2="0" y2="1">
|
|
94
|
+
<stop offset="0" stopColor={color} stopOpacity={0.35} />
|
|
95
|
+
<stop offset="1" stopColor={color} stopOpacity={0} />
|
|
96
|
+
</linearGradient>
|
|
97
|
+
<clipPath id={`${id}-c`}>
|
|
98
|
+
<rect x={0} y={-50} width={ancho * p} height={alto + 100} />
|
|
99
|
+
</clipPath>
|
|
100
|
+
</defs>
|
|
101
|
+
<path d={`${d} L ${ancho} ${alto} L 0 ${alto} Z`} fill={`url(#${id})`} clipPath={`url(#${id}-c)`} />
|
|
102
|
+
</>
|
|
103
|
+
) : null}
|
|
104
|
+
<path d={d} fill="none" stroke={color} strokeWidth={7} strokeLinecap="round" strokeLinejoin="round" strokeDasharray={largo} strokeDashoffset={largo * (1 - p)} />
|
|
105
|
+
{puntos.map((v, i) =>
|
|
106
|
+
px(i) <= ancho * p + 1 ? <circle key={i} cx={px(i)} cy={py(v)} r={11} fill={color} stroke="#fff" strokeWidth={4} /> : null,
|
|
107
|
+
)}
|
|
108
|
+
{etiquetas.map((e, i) => (
|
|
109
|
+
<text key={i} x={px(i)} y={alto + 44} textAnchor="middle" fontFamily={fuente} fontSize={28} fill={texto} opacity={0.7}>
|
|
110
|
+
{e}
|
|
111
|
+
</text>
|
|
112
|
+
))}
|
|
113
|
+
</g>
|
|
114
|
+
);
|
|
115
|
+
};
|
|
116
|
+
|
|
117
|
+
/** Dos columnas que crecen hasta su valor (antes / después, A contra B), con el número contando encima. */
|
|
118
|
+
export const Comparacion: React.FC<Comun & {a: [string, number]; b: [string, number]; unidad?: string; alto?: number; colorA?: string; colorB?: string; decimales?: number}> = ({
|
|
119
|
+
x,
|
|
120
|
+
y,
|
|
121
|
+
t,
|
|
122
|
+
en = 0,
|
|
123
|
+
a,
|
|
124
|
+
b,
|
|
125
|
+
unidad = '',
|
|
126
|
+
alto = 520,
|
|
127
|
+
colorA = '#8A8F9C',
|
|
128
|
+
colorB = '#2FD08A',
|
|
129
|
+
texto = '#FFFFFF',
|
|
130
|
+
fuente = 'Montserrat, sans-serif',
|
|
131
|
+
decimales = 0,
|
|
132
|
+
}) => {
|
|
133
|
+
const max = Math.max(a[1], b[1], 1e-6);
|
|
134
|
+
const col = (lbl: string, v: number, c: string, dx: number, retraso: number) => {
|
|
135
|
+
const p = k(t, en + retraso, 1.1);
|
|
136
|
+
const h = (alto * v * p) / max;
|
|
137
|
+
return (
|
|
138
|
+
<g transform={`translate(${dx} 0)`}>
|
|
139
|
+
<rect x={-90} y={-h} width={180} height={Math.max(2, h)} rx={18} fill={c} />
|
|
140
|
+
<text x={0} y={-h - 28} textAnchor="middle" fontFamily={fuente} fontWeight={900} fontSize={64} fill={texto}>
|
|
141
|
+
{fmt(v * p, decimales)}
|
|
142
|
+
<tspan fontSize={34}>{unidad}</tspan>
|
|
143
|
+
</text>
|
|
144
|
+
<text x={0} y={56} textAnchor="middle" fontFamily={fuente} fontWeight={800} fontSize={36} fill={texto} opacity={0.8}>
|
|
145
|
+
{lbl}
|
|
146
|
+
</text>
|
|
147
|
+
</g>
|
|
148
|
+
);
|
|
149
|
+
};
|
|
150
|
+
return (
|
|
151
|
+
<g transform={`translate(${x} ${y})`}>
|
|
152
|
+
<line x1={-280} y1={0} x2={280} y2={0} stroke={texto} strokeOpacity={0.4} strokeWidth={4} />
|
|
153
|
+
{col(a[0], a[1], colorA, -150, 0)}
|
|
154
|
+
{col(b[0], b[1], colorB, 150, 0.5)}
|
|
155
|
+
</g>
|
|
156
|
+
);
|
|
157
|
+
};
|
|
158
|
+
|
|
159
|
+
/** Lupa: aro con mango; dentro se ve `children` (lo que dibujes, normalmente más grande). Entra con un pequeño rebote. */
|
|
160
|
+
export const Lupa: React.FC<{x: number; y: number; radio?: number; t: number; en?: number; fondo?: string; children?: React.ReactNode}> = ({x, y, radio = 220, t, en = 0, fondo = '#F4E6D8', children}) => {
|
|
161
|
+
const p = k(t, en, 0.5);
|
|
162
|
+
const s = 0.6 + 0.4 * p + Math.sin(Math.min(1, (t - en) / 0.5) * Math.PI) * 0.05;
|
|
163
|
+
const id = `lupa-${Math.round(x)}-${Math.round(y)}`;
|
|
164
|
+
if (p <= 0) return null;
|
|
165
|
+
return (
|
|
166
|
+
<g transform={`translate(${x} ${y}) scale(${s})`} opacity={Math.min(1, p * 3)}>
|
|
167
|
+
<defs>
|
|
168
|
+
<clipPath id={id}>
|
|
169
|
+
<circle r={radio} />
|
|
170
|
+
</clipPath>
|
|
171
|
+
</defs>
|
|
172
|
+
<rect x={radio * 0.62} y={radio * 0.62} width={radio * 0.3} height={radio * 0.95} rx={radio * 0.12} transform={`rotate(-45 ${radio * 0.77} ${radio * 0.62})`} fill="#5A3A28" />
|
|
173
|
+
<circle r={radio} fill={fondo} />
|
|
174
|
+
<g clipPath={`url(#${id})`}>{children}</g>
|
|
175
|
+
<circle r={radio} fill="none" stroke="#E9EDF2" strokeWidth={radio * 0.11} />
|
|
176
|
+
<circle r={radio * 1.05} fill="none" stroke="rgba(0,0,0,0.25)" strokeWidth={4} />
|
|
177
|
+
<path d={`M ${-radio * 0.55} ${-radio * 0.35} A ${radio * 0.7} ${radio * 0.7} 0 0 1 ${-radio * 0.2} ${-radio * 0.62}`} stroke="rgba(255,255,255,0.7)" strokeWidth={10} fill="none" strokeLinecap="round" />
|
|
178
|
+
</g>
|
|
179
|
+
);
|
|
180
|
+
};
|