primocode 8.37.0 → 8.38.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/bin/primocode.js +65 -50
- package/lib/act.js +121 -78
- package/lib/area.js +104 -0
- package/lib/browser.js +110 -2
- package/lib/catalogo.js +7 -2
- package/lib/conta.js +122 -29
- package/lib/especialistas.js +123 -0
- package/lib/fala.js +146 -0
- package/lib/studio.js +319 -10
- package/lib/tools.js +19 -5
- package/lib/ui.js +57 -22
- package/package.json +2 -2
- package/studio/ferramentas/01-modelos.md +3 -1
- package/studio/ferramentas/corte.md +8 -0
- package/studio/ferramentas/prisma.md +8 -0
- package/studio/server.py +2 -1
- package/studio/web/js/icones.js +30 -1
- package/studio/web/js/player.js +58 -16
- package/studio/web/js/render.js +53 -1
- package/studio/web/js/video.js +44 -18
- package/studio/web/render.css +80 -0
package/lib/fala.js
ADDED
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* fala.js — a voz do terminal: como cada coisa que vai e vem é desenhada.
|
|
3
|
+
*
|
|
4
|
+
* "Melhore o layout das mensagens que vão e vem, deve ficar mais bonito.
|
|
5
|
+
* Não deve aparecer erros [...]. Nunca mostrar limitações tipo limite do
|
|
6
|
+
* minuto, tudo deve ocorrer ocultamente; deve mostrar um pensamento e
|
|
7
|
+
* coisas com aquela sua personalidade tipo: 'vídeo está em formato
|
|
8
|
+
* inválido, vou convertê-lo para mp4' / Bash (comando) / 'Feito,
|
|
9
|
+
* continuando...'"
|
|
10
|
+
*
|
|
11
|
+
* ── UM LUGAR SÓ ─────────────────────────────────────────────────────────
|
|
12
|
+
* Antes cada arquivo desenhava do seu jeito: o act.js com `⎿` para cabeçalho
|
|
13
|
+
* E resultado (tudo no mesmo nível, sem hierarquia), o bin com `c.white`
|
|
14
|
+
* solto para a resposta do chat, e os avisos do encanamento ("limite do
|
|
15
|
+
* minuto", "conversa encolhida", "o modelo tropeçou") saindo na tela como se
|
|
16
|
+
* fossem parte do trabalho. Aqui mora a gramática inteira:
|
|
17
|
+
*
|
|
18
|
+
* ❯ o que a pessoa pediu (o prompt, no bin)
|
|
19
|
+
* ✦ o vídeo veio em .avi, vou converter pensamento()
|
|
20
|
+
* ● Bash ffmpeg -y -i entrada.avi saida.mp4 acao()
|
|
21
|
+
* ⎿ exit 0 resultado() + act.logResult
|
|
22
|
+
* ✦ Feito, continuando. pensamento()
|
|
23
|
+
*
|
|
24
|
+
* ● A resposta, alinhada ao ❯ de quem perguntou. resposta() / fluxo()
|
|
25
|
+
*
|
|
26
|
+
* O ❯ e o ● têm a mesma cor no mesmo nível: pergunta e resposta se espelham;
|
|
27
|
+
* o trabalho entre os dois fica recuado, como um parêntese que se lê depois.
|
|
28
|
+
*
|
|
29
|
+
* ── O QUE NÃO APARECE ───────────────────────────────────────────────────
|
|
30
|
+
* Limite do minuto, encolhimento da conversa, tropeço na chamada de função,
|
|
31
|
+
* resposta vazia, troca de modelo, chamada repetida: nada disso é do usuário.
|
|
32
|
+
* O que ele vê é o spinner de sempre girando um pouco mais. `problema()` é o
|
|
33
|
+
* único caminho de erro para a tela, e sai calmo — âmbar, uma linha, sem
|
|
34
|
+
* JSON: quem chega ali é o que a pessoa PRECISA fazer (crédito, chave,
|
|
35
|
+
* rede), nunca o que o encanamento passou para chegar até aqui.
|
|
36
|
+
*/
|
|
37
|
+
|
|
38
|
+
'use strict';
|
|
39
|
+
|
|
40
|
+
const { c, actionHeader, friendlyError } = require('./ui');
|
|
41
|
+
|
|
42
|
+
/* Até este tamanho, o texto que o modelo escreve antes de chamar uma tool é
|
|
43
|
+
guardado e sai de uma vez, como pensamento. Passou disso sem tool à vista,
|
|
44
|
+
é resposta — e volta a correr na tela conforme chega. Uma frase de
|
|
45
|
+
narração tem 60-150 caracteres; 320 dá folga para duas. */
|
|
46
|
+
const LIMIAR_PENSAMENTO = 320;
|
|
47
|
+
|
|
48
|
+
const ident = (n) => ' '.repeat(n || 0);
|
|
49
|
+
|
|
50
|
+
function linhas(texto) {
|
|
51
|
+
return String(texto || '').replace(/\r/g, '').trim().split('\n').map((l) => l.trimEnd());
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** O que o Primo pensa antes de agir: recuado, cinza, com ✦. */
|
|
55
|
+
function pensamento(texto, profundidade) {
|
|
56
|
+
const ls = linhas(texto);
|
|
57
|
+
if (!ls.length || !ls[0]) return '';
|
|
58
|
+
const p = ident(profundidade);
|
|
59
|
+
const saida = ls.map((l, i) => `${p} ${i === 0 ? c.dim('✦') : ' '} ${c.muted(l)}`).join('\n');
|
|
60
|
+
console.log(saida);
|
|
61
|
+
return saida;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** A ação (tool) que ele decidiu fazer. */
|
|
65
|
+
function acao(name, args, profundidade) {
|
|
66
|
+
const saida = ident(profundidade) + actionHeader(name, args);
|
|
67
|
+
console.log(saida);
|
|
68
|
+
return saida;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** O prefixo das linhas de resultado: embaixo da ação, um degrau para dentro. */
|
|
72
|
+
function resultado(profundidade) {
|
|
73
|
+
return ident(profundidade) + c.muted(' ⎿ ');
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function formatar(texto, marca) {
|
|
77
|
+
return linhas(texto).map((l, i) => (i === 0 ? `${marca} ${c.white(l)}` : ` ${c.white(l)}`)).join('\n');
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** A resposta final, pronta. Espelha o ❯ de quem perguntou. */
|
|
81
|
+
function resposta(texto) {
|
|
82
|
+
if (!String(texto || '').trim()) return '';
|
|
83
|
+
const saida = '\n' + formatar(texto, c.brand('●'));
|
|
84
|
+
console.log(saida);
|
|
85
|
+
return saida;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** O que precisa ser dito e não é resposta: parou, não concluiu, tem algo a
|
|
89
|
+
* fazer. Âmbar, nunca vermelho — é aviso, não catástrofe. */
|
|
90
|
+
function aviso(texto) {
|
|
91
|
+
if (!String(texto || '').trim()) return '';
|
|
92
|
+
const saida = '\n' + formatar(texto, c.warn('●'));
|
|
93
|
+
console.log(saida);
|
|
94
|
+
return saida;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/** Um erro que chegou até aqui. Passa pelo friendlyError e sai como aviso. */
|
|
98
|
+
function problema(erro) {
|
|
99
|
+
return aviso(friendlyError(erro));
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/** A frase humana de um erro, para quem precisa do texto e não da tela. */
|
|
103
|
+
const frase = friendlyError;
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* A resposta que chega em pedaços (o chat, e a resposta longa do agente).
|
|
107
|
+
* O `●` sai no primeiro pedaço; quebra de linha dentro do texto ganha o
|
|
108
|
+
* recuo, para o bloco continuar alinhado ao marcador.
|
|
109
|
+
*/
|
|
110
|
+
function fluxo() {
|
|
111
|
+
let escreveu = false;
|
|
112
|
+
return {
|
|
113
|
+
escrever(delta) {
|
|
114
|
+
const t = String(delta || '');
|
|
115
|
+
if (!t) return;
|
|
116
|
+
if (!escreveu) { process.stdout.write('\n' + c.brand('●') + ' '); escreveu = true; }
|
|
117
|
+
process.stdout.write(c.white(t.replace(/\n/g, '\n ')));
|
|
118
|
+
},
|
|
119
|
+
fechar() { if (escreveu) process.stdout.write('\n'); },
|
|
120
|
+
escreveu: () => escreveu,
|
|
121
|
+
};
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/** Vários agentes saindo juntos. */
|
|
125
|
+
function paralelo(quantos, profundidade) {
|
|
126
|
+
const saida = `${ident(profundidade)} ${c.brand('◆')} ${c.bold('Agentes')} ${c.muted(`${quantos} em paralelo`)}`;
|
|
127
|
+
console.log(saida);
|
|
128
|
+
return saida;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/** Um agente delegado começando: "◆ Agente·frontend a folha de estilo". */
|
|
132
|
+
function agente(rotulo, descricao, profundidade) {
|
|
133
|
+
const saida = `${ident(profundidade)} ${c.brand('◆')} ${c.bold(rotulo || 'Agente')}${descricao ? ' ' + c.muted(descricao) : ''}`;
|
|
134
|
+
console.log(saida);
|
|
135
|
+
return saida;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/** …e voltando, com o resumo dele embaixo, como um resultado. */
|
|
139
|
+
function agenteVoltou(resumo, profundidade) {
|
|
140
|
+
const texto = String(resumo || 'sem resultado').replace(/\s+/g, ' ').trim();
|
|
141
|
+
const saida = resultado(profundidade) + c.muted(texto.length > 140 ? texto.slice(0, 139) + '…' : texto);
|
|
142
|
+
console.log(saida);
|
|
143
|
+
return saida;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
module.exports = { LIMIAR_PENSAMENTO, pensamento, acao, resultado, resposta, aviso, problema, frase, fluxo, paralelo, agente, agenteVoltou };
|
package/lib/studio.js
CHANGED
|
@@ -410,7 +410,13 @@ const TIPOS_MIDIA = {
|
|
|
410
410
|
'.mp4': 1, '.webm': 1, '.mov': 1,
|
|
411
411
|
};
|
|
412
412
|
|
|
413
|
-
const midiaListar = () => comStudio(async () =>
|
|
413
|
+
const midiaListar = () => comStudio(async () => {
|
|
414
|
+
const lista = await pedir('GET', '/api/midia');
|
|
415
|
+
// As cópias `.compat.webm` são encanamento da exportação escondida (ver
|
|
416
|
+
// `compativel`), não mídia do usuário: listá-las faria o agente usá-las.
|
|
417
|
+
const midia = Array.isArray(lista) ? lista.filter((m) => !/\.compat\.webm$/i.test(String((m && (m.nome || m.url)) || ''))) : lista;
|
|
418
|
+
return { ok: true, midia };
|
|
419
|
+
});
|
|
414
420
|
|
|
415
421
|
/* O caminho como ele chega quando a pessoa ARRASTA o arquivo para o terminal.
|
|
416
422
|
*
|
|
@@ -450,7 +456,25 @@ const midiaEnviar = ({ arquivo, nome }) => comStudio(async () => {
|
|
|
450
456
|
|
|
451
457
|
const ext = path.extname(caminho).toLowerCase();
|
|
452
458
|
if (!TIPOS_MIDIA[ext]) {
|
|
453
|
-
|
|
459
|
+
// O erro traz o CONSERTO: o comando pronto. É o que faz "o vídeo veio
|
|
460
|
+
// em .avi, vou converter para mp4 / Bash / feito, continuando" ser o
|
|
461
|
+
// que acontece, em vez de "formato não aceito" e a tarefa morrendo —
|
|
462
|
+
// o modelo copia o comando, roda e manda o arquivo certo.
|
|
463
|
+
const base = caminho.replace(/\.[^.]+$/, '');
|
|
464
|
+
const pareceAudio = /^\.(wma|aiff?|amr|mid|midi|ra|au|ape|mka)$/.test(ext);
|
|
465
|
+
const pareceImagem = /^\.(heic|heif|bmp|tiff?|ico|psd|raw|cr2|nef)$/.test(ext);
|
|
466
|
+
const comando = pareceAudio
|
|
467
|
+
? `ffmpeg -y -i "${caminho}" -vn -c:a libmp3lame -q:a 2 "${base}.mp3"`
|
|
468
|
+
: pareceImagem
|
|
469
|
+
? `ffmpeg -y -i "${caminho}" "${base}.png"`
|
|
470
|
+
: `ffmpeg -y -i "${caminho}" -c:v libx264 -pix_fmt yuv420p -c:a aac -movflags +faststart "${base}.mp4"`;
|
|
471
|
+
return {
|
|
472
|
+
ok: false,
|
|
473
|
+
error: `Formato não aceito: ${ext}. Converta com run_shell e mande o arquivo convertido: ${comando}`,
|
|
474
|
+
aceitos: Object.keys(TIPOS_MIDIA).join(' '),
|
|
475
|
+
comando,
|
|
476
|
+
semFfmpeg: 'brew install ffmpeg · sudo apt-get install -y ffmpeg · winget install Gyan.FFmpeg',
|
|
477
|
+
};
|
|
454
478
|
}
|
|
455
479
|
const bytes = fs.statSync(caminho).size;
|
|
456
480
|
const ehVideo = ext === '.mp4' || ext === '.webm' || ext === '.mov';
|
|
@@ -519,11 +543,38 @@ function enviarArquivo(caminho, nome) {
|
|
|
519
543
|
* `abrir` é injetado (é a função de abrir navegador do tools.js) para esta
|
|
520
544
|
* camada não passar a conhecer o sistema operacional.
|
|
521
545
|
*/
|
|
522
|
-
const analisarAudio = ({ arquivo, pausa, manter, repetida, abrir }) => comStudio(async () => {
|
|
546
|
+
const analisarAudio = ({ arquivo, pausa, manter, repetida, abrir, escondido }) => comStudio(async () => {
|
|
523
547
|
if (!arquivo) return { ok: false, error: 'Diga qual mídia analisar (arquivo: "/midia/x.mp4").' };
|
|
524
|
-
|
|
525
|
-
|
|
548
|
+
// No navegador escondido a conta roda sobre a cópia que ele decodifica
|
|
549
|
+
// (ver `compativel`); o resultado vale para o original, que é o que o
|
|
550
|
+
// agente escreve nas cenas — a linha do tempo é a mesma.
|
|
551
|
+
let alvo = arquivo;
|
|
552
|
+
if (escondido) {
|
|
553
|
+
// Vídeo SEM faixa de áudio: não há pausa para achar, e a página só
|
|
554
|
+
// saberia dizer "não consegui decodificar" — que manda o agente caçar
|
|
555
|
+
// um formato errado que não existe. Quem tem ffprobe pergunta antes.
|
|
556
|
+
if (!temAudio(noDisco(arquivo))) {
|
|
557
|
+
return { ok: false, semAudio: true,
|
|
558
|
+
error: 'Este vídeo não tem faixa de áudio: não há pausa nem fala para cortar. Use-o inteiro num '
|
|
559
|
+
+ 'clipe só (sem "de"/"ate") e, se quiser voz, escreva "narracao" na cena — ela entra por cima.' };
|
|
560
|
+
}
|
|
561
|
+
const c = await compativel(arquivo, escondido);
|
|
562
|
+
if (c.aviso) return { ok: false, error: c.aviso };
|
|
563
|
+
alvo = c.url;
|
|
564
|
+
}
|
|
565
|
+
const tarefa = await pedir('POST', '/api/analise', { arquivo: alvo, pausa, manter, repetida });
|
|
566
|
+
// `abrir` pode devolver a aba escondida (com `fechar`) ou nada (o
|
|
567
|
+
// navegador da pessoa). A aba fica de pé até a análise voltar.
|
|
568
|
+
let aba = null;
|
|
569
|
+
if (abrir) { try { aba = await abrir(`${BASE}${tarefa.pagina}`); } catch { /* segue e expira */ } }
|
|
570
|
+
try {
|
|
571
|
+
return await esperarAnalise(tarefa, arquivo);
|
|
572
|
+
} finally {
|
|
573
|
+
if (aba && typeof aba.fechar === 'function') { try { await aba.fechar(); } catch { /* já fechou */ } }
|
|
574
|
+
}
|
|
575
|
+
});
|
|
526
576
|
|
|
577
|
+
async function esperarAnalise(tarefa, arquivo) {
|
|
527
578
|
// Um vídeo de meia hora leva alguns segundos para decodificar; três
|
|
528
579
|
// minutos é folga com sobra, e continua sendo um teto — sem ele um
|
|
529
580
|
// navegador que nunca abriu deixaria a tarefa pendurada para sempre.
|
|
@@ -533,7 +584,16 @@ const analisarAudio = ({ arquivo, pausa, manter, repetida, abrir }) => comStudio
|
|
|
533
584
|
const estado = await pedir('GET', `/api/analise/${tarefa.id}`, null, 8000).catch(() => null);
|
|
534
585
|
if (!estado) continue;
|
|
535
586
|
if (estado.estado === 'erro') {
|
|
536
|
-
|
|
587
|
+
const erro = String((estado.resultado && estado.resultado.erro) || 'a análise falhou');
|
|
588
|
+
// O erro do navegador é genérico; o que o agente precisa é do
|
|
589
|
+
// caminho a seguir, e ele é o mesmo nos dois casos que produzem
|
|
590
|
+
// essa frase (sem faixa de áudio; formato que o navegador não lê).
|
|
591
|
+
if (/decode audio|decodeAudioData|EncodingError/i.test(erro)) {
|
|
592
|
+
return { ok: false, error: 'Não consegui ler o áudio deste arquivo — ele pode não ter faixa de áudio, '
|
|
593
|
+
+ 'ou estar num formato que o navegador não decodifica. Siga sem o corte automático: use o vídeo '
|
|
594
|
+
+ 'inteiro num clipe só (sem "de"/"ate"), ou converta com ffmpeg para mp4 (H.264/AAC) e mande de novo.' };
|
|
595
|
+
}
|
|
596
|
+
return { ok: false, error: erro };
|
|
537
597
|
}
|
|
538
598
|
if (estado.estado === 'pronto') {
|
|
539
599
|
const r = estado.resultado || {};
|
|
@@ -568,11 +628,260 @@ const analisarAudio = ({ arquivo, pausa, manter, repetida, abrir }) => comStudio
|
|
|
568
628
|
+ 'Siga sem o corte automático: escreva os trechos que o usuário pedir, ou use o vídeo inteiro '
|
|
569
629
|
+ 'num clipe só (sem "de"/"ate").',
|
|
570
630
|
};
|
|
571
|
-
}
|
|
631
|
+
}
|
|
632
|
+
|
|
633
|
+
/* ── Exportar o vídeo sem ninguém clicar ────────────────────────────────
|
|
634
|
+
*
|
|
635
|
+
* "Ele libera, você volta ao terminal... faz testes pedindo vídeo e pra ele
|
|
636
|
+
* editar vídeos."
|
|
637
|
+
*
|
|
638
|
+
* O botão "Baixar" do player já monta o arquivo certo (cena a cena, com a
|
|
639
|
+
* narração, a trilha e os clipes mixados). O que faltava era ALGUÉM clicar:
|
|
640
|
+
* o agente entregava um link e dizia "clique em baixar". Aqui a mesma página
|
|
641
|
+
* roda num navegador escondido, gera pelo MESMO caminho do botão
|
|
642
|
+
* (Player.exportar) e devolve o arquivo ao estúdio; daqui ele vai para a
|
|
643
|
+
* pasta do projeto, em .mp4 quando há ffmpeg.
|
|
644
|
+
*
|
|
645
|
+
* ── O NAVEGADOR ESCONDIDO NÃO LÊ H.264 ─────────────────────────────────
|
|
646
|
+
* O Chromium do Playwright (o que existe em CI e em máquina sem Chrome) não
|
|
647
|
+
* traz H.264 nem AAC — são patentes. Um .mp4 do usuário abre lá em PRETO e
|
|
648
|
+
* MUDO, sem erro nenhum, e o export sairia "perfeito" com um buraco no meio.
|
|
649
|
+
* `capacidadesDoNavegador` pergunta uma vez; `compativel` converte o que ele
|
|
650
|
+
* não lê para uma cópia .webm (VP8/Opus), feita uma vez e guardada ao lado. O
|
|
651
|
+
* mp4 original continua sendo o que a pessoa vê e edita.
|
|
652
|
+
*/
|
|
653
|
+
|
|
654
|
+
const MIDIA_DIR = path.join(CASA, 'midia');
|
|
655
|
+
|
|
656
|
+
let ffmpegConhecido = null;
|
|
657
|
+
function temFfmpeg() {
|
|
658
|
+
if (ffmpegConhecido !== null) return ffmpegConhecido;
|
|
659
|
+
try { execSync('ffmpeg -version', { stdio: 'ignore', timeout: 5000 }); ffmpegConhecido = true; }
|
|
660
|
+
catch { ffmpegConhecido = false; }
|
|
661
|
+
return ffmpegConhecido;
|
|
662
|
+
}
|
|
663
|
+
|
|
664
|
+
function ffmpeg(args, timeoutMs) {
|
|
665
|
+
const { execFile } = require('child_process');
|
|
666
|
+
return new Promise((ok, falha) => {
|
|
667
|
+
execFile('ffmpeg', ['-y', '-hide_banner', '-loglevel', 'error', ...args],
|
|
668
|
+
{ timeout: timeoutMs || 30 * 60 * 1000, maxBuffer: 8 * 1024 * 1024 }, (erro, so, se) => {
|
|
669
|
+
if (erro) return falha(new Error('ffmpeg: ' + String(se || erro.message).trim().split('\n').pop()));
|
|
670
|
+
ok();
|
|
671
|
+
});
|
|
672
|
+
});
|
|
673
|
+
}
|
|
674
|
+
|
|
675
|
+
let capacidades = null;
|
|
676
|
+
async function capacidadesDoNavegador(escondido) {
|
|
677
|
+
if (capacidades) return capacidades;
|
|
678
|
+
try {
|
|
679
|
+
const r = await escondido(`${BASE}/`, {
|
|
680
|
+
prazo: 15000,
|
|
681
|
+
script: `(function(){ var v = document.createElement('video');
|
|
682
|
+
window.__primo = { h264: !!v.canPlayType('video/mp4; codecs="avc1.42E01E"'),
|
|
683
|
+
aac: !!v.canPlayType('audio/mp4; codecs="mp4a.40.2"'),
|
|
684
|
+
mp4Grava: !!(window.MediaRecorder && MediaRecorder.isTypeSupported('video/mp4')) }; })()`,
|
|
685
|
+
});
|
|
686
|
+
capacidades = (r && r.valor) || { h264: false, aac: false, mp4Grava: false };
|
|
687
|
+
} catch {
|
|
688
|
+
// Sem navegador escondido nesta máquina: quem vai tocar é o da pessoa,
|
|
689
|
+
// e o dela (Chrome, Edge, Safari) lê tudo.
|
|
690
|
+
capacidades = { h264: true, aac: true, mp4Grava: false, semEscondido: true };
|
|
691
|
+
}
|
|
692
|
+
return capacidades;
|
|
693
|
+
}
|
|
694
|
+
|
|
695
|
+
/* O arquivo tem faixa de áudio? Sem ffprobe, "não sei" — e "não sei" é sim:
|
|
696
|
+
a página ainda vai tentar, e o erro dela é traduzido em esperarAnalise. */
|
|
697
|
+
function temAudio(caminho) {
|
|
698
|
+
if (!caminho || !fs.existsSync(caminho) || !temFfmpeg()) return true;
|
|
699
|
+
try {
|
|
700
|
+
const saida = execSync(`ffprobe -v error -select_streams a -show_entries stream=codec_type -of csv=p=0 "${caminho}"`,
|
|
701
|
+
{ encoding: 'utf8', timeout: 20000, stdio: ['ignore', 'pipe', 'ignore'] });
|
|
702
|
+
return /audio/.test(saida);
|
|
703
|
+
} catch { return true; }
|
|
704
|
+
}
|
|
705
|
+
|
|
706
|
+
/* A URL "/midia/x.mp4" → o arquivo no disco. */
|
|
707
|
+
function noDisco(url) {
|
|
708
|
+
const nome = path.basename(decodeURIComponent(String(url || '').replace(/^\/midia\//, '')));
|
|
709
|
+
return nome && !nome.includes('..') ? path.join(MIDIA_DIR, nome) : null;
|
|
710
|
+
}
|
|
711
|
+
|
|
712
|
+
/* Devolve uma URL que o navegador escondido consegue tocar. */
|
|
713
|
+
async function compativel(url, escondido) {
|
|
714
|
+
const ext = path.extname(String(url || '')).toLowerCase();
|
|
715
|
+
if (!['.mp4', '.mov', '.m4a', '.aac'].includes(ext)) return { url };
|
|
716
|
+
const cap = await capacidadesDoNavegador(escondido);
|
|
717
|
+
const ehAudio = ext === '.m4a' || ext === '.aac';
|
|
718
|
+
if (ehAudio ? cap.aac : (cap.h264 && cap.aac)) return { url };
|
|
719
|
+
const origem = noDisco(url);
|
|
720
|
+
if (!origem || !fs.existsSync(origem)) return { url };
|
|
721
|
+
if (!temFfmpeg()) {
|
|
722
|
+
return { url, aviso: 'O navegador escondido não decodifica H.264/AAC e não há ffmpeg para converter: '
|
|
723
|
+
+ 'o clipe sairia preto e mudo. Instale o ffmpeg (brew install ffmpeg · sudo apt-get install -y ffmpeg · '
|
|
724
|
+
+ 'winget install Gyan.FFmpeg) e chame de novo.' };
|
|
725
|
+
}
|
|
726
|
+
const destino = origem.replace(/\.[^.]+$/, '') + '.compat.webm';
|
|
727
|
+
if (!fs.existsSync(destino) || fs.statSync(destino).mtimeMs < fs.statSync(origem).mtimeMs) {
|
|
728
|
+
// VP8 em tempo real, não VP9: um vídeo de cinco minutos em VP9 leva
|
|
729
|
+
// minutos para converter, e isto acontece no meio do pedido da pessoa.
|
|
730
|
+
const args = ehAudio
|
|
731
|
+
? ['-i', origem, '-vn', '-c:a', 'libopus', '-b:a', '128k', destino]
|
|
732
|
+
: ['-i', origem, '-c:v', 'libvpx', '-deadline', 'realtime', '-cpu-used', '8', '-b:v', '2500k',
|
|
733
|
+
'-c:a', 'libopus', '-b:a', '128k', destino];
|
|
734
|
+
await ffmpeg(args);
|
|
735
|
+
}
|
|
736
|
+
return { url: '/midia/' + path.basename(destino), convertido: true };
|
|
737
|
+
}
|
|
738
|
+
|
|
739
|
+
/* Toda URL de mídia do estúdio dentro do documento, sem repetição. */
|
|
740
|
+
function srcsDeMidia(doc) {
|
|
741
|
+
const achados = new Set();
|
|
742
|
+
const andar = (o) => {
|
|
743
|
+
if (!o) return;
|
|
744
|
+
if (typeof o === 'string') { if (o.startsWith('/midia/')) achados.add(o); return; }
|
|
745
|
+
if (Array.isArray(o)) return o.forEach(andar);
|
|
746
|
+
if (typeof o === 'object') Object.values(o).forEach(andar);
|
|
747
|
+
};
|
|
748
|
+
andar(doc);
|
|
749
|
+
return [...achados];
|
|
750
|
+
}
|
|
751
|
+
|
|
752
|
+
/* Quanto o vídeo dura, para o prazo da exportação — a mesma conta do player,
|
|
753
|
+
por alto: a cena dura o que o autor pediu, o clipe dura o trecho, e a
|
|
754
|
+
narração estica quando é maior. */
|
|
755
|
+
function duracaoEstimada(doc) {
|
|
756
|
+
return (doc.cenas || []).reduce((t, c) => {
|
|
757
|
+
let d = Number(c && c.duracao);
|
|
758
|
+
if (!(d > 0)) {
|
|
759
|
+
const clipe = c && c.tipo === 'clipe' ? c : ((c && c.elementos) || []).find((e) => e && e.tipo === 'clipe');
|
|
760
|
+
d = clipe && clipe.ate > clipe.de ? clipe.ate - clipe.de : 4;
|
|
761
|
+
}
|
|
762
|
+
const n = Number(c && c.narracaoDur);
|
|
763
|
+
if (n > 0) d = Math.max(d, Math.min(90, n + 0.4));
|
|
764
|
+
return t + d;
|
|
765
|
+
}, 0);
|
|
766
|
+
}
|
|
767
|
+
|
|
768
|
+
function errosDoNavegador(r) {
|
|
769
|
+
const erros = (r && r.erros) || [];
|
|
770
|
+
return erros.length ? ' O navegador reclamou: ' + erros.slice(0, 3).map((e) => e.texto).join(' · ') : '';
|
|
771
|
+
}
|
|
772
|
+
|
|
773
|
+
const exportarVideo = ({ id, destino, fps, escondido, pasta }) => comStudio(() => comIdReal(id, async () => {
|
|
774
|
+
if (!id) return { ok: false, error: 'Falta o id do vídeo.' };
|
|
775
|
+
if (typeof escondido !== 'function') return { ok: false, error: 'Sem navegador escondido para gerar o vídeo.' };
|
|
776
|
+
const a = await pedir('GET', `/api/arquivos/${encodeURIComponent(id)}`);
|
|
777
|
+
if (a.ferramenta !== 'corte') {
|
|
778
|
+
return { ok: false, error: `"${a.titulo}" é ${a.ferramenta}, não vídeo. Só o corte vira .mp4 — para as `
|
|
779
|
+
+ 'outras ferramentas o link "ver" já é a peça pronta.' };
|
|
780
|
+
}
|
|
781
|
+
const doc = a.doc || {};
|
|
782
|
+
if (!(doc.cenas || []).length) return { ok: false, error: 'Este vídeo não tem cenas.' };
|
|
783
|
+
|
|
784
|
+
// 1. Os clipes do usuário, no formato que o navegador escondido toca.
|
|
785
|
+
const mapa = {};
|
|
786
|
+
const avisos = [];
|
|
787
|
+
for (const src of srcsDeMidia(doc)) {
|
|
788
|
+
const r = await compativel(src, escondido);
|
|
789
|
+
if (r.aviso) avisos.push(r.aviso);
|
|
790
|
+
if (r.url !== src) mapa[src] = r.url;
|
|
791
|
+
}
|
|
792
|
+
|
|
793
|
+
// 2. A página do player faz o trabalho do botão e devolve o arquivo ao estúdio.
|
|
794
|
+
const segundos = duracaoEstimada(doc);
|
|
795
|
+
const script = `(async () => {
|
|
796
|
+
function trocarSrc(o, m) {
|
|
797
|
+
if (!o || typeof o !== 'object') return;
|
|
798
|
+
if (Array.isArray(o)) return o.forEach(function (x) { trocarSrc(x, m); });
|
|
799
|
+
Object.keys(o).forEach(function (k) {
|
|
800
|
+
if (typeof o[k] === 'string' && m[o[k]]) o[k] = m[o[k]]; else trocarSrc(o[k], m);
|
|
801
|
+
});
|
|
802
|
+
}
|
|
803
|
+
try {
|
|
804
|
+
const a = await Player.carregado;
|
|
805
|
+
const mapa = ${JSON.stringify(mapa)};
|
|
806
|
+
if (Object.keys(mapa).length) {
|
|
807
|
+
const copia = JSON.parse(JSON.stringify(a));
|
|
808
|
+
trocarSrc(copia.doc, mapa);
|
|
809
|
+
Player.remontar(copia);
|
|
810
|
+
}
|
|
811
|
+
const r = await Player.exportar({ fps: ${Number(fps) > 0 ? Number(fps) : 30} });
|
|
812
|
+
const nome = ${JSON.stringify('export-' + id)} + r.formato;
|
|
813
|
+
const resp = await fetch('/api/midia/' + encodeURIComponent(nome), {
|
|
814
|
+
method: 'PUT', body: r.blob,
|
|
815
|
+
headers: { 'Content-Type': r.blob.type || 'application/octet-stream' },
|
|
816
|
+
});
|
|
817
|
+
const j = await resp.json();
|
|
818
|
+
if (!resp.ok) throw new Error(j.erro || ('HTTP ' + resp.status));
|
|
819
|
+
window.__primo = { ok: true, url: j.url, formato: r.formato, tipo: r.tipo, bytes: r.bytes, segundos: r.segundos, mp4: r.mp4 };
|
|
820
|
+
} catch (e) {
|
|
821
|
+
window.__primo = { ok: false, erro: String((e && e.message) || e) };
|
|
822
|
+
}
|
|
823
|
+
})()`;
|
|
824
|
+
const prazo = Math.round(segundos + 90);
|
|
825
|
+
const r = await escondido(`${BASE}/v/${encodeURIComponent(id)}`, {
|
|
826
|
+
script, gesto: true, prazo: prazo * 1000, passo: 700, assentar: 1500,
|
|
827
|
+
});
|
|
828
|
+
if (!r || !r.valor) return { ok: false, error: `A exportação não terminou em ${prazo}s.` + errosDoNavegador(r) };
|
|
829
|
+
if (!r.valor.ok) return { ok: false, error: `O navegador não conseguiu gerar o vídeo: ${r.valor.erro}.` + errosDoNavegador(r) };
|
|
830
|
+
|
|
831
|
+
// 3. Do estúdio para a pasta do projeto, em mp4 quando dá.
|
|
832
|
+
const origem = noDisco(r.valor.url);
|
|
833
|
+
if (!origem || !fs.existsSync(origem)) return { ok: false, error: 'O navegador gerou o vídeo mas o estúdio não o guardou.' };
|
|
834
|
+
const base = pasta || process.cwd();
|
|
835
|
+
const slug = String(a.titulo || 'video').normalize('NFD').replace(/[̀-ͯ]/g, '')
|
|
836
|
+
.replace(/[^\w-]+/g, '-').replace(/^-+|-+$/g, '').toLowerCase() || 'video';
|
|
837
|
+
let saida = destino ? path.resolve(base, destino) : path.join(base, `${slug}.mp4`);
|
|
838
|
+
if (!path.extname(saida)) saida += '.mp4';
|
|
839
|
+
fs.mkdirSync(path.dirname(saida), { recursive: true });
|
|
840
|
+
let formato = String(r.valor.formato || '.webm');
|
|
841
|
+
// "mp4" do gravador do navegador pode ser VP9 dentro de um .mp4 — o
|
|
842
|
+
// Chromium sem H.264 grava assim, e o arquivo não abre no iPhone nem no
|
|
843
|
+
// QuickTime. O que decide é o CODEC (avc1), não a extensão: sem avc1 e
|
|
844
|
+
// com ffmpeg na máquina, o arquivo é recodificado em H.264/AAC.
|
|
845
|
+
const jaH264 = /avc1|avc3|h264/i.test(String(r.valor.tipo || ''));
|
|
846
|
+
if ((formato === '.webm' || !jaH264) && /\.mp4$/i.test(saida)) {
|
|
847
|
+
if (temFfmpeg()) {
|
|
848
|
+
await ffmpeg(['-i', origem, '-c:v', 'libx264', '-preset', 'veryfast', '-crf', '20', '-pix_fmt', 'yuv420p',
|
|
849
|
+
'-c:a', 'aac', '-b:a', '160k', '-movflags', '+faststart', saida]);
|
|
850
|
+
formato = '.mp4';
|
|
851
|
+
} else if (formato === '.webm') {
|
|
852
|
+
saida = saida.replace(/\.mp4$/i, '.webm');
|
|
853
|
+
fs.copyFileSync(origem, saida);
|
|
854
|
+
avisos.push('Sem ffmpeg nesta máquina o arquivo ficou em .webm (toca em qualquer navegador e no VLC); '
|
|
855
|
+
+ 'instale o ffmpeg para sair em .mp4.');
|
|
856
|
+
} else {
|
|
857
|
+
fs.copyFileSync(origem, saida);
|
|
858
|
+
avisos.push('O .mp4 saiu em VP9 (este navegador não grava H.264) — abre no Chrome, no Android e no VLC, '
|
|
859
|
+
+ 'mas não no iPhone nem no QuickTime. Com o ffmpeg instalado ele sai em H.264.');
|
|
860
|
+
}
|
|
861
|
+
} else {
|
|
862
|
+
if (path.extname(saida).toLowerCase() !== formato) saida = saida.replace(/\.[^.]+$/, '') + formato;
|
|
863
|
+
fs.copyFileSync(origem, saida);
|
|
864
|
+
}
|
|
865
|
+
// O rascunho no estúdio já cumpriu o papel: ficar lá seria um vídeo
|
|
866
|
+
// inteiro duplicado na galeria a cada exportação.
|
|
867
|
+
try { fs.unlinkSync(origem); } catch { /* fica */ }
|
|
868
|
+
const bytes = fs.statSync(saida).size;
|
|
869
|
+
return {
|
|
870
|
+
ok: true,
|
|
871
|
+
arquivo: saida,
|
|
872
|
+
formato: formato.slice(1),
|
|
873
|
+
bytes,
|
|
874
|
+
segundos: r.valor.segundos,
|
|
875
|
+
avisos: avisos.length ? avisos : undefined,
|
|
876
|
+
comoUsar: `O vídeo está em ${saida} (${(bytes / 1048576).toFixed(1)} MB, ${Math.round(r.valor.segundos || segundos)}s). `
|
|
877
|
+
+ 'Diga ao usuário o caminho, em uma linha.' + (avisos.length ? ' Avisos: ' + avisos.join(' ') : ''),
|
|
878
|
+
};
|
|
879
|
+
}));
|
|
572
880
|
|
|
573
881
|
module.exports = {
|
|
574
|
-
BASE, PORTA, CASA,
|
|
575
|
-
garantirNoAr, estaNoAr, prepararCasa, pythonCmd,
|
|
882
|
+
BASE, PORTA, CASA, MIDIA_DIR,
|
|
883
|
+
garantirNoAr, estaNoAr, prepararCasa, pythonCmd, derrubar,
|
|
576
884
|
manual, ferramentas, spec, listar, ler, criar, atualizar, versoesDe,
|
|
577
|
-
midiaListar, midiaEnviar, analisarAudio,
|
|
885
|
+
midiaListar, midiaEnviar, analisarAudio, exportarVideo,
|
|
886
|
+
temFfmpeg, compativel, capacidadesDoNavegador, srcsDeMidia, duracaoEstimada,
|
|
578
887
|
};
|
package/lib/tools.js
CHANGED
|
@@ -570,14 +570,28 @@ async function despachar(name, args, ctx) {
|
|
|
570
570
|
return args.arquivo
|
|
571
571
|
? studio.midiaEnviar({ arquivo: resolveInProject(args.arquivo, projectDir), nome: args.nome })
|
|
572
572
|
: studio.midiaListar();
|
|
573
|
-
case 'studio_analisar':
|
|
574
|
-
// Ouvir o vídeo para achar as pausas. A conta acontece
|
|
573
|
+
case 'studio_analisar': {
|
|
574
|
+
// Ouvir o vídeo para achar as pausas. A conta acontece num
|
|
575
575
|
// navegador (é quem sabe decodificar MP4 sem instalar nada) —
|
|
576
|
-
//
|
|
577
|
-
//
|
|
576
|
+
// e ESCONDIDO: a conta não é da pessoa, e uma aba piscando no
|
|
577
|
+
// navegador dela é encanamento aparecendo. Sem Chrome nem
|
|
578
|
+
// Chromium nesta máquina, cai no navegador dela, como antes.
|
|
579
|
+
const visivel = process.env.PRIMOCODE_ANALISE_VISIVEL === '1';
|
|
580
|
+
const abrir = visivel ? openInBrowser : async (url) => {
|
|
581
|
+
try { return await browser.abrirEscondido(url); }
|
|
582
|
+
catch { openInBrowser(url); return null; }
|
|
583
|
+
};
|
|
578
584
|
return studio.analisarAudio({
|
|
579
585
|
arquivo: args.arquivo, pausa: args.pausa, manter: args.manter,
|
|
580
|
-
repetida: args.repetida, abrir:
|
|
586
|
+
repetida: args.repetida, abrir, escondido: visivel ? null : browser.rodarEscondido,
|
|
587
|
+
});
|
|
588
|
+
}
|
|
589
|
+
case 'studio_exportar':
|
|
590
|
+
// O mp4 sem clique: a página do player roda escondida e gera
|
|
591
|
+
// pelo mesmo caminho do botão. Ver lib/studio.js.
|
|
592
|
+
return studio.exportarVideo({
|
|
593
|
+
id: args.id, destino: args.destino, fps: args.fps,
|
|
594
|
+
escondido: browser.rodarEscondido, pasta: projectDir || process.cwd(),
|
|
581
595
|
});
|
|
582
596
|
|
|
583
597
|
/* A MESMA peça, virando projeto Remotion.
|