primocode 8.26.0 → 8.30.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.
@@ -0,0 +1,463 @@
1
+ /**
2
+ * remotion.js — a mesma peça, renderizada por Remotion.
3
+ *
4
+ * "Também ele vai poder usar o Remotion. Aí ele decide qual usar, ou usa o
5
+ * que o usuário pedir."
6
+ *
7
+ * ── POR QUE ISTO NÃO É UMA DEPENDÊNCIA DO PRIMOCODE ──────────────────────
8
+ * Remotion é React + um Chromium próprio + ffmpeg: perto de meio giga. Pôr
9
+ * isso no `dependencies` do PrimoCode faria `npm i -g primocode` baixar meio
10
+ * giga para todo mundo — inclusive para quem nunca vai fazer um vídeo.
11
+ *
12
+ * E não precisa: Remotion é um projeto, não uma biblioteca de linha de
13
+ * comando. O jeito certo de usá-lo é ter um projeto Remotion, e é isso que
14
+ * este módulo escreve — na pasta do USUÁRIO, com as dependências dele. O
15
+ * PrimoCode continua com zero dependência, e quem quiser Remotion tem um
16
+ * projeto de verdade, que abre no `remotion studio`, entra no git e pode ser
17
+ * editado à mão depois.
18
+ *
19
+ * ── POR QUE ELE PARTE DO DOCUMENTO DO ESTÚDIO ────────────────────────────
20
+ * A tentação era inventar um segundo vocabulário — "para o Remotion escreva
21
+ * assim". Seriam duas gramáticas para o agente aprender, duas para manter, e
22
+ * uma ficaria velha.
23
+ *
24
+ * O projeto gerado leva junto o `render.js` e o `render.css` do estúdio, que
25
+ * é quem sabe desenhar uma cena. A composição React só decide QUAL cena está
26
+ * no ar em cada quadro e aplica entrada e transição a partir de
27
+ * `useCurrentFrame()`. O mesmo `doc` que toca no navegador renderiza aqui.
28
+ *
29
+ * ── QUANDO VALE A PENA ───────────────────────────────────────────────────
30
+ * O estúdio exporta em segundos, sem instalar nada, e é o padrão. Remotion
31
+ * pede `npm install` (uma vez, minutos) e devolve outra coisa:
32
+ *
33
+ * · quadro a quadro determinístico — o mesmo arquivo em toda máquina, sem
34
+ * depender de a aba estar em foco nem da carga do computador;
35
+ * · vídeo longo sem risco de o navegador engasgar no meio;
36
+ * · fps e qualidade escolhidos, ProRes, canal alfa, renderização paralela;
37
+ * · e um projeto que a pessoa pode abrir e mexer em React.
38
+ *
39
+ * ── LICENÇA ──────────────────────────────────────────────────────────────
40
+ * Remotion é grátis para pessoa física e para empresa pequena, mas EMPRESA
41
+ * ACIMA DE TRÊS PESSOAS precisa de licença paga. Isso vale para o projeto que
42
+ * nasce na pasta do usuário, não para o PrimoCode — e por isso o aviso vai no
43
+ * README gerado e na resposta da tool. Descobrir isso depois de montar o
44
+ * 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 ORIGEM_ESTUDIO = path.join(__dirname, '..', 'studio', 'web');
54
+ const VERSAO_REMOTION = '4.0.522';
55
+
56
+ // Arquivos do estúdio que o projeto Remotion precisa para desenhar uma cena.
57
+ // São os mesmos que o navegador carrega — nada é reescrito.
58
+ const DO_ESTUDIO = [
59
+ ['js/render.js', 'render.js'],
60
+ ['js/icones.js', 'icones.js'],
61
+ ['render.css', 'render.css'],
62
+ ['fontes.css', 'fontes.css'],
63
+ ];
64
+
65
+ function escrever(destino, conteudo) {
66
+ fs.mkdirSync(path.dirname(destino), { recursive: true });
67
+ fs.writeFileSync(destino, conteudo, 'utf-8');
68
+ }
69
+
70
+ /* Quantos quadros dura cada cena, com a MESMA conta do player.
71
+ *
72
+ * Duplicar esta conta aqui foi tentador e seria errado: se ela divergisse do
73
+ * player, o vídeo exportado por Remotion teria duração diferente do que a
74
+ * pessoa aprovou na tela — e ninguém desconfiaria, porque os dois estariam
75
+ * "funcionando". Então a regra é uma só e está escrita em um lugar; o
76
+ * projeto gerado importa daqui. */
77
+ function segundosDaCena(c) {
78
+ let d = Number(c && c.duracao);
79
+ let clipe = 0;
80
+ for (const e of (c && c.elementos) || []) {
81
+ if (!e || e.tipo !== 'clipe') continue;
82
+ const de = Number(e.de) || 0;
83
+ const ate = Number(e.ate);
84
+ if (isFinite(ate) && ate > de) clipe = Math.max(clipe, ate - de);
85
+ }
86
+ if (!(isFinite(d) && d > 0)) d = clipe > 0 ? Math.min(600, clipe) : 4;
87
+ else d = Math.min(clipe > 0 ? 600 : 60, d);
88
+
89
+ const n = Number(c && c.narracaoDur);
90
+ if (isFinite(n) && n > 0 && c && c.narracaoAudio) d = Math.max(d, Math.min(90, n + 0.4));
91
+ return d;
92
+ }
93
+
94
+ function totalSegundos(doc) {
95
+ return ((doc && doc.cenas) || []).reduce((s, c) => s + segundosDaCena(c), 0);
96
+ }
97
+
98
+ // ── os arquivos do projeto ───────────────────────────────────────────────
99
+
100
+ function pkgJson(nome) {
101
+ return JSON.stringify({
102
+ name: nome || 'video-primocode',
103
+ version: '1.0.0',
104
+ private: true,
105
+ scripts: {
106
+ /* `studio` abre a pré-visualização com linha do tempo; `render`
107
+ escreve o mp4.
108
+
109
+ O ponto de entrada vai ESCRITO. O Remotion procura sozinho por
110
+ `src/index.ts` e alguns outros nomes fixos, e este projeto é
111
+ .jsx — sem TypeScript, para não arrastar um toolchain inteiro
112
+ para dentro da pasta de quem só quer um vídeo. Sem o caminho,
113
+ ele responde "No entry point specified" e não renderiza nada. */
114
+ studio: 'remotion studio src/index.jsx',
115
+ render: 'remotion render src/index.jsx Video saida/video.mp4',
116
+ build: 'remotion render src/index.jsx Video saida/video.mp4 --codec=h264 --crf=16',
117
+ },
118
+ dependencies: {
119
+ remotion: VERSAO_REMOTION,
120
+ '@remotion/cli': VERSAO_REMOTION,
121
+ react: '19.0.0',
122
+ 'react-dom': '19.0.0',
123
+ },
124
+ }, null, 2) + '\n';
125
+ }
126
+
127
+ const INDEX_JSX = `import { registerRoot } from "remotion";
128
+ import { Raiz } from "./Raiz.jsx";
129
+
130
+ registerRoot(Raiz);
131
+ `;
132
+
133
+ function raizJsx(doc, fps) {
134
+ return `import { Composition } from "remotion";
135
+ import { Filme } from "./Filme.jsx";
136
+ import doc from "./doc.json";
137
+ import { totalQuadros } from "./tempo.js";
138
+
139
+ /* A composição. \`durationInFrames\` sai da MESMA conta de duração que o
140
+ player do estúdio usa (ver tempo.js) — se as duas divergissem, o arquivo
141
+ exportado teria outro tamanho que o vídeo aprovado na tela, e nada
142
+ denunciaria. */
143
+ export const Raiz = () => (
144
+ <Composition
145
+ id="Video"
146
+ component={Filme}
147
+ durationInFrames={totalQuadros(doc, ${fps})}
148
+ fps={${fps}}
149
+ width={${doc.largura || 1920}}
150
+ height={${doc.altura || 1080}}
151
+ defaultProps={{ doc }}
152
+ />
153
+ );
154
+ `;
155
+ }
156
+
157
+ /* A conta de tempo, em módulo próprio, gerada a partir da função acima.
158
+ Assim o projeto do usuário não depende do PrimoCode estar instalado. */
159
+ function tempoJs(fps) {
160
+ return `/* Quanto dura cada cena — a MESMA regra do player do estúdio.
161
+ *
162
+ * Copiada de propósito para dentro do projeto: ele tem de renderizar sozinho,
163
+ * sem o PrimoCode instalado. Se você mudar a duração de uma cena, mude no
164
+ * doc.json — não aqui.
165
+ */
166
+ export const FPS = ${fps};
167
+
168
+ export function segundosDaCena(c) {
169
+ let d = Number(c && c.duracao);
170
+ let clipe = 0;
171
+ for (const e of (c && c.elementos) || []) {
172
+ if (!e || e.tipo !== "clipe") continue;
173
+ const de = Number(e.de) || 0;
174
+ const ate = Number(e.ate);
175
+ if (isFinite(ate) && ate > de) clipe = Math.max(clipe, ate - de);
176
+ }
177
+ if (!(isFinite(d) && d > 0)) d = clipe > 0 ? Math.min(600, clipe) : 4;
178
+ else d = Math.min(clipe > 0 ? 600 : 60, d);
179
+
180
+ const n = Number(c && c.narracaoDur);
181
+ if (isFinite(n) && n > 0 && c && c.narracaoAudio) d = Math.max(d, Math.min(90, n + 0.4));
182
+ return d;
183
+ }
184
+
185
+ /* Quadros, não segundos: dentro do Remotion o tempo é inteiro. Arredondar
186
+ * para cima em cada cena evita a cena de 3,02s virar 3,0 e comer dois
187
+ * quadros do fim — e evita o acúmulo desse erro ao longo do filme. */
188
+ export function quadrosDaCena(c, fps = FPS) {
189
+ return Math.max(1, Math.round(segundosDaCena(c) * fps));
190
+ }
191
+
192
+ export function totalQuadros(doc, fps = FPS) {
193
+ const cenas = (doc && doc.cenas) || [];
194
+ const soma = cenas.reduce((s, c) => s + quadrosDaCena(c, fps), 0);
195
+ return Math.max(1, soma);
196
+ }
197
+
198
+ /* Qual cena está no ar no quadro \`q\`, e há quantos quadros ela começou.
199
+ * Devolver o "dentro" junto evita o chamador refazer a soma — e refazer a
200
+ * soma com outra regra de arredondamento é exatamente como as duas metades
201
+ * de um render saem fora de sincronia. */
202
+ export function cenaNoQuadro(doc, q, fps = FPS) {
203
+ const cenas = (doc && doc.cenas) || [];
204
+ let acc = 0;
205
+ for (let i = 0; i < cenas.length; i++) {
206
+ const dur = quadrosDaCena(cenas[i], fps);
207
+ if (q < acc + dur) return { i, dentro: q - acc, dur };
208
+ acc += dur;
209
+ }
210
+ const ult = cenas.length - 1;
211
+ const dur = ult >= 0 ? quadrosDaCena(cenas[ult], fps) : 1;
212
+ return { i: Math.max(0, ult), dentro: dur - 1, dur };
213
+ }
214
+ `;
215
+ }
216
+
217
+ const FILME_JSX = `import { useCurrentFrame, useVideoConfig, AbsoluteFill, Audio, staticFile } from "remotion";
218
+ import { useEffect, useRef } from "react";
219
+ import { cenaNoQuadro, quadrosDaCena } from "./tempo.js";
220
+ import "./render.css";
221
+ import "./fontes.css";
222
+ import { renderCena } from "./render.js";
223
+
224
+ /* O desenho de uma cena é do ESTÚDIO — render.js é o mesmo arquivo que o
225
+ navegador carrega. Aqui só se decide qual cena está no ar e como ela entra.
226
+ Inventar um segundo desenhista daria duas gramáticas para manter, e uma
227
+ ficaria velha. */
228
+ function Cena({ cena, doc, dentro, dur }) {
229
+ const caixa = useRef(null);
230
+
231
+ useEffect(() => {
232
+ if (!caixa.current) return;
233
+ caixa.current.innerHTML = "";
234
+ // \`animar: false\`: quem anima aqui é o Remotion, quadro a quadro. Com as
235
+ // animações CSS ligadas, cada quadro renderizado recomeçaria a animação
236
+ // do zero — o vídeo sairia com o primeiro instante repetido do começo ao
237
+ // fim, tecnicamente perfeito e visualmente parado.
238
+ caixa.current.appendChild(renderCena(cena, doc, { animar: false }));
239
+ }, [cena, doc]);
240
+
241
+ return <div ref={caixa} style={{ width: "100%", height: "100%" }} />;
242
+ }
243
+
244
+ /* Entrada e transição vindas do quadro, não do CSS.
245
+
246
+ \`t\` vai de 0 a 1 nos primeiros 18 quadros da cena. A curva é a mesma do
247
+ estúdio (cubic-bezier .22 1 .36 1, aproximada por um easeOutCubic): sem
248
+ isso a peça exportada por Remotion teria outro tempo de entrada que a
249
+ mesma peça vista no navegador. */
250
+ function entrada(transicao, t) {
251
+ const s = 1 - Math.pow(1 - t, 3);
252
+ switch (transicao) {
253
+ case "corte": return {};
254
+ case "deslizar": return { opacity: s, transform: \`translateX(\${(1 - s) * 8}%)\` };
255
+ case "subir": return { opacity: s, transform: \`translateY(\${(1 - s) * 8}%)\` };
256
+ case "zoom": return { opacity: s, transform: \`scale(\${0.92 + s * 0.08})\` };
257
+ case "flash": return { opacity: s, filter: \`brightness(\${1 + (1 - s) * 2.2})\` };
258
+ case "limpar": return { opacity: 1, clipPath: \`inset(0 \${(1 - s) * 100}% 0 0)\` };
259
+ case "persiana": return { opacity: 1, clipPath: \`inset(\${(1 - s) * 50}% 0 \${(1 - s) * 50}% 0)\` };
260
+ default: return { opacity: s }; // fade
261
+ }
262
+ }
263
+
264
+ export const Filme = ({ doc }) => {
265
+ const quadro = useCurrentFrame();
266
+ const { fps } = useVideoConfig();
267
+ const { i, dentro } = cenaNoQuadro(doc, quadro, fps);
268
+ const cenas = doc.cenas || [];
269
+ const cena = cenas[i] || {};
270
+ const dur = quadrosDaCena(cena, fps);
271
+
272
+ const QUADROS_ENTRADA = Math.max(1, Math.round(0.62 * fps));
273
+ const t = Math.min(1, dentro / QUADROS_ENTRADA);
274
+
275
+ return (
276
+ <AbsoluteFill style={{ backgroundColor: "#000" }}>
277
+ <AbsoluteFill style={entrada(cena.transicao, t)}>
278
+ <Cena cena={cena} doc={doc} dentro={dentro} dur={dur} />
279
+ </AbsoluteFill>
280
+ {doc.trilha && doc.trilha.src ? (
281
+ <Audio src={doc.trilha.src.startsWith("http") ? doc.trilha.src : staticFile(doc.trilha.src)}
282
+ volume={doc.trilha.volume == null ? 0.7 : doc.trilha.volume} />
283
+ ) : null}
284
+ </AbsoluteFill>
285
+ );
286
+ };
287
+ `;
288
+
289
+ /* O render.js do estúdio é um script de navegador: define funções soltas, sem
290
+ * export nenhum. O bundler do Remotion precisa de módulo.
291
+ *
292
+ * A saída é o arquivo ORIGINAL, sem uma linha alterada, com um `export` no
293
+ * fim. Reescrever o conteúdo seria criar uma segunda versão do desenhista
294
+ * para manter — e a que ficasse velha seria justamente a que renderiza o
295
+ * arquivo final, onde ninguém olha até estar publicado. */
296
+ function comoModulo(fonte, nomes) {
297
+ return fonte
298
+ + '\n\n/* ── acrescentado pelo PrimoCode ─────────────────────────────────────\n'
299
+ + ' O arquivo acima é o render do estúdio, byte por byte. Só o export é\n'
300
+ + ' novo: no navegador ele é um script solto, e aqui precisa ser módulo.\n'
301
+ + ' Não edite o que está em cima — atualize o PrimoCode e gere de novo. */\n'
302
+ + `export { ${nomes.join(', ')} };\n`;
303
+ }
304
+
305
+ /* `icones.js` é lido pelo render.js pelo nome global. Em módulo, o nome não
306
+ vaza para o escopo global sozinho — daí o empurrão explícito. */
307
+ function iconesComoModulo(fonte) {
308
+ return fonte
309
+ + '\n\n/* ── acrescentado pelo PrimoCode ────────────────────────────────────\n'
310
+ + ' O render.js procura os ícones no escopo global, como no navegador. */\n'
311
+ + 'if (typeof globalThis !== "undefined") {\n'
312
+ + ' if (typeof ICONES !== "undefined") globalThis.ICONES = ICONES;\n'
313
+ + ' if (typeof svgIcone === "function") globalThis.svgIcone = svgIcone;\n'
314
+ + '}\n';
315
+ }
316
+
317
+ function readme(titulo, doc) {
318
+ const seg = totalSegundos(doc);
319
+ return `# ${titulo || 'Vídeo'} — projeto Remotion
320
+
321
+ Gerado pelo PrimoCode a partir da peça do Studio. As cenas estão em
322
+ \`src/doc.json\`; o desenho delas é o mesmo do Studio (\`src/render.js\`).
323
+
324
+ ## Rodar
325
+
326
+ \`\`\`bash
327
+ npm install # uma vez, e demora — Remotion traz o próprio navegador
328
+ npm run studio # pré-visualização com linha do tempo
329
+ npm run render # escreve saida/video.mp4
330
+ \`\`\`
331
+
332
+ Duração: ${seg.toFixed(1)}s · ${doc.largura || 1920}×${doc.altura || 1080}
333
+ · ${(doc.cenas || []).length} cena(s).
334
+
335
+ ## Quando usar isto em vez do Studio
336
+
337
+ O Studio exporta o MP4 num clique, sem instalar nada, e é o caminho normal.
338
+ Este projeto existe para o que ele não faz:
339
+
340
+ - **quadro a quadro determinístico** — o mesmo arquivo em qualquer máquina,
341
+ sem depender de a aba estar em foco nem da carga do computador;
342
+ - **vídeo longo** sem risco de o navegador engasgar no meio;
343
+ - **fps, CRF, ProRes, canal alfa** e renderização paralela;
344
+ - **mexer no código**: daqui para a frente é React, e é seu.
345
+
346
+ ## Editar
347
+
348
+ - Conteúdo, cores e tempo: \`src/doc.json\`.
349
+ - Como cada cena entra: \`entrada()\` em \`src/Filme.jsx\`.
350
+ - Como um elemento é desenhado: \`src/render.js\` — mas ele é copiado do
351
+ Studio. Mexendo ali, um novo \`remotion_projeto\` sobrescreve. Se for
352
+ mudança para valer, mude no PrimoCode.
353
+
354
+ ## Licença do Remotion
355
+
356
+ Remotion é **grátis para pessoa física e para empresa de até três pessoas**.
357
+ Acima disso, a empresa precisa de licença paga — veja
358
+ <https://remotion.dev/license>. Isso vale para ESTE projeto, que é seu; o
359
+ PrimoCode não depende de Remotion e não muda de licença por causa disto.
360
+ `;
361
+ }
362
+
363
+ const GITIGNORE = 'node_modules/\nsaida/\n.remotion/\n';
364
+
365
+ // ── montar o projeto ─────────────────────────────────────────────────────
366
+
367
+ /**
368
+ * Escreve um projeto Remotion completo a partir de uma peça do Studio.
369
+ *
370
+ * @param {object} op
371
+ * @param {string} op.destino pasta onde o projeto nasce
372
+ * @param {string} op.titulo
373
+ * @param {object} op.doc o documento da peça (cenas, tema, trilha…)
374
+ * @param {number} [op.fps]
375
+ */
376
+ function montar({ destino, titulo, doc, fps }) {
377
+ if (!destino) return { ok: false, error: 'diga em que pasta o projeto vai nascer' };
378
+ if (!doc || !Array.isArray(doc.cenas) || !doc.cenas.length) {
379
+ return { ok: false, error: 'esta peça não tem cenas — Remotion renderiza cenas, como o Studio' };
380
+ }
381
+ const taxa = Math.max(1, Math.min(120, Number(fps) || 30));
382
+
383
+ const src = path.join(destino, 'src');
384
+ fs.mkdirSync(src, { recursive: true });
385
+
386
+ // Os arquivos do estúdio, sem reescrever conteúdo.
387
+ for (const [de, para] of DO_ESTUDIO) {
388
+ const origem = path.join(ORIGEM_ESTUDIO, de);
389
+ if (!fs.existsSync(origem)) continue;
390
+ let conteudo = fs.readFileSync(origem, 'utf-8');
391
+ if (para === 'render.js') conteudo = comoModulo(conteudo, ['renderCena', 'renderElemento']);
392
+ else if (para === 'icones.js') conteudo = iconesComoModulo(conteudo);
393
+ escrever(path.join(src, para), conteudo);
394
+ }
395
+ // A ordem importa: render.js procura os ícones no global.
396
+ const alvoRender = path.join(src, 'render.js');
397
+ if (fs.existsSync(alvoRender)) {
398
+ escrever(alvoRender, 'import "./icones.js";\n' + fs.readFileSync(alvoRender, 'utf-8'));
399
+ }
400
+
401
+ escrever(path.join(destino, 'package.json'), pkgJson(titulo));
402
+ escrever(path.join(destino, 'README.md'), readme(titulo, doc));
403
+ escrever(path.join(destino, '.gitignore'), GITIGNORE);
404
+ escrever(path.join(src, 'index.jsx'), INDEX_JSX);
405
+ escrever(path.join(src, 'Raiz.jsx'), raizJsx(doc, taxa));
406
+ escrever(path.join(src, 'Filme.jsx'), FILME_JSX);
407
+ escrever(path.join(src, 'tempo.js'), tempoJs(taxa));
408
+ escrever(path.join(src, 'doc.json'), JSON.stringify(doc, null, 2) + '\n');
409
+
410
+ return {
411
+ ok: true,
412
+ pasta: destino,
413
+ fps: taxa,
414
+ segundos: +totalSegundos(doc).toFixed(2),
415
+ quadros: Math.round(totalSegundos(doc) * taxa),
416
+ cenas: doc.cenas.length,
417
+ comoRodar: `cd ${destino} && npm install && npm run render`,
418
+ licenca: 'Remotion é grátis para pessoa física e empresa de até 3 pessoas; '
419
+ + 'acima disso a empresa precisa de licença paga (remotion.dev/license). '
420
+ + 'Vale para este projeto, não para o PrimoCode.',
421
+ };
422
+ }
423
+
424
+ /**
425
+ * Roda o render de um projeto já montado. Só faz sentido depois do
426
+ * `npm install` — Remotion baixa o próprio navegador, e isso leva minutos na
427
+ * primeira vez.
428
+ */
429
+ function renderizar({ pasta, saida, minutos = 20 }) {
430
+ return new Promise((resolve) => {
431
+ if (!fs.existsSync(path.join(pasta, 'node_modules'))) {
432
+ return resolve({
433
+ ok: false,
434
+ error: `Falta instalar. Rode: cd ${pasta} && npm install (demora — o Remotion `
435
+ + 'traz o próprio navegador). Depois peça o render de novo.',
436
+ });
437
+ }
438
+ const arquivo = saida || path.join('saida', 'video.mp4');
439
+ const proc = spawn(process.platform === 'win32' ? 'npx.cmd' : 'npx',
440
+ ['remotion', 'render', 'src/index.jsx', 'Video', arquivo, '--log=error'],
441
+ { cwd: pasta, stdio: ['ignore', 'pipe', 'pipe'] });
442
+
443
+ let fora = '';
444
+ proc.stdout.on('data', (d) => { fora += d; });
445
+ proc.stderr.on('data', (d) => { fora += d; });
446
+
447
+ const prazo = setTimeout(() => { try { proc.kill(); } catch {} }, minutos * 60000);
448
+ proc.on('close', (codigo) => {
449
+ clearTimeout(prazo);
450
+ const alvo = path.join(pasta, arquivo);
451
+ if (codigo === 0 && fs.existsSync(alvo)) {
452
+ return resolve({
453
+ ok: true, arquivo: alvo,
454
+ bytes: fs.statSync(alvo).size,
455
+ });
456
+ }
457
+ resolve({ ok: false, error: (fora || `remotion saiu com ${codigo}`).slice(-1200) });
458
+ });
459
+ proc.on('error', (e) => { clearTimeout(prazo); resolve({ ok: false, error: e.message }); });
460
+ });
461
+ }
462
+
463
+ module.exports = { montar, renderizar, segundosDaCena, totalSegundos, VERSAO_REMOTION };
package/lib/tools.js CHANGED
@@ -579,6 +579,36 @@ async function despachar(name, args, ctx) {
579
579
  arquivo: args.arquivo, pausa: args.pausa, abrir: openInBrowser,
580
580
  });
581
581
 
582
+ /* A MESMA peça, virando projeto Remotion.
583
+ *
584
+ * O Studio continua sendo o caminho normal: exporta em segundos,
585
+ * sem instalar nada. Isto é para quando o usuário pede Remotion
586
+ * pelo nome, ou quando a peça precisa do que só ele dá — render
587
+ * determinístico, vídeo longo, fps e codec escolhidos, ou o
588
+ * projeto em React para a pessoa mexer.
589
+ *
590
+ * Parte do documento que já existe: nada de um segundo
591
+ * vocabulário para o agente aprender. */
592
+ case 'remotion_projeto': {
593
+ const remotion = require('./remotion.js');
594
+ const peca = await studio.ler(args.id);
595
+ if (!peca || peca.ok === false) {
596
+ return peca || { ok: false, error: `não achei a peça ${args.id}` };
597
+ }
598
+ const arq = peca.arquivo || peca;
599
+ const base = projectDir || cfg.ROOT_DIR;
600
+ const nome = cfg.slugify(args.pasta || arq.titulo || 'video') + '-remotion';
601
+ const destino = path.join(base, nome);
602
+ const r = remotion.montar({
603
+ destino, titulo: arq.titulo, doc: arq.doc, fps: args.fps,
604
+ });
605
+ if (!r.ok || !args.renderizar) return r;
606
+ // Só renderiza quando pedido: o `npm install` demora minutos e
607
+ // é escolha do usuário, não efeito colateral de criar a pasta.
608
+ const feito = await remotion.renderizar({ pasta: destino });
609
+ return Object.assign(r, { render: feito });
610
+ }
611
+
582
612
  case 'desktop_double_click':
583
613
  return desktop.doubleClick(args.x, args.y);
584
614
  case 'desktop_open_app':
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "primocode",
3
- "version": "8.26.0",
3
+ "version": "8.30.0",
4
4
  "description": "PrimoCode — agente de engenharia com IA e cursor próprio. Modelos grátis. Cria arquivos, roda comandos, controla navegador e desktop: abre apps, clica em botões e ícones pelo nome, digita e usa atalhos.",
5
5
  "main": "bin/primocode.js",
6
6
  "bin": {
@@ -8,7 +8,7 @@
8
8
  },
9
9
  "scripts": {
10
10
  "start": "node bin/primocode.js",
11
- "test": "node test/tools.test.js && node test/ui.test.js && node test/modo.test.js && node test/pasta.test.js && node test/catalogo.test.js && node test/desktop.test.js && node test/effort.test.js && node test/claude-engine.test.js && node test/regressao.test.js && node test/memoria-conversa.test.js && node test/repeticao.test.js && node test/referencia.test.js && node test/parar.test.js && node test/continuar.test.js && node test/studio-spec.test.js && node test/studio-versoes.test.js && node test/voz.test.js && node test/editor.test.js && node test/estudio-projeto.test.js && node test/vocabulario.test.js && node test/video-export.test.js && node test/monotonia.test.js && node test/clipe.test.js && node test/midia-envio.test.js && node test/pausas.test.js && node test/app.test.js"
11
+ "test": "node test/tools.test.js && node test/ui.test.js && node test/modo.test.js && node test/pasta.test.js && node test/catalogo.test.js && node test/desktop.test.js && node test/effort.test.js && node test/claude-engine.test.js && node test/regressao.test.js && node test/memoria-conversa.test.js && node test/repeticao.test.js && node test/referencia.test.js && node test/parar.test.js && node test/continuar.test.js && node test/studio-spec.test.js && node test/studio-versoes.test.js && node test/voz.test.js && node test/editor.test.js && node test/estudio-projeto.test.js && node test/vocabulario.test.js && node test/video-export.test.js && node test/monotonia.test.js && node test/clipe.test.js && node test/midia-envio.test.js && node test/pausas.test.js && node test/app.test.js && node test/elementos.test.js && node test/remotion.test.js && node test/aplicativo.test.js && node test/conta.test.mjs"
12
12
  },
13
13
  "engines": {
14
14
  "node": ">=18.17.0"
@@ -57,7 +57,8 @@ Em dúvida entre duas, escolha a que o usuário vai **mostrar para outra pessoa*
57
57
  - **Ritmo.** Em decks longos, um `layout: "secao"` a cada 3–5 cenas.
58
58
  - **Escolha o tema pelo assunto:** `meia-noite` produto e tecnologia · `claro`
59
59
  corporativo e projetor · `papel` editorial e longo · `neon` técnico e dev ·
60
- `brasa` energia e urgência · `floresta` sustentabilidade e saúde.
60
+ `brasa` energia e urgência · `floresta` sustentabilidade e saúde ·
61
+ `ambar` curso, aula e conteúdo longo · `grafite` técnico sóbrio e relatório.
61
62
  - **Notas do apresentador** (`notas` na cena) em quem vai falar por cima.
62
63
  - **Nunca invente dado com cara de real.** Sem fonte, escreva a ordem de grandeza
63
64
  ou deixe o campo como pergunta explícita ao usuário.
@@ -134,9 +134,10 @@ Sem declarar nada, os elementos já entram em cascata na ordem em que aparecem.
134
134
 
135
135
  ### Temas
136
136
 
137
- `meia-noite` `claro` `papel` `neon` `brasa` `floresta` `retro` `oceano` `doce` `mono`
137
+ `meia-noite` `claro` `papel` `neon` `brasa` `floresta` `retro` `oceano` `doce`
138
+ `mono` `ambar` `grafite`
138
139
 
139
- São esses dez, e só. Nome fora da lista é recusado: escuro é `meia-noite`, não
140
+ São esses doze, e só. Nome fora da lista é recusado: escuro é `meia-noite`, não
140
141
  `noite`. Vale no documento ou por cena.
141
142
 
142
143
  ### Identidade — o que separa um vídeo genérico de um da marca
@@ -89,6 +89,8 @@ cena. Se o assunto não empurrar para nenhuma, escolha a que você NÃO usou da
89
89
  | explicação, tutorial, passo a passo | `papel` ou `claro` | `lofi` | **Aula** |
90
90
  | conquista, agradecimento, celebração | `doce` ou `neon` | `alegre` | **Brinde** |
91
91
  | memória, história, retrospectiva | `retro` ou `mono` | `lofi` | **Linha** |
92
+ | curso, aula, treinamento, método | `ambar` | `lofi` | **Aula** |
93
+ | relatório, número, análise técnica | `grafite` | `ambiente` | **Manchete** |
92
94
 
93
95
  ### As seis estruturas
94
96
 
@@ -178,6 +180,116 @@ livre de direitos, ou uma das trilhas geradas acima.
178
180
 
179
181
  ---
180
182
 
183
+ ## Studio ou Remotion?
184
+
185
+ **O padrão é o Studio.** Ele exporta o MP4 num clique, em segundos, sem
186
+ instalar nada. Não ofereça Remotion sem motivo — trocar um clique por
187
+ `npm install` de 250 MB é piorar a vida de quem só quer o vídeo.
188
+
189
+ Chame `remotion_projeto` (com o `id` da peça) em três casos, e só neles:
190
+
191
+ 1. **O usuário pediu Remotion pelo nome.** Não discuta, faça.
192
+ 2. **A peça precisa do que o Studio não dá:** render determinístico (o mesmo
193
+ arquivo em qualquer máquina, sem depender de a aba estar em foco), vídeo
194
+ muito longo, fps ou codec específico (ProRes, canal alfa), renderização em
195
+ servidor.
196
+ 3. **O usuário quer mexer no código.** O projeto que nasce é React, é dele, e
197
+ entra no git.
198
+
199
+ A peça é a MESMA — `remotion_projeto` parte do documento que você já criou
200
+ com `studio_criar`. Não existe um segundo formato para aprender: crie a peça
201
+ normalmente, veja no navegador, e só então gere o projeto se fizer sentido.
202
+
203
+ O projeto nasce sem `node_modules`. **Não rode `renderizar: true` antes de o
204
+ `npm install` ter sido feito** — diga ao usuário os dois comandos e deixe ele
205
+ decidir se quer esperar.
206
+
207
+ **Licença:** Remotion é grátis para pessoa física e empresa de até três
208
+ pessoas; acima disso a empresa precisa de licença paga. Avise ao oferecer —
209
+ descobrir isso depois de montar o fluxo de trabalho em cima seria péssimo.
210
+ Isso vale para o projeto do usuário, não para o PrimoCode.
211
+
212
+ ---
213
+
214
+ ## Elementos que dão cara de produção
215
+
216
+ Estes seis existem porque um vídeo genérico é feito só de título + texto. São
217
+ baratos de escrever e mudam completamente o resultado.
218
+
219
+ ### `selo` — a pílula de estado
220
+
221
+ ```json
222
+ { "tipo": "selo", "texto": "Módulo 2 de 5" }
223
+ { "tipo": "selo", "texto": "Resultado 4", "icone": "★", "estilo": "vivo" }
224
+ ```
225
+
226
+ `estilo`: sem nada (contorno), `"vivo"` (preenchido) ou `"solto"` (só o texto
227
+ em versalete). Vai sozinho para cima do título.
228
+
229
+ ### `progresso` — onde estamos no caminho
230
+
231
+ ```json
232
+ { "tipo": "progresso", "atual": 4, "total": 15 }
233
+ ```
234
+
235
+ Mostra "4/15" e os pontinhos. Use em série de cenas: diz ao espectador que
236
+ existe um percurso, e isso segura ele até o fim. `"numero": false` deixa só
237
+ os pontos.
238
+
239
+ ### `avatar` — a pessoa
240
+
241
+ ```json
242
+ { "tipo": "avatar", "src": "/midia/foto.jpg", "nome": "Cleiton Paris", "tamanho": 280 }
243
+ ```
244
+
245
+ Círculo com anel aceso. Sem `src`, mostra as iniciais do `nome` — melhor que
246
+ um buraco cinza. Vai para cima do nome sozinho.
247
+
248
+ ### `dispositivo` — celular, notebook ou navegador com conteúdo dentro
249
+
250
+ ```json
251
+ { "tipo": "dispositivo", "forma": "celular", "dentro": [ ...elementos... ] }
252
+ ```
253
+
254
+ `forma`: `celular` | `notebook` | `tablet` | `navegador` (este aceita
255
+ `"endereco": "primocode.dev"`). `dentro` é uma lista de elementos comuns — o
256
+ que couber. Nos layouts `imagem-direita` / `imagem-esquerda` o aparelho vai
257
+ para o lado sozinho, com o texto do outro. É o enquadramento mais forte que
258
+ existe aqui: use quando estiver mostrando uma tela.
259
+
260
+ ### `conversa` — balões de pergunta e resposta
261
+
262
+ ```json
263
+ { "tipo": "conversa", "itens": [
264
+ { "de": "eu", "texto": "Como acelero meu workflow?" },
265
+ { "de": "ela", "texto": "Peça o storyboard em seis quadros." } ] }
266
+ ```
267
+
268
+ Entram um de cada vez, como diálogo. `de: "eu"` à esquerda; qualquer outra
269
+ coisa à direita, com a cor do tema. Combina com `dispositivo` — a conversa
270
+ dentro do celular é a cena mais usada em vídeo de IA.
271
+
272
+ ### `passos` e `opcoes`
273
+
274
+ ```json
275
+ { "tipo": "passos", "atual": 2, "itens": ["Passo 1|roteiro", "Passo 2|storyboard", "Passo 3|corte"] }
276
+ { "tipo": "opcoes", "pergunta": "O que garante o look?", "certa": 1,
277
+ "itens": ["LUT uniforme", "Saturação aleatória", "Espaços de cor diferentes"] }
278
+ ```
279
+
280
+ `passos` acende a etapa `atual` e apaga as outras — mude o `atual` a cada
281
+ cena e a série ganha movimento sem esforço. `opcoes` marca a alternativa
282
+ `certa` com ✓ (não escreva o ✓ no texto: ele vem sozinho). Alternativa faz o
283
+ espectador pensar antes da resposta, e pensar é o que segura alguém no vídeo.
284
+
285
+ ### Vidro
286
+
287
+ `"estilo": "vidro"` em `cartoes` dá painel translúcido com bisel de luz em
288
+ cima e sombra embaixo — o "liquid glass". Funciona em tema escuro; em tema
289
+ claro use os cartões normais.
290
+
291
+ ---
292
+
181
293
  ## Editar o vídeo do usuário (`clipe`)
182
294
 
183
295
  Quando a pessoa manda uma gravação dela — o rosto falando, uma tela gravada,