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.
- package/README.md +41 -37
- package/bin/primocode.js +598 -360
- package/lib/acervo.js +8 -1
- package/lib/act.js +205 -114
- package/lib/api.js +35 -4
- package/lib/boas-vindas.js +31 -192
- package/lib/catalogo.js +60 -60
- package/lib/config.js +1 -1
- package/lib/conta.js +3 -24
- package/lib/diff.js +161 -0
- package/lib/diretor.js +37 -47
- package/lib/entrada.js +545 -0
- package/lib/especialistas.js +2 -2
- package/lib/estudio/assets.js +343 -0
- package/lib/estudio/identidade.js +560 -0
- package/lib/estudio/index.js +565 -0
- package/lib/estudio/plano.js +327 -0
- package/lib/estudio/projeto.js +246 -0
- package/lib/estudio/remotion.js +606 -0
- package/lib/estudio/slides.js +190 -0
- package/lib/estudio/video.js +296 -0
- package/lib/fala.js +13 -1
- package/lib/marca.js +4 -1
- package/lib/marketplace.js +189 -0
- package/lib/permissao.js +137 -0
- package/lib/registro.js +229 -0
- package/lib/skills.js +329 -0
- package/lib/tools.js +51 -181
- package/lib/ui.js +7 -3
- package/lib/voz.js +239 -0
- package/package.json +12 -3
- package/skills/acessibilidade/SKILL.md +28 -0
- package/skills/api-que-nao-quebra/SKILL.md +27 -0
- package/skills/depurar-de-verdade/SKILL.md +29 -0
- package/skills/editar-meu-video/SKILL.md +59 -0
- package/skills/escrever-em-portugues/SKILL.md +28 -0
- package/skills/git-limpo/SKILL.md +28 -0
- package/skills/motion-de-primeira/SKILL.md +62 -0
- package/skills/pagina-que-converte/SKILL.md +28 -0
- package/skills/performance-web/SKILL.md +28 -0
- package/skills/revisao-antes-de-entregar/SKILL.md +27 -0
- package/skills/slides-que-prendem/SKILL.md +40 -0
- package/studio/edge.py +16 -66
- package/studio/narrar.py +274 -0
- package/studio/pessoa.py +167 -0
- package/voz/primo_voz/__init__.py +0 -0
- package/voz/primo_voz/__main__.py +325 -0
- package/voz/primo_voz/escuta.py +235 -0
- package/voz/primo_voz/orbe.py +216 -0
- package/lib/audio-livre.js +0 -177
- package/lib/chaves.js +0 -149
- package/lib/claude-engine.js +0 -631
- package/lib/codex-engine.js +0 -208
- package/lib/remotion.js +0 -663
- package/lib/studio.js +0 -960
- package/studio/PRIMOCODE.md +0 -58
- package/studio/README.md +0 -154
- package/studio/__pycache__/edge.cpython-311.pyc +0 -0
- package/studio/__pycache__/elevenlabs.cpython-311.pyc +0 -0
- package/studio/__pycache__/voz.cpython-311.pyc +0 -0
- package/studio/arquivos/corte-teste-de-camera-fb823.json +0 -57
- package/studio/ferramentas/00-protocolo.md +0 -125
- package/studio/ferramentas/01-modelos.md +0 -270
- package/studio/ferramentas/README.md +0 -32
- package/studio/ferramentas/corte.md +0 -919
- package/studio/ferramentas/grade.md +0 -92
- package/studio/ferramentas/prisma.md +0 -107
- package/studio/ferramentas/prosa.md +0 -86
- package/studio/ferramentas/tela.md +0 -93
- package/studio/ferramentas/traco.md +0 -79
- package/studio/server.py +0 -2254
- package/studio/studio.log +0 -57
- package/studio/voz.py +0 -388
- package/studio/web/analise.html +0 -52
- package/studio/web/core.css +0 -1167
- package/studio/web/fontes/bebas.woff2 +0 -0
- package/studio/web/fontes/grotesk-700.woff2 +0 -0
- package/studio/web/fontes/inter-400.woff2 +0 -0
- package/studio/web/fontes/inter-700.woff2 +0 -0
- package/studio/web/fontes/jetbrains-400.woff2 +0 -0
- package/studio/web/fontes/playfair-700.woff2 +0 -0
- package/studio/web/fontes.css +0 -23
- package/studio/web/home.html +0 -217
- package/studio/web/js/analise.js +0 -510
- package/studio/web/js/audio.js +0 -607
- package/studio/web/js/editor.js +0 -1802
- package/studio/web/js/exportar.js +0 -222
- package/studio/web/js/ferramentas.js +0 -108
- package/studio/web/js/icones.js +0 -120
- package/studio/web/js/midia.js +0 -157
- package/studio/web/js/narracao.js +0 -325
- package/studio/web/js/player.js +0 -903
- package/studio/web/js/render-grade.js +0 -243
- package/studio/web/js/render-prosa.js +0 -104
- package/studio/web/js/render.js +0 -1852
- package/studio/web/js/sfx.js +0 -233
- package/studio/web/js/video.js +0 -464
- package/studio/web/js/voz-neural.js +0 -152
- package/studio/web/render.css +0 -1352
- package/studio/web/tf.html +0 -15
- package/studio/web/tool.html +0 -78
- 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
|
-
|
|
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,
|