primocode 8.42.2 → 9.1.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 (104) 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 +42 -195
  7. package/lib/catalogo.js +100 -61
  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 +576 -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 +644 -0
  20. package/lib/estudio/slides.js +190 -0
  21. package/lib/estudio/video.js +296 -0
  22. package/lib/fala.js +49 -6
  23. package/lib/marca.js +4 -1
  24. package/lib/markdown.js +376 -0
  25. package/lib/marketplace.js +189 -0
  26. package/lib/pasta.js +15 -4
  27. package/lib/permissao.js +137 -0
  28. package/lib/registro.js +229 -0
  29. package/lib/skills.js +329 -0
  30. package/lib/tools.js +106 -187
  31. package/lib/ui.js +7 -3
  32. package/lib/voz.js +239 -0
  33. package/package.json +12 -3
  34. package/skills/acessibilidade/SKILL.md +28 -0
  35. package/skills/api-que-nao-quebra/SKILL.md +27 -0
  36. package/skills/depurar-de-verdade/SKILL.md +29 -0
  37. package/skills/editar-meu-video/SKILL.md +59 -0
  38. package/skills/escrever-em-portugues/SKILL.md +28 -0
  39. package/skills/git-limpo/SKILL.md +28 -0
  40. package/skills/motion-de-primeira/SKILL.md +62 -0
  41. package/skills/pagina-que-converte/SKILL.md +28 -0
  42. package/skills/performance-web/SKILL.md +28 -0
  43. package/skills/revisao-antes-de-entregar/SKILL.md +27 -0
  44. package/skills/slides-que-prendem/SKILL.md +40 -0
  45. package/studio/edge.py +16 -66
  46. package/studio/narrar.py +274 -0
  47. package/studio/pessoa.py +199 -0
  48. package/voz/primo_voz/__init__.py +0 -0
  49. package/voz/primo_voz/__main__.py +383 -0
  50. package/voz/primo_voz/escuta.py +235 -0
  51. package/voz/primo_voz/orbe.py +216 -0
  52. package/lib/audio-livre.js +0 -177
  53. package/lib/chaves.js +0 -149
  54. package/lib/claude-engine.js +0 -631
  55. package/lib/codex-engine.js +0 -208
  56. package/lib/remotion.js +0 -663
  57. package/lib/studio.js +0 -960
  58. package/studio/PRIMOCODE.md +0 -58
  59. package/studio/README.md +0 -154
  60. package/studio/__pycache__/edge.cpython-311.pyc +0 -0
  61. package/studio/__pycache__/elevenlabs.cpython-311.pyc +0 -0
  62. package/studio/__pycache__/voz.cpython-311.pyc +0 -0
  63. package/studio/arquivos/corte-teste-de-camera-fb823.json +0 -57
  64. package/studio/ferramentas/00-protocolo.md +0 -125
  65. package/studio/ferramentas/01-modelos.md +0 -270
  66. package/studio/ferramentas/README.md +0 -32
  67. package/studio/ferramentas/corte.md +0 -919
  68. package/studio/ferramentas/grade.md +0 -92
  69. package/studio/ferramentas/prisma.md +0 -107
  70. package/studio/ferramentas/prosa.md +0 -86
  71. package/studio/ferramentas/tela.md +0 -93
  72. package/studio/ferramentas/traco.md +0 -79
  73. package/studio/server.py +0 -2254
  74. package/studio/studio.log +0 -57
  75. package/studio/voz.py +0 -388
  76. package/studio/web/analise.html +0 -52
  77. package/studio/web/core.css +0 -1167
  78. package/studio/web/fontes/bebas.woff2 +0 -0
  79. package/studio/web/fontes/grotesk-700.woff2 +0 -0
  80. package/studio/web/fontes/inter-400.woff2 +0 -0
  81. package/studio/web/fontes/inter-700.woff2 +0 -0
  82. package/studio/web/fontes/jetbrains-400.woff2 +0 -0
  83. package/studio/web/fontes/playfair-700.woff2 +0 -0
  84. package/studio/web/fontes.css +0 -23
  85. package/studio/web/home.html +0 -217
  86. package/studio/web/js/analise.js +0 -510
  87. package/studio/web/js/audio.js +0 -607
  88. package/studio/web/js/editor.js +0 -1802
  89. package/studio/web/js/exportar.js +0 -222
  90. package/studio/web/js/ferramentas.js +0 -108
  91. package/studio/web/js/icones.js +0 -120
  92. package/studio/web/js/midia.js +0 -157
  93. package/studio/web/js/narracao.js +0 -325
  94. package/studio/web/js/player.js +0 -903
  95. package/studio/web/js/render-grade.js +0 -243
  96. package/studio/web/js/render-prosa.js +0 -104
  97. package/studio/web/js/render.js +0 -1852
  98. package/studio/web/js/sfx.js +0 -233
  99. package/studio/web/js/video.js +0 -464
  100. package/studio/web/js/voz-neural.js +0 -152
  101. package/studio/web/render.css +0 -1352
  102. package/studio/web/tf.html +0 -15
  103. package/studio/web/tool.html +0 -78
  104. 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
@@ -38,6 +38,7 @@
38
38
  'use strict';
39
39
 
40
40
  const { c, actionHeader, friendlyError } = require('./ui');
41
+ const markdown = require('./markdown.js');
41
42
 
42
43
  /* Até este tamanho, o texto que o modelo escreve antes de chamar uma tool é
43
44
  guardado e sai de uma vez, como pensamento. Passou disso sem tool à vista,
@@ -73,14 +74,34 @@ function resultado(profundidade) {
73
74
  return ident(profundidade) + c.muted(' ⎿ ');
74
75
  }
75
76
 
77
+ /* O MESMO recuo, sem o gancho e sem cor — para blocos de várias linhas
78
+ (o diff) que sentam embaixo do resultado.
79
+ *
80
+ * Não dá para obter isto tirando o `⎿` do `resultado()` com um replace: aquela
81
+ * string termina em código de cor, então o `⎿` não está no fim e a troca não
82
+ * pega. Medido: o diff saiu com um `⎿` em TODA linha, como se cada uma fosse
83
+ * um resultado novo. Duas funções, uma verdade só — a largura. */
84
+ const LARGURA_RESULTADO = 6; // ' ⎿ '
85
+ function recuo(profundidade) {
86
+ return ident(profundidade) + ' '.repeat(LARGURA_RESULTADO);
87
+ }
88
+
76
89
  function formatar(texto, marca) {
77
90
  return linhas(texto).map((l, i) => (i === 0 ? `${marca} ${c.white(l)}` : ` ${c.white(l)}`)).join('\n');
78
91
  }
79
92
 
80
- /** A resposta final, pronta. Espelha o ❯ de quem perguntou. */
93
+ /** A resposta final, pronta. Espelha o ❯ de quem perguntou.
94
+ *
95
+ * Passa pelo desenhador de Markdown pelo mesmo motivo do fluxo: o modelo
96
+ * escreve com negrito, lista, tabela e bloco de código, e sem desenhar isso
97
+ * a resposta chega com a marcação à mostra. `formatar` continua servindo ao
98
+ * que NÃO é resposta do modelo (aviso, erro), que é texto nosso e já vem
99
+ * pronto. */
81
100
  function resposta(texto) {
82
101
  if (!String(texto || '').trim()) return '';
83
- const saida = '\n' + formatar(texto, c.brand('●'));
102
+ const largura = Math.max(40, (process.stdout.columns || 80) - 4);
103
+ const linhasMd = markdown.renderizar(texto, largura);
104
+ const saida = '\n' + linhasMd.map((l, i) => (i === 0 ? `${c.brand('●')} ${l}` : ` ${l}`)).join('\n');
84
105
  console.log(saida);
85
106
  return saida;
86
107
  }
@@ -108,15 +129,37 @@ const frase = friendlyError;
108
129
  * recuo, para o bloco continuar alinhado ao marcador.
109
130
  */
110
131
  function fluxo() {
132
+ /* A resposta do modelo vem em MARKDOWN — é o que ele escreve, e é o que o
133
+ prompt pede. Antes ela saía crua: "**importante**" aparecia com os
134
+ asteriscos, a tabela virava um monte de barras desalinhadas e o bloco de
135
+ código vinha com as três crases à mostra. Quem lia recebia o material
136
+ bruto e formatava de cabeça.
137
+
138
+ O desenho é por LINHA, não no fim: a resposta continua aparecendo
139
+ enquanto chega. Só a tabela e o bloco de código esperam fechar, porque
140
+ meia tabela na tela é pior que a tabela um segundo depois. */
111
141
  let escreveu = false;
142
+ const largura = Math.max(40, (process.stdout.columns || 80) - 4);
143
+ const desenho = markdown.fluxo(largura);
144
+
145
+ const sair = (linhas) => {
146
+ for (const linha of linhas) {
147
+ if (!escreveu) { process.stdout.write('\n' + c.brand('●') + ' '); escreveu = true; }
148
+ else process.stdout.write('\n ');
149
+ process.stdout.write(linha);
150
+ }
151
+ };
152
+
112
153
  return {
113
154
  escrever(delta) {
114
155
  const t = String(delta || '');
115
156
  if (!t) return;
116
- if (!escreveu) { process.stdout.write('\n' + c.brand('●') + ' '); escreveu = true; }
117
- process.stdout.write(c.white(t.replace(/\n/g, '\n ')));
157
+ sair(desenho.pedaco(t));
158
+ },
159
+ fechar() {
160
+ sair(desenho.fim());
161
+ if (escreveu) process.stdout.write('\n');
118
162
  },
119
- fechar() { if (escreveu) process.stdout.write('\n'); },
120
163
  escreveu: () => escreveu,
121
164
  };
122
165
  }
@@ -143,4 +186,4 @@ function agenteVoltou(resumo, profundidade) {
143
186
  return saida;
144
187
  }
145
188
 
146
- module.exports = { LIMIAR_PENSAMENTO, pensamento, acao, resultado, resposta, aviso, problema, frase, fluxo, paralelo, agente, agenteVoltou };
189
+ 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,