primocode 9.9.0 → 9.10.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/estudio/ende.js +2 -0
- package/lib/estudio/index.js +38 -9
- package/lib/estudio/morfar.js +191 -0
- package/lib/estudio/motion.js +336 -0
- package/lib/estudio/plano.js +27 -5
- package/lib/estudio/remotion.js +81 -10
- package/lib/estudio/sons-leves.js +62 -0
- package/package.json +2 -2
- package/skills/motion-de-primeira/SKILL.md +21 -0
package/README.md
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
# PrimoCode v9.
|
|
1
|
+
# PrimoCode v9.10.0
|
|
2
2
|
|
|
3
3
|
Agente de engenharia brasileiro para o terminal, no estilo Claude Code.
|
|
4
4
|
Cria arquivos de verdade, roda comandos, **controla o navegador e o desktop** —
|
|
@@ -9,7 +9,7 @@ OpenRouter, e cada apelido (`top`/`main`/`fast`) é uma **corrente**: se um mode
|
|
|
9
9
|
está no limite de pedidos, o próximo atende. Você não vê o erro, e não paga nada.
|
|
10
10
|
|
|
11
11
|
```
|
|
12
|
-
▐▛███▜▌ Primo Code v9.
|
|
12
|
+
▐▛███▜▌ Primo Code v9.10.0
|
|
13
13
|
▝▜█████▛▘ Bem-vindo de volta, Joel
|
|
14
14
|
▘▘ ▝▝ ~/primocode · /dir <pasta> muda
|
|
15
15
|
```
|
package/lib/estudio/ende.js
CHANGED
|
@@ -38,6 +38,8 @@ const CLIMAS = {
|
|
|
38
38
|
viral: ['upbeat', 'beat', 'energetic', 'pop', 'electronic'],
|
|
39
39
|
impacto: ['epic', 'drums', 'trailer', 'energetic', 'beat'],
|
|
40
40
|
neutro: ['background', 'corporate', 'chilled', 'calm', 'ambient'],
|
|
41
|
+
// Produto e tecnologia: pulso de sintetizador, limpo, com impulso.
|
|
42
|
+
tecnologia: ['arpeggiator', 'analog synths', 'clockwork', 'rhythm', 'commercial', 'clean mix', 'crisp'],
|
|
41
43
|
};
|
|
42
44
|
|
|
43
45
|
const limpar = (s) => String(s || '').replace(/<[^>]*>/g, '').replace(/&/g, '&').replace(/�?39;/g, "'").replace(/\s+/g, ' ').trim();
|
package/lib/estudio/index.js
CHANGED
|
@@ -45,6 +45,9 @@ const composicao = require('./composicao.js');
|
|
|
45
45
|
const acervoSite = require('./acervo-site.js');
|
|
46
46
|
const impacto = require('./impacto.js');
|
|
47
47
|
const cinema = require('./cinema.js');
|
|
48
|
+
const motion = require('./motion.js');
|
|
49
|
+
const sonsLeves = require('./sons-leves.js');
|
|
50
|
+
const ende = require('./ende.js');
|
|
48
51
|
|
|
49
52
|
const PY = path.join(__dirname, '..', '..', 'studio');
|
|
50
53
|
|
|
@@ -242,6 +245,8 @@ async function garantirAcervo(p) {
|
|
|
242
245
|
/* O ROTEIRISTA do servidor: quando o modelo não manda um roteiro que preste
|
|
243
246
|
(nenhum, ou texto corrido), o pedido e os fatos da marca viram o roteiro
|
|
244
247
|
numa chamada só, com uma tarefa só. */
|
|
248
|
+
const PEDE_MOTION = /\bmotion\b|anima[çc][ãa]o|animad[oa]/i;
|
|
249
|
+
|
|
245
250
|
async function roteiroDoServidor(p, args) {
|
|
246
251
|
const cfg = require('../config').loadConfig();
|
|
247
252
|
const api = require('../api');
|
|
@@ -254,7 +259,8 @@ async function roteiroDoServidor(p, args) {
|
|
|
254
259
|
fotos: (acervo.fotos || []).length ? `${acervo.fotos.length} fotos reais` : '',
|
|
255
260
|
site: acervo.site ? new URL(acervo.site).hostname.replace(/^www\./, '') : '',
|
|
256
261
|
rascunho: typeof args.roteiro === 'string' ? args.roteiro : '',
|
|
257
|
-
|
|
262
|
+
// Pedido de motion nunca vira impacto, nem sendo anúncio.
|
|
263
|
+
estilo: args.estilo || (!PEDE_MOTION.test(pedido) && /an[uú]nci|reels?|divulg|promo|lan[çc]a|vend|campanha|convite|inscri/i.test(pedido) ? 'impacto' : undefined),
|
|
258
264
|
}, cfg.token);
|
|
259
265
|
if (!r || !r.ok) return { ok: false, error: (r && r.erro) || 'o roteirista não respondeu.' };
|
|
260
266
|
return { ok: true, roteiro: { ...r.roteiro, marca: (args.roteiro && args.roteiro.marca) || p.marca || undefined } };
|
|
@@ -916,11 +922,20 @@ async function prepararRoteiro(p, args) {
|
|
|
916
922
|
if (roteiro.marca.logo && !existe(roteiro.marca.logo)) roteiro.marca.logo = (p.marca && existe(p.marca.logo)) ? p.marca.logo : undefined;
|
|
917
923
|
} else if (p.marca) roteiro.marca = p.marca;
|
|
918
924
|
const partes = roteiro.partes || [];
|
|
919
|
-
/* O ESTILO
|
|
920
|
-
|
|
921
|
-
|
|
922
|
-
|
|
923
|
-
|
|
925
|
+
/* O ESTILO. O padrão é MOTION (motion.js): um plano contínuo em 3D, com
|
|
926
|
+
um protagonista que se deforma de parte em parte — "isso sim é motion"
|
|
927
|
+
(Joel, 28/09). "limpo" era o nome antigo do padrão e vira motion
|
|
928
|
+
também. Anúncio, reels e venda vão para o "impacto" (corte a cada
|
|
929
|
+
batida), a menos que o pedido diga motion ou animação. A composição
|
|
930
|
+
antiga, uma cena por parte, só com estilo: "composicao". */
|
|
931
|
+
const pedidoTxt = `${p.pedido || ''} ${args.pedido || ''}`;
|
|
932
|
+
// O estilo que o AGENTE pediu vale; o que o roteirista pôs no roteiro não
|
|
933
|
+
// passa por cima de um pedido de motion.
|
|
934
|
+
const pedido0 = args.estilo || (PEDE_MOTION.test(pedidoTxt) ? null : roteiro.estilo);
|
|
935
|
+
const estilo = pedido0 === 'limpo' ? 'motion' : (pedido0
|
|
936
|
+
|| (PEDE_MOTION.test(pedidoTxt) ? 'motion'
|
|
937
|
+
: /an[uú]nci|reels?|divulg|promo|lan[çc]a|vend|campanha|convite|inscri/i.test(pedidoTxt) ? 'impacto' : 'motion'));
|
|
938
|
+
const ehMotion = estilo === 'motion';
|
|
924
939
|
const modo = (args.modo || roteiro.modo || (p.formato === 'slides' || p.formato === 'arte' ? 'slides' : 'video'));
|
|
925
940
|
const [largura, altura] = PROPORCOES[roteiro.proporcao || args.proporcao] || (modo === 'slides' ? PROPORCOES['16:9'] : PROPORCOES['16:9']);
|
|
926
941
|
|
|
@@ -1131,8 +1146,17 @@ async function prepararRoteiro(p, args) {
|
|
|
1131
1146
|
tempos = v.partes;
|
|
1132
1147
|
narracao = path.relative(p.pasta, mp3).replace(/\\/g, '/');
|
|
1133
1148
|
projeto.anotar(p.id, 'narracao', `${v.segundos}s em ${partes.length} partes, voz ${v.voz}`);
|
|
1134
|
-
|
|
1135
|
-
|
|
1149
|
+
// O motion usa os efeitos leves; os estilos de corte, os cheios.
|
|
1150
|
+
sons = ehMotion ? sonsLeves.preparar(p.pasta) : await edicao.prepararSons(p.pasta, video);
|
|
1151
|
+
// A trilha do motion: ende.app (CC BY 4.0, sem Content ID), clima tecnologia.
|
|
1152
|
+
if (ehMotion) {
|
|
1153
|
+
try {
|
|
1154
|
+
const segundos = tempos.length ? tempos[tempos.length - 1].ate + 2 : 20;
|
|
1155
|
+
const m = await ende.provedor({ pasta: path.join(p.pasta, 'midia') }).gerar({ clima: roteiro.clima || 'tecnologia', segundos });
|
|
1156
|
+
if (m) { trilha = path.relative(p.pasta, m.src).replace(/\\/g, '/'); projeto.anotar(p.id, 'trilha', m.credito); }
|
|
1157
|
+
} catch { /* cai na busca de sempre */ }
|
|
1158
|
+
}
|
|
1159
|
+
if (!trilha) try {
|
|
1136
1160
|
const b = await assets.procurarMusica(roteiro.musica || args.trilha || (estilo === 'impacto' ? 'epic energetic' : 'inspiring corporate'), { quantas: 6, duracaoMin: 45 });
|
|
1137
1161
|
for (const item of (b.ok ? b.sons : [])) {
|
|
1138
1162
|
const d = await assets.baixar(item, { pasta: p.pasta, nome: 'trilha' });
|
|
@@ -1156,6 +1180,11 @@ async function prepararRoteiro(p, args) {
|
|
|
1156
1180
|
if (r.ok) projeto.anotar(p.id, 'plano', `estilo impacto: ${r.resumo.cenas} batidas`);
|
|
1157
1181
|
return r;
|
|
1158
1182
|
}
|
|
1183
|
+
if (ehMotion && modo === 'video') {
|
|
1184
|
+
const r = motion.montarMotion({ roteiro, largura, altura, tempos, narracao, sons, trilha, imagens });
|
|
1185
|
+
if (r.ok) projeto.anotar(p.id, 'plano', `estilo motion: ${r.resumo.segundos}s contínuos, ${r.resumo.partes} partes, ${r.resumo.fps} fps`);
|
|
1186
|
+
return r;
|
|
1187
|
+
}
|
|
1159
1188
|
return composicao.comporRoteiro({ roteiro, modo, largura, altura, tempos, narracao, sons, trilha, imagens, iconesOk, fundos });
|
|
1160
1189
|
}
|
|
1161
1190
|
|
|
@@ -1435,5 +1464,5 @@ async function ferramentaSite(args) {
|
|
|
1435
1464
|
module.exports = {
|
|
1436
1465
|
ferramentaProjeto, ferramentaPlano, ferramentaMidia, ferramentaNarrar,
|
|
1437
1466
|
ferramentaVideo, ferramentaRender, ferramentaSite,
|
|
1438
|
-
projeto, plano, remotion, assets, video, identidade, slides, python, rodarPython, alinharPartes,
|
|
1467
|
+
projeto, plano, remotion, assets, video, identidade, slides, python, rodarPython, alinharPartes, prepararRoteiro,
|
|
1439
1468
|
};
|
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* morfar.js — uma forma que vira outra, sem corte.
|
|
3
|
+
*
|
|
4
|
+
* "Uma bolinha viaja, aparece um fogo atrás, ela vai se deformando e se
|
|
5
|
+
* transforma no foguete." — Joel, sobre o que separa motion de slide
|
|
6
|
+
* (28/09/2026)
|
|
7
|
+
*
|
|
8
|
+
* O "morph" antigo era uma transição entre cenas (esmaece e desfoca). Isto é
|
|
9
|
+
* a deformação DE VERDADE: a `forma: "traco"` tem um `caminho` (o `d` de um
|
|
10
|
+
* SVG), e o `caminho` anima por quadro-chave como qualquer número. Entre dois
|
|
11
|
+
* caminhos, cada contorno é reamostrado no mesmo número de pontos, alinhado
|
|
12
|
+
* (mesmo sentido, mesmo ponto de partida) e interpolado ponto a ponto.
|
|
13
|
+
*
|
|
14
|
+
* O alinhamento é o que faz a bolinha "esticar" em foguete em vez de girar e
|
|
15
|
+
* se embolar no meio: sem ele, o ponto 0 de uma forma vai para o outro lado da
|
|
16
|
+
* outra.
|
|
17
|
+
*
|
|
18
|
+
* Aceita M, L, H, V, C, S, Q, T e Z (maiúsculo e minúsculo). Arco (A) não:
|
|
19
|
+
* escreva a curva com C. Só o primeiro contorno do caminho conta.
|
|
20
|
+
*
|
|
21
|
+
* Puro e sem dependência. O MESMO código vai para o projeto Remotion
|
|
22
|
+
* (fonteEsm), então o teste do Node prova o que o render desenha.
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
'use strict';
|
|
26
|
+
|
|
27
|
+
// ── embutido: daqui até o fim do bloco vai para src/morfar.js do Remotion ──
|
|
28
|
+
const PONTOS_PADRAO = 120;
|
|
29
|
+
const PASSOS_CURVA = 24;
|
|
30
|
+
const _memo = new Map();
|
|
31
|
+
|
|
32
|
+
function lerComandos(d) {
|
|
33
|
+
const partes = String(d || '').match(/[a-df-z]|-?(?:\d+\.?\d*|\.\d+)(?:e-?\d+)?/gi) || [];
|
|
34
|
+
const cmds = [];
|
|
35
|
+
let atual = null;
|
|
36
|
+
for (const p of partes) {
|
|
37
|
+
if (/[a-z]/i.test(p) && !/^e/i.test(p)) {
|
|
38
|
+
if (/a/i.test(p)) throw new Error('arco (A) não é aceito no caminho: desenhe a curva com C');
|
|
39
|
+
atual = { c: p, n: [] };
|
|
40
|
+
cmds.push(atual);
|
|
41
|
+
} else if (atual) atual.n.push(Number(p));
|
|
42
|
+
}
|
|
43
|
+
return cmds;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** O primeiro contorno do caminho, como uma linha de pontos. */
|
|
47
|
+
function contorno(d) {
|
|
48
|
+
const cmds = lerComandos(d);
|
|
49
|
+
const pts = [];
|
|
50
|
+
let x = 0, y = 0, x0 = 0, y0 = 0, cx = null, cy = null, qx = null, qy = null;
|
|
51
|
+
const curvaC = (x1, y1, x2, y2, x3, y3) => {
|
|
52
|
+
for (let i = 1; i <= PASSOS_CURVA; i++) {
|
|
53
|
+
const t = i / PASSOS_CURVA, u = 1 - t;
|
|
54
|
+
pts.push([u * u * u * x + 3 * u * u * t * x1 + 3 * u * t * t * x2 + t * t * t * x3,
|
|
55
|
+
u * u * u * y + 3 * u * u * t * y1 + 3 * u * t * t * y2 + t * t * t * y3]);
|
|
56
|
+
}
|
|
57
|
+
cx = x2; cy = y2; x = x3; y = y3;
|
|
58
|
+
};
|
|
59
|
+
const curvaQ = (x1, y1, x2, y2) => {
|
|
60
|
+
for (let i = 1; i <= PASSOS_CURVA; i++) {
|
|
61
|
+
const t = i / PASSOS_CURVA, u = 1 - t;
|
|
62
|
+
pts.push([u * u * x + 2 * u * t * x1 + t * t * x2, u * u * y + 2 * u * t * y1 + t * t * y2]);
|
|
63
|
+
}
|
|
64
|
+
qx = x1; qy = y1; x = x2; y = y2;
|
|
65
|
+
};
|
|
66
|
+
let fechou = false;
|
|
67
|
+
for (const { c, n } of cmds) {
|
|
68
|
+
if (fechou) break;
|
|
69
|
+
const rel = c === c.toLowerCase();
|
|
70
|
+
const C = c.toUpperCase();
|
|
71
|
+
const ox = () => (rel ? x : 0), oy = () => (rel ? y : 0);
|
|
72
|
+
if (C !== 'C' && C !== 'S') { cx = null; cy = null; }
|
|
73
|
+
if (C !== 'Q' && C !== 'T') { qx = null; qy = null; }
|
|
74
|
+
if (C === 'Z') { fechou = pts.length > 0; x = x0; y = y0; continue; }
|
|
75
|
+
const passo = { M: 2, L: 2, H: 1, V: 1, C: 6, S: 4, Q: 4, T: 2 }[C];
|
|
76
|
+
if (!passo) continue;
|
|
77
|
+
for (let i = 0; i + passo <= n.length; i += passo) {
|
|
78
|
+
const a = n.slice(i, i + passo);
|
|
79
|
+
if (C === 'M') {
|
|
80
|
+
if (i === 0 && pts.length) { fechou = true; break; }
|
|
81
|
+
x = a[0] + ox(); y = a[1] + oy();
|
|
82
|
+
if (i === 0) { x0 = x; y0 = y; }
|
|
83
|
+
pts.push([x, y]);
|
|
84
|
+
} else if (C === 'L') { x = a[0] + ox(); y = a[1] + oy(); pts.push([x, y]); }
|
|
85
|
+
else if (C === 'H') { x = a[0] + (rel ? x : 0); pts.push([x, y]); }
|
|
86
|
+
else if (C === 'V') { y = a[0] + (rel ? y : 0); pts.push([x, y]); }
|
|
87
|
+
else if (C === 'C') { const [bx, by] = [ox(), oy()]; curvaC(a[0] + bx, a[1] + by, a[2] + bx, a[3] + by, a[4] + bx, a[5] + by); }
|
|
88
|
+
else if (C === 'S') {
|
|
89
|
+
const [bx, by] = [ox(), oy()];
|
|
90
|
+
const x1 = cx == null ? x : 2 * x - cx, y1 = cy == null ? y : 2 * y - cy;
|
|
91
|
+
curvaC(x1, y1, a[0] + bx, a[1] + by, a[2] + bx, a[3] + by);
|
|
92
|
+
} else if (C === 'Q') { const [bx, by] = [ox(), oy()]; curvaQ(a[0] + bx, a[1] + by, a[2] + bx, a[3] + by); }
|
|
93
|
+
else if (C === 'T') {
|
|
94
|
+
const x1 = qx == null ? x : 2 * x - qx, y1 = qy == null ? y : 2 * y - qy;
|
|
95
|
+
curvaQ(x1, y1, a[0] + ox(), a[1] + oy());
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
if (pts.length < 2) throw new Error('o caminho precisa de pelo menos dois pontos');
|
|
100
|
+
return pts;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/** `n` pontos igualmente espaçados ao longo do contorno fechado. */
|
|
104
|
+
function reamostrar(pts, n) {
|
|
105
|
+
const fechado = [...pts, pts[0]];
|
|
106
|
+
const acum = [0];
|
|
107
|
+
for (let i = 1; i < fechado.length; i++) {
|
|
108
|
+
acum.push(acum[i - 1] + Math.hypot(fechado[i][0] - fechado[i - 1][0], fechado[i][1] - fechado[i - 1][1]));
|
|
109
|
+
}
|
|
110
|
+
const total = acum[acum.length - 1] || 1;
|
|
111
|
+
const saida = [];
|
|
112
|
+
let j = 0;
|
|
113
|
+
for (let k = 0; k < n; k++) {
|
|
114
|
+
const alvo = (k / n) * total;
|
|
115
|
+
while (j < acum.length - 2 && acum[j + 1] < alvo) j++;
|
|
116
|
+
const seg = acum[j + 1] - acum[j] || 1;
|
|
117
|
+
const t = (alvo - acum[j]) / seg;
|
|
118
|
+
saida.push([fechado[j][0] + (fechado[j + 1][0] - fechado[j][0]) * t, fechado[j][1] + (fechado[j + 1][1] - fechado[j][1]) * t]);
|
|
119
|
+
}
|
|
120
|
+
return saida;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
function area(pts) {
|
|
124
|
+
let s = 0;
|
|
125
|
+
for (let i = 0; i < pts.length; i++) { const a = pts[i], b = pts[(i + 1) % pts.length]; s += a[0] * b[1] - b[0] * a[1]; }
|
|
126
|
+
return s / 2;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
function pontos(d, n = PONTOS_PADRAO) {
|
|
130
|
+
const chave = n + '|' + d;
|
|
131
|
+
if (!_memo.has(chave)) _memo.set(chave, reamostrar(contorno(d), n));
|
|
132
|
+
return _memo.get(chave);
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/** Os dois contornos no mesmo sentido e com o ponto de partida que menos anda. */
|
|
136
|
+
function alinhados(dA, dB, n = PONTOS_PADRAO) {
|
|
137
|
+
const chave = 'al|' + n + '|' + dA + '|' + dB;
|
|
138
|
+
if (_memo.has(chave)) return _memo.get(chave);
|
|
139
|
+
const a = pontos(dA, n);
|
|
140
|
+
let b = pontos(dB, n);
|
|
141
|
+
if (Math.sign(area(a)) !== Math.sign(area(b))) b = [...b].reverse();
|
|
142
|
+
let melhor = 0, menor = Infinity;
|
|
143
|
+
for (let s = 0; s < n; s++) {
|
|
144
|
+
let soma = 0;
|
|
145
|
+
for (let i = 0; i < n && soma < menor; i++) {
|
|
146
|
+
const p = a[i], q = b[(i + s) % n];
|
|
147
|
+
soma += (p[0] - q[0]) ** 2 + (p[1] - q[1]) ** 2;
|
|
148
|
+
}
|
|
149
|
+
if (soma < menor) { menor = soma; melhor = s; }
|
|
150
|
+
}
|
|
151
|
+
const r = { a, b: b.map((_, i) => b[(i + melhor) % n]) };
|
|
152
|
+
_memo.set(chave, r);
|
|
153
|
+
return r;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
const f2 = (v) => String(Math.round(v * 100) / 100);
|
|
157
|
+
function caminhoDe(pts) {
|
|
158
|
+
return 'M ' + pts.map(([x, y]) => f2(x) + ' ' + f2(y)).join(' L ') + ' Z';
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/** O caminho no instante `k` (0 = dA, 1 = dB). */
|
|
162
|
+
function morfar(dA, dB, k, n = PONTOS_PADRAO) {
|
|
163
|
+
const { a, b } = alinhados(dA, dB, n);
|
|
164
|
+
const t = Math.max(0, Math.min(1, k));
|
|
165
|
+
return caminhoDe(a.map((p, i) => [p[0] + (b[i][0] - p[0]) * t, p[1] + (b[i][1] - p[1]) * t]));
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
const ehCor = (v) => typeof v === 'string' && /^#([0-9a-f]{3}|[0-9a-f]{6})$/i.test(v.trim());
|
|
169
|
+
function rgb(h) {
|
|
170
|
+
let s = h.trim().slice(1);
|
|
171
|
+
if (s.length === 3) s = s.split('').map((c) => c + c).join('');
|
|
172
|
+
return [0, 2, 4].map((i) => parseInt(s.slice(i, i + 2), 16));
|
|
173
|
+
}
|
|
174
|
+
function misturarCor(a, b, k) {
|
|
175
|
+
const x = rgb(a), y = rgb(b);
|
|
176
|
+
// A curva elástica passa de 1: a cor não pode passar de 255.
|
|
177
|
+
return '#' + x.map((v, i) => Math.max(0, Math.min(255, Math.round(v + (y[i] - v) * k))).toString(16).padStart(2, '0')).join('');
|
|
178
|
+
}
|
|
179
|
+
// ── fim do embutido ──
|
|
180
|
+
|
|
181
|
+
/** Este mesmo código como ES module, para o src/morfar.js do projeto Remotion. */
|
|
182
|
+
function fonteEsm() {
|
|
183
|
+
const src = require('fs').readFileSync(__filename, 'utf-8');
|
|
184
|
+
const ini = src.indexOf('// ── embutido');
|
|
185
|
+
const fim = src.indexOf('// ── fim do embutido');
|
|
186
|
+
return '/* Gerado de lib/estudio/morfar.js do PrimoCode — não edite aqui. */\n'
|
|
187
|
+
+ src.slice(ini, fim)
|
|
188
|
+
+ 'export { pontos, alinhados, caminhoDe, morfar, ehCor, misturarCor };\n';
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
module.exports = { pontos, alinhados, caminhoDe, morfar, ehCor, misturarCor, fonteEsm, PONTOS_PADRAO };
|
|
@@ -0,0 +1,336 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* motion.js — o roteiro vira UM plano contínuo, em 3D. Não slide.
|
|
3
|
+
*
|
|
4
|
+
* "Isso aí tá muito bom. Eu quero agora que os motion saiam desse estilo,
|
|
5
|
+
* as animações assim, motion assim mesmo. Isso sim é motion." — Joel,
|
|
6
|
+
* depois do exemplo da bolinha (28/09/2026)
|
|
7
|
+
*
|
|
8
|
+
* A composição (composicao.js) faz uma cena por parte: título, corta, lista,
|
|
9
|
+
* corta. Isso é slide, por mais que cada cena se mexa. Aqui existe um
|
|
10
|
+
* PROTAGONISTA — uma forma na cor da marca — que vive o vídeo inteiro e se
|
|
11
|
+
* deforma no que cada parte pede:
|
|
12
|
+
*
|
|
13
|
+
* abertura esfera que chega do fundo do espaço
|
|
14
|
+
* titulo pílula que carrega o título
|
|
15
|
+
* numero disco com o número contando
|
|
16
|
+
* lista pílula do título, e os itens saem dela em leque 3D
|
|
17
|
+
* passos idem, numerados
|
|
18
|
+
* contraste o "não" riscado cai, o "sim" sobe no lugar
|
|
19
|
+
* citacao cartão com a frase
|
|
20
|
+
* imagem moldura, e a foto aparece dentro dela
|
|
21
|
+
* cta o botão; o cursor entra girando e clica
|
|
22
|
+
*
|
|
23
|
+
* Uma cena só, 60 quadros por segundo. Cada parte entra antes de a anterior
|
|
24
|
+
* sair, a câmera orbita o mundo o tempo todo, partículas em três
|
|
25
|
+
* profundidades dão o espaço, e o protagonista tem espessura (uma cópia mais
|
|
26
|
+
* escura atrás). É a mesma gramática do exemplo docs/exemplos/motion-bolinha.
|
|
27
|
+
*
|
|
28
|
+
* Puro: recebe tempos, narração, trilha e sons já prontos; devolve o plano.
|
|
29
|
+
*/
|
|
30
|
+
|
|
31
|
+
'use strict';
|
|
32
|
+
|
|
33
|
+
const { caber, falaDe } = require('./composicao.js');
|
|
34
|
+
const { misturarCor, ehCor } = require('./morfar.js');
|
|
35
|
+
|
|
36
|
+
const k = (t, v, e) => (e ? { t, v, e } : { t, v });
|
|
37
|
+
const r2 = (n) => Math.round(n * 100) / 100;
|
|
38
|
+
const FPS = 60;
|
|
39
|
+
|
|
40
|
+
// ── o protagonista: caixa de 1400 px, formas em 0..100 ───────────────────
|
|
41
|
+
const CAIXA = 1400;
|
|
42
|
+
const U = CAIXA / 100; // px por unidade
|
|
43
|
+
const KAPPA = 0.5523;
|
|
44
|
+
/** Retângulo de cantos redondos em unidades (é também círculo e pílula). */
|
|
45
|
+
function forma(w, h, r = Math.min(w, h) / 2) {
|
|
46
|
+
const x0 = 50 - w / 2, y0 = 50 - h / 2, x1 = 50 + w / 2, y1 = 50 + h / 2;
|
|
47
|
+
const rr = Math.min(r, w / 2, h / 2), c = rr * KAPPA;
|
|
48
|
+
const f = (n) => r2(n);
|
|
49
|
+
return [
|
|
50
|
+
`M ${f(x0 + rr)} ${f(y0)}`, `L ${f(x1 - rr)} ${f(y0)}`,
|
|
51
|
+
`C ${f(x1 - rr + c)} ${f(y0)} ${f(x1)} ${f(y0 + rr - c)} ${f(x1)} ${f(y0 + rr)}`,
|
|
52
|
+
`L ${f(x1)} ${f(y1 - rr)}`,
|
|
53
|
+
`C ${f(x1)} ${f(y1 - rr + c)} ${f(x1 - rr + c)} ${f(y1)} ${f(x1 - rr)} ${f(y1)}`,
|
|
54
|
+
`L ${f(x0 + rr)} ${f(y1)}`,
|
|
55
|
+
`C ${f(x0 + rr - c)} ${f(y1)} ${f(x0)} ${f(y1 - rr + c)} ${f(x0)} ${f(y1 - rr)}`,
|
|
56
|
+
`L ${f(x0)} ${f(y0 + rr)}`,
|
|
57
|
+
`C ${f(x0)} ${f(y0 + rr - c)} ${f(x0 + rr - c)} ${f(y0)} ${f(x0 + rr)} ${f(y0)}`, 'Z',
|
|
58
|
+
].join(' ');
|
|
59
|
+
}
|
|
60
|
+
const px = (n) => n / U; // px → unidades da caixa
|
|
61
|
+
|
|
62
|
+
const numeroDe = (v) => {
|
|
63
|
+
const m = String(v == null ? '' : v).match(/^(\D*)([\d.,]+)(.*)$/);
|
|
64
|
+
if (!m) return null;
|
|
65
|
+
const n = Number(m[2].replace(/\./g, '').replace(',', '.'));
|
|
66
|
+
return Number.isFinite(n) ? { antes: m[1], ate: n, depois: m[3], casas: /,\d/.test(m[2]) ? 1 : 0 } : null;
|
|
67
|
+
};
|
|
68
|
+
const itensDe = (p) => (p.itens || []).map((i) => (typeof i === 'string' ? i : i.texto || i.titulo || '')).filter(Boolean);
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* @param {object} op
|
|
72
|
+
* @param {object} op.roteiro { marca, partes: [{tipo, titulo, sub, itens, valor, rotulo, sim, nao, link, autor, src}] }
|
|
73
|
+
* @param {Array} op.tempos [{de, ate, palavras}] por parte (da narração); sem ela, 2,4 s por parte
|
|
74
|
+
* @param {string} op.narracao o mp3 da narração inteira
|
|
75
|
+
* @param {object} op.sons { sopro, bolha, clique, brilho, subida, toque } (sons-leves.js)
|
|
76
|
+
* @param {string} op.trilha a música
|
|
77
|
+
* @param {object} op.imagens { indiceDaParte: {src, tipo} }
|
|
78
|
+
*/
|
|
79
|
+
function montarMotion({ roteiro, largura = 1920, altura = 1080, tempos = [], narracao = null, sons = {}, trilha = null, imagens = {} }) {
|
|
80
|
+
const partes = (roteiro && roteiro.partes) || [];
|
|
81
|
+
if (!partes.length) return { ok: false, error: 'o roteiro veio sem partes.' };
|
|
82
|
+
const W = largura, H = altura, deitado = W >= H;
|
|
83
|
+
const base = Math.min(W, H) / 1080;
|
|
84
|
+
|
|
85
|
+
// ── a marca ──────────────────────────────────────────────────────────
|
|
86
|
+
const marca = roteiro.marca || {};
|
|
87
|
+
const cores = marca.cores || {};
|
|
88
|
+
const DEST = ehCor(cores.destaque) ? cores.destaque : (marca.gradiente && ehCor(marca.gradiente[1]) ? marca.gradiente[1] : '#7c5cff');
|
|
89
|
+
const DEST2 = marca.gradiente && ehCor(marca.gradiente[0]) ? marca.gradiente[0] : misturarCor(DEST, '#ffffff', 0.35);
|
|
90
|
+
const FUNDO = ehCor(cores.fundo) ? cores.fundo : '#0a0a12';
|
|
91
|
+
const TINTA = ehCor(cores.tinta) ? cores.tinta : '#ffffff';
|
|
92
|
+
const F = { titulo: (marca.fontes && marca.fontes.titulo) || 'Plus Jakarta Sans', texto: (marca.fontes && marca.fontes.texto) || 'Plus Jakarta Sans' };
|
|
93
|
+
const LUZ = { tipo: 'gradiente', radial: true, centro: [0.32, 0.26], raio: 0.9, cores: [misturarCor(DEST, '#ffffff', 0.55), DEST, misturarCor(DEST, '#000000', 0.45)] };
|
|
94
|
+
const LADO = { tipo: 'gradiente', radial: true, centro: [0.32, 0.26], raio: 0.9, cores: [misturarCor(DEST, '#000000', 0.35), misturarCor(DEST, '#000000', 0.6)] };
|
|
95
|
+
|
|
96
|
+
// ── o tempo de cada parte (a narração manda) ────────────────────────
|
|
97
|
+
const temVoz = tempos.length === partes.length && tempos.every((t) => t && t.ate > t.de);
|
|
98
|
+
const ini = partes.map((_, i) => (temVoz ? (i === 0 ? 0 : Math.max(0, tempos[i].de - 0.25)) : i * 2.4));
|
|
99
|
+
const DUR = r2(temVoz ? tempos[tempos.length - 1].ate + 1.3 : partes.length * 2.4 + 1.0);
|
|
100
|
+
const fim = partes.map((_, i) => (i < partes.length - 1 ? ini[i + 1] : DUR));
|
|
101
|
+
|
|
102
|
+
const elementos = [];
|
|
103
|
+
const efeitos = [];
|
|
104
|
+
const som = (t, nome, volume) => { if (sons[nome]) efeitos.push({ t: r2(Math.max(0, t)), src: sons[nome], volume }); };
|
|
105
|
+
|
|
106
|
+
// Trilhas do protagonista, construídas parte a parte.
|
|
107
|
+
const trilhaForma = [], trilhaX = [], trilhaY = [], trilhaRot = [], trilhaEsc = [];
|
|
108
|
+
|
|
109
|
+
// Uma peça de conteúdo: entra do 3D (z, giro em X), sai recuando e desfocando.
|
|
110
|
+
const peca = (el, de, ate, { atraso = 0 } = {}) => {
|
|
111
|
+
const d0 = r2(de + atraso), vida = r2(Math.max(0.5, ate - d0));
|
|
112
|
+
const a = el.anima || {};
|
|
113
|
+
return {
|
|
114
|
+
...el, de: d0, ate: r2(d0 + vida),
|
|
115
|
+
anima: {
|
|
116
|
+
...a,
|
|
117
|
+
opacidade: [k(0, 0), k(0.3, 1, 'saida'), k(Math.max(0.35, vida - 0.3), 1), k(vida, 0, 'suave')],
|
|
118
|
+
// 70 px à frente do protagonista: quando ele gira, a lateral
|
|
119
|
+
// dele avança em z e, mais perto que isso, cobria o texto.
|
|
120
|
+
z: [k(0, 260), k(0.5, 70, 'saida'), k(Math.max(0.55, vida - 0.3), 70), k(vida, -260, 'suave')],
|
|
121
|
+
rotacaoX: a.rotacaoX || [k(0, 28), k(0.5, 0, 'saida')],
|
|
122
|
+
desfoque: [k(Math.max(0.35, vida - 0.3), 0), k(vida, 6, 'suave')],
|
|
123
|
+
},
|
|
124
|
+
};
|
|
125
|
+
};
|
|
126
|
+
// Texto que se revela palavra a palavra.
|
|
127
|
+
const texto = (t, x, y, { tamanho, peso = 800, cor = TINTA, fonte = F.titulo, alinhamento = 'center', largura: lg, ...resto }) => ({
|
|
128
|
+
tipo: 'texto', texto: t, x, y, ancora: 'centro', tamanho: Math.round(tamanho), peso, cor, fonte, alinhamento,
|
|
129
|
+
largura: lg, entrelinha: 1.08, revelar: String(t).split(' ').map((_, i) => r2(0.08 + i * 0.07)), ...resto,
|
|
130
|
+
});
|
|
131
|
+
const caixaTexto = (t, maxLarg, linhas, max) => {
|
|
132
|
+
const fit = caber(t, { largura: maxLarg, linhas, max: max * base, min: 22 * base, fonte: F.titulo });
|
|
133
|
+
const chars = String(t).length / Math.max(1, fit.linhas);
|
|
134
|
+
const larg = Math.min(maxLarg, chars * fit.tamanho * 0.56);
|
|
135
|
+
return { ...fit, larg, alt: fit.linhas * fit.tamanho * 1.1 };
|
|
136
|
+
};
|
|
137
|
+
|
|
138
|
+
const CX = W / 2, CY = H / 2;
|
|
139
|
+
let lado = 0;
|
|
140
|
+
partes.forEach((p, i) => {
|
|
141
|
+
const t0 = ini[i], t1 = fim[i];
|
|
142
|
+
const d = t1 - t0;
|
|
143
|
+
const oscila = (lado++ % 2 === 0 ? -1 : 1);
|
|
144
|
+
// O protagonista VIAJA: cada parte fica num lugar diferente da tela.
|
|
145
|
+
const ox = CX + oscila * (deitado ? 200 : 40) * base, oy = CY + (i % 3 - 1) * 60 * base;
|
|
146
|
+
const titulo = p.titulo || p.texto || '';
|
|
147
|
+
let fx, fy, fr, x = ox, y = oy; // forma do protagonista (unidades) e onde ele fica
|
|
148
|
+
// A saída da parte vai até 0,45 s depois do fim, e a primeira peça da
|
|
149
|
+
// próxima entra em 0,2 s: as duas se cruzam no ar.
|
|
150
|
+
const add = (el, atraso) => elementos.push(peca(el, t0, t1 + 0.45, { atraso }));
|
|
151
|
+
|
|
152
|
+
// Imagem sem mídia vira título: moldura vazia é pior que nenhuma.
|
|
153
|
+
const semMidia = p.tipo === 'imagem' && !(imagens[i] || p.src);
|
|
154
|
+
switch (semMidia ? 'titulo' : p.tipo) {
|
|
155
|
+
case 'abertura': {
|
|
156
|
+
fx = fy = px(170 * base); fr = fx / 2; y = oy - 90 * base;
|
|
157
|
+
const tt = caixaTexto(titulo, W * 0.8, 2, 120);
|
|
158
|
+
add(texto(titulo, CX, y + 230 * base, { tamanho: tt.tamanho, largura: W * 0.84 }), 0.35);
|
|
159
|
+
if (p.sub) add(texto(p.sub, CX, y + 230 * base + tt.alt / 2 + 70 * base, { tamanho: 40 * base, peso: 500, fonte: F.texto, cor: misturarCor(TINTA, FUNDO, 0.3) }), 0.6);
|
|
160
|
+
break;
|
|
161
|
+
}
|
|
162
|
+
case 'numero': {
|
|
163
|
+
const r = 230 * base;
|
|
164
|
+
fx = fy = px(r * 2); fr = fx / 2;
|
|
165
|
+
const n = numeroDe(p.valor);
|
|
166
|
+
const v = String(p.valor || '');
|
|
167
|
+
add({ ...texto(v, x, y, { tamanho: Math.min(170, 900 / Math.max(2, v.length)) * base }), revelar: undefined,
|
|
168
|
+
...(n ? { contar: { ate: n.ate, antes: n.antes, depois: n.depois, casas: n.casas, dur: Math.min(1.4, d * 0.5) } } : {}) }, 0.2);
|
|
169
|
+
if (p.rotulo || titulo) add(texto(p.rotulo || titulo, x, y + r + 80 * base, { tamanho: 48 * base, peso: 600, fonte: F.texto }), 0.5);
|
|
170
|
+
som(t0 + 0.2, 'brilho', 0.3);
|
|
171
|
+
break;
|
|
172
|
+
}
|
|
173
|
+
case 'lista': case 'passos': case 'grafico': {
|
|
174
|
+
const itens = itensDe(p).slice(0, 6);
|
|
175
|
+
const tt = caixaTexto(titulo || ' ', W * 0.62, 1, 76);
|
|
176
|
+
fx = px(tt.larg + 120 * base); fy = px(tt.alt + 70 * base); fr = fy / 2;
|
|
177
|
+
y = CY - (itens.length > 3 ? 250 : 190) * base;
|
|
178
|
+
if (titulo) add(texto(titulo, x, y, { tamanho: tt.tamanho, largura: tt.larg + 40 }), 0.2);
|
|
179
|
+
const porLinha = deitado ? Math.min(3, itens.length) : 1;
|
|
180
|
+
const cw = deitado ? Math.min(520, (W * 0.84) / porLinha - 30) * 1 : W * 0.8;
|
|
181
|
+
const ch = 150 * base;
|
|
182
|
+
itens.forEach((it, j) => {
|
|
183
|
+
const col = j % porLinha, lin = Math.floor(j / porLinha);
|
|
184
|
+
const n = Math.min(porLinha, itens.length - lin * porLinha);
|
|
185
|
+
const cx = CX + (col - (n - 1) / 2) * (cw + 30), cy = y + 190 * base + lin * (ch + 26 * base) + ch / 2;
|
|
186
|
+
const fit = caber(it, { largura: cw - 70, linhas: 2, max: 40 * base, min: 20 * base, fonte: F.texto });
|
|
187
|
+
const at = r2(0.55 + j * Math.min(0.22, (d - 1.2) / Math.max(1, itens.length)));
|
|
188
|
+
add({
|
|
189
|
+
tipo: 'grupo', x: cx, y: cy, largura: cw, altura: ch, ancora: 'centro', raio: 30,
|
|
190
|
+
fundo: misturarCor(FUNDO, '#ffffff', 0.08), contorno: `1px solid ${misturarCor(FUNDO, '#ffffff', 0.18)}`,
|
|
191
|
+
sombra: '0 24px 50px rgba(0,0,0,0.35)',
|
|
192
|
+
// Sai de dentro do protagonista e gira no eixo Y até o lugar.
|
|
193
|
+
anima: { x: [k(0, x - cx), k(0.55, 0, 'saida')], y: [k(0, y - cy), k(0.55, 0, 'saida')], rotacaoY: [k(0, (col - 1) * 55 || 40), k(0.6, 0, 'saida')],
|
|
194
|
+
escala: [k(0, 0.4), k(0.55, 1, 'saida')] },
|
|
195
|
+
elementos: [
|
|
196
|
+
...(p.tipo === 'passos' ? [{ tipo: 'texto', texto: String(j + 1), x: 46, y: ch / 2, ancora: 'centro', tamanho: 52 * base, peso: 800, fonte: F.titulo, cor: DEST }] : []),
|
|
197
|
+
{ tipo: 'texto', texto: it, x: p.tipo === 'passos' ? 88 : cw / 2, y: ch / 2, ancora: p.tipo === 'passos' ? 'centro-y' : 'centro',
|
|
198
|
+
largura: cw - (p.tipo === 'passos' ? 120 : 70), tamanho: fit.tamanho, peso: 600, fonte: F.texto, cor: TINTA,
|
|
199
|
+
alinhamento: p.tipo === 'passos' ? 'left' : 'center', entrelinha: 1.12 },
|
|
200
|
+
],
|
|
201
|
+
}, at);
|
|
202
|
+
som(t0 + at, 'bolha', 0.22);
|
|
203
|
+
});
|
|
204
|
+
break;
|
|
205
|
+
}
|
|
206
|
+
case 'contraste': {
|
|
207
|
+
const a = p.nao || '', b = p.sim || titulo;
|
|
208
|
+
const ta = caixaTexto(a || ' ', W * 0.7, 2, 90), tb = caixaTexto(b || ' ', W * 0.7, 2, 96);
|
|
209
|
+
fx = px(Math.max(ta.larg, tb.larg) + 150 * base); fy = px(Math.max(ta.alt, tb.alt) + 80 * base); fr = Math.min(fy / 2, px(60));
|
|
210
|
+
const meio = r2(d * 0.45);
|
|
211
|
+
if (a) elementos.push(peca(texto(a, x, y, { tamanho: ta.tamanho, riscado: true, opacidade: 0.75, largura: ta.larg + 60 }), t0, t0 + meio + 0.3, { atraso: 0.2 }));
|
|
212
|
+
if (b) elementos.push(peca(texto(b, x, y, { tamanho: tb.tamanho, largura: tb.larg + 60 }), t0 + meio, t1 + 0.45, { atraso: 0.1 }));
|
|
213
|
+
som(t0 + meio, 'sopro', 0.25);
|
|
214
|
+
break;
|
|
215
|
+
}
|
|
216
|
+
case 'citacao': {
|
|
217
|
+
const tt = caixaTexto(`“${titulo}”`, W * 0.66, 3, 70);
|
|
218
|
+
fx = px(tt.larg + 180 * base); fy = px(tt.alt + 150 * base); fr = px(56);
|
|
219
|
+
add(texto(`“${titulo}”`, x, y - (p.autor ? 22 : 0), { tamanho: tt.tamanho, italico: true, peso: 600, largura: tt.larg + 60 }), 0.2);
|
|
220
|
+
if (p.autor) add(texto(`— ${p.autor}`, x, y + tt.alt / 2 + 20 * base, { tamanho: 34 * base, peso: 500, fonte: F.texto, cor: misturarCor(TINTA, DEST, 0.4) }), 0.7);
|
|
221
|
+
break;
|
|
222
|
+
}
|
|
223
|
+
case 'imagem': {
|
|
224
|
+
const iw = deitado ? W * 0.56 : W * 0.84, ih = iw * 9 / 16;
|
|
225
|
+
fx = px(iw); fy = px(ih); fr = px(40);
|
|
226
|
+
y = oy - (titulo ? 40 * base : 0);
|
|
227
|
+
const m = imagens[i] || (p.src ? { src: p.src, tipo: 'imagem' } : null);
|
|
228
|
+
if (m) {
|
|
229
|
+
add({ tipo: 'grupo', x, y, largura: iw, altura: ih, ancora: 'centro', raio: 40, recorte: true,
|
|
230
|
+
elementos: [{ tipo: m.tipo === 'video' ? 'video' : 'imagem', src: m.src, x: 0, y: 0, largura: '100%', altura: '100%', ajuste: 'cover',
|
|
231
|
+
...(m.tipo === 'video' ? { volume: 0 } : {}), anima: { escala: [k(0, 1.12), k(d + 0.4, 1.0, 'suave')] } }] }, 0.2);
|
|
232
|
+
}
|
|
233
|
+
if (titulo) add(texto(titulo, x, y + ih / 2 + 70 * base, { tamanho: 50 * base, peso: 700, largura: iw }), 0.6);
|
|
234
|
+
break;
|
|
235
|
+
}
|
|
236
|
+
case 'cta': {
|
|
237
|
+
const tt = caixaTexto(titulo || ' ', W * 0.6, 1, 64);
|
|
238
|
+
fx = px(tt.larg + 160 * base); fy = px(tt.alt + 70 * base); fr = fy / 2;
|
|
239
|
+
y = oy + 40 * base;
|
|
240
|
+
if (marca.logo) add({ tipo: 'imagem', src: marca.logo, x, y: y - 230 * base, largura: 140 * base, altura: 140 * base, ancora: 'centro', ajuste: 'contain' }, 0.2);
|
|
241
|
+
if (titulo) add(texto(titulo, x, y, { tamanho: tt.tamanho, largura: tt.larg + 40 }), 0.2);
|
|
242
|
+
if (p.link) add(texto(p.link, x, y + fy * U / 2 + 60 * base, { tamanho: 36 * base, peso: 600, fonte: F.texto, cor: misturarCor(TINTA, DEST, 0.35) }), 0.6);
|
|
243
|
+
// O cursor entra girando e clica no botão.
|
|
244
|
+
const clique = r2(Math.min(d - 0.4, 1.5));
|
|
245
|
+
elementos.push({
|
|
246
|
+
tipo: 'forma', forma: 'traco', caminho: 'M 10 4 L 10 84 L 31 64 L 45 94 L 58 88 L 44 59 L 74 59 Z', x: 0, y: 0, largura: 56 * base, altura: 56 * base,
|
|
247
|
+
cor: '#ffffff', linha: '#14151c', espessura: 4, sombra: '0 10px 12px rgba(0,0,0,0.4)', origem: '10% 4%', de: r2(t0 + 0.4), ate: r2(t1),
|
|
248
|
+
anima: {
|
|
249
|
+
x: [k(0, W * 0.92), k(clique - 0.4, x + fx * U * 0.22, 'saida')], y: [k(0, H * 0.95), k(clique - 0.4, y + 6, 'saida')],
|
|
250
|
+
z: [k(0, 300), k(clique - 0.4, 40, 'saida')], rotacao: [k(0, 150), k(clique - 0.4, 0, 'saida')],
|
|
251
|
+
escala: [k(clique - 0.45, 1), k(clique - 0.4 + 0.08, 0.84, 'suave'), k(clique - 0.4 + 0.26, 1, 'suave')],
|
|
252
|
+
opacidade: [k(0, 0), k(0.2, 1)],
|
|
253
|
+
},
|
|
254
|
+
});
|
|
255
|
+
som(t0 + clique, 'clique', 0.55);
|
|
256
|
+
break;
|
|
257
|
+
}
|
|
258
|
+
default: { // titulo e o que mais vier
|
|
259
|
+
const tt = caixaTexto(titulo || ' ', W * 0.7, 2, 104);
|
|
260
|
+
fx = px(tt.larg + 150 * base); fy = px(tt.alt + 80 * base); fr = Math.min(fy / 2, px(90));
|
|
261
|
+
add(texto(titulo, x, y, { tamanho: tt.tamanho, largura: tt.larg + 60 }), 0.2);
|
|
262
|
+
if (p.sub) add(texto(p.sub, x, y + fy * U / 2 + 58 * base, { tamanho: 38 * base, peso: 500, fonte: F.texto, cor: misturarCor(TINTA, FUNDO, 0.3) }), 0.55);
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
// O protagonista: deforma no começo da parte, anda até o lugar dela.
|
|
267
|
+
const alvo = forma(Math.min(96, fx), Math.min(96, fy), fr);
|
|
268
|
+
const tIni = r2(Math.max(0, t0 - 0.05)), tFim = r2(t0 + 0.55);
|
|
269
|
+
if (i === 0) trilhaForma.push(k(0, alvo)); else trilhaForma.push(k(tIni, trilhaForma[trilhaForma.length - 1].v), k(tFim, alvo, 'suave'));
|
|
270
|
+
if (i === 0) { trilhaX.push(k(0, x)); trilhaY.push(k(0, y)); } else { trilhaX.push(k(tIni, trilhaX[trilhaX.length - 1].v), k(tFim, x, 'suave')); trilhaY.push(k(tIni, trilhaY[trilhaY.length - 1].v), k(tFim, y, 'suave')); }
|
|
271
|
+
trilhaRot.push(k(r2(t0 + d * 0.5), oscila * 3, 'suave'));
|
|
272
|
+
trilhaEsc.push(k(tIni, 1), k(r2(t0 + 0.3), 1.04, 'suave'), k(tFim, 1, 'suave'));
|
|
273
|
+
if (i > 0) { som(t0, 'sopro', 0.28); som(t0 + 0.05, 'subida', 0.14); }
|
|
274
|
+
});
|
|
275
|
+
|
|
276
|
+
// ── o protagonista, com espessura ────────────────────────────────────
|
|
277
|
+
const protagonista = (fundo, z, extra = {}) => ({
|
|
278
|
+
tipo: 'forma', forma: 'traco', caminho: trilhaForma[0].v, x: 0, y: 0, largura: CAIXA, altura: CAIXA, ancora: 'centro', fundo, ...extra,
|
|
279
|
+
anima: {
|
|
280
|
+
caminho: trilhaForma, x: trilhaX, y: trilhaY, rotacaoY: [k(0, 40), k(0.9, 0, 'saida'), ...trilhaRot.filter((q) => q.t > 0.9)],
|
|
281
|
+
escala: [k(0, 0.2), k(0.8, 1, 'saida'), ...trilhaEsc.filter((q) => q.t > 0.8)],
|
|
282
|
+
z: [k(0, -1400 + z), k(0.8, z, 'saida')],
|
|
283
|
+
},
|
|
284
|
+
});
|
|
285
|
+
const corpo = [protagonista(LADO, -18), protagonista(LUZ, 0, { sombra: '0 30px 40px rgba(0,0,0,0.45)' })];
|
|
286
|
+
|
|
287
|
+
// ── o espaço: partículas em três profundidades ──────────────────────
|
|
288
|
+
const particulas = [];
|
|
289
|
+
for (let i = 0; i < 40; i++) {
|
|
290
|
+
const z = [-1600, -900, -400][i % 3];
|
|
291
|
+
const s = [6, 5, 4][i % 3];
|
|
292
|
+
particulas.push({ tipo: 'forma', forma: 'circulo', x: -900 + (i * 541) % (W + 1800), y: -600 + (i * 313) % (H + 1200), largura: s, altura: s,
|
|
293
|
+
cor: i % 4 === 0 ? DEST : '#ffffff', opacidade: 0.18 + (i % 4) * 0.1,
|
|
294
|
+
anima: { z: [k(0, z)], y: [k(0, 0), k(DUR, -40 - (i % 3) * 30, 'linear')] } });
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
// ── a câmera orbita: um giro por parte, e nunca para ────────────────
|
|
298
|
+
const cam = { rotacaoY: [k(0, -14)], rotacaoX: [k(0, 7)], z: [k(0, -120)] };
|
|
299
|
+
partes.forEach((_, i) => {
|
|
300
|
+
const meio = r2((ini[i] + fim[i]) / 2);
|
|
301
|
+
cam.rotacaoY.push(k(meio, i % 2 ? 7 : -7, 'suave'));
|
|
302
|
+
cam.rotacaoX.push(k(meio, i % 2 ? -3 : 3, 'suave'));
|
|
303
|
+
cam.z.push(k(meio, 60, 'suave'), k(r2(fim[i]), 0, 'suave'));
|
|
304
|
+
});
|
|
305
|
+
cam.rotacaoY.push(k(DUR, 0, 'suave'));
|
|
306
|
+
cam.rotacaoX.push(k(DUR, 0, 'suave'));
|
|
307
|
+
|
|
308
|
+
// ── o som: a música abaixa quando a voz fala ─────────────────────────
|
|
309
|
+
const volumes = [k(0, 0.05), k(0.4, 0.22)];
|
|
310
|
+
if (temVoz) {
|
|
311
|
+
for (const tp of tempos) volumes.push(k(r2(Math.max(0.41, tp.de - 0.15)), 0.22), k(r2(tp.de), 0.11), k(r2(tp.ate), 0.11), k(r2(tp.ate + 0.35), 0.22));
|
|
312
|
+
}
|
|
313
|
+
volumes.push(k(r2(DUR - 0.6), 0.2), k(DUR, 0));
|
|
314
|
+
som(0.05, 'sopro', 0.3);
|
|
315
|
+
som(DUR - 1.2, 'brilho', 0.35);
|
|
316
|
+
|
|
317
|
+
const plano = {
|
|
318
|
+
formato: { largura: W, altura: H, fps: FPS },
|
|
319
|
+
fundoGeral: FUNDO,
|
|
320
|
+
cenas: [{
|
|
321
|
+
duracao: DUR,
|
|
322
|
+
perspectiva: 1300,
|
|
323
|
+
fundo: { tipo: 'gradiente', radial: true, centro: '38% 42%', cores: [misturarCor(FUNDO, DEST, 0.18), FUNDO] },
|
|
324
|
+
camera: cam,
|
|
325
|
+
elementos: [...particulas, ...corpo, ...elementos],
|
|
326
|
+
}],
|
|
327
|
+
audio: {
|
|
328
|
+
...(narracao ? { narracao } : {}),
|
|
329
|
+
...(trilha ? { trilha: { src: trilha, volumes, loop: true } } : {}),
|
|
330
|
+
efeitos,
|
|
331
|
+
},
|
|
332
|
+
};
|
|
333
|
+
return { ok: true, plano, resumo: { cenas: 1, segundos: DUR, partes: partes.length, fps: FPS } };
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
module.exports = { montarMotion, forma, CAIXA };
|
package/lib/estudio/plano.js
CHANGED
|
@@ -47,8 +47,9 @@
|
|
|
47
47
|
|
|
48
48
|
const fs = require('fs');
|
|
49
49
|
const path = require('path');
|
|
50
|
+
const morfar = require('./morfar.js');
|
|
50
51
|
|
|
51
|
-
const TIPOS = new Set(['texto', 'forma', 'imagem', 'video', 'icone', 'legenda', 'componente']);
|
|
52
|
+
const TIPOS = new Set(['texto', 'forma', 'imagem', 'video', 'icone', 'legenda', 'componente', 'grupo']);
|
|
52
53
|
const FORMAS = new Set(['retangulo', 'circulo', 'linha', 'poligono', 'traco']);
|
|
53
54
|
const AJUSTES = new Set(['cover', 'contain', 'preencher']);
|
|
54
55
|
const TRANSICOES = new Set(['corte', 'esmaecer', 'deslizar', 'zoom', 'giro', 'mascara', 'morph', 'chicote', 'soco', 'flash']);
|
|
@@ -61,7 +62,11 @@ const ANIMAVEIS = new Set([
|
|
|
61
62
|
'opacidade', 'x', 'y', 'z', 'escala', 'escalaX', 'escalaY',
|
|
62
63
|
'rotacao', 'rotacaoX', 'rotacaoY', 'desfoque', 'brilho', 'saturacao',
|
|
63
64
|
'corte', 'raio', 'espacamento',
|
|
65
|
+
// Estes dois não são número: "cor" é "#rrggbb" e "caminho" é o "d" de um
|
|
66
|
+
// SVG. É o que deixa a bolinha virar foguete no mesmo plano (morfar.js).
|
|
67
|
+
'cor', 'caminho',
|
|
64
68
|
]);
|
|
69
|
+
const TEXTUAIS = new Set(['cor', 'caminho']);
|
|
65
70
|
|
|
66
71
|
/* A CÂMERA tem vocabulário próprio, e é de propósito.
|
|
67
72
|
*
|
|
@@ -70,7 +75,8 @@ const ANIMAVEIS = new Set([
|
|
|
70
75
|
* agente escrever zoom de câmera dentro de um elemento — o que dá quase o
|
|
71
76
|
* mesmo resultado na cena de uma coisa só, e nenhum resultado na cena com
|
|
72
77
|
* cinco. Vocabulários separados tornam o erro impossível em vez de sutil. */
|
|
73
|
-
|
|
78
|
+
// rotacaoX/rotacaoY/z: a câmera gira em volta do mundo e avança nele (precisa de `perspectiva` na cena).
|
|
79
|
+
const ANIMAVEIS_CAMERA = new Set(['zoom', 'x', 'y', 'rotacao', 'rotacaoX', 'rotacaoY', 'z']);
|
|
74
80
|
|
|
75
81
|
const SUAVIZACOES = new Set(['linear', 'suave', 'entrada', 'saida', 'elastico', 'salto']);
|
|
76
82
|
|
|
@@ -92,7 +98,7 @@ function medida(v) {
|
|
|
92
98
|
* maior faz o elemento voltar no tempo, e o que se vê é um tremor que
|
|
93
99
|
* ninguém consegue explicar olhando o resultado.
|
|
94
100
|
*/
|
|
95
|
-
function conferirTrilha(caminho, trilha, problemas) {
|
|
101
|
+
function conferirTrilha(caminho, trilha, problemas, prop) {
|
|
96
102
|
if (!Array.isArray(trilha)) {
|
|
97
103
|
problemas.push(erro(caminho, 'a animação de uma propriedade é uma lista de quadros-chave'));
|
|
98
104
|
return;
|
|
@@ -104,7 +110,11 @@ function conferirTrilha(caminho, trilha, problemas) {
|
|
|
104
110
|
if (typeof q.t !== 'number' || !Number.isFinite(q.t) || q.t < 0) {
|
|
105
111
|
problemas.push(erro(onde, 't é o tempo em segundos dentro da cena, a partir de 0'));
|
|
106
112
|
}
|
|
107
|
-
if (
|
|
113
|
+
if (prop === 'cor') {
|
|
114
|
+
if (!morfar.ehCor(q.v)) problemas.push(erro(onde, 'v da cor é "#rrggbb" (ou "#rgb")'));
|
|
115
|
+
} else if (prop === 'caminho') {
|
|
116
|
+
try { morfar.pontos(q.v, 16); } catch (e) { problemas.push(erro(onde, `caminho inválido: ${e.message}`)); }
|
|
117
|
+
} else if (typeof q.v !== 'number' || !Number.isFinite(q.v)) {
|
|
108
118
|
problemas.push(erro(onde, 'v é o valor numérico da propriedade nesse instante'));
|
|
109
119
|
}
|
|
110
120
|
if (q.e && !SUAVIZACOES.has(q.e)) {
|
|
@@ -126,7 +136,7 @@ function conferirAnima(caminho, anima, problemas, permitidas = ANIMAVEIS) {
|
|
|
126
136
|
`não dá para animar "${prop}" aqui. Dá para: ${[...permitidas].join(', ')}`));
|
|
127
137
|
continue;
|
|
128
138
|
}
|
|
129
|
-
conferirTrilha(`${caminho}.${prop}`, trilha, problemas);
|
|
139
|
+
conferirTrilha(`${caminho}.${prop}`, trilha, problemas, prop);
|
|
130
140
|
}
|
|
131
141
|
}
|
|
132
142
|
|
|
@@ -193,7 +203,19 @@ function conferirElemento(caminho, el, pasta, problemas, duracao) {
|
|
|
193
203
|
if (el.forma && !FORMAS.has(el.forma)) {
|
|
194
204
|
problemas.push(erro(`${caminho}.forma`, `forma "${el.forma}" não existe — ${[...FORMAS].join(', ')}`));
|
|
195
205
|
}
|
|
206
|
+
if (el.forma === 'traco') {
|
|
207
|
+
const d = el.caminho || (el.anima && el.anima.caminho && el.anima.caminho[0] && el.anima.caminho[0].v);
|
|
208
|
+
if (!d) problemas.push(erro(`${caminho}.caminho`, 'o traço precisa de "caminho": o "d" de um SVG, na caixa 0 0 100 100'));
|
|
209
|
+
else { try { morfar.pontos(d, 16); } catch (e) { problemas.push(erro(`${caminho}.caminho`, e.message)); } }
|
|
210
|
+
}
|
|
211
|
+
break;
|
|
212
|
+
case 'grupo': {
|
|
213
|
+
// Os filhos vivem no tempo do grupo: o 0 deles é o "de" do grupo.
|
|
214
|
+
if (!Array.isArray(el.elementos)) { problemas.push(erro(`${caminho}.elementos`, 'o grupo precisa da lista de elementos dele')); break; }
|
|
215
|
+
const vida = el.ate != null ? Number(el.ate) - Number(el.de || 0) : duracao - Number(el.de || 0);
|
|
216
|
+
el.elementos.forEach((f, k) => conferirElemento(`${caminho}.elementos[${k}]`, f, pasta, problemas, vida));
|
|
196
217
|
break;
|
|
218
|
+
}
|
|
197
219
|
case 'imagem':
|
|
198
220
|
case 'video':
|
|
199
221
|
conferirArquivo(`${caminho}.src`, el.src, pasta, problemas);
|
package/lib/estudio/remotion.js
CHANGED
|
@@ -97,6 +97,7 @@ 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';
|
|
100
101
|
|
|
101
102
|
/* ── SUAVIZAÇÃO ──────────────────────────────────────────────────────────
|
|
102
103
|
* Cada curva é uma função de 0..1 em 0..1. "suave" é o padrão porque
|
|
@@ -133,7 +134,14 @@ function valorEm(trilha, t, padrao) {
|
|
|
133
134
|
if (t >= a.t && t <= b.t) {
|
|
134
135
|
const bruto = b.t === a.t ? 1 : (t - a.t) / (b.t - a.t);
|
|
135
136
|
const curva = CURVAS[b.e || a.e || 'suave'] || CURVAS.suave;
|
|
136
|
-
|
|
137
|
+
const k = curva(bruto);
|
|
138
|
+
/* Texto no lugar do número: é COR ("#ff6a00") ou CAMINHO (o "d" de um
|
|
139
|
+
SVG). A cor mistura; o caminho se deforma (morfar.js): a bolinha
|
|
140
|
+
vira foguete sem corte. */
|
|
141
|
+
if (typeof a.v === 'string' || typeof b.v === 'string') {
|
|
142
|
+
return ehCor(a.v) && ehCor(b.v) ? misturarCor(a.v, b.v, k) : morfar(a.v, b.v, k);
|
|
143
|
+
}
|
|
144
|
+
return a.v + (b.v - a.v) * k;
|
|
137
145
|
}
|
|
138
146
|
}
|
|
139
147
|
return padrao;
|
|
@@ -153,12 +161,17 @@ function transformDe(a, base) {
|
|
|
153
161
|
if (x != null || y != null || z != null) {
|
|
154
162
|
partes.push('translate3d(' + (x || 0) + 'px,' + (y || 0) + 'px,' + (z || 0) + 'px)');
|
|
155
163
|
}
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
164
|
+
/* GIRA DEPOIS DE ENCOLHER. No CSS o último da lista age primeiro, então a
|
|
165
|
+
escala vai por último: o elemento encolhe e só então gira. Na ordem
|
|
166
|
+
inversa, uma janela de 1920 px girava no tamanho cheio e só depois
|
|
167
|
+
encolhia em X e Y — a profundidade não encolhe, e sobrava um risco de
|
|
168
|
+
1920 px atravessando a tela. */
|
|
159
169
|
if (a.rotacao != null) partes.push('rotate(' + a.rotacao + 'deg)');
|
|
160
170
|
if (a.rotacaoX != null) partes.push('rotateX(' + a.rotacaoX + 'deg)');
|
|
161
171
|
if (a.rotacaoY != null) partes.push('rotateY(' + a.rotacaoY + 'deg)');
|
|
172
|
+
if (a.escala != null) partes.push('scale(' + a.escala + ')');
|
|
173
|
+
if (a.escalaX != null) partes.push('scaleX(' + a.escalaX + ')');
|
|
174
|
+
if (a.escalaY != null) partes.push('scaleY(' + a.escalaY + ')');
|
|
162
175
|
return partes.join(' ');
|
|
163
176
|
}
|
|
164
177
|
|
|
@@ -222,7 +235,7 @@ function Elemento({ el, t, dados }) {
|
|
|
222
235
|
estilo.backgroundColor = el.vidro.cor || 'rgba(255,255,255,0.12)';
|
|
223
236
|
estilo.border = el.vidro.borda || '1px solid rgba(255,255,255,0.22)';
|
|
224
237
|
}
|
|
225
|
-
if (el.raio != null) estilo.borderRadius = medida(a.raio ?? el.raio);
|
|
238
|
+
if (el.raio != null || a.raio != null) estilo.borderRadius = medida(a.raio ?? el.raio);
|
|
226
239
|
if (el.sombra) estilo.boxShadow = el.sombra;
|
|
227
240
|
if (el.mascara) {
|
|
228
241
|
estilo.WebkitMaskImage = el.mascara;
|
|
@@ -298,18 +311,68 @@ function Elemento({ el, t, dados }) {
|
|
|
298
311
|
|
|
299
312
|
case 'forma': {
|
|
300
313
|
const forma = el.forma || 'retangulo';
|
|
314
|
+
/* O TRAÇO: qualquer forma, desenhada por um caminho SVG na caixa
|
|
315
|
+
"vista" (0 0 100 100 por padrão). "caminho" e "cor" animam, e é
|
|
316
|
+
isso que deixa uma forma virar outra no mesmo plano. */
|
|
317
|
+
if (forma === 'traco') {
|
|
318
|
+
const g = el.fundo && typeof el.fundo === 'object' && el.fundo.tipo === 'gradiente' ? el.fundo : null;
|
|
319
|
+
const gid = 'g' + String(el.id || JSON.stringify(g || '')).replace(/[^a-z0-9]/gi, '').slice(0, 24);
|
|
320
|
+
const ang = ((g && g.angulo) ?? 180) * Math.PI / 180;
|
|
321
|
+
/* Volume: o degradê RADIAL com o centro fora do meio é a luz batendo de
|
|
322
|
+
um lado (a bola vira esfera), e a sombra segue o CONTORNO da forma
|
|
323
|
+
(drop-shadow), não a caixa dela. */
|
|
324
|
+
const paradas = g ? g.cores.map((c, i) => <stop key={i} offset={i / Math.max(1, g.cores.length - 1)} stopColor={c} />) : null;
|
|
325
|
+
const sombra = el.sombra ? 'drop-shadow(' + el.sombra + ')' : '';
|
|
326
|
+
return (
|
|
327
|
+
<div style={{ ...estilo, width: medida(el.largura, '200px'), height: medida(el.altura, '200px'),
|
|
328
|
+
filter: [estilo.filter, sombra].filter(Boolean).join(' ') || undefined, boxShadow: undefined }}>
|
|
329
|
+
<svg viewBox={el.vista || '0 0 100 100'} width="100%" height="100%" style={{ overflow: 'visible', display: 'block' }}>
|
|
330
|
+
{g && (
|
|
331
|
+
<defs>
|
|
332
|
+
{g.radial
|
|
333
|
+
? <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>
|
|
334
|
+
: <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>}
|
|
335
|
+
</defs>
|
|
336
|
+
)}
|
|
337
|
+
<path d={a.caminho ?? el.caminho} fill={el.vazado ? 'none' : (g ? 'url(#' + gid + ')' : (a.cor ?? el.cor ?? '#fff'))}
|
|
338
|
+
stroke={el.linha || undefined} strokeWidth={el.espessura || undefined} strokeLinejoin="round" strokeLinecap="round" />
|
|
339
|
+
</svg>
|
|
340
|
+
</div>
|
|
341
|
+
);
|
|
342
|
+
}
|
|
301
343
|
return (
|
|
302
344
|
<div style={{
|
|
303
345
|
...estilo,
|
|
304
346
|
width: medida(el.largura, '200px'),
|
|
305
347
|
height: forma === 'linha' ? medida(el.espessura, '4px') : medida(el.altura, '200px'),
|
|
306
|
-
...(el.fundo && typeof el.fundo === 'object' ? fundoCSS(el.fundo) : { background: el.fundo
|
|
348
|
+
...(el.fundo && typeof el.fundo === 'object' ? fundoCSS(el.fundo) : { background: a.cor ?? el.fundo ?? el.cor ?? '#fff' }),
|
|
307
349
|
borderRadius: forma === 'circulo' ? '50%' : medida(a.raio ?? el.raio, '0px'),
|
|
308
350
|
border: el.contorno || undefined,
|
|
309
351
|
}} />
|
|
310
352
|
);
|
|
311
353
|
}
|
|
312
354
|
|
|
355
|
+
/* O GRUPO: uma tela dentro da tela. Os filhos vivem no tempo e no espaço
|
|
356
|
+
dele; com "recorte" o que passa da borda some (a tela do celular). O
|
|
357
|
+
grupo anima como qualquer elemento: cresce, sai do celular, cobre tudo. */
|
|
358
|
+
case 'grupo':
|
|
359
|
+
return (
|
|
360
|
+
<div style={{
|
|
361
|
+
...estilo,
|
|
362
|
+
width: medida(el.largura, '100%'), height: medida(el.altura, '100%'),
|
|
363
|
+
overflow: el.recorte ? 'hidden' : 'visible',
|
|
364
|
+
/* 3D: sem recorte, o grupo deixa os filhos no espaço da cena
|
|
365
|
+
(preserve-3d). Com recorte ele vira uma superfície plana, e a
|
|
366
|
+
"perspectiva" dele dá profundidade ao que está dentro. */
|
|
367
|
+
transformStyle: el.recorte ? undefined : 'preserve-3d',
|
|
368
|
+
perspective: el.perspectiva ? el.perspectiva + 'px' : undefined,
|
|
369
|
+
...(el.fundo ? (typeof el.fundo === 'object' ? fundoCSS(el.fundo) : { background: el.fundo }) : {}),
|
|
370
|
+
border: el.contorno || undefined,
|
|
371
|
+
}}>
|
|
372
|
+
{(el.elementos || []).map((f, i) => <Elemento key={i} el={f} t={tLocal} dados={dados} />)}
|
|
373
|
+
</div>
|
|
374
|
+
);
|
|
375
|
+
|
|
313
376
|
case 'imagem':
|
|
314
377
|
return (
|
|
315
378
|
<div style={{ ...estilo, overflow: 'hidden' }}>
|
|
@@ -445,10 +508,19 @@ export function Cena({ cena, dados, tFixo }) {
|
|
|
445
508
|
tx || ty ? 'translate(' + tx + 'px,' + ty + 'px)' : '',
|
|
446
509
|
cam.zoom != null || soco !== 1 ? 'scale(' + ((cam.zoom ?? 1) * soco) + ')' : '',
|
|
447
510
|
cam.rotacao != null || tr ? 'rotate(' + ((cam.rotacao || 0) + tr) + 'deg)' : '',
|
|
511
|
+
/* A CÂMERA 3D: ela gira em volta do mundo (rotacaoX/rotacaoY) e avança
|
|
512
|
+
nele (z). O que está mais perto anda mais que o que está longe — é a
|
|
513
|
+
profundidade de verdade, não um zoom. */
|
|
514
|
+
cam.z != null ? 'translateZ(' + cam.z + 'px)' : '',
|
|
515
|
+
cam.rotacaoX != null ? 'rotateX(' + cam.rotacaoX + 'deg)' : '',
|
|
516
|
+
cam.rotacaoY != null ? 'rotateY(' + cam.rotacaoY + 'deg)' : '',
|
|
448
517
|
].filter(Boolean).join(' ');
|
|
449
518
|
|
|
450
519
|
return (
|
|
451
|
-
|
|
520
|
+
/* A perspectiva mora AQUI, um nível acima do mundo: se ficasse no mesmo
|
|
521
|
+
elemento da câmera, girar a câmera sairia achatado. */
|
|
522
|
+
<AbsoluteFill style={{ ...fundoCSS(cena.fundo), overflow: 'hidden',
|
|
523
|
+
perspective: cena.perspectiva ? cena.perspectiva + 'px' : undefined }}>
|
|
452
524
|
{cena.fundo && cena.fundo.tipo === 'imagem' && (
|
|
453
525
|
<Img src={staticFile(cena.fundo.src)} style={{
|
|
454
526
|
position: 'absolute', inset: 0, width: '100%', height: '100%',
|
|
@@ -464,9 +536,7 @@ export function Cena({ cena, dados, tFixo }) {
|
|
|
464
536
|
<AbsoluteFill style={{
|
|
465
537
|
transform: camTransform || undefined,
|
|
466
538
|
transformOrigin: cena.cameraOrigem || 'center center',
|
|
467
|
-
//
|
|
468
|
-
// sem ela, girar em X só achata o elemento.
|
|
469
|
-
perspective: cena.perspectiva ? cena.perspectiva + 'px' : undefined,
|
|
539
|
+
// O mundo guarda o 3D dos filhos; a perspectiva vem do pai.
|
|
470
540
|
transformStyle: cena.perspectiva ? 'preserve-3d' : undefined,
|
|
471
541
|
}}>
|
|
472
542
|
{(cena.elementos || []).map((el, i) => (
|
|
@@ -729,6 +799,7 @@ function montar({ pasta, titulo, plano, icones = {} }) {
|
|
|
729
799
|
escrever(path.join(src, 'Root.jsx'), rootJsx(plano));
|
|
730
800
|
escrever(path.join(src, 'Video.jsx'), RAIZ_JSX);
|
|
731
801
|
escrever(path.join(src, 'Cena.jsx'), CENA_JSX);
|
|
802
|
+
escrever(path.join(src, 'morfar.js'), require('./morfar.js').fonteEsm());
|
|
732
803
|
escrever(path.join(src, 'plano.json'), JSON.stringify(plano, null, 2));
|
|
733
804
|
escrever(path.join(src, 'icones.json'), JSON.stringify(icones, null, 2));
|
|
734
805
|
escrever(path.join(src, 'fontes.css'), fontesCSS(plano));
|
|
@@ -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 };
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "primocode",
|
|
3
|
-
"version": "9.
|
|
3
|
+
"version": "9.10.0",
|
|
4
4
|
"description": "PrimoCode — agente de engenharia com IA e cursor próprio. Requer conta Conecta Primo AI (Premium ou Super). 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/entrada.test.js && node test/transparencia.test.js && node test/permissao.test.js && node test/compatibilidade.test.js && node test/skills.test.js && node test/skills-de-fora.test.js && node test/skills-novas.test.js && node test/comandos.test.js && node test/estudio.test.js && node test/voz.test.js && node test/voz-conversa.test.js && node test/modo.test.js && node test/pasta.test.js && node test/projeto.test.js && node test/nome-projeto.test.js && node test/api-propria.test.js && node test/claude-engine.test.js && node test/codex.test.js && node test/conferir.test.js && node test/catalogo.test.js && node test/desktop.test.js && node test/seta.test.js && node test/effort.test.js && node test/regressao.test.js && node test/memoria-conversa.test.js && node test/repeticao.test.js && node test/autonomia.test.js && node test/desfazer.test.js && node test/desfazer-git.test.js && node test/referencia.test.js && node test/parar.test.js && node test/continuar.test.js && node test/pipeline.test.js && node test/app.test.js && node test/app-janela.test.js && node test/aplicativo.test.js && node test/conta.test.mjs && node test/nuvem.test.js && node test/prazo.test.js && node test/primeira-vez.test.js && node test/janela.test.js && node test/fala.test.js && node test/especialistas.test.js && node test/empurrao.test.js && node test/terminal-novo.test.js && node test/markdown.test.js && node test/coerencia.test.js && node test/contexto.test.js && node test/perigo.test.js && node test/rapidez.test.js && node test/mascotes.test.js && node test/comandos-extra.test.js && node test/offline.test.js && node test/edicao.test.js && node test/composicao.test.js && node test/acervo-site.test.js && node test/impacto.test.js && node test/cinema.test.js && node test/producao.test.js && node test/ende.test.js && node test/capa.test.js && node test/broll.test.js"
|
|
11
|
+
"test": "node test/tools.test.js && node test/ui.test.js && node test/entrada.test.js && node test/transparencia.test.js && node test/permissao.test.js && node test/compatibilidade.test.js && node test/skills.test.js && node test/skills-de-fora.test.js && node test/skills-novas.test.js && node test/comandos.test.js && node test/estudio.test.js && node test/voz.test.js && node test/voz-conversa.test.js && node test/modo.test.js && node test/pasta.test.js && node test/projeto.test.js && node test/nome-projeto.test.js && node test/api-propria.test.js && node test/claude-engine.test.js && node test/codex.test.js && node test/conferir.test.js && node test/catalogo.test.js && node test/desktop.test.js && node test/seta.test.js && node test/effort.test.js && node test/regressao.test.js && node test/memoria-conversa.test.js && node test/repeticao.test.js && node test/autonomia.test.js && node test/desfazer.test.js && node test/desfazer-git.test.js && node test/referencia.test.js && node test/parar.test.js && node test/continuar.test.js && node test/pipeline.test.js && node test/app.test.js && node test/app-janela.test.js && node test/aplicativo.test.js && node test/conta.test.mjs && node test/nuvem.test.js && node test/prazo.test.js && node test/primeira-vez.test.js && node test/janela.test.js && node test/fala.test.js && node test/especialistas.test.js && node test/empurrao.test.js && node test/terminal-novo.test.js && node test/markdown.test.js && node test/coerencia.test.js && node test/contexto.test.js && node test/perigo.test.js && node test/rapidez.test.js && node test/mascotes.test.js && node test/comandos-extra.test.js && node test/offline.test.js && node test/edicao.test.js && node test/composicao.test.js && node test/acervo-site.test.js && node test/impacto.test.js && node test/cinema.test.js && node test/producao.test.js && node test/ende.test.js && node test/capa.test.js && node test/broll.test.js && node test/morfar.test.js && node test/motion.test.js && node test/motion-roteiro.test.js"
|
|
12
12
|
},
|
|
13
13
|
"engines": {
|
|
14
14
|
"node": ">=18.17.0"
|
|
@@ -42,6 +42,27 @@ a 4s. Fecho: 2s. Variar a duração é o que cria ritmo; repetir cria sono.
|
|
|
42
42
|
**Silêncio visual.** Um quadro com uma frase e mais nada vale mais que um com
|
|
43
43
|
seis elementos. Espaço vazio é composição, não desperdício.
|
|
44
44
|
|
|
45
|
+
## Motion é contínuo, não uma tela por ideia
|
|
46
|
+
|
|
47
|
+
Slide troca de tela. Motion é UM plano onde o mesmo objeto VIRA o próximo: a
|
|
48
|
+
bolinha acende um fogo, se deforma em foguete, mergulha no celular e vira o
|
|
49
|
+
ícone; o cursor gira e clica; o ícone salta e cobre a tela com o app dentro.
|
|
50
|
+
|
|
51
|
+
Em vídeo de produto, anúncio e vinheta, escreva **uma cena de 8 a 15 s** no
|
|
52
|
+
`estudio_plano`, não uma cena por frase:
|
|
53
|
+
|
|
54
|
+
- **`forma: "traco"`**: forma livre por `caminho` (`d` de SVG, caixa 0 0 100
|
|
55
|
+
100, M/L/C/Q/Z). `caminho` e `cor` animam:
|
|
56
|
+
`"caminho": [{"t":1.6,"v":BOLA},{"t":2.5,"v":FOGUETE,"e":"elastico"}]`.
|
|
57
|
+
- **`tipo: "grupo"`**: elementos que andam juntos, ou a tela dentro da tela
|
|
58
|
+
(`"recorte": true`). Filhos contam tempo e posição a partir do grupo.
|
|
59
|
+
- **A troca é no mesmo lugar e instante**: o foguete some onde o ícone nasce; o
|
|
60
|
+
app nasce do tamanho e da COR do ícone.
|
|
61
|
+
|
|
62
|
+
Espreme ao quicar e deixe a câmera andar até o fim.
|
|
63
|
+
|
|
64
|
+
Vídeo por roteiro já sai assim (estilo padrão `motion`); escreva à mão só o que o roteiro não descreve.
|
|
65
|
+
|
|
45
66
|
## Cor e tipo
|
|
46
67
|
|
|
47
68
|
Escolha a paleta a partir do ASSUNTO, e escreva os valores. Três a cinco
|