morfeo 0.4.0 → 0.5.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/lib/guion.mjs CHANGED
@@ -170,7 +170,7 @@ export async function esquemaMotor() {
170
170
  claves: z.array(z.string()).nullable(),
171
171
  });
172
172
  const cierre = z.object({pide: z.enum(['comenta', 'comparte', 'sigue']), say: z.string(), palabra: z.string().nullable(), a: z.string().nullable()});
173
- return z.object({id: z.string(), title: z.string(), formula: z.enum(IDS_FORMULA), music: z.enum(MUSICAS), scenes: z.array(escena), cierre});
173
+ return z.object({id: z.string(), title: z.string(), formula: z.enum(IDS_FORMULA), music: z.enum(MUSICAS), descripcion: z.string(), tags: z.array(z.string()), hooks: z.array(z.string()), scenes: z.array(escena), cierre});
174
174
  }
175
175
 
176
176
  /** De la salida de la IA (motor de capas) al formato de escenas de morfeo. */
@@ -266,6 +266,51 @@ export function aEscena(e) {
266
266
  return out;
267
267
  }
268
268
 
269
+ /**
270
+ * Investiga el tema en la web antes de escribir (búsqueda del servidor de Claude): devuelve notas con cada dato y su
271
+ * fuente, y la lista de fuentes que de verdad se consultaron (las URLs de los resultados de búsqueda citados).
272
+ * @returns {Promise<{notas: string, fuentes: {texto: string, url: string}[]}>}
273
+ */
274
+ export async function investigar(client, Anthropic, tema, {idioma = 'español de México', modelo = MODELO_GUION, log = () => {}} = {}) {
275
+ const messages = [
276
+ {
277
+ role: 'user',
278
+ content: `Investiga para un video corto vertical sobre: ${tema}
279
+
280
+ Busca en la web y junta 8 a 12 datos concretos, sorprendentes y verificables (cifras, fechas, nombres, lugares), cada uno
281
+ con la fuente donde lo leíste (nombre del medio o institución y URL). Prefiere fuentes primarias u oficiales. Si dos
282
+ fuentes no coinciden, dilo. No inventes nada que no esté en una fuente.
283
+ Responde en ${idioma} con una lista: "- dato · fuente (URL)".`,
284
+ },
285
+ ];
286
+ const vistas = new Map();
287
+ let texto = '';
288
+ // la búsqueda corre en el servidor; si el turno se pausa (pause_turn), se reenvía para que continúe
289
+ for (let vuelta = 0; vuelta < 6; vuelta++) {
290
+ const res = await client.messages.create({
291
+ model: modelo,
292
+ max_tokens: 16000,
293
+ tools: [{type: 'web_search_20260209', name: 'web_search', max_uses: 8}],
294
+ messages,
295
+ });
296
+ for (const b of res.content) {
297
+ if (b.type === 'web_search_tool_result' && Array.isArray(b.content)) for (const r of b.content) if (r.url) vistas.set(r.url, r.title ?? r.url);
298
+ if (b.type === 'text') {
299
+ texto += b.text;
300
+ for (const c of b.citations ?? []) if (c.url) vistas.set(c.url, c.title ?? vistas.get(c.url) ?? c.url);
301
+ }
302
+ }
303
+ if (res.stop_reason === 'refusal') throw new ErrorMorfeo('Claude no quiso investigar ese tema.');
304
+ if (res.stop_reason !== 'pause_turn') break;
305
+ log(' (sigue buscando…)');
306
+ messages.push({role: 'assistant', content: res.content});
307
+ }
308
+ if (!texto.trim()) throw new ErrorMorfeo('La investigación no devolvió datos; intenta otra vez o sin --investigar.');
309
+ // solo las fuentes que aparecen en las notas (las que de verdad respaldan un dato)
310
+ const fuentes = [...vistas].filter(([url]) => texto.includes(url) || texto.includes(new URL(url).hostname)).map(([url, titulo]) => ({texto: titulo, url}));
311
+ return {notas: texto.trim(), fuentes: fuentes.length ? fuentes : [...vistas].slice(0, 8).map(([url, titulo]) => ({texto: titulo, url}))};
312
+ }
313
+
269
314
  /**
270
315
  * Pide a Claude un video completo.
271
316
  * @param {{tema?: string, texto?: string, idioma?: string, duracion?: number, tono?: string, estilo?: string, imagenes?: boolean, voz?: any, modelo?: string}} o
@@ -283,10 +328,25 @@ export async function guionConIA(o) {
283
328
  throw new ErrorMorfeo('Falta la dependencia @anthropic-ai/sdk (npm install @anthropic-ai/sdk zod)');
284
329
  }
285
330
  const client = new Anthropic();
331
+ // --investigar: primero los datos con su fuente (búsqueda web), luego el guion se escribe solo con esos datos
332
+ let investigacion = null;
333
+ if (o.investigar && !o.texto) {
334
+ (o.log ?? console.log)('Investigando el tema en la web (cada dato con su fuente)...');
335
+ try {
336
+ investigacion = await investigar(client, Anthropic, o.tema, {idioma: o.idioma ?? 'español de México', modelo: o.modelo ?? MODELO_GUION, log: o.log ?? console.log});
337
+ } catch (e) {
338
+ if (e instanceof ErrorMorfeo) throw e;
339
+ if (e instanceof Anthropic.APIError) throw new ErrorMorfeo(`La búsqueda web falló (${e.status}): ${e.message}. Intenta sin --investigar.`);
340
+ throw e;
341
+ }
342
+ }
286
343
  const idioma = o.idioma ?? 'español de México';
287
- const pedido = o.texto
344
+ const pedidoBase = o.texto
288
345
  ? `Convierte este guion en escenas. Respeta su contenido y su orden; puedes partir frases largas.\n\n<guion>\n${o.texto}\n</guion>`
289
346
  : `Escribe un video sobre: ${o.tema}`;
347
+ const pedido = investigacion
348
+ ? `${pedidoBase}\n\nUsa SOLO estos datos investigados (no agregues cifras que no estén aquí):\n<investigacion>\n${investigacion.notas}\n</investigacion>`
349
+ : pedidoBase;
290
350
  // ~45 s por defecto: los videos de referencia rondan el minuto y ninguna idea pasa de ~5 s (lib/estructura.mjs)
291
351
  const forzada = o.formula ? formulaDe(o.formula) : null;
292
352
  if (o.formula && !forzada) throw new ErrorMorfeo(`La fórmula "${o.formula}" no existe. Opciones: ${IDS_FORMULA.join(', ')}`);
@@ -304,7 +364,7 @@ export async function guionConIA(o) {
304
364
  model: o.modelo ?? MODELO_GUION,
305
365
  max_tokens: 16000,
306
366
  system: motor ? INSTRUCCIONES_MOTOR : INSTRUCCIONES,
307
- messages: [{role: 'user', content: `${pedido}\n\n${extra.join('\n')}\nEn "id" pon un identificador corto en minúsculas sin espacios. En "title" el título para publicar (con un emoji). En "music" el estilo de música que mejor le quede.`}],
367
+ messages: [{role: 'user', content: `${pedido}\n\n${extra.join('\n')}\nEn "id" pon un identificador corto en minúsculas sin espacios. En "title" el título para publicar (con un emoji). En "music" el estilo de música que mejor le quede. En "descripcion" el texto del post (1–2 frases). En "tags" 3–5 etiquetas sin #. En "hooks" 3 primeras frases alternativas al gancho, distintas entre sí, para probar otra versión.`}],
308
368
  output_config: {format: zodOutputFormat(motor ? await esquemaMotor() : await esquema())},
309
369
  });
310
370
  } catch (e) {
@@ -321,7 +381,7 @@ export async function guionConIA(o) {
321
381
  const scenes = g.scenes.map((e) => aEscenaMotor(e, !!o.imagenes));
322
382
  const c = g.cierre;
323
383
  const cta = !c || c.pide === 'sigue' ? {type: 'cta', ...(c?.say ? {say: c.say} : {})} : {type: 'cta', pide: c.pide, say: c.say, ...(c.palabra ? {palabra: c.palabra} : {}), ...(c.a ? {a: c.a} : {})};
324
- return {id: slug(g.id || g.title), video: {estilo: o.estilo, formula: forzada?.id ?? g.formula, title: g.title, voice: o.voz ?? 'em_alex', music: g.music, scenes: [...scenes, cta]}};
384
+ return {id: slug(g.id || g.title), video: {estilo: o.estilo, formula: forzada?.id ?? g.formula, title: g.title, descripcion: g.descripcion, tags: g.tags, hooks: g.hooks, ...(investigacion?.fuentes.length ? {fuentes: investigacion.fuentes} : {}), voice: o.voz ?? 'em_alex', music: g.music, scenes: [...scenes, cta]}};
325
385
  }
326
386
  const scenes = g.scenes.map(aEscena);
327
387
  if (scenes[0]?.type !== 'title') scenes.unshift({type: 'title', say: scenes[0]?.say ?? g.title, rows: [[g.title.toUpperCase().slice(0, 14), '#FFD400', 200]], icon: 'question'});
package/lib/hoja.mjs CHANGED
@@ -1,6 +1,7 @@
1
1
  // `morfeo hoja <id>`: hoja de contacto (PNG con cuadros repartidos en el tiempo) para revisar un video de un vistazo.
2
2
  // El ffmpeg que trae Remotion viene recortado (sin los filtros `tile` ni `fps`), así que cada cuadro se pide como
3
3
  // PNG sin predicción por una tubería y la hoja se arma aquí con zlib.
4
+ import {ZONAS} from './calidad.mjs';
4
5
  import {spawnSync} from 'node:child_process';
5
6
  import fs from 'node:fs';
6
7
  import {createRequire} from 'node:module';
@@ -62,10 +63,11 @@ function leerPng(b, w, h) {
62
63
  }
63
64
 
64
65
  /**
65
- * @param {{video: string, salida?: string, cols?: number, filas?: number, desde?: number, hasta?: number, ancho?: number}} op
66
+ * @param {{video: string, salida?: string, cols?: number, filas?: number, desde?: number, hasta?: number, ancho?: number, zona?: boolean}} op
67
+ * zona: pinta en rojo lo que tapan los botones de TikTok/Reels/Shorts
66
68
  * @returns {string} ruta del PNG
67
69
  */
68
- export function hoja({video, salida, cols = 5, filas = 3, desde = 0, hasta, ancho = 216}) {
70
+ export function hoja({video, salida, cols = 5, filas = 3, desde = 0, hasta, ancho = 216, zona = false}) {
69
71
  // rutas absolutas: ffmpeg corre desde la carpeta de sus DLL, así que una ruta relativa no se encontraría
70
72
  video = path.resolve(video);
71
73
  if (salida) salida = path.resolve(salida);
@@ -87,6 +89,19 @@ export function hoja({video, salida, cols = 5, filas = 3, desde = 0, hasta, anch
87
89
  const r = spawnSync(ffmpeg, ['-v', 'error', '-ss', t.toFixed(3), '-i', video, '-frames:v', '1', '-vf', `scale=${w}:${h}`, '-f', 'image2pipe', '-c:v', 'png', '-pred', 'none', '-pix_fmt', 'rgb24', '-'], {maxBuffer: 64 * 1024 * 1024, cwd});
88
90
  if (r.status !== 0 || !r.stdout?.length) continue;
89
91
  const px = leerPng(r.stdout, w, h);
92
+ if (zona) {
93
+ // tinte rojo en las zonas que tapan las apps (escala de 1080×1920 al tamaño del cuadro)
94
+ const ex = w / 1080;
95
+ const ey = h / 1920;
96
+ for (const z of ZONAS)
97
+ for (let y = Math.floor(z.y0 * ey); y < Math.min(h, Math.ceil(z.y1 * ey)); y++)
98
+ for (let x = Math.floor(z.x0 * ex); x < Math.min(w, Math.ceil(z.x1 * ex)); x++) {
99
+ const k = (y * w + x) * 3;
100
+ px[k] = Math.round(px[k] * 0.55 + 255 * 0.45);
101
+ px[k + 1] = Math.round(px[k + 1] * 0.55);
102
+ px[k + 2] = Math.round(px[k + 2] * 0.55);
103
+ }
104
+ }
90
105
  const x0 = (i % cols) * w;
91
106
  const y0 = Math.floor(i / cols) * h;
92
107
  for (let y = 0; y < h; y++) px.copy(lienzo, (y0 + y) * (1 + W * 3) + 1 + x0 * 3, y * w * 3, (y + 1) * w * 3);
package/lib/palabras.mjs CHANGED
@@ -35,7 +35,7 @@ function momentosDe(x, ruta, out) {
35
35
  if (p) out.push([`${ruta}.${k}`, p]);
36
36
  }
37
37
  // lo que vive dentro de una capa: sus capas, su capa repetida, cambios, poses, items, líneas, movimientos
38
- for (const k of ['capas', 'capa', 'cambios', 'poses', 'items', 'lineas', 'mover', 'escalar', 'opciones', 'capasPantalla'])
38
+ for (const k of ['capas', 'capa', 'cambios', 'poses', 'items', 'lineas', 'mover', 'escalar', 'opciones', 'capasPantalla', 'hitos', 'notificaciones'])
39
39
  if (x[k] != null) momentosDe(x[k], `${ruta}.${k}`, out);
40
40
  }
41
41
 
package/lib/render.mjs CHANGED
@@ -208,12 +208,31 @@ 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)"/.test(texto)) 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, log = console.log, alProgreso} = {}) {
233
+ export async function render({proyecto = process.cwd(), ids, todos = false, soloFallidos = false, previa = null, log = console.log, alProgreso} = {}) {
234
+ // previa: solo el gancho (las primeras escenas, hasta `previa` segundos o ~8 s) en "<título> (previa).mp4", sin miniatura
235
+ if (previa && !ids?.length) throw new ErrorMorfeo('Dime qué video: npx morfeo previa mi-video');
217
236
  cargarEnv(proyecto);
218
237
  const p = await cargarProyecto(proyecto);
219
238
  const loteArchivo = path.join(p.trabajo, 'ultimo-lote.json');
@@ -266,9 +285,15 @@ export async function render({proyecto = process.cwd(), ids, todos = false, solo
266
285
  const t = Date.now();
267
286
  try {
268
287
  const inputProps = {videos: {[id]: datos[id]}, marca, ...extras};
269
- const mp4 = salidaDe(p, id);
288
+ const mp4 = previa ? salidaDe(p, id).replace(/\.mp4$/, ' (previa).mp4') : salidaDe(p, id);
289
+ // tramo de la previa: hasta el final de la 2.ª escena (gancho + la que sigue), entre 4 y 8 s, o lo que se pida
290
+ const d0 = datos[id];
291
+ 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));
270
292
  let gl = p.render.gl;
271
- let concurrencia = concurrenciaBase;
293
+ // WebGL con GPU y muchas pestañas: alguna se queda "pegada" y sus cuadros salen con rótulos o subtítulos de
294
+ // cuadros anteriores (medido en Windows + RTX 3060: 9 pestañas → 0–54 cuadros malos de 270; 4 → 41–81; 2–3 → 0
295
+ // en 24 pruebas, casi igual de rápido). Solo se limita si el video usa 3D y nadie fijó la concurrencia.
296
+ let concurrencia = gl === 'angle' && p.render.concurrencia == null && usaWebGL(p, id) ? Math.min(concurrenciaBase, 3) : concurrenciaBase;
272
297
  let composition;
273
298
  // hasta 3 intentos: si el navegador truena, se baja la concurrencia y, con GPU, se pasa a software
274
299
  for (let intento = 1; ; intento++) {
@@ -289,6 +314,7 @@ export async function render({proyecto = process.cwd(), ids, todos = false, solo
289
314
  jpegQuality: 92,
290
315
  concurrency: concurrencia,
291
316
  outputLocation: mp4,
317
+ ...(previa ? {frameRange: [0, Math.min(composition.durationInFrames - 1, Math.round(finPrevia * composition.fps))]} : {}),
292
318
  onProgress: ({progress}) => {
293
319
  alProgreso?.(id, progress);
294
320
  // con terminal: cada 10 % en la misma línea; sin terminal (logs, CI): cada 25 %
@@ -314,7 +340,7 @@ export async function render({proyecto = process.cwd(), ids, todos = false, solo
314
340
  }
315
341
  }
316
342
  let jpg = null;
317
- if (p.render.miniatura) {
343
+ if (p.render.miniatura && !previa) {
318
344
  // miniatura: cuando la primera escena ya está completa (o en `miniaturaEn`), sin subtítulos
319
345
  jpg = salidaDe(p, id, 'jpg');
320
346
  const d = datos[id];
@@ -324,6 +350,17 @@ export async function render({proyecto = process.cwd(), ids, todos = false, solo
324
350
  const props = {...inputProps, miniatura: true};
325
351
  await renderStill({serveUrl, composition: {...composition, props: {...composition.props, miniatura: true}}, inputProps: props, browserExecutable, chromiumOptions: {gl}, frame, imageFormat: 'jpeg', jpegQuality: 92, output: jpg});
326
352
  }
353
+ // portada: el mismo cuadro con el título grande y la marca (`portada: 'texto'` o el título sin emojis; false = sin portada)
354
+ const vid = p.videos[id];
355
+ if (!previa && p.render.portada !== false && vid.portada !== false && jpg) {
356
+ const textoPortada = typeof vid.portada === 'string' ? vid.portada : String(vid.title ?? id).replace(/[\p{Extended_Pictographic}\uFE0F\u200D]/gu, '').replace(/\s*[|·].*$/, '').trim();
357
+ const d = datos[id];
358
+ const e0 = d.escenas[0];
359
+ const seg = vid.miniaturaEn ?? Math.min(e0.dur - 0.15, Math.max(1.4, e0.habla[1] * 0.85));
360
+ const frame = Math.max(0, Math.min(composition.durationInFrames - 1, Math.round(seg * composition.fps)));
361
+ const props = {...inputProps, portada: textoPortada};
362
+ await renderStill({serveUrl, composition: {...composition, props: {...composition.props, portada: textoPortada}}, inputProps: props, browserExecutable, chromiumOptions: {gl}, frame, imageFormat: 'jpeg', jpegQuality: 92, output: salidaDe(p, id, 'jpg').replace(/\.jpg$/, ' (portada).jpg')});
363
+ }
327
364
  const segundos = Math.round((Date.now() - t) / 1000);
328
365
  if (process.stdout.isTTY) process.stdout.write('\r');
329
366
  log(`[${i + 1}/${listos.length}] ${id}: listo en ${segundos} s → ${path.relative(p.proyecto, mp4)} `);
@@ -337,6 +374,7 @@ export async function render({proyecto = process.cwd(), ids, todos = false, solo
337
374
  } finally {
338
375
  soltar();
339
376
  }
377
+ if (previa) return hechos;
340
378
  return terminarLote(p, loteArchivo, hechos, fallidos, log);
341
379
  }
342
380
 
@@ -438,7 +476,18 @@ function escribirResumen(p) {
438
476
  const v = p.videos[f.id];
439
477
  const tags = (v.tags ?? []).map((x) => `#${String(x).replace(/^#/, '').replace(/\s+/g, '')}`).join(' ');
440
478
  const creditos = f.creditos?.length ? ['', 'Créditos:', ...f.creditos.map((c) => `- ${c}`)] : [];
441
- const lineas = [v.title ?? f.id, '', ...(v.descripcion ? [v.descripcion, ''] : []), ...(tags ? [tags] : []), ...creditos];
479
+ const fuentes = (v.fuentes ?? []).map((x) => (typeof x === 'string' ? `- ${x}` : `- ${x.texto ?? ''}${x.url ? ` · ${x.url}` : ''}`));
480
+ const ganchos = (v.hooks ?? []).map((h, i) => `${i + 1}. ${h}`);
481
+ const lineas = [
482
+ v.title ?? f.id,
483
+ '',
484
+ ...(v.descripcion ? [v.descripcion, ''] : []),
485
+ ...(tags ? [tags] : []),
486
+ ...(fuentes.length ? ['', 'Fuentes:', ...fuentes] : []),
487
+ ...creditos,
488
+ // lo de abajo no va en el post: ganchos para probar otra versión del inicio
489
+ ...(ganchos.length ? ['', '———', 'Ganchos de reserva (para otra versión):', ...ganchos] : []),
490
+ ];
442
491
  fs.writeFileSync(salidaDe(p, f.id, 'txt'), lineas.join('\n').trim() + '\n');
443
492
  }
444
493
  if (conCreditos.length)
package/package.json CHANGED
@@ -1,11 +1,11 @@
1
1
  {
2
2
  "name": "morfeo",
3
- "version": "0.4.0",
3
+ "version": "0.5.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://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"
@@ -0,0 +1,54 @@
1
+ # Instrucciones para la IA (Claude Code, Codex u otra)
2
+
3
+ Este es un proyecto de **Morfeo**: videos verticales animados (Shorts, TikTok, Reels) desde código. Tú eres quien
4
+ los hace: la persona te pide un tema y tú le devuelves el video terminado. Habla con ella en su idioma, sin jerga
5
+ técnica. Nunca publiques nada por ella: le entregas los archivos y ella decide dónde subirlos.
6
+
7
+ Comandos de referencia: `npx morfeo ayuda`. Tipos de todo lo que existe: `node_modules/morfeo/index.d.ts`.
8
+
9
+ ## Cuando diga «Configúrame» (o es la primera vez)
10
+
11
+ 1. Revisa que haya Node 18.17+ (`node -v`). Si falta, dile cómo instalarlo y espera.
12
+ 2. `npm install` en esta carpeta.
13
+ 3. `npx morfeo instalar --si` (Python, voces gratis, navegador; tarda unos minutos la primera vez). Si algo falla,
14
+ lee el mensaje, arréglalo y vuelve a correrlo. `npx morfeo doctor` dice qué falta.
15
+ 4. `npx morfeo estilo`: abre una página para que elija nombre, siglas, colores, letra, voz, ritmo y frase de cierre.
16
+ Espera a que diga que guardó (crea `estilo.json`).
17
+ 5. Si quiere su voz de ElevenLabs: que pegue su clave en `.env` como `ELEVENLABS_API_KEY=…` (cópialo de `.env.example`;
18
+ nunca escribas tú la clave ni la pongas en el chat). Para investigar con Claude desde la terminal, `ANTHROPIC_API_KEY`.
19
+ 6. Haz un video de prueba (`npx morfeo render ejemplo-2d`) y dile dónde quedó.
20
+
21
+ ## Cuando diga «Hazme un video sobre…»
22
+
23
+ 1. **Investiga.** Busca los datos en la web y quédate solo con lo que puedas respaldar con una fuente (medio,
24
+ institución, URL). Si no puedes buscar, usa `npx morfeo guion "tema" --investigar` (necesita `ANTHROPIC_API_KEY`).
25
+ Si dudas de una cifra, dila en general o quítala.
26
+ 2. **Elige la fórmula** que mejor le queda al tema: `npx morfeo formulas` (construiste, mito, misterio, serie,
27
+ biografía, experimento, demuéstralo, roast…). Si hay una base para esa fórmula, empieza de ahí:
28
+ `npx morfeo nuevo-video <id> --formula <fórmula>` (bases: serie, mito, escalada, biografia, construiste; o
29
+ `--look 2d` / `--look 3d`). Si no, `npx morfeo guion "tema" --formula <fórmula>` o escribe el guion a mano en
30
+ `guiones/<id>.mjs` (mira los ejemplos de `guiones/`).
31
+ 3. **Escribe el guion** siguiendo la fórmula y estas reglas:
32
+ - Gancho en los primeros 2 s, en segunda persona o con una cifra imposible. Nada de "hoy te voy a contar".
33
+ - Una idea por escena (2–5 s de voz). Pregunta antes de revelar. Sube la apuesta hacia el final.
34
+ - `say` es lo que dice la voz (cifras con letra); `show` el subtítulo con cifras.
35
+ - Rótulos cortos en 2–4 escenas (`rotulo`, con `rotuloEstilo` sello/paso/pixel si va), `claves` en cada escena.
36
+ - Cierre que pide algo: `{type: 'cta', pide: 'comenta', palabra: 'X'}` o `pide: 'comparte', a: 'tu amigo que…'`.
37
+ - `title`, `descripcion`, `tags`, `hooks` (3 ganchos de reserva) y `fuentes` (cada dato con su fuente).
38
+ 4. **Enséñale el guion** (texto de cada escena, en limpio) y espera su visto bueno o sus cambios.
39
+ 5. **Enséñale el gancho:** `npx morfeo previa <id>` renderiza solo los primeros segundos. Si no le convence, cámbialo
40
+ (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
+ - `npx morfeo hoja <id> --zona`: mira la hoja de contacto (es una imagen); lo pintado en rojo es lo que tapan las apps.
45
+ - 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
47
+ `<título>.txt` (texto del post, etiquetas, fuentes y ganchos de reserva). Dile eso y nada más.
48
+
49
+ ## Reglas
50
+
51
+ - No inventes datos. No copies videos de otros: se toma la forma, no el contenido.
52
+ - Emojis solo como último recurso; mejor objetos 2D/3D (`{tipo: 'recurso', buscar: 'volcán', estilo: '3d'}`).
53
+ - Un render a la vez. Si un render falla, lee el error (Morfeo explica qué falta) y corrígelo.
54
+ - No toques `node_modules/`. Lo de la persona vive en `guiones/`, `escenas/`, `assets/`, `estilo.json` y `morfeo.config.mjs`.
@@ -0,0 +1,5 @@
1
+ # Instrucciones para Claude Code
2
+
3
+ Las instrucciones de este proyecto están en AGENTS.md (las mismas para cualquier IA):
4
+
5
+ @AGENTS.md
package/python/musica.py CHANGED
@@ -179,11 +179,97 @@ def efecto(nombre, rng):
179
179
  x = 0.22 * pop(180, 90, 0.16)
180
180
  add(x, 0.1 * lp(rng.standard_normal(int(0.06 * SR)), 1200), 0)
181
181
  return x
182
+ if nombre in EFECTOS_EXTRA: return EFECTOS_EXTRA[nombre](rng)
182
183
  return 0.14 * pop()
183
184
 
184
185
 
186
+ def _env(n, ataque=0.004, cola=6.0):
187
+ t = np.arange(n) / SR
188
+ return np.minimum(1, t / ataque) * np.exp(-t * cola)
189
+
190
+
191
+ def _tono(f, d, cola=8.0, armonicos=(1,)):
192
+ n = int(d * SR); t = np.arange(n) / SR
193
+ x = sum(np.sin(2 * np.pi * f * h * t) / (i + 1) for i, h in enumerate(armonicos))
194
+ return x * _env(n, 0.003, cola)
195
+
196
+
197
+ def _teclas(rng):
198
+ """Ráfaga de teclas de máquina: 7 clics secos con espaciado irregular."""
199
+ x = np.zeros(int(0.9 * SR)); t = 0.0
200
+ for _ in range(7):
201
+ c = hp(rng.standard_normal(int(0.012 * SR)), 2500) * _env(int(0.012 * SR), 0.0005, 300)
202
+ add(x, 0.14 * c, t); t += 0.07 + rng.random() * 0.07
203
+ return x
204
+
205
+
206
+ def _radio(rng):
207
+ """Estática de radio con un pitido de sintonía."""
208
+ n = int(0.7 * SR)
209
+ ruido = hp(lp(rng.standard_normal(n), 3500), 400) * 0.25 * np.minimum(1, np.arange(n) / (0.03 * SR)) * np.linspace(1, 0.2, n)
210
+ add(ruido, 0.08 * _tono(1200, 0.25, 4), 0.2)
211
+ return ruido
212
+
213
+
214
+ def _obturador(rng):
215
+ """Obturador de cámara: dos clics mecánicos."""
216
+ x = np.zeros(int(0.25 * SR))
217
+ for t, f in ((0.0, 1800), (0.07, 1300)):
218
+ c = lp(hp(rng.standard_normal(int(0.02 * SR)), 900), f * 3) * _env(int(0.02 * SR), 0.0004, 220)
219
+ add(x, 0.5 * c, t)
220
+ return x
221
+
222
+
223
+ def _notificacion(rng):
224
+ """Notificación del teléfono: dos notas que suben."""
225
+ x = np.zeros(int(0.5 * SR))
226
+ add(x, 0.18 * _tono(nf(84), 0.25, 14, (1, 2)), 0)
227
+ add(x, 0.18 * _tono(nf(91), 0.35, 10, (1, 2)), 0.11)
228
+ return x
229
+
230
+
231
+ def _latido(rng):
232
+ """Latido: dos golpes graves (lub-dub)."""
233
+ x = np.zeros(int(0.7 * SR))
234
+ add(x, 0.5 * _tono(55, 0.18, 22), 0)
235
+ add(x, 0.35 * _tono(48, 0.16, 24), 0.2)
236
+ return lp(x, 300)
237
+
238
+
239
+ def _alarma(rng):
240
+ """Alarma: tres pitidos cuadrados."""
241
+ x = np.zeros(int(0.9 * SR))
242
+ for i in range(3):
243
+ n = int(0.16 * SR); t = np.arange(n) / SR
244
+ b = np.sign(np.sin(2 * np.pi * 1600 * t)) * 0.09 * np.minimum(1, (n - np.arange(n)) / (0.01 * SR))
245
+ add(x, lp(b, 5000), i * 0.27)
246
+ return x
247
+
248
+
249
+ def _reloj(rng):
250
+ """Tic-tac de reloj: tic agudo y tac grave."""
251
+ x = np.zeros(int(1.0 * SR))
252
+ for i in range(2):
253
+ add(x, 0.45 * tick(0.02), i * 0.5)
254
+ add(x, 0.3 * lp(tick(0.025), 1500), i * 0.5 + 0.25)
255
+ return x
256
+
257
+
258
+ def _caja(rng):
259
+ """Caja registradora: campanita y cajón que se abre."""
260
+ x = np.zeros(int(0.9 * SR))
261
+ add(x, 0.25 * bell(nf(96), 0.7), 0.0)
262
+ cajon = lp(rng.standard_normal(int(0.25 * SR)), 900) * _env(int(0.25 * SR), 0.01, 10) * 0.25
263
+ add(x, cajon, 0.12)
264
+ return x
265
+
266
+
267
+ EFECTOS_EXTRA = {"teclas": _teclas, "radio": _radio, "obturador": _obturador, "notificacion": _notificacion, "latido": _latido, "alarma": _alarma, "reloj": _reloj, "caja": _caja}
268
+
269
+
185
270
  # sonido por defecto según cómo entra o qué es la capa (sonido: false lo apaga; sonido: "x" lo cambia)
186
- SONIDO_TIPO = {"sello": "golpe", "dato": "tick", "contador": "tick", "nombre": "pop", "accion": "pop", "chip": "pop", "insignia": "pop"}
271
+ SONIDO_TIPO = {"sello": "golpe", "dato": "tick", "contador": "tick", "nombre": "pop", "accion": "pop", "chip": "pop", "insignia": "pop",
272
+ "bloqueo": "notificacion", "llamada": "notificacion", "vigilancia": "radio", "terminal": "teclas", "calendario": "reloj", "fecha": "golpe", "noticia": "radio", "recuento": "tick", "cronometro": "reloj"}
187
273
  SONIDO_ENTRADA = {"rebote": "pop", "caer": "golpe", "deslizar-izq": "whoosh", "deslizar-der": "whoosh", "deslizar-arriba": "whoosh", "deslizar-abajo": "whoosh", "girar": "whoosh"}
188
274
 
189
275
 
@@ -27,10 +27,11 @@ import {MARCA_VACIA, type Datos, type Escena, type Marca as TipoMarca} from './t
27
27
  import {Capas} from './motor/capas';
28
28
  import {capasDe} from './motor/plantillas';
29
29
  import {temaDe} from './motor/tema';
30
+ import {Portada} from './motor/portada';
30
31
  import {VideoConAudio} from './motor/Video';
31
32
  import {loadFont} from '@remotion/fonts';
32
33
 
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
+ export type PropsShort = {id: string; videos?: Record<string, Datos>; marca?: TipoMarca; guias?: boolean; miniatura?: boolean; portada?: string; fuentes?: {familia: string; url: string}[]; estilos?: Record<string, any>};
34
35
 
35
36
  const ADELANTO_GANCHO = 0.4; // el gancho arranca ya animado: el fotograma 0 nunca está vacío
36
37
  const CIERRE_BUCLE = 0.5; // al final se funde con el fotograma 0 para que el video se repita sin corte
@@ -246,7 +247,7 @@ export const Fotograma: React.FC<{d: Datos; T: number; guias?: boolean; miniatur
246
247
  );
247
248
  };
248
249
 
249
- export const Short: React.FC<PropsShort> = ({id, videos, marca, guias, miniatura, fuentes, estilos}) => {
250
+ export const Short: React.FC<PropsShort> = ({id, videos, marca, guias, miniatura, portada, fuentes, estilos}) => {
250
251
  const frame = useCurrentFrame();
251
252
  const [listo, setListo] = useState(false);
252
253
  const [handle] = useState(() => delayRender('Cargando fuentes'));
@@ -259,6 +260,13 @@ export const Short: React.FC<PropsShort> = ({id, videos, marca, guias, miniatura
259
260
  const d = videos?.[id];
260
261
  if (!d) throw new Error(`El video "${id}" no está preparado. Corre: npx morfeo render ${id}`);
261
262
  if (!listo) return null;
263
+ if (portada)
264
+ return (
265
+ <AbsoluteFill>
266
+ {d.estilo && d.estilo !== 'datos-curiosos' ? <VideoConAudio d={d} frame={frame} marca={{...MARCA_VACIA, ...marca}} estilos={estilos} miniatura sinMarca /> : <Fotograma d={d} T={frame / FPS} miniatura />}
267
+ <Portada texto={portada} marca={{...MARCA_VACIA, ...marca}} />
268
+ </AbsoluteFill>
269
+ );
262
270
  if (d.estilo && d.estilo !== 'datos-curiosos') return <VideoConAudio d={d} frame={frame} marca={{...MARCA_VACIA, ...marca}} estilos={estilos} miniatura={miniatura} />;
263
271
  const T = frame / FPS;
264
272
  const kb = clamp((T - (d.duracion - CIERRE_BUCLE)) / CIERRE_BUCLE);
@@ -383,7 +383,7 @@ const MarcaEsquina: React.FC<{tema: Tema}> = ({tema}) => {
383
383
  );
384
384
  };
385
385
 
386
- export const VideoMotor: React.FC<{d: Datos; T: number; estilos?: Record<string, any>; miniatura?: boolean}> = ({d, T, estilos, miniatura}) => {
386
+ export const VideoMotor: React.FC<{d: Datos; T: number; estilos?: Record<string, any>; miniatura?: boolean; sinMarca?: boolean}> = ({d, T, estilos, miniatura, sinMarca}) => {
387
387
  const tema = temaDe(d.estilo, d.tema, estilos);
388
388
  const n = d.escenas.length;
389
389
  let si = d.escenas.findIndex((e) => T < e.t0 + e.dur);
@@ -459,18 +459,18 @@ export const VideoMotor: React.FC<{d: Datos; T: number; estilos?: Record<string,
459
459
  <Vineta fuerza={tema.vineta ?? 0} color={tema.post?.vinetaColor} />
460
460
  {d.hud?.length ? <Hud d={d} T={T} tema={tema} /> : null}
461
461
  {d.serie && e.tipo !== 'cta' ? <Serie texto={d.serie} tema={tema} /> : null}
462
- {e.tipo !== 'cta' ? <Rotulo e={e} t={t} tema={e.p.tema ? mezclar(tema, e.p.tema) : tema} /> : null}
462
+ {e.tipo !== 'cta' && !sinMarca ? <Rotulo e={e} t={t} tema={e.p.tema ? mezclar(tema, e.p.tema) : tema} /> : null}
463
463
  {e.tipo !== 'cta' && !miniatura ? <Subtitulos tema={e.p.tema ? mezclar(tema, e.p.tema) : tema} palabras={e.palabras ?? []} t={t} dur={e.dur} claves={e.p.claves} /> : null}
464
- {e.tipo !== 'cta' ? <MarcaEsquina tema={tema} /> : null}
464
+ {e.tipo !== 'cta' && !sinMarca ? <MarcaEsquina tema={tema} /> : null}
465
465
  </AbsoluteFill>
466
466
  );
467
467
  };
468
468
 
469
469
  /** Componente raíz de un video del motor de capas (con su audio). */
470
- export const VideoConAudio: React.FC<{d: Datos; frame: number; marca: any; estilos?: Record<string, any>; miniatura?: boolean}> = ({d, frame, marca, estilos, miniatura}) => (
470
+ export const VideoConAudio: React.FC<{d: Datos; frame: number; marca: any; estilos?: Record<string, any>; miniatura?: boolean; sinMarca?: boolean}> = ({d, frame, marca, estilos, miniatura, sinMarca}) => (
471
471
  <MarcaContexto.Provider value={marca}>
472
472
  <AbsoluteFill>
473
- <VideoMotor d={d} T={frame / FPS} estilos={estilos} miniatura={miniatura} />
473
+ <VideoMotor d={d} T={frame / FPS} estilos={estilos} miniatura={miniatura} sinMarca={sinMarca} />
474
474
  <Audio src={staticFile(d.audio)} />
475
475
  </AbsoluteFill>
476
476
  </MarcaContexto.Provider>
@@ -367,9 +367,13 @@ export const Tierra: React.FC<{c: C; ctx: CtxEscena}> = ({c, ctx}) => {
367
367
  const pin = clamp((t - (c.girar === false ? 0.2 : (c.duracion ?? 1.8) * 0.85)) / 0.35);
368
368
  const X = cx + px * R;
369
369
  const Y = cx - py * R;
370
+ // `acercar: 4`: después del pin, la cámara vuela hacia el sitio (el globo crece alrededor del pin)
371
+ const tPin = c.girar === false ? 0.2 : (c.duracion ?? 1.8) * 0.85;
372
+ const vuelo = c.acercar ? easeIO(clamp((t - tPin - 0.6) / (c.duracionVuelo ?? 1.2))) : 0;
373
+ const zoom = 1 + ((c.acercar ?? 1) - 1) * vuelo;
370
374
  const color = c.color ?? '#E5484D';
371
375
  return (
372
- <div style={{position: 'relative', width: tam, height: tam}}>
376
+ <div style={{position: 'relative', width: tam, height: tam, transform: `scale(${zoom})`, transformOrigin: `${X}px ${Y}px`}}>
373
377
  <svg width={tam} height={tam} style={{overflow: 'visible'}}>
374
378
  <defs>
375
379
  <radialGradient id="tierra-mar" cx="38%" cy="32%" r="75%">
@@ -390,7 +394,7 @@ export const Tierra: React.FC<{c: C; ctx: CtxEscena}> = ({c, ctx}) => {
390
394
  <circle cx={cx} cy={cx} r={R} fill="url(#tierra-mar)" />
391
395
  <g clipPath="url(#tierra-disco)">
392
396
  {caminos.map((d, i) => (
393
- <path key={i} d={d} fill="#6FBF73" stroke="#4E9A55" strokeWidth={1.2} strokeLinejoin="round" />
397
+ <path key={i} d={d} fill="#6FBF73" stroke="#4E9A55" strokeWidth={1.2} strokeLinejoin="round" vectorEffect="non-scaling-stroke" />
394
398
  ))}
395
399
  </g>
396
400
  <circle cx={cx} cy={cx} r={R} fill="url(#tierra-luz)" />
@@ -411,9 +415,48 @@ export const Tierra: React.FC<{c: C; ctx: CtxEscena}> = ({c, ctx}) => {
411
415
  );
412
416
  };
413
417
 
414
- export const HISTORIAS = ['tierra', 'bloqueo', 'llamada', 'recuento', 'capitulo', 'vigilancia', 'cintillo', 'fecha', 'calendario', 'dividida'];
418
+ /**
419
+ * Línea de tiempo vertical: la línea se dibuja hacia abajo y cada hito entra en su palabra (`en`), con el año en
420
+ * grande y su texto. `hitos: [{anio: 1963, texto: 'Nace en Taiwán', en: 'nació'}]`, `color`, `ancho`. El último que
421
+ * ya entró queda resaltado.
422
+ */
423
+ export const Linea: React.FC<{c: C; ctx: CtxEscena}> = ({c, ctx}) => {
424
+ const hitos: C[] = c.hitos ?? [];
425
+ const color = c.color ?? ctx.tema.colores.acento;
426
+ const paso = c.paso ?? 190;
427
+ const tiempos = hitos.map((h, i) => cuando(h.en, ctx, 0.3 + i * 0.8));
428
+ const visibles = tiempos.filter((t0) => ctx.t >= t0).length;
429
+ const ultimo = visibles - 1;
430
+ // la línea llega hasta el último hito que ya entró (y avanza mientras entra)
431
+ const kUlt = ultimo >= 0 ? easeOut(clamp((ctx.t - tiempos[ultimo]) / 0.4)) : 0;
432
+ const largo = ultimo >= 0 ? (ultimo - 1 + kUlt) * paso + paso * 0.15 : 0;
433
+ return (
434
+ <div style={{position: 'relative', width: c.ancho ?? 820, height: hitos.length * paso, fontFamily: 'Montserrat, sans-serif'}}>
435
+ <div style={{position: 'absolute', left: 38, top: 30, width: 8, height: (hitos.length - 1) * paso, borderRadius: 4, background: 'rgba(255,255,255,0.18)'}} />
436
+ <div style={{position: 'absolute', left: 38, top: 30, width: 8, height: Math.max(0, largo + paso * 0.85), borderRadius: 4, background: color}} />
437
+ {hitos.map((h, i) => {
438
+ const k = easeOut(clamp((ctx.t - tiempos[i]) / 0.4));
439
+ if (k <= 0) return null;
440
+ const actual = i === ultimo;
441
+ return (
442
+ <div key={i} style={{position: 'absolute', left: 0, top: i * paso, display: 'flex', alignItems: 'flex-start', gap: 34, opacity: k, transform: `translateX(${(1 - k) * 40}px)`}}>
443
+ <div style={{width: 84, height: 84, borderRadius: '50%', background: actual ? color : '#1B1B1F', border: `6px solid ${color}`, boxShadow: actual ? `0 0 24px ${color}` : undefined, transform: `scale(${resorte(k, 0.3)})`, flex: 'none'}} />
444
+ <div style={{paddingTop: 2}}>
445
+ <div style={{color: actual ? color : '#FFFFFF', fontWeight: 900, fontSize: 64, lineHeight: 1}}>{h.anio ?? h.titulo}</div>
446
+ {h.texto ? <div style={{color: '#FFFFFF', fontWeight: 700, fontSize: 36, marginTop: 6, maxWidth: (c.ancho ?? 820) - 140, textShadow: '0 2px 10px rgba(0,0,0,0.5)'}}>{h.texto}</div> : null}
447
+ </div>
448
+ </div>
449
+ );
450
+ })}
451
+ </div>
452
+ );
453
+ };
454
+
455
+ export const HISTORIAS = ['linea', 'tierra', 'bloqueo', 'llamada', 'recuento', 'capitulo', 'vigilancia', 'cintillo', 'fecha', 'calendario', 'dividida'];
415
456
  export const Historias: React.FC<{c: C; ctx: CtxEscena}> = ({c, ctx}) => {
416
457
  switch (c.tipo) {
458
+ case 'linea':
459
+ return <Linea c={c} ctx={ctx} />;
417
460
  case 'tierra':
418
461
  return <Tierra c={c} ctx={ctx} />;
419
462
  case 'bloqueo':
@@ -0,0 +1,44 @@
1
+ // Portada del video (salida/<título> (portada).jpg): el cuadro de la miniatura con el título grande abajo, la palabra
2
+ // clave en color y la marca arriba. Distinta de la miniatura (que es el cuadro limpio): la portada es la que se ve en
3
+ // el perfil y en la cuadrícula del canal, así que el título tiene que leerse en chiquito.
4
+ import React from 'react';
5
+ import {AbsoluteFill} from 'remotion';
6
+ import {ANTON, ancho} from '../estilo';
7
+ import {Avatar, MarcaContexto} from '../piezas';
8
+ import type {Marca} from '../tipos';
9
+ import {partesResaltadas} from './util';
10
+
11
+ export const Portada: React.FC<{texto: string; marca: Marca; acento?: string}> = ({texto, marca, acento = marca.color ?? '#FFD400'}) => {
12
+ const letra = marca.letra ?? ANTON;
13
+ const limpio = texto.replace(/\*/g, '').toUpperCase();
14
+ // sin *palabra*, se resalta la última palabra larga
15
+ const marcado = texto.includes('*') ? texto : texto.replace(/([^\s]{5,})(?![\s\S]*\s[^\s]{5,})/, '*$1*');
16
+ let tam = 150;
17
+ try {
18
+ // en 2–3 renglones de 940 px
19
+ const w = ancho(limpio, letra, tam, 400);
20
+ if (w > 940 * 2.6) tam = Math.max(84, Math.floor((tam * 940 * 2.6) / w));
21
+ } catch {}
22
+ return (
23
+ <MarcaContexto.Provider value={marca}>
24
+ <AbsoluteFill>
25
+ <AbsoluteFill style={{background: 'linear-gradient(180deg, rgba(0,0,0,0.35) 0%, rgba(0,0,0,0) 22%, rgba(0,0,0,0) 45%, rgba(0,0,0,0.82) 78%, rgba(0,0,0,0.92) 100%)'}} />
26
+ {marca.nombre || marca.logo ? (
27
+ <div style={{position: 'absolute', left: 56, top: 110, display: 'flex', alignItems: 'center', gap: 18}}>
28
+ <div style={{position: 'relative', width: 96, height: 96}}>
29
+ <Avatar x={0} y={0} size={88} aro={5} />
30
+ </div>
31
+ {marca.nombre ? <div style={{fontFamily: 'Montserrat, sans-serif', fontWeight: 900, fontSize: 44, color: '#FFFFFF', textShadow: '0 3px 12px rgba(0,0,0,0.6)'}}>{marca.nombre}</div> : null}
32
+ </div>
33
+ ) : null}
34
+ <div style={{position: 'absolute', left: 60, right: 60, bottom: 230, fontFamily: letra, fontSize: tam, lineHeight: 1.02, color: '#FFFFFF', textTransform: 'uppercase', textShadow: '0 8px 30px rgba(0,0,0,0.6)', WebkitTextStroke: `${Math.round(tam * 0.04)}px #000`, paintOrder: 'stroke fill'}}>
35
+ {partesResaltadas(marcado).map(([p, r], i) => (
36
+ <span key={i} style={r ? {color: acento} : undefined}>
37
+ {p}
38
+ </span>
39
+ ))}
40
+ </div>
41
+ </AbsoluteFill>
42
+ </MarcaContexto.Provider>
43
+ );
44
+ };