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 CHANGED
@@ -1,4 +1,4 @@
1
- # PrimoCode v10.4.4
1
+ # PrimoCode v10.4.7
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 v10.4.4
12
+ ▐▛███▜▌ Primo Code v10.4.7
13
13
  ▝▜█████▛▘ Bem-vindo de volta, Joel
14
14
  ▘▘ ▝▝ ~/primocode · /dir <pasta> muda
15
15
  ```
package/lib/catalogo.js CHANGED
@@ -93,8 +93,8 @@ const GRUPOS = [
93
93
  + 'contraste{nao,sim} citacao{texto,autor} imagem{busca,titulo,sub} grafico{titulo,barras:[{rotulo,valor}]} cta{titulo,link} '
94
94
  + 'logo{titulo} cores{cores:[{nome,hex}]} produto{titulo,texto,destaque,selo,barras}. Em qualquer parte: destaque (a palavra-tese), fonte (a fonte citada). O texto da TELA sai da FALA: o título usa palavras que a fala diz, cedo na frase (a tela entra quando a voz diz; palavra que a voz não diz deixa a tela esperando). '
95
95
  + 'visual:{titulo,texto,paleta} (hibrido, cinema, impacto): fonte e cor do cardápio (estudio_midia acao:fontes); sem visual, o PrimoCode sorteia sem repetir os últimos vídeos. '
96
- + 'estilo (SEM IMAGEM é o padrão: escolha o motion que serve a ESTE roteiro, ou deixe vazio e ele gira entre gerativo, fluido e impacto): hibrido (SÓ quando a pessoa pediu imagem: foto e vídeo reais + o motion por cima; dê busca:"termo em inglês" só nas partes em que a imagem agrega) | gerativo (= motion: sem template — a tipografia anima nos eixos da fonte numa linha do tempo contínua, composição, fonte, cor e som sorteados do roteiro; modelo3d fone|celular|notebook|relogio|garrafa|lata|caixa: o objeto gira em 3D numa batida — ponha quando o vídeo é de um produto ou objeto físico; ideia abstrata, história ou explicação: deixe vazio; o vídeo tem UMA identidade (2 chãos, 1 acento, 2 fontes) e o fundo só troca na virada de seção — se o roteiro tem atos, dê secao:"nome curto" nas partes, senão o PrimoCode divide; moldura cantos|completa|false: o quadro fixo do reel (cantos, o assunto no alto, "01 · seção", o tempo) — ponha só se o vídeo pede cara de peça de design; vazio, o PrimoCode alterna com memória e às vezes não põe; heroi em cada parte: o desenho da batida além do texto, tirado do CONTEÚDO — um ícone do Iconify do objeto que a fala cita ("mdi:bread-slice", "mdi:qrcode"; procure com estudio_midia acao:icones), um 3D do kit (raio|relogio|check|balao|escudo|coracao|estrela|sol|play|engrenagem|cadeado|barras|fone|onda|celular|seta|bateria) ou "nenhum" quando a tipografia basta; forma abstrata (explosao|poligono|esfera|toro|truchet|aneis|grade|rastro) só em assunto abstrato ou de tecnologia — numa conversa de padaria, nunca; ambiente limpo|luz|bokeh e acento traco|viagem (o ponto que viaja entre as batidas): vazio, o PrimoCode escolhe com memória e o bokeh quase não sai fora de tecnologia) | classico (o motion 3D antigo) | vox (explainer editorial: tinta sobre papel, câmera que conta a frase) | '
97
- + 'marca (brand reveal: brilho, letras em vetor, marca cromada, padrão) | lancamento (produto: cartões, painel, calendário) | fluido (minimalista: uma ideia por batida, a forma de uma vira a próxima, sem corte; paleta noite|breu|preto|cobalto (escuras) ou lavanda|creme|laranja|branco (claras) — escolha pelo assunto e NÃO repita a do vídeo anterior; vazio, o PrimoCode escolhe; fontesFluido aprovado|editorial|revista|carater|tecnico|classico, vazio gira) | impacto | cinema | personagem (o vídeo GRAVADO em 3D — cada parte é UMA fala {falante, texto, emocao?, destaque?}, sem tipo: personagens modelados — gente ou robô — conversando num set de podcast/estúdio/escritório, a boca segue a voz, gestos, e a câmera corta entre aberto, ombro e close; use quando o pedido fala em personagem, boneco, avatar, podcast, entrevista, ou alguém FALANDO em 3D. Com dois falantes (falante em cada parte) eles conversam; personagens:{"nome":{tipo:robo|pessoa, pele, cabelo:curto|cacheado|longo|topete|coque|careca, corCabelo, roupa, oculos, barba, rotulo}} — nome de IA vira robô sozinho; emocao feliz|serio|surpreso|pensativo|animado|curioso e destaque:"texto curto" (o cartão na tela) em cada parte; cenario podcast|estudio|escritorio|noite). '
96
+ + 'estilo (SEM IMAGEM é o padrão: escolha o motion que serve a ESTE roteiro, ou deixe vazio e ele gira entre gerativo, fluido e impacto): hibrido (SÓ quando a pessoa pediu imagem: foto e vídeo reais + o motion por cima; dê busca:"termo em inglês" só nas partes em que a imagem agrega) | gerativo (= motion: sem template — a tipografia anima nos eixos da fonte numa linha do tempo contínua, composição, fonte, cor e som sorteados do roteiro; modelo3d fone|celular|notebook|relogio|garrafa|lata|caixa: o objeto gira em 3D numa batida — ponha quando o vídeo é de um produto ou objeto físico; ideia abstrata, história ou explicação: deixe vazio; o vídeo tem UMA identidade (2 chãos, 1 acento, 2 fontes) e o fundo só troca na virada de seção — se o roteiro tem atos, dê secao:"nome curto" nas partes, senão o PrimoCode divide; moldura cantos|completa: o quadro fixo do reel (cantos, o assunto no alto, "01 · seção", o tempo) — SÓ quando a pessoa pedir; vazio = sem moldura (ela entrega que o vídeo foi gerado); heroi em cada parte: o desenho da batida além do texto, tirado do CONTEÚDO — um ícone 3D RENDERIZADO da biblioteca ("fluent3d:hot beverage", "fluent3d:money bag"; procure com estudio_midia acao:icones3d — é o que mais parece 3D de verdade), uma animação pronta ("noto:1f525"; acao:animacoes), um ícone do Iconify do objeto que a fala cita ("mdi:bread-slice", "mdi:qrcode"; acao:icones), um 3D do kit (raio|relogio|check|balao|escudo|coracao|estrela|sol|play|engrenagem|cadeado|barras|fone|onda|celular|seta|bateria) ou "nenhum" quando a tipografia basta; forma abstrata (explosao|poligono|esfera|toro|truchet|aneis|grade|rastro) só em assunto abstrato ou de tecnologia — numa conversa de padaria, nunca; ambiente limpo|luz|bokeh e acento traco|viagem (o ponto que viaja entre as batidas): vazio, o PrimoCode escolhe com memória e o bokeh quase não sai fora de tecnologia) | classico (o motion 3D antigo) | vox (explainer editorial: tinta sobre papel, câmera que conta a frase) | '
97
+ + 'marca (brand reveal: brilho, letras em vetor, marca cromada, padrão) | lancamento (produto: cartões, painel, calendário) | fluido (minimalista: uma ideia por batida, a forma de uma vira a próxima, sem corte; paleta noite|breu|preto|cobalto (escuras) ou lavanda|creme|laranja|branco (claras) — escolha pelo assunto e NÃO repita a do vídeo anterior; vazio, o PrimoCode escolhe; fontesFluido aprovado|editorial|revista|carater|tecnico|classico, vazio gira) | impacto | cinema | personagem (a ANIMAÇÃO 3D de verdade, o vídeo GRAVADO em 3D, no visual de blocos/voxel de render de cinema: bonecos de blocos e o mascote laranja (quem é IA) num set de ladrilhos com uma parede de telas, luz roxa de recorte, o fundo desfocado de lente e a legenda branca no meio — cada parte é UMA fala {falante, texto, emocao?, destaque?}, sem tipo; a boca segue a voz, gestos, e a câmera corta entre aberto, ombro e close de baixo; é a escolha para animação 3D, personagem, boneco, avatar, mascote, podcast, entrevista ou alguém FALANDO em 3D — um narrador só também serve (sem falante). As telas do fundo mostram o ASSUNTO: paineis:["título curto", …] (até 12) — vazio, saem dos destaques e das palavras fortes. Com dois falantes (falante em cada parte) eles conversam; personagens:{"nome":{tipo:robo|pessoa, pele, cabelo:curto|cacheado|longo|topete|coque|careca, corCabelo, roupa, oculos, barba, rotulo}} — nome de IA vira robô sozinho; emocao feliz|serio|surpreso|pensativo|animado|curioso e destaque:"texto curto" (o cartão na tela) em cada parte; cenario telas (o roxo da referência, padrão)|neon|oceano|terra; cor do mascote: personagens:{"claude":{cor:"#d97757"}}; visual:"liso" só se pedirem o boneco liso antigo). '
98
98
  + 'marca e lancamento aceitam visual:orbe|vetor|esfera|grade|site|padrao|cromo|cartoes|painel|cores numa parte. marca.nome dá o nome. '
99
99
  + 'voz (thalita|antonio|francisca). DUAS VOZES (o pedido fala em dois narradores, diálogo, conversa, entrevista): roteiro.vozes {"nome":"antonio","outro":"thalita"} e falante:"nome" em cada parte — cada um sai na sua voz. Ajuste fino: plano: {formato:{largura,altura,fps}, cenas:[…], audio:{…}} — ou cenas direto. '
100
100
  + 'cena (opcional): o número de UMA cena para trocar só ela, sem reescrever o resto. '
@@ -109,7 +109,11 @@ const GRUPOS = [
109
109
  + 'o render empurra para dentro, encolhe o texto largo e afasta o colado; enfeite de fundo marque decorativo: true.' },
110
110
 
111
111
  { tool: 'estudio_midia', escreve: true, faz: 'procura e baixa ícone, imagem, música e efeito sonoro da internet, e lista as fontes',
112
- args: 'acao: imagem | icones | som | trilha | efeito | fontes | print | elementos | elemento | recortar. termo. id (para baixar). '
112
+ args: 'acao: biblioteca | imagem | icones | icones3d | animacoes | som | trilha | efeito | fontes | print | elementos | elemento | recortar. termo. id (para baixar). '
113
+ + 'biblioteca: o cardápio INTEIRO do que existe (ícone 2D, ícone 3D, animação pronta, efeito sonoro, música, fonte, personagem 3D) — olhe antes de escolher. '
114
+ + 'icones3d termo:"coffee": os 1.500+ ícones 3D renderizados do Fluent Emoji (MIT); com item:"fluent3d:<nome>" e o id do projeto, baixa e devolve o elemento {tipo:"imagem"} pronto. '
115
+ + 'animacoes termo:"fire": as 881 animações prontas do Noto (Lottie, vetor; CC BY 4.0, ponha o crédito); com item:"noto:<código>", baixa e devolve o elemento {tipo:"kit", peca:"animacao", src, loop} pronto. '
116
+ + 'O termo vai em inglês (as palavras comuns em português também casam). Escolha pelo que a fala DIZ: numa conversa sobre café, a xícara; nunca enfeite sem relação. '
113
117
  + 'print: url — tira o print REAL do site e devolve o src e um elemento {tipo:"kit", peca:"printsite"} pronto '
114
118
  + '(o site de verdade numa janela 3D que rola a página; celular: true para a tela do celular; inteira: false só a primeira dobra). '
115
119
  + 'Use SE o pedido trouxer um site e o vídeo for SOBRE ele (lançamento, tour, case); vídeo que só cita a marca não precisa. '
package/lib/conta.js CHANGED
@@ -54,6 +54,12 @@ const FIREBASE_AUTH = process.env.PRIMOCODE_FIREBASE_AUTH_URL || 'https://identi
54
54
  // (`PLANOS_COM_PRO`), e quem decide de verdade é ele: isto aqui só evita uma
55
55
  // ida à rede para mostrar o selo na abertura.
56
56
  const PLANOS_PRO = ['premium', 'super'];
57
+ /* ASSINANTE É QUEM NÃO ESTÁ NO GRÁTIS (11/10). A conta do Joel veio do Conecta
58
+ como "empresarial" — um plano pago que esta lista não conhecia — e ele caiu
59
+ no limite de quem não paga, com a mensagem "assine o Premium". O Conecta cria
60
+ plano novo sem avisar o PrimoCode; o que não muda é o nome do grátis. */
61
+ const PLANOS_GRATIS = ['free', 'gratis', 'grátis', 'gratuito', 'basico', 'básico', ''];
62
+ const ehPago = (plano) => !PLANOS_GRATIS.includes(String(plano == null ? '' : plano).trim().toLowerCase());
57
63
 
58
64
  function pedir(rota, corpo, timeout = 30000, { token, base = CONECTA } = {}) {
59
65
  return new Promise((resolve, reject) => {
@@ -116,7 +122,7 @@ function atual() {
116
122
  const c = ler();
117
123
  if (!c || !c.token) return null;
118
124
  return { uid: c.uid, plano: c.plano || 'free', email: c.email || null,
119
- pro: PLANOS_PRO.includes(c.plano), desde: c.desde };
125
+ pro: ehPago(c.plano), desde: c.desde };
120
126
  }
121
127
 
122
128
  // ── entrar ───────────────────────────────────────────────────────────────
@@ -189,7 +195,7 @@ async function entrar({ email, senha } = {}) {
189
195
  token: r.token, uid: r.uid, plano: r.plano || 'free', email,
190
196
  desde: new Date().toISOString(), servidor: CONECTA,
191
197
  });
192
- return { ok: true, uid: r.uid, plano: r.plano, pro: PLANOS_PRO.includes(r.plano) };
198
+ return { ok: true, uid: r.uid, plano: r.plano, pro: ehPago(r.plano) };
193
199
  } catch (e) {
194
200
  return { ok: false, error: `não consegui concluir o login: ${e.message}` };
195
201
  }
@@ -234,4 +240,4 @@ function versao() {
234
240
  try { return require('../package.json').version; } catch { return '?'; }
235
241
  }
236
242
 
237
- module.exports = { entrar, sair, atual, sessao, dispositivos, erroDoFirebase, ARQUIVO, CONECTA, PLANOS_PRO };
243
+ module.exports = { entrar, sair, atual, sessao, dispositivos, erroDoFirebase, ARQUIVO, CONECTA, PLANOS_PRO, PLANOS_GRATIS, ehPago };
@@ -141,7 +141,16 @@ const CONFIANCA_MINIMA = 0.7; // abaixo disso a batida é chute: a faixa entra
141
141
 
142
142
  const TRANSICOES_FORTES = new Set(['soco', 'flash', 'chicote', 'zoom', 'giro']);
143
143
 
144
- /** Os cortes do plano no tempo do vídeo: [{t, peso}]. Corte de impacto pesa 3; o maior impacto vira `impacto`. */
144
+ /**
145
+ * Os cortes do plano no tempo do vídeo: [{t, peso}]. Corte de impacto pesa 3; o maior impacto vira `impacto`.
146
+ *
147
+ * O GERATIVO É UMA CENA SÓ: as batidas dele moram dentro da partitura do kit
148
+ * (cena.elementos[].partitura.batidas). Contando só as cenas, o vídeo dele
149
+ * tinha 0 cortes, e o log dizia "0 de 0 cortes caem na batida" (10/10). Cada
150
+ * batida (menos a primeira) é um corte; a troca de campo (fundo de cor) e a
151
+ * tese gigante pesam 3. O corte leva a referência da batida para ser
152
+ * ENCOSTADO na batida da música depois (melhorInicio).
153
+ */
145
154
  function cortesDoPlano(plano) {
146
155
  const cortes = [];
147
156
  let t = 0;
@@ -150,35 +159,111 @@ function cortesDoPlano(plano) {
150
159
  const tr = cena.transicao && (cena.transicao.tipo || cena.transicao);
151
160
  cortes.push({ t: r3(t), peso: cena.impacto || TRANSICOES_FORTES.has(tr) ? 3 : 1 });
152
161
  }
162
+ for (const el of cena.elementos || []) {
163
+ const bs = el && el.partitura && Array.isArray(el.partitura.batidas) ? el.partitura.batidas : null;
164
+ if (!bs) continue;
165
+ bs.forEach((b, j) => {
166
+ if (j === 0) return;
167
+ const forte = b.troca || b.comp === 'gigante' || b.campoCor;
168
+ // A palavra que a batida mostra: a entrada da primeira linha (sem linha, a batida mesmo).
169
+ const ts = (b.linhas || []).map((l) => l.t).filter((x) => typeof x === 'number');
170
+ const palavra = ts.length ? Math.min(...ts) : b.de;
171
+ cortes.push({ t: r3(t + b.de), peso: forte ? 3 : 1, ref: { bs, j, base: t, palavra } });
172
+ });
173
+ }
153
174
  t += Number(cena.duracao) || 0;
154
175
  }
155
- return cortes;
176
+ return cortes.sort((a, b) => a.t - b.t);
177
+ }
178
+
179
+ /** A batida mais perto de `t` numa lista ordenada: {bt, d}. */
180
+ function maisPerto(lista, t) {
181
+ let lo = 0, hi = lista.length - 1;
182
+ while (lo < hi) { const m = (lo + hi) >> 1; if (lista[m] < t) lo = m + 1; else hi = m; }
183
+ const a = lista[lo], b = lo > 0 ? lista[lo - 1] : Infinity;
184
+ return Math.abs(a - t) <= Math.abs(b - t) ? { bt: a, d: Math.abs(a - t) } : { bt: b, d: Math.abs(b - t) };
185
+ }
186
+
187
+ /* Quanto a troca de tela pode se afastar da PALAVRA que ela mostra: a fala
188
+ continua onde estava, e a troca adianta ou atrasa a palavra em no máximo
189
+ 150 ms — abaixo do que o olho percebe como fora de sincronia. A janela é
190
+ medida da palavra, não do corte de antes (que já vinha ~120 ms adiantado). */
191
+ const EMPURRAO = 0.15;
192
+ /** A janela [de, até] em que o corte pode cair, no tempo do vídeo. */
193
+ const janelaDo = (c) => (c.ref ? [c.ref.base + c.ref.palavra - EMPURRAO, c.ref.base + c.ref.palavra + EMPURRAO] : [c.t, c.t]);
194
+ /** A batida da música (no tempo do vídeo) dentro da janela, a mais perto do corte; ou null. */
195
+ function batidaNaJanela(bats, c, o) {
196
+ const [a, z] = janelaDo(c);
197
+ let lo = 0, hi = bats.length;
198
+ while (lo < hi) { const m = (lo + hi) >> 1; if (bats[m] - o < a) lo = m + 1; else hi = m; }
199
+ let melhor = null;
200
+ for (let i = lo; i < bats.length && bats[i] - o <= z; i++) {
201
+ const v = bats[i] - o;
202
+ if (!melhor || Math.abs(v - c.t) < Math.abs(melhor - c.t)) melhor = v;
203
+ }
204
+ return melhor;
156
205
  }
206
+ const MIN_BATIDA = 0.6; // nenhuma batida fica mais curta que isto depois do empurrão
157
207
 
158
208
  /**
159
209
  * De onde a faixa começa (em s) para o máximo de cortes cair numa batida.
160
210
  * Procura em passos de 10 ms, de 0 a `maximo`; empate fica com o início mais
161
211
  * cedo. O corte mais pesado ganha bônus se cair num acento.
212
+ *
213
+ * Os cortes do gerativo (com `ref`) contam como "na batida" até EMPURRAO de
214
+ * distância, porque depois da escolha eles são ENCOSTADOS nela: a batida do
215
+ * vídeo anda (b.de e o ate da anterior), e o golpe da tese também. O plano é
216
+ * mudado no lugar — é ele que vai para o render.
162
217
  */
163
218
  function melhorInicio(cortes, analise, { maximo = 0, tolerancia = 0.05 } = {}) {
164
219
  const bats = (analise && analise.batidas) || [];
165
220
  if (!cortes.length || bats.length < 4 || (analise.confianca != null && analise.confianca < CONFIANCA_MINIMA)) return { inicio: 0, alinhados: 0, total: cortes.length };
166
- const perto = (lista, t) => {
167
- let lo = 0, hi = lista.length - 1;
168
- while (lo < hi) { const m = (lo + hi) >> 1; if (lista[m] < t) lo = m + 1; else hi = m; }
169
- return Math.min(Math.abs(lista[lo] - t), lo > 0 ? Math.abs(lista[lo - 1] - t) : Infinity);
170
- };
221
+ const perto = (lista, t) => maisPerto(lista, t).d;
171
222
  const maior = cortes.reduce((a, b) => (b.peso > a.peso ? b : a), cortes[0]);
172
223
  const acentos = (analise.acentos || []);
173
224
  let melhor = { inicio: 0, pontos: -1 };
174
225
  for (let o = 0; o <= Math.max(0, maximo) + 1e-9; o += 0.01) {
175
226
  let pontos = 0;
176
- for (const c of cortes) pontos += c.peso * Math.exp(-0.5 * Math.pow(perto(bats, c.t + o) / tolerancia, 2));
227
+ for (const c of cortes) {
228
+ if (c.ref) {
229
+ const v = batidaNaJanela(bats, c, o);
230
+ if (v != null) pontos += c.peso * (1 - 0.3 * Math.min(1, Math.abs(v - c.t) / (2 * EMPURRAO)));
231
+ } else pontos += c.peso * Math.exp(-0.5 * Math.pow(perto(bats, c.t + o) / tolerancia, 2));
232
+ }
177
233
  if (maior.peso > 1 && acentos.length && perto(acentos, maior.t + o) < 0.1) pontos += 2;
178
234
  if (pontos > melhor.pontos + 1e-6) melhor = { inicio: r3(o), pontos };
179
235
  }
180
- const alinhados = cortes.filter((c) => perto(bats, c.t + melhor.inicio) <= tolerancia).length;
181
- return { inicio: melhor.inicio, alinhados, total: cortes.length };
236
+ const o = melhor.inicio;
237
+ // O empurrão: cada corte do gerativo vai para a batida mais perto, se couber.
238
+ let empurrados = 0;
239
+ for (const c of cortes) {
240
+ if (!c.ref) continue;
241
+ const { bs, j, base } = c.ref;
242
+ const v = batidaNaJanela(bats, c, o);
243
+ if (v == null || Math.abs(v - c.t) <= 1e-3) continue;
244
+ const delta = v - c.t;
245
+ const b = bs[j], a = bs[j - 1];
246
+ const novo = b.de + delta;
247
+ if (novo - a.de < MIN_BATIDA || b.ate - novo < MIN_BATIDA) continue;
248
+ const colado = Math.abs(a.ate - b.de) < 0.02;
249
+ b.de = r3(novo);
250
+ if (colado) a.ate = b.de;
251
+ c.t = r3(base + b.de);
252
+ empurrados++;
253
+ }
254
+ // O golpe (a tese que bate) também cai na batida, sem sair da batida dele.
255
+ const vistos = new Set();
256
+ for (const c of cortes) {
257
+ if (!c.ref || vistos.has(c.ref.bs)) continue;
258
+ vistos.add(c.ref.bs);
259
+ for (const b of c.ref.bs) for (const g of b.golpes || []) {
260
+ const { bt, d } = maisPerto(bats, c.ref.base + g.t + o);
261
+ const alvo = bt - o - c.ref.base;
262
+ if (d <= EMPURRAO && alvo > b.de + 0.05 && alvo < b.ate - 0.1) g.t = r3(alvo);
263
+ }
264
+ }
265
+ const alinhados = cortes.filter((c) => perto(bats, c.t + o) <= tolerancia).length;
266
+ return { inicio: o, alinhados, total: cortes.length, empurrados };
182
267
  }
183
268
 
184
269
  module.exports = { analisarPCM, analisar, cortesDoPlano, melhorInicio, TAXA, CONFIANCA_MINIMA };
@@ -0,0 +1,185 @@
1
+ /**
2
+ * colecao.js — a biblioteca completa do Estúdio: o que existe para pôr num vídeo.
3
+ *
4
+ * "tem que ter uma biblioteca completa de ícones, pra ele poder pegar os
5
+ * ícones 3D, 2D, animações, também efeitos sonoros e músicas, também
6
+ * fontes de texto" (Joel, 11/10)
7
+ *
8
+ * O 2D (Iconify), a imagem e o som (Openverse), a trilha e as fontes já
9
+ * existiam (assets.js, biblioteca.js, sons-leves.js). Faltavam duas coisas, e
10
+ * as duas entram aqui:
11
+ *
12
+ * · ÍCONE 3D — o Fluent Emoji 3D da Microsoft (licença MIT): mais de 1.500 objetos
13
+ * renderizados em 3D, de "coffee" a "rocket". O índice mora no pacote
14
+ * (dados/fluent-3d.json), então PROCURAR não usa rede; só o PNG escolhido
15
+ * desce, do CDN do jsDelivr.
16
+ * · ANIMAÇÃO PRONTA — o Noto Emoji animado do Google (CC BY 4.0): 881
17
+ * animações em Lottie (vetor, nítido em qualquer tamanho), tocadas quadro
18
+ * a quadro pela peça kit/animacao.jsx. Índice também no pacote.
19
+ *
20
+ * E `cardapio()` junta TUDO numa resposta só, para a IA saber o que existe
21
+ * antes de escolher — a escolha é dela (nada entra no vídeo sozinho).
22
+ *
23
+ * A licença anda junto: cada download grava o .json ao lado (fonte, licença,
24
+ * crédito), como no acervo.js.
25
+ */
26
+
27
+ 'use strict';
28
+
29
+ const fs = require('fs');
30
+ const path = require('path');
31
+
32
+ const DADOS = path.join(__dirname, 'dados');
33
+ const CDN_FLUENT = 'https://cdn.jsdelivr.net/gh/microsoft/fluentui-emoji@main/assets/';
34
+ const LOTTIE_NOTO = (cp) => `https://fonts.gstatic.com/s/e/notoemoji/latest/${cp}/lottie.json`;
35
+ const GIF_NOTO = (cp) => `https://fonts.gstatic.com/s/e/notoemoji/latest/${cp}/512.gif`;
36
+
37
+ let _fluent = null, _noto = null;
38
+ const fluent = () => (_fluent = _fluent || JSON.parse(fs.readFileSync(path.join(DADOS, 'fluent-3d.json'), 'utf8')));
39
+ const noto = () => (_noto = _noto || JSON.parse(fs.readFileSync(path.join(DADOS, 'noto-animado.json'), 'utf8')));
40
+
41
+ /* As duas bibliotecas são em inglês. A IA costuma mandar o termo em inglês,
42
+ mas a pessoa fala português: as palavras mais comuns de vídeo têm par. */
43
+ const PT_EN = {
44
+ cafe: 'coffee', 'café': 'coffee', pao: 'bread', 'pão': 'bread', padaria: 'bread', dinheiro: 'money', grana: 'money', moeda: 'coin',
45
+ foguete: 'rocket', coracao: 'heart', 'coração': 'heart', fogo: 'fire', ideia: 'light bulb', lampada: 'light bulb', 'lâmpada': 'light bulb',
46
+ estrela: 'star', relogio: 'clock', 'relógio': 'clock', tempo: 'hourglass', celular: 'mobile phone', telefone: 'telephone',
47
+ computador: 'laptop', notebook: 'laptop', robo: 'robot', 'robô': 'robot', livro: 'books', escola: 'school', carro: 'car',
48
+ casa: 'house', comida: 'food', pizza: 'pizza', musica: 'musical note', 'música': 'musical note', foto: 'camera', camera: 'camera',
49
+ 'câmera': 'camera', mundo: 'globe', terra: 'globe', chave: 'key', cadeado: 'locked', seguranca: 'shield', 'segurança': 'shield',
50
+ escudo: 'shield', grafico: 'chart', 'gráfico': 'chart', alvo: 'direct hit', trofeu: 'trophy', 'troféu': 'trophy', medalha: 'medal',
51
+ presente: 'gift', festa: 'party', sol: 'sun', chuva: 'rain', raio: 'high voltage', energia: 'high voltage', bateria: 'battery',
52
+ email: 'envelope', mensagem: 'speech balloon', conversa: 'speech balloon', pergunta: 'question', ok: 'check mark', certo: 'check mark',
53
+ errado: 'cross mark', aviso: 'warning', dinossauro: 'dinosaur', gato: 'cat', cachorro: 'dog', planta: 'seedling', arvore: 'tree',
54
+ 'árvore': 'tree', sorriso: 'smile', feliz: 'smile', triste: 'sad', bravo: 'angry', amor: 'heart', rindo: 'joy', pensando: 'thinking',
55
+ surpreso: 'surprised', cerebro: 'brain', 'cérebro': 'brain', olho: 'eye', mao: 'hand', 'mão': 'hand', joinha: 'thumbs up',
56
+ palmas: 'clapping', aplauso: 'clapping', calendario: 'calendar', 'calendário': 'calendar', saco: 'money bag', banco: 'bank',
57
+ cartao: 'credit card', 'cartão': 'credit card', loja: 'shopping', compras: 'shopping cart', voto: 'ballot box', ferramenta: 'hammer',
58
+ engrenagem: 'gear', ciencia: 'test tube', 'ciência': 'test tube', saude: 'hospital', 'saúde': 'hospital', remedio: 'pill', 'remédio': 'pill',
59
+ };
60
+
61
+ const norm = (t) => String(t || '').toLowerCase().normalize('NFD').replace(/[̀-ͯ]/g, '').replace(/[^a-z0-9 ]+/g, ' ').trim();
62
+
63
+ function termos(termo) {
64
+ const cru = String(termo || '').toLowerCase().trim();
65
+ const out = new Set([norm(cru)]);
66
+ for (const w of cru.split(/\s+/)) if (PT_EN[w]) out.add(PT_EN[w]);
67
+ if (PT_EN[cru]) out.add(PT_EN[cru]);
68
+ return [...out].filter(Boolean);
69
+ }
70
+
71
+ // A nota de um nome contra a busca: palavra inteira vale mais que pedaço.
72
+ function nota(nome, ts) {
73
+ const n = ' ' + norm(nome) + ' ';
74
+ let melhor = 0;
75
+ for (const t of ts) {
76
+ if (n.trim() === t) melhor = Math.max(melhor, 100);
77
+ else if (n.includes(' ' + t + ' ')) melhor = Math.max(melhor, 60 - n.length * 0.1);
78
+ else {
79
+ const ws = t.split(' ').filter((w) => w.length > 2);
80
+ const achou = ws.filter((w) => n.includes(' ' + w)).length;
81
+ if (achou) melhor = Math.max(melhor, (achou / ws.length) * 40 - n.length * 0.1);
82
+ }
83
+ }
84
+ return melhor;
85
+ }
86
+
87
+ /** Os ícones 3D (Fluent) que casam com o termo. */
88
+ function procurarIcones3d(termo, { quantas = 10 } = {}) {
89
+ const ts = termos(termo);
90
+ if (!ts.length) return { ok: false, error: 'diga o que procurar (termo), de preferência em inglês: "coffee", "rocket".' };
91
+ // O nome manda; as palavras-chave do metadata (coffee em "hot beverage") e o grupo ajudam.
92
+ const achados = fluent().map(([nome, p, kw = '', grupo = '']) => ({ nome, p, grupo, n: Math.max(nota(nome, ts), nota(kw, ts) * 0.8, nota(grupo, ts) * 0.3) })).filter((x) => x.n > 0)
93
+ .sort((a, b) => b.n - a.n).slice(0, quantas);
94
+ return {
95
+ ok: true, termo, total: achados.length,
96
+ icones: achados.map((x) => ({ id: 'fluent3d:' + x.nome, nome: x.nome, grupo: x.grupo, url: CDN_FLUENT + encodeURI(x.p), licenca: 'MIT', fonte: 'Microsoft Fluent Emoji 3D' })),
97
+ comoUsar: 'baixe com acao:icones3d id:"fluent3d:<nome>" (precisa do id do projeto) — volta um elemento {tipo:"imagem"} pronto, PNG transparente de 256 px em 3D.',
98
+ };
99
+ }
100
+
101
+ /** As animações prontas (Noto animado, Lottie) que casam com o termo. */
102
+ function procurarAnimacoes(termo, { quantas = 10 } = {}) {
103
+ const ts = termos(termo);
104
+ if (!ts.length) return { ok: false, error: 'diga o que procurar (termo): "fire", "party", "thinking"…' };
105
+ const achados = noto().map(([cp, tags, cat]) => ({ cp, tags, cat, n: Math.max(nota(tags, ts), nota(cat, ts) * 0.5) }))
106
+ .filter((x) => x.n > 0).sort((a, b) => b.n - a.n).slice(0, quantas);
107
+ return {
108
+ ok: true, termo, total: achados.length,
109
+ animacoes: achados.map((x) => ({ id: 'noto:' + x.cp, nome: x.tags, categoria: x.cat, lottie: LOTTIE_NOTO(x.cp), gif: GIF_NOTO(x.cp),
110
+ licenca: 'CC BY 4.0', creditos: 'Noto Emoji Animation, Google (CC BY 4.0)' })),
111
+ comoUsar: 'baixe com acao:animacoes id:"noto:<código>" (precisa do id do projeto) — volta um elemento {tipo:"kit", peca:"animacao"} pronto, que toca quadro a quadro.',
112
+ };
113
+ }
114
+
115
+ async function baixarUrl(url, destino) {
116
+ const r = await fetch(url, { headers: { 'user-agent': 'primocode-estudio' } });
117
+ if (!r.ok) return { ok: false, error: `${r.status} ao baixar ${url}` };
118
+ const buf = Buffer.from(await r.arrayBuffer());
119
+ fs.mkdirSync(path.dirname(destino), { recursive: true });
120
+ fs.writeFileSync(destino, buf);
121
+ return { ok: true, bytes: buf.length };
122
+ }
123
+
124
+ /**
125
+ * Baixa um item da biblioteca para a pasta do projeto e devolve o elemento pronto.
126
+ * @param {string} id "fluent3d:<nome>" ou "noto:<código>"
127
+ */
128
+ async function baixar(id, { pasta, largura = 1920, altura = 1080 } = {}) {
129
+ if (!pasta) return { ok: false, error: 'para baixar é preciso dizer em qual projeto (id).' };
130
+ const [tipo, chave0] = String(id || '').split(/:(.+)/);
131
+ const chave = String(chave0 || '').trim().toLowerCase();
132
+ const lado = Math.round(Math.min(largura, altura) * 0.32);
133
+ if (tipo === 'fluent3d') {
134
+ const item = fluent().find(([nome]) => nome === chave);
135
+ if (!item) return { ok: false, error: `não há o ícone 3D "${chave}" — procure com acao:icones3d termo:"…".` };
136
+ const arquivo = chave.replace(/[^a-z0-9]+/g, '-') + '.png';
137
+ const destino = path.join(pasta, 'midia', 'icones3d', arquivo);
138
+ if (!fs.existsSync(destino)) {
139
+ const r = await baixarUrl(CDN_FLUENT + encodeURI(item[1]), destino);
140
+ if (!r.ok) return r;
141
+ fs.writeFileSync(destino.replace(/\.png$/, '.json'), JSON.stringify({ fonte: 'Microsoft Fluent Emoji 3D', licenca: 'MIT', url: CDN_FLUENT + encodeURI(item[1]) }, null, 2));
142
+ }
143
+ const src = path.relative(pasta, destino).replace(/\\/g, '/');
144
+ return { ok: true, src, licenca: 'MIT',
145
+ elemento: { tipo: 'imagem', src, x: Math.round((largura - lado) / 2), y: Math.round((altura - lado) / 2), largura: lado, altura: lado, ajuste: 'contain' } };
146
+ }
147
+ if (tipo === 'noto') {
148
+ const item = noto().find(([cp]) => cp === chave);
149
+ if (!item) return { ok: false, error: `não há a animação "${chave}" — procure com acao:animacoes termo:"…".` };
150
+ const destino = path.join(pasta, 'midia', 'animacoes', chave + '.json');
151
+ if (!fs.existsSync(destino)) {
152
+ const r = await baixarUrl(LOTTIE_NOTO(chave), destino);
153
+ if (!r.ok) return r;
154
+ fs.writeFileSync(destino.replace(/\.json$/, '.licenca.json'), JSON.stringify({ fonte: 'Noto Emoji Animation (Google)', licenca: 'CC BY 4.0', url: LOTTIE_NOTO(chave) }, null, 2));
155
+ }
156
+ const src = path.relative(pasta, destino).replace(/\\/g, '/');
157
+ return { ok: true, src, licenca: 'CC BY 4.0', creditos: 'Noto Emoji Animation, Google (CC BY 4.0)',
158
+ elemento: { tipo: 'kit', peca: 'animacao', src, loop: true, x: Math.round((largura - lado) / 2), y: Math.round((altura - lado) / 2), largura: lado, altura: lado } };
159
+ }
160
+ return { ok: false, error: 'id da biblioteca é "fluent3d:<nome>" ou "noto:<código>".' };
161
+ }
162
+
163
+ /**
164
+ * O CARDÁPIO: tudo o que a biblioteca tem, numa resposta. A IA lê isto e
165
+ * escolhe pelo assunto do vídeo — a regra da casa é que todo elemento tenha a
166
+ * ver com o que a fala diz.
167
+ */
168
+ function cardapio() {
169
+ let fontes = 0, sons = [];
170
+ try { fontes = Object.keys(require('./biblioteca.js').FONTES || {}).length; } catch { /* sem a lista */ }
171
+ try { sons = Object.keys(require('./sons-leves.js').EFEITOS || {}); } catch { /* sem a lista */ }
172
+ return {
173
+ ok: true,
174
+ icones2d: { quantos: '200 mil+ (Iconify: Material, Lucide, Phosphor, marcas…)', acao: 'icones', exemplo: 'termo:"bread"' },
175
+ icones3d: { quantos: fluent().length, fonte: 'Microsoft Fluent Emoji 3D (MIT)', acao: 'icones3d', exemplo: 'termo:"coffee" → id:"fluent3d:hot beverage"' },
176
+ animacoes: { quantas: noto().length, fonte: 'Noto Emoji animado do Google, Lottie (CC BY 4.0)', acao: 'animacoes', exemplo: 'termo:"fire" → id:"noto:1f525"' },
177
+ personagens3d: 'o estilo personagem (voxel): bonecos de blocos e o mascote, com a câmera de cinema — estudio_roteiro estilo:"personagem"',
178
+ efeitosSonoros: { sintetizados: sons, internet: 'acao:efeito termo:"whoosh" (Openverse, licença aberta, até 6 s)' },
179
+ musicas: { internet: 'acao:trilha termo:"lofi" (Openverse, 30 s ou mais)', automatica: 'sem trilha pedida, o PrimoCode escolhe uma sem repetir as últimas' },
180
+ fontes: { quantas: fontes || 128, acao: 'fontes', nota: 'Google Fonts, por tipo (condensada, display, serifada, manuscrita…)' },
181
+ imagens: 'acao:imagem termo:"…" (Openverse e Wikimedia; Google com chave) — só quando a pessoa pediu imagem',
182
+ };
183
+ }
184
+
185
+ module.exports = { procurarIcones3d, procurarAnimacoes, baixar, cardapio, termos, PT_EN };