primocode 8.42.2 → 9.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (102) hide show
  1. package/README.md +41 -37
  2. package/bin/primocode.js +598 -360
  3. package/lib/acervo.js +8 -1
  4. package/lib/act.js +205 -114
  5. package/lib/api.js +35 -4
  6. package/lib/boas-vindas.js +31 -192
  7. package/lib/catalogo.js +60 -60
  8. package/lib/config.js +1 -1
  9. package/lib/conta.js +3 -24
  10. package/lib/diff.js +161 -0
  11. package/lib/diretor.js +37 -47
  12. package/lib/entrada.js +545 -0
  13. package/lib/especialistas.js +2 -2
  14. package/lib/estudio/assets.js +343 -0
  15. package/lib/estudio/identidade.js +560 -0
  16. package/lib/estudio/index.js +565 -0
  17. package/lib/estudio/plano.js +327 -0
  18. package/lib/estudio/projeto.js +246 -0
  19. package/lib/estudio/remotion.js +606 -0
  20. package/lib/estudio/slides.js +190 -0
  21. package/lib/estudio/video.js +296 -0
  22. package/lib/fala.js +13 -1
  23. package/lib/marca.js +4 -1
  24. package/lib/marketplace.js +189 -0
  25. package/lib/permissao.js +137 -0
  26. package/lib/registro.js +229 -0
  27. package/lib/skills.js +329 -0
  28. package/lib/tools.js +51 -181
  29. package/lib/ui.js +7 -3
  30. package/lib/voz.js +239 -0
  31. package/package.json +12 -3
  32. package/skills/acessibilidade/SKILL.md +28 -0
  33. package/skills/api-que-nao-quebra/SKILL.md +27 -0
  34. package/skills/depurar-de-verdade/SKILL.md +29 -0
  35. package/skills/editar-meu-video/SKILL.md +59 -0
  36. package/skills/escrever-em-portugues/SKILL.md +28 -0
  37. package/skills/git-limpo/SKILL.md +28 -0
  38. package/skills/motion-de-primeira/SKILL.md +62 -0
  39. package/skills/pagina-que-converte/SKILL.md +28 -0
  40. package/skills/performance-web/SKILL.md +28 -0
  41. package/skills/revisao-antes-de-entregar/SKILL.md +27 -0
  42. package/skills/slides-que-prendem/SKILL.md +40 -0
  43. package/studio/edge.py +16 -66
  44. package/studio/narrar.py +274 -0
  45. package/studio/pessoa.py +167 -0
  46. package/voz/primo_voz/__init__.py +0 -0
  47. package/voz/primo_voz/__main__.py +325 -0
  48. package/voz/primo_voz/escuta.py +235 -0
  49. package/voz/primo_voz/orbe.py +216 -0
  50. package/lib/audio-livre.js +0 -177
  51. package/lib/chaves.js +0 -149
  52. package/lib/claude-engine.js +0 -631
  53. package/lib/codex-engine.js +0 -208
  54. package/lib/remotion.js +0 -663
  55. package/lib/studio.js +0 -960
  56. package/studio/PRIMOCODE.md +0 -58
  57. package/studio/README.md +0 -154
  58. package/studio/__pycache__/edge.cpython-311.pyc +0 -0
  59. package/studio/__pycache__/elevenlabs.cpython-311.pyc +0 -0
  60. package/studio/__pycache__/voz.cpython-311.pyc +0 -0
  61. package/studio/arquivos/corte-teste-de-camera-fb823.json +0 -57
  62. package/studio/ferramentas/00-protocolo.md +0 -125
  63. package/studio/ferramentas/01-modelos.md +0 -270
  64. package/studio/ferramentas/README.md +0 -32
  65. package/studio/ferramentas/corte.md +0 -919
  66. package/studio/ferramentas/grade.md +0 -92
  67. package/studio/ferramentas/prisma.md +0 -107
  68. package/studio/ferramentas/prosa.md +0 -86
  69. package/studio/ferramentas/tela.md +0 -93
  70. package/studio/ferramentas/traco.md +0 -79
  71. package/studio/server.py +0 -2254
  72. package/studio/studio.log +0 -57
  73. package/studio/voz.py +0 -388
  74. package/studio/web/analise.html +0 -52
  75. package/studio/web/core.css +0 -1167
  76. package/studio/web/fontes/bebas.woff2 +0 -0
  77. package/studio/web/fontes/grotesk-700.woff2 +0 -0
  78. package/studio/web/fontes/inter-400.woff2 +0 -0
  79. package/studio/web/fontes/inter-700.woff2 +0 -0
  80. package/studio/web/fontes/jetbrains-400.woff2 +0 -0
  81. package/studio/web/fontes/playfair-700.woff2 +0 -0
  82. package/studio/web/fontes.css +0 -23
  83. package/studio/web/home.html +0 -217
  84. package/studio/web/js/analise.js +0 -510
  85. package/studio/web/js/audio.js +0 -607
  86. package/studio/web/js/editor.js +0 -1802
  87. package/studio/web/js/exportar.js +0 -222
  88. package/studio/web/js/ferramentas.js +0 -108
  89. package/studio/web/js/icones.js +0 -120
  90. package/studio/web/js/midia.js +0 -157
  91. package/studio/web/js/narracao.js +0 -325
  92. package/studio/web/js/player.js +0 -903
  93. package/studio/web/js/render-grade.js +0 -243
  94. package/studio/web/js/render-prosa.js +0 -104
  95. package/studio/web/js/render.js +0 -1852
  96. package/studio/web/js/sfx.js +0 -233
  97. package/studio/web/js/video.js +0 -464
  98. package/studio/web/js/voz-neural.js +0 -152
  99. package/studio/web/render.css +0 -1352
  100. package/studio/web/tf.html +0 -15
  101. package/studio/web/tool.html +0 -78
  102. package/studio/web/view.html +0 -27
@@ -0,0 +1,190 @@
1
+ /**
2
+ * slides.js — o mesmo vocabulário, parado no tempo.
3
+ *
4
+ * "O Studio é completo: também gera apresentações de slides e os demais
5
+ * formatos que já existem no repositório hoje. Para todos eles vale a mesma
6
+ * regra: remover modelos prontos, elevar muito o nível visual."
7
+ *
8
+ * ── POR QUE SLIDE NÃO GANHOU MÓDULO PRÓPRIO ──────────────────────────────
9
+ * O Studio antigo tinha seis ferramentas — prisma (slides), tela (arte), prosa
10
+ * (documento), grade (planilha), corte (vídeo), traço (diagrama) —, cada uma
11
+ * com o seu formato de documento, a sua spec e os seus layouts. Seis
12
+ * vocabulários para o agente aprender, seis para manter, e cinco deles quase
13
+ * iguais: todos eram "coisas posicionadas num retângulo".
14
+ *
15
+ * Um slide É uma cena parada. Um post é uma cena parada. Uma capa é uma cena
16
+ * parada. O que muda entre eles é o tamanho do retângulo e o fato de não
17
+ * haver tempo — e isso não justifica um segundo vocabulário, muito menos
18
+ * cinco.
19
+ *
20
+ * Então slide usa o MESMO plano do vídeo: as mesmas primitivas, as mesmas
21
+ * cores, as mesmas fontes, a mesma ausência de layout pronto. Cada cena vira
22
+ * uma página. A animação simplesmente não é usada — e quando é (um GIF, um
23
+ * slide que se move numa tela de evento), ela continua lá, de graça.
24
+ *
25
+ * ── O PDF É ESCRITO À MÃO, E ISSO É MAIS SIMPLES DO QUE PARECE ───────────
26
+ * Um PDF de imagens é um arquivo de texto com alguns objetos numerados e uma
27
+ * tabela de deslocamentos no fim. São ~80 linhas. A alternativa era uma
28
+ * dependência de alguns megabytes num produto cujo `npm i -g` termina em
29
+ * segundos — e que não tem dependência nenhuma, de propósito.
30
+ *
31
+ * JPEG entra no PDF SEM recodificar: o formato aceita o fluxo do arquivo como
32
+ * está (DCTDecode). Então montar o PDF é copiar bytes, não redesenhar.
33
+ */
34
+
35
+ 'use strict';
36
+
37
+ const fs = require('fs');
38
+ const path = require('path');
39
+ const { spawn } = require('child_process');
40
+
41
+ /**
42
+ * Renderiza cada cena como uma imagem parada.
43
+ *
44
+ * `remotion still` desenha UM quadro. O quadro escolhido é o do MEIO da cena,
45
+ * não o primeiro: quem escreveu animação de entrada — e quase todo mundo
46
+ * escreve — teria a primeira página em branco, com tudo ainda fora do quadro.
47
+ */
48
+ function renderizarPaginas({ raiz, destino, plano, aoFalar = () => {} }) {
49
+ return new Promise((resolve) => {
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
+ }
63
+
64
+ const paginas = [];
65
+ 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
+
77
+ let erro = '';
78
+ p.stderr.on('data', (d) => { erro += d.toString().slice(0, 1000); });
79
+ const relogio = setTimeout(() => { try { p.kill(); } catch {} }, 5 * 60000);
80
+ p.on('close', (code) => {
81
+ 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);
86
+ correr(i + 1);
87
+ });
88
+ p.on('error', (e) => { clearTimeout(relogio); resolve({ ok: false, error: e.message }); });
89
+ };
90
+ correr(0);
91
+ });
92
+ }
93
+
94
+ // ── o PDF ────────────────────────────────────────────────────────────────
95
+
96
+ /** Largura e altura de um JPEG, lidas do próprio arquivo. */
97
+ function medirJpeg(dados) {
98
+ let i = 2; // pula o SOI (FFD8)
99
+ while (i < dados.length) {
100
+ if (dados[i] !== 0xff) { i++; continue; }
101
+ const marca = dados[i + 1];
102
+ // Os marcadores SOFn carregam as dimensões. Os quatro que não são
103
+ // SOF (C4 tabela de Huffman, C8, CC) ficam de fora de propósito.
104
+ if (marca >= 0xc0 && marca <= 0xcf && marca !== 0xc4 && marca !== 0xc8 && marca !== 0xcc) {
105
+ return { altura: dados.readUInt16BE(i + 5), largura: dados.readUInt16BE(i + 7) };
106
+ }
107
+ i += 2 + dados.readUInt16BE(i + 2);
108
+ }
109
+ return null;
110
+ }
111
+
112
+ /**
113
+ * Junta as imagens num PDF, uma por página.
114
+ *
115
+ * A estrutura: um catálogo, uma lista de páginas, e por página um objeto de
116
+ * página + um de conteúdo + a imagem. A tabela `xref` no fim diz em que byte
117
+ * cada objeto começa — é por ela que o leitor de PDF acha as coisas, e é a
118
+ * única parte em que um erro de contagem estraga o arquivo inteiro.
119
+ */
120
+ function montarPDF(imagens, saida) {
121
+ const objetos = [];
122
+ const pedacos = [];
123
+ let deslocamento = 0;
124
+
125
+ const escrever = (buf) => {
126
+ pedacos.push(buf);
127
+ deslocamento += buf.length;
128
+ };
129
+ const objeto = (corpo, fluxo = null) => {
130
+ const n = objetos.length + 1;
131
+ objetos.push(deslocamento);
132
+ escrever(Buffer.from(`${n} 0 obj\n${corpo}\n`, 'latin1'));
133
+ if (fluxo) {
134
+ escrever(Buffer.from('stream\n', 'latin1'));
135
+ escrever(fluxo);
136
+ escrever(Buffer.from('\nendstream\n', 'latin1'));
137
+ }
138
+ escrever(Buffer.from('endobj\n', 'latin1'));
139
+ return n;
140
+ };
141
+
142
+ escrever(Buffer.from('%PDF-1.4\n%\xe2\xe3\xcf\xd3\n', 'latin1'));
143
+
144
+ // 1 é o catálogo e 2 é a árvore de páginas; os dois precisam existir antes
145
+ // de as páginas saberem a quem pertencem, então ficam reservados.
146
+ objetos.push(0, 0);
147
+ const paginas = [];
148
+
149
+ for (const arquivo of imagens) {
150
+ const dados = fs.readFileSync(arquivo);
151
+ const medida = medirJpeg(dados);
152
+ if (!medida) return { ok: false, error: `não consegui ler as dimensões de ${path.basename(arquivo)}` };
153
+
154
+ const img = objeto(
155
+ `<< /Type /XObject /Subtype /Image /Width ${medida.largura} /Height ${medida.altura} `
156
+ + `/ColorSpace /DeviceRGB /BitsPerComponent 8 /Filter /DCTDecode /Length ${dados.length} >>`,
157
+ dados);
158
+
159
+ // A página tem o TAMANHO da imagem: apresentação não é papel A4, e
160
+ // encaixar um 1920×1080 numa folha de carta deixaria margem branca
161
+ // em cima e embaixo de toda página.
162
+ const conteudo = Buffer.from(
163
+ `q\n${medida.largura} 0 0 ${medida.altura} 0 0 cm\n/Im0 Do\nQ\n`, 'latin1');
164
+ const fluxo = objeto(`<< /Length ${conteudo.length} >>`, conteudo);
165
+ const pagina = objeto(
166
+ `<< /Type /Page /Parent 2 0 R /MediaBox [0 0 ${medida.largura} ${medida.altura}] `
167
+ + `/Resources << /XObject << /Im0 ${img} 0 R >> >> /Contents ${fluxo} 0 R >>`);
168
+ paginas.push(pagina);
169
+ }
170
+
171
+ // Agora os dois reservados, com os deslocamentos de verdade.
172
+ objetos[0] = deslocamento;
173
+ escrever(Buffer.from('1 0 obj\n<< /Type /Catalog /Pages 2 0 R >>\nendobj\n', 'latin1'));
174
+ objetos[1] = deslocamento;
175
+ escrever(Buffer.from(
176
+ `2 0 obj\n<< /Type /Pages /Kids [${paginas.map((n) => `${n} 0 R`).join(' ')}] `
177
+ + `/Count ${paginas.length} >>\nendobj\n`, 'latin1'));
178
+
179
+ const inicioXref = deslocamento;
180
+ let xref = `xref\n0 ${objetos.length + 1}\n0000000000 65535 f \n`;
181
+ for (const off of objetos) xref += `${String(off).padStart(10, '0')} 00000 n \n`;
182
+ xref += `trailer\n<< /Size ${objetos.length + 1} /Root 1 0 R >>\nstartxref\n${inicioXref}\n%%EOF\n`;
183
+ escrever(Buffer.from(xref, 'latin1'));
184
+
185
+ fs.mkdirSync(path.dirname(saida), { recursive: true });
186
+ fs.writeFileSync(saida, Buffer.concat(pedacos));
187
+ return { ok: true, arquivo: saida, paginas: paginas.length, bytes: fs.statSync(saida).size };
188
+ }
189
+
190
+ module.exports = { renderizarPaginas, montarPDF, medirJpeg };
@@ -0,0 +1,296 @@
1
+ /**
2
+ * video.js — o que se faz com o vídeo que VOCÊ gravou.
3
+ *
4
+ * "Eu subo meus vídeos em uma pasta; a IA trabalha naquela pasta. Cortes de
5
+ * silêncio com ritmo natural, sem ficarem forçados ou robóticos."
6
+ *
7
+ * ── O QUE ESTE MÓDULO É ──────────────────────────────────────────────────
8
+ * Uma camada fina sobre o ffmpeg, com uma regra: ela NÃO decide nada de
9
+ * estética. Ela mede (onde tem silêncio, quanto dura, que tamanho tem) e
10
+ * executa (corta, extrai, junta). Quem decide o que cortar e como montar é o
11
+ * agente, com o plano.
12
+ *
13
+ * ── POR QUE O CORTE DE SILÊNCIO TEM UMA CONTA E NÃO UM BOTÃO ─────────────
14
+ * Cortar todo silêncio acima de X é o que faz vídeo editado por máquina soar
15
+ * como máquina: a fala fica colada, sem ar, e cansa em trinta segundos. O que
16
+ * se ouve como natural é outra coisa — tirar a PAUSA MORTA e deixar a
17
+ * respiração.
18
+ *
19
+ * Três decisões, e cada uma vale um ajuste aqui:
20
+ *
21
+ * 1. só corta silêncio mais longo que `minimoParaCortar` (padrão 0,6s):
22
+ * pausa curta é ritmo de fala, não erro;
23
+ * 2. quando corta, DEIXA `respiro` (padrão 0,18s) em cada ponta, em vez de
24
+ * emendar no exato instante em que a voz volta;
25
+ * 3. nunca corta o trecho inteiro: sobra sempre um mínimo, senão duas
26
+ * frases seguidas grudam sem intervalo nenhum.
27
+ *
28
+ * Os números são o ponto de partida. O que não pode existir é o botão "cortar
29
+ * silêncio" sem eles.
30
+ *
31
+ * ── E QUANDO NÃO HÁ ffmpeg ───────────────────────────────────────────────
32
+ * Sem ffmpeg não há edição de vídeo, e isso precisa ser dito na hora, com o
33
+ * comando de instalar — e não descoberto por um erro de processo no meio da
34
+ * tarefa. `conferir()` é a primeira coisa que o agente chama.
35
+ */
36
+
37
+ 'use strict';
38
+
39
+ const fs = require('fs');
40
+ const os = require('os');
41
+ const path = require('path');
42
+ const { spawn, execFileSync } = require('child_process');
43
+
44
+ // ── o ffmpeg existe? ─────────────────────────────────────────────────────
45
+
46
+ function achar(nome) {
47
+ const candidatos = process.platform === 'win32'
48
+ ? [nome + '.exe', nome]
49
+ : [nome, '/usr/bin/' + nome, '/usr/local/bin/' + nome, '/opt/homebrew/bin/' + nome];
50
+ for (const c of candidatos) {
51
+ try {
52
+ execFileSync(c, ['-version'], { stdio: 'ignore', timeout: 5000 });
53
+ return c;
54
+ } catch { /* próximo */ }
55
+ }
56
+ return null;
57
+ }
58
+
59
+ let _ffmpeg = null;
60
+ let _ffprobe = null;
61
+ const ffmpeg = () => (_ffmpeg ??= achar('ffmpeg'));
62
+ const ffprobe = () => (_ffprobe ??= achar('ffprobe'));
63
+
64
+ const COMO_INSTALAR = {
65
+ darwin: 'brew install ffmpeg',
66
+ linux: 'sudo apt install ffmpeg (ou o gerenciador da sua distribuição)',
67
+ win32: 'winget install Gyan.FFmpeg',
68
+ };
69
+
70
+ function conferir() {
71
+ if (ffmpeg() && ffprobe()) return { ok: true, ffmpeg: ffmpeg(), ffprobe: ffprobe() };
72
+ return {
73
+ ok: false,
74
+ error: 'O ffmpeg não está instalado, e sem ele não há edição de vídeo.',
75
+ comoResolver: COMO_INSTALAR[process.platform] || 'instale o ffmpeg pelo gerenciador do seu sistema',
76
+ };
77
+ }
78
+
79
+ function rodar(bin, args, { timeout = 15 * 60000 } = {}) {
80
+ return new Promise((resolve) => {
81
+ const p = spawn(bin, args);
82
+ let saida = '', erro = '';
83
+ p.stdout.on('data', (d) => { saida += d.toString(); });
84
+ p.stderr.on('data', (d) => { erro += d.toString(); });
85
+ const relogio = setTimeout(() => { try { p.kill(); } catch {} }, timeout);
86
+ p.on('close', (code) => {
87
+ clearTimeout(relogio);
88
+ resolve({ ok: code === 0, code, saida, erro });
89
+ });
90
+ p.on('error', (e) => { clearTimeout(relogio); resolve({ ok: false, code: -1, saida: '', erro: e.message }); });
91
+ });
92
+ }
93
+
94
+ // ── medir ────────────────────────────────────────────────────────────────
95
+
96
+ /** Duração, tamanho, fps, se tem áudio. */
97
+ async function medir(arquivo) {
98
+ const c = conferir();
99
+ if (!c.ok) return c;
100
+ const r = await rodar(ffprobe(), [
101
+ '-v', 'error', '-print_format', 'json',
102
+ '-show_format', '-show_streams', arquivo,
103
+ ], { timeout: 60000 });
104
+ if (!r.ok) return { ok: false, error: `não consegui ler "${path.basename(arquivo)}": ${r.erro.slice(-200)}` };
105
+ let j;
106
+ try { j = JSON.parse(r.saida); } catch { return { ok: false, error: 'o ffprobe respondeu algo ilegível' }; }
107
+
108
+ const video = (j.streams || []).find((s) => s.codec_type === 'video');
109
+ const audio = (j.streams || []).find((s) => s.codec_type === 'audio');
110
+ const [num, den] = String((video && video.r_frame_rate) || '0/1').split('/').map(Number);
111
+ return {
112
+ ok: true,
113
+ segundos: Number((j.format || {}).duration) || 0,
114
+ bytes: Number((j.format || {}).size) || 0,
115
+ largura: video ? video.width : 0,
116
+ altura: video ? video.height : 0,
117
+ fps: den ? Number((num / den).toFixed(3)) : 0,
118
+ temAudio: !!audio,
119
+ codec: video ? video.codec_name : null,
120
+ };
121
+ }
122
+
123
+ // ── silêncio ─────────────────────────────────────────────────────────────
124
+
125
+ /**
126
+ * Onde há silêncio, segundo o ffmpeg.
127
+ *
128
+ * `-40dB` é o limiar. Mais alto que isso e a respiração entre frases vira
129
+ * "silêncio"; mais baixo e o ruído de fundo de qualquer quarto vira "fala".
130
+ */
131
+ async function silencios(arquivo, { limiarDb = -40, minimo = 0.35 } = {}) {
132
+ const c = conferir();
133
+ if (!c.ok) return c;
134
+ const r = await rodar(ffmpeg(), [
135
+ '-hide_banner', '-i', arquivo,
136
+ '-af', `silencedetect=noise=${limiarDb}dB:d=${minimo}`,
137
+ '-f', 'null', '-',
138
+ ]);
139
+ // O silencedetect escreve na saída de erro, e não em JSON.
140
+ const trechos = [];
141
+ let inicio = null;
142
+ for (const linha of r.erro.split('\n')) {
143
+ const i = linha.match(/silence_start:\s*(-?[\d.]+)/);
144
+ if (i) { inicio = Math.max(0, Number(i[1])); continue; }
145
+ const f = linha.match(/silence_end:\s*([\d.]+)/);
146
+ if (f && inicio != null) {
147
+ trechos.push({ de: inicio, ate: Number(f[1]) });
148
+ inicio = null;
149
+ }
150
+ }
151
+ return { ok: true, silencios: trechos };
152
+ }
153
+
154
+ /**
155
+ * Os trechos que FICAM, depois de tirar as pausas mortas.
156
+ *
157
+ * Função pura: recebe as medidas e devolve a lista. Assim o ritmo — que é a
158
+ * parte que se acerta no ouvido — é testável sem vídeo nenhum.
159
+ */
160
+ function trechosQueFicam(duracao, listaDeSilencios, {
161
+ minimoParaCortar = 0.6, // pausa menor que isto é ritmo de fala, não erro
162
+ respiro = 0.18, // o ar que sobra em cada ponta do corte
163
+ minimoDeTrecho = 0.25, // nada de fatia menor que isto
164
+ } = {}) {
165
+ const cortar = (listaDeSilencios || [])
166
+ .map((s) => ({ de: s.de + respiro, ate: s.ate - respiro }))
167
+ .filter((s) => s.ate - s.de >= minimoParaCortar - respiro * 2)
168
+ .sort((a, b) => a.de - b.de);
169
+
170
+ const ficam = [];
171
+ let cursor = 0;
172
+ for (const s of cortar) {
173
+ const de = Math.max(0, Math.min(s.de, duracao));
174
+ if (de - cursor >= minimoDeTrecho) ficam.push({ de: cursor, ate: de });
175
+ cursor = Math.max(cursor, Math.min(s.ate, duracao));
176
+ }
177
+ if (duracao - cursor >= minimoDeTrecho) ficam.push({ de: cursor, ate: duracao });
178
+ // Nenhum corte aproveitável: melhor devolver o vídeo inteiro do que um
179
+ // arquivo vazio que "tecnicamente" obedeceu ao pedido.
180
+ if (!ficam.length) return [{ de: 0, ate: duracao }];
181
+ return ficam.map((t) => ({ de: Number(t.de.toFixed(3)), ate: Number(t.ate.toFixed(3)) }));
182
+ }
183
+
184
+ /** Corta as pausas mortas e escreve um vídeo novo. */
185
+ async function cortarSilencio(entrada, saida, op = {}) {
186
+ const c = conferir();
187
+ if (!c.ok) return c;
188
+ const m = await medir(entrada);
189
+ if (!m.ok) return m;
190
+ const s = await silencios(entrada, op);
191
+ if (!s.ok) return s;
192
+
193
+ const ficam = trechosQueFicam(m.segundos, s.silencios, op);
194
+ if (ficam.length === 1 && ficam[0].de === 0 && ficam[0].ate >= m.segundos - 0.05) {
195
+ return { ok: true, semCorte: true, trechos: ficam, arquivo: entrada,
196
+ aviso: 'não havia pausa morta para tirar — o vídeo segue inteiro' };
197
+ }
198
+
199
+ /* Um filtro só, em vez de N arquivos temporários e um concat: o ffmpeg
200
+ recodifica uma vez, e não há pasta de sobras para limpar depois. */
201
+ const partes = ficam.map((t, i) =>
202
+ `[0:v]trim=start=${t.de}:end=${t.ate},setpts=PTS-STARTPTS[v${i}];`
203
+ + `[0:a]atrim=start=${t.de}:end=${t.ate},asetpts=PTS-STARTPTS[a${i}];`).join('');
204
+ const junta = ficam.map((_, i) => `[v${i}][a${i}]`).join('');
205
+ const filtro = `${partes}${junta}concat=n=${ficam.length}:v=1:a=1[v][a]`;
206
+
207
+ const r = await rodar(ffmpeg(), [
208
+ '-hide_banner', '-y', '-i', entrada,
209
+ '-filter_complex', filtro, '-map', '[v]', '-map', '[a]',
210
+ '-c:v', 'libx264', '-preset', 'medium', '-crf', '18',
211
+ '-c:a', 'aac', '-b:a', '192k', saida,
212
+ ]);
213
+ if (!r.ok) return { ok: false, error: `o corte falhou: ${r.erro.slice(-400)}` };
214
+
215
+ const depois = await medir(saida);
216
+ return {
217
+ ok: true, arquivo: saida, trechos: ficam,
218
+ antes: Number(m.segundos.toFixed(2)),
219
+ depois: depois.ok ? Number(depois.segundos.toFixed(2)) : null,
220
+ cortes: ficam.length - 1,
221
+ };
222
+ }
223
+
224
+ // ── extrair ──────────────────────────────────────────────────────────────
225
+
226
+ /**
227
+ * O áudio, pequeno, para transcrever.
228
+ *
229
+ * Mono, 16kHz, 32kbps: é o que a transcrição precisa e nada além. Dez minutos
230
+ * cabem em ~2,4 MB — o vídeo original teria centenas. O tamanho importa
231
+ * porque esse arquivo atravessa a rede.
232
+ */
233
+ async function extrairAudio(entrada, saida) {
234
+ const c = conferir();
235
+ if (!c.ok) return c;
236
+ const r = await rodar(ffmpeg(), [
237
+ '-hide_banner', '-y', '-i', entrada,
238
+ '-vn', '-ac', '1', '-ar', '16000', '-b:a', '32k', saida,
239
+ ]);
240
+ if (!r.ok) return { ok: false, error: `não deu para extrair o áudio: ${r.erro.slice(-300)}` };
241
+ const bytes = fs.existsSync(saida) ? fs.statSync(saida).size : 0;
242
+ return { ok: true, arquivo: saida, bytes };
243
+ }
244
+
245
+ /** Um pedaço do vídeo, sem recodificar quando dá. */
246
+ async function recortar(entrada, saida, de, ate) {
247
+ const c = conferir();
248
+ if (!c.ok) return c;
249
+ const r = await rodar(ffmpeg(), [
250
+ '-hide_banner', '-y', '-ss', String(de), '-to', String(ate), '-i', entrada,
251
+ '-c:v', 'libx264', '-preset', 'medium', '-crf', '18', '-c:a', 'aac', saida,
252
+ ]);
253
+ return r.ok ? { ok: true, arquivo: saida, de, ate }
254
+ : { ok: false, error: `o recorte falhou: ${r.erro.slice(-300)}` };
255
+ }
256
+
257
+ /** Um quadro em PNG — para olhar o vídeo, escolher b-roll, medir enquadramento. */
258
+ async function quadro(entrada, saida, segundo = 0) {
259
+ const c = conferir();
260
+ if (!c.ok) return c;
261
+ const r = await rodar(ffmpeg(), [
262
+ '-hide_banner', '-y', '-ss', String(segundo), '-i', entrada,
263
+ '-frames:v', '1', '-q:v', '2', saida,
264
+ ], { timeout: 60000 });
265
+ return r.ok ? { ok: true, arquivo: saida } : { ok: false, error: r.erro.slice(-300) };
266
+ }
267
+
268
+ /**
269
+ * Troca o fundo da pessoa por uma imagem.
270
+ *
271
+ * "Recorte de pessoa: segmentar meu rosto/corpo e trocar o fundo por uma
272
+ * imagem relacionada ao que estou falando."
273
+ *
274
+ * Isto NÃO é feito aqui: segmentação por quadro é trabalho de modelo de
275
+ * visão, e o ffmpeg só sabe fazer chroma key (fundo verde). O caminho real
276
+ * está em `pessoa.py`, que usa o MediaPipe — e esta função existe para
277
+ * costurar o resultado dele (um vídeo com canal alfa) sobre a imagem.
278
+ */
279
+ async function sobreporAlfa({ pessoa, fundo, saida, escala = 1 }) {
280
+ const c = conferir();
281
+ if (!c.ok) return c;
282
+ const r = await rodar(ffmpeg(), [
283
+ '-hide_banner', '-y',
284
+ '-loop', '1', '-i', fundo,
285
+ '-c:v', 'libvpx-vp9', '-i', pessoa,
286
+ '-filter_complex',
287
+ `[1:v]scale=iw*${escala}:-1[p];[0:v][p]overlay=(W-w)/2:(H-h):shortest=1[v]`,
288
+ '-map', '[v]', '-map', '1:a?', '-c:v', 'libx264', '-crf', '18', '-c:a', 'aac', saida,
289
+ ]);
290
+ return r.ok ? { ok: true, arquivo: saida } : { ok: false, error: r.erro.slice(-400) };
291
+ }
292
+
293
+ module.exports = {
294
+ conferir, medir, silencios, trechosQueFicam, cortarSilencio,
295
+ extrairAudio, recortar, quadro, sobreporAlfa, rodar, ffmpeg, ffprobe,
296
+ };
package/lib/fala.js CHANGED
@@ -73,6 +73,18 @@ function resultado(profundidade) {
73
73
  return ident(profundidade) + c.muted(' ⎿ ');
74
74
  }
75
75
 
76
+ /* O MESMO recuo, sem o gancho e sem cor — para blocos de várias linhas
77
+ (o diff) que sentam embaixo do resultado.
78
+ *
79
+ * Não dá para obter isto tirando o `⎿` do `resultado()` com um replace: aquela
80
+ * string termina em código de cor, então o `⎿` não está no fim e a troca não
81
+ * pega. Medido: o diff saiu com um `⎿` em TODA linha, como se cada uma fosse
82
+ * um resultado novo. Duas funções, uma verdade só — a largura. */
83
+ const LARGURA_RESULTADO = 6; // ' ⎿ '
84
+ function recuo(profundidade) {
85
+ return ident(profundidade) + ' '.repeat(LARGURA_RESULTADO);
86
+ }
87
+
76
88
  function formatar(texto, marca) {
77
89
  return linhas(texto).map((l, i) => (i === 0 ? `${marca} ${c.white(l)}` : ` ${c.white(l)}`)).join('\n');
78
90
  }
@@ -143,4 +155,4 @@ function agenteVoltou(resumo, profundidade) {
143
155
  return saida;
144
156
  }
145
157
 
146
- module.exports = { LIMIAR_PENSAMENTO, pensamento, acao, resultado, resposta, aviso, problema, frase, fluxo, paralelo, agente, agenteVoltou };
158
+ module.exports = { LIMIAR_PENSAMENTO, pensamento, acao, resultado, recuo, resposta, aviso, problema, frase, fluxo, paralelo, agente, agenteVoltou };
package/lib/marca.js CHANGED
@@ -67,7 +67,10 @@ function candidatasALogo(html, base) {
67
67
  const id = atributo(tag, 'id') || '';
68
68
  if (/logo|brand|marca/i.test(`${src} ${alt} ${cls} ${id}`) && !/sponsor|partner|cliente|client|parceir/i.test(`${alt} ${cls} ${src}`)) {
69
69
  const u = absoluta(src, base);
70
- if (u && !/^data:/.test(u)) lista.push({ url: u, fonte: 'img-logo', nota: /\.svg(\?|$)/i.test(u) ? 30 : 20 });
70
+ // O `alt` vai junto: "Nubank logo" diz de QUEM é a logo, e o
71
+ // endereço nem sempre diz (CDN devolve hash por nome de arquivo).
72
+ // Quem usa isso é `ordenarPelaMarca`, no estudio/identidade.js.
73
+ if (u && !/^data:/.test(u)) lista.push({ url: u, alt, fonte: 'img-logo', nota: /\.svg(\?|$)/i.test(u) ? 30 : 20 });
71
74
  }
72
75
  }
73
76
  // 1b. SVG inline no cabeçalho com "logo" no nome — não dá para baixar,