primocode 9.9.0 → 10.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +30 -6
- package/bin/primocode.js +100 -341
- package/lib/act.js +245 -23
- package/lib/browser.js +17 -0
- package/lib/casar.js +108 -0
- package/lib/catalogo.js +6 -2
- package/lib/config.js +10 -3
- package/lib/cursor.js +10 -1
- package/lib/entrada.js +4 -2
- package/lib/estudio/diretor.js +9 -1
- package/lib/estudio/ende.js +2 -0
- package/lib/estudio/estilo.js +51 -0
- package/lib/estudio/glifos.js +215 -0
- package/lib/estudio/index.js +63 -11
- package/lib/estudio/kit/base.jsx +285 -0
- package/lib/estudio/kit/index.jsx +11 -0
- package/lib/estudio/kit/marca.jsx +741 -0
- package/lib/estudio/kit/vox.jsx +357 -0
- package/lib/estudio/kit.js +72 -0
- package/lib/estudio/lancamento.js +413 -0
- package/lib/estudio/morfar.js +191 -0
- package/lib/estudio/motion.js +333 -0
- package/lib/estudio/plano.js +54 -7
- package/lib/estudio/remotion.js +167 -52
- package/lib/estudio/ritmo.js +77 -0
- package/lib/estudio/sons-leves.js +62 -0
- package/lib/estudio/vox.js +385 -0
- package/lib/fala.js +1 -94
- package/lib/pipeline.js +55 -56
- package/lib/repeticao.js +3 -1
- package/lib/skills.js +8 -2
- package/lib/snapshot.js +111 -0
- package/lib/tools.js +79 -20
- package/lib/ui.js +13 -6
- package/lib/video/analise.js +89 -0
- package/lib/video/cli.js +74 -0
- package/lib/video/delta.js +90 -0
- package/lib/video/edits.js +379 -0
- package/lib/video/filters.js +247 -0
- package/lib/video/ops.js +322 -0
- package/lib/video/projeto.js +133 -0
- package/lib/video/render.js +221 -0
- package/lib/video/texto.js +142 -0
- package/lib/video/timeline.js +310 -0
- package/lib/video/transcript.js +124 -0
- package/lib/voz.js +75 -223
- package/package.json +2 -3
- package/skills/motion-de-primeira/SKILL.md +21 -1
- package/studio/texto.py +208 -0
- package/voz/primo_voz/__main__.py +96 -236
- package/voz/primo_voz/transcrever.py +31 -1
- package/app/ansi.js +0 -55
- package/app/app.css +0 -500
- package/app/app.js +0 -1017
- package/app/icone.svg +0 -6
- package/app/index.html +0 -220
- package/app/primo-marca.js +0 -192
- package/lib/app.js +0 -615
- package/lib/icone.js +0 -263
- package/lib/instalar-app.js +0 -231
- package/lib/painel.js +0 -291
- package/voz/primo_voz/desperta.py +0 -195
- package/voz/primo_voz/orbe.py +0 -406
package/lib/estudio/remotion.js
CHANGED
|
@@ -97,6 +97,8 @@ import {
|
|
|
97
97
|
AbsoluteFill, Img, Video, Audio, Sequence, useCurrentFrame,
|
|
98
98
|
useVideoConfig, interpolate, staticFile, continueRender, delayRender,
|
|
99
99
|
} from 'remotion';
|
|
100
|
+
import { morfar, ehCor, misturarCor } from './morfar';
|
|
101
|
+
import { KIT, CURVAS_KIT, Filtros } from './kit';
|
|
100
102
|
|
|
101
103
|
/* ── SUAVIZAÇÃO ──────────────────────────────────────────────────────────
|
|
102
104
|
* Cada curva é uma função de 0..1 em 0..1. "suave" é o padrão porque
|
|
@@ -116,6 +118,8 @@ const CURVAS = {
|
|
|
116
118
|
if (t < 2.5 / d) return n * (t -= 2.25 / d) * t + 0.9375;
|
|
117
119
|
return n * (t -= 2.625 / d) * t + 0.984375;
|
|
118
120
|
},
|
|
121
|
+
// editorial, camera, arrasto, acelera (kit/base.jsx): a curva certa do guia VOX.
|
|
122
|
+
...CURVAS_KIT,
|
|
119
123
|
};
|
|
120
124
|
|
|
121
125
|
/* O valor de uma propriedade animada no segundo "t".
|
|
@@ -133,7 +137,14 @@ function valorEm(trilha, t, padrao) {
|
|
|
133
137
|
if (t >= a.t && t <= b.t) {
|
|
134
138
|
const bruto = b.t === a.t ? 1 : (t - a.t) / (b.t - a.t);
|
|
135
139
|
const curva = CURVAS[b.e || a.e || 'suave'] || CURVAS.suave;
|
|
136
|
-
|
|
140
|
+
const k = curva(bruto);
|
|
141
|
+
/* Texto no lugar do número: é COR ("#ff6a00") ou CAMINHO (o "d" de um
|
|
142
|
+
SVG). A cor mistura; o caminho se deforma (morfar.js): a bolinha
|
|
143
|
+
vira foguete sem corte. */
|
|
144
|
+
if (typeof a.v === 'string' || typeof b.v === 'string') {
|
|
145
|
+
return ehCor(a.v) && ehCor(b.v) ? misturarCor(a.v, b.v, k) : morfar(a.v, b.v, k);
|
|
146
|
+
}
|
|
147
|
+
return a.v + (b.v - a.v) * k;
|
|
137
148
|
}
|
|
138
149
|
}
|
|
139
150
|
return padrao;
|
|
@@ -153,12 +164,17 @@ function transformDe(a, base) {
|
|
|
153
164
|
if (x != null || y != null || z != null) {
|
|
154
165
|
partes.push('translate3d(' + (x || 0) + 'px,' + (y || 0) + 'px,' + (z || 0) + 'px)');
|
|
155
166
|
}
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
167
|
+
/* GIRA DEPOIS DE ENCOLHER. No CSS o último da lista age primeiro, então a
|
|
168
|
+
escala vai por último: o elemento encolhe e só então gira. Na ordem
|
|
169
|
+
inversa, uma janela de 1920 px girava no tamanho cheio e só depois
|
|
170
|
+
encolhia em X e Y — a profundidade não encolhe, e sobrava um risco de
|
|
171
|
+
1920 px atravessando a tela. */
|
|
159
172
|
if (a.rotacao != null) partes.push('rotate(' + a.rotacao + 'deg)');
|
|
160
173
|
if (a.rotacaoX != null) partes.push('rotateX(' + a.rotacaoX + 'deg)');
|
|
161
174
|
if (a.rotacaoY != null) partes.push('rotateY(' + a.rotacaoY + 'deg)');
|
|
175
|
+
if (a.escala != null) partes.push('scale(' + a.escala + ')');
|
|
176
|
+
if (a.escalaX != null) partes.push('scaleX(' + a.escalaX + ')');
|
|
177
|
+
if (a.escalaY != null) partes.push('scaleY(' + a.escalaY + ')');
|
|
162
178
|
return partes.join(' ');
|
|
163
179
|
}
|
|
164
180
|
|
|
@@ -204,7 +220,12 @@ function Elemento({ el, t, dados }) {
|
|
|
204
220
|
|
|
205
221
|
const tLocal = t - (el.de || 0);
|
|
206
222
|
const a = animado(el.anima, tLocal);
|
|
207
|
-
|
|
223
|
+
/* A PEÇA DO KIT desenha nas coordenadas da cena (x, y dela são o lugar do
|
|
224
|
+
assunto, não o canto de uma caixa): a caixa dela é o quadro inteiro, e o
|
|
225
|
+
"anima" do elemento move o conjunto. */
|
|
226
|
+
const base = el.tipo === 'kit'
|
|
227
|
+
? { position: 'absolute', left: 0, top: 0, width: '100%', height: '100%' }
|
|
228
|
+
: posicao(el);
|
|
208
229
|
const move = transformDe(a, {});
|
|
209
230
|
const estilo = {
|
|
210
231
|
...base,
|
|
@@ -222,7 +243,7 @@ function Elemento({ el, t, dados }) {
|
|
|
222
243
|
estilo.backgroundColor = el.vidro.cor || 'rgba(255,255,255,0.12)';
|
|
223
244
|
estilo.border = el.vidro.borda || '1px solid rgba(255,255,255,0.22)';
|
|
224
245
|
}
|
|
225
|
-
if (el.raio != null) estilo.borderRadius = medida(a.raio ?? el.raio);
|
|
246
|
+
if (el.raio != null || a.raio != null) estilo.borderRadius = medida(a.raio ?? el.raio);
|
|
226
247
|
if (el.sombra) estilo.boxShadow = el.sombra;
|
|
227
248
|
if (el.mascara) {
|
|
228
249
|
estilo.WebkitMaskImage = el.mascara;
|
|
@@ -298,18 +319,78 @@ function Elemento({ el, t, dados }) {
|
|
|
298
319
|
|
|
299
320
|
case 'forma': {
|
|
300
321
|
const forma = el.forma || 'retangulo';
|
|
322
|
+
/* O TRAÇO: qualquer forma, desenhada por um caminho SVG na caixa
|
|
323
|
+
"vista" (0 0 100 100 por padrão). "caminho" e "cor" animam, e é
|
|
324
|
+
isso que deixa uma forma virar outra no mesmo plano. */
|
|
325
|
+
if (forma === 'traco') {
|
|
326
|
+
const g = el.fundo && typeof el.fundo === 'object' && el.fundo.tipo === 'gradiente' ? el.fundo : null;
|
|
327
|
+
const gid = 'g' + String(el.id || JSON.stringify(g || '')).replace(/[^a-z0-9]/gi, '').slice(0, 24);
|
|
328
|
+
const ang = ((g && g.angulo) ?? 180) * Math.PI / 180;
|
|
329
|
+
/* Volume: o degradê RADIAL com o centro fora do meio é a luz batendo de
|
|
330
|
+
um lado (a bola vira esfera), e a sombra segue o CONTORNO da forma
|
|
331
|
+
(drop-shadow), não a caixa dela. */
|
|
332
|
+
const paradas = g ? g.cores.map((c, i) => <stop key={i} offset={i / Math.max(1, g.cores.length - 1)} stopColor={c} />) : null;
|
|
333
|
+
const sombra = el.sombra ? 'drop-shadow(' + el.sombra + ')' : '';
|
|
334
|
+
return (
|
|
335
|
+
<div style={{ ...estilo, width: medida(el.largura, '200px'), height: medida(el.altura, '200px'),
|
|
336
|
+
filter: [estilo.filter, sombra].filter(Boolean).join(' ') || undefined, boxShadow: undefined }}>
|
|
337
|
+
<svg viewBox={el.vista || '0 0 100 100'} width="100%" height="100%" style={{ overflow: 'visible', display: 'block' }}>
|
|
338
|
+
{g && (
|
|
339
|
+
<defs>
|
|
340
|
+
{g.radial
|
|
341
|
+
? <radialGradient id={gid} cx={(g.centro && g.centro[0]) ?? 0.35} cy={(g.centro && g.centro[1]) ?? 0.3} r={g.raio ?? 0.75}>{paradas}</radialGradient>
|
|
342
|
+
: <linearGradient id={gid} x1={0.5 - Math.sin(ang) / 2} y1={0.5 + Math.cos(ang) / 2} x2={0.5 + Math.sin(ang) / 2} y2={0.5 - Math.cos(ang) / 2}>{paradas}</linearGradient>}
|
|
343
|
+
</defs>
|
|
344
|
+
)}
|
|
345
|
+
<path d={a.caminho ?? el.caminho} fill={el.vazado ? 'none' : (g ? 'url(#' + gid + ')' : (a.cor ?? el.cor ?? '#fff'))}
|
|
346
|
+
stroke={el.linha || undefined} strokeWidth={el.espessura || undefined} strokeLinejoin="round" strokeLinecap="round" />
|
|
347
|
+
</svg>
|
|
348
|
+
</div>
|
|
349
|
+
);
|
|
350
|
+
}
|
|
301
351
|
return (
|
|
302
352
|
<div style={{
|
|
303
353
|
...estilo,
|
|
304
354
|
width: medida(el.largura, '200px'),
|
|
305
355
|
height: forma === 'linha' ? medida(el.espessura, '4px') : medida(el.altura, '200px'),
|
|
306
|
-
...(el.fundo && typeof el.fundo === 'object' ? fundoCSS(el.fundo) : { background: el.fundo
|
|
356
|
+
...(el.fundo && typeof el.fundo === 'object' ? fundoCSS(el.fundo) : { background: a.cor ?? el.fundo ?? el.cor ?? '#fff' }),
|
|
307
357
|
borderRadius: forma === 'circulo' ? '50%' : medida(a.raio ?? el.raio, '0px'),
|
|
308
358
|
border: el.contorno || undefined,
|
|
309
359
|
}} />
|
|
310
360
|
);
|
|
311
361
|
}
|
|
312
362
|
|
|
363
|
+
/* O GRUPO: uma tela dentro da tela. Os filhos vivem no tempo e no espaço
|
|
364
|
+
dele; com "recorte" o que passa da borda some (a tela do celular). O
|
|
365
|
+
grupo anima como qualquer elemento: cresce, sai do celular, cobre tudo. */
|
|
366
|
+
case 'kit': {
|
|
367
|
+
const Peca = KIT[el.peca];
|
|
368
|
+
if (!Peca) return null;
|
|
369
|
+
return (
|
|
370
|
+
<div style={{ ...estilo, transformOrigin: el.origem || ((el.x || 0) + 'px ' + (el.y || 0) + 'px') }}>
|
|
371
|
+
<Peca el={el} t={tLocal} fps={dados.fps} />
|
|
372
|
+
</div>
|
|
373
|
+
);
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
case 'grupo':
|
|
377
|
+
return (
|
|
378
|
+
<div style={{
|
|
379
|
+
...estilo,
|
|
380
|
+
width: medida(el.largura, '100%'), height: medida(el.altura, '100%'),
|
|
381
|
+
overflow: el.recorte ? 'hidden' : 'visible',
|
|
382
|
+
/* 3D: sem recorte, o grupo deixa os filhos no espaço da cena
|
|
383
|
+
(preserve-3d). Com recorte ele vira uma superfície plana, e a
|
|
384
|
+
"perspectiva" dele dá profundidade ao que está dentro. */
|
|
385
|
+
transformStyle: el.recorte ? undefined : 'preserve-3d',
|
|
386
|
+
perspective: el.perspectiva ? el.perspectiva + 'px' : undefined,
|
|
387
|
+
...(el.fundo ? (typeof el.fundo === 'object' ? fundoCSS(el.fundo) : { background: el.fundo }) : {}),
|
|
388
|
+
border: el.contorno || undefined,
|
|
389
|
+
}}>
|
|
390
|
+
{(el.elementos || []).map((f, i) => <Elemento key={i} el={f} t={tLocal} dados={dados} />)}
|
|
391
|
+
</div>
|
|
392
|
+
);
|
|
393
|
+
|
|
313
394
|
case 'imagem':
|
|
314
395
|
return (
|
|
315
396
|
<div style={{ ...estilo, overflow: 'hidden' }}>
|
|
@@ -445,10 +526,44 @@ export function Cena({ cena, dados, tFixo }) {
|
|
|
445
526
|
tx || ty ? 'translate(' + tx + 'px,' + ty + 'px)' : '',
|
|
446
527
|
cam.zoom != null || soco !== 1 ? 'scale(' + ((cam.zoom ?? 1) * soco) + ')' : '',
|
|
447
528
|
cam.rotacao != null || tr ? 'rotate(' + ((cam.rotacao || 0) + tr) + 'deg)' : '',
|
|
529
|
+
/* A CÂMERA 3D: ela gira em volta do mundo (rotacaoX/rotacaoY) e avança
|
|
530
|
+
nele (z). O que está mais perto anda mais que o que está longe — é a
|
|
531
|
+
profundidade de verdade, não um zoom. */
|
|
532
|
+
cam.z != null ? 'translateZ(' + cam.z + 'px)' : '',
|
|
533
|
+
cam.rotacaoX != null ? 'rotateX(' + cam.rotacaoX + 'deg)' : '',
|
|
534
|
+
cam.rotacaoY != null ? 'rotateY(' + cam.rotacaoY + 'deg)' : '',
|
|
448
535
|
].filter(Boolean).join(' ');
|
|
449
536
|
|
|
537
|
+
/* A CÂMERA EM CAMADAS (o parallax do guia VOX): "camadas" são planos de
|
|
538
|
+
profundidade, e o que está longe anda menos que o que está perto. O
|
|
539
|
+
parallax é CONSEQUÊNCIA da câmera, não um efeito à parte: o plano do
|
|
540
|
+
assunto ("sujeito") recebe a câmera exata, os outros na proporção da
|
|
541
|
+
profundidade deles — e cada um com o desfoque da lente naquela distância.
|
|
542
|
+
Cada elemento diz a camada dele ("camada": índice); sem, é a do sujeito. */
|
|
543
|
+
const camadas = Array.isArray(cena.camadas) && cena.camadas.length ? cena.camadas : null;
|
|
544
|
+
const iSujeito = camadas ? Math.max(0, camadas.findIndex((c) => c.sujeito)) : 0;
|
|
545
|
+
const mundo = (els, f, desfoque) => (
|
|
546
|
+
<AbsoluteFill style={{
|
|
547
|
+
transform: f == null ? (camTransform || undefined) : [
|
|
548
|
+
'translate(' + (-(cam.x || 0) * f + tx) + 'px,' + (-(cam.y || 0) * f + ty) + 'px)',
|
|
549
|
+
'scale(' + ((1 + ((cam.zoom ?? 1) - 1) * f) * soco) + ')',
|
|
550
|
+
'rotate(' + ((cam.rotacao || 0) * f + tr) + 'deg)',
|
|
551
|
+
].join(' '),
|
|
552
|
+
transformOrigin: cena.cameraOrigem || 'center center',
|
|
553
|
+
// O mundo guarda o 3D dos filhos; a perspectiva vem do pai.
|
|
554
|
+
transformStyle: cena.perspectiva ? 'preserve-3d' : undefined,
|
|
555
|
+
filter: desfoque ? 'blur(' + desfoque + 'px)' : undefined,
|
|
556
|
+
}}>
|
|
557
|
+
{els.map((el, i) => <Elemento key={i} el={el} t={t} dados={dados} />)}
|
|
558
|
+
</AbsoluteFill>
|
|
559
|
+
);
|
|
560
|
+
|
|
450
561
|
return (
|
|
451
|
-
|
|
562
|
+
/* A perspectiva mora AQUI, um nível acima do mundo: se ficasse no mesmo
|
|
563
|
+
elemento da câmera, girar a câmera sairia achatado. */
|
|
564
|
+
<AbsoluteFill style={{ ...fundoCSS(cena.fundo), overflow: 'hidden',
|
|
565
|
+
perspective: cena.perspectiva ? cena.perspectiva + 'px' : undefined }}>
|
|
566
|
+
<Filtros />
|
|
452
567
|
{cena.fundo && cena.fundo.tipo === 'imagem' && (
|
|
453
568
|
<Img src={staticFile(cena.fundo.src)} style={{
|
|
454
569
|
position: 'absolute', inset: 0, width: '100%', height: '100%',
|
|
@@ -461,18 +576,13 @@ export function Cena({ cena, dados, tFixo }) {
|
|
|
461
576
|
objectFit: cena.fundo.ajuste || 'cover',
|
|
462
577
|
}} />
|
|
463
578
|
)}
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
}}>
|
|
472
|
-
{(cena.elementos || []).map((el, i) => (
|
|
473
|
-
<Elemento key={i} el={el} t={t} dados={dados} />
|
|
474
|
-
))}
|
|
475
|
-
</AbsoluteFill>
|
|
579
|
+
{camadas
|
|
580
|
+
? camadas.map((c, ci) => (
|
|
581
|
+
<React.Fragment key={ci}>
|
|
582
|
+
{mundo((cena.elementos || []).filter((el) => (el.camada ?? iSujeito) === ci), (c.p || 1) / (camadas[iSujeito].p || 1), c.desfoque)}
|
|
583
|
+
</React.Fragment>
|
|
584
|
+
))
|
|
585
|
+
: mundo(cena.elementos || [], null, 0)}
|
|
476
586
|
{flash > 0.01 && <AbsoluteFill style={{ backgroundColor: cena.corFlash || '#fff', opacity: flash * 0.85 }} />}
|
|
477
587
|
</AbsoluteFill>
|
|
478
588
|
);
|
|
@@ -482,6 +592,7 @@ export function Cena({ cena, dados, tFixo }) {
|
|
|
482
592
|
const RAIZ_JSX = String.raw`import React from 'react';
|
|
483
593
|
import { AbsoluteFill, Audio, Sequence, staticFile, useCurrentFrame, useVideoConfig, interpolate } from 'remotion';
|
|
484
594
|
import { Cena } from './Cena';
|
|
595
|
+
import { Acabamento, Hud, bolhaCaminho } from './kit';
|
|
485
596
|
import plano from './plano.json';
|
|
486
597
|
import icones from './icones.json';
|
|
487
598
|
|
|
@@ -490,6 +601,7 @@ import icones from './icones.json';
|
|
|
490
601
|
* o "piscar" de fundo que denuncia corte mal feito. */
|
|
491
602
|
function Transicao({ tipo, progresso, children }) {
|
|
492
603
|
const p = progresso;
|
|
604
|
+
const { width: W, height: H } = useVideoConfig();
|
|
493
605
|
let estilo = {};
|
|
494
606
|
if (tipo === 'esmaecer') estilo = { opacity: p };
|
|
495
607
|
else if (tipo === 'deslizar') estilo = { transform: 'translateX(' + (1 - p) * 100 + '%)' };
|
|
@@ -502,6 +614,12 @@ function Transicao({ tipo, progresso, children }) {
|
|
|
502
614
|
// O SOCO: a cena entra grande e bate no lugar.
|
|
503
615
|
else if (tipo === 'soco') estilo = { transform: 'scale(' + (1.35 - 0.35 * p) + ')', opacity: Math.min(1, p * 3) };
|
|
504
616
|
else if (tipo === 'flash') estilo = { opacity: Math.min(1, p * 2), filter: 'brightness(' + (1 + (1 - p) * 3) + ')' };
|
|
617
|
+
/* O LÍQUIDO: a cena nova entra como uma mancha que cresce de um canto e
|
|
618
|
+
engole a anterior, com a borda viva (os lóbulos respiram enquanto crescem). */
|
|
619
|
+
else if (tipo === 'liquido' && p < 1) {
|
|
620
|
+
const k = p < 0.5 ? 4 * p * p * p : 1 - Math.pow(-2 * p + 2, 3) / 2;
|
|
621
|
+
estilo = { clipPath: "path('" + bolhaCaminho(W * 0.78, H * 0.62, Math.hypot(W, H) * 0.95 * k, p * 3, 2) + "')" };
|
|
622
|
+
}
|
|
505
623
|
return <AbsoluteFill style={estilo}>{children}</AbsoluteFill>;
|
|
506
624
|
}
|
|
507
625
|
|
|
@@ -521,7 +639,7 @@ export const Paginas = () => {
|
|
|
521
639
|
};
|
|
522
640
|
|
|
523
641
|
export const Video = () => {
|
|
524
|
-
const { fps } = useVideoConfig();
|
|
642
|
+
const { fps, width: largura, height: altura } = useVideoConfig();
|
|
525
643
|
const frame = useCurrentFrame();
|
|
526
644
|
const dados = { fps, icones };
|
|
527
645
|
|
|
@@ -562,31 +680,17 @@ export const Video = () => {
|
|
|
562
680
|
);
|
|
563
681
|
})}
|
|
564
682
|
|
|
565
|
-
{/* O ACABAMENTO: granulação de filme, vinheta e a luz da marca passando
|
|
566
|
-
É o que tira a cara de apresentação
|
|
567
|
-
{plano.acabamento
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
<
|
|
575
|
-
|
|
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
|
-
)}
|
|
683
|
+
{/* O ACABAMENTO: granulação de filme, vinheta e a luz da marca passando
|
|
684
|
+
(kit/base.jsx). É o que tira a cara de apresentação. */}
|
|
685
|
+
<Acabamento ac={plano.acabamento} frame={frame} />
|
|
686
|
+
{/* O HUD do motion designer: cantos, timecode, fps e a seção da cena. */}
|
|
687
|
+
{plano.hud && (() => {
|
|
688
|
+
const atual = pedacos.filter((q) => frame >= q.de).pop() || pedacos[0];
|
|
689
|
+
const total = pedacos.length ? pedacos[pedacos.length - 1].de + pedacos[pedacos.length - 1].dur : 1;
|
|
690
|
+
// A cena diz se é escura (o HUD claro) ou clara (o HUD escuro).
|
|
691
|
+
const escuro = !(atual && atual.cena.escuro === false);
|
|
692
|
+
return <Hud hud={plano.hud} frame={frame} fps={fps} total={total} W={largura} H={altura} secao={atual && atual.cena.secao} escuro={escuro} />;
|
|
693
|
+
})()}
|
|
590
694
|
|
|
591
695
|
{plano.audio && plano.audio.narracao && (
|
|
592
696
|
<Audio src={staticFile(plano.audio.narracao)} volume={plano.audio.volumeNarracao ?? 1} />
|
|
@@ -729,6 +833,9 @@ function montar({ pasta, titulo, plano, icones = {} }) {
|
|
|
729
833
|
escrever(path.join(src, 'Root.jsx'), rootJsx(plano));
|
|
730
834
|
escrever(path.join(src, 'Video.jsx'), RAIZ_JSX);
|
|
731
835
|
escrever(path.join(src, 'Cena.jsx'), CENA_JSX);
|
|
836
|
+
escrever(path.join(src, 'morfar.js'), require('./morfar.js').fonteEsm());
|
|
837
|
+
// O kit de motion (kit.js): as peças que o elemento {"tipo":"kit"} monta.
|
|
838
|
+
for (const f of require('./kit.js').arquivos()) escrever(path.join(src, f.nome), f.conteudo);
|
|
732
839
|
escrever(path.join(src, 'plano.json'), JSON.stringify(plano, null, 2));
|
|
733
840
|
escrever(path.join(src, 'icones.json'), JSON.stringify(icones, null, 2));
|
|
734
841
|
escrever(path.join(src, 'fontes.css'), fontesCSS(plano));
|
|
@@ -755,16 +862,24 @@ function montar({ pasta, titulo, plano, icones = {} }) {
|
|
|
755
862
|
do usuário e roda com rede disponível no `npm install`; o Chromium do
|
|
756
863
|
Remotion baixa a fonte no primeiro quadro e o Remotion espera por ela. */
|
|
757
864
|
function fontesCSS(plano) {
|
|
865
|
+
/* Toda chave que começa com "fonte" (fonte, fonteMono, fonteDef…), em
|
|
866
|
+
qualquer profundidade: dentro de grupo, na legenda, nas peças do kit e
|
|
867
|
+
no HUD. Fonte que o plano usa e o CSS não declara sai em Times. */
|
|
758
868
|
const usadas = new Set();
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
|
|
869
|
+
const andar = (o) => {
|
|
870
|
+
if (!o || typeof o !== 'object') return;
|
|
871
|
+
if (Array.isArray(o)) return o.forEach(andar);
|
|
872
|
+
for (const [k, v] of Object.entries(o)) {
|
|
873
|
+
if (/^fonte/.test(k) && typeof v === 'string' && v.trim()) usadas.add(v.trim());
|
|
874
|
+
else if (v && typeof v === 'object') andar(v);
|
|
763
875
|
}
|
|
764
|
-
}
|
|
876
|
+
};
|
|
877
|
+
for (const cena of plano.cenas || []) andar(cena.elementos || []);
|
|
878
|
+
andar(plano.hud);
|
|
765
879
|
if (!usadas.size) return '/* nenhuma fonte externa neste vídeo */\n';
|
|
766
880
|
const familias = [...usadas]
|
|
767
|
-
|
|
881
|
+
// O Google devolve o que a família tem e ignora o resto: pedir todos os pesos e o itálico não quebra.
|
|
882
|
+
.map((f) => 'family=' + encodeURIComponent(f).replace(/%20/g, '+') + ':ital,wght@0,300;0,400;0,500;0,600;0,700;0,800;0,900;1,400;1,600')
|
|
768
883
|
.join('&');
|
|
769
884
|
return `@import url('https://fonts.googleapis.com/css2?${familias}&display=block');\n`;
|
|
770
885
|
}
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ritmo.js — o tempo de um vídeo feito de partes: onde cada uma corta, quando
|
|
3
|
+
* cada coisa entra, e a música que abaixa quando a voz fala.
|
|
4
|
+
*
|
|
5
|
+
* "O áudio comanda o quadro." — guia MOTION VOX STYLE
|
|
6
|
+
*
|
|
7
|
+
* Os estilos de cena por parte (vox.js, lancamento.js) dividem isto: o corte
|
|
8
|
+
* cai no beat (um pouco antes da primeira palavra da parte), cada elemento
|
|
9
|
+
* entra no instante em que a palavra dele é dita, e o volume da trilha segue
|
|
10
|
+
* a narração. Puro: tempos entram, números saem.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
'use strict';
|
|
14
|
+
|
|
15
|
+
const { quando } = require('./cinema.js');
|
|
16
|
+
|
|
17
|
+
const r2 = (n) => Math.round(n * 100) / 100;
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Onde cada parte começa e quanto dura.
|
|
21
|
+
*
|
|
22
|
+
* Com voz, a parte começa `antes` segundos antes da primeira palavra (o corte
|
|
23
|
+
* seco no beat) e vai até a próxima; a última ganha `cauda` de respiro. Sem
|
|
24
|
+
* voz, cada parte dura o que se leva para ler o que ela mostra.
|
|
25
|
+
*
|
|
26
|
+
* @returns {{ini:number[], dur:number[], total:number, temVoz:boolean}}
|
|
27
|
+
*/
|
|
28
|
+
function tempoDasPartes(partes, tempos = [], { semVoz = () => 2.6, antes = 0.12, cauda = 1.1, minimo = 1.1 } = {}) {
|
|
29
|
+
const temVoz = tempos.length === partes.length && tempos.every((t) => t && t.ate > t.de);
|
|
30
|
+
const ini = [];
|
|
31
|
+
if (temVoz) partes.forEach((_, i) => ini.push(i === 0 ? 0 : r2(Math.max(ini[i - 1] + minimo, tempos[i].de - antes))));
|
|
32
|
+
else partes.forEach((p, i) => ini.push(i === 0 ? 0 : r2(ini[i - 1] + Math.max(minimo, semVoz(partes[i - 1])))));
|
|
33
|
+
const total = r2(temVoz ? Math.max(ini[ini.length - 1] + minimo, tempos[tempos.length - 1].ate + cauda) : ini[ini.length - 1] + Math.max(minimo, semVoz(partes[partes.length - 1])) + 0.4);
|
|
34
|
+
const dur = ini.map((t, i) => r2((i < ini.length - 1 ? ini[i + 1] : total) - t));
|
|
35
|
+
return { ini, dur, total, temVoz };
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Quando cada texto entra, no tempo DA CENA: o instante em que a primeira
|
|
40
|
+
* palavra forte dele é dita. Sem voz (ou sem achar), espalha os textos pelo
|
|
41
|
+
* começo da cena, respeitando a ordem.
|
|
42
|
+
*/
|
|
43
|
+
function gatilhos(textos, tempo, inicio, dur, { t0 = 0.3, ate = 0.62 } = {}) {
|
|
44
|
+
const palavras = (tempo && tempo.palavras) || [];
|
|
45
|
+
const fim = Math.max(t0 + 0.2, dur * ate);
|
|
46
|
+
if (!palavras.length) return textos.map((_, i) => r2(t0 + (fim - t0) * (i / Math.max(1, textos.length))));
|
|
47
|
+
const abs = quando(textos, palavras, inicio + fim, inicio + t0);
|
|
48
|
+
// Nunca antes do corte, nunca tarde demais para ser lido, sempre em ordem.
|
|
49
|
+
let antes = 0;
|
|
50
|
+
return abs.map((t) => {
|
|
51
|
+
const rel = Math.min(Math.max(0.08, t - inicio), Math.max(0.1, dur - 0.9));
|
|
52
|
+
antes = Math.max(antes + 0.1, rel);
|
|
53
|
+
return r2(antes);
|
|
54
|
+
});
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** As palavras faladas numa parte, no tempo da cena: [{texto, t}]. */
|
|
58
|
+
function palavrasNaCena(tempo, inicio) {
|
|
59
|
+
return ((tempo && tempo.palavras) || []).map((w) => ({ texto: w.texto, t: r2(Math.max(0.05, w.de - inicio)) }));
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** O volume da trilha no tempo: cheia no respiro, baixa quando a voz fala, some no fim. */
|
|
63
|
+
function volumesDaTrilha(tempos, total, { alto = 0.22, baixo = 0.11 } = {}) {
|
|
64
|
+
const k = (t, v) => ({ t: r2(t), v });
|
|
65
|
+
const vs = [k(0, 0.05), k(0.4, alto)];
|
|
66
|
+
// Um quadro-chave de cada vez, sempre depois do anterior: fala colada na outra não volta no tempo.
|
|
67
|
+
const poe = (t, v) => vs.push(k(Math.max(vs[vs.length - 1].t + 0.01, t), v));
|
|
68
|
+
for (const tp of tempos || []) {
|
|
69
|
+
if (!tp || !(tp.ate > tp.de)) continue;
|
|
70
|
+
poe(tp.de - 0.15, alto); poe(tp.de, baixo); poe(tp.ate, baixo); poe(tp.ate + 0.35, alto);
|
|
71
|
+
}
|
|
72
|
+
poe(total - 0.6, alto * 0.9);
|
|
73
|
+
poe(total, 0);
|
|
74
|
+
return vs;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
module.exports = { tempoDasPartes, gatilhos, palavrasNaCena, volumesDaTrilha, r2 };
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* sons-leves.js — os efeitos do motion: leves, limpos, sintetizados.
|
|
3
|
+
*
|
|
4
|
+
* "Animações suaves, leves e limpas, coloca música, narração e efeitos
|
|
5
|
+
* sonoros." — Joel (28/09/2026)
|
|
6
|
+
*
|
|
7
|
+
* Os efeitos da edição (edicao.js SONS) são de corte de reels: whoosh cheio,
|
|
8
|
+
* estalo seco, impacto grave. Para o motion contínuo eles pesam. Estes são a
|
|
9
|
+
* versão leve: sopro em vez de whoosh, bolha em vez de estalo, um sino para o
|
|
10
|
+
* que nasce, um toque grave e macio para o que chega.
|
|
11
|
+
*
|
|
12
|
+
* Tudo sai do ffmpeg na hora (nada baixado, nada com licença de terceiros),
|
|
13
|
+
* uma vez por projeto.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
'use strict';
|
|
17
|
+
|
|
18
|
+
const fs = require('fs');
|
|
19
|
+
const path = require('path');
|
|
20
|
+
|
|
21
|
+
const EFEITOS = {
|
|
22
|
+
// Sopro: ruído rosa filtrado, entra e sai devagar. É o ar da passagem.
|
|
23
|
+
sopro: ['-f', 'lavfi', '-i', 'anoisesrc=d=0.9:c=pink:a=0.5', '-af',
|
|
24
|
+
'highpass=f=300,lowpass=f=2600,afade=t=in:st=0:d=0.45:curve=qsin,afade=t=out:st=0.45:d=0.45:curve=qsin,volume=0.7'],
|
|
25
|
+
// Bolha: um seno curto que sobe, sem ataque seco.
|
|
26
|
+
bolha: ['-f', 'lavfi', '-i', "aevalsrc='0.6*sin(2*PI*(520+900*t)*t)*exp(-26*t)*(1-exp(-400*t))':s=44100:d=0.22"],
|
|
27
|
+
// Clique: o tique limpo de um trackpad.
|
|
28
|
+
clique: ['-f', 'lavfi', '-i', "aevalsrc='0.5*sin(2*PI*2400*t)*exp(-90*t)+0.15*(random(0)*2-1)*exp(-160*t)':s=44100:d=0.08"],
|
|
29
|
+
// Brilho: dois sinos em quinta, decaindo — o que nasce.
|
|
30
|
+
brilho: ['-f', 'lavfi', '-i', "aevalsrc='0.32*sin(2*PI*880*t)*exp(-3.2*t)+0.22*sin(2*PI*1320*t)*exp(-4*t)+0.12*sin(2*PI*1760*t)*exp(-5*t)':s=44100:d=1.4"],
|
|
31
|
+
// Subida: um seno que cresce devagar — anuncia a transformação.
|
|
32
|
+
subida: ['-f', 'lavfi', '-i', "aevalsrc='0.28*sin(2*PI*(180+260*t*t)*t)*(t/1.4)':s=44100:d=1.4", '-af', 'afade=t=out:st=1.2:d=0.2'],
|
|
33
|
+
// Toque grave e macio: o que chega e assenta.
|
|
34
|
+
toque: ['-f', 'lavfi', '-i', "aevalsrc='0.7*sin(2*PI*(110-50*t)*t)*exp(-7*t)':s=44100:d=0.6", '-af', 'lowpass=f=900'],
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Gera os efeitos na pasta do projeto (midia/sfx-<nome>.mp3) e devolve
|
|
39
|
+
* { nome: caminho relativo }. O que já existe não é refeito; o que o ffmpeg
|
|
40
|
+
* não conseguiu gerar fica de fora (o vídeo sai sem aquele som, não quebra).
|
|
41
|
+
*
|
|
42
|
+
* `rodar(args)` é injetável: o padrão chama o ffmpeg por spawn síncrono.
|
|
43
|
+
*/
|
|
44
|
+
function preparar(pasta, { rodar = null } = {}) {
|
|
45
|
+
const exec = rodar || ((args) => {
|
|
46
|
+
try { require('child_process').execFileSync('ffmpeg', args, { stdio: 'ignore', timeout: 30000 }); return true; }
|
|
47
|
+
catch { return false; }
|
|
48
|
+
});
|
|
49
|
+
const sons = {};
|
|
50
|
+
for (const [nome, args] of Object.entries(EFEITOS)) {
|
|
51
|
+
const rel = `midia/sfx-${nome}.mp3`;
|
|
52
|
+
const cheio = path.join(pasta, rel);
|
|
53
|
+
if (!fs.existsSync(cheio)) {
|
|
54
|
+
fs.mkdirSync(path.dirname(cheio), { recursive: true });
|
|
55
|
+
if (!exec(['-y', '-loglevel', 'error', ...args, '-q:a', '3', cheio])) continue;
|
|
56
|
+
}
|
|
57
|
+
sons[nome] = rel;
|
|
58
|
+
}
|
|
59
|
+
return sons;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
module.exports = { EFEITOS, preparar };
|