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.
- package/README.md +28 -5
- package/bin/primocode.js +90 -0
- package/lib/acervo.js +391 -0
- package/lib/act.js +71 -0
- package/lib/audio-livre.js +6 -1
- package/lib/boas-vindas.js +80 -21
- package/lib/catalogo.js +24 -0
- package/lib/conta.js +24 -3
- package/lib/diretor.js +306 -0
- package/lib/remotion.js +245 -45
- package/lib/studio.js +54 -2
- package/lib/tools.js +46 -0
- package/lib/ui.js +3 -2
- package/package.json +2 -2
- package/studio/__pycache__/voz.cpython-311.pyc +0 -0
- package/studio/ferramentas/corte.md +23 -28
- package/studio/web/js/render.js +32 -13
- package/studio/__pycache__/edge.cpython-311.pyc +0 -0
- package/studio/__pycache__/elevenlabs.cpython-311.pyc +0 -0
- package/studio/arquivos/corte-estatico-corte-a5bab.json +0 -150
- package/studio/arquivos/corte-founders-ai-teste-do-player-2de1d.json +0 -65
- package/studio/arquivos/corte-legado-teste.json +0 -1
- package/studio/arquivos/corte-legado2.json +0 -1
- package/studio/arquivos/corte-minimo-2a829.json +0 -24
- package/studio/arquivos/corte-ok-00a3b.json +0 -18
- package/studio/arquivos/corte-ok-1270e.json +0 -22
- package/studio/arquivos/corte-sweep-corte-cfb29.json +0 -147
- package/studio/arquivos/corte-visual-completo-78f50.json +0 -35
- package/studio/arquivos/grade-sweep-grade-0c2f1.json +0 -119
- package/studio/arquivos/prisma-estatico-prisma-d8740.json +0 -182
- package/studio/arquivos/prisma-sweep-prisma-4c58c.json +0 -166
- package/studio/arquivos/prisma-y-b0785.json +0 -52
- package/studio/arquivos/prosa-sweep-prosa-f29cf.json +0 -80
- package/studio/arquivos/tela-estatico-tela-c7daf.json +0 -90
- package/studio/arquivos/tela-motion-3ecaa.json +0 -135
- package/studio/arquivos/tela-motion-d6150.json +0 -135
- package/studio/arquivos/tela-sweep-tela-0d2bf.json +0 -84
- package/studio/arquivos/traco-estatico-traco-25872.json +0 -113
- package/studio/arquivos/traco-sweep-traco-0bcf4.json +0 -102
- 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 {
|
|
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
|
-
/*
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
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
|
-
|
|
271
|
+
useLayoutEffect(() => {
|
|
232
272
|
if (!caixa.current) return;
|
|
233
273
|
caixa.current.innerHTML = "";
|
|
234
|
-
|
|
235
|
-
|
|
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
|
-
/*
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
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
|
-
<
|
|
278
|
-
|
|
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(
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
'
|
|
164
|
-
'
|
|
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.
|
|
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"
|
|
Binary file
|
|
@@ -215,34 +215,29 @@ usuário ou uma das trilhas geradas acima.
|
|
|
215
215
|
|
|
216
216
|
---
|
|
217
217
|
|
|
218
|
-
## Studio
|
|
219
|
-
|
|
220
|
-
**
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
com
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
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
|
|