morfeo 0.0.0-stage → 0.1.0

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.
Files changed (51) hide show
  1. package/LICENSE +22 -0
  2. package/README.md +210 -2
  3. package/assets/fuentes/Anton.ttf +0 -0
  4. package/assets/fuentes/JetBrainsMono.ttf +0 -0
  5. package/assets/fuentes/Montserrat.ttf +0 -0
  6. package/bin/morfeo.mjs +184 -0
  7. package/bin/postinstall.mjs +10 -0
  8. package/index.d.ts +246 -0
  9. package/lib/config.mjs +241 -0
  10. package/lib/entorno.mjs +93 -0
  11. package/lib/guion.mjs +309 -0
  12. package/lib/imagenes.mjs +99 -0
  13. package/lib/index.mjs +15 -0
  14. package/lib/instalar.mjs +193 -0
  15. package/lib/navegador.mjs +64 -0
  16. package/lib/personajes.mjs +94 -0
  17. package/lib/render.mjs +278 -0
  18. package/lib/voces.mjs +122 -0
  19. package/lib/whisper.mjs +45 -0
  20. package/package.json +84 -5
  21. package/plantilla/assets/.gitkeep +0 -0
  22. package/plantilla/env.example +7 -0
  23. package/plantilla/escenas/Barras.tsx +15 -0
  24. package/plantilla/gitignore +4 -0
  25. package/plantilla/guiones/cachalote.mjs +24 -0
  26. package/plantilla/guiones/tutorial.mjs +16 -0
  27. package/plantilla/morfeo.config.mjs +43 -0
  28. package/plantilla/package.json +13 -0
  29. package/plantilla/videos.mjs +25 -0
  30. package/python/musica.py +221 -0
  31. package/python/preparar.py +268 -0
  32. package/python/requirements.txt +5 -0
  33. package/remotion/Root.tsx +35 -0
  34. package/remotion/Short.tsx +278 -0
  35. package/remotion/escenas.tsx +430 -0
  36. package/remotion/estilo.ts +73 -0
  37. package/remotion/iconos.ts +326 -0
  38. package/remotion/index.ts +4 -0
  39. package/remotion/motor/Video.tsx +119 -0
  40. package/remotion/motor/capas.tsx +547 -0
  41. package/remotion/motor/fondo.tsx +149 -0
  42. package/remotion/motor/modelo3d.tsx +252 -0
  43. package/remotion/motor/plantillas.tsx +123 -0
  44. package/remotion/motor/registro.ts +11 -0
  45. package/remotion/motor/subtitulos.tsx +137 -0
  46. package/remotion/motor/tema.ts +202 -0
  47. package/remotion/motor/util.ts +66 -0
  48. package/remotion/motor/widgets.tsx +248 -0
  49. package/remotion/piezas.tsx +181 -0
  50. package/remotion/tipos.ts +43 -0
  51. package/remotion/tres/Capa3D.tsx +153 -0
@@ -0,0 +1,64 @@
1
+ // Navegador de render compartido. Remotion descarga Chrome Headless Shell junto al package.json más
2
+ // cercano al directorio actual (o sea, una copia de ~140 MB por proyecto). Morfeo lo descarga una sola
3
+ // vez en ~/.morfeo/navegador y se lo pasa a cada render como browserExecutable.
4
+ import fs from 'node:fs';
5
+ import {createRequire} from 'node:module';
6
+ import path from 'node:path';
7
+ import {HOME} from './entorno.mjs';
8
+
9
+ const require = createRequire(import.meta.url);
10
+ const DIR = path.join(HOME, 'navegador');
11
+ const MARCA = path.join(DIR, '.version-remotion');
12
+ const versionRemotion = () => require('@remotion/renderer/package.json').version;
13
+
14
+ function buscarEjecutable(dir, profundidad = 0) {
15
+ if (profundidad > 5 || !fs.existsSync(dir)) return null;
16
+ for (const e of fs.readdirSync(dir, {withFileTypes: true})) {
17
+ const p = path.join(dir, e.name);
18
+ if (e.isFile() && /^chrome-headless-shell(\.exe)?$/.test(e.name)) return p;
19
+ if (e.isDirectory()) {
20
+ const r = buscarEjecutable(p, profundidad + 1);
21
+ if (r) return r;
22
+ }
23
+ }
24
+ return null;
25
+ }
26
+
27
+ const ejecutable = () => buscarEjecutable(path.join(DIR, 'node_modules', '.remotion'));
28
+
29
+ export const navegadorListo = () => {
30
+ try {
31
+ return !!ejecutable() && fs.readFileSync(MARCA, 'utf8') === versionRemotion();
32
+ } catch {
33
+ return false;
34
+ }
35
+ };
36
+
37
+ /** Devuelve la ruta del navegador compartido; lo descarga si falta o si cambió la versión de Remotion. */
38
+ export async function asegurarNavegador(log = console.log) {
39
+ if (navegadorListo()) return ejecutable();
40
+ fs.mkdirSync(DIR, {recursive: true});
41
+ const pkg = path.join(DIR, 'package.json');
42
+ if (!fs.existsSync(pkg)) fs.writeFileSync(pkg, '{"private": true}\n');
43
+ log('Descargando el navegador de render (~100 MB, una sola vez para todos tus proyectos)...');
44
+ const {ensureBrowser} = await import('@remotion/renderer');
45
+ const antes = process.cwd();
46
+ process.chdir(DIR); // Remotion lo guarda junto al package.json del directorio actual
47
+ try {
48
+ await ensureBrowser({
49
+ onBrowserDownload: () => ({
50
+ version: null,
51
+ onProgress: ({percent}) => {
52
+ if (process.stdout.isTTY) process.stdout.write(`\r navegador: ${Math.round(percent * 100)}% `);
53
+ },
54
+ }),
55
+ });
56
+ if (process.stdout.isTTY) process.stdout.write('\n');
57
+ } finally {
58
+ process.chdir(antes);
59
+ }
60
+ const exe = ejecutable();
61
+ if (!exe) throw new Error(`No encontré el navegador después de descargarlo en ${DIR}`);
62
+ fs.writeFileSync(MARCA, versionRemotion());
63
+ return exe;
64
+ }
@@ -0,0 +1,94 @@
1
+ // Personajes recurrentes (series): cada uno con sus poses, en archivos propios o generadas con IA
2
+ // a partir de una imagen de referencia (así siempre es el mismo personaje).
3
+ //
4
+ // personajes: {
5
+ // neto: {
6
+ // referencia: './personajes/neto.png', // opcional: base para generar poses con IA
7
+ // poses: {
8
+ // normal: './personajes/neto.png', // archivo propio
9
+ // feliz: 'con ojos de corazón, sonriendo', // sin archivo: se genera con IA desde la referencia
10
+ // triste: './personajes/neto-triste.png',
11
+ // },
12
+ // },
13
+ // }
14
+ //
15
+ // En una escena: {tipo: 'personaje', nombre: 'neto', pose: 'normal', poses: [{en: 'enamorado', pose: 'feliz'}], hablar: true}
16
+ import crypto from 'node:crypto';
17
+ import fs from 'node:fs';
18
+ import path from 'node:path';
19
+ import {ErrorMorfeo} from './config.mjs';
20
+
21
+ const huella = (x) => crypto.createHash('sha1').update(JSON.stringify(x)).digest('hex').slice(0, 20);
22
+ const esArchivo = (proyecto, v) => typeof v === 'string' && /\.(png|webp|jpe?g|svg|gif)$/i.test(v) && fs.existsSync(path.resolve(proyecto, v));
23
+
24
+ /** Genera una pose nueva editando la imagen de referencia (OpenAI gpt-image-1, endpoint de ediciones). */
25
+ async function poseOpenAI(referencia, descripcion, o) {
26
+ const clave = o.apiKey ?? process.env.OPENAI_API_KEY;
27
+ if (!clave) throw new ErrorMorfeo('Para generar poses con IA pon OPENAI_API_KEY en el .env (o usa tus propios PNG para cada pose).');
28
+ const form = new FormData();
29
+ form.append('model', o.modelo ?? 'gpt-image-1');
30
+ form.append('prompt', `El mismo personaje de la imagen, mismo estilo de dibujo, misma ropa y proporciones, cuerpo completo, ${descripcion}. Fondo transparente, sin texto.`);
31
+ form.append('background', 'transparent');
32
+ form.append('size', '1024x1536');
33
+ form.append('image[]', new Blob([fs.readFileSync(referencia)], {type: 'image/png'}), path.basename(referencia));
34
+ const res = await fetch('https://api.openai.com/v1/images/edits', {method: 'POST', headers: {authorization: `Bearer ${clave}`}, body: form});
35
+ if (!res.ok) throw new ErrorMorfeo(`OpenAI (poses) respondió ${res.status}: ${(await res.text()).slice(0, 300)}`);
36
+ const b64 = (await res.json()).data?.[0]?.b64_json;
37
+ if (!b64) throw new ErrorMorfeo('OpenAI no devolvió la pose.');
38
+ return Buffer.from(b64, 'base64');
39
+ }
40
+
41
+ /**
42
+ * Copia (o genera) las poses de cada personaje a public/assets/personajes/<nombre>/ y devuelve
43
+ * {nombre: {pose: 'personajes/nombre/pose.png'}} para que Remotion las encuentre.
44
+ */
45
+ export async function prepararPersonajes(p, assetsPublico, log = console.log) {
46
+ const out = {};
47
+ for (const [nombre, def] of Object.entries(p.personajes ?? {})) {
48
+ const poses = def.poses ?? {};
49
+ const ref = def.referencia ? path.resolve(p.proyecto, def.referencia) : null;
50
+ if (ref && !fs.existsSync(ref)) throw new ErrorMorfeo(`Personaje ${nombre}: no existe la referencia ${def.referencia}`);
51
+ out[nombre] = {};
52
+ const dir = path.join(assetsPublico, 'personajes', nombre);
53
+ fs.mkdirSync(dir, {recursive: true});
54
+ for (const [pose, v] of Object.entries(poses)) {
55
+ let origen;
56
+ if (esArchivo(p.proyecto, v)) origen = path.resolve(p.proyecto, v);
57
+ else {
58
+ if (!ref) throw new ErrorMorfeo(`Personaje ${nombre}, pose "${pose}": "${v}" no es un archivo. Para generarla con IA agrega referencia: './personaje.png'`);
59
+ const cache = path.join(p.trabajo, 'cache', 'personajes');
60
+ fs.mkdirSync(cache, {recursive: true});
61
+ origen = path.join(cache, `${huella([nombre, fs.statSync(ref).size, fs.statSync(ref).mtimeMs, v])}.png`);
62
+ if (!fs.existsSync(origen)) {
63
+ log(` personaje ${nombre}: generando pose "${pose}"...`);
64
+ const fn = typeof p.imagenes?.proveedorPoses === 'function' ? p.imagenes.proveedorPoses : poseOpenAI;
65
+ const r = await fn(ref, String(v), p.imagenes ?? {});
66
+ if (typeof r === 'string') fs.copyFileSync(r, origen);
67
+ else fs.writeFileSync(origen, Buffer.from(r));
68
+ }
69
+ }
70
+ const ext = path.extname(origen).toLowerCase() || '.png';
71
+ fs.copyFileSync(origen, path.join(dir, `${pose}${ext}`));
72
+ out[nombre][pose] = `personajes/${nombre}/${pose}${ext}`;
73
+ }
74
+ if (!Object.keys(out[nombre]).length) throw new ErrorMorfeo(`Personaje ${nombre}: no tiene poses.`);
75
+ }
76
+ return out;
77
+ }
78
+
79
+ /** Pone en cada capa {tipo: 'personaje'} el mapa de sus poses (escenas y HUD). */
80
+ export function ponerPoses(video, mapa) {
81
+ const recorrer = (x) => {
82
+ if (Array.isArray(x)) return x.map(recorrer);
83
+ if (!x || typeof x !== 'object') return x;
84
+ const o = {};
85
+ for (const [k, v] of Object.entries(x)) o[k] = recorrer(v);
86
+ if (o.tipo === 'personaje') {
87
+ const poses = mapa[o.nombre];
88
+ if (!poses) throw new ErrorMorfeo(`No existe el personaje "${o.nombre}". Defínelo en morfeo.config.mjs → personajes. Hay: ${Object.keys(mapa).join(', ') || 'ninguno'}`);
89
+ o.__poses = poses;
90
+ }
91
+ return o;
92
+ };
93
+ return {...video, scenes: recorrer(video.scenes), hud: recorrer(video.hud ?? [])};
94
+ }
package/lib/render.mjs ADDED
@@ -0,0 +1,278 @@
1
+ // Render: audio (Python) -> datos -> video (Remotion). También lista y Studio.
2
+ import {spawn, spawnSync} from 'node:child_process';
3
+ import crypto from 'node:crypto';
4
+ import fs from 'node:fs';
5
+ import {createRequire} from 'node:module';
6
+ import path from 'node:path';
7
+ import {cargarProyecto, ErrorMorfeo} from './config.mjs';
8
+ import {ENTRADA_REMOTION, FUENTES, MODELOS, PY_DIR, modelosListos, pythonVenv, venvListo} from './entorno.mjs';
9
+ import {asegurarNavegador} from './navegador.mjs';
10
+ import {generarImagenes} from './imagenes.mjs';
11
+ import {ponerPoses, prepararPersonajes} from './personajes.mjs';
12
+ import {sintetizarVoces} from './voces.mjs';
13
+ import {transcribir, whisperListo} from './whisper.mjs';
14
+
15
+ const require = createRequire(import.meta.url);
16
+
17
+ /** Carga variables de <proyecto>/.env (sin pisar las que ya existen). */
18
+ export function cargarEnv(proyecto) {
19
+ const f = path.join(proyecto, '.env');
20
+ if (!fs.existsSync(f)) return;
21
+ if (typeof process.loadEnvFile === 'function') {
22
+ const antes = {...process.env};
23
+ process.loadEnvFile(f);
24
+ for (const [k, v] of Object.entries(antes)) process.env[k] = v;
25
+ return;
26
+ }
27
+ for (const linea of fs.readFileSync(f, 'utf8').split(/\r?\n/)) {
28
+ const m = linea.match(/^\s*(?:export\s+)?([A-Za-z_][A-Za-z0-9_]*)\s*=\s*(.*)\s*$/);
29
+ if (m && process.env[m[1]] == null) process.env[m[1]] = m[2].replace(/^(['"])(.*)\1$/, '$2');
30
+ }
31
+ }
32
+
33
+ const copiarCarpeta = (de, a) => {
34
+ for (const e of fs.readdirSync(de, {withFileTypes: true})) {
35
+ if (e.name.startsWith('.')) continue;
36
+ const o = path.join(de, e.name);
37
+ const d = path.join(a, e.name);
38
+ if (e.isDirectory()) copiarCarpeta(o, d);
39
+ else copiarSiCambia(o, d);
40
+ }
41
+ };
42
+
43
+ const copiarSiCambia = (de, a) => {
44
+ if (fs.existsSync(a) && fs.statSync(a).size === fs.statSync(de).size && fs.statSync(a).mtimeMs >= fs.statSync(de).mtimeMs) return;
45
+ fs.mkdirSync(path.dirname(a), {recursive: true});
46
+ fs.copyFileSync(de, a);
47
+ };
48
+
49
+ /**
50
+ * Huella del código de animación (tamaño y fecha de cada archivo de remotion/). Webpack trata
51
+ * node_modules como inmutable por versión: sin esto, un Morfeo actualizado podía usar un bundle viejo.
52
+ */
53
+ function huellaMotor() {
54
+ const partes = [];
55
+ const recorrer = (dir) => {
56
+ for (const e of fs.readdirSync(dir, {withFileTypes: true}).sort((a, b) => a.name.localeCompare(b.name))) {
57
+ const p = path.join(dir, e.name);
58
+ if (e.isDirectory()) recorrer(p);
59
+ else {
60
+ const st = fs.statSync(p);
61
+ partes.push(`${e.name}:${st.size}:${Math.round(st.mtimeMs)}`);
62
+ }
63
+ }
64
+ };
65
+ recorrer(path.dirname(ENTRADA_REMOTION));
66
+ return crypto.createHash('sha1').update(partes.join('|')).digest('hex').slice(0, 16);
67
+ }
68
+
69
+ // Remotion calcula la clave del caché a partir de la configuración: meter la huella ahí la invalida
70
+ const conHuella = (config) => ({...config, name: `morfeo-${huellaMotor()}`});
71
+
72
+ /**
73
+ * Entrada de Remotion. Sin componentes propios es la del paquete; con ellos, Morfeo escribe
74
+ * .morfeo/entrada.tsx que los importa y los registra antes de arrancar.
75
+ */
76
+ function entradaDe(p) {
77
+ const nombres = Object.keys(p.componentes);
78
+ if (!nombres.length) return ENTRADA_REMOTION;
79
+ const archivo = path.join(p.trabajo, 'entrada.tsx');
80
+ const rel = (abs) => {
81
+ const r = path.relative(p.trabajo, abs).split(path.sep).join('/');
82
+ return r.startsWith('.') ? r : `./${r}`;
83
+ };
84
+ const motor = (f) => rel(path.join(path.dirname(ENTRADA_REMOTION), f)).replace(/\.tsx?$/, '');
85
+ const codigo = [
86
+ '// Generado por morfeo: registra tus componentes y arranca la composición. No lo edites.',
87
+ "import {registerRoot} from 'remotion';",
88
+ `import {Root} from '${motor('Root.tsx')}';`,
89
+ `import {registrarComponentes} from '${motor('motor/registro.ts')}';`,
90
+ ...nombres.map((n, i) => `import C${i} from '${rel(p.componentes[n]).replace(/\.tsx?$/, '')}';`),
91
+ `registrarComponentes({${nombres.map((n, i) => `${n}: C${i}`).join(', ')}});`,
92
+ 'registerRoot(Root);',
93
+ '',
94
+ ].join('\n');
95
+ fs.mkdirSync(p.trabajo, {recursive: true});
96
+ if (!fs.existsSync(archivo) || fs.readFileSync(archivo, 'utf8') !== codigo) fs.writeFileSync(archivo, codigo);
97
+ return archivo;
98
+ }
99
+
100
+ /** Arma la carpeta public que ve Remotion: fuentes del paquete + logo de la marca + audios generados. */
101
+ function prepararPublico(p) {
102
+ const publico = path.join(p.trabajo, 'public');
103
+ for (const f of fs.readdirSync(FUENTES)) copiarSiCambia(path.join(FUENTES, f), path.join(publico, 'fuentes', f));
104
+ let logo = null;
105
+ if (p.marca.logo) {
106
+ logo = `marca/logo${path.extname(p.marca.logo).toLowerCase()}`;
107
+ copiarSiCambia(p.marca.logo, path.join(publico, logo));
108
+ }
109
+ // assets/ del proyecto (imágenes, videos, modelos) -> public/assets
110
+ if (fs.existsSync(p.assets)) copiarCarpeta(p.assets, path.join(publico, 'assets'));
111
+ // fuentes propias -> public/fuentes-proyecto
112
+ const fuentes = p.fuentes.map(({familia, archivo}) => {
113
+ const url = `fuentes-proyecto/${path.basename(archivo)}`;
114
+ copiarSiCambia(archivo, path.join(publico, url));
115
+ return {familia, url};
116
+ });
117
+ const ignorar = path.join(p.trabajo, '.gitignore');
118
+ if (!fs.existsSync(ignorar)) fs.writeFileSync(ignorar, '*\n');
119
+ return {publico, marca: {...p.marca, logo, cta: undefined}, extras: {fuentes, estilos: p.estilos}};
120
+ }
121
+
122
+ const datosDe = (publico, id) => {
123
+ const f = path.join(publico, 'v', id, 'datos.json');
124
+ return fs.existsSync(f) ? JSON.parse(fs.readFileSync(f, 'utf8')) : null;
125
+ };
126
+
127
+ const salidaDe = (p, id) => path.join(p.salida, `${id}.mp4`);
128
+
129
+ /** [{id, titulo, hecho}] en el orden del proyecto. */
130
+ export async function lista({proyecto = process.cwd()} = {}) {
131
+ cargarEnv(proyecto);
132
+ const p = await cargarProyecto(proyecto);
133
+ return Object.entries(p.videos).map(([id, v]) => ({id, titulo: v.title ?? id, hecho: fs.existsSync(salidaDe(p, id))}));
134
+ }
135
+
136
+ /** Corre python/preparar.py con el trabajo indicado; su salida va directo a la terminal. */
137
+ function python(trabajo) {
138
+ const r = spawnSync(pythonVenv(), [path.join(PY_DIR, 'preparar.py'), trabajo], {
139
+ stdio: 'inherit',
140
+ env: {...process.env, PYTHONUTF8: '1', PYTHONIOENCODING: 'utf-8'},
141
+ });
142
+ if (r.status !== 0) throw new ErrorMorfeo(`Falló la preparación del audio (código ${r.status}). Revisa el mensaje de arriba.`);
143
+ }
144
+
145
+ /**
146
+ * Prepara (audio + tiempos) los videos indicados. Devuelve {publico, marca, datos: {id: datos}}.
147
+ * @param {{proyecto?: string, ids?: string[], log?: (m: string) => void}} o
148
+ */
149
+ export async function preparar({proyecto = process.cwd(), ids, log = console.log, _p} = {}) {
150
+ cargarEnv(proyecto);
151
+ const p = _p ?? (await cargarProyecto(proyecto));
152
+ if (!venvListo() || !modelosListos()) throw new ErrorMorfeo('Falta preparar tu computadora. Corre una vez: npx morfeo instalar');
153
+ const {publico, marca, extras} = prepararPublico(p);
154
+ const todos = Object.keys(p.videos);
155
+ const conWhisper = whisperListo();
156
+ const datos = {};
157
+ const poses = await prepararPersonajes(p, path.join(publico, 'assets'), log);
158
+ const trabajos = path.join(p.trabajo, 'trabajos');
159
+ fs.mkdirSync(trabajos, {recursive: true});
160
+ for (const [i, id] of (ids ?? todos).entries()) {
161
+ const original = p.videos[id];
162
+ if (!original) throw new ErrorMorfeo(`No existe el video "${id}". Usa: npx morfeo lista`);
163
+ log(`[${i + 1}/${(ids ?? todos).length}] ${id}: audio...`);
164
+ const conImagenes = await generarImagenes(ponerPoses(original, poses), p.imagenes, path.join(p.trabajo, 'cache', 'imagenes'), path.join(publico, 'assets'), log);
165
+ const video = await sintetizarVoces(id, conImagenes, path.join(p.trabajo, 'cache', 'voces'), log);
166
+ const job = path.join(trabajos, `${id}.json`);
167
+ const base = {id, video, indice: todos.indexOf(id), publico, modelos: MODELOS};
168
+ fs.writeFileSync(job, JSON.stringify({...base, soloVoz: true}));
169
+ python(job);
170
+ if (conWhisper && !fs.existsSync(path.join(publico, 'v', id, 'whisper.json'))) {
171
+ log(' whisper...');
172
+ await transcribir(path.join(publico, 'v', id), (video.lang ?? 'es').slice(0, 2));
173
+ }
174
+ fs.writeFileSync(job, JSON.stringify(base));
175
+ python(job);
176
+ datos[id] = datosDe(publico, id);
177
+ }
178
+ return {p, publico, marca, extras, datos};
179
+ }
180
+
181
+ /**
182
+ * Genera los videos. Sin ids: los que aún no existen en la carpeta de salida.
183
+ * @param {{proyecto?: string, ids?: string[], todos?: boolean, log?: (m: string) => void, alProgreso?: (id: string, p: number) => void}} o
184
+ * @returns {Promise<{id: string, mp4: string, jpg: string | null, segundos: number}[]>}
185
+ */
186
+ export async function render({proyecto = process.cwd(), ids, todos = false, log = console.log, alProgreso} = {}) {
187
+ cargarEnv(proyecto);
188
+ const p = await cargarProyecto(proyecto);
189
+ let lista = ids?.length ? ids : todos ? Object.keys(p.videos) : Object.keys(p.videos).filter((id) => !fs.existsSync(salidaDe(p, id)));
190
+ const malos = lista.filter((id) => !p.videos[id]);
191
+ if (malos.length) throw new ErrorMorfeo(`No existen: ${malos.join(', ')}. Usa: npx morfeo lista`);
192
+ if (!lista.length) {
193
+ log('No hay videos pendientes. Para regenerar: npx morfeo render --todos (o npx morfeo render <id>)');
194
+ return [];
195
+ }
196
+ if (!whisperListo()) log('(Whisper no está instalado: subtítulos repartidos por frase. Para tiempos exactos: npx morfeo instalar --whisper)');
197
+ const {publico, marca, extras, datos} = await preparar({proyecto, ids: lista, log, _p: p});
198
+
199
+ log('Empaquetando la animación...');
200
+ const {bundle} = await import('@remotion/bundler');
201
+ const {renderMedia, renderStill, selectComposition} = await import('@remotion/renderer');
202
+ const entrada = entradaDe(p);
203
+ const serveUrl = await bundle({entryPoint: entrada, publicDir: publico, rootDir: entrada === ENTRADA_REMOTION ? path.dirname(ENTRADA_REMOTION) : p.proyecto, webpackOverride: conHuella});
204
+ fs.mkdirSync(p.salida, {recursive: true});
205
+ const chromiumOptions = {gl: p.render.gl};
206
+ const browserExecutable = p.render.navegador ?? (await asegurarNavegador(log));
207
+ const hechos = [];
208
+ for (const [i, id] of lista.entries()) {
209
+ const t = Date.now();
210
+ const inputProps = {videos: {[id]: datos[id]}, marca, ...extras};
211
+ const composition = await selectComposition({serveUrl, id, inputProps, browserExecutable, chromiumOptions});
212
+ let ultimo = -1;
213
+ const mp4 = salidaDe(p, id);
214
+ await renderMedia({
215
+ serveUrl,
216
+ composition,
217
+ inputProps,
218
+ browserExecutable,
219
+ chromiumOptions,
220
+ codec: 'h264',
221
+ crf: p.render.crf,
222
+ audioBitrate: '192k',
223
+ imageFormat: 'jpeg',
224
+ jpegQuality: 92,
225
+ concurrency: p.render.concurrencia,
226
+ outputLocation: mp4,
227
+ onProgress: ({progress}) => {
228
+ alProgreso?.(id, progress);
229
+ const n = Math.floor(progress * 10);
230
+ if (n !== ultimo && process.stdout.isTTY) process.stdout.write(`\r[${i + 1}/${lista.length}] ${id}: video ${n * 10}% `);
231
+ ultimo = n;
232
+ },
233
+ });
234
+ let jpg = null;
235
+ if (p.render.miniatura) {
236
+ // miniatura: el gancho ya completo
237
+ jpg = path.join(p.salida, `${id}.jpg`);
238
+ await renderStill({serveUrl, composition, inputProps, browserExecutable, chromiumOptions, frame: Math.min(36, composition.durationInFrames - 1), imageFormat: 'jpeg', jpegQuality: 92, output: jpg});
239
+ }
240
+ const segundos = Math.round((Date.now() - t) / 1000);
241
+ if (process.stdout.isTTY) process.stdout.write('\r');
242
+ log(`[${i + 1}/${lista.length}] ${id}: listo en ${segundos} s → ${path.relative(p.proyecto, mp4)} `);
243
+ hechos.push({id, mp4, jpg, segundos});
244
+ }
245
+ escribirResumen(p);
246
+ return hechos;
247
+ }
248
+
249
+ /** <salida>/resumen.json: título, archivo y duración de cada video hecho (útil para publicar). */
250
+ function escribirResumen(p) {
251
+ const publico = path.join(p.trabajo, 'public');
252
+ const filas = Object.entries(p.videos)
253
+ .filter(([id]) => fs.existsSync(salidaDe(p, id)))
254
+ .map(([id, v]) => ({id, titulo: v.title ?? id, archivo: `${id}.mp4`, duracion: datosDe(publico, id)?.duracion ?? null, ...(v.meta ? {meta: v.meta} : {})}));
255
+ fs.writeFileSync(path.join(p.salida, 'resumen.json'), JSON.stringify(filas, null, '\t') + '\n');
256
+ }
257
+
258
+ /** Abre Remotion Studio con los videos ya preparados (para verlos y ajustar en vivo). */
259
+ export async function studio({proyecto = process.cwd(), puerto} = {}) {
260
+ cargarEnv(proyecto);
261
+ const p = await cargarProyecto(proyecto);
262
+ const {publico, marca, extras} = prepararPublico(p);
263
+ const videos = {};
264
+ for (const id of Object.keys(p.videos)) {
265
+ const d = datosDe(publico, id);
266
+ if (d) videos[id] = d;
267
+ }
268
+ if (!Object.keys(videos).length) console.log('Todavía no hay videos preparados. Corre primero: npx morfeo render');
269
+ const props = path.join(p.trabajo, 'studio-props.json');
270
+ fs.writeFileSync(props, JSON.stringify({videos, marca, ...extras}));
271
+ const cli = path.join(path.dirname(require.resolve('@remotion/cli/package.json')), 'remotion-cli.js');
272
+ const navegador = p.render.navegador ?? (await asegurarNavegador());
273
+ const args = [cli, 'studio', entradaDe(p), `--public-dir=${publico}`, `--props=${props}`, `--gl=${p.render.gl}`, `--browser-executable=${navegador}`];
274
+ if (puerto) args.push(`--port=${puerto}`);
275
+ const hijo = spawn(process.execPath, args, {stdio: 'inherit', cwd: p.proyecto});
276
+ await new Promise((r) => hijo.on('exit', r));
277
+ }
278
+
package/lib/voces.mjs ADDED
@@ -0,0 +1,122 @@
1
+ // Voces por servicio externo (ElevenLabs, OpenAI o una función tuya).
2
+ // Cada frase se sintetiza una sola vez: el audio queda en .morfeo/cache/voces/<huella>.<ext>
3
+ // y si el texto, la voz y las opciones no cambian, no se vuelve a pedir (ni a cobrar).
4
+ // Las claves se leen del entorno (.env del proyecto), nunca del código.
5
+ import crypto from 'node:crypto';
6
+ import fs from 'node:fs';
7
+ import path from 'node:path';
8
+ import {ErrorMorfeo} from './config.mjs';
9
+
10
+ const huella = (obj) => crypto.createHash('sha1').update(JSON.stringify(obj)).digest('hex').slice(0, 20);
11
+
12
+ const clave = (nombre, opciones) => {
13
+ const v = opciones.apiKey ?? process.env[nombre];
14
+ if (!v) throw new ErrorMorfeo(`Falta ${nombre}. Ponla en el archivo .env de tu proyecto: ${nombre}=...`);
15
+ return v;
16
+ };
17
+
18
+ async function pedir(url, init, servicio) {
19
+ let res;
20
+ try {
21
+ res = await fetch(url, init);
22
+ } catch (e) {
23
+ throw new ErrorMorfeo(`No pude conectar con ${servicio}: ${e.message}`);
24
+ }
25
+ if (!res.ok) {
26
+ let detalle = '';
27
+ try {
28
+ detalle = (await res.text()).slice(0, 300);
29
+ } catch {}
30
+ const pista = res.status === 401 ? ' (revisa tu clave)' : res.status === 429 ? ' (límite o saldo agotado)' : '';
31
+ throw new ErrorMorfeo(`${servicio} respondió ${res.status}${pista}: ${detalle}`);
32
+ }
33
+ return Buffer.from(await res.arrayBuffer());
34
+ }
35
+
36
+ const PROVEEDORES = {
37
+ // https://elevenlabs.io/docs/api-reference/text-to-speech/convert
38
+ async elevenlabs(texto, o) {
39
+ const voz = o.voz ?? o.voice ?? o.voiceId;
40
+ if (!voz) throw new ErrorMorfeo('ElevenLabs: falta `voz` (el voice_id de tu voz en ElevenLabs)');
41
+ const datos = await pedir(
42
+ `https://api.elevenlabs.io/v1/text-to-speech/${encodeURIComponent(voz)}?output_format=mp3_44100_128`,
43
+ {
44
+ method: 'POST',
45
+ headers: {'xi-api-key': clave('ELEVENLABS_API_KEY', o), 'content-type': 'application/json', accept: 'audio/mpeg'},
46
+ body: JSON.stringify({
47
+ text: texto,
48
+ model_id: o.modelo ?? o.model ?? 'eleven_multilingual_v2',
49
+ ...(o.idioma ? {language_code: o.idioma} : {}),
50
+ ...(o.ajustes ? {voice_settings: o.ajustes} : {}),
51
+ }),
52
+ },
53
+ 'ElevenLabs',
54
+ );
55
+ return {datos, ext: 'mp3'};
56
+ },
57
+ // https://platform.openai.com/docs/api-reference/audio/createSpeech
58
+ async openai(texto, o) {
59
+ const datos = await pedir(
60
+ 'https://api.openai.com/v1/audio/speech',
61
+ {
62
+ method: 'POST',
63
+ headers: {authorization: `Bearer ${clave('OPENAI_API_KEY', o)}`, 'content-type': 'application/json'},
64
+ body: JSON.stringify({
65
+ model: o.modelo ?? o.model ?? 'gpt-4o-mini-tts',
66
+ voice: o.voz ?? o.voice ?? 'alloy',
67
+ input: texto,
68
+ response_format: 'wav',
69
+ ...(o.instrucciones ? {instructions: o.instrucciones} : {}),
70
+ ...(o.velocidad ? {speed: o.velocidad} : {}),
71
+ }),
72
+ },
73
+ 'OpenAI',
74
+ );
75
+ return {datos, ext: 'wav'};
76
+ },
77
+ };
78
+
79
+ /** Normaliza lo que devuelve una función de voz propia: ruta de archivo, Buffer, o {datos, ext}. */
80
+ function resultadoPropio(r) {
81
+ if (typeof r === 'string') {
82
+ if (!fs.existsSync(r)) throw new ErrorMorfeo(`Tu función de voz devolvió una ruta que no existe: ${r}`);
83
+ return {datos: fs.readFileSync(r), ext: path.extname(r).slice(1) || 'wav'};
84
+ }
85
+ if (Buffer.isBuffer(r) || r instanceof Uint8Array) return {datos: Buffer.from(r), ext: 'wav'};
86
+ if (r && (Buffer.isBuffer(r.datos) || r.datos instanceof Uint8Array)) return {datos: Buffer.from(r.datos), ext: r.ext ?? 'wav'};
87
+ throw new ErrorMorfeo('Tu función de voz debe devolver una ruta de archivo, un Buffer, o {datos: Buffer, ext: "mp3"}');
88
+ }
89
+
90
+ /**
91
+ * Si el video usa un proveedor externo, sintetiza cada escena sin `audio` y le pone su grabación.
92
+ * Devuelve una copia del video lista para preparar.py (voice: null cuando ya todo trae audio).
93
+ */
94
+ export async function sintetizarVoces(id, video, cacheDir, log = console.log) {
95
+ const voz = video.voice;
96
+ if (!voz || Array.isArray(voz)) return video;
97
+ const {proveedor, ...opciones} = voz;
98
+ const fn = typeof proveedor === 'function' ? async (t, o) => resultadoPropio(await proveedor(t, o)) : PROVEEDORES[proveedor];
99
+ fs.mkdirSync(cacheDir, {recursive: true});
100
+ const nombre = typeof proveedor === 'function' ? proveedor.name || 'propia' : proveedor;
101
+ const {apiKey, ...publicas} = opciones; // la clave no entra en la huella del caché
102
+ let nuevas = 0;
103
+ const scenes = [];
104
+ for (const sc of video.scenes) {
105
+ if (sc.audio) {
106
+ scenes.push(sc);
107
+ continue;
108
+ }
109
+ const h = huella([nombre, publicas, sc.say]);
110
+ const previo = fs.readdirSync(cacheDir).find((f) => f.startsWith(h + '.'));
111
+ let archivo = previo && path.join(cacheDir, previo);
112
+ if (!archivo) {
113
+ const {datos, ext} = await fn(sc.say, publicas.apiKey ? opciones : {...publicas, apiKey});
114
+ archivo = path.join(cacheDir, `${h}.${ext}`);
115
+ fs.writeFileSync(archivo, datos);
116
+ nuevas++;
117
+ }
118
+ scenes.push({...sc, audio: archivo});
119
+ }
120
+ if (nuevas) log(` voz ${nombre}: ${nuevas} frase(s) nuevas`);
121
+ return {...video, voice: null, scenes};
122
+ }
@@ -0,0 +1,45 @@
1
+ // Whisper (opcional): tiempos reales de cada palabra para los subtítulos.
2
+ import {downloadWhisperModel, installWhisperCpp, toCaptions, transcribe} from '@remotion/install-whisper-cpp';
3
+ import {execFileSync} from 'node:child_process';
4
+ import fs from 'node:fs';
5
+ import path from 'node:path';
6
+ import {WHISPER, pythonVenv} from './entorno.mjs';
7
+
8
+ const VERSION_CPP = '1.7.6';
9
+ export const MODELO_WHISPER = 'small';
10
+
11
+ export const whisperListo = () => fs.existsSync(path.join(WHISPER, `ggml-${MODELO_WHISPER}.bin`)) && fs.existsSync(path.join(WHISPER, '.listo'));
12
+
13
+ export async function instalarWhisper() {
14
+ await installWhisperCpp({to: WHISPER, version: VERSION_CPP, printOutput: true});
15
+ await downloadWhisperModel({model: MODELO_WHISPER, folder: WHISPER, printOutput: true});
16
+ fs.writeFileSync(path.join(WHISPER, '.listo'), VERSION_CPP);
17
+ }
18
+
19
+ /** Transcribe <dir>/voz.wav y escribe <dir>/whisper.json (palabras con tiempos). */
20
+ export async function transcribir(dir, idioma = 'es') {
21
+ const wav16 = path.join(dir, 'voz16k.wav');
22
+ // whisper.cpp pide WAV 16 kHz mono 16 bits: lo convierte el Python del entorno (ya trae scipy y soundfile)
23
+ execFileSync(
24
+ pythonVenv(),
25
+ [
26
+ '-c',
27
+ 'import sys,soundfile as sf;from scipy.signal import resample_poly;x,sr=sf.read(sys.argv[1]);x=x.mean(1) if x.ndim>1 else x;sf.write(sys.argv[2],resample_poly(x,16000,sr),16000,subtype="PCM_16")',
28
+ path.join(dir, 'voz.wav'),
29
+ wav16,
30
+ ],
31
+ {stdio: 'inherit'},
32
+ );
33
+ const res = await transcribe({
34
+ inputPath: wav16,
35
+ whisperPath: WHISPER,
36
+ whisperCppVersion: VERSION_CPP,
37
+ model: MODELO_WHISPER,
38
+ language: idioma,
39
+ tokenLevelTimestamps: true,
40
+ printOutput: false,
41
+ });
42
+ const {captions} = toCaptions({whisperCppOutput: res});
43
+ fs.writeFileSync(path.join(dir, 'whisper.json'), JSON.stringify(captions.map(({text, startMs, endMs}) => ({text, startMs, endMs}))));
44
+ fs.rmSync(wav16, {force: true});
45
+ }