primocode 10.4.4 → 10.5.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/catalogo.js +7 -3
- package/lib/conta.js +9 -3
- package/lib/estudio/batida.js +95 -10
- package/lib/estudio/colecao.js +185 -0
- package/lib/estudio/dados/fluent-3d.json +1 -0
- package/lib/estudio/dados/noto-animado.json +1 -0
- package/lib/estudio/estilo.js +6 -2
- package/lib/estudio/gerativo.js +181 -29
- package/lib/estudio/index.js +97 -22
- package/lib/estudio/kit/animacao.jsx +41 -0
- package/lib/estudio/kit/gerativo.jsx +63 -5
- package/lib/estudio/kit/index.jsx +3 -1
- package/lib/estudio/kit/tres.jsx +156 -0
- package/lib/estudio/kit/voxel.jsx +609 -0
- package/lib/estudio/kit.js +5 -1
- package/lib/estudio/personagem.js +75 -4
- package/lib/estudio/remotion.js +2 -0
- package/package.json +2 -2
package/lib/estudio/index.js
CHANGED
|
@@ -66,6 +66,7 @@ const kokoro = require('./kokoro.js');
|
|
|
66
66
|
const lancamento = require('./lancamento.js');
|
|
67
67
|
const glifos = require('./glifos.js');
|
|
68
68
|
const personagem = require('./personagem.js');
|
|
69
|
+
const colecao = require('./colecao.js');
|
|
69
70
|
const { escolherEstilo, LEVES } = require('./estilo.js');
|
|
70
71
|
const sonsLeves = require('./sons-leves.js');
|
|
71
72
|
const ende = require('./ende.js');
|
|
@@ -518,6 +519,23 @@ async function ferramentaMidia(args) {
|
|
|
518
519
|
return r;
|
|
519
520
|
}
|
|
520
521
|
|
|
522
|
+
/* A BIBLIOTECA (colecao.js, 11/10): o cardápio inteiro, o ícone 3D e a
|
|
523
|
+
animação pronta. Procurar não usa rede (o índice mora no pacote); com
|
|
524
|
+
id, baixa para o projeto e devolve o elemento pronto. */
|
|
525
|
+
if (acao === 'biblioteca' || acao === 'cardapio' || acao === 'cardápio') return colecao.cardapio();
|
|
526
|
+
if (acao === 'icones3d' || acao === 'icone3d' || acao === 'animacoes' || acao === 'animacao' || acao === 'animação' || acao === 'animações') {
|
|
527
|
+
const ehAnim = /^anima/.test(acao);
|
|
528
|
+
if (args.id_item || /^(fluent3d|noto):/.test(String(args.item || args.baixar || ''))) {
|
|
529
|
+
const pj = p ? projeto.ler(p.id) : null;
|
|
530
|
+
const fmt = (pj && pj.formato) || {};
|
|
531
|
+
const r = await colecao.baixar(args.id_item || args.item || args.baixar, { pasta: p ? p.pasta : null, largura: fmt.largura || 1920, altura: fmt.altura || 1080 });
|
|
532
|
+
if (r.ok && p) projeto.anotar(p.id, 'midia', `biblioteca: ${r.src} (${r.licenca})`);
|
|
533
|
+
return r;
|
|
534
|
+
}
|
|
535
|
+
return ehAnim ? colecao.procurarAnimacoes(args.termo || args.busca || '', { quantas: Number(args.quantas) || 10 })
|
|
536
|
+
: colecao.procurarIcones3d(args.termo || args.busca || '', { quantas: Number(args.quantas) || 10 });
|
|
537
|
+
}
|
|
538
|
+
|
|
521
539
|
if (acao === 'icones' || acao === 'icone') {
|
|
522
540
|
if (args.nome) {
|
|
523
541
|
const r = await assets.icone(args.nome, { pasta: p ? p.pasta : null });
|
|
@@ -896,6 +914,32 @@ function alinharPartes(lista, palavras, total) {
|
|
|
896
914
|
});
|
|
897
915
|
}
|
|
898
916
|
|
|
917
|
+
/**
|
|
918
|
+
* Onde o roteiro COMEÇA na fala transcrita, em segundos — 0 se começa logo.
|
|
919
|
+
*
|
|
920
|
+
* Dois jeitos de o Gemini vazar a direção, os dois vistos no vídeo do Joel
|
|
921
|
+
* (10/10): falar "Rápido e contínuo." ANTES do roteiro, e falar NO LUGAR da
|
|
922
|
+
* primeira frase ("Rápido e contínuo. Você pergunta…", sem o "IA é pra tirar
|
|
923
|
+
* dúvida"). Então: acha o primeiro par de palavras seguidas que existe no
|
|
924
|
+
* roteiro (não só o par do começo), e corta o que veio antes — mas só se o que
|
|
925
|
+
* veio antes for, na maior parte, palavra da DIREÇÃO. Fala do roteiro que o
|
|
926
|
+
* transcritor errou não é vazamento e não sai.
|
|
927
|
+
*/
|
|
928
|
+
function vazamentoNoComeco(lista, palavras, direcao = '') {
|
|
929
|
+
const norm = (t) => String(t || '').toLowerCase().normalize('NFD').replace(/[\u0300-\u036f]/g, '').replace(/[^a-z0-9]+/g, ' ').trim();
|
|
930
|
+
const roteiro = norm(lista.slice(0, 2).map((x) => x.texto).join(' ')).split(' ').filter(Boolean);
|
|
931
|
+
if (roteiro.length < 2 || !palavras.length) return 0;
|
|
932
|
+
const pares = new Set(roteiro.slice(0, -1).map((w, i) => w + ' ' + roteiro[i + 1]));
|
|
933
|
+
const ditas = palavras.slice(0, 14).map((w) => norm(w.texto));
|
|
934
|
+
let i = 0;
|
|
935
|
+
while (i < ditas.length - 1 && !pares.has(ditas[i] + ' ' + ditas[i + 1])) i++;
|
|
936
|
+
if (i === 0 || i >= ditas.length - 1) return 0;
|
|
937
|
+
const daDirecao = new Set(norm(direcao).split(' ').filter(Boolean));
|
|
938
|
+
const antes = ditas.slice(0, i).join(' ').split(' ').filter(Boolean);
|
|
939
|
+
const vazou = !direcao || antes.filter((w) => daDirecao.has(w)).length / antes.length >= 0.6;
|
|
940
|
+
return vazou ? Math.max(0, palavras[i].de - 0.08) : 0;
|
|
941
|
+
}
|
|
942
|
+
|
|
899
943
|
async function narrarComGemini(lista, mp3, { voz, direcao } = {}) {
|
|
900
944
|
try {
|
|
901
945
|
const cfg = require('../config').loadConfig();
|
|
@@ -931,9 +975,25 @@ async function narrarComGemini(lista, mp3, { voz, direcao } = {}) {
|
|
|
931
975
|
const chars = lista.reduce((n, x) => n + x.texto.length, 0);
|
|
932
976
|
if (med.segundos < chars / 22) return { ok: false, error: `a voz veio cortada (${med.segundos.toFixed(1)} s para ${chars} caracteres)` };
|
|
933
977
|
const tr = await api.requestTranscricao(cfg.server, { token: cfg.token, audio: fs.readFileSync(mp3).toString('base64'), nome: 'voz.mp3', idioma: 'pt' });
|
|
934
|
-
|
|
978
|
+
let palavras = (tr && tr.palavras) || [];
|
|
935
979
|
if (!palavras.length) return { ok: false, error: 'sem a transcrição da voz para achar as partes' };
|
|
936
|
-
|
|
980
|
+
let segundos = med.segundos;
|
|
981
|
+
/* A DIREÇÃO LIDA EM VOZ ALTA. O servidor manda a direção ("leia RÁPIDO e
|
|
982
|
+
contínuo…") e o texto no mesmo pedido, e o Gemini às vezes fala o
|
|
983
|
+
começo dela: o vídeo do Joel abria com "Rápido e contínuo." antes do
|
|
984
|
+
roteiro (10/10). Se o roteiro só começa depois de algumas palavras,
|
|
985
|
+
o que vem antes é vazamento — e sai do áudio. */
|
|
986
|
+
const corte = vazamentoNoComeco(lista, palavras, direcao);
|
|
987
|
+
if (corte > 0.15) {
|
|
988
|
+
const cortado = mp3.replace(/\.mp3$/, '-c.mp3');
|
|
989
|
+
const k = await video.rodar(video.ffmpeg(), ['-y', '-ss', corte.toFixed(3), '-i', mp3, '-c:a', 'libmp3lame', '-q:a', '3', cortado], { timeout: 120000 });
|
|
990
|
+
if (k.ok && fs.existsSync(cortado)) {
|
|
991
|
+
fs.renameSync(cortado, mp3);
|
|
992
|
+
palavras = palavras.filter((w) => w.de >= corte - 0.02).map((w) => ({ ...w, de: Math.max(0, w.de - corte), ate: Math.max(0, w.ate - corte) }));
|
|
993
|
+
segundos = Math.max(0, segundos - corte);
|
|
994
|
+
}
|
|
995
|
+
}
|
|
996
|
+
return { ok: true, voz: `gemini ${r.voz}`, segundos, partes: alinharPartes(lista, palavras, segundos), ...(corte > 0.15 ? { cortouDirecao: corte } : {}) };
|
|
937
997
|
} catch (e) {
|
|
938
998
|
return { ok: false, error: e.message };
|
|
939
999
|
}
|
|
@@ -1009,9 +1069,24 @@ async function prepararDepoimento(p, c) {
|
|
|
1009
1069
|
origem: { src: c.src, de: c.de, ate: c.ate, frase: c.frase }, ...(c.fraseDita ? { frase: c.fraseDita } : {}) };
|
|
1010
1070
|
}
|
|
1011
1071
|
|
|
1072
|
+
/* O tom de cada estilo, em UMA frase de instrução, em inglês e terminando em
|
|
1073
|
+
dois-pontos, que é a forma que o Gemini TTS entende como instrução e não
|
|
1074
|
+
como fala. Se ainda vazar, o corte de vazamentoNoComeco tira (as palavras
|
|
1075
|
+
ditas antes do roteiro são quase todas daqui). */
|
|
1076
|
+
const DIRECAO_DA_VOZ = {
|
|
1077
|
+
gerativo: 'Read aloud in Brazilian Portuguese as a tech reels narrator: full, warm voice close to the mic, energetic and smiling, no long pauses:',
|
|
1078
|
+
fluido: 'Read aloud in Brazilian Portuguese as a premium product video narrator: confident and close, clean sentences that flow into each other, no ad tone:',
|
|
1079
|
+
personagem: 'Read aloud in Brazilian Portuguese as someone really talking on a podcast: natural and close, never like an announcer:',
|
|
1080
|
+
impacto: 'Read aloud in Brazilian Portuguese as an energetic ad announcer: full voice, strong emphasis on the impact words:',
|
|
1081
|
+
padrao: 'Read aloud in Brazilian Portuguese as a lively tech reels narrator: confident, smiling, emphasis on the words that matter:',
|
|
1082
|
+
};
|
|
1083
|
+
|
|
1012
1084
|
/* A narração com cache (a mesma fala, na mesma direção, não é gerada duas vezes). */
|
|
1013
1085
|
async function narrarComCache(lista, mp3, { voz, direcao } = {}) {
|
|
1014
|
-
|
|
1086
|
+
/* 'v3' (11/10): as narrações guardadas com a direção antiga (a que tinha
|
|
1087
|
+
"RÁPIDO" e "contínuas") ficam para trás — reaproveitar uma delas trazia o
|
|
1088
|
+
"Rápido e contínuo." de volta mesmo com a direção nova. */
|
|
1089
|
+
const chave = require('crypto').createHash('sha1').update(JSON.stringify(['v3', lista.map((x) => x.texto), voz || '', direcao || ''])).digest('hex').slice(0, 16);
|
|
1015
1090
|
const dir = path.join(os.homedir(), '.primocode', 'studio', 'cache-voz');
|
|
1016
1091
|
const cm = path.join(dir, chave + '.mp3'), cj = path.join(dir, chave + '.json');
|
|
1017
1092
|
if (fs.existsSync(cm) && fs.existsSync(cj)) { fs.copyFileSync(cm, mp3); return { ok: true, ...JSON.parse(fs.readFileSync(cj, 'utf8')) }; }
|
|
@@ -1493,24 +1568,14 @@ async function prepararRoteiro(p, args) {
|
|
|
1493
1568
|
const mp3 = path.join(p.pasta, 'midia', `narracao-${Date.now().toString(36)}.mp3`);
|
|
1494
1569
|
// A voz de gente primeiro (Gemini, pelo servidor); o Edge é a reserva.
|
|
1495
1570
|
// Com cache: refazer o vídeo (outro estilo, outra proporção) não gasta a cota da voz.
|
|
1496
|
-
|
|
1497
|
-
|
|
1498
|
-
|
|
1499
|
-
|
|
1500
|
-
|
|
1501
|
-
|
|
1502
|
-
|
|
1503
|
-
|
|
1504
|
-
: estilo === 'fluido' ? 'Leia em português do Brasil como narrador de vídeo de produto premium: seguro, próximo e RÁPIDO, '
|
|
1505
|
-
+ 'frases limpas e contínuas, quase sem pausa entre elas. Nunca arraste, nunca leia devagar; sem tom de anúncio.'
|
|
1506
|
-
// O personagem CONVERSA: voz de gente falando com gente, não de locutor.
|
|
1507
|
-
: estilo === 'personagem' ? 'Fale em português do Brasil como alguém conversando de verdade num podcast: natural, próximo, '
|
|
1508
|
-
+ 'com as pausas e a entonação de quem está pensando junto. Sem voz de locutor, sem leitura.'
|
|
1509
|
-
: estilo === 'impacto' ? 'Leia em português do Brasil como locutor de comercial agressivo e vendedor: energia alta, ritmo rápido, '
|
|
1510
|
-
+ 'voz encorpada, ênfase forte nas palavras de impacto, pausas curtas e secas entre as frases, sorrindo na voz.'
|
|
1511
|
-
// "A narração tá lenta, deixa mais viva" (29/09): todo estilo fala com energia de reels.
|
|
1512
|
-
: 'Leia em português do Brasil como narrador de reels de tecnologia: vivo, confiante e rápido, sorrindo na voz, '
|
|
1513
|
-
+ 'ênfase nas palavras que importam, pausas curtas entre as frases. Nada de leitura lenta ou solene.';
|
|
1571
|
+
/* A DIREÇÃO DA VOZ. O servidor manda a direção e o texto no MESMO pedido,
|
|
1572
|
+
e o Gemini às vezes lê o começo dela em voz alta: todo vídeo do fluido
|
|
1573
|
+
abria com "Rápido e contínuo." (11/10, "em tudo ela coloca isso, pode
|
|
1574
|
+
ser um negócio de padaria"). Então a direção é curta, descreve só o
|
|
1575
|
+
TOM, e não tem palavra solta que soe como a primeira frase de um
|
|
1576
|
+
roteiro ("rápido", "contínuo", caixa alta). A velocidade não vem
|
|
1577
|
+
daqui: vem do atempo (ritmoVoz) depois da voz pronta. */
|
|
1578
|
+
const direcao = DIRECAO_DA_VOZ[estilo] || DIRECAO_DA_VOZ.padrao;
|
|
1514
1579
|
/* Uma voz por falante (falantes.js). Com uma voz só, um grupo só: é a
|
|
1515
1580
|
narração de sempre, com o mesmo cache. */
|
|
1516
1581
|
const vozesL = falantes.vozesDasPartes(partes, roteiro, args.voz || roteiro.voz);
|
|
@@ -1656,6 +1721,15 @@ async function prepararRoteiro(p, args) {
|
|
|
1656
1721
|
const icones = {};
|
|
1657
1722
|
for (const pt of partes) {
|
|
1658
1723
|
const h = typeof (pt.heroi != null ? pt.heroi : pt.icone) === 'string' ? String(pt.heroi != null ? pt.heroi : pt.icone).trim() : '';
|
|
1724
|
+
if (/^(fluent3d|noto):/i.test(h) && !icones[h]) {
|
|
1725
|
+
// O herói da biblioteca: o PNG 3D ou o Lottie, baixado para o projeto.
|
|
1726
|
+
try {
|
|
1727
|
+
const r = await colecao.baixar(h, { pasta: p.pasta, largura, altura });
|
|
1728
|
+
if (r.ok) icones[h] = /^noto:/i.test(h) ? { lottie: r.src } : { png: r.src };
|
|
1729
|
+
else projeto.anotar(p.id, 'icones', `herói ${h} não baixou: ${r.error || '?'}`);
|
|
1730
|
+
} catch { /* fica sem */ }
|
|
1731
|
+
continue;
|
|
1732
|
+
}
|
|
1659
1733
|
if (!/^[a-z0-9-]+:[a-z0-9-]+$/i.test(h) || icones[h]) continue;
|
|
1660
1734
|
try { const ic = await assets.icone(h, { pasta: p.pasta }); if (ic && ic.ok) icones[h] = ic.svg; else projeto.anotar(p.id, 'icones', `herói ${h} não baixou: ${(ic && ic.error) || '?'}`); } catch { /* fica sem */ }
|
|
1661
1735
|
}
|
|
@@ -2042,7 +2116,8 @@ async function ferramentaSite(args) {
|
|
|
2042
2116
|
return r;
|
|
2043
2117
|
}
|
|
2044
2118
|
|
|
2045
|
-
module.exports = {
|
|
2119
|
+
module.exports = {
|
|
2120
|
+
vazamentoNoComeco, variarIcones,
|
|
2046
2121
|
ferramentaProjeto, ferramentaPlano, ferramentaMidia, ferramentaNarrar,
|
|
2047
2122
|
ferramentaVideo, ferramentaRender, ferramentaSite,
|
|
2048
2123
|
projeto, plano, remotion, assets, video, identidade, slides, python, rodarPython, alinharPartes, prepararRoteiro, roteiroValido, juntarVozes,
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
/* kit/animacao.jsx — a animação pronta (Lottie) da biblioteca, quadro a quadro.
|
|
2
|
+
*
|
|
3
|
+
* {"tipo":"kit","peca":"animacao","src":"midia/animacoes/1f525.json","x":…,"y":…,"largura":…,"altura":…,
|
|
4
|
+
* "loop":true,"velocidade":1}
|
|
5
|
+
*
|
|
6
|
+
* O Lottie é vetor: sai nítido em qualquer tamanho. Ele NÃO toca sozinho
|
|
7
|
+
* (o relógio do navegador faria cada render sair diferente): a cada quadro o
|
|
8
|
+
* tempo do elemento vira o quadro da animação (goToAndStop). Os arquivos vêm
|
|
9
|
+
* de colecao.js (o Noto Emoji animado do Google, CC BY 4.0). */
|
|
10
|
+
import React, { useEffect, useRef, useState } from 'react';
|
|
11
|
+
import { staticFile, delayRender, continueRender } from 'remotion';
|
|
12
|
+
import lottie from 'lottie-web';
|
|
13
|
+
|
|
14
|
+
export function Animacao({ el, t }) {
|
|
15
|
+
const caixa = useRef(null);
|
|
16
|
+
const [anim, setAnim] = useState(null);
|
|
17
|
+
const [espera] = useState(() => delayRender('animação ' + el.src));
|
|
18
|
+
useEffect(() => {
|
|
19
|
+
let vivo = true;
|
|
20
|
+
let a = null;
|
|
21
|
+
fetch(/^https?:/.test(el.src) ? el.src : staticFile(el.src))
|
|
22
|
+
.then((r) => r.json())
|
|
23
|
+
.then((dados) => {
|
|
24
|
+
if (!vivo || !caixa.current) return;
|
|
25
|
+
a = lottie.loadAnimation({ container: caixa.current, renderer: 'svg', loop: false, autoplay: false, animationData: dados,
|
|
26
|
+
rendererSettings: { preserveAspectRatio: 'xMidYMid meet' } });
|
|
27
|
+
a.addEventListener('DOMLoaded', () => { setAnim(a); continueRender(espera); });
|
|
28
|
+
})
|
|
29
|
+
.catch(() => continueRender(espera));
|
|
30
|
+
return () => { vivo = false; if (a) a.destroy(); };
|
|
31
|
+
}, [el.src]);
|
|
32
|
+
if (anim) {
|
|
33
|
+
const total = Math.max(1, anim.totalFrames - 1);
|
|
34
|
+
const q = Math.max(0, t) * (anim.frameRate || 30) * (el.velocidade || 1);
|
|
35
|
+
anim.goToAndStop(el.loop === false ? Math.min(q, total) : q % total, true);
|
|
36
|
+
}
|
|
37
|
+
const w = el.largura || 300, h = el.altura || w;
|
|
38
|
+
return <div ref={caixa} style={{ position: 'absolute', left: el.x || 0, top: el.y || 0, width: w, height: h }} />;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export const ANIMACAO = { animacao: Animacao };
|
|
@@ -12,9 +12,10 @@
|
|
|
12
12
|
* quadro as fontes carregam e cada linha é MEDIDA no navegador (delayRender):
|
|
13
13
|
* o que não cabe encolhe, e o acento sabe onde fica cada palavra. */
|
|
14
14
|
import React, { useState, useEffect, useMemo } from 'react';
|
|
15
|
-
import { delayRender, continueRender } from 'remotion';
|
|
15
|
+
import { delayRender, continueRender, Img, staticFile } from 'remotion';
|
|
16
|
+
import { Animacao } from './animacao';
|
|
16
17
|
import { SNAPPY, CURVAS_KIT, acaso, suave, alfa, mistura } from './base';
|
|
17
|
-
import { Produto3D, Icone3D } from './tres';
|
|
18
|
+
import { Produto3D, Icone3D, Heroi3D } from './tres';
|
|
18
19
|
|
|
19
20
|
const ed = CURVAS_KIT.editorial, curvaCam = CURVAS_KIT.camera, arrasto = CURVAS_KIT.arrasto, acelera = CURVAS_KIT.acelera;
|
|
20
21
|
const c01 = (x) => (x < 0 ? 0 : x > 1 ? 1 : x);
|
|
@@ -193,7 +194,9 @@ function leiaute(P, med) {
|
|
|
193
194
|
if (!l.encher || !med[l.id]) continue;
|
|
194
195
|
const k = l.encher + (l.coluna != null ? l.coluna : '');
|
|
195
196
|
// O grid manda: a tese ocupa até ~74% da largura do bloco; só a sangria vai a 97%.
|
|
196
|
-
|
|
197
|
+
/* (10/10) A tese gigante, a parede e a marca ENCHEM o bloco (97%): com fonte
|
|
198
|
+
larga, os 74% de antes paravam "CLAUDE CODE" em 9% da altura. O resto, 80%. */
|
|
199
|
+
const s = Math.min(l.max || l.tamanho, (bl.largura * (b.cheio || ['gigante', 'parede', 'marca'].includes(b.comp) ? 0.97 : 0.8)) / med[l.id].w);
|
|
197
200
|
grupo[k] = grupo[k] == null ? s : Math.min(grupo[k], s);
|
|
198
201
|
}
|
|
199
202
|
const tam = (l) => {
|
|
@@ -950,9 +953,24 @@ export function Gerativo({ el, t, fps }) {
|
|
|
950
953
|
e do texto — e calmo: desenhado por cima, brigava com os dois (05/10). */}
|
|
951
954
|
{noAr.filter((x) => x.heroi && x.heroi.modo === 'campo' && L[x.id]).map((x) => {
|
|
952
955
|
const bl = L[x.id].bloco, some = 1 - c01((t - x.ate) / 0.14);
|
|
956
|
+
let campo3d = null;
|
|
957
|
+
if (x.heroi.tres) {
|
|
958
|
+
/* As colunas NUNCA encostam no texto: só existem numa faixa abaixo
|
|
959
|
+
(ou acima, de ponta-cabeça) do bloco MEDIDO, com folga. O campo mora
|
|
960
|
+
mais fundo que o texto (translateZ -200), então na tela ele encolhe
|
|
961
|
+
para o centro mais que o texto: a folga medida aqui só cresce lá. */
|
|
962
|
+
const folga = Math.max(x.vao || 0, 0.07 * H);
|
|
963
|
+
const ds = DUR_SAIDA[x.saida] || 0;
|
|
964
|
+
const saiDe = x.ate - ds - 0.15;
|
|
965
|
+
if (H - (bl.y1 + folga) >= 0.34 * H) campo3d = { op: 0.7, faixa: { y0: bl.y1 + folga, y1: H }, saiDe };
|
|
966
|
+
else if (bl.y - folga >= 0.34 * H) campo3d = { op: 0.7, faixa: { y0: 0, y1: bl.y - folga }, teto: true, saiDe };
|
|
967
|
+
if (!campo3d) return null; // sem faixa livre: sem colunas
|
|
968
|
+
}
|
|
953
969
|
return (
|
|
954
970
|
<div key={'campo' + x.id} style={{ position: 'absolute', left: 0, top: 0, width: W, height: H, transform: 'translateZ(-200px)', opacity: some }}>
|
|
955
|
-
|
|
971
|
+
{x.heroi.tres
|
|
972
|
+
? <Heroi3D h={x.heroi} t={t} de={x.de} ate={x.ate} W={W} H={H} slot={campo3d} />
|
|
973
|
+
: <Heroi b={x} t={t} P={P} slot={{ cx: (bl.x + bl.x1) / 2, cy: (bl.y + bl.y1) / 2, R: Math.min(W, H) * 0.32, op: 0.18 }} />}
|
|
956
974
|
</div>
|
|
957
975
|
);
|
|
958
976
|
})}
|
|
@@ -998,10 +1016,31 @@ export function Gerativo({ el, t, fps }) {
|
|
|
998
1016
|
// O lugar do herói: o vazio do bloco (como o ícone); sem vazio, atrás do texto e mais fraco.
|
|
999
1017
|
const bl = lay.bloco, M = P.margem;
|
|
1000
1018
|
if (x.heroi.modo === 'campo') return null; // desenhado atrás, antes do produto
|
|
1001
|
-
const
|
|
1019
|
+
const tres = x.heroi.modo === 'objeto3d';
|
|
1020
|
+
// O 3D é GRANDE ("um 3d bem presente"): o raio pede o vazio inteiro.
|
|
1021
|
+
const R = Math.min(W, H) * (tres ? (W >= H ? 0.26 : 0.22) : (W >= H ? 0.2 : 0.17));
|
|
1002
1022
|
let slot = null;
|
|
1003
1023
|
// A área segura (margem.js), com o vão do bloco; partitura antiga: a margem do mundo.
|
|
1004
1024
|
const S = x.seguro || { x0: M, y0: M * 1.4, x1: W - M, y1: H - M * 1.4 }, V = x.vao || 0;
|
|
1025
|
+
if (tres) {
|
|
1026
|
+
/* O 3D NUNCA cruza o texto: mora no maior retângulo livre ao lado do
|
|
1027
|
+
bloco MEDIDO (com o vão e uma folga), dentro da área segura; o raio
|
|
1028
|
+
é o círculo que cabe nele, e o Heroi3D garante que a projeção inteira
|
|
1029
|
+
(anel, tubo, dolly, perspectiva) fica dentro desse raio. Sem
|
|
1030
|
+
retângulo grande o bastante: sem 3D nesta batida. */
|
|
1031
|
+
const folga = Math.max(V, 0.035 * Math.min(W, H));
|
|
1032
|
+
const livres = [
|
|
1033
|
+
{ x0: bl.x1 + folga, y0: S.y0, x1: S.x1, y1: S.y1 },
|
|
1034
|
+
{ x0: S.x0, y0: S.y0, x1: bl.x - folga, y1: S.y1 },
|
|
1035
|
+
{ x0: S.x0, y0: S.y0, x1: S.x1, y1: bl.y - folga },
|
|
1036
|
+
{ x0: S.x0, y0: bl.y1 + folga, x1: S.x1, y1: S.y1 },
|
|
1037
|
+
].map((q) => ({ ...q, r: Math.min(q.x1 - q.x0, q.y1 - q.y0) / 2 })).sort((a, c) => c.r - a.r);
|
|
1038
|
+
const q = livres[0];
|
|
1039
|
+
if (!q || q.r < Math.min(W, H) * 0.12) return null;
|
|
1040
|
+
const ds = DUR_SAIDA[x.saida] || 0;
|
|
1041
|
+
slot = { cx: (q.x0 + q.x1) / 2, cy: (q.y0 + q.y1) / 2, R: Math.min(q.r, Math.min(W, H) * 0.3), saiDe: x.ate - ds - 0.12 };
|
|
1042
|
+
return <Heroi3D h={x.heroi} t={t} de={x.de} ate={x.ate} W={W} H={H} slot={slot} />;
|
|
1043
|
+
}
|
|
1005
1044
|
if (S.x1 - bl.x1 - V >= R * 2.3) slot = { cx: bl.x1 + V + (S.x1 - bl.x1 - V) / 2, cy: (bl.y + bl.y1) / 2, R };
|
|
1006
1045
|
else if (bl.y - V - S.y0 >= R * 2.2) slot = { cx: W / 2, cy: S.y0 + (bl.y - V - S.y0) / 2, R };
|
|
1007
1046
|
else if (S.y1 - bl.y1 - V >= R * 2.2) slot = { cx: W / 2, cy: bl.y1 + V + (S.y1 - bl.y1 - V) / 2, R };
|
|
@@ -1037,6 +1076,25 @@ export function Gerativo({ el, t, fps }) {
|
|
|
1037
1076
|
const [px, py] = achou;
|
|
1038
1077
|
/* O ÍCONE PLANO (09/10): o do Iconify que o roteiro pediu (o pão, o QR
|
|
1039
1078
|
code), na cor do acento da identidade — entra em mola e flutua de leve. */
|
|
1079
|
+
/* O ÍCONE DA BIBLIOTECA (11/10): o 3D renderizado do Fluent (png) ou a
|
|
1080
|
+
animação pronta do Noto (lottie). Entra em mola, gira de leve no
|
|
1081
|
+
eixo Y e flutua, com a sombra no chão que dá peso de objeto. */
|
|
1082
|
+
if (ic.png || ic.lottie) {
|
|
1083
|
+
const k = MOLA(t - ic.t), dt = t - ic.t, sai = c01((t - (x.ate - 0.25)) / 0.25);
|
|
1084
|
+
const fl = Math.sin(dt * 1.4) * 0.025 * tam, giro = Math.sin(dt * 0.9) * 10;
|
|
1085
|
+
return (
|
|
1086
|
+
<div style={{ position: 'absolute', left: Math.round(px), top: Math.round(py), width: tam, height: tam, opacity: c01(k * 1.4) * (1 - sai),
|
|
1087
|
+
transform: 'translateZ(140px) scale(' + (0.5 + 0.5 * k).toFixed(4) + ')', transformOrigin: '50% 70%' }}>
|
|
1088
|
+
<div style={{ position: 'absolute', left: '18%', right: '18%', bottom: '-4%', height: '9%', borderRadius: '50%', background: 'rgba(0,0,0,0.28)',
|
|
1089
|
+
filter: 'blur(' + (tam * 0.03).toFixed(1) + 'px)', transform: 'scale(' + (1 - fl / tam * 2).toFixed(3) + ')' }} />
|
|
1090
|
+
<div style={{ position: 'absolute', inset: 0, transform: 'translateY(' + fl.toFixed(1) + 'px) perspective(800px) rotateY(' + giro.toFixed(2) + 'deg) rotate(' + ((1 - k) * -10).toFixed(2) + 'deg)' }}>
|
|
1091
|
+
{ic.png
|
|
1092
|
+
? <Img src={staticFile(ic.png)} style={{ width: '100%', height: '100%', objectFit: 'contain' }} />
|
|
1093
|
+
: <Animacao el={{ src: ic.lottie, x: 0, y: 0, largura: tam, altura: tam, loop: true }} t={Math.max(0, dt)} />}
|
|
1094
|
+
</div>
|
|
1095
|
+
</div>
|
|
1096
|
+
);
|
|
1097
|
+
}
|
|
1040
1098
|
if (ic.svg) {
|
|
1041
1099
|
const k = MOLA(t - ic.t), fl = Math.sin((t - ic.t) * 1.3) * 0.012 * tam, sai = c01((t - (x.ate - 0.25)) / 0.25);
|
|
1042
1100
|
return <div style={{ position: 'absolute', left: Math.round(px), top: Math.round(py + fl), width: tam, height: tam, color: ic.cor, opacity: c01(k * 1.4) * (1 - sai),
|
|
@@ -10,6 +10,8 @@ import { TRES } from './tres';
|
|
|
10
10
|
import { GERATIVO } from './gerativo';
|
|
11
11
|
import { FLUIDO } from './fluido';
|
|
12
12
|
import { PERSONAGEM } from './personagem';
|
|
13
|
+
import { VOXEL } from './voxel';
|
|
14
|
+
import { ANIMACAO } from './animacao';
|
|
13
15
|
|
|
14
|
-
export const KIT = { ...VOX, ...MARCA, ...TRES, ...GERATIVO, ...FLUIDO, ...PERSONAGEM, papel: Papel };
|
|
16
|
+
export const KIT = { ...VOX, ...MARCA, ...TRES, ...GERATIVO, ...FLUIDO, ...PERSONAGEM, ...VOXEL, ...ANIMACAO, papel: Papel };
|
|
15
17
|
export { Filtros, Acabamento, Hud, CURVAS_KIT, bolhaCaminho } from './base';
|
package/lib/estudio/kit/tres.jsx
CHANGED
|
@@ -375,4 +375,160 @@ export function Icone3D({ el, t }) {
|
|
|
375
375
|
);
|
|
376
376
|
}
|
|
377
377
|
|
|
378
|
+
|
|
379
|
+
/* ── OS HERÓIS EM 3D DE VERDADE (10/10) ───────────────────────────────────
|
|
380
|
+
* "As animações são muito iguais, quero um 3d bem presente" (Joel, com um
|
|
381
|
+
* reel de motion de referência). O herói abstrato era SVG — pontos girando
|
|
382
|
+
* num plano. Estes três são three.js: perspectiva, profundidade, luz e
|
|
383
|
+
* câmera que anda. Grandes: o raio vem do vazio do bloco (gerativo.jsx).
|
|
384
|
+
*
|
|
385
|
+
* globo3d a esfera de pontos e malha, girando inclinada, com um anel
|
|
386
|
+
* de órbita; os pontos de perto maiores (perspectiva real);
|
|
387
|
+
* fita3d o nó que se torce, em material com verniz e a luz de
|
|
388
|
+
* recorte na cor de acento;
|
|
389
|
+
* colunas3d o campo de colunas que sobe em onda, sobrevoado pela câmera
|
|
390
|
+
* e sumindo na névoa da cor do fundo — fica ATRÁS do texto.
|
|
391
|
+
*
|
|
392
|
+
* Canvas do quadro inteiro, transparente: o objeto é posto no lugar do
|
|
393
|
+
* vazio convertendo pixel → mundo (câmera fixa, fov 30, a 12 unidades).
|
|
394
|
+
*/
|
|
395
|
+
const FOV3D = 30, DIST3D = 12;
|
|
396
|
+
const VIS3D = 2 * DIST3D * Math.tan((FOV3D / 2) * Math.PI / 180);
|
|
397
|
+
const mola3d = (x) => { const k = Math.max(0, Math.min(1, x)); return 1 - Math.pow(1 - k, 3) * Math.cos(k * 4.2); };
|
|
398
|
+
|
|
399
|
+
function CameraFixa({ pos, alvo, teto }) {
|
|
400
|
+
const { camera } = useThree();
|
|
401
|
+
// De ponta-cabeça: o campo pende do alto (o texto mora embaixo).
|
|
402
|
+
camera.up.set(0, teto ? -1 : 1, 0);
|
|
403
|
+
camera.position.set(pos[0], pos[1], pos[2]);
|
|
404
|
+
camera.near = 0.1; camera.far = 80;
|
|
405
|
+
camera.lookAt(alvo[0], alvo[1], alvo[2]);
|
|
406
|
+
camera.updateProjectionMatrix();
|
|
407
|
+
return null;
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
function Globo({ r, cor, cor2, sem, tl }) {
|
|
411
|
+
const geo = useMemo(() => {
|
|
412
|
+
const N = 1100, pos = new Float32Array(N * 3), col = new Float32Array(N * 3);
|
|
413
|
+
const c1 = new THREE.Color(cor2), c2 = new THREE.Color(cor);
|
|
414
|
+
const ouro = Math.PI * (3 - Math.sqrt(5));
|
|
415
|
+
for (let i = 0; i < N; i++) {
|
|
416
|
+
const y = 1 - (i / (N - 1)) * 2, rr = Math.sqrt(1 - y * y), a = i * ouro;
|
|
417
|
+
pos.set([Math.cos(a) * rr * r, y * r, Math.sin(a) * rr * r], i * 3);
|
|
418
|
+
const c = (i * 7 + sem) % 6 === 0 ? c2 : c1;
|
|
419
|
+
col.set([c.r, c.g, c.b], i * 3);
|
|
420
|
+
}
|
|
421
|
+
const g = new THREE.BufferGeometry();
|
|
422
|
+
g.setAttribute('position', new THREE.BufferAttribute(pos, 3));
|
|
423
|
+
g.setAttribute('color', new THREE.BufferAttribute(col, 3));
|
|
424
|
+
return g;
|
|
425
|
+
}, [r, cor, cor2, sem]);
|
|
426
|
+
return (
|
|
427
|
+
<group rotation={[0.38 + Math.sin(tl * 0.5) * 0.08, tl * 0.55 + sem * 0.01, 0.12]}>
|
|
428
|
+
<points geometry={geo}><pointsMaterial size={r * 0.06} vertexColors sizeAttenuation /></points>
|
|
429
|
+
<mesh><sphereGeometry args={[r * 0.99, 32, 20]} /><meshBasicMaterial color={cor2} wireframe transparent opacity={0.22} /></mesh>
|
|
430
|
+
<mesh rotation={[1.2, 0.3, tl * 0.4]}><torusGeometry args={[r * 1.14, r * 0.008, 8, 160]} /><meshBasicMaterial color={cor} transparent opacity={0.85} /></mesh>
|
|
431
|
+
<mesh position={[Math.cos(tl * 1.3) * r * 1.14, 0, Math.sin(tl * 1.3) * r * 1.14]} rotation={[1.2, 0.3, 0]}>
|
|
432
|
+
<sphereGeometry args={[r * 0.05, 16, 12]} /><meshBasicMaterial color={cor} />
|
|
433
|
+
</mesh>
|
|
434
|
+
</group>
|
|
435
|
+
);
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
function Fita({ r, cor, cor2, sem, tl }) {
|
|
439
|
+
return (
|
|
440
|
+
<group rotation={[0.5 + tl * 0.35, tl * 0.6 + sem * 0.02, 0.2]}>
|
|
441
|
+
<mesh>
|
|
442
|
+
<torusKnotGeometry args={[r * 0.52, r * 0.13, 280, 32, 2 + (sem % 2), 3]} />
|
|
443
|
+
<meshPhysicalMaterial color={cor2} roughness={0.28} metalness={0.35} clearcoat={0.9} clearcoatRoughness={0.2} />
|
|
444
|
+
</mesh>
|
|
445
|
+
<mesh scale={[1.02, 1.02, 1.02]}>
|
|
446
|
+
<torusKnotGeometry args={[r * 0.52, r * 0.13, 140, 12, 2 + (sem % 2), 3]} />
|
|
447
|
+
<meshBasicMaterial color={cor} wireframe transparent opacity={0.22} />
|
|
448
|
+
</mesh>
|
|
449
|
+
</group>
|
|
450
|
+
);
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
function Colunas({ cor, cor2, fundo, sem, tl }) {
|
|
454
|
+
const n = 15, passo = 0.9;
|
|
455
|
+
const base = new THREE.Color(fundo).lerp(new THREE.Color(cor2), 0.22).getStyle();
|
|
456
|
+
const acesa = new THREE.Color(cor).getStyle();
|
|
457
|
+
const els = [];
|
|
458
|
+
for (let i = 0; i < n; i++) for (let j = 0; j < n; j++) {
|
|
459
|
+
const x = (i - (n - 1) / 2) * passo, z = (j - (n - 1) / 2) * passo - tl * 1.1;
|
|
460
|
+
const d = Math.hypot(i - n / 2, j - n / 2);
|
|
461
|
+
const onda = 0.5 + 0.5 * Math.sin(d * 0.85 - tl * 2.6 + (sem % 7));
|
|
462
|
+
const h = 0.25 + 2.2 * onda * onda;
|
|
463
|
+
const crista = onda > 0.93;
|
|
464
|
+
els.push(
|
|
465
|
+
<mesh key={i + '-' + j} position={[x, h / 2 - 1.6, z]} scale={[passo * 0.78, h, passo * 0.78]}>
|
|
466
|
+
<boxGeometry args={[1, 1, 1]} />
|
|
467
|
+
<meshStandardMaterial color={crista ? acesa : base} roughness={0.55} metalness={0.15}
|
|
468
|
+
{...(crista ? { emissive: acesa, emissiveIntensity: 0.6 } : {})} />
|
|
469
|
+
</mesh>,
|
|
470
|
+
);
|
|
471
|
+
}
|
|
472
|
+
return <group>{els}</group>;
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
/* O quanto cada forma passa do raio r, no pior quadro: o anel e a lua do
|
|
476
|
+
globo (1,14 r + a lua), o nó (o tubo vai a 1,5 × 0,52 r + 0,13 r). */
|
|
477
|
+
const ALCANCE3D = { globo3d: 1.2, fita3d: 0.92 };
|
|
478
|
+
|
|
479
|
+
export function Heroi3D({ h, t, de, ate, W, H, slot }) {
|
|
480
|
+
const tl = Math.max(0, t - de), dur = Math.max(0.8, ate - de);
|
|
481
|
+
const k = Math.min(1.02, mola3d(tl / 0.8));
|
|
482
|
+
// Some ANTES de o texto sair (a saída dele voa pela tela): o herói nunca cruza a letra.
|
|
483
|
+
const saiDe = slot.saiDe != null ? slot.saiDe : ate - 0.25;
|
|
484
|
+
const sai = Math.max(0, Math.min(1, (t - saiDe) / 0.2));
|
|
485
|
+
const sem = h.semente || 0;
|
|
486
|
+
const cor = h.cor || '#ff5a1f', cor2 = h.cor2 || '#eeeeee', fundo = h.fundo || '#111111';
|
|
487
|
+
if (h.tipo === 'colunas3d') {
|
|
488
|
+
// O sobrevoo: a câmera alta, olhando para a frente e para baixo, andando sobre o campo.
|
|
489
|
+
const avanca = tl * 0.25;
|
|
490
|
+
return (
|
|
491
|
+
<div style={{ position: 'absolute', left: 0, top: 0, width: W, height: H, opacity: (slot.op != null ? slot.op : 0.85) * Math.min(1, tl / 0.5) * (1 - sai),
|
|
492
|
+
/* A FAIXA: as colunas só existem fora do texto (clip), com a borda esfumada. */
|
|
493
|
+
...(slot.faixa ? { clipPath: `inset(${slot.faixa.y0}px 0 ${H - slot.faixa.y1}px 0)`,
|
|
494
|
+
WebkitMaskImage: slot.teto ? `linear-gradient(to top, transparent ${H - slot.faixa.y1}px, #000 ${H - slot.faixa.y1 + 0.06 * H}px)` : `linear-gradient(to bottom, transparent ${slot.faixa.y0}px, #000 ${slot.faixa.y0 + 0.06 * H}px)` } : {}) }}>
|
|
495
|
+
<ThreeCanvas width={W} height={H} camera={{ position: [0, 4, 9], fov: 38 }} gl={{ antialias: true, preserveDrawingBuffer: true, alpha: true }}>
|
|
496
|
+
{/* Câmera baixa olhando quase reto: o campo ocupa a metade de baixo e some na névoa — o meio fica para o texto. */}
|
|
497
|
+
<CameraFixa teto={slot.teto} pos={[Math.sin(tl * 0.3) * 1.2, 2.3, 8 - avanca]} alvo={[0, 0.9, -4 - avanca]} />
|
|
498
|
+
<fog attach="fog" args={[fundo, 6, 19]} />
|
|
499
|
+
<ambientLight intensity={0.45} />
|
|
500
|
+
<directionalLight position={[4, 8, 5]} intensity={2.2} />
|
|
501
|
+
<directionalLight position={[-6, 2, -4]} intensity={2.6} color={cor} />
|
|
502
|
+
<Colunas cor={cor} cor2={cor2} fundo={fundo} sem={sem} tl={tl} />
|
|
503
|
+
</ThreeCanvas>
|
|
504
|
+
</div>
|
|
505
|
+
);
|
|
506
|
+
}
|
|
507
|
+
const { cx, cy, R } = slot;
|
|
508
|
+
const wx = ((cx - W / 2) / H) * VIS3D, wy = (-(cy - H / 2) / H) * VIS3D;
|
|
509
|
+
/* R é o TETO do que aparece na tela (o vão livre ao lado do texto). O raio
|
|
510
|
+
no mundo sai dele de trás para a frente: o alcance da forma, a escala
|
|
511
|
+
máxima e a perspectiva do ponto mais perto da lente no fim do dolly. */
|
|
512
|
+
const F = ALCANCE3D[h.tipo] || 1.2, zMax = 0.45;
|
|
513
|
+
let r = (R / H) * VIS3D / (F * 1.02);
|
|
514
|
+
for (let i = 0; i < 3; i++) r = ((R / H) * VIS3D / (F * 1.02)) * ((DIST3D - zMax - F * r) / DIST3D);
|
|
515
|
+
// O dolly: o objeto chega do fundo e vem devagar para a lente durante a batida.
|
|
516
|
+
const z = -3.5 * (1 - Math.min(1, k)) + zMax * Math.min(1, tl / dur);
|
|
517
|
+
const esc = 0.55 + 0.45 * k;
|
|
518
|
+
return (
|
|
519
|
+
<div style={{ position: 'absolute', left: 0, top: 0, width: W, height: H, opacity: (slot.op != null ? slot.op : 1) * Math.min(1, tl / 0.3) * (1 - sai) }}>
|
|
520
|
+
<ThreeCanvas width={W} height={H} camera={{ position: [0, 0, DIST3D], fov: FOV3D }} gl={{ antialias: true, preserveDrawingBuffer: true, alpha: true }}>
|
|
521
|
+
<Ambiente forca={0.9} />
|
|
522
|
+
<ambientLight intensity={0.3} />
|
|
523
|
+
<directionalLight position={[3, 4, 6]} intensity={2.6} />
|
|
524
|
+
<directionalLight position={[-5, -1, -3]} intensity={3.2} color={cor} />
|
|
525
|
+
<pointLight position={[0, 0, 5]} intensity={0.6} />
|
|
526
|
+
<group position={[wx, wy, z]} scale={[esc, esc, esc]}>
|
|
527
|
+
{h.tipo === 'fita3d' ? <Fita r={r} cor={cor} cor2={cor2} sem={sem} tl={tl} /> : <Globo r={r} cor={cor} cor2={cor2} sem={sem} tl={tl} />}
|
|
528
|
+
</group>
|
|
529
|
+
</ThreeCanvas>
|
|
530
|
+
</div>
|
|
531
|
+
);
|
|
532
|
+
}
|
|
533
|
+
|
|
378
534
|
export const TRES = { produto3d: Produto3D, icone3d: Icone3D };
|