morfeo 0.4.0 → 0.5.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.
- package/bin/morfeo.mjs +23 -4
- package/index.d.ts +9 -2
- package/lib/calidad.mjs +74 -0
- package/lib/config.mjs +23 -3
- package/lib/estilo-pagina.mjs +174 -0
- package/lib/estilo-propio.mjs +70 -0
- package/lib/estructura.mjs +3 -0
- package/lib/guion.mjs +64 -4
- package/lib/hoja.mjs +17 -2
- package/lib/palabras.mjs +1 -1
- package/lib/render.mjs +33 -4
- package/package.json +1 -1
- package/plantilla/AGENTS.md +54 -0
- package/plantilla/CLAUDE.md +5 -0
- package/python/musica.py +87 -1
- package/remotion/Short.tsx +10 -2
- package/remotion/motor/Video.tsx +5 -5
- package/remotion/motor/historias.tsx +46 -3
- package/remotion/motor/portada.tsx +44 -0
- package/remotion/piezas.tsx +3 -3
- package/remotion/tipos.ts +3 -0
package/bin/morfeo.mjs
CHANGED
|
@@ -22,8 +22,10 @@ Videos
|
|
|
22
22
|
--todos regenera todos
|
|
23
23
|
--solo-fallidos reintenta solo los que fallaron en el último lote
|
|
24
24
|
--gpu usa la tarjeta de video (más rápido; si falla, quítalo)
|
|
25
|
-
|
|
25
|
+
previa <id> Solo el gancho (~8 s) para aprobarlo antes del render completo: --hasta 6
|
|
26
|
+
revisar [id...] Revisa la estructura (gancho, ritmo, preguntas, cierre), que la voz diga el guion y la zona segura
|
|
26
27
|
hoja <id> Hoja de contacto (PNG con 15 cuadros) para revisar un video de un vistazo
|
|
28
|
+
--zona pinta en rojo lo que tapan los botones de TikTok, Reels y Shorts
|
|
27
29
|
--desde 8 --hasta 11 solo ese tramo (segundos) --cols 5 --filas 3
|
|
28
30
|
studio Abre el editor en el navegador para ver y ajustar en vivo
|
|
29
31
|
estilos Lista los estilos visuales disponibles
|
|
@@ -34,7 +36,9 @@ Guiones
|
|
|
34
36
|
guion "tema" La IA (Claude) escribe un video completo sobre el tema
|
|
35
37
|
guion --desde f.txt Convierte tu guion en escenas (con IA; --sin-ia: una escena por frase)
|
|
36
38
|
--formula <id> una fórmula de historia (construiste, serie, mito, misterio…)
|
|
39
|
+
--investigar busca cada dato en la web con su fuente antes de escribir (van al texto del post)
|
|
37
40
|
formulas Las fórmulas de historia medidas en videos de referencia
|
|
41
|
+
estilo Abre una página para crear tu estilo: nombre, siglas, colores, letra, voz, ritmo y cierre
|
|
38
42
|
--estilo documental estilo visual (por defecto, el del proyecto)
|
|
39
43
|
--imagenes pide ilustraciones con IA (necesita OPENAI_API_KEY)
|
|
40
44
|
--id nombre --voz em_alex --duracion 40 --idioma "inglés" --tono "humor"
|
|
@@ -113,6 +117,14 @@ Pruébalo: npx morfeo render ${pos[0]} y revísalo: npx morfeo hoja ${pos[0]
|
|
|
113
117
|
await render({proyecto, ids: pos, todos: !!op.todos || pos[0] === 'todos', soloFallidos: !!op.soloFallidos});
|
|
114
118
|
return;
|
|
115
119
|
}
|
|
120
|
+
case 'previa': {
|
|
121
|
+
if (op.gpu) process.env.MORFEO_GL = 'angle';
|
|
122
|
+
const {render} = await import('../lib/render.mjs');
|
|
123
|
+
const seg = op.hasta != null && op.hasta !== true ? Number(op.hasta) : true;
|
|
124
|
+
await render({proyecto, ids: pos, previa: seg});
|
|
125
|
+
console.log('Si el gancho te gusta: npx morfeo render ' + pos.join(' '));
|
|
126
|
+
return;
|
|
127
|
+
}
|
|
116
128
|
case 'revisar': {
|
|
117
129
|
const {cargarProyecto} = await import('../lib/config.mjs');
|
|
118
130
|
const {revisarEstructura, consejosEnTexto, REGLAS} = await import('../lib/estructura.mjs');
|
|
@@ -142,7 +154,7 @@ Pruébalo: npx morfeo render ${pos[0]} y revísalo: npx morfeo hoja ${pos[0]
|
|
|
142
154
|
const {salidaDe, ordenarSalida} = await import('../lib/salida.mjs');
|
|
143
155
|
ordenarSalida(p);
|
|
144
156
|
const video = /\.(mp4|mov|webm)$/i.test(id) ? path.resolve(id) : salidaDe(p, id);
|
|
145
|
-
console.log(hoja({video, cols: num(op.cols), filas: num(op.filas), desde: num(op.desde), hasta: num(op.hasta)}));
|
|
157
|
+
console.log(hoja({video, cols: num(op.cols), filas: num(op.filas), desde: num(op.desde), hasta: num(op.hasta), zona: !!op.zona}));
|
|
146
158
|
}
|
|
147
159
|
return;
|
|
148
160
|
}
|
|
@@ -192,6 +204,13 @@ Pruébalo: npx morfeo render ${pos[0]} y revísalo: npx morfeo hoja ${pos[0]
|
|
|
192
204
|
console.log('\nÚsalo con estilo: "documental" en el video o en morfeo.config.mjs. Crea los tuyos con estilos: {...} en el config.');
|
|
193
205
|
return;
|
|
194
206
|
}
|
|
207
|
+
case 'estilo': {
|
|
208
|
+
const {abrirEditorDeEstilo} = await import('../lib/estilo-pagina.mjs');
|
|
209
|
+
const {buscarConfig} = await import('../lib/config.mjs');
|
|
210
|
+
if (!buscarConfig(proyecto)) throw new ErrorMorfeo('Corre esto dentro de un proyecto de Morfeo (npx morfeo nuevo mi-canal).');
|
|
211
|
+
await abrirEditorDeEstilo({proyecto, puerto: op.puerto ? Number(op.puerto) : 4321, abrir: !op.sinAbrir});
|
|
212
|
+
return;
|
|
213
|
+
}
|
|
195
214
|
case 'formulas':
|
|
196
215
|
case 'formula': {
|
|
197
216
|
const {FORMULAS} = await import('../lib/formulas.mjs');
|
|
@@ -233,7 +252,7 @@ async function guion(pos, op, proyecto) {
|
|
|
233
252
|
if (texto && op.sinIa) r = guionSinIA(texto, {id: op.id, voz: op.voz, musica: op.musica, titulo: op.titulo, estilo});
|
|
234
253
|
else {
|
|
235
254
|
console.log(texto ? 'Claude está convirtiendo tu guion en escenas...' : `Claude está escribiendo un video sobre: ${tema}...`);
|
|
236
|
-
r = await guionConIA({tema, texto, formula: op.formula, idioma: op.idioma, duracion: op.duracion ? Number(op.duracion) : undefined, tono: op.tono, estilo, imagenes: !!op.imagenes, voz: op.voz, modelo: op.modelo});
|
|
255
|
+
r = await guionConIA({tema, texto, investigar: !!op.investigar, formula: op.formula, idioma: op.idioma, duracion: op.duracion ? Number(op.duracion) : undefined, tono: op.tono, estilo, imagenes: !!op.imagenes, voz: op.voz, modelo: op.modelo});
|
|
237
256
|
}
|
|
238
257
|
if (op.id) r.id = op.id;
|
|
239
258
|
const {cargarProyecto} = await import('../lib/config.mjs');
|
|
@@ -261,7 +280,7 @@ async function nuevo(carpeta, op) {
|
|
|
261
280
|
// dos videos de ejemplo con mundo y la estructura completa (los mismos que `morfeo nuevo-video`)
|
|
262
281
|
const {nuevoVideo} = await import('../lib/nuevo-video.mjs');
|
|
263
282
|
for (const look of ['2d', '3d']) nuevoVideo({proyecto: destino, id: `ejemplo-${look}`, look});
|
|
264
|
-
console.log(`Proyecto creado en ${carpeta}/\n\
|
|
283
|
+
console.log(`Proyecto creado en ${carpeta}/\n\nCon tu IA: abre la carpeta en Claude Code o Codex y escribe «Configúrame»; luego «Hazme un video sobre…».\n\nA mano:\n cd ${carpeta}\n npm install\n npx morfeo instalar (solo la primera vez en esta computadora)\n npx morfeo estilo (tu nombre, colores, letra y voz)\n npx morfeo render`);
|
|
265
284
|
}
|
|
266
285
|
|
|
267
286
|
main().catch((e) => {
|
package/index.d.ts
CHANGED
|
@@ -64,7 +64,7 @@ interface CapaBase {
|
|
|
64
64
|
z?: number;
|
|
65
65
|
/** Efecto de sonido al aparecer. */
|
|
66
66
|
/** Efecto al aparecer. Sin decirlo, suena según cómo entra (rebote, caer, deslizar) o qué es (sello, dato, chip…); `false` lo apaga. */
|
|
67
|
-
sonido?: 'pop' | 'whoosh' | 'tick' | 'glitch' | 'ding' | 'golpe' | false;
|
|
67
|
+
sonido?: 'pop' | 'whoosh' | 'tick' | 'glitch' | 'ding' | 'golpe' | 'teclas' | 'radio' | 'obturador' | 'notificacion' | 'latido' | 'alarma' | 'reloj' | 'caja' | false;
|
|
68
68
|
/** Cambios en el tiempo (ideal para el HUD): los números se animan desde el valor anterior. */
|
|
69
69
|
cambios?: ({en: Momento | {escena: number}} & Record<string, unknown>)[];
|
|
70
70
|
}
|
|
@@ -104,7 +104,8 @@ export type Capa = CapaBase &
|
|
|
104
104
|
| {tipo: 'fecha'; anio?: number; de?: number; texto?: string; pie?: string; color?: string; tam?: number}
|
|
105
105
|
| {tipo: 'calendario'; titulo?: string; de?: number; a?: number; color?: string; tam?: number; duracion?: number}
|
|
106
106
|
| {tipo: 'dividida'; izq?: Mitad; der?: Mitad; arriba?: Mitad; abajo?: Mitad; vertical?: boolean; fondo?: string; linea?: string}
|
|
107
|
-
| {tipo: 'tierra'; lat: number; lon: number; etiqueta?: string; tam?: number; color?: string; girar?: boolean}
|
|
107
|
+
| {tipo: 'tierra'; lat: number; lon: number; etiqueta?: string; tam?: number; color?: string; girar?: boolean; /** Después del pin, vuela hacia el sitio: el globo crece × este valor. */ acercar?: number; duracionVuelo?: number}
|
|
108
|
+
| {tipo: 'linea'; hitos: {anio?: number | string; titulo?: string; texto?: string; en?: Momento}[]; color?: string; ancho?: number; paso?: number}
|
|
108
109
|
| {tipo: 'mapa'; ruta?: [number, number][]; distancia?: string; ancho?: number; color?: string; fondo?: string; inclinar?: number; duracion?: number}
|
|
109
110
|
| {tipo: 'cursor'; a: [number, number]; clic?: Momento; duracion?: number; tam?: number; color?: string}
|
|
110
111
|
| {tipo: 'texto'; texto: string; tam?: number; color?: string; acento?: string; fuente?: 'titulo' | 'texto' | 'mono' | (string & {}); peso?: number; mayusculas?: boolean; contorno?: number; colorContorno?: string; fondo?: string; relleno?: string; radio?: number; maxAncho?: number | 'auto'; /** Achica la letra si no cabe (palabra más larga o más de `maxLineas` renglones). Por defecto sí. */ ajustar?: boolean; maxLineas?: number; alinear?: 'center' | 'left' | 'right'; interlineado?: number; escribir?: boolean | number; subrayado?: boolean}
|
|
@@ -295,6 +296,12 @@ export interface Video {
|
|
|
295
296
|
/** Para publicar: descripción y etiquetas (van a salida/<id>.txt junto con el título). */
|
|
296
297
|
descripcion?: string;
|
|
297
298
|
tags?: string[];
|
|
299
|
+
/** Texto grande de la portada (salida/<título> (portada).jpg); por defecto el título sin emojis. `*palabra*` en color. false = sin portada. */
|
|
300
|
+
portada?: string | false;
|
|
301
|
+
/** Ganchos de reserva: otras primeras frases para probar otra versión (van al final de salida/<título>.txt). */
|
|
302
|
+
hooks?: string[];
|
|
303
|
+
/** Fuentes de los datos del video (van en salida/<título>.txt): 'INEGI 2024' o {texto, url}. */
|
|
304
|
+
fuentes?: (string | {texto?: string; url?: string})[];
|
|
298
305
|
/**
|
|
299
306
|
* Fórmula de historia (ver `npx morfeo formulas`): 'construiste' | 'recomendable' | 'escalada' | 'supervivencia' |
|
|
300
307
|
* 'experimento' | 'misterio' | 'demuestralo' | 'mito' | 'roast' | 'serie' | 'biografia'. `revisar` da consejos propios.
|
package/lib/calidad.mjs
ADDED
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
// Revisión automática de lo que no se ve a simple vista:
|
|
2
|
+
// - voz = guion: con Whisper, compara lo que la voz dijo de verdad con el `say` de cada escena (la voz sintética a
|
|
3
|
+
// veces se come palabras, lee mal cifras o siglas).
|
|
4
|
+
// - zona segura: lo que queda debajo de los botones y textos de TikTok, Reels y Shorts (columna derecha, pie con
|
|
5
|
+
// el nombre y la descripción, barra de arriba). Coordenadas en 1080×1920.
|
|
6
|
+
import {normalizar} from './palabras.mjs';
|
|
7
|
+
|
|
8
|
+
const NUMEROS = /^(uno|una|dos|tres|cuatro|cinco|seis|siete|ocho|nueve|diez|once|doce|trece|catorce|quince|dieci\w*|veint\w*|treinta|cuarenta|cincuenta|sesenta|setenta|ochenta|noventa|cien|ciento|\w*cientos|mil|millon\w*|porciento|ciento)$/;
|
|
9
|
+
|
|
10
|
+
/** Zonas que tapan las apps (unión aproximada de TikTok, Instagram Reels y YouTube Shorts). */
|
|
11
|
+
export const ZONAS = [
|
|
12
|
+
{id: 'derecha', nombre: 'botones de la derecha (me gusta, comentar, compartir)', x0: 930, y0: 760, x1: 1080, y1: 1660},
|
|
13
|
+
{id: 'pie', nombre: 'nombre de la cuenta y descripción', x0: 0, y0: 1640, x1: 1080, y1: 1920},
|
|
14
|
+
{id: 'arriba', nombre: 'barra de arriba (Siguiendo / Para ti)', x0: 0, y0: 0, x1: 1080, y1: 150},
|
|
15
|
+
];
|
|
16
|
+
|
|
17
|
+
/** Medida aproximada de una capa: su caja centrada en (x, y). */
|
|
18
|
+
function caja(c) {
|
|
19
|
+
const ancho = Number(c.ancho ?? c.tam ?? 300);
|
|
20
|
+
const alto = Number(c.alto ?? c.tam ?? ancho * 0.4);
|
|
21
|
+
const x = Number(c.x ?? 540);
|
|
22
|
+
const y = Number(c.y ?? 960);
|
|
23
|
+
return {x0: x - ancho / 2, y0: y - alto / 2, x1: x + ancho / 2, y1: y + alto / 2, x, y};
|
|
24
|
+
}
|
|
25
|
+
const pisa = (b, z, margen = 0.25) => {
|
|
26
|
+
const ix = Math.max(0, Math.min(b.x1, z.x1) - Math.max(b.x0, z.x0));
|
|
27
|
+
const iy = Math.max(0, Math.min(b.y1, z.y1) - Math.max(b.y0, z.y0));
|
|
28
|
+
const area = Math.max(1, (b.x1 - b.x0) * (b.y1 - b.y0));
|
|
29
|
+
return (ix * iy) / area > margen;
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
// capas que llenan la pantalla o viven en el mundo: no se revisan
|
|
33
|
+
const LLENAS = new Set(['particulas', 'escenario', 'vigilancia', 'dividida', 'componente', 'grupo', 'escena3d']);
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Capas cuyo texto o dato queda debajo de la interfaz de las apps.
|
|
37
|
+
* @returns {{id: string, nivel: 'aviso' | 'idea', texto: string}[]}
|
|
38
|
+
*/
|
|
39
|
+
export function revisarZona(v) {
|
|
40
|
+
const out = [];
|
|
41
|
+
(v.scenes ?? []).forEach((s, i) => {
|
|
42
|
+
for (const c of s.capas ?? []) {
|
|
43
|
+
if (!c || LLENAS.has(c.tipo) || c.enMundo || c.x == null || c.y == null) continue;
|
|
44
|
+
const b = caja(c);
|
|
45
|
+
for (const z of ZONAS) if (pisa(b, z)) out.push({id: 'zona', nivel: 'aviso', texto: `Escena ${i + 1}: la capa ${c.tipo}${c.texto ? ` "${String(c.texto).slice(0, 24)}"` : ''} (x ${c.x}, y ${c.y}) queda bajo ${z.nombre}. Muévela al centro.`});
|
|
46
|
+
}
|
|
47
|
+
if (s.rotuloY != null && (s.rotuloY > 1600 || s.rotuloY < 160)) out.push({id: 'zona', nivel: 'aviso', texto: `Escena ${i + 1}: el rótulo (y ${s.rotuloY}) queda bajo la interfaz de la app.`});
|
|
48
|
+
});
|
|
49
|
+
const sy = v.tema?.subtitulos?.y;
|
|
50
|
+
if (sy != null && (sy > 1600 || sy < 160)) out.push({id: 'zona', nivel: 'aviso', texto: `Los subtítulos (y ${sy}) quedan bajo la interfaz de la app: entre 200 y 1550 se leen en todas.`});
|
|
51
|
+
return out;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Compara lo que la voz dijo (palabras de Whisper en datos.json) con el `say` de cada escena. Solo con tiempos reales.
|
|
56
|
+
* @returns {{id: string, nivel: 'aviso' | 'idea', texto: string}[]}
|
|
57
|
+
*/
|
|
58
|
+
export function revisarVoz(v, d) {
|
|
59
|
+
if (!d?.escenas || !d.whisper) return [];
|
|
60
|
+
const out = [];
|
|
61
|
+
(v.scenes ?? []).forEach((s, i) => {
|
|
62
|
+
const e = d.escenas[i];
|
|
63
|
+
if (!e?.palabras?.length || s.audio || !s.say) return;
|
|
64
|
+
const esperadas = String(s.say).split(/\s+/).map(normalizar).filter((w) => w.length > 2);
|
|
65
|
+
if (esperadas.length < 3) return;
|
|
66
|
+
const dichas = e.palabras.map(([w]) => normalizar(w));
|
|
67
|
+
// Whisper escribe las cifras con números ("33"): las palabras de número cuentan como dichas si hay cifras
|
|
68
|
+
const hayCifras = dichas.some((x) => /\d/.test(x));
|
|
69
|
+
const faltan = esperadas.filter((w) => !(hayCifras && NUMEROS.test(w)) && !dichas.some((x) => x.startsWith(w.slice(0, 5)) || w.startsWith(x.slice(0, 5))));
|
|
70
|
+
if (faltan.length / esperadas.length > 0.25)
|
|
71
|
+
out.push({id: 'voz', nivel: 'aviso', texto: `Escena ${i + 1}: la voz no dijo bien ${faltan.length} de ${esperadas.length} palabras (${faltan.slice(0, 4).join(', ')}). Escucha esa escena; escribe las cifras y siglas con letras en \`say\`.`});
|
|
72
|
+
});
|
|
73
|
+
return out;
|
|
74
|
+
}
|
package/lib/config.mjs
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
// Carga y valida morfeo.config.mjs del proyecto del usuario.
|
|
2
2
|
import {IDS_FORMULA} from './formulas.mjs';
|
|
3
|
+
import {aplicarEstiloPropio, leerEstiloPropio} from './estilo-propio.mjs';
|
|
3
4
|
import fs from 'node:fs';
|
|
4
5
|
import path from 'node:path';
|
|
5
6
|
import {pathToFileURL} from 'node:url';
|
|
@@ -26,7 +27,7 @@ export const TIPOS_ESCENA = [...new Set([...TIPOS_CLASICOS, ...TIPOS_MOTOR])];
|
|
|
26
27
|
export const ROTULOS = ['normal', 'contorno', 'pastilla', 'sello', 'paso', 'pixel'];
|
|
27
28
|
export const MODOS = ['rayosx', 'plano', 'holograma'];
|
|
28
29
|
export const ESTILOS = ['datos-curiosos', 'explicativo', 'documental', 'ilustrado', 'historia', 'mesa', 'serie', 'infografia', '3d', 'maqueta', 'minimal'];
|
|
29
|
-
export const TIPOS_CAPA = ['imagen', 'video', 'texto', 'contador', 'chip', 'etiqueta', 'numero', 'tarjeta', 'telefono', 'forma', 'icono', 'emoji', 'marca', 'particulas', 'grupo', 'modelo', 'componente', 'barra', 'medidor', 'votacion', 'estado', 'noticia', 'chat', 'onda', 'globo', 'seguir', 'personaje', 'escena3d', 'ventana', 'escenario', 'recurso', 'dato', 'medida', 'trazo', 'contorno', 'fuente', 'seccion', 'insignia', 'sello', 'cursor', 'terminal', 'nombre', 'accion', 'reloj', 'cronometro', 'termometro', 'recipientes', 'ficha', 'checklist', 'mapa', 'bloqueo', 'llamada', 'recuento', 'capitulo', 'vigilancia', 'cintillo', 'fecha', 'calendario', 'dividida', 'tierra'];
|
|
30
|
+
export const TIPOS_CAPA = ['imagen', 'video', 'texto', 'contador', 'chip', 'etiqueta', 'numero', 'tarjeta', 'telefono', 'forma', 'icono', 'emoji', 'marca', 'particulas', 'grupo', 'modelo', 'componente', 'barra', 'medidor', 'votacion', 'estado', 'noticia', 'chat', 'onda', 'globo', 'seguir', 'personaje', 'escena3d', 'ventana', 'escenario', 'recurso', 'dato', 'medida', 'trazo', 'contorno', 'fuente', 'seccion', 'insignia', 'sello', 'cursor', 'terminal', 'nombre', 'accion', 'reloj', 'cronometro', 'termometro', 'recipientes', 'ficha', 'checklist', 'mapa', 'bloqueo', 'llamada', 'recuento', 'capitulo', 'vigilancia', 'cintillo', 'fecha', 'calendario', 'dividida', 'tierra', 'linea'];
|
|
30
31
|
export const ESCENARIOS = ['bosque', 'cuarto', 'departamento', 'tunel', 'pasillo'];
|
|
31
32
|
export const FORMAS_3D = ['moneda', 'billete', 'lingote', 'cubo', 'esfera', 'corazon', 'estrella', 'rayo', 'diamante', 'trofeo', 'cohete', 'foco', 'reloj', 'planeta', 'arbol', 'nube', 'regalo', 'llave', 'candado', 'casa', 'libro', 'globo', 'medalla', 'escudo', 'pin', 'tuberia', 'muro', 'viga', 'columna', 'escalera', 'losa', 'tanque', 'suelo'];
|
|
32
33
|
export const PROVEEDORES_VOZ = ['kokoro', 'elevenlabs', 'openai'];
|
|
@@ -80,6 +81,12 @@ export async function cargarProyecto(proyecto = process.cwd()) {
|
|
|
80
81
|
let cfg = await importar(archivo);
|
|
81
82
|
if (typeof cfg === 'function') cfg = await cfg();
|
|
82
83
|
if (!cfg || typeof cfg !== 'object') throw new ErrorMorfeo(`${path.basename(archivo)} debe exportar un objeto (export default defineConfig({...}))`);
|
|
84
|
+
// estilo.json (de `morfeo estilo`): nombre, siglas, colores, letra, voz, ritmo y cierre encima del config
|
|
85
|
+
try {
|
|
86
|
+
cfg = aplicarEstiloPropio(cfg, leerEstiloPropio(proyecto), proyecto);
|
|
87
|
+
} catch (e) {
|
|
88
|
+
throw new ErrorMorfeo(e.message);
|
|
89
|
+
}
|
|
83
90
|
|
|
84
91
|
let videos = cfg.videos ?? (fs.existsSync(path.join(proyecto, 'videos.mjs')) ? './videos.mjs' : './guiones');
|
|
85
92
|
const carpetaGuiones = typeof videos === 'string' && fs.existsSync(path.resolve(proyecto, videos)) && fs.statSync(path.resolve(proyecto, videos)).isDirectory()
|
|
@@ -103,6 +110,9 @@ export async function cargarProyecto(proyecto = process.cwd()) {
|
|
|
103
110
|
redes: m.redes ?? '',
|
|
104
111
|
seguir: m.seguir ?? '+ SEGUIR',
|
|
105
112
|
siguiendo: m.siguiendo ?? 'SIGUIENDO',
|
|
113
|
+
...(m.siglas ? {siglas: String(m.siglas)} : {}),
|
|
114
|
+
...(m.color ? {color: String(m.color)} : {}),
|
|
115
|
+
...(m.letra ? {letra: String(m.letra)} : {}),
|
|
106
116
|
etiqueta: m.etiqueta ?? 'DATO CURIOSO',
|
|
107
117
|
cta: m.cta ?? (m.nombre ? `Sígueme en ${m.nombre} para más.` : 'Sígueme para más.'),
|
|
108
118
|
};
|
|
@@ -112,7 +122,7 @@ export async function cargarProyecto(proyecto = process.cwd()) {
|
|
|
112
122
|
const estilosPropios = cfg.estilos ?? {};
|
|
113
123
|
const estilo = cfg.estilo ?? 'datos-curiosos';
|
|
114
124
|
const assetsDir = path.resolve(proyecto, cfg.assets ?? 'assets');
|
|
115
|
-
const ctx = {proyecto, marca, voces: cfg.voces ?? {}, musicas: cfg.musicas ?? {}, errores, estilo, estilosPropios, assetsDir, personajes: cfg.personajes ?? {}, componentes: Object.keys(cfg.componentes ?? {})};
|
|
125
|
+
const ctx = {proyecto, marca, propio: cfg.__estiloPropio ?? null, voces: cfg.voces ?? {}, musicas: cfg.musicas ?? {}, errores, estilo, estilosPropios, assetsDir, personajes: cfg.personajes ?? {}, componentes: Object.keys(cfg.componentes ?? {})};
|
|
116
126
|
if (!ESTILOS.includes(estilo) && !estilosPropios[estilo]) errores.push(`estilo "${estilo}" no existe. Opciones: ${[...ESTILOS, ...Object.keys(estilosPropios)].join(', ')}`);
|
|
117
127
|
const componentes = {};
|
|
118
128
|
for (const [nombre, ruta] of Object.entries(cfg.componentes ?? {})) {
|
|
@@ -161,6 +171,7 @@ export async function cargarProyecto(proyecto = process.cwd()) {
|
|
|
161
171
|
concurrencia: Number(process.env.MORFEO_CONCURRENCIA) || r.concurrencia || null, // null = Morfeo elige según la máquina y el modo (ver render.mjs)
|
|
162
172
|
miniatura: r.miniatura ?? true,
|
|
163
173
|
consejos: r.consejos ?? true,
|
|
174
|
+
portada: r.portada ?? true,
|
|
164
175
|
// 'titulo' (por defecto): salida/<Título del video>.mp4, listo para subir · 'id': salida/<id>.mp4
|
|
165
176
|
nombreArchivo: r.nombreArchivo ?? 'titulo',
|
|
166
177
|
},
|
|
@@ -230,6 +241,7 @@ function validarCapas(capas, donde, err, comps) {
|
|
|
230
241
|
if (c.estilo && !['2d', '3d', 'realista'].includes(c.estilo)) err(`${d}: estilo "${c.estilo}" no existe. Usa '2d', '3d' o 'realista'`);
|
|
231
242
|
}
|
|
232
243
|
if (c.tipo === 'dividida') for (const lado of ['izq', 'der', 'arriba', 'abajo']) if (c[lado]?.capas) validarCapas(c[lado].capas, `${d}, ${lado}`, err, comps);
|
|
244
|
+
if (c.tipo === 'linea' && (!Array.isArray(c.hitos) || !c.hitos.length)) err(`${d}: \`linea\` necesita \`hitos\`: [{anio: 1963, texto: '…', en: 'palabra'}]`);
|
|
233
245
|
if (c.tipo === 'cintillo' && !c.nombre) err(`${d}: \`cintillo\` necesita \`nombre\``);
|
|
234
246
|
if (c.tipo === 'llamada' && c.estado != null && !['entrante', 'curso', 'terminada'].includes(c.estado)) err(`${d}: estado "${c.estado}" no existe (entrante, curso o terminada)`);
|
|
235
247
|
if (c.tipo === 'tierra' && (!esNum(c.lat) || !esNum(c.lon))) err(`${d}: \`tierra\` necesita \`lat\` y \`lon\` (números)`);
|
|
@@ -303,7 +315,9 @@ function validarVideo(id, v, ctx) {
|
|
|
303
315
|
const err = (m) => ctx.errores.push(`${id}: ${m}`);
|
|
304
316
|
if (!v || typeof v !== 'object') return err('debe ser un objeto'), null;
|
|
305
317
|
if (!Array.isArray(v.scenes) || !v.scenes.length) return err('falta `scenes` (lista de escenas)'), null;
|
|
306
|
-
|
|
318
|
+
// con estilo.json, un video viejo de escenas clásicas (title, text, card…) sin `estilo` sigue en su estilo de antes
|
|
319
|
+
const deClasicas = v.scenes.some((s) => s && TIPOS_CLASICOS.includes(s.type) && s.type !== 'cta' && !TIPOS_MOTOR.includes(s.type));
|
|
320
|
+
const estilo = v.estilo ?? (ctx.propio && deClasicas ? ctx.propio.estiloAnterior ?? 'datos-curiosos' : ctx.estilo);
|
|
307
321
|
const clasico = estilo === 'datos-curiosos';
|
|
308
322
|
if (!ESTILOS.includes(estilo) && !ctx.estilosPropios[estilo]) err(`estilo "${estilo}" no existe. Opciones: ${[...ESTILOS, ...Object.keys(ctx.estilosPropios)].join(', ')}`);
|
|
309
323
|
const permitidos = clasico ? TIPOS_ESCENA : TIPOS_MOTOR;
|
|
@@ -356,6 +370,9 @@ function validarVideo(id, v, ctx) {
|
|
|
356
370
|
// la voz sintética solo hace falta si alguna escena no trae su propia grabación
|
|
357
371
|
const necesitaVoz = scenes.some((s) => s && !s.audio);
|
|
358
372
|
let voice = null;
|
|
373
|
+
// sin `voice`, la del estilo propio (estilo.json)
|
|
374
|
+
if (v.voice == null && ctx.propio?.voz) v = {...v, voice: ctx.propio.voz};
|
|
375
|
+
if (v.speed == null && ctx.propio?.velocidad) v = {...v, speed: ctx.propio.velocidad};
|
|
359
376
|
if (v.voice != null) voice = normalizarVoz(v.voice, ctx, err);
|
|
360
377
|
else if (necesitaVoz) err('falta `voice` (p. ej. "em_alex", una mezcla [["em_alex", 0.6], ["am_michael", 0.4]], un .npy, o `audio` en cada escena)');
|
|
361
378
|
if (v.hud != null) validarCapas(v.hud, 'hud', err, ctx.componentes);
|
|
@@ -372,6 +389,9 @@ function validarVideo(id, v, ctx) {
|
|
|
372
389
|
if (v.serie != null && typeof v.serie !== 'string') err("`serie` debe ser un texto ('GYM CON CIENCIA · PARTE 5')");
|
|
373
390
|
if (v.title != null && typeof v.title !== 'string') err('`title` debe ser un texto (también es el nombre del archivo del video)');
|
|
374
391
|
if (v.calidad != null && !['alta', 'media', 'ligera'].includes(v.calidad)) err("`calidad` debe ser 'alta', 'media' o 'ligera'");
|
|
392
|
+
if (v.portada != null && v.portada !== false && typeof v.portada !== 'string') err('`portada` debe ser un texto (el título grande de la portada) o false');
|
|
393
|
+
if (v.hooks != null && (!Array.isArray(v.hooks) || v.hooks.some((x) => typeof x !== 'string'))) err("`hooks` debe ser una lista de textos (ganchos de reserva)");
|
|
394
|
+
if (v.fuentes != null && (!Array.isArray(v.fuentes) || v.fuentes.some((x) => typeof x !== 'string' && !(x && typeof x === 'object' && (x.texto || x.url))))) err("`fuentes` debe ser una lista: ['INEGI 2024'] o [{texto: 'INEGI', url: 'https://…'}]");
|
|
375
395
|
if (v.tags != null && (!Array.isArray(v.tags) || v.tags.some((x) => typeof x !== 'string'))) err("`tags` debe ser una lista de textos (['curiosidades', 'ciencia'])");
|
|
376
396
|
// archivos de assets/ que piden las escenas y el HUD: mejor avisar ahora que a mitad del render
|
|
377
397
|
revisarArchivos({scenes, hud: v.hud, mundo: v.mundo, lugares: v.lugares, tema: v.tema}, ctx.assetsDir, err);
|
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
// `morfeo estilo`: abre una página en tu computadora para crear el estilo del canal (nombre, siglas, colores, letra,
|
|
2
|
+
// voz, ritmo y cierre) con una vista previa en vivo. Al guardar escribe estilo.json en el proyecto y baja la letra
|
|
3
|
+
// elegida a fuentes/. No sale nada de tu computadora salvo la descarga de la letra (Google Fonts, licencia libre).
|
|
4
|
+
import fs from 'node:fs';
|
|
5
|
+
import http from 'node:http';
|
|
6
|
+
import path from 'node:path';
|
|
7
|
+
import {spawn} from 'node:child_process';
|
|
8
|
+
import {ARCHIVO_ESTILO, LETRAS, RITMOS, leerEstiloPropio} from './estilo-propio.mjs';
|
|
9
|
+
import {ESTILOS} from './config.mjs';
|
|
10
|
+
|
|
11
|
+
const VOCES_KOKORO = {
|
|
12
|
+
'Narrador (mezcla)': [['em_alex', 0.6], ['am_michael', 0.4]],
|
|
13
|
+
'Narradora (mezcla)': [['ef_dora', 0.6], ['af_heart', 0.4]],
|
|
14
|
+
'Alex (hombre, español)': 'em_alex',
|
|
15
|
+
'Santa (hombre, español)': 'em_santa',
|
|
16
|
+
'Dora (mujer, español)': 'ef_dora',
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
const esc = (s) => String(s ?? '').replace(/[&<>"]/g, (c) => ({'&': '&', '<': '<', '>': '>', '"': '"'})[c]);
|
|
20
|
+
|
|
21
|
+
function pagina(actual) {
|
|
22
|
+
const a = actual ?? {};
|
|
23
|
+
const vozActual = typeof a.voz === 'object' && a.voz?.proveedor === 'elevenlabs' ? 'eleven' : JSON.stringify(a.voz ?? VOCES_KOKORO['Narrador (mezcla)']);
|
|
24
|
+
const familias = Object.keys(LETRAS).filter((f) => f !== 'Montserrat').map((f) => f.replace(/ /g, '+')).join('&family=');
|
|
25
|
+
return `<!doctype html><html lang="es"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
|
|
26
|
+
<title>Tu estilo · Morfeo</title>
|
|
27
|
+
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=${familias}&family=Montserrat:wght@500;700;900&display=swap">
|
|
28
|
+
<style>
|
|
29
|
+
:root{--fondo:#0f0d15;--panel:#17151f;--linea:rgba(241,236,228,.12);--tinta:#f1ece4;--tenue:#a49dae;--acento:${esc(a.colores?.acento ?? '#FFD400')}}
|
|
30
|
+
*{box-sizing:border-box}body{margin:0;background:var(--fondo);color:var(--tinta);font-family:Montserrat,system-ui,sans-serif}
|
|
31
|
+
main{max-width:1100px;margin:0 auto;padding:40px 24px 80px;display:grid;grid-template-columns:1fr 360px;gap:48px}
|
|
32
|
+
h1{font-weight:900;font-size:34px;margin:0 0 6px;letter-spacing:-.02em}p.sub{color:var(--tenue);margin:0 0 30px}
|
|
33
|
+
fieldset{border:1px solid var(--linea);border-radius:16px;padding:18px 20px 20px;margin:0 0 18px}
|
|
34
|
+
legend{font-size:12px;letter-spacing:.14em;text-transform:uppercase;color:var(--tenue);padding:0 8px}
|
|
35
|
+
label{display:block;font-size:14px;font-weight:700;margin:12px 0 6px}
|
|
36
|
+
input[type=text],select{width:100%;background:var(--panel);border:1px solid var(--linea);color:var(--tinta);border-radius:10px;padding:11px 12px;font:inherit}
|
|
37
|
+
.fila{display:grid;grid-template-columns:1fr 1fr;gap:14px}.colores{display:flex;gap:12px;align-items:center}
|
|
38
|
+
input[type=color]{width:52px;height:40px;border:none;background:none;padding:0;cursor:pointer}
|
|
39
|
+
.ritmos{display:flex;gap:8px}.ritmos label{flex:1;margin:0;text-align:center;border:1px solid var(--linea);border-radius:10px;padding:10px;cursor:pointer;font-weight:600}
|
|
40
|
+
.ritmos input{display:none}.ritmos input:checked+span{color:var(--acento)}.ritmos label:has(input:checked){border-color:var(--acento)}
|
|
41
|
+
button{margin-top:10px;background:var(--acento);color:#111;border:none;border-radius:12px;padding:14px 26px;font:900 16px Montserrat;cursor:pointer}
|
|
42
|
+
.nota{font-size:13px;color:var(--tenue);margin-top:6px}
|
|
43
|
+
.tel{position:sticky;top:30px;width:360px;aspect-ratio:9/16;border-radius:34px;padding:9px;background:#0b0a10;box-shadow:0 30px 60px rgba(0,0,0,.5),inset 0 0 0 2px #2a2733}
|
|
44
|
+
.pantalla{position:relative;width:100%;height:100%;border-radius:27px;overflow:hidden;background:radial-gradient(ellipse at 50% 30%,#2b2f55,#0c0d1a)}
|
|
45
|
+
.marca{position:absolute;left:16px;top:44px;display:flex;align-items:center;gap:8px;font-weight:800;font-size:14px}
|
|
46
|
+
.av{width:34px;height:34px;border-radius:50%;border:2px solid #fff;display:grid;place-items:center;font-weight:900;font-size:13px;background:linear-gradient(var(--acento),#111);color:#fff}
|
|
47
|
+
.rotulo{position:absolute;left:18px;right:18px;top:38%;text-align:center;font-size:42px;line-height:1;text-transform:uppercase;color:#fff;-webkit-text-stroke:2px #000;paint-order:stroke fill}
|
|
48
|
+
.rotulo b{color:var(--acento);font-weight:inherit}
|
|
49
|
+
.subs{position:absolute;left:0;right:0;top:62%;text-align:center;font-weight:800;font-size:17px;color:#fff}.subs span{background:var(--sub,#4C8DFF);padding:1px 6px;border-radius:4px}
|
|
50
|
+
.cierre{position:absolute;left:18px;right:18px;bottom:40px;text-align:center;font-size:13px;color:var(--tenue)}
|
|
51
|
+
.ok{display:none;margin-top:14px;padding:12px 14px;border-radius:10px;background:rgba(48,164,108,.15);color:#7ee0a8;font-weight:700}
|
|
52
|
+
@media (max-width:860px){main{grid-template-columns:1fr}.tel{position:relative;top:0;margin:0 auto}}
|
|
53
|
+
</style></head><body><main>
|
|
54
|
+
<form id="f">
|
|
55
|
+
<h1>Tu estilo</h1><p class="sub">Así se verán y sonarán todos tus videos. Puedes cambiarlo cuando quieras.</p>
|
|
56
|
+
<fieldset><legend>Canal</legend>
|
|
57
|
+
<div class="fila"><div><label>Nombre</label><input type="text" name="nombre" value="${esc(a.nombre ?? '')}" placeholder="Datos Rápidos"></div>
|
|
58
|
+
<div><label>Siglas (sin logo)</label><input type="text" name="siglas" maxlength="3" value="${esc(a.siglas ?? '')}" placeholder="DR"></div></div>
|
|
59
|
+
<label>Redes (salen en el cierre)</label><input type="text" name="redes" value="${esc(a.redes ?? '')}" placeholder="TikTok @datosrapidos · YT @datosrapidos">
|
|
60
|
+
</fieldset>
|
|
61
|
+
<fieldset><legend>Cómo se ve</legend>
|
|
62
|
+
<label>Estilo base</label><select name="base">${ESTILOS.filter((e) => e !== 'datos-curiosos').map((e) => `<option ${e === (a.base ?? 'documental') ? 'selected' : ''}>${e}</option>`).join('')}</select>
|
|
63
|
+
<label>Colores</label><div class="colores"><input type="color" name="acento" value="${esc(a.colores?.acento ?? '#FFD400')}"><span class="nota">acento (cifras, palabra clave, siglas)</span>
|
|
64
|
+
<input type="color" name="subtitulo" value="${esc(a.colores?.subtitulo ?? '#4C8DFF')}"><span class="nota">caja del subtítulo</span></div>
|
|
65
|
+
<label>Letra de títulos</label><select name="letra">${Object.keys(LETRAS).map((f) => `<option ${f === (a.letra ?? 'Anton') ? 'selected' : ''}>${f}</option>`).join('')}</select>
|
|
66
|
+
</fieldset>
|
|
67
|
+
<fieldset><legend>Cómo suena</legend>
|
|
68
|
+
<label>Voz</label><select name="voz">${Object.entries(VOCES_KOKORO).map(([n, v]) => `<option value='${esc(JSON.stringify(v))}' ${JSON.stringify(v) === vozActual ? 'selected' : ''}>${n} · gratis</option>`).join('')}<option value="eleven" ${vozActual === 'eleven' ? 'selected' : ''}>Mi voz de ElevenLabs</option></select>
|
|
69
|
+
<div id="eleven" style="display:${vozActual === 'eleven' ? 'block' : 'none'}"><label>ID de la voz en ElevenLabs</label><input type="text" name="elevenId" value="${esc(a.voz?.voz ?? '')}" placeholder="21m00Tcm4TlvDq8ikWAM"><p class="nota">La clave va en el .env del proyecto: ELEVENLABS_API_KEY=…</p></div>
|
|
70
|
+
<label>Ritmo</label><div class="ritmos">${Object.keys(RITMOS).map((r) => `<label><input type="radio" name="ritmo" value="${r}" ${r === (a.ritmo ?? 'natural') ? 'checked' : ''}><span>${r === 'agil' ? 'Ágil' : r[0].toUpperCase() + r.slice(1)}</span></label>`).join('')}</div>
|
|
71
|
+
<label>Frase de cierre</label><input type="text" name="cierre" value="${esc(a.cierre ?? '')}" placeholder="Guárdalo para cuando te pregunten.">
|
|
72
|
+
</fieldset>
|
|
73
|
+
<button type="submit">Guardar estilo</button><div class="ok" id="ok"></div>
|
|
74
|
+
</form>
|
|
75
|
+
<div><div class="tel"><div class="pantalla"><div class="marca"><div class="av" id="pav">M</div><span id="pnom">Mi Canal</span></div>
|
|
76
|
+
<div class="rotulo" id="prot">MIL <b>TRESCIENTOS</b> METROS</div><div class="subs">a casi <span>mil trescientos</span> metros</div><div class="cierre" id="pcie"></div></div></div></div>
|
|
77
|
+
</main>
|
|
78
|
+
<script>
|
|
79
|
+
const f=document.getElementById('f');
|
|
80
|
+
function vista(){const d=new FormData(f);document.documentElement.style.setProperty('--acento',d.get('acento'));document.querySelector('.subs').style.setProperty('--sub',d.get('subtitulo'));
|
|
81
|
+
document.getElementById('pnom').textContent=d.get('nombre')||'Mi Canal';document.getElementById('pav').textContent=(d.get('siglas')||(d.get('nombre')||'M')[0]).toUpperCase();
|
|
82
|
+
document.getElementById('prot').style.fontFamily='"'+d.get('letra')+'"';document.getElementById('pcie').textContent=d.get('cierre')||'';
|
|
83
|
+
document.getElementById('eleven').style.display=d.get('voz')==='eleven'?'block':'none';}
|
|
84
|
+
f.addEventListener('input',vista);vista();
|
|
85
|
+
f.addEventListener('submit',async(e)=>{e.preventDefault();const d=Object.fromEntries(new FormData(f));const r=await fetch('/guardar',{method:'POST',headers:{'content-type':'application/json'},body:JSON.stringify(d)});
|
|
86
|
+
const t=await r.text();const ok=document.getElementById('ok');ok.style.display='block';ok.textContent=t;});
|
|
87
|
+
</script></body></html>`;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/** Arma estilo.json desde el formulario. */
|
|
91
|
+
export function estiloDesdeFormulario(d) {
|
|
92
|
+
let voz;
|
|
93
|
+
if (d.voz === 'eleven') voz = {proveedor: 'elevenlabs', voz: String(d.elevenId ?? '').trim()};
|
|
94
|
+
else {
|
|
95
|
+
try {
|
|
96
|
+
voz = JSON.parse(d.voz);
|
|
97
|
+
} catch {
|
|
98
|
+
voz = 'em_alex';
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
return {
|
|
102
|
+
nombre: String(d.nombre ?? '').trim(),
|
|
103
|
+
siglas: String(d.siglas ?? '').trim().toUpperCase().slice(0, 3),
|
|
104
|
+
redes: String(d.redes ?? '').trim(),
|
|
105
|
+
base: ESTILOS.includes(d.base) ? d.base : 'documental',
|
|
106
|
+
colores: {acento: d.acento || '#FFD400', subtitulo: d.subtitulo || '#4C8DFF'},
|
|
107
|
+
letra: LETRAS[d.letra] !== undefined ? d.letra : 'Anton',
|
|
108
|
+
voz,
|
|
109
|
+
ritmo: RITMOS[d.ritmo] ? d.ritmo : 'natural',
|
|
110
|
+
cierre: String(d.cierre ?? '').trim(),
|
|
111
|
+
};
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/** Baja la letra a fuentes/ del proyecto (si no viene ya en el paquete o ya está). */
|
|
115
|
+
export async function bajarLetra(proyecto, letra) {
|
|
116
|
+
const ruta = LETRAS[letra];
|
|
117
|
+
if (!ruta) return null;
|
|
118
|
+
const destino = path.join(proyecto, 'fuentes', `${letra.replace(/\s+/g, '')}.ttf`);
|
|
119
|
+
if (fs.existsSync(destino)) return destino;
|
|
120
|
+
const r = await fetch(`https://raw.githubusercontent.com/google/fonts/main/${ruta}`);
|
|
121
|
+
if (!r.ok) throw new Error(`no pude bajar la letra ${letra} (${r.status})`);
|
|
122
|
+
fs.mkdirSync(path.dirname(destino), {recursive: true});
|
|
123
|
+
fs.writeFileSync(destino, Buffer.from(await r.arrayBuffer()));
|
|
124
|
+
return destino;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
export function abrirNavegador(url) {
|
|
128
|
+
const cmd = process.platform === 'darwin' ? 'open' : process.platform === 'win32' ? 'cmd' : 'xdg-open';
|
|
129
|
+
const args = process.platform === 'win32' ? ['/c', 'start', '', url] : [url];
|
|
130
|
+
try {
|
|
131
|
+
spawn(cmd, args, {stdio: 'ignore', detached: true}).unref();
|
|
132
|
+
} catch {}
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/** Levanta la página en localhost y espera a que se guarde (o a Ctrl+C). */
|
|
136
|
+
export function abrirEditorDeEstilo({proyecto, puerto = 4321, log = console.log, abrir = true}) {
|
|
137
|
+
return new Promise((resolve, reject) => {
|
|
138
|
+
const srv = http.createServer(async (req, res) => {
|
|
139
|
+
try {
|
|
140
|
+
if (req.method === 'GET' && (req.url === '/' || req.url?.startsWith('/?'))) {
|
|
141
|
+
res.writeHead(200, {'content-type': 'text/html; charset=utf-8'});
|
|
142
|
+
return res.end(pagina(leerEstiloPropio(proyecto)));
|
|
143
|
+
}
|
|
144
|
+
if (req.method === 'POST' && req.url === '/guardar') {
|
|
145
|
+
let cuerpo = '';
|
|
146
|
+
for await (const trozo of req) cuerpo += trozo;
|
|
147
|
+
const e = estiloDesdeFormulario(JSON.parse(cuerpo));
|
|
148
|
+
let aviso = '';
|
|
149
|
+
try {
|
|
150
|
+
await bajarLetra(proyecto, e.letra);
|
|
151
|
+
} catch (err) {
|
|
152
|
+
aviso = ` (la letra no se pudo bajar: ${err.message}; se usará Anton)`;
|
|
153
|
+
e.letra = 'Anton';
|
|
154
|
+
}
|
|
155
|
+
fs.writeFileSync(path.join(proyecto, ARCHIVO_ESTILO), JSON.stringify(e, null, '\t') + '\n');
|
|
156
|
+
log(`Guardado ${ARCHIVO_ESTILO}${aviso}. Ya puedes cerrar la página.`);
|
|
157
|
+
res.writeHead(200, {'content-type': 'text/plain; charset=utf-8'});
|
|
158
|
+
res.end(`Listo: tus videos ya salen con este estilo${aviso}. Vuelve a la terminal o a tu IA y pide un video.`);
|
|
159
|
+
setTimeout(() => srv.close(() => resolve(e)), 300);
|
|
160
|
+
return;
|
|
161
|
+
}
|
|
162
|
+
res.writeHead(404).end();
|
|
163
|
+
} catch (err) {
|
|
164
|
+
res.writeHead(500, {'content-type': 'text/plain; charset=utf-8'}).end(String(err.message));
|
|
165
|
+
}
|
|
166
|
+
});
|
|
167
|
+
srv.on('error', reject);
|
|
168
|
+
srv.listen(puerto, '127.0.0.1', () => {
|
|
169
|
+
const url = `http://localhost:${puerto}/`;
|
|
170
|
+
log(`Abre ${url} para crear tu estilo (Ctrl+C para salir).`);
|
|
171
|
+
if (abrir) abrirNavegador(url);
|
|
172
|
+
});
|
|
173
|
+
});
|
|
174
|
+
}
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
// Estilo propio del canal (estilo.json en la carpeta del proyecto, lo escribe `morfeo estilo`): nombre, siglas,
|
|
2
|
+
// colores, letra, voz, ritmo y frase de cierre. Se aplica al cargar el proyecto, encima de morfeo.config.mjs:
|
|
3
|
+
// todos los videos salen así salvo que uno diga otra cosa (su `estilo`, `voice` o `speed`).
|
|
4
|
+
import fs from 'node:fs';
|
|
5
|
+
import path from 'node:path';
|
|
6
|
+
|
|
7
|
+
export const ARCHIVO_ESTILO = 'estilo.json';
|
|
8
|
+
|
|
9
|
+
/** Letras para títulos (OFL/Apache, del repositorio de Google Fonts). Las dos primeras ya vienen en el paquete. */
|
|
10
|
+
export const LETRAS = {
|
|
11
|
+
Anton: null,
|
|
12
|
+
Montserrat: null,
|
|
13
|
+
'Archivo Black': 'ofl/archivoblack/ArchivoBlack-Regular.ttf',
|
|
14
|
+
'Bebas Neue': 'ofl/bebasneue/BebasNeue-Regular.ttf',
|
|
15
|
+
'Luckiest Guy': 'apache/luckiestguy/LuckiestGuy-Regular.ttf',
|
|
16
|
+
Bangers: 'ofl/bangers/Bangers-Regular.ttf',
|
|
17
|
+
'Lilita One': 'ofl/lilitaone/LilitaOne-Regular.ttf',
|
|
18
|
+
'Russo One': 'ofl/russoone/RussoOne-Regular.ttf',
|
|
19
|
+
'Titan One': 'ofl/titanone/TitanOne-Regular.ttf',
|
|
20
|
+
'Black Ops One': 'ofl/blackopsone/BlackOpsOne-Regular.ttf',
|
|
21
|
+
'Rubik Mono One': 'ofl/rubikmonoone/RubikMonoOne-Regular.ttf',
|
|
22
|
+
'Passion One': 'ofl/passionone/PassionOne-Black.ttf',
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
/** Ritmo → velocidad de la voz y duración de entradas/transiciones. */
|
|
26
|
+
export const RITMOS = {
|
|
27
|
+
pausado: {velocidad: 1.05, movimiento: {entrada: 0.6, salida: 0.45, transicion: 0.45, curva: 'suave'}},
|
|
28
|
+
natural: {velocidad: 1.2, movimiento: {entrada: 0.45, salida: 0.35, transicion: 0.35, curva: 'suave'}},
|
|
29
|
+
agil: {velocidad: 1.32, movimiento: {entrada: 0.3, salida: 0.25, transicion: 0.25, curva: 'seca'}},
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
export function leerEstiloPropio(proyecto) {
|
|
33
|
+
const f = path.join(proyecto, ARCHIVO_ESTILO);
|
|
34
|
+
if (!fs.existsSync(f)) return null;
|
|
35
|
+
try {
|
|
36
|
+
return JSON.parse(fs.readFileSync(f, 'utf8'));
|
|
37
|
+
} catch (e) {
|
|
38
|
+
throw new Error(`${ARCHIVO_ESTILO} no es un JSON válido: ${e.message}`);
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Mezcla el estilo propio en la configuración (devuelve una copia). El estilo queda como `mi-estilo`, heredando de
|
|
44
|
+
* `base`, y es el estilo por defecto del proyecto.
|
|
45
|
+
*/
|
|
46
|
+
export function aplicarEstiloPropio(cfg, e, proyecto) {
|
|
47
|
+
if (!e) return cfg;
|
|
48
|
+
const out = {...cfg, marca: {...(cfg.marca ?? {})}, voces: {...(cfg.voces ?? {})}, estilos: {...(cfg.estilos ?? {})}, fuentes: {...(cfg.fuentes ?? {})}};
|
|
49
|
+
if (e.nombre) out.marca.nombre = e.nombre;
|
|
50
|
+
if (e.siglas) out.marca.siglas = e.siglas;
|
|
51
|
+
if (e.colores?.acento) out.marca.color = e.colores.acento;
|
|
52
|
+
if (e.cierre) out.marca.cta = e.cierre;
|
|
53
|
+
if (e.letra) out.marca.letra = e.letra;
|
|
54
|
+
if (e.redes) out.marca.redes = e.redes;
|
|
55
|
+
const ritmo = RITMOS[e.ritmo] ?? RITMOS.natural;
|
|
56
|
+
const tema = {base: e.base ?? cfg.estilo ?? 'documental', movimiento: ritmo.movimiento};
|
|
57
|
+
if (e.colores?.acento) tema.colores = {acento: e.colores.acento, ...(e.colores.acento2 ? {acento2: e.colores.acento2} : {})};
|
|
58
|
+
if (e.colores?.subtitulo) tema.subtitulos = {resalte: e.colores.subtitulo};
|
|
59
|
+
if (e.letra && e.letra !== 'Montserrat') {
|
|
60
|
+
tema.fuentes = {titulo: e.letra};
|
|
61
|
+
// las letras descargadas viven en fuentes/ del proyecto (Anton viene en el paquete)
|
|
62
|
+
const archivo = path.join(proyecto, 'fuentes', `${e.letra.replace(/\s+/g, '')}.ttf`);
|
|
63
|
+
if (LETRAS[e.letra] && fs.existsSync(archivo)) out.fuentes[e.letra] = archivo;
|
|
64
|
+
}
|
|
65
|
+
out.estilos['mi-estilo'] = tema;
|
|
66
|
+
out.estilo = 'mi-estilo';
|
|
67
|
+
if (e.voz) out.voces['mi-voz'] = e.voz;
|
|
68
|
+
out.__estiloPropio = {voz: e.voz ? 'mi-voz' : null, velocidad: ritmo.velocidad, estiloAnterior: cfg.estilo ?? 'datos-curiosos'};
|
|
69
|
+
return out;
|
|
70
|
+
}
|
package/lib/estructura.mjs
CHANGED
|
@@ -43,6 +43,7 @@ export const REGLAS = [
|
|
|
43
43
|
export const REGLAS_TEXTO = `Estructura (medida en videos virales; síguela):
|
|
44
44
|
${REGLAS.map((r, i) => `${i + 1}. ${r.regla}: ${r.como}`).join('\n')}`;
|
|
45
45
|
|
|
46
|
+
import {revisarVoz, revisarZona} from './calidad.mjs';
|
|
46
47
|
import {formulaDe, revisarFormula} from './formulas.mjs';
|
|
47
48
|
|
|
48
49
|
const palabrasDe = (s) => String(s ?? '').trim().split(/\s+/).filter(Boolean);
|
|
@@ -113,6 +114,8 @@ export function revisarEstructura(v, d) {
|
|
|
113
114
|
if (n > 40 && tu === 0) add('tu', 'idea', 'No le habla al espectador ni una vez: una frase en segunda persona ("imagina que tú…") lo mete en la historia.');
|
|
114
115
|
// 6) lo propio de la fórmula del video (lib/formulas.mjs)
|
|
115
116
|
for (const t of revisarFormula(v)) add('formula', 'idea', t);
|
|
117
|
+
// 7) lo que no se ve a simple vista: la voz dijo lo del guion y nada queda bajo los botones de las apps
|
|
118
|
+
out.push(...revisarZona(v), ...revisarVoz(v, d));
|
|
116
119
|
// "envíaselo a…" también es un cierre que pide algo
|
|
117
120
|
return out;
|
|
118
121
|
}
|
package/lib/guion.mjs
CHANGED
|
@@ -170,7 +170,7 @@ export async function esquemaMotor() {
|
|
|
170
170
|
claves: z.array(z.string()).nullable(),
|
|
171
171
|
});
|
|
172
172
|
const cierre = z.object({pide: z.enum(['comenta', 'comparte', 'sigue']), say: z.string(), palabra: z.string().nullable(), a: z.string().nullable()});
|
|
173
|
-
return z.object({id: z.string(), title: z.string(), formula: z.enum(IDS_FORMULA), music: z.enum(MUSICAS), scenes: z.array(escena), cierre});
|
|
173
|
+
return z.object({id: z.string(), title: z.string(), formula: z.enum(IDS_FORMULA), music: z.enum(MUSICAS), descripcion: z.string(), tags: z.array(z.string()), hooks: z.array(z.string()), scenes: z.array(escena), cierre});
|
|
174
174
|
}
|
|
175
175
|
|
|
176
176
|
/** De la salida de la IA (motor de capas) al formato de escenas de morfeo. */
|
|
@@ -266,6 +266,51 @@ export function aEscena(e) {
|
|
|
266
266
|
return out;
|
|
267
267
|
}
|
|
268
268
|
|
|
269
|
+
/**
|
|
270
|
+
* Investiga el tema en la web antes de escribir (búsqueda del servidor de Claude): devuelve notas con cada dato y su
|
|
271
|
+
* fuente, y la lista de fuentes que de verdad se consultaron (las URLs de los resultados de búsqueda citados).
|
|
272
|
+
* @returns {Promise<{notas: string, fuentes: {texto: string, url: string}[]}>}
|
|
273
|
+
*/
|
|
274
|
+
export async function investigar(client, Anthropic, tema, {idioma = 'español de México', modelo = MODELO_GUION, log = () => {}} = {}) {
|
|
275
|
+
const messages = [
|
|
276
|
+
{
|
|
277
|
+
role: 'user',
|
|
278
|
+
content: `Investiga para un video corto vertical sobre: ${tema}
|
|
279
|
+
|
|
280
|
+
Busca en la web y junta 8 a 12 datos concretos, sorprendentes y verificables (cifras, fechas, nombres, lugares), cada uno
|
|
281
|
+
con la fuente donde lo leíste (nombre del medio o institución y URL). Prefiere fuentes primarias u oficiales. Si dos
|
|
282
|
+
fuentes no coinciden, dilo. No inventes nada que no esté en una fuente.
|
|
283
|
+
Responde en ${idioma} con una lista: "- dato · fuente (URL)".`,
|
|
284
|
+
},
|
|
285
|
+
];
|
|
286
|
+
const vistas = new Map();
|
|
287
|
+
let texto = '';
|
|
288
|
+
// la búsqueda corre en el servidor; si el turno se pausa (pause_turn), se reenvía para que continúe
|
|
289
|
+
for (let vuelta = 0; vuelta < 6; vuelta++) {
|
|
290
|
+
const res = await client.messages.create({
|
|
291
|
+
model: modelo,
|
|
292
|
+
max_tokens: 16000,
|
|
293
|
+
tools: [{type: 'web_search_20260209', name: 'web_search', max_uses: 8}],
|
|
294
|
+
messages,
|
|
295
|
+
});
|
|
296
|
+
for (const b of res.content) {
|
|
297
|
+
if (b.type === 'web_search_tool_result' && Array.isArray(b.content)) for (const r of b.content) if (r.url) vistas.set(r.url, r.title ?? r.url);
|
|
298
|
+
if (b.type === 'text') {
|
|
299
|
+
texto += b.text;
|
|
300
|
+
for (const c of b.citations ?? []) if (c.url) vistas.set(c.url, c.title ?? vistas.get(c.url) ?? c.url);
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
if (res.stop_reason === 'refusal') throw new ErrorMorfeo('Claude no quiso investigar ese tema.');
|
|
304
|
+
if (res.stop_reason !== 'pause_turn') break;
|
|
305
|
+
log(' (sigue buscando…)');
|
|
306
|
+
messages.push({role: 'assistant', content: res.content});
|
|
307
|
+
}
|
|
308
|
+
if (!texto.trim()) throw new ErrorMorfeo('La investigación no devolvió datos; intenta otra vez o sin --investigar.');
|
|
309
|
+
// solo las fuentes que aparecen en las notas (las que de verdad respaldan un dato)
|
|
310
|
+
const fuentes = [...vistas].filter(([url]) => texto.includes(url) || texto.includes(new URL(url).hostname)).map(([url, titulo]) => ({texto: titulo, url}));
|
|
311
|
+
return {notas: texto.trim(), fuentes: fuentes.length ? fuentes : [...vistas].slice(0, 8).map(([url, titulo]) => ({texto: titulo, url}))};
|
|
312
|
+
}
|
|
313
|
+
|
|
269
314
|
/**
|
|
270
315
|
* Pide a Claude un video completo.
|
|
271
316
|
* @param {{tema?: string, texto?: string, idioma?: string, duracion?: number, tono?: string, estilo?: string, imagenes?: boolean, voz?: any, modelo?: string}} o
|
|
@@ -283,10 +328,25 @@ export async function guionConIA(o) {
|
|
|
283
328
|
throw new ErrorMorfeo('Falta la dependencia @anthropic-ai/sdk (npm install @anthropic-ai/sdk zod)');
|
|
284
329
|
}
|
|
285
330
|
const client = new Anthropic();
|
|
331
|
+
// --investigar: primero los datos con su fuente (búsqueda web), luego el guion se escribe solo con esos datos
|
|
332
|
+
let investigacion = null;
|
|
333
|
+
if (o.investigar && !o.texto) {
|
|
334
|
+
(o.log ?? console.log)('Investigando el tema en la web (cada dato con su fuente)...');
|
|
335
|
+
try {
|
|
336
|
+
investigacion = await investigar(client, Anthropic, o.tema, {idioma: o.idioma ?? 'español de México', modelo: o.modelo ?? MODELO_GUION, log: o.log ?? console.log});
|
|
337
|
+
} catch (e) {
|
|
338
|
+
if (e instanceof ErrorMorfeo) throw e;
|
|
339
|
+
if (e instanceof Anthropic.APIError) throw new ErrorMorfeo(`La búsqueda web falló (${e.status}): ${e.message}. Intenta sin --investigar.`);
|
|
340
|
+
throw e;
|
|
341
|
+
}
|
|
342
|
+
}
|
|
286
343
|
const idioma = o.idioma ?? 'español de México';
|
|
287
|
-
const
|
|
344
|
+
const pedidoBase = o.texto
|
|
288
345
|
? `Convierte este guion en escenas. Respeta su contenido y su orden; puedes partir frases largas.\n\n<guion>\n${o.texto}\n</guion>`
|
|
289
346
|
: `Escribe un video sobre: ${o.tema}`;
|
|
347
|
+
const pedido = investigacion
|
|
348
|
+
? `${pedidoBase}\n\nUsa SOLO estos datos investigados (no agregues cifras que no estén aquí):\n<investigacion>\n${investigacion.notas}\n</investigacion>`
|
|
349
|
+
: pedidoBase;
|
|
290
350
|
// ~45 s por defecto: los videos de referencia rondan el minuto y ninguna idea pasa de ~5 s (lib/estructura.mjs)
|
|
291
351
|
const forzada = o.formula ? formulaDe(o.formula) : null;
|
|
292
352
|
if (o.formula && !forzada) throw new ErrorMorfeo(`La fórmula "${o.formula}" no existe. Opciones: ${IDS_FORMULA.join(', ')}`);
|
|
@@ -304,7 +364,7 @@ export async function guionConIA(o) {
|
|
|
304
364
|
model: o.modelo ?? MODELO_GUION,
|
|
305
365
|
max_tokens: 16000,
|
|
306
366
|
system: motor ? INSTRUCCIONES_MOTOR : INSTRUCCIONES,
|
|
307
|
-
messages: [{role: 'user', content: `${pedido}\n\n${extra.join('\n')}\nEn "id" pon un identificador corto en minúsculas sin espacios. En "title" el título para publicar (con un emoji). En "music" el estilo de música que mejor le quede.`}],
|
|
367
|
+
messages: [{role: 'user', content: `${pedido}\n\n${extra.join('\n')}\nEn "id" pon un identificador corto en minúsculas sin espacios. En "title" el título para publicar (con un emoji). En "music" el estilo de música que mejor le quede. En "descripcion" el texto del post (1–2 frases). En "tags" 3–5 etiquetas sin #. En "hooks" 3 primeras frases alternativas al gancho, distintas entre sí, para probar otra versión.`}],
|
|
308
368
|
output_config: {format: zodOutputFormat(motor ? await esquemaMotor() : await esquema())},
|
|
309
369
|
});
|
|
310
370
|
} catch (e) {
|
|
@@ -321,7 +381,7 @@ export async function guionConIA(o) {
|
|
|
321
381
|
const scenes = g.scenes.map((e) => aEscenaMotor(e, !!o.imagenes));
|
|
322
382
|
const c = g.cierre;
|
|
323
383
|
const cta = !c || c.pide === 'sigue' ? {type: 'cta', ...(c?.say ? {say: c.say} : {})} : {type: 'cta', pide: c.pide, say: c.say, ...(c.palabra ? {palabra: c.palabra} : {}), ...(c.a ? {a: c.a} : {})};
|
|
324
|
-
return {id: slug(g.id || g.title), video: {estilo: o.estilo, formula: forzada?.id ?? g.formula, title: g.title, voice: o.voz ?? 'em_alex', music: g.music, scenes: [...scenes, cta]}};
|
|
384
|
+
return {id: slug(g.id || g.title), video: {estilo: o.estilo, formula: forzada?.id ?? g.formula, title: g.title, descripcion: g.descripcion, tags: g.tags, hooks: g.hooks, ...(investigacion?.fuentes.length ? {fuentes: investigacion.fuentes} : {}), voice: o.voz ?? 'em_alex', music: g.music, scenes: [...scenes, cta]}};
|
|
325
385
|
}
|
|
326
386
|
const scenes = g.scenes.map(aEscena);
|
|
327
387
|
if (scenes[0]?.type !== 'title') scenes.unshift({type: 'title', say: scenes[0]?.say ?? g.title, rows: [[g.title.toUpperCase().slice(0, 14), '#FFD400', 200]], icon: 'question'});
|
package/lib/hoja.mjs
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
// `morfeo hoja <id>`: hoja de contacto (PNG con cuadros repartidos en el tiempo) para revisar un video de un vistazo.
|
|
2
2
|
// El ffmpeg que trae Remotion viene recortado (sin los filtros `tile` ni `fps`), así que cada cuadro se pide como
|
|
3
3
|
// PNG sin predicción por una tubería y la hoja se arma aquí con zlib.
|
|
4
|
+
import {ZONAS} from './calidad.mjs';
|
|
4
5
|
import {spawnSync} from 'node:child_process';
|
|
5
6
|
import fs from 'node:fs';
|
|
6
7
|
import {createRequire} from 'node:module';
|
|
@@ -62,10 +63,11 @@ function leerPng(b, w, h) {
|
|
|
62
63
|
}
|
|
63
64
|
|
|
64
65
|
/**
|
|
65
|
-
* @param {{video: string, salida?: string, cols?: number, filas?: number, desde?: number, hasta?: number, ancho?: number}} op
|
|
66
|
+
* @param {{video: string, salida?: string, cols?: number, filas?: number, desde?: number, hasta?: number, ancho?: number, zona?: boolean}} op
|
|
67
|
+
* zona: pinta en rojo lo que tapan los botones de TikTok/Reels/Shorts
|
|
66
68
|
* @returns {string} ruta del PNG
|
|
67
69
|
*/
|
|
68
|
-
export function hoja({video, salida, cols = 5, filas = 3, desde = 0, hasta, ancho = 216}) {
|
|
70
|
+
export function hoja({video, salida, cols = 5, filas = 3, desde = 0, hasta, ancho = 216, zona = false}) {
|
|
69
71
|
// rutas absolutas: ffmpeg corre desde la carpeta de sus DLL, así que una ruta relativa no se encontraría
|
|
70
72
|
video = path.resolve(video);
|
|
71
73
|
if (salida) salida = path.resolve(salida);
|
|
@@ -87,6 +89,19 @@ export function hoja({video, salida, cols = 5, filas = 3, desde = 0, hasta, anch
|
|
|
87
89
|
const r = spawnSync(ffmpeg, ['-v', 'error', '-ss', t.toFixed(3), '-i', video, '-frames:v', '1', '-vf', `scale=${w}:${h}`, '-f', 'image2pipe', '-c:v', 'png', '-pred', 'none', '-pix_fmt', 'rgb24', '-'], {maxBuffer: 64 * 1024 * 1024, cwd});
|
|
88
90
|
if (r.status !== 0 || !r.stdout?.length) continue;
|
|
89
91
|
const px = leerPng(r.stdout, w, h);
|
|
92
|
+
if (zona) {
|
|
93
|
+
// tinte rojo en las zonas que tapan las apps (escala de 1080×1920 al tamaño del cuadro)
|
|
94
|
+
const ex = w / 1080;
|
|
95
|
+
const ey = h / 1920;
|
|
96
|
+
for (const z of ZONAS)
|
|
97
|
+
for (let y = Math.floor(z.y0 * ey); y < Math.min(h, Math.ceil(z.y1 * ey)); y++)
|
|
98
|
+
for (let x = Math.floor(z.x0 * ex); x < Math.min(w, Math.ceil(z.x1 * ex)); x++) {
|
|
99
|
+
const k = (y * w + x) * 3;
|
|
100
|
+
px[k] = Math.round(px[k] * 0.55 + 255 * 0.45);
|
|
101
|
+
px[k + 1] = Math.round(px[k + 1] * 0.55);
|
|
102
|
+
px[k + 2] = Math.round(px[k + 2] * 0.55);
|
|
103
|
+
}
|
|
104
|
+
}
|
|
90
105
|
const x0 = (i % cols) * w;
|
|
91
106
|
const y0 = Math.floor(i / cols) * h;
|
|
92
107
|
for (let y = 0; y < h; y++) px.copy(lienzo, (y0 + y) * (1 + W * 3) + 1 + x0 * 3, y * w * 3, (y + 1) * w * 3);
|
package/lib/palabras.mjs
CHANGED
|
@@ -35,7 +35,7 @@ function momentosDe(x, ruta, out) {
|
|
|
35
35
|
if (p) out.push([`${ruta}.${k}`, p]);
|
|
36
36
|
}
|
|
37
37
|
// lo que vive dentro de una capa: sus capas, su capa repetida, cambios, poses, items, líneas, movimientos
|
|
38
|
-
for (const k of ['capas', 'capa', 'cambios', 'poses', 'items', 'lineas', 'mover', 'escalar', 'opciones', 'capasPantalla'])
|
|
38
|
+
for (const k of ['capas', 'capa', 'cambios', 'poses', 'items', 'lineas', 'mover', 'escalar', 'opciones', 'capasPantalla', 'hitos', 'notificaciones'])
|
|
39
39
|
if (x[k] != null) momentosDe(x[k], `${ruta}.${k}`, out);
|
|
40
40
|
}
|
|
41
41
|
|
package/lib/render.mjs
CHANGED
|
@@ -213,7 +213,9 @@ export async function preparar({proyecto = process.cwd(), ids, log = console.log
|
|
|
213
213
|
* @param {{proyecto?: string, ids?: string[], todos?: boolean, log?: (m: string) => void, alProgreso?: (id: string, p: number) => void}} o
|
|
214
214
|
* @returns {Promise<{id: string, mp4: string, jpg: string | null, segundos: number}[]>}
|
|
215
215
|
*/
|
|
216
|
-
export async function render({proyecto = process.cwd(), ids, todos = false, soloFallidos = false, log = console.log, alProgreso} = {}) {
|
|
216
|
+
export async function render({proyecto = process.cwd(), ids, todos = false, soloFallidos = false, previa = null, log = console.log, alProgreso} = {}) {
|
|
217
|
+
// previa: solo el gancho (las primeras escenas, hasta `previa` segundos o ~8 s) en "<título> (previa).mp4", sin miniatura
|
|
218
|
+
if (previa && !ids?.length) throw new ErrorMorfeo('Dime qué video: npx morfeo previa mi-video');
|
|
217
219
|
cargarEnv(proyecto);
|
|
218
220
|
const p = await cargarProyecto(proyecto);
|
|
219
221
|
const loteArchivo = path.join(p.trabajo, 'ultimo-lote.json');
|
|
@@ -266,7 +268,10 @@ export async function render({proyecto = process.cwd(), ids, todos = false, solo
|
|
|
266
268
|
const t = Date.now();
|
|
267
269
|
try {
|
|
268
270
|
const inputProps = {videos: {[id]: datos[id]}, marca, ...extras};
|
|
269
|
-
const mp4 = salidaDe(p, id);
|
|
271
|
+
const mp4 = previa ? salidaDe(p, id).replace(/\.mp4$/, ' (previa).mp4') : salidaDe(p, id);
|
|
272
|
+
// tramo de la previa: hasta el final de la 2.ª escena (gancho + la que sigue), entre 4 y 8 s, o lo que se pida
|
|
273
|
+
const d0 = datos[id];
|
|
274
|
+
const finPrevia = typeof previa === 'number' ? previa : Math.min(8, Math.max(4, (d0.escenas[1] ?? d0.escenas[0]).t0 + (d0.escenas[1] ?? d0.escenas[0]).dur));
|
|
270
275
|
let gl = p.render.gl;
|
|
271
276
|
let concurrencia = concurrenciaBase;
|
|
272
277
|
let composition;
|
|
@@ -289,6 +294,7 @@ export async function render({proyecto = process.cwd(), ids, todos = false, solo
|
|
|
289
294
|
jpegQuality: 92,
|
|
290
295
|
concurrency: concurrencia,
|
|
291
296
|
outputLocation: mp4,
|
|
297
|
+
...(previa ? {frameRange: [0, Math.min(composition.durationInFrames - 1, Math.round(finPrevia * composition.fps))]} : {}),
|
|
292
298
|
onProgress: ({progress}) => {
|
|
293
299
|
alProgreso?.(id, progress);
|
|
294
300
|
// con terminal: cada 10 % en la misma línea; sin terminal (logs, CI): cada 25 %
|
|
@@ -314,7 +320,7 @@ export async function render({proyecto = process.cwd(), ids, todos = false, solo
|
|
|
314
320
|
}
|
|
315
321
|
}
|
|
316
322
|
let jpg = null;
|
|
317
|
-
if (p.render.miniatura) {
|
|
323
|
+
if (p.render.miniatura && !previa) {
|
|
318
324
|
// miniatura: cuando la primera escena ya está completa (o en `miniaturaEn`), sin subtítulos
|
|
319
325
|
jpg = salidaDe(p, id, 'jpg');
|
|
320
326
|
const d = datos[id];
|
|
@@ -324,6 +330,17 @@ export async function render({proyecto = process.cwd(), ids, todos = false, solo
|
|
|
324
330
|
const props = {...inputProps, miniatura: true};
|
|
325
331
|
await renderStill({serveUrl, composition: {...composition, props: {...composition.props, miniatura: true}}, inputProps: props, browserExecutable, chromiumOptions: {gl}, frame, imageFormat: 'jpeg', jpegQuality: 92, output: jpg});
|
|
326
332
|
}
|
|
333
|
+
// portada: el mismo cuadro con el título grande y la marca (`portada: 'texto'` o el título sin emojis; false = sin portada)
|
|
334
|
+
const vid = p.videos[id];
|
|
335
|
+
if (!previa && p.render.portada !== false && vid.portada !== false && jpg) {
|
|
336
|
+
const textoPortada = typeof vid.portada === 'string' ? vid.portada : String(vid.title ?? id).replace(/[\p{Extended_Pictographic}\uFE0F\u200D]/gu, '').replace(/\s*[|·].*$/, '').trim();
|
|
337
|
+
const d = datos[id];
|
|
338
|
+
const e0 = d.escenas[0];
|
|
339
|
+
const seg = vid.miniaturaEn ?? Math.min(e0.dur - 0.15, Math.max(1.4, e0.habla[1] * 0.85));
|
|
340
|
+
const frame = Math.max(0, Math.min(composition.durationInFrames - 1, Math.round(seg * composition.fps)));
|
|
341
|
+
const props = {...inputProps, portada: textoPortada};
|
|
342
|
+
await renderStill({serveUrl, composition: {...composition, props: {...composition.props, portada: textoPortada}}, inputProps: props, browserExecutable, chromiumOptions: {gl}, frame, imageFormat: 'jpeg', jpegQuality: 92, output: salidaDe(p, id, 'jpg').replace(/\.jpg$/, ' (portada).jpg')});
|
|
343
|
+
}
|
|
327
344
|
const segundos = Math.round((Date.now() - t) / 1000);
|
|
328
345
|
if (process.stdout.isTTY) process.stdout.write('\r');
|
|
329
346
|
log(`[${i + 1}/${listos.length}] ${id}: listo en ${segundos} s → ${path.relative(p.proyecto, mp4)} `);
|
|
@@ -337,6 +354,7 @@ export async function render({proyecto = process.cwd(), ids, todos = false, solo
|
|
|
337
354
|
} finally {
|
|
338
355
|
soltar();
|
|
339
356
|
}
|
|
357
|
+
if (previa) return hechos;
|
|
340
358
|
return terminarLote(p, loteArchivo, hechos, fallidos, log);
|
|
341
359
|
}
|
|
342
360
|
|
|
@@ -438,7 +456,18 @@ function escribirResumen(p) {
|
|
|
438
456
|
const v = p.videos[f.id];
|
|
439
457
|
const tags = (v.tags ?? []).map((x) => `#${String(x).replace(/^#/, '').replace(/\s+/g, '')}`).join(' ');
|
|
440
458
|
const creditos = f.creditos?.length ? ['', 'Créditos:', ...f.creditos.map((c) => `- ${c}`)] : [];
|
|
441
|
-
const
|
|
459
|
+
const fuentes = (v.fuentes ?? []).map((x) => (typeof x === 'string' ? `- ${x}` : `- ${x.texto ?? ''}${x.url ? ` · ${x.url}` : ''}`));
|
|
460
|
+
const ganchos = (v.hooks ?? []).map((h, i) => `${i + 1}. ${h}`);
|
|
461
|
+
const lineas = [
|
|
462
|
+
v.title ?? f.id,
|
|
463
|
+
'',
|
|
464
|
+
...(v.descripcion ? [v.descripcion, ''] : []),
|
|
465
|
+
...(tags ? [tags] : []),
|
|
466
|
+
...(fuentes.length ? ['', 'Fuentes:', ...fuentes] : []),
|
|
467
|
+
...creditos,
|
|
468
|
+
// lo de abajo no va en el post: ganchos para probar otra versión del inicio
|
|
469
|
+
...(ganchos.length ? ['', '———', 'Ganchos de reserva (para otra versión):', ...ganchos] : []),
|
|
470
|
+
];
|
|
442
471
|
fs.writeFileSync(salidaDe(p, f.id, 'txt'), lineas.join('\n').trim() + '\n');
|
|
443
472
|
}
|
|
444
473
|
if (conCreditos.length)
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "morfeo",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.5.0",
|
|
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",
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
# Instrucciones para la IA (Claude Code, Codex u otra)
|
|
2
|
+
|
|
3
|
+
Este es un proyecto de **Morfeo**: videos verticales animados (Shorts, TikTok, Reels) desde código. Tú eres quien
|
|
4
|
+
los hace: la persona te pide un tema y tú le devuelves el video terminado. Habla con ella en su idioma, sin jerga
|
|
5
|
+
técnica. Nunca publiques nada por ella: le entregas los archivos y ella decide dónde subirlos.
|
|
6
|
+
|
|
7
|
+
Comandos de referencia: `npx morfeo ayuda`. Tipos de todo lo que existe: `node_modules/morfeo/index.d.ts`.
|
|
8
|
+
|
|
9
|
+
## Cuando diga «Configúrame» (o es la primera vez)
|
|
10
|
+
|
|
11
|
+
1. Revisa que haya Node 20+ (`node -v`). Si falta, dile cómo instalarlo y espera.
|
|
12
|
+
2. `npm install` en esta carpeta.
|
|
13
|
+
3. `npx morfeo instalar --si` (Python, voces gratis, navegador; tarda unos minutos la primera vez). Si algo falla,
|
|
14
|
+
lee el mensaje, arréglalo y vuelve a correrlo. `npx morfeo doctor` dice qué falta.
|
|
15
|
+
4. `npx morfeo estilo`: abre una página para que elija nombre, siglas, colores, letra, voz, ritmo y frase de cierre.
|
|
16
|
+
Espera a que diga que guardó (crea `estilo.json`).
|
|
17
|
+
5. Si quiere su voz de ElevenLabs: que pegue su clave en `.env` como `ELEVENLABS_API_KEY=…` (cópialo de `.env.example`;
|
|
18
|
+
nunca escribas tú la clave ni la pongas en el chat). Para investigar con Claude desde la terminal, `ANTHROPIC_API_KEY`.
|
|
19
|
+
6. Haz un video de prueba (`npx morfeo render ejemplo-2d`) y dile dónde quedó.
|
|
20
|
+
|
|
21
|
+
## Cuando diga «Hazme un video sobre…»
|
|
22
|
+
|
|
23
|
+
1. **Investiga.** Busca los datos en la web y quédate solo con lo que puedas respaldar con una fuente (medio,
|
|
24
|
+
institución, URL). Si no puedes buscar, usa `npx morfeo guion "tema" --investigar` (necesita `ANTHROPIC_API_KEY`).
|
|
25
|
+
Si dudas de una cifra, dila en general o quítala.
|
|
26
|
+
2. **Elige la fórmula** que mejor le queda al tema: `npx morfeo formulas` (construiste, mito, misterio, serie,
|
|
27
|
+
biografía, experimento, demuéstralo, roast…). Si hay una base para esa fórmula, empieza de ahí:
|
|
28
|
+
`npx morfeo nuevo-video <id> --formula <fórmula>` (bases: serie, mito, escalada, biografia, construiste; o
|
|
29
|
+
`--look 2d` / `--look 3d`). Si no, `npx morfeo guion "tema" --formula <fórmula>` o escribe el guion a mano en
|
|
30
|
+
`guiones/<id>.mjs` (mira los ejemplos de `guiones/`).
|
|
31
|
+
3. **Escribe el guion** siguiendo la fórmula y estas reglas:
|
|
32
|
+
- Gancho en los primeros 2 s, en segunda persona o con una cifra imposible. Nada de "hoy te voy a contar".
|
|
33
|
+
- Una idea por escena (2–5 s de voz). Pregunta antes de revelar. Sube la apuesta hacia el final.
|
|
34
|
+
- `say` es lo que dice la voz (cifras con letra); `show` el subtítulo con cifras.
|
|
35
|
+
- Rótulos cortos en 2–4 escenas (`rotulo`, con `rotuloEstilo` sello/paso/pixel si va), `claves` en cada escena.
|
|
36
|
+
- Cierre que pide algo: `{type: 'cta', pide: 'comenta', palabra: 'X'}` o `pide: 'comparte', a: 'tu amigo que…'`.
|
|
37
|
+
- `title`, `descripcion`, `tags`, `hooks` (3 ganchos de reserva) y `fuentes` (cada dato con su fuente).
|
|
38
|
+
4. **Enséñale el guion** (texto de cada escena, en limpio) y espera su visto bueno o sus cambios.
|
|
39
|
+
5. **Enséñale el gancho:** `npx morfeo previa <id>` renderiza solo los primeros segundos. Si no le convence, cámbialo
|
|
40
|
+
(tiene 3 ganchos de reserva en `hooks`).
|
|
41
|
+
6. **Renderiza:** `npx morfeo render <id>`.
|
|
42
|
+
7. **Revisa antes de entregar:**
|
|
43
|
+
- `npx morfeo revisar <id>`: estructura, que la voz haya dicho el guion y que nada quede bajo los botones de la app.
|
|
44
|
+
- `npx morfeo hoja <id> --zona`: mira la hoja de contacto (es una imagen); lo pintado en rojo es lo que tapan las apps.
|
|
45
|
+
- Si algo se ve mal (texto encimado, objeto cortado, cuadro vacío), arréglalo y vuelve a renderizar.
|
|
46
|
+
8. **Entrega:** en `salida/` quedan `<título>.mp4`, `<título>.jpg` (miniatura), `<título> (portada).jpg` y
|
|
47
|
+
`<título>.txt` (texto del post, etiquetas, fuentes y ganchos de reserva). Dile eso y nada más.
|
|
48
|
+
|
|
49
|
+
## Reglas
|
|
50
|
+
|
|
51
|
+
- No inventes datos. No copies videos de otros: se toma la forma, no el contenido.
|
|
52
|
+
- Emojis solo como último recurso; mejor objetos 2D/3D (`{tipo: 'recurso', buscar: 'volcán', estilo: '3d'}`).
|
|
53
|
+
- Un render a la vez. Si un render falla, lee el error (Morfeo explica qué falta) y corrígelo.
|
|
54
|
+
- No toques `node_modules/`. Lo de la persona vive en `guiones/`, `escenas/`, `assets/`, `estilo.json` y `morfeo.config.mjs`.
|
package/python/musica.py
CHANGED
|
@@ -179,11 +179,97 @@ def efecto(nombre, rng):
|
|
|
179
179
|
x = 0.22 * pop(180, 90, 0.16)
|
|
180
180
|
add(x, 0.1 * lp(rng.standard_normal(int(0.06 * SR)), 1200), 0)
|
|
181
181
|
return x
|
|
182
|
+
if nombre in EFECTOS_EXTRA: return EFECTOS_EXTRA[nombre](rng)
|
|
182
183
|
return 0.14 * pop()
|
|
183
184
|
|
|
184
185
|
|
|
186
|
+
def _env(n, ataque=0.004, cola=6.0):
|
|
187
|
+
t = np.arange(n) / SR
|
|
188
|
+
return np.minimum(1, t / ataque) * np.exp(-t * cola)
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
def _tono(f, d, cola=8.0, armonicos=(1,)):
|
|
192
|
+
n = int(d * SR); t = np.arange(n) / SR
|
|
193
|
+
x = sum(np.sin(2 * np.pi * f * h * t) / (i + 1) for i, h in enumerate(armonicos))
|
|
194
|
+
return x * _env(n, 0.003, cola)
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
def _teclas(rng):
|
|
198
|
+
"""Ráfaga de teclas de máquina: 7 clics secos con espaciado irregular."""
|
|
199
|
+
x = np.zeros(int(0.9 * SR)); t = 0.0
|
|
200
|
+
for _ in range(7):
|
|
201
|
+
c = hp(rng.standard_normal(int(0.012 * SR)), 2500) * _env(int(0.012 * SR), 0.0005, 300)
|
|
202
|
+
add(x, 0.14 * c, t); t += 0.07 + rng.random() * 0.07
|
|
203
|
+
return x
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
def _radio(rng):
|
|
207
|
+
"""Estática de radio con un pitido de sintonía."""
|
|
208
|
+
n = int(0.7 * SR)
|
|
209
|
+
ruido = hp(lp(rng.standard_normal(n), 3500), 400) * 0.25 * np.minimum(1, np.arange(n) / (0.03 * SR)) * np.linspace(1, 0.2, n)
|
|
210
|
+
add(ruido, 0.08 * _tono(1200, 0.25, 4), 0.2)
|
|
211
|
+
return ruido
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
def _obturador(rng):
|
|
215
|
+
"""Obturador de cámara: dos clics mecánicos."""
|
|
216
|
+
x = np.zeros(int(0.25 * SR))
|
|
217
|
+
for t, f in ((0.0, 1800), (0.07, 1300)):
|
|
218
|
+
c = lp(hp(rng.standard_normal(int(0.02 * SR)), 900), f * 3) * _env(int(0.02 * SR), 0.0004, 220)
|
|
219
|
+
add(x, 0.5 * c, t)
|
|
220
|
+
return x
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
def _notificacion(rng):
|
|
224
|
+
"""Notificación del teléfono: dos notas que suben."""
|
|
225
|
+
x = np.zeros(int(0.5 * SR))
|
|
226
|
+
add(x, 0.18 * _tono(nf(84), 0.25, 14, (1, 2)), 0)
|
|
227
|
+
add(x, 0.18 * _tono(nf(91), 0.35, 10, (1, 2)), 0.11)
|
|
228
|
+
return x
|
|
229
|
+
|
|
230
|
+
|
|
231
|
+
def _latido(rng):
|
|
232
|
+
"""Latido: dos golpes graves (lub-dub)."""
|
|
233
|
+
x = np.zeros(int(0.7 * SR))
|
|
234
|
+
add(x, 0.5 * _tono(55, 0.18, 22), 0)
|
|
235
|
+
add(x, 0.35 * _tono(48, 0.16, 24), 0.2)
|
|
236
|
+
return lp(x, 300)
|
|
237
|
+
|
|
238
|
+
|
|
239
|
+
def _alarma(rng):
|
|
240
|
+
"""Alarma: tres pitidos cuadrados."""
|
|
241
|
+
x = np.zeros(int(0.9 * SR))
|
|
242
|
+
for i in range(3):
|
|
243
|
+
n = int(0.16 * SR); t = np.arange(n) / SR
|
|
244
|
+
b = np.sign(np.sin(2 * np.pi * 1600 * t)) * 0.09 * np.minimum(1, (n - np.arange(n)) / (0.01 * SR))
|
|
245
|
+
add(x, lp(b, 5000), i * 0.27)
|
|
246
|
+
return x
|
|
247
|
+
|
|
248
|
+
|
|
249
|
+
def _reloj(rng):
|
|
250
|
+
"""Tic-tac de reloj: tic agudo y tac grave."""
|
|
251
|
+
x = np.zeros(int(1.0 * SR))
|
|
252
|
+
for i in range(2):
|
|
253
|
+
add(x, 0.45 * tick(0.02), i * 0.5)
|
|
254
|
+
add(x, 0.3 * lp(tick(0.025), 1500), i * 0.5 + 0.25)
|
|
255
|
+
return x
|
|
256
|
+
|
|
257
|
+
|
|
258
|
+
def _caja(rng):
|
|
259
|
+
"""Caja registradora: campanita y cajón que se abre."""
|
|
260
|
+
x = np.zeros(int(0.9 * SR))
|
|
261
|
+
add(x, 0.25 * bell(nf(96), 0.7), 0.0)
|
|
262
|
+
cajon = lp(rng.standard_normal(int(0.25 * SR)), 900) * _env(int(0.25 * SR), 0.01, 10) * 0.25
|
|
263
|
+
add(x, cajon, 0.12)
|
|
264
|
+
return x
|
|
265
|
+
|
|
266
|
+
|
|
267
|
+
EFECTOS_EXTRA = {"teclas": _teclas, "radio": _radio, "obturador": _obturador, "notificacion": _notificacion, "latido": _latido, "alarma": _alarma, "reloj": _reloj, "caja": _caja}
|
|
268
|
+
|
|
269
|
+
|
|
185
270
|
# 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"
|
|
271
|
+
SONIDO_TIPO = {"sello": "golpe", "dato": "tick", "contador": "tick", "nombre": "pop", "accion": "pop", "chip": "pop", "insignia": "pop",
|
|
272
|
+
"bloqueo": "notificacion", "llamada": "notificacion", "vigilancia": "radio", "terminal": "teclas", "calendario": "reloj", "fecha": "golpe", "noticia": "radio", "recuento": "tick", "cronometro": "reloj"}
|
|
187
273
|
SONIDO_ENTRADA = {"rebote": "pop", "caer": "golpe", "deslizar-izq": "whoosh", "deslizar-der": "whoosh", "deslizar-arriba": "whoosh", "deslizar-abajo": "whoosh", "girar": "whoosh"}
|
|
188
274
|
|
|
189
275
|
|
package/remotion/Short.tsx
CHANGED
|
@@ -27,10 +27,11 @@ import {MARCA_VACIA, type Datos, type Escena, type Marca as TipoMarca} from './t
|
|
|
27
27
|
import {Capas} from './motor/capas';
|
|
28
28
|
import {capasDe} from './motor/plantillas';
|
|
29
29
|
import {temaDe} from './motor/tema';
|
|
30
|
+
import {Portada} from './motor/portada';
|
|
30
31
|
import {VideoConAudio} from './motor/Video';
|
|
31
32
|
import {loadFont} from '@remotion/fonts';
|
|
32
33
|
|
|
33
|
-
export type PropsShort = {id: string; videos?: Record<string, Datos>; marca?: TipoMarca; guias?: boolean; miniatura?: boolean; fuentes?: {familia: string; url: string}[]; estilos?: Record<string, any>};
|
|
34
|
+
export type PropsShort = {id: string; videos?: Record<string, Datos>; marca?: TipoMarca; guias?: boolean; miniatura?: boolean; portada?: string; fuentes?: {familia: string; url: string}[]; estilos?: Record<string, any>};
|
|
34
35
|
|
|
35
36
|
const ADELANTO_GANCHO = 0.4; // el gancho arranca ya animado: el fotograma 0 nunca está vacío
|
|
36
37
|
const CIERRE_BUCLE = 0.5; // al final se funde con el fotograma 0 para que el video se repita sin corte
|
|
@@ -246,7 +247,7 @@ export const Fotograma: React.FC<{d: Datos; T: number; guias?: boolean; miniatur
|
|
|
246
247
|
);
|
|
247
248
|
};
|
|
248
249
|
|
|
249
|
-
export const Short: React.FC<PropsShort> = ({id, videos, marca, guias, miniatura, fuentes, estilos}) => {
|
|
250
|
+
export const Short: React.FC<PropsShort> = ({id, videos, marca, guias, miniatura, portada, fuentes, estilos}) => {
|
|
250
251
|
const frame = useCurrentFrame();
|
|
251
252
|
const [listo, setListo] = useState(false);
|
|
252
253
|
const [handle] = useState(() => delayRender('Cargando fuentes'));
|
|
@@ -259,6 +260,13 @@ export const Short: React.FC<PropsShort> = ({id, videos, marca, guias, miniatura
|
|
|
259
260
|
const d = videos?.[id];
|
|
260
261
|
if (!d) throw new Error(`El video "${id}" no está preparado. Corre: npx morfeo render ${id}`);
|
|
261
262
|
if (!listo) return null;
|
|
263
|
+
if (portada)
|
|
264
|
+
return (
|
|
265
|
+
<AbsoluteFill>
|
|
266
|
+
{d.estilo && d.estilo !== 'datos-curiosos' ? <VideoConAudio d={d} frame={frame} marca={{...MARCA_VACIA, ...marca}} estilos={estilos} miniatura sinMarca /> : <Fotograma d={d} T={frame / FPS} miniatura />}
|
|
267
|
+
<Portada texto={portada} marca={{...MARCA_VACIA, ...marca}} />
|
|
268
|
+
</AbsoluteFill>
|
|
269
|
+
);
|
|
262
270
|
if (d.estilo && d.estilo !== 'datos-curiosos') return <VideoConAudio d={d} frame={frame} marca={{...MARCA_VACIA, ...marca}} estilos={estilos} miniatura={miniatura} />;
|
|
263
271
|
const T = frame / FPS;
|
|
264
272
|
const kb = clamp((T - (d.duracion - CIERRE_BUCLE)) / CIERRE_BUCLE);
|
package/remotion/motor/Video.tsx
CHANGED
|
@@ -383,7 +383,7 @@ const MarcaEsquina: React.FC<{tema: Tema}> = ({tema}) => {
|
|
|
383
383
|
);
|
|
384
384
|
};
|
|
385
385
|
|
|
386
|
-
export const VideoMotor: React.FC<{d: Datos; T: number; estilos?: Record<string, any>; miniatura?: boolean}> = ({d, T, estilos, miniatura}) => {
|
|
386
|
+
export const VideoMotor: React.FC<{d: Datos; T: number; estilos?: Record<string, any>; miniatura?: boolean; sinMarca?: boolean}> = ({d, T, estilos, miniatura, sinMarca}) => {
|
|
387
387
|
const tema = temaDe(d.estilo, d.tema, estilos);
|
|
388
388
|
const n = d.escenas.length;
|
|
389
389
|
let si = d.escenas.findIndex((e) => T < e.t0 + e.dur);
|
|
@@ -459,18 +459,18 @@ export const VideoMotor: React.FC<{d: Datos; T: number; estilos?: Record<string,
|
|
|
459
459
|
<Vineta fuerza={tema.vineta ?? 0} color={tema.post?.vinetaColor} />
|
|
460
460
|
{d.hud?.length ? <Hud d={d} T={T} tema={tema} /> : null}
|
|
461
461
|
{d.serie && e.tipo !== 'cta' ? <Serie texto={d.serie} tema={tema} /> : null}
|
|
462
|
-
{e.tipo !== 'cta' ? <Rotulo e={e} t={t} tema={e.p.tema ? mezclar(tema, e.p.tema) : tema} /> : null}
|
|
462
|
+
{e.tipo !== 'cta' && !sinMarca ? <Rotulo e={e} t={t} tema={e.p.tema ? mezclar(tema, e.p.tema) : tema} /> : null}
|
|
463
463
|
{e.tipo !== 'cta' && !miniatura ? <Subtitulos tema={e.p.tema ? mezclar(tema, e.p.tema) : tema} palabras={e.palabras ?? []} t={t} dur={e.dur} claves={e.p.claves} /> : null}
|
|
464
|
-
{e.tipo !== 'cta' ? <MarcaEsquina tema={tema} /> : null}
|
|
464
|
+
{e.tipo !== 'cta' && !sinMarca ? <MarcaEsquina tema={tema} /> : null}
|
|
465
465
|
</AbsoluteFill>
|
|
466
466
|
);
|
|
467
467
|
};
|
|
468
468
|
|
|
469
469
|
/** Componente raíz de un video del motor de capas (con su audio). */
|
|
470
|
-
export const VideoConAudio: React.FC<{d: Datos; frame: number; marca: any; estilos?: Record<string, any>; miniatura?: boolean}> = ({d, frame, marca, estilos, miniatura}) => (
|
|
470
|
+
export const VideoConAudio: React.FC<{d: Datos; frame: number; marca: any; estilos?: Record<string, any>; miniatura?: boolean; sinMarca?: boolean}> = ({d, frame, marca, estilos, miniatura, sinMarca}) => (
|
|
471
471
|
<MarcaContexto.Provider value={marca}>
|
|
472
472
|
<AbsoluteFill>
|
|
473
|
-
<VideoMotor d={d} T={frame / FPS} estilos={estilos} miniatura={miniatura} />
|
|
473
|
+
<VideoMotor d={d} T={frame / FPS} estilos={estilos} miniatura={miniatura} sinMarca={sinMarca} />
|
|
474
474
|
<Audio src={staticFile(d.audio)} />
|
|
475
475
|
</AbsoluteFill>
|
|
476
476
|
</MarcaContexto.Provider>
|
|
@@ -367,9 +367,13 @@ export const Tierra: React.FC<{c: C; ctx: CtxEscena}> = ({c, ctx}) => {
|
|
|
367
367
|
const pin = clamp((t - (c.girar === false ? 0.2 : (c.duracion ?? 1.8) * 0.85)) / 0.35);
|
|
368
368
|
const X = cx + px * R;
|
|
369
369
|
const Y = cx - py * R;
|
|
370
|
+
// `acercar: 4`: después del pin, la cámara vuela hacia el sitio (el globo crece alrededor del pin)
|
|
371
|
+
const tPin = c.girar === false ? 0.2 : (c.duracion ?? 1.8) * 0.85;
|
|
372
|
+
const vuelo = c.acercar ? easeIO(clamp((t - tPin - 0.6) / (c.duracionVuelo ?? 1.2))) : 0;
|
|
373
|
+
const zoom = 1 + ((c.acercar ?? 1) - 1) * vuelo;
|
|
370
374
|
const color = c.color ?? '#E5484D';
|
|
371
375
|
return (
|
|
372
|
-
<div style={{position: 'relative', width: tam, height: tam}}>
|
|
376
|
+
<div style={{position: 'relative', width: tam, height: tam, transform: `scale(${zoom})`, transformOrigin: `${X}px ${Y}px`}}>
|
|
373
377
|
<svg width={tam} height={tam} style={{overflow: 'visible'}}>
|
|
374
378
|
<defs>
|
|
375
379
|
<radialGradient id="tierra-mar" cx="38%" cy="32%" r="75%">
|
|
@@ -390,7 +394,7 @@ export const Tierra: React.FC<{c: C; ctx: CtxEscena}> = ({c, ctx}) => {
|
|
|
390
394
|
<circle cx={cx} cy={cx} r={R} fill="url(#tierra-mar)" />
|
|
391
395
|
<g clipPath="url(#tierra-disco)">
|
|
392
396
|
{caminos.map((d, i) => (
|
|
393
|
-
<path key={i} d={d} fill="#6FBF73" stroke="#4E9A55" strokeWidth={1.2} strokeLinejoin="round" />
|
|
397
|
+
<path key={i} d={d} fill="#6FBF73" stroke="#4E9A55" strokeWidth={1.2} strokeLinejoin="round" vectorEffect="non-scaling-stroke" />
|
|
394
398
|
))}
|
|
395
399
|
</g>
|
|
396
400
|
<circle cx={cx} cy={cx} r={R} fill="url(#tierra-luz)" />
|
|
@@ -411,9 +415,48 @@ export const Tierra: React.FC<{c: C; ctx: CtxEscena}> = ({c, ctx}) => {
|
|
|
411
415
|
);
|
|
412
416
|
};
|
|
413
417
|
|
|
414
|
-
|
|
418
|
+
/**
|
|
419
|
+
* Línea de tiempo vertical: la línea se dibuja hacia abajo y cada hito entra en su palabra (`en`), con el año en
|
|
420
|
+
* grande y su texto. `hitos: [{anio: 1963, texto: 'Nace en Taiwán', en: 'nació'}]`, `color`, `ancho`. El último que
|
|
421
|
+
* ya entró queda resaltado.
|
|
422
|
+
*/
|
|
423
|
+
export const Linea: React.FC<{c: C; ctx: CtxEscena}> = ({c, ctx}) => {
|
|
424
|
+
const hitos: C[] = c.hitos ?? [];
|
|
425
|
+
const color = c.color ?? ctx.tema.colores.acento;
|
|
426
|
+
const paso = c.paso ?? 190;
|
|
427
|
+
const tiempos = hitos.map((h, i) => cuando(h.en, ctx, 0.3 + i * 0.8));
|
|
428
|
+
const visibles = tiempos.filter((t0) => ctx.t >= t0).length;
|
|
429
|
+
const ultimo = visibles - 1;
|
|
430
|
+
// la línea llega hasta el último hito que ya entró (y avanza mientras entra)
|
|
431
|
+
const kUlt = ultimo >= 0 ? easeOut(clamp((ctx.t - tiempos[ultimo]) / 0.4)) : 0;
|
|
432
|
+
const largo = ultimo >= 0 ? (ultimo - 1 + kUlt) * paso + paso * 0.15 : 0;
|
|
433
|
+
return (
|
|
434
|
+
<div style={{position: 'relative', width: c.ancho ?? 820, height: hitos.length * paso, fontFamily: 'Montserrat, sans-serif'}}>
|
|
435
|
+
<div style={{position: 'absolute', left: 38, top: 30, width: 8, height: (hitos.length - 1) * paso, borderRadius: 4, background: 'rgba(255,255,255,0.18)'}} />
|
|
436
|
+
<div style={{position: 'absolute', left: 38, top: 30, width: 8, height: Math.max(0, largo + paso * 0.85), borderRadius: 4, background: color}} />
|
|
437
|
+
{hitos.map((h, i) => {
|
|
438
|
+
const k = easeOut(clamp((ctx.t - tiempos[i]) / 0.4));
|
|
439
|
+
if (k <= 0) return null;
|
|
440
|
+
const actual = i === ultimo;
|
|
441
|
+
return (
|
|
442
|
+
<div key={i} style={{position: 'absolute', left: 0, top: i * paso, display: 'flex', alignItems: 'flex-start', gap: 34, opacity: k, transform: `translateX(${(1 - k) * 40}px)`}}>
|
|
443
|
+
<div style={{width: 84, height: 84, borderRadius: '50%', background: actual ? color : '#1B1B1F', border: `6px solid ${color}`, boxShadow: actual ? `0 0 24px ${color}` : undefined, transform: `scale(${resorte(k, 0.3)})`, flex: 'none'}} />
|
|
444
|
+
<div style={{paddingTop: 2}}>
|
|
445
|
+
<div style={{color: actual ? color : '#FFFFFF', fontWeight: 900, fontSize: 64, lineHeight: 1}}>{h.anio ?? h.titulo}</div>
|
|
446
|
+
{h.texto ? <div style={{color: '#FFFFFF', fontWeight: 700, fontSize: 36, marginTop: 6, maxWidth: (c.ancho ?? 820) - 140, textShadow: '0 2px 10px rgba(0,0,0,0.5)'}}>{h.texto}</div> : null}
|
|
447
|
+
</div>
|
|
448
|
+
</div>
|
|
449
|
+
);
|
|
450
|
+
})}
|
|
451
|
+
</div>
|
|
452
|
+
);
|
|
453
|
+
};
|
|
454
|
+
|
|
455
|
+
export const HISTORIAS = ['linea', 'tierra', 'bloqueo', 'llamada', 'recuento', 'capitulo', 'vigilancia', 'cintillo', 'fecha', 'calendario', 'dividida'];
|
|
415
456
|
export const Historias: React.FC<{c: C; ctx: CtxEscena}> = ({c, ctx}) => {
|
|
416
457
|
switch (c.tipo) {
|
|
458
|
+
case 'linea':
|
|
459
|
+
return <Linea c={c} ctx={ctx} />;
|
|
417
460
|
case 'tierra':
|
|
418
461
|
return <Tierra c={c} ctx={ctx} />;
|
|
419
462
|
case 'bloqueo':
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
// Portada del video (salida/<título> (portada).jpg): el cuadro de la miniatura con el título grande abajo, la palabra
|
|
2
|
+
// clave en color y la marca arriba. Distinta de la miniatura (que es el cuadro limpio): la portada es la que se ve en
|
|
3
|
+
// el perfil y en la cuadrícula del canal, así que el título tiene que leerse en chiquito.
|
|
4
|
+
import React from 'react';
|
|
5
|
+
import {AbsoluteFill} from 'remotion';
|
|
6
|
+
import {ANTON, ancho} from '../estilo';
|
|
7
|
+
import {Avatar, MarcaContexto} from '../piezas';
|
|
8
|
+
import type {Marca} from '../tipos';
|
|
9
|
+
import {partesResaltadas} from './util';
|
|
10
|
+
|
|
11
|
+
export const Portada: React.FC<{texto: string; marca: Marca; acento?: string}> = ({texto, marca, acento = marca.color ?? '#FFD400'}) => {
|
|
12
|
+
const letra = marca.letra ?? ANTON;
|
|
13
|
+
const limpio = texto.replace(/\*/g, '').toUpperCase();
|
|
14
|
+
// sin *palabra*, se resalta la última palabra larga
|
|
15
|
+
const marcado = texto.includes('*') ? texto : texto.replace(/([^\s]{5,})(?![\s\S]*\s[^\s]{5,})/, '*$1*');
|
|
16
|
+
let tam = 150;
|
|
17
|
+
try {
|
|
18
|
+
// en 2–3 renglones de 940 px
|
|
19
|
+
const w = ancho(limpio, letra, tam, 400);
|
|
20
|
+
if (w > 940 * 2.6) tam = Math.max(84, Math.floor((tam * 940 * 2.6) / w));
|
|
21
|
+
} catch {}
|
|
22
|
+
return (
|
|
23
|
+
<MarcaContexto.Provider value={marca}>
|
|
24
|
+
<AbsoluteFill>
|
|
25
|
+
<AbsoluteFill style={{background: 'linear-gradient(180deg, rgba(0,0,0,0.35) 0%, rgba(0,0,0,0) 22%, rgba(0,0,0,0) 45%, rgba(0,0,0,0.82) 78%, rgba(0,0,0,0.92) 100%)'}} />
|
|
26
|
+
{marca.nombre || marca.logo ? (
|
|
27
|
+
<div style={{position: 'absolute', left: 56, top: 110, display: 'flex', alignItems: 'center', gap: 18}}>
|
|
28
|
+
<div style={{position: 'relative', width: 96, height: 96}}>
|
|
29
|
+
<Avatar x={0} y={0} size={88} aro={5} />
|
|
30
|
+
</div>
|
|
31
|
+
{marca.nombre ? <div style={{fontFamily: 'Montserrat, sans-serif', fontWeight: 900, fontSize: 44, color: '#FFFFFF', textShadow: '0 3px 12px rgba(0,0,0,0.6)'}}>{marca.nombre}</div> : null}
|
|
32
|
+
</div>
|
|
33
|
+
) : null}
|
|
34
|
+
<div style={{position: 'absolute', left: 60, right: 60, bottom: 230, fontFamily: letra, fontSize: tam, lineHeight: 1.02, color: '#FFFFFF', textTransform: 'uppercase', textShadow: '0 8px 30px rgba(0,0,0,0.6)', WebkitTextStroke: `${Math.round(tam * 0.04)}px #000`, paintOrder: 'stroke fill'}}>
|
|
35
|
+
{partesResaltadas(marcado).map(([p, r], i) => (
|
|
36
|
+
<span key={i} style={r ? {color: acento} : undefined}>
|
|
37
|
+
{p}
|
|
38
|
+
</span>
|
|
39
|
+
))}
|
|
40
|
+
</div>
|
|
41
|
+
</AbsoluteFill>
|
|
42
|
+
</MarcaContexto.Provider>
|
|
43
|
+
);
|
|
44
|
+
};
|
package/remotion/piezas.tsx
CHANGED
|
@@ -164,16 +164,16 @@ export const Avatar: React.FC<{x: number; y: number; size: number; aro: number}>
|
|
|
164
164
|
width: size,
|
|
165
165
|
height: size,
|
|
166
166
|
borderRadius: '50%',
|
|
167
|
-
background: 'linear-gradient(#5A3CAA, #2A1650)',
|
|
167
|
+
background: marca.color ? `linear-gradient(${marca.color}, #111)` : 'linear-gradient(#5A3CAA, #2A1650)',
|
|
168
168
|
color: '#fff',
|
|
169
169
|
fontFamily: ANTON,
|
|
170
|
-
fontSize: size * 0.55,
|
|
170
|
+
fontSize: size * (marca.siglas && marca.siglas.length > 1 ? 0.42 : 0.55),
|
|
171
171
|
display: 'flex',
|
|
172
172
|
alignItems: 'center',
|
|
173
173
|
justifyContent: 'center',
|
|
174
174
|
}}
|
|
175
175
|
>
|
|
176
|
-
{(marca.nombre.trim()[0] || 'M').toUpperCase()}
|
|
176
|
+
{(marca.siglas || marca.nombre.trim()[0] || 'M').toUpperCase().slice(0, 3)}
|
|
177
177
|
</div>
|
|
178
178
|
)}
|
|
179
179
|
</div>
|
package/remotion/tipos.ts
CHANGED
|
@@ -34,6 +34,9 @@ export type Datos = {
|
|
|
34
34
|
export type Marca = {
|
|
35
35
|
nombre: string; // "" = sin etiqueta
|
|
36
36
|
logo: string | null; // ruta dentro de la carpeta public (la copia morfeo), o null = inicial del nombre
|
|
37
|
+
siglas?: string; // sin logo: estas siglas (2–3 letras) en vez de la inicial
|
|
38
|
+
color?: string; // color del círculo de las siglas y acento de la portada
|
|
39
|
+
letra?: string; // letra de títulos del estilo propio (portada)
|
|
37
40
|
redes: string; // línea pequeña bajo el botón de la escena "cta" ("" = nada)
|
|
38
41
|
seguir: string; // texto del botón de la escena "cta"
|
|
39
42
|
siguiendo: string; // texto del botón ya pulsado
|