morfeo 0.5.0 → 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/README.md +1 -1
- 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 +34 -4
- package/lib/formulas.mjs +2 -1
- package/lib/hoja.mjs +2 -2
- package/lib/recursos.mjs +6 -2
- package/lib/render.mjs +42 -6
- package/package.json +4 -2
- package/plantilla/AGENTS.md +11 -5
- 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/README.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# Morfeo
|
|
2
2
|
|
|
3
|
-
> **Documentación: [morfeo
|
|
3
|
+
> **Documentación: [morfeo.ureck.dev](https://morfeo.ureck.dev/)** · Ejemplos: [github.com/UreckChan/morfeo](https://github.com/UreckChan/morfeo) · Versiones: [historial](https://morfeo.ureck.dev/historial)
|
|
4
4
|
|
|
5
5
|
Genera videos verticales animados (Shorts, TikTok, Reels) desde código: escribes las escenas y Morfeo agrega
|
|
6
6
|
la voz, la música, los efectos de sonido, los subtítulos, las animaciones y la miniatura.
|
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
|
@@ -66,6 +66,9 @@ export function revisarEstructura(v, d) {
|
|
|
66
66
|
// formato largo: explicador de maqueta (o `formato: 'largo'` en el video)
|
|
67
67
|
const formula = formulaDe(v.formula);
|
|
68
68
|
const largo = v.estilo === 'maqueta' || v.formato === 'largo' || (formula?.duracion[1] ?? 0) > 90;
|
|
69
|
+
// a qué se compara: los explicadores de maqueta (100–130 s) o la duración de la fórmula (biografía 90–180 s…)
|
|
70
|
+
const enLargo = v.estilo === 'maqueta' || !formula ? 'en maqueta' : `en la fórmula "${formula.id}"`;
|
|
71
|
+
const rangoLargo = v.estilo === 'maqueta' || !formula ? [100, 130] : formula.duracion;
|
|
69
72
|
|
|
70
73
|
// 1) gancho
|
|
71
74
|
const dicho = String(primera.say ?? '').trim();
|
|
@@ -79,8 +82,8 @@ export function revisarEstructura(v, d) {
|
|
|
79
82
|
const es = d?.escenas?.filter((e) => e.tipo !== 'cta') ?? [];
|
|
80
83
|
if (es.length) {
|
|
81
84
|
const dur = d.duracion;
|
|
82
|
-
if (largo && dur >
|
|
83
|
-
else if (largo && dur < 60) add('duracion', 'idea', `Dura ${Math.round(dur)} s:
|
|
85
|
+
if (largo && dur > rangoLargo[1] + 10) add('duracion', 'idea', `Dura ${Math.round(dur)} s: ${enLargo} los de referencia van de ${rangoLargo[0]} a ${rangoLargo[1]} s.`);
|
|
86
|
+
else if (largo && dur < 60) add('duracion', 'idea', `Dura ${Math.round(dur)} s: ${enLargo} los de referencia duran ${rangoLargo[0]}–${rangoLargo[1]} s; hay espacio para otra pregunta y su respuesta.`);
|
|
84
87
|
else if (!largo && 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 (o usa estilo maqueta para un explicador largo).`);
|
|
85
88
|
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.`);
|
|
86
89
|
const e0 = es[0];
|
|
@@ -91,7 +94,7 @@ export function revisarEstructura(v, d) {
|
|
|
91
94
|
// algo que cambie a media escena: capas con `en`, cámara con rango, toma con movimiento, o un mundo vivo
|
|
92
95
|
const capas = s.capas ?? [];
|
|
93
96
|
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);
|
|
94
|
-
if (largo && voz > 4.5) add('ritmo', 'idea', `Escena ${i + 1}: ${voz.toFixed(1)} s en una sola toma;
|
|
97
|
+
if (largo && voz > 4.5) add('ritmo', 'idea', `Escena ${i + 1}: ${voz.toFixed(1)} s en una sola toma; ${enLargo} la cámara corta cada 2–3 s. Pártela en dos tomas.`);
|
|
95
98
|
else 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.`);
|
|
96
99
|
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.`);
|
|
97
100
|
});
|
|
@@ -110,8 +113,35 @@ export function revisarEstructura(v, d) {
|
|
|
110
113
|
// 5) habla de tú
|
|
111
114
|
const todo = cuerpo.map((s) => s.say ?? '').join(' ');
|
|
112
115
|
const n = palabrasDe(todo).length;
|
|
113
|
-
const tu = (todo.match(
|
|
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;
|
|
114
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
|
+
}
|
|
115
145
|
// 6) lo propio de la fórmula del video (lib/formulas.mjs)
|
|
116
146
|
for (const t of revisarFormula(v)) add('formula', 'idea', t);
|
|
117
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
|
@@ -208,12 +208,33 @@ export async function preparar({proyecto = process.cwd(), ids, log = console.log
|
|
|
208
208
|
return {p, publico, marca, extras, datos, creditos, fallidos};
|
|
209
209
|
}
|
|
210
210
|
|
|
211
|
+
// ¿El video dibuja con WebGL? Capas 3D del motor o componentes del proyecto que usan Three.js / Lienzo3D.
|
|
212
|
+
export function usaWebGL(p, id) {
|
|
213
|
+
const v = p.videos[id];
|
|
214
|
+
if (!v) return false;
|
|
215
|
+
const texto = JSON.stringify(v);
|
|
216
|
+
if (/"tipo":"(modelo|escena3d|ambiente)"/.test(texto) || v.ambiente) return true;
|
|
217
|
+
for (const [nombre, ruta] of Object.entries(p.componentes ?? {})) {
|
|
218
|
+
if (!texto.includes(`"nombre":"${nombre}"`)) continue;
|
|
219
|
+
try {
|
|
220
|
+
if (/Lienzo3D|ThreeCanvas|@react-three|from ['"]three['"]/.test(fs.readFileSync(ruta, 'utf8'))) return true;
|
|
221
|
+
} catch {
|
|
222
|
+
return true;
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
return false;
|
|
226
|
+
}
|
|
227
|
+
|
|
211
228
|
/**
|
|
212
229
|
* Genera los videos. Sin ids: los que aún no existen en la carpeta de salida.
|
|
213
230
|
* @param {{proyecto?: string, ids?: string[], todos?: boolean, log?: (m: string) => void, alProgreso?: (id: string, p: number) => void}} o
|
|
214
231
|
* @returns {Promise<{id: string, mp4: string, jpg: string | null, segundos: number}[]>}
|
|
215
232
|
*/
|
|
216
|
-
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');
|
|
217
238
|
// previa: solo el gancho (las primeras escenas, hasta `previa` segundos o ~8 s) en "<título> (previa).mp4", sin miniatura
|
|
218
239
|
if (previa && !ids?.length) throw new ErrorMorfeo('Dime qué video: npx morfeo previa mi-video');
|
|
219
240
|
cargarEnv(proyecto);
|
|
@@ -268,12 +289,16 @@ export async function render({proyecto = process.cwd(), ids, todos = false, solo
|
|
|
268
289
|
const t = Date.now();
|
|
269
290
|
try {
|
|
270
291
|
const inputProps = {videos: {[id]: datos[id]}, marca, ...extras};
|
|
271
|
-
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);
|
|
272
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
|
|
273
295
|
const d0 = datos[id];
|
|
274
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));
|
|
275
297
|
let gl = p.render.gl;
|
|
276
|
-
|
|
298
|
+
// WebGL con GPU y muchas pestañas: alguna se queda "pegada" y sus cuadros salen con rótulos o subtítulos de
|
|
299
|
+
// cuadros anteriores (medido en Windows + RTX 3060: 9 pestañas → 0–54 cuadros malos de 270; 4 → 41–81; 2–3 → 0
|
|
300
|
+
// en 24 pruebas, casi igual de rápido). Solo se limita si el video usa 3D y nadie fijó la concurrencia.
|
|
301
|
+
let concurrencia = gl === 'angle' && p.render.concurrencia == null && usaWebGL(p, id) ? Math.min(concurrenciaBase, 3) : concurrenciaBase;
|
|
277
302
|
let composition;
|
|
278
303
|
// hasta 3 intentos: si el navegador truena, se baja la concurrencia y, con GPU, se pasa a software
|
|
279
304
|
for (let intento = 1; ; intento++) {
|
|
@@ -295,6 +320,8 @@ export async function render({proyecto = process.cwd(), ids, todos = false, solo
|
|
|
295
320
|
concurrency: concurrencia,
|
|
296
321
|
outputLocation: mp4,
|
|
297
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} : {}),
|
|
298
325
|
onProgress: ({progress}) => {
|
|
299
326
|
alProgreso?.(id, progress);
|
|
300
327
|
// con terminal: cada 10 % en la misma línea; sin terminal (logs, CI): cada 25 %
|
|
@@ -320,7 +347,7 @@ export async function render({proyecto = process.cwd(), ids, todos = false, solo
|
|
|
320
347
|
}
|
|
321
348
|
}
|
|
322
349
|
let jpg = null;
|
|
323
|
-
if (p.render.miniatura && !previa) {
|
|
350
|
+
if (p.render.miniatura && !previa && !parcial) {
|
|
324
351
|
// miniatura: cuando la primera escena ya está completa (o en `miniaturaEn`), sin subtítulos
|
|
325
352
|
jpg = salidaDe(p, id, 'jpg');
|
|
326
353
|
const d = datos[id];
|
|
@@ -332,7 +359,7 @@ export async function render({proyecto = process.cwd(), ids, todos = false, solo
|
|
|
332
359
|
}
|
|
333
360
|
// portada: el mismo cuadro con el título grande y la marca (`portada: 'texto'` o el título sin emojis; false = sin portada)
|
|
334
361
|
const vid = p.videos[id];
|
|
335
|
-
if (!previa && p.render.portada !== false && vid.portada !== false && jpg) {
|
|
362
|
+
if (!previa && !parcial && p.render.portada !== false && vid.portada !== false && jpg) {
|
|
336
363
|
const textoPortada = typeof vid.portada === 'string' ? vid.portada : String(vid.title ?? id).replace(/[\p{Extended_Pictographic}\uFE0F\u200D]/gu, '').replace(/\s*[|·].*$/, '').trim();
|
|
337
364
|
const d = datos[id];
|
|
338
365
|
const e0 = d.escenas[0];
|
|
@@ -354,11 +381,20 @@ export async function render({proyecto = process.cwd(), ids, todos = false, solo
|
|
|
354
381
|
} finally {
|
|
355
382
|
soltar();
|
|
356
383
|
}
|
|
357
|
-
if (previa) return hechos;
|
|
384
|
+
if (previa || parcial) return hechos;
|
|
358
385
|
return terminarLote(p, loteArchivo, hechos, fallidos, log);
|
|
359
386
|
}
|
|
360
387
|
|
|
361
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
|
+
|
|
362
398
|
/** CRF del video: `crf` explícito > `calidad` del video > `render.calidad` > media con mundo, alta sin él. */
|
|
363
399
|
export function crfDe(p, id) {
|
|
364
400
|
if (p.render.crf != null) return p.render.crf;
|
package/package.json
CHANGED
|
@@ -1,11 +1,11 @@
|
|
|
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",
|
|
7
7
|
"author": "Uriel Gómez",
|
|
8
|
-
"homepage": "https://morfeo
|
|
8
|
+
"homepage": "https://morfeo.ureck.dev/",
|
|
9
9
|
"repository": {
|
|
10
10
|
"type": "git",
|
|
11
11
|
"url": "git+https://github.com/UreckChan/morfeo.git"
|
|
@@ -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,9 +6,12 @@ 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
|
-
1. Revisa que haya Node
|
|
14
|
+
1. Revisa que haya Node 18.17+ (`node -v`). Si falta, dile cómo instalarlo y espera.
|
|
12
15
|
2. `npm install` en esta carpeta.
|
|
13
16
|
3. `npx morfeo instalar --si` (Python, voces gratis, navegador; tarda unos minutos la primera vez). Si algo falla,
|
|
14
17
|
lee el mensaje, arréglalo y vuelve a correrlo. `npx morfeo doctor` dice qué falta.
|
|
@@ -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.
|