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/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
- fs.writeFileSync(job, JSON.stringify({...base, soloVoz: true}));
176
- python(job);
177
- if (conWhisper && !fs.existsSync(path.join(publico, 'v', id, 'whisper.json'))) {
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
- await transcribir(path.join(publico, 'v', id), (video.lang ?? 'es').slice(0, 2));
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 lista) {
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 lista.entries()) {
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.render.crf,
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}/${lista.length}] ${id}: video ${Math.round((n / paso) * 100)}%`);
258
- else process.stdout.write(`\r[${i + 1}/${lista.length}] ${id}: video ${n * 10}% `);
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}/${lista.length}] ${id}: listo en ${segundos} s → ${path.relative(p.proyecto, mp4)} `);
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
- export const whisperListo = () => fs.existsSync(path.join(WHISPER, `ggml-${MODELO_WHISPER}.bin`)) && fs.existsSync(path.join(WHISPER, '.listo'));
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
- await installWhisperCpp({to: WHISPER, version: VERSION_CPP, printOutput: true});
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
- await downloadWhisperModel({model: MODELO_WHISPER, folder: WHISPER, printOutput: true});
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.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
- """Capas de la escena (y de sus grupos) que piden un efecto de sonido."""
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
- if c.get("sonido"): out.append(c)
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 (o grupo) con "sonido" suena cuando entra
210
- for capa in capas_con_sonido(sp):
211
- cuando = capa.get("en", 0.3)
212
- tc = t0 + (ev(si, cuando) if isinstance(cuando, str) else float(cuando))
213
- add(sfx, efecto(capa["sonido"], rng), tc)
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]
@@ -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.lower().strip("¿?.,:¡!").startswith(prefix.lower()): return a - seg["t0"]
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
+ };