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.
- package/README.md +2 -2
- package/lib/act.js +16 -9
- package/lib/api.js +8 -1
- package/lib/catalogo.js +11 -3
- package/lib/claude-engine.js +25 -3
- package/lib/estudio/acervo-site.js +157 -0
- package/lib/estudio/assets.js +77 -3
- package/lib/estudio/cinema.js +399 -0
- package/lib/estudio/composicao.js +470 -0
- package/lib/estudio/edicao.js +450 -0
- package/lib/estudio/impacto.js +263 -0
- package/lib/estudio/index.js +854 -5
- package/lib/estudio/plano.js +5 -2
- package/lib/estudio/remotion.js +228 -23
- package/lib/estudio/slides.js +34 -30
- package/lib/local/cerebro.json +361 -180
- package/lib/local/entrada.js +18 -8
- package/lib/local/motor-cli.js +1 -1
- package/package.json +2 -2
- package/studio/narrar.py +111 -10
- package/studio/rostos.py +52 -0
package/lib/estudio/plano.js
CHANGED
|
@@ -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
|
-
|
|
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
|
package/lib/estudio/remotion.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
337
|
-
cam.
|
|
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 =
|
|
543
|
+
const cruz = cruzDe(cena, i);
|
|
403
544
|
const de = inicio;
|
|
404
|
-
inicio += dur -
|
|
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
|
|
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
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
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));
|
package/lib/estudio/slides.js
CHANGED
|
@@ -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
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
const
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
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 >=
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
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,
|
|
79
|
-
const relogio = setTimeout(() => { try { p.kill(); } catch {} },
|
|
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 }); });
|