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 CHANGED
@@ -1,6 +1,6 @@
1
1
  # Morfeo
2
2
 
3
- > **Documentación: [morfeo-docs-two.vercel.app](https://morfeo-docs-two.vercel.app/)** · Ejemplos: [github.com/UreckChan/morfeo](https://github.com/UreckChan/morfeo) · Versiones: [historial](https://morfeo-docs-two.vercel.app/historial)
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
- revisar [id...] Revisa la estructura (gancho, ritmo, preguntas, cierre), que la voz diga el guion y la zona segura
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
- await render({proyecto, ids: pos, todos: !!op.todos || pos[0] === 'todos', soloFallidos: !!op.soloFallidos});
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;
@@ -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 > 140) add('duracion', 'idea', `Dura ${Math.round(dur)} s: los explicadores de maqueta de referencia van de 100 a 130 s.`);
83
- else if (largo && dur < 60) add('duracion', 'idea', `Dura ${Math.round(dur)} s: en maqueta los de referencia duran 100–130 s; hay espacio para otra pregunta y su respuesta.`);
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; en maqueta la cámara corta cada 2–3 s. Pártela en dos tomas.`);
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(/\b(tú|tu|tus|te|ti|contigo|imagina|imaginas|tienes|eres|puedes|sabes|sabías|crees|conoces|harías|pagarías)\b/gi) ?? []).length;
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
- revisar: (v) => (!(v.scenes ?? []).some((s) => s.modo) ? ['Fórmula "demuéstralo": la explicación luce con `modo: \'rayosx\'` en la palabra donde se ve "por dentro".'] : []),
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
- : [...['3d', 'realista'].filter((e) => e !== estilo).map((e) => [e]), ['2d', variante]];
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 mp4 = previa ? salidaDe(p, id).replace(/\.mp4$/, ' (previa).mp4') : salidaDe(p, id);
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
- let concurrencia = concurrenciaBase;
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.5.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-docs-two.vercel.app/",
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",
@@ -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 20+ (`node -v`). Si falta, dile cómo instalarlo y espera.
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. **Renderiza:** `npx morfeo render <id>`.
42
- 7. **Revisa antes de entregar:**
43
- - `npx morfeo revisar <id>`: estructura, que la voz haya dicho el guion y que nada quede bajo los botones de la app.
44
+ 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
- 8. **Entrega:** en `salida/` quedan `<título>.mp4`, `<título>.jpg` (miniatura), `<título> (portada).jpg` y
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.