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,565 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* estudio/index.js — as ferramentas do Studio, do jeito que o agente as vê.
|
|
3
|
+
*
|
|
4
|
+
* ── O QUE ESTE ARQUIVO SUBSTITUI ─────────────────────────────────────────
|
|
5
|
+
* `lib/studio.js`, 887 linhas cuja maior parte era ligar, manter de pé e
|
|
6
|
+
* conversar por HTTP com um servidor Python que desenhava páginas — e mais o
|
|
7
|
+
* cuidado de abrir uma aba no navegador da pessoa no momento certo, e de não
|
|
8
|
+
* abrir três.
|
|
9
|
+
*
|
|
10
|
+
* Nada disso existe mais. Cada peça é uma PASTA, cada render é o Remotion, e
|
|
11
|
+
* a única coisa que abre no fim é o vídeo pronto.
|
|
12
|
+
*
|
|
13
|
+
* ── AS SETE FERRAMENTAS, E POR QUE SÃO SETE ──────────────────────────────
|
|
14
|
+
* estudio_projeto abrir, achar, listar, lembrar
|
|
15
|
+
* estudio_plano escrever ou ajustar o que o vídeo é
|
|
16
|
+
* estudio_midia procurar e baixar imagem, som, ícone, fonte
|
|
17
|
+
* estudio_narrar a voz, com os tempos medidos
|
|
18
|
+
* estudio_video o que se faz com a gravação: medir, cortar, legendar
|
|
19
|
+
* estudio_render virar arquivo
|
|
20
|
+
* estudio_site ler a identidade de uma marca a partir do link
|
|
21
|
+
*
|
|
22
|
+
* A tentação era uma ferramenta por verbo (criar cena, mover elemento, trocar
|
|
23
|
+
* cor). Isso multiplicaria o catálogo por dez e, pior, faria o modelo montar
|
|
24
|
+
* o vídeo por ajustes — que é exatamente como se produz vídeo genérico. O
|
|
25
|
+
* plano inteiro de uma vez obriga a decidir o vídeo inteiro de uma vez.
|
|
26
|
+
*/
|
|
27
|
+
|
|
28
|
+
'use strict';
|
|
29
|
+
|
|
30
|
+
const fs = require('fs');
|
|
31
|
+
const os = require('os');
|
|
32
|
+
const path = require('path');
|
|
33
|
+
const { spawn } = require('child_process');
|
|
34
|
+
|
|
35
|
+
const projeto = require('./projeto.js');
|
|
36
|
+
const acervo = require('../acervo.js');
|
|
37
|
+
const plano = require('./plano.js');
|
|
38
|
+
const remotion = require('./remotion.js');
|
|
39
|
+
const assets = require('./assets.js');
|
|
40
|
+
const video = require('./video.js');
|
|
41
|
+
const identidade = require('./identidade.js');
|
|
42
|
+
const slides = require('./slides.js');
|
|
43
|
+
|
|
44
|
+
const PY = path.join(__dirname, '..', '..', 'studio');
|
|
45
|
+
|
|
46
|
+
/** Qual python desta máquina. O mesmo problema do Studio antigo, mesma cura. */
|
|
47
|
+
function python() {
|
|
48
|
+
const { execSync } = require('child_process');
|
|
49
|
+
const nomes = process.platform === 'win32'
|
|
50
|
+
? ['python', 'py', 'python3']
|
|
51
|
+
: ['python3', 'python', '/opt/homebrew/bin/python3', '/usr/local/bin/python3', '/usr/bin/python3'];
|
|
52
|
+
for (const n of nomes) {
|
|
53
|
+
try {
|
|
54
|
+
const v = execSync(`${n} --version`, { stdio: ['ignore', 'pipe', 'pipe'], timeout: 5000 }).toString();
|
|
55
|
+
if (/Python 3/.test(v)) return n;
|
|
56
|
+
} catch { /* próximo */ }
|
|
57
|
+
}
|
|
58
|
+
return null;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function rodarPython(script, args, { timeout = 20 * 60000 } = {}) {
|
|
62
|
+
return new Promise((resolve) => {
|
|
63
|
+
const py = python();
|
|
64
|
+
if (!py) {
|
|
65
|
+
return resolve({ ok: false, error: 'não achei o Python 3 nesta máquina.',
|
|
66
|
+
comoResolver: 'instale o Python 3 (python.org) e tente de novo' });
|
|
67
|
+
}
|
|
68
|
+
const p = spawn(py, [path.join(PY, script), ...args]);
|
|
69
|
+
let saida = '', erro = '';
|
|
70
|
+
p.stdout.on('data', (d) => { saida += d.toString(); });
|
|
71
|
+
p.stderr.on('data', (d) => { erro += d.toString(); });
|
|
72
|
+
const relogio = setTimeout(() => { try { p.kill(); } catch {} }, timeout);
|
|
73
|
+
p.on('close', () => {
|
|
74
|
+
clearTimeout(relogio);
|
|
75
|
+
try { resolve(JSON.parse(saida.trim().split('\n').pop())); }
|
|
76
|
+
catch { resolve({ ok: false, error: (erro || saida).slice(-400).trim() || 'o script não respondeu' }); }
|
|
77
|
+
});
|
|
78
|
+
p.on('error', (e) => { clearTimeout(relogio); resolve({ ok: false, error: e.message }); });
|
|
79
|
+
});
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// ── estudio_projeto ──────────────────────────────────────────────────────
|
|
83
|
+
|
|
84
|
+
async function ferramentaProjeto(args) {
|
|
85
|
+
const acao = String(args.acao || 'listar').toLowerCase();
|
|
86
|
+
|
|
87
|
+
if (acao === 'criar' || acao === 'novo') {
|
|
88
|
+
const r = projeto.criar({
|
|
89
|
+
titulo: args.titulo, pedido: args.pedido || args.titulo, formato: args.formato || 'video',
|
|
90
|
+
});
|
|
91
|
+
return { ...r, comoUsar: 'agora escreva o plano com estudio_plano, usando este id.' };
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
if (acao === 'abrir' || acao === 'ler') {
|
|
95
|
+
const p = projeto.ler(args.id);
|
|
96
|
+
if (!p) return { ok: false, error: `não achei o projeto "${args.id}". Use acao:"procurar".` };
|
|
97
|
+
const pl = projeto.lerPlano(args.id);
|
|
98
|
+
return {
|
|
99
|
+
ok: true, projeto: p,
|
|
100
|
+
// O caderno é o que faz "mexe naquela parte" funcionar semanas
|
|
101
|
+
// depois: sem ele, reabrir diz o que existe e nunca por quê.
|
|
102
|
+
caderno: projeto.caderno(args.id, { ultimas: 25 }),
|
|
103
|
+
plano: pl ? plano.resumo(pl) : 'ainda não há plano',
|
|
104
|
+
versoes: projeto.versoes(args.id),
|
|
105
|
+
arquivos: listarArquivos(p.pasta),
|
|
106
|
+
};
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
if (acao === 'procurar' || acao === 'achar') {
|
|
110
|
+
const achados = projeto.procurar(args.texto || args.titulo || '');
|
|
111
|
+
if (!achados.length) return { ok: true, achados: [], aviso: 'nenhum projeto parecido.' };
|
|
112
|
+
return {
|
|
113
|
+
ok: true,
|
|
114
|
+
achados: achados.map((p) => ({ id: p.id, titulo: p.titulo, criado: p.criado.slice(0, 10), pedido: p.pedido.slice(0, 120) })),
|
|
115
|
+
};
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
if (acao === 'anotar') {
|
|
119
|
+
projeto.anotar(args.id, args.tipo || 'nota', args.texto || '');
|
|
120
|
+
return { ok: true };
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
const todos = projeto.listar({ limite: Number(args.limite) || 20 });
|
|
124
|
+
return {
|
|
125
|
+
ok: true,
|
|
126
|
+
projetos: todos.map((p) => ({ id: p.id, titulo: p.titulo, criado: p.criado.slice(0, 10), renders: (p.renders || []).length })),
|
|
127
|
+
};
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
function listarArquivos(pasta) {
|
|
131
|
+
const saida = {};
|
|
132
|
+
for (const sub of ['entrada', 'midia', 'saida']) {
|
|
133
|
+
try {
|
|
134
|
+
saida[sub] = fs.readdirSync(path.join(pasta, sub))
|
|
135
|
+
.filter((f) => !f.endsWith('.licenca.json')).slice(0, 40);
|
|
136
|
+
} catch { saida[sub] = []; }
|
|
137
|
+
}
|
|
138
|
+
return saida;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
// ── estudio_plano ────────────────────────────────────────────────────────
|
|
142
|
+
|
|
143
|
+
async function ferramentaPlano(args) {
|
|
144
|
+
const p = projeto.ler(args.id);
|
|
145
|
+
if (!p) return { ok: false, error: `não achei o projeto "${args.id}".` };
|
|
146
|
+
|
|
147
|
+
if (args.voltarPara) return projeto.voltarPara(args.id, args.voltarPara);
|
|
148
|
+
|
|
149
|
+
if (!args.plano && !args.cenas) {
|
|
150
|
+
const atual = projeto.lerPlano(args.id);
|
|
151
|
+
if (!atual) return { ok: false, error: 'este projeto ainda não tem plano — mande um.' };
|
|
152
|
+
return { ok: true, plano: atual, resumo: plano.resumo(atual) };
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
// Um plano inteiro, ou só as cenas (que é o caso comum).
|
|
156
|
+
let novo = args.plano || { formato: args.formato, cenas: args.cenas, audio: args.audio };
|
|
157
|
+
|
|
158
|
+
/* Ajuste de UMA cena, sem reescrever o resto.
|
|
159
|
+
*
|
|
160
|
+
* É o caminho de "não gostei da parte do cartão": reescrever o plano
|
|
161
|
+
* inteiro para mexer numa cena é como o vídeo antigo mudava de cara
|
|
162
|
+
* sozinho a cada correção. */
|
|
163
|
+
if (args.cena != null) {
|
|
164
|
+
const atual = projeto.lerPlano(args.id);
|
|
165
|
+
if (!atual) return { ok: false, error: 'não há plano para ajustar.' };
|
|
166
|
+
const i = Number(args.cena) - 1;
|
|
167
|
+
if (!(i >= 0 && i < atual.cenas.length)) {
|
|
168
|
+
return { ok: false, error: `a cena ${args.cena} não existe (são ${atual.cenas.length}).` };
|
|
169
|
+
}
|
|
170
|
+
atual.cenas[i] = args.plano || args.novaCena || atual.cenas[i];
|
|
171
|
+
novo = atual;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
const conferida = plano.conferir(novo, { pasta: p.pasta });
|
|
175
|
+
if (!conferida.ok) {
|
|
176
|
+
return {
|
|
177
|
+
ok: false,
|
|
178
|
+
error: 'o plano tem problemas que quebrariam o render — conserte e mande de novo.',
|
|
179
|
+
problemas: conferida.problemas.slice(0, 12),
|
|
180
|
+
};
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
projeto.salvarPlano(args.id, novo, args.motivo || '');
|
|
184
|
+
projeto.salvar(args.id, { duracao: conferida.duracao });
|
|
185
|
+
return {
|
|
186
|
+
ok: true, duracao: conferida.duracao, resumo: plano.resumo(novo),
|
|
187
|
+
proximo: 'estudio_render quando estiver pronto.',
|
|
188
|
+
};
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
// ── estudio_midia ────────────────────────────────────────────────────────
|
|
192
|
+
|
|
193
|
+
async function ferramentaMidia(args) {
|
|
194
|
+
const acao = String(args.acao || 'imagem').toLowerCase();
|
|
195
|
+
const p = args.id ? projeto.ler(args.id) : null;
|
|
196
|
+
if (args.id && !p) return { ok: false, error: `não achei o projeto "${args.id}".` };
|
|
197
|
+
|
|
198
|
+
if (acao === 'fontes') {
|
|
199
|
+
if (args.conferir) return assets.conferirFonte(args.conferir);
|
|
200
|
+
return { ok: true, fontes: assets.FONTES,
|
|
201
|
+
nota: 'qualquer família do Google Fonts funciona — esta lista é para não repetir sempre as mesmas.' };
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
/* Recortar o fundo. O pedido pede "buscar PNGs na internet, remover fundo
|
|
205
|
+
e inserir" — e sem isto o agente colava a foto com o retângulo branco
|
|
206
|
+
dela por cima do fundo da cena, que é o cara-de-recorte que denuncia
|
|
207
|
+
vídeo feito às pressas. O recorte real é modelo de segmentação (rembg);
|
|
208
|
+
sem ele na máquina, sobra o corte por cor, que só serve em fundo
|
|
209
|
+
chapado — e a resposta diz qual dos dois foi usado. */
|
|
210
|
+
if (acao === 'semfundo' || acao === 'sem-fundo' || acao === 'recortar') {
|
|
211
|
+
const alvo = args.arquivo || args.src;
|
|
212
|
+
if (!alvo) return { ok: false, error: 'diga o arquivo (arquivo) da imagem a recortar.' };
|
|
213
|
+
const cheio = p ? path.resolve(p.pasta, alvo) : path.resolve(alvo);
|
|
214
|
+
const r = await acervo.tirarFundo(cheio, { cor: args.cor, tolerancia: args.tolerancia });
|
|
215
|
+
if (!r.ok) return r;
|
|
216
|
+
const relativo = p ? path.relative(p.pasta, r.arquivo).replace(/\\/g, '/') : r.arquivo;
|
|
217
|
+
if (p) projeto.anotar(p.id, 'midia', `recortou o fundo de ${alvo} (${r.metodo})`);
|
|
218
|
+
return { ok: true, src: relativo, metodo: r.metodo, aviso: r.aviso || undefined };
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
if (acao === 'logo') {
|
|
222
|
+
if (!p) return { ok: false, error: 'para baixar a logo é preciso dizer em qual projeto (id).' };
|
|
223
|
+
const nome = String(args.marca || args.nome || args.termo || '').trim();
|
|
224
|
+
if (!nome) return { ok: false, error: 'diga a marca (marca).' };
|
|
225
|
+
let html = '';
|
|
226
|
+
let site = String(args.site || args.url || '').trim();
|
|
227
|
+
if (!site) {
|
|
228
|
+
const achado = await identidade.acharSite(nome);
|
|
229
|
+
if (achado.ok) site = achado.url;
|
|
230
|
+
}
|
|
231
|
+
if (site) {
|
|
232
|
+
if (!/^https?:\/\//i.test(site)) site = 'https://' + site;
|
|
233
|
+
html = (await require('../referencia.js').pegar(site, { timeout: 15000 })) || '';
|
|
234
|
+
}
|
|
235
|
+
const r = await identidade.pegarLogo(html, site || 'https://exemplo.invalido', { pasta: p.pasta, nome });
|
|
236
|
+
if (r.ok) projeto.anotar(p.id, 'midia', `logo de ${nome}: ${r.src} (${r.achadaEm})`);
|
|
237
|
+
return r;
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
if (acao === 'icones' || acao === 'icone') {
|
|
241
|
+
if (args.nome) {
|
|
242
|
+
const r = await assets.icone(args.nome, { pasta: p ? p.pasta : null });
|
|
243
|
+
return r.ok ? { ok: true, nome: args.nome, baixado: true } : r;
|
|
244
|
+
}
|
|
245
|
+
return assets.procurarIcones(args.termo || args.busca || '');
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
if (acao === 'som' || acao === 'audio' || acao === 'trilha' || acao === 'efeito') {
|
|
249
|
+
const busca = await assets.procurarSom(args.termo || '', {
|
|
250
|
+
quantas: Number(args.quantas) || 8,
|
|
251
|
+
duracaoMin: acao === 'trilha' ? 30 : 0,
|
|
252
|
+
duracaoMax: acao === 'efeito' ? 6 : 0,
|
|
253
|
+
});
|
|
254
|
+
if (!busca.ok || !args.baixar) return busca;
|
|
255
|
+
return baixarEscolhido(busca.sons, args, p);
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
const busca = await assets.procurarImagens(args.termo || '', {
|
|
259
|
+
quantas: Number(args.quantas) || 8,
|
|
260
|
+
transparente: !!args.transparente,
|
|
261
|
+
});
|
|
262
|
+
if (!busca.ok || !args.baixar) return busca;
|
|
263
|
+
return baixarEscolhido(busca.imagens, args, p);
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
async function baixarEscolhido(lista, args, p) {
|
|
267
|
+
if (!p) return { ok: false, error: 'para baixar é preciso dizer em qual projeto (id).' };
|
|
268
|
+
const i = Number(args.baixar) === true ? 0 : Math.max(0, Number(args.baixar) - 1 || 0);
|
|
269
|
+
const item = lista[i];
|
|
270
|
+
if (!item) return { ok: false, error: `não há o item ${args.baixar} nessa busca.` };
|
|
271
|
+
const r = await assets.baixar(item, { pasta: p.pasta, nome: args.nome });
|
|
272
|
+
if (r.ok) {
|
|
273
|
+
projeto.anotar(p.id, 'midia', `baixou "${item.titulo}" (${item.licenca || 'sem licença declarada'})`);
|
|
274
|
+
}
|
|
275
|
+
return { ...r, creditos: item.creditos || null,
|
|
276
|
+
comoUsar: r.ok ? `no plano: "src": "${r.relativo}"` : undefined };
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
// ── estudio_narrar ───────────────────────────────────────────────────────
|
|
280
|
+
|
|
281
|
+
async function ferramentaNarrar(args) {
|
|
282
|
+
if (args.vozes) return rodarPython('narrar.py', ['vozes'], { timeout: 30000 });
|
|
283
|
+
|
|
284
|
+
const p = projeto.ler(args.id);
|
|
285
|
+
if (!p) return { ok: false, error: `não achei o projeto "${args.id}".` };
|
|
286
|
+
const texto = String(args.texto || '').trim();
|
|
287
|
+
if (!texto) return { ok: false, error: 'não há texto para narrar.' };
|
|
288
|
+
|
|
289
|
+
const saida = path.join(p.pasta, 'midia', `narracao-${Date.now().toString(36)}.mp3`);
|
|
290
|
+
fs.mkdirSync(path.dirname(saida), { recursive: true });
|
|
291
|
+
|
|
292
|
+
const r = await rodarPython('narrar.py', [
|
|
293
|
+
'falar', '--texto', texto, '--saida', saida,
|
|
294
|
+
'--voz', String(args.voz || 'antonio'),
|
|
295
|
+
'--estilo', String(args.estilo || 'normal'),
|
|
296
|
+
...(args.ritmo ? ['--ritmo', String(args.ritmo)] : []),
|
|
297
|
+
...(args.tom ? ['--tom', String(args.tom)] : []),
|
|
298
|
+
], { timeout: 5 * 60000 });
|
|
299
|
+
|
|
300
|
+
if (!r.ok) return r;
|
|
301
|
+
const relativo = path.relative(p.pasta, saida).replace(/\\/g, '/');
|
|
302
|
+
projeto.anotar(p.id, 'narracao', `${r.segundos}s na voz ${args.voz || 'antonio'}`);
|
|
303
|
+
return {
|
|
304
|
+
...r, relativo,
|
|
305
|
+
comoUsar: `no plano: "audio": {"narracao": "${relativo}"}. `
|
|
306
|
+
+ 'Os tempos de "falas" são MEDIDOS — use-os para a duração de cada cena e para as legendas, '
|
|
307
|
+
+ 'em vez de estimar por número de palavras.',
|
|
308
|
+
};
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
// ── estudio_video ────────────────────────────────────────────────────────
|
|
312
|
+
|
|
313
|
+
async function ferramentaVideo(args, ctx = {}) {
|
|
314
|
+
const acao = String(args.acao || 'medir').toLowerCase();
|
|
315
|
+
const p = projeto.ler(args.id);
|
|
316
|
+
if (!p) return { ok: false, error: `não achei o projeto "${args.id}".` };
|
|
317
|
+
|
|
318
|
+
const pronto = video.conferir();
|
|
319
|
+
if (!pronto.ok) return pronto;
|
|
320
|
+
|
|
321
|
+
const dentro = (rel) => {
|
|
322
|
+
const cheio = path.resolve(p.pasta, String(rel || ''));
|
|
323
|
+
return cheio.startsWith(path.resolve(p.pasta)) ? cheio : null;
|
|
324
|
+
};
|
|
325
|
+
|
|
326
|
+
if (acao === 'importar') {
|
|
327
|
+
// O vídeo da pessoa entra na pasta do projeto. Trabalhar a partir de
|
|
328
|
+
// onde ele está é o caminho para o agente escrever em cima do
|
|
329
|
+
// original — e o original dela não tem cópia.
|
|
330
|
+
const de = path.resolve(String(args.arquivo || '').replace(/^~(?=$|[/\\])/, os.homedir()));
|
|
331
|
+
if (!fs.existsSync(de)) return { ok: false, error: `não achei "${args.arquivo}".` };
|
|
332
|
+
const para = path.join(p.pasta, 'entrada', path.basename(de));
|
|
333
|
+
fs.copyFileSync(de, para);
|
|
334
|
+
const m = await video.medir(para);
|
|
335
|
+
projeto.anotar(p.id, 'entrada', `importou ${path.basename(de)} (${m.ok ? m.segundos + 's' : '?'})`);
|
|
336
|
+
return { ok: true, arquivo: path.relative(p.pasta, para).replace(/\\/g, '/'), medida: m };
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
const alvo = dentro(args.arquivo);
|
|
340
|
+
if (!alvo || !fs.existsSync(alvo)) {
|
|
341
|
+
return { ok: false, error: `"${args.arquivo}" não está na pasta do projeto. Use acao:"importar" antes.` };
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
if (acao === 'medir') return video.medir(alvo);
|
|
345
|
+
|
|
346
|
+
if (acao === 'silencio' || acao === 'pausas') {
|
|
347
|
+
const s = await video.silencios(alvo, { limiarDb: Number(args.limiarDb) || -40 });
|
|
348
|
+
if (!s.ok) return s;
|
|
349
|
+
const m = await video.medir(alvo);
|
|
350
|
+
return {
|
|
351
|
+
ok: true, silencios: s.silencios,
|
|
352
|
+
ficariam: video.trechosQueFicam(m.segundos, s.silencios, args),
|
|
353
|
+
};
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
if (acao === 'cortar-silencio' || acao === 'cortar') {
|
|
357
|
+
const saida = path.join(p.pasta, 'midia', `cortado-${Date.now().toString(36)}.mp4`);
|
|
358
|
+
const r = await video.cortarSilencio(alvo, saida, args);
|
|
359
|
+
if (r.ok && !r.semCorte) {
|
|
360
|
+
projeto.anotar(p.id, 'corte', `${r.antes}s → ${r.depois}s, ${r.cortes} cortes`);
|
|
361
|
+
r.relativo = path.relative(p.pasta, saida).replace(/\\/g, '/');
|
|
362
|
+
}
|
|
363
|
+
return r;
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
if (acao === 'quadro') {
|
|
367
|
+
const saida = path.join(p.pasta, 'midia', `quadro-${Math.round(Number(args.segundo) || 0)}s.png`);
|
|
368
|
+
const r = await video.quadro(alvo, saida, Number(args.segundo) || 0);
|
|
369
|
+
if (r.ok) r.relativo = path.relative(p.pasta, saida).replace(/\\/g, '/');
|
|
370
|
+
return r;
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
if (acao === 'legendas' || acao === 'transcrever') {
|
|
374
|
+
return transcrever(p, alvo, ctx);
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
if (acao === 'recortar-pessoa' || acao === 'pessoa') {
|
|
378
|
+
const saida = path.join(p.pasta, 'midia', `pessoa-${Date.now().toString(36)}.webm`);
|
|
379
|
+
const r = await rodarPython('pessoa.py', [
|
|
380
|
+
'recortar', '--entrada', alvo, '--saida', saida,
|
|
381
|
+
...(args.suavizar != null ? ['--suavizar', String(args.suavizar)] : []),
|
|
382
|
+
...(args.encolher != null ? ['--encolher', String(args.encolher)] : []),
|
|
383
|
+
], { timeout: 40 * 60000 });
|
|
384
|
+
if (r.ok) {
|
|
385
|
+
r.relativo = path.relative(p.pasta, saida).replace(/\\/g, '/');
|
|
386
|
+
r.comoUsar = 'é um vídeo com fundo TRANSPARENTE. Ponha-o como elemento de vídeo numa cena '
|
|
387
|
+
+ 'que tenha a imagem de fundo atrás — não precisa juntar os dois num arquivo.';
|
|
388
|
+
projeto.anotar(p.id, 'pessoa', `recortou ${r.quadros} quadros`);
|
|
389
|
+
}
|
|
390
|
+
return r;
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
return { ok: false, error: `não conheço a ação "${acao}".` };
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
/**
|
|
397
|
+
* A transcrição com tempo — é ela que dá legenda sincronizada.
|
|
398
|
+
*
|
|
399
|
+
* Quem tem a chave é o servidor, e é assim de propósito (a API vem com o
|
|
400
|
+
* produto). O áudio vai pequeno: mono, 16 kHz, 32 kbps.
|
|
401
|
+
*/
|
|
402
|
+
async function transcrever(p, alvo, ctx) {
|
|
403
|
+
const mp3 = path.join(p.pasta, 'midia', `audio-${Date.now().toString(36)}.mp3`);
|
|
404
|
+
const ex = await video.extrairAudio(alvo, mp3);
|
|
405
|
+
if (!ex.ok) return ex;
|
|
406
|
+
|
|
407
|
+
if (!ctx.api || !ctx.server) {
|
|
408
|
+
return { ok: false, error: 'a transcrição precisa do servidor, e ele não foi passado para a ferramenta.' };
|
|
409
|
+
}
|
|
410
|
+
if (ex.bytes > 20 * 1024 * 1024) {
|
|
411
|
+
return { ok: false, error: `o áudio ficou com ${(ex.bytes / 1048576).toFixed(1)} MB, acima do teto de 20 MB. `
|
|
412
|
+
+ 'Transcreva em pedaços: recorte o vídeo antes.' };
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
try {
|
|
416
|
+
const r = await ctx.api.requestTranscricao(ctx.server, {
|
|
417
|
+
token: ctx.token,
|
|
418
|
+
audio: fs.readFileSync(mp3).toString('base64'),
|
|
419
|
+
nome: path.basename(mp3),
|
|
420
|
+
idioma: 'pt',
|
|
421
|
+
});
|
|
422
|
+
try { fs.unlinkSync(mp3); } catch { /* fica, não faz mal */ }
|
|
423
|
+
if (!r || r.ok === false) return { ok: false, error: (r && r.erro) || 'a transcrição falhou.' };
|
|
424
|
+
projeto.anotar(p.id, 'transcricao', `${(r.trechos || []).length} trechos`);
|
|
425
|
+
return {
|
|
426
|
+
ok: true, texto: r.texto, trechos: r.trechos,
|
|
427
|
+
comoUsar: 'cada trecho vira uma fala da legenda: {"de":…, "ate":…, "texto":…}. '
|
|
428
|
+
+ 'O estilo da legenda (fonte, cor, peso, posição, animação) é escolha sua — não há padrão.',
|
|
429
|
+
};
|
|
430
|
+
} catch (e) {
|
|
431
|
+
return { ok: false, error: `a transcrição falhou: ${e.message}` };
|
|
432
|
+
}
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
// ── estudio_render ───────────────────────────────────────────────────────
|
|
436
|
+
|
|
437
|
+
async function ferramentaRender(args, ctx = {}) {
|
|
438
|
+
const p = projeto.ler(args.id);
|
|
439
|
+
if (!p) return { ok: false, error: `não achei o projeto "${args.id}".` };
|
|
440
|
+
const pl = projeto.lerPlano(args.id);
|
|
441
|
+
if (!pl) return { ok: false, error: 'não há plano neste projeto.' };
|
|
442
|
+
|
|
443
|
+
const conferida = plano.conferir(pl, { pasta: p.pasta });
|
|
444
|
+
if (!conferida.ok) {
|
|
445
|
+
return { ok: false, error: 'o plano tem problemas — conserte antes de renderizar.',
|
|
446
|
+
problemas: conferida.problemas.slice(0, 12) };
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
// Os ícones vêm ANTES do render: um render que busca ícone na internet
|
|
450
|
+
// falha no meio, depois de vinte minutos.
|
|
451
|
+
const { mapa, faltaram } = await assets.iconesDoPlano(pl, p.pasta);
|
|
452
|
+
|
|
453
|
+
const montado = remotion.montar({ pasta: p.pasta, titulo: p.titulo, plano: pl, icones: mapa });
|
|
454
|
+
|
|
455
|
+
if (args.somenteProjeto) {
|
|
456
|
+
return { ok: true, projeto: montado.raiz, faltaram,
|
|
457
|
+
aviso: 'o projeto Remotion está escrito, mas nada foi renderizado.' };
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
/* SLIDES E ARTE são o mesmo plano, parados no tempo: cada cena vira uma
|
|
461
|
+
página. O Studio antigo tinha seis ferramentas com seis formatos de
|
|
462
|
+
documento; cinco delas eram "coisas posicionadas num retângulo", que é
|
|
463
|
+
o que uma cena já é. */
|
|
464
|
+
const parado = p.formato === 'slides' || p.formato === 'arte' || args.paginas;
|
|
465
|
+
if (parado) {
|
|
466
|
+
const pasta = path.join(p.pasta, 'saida', 'paginas');
|
|
467
|
+
const r = await slides.renderizarPaginas({
|
|
468
|
+
raiz: montado.raiz, destino: pasta, plano: pl, aoFalar: ctx.aoFalar || (() => {}),
|
|
469
|
+
});
|
|
470
|
+
if (!r.ok) return { ...r, faltaram };
|
|
471
|
+
|
|
472
|
+
const pdf = path.join(p.pasta, 'saida', `${projeto.apelidar(p.titulo)}.pdf`);
|
|
473
|
+
const junto = slides.montarPDF(r.paginas, pdf);
|
|
474
|
+
if (!junto.ok) return { ...junto, imagens: r.paginas };
|
|
475
|
+
|
|
476
|
+
projeto.salvar(args.id, { renders: [...(p.renders || []), { quando: new Date().toISOString(), arquivo: pdf, bytes: junto.bytes }] });
|
|
477
|
+
projeto.anotar(args.id, 'render', `${junto.paginas} páginas em PDF`);
|
|
478
|
+
if (ctx.abrir) { try { ctx.abrir(pdf); } catch { /* o caminho está na resposta */ } }
|
|
479
|
+
return {
|
|
480
|
+
ok: true, arquivo: pdf, paginas: junto.paginas, bytes: junto.bytes,
|
|
481
|
+
imagens: r.paginas,
|
|
482
|
+
faltaram: faltaram.length ? faltaram : undefined,
|
|
483
|
+
aviso: 'ABERTO para o usuário ver. Chame finish dizendo o que foi feito.',
|
|
484
|
+
};
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
const saida = path.join(p.pasta, 'saida', `${projeto.apelidar(p.titulo)}.mp4`);
|
|
488
|
+
const falar = ctx.aoFalar || (() => {});
|
|
489
|
+
const r = await remotion.renderizar({
|
|
490
|
+
raiz: montado.raiz, saida,
|
|
491
|
+
minutos: Number(args.minutos) || 30,
|
|
492
|
+
aoFalar: falar,
|
|
493
|
+
});
|
|
494
|
+
if (!r.ok) return { ...r, faltaram };
|
|
495
|
+
|
|
496
|
+
const bytes = fs.existsSync(saida) ? fs.statSync(saida).size : 0;
|
|
497
|
+
projeto.salvar(args.id, { renders: [...(p.renders || []), { quando: new Date().toISOString(), arquivo: saida, bytes }] });
|
|
498
|
+
projeto.anotar(args.id, 'render', `${(bytes / 1048576).toFixed(1)} MB, ${conferida.duracao}s`);
|
|
499
|
+
|
|
500
|
+
// O vídeo pronto abre. É a única coisa que abre — a interface morreu, e
|
|
501
|
+
// o que a pessoa pediu foi o vídeo.
|
|
502
|
+
if (ctx.abrir) { try { ctx.abrir(saida); } catch { /* o caminho está na resposta */ } }
|
|
503
|
+
|
|
504
|
+
return {
|
|
505
|
+
ok: true, arquivo: saida, bytes, segundos: conferida.duracao,
|
|
506
|
+
faltaram: faltaram.length ? faltaram : undefined,
|
|
507
|
+
aviso: 'ABERTO para o usuário assistir. Chame finish dizendo o que foi feito.',
|
|
508
|
+
};
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
// ── estudio_site ─────────────────────────────────────────────────────────
|
|
512
|
+
|
|
513
|
+
async function ferramentaSite(args) {
|
|
514
|
+
let alvo = String(args.url || args.site || '').trim();
|
|
515
|
+
|
|
516
|
+
/* Só o NOME da empresa também serve: "faz um vídeo para a Paris Group"
|
|
517
|
+
não traz link nenhum, e sem este caminho o agente inventaria as cores
|
|
518
|
+
da marca — erro que aparece pronto no vídeo entregue. */
|
|
519
|
+
if (!alvo && (args.marca || args.nome || args.empresa)) {
|
|
520
|
+
const achado = await identidade.acharSite(args.marca || args.nome || args.empresa);
|
|
521
|
+
if (!achado.ok) return achado;
|
|
522
|
+
alvo = achado.url;
|
|
523
|
+
}
|
|
524
|
+
if (!alvo) return { ok: false, error: 'mande a url do site, ou o nome da empresa em "marca".' };
|
|
525
|
+
// "tailwindcss.com" é endereço para gente e não é URL para o `new URL`.
|
|
526
|
+
// Sem isto, o domínio sem esquema voltava "não é um endereço" — e quem
|
|
527
|
+
// digita o domínio pelado é justamente quem já sabe qual é o site.
|
|
528
|
+
if (!/^https?:\/\//i.test(alvo)) alvo = 'https://' + alvo;
|
|
529
|
+
|
|
530
|
+
const r = await identidade.analisar(alvo);
|
|
531
|
+
if (r.ok) r.achadoPor = args.url || args.site ? 'url dada' : 'nome da empresa';
|
|
532
|
+
|
|
533
|
+
/* A LOGO. Cor e fonte saem do CSS; a logo não — ela mora num <img> do
|
|
534
|
+
cabeçalho, num apple-touch-icon ou no og:image. Sem baixar aqui, o
|
|
535
|
+
agente montava a peça de marca com o nome em texto e o usuário
|
|
536
|
+
perguntava por que a logo dele não apareceu.
|
|
537
|
+
|
|
538
|
+
Só baixa quando há projeto: arquivo de mídia solto no disco, fora de
|
|
539
|
+
uma pasta de projeto, é lixo que ninguém recolhe. */
|
|
540
|
+
const html = r.html;
|
|
541
|
+
delete r.html; // o HTML cru não vai para o modelo
|
|
542
|
+
if (r.ok && args.id && args.logo !== false) {
|
|
543
|
+
const p = projeto.ler(args.id);
|
|
544
|
+
if (p) {
|
|
545
|
+
/* O nome que o USUÁRIO disse vem primeiro. `r.marca` sai do
|
|
546
|
+
<title>, e título de página é frase: o nubank.com.br devolve
|
|
547
|
+
"Nu | About us", de onde sobra "about" — que não identifica
|
|
548
|
+
marca nenhuma e faz o desempate premiar a imagem errada. */
|
|
549
|
+
const daMarca = args.marca || args.nome || args.empresa || r.marca || r.titulo || 'marca';
|
|
550
|
+
r.logo = await identidade.pegarLogo(html, r.url, { pasta: p.pasta, nome: daMarca });
|
|
551
|
+
if (r.logo.ok) projeto.anotar(args.id, 'midia', `logo de ${r.marca || r.url}: ${r.logo.src} (${r.logo.achadaEm})`);
|
|
552
|
+
}
|
|
553
|
+
}
|
|
554
|
+
|
|
555
|
+
if (r.ok && args.id) {
|
|
556
|
+
projeto.anotar(args.id, 'referencia', `leu ${r.url}: ${(r.cores.marca[0] || {}).cor || 'sem cor'} · ${(r.fontes.declaradas[0] || {}).fonte || 'sem fonte'}`);
|
|
557
|
+
}
|
|
558
|
+
return r;
|
|
559
|
+
}
|
|
560
|
+
|
|
561
|
+
module.exports = {
|
|
562
|
+
ferramentaProjeto, ferramentaPlano, ferramentaMidia, ferramentaNarrar,
|
|
563
|
+
ferramentaVideo, ferramentaRender, ferramentaSite,
|
|
564
|
+
projeto, plano, remotion, assets, video, identidade, slides, python, rodarPython,
|
|
565
|
+
};
|