morfeo 0.5.1 → 0.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/bin/morfeo.mjs +27 -3
- package/index.d.ts +20 -1
- package/lib/calidad.mjs +127 -0
- package/lib/config.mjs +10 -1
- package/lib/estructura.mjs +27 -0
- package/lib/formulas.mjs +2 -1
- package/lib/hoja.mjs +2 -2
- package/lib/recursos.mjs +6 -2
- package/lib/render.mjs +22 -6
- package/package.json +3 -1
- package/plantilla/AGENTS.md +10 -4
- package/plantilla/gustos.md +19 -0
- package/python/musica.py +43 -2
- package/python/preparar.py +1 -1
- package/remotion/kit/ambientes.tsx +635 -0
- package/remotion/kit/casa.tsx +130 -0
- package/remotion/kit/index.ts +3 -0
- package/remotion/kit/tres.tsx +150 -11
- package/remotion/motor/Video.tsx +33 -2
- package/remotion/motor/capas.tsx +24 -0
- package/remotion/motor/util.ts +2 -1
package/bin/morfeo.mjs
CHANGED
|
@@ -21,9 +21,12 @@ Videos
|
|
|
21
21
|
render [id...] Genera los videos que faltan (o los que indiques)
|
|
22
22
|
--todos regenera todos
|
|
23
23
|
--solo-fallidos reintenta solo los que fallaron en el último lote
|
|
24
|
+
--escenas 5-7 solo esas escenas (para corregir una toma sin rehacer todo) + su hoja
|
|
24
25
|
--gpu usa la tarjeta de video (más rápido; si falla, quítalo)
|
|
25
26
|
previa <id> Solo el gancho (~8 s) para aprobarlo antes del render completo: --hasta 6
|
|
26
|
-
|
|
27
|
+
animatica <id> Todo el video a 1/3 de tamaño (~3× más rápido) + su hoja: para aprobar tomas y ritmo
|
|
28
|
+
revisar [id...] Revisa la estructura (gancho, ritmo, preguntas, cierre), que la voz diga el guion, la zona segura,
|
|
29
|
+
el riesgo de "diapositiva" y, si ya está el MP4, cuadros negros, tapados, planos o quietos
|
|
27
30
|
hoja <id> Hoja de contacto (PNG con 15 cuadros) para revisar un video de un vistazo
|
|
28
31
|
--zona pinta en rojo lo que tapan los botones de TikTok, Reels y Shorts
|
|
29
32
|
--desde 8 --hasta 11 solo ese tramo (segundos) --cols 5 --filas 3
|
|
@@ -59,7 +62,7 @@ function opciones(argv) {
|
|
|
59
62
|
const [k, v] = a.slice(2).split('=');
|
|
60
63
|
const clave = k.replace(/-([a-z])/g, (_, c) => c.toUpperCase());
|
|
61
64
|
const sig = argv[i + 1];
|
|
62
|
-
const conValor = ['proyecto', 'desde', 'id', 'voz', 'duracion', 'idioma', 'estilo', 'tono', 'variante', 'fuente', 'musica', 'titulo', 'modelo', 'puerto', 'hasta', 'cols', 'filas', 'look', 'formula'];
|
|
65
|
+
const conValor = ['proyecto', 'desde', 'id', 'voz', 'duracion', 'idioma', 'estilo', 'tono', 'variante', 'fuente', 'musica', 'titulo', 'modelo', 'puerto', 'hasta', 'cols', 'filas', 'look', 'formula', 'escenas'];
|
|
63
66
|
if (v != null) op[clave] = v;
|
|
64
67
|
else if (conValor.includes(clave) && sig != null && !sig.startsWith('--')) op[clave] = argv[++i];
|
|
65
68
|
else op[clave] = true;
|
|
@@ -114,7 +117,22 @@ Pruébalo: npx morfeo render ${pos[0]} y revísalo: npx morfeo hoja ${pos[0]
|
|
|
114
117
|
case 'render': {
|
|
115
118
|
if (op.gpu) process.env.MORFEO_GL = 'angle';
|
|
116
119
|
const {render} = await import('../lib/render.mjs');
|
|
117
|
-
|
|
120
|
+
const escenas = op.escenas != null && op.escenas !== true ? String(op.escenas).split(/[-,]/).map(Number) : null;
|
|
121
|
+
if (escenas && (escenas.some((x) => !Number.isInteger(x) || x < 1) || pos.length !== 1)) throw new ErrorMorfeo('Uso: npx morfeo render mi-video --escenas 5 (o 5-7)');
|
|
122
|
+
const hechos = await render({proyecto, ids: pos, todos: !!op.todos || pos[0] === 'todos', soloFallidos: !!op.soloFallidos, escenas: escenas ? [escenas[0], escenas[escenas.length - 1]] : null});
|
|
123
|
+
if (escenas) {
|
|
124
|
+
const {hoja} = await import('../lib/hoja.mjs');
|
|
125
|
+
for (const h of hechos ?? []) console.log(hoja({video: h.mp4, cols: 5, filas: 2}));
|
|
126
|
+
}
|
|
127
|
+
return;
|
|
128
|
+
}
|
|
129
|
+
case 'animatica': {
|
|
130
|
+
if (op.gpu) process.env.MORFEO_GL = 'angle';
|
|
131
|
+
const {render} = await import('../lib/render.mjs');
|
|
132
|
+
const {hoja} = await import('../lib/hoja.mjs');
|
|
133
|
+
const hechos = await render({proyecto, ids: pos, animatica: true});
|
|
134
|
+
for (const h of hechos ?? []) console.log(hoja({video: h.mp4, cols: 6, filas: 3, ancho: 180}));
|
|
135
|
+
console.log('Si las tomas y el ritmo te gustan: npx morfeo render ' + pos.join(' '));
|
|
118
136
|
return;
|
|
119
137
|
}
|
|
120
138
|
case 'previa': {
|
|
@@ -138,6 +156,12 @@ Pruébalo: npx morfeo render ${pos[0]} y revísalo: npx morfeo hoja ${pos[0]
|
|
|
138
156
|
const f = path.join(p.trabajo, 'public', 'v', id, 'datos.json');
|
|
139
157
|
const d = fs.existsSync(f) ? JSON.parse(fs.readFileSync(f, 'utf8')) : null;
|
|
140
158
|
const c = revisarEstructura(p.videos[id], d);
|
|
159
|
+
// con el MP4 ya hecho: cuadros negros, tapados, planos o quietos (--sin-cuadros lo salta)
|
|
160
|
+
if (!op.sinCuadros) {
|
|
161
|
+
const {salidaDe} = await import('../lib/salida.mjs');
|
|
162
|
+
const {revisarCuadros} = await import('../lib/calidad.mjs');
|
|
163
|
+
c.push(...(await revisarCuadros(salidaDe(p, id), d)));
|
|
164
|
+
}
|
|
141
165
|
total += c.length;
|
|
142
166
|
console.log(c.length ? `${id}${d ? '' : ' (sin tiempos: renderízalo para revisar el ritmo)'}\n${consejosEnTexto(c)}` : `${id} ✔`);
|
|
143
167
|
}
|
package/index.d.ts
CHANGED
|
@@ -1,6 +1,9 @@
|
|
|
1
1
|
// Tipos de Morfeo: dan autocompletado en morfeo.config.mjs y videos.mjs (también en JS).
|
|
2
2
|
|
|
3
3
|
/** '#RRGGBB' o [r, g, b] */
|
|
4
|
+
export type Ambiente = 'cuarto-gamer' | 'sala' | 'arcade';
|
|
5
|
+
/** Postproceso 3D: 'cine' (foco + brillo + viñeta), 'miniatura' (tilt-shift), 'suave', false o ajustes propios. */
|
|
6
|
+
export type Post3D = 'cine' | 'miniatura' | 'suave' | false | {foco?: boolean | number; brillo?: number; vinieta?: number; miniatura?: number};
|
|
4
7
|
export type Color = string | [number, number, number];
|
|
5
8
|
/** [texto, color, tamaño] — tamaño 110–260 aprox. */
|
|
6
9
|
export type Fila = [string, Color, number];
|
|
@@ -123,6 +126,8 @@ export type Capa = CapaBase &
|
|
|
123
126
|
| {tipo: 'grupo'; capas?: Capa[]; capa?: Capa; repetir?: number; disposicion?: 'circulo' | 'fila' | 'columna' | 'cuadricula' | 'pila' | 'aleatorio'; radio?: number; espacio?: number; espacioV?: number; columnas?: number; orientar?: boolean | number; retraso?: number; area?: [number, number]; girarAzar?: number; desorden?: number}
|
|
124
127
|
| {tipo: 'modelo'; src?: string; forma?: 'moneda' | 'billete' | 'cubo' | 'esfera' | 'lingote' | Objeto3D; color?: string; metal?: number; rugosidad?: number; ancho?: number; alto?: number; tamano?: number; rotacion?: [number, number, number]; girar3d?: [number, number, number]; cantidad?: number; crecer?: boolean; duracion?: number; distancia?: number; camaraY?: number; luz?: number; reflejos?: number; /** levanta hacia la cámara los modelos planos acostados (por defecto en recursos) */ levantar?: boolean; /** tuberia */ puntos?: [number, number, number][]; radio?: number; material?: 'pvc' | 'cobre' | 'acero' | 'vidrio'; agua?: boolean; velocidad?: number; colorAgua?: string; gotas?: number; /** muro */ columnas?: number; filas?: number; colorLadrillo?: string; colorMezcla?: string; /** viga */ largo?: number; vertical?: boolean; /** escalera */ escalones?: number; /** suelo */ capas?: [string, number][]}
|
|
125
128
|
| {tipo: 'componente'; nombre: string; props?: Record<string, unknown>}
|
|
129
|
+
/** Ambiente 3D completo (set con utilería, luz y vida de fondo). Como `mundo`, cada escena elige su `plano`. */
|
|
130
|
+
| {tipo: 'ambiente'; nombre: Ambiente; plano?: string; planos?: (string | null)[]; persona?: boolean | {ropa?: string; pelo?: string; piel?: string; semilla?: number}; reaccionar?: boolean; reacciones?: {en: number; expresion?: string; pose?: string}[]; post?: Post3D; etiquetas?: {ancla?: string; pos?: [number, number, number]; texto: string; escena?: number | number[]; en?: number; hasta?: number; color?: string; lado?: 'arriba' | 'abajo' | 'izq' | 'der'}[]}
|
|
126
131
|
| {tipo: 'personaje'; nombre: string; pose?: string; poses?: {en: Momento; pose: string}[]; hablar?: boolean; ancho?: number}
|
|
127
132
|
| {tipo: 'escena3d'; src: string; camara?: {en?: Momento; pos: [number, number, number]; mirar?: [number, number, number]; fov?: number}[]; animacion?: boolean | string | string[]; velocidad?: number; mostrar?: {nodo: string; en: Momento}[]; ocultar?: {nodo: string; en: Momento}[]; fondo?: string | null; niebla?: [number, number]; luz?: number; luzAmbiente?: number; sol?: [number, number, number]; reflejos?: number; sombras?: boolean; ancho?: number; alto?: number}
|
|
128
133
|
| {tipo: 'barra'; valor: number; max?: number; titulo?: string; unidad?: string; mostrar?: boolean; color?: string; ancho?: number; alto?: number; fondo?: string; decimales?: number}
|
|
@@ -192,6 +197,15 @@ interface EscenaMotorBase extends EscenaBase {
|
|
|
192
197
|
modoHasta?: Momento;
|
|
193
198
|
/** Lugar (de `lugares` del video) donde pasa esta escena. */
|
|
194
199
|
lugar?: string;
|
|
200
|
+
/** Con `ambiente`: plano de cámara de esta escena ('general', 'persona', 'tele'…). Sin él, cada escena toma otro ángulo. */
|
|
201
|
+
plano?: string;
|
|
202
|
+
/**
|
|
203
|
+
* Cortes de cámara dentro de la escena (con `ambiente`): segundos desde que empieza. Sin decirlo, corta solo en la
|
|
204
|
+
* primera palabra clave si la escena dura más de 3,5 s (o a la mitad si pasa de 7 s). `false` los apaga.
|
|
205
|
+
*/
|
|
206
|
+
cortes?: number[] | false;
|
|
207
|
+
/** Golpe de cámara (zoom con sacudida) cuando cae el rótulo. Por defecto sí con rotuloEstilo 'sello' y 'pixel'. */
|
|
208
|
+
golpe?: boolean;
|
|
195
209
|
/** Invertir la imagen en el modo (fondo claro → oscuro). Por defecto sí en looks claros (3d, maqueta, explicativo, infografia, minimal). */
|
|
196
210
|
modoInvertir?: boolean;
|
|
197
211
|
/** Palabras clave de la escena para los subtítulos (la que lleva caja o color de acento en cada frase). */
|
|
@@ -224,7 +238,7 @@ export interface Tema {
|
|
|
224
238
|
subtitulos: {tipo: 'karaoke' | 'seleccion' | 'pop' | 'bloque' | 'palabra' | 'caja' | 'ninguno'; y: number; tam: number; color: string; pendiente: string; resalte: string; maxPalabras: number; mayusculas?: boolean | 'inicial'; contorno?: number; fondo?: string; /** Colores que rotan entre las palabras clave (palabra, pop). */ coloresClave?: string[]};
|
|
225
239
|
transicion: Transicion;
|
|
226
240
|
/** Ritmo de las animaciones: duraciones (s) y curva de las entradas. */
|
|
227
|
-
movimiento?: {entrada?: number; salida?: number; transicion?: number; curva?: 'suave' | 'resorte' | 'seca'};
|
|
241
|
+
movimiento?: {entrada?: number; salida?: number; transicion?: number; curva?: 'suave' | 'resorte' | 'seca'; /** false quita los golpes de cámara de los rótulos sello/pixel. */ golpes?: boolean};
|
|
228
242
|
/** Acabado sobre todo el cuadro: tinte, cono de luz y desenfoque de bordes. */
|
|
229
243
|
post?: {
|
|
230
244
|
tinte?: {color: string; fuerza?: number; modo?: 'multiply' | 'screen' | 'overlay' | 'soft-light'};
|
|
@@ -282,6 +296,11 @@ export interface Video {
|
|
|
282
296
|
* La cámara las recorre con la `toma` de cada escena, en lugar de cambiar de fondo en cada una.
|
|
283
297
|
* El componente recibe además `escena` (1, 2, 3...), `tEscena`, `inicios` y `camara` (para paralaje).
|
|
284
298
|
*/
|
|
299
|
+
/**
|
|
300
|
+
* Ambiente 3D listo como mundo del video: 'cuarto-gamer' | 'sala' | 'arcade' (o {nombre, persona, post}).
|
|
301
|
+
* Sirve en cualquier estilo del motor de capas; cada escena elige su `plano` o toma otro ángulo sola.
|
|
302
|
+
*/
|
|
303
|
+
ambiente?: Ambiente | {nombre: Ambiente; persona?: boolean | {ropa?: string; pelo?: string; piel?: string}; reaccionar?: boolean; post?: Post3D};
|
|
285
304
|
mundo?: Capa[];
|
|
286
305
|
/**
|
|
287
306
|
* Varios mundos en un video (biografías, historias que cambian de sitio): `{cocina: [...], boda: [...]}`. Cada escena
|
package/lib/calidad.mjs
CHANGED
|
@@ -72,3 +72,130 @@ export function revisarVoz(v, d) {
|
|
|
72
72
|
});
|
|
73
73
|
return out;
|
|
74
74
|
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Mira el video terminado (cuadros cada 0,5 s a 54×96) y avisa lo que en la hoja se ve "a ojo":
|
|
78
|
+
* negro cuadro casi negro y liso (escena sin nada)
|
|
79
|
+
* tapado un solo color cubre más del 58 % del cuadro (cámara pegada a un objeto, pantalla o pared)
|
|
80
|
+
* plano fondo liso sin variación (solo subtítulo sobre un color)
|
|
81
|
+
* quieto más de 3 s sin que nada se mueva
|
|
82
|
+
* `d` (datos.json) sirve para decir en qué escena pasa. Necesita el MP4: corre después del render.
|
|
83
|
+
* @returns {{id: string, nivel: 'aviso' | 'idea', texto: string}[]}
|
|
84
|
+
*/
|
|
85
|
+
export async function revisarCuadros(mp4, d) {
|
|
86
|
+
const {binarios, leerPng} = await import('./hoja.mjs');
|
|
87
|
+
const {spawnSync} = await import('node:child_process');
|
|
88
|
+
const fs = await import('node:fs');
|
|
89
|
+
if (!fs.existsSync(mp4)) return [];
|
|
90
|
+
const {ffmpeg, ffprobe, cwd} = binarios();
|
|
91
|
+
const sonda = spawnSync(ffprobe, ['-v', 'error', '-show_entries', 'format=duration', '-of', 'csv=p=0', mp4], {encoding: 'utf8', cwd});
|
|
92
|
+
const dur = Number(sonda.stdout?.trim());
|
|
93
|
+
if (!dur) return [];
|
|
94
|
+
const W = 54;
|
|
95
|
+
const H = 96;
|
|
96
|
+
// todos los cuadros en una pasada: -r 2 en la salida toma 2 por segundo (el ffmpeg de Remotion no trae el filtro fps)
|
|
97
|
+
// (ni rawvideo: los cuadros salen como PNG seguidos por la tubería y se separan en cada IEND)
|
|
98
|
+
const r = spawnSync(ffmpeg, ['-v', 'error', '-i', mp4, '-r', '2', '-vf', `scale=${W}:${H}`, '-f', 'image2pipe', '-c:v', 'png', '-pred', 'none', '-pix_fmt', 'rgb24', '-'], {maxBuffer: 512 * 1024 * 1024, cwd});
|
|
99
|
+
if (r.status !== 0 || !r.stdout?.length) return [];
|
|
100
|
+
const pngs = [];
|
|
101
|
+
for (let p = 0, ini = 0; p + 8 <= r.stdout.length; ) {
|
|
102
|
+
if (p === ini) p += 8; // firma PNG
|
|
103
|
+
const largo = r.stdout.readUInt32BE(p);
|
|
104
|
+
const tipo = r.stdout.toString('latin1', p + 4, p + 8);
|
|
105
|
+
p += 12 + largo;
|
|
106
|
+
if (tipo === 'IEND') pngs.push(r.stdout.subarray(ini, p)), (ini = p);
|
|
107
|
+
}
|
|
108
|
+
const tam = W * H * 3;
|
|
109
|
+
const n = pngs.length;
|
|
110
|
+
const muestras = [];
|
|
111
|
+
let prev = null;
|
|
112
|
+
for (let i = 0; i < n; i++) {
|
|
113
|
+
let px;
|
|
114
|
+
try {
|
|
115
|
+
px = leerPng(pngs[i], W, H);
|
|
116
|
+
} catch {
|
|
117
|
+
continue;
|
|
118
|
+
}
|
|
119
|
+
const cubetas = new Map();
|
|
120
|
+
let suma = 0;
|
|
121
|
+
let suma2 = 0;
|
|
122
|
+
let dif = 0;
|
|
123
|
+
for (let k = 0; k < tam; k += 3) {
|
|
124
|
+
const l = 0.299 * px[k] + 0.587 * px[k + 1] + 0.114 * px[k + 2];
|
|
125
|
+
suma += l;
|
|
126
|
+
suma2 += l * l;
|
|
127
|
+
const c = ((px[k] >> 5) << 6) | ((px[k + 1] >> 5) << 3) | (px[k + 2] >> 5);
|
|
128
|
+
cubetas.set(c, (cubetas.get(c) ?? 0) + 1);
|
|
129
|
+
if (prev) dif += Math.abs(px[k] - prev[k]) + Math.abs(px[k + 1] - prev[k + 1]) + Math.abs(px[k + 2] - prev[k + 2]);
|
|
130
|
+
}
|
|
131
|
+
const m = W * H;
|
|
132
|
+
const media = suma / m;
|
|
133
|
+
const desv = Math.sqrt(Math.max(0, suma2 / m - media * media));
|
|
134
|
+
let dom = 0;
|
|
135
|
+
let domC = 0;
|
|
136
|
+
for (const [c, v] of cubetas) if (v > dom) (dom = v), (domC = c);
|
|
137
|
+
// brillo de la cubeta dominante (0–255): un fondo nocturno oscuro no cuenta como "tapado"
|
|
138
|
+
const luzDom = 0.299 * ((domC >> 6) * 32 + 16) + 0.587 * (((domC >> 3) & 7) * 32 + 16) + 0.114 * ((domC & 7) * 32 + 16);
|
|
139
|
+
muestras.push({t: i / 2, media, desv, dom: dom / m, luzDom, dif: prev ? dif / (m * 3) : null});
|
|
140
|
+
if (process.env.MORFEO_DEBUG_CUADROS) console.log(JSON.stringify(muestras.at(-1), (k, v) => (typeof v === 'number' ? Math.round(v * 100) / 100 : v)));
|
|
141
|
+
prev = px;
|
|
142
|
+
}
|
|
143
|
+
const escenaDe = (t) => {
|
|
144
|
+
const es = d?.escenas ?? [];
|
|
145
|
+
for (let i = es.length - 1; i >= 0; i--) if (t >= es[i].t0) return {n: i + 1, cta: es[i].tipo === 'cta'};
|
|
146
|
+
return null;
|
|
147
|
+
};
|
|
148
|
+
const tipos = {
|
|
149
|
+
// medido: escenas sin nada sobre fondo oscuro → media 13–15 y la cubeta más oscura con 97 % del cuadro;
|
|
150
|
+
// tele o pared pegadas a la cámara → desviación 12–16 con media > 130; los videos buenos del canal: desviación ≥ 27
|
|
151
|
+
negro: (s) => (s.media < 16 && s.desv < 9) || (s.media < 25 && s.dom > 0.9),
|
|
152
|
+
tapado: (s) => (s.dom > 0.58 && s.luzDom > 45) || (s.desv < 18 && s.media > 60),
|
|
153
|
+
plano: (s) => s.desv < 14 && s.media >= 25 && s.media <= 60,
|
|
154
|
+
};
|
|
155
|
+
const TEXTO = {
|
|
156
|
+
negro: 'cuadro casi negro (¿escena sin nada en pantalla?)',
|
|
157
|
+
tapado: 'un solo color cubre casi todo el cuadro (¿cámara pegada a un objeto, a una pantalla o a una pared?)',
|
|
158
|
+
plano: 'fondo liso, sin nada detrás del texto (prueba un `ambiente` o un `mundo`)',
|
|
159
|
+
};
|
|
160
|
+
const out = [];
|
|
161
|
+
const fmt = (a, b) => (b - a < 0.6 ? `${a.toFixed(1)} s` : `${a.toFixed(1)}–${b.toFixed(1)} s`);
|
|
162
|
+
const donde = (a) => {
|
|
163
|
+
const e = escenaDe(a);
|
|
164
|
+
return e ? `Escena ${e.n}` : 'Video';
|
|
165
|
+
};
|
|
166
|
+
for (const [tipo, es] of Object.entries(tipos)) {
|
|
167
|
+
let ini = null;
|
|
168
|
+
for (let i = 0; i <= muestras.length; i++) {
|
|
169
|
+
const s = muestras[i];
|
|
170
|
+
const si = s && es(s) && !escenaDe(s.t)?.cta;
|
|
171
|
+
// un aviso por escena: si cambia de escena a media racha, se corta ahí
|
|
172
|
+
const otra = si && ini != null && escenaDe(s.t)?.n !== escenaDe(ini)?.n;
|
|
173
|
+
if (otra) {
|
|
174
|
+
const fin = muestras[i - 1].t;
|
|
175
|
+
if (fin - ini >= 1) out.push({id: 'cuadros', nivel: 'aviso', texto: `${donde(ini)} · ${fmt(ini, fin)}: ${TEXTO[tipo]}`});
|
|
176
|
+
ini = s.t;
|
|
177
|
+
continue;
|
|
178
|
+
}
|
|
179
|
+
if (si && ini == null) ini = s.t;
|
|
180
|
+
if (!si && ini != null) {
|
|
181
|
+
const fin = muestras[i - 1].t;
|
|
182
|
+
// un cuadro suelto (un destello, un corte) no cuenta: al menos 1 s seguido
|
|
183
|
+
if (fin - ini >= 1) out.push({id: 'cuadros', nivel: 'aviso', texto: `${donde(ini)} · ${fmt(ini, fin)}: ${TEXTO[tipo]}`});
|
|
184
|
+
ini = null;
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
// quieto: diferencia media entre cuadros (0.5 s) casi cero durante más de 3 s
|
|
189
|
+
let ini = null;
|
|
190
|
+
for (let i = 1; i <= muestras.length; i++) {
|
|
191
|
+
const s = muestras[i];
|
|
192
|
+
const quieto = s && s.dif != null && s.dif < 0.7 && !escenaDe(s.t)?.cta;
|
|
193
|
+
if (quieto && ini == null) ini = muestras[i - 1].t;
|
|
194
|
+
if (!quieto && ini != null) {
|
|
195
|
+
const fin = muestras[i - 1].t;
|
|
196
|
+
if (fin - ini >= 3) out.push({id: 'cuadros', nivel: 'aviso', texto: `${donde(ini)} · ${fmt(ini, fin)}: nada se mueve (agrega movimiento de cámara, un \`ambiente\` o algo que entre a media frase)`});
|
|
197
|
+
ini = null;
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
return out;
|
|
201
|
+
}
|
package/lib/config.mjs
CHANGED
|
@@ -27,7 +27,8 @@ export const TIPOS_ESCENA = [...new Set([...TIPOS_CLASICOS, ...TIPOS_MOTOR])];
|
|
|
27
27
|
export const ROTULOS = ['normal', 'contorno', 'pastilla', 'sello', 'paso', 'pixel'];
|
|
28
28
|
export const MODOS = ['rayosx', 'plano', 'holograma'];
|
|
29
29
|
export const ESTILOS = ['datos-curiosos', 'explicativo', 'documental', 'ilustrado', 'historia', 'mesa', 'serie', 'infografia', '3d', 'maqueta', 'minimal'];
|
|
30
|
-
export const TIPOS_CAPA = ['imagen', 'video', 'texto', 'contador', 'chip', 'etiqueta', 'numero', 'tarjeta', 'telefono', 'forma', 'icono', 'emoji', 'marca', 'particulas', 'grupo', 'modelo', 'componente', 'barra', 'medidor', 'votacion', 'estado', 'noticia', 'chat', 'onda', 'globo', 'seguir', 'personaje', 'escena3d', 'ventana', 'escenario', 'recurso', 'dato', 'medida', 'trazo', 'contorno', 'fuente', 'seccion', 'insignia', 'sello', 'cursor', 'terminal', 'nombre', 'accion', 'reloj', 'cronometro', 'termometro', 'recipientes', 'ficha', 'checklist', 'mapa', 'bloqueo', 'llamada', 'recuento', 'capitulo', 'vigilancia', 'cintillo', 'fecha', 'calendario', 'dividida', 'tierra', 'linea'];
|
|
30
|
+
export const TIPOS_CAPA = ['imagen', 'video', 'texto', 'contador', 'chip', 'etiqueta', 'numero', 'tarjeta', 'telefono', 'forma', 'icono', 'emoji', 'marca', 'particulas', 'grupo', 'modelo', 'componente', 'ambiente', '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'];
|
|
31
|
+
export const AMBIENTES = ['cuarto-gamer', 'sala', 'arcade'];
|
|
31
32
|
export const ESCENARIOS = ['bosque', 'cuarto', 'departamento', 'tunel', 'pasillo'];
|
|
32
33
|
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'];
|
|
33
34
|
export const PROVEEDORES_VOZ = ['kokoro', 'elevenlabs', 'openai'];
|
|
@@ -230,6 +231,7 @@ function validarCapas(capas, donde, err, comps) {
|
|
|
230
231
|
else if (comps && !comps.includes(c.nombre))
|
|
231
232
|
err(`${d}: el componente "${c.nombre}" no está en morfeo.config.mjs → componentes${comps.length ? ` (hay: ${comps.join(', ')})` : ''}`);
|
|
232
233
|
}
|
|
234
|
+
if (c.tipo === 'ambiente' && !AMBIENTES.includes(c.nombre)) err(`${d}: ambiente "${c.nombre}" no existe. Opciones: ${AMBIENTES.join(', ')}`);
|
|
233
235
|
if (c.tipo === 'medida' && (!Array.isArray(c.de) || !Array.isArray(c.a))) err(`${d}: \`medida\` necesita \`de: [x, y]\` y \`a: [x, y]\``);
|
|
234
236
|
if (c.tipo === 'trazo' && (!Array.isArray(c.puntos) || c.puntos.length < 2)) err(`${d}: \`trazo\` necesita \`puntos\` con al menos 2 [x, y]`);
|
|
235
237
|
if (c.tipo === 'terminal' && !Array.isArray(c.lineas)) err(`${d}: \`terminal\` necesita \`lineas\` (lista de textos)`);
|
|
@@ -320,6 +322,12 @@ function validarVideo(id, v, ctx) {
|
|
|
320
322
|
const estilo = v.estilo ?? (ctx.propio && deClasicas ? ctx.propio.estiloAnterior ?? 'datos-curiosos' : ctx.estilo);
|
|
321
323
|
const clasico = estilo === 'datos-curiosos';
|
|
322
324
|
if (!ESTILOS.includes(estilo) && !ctx.estilosPropios[estilo]) err(`estilo "${estilo}" no existe. Opciones: ${[...ESTILOS, ...Object.keys(ctx.estilosPropios)].join(', ')}`);
|
|
325
|
+
// `ambiente: 'cuarto-gamer'` (o {nombre, persona, post}) = un mundo listo con ese set
|
|
326
|
+
if (v.ambiente != null) {
|
|
327
|
+
if (v.mundo || v.lugares) err('usa `ambiente` o `mundo`/`lugares`, no los dos');
|
|
328
|
+
else if (clasico) err('`ambiente` es del motor de capas: usa un estilo como 3d, historia o minimal (no "datos-curiosos")');
|
|
329
|
+
else v = {...v, mundo: [{tipo: 'ambiente', x: 540, y: 960, entrada: 'ninguna', ...(typeof v.ambiente === 'string' ? {nombre: v.ambiente} : v.ambiente)}]};
|
|
330
|
+
}
|
|
323
331
|
const permitidos = clasico ? TIPOS_ESCENA : TIPOS_MOTOR;
|
|
324
332
|
const scenes = v.scenes.map((s0, i) => {
|
|
325
333
|
const n = `escena ${i + 1}`;
|
|
@@ -340,6 +348,7 @@ function validarVideo(id, v, ctx) {
|
|
|
340
348
|
if (s.modo != null) {
|
|
341
349
|
if (!MODOS.includes(s.modo)) err(`${n}: modo "${s.modo}" no existe. Opciones: ${MODOS.join(', ')}`);
|
|
342
350
|
} else if (s.modoEn != null || s.modoHasta != null) err(`${n}: \`modoEn\`/\`modoHasta\` necesitan \`modo\` ('rayosx', 'plano' u 'holograma')`);
|
|
351
|
+
if (s.plano != null && typeof s.plano !== 'string' && typeof s.plano !== 'object') err(`${n}: \`plano\` es el nombre de un plano del ambiente ('tele', 'persona'…) o una toma 3D`);
|
|
343
352
|
if (s.claves != null && (!Array.isArray(s.claves) || s.claves.some((k) => typeof k !== 'string'))) err(`${n}: \`claves\` debe ser una lista de palabras (['cerebro'])`);
|
|
344
353
|
if (s.toma != null) {
|
|
345
354
|
const tm = s.toma;
|
package/lib/estructura.mjs
CHANGED
|
@@ -115,6 +115,33 @@ export function revisarEstructura(v, d) {
|
|
|
115
115
|
const n = palabrasDe(todo).length;
|
|
116
116
|
const tu = (todo.match(/(?<!\p{L})(tú|tu|tus|te|ti|contigo|imagina|imaginas|tienes|eres|puedes|sabes|sabías|crees|conoces|harías|pagarías|jugabas|tuviste|tuyo|tuya)(?!\p{L})/giu) ?? []).length;
|
|
117
117
|
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.');
|
|
118
|
+
// 5b) escenas vacías y riesgo de "diapositiva" (texto sobre fondo plano, sin mundo y siempre lo mismo)
|
|
119
|
+
if (!clasico) {
|
|
120
|
+
// fondo de pantalla completa en la escena (escenario, componente propio, 3D, vigilancia…) también cuenta como mundo
|
|
121
|
+
const FONDOS = new Set(['escenario', 'componente', 'ambiente', 'escena3d', 'vigilancia', 'dividida', 'imagen', 'video', 'tierra', 'personaje']);
|
|
122
|
+
const conFondo = cuerpo.filter((s) => (s.capas ?? []).some((c) => c && FONDOS.has(c.tipo))).length;
|
|
123
|
+
const conMundo = !!(v.mundo?.length || v.lugares || v.ambiente) || conFondo / Math.max(1, cuerpo.length) >= 0.6;
|
|
124
|
+
const SOLO_TEXTO = new Set(['texto', 'chip', 'numero', 'etiqueta', 'tarjeta', 'titular', 'contador', 'checklist']);
|
|
125
|
+
let vacias = 0;
|
|
126
|
+
let soloTexto = 0;
|
|
127
|
+
const tipos = new Map();
|
|
128
|
+
cuerpo.forEach((s, i) => {
|
|
129
|
+
const capas = (s.capas ?? []).filter(Boolean);
|
|
130
|
+
if (!conMundo && (s.type ?? 'capas') === 'capas' && !capas.length && !s.rotulo) {
|
|
131
|
+
vacias++;
|
|
132
|
+
add('vacia', 'aviso', `Escena ${i + 1}: solo el subtítulo sobre el fondo. Agrega una capa o dale al video un \`ambiente: 'cuarto-gamer'\` (o 'sala', 'arcade').`);
|
|
133
|
+
}
|
|
134
|
+
if (capas.length && capas.every((c) => SOLO_TEXTO.has(c.tipo))) soloTexto++;
|
|
135
|
+
for (const c of capas) tipos.set(c.tipo, (tipos.get(c.tipo) ?? 0) + 1);
|
|
136
|
+
});
|
|
137
|
+
const m = Math.max(1, cuerpo.length);
|
|
138
|
+
const repetido = Math.max(0, ...tipos.values()) / m;
|
|
139
|
+
const riesgo = Math.round((conMundo ? 0 : 35) + (vacias / m) * 30 + (soloTexto / m) * 25 + (repetido > 0.5 ? 10 : 0));
|
|
140
|
+
if (riesgo >= 45) {
|
|
141
|
+
const por = [!conMundo && 'sin mundo ni ambiente', vacias && `${vacias} escena(s) vacías`, soloTexto && `${soloTexto} escena(s) de puro texto`, repetido > 0.5 && 'la misma capa en casi todas'].filter(Boolean).join(', ');
|
|
142
|
+
add('diapositiva', 'aviso', `Riesgo de "diapositiva" ${riesgo}/100 (${por}): se verá como texto sobre un fondo. Un \`ambiente\` o un \`mundo\` da fondo vivo y otro ángulo en cada escena.`);
|
|
143
|
+
}
|
|
144
|
+
}
|
|
118
145
|
// 6) lo propio de la fórmula del video (lib/formulas.mjs)
|
|
119
146
|
for (const t of revisarFormula(v)) add('formula', 'idea', t);
|
|
120
147
|
// 7) lo que no se ve a simple vista: la voz dijo lo del guion y nada queda bajo los botones de las apps
|
package/lib/formulas.mjs
CHANGED
|
@@ -94,7 +94,8 @@ export const FORMULAS = [
|
|
|
94
94
|
duracion: [30, 45],
|
|
95
95
|
estilos: ['ilustrado', 'explicativo'],
|
|
96
96
|
piezas: 'modo rayosx en la explicación, trazo/contorno sobre la parte, cierre comparte',
|
|
97
|
-
|
|
97
|
+
// solo si el guion habla de lo de adentro: un demuéstralo de reflejos o de vista no tiene interior que mostrar
|
|
98
|
+
revisar: (v) => (!(v.scenes ?? []).some((s) => s.modo) && /dentro|adentro|interior|por dentro/i.test((v.scenes ?? []).map((s) => s.say ?? '').join(' ')) ? ['Fórmula "demuéstralo": la explicación luce con `modo: \'rayosx\'` en la palabra donde se ve "por dentro".'] : []),
|
|
98
99
|
},
|
|
99
100
|
{
|
|
100
101
|
id: 'mito',
|
package/lib/hoja.mjs
CHANGED
|
@@ -12,7 +12,7 @@ import {ErrorMorfeo} from './config.mjs';
|
|
|
12
12
|
const require = createRequire(import.meta.url);
|
|
13
13
|
|
|
14
14
|
/** Rutas de ffmpeg y ffprobe: las del paquete de Remotion de esta plataforma; si no, las del PATH. */
|
|
15
|
-
function binarios() {
|
|
15
|
+
export function binarios() {
|
|
16
16
|
const exe = process.platform === 'win32' ? '.exe' : '';
|
|
17
17
|
const libc = process.platform === 'linux' ? (process.report?.getReport?.().header?.glibcVersionRuntime ? '-gnu' : '-musl') : '';
|
|
18
18
|
const sufijo = process.platform === 'win32' ? '-msvc' : libc;
|
|
@@ -47,7 +47,7 @@ const trozo = (tipo, datos) => {
|
|
|
47
47
|
};
|
|
48
48
|
|
|
49
49
|
/** PNG RGB de 8 bits sin predicción -> píxeles crudos (alto × ancho × 3). */
|
|
50
|
-
function leerPng(b, w, h) {
|
|
50
|
+
export function leerPng(b, w, h) {
|
|
51
51
|
const idat = [];
|
|
52
52
|
for (let p = 8; p + 8 <= b.length; ) {
|
|
53
53
|
const largo = b.readUInt32BE(p);
|
package/lib/recursos.mjs
CHANGED
|
@@ -260,11 +260,15 @@ export async function resolverRecursos(video, assetsPublico, log = console.log)
|
|
|
260
260
|
const intentos =
|
|
261
261
|
estilo === '2d'
|
|
262
262
|
? [...Object.keys(VARIANTES_2D).filter((v) => v !== (variante ?? 'color') && v !== 'linea').map((v) => ['2d', v]), ['3d'], ['realista']]
|
|
263
|
-
:
|
|
263
|
+
: // un 3D low-poly que no existe cae a 2D antes que a realista: un gato de piedra fotorrealista no
|
|
264
|
+
// combina con un video de figuras planas (pasó con 'gato' en un video del canal)
|
|
265
|
+
estilo === 'realista'
|
|
266
|
+
? [['3d'], ['2d', variante]]
|
|
267
|
+
: [['2d', variante], ['realista']];
|
|
264
268
|
for (const [otro, otraVariante] of intentos) {
|
|
265
269
|
res = await buscarRecursos(buscar, {estilo: otro, variante: otraVariante});
|
|
266
270
|
if (res.length) {
|
|
267
|
-
log(` "${buscar}" no está en ${estilo}${variante ? ' ' + variante : ''}; uso ${otro}${otraVariante && otro === '2d' ? ' ' + otraVariante : ''}`);
|
|
271
|
+
log(` ${otro === 'realista' ? '⚠ ' : ''}"${buscar}" no está en ${estilo}${variante ? ' ' + variante : ''}; uso ${otro}${otraVariante && otro === '2d' ? ' ' + otraVariante : ''}${otro === 'realista' ? ' (puede no combinar con el resto: prueba un sinónimo o estilo: \'2d\')' : ''}`);
|
|
268
272
|
break;
|
|
269
273
|
}
|
|
270
274
|
}
|
package/lib/render.mjs
CHANGED
|
@@ -213,7 +213,7 @@ export function usaWebGL(p, id) {
|
|
|
213
213
|
const v = p.videos[id];
|
|
214
214
|
if (!v) return false;
|
|
215
215
|
const texto = JSON.stringify(v);
|
|
216
|
-
if (/"tipo":"(modelo|escena3d)"/.test(texto)) return true;
|
|
216
|
+
if (/"tipo":"(modelo|escena3d|ambiente)"/.test(texto) || v.ambiente) return true;
|
|
217
217
|
for (const [nombre, ruta] of Object.entries(p.componentes ?? {})) {
|
|
218
218
|
if (!texto.includes(`"nombre":"${nombre}"`)) continue;
|
|
219
219
|
try {
|
|
@@ -230,7 +230,11 @@ export function usaWebGL(p, id) {
|
|
|
230
230
|
* @param {{proyecto?: string, ids?: string[], todos?: boolean, log?: (m: string) => void, alProgreso?: (id: string, p: number) => void}} o
|
|
231
231
|
* @returns {Promise<{id: string, mp4: string, jpg: string | null, segundos: number}[]>}
|
|
232
232
|
*/
|
|
233
|
-
export async function render({proyecto = process.cwd(), ids, todos = false, soloFallidos = false, previa = null, log = console.log, alProgreso} = {}) {
|
|
233
|
+
export async function render({proyecto = process.cwd(), ids, todos = false, soloFallidos = false, previa = null, animatica = false, escenas = null, log = console.log, alProgreso} = {}) {
|
|
234
|
+
// animática: todo el video a 1/3 de tamaño y menor calidad (para aprobar tomas y ritmo en segundos)
|
|
235
|
+
// escenas: [a, b] (1 = la primera) solo ese tramo, en tamaño completo
|
|
236
|
+
const parcial = !!(animatica || escenas);
|
|
237
|
+
if (parcial && !ids?.length) throw new ErrorMorfeo('Dime qué video: npx morfeo ' + (animatica ? 'animatica' : 'render') + ' mi-video');
|
|
234
238
|
// previa: solo el gancho (las primeras escenas, hasta `previa` segundos o ~8 s) en "<título> (previa).mp4", sin miniatura
|
|
235
239
|
if (previa && !ids?.length) throw new ErrorMorfeo('Dime qué video: npx morfeo previa mi-video');
|
|
236
240
|
cargarEnv(proyecto);
|
|
@@ -285,7 +289,8 @@ export async function render({proyecto = process.cwd(), ids, todos = false, solo
|
|
|
285
289
|
const t = Date.now();
|
|
286
290
|
try {
|
|
287
291
|
const inputProps = {videos: {[id]: datos[id]}, marca, ...extras};
|
|
288
|
-
const
|
|
292
|
+
const sufijo = previa ? ' (previa)' : animatica ? ' (animatica)' : escenas ? ` (escenas ${escenas[0]}${escenas[1] !== escenas[0] ? '-' + escenas[1] : ''})` : '';
|
|
293
|
+
const mp4 = sufijo ? salidaDe(p, id).replace(/\.mp4$/, `${sufijo}.mp4`) : salidaDe(p, id);
|
|
289
294
|
// tramo de la previa: hasta el final de la 2.ª escena (gancho + la que sigue), entre 4 y 8 s, o lo que se pida
|
|
290
295
|
const d0 = datos[id];
|
|
291
296
|
const finPrevia = typeof previa === 'number' ? previa : Math.min(8, Math.max(4, (d0.escenas[1] ?? d0.escenas[0]).t0 + (d0.escenas[1] ?? d0.escenas[0]).dur));
|
|
@@ -315,6 +320,8 @@ export async function render({proyecto = process.cwd(), ids, todos = false, solo
|
|
|
315
320
|
concurrency: concurrencia,
|
|
316
321
|
outputLocation: mp4,
|
|
317
322
|
...(previa ? {frameRange: [0, Math.min(composition.durationInFrames - 1, Math.round(finPrevia * composition.fps))]} : {}),
|
|
323
|
+
...(escenas ? {frameRange: tramoEscenas(d0, escenas, composition)} : {}),
|
|
324
|
+
...(animatica ? {scale: 1 / 3, crf: 30, jpegQuality: 70} : {}),
|
|
318
325
|
onProgress: ({progress}) => {
|
|
319
326
|
alProgreso?.(id, progress);
|
|
320
327
|
// con terminal: cada 10 % en la misma línea; sin terminal (logs, CI): cada 25 %
|
|
@@ -340,7 +347,7 @@ export async function render({proyecto = process.cwd(), ids, todos = false, solo
|
|
|
340
347
|
}
|
|
341
348
|
}
|
|
342
349
|
let jpg = null;
|
|
343
|
-
if (p.render.miniatura && !previa) {
|
|
350
|
+
if (p.render.miniatura && !previa && !parcial) {
|
|
344
351
|
// miniatura: cuando la primera escena ya está completa (o en `miniaturaEn`), sin subtítulos
|
|
345
352
|
jpg = salidaDe(p, id, 'jpg');
|
|
346
353
|
const d = datos[id];
|
|
@@ -352,7 +359,7 @@ export async function render({proyecto = process.cwd(), ids, todos = false, solo
|
|
|
352
359
|
}
|
|
353
360
|
// portada: el mismo cuadro con el título grande y la marca (`portada: 'texto'` o el título sin emojis; false = sin portada)
|
|
354
361
|
const vid = p.videos[id];
|
|
355
|
-
if (!previa && p.render.portada !== false && vid.portada !== false && jpg) {
|
|
362
|
+
if (!previa && !parcial && p.render.portada !== false && vid.portada !== false && jpg) {
|
|
356
363
|
const textoPortada = typeof vid.portada === 'string' ? vid.portada : String(vid.title ?? id).replace(/[\p{Extended_Pictographic}\uFE0F\u200D]/gu, '').replace(/\s*[|·].*$/, '').trim();
|
|
357
364
|
const d = datos[id];
|
|
358
365
|
const e0 = d.escenas[0];
|
|
@@ -374,11 +381,20 @@ export async function render({proyecto = process.cwd(), ids, todos = false, solo
|
|
|
374
381
|
} finally {
|
|
375
382
|
soltar();
|
|
376
383
|
}
|
|
377
|
-
if (previa) return hechos;
|
|
384
|
+
if (previa || parcial) return hechos;
|
|
378
385
|
return terminarLote(p, loteArchivo, hechos, fallidos, log);
|
|
379
386
|
}
|
|
380
387
|
|
|
381
388
|
const CRF = {alta: 18, media: 21, ligera: 24};
|
|
389
|
+
/** Cuadros [inicio, fin] de las escenas a..b (1 = la primera). */
|
|
390
|
+
function tramoEscenas(d, [a, b], composition) {
|
|
391
|
+
const n = d.escenas.length;
|
|
392
|
+
const ea = d.escenas[Math.max(0, Math.min(n - 1, a - 1))];
|
|
393
|
+
const eb = d.escenas[Math.max(0, Math.min(n - 1, b - 1))];
|
|
394
|
+
const fps = composition.fps;
|
|
395
|
+
return [Math.round(ea.t0 * fps), Math.min(composition.durationInFrames - 1, Math.round((eb.t0 + eb.dur) * fps) - 1)];
|
|
396
|
+
}
|
|
397
|
+
|
|
382
398
|
/** CRF del video: `crf` explícito > `calidad` del video > `render.calidad` > media con mundo, alta sin él. */
|
|
383
399
|
export function crfDe(p, id) {
|
|
384
400
|
if (p.render.crf != null) return p.render.crf;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "morfeo",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.6.0",
|
|
4
4
|
"description": "Genera videos verticales animados (Remotion + Three.js) con voz, música y subtítulos, desde un guion en código o escrito por IA.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "SEE LICENSE IN LICENSE",
|
|
@@ -62,6 +62,7 @@
|
|
|
62
62
|
"dependencies": {
|
|
63
63
|
"@anthropic-ai/sdk": "^1.6.0",
|
|
64
64
|
"@react-three/fiber": "^9.3.0",
|
|
65
|
+
"@react-three/postprocessing": "3.2.0",
|
|
65
66
|
"@remotion/bundler": "4.0.541",
|
|
66
67
|
"@remotion/cli": "4.0.541",
|
|
67
68
|
"@remotion/fonts": "4.0.541",
|
|
@@ -70,6 +71,7 @@
|
|
|
70
71
|
"@remotion/noise": "4.0.541",
|
|
71
72
|
"@remotion/renderer": "4.0.541",
|
|
72
73
|
"@remotion/three": "4.0.541",
|
|
74
|
+
"postprocessing": "6.39.5",
|
|
73
75
|
"react": "^19.2.0",
|
|
74
76
|
"react-dom": "^19.2.0",
|
|
75
77
|
"remotion": "4.0.541",
|
package/plantilla/AGENTS.md
CHANGED
|
@@ -6,6 +6,9 @@ técnica. Nunca publiques nada por ella: le entregas los archivos y ella decide
|
|
|
6
6
|
|
|
7
7
|
Comandos de referencia: `npx morfeo ayuda`. Tipos de todo lo que existe: `node_modules/morfeo/index.d.ts`.
|
|
8
8
|
|
|
9
|
+
**Antes de cada video lee `gustos.md`**: son las reglas de estilo de este canal y mandan sobre lo de abajo. Cuando
|
|
10
|
+
la persona te corrija algo que deba valer para los siguientes videos, ofrécele agregarlo a `gustos.md`.
|
|
11
|
+
|
|
9
12
|
## Cuando diga «Configúrame» (o es la primera vez)
|
|
10
13
|
|
|
11
14
|
1. Revisa que haya Node 18.17+ (`node -v`). Si falta, dile cómo instalarlo y espera.
|
|
@@ -38,12 +41,15 @@ Comandos de referencia: `npx morfeo ayuda`. Tipos de todo lo que existe: `node_m
|
|
|
38
41
|
4. **Enséñale el guion** (texto de cada escena, en limpio) y espera su visto bueno o sus cambios.
|
|
39
42
|
5. **Enséñale el gancho:** `npx morfeo previa <id>` renderiza solo los primeros segundos. Si no le convence, cámbialo
|
|
40
43
|
(tiene 3 ganchos de reserva en `hooks`).
|
|
41
|
-
6. **
|
|
42
|
-
|
|
43
|
-
|
|
44
|
+
6. **Aprueba tomas y ritmo barato:** `npx morfeo animatica <id>` (todo el video a 1/3 de tamaño + su hoja). Si una
|
|
45
|
+
toma está mal, arréglala y vuelve a pedir solo esas escenas: `npx morfeo render <id> --escenas 5-6`.
|
|
46
|
+
7. **Renderiza:** `npx morfeo render <id>`.
|
|
47
|
+
8. **Revisa antes de entregar:**
|
|
48
|
+
- `npx morfeo revisar <id>`: estructura, que la voz haya dicho el guion, que nada quede bajo los botones de la app,
|
|
49
|
+
el riesgo de "diapositiva" y, en el MP4, cuadros negros, tapados (cámara pegada a algo), lisos o quietos.
|
|
44
50
|
- `npx morfeo hoja <id> --zona`: mira la hoja de contacto (es una imagen); lo pintado en rojo es lo que tapan las apps.
|
|
45
51
|
- Si algo se ve mal (texto encimado, objeto cortado, cuadro vacío), arréglalo y vuelve a renderizar.
|
|
46
|
-
|
|
52
|
+
9. **Entrega:** en `salida/` quedan `<título>.mp4`, `<título>.jpg` (miniatura), `<título> (portada).jpg` y
|
|
47
53
|
`<título>.txt` (texto del post, etiquetas, fuentes y ganchos de reserva). Dile eso y nada más.
|
|
48
54
|
|
|
49
55
|
## Reglas
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
# Gustos del canal
|
|
2
|
+
|
|
3
|
+
Reglas de estilo que el agente (Claude Code, Codex…) sigue en **todos** los videos de este proyecto. Escríbelas a tu
|
|
4
|
+
manera: una por línea. Cuando le corrijas algo a un video y quieras que no se repita, agrégalo aquí.
|
|
5
|
+
|
|
6
|
+
## Lo que sí
|
|
7
|
+
|
|
8
|
+
- Siempre un fondo vivo: un `ambiente` ('cuarto-gamer', 'sala', 'arcade') o un `mundo` propio. Nada de texto sobre un color liso.
|
|
9
|
+
- Objetos 2D/3D (`recurso`) antes que emojis.
|
|
10
|
+
- Gancho en segunda persona en los primeros 2 segundos.
|
|
11
|
+
|
|
12
|
+
## Lo que no
|
|
13
|
+
|
|
14
|
+
- Emojis en pantalla (solo en el título, si acaso).
|
|
15
|
+
- Escenas de más de 5 segundos con la misma toma.
|
|
16
|
+
|
|
17
|
+
## Mi tono
|
|
18
|
+
|
|
19
|
+
- (Ejemplo) Cercano, con humor, sin groserías.
|
package/python/musica.py
CHANGED
|
@@ -305,7 +305,44 @@ def momento(en, ev, si, m):
|
|
|
305
305
|
return ev(si, base) + ajuste
|
|
306
306
|
|
|
307
307
|
|
|
308
|
-
def
|
|
308
|
+
def cama(nombre, N, rng):
|
|
309
|
+
"""Sonido de fondo de un ambiente 3D (bucle suave, sin melodía): lluvia, zumbido de tele, arcade."""
|
|
310
|
+
t = np.arange(N) / SR
|
|
311
|
+
zumbido = 0.012 * (np.sin(2 * np.pi * 60 * t) + 0.5 * np.sin(2 * np.pi * 120 * t))
|
|
312
|
+
def lluvia(vol):
|
|
313
|
+
x = lp(hp(rng.standard_normal(N), 900), 6000) * vol * (0.85 + 0.15 * np.sin(2 * np.pi * 0.07 * t))
|
|
314
|
+
for _ in range(int(N / SR * 14)): # gotas sueltas en el vidrio
|
|
315
|
+
g = int(rng.random() * (N - SR // 20)); n = int(0.012 * SR)
|
|
316
|
+
x[g:g + n] += vol * 1.5 * hp(rng.standard_normal(n), 2500) * np.exp(-np.arange(n) / (0.002 * SR))
|
|
317
|
+
return x
|
|
318
|
+
if nombre == "cuarto-gamer":
|
|
319
|
+
x = lluvia(0.05) + zumbido
|
|
320
|
+
for _ in range(int(N / SR / 2.5)): # el juego de la tele: blips lejanos
|
|
321
|
+
add(x, 0.012 * lp(_tono(nf(72 + int(rng.random() * 12)), 0.12, 20), 2500), rng.random() * (N / SR - 0.2))
|
|
322
|
+
return x
|
|
323
|
+
if nombre == "sala":
|
|
324
|
+
murmullo = lp(hp(rng.standard_normal(N), 250), 1400) * 0.035 * (0.6 + 0.4 * np.abs(np.sin(2 * np.pi * 0.9 * t + np.sin(2 * np.pi * 0.23 * t) * 3)))
|
|
325
|
+
return lluvia(0.03) + murmullo + zumbido
|
|
326
|
+
if nombre == "arcade":
|
|
327
|
+
x = lp(rng.standard_normal(N), 700) * 0.05 + zumbido
|
|
328
|
+
for _ in range(int(N / SR * 3)): # maquinitas sonando por todos lados
|
|
329
|
+
f = nf(70 + int(rng.random() * 20)); d = 0.06 + rng.random() * 0.1
|
|
330
|
+
n = int(d * SR); tt = np.arange(n) / SR
|
|
331
|
+
b = np.sign(np.sin(2 * np.pi * f * tt * (1 + 0.5 * tt / d))) * np.exp(-tt / d * 3)
|
|
332
|
+
add(x, 0.012 * lp(b, 4000), rng.random() * (N / SR - d))
|
|
333
|
+
return x
|
|
334
|
+
return np.zeros(N)
|
|
335
|
+
|
|
336
|
+
|
|
337
|
+
def ambiente_de(v):
|
|
338
|
+
"""Nombre del ambiente del video (capa `ambiente` del mundo) si su sonido no está apagado."""
|
|
339
|
+
for c in (v.get("mundo") or []):
|
|
340
|
+
if isinstance(c, dict) and c.get("tipo") == "ambiente" and c.get("sonido", True) is not False:
|
|
341
|
+
return c.get("nombre")
|
|
342
|
+
return None
|
|
343
|
+
|
|
344
|
+
|
|
345
|
+
def mix(vid, style, outdir, TIM, SPEC, durs, ev, ambiente=None):
|
|
309
346
|
"""Mezcla voz + música (con ducking) + efectos y escribe mix.wav. style=None: sin música."""
|
|
310
347
|
dur = sum(durs); N = int(dur * SR); rng = np.random.default_rng(11)
|
|
311
348
|
music = musica_de(vid, style, dur, N)
|
|
@@ -343,6 +380,10 @@ def mix(vid, style, outdir, TIM, SPEC, durs, ev):
|
|
|
343
380
|
v = np.zeros(N); v[: min(N, len(voice))] = voice[:N]
|
|
344
381
|
w = int(0.15 * SR); envv = np.convolve(np.abs(v), np.ones(w) / w, mode="same")
|
|
345
382
|
music *= 1 - 0.6 * np.clip(envv / 0.05, 0, 1)
|
|
346
|
-
|
|
383
|
+
fondo = cama(ambiente, N, np.random.default_rng(23)) if ambiente else np.zeros(N)
|
|
384
|
+
fondo *= 1 - 0.45 * np.clip(envv / 0.05, 0, 1)
|
|
385
|
+
if len(fondo) > SR: # entra y sale suave
|
|
386
|
+
fondo[:SR // 2] *= np.linspace(0, 1, SR // 2); fondo[-SR:] *= np.linspace(1, 0, SR)
|
|
387
|
+
out = 1.3 * v + 0.22 * music + 0.6 * sfx + fondo
|
|
347
388
|
out = np.tanh(out * 1.1) * 0.89
|
|
348
389
|
sf.write(os.path.join(outdir, "mix.wav"), np.stack([out, out], 1).astype(np.float32), SR)
|
package/python/preparar.py
CHANGED
|
@@ -250,7 +250,7 @@ def preparar(job):
|
|
|
250
250
|
return seg["speech"] * 0.5
|
|
251
251
|
|
|
252
252
|
durs = [seg["dur"] + (0.6 if i == len(TIM) - 1 else 0) for i, seg in enumerate(TIM)]
|
|
253
|
-
musica.mix(vid, v.get("music"), outdir, TIM, spec, durs, ev) # -> mix.wav (voz + música con ducking + efectos)
|
|
253
|
+
musica.mix(vid, v.get("music"), outdir, TIM, spec, durs, ev, ambiente=musica.ambiente_de(v)) # -> mix.wav (voz + música con ducking + efectos)
|
|
254
254
|
|
|
255
255
|
escenas = []
|
|
256
256
|
for si, (seg, sp) in enumerate(zip(TIM, spec)):
|