primocode 10.0.0 → 10.0.9

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 (55) hide show
  1. package/README.md +2 -2
  2. package/bin/primocode.js +69 -1
  3. package/lib/act.js +118 -19
  4. package/lib/browser.js +89 -5
  5. package/lib/catalogo.js +1 -1
  6. package/lib/claude-engine.js +38 -17
  7. package/lib/codex-engine.js +27 -9
  8. package/lib/entrada.js +73 -9
  9. package/lib/estudio/cinema.js +27 -9
  10. package/lib/estudio/diretor.js +13 -1
  11. package/lib/estudio/ende.js +16 -5
  12. package/lib/estudio/estilo.js +23 -8
  13. package/lib/estudio/gerativo.js +1453 -0
  14. package/lib/estudio/hibrido.js +165 -0
  15. package/lib/estudio/index.js +244 -36
  16. package/lib/estudio/kit/gerativo.jsx +980 -0
  17. package/lib/estudio/kit/index.jsx +3 -1
  18. package/lib/estudio/kit/tres.jsx +308 -0
  19. package/lib/estudio/kit.js +7 -1
  20. package/lib/estudio/lancamento.js +28 -0
  21. package/lib/estudio/referencias.js +105 -0
  22. package/lib/estudio/remotion.js +53 -9
  23. package/lib/estudio/ritmo.js +2 -2
  24. package/lib/estudio/sons-leves.js +38 -1
  25. package/lib/estudio/variedade.js +105 -0
  26. package/lib/estudio/vox.js +37 -8
  27. package/lib/mcp.js +156 -0
  28. package/lib/personas.js +67 -0
  29. package/lib/skills.js +36 -0
  30. package/lib/tools.js +22 -3
  31. package/lib/ui.js +41 -6
  32. package/lib/video/auto.js +329 -0
  33. package/lib/video/camadas.js +207 -0
  34. package/lib/video/cli.js +1 -1
  35. package/lib/video/filters.js +39 -6
  36. package/lib/video/fx3d/Camadas.jsx +64 -0
  37. package/lib/video/fx3d/Objetos.jsx +128 -0
  38. package/lib/video/fx3d/Root.jsx +18 -0
  39. package/lib/video/fx3d/modelos.jsx +304 -0
  40. package/lib/video/fx3d.js +108 -0
  41. package/lib/video/gerados.js +119 -0
  42. package/lib/video/objetos.js +111 -0
  43. package/lib/video/ops-fx.js +453 -0
  44. package/lib/video/ops.js +30 -5
  45. package/lib/video/paralelo.js +50 -0
  46. package/lib/video/pedido.js +191 -0
  47. package/lib/video/rastrear.js +95 -0
  48. package/lib/video/rastro.js +276 -0
  49. package/lib/video/render.js +67 -14
  50. package/lib/video/texto.js +6 -4
  51. package/lib/video/timeline.js +9 -1
  52. package/package.json +2 -2
  53. package/skills/motion-de-primeira/SKILL.md +5 -4
  54. package/studio/narrar.py +10 -7
  55. package/studio/rastrear.py +399 -0
@@ -0,0 +1,165 @@
1
+ /**
2
+ * hibrido.js — o cinema com o kit por cima: foto real que não para, e o
3
+ * motion que parece feito à mão.
4
+ *
5
+ * "Deve funcionar como um mix da versão antiga (o reels da imersão) e a
6
+ * última versão — trabalhar juntas, absurdas, coisas que nem parece que
7
+ * foi IA que fez." (29/09)
8
+ *
9
+ * O que cada lado traz:
10
+ * do cinema (cinema.js) foto e vídeo REAIS ocupando a tela, escurecidos,
11
+ * trocando por baixo do mesmo texto; letra grossa em
12
+ * caixa alta com a palavra da tese no gradiente; o
13
+ * número que conta; a virada que bate no fundo; a
14
+ * marca só no fim.
15
+ * do kit (kit/*.jsx) a câmera que empurra e desliza em toda cena; a
16
+ * etiqueta da fonte que se digita; partículas no
17
+ * número e no fim; o HUD de motion designer; o
18
+ * líquido entre blocos; grão e vinheta.
19
+ *
20
+ * Três passos, e nenhum repete o que o cinema já faz:
21
+ * traduzir(roteiro) qualquer roteiro (vox, motion, composição) vira os
22
+ * tipos do cinema; o que o cinema não usa (a fonte, o
23
+ * destaque) fica guardado por parte.
24
+ * termosDeBusca(roteiro) o que procurar de b-roll para cada parte.
25
+ * enfeitar(plano, …) o kit por cima do plano que o cinema montou.
26
+ */
27
+
28
+ 'use strict';
29
+
30
+ const r2 = (n) => Math.round(n * 100) / 100;
31
+ const k = (t, v, e) => (e ? { t: r2(t), v, e } : { t: r2(t), v });
32
+ const limpo = (x) => String(x || '').replace(/\s+/g, ' ').trim();
33
+
34
+ /* O b-roll quando a parte não diz o que procurar. Genérico de propósito: é o
35
+ fundo que se move, não a prova — a prova é o texto e a foto da marca. */
36
+ const BUSCA_PADRAO = ['technology abstract light', 'people working on laptop', 'hands typing keyboard close up',
37
+ 'data center servers', 'city night timelapse', 'team meeting office', 'developer code screen', 'futuristic digital network'];
38
+
39
+ /** O roteiro de qualquer estilo → o roteiro do cinema. Devolve {roteiro, extras}. */
40
+ function traduzir(roteiro) {
41
+ const partes = (roteiro && roteiro.partes) || [];
42
+ const saida = [];
43
+ const extras = [];
44
+ // A virada: a parte marcada com climax, ou o último contraste antes do fim.
45
+ let iClimax = partes.findIndex((p) => p.climax);
46
+ if (iClimax < 0) {
47
+ for (let i = partes.length - 2; i > 0; i--) if (partes[i].tipo === 'contraste') { iClimax = i; break; }
48
+ }
49
+ partes.forEach((p, i) => {
50
+ const tipo = p.tipo || 'frase';
51
+ const marcar = [p.destaque, ...(p.marcar || [])].filter(Boolean);
52
+ const base = { fala: p.fala, ...(p.mudo ? { mudo: true } : {}), ...(p.falaForte ? { falaForte: p.falaForte } : {}), marcar };
53
+ let q;
54
+ // Os tipos que o cinema já conhece passam como estão.
55
+ if (['frase', 'montagem', 'numeros', 'escada', 'dia', 'depoimento', 'climax', 'final'].includes(tipo)) q = { ...p };
56
+ else if (i === iClimax) q = { tipo: 'climax', linhas: [p.nao, p.sim].filter(Boolean).map(limpo), ...base };
57
+ // A abertura é o título, grande; o subtítulo vira a etiqueta digitada (a fonte).
58
+ else if (tipo === 'abertura') q = { tipo: 'montagem', linhas: [limpo(p.titulo)], ritmo: 0.6, ...base };
59
+ else if (tipo === 'titulo' || tipo === 'imagem') q = { tipo: 'montagem', linhas: [p.titulo || p.texto, p.sub].filter(Boolean).map(limpo), ...base };
60
+ else if (tipo === 'numero' || tipo === 'grafico') {
61
+ // "30% mais rápido": o cinema conta o número e escreve o resto embaixo.
62
+ q = { tipo: 'numeros', itens: [limpo(`${p.valor || ''} ${p.rotulo || p.titulo || ''}`)], ...base };
63
+ } else if (tipo === 'passos' || tipo === 'lista') {
64
+ /* Na escada, o item é o NOME ("Sonnet 4"); a data e o detalhe depois do
65
+ "·" ou do "—" estão na fala. Cinco itens de cinco palavras eram 7 s
66
+ de escada pela regra de 0,3 s por palavra. */
67
+ const itens = (p.itens || []).map((x) => limpo(typeof x === 'string' ? x : (x.titulo || x.texto || '')).split(/\s+[·—–-]\s+/)[0]).filter(Boolean);
68
+ q = { tipo: 'escada', itens: itens.slice(0, 5), ...(p.titulo ? { final: limpo(p.titulo) } : {}), ...base };
69
+ } else if (tipo === 'citacao') q = { tipo: 'frase', linhas: [`“${limpo(p.texto)}”`], ...base };
70
+ else if (tipo === 'contraste') q = { tipo: 'montagem', linhas: [p.nao, p.sim].filter(Boolean).map(limpo), ...base };
71
+ else if (tipo === 'cta' || tipo === 'logo' || tipo === 'fim') q = { tipo: 'final', linhas: [p.titulo || p.texto, p.link].filter(Boolean).map(limpo), ...base };
72
+ else q = { tipo: 'montagem', linhas: [p.titulo || p.texto || p.fala].filter(Boolean).map(limpo), ...base };
73
+ saida.push(q);
74
+ extras.push({ tipoOriginal: tipo, fonte: tipo === 'abertura' && p.sub ? p.sub : (p.fonte || (tipo === 'citacao' ? p.autor : '')), autor: p.autor || '', busca: p.busca || '' });
75
+ });
76
+ return { roteiro: { ...roteiro, partes: saida, estilo: 'hibrido' }, extras };
77
+ }
78
+
79
+ /** O que procurar de b-roll para cada parte: a busca dela, a do roteiro, ou o padrão. */
80
+ function termosDeBusca(roteiro, extras = []) {
81
+ const global = [].concat(roteiro.busca || []).filter(Boolean);
82
+ // Escopo fechado: só os termos da pessoa, nunca o padrão genérico.
83
+ const fechado = require('./referencias.js').atual().modo === 'fechado';
84
+ const lista = global.length ? global : fechado ? [] : BUSCA_PADRAO;
85
+ const termos = new Set();
86
+ (roteiro.partes || []).forEach((p, i) => {
87
+ const b = (extras[i] && extras[i].busca) || p.busca;
88
+ if (b) termos.add(String(b));
89
+ });
90
+ for (const t of lista) termos.add(t);
91
+ return [...termos].slice(0, 8);
92
+ }
93
+
94
+ /* A câmera de cada cena do cinema: empurra e desliza, alternando o lado. A
95
+ foto já se move por dentro (os movimentos do cinema); a câmera move o
96
+ CONJUNTO — é a soma dos dois que dá a profundidade. */
97
+ function camera(d, i, deitado, forte) {
98
+ const lado = i % 2 ? 1 : -1;
99
+ const z = forte ? 1.14 : 1.07;
100
+ const desliza = (deitado ? 26 : 18);
101
+ return {
102
+ zoom: [k(0, 1.0), k(Math.max(0.3, d - 0.25), z, 'camera'), k(d, z + 0.04, 'acelera')],
103
+ x: [k(0, -lado * desliza), k(d, lado * desliza, 'camera')],
104
+ rotacao: [k(0, -lado * 0.35), k(d, lado * 0.35, 'camera')],
105
+ };
106
+ }
107
+
108
+ /** O kit por cima do plano do cinema. Mexe no plano recebido e o devolve. */
109
+ function enfeitar(plano, { extras = [], partes = [], marca = {}, largura, altura } = {}) {
110
+ if (!plano || !Array.isArray(plano.cenas)) return plano;
111
+ const W = largura || (plano.formato && plano.formato.largura) || 1920;
112
+ const H = altura || (plano.formato && plano.formato.altura) || 1080;
113
+ const deitado = W >= H;
114
+ const base = Math.min(W, H);
115
+ const M = Math.round(base * 0.08);
116
+ const grad = marca.gradiente && marca.gradiente.length >= 2 ? marca.gradiente : ['#2E81ED', '#9567DB'];
117
+ const ACENTO = (marca.cores && marca.cores.destaque) || grad[1];
118
+
119
+ /* As cenas do cinema não são uma por parte: o depoimento com três clipes
120
+ vira três cenas. O mapa cena → parte sai das partes já traduzidas. */
121
+ const parteDaCena = [];
122
+ partes.forEach((p, i) => {
123
+ const n = p.tipo === 'depoimento' ? Math.max(1, (p.clipes || []).length) : 1;
124
+ for (let q = 0; q < n; q++) parteDaCena.push(i);
125
+ });
126
+ plano.cenas.forEach((cena, c) => {
127
+ const iParte = parteDaCena[c] != null ? parteDaCena[c] : c;
128
+ const ex = extras[iParte] || {};
129
+ const d = cena.duracao || 2;
130
+ const tipo = ex.tipoOriginal || '';
131
+ const els = cena.elementos || (cena.elementos = []);
132
+ const forte = tipo === 'numero' || tipo === 'contraste' || tipo === 'abertura';
133
+ if (!cena.camera) cena.camera = camera(d, c, deitado, forte);
134
+
135
+ // A FONTE que se digita, com a barra do acento: vira prova, não enfeite.
136
+ if (ex.fonte && d > 1.4) {
137
+ els.push({ tipo: 'kit', peca: 'fonte', texto: String(ex.fonte).slice(0, 48), t: Math.min(0.9, d * 0.35),
138
+ x: M, y: Math.round(H - M * (deitado ? 1.1 : 2.6)), tamanho: Math.round(base * 0.016), cor: 'rgba(245,245,247,0.72)', corAcento: ACENTO });
139
+ }
140
+ // O NÚMERO estoura em partículas quando assenta.
141
+ if (tipo === 'numero' || tipo === 'grafico') {
142
+ els.push({ tipo: 'kit', peca: 'particulas', t: 0.45, x: Math.round(W / 2), y: Math.round(H * 0.42), raio: Math.round(base * 0.34), n: 46,
143
+ tamanho: Math.max(5, Math.round(base * 0.007)), cores: ['#ffffff', ACENTO, grad[0]] });
144
+ }
145
+ // O FIM: a marca entra com um brilho de partículas.
146
+ if (tipo === 'cta' || tipo === 'logo' || tipo === 'fim') {
147
+ els.push({ tipo: 'kit', peca: 'particulas', t: 0.2, x: Math.round(W / 2), y: Math.round(H * 0.36), raio: Math.round(base * 0.45), n: 60,
148
+ tamanho: Math.max(4, Math.round(base * 0.006)), cores: ['#ffffff', ACENTO], forma: 'circulo' });
149
+ }
150
+ /* O LÍQUIDO entre blocos: a cada três partes a passagem é a mancha que
151
+ cobre a tela, não o esmaecer. Mais que isso vira truque. */
152
+ if (c > 0 && iParte % 3 === 0 && cena.transicao && cena.transicao.tipo !== 'corte' && tipo !== 'cta') {
153
+ cena.transicao = { tipo: 'liquido', duracao: 0.5, cor: ACENTO };
154
+ }
155
+ });
156
+
157
+ // O HUD de motion designer: cantos, timecode e a marca.
158
+ plano.hud = plano.hud || { marca: String(marca.nome || '').toUpperCase().slice(0, 24) || undefined };
159
+ // O acabamento de filme um pouco mais presente que o do cinema.
160
+ plano.acabamento = { ...(plano.acabamento || {}), grao: Math.max(0.06, (plano.acabamento || {}).grao || 0), vinheta: Math.max(0.55, (plano.acabamento || {}).vinheta || 0) };
161
+ plano.estilo = 'hibrido';
162
+ return plano;
163
+ }
164
+
165
+ module.exports = { traduzir, termosDeBusca, enfeitar, BUSCA_PADRAO };
@@ -34,6 +34,9 @@ const { spawn } = require('child_process');
34
34
 
35
35
  const projeto = require('./projeto.js');
36
36
  const acervo = require('./acervo.js');
37
+ const hibrido = require('./hibrido.js');
38
+ const referencias = require('./referencias.js');
39
+ const variedade = require('./variedade.js');
37
40
  const plano = require('./plano.js');
38
41
  const remotion = require('./remotion.js');
39
42
  const assets = require('./assets.js');
@@ -46,6 +49,7 @@ const acervoSite = require('./acervo-site.js');
46
49
  const impacto = require('./impacto.js');
47
50
  const cinema = require('./cinema.js');
48
51
  const motion = require('./motion.js');
52
+ const gerativo = require('./gerativo.js');
49
53
  const vox = require('./vox.js');
50
54
  const lancamento = require('./lancamento.js');
51
55
  const glifos = require('./glifos.js');
@@ -306,6 +310,11 @@ async function ferramentaPlano(args) {
306
310
  sons (lib/estudio/composicao.js). É o caminho para qualquer modelo
307
311
  entregar motion e slides que prestam. */
308
312
  if (args.roteiro) {
313
+ /* O roteiro como a pessoa (ou o modelo) mandou: o híbrido traduz as
314
+ partes para o cinema no caminho, e o que fica guardado tem de ser o
315
+ original — senão o próximo ajuste parte do traduzido. */
316
+ let original = null;
317
+ try { original = JSON.parse(typeof args.roteiro === 'string' ? args.roteiro : JSON.stringify(args.roteiro)); } catch { original = args.roteiro; }
309
318
  const r = await prepararRoteiro(p, args);
310
319
  if (!r.ok) return r;
311
320
  let conferida = plano.conferir(r.plano, { pasta: p.pasta });
@@ -322,7 +331,7 @@ async function ferramentaPlano(args) {
322
331
  }
323
332
  projeto.salvarPlano(args.id, r.plano, args.motivo || 'montado do roteiro');
324
333
  // O roteiro fica guardado: ajustar uma frase é mandar o roteiro de novo, não reescrever tudo.
325
- try { fs.writeFileSync(path.join(p.pasta, 'roteiro.json'), JSON.stringify(args.roteiro, null, 2)); } catch { /* segue */ }
334
+ try { fs.writeFileSync(path.join(p.pasta, 'roteiro.json'), JSON.stringify(original || args.roteiro, null, 2)); } catch { /* segue */ }
326
335
  projeto.salvar(args.id, { duracao: conferida.duracao });
327
336
  return {
328
337
  ok: true, duracao: conferida.duracao, ...r.resumo, resumo: plano.resumo(r.plano),
@@ -887,21 +896,18 @@ async function temLetra(arquivo) {
887
896
  (25/09/2026) */
888
897
  async function sonsReais(p, video) {
889
898
  const sint = await edicao.prepararSons(p.pasta, video);
890
- const quer = {
891
- teclado: ['keyboard typing', 'typing keyboard'],
892
- whoosh: ['cinematic whoosh', 'swoosh transition'],
893
- impacto: ['cinematic boom impact', 'deep cinematic impact', 'trailer hit'],
894
- sala: ['crowd murmur indoor', 'office ambience', 'people talking background'],
895
- };
899
+ /* Termo sorteado e resultado sorteado, sem repetir os últimos: o primeiro
900
+ resultado do termo fixo era o mesmo whoosh em todo vídeo (02/10). */
896
901
  const sons = { ...sint };
897
- for (const [nome, termos] of Object.entries(quer)) {
898
- for (const termo of termos) {
902
+ for (const nome of ['teclado', 'whoosh', 'impacto', 'sala']) {
903
+ for (const termo of variedade.termosSfx(nome)) {
904
+ if (sons[nome] !== sint[nome]) break;
899
905
  try {
900
- const b = await assets.procurarSom(termo, { quantas: 5 });
901
- const item = (b.ok ? b.sons : []).find((x) => x.url);
902
- if (!item) continue;
903
- const d = await assets.baixar(item, { pasta: p.pasta, nome: 'sfx-real-' + nome });
904
- if (d.ok) { sons[nome] = d.relativo; break; }
906
+ const b = await assets.procurarSom(termo, { quantas: 10 });
907
+ for (const item of variedade.ordenar(b.ok ? b.sons : [], 'sfx').slice(0, 3)) {
908
+ const d = await assets.baixar(item, { pasta: p.pasta, nome: 'sfx-real-' + nome });
909
+ if (d.ok) { sons[nome] = d.relativo; variedade.marcar('sfx', item.url); break; }
910
+ }
905
911
  } catch { /* o próximo termo */ }
906
912
  }
907
913
  }
@@ -932,7 +938,25 @@ async function prepararRoteiro(p, args) {
932
938
  lançamento de produto vira LANCAMENTO, anúncio e reels vão para o
933
939
  IMPACTO. O que o agente pediu vale mais que tudo. */
934
940
  const pedidoTxt = `${p.pedido || ''} ${args.pedido || ''}`;
935
- const estilo = escolherEstilo({ pedido: pedidoTxt, agente: args.estilo, roteiro: roteiro.estilo });
941
+ const ref = referencias.atual();
942
+ let estilo = escolherEstilo({ pedido: pedidoTxt, agente: args.estilo, roteiro: roteiro.estilo });
943
+ /* "Sem referências, só motion": o híbrido vive de b-roll de fora, então
944
+ cai para o motion — sem nada baixado. */
945
+ const produtoNoPedido = /produto|an[uú]nci|lan[çc]a|headset|fone|celular|t[eê]nis|rel[oó]gio|garrafa|lata|app\b/i.test(pedidoTxt);
946
+ /* E o motion de agora é o GERATIVO: sem template, produto ou não (o
947
+ produto em 3D entra numa batida dele, logo abaixo). */
948
+ if (ref.modo === 'sem' && estilo === 'hibrido') estilo = 'gerativo';
949
+ void produtoNoPedido;
950
+ /* "O vídeo deve ter um 3D do fone" (30/09): pedido de 3D vira o gerativo
951
+ com o produto modelado girando numa batida (kit/tres.jsx) — a menos que
952
+ o agente tenha escolhido outro estilo pelo nome. */
953
+ if (/\b3\s?d\b|tridimensional/i.test(pedidoTxt) && (!args.estilo || estilo === 'gerativo' || estilo === 'lancamento')) {
954
+ // O 3D mora numa batida do gerativo; o lançamento antigo, só pelo nome.
955
+ if (!args.estilo && estilo !== 'lancamento') estilo = 'gerativo';
956
+ if (!roteiro.modelo3d) roteiro.modelo3d = /fone|headset|headphone/i.test(pedidoTxt) ? 'fone'
957
+ : /celular|smartphone|phone/i.test(pedidoTxt) ? 'celular' : /lata|bebida|garrafa/i.test(pedidoTxt) ? 'lata' : 'caixa';
958
+ }
959
+ if (ref.modo === 'fechado' && !roteiro.busca && referencias.termos().length) roteiro.busca = referencias.termos();
936
960
  // Os estilos de motion usam os efeitos leves e a trilha do ende.app; os de corte, os cheios.
937
961
  const ehMotion = LEVES.has(estilo);
938
962
  const modo = (args.modo || roteiro.modo || (p.formato === 'slides' || p.formato === 'arte' ? 'slides' : 'video'));
@@ -981,7 +1005,15 @@ async function prepararRoteiro(p, args) {
981
1005
  }
982
1006
 
983
1007
  let tempos = [], narracao = null, sons = {}, trilha = null;
984
- if (modo === 'video' && estilo === 'cinema') {
1008
+ /* O HÍBRIDO (hibrido.js) é o cinema com o kit por cima: o roteiro de
1009
+ qualquer estilo vira os tipos do cinema aqui, antes da voz — a voz lê
1010
+ o que o cinema vai mostrar. */
1011
+ let hib = null;
1012
+ if (modo === 'video' && estilo === 'hibrido') {
1013
+ hib = hibrido.traduzir(roteiro);
1014
+ partes.splice(0, partes.length, ...hib.roteiro.partes);
1015
+ }
1016
+ if (modo === 'video' && (estilo === 'cinema' || estilo === 'hibrido')) {
985
1017
  // Só as partes com voz vão para a narração; o depoimento tem a voz do aluno.
986
1018
  // Só as partes com voz: o depoimento tem a voz do aluno, e a parte muda não tem voz.
987
1019
  // A parte com voz forte é narrada à parte: se entrasse aqui também, seria falada duas vezes (e muda uma delas).
@@ -1051,20 +1083,39 @@ async function prepararRoteiro(p, args) {
1051
1083
  }
1052
1084
  }
1053
1085
  }
1086
+ /* No HÍBRIDO a voz anda um pouco mais rápido por padrão (1,12×, como no
1087
+ vox): o trailer premium do cinema lido pelo Gemini sai no passo de
1088
+ leitura, e o vídeo segue a voz. `roteiro.ritmoVoz` ajusta. */
1089
+ if (hib && ritmoVoz <= 1.01) {
1090
+ const kh = Math.max(1, Math.min(1.5, Number(roteiro.ritmoVoz) || 1.12));
1091
+ const rapido = mp3.replace(/\.mp3$/, '-h.mp3');
1092
+ const c = kh > 1.01 ? await video.rodar(video.ffmpeg(), ['-y', '-i', mp3, '-filter:a', `atempo=${kh.toFixed(3)}`, rapido], { timeout: 120000 }) : { ok: false };
1093
+ if (process.env.PRIMOCODE_TRACO === '1') process.stderr.write(`[traço] voz do híbrido ${kh}× ok=${c.ok} existe=${fs.existsSync(rapido)} ${String(c.erro || '').slice(-120)}\n`);
1094
+ if (c.ok && fs.existsSync(rapido)) {
1095
+ fs.renameSync(rapido, mp3);
1096
+ ritmoVoz = kh;
1097
+ const div = (x) => Math.round((x / kh) * 1000) / 1000;
1098
+ v.partes = v.partes.map((pt) => ({ de: div(pt.de), ate: div(pt.ate), palavras: (pt.palavras || []).map((w) => ({ ...w, de: div(w.de), ate: div(w.ate) })) }));
1099
+ v.segundos = div(v.segundos);
1100
+ }
1101
+ }
1054
1102
  projeto.anotar(p.id, 'narracao', `${v.segundos}s em ${lista.length} partes, voz ${v.voz}`);
1055
1103
  const sonsC = await sonsReais(p, video);
1056
1104
  let trilhaC = null;
1057
1105
  // Vários termos e durações: vídeo sem música foi o que saiu na primeira vez (25/09).
1058
1106
  // E só instrumental: "essa música daria pra deixar só o instrumental?" — a faixa é OUVIDA.
1059
- for (const [termo, min] of [[roteiro.musica, 60], ['instrumental cinematic inspiring', 70], ['instrumental piano cinematic', 60], ['instrumental epic cinematic', 60], ['instrumental ambient inspiring', 45], ['instrumental', 45]]) {
1060
- if (!termo || trilhaC) continue;
1107
+ // O termo sai do clima do roteiro (variedade.js), não de uma lista fixa de "cinematic inspiring".
1108
+ for (const termo of [...variedade.termosTrilha(roteiro, 'cinema'), 'instrumental']) {
1109
+ if (trilhaC) break;
1061
1110
  try {
1062
- const b = await assets.procurarMusica(termo, { quantas: 6, duracaoMin: min });
1063
- for (const item of (b.ok ? b.sons : [])) {
1111
+ const b = await assets.procurarMusica(termo, { quantas: 10, duracaoMin: 45 });
1112
+ for (const item of variedade.ordenar(b.ok ? b.sons : [], 'trilha').filter((x) => !/vocal|voice|feat|ft\.|remix/i.test(x.titulo || '')).slice(0, 4)) {
1064
1113
  const d = await assets.baixar(item, { pasta: p.pasta, nome: 'trilha' });
1065
1114
  if (!d.ok) continue;
1066
1115
  if (await temLetra(path.join(p.pasta, d.relativo))) { projeto.anotar(p.id, 'midia', `trilha com voz descartada: ${item.titulo}`); continue; }
1067
- trilhaC = d.relativo; break;
1116
+ trilhaC = d.relativo; variedade.marcar('trilha', item.url);
1117
+ projeto.anotar(p.id, 'trilha', `"${termo}": ${item.titulo} — ${item.autor || 'Jamendo'} (${item.licenca || 'CC'})`);
1118
+ break;
1068
1119
  }
1069
1120
  } catch { /* o próximo termo */ }
1070
1121
  }
@@ -1085,7 +1136,69 @@ async function prepararRoteiro(p, args) {
1085
1136
  const MINI = /poster|face \d+|\bthumb/i;
1086
1137
  const fotosC = acervo.fotos.filter((f) => !MINI.test(f.alt || ''))
1087
1138
  .map((f) => ({ src: f.src, tipo: 'imagem', cat: TELA.test(f.alt || '') ? 'tela' : 'pessoas', alt: f.alt }));
1088
- const rostos = await acharRostos(p, fotosC.map((f) => f.src));
1139
+ /* O B-ROLL do híbrido: o cinema vive de foto que troca por baixo do
1140
+ texto, e o acervo da marca raramente passa de meia dúzia. Vídeo de
1141
+ banco primeiro (o fundo que se move sozinho), foto como reserva —
1142
+ em paralelo, porque são oito buscas. */
1143
+ /* Foto de compartilhamento (og:image) e logo trazem o NOME da marca em
1144
+ letra gigante: como fundo, viravam um "THRO" cortado atrás do texto. */
1145
+ /* E arte de banner (2400×1260 e afins): é o logo da marca desenhado, não
1146
+ uma cena — cortada no vídeo em pé, vira um "✳ Cl" gigante. */
1147
+ const banner = (alt) => { const m = /(\d{3,4})x(\d{3,4})/.exec(alt || ''); return m && Number(m[1]) / Number(m[2]) > 1.7 && altura > largura; };
1148
+ if (hib) for (let q = fotosC.length - 1; q >= 0; q--) if (/open graph|og[:_ -]?image|\blogo|banner|wordmark/i.test(fotosC[q].alt || '') || banner(fotosC[q].alt)) fotosC.splice(q, 1);
1149
+ if (hib && fotosC.length < 14) {
1150
+ const orientacao = largura >= altura ? 'landscape' : 'portrait';
1151
+ const pastaMidia = path.join(p.pasta, 'midia');
1152
+ const jaTem = (() => { try { return fs.readdirSync(pastaMidia); } catch { return []; } })();
1153
+ const cat = (termo) => (/screen|code|data|server|dashboard/i.test(termo) ? 'tela' : 'pessoas');
1154
+ /* A versão LEVE do b-roll: o Pexels entrega 1080×1920 inteiro (até 30
1155
+ MB e 20 s), e o render parava num quadro esperando vários vídeos
1156
+ assim ao mesmo tempo (medido: timeout de 120 s no quadro 184). Cada
1157
+ um vira 8 s, 720 de largura, sem áudio — e fica guardado, para o
1158
+ próximo render não baixar de novo. */
1159
+ const leve = async (rel, destino) => {
1160
+ const alvo = path.join(pastaMidia, destino);
1161
+ const escala = largura >= altura ? 'scale=1280:-2' : 'scale=720:-2';
1162
+ const c = await video.rodar(video.ffmpeg(), ['-y', '-i', path.join(p.pasta, rel), '-t', '8', '-vf', escala + ',fps=30', '-an',
1163
+ '-c:v', 'libx264', '-preset', 'veryfast', '-crf', '24', '-pix_fmt', 'yuv420p', '-movflags', '+faststart', alvo], { timeout: 180000 });
1164
+ if (!c.ok || !fs.existsSync(alvo)) return null;
1165
+ try { fs.unlinkSync(path.join(p.pasta, rel)); } catch { /* fica o original */ }
1166
+ return 'midia/' + destino;
1167
+ };
1168
+ const achados = await Promise.all(hibrido.termosDeBusca(roteiro, hib.extras).map(async (termo) => {
1169
+ const out = [];
1170
+ const slug = 'broll-' + String(termo).toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '');
1171
+ const proxy = `${slug}.leve.mp4`;
1172
+ if (jaTem.includes(proxy)) out.push({ src: 'midia/' + proxy, tipo: 'video', cat: cat(termo), alt: termo });
1173
+ else try {
1174
+ const b = await assets.procurarVideos(termo, { quantas: 3, orientacao });
1175
+ for (const item of (b.ok ? b.videos : []).filter((v) => !v.duracao || v.duracao >= 4).slice(0, 1)) {
1176
+ const d = await assets.baixar(item, { pasta: p.pasta, nome: slug });
1177
+ const l = d.ok ? await leve(d.relativo, proxy) : null;
1178
+ if (l) out.push({ src: l, tipo: 'video', cat: cat(termo), alt: termo });
1179
+ }
1180
+ } catch { /* segue com a foto */ }
1181
+ const foto = jaTem.find((f) => f.startsWith(slug + '-') && /\.(jpe?g|png|webp)$/i.test(f));
1182
+ if (foto) out.push({ src: 'midia/' + foto, tipo: 'imagem', cat: cat(termo), alt: termo });
1183
+ else try {
1184
+ const b = await assets.procurarImagens(termo, { quantas: 3, orientacao });
1185
+ for (const item of (b.ok ? b.imagens : []).slice(0, 1)) {
1186
+ const d = await assets.baixar(item, { pasta: p.pasta, nome: slug });
1187
+ if (d.ok) out.push({ src: d.relativo, tipo: 'imagem', cat: cat(termo), alt: termo });
1188
+ }
1189
+ } catch { /* sem */ }
1190
+ return out;
1191
+ }));
1192
+ const novos = achados.flat();
1193
+ // Intercalados com as da marca: a foto da marca não pode ficar toda no começo.
1194
+ const marcaF = fotosC.splice(0);
1195
+ for (let q = 0; q < Math.max(marcaF.length, novos.length); q++) {
1196
+ if (novos[q]) fotosC.push(novos[q]);
1197
+ if (marcaF[q]) fotosC.push(marcaF[q]);
1198
+ }
1199
+ projeto.anotar(p.id, 'midia', `b-roll do híbrido: ${novos.filter((f) => f.tipo === 'video').length} vídeos e ${novos.filter((f) => f.tipo === 'imagem').length} fotos`);
1200
+ }
1201
+ const rostos = await acharRostos(p, fotosC.filter((f) => f.tipo !== 'video').map((f) => f.src));
1089
1202
  for (const f of fotosC) if (rostos[f.src]) f.rosto = rostos[f.src];
1090
1203
  // Os depoimentos: cortados na palavra, com a voz no volume certo e fade.
1091
1204
  for (const pt of partes) {
@@ -1126,8 +1239,12 @@ async function prepararRoteiro(p, args) {
1126
1239
  iNarr++;
1127
1240
  }
1128
1241
  const r = cinema.montarCinema({ roteiro, largura, altura, tempos: v.partes, narracao: path.relative(p.pasta, mp3).replace(/\\/g, '/'),
1129
- sons: sonsC, trilha: trilhaC, fotos: fotosC, fortes });
1130
- if (r.ok) projeto.anotar(p.id, 'plano', `estilo cinema: ${r.resumo.cenas} cenas, ${r.resumo.segundos}s`);
1242
+ sons: sonsC, trilha: trilhaC, fotos: fotosC, fortes, ritmoLeitura: hib ? 0.55 : 1 });
1243
+ if (r.ok && hib) {
1244
+ hibrido.enfeitar(r.plano, { extras: hib.extras, partes, marca: roteiro.marca || {}, largura, altura });
1245
+ r.resumo.estilo = 'hibrido';
1246
+ }
1247
+ if (r.ok) projeto.anotar(p.id, 'plano', `estilo ${hib ? 'hibrido' : 'cinema'}: ${r.resumo.cenas} cenas, ${r.resumo.segundos}s`);
1131
1248
  return r;
1132
1249
  }
1133
1250
  if (modo === 'video') {
@@ -1137,31 +1254,100 @@ async function prepararRoteiro(p, args) {
1137
1254
  fs.writeFileSync(arq, JSON.stringify(lista));
1138
1255
  const mp3 = path.join(p.pasta, 'midia', `narracao-${Date.now().toString(36)}.mp3`);
1139
1256
  // A voz de gente primeiro (Gemini, pelo servidor); o Edge é a reserva.
1140
- let v = await narrarComGemini(lista, mp3, { voz: args.voz || roteiro.voz,
1141
- direcao: estilo === 'impacto' ? 'Leia em português do Brasil como locutor de comercial agressivo e vendedor: energia alta, ritmo rápido, '
1142
- + 'voz encorpada, ênfase forte nas palavras de impacto, pausas curtas e secas entre as frases, sorrindo na voz.' : undefined });
1257
+ // Com cache: refazer o vídeo (outro estilo, outra proporção) não gasta a cota da voz.
1258
+ let v = await narrarComCache(lista, mp3, { voz: args.voz || roteiro.voz,
1259
+ direcao: estilo === 'gerativo'
1260
+ /* O gerativo pede voz com CORPO ("narração encorpada, extravagante",
1261
+ 30/09): cheia, quente, perto do microfone — e o ritmo segue 1,12×. */
1262
+ /* (A primeira versão pedia "trailer" e "ênfase sustentada": a voz saiu 25% mais lenta, medido em 30/09.) */
1263
+ ? 'Leia em português do Brasil como locutor de reels de tecnologia: RÁPIDO e contínuo, com voz cheia, quente e encorpada, '
1264
+ + 'bem perto do microfone, muita energia e sorriso na voz. Ataque firme e ênfase curta e forte nas palavras de impacto; '
1265
+ + 'quase sem pausa entre as frases. Nunca arraste, nunca leia devagar nem solene.'
1266
+ : estilo === 'impacto' ? 'Leia em português do Brasil como locutor de comercial agressivo e vendedor: energia alta, ritmo rápido, '
1267
+ + 'voz encorpada, ênfase forte nas palavras de impacto, pausas curtas e secas entre as frases, sorrindo na voz.'
1268
+ // "A narração tá lenta, deixa mais viva" (29/09): todo estilo fala com energia de reels.
1269
+ : 'Leia em português do Brasil como narrador de reels de tecnologia: vivo, confiante e rápido, sorrindo na voz, '
1270
+ + 'ênfase nas palavras que importam, pausas curtas entre as frases. Nada de leitura lenta ou solene.' });
1143
1271
  if (!v.ok) v = await rodarPython('narrar.py', ['roteiro', '--arquivo', arq, '--saida', mp3, '--voz', String(args.voz || roteiro.voz || 'thalita')], { timeout: 10 * 60000 });
1144
1272
  if (!v.ok) return v;
1273
+ /* O RITMO DA VOZ. Mesmo com a direção, a voz sai no passo de leitura, e
1274
+ o vídeo — que segue a voz palavra por palavra — sai lento junto. O
1275
+ áudio acelera 1,12× (1,3× ficou corrido demais, 29/09; atempo não muda o tom) e cada tempo de palavra
1276
+ acompanha; `roteiro.ritmoVoz` ajusta (1 desliga). */
1277
+ const ritmo = Math.max(1, Math.min(1.5, Number(roteiro.ritmoVoz || args.ritmoVoz) || 1.12));
1278
+ if (ritmo > 1.01) {
1279
+ const rap = mp3.replace(/\.mp3$/, '-r.mp3');
1280
+ const c = await video.rodar(video.ffmpeg(), ['-y', '-i', mp3, '-filter:a', `atempo=${ritmo.toFixed(3)}`, rap], { timeout: 120000 });
1281
+ if (c.ok && fs.existsSync(rap)) {
1282
+ fs.renameSync(rap, mp3);
1283
+ const esc = (x) => (typeof x === 'number' ? Math.round((x / ritmo) * 1000) / 1000 : x);
1284
+ v.segundos = esc(v.segundos);
1285
+ v.partes = (v.partes || []).map((pt) => ({ ...pt, de: esc(pt.de), ate: esc(pt.ate),
1286
+ palavras: (pt.palavras || []).map((w) => ({ ...w, de: esc(w.de), ate: esc(w.ate) })) }));
1287
+ }
1288
+ }
1145
1289
  tempos = v.partes;
1146
1290
  narracao = path.relative(p.pasta, mp3).replace(/\\/g, '/');
1147
1291
  projeto.anotar(p.id, 'narracao', `${v.segundos}s em ${partes.length} partes, voz ${v.voz}`);
1148
1292
  // O motion usa os efeitos leves; os estilos de corte, os cheios.
1149
- sons = ehMotion ? sonsLeves.preparar(p.pasta) : await edicao.prepararSons(p.pasta, video);
1293
+ // O gerativo tem a família de sons DELE (sintetizada da semente); os outros motion, os leves.
1294
+ // O gerativo desenha os sons DEPOIS da partitura (um arquivo por evento, da própria trilha).
1295
+ sons = estilo === 'gerativo' ? {}
1296
+ : ehMotion ? sonsLeves.preparar(p.pasta) : await edicao.prepararSons(p.pasta, video);
1150
1297
  // A trilha do motion: ende.app (CC BY 4.0, sem Content ID), clima tecnologia.
1151
1298
  if (ehMotion) {
1152
1299
  try {
1153
1300
  const segundos = tempos.length ? tempos[tempos.length - 1].ate + 2 : 20;
1154
- const m = await ende.provedor({ pasta: path.join(p.pasta, 'midia') }).gerar({ clima: roteiro.clima || 'tecnologia', segundos });
1301
+ /* O gerativo escolhe o GÊNERO pelo assunto (jazz para o explainer,
1302
+ funk para o fone, eletrônica para IA…), sorteia a faixa e não
1303
+ repete as últimas usadas — nada de "inspiring corporate". */
1304
+ let m = null;
1305
+ const usadasArq = path.join(os.homedir(), '.primocode', 'studio', 'trilhas-usadas.json');
1306
+ let usadas = [];
1307
+ try { usadas = JSON.parse(fs.readFileSync(usadasArq, 'utf8')); } catch { usadas = []; }
1308
+ const mus = estilo === 'gerativo' ? gerativo.musicaDe(roteiro) : null;
1309
+ /* A trilha do gerativo vem do Jamendo (Openverse), pelo GÊNERO do
1310
+ assunto, instrumental, sorteada e sem repetir as últimas — a
1311
+ ende.app fica de reserva ("parar de cair sempre na ende", 30/09). */
1312
+ if (mus) {
1313
+ const BUSCA = { jazz: 'jazz instrumental', 'lofi-chill-beats': 'lofi hip hop instrumental', electronica: 'electronic instrumental', funk: 'funk instrumental',
1314
+ 'dance-club': 'house instrumental', ambient: 'ambient instrumental', acoustic: 'acoustic guitar instrumental', cinematic: 'cinematic instrumental',
1315
+ classical: 'piano classical instrumental', 'soul-rnb': 'soul instrumental' };
1316
+ try {
1317
+ const b = await assets.procurarMusica(BUSCA[roteiro.genero || mus.genero] || ((roteiro.genero || mus.genero) + ' instrumental'), { quantas: 8, duracaoMin: Math.max(45, segundos + 10) });
1318
+ const opcoes = (b.ok ? b.sons : []).filter((x) => x.url && !usadas.includes(x.url) && !/vocal|voice|feat|ft\.|remix/i.test(x.titulo || ''));
1319
+ for (let k = 0; k < opcoes.length && !m; k++) {
1320
+ const item = opcoes[(mus.semente + k) % opcoes.length];
1321
+ const d = await assets.baixar(item, { pasta: p.pasta, nome: 'trilha-' + (roteiro.genero || mus.genero) });
1322
+ if (!d.ok) continue;
1323
+ if (await temLetra(path.join(p.pasta, d.relativo))) { projeto.anotar(p.id, 'midia', `trilha com voz descartada: ${item.titulo}`); continue; }
1324
+ m = { src: path.join(p.pasta, d.relativo), credito: `Música: ${item.titulo} — ${item.autor || 'Jamendo'} (${item.licenca || 'CC'}, Jamendo)`, id: item.url, segundos: item.segundos };
1325
+ }
1326
+ } catch { /* cai na ende.app */ }
1327
+ }
1328
+ if (mus && m) {
1329
+ try { fs.writeFileSync(usadasArq, JSON.stringify([m.id, ...usadas.filter((x) => x !== m.id)].slice(0, 16))); } catch { /* sem memória */ }
1330
+ projeto.anotar(p.id, 'trilha', `gênero ${roteiro.genero || mus.genero} (Jamendo)`);
1331
+ } else if (mus) {
1332
+ m = await ende.provedor({ pasta: path.join(p.pasta, 'midia') }).gerar({ clima: roteiro.clima || 'energetico', segundos, genero: roteiro.genero || mus.genero, semente: mus.semente, evitar: usadas });
1333
+ const id = m && Number((/ende-(\d+)\.mp3$/.exec(m.src) || [])[1]);
1334
+ if (id) try { fs.writeFileSync(usadasArq, JSON.stringify([id, ...usadas.filter((x) => x !== id)].slice(0, 16))); } catch { /* sem memória */ }
1335
+ if (m) projeto.anotar(p.id, 'trilha', `gênero ${roteiro.genero || mus.genero}`);
1336
+ } else m = await ende.provedor({ pasta: path.join(p.pasta, 'midia') }).gerar({ clima: roteiro.clima || 'tecnologia', segundos });
1155
1337
  if (m) { trilha = path.relative(p.pasta, m.src).replace(/\\/g, '/'); projeto.anotar(p.id, 'trilha', m.credito); }
1156
1338
  } catch { /* cai na busca de sempre */ }
1157
1339
  }
1158
- if (!trilha) try {
1159
- const b = await assets.procurarMusica(roteiro.musica || args.trilha || (estilo === 'impacto' ? 'epic energetic' : 'inspiring corporate'), { quantas: 6, duracaoMin: 45 });
1160
- for (const item of (b.ok ? b.sons : [])) {
1161
- const d = await assets.baixar(item, { pasta: p.pasta, nome: 'trilha' });
1162
- if (d.ok) { trilha = d.relativo; break; }
1163
- }
1164
- } catch { /* sem música */ }
1340
+ // Termo pelo clima e faixa sorteada sem repetir — o "inspiring corporate" fixo era a mesma música em todo vídeo.
1341
+ for (const termo of [args.trilha, ...variedade.termosTrilha(roteiro, estilo)].filter(Boolean)) {
1342
+ if (trilha) break;
1343
+ try {
1344
+ const b = await assets.procurarMusica(termo, { quantas: 10, duracaoMin: 45 });
1345
+ for (const item of variedade.ordenar(b.ok ? b.sons : [], 'trilha').slice(0, 3)) {
1346
+ const d = await assets.baixar(item, { pasta: p.pasta, nome: 'trilha' });
1347
+ if (d.ok) { trilha = d.relativo; variedade.marcar('trilha', item.url); projeto.anotar(p.id, 'trilha', `"${termo}": ${item.titulo}`); break; }
1348
+ }
1349
+ } catch { /* o próximo termo */ }
1350
+ }
1165
1351
  }
1166
1352
  if (estilo === 'impacto' && modo === 'video') {
1167
1353
  // Os emojis das palavras FALADAS também (a batida mostra a fala).
@@ -1197,6 +1383,28 @@ async function prepararRoteiro(p, args) {
1197
1383
  if (r.ok) projeto.anotar(p.id, 'plano', `estilo ${estilo}: ${r.resumo.cenas} cenas, ${r.resumo.segundos}s${precisa ? (g ? ', vetor com os pontos da fonte' : ', vetor sem os pontos (sem a fonte)') : ''}`);
1198
1384
  return r;
1199
1385
  }
1386
+ if (estilo === 'gerativo' && modo === 'video') {
1387
+ /* A música a -18 LUFS (a voz sai a -14): os volumes do plano partem
1388
+ daí — cheia no respiro, uns 9 dB abaixo quando a voz fala. */
1389
+ if (trilha && !/-n18\.mp3$/.test(trilha)) {
1390
+ const norm = path.join(p.pasta, 'midia', path.basename(trilha).replace(/\.[a-z0-9]+$/i, '') + '-n18.mp3');
1391
+ /* A introdução da faixa fica de fora (ela começa morna): corta de 12% em
1392
+ diante, com 0,4 s de entrada, no tamanho do vídeo. */
1393
+ let ini = 0;
1394
+ try { const md = await video.medir(path.join(p.pasta, trilha)); const tot = md.segundos || 0; const quer = (tempos.length ? tempos[tempos.length - 1].ate : 30) + 6; if (tot > quer + 8) ini = Math.min(tot * 0.12, tot - quer - 2); } catch { /* do começo */ }
1395
+ const c = fs.existsSync(norm) ? { ok: true } : await video.rodar(video.ffmpeg(), ['-y', ...(ini > 1 ? ['-ss', ini.toFixed(2)] : []), '-i', path.join(p.pasta, trilha),
1396
+ '-af', 'afade=t=in:st=0:d=0.4,loudnorm=I=-18:TP=-2:LRA=11', '-ar', '44100', norm], { timeout: 5 * 60000 });
1397
+ if (c.ok && fs.existsSync(norm)) trilha = path.relative(p.pasta, norm).replace(/\\/g, '/');
1398
+ }
1399
+ const r = gerativo.montarGerativo({ roteiro, largura, altura, tempos, narracao, sons, trilha });
1400
+ if (r.ok) {
1401
+ const feitos = sonsLeves.prepararReceitas(p.pasta, r.somReceitas);
1402
+ // Som que o ffmpeg não conseguiu fazer sai do plano (o vídeo não espera um arquivo que não existe).
1403
+ r.plano.audio.efeitos = r.plano.audio.efeitos.filter((e) => Object.values(feitos).includes(e.src));
1404
+ }
1405
+ if (r.ok) projeto.anotar(p.id, 'plano', `estilo gerativo: ${r.resumo.batidas} batidas (${r.resumo.composicoes.join(' → ')}), ${r.resumo.segundos}s, semente ${r.resumo.semente}`);
1406
+ return r;
1407
+ }
1200
1408
  if (estilo === 'motion' && modo === 'video') {
1201
1409
  const r = motion.montarMotion({ roteiro, largura, altura, tempos, narracao, sons, trilha, imagens });
1202
1410
  if (r.ok) projeto.anotar(p.id, 'plano', `estilo motion: ${r.resumo.segundos}s contínuos, ${r.resumo.partes} partes, ${r.resumo.fps} fps`);