morfeo 0.3.4 → 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/bases/biografia/Mundo.tsx +35 -0
- package/bases/biografia/guion.mjs +34 -0
- package/bases/construiste/Mundo.tsx +273 -0
- package/bases/construiste/guion.mjs +84 -0
- package/bases/escalada/Mundo.tsx +45 -0
- package/bases/escalada/guion.mjs +24 -0
- package/bases/mito/Mundo.tsx +55 -0
- package/bases/mito/guion.mjs +29 -0
- package/bases/serie/guion.mjs +32 -0
- package/bin/morfeo.mjs +36 -9
- package/index.d.ts +34 -1
- package/lib/calidad.mjs +74 -0
- package/lib/config.mjs +38 -6
- package/lib/estilo-pagina.mjs +174 -0
- package/lib/estilo-propio.mjs +70 -0
- package/lib/estructura.mjs +12 -3
- package/lib/formulas.mjs +179 -0
- package/lib/guion.mjs +91 -5
- package/lib/hoja.mjs +17 -2
- package/lib/nuevo-video.mjs +12 -3
- package/lib/palabras.mjs +2 -2
- 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/python/preparar.py +1 -1
- package/remotion/Short.tsx +10 -2
- package/remotion/kit/armar.tsx +245 -0
- package/remotion/kit/index.ts +1 -0
- package/remotion/motor/Video.tsx +50 -16
- package/remotion/motor/capas.tsx +2 -0
- package/remotion/motor/historias.tsx +483 -0
- package/remotion/motor/portada.tsx +44 -0
- package/remotion/motor/tierra-datos.ts +2 -0
- package/remotion/piezas.tsx +3 -3
- package/remotion/tipos.ts +4 -0
package/bin/morfeo.mjs
CHANGED
|
@@ -13,7 +13,7 @@ Primeros pasos
|
|
|
13
13
|
instalar Prepara tu computadora: Python, voces, navegador y (opcional) Whisper
|
|
14
14
|
--si responde "sí" a todo --whisper / --sin-whisper
|
|
15
15
|
nuevo <carpeta> Crea un proyecto de ejemplo listo para renderizar
|
|
16
|
-
nuevo-video <id> Crea un video desde una base que ya renderiza
|
|
16
|
+
nuevo-video <id> Crea un video desde una base que ya renderiza: --look 2d | 3d | serie | biografia | escalada | mito | construiste
|
|
17
17
|
doctor Revisa qué está instalado y qué falta
|
|
18
18
|
|
|
19
19
|
Videos
|
|
@@ -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
|
|
@@ -33,6 +35,10 @@ Videos
|
|
|
33
35
|
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)
|
|
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)
|
|
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
|
|
36
42
|
--estilo documental estilo visual (por defecto, el del proyecto)
|
|
37
43
|
--imagenes pide ilustraciones con IA (necesita OPENAI_API_KEY)
|
|
38
44
|
--id nombre --voz em_alex --duracion 40 --idioma "inglés" --tono "humor"
|
|
@@ -53,7 +59,7 @@ function opciones(argv) {
|
|
|
53
59
|
const [k, v] = a.slice(2).split('=');
|
|
54
60
|
const clave = k.replace(/-([a-z])/g, (_, c) => c.toUpperCase());
|
|
55
61
|
const sig = argv[i + 1];
|
|
56
|
-
const conValor = ['proyecto', 'desde', 'id', 'voz', 'duracion', 'idioma', 'estilo', 'tono', 'variante', 'fuente', 'musica', 'titulo', 'modelo', 'puerto', 'hasta', 'cols', 'filas', 'look'];
|
|
62
|
+
const conValor = ['proyecto', 'desde', 'id', 'voz', 'duracion', 'idioma', 'estilo', 'tono', 'variante', 'fuente', 'musica', 'titulo', 'modelo', 'puerto', 'hasta', 'cols', 'filas', 'look', 'formula'];
|
|
57
63
|
if (v != null) op[clave] = v;
|
|
58
64
|
else if (conValor.includes(clave) && sig != null && !sig.startsWith('--')) op[clave] = argv[++i];
|
|
59
65
|
else op[clave] = true;
|
|
@@ -87,12 +93,11 @@ async function main() {
|
|
|
87
93
|
return nuevo(pos[0], op);
|
|
88
94
|
case 'nuevo-video': {
|
|
89
95
|
const {nuevoVideo} = await import('../lib/nuevo-video.mjs');
|
|
90
|
-
const look = op.look === true || op.look == null ? '2d' : String(op.look).toLowerCase();
|
|
96
|
+
const look = typeof op.formula === 'string' ? op.formula.toLowerCase() : op.look === true || op.look == null ? '2d' : String(op.look).toLowerCase();
|
|
91
97
|
const r = nuevoVideo({proyecto, id: pos[0], look});
|
|
92
98
|
const rel = (f) => path.relative(process.cwd(), f);
|
|
93
99
|
console.log(`Listo (${look}):
|
|
94
|
-
${rel(r.guion)} el guion: texto, tomas, rótulos y capas
|
|
95
|
-
${rel(r.escena)} el mundo (${r.componente}, ya registrado en morfeo.config.mjs)
|
|
100
|
+
${rel(r.guion)} el guion: texto, tomas, rótulos y capas${r.escena ? `\n ${rel(r.escena)} el mundo (${r.componente}, ya registrado en morfeo.config.mjs)` : ''}
|
|
96
101
|
|
|
97
102
|
Pruébalo: npx morfeo render ${pos[0]} y revísalo: npx morfeo hoja ${pos[0]}`);
|
|
98
103
|
return;
|
|
@@ -112,6 +117,14 @@ Pruébalo: npx morfeo render ${pos[0]} y revísalo: npx morfeo hoja ${pos[0]
|
|
|
112
117
|
await render({proyecto, ids: pos, todos: !!op.todos || pos[0] === 'todos', soloFallidos: !!op.soloFallidos});
|
|
113
118
|
return;
|
|
114
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
|
+
}
|
|
115
128
|
case 'revisar': {
|
|
116
129
|
const {cargarProyecto} = await import('../lib/config.mjs');
|
|
117
130
|
const {revisarEstructura, consejosEnTexto, REGLAS} = await import('../lib/estructura.mjs');
|
|
@@ -141,7 +154,7 @@ Pruébalo: npx morfeo render ${pos[0]} y revísalo: npx morfeo hoja ${pos[0]
|
|
|
141
154
|
const {salidaDe, ordenarSalida} = await import('../lib/salida.mjs');
|
|
142
155
|
ordenarSalida(p);
|
|
143
156
|
const video = /\.(mp4|mov|webm)$/i.test(id) ? path.resolve(id) : salidaDe(p, id);
|
|
144
|
-
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}));
|
|
145
158
|
}
|
|
146
159
|
return;
|
|
147
160
|
}
|
|
@@ -191,6 +204,20 @@ Pruébalo: npx morfeo render ${pos[0]} y revísalo: npx morfeo hoja ${pos[0]
|
|
|
191
204
|
console.log('\nÚsalo con estilo: "documental" en el video o en morfeo.config.mjs. Crea los tuyos con estilos: {...} en el config.');
|
|
192
205
|
return;
|
|
193
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
|
+
}
|
|
214
|
+
case 'formulas':
|
|
215
|
+
case 'formula': {
|
|
216
|
+
const {FORMULAS} = await import('../lib/formulas.mjs');
|
|
217
|
+
for (const f of FORMULAS) console.log(`${f.id.padEnd(14)} ${f.nombre} (${f.duracion[0]}–${f.duracion[1]} s · ${f.estilos.join(', ')})\n${' '.repeat(15)}${f.para}\n${' '.repeat(15)}Gancho: ${f.gancho}\n`);
|
|
218
|
+
console.log('Úsala: npx morfeo guion "tu tema" --formula construiste (o formula: \'construiste\' en el guion para que `revisar` la revise)');
|
|
219
|
+
return;
|
|
220
|
+
}
|
|
194
221
|
case 'guion':
|
|
195
222
|
case 'script':
|
|
196
223
|
return guion(pos, op, proyecto);
|
|
@@ -225,7 +252,7 @@ async function guion(pos, op, proyecto) {
|
|
|
225
252
|
if (texto && op.sinIa) r = guionSinIA(texto, {id: op.id, voz: op.voz, musica: op.musica, titulo: op.titulo, estilo});
|
|
226
253
|
else {
|
|
227
254
|
console.log(texto ? 'Claude está convirtiendo tu guion en escenas...' : `Claude está escribiendo un video sobre: ${tema}...`);
|
|
228
|
-
r = await guionConIA({tema, texto, 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});
|
|
229
256
|
}
|
|
230
257
|
if (op.id) r.id = op.id;
|
|
231
258
|
const {cargarProyecto} = await import('../lib/config.mjs');
|
|
@@ -253,7 +280,7 @@ async function nuevo(carpeta, op) {
|
|
|
253
280
|
// dos videos de ejemplo con mundo y la estructura completa (los mismos que `morfeo nuevo-video`)
|
|
254
281
|
const {nuevoVideo} = await import('../lib/nuevo-video.mjs');
|
|
255
282
|
for (const look of ['2d', '3d']) nuevoVideo({proyecto: destino, id: `ejemplo-${look}`, look});
|
|
256
|
-
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`);
|
|
257
284
|
}
|
|
258
285
|
|
|
259
286
|
main().catch((e) => {
|
package/index.d.ts
CHANGED
|
@@ -64,11 +64,14 @@ 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
|
}
|
|
71
71
|
|
|
72
|
+
/** Mitad de una pantalla dividida: sus capas usan coordenadas de 1080×1920 recortadas a su mitad. */
|
|
73
|
+
export interface Mitad {etiqueta?: string; color?: string; fondo?: string; capas?: Capa[]}
|
|
74
|
+
|
|
72
75
|
export type Capa = CapaBase &
|
|
73
76
|
(
|
|
74
77
|
| {tipo: 'imagen'; src?: string; generar?: string; ancho?: number; alto?: number; redondo?: boolean; radio?: number; cambiarA?: {src: string; en?: Momento; efecto?: 'fundido' | 'barrido' | 'circulo'; duracion?: number}}
|
|
@@ -92,6 +95,17 @@ export type Capa = CapaBase &
|
|
|
92
95
|
| {tipo: 'recipientes'; n?: number; valor?: number; forma?: 'garrafon' | 'vaso'; total?: string; color?: string; tamPieza?: number; duracion?: number}
|
|
93
96
|
| {tipo: 'ficha'; titulo?: string; foto?: string; campos: [string, string][]; ancho?: number; color?: string; fondoFoto?: string}
|
|
94
97
|
| {tipo: 'checklist'; items: (string | {texto: string; en?: Momento; pendiente?: boolean})[]; titulo?: string; tam?: number; color?: string; cada?: number}
|
|
98
|
+
| {tipo: 'bloqueo'; hora?: string; fecha?: string; /** Año abajo de la hora; [2026, 2031] salta de uno a otro en `anioEn`. */ anio?: number | string | (number | string)[]; anioEn?: Momento; notificaciones?: {app?: string; titulo?: string; texto?: string; en?: Momento}[]; fondo?: string; ancho?: number}
|
|
99
|
+
| {tipo: 'llamada'; estado?: 'entrante' | 'curso' | 'terminada'; nombre?: string; numero?: string; aviso?: string | false; /** Segundos con los que arranca el contador (curso) o duración final (terminada). */ desde?: number; duracion?: number; fondo?: string; ancho?: number}
|
|
100
|
+
| {tipo: 'recuento'; cabecera?: string; titulo?: string; opciones: {texto: string; valor: number; sub?: string}[]; color?: string; ancho?: number}
|
|
101
|
+
| {tipo: 'capitulo'; serie?: string; capitulo?: string; pregunta?: string; opciones?: {texto: string; sub?: string}[]; pie?: string; siguiente?: string; horario?: string; color?: string; ancho?: number}
|
|
102
|
+
| {tipo: 'vigilancia'; hora?: string; fecha?: string; etiqueta?: string; zoom?: number; mira?: boolean; miraX?: number; miraY?: number; color?: string}
|
|
103
|
+
| {tipo: 'cintillo'; nombre: string; cargo?: string; color?: string; tam?: number}
|
|
104
|
+
| {tipo: 'fecha'; anio?: number; de?: number; texto?: string; pie?: string; color?: string; tam?: number}
|
|
105
|
+
| {tipo: 'calendario'; titulo?: string; de?: number; a?: number; color?: string; tam?: number; duracion?: number}
|
|
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; /** 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}
|
|
95
109
|
| {tipo: 'mapa'; ruta?: [number, number][]; distancia?: string; ancho?: number; color?: string; fondo?: string; inclinar?: number; duracion?: number}
|
|
96
110
|
| {tipo: 'cursor'; a: [number, number]; clic?: Momento; duracion?: number; tam?: number; color?: string}
|
|
97
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}
|
|
@@ -176,6 +190,8 @@ interface EscenaMotorBase extends EscenaBase {
|
|
|
176
190
|
modoEn?: Momento;
|
|
177
191
|
/** Palabra o segundos en que vuelve a la imagen normal. */
|
|
178
192
|
modoHasta?: Momento;
|
|
193
|
+
/** Lugar (de `lugares` del video) donde pasa esta escena. */
|
|
194
|
+
lugar?: string;
|
|
179
195
|
/** Invertir la imagen en el modo (fondo claro → oscuro). Por defecto sí en looks claros (3d, maqueta, explicativo, infografia, minimal). */
|
|
180
196
|
modoInvertir?: boolean;
|
|
181
197
|
/** Palabras clave de la escena para los subtítulos (la que lleva caja o color de acento en cada frase). */
|
|
@@ -267,6 +283,12 @@ export interface Video {
|
|
|
267
283
|
* El componente recibe además `escena` (1, 2, 3...), `tEscena`, `inicios` y `camara` (para paralaje).
|
|
268
284
|
*/
|
|
269
285
|
mundo?: Capa[];
|
|
286
|
+
/**
|
|
287
|
+
* Varios mundos en un video (biografías, historias que cambian de sitio): `{cocina: [...], boda: [...]}`. Cada escena
|
|
288
|
+
* elige uno con `lugar: 'boda'` (o sigue en el anterior). Cada lugar tiene su propia cámara; al cambiar, corta o usa
|
|
289
|
+
* la `transicion` de la escena.
|
|
290
|
+
*/
|
|
291
|
+
lugares?: Record<string, Capa[]>;
|
|
270
292
|
/** Pastilla fija arriba para series: 'GYM CON CIENCIA · PARTE 5'. */
|
|
271
293
|
serie?: string;
|
|
272
294
|
/** Calidad de este video (ver `render.calidad`). */
|
|
@@ -274,6 +296,17 @@ export interface Video {
|
|
|
274
296
|
/** Para publicar: descripción y etiquetas (van a salida/<id>.txt junto con el título). */
|
|
275
297
|
descripcion?: string;
|
|
276
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})[];
|
|
305
|
+
/**
|
|
306
|
+
* Fórmula de historia (ver `npx morfeo formulas`): 'construiste' | 'recomendable' | 'escalada' | 'supervivencia' |
|
|
307
|
+
* 'experimento' | 'misterio' | 'demuestralo' | 'mito' | 'roast' | 'serie' | 'biografia'. `revisar` da consejos propios.
|
|
308
|
+
*/
|
|
309
|
+
formula?: 'construiste' | 'recomendable' | 'escalada' | 'supervivencia' | 'experimento' | 'misterio' | 'demuestralo' | 'mito' | 'roast' | 'serie' | 'biografia';
|
|
277
310
|
/** 'largo' = explicador de 90–130 s (lo asume el estilo maqueta): cambia las reglas de `morfeo revisar`. */
|
|
278
311
|
formato?: 'corto' | 'largo';
|
|
279
312
|
/** Ajustes del estilo para este video (colores, fondo, subtítulos...). */
|
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,4 +1,6 @@
|
|
|
1
1
|
// Carga y valida morfeo.config.mjs del proyecto del usuario.
|
|
2
|
+
import {IDS_FORMULA} from './formulas.mjs';
|
|
3
|
+
import {aplicarEstiloPropio, leerEstiloPropio} from './estilo-propio.mjs';
|
|
2
4
|
import fs from 'node:fs';
|
|
3
5
|
import path from 'node:path';
|
|
4
6
|
import {pathToFileURL} from 'node:url';
|
|
@@ -25,7 +27,7 @@ export const TIPOS_ESCENA = [...new Set([...TIPOS_CLASICOS, ...TIPOS_MOTOR])];
|
|
|
25
27
|
export const ROTULOS = ['normal', 'contorno', 'pastilla', 'sello', 'paso', 'pixel'];
|
|
26
28
|
export const MODOS = ['rayosx', 'plano', 'holograma'];
|
|
27
29
|
export const ESTILOS = ['datos-curiosos', 'explicativo', 'documental', 'ilustrado', 'historia', 'mesa', 'serie', 'infografia', '3d', 'maqueta', 'minimal'];
|
|
28
|
-
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'];
|
|
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'];
|
|
29
31
|
export const ESCENARIOS = ['bosque', 'cuarto', 'departamento', 'tunel', 'pasillo'];
|
|
30
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'];
|
|
31
33
|
export const PROVEEDORES_VOZ = ['kokoro', 'elevenlabs', 'openai'];
|
|
@@ -79,6 +81,12 @@ export async function cargarProyecto(proyecto = process.cwd()) {
|
|
|
79
81
|
let cfg = await importar(archivo);
|
|
80
82
|
if (typeof cfg === 'function') cfg = await cfg();
|
|
81
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
|
+
}
|
|
82
90
|
|
|
83
91
|
let videos = cfg.videos ?? (fs.existsSync(path.join(proyecto, 'videos.mjs')) ? './videos.mjs' : './guiones');
|
|
84
92
|
const carpetaGuiones = typeof videos === 'string' && fs.existsSync(path.resolve(proyecto, videos)) && fs.statSync(path.resolve(proyecto, videos)).isDirectory()
|
|
@@ -102,6 +110,9 @@ export async function cargarProyecto(proyecto = process.cwd()) {
|
|
|
102
110
|
redes: m.redes ?? '',
|
|
103
111
|
seguir: m.seguir ?? '+ SEGUIR',
|
|
104
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)} : {}),
|
|
105
116
|
etiqueta: m.etiqueta ?? 'DATO CURIOSO',
|
|
106
117
|
cta: m.cta ?? (m.nombre ? `Sígueme en ${m.nombre} para más.` : 'Sígueme para más.'),
|
|
107
118
|
};
|
|
@@ -111,7 +122,7 @@ export async function cargarProyecto(proyecto = process.cwd()) {
|
|
|
111
122
|
const estilosPropios = cfg.estilos ?? {};
|
|
112
123
|
const estilo = cfg.estilo ?? 'datos-curiosos';
|
|
113
124
|
const assetsDir = path.resolve(proyecto, cfg.assets ?? 'assets');
|
|
114
|
-
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 ?? {})};
|
|
115
126
|
if (!ESTILOS.includes(estilo) && !estilosPropios[estilo]) errores.push(`estilo "${estilo}" no existe. Opciones: ${[...ESTILOS, ...Object.keys(estilosPropios)].join(', ')}`);
|
|
116
127
|
const componentes = {};
|
|
117
128
|
for (const [nombre, ruta] of Object.entries(cfg.componentes ?? {})) {
|
|
@@ -160,6 +171,7 @@ export async function cargarProyecto(proyecto = process.cwd()) {
|
|
|
160
171
|
concurrencia: Number(process.env.MORFEO_CONCURRENCIA) || r.concurrencia || null, // null = Morfeo elige según la máquina y el modo (ver render.mjs)
|
|
161
172
|
miniatura: r.miniatura ?? true,
|
|
162
173
|
consejos: r.consejos ?? true,
|
|
174
|
+
portada: r.portada ?? true,
|
|
163
175
|
// 'titulo' (por defecto): salida/<Título del video>.mp4, listo para subir · 'id': salida/<id>.mp4
|
|
164
176
|
nombreArchivo: r.nombreArchivo ?? 'titulo',
|
|
165
177
|
},
|
|
@@ -228,6 +240,11 @@ function validarCapas(capas, donde, err, comps) {
|
|
|
228
240
|
if (!c.buscar) err(`${d}: falta \`buscar\` (qué objeto: 'tubería', 'casa', 'pizza'...)`);
|
|
229
241
|
if (c.estilo && !['2d', '3d', 'realista'].includes(c.estilo)) err(`${d}: estilo "${c.estilo}" no existe. Usa '2d', '3d' o 'realista'`);
|
|
230
242
|
}
|
|
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'}]`);
|
|
245
|
+
if (c.tipo === 'cintillo' && !c.nombre) err(`${d}: \`cintillo\` necesita \`nombre\``);
|
|
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)`);
|
|
247
|
+
if (c.tipo === 'tierra' && (!esNum(c.lat) || !esNum(c.lon))) err(`${d}: \`tierra\` necesita \`lat\` y \`lon\` (números)`);
|
|
231
248
|
if (c.capas) validarCapas(c.capas, d, err, comps);
|
|
232
249
|
if (c.capa) validarCapas([c.capa], d, err, comps);
|
|
233
250
|
});
|
|
@@ -298,7 +315,9 @@ function validarVideo(id, v, ctx) {
|
|
|
298
315
|
const err = (m) => ctx.errores.push(`${id}: ${m}`);
|
|
299
316
|
if (!v || typeof v !== 'object') return err('debe ser un objeto'), null;
|
|
300
317
|
if (!Array.isArray(v.scenes) || !v.scenes.length) return err('falta `scenes` (lista de escenas)'), null;
|
|
301
|
-
|
|
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);
|
|
302
321
|
const clasico = estilo === 'datos-curiosos';
|
|
303
322
|
if (!ESTILOS.includes(estilo) && !ctx.estilosPropios[estilo]) err(`estilo "${estilo}" no existe. Opciones: ${[...ESTILOS, ...Object.keys(ctx.estilosPropios)].join(', ')}`);
|
|
304
323
|
const permitidos = clasico ? TIPOS_ESCENA : TIPOS_MOTOR;
|
|
@@ -315,6 +334,7 @@ function validarVideo(id, v, ctx) {
|
|
|
315
334
|
);
|
|
316
335
|
if (s.capas != null) validarCapas(s.capas, `${n}`, err, ctx.componentes);
|
|
317
336
|
if (s.rotulo != null && typeof s.rotulo !== 'string') err(`${n}: \`rotulo\` debe ser un texto corto ('¿CUÁNTO *SUBIÓ*?')`);
|
|
337
|
+
if (s.lugar != null && !(v.lugares && typeof v.lugares === 'object' && v.lugares[s.lugar])) err(`${n}: el lugar "${s.lugar}" no está en \`lugares\`${v.lugares && typeof v.lugares === 'object' ? ` (hay: ${Object.keys(v.lugares).join(', ')})` : ''}`);
|
|
318
338
|
if (s.rotuloEstilo != null && !ROTULOS.includes(s.rotuloEstilo)) err(`${n}: rotuloEstilo "${s.rotuloEstilo}" no existe. Opciones: ${ROTULOS.join(', ')}`);
|
|
319
339
|
if (s.rotuloPaso != null && s.rotuloEstilo !== 'paso') err(`${n}: \`rotuloPaso\` es para rotuloEstilo: 'paso'`);
|
|
320
340
|
if (s.modo != null) {
|
|
@@ -329,7 +349,7 @@ function validarVideo(id, v, ctx) {
|
|
|
329
349
|
if (tm.zoom != null && tm.zoom <= 0) err(`${n}: \`toma.zoom\` debe ser mayor que 0`);
|
|
330
350
|
if (tm.a != null) for (const k of ['x', 'y', 'zoom', 'giro']) if (tm.a[k] != null && !esNum(tm.a[k])) err(`${n}: \`toma.a.${k}\` debe ser un número`);
|
|
331
351
|
}
|
|
332
|
-
if (!v.mundo?.length) err(`${n}: \`toma\` mueve la cámara del \`mundo\`, pero el video no tiene \`mundo\``);
|
|
352
|
+
if (!v.mundo?.length && !v.lugares) err(`${n}: \`toma\` mueve la cámara del \`mundo\`, pero el video no tiene \`mundo\` ni \`lugares\``);
|
|
333
353
|
}
|
|
334
354
|
if (s.type === 'cta' && s.pide != null) {
|
|
335
355
|
if (!['comenta', 'comparte'].includes(s.pide)) err(`${n}: \`pide\` debe ser 'comenta' o 'comparte'`);
|
|
@@ -350,20 +370,32 @@ function validarVideo(id, v, ctx) {
|
|
|
350
370
|
// la voz sintética solo hace falta si alguna escena no trae su propia grabación
|
|
351
371
|
const necesitaVoz = scenes.some((s) => s && !s.audio);
|
|
352
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};
|
|
353
376
|
if (v.voice != null) voice = normalizarVoz(v.voice, ctx, err);
|
|
354
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)');
|
|
355
378
|
if (v.hud != null) validarCapas(v.hud, 'hud', err, ctx.componentes);
|
|
379
|
+
if (v.lugares != null) {
|
|
380
|
+
if (typeof v.lugares !== 'object' || Array.isArray(v.lugares) || !Object.keys(v.lugares).length) err("`lugares` debe ser un objeto con un mundo por nombre: {cocina: [...], boda: [...]}");
|
|
381
|
+
else for (const [nombre, capas] of Object.entries(v.lugares)) validarCapas(capas, `lugares.${nombre}`, err, ctx.componentes);
|
|
382
|
+
if (clasico) err('`lugares` es del motor de capas: usa un estilo como ilustrado, 3d o mesa (no "datos-curiosos")');
|
|
383
|
+
}
|
|
356
384
|
if (v.mundo != null) {
|
|
357
385
|
validarCapas(v.mundo, 'mundo', err, ctx.componentes);
|
|
358
386
|
if (clasico) err('`mundo` es del motor de capas: usa un estilo como ilustrado, 3d o mesa (no "datos-curiosos")');
|
|
359
387
|
}
|
|
388
|
+
if (v.formula != null && !IDS_FORMULA.includes(v.formula)) err(`\`formula\` "${v.formula}" no existe. Opciones: ${IDS_FORMULA.join(', ')} (npx morfeo formulas)`);
|
|
360
389
|
if (v.serie != null && typeof v.serie !== 'string') err("`serie` debe ser un texto ('GYM CON CIENCIA · PARTE 5')");
|
|
361
390
|
if (v.title != null && typeof v.title !== 'string') err('`title` debe ser un texto (también es el nombre del archivo del video)');
|
|
362
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://…'}]");
|
|
363
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'])");
|
|
364
396
|
// archivos de assets/ que piden las escenas y el HUD: mejor avisar ahora que a mitad del render
|
|
365
|
-
revisarArchivos({scenes, hud: v.hud, mundo: v.mundo, tema: v.tema}, ctx.assetsDir, err);
|
|
366
|
-
revisarPersonajes({scenes, hud: v.hud, mundo: v.mundo}, ctx.personajes, err);
|
|
397
|
+
revisarArchivos({scenes, hud: v.hud, mundo: v.mundo, lugares: v.lugares, tema: v.tema}, ctx.assetsDir, err);
|
|
398
|
+
revisarPersonajes({scenes, hud: v.hud, mundo: v.mundo, lugares: v.lugares}, ctx.personajes, err);
|
|
367
399
|
revisarEscenarios({scenes, hud: v.hud, tema: v.tema}, err);
|
|
368
400
|
return {...v, estilo, voice, music: normalizarMusica(v.music, ctx, err), scenes};
|
|
369
401
|
}
|
|
@@ -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
|
+
}
|