primocode 9.8.0-beta.2 → 9.8.0-beta.21

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.
Files changed (74) hide show
  1. package/README.md +33 -6
  2. package/app/ansi.js +55 -0
  3. package/app/app.css +482 -281
  4. package/app/app.js +888 -315
  5. package/app/index.html +176 -101
  6. package/app/primo-marca.js +58 -1
  7. package/bin/primocode.js +697 -118
  8. package/comandos/explicar.md +14 -0
  9. package/comandos/publicar.md +10 -0
  10. package/comandos/revisar.md +13 -0
  11. package/lib/act.js +130 -26
  12. package/lib/api.js +8 -1
  13. package/lib/app.js +317 -74
  14. package/lib/browser.js +79 -3
  15. package/lib/catalogo.js +11 -3
  16. package/lib/checkpoint-git.js +218 -0
  17. package/lib/checkpoint.js +249 -0
  18. package/lib/claude-engine.js +25 -3
  19. package/lib/comandos-extra.js +481 -0
  20. package/lib/comandos.js +104 -0
  21. package/lib/config.js +21 -4
  22. package/lib/estudio/acervo-site.js +157 -0
  23. package/lib/estudio/assets.js +77 -3
  24. package/lib/estudio/cinema.js +354 -0
  25. package/lib/estudio/composicao.js +470 -0
  26. package/lib/estudio/edicao.js +450 -0
  27. package/lib/estudio/impacto.js +263 -0
  28. package/lib/estudio/index.js +769 -5
  29. package/lib/estudio/plano.js +5 -2
  30. package/lib/estudio/remotion.js +228 -23
  31. package/lib/estudio/slides.js +34 -30
  32. package/lib/fala.js +95 -1
  33. package/lib/local/cerebro.json +361 -180
  34. package/lib/local/entrada.js +18 -8
  35. package/lib/local/motor-cli.js +1 -1
  36. package/lib/mencoes.js +98 -0
  37. package/lib/offline.js +168 -0
  38. package/lib/painel.js +291 -0
  39. package/lib/perigo.js +256 -0
  40. package/lib/pre-leitura.js +118 -0
  41. package/lib/repeticao.js +4 -2
  42. package/lib/skills.js +6 -1
  43. package/lib/tempos.js +62 -0
  44. package/lib/ui.js +171 -17
  45. package/package.json +3 -2
  46. package/skills/apresentacao-pptx/SKILL.md +52 -0
  47. package/skills/cartaz-de-arte/LICENSE.txt +202 -0
  48. package/skills/cartaz-de-arte/SKILL.md +47 -0
  49. package/skills/criar-skill/LICENSE.txt +202 -0
  50. package/skills/criar-skill/SKILL.md +49 -0
  51. package/skills/depurar-de-verdade/LICENSE.txt +21 -0
  52. package/skills/depurar-de-verdade/SKILL.md +14 -5
  53. package/skills/design-de-verdade/LICENSE.txt +177 -0
  54. package/skills/design-de-verdade/SKILL.md +42 -0
  55. package/skills/documento-word/SKILL.md +57 -0
  56. package/skills/interface-revisada/SKILL.md +47 -0
  57. package/skills/me-ensina/LICENSE.txt +21 -0
  58. package/skills/me-ensina/SKILL.md +27 -0
  59. package/skills/me-entrevista/LICENSE.txt +21 -0
  60. package/skills/me-entrevista/SKILL.md +27 -0
  61. package/skills/motion-de-primeira/SKILL.md +1 -1
  62. package/skills/pdf-bonito/SKILL.md +48 -0
  63. package/skills/planilha-excel/SKILL.md +47 -0
  64. package/skills/react-sem-dor/SKILL.md +48 -0
  65. package/skills/revisao-antes-de-entregar/LICENSE.txt +21 -0
  66. package/skills/revisao-antes-de-entregar/SKILL.md +6 -1
  67. package/skills/site-com-motion/SKILL.md +39 -0
  68. package/skills/temas-prontos/LICENSE.txt +202 -0
  69. package/skills/temas-prontos/SKILL.md +50 -0
  70. package/skills/testar-o-site/LICENSE.txt +202 -0
  71. package/skills/testar-o-site/SKILL.md +46 -0
  72. package/skills/video-no-remotion/SKILL.md +48 -0
  73. package/studio/narrar.py +111 -10
  74. package/studio/rostos.py +52 -0
@@ -51,7 +51,7 @@ const path = require('path');
51
51
  const TIPOS = new Set(['texto', 'forma', 'imagem', 'video', 'icone', 'legenda', 'componente']);
52
52
  const FORMAS = new Set(['retangulo', 'circulo', 'linha', 'poligono', 'traco']);
53
53
  const AJUSTES = new Set(['cover', 'contain', 'preencher']);
54
- const TRANSICOES = new Set(['corte', 'esmaecer', 'deslizar', 'zoom', 'giro', 'mascara', 'morph']);
54
+ const TRANSICOES = new Set(['corte', 'esmaecer', 'deslizar', 'zoom', 'giro', 'mascara', 'morph', 'chicote', 'soco', 'flash']);
55
55
 
56
56
  /* As propriedades que a animação por quadro-chave alcança. Cada uma é um
57
57
  número no tempo; a curva entre dois quadros é a suavização declarada.
@@ -165,7 +165,10 @@ function conferirElemento(caminho, el, pasta, problemas, duracao) {
165
165
  }
166
166
  }
167
167
  conferirCor(`${caminho}.cor`, el.cor, problemas);
168
- conferirCor(`${caminho}.fundo`, el.fundo, problemas);
168
+ // O fundo do elemento é cor, ou o mesmo objeto do fundo da cena (um
169
+ // gradiente sobre a foto, por exemplo).
170
+ if (el.fundo && typeof el.fundo === 'object') conferirFundo(`${caminho}.fundo`, el.fundo, pasta, problemas);
171
+ else conferirCor(`${caminho}.fundo`, el.fundo, problemas);
169
172
  conferirAnima(`${caminho}.anima`, el.anima, problemas);
170
173
 
171
174
  // `de` e `ate` recortam o elemento no tempo DA CENA. Um elemento que
@@ -212,6 +212,7 @@ function Elemento({ el, t, dados }) {
212
212
  transform: [base.transform, move].filter(Boolean).join(' '),
213
213
  filter: filtroDe(a) || undefined,
214
214
  mixBlendMode: el.mistura || undefined,
215
+ transformOrigin: el.origem || undefined,
215
216
  };
216
217
 
217
218
  if (el.vidro) {
@@ -233,7 +234,39 @@ function Elemento({ el, t, dados }) {
233
234
  }
234
235
 
235
236
  switch (el.tipo) {
236
- case 'texto':
237
+ case 'texto': {
238
+ /* O NÚMERO QUE CONTA: "contar": {ate: 9, dur: 1.2} faz o texto subir
239
+ de 0 até o valor, com a curva de saída. Casas decimais e o que vem
240
+ antes/depois do número (R$, %, mil) ficam como estão. */
241
+ let conteudo = el.texto;
242
+ if (el.contar) {
243
+ const alvo = Number(el.contar.ate) || 0;
244
+ const dur = el.contar.dur || 1.2;
245
+ const k = CURVAS.saida(Math.min(1, Math.max(0, tLocal / dur)));
246
+ const casas = el.contar.casas || 0;
247
+ const v = (alvo * k).toLocaleString('pt-BR', { minimumFractionDigits: casas, maximumFractionDigits: casas });
248
+ conteudo = (el.contar.antes || '') + v + (el.contar.depois || '');
249
+ }
250
+ /* O TEXTO QUE SE REVELA: "revelar": [t0, t1, …] é o instante (no tempo
251
+ do elemento) em que cada palavra entra — o da narração. A palavra
252
+ sobe e aparece; as que ainda não foram ditas esperam invisíveis, e o
253
+ bloco não muda de tamanho quando elas entram. */
254
+ if (Array.isArray(el.revelar) && typeof el.texto === 'string') {
255
+ const palavras = el.texto.split(' ');
256
+ conteudo = palavras.map((w, i) => {
257
+ const t0 = el.revelar[Math.min(i, el.revelar.length - 1)] ?? 0;
258
+ const k = Math.min(1, Math.max(0, (tLocal - t0) / 0.28));
259
+ const e = CURVAS.saida(k);
260
+ // O espaço fica FORA do span: é ali que a linha pode quebrar.
261
+ return (
262
+ <React.Fragment key={i}>
263
+ <span style={{ display: 'inline-block', opacity: e, transform: 'translateY(' + (1 - e) * 0.35 + 'em)',
264
+ color: el.destaques && el.destaques.includes(i) ? (el.corDestaque || el.cor) : undefined }}>{w}</span>
265
+ {i < palavras.length - 1 ? ' ' : null}
266
+ </React.Fragment>
267
+ );
268
+ });
269
+ }
237
270
  return (
238
271
  <div style={{
239
272
  ...estilo,
@@ -249,8 +282,19 @@ function Elemento({ el, t, dados }) {
249
282
  background: el.gradienteTexto || undefined,
250
283
  WebkitBackgroundClip: el.gradienteTexto ? 'text' : undefined,
251
284
  WebkitTextFillColor: el.gradienteTexto ? 'transparent' : undefined,
252
- }}>{el.texto}</div>
285
+ fontStyle: el.italico ? 'italic' : undefined,
286
+ WebkitTextStroke: el.contorno || undefined,
287
+ // O GLITCH: o texto se parte em vermelho e ciano por um instante.
288
+ textShadow: el.glitch && tLocal < (el.glitch.dur || 0.35)
289
+ ? (() => { const f = Math.round(t * 30); const d = (Math.sin(f * 7.1) * 0.5 + 0.5) * (el.glitch.forca || 10) * (1 - tLocal / (el.glitch.dur || 0.35));
290
+ return d.toFixed(1) + 'px 0 #ff2d55, ' + (-d).toFixed(1) + 'px 0 #00e5ff'; })()
291
+ : (el.sombraTexto || undefined),
292
+ textDecoration: el.riscado ? 'line-through' : undefined,
293
+ textDecorationThickness: el.riscado ? '0.08em' : undefined,
294
+ wordBreak: 'normal', overflowWrap: 'break-word',
295
+ }}>{conteudo}</div>
253
296
  );
297
+ }
254
298
 
255
299
  case 'forma': {
256
300
  const forma = el.forma || 'retangulo';
@@ -259,7 +303,7 @@ function Elemento({ el, t, dados }) {
259
303
  ...estilo,
260
304
  width: medida(el.largura, '200px'),
261
305
  height: forma === 'linha' ? medida(el.espessura, '4px') : medida(el.altura, '200px'),
262
- background: el.fundo || el.cor || '#fff',
306
+ ...(el.fundo && typeof el.fundo === 'object' ? fundoCSS(el.fundo) : { background: el.fundo || el.cor || '#fff' }),
263
307
  borderRadius: forma === 'circulo' ? '50%' : medida(a.raio ?? el.raio, '0px'),
264
308
  border: el.contorno || undefined,
265
309
  }} />
@@ -285,7 +329,7 @@ function Elemento({ el, t, dados }) {
285
329
  startFrom={el.deOrigem ? Math.round(el.deOrigem * dados.fps) : undefined}
286
330
  endAt={el.ateOrigem ? Math.round(el.ateOrigem * dados.fps) : undefined}
287
331
  volume={el.volume ?? 0}
288
- style={{ width: '100%', height: '100%', objectFit: el.ajuste || 'cover' }}
332
+ style={{ width: '100%', height: '100%', objectFit: el.ajuste || 'cover', objectPosition: el.foco || 'center' }}
289
333
  />
290
334
  </div>
291
335
  );
@@ -294,7 +338,7 @@ function Elemento({ el, t, dados }) {
294
338
  // O SVG foi baixado para a pasta do projeto (estudio/assets.js): render
295
339
  // que busca ícone na internet falha no meio, depois de tudo pronto.
296
340
  return (
297
- <div style={{ ...estilo, width: medida(el.tamanho, '96px'), height: medida(el.tamanho, '96px'), color: el.cor || '#fff' }}
341
+ <div style={{ ...estilo, width: medida(el.tamanho, '96px'), height: medida(el.tamanho, '96px'), fontSize: medida(el.tamanho, '96px'), lineHeight: 0, color: el.cor || '#fff' }}
298
342
  dangerouslySetInnerHTML={{ __html: dados.icones[el.nome] || '' }} />
299
343
  );
300
344
 
@@ -302,6 +346,55 @@ function Elemento({ el, t, dados }) {
302
346
  const fala = (el.fala || []).find((f) => t >= f.de && t <= f.ate);
303
347
  if (!fala) return null;
304
348
  const e = el.estilo || {};
349
+ /* LEGENDA DE VIDEOMAKER: com o tempo de cada palavra, elas entram uma a
350
+ uma, no instante em que são ditas, com um salto curto. A que está
351
+ sendo dita ganha a cor de destaque; a palavra forte (a ideia da
352
+ frase) ganha a cor dela e fica maior. Sem "palavras", a frase entra
353
+ inteira, como antes. */
354
+ if (Array.isArray(fala.palavras) && fala.palavras.length) {
355
+ const destaque = e.destaque || '#FFD400';
356
+ const forte = e.corForte || '#00E0A4';
357
+ const soUma = e.modo === 'palavra';
358
+ const visiveis = soUma
359
+ ? fala.palavras.filter((p, i, l) => t >= p.de && (i === l.length - 1 || t < l[i + 1].de)).slice(-1)
360
+ : fala.palavras.filter((p) => t >= p.de - 0.02);
361
+ return (
362
+ <div style={{
363
+ ...estilo,
364
+ display: 'flex', flexWrap: 'wrap', justifyContent: 'center', alignItems: 'baseline',
365
+ gap: '0 0.34em', maxWidth: medida(e.largura, '86%'),
366
+ fontFamily: e.fonte ? '"' + e.fonte + '", system-ui, sans-serif' : 'system-ui, sans-serif',
367
+ fontSize: medida(e.tamanho, '64px'),
368
+ fontWeight: e.peso || 900,
369
+ textTransform: e.caixa || undefined,
370
+ lineHeight: 1.1,
371
+ }}>
372
+ {visiveis.map((p, i) => {
373
+ const idade = t - p.de;
374
+ const entra = Math.min(1, Math.max(0, idade / 0.14));
375
+ const salto = entra < 1 ? 0.7 + 0.42 * CURVAS.saida(entra) : 1.12 - 0.12 * Math.min(1, (idade - 0.14) / 0.12);
376
+ const falando = t >= p.de && t <= p.ate + 0.05;
377
+ const cor = p.forte ? forte : (falando ? destaque : (e.cor || '#fff'));
378
+ return (
379
+ <span key={i} style={{
380
+ display: 'inline-block',
381
+ // A palavra forte é MAIOR na fonte, não na escala: escala não
382
+ // abre espaço, e ela cresceria por cima da vizinha.
383
+ fontSize: p.forte ? '1.22em' : undefined,
384
+ transform: 'scale(' + salto + ') translateY(' + (1 - entra) * 18 + 'px)',
385
+ opacity: entra,
386
+ color: cor,
387
+ textShadow: e.sombra || '0 6px 22px rgba(0,0,0,.6), 0 2px 0 rgba(0,0,0,.35)',
388
+ WebkitTextStroke: e.contorno || '2px rgba(0,0,0,.55)',
389
+ paintOrder: 'stroke fill',
390
+ background: falando && e.fundoAtivo ? e.fundoAtivo : undefined,
391
+ borderRadius: '0.18em', padding: falando && e.fundoAtivo ? '0 0.12em' : undefined,
392
+ }}>{p.texto}{p.emoji ? ' ' + p.emoji : ''}</span>
393
+ );
394
+ })}
395
+ </div>
396
+ );
397
+ }
305
398
  return (
306
399
  <div style={{
307
400
  ...estilo,
@@ -325,16 +418,33 @@ function Elemento({ el, t, dados }) {
325
418
  }
326
419
  }
327
420
 
328
- export function Cena({ cena, dados }) {
421
+ export function Cena({ cena, dados, tFixo }) {
329
422
  const frame = useCurrentFrame();
330
423
  const { fps } = useVideoConfig();
331
- const t = frame / fps;
424
+ // A página parada é a cena num instante só: tFixo manda no tempo.
425
+ const t = tFixo != null ? tFixo : frame / fps;
332
426
 
333
427
  const cam = animado(cena.camera, t);
428
+ /* O IMPACTO: tremor (a câmera treme e assenta), soco (o zoom que bate e
429
+ volta) e flash (o branco que estoura e some). O tremor é pseudoaleatório
430
+ pelo QUADRO, então o mesmo render sai sempre igual. */
431
+ const acaso = (n) => { const x = Math.sin(n * 12.9898 + 78.233) * 43758.5453; return x - Math.floor(x); };
432
+ let tx = 0, ty = 0, tr = 0, soco = 1;
433
+ for (const w of cena.tremor || []) {
434
+ if (t < w.de || t > w.ate) continue;
435
+ const k = (w.forca || 14) * (1 - (t - w.de) / Math.max(0.01, w.ate - w.de));
436
+ tx += (acaso(frame) - 0.5) * 2 * k; ty += (acaso(frame + 91) - 0.5) * 2 * k; tr += (acaso(frame + 37) - 0.5) * k * 0.08;
437
+ }
438
+ for (const s0 of cena.socos || []) {
439
+ if (t >= s0.t) soco *= 1 + (s0.forca || 0.18) * Math.exp(-14 * (t - s0.t));
440
+ }
441
+ let flash = 0;
442
+ for (const f of cena.flashes || []) if (t >= f && t < f + 0.25) flash = Math.max(flash, Math.exp(-18 * (t - f)));
334
443
  const camTransform = [
335
444
  cam.x != null || cam.y != null ? 'translate(' + (-(cam.x || 0)) + 'px,' + (-(cam.y || 0)) + 'px)' : '',
336
- cam.zoom != null ? 'scale(' + cam.zoom + ')' : '',
337
- cam.rotacao != null ? 'rotate(' + cam.rotacao + 'deg)' : '',
445
+ tx || ty ? 'translate(' + tx + 'px,' + ty + 'px)' : '',
446
+ cam.zoom != null || soco !== 1 ? 'scale(' + ((cam.zoom ?? 1) * soco) + ')' : '',
447
+ cam.rotacao != null || tr ? 'rotate(' + ((cam.rotacao || 0) + tr) + 'deg)' : '',
338
448
  ].filter(Boolean).join(' ');
339
449
 
340
450
  return (
@@ -363,10 +473,11 @@ export function Cena({ cena, dados }) {
363
473
  <Elemento key={i} el={el} t={t} dados={dados} />
364
474
  ))}
365
475
  </AbsoluteFill>
476
+ {flash > 0.01 && <AbsoluteFill style={{ backgroundColor: cena.corFlash || '#fff', opacity: flash * 0.85 }} />}
366
477
  </AbsoluteFill>
367
478
  );
368
479
  }
369
- ";`;
480
+ `;
370
481
 
371
482
  const RAIZ_JSX = String.raw`import React from 'react';
372
483
  import { AbsoluteFill, Audio, Sequence, staticFile, useCurrentFrame, useVideoConfig, interpolate } from 'remotion';
@@ -386,22 +497,52 @@ function Transicao({ tipo, progresso, children }) {
386
497
  else if (tipo === 'giro') estilo = { transform: 'rotateY(' + (1 - p) * 35 + 'deg) scale(' + (0.9 + 0.1 * p) + ')', opacity: p };
387
498
  else if (tipo === 'mascara') estilo = { clipPath: 'circle(' + (p * 75) + '% at 50% 50%)' };
388
499
  else if (tipo === 'morph') estilo = { opacity: p, filter: 'blur(' + (1 - p) * 14 + 'px)', transform: 'scale(' + (1.06 - 0.06 * p) + ')' };
500
+ // O CHICOTE: a cena chega rasgando de lado, borrada de velocidade.
501
+ else if (tipo === 'chicote') estilo = { transform: 'translateX(' + (1 - p) * 70 + '%) skewX(' + (1 - p) * -12 + 'deg)', filter: 'blur(' + (1 - p) * 40 + 'px)' };
502
+ // O SOCO: a cena entra grande e bate no lugar.
503
+ else if (tipo === 'soco') estilo = { transform: 'scale(' + (1.35 - 0.35 * p) + ')', opacity: Math.min(1, p * 3) };
504
+ else if (tipo === 'flash') estilo = { opacity: Math.min(1, p * 2), filter: 'brightness(' + (1 + (1 - p) * 3) + ')' };
389
505
  return <AbsoluteFill style={estilo}>{children}</AbsoluteFill>;
390
506
  }
391
507
 
508
+ /* AS PÁGINAS: um quadro por cena, cada uma congelada no meio dela. Os
509
+ * slides saem num render só — antes era um "remotion still" por página, e
510
+ * cada um empacotava o projeto inteiro de novo (onze páginas, onze
511
+ * empacotamentos: é a demora de 25 minutos medida em 25/09). */
512
+ export const Paginas = () => {
513
+ const { fps } = useVideoConfig();
514
+ const frame = useCurrentFrame();
515
+ const cena = plano.cenas[Math.min(frame, plano.cenas.length - 1)];
516
+ return (
517
+ <AbsoluteFill style={{ backgroundColor: plano.fundoGeral || '#000' }}>
518
+ <Cena cena={cena} dados={{ fps, icones }} tFixo={cena.duracao / 2} />
519
+ </AbsoluteFill>
520
+ );
521
+ };
522
+
392
523
  export const Video = () => {
393
524
  const { fps } = useVideoConfig();
394
525
  const frame = useCurrentFrame();
395
526
  const dados = { fps, icones };
396
527
 
528
+ /* O cruzamento é da cena que ENTRA: ela começa "cruz" quadros antes de a
529
+ anterior acabar, e as duas ficam no ar juntas. Contar pela que sai deixava
530
+ a que entra começando sobre o preto (medido em 25/09: o "soco" abria com
531
+ um quadro preto). */
532
+ const cruzDe = (cena, i) => {
533
+ if (!cena || i === 0) return 0;
534
+ const t = cena.transicao || {};
535
+ const tipo = t.tipo || t || 'corte';
536
+ return tipo === 'corte' ? 0 : Math.round((t.duracao ?? 0.4) * fps);
537
+ };
397
538
  let inicio = 0;
398
539
  const pedacos = plano.cenas.map((cena, i) => {
399
540
  const dur = Math.max(1, Math.round(cena.duracao * fps));
400
541
  const t = cena.transicao || {};
401
542
  const tipo = t.tipo || t || 'corte';
402
- const cruz = tipo === 'corte' ? 0 : Math.round((t.duracao ?? 0.4) * fps);
543
+ const cruz = cruzDe(cena, i);
403
544
  const de = inicio;
404
- inicio += dur - cruz;
545
+ inicio += dur - cruzDe(plano.cenas[i + 1], i + 1);
405
546
  return { cena, de, dur, tipo, cruz, i };
406
547
  });
407
548
 
@@ -421,15 +562,56 @@ export const Video = () => {
421
562
  );
422
563
  })}
423
564
 
565
+ {/* O ACABAMENTO: granulação de filme, vinheta e a luz da marca passando.
566
+ É o que tira a cara de apresentação e dá a cara de peça de 2026. */}
567
+ {plano.acabamento && plano.acabamento.luz && (
568
+ <AbsoluteFill style={{ pointerEvents: 'none', mixBlendMode: 'screen', opacity: plano.acabamento.luzForca ?? 0.22,
569
+ background: 'radial-gradient(circle at ' + (50 + 40 * Math.sin(frame / 38)) + '% ' + (30 + 25 * Math.cos(frame / 51)) + '%, '
570
+ + plano.acabamento.luz[0] + ' 0%, transparent 45%), radial-gradient(circle at ' + (50 - 40 * Math.sin(frame / 44)) + '% '
571
+ + (75 - 20 * Math.sin(frame / 33)) + '%, ' + (plano.acabamento.luz[1] || plano.acabamento.luz[0]) + ' 0%, transparent 40%)' }} />
572
+ )}
573
+ {plano.acabamento && plano.acabamento.grade && (
574
+ <AbsoluteFill style={{ pointerEvents: 'none', opacity: plano.acabamento.grade,
575
+ backgroundImage: 'linear-gradient(rgba(255,255,255,0.5) 1px, transparent 1px), linear-gradient(90deg, rgba(255,255,255,0.5) 1px, transparent 1px)',
576
+ backgroundSize: (plano.acabamento.passoGrade || 120) + 'px ' + (plano.acabamento.passoGrade || 120) + 'px',
577
+ backgroundPosition: (frame * 0.2) + 'px ' + (frame * 0.1) + 'px' }} />
578
+ )}
579
+ {plano.acabamento && plano.acabamento.vinheta && (
580
+ <AbsoluteFill style={{ pointerEvents: 'none', background: 'radial-gradient(ellipse at center, transparent 55%, rgba(0,0,0,' + plano.acabamento.vinheta + ') 100%)' }} />
581
+ )}
582
+ {plano.acabamento && plano.acabamento.grao && (
583
+ <AbsoluteFill style={{ pointerEvents: 'none', opacity: plano.acabamento.grao, mixBlendMode: 'overlay' }}>
584
+ <svg width="100%" height="100%">
585
+ <filter id="grao"><feTurbulence type="fractalNoise" baseFrequency="0.85" numOctaves="2" seed={frame % 60} /></filter>
586
+ <rect width="100%" height="100%" filter="url(#grao)" />
587
+ </svg>
588
+ </AbsoluteFill>
589
+ )}
590
+
424
591
  {plano.audio && plano.audio.narracao && (
425
592
  <Audio src={staticFile(plano.audio.narracao)} volume={plano.audio.volumeNarracao ?? 1} />
426
593
  )}
427
594
  {plano.audio && plano.audio.trilha && (
428
595
  <Audio
429
596
  src={staticFile(plano.audio.trilha.src || plano.audio.trilha)}
430
- volume={plano.audio.trilha.volume ?? 0.18}
597
+ /* O volume da música pode mudar no tempo ("volumes": [{t, v}]): começa
598
+ mínima, cresce, abaixa no depoimento e some antes do clímax. */
599
+ volume={Array.isArray(plano.audio.trilha.volumes)
600
+ ? (f) => { const tt = f / fps, vs = plano.audio.trilha.volumes;
601
+ if (tt <= vs[0].t) return vs[0].v;
602
+ for (let k = 0; k < vs.length - 1; k++) if (tt >= vs[k].t && tt <= vs[k + 1].t) return vs[k].v + (vs[k + 1].v - vs[k].v) * ((tt - vs[k].t) / Math.max(0.001, vs[k + 1].t - vs[k].t));
603
+ return vs[vs.length - 1].v; }
604
+ : (plano.audio.trilha.volume ?? 0.18)}
605
+ loop={plano.audio.trilha.loop !== false}
431
606
  />
432
607
  )}
608
+ {/* Os TRECHOS: pedaços de áudio no tempo certo — a narração partida entre
609
+ os depoimentos, o som de ambiente de uma cena. */}
610
+ {(plano.audio && plano.audio.trechos ? plano.audio.trechos : []).map((a, k) => (
611
+ <Sequence key={'tr' + k} from={Math.round((a.t || 0) * fps)} durationInFrames={Math.max(1, Math.round(((a.ate ?? 999) - (a.de || 0)) * fps))}>
612
+ <Audio src={staticFile(a.src)} startFrom={Math.round((a.de || 0) * fps)} volume={a.volume ?? 1} />
613
+ </Sequence>
614
+ ))}
433
615
  {(plano.audio && plano.audio.efeitos ? plano.audio.efeitos : []).map((e, i) => (
434
616
  <Sequence key={'sfx' + i} from={Math.round((e.t || 0) * fps)}>
435
617
  <Audio src={staticFile(e.src)} volume={e.volume ?? 0.7} />
@@ -438,13 +620,13 @@ export const Video = () => {
438
620
  </AbsoluteFill>
439
621
  );
440
622
  };
441
- ";`;
623
+ `;
442
624
 
443
625
  function rootJsx(plano) {
444
626
  const f = plano.formato || {};
445
627
  return `import React from 'react';
446
628
  import { Composition } from 'remotion';
447
- import { Video } from './Video';
629
+ import { Video, Paginas } from './Video';
448
630
  import plano from './plano.json';
449
631
  import './fontes.css';
450
632
 
@@ -455,14 +637,24 @@ const quadros = Math.max(1, Math.round(
455
637
  ));
456
638
 
457
639
  export const RemotionRoot = () => (
458
- <Composition
459
- id="Video"
460
- component={Video}
461
- durationInFrames={quadros}
462
- fps={fps}
463
- width={${Number(f.largura) || 1920}}
464
- height={${Number(f.altura) || 1080}}
465
- />
640
+ <>
641
+ <Composition
642
+ id="Video"
643
+ component={Video}
644
+ durationInFrames={quadros}
645
+ fps={fps}
646
+ width={${Number(f.largura) || 1920}}
647
+ height={${Number(f.altura) || 1080}}
648
+ />
649
+ <Composition
650
+ id="Paginas"
651
+ component={Paginas}
652
+ durationInFrames={plano.cenas.length}
653
+ fps={fps}
654
+ width={${Number(f.largura) || 1920}}
655
+ height={${Number(f.altura) || 1080}}
656
+ />
657
+ </>
466
658
  );
467
659
  `;
468
660
  }
@@ -513,12 +705,25 @@ para este projeto, não para o PrimoCode.
513
705
  * @param {object} op.plano
514
706
  * @param {object} op.icones { "lucide:zap": "<svg…>" } já baixados
515
707
  */
708
+ const TSCONFIG = JSON.stringify({
709
+ compilerOptions: {
710
+ target: 'ES2018', module: 'commonjs', jsx: 'react-jsx', strict: false, noEmit: true,
711
+ lib: ['es2015', 'dom'], esModuleInterop: true, skipLibCheck: true, forceConsistentCasingInFileNames: true,
712
+ },
713
+ exclude: ['remotion.config.ts'],
714
+ }, null, 2) + '\n';
715
+
516
716
  function montar({ pasta, titulo, plano, icones = {} }) {
517
717
  const raiz = path.join(pasta, 'remotion');
518
718
  const src = path.join(raiz, 'src');
519
719
 
520
720
  escrever(path.join(raiz, 'package.json'), pkgJson(titulo || 'video'));
521
721
  escrever(path.join(raiz, 'remotion.config.ts'), REMOTION_CONFIG);
722
+ /* O remotion.config.ts é TypeScript, e o Remotion 4 recusa renderizar sem
723
+ um tsconfig.json na raiz ("Could not find a tsconfig.json"). Medido em
724
+ 25/09: o vídeo que saiu bom só saiu porque o modelo criou este arquivo à
725
+ mão depois do erro — um modelo grátis travaria ali. */
726
+ escrever(path.join(raiz, 'tsconfig.json'), TSCONFIG);
522
727
  escrever(path.join(raiz, 'README.md'), readme(titulo || 'Vídeo', plano));
523
728
  escrever(path.join(src, 'index.js'), INDEX_JS);
524
729
  escrever(path.join(src, 'Root.jsx'), rootJsx(plano));
@@ -48,41 +48,45 @@ const { spawn } = require('child_process');
48
48
  function renderizarPaginas({ raiz, destino, plano, aoFalar = () => {} }) {
49
49
  return new Promise((resolve) => {
50
50
  fs.mkdirSync(destino, { recursive: true });
51
- const fps = Number((plano.formato || {}).fps) || 30;
52
-
53
- // Onde começa cada cena, em quadros — a mesma conta do Video.jsx.
54
- const inicios = [];
55
- let cursor = 0;
56
- for (const cena of plano.cenas || []) {
57
- const dur = Math.max(1, Math.round(cena.duracao * fps));
58
- const t = cena.transicao || {};
59
- const cruz = (t.tipo || t) === 'corte' ? 0 : Math.round((t.duracao ?? 0.4) * fps);
60
- inicios.push({ de: cursor, dur });
61
- cursor += dur - cruz;
62
- }
51
+ for (const f of fs.readdirSync(destino)) if (/\.(jpe?g|png)$/i.test(f)) fs.rmSync(path.join(destino, f));
52
+ const n = (plano.cenas || []).length;
53
+ const shell = process.platform === 'win32';
54
+ const cita = (a) => (shell && /\s/.test(a) ? `"${a}"` : a);
55
+
56
+ /* Projeto novo não tem o Remotion instalado: o caminho de slides
57
+ rodava o npx direto e morria em "could not determine executable". */
58
+ const passos = fs.existsSync(path.join(raiz, 'node_modules')) ? [] : [['npm', ['install', '--no-audit', '--no-fund']]];
59
+ // Todas as páginas num render só: a composição "Paginas" tem um quadro por cena.
60
+ /* O Remotion recusa pasta de sequência com ponto no caminho ("cannot
61
+ have an extension") — e ~/.primocode tem. A sequência sai numa pasta
62
+ temporária sem ponto e as páginas são movidas depois. */
63
+ const tmp = fs.mkdtempSync(path.join(require('os').tmpdir(), 'primo-paginas-'));
64
+ passos.push(['npx', ['remotion', 'render', 'Paginas', tmp, '--sequence',
65
+ '--image-format=jpeg', '--jpeg-quality=95', '--log=warn']]);
63
66
 
64
- const paginas = [];
65
67
  const correr = (i) => {
66
- if (i >= inicios.length) return resolve({ ok: true, paginas });
67
- const { de, dur } = inicios[i];
68
- const quadro = de + Math.floor(dur / 2);
69
- const arquivo = path.join(destino, `pagina-${String(i + 1).padStart(3, '0')}.jpeg`);
70
- aoFalar(`página ${i + 1} de ${inicios.length}`);
71
-
72
- const p = spawn('npx', [
73
- 'remotion', 'still', 'Video', arquivo,
74
- '--frame=' + quadro, '--image-format=jpeg', '--quality=95', '--log=warn',
75
- ], { cwd: raiz, shell: process.platform === 'win32' });
76
-
68
+ if (i >= passos.length) {
69
+ // Pelo NÚMERO: em ordem alfabética, element-10 vem antes de element-2.
70
+ const num = (f) => Number((f.match(/(\d+)\.jpe?g$/i) || [])[1] || 0);
71
+ const saidas = fs.readdirSync(tmp).filter((f) => /\.jpe?g$/i.test(f)).sort((a, b) => num(a) - num(b));
72
+ const paginas = saidas.map((f, k) => {
73
+ const final = path.join(destino, `pagina-${String(k + 1).padStart(3, '0')}.jpeg`);
74
+ fs.copyFileSync(path.join(tmp, f), final);
75
+ return final;
76
+ });
77
+ try { fs.rmSync(tmp, { recursive: true, force: true }); } catch { /* fica no temporário */ }
78
+ if (paginas.length !== n) return resolve({ ok: false, error: `saíram ${paginas.length} páginas de ${n}.` });
79
+ return resolve({ ok: true, paginas });
80
+ }
81
+ const [cmd, args] = passos[i];
82
+ aoFalar(cmd === 'npm' ? 'instalando o Remotion nesta pasta (uma vez só)' : `desenhando ${n} páginas`);
83
+ const p = spawn(cmd, args.map(cita), { cwd: raiz, shell });
77
84
  let erro = '';
78
- p.stderr.on('data', (d) => { erro += d.toString().slice(0, 1000); });
79
- const relogio = setTimeout(() => { try { p.kill(); } catch {} }, 5 * 60000);
85
+ p.stderr.on('data', (d) => { erro += d.toString().slice(0, 2000); });
86
+ const relogio = setTimeout(() => { try { p.kill(); } catch {} }, 15 * 60000);
80
87
  p.on('close', (code) => {
81
88
  clearTimeout(relogio);
82
- if (code !== 0) {
83
- return resolve({ ok: false, error: `a página ${i + 1} falhou: ${erro.slice(-400).trim()}` });
84
- }
85
- paginas.push(arquivo);
89
+ if (code !== 0) return resolve({ ok: false, error: `${cmd} saiu com ${code}: ${erro.slice(-400).trim()}` });
86
90
  correr(i + 1);
87
91
  });
88
92
  p.on('error', (e) => { clearTimeout(relogio); resolve({ ok: false, error: e.message }); });
package/lib/fala.js CHANGED
@@ -37,9 +37,95 @@
37
37
 
38
38
  'use strict';
39
39
 
40
+ const { EventEmitter } = require('events');
40
41
  const { c, actionHeader, friendlyError } = require('./ui');
41
42
  const markdown = require('./markdown.js');
42
43
 
44
+ /* ── O CANAL ESTRUTURADO ─────────────────────────────────────────────────
45
+ * A janela do app (lib/app.js) mostra a conversa como CHAT: a fala do Primo
46
+ * em texto e cada ação dele numa linha cinza ("Leu index.html", "Rodou npm
47
+ * test ✓"). Adivinhar isso relendo a saída colorida do terminal seria frágil
48
+ * — um rótulo que muda aqui quebraria a janela calado. Então a MESMA função
49
+ * que desenha no terminal também avisa, por este canal, o que desenhou.
50
+ *
51
+ * Sem ouvinte (terminal puro), `avisar` não faz nada: nenhum custo para quem
52
+ * não abriu a janela. */
53
+ const eventos = new EventEmitter();
54
+ eventos.setMaxListeners(20);
55
+ function avisar(tipo, dados) {
56
+ if (!eventos.listenerCount('evento')) return;
57
+ try { eventos.emit('evento', Object.assign({ tipo }, dados || {})); } catch { /* a janela nunca derruba o agente */ }
58
+ }
59
+
60
+ /* O que a linha de ação da janela diz, por ferramenta. Verbo no passado
61
+ porque a linha fica na conversa depois que a ação acabou — "Lendo" viraria
62
+ mentira um segundo depois. O ícone é uma família, não um desenho por tool:
63
+ a página tem dez ícones, não setenta. */
64
+ const VERBOS = {
65
+ read_file: ['Leu', 'ler'], write_file: ['Escreveu', 'escrever'], edit_file: ['Editou', 'editar'],
66
+ multi_edit: ['Editou', 'editar'], list_dir: ['Listou', 'buscar'], glob: ['Procurou', 'buscar'],
67
+ grep: ['Buscou', 'buscar'], run_shell: ['Rodou', 'rodar'], run_in_new_terminal: ['Abriu um terminal com', 'rodar'],
68
+ open_in_browser: ['Abriu', 'web'], spawn_agent: ['Chamou um agente', 'agente'], write_todos: ['Planejou', 'plano'],
69
+ finish: ['Concluiu', 'plano'], pesquisar_referencia: ['Pesquisou', 'web'], remember_general: ['Guardou na memória', 'plano'],
70
+ planejar_producao: ['Planejou a produção', 'agente'], construir_app: ['Construiu', 'agente'],
71
+ };
72
+
73
+ /** Verbo, alvo e ícone de uma ação — só o que cabe numa linha, nunca o
74
+ * conteúdo inteiro de um write_file. */
75
+ function resumoDaAcao(name, args) {
76
+ const a = args || {};
77
+ let [verbo, icone] = VERBOS[name] || [];
78
+ if (!verbo) {
79
+ if (/^browser_/.test(name)) { verbo = 'Navegador · ' + name.slice(8); icone = 'web'; }
80
+ else if (/^desktop_/.test(name)) { verbo = 'Computador · ' + name.slice(8).replace(/_/g, ' '); icone = 'computador'; }
81
+ else if (/^estudio_/.test(name)) { verbo = 'Estúdio · ' + name.slice(8); icone = 'agente'; }
82
+ else { verbo = name; icone = 'outro'; }
83
+ }
84
+ let alvo = a.path || a.command || a.pattern || a.consulta || a.url || a.target || a.app || a.selector
85
+ || a.titulo || a.description || a.arquivo || (a.todos ? `${a.todos.length} tarefas` : '') || '';
86
+ if (typeof alvo !== 'string') alvo = '';
87
+ alvo = alvo.replace(/\s+/g, ' ').trim();
88
+ if (alvo.length > 120) alvo = alvo.slice(0, 119) + '…';
89
+ return { nome: name, verbo, alvo, icone };
90
+ }
91
+
92
+ /** O resultado de uma ação, resumido para a linha da janela: ok ou não, o
93
+ * "+12 −3" das edições e até 40 linhas de detalhe para quem expandir. */
94
+ function resumoDoResultado(name, r) {
95
+ if (!r || typeof r !== 'object') return { nome: name, ok: true };
96
+ if (r.ok === false) {
97
+ return { nome: name, ok: false, resumo: String(r.error || `exit ${r.code ?? 'erro'}`).replace(/\s+/g, ' ').trim().slice(0, 300) };
98
+ }
99
+ const fora = { nome: name, ok: true };
100
+ if (typeof r._antes === 'string' && typeof r._depois === 'string') {
101
+ try {
102
+ const diff = require('./diff.js');
103
+ const n = r._antes.split('\n').length + r._depois.split('\n').length;
104
+ if (n <= diff.MAX_LINHAS_ARQUIVO * 2) {
105
+ const ops = diff.operacoes(r._antes, r._depois);
106
+ Object.assign(fora, diff.contar(ops));
107
+ fora.detalhe = ops.filter((o) => o.tipo !== ' ').slice(0, 40).map((o) => o.tipo + ' ' + o.texto).join('\n');
108
+ }
109
+ } catch { /* sem diff: a linha sai sem os números */ }
110
+ }
111
+ if (name === 'write_file') fora.criado = !!r.created;
112
+ if (name === 'run_shell' || name === 'run_in_new_terminal') {
113
+ const saida = String(r.stdout || '').trim() + (r.stderr ? '\n' + String(r.stderr).trim() : '');
114
+ fora.detalhe = saida.split('\n').slice(-40).join('\n').trim();
115
+ fora.codigo = r.code ?? 0;
116
+ }
117
+ const plural = (n, um, varios) => `${n} ${n === 1 ? um : varios}`;
118
+ if (name === 'read_file') fora.resumo = plural(r.linhas || 0, 'linha', 'linhas');
119
+ if (name === 'grep') fora.resumo = plural(r.total || 0, 'ocorrência', 'ocorrências');
120
+ if (name === 'glob') fora.resumo = plural(r.total || 0, 'arquivo', 'arquivos');
121
+ if (name === 'list_dir' && r.tree) fora.detalhe = String(r.tree).split('\n').slice(0, 40).join('\n');
122
+ if (name === 'write_todos' && Array.isArray(r.todos)) {
123
+ fora.detalhe = r.todos.map((t) => (t.status === 'completed' ? '✓ ' : '○ ') + t.content).join('\n');
124
+ fora.resumo = `${r.feitos}/${r.total}`;
125
+ }
126
+ return fora;
127
+ }
128
+
43
129
  /* Até este tamanho, o texto que o modelo escreve antes de chamar uma tool é
44
130
  guardado e sai de uma vez, como pensamento. Passou disso sem tool à vista,
45
131
  é resposta — e volta a correr na tela conforme chega. Uma frase de
@@ -58,6 +144,7 @@ function pensamento(texto, profundidade) {
58
144
  if (!ls.length || !ls[0]) return '';
59
145
  const p = ident(profundidade);
60
146
  const saida = ls.map((l, i) => `${p} ${i === 0 ? c.dim('✦') : ' '} ${c.muted(l)}`).join('\n');
147
+ avisar('pensamento', { texto: ls.join('\n'), prof: profundidade || 0 });
61
148
  console.log(saida);
62
149
  return saida;
63
150
  }
@@ -65,6 +152,7 @@ function pensamento(texto, profundidade) {
65
152
  /** A ação (tool) que ele decidiu fazer. */
66
153
  function acao(name, args, profundidade) {
67
154
  const saida = ident(profundidade) + actionHeader(name, args);
155
+ avisar('acao', Object.assign(resumoDaAcao(name, args), { prof: profundidade || 0 }));
68
156
  console.log(saida);
69
157
  return saida;
70
158
  }
@@ -102,6 +190,7 @@ function resposta(texto) {
102
190
  const largura = Math.max(40, (process.stdout.columns || 80) - 4);
103
191
  const linhasMd = markdown.renderizar(texto, largura);
104
192
  const saida = '\n' + linhasMd.map((l, i) => (i === 0 ? `${c.brand('●')} ${l}` : ` ${l}`)).join('\n');
193
+ avisar('resposta', { texto: String(texto) });
105
194
  console.log(saida);
106
195
  return saida;
107
196
  }
@@ -111,6 +200,7 @@ function resposta(texto) {
111
200
  function aviso(texto) {
112
201
  if (!String(texto || '').trim()) return '';
113
202
  const saida = '\n' + formatar(texto, c.warn('●'));
203
+ avisar('aviso', { texto: String(texto) });
114
204
  console.log(saida);
115
205
  return saida;
116
206
  }
@@ -154,9 +244,11 @@ function fluxo() {
154
244
  escrever(delta) {
155
245
  const t = String(delta || '');
156
246
  if (!t) return;
247
+ avisar('pedaco', { texto: t });
157
248
  sair(desenho.pedaco(t));
158
249
  },
159
250
  fechar() {
251
+ avisar('fim', {});
160
252
  sair(desenho.fim());
161
253
  if (escreveu) process.stdout.write('\n');
162
254
  },
@@ -174,6 +266,7 @@ function paralelo(quantos, profundidade) {
174
266
  /** Um agente delegado começando: "◆ Agente·frontend a folha de estilo". */
175
267
  function agente(rotulo, descricao, profundidade) {
176
268
  const saida = `${ident(profundidade)} ${c.brand('◆')} ${c.bold(rotulo || 'Agente')}${descricao ? ' ' + c.muted(descricao) : ''}`;
269
+ avisar('acao', { nome: 'agente', verbo: rotulo || 'Agente', alvo: String(descricao || '').slice(0, 120), icone: 'agente', prof: profundidade || 0 });
177
270
  console.log(saida);
178
271
  return saida;
179
272
  }
@@ -182,8 +275,9 @@ function agente(rotulo, descricao, profundidade) {
182
275
  function agenteVoltou(resumo, profundidade) {
183
276
  const texto = String(resumo || 'sem resultado').replace(/\s+/g, ' ').trim();
184
277
  const saida = resultado(profundidade) + c.muted(texto.length > 140 ? texto.slice(0, 139) + '…' : texto);
278
+ avisar('resultado', { nome: 'agente', ok: true, detalhe: texto.slice(0, 2000) });
185
279
  console.log(saida);
186
280
  return saida;
187
281
  }
188
282
 
189
- module.exports = { LIMIAR_PENSAMENTO, pensamento, acao, resultado, recuo, resposta, aviso, problema, frase, fluxo, paralelo, agente, agenteVoltou };
283
+ module.exports = { eventos, avisar, resumoDaAcao, resumoDoResultado, LIMIAR_PENSAMENTO, pensamento, acao, resultado, recuo, resposta, aviso, problema, frase, fluxo, paralelo, agente, agenteVoltou };