primocode 8.36.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/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 () => ({ ok: true, midia: await pedir('GET', '/api/midia') }));
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
- return { ok: false, error: `Formato não aceito: ${ext}`, aceitos: Object.keys(TIPOS_MIDIA).join(' ') };
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
- const tarefa = await pedir('POST', '/api/analise', { arquivo, pausa, manter, repetida });
525
- if (abrir) { try { abrir(`${BASE}${tarefa.pagina}`); } catch { /* segue e expira */ } }
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
- return { ok: false, error: (estado.resultado && estado.resultado.erro) || 'a análise falhou' };
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 no
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
- // por isso passa a função de abrir, e por isso demora alguns
577
- // segundos em vez de responder na hora.
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: openInBrowser,
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.