primocode 9.10.0 → 10.0.9
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 +30 -6
- package/bin/primocode.js +169 -342
- package/lib/act.js +360 -39
- package/lib/browser.js +105 -4
- package/lib/casar.js +108 -0
- package/lib/catalogo.js +6 -2
- package/lib/claude-engine.js +38 -17
- package/lib/codex-engine.js +27 -9
- package/lib/config.js +10 -3
- package/lib/cursor.js +10 -1
- package/lib/entrada.js +77 -11
- package/lib/estudio/cinema.js +27 -9
- package/lib/estudio/diretor.js +21 -1
- package/lib/estudio/ende.js +16 -5
- package/lib/estudio/estilo.js +66 -0
- package/lib/estudio/gerativo.js +1453 -0
- package/lib/estudio/glifos.js +215 -0
- package/lib/estudio/hibrido.js +165 -0
- package/lib/estudio/index.js +284 -53
- package/lib/estudio/kit/base.jsx +285 -0
- package/lib/estudio/kit/gerativo.jsx +980 -0
- package/lib/estudio/kit/index.jsx +13 -0
- package/lib/estudio/kit/marca.jsx +741 -0
- package/lib/estudio/kit/tres.jsx +308 -0
- package/lib/estudio/kit/vox.jsx +357 -0
- package/lib/estudio/kit.js +78 -0
- package/lib/estudio/lancamento.js +441 -0
- package/lib/estudio/motion.js +2 -5
- package/lib/estudio/plano.js +28 -3
- package/lib/estudio/referencias.js +105 -0
- package/lib/estudio/remotion.js +139 -51
- package/lib/estudio/ritmo.js +77 -0
- package/lib/estudio/sons-leves.js +38 -1
- package/lib/estudio/variedade.js +105 -0
- package/lib/estudio/vox.js +414 -0
- package/lib/fala.js +1 -94
- package/lib/mcp.js +156 -0
- package/lib/personas.js +67 -0
- package/lib/pipeline.js +55 -56
- package/lib/repeticao.js +3 -1
- package/lib/skills.js +44 -2
- package/lib/snapshot.js +111 -0
- package/lib/tools.js +100 -22
- package/lib/ui.js +50 -8
- package/lib/video/analise.js +89 -0
- package/lib/video/auto.js +329 -0
- package/lib/video/camadas.js +207 -0
- package/lib/video/cli.js +74 -0
- package/lib/video/delta.js +90 -0
- package/lib/video/edits.js +379 -0
- package/lib/video/filters.js +280 -0
- package/lib/video/fx3d/Camadas.jsx +64 -0
- package/lib/video/fx3d/Objetos.jsx +128 -0
- package/lib/video/fx3d/Root.jsx +18 -0
- package/lib/video/fx3d/modelos.jsx +304 -0
- package/lib/video/fx3d.js +108 -0
- package/lib/video/gerados.js +119 -0
- package/lib/video/objetos.js +111 -0
- package/lib/video/ops-fx.js +453 -0
- package/lib/video/ops.js +347 -0
- package/lib/video/paralelo.js +50 -0
- package/lib/video/pedido.js +191 -0
- package/lib/video/projeto.js +133 -0
- package/lib/video/rastrear.js +95 -0
- package/lib/video/rastro.js +276 -0
- package/lib/video/render.js +274 -0
- package/lib/video/texto.js +144 -0
- package/lib/video/timeline.js +318 -0
- package/lib/video/transcript.js +124 -0
- package/lib/voz.js +75 -223
- package/package.json +2 -3
- package/skills/motion-de-primeira/SKILL.md +5 -5
- package/studio/narrar.py +10 -7
- package/studio/rastrear.py +399 -0
- package/studio/texto.py +208 -0
- package/voz/primo_voz/__main__.py +96 -236
- package/voz/primo_voz/transcrever.py +31 -1
- package/app/ansi.js +0 -55
- package/app/app.css +0 -500
- package/app/app.js +0 -1017
- package/app/icone.svg +0 -6
- package/app/index.html +0 -220
- package/app/primo-marca.js +0 -192
- package/lib/app.js +0 -615
- package/lib/icone.js +0 -263
- package/lib/instalar-app.js +0 -231
- package/lib/painel.js +0 -291
- package/voz/primo_voz/desperta.py +0 -195
- package/voz/primo_voz/orbe.py +0 -406
package/lib/estudio/remotion.js
CHANGED
|
@@ -74,6 +74,10 @@ function pkgJson(nome) {
|
|
|
74
74
|
remotion: VERSAO_REMOTION,
|
|
75
75
|
react: '19.0.0',
|
|
76
76
|
'react-dom': '19.0.0',
|
|
77
|
+
// O produto em 3D (kit/tres.jsx). Instalado uma vez na base compartilhada.
|
|
78
|
+
'@remotion/three': VERSAO_REMOTION,
|
|
79
|
+
three: '0.186.1',
|
|
80
|
+
'@react-three/fiber': '9.8.1',
|
|
77
81
|
},
|
|
78
82
|
}, null, 2) + '\n';
|
|
79
83
|
}
|
|
@@ -98,6 +102,7 @@ import {
|
|
|
98
102
|
useVideoConfig, interpolate, staticFile, continueRender, delayRender,
|
|
99
103
|
} from 'remotion';
|
|
100
104
|
import { morfar, ehCor, misturarCor } from './morfar';
|
|
105
|
+
import { KIT, CURVAS_KIT, Filtros } from './kit';
|
|
101
106
|
|
|
102
107
|
/* ── SUAVIZAÇÃO ──────────────────────────────────────────────────────────
|
|
103
108
|
* Cada curva é uma função de 0..1 em 0..1. "suave" é o padrão porque
|
|
@@ -117,6 +122,8 @@ const CURVAS = {
|
|
|
117
122
|
if (t < 2.5 / d) return n * (t -= 2.25 / d) * t + 0.9375;
|
|
118
123
|
return n * (t -= 2.625 / d) * t + 0.984375;
|
|
119
124
|
},
|
|
125
|
+
// editorial, camera, arrasto, acelera (kit/base.jsx): a curva certa do guia VOX.
|
|
126
|
+
...CURVAS_KIT,
|
|
120
127
|
};
|
|
121
128
|
|
|
122
129
|
/* O valor de uma propriedade animada no segundo "t".
|
|
@@ -217,7 +224,12 @@ function Elemento({ el, t, dados }) {
|
|
|
217
224
|
|
|
218
225
|
const tLocal = t - (el.de || 0);
|
|
219
226
|
const a = animado(el.anima, tLocal);
|
|
220
|
-
|
|
227
|
+
/* A PEÇA DO KIT desenha nas coordenadas da cena (x, y dela são o lugar do
|
|
228
|
+
assunto, não o canto de uma caixa): a caixa dela é o quadro inteiro, e o
|
|
229
|
+
"anima" do elemento move o conjunto. */
|
|
230
|
+
const base = el.tipo === 'kit'
|
|
231
|
+
? { position: 'absolute', left: 0, top: 0, width: '100%', height: '100%' }
|
|
232
|
+
: posicao(el);
|
|
221
233
|
const move = transformDe(a, {});
|
|
222
234
|
const estilo = {
|
|
223
235
|
...base,
|
|
@@ -355,6 +367,16 @@ function Elemento({ el, t, dados }) {
|
|
|
355
367
|
/* O GRUPO: uma tela dentro da tela. Os filhos vivem no tempo e no espaço
|
|
356
368
|
dele; com "recorte" o que passa da borda some (a tela do celular). O
|
|
357
369
|
grupo anima como qualquer elemento: cresce, sai do celular, cobre tudo. */
|
|
370
|
+
case 'kit': {
|
|
371
|
+
const Peca = KIT[el.peca];
|
|
372
|
+
if (!Peca) return null;
|
|
373
|
+
return (
|
|
374
|
+
<div style={{ ...estilo, transformOrigin: el.origem || ((el.x || 0) + 'px ' + (el.y || 0) + 'px') }}>
|
|
375
|
+
<Peca el={el} t={tLocal} fps={dados.fps} />
|
|
376
|
+
</div>
|
|
377
|
+
);
|
|
378
|
+
}
|
|
379
|
+
|
|
358
380
|
case 'grupo':
|
|
359
381
|
return (
|
|
360
382
|
<div style={{
|
|
@@ -516,11 +538,36 @@ export function Cena({ cena, dados, tFixo }) {
|
|
|
516
538
|
cam.rotacaoY != null ? 'rotateY(' + cam.rotacaoY + 'deg)' : '',
|
|
517
539
|
].filter(Boolean).join(' ');
|
|
518
540
|
|
|
541
|
+
/* A CÂMERA EM CAMADAS (o parallax do guia VOX): "camadas" são planos de
|
|
542
|
+
profundidade, e o que está longe anda menos que o que está perto. O
|
|
543
|
+
parallax é CONSEQUÊNCIA da câmera, não um efeito à parte: o plano do
|
|
544
|
+
assunto ("sujeito") recebe a câmera exata, os outros na proporção da
|
|
545
|
+
profundidade deles — e cada um com o desfoque da lente naquela distância.
|
|
546
|
+
Cada elemento diz a camada dele ("camada": índice); sem, é a do sujeito. */
|
|
547
|
+
const camadas = Array.isArray(cena.camadas) && cena.camadas.length ? cena.camadas : null;
|
|
548
|
+
const iSujeito = camadas ? Math.max(0, camadas.findIndex((c) => c.sujeito)) : 0;
|
|
549
|
+
const mundo = (els, f, desfoque) => (
|
|
550
|
+
<AbsoluteFill style={{
|
|
551
|
+
transform: f == null ? (camTransform || undefined) : [
|
|
552
|
+
'translate(' + (-(cam.x || 0) * f + tx) + 'px,' + (-(cam.y || 0) * f + ty) + 'px)',
|
|
553
|
+
'scale(' + ((1 + ((cam.zoom ?? 1) - 1) * f) * soco) + ')',
|
|
554
|
+
'rotate(' + ((cam.rotacao || 0) * f + tr) + 'deg)',
|
|
555
|
+
].join(' '),
|
|
556
|
+
transformOrigin: cena.cameraOrigem || 'center center',
|
|
557
|
+
// O mundo guarda o 3D dos filhos; a perspectiva vem do pai.
|
|
558
|
+
transformStyle: cena.perspectiva ? 'preserve-3d' : undefined,
|
|
559
|
+
filter: desfoque ? 'blur(' + desfoque + 'px)' : undefined,
|
|
560
|
+
}}>
|
|
561
|
+
{els.map((el, i) => <Elemento key={i} el={el} t={t} dados={dados} />)}
|
|
562
|
+
</AbsoluteFill>
|
|
563
|
+
);
|
|
564
|
+
|
|
519
565
|
return (
|
|
520
566
|
/* A perspectiva mora AQUI, um nível acima do mundo: se ficasse no mesmo
|
|
521
567
|
elemento da câmera, girar a câmera sairia achatado. */
|
|
522
568
|
<AbsoluteFill style={{ ...fundoCSS(cena.fundo), overflow: 'hidden',
|
|
523
569
|
perspective: cena.perspectiva ? cena.perspectiva + 'px' : undefined }}>
|
|
570
|
+
<Filtros />
|
|
524
571
|
{cena.fundo && cena.fundo.tipo === 'imagem' && (
|
|
525
572
|
<Img src={staticFile(cena.fundo.src)} style={{
|
|
526
573
|
position: 'absolute', inset: 0, width: '100%', height: '100%',
|
|
@@ -533,16 +580,13 @@ export function Cena({ cena, dados, tFixo }) {
|
|
|
533
580
|
objectFit: cena.fundo.ajuste || 'cover',
|
|
534
581
|
}} />
|
|
535
582
|
)}
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
<Elemento key={i} el={el} t={t} dados={dados} />
|
|
544
|
-
))}
|
|
545
|
-
</AbsoluteFill>
|
|
583
|
+
{camadas
|
|
584
|
+
? camadas.map((c, ci) => (
|
|
585
|
+
<React.Fragment key={ci}>
|
|
586
|
+
{mundo((cena.elementos || []).filter((el) => (el.camada ?? iSujeito) === ci), (c.p || 1) / (camadas[iSujeito].p || 1), c.desfoque)}
|
|
587
|
+
</React.Fragment>
|
|
588
|
+
))
|
|
589
|
+
: mundo(cena.elementos || [], null, 0)}
|
|
546
590
|
{flash > 0.01 && <AbsoluteFill style={{ backgroundColor: cena.corFlash || '#fff', opacity: flash * 0.85 }} />}
|
|
547
591
|
</AbsoluteFill>
|
|
548
592
|
);
|
|
@@ -552,6 +596,7 @@ export function Cena({ cena, dados, tFixo }) {
|
|
|
552
596
|
const RAIZ_JSX = String.raw`import React from 'react';
|
|
553
597
|
import { AbsoluteFill, Audio, Sequence, staticFile, useCurrentFrame, useVideoConfig, interpolate } from 'remotion';
|
|
554
598
|
import { Cena } from './Cena';
|
|
599
|
+
import { Acabamento, Hud, bolhaCaminho } from './kit';
|
|
555
600
|
import plano from './plano.json';
|
|
556
601
|
import icones from './icones.json';
|
|
557
602
|
|
|
@@ -560,6 +605,7 @@ import icones from './icones.json';
|
|
|
560
605
|
* o "piscar" de fundo que denuncia corte mal feito. */
|
|
561
606
|
function Transicao({ tipo, progresso, children }) {
|
|
562
607
|
const p = progresso;
|
|
608
|
+
const { width: W, height: H } = useVideoConfig();
|
|
563
609
|
let estilo = {};
|
|
564
610
|
if (tipo === 'esmaecer') estilo = { opacity: p };
|
|
565
611
|
else if (tipo === 'deslizar') estilo = { transform: 'translateX(' + (1 - p) * 100 + '%)' };
|
|
@@ -572,6 +618,12 @@ function Transicao({ tipo, progresso, children }) {
|
|
|
572
618
|
// O SOCO: a cena entra grande e bate no lugar.
|
|
573
619
|
else if (tipo === 'soco') estilo = { transform: 'scale(' + (1.35 - 0.35 * p) + ')', opacity: Math.min(1, p * 3) };
|
|
574
620
|
else if (tipo === 'flash') estilo = { opacity: Math.min(1, p * 2), filter: 'brightness(' + (1 + (1 - p) * 3) + ')' };
|
|
621
|
+
/* O LÍQUIDO: a cena nova entra como uma mancha que cresce de um canto e
|
|
622
|
+
engole a anterior, com a borda viva (os lóbulos respiram enquanto crescem). */
|
|
623
|
+
else if (tipo === 'liquido' && p < 1) {
|
|
624
|
+
const k = p < 0.5 ? 4 * p * p * p : 1 - Math.pow(-2 * p + 2, 3) / 2;
|
|
625
|
+
estilo = { clipPath: "path('" + bolhaCaminho(W * 0.78, H * 0.62, Math.hypot(W, H) * 0.95 * k, p * 3, 2) + "')" };
|
|
626
|
+
}
|
|
575
627
|
return <AbsoluteFill style={estilo}>{children}</AbsoluteFill>;
|
|
576
628
|
}
|
|
577
629
|
|
|
@@ -591,7 +643,7 @@ export const Paginas = () => {
|
|
|
591
643
|
};
|
|
592
644
|
|
|
593
645
|
export const Video = () => {
|
|
594
|
-
const { fps } = useVideoConfig();
|
|
646
|
+
const { fps, width: largura, height: altura } = useVideoConfig();
|
|
595
647
|
const frame = useCurrentFrame();
|
|
596
648
|
const dados = { fps, icones };
|
|
597
649
|
|
|
@@ -632,31 +684,17 @@ export const Video = () => {
|
|
|
632
684
|
);
|
|
633
685
|
})}
|
|
634
686
|
|
|
635
|
-
{/* O ACABAMENTO: granulação de filme, vinheta e a luz da marca passando
|
|
636
|
-
É o que tira a cara de apresentação
|
|
637
|
-
{plano.acabamento
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
<
|
|
645
|
-
|
|
646
|
-
backgroundSize: (plano.acabamento.passoGrade || 120) + 'px ' + (plano.acabamento.passoGrade || 120) + 'px',
|
|
647
|
-
backgroundPosition: (frame * 0.2) + 'px ' + (frame * 0.1) + 'px' }} />
|
|
648
|
-
)}
|
|
649
|
-
{plano.acabamento && plano.acabamento.vinheta && (
|
|
650
|
-
<AbsoluteFill style={{ pointerEvents: 'none', background: 'radial-gradient(ellipse at center, transparent 55%, rgba(0,0,0,' + plano.acabamento.vinheta + ') 100%)' }} />
|
|
651
|
-
)}
|
|
652
|
-
{plano.acabamento && plano.acabamento.grao && (
|
|
653
|
-
<AbsoluteFill style={{ pointerEvents: 'none', opacity: plano.acabamento.grao, mixBlendMode: 'overlay' }}>
|
|
654
|
-
<svg width="100%" height="100%">
|
|
655
|
-
<filter id="grao"><feTurbulence type="fractalNoise" baseFrequency="0.85" numOctaves="2" seed={frame % 60} /></filter>
|
|
656
|
-
<rect width="100%" height="100%" filter="url(#grao)" />
|
|
657
|
-
</svg>
|
|
658
|
-
</AbsoluteFill>
|
|
659
|
-
)}
|
|
687
|
+
{/* O ACABAMENTO: granulação de filme, vinheta e a luz da marca passando
|
|
688
|
+
(kit/base.jsx). É o que tira a cara de apresentação. */}
|
|
689
|
+
<Acabamento ac={plano.acabamento} frame={frame} />
|
|
690
|
+
{/* O HUD do motion designer: cantos, timecode, fps e a seção da cena. */}
|
|
691
|
+
{plano.hud && (() => {
|
|
692
|
+
const atual = pedacos.filter((q) => frame >= q.de).pop() || pedacos[0];
|
|
693
|
+
const total = pedacos.length ? pedacos[pedacos.length - 1].de + pedacos[pedacos.length - 1].dur : 1;
|
|
694
|
+
// A cena diz se é escura (o HUD claro) ou clara (o HUD escuro).
|
|
695
|
+
const escuro = !(atual && atual.cena.escuro === false);
|
|
696
|
+
return <Hud hud={plano.hud} frame={frame} fps={fps} total={total} W={largura} H={altura} secao={atual && atual.cena.secao} escuro={escuro} />;
|
|
697
|
+
})()}
|
|
660
698
|
|
|
661
699
|
{plano.audio && plano.audio.narracao && (
|
|
662
700
|
<Audio src={staticFile(plano.audio.narracao)} volume={plano.audio.volumeNarracao ?? 1} />
|
|
@@ -800,6 +838,8 @@ function montar({ pasta, titulo, plano, icones = {} }) {
|
|
|
800
838
|
escrever(path.join(src, 'Video.jsx'), RAIZ_JSX);
|
|
801
839
|
escrever(path.join(src, 'Cena.jsx'), CENA_JSX);
|
|
802
840
|
escrever(path.join(src, 'morfar.js'), require('./morfar.js').fonteEsm());
|
|
841
|
+
// O kit de motion (kit.js): as peças que o elemento {"tipo":"kit"} monta.
|
|
842
|
+
for (const f of require('./kit.js').arquivos()) escrever(path.join(src, f.nome), f.conteudo);
|
|
803
843
|
escrever(path.join(src, 'plano.json'), JSON.stringify(plano, null, 2));
|
|
804
844
|
escrever(path.join(src, 'icones.json'), JSON.stringify(icones, null, 2));
|
|
805
845
|
escrever(path.join(src, 'fontes.css'), fontesCSS(plano));
|
|
@@ -826,16 +866,30 @@ function montar({ pasta, titulo, plano, icones = {} }) {
|
|
|
826
866
|
do usuário e roda com rede disponível no `npm install`; o Chromium do
|
|
827
867
|
Remotion baixa a fonte no primeiro quadro e o Remotion espera por ela. */
|
|
828
868
|
function fontesCSS(plano) {
|
|
869
|
+
/* Toda chave que começa com "fonte" (fonte, fonteMono, fonteDef…), em
|
|
870
|
+
qualquer profundidade: dentro de grupo, na legenda, nas peças do kit e
|
|
871
|
+
no HUD. Fonte que o plano usa e o CSS não declara sai em Times. */
|
|
829
872
|
const usadas = new Set();
|
|
830
|
-
|
|
831
|
-
|
|
832
|
-
|
|
833
|
-
|
|
873
|
+
const andar = (o) => {
|
|
874
|
+
if (!o || typeof o !== 'object') return;
|
|
875
|
+
if (Array.isArray(o)) return o.forEach(andar);
|
|
876
|
+
for (const [k, v] of Object.entries(o)) {
|
|
877
|
+
if (/^fonte/.test(k) && typeof v === 'string' && v.trim()) usadas.add(v.trim());
|
|
878
|
+
else if (v && typeof v === 'object') andar(v);
|
|
834
879
|
}
|
|
835
|
-
}
|
|
880
|
+
};
|
|
881
|
+
for (const cena of plano.cenas || []) andar(cena.elementos || []);
|
|
882
|
+
andar(plano.hud);
|
|
836
883
|
if (!usadas.size) return '/* nenhuma fonte externa neste vídeo */\n';
|
|
884
|
+
/* A FONTE VARIÁVEL: o motion gerativo anima os eixos (wght, wdth, GRAD…)
|
|
885
|
+
e precisa do arquivo variável inteiro, pedido pela faixa de cada eixo
|
|
886
|
+
("Roboto+Flex:opsz,slnt,wdth,wght,GRAD@8..144,…"). As outras seguem
|
|
887
|
+
com os pesos fixos. */
|
|
888
|
+
const variaveis = plano.fontesVariaveis || {};
|
|
837
889
|
const familias = [...usadas]
|
|
838
|
-
|
|
890
|
+
// O Google devolve o que a família tem e ignora o resto: pedir todos os pesos e o itálico não quebra.
|
|
891
|
+
.map((f) => 'family=' + encodeURIComponent(f).replace(/%20/g, '+')
|
|
892
|
+
+ (variaveis[f] ? ':' + variaveis[f] : ':ital,wght@0,300;0,400;0,500;0,600;0,700;0,800;0,900;1,400;1,600'))
|
|
839
893
|
.join('&');
|
|
840
894
|
return `@import url('https://fonts.googleapis.com/css2?${familias}&display=block');\n`;
|
|
841
895
|
}
|
|
@@ -860,15 +914,47 @@ function citarWin(a) {
|
|
|
860
914
|
return '"' + t.replace(/"/g, '""') + '"';
|
|
861
915
|
}
|
|
862
916
|
|
|
917
|
+
/* O REMOTION COMPARTILHADO. Cada projeto rodava o próprio `npm install`
|
|
918
|
+
(minutos, e ~300 MB por vídeo) — e um 404 passageiro do registro no meio
|
|
919
|
+
dele derrubava o vídeo inteiro (medido em 30/09: source-map-js 1.2.2).
|
|
920
|
+
Agora a instalação mora UMA vez em ~/.primocode/studio/remotion-base, com
|
|
921
|
+
as mesmas dependências, e o projeto aponta para ela. Dependência diferente
|
|
922
|
+
(versão nova do Remotion) reinstala a base. */
|
|
923
|
+
const os = require('os');
|
|
924
|
+
function baseCompartilhada(raiz) {
|
|
925
|
+
const base = path.join(os.homedir(), '.primocode', 'studio', 'remotion-base');
|
|
926
|
+
let deps = '';
|
|
927
|
+
try { const pj = JSON.parse(fs.readFileSync(path.join(raiz, 'package.json'), 'utf8')); deps = JSON.stringify({ d: pj.dependencies || {}, v: pj.devDependencies || {} }); } catch { return null; }
|
|
928
|
+
return { base, deps, pronta: (() => {
|
|
929
|
+
try { return fs.existsSync(path.join(base, 'node_modules', '.bin')) && fs.readFileSync(path.join(base, 'deps.json'), 'utf8') === deps; } catch { return false; }
|
|
930
|
+
})() };
|
|
931
|
+
}
|
|
932
|
+
|
|
863
933
|
function renderizar({ raiz, saida, minutos = 30, aoFalar = () => {} }) {
|
|
864
934
|
return new Promise((resolve) => {
|
|
865
|
-
|
|
866
|
-
const
|
|
867
|
-
|
|
868
|
-
|
|
869
|
-
|
|
935
|
+
let temNodeModules = fs.existsSync(path.join(raiz, 'node_modules'));
|
|
936
|
+
const comp = temNodeModules || process.platform === 'win32' ? null : baseCompartilhada(raiz);
|
|
937
|
+
if (comp && comp.pronta) {
|
|
938
|
+
try { fs.symlinkSync(path.join(comp.base, 'node_modules'), path.join(raiz, 'node_modules'), 'dir'); temNodeModules = true; } catch { /* instala aqui mesmo */ }
|
|
939
|
+
}
|
|
940
|
+
const passos = [];
|
|
941
|
+
if (!temNodeModules && comp) {
|
|
942
|
+
// Instala na base (com uma segunda tentativa: o registro às vezes devolve 404 por instantes) e liga.
|
|
943
|
+
try { fs.mkdirSync(comp.base, { recursive: true }); fs.copyFileSync(path.join(raiz, 'package.json'), path.join(comp.base, 'package.json')); } catch { /* segue */ }
|
|
944
|
+
passos.push(['npm', ['install', '--no-audit', '--no-fund'], { cwd: comp.base, tentativas: 2, depois: () => {
|
|
945
|
+
fs.writeFileSync(path.join(comp.base, 'deps.json'), comp.deps);
|
|
946
|
+
try { fs.symlinkSync(path.join(comp.base, 'node_modules'), path.join(raiz, 'node_modules'), 'dir'); } catch { /* já existe */ }
|
|
947
|
+
} }]);
|
|
948
|
+
} else if (!temNodeModules) passos.push(['npm', ['install', '--no-audit', '--no-fund'], { tentativas: 2 }]);
|
|
949
|
+
// Vídeo de b-roll pesado (Pexels HD) levava mais de 30 s para o primeiro quadro.
|
|
950
|
+
/* VÁRIOS TRABALHADORES no render: o Remotion usa metade dos núcleos por
|
|
951
|
+
padrão; aqui vão quase todos (um fica para o terminal respirar). */
|
|
952
|
+
const nucleos = Math.max(2, (require('os').cpus() || []).length - 1);
|
|
953
|
+
passos.push(['npx', ['remotion', 'render', 'Video', saida, '--log=warn', '--timeout=120000', `--concurrency=${nucleos}`]]);
|
|
954
|
+
|
|
955
|
+
const correr = (i, tentativa = 1) => {
|
|
870
956
|
if (i >= passos.length) return resolve({ ok: true, arquivo: saida });
|
|
871
|
-
const [cmd, args] = passos[i];
|
|
957
|
+
const [cmd, args, op = {}] = passos[i];
|
|
872
958
|
aoFalar(i === 0 && !temNodeModules
|
|
873
959
|
? 'instalando o Remotion nesta pasta (uma vez só, alguns minutos)'
|
|
874
960
|
: 'renderizando quadro a quadro');
|
|
@@ -882,7 +968,7 @@ function renderizar({ raiz, saida, minutos = 30, aoFalar = () => {} }) {
|
|
|
882
968
|
CVE-2024-27980 o Node recusa spawnar npm.cmd/npx.cmd sem shell.
|
|
883
969
|
Então as aspas entram na mão, só onde o shell existe. */
|
|
884
970
|
const citados = process.platform === 'win32' ? args.map(citarWin) : args;
|
|
885
|
-
const p = spawn(cmd, citados, { cwd: raiz, shell: process.platform === 'win32' });
|
|
971
|
+
const p = spawn(cmd, citados, { cwd: op.cwd || raiz, shell: process.platform === 'win32' });
|
|
886
972
|
let erro = '';
|
|
887
973
|
p.stderr.on('data', (d) => { erro += d.toString().slice(0, 2000); });
|
|
888
974
|
p.stdout.on('data', (d) => {
|
|
@@ -893,8 +979,10 @@ function renderizar({ raiz, saida, minutos = 30, aoFalar = () => {} }) {
|
|
|
893
979
|
p.on('close', (code) => {
|
|
894
980
|
clearTimeout(relogio);
|
|
895
981
|
if (code !== 0) {
|
|
982
|
+
if (tentativa < (op.tentativas || 1)) { aoFalar('o npm tropeçou — tentando de novo'); return setTimeout(() => correr(i, tentativa + 1), 3000); }
|
|
896
983
|
return resolve({ ok: false, error: `${cmd} saiu com ${code}: ${erro.slice(-600).trim()}` });
|
|
897
984
|
}
|
|
985
|
+
try { if (op.depois) op.depois(); } catch { /* segue */ }
|
|
898
986
|
correr(i + 1);
|
|
899
987
|
});
|
|
900
988
|
p.on('error', (e) => {
|
|
@@ -917,4 +1005,4 @@ function renderizar({ raiz, saida, minutos = 30, aoFalar = () => {} }) {
|
|
|
917
1005
|
});
|
|
918
1006
|
}
|
|
919
1007
|
|
|
920
|
-
module.exports = { montar, renderizar, fontesCSS, VERSAO_REMOTION, CENA_JSX };
|
|
1008
|
+
module.exports = { montar, renderizar, fontesCSS, VERSAO_REMOTION, CENA_JSX, pkgJson, baseCompartilhada, TSCONFIG };
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ritmo.js — o tempo de um vídeo feito de partes: onde cada uma corta, quando
|
|
3
|
+
* cada coisa entra, e a música que abaixa quando a voz fala.
|
|
4
|
+
*
|
|
5
|
+
* "O áudio comanda o quadro." — guia MOTION VOX STYLE
|
|
6
|
+
*
|
|
7
|
+
* Os estilos de cena por parte (vox.js, lancamento.js) dividem isto: o corte
|
|
8
|
+
* cai no beat (um pouco antes da primeira palavra da parte), cada elemento
|
|
9
|
+
* entra no instante em que a palavra dele é dita, e o volume da trilha segue
|
|
10
|
+
* a narração. Puro: tempos entram, números saem.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
'use strict';
|
|
14
|
+
|
|
15
|
+
const { quando } = require('./cinema.js');
|
|
16
|
+
|
|
17
|
+
const r2 = (n) => Math.round(n * 100) / 100;
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Onde cada parte começa e quanto dura.
|
|
21
|
+
*
|
|
22
|
+
* Com voz, a parte começa `antes` segundos antes da primeira palavra (o corte
|
|
23
|
+
* seco no beat) e vai até a próxima; a última ganha `cauda` de respiro. Sem
|
|
24
|
+
* voz, cada parte dura o que se leva para ler o que ela mostra.
|
|
25
|
+
*
|
|
26
|
+
* @returns {{ini:number[], dur:number[], total:number, temVoz:boolean}}
|
|
27
|
+
*/
|
|
28
|
+
function tempoDasPartes(partes, tempos = [], { semVoz = () => 2.0, antes = 0.08, cauda = 0.6, minimo = 0.9 } = {}) {
|
|
29
|
+
const temVoz = tempos.length === partes.length && tempos.every((t) => t && t.ate > t.de);
|
|
30
|
+
const ini = [];
|
|
31
|
+
if (temVoz) partes.forEach((_, i) => ini.push(i === 0 ? 0 : r2(Math.max(ini[i - 1] + minimo, tempos[i].de - antes))));
|
|
32
|
+
else partes.forEach((p, i) => ini.push(i === 0 ? 0 : r2(ini[i - 1] + Math.max(minimo, semVoz(partes[i - 1])))));
|
|
33
|
+
const total = r2(temVoz ? Math.max(ini[ini.length - 1] + minimo, tempos[tempos.length - 1].ate + cauda) : ini[ini.length - 1] + Math.max(minimo, semVoz(partes[partes.length - 1])) + 0.4);
|
|
34
|
+
const dur = ini.map((t, i) => r2((i < ini.length - 1 ? ini[i + 1] : total) - t));
|
|
35
|
+
return { ini, dur, total, temVoz };
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Quando cada texto entra, no tempo DA CENA: o instante em que a primeira
|
|
40
|
+
* palavra forte dele é dita. Sem voz (ou sem achar), espalha os textos pelo
|
|
41
|
+
* começo da cena, respeitando a ordem.
|
|
42
|
+
*/
|
|
43
|
+
function gatilhos(textos, tempo, inicio, dur, { t0 = 0.3, ate = 0.62 } = {}) {
|
|
44
|
+
const palavras = (tempo && tempo.palavras) || [];
|
|
45
|
+
const fim = Math.max(t0 + 0.2, dur * ate);
|
|
46
|
+
if (!palavras.length) return textos.map((_, i) => r2(t0 + (fim - t0) * (i / Math.max(1, textos.length))));
|
|
47
|
+
const abs = quando(textos, palavras, inicio + fim, inicio + t0);
|
|
48
|
+
// Nunca antes do corte, nunca tarde demais para ser lido, sempre em ordem.
|
|
49
|
+
let antes = 0;
|
|
50
|
+
return abs.map((t) => {
|
|
51
|
+
const rel = Math.min(Math.max(0.08, t - inicio), Math.max(0.1, dur - 0.9));
|
|
52
|
+
antes = Math.max(antes + 0.1, rel);
|
|
53
|
+
return r2(antes);
|
|
54
|
+
});
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** As palavras faladas numa parte, no tempo da cena: [{texto, t}]. */
|
|
58
|
+
function palavrasNaCena(tempo, inicio) {
|
|
59
|
+
return ((tempo && tempo.palavras) || []).map((w) => ({ texto: w.texto, t: r2(Math.max(0.05, w.de - inicio)) }));
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** O volume da trilha no tempo: cheia no respiro, baixa quando a voz fala, some no fim. */
|
|
63
|
+
function volumesDaTrilha(tempos, total, { alto = 0.4, baixo = 0.2 } = {}) {
|
|
64
|
+
const k = (t, v) => ({ t: r2(t), v });
|
|
65
|
+
const vs = [k(0, 0.05), k(0.4, alto)];
|
|
66
|
+
// Um quadro-chave de cada vez, sempre depois do anterior: fala colada na outra não volta no tempo.
|
|
67
|
+
const poe = (t, v) => vs.push(k(Math.max(vs[vs.length - 1].t + 0.01, t), v));
|
|
68
|
+
for (const tp of tempos || []) {
|
|
69
|
+
if (!tp || !(tp.ate > tp.de)) continue;
|
|
70
|
+
poe(tp.de - 0.15, alto); poe(tp.de, baixo); poe(tp.ate, baixo); poe(tp.ate + 0.35, alto);
|
|
71
|
+
}
|
|
72
|
+
poe(total - 0.6, alto * 0.9);
|
|
73
|
+
poe(total, 0);
|
|
74
|
+
return vs;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
module.exports = { tempoDasPartes, gatilhos, palavrasNaCena, volumesDaTrilha, r2 };
|
|
@@ -32,6 +32,14 @@ const EFEITOS = {
|
|
|
32
32
|
subida: ['-f', 'lavfi', '-i', "aevalsrc='0.28*sin(2*PI*(180+260*t*t)*t)*(t/1.4)':s=44100:d=1.4", '-af', 'afade=t=out:st=1.2:d=0.2'],
|
|
33
33
|
// Toque grave e macio: o que chega e assenta.
|
|
34
34
|
toque: ['-f', 'lavfi', '-i', "aevalsrc='0.7*sin(2*PI*(110-50*t)*t)*exp(-7*t)':s=44100:d=0.6", '-af', 'lowpass=f=900'],
|
|
35
|
+
/* Os CHEIOS do motion gerativo ("motion é vida: efeito sonoro NA ação",
|
|
36
|
+
30/09): o whoosh da passagem, o impacto da tese, o riser antes da
|
|
37
|
+
revelação e o pop da letra que nasce. Ainda sintetizados, ainda limpos. */
|
|
38
|
+
whoosh: ['-f', 'lavfi', '-i', 'anoisesrc=d=0.75:c=pink:a=0.9', '-af',
|
|
39
|
+
'bandpass=f=1100:width_type=o:w=2.2,afade=t=in:st=0:d=0.42:curve=exp,afade=t=out:st=0.42:d=0.33:curve=qsin,volume=2.2'],
|
|
40
|
+
impacto: ['-f', 'lavfi', '-i', "aevalsrc='0.95*sin(2*PI*(62-26*t)*t)*exp(-4.5*t)+0.45*(random(0)*2-1)*exp(-38*t)':s=44100:d=1.1", '-af', 'lowpass=f=2600,volume=1.4'],
|
|
41
|
+
riser: ['-f', 'lavfi', '-i', "aevalsrc='(0.32*sin(2*PI*(170+620*t*t)*t)+0.22*(random(0)*2-1))*pow(t/1.1,2)':s=44100:d=1.1", '-af', 'highpass=f=260,afade=t=out:st=1.04:d=0.06,volume=1.3'],
|
|
42
|
+
pop: ['-f', 'lavfi', '-i', "aevalsrc='0.8*sin(2*PI*(980-640*t)*t)*exp(-28*t)*(1-exp(-700*t))':s=44100:d=0.18"],
|
|
35
43
|
};
|
|
36
44
|
|
|
37
45
|
/**
|
|
@@ -59,4 +67,33 @@ function preparar(pasta, { rodar = null } = {}) {
|
|
|
59
67
|
return sons;
|
|
60
68
|
}
|
|
61
69
|
|
|
62
|
-
|
|
70
|
+
/**
|
|
71
|
+
* A família de sons de UM vídeo (gerativo.js sonsDoVideo): cada receita vira
|
|
72
|
+
* midia/sfx-g<semente>-<nome>.mp3. O que já existe não é refeito.
|
|
73
|
+
*/
|
|
74
|
+
function prepararReceitas(pasta, { receitas = {}, semente = 0 } = {}, { rodar = null } = {}) {
|
|
75
|
+
const exec = rodar || ((args) => {
|
|
76
|
+
try { require('child_process').execFileSync('ffmpeg', args, { stdio: 'ignore', timeout: 30000 }); return true; }
|
|
77
|
+
catch { return false; }
|
|
78
|
+
});
|
|
79
|
+
const sons = {};
|
|
80
|
+
const sufixo = (semente >>> 0).toString(36);
|
|
81
|
+
for (const [nome, args] of Object.entries(receitas)) {
|
|
82
|
+
const rel = `midia/sfx-g${sufixo}-${nome}.mp3`;
|
|
83
|
+
const cheio = path.join(pasta, rel);
|
|
84
|
+
if (!fs.existsSync(cheio)) {
|
|
85
|
+
fs.mkdirSync(path.dirname(cheio), { recursive: true });
|
|
86
|
+
// O limitador no fim: o golpe sintetizado passava de 0 dB e estalava.
|
|
87
|
+
// "{pasta}" é a pasta do projeto: o som feito da própria trilha lê o arquivo dela.
|
|
88
|
+
const a = args.map((x) => String(x).split('{pasta}').join(pasta));
|
|
89
|
+
const fc = a.indexOf('-filter_complex'), i = a.indexOf('-af');
|
|
90
|
+
if (fc >= 0) a[fc + 1] += ',alimiter=limit=0.8:level=false';
|
|
91
|
+
else if (i >= 0) a[i + 1] += ',alimiter=limit=0.8:level=false'; else a.push('-af', 'alimiter=limit=0.8:level=false');
|
|
92
|
+
if (!exec(['-y', '-loglevel', 'error', ...a, '-q:a', '3', cheio])) continue;
|
|
93
|
+
}
|
|
94
|
+
sons[nome] = rel;
|
|
95
|
+
}
|
|
96
|
+
return sons;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
module.exports = { EFEITOS, preparar, prepararReceitas };
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* variedade.js — a trilha e os efeitos dos vídeos de corte (híbrido, cinema,
|
|
3
|
+
* impacto) sem repetir de um vídeo para o outro.
|
|
4
|
+
*
|
|
5
|
+
* "todos os vídeos é sempre as mesmas músicas, os mesmos efeitos sonoros,
|
|
6
|
+
* sempre o mesmo padrão de vídeo" (Joel, 02/10)
|
|
7
|
+
*
|
|
8
|
+
* A causa era a mesma nos dois: um termo fixo na busca ("inspiring
|
|
9
|
+
* corporate", "cinematic whoosh") e o PRIMEIRO resultado baixado. A busca
|
|
10
|
+
* devolve a mesma lista na mesma ordem, então todo vídeo saía com a mesma
|
|
11
|
+
* faixa e o mesmo whoosh. Aqui:
|
|
12
|
+
* 1. o termo sai de um cardápio pelo clima do roteiro, em ordem sorteada;
|
|
13
|
+
* 2. dos resultados, sorteia — e pula o que os últimos vídeos já usaram;
|
|
14
|
+
* 3. o que foi usado entra na memória (~/.primocode/studio/<tipo>-usadas.json).
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
'use strict';
|
|
18
|
+
|
|
19
|
+
const fs = require('fs');
|
|
20
|
+
const os = require('os');
|
|
21
|
+
const path = require('path');
|
|
22
|
+
|
|
23
|
+
const PASTA = path.join(os.homedir(), '.primocode', 'studio');
|
|
24
|
+
const MEMORIA = { trilha: 16, sfx: 40 };
|
|
25
|
+
|
|
26
|
+
function arquivo(tipo) { return path.join(PASTA, `${tipo}-usadas.json`); }
|
|
27
|
+
|
|
28
|
+
function usadas(tipo) {
|
|
29
|
+
try { const l = JSON.parse(fs.readFileSync(arquivo(tipo), 'utf8')); return Array.isArray(l) ? l : []; } catch { return []; }
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function marcar(tipo, id) {
|
|
33
|
+
if (!id) return;
|
|
34
|
+
try {
|
|
35
|
+
fs.mkdirSync(PASTA, { recursive: true });
|
|
36
|
+
const l = [id, ...usadas(tipo).filter((x) => x !== id)].slice(0, MEMORIA[tipo] || 20);
|
|
37
|
+
fs.writeFileSync(arquivo(tipo), JSON.stringify(l));
|
|
38
|
+
} catch { /* sem memória: só perde a garantia de não repetir */ }
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function embaralhar(lista, aleatorio = Math.random) {
|
|
42
|
+
const a = [...lista];
|
|
43
|
+
for (let i = a.length - 1; i > 0; i--) {
|
|
44
|
+
const j = Math.floor(aleatorio() * (i + 1));
|
|
45
|
+
[a[i], a[j]] = [a[j], a[i]];
|
|
46
|
+
}
|
|
47
|
+
return a;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** Os itens da busca na ordem de tentar: os nunca usados primeiro (sorteados), os usados no fim. */
|
|
51
|
+
function ordenar(itens, tipo, aleatorio = Math.random) {
|
|
52
|
+
const ja = new Set(usadas(tipo));
|
|
53
|
+
const validos = (itens || []).filter((x) => x && x.url);
|
|
54
|
+
return [...embaralhar(validos.filter((x) => !ja.has(x.url)), aleatorio), ...validos.filter((x) => ja.has(x.url))];
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// ── a trilha: termos por clima ──────────────────────────────────────────
|
|
58
|
+
const CLIMAS = {
|
|
59
|
+
tecnologia: ['electronic instrumental', 'synthwave instrumental', 'future bass instrumental', 'downtempo electronic', 'minimal techno instrumental', 'chillwave instrumental'],
|
|
60
|
+
energetico: ['funk instrumental', 'rock instrumental', 'drum and bass instrumental', 'breakbeat instrumental', 'upbeat indie instrumental', 'house instrumental'],
|
|
61
|
+
epico: ['epic orchestral', 'trailer instrumental', 'cinematic drums', 'epic hybrid instrumental', 'dramatic strings'],
|
|
62
|
+
calmo: ['lofi hip hop instrumental', 'ambient instrumental', 'acoustic guitar instrumental', 'piano instrumental', 'jazz instrumental'],
|
|
63
|
+
alegre: ['ukulele instrumental', 'indie pop instrumental', 'happy acoustic instrumental', 'latin instrumental', 'disco instrumental'],
|
|
64
|
+
emocional: ['piano emotional instrumental', 'post rock instrumental', 'cinematic strings', 'neo classical instrumental'],
|
|
65
|
+
misterio: ['dark ambient instrumental', 'trip hop instrumental', 'suspense instrumental', 'noir jazz instrumental'],
|
|
66
|
+
neutro: ['indie instrumental', 'soul instrumental', 'jazz hop instrumental', 'world instrumental', 'electronic instrumental', 'acoustic instrumental'],
|
|
67
|
+
};
|
|
68
|
+
const SINONIMOS = {
|
|
69
|
+
tecnologia: /tec|tech|ia\b|ai\b|software|app|digital|futur/, energetico: /energ|agit|rapid|forte|esport|academ/,
|
|
70
|
+
epico: /epic|epico|grandi|trailer|impact/, calmo: /calm|tranq|suave|relax|chill/, alegre: /aleg|feliz|divert|leve/,
|
|
71
|
+
emocional: /emoc|emoti|inspir|sentim|historia/, misterio: /mist|sombr|dark|suspen|tens/,
|
|
72
|
+
};
|
|
73
|
+
|
|
74
|
+
function climaDe(roteiro = {}, estilo = '') {
|
|
75
|
+
const t = String([roteiro.clima, roteiro.genero, roteiro.tom].filter(Boolean).join(' ')).toLowerCase()
|
|
76
|
+
.normalize('NFD').replace(/[̀-ͯ]/g, '');
|
|
77
|
+
for (const [clima, re] of Object.entries(SINONIMOS)) if (re.test(t)) return clima;
|
|
78
|
+
return estilo === 'impacto' ? 'energetico' : 'neutro';
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Os termos para buscar a trilha, em ordem de tentar. O pedido explícito
|
|
83
|
+
* (roteiro.musica) vem primeiro; depois o clima, sorteado; e por último um
|
|
84
|
+
* clima vizinho, para nunca sair sem música.
|
|
85
|
+
*/
|
|
86
|
+
function termosTrilha(roteiro = {}, estilo = '', aleatorio = Math.random) {
|
|
87
|
+
const clima = climaDe(roteiro, estilo);
|
|
88
|
+
const reserva = clima === 'neutro' ? 'calmo' : 'neutro';
|
|
89
|
+
return [...new Set([roteiro.musica, ...embaralhar(CLIMAS[clima], aleatorio).slice(0, 3),
|
|
90
|
+
...embaralhar(CLIMAS[reserva], aleatorio).slice(0, 2)].filter(Boolean))];
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// ── os efeitos: várias formas de pedir o mesmo som ───────────────────────
|
|
94
|
+
const SFX = {
|
|
95
|
+
teclado: ['keyboard typing', 'typing keyboard', 'mechanical keyboard', 'laptop typing', 'typewriter keys'],
|
|
96
|
+
whoosh: ['cinematic whoosh', 'swoosh transition', 'air whoosh', 'fast swipe', 'woosh pass by', 'riser whoosh'],
|
|
97
|
+
impacto: ['cinematic boom impact', 'deep cinematic impact', 'trailer hit', 'bass drop hit', 'punch hit', 'sub boom'],
|
|
98
|
+
sala: ['crowd murmur indoor', 'office ambience', 'people talking background', 'cafe ambience', 'room tone people'],
|
|
99
|
+
};
|
|
100
|
+
|
|
101
|
+
function termosSfx(nome, aleatorio = Math.random) {
|
|
102
|
+
return embaralhar(SFX[nome] || [nome], aleatorio).slice(0, 3);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
module.exports = { usadas, marcar, embaralhar, ordenar, climaDe, termosTrilha, termosSfx, CLIMAS, SFX };
|