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/act.js +87 -2
- package/lib/audio-livre.js +172 -0
- package/lib/catalogo.js +7 -0
- package/lib/marca.js +280 -0
- package/lib/referencia.js +210 -63
- package/lib/remotion.js +245 -45
- package/lib/studio.js +76 -3
- package/lib/tools.js +13 -0
- package/package.json +2 -2
- package/studio/__pycache__/edge.cpython-311.pyc +0 -0
- package/studio/__pycache__/elevenlabs.cpython-311.pyc +0 -0
- package/studio/arquivos/tela-motion-3ecaa.json +135 -0
- package/studio/arquivos/tela-motion-d6150.json +135 -0
- package/studio/edge.py +66 -16
- package/studio/ferramentas/01-modelos.md +20 -2
- package/studio/ferramentas/corte.md +140 -35
- package/studio/ferramentas/tela.md +21 -0
- package/studio/server.py +47 -11
- package/studio/studio.log +28 -0
- package/studio/web/js/player.js +30 -2
- package/studio/web/js/render.js +74 -4
- package/studio/web/js/sfx.js +128 -2
- package/studio/web/render.css +95 -0
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
|
@@ -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 (
|
|
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
|
-
|
|
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.
|
|
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"
|
|
Binary file
|
|
Binary file
|
|
@@ -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
|
+
}
|