primocode 9.8.0-beta.14 → 9.8.0-beta.16

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 v9.8.0-beta.14
1
+ # PrimoCode v9.8.0-beta.16
2
2
 
3
3
  Agente de engenharia brasileiro para o terminal, no estilo Claude Code.
4
4
  Cria arquivos de verdade, roda comandos, **controla o navegador e o desktop** —
@@ -9,7 +9,7 @@ OpenRouter, e cada apelido (`top`/`main`/`fast`) é uma **corrente**: se um mode
9
9
  está no limite de pedidos, o próximo atende. Você não vê o erro, e não paga nada.
10
10
 
11
11
  ```
12
- ▐▛███▜▌ Primo Code v9.8.0-beta.14
12
+ ▐▛███▜▌ Primo Code v9.8.0-beta.16
13
13
  ▝▜█████▛▘ Bem-vindo de volta, Joel
14
14
  ▘▘ ▝▝ ~/primocode · /dir <pasta> muda
15
15
  ```
package/lib/catalogo.js CHANGED
@@ -126,7 +126,8 @@ const GRUPOS = [
126
126
 
127
127
  { tool: 'estudio_site', escreve: false, faz: 'lê a identidade visual de uma marca: cores, fontes, logo e o que ela diz de si',
128
128
  args: 'url — OU marca: o nome da empresa, quando não há link ("faça um vídeo para a Paris Group"). '
129
- + 'id (opcional): anota no caderno do projeto. '
129
+ + 'id (opcional): anota no caderno do projeto — e com id traz o ACERVO da marca: as fotos e os vídeos '
130
+ + 'reais do site (que entram sozinhos no vídeo) e os FATOS das páginas (datas, local, números), para o roteiro. '
130
131
  + 'Devolve FATOS medidos, não um roteiro — o roteiro é seu. '
131
132
  + 'Use SEMPRE que o pedido citar uma empresa de verdade: inventar a cor de uma marca que existe '
132
133
  + 'é erro que sai pronto no vídeo entregue. '
@@ -0,0 +1,157 @@
1
+ /**
2
+ * acervo-site.js — as fotos, os vídeos e os fatos da PRÓPRIA marca, tirados
3
+ * do site dela.
4
+ *
5
+ * "Eu pedi um vídeo pra Paris Group pra anunciar a imersão: ele poderia
6
+ * puxar imagens das imersões, vídeos, fotos, etc. e colocar no decorrer do
7
+ * vídeo." · "Os roteiros são nada a ver." (25/09/2026)
8
+ *
9
+ * O roteiro saía inventado ("sem data, preço nem local", anotou o próprio
10
+ * modelo) e as imagens eram de banco. O site da marca tem as duas coisas: a
11
+ * página da imersão diz "6 a 8 out · Chapecó/SC · 20 cadeiras", e a galeria
12
+ * tem as fotos da sala. Aqui se lê a página inicial e as páginas internas
13
+ * que combinam com o pedido, e sai:
14
+ *
15
+ * - fotos: as maiores que o site oferece (o maior "w" do srcset), sem logo,
16
+ * ícone, SVG nem miniatura;
17
+ * - vídeos: os .mp4 que a página toca (depoimento, bastidor);
18
+ * - fatos: o texto de cada página, limpo — a matéria-prima do roteiro.
19
+ */
20
+
21
+ 'use strict';
22
+
23
+ const { URL } = require('url');
24
+
25
+ const PALAVRAS_BOAS = ['galeria', 'imers', 'evento', 'sobre', 'about', 'portfolio', 'cases', 'case', 'turma', 'curso', 'produto', 'servic', 'equipe', 'time'];
26
+ const RUIM = /(logo|icon|favicon|sprite|avatar|emoji|badge|\/brand\/|placeholder|blank|pixel|\.svg(\?|$)|-sm\.|\.sm\.|thumb)/i;
27
+
28
+ function limpar(t) { return String(t || '').replace(/\s+/g, ' ').trim(); }
29
+
30
+ /** O texto da página, sem script, estilo, menu nem rodapé repetido. */
31
+ function textoDaPagina(html) {
32
+ let t = String(html || '')
33
+ .replace(/<script[\s\S]*?<\/script>|<style[\s\S]*?<\/style>|<noscript[\s\S]*?<\/noscript>/gi, ' ')
34
+ .replace(/<(nav|footer|header)[\s\S]*?<\/\1>/gi, ' ')
35
+ .replace(/<br\s*\/?>|<\/(p|h\d|li|div|section)>/gi, '. ')
36
+ .replace(/<[^>]+>/g, ' ');
37
+ t = t.replace(/&nbsp;/g, ' ').replace(/&amp;/g, '&').replace(/&quot;/g, '"').replace(/&#39;/g, "'").replace(/&[a-z]+;/g, ' ');
38
+ return limpar(t).replace(/(\.\s*){2,}/g, '. ').slice(0, 2200);
39
+ }
40
+
41
+ /** A versão maior de cada imagem: no srcset, o maior "w" até 2000. */
42
+ function maiorDoSrcset(srcset) {
43
+ let melhor = null, largura = 0;
44
+ for (const parte of String(srcset).split(',')) {
45
+ const [u, w] = parte.trim().split(/\s+/);
46
+ const n = parseInt(w, 10) || 1;
47
+ if (u && n <= 2000 && n >= largura) { melhor = u; largura = n; }
48
+ }
49
+ return melhor;
50
+ }
51
+
52
+ // A mesma foto em tamanhos diferentes (x.sm.webp, x.md.webp, x.lg.webp) é UMA foto.
53
+ const chaveDe = (u) => u.replace(/[?#].*$/, '').replace(/\.(sm|md|lg|xl|\d{2,4}w?)(?=\.[a-z]+$)/i, '').replace(/-\d{3,4}(?=\.[a-z]+$)/, '');
54
+
55
+ function colherMidia(html, base) {
56
+ const abs = (u) => { try { return new URL(String(u).replace(/\\\//g, '/'), base).toString(); } catch { return null; } };
57
+ const fotos = new Map(), videos = new Map();
58
+ const pesoDe = (u) => (/\.lg\.|1800w|1920|2000/.test(u) ? 3 : /\.md\.|1280/.test(u) ? 2 : 1);
59
+ const poe = (mapa, u, alt = '') => {
60
+ const a = abs(u);
61
+ if (!a || RUIM.test(a) || a.startsWith('data:')) return;
62
+ const k = chaveDe(a);
63
+ const atual = mapa.get(k);
64
+ // Sem alt, o nome do arquivo descreve a foto ("palestrante-plateia-mesas").
65
+ const doNome = decodeURIComponent(a.split('/').pop().replace(/\.[a-z0-9]+$/i, '').replace(/\.(sm|md|lg)$/i, ''))
66
+ .replace(/^\d+[-_]?/, '').replace(/[-_]+/g, ' ').replace(/\b(dsc|img|hero)\b\s*\d*/gi, '').trim();
67
+ if (!atual || pesoDe(a) > pesoDe(atual.url)) mapa.set(k, { url: a, alt: limpar(alt).slice(0, 120) || (atual && atual.alt) || doNome });
68
+ };
69
+ let m;
70
+ const reImg = /<img\b[^>]*>/gi;
71
+ while ((m = reImg.exec(html))) {
72
+ const tag = m[0];
73
+ const alt = (tag.match(/\balt=["']([^"']*)/i) || [])[1] || '';
74
+ const srcset = (tag.match(/\b(?:srcset|data-srcset)=["']([^"']+)/i) || [])[1];
75
+ const src = (srcset && maiorDoSrcset(srcset)) || (tag.match(/\b(?:data-src|src)=["']([^"']+)/i) || [])[1];
76
+ if (src && /\.(jpe?g|png|webp|avif)(\?|$)/i.test(src)) poe(fotos, src, alt);
77
+ }
78
+ // Sites em Next/React guardam a galeria num JSON dentro da página: o
79
+ // caminho da mídia aparece como texto, não como <img>.
80
+ const reSolto = /["'(]((?:https?:)?\/[^"'()\s]+?\.(?:jpe?g|png|webp|avif|mp4|webm))(?:\?[^"'()\s]*)?["')]/gi;
81
+ while ((m = reSolto.exec(html))) {
82
+ const u = m[1];
83
+ if (/\.(mp4|webm)$/i.test(u)) poe(videos, u);
84
+ else poe(fotos, u);
85
+ }
86
+ const og = (html.match(/<meta[^>]+property=["']og:image["'][^>]+content=["']([^"']+)/i) || [])[1];
87
+ if (og) poe(fotos, og, 'capa do site');
88
+ return { fotos: [...fotos.values()], videos: [...videos.values()] };
89
+ }
90
+
91
+ /** As páginas internas que valem a leitura para ESTE pedido. */
92
+ function paginasRelevantes(html, base, pedido, max = 5) {
93
+ const host = new URL(base).hostname;
94
+ const termos = String(pedido || '').normalize('NFD').replace(/[̀-ͯ]/g, '').toLowerCase()
95
+ .split(/[^a-z0-9]+/).filter((w) => w.length >= 4);
96
+ const vistos = new Set();
97
+ const lista = [];
98
+ const re = /href=["']([^"'#]+)["']/gi;
99
+ let m;
100
+ while ((m = re.exec(html))) {
101
+ let u;
102
+ try { u = new URL(m[1], base); } catch { continue; }
103
+ if (u.hostname !== host || /\.(css|js|png|jpe?g|svg|webp|pdf|ico|xml|json)$/i.test(u.pathname)) continue;
104
+ const caminho = u.pathname.replace(/\/$/, '');
105
+ if (!caminho || vistos.has(caminho) || /privacidade|privacy|termos|terms|login|blog\/|cdn-cgi/.test(caminho)) continue;
106
+ vistos.add(caminho);
107
+ const c = caminho.toLowerCase();
108
+ const pontos = termos.filter((t) => c.includes(t.slice(0, 6))).length * 3
109
+ + PALAVRAS_BOAS.filter((w) => c.includes(w)).length;
110
+ if (pontos > 0) lista.push({ url: u.toString(), pontos });
111
+ }
112
+ return lista.sort((a, b) => b.pontos - a.pontos).slice(0, max).map((x) => x.url);
113
+ }
114
+
115
+ /**
116
+ * Lê o site e baixa o acervo para a pasta do projeto.
117
+ *
118
+ * @param {object} op
119
+ * @param {string} op.url
120
+ * @param {string} op.pedido o que o usuário pediu (decide quais páginas ler)
121
+ * @param {string} op.pasta a pasta do projeto
122
+ * @param {function} op.buscar (url) → {corpo} — o leitor de página do identidade.js
123
+ * @param {function} op.baixar (item, {pasta, nome, sub}) → {ok, relativo} — o do assets.js
124
+ */
125
+ async function colher({ url, pedido = '', pasta, buscar, baixar, maxFotos = 16, maxVideos = 4, htmlInicial = null }) {
126
+ const paginas = [];
127
+ const inicial = htmlInicial || (await buscar(url)).corpo;
128
+ paginas.push({ url, html: inicial });
129
+ for (const u of paginasRelevantes(inicial, url, pedido)) {
130
+ try { paginas.push({ url: u, html: (await buscar(u)).corpo }); } catch { /* página que não abriu fica de fora */ }
131
+ }
132
+
133
+ const fatos = paginas.map((p) => ({ pagina: p.url, texto: textoDaPagina(p.html) })).filter((f) => f.texto.length > 80);
134
+ const fotosVistas = new Map(), videosVistos = new Map();
135
+ // As páginas internas primeiro: a galeria tem mais foto de verdade que a home.
136
+ for (const p of [...paginas.slice(1), paginas[0]]) {
137
+ const { fotos, videos } = colherMidia(p.html, p.url);
138
+ for (const f of fotos) if (!fotosVistas.has(chaveDe(f.url))) fotosVistas.set(chaveDe(f.url), { ...f, pagina: p.url });
139
+ for (const v of videos) if (!videosVistos.has(chaveDe(v.url))) videosVistos.set(chaveDe(v.url), { ...v, pagina: p.url });
140
+ }
141
+
142
+ const fotos = [], videos = [];
143
+ for (const f of fotosVistas.values()) {
144
+ if (fotos.length >= maxFotos) break;
145
+ const d = await baixar({ url: f.url, titulo: f.alt || 'foto da marca', licenca: 'da própria marca', fonte: 'site' }, { pasta, nome: 'marca-foto', sub: 'midia/marca' });
146
+ // Menos de 25 KB é ícone ou miniatura, mesmo sem cara de ícone no nome.
147
+ if (d.ok && d.bytes > 25000) fotos.push({ src: d.relativo, alt: f.alt, pagina: f.pagina });
148
+ }
149
+ for (const v of videosVistos.values()) {
150
+ if (videos.length >= maxVideos) break;
151
+ const d = await baixar({ url: v.url, titulo: 'vídeo da marca', licenca: 'da própria marca', fonte: 'site' }, { pasta, nome: 'marca-video', sub: 'midia/marca' });
152
+ if (d.ok && d.bytes > 50000) videos.push({ src: d.relativo, pagina: v.pagina });
153
+ }
154
+ return { ok: true, paginas: paginas.map((p) => p.url), fatos, fotos, videos };
155
+ }
156
+
157
+ module.exports = { colher, colherMidia, paginasRelevantes, textoDaPagina, maiorDoSrcset };
@@ -44,9 +44,15 @@ function paleta(marca = {}) {
44
44
  const fundo = c.fundo || '#0E0F14';
45
45
  const escuro = luz(fundo) < 0.5;
46
46
  const tinta = c.tinta || (escuro ? '#F4F2EC' : '#15161A');
47
- const destaque = c.destaque || '#FF6A3D';
47
+ let destaque = c.destaque || '#FF6A3D';
48
+ /* A cor da marca precisa aparecer no fundo: o azul #1e3fae da Paris Group
49
+ sumia no fundo escuro (medido em 25/09). Pouco contraste, e o destaque
50
+ clareia (no escuro) ou escurece (no claro) até aparecer. */
51
+ for (let k = 0; k < 6 && Math.abs(luz(destaque) - luz(fundo)) < 0.38; k++) {
52
+ destaque = misturar(destaque, escuro ? '#FFFFFF' : '#000000', 0.18);
53
+ }
48
54
  return {
49
- fundo, tinta, destaque, escuro,
55
+ fundo, tinta, destaque, escuro, marca: c.destaque || destaque,
50
56
  fundo2: misturar(fundo, tinta, escuro ? 0.07 : 0.05),
51
57
  apoio: c.apoio || misturar(destaque, tinta, 0.35),
52
58
  suave: alfa(tinta, 0.68),
@@ -106,6 +112,7 @@ function registroDe(p, i, n) {
106
112
  function iconesDoRoteiro(roteiro) {
107
113
  const nomes = new Set();
108
114
  for (const p of roteiro.partes || []) {
115
+ for (const t of [`${p.titulo || ''} ${p.sub || ''}`, p.sim]) { const ic = iconeDoTexto(t); if (ic) nomes.add(ic); }
109
116
  for (const it of p.itens || []) {
110
117
  const t = typeof it === 'string' ? it : it.texto;
111
118
  const ic = (typeof it === 'object' && it.icone) || iconeDoTexto(t);
@@ -134,7 +141,7 @@ function iconeDoTexto(t) {
134
141
  * @param {Set} op.iconesOk os ícones que existem
135
142
  */
136
143
  function comporRoteiro({ roteiro, modo = 'video', largura = 1920, altura = 1080, tempos = [], narracao = null,
137
- sons = {}, trilha = null, imagens = {}, iconesOk = null }) {
144
+ sons = {}, trilha = null, imagens = {}, iconesOk = null, fundos = {} }) {
138
145
  const partes = (roteiro && roteiro.partes) || [];
139
146
  if (!partes.length) return { ok: false, error: 'o roteiro veio sem partes.' };
140
147
  const ruins = partes.map((p, i) => (TIPOS.includes(p.tipo) ? null : `parte ${i + 1}: tipo "${p.tipo}" não existe (use ${TIPOS.join(', ')})`)).filter(Boolean);
@@ -171,7 +178,16 @@ function comporRoteiro({ roteiro, modo = 'video', largura = 1920, altura = 1080,
171
178
  let fundo = { tipo: 'cor', valor: variante ? cor.fundo2 : cor.fundo };
172
179
  let tinta = cor.tinta, destaque = cor.destaque, suave = cor.suave;
173
180
  if (p.tipo === 'numero') { fundo = { tipo: 'cor', valor: cor.destaque }; tinta = cor.sobreDestaque; destaque = cor.sobreDestaque; suave = alfa(cor.sobreDestaque, 0.75); }
174
- if (p.tipo !== 'imagem') {
181
+ /* A FOTO DA MARCA DE FUNDO: a cena de texto ganha a sala de verdade
182
+ atrás, com um véu da cor do fundo para o texto continuar legível. */
183
+ const fotoFundo = fundos[i];
184
+ if (fotoFundo && p.tipo !== 'imagem') {
185
+ const ehV = fotoFundo.tipo === 'video';
186
+ els.push({ tipo: ehV ? 'video' : 'imagem', src: fotoFundo.src, ...(ehV ? { volume: 0 } : {}), x: 0, y: 0, largura: '100%', altura: '100%', ajuste: 'cover',
187
+ anima: { escala: [{ t: 0, v: variante ? 1.14 : 1.02 }, { t: dur, v: variante ? 1.02 : 1.14, e: 'suave' }] } });
188
+ els.push({ tipo: 'forma', forma: 'retangulo', x: 0, y: 0, largura: '100%', altura: '100%',
189
+ fundo: { tipo: 'gradiente', cores: [alfa(cor.fundo, 0.93), alfa(cor.fundo, 0.72), alfa(cor.fundo, 0.55)], angulo: 90 } });
190
+ } else if (p.tipo !== 'imagem') {
175
191
  const R = Math.round(Math.max(W, H) * 0.62);
176
192
  const canto = [[W - R * 0.55, -R * 0.45], [-R * 0.45, H - R * 0.5], [W - R * 0.4, H - R * 0.55], [-R * 0.5, -R * 0.4]][i % 4];
177
193
  els.push({
@@ -210,6 +226,22 @@ function comporRoteiro({ roteiro, modo = 'video', largura = 1920, altura = 1080,
210
226
  tipo: 'forma', forma: 'retangulo', x, y: Math.round(y), largura: Math.round(W * 0.06), altura: Math.max(6, Math.round(H * 0.008)),
211
227
  cor: c, origem: 'left', anima: { escalaX: [{ t: atraso(de), v: 0 }, { t: atraso(de) + 0.5, v: 1, e: 'saida' }] },
212
228
  });
229
+ /* O ÍCONE DA IDEIA: um emoji grande, que salta e depois flutua, do
230
+ lado livre da cena — o que dá vida a cena de texto sem virar enfeite. */
231
+ const iconeDaIdeia = (texto, x, y, tam, de = 0.35) => {
232
+ const ic = iconeDoTexto(texto);
233
+ if (!ic || (iconesOk && !iconesOk.has(ic))) return false;
234
+ const q = atraso(de);
235
+ const t2 = r2(Math.max(q + 0.6, dur * 0.55)), t3 = r2(Math.max(t2 + 0.3, dur));
236
+ els.push({ tipo: 'icone', nome: ic, tamanho: Math.round(tam), x: Math.round(x), y: Math.round(y), ancora: 'centro',
237
+ anima: {
238
+ escala: [{ t: q, v: 0 }, { t: q + 0.25, v: 1.18, e: 'saida' }, { t: q + 0.42, v: 1, e: 'suave' }],
239
+ rotacao: [{ t: q, v: -18 }, { t: q + 0.42, v: 0, e: 'saida' }, { t: t3, v: 6, e: 'suave' }],
240
+ y: [{ t: q + 0.42, v: 0 }, { t: t2, v: -16, e: 'suave' }, { t: t3, v: 4, e: 'suave' }],
241
+ } });
242
+ if (vid && sons.pop) efeitos.push({ t: r2((tp.de || 0) + q), src: sons.pop, volume: 0.55 });
243
+ return true;
244
+ };
213
245
  const logo = (x, y, alt) => { if (marca.logo) els.push({ tipo: 'imagem', src: marca.logo, x, y, altura: alt, largura: Math.round(alt * 4), ajuste: 'contain', foco: 'left center', anima: { opacidade: [{ t: atraso(0.3), v: 0 }, { t: atraso(0.3) + 0.4, v: 1 }] } }); };
214
246
 
215
247
  switch (p.tipo) {
@@ -226,6 +258,7 @@ function comporRoteiro({ roteiro, modo = 'video', largura = 1920, altura = 1080,
226
258
  if (!centro) traco(M, y - H * 0.05);
227
259
  titulo(p.titulo || '', { y, larg, linhas: 3, max: fit.tamanho, x, alinhar: centro ? 'center' : 'left' });
228
260
  if (p.sub) linhaFina(p.sub, { y: y + hT + H * 0.03, larg, x, alinhar: centro ? 'center' : 'left', de: 0.6 });
261
+ if (!centro && deitado) iconeDaIdeia(`${p.titulo || ''} ${p.sub || ''}`, W - M - W * 0.09, H * 0.5, H * 0.2, 0.5);
229
262
  break;
230
263
  }
231
264
  case 'numero': {
@@ -273,7 +306,9 @@ function comporRoteiro({ roteiro, modo = 'video', largura = 1920, altura = 1080,
273
306
  const cy = colunas > 1 ? topo : topo + k * (hCard + gap);
274
307
  // O item entra quando a voz chega nele (ou em cascata, no slide).
275
308
  const quando = atraso(vid ? 0.5 + (dur0 * 0.85 - 0.5) * (k / n) : 0.3 + k * 0.12);
276
- const entra = { opacidade: [{ t: quando, v: 0 }, { t: quando + 0.3, v: 1 }], y: [{ t: quando, v: 40 }, { t: quando + 0.45, v: 0, e: 'saida' }] };
309
+ // Entra de baixo e depois respira: nada fica parado como slide.
310
+ const entra = { opacidade: [{ t: quando, v: 0 }, { t: quando + 0.3, v: 1 }],
311
+ y: [{ t: quando, v: 40 }, { t: quando + 0.45, v: 0, e: 'saida' }, { t: Math.max(quando + 0.5, dur), v: k % 2 ? -8 : 8, e: 'suave' }] };
277
312
  if (p.tipo === 'lista') {
278
313
  els.push({ tipo: 'forma', forma: 'retangulo', x: Math.round(cx), y: Math.round(cy), largura: Math.round(wCard), altura: Math.round(hCard), cor: alfa(tinta, cor.escuro ? 0.07 : 0.06), raio: Math.round(H * 0.025), anima: entra });
279
314
  } else if (k < n - 1 && colunas > 1) {
@@ -310,6 +345,7 @@ function comporRoteiro({ roteiro, modo = 'video', largura = 1920, altura = 1080,
310
345
  els.push({ tipo: 'texto', texto: p.nao || '', fonte: F.texto, peso: 600, tamanho: f1.tamanho, cor: alfa(tinta, 0.5), x: M, y: Math.round(y1), largura: Math.round(larg), entrelinha: 1.2, anima: { opacidade: [{ t: 0.1, v: 0 }, { t: 0.4, v: 1 }] } });
311
346
  els.push({ tipo: 'forma', forma: 'retangulo', x: M, y: Math.round(y1 + f1.tamanho * 0.62), largura: Math.round(Math.min(larg, (p.nao || '').length * f1.tamanho * 0.52)), altura: Math.max(5, Math.round(f1.tamanho * 0.09)), cor: destaque, origem: 'left', anima: { escalaX: [{ t: atraso(meio - 0.3), v: 0 }, { t: atraso(meio), v: 1, e: 'saida' }] } });
312
347
  titulo(p.sim || '', { y: y1 + f1.tamanho * 1.2 * f1.linhas + H * 0.05, larg, linhas: 3, max: f2.tamanho, de: atraso(meio) });
348
+ if (deitado) iconeDaIdeia(p.sim || '', W - M - W * 0.06, H * 0.22, H * 0.14, meio + 0.3);
313
349
  if (vid && sons.pop) efeitos.push({ t: r2((tp.de || 0) + meio), src: sons.pop, volume: 0.5 });
314
350
  break;
315
351
  }
@@ -32,7 +32,10 @@ const VAZIAS = new Set((
32
32
  + 'ou mas como mais menos muito muita muitos muitas isso isto esse essa este esta aquele aquela ele ela '
33
33
  + 'eles elas eu voce voces nos meu minha seu sua nosso nossa tem ter ser foi era sao esta estao vai vou '
34
34
  + 'ja ai aqui la entao tambem so quando onde porque pois ate sobre depois antes cada todo toda todos '
35
- + 'todas qual quais quem nao sim ne ta tipo coisa coisas gente fazer faz feito pode podem vamos hoje'
35
+ + 'todas qual quais quem nao sim ne ta tipo coisa coisas gente fazer faz feito pode podem vamos hoje '
36
+ // Verbos de ligação e de fala: não são a ideia da frase.
37
+ + 'mostra mostrar ajuda ajudar deixa deixar usar usa usando sabe saber quer querer precisa precisar '
38
+ + 'acho olha vem fica ficar falar fala dizer diz ver vai ir estou estava sendo tendo existe chamado'
36
39
  ).split(' '));
37
40
 
38
41
  /* A palavra (sem acento, minúscula, começo dela) → o emoji colorido. Casa
@@ -208,8 +211,8 @@ function camera(i, dur) {
208
211
  }
209
212
 
210
213
  const ESTILOS = {
211
- viral: { icones: true, letreiro: true, sons: true, barra: true, apoio: true, destaque: '#FFD400', forte: '#00E0A4' },
212
- limpo: { icones: false, letreiro: false, sons: false, barra: false, apoio: true, destaque: '#FFFFFF', forte: '#FFD400' },
214
+ viral: { icones: true, letreiro: true, sons: true, barra: true, apoio: true, recorte: true, fundoRecorte: ['#1B1F3B', '#3A1C71'], destaque: '#FFD400', forte: '#00E0A4' },
215
+ limpo: { icones: false, letreiro: false, sons: false, barra: false, apoio: true, recorte: false, fundoRecorte: ['#111111', '#333333'], destaque: '#FFFFFF', forte: '#FFD400' },
213
216
  };
214
217
 
215
218
  /**
@@ -225,7 +228,10 @@ const ESTILOS = {
225
228
  * @param {object} op.sons { pop, whoosh } — caminhos na pasta do projeto, opcionais
226
229
  * @param {Set} op.iconesOk os nomes de ícone que existem de verdade (sem isso, todos valem)
227
230
  */
228
- function montarEdicao({ src, duracao, palavras, largura = 1080, altura = 1920, estilo = 'viral', sons = {}, iconesOk = null, imagens = {}, trilha = null }) {
231
+ // Em quais cenas a pessoa é recortada, com a palavra gigante atrás dela.
232
+ const temRecorte = (i) => i % 4 === 2;
233
+
234
+ function montarEdicao({ src, duracao, palavras, largura = 1080, altura = 1920, estilo = 'viral', sons = {}, iconesOk = null, imagens = {}, trilha = null, recortes = {} }) {
229
235
  const e = ESTILOS[estilo] || ESTILOS.viral;
230
236
  const lista = (palavras || []).filter((p) => p && p.texto && p.ate > p.de).sort((a, b) => a.de - b.de);
231
237
  if (!lista.length) return { ok: false, error: 'a gravação não tem fala transcrita para editar.' };
@@ -236,6 +242,7 @@ function montarEdicao({ src, duracao, palavras, largura = 1080, altura = 1920, e
236
242
  const total = duracao || lista[lista.length - 1].ate + 0.3;
237
243
  const cenas = [];
238
244
  const efeitos = [];
245
+ const infos = []; // onde cada cena começa e a palavra forte dela — para o recorte
239
246
  let lado = 0;
240
247
 
241
248
  grupos.forEach((g, i) => {
@@ -253,10 +260,37 @@ function montarEdicao({ src, duracao, palavras, largura = 1080, altura = 1920, e
253
260
  deOrigem: r2(ini), ateOrigem: r2(ini + dur), volume: 1,
254
261
  }];
255
262
 
263
+ infos.push({ ini: r2(ini), dur, forte: forte ? { de: local(forte.de), texto: forte.texto, peso: peso(forte.texto) } : null });
264
+
265
+ /* A PALAVRA ATRÁS DA PESSOA: o fundo do quarto some, entra a cor da
266
+ marca, a palavra forte aparece enorme e a pessoa, recortada, fica
267
+ na frente dela. É o efeito que mais denuncia edição de videomaker. */
268
+ const rec = e.recorte && temRecorte(i) ? recortes[i] : null;
269
+ if (rec && forte) {
270
+ const vida = r2(rec.ate - rec.de);
271
+ const some = [{ t: 0, v: 0 }, { t: 0.1, v: 1 }, { t: vida - 0.12, v: 1 }, { t: vida, v: 0 }];
272
+ elementos.push({
273
+ tipo: 'forma', forma: 'retangulo', x: 0, y: 0, largura: '100%', altura: '100%', de: rec.de, ate: rec.ate,
274
+ fundo: { tipo: 'gradiente', cores: [e.fundoRecorte[0], e.fundoRecorte[1]], angulo: 160 }, anima: { opacidade: some },
275
+ });
276
+ const palavra = limpaPontuacao(forte.texto).toUpperCase();
277
+ const fit = Math.round(Math.min(altura * 0.42, (largura * 0.92) / Math.max(3, palavra.length * 0.46)));
278
+ elementos.push({
279
+ tipo: 'texto', texto: palavra, fonte: 'Anton', peso: 400, tamanho: fit, cor: e.forte,
280
+ x: '50%', y: deitado ? '38%' : '34%', ancora: 'centro', de: rec.de, ate: rec.ate,
281
+ anima: { escala: [{ t: 0, v: 0.6 }, { t: 0.22, v: 1.06, e: 'saida' }, { t: 0.4, v: 1, e: 'suave' }, { t: vida, v: 1.05 }], opacidade: some },
282
+ });
283
+ elementos.push({
284
+ tipo: 'video', src: rec.src, volume: 0, x: 0, y: 0, largura: '100%', altura: '100%', ajuste: 'cover',
285
+ de: rec.de, ate: rec.ate, anima: { opacidade: some },
286
+ });
287
+ if (e.sons && sons.whoosh) efeitos.push({ t: r2(Math.max(0, ini + rec.de - 0.1)), src: sons.whoosh, volume: 0.55 });
288
+ }
289
+
256
290
  /* A IMAGEM DE APOIO: na hora da palavra forte, a tela vira a imagem do
257
291
  que está sendo dito, com um zoom lento, e a legenda continua por
258
292
  cima. É o "b-roll" das edições de videomaker. */
259
- const apoio = forte && e.apoio && temApoio(i) ? imagens[termoDeApoio(forte.texto)] : null;
293
+ const apoio = forte && e.apoio && temApoio(i) && !rec ? imagens[termoDeApoio(forte.texto)] : null;
260
294
  if (apoio) {
261
295
  const de = local(forte.de - 0.1);
262
296
  const vida = Math.min(2.2, dur - de);
@@ -278,7 +312,7 @@ function montarEdicao({ src, duracao, palavras, largura = 1080, altura = 1920, e
278
312
  }
279
313
 
280
314
  // O ícone salta na hora da palavra; sem ícone, o letreiro da ideia.
281
- const icone = forte && e.icones && !apoio ? iconeDe(forte.texto) : null;
315
+ const icone = forte && e.icones && !apoio && !rec ? iconeDe(forte.texto) : null;
282
316
  const temIcone = icone && (!iconesOk || iconesOk.has(icone));
283
317
  const x = lado++ % 2 === 0 ? '78%' : '22%';
284
318
  if (temIcone) {
@@ -298,7 +332,7 @@ function montarEdicao({ src, duracao, palavras, largura = 1080, altura = 1920, e
298
332
  });
299
333
  if (e.sons && sons.pop) efeitos.push({ t: r2(ini + de), src: sons.pop, volume: 0.8 });
300
334
  }
301
- } else if (forte && e.letreiro && i % 2 === 0 && peso(forte.texto) >= 7) {
335
+ } else if (forte && e.letreiro && !rec && i % 2 === 0 && peso(forte.texto) >= 7) {
302
336
  const de = local(forte.de);
303
337
  const vida = Math.min(1.6, dur - de);
304
338
  if (vida > 0.5) {
@@ -362,8 +396,10 @@ function montarEdicao({ src, duracao, palavras, largura = 1080, altura = 1920, e
362
396
  ...(trilha ? { trilha: { src: trilha, volume: 0.07, loop: true } } : {}),
363
397
  } } : {}),
364
398
  },
399
+ infos,
365
400
  resumo: {
366
401
  cenas: cenas.length,
402
+ recortes: Object.keys(recortes).length,
367
403
  icones: cenas.reduce((n, c) => n + c.elementos.filter((x) => x.tipo === 'icone').length, 0),
368
404
  apoios: cenas.reduce((n, c) => n + c.elementos.filter((x) => x.tipo === 'imagem' || (x.tipo === 'video' && x.volume === 0)).length, 0),
369
405
  musica: Boolean(trilha),
@@ -402,4 +438,4 @@ async function prepararSons(pasta, video) {
402
438
  return sons;
403
439
  }
404
440
 
405
- module.exports = { montarEdicao, prepararSons, iconeDe, peso, frases, blocos, termosDeApoio, termoDeApoio, ESTILOS, ICONES, FOTO };
441
+ module.exports = { montarEdicao, temRecorte, prepararSons, iconeDe, peso, frases, blocos, termosDeApoio, termoDeApoio, ESTILOS, ICONES, FOTO };
@@ -42,6 +42,7 @@ const identidade = require('./identidade.js');
42
42
  const slides = require('./slides.js');
43
43
  const edicao = require('./edicao.js');
44
44
  const composicao = require('./composicao.js');
45
+ const acervoSite = require('./acervo-site.js');
45
46
 
46
47
  const PY = path.join(__dirname, '..', '..', 'studio');
47
48
 
@@ -81,6 +82,37 @@ function rodarPython(script, args, { timeout = 20 * 60000 } = {}) {
81
82
  });
82
83
  }
83
84
 
85
+ /* O RECORTE DA PESSOA precisa do MediaPipe, e o MediaPipe só existe até o
86
+ Python 3.12 — no Python 3.14 do Homebrew ele nem instala (medido em
87
+ 25/09: o recorte nunca tinha rodado nesta máquina). Com o `uv`, o script
88
+ roda num Python 3.12 isolado, baixado uma vez; sem ele, tenta o Python da
89
+ máquina, como antes. */
90
+ function acharUv() {
91
+ const { execFileSync } = require('child_process');
92
+ for (const c of ['uv', path.join(os.homedir(), '.local', 'bin', 'uv'), '/opt/homebrew/bin/uv', '/usr/local/bin/uv']) {
93
+ try { execFileSync(c, ['--version'], { stdio: 'ignore', timeout: 5000 }); return c; } catch { /* próximo */ }
94
+ }
95
+ return null;
96
+ }
97
+ function rodarPessoa(args, { timeout = 40 * 60000 } = {}) {
98
+ const uv = acharUv();
99
+ if (!uv) return rodarPython('pessoa.py', args, { timeout });
100
+ return new Promise((resolve) => {
101
+ const p = spawn(uv, ['run', '--quiet', '--python', '3.12', '--with', 'mediapipe<0.10.22',
102
+ '--with', 'opencv-python', '--with', 'numpy', 'python', path.join(PY, 'pessoa.py'), ...args]);
103
+ let saida = '', erro = '';
104
+ p.stdout.on('data', (d) => { saida += d.toString(); });
105
+ p.stderr.on('data', (d) => { erro += d.toString(); });
106
+ const relogio = setTimeout(() => { try { p.kill(); } catch {} }, timeout);
107
+ p.on('close', () => {
108
+ clearTimeout(relogio);
109
+ try { resolve(JSON.parse(saida.trim().split('\n').pop())); }
110
+ catch { resolve({ ok: false, error: (erro || saida).slice(-400).trim() || 'o recorte não respondeu' }); }
111
+ });
112
+ p.on('error', (e) => { clearTimeout(relogio); resolve({ ok: false, error: e.message }); });
113
+ });
114
+ }
115
+
84
116
  // ── estudio_projeto ──────────────────────────────────────────────────────
85
117
 
86
118
  /**
@@ -167,10 +199,85 @@ function listarArquivos(pasta) {
167
199
 
168
200
  // ── estudio_plano ────────────────────────────────────────────────────────
169
201
 
202
+ /* O projeto de AGORA, quando o modelo esquece o id: o criado há menos de
203
+ 30 minutos. Medido com o modelo grátis em 25/09: ele chamou estudio_plano
204
+ sem id logo depois de criar o projeto, e a tarefa morreu ali. */
205
+ function projetoRecente() {
206
+ const agora = Date.now();
207
+ const lista = projeto.listar({ limite: 8 })
208
+ .filter((x) => x.criado && agora - new Date(x.criado).getTime() < 30 * 60000)
209
+ .sort((a, b) => new Date(b.criado) - new Date(a.criado));
210
+ return lista[0] || null;
211
+ }
212
+
213
+ /** O roteiro é utilizável como veio? (objeto ou JSON, com partes de tipo conhecido) */
214
+ function roteiroValido(r) {
215
+ let x = r;
216
+ if (typeof x === 'string') { try { x = JSON.parse(x); } catch { return null; } }
217
+ const partes = Array.isArray(x) ? x : x && x.partes;
218
+ if (!Array.isArray(partes) || partes.length < 2) return null;
219
+ if (!partes.every((pt) => pt && composicao.TIPOS.includes(pt.tipo))) return null;
220
+ return Array.isArray(x) ? { partes } : x;
221
+ }
222
+
223
+ // O último site lido, para o projeto que nasceu DEPOIS da leitura.
224
+ let ultimoSite = null;
225
+
226
+ async function garantirAcervo(p) {
227
+ if (p.acervo && (p.acervo.fotos || []).length) return p;
228
+ if (!ultimoSite || Date.now() - ultimoSite.quando > 60 * 60000) return p;
229
+ try {
230
+ const a = await acervoSite.colher({ url: ultimoSite.url, pedido: p.pedido || '', pasta: p.pasta, htmlInicial: ultimoSite.html,
231
+ buscar: identidade.buscar, baixar: assets.baixar });
232
+ projeto.salvar(p.id, { acervo: { fotos: a.fotos, videos: a.videos, fatos: a.fatos, site: ultimoSite.url }, ...(p.marca ? {} : { marca: ultimoSite.marca }) });
233
+ projeto.anotar(p.id, 'midia', `acervo do site: ${a.fotos.length} fotos, ${a.videos.length} vídeos`);
234
+ } catch { /* segue sem acervo */ }
235
+ return projeto.ler(p.id);
236
+ }
237
+
238
+ /* O ROTEIRISTA do servidor: quando o modelo não manda um roteiro que preste
239
+ (nenhum, ou texto corrido), o pedido e os fatos da marca viram o roteiro
240
+ numa chamada só, com uma tarefa só. */
241
+ async function roteiroDoServidor(p, args) {
242
+ const cfg = require('../config').loadConfig();
243
+ const api = require('../api');
244
+ const pedido = [p.pedido, args.pedido].filter(Boolean).join(' — ');
245
+ const seg = Number(args.segundos) || Number((pedido.match(/(\d{2,3})\s*(s\b|seg)/i) || [])[1]) || 30;
246
+ const acervo = p.acervo || {};
247
+ const r = await api.request(cfg.server, '/api/roteiro', {
248
+ pedido, segundos: seg,
249
+ fatos: (acervo.fatos || []).map((f) => f.texto).join('\n\n').slice(0, 5000),
250
+ fotos: (acervo.fotos || []).length ? `${acervo.fotos.length} fotos reais` : '',
251
+ site: acervo.site ? new URL(acervo.site).hostname.replace(/^www\./, '') : '',
252
+ rascunho: typeof args.roteiro === 'string' ? args.roteiro : '',
253
+ }, cfg.token);
254
+ if (!r || !r.ok) return { ok: false, error: (r && r.erro) || 'o roteirista não respondeu.' };
255
+ return { ok: true, roteiro: { ...r.roteiro, marca: (args.roteiro && args.roteiro.marca) || p.marca || undefined } };
256
+ }
257
+
170
258
  async function ferramentaPlano(args) {
171
- const p = projeto.ler(args.id);
259
+ let p = projeto.ler(args.id);
260
+ // Só quando o id FALTOU: id errado continua sendo erro, para não escrever no projeto errado.
261
+ if (!p && (args.id == null || String(args.id).trim() === '') && (args.roteiro || args.plano || args.cenas)) {
262
+ const recente = projetoRecente();
263
+ if (recente) { args = { ...args, id: recente.id }; p = recente; }
264
+ }
172
265
  if (!p) return semProjeto(args.id);
173
266
 
267
+ /* Roteiro que não serve (texto corrido, JSON quebrado, tipo inventado) ou
268
+ nenhum, num projeto ainda sem plano: o roteirista escreve. */
269
+ const valido = args.roteiro ? roteiroValido(args.roteiro) : null;
270
+ if (!args.plano && !args.cenas && !args.voltarPara && !valido && (args.roteiro || !projeto.lerPlano(args.id))) {
271
+ p = await garantirAcervo(p);
272
+ const escrito = await roteiroDoServidor(p, args);
273
+ if (!escrito.ok) return escrito;
274
+ args = { ...args, roteiro: escrito.roteiro };
275
+ projeto.anotar(p.id, 'plano', `roteiro escrito pelo roteirista: ${escrito.roteiro.partes.length} partes`);
276
+ } else if (valido) {
277
+ args = { ...args, roteiro: valido };
278
+ p = await garantirAcervo(p);
279
+ }
280
+
174
281
  if (args.voltarPara) return projeto.voltarPara(args.id, args.voltarPara);
175
282
 
176
283
  if (!args.plano && !args.cenas && !args.roteiro) {
@@ -186,7 +293,15 @@ async function ferramentaPlano(args) {
186
293
  if (args.roteiro) {
187
294
  const r = await prepararRoteiro(p, args);
188
295
  if (!r.ok) return r;
189
- const conferida = plano.conferir(r.plano, { pasta: p.pasta });
296
+ let conferida = plano.conferir(r.plano, { pasta: p.pasta });
297
+ /* A rede de segurança: o elemento que não passa sai, e o vídeo segue.
298
+ Um defeito num ícone não pode custar o vídeo inteiro. */
299
+ if (!conferida.ok) {
300
+ projeto.anotar(p.id, 'defeito', `plano do roteiro reprovou: ${conferida.problemas.slice(0, 4).map((x) => x.onde + ' ' + x.oque).join(' | ')}`);
301
+ const ruins = new Set(conferida.problemas.map((x) => (String(x.onde).match(/^cenas\[(\d+)\]\.elementos\[(\d+)\]/) || []).slice(1, 3).join(':')).filter((k) => k.includes(':')));
302
+ r.plano.cenas.forEach((c, i) => { c.elementos = c.elementos.filter((_, j) => !ruins.has(`${i}:${j}`)); });
303
+ conferida = plano.conferir(r.plano, { pasta: p.pasta });
304
+ }
190
305
  if (!conferida.ok) {
191
306
  return { ok: false, error: 'o roteiro virou um plano com problemas (defeito do PrimoCode, não seu).', problemas: conferida.problemas.slice(0, 12) };
192
307
  }
@@ -481,12 +596,36 @@ async function ferramentaVideo(args, ctx = {}) {
481
596
  } catch { /* segue sem música */ }
482
597
  }
483
598
 
484
- const montado = edicao.montarEdicao({
599
+ const base = {
485
600
  src: path.relative(p.pasta, fonte).replace(/\\/g, '/'),
486
601
  duracao: m.segundos, palavras, largura: m.largura, altura: m.altura,
487
602
  estilo: args.estilo || 'viral', sons, iconesOk, imagens, trilha,
488
- });
603
+ };
604
+ let montado = edicao.montarEdicao(base);
489
605
  if (!montado.ok) return montado;
606
+
607
+ /* A segunda passada: nas frases escolhidas, a pessoa recortada na
608
+ frente da palavra gigante. Só os trechos que entram são recortados
609
+ (2 s cada), não o vídeo inteiro — recortar 52 s levaria minutos. */
610
+ if ((args.estilo || 'viral') === 'viral' && args.recorte !== false) {
611
+ const recortes = {};
612
+ for (const [i, info] of montado.infos.entries()) {
613
+ if (!edicao.temRecorte(i) || !info.forte || info.forte.peso < 9 || Object.keys(recortes).length >= 3) continue;
614
+ const de = Math.max(0, info.forte.de - 0.15);
615
+ const ate = Math.min(info.dur, de + 2.2);
616
+ if (ate - de < 1) continue;
617
+ const seg = path.join(p.pasta, 'midia', `seg-${i + 1}-${Date.now().toString(36)}.mp4`);
618
+ const corte = await video.rodar(video.ffmpeg(), ['-y', '-ss', String(info.ini + de), '-t', String(ate - de), '-i', fonte,
619
+ '-vf', `scale=${Math.min(1280, m.largura)}:-2`, '-an', '-c:v', 'libx264', '-preset', 'veryfast', seg], { timeout: 120000 });
620
+ if (!corte.ok) continue;
621
+ const saida = path.join(p.pasta, 'midia', `pessoa-${i + 1}-${Date.now().toString(36)}.webm`);
622
+ const r = await rodarPessoa(['recortar', '--entrada', seg, '--saida', saida], { timeout: 10 * 60000 });
623
+ try { fs.unlinkSync(seg); } catch { /* fica */ }
624
+ if (!r.ok) break; // sem MediaPipe, nenhum recorte vai dar: não tenta os outros
625
+ recortes[i] = { src: path.relative(p.pasta, saida).replace(/\\/g, '/'), de: Math.round(de * 100) / 100, ate: Math.round(ate * 100) / 100 };
626
+ }
627
+ if (Object.keys(recortes).length) montado = edicao.montarEdicao({ ...base, recortes });
628
+ }
490
629
  const salvo = await ferramentaPlano({ id: args.id, plano: montado.plano, motivo: `edição automática (${args.estilo || 'viral'})` });
491
630
  if (!salvo.ok) return salvo;
492
631
  projeto.anotar(p.id, 'edicao', `${montado.resumo.cenas} cenas, ${montado.resumo.icones} ícones, ${cortes} cortes de silêncio`);
@@ -499,7 +638,7 @@ async function ferramentaVideo(args, ctx = {}) {
499
638
 
500
639
  if (acao === 'recortar-pessoa' || acao === 'pessoa') {
501
640
  const saida = path.join(p.pasta, 'midia', `pessoa-${Date.now().toString(36)}.webm`);
502
- const r = await rodarPython('pessoa.py', [
641
+ const r = await rodarPessoa([
503
642
  'recortar', '--entrada', alvo, '--saida', saida,
504
643
  ...(args.suavizar != null ? ['--suavizar', String(args.suavizar)] : []),
505
644
  ...(args.encolher != null ? ['--encolher', String(args.encolher)] : []),
@@ -538,12 +677,82 @@ async function baixarApoio(p, termo, { orientacao = null, video: querVideo = fal
538
677
  return null;
539
678
  }
540
679
 
680
+ /* ── A VOZ DO GEMINI ─────────────────────────────────────────────────────
681
+ * O roteiro inteiro numa chamada só (o plano grátis tem poucos pedidos por
682
+ * minuto), e as partes achadas depois na transcrição do próprio áudio: a
683
+ * primeira palavra de cada parte, procurada em ordem. Sem achar, o tempo é
684
+ * repartido pelo tamanho do texto de cada parte. */
685
+ const VOZ_GEMINI = { thalita: 'Kore', francisca: 'Aoede', antonio: 'Charon', feminina: 'Kore', masculina: 'Charon' };
686
+ const tokens = (t) => String(t || '').normalize('NFD').replace(/[̀-ͯ]/g, '').toLowerCase().split(/[^a-z0-9]+/).filter(Boolean);
687
+
688
+ function alinharPartes(lista, palavras, total) {
689
+ const ws = palavras.map((w) => ({ ...w, k: tokens(w.texto)[0] || '' }));
690
+ const chars = lista.map((x) => x.texto.length);
691
+ const soma = chars.reduce((a, b) => a + b, 0) || 1;
692
+ const inicios = [0];
693
+ let cursor = 0, acum = 0;
694
+ for (let i = 1; i < lista.length; i++) {
695
+ acum += chars[i - 1];
696
+ const esperado = Math.round(ws.length * acum / soma);
697
+ const alvo = tokens(lista[i].texto).find((w) => w.length >= 3) || tokens(lista[i].texto)[0];
698
+ let achado = -1;
699
+ // Procura perto de onde a proporção diz que a parte começa.
700
+ for (let j = Math.max(cursor + 1, esperado - 12); j < Math.min(ws.length, esperado + 12); j++) {
701
+ if (ws[j].k === alvo) { achado = j; break; }
702
+ }
703
+ const idx = achado > cursor ? achado : Math.max(cursor + 1, Math.min(ws.length - 1, esperado));
704
+ inicios.push(idx);
705
+ cursor = idx;
706
+ }
707
+ return lista.map((_, i) => {
708
+ const a = ws[inicios[i]], b = ws[(inicios[i + 1] || ws.length) - 1];
709
+ return { de: i === 0 ? 0 : (a ? a.de : 0), ate: b ? b.ate : total, palavras: [] };
710
+ });
711
+ }
712
+
713
+ async function narrarComGemini(lista, mp3, { voz } = {}) {
714
+ try {
715
+ const cfg = require('../config').loadConfig();
716
+ const api = require('../api');
717
+ const r = await api.request(cfg.server, '/api/voz', {
718
+ texto: lista.map((x) => x.texto).join('\n\n'),
719
+ voz: VOZ_GEMINI[String(voz || '').toLowerCase()] || voz || 'Charon',
720
+ }, cfg.token);
721
+ if (!r || !r.ok) return { ok: false, error: (r && r.erro) || 'sem voz no servidor' };
722
+ const pcm = mp3.replace(/\.mp3$/, '.pcm');
723
+ fs.writeFileSync(pcm, Buffer.from(r.audio, 'base64'));
724
+ const taxa = (String(r.formato).match(/rate=(\d+)/) || [])[1] || '24000';
725
+ const c = await video.rodar(video.ffmpeg(), ['-y', '-f', 's16le', '-ar', taxa, '-ac', '1', '-i', pcm,
726
+ '-af', 'loudnorm=I=-14:TP=-1.5:LRA=9', '-c:a', 'libmp3lame', '-q:a', '3', mp3], { timeout: 120000 });
727
+ try { fs.unlinkSync(pcm); } catch { /* fica */ }
728
+ if (!c.ok) return { ok: false, error: 'não consegui converter a voz' };
729
+ const med = await video.medir(mp3);
730
+ const tr = await api.requestTranscricao(cfg.server, { token: cfg.token, audio: fs.readFileSync(mp3).toString('base64'), nome: 'voz.mp3', idioma: 'pt' });
731
+ const palavras = (tr && tr.palavras) || [];
732
+ if (!palavras.length) return { ok: false, error: 'sem a transcrição da voz para achar as partes' };
733
+ return { ok: true, voz: `gemini ${r.voz}`, segundos: med.segundos, partes: alinharPartes(lista, palavras, med.segundos) };
734
+ } catch (e) {
735
+ return { ok: false, error: e.message };
736
+ }
737
+ }
738
+
541
739
  const PROPORCOES = { '16:9': [1920, 1080], '9:16': [1080, 1920], '1:1': [1080, 1080], '4:5': [1080, 1350] };
542
740
 
543
741
  /** O roteiro vira plano: baixa o que falta, narra, e chama o compositor. */
544
742
  async function prepararRoteiro(p, args) {
545
743
  const roteiro = typeof args.roteiro === 'string' ? JSON.parse(args.roteiro) : args.roteiro;
546
744
  if (Array.isArray(roteiro)) return prepararRoteiro(p, { ...args, roteiro: { partes: roteiro } });
745
+ /* O que o modelo manda e o disco não tem vira problema de render: um
746
+ "src" que é endereço da internet vira busca, e um arquivo que não
747
+ existe na pasta sai. A logo da marca vale só se estiver lá. */
748
+ const existe = (rel) => rel && !/^https?:/i.test(rel) && fs.existsSync(path.resolve(p.pasta, rel));
749
+ for (const pt of roteiro.partes || []) {
750
+ if (pt.src && !existe(pt.src)) { if (!pt.busca && /^https?:/i.test(pt.src)) pt.busca = pt.titulo || ''; delete pt.src; }
751
+ }
752
+ if (roteiro.marca) {
753
+ roteiro.marca = { ...(p.marca || {}), ...roteiro.marca };
754
+ if (roteiro.marca.logo && !existe(roteiro.marca.logo)) roteiro.marca.logo = (p.marca && existe(p.marca.logo)) ? p.marca.logo : undefined;
755
+ } else if (p.marca) roteiro.marca = p.marca;
547
756
  const partes = roteiro.partes || [];
548
757
  const modo = (args.modo || roteiro.modo || (p.formato === 'slides' || p.formato === 'arte' ? 'slides' : 'video'));
549
758
  const [largura, altura] = PROPORCOES[roteiro.proporcao || args.proporcao] || (modo === 'slides' ? PROPORCOES['16:9'] : PROPORCOES['16:9']);
@@ -552,12 +761,43 @@ async function prepararRoteiro(p, args) {
552
761
  for (const nome of composicao.iconesDoRoteiro(roteiro)) {
553
762
  try { const r = await assets.icone(nome); if (r && r.ok) iconesOk.add(nome); } catch { /* sem */ }
554
763
  }
555
- const imagens = {};
764
+ /* O ACERVO DA MARCA (guardado pelo estudio_site): as fotos reais entram
765
+ nas partes de imagem e de FUNDO nas cenas de texto; os vídeos, nas
766
+ partes de imagem do vídeo. A foto escolhida é a que mais combina, pela
767
+ descrição, com o que a parte diz. Sem acervo, vale a busca (Pexels). */
768
+ const acervo = (p.acervo && (p.acervo.fotos || []).length) ? p.acervo : { fotos: [], videos: [] };
769
+ const usadas = new Set();
770
+ const palavrasDe = (t) => new Set(String(t || '').normalize('NFD').replace(/[\u0300-\u036f]/g, '').toLowerCase().split(/[^a-z0-9]+/).filter((w) => w.length >= 4));
771
+ const melhorFoto = (texto) => {
772
+ const quer = palavrasDe(texto);
773
+ let melhor = null, pontos = -1;
774
+ for (const f of acervo.fotos) {
775
+ if (usadas.has(f.src)) continue;
776
+ const n = [...palavrasDe(f.alt)].filter((w) => quer.has(w)).length;
777
+ if (n > pontos) { melhor = f; pontos = n; }
778
+ }
779
+ if (melhor) usadas.add(melhor.src);
780
+ return melhor ? { src: melhor.src, tipo: 'imagem' } : null;
781
+ };
782
+ const videosMarca = [...(acervo.videos || [])];
783
+ const imagens = {}, fundos = {};
556
784
  for (const [i, parte] of partes.entries()) {
557
- if (parte.tipo !== 'imagem' || parte.src || !parte.busca) continue;
558
- const m = await baixarApoio(p, parte.busca, { orientacao: largura >= altura ? 'landscape' : 'portrait', video: modo === 'video' });
785
+ if (parte.tipo !== 'imagem' || parte.src) continue;
786
+ const texto = `${parte.titulo || ''} ${parte.sub || ''} ${parte.fala || ''} ${parte.busca || ''}`;
787
+ if (modo === 'video' && videosMarca.length && i % 2 === 1) { imagens[i] = { src: videosMarca.shift().src, tipo: 'video' }; continue; }
788
+ const m = melhorFoto(texto) || (parte.busca ? await baixarApoio(p, parte.busca, { orientacao: largura >= altura ? 'landscape' : 'portrait', video: modo === 'video' }) : null);
559
789
  if (m) imagens[i] = m;
560
790
  }
791
+ if (acervo.fotos.length) {
792
+ const comFundo = ['abertura', 'titulo', 'contraste', 'citacao', 'cta', 'passos'];
793
+ let alterna = 0;
794
+ for (const [i, parte] of partes.entries()) {
795
+ if (!comFundo.includes(parte.tipo)) continue;
796
+ if (i !== 0 && alterna++ % 2 === 1) continue; // uma sim, uma não: o respiro também é ritmo
797
+ const m = melhorFoto(`${parte.titulo || ''} ${parte.sim || ''} ${parte.fala || ''}`);
798
+ if (m) fundos[i] = m;
799
+ }
800
+ }
561
801
 
562
802
  let tempos = [], narracao = null, sons = {}, trilha = null;
563
803
  if (modo === 'video') {
@@ -566,7 +806,9 @@ async function prepararRoteiro(p, args) {
566
806
  fs.mkdirSync(path.dirname(arq), { recursive: true });
567
807
  fs.writeFileSync(arq, JSON.stringify(lista));
568
808
  const mp3 = path.join(p.pasta, 'midia', `narracao-${Date.now().toString(36)}.mp3`);
569
- const v = await rodarPython('narrar.py', ['roteiro', '--arquivo', arq, '--saida', mp3, '--voz', String(args.voz || roteiro.voz || 'thalita')], { timeout: 10 * 60000 });
809
+ // A voz de gente primeiro (Gemini, pelo servidor); o Edge é a reserva.
810
+ let v = await narrarComGemini(lista, mp3, { voz: args.voz || roteiro.voz });
811
+ if (!v.ok) v = await rodarPython('narrar.py', ['roteiro', '--arquivo', arq, '--saida', mp3, '--voz', String(args.voz || roteiro.voz || 'thalita')], { timeout: 10 * 60000 });
570
812
  if (!v.ok) return v;
571
813
  tempos = v.partes;
572
814
  narracao = path.relative(p.pasta, mp3).replace(/\\/g, '/');
@@ -580,7 +822,7 @@ async function prepararRoteiro(p, args) {
580
822
  }
581
823
  } catch { /* sem música */ }
582
824
  }
583
- return composicao.comporRoteiro({ roteiro, modo, largura, altura, tempos, narracao, sons, trilha, imagens, iconesOk });
825
+ return composicao.comporRoteiro({ roteiro, modo, largura, altura, tempos, narracao, sons, trilha, imagens, iconesOk, fundos });
584
826
  }
585
827
 
586
828
  /* Sem o tempo de cada palavra, cada trecho é repartido pelas palavras dele,
@@ -746,6 +988,13 @@ async function ferramentaSite(args) {
746
988
  uma pasta de projeto, é lixo que ninguém recolhe. */
747
989
  const html = r.html;
748
990
  delete r.html; // o HTML cru não vai para o modelo
991
+ if (r.ok) {
992
+ const marcaLida = {
993
+ cores: { destaque: ((r.cores && r.cores.marca) || [])[0] ? r.cores.marca[0].cor : undefined },
994
+ fontes: { titulo: ((r.fontes && r.fontes.declaradas) || [])[0] ? r.fontes.declaradas[0].fonte : undefined },
995
+ };
996
+ ultimoSite = { url: r.url, html, quando: Date.now(), marca: marcaLida };
997
+ }
749
998
  if (r.ok && args.id && args.logo !== false) {
750
999
  const p = projeto.ler(args.id);
751
1000
  if (p) {
@@ -762,11 +1011,44 @@ async function ferramentaSite(args) {
762
1011
  if (r.ok && args.id) {
763
1012
  projeto.anotar(args.id, 'referencia', `leu ${r.url}: ${(r.cores.marca[0] || {}).cor || 'sem cor'} · ${(r.fontes.declaradas[0] || {}).fonte || 'sem fonte'}`);
764
1013
  }
1014
+
1015
+ /* O ACERVO DA MARCA: as fotos e os vídeos reais do site, e o texto das
1016
+ páginas que combinam com o pedido (lib/estudio/acervo-site.js). As
1017
+ fotos ficam guardadas no projeto e o roteiro as usa sozinho; os fatos
1018
+ voltam para o modelo escrever o roteiro com dado de verdade. */
1019
+ if (r.ok && args.id && args.acervo !== false) {
1020
+ const p = projeto.ler(args.id);
1021
+ if (p) {
1022
+ try {
1023
+ const a = await acervoSite.colher({
1024
+ url: r.url, pedido: args.pedido || p.pedido || '', pasta: p.pasta, htmlInicial: html,
1025
+ buscar: identidade.buscar, baixar: assets.baixar,
1026
+ });
1027
+ const marca = { ...ultimoSite.marca, ...(r.logo && r.logo.ok ? { logo: r.logo.src } : {}) };
1028
+ projeto.salvar(args.id, { acervo: { fotos: a.fotos, videos: a.videos, fatos: a.fatos, site: r.url }, marca });
1029
+ projeto.anotar(args.id, 'midia', `acervo do site: ${a.fotos.length} fotos, ${a.videos.length} vídeos, ${a.paginas.length} páginas lidas`);
1030
+ let orcamento = 4200;
1031
+ r.fatos = a.fatos.map((f) => {
1032
+ const texto = f.texto.slice(0, Math.max(0, Math.min(1400, orcamento)));
1033
+ orcamento -= texto.length;
1034
+ return { pagina: f.pagina, texto };
1035
+ }).filter((f) => f.texto);
1036
+ r.acervo = { fotos: a.fotos.length, videos: a.videos.length,
1037
+ exemplos: a.fotos.slice(0, 6).map((f) => f.alt).filter(Boolean) };
1038
+ r.comoUsar = 'Escreva o roteiro com os FATOS acima (datas, local, números, frases da marca) — nada '
1039
+ + 'inventado. As fotos e os vídeos reais da marca entram SOZINHOS no vídeo: não precisa pôr '
1040
+ + '"busca" nas partes de imagem (use tipo "imagem" com só o título). Use as cores, a fonte e '
1041
+ + 'a logo em roteiro.marca.';
1042
+ } catch (e) {
1043
+ r.acervo = { erro: e.message };
1044
+ }
1045
+ }
1046
+ }
765
1047
  return r;
766
1048
  }
767
1049
 
768
1050
  module.exports = {
769
1051
  ferramentaProjeto, ferramentaPlano, ferramentaMidia, ferramentaNarrar,
770
1052
  ferramentaVideo, ferramentaRender, ferramentaSite,
771
- projeto, plano, remotion, assets, video, identidade, slides, python, rodarPython,
1053
+ projeto, plano, remotion, assets, video, identidade, slides, python, rodarPython, alinharPartes,
772
1054
  };
@@ -1,5 +1,5 @@
1
1
  {
2
- "origem": "primocode-server 8.15.0 52f6257",
2
+ "origem": "primocode-server 8.18.0 437cba2",
3
3
  "SYSTEM_NUCLEO": "Você é o PrimoCode, agente de engenharia da ConectaPrimoAI.\n\nCONVERSA OU TRABALHO: cumprimento, agradecimento, pergunta sobre você ou papo\n(\"oi\", \"o que você faz?\") → responda em texto, 1 ou 2 frases, sem tool nenhuma,\ne não invente tarefa que ninguém pediu. Pedido de criar, alterar, ler, rodar ou\nconsertar → EXECUTE, até o fim, sem conversar no meio.\n\nREGRAS ABSOLUTAS (executando):\n1. NUNCA escreva código na resposta. Código vai SEMPRE dentro de uma tool.\n2. NUNCA pergunte ao usuário. Decida e execute.\n3. NUNCA invente ferramenta, serviço, porta ou API: as suas são EXATAMENTE as\n desta lista. E nunca diga que algo \"não está rodando\" para se esquivar — o\n que precisa subir, sobe sozinho quando você chama a tool. Prometer o que\n você não tem, ou culpar um serviço, é o pior erro possível.\n4. Caminhos RELATIVOS (\"index.html\", \"src/game.js\"). Você já está na pasta do\n projeto: nunca caminho absoluto, nunca \"../\".\n5. Termine SEMPRE com finish(\"frase curta do que foi feito\").\n6. Resposta em texto: no máximo 1 frase, sem markdown.\n7. NUNCA repita uma chamada que já fez. Reformular a mesma busca traz as mesmas\n fontes; spec e manual são FIXOS — leu uma vez, está lido. Pesquisou e leu a\n spec? O passo seguinte é CRIAR com o que já tem.\n\nESCOLHENDO A TOOL: arquivo novo → write_file; mudar parte → edit_file (nunca\nreescreva o arquivo inteiro para trocar poucas linhas); várias mudanças no mesmo\narquivo → multi_edit, numa chamada; não sabe onde está → grep (conteúdo) ou glob\n(nome); arquivo grande → read_file com offset/limit; servidor que fica de pé →\nrun_in_new_terminal.\n\nANTES DE EDITAR, LEIA — SEM EXCEÇÃO: edit_file exige o texto LITERAL do arquivo,\ne você não adivinha indentação. read_file (ou grep) PRIMEIRO, edit_file depois.\n\nQUANDO FALHA: leia o erro e conserte. Se edit_file disser \"trecho não\nencontrado\", ele devolve \"trechosParecidos\" com as linhas reais — copie de lá.\nNUNCA chame finish dizendo que fez algo que falhou: \"não consegui alterar X\nporque Y\" é melhor que um \"pronto!\" falso.\n\nPLANO: tarefa de 3+ passos começa com write_todos e vai atualizando o status (só\nUMA \"in_progress\" por vez). Tarefa curta não precisa.\n\nCOMO VOCÊ FALA ENQUANTO TRABALHA: antes de uma tool que muda o rumo (conserto,\nconversão, desvio), UMA frase em primeira pessoa com o quê e o porquê (\"o vídeo\nveio em .avi, vou converter para mp4\"); resolvido, \"feito, continuando\". Passo\nóbvio não precisa. NUNCA cite limite, cota, token, modelo, provedor, tentativa\nou erro interno: quem lê vê o trabalho, não o encanamento. Contornou uma\nfalha? Conte o contorno.\n\nMEMÓRIA: você recebe memory-geral.md (o usuário) e o memory.md do projeto.\nRespeite as preferências e decisões que estão lá, e não recomece do zero um\nprojeto que já tem arquivos — leia antes de escrever. O memory.md do projeto se\ngrava sozinho. Já o memory-geral.md só guarda o que você mandar com\nremember_general: nome, preferência que vale em qualquer projeto, decisão de\nlongo prazo. Sem essa chamada, nada disso sobrevive para a próxima vez.\n\nPROCESSO: ver o que existe (list_dir/glob) → entender (read_file/grep) → fazer\n(write_file/edit_file) → conferir → finish.",
4
4
  "SECOES": {
5
5
  "pesquisa": "PEDIDO QUE DEPENDE DE FATO DO MUNDO — PESQUISE PRIMEIRO:\nSe o que o usuário pediu envolve algo real (uma empresa, um produto, uma\npessoa, um mercado, um número, um acontecimento), chame pesquisar_referencia\nANTES de criar qualquer coisa, em qualquer esforço. \"Pesquise sobre a empresa X\ne faça slides\" é UM pedido só: pesquisa e depois cria, sem parar para perguntar\ne sem entregar o deck com dado inventado.\n\nUm deck bonito cheio de número inventado é pior que nenhum deck — o usuário\nleva aquilo para uma reunião. Se depois da pesquisa faltar um dado, escreva na\npeça o que você sabe e deixe o buraco explícito (\"faturamento: não divulgado\"),\nnunca um número plausível de sua cabeça.",
@@ -743,7 +743,7 @@
743
743
  "type": "function",
744
744
  "function": {
745
745
  "name": "estudio_site",
746
- "description": "Le cores, fontes, logo e discurso de uma marca de verdade. Use SEMPRE que o pedido citar uma empresa. Com id, baixa a logo.",
746
+ "description": "Le cores, fontes, logo e discurso de uma marca de verdade. Use SEMPRE que o pedido citar uma empresa. Com id, baixa a logo e o ACERVO: fotos e videos reais do site (entram sozinhos no video) e os FATOS das paginas (datas, local, numeros) para escrever o roteiro sem inventar.",
747
747
  "parameters": {
748
748
  "type": "object",
749
749
  "properties": {
@@ -755,6 +755,10 @@
755
755
  },
756
756
  "id": {
757
757
  "type": "string"
758
+ },
759
+ "pedido": {
760
+ "type": "string",
761
+ "description": "O pedido do usuario: decide quais paginas ler"
758
762
  }
759
763
  },
760
764
  "required": []
@@ -1927,7 +1931,7 @@
1927
1931
  "type": "function",
1928
1932
  "function": {
1929
1933
  "name": "estudio_site",
1930
- "description": "Le cores, fontes, logo e discurso de uma marca de verdade. Use SEMPRE que o pedido citar uma empresa. Com id, baixa a logo.",
1934
+ "description": "Le cores, fontes, logo e discurso de uma marca de verdade. Use SEMPRE que o pedido citar uma empresa. Com id, baixa a logo e o ACERVO: fotos e videos reais do site (entram sozinhos no video) e os FATOS das paginas (datas, local, numeros) para escrever o roteiro sem inventar.",
1931
1935
  "parameters": {
1932
1936
  "type": "object",
1933
1937
  "properties": {
@@ -1939,6 +1943,10 @@
1939
1943
  },
1940
1944
  "id": {
1941
1945
  "type": "string"
1946
+ },
1947
+ "pedido": {
1948
+ "type": "string",
1949
+ "description": "O pedido do usuario: decide quais paginas ler"
1942
1950
  }
1943
1951
  },
1944
1952
  "required": []
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "primocode",
3
- "version": "9.8.0-beta.14",
3
+ "version": "9.8.0-beta.16",
4
4
  "description": "PrimoCode — agente de engenharia com IA e cursor próprio. Requer conta Conecta Primo AI (Premium ou Super). Cria arquivos, roda comandos, controla navegador e desktop: abre apps, clica em botões e ícones pelo nome, digita e usa atalhos.",
5
5
  "main": "bin/primocode.js",
6
6
  "bin": {
@@ -8,7 +8,7 @@
8
8
  },
9
9
  "scripts": {
10
10
  "start": "node bin/primocode.js",
11
- "test": "node test/tools.test.js && node test/ui.test.js && node test/entrada.test.js && node test/transparencia.test.js && node test/permissao.test.js && node test/compatibilidade.test.js && node test/skills.test.js && node test/skills-de-fora.test.js && node test/skills-novas.test.js && node test/comandos.test.js && node test/estudio.test.js && node test/voz.test.js && node test/voz-conversa.test.js && node test/modo.test.js && node test/pasta.test.js && node test/projeto.test.js && node test/nome-projeto.test.js && node test/api-propria.test.js && node test/claude-engine.test.js && node test/codex.test.js && node test/conferir.test.js && node test/catalogo.test.js && node test/desktop.test.js && node test/seta.test.js && node test/effort.test.js && node test/regressao.test.js && node test/memoria-conversa.test.js && node test/repeticao.test.js && node test/autonomia.test.js && node test/desfazer.test.js && node test/desfazer-git.test.js && node test/referencia.test.js && node test/parar.test.js && node test/continuar.test.js && node test/pipeline.test.js && node test/app.test.js && node test/app-janela.test.js && node test/aplicativo.test.js && node test/conta.test.mjs && node test/nuvem.test.js && node test/prazo.test.js && node test/primeira-vez.test.js && node test/janela.test.js && node test/fala.test.js && node test/especialistas.test.js && node test/empurrao.test.js && node test/terminal-novo.test.js && node test/markdown.test.js && node test/coerencia.test.js && node test/contexto.test.js && node test/perigo.test.js && node test/rapidez.test.js && node test/mascotes.test.js && node test/comandos-extra.test.js && node test/offline.test.js && node test/edicao.test.js && node test/composicao.test.js"
11
+ "test": "node test/tools.test.js && node test/ui.test.js && node test/entrada.test.js && node test/transparencia.test.js && node test/permissao.test.js && node test/compatibilidade.test.js && node test/skills.test.js && node test/skills-de-fora.test.js && node test/skills-novas.test.js && node test/comandos.test.js && node test/estudio.test.js && node test/voz.test.js && node test/voz-conversa.test.js && node test/modo.test.js && node test/pasta.test.js && node test/projeto.test.js && node test/nome-projeto.test.js && node test/api-propria.test.js && node test/claude-engine.test.js && node test/codex.test.js && node test/conferir.test.js && node test/catalogo.test.js && node test/desktop.test.js && node test/seta.test.js && node test/effort.test.js && node test/regressao.test.js && node test/memoria-conversa.test.js && node test/repeticao.test.js && node test/autonomia.test.js && node test/desfazer.test.js && node test/desfazer-git.test.js && node test/referencia.test.js && node test/parar.test.js && node test/continuar.test.js && node test/pipeline.test.js && node test/app.test.js && node test/app-janela.test.js && node test/aplicativo.test.js && node test/conta.test.mjs && node test/nuvem.test.js && node test/prazo.test.js && node test/primeira-vez.test.js && node test/janela.test.js && node test/fala.test.js && node test/especialistas.test.js && node test/empurrao.test.js && node test/terminal-novo.test.js && node test/markdown.test.js && node test/coerencia.test.js && node test/contexto.test.js && node test/perigo.test.js && node test/rapidez.test.js && node test/mascotes.test.js && node test/comandos-extra.test.js && node test/offline.test.js && node test/edicao.test.js && node test/composicao.test.js && node test/acervo-site.test.js"
12
12
  },
13
13
  "engines": {
14
14
  "node": ">=18.17.0"