morfeo 0.1.0 → 0.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # Morfeo
2
2
 
3
- > Galería de estilos y ejemplos: [github.com/UreckChan/morfeo](https://github.com/UreckChan/morfeo).
3
+ > **Documentación: [ureckchan.github.io/morfeo](https://ureckchan.github.io/morfeo/)** · Ejemplos: [github.com/UreckChan/morfeo](https://github.com/UreckChan/morfeo)
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/index.d.ts CHANGED
@@ -87,6 +87,7 @@ export type Capa = CapaBase &
87
87
  | {tipo: 'onda'; barras?: number; ancho?: number; alto?: number; color?: string; siempre?: boolean}
88
88
  | {tipo: 'globo'; texto: string; cola?: 'abajo' | 'arriba' | 'izq' | 'der' | 'ninguna'; fondo?: string; color?: string; borde?: string; tam?: number; maxAncho?: number}
89
89
  | {tipo: 'seguir'; usuario?: string; texto?: string; pulsar?: number}
90
+ | {tipo: 'ventana'; diametro?: number; src?: string; capas?: Capa[]; borde?: 'metal' | 'ninguno' | (string & {}); grosor?: number; remaches?: number | false; zoom?: number | [number, number]; fondo?: string}
90
91
  );
91
92
 
92
93
  export type FondoEscena =
@@ -182,6 +183,8 @@ export interface Video {
182
183
  /** Idioma de Kokoro: "es", "en-us", "en-gb", "fr-fr", "it", "pt-br", "ja", "cmn", "hi". */
183
184
  lang?: string;
184
185
  scenes: Escena[];
186
+ /** Segundo del video para la miniatura (por defecto, cuando la primera escena ya está completa). Sale sin subtítulos. */
187
+ miniaturaEn?: number;
185
188
  /** Datos libres que se copian a salida/resumen.json. */
186
189
  meta?: Record<string, unknown>;
187
190
  }
@@ -221,6 +224,7 @@ export interface Config {
221
224
  musicas?: Record<string, Musica>;
222
225
  /** Carpeta de los .mp4 (por defecto ./salida). */
223
226
  salida?: string;
227
+ /** gl: por defecto 'angle' (GPU) en Mac con Apple Silicon y 'swangle' (CPU) en lo demás; si la GPU falla, reintenta con CPU. */
224
228
  render?: {crf?: number; gl?: 'swangle' | 'angle' | 'egl' | 'swiftshader' | 'vulkan'; navegador?: string | null; concurrencia?: number | null; miniatura?: boolean};
225
229
  }
226
230
 
package/lib/config.mjs CHANGED
@@ -5,16 +5,37 @@ import {pathToFileURL} from 'node:url';
5
5
 
6
6
  export const NOMBRES_CONFIG = ['morfeo.config.mjs', 'morfeo.config.js'];
7
7
 
8
+ // Voces de Kokoro v1.0 (archivo voices-v1.0.bin)
9
+ export const VOCES_KOKORO = 'af_alloy af_aoede af_bella af_heart af_jessica af_kore af_nicole af_nova af_river af_sarah af_sky am_adam am_echo am_eric am_fenrir am_liam am_michael am_onyx am_puck am_santa bf_alice bf_emma bf_isabella bf_lily bm_daniel bm_fable bm_george bm_lewis ef_dora em_alex em_santa ff_siwis hf_alpha hf_beta hm_omega hm_psi if_sara im_nicola jf_alpha jf_gongitsune jf_nezumi jf_tebukuro jm_kumo pf_dora pm_alex pm_santa zf_xiaobei zf_xiaoni zf_xiaoxiao zf_xiaoyi zm_yunjian zm_yunxi zm_yunxia zm_yunyang'.split(' ');
10
+
11
+ /** Las voces más parecidas a un nombre mal escrito (para sugerir). */
12
+ const parecidas = (nombre, lista, n = 3) => {
13
+ const d = (a, b) => {
14
+ const m = Array.from({length: a.length + 1}, (_, i) => [i, ...Array(b.length).fill(0)]);
15
+ for (let j = 1; j <= b.length; j++) m[0][j] = j;
16
+ for (let i = 1; i <= a.length; i++) for (let j = 1; j <= b.length; j++) m[i][j] = Math.min(m[i - 1][j] + 1, m[i][j - 1] + 1, m[i - 1][j - 1] + (a[i - 1] === b[j - 1] ? 0 : 1));
17
+ return m[a.length][b.length];
18
+ };
19
+ return [...lista].sort((a, b) => d(nombre, a) - d(nombre, b)).slice(0, n);
20
+ };
21
+
8
22
  export const TIPOS_CLASICOS = ['title', 'year', 'text', 'icon', 'compare', 'card', 'dialog', 'aliens', 'memory', 'morph', 'cta'];
9
23
  export const TIPOS_MOTOR = ['capas', 'titular', 'dato', 'lista', 'telefono', 'comparar', 'eleccion', 'cta'];
10
24
  export const TIPOS_ESCENA = [...new Set([...TIPOS_CLASICOS, ...TIPOS_MOTOR])];
11
25
  export const ESTILOS = ['datos-curiosos', 'explicativo', 'documental', 'ilustrado', 'historia', 'mesa', 'serie', 'infografia', '3d', 'minimal'];
12
- 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'];
26
+ export const TIPOS_CAPA = ['imagen', 'video', 'texto', 'contador', 'chip', 'etiqueta', 'numero', 'tarjeta', 'telefono', 'forma', 'icono', 'emoji', 'marca', 'particulas', 'grupo', 'modelo', 'componente', 'barra', 'medidor', 'votacion', 'estado', 'noticia', 'chat', 'onda', 'globo', 'seguir', 'personaje', 'escena3d', 'ventana'];
13
27
  export const PROVEEDORES_VOZ = ['kokoro', 'elevenlabs', 'openai'];
14
28
  export const MUSICAS = ['lofi', 'lofi2', 'chillpop', 'synth', 'house', 'ballad', 'trap'];
15
29
 
16
30
  export class ErrorMorfeo extends Error {}
17
31
 
32
+ /**
33
+ * WebGL por defecto: en Mac con Apple Silicon la tarjeta de video (angle) es estable y ~4 veces más rápida;
34
+ * en lo demás, por software (swangle), que funciona en cualquier computadora. Si angle falla, el render
35
+ * reintenta solo con swangle.
36
+ */
37
+ export const glPorDefecto = () => (process.platform === 'darwin' && process.arch === 'arm64' ? 'angle' : 'swangle');
38
+
18
39
  /** Una carpeta de guiones: cada <id>.json o <id>.mjs es un video. */
19
40
  async function leerCarpeta(dir) {
20
41
  const out = {};
@@ -84,7 +105,8 @@ export async function cargarProyecto(proyecto = process.cwd()) {
84
105
  const errores = [];
85
106
  const estilosPropios = cfg.estilos ?? {};
86
107
  const estilo = cfg.estilo ?? 'datos-curiosos';
87
- const ctx = {proyecto, marca, voces: cfg.voces ?? {}, musicas: cfg.musicas ?? {}, errores, estilo, estilosPropios};
108
+ const assetsDir = path.resolve(proyecto, cfg.assets ?? 'assets');
109
+ const ctx = {proyecto, marca, voces: cfg.voces ?? {}, musicas: cfg.musicas ?? {}, errores, estilo, estilosPropios, assetsDir};
88
110
  if (!ESTILOS.includes(estilo) && !estilosPropios[estilo]) errores.push(`estilo "${estilo}" no existe. Opciones: ${[...ESTILOS, ...Object.keys(estilosPropios)].join(', ')}`);
89
111
  const componentes = {};
90
112
  for (const [nombre, ruta] of Object.entries(cfg.componentes ?? {})) {
@@ -125,14 +147,27 @@ export async function cargarProyecto(proyecto = process.cwd()) {
125
147
  componentes,
126
148
  render: {
127
149
  crf: r.crf ?? 18,
128
- gl: process.env.MORFEO_GL || process.env.REMOTION_GL || r.gl || 'swangle',
150
+ gl: process.env.MORFEO_GL || process.env.REMOTION_GL || r.gl || glPorDefecto(),
129
151
  navegador: process.env.MORFEO_NAVEGADOR || r.navegador || null,
130
- concurrencia: r.concurrencia ?? null,
152
+ concurrencia: r.concurrencia ?? null, // null = Morfeo elige según la máquina y el modo (ver render.mjs)
131
153
  miniatura: r.miniatura ?? true,
132
154
  },
133
155
  };
134
156
  }
135
157
 
158
+ const CAMPOS_ARCHIVO = new Set(['src', 'imagen']);
159
+ /** Busca `src`/`imagen` que apunten a assets/ y avisa de los que no existen. */
160
+ function revisarArchivos(x, assetsDir, err, ruta = '') {
161
+ if (Array.isArray(x)) return x.forEach((v, i) => revisarArchivos(v, assetsDir, err, `${ruta}[${i}]`));
162
+ if (!x || typeof x !== 'object') return;
163
+ for (const [k, v] of Object.entries(x)) {
164
+ if (CAMPOS_ARCHIVO.has(k) && typeof v === 'string' && !/^(https?:|data:)/.test(v)) {
165
+ const rel = v.replace(/^\.?\/?(assets\/)?/, '');
166
+ if (!fs.existsSync(path.join(assetsDir, rel))) err(`no existe assets/${rel} (lo pide ${ruta.replace(/^\./, '') || 'una capa'}.${k}). Ponlo en la carpeta assets/ o usa generar: 'descripción' para la IA`);
167
+ } else if (v && typeof v === 'object') revisarArchivos(v, assetsDir, err, `${ruta}.${k}`);
168
+ }
169
+ }
170
+
136
171
  function validarCapas(capas, donde, err) {
137
172
  if (!Array.isArray(capas)) return err(`${donde}: \`capas\` debe ser una lista`);
138
173
  capas.forEach((c, i) => {
@@ -178,7 +213,13 @@ function normalizarVoz(voz, ctx, err, profundidad = 0) {
178
213
  if (!/\.npy$/i.test(abs)) err(`la voz ${ref} debe ser un archivo .npy (estilo de voz Kokoro, forma 510×1×256)`);
179
214
  else if (!fs.existsSync(abs)) err(`no existe el archivo de voz ${ref}`);
180
215
  out.push([abs, peso]);
181
- } else out.push([ref, peso]);
216
+ } else {
217
+ if (!VOCES_KOKORO.includes(ref)) {
218
+ const alias = Object.keys(ctx.voces);
219
+ err(`la voz "${ref}" no existe. ¿Quisiste decir ${parecidas(ref, [...alias, ...VOCES_KOKORO]).map((x) => `"${x}"`).join(', ')}? En español: em_alex, ef_dora, em_santa`);
220
+ }
221
+ out.push([ref, peso]);
222
+ }
182
223
  }
183
224
  return out;
184
225
  }
@@ -237,5 +278,7 @@ function validarVideo(id, v, ctx) {
237
278
  if (v.voice != null) voice = normalizarVoz(v.voice, ctx, err);
238
279
  else if (necesitaVoz) err('falta `voice` (p. ej. "em_alex", una mezcla [["em_alex", 0.6], ["am_michael", 0.4]], un .npy, o `audio` en cada escena)');
239
280
  if (v.hud != null) validarCapas(v.hud, 'hud', err);
281
+ // archivos de assets/ que piden las escenas y el HUD: mejor avisar ahora que a mitad del render
282
+ revisarArchivos({scenes, hud: v.hud, tema: v.tema}, ctx.assetsDir, err);
240
283
  return {...v, estilo, voice, music: normalizarMusica(v.music, ctx, err), scenes};
241
284
  }
package/lib/guion.mjs CHANGED
@@ -13,14 +13,18 @@ export const ICONOS = [
13
13
  ];
14
14
  const COLORES = {blanco: '#FFFFFF', amarillo: '#FFD400', rojo: '#FF4050', verde: '#5ADC6E', cian: '#50DCFF'};
15
15
 
16
- export const slug = (s) =>
17
- s
16
+ /** id corto para el archivo: sin acentos, en minúsculas, hasta ~38 letras y sin cortar palabras. */
17
+ export const slug = (s) => {
18
+ const base = s
18
19
  .normalize('NFD')
19
- .replace(/[̀-ͯ]/g, '')
20
+ .replace(/[\u0300-\u036f]/g, '')
20
21
  .toLowerCase()
21
22
  .replace(/[^a-z0-9]+/g, '-')
22
- .replace(/^-+|-+$/g, '')
23
- .slice(0, 40) || 'video';
23
+ .replace(/^-+|-+$/g, '');
24
+ if (base.length <= 38) return base || 'video';
25
+ const corto = base.slice(0, 39);
26
+ return corto.slice(0, Math.max(corto.lastIndexOf('-'), 12)).replace(/-+$/, '') || 'video';
27
+ };
24
28
 
25
29
  const INSTRUCCIONES = `Eres guionista de videos cortos verticales (YouTube Shorts / TikTok / Reels) de 25 a 45 segundos.
26
30
  Escribes en el idioma que se te pida, con frases cortas, naturales y fáciles de decir en voz alta.
package/lib/render.mjs CHANGED
@@ -1,6 +1,7 @@
1
1
  // Render: audio (Python) -> datos -> video (Remotion). También lista y Studio.
2
2
  import {spawn, spawnSync} from 'node:child_process';
3
3
  import crypto from 'node:crypto';
4
+ import os from 'node:os';
4
5
  import fs from 'node:fs';
5
6
  import {createRequire} from 'node:module';
6
7
  import path from 'node:path';
@@ -205,37 +206,70 @@ export async function render({proyecto = process.cwd(), ids, todos = false, log
205
206
  const chromiumOptions = {gl: p.render.gl};
206
207
  const browserExecutable = p.render.navegador ?? (await asegurarNavegador(log));
207
208
  const hechos = [];
209
+ // cuadros en paralelo: con GPU aguanta más; por software cada pestaña come mucha CPU y memoria
210
+ const nucleos = os.cpus().length;
211
+ const concurrenciaBase = p.render.concurrencia ?? (p.render.gl === 'angle' ? Math.max(2, Math.min(6, Math.floor(nucleos / 2))) : Math.max(1, Math.min(3, Math.floor(nucleos / 4))));
212
+ const sinTTY = !process.stdout.isTTY;
208
213
  for (const [i, id] of lista.entries()) {
209
214
  const t = Date.now();
210
215
  const inputProps = {videos: {[id]: datos[id]}, marca, ...extras};
211
- const composition = await selectComposition({serveUrl, id, inputProps, browserExecutable, chromiumOptions});
212
- let ultimo = -1;
213
216
  const mp4 = salidaDe(p, id);
214
- await renderMedia({
215
- serveUrl,
216
- composition,
217
- inputProps,
218
- browserExecutable,
219
- chromiumOptions,
220
- codec: 'h264',
221
- crf: p.render.crf,
222
- audioBitrate: '192k',
223
- imageFormat: 'jpeg',
224
- jpegQuality: 92,
225
- concurrency: p.render.concurrencia,
226
- outputLocation: mp4,
227
- onProgress: ({progress}) => {
228
- alProgreso?.(id, progress);
229
- const n = Math.floor(progress * 10);
230
- if (n !== ultimo && process.stdout.isTTY) process.stdout.write(`\r[${i + 1}/${lista.length}] ${id}: video ${n * 10}% `);
231
- ultimo = n;
232
- },
233
- });
217
+ let gl = p.render.gl;
218
+ let concurrencia = concurrenciaBase;
219
+ let composition;
220
+ // hasta 3 intentos: si el navegador truena, se baja la concurrencia y, con GPU, se pasa a software
221
+ for (let intento = 1; ; intento++) {
222
+ const chromium = {gl};
223
+ let ultimo = -1;
224
+ try {
225
+ composition = await selectComposition({serveUrl, id, inputProps, browserExecutable, chromiumOptions: chromium});
226
+ await renderMedia({
227
+ serveUrl,
228
+ composition,
229
+ inputProps,
230
+ browserExecutable,
231
+ chromiumOptions: chromium,
232
+ codec: 'h264',
233
+ crf: p.render.crf,
234
+ audioBitrate: '192k',
235
+ imageFormat: 'jpeg',
236
+ jpegQuality: 92,
237
+ concurrency: concurrencia,
238
+ outputLocation: mp4,
239
+ onProgress: ({progress}) => {
240
+ alProgreso?.(id, progress);
241
+ // con terminal: cada 10 % en la misma línea; sin terminal (logs, CI): cada 25 %
242
+ const paso = sinTTY ? 4 : 10;
243
+ const n = Math.floor(progress * paso);
244
+ if (n !== ultimo) {
245
+ if (sinTTY) log(`[${i + 1}/${lista.length}] ${id}: video ${Math.round((n / paso) * 100)}%`);
246
+ else process.stdout.write(`\r[${i + 1}/${lista.length}] ${id}: video ${n * 10}% `);
247
+ }
248
+ ultimo = n;
249
+ },
250
+ });
251
+ chromiumOptions.gl = gl;
252
+ break;
253
+ } catch (e) {
254
+ const msg = String(e?.message ?? e);
255
+ const truena = /crash|Session closed|Target closed|Protocol error|WebGL|context lost|browser has disconnected|Navigation failed/i.test(msg);
256
+ if (!truena || intento >= 3) throw e;
257
+ if (gl === 'angle' && /WebGL|context/i.test(msg)) gl = 'swangle';
258
+ else if (concurrencia > 1) concurrencia = Math.max(1, Math.floor(concurrencia / 2));
259
+ else if (gl === 'angle') gl = 'swangle';
260
+ log(`\n el navegador falló; reintento con ${gl === 'angle' ? 'GPU' : 'CPU'} y ${concurrencia} cuadro(s) a la vez...`);
261
+ }
262
+ }
234
263
  let jpg = null;
235
264
  if (p.render.miniatura) {
236
- // miniatura: el gancho ya completo
265
+ // miniatura: cuando la primera escena ya está completa (o en `miniaturaEn`), sin subtítulos
237
266
  jpg = path.join(p.salida, `${id}.jpg`);
238
- await renderStill({serveUrl, composition, inputProps, browserExecutable, chromiumOptions, frame: Math.min(36, composition.durationInFrames - 1), imageFormat: 'jpeg', jpegQuality: 92, output: jpg});
267
+ const d = datos[id];
268
+ const e0 = d.escenas[0];
269
+ const seg = p.videos[id].miniaturaEn ?? Math.min(e0.dur - 0.15, Math.max(1.4, e0.habla[1] * 0.85));
270
+ const frame = Math.max(0, Math.min(composition.durationInFrames - 1, Math.round(seg * composition.fps)));
271
+ const props = {...inputProps, miniatura: true};
272
+ await renderStill({serveUrl, composition: {...composition, props: {...composition.props, miniatura: true}}, inputProps: props, browserExecutable, chromiumOptions: {gl}, frame, imageFormat: 'jpeg', jpegQuality: 92, output: jpg});
239
273
  }
240
274
  const segundos = Math.round((Date.now() - t) / 1000);
241
275
  if (process.stdout.isTTY) process.stdout.write('\r');
package/package.json CHANGED
@@ -1,11 +1,11 @@
1
1
  {
2
2
  "name": "morfeo",
3
- "version": "0.1.0",
3
+ "version": "0.1.1",
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://github.com/UreckChan/morfeo#readme",
8
+ "homepage": "https://ureckchan.github.io/morfeo/",
9
9
  "repository": {
10
10
  "type": "git",
11
11
  "url": "git+https://github.com/UreckChan/morfeo.git"
@@ -30,7 +30,7 @@ import {temaDe} from './motor/tema';
30
30
  import {VideoConAudio} from './motor/Video';
31
31
  import {loadFont} from '@remotion/fonts';
32
32
 
33
- export type PropsShort = {id: string; videos?: Record<string, Datos>; marca?: TipoMarca; guias?: boolean; fuentes?: {familia: string; url: string}[]; estilos?: Record<string, any>};
33
+ export type PropsShort = {id: string; videos?: Record<string, Datos>; marca?: TipoMarca; guias?: boolean; miniatura?: boolean; fuentes?: {familia: string; url: string}[]; estilos?: Record<string, any>};
34
34
 
35
35
  const ADELANTO_GANCHO = 0.4; // el gancho arranca ya animado: el fotograma 0 nunca está vacío
36
36
  const CIERRE_BUCLE = 0.5; // al final se funde con el fotograma 0 para que el video se repita sin corte
@@ -220,7 +220,7 @@ const Guias: React.FC = () => (
220
220
  </AbsoluteFill>
221
221
  );
222
222
 
223
- export const Fotograma: React.FC<{d: Datos; T: number; guias?: boolean}> = ({d, T, guias}) => {
223
+ export const Fotograma: React.FC<{d: Datos; T: number; guias?: boolean; miniatura?: boolean}> = ({d, T, guias, miniatura}) => {
224
224
  const n = d.escenas.length;
225
225
  let si = n - 1;
226
226
  for (let i = 0; i < n; i++) if (T < d.escenas[i].t0 + d.escenas[i].dur) {
@@ -238,7 +238,7 @@ export const Fotograma: React.FC<{d: Datos; T: number; guias?: boolean}> = ({d,
238
238
  <Fondo T={T} d={d} e={e} t={t} salida={si === n - 1 ? 0 : clamp((t - (e.dur - 0.12)) / 0.12)} />
239
239
  <CapaEscena e={e} si={si} t={t} d={d} ultima={si === n - 1} />
240
240
  {remate && <Destello t={t} semilla={d.indice} />}
241
- <Subtitulos T={T} d={d} />
241
+ {miniatura ? null : <Subtitulos T={T} d={d} />}
242
242
  </AbsoluteFill>
243
243
  {e.tipo !== 'cta' && <Marca />}
244
244
  {guias && <Guias />}
@@ -246,7 +246,7 @@ export const Fotograma: React.FC<{d: Datos; T: number; guias?: boolean}> = ({d,
246
246
  );
247
247
  };
248
248
 
249
- export const Short: React.FC<PropsShort> = ({id, videos, marca, guias, fuentes, estilos}) => {
249
+ export const Short: React.FC<PropsShort> = ({id, videos, marca, guias, miniatura, fuentes, estilos}) => {
250
250
  const frame = useCurrentFrame();
251
251
  const [listo, setListo] = useState(false);
252
252
  const [handle] = useState(() => delayRender('Cargando fuentes'));
@@ -259,13 +259,13 @@ export const Short: React.FC<PropsShort> = ({id, videos, marca, guias, fuentes,
259
259
  const d = videos?.[id];
260
260
  if (!d) throw new Error(`El video "${id}" no está preparado. Corre: npx morfeo render ${id}`);
261
261
  if (!listo) return null;
262
- if (d.estilo && d.estilo !== 'datos-curiosos') return <VideoConAudio d={d} frame={frame} marca={{...MARCA_VACIA, ...marca}} estilos={estilos} />;
262
+ if (d.estilo && d.estilo !== 'datos-curiosos') return <VideoConAudio d={d} frame={frame} marca={{...MARCA_VACIA, ...marca}} estilos={estilos} miniatura={miniatura} />;
263
263
  const T = frame / FPS;
264
264
  const kb = clamp((T - (d.duracion - CIERRE_BUCLE)) / CIERRE_BUCLE);
265
265
  return (
266
266
  <MarcaContexto.Provider value={{...MARCA_VACIA, ...marca}}>
267
267
  <AbsoluteFill>
268
- <Fotograma d={d} T={T} guias={guias} />
268
+ <Fotograma d={d} T={T} guias={guias} miniatura={miniatura} />
269
269
  {kb > 0 && (
270
270
  <AbsoluteFill style={{opacity: backOut(kb, 0)}}>
271
271
  <Fotograma d={d} T={0} />
@@ -5,7 +5,7 @@ import {Avatar, MarcaContexto, useMarca} from '../piezas';
5
5
  import {FPS, W, clamp, easeIO, easeOut, lerp} from '../estilo';
6
6
  import type {Datos, Escena} from '../tipos';
7
7
  import {Capas} from './capas';
8
- import {FondoTema, Vineta} from './fondo';
8
+ import {FondoTema, Textura, Vineta} from './fondo';
9
9
  import {capasDe} from './plantillas';
10
10
  import {Subtitulos} from './subtitulos';
11
11
  import {mezclar, temaDe, type Tema} from './tema';
@@ -66,7 +66,7 @@ const MarcaEsquina: React.FC<{tema: Tema}> = ({tema}) => {
66
66
  );
67
67
  };
68
68
 
69
- export const VideoMotor: React.FC<{d: Datos; T: number; estilos?: Record<string, any>}> = ({d, T, estilos}) => {
69
+ export const VideoMotor: React.FC<{d: Datos; T: number; estilos?: Record<string, any>; miniatura?: boolean}> = ({d, T, estilos, miniatura}) => {
70
70
  const tema = temaDe(d.estilo, d.tema, estilos);
71
71
  const n = d.escenas.length;
72
72
  let si = d.escenas.findIndex((e) => T < e.t0 + e.dur);
@@ -100,19 +100,21 @@ export const VideoMotor: React.FC<{d: Datos; T: number; estilos?: Record<string,
100
100
  <AbsoluteFill style={estiloActual}>
101
101
  <EscenaVista e={e} t={t} tema={tema} semilla={semilla} />
102
102
  </AbsoluteFill>
103
+ {/* la textura va quieta, fuera de la cámara: si se moviera con el zoom, cada cuadro sería distinto y el video pesaría 10 veces más */}
104
+ {tema.textura && tema.textura !== 'ninguna' ? <Textura tipo={tema.textura} /> : null}
103
105
  <Vineta fuerza={tema.vineta ?? 0} />
104
106
  {d.hud?.length ? <Hud d={d} T={T} tema={tema} /> : null}
105
- {e.tipo !== 'cta' ? <Subtitulos tema={e.p.tema ? mezclar(tema, e.p.tema) : tema} palabras={e.palabras ?? []} t={t} dur={e.dur} /> : null}
107
+ {e.tipo !== 'cta' && !miniatura ? <Subtitulos tema={e.p.tema ? mezclar(tema, e.p.tema) : tema} palabras={e.palabras ?? []} t={t} dur={e.dur} /> : null}
106
108
  {e.tipo !== 'cta' ? <MarcaEsquina tema={tema} /> : null}
107
109
  </AbsoluteFill>
108
110
  );
109
111
  };
110
112
 
111
113
  /** Componente raíz de un video del motor de capas (con su audio). */
112
- export const VideoConAudio: React.FC<{d: Datos; frame: number; marca: any; estilos?: Record<string, any>}> = ({d, frame, marca, estilos}) => (
114
+ export const VideoConAudio: React.FC<{d: Datos; frame: number; marca: any; estilos?: Record<string, any>; miniatura?: boolean}> = ({d, frame, marca, estilos, miniatura}) => (
113
115
  <MarcaContexto.Provider value={marca}>
114
116
  <AbsoluteFill>
115
- <VideoMotor d={d} T={frame / FPS} estilos={estilos} />
117
+ <VideoMotor d={d} T={frame / FPS} estilos={estilos} miniatura={miniatura} />
116
118
  <Audio src={staticFile(d.audio)} />
117
119
  </AbsoluteFill>
118
120
  </MarcaContexto.Provider>
@@ -14,6 +14,9 @@ export type Capa = Record<string, any> & {tipo: string};
14
14
 
15
15
  const ENTRADA_DUR = 0.5;
16
16
 
17
+ // el personaje de una serie sigue en pantalla de escena en escena: no "reaparece" en cada una
18
+ const entradaPorDefecto = (c: Capa) => (c.tipo === 'personaje' ? 'ninguna' : 'escala');
19
+
17
20
  /** Posición, escala, giro y opacidad de una capa en el instante t (entrada, movimiento, salida). */
18
21
  function animar(c: Capa, ctx: CtxEscena, relativo: boolean) {
19
22
  const t = ctx.t;
@@ -26,7 +29,7 @@ function animar(c: Capa, ctx: CtxEscena, relativo: boolean) {
26
29
  let r = c.rotar ?? 0;
27
30
  let o = c.opacidad ?? 1;
28
31
  if (t < t0) return null; // con entrada 'ninguna' aparece de golpe en su momento
29
- switch (c.entrada ?? 'escala') {
32
+ switch (c.entrada ?? entradaPorDefecto(c)) {
30
33
  case 'aparecer':
31
34
  o *= easeOut(p);
32
35
  break;
@@ -185,6 +188,8 @@ const Contenido: React.FC<{c: Capa; ctx: CtxEscena}> = ({c, ctx}) => {
185
188
  return <Tarjeta c={c} ctx={ctx} />;
186
189
  case 'telefono':
187
190
  return <Telefono c={c} ctx={ctx} />;
191
+ case 'ventana':
192
+ return <Ventana c={c} ctx={ctx} />;
188
193
  case 'forma':
189
194
  return <Forma c={c} ctx={ctx} />;
190
195
  case 'icono': {
@@ -260,7 +265,7 @@ const TextoCapa: React.FC<{c: Capa; ctx: CtxEscena; fuente: string}> = ({c, ctx,
260
265
  const vel = typeof c.escribir === 'number' ? c.escribir : 22; // letras por segundo
261
266
  const n = Math.max(0, Math.floor((ctx.t - t0) * vel));
262
267
  let cuenta = 0;
263
- texto = texto.replace(/[^*]/g, (ch) => (cuenta++ < n ? ch : ch === '\n' ? '\n' : ''));
268
+ texto = texto.replace(/[^*]/g, (ch) => (cuenta++ < n ? ch : ch === '\n' ? '\n' : '')).replace(/\*\*/g, '');
264
269
  }
265
270
  const color = c.color ?? T.colores.texto;
266
271
  const acento = c.acento ?? T.colores.acento;
@@ -398,7 +403,9 @@ const Telefono: React.FC<{c: Capa; ctx: CtxEscena}> = ({c, ctx}) => {
398
403
  const borde = ancho * 0.035;
399
404
  return (
400
405
  <div style={{width: ancho, height: alto, background: '#0A0A0C', borderRadius: ancho * 0.13, padding: borde, boxShadow: '0 30px 60px rgba(0,0,0,0.45), inset 0 0 0 3px #2A2A30', boxSizing: 'border-box'}}>
401
- <div style={{position: 'relative', width: '100%', height: '100%', borderRadius: ancho * 0.1, overflow: 'hidden', background: c.pantalla ?? '#111'}}>
406
+ <div style={{position: 'relative', width: '100%', height: '100%', borderRadius: ancho * 0.1, overflow: 'hidden', background: c.pantalla ?? `linear-gradient(160deg, ${ctx.tema.colores.acento2}, ${ctx.tema.colores.acento} 60%, #111)`}}>
407
+ {/* sin imagen ni capas: una pantalla de app genérica en vez de un rectángulo negro */}
408
+ {!c.src && !c.capas?.length ? <PantallaGenerica ancho={ancho} t={ctx.t} /> : null}
402
409
  {c.src ? (
403
410
  /\.(mp4|webm|mov)$/i.test(c.src) ? (
404
411
  <OffthreadVideo src={recurso(c.src)} muted style={{width: '100%', height: '100%', objectFit: 'cover'}} />
@@ -418,6 +425,74 @@ const Telefono: React.FC<{c: Capa; ctx: CtxEscena}> = ({c, ctx}) => {
418
425
  );
419
426
  };
420
427
 
428
+ /**
429
+ * Ventana circular (escotilla, lupa, mirilla): muestra una imagen o capas adentro, con borde metálico y remaches.
430
+ * {tipo: 'ventana', diametro: 640, src: 'interior.png'} o {tipo: 'ventana', capas: [...]} (coordenadas desde el centro)
431
+ * borde: 'metal' | 'ninguno' | '#color' · remaches · zoom: [inicio, fin] para acercar el contenido · fondo: color detrás
432
+ */
433
+ const Ventana: React.FC<{c: Capa; ctx: CtxEscena}> = ({c, ctx}) => {
434
+ const d = c.diametro ?? 640;
435
+ const aro = c.borde === 'ninguno' ? 0 : c.grosor ?? Math.round(d * 0.06);
436
+ const metal = c.borde && c.borde !== 'metal' && c.borde !== 'ninguno' ? c.borde : null;
437
+ const t0 = cuando(c.en, ctx, 0);
438
+ const k = clamp((ctx.t - t0) / Math.max(0.5, ctx.dur - t0));
439
+ const zoom = Array.isArray(c.zoom) ? lerp(c.zoom[0], c.zoom[1], easeIO(k)) : c.zoom ?? 1;
440
+ const n = c.remaches === false ? 0 : c.remaches ?? 16;
441
+ const total = d + aro * 2;
442
+ return (
443
+ <div style={{position: 'relative', width: total, height: total}}>
444
+ <div
445
+ style={{
446
+ position: 'absolute',
447
+ inset: 0,
448
+ borderRadius: '50%',
449
+ background: metal ?? 'conic-gradient(from 210deg, #8C96A3, #E3E8EE, #7A8592, #C9D0D8, #6E7884, #DDE3EA, #8C96A3)',
450
+ boxShadow: '0 24px 50px rgba(0,0,0,0.45), inset 0 0 0 3px rgba(0,0,0,0.35)',
451
+ }}
452
+ />
453
+ <div style={{position: 'absolute', left: aro, top: aro, width: d, height: d, borderRadius: '50%', overflow: 'hidden', background: c.fondo ?? '#0C1A2A', boxShadow: 'inset 0 0 40px rgba(0,0,0,0.6)'}}>
454
+ <div style={{position: 'absolute', inset: 0, transform: `scale(${zoom})`}}>
455
+ {c.src ? <Img src={recurso(c.src)} style={{width: '100%', height: '100%', objectFit: 'cover'}} /> : null}
456
+ <div style={{position: 'absolute', left: '50%', top: '50%', width: 0, height: 0}}>
457
+ {(c.capas ?? []).map((h: Capa, i: number) => (
458
+ <CapaVista key={i} c={h} ctx={ctx} relativo i={i} />
459
+ ))}
460
+ </div>
461
+ </div>
462
+ {/* brillo del vidrio */}
463
+ <div style={{position: 'absolute', inset: 0, borderRadius: '50%', background: 'radial-gradient(circle at 30% 25%, rgba(255,255,255,0.22), transparent 40%)'}} />
464
+ </div>
465
+ {Array.from({length: n}, (_, i) => {
466
+ const a = (i / n) * Math.PI * 2;
467
+ const r = d / 2 + aro / 2;
468
+ return <div key={i} style={{position: 'absolute', left: total / 2 + Math.cos(a) * r - aro * 0.16, top: total / 2 + Math.sin(a) * r - aro * 0.16, width: aro * 0.32, height: aro * 0.32, borderRadius: '50%', background: 'radial-gradient(circle at 35% 35%, #F2F5F8, #6E7884)'}} />;
469
+ })}
470
+ </div>
471
+ );
472
+ };
473
+
474
+ /** Pantalla de relleno: barra de app, un video con botón de play y renglones de texto. */
475
+ const PantallaGenerica: React.FC<{ancho: number; t: number}> = ({ancho, t}) => {
476
+ const u = ancho / 100;
477
+ const pulso = 1 + 0.05 * Math.sin(t * 4);
478
+ return (
479
+ <div style={{position: 'absolute', inset: 0, padding: `${12 * u}px ${7 * u}px`, display: 'flex', flexDirection: 'column', gap: 4 * u}}>
480
+ <div style={{display: 'flex', alignItems: 'center', gap: 3 * u}}>
481
+ <div style={{width: 9 * u, height: 9 * u, borderRadius: '50%', background: 'rgba(255,255,255,0.85)'}} />
482
+ <div style={{flex: 1, height: 3 * u, borderRadius: 99, background: 'rgba(255,255,255,0.6)'}} />
483
+ </div>
484
+ <div style={{position: 'relative', height: 60 * u, borderRadius: 5 * u, background: 'rgba(0,0,0,0.28)', display: 'flex', alignItems: 'center', justifyContent: 'center'}}>
485
+ <div style={{width: 18 * u, height: 18 * u, borderRadius: '50%', background: 'rgba(255,255,255,0.9)', display: 'flex', alignItems: 'center', justifyContent: 'center', transform: `scale(${pulso})`}}>
486
+ <div style={{width: 0, height: 0, borderStyle: 'solid', borderWidth: `${4.5 * u}px 0 ${4.5 * u}px ${7.5 * u}px`, borderColor: 'transparent transparent transparent #111', marginLeft: 1.5 * u}} />
487
+ </div>
488
+ </div>
489
+ {[90, 70, 80, 50].map((w, i) => (
490
+ <div key={i} style={{width: `${w}%`, height: 4 * u, borderRadius: 99, background: 'rgba(255,255,255,0.55)'}} />
491
+ ))}
492
+ </div>
493
+ );
494
+ };
495
+
421
496
  const Forma: React.FC<{c: Capa; ctx: CtxEscena}> = ({c, ctx}) => {
422
497
  const T = ctx.tema;
423
498
  const color = c.color ?? T.colores.acento;
@@ -110,8 +110,8 @@ export const Particulas: React.FC<{p: TParticulas; t: number; semilla: string}>
110
110
  };
111
111
 
112
112
  /** Ruido fino (película) o papel: un SVG fractal estable. */
113
- const Textura: React.FC<{tipo: 'papel' | 'grano'}> = ({tipo}) => (
114
- <AbsoluteFill style={{opacity: tipo === 'papel' ? 0.35 : 0.12, mixBlendMode: 'multiply', pointerEvents: 'none'}}>
113
+ export const Textura: React.FC<{tipo: 'papel' | 'grano'}> = ({tipo}) => (
114
+ <AbsoluteFill style={{opacity: tipo === 'papel' ? 0.22 : 0.1, mixBlendMode: 'multiply', pointerEvents: 'none'}}>
115
115
  <svg width={W} height={H}>
116
116
  <filter id={`tx-${tipo}`}>
117
117
  <feTurbulence type="fractalNoise" baseFrequency={tipo === 'papel' ? 0.9 : 1.6} numOctaves={tipo === 'papel' ? 3 : 2} seed={3} />
@@ -139,7 +139,6 @@ export const FondoTema: React.FC<{tema: Tema; fondo?: TFondo; t: number; semilla
139
139
  }}
140
140
  />
141
141
  ) : null}
142
- {tema.textura && tema.textura !== 'ninguna' ? <Textura tipo={tema.textura} /> : null}
143
142
  {tema.particulas ? <Particulas p={tema.particulas} t={t} semilla={semilla} /> : null}
144
143
  </AbsoluteFill>
145
144
  );
@@ -74,7 +74,9 @@ export function capasDe(tipo: string, p: P, tema: Tema, marca: Marca): Capa[] {
74
74
  case 'eleccion': {
75
75
  const ops: P[] = (p.opciones ?? []).map((o: any) => (typeof o === 'string' ? {texto: o} : o));
76
76
  const out: Capa[] = [];
77
- const yTit = p.y ?? 640;
77
+ // con subtítulos al centro (serie), la pregunta y los botones suben para no encimarse
78
+ const subsAlCentro = tema.subtitulos.tipo !== 'ninguno' && tema.subtitulos.y > 850 && tema.subtitulos.y < 1300;
79
+ const yTit = p.y ?? (subsAlCentro && (p.formato ?? 'cartas') === 'botones' ? 420 : 640);
78
80
  if (p.pregunta) out.push({tipo: 'texto', texto: p.pregunta, x: 540, y: yTit, tam: p.tamPregunta ?? 84, maxAncho: 900, entrada: 'subir'});
79
81
  if ((p.formato ?? 'cartas') === 'botones') {
80
82
  ops.forEach((o, i) =>
@@ -159,10 +159,11 @@ export const ESTILOS: Record<string, Tema> = {
159
159
  '3d': {
160
160
  fondo: {tipo: 'degradado', colores: ['#BFE3FF', '#EAF6FF']},
161
161
  vineta: 0.1,
162
- colores: {texto: '#FFFFFF', acento: '#FFD43B', acento2: '#FF5A36', suave: 'rgba(255,255,255,0.7)', tarjeta: 'rgba(20,20,20,0.75)', tarjetaTexto: '#FFFFFF', borde: '#141414'},
162
+ // cielo claro: el texto propio va oscuro; los subtítulos siguen blancos con contorno negro
163
+ colores: {texto: '#14233A', acento: '#FF8A00', acento2: '#FF5A36', suave: '#4F6278', tarjeta: 'rgba(20,20,20,0.78)', tarjetaTexto: '#FFFFFF', borde: '#141414'},
163
164
  fuentes: {titulo: MONT, texto: MONT, mono: MONO},
164
165
  tituloMayusculas: true,
165
- subtitulos: {tipo: 'palabra', y: 1000, tam: 76, maxPalabras: 1, color: '#FFFFFF', pendiente: '#FFFFFF', resalte: '', contorno: 10},
166
+ subtitulos: {tipo: 'palabra', y: 1400, tam: 76, maxPalabras: 1, color: '#FFFFFF', pendiente: '#FFFFFF', resalte: '', contorno: 10},
166
167
  transicion: 'corte',
167
168
  marca: 'esquina',
168
169
  zoomLento: 1.0,
@@ -182,11 +183,15 @@ export const ESTILOS: Record<string, Tema> = {
182
183
  };
183
184
 
184
185
  const esObjeto = (x: unknown): x is Record<string, any> => !!x && typeof x === 'object' && !Array.isArray(x);
185
- /** Mezcla profunda: `b` gana sobre `a` (los arreglos se reemplazan completos). */
186
+ /**
187
+ * Mezcla profunda: `b` gana sobre `a` (los arreglos se reemplazan completos).
188
+ * Solo `fondo` se reemplaza entero, porque cada tipo de fondo lleva campos distintos; lo demás
189
+ * (subtitulos, colores, particulas...) se combina: {subtitulos: {tipo: 'bloque'}} conserva color y tamaño.
190
+ */
186
191
  export const mezclar = <T,>(a: T, b: any): T => {
187
192
  if (!esObjeto(b)) return a;
188
193
  const out: any = {...a};
189
- for (const [k, v] of Object.entries(b)) out[k] = esObjeto(v) && esObjeto(out[k]) && !('tipo' in v) ? mezclar(out[k], v) : v;
194
+ for (const [k, v] of Object.entries(b)) out[k] = k !== 'fondo' && esObjeto(v) && esObjeto(out[k]) ? mezclar(out[k], v) : v;
190
195
  return out;
191
196
  };
192
197
 
@@ -19,18 +19,18 @@ const animado = (c: C, ctx: CtxEscena, campo = 'valor', dur = 0.8) => {
19
19
 
20
20
  export const Barra: React.FC<{c: C; ctx: CtxEscena}> = ({c, ctx}) => {
21
21
  const T = ctx.tema;
22
- const ancho = c.ancho ?? 560;
22
+ const ancho = c.ancho ?? 760;
23
23
  const v = clamp(animado(c, ctx) / (c.max ?? 100));
24
24
  const color = c.color ?? T.colores.acento;
25
25
  return (
26
26
  <div style={{width: ancho, background: c.fondo ?? T.colores.tarjeta, borderRadius: 16, padding: '14px 20px', boxShadow: '0 10px 26px rgba(0,0,0,0.25)', border: `3px solid ${c.borde ?? 'rgba(0,0,0,0.08)'}`, fontFamily: T.fuentes.texto}}>
27
27
  {c.titulo || c.mostrar !== false ? (
28
28
  <div style={{display: 'flex', justifyContent: 'space-between', alignItems: 'baseline', marginBottom: 10, color: T.colores.tarjetaTexto}}>
29
- <span style={{fontSize: c.tamTitulo ?? 28, fontWeight: 800, letterSpacing: 1, textTransform: 'uppercase'}}>{c.titulo ?? ''}</span>
30
- {c.mostrar !== false ? <span style={{fontSize: 30, fontWeight: 800, color}}>{formatoNumero(v * (c.max ?? 100), c.decimales ?? 0)}{c.unidad ?? '%'}</span> : null}
29
+ <span style={{fontSize: c.tamTitulo ?? 34, fontWeight: 800, letterSpacing: 1, textTransform: 'uppercase'}}>{c.titulo ?? ''}</span>
30
+ {c.mostrar !== false ? <span style={{fontSize: 40, fontWeight: 800, color}}>{formatoNumero(v * (c.max ?? 100), c.decimales ?? 0)}{c.unidad ?? '%'}</span> : null}
31
31
  </div>
32
32
  ) : null}
33
- <div style={{height: c.alto ?? 24, background: c.pista ?? 'rgba(127,127,127,0.25)', borderRadius: 99, overflow: 'hidden'}}>
33
+ <div style={{height: c.alto ?? 30, background: c.pista ?? 'rgba(127,127,127,0.25)', borderRadius: 99, overflow: 'hidden'}}>
34
34
  <div style={{width: `${v * 100}%`, height: '100%', background: color, borderRadius: 99, boxShadow: `0 0 14px ${color}`}} />
35
35
  </div>
36
36
  </div>