primocode 8.39.0 → 8.41.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.
Files changed (40) hide show
  1. package/README.md +28 -5
  2. package/bin/primocode.js +90 -0
  3. package/lib/acervo.js +391 -0
  4. package/lib/act.js +71 -0
  5. package/lib/audio-livre.js +6 -1
  6. package/lib/boas-vindas.js +80 -21
  7. package/lib/catalogo.js +24 -0
  8. package/lib/conta.js +24 -3
  9. package/lib/diretor.js +306 -0
  10. package/lib/remotion.js +245 -45
  11. package/lib/studio.js +54 -2
  12. package/lib/tools.js +46 -0
  13. package/lib/ui.js +3 -2
  14. package/package.json +2 -2
  15. package/studio/__pycache__/voz.cpython-311.pyc +0 -0
  16. package/studio/ferramentas/corte.md +23 -28
  17. package/studio/web/js/render.js +32 -13
  18. package/studio/__pycache__/edge.cpython-311.pyc +0 -0
  19. package/studio/__pycache__/elevenlabs.cpython-311.pyc +0 -0
  20. package/studio/arquivos/corte-estatico-corte-a5bab.json +0 -150
  21. package/studio/arquivos/corte-founders-ai-teste-do-player-2de1d.json +0 -65
  22. package/studio/arquivos/corte-legado-teste.json +0 -1
  23. package/studio/arquivos/corte-legado2.json +0 -1
  24. package/studio/arquivos/corte-minimo-2a829.json +0 -24
  25. package/studio/arquivos/corte-ok-00a3b.json +0 -18
  26. package/studio/arquivos/corte-ok-1270e.json +0 -22
  27. package/studio/arquivos/corte-sweep-corte-cfb29.json +0 -147
  28. package/studio/arquivos/corte-visual-completo-78f50.json +0 -35
  29. package/studio/arquivos/grade-sweep-grade-0c2f1.json +0 -119
  30. package/studio/arquivos/prisma-estatico-prisma-d8740.json +0 -182
  31. package/studio/arquivos/prisma-sweep-prisma-4c58c.json +0 -166
  32. package/studio/arquivos/prisma-y-b0785.json +0 -52
  33. package/studio/arquivos/prosa-sweep-prosa-f29cf.json +0 -80
  34. package/studio/arquivos/tela-estatico-tela-c7daf.json +0 -90
  35. package/studio/arquivos/tela-motion-3ecaa.json +0 -135
  36. package/studio/arquivos/tela-motion-d6150.json +0 -135
  37. package/studio/arquivos/tela-sweep-tela-0d2bf.json +0 -84
  38. package/studio/arquivos/traco-estatico-traco-25872.json +0 -113
  39. package/studio/arquivos/traco-sweep-traco-0bcf4.json +0 -102
  40. package/studio/studio.log +0 -28
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
@@ -791,7 +791,7 @@ function errosDoNavegador(r) {
791
791
  return erros.length ? ' O navegador reclamou: ' + erros.slice(0, 3).map((e) => e.texto).join(' · ') : '';
792
792
  }
793
793
 
794
- const exportarVideo = ({ id, destino, fps, escondido, pasta }) => comStudio(() => comIdReal(id, async () => {
794
+ const exportarVideo = ({ id, destino, fps, escondido, pasta, remotion }) => comStudio(() => comIdReal(id, async () => {
795
795
  if (!id) return { ok: false, error: 'Falta o id do vídeo.' };
796
796
  if (typeof escondido !== 'function') return { ok: false, error: 'Sem navegador escondido para gerar o vídeo.' };
797
797
  const a = await pedir('GET', `/api/arquivos/${encodeURIComponent(id)}`);
@@ -850,8 +850,59 @@ const exportarVideo = ({ id, destino, fps, escondido, pasta }) => comStudio(() =
850
850
  if (!r.valor.ok) return { ok: false, error: `O navegador não conseguiu gerar o vídeo: ${r.valor.erro}.` + errosDoNavegador(r) };
851
851
 
852
852
  // 3. Do estúdio para a pasta do projeto, em mp4 quando dá.
853
- const origem = noDisco(r.valor.url);
853
+ let origem = noDisco(r.valor.url);
854
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
+ }
855
906
  const base = pasta || process.cwd();
856
907
  const slug = String(a.titulo || 'video').normalize('NFD').replace(/[̀-ͯ]/g, '')
857
908
  .replace(/[^\w-]+/g, '-').replace(/^-+|-+$/g, '').toLowerCase() || 'video';
@@ -891,6 +942,7 @@ const exportarVideo = ({ id, destino, fps, escondido, pasta }) => comStudio(() =
891
942
  ok: true,
892
943
  arquivo: saida,
893
944
  formato: formato.slice(1),
945
+ motor,
894
946
  bytes,
895
947
  segundos: r.valor.segundos,
896
948
  avisos: avisos.length ? avisos : undefined,
package/lib/tools.js CHANGED
@@ -15,6 +15,7 @@ const browser = require('./browser');
15
15
  const desktop = require('./desktop');
16
16
  const referencia = require('./referencia');
17
17
  const studio = require('./studio');
18
+ const acervo = require('./acervo');
18
19
  const cfg = require('./config');
19
20
 
20
21
  const IGNORAR = new Set(['node_modules', '.git', 'dist', 'build', '.next', 'coverage', '.cache', 'vendor']);
@@ -580,6 +581,48 @@ async function despachar(name, args, ctx) {
580
581
  return args.arquivo
581
582
  ? studio.midiaEnviar({ arquivo: resolveInProject(args.arquivo, projectDir), nome: args.nome })
582
583
  : studio.midiaListar();
584
+ /* ── MÍDIA DE VERDADE, VINDA DA INTERNET ──────────────────
585
+ *
586
+ * Buscar, baixar, recortar o fundo e já deixar na galeria do
587
+ * estúdio. O caminho inteiro é uma tool só do ponto de vista do
588
+ * agente (procurar → baixar), e o `paraEstudio` é o que evita a
589
+ * volta clássica: baixar num canto do disco e depois descobrir
590
+ * que a cena precisa de uma URL "/midia/...".
591
+ *
592
+ * A licença desce junto e fica gravada ao lado do arquivo. Um
593
+ * vídeo de cliente com foto CC BY sem crédito é problema do
594
+ * cliente, e descobrir isso depois de publicar é tarde. */
595
+ case 'midia_procurar':
596
+ return acervo.procurar(args.consulta || args.q || '', {
597
+ tipo: args.tipo, quantos: args.quantos, orientacao: args.orientacao,
598
+ licenca: args.licenca,
599
+ });
600
+ case 'midia_baixar': {
601
+ const r = await acervo.baixar(args.url, { titulo: args.titulo, meta: {
602
+ autor: args.autor || null, licenca: args.licenca || null,
603
+ fonte: args.fonte || null, pagina: args.pagina || null,
604
+ } });
605
+ if (!r.ok) return r;
606
+ let arquivo = r.arquivo;
607
+ if (args.semFundo) {
608
+ const corte = await acervo.tirarFundo(arquivo);
609
+ if (corte.ok) { arquivo = corte.arquivo; r.recorte = corte.metodo; r.avisoRecorte = corte.aviso; }
610
+ else r.avisoRecorte = corte.error;
611
+ }
612
+ r.arquivo = arquivo;
613
+ if (args.paraEstudio !== false) {
614
+ const g = await studio.midiaEnviar({ arquivo, nome: args.nome });
615
+ // O "/midia/..." é o que vai em `src` e em `fundo.imagem`.
616
+ if (g && g.ok !== false) r.src = g.url || g.src || null;
617
+ else r.avisoGaleria = (g && g.error) || 'não deu para mandar para a galeria';
618
+ }
619
+ return r;
620
+ }
621
+ case 'midia_sem_fundo':
622
+ return acervo.tirarFundo(resolveInProject(args.arquivo, projectDir), { destino: args.destino });
623
+ case 'midia_creditos':
624
+ return { ok: true, creditos: acervo.creditos() };
625
+
583
626
  case 'studio_analisar': {
584
627
  // Ouvir o vídeo para achar as pausas. A conta acontece num
585
628
  // navegador (é quem sabe decodificar MP4 sem instalar nada) —
@@ -599,9 +642,12 @@ async function despachar(name, args, ctx) {
599
642
  case 'studio_exportar':
600
643
  // O mp4 sem clique: a página do player roda escondida e gera
601
644
  // pelo mesmo caminho do botão. Ver lib/studio.js.
645
+ // Com o Remotion mesclado: o vídeo quadro a quadro dele, o
646
+ // áudio do Studio. PRIMOCODE_SEM_REMOTION=1 desliga.
602
647
  return studio.exportarVideo({
603
648
  id: args.id, destino: args.destino, fps: args.fps,
604
649
  escondido: browser.rodarEscondido, pasta: projectDir || process.cwd(),
650
+ remotion: process.env.PRIMOCODE_SEM_REMOTION !== '1',
605
651
  });
606
652
 
607
653
  /* A MESMA peça, virando projeto Remotion.
package/lib/ui.js CHANGED
@@ -160,8 +160,9 @@ const TIPS = [
160
160
  ];
161
161
 
162
162
  const NEWS = [
163
- '/effort escolhe o esforço num slider — do rápido ao mais esperto',
164
- 'Do "high" para cima ele PESQUISA referências antes de escrever',
163
+ 'Vídeo com DIRETOR: roteiro, narração, voz e movimento antes de produzir',
164
+ 'Ele busca e baixa imagem, logo e música — com a licença junto',
165
+ 'A escolha da IA na abertura agora anda nas setinhas',
165
166
  'Esc Esc a qualquer momento para o agente no meio do trabalho',
166
167
  ];
167
168
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "primocode",
3
- "version": "8.39.0",
3
+ "version": "8.41.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 && node test/ate-o-fim.test.js && node test/motion.test.js && node test/audio-marca.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 && node test/diretor.test.js"
12
12
  },
13
13
  "engines": {
14
14
  "node": ">=18.17.0"
@@ -215,34 +215,29 @@ usuário ou uma das trilhas geradas acima.
215
215
 
216
216
  ---
217
217
 
218
- ## Studio ou Remotion?
219
-
220
- **O padrão é o Studio.** Ele exporta o MP4 num clique, em segundos, sem
221
- instalar nada. Não ofereça Remotion sem motivo — trocar um clique por
222
- `npm install` de 250 MB é piorar a vida de quem só quer o vídeo.
223
-
224
- Chame `remotion_projeto` (com o `id` da peça) em três casos, e só neles:
225
-
226
- 1. **O usuário pediu Remotion pelo nome.** Não discuta, faça.
227
- 2. **A peça precisa do que o Studio não dá:** render determinístico (o mesmo
228
- arquivo em qualquer máquina, sem depender de a aba estar em foco), vídeo
229
- muito longo, fps ou codec específico (ProRes, canal alfa), renderização em
230
- servidor.
231
- 3. **O usuário quer mexer no código.** O projeto que nasce é React, é dele, e
232
- entra no git.
233
-
234
- A peça é a MESMA — `remotion_projeto` parte do documento que você já criou
235
- com `studio_criar`. Não existe um segundo formato para aprender: crie a peça
236
- normalmente, veja no navegador, e só então gere o projeto se fizer sentido.
237
-
238
- O projeto nasce sem `node_modules`. **Não rode `renderizar: true` antes de o
239
- `npm install` ter sido feito** — diga ao usuário os dois comandos e deixe ele
240
- decidir se quer esperar.
241
-
242
- **Licença:** Remotion é grátis para pessoa física e empresa de até três
243
- pessoas; acima disso a empresa precisa de licença paga. Avise ao oferecer —
244
- descobrir isso depois de montar o fluxo de trabalho em cima seria péssimo.
245
- Isso vale para o projeto do usuário, não para o PrimoCode.
218
+ ## Studio E Remotion — os dois, mesclados
219
+
220
+ **Não é uma escolha.** O Studio é quem AUTORA e mostra: a cena, o motion, a
221
+ narração, a trilha gerada, os efeitos — o link "ver" é o vídeo, na hora. Quando
222
+ o usuário pede o ARQUIVO (`studio_exportar`), o PrimoCode renderiza a mesma
223
+ peça quadro a quadro pelo **Remotion** (instalado uma vez, sozinho, em
224
+ `~/.primocode/remotion`) — determinístico, sem perder quadro, com todas as
225
+ animações, chaves 3D, contagem e clipes no instante certo — e **mistura o
226
+ áudio do Studio** (narração, trilha, efeitos sintetizados) no MP4 final. Você
227
+ não escolhe motor: chama `studio_exportar` e recebe o arquivo.
228
+
229
+ O primeiro vídeo que saiu só do Remotion estava **mudo e parado** — animações
230
+ desligadas, só `trilha.src` como som. Foi por isso que a mescla existe: o
231
+ Remotion sozinho não tem a narração nem a trilha gerada; o Studio sozinho
232
+ grava em tempo real. Juntos, o arquivo sai como a prévia.
233
+
234
+ `remotion_projeto` (com o `id` da peça) é OUTRA coisa: gera o **projeto React**
235
+ na pasta do usuário, com as cenas, a mídia e o motion, para quem quer mexer no
236
+ código, versionar no git ou renderizar em servidor. Só quando o usuário pedir
237
+ isso — não é o jeito de gerar o arquivo. O projeto nasce sem `node_modules`;
238
+ diga os dois comandos (`npm install`, `npm run render`) e deixe ele decidir. A
239
+ trilha **gerada** e os sons **sintetizados** não existem no projeto solto
240
+ (são WebAudio ao vivo): lá, use `buscar_audio` para ter a trilha em arquivo.
246
241
 
247
242
  ---
248
243