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.
Files changed (102) hide show
  1. package/README.md +41 -37
  2. package/bin/primocode.js +598 -360
  3. package/lib/acervo.js +8 -1
  4. package/lib/act.js +205 -114
  5. package/lib/api.js +35 -4
  6. package/lib/boas-vindas.js +31 -192
  7. package/lib/catalogo.js +60 -60
  8. package/lib/config.js +1 -1
  9. package/lib/conta.js +3 -24
  10. package/lib/diff.js +161 -0
  11. package/lib/diretor.js +37 -47
  12. package/lib/entrada.js +545 -0
  13. package/lib/especialistas.js +2 -2
  14. package/lib/estudio/assets.js +343 -0
  15. package/lib/estudio/identidade.js +560 -0
  16. package/lib/estudio/index.js +565 -0
  17. package/lib/estudio/plano.js +327 -0
  18. package/lib/estudio/projeto.js +246 -0
  19. package/lib/estudio/remotion.js +606 -0
  20. package/lib/estudio/slides.js +190 -0
  21. package/lib/estudio/video.js +296 -0
  22. package/lib/fala.js +13 -1
  23. package/lib/marca.js +4 -1
  24. package/lib/marketplace.js +189 -0
  25. package/lib/permissao.js +137 -0
  26. package/lib/registro.js +229 -0
  27. package/lib/skills.js +329 -0
  28. package/lib/tools.js +51 -181
  29. package/lib/ui.js +7 -3
  30. package/lib/voz.js +239 -0
  31. package/package.json +12 -3
  32. package/skills/acessibilidade/SKILL.md +28 -0
  33. package/skills/api-que-nao-quebra/SKILL.md +27 -0
  34. package/skills/depurar-de-verdade/SKILL.md +29 -0
  35. package/skills/editar-meu-video/SKILL.md +59 -0
  36. package/skills/escrever-em-portugues/SKILL.md +28 -0
  37. package/skills/git-limpo/SKILL.md +28 -0
  38. package/skills/motion-de-primeira/SKILL.md +62 -0
  39. package/skills/pagina-que-converte/SKILL.md +28 -0
  40. package/skills/performance-web/SKILL.md +28 -0
  41. package/skills/revisao-antes-de-entregar/SKILL.md +27 -0
  42. package/skills/slides-que-prendem/SKILL.md +40 -0
  43. package/studio/edge.py +16 -66
  44. package/studio/narrar.py +274 -0
  45. package/studio/pessoa.py +167 -0
  46. package/voz/primo_voz/__init__.py +0 -0
  47. package/voz/primo_voz/__main__.py +325 -0
  48. package/voz/primo_voz/escuta.py +235 -0
  49. package/voz/primo_voz/orbe.py +216 -0
  50. package/lib/audio-livre.js +0 -177
  51. package/lib/chaves.js +0 -149
  52. package/lib/claude-engine.js +0 -631
  53. package/lib/codex-engine.js +0 -208
  54. package/lib/remotion.js +0 -663
  55. package/lib/studio.js +0 -960
  56. package/studio/PRIMOCODE.md +0 -58
  57. package/studio/README.md +0 -154
  58. package/studio/__pycache__/edge.cpython-311.pyc +0 -0
  59. package/studio/__pycache__/elevenlabs.cpython-311.pyc +0 -0
  60. package/studio/__pycache__/voz.cpython-311.pyc +0 -0
  61. package/studio/arquivos/corte-teste-de-camera-fb823.json +0 -57
  62. package/studio/ferramentas/00-protocolo.md +0 -125
  63. package/studio/ferramentas/01-modelos.md +0 -270
  64. package/studio/ferramentas/README.md +0 -32
  65. package/studio/ferramentas/corte.md +0 -919
  66. package/studio/ferramentas/grade.md +0 -92
  67. package/studio/ferramentas/prisma.md +0 -107
  68. package/studio/ferramentas/prosa.md +0 -86
  69. package/studio/ferramentas/tela.md +0 -93
  70. package/studio/ferramentas/traco.md +0 -79
  71. package/studio/server.py +0 -2254
  72. package/studio/studio.log +0 -57
  73. package/studio/voz.py +0 -388
  74. package/studio/web/analise.html +0 -52
  75. package/studio/web/core.css +0 -1167
  76. package/studio/web/fontes/bebas.woff2 +0 -0
  77. package/studio/web/fontes/grotesk-700.woff2 +0 -0
  78. package/studio/web/fontes/inter-400.woff2 +0 -0
  79. package/studio/web/fontes/inter-700.woff2 +0 -0
  80. package/studio/web/fontes/jetbrains-400.woff2 +0 -0
  81. package/studio/web/fontes/playfair-700.woff2 +0 -0
  82. package/studio/web/fontes.css +0 -23
  83. package/studio/web/home.html +0 -217
  84. package/studio/web/js/analise.js +0 -510
  85. package/studio/web/js/audio.js +0 -607
  86. package/studio/web/js/editor.js +0 -1802
  87. package/studio/web/js/exportar.js +0 -222
  88. package/studio/web/js/ferramentas.js +0 -108
  89. package/studio/web/js/icones.js +0 -120
  90. package/studio/web/js/midia.js +0 -157
  91. package/studio/web/js/narracao.js +0 -325
  92. package/studio/web/js/player.js +0 -903
  93. package/studio/web/js/render-grade.js +0 -243
  94. package/studio/web/js/render-prosa.js +0 -104
  95. package/studio/web/js/render.js +0 -1852
  96. package/studio/web/js/sfx.js +0 -233
  97. package/studio/web/js/video.js +0 -464
  98. package/studio/web/js/voz-neural.js +0 -152
  99. package/studio/web/render.css +0 -1352
  100. package/studio/web/tf.html +0 -15
  101. package/studio/web/tool.html +0 -78
  102. 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 };