primocode 8.42.2 → 9.0.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 +41 -37
- package/bin/primocode.js +598 -360
- package/lib/acervo.js +8 -1
- package/lib/act.js +205 -114
- package/lib/api.js +35 -4
- package/lib/boas-vindas.js +31 -192
- package/lib/catalogo.js +60 -60
- package/lib/config.js +1 -1
- package/lib/conta.js +3 -24
- package/lib/diff.js +161 -0
- package/lib/diretor.js +37 -47
- package/lib/entrada.js +545 -0
- package/lib/especialistas.js +2 -2
- package/lib/estudio/assets.js +343 -0
- package/lib/estudio/identidade.js +560 -0
- package/lib/estudio/index.js +565 -0
- package/lib/estudio/plano.js +327 -0
- package/lib/estudio/projeto.js +246 -0
- package/lib/estudio/remotion.js +606 -0
- package/lib/estudio/slides.js +190 -0
- package/lib/estudio/video.js +296 -0
- package/lib/fala.js +13 -1
- package/lib/marca.js +4 -1
- package/lib/marketplace.js +189 -0
- package/lib/permissao.js +137 -0
- package/lib/registro.js +229 -0
- package/lib/skills.js +329 -0
- package/lib/tools.js +51 -181
- package/lib/ui.js +7 -3
- package/lib/voz.js +239 -0
- package/package.json +12 -3
- package/skills/acessibilidade/SKILL.md +28 -0
- package/skills/api-que-nao-quebra/SKILL.md +27 -0
- package/skills/depurar-de-verdade/SKILL.md +29 -0
- package/skills/editar-meu-video/SKILL.md +59 -0
- package/skills/escrever-em-portugues/SKILL.md +28 -0
- package/skills/git-limpo/SKILL.md +28 -0
- package/skills/motion-de-primeira/SKILL.md +62 -0
- package/skills/pagina-que-converte/SKILL.md +28 -0
- package/skills/performance-web/SKILL.md +28 -0
- package/skills/revisao-antes-de-entregar/SKILL.md +27 -0
- package/skills/slides-que-prendem/SKILL.md +40 -0
- package/studio/edge.py +16 -66
- package/studio/narrar.py +274 -0
- package/studio/pessoa.py +167 -0
- package/voz/primo_voz/__init__.py +0 -0
- package/voz/primo_voz/__main__.py +325 -0
- package/voz/primo_voz/escuta.py +235 -0
- package/voz/primo_voz/orbe.py +216 -0
- package/lib/audio-livre.js +0 -177
- package/lib/chaves.js +0 -149
- package/lib/claude-engine.js +0 -631
- package/lib/codex-engine.js +0 -208
- package/lib/remotion.js +0 -663
- package/lib/studio.js +0 -960
- package/studio/PRIMOCODE.md +0 -58
- package/studio/README.md +0 -154
- package/studio/__pycache__/edge.cpython-311.pyc +0 -0
- package/studio/__pycache__/elevenlabs.cpython-311.pyc +0 -0
- package/studio/__pycache__/voz.cpython-311.pyc +0 -0
- package/studio/arquivos/corte-teste-de-camera-fb823.json +0 -57
- package/studio/ferramentas/00-protocolo.md +0 -125
- package/studio/ferramentas/01-modelos.md +0 -270
- package/studio/ferramentas/README.md +0 -32
- package/studio/ferramentas/corte.md +0 -919
- package/studio/ferramentas/grade.md +0 -92
- package/studio/ferramentas/prisma.md +0 -107
- package/studio/ferramentas/prosa.md +0 -86
- package/studio/ferramentas/tela.md +0 -93
- package/studio/ferramentas/traco.md +0 -79
- package/studio/server.py +0 -2254
- package/studio/studio.log +0 -57
- package/studio/voz.py +0 -388
- package/studio/web/analise.html +0 -52
- package/studio/web/core.css +0 -1167
- package/studio/web/fontes/bebas.woff2 +0 -0
- package/studio/web/fontes/grotesk-700.woff2 +0 -0
- package/studio/web/fontes/inter-400.woff2 +0 -0
- package/studio/web/fontes/inter-700.woff2 +0 -0
- package/studio/web/fontes/jetbrains-400.woff2 +0 -0
- package/studio/web/fontes/playfair-700.woff2 +0 -0
- package/studio/web/fontes.css +0 -23
- package/studio/web/home.html +0 -217
- package/studio/web/js/analise.js +0 -510
- package/studio/web/js/audio.js +0 -607
- package/studio/web/js/editor.js +0 -1802
- package/studio/web/js/exportar.js +0 -222
- package/studio/web/js/ferramentas.js +0 -108
- package/studio/web/js/icones.js +0 -120
- package/studio/web/js/midia.js +0 -157
- package/studio/web/js/narracao.js +0 -325
- package/studio/web/js/player.js +0 -903
- package/studio/web/js/render-grade.js +0 -243
- package/studio/web/js/render-prosa.js +0 -104
- package/studio/web/js/render.js +0 -1852
- package/studio/web/js/sfx.js +0 -233
- package/studio/web/js/video.js +0 -464
- package/studio/web/js/voz-neural.js +0 -152
- package/studio/web/render.css +0 -1352
- package/studio/web/tf.html +0 -15
- package/studio/web/tool.html +0 -78
- package/studio/web/view.html +0 -27
|
@@ -0,0 +1,606 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* remotion.js — o plano vira um projeto Remotion de verdade.
|
|
3
|
+
*
|
|
4
|
+
* "Toda a renderização e composição deve ser integrada ao Remotion."
|
|
5
|
+
*
|
|
6
|
+
* ── O QUE ISTO SUBSTITUI ─────────────────────────────────────────────────
|
|
7
|
+
* O Studio antigo desenhava as cenas num navegador, com um `render.js` de
|
|
8
|
+
* 1.642 linhas e um `render.css` de 1.239, e "exportava" gravando a tela
|
|
9
|
+
* enquanto a animação tocava. Três consequências vividas:
|
|
10
|
+
*
|
|
11
|
+
* · o vídeo saía diferente em cada máquina, porque dependia da carga do
|
|
12
|
+
* computador e de a aba estar em foco;
|
|
13
|
+
* · vídeo longo engasgava no meio da gravação;
|
|
14
|
+
* · e o que dava para fazer era o que dava para fazer em CSS.
|
|
15
|
+
*
|
|
16
|
+
* Remotion é quadro a quadro, determinístico: o mesmo plano dá o mesmo
|
|
17
|
+
* arquivo em qualquer máquina, e o que dá para fazer é o que dá para fazer em
|
|
18
|
+
* React — 3D, máscara, canal alfa, ProRes, render paralelo.
|
|
19
|
+
*
|
|
20
|
+
* ── POR QUE O PROJETO NASCE NA PASTA DO USUÁRIO ──────────────────────────
|
|
21
|
+
* Remotion é React + um Chromium próprio + ffmpeg: perto de meio giga. Pôr
|
|
22
|
+
* isso no `dependencies` do PrimoCode faria o `npm i -g primocode` baixar meio
|
|
23
|
+
* giga para todo mundo, inclusive para quem nunca vai fazer um vídeo.
|
|
24
|
+
*
|
|
25
|
+
* E não precisa: Remotion é um projeto, não uma biblioteca de linha de
|
|
26
|
+
* comando. Então cada vídeo é um projeto de verdade, na pasta dele, que abre
|
|
27
|
+
* no `remotion studio`, entra no git e pode ser editado à mão depois. O
|
|
28
|
+
* PrimoCode continua com zero dependência.
|
|
29
|
+
*
|
|
30
|
+
* ── O INTERPRETADOR NÃO É UM TEMPLATE ────────────────────────────────────
|
|
31
|
+
* O `Cena.jsx` que este arquivo escreve sabe desenhar texto, forma, imagem,
|
|
32
|
+
* vídeo, ícone e legenda, e sabe animar qualquer propriedade por
|
|
33
|
+
* quadro-chave. Ele não sabe o que é uma "capa", uma "abertura" ou um "slide
|
|
34
|
+
* de números" — não existe nenhuma aparência embutida nele.
|
|
35
|
+
*
|
|
36
|
+
* É a diferença entre tinta e quadro pintado. E quando nem a tinta alcança
|
|
37
|
+
* (uma malha 3D, um morfismo), a cena aponta um `componente`: um arquivo
|
|
38
|
+
* React escrito pela própria IA, montado como qualquer outra cena.
|
|
39
|
+
*
|
|
40
|
+
* ── LICENÇA ──────────────────────────────────────────────────────────────
|
|
41
|
+
* Remotion é grátis para pessoa física e empresa pequena; empresa acima de
|
|
42
|
+
* três pessoas precisa de licença paga. Isso vale para o projeto que nasce na
|
|
43
|
+
* pasta do usuário, e o aviso vai no README gerado — descobrir isso depois de
|
|
44
|
+
* montar o fluxo de trabalho em cima dele seria péssimo.
|
|
45
|
+
*/
|
|
46
|
+
|
|
47
|
+
'use strict';
|
|
48
|
+
|
|
49
|
+
const fs = require('fs');
|
|
50
|
+
const path = require('path');
|
|
51
|
+
const { spawn } = require('child_process');
|
|
52
|
+
|
|
53
|
+
const VERSAO_REMOTION = '4.0.522';
|
|
54
|
+
|
|
55
|
+
function escrever(destino, conteudo) {
|
|
56
|
+
fs.mkdirSync(path.dirname(destino), { recursive: true });
|
|
57
|
+
fs.writeFileSync(destino, conteudo, 'utf-8');
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// ── os arquivos do projeto ───────────────────────────────────────────────
|
|
61
|
+
|
|
62
|
+
function pkgJson(nome) {
|
|
63
|
+
return JSON.stringify({
|
|
64
|
+
name: nome.replace(/[^a-z0-9-]/gi, '-').toLowerCase() || 'video',
|
|
65
|
+
version: '1.0.0',
|
|
66
|
+
private: true,
|
|
67
|
+
scripts: {
|
|
68
|
+
studio: 'remotion studio',
|
|
69
|
+
render: 'remotion render Video saida/video.mp4',
|
|
70
|
+
},
|
|
71
|
+
dependencies: {
|
|
72
|
+
'@remotion/cli': VERSAO_REMOTION,
|
|
73
|
+
'@remotion/media-utils': VERSAO_REMOTION,
|
|
74
|
+
remotion: VERSAO_REMOTION,
|
|
75
|
+
react: '19.0.0',
|
|
76
|
+
'react-dom': '19.0.0',
|
|
77
|
+
},
|
|
78
|
+
}, null, 2) + '\n';
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
const REMOTION_CONFIG = `import { Config } from '@remotion/cli/config';
|
|
82
|
+
|
|
83
|
+
// Qualidade antes de tamanho: o arquivo sai maior e sem a sujeira de
|
|
84
|
+
// compressão que aparece em fundo de cor chapada e em texto fino — que é
|
|
85
|
+
// justamente o que mais existe num vídeo de motion.
|
|
86
|
+
Config.setVideoImageFormat('jpeg');
|
|
87
|
+
Config.setJpegQuality(95);
|
|
88
|
+
Config.setCodec('h264');
|
|
89
|
+
Config.setCrf(16);
|
|
90
|
+
Config.setChromiumOpenGlRenderer('angle');
|
|
91
|
+
`;
|
|
92
|
+
|
|
93
|
+
/* O interpretador. Ele é escrito como texto e copiado para o projeto porque é
|
|
94
|
+
lá que ele roda: o Remotion monta a pasta do usuário, não a nossa. */
|
|
95
|
+
const CENA_JSX = String.raw`import React from 'react';
|
|
96
|
+
import {
|
|
97
|
+
AbsoluteFill, Img, Video, Audio, Sequence, useCurrentFrame,
|
|
98
|
+
useVideoConfig, interpolate, staticFile, continueRender, delayRender,
|
|
99
|
+
} from 'remotion';
|
|
100
|
+
|
|
101
|
+
/* ── SUAVIZAÇÃO ──────────────────────────────────────────────────────────
|
|
102
|
+
* Cada curva é uma função de 0..1 em 0..1. "suave" é o padrão porque
|
|
103
|
+
* movimento linear é o que mais denuncia animação feita por máquina: nada no
|
|
104
|
+
* mundo físico começa e para na mesma velocidade. */
|
|
105
|
+
const CURVAS = {
|
|
106
|
+
linear: (t) => t,
|
|
107
|
+
suave: (t) => (t < 0.5 ? 2 * t * t : 1 - Math.pow(-2 * t + 2, 2) / 2),
|
|
108
|
+
entrada: (t) => t * t * t,
|
|
109
|
+
saida: (t) => 1 - Math.pow(1 - t, 3),
|
|
110
|
+
elastico: (t) => (t === 0 || t === 1 ? t
|
|
111
|
+
: Math.pow(2, -10 * t) * Math.sin((t * 10 - 0.75) * ((2 * Math.PI) / 3)) + 1),
|
|
112
|
+
salto: (t) => {
|
|
113
|
+
const n = 7.5625, d = 2.75;
|
|
114
|
+
if (t < 1 / d) return n * t * t;
|
|
115
|
+
if (t < 2 / d) return n * (t -= 1.5 / d) * t + 0.75;
|
|
116
|
+
if (t < 2.5 / d) return n * (t -= 2.25 / d) * t + 0.9375;
|
|
117
|
+
return n * (t -= 2.625 / d) * t + 0.984375;
|
|
118
|
+
},
|
|
119
|
+
};
|
|
120
|
+
|
|
121
|
+
/* O valor de uma propriedade animada no segundo "t".
|
|
122
|
+
*
|
|
123
|
+
* Antes do primeiro quadro-chave vale o primeiro; depois do último vale o
|
|
124
|
+
* último. Sem isso, um elemento com animação que começa em 0.5s aparece
|
|
125
|
+
* indefinido no primeiro meio segundo — e some da tela sem motivo visível. */
|
|
126
|
+
function valorEm(trilha, t, padrao) {
|
|
127
|
+
if (!Array.isArray(trilha) || trilha.length === 0) return padrao;
|
|
128
|
+
if (t <= trilha[0].t) return trilha[0].v;
|
|
129
|
+
const ultimo = trilha[trilha.length - 1];
|
|
130
|
+
if (t >= ultimo.t) return ultimo.v;
|
|
131
|
+
for (let i = 0; i < trilha.length - 1; i++) {
|
|
132
|
+
const a = trilha[i], b = trilha[i + 1];
|
|
133
|
+
if (t >= a.t && t <= b.t) {
|
|
134
|
+
const bruto = b.t === a.t ? 1 : (t - a.t) / (b.t - a.t);
|
|
135
|
+
const curva = CURVAS[b.e || a.e || 'suave'] || CURVAS.suave;
|
|
136
|
+
return a.v + (b.v - a.v) * curva(bruto);
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
return padrao;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
function animado(anima, t) {
|
|
143
|
+
const v = {};
|
|
144
|
+
for (const [prop, trilha] of Object.entries(anima || {})) v[prop] = valorEm(trilha, t, undefined);
|
|
145
|
+
return v;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
const medida = (v, padrao) => (v == null ? padrao : (typeof v === 'number' ? v + 'px' : String(v)));
|
|
149
|
+
|
|
150
|
+
function transformDe(a, base) {
|
|
151
|
+
const partes = [];
|
|
152
|
+
const x = a.x ?? base.x, y = a.y ?? base.y, z = a.z;
|
|
153
|
+
if (x != null || y != null || z != null) {
|
|
154
|
+
partes.push('translate3d(' + (x || 0) + 'px,' + (y || 0) + 'px,' + (z || 0) + 'px)');
|
|
155
|
+
}
|
|
156
|
+
if (a.escala != null) partes.push('scale(' + a.escala + ')');
|
|
157
|
+
if (a.escalaX != null) partes.push('scaleX(' + a.escalaX + ')');
|
|
158
|
+
if (a.escalaY != null) partes.push('scaleY(' + a.escalaY + ')');
|
|
159
|
+
if (a.rotacao != null) partes.push('rotate(' + a.rotacao + 'deg)');
|
|
160
|
+
if (a.rotacaoX != null) partes.push('rotateX(' + a.rotacaoX + 'deg)');
|
|
161
|
+
if (a.rotacaoY != null) partes.push('rotateY(' + a.rotacaoY + 'deg)');
|
|
162
|
+
return partes.join(' ');
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
function filtroDe(a) {
|
|
166
|
+
const f = [];
|
|
167
|
+
if (a.desfoque) f.push('blur(' + a.desfoque + 'px)');
|
|
168
|
+
if (a.brilho != null) f.push('brightness(' + a.brilho + ')');
|
|
169
|
+
if (a.saturacao != null) f.push('saturate(' + a.saturacao + ')');
|
|
170
|
+
return f.join(' ');
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
function fundoCSS(fundo) {
|
|
174
|
+
if (!fundo) return {};
|
|
175
|
+
if (typeof fundo === 'string') return { backgroundColor: fundo };
|
|
176
|
+
if (fundo.tipo === 'cor') return { backgroundColor: fundo.valor };
|
|
177
|
+
if (fundo.tipo === 'gradiente') {
|
|
178
|
+
const ang = fundo.angulo == null ? 180 : fundo.angulo;
|
|
179
|
+
const tipo = fundo.radial ? 'radial-gradient(circle at ' + (fundo.centro || '50% 50%') + ', '
|
|
180
|
+
: 'linear-gradient(' + ang + 'deg, ';
|
|
181
|
+
return { backgroundImage: tipo + fundo.cores.join(', ') + ')' };
|
|
182
|
+
}
|
|
183
|
+
return {};
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/* Posição: "x"/"y" são o CANTO por padrão, e o centro quando a âncora pede.
|
|
187
|
+
* A âncora existe porque centralizar à mão exige saber a largura do texto, e
|
|
188
|
+
* a largura do texto só se sabe depois de ele ser desenhado. */
|
|
189
|
+
function posicao(el) {
|
|
190
|
+
const base = { position: 'absolute', left: medida(el.x, '0px'), top: medida(el.y, '0px') };
|
|
191
|
+
if (el.largura != null) base.width = medida(el.largura);
|
|
192
|
+
if (el.altura != null) base.height = medida(el.altura);
|
|
193
|
+
const a = el.ancora || 'canto';
|
|
194
|
+
if (a === 'centro') base.transform = 'translate(-50%, -50%)';
|
|
195
|
+
else if (a === 'centro-x') base.transform = 'translateX(-50%)';
|
|
196
|
+
else if (a === 'centro-y') base.transform = 'translateY(-50%)';
|
|
197
|
+
return base;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
function Elemento({ el, t, dados }) {
|
|
201
|
+
// "de"/"ate" recortam o elemento no tempo da cena.
|
|
202
|
+
if (el.de != null && t < el.de) return null;
|
|
203
|
+
if (el.ate != null && t > el.ate) return null;
|
|
204
|
+
|
|
205
|
+
const tLocal = t - (el.de || 0);
|
|
206
|
+
const a = animado(el.anima, tLocal);
|
|
207
|
+
const base = posicao(el);
|
|
208
|
+
const move = transformDe(a, {});
|
|
209
|
+
const estilo = {
|
|
210
|
+
...base,
|
|
211
|
+
opacity: a.opacidade ?? el.opacidade ?? 1,
|
|
212
|
+
transform: [base.transform, move].filter(Boolean).join(' '),
|
|
213
|
+
filter: filtroDe(a) || undefined,
|
|
214
|
+
mixBlendMode: el.mistura || undefined,
|
|
215
|
+
};
|
|
216
|
+
|
|
217
|
+
if (el.vidro) {
|
|
218
|
+
// Vidro fosco: desfoque do que está ATRÁS, com um véu por cima. Os dois
|
|
219
|
+
// juntos — só o desfoque some sobre fundo chapado, só o véu vira retângulo.
|
|
220
|
+
estilo.backdropFilter = 'blur(' + (el.vidro.desfoque ?? 18) + 'px) saturate(' + (el.vidro.saturacao ?? 1.4) + ')';
|
|
221
|
+
estilo.backgroundColor = el.vidro.cor || 'rgba(255,255,255,0.12)';
|
|
222
|
+
estilo.border = el.vidro.borda || '1px solid rgba(255,255,255,0.22)';
|
|
223
|
+
}
|
|
224
|
+
if (el.raio != null) estilo.borderRadius = medida(a.raio ?? el.raio);
|
|
225
|
+
if (el.sombra) estilo.boxShadow = el.sombra;
|
|
226
|
+
if (el.mascara) {
|
|
227
|
+
estilo.WebkitMaskImage = el.mascara;
|
|
228
|
+
estilo.maskImage = el.mascara;
|
|
229
|
+
estilo.WebkitMaskSize = 'contain';
|
|
230
|
+
estilo.maskSize = 'contain';
|
|
231
|
+
estilo.WebkitMaskRepeat = 'no-repeat';
|
|
232
|
+
estilo.maskRepeat = 'no-repeat';
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
switch (el.tipo) {
|
|
236
|
+
case 'texto':
|
|
237
|
+
return (
|
|
238
|
+
<div style={{
|
|
239
|
+
...estilo,
|
|
240
|
+
fontFamily: el.fonte ? '"' + el.fonte + '", system-ui, sans-serif' : 'system-ui, sans-serif',
|
|
241
|
+
fontSize: medida(el.tamanho, '64px'),
|
|
242
|
+
fontWeight: el.peso || 700,
|
|
243
|
+
color: el.cor || '#fff',
|
|
244
|
+
lineHeight: el.entrelinha || 1.05,
|
|
245
|
+
letterSpacing: (a.espacamento ?? el.espacamento ?? 0) + 'px',
|
|
246
|
+
textAlign: el.alinhamento || 'left',
|
|
247
|
+
textTransform: el.caixa || undefined,
|
|
248
|
+
whiteSpace: 'pre-wrap',
|
|
249
|
+
background: el.gradienteTexto || undefined,
|
|
250
|
+
WebkitBackgroundClip: el.gradienteTexto ? 'text' : undefined,
|
|
251
|
+
WebkitTextFillColor: el.gradienteTexto ? 'transparent' : undefined,
|
|
252
|
+
}}>{el.texto}</div>
|
|
253
|
+
);
|
|
254
|
+
|
|
255
|
+
case 'forma': {
|
|
256
|
+
const forma = el.forma || 'retangulo';
|
|
257
|
+
return (
|
|
258
|
+
<div style={{
|
|
259
|
+
...estilo,
|
|
260
|
+
width: medida(el.largura, '200px'),
|
|
261
|
+
height: forma === 'linha' ? medida(el.espessura, '4px') : medida(el.altura, '200px'),
|
|
262
|
+
background: el.fundo || el.cor || '#fff',
|
|
263
|
+
borderRadius: forma === 'circulo' ? '50%' : medida(a.raio ?? el.raio, '0px'),
|
|
264
|
+
border: el.contorno || undefined,
|
|
265
|
+
}} />
|
|
266
|
+
);
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
case 'imagem':
|
|
270
|
+
return (
|
|
271
|
+
<div style={{ ...estilo, overflow: 'hidden' }}>
|
|
272
|
+
<Img src={staticFile(el.src)} style={{
|
|
273
|
+
width: '100%', height: '100%',
|
|
274
|
+
objectFit: el.ajuste === 'preencher' ? 'fill' : (el.ajuste || 'cover'),
|
|
275
|
+
objectPosition: el.foco || 'center',
|
|
276
|
+
}} />
|
|
277
|
+
</div>
|
|
278
|
+
);
|
|
279
|
+
|
|
280
|
+
case 'video':
|
|
281
|
+
return (
|
|
282
|
+
<div style={{ ...estilo, overflow: 'hidden' }}>
|
|
283
|
+
<Video
|
|
284
|
+
src={staticFile(el.src)}
|
|
285
|
+
startFrom={el.deOrigem ? Math.round(el.deOrigem * dados.fps) : undefined}
|
|
286
|
+
endAt={el.ateOrigem ? Math.round(el.ateOrigem * dados.fps) : undefined}
|
|
287
|
+
volume={el.volume ?? 0}
|
|
288
|
+
style={{ width: '100%', height: '100%', objectFit: el.ajuste || 'cover' }}
|
|
289
|
+
/>
|
|
290
|
+
</div>
|
|
291
|
+
);
|
|
292
|
+
|
|
293
|
+
case 'icone':
|
|
294
|
+
// O SVG foi baixado para a pasta do projeto (estudio/assets.js): render
|
|
295
|
+
// que busca ícone na internet falha no meio, depois de tudo pronto.
|
|
296
|
+
return (
|
|
297
|
+
<div style={{ ...estilo, width: medida(el.tamanho, '96px'), height: medida(el.tamanho, '96px'), color: el.cor || '#fff' }}
|
|
298
|
+
dangerouslySetInnerHTML={{ __html: dados.icones[el.nome] || '' }} />
|
|
299
|
+
);
|
|
300
|
+
|
|
301
|
+
case 'legenda': {
|
|
302
|
+
const fala = (el.fala || []).find((f) => t >= f.de && t <= f.ate);
|
|
303
|
+
if (!fala) return null;
|
|
304
|
+
const e = el.estilo || {};
|
|
305
|
+
return (
|
|
306
|
+
<div style={{
|
|
307
|
+
...estilo,
|
|
308
|
+
fontFamily: e.fonte ? '"' + e.fonte + '", system-ui, sans-serif' : 'system-ui, sans-serif',
|
|
309
|
+
fontSize: medida(e.tamanho, '52px'),
|
|
310
|
+
fontWeight: e.peso || 800,
|
|
311
|
+
color: e.cor || '#fff',
|
|
312
|
+
textAlign: 'center',
|
|
313
|
+
textShadow: e.sombra || '0 4px 18px rgba(0,0,0,.55)',
|
|
314
|
+
WebkitTextStroke: e.contorno || undefined,
|
|
315
|
+
background: e.fundo || undefined,
|
|
316
|
+
padding: e.fundo ? '10px 22px' : undefined,
|
|
317
|
+
borderRadius: e.fundo ? medida(e.raio, '14px') : undefined,
|
|
318
|
+
textTransform: e.caixa || undefined,
|
|
319
|
+
}}>{fala.texto}</div>
|
|
320
|
+
);
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
default:
|
|
324
|
+
return null;
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
export function Cena({ cena, dados }) {
|
|
329
|
+
const frame = useCurrentFrame();
|
|
330
|
+
const { fps } = useVideoConfig();
|
|
331
|
+
const t = frame / fps;
|
|
332
|
+
|
|
333
|
+
const cam = animado(cena.camera, t);
|
|
334
|
+
const camTransform = [
|
|
335
|
+
cam.x != null || cam.y != null ? 'translate(' + (-(cam.x || 0)) + 'px,' + (-(cam.y || 0)) + 'px)' : '',
|
|
336
|
+
cam.zoom != null ? 'scale(' + cam.zoom + ')' : '',
|
|
337
|
+
cam.rotacao != null ? 'rotate(' + cam.rotacao + 'deg)' : '',
|
|
338
|
+
].filter(Boolean).join(' ');
|
|
339
|
+
|
|
340
|
+
return (
|
|
341
|
+
<AbsoluteFill style={{ ...fundoCSS(cena.fundo), overflow: 'hidden' }}>
|
|
342
|
+
{cena.fundo && cena.fundo.tipo === 'imagem' && (
|
|
343
|
+
<Img src={staticFile(cena.fundo.src)} style={{
|
|
344
|
+
position: 'absolute', inset: 0, width: '100%', height: '100%',
|
|
345
|
+
objectFit: cena.fundo.ajuste || 'cover',
|
|
346
|
+
}} />
|
|
347
|
+
)}
|
|
348
|
+
{cena.fundo && cena.fundo.tipo === 'video' && (
|
|
349
|
+
<Video src={staticFile(cena.fundo.src)} volume={0} style={{
|
|
350
|
+
position: 'absolute', inset: 0, width: '100%', height: '100%',
|
|
351
|
+
objectFit: cena.fundo.ajuste || 'cover',
|
|
352
|
+
}} />
|
|
353
|
+
)}
|
|
354
|
+
<AbsoluteFill style={{
|
|
355
|
+
transform: camTransform || undefined,
|
|
356
|
+
transformOrigin: cena.cameraOrigem || 'center center',
|
|
357
|
+
// A perspectiva é o que transforma rotateX/rotateY em 3D de verdade;
|
|
358
|
+
// sem ela, girar em X só achata o elemento.
|
|
359
|
+
perspective: cena.perspectiva ? cena.perspectiva + 'px' : undefined,
|
|
360
|
+
transformStyle: cena.perspectiva ? 'preserve-3d' : undefined,
|
|
361
|
+
}}>
|
|
362
|
+
{(cena.elementos || []).map((el, i) => (
|
|
363
|
+
<Elemento key={i} el={el} t={t} dados={dados} />
|
|
364
|
+
))}
|
|
365
|
+
</AbsoluteFill>
|
|
366
|
+
</AbsoluteFill>
|
|
367
|
+
);
|
|
368
|
+
}
|
|
369
|
+
";`;
|
|
370
|
+
|
|
371
|
+
const RAIZ_JSX = String.raw`import React from 'react';
|
|
372
|
+
import { AbsoluteFill, Audio, Sequence, staticFile, useCurrentFrame, useVideoConfig, interpolate } from 'remotion';
|
|
373
|
+
import { Cena } from './Cena';
|
|
374
|
+
import plano from './plano.json';
|
|
375
|
+
import icones from './icones.json';
|
|
376
|
+
|
|
377
|
+
/* A transição vive em quem SAI e em quem ENTRA ao mesmo tempo: as duas cenas
|
|
378
|
+
* ficam no ar durante o cruzamento. Fazer só a que entra aparecer por cima dá
|
|
379
|
+
* o "piscar" de fundo que denuncia corte mal feito. */
|
|
380
|
+
function Transicao({ tipo, progresso, children }) {
|
|
381
|
+
const p = progresso;
|
|
382
|
+
let estilo = {};
|
|
383
|
+
if (tipo === 'esmaecer') estilo = { opacity: p };
|
|
384
|
+
else if (tipo === 'deslizar') estilo = { transform: 'translateX(' + (1 - p) * 100 + '%)' };
|
|
385
|
+
else if (tipo === 'zoom') estilo = { transform: 'scale(' + (0.86 + 0.14 * p) + ')', opacity: p };
|
|
386
|
+
else if (tipo === 'giro') estilo = { transform: 'rotateY(' + (1 - p) * 35 + 'deg) scale(' + (0.9 + 0.1 * p) + ')', opacity: p };
|
|
387
|
+
else if (tipo === 'mascara') estilo = { clipPath: 'circle(' + (p * 75) + '% at 50% 50%)' };
|
|
388
|
+
else if (tipo === 'morph') estilo = { opacity: p, filter: 'blur(' + (1 - p) * 14 + 'px)', transform: 'scale(' + (1.06 - 0.06 * p) + ')' };
|
|
389
|
+
return <AbsoluteFill style={estilo}>{children}</AbsoluteFill>;
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
export const Video = () => {
|
|
393
|
+
const { fps } = useVideoConfig();
|
|
394
|
+
const frame = useCurrentFrame();
|
|
395
|
+
const dados = { fps, icones };
|
|
396
|
+
|
|
397
|
+
let inicio = 0;
|
|
398
|
+
const pedacos = plano.cenas.map((cena, i) => {
|
|
399
|
+
const dur = Math.max(1, Math.round(cena.duracao * fps));
|
|
400
|
+
const t = cena.transicao || {};
|
|
401
|
+
const tipo = t.tipo || t || 'corte';
|
|
402
|
+
const cruz = tipo === 'corte' ? 0 : Math.round((t.duracao ?? 0.4) * fps);
|
|
403
|
+
const de = inicio;
|
|
404
|
+
inicio += dur - cruz;
|
|
405
|
+
return { cena, de, dur, tipo, cruz, i };
|
|
406
|
+
});
|
|
407
|
+
|
|
408
|
+
return (
|
|
409
|
+
<AbsoluteFill style={{ backgroundColor: plano.fundoGeral || '#000' }}>
|
|
410
|
+
{pedacos.map(({ cena, de, dur, tipo, cruz, i }) => {
|
|
411
|
+
const local = frame - de;
|
|
412
|
+
const progresso = cruz > 0 && local < cruz && i > 0
|
|
413
|
+
? interpolate(local, [0, cruz], [0, 1], { extrapolateLeft: 'clamp', extrapolateRight: 'clamp' })
|
|
414
|
+
: 1;
|
|
415
|
+
return (
|
|
416
|
+
<Sequence key={i} from={de} durationInFrames={dur} name={'cena ' + (i + 1)}>
|
|
417
|
+
<Transicao tipo={i === 0 ? 'corte' : tipo} progresso={progresso}>
|
|
418
|
+
<Cena cena={cena} dados={dados} />
|
|
419
|
+
</Transicao>
|
|
420
|
+
</Sequence>
|
|
421
|
+
);
|
|
422
|
+
})}
|
|
423
|
+
|
|
424
|
+
{plano.audio && plano.audio.narracao && (
|
|
425
|
+
<Audio src={staticFile(plano.audio.narracao)} volume={plano.audio.volumeNarracao ?? 1} />
|
|
426
|
+
)}
|
|
427
|
+
{plano.audio && plano.audio.trilha && (
|
|
428
|
+
<Audio
|
|
429
|
+
src={staticFile(plano.audio.trilha.src || plano.audio.trilha)}
|
|
430
|
+
volume={plano.audio.trilha.volume ?? 0.18}
|
|
431
|
+
/>
|
|
432
|
+
)}
|
|
433
|
+
{(plano.audio && plano.audio.efeitos ? plano.audio.efeitos : []).map((e, i) => (
|
|
434
|
+
<Sequence key={'sfx' + i} from={Math.round((e.t || 0) * fps)}>
|
|
435
|
+
<Audio src={staticFile(e.src)} volume={e.volume ?? 0.7} />
|
|
436
|
+
</Sequence>
|
|
437
|
+
))}
|
|
438
|
+
</AbsoluteFill>
|
|
439
|
+
);
|
|
440
|
+
};
|
|
441
|
+
";`;
|
|
442
|
+
|
|
443
|
+
function rootJsx(plano) {
|
|
444
|
+
const f = plano.formato || {};
|
|
445
|
+
return `import React from 'react';
|
|
446
|
+
import { Composition } from 'remotion';
|
|
447
|
+
import { Video } from './Video';
|
|
448
|
+
import plano from './plano.json';
|
|
449
|
+
import './fontes.css';
|
|
450
|
+
|
|
451
|
+
const fps = ${Number(f.fps) || 30};
|
|
452
|
+
const quadros = Math.max(1, Math.round(
|
|
453
|
+
plano.cenas.reduce((n, c) => n + c.duracao, 0) * fps
|
|
454
|
+
- plano.cenas.slice(1).reduce((n, c) => n + Math.round(((c.transicao && c.transicao.duracao) || 0) * fps), 0)
|
|
455
|
+
));
|
|
456
|
+
|
|
457
|
+
export const RemotionRoot = () => (
|
|
458
|
+
<Composition
|
|
459
|
+
id="Video"
|
|
460
|
+
component={Video}
|
|
461
|
+
durationInFrames={quadros}
|
|
462
|
+
fps={fps}
|
|
463
|
+
width={${Number(f.largura) || 1920}}
|
|
464
|
+
height={${Number(f.altura) || 1080}}
|
|
465
|
+
/>
|
|
466
|
+
);
|
|
467
|
+
`;
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
const INDEX_JS = `import { registerRoot } from 'remotion';
|
|
471
|
+
import { RemotionRoot } from './Root';
|
|
472
|
+
registerRoot(RemotionRoot);
|
|
473
|
+
`;
|
|
474
|
+
|
|
475
|
+
function readme(titulo, plano) {
|
|
476
|
+
const f = plano.formato || {};
|
|
477
|
+
return `# ${titulo}
|
|
478
|
+
|
|
479
|
+
Projeto Remotion gerado pelo PrimoCode Studio.
|
|
480
|
+
|
|
481
|
+
- ${(plano.cenas || []).length} cenas · ${f.largura || 1920}×${f.altura || 1080} · ${f.fps || 30}fps
|
|
482
|
+
|
|
483
|
+
## Rodar
|
|
484
|
+
|
|
485
|
+
\`\`\`bash
|
|
486
|
+
npm install # uma vez, alguns minutos
|
|
487
|
+
npm run studio # abre para ver e mexer
|
|
488
|
+
npm run render # gera saida/video.mp4
|
|
489
|
+
\`\`\`
|
|
490
|
+
|
|
491
|
+
## Como ele é montado
|
|
492
|
+
|
|
493
|
+
\`plano.json\` descreve as cenas; \`src/Cena.jsx\` as interpreta. Não há
|
|
494
|
+
template nem tema: cada propriedade do vídeo está no plano.
|
|
495
|
+
|
|
496
|
+
Cena que nenhuma primitiva alcança usa \`{"tipo":"componente","src":"..."}\`,
|
|
497
|
+
apontando um arquivo React desta mesma pasta.
|
|
498
|
+
|
|
499
|
+
## Licença do Remotion
|
|
500
|
+
|
|
501
|
+
Remotion é gratuito para uso individual e empresa pequena. **Empresa com mais
|
|
502
|
+
de três pessoas precisa de licença paga** (remotion.dev/license). Isso vale
|
|
503
|
+
para este projeto, não para o PrimoCode.
|
|
504
|
+
`;
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
/**
|
|
508
|
+
* Escreve o projeto Remotion dentro da pasta do projeto do Studio.
|
|
509
|
+
*
|
|
510
|
+
* @param {object} op
|
|
511
|
+
* @param {string} op.pasta a pasta do projeto (~/.primocode/studio/projetos/<id>)
|
|
512
|
+
* @param {string} op.titulo
|
|
513
|
+
* @param {object} op.plano
|
|
514
|
+
* @param {object} op.icones { "lucide:zap": "<svg…>" } já baixados
|
|
515
|
+
*/
|
|
516
|
+
function montar({ pasta, titulo, plano, icones = {} }) {
|
|
517
|
+
const raiz = path.join(pasta, 'remotion');
|
|
518
|
+
const src = path.join(raiz, 'src');
|
|
519
|
+
|
|
520
|
+
escrever(path.join(raiz, 'package.json'), pkgJson(titulo || 'video'));
|
|
521
|
+
escrever(path.join(raiz, 'remotion.config.ts'), REMOTION_CONFIG);
|
|
522
|
+
escrever(path.join(raiz, 'README.md'), readme(titulo || 'Vídeo', plano));
|
|
523
|
+
escrever(path.join(src, 'index.js'), INDEX_JS);
|
|
524
|
+
escrever(path.join(src, 'Root.jsx'), rootJsx(plano));
|
|
525
|
+
escrever(path.join(src, 'Video.jsx'), RAIZ_JSX);
|
|
526
|
+
escrever(path.join(src, 'Cena.jsx'), CENA_JSX);
|
|
527
|
+
escrever(path.join(src, 'plano.json'), JSON.stringify(plano, null, 2));
|
|
528
|
+
escrever(path.join(src, 'icones.json'), JSON.stringify(icones, null, 2));
|
|
529
|
+
escrever(path.join(src, 'fontes.css'), fontesCSS(plano));
|
|
530
|
+
|
|
531
|
+
/* O Remotion serve `public/` pelo `staticFile()`. As mídias moram em
|
|
532
|
+
`midia/` e `entrada/` na pasta do projeto, então um link simbólico
|
|
533
|
+
evita duplicar arquivo de vídeo de centenas de megabytes. Onde o
|
|
534
|
+
sistema não deixa criar link (Windows sem permissão), copia. */
|
|
535
|
+
const publico = path.join(raiz, 'public');
|
|
536
|
+
fs.mkdirSync(publico, { recursive: true });
|
|
537
|
+
for (const sub of ['midia', 'entrada']) {
|
|
538
|
+
const de = path.join(pasta, sub);
|
|
539
|
+
const para = path.join(publico, sub);
|
|
540
|
+
if (!fs.existsSync(de)) continue;
|
|
541
|
+
if (fs.existsSync(para)) continue;
|
|
542
|
+
try { fs.symlinkSync(de, para, 'junction'); }
|
|
543
|
+
catch { try { fs.cpSync(de, para, { recursive: true }); } catch { /* segue */ } }
|
|
544
|
+
}
|
|
545
|
+
|
|
546
|
+
return { ok: true, raiz, publico };
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
/* As fontes vêm do Google Fonts, declaradas por @import. O projeto gerado é
|
|
550
|
+
do usuário e roda com rede disponível no `npm install`; o Chromium do
|
|
551
|
+
Remotion baixa a fonte no primeiro quadro e o Remotion espera por ela. */
|
|
552
|
+
function fontesCSS(plano) {
|
|
553
|
+
const usadas = new Set();
|
|
554
|
+
for (const cena of plano.cenas || []) {
|
|
555
|
+
for (const el of cena.elementos || []) {
|
|
556
|
+
if (el.fonte) usadas.add(el.fonte);
|
|
557
|
+
if (el.estilo && el.estilo.fonte) usadas.add(el.estilo.fonte);
|
|
558
|
+
}
|
|
559
|
+
}
|
|
560
|
+
if (!usadas.size) return '/* nenhuma fonte externa neste vídeo */\n';
|
|
561
|
+
const familias = [...usadas]
|
|
562
|
+
.map((f) => 'family=' + encodeURIComponent(f).replace(/%20/g, '+') + ':wght@300;400;500;600;700;800;900')
|
|
563
|
+
.join('&');
|
|
564
|
+
return `@import url('https://fonts.googleapis.com/css2?${familias}&display=block');\n`;
|
|
565
|
+
}
|
|
566
|
+
|
|
567
|
+
/**
|
|
568
|
+
* Renderiza. Devolve o caminho do arquivo.
|
|
569
|
+
*
|
|
570
|
+
* `npm install` roda uma vez por projeto e demora minutos — é o preço do
|
|
571
|
+
* Remotion, e está dito no README. Daí em diante, só o render.
|
|
572
|
+
*/
|
|
573
|
+
function renderizar({ raiz, saida, minutos = 30, aoFalar = () => {} }) {
|
|
574
|
+
return new Promise((resolve) => {
|
|
575
|
+
const temNodeModules = fs.existsSync(path.join(raiz, 'node_modules'));
|
|
576
|
+
const passos = temNodeModules ? [] : [['npm', ['install', '--no-audit', '--no-fund']]];
|
|
577
|
+
passos.push(['npx', ['remotion', 'render', 'Video', saida, '--log=warn']]);
|
|
578
|
+
|
|
579
|
+
const correr = (i) => {
|
|
580
|
+
if (i >= passos.length) return resolve({ ok: true, arquivo: saida });
|
|
581
|
+
const [cmd, args] = passos[i];
|
|
582
|
+
aoFalar(i === 0 && !temNodeModules
|
|
583
|
+
? 'instalando o Remotion nesta pasta (uma vez só, alguns minutos)'
|
|
584
|
+
: 'renderizando quadro a quadro');
|
|
585
|
+
const p = spawn(cmd, args, { cwd: raiz, shell: process.platform === 'win32' });
|
|
586
|
+
let erro = '';
|
|
587
|
+
p.stderr.on('data', (d) => { erro += d.toString().slice(0, 2000); });
|
|
588
|
+
p.stdout.on('data', (d) => {
|
|
589
|
+
const linha = d.toString().trim().split('\n').pop();
|
|
590
|
+
if (/\d+%|Rendered|Encoding/i.test(linha)) aoFalar(linha.slice(0, 80));
|
|
591
|
+
});
|
|
592
|
+
const relogio = setTimeout(() => { try { p.kill(); } catch {} }, minutos * 60000);
|
|
593
|
+
p.on('close', (code) => {
|
|
594
|
+
clearTimeout(relogio);
|
|
595
|
+
if (code !== 0) {
|
|
596
|
+
return resolve({ ok: false, error: `${cmd} saiu com ${code}: ${erro.slice(-600).trim()}` });
|
|
597
|
+
}
|
|
598
|
+
correr(i + 1);
|
|
599
|
+
});
|
|
600
|
+
p.on('error', (e) => { clearTimeout(relogio); resolve({ ok: false, error: e.message }); });
|
|
601
|
+
};
|
|
602
|
+
correr(0);
|
|
603
|
+
});
|
|
604
|
+
}
|
|
605
|
+
|
|
606
|
+
module.exports = { montar, renderizar, fontesCSS, VERSAO_REMOTION, CENA_JSX };
|