primocode 9.8.0-beta.9 → 9.8.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.
@@ -51,7 +51,7 @@ const path = require('path');
51
51
  const TIPOS = new Set(['texto', 'forma', 'imagem', 'video', 'icone', 'legenda', 'componente']);
52
52
  const FORMAS = new Set(['retangulo', 'circulo', 'linha', 'poligono', 'traco']);
53
53
  const AJUSTES = new Set(['cover', 'contain', 'preencher']);
54
- const TRANSICOES = new Set(['corte', 'esmaecer', 'deslizar', 'zoom', 'giro', 'mascara', 'morph']);
54
+ const TRANSICOES = new Set(['corte', 'esmaecer', 'deslizar', 'zoom', 'giro', 'mascara', 'morph', 'chicote', 'soco', 'flash']);
55
55
 
56
56
  /* As propriedades que a animação por quadro-chave alcança. Cada uma é um
57
57
  número no tempo; a curva entre dois quadros é a suavização declarada.
@@ -165,7 +165,10 @@ function conferirElemento(caminho, el, pasta, problemas, duracao) {
165
165
  }
166
166
  }
167
167
  conferirCor(`${caminho}.cor`, el.cor, problemas);
168
- conferirCor(`${caminho}.fundo`, el.fundo, problemas);
168
+ // O fundo do elemento é cor, ou o mesmo objeto do fundo da cena (um
169
+ // gradiente sobre a foto, por exemplo).
170
+ if (el.fundo && typeof el.fundo === 'object') conferirFundo(`${caminho}.fundo`, el.fundo, pasta, problemas);
171
+ else conferirCor(`${caminho}.fundo`, el.fundo, problemas);
169
172
  conferirAnima(`${caminho}.anima`, el.anima, problemas);
170
173
 
171
174
  // `de` e `ate` recortam o elemento no tempo DA CENA. Um elemento que
@@ -212,6 +212,7 @@ function Elemento({ el, t, dados }) {
212
212
  transform: [base.transform, move].filter(Boolean).join(' '),
213
213
  filter: filtroDe(a) || undefined,
214
214
  mixBlendMode: el.mistura || undefined,
215
+ transformOrigin: el.origem || undefined,
215
216
  };
216
217
 
217
218
  if (el.vidro) {
@@ -233,7 +234,39 @@ function Elemento({ el, t, dados }) {
233
234
  }
234
235
 
235
236
  switch (el.tipo) {
236
- case 'texto':
237
+ case 'texto': {
238
+ /* O NÚMERO QUE CONTA: "contar": {ate: 9, dur: 1.2} faz o texto subir
239
+ de 0 até o valor, com a curva de saída. Casas decimais e o que vem
240
+ antes/depois do número (R$, %, mil) ficam como estão. */
241
+ let conteudo = el.texto;
242
+ if (el.contar) {
243
+ const alvo = Number(el.contar.ate) || 0;
244
+ const dur = el.contar.dur || 1.2;
245
+ const k = CURVAS.saida(Math.min(1, Math.max(0, tLocal / dur)));
246
+ const casas = el.contar.casas || 0;
247
+ const v = (alvo * k).toLocaleString('pt-BR', { minimumFractionDigits: casas, maximumFractionDigits: casas });
248
+ conteudo = (el.contar.antes || '') + v + (el.contar.depois || '');
249
+ }
250
+ /* O TEXTO QUE SE REVELA: "revelar": [t0, t1, …] é o instante (no tempo
251
+ do elemento) em que cada palavra entra — o da narração. A palavra
252
+ sobe e aparece; as que ainda não foram ditas esperam invisíveis, e o
253
+ bloco não muda de tamanho quando elas entram. */
254
+ if (Array.isArray(el.revelar) && typeof el.texto === 'string') {
255
+ const palavras = el.texto.split(' ');
256
+ conteudo = palavras.map((w, i) => {
257
+ const t0 = el.revelar[Math.min(i, el.revelar.length - 1)] ?? 0;
258
+ const k = Math.min(1, Math.max(0, (tLocal - t0) / 0.28));
259
+ const e = CURVAS.saida(k);
260
+ // O espaço fica FORA do span: é ali que a linha pode quebrar.
261
+ return (
262
+ <React.Fragment key={i}>
263
+ <span style={{ display: 'inline-block', opacity: e, transform: 'translateY(' + (1 - e) * 0.35 + 'em)',
264
+ color: el.destaques && el.destaques.includes(i) ? (el.corDestaque || el.cor) : undefined }}>{w}</span>
265
+ {i < palavras.length - 1 ? ' ' : null}
266
+ </React.Fragment>
267
+ );
268
+ });
269
+ }
237
270
  return (
238
271
  <div style={{
239
272
  ...estilo,
@@ -249,8 +282,19 @@ function Elemento({ el, t, dados }) {
249
282
  background: el.gradienteTexto || undefined,
250
283
  WebkitBackgroundClip: el.gradienteTexto ? 'text' : undefined,
251
284
  WebkitTextFillColor: el.gradienteTexto ? 'transparent' : undefined,
252
- }}>{el.texto}</div>
285
+ fontStyle: el.italico ? 'italic' : undefined,
286
+ WebkitTextStroke: el.contorno || undefined,
287
+ // O GLITCH: o texto se parte em vermelho e ciano por um instante.
288
+ textShadow: el.glitch && tLocal < (el.glitch.dur || 0.35)
289
+ ? (() => { const f = Math.round(t * 30); const d = (Math.sin(f * 7.1) * 0.5 + 0.5) * (el.glitch.forca || 10) * (1 - tLocal / (el.glitch.dur || 0.35));
290
+ return d.toFixed(1) + 'px 0 #ff2d55, ' + (-d).toFixed(1) + 'px 0 #00e5ff'; })()
291
+ : (el.sombraTexto || undefined),
292
+ textDecoration: el.riscado ? 'line-through' : undefined,
293
+ textDecorationThickness: el.riscado ? '0.08em' : undefined,
294
+ wordBreak: 'normal', overflowWrap: 'break-word',
295
+ }}>{conteudo}</div>
253
296
  );
297
+ }
254
298
 
255
299
  case 'forma': {
256
300
  const forma = el.forma || 'retangulo';
@@ -259,7 +303,7 @@ function Elemento({ el, t, dados }) {
259
303
  ...estilo,
260
304
  width: medida(el.largura, '200px'),
261
305
  height: forma === 'linha' ? medida(el.espessura, '4px') : medida(el.altura, '200px'),
262
- background: el.fundo || el.cor || '#fff',
306
+ ...(el.fundo && typeof el.fundo === 'object' ? fundoCSS(el.fundo) : { background: el.fundo || el.cor || '#fff' }),
263
307
  borderRadius: forma === 'circulo' ? '50%' : medida(a.raio ?? el.raio, '0px'),
264
308
  border: el.contorno || undefined,
265
309
  }} />
@@ -285,7 +329,7 @@ function Elemento({ el, t, dados }) {
285
329
  startFrom={el.deOrigem ? Math.round(el.deOrigem * dados.fps) : undefined}
286
330
  endAt={el.ateOrigem ? Math.round(el.ateOrigem * dados.fps) : undefined}
287
331
  volume={el.volume ?? 0}
288
- style={{ width: '100%', height: '100%', objectFit: el.ajuste || 'cover' }}
332
+ style={{ width: '100%', height: '100%', objectFit: el.ajuste || 'cover', objectPosition: el.foco || 'center' }}
289
333
  />
290
334
  </div>
291
335
  );
@@ -294,7 +338,7 @@ function Elemento({ el, t, dados }) {
294
338
  // O SVG foi baixado para a pasta do projeto (estudio/assets.js): render
295
339
  // que busca ícone na internet falha no meio, depois de tudo pronto.
296
340
  return (
297
- <div style={{ ...estilo, width: medida(el.tamanho, '96px'), height: medida(el.tamanho, '96px'), color: el.cor || '#fff' }}
341
+ <div style={{ ...estilo, width: medida(el.tamanho, '96px'), height: medida(el.tamanho, '96px'), fontSize: medida(el.tamanho, '96px'), lineHeight: 0, color: el.cor || '#fff' }}
298
342
  dangerouslySetInnerHTML={{ __html: dados.icones[el.nome] || '' }} />
299
343
  );
300
344
 
@@ -302,6 +346,55 @@ function Elemento({ el, t, dados }) {
302
346
  const fala = (el.fala || []).find((f) => t >= f.de && t <= f.ate);
303
347
  if (!fala) return null;
304
348
  const e = el.estilo || {};
349
+ /* LEGENDA DE VIDEOMAKER: com o tempo de cada palavra, elas entram uma a
350
+ uma, no instante em que são ditas, com um salto curto. A que está
351
+ sendo dita ganha a cor de destaque; a palavra forte (a ideia da
352
+ frase) ganha a cor dela e fica maior. Sem "palavras", a frase entra
353
+ inteira, como antes. */
354
+ if (Array.isArray(fala.palavras) && fala.palavras.length) {
355
+ const destaque = e.destaque || '#FFD400';
356
+ const forte = e.corForte || '#00E0A4';
357
+ const soUma = e.modo === 'palavra';
358
+ const visiveis = soUma
359
+ ? fala.palavras.filter((p, i, l) => t >= p.de && (i === l.length - 1 || t < l[i + 1].de)).slice(-1)
360
+ : fala.palavras.filter((p) => t >= p.de - 0.02);
361
+ return (
362
+ <div style={{
363
+ ...estilo,
364
+ display: 'flex', flexWrap: 'wrap', justifyContent: 'center', alignItems: 'baseline',
365
+ gap: '0 0.34em', maxWidth: medida(e.largura, '86%'),
366
+ fontFamily: e.fonte ? '"' + e.fonte + '", system-ui, sans-serif' : 'system-ui, sans-serif',
367
+ fontSize: medida(e.tamanho, '64px'),
368
+ fontWeight: e.peso || 900,
369
+ textTransform: e.caixa || undefined,
370
+ lineHeight: 1.1,
371
+ }}>
372
+ {visiveis.map((p, i) => {
373
+ const idade = t - p.de;
374
+ const entra = Math.min(1, Math.max(0, idade / 0.14));
375
+ const salto = entra < 1 ? 0.7 + 0.42 * CURVAS.saida(entra) : 1.12 - 0.12 * Math.min(1, (idade - 0.14) / 0.12);
376
+ const falando = t >= p.de && t <= p.ate + 0.05;
377
+ const cor = p.forte ? forte : (falando ? destaque : (e.cor || '#fff'));
378
+ return (
379
+ <span key={i} style={{
380
+ display: 'inline-block',
381
+ // A palavra forte é MAIOR na fonte, não na escala: escala não
382
+ // abre espaço, e ela cresceria por cima da vizinha.
383
+ fontSize: p.forte ? '1.22em' : undefined,
384
+ transform: 'scale(' + salto + ') translateY(' + (1 - entra) * 18 + 'px)',
385
+ opacity: entra,
386
+ color: cor,
387
+ textShadow: e.sombra || '0 6px 22px rgba(0,0,0,.6), 0 2px 0 rgba(0,0,0,.35)',
388
+ WebkitTextStroke: e.contorno || '2px rgba(0,0,0,.55)',
389
+ paintOrder: 'stroke fill',
390
+ background: falando && e.fundoAtivo ? e.fundoAtivo : undefined,
391
+ borderRadius: '0.18em', padding: falando && e.fundoAtivo ? '0 0.12em' : undefined,
392
+ }}>{p.texto}{p.emoji ? ' ' + p.emoji : ''}</span>
393
+ );
394
+ })}
395
+ </div>
396
+ );
397
+ }
305
398
  return (
306
399
  <div style={{
307
400
  ...estilo,
@@ -325,16 +418,33 @@ function Elemento({ el, t, dados }) {
325
418
  }
326
419
  }
327
420
 
328
- export function Cena({ cena, dados }) {
421
+ export function Cena({ cena, dados, tFixo }) {
329
422
  const frame = useCurrentFrame();
330
423
  const { fps } = useVideoConfig();
331
- const t = frame / fps;
424
+ // A página parada é a cena num instante só: tFixo manda no tempo.
425
+ const t = tFixo != null ? tFixo : frame / fps;
332
426
 
333
427
  const cam = animado(cena.camera, t);
428
+ /* O IMPACTO: tremor (a câmera treme e assenta), soco (o zoom que bate e
429
+ volta) e flash (o branco que estoura e some). O tremor é pseudoaleatório
430
+ pelo QUADRO, então o mesmo render sai sempre igual. */
431
+ const acaso = (n) => { const x = Math.sin(n * 12.9898 + 78.233) * 43758.5453; return x - Math.floor(x); };
432
+ let tx = 0, ty = 0, tr = 0, soco = 1;
433
+ for (const w of cena.tremor || []) {
434
+ if (t < w.de || t > w.ate) continue;
435
+ const k = (w.forca || 14) * (1 - (t - w.de) / Math.max(0.01, w.ate - w.de));
436
+ tx += (acaso(frame) - 0.5) * 2 * k; ty += (acaso(frame + 91) - 0.5) * 2 * k; tr += (acaso(frame + 37) - 0.5) * k * 0.08;
437
+ }
438
+ for (const s0 of cena.socos || []) {
439
+ if (t >= s0.t) soco *= 1 + (s0.forca || 0.18) * Math.exp(-14 * (t - s0.t));
440
+ }
441
+ let flash = 0;
442
+ for (const f of cena.flashes || []) if (t >= f && t < f + 0.25) flash = Math.max(flash, Math.exp(-18 * (t - f)));
334
443
  const camTransform = [
335
444
  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)' : '',
445
+ tx || ty ? 'translate(' + tx + 'px,' + ty + 'px)' : '',
446
+ cam.zoom != null || soco !== 1 ? 'scale(' + ((cam.zoom ?? 1) * soco) + ')' : '',
447
+ cam.rotacao != null || tr ? 'rotate(' + ((cam.rotacao || 0) + tr) + 'deg)' : '',
338
448
  ].filter(Boolean).join(' ');
339
449
 
340
450
  return (
@@ -363,10 +473,11 @@ export function Cena({ cena, dados }) {
363
473
  <Elemento key={i} el={el} t={t} dados={dados} />
364
474
  ))}
365
475
  </AbsoluteFill>
476
+ {flash > 0.01 && <AbsoluteFill style={{ backgroundColor: cena.corFlash || '#fff', opacity: flash * 0.85 }} />}
366
477
  </AbsoluteFill>
367
478
  );
368
479
  }
369
- ";`;
480
+ `;
370
481
 
371
482
  const RAIZ_JSX = String.raw`import React from 'react';
372
483
  import { AbsoluteFill, Audio, Sequence, staticFile, useCurrentFrame, useVideoConfig, interpolate } from 'remotion';
@@ -386,22 +497,52 @@ function Transicao({ tipo, progresso, children }) {
386
497
  else if (tipo === 'giro') estilo = { transform: 'rotateY(' + (1 - p) * 35 + 'deg) scale(' + (0.9 + 0.1 * p) + ')', opacity: p };
387
498
  else if (tipo === 'mascara') estilo = { clipPath: 'circle(' + (p * 75) + '% at 50% 50%)' };
388
499
  else if (tipo === 'morph') estilo = { opacity: p, filter: 'blur(' + (1 - p) * 14 + 'px)', transform: 'scale(' + (1.06 - 0.06 * p) + ')' };
500
+ // O CHICOTE: a cena chega rasgando de lado, borrada de velocidade.
501
+ else if (tipo === 'chicote') estilo = { transform: 'translateX(' + (1 - p) * 70 + '%) skewX(' + (1 - p) * -12 + 'deg)', filter: 'blur(' + (1 - p) * 40 + 'px)' };
502
+ // O SOCO: a cena entra grande e bate no lugar.
503
+ else if (tipo === 'soco') estilo = { transform: 'scale(' + (1.35 - 0.35 * p) + ')', opacity: Math.min(1, p * 3) };
504
+ else if (tipo === 'flash') estilo = { opacity: Math.min(1, p * 2), filter: 'brightness(' + (1 + (1 - p) * 3) + ')' };
389
505
  return <AbsoluteFill style={estilo}>{children}</AbsoluteFill>;
390
506
  }
391
507
 
508
+ /* AS PÁGINAS: um quadro por cena, cada uma congelada no meio dela. Os
509
+ * slides saem num render só — antes era um "remotion still" por página, e
510
+ * cada um empacotava o projeto inteiro de novo (onze páginas, onze
511
+ * empacotamentos: é a demora de 25 minutos medida em 25/09). */
512
+ export const Paginas = () => {
513
+ const { fps } = useVideoConfig();
514
+ const frame = useCurrentFrame();
515
+ const cena = plano.cenas[Math.min(frame, plano.cenas.length - 1)];
516
+ return (
517
+ <AbsoluteFill style={{ backgroundColor: plano.fundoGeral || '#000' }}>
518
+ <Cena cena={cena} dados={{ fps, icones }} tFixo={cena.duracao / 2} />
519
+ </AbsoluteFill>
520
+ );
521
+ };
522
+
392
523
  export const Video = () => {
393
524
  const { fps } = useVideoConfig();
394
525
  const frame = useCurrentFrame();
395
526
  const dados = { fps, icones };
396
527
 
528
+ /* O cruzamento é da cena que ENTRA: ela começa "cruz" quadros antes de a
529
+ anterior acabar, e as duas ficam no ar juntas. Contar pela que sai deixava
530
+ a que entra começando sobre o preto (medido em 25/09: o "soco" abria com
531
+ um quadro preto). */
532
+ const cruzDe = (cena, i) => {
533
+ if (!cena || i === 0) return 0;
534
+ const t = cena.transicao || {};
535
+ const tipo = t.tipo || t || 'corte';
536
+ return tipo === 'corte' ? 0 : Math.round((t.duracao ?? 0.4) * fps);
537
+ };
397
538
  let inicio = 0;
398
539
  const pedacos = plano.cenas.map((cena, i) => {
399
540
  const dur = Math.max(1, Math.round(cena.duracao * fps));
400
541
  const t = cena.transicao || {};
401
542
  const tipo = t.tipo || t || 'corte';
402
- const cruz = tipo === 'corte' ? 0 : Math.round((t.duracao ?? 0.4) * fps);
543
+ const cruz = cruzDe(cena, i);
403
544
  const de = inicio;
404
- inicio += dur - cruz;
545
+ inicio += dur - cruzDe(plano.cenas[i + 1], i + 1);
405
546
  return { cena, de, dur, tipo, cruz, i };
406
547
  });
407
548
 
@@ -421,15 +562,56 @@ export const Video = () => {
421
562
  );
422
563
  })}
423
564
 
565
+ {/* O ACABAMENTO: granulação de filme, vinheta e a luz da marca passando.
566
+ É o que tira a cara de apresentação e dá a cara de peça de 2026. */}
567
+ {plano.acabamento && plano.acabamento.luz && (
568
+ <AbsoluteFill style={{ pointerEvents: 'none', mixBlendMode: 'screen', opacity: plano.acabamento.luzForca ?? 0.22,
569
+ background: 'radial-gradient(circle at ' + (50 + 40 * Math.sin(frame / 38)) + '% ' + (30 + 25 * Math.cos(frame / 51)) + '%, '
570
+ + plano.acabamento.luz[0] + ' 0%, transparent 45%), radial-gradient(circle at ' + (50 - 40 * Math.sin(frame / 44)) + '% '
571
+ + (75 - 20 * Math.sin(frame / 33)) + '%, ' + (plano.acabamento.luz[1] || plano.acabamento.luz[0]) + ' 0%, transparent 40%)' }} />
572
+ )}
573
+ {plano.acabamento && plano.acabamento.grade && (
574
+ <AbsoluteFill style={{ pointerEvents: 'none', opacity: plano.acabamento.grade,
575
+ backgroundImage: 'linear-gradient(rgba(255,255,255,0.5) 1px, transparent 1px), linear-gradient(90deg, rgba(255,255,255,0.5) 1px, transparent 1px)',
576
+ backgroundSize: (plano.acabamento.passoGrade || 120) + 'px ' + (plano.acabamento.passoGrade || 120) + 'px',
577
+ backgroundPosition: (frame * 0.2) + 'px ' + (frame * 0.1) + 'px' }} />
578
+ )}
579
+ {plano.acabamento && plano.acabamento.vinheta && (
580
+ <AbsoluteFill style={{ pointerEvents: 'none', background: 'radial-gradient(ellipse at center, transparent 55%, rgba(0,0,0,' + plano.acabamento.vinheta + ') 100%)' }} />
581
+ )}
582
+ {plano.acabamento && plano.acabamento.grao && (
583
+ <AbsoluteFill style={{ pointerEvents: 'none', opacity: plano.acabamento.grao, mixBlendMode: 'overlay' }}>
584
+ <svg width="100%" height="100%">
585
+ <filter id="grao"><feTurbulence type="fractalNoise" baseFrequency="0.85" numOctaves="2" seed={frame % 60} /></filter>
586
+ <rect width="100%" height="100%" filter="url(#grao)" />
587
+ </svg>
588
+ </AbsoluteFill>
589
+ )}
590
+
424
591
  {plano.audio && plano.audio.narracao && (
425
592
  <Audio src={staticFile(plano.audio.narracao)} volume={plano.audio.volumeNarracao ?? 1} />
426
593
  )}
427
594
  {plano.audio && plano.audio.trilha && (
428
595
  <Audio
429
596
  src={staticFile(plano.audio.trilha.src || plano.audio.trilha)}
430
- volume={plano.audio.trilha.volume ?? 0.18}
597
+ /* O volume da música pode mudar no tempo ("volumes": [{t, v}]): começa
598
+ mínima, cresce, abaixa no depoimento e some antes do clímax. */
599
+ volume={Array.isArray(plano.audio.trilha.volumes)
600
+ ? (f) => { const tt = f / fps, vs = plano.audio.trilha.volumes;
601
+ if (tt <= vs[0].t) return vs[0].v;
602
+ for (let k = 0; k < vs.length - 1; k++) if (tt >= vs[k].t && tt <= vs[k + 1].t) return vs[k].v + (vs[k + 1].v - vs[k].v) * ((tt - vs[k].t) / Math.max(0.001, vs[k + 1].t - vs[k].t));
603
+ return vs[vs.length - 1].v; }
604
+ : (plano.audio.trilha.volume ?? 0.18)}
605
+ loop={plano.audio.trilha.loop !== false}
431
606
  />
432
607
  )}
608
+ {/* Os TRECHOS: pedaços de áudio no tempo certo — a narração partida entre
609
+ os depoimentos, o som de ambiente de uma cena. */}
610
+ {(plano.audio && plano.audio.trechos ? plano.audio.trechos : []).map((a, k) => (
611
+ <Sequence key={'tr' + k} from={Math.round((a.t || 0) * fps)} durationInFrames={Math.max(1, Math.round(((a.ate ?? 999) - (a.de || 0)) * fps))}>
612
+ <Audio src={staticFile(a.src)} startFrom={Math.round((a.de || 0) * fps)} volume={a.volume ?? 1} />
613
+ </Sequence>
614
+ ))}
433
615
  {(plano.audio && plano.audio.efeitos ? plano.audio.efeitos : []).map((e, i) => (
434
616
  <Sequence key={'sfx' + i} from={Math.round((e.t || 0) * fps)}>
435
617
  <Audio src={staticFile(e.src)} volume={e.volume ?? 0.7} />
@@ -438,13 +620,13 @@ export const Video = () => {
438
620
  </AbsoluteFill>
439
621
  );
440
622
  };
441
- ";`;
623
+ `;
442
624
 
443
625
  function rootJsx(plano) {
444
626
  const f = plano.formato || {};
445
627
  return `import React from 'react';
446
628
  import { Composition } from 'remotion';
447
- import { Video } from './Video';
629
+ import { Video, Paginas } from './Video';
448
630
  import plano from './plano.json';
449
631
  import './fontes.css';
450
632
 
@@ -455,14 +637,24 @@ const quadros = Math.max(1, Math.round(
455
637
  ));
456
638
 
457
639
  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
- />
640
+ <>
641
+ <Composition
642
+ id="Video"
643
+ component={Video}
644
+ durationInFrames={quadros}
645
+ fps={fps}
646
+ width={${Number(f.largura) || 1920}}
647
+ height={${Number(f.altura) || 1080}}
648
+ />
649
+ <Composition
650
+ id="Paginas"
651
+ component={Paginas}
652
+ durationInFrames={plano.cenas.length}
653
+ fps={fps}
654
+ width={${Number(f.largura) || 1920}}
655
+ height={${Number(f.altura) || 1080}}
656
+ />
657
+ </>
466
658
  );
467
659
  `;
468
660
  }
@@ -513,12 +705,25 @@ para este projeto, não para o PrimoCode.
513
705
  * @param {object} op.plano
514
706
  * @param {object} op.icones { "lucide:zap": "<svg…>" } já baixados
515
707
  */
708
+ const TSCONFIG = JSON.stringify({
709
+ compilerOptions: {
710
+ target: 'ES2018', module: 'commonjs', jsx: 'react-jsx', strict: false, noEmit: true,
711
+ lib: ['es2015', 'dom'], esModuleInterop: true, skipLibCheck: true, forceConsistentCasingInFileNames: true,
712
+ },
713
+ exclude: ['remotion.config.ts'],
714
+ }, null, 2) + '\n';
715
+
516
716
  function montar({ pasta, titulo, plano, icones = {} }) {
517
717
  const raiz = path.join(pasta, 'remotion');
518
718
  const src = path.join(raiz, 'src');
519
719
 
520
720
  escrever(path.join(raiz, 'package.json'), pkgJson(titulo || 'video'));
521
721
  escrever(path.join(raiz, 'remotion.config.ts'), REMOTION_CONFIG);
722
+ /* O remotion.config.ts é TypeScript, e o Remotion 4 recusa renderizar sem
723
+ um tsconfig.json na raiz ("Could not find a tsconfig.json"). Medido em
724
+ 25/09: o vídeo que saiu bom só saiu porque o modelo criou este arquivo à
725
+ mão depois do erro — um modelo grátis travaria ali. */
726
+ escrever(path.join(raiz, 'tsconfig.json'), TSCONFIG);
522
727
  escrever(path.join(raiz, 'README.md'), readme(titulo || 'Vídeo', plano));
523
728
  escrever(path.join(src, 'index.js'), INDEX_JS);
524
729
  escrever(path.join(src, 'Root.jsx'), rootJsx(plano));
@@ -48,41 +48,45 @@ const { spawn } = require('child_process');
48
48
  function renderizarPaginas({ raiz, destino, plano, aoFalar = () => {} }) {
49
49
  return new Promise((resolve) => {
50
50
  fs.mkdirSync(destino, { recursive: true });
51
- const fps = Number((plano.formato || {}).fps) || 30;
52
-
53
- // Onde começa cada cena, em quadros — a mesma conta do Video.jsx.
54
- const inicios = [];
55
- let cursor = 0;
56
- for (const cena of plano.cenas || []) {
57
- const dur = Math.max(1, Math.round(cena.duracao * fps));
58
- const t = cena.transicao || {};
59
- const cruz = (t.tipo || t) === 'corte' ? 0 : Math.round((t.duracao ?? 0.4) * fps);
60
- inicios.push({ de: cursor, dur });
61
- cursor += dur - cruz;
62
- }
51
+ for (const f of fs.readdirSync(destino)) if (/\.(jpe?g|png)$/i.test(f)) fs.rmSync(path.join(destino, f));
52
+ const n = (plano.cenas || []).length;
53
+ const shell = process.platform === 'win32';
54
+ const cita = (a) => (shell && /\s/.test(a) ? `"${a}"` : a);
55
+
56
+ /* Projeto novo não tem o Remotion instalado: o caminho de slides
57
+ rodava o npx direto e morria em "could not determine executable". */
58
+ const passos = fs.existsSync(path.join(raiz, 'node_modules')) ? [] : [['npm', ['install', '--no-audit', '--no-fund']]];
59
+ // Todas as páginas num render só: a composição "Paginas" tem um quadro por cena.
60
+ /* O Remotion recusa pasta de sequência com ponto no caminho ("cannot
61
+ have an extension") — e ~/.primocode tem. A sequência sai numa pasta
62
+ temporária sem ponto e as páginas são movidas depois. */
63
+ const tmp = fs.mkdtempSync(path.join(require('os').tmpdir(), 'primo-paginas-'));
64
+ passos.push(['npx', ['remotion', 'render', 'Paginas', tmp, '--sequence',
65
+ '--image-format=jpeg', '--jpeg-quality=95', '--log=warn']]);
63
66
 
64
- const paginas = [];
65
67
  const correr = (i) => {
66
- if (i >= inicios.length) return resolve({ ok: true, paginas });
67
- const { de, dur } = inicios[i];
68
- const quadro = de + Math.floor(dur / 2);
69
- const arquivo = path.join(destino, `pagina-${String(i + 1).padStart(3, '0')}.jpeg`);
70
- aoFalar(`página ${i + 1} de ${inicios.length}`);
71
-
72
- const p = spawn('npx', [
73
- 'remotion', 'still', 'Video', arquivo,
74
- '--frame=' + quadro, '--image-format=jpeg', '--quality=95', '--log=warn',
75
- ], { cwd: raiz, shell: process.platform === 'win32' });
76
-
68
+ if (i >= passos.length) {
69
+ // Pelo NÚMERO: em ordem alfabética, element-10 vem antes de element-2.
70
+ const num = (f) => Number((f.match(/(\d+)\.jpe?g$/i) || [])[1] || 0);
71
+ const saidas = fs.readdirSync(tmp).filter((f) => /\.jpe?g$/i.test(f)).sort((a, b) => num(a) - num(b));
72
+ const paginas = saidas.map((f, k) => {
73
+ const final = path.join(destino, `pagina-${String(k + 1).padStart(3, '0')}.jpeg`);
74
+ fs.copyFileSync(path.join(tmp, f), final);
75
+ return final;
76
+ });
77
+ try { fs.rmSync(tmp, { recursive: true, force: true }); } catch { /* fica no temporário */ }
78
+ if (paginas.length !== n) return resolve({ ok: false, error: `saíram ${paginas.length} páginas de ${n}.` });
79
+ return resolve({ ok: true, paginas });
80
+ }
81
+ const [cmd, args] = passos[i];
82
+ aoFalar(cmd === 'npm' ? 'instalando o Remotion nesta pasta (uma vez só)' : `desenhando ${n} páginas`);
83
+ const p = spawn(cmd, args.map(cita), { cwd: raiz, shell });
77
84
  let erro = '';
78
- p.stderr.on('data', (d) => { erro += d.toString().slice(0, 1000); });
79
- const relogio = setTimeout(() => { try { p.kill(); } catch {} }, 5 * 60000);
85
+ p.stderr.on('data', (d) => { erro += d.toString().slice(0, 2000); });
86
+ const relogio = setTimeout(() => { try { p.kill(); } catch {} }, 15 * 60000);
80
87
  p.on('close', (code) => {
81
88
  clearTimeout(relogio);
82
- if (code !== 0) {
83
- return resolve({ ok: false, error: `a página ${i + 1} falhou: ${erro.slice(-400).trim()}` });
84
- }
85
- paginas.push(arquivo);
89
+ if (code !== 0) return resolve({ ok: false, error: `${cmd} saiu com ${code}: ${erro.slice(-400).trim()}` });
86
90
  correr(i + 1);
87
91
  });
88
92
  p.on('error', (e) => { clearTimeout(relogio); resolve({ ok: false, error: e.message }); });