primocode 8.38.1 → 8.40.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/remotion.js CHANGED
@@ -214,51 +214,141 @@ export function cenaNoQuadro(doc, q, fps = FPS) {
214
214
  `;
215
215
  }
216
216
 
217
- const FILME_JSX = `import { useCurrentFrame, useVideoConfig, AbsoluteFill, Audio, staticFile } from "remotion";
218
- import { useEffect, useRef } from "react";
217
+ const FILME_JSX = `import { useCurrentFrame, useVideoConfig, AbsoluteFill, Audio, Sequence, staticFile, delayRender, continueRender } from "remotion";
218
+ import { useLayoutEffect, useRef } from "react";
219
219
  import { cenaNoQuadro, quadrosDaCena } from "./tempo.js";
220
220
  import "./render.css";
221
221
  import "./fontes.css";
222
222
  import { renderCena } from "./render.js";
223
223
 
224
- /* O desenho de uma cena é do ESTÚDIO — render.js é o mesmo arquivo que o
225
- navegador carrega. Aqui só se decide qual cena está no ar e como ela entra.
226
- Inventar um segundo desenhista daria duas gramáticas para manter, e uma
227
- ficaria velha. */
228
- function Cena({ cena, doc, dentro, dur }) {
224
+ /* ── POR QUE ESTE ARQUIVO MUDOU ──────────────────────────────────────────
225
+ O primeiro vídeo que saiu daqui estava MUDO e PARADO: \`animar: false\`
226
+ congelava toda animação (o número não contava, a lista não revelava, o
227
+ vidro não brilhava), e o único áudio era \`trilha.src\` — sem narração, sem
228
+ trilha gerada, sem efeitos. Tecnicamente perfeito, visualmente morto.
229
+
230
+ Agora a cena é desenhada COM as animações ligadas (a mesma classe
231
+ \`animar\` do player) e, a cada quadro, TODAS elas são levadas ao instante
232
+ certo pela Web Animations API (\`getAnimations().currentTime\`). É o que
233
+ torna o render determinístico: o mesmo quadro em qualquer máquina, sem
234
+ depender de relógio. A contagem dos números e o instante dos clipes seguem
235
+ a mesma conta do player. A narração de cada cena, a trilha em arquivo, o
236
+ áudio dos clipes e os sons por arquivo entram como <Audio> do Remotion.
237
+
238
+ O que o Remotion NÃO faz sozinho: a trilha GERADA e os sons SINTETIZADOS
239
+ (são WebAudio ao vivo). Por isso o studio_exportar do PrimoCode renderiza
240
+ este projeto sem áudio e MISTURA o áudio do Studio — é a mescla dos dois.
241
+ Aqui dentro, \`doc.__semAudio\` é como o PrimoCode pede esse silêncio. */
242
+
243
+ const src = (u) => (/^(https?:|data:|blob:)/.test(u) ? u : staticFile(String(u).replace(/^\\//, "")));
244
+
245
+ function inicioDaCena(cenas, k, fps) {
246
+ let q = 0;
247
+ for (let i = 0; i < k; i++) q += quadrosDaCena(cenas[i], fps);
248
+ return q;
249
+ }
250
+
251
+ /* A contagem dos números, determinística: a MESMA curva do player
252
+ (1,1 s, easeOutCubic), só que em função do tempo da cena e não do relógio. */
253
+ function contarEm(el, t) {
254
+ el.querySelectorAll("b[data-contar]").forEach((b) => {
255
+ const alvo = parseFloat(b.getAttribute("data-contar"));
256
+ const molde = b.getAttribute("data-molde") || String(alvo);
257
+ const casas = (String(alvo).split(".")[1] || "").length;
258
+ const trecho = molde.match(/-?[\\d.,]+/);
259
+ if (!trecho) return;
260
+ const p = Math.min(1, t / 1.1);
261
+ const suave = 1 - Math.pow(1 - p, 3);
262
+ const v = (alvo * suave).toFixed(casas);
263
+ b.textContent = p >= 1 ? molde : molde.replace(trecho[0], String(v).replace(".", casas ? "," : ""));
264
+ });
265
+ }
266
+
267
+ function Cena({ cena, doc, dentro, fps }) {
229
268
  const caixa = useRef(null);
269
+ const no = useRef(null);
230
270
 
231
- useEffect(() => {
271
+ useLayoutEffect(() => {
232
272
  if (!caixa.current) return;
233
273
  caixa.current.innerHTML = "";
234
- // \`animar: false\`: quem anima aqui é o Remotion, quadro a quadro. Com as
235
- // animações CSS ligadas, cada quadro renderizado recomeçaria a animação
236
- // do zero — o vídeo sairia com o primeiro instante repetido do começo ao
237
- // fim, tecnicamente perfeito e visualmente parado.
238
- caixa.current.appendChild(renderCena(cena, doc, { animar: false }));
274
+ no.current = renderCena(cena, doc, { animar: true });
275
+ caixa.current.appendChild(no.current);
239
276
  }, [cena, doc]);
240
277
 
278
+ useLayoutEffect(() => {
279
+ const el = no.current;
280
+ if (!el) return;
281
+ const t = dentro / fps;
282
+ // 1. Toda animação CSS — entrada, chaves, transição da cena, brilho do
283
+ // vidro, cubo girando — no instante t. Pausada: quem avança é o quadro.
284
+ let anims = [];
285
+ try { anims = el.getAnimations({ subtree: true }); } catch (e) { anims = []; }
286
+ for (const a of anims) {
287
+ try { a.pause(); a.currentTime = t * 1000; } catch (e) { /* animação já encerrada */ }
288
+ }
289
+ // 2. Os números que contam.
290
+ contarEm(el, t);
291
+ // 3. Os clipes do usuário: o quadro certo do arquivo, esperado de verdade.
292
+ el.querySelectorAll("video").forEach((v) => {
293
+ const de = Number(v.dataset.de || 0);
294
+ const alvo = de + t;
295
+ v.pause();
296
+ const buscar = () => {
297
+ if (Math.abs(v.currentTime - alvo) < 1 / (fps * 2)) return;
298
+ const h = delayRender("clipe em " + alvo.toFixed(2) + "s");
299
+ const fim = () => { v.removeEventListener("seeked", fim); continueRender(h); };
300
+ v.addEventListener("seeked", fim);
301
+ v.currentTime = alvo;
302
+ };
303
+ if (v.readyState >= 1) buscar();
304
+ else {
305
+ const h = delayRender("carregando clipe");
306
+ v.addEventListener("loadedmetadata", () => { buscar(); continueRender(h); }, { once: true });
307
+ }
308
+ });
309
+ }, [dentro, fps, cena, doc]);
310
+
241
311
  return <div ref={caixa} style={{ width: "100%", height: "100%" }} />;
242
312
  }
243
313
 
244
- /* Entrada e transição vindas do quadro, não do CSS.
245
-
246
- \`t\` vai de 0 a 1 nos primeiros 18 quadros da cena. A curva é a mesma do
247
- estúdio (cubic-bezier .22 1 .36 1, aproximada por um easeOutCubic): sem
248
- isso a peça exportada por Remotion teria outro tempo de entrada que a
249
- mesma peça vista no navegador. */
250
- function entrada(transicao, t) {
251
- const s = 1 - Math.pow(1 - t, 3);
252
- switch (transicao) {
253
- case "corte": return {};
254
- case "deslizar": return { opacity: s, transform: \`translateX(\${(1 - s) * 8}%)\` };
255
- case "subir": return { opacity: s, transform: \`translateY(\${(1 - s) * 8}%)\` };
256
- case "zoom": return { opacity: s, transform: \`scale(\${0.92 + s * 0.08})\` };
257
- case "flash": return { opacity: s, filter: \`brightness(\${1 + (1 - s) * 2.2})\` };
258
- case "limpar": return { opacity: 1, clipPath: \`inset(0 \${(1 - s) * 100}% 0 0)\` };
259
- case "persiana": return { opacity: 1, clipPath: \`inset(\${(1 - s) * 50}% 0 \${(1 - s) * 50}% 0)\` };
260
- default: return { opacity: s }; // fade
314
+ /* O som da peça, fora o que é sintetizado. */
315
+ function Sons({ doc, fps }) {
316
+ if (doc.__semAudio) return null;
317
+ const cenas = doc.cenas || [];
318
+ const pecas = [];
319
+ const trilha = doc.trilha || {};
320
+ if (trilha.src && trilha.tipo !== "gerada") {
321
+ pecas.push(<Audio key="trilha" src={src(trilha.src)} loop={trilha.repetir !== false}
322
+ volume={trilha.volume == null ? 0.7 : trilha.volume}
323
+ startFrom={Math.round((Number(trilha.inicio) || 0) * fps)} />);
261
324
  }
325
+ cenas.forEach((c, k) => {
326
+ const de = inicioDaCena(cenas, k, fps);
327
+ const dur = quadrosDaCena(c, fps);
328
+ if (c.narracaoAudio) {
329
+ pecas.push(<Sequence key={"n" + k} from={de} durationInFrames={dur}>
330
+ <Audio src={src(c.narracaoAudio)} volume={doc.voz && doc.voz.volume != null ? doc.voz.volume : 1} />
331
+ </Sequence>);
332
+ }
333
+ (c.elementos || []).forEach((e, i) => {
334
+ if (!e) return;
335
+ if (e.tipo === "clipe" && e.src && !e.mudo) {
336
+ pecas.push(<Sequence key={"c" + k + "-" + i} from={de} durationInFrames={dur}>
337
+ <Audio src={src(e.src)} startFrom={Math.round((Number(e.de) || 0) * fps)}
338
+ volume={e.volume == null ? 1 : e.volume} />
339
+ </Sequence>);
340
+ }
341
+ if (e.som && typeof e.som === "object" && e.som.arquivo) {
342
+ const atraso = e.som.em != null ? Number(e.som.em)
343
+ : (e.anim && typeof e.anim === "object" && e.anim.atraso != null) ? Number(e.anim.atraso)
344
+ : (e.chaves && e.chaves.length > 1 ? 0 : i * 0.09 + 0.1);
345
+ pecas.push(<Sequence key={"s" + k + "-" + i} from={de + Math.round(atraso * fps)}>
346
+ <Audio src={src(e.som.arquivo)} volume={e.som.volume == null ? 0.7 : e.som.volume} />
347
+ </Sequence>);
348
+ }
349
+ });
350
+ });
351
+ return <>{pecas}</>;
262
352
  }
263
353
 
264
354
  export const Filme = ({ doc }) => {
@@ -267,20 +357,11 @@ export const Filme = ({ doc }) => {
267
357
  const { i, dentro } = cenaNoQuadro(doc, quadro, fps);
268
358
  const cenas = doc.cenas || [];
269
359
  const cena = cenas[i] || {};
270
- const dur = quadrosDaCena(cena, fps);
271
-
272
- const QUADROS_ENTRADA = Math.max(1, Math.round(0.62 * fps));
273
- const t = Math.min(1, dentro / QUADROS_ENTRADA);
274
360
 
275
361
  return (
276
362
  <AbsoluteFill style={{ backgroundColor: "#000" }}>
277
- <AbsoluteFill style={entrada(cena.transicao, t)}>
278
- <Cena cena={cena} doc={doc} dentro={dentro} dur={dur} />
279
- </AbsoluteFill>
280
- {doc.trilha && doc.trilha.src ? (
281
- <Audio src={doc.trilha.src.startsWith("http") ? doc.trilha.src : staticFile(doc.trilha.src)}
282
- volume={doc.trilha.volume == null ? 0.7 : doc.trilha.volume} />
283
- ) : null}
363
+ <Cena cena={cena} doc={doc} dentro={dentro} fps={fps} />
364
+ <Sons doc={doc} fps={fps} />
284
365
  </AbsoluteFill>
285
366
  );
286
367
  };
@@ -373,7 +454,7 @@ const GITIGNORE = 'node_modules/\nsaida/\n.remotion/\n';
373
454
  * @param {object} op.doc o documento da peça (cenas, tema, trilha…)
374
455
  * @param {number} [op.fps]
375
456
  */
376
- function montar({ destino, titulo, doc, fps }) {
457
+ function montar({ destino, titulo, doc, fps, midiaDir, semAudio }) {
377
458
  if (!destino) return { ok: false, error: 'diga em que pasta o projeto vai nascer' };
378
459
  if (!doc || !Array.isArray(doc.cenas) || !doc.cenas.length) {
379
460
  return { ok: false, error: 'esta peça não tem cenas — Remotion renderiza cenas, como o Studio' };
@@ -383,6 +464,29 @@ function montar({ destino, titulo, doc, fps }) {
383
464
  const src = path.join(destino, 'src');
384
465
  fs.mkdirSync(src, { recursive: true });
385
466
 
467
+ // A MÍDIA vai junto: "/midia/x.mp3" no doc vira public/midia/x.mp3 no
468
+ // projeto, que o Remotion serve por staticFile(). Sem isto a narração, a
469
+ // trilha e os clipes apontavam para o servidor do Studio — que não existe
470
+ // na hora do render, e o projeto nascia mudo sem dizer por quê.
471
+ const midia = [];
472
+ if (midiaDir) {
473
+ const andar = (o) => {
474
+ if (!o) return;
475
+ if (typeof o === 'string') { if (o.startsWith('/midia/')) midia.push(o); return; }
476
+ if (Array.isArray(o)) return o.forEach(andar);
477
+ if (typeof o === 'object') Object.values(o).forEach(andar);
478
+ };
479
+ andar(doc);
480
+ for (const u of new Set(midia)) {
481
+ const origem = path.join(midiaDir, path.basename(u));
482
+ if (!fs.existsSync(origem)) continue;
483
+ const alvo = path.join(destino, 'public', 'midia', path.basename(u));
484
+ fs.mkdirSync(path.dirname(alvo), { recursive: true });
485
+ try { fs.copyFileSync(origem, alvo); } catch { /* fica sem essa */ }
486
+ }
487
+ }
488
+ const docFinal = semAudio ? Object.assign({}, doc, { __semAudio: true }) : doc;
489
+
386
490
  // Os arquivos do estúdio, sem reescrever conteúdo.
387
491
  for (const [de, para] of DO_ESTUDIO) {
388
492
  const origem = path.join(ORIGEM_ESTUDIO, de);
@@ -402,14 +506,15 @@ function montar({ destino, titulo, doc, fps }) {
402
506
  escrever(path.join(destino, 'README.md'), readme(titulo, doc));
403
507
  escrever(path.join(destino, '.gitignore'), GITIGNORE);
404
508
  escrever(path.join(src, 'index.jsx'), INDEX_JSX);
405
- escrever(path.join(src, 'Raiz.jsx'), raizJsx(doc, taxa));
509
+ escrever(path.join(src, 'Raiz.jsx'), raizJsx(docFinal, taxa));
406
510
  escrever(path.join(src, 'Filme.jsx'), FILME_JSX);
407
511
  escrever(path.join(src, 'tempo.js'), tempoJs(taxa));
408
- escrever(path.join(src, 'doc.json'), JSON.stringify(doc, null, 2) + '\n');
512
+ escrever(path.join(src, 'doc.json'), JSON.stringify(docFinal, null, 2) + '\n');
409
513
 
410
514
  return {
411
515
  ok: true,
412
516
  pasta: destino,
517
+ midia: [...new Set(midia)].length,
413
518
  fps: taxa,
414
519
  segundos: +totalSegundos(doc).toFixed(2),
415
520
  quadros: Math.round(totalSegundos(doc) * taxa),
@@ -460,4 +565,99 @@ function renderizar({ pasta, saida, minutos = 20 }) {
460
565
  });
461
566
  }
462
567
 
463
- module.exports = { montar, renderizar, segundosDaCena, totalSegundos, VERSAO_REMOTION };
568
+ /* ── A MESCLA: o Remotion como motor de render do studio_exportar ─────────
569
+ *
570
+ * O dono: "ele deve usar o Remotion e o Studio, deve mesclar os 2". O Studio
571
+ * é o autor — a cena, o motion, a narração, a trilha gerada, os efeitos — e
572
+ * grava o MP4 em tempo real, com o risco de quadro perdido quando a máquina
573
+ * está ocupada. O Remotion desenha a MESMA cena quadro a quadro, sem relógio,
574
+ * mas não tem WebAudio. Então: o vídeo vem do Remotion, o áudio vem do
575
+ * Studio, e o ffmpeg junta os dois. Quem chama é o lib/studio.js.
576
+ *
577
+ * Uma instalação só, em ~/.primocode/remotion — 250 MB uma vez, para todos
578
+ * os projetos —, e nunca dentro do PrimoCode (zero dependências continua
579
+ * valendo). Os projetos renderizados nascem em projetos/<slug>/ e resolvem
580
+ * `remotion` e `react` subindo a árvore até o node_modules compartilhado. */
581
+ const os = require('os');
582
+ const CASA_REMOTION = process.env.PRIMOCODE_REMOTION_CASA || path.join(os.homedir(), '.primocode', 'remotion');
583
+
584
+ function instalado() {
585
+ return fs.existsSync(path.join(CASA_REMOTION, 'node_modules', '@remotion', 'cli', 'package.json'))
586
+ && fs.existsSync(path.join(CASA_REMOTION, 'node_modules', 'react'));
587
+ }
588
+
589
+ function cliRemotion() {
590
+ const pasta = path.join(CASA_REMOTION, 'node_modules', '@remotion', 'cli');
591
+ try {
592
+ const pkg = JSON.parse(fs.readFileSync(path.join(pasta, 'package.json'), 'utf-8'));
593
+ const bin = typeof pkg.bin === 'string' ? pkg.bin : (pkg.bin && (pkg.bin.remotion || Object.values(pkg.bin)[0]));
594
+ if (bin) return path.join(pasta, bin);
595
+ } catch { /* cai no nome conhecido */ }
596
+ return path.join(pasta, 'remotion-cli.js');
597
+ }
598
+
599
+ let instalando = null;
600
+ /** Traz o Remotion uma vez. `avisar(texto)` recebe o que dizer na tela. */
601
+ function garantirInstalado({ minutos = 15, avisar } = {}) {
602
+ if (instalado()) return Promise.resolve({ ok: true, jaEstava: true });
603
+ if (instalando) return instalando;
604
+ instalando = new Promise((resolve) => {
605
+ fs.mkdirSync(CASA_REMOTION, { recursive: true });
606
+ escrever(path.join(CASA_REMOTION, 'package.json'), pkgJson('primocode-remotion'));
607
+ if (avisar) avisar('instalando o Remotion (uma vez, ~250 MB — alguns minutos)…');
608
+ const npm = process.platform === 'win32' ? 'npm.cmd' : 'npm';
609
+ const proc = spawn(npm, ['install', '--no-audit', '--no-fund', '--loglevel=error'],
610
+ { cwd: CASA_REMOTION, stdio: ['ignore', 'pipe', 'pipe'], shell: process.platform === 'win32' });
611
+ let fora = '';
612
+ proc.stdout.on('data', (d) => { fora += d; });
613
+ proc.stderr.on('data', (d) => { fora += d; });
614
+ const prazo = setTimeout(() => { try { proc.kill(); } catch {} }, minutos * 60000);
615
+ proc.on('close', (codigo) => {
616
+ clearTimeout(prazo);
617
+ instalando = null;
618
+ if (codigo === 0 && instalado()) return resolve({ ok: true });
619
+ resolve({ ok: false, error: (fora || `npm saiu com ${codigo}`).slice(-600) });
620
+ });
621
+ proc.on('error', (e) => { clearTimeout(prazo); instalando = null; resolve({ ok: false, error: e.message }); });
622
+ });
623
+ return instalando;
624
+ }
625
+
626
+ /**
627
+ * Renderiza UMA peça do Studio pelo Remotion e devolve o mp4 (só vídeo quando
628
+ * `semAudio`). Monta o projeto em CASA_REMOTION/projetos/<slug>, com a mídia.
629
+ */
630
+ async function renderizarPeca({ doc, titulo, fps, midiaDir, semAudio = true, minutos = 30, avisar }) {
631
+ const pronto = await garantirInstalado({ avisar });
632
+ if (!pronto.ok) return { ok: false, error: `Remotion não instalou: ${pronto.error}` };
633
+ const slug = String(titulo || 'video').normalize('NFD').replace(/[̀-ͯ]/g, '')
634
+ .replace(/[^\w-]+/g, '-').replace(/^-+|-+$/g, '').toLowerCase().slice(0, 40) || 'video';
635
+ const destino = path.join(CASA_REMOTION, 'projetos', slug);
636
+ const m = montar({ destino, titulo, doc, fps, midiaDir, semAudio });
637
+ if (!m.ok) return m;
638
+ if (avisar) avisar(`renderizando ${m.quadros} quadros pelo Remotion…`);
639
+ const arquivo = path.join('saida', 'video.mp4');
640
+ fs.mkdirSync(path.join(destino, 'saida'), { recursive: true });
641
+ const args = [cliRemotion(), 'render', 'src/index.jsx', 'Video', arquivo,
642
+ '--codec=h264', '--crf=18', '--log=error', '--concurrency=50%'];
643
+ if (semAudio) args.push('--muted');
644
+ return new Promise((resolve) => {
645
+ const proc = spawn(process.execPath, args, { cwd: destino, stdio: ['ignore', 'pipe', 'pipe'] });
646
+ let fora = '';
647
+ proc.stdout.on('data', (d) => { fora += d; });
648
+ proc.stderr.on('data', (d) => { fora += d; });
649
+ const prazo = setTimeout(() => { try { proc.kill(); } catch {} }, minutos * 60000);
650
+ proc.on('close', (codigo) => {
651
+ clearTimeout(prazo);
652
+ const alvo = path.join(destino, arquivo);
653
+ if (codigo === 0 && fs.existsSync(alvo)) {
654
+ return resolve({ ok: true, arquivo: alvo, bytes: fs.statSync(alvo).size, quadros: m.quadros, segundos: m.segundos, pasta: destino });
655
+ }
656
+ resolve({ ok: false, error: (fora || `remotion saiu com ${codigo}`).slice(-1200) });
657
+ });
658
+ proc.on('error', (e) => { clearTimeout(prazo); resolve({ ok: false, error: e.message }); });
659
+ });
660
+ }
661
+
662
+ module.exports = { montar, renderizar, renderizarPeca, garantirInstalado, instalado, cliRemotion,
663
+ segundosDaCena, totalSegundos, VERSAO_REMOTION, CASA_REMOTION };
package/lib/studio.js CHANGED
@@ -313,7 +313,28 @@ async function avisoDaVoz(doc) {
313
313
  if (!temNarracao) return null;
314
314
  try {
315
315
  const s = await pedir('GET', '/api/voz/status', null, 3000);
316
- if (s && s.piper && !s.piper.pronta && s.piper.suportado) {
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) {
317
338
  return 'A voz neural (offline, ilimitada) está sendo baixada agora — ~90 MB, uma vez só. '
318
339
  + 'Até terminar a narração sai por uma voz de reserva, mais simples. Diga isso ao usuário.';
319
340
  }
@@ -770,7 +791,7 @@ function errosDoNavegador(r) {
770
791
  return erros.length ? ' O navegador reclamou: ' + erros.slice(0, 3).map((e) => e.texto).join(' · ') : '';
771
792
  }
772
793
 
773
- const exportarVideo = ({ id, destino, fps, escondido, pasta }) => comStudio(() => comIdReal(id, async () => {
794
+ const exportarVideo = ({ id, destino, fps, escondido, pasta, remotion }) => comStudio(() => comIdReal(id, async () => {
774
795
  if (!id) return { ok: false, error: 'Falta o id do vídeo.' };
775
796
  if (typeof escondido !== 'function') return { ok: false, error: 'Sem navegador escondido para gerar o vídeo.' };
776
797
  const a = await pedir('GET', `/api/arquivos/${encodeURIComponent(id)}`);
@@ -829,8 +850,59 @@ const exportarVideo = ({ id, destino, fps, escondido, pasta }) => comStudio(() =
829
850
  if (!r.valor.ok) return { ok: false, error: `O navegador não conseguiu gerar o vídeo: ${r.valor.erro}.` + errosDoNavegador(r) };
830
851
 
831
852
  // 3. Do estúdio para a pasta do projeto, em mp4 quando dá.
832
- const origem = noDisco(r.valor.url);
853
+ let origem = noDisco(r.valor.url);
833
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
+ }
834
906
  const base = pasta || process.cwd();
835
907
  const slug = String(a.titulo || 'video').normalize('NFD').replace(/[̀-ͯ]/g, '')
836
908
  .replace(/[^\w-]+/g, '-').replace(/^-+|-+$/g, '').toLowerCase() || 'video';
@@ -870,6 +942,7 @@ const exportarVideo = ({ id, destino, fps, escondido, pasta }) => comStudio(() =
870
942
  ok: true,
871
943
  arquivo: saida,
872
944
  formato: formato.slice(1),
945
+ motor,
873
946
  bytes,
874
947
  segundos: r.valor.segundos,
875
948
  avisos: avisos.length ? avisos : undefined,
package/lib/tools.js CHANGED
@@ -440,6 +440,16 @@ async function despachar(name, args, ctx) {
440
440
  paginas: Math.max(1, Math.min(5, Number(args.paginas) || 3)),
441
441
  });
442
442
 
443
+ // Música e efeito sonoro da internet, com licença e crédito. O
444
+ // arquivo vem para a pasta do projeto e sobe para o estúdio;
445
+ // volta a URL "/midia/…" pronta para trilha.src ou som.arquivo.
446
+ case 'buscar_audio':
447
+ return require('./audio-livre.js').buscarAudio(args, { projectDir, studio });
448
+
449
+ // A logo e a identidade visual da empresa, lidas do site dela.
450
+ case 'marca_da_empresa':
451
+ return require('./marca.js').marcaDaEmpresa(args, { projectDir, studio });
452
+
443
453
  // memory-geral.md fica FORA da pasta do projeto — write_file/edit_file
444
454
  // não alcançam por design (resolveInProject bloqueia ../ de propósito).
445
455
  // Esta é a única porta para lá, sem reabrir a sandbox.
@@ -589,9 +599,12 @@ async function despachar(name, args, ctx) {
589
599
  case 'studio_exportar':
590
600
  // O mp4 sem clique: a página do player roda escondida e gera
591
601
  // pelo mesmo caminho do botão. Ver lib/studio.js.
602
+ // Com o Remotion mesclado: o vídeo quadro a quadro dele, o
603
+ // áudio do Studio. PRIMOCODE_SEM_REMOTION=1 desliga.
592
604
  return studio.exportarVideo({
593
605
  id: args.id, destino: args.destino, fps: args.fps,
594
606
  escondido: browser.rodarEscondido, pasta: projectDir || process.cwd(),
607
+ remotion: process.env.PRIMOCODE_SEM_REMOTION !== '1',
595
608
  });
596
609
 
597
610
  /* A MESMA peça, virando projeto Remotion.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "primocode",
3
- "version": "8.38.1",
3
+ "version": "8.40.0",
4
4
  "description": "PrimoCode — agente de engenharia com IA e cursor próprio. Requer conta Conecta Primo AI (Premium ou Super). Cria arquivos, roda comandos, controla navegador e desktop: abre apps, clica em botões e ícones pelo nome, digita e usa atalhos.",
5
5
  "main": "bin/primocode.js",
6
6
  "bin": {
@@ -8,7 +8,7 @@
8
8
  },
9
9
  "scripts": {
10
10
  "start": "node bin/primocode.js",
11
- "test": "node test/tools.test.js && node test/ui.test.js && node test/modo.test.js && node test/pasta.test.js && node test/catalogo.test.js && node test/desktop.test.js && node test/effort.test.js && node test/claude-engine.test.js && node test/regressao.test.js && node test/memoria-conversa.test.js && node test/repeticao.test.js && node test/referencia.test.js && node test/parar.test.js && node test/continuar.test.js && node test/studio-spec.test.js && node test/studio-versoes.test.js && node test/voz.test.js && node test/editor.test.js && node test/estudio-projeto.test.js && node test/vocabulario.test.js && node test/video-export.test.js && node test/monotonia.test.js && node test/clipe.test.js && node test/midia-envio.test.js && node test/pausas.test.js && node test/fala-repetida.test.js && node test/trilha.test.js && node test/anuncio.test.js && node test/pipeline.test.js && node test/app.test.js && node test/elementos.test.js && node test/remotion.test.js && node test/aplicativo.test.js && node test/conta.test.mjs && node test/nuvem.test.js && node test/codex.test.js && node test/chaves.test.js && node test/prazo.test.js && node test/primeira-vez.test.js && node test/janela.test.js && node test/pareamento.test.js && node test/fala.test.js && node test/especialistas.test.js && node test/icone.test.js && node test/exportar.test.js"
11
+ "test": "node test/tools.test.js && node test/ui.test.js && node test/modo.test.js && node test/pasta.test.js && node test/catalogo.test.js && node test/desktop.test.js && node test/effort.test.js && node test/claude-engine.test.js && node test/regressao.test.js && node test/memoria-conversa.test.js && node test/repeticao.test.js && node test/referencia.test.js && node test/parar.test.js && node test/continuar.test.js && node test/studio-spec.test.js && node test/studio-versoes.test.js && node test/voz.test.js && node test/editor.test.js && node test/estudio-projeto.test.js && node test/vocabulario.test.js && node test/video-export.test.js && node test/monotonia.test.js && node test/clipe.test.js && node test/midia-envio.test.js && node test/pausas.test.js && node test/fala-repetida.test.js && node test/trilha.test.js && node test/anuncio.test.js && node test/pipeline.test.js && node test/app.test.js && node test/elementos.test.js && node test/remotion.test.js && node test/aplicativo.test.js && node test/conta.test.mjs && node test/nuvem.test.js && node test/codex.test.js && node test/chaves.test.js && node test/prazo.test.js && node test/primeira-vez.test.js && node test/janela.test.js && node test/pareamento.test.js && node test/fala.test.js && node test/especialistas.test.js && node test/icone.test.js && node test/exportar.test.js && node test/ate-o-fim.test.js && node test/motion.test.js && node test/audio-marca.test.js"
12
12
  },
13
13
  "engines": {
14
14
  "node": ">=18.17.0"
@@ -0,0 +1,135 @@
1
+ {
2
+ "id": "tela-motion-3ecaa",
3
+ "ferramenta": "tela",
4
+ "titulo": "motion",
5
+ "doc": {
6
+ "tema": "meia-noite",
7
+ "largura": 1920,
8
+ "altura": 1080,
9
+ "cenas": [
10
+ {
11
+ "layout": "livre",
12
+ "duracao": 6,
13
+ "fundo": "aurora",
14
+ "elementos": [
15
+ {
16
+ "tipo": "forma",
17
+ "forma": "circulo",
18
+ "x": 1250,
19
+ "y": 120,
20
+ "l": 520,
21
+ "a": 520,
22
+ "preenchimento": "#ff5a36",
23
+ "opacidade": 0.9
24
+ },
25
+ {
26
+ "tipo": "forma",
27
+ "forma": "retangulo",
28
+ "material": "vidro",
29
+ "x": 120,
30
+ "y": 140,
31
+ "l": 860,
32
+ "a": 420,
33
+ "raio": 36,
34
+ "preenchimento": "#22d3ee"
35
+ },
36
+ {
37
+ "tipo": "texto",
38
+ "x": 170,
39
+ "y": 190,
40
+ "l": 760,
41
+ "a": 120,
42
+ "texto": "Liquid glass de verdade",
43
+ "tamanho": 74,
44
+ "peso": 750
45
+ },
46
+ {
47
+ "tipo": "texto",
48
+ "x": 170,
49
+ "y": 330,
50
+ "l": 760,
51
+ "a": 60,
52
+ "texto": "desfoca o que está atrás, bisel em cima",
53
+ "tamanho": 34,
54
+ "cor": "#bfe9f4"
55
+ },
56
+ {
57
+ "tipo": "texto",
58
+ "material": "vidro",
59
+ "x": 170,
60
+ "y": 430,
61
+ "l": 420,
62
+ "a": 70,
63
+ "texto": "pílula de vidro",
64
+ "tamanho": 32
65
+ },
66
+ {
67
+ "tipo": "icone",
68
+ "nome": "foguete",
69
+ "estilo": "app",
70
+ "x": 1130,
71
+ "y": 640,
72
+ "tamanho": 220,
73
+ "fundo": "#8b5cf6",
74
+ "som": "pop"
75
+ },
76
+ {
77
+ "tipo": "icone",
78
+ "nome": "raio",
79
+ "estilo": "vidro",
80
+ "x": 1400,
81
+ "y": 640,
82
+ "tamanho": 220,
83
+ "cor": "#ffb03a"
84
+ },
85
+ {
86
+ "tipo": "objeto3d",
87
+ "forma": "cubo",
88
+ "x": 620,
89
+ "y": 620,
90
+ "tamanho": 260,
91
+ "girar": 0,
92
+ "faces": [
93
+ "IA",
94
+ "3D",
95
+ "Motion",
96
+ "Vidro",
97
+ "Som",
98
+ "Marca"
99
+ ]
100
+ },
101
+ {
102
+ "tipo": "texto",
103
+ "x": 120,
104
+ "y": 700,
105
+ "l": 400,
106
+ "a": 100,
107
+ "texto": "chaves 3D",
108
+ "tamanho": 60,
109
+ "peso": 700,
110
+ "chaves": [
111
+ {
112
+ "t": 0,
113
+ "giroY": -60,
114
+ "opacidade": 0,
115
+ "x": -200
116
+ },
117
+ {
118
+ "t": 0.4,
119
+ "giroY": 0,
120
+ "opacidade": 1,
121
+ "x": 0
122
+ },
123
+ {
124
+ "t": 1,
125
+ "giroY": 20
126
+ }
127
+ ],
128
+ "suavizar": "elastico"
129
+ }
130
+ ]
131
+ }
132
+ ]
133
+ },
134
+ "atualizado": "2026-09-09T12:28:40+00:00"
135
+ }