primocode 10.4.7 → 10.6.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/bin/primocode.js +117 -24
- package/lib/act.js +141 -9
- package/lib/agents-md.js +140 -0
- package/lib/auditoria.js +122 -0
- package/lib/browser.js +48 -1
- package/lib/catalogo.js +29 -5
- package/lib/comandos-extra.js +2 -0
- package/lib/config.js +32 -4
- package/lib/conta.js +9 -3
- package/lib/entrada.js +33 -11
- package/lib/estudio/colecao.js +218 -0
- package/lib/estudio/dados/fluent-3d.json +1 -0
- package/lib/estudio/dados/noto-animado.json +1 -0
- package/lib/estudio/diretor.js +1 -0
- package/lib/estudio/estilo.js +6 -2
- package/lib/estudio/falantes.js +30 -6
- package/lib/estudio/gerativo.js +8 -0
- package/lib/estudio/hibrido.js +144 -8
- package/lib/estudio/index.js +202 -83
- package/lib/estudio/kit/animacao.jsx +41 -0
- package/lib/estudio/kit/gerativo.jsx +21 -1
- package/lib/estudio/kit/index.jsx +3 -1
- package/lib/estudio/kit/mundo.jsx +1060 -0
- package/lib/estudio/kit/voxel.jsx +854 -0
- package/lib/estudio/kit.js +5 -1
- package/lib/estudio/kokoro.js +18 -4
- package/lib/estudio/margem.js +1 -1
- package/lib/estudio/mundo.js +305 -0
- package/lib/estudio/personagem.js +234 -10
- package/lib/estudio/producao.js +107 -17
- package/lib/estudio/projeto.js +77 -22
- package/lib/estudio/remotion.js +2 -0
- package/lib/leitura.js +176 -0
- package/lib/local/cerebro.json +180 -3
- package/lib/local/entrada.js +61 -4
- package/lib/mcp.js +296 -12
- package/lib/memoria-conversa.js +32 -3
- package/lib/mencoes.js +5 -1
- package/lib/nome-curto.js +126 -0
- package/lib/nome-projeto.js +5 -2
- package/lib/outra-pasta.js +161 -0
- package/lib/perigo.js +34 -2
- package/lib/referencia.js +40 -11
- package/lib/repeticao.js +20 -1
- package/lib/tela.js +1 -1
- package/lib/tools.js +76 -6
- package/lib/travado.js +141 -0
- package/lib/ui.js +1 -0
- package/package.json +2 -2
package/lib/estudio/index.js
CHANGED
|
@@ -66,9 +66,12 @@ 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 mundo = require('./mundo.js');
|
|
70
|
+
const colecao = require('./colecao.js');
|
|
69
71
|
const { escolherEstilo, LEVES } = require('./estilo.js');
|
|
70
72
|
const sonsLeves = require('./sons-leves.js');
|
|
71
73
|
const ende = require('./ende.js');
|
|
74
|
+
const producao = require('./producao.js');
|
|
72
75
|
|
|
73
76
|
const PY = path.join(__dirname, '..', '..', 'studio');
|
|
74
77
|
|
|
@@ -162,7 +165,7 @@ function semProjeto(id) {
|
|
|
162
165
|
ok: false,
|
|
163
166
|
error: `não achei o projeto "${id}".`,
|
|
164
167
|
comoResolver: 'liste com estudio_projeto acao:"listar" e use um id de lá — '
|
|
165
|
-
+ 'o id é o nome da pasta (ex: "
|
|
168
|
+
+ 'o id é o nome da pasta (ex: "anuncio-picpay"), não o título.',
|
|
166
169
|
};
|
|
167
170
|
}
|
|
168
171
|
|
|
@@ -187,6 +190,9 @@ async function ferramentaProjeto(args, ctx = {}) {
|
|
|
187
190
|
// O caderno é o que faz "mexe naquela parte" funcionar semanas
|
|
188
191
|
// depois: sem ele, reabrir diz o que existe e nunca por quê.
|
|
189
192
|
caderno: projeto.caderno(args.id, { ultimas: 25 }),
|
|
193
|
+
// A conversa das sessões em que se mexeu neste projeto (AGENTS.md
|
|
194
|
+
// da pasta dele, lib/agents-md.js): o que foi pedido e decidido.
|
|
195
|
+
memoria: require('../agents-md.js').ler(p.pasta) || undefined,
|
|
190
196
|
plano: pl ? plano.resumo(pl) : 'ainda não há plano',
|
|
191
197
|
versoes: projeto.versoes(args.id),
|
|
192
198
|
arquivos: listarArquivos(p.pasta),
|
|
@@ -518,6 +524,23 @@ async function ferramentaMidia(args) {
|
|
|
518
524
|
return r;
|
|
519
525
|
}
|
|
520
526
|
|
|
527
|
+
/* A BIBLIOTECA (colecao.js, 11/10): o cardápio inteiro, o ícone 3D e a
|
|
528
|
+
animação pronta. Procurar não usa rede (o índice mora no pacote); com
|
|
529
|
+
id, baixa para o projeto e devolve o elemento pronto. */
|
|
530
|
+
if (acao === 'biblioteca' || acao === 'cardapio' || acao === 'cardápio') return colecao.cardapio();
|
|
531
|
+
if (acao === 'icones3d' || acao === 'icone3d' || acao === 'animacoes' || acao === 'animacao' || acao === 'animação' || acao === 'animações') {
|
|
532
|
+
const ehAnim = /^anima/.test(acao);
|
|
533
|
+
if (args.id_item || /^(fluent3d|noto):/.test(String(args.item || args.baixar || ''))) {
|
|
534
|
+
const pj = p ? projeto.ler(p.id) : null;
|
|
535
|
+
const fmt = (pj && pj.formato) || {};
|
|
536
|
+
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 });
|
|
537
|
+
if (r.ok && p) projeto.anotar(p.id, 'midia', `biblioteca: ${r.src} (${r.licenca})`);
|
|
538
|
+
return r;
|
|
539
|
+
}
|
|
540
|
+
return ehAnim ? colecao.procurarAnimacoes(args.termo || args.busca || '', { quantas: Number(args.quantas) || 10 })
|
|
541
|
+
: colecao.procurarIcones3d(args.termo || args.busca || '', { quantas: Number(args.quantas) || 10 });
|
|
542
|
+
}
|
|
543
|
+
|
|
521
544
|
if (acao === 'icones' || acao === 'icone') {
|
|
522
545
|
if (args.nome) {
|
|
523
546
|
const r = await assets.icone(args.nome, { pasta: p ? p.pasta : null });
|
|
@@ -1051,12 +1074,24 @@ async function prepararDepoimento(p, c) {
|
|
|
1051
1074
|
origem: { src: c.src, de: c.de, ate: c.ate, frase: c.frase }, ...(c.fraseDita ? { frase: c.fraseDita } : {}) };
|
|
1052
1075
|
}
|
|
1053
1076
|
|
|
1077
|
+
/* O tom de cada estilo, em UMA frase de instrução, em inglês e terminando em
|
|
1078
|
+
dois-pontos, que é a forma que o Gemini TTS entende como instrução e não
|
|
1079
|
+
como fala. Se ainda vazar, o corte de vazamentoNoComeco tira (as palavras
|
|
1080
|
+
ditas antes do roteiro são quase todas daqui). */
|
|
1081
|
+
const DIRECAO_DA_VOZ = {
|
|
1082
|
+
gerativo: 'Read aloud in Brazilian Portuguese as a tech reels narrator: full, warm voice close to the mic, energetic and smiling, no long pauses:',
|
|
1083
|
+
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:',
|
|
1084
|
+
personagem: 'Read aloud in Brazilian Portuguese as someone really talking on a podcast: natural and close, never like an announcer:',
|
|
1085
|
+
impacto: 'Read aloud in Brazilian Portuguese as an energetic ad announcer: full voice, strong emphasis on the impact words:',
|
|
1086
|
+
padrao: 'Read aloud in Brazilian Portuguese as a lively tech reels narrator: confident, smiling, emphasis on the words that matter:',
|
|
1087
|
+
};
|
|
1088
|
+
|
|
1054
1089
|
/* A narração com cache (a mesma fala, na mesma direção, não é gerada duas vezes). */
|
|
1055
1090
|
async function narrarComCache(lista, mp3, { voz, direcao } = {}) {
|
|
1056
|
-
/* '
|
|
1057
|
-
|
|
1058
|
-
|
|
1059
|
-
const chave = require('crypto').createHash('sha1').update(JSON.stringify(['
|
|
1091
|
+
/* 'v3' (11/10): as narrações guardadas com a direção antiga (a que tinha
|
|
1092
|
+
"RÁPIDO" e "contínuas") ficam para trás — reaproveitar uma delas trazia o
|
|
1093
|
+
"Rápido e contínuo." de volta mesmo com a direção nova. */
|
|
1094
|
+
const chave = require('crypto').createHash('sha1').update(JSON.stringify(['v3', lista.map((x) => x.texto), voz || '', direcao || ''])).digest('hex').slice(0, 16);
|
|
1060
1095
|
const dir = path.join(os.homedir(), '.primocode', 'studio', 'cache-voz');
|
|
1061
1096
|
const cm = path.join(dir, chave + '.mp3'), cj = path.join(dir, chave + '.json');
|
|
1062
1097
|
if (fs.existsSync(cm) && fs.existsSync(cj)) { fs.copyFileSync(cm, mp3); return { ok: true, ...JSON.parse(fs.readFileSync(cj, 'utf8')) }; }
|
|
@@ -1067,13 +1102,42 @@ async function narrarComCache(lista, mp3, { voz, direcao } = {}) {
|
|
|
1067
1102
|
return v;
|
|
1068
1103
|
}
|
|
1069
1104
|
|
|
1105
|
+
/* A PRODUÇÃO (producao.js): cada necessidade — voz, trilha, efeitos — pela
|
|
1106
|
+
corrente de provedores dela, com o cache do projeto. `roteiro.provedores`
|
|
1107
|
+
({ voz: 'kokoro' }) passa um provedor para a frente, e o caderno guarda
|
|
1108
|
+
quem produziu cada coisa. */
|
|
1109
|
+
async function produzirNecessidade(p, roteiro, pedido, provedores) {
|
|
1110
|
+
const r = await producao.produzirUma(pedido, provedores, { escolha: ((roteiro && roteiro.provedores) || {})[pedido.tipo], cache: producao.cacheDe(p.pasta) });
|
|
1111
|
+
projeto.anotar(p.id, 'producao', producao.linhaCaderno(r), { necessidade: pedido.tipo, provedor: r.quem || null, cache: !!r.doCache });
|
|
1112
|
+
return r;
|
|
1113
|
+
}
|
|
1114
|
+
|
|
1115
|
+
/* A voz como provedores, na ordem do kokoro.ordem: o Gemini (com o cache e a
|
|
1116
|
+
direção dele), o Kokoro local e o Edge. A falha de uma passa para a próxima. */
|
|
1117
|
+
function provedoresDeVoz(lista, mp3, arq, voz, vozEdge, { soEdge = false, gemini, escolha } = {}) {
|
|
1118
|
+
const todos = {
|
|
1119
|
+
gemini: () => gemini(),
|
|
1120
|
+
kokoro: () => kokoro.narrar(lista, mp3, { voz, uv: acharUv() }),
|
|
1121
|
+
edge: () => {
|
|
1122
|
+
// Ritmo NORMAL: o "citação" (-10%, respiro longo) esticou o vídeo para 125 s.
|
|
1123
|
+
if (!fs.existsSync(arq)) fs.writeFileSync(arq, JSON.stringify(lista.map((x) => ({ ...x, registro: 'normal' }))));
|
|
1124
|
+
return narrarReserva(lista, mp3, arq, voz, vozEdge, { soEdge: true });
|
|
1125
|
+
},
|
|
1126
|
+
};
|
|
1127
|
+
const comResultado = (f) => async () => { const v = await f(); return v && v.ok ? { ...v, arquivo: mp3 } : v; };
|
|
1128
|
+
const ordem = kokoro.ordem(voz, process.env, { soEdge });
|
|
1129
|
+
// A escolhida no roteiro entra mesmo fora da ordem (o produzirUma a põe na frente).
|
|
1130
|
+
if (escolha && todos[escolha] && !ordem.includes(escolha)) ordem.push(escolha);
|
|
1131
|
+
return ordem.map((nome) => ({ nome, gerar: comResultado(todos[nome]) }));
|
|
1132
|
+
}
|
|
1133
|
+
|
|
1070
1134
|
/* A VOZ DE RESERVA, quando o Gemini falha: o Kokoro (local, sem cota) e
|
|
1071
1135
|
depois o Edge. A falha de uma passa para a próxima, nunca derruba. */
|
|
1072
1136
|
function narrarReserva(lista, mp3, arq, voz, vozEdge, { soEdge = false, semKokoro = false } = {}) {
|
|
1073
1137
|
const passos = kokoro.ordem(voz, process.env, { soEdge }).filter((n) => n !== 'gemini' && !(semKokoro && n === 'kokoro'));
|
|
1074
1138
|
return kokoro.corrente(passos, {
|
|
1075
1139
|
kokoro: () => kokoro.narrar(lista, mp3, { voz, uv: acharUv() }),
|
|
1076
|
-
edge: () => rodarPython('narrar.py', ['roteiro', '--arquivo', arq, '--saida', mp3, '--voz',
|
|
1140
|
+
edge: () => rodarPython('narrar.py', ['roteiro', '--arquivo', arq, '--saida', mp3, '--voz', kokoro.vozNoEdge(voz, vozEdge)], { timeout: 10 * 60000 }),
|
|
1077
1141
|
});
|
|
1078
1142
|
}
|
|
1079
1143
|
|
|
@@ -1181,6 +1245,11 @@ async function prepararRoteiro(p, args) {
|
|
|
1181
1245
|
if (!args.estilo && estilo !== 'lancamento') estilo = 'gerativo';
|
|
1182
1246
|
if (!roteiro.modelo3d) roteiro.modelo3d = variacao3d.modeloDoPedido(pedidoTxt);
|
|
1183
1247
|
}
|
|
1248
|
+
// O bicho do vídeo 3D não fala com voz de gente (a menos que o pedido peça): sai da narração e fica na cena.
|
|
1249
|
+
if (estilo === 'personagem') {
|
|
1250
|
+
const calados = personagem.calarBichos(roteiro, pedidoTxt);
|
|
1251
|
+
if (calados.length) projeto.anotar(p.id, 'plano', `sem fala (bicho): ${calados.map((c) => `${c.nome} (${c.especie})`).join(', ')} — fica na cena; para falar, peça "${calados[0].especie} falante"`);
|
|
1252
|
+
}
|
|
1184
1253
|
// O 3D de cada vídeo: acabamento, câmera, luz e ritmo sorteados sem repetir os últimos.
|
|
1185
1254
|
/* Por PROJETO, não por chamada: mandar o roteiro de novo para ajustar uma
|
|
1186
1255
|
frase não pode sortear outro 3D nem outro vídeo. Projeto novo, sorteio novo. */
|
|
@@ -1302,35 +1371,38 @@ async function prepararRoteiro(p, args) {
|
|
|
1302
1371
|
return narrarReserva(l, m, a, voz, 'antonio', { soEdge });
|
|
1303
1372
|
} });
|
|
1304
1373
|
if (!v.ok) projeto.anotar(p.id, 'narracao', `as duas vozes falharam (${v.error}); saiu numa voz só`);
|
|
1305
|
-
|
|
1306
|
-
|
|
1307
|
-
|
|
1308
|
-
|
|
1309
|
-
v = { ok: true, ...JSON.parse(fs.readFileSync(cacheJson, 'utf8')), doCache: true };
|
|
1310
|
-
// Cache sem as palavras (vindo de um plano antigo): transcreve de novo e realinha — sem gastar voz.
|
|
1311
|
-
if (!(v.partes || []).some((pt) => (pt.palavras || []).length)) {
|
|
1312
|
-
try {
|
|
1313
|
-
const cfg = require('../config').loadConfig();
|
|
1314
|
-
const tr = await require('../api').requestTranscricao(cfg.server, { token: cfg.token, audio: fs.readFileSync(mp3).toString('base64'), nome: 'voz.mp3', idioma: 'pt' });
|
|
1315
|
-
if (tr && (tr.palavras || []).length) {
|
|
1316
|
-
v.partes = alinharPartes(lista, tr.palavras, v.segundos);
|
|
1317
|
-
fs.writeFileSync(cacheJson, JSON.stringify({ voz: v.voz, segundos: v.segundos, partes: v.partes }));
|
|
1318
|
-
}
|
|
1319
|
-
} catch { /* fica o alinhamento guardado */ }
|
|
1374
|
+
if (!v.ok) {
|
|
1375
|
+
const arq = path.join(p.pasta, 'midia', `roteiro-${Date.now().toString(36)}.json`);
|
|
1376
|
+
fs.writeFileSync(arq, JSON.stringify(lista.map((x) => ({ ...x, registro: 'normal' }))));
|
|
1377
|
+
v = await narrarReserva(lista, mp3, arq, args.voz || roteiro.voz, 'antonio', { soEdge });
|
|
1320
1378
|
}
|
|
1321
|
-
} else if (kokoro.querKokoro(args.voz || roteiro.voz)) {
|
|
1322
|
-
v = { ok: false, error: 'kokoro pedido' };
|
|
1323
1379
|
} else {
|
|
1324
|
-
v = await narrarComGemini(lista, mp3, { voz: args.voz || roteiro.voz, direcao: DIRECAO_CINEMA });
|
|
1325
|
-
if (v.ok) {
|
|
1326
|
-
try { fs.mkdirSync(cacheDir, { recursive: true }); fs.copyFileSync(mp3, cacheMp3); fs.writeFileSync(cacheJson, JSON.stringify({ voz: v.voz, segundos: v.segundos, partes: v.partes })); } catch { /* sem cache */ }
|
|
1327
|
-
}
|
|
1328
|
-
}
|
|
1329
|
-
if (!v.ok) {
|
|
1330
1380
|
const arq = path.join(p.pasta, 'midia', `roteiro-${Date.now().toString(36)}.json`);
|
|
1331
|
-
|
|
1332
|
-
|
|
1333
|
-
|
|
1381
|
+
const gemini = async () => {
|
|
1382
|
+
if (fs.existsSync(cacheMp3) && fs.existsSync(cacheJson)) {
|
|
1383
|
+
fs.copyFileSync(cacheMp3, mp3);
|
|
1384
|
+
const g = { ok: true, ...JSON.parse(fs.readFileSync(cacheJson, 'utf8')), doCache: true };
|
|
1385
|
+
// Cache sem as palavras (vindo de um plano antigo): transcreve de novo e realinha — sem gastar voz.
|
|
1386
|
+
if (!(g.partes || []).some((pt) => (pt.palavras || []).length)) {
|
|
1387
|
+
try {
|
|
1388
|
+
const cfg = require('../config').loadConfig();
|
|
1389
|
+
const tr = await require('../api').requestTranscricao(cfg.server, { token: cfg.token, audio: fs.readFileSync(mp3).toString('base64'), nome: 'voz.mp3', idioma: 'pt' });
|
|
1390
|
+
if (tr && (tr.palavras || []).length) {
|
|
1391
|
+
g.partes = alinharPartes(lista, tr.palavras, g.segundos);
|
|
1392
|
+
fs.writeFileSync(cacheJson, JSON.stringify({ voz: g.voz, segundos: g.segundos, partes: g.partes }));
|
|
1393
|
+
}
|
|
1394
|
+
} catch { /* fica o alinhamento guardado */ }
|
|
1395
|
+
}
|
|
1396
|
+
return g;
|
|
1397
|
+
}
|
|
1398
|
+
const g = await narrarComGemini(lista, mp3, { voz: args.voz || roteiro.voz, direcao: DIRECAO_CINEMA });
|
|
1399
|
+
if (g.ok) {
|
|
1400
|
+
try { fs.mkdirSync(cacheDir, { recursive: true }); fs.copyFileSync(mp3, cacheMp3); fs.writeFileSync(cacheJson, JSON.stringify({ voz: g.voz, segundos: g.segundos, partes: g.partes })); } catch { /* sem cache */ }
|
|
1401
|
+
}
|
|
1402
|
+
return g;
|
|
1403
|
+
};
|
|
1404
|
+
v = await produzirNecessidade(p, roteiro, { tipo: 'voz', chave: [lista.map((x) => x.texto), args.voz || roteiro.voz || '', 'cinema-v1', soEdge], destino: mp3 },
|
|
1405
|
+
provedoresDeVoz(lista, mp3, arq, args.voz || roteiro.voz, 'antonio', { soEdge, gemini, escolha: (roteiro.provedores || {}).voz }));
|
|
1334
1406
|
}
|
|
1335
1407
|
if (!v.ok) return v;
|
|
1336
1408
|
/* A DURAÇÃO que o roteiro pede ("60–75 s"): se a voz passa do que cabe,
|
|
@@ -1374,25 +1446,32 @@ async function prepararRoteiro(p, args) {
|
|
|
1374
1446
|
}
|
|
1375
1447
|
}
|
|
1376
1448
|
projeto.anotar(p.id, 'narracao', `${v.segundos}s em ${lista.length} partes, voz ${v.voz}`);
|
|
1377
|
-
|
|
1378
|
-
|
|
1449
|
+
/* A chave leva o id do projeto e o hash das falas. Só `cinema` fazia um
|
|
1450
|
+
roteiro novo, no mesmo projeto, reaproveitar os efeitos do anterior. */
|
|
1451
|
+
const marcaSfx = require('crypto').createHash('sha1').update(JSON.stringify({ id: p.id || '', falas: partes.map((pt) => cinema.falaDe(pt)) })).digest('hex').slice(0, 16);
|
|
1452
|
+
const sonsC = (await produzirNecessidade(p, roteiro, { tipo: 'sfx', chave: [p.id || '', marcaSfx] },
|
|
1453
|
+
[{ nome: 'openverse', gerar: async () => ({ sons: await sonsReais(p, video) }) }])).sons || {};
|
|
1379
1454
|
// Vários termos e durações: vídeo sem música foi o que saiu na primeira vez (25/09).
|
|
1380
1455
|
// E só instrumental: "essa música daria pra deixar só o instrumental?" — a faixa é OUVIDA.
|
|
1381
1456
|
// O termo sai do clima do roteiro (variedade.js), não de uma lista fixa de "cinematic inspiring".
|
|
1382
|
-
|
|
1383
|
-
|
|
1384
|
-
|
|
1385
|
-
|
|
1386
|
-
|
|
1387
|
-
|
|
1388
|
-
|
|
1389
|
-
|
|
1390
|
-
|
|
1391
|
-
|
|
1392
|
-
|
|
1457
|
+
let trilhaC = (await produzirNecessidade(p, roteiro, { tipo: 'trilha', chave: ['cinema', roteiro.clima || '', roteiro.genero || ''] },
|
|
1458
|
+
[{ nome: 'jamendo', gerar: async () => {
|
|
1459
|
+
for (const termo of [...variedade.termosTrilha(roteiro, 'cinema'), 'instrumental']) {
|
|
1460
|
+
try {
|
|
1461
|
+
const b = await assets.procurarMusica(termo, { quantas: 10, duracaoMin: 45 });
|
|
1462
|
+
for (const item of variedade.ordenar(b.ok ? b.sons : [], 'trilha').filter((x) => !/vocal|voice|feat|ft\.|remix/i.test(x.titulo || '')).slice(0, 4)) {
|
|
1463
|
+
const d = await assets.baixar(item, { pasta: p.pasta, nome: 'trilha' });
|
|
1464
|
+
if (!d.ok) continue;
|
|
1465
|
+
if (await temLetra(path.join(p.pasta, d.relativo))) { projeto.anotar(p.id, 'midia', `trilha com voz descartada: ${item.titulo}`); continue; }
|
|
1466
|
+
variedade.marcar('trilha', item.url);
|
|
1467
|
+
const credito = `"${termo}": ${item.titulo} — ${item.autor || 'Jamendo'} (${item.licenca || 'CC'})`;
|
|
1468
|
+
projeto.anotar(p.id, 'trilha', credito);
|
|
1469
|
+
return { src: d.relativo, credito };
|
|
1470
|
+
}
|
|
1471
|
+
} catch { /* o próximo termo */ }
|
|
1393
1472
|
}
|
|
1394
|
-
|
|
1395
|
-
|
|
1473
|
+
return null;
|
|
1474
|
+
} }])).src || null;
|
|
1396
1475
|
/* A MÚSICA mais baixa na origem: a faixa vem masterizada alta e comprimida,
|
|
1397
1476
|
e mesmo a 30% ela cobria a voz e o depoimento ("a música tá muito
|
|
1398
1477
|
alta, não dá pra ouvir as pessoas", 25/09). Ela vai a -28 LUFS antes
|
|
@@ -1439,11 +1518,12 @@ async function prepararRoteiro(p, args) {
|
|
|
1439
1518
|
try { fs.unlinkSync(path.join(p.pasta, rel)); } catch { /* fica o original */ }
|
|
1440
1519
|
return 'midia/' + destino;
|
|
1441
1520
|
};
|
|
1521
|
+
// banco: true marca o que veio do banco (Pexels): a rota produto prefere a tela da marca (hibrido.ancorar).
|
|
1442
1522
|
const achados = await Promise.all(hibrido.termosDeBusca(roteiro, hib.extras).map(async (termo) => {
|
|
1443
1523
|
const out = [];
|
|
1444
1524
|
const slug = 'broll-' + String(termo).toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '');
|
|
1445
1525
|
const proxy = `${slug}.leve.mp4`;
|
|
1446
|
-
if (jaTem.includes(proxy)) out.push({ src: 'midia/' + proxy, tipo: 'video', cat: cat(termo), alt: termo });
|
|
1526
|
+
if (jaTem.includes(proxy)) out.push({ src: 'midia/' + proxy, tipo: 'video', cat: cat(termo), alt: termo, banco: true });
|
|
1447
1527
|
else try {
|
|
1448
1528
|
// Sorteado entre os não usados, com o termo variado (variedade.js, 05/10).
|
|
1449
1529
|
const itens = await variedade.buscarSemRepetir(async (t) => {
|
|
@@ -1453,11 +1533,11 @@ async function prepararRoteiro(p, args) {
|
|
|
1453
1533
|
for (const item of itens.slice(0, 1)) {
|
|
1454
1534
|
const d = await assets.baixar(item, { pasta: p.pasta, nome: slug });
|
|
1455
1535
|
const l = d.ok ? await leve(d.relativo, proxy) : null;
|
|
1456
|
-
if (l) { out.push({ src: l, tipo: 'video', cat: cat(termo), alt: termo }); variedade.marcar('broll', item.url); }
|
|
1536
|
+
if (l) { out.push({ src: l, tipo: 'video', cat: cat(termo), alt: termo, banco: true }); variedade.marcar('broll', item.url); }
|
|
1457
1537
|
}
|
|
1458
1538
|
} catch { /* segue com a foto */ }
|
|
1459
1539
|
const foto = jaTem.find((f) => f.startsWith(slug + '-') && /\.(jpe?g|png|webp)$/i.test(f));
|
|
1460
|
-
if (foto) out.push({ src: 'midia/' + foto, tipo: 'imagem', cat: cat(termo), alt: termo });
|
|
1540
|
+
if (foto) out.push({ src: 'midia/' + foto, tipo: 'imagem', cat: cat(termo), alt: termo, banco: true });
|
|
1461
1541
|
else try {
|
|
1462
1542
|
const itens = await variedade.buscarSemRepetir(async (t) => {
|
|
1463
1543
|
const b = await assets.procurarImagens(t, { quantas: 8, orientacao });
|
|
@@ -1465,7 +1545,7 @@ async function prepararRoteiro(p, args) {
|
|
|
1465
1545
|
}, termo);
|
|
1466
1546
|
for (const item of itens.slice(0, 1)) {
|
|
1467
1547
|
const d = await assets.baixar(item, { pasta: p.pasta, nome: slug });
|
|
1468
|
-
if (d.ok) { out.push({ src: d.relativo, tipo: 'imagem', cat: cat(termo), alt: termo }); variedade.marcar('broll', item.url); }
|
|
1548
|
+
if (d.ok) { out.push({ src: d.relativo, tipo: 'imagem', cat: cat(termo), alt: termo, banco: true }); variedade.marcar('broll', item.url); }
|
|
1469
1549
|
}
|
|
1470
1550
|
} catch { /* sem */ }
|
|
1471
1551
|
return out;
|
|
@@ -1524,7 +1604,9 @@ async function prepararRoteiro(p, args) {
|
|
|
1524
1604
|
const r = cinema.montarCinema({ roteiro, largura, altura, tempos: v.partes, narracao: path.relative(p.pasta, mp3).replace(/\\/g, '/'),
|
|
1525
1605
|
sons: sonsC, trilha: trilhaC, fotos: fotosC, fortes, ritmoLeitura: hib ? 0.55 : 1 });
|
|
1526
1606
|
if (r.ok && hib) {
|
|
1527
|
-
hibrido.enfeitar(r.plano, { extras: hib.extras, partes, marca: roteiro.marca || {}, largura, altura, hud: roteiro.hud === true, particulas: roteiro.particulas === true
|
|
1607
|
+
hibrido.enfeitar(r.plano, { extras: hib.extras, partes, marca: roteiro.marca || {}, largura, altura, hud: roteiro.hud === true, particulas: roteiro.particulas === true,
|
|
1608
|
+
// O b-roll de cada frase troca 0,2 s depois da palavra-âncora (hibrido.ancorar).
|
|
1609
|
+
tempos: v.partes, fortes, fotos: fotosC });
|
|
1528
1610
|
r.resumo.estilo = 'hibrido';
|
|
1529
1611
|
}
|
|
1530
1612
|
if (r.ok) projeto.anotar(p.id, 'plano', `estilo ${hib ? 'hibrido' : 'cinema'}: ${r.resumo.cenas} cenas, ${r.resumo.segundos}s`);
|
|
@@ -1538,38 +1620,52 @@ async function prepararRoteiro(p, args) {
|
|
|
1538
1620
|
const mp3 = path.join(p.pasta, 'midia', `narracao-${Date.now().toString(36)}.mp3`);
|
|
1539
1621
|
// A voz de gente primeiro (Gemini, pelo servidor); o Edge é a reserva.
|
|
1540
1622
|
// Com cache: refazer o vídeo (outro estilo, outra proporção) não gasta a cota da voz.
|
|
1541
|
-
|
|
1542
|
-
|
|
1543
|
-
|
|
1544
|
-
|
|
1545
|
-
|
|
1546
|
-
|
|
1547
|
-
|
|
1548
|
-
|
|
1549
|
-
+ 'quase sem pausa entre as frases. Nunca arraste, nunca leia devagar nem solene.'
|
|
1550
|
-
// O fluido é o vídeo de produto: voz limpa e segura, e rápida ("mais rápido", 06/10).
|
|
1551
|
-
: estilo === 'fluido' ? 'Leia em português do Brasil como narrador de vídeo de produto premium: seguro, próximo e RÁPIDO, '
|
|
1552
|
-
+ 'frases limpas e contínuas, quase sem pausa entre elas. Nunca arraste, nunca leia devagar; sem tom de anúncio.'
|
|
1553
|
-
// O personagem CONVERSA: voz de gente falando com gente, não de locutor.
|
|
1554
|
-
: estilo === 'personagem' ? 'Fale em português do Brasil como alguém conversando de verdade num podcast: natural, próximo, '
|
|
1555
|
-
+ 'com as pausas e a entonação de quem está pensando junto. Sem voz de locutor, sem leitura.'
|
|
1556
|
-
: estilo === 'impacto' ? 'Leia em português do Brasil como locutor de comercial agressivo e vendedor: energia alta, ritmo rápido, '
|
|
1557
|
-
+ 'voz encorpada, ênfase forte nas palavras de impacto, pausas curtas e secas entre as frases, sorrindo na voz.'
|
|
1558
|
-
// "A narração tá lenta, deixa mais viva" (29/09): todo estilo fala com energia de reels.
|
|
1559
|
-
: 'Leia em português do Brasil como narrador de reels de tecnologia: vivo, confiante e rápido, sorrindo na voz, '
|
|
1560
|
-
+ 'ênfase nas palavras que importam, pausas curtas entre as frases. Nada de leitura lenta ou solene.';
|
|
1623
|
+
/* A DIREÇÃO DA VOZ. O servidor manda a direção e o texto no MESMO pedido,
|
|
1624
|
+
e o Gemini às vezes lê o começo dela em voz alta: todo vídeo do fluido
|
|
1625
|
+
abria com "Rápido e contínuo." (11/10, "em tudo ela coloca isso, pode
|
|
1626
|
+
ser um negócio de padaria"). Então a direção é curta, descreve só o
|
|
1627
|
+
TOM, e não tem palavra solta que soe como a primeira frase de um
|
|
1628
|
+
roteiro ("rápido", "contínuo", caixa alta). A velocidade não vem
|
|
1629
|
+
daqui: vem do atempo (ritmoVoz) depois da voz pronta. */
|
|
1630
|
+
const direcao = DIRECAO_DA_VOZ[estilo] || DIRECAO_DA_VOZ.padrao;
|
|
1561
1631
|
/* Uma voz por falante (falantes.js). Com uma voz só, um grupo só: é a
|
|
1562
1632
|
narração de sempre, com o mesmo cache. */
|
|
1563
1633
|
const vozesL = falantes.vozesDasPartes(partes, roteiro, args.voz || roteiro.voz);
|
|
1564
|
-
let v = await
|
|
1565
|
-
|
|
1566
|
-
|
|
1567
|
-
|
|
1568
|
-
|
|
1569
|
-
const
|
|
1570
|
-
|
|
1571
|
-
|
|
1572
|
-
|
|
1634
|
+
let v = await produzirNecessidade(p, roteiro, { tipo: 'voz', chave: [lista.map((x) => x.texto), vozesL, direcao], destino: mp3 }, [{
|
|
1635
|
+
nome: ((roteiro.provedores || {}).voz) || 'estudio',
|
|
1636
|
+
gerar: async () => {
|
|
1637
|
+
let reserva = false, gemini = false;
|
|
1638
|
+
const comLista = lista.map((x, i) => ({ ...x, voz: vozesL[i] }));
|
|
1639
|
+
const pelaReserva = async (l, m, voz) => {
|
|
1640
|
+
// O Kokoro pedido já foi tentado no narrarComCache: a reserva não repete.
|
|
1641
|
+
const a = m === mp3 ? arq : m.replace(/\.mp3$/, '.json');
|
|
1642
|
+
if (a !== arq) fs.writeFileSync(a, JSON.stringify(l));
|
|
1643
|
+
const rr = await narrarReserva(l, m, a, voz, 'thalita', { semKokoro: kokoro.querKokoro(voz) });
|
|
1644
|
+
if (rr && rr.ok) reserva = true;
|
|
1645
|
+
return rr;
|
|
1646
|
+
};
|
|
1647
|
+
let r = await falantes.narrarPorFalante(comLista, mp3, { juntar: juntarVozes,
|
|
1648
|
+
narrarUm: async (l, m, voz) => {
|
|
1649
|
+
const g = await narrarComCache(l, m, { voz, direcao });
|
|
1650
|
+
if (g.ok) { gemini = true; return g; }
|
|
1651
|
+
return pelaReserva(l, m, voz);
|
|
1652
|
+
} });
|
|
1653
|
+
/* UMA VOZ SÓ POR VÍDEO (11/10, "os personagens falam com vozes
|
|
1654
|
+
diferentes em cada cena"): a cota do Gemini acabava no meio, e o
|
|
1655
|
+
mesmo personagem saía Gemini numa fala e reserva na outra. Se
|
|
1656
|
+
misturou, o vídeo inteiro é refeito na reserva — pior que o
|
|
1657
|
+
Gemini, mas cada um com a mesma voz do começo ao fim. */
|
|
1658
|
+
if (r && r.ok && reserva && gemini) {
|
|
1659
|
+
gemini = false;
|
|
1660
|
+
r = await falantes.narrarPorFalante(comLista, mp3, { juntar: juntarVozes, narrarUm: pelaReserva });
|
|
1661
|
+
}
|
|
1662
|
+
/* A voz de RESERVA (Kokoro/Edge, quando o Gemini falhou) não vai para o
|
|
1663
|
+
cache do projeto: guardada, ela voltava em todo refazer do mesmo
|
|
1664
|
+
texto, mesmo com o Gemini de pé — era a "voz robótica que não sai"
|
|
1665
|
+
(11/10). Na próxima vez o Gemini é tentado de novo. */
|
|
1666
|
+
return r && r.ok ? { ...r, arquivo: mp3, ...(reserva ? { naoGuardar: true } : {}) } : r;
|
|
1667
|
+
},
|
|
1668
|
+
}]);
|
|
1573
1669
|
if (!v.ok) return v;
|
|
1574
1670
|
/* O RITMO DA VOZ. Mesmo com a direção, a voz sai no passo de leitura, e
|
|
1575
1671
|
o vídeo — que segue a voz palavra por palavra — sai lento junto. O
|
|
@@ -1703,6 +1799,15 @@ async function prepararRoteiro(p, args) {
|
|
|
1703
1799
|
const icones = {};
|
|
1704
1800
|
for (const pt of partes) {
|
|
1705
1801
|
const h = typeof (pt.heroi != null ? pt.heroi : pt.icone) === 'string' ? String(pt.heroi != null ? pt.heroi : pt.icone).trim() : '';
|
|
1802
|
+
if (/^(fluent3d|noto):/i.test(h) && !icones[h]) {
|
|
1803
|
+
// O herói da biblioteca: o PNG 3D ou o Lottie, baixado para o projeto.
|
|
1804
|
+
try {
|
|
1805
|
+
const r = await colecao.baixar(h, { pasta: p.pasta, largura, altura });
|
|
1806
|
+
if (r.ok) icones[h] = /^noto:/i.test(h) ? { lottie: r.src } : { png: r.src };
|
|
1807
|
+
else projeto.anotar(p.id, 'icones', `herói ${h} não baixou: ${r.error || '?'}`);
|
|
1808
|
+
} catch { /* fica sem */ }
|
|
1809
|
+
continue;
|
|
1810
|
+
}
|
|
1706
1811
|
if (!/^[a-z0-9-]+:[a-z0-9-]+$/i.test(h) || icones[h]) continue;
|
|
1707
1812
|
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 */ }
|
|
1708
1813
|
}
|
|
@@ -1766,8 +1871,22 @@ async function prepararRoteiro(p, args) {
|
|
|
1766
1871
|
return r;
|
|
1767
1872
|
}
|
|
1768
1873
|
if (estilo === 'personagem' && modo === 'video') {
|
|
1769
|
-
const r = personagem.montarPersonagem({ roteiro, largura, altura, tempos, narracao, sons, trilha });
|
|
1770
|
-
if (r.ok)
|
|
1874
|
+
const r = personagem.montarPersonagem({ roteiro: { ...roteiro, pedido: roteiro.pedido || pedidoTxt }, largura, altura, tempos, narracao, sons, trilha });
|
|
1875
|
+
if (r.ok) {
|
|
1876
|
+
/* O que não é peça do catálogo vira ícone 3D da biblioteca, em blocos
|
|
1877
|
+
(kit/mundo.jsx): "uma pizza que fala", "um violão na praia". Sem
|
|
1878
|
+
ícone, a peça sai da cena (e a coisa que fala vira o mascote). */
|
|
1879
|
+
const achados = {};
|
|
1880
|
+
for (const nome of mundo.iconesPendentes(r.partitura)) {
|
|
1881
|
+
const id = colecao.melhorIcone3d(nome, 40);
|
|
1882
|
+
if (!id) continue;
|
|
1883
|
+
const b = await colecao.baixar(id, { pasta: p.pasta, largura, altura }).catch(() => null);
|
|
1884
|
+
if (b && b.ok) achados[nome] = b.src;
|
|
1885
|
+
}
|
|
1886
|
+
mundo.aplicarIcones(r.partitura, achados);
|
|
1887
|
+
const icones = Object.keys(achados);
|
|
1888
|
+
projeto.anotar(p.id, 'plano', `estilo personagem: ${r.resumo.elenco.join(' e ')}${r.resumo.figurantes.length ? ` (+ ${r.resumo.figurantes.join(', ')})` : ''} em ${r.resumo.lugares.join(' → ')}, ${r.resumo.planos} planos, ${r.resumo.segundos}s${icones.length ? `; em blocos: ${icones.join(', ')}` : ''}`);
|
|
1889
|
+
}
|
|
1771
1890
|
return r;
|
|
1772
1891
|
}
|
|
1773
1892
|
if (estilo === 'motion' && modo === 'video') {
|
|
@@ -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,7 +12,8 @@
|
|
|
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
18
|
import { Produto3D, Icone3D, Heroi3D } from './tres';
|
|
18
19
|
|
|
@@ -1075,6 +1076,25 @@ export function Gerativo({ el, t, fps }) {
|
|
|
1075
1076
|
const [px, py] = achou;
|
|
1076
1077
|
/* O ÍCONE PLANO (09/10): o do Iconify que o roteiro pediu (o pão, o QR
|
|
1077
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
|
+
}
|
|
1078
1098
|
if (ic.svg) {
|
|
1079
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);
|
|
1080
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';
|