primocode 8.42.2 → 9.1.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 (104) 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 +42 -195
  7. package/lib/catalogo.js +100 -61
  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 +576 -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 +644 -0
  20. package/lib/estudio/slides.js +190 -0
  21. package/lib/estudio/video.js +296 -0
  22. package/lib/fala.js +49 -6
  23. package/lib/marca.js +4 -1
  24. package/lib/markdown.js +376 -0
  25. package/lib/marketplace.js +189 -0
  26. package/lib/pasta.js +15 -4
  27. package/lib/permissao.js +137 -0
  28. package/lib/registro.js +229 -0
  29. package/lib/skills.js +329 -0
  30. package/lib/tools.js +106 -187
  31. package/lib/ui.js +7 -3
  32. package/lib/voz.js +239 -0
  33. package/package.json +12 -3
  34. package/skills/acessibilidade/SKILL.md +28 -0
  35. package/skills/api-que-nao-quebra/SKILL.md +27 -0
  36. package/skills/depurar-de-verdade/SKILL.md +29 -0
  37. package/skills/editar-meu-video/SKILL.md +59 -0
  38. package/skills/escrever-em-portugues/SKILL.md +28 -0
  39. package/skills/git-limpo/SKILL.md +28 -0
  40. package/skills/motion-de-primeira/SKILL.md +62 -0
  41. package/skills/pagina-que-converte/SKILL.md +28 -0
  42. package/skills/performance-web/SKILL.md +28 -0
  43. package/skills/revisao-antes-de-entregar/SKILL.md +27 -0
  44. package/skills/slides-que-prendem/SKILL.md +40 -0
  45. package/studio/edge.py +16 -66
  46. package/studio/narrar.py +274 -0
  47. package/studio/pessoa.py +199 -0
  48. package/voz/primo_voz/__init__.py +0 -0
  49. package/voz/primo_voz/__main__.py +383 -0
  50. package/voz/primo_voz/escuta.py +235 -0
  51. package/voz/primo_voz/orbe.py +216 -0
  52. package/lib/audio-livre.js +0 -177
  53. package/lib/chaves.js +0 -149
  54. package/lib/claude-engine.js +0 -631
  55. package/lib/codex-engine.js +0 -208
  56. package/lib/remotion.js +0 -663
  57. package/lib/studio.js +0 -960
  58. package/studio/PRIMOCODE.md +0 -58
  59. package/studio/README.md +0 -154
  60. package/studio/__pycache__/edge.cpython-311.pyc +0 -0
  61. package/studio/__pycache__/elevenlabs.cpython-311.pyc +0 -0
  62. package/studio/__pycache__/voz.cpython-311.pyc +0 -0
  63. package/studio/arquivos/corte-teste-de-camera-fb823.json +0 -57
  64. package/studio/ferramentas/00-protocolo.md +0 -125
  65. package/studio/ferramentas/01-modelos.md +0 -270
  66. package/studio/ferramentas/README.md +0 -32
  67. package/studio/ferramentas/corte.md +0 -919
  68. package/studio/ferramentas/grade.md +0 -92
  69. package/studio/ferramentas/prisma.md +0 -107
  70. package/studio/ferramentas/prosa.md +0 -86
  71. package/studio/ferramentas/tela.md +0 -93
  72. package/studio/ferramentas/traco.md +0 -79
  73. package/studio/server.py +0 -2254
  74. package/studio/studio.log +0 -57
  75. package/studio/voz.py +0 -388
  76. package/studio/web/analise.html +0 -52
  77. package/studio/web/core.css +0 -1167
  78. package/studio/web/fontes/bebas.woff2 +0 -0
  79. package/studio/web/fontes/grotesk-700.woff2 +0 -0
  80. package/studio/web/fontes/inter-400.woff2 +0 -0
  81. package/studio/web/fontes/inter-700.woff2 +0 -0
  82. package/studio/web/fontes/jetbrains-400.woff2 +0 -0
  83. package/studio/web/fontes/playfair-700.woff2 +0 -0
  84. package/studio/web/fontes.css +0 -23
  85. package/studio/web/home.html +0 -217
  86. package/studio/web/js/analise.js +0 -510
  87. package/studio/web/js/audio.js +0 -607
  88. package/studio/web/js/editor.js +0 -1802
  89. package/studio/web/js/exportar.js +0 -222
  90. package/studio/web/js/ferramentas.js +0 -108
  91. package/studio/web/js/icones.js +0 -120
  92. package/studio/web/js/midia.js +0 -157
  93. package/studio/web/js/narracao.js +0 -325
  94. package/studio/web/js/player.js +0 -903
  95. package/studio/web/js/render-grade.js +0 -243
  96. package/studio/web/js/render-prosa.js +0 -104
  97. package/studio/web/js/render.js +0 -1852
  98. package/studio/web/js/sfx.js +0 -233
  99. package/studio/web/js/video.js +0 -464
  100. package/studio/web/js/voz-neural.js +0 -152
  101. package/studio/web/render.css +0 -1352
  102. package/studio/web/tf.html +0 -15
  103. package/studio/web/tool.html +0 -78
  104. package/studio/web/view.html +0 -27
@@ -0,0 +1,644 @@
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
+ /**
574
+ * Cita um argumento para a linha de comando do cmd.exe.
575
+ *
576
+ * Só serve ao Windows, e só porque o `shell: true` é obrigatório ali (o Node
577
+ * recusa spawnar npm.cmd/npx.cmd sem ele desde a correção do CVE-2024-27980).
578
+ * Quem não tem espaço nem metacaractere passa intacto: citar tudo deixaria a
579
+ * linha ilegível no log sem ganhar nada.
580
+ */
581
+ function citarWin(a) {
582
+ const t = String(a);
583
+ if (!/[\s&|<>^"()]/.test(t)) return t;
584
+ return '"' + t.replace(/"/g, '""') + '"';
585
+ }
586
+
587
+ function renderizar({ raiz, saida, minutos = 30, aoFalar = () => {} }) {
588
+ return new Promise((resolve) => {
589
+ const temNodeModules = fs.existsSync(path.join(raiz, 'node_modules'));
590
+ const passos = temNodeModules ? [] : [['npm', ['install', '--no-audit', '--no-fund']]];
591
+ passos.push(['npx', ['remotion', 'render', 'Video', saida, '--log=warn']]);
592
+
593
+ const correr = (i) => {
594
+ if (i >= passos.length) return resolve({ ok: true, arquivo: saida });
595
+ const [cmd, args] = passos[i];
596
+ aoFalar(i === 0 && !temNodeModules
597
+ ? 'instalando o Remotion nesta pasta (uma vez só, alguns minutos)'
598
+ : 'renderizando quadro a quadro');
599
+ /* NO WINDOWS O `shell: true` NÃO CITA NADA.
600
+ Ele monta a linha do cmd.exe concatenando os argumentos crus, e
601
+ o `saida` é um caminho de arquivo que quase sempre tem espaço
602
+ (C:\Users\Joel Silva\primocode\...\saida\video.mp4). Sem
603
+ aspas, aquilo vira dois argumentos e o render falha — ou, pior,
604
+ escreve num lugar que ninguém pediu.
605
+ O `shell: true` não dá para tirar: desde a correção do
606
+ CVE-2024-27980 o Node recusa spawnar npm.cmd/npx.cmd sem shell.
607
+ Então as aspas entram na mão, só onde o shell existe. */
608
+ const citados = process.platform === 'win32' ? args.map(citarWin) : args;
609
+ const p = spawn(cmd, citados, { cwd: raiz, shell: process.platform === 'win32' });
610
+ let erro = '';
611
+ p.stderr.on('data', (d) => { erro += d.toString().slice(0, 2000); });
612
+ p.stdout.on('data', (d) => {
613
+ const linha = d.toString().trim().split('\n').pop();
614
+ if (/\d+%|Rendered|Encoding/i.test(linha)) aoFalar(linha.slice(0, 80));
615
+ });
616
+ const relogio = setTimeout(() => { try { p.kill(); } catch {} }, minutos * 60000);
617
+ p.on('close', (code) => {
618
+ clearTimeout(relogio);
619
+ if (code !== 0) {
620
+ return resolve({ ok: false, error: `${cmd} saiu com ${code}: ${erro.slice(-600).trim()}` });
621
+ }
622
+ correr(i + 1);
623
+ });
624
+ p.on('error', (e) => {
625
+ clearTimeout(relogio);
626
+ /* "spawn npx ENOENT" não diz nada a quem instalou pelo ícone.
627
+ Aplicativo aberto pela interface gráfica não herda o PATH do
628
+ terminal: o node vem por caminho absoluto (lib/instalar-app.js),
629
+ mas o npm e o npx não estão em lugar nenhum que ele enxergue. */
630
+ if (e.code === 'ENOENT') {
631
+ return resolve({ ok: false,
632
+ error: `não achei o "${cmd}" nesta máquina — o Remotion precisa dele para renderizar.`,
633
+ comoResolver: 'instale o Node.js completo em nodejs.org (o npm vem junto). '
634
+ + 'Se ele já está instalado e isto apareceu com o PrimoCode aberto pelo ícone, '
635
+ + 'abra pelo terminal: o aplicativo não herda o PATH.' });
636
+ }
637
+ resolve({ ok: false, error: e.message });
638
+ });
639
+ };
640
+ correr(0);
641
+ });
642
+ }
643
+
644
+ module.exports = { montar, renderizar, fontesCSS, VERSAO_REMOTION, CENA_JSX };