primocode 8.42.1 → 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 +599 -327
- 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
package/lib/studio.js
DELETED
|
@@ -1,960 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* studio.js — o PrimoCode Studio, ligado ao agente.
|
|
3
|
-
*
|
|
4
|
-
* O Studio é a suíte de criação que vem junto com o PrimoCode (pasta studio/):
|
|
5
|
-
* Prisma (apresentações), Tela (design), Prosa (documentos), Grade (planilhas),
|
|
6
|
-
* Corte (vídeo) e Traço (diagramas). É um servidor Python de biblioteca padrão,
|
|
7
|
-
* sem nada para instalar.
|
|
8
|
-
*
|
|
9
|
-
* ── POR QUE ESTE MÓDULO EXISTE ───────────────────────────────────────────
|
|
10
|
-
* Medido em uso real: o agente sabia do Studio, tentou falar com ele em
|
|
11
|
-
* localhost:7777, tomou "conexão recusada" e DESISTIU — respondeu ao usuário
|
|
12
|
-
* que "o estúdio não está rodando na sua máquina". O usuário não tem que ligar
|
|
13
|
-
* ferramenta nenhuma: ele pediu um vídeo, quer o vídeo.
|
|
14
|
-
*
|
|
15
|
-
* Então aqui o Studio sobe SOZINHO, na primeira vez que o agente precisa dele.
|
|
16
|
-
* `garantirNoAr()` é chamado antes de toda tool studio_*, e o modelo nem sabe
|
|
17
|
-
* que houve um passo a mais.
|
|
18
|
-
*
|
|
19
|
-
* ── POR QUE ELE RODA DE ~/.primocode/studio, E NÃO DE DENTRO DO PACOTE ────
|
|
20
|
-
* O Studio grava o que cria (arquivos/ e midia/) ao lado do server.py. Com o
|
|
21
|
-
* pacote instalado global (`npm i -g`), essa pasta costuma ser de sistema e a
|
|
22
|
-
* escrita falharia — e, pior, um `npm update` apagaria o trabalho do usuário.
|
|
23
|
-
* Então o código é copiado para ~/.primocode/studio na primeira vez, e as
|
|
24
|
-
* criações ficam lá, a salvo de atualização do pacote.
|
|
25
|
-
*/
|
|
26
|
-
|
|
27
|
-
'use strict';
|
|
28
|
-
|
|
29
|
-
const http = require('http');
|
|
30
|
-
const fs = require('fs');
|
|
31
|
-
const path = require('path');
|
|
32
|
-
const os = require('os');
|
|
33
|
-
const { spawn, execSync } = require('child_process');
|
|
34
|
-
|
|
35
|
-
const ORIGEM = path.join(__dirname, '..', 'studio');
|
|
36
|
-
const CASA = path.join(os.homedir(), '.primocode', 'studio');
|
|
37
|
-
const PORTA = Number(process.env.PRIMOCODE_STUDIO_PORT) || 7777;
|
|
38
|
-
const BASE = `http://127.0.0.1:${PORTA}`;
|
|
39
|
-
|
|
40
|
-
// Só o código do Studio é atualizado; `arquivos/` e `midia/` (o que o usuário
|
|
41
|
-
// criou) nunca são tocados.
|
|
42
|
-
// voz.py é módulo do servidor, não enfeite: sem ele o server.py não importa
|
|
43
|
-
// e o Studio inteiro deixa de subir. Todo arquivo .py do Studio precisa
|
|
44
|
-
// entrar aqui.
|
|
45
|
-
const COPIAVEIS = new Set(['server.py', 'voz.py', 'elevenlabs.py', 'edge.py', 'web', 'ferramentas', 'README.md', 'PRIMOCODE.md']);
|
|
46
|
-
|
|
47
|
-
/**
|
|
48
|
-
* Onde está o Python 3 desta máquina.
|
|
49
|
-
*
|
|
50
|
-
* Procurar só por `python3` no PATH não basta, e isso custou uma tarefa
|
|
51
|
-
* inteira num Mac: o comando existe mas é o atalho da Apple, que sem as
|
|
52
|
-
* Ferramentas de Linha de Comando instaladas sai com erro em vez de versão —
|
|
53
|
-
* e o usuário lia "Python 3 não está instalado" com o Python instalado pelo
|
|
54
|
-
* Homebrew ali do lado, fora do PATH daquele terminal.
|
|
55
|
-
*
|
|
56
|
-
* Então: nomes comuns, versões numeradas, e os caminhos absolutos onde o
|
|
57
|
-
* Python realmente mora em cada sistema.
|
|
58
|
-
*/
|
|
59
|
-
function pythonCmd() {
|
|
60
|
-
const numeradas = ['python3.13', 'python3.12', 'python3.11', 'python3.10', 'python3.9'];
|
|
61
|
-
let candidatos;
|
|
62
|
-
if (os.platform() === 'win32') {
|
|
63
|
-
candidatos = ['python', 'py', 'python3', ...numeradas];
|
|
64
|
-
} else {
|
|
65
|
-
candidatos = [
|
|
66
|
-
'python3', 'python', ...numeradas,
|
|
67
|
-
// Homebrew (Apple Silicon e Intel) e o do sistema — o PATH de um
|
|
68
|
-
// terminal aberto por atalho às vezes não tem nenhum dos dois.
|
|
69
|
-
'/opt/homebrew/bin/python3', '/usr/local/bin/python3', '/usr/bin/python3',
|
|
70
|
-
];
|
|
71
|
-
}
|
|
72
|
-
for (const c of candidatos) {
|
|
73
|
-
try {
|
|
74
|
-
const v = execSync(`${c} --version`, { stdio: ['ignore', 'pipe', 'pipe'], timeout: 5000 }).toString();
|
|
75
|
-
if (/Python 3/.test(v)) return c;
|
|
76
|
-
} catch {}
|
|
77
|
-
}
|
|
78
|
-
return null;
|
|
79
|
-
}
|
|
80
|
-
|
|
81
|
-
// Cópia recursiva que só reescreve o que mudou — assim `npm update` traz o
|
|
82
|
-
// Studio novo sem custo, e o que o usuário criou continua onde está.
|
|
83
|
-
function copiar(de, para) {
|
|
84
|
-
const st = fs.statSync(de);
|
|
85
|
-
if (st.isDirectory()) {
|
|
86
|
-
fs.mkdirSync(para, { recursive: true });
|
|
87
|
-
for (const item of fs.readdirSync(de)) copiar(path.join(de, item), path.join(para, item));
|
|
88
|
-
return;
|
|
89
|
-
}
|
|
90
|
-
// Copia SEMPRE. A comparação esperta (tamanho + data) deixava passar
|
|
91
|
-
// arquivo alterado de mesmo tamanho: o npm instala com data antiga
|
|
92
|
-
// (1985, de propósito, por reprodutibilidade) e o teste de mtime nunca
|
|
93
|
-
// disparava. O que o usuário criou não corre risco — arquivos/ e midia/
|
|
94
|
-
// não estão em COPIAVEIS.
|
|
95
|
-
fs.copyFileSync(de, para);
|
|
96
|
-
}
|
|
97
|
-
|
|
98
|
-
function prepararCasa() {
|
|
99
|
-
if (!fs.existsSync(ORIGEM)) return { ok: false, error: 'A pasta studio/ não veio no pacote.' };
|
|
100
|
-
fs.mkdirSync(CASA, { recursive: true });
|
|
101
|
-
for (const item of COPIAVEIS) {
|
|
102
|
-
const de = path.join(ORIGEM, item);
|
|
103
|
-
if (fs.existsSync(de)) copiar(de, path.join(CASA, item));
|
|
104
|
-
}
|
|
105
|
-
return { ok: true, casa: CASA };
|
|
106
|
-
}
|
|
107
|
-
|
|
108
|
-
// ── HTTP ─────────────────────────────────────────────────────────────────
|
|
109
|
-
function pedir(metodo, rota, corpo, timeout = 30000) {
|
|
110
|
-
return new Promise((resolve, reject) => {
|
|
111
|
-
const dados = corpo == null ? null : Buffer.from(JSON.stringify(corpo), 'utf8');
|
|
112
|
-
const req = http.request({
|
|
113
|
-
hostname: '127.0.0.1',
|
|
114
|
-
port: PORTA,
|
|
115
|
-
path: rota,
|
|
116
|
-
method: metodo,
|
|
117
|
-
timeout,
|
|
118
|
-
// Este cabeçalho diz ao Studio que quem fala é a IA, não o editor
|
|
119
|
-
// do navegador. O servidor valida o documento com rigor só neste
|
|
120
|
-
// caso: erro de formato tem de voltar para o agente como lição, e
|
|
121
|
-
// não pode fazer o salvamento de uma pessoa que está digitando
|
|
122
|
-
// falhar no meio da edição.
|
|
123
|
-
headers: Object.assign({ 'X-Primo-Agente': '1' },
|
|
124
|
-
dados ? { 'Content-Type': 'application/json', 'Content-Length': dados.length } : {}),
|
|
125
|
-
}, (res) => {
|
|
126
|
-
let texto = '';
|
|
127
|
-
res.setEncoding('utf8');
|
|
128
|
-
res.on('data', (c) => { texto += c; });
|
|
129
|
-
res.on('end', () => {
|
|
130
|
-
if (res.statusCode >= 400) {
|
|
131
|
-
// A mensagem do servidor é escrita PARA o agente ("tema X
|
|
132
|
-
// não existe, os temas são..."). Envolver em "HTTP 400:
|
|
133
|
-
// {json}" só afasta a lição do olho dele.
|
|
134
|
-
let motivo = texto.slice(0, 400);
|
|
135
|
-
let corpoErro = null;
|
|
136
|
-
try {
|
|
137
|
-
corpoErro = JSON.parse(texto);
|
|
138
|
-
motivo = corpoErro.erro || motivo;
|
|
139
|
-
} catch { /* texto cru serve */ }
|
|
140
|
-
const erro = new Error(motivo);
|
|
141
|
-
erro.corpo = corpoErro;
|
|
142
|
-
// O código vem no objeto, e não mais embutido no texto:
|
|
143
|
-
// quem trata 404 (comIdReal) precisa saber que é 404 sem
|
|
144
|
-
// depender de a mensagem começar com "HTTP 404".
|
|
145
|
-
erro.status = res.statusCode;
|
|
146
|
-
return reject(erro);
|
|
147
|
-
}
|
|
148
|
-
try { resolve(JSON.parse(texto)); }
|
|
149
|
-
catch { resolve({ texto }); }
|
|
150
|
-
});
|
|
151
|
-
});
|
|
152
|
-
req.on('timeout', () => { req.destroy(new Error('tempo esgotado falando com o Studio')); });
|
|
153
|
-
req.on('error', reject);
|
|
154
|
-
if (dados) req.write(dados);
|
|
155
|
-
req.end();
|
|
156
|
-
});
|
|
157
|
-
}
|
|
158
|
-
|
|
159
|
-
async function estaNoAr() {
|
|
160
|
-
try { await pedir('GET', '/api/ferramentas', null, 1500); return true; }
|
|
161
|
-
catch { return false; }
|
|
162
|
-
}
|
|
163
|
-
|
|
164
|
-
// ── Subir ────────────────────────────────────────────────────────────────
|
|
165
|
-
// Uma subida por vez: sem esta trava, duas tools chamadas no mesmo turno
|
|
166
|
-
// disparariam dois servidores na mesma porta e o segundo morreria com
|
|
167
|
-
// "address in use" — barulho por nada.
|
|
168
|
-
/** A assinatura do server.py que está NO DISCO — a versão que deveria rodar. */
|
|
169
|
-
function assinaturaDoDisco() {
|
|
170
|
-
try {
|
|
171
|
-
const corpo = fs.readFileSync(path.join(CASA, 'server.py'));
|
|
172
|
-
return require('crypto').createHash('sha1').update(corpo).digest('hex').slice(0, 12);
|
|
173
|
-
} catch { return null; }
|
|
174
|
-
}
|
|
175
|
-
|
|
176
|
-
/** A assinatura do processo que está NO AR — a versão que roda de fato. */
|
|
177
|
-
async function assinaturaNoAr() {
|
|
178
|
-
try { return (await pedir('GET', '/api/versao', null, 1500)).assinatura || null; }
|
|
179
|
-
catch { return null; } // servidor antigo não tem a rota — também é "desatualizado"
|
|
180
|
-
}
|
|
181
|
-
|
|
182
|
-
/**
|
|
183
|
-
* Derruba o Studio que está no ar. Primeiro pelo caminho limpo (/api/sair);
|
|
184
|
-
* se for um servidor antigo, que não tem a rota, pelo sistema.
|
|
185
|
-
*/
|
|
186
|
-
async function derrubar() {
|
|
187
|
-
try { await pedir('POST', '/api/sair', {}, 1500); } catch {}
|
|
188
|
-
for (let i = 0; i < 10; i++) {
|
|
189
|
-
await new Promise((r) => setTimeout(r, 300));
|
|
190
|
-
if (!(await estaNoAr())) return true;
|
|
191
|
-
}
|
|
192
|
-
// Servidor antigo: sem /api/sair. Mata pelo padrão da linha de comando.
|
|
193
|
-
try {
|
|
194
|
-
const { execSync } = require('child_process');
|
|
195
|
-
if (os.platform() === 'win32') {
|
|
196
|
-
execSync('powershell -NoProfile -Command "Get-CimInstance Win32_Process | ' +
|
|
197
|
-
"Where-Object {$_.CommandLine -like '*primocode*studio*server.py*'} | " +
|
|
198
|
-
'ForEach-Object { Stop-Process -Id $_.ProcessId -Force }"', { stdio: 'ignore' });
|
|
199
|
-
} else {
|
|
200
|
-
execSync("pkill -f '[.]primocode/studio/server.py'", { stdio: 'ignore' });
|
|
201
|
-
}
|
|
202
|
-
} catch {}
|
|
203
|
-
for (let i = 0; i < 10; i++) {
|
|
204
|
-
await new Promise((r) => setTimeout(r, 300));
|
|
205
|
-
if (!(await estaNoAr())) return true;
|
|
206
|
-
}
|
|
207
|
-
return !(await estaNoAr());
|
|
208
|
-
}
|
|
209
|
-
|
|
210
|
-
let subindo = null;
|
|
211
|
-
|
|
212
|
-
async function garantirNoAr() {
|
|
213
|
-
if (await estaNoAr()) {
|
|
214
|
-
// O npm pode ter trocado o código no disco DEPOIS de este processo
|
|
215
|
-
// subir — e aí o Studio no ar é o antigo, servindo o bug que o
|
|
216
|
-
// update corrigiu. Atualiza o disco e compara as assinaturas; se
|
|
217
|
-
// divergirem, reinicia. Na véspera de uma apresentação, "atualizei
|
|
218
|
-
// mas continua igual" é o pior modo de falha que existe.
|
|
219
|
-
prepararCasa();
|
|
220
|
-
const doDisco = assinaturaDoDisco();
|
|
221
|
-
const noAr = await assinaturaNoAr();
|
|
222
|
-
if (!doDisco || (noAr && noAr === doDisco)) return { ok: true, url: BASE, jaEstava: true };
|
|
223
|
-
await derrubar();
|
|
224
|
-
// cai para o caminho de subida normal, já com o código novo
|
|
225
|
-
}
|
|
226
|
-
if (subindo) return subindo;
|
|
227
|
-
|
|
228
|
-
subindo = (async () => {
|
|
229
|
-
const casa = prepararCasa();
|
|
230
|
-
if (!casa.ok) return casa;
|
|
231
|
-
|
|
232
|
-
const py = pythonCmd();
|
|
233
|
-
if (!py) {
|
|
234
|
-
return {
|
|
235
|
-
ok: false,
|
|
236
|
-
error: 'O Studio precisa do Python 3, e não achei nenhum nesta máquina.',
|
|
237
|
-
comoResolver: os.platform() === 'darwin'
|
|
238
|
-
? 'No Mac, o mais rápido é: xcode-select --install (traz o Python 3 da Apple). '
|
|
239
|
-
+ 'Ou, se você usa Homebrew: brew install python. Depois é só pedir de novo.'
|
|
240
|
-
: os.platform() === 'win32'
|
|
241
|
-
? 'Instale em python.org/downloads (marque "Add python.exe to PATH" no instalador).'
|
|
242
|
-
: 'Instale o Python 3 pelo gerenciador da sua distribuição (ex: sudo apt install python3).',
|
|
243
|
-
// Sem isto o agente responde "não consegui" e para. O trabalho
|
|
244
|
-
// pedido quase sempre tem um caminho que não depende do Studio.
|
|
245
|
-
enquantoIsso: 'Não desista da tarefa: dá para entregar em HTML com write_file e abrir com open_in_browser.',
|
|
246
|
-
};
|
|
247
|
-
}
|
|
248
|
-
|
|
249
|
-
// Solto do processo do CLI: o Studio continua de pé quando a sessão
|
|
250
|
-
// fecha, que é o que o usuário espera de um app aberto.
|
|
251
|
-
const filho = spawn(py, ['server.py', String(PORTA)], {
|
|
252
|
-
cwd: CASA,
|
|
253
|
-
detached: true,
|
|
254
|
-
stdio: 'ignore',
|
|
255
|
-
});
|
|
256
|
-
filho.unref();
|
|
257
|
-
|
|
258
|
-
// Espera ficar de pé (~8s no pior caso). Máquina lenta continua sendo
|
|
259
|
-
// atendida; máquina rápida sai na primeira volta.
|
|
260
|
-
for (let i = 0; i < 40; i++) {
|
|
261
|
-
await new Promise((r) => setTimeout(r, 200));
|
|
262
|
-
if (await estaNoAr()) return { ok: true, url: BASE, subiuAgora: true };
|
|
263
|
-
}
|
|
264
|
-
return { ok: false, error: `O Studio não respondeu em ${BASE} depois de subir. Veja se a porta ${PORTA} está livre.` };
|
|
265
|
-
})();
|
|
266
|
-
|
|
267
|
-
try { return await subindo; }
|
|
268
|
-
finally { subindo = null; }
|
|
269
|
-
}
|
|
270
|
-
|
|
271
|
-
// ── O que o agente usa ───────────────────────────────────────────────────
|
|
272
|
-
// Toda função garante o Studio no ar ANTES de falar com ele. É isto que faz o
|
|
273
|
-
// "abre o Canva e faz um post" virar um post pronto, sem o usuário ligar nada.
|
|
274
|
-
|
|
275
|
-
async function comStudio(fn) {
|
|
276
|
-
const no = await garantirNoAr();
|
|
277
|
-
if (!no.ok) return no;
|
|
278
|
-
return fn();
|
|
279
|
-
}
|
|
280
|
-
|
|
281
|
-
const manual = () => comStudio(async () => ({ ok: true, ...(await pedir('GET', '/api/manual')) }));
|
|
282
|
-
const ferramentas = () => comStudio(async () => ({ ok: true, ferramentas: await pedir('GET', '/api/ferramentas') }));
|
|
283
|
-
const spec = (slug) => comStudio(async () => ({ ok: true, spec: await pedir('GET', `/api/ferramentas/${encodeURIComponent(slug)}`) }));
|
|
284
|
-
const listar = () => comStudio(async () => ({ ok: true, arquivos: await pedir('GET', '/api/arquivos') }));
|
|
285
|
-
// 404 de id é quase sempre id INVENTADO: num pedido real o agente criou o
|
|
286
|
-
// arquivo, recebeu "corte-founders-ea74b" de volta, e chamou studio_ler com
|
|
287
|
-
// "imersao-foulders-ai" — um id que ele imaginou. O erro seco ("não
|
|
288
|
-
// encontrado") não dizia isso, e ele insistia. O erro que ensina, diz.
|
|
289
|
-
async function comIdReal(id, fn) {
|
|
290
|
-
try {
|
|
291
|
-
return await fn();
|
|
292
|
-
} catch (e) {
|
|
293
|
-
if (!(e && e.status === 404)) throw e;
|
|
294
|
-
let existem = [];
|
|
295
|
-
try { existem = (await pedir('GET', '/api/arquivos')).map((a) => a.id).slice(0, 8); } catch {}
|
|
296
|
-
return {
|
|
297
|
-
ok: false,
|
|
298
|
-
error: `O id "${id}" não existe. NÃO invente id: use EXATAMENTE o campo "id" que studio_criar devolveu.`
|
|
299
|
-
+ (existem.length ? ` Os que existem agora: ${existem.join(', ')}.` : ''),
|
|
300
|
-
};
|
|
301
|
-
}
|
|
302
|
-
}
|
|
303
|
-
|
|
304
|
-
const ler = (id) => comStudio(() => comIdReal(id, async () => ({ ok: true, arquivo: await pedir('GET', `/api/arquivos/${encodeURIComponent(id)}`) })));
|
|
305
|
-
|
|
306
|
-
// Quando o documento tem narração, o servidor começa a baixar a voz neural
|
|
307
|
-
// sozinho. São ~90 MB, e o usuário merece saber por que o primeiro vídeo sai
|
|
308
|
-
// com uma voz e o próximo com outra — melhor ele ouvir isso do agente do que
|
|
309
|
-
// achar que o programa está inconstante.
|
|
310
|
-
async function avisoDaVoz(doc) {
|
|
311
|
-
const temNarracao = !!(doc && (doc.narracao
|
|
312
|
-
|| (doc.cenas || []).some((c) => c && typeof c.narracao === 'string' && c.narracao.trim())));
|
|
313
|
-
if (!temNarracao) return null;
|
|
314
|
-
try {
|
|
315
|
-
const s = await pedir('GET', '/api/voz/status', null, 3000);
|
|
316
|
-
if (!s) return null;
|
|
317
|
-
// Voz de gente garantida: ElevenLabs com chave, ou o Edge (Python,
|
|
318
|
-
// neural, grátis) já instalado. Nada a avisar.
|
|
319
|
-
if (s.elevenlabs && (s.elevenlabs.configurado || s.elevenlabs.ativo)) return null;
|
|
320
|
-
if (s.edge && s.edge.disponivel) return null;
|
|
321
|
-
if (s.piper && s.piper.pronta) return null;
|
|
322
|
-
// "A narração tá pegando do Google, tem que ser do Python" — o dono
|
|
323
|
-
// ouviu a reserva sem saber que era reserva. Quando o Edge ainda não
|
|
324
|
-
// está na máquina, o agente fica sabendo e conta: o pip está rodando
|
|
325
|
-
// (as cenas esperam por ele), ou falhou, e aí a pessoa pode instalar
|
|
326
|
-
// à mão em vez de achar que a voz do produto é aquela.
|
|
327
|
-
if (s.edge && s.edge.instalando) {
|
|
328
|
-
return 'A voz neural (edge-tts, Python) está sendo instalada agora — leva segundos, uma vez só; '
|
|
329
|
-
+ 'a narração das cenas espera por ela. Se o vídeo tocar antes, a primeira reprodução pode sair '
|
|
330
|
-
+ 'com a voz de reserva. Diga isso ao usuário em uma frase.';
|
|
331
|
-
}
|
|
332
|
-
if (s.edge && s.edge.erro) {
|
|
333
|
-
return `A voz neural (edge-tts) não instalou sozinha (${String(s.edge.erro).slice(0, 120)}). `
|
|
334
|
-
+ 'Até instalar, a narração sai pela voz de reserva do Google. AVISE o usuário e dê o comando: '
|
|
335
|
-
+ 'python3 -m pip install --user edge-tts (ou com --break-system-packages).';
|
|
336
|
-
}
|
|
337
|
-
if (s.piper && !s.piper.pronta && s.piper.suportado) {
|
|
338
|
-
return 'A voz neural (offline, ilimitada) está sendo baixada agora — ~90 MB, uma vez só. '
|
|
339
|
-
+ 'Até terminar a narração sai por uma voz de reserva, mais simples. Diga isso ao usuário.';
|
|
340
|
-
}
|
|
341
|
-
} catch { /* status é enfeite; não pode atrapalhar a criação */ }
|
|
342
|
-
return null;
|
|
343
|
-
}
|
|
344
|
-
|
|
345
|
-
const criar = ({ ferramenta, titulo, doc }) => comStudio(async () => {
|
|
346
|
-
if (!ferramenta) return { ok: false, error: 'Diga a ferramenta: prisma, tela, prosa, grade, corte ou traco.' };
|
|
347
|
-
if (!doc) return { ok: false, error: 'Falta o doc. Leia a spec com studio_spec antes de criar.' };
|
|
348
|
-
const r = await pedir('POST', '/api/arquivos', { ferramenta, titulo: titulo || 'Sem título', doc });
|
|
349
|
-
const saida = { ok: true, ...r };
|
|
350
|
-
const voz = await avisoDaVoz(doc);
|
|
351
|
-
if (voz) saida.voz = voz;
|
|
352
|
-
return saida;
|
|
353
|
-
});
|
|
354
|
-
|
|
355
|
-
// O que o agente recebe de volta depois de mexer num arquivo. Devolver o
|
|
356
|
-
// documento inteiro era caro e inútil: um vídeo de oito cenas volta como
|
|
357
|
-
// milhares de tokens que ele acabou de escrever. O que ele precisa saber é
|
|
358
|
-
// que gravou, quantas peças ficaram e como voltar atrás.
|
|
359
|
-
const resumoDoArquivo = (r) => {
|
|
360
|
-
const doc = r.doc || {};
|
|
361
|
-
const pecas = (doc.cenas || doc.blocos || doc.abas || []).length;
|
|
362
|
-
return {
|
|
363
|
-
id: r.id, titulo: r.titulo, ferramenta: r.ferramenta,
|
|
364
|
-
pecas, tema: doc.tema,
|
|
365
|
-
temNarracao: !!(doc.narracao || (doc.cenas || []).some((c) => c && c.narracao)),
|
|
366
|
-
temTrilha: !!doc.trilha,
|
|
367
|
-
// O mesmo link de sempre: ajustar não cria peça nova, e é isso que o
|
|
368
|
-
// agente precisa dizer ao usuário no finish.
|
|
369
|
-
// localhost, e não 127.0.0.1, para bater com o link que studio_criar
|
|
370
|
-
// já deu ao usuário — dois endereços para a mesma peça confundem.
|
|
371
|
-
ver: `http://localhost:${PORTA}/v/${r.id}`,
|
|
372
|
-
};
|
|
373
|
-
};
|
|
374
|
-
|
|
375
|
-
const atualizar = ({ id, titulo, doc }) => comStudio(() => comIdReal(id, async () => {
|
|
376
|
-
if (!id) return { ok: false, error: 'Falta o id do arquivo.' };
|
|
377
|
-
const corpo = {};
|
|
378
|
-
if (titulo != null) corpo.titulo = titulo;
|
|
379
|
-
if (doc != null) corpo.doc = doc;
|
|
380
|
-
const r = await pedir('PUT', `/api/arquivos/${encodeURIComponent(id)}`, corpo);
|
|
381
|
-
const saida = { ok: true, ...resumoDoArquivo(r) };
|
|
382
|
-
// A mescla é a razão de isto não ter destruído nada: as chaves que você
|
|
383
|
-
// não mandou continuam lá. Dizer isso evita a rodada seguinte em que o
|
|
384
|
-
// agente "reconstrói" o que nunca se perdeu.
|
|
385
|
-
saida.nota = 'Só as chaves que você mandou mudaram; o resto do documento continua como estava.';
|
|
386
|
-
if (r.versao_anterior) saida.desfazer = `studio_versoes com id "${r.id}" e restaurar "${r.versao_anterior}" volta ao estado anterior.`;
|
|
387
|
-
return saida;
|
|
388
|
-
}));
|
|
389
|
-
|
|
390
|
-
// ── Histórico ────────────────────────────────────────────────────────────
|
|
391
|
-
// Existe porque uma vez não existiu: um ajuste apagou oito cenas de trabalho
|
|
392
|
-
// e não havia de onde tirar de volta.
|
|
393
|
-
const versoesDe = ({ id, restaurar }) => comStudio(() => comIdReal(id, async () => {
|
|
394
|
-
if (!id) return { ok: false, error: 'Falta o id do arquivo.' };
|
|
395
|
-
if (restaurar) {
|
|
396
|
-
const alvo = restaurar === true || restaurar === 'anterior' ? null : String(restaurar);
|
|
397
|
-
try {
|
|
398
|
-
const r = await pedir('POST', `/api/arquivos/${encodeURIComponent(id)}/restaurar`,
|
|
399
|
-
alvo ? { versao: alvo } : {});
|
|
400
|
-
return { ok: true, ...r };
|
|
401
|
-
} catch (e) {
|
|
402
|
-
// 404 aqui pode ser o arquivo OU a versão. Quando o servidor manda
|
|
403
|
-
// a lista junto, o arquivo existe e quem não existe é a versão —
|
|
404
|
-
// deixar comIdReal dizer "o id não existe" mandaria o agente
|
|
405
|
-
// caçar um problema que não é o dele.
|
|
406
|
-
if (e && e.status === 404 && e.corpo && Array.isArray(e.corpo.versoes)) {
|
|
407
|
-
return {
|
|
408
|
-
ok: false,
|
|
409
|
-
error: `A versão "${alvo}" não existe.`,
|
|
410
|
-
versoes: e.corpo.versoes,
|
|
411
|
-
comoCorrigir: 'Use um "versao" desta lista, copiado LITERALMENTE, ou "anterior" para desfazer a última mudança.',
|
|
412
|
-
};
|
|
413
|
-
}
|
|
414
|
-
throw e;
|
|
415
|
-
}
|
|
416
|
-
}
|
|
417
|
-
const r = await pedir('GET', `/api/arquivos/${encodeURIComponent(id)}/versoes`);
|
|
418
|
-
return {
|
|
419
|
-
ok: true,
|
|
420
|
-
versoes: r.versoes || [],
|
|
421
|
-
comoVoltar: 'Para voltar, chame de novo com restaurar: "<versao>" — ou restaurar: "anterior" para desfazer a última mudança.',
|
|
422
|
-
};
|
|
423
|
-
}));
|
|
424
|
-
|
|
425
|
-
// ── Mídia: a imagem de fundo e a música do usuário ───────────────────────
|
|
426
|
-
// Sem isto o agente não tinha como pôr foto de fundo nem trilha própria: a
|
|
427
|
-
// API pede base64, e ele não ia montar data URI na mão dentro de um curl.
|
|
428
|
-
const TIPOS_MIDIA = {
|
|
429
|
-
'.png': 1, '.jpg': 1, '.jpeg': 1, '.gif': 1, '.webp': 1, '.svg': 1, '.avif': 1,
|
|
430
|
-
'.mp3': 1, '.m4a': 1, '.wav': 1, '.ogg': 1, '.aac': 1, '.flac': 1, '.opus': 1, '.weba': 1,
|
|
431
|
-
'.mp4': 1, '.webm': 1, '.mov': 1,
|
|
432
|
-
};
|
|
433
|
-
|
|
434
|
-
const midiaListar = () => comStudio(async () => {
|
|
435
|
-
const lista = await pedir('GET', '/api/midia');
|
|
436
|
-
// As cópias `.compat.webm` são encanamento da exportação escondida (ver
|
|
437
|
-
// `compativel`), não mídia do usuário: listá-las faria o agente usá-las.
|
|
438
|
-
const midia = Array.isArray(lista) ? lista.filter((m) => !/\.compat\.webm$/i.test(String((m && (m.nome || m.url)) || ''))) : lista;
|
|
439
|
-
return { ok: true, midia };
|
|
440
|
-
});
|
|
441
|
-
|
|
442
|
-
/* O caminho como ele chega quando a pessoa ARRASTA o arquivo para o terminal.
|
|
443
|
-
*
|
|
444
|
-
* "Eu quero que eu consiga arrastar os meus vídeos pra dentro do terminal."
|
|
445
|
-
* Arrastar, num terminal, cola o caminho — e cola do jeito do sistema: o
|
|
446
|
-
* macOS escapa espaço com barra invertida, o Windows põe aspas em volta, e
|
|
447
|
-
* alguns colam file:// na frente. `path.resolve` de qualquer um desses dá um
|
|
448
|
-
* caminho que não existe, e a resposta era "arquivo não existe" para um
|
|
449
|
-
* arquivo que está bem ali.
|
|
450
|
-
*/
|
|
451
|
-
function limparCaminho(bruto) {
|
|
452
|
-
let s = String(bruto || '').trim();
|
|
453
|
-
if ((s.startsWith('"') && s.endsWith('"')) || (s.startsWith("'") && s.endsWith("'"))) {
|
|
454
|
-
s = s.slice(1, -1);
|
|
455
|
-
}
|
|
456
|
-
if (/^file:\/\//i.test(s)) {
|
|
457
|
-
try { s = decodeURIComponent(s.replace(/^file:\/\//i, '')); } catch { /* fica como está */ }
|
|
458
|
-
}
|
|
459
|
-
// Só desescapa o que o shell escapa; um "\" de caminho do Windows fica.
|
|
460
|
-
s = s.replace(/\\([ ()'"&!$`\\])/g, '$1');
|
|
461
|
-
if (s.startsWith('~/') || s === '~') s = path.join(os.homedir(), s.slice(1));
|
|
462
|
-
return s.trim();
|
|
463
|
-
}
|
|
464
|
-
|
|
465
|
-
const midiaEnviar = ({ arquivo, nome }) => comStudio(async () => {
|
|
466
|
-
if (!arquivo) return { ok: false, error: 'Diga o caminho do arquivo (arquivo: "foto.jpg").' };
|
|
467
|
-
const caminho = path.resolve(limparCaminho(arquivo));
|
|
468
|
-
if (!fs.existsSync(caminho)) {
|
|
469
|
-
return {
|
|
470
|
-
ok: false,
|
|
471
|
-
error: `Arquivo não existe: ${arquivo}. NÃO invente arquivo de mídia — num pedido real o agente `
|
|
472
|
-
+ 'tentou fundo.jpg, trilha.mp3 e logo.png que nunca existiram. Sem arquivo do usuário, siga sem mídia: '
|
|
473
|
-
+ 'fundo pode ser "aurora"/"raios"/"brilho" (gerados), e trilha pode ser gerada — '
|
|
474
|
-
+ '"trilha":{"tipo":"gerada","estilo":"epico"}. O vídeo fica pronto do mesmo jeito.',
|
|
475
|
-
};
|
|
476
|
-
}
|
|
477
|
-
|
|
478
|
-
const ext = path.extname(caminho).toLowerCase();
|
|
479
|
-
if (!TIPOS_MIDIA[ext]) {
|
|
480
|
-
// O erro traz o CONSERTO: o comando pronto. É o que faz "o vídeo veio
|
|
481
|
-
// em .avi, vou converter para mp4 / Bash / feito, continuando" ser o
|
|
482
|
-
// que acontece, em vez de "formato não aceito" e a tarefa morrendo —
|
|
483
|
-
// o modelo copia o comando, roda e manda o arquivo certo.
|
|
484
|
-
const base = caminho.replace(/\.[^.]+$/, '');
|
|
485
|
-
const pareceAudio = /^\.(wma|aiff?|amr|mid|midi|ra|au|ape|mka)$/.test(ext);
|
|
486
|
-
const pareceImagem = /^\.(heic|heif|bmp|tiff?|ico|psd|raw|cr2|nef)$/.test(ext);
|
|
487
|
-
const comando = pareceAudio
|
|
488
|
-
? `ffmpeg -y -i "${caminho}" -vn -c:a libmp3lame -q:a 2 "${base}.mp3"`
|
|
489
|
-
: pareceImagem
|
|
490
|
-
? `ffmpeg -y -i "${caminho}" "${base}.png"`
|
|
491
|
-
: `ffmpeg -y -i "${caminho}" -c:v libx264 -pix_fmt yuv420p -c:a aac -movflags +faststart "${base}.mp4"`;
|
|
492
|
-
return {
|
|
493
|
-
ok: false,
|
|
494
|
-
error: `Formato não aceito: ${ext}. Converta com run_shell e mande o arquivo convertido: ${comando}`,
|
|
495
|
-
aceitos: Object.keys(TIPOS_MIDIA).join(' '),
|
|
496
|
-
comando,
|
|
497
|
-
semFfmpeg: 'brew install ffmpeg · sudo apt-get install -y ffmpeg · winget install Gyan.FFmpeg',
|
|
498
|
-
};
|
|
499
|
-
}
|
|
500
|
-
const bytes = fs.statSync(caminho).size;
|
|
501
|
-
const ehVideo = ext === '.mp4' || ext === '.webm' || ext === '.mov';
|
|
502
|
-
const teto = ehVideo ? 2 * 1024 * 1024 * 1024 : 60 * 1024 * 1024;
|
|
503
|
-
if (bytes > teto) {
|
|
504
|
-
return { ok: false, error: `Arquivo tem ${(bytes / 1048576).toFixed(1)} MB; `
|
|
505
|
-
+ `o limite é ${Math.round(teto / 1048576)} MB.` };
|
|
506
|
-
}
|
|
507
|
-
|
|
508
|
-
// O corpo vai em pedaços, direto do disco para o socket. Em base64 um
|
|
509
|
-
// vídeo de celular precisaria caber inteiro na memória três vezes — e o
|
|
510
|
-
// Node estourava antes de o estúdio ver um byte.
|
|
511
|
-
const r = await enviarArquivo(caminho, nome || path.basename(caminho));
|
|
512
|
-
const comoUsar = ehVideo
|
|
513
|
-
? `Use "${r.url}" no "src" de um elemento {"tipo":"clipe"}. Para cortar as pausas, `
|
|
514
|
-
+ 'escreva VÁRIOS clipes do mesmo src, cada um com "de" e "ate" em segundos.'
|
|
515
|
-
: `Use "${r.url}" em fundo.imagem, em src de uma imagem, ou em trilha.src.`;
|
|
516
|
-
return { ok: true, ...r, comoUsar };
|
|
517
|
-
});
|
|
518
|
-
|
|
519
|
-
/* PUT /api/midia/<nome> com o arquivo cru no corpo. */
|
|
520
|
-
function enviarArquivo(caminho, nome) {
|
|
521
|
-
return new Promise((resolve, reject) => {
|
|
522
|
-
const bytes = fs.statSync(caminho).size;
|
|
523
|
-
const req = http.request({
|
|
524
|
-
hostname: '127.0.0.1', port: PORTA, method: 'PUT',
|
|
525
|
-
path: '/api/midia/' + encodeURIComponent(nome),
|
|
526
|
-
// Vídeo grande copiando para o disco leva tempo; meia hora é
|
|
527
|
-
// folga para um arquivo de duas horas, e continua sendo um teto.
|
|
528
|
-
timeout: 30 * 60 * 1000,
|
|
529
|
-
headers: { 'X-Primo-Agente': '1', 'Content-Type': 'application/octet-stream',
|
|
530
|
-
'Content-Length': bytes },
|
|
531
|
-
}, (res) => {
|
|
532
|
-
let texto = '';
|
|
533
|
-
res.setEncoding('utf8');
|
|
534
|
-
res.on('data', (c) => { texto += c; });
|
|
535
|
-
res.on('end', () => {
|
|
536
|
-
let corpo = null;
|
|
537
|
-
try { corpo = JSON.parse(texto); } catch { /* texto cru serve */ }
|
|
538
|
-
if (res.statusCode >= 400) {
|
|
539
|
-
return reject(new Error((corpo && corpo.erro) || texto.slice(0, 300)));
|
|
540
|
-
}
|
|
541
|
-
resolve(corpo || {});
|
|
542
|
-
});
|
|
543
|
-
});
|
|
544
|
-
req.on('timeout', () => { req.destroy(new Error('o envio demorou demais')); });
|
|
545
|
-
req.on('error', reject);
|
|
546
|
-
const leitura = fs.createReadStream(caminho);
|
|
547
|
-
leitura.on('error', (e) => { req.destroy(e); reject(e); });
|
|
548
|
-
leitura.pipe(req);
|
|
549
|
-
});
|
|
550
|
-
}
|
|
551
|
-
|
|
552
|
-
/* ── Ouvir o vídeo para achar as pausas ──────────────────────────────────
|
|
553
|
-
*
|
|
554
|
-
* "Ele vai cortar as pausas demoradas no vídeo."
|
|
555
|
-
*
|
|
556
|
-
* Cortar pausa exige saber onde a fala para, e nada aqui lê o áudio de um
|
|
557
|
-
* .mp4: o PrimoCode não tem dependência, e o Python da máquina lê WAV e mais
|
|
558
|
-
* nada. ffmpeg resolveria e seria mais uma instalação de 80 MB para quem só
|
|
559
|
-
* quer editar um vídeo.
|
|
560
|
-
*
|
|
561
|
-
* Quem decodifica MP4, MOV e webm já está aberto na mesa. A conta roda numa
|
|
562
|
-
* aba que abre, trabalha e se fecha; aqui a gente espera a resposta.
|
|
563
|
-
*
|
|
564
|
-
* `abrir` é injetado (é a função de abrir navegador do tools.js) para esta
|
|
565
|
-
* camada não passar a conhecer o sistema operacional.
|
|
566
|
-
*/
|
|
567
|
-
const analisarAudio = ({ arquivo, pausa, manter, repetida, abrir, escondido }) => comStudio(async () => {
|
|
568
|
-
if (!arquivo) return { ok: false, error: 'Diga qual mídia analisar (arquivo: "/midia/x.mp4").' };
|
|
569
|
-
// No navegador escondido a conta roda sobre a cópia que ele decodifica
|
|
570
|
-
// (ver `compativel`); o resultado vale para o original, que é o que o
|
|
571
|
-
// agente escreve nas cenas — a linha do tempo é a mesma.
|
|
572
|
-
let alvo = arquivo;
|
|
573
|
-
if (escondido) {
|
|
574
|
-
// Vídeo SEM faixa de áudio: não há pausa para achar, e a página só
|
|
575
|
-
// saberia dizer "não consegui decodificar" — que manda o agente caçar
|
|
576
|
-
// um formato errado que não existe. Quem tem ffprobe pergunta antes.
|
|
577
|
-
if (!temAudio(noDisco(arquivo))) {
|
|
578
|
-
return { ok: false, semAudio: true,
|
|
579
|
-
error: 'Este vídeo não tem faixa de áudio: não há pausa nem fala para cortar. Use-o inteiro num '
|
|
580
|
-
+ 'clipe só (sem "de"/"ate") e, se quiser voz, escreva "narracao" na cena — ela entra por cima.' };
|
|
581
|
-
}
|
|
582
|
-
const c = await compativel(arquivo, escondido);
|
|
583
|
-
if (c.aviso) return { ok: false, error: c.aviso };
|
|
584
|
-
alvo = c.url;
|
|
585
|
-
}
|
|
586
|
-
const tarefa = await pedir('POST', '/api/analise', { arquivo: alvo, pausa, manter, repetida });
|
|
587
|
-
// `abrir` pode devolver a aba escondida (com `fechar`) ou nada (o
|
|
588
|
-
// navegador da pessoa). A aba fica de pé até a análise voltar.
|
|
589
|
-
let aba = null;
|
|
590
|
-
if (abrir) { try { aba = await abrir(`${BASE}${tarefa.pagina}`); } catch { /* segue e expira */ } }
|
|
591
|
-
try {
|
|
592
|
-
return await esperarAnalise(tarefa, arquivo);
|
|
593
|
-
} finally {
|
|
594
|
-
if (aba && typeof aba.fechar === 'function') { try { await aba.fechar(); } catch { /* já fechou */ } }
|
|
595
|
-
}
|
|
596
|
-
});
|
|
597
|
-
|
|
598
|
-
async function esperarAnalise(tarefa, arquivo) {
|
|
599
|
-
// Um vídeo de meia hora leva alguns segundos para decodificar; três
|
|
600
|
-
// minutos é folga com sobra, e continua sendo um teto — sem ele um
|
|
601
|
-
// navegador que nunca abriu deixaria a tarefa pendurada para sempre.
|
|
602
|
-
const prazo = Date.now() + 180000;
|
|
603
|
-
while (Date.now() < prazo) {
|
|
604
|
-
await new Promise((ok) => setTimeout(ok, 700));
|
|
605
|
-
const estado = await pedir('GET', `/api/analise/${tarefa.id}`, null, 8000).catch(() => null);
|
|
606
|
-
if (!estado) continue;
|
|
607
|
-
if (estado.estado === 'erro') {
|
|
608
|
-
const erro = String((estado.resultado && estado.resultado.erro) || 'a análise falhou');
|
|
609
|
-
// O erro do navegador é genérico; o que o agente precisa é do
|
|
610
|
-
// caminho a seguir, e ele é o mesmo nos dois casos que produzem
|
|
611
|
-
// essa frase (sem faixa de áudio; formato que o navegador não lê).
|
|
612
|
-
if (/decode audio|decodeAudioData|EncodingError/i.test(erro)) {
|
|
613
|
-
return { ok: false, error: 'Não consegui ler o áudio deste arquivo — ele pode não ter faixa de áudio, '
|
|
614
|
-
+ 'ou estar num formato que o navegador não decodifica. Siga sem o corte automático: use o vídeo '
|
|
615
|
-
+ 'inteiro num clipe só (sem "de"/"ate"), ou converta com ffmpeg para mp4 (H.264/AAC) e mande de novo.' };
|
|
616
|
-
}
|
|
617
|
-
return { ok: false, error: erro };
|
|
618
|
-
}
|
|
619
|
-
if (estado.estado === 'pronto') {
|
|
620
|
-
const r = estado.resultado || {};
|
|
621
|
-
const repetidos = r.repetidos || [];
|
|
622
|
-
/* O QUE FOI TIRADO É DITO, e não só cortado. O vídeo sai menor e a
|
|
623
|
-
diferença é fala da pessoa: um resultado que só entrega os
|
|
624
|
-
trechos deixa o agente montar o vídeo sem ter como contar que
|
|
625
|
-
dois segundos dela sumiram — e vídeo que encurta sem explicação é
|
|
626
|
-
indistinguível de vídeo quebrado. */
|
|
627
|
-
const aviso = repetidos.length
|
|
628
|
-
? ` ${repetidos.length} trecho(s) saíram por terem sido ditos duas vezes `
|
|
629
|
-
+ `(${repetidos.map((x) => `${x.de}s–${x.ate}s, ${x.motivo}`).join('; ')}). `
|
|
630
|
-
+ 'DIGA isso ao usuário na sua resposta, com os tempos. Se ele quiser tudo de volta, '
|
|
631
|
-
+ 'chame de novo com repetida:false.'
|
|
632
|
-
: '';
|
|
633
|
-
return {
|
|
634
|
-
ok: true,
|
|
635
|
-
duracao: r.duracao,
|
|
636
|
-
cortado: r.cortado,
|
|
637
|
-
pausas: r.pausas,
|
|
638
|
-
trechos: r.trechos,
|
|
639
|
-
repetidos,
|
|
640
|
-
comoUsar: `${(r.trechos || []).length} trecho(s) sem pausa. Escreva UMA CENA por trecho, `
|
|
641
|
-
+ `cada uma com {"tipo":"clipe","src":"${arquivo}","de":<início>,"ate":<fim>}. `
|
|
642
|
-
+ 'NÃO escreva "duracao" nessas cenas: a cena já dura o trecho.' + aviso,
|
|
643
|
-
};
|
|
644
|
-
}
|
|
645
|
-
}
|
|
646
|
-
return {
|
|
647
|
-
ok: false,
|
|
648
|
-
error: 'A aba de análise não respondeu em 3 minutos. Ela precisa do navegador aberto. '
|
|
649
|
-
+ 'Siga sem o corte automático: escreva os trechos que o usuário pedir, ou use o vídeo inteiro '
|
|
650
|
-
+ 'num clipe só (sem "de"/"ate").',
|
|
651
|
-
};
|
|
652
|
-
}
|
|
653
|
-
|
|
654
|
-
/* ── Exportar o vídeo sem ninguém clicar ────────────────────────────────
|
|
655
|
-
*
|
|
656
|
-
* "Ele libera, você volta ao terminal... faz testes pedindo vídeo e pra ele
|
|
657
|
-
* editar vídeos."
|
|
658
|
-
*
|
|
659
|
-
* O botão "Baixar" do player já monta o arquivo certo (cena a cena, com a
|
|
660
|
-
* narração, a trilha e os clipes mixados). O que faltava era ALGUÉM clicar:
|
|
661
|
-
* o agente entregava um link e dizia "clique em baixar". Aqui a mesma página
|
|
662
|
-
* roda num navegador escondido, gera pelo MESMO caminho do botão
|
|
663
|
-
* (Player.exportar) e devolve o arquivo ao estúdio; daqui ele vai para a
|
|
664
|
-
* pasta do projeto, em .mp4 quando há ffmpeg.
|
|
665
|
-
*
|
|
666
|
-
* ── O NAVEGADOR ESCONDIDO NÃO LÊ H.264 ─────────────────────────────────
|
|
667
|
-
* O Chromium do Playwright (o que existe em CI e em máquina sem Chrome) não
|
|
668
|
-
* traz H.264 nem AAC — são patentes. Um .mp4 do usuário abre lá em PRETO e
|
|
669
|
-
* MUDO, sem erro nenhum, e o export sairia "perfeito" com um buraco no meio.
|
|
670
|
-
* `capacidadesDoNavegador` pergunta uma vez; `compativel` converte o que ele
|
|
671
|
-
* não lê para uma cópia .webm (VP8/Opus), feita uma vez e guardada ao lado. O
|
|
672
|
-
* mp4 original continua sendo o que a pessoa vê e edita.
|
|
673
|
-
*/
|
|
674
|
-
|
|
675
|
-
const MIDIA_DIR = path.join(CASA, 'midia');
|
|
676
|
-
|
|
677
|
-
let ffmpegConhecido = null;
|
|
678
|
-
function temFfmpeg() {
|
|
679
|
-
if (ffmpegConhecido !== null) return ffmpegConhecido;
|
|
680
|
-
try { execSync('ffmpeg -version', { stdio: 'ignore', timeout: 5000 }); ffmpegConhecido = true; }
|
|
681
|
-
catch { ffmpegConhecido = false; }
|
|
682
|
-
return ffmpegConhecido;
|
|
683
|
-
}
|
|
684
|
-
|
|
685
|
-
function ffmpeg(args, timeoutMs) {
|
|
686
|
-
const { execFile } = require('child_process');
|
|
687
|
-
return new Promise((ok, falha) => {
|
|
688
|
-
execFile('ffmpeg', ['-y', '-hide_banner', '-loglevel', 'error', ...args],
|
|
689
|
-
{ timeout: timeoutMs || 30 * 60 * 1000, maxBuffer: 8 * 1024 * 1024 }, (erro, so, se) => {
|
|
690
|
-
if (erro) return falha(new Error('ffmpeg: ' + String(se || erro.message).trim().split('\n').pop()));
|
|
691
|
-
ok();
|
|
692
|
-
});
|
|
693
|
-
});
|
|
694
|
-
}
|
|
695
|
-
|
|
696
|
-
let capacidades = null;
|
|
697
|
-
async function capacidadesDoNavegador(escondido) {
|
|
698
|
-
if (capacidades) return capacidades;
|
|
699
|
-
try {
|
|
700
|
-
const r = await escondido(`${BASE}/`, {
|
|
701
|
-
prazo: 15000,
|
|
702
|
-
script: `(function(){ var v = document.createElement('video');
|
|
703
|
-
window.__primo = { h264: !!v.canPlayType('video/mp4; codecs="avc1.42E01E"'),
|
|
704
|
-
aac: !!v.canPlayType('audio/mp4; codecs="mp4a.40.2"'),
|
|
705
|
-
mp4Grava: !!(window.MediaRecorder && MediaRecorder.isTypeSupported('video/mp4')) }; })()`,
|
|
706
|
-
});
|
|
707
|
-
capacidades = (r && r.valor) || { h264: false, aac: false, mp4Grava: false };
|
|
708
|
-
} catch {
|
|
709
|
-
// Sem navegador escondido nesta máquina: quem vai tocar é o da pessoa,
|
|
710
|
-
// e o dela (Chrome, Edge, Safari) lê tudo.
|
|
711
|
-
capacidades = { h264: true, aac: true, mp4Grava: false, semEscondido: true };
|
|
712
|
-
}
|
|
713
|
-
return capacidades;
|
|
714
|
-
}
|
|
715
|
-
|
|
716
|
-
/* O arquivo tem faixa de áudio? Sem ffprobe, "não sei" — e "não sei" é sim:
|
|
717
|
-
a página ainda vai tentar, e o erro dela é traduzido em esperarAnalise. */
|
|
718
|
-
function temAudio(caminho) {
|
|
719
|
-
if (!caminho || !fs.existsSync(caminho) || !temFfmpeg()) return true;
|
|
720
|
-
try {
|
|
721
|
-
const saida = execSync(`ffprobe -v error -select_streams a -show_entries stream=codec_type -of csv=p=0 "${caminho}"`,
|
|
722
|
-
{ encoding: 'utf8', timeout: 20000, stdio: ['ignore', 'pipe', 'ignore'] });
|
|
723
|
-
return /audio/.test(saida);
|
|
724
|
-
} catch { return true; }
|
|
725
|
-
}
|
|
726
|
-
|
|
727
|
-
/* A URL "/midia/x.mp4" → o arquivo no disco. */
|
|
728
|
-
function noDisco(url) {
|
|
729
|
-
const nome = path.basename(decodeURIComponent(String(url || '').replace(/^\/midia\//, '')));
|
|
730
|
-
return nome && !nome.includes('..') ? path.join(MIDIA_DIR, nome) : null;
|
|
731
|
-
}
|
|
732
|
-
|
|
733
|
-
/* Devolve uma URL que o navegador escondido consegue tocar. */
|
|
734
|
-
async function compativel(url, escondido) {
|
|
735
|
-
const ext = path.extname(String(url || '')).toLowerCase();
|
|
736
|
-
if (!['.mp4', '.mov', '.m4a', '.aac'].includes(ext)) return { url };
|
|
737
|
-
const cap = await capacidadesDoNavegador(escondido);
|
|
738
|
-
const ehAudio = ext === '.m4a' || ext === '.aac';
|
|
739
|
-
if (ehAudio ? cap.aac : (cap.h264 && cap.aac)) return { url };
|
|
740
|
-
const origem = noDisco(url);
|
|
741
|
-
if (!origem || !fs.existsSync(origem)) return { url };
|
|
742
|
-
if (!temFfmpeg()) {
|
|
743
|
-
return { url, aviso: 'O navegador escondido não decodifica H.264/AAC e não há ffmpeg para converter: '
|
|
744
|
-
+ 'o clipe sairia preto e mudo. Instale o ffmpeg (brew install ffmpeg · sudo apt-get install -y ffmpeg · '
|
|
745
|
-
+ 'winget install Gyan.FFmpeg) e chame de novo.' };
|
|
746
|
-
}
|
|
747
|
-
const destino = origem.replace(/\.[^.]+$/, '') + '.compat.webm';
|
|
748
|
-
if (!fs.existsSync(destino) || fs.statSync(destino).mtimeMs < fs.statSync(origem).mtimeMs) {
|
|
749
|
-
// VP8 em tempo real, não VP9: um vídeo de cinco minutos em VP9 leva
|
|
750
|
-
// minutos para converter, e isto acontece no meio do pedido da pessoa.
|
|
751
|
-
const args = ehAudio
|
|
752
|
-
? ['-i', origem, '-vn', '-c:a', 'libopus', '-b:a', '128k', destino]
|
|
753
|
-
: ['-i', origem, '-c:v', 'libvpx', '-deadline', 'realtime', '-cpu-used', '8', '-b:v', '2500k',
|
|
754
|
-
'-c:a', 'libopus', '-b:a', '128k', destino];
|
|
755
|
-
await ffmpeg(args);
|
|
756
|
-
}
|
|
757
|
-
return { url: '/midia/' + path.basename(destino), convertido: true };
|
|
758
|
-
}
|
|
759
|
-
|
|
760
|
-
/* Toda URL de mídia do estúdio dentro do documento, sem repetição. */
|
|
761
|
-
function srcsDeMidia(doc) {
|
|
762
|
-
const achados = new Set();
|
|
763
|
-
const andar = (o) => {
|
|
764
|
-
if (!o) return;
|
|
765
|
-
if (typeof o === 'string') { if (o.startsWith('/midia/')) achados.add(o); return; }
|
|
766
|
-
if (Array.isArray(o)) return o.forEach(andar);
|
|
767
|
-
if (typeof o === 'object') Object.values(o).forEach(andar);
|
|
768
|
-
};
|
|
769
|
-
andar(doc);
|
|
770
|
-
return [...achados];
|
|
771
|
-
}
|
|
772
|
-
|
|
773
|
-
/* Quanto o vídeo dura, para o prazo da exportação — a mesma conta do player,
|
|
774
|
-
por alto: a cena dura o que o autor pediu, o clipe dura o trecho, e a
|
|
775
|
-
narração estica quando é maior. */
|
|
776
|
-
function duracaoEstimada(doc) {
|
|
777
|
-
return (doc.cenas || []).reduce((t, c) => {
|
|
778
|
-
let d = Number(c && c.duracao);
|
|
779
|
-
if (!(d > 0)) {
|
|
780
|
-
const clipe = c && c.tipo === 'clipe' ? c : ((c && c.elementos) || []).find((e) => e && e.tipo === 'clipe');
|
|
781
|
-
d = clipe && clipe.ate > clipe.de ? clipe.ate - clipe.de : 4;
|
|
782
|
-
}
|
|
783
|
-
const n = Number(c && c.narracaoDur);
|
|
784
|
-
if (n > 0) d = Math.max(d, Math.min(90, n + 0.4));
|
|
785
|
-
return t + d;
|
|
786
|
-
}, 0);
|
|
787
|
-
}
|
|
788
|
-
|
|
789
|
-
function errosDoNavegador(r) {
|
|
790
|
-
const erros = (r && r.erros) || [];
|
|
791
|
-
return erros.length ? ' O navegador reclamou: ' + erros.slice(0, 3).map((e) => e.texto).join(' · ') : '';
|
|
792
|
-
}
|
|
793
|
-
|
|
794
|
-
const exportarVideo = ({ id, destino, fps, escondido, pasta, remotion }) => comStudio(() => comIdReal(id, async () => {
|
|
795
|
-
if (!id) return { ok: false, error: 'Falta o id do vídeo.' };
|
|
796
|
-
if (typeof escondido !== 'function') return { ok: false, error: 'Sem navegador escondido para gerar o vídeo.' };
|
|
797
|
-
const a = await pedir('GET', `/api/arquivos/${encodeURIComponent(id)}`);
|
|
798
|
-
if (a.ferramenta !== 'corte') {
|
|
799
|
-
return { ok: false, error: `"${a.titulo}" é ${a.ferramenta}, não vídeo. Só o corte vira .mp4 — para as `
|
|
800
|
-
+ 'outras ferramentas o link "ver" já é a peça pronta.' };
|
|
801
|
-
}
|
|
802
|
-
const doc = a.doc || {};
|
|
803
|
-
if (!(doc.cenas || []).length) return { ok: false, error: 'Este vídeo não tem cenas.' };
|
|
804
|
-
|
|
805
|
-
// 1. Os clipes do usuário, no formato que o navegador escondido toca.
|
|
806
|
-
const mapa = {};
|
|
807
|
-
const avisos = [];
|
|
808
|
-
for (const src of srcsDeMidia(doc)) {
|
|
809
|
-
const r = await compativel(src, escondido);
|
|
810
|
-
if (r.aviso) avisos.push(r.aviso);
|
|
811
|
-
if (r.url !== src) mapa[src] = r.url;
|
|
812
|
-
}
|
|
813
|
-
|
|
814
|
-
// 2. A página do player faz o trabalho do botão e devolve o arquivo ao estúdio.
|
|
815
|
-
const segundos = duracaoEstimada(doc);
|
|
816
|
-
const script = `(async () => {
|
|
817
|
-
function trocarSrc(o, m) {
|
|
818
|
-
if (!o || typeof o !== 'object') return;
|
|
819
|
-
if (Array.isArray(o)) return o.forEach(function (x) { trocarSrc(x, m); });
|
|
820
|
-
Object.keys(o).forEach(function (k) {
|
|
821
|
-
if (typeof o[k] === 'string' && m[o[k]]) o[k] = m[o[k]]; else trocarSrc(o[k], m);
|
|
822
|
-
});
|
|
823
|
-
}
|
|
824
|
-
try {
|
|
825
|
-
const a = await Player.carregado;
|
|
826
|
-
const mapa = ${JSON.stringify(mapa)};
|
|
827
|
-
if (Object.keys(mapa).length) {
|
|
828
|
-
const copia = JSON.parse(JSON.stringify(a));
|
|
829
|
-
trocarSrc(copia.doc, mapa);
|
|
830
|
-
Player.remontar(copia);
|
|
831
|
-
}
|
|
832
|
-
const r = await Player.exportar({ fps: ${Number(fps) > 0 ? Number(fps) : 30} });
|
|
833
|
-
const nome = ${JSON.stringify('export-' + id)} + r.formato;
|
|
834
|
-
const resp = await fetch('/api/midia/' + encodeURIComponent(nome), {
|
|
835
|
-
method: 'PUT', body: r.blob,
|
|
836
|
-
headers: { 'Content-Type': r.blob.type || 'application/octet-stream' },
|
|
837
|
-
});
|
|
838
|
-
const j = await resp.json();
|
|
839
|
-
if (!resp.ok) throw new Error(j.erro || ('HTTP ' + resp.status));
|
|
840
|
-
window.__primo = { ok: true, url: j.url, formato: r.formato, tipo: r.tipo, bytes: r.bytes, segundos: r.segundos, mp4: r.mp4 };
|
|
841
|
-
} catch (e) {
|
|
842
|
-
window.__primo = { ok: false, erro: String((e && e.message) || e) };
|
|
843
|
-
}
|
|
844
|
-
})()`;
|
|
845
|
-
const prazo = Math.round(segundos + 90);
|
|
846
|
-
const r = await escondido(`${BASE}/v/${encodeURIComponent(id)}`, {
|
|
847
|
-
script, gesto: true, prazo: prazo * 1000, passo: 700, assentar: 1500,
|
|
848
|
-
});
|
|
849
|
-
if (!r || !r.valor) return { ok: false, error: `A exportação não terminou em ${prazo}s.` + errosDoNavegador(r) };
|
|
850
|
-
if (!r.valor.ok) return { ok: false, error: `O navegador não conseguiu gerar o vídeo: ${r.valor.erro}.` + errosDoNavegador(r) };
|
|
851
|
-
|
|
852
|
-
// 3. Do estúdio para a pasta do projeto, em mp4 quando dá.
|
|
853
|
-
let origem = noDisco(r.valor.url);
|
|
854
|
-
if (!origem || !fs.existsSync(origem)) return { ok: false, error: 'O navegador gerou o vídeo mas o estúdio não o guardou.' };
|
|
855
|
-
|
|
856
|
-
/* A MESCLA COM O REMOTION. O Studio acabou de gravar a peça em tempo real:
|
|
857
|
-
o ÁUDIO dele é completo (narração, trilha gerada, efeitos sintetizados),
|
|
858
|
-
mas o vídeo gravado depende do relógio — máquina ocupada perde quadro.
|
|
859
|
-
O Remotion desenha a MESMA cena quadro a quadro, determinístico, com o
|
|
860
|
-
motion inteiro (Filme.jsx leva as animações ao instante certo pela Web
|
|
861
|
-
Animations API). Então o vídeo vem do Remotion, o áudio do Studio, e o
|
|
862
|
-
ffmpeg junta. Pedido do dono: "deve usar o Remotion e o Studio, deve
|
|
863
|
-
mesclar os 2". Sem ffmpeg ou com o Remotion indisponível, fica o
|
|
864
|
-
arquivo do Studio — e o aviso diz por quê. */
|
|
865
|
-
let motor = 'studio';
|
|
866
|
-
if (remotion && temFfmpeg()) {
|
|
867
|
-
const rem = require('./remotion.js');
|
|
868
|
-
const c = require('./ui').c;
|
|
869
|
-
const avisar = (t) => console.log(c.dim(' ' + t));
|
|
870
|
-
// O doc que o Remotion desenha é o MESMO que o navegador escondido
|
|
871
|
-
// desenhou: com as cópias compatíveis dos clipes, se houve.
|
|
872
|
-
const docRemotion = JSON.parse(JSON.stringify(doc));
|
|
873
|
-
const trocar = (o) => {
|
|
874
|
-
if (!o || typeof o !== 'object') return;
|
|
875
|
-
if (Array.isArray(o)) return o.forEach(trocar);
|
|
876
|
-
for (const k of Object.keys(o)) {
|
|
877
|
-
if (typeof o[k] === 'string' && mapa[o[k]]) o[k] = mapa[o[k]]; else trocar(o[k]);
|
|
878
|
-
}
|
|
879
|
-
};
|
|
880
|
-
trocar(docRemotion);
|
|
881
|
-
const rr = await rem.renderizarPeca({
|
|
882
|
-
doc: docRemotion, titulo: a.titulo, fps: Number(fps) > 0 ? Number(fps) : 30,
|
|
883
|
-
midiaDir: MIDIA_DIR, semAudio: true, avisar,
|
|
884
|
-
});
|
|
885
|
-
if (rr.ok) {
|
|
886
|
-
// Vídeo do Remotion + áudio do Studio. `-shortest` porque as duas
|
|
887
|
-
// durações podem diferir por um quadro.
|
|
888
|
-
const mesclado = origem.replace(/\.[^.]+$/, '') + '.remotion.mp4';
|
|
889
|
-
try {
|
|
890
|
-
await ffmpeg(['-i', rr.arquivo, '-i', origem, '-map', '0:v:0', '-map', '1:a:0?',
|
|
891
|
-
'-c:v', 'copy', '-c:a', 'aac', '-b:a', '160k', '-shortest', '-movflags', '+faststart', mesclado]);
|
|
892
|
-
if (fs.existsSync(mesclado) && fs.statSync(mesclado).size > 10000) {
|
|
893
|
-
try { fs.unlinkSync(origem); } catch { /* fica */ }
|
|
894
|
-
origem = mesclado;
|
|
895
|
-
motor = 'remotion+studio';
|
|
896
|
-
r.valor.tipo = 'video/mp4; codecs="avc1"';
|
|
897
|
-
r.valor.formato = '.mp4';
|
|
898
|
-
}
|
|
899
|
-
} catch (e) {
|
|
900
|
-
avisos.push(`O Remotion renderizou mas a mistura com o áudio falhou (${String(e.message || e).slice(0, 120)}); ficou o arquivo do Studio.`);
|
|
901
|
-
}
|
|
902
|
-
} else {
|
|
903
|
-
avisos.push(`O Remotion não renderizou desta vez (${String(rr.error || '').slice(0, 160)}); ficou o arquivo do Studio, gravado em tempo real.`);
|
|
904
|
-
}
|
|
905
|
-
}
|
|
906
|
-
const base = pasta || process.cwd();
|
|
907
|
-
const slug = String(a.titulo || 'video').normalize('NFD').replace(/[̀-ͯ]/g, '')
|
|
908
|
-
.replace(/[^\w-]+/g, '-').replace(/^-+|-+$/g, '').toLowerCase() || 'video';
|
|
909
|
-
let saida = destino ? path.resolve(base, destino) : path.join(base, `${slug}.mp4`);
|
|
910
|
-
if (!path.extname(saida)) saida += '.mp4';
|
|
911
|
-
fs.mkdirSync(path.dirname(saida), { recursive: true });
|
|
912
|
-
let formato = String(r.valor.formato || '.webm');
|
|
913
|
-
// "mp4" do gravador do navegador pode ser VP9 dentro de um .mp4 — o
|
|
914
|
-
// Chromium sem H.264 grava assim, e o arquivo não abre no iPhone nem no
|
|
915
|
-
// QuickTime. O que decide é o CODEC (avc1), não a extensão: sem avc1 e
|
|
916
|
-
// com ffmpeg na máquina, o arquivo é recodificado em H.264/AAC.
|
|
917
|
-
const jaH264 = /avc1|avc3|h264/i.test(String(r.valor.tipo || ''));
|
|
918
|
-
if ((formato === '.webm' || !jaH264) && /\.mp4$/i.test(saida)) {
|
|
919
|
-
if (temFfmpeg()) {
|
|
920
|
-
await ffmpeg(['-i', origem, '-c:v', 'libx264', '-preset', 'veryfast', '-crf', '20', '-pix_fmt', 'yuv420p',
|
|
921
|
-
'-c:a', 'aac', '-b:a', '160k', '-movflags', '+faststart', saida]);
|
|
922
|
-
formato = '.mp4';
|
|
923
|
-
} else if (formato === '.webm') {
|
|
924
|
-
saida = saida.replace(/\.mp4$/i, '.webm');
|
|
925
|
-
fs.copyFileSync(origem, saida);
|
|
926
|
-
avisos.push('Sem ffmpeg nesta máquina o arquivo ficou em .webm (toca em qualquer navegador e no VLC); '
|
|
927
|
-
+ 'instale o ffmpeg para sair em .mp4.');
|
|
928
|
-
} else {
|
|
929
|
-
fs.copyFileSync(origem, saida);
|
|
930
|
-
avisos.push('O .mp4 saiu em VP9 (este navegador não grava H.264) — abre no Chrome, no Android e no VLC, '
|
|
931
|
-
+ 'mas não no iPhone nem no QuickTime. Com o ffmpeg instalado ele sai em H.264.');
|
|
932
|
-
}
|
|
933
|
-
} else {
|
|
934
|
-
if (path.extname(saida).toLowerCase() !== formato) saida = saida.replace(/\.[^.]+$/, '') + formato;
|
|
935
|
-
fs.copyFileSync(origem, saida);
|
|
936
|
-
}
|
|
937
|
-
// O rascunho no estúdio já cumpriu o papel: ficar lá seria um vídeo
|
|
938
|
-
// inteiro duplicado na galeria a cada exportação.
|
|
939
|
-
try { fs.unlinkSync(origem); } catch { /* fica */ }
|
|
940
|
-
const bytes = fs.statSync(saida).size;
|
|
941
|
-
return {
|
|
942
|
-
ok: true,
|
|
943
|
-
arquivo: saida,
|
|
944
|
-
formato: formato.slice(1),
|
|
945
|
-
motor,
|
|
946
|
-
bytes,
|
|
947
|
-
segundos: r.valor.segundos,
|
|
948
|
-
avisos: avisos.length ? avisos : undefined,
|
|
949
|
-
comoUsar: `O vídeo está em ${saida} (${(bytes / 1048576).toFixed(1)} MB, ${Math.round(r.valor.segundos || segundos)}s). `
|
|
950
|
-
+ 'Diga ao usuário o caminho, em uma linha.' + (avisos.length ? ' Avisos: ' + avisos.join(' ') : ''),
|
|
951
|
-
};
|
|
952
|
-
}));
|
|
953
|
-
|
|
954
|
-
module.exports = {
|
|
955
|
-
BASE, PORTA, CASA, MIDIA_DIR,
|
|
956
|
-
garantirNoAr, estaNoAr, prepararCasa, pythonCmd, derrubar,
|
|
957
|
-
manual, ferramentas, spec, listar, ler, criar, atualizar, versoesDe,
|
|
958
|
-
midiaListar, midiaEnviar, analisarAudio, exportarVideo,
|
|
959
|
-
temFfmpeg, compativel, capacidadesDoNavegador, srcsDeMidia, duracaoEstimada,
|
|
960
|
-
};
|