morfeo 0.1.3 → 0.2.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/bin/morfeo.mjs +35 -1
- package/index.d.ts +57 -5
- package/lib/config.mjs +11 -8
- package/lib/estructura.mjs +104 -0
- package/lib/guion.mjs +25 -8
- package/lib/hoja.mjs +98 -0
- package/lib/imagenes.mjs +2 -1
- package/lib/instalar.mjs +18 -2
- package/lib/personajes.mjs +1 -1
- package/lib/recursos.mjs +2 -1
- package/lib/render.mjs +9 -2
- package/lib/whisper.mjs +18 -2
- package/package.json +2 -1
- package/python/preparar.py +1 -1
- package/remotion/estilo.ts +13 -0
- package/remotion/kit/ilustrado.tsx +220 -0
- package/remotion/kit/index.ts +6 -0
- package/remotion/kit/tres.tsx +328 -0
- package/remotion/motor/Video.tsx +171 -20
- package/remotion/motor/anotaciones.tsx +296 -0
- package/remotion/motor/capas.tsx +50 -27
- package/remotion/motor/fondo.tsx +56 -2
- package/remotion/motor/plantillas.tsx +26 -1
- package/remotion/motor/subtitulos.tsx +159 -36
- package/remotion/motor/tema.ts +46 -17
- package/remotion/motor/util.ts +3 -0
- package/remotion/tipos.ts +2 -0
package/bin/morfeo.mjs
CHANGED
|
@@ -20,6 +20,9 @@ Videos
|
|
|
20
20
|
render [id...] Genera los videos que faltan (o los que indiques)
|
|
21
21
|
--todos regenera todos
|
|
22
22
|
--gpu usa la tarjeta de video (más rápido; si falla, quítalo)
|
|
23
|
+
revisar [id...] Revisa la estructura (gancho, ritmo, preguntas, cierre) con reglas de videos virales
|
|
24
|
+
hoja <id> Hoja de contacto (PNG con 15 cuadros) para revisar un video de un vistazo
|
|
25
|
+
--desde 8 --hasta 11 solo ese tramo (segundos) --cols 5 --filas 3
|
|
23
26
|
studio Abre el editor en el navegador para ver y ajustar en vivo
|
|
24
27
|
estilos Lista los estilos visuales disponibles
|
|
25
28
|
recursos temas Diccionario de objetos 2D/3D por temática
|
|
@@ -48,7 +51,7 @@ function opciones(argv) {
|
|
|
48
51
|
const [k, v] = a.slice(2).split('=');
|
|
49
52
|
const clave = k.replace(/-([a-z])/g, (_, c) => c.toUpperCase());
|
|
50
53
|
const sig = argv[i + 1];
|
|
51
|
-
const conValor = ['proyecto', 'desde', 'id', 'voz', 'duracion', 'idioma', 'estilo', 'tono', 'variante', 'fuente', 'musica', 'titulo', 'modelo', 'puerto'];
|
|
54
|
+
const conValor = ['proyecto', 'desde', 'id', 'voz', 'duracion', 'idioma', 'estilo', 'tono', 'variante', 'fuente', 'musica', 'titulo', 'modelo', 'puerto', 'hasta', 'cols', 'filas'];
|
|
52
55
|
if (v != null) op[clave] = v;
|
|
53
56
|
else if (conValor.includes(clave) && sig != null && !sig.startsWith('--')) op[clave] = argv[++i];
|
|
54
57
|
else op[clave] = true;
|
|
@@ -95,6 +98,37 @@ async function main() {
|
|
|
95
98
|
await render({proyecto, ids: pos, todos: !!op.todos || pos[0] === 'todos'});
|
|
96
99
|
return;
|
|
97
100
|
}
|
|
101
|
+
case 'revisar': {
|
|
102
|
+
const {cargarProyecto} = await import('../lib/config.mjs');
|
|
103
|
+
const {revisarEstructura, consejosEnTexto, REGLAS} = await import('../lib/estructura.mjs');
|
|
104
|
+
const p = await cargarProyecto(proyecto);
|
|
105
|
+
const ids = pos.length ? pos : Object.keys(p.videos);
|
|
106
|
+
const fs = await import('node:fs');
|
|
107
|
+
let total = 0;
|
|
108
|
+
for (const id of ids) {
|
|
109
|
+
if (!p.videos[id]) throw new ErrorMorfeo(`No existe el video ${id}. Usa: npx morfeo lista`);
|
|
110
|
+
// tiempos reales si ya se preparó la voz (después de un render); si no, solo el texto
|
|
111
|
+
const f = path.join(p.trabajo, 'public', 'v', id, 'datos.json');
|
|
112
|
+
const d = fs.existsSync(f) ? JSON.parse(fs.readFileSync(f, 'utf8')) : null;
|
|
113
|
+
const c = revisarEstructura(p.videos[id], d);
|
|
114
|
+
total += c.length;
|
|
115
|
+
console.log(c.length ? `${id}${d ? '' : ' (sin tiempos: renderízalo para revisar el ritmo)'}\n${consejosEnTexto(c)}` : `${id} ✔`);
|
|
116
|
+
}
|
|
117
|
+
if (total) console.log(`\nReglas completas: ${REGLAS.map((r) => r.regla.toLowerCase()).join(' · ')}`);
|
|
118
|
+
return;
|
|
119
|
+
}
|
|
120
|
+
case 'hoja': {
|
|
121
|
+
const {cargarProyecto} = await import('../lib/config.mjs');
|
|
122
|
+
const {hoja} = await import('../lib/hoja.mjs');
|
|
123
|
+
if (!pos.length) throw new ErrorMorfeo('¿De cuál video? npx morfeo hoja <id>');
|
|
124
|
+
const p = await cargarProyecto(proyecto);
|
|
125
|
+
const num = (v) => (v == null || v === true ? undefined : Number(v));
|
|
126
|
+
for (const id of pos) {
|
|
127
|
+
const video = /\.(mp4|mov|webm)$/i.test(id) ? path.resolve(id) : path.join(p.salida, `${id}.mp4`);
|
|
128
|
+
console.log(hoja({video, cols: num(op.cols), filas: num(op.filas), desde: num(op.desde), hasta: num(op.hasta)}));
|
|
129
|
+
}
|
|
130
|
+
return;
|
|
131
|
+
}
|
|
98
132
|
case 'studio': {
|
|
99
133
|
const {studio} = await import('../lib/render.mjs');
|
|
100
134
|
return studio({proyecto, puerto: op.puerto});
|
package/index.d.ts
CHANGED
|
@@ -28,7 +28,7 @@ export type Objeto3D = 'corazon' | 'estrella' | 'rayo' | 'diamante' | 'trofeo' |
|
|
|
28
28
|
|
|
29
29
|
/** Momento: segundos desde que empieza la escena, una palabra de `say` ("orcas"), "voz", "fin", o "palabra+0.3". */
|
|
30
30
|
export type Momento = number | string;
|
|
31
|
-
export type Entrada = 'escala' | 'rebote' | 'aparecer' | 'subir' | 'caer' | 'girar' | 'deslizar-izq' | 'deslizar-der' | 'deslizar-arriba' | 'deslizar-abajo' | 'ninguna';
|
|
31
|
+
export type Entrada = 'escala' | 'rebote' | 'aparecer' | 'subir' | 'caer' | 'girar' | 'deslizar-izq' | 'deslizar-der' | 'deslizar-arriba' | 'deslizar-abajo' | 'ninguna' | 'enfocar';
|
|
32
32
|
|
|
33
33
|
interface CapaBase {
|
|
34
34
|
/** Centro en el lienzo 1080×1920 (dentro de un grupo o teléfono: relativo a su centro). */
|
|
@@ -52,6 +52,14 @@ interface CapaBase {
|
|
|
52
52
|
latir?: boolean;
|
|
53
53
|
voltear?: boolean;
|
|
54
54
|
desenfoque?: number;
|
|
55
|
+
/** Curva de la entrada de esta capa (por defecto, la del estilo). */
|
|
56
|
+
curva?: 'suave' | 'resorte' | 'seca';
|
|
57
|
+
duracionEntrada?: number;
|
|
58
|
+
/** Sigue a otra capa: `x`, `y` pasan a ser la distancia al centro de la capa con ese `id`. */
|
|
59
|
+
sigue?: string;
|
|
60
|
+
id?: string;
|
|
61
|
+
/** En un video con `mundo`: la capa vive en el mundo (coordenadas del mundo) y la cámara la mueve con él. */
|
|
62
|
+
enMundo?: boolean;
|
|
55
63
|
sombra?: boolean | string;
|
|
56
64
|
z?: number;
|
|
57
65
|
/** Efecto de sonido al aparecer. */
|
|
@@ -64,6 +72,19 @@ export type Capa = CapaBase &
|
|
|
64
72
|
(
|
|
65
73
|
| {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}}
|
|
66
74
|
| {tipo: 'video'; src: string; ancho?: number; alto?: number; radio?: number}
|
|
75
|
+
/** Anotaciones: señalan y explican lo que hay en pantalla. `medida` y `trazo` usan coordenadas del lienzo. */
|
|
76
|
+
| {tipo: 'dato'; valor: number | string; unidad?: string; de?: number; duracion?: number; decimales?: number; tam?: number; color?: string; fondo?: string}
|
|
77
|
+
| {tipo: 'medida'; de: [number, number]; a: [number, number]; texto?: string; unidad?: string; tam?: number; color?: string; grosor?: number; tope?: number; separacion?: number; duracion?: number}
|
|
78
|
+
| {tipo: 'trazo'; puntos: [number, number][]; flecha?: boolean; punteado?: boolean | number; color?: string; grosor?: number; duracion?: number; velocidad?: number}
|
|
79
|
+
| {tipo: 'contorno'; forma?: 'rect' | 'circulo' | 'aro'; ancho?: number; alto?: number; radio?: number; color?: string; grosor?: number; punteado?: boolean; relleno?: string; velocidad?: number; duracion?: number}
|
|
80
|
+
| {tipo: 'fuente'; texto: string; etiqueta?: string; tam?: number; color?: string; colorTexto?: string}
|
|
81
|
+
| {tipo: 'seccion'; texto: string; tam?: number; color?: string}
|
|
82
|
+
| {tipo: 'insignia'; texto: string | number; tam?: number; fondo?: string; colorTexto?: string; inclinar?: number}
|
|
83
|
+
| {tipo: 'sello'; texto: string; tam?: number; color?: string; inclinar?: number}
|
|
84
|
+
| {tipo: 'terminal'; lineas: (string | {texto: string; en?: Momento; salida?: boolean; color?: string})[]; titulo?: string; ancho?: number; tam?: number; fondo?: string; velocidad?: number}
|
|
85
|
+
| {tipo: 'nombre'; texto: string; sub?: string; tam?: number; fondo?: string; colorTexto?: string; borde?: string}
|
|
86
|
+
| {tipo: 'accion'; texto: string; tam?: number; color?: string; fondo?: string; colorTexto?: string}
|
|
87
|
+
| {tipo: 'cursor'; a: [number, number]; clic?: Momento; duracion?: number; tam?: number; color?: string}
|
|
67
88
|
| {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'; alinear?: 'center' | 'left' | 'right'; interlineado?: number; escribir?: boolean | number; subrayado?: boolean}
|
|
68
89
|
| {tipo: 'contador'; de?: number; a: number; prefijo?: string; sufijo?: string; decimales?: number; miles?: string; decimal?: string; duracion?: number; curva?: 'suave' | 'lineal' | 'exponencial'; formato?: 'grande' | 'lcd' | 'chip'; tam?: number; color?: string; fondo?: string}
|
|
69
90
|
| {tipo: 'chip'; texto: string; unidad?: string; tam?: number; color?: string; fondo?: string; borde?: string}
|
|
@@ -105,12 +126,26 @@ export type FondoEscena =
|
|
|
105
126
|
/** Escenarios ilustrados incluidos (animados). bosque: paneo, luna {cara, cantar, bajar, a}; cuarto: dia, parte; tunel: llegar; pasillo: golpe. */
|
|
106
127
|
export type Escenario = 'bosque' | 'cuarto' | 'departamento' | 'tunel' | 'pasillo';
|
|
107
128
|
|
|
129
|
+
export type Transicion = 'corte' | 'fundido' | 'deslizar' | 'zoom' | 'barrido' | 'empuje' | 'mascara' | 'destello';
|
|
130
|
+
|
|
108
131
|
interface EscenaMotorBase extends EscenaBase {
|
|
109
132
|
capas?: Capa[];
|
|
110
133
|
fondo?: FondoEscena;
|
|
111
134
|
/** Cámara: número fijo o [inicio, fin] a lo largo de la escena. */
|
|
112
135
|
camara?: {zoom?: number | [number, number]; x?: number | [number, number]; y?: number | [number, number]; en?: number; duracion?: number; origen?: [number, number]; temblor?: {fuerza?: number; duracion?: number}};
|
|
113
|
-
transicion?:
|
|
136
|
+
transicion?: Transicion;
|
|
137
|
+
/**
|
|
138
|
+
* En un video con `mundo`: a dónde mira la cámara en esta escena. `x`, `y` = punto del mundo que queda al centro.
|
|
139
|
+
* La cámara viaja desde donde terminó la escena anterior (`duracion` s; `corte: true` salta) y luego deriva
|
|
140
|
+
* despacio (`deriva`: cuánto acerca, 0.05 por defecto) o se mueve hasta `a`. Sin `toma` conserva la anterior.
|
|
141
|
+
*/
|
|
142
|
+
toma?: {x?: number; y?: number; zoom?: number; giro?: number; duracion?: number; corte?: boolean; deriva?: number; a?: {x?: number; y?: number; zoom?: number; giro?: number}};
|
|
143
|
+
/** Rótulo grande y corto que resume el momento (segundo nivel de texto): '¿CUÁNTO *SUBIÓ*?'. `rotuloEn`: palabra o segundos. */
|
|
144
|
+
rotulo?: string;
|
|
145
|
+
rotuloEn?: Momento;
|
|
146
|
+
rotuloTam?: number;
|
|
147
|
+
/** Palabras clave de la escena para los subtítulos (la que lleva caja o color de acento en cada frase). */
|
|
148
|
+
claves?: string[];
|
|
114
149
|
/** Ajustes del tema solo para esta escena. */
|
|
115
150
|
tema?: Partial<Tema>;
|
|
116
151
|
}
|
|
@@ -124,7 +159,7 @@ export type EscenaMotor =
|
|
|
124
159
|
| (EscenaMotorBase & {type: 'telefono'; src?: string; capasPantalla?: Capa[]; texto?: string; ancho?: number; y?: number})
|
|
125
160
|
| (EscenaMotorBase & {type: 'comparar'; a: string | {texto: string; imagen?: string; color?: string}; b: string | {texto: string; imagen?: string; color?: string}; at?: string})
|
|
126
161
|
| (EscenaMotorBase & {type: 'eleccion'; pregunta?: string; opciones: (string | {texto: string; sub?: string; objeto?: Objeto3D; emoji?: string; icono?: Icono; imagen?: string; color?: string; fondo?: string; en?: Momento})[]; formato?: 'cartas' | 'botones'; comenta?: string | false; enComenta?: Momento; y?: number})
|
|
127
|
-
| (Partial<EscenaMotorBase> & {type: 'cta'});
|
|
162
|
+
| (Partial<EscenaMotorBase> & {type: 'cta'; /** Pide una acción concreta en vez de solo seguir. */ pide?: 'comenta' | 'comparte'; /** comenta: la palabra a escribir. */ palabra?: string; /** comparte: a quién se lo mandan. */ a?: string; texto?: string; sub?: string});
|
|
128
163
|
|
|
129
164
|
export interface Tema {
|
|
130
165
|
fondo: FondoEscena;
|
|
@@ -136,8 +171,17 @@ export interface Tema {
|
|
|
136
171
|
colores: {texto: string; acento: string; acento2: string; suave: string; tarjeta: string; tarjetaTexto: string; borde: string};
|
|
137
172
|
fuentes: {titulo: string; texto: string; mono: string};
|
|
138
173
|
tituloMayusculas: boolean;
|
|
139
|
-
subtitulos: {tipo: 'karaoke' | 'pop' | 'bloque' | 'palabra' | 'ninguno'; y: number; tam: number; color: string; pendiente: string; resalte: string; maxPalabras: number; mayusculas?: boolean; contorno?: number; fondo?: string};
|
|
140
|
-
transicion:
|
|
174
|
+
subtitulos: {tipo: 'karaoke' | 'seleccion' | 'pop' | 'bloque' | 'palabra' | 'caja' | 'ninguno'; y: number; tam: number; color: string; pendiente: string; resalte: string; maxPalabras: number; mayusculas?: boolean; contorno?: number; fondo?: string};
|
|
175
|
+
transicion: Transicion;
|
|
176
|
+
/** Ritmo de las animaciones: duraciones (s) y curva de las entradas. */
|
|
177
|
+
movimiento?: {entrada?: number; salida?: number; transicion?: number; curva?: 'suave' | 'resorte' | 'seca'};
|
|
178
|
+
/** Acabado sobre todo el cuadro: tinte, cono de luz y desenfoque de bordes. */
|
|
179
|
+
post?: {
|
|
180
|
+
tinte?: {color: string; fuerza?: number; modo?: 'multiply' | 'screen' | 'overlay' | 'soft-light'};
|
|
181
|
+
luz?: {x?: number; y?: number; ancho?: number; largo?: number; color?: string; fuerza?: number; angulo?: number};
|
|
182
|
+
desenfoqueBordes?: number;
|
|
183
|
+
vinetaColor?: string;
|
|
184
|
+
};
|
|
141
185
|
marca: 'esquina' | 'arriba' | 'ninguna';
|
|
142
186
|
zoomLento: number;
|
|
143
187
|
}
|
|
@@ -181,6 +225,14 @@ export interface Video {
|
|
|
181
225
|
estilo?: Estilo;
|
|
182
226
|
/** Capas fijas encima de todo el video (día, barras, hora...); se actualizan con `cambios`. */
|
|
183
227
|
hud?: Capa[];
|
|
228
|
+
/**
|
|
229
|
+
* Mundo del video: capas (normalmente un `componente` tuyo más grande que el cuadro) que duran todo el video.
|
|
230
|
+
* La cámara las recorre con la `toma` de cada escena, en lugar de cambiar de fondo en cada una.
|
|
231
|
+
* El componente recibe además `escena` (1, 2, 3...), `tEscena`, `inicios` y `camara` (para paralaje).
|
|
232
|
+
*/
|
|
233
|
+
mundo?: Capa[];
|
|
234
|
+
/** Pastilla fija arriba para series: 'GYM CON CIENCIA · PARTE 5'. */
|
|
235
|
+
serie?: string;
|
|
184
236
|
/** Ajustes del estilo para este video (colores, fondo, subtítulos...). */
|
|
185
237
|
tema?: Partial<Tema> & Record<string, unknown>;
|
|
186
238
|
/** Título para publicar. */
|
package/lib/config.mjs
CHANGED
|
@@ -23,7 +23,7 @@ export const TIPOS_CLASICOS = ['title', 'year', 'text', 'icon', 'compare', 'card
|
|
|
23
23
|
export const TIPOS_MOTOR = ['capas', 'titular', 'dato', 'lista', 'telefono', 'comparar', 'eleccion', 'cta'];
|
|
24
24
|
export const TIPOS_ESCENA = [...new Set([...TIPOS_CLASICOS, ...TIPOS_MOTOR])];
|
|
25
25
|
export const ESTILOS = ['datos-curiosos', 'explicativo', 'documental', 'ilustrado', 'historia', 'mesa', 'serie', 'infografia', '3d', 'minimal'];
|
|
26
|
-
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'];
|
|
26
|
+
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'];
|
|
27
27
|
export const ESCENARIOS = ['bosque', 'cuarto', 'departamento', 'tunel', 'pasillo'];
|
|
28
28
|
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'];
|
|
29
29
|
export const PROVEEDORES_VOZ = ['kokoro', 'elevenlabs', 'openai'];
|
|
@@ -32,11 +32,12 @@ export const MUSICAS = ['lofi', 'lofi2', 'chillpop', 'synth', 'house', 'ballad',
|
|
|
32
32
|
export class ErrorMorfeo extends Error {}
|
|
33
33
|
|
|
34
34
|
/**
|
|
35
|
-
* WebGL por defecto: en Mac con Apple Silicon la tarjeta de video (angle) es
|
|
36
|
-
* en lo demás, por software (swangle), que funciona en cualquier
|
|
37
|
-
* reintenta solo con swangle.
|
|
35
|
+
* WebGL por defecto: en Mac con Apple Silicon y en Windows la tarjeta de video (angle) es ~4 veces más rápida
|
|
36
|
+
* (Windows con RTX 3060: 23 s vs 91 s); en lo demás, por software (swangle), que funciona en cualquier
|
|
37
|
+
* computadora. Si angle falla, el render reintenta solo con swangle.
|
|
38
38
|
*/
|
|
39
|
-
export const glPorDefecto = () =>
|
|
39
|
+
export const glPorDefecto = () =>
|
|
40
|
+
(process.platform === 'darwin' && process.arch === 'arm64') || process.platform === 'win32' ? 'angle' : 'swangle';
|
|
40
41
|
|
|
41
42
|
/** Una carpeta de guiones: cada <id>.json o <id>.mjs es un video. */
|
|
42
43
|
async function leerCarpeta(dir) {
|
|
@@ -151,8 +152,9 @@ export async function cargarProyecto(proyecto = process.cwd()) {
|
|
|
151
152
|
crf: r.crf ?? 18,
|
|
152
153
|
gl: process.env.MORFEO_GL || process.env.REMOTION_GL || r.gl || glPorDefecto(),
|
|
153
154
|
navegador: process.env.MORFEO_NAVEGADOR || r.navegador || null,
|
|
154
|
-
concurrencia: r.concurrencia
|
|
155
|
+
concurrencia: Number(process.env.MORFEO_CONCURRENCIA) || r.concurrencia || null, // null = Morfeo elige según la máquina y el modo (ver render.mjs)
|
|
155
156
|
miniatura: r.miniatura ?? true,
|
|
157
|
+
consejos: r.consejos ?? true,
|
|
156
158
|
},
|
|
157
159
|
};
|
|
158
160
|
}
|
|
@@ -311,9 +313,10 @@ function validarVideo(id, v, ctx) {
|
|
|
311
313
|
if (v.voice != null) voice = normalizarVoz(v.voice, ctx, err);
|
|
312
314
|
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)');
|
|
313
315
|
if (v.hud != null) validarCapas(v.hud, 'hud', err);
|
|
316
|
+
if (v.mundo != null) validarCapas(v.mundo, 'mundo', err);
|
|
314
317
|
// archivos de assets/ que piden las escenas y el HUD: mejor avisar ahora que a mitad del render
|
|
315
|
-
revisarArchivos({scenes, hud: v.hud, tema: v.tema}, ctx.assetsDir, err);
|
|
316
|
-
revisarPersonajes({scenes, hud: v.hud}, ctx.personajes, err);
|
|
318
|
+
revisarArchivos({scenes, hud: v.hud, mundo: v.mundo, tema: v.tema}, ctx.assetsDir, err);
|
|
319
|
+
revisarPersonajes({scenes, hud: v.hud, mundo: v.mundo}, ctx.personajes, err);
|
|
317
320
|
revisarEscenarios({scenes, hud: v.hud, tema: v.tema}, err);
|
|
318
321
|
return {...v, estilo, voice, music: normalizarMusica(v.music, ctx, err), scenes};
|
|
319
322
|
}
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
// Estructura base de un video corto que retiene: reglas medidas en 19 videos de referencia (virales y bien hechos),
|
|
2
|
+
// una revisión automática de cada guion y el texto que se le da a la IA cuando escribe guiones.
|
|
3
|
+
//
|
|
4
|
+
// Mediciones (2026-11, 19 videos de 38 a 179 s, la mayoría ~55–65 s):
|
|
5
|
+
// - Todos arrancan DENTRO de la escena: el sujeto ya está en pantalla en el cuadro 1 y la voz empieza de inmediato.
|
|
6
|
+
// Ninguno abre con un título, un logo o una presentación.
|
|
7
|
+
// - Primeras palabras = gancho en segunda persona o con una cifra imposible: "Imagina que te despierta una alerta
|
|
8
|
+
// nuclear", "¿Y si tu nariz creciera cada vez que respiras?", "Si tu pareja te dice que ya no siente lo mismo",
|
|
9
|
+
// "A diez abuelitos de noventa los pusieron a levantar pesas", "Este es Alberto. Pero en el pueblo todos le...".
|
|
10
|
+
// - Ritmo: los de cortes rápidos cambian de plano cada 2,5–5 s (11–22 cortes por minuto; uno llega a 1,5 s).
|
|
11
|
+
// Los de mundo continuo casi no cortan (1–5 por minuto) pero la cámara nunca se queda quieta y entra algo nuevo
|
|
12
|
+
// (etiqueta, dato, personaje) cada 2–4 s.
|
|
13
|
+
// - Texto en dos niveles: subtítulo chico (lo que se dice) + un rótulo grande con la idea del momento
|
|
14
|
+
// ("¿CUÁNTO SUBIÓ?", "¡CASI EL TRIPLE!", "SALIR DE CASA", "DÍA 21").
|
|
15
|
+
// - Preguntas antes de cada revelación ("¿Para qué?", "¿Cuánto subió?", "¿...o músculo?") y la respuesta con un número
|
|
16
|
+
// que cuenta o un golpe visual.
|
|
17
|
+
// - Datos y nombres pegados a lo que señalan: "DOÑA ROSA · la mamá, 48 años", "▶ Te despiertas", "EL CABLE", "57 toneladas".
|
|
18
|
+
// - Series: una pastilla fija arriba ("TITO DESDE CERO · EP. 1", "GYM CON CIENCIA · PARTE 5") y contadores persistentes.
|
|
19
|
+
// - Cierre con una acción concreta, no solo "sígueme": elegir A o B en comentarios, "escribe PASO si entendiste",
|
|
20
|
+
// "mándaselo al que dice que...", cuenta regresiva para la próxima parte.
|
|
21
|
+
|
|
22
|
+
/** Las reglas, en el orden en que se le explican a la IA y a quien escribe un guion. */
|
|
23
|
+
export const REGLAS = [
|
|
24
|
+
{id: 'gancho', regla: 'Gancho en los primeros 2 segundos', como: 'La primera frase pone al espectador dentro ("Imagina que…", "¿Y si tu…?", "Si tu…") o suelta una cifra o situación imposible. El sujeto ya está en pantalla desde el cuadro 1. Nada de títulos, logos ni "hoy te voy a contar".'},
|
|
25
|
+
{id: 'tu', regla: 'Habla de tú', como: 'Segunda persona y presente: "te despiertas", "abres la puerta". Si es una historia, un nombre propio en la primera frase ("Este es Alberto").'},
|
|
26
|
+
{id: 'ritmo', regla: 'Algo nuevo cada 2–4 segundos', como: 'Escenas de 2 a 5 s de voz (una idea por escena). Si la escena dura más, que algo entre a media frase (un dato, una etiqueta, un movimiento de cámara).'},
|
|
27
|
+
{id: 'pregunta', regla: 'Pregunta antes de revelar', como: 'Antes de cada dato fuerte, la pregunta que el espectador se haría ("¿Cuánto subió?"); la respuesta llega en la escena siguiente con un número que cuenta o un golpe visual.'},
|
|
28
|
+
{id: 'rotulo', regla: 'Texto en dos niveles', como: 'El subtítulo dice lo que se oye; un rótulo grande y corto (2–4 palabras) resume el momento: "¿CUÁNTO SUBIÓ?", "SALIR DE CASA", "DÍA 21".'},
|
|
29
|
+
{id: 'etiquetas', regla: 'Datos pegados a las cosas', como: 'Nombres y cifras junto a lo que señalan (nombre de personaje con su rol, "▶ acción", "57 toneladas" junto a la ballena), no en un letrero aparte.'},
|
|
30
|
+
{id: 'escalada', regla: 'Que suba la apuesta', como: 'Cada escena más grave, más grande o más rara que la anterior; lo más fuerte cerca del final, no al principio.'},
|
|
31
|
+
{id: 'cierre', regla: 'Cierre con una acción concreta', como: 'Pide algo fácil y específico: elegir A o B en comentarios, escribir una palabra ("escribe PASO"), mandárselo a alguien ("mándaselo al que dice que…") o esperar la parte 2. "Sígueme" solo no basta.'},
|
|
32
|
+
{id: 'bucle', regla: 'Final que conecta con el inicio', como: 'La última frase puede regresar a la primera imagen o dejar una pregunta abierta, para que se vuelva a ver.'},
|
|
33
|
+
{id: 'duracion', regla: 'Entre 30 y 65 segundos', como: 'La mayoría de los de referencia dura ~1 minuto. Más de 75 s solo si es una serie o una historia que lo sostiene.'},
|
|
34
|
+
];
|
|
35
|
+
|
|
36
|
+
/** Las reglas como texto para el prompt de la IA. */
|
|
37
|
+
export const REGLAS_TEXTO = `Estructura (medida en videos virales; síguela):
|
|
38
|
+
${REGLAS.map((r, i) => `${i + 1}. ${r.regla}: ${r.como}`).join('\n')}`;
|
|
39
|
+
|
|
40
|
+
const palabrasDe = (s) => String(s ?? '').trim().split(/\s+/).filter(Boolean);
|
|
41
|
+
const GANCHOS = /^(¿|imagina|y si|si tu|si te|si un|qué pasaría|que pasaria|este es|esta es|esto es|nadie|nunca|todos|a \S+ |hay |tu |tú |te |tienes|cuando)/i;
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Revisa la estructura de un video y devuelve consejos (no bloquea el render).
|
|
45
|
+
* @param {object} v el video ya validado (scenes, mundo, serie...)
|
|
46
|
+
* @param {object} [d] datos.json de ese video (tiempos reales de la voz); sin él solo se revisa el texto
|
|
47
|
+
* @returns {{id: string, nivel: 'aviso' | 'idea', texto: string}[]}
|
|
48
|
+
*/
|
|
49
|
+
export function revisarEstructura(v, d) {
|
|
50
|
+
const out = [];
|
|
51
|
+
const add = (id, nivel, texto) => out.push({id, nivel, texto});
|
|
52
|
+
const escenas = (v.scenes ?? []).filter(Boolean);
|
|
53
|
+
const cuerpo = escenas.filter((s) => s.type !== 'cta');
|
|
54
|
+
if (!cuerpo.length) return out;
|
|
55
|
+
const primera = cuerpo[0];
|
|
56
|
+
const clasico = (v.estilo ?? 'datos-curiosos') === 'datos-curiosos';
|
|
57
|
+
|
|
58
|
+
// 1) gancho
|
|
59
|
+
const dicho = String(primera.say ?? '').trim();
|
|
60
|
+
if (!GANCHOS.test(dicho) && !/\d/.test(primera.show ?? '') && !/\?/.test(dicho))
|
|
61
|
+
add('gancho', 'idea', `La primera frase ("${dicho.slice(0, 50)}${dicho.length > 50 ? '…' : ''}") no engancha de entrada: prueba "Imagina que…", "¿Y si tu…?" o una cifra imposible.`);
|
|
62
|
+
if (!clasico && !v.mundo?.length && ['titular', 'dato', 'lista'].includes(primera.type) && !(primera.capas ?? []).length)
|
|
63
|
+
add('gancho', 'aviso', 'El video abre con un letrero sin imagen. En los videos de referencia el sujeto ya está en pantalla desde el primer cuadro.');
|
|
64
|
+
if (/^(hola|hoy|bienvenid|en este video|te voy a)/i.test(dicho)) add('gancho', 'aviso', 'No abras presentando ("Hoy te voy a contar…"): entra directo a la situación.');
|
|
65
|
+
|
|
66
|
+
// 2) ritmo y duración (con los tiempos reales si ya hay voz)
|
|
67
|
+
const es = d?.escenas?.filter((e) => e.tipo !== 'cta') ?? [];
|
|
68
|
+
if (es.length) {
|
|
69
|
+
const dur = d.duracion;
|
|
70
|
+
if (dur > 75) add('duracion', 'idea', `Dura ${Math.round(dur)} s: los de referencia rondan 60 s. Si no es una serie o una historia, recorta.`);
|
|
71
|
+
if (dur < 20) add('duracion', 'idea', `Dura ${Math.round(dur)} s: un poco más de desarrollo (una pregunta y su respuesta) suele retener mejor.`);
|
|
72
|
+
const e0 = es[0];
|
|
73
|
+
if (e0 && e0.habla?.[0] > 0.5) add('gancho', 'aviso', `La voz tarda ${e0.habla[0].toFixed(1)} s en empezar: debe arrancar de inmediato.`);
|
|
74
|
+
es.forEach((e, i) => {
|
|
75
|
+
const s = cuerpo[i] ?? {};
|
|
76
|
+
const voz = (e.habla?.[1] ?? 0) - (e.habla?.[0] ?? 0);
|
|
77
|
+
// algo que cambie a media escena: capas con `en`, cámara con rango, toma con movimiento, o un mundo vivo
|
|
78
|
+
const capas = s.capas ?? [];
|
|
79
|
+
const cambia = capas.some((c) => c.en != null && c.en !== 0 && c.en !== 'inicio') || Array.isArray(s.camara?.zoom) || s.toma?.a || capas.some((c) => c.mover || c.cambios);
|
|
80
|
+
if (voz > 6 && !cambia && !v.mundo?.length) add('ritmo', 'aviso', `Escena ${i + 1}: ${voz.toFixed(1)} s de voz sin que nada cambie. Pártela en dos o haz que algo entre a media frase.`);
|
|
81
|
+
else if (voz > 9) add('ritmo', 'idea', `Escena ${i + 1}: ${voz.toFixed(1)} s de voz en una sola escena; en los de referencia ninguna idea pasa de ~5 s.`);
|
|
82
|
+
});
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// 3) preguntas y rótulos
|
|
86
|
+
const preguntas = cuerpo.slice(1).filter((s) => /\?/.test(s.say ?? '') || /\?/.test(s.rotulo ?? ''));
|
|
87
|
+
if (cuerpo.length >= 4 && !preguntas.length) add('pregunta', 'idea', 'Ninguna pregunta a media historia: antes del dato más fuerte pon la pregunta que se haría el espectador ("¿Cuánto subió?").');
|
|
88
|
+
if (!clasico && cuerpo.length >= 4 && !cuerpo.some((s) => s.rotulo || s.type === 'titular')) add('rotulo', 'idea', 'Sin rótulos: un `rotulo` corto en 2–3 escenas clave ("¿CUÁNTO SUBIÓ?") da el segundo nivel de texto que usan los de referencia.');
|
|
89
|
+
|
|
90
|
+
// 4) cierre
|
|
91
|
+
const fin = escenas.at(-1);
|
|
92
|
+
const pideAlgo = escenas.some((s) => s.type === 'eleccion') || (fin?.type === 'cta' && fin.pide) || /comenta|escribe|mándaselo|mandaselo|comparte|elige|parte 2|siguiente parte/i.test(cuerpo.at(-1)?.say ?? '');
|
|
93
|
+
if (!pideAlgo) add('cierre', 'idea', "El cierre solo pide seguir. Pide algo concreto: {type: 'cta', pide: 'comenta', palabra: 'PASO'}, pide: 'comparte' o una escena 'eleccion' (A o B).");
|
|
94
|
+
|
|
95
|
+
// 5) habla de tú
|
|
96
|
+
const todo = cuerpo.map((s) => s.say ?? '').join(' ');
|
|
97
|
+
const n = palabrasDe(todo).length;
|
|
98
|
+
const tu = (todo.match(/\b(tú|tu|tus|te|ti|contigo|imagina|tienes|eres|puedes|sabes)\b/gi) ?? []).length;
|
|
99
|
+
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.');
|
|
100
|
+
return out;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/** Consejos en texto para la terminal. */
|
|
104
|
+
export const consejosEnTexto = (lista) => lista.map((c) => ` ${c.nivel === 'aviso' ? '!' : '·'} ${c.texto}`).join('\n');
|
package/lib/guion.mjs
CHANGED
|
@@ -5,6 +5,7 @@ import os from 'node:os';
|
|
|
5
5
|
import path from 'node:path';
|
|
6
6
|
import {ErrorMorfeo, MUSICAS} from './config.mjs';
|
|
7
7
|
import {listaTemas} from './diccionario.mjs';
|
|
8
|
+
import {REGLAS_TEXTO} from './estructura.mjs';
|
|
8
9
|
|
|
9
10
|
export const MODELO_GUION = 'claude-opus-5-5';
|
|
10
11
|
export const TIPOS_IA = ['title', 'year', 'text', 'icon', 'compare', 'card', 'dialog'];
|
|
@@ -27,7 +28,7 @@ export const slug = (s) => {
|
|
|
27
28
|
return corto.slice(0, Math.max(corto.lastIndexOf('-'), 12)).replace(/-+$/, '') || 'video';
|
|
28
29
|
};
|
|
29
30
|
|
|
30
|
-
const INSTRUCCIONES = `Eres guionista de videos cortos verticales (YouTube Shorts / TikTok / Reels) de
|
|
31
|
+
const INSTRUCCIONES = `Eres guionista de videos cortos verticales (YouTube Shorts / TikTok / Reels) de 30 a 60 segundos.
|
|
31
32
|
Escribes en el idioma que se te pida, con frases cortas, naturales y fáciles de decir en voz alta.
|
|
32
33
|
|
|
33
34
|
Un video es una lista de escenas. Cada escena tiene "say": lo que dice la voz (una o dos frases cortas).
|
|
@@ -35,7 +36,7 @@ Los números y años en "say" van ESCRITOS CON LETRAS ("mil novecientos ochenta"
|
|
|
35
36
|
en ese caso pon en "show" la misma frase con cifras ("1980") para el subtítulo. Si no hay números, "show" es null.
|
|
36
37
|
|
|
37
38
|
Tipos de escena (elige el que mejor ilustre cada frase y varíalos):
|
|
38
|
-
- title: el gancho, SIEMPRE la primera. "rows": 2-3 filas
|
|
39
|
+
- title: el gancho, SIEMPRE la primera. "say" es la frase gancho (ver Estructura); "rows": 2-3 filas con lo esencial del gancho; la última es el remate (color amarillo o rojo). "icon": un ícono.
|
|
39
40
|
- year: una fecha clave. "pre": contexto corto (p. ej. "JAPÓN"), "year": el año como número, "sub": qué pasó (2-3 palabras).
|
|
40
41
|
- text: 1-3 filas de texto grande con la idea clave.
|
|
41
42
|
- icon: un ícono grande con "label" (2-3 palabras) y "color".
|
|
@@ -47,7 +48,9 @@ Tipos de escena (elige el que mejor ilustre cada frase y varíalos):
|
|
|
47
48
|
Reglas de texto en pantalla: MAYÚSCULAS, máximo 14 letras por fila, tamaños ("size") entre 110 y 260 (más corto = más grande).
|
|
48
49
|
Colores disponibles: blanco, amarillo, rojo, verde, cian. Usa blanco para lo normal y un color para lo importante.
|
|
49
50
|
"at" y "click" deben ser palabras que aparezcan tal cual en "say".
|
|
50
|
-
No incluyas la escena final de "sígueme": morfeo la agrega sola
|
|
51
|
+
No incluyas la escena final de "sígueme": morfeo la agrega sola; la última escena tuya pide algo concreto (comentar una palabra, elegir, mandárselo a alguien).
|
|
52
|
+
|
|
53
|
+
${REGLAS_TEXTO}`;
|
|
51
54
|
|
|
52
55
|
/** Esquema plano (todas las claves presentes, null cuando no aplican) para la salida estructurada. */
|
|
53
56
|
export async function esquema() {
|
|
@@ -93,7 +96,7 @@ const OBJETOS = ['corazon', 'estrella', 'rayo', 'diamante', 'trofeo', 'cohete',
|
|
|
93
96
|
|
|
94
97
|
const ESTILOS_MOTOR = ['explicativo', 'documental', 'ilustrado', 'historia', 'mesa', 'minimal'];
|
|
95
98
|
|
|
96
|
-
const INSTRUCCIONES_MOTOR = `Eres guionista y director de arte de videos cortos verticales (YouTube Shorts / TikTok / Reels) de
|
|
99
|
+
const INSTRUCCIONES_MOTOR = `Eres guionista y director de arte de videos cortos verticales (YouTube Shorts / TikTok / Reels) de 30 a 60 segundos.
|
|
97
100
|
Escribes en el idioma que se te pida, con frases cortas y naturales para decir en voz alta.
|
|
98
101
|
Los subtítulos ya salen solos arriba: en pantalla pon solo lo que ILUSTRA la frase, no la repitas.
|
|
99
102
|
|
|
@@ -111,8 +114,13 @@ Tipos de escena:
|
|
|
111
114
|
4. "emoji" SOLO si nada de lo anterior encaja (es el último recurso),
|
|
112
115
|
más un "chip" opcional (dato corto como "×5" o "230 dB") que aparece en la palabra "en".
|
|
113
116
|
- eleccion: final abierto para que el público comente: "pregunta" y 2 "opciones" ({texto, sub, objeto}; emoji solo si ningún objeto encaja). Úsala al final en series o historias.
|
|
117
|
+
Además, en cualquier escena:
|
|
118
|
+
- "rotulo": 2–4 palabras grandes que resumen el momento ("¿CUÁNTO *SUBIÓ*?", "SALIR DE CASA"), con la clave entre *asteriscos*. Úsalo en 2–4 escenas, sobre todo en preguntas y revelaciones; null en las demás.
|
|
119
|
+
- "claves": 1–2 palabras de "say" que se resaltan en el subtítulo (la idea de la frase), o null.
|
|
114
120
|
Varía los tipos. "at", "check" y "en" deben ser palabras que aparezcan tal cual en "say".
|
|
115
|
-
No
|
|
121
|
+
No escribas la escena de cierre: va en "cierre" (lo que pide al final: "comenta" una "palabra", "comparte" con "a" quién se lo mandan, o "sigue"), con su "say".
|
|
122
|
+
|
|
123
|
+
${REGLAS_TEXTO}
|
|
116
124
|
|
|
117
125
|
Diccionario de recursos por tema (usa estas palabras en "recurso"):
|
|
118
126
|
${Object.entries(listaTemas()).map(([t, v]) => `- ${t}: ${v.join(', ')}`).join('\n')}`;
|
|
@@ -144,8 +152,11 @@ export async function esquemaMotor() {
|
|
|
144
152
|
en: z.string().nullable(),
|
|
145
153
|
pregunta: z.string().nullable(),
|
|
146
154
|
opciones: z.array(z.object({texto: z.string(), sub: z.string(), objeto: z.enum(OBJETOS).nullable(), emoji: z.string().nullable()})).nullable(),
|
|
155
|
+
rotulo: z.string().nullable(),
|
|
156
|
+
claves: z.array(z.string()).nullable(),
|
|
147
157
|
});
|
|
148
|
-
|
|
158
|
+
const cierre = z.object({pide: z.enum(['comenta', 'comparte', 'sigue']), say: z.string(), palabra: z.string().nullable(), a: z.string().nullable()});
|
|
159
|
+
return z.object({id: z.string(), title: z.string(), music: z.enum(MUSICAS), scenes: z.array(escena), cierre});
|
|
149
160
|
}
|
|
150
161
|
|
|
151
162
|
/** De la salida de la IA (motor de capas) al formato de escenas de morfeo. */
|
|
@@ -153,6 +164,8 @@ function aEscenaMotor(e, conImagenes) {
|
|
|
153
164
|
const out = {type: e.type, say: e.say};
|
|
154
165
|
if (e.show && e.show !== e.say) out.show = e.show;
|
|
155
166
|
const si = (k, v) => v != null && v !== '' && (out[k] = v);
|
|
167
|
+
si('rotulo', e.rotulo);
|
|
168
|
+
if (e.claves?.length) out.claves = e.claves;
|
|
156
169
|
switch (e.type) {
|
|
157
170
|
case 'titular':
|
|
158
171
|
si('texto', e.texto ?? '');
|
|
@@ -252,7 +265,9 @@ export async function guionConIA(o) {
|
|
|
252
265
|
const pedido = o.texto
|
|
253
266
|
? `Convierte este guion en escenas. Respeta su contenido y su orden; puedes partir frases largas.\n\n<guion>\n${o.texto}\n</guion>`
|
|
254
267
|
: `Escribe un video sobre: ${o.tema}`;
|
|
255
|
-
|
|
268
|
+
// ~45 s por defecto: los videos de referencia rondan el minuto y ninguna idea pasa de ~5 s (lib/estructura.mjs)
|
|
269
|
+
const dur = o.duracion ?? 45;
|
|
270
|
+
const extra = [`Idioma: ${idioma}.`, `Duración aproximada: ${dur} segundos (unas ${Math.round(dur / 4)} escenas de 3 a 5 segundos).`];
|
|
256
271
|
if (o.tono) extra.push(`Tono: ${o.tono}.`);
|
|
257
272
|
const motor = o.estilo && o.estilo !== 'datos-curiosos';
|
|
258
273
|
if (motor) extra.push(`Estilo visual: ${o.estilo}.${o.imagenes ? ' Se pueden generar ilustraciones (usa imagen_ia en las escenas capas).' : ' No hay ilustraciones: usa emoji en las escenas capas.'}`);
|
|
@@ -278,7 +293,9 @@ export async function guionConIA(o) {
|
|
|
278
293
|
if (!g) throw new ErrorMorfeo('Claude no devolvió un guion válido; intenta otra vez.');
|
|
279
294
|
if (motor) {
|
|
280
295
|
const scenes = g.scenes.map((e) => aEscenaMotor(e, !!o.imagenes));
|
|
281
|
-
|
|
296
|
+
const c = g.cierre;
|
|
297
|
+
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} : {})};
|
|
298
|
+
return {id: slug(g.id || g.title), video: {estilo: o.estilo, title: g.title, voice: o.voz ?? 'em_alex', music: g.music, scenes: [...scenes, cta]}};
|
|
282
299
|
}
|
|
283
300
|
const scenes = g.scenes.map(aEscena);
|
|
284
301
|
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
ADDED
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
// `morfeo hoja <id>`: hoja de contacto (PNG con cuadros repartidos en el tiempo) para revisar un video de un vistazo.
|
|
2
|
+
// El ffmpeg que trae Remotion viene recortado (sin los filtros `tile` ni `fps`), así que cada cuadro se pide como
|
|
3
|
+
// PNG sin predicción por una tubería y la hoja se arma aquí con zlib.
|
|
4
|
+
import {spawnSync} from 'node:child_process';
|
|
5
|
+
import fs from 'node:fs';
|
|
6
|
+
import {createRequire} from 'node:module';
|
|
7
|
+
import path from 'node:path';
|
|
8
|
+
import zlib from 'node:zlib';
|
|
9
|
+
import {ErrorMorfeo} from './config.mjs';
|
|
10
|
+
|
|
11
|
+
const require = createRequire(import.meta.url);
|
|
12
|
+
|
|
13
|
+
/** Rutas de ffmpeg y ffprobe: las del paquete de Remotion de esta plataforma; si no, las del PATH. */
|
|
14
|
+
function binarios() {
|
|
15
|
+
const exe = process.platform === 'win32' ? '.exe' : '';
|
|
16
|
+
const libc = process.platform === 'linux' ? (process.report?.getReport?.().header?.glibcVersionRuntime ? '-gnu' : '-musl') : '';
|
|
17
|
+
const sufijo = process.platform === 'win32' ? '-msvc' : libc;
|
|
18
|
+
try {
|
|
19
|
+
const dir = path.dirname(require.resolve(`@remotion/compositor-${process.platform}-${process.arch}${sufijo}/package.json`));
|
|
20
|
+
if (fs.existsSync(path.join(dir, `ffmpeg${exe}`))) return {ffmpeg: path.join(dir, `ffmpeg${exe}`), ffprobe: path.join(dir, `ffprobe${exe}`), cwd: dir};
|
|
21
|
+
} catch {}
|
|
22
|
+
return {ffmpeg: 'ffmpeg', ffprobe: 'ffprobe', cwd: undefined};
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const CRC = (() => {
|
|
26
|
+
const t = new Uint32Array(256);
|
|
27
|
+
for (let n = 0; n < 256; n++) {
|
|
28
|
+
let c = n;
|
|
29
|
+
for (let k = 0; k < 8; k++) c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1;
|
|
30
|
+
t[n] = c >>> 0;
|
|
31
|
+
}
|
|
32
|
+
return t;
|
|
33
|
+
})();
|
|
34
|
+
const crc32 = (b) => {
|
|
35
|
+
let c = 0xffffffff;
|
|
36
|
+
for (let i = 0; i < b.length; i++) c = CRC[(c ^ b[i]) & 255] ^ (c >>> 8);
|
|
37
|
+
return (c ^ 0xffffffff) >>> 0;
|
|
38
|
+
};
|
|
39
|
+
const trozo = (tipo, datos) => {
|
|
40
|
+
const cab = Buffer.alloc(8);
|
|
41
|
+
cab.writeUInt32BE(datos.length, 0);
|
|
42
|
+
cab.write(tipo, 4, 'latin1');
|
|
43
|
+
const fin = Buffer.alloc(4);
|
|
44
|
+
fin.writeUInt32BE(crc32(Buffer.concat([cab.subarray(4), datos])), 0);
|
|
45
|
+
return Buffer.concat([cab, datos, fin]);
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
/** PNG RGB de 8 bits sin predicción -> píxeles crudos (alto × ancho × 3). */
|
|
49
|
+
function leerPng(b, w, h) {
|
|
50
|
+
const idat = [];
|
|
51
|
+
for (let p = 8; p + 8 <= b.length; ) {
|
|
52
|
+
const largo = b.readUInt32BE(p);
|
|
53
|
+
if (b.toString('latin1', p + 4, p + 8) === 'IDAT') idat.push(b.subarray(p + 8, p + 8 + largo));
|
|
54
|
+
p += 12 + largo;
|
|
55
|
+
}
|
|
56
|
+
const crudo = zlib.inflateSync(Buffer.concat(idat));
|
|
57
|
+
const fila = 1 + w * 3;
|
|
58
|
+
if (crudo.length !== fila * h) throw new Error('cuadro con tamaño inesperado');
|
|
59
|
+
const out = Buffer.alloc(w * h * 3);
|
|
60
|
+
for (let y = 0; y < h; y++) crudo.copy(out, y * w * 3, y * fila + 1, (y + 1) * fila);
|
|
61
|
+
return out;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* @param {{video: string, salida?: string, cols?: number, filas?: number, desde?: number, hasta?: number, ancho?: number}} op
|
|
66
|
+
* @returns {string} ruta del PNG
|
|
67
|
+
*/
|
|
68
|
+
export function hoja({video, salida, cols = 5, filas = 3, desde = 0, hasta, ancho = 216}) {
|
|
69
|
+
if (!fs.existsSync(video)) throw new ErrorMorfeo(`No encuentro ${video}. Renderízalo primero: npx morfeo render`);
|
|
70
|
+
const {ffmpeg, ffprobe, cwd} = binarios();
|
|
71
|
+
const sonda = spawnSync(ffprobe, ['-v', 'error', '-select_streams', 'v:0', '-show_entries', 'stream=width,height:format=duration', '-of', 'csv=p=0', video], {encoding: 'utf8', cwd});
|
|
72
|
+
if (sonda.status !== 0) throw new ErrorMorfeo(`No pude leer ${video} (¿falta ffprobe?)`);
|
|
73
|
+
const [dim, dur] = sonda.stdout.trim().split(/\r?\n/);
|
|
74
|
+
const [vw, vh] = dim.split(',').map(Number);
|
|
75
|
+
const fin = hasta ?? Number(dur);
|
|
76
|
+
const w = ancho - (ancho % 2);
|
|
77
|
+
const h = Math.round((w * vh) / vw / 2) * 2;
|
|
78
|
+
const n = cols * filas;
|
|
79
|
+
const W = cols * w;
|
|
80
|
+
const H = filas * h;
|
|
81
|
+
const lienzo = Buffer.alloc(H * (1 + W * 3)); // cada fila empieza con el byte de filtro 0
|
|
82
|
+
for (let i = 0; i < n; i++) {
|
|
83
|
+
const t = desde + ((fin - desde) * (i + 0.5)) / n;
|
|
84
|
+
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});
|
|
85
|
+
if (r.status !== 0 || !r.stdout?.length) continue;
|
|
86
|
+
const px = leerPng(r.stdout, w, h);
|
|
87
|
+
const x0 = (i % cols) * w;
|
|
88
|
+
const y0 = Math.floor(i / cols) * h;
|
|
89
|
+
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);
|
|
90
|
+
}
|
|
91
|
+
const ihdr = Buffer.alloc(13);
|
|
92
|
+
ihdr.writeUInt32BE(W, 0);
|
|
93
|
+
ihdr.writeUInt32BE(H, 4);
|
|
94
|
+
ihdr.set([8, 2, 0, 0, 0], 8);
|
|
95
|
+
const destino = salida ?? video.replace(/\.[^.]+$/, '') + '-hoja.png';
|
|
96
|
+
fs.writeFileSync(destino, Buffer.concat([Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]), trozo('IHDR', ihdr), trozo('IDAT', zlib.deflateSync(lienzo, {level: 6})), trozo('IEND', Buffer.alloc(0))]));
|
|
97
|
+
return destino;
|
|
98
|
+
}
|
package/lib/imagenes.mjs
CHANGED
|
@@ -62,6 +62,7 @@ export async function generarImagenes(video, cfgImagenes, cacheDir, assetsDir, l
|
|
|
62
62
|
};
|
|
63
63
|
const scenes = recorrer(video.scenes);
|
|
64
64
|
const hud = recorrer(video.hud ?? []);
|
|
65
|
+
const mundo = recorrer(video.mundo ?? []);
|
|
65
66
|
if (!pendientes.length) return video;
|
|
66
67
|
const cfg = cfgImagenes ?? {};
|
|
67
68
|
const proveedor = cfg.proveedor ?? 'openai';
|
|
@@ -95,5 +96,5 @@ export async function generarImagenes(video, cfgImagenes, cacheDir, assetsDir, l
|
|
|
95
96
|
if (x.__src) out.src = x.__src.ruta;
|
|
96
97
|
return out;
|
|
97
98
|
};
|
|
98
|
-
return {...video, scenes: fijar(scenes), hud: fijar(hud)};
|
|
99
|
+
return {...video, scenes: fijar(scenes), hud: fijar(hud), mundo: fijar(mundo)};
|
|
99
100
|
}
|
package/lib/instalar.mjs
CHANGED
|
@@ -22,7 +22,7 @@ import {
|
|
|
22
22
|
venvListo,
|
|
23
23
|
versionPython,
|
|
24
24
|
} from './entorno.mjs';
|
|
25
|
-
import {whisperListo, instalarWhisper} from './whisper.mjs';
|
|
25
|
+
import {faltaParaWhisper, whisperListo, instalarWhisper} from './whisper.mjs';
|
|
26
26
|
|
|
27
27
|
const ok = (m) => console.log(` ✔ ${m}`);
|
|
28
28
|
const no = (m) => console.log(` ✘ ${m}`);
|
|
@@ -154,7 +154,7 @@ export async function instalar(op = {}) {
|
|
|
154
154
|
if (process.platform === 'darwin' && spawnSync('xcode-select', ['-p'], {stdio: 'ignore'}).status !== 0) {
|
|
155
155
|
no('Whisper se compila y necesita las herramientas de Apple.');
|
|
156
156
|
info('Corre: xcode-select --install y cuando termine: npx morfeo instalar --whisper');
|
|
157
|
-
} else {
|
|
157
|
+
} else if (await herramientasWhisper(op)) {
|
|
158
158
|
await instalarWhisper();
|
|
159
159
|
ok('Whisper listo');
|
|
160
160
|
}
|
|
@@ -164,6 +164,22 @@ export async function instalar(op = {}) {
|
|
|
164
164
|
console.log('\nTodo listo. Prueba: npx morfeo nuevo mi-canal && cd mi-canal && npx morfeo render');
|
|
165
165
|
}
|
|
166
166
|
|
|
167
|
+
/** Revisa git, make y cmake antes de compilar whisper.cpp; en Mac con Homebrew ofrece instalar cmake. */
|
|
168
|
+
async function herramientasWhisper(op) {
|
|
169
|
+
let falta = faltaParaWhisper();
|
|
170
|
+
if (falta.length === 1 && falta[0] === 'cmake' && process.platform === 'darwin' && tiene('brew')) {
|
|
171
|
+
if (await preguntar('Whisper necesita cmake para compilarse. ¿Lo instalo con Homebrew?', {...op, porDefecto: true})) {
|
|
172
|
+
correr('brew', ['install', 'cmake']);
|
|
173
|
+
falta = faltaParaWhisper();
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
if (!falta.length) return true;
|
|
177
|
+
no(`Whisper se compila y falta: ${falta.join(', ')}.`);
|
|
178
|
+
if (process.platform === 'darwin') info(`Corre: brew install ${falta.join(' ')} y cuando termine: npx morfeo instalar --whisper`);
|
|
179
|
+
else info(`Instálalo con tu gestor de paquetes (por ejemplo: sudo apt install ${falta.join(' ')} ) y luego: npx morfeo instalar --whisper`);
|
|
180
|
+
return false;
|
|
181
|
+
}
|
|
182
|
+
|
|
167
183
|
/** Devuelve el estado de cada pieza; con imprimir=true lo muestra. */
|
|
168
184
|
export async function doctor({imprimir = true} = {}) {
|
|
169
185
|
const estado = [];
|
package/lib/personajes.mjs
CHANGED
|
@@ -90,5 +90,5 @@ export function ponerPoses(video, mapa) {
|
|
|
90
90
|
}
|
|
91
91
|
return o;
|
|
92
92
|
};
|
|
93
|
-
return {...video, scenes: recorrer(video.scenes), hud: recorrer(video.hud ?? [])};
|
|
93
|
+
return {...video, scenes: recorrer(video.scenes), hud: recorrer(video.hud ?? []), mundo: recorrer(video.mundo ?? [])};
|
|
94
94
|
}
|
package/lib/recursos.mjs
CHANGED
|
@@ -306,5 +306,6 @@ export async function resolverRecursos(video, assetsPublico, log = console.log)
|
|
|
306
306
|
};
|
|
307
307
|
const scenes = await recorrer(video.scenes);
|
|
308
308
|
const hud = await recorrer(video.hud ?? []);
|
|
309
|
-
|
|
309
|
+
const mundo = await recorrer(video.mundo ?? []);
|
|
310
|
+
return {video: {...video, scenes, hud, mundo}, creditos: [...creditos]};
|
|
310
311
|
}
|