primocode 9.8.0-beta.15 → 9.8.0-beta.17

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.15
1
+ # PrimoCode v9.8.0-beta.17
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.15
12
+ ▐▛███▜▌ Primo Code v9.8.0-beta.17
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 };
@@ -120,7 +120,41 @@ const COLECOES = [
120
120
  * O SVG vem com `currentColor` no traço para a cor ser decidida na cena — um
121
121
  * ícone com cor cravada no arquivo obriga a baixar de novo para mudar de cor.
122
122
  */
123
+ /* ── EMOJI 3D ─────────────────────────────────────────────────────────
124
+ * "Os emojis, quero emojis da Apple." (25/09/2026) Os da Apple têm direito
125
+ * autoral e não podem ir num vídeo publicado; os Fluent 3D da Microsoft têm
126
+ * licença MIT e o mesmo visual brilhante e com volume. "fluent-3d:robot" é o
127
+ * PNG do repositório oficial (pelo jsDelivr), embutido como <img> — o resto
128
+ * do render trata igual a um ícone SVG. Sem o 3D, cai no emoji plano. */
129
+ const cache3d = new Map();
130
+ async function emoji3d(nome, { pasta = null } = {}) {
131
+ if (cache3d.has(nome)) return cache3d.get(nome);
132
+ const palavras = nome.split('-');
133
+ const pastas = [
134
+ palavras.join(' ').replace(/^./, (c) => c.toUpperCase()),
135
+ palavras.map((w) => w[0].toUpperCase() + w.slice(1)).join(' '),
136
+ ];
137
+ for (const pastaGit of pastas) {
138
+ const url = `https://cdn.jsdelivr.net/gh/microsoft/fluentui-emoji@main/assets/${encodeURIComponent(pastaGit)}/3D/${palavras.join('_')}_3d.png`;
139
+ try {
140
+ const png = await pegar(url, { binario: true });
141
+ if (png && png.length > 1000 && png[0] === 0x89) {
142
+ if (pasta) guardar(path.join(pasta, 'midia', 'icones', `fluent-3d-${nome}.png`), png, { licenca: 'MIT (Microsoft Fluent UI Emoji)', origem: url });
143
+ const r = { ok: true, nome: 'fluent-3d:' + nome,
144
+ svg: `<img src="data:image/png;base64,${png.toString('base64')}" style="width:100%;height:100%;object-fit:contain;display:block" />` };
145
+ cache3d.set(nome, r);
146
+ return r;
147
+ }
148
+ } catch { /* a próxima grafia */ }
149
+ }
150
+ const plano = await icone('fluent-emoji-flat:' + nome, { pasta });
151
+ const r = plano.ok ? { ...plano, nome: 'fluent-3d:' + nome } : plano;
152
+ cache3d.set(nome, r);
153
+ return r;
154
+ }
155
+
123
156
  async function icone(nome, { pasta = null } = {}) {
157
+ if (/^fluent-3d:/i.test(String(nome))) return emoji3d(String(nome).split(':')[1], { pasta });
124
158
  const m = String(nome || '').match(/^([a-z0-9-]+):([a-z0-9-]+)$/i);
125
159
  if (!m) return { ok: false, error: 'o ícone é "coleção:nome", como "lucide:zap"' };
126
160
  const url = `https://api.iconify.design/${m[1]}/${m[2]}.svg?color=currentColor`;
@@ -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),
@@ -58,7 +64,8 @@ function paleta(marca = {}) {
58
64
  /* ── a letra que cabe ─────────────────────────────────────────────────────
59
65
  * Largura média do caractere em "em" por família. Não é exata — é o
60
66
  * bastante para escolher o maior tamanho que cabe em N linhas, com folga. */
61
- const LARGURA = { condensada: 0.43, display: 0.56, texto: 0.52, serifa: 0.5 };
67
+ // Com folga: medido em 25/09, 0,52 deixava "Fundadores" quebrar no meio no card.
68
+ const LARGURA = { condensada: 0.46, display: 0.6, texto: 0.58, serifa: 0.54 };
62
69
  const FAMILIA_TIPO = (f) => (/condensed|narrow|anton|oswald|bebas|teko|league gothic/i.test(f) ? 'condensada'
63
70
  : /serif|fraunces|playfair|garamond|lora|newsreader|spectral|baskerville|dm serif/i.test(f) ? 'serifa'
64
71
  : /black|bricolage|archivo|sora|grotesk|syne|unbounded/i.test(f) ? 'display' : 'texto');
@@ -106,6 +113,7 @@ function registroDe(p, i, n) {
106
113
  function iconesDoRoteiro(roteiro) {
107
114
  const nomes = new Set();
108
115
  for (const p of roteiro.partes || []) {
116
+ for (const t of [`${p.titulo || ''} ${p.sub || ''}`, p.sim]) { const ic = iconeDoTexto(t); if (ic) nomes.add(ic); }
109
117
  for (const it of p.itens || []) {
110
118
  const t = typeof it === 'string' ? it : it.texto;
111
119
  const ic = (typeof it === 'object' && it.icone) || iconeDoTexto(t);
@@ -134,7 +142,7 @@ function iconeDoTexto(t) {
134
142
  * @param {Set} op.iconesOk os ícones que existem
135
143
  */
136
144
  function comporRoteiro({ roteiro, modo = 'video', largura = 1920, altura = 1080, tempos = [], narracao = null,
137
- sons = {}, trilha = null, imagens = {}, iconesOk = null }) {
145
+ sons = {}, trilha = null, imagens = {}, iconesOk = null, fundos = {} }) {
138
146
  const partes = (roteiro && roteiro.partes) || [];
139
147
  if (!partes.length) return { ok: false, error: 'o roteiro veio sem partes.' };
140
148
  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,12 +179,22 @@ function comporRoteiro({ roteiro, modo = 'video', largura = 1920, altura = 1080,
171
179
  let fundo = { tipo: 'cor', valor: variante ? cor.fundo2 : cor.fundo };
172
180
  let tinta = cor.tinta, destaque = cor.destaque, suave = cor.suave;
173
181
  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') {
182
+ /* A FOTO DA MARCA DE FUNDO: a cena de texto ganha a sala de verdade
183
+ atrás, com um véu da cor do fundo para o texto continuar legível. */
184
+ const fotoFundo = fundos[i];
185
+ if (fotoFundo && p.tipo !== 'imagem') {
186
+ const ehV = fotoFundo.tipo === 'video';
187
+ els.push({ tipo: ehV ? 'video' : 'imagem', src: fotoFundo.src, ...(ehV ? { volume: 0 } : {}), x: 0, y: 0, largura: '100%', altura: '100%', ajuste: 'cover', foco: 'center 28%',
188
+ anima: { escala: [{ t: 0, v: variante ? 1.14 : 1.02 }, { t: dur, v: variante ? 1.02 : 1.14, e: 'suave' }] } });
189
+ els.push({ tipo: 'forma', forma: 'retangulo', x: 0, y: 0, largura: '100%', altura: '100%',
190
+ fundo: { tipo: 'gradiente', cores: [alfa(cor.fundo, 0.93), alfa(cor.fundo, 0.72), alfa(cor.fundo, 0.55)], angulo: 90 } });
191
+ } else if (p.tipo !== 'imagem') {
175
192
  const R = Math.round(Math.max(W, H) * 0.62);
176
193
  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
194
  els.push({
178
195
  tipo: 'forma', forma: 'circulo', largura: R, altura: R, x: Math.round(canto[0]), y: Math.round(canto[1]),
179
- cor: p.tipo === 'numero' ? cor.sobreDestaque : cor.destaque, opacidade: p.tipo === 'numero' ? 0.12 : (cor.escuro ? 0.22 : 0.16),
196
+ // O brilho alterna entre o destaque e a cor de apoio: as duas cores da marca aparecem.
197
+ cor: p.tipo === 'numero' ? cor.sobreDestaque : (variante && marca.cores && marca.cores.apoio ? cor.apoio : cor.destaque), opacidade: p.tipo === 'numero' ? 0.12 : (cor.escuro ? 0.26 : 0.16),
180
198
  anima: {
181
199
  desfoque: [{ t: 0, v: Math.round(R * 0.22) }],
182
200
  x: [{ t: 0, v: 0 }, { t: dur, v: variante ? 60 : -60, e: 'suave' }],
@@ -210,6 +228,22 @@ function comporRoteiro({ roteiro, modo = 'video', largura = 1920, altura = 1080,
210
228
  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
229
  cor: c, origem: 'left', anima: { escalaX: [{ t: atraso(de), v: 0 }, { t: atraso(de) + 0.5, v: 1, e: 'saida' }] },
212
230
  });
231
+ /* O ÍCONE DA IDEIA: um emoji grande, que salta e depois flutua, do
232
+ lado livre da cena — o que dá vida a cena de texto sem virar enfeite. */
233
+ const iconeDaIdeia = (texto, x, y, tam, de = 0.35) => {
234
+ const ic = iconeDoTexto(texto);
235
+ if (!ic || (iconesOk && !iconesOk.has(ic))) return false;
236
+ const q = atraso(de);
237
+ const t2 = r2(Math.max(q + 0.6, dur * 0.55)), t3 = r2(Math.max(t2 + 0.3, dur));
238
+ els.push({ tipo: 'icone', nome: ic, tamanho: Math.round(tam), x: Math.round(x), y: Math.round(y), ancora: 'centro',
239
+ anima: {
240
+ escala: [{ t: q, v: 0 }, { t: q + 0.25, v: 1.18, e: 'saida' }, { t: q + 0.42, v: 1, e: 'suave' }],
241
+ rotacao: [{ t: q, v: -18 }, { t: q + 0.42, v: 0, e: 'saida' }, { t: t3, v: 6, e: 'suave' }],
242
+ y: [{ t: q + 0.42, v: 0 }, { t: t2, v: -16, e: 'suave' }, { t: t3, v: 4, e: 'suave' }],
243
+ } });
244
+ if (vid && sons.pop) efeitos.push({ t: r2((tp.de || 0) + q), src: sons.pop, volume: 0.55 });
245
+ return true;
246
+ };
213
247
  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
248
 
215
249
  switch (p.tipo) {
@@ -226,6 +260,7 @@ function comporRoteiro({ roteiro, modo = 'video', largura = 1920, altura = 1080,
226
260
  if (!centro) traco(M, y - H * 0.05);
227
261
  titulo(p.titulo || '', { y, larg, linhas: 3, max: fit.tamanho, x, alinhar: centro ? 'center' : 'left' });
228
262
  if (p.sub) linhaFina(p.sub, { y: y + hT + H * 0.03, larg, x, alinhar: centro ? 'center' : 'left', de: 0.6 });
263
+ if (!centro && deitado) iconeDaIdeia(`${p.titulo || ''} ${p.sub || ''}`, W - M - W * 0.09, H * 0.5, H * 0.2, 0.5);
229
264
  break;
230
265
  }
231
266
  case 'numero': {
@@ -260,7 +295,7 @@ function comporRoteiro({ roteiro, modo = 'video', largura = 1920, altura = 1080,
260
295
  vazia embaixo. */
261
296
  const fT = p.titulo ? caber(p.titulo, { largura: W0, linhas: 2, max: H * 0.1, fonte: F.titulo }) : { tamanho: 0, linhas: 0 };
262
297
  const hTit = fT.tamanho * 1.04 * fT.linhas;
263
- const hCard0 = colunas > 1 ? (p.tipo === 'passos' ? H * 0.26 : H * 0.44) : H * 0.14;
298
+ const hCard0 = colunas > 1 ? (p.tipo === 'passos' ? H * 0.26 : H * 0.44) : H * (deitado ? 0.14 : 0.13);
264
299
  const hItens = colunas > 1 ? hCard0 : n * hCard0 + (n - 1) * gap;
265
300
  const bloco = hTit + (hTit ? H * 0.07 : 0) + hItens;
266
301
  const yTit = Math.max(M, (H - bloco) / 2);
@@ -268,12 +303,31 @@ function comporRoteiro({ roteiro, modo = 'video', largura = 1920, altura = 1080,
268
303
  const topo = yTit + hTit + (hTit ? H * 0.07 : 0);
269
304
  const hCard = Math.min(hCard0, colunas > 1 ? H - topo - M : (H - topo - M - gap * (n - 1)) / n);
270
305
  const dur0 = vid ? Math.max(1, (tp.ate || dur) - (tp.de || 0)) : 1;
306
+ /* Um tamanho de letra para TODOS os itens: o do mais apertado. Cada
307
+ item com o seu tamanho deixava um card com a letra maior que os
308
+ outros, e esse texto saía do card (medido em 25/09). */
309
+ const padG = Math.round(Math.min(wCard, hCard) * 0.14);
310
+ const tamIG = Math.round(Math.min(hCard * (colunas > 1 ? 0.28 : 0.5), H * 0.09));
311
+ const lwG = colunas > 1 ? wCard - 2 * padG : wCard - (tamIG + 3 * padG);
312
+ // No vertical a linha é estreita: 3 linhas por item, para a letra caber grande.
313
+ const linhasG = colunas > 1 ? 4 : (deitado ? 2 : 3);
314
+ const hTexto = colunas > 1 ? hCard - (padG * 1.8 + tamIG + padG) : hCard - padG;
315
+ const maxItem = deitado ? Math.min(H * 0.05, hCard * 0.3) : Math.min(W * 0.062, hCard * 0.26);
316
+ const fits = itens.map((it) => caber(it.texto, { largura: lwG, linhas: linhasG, max: maxItem, fonte: F.texto }));
317
+ let tamItens = Math.min(...fits.map((f) => f.tamanho));
318
+ // Só na lista o texto mora DENTRO do card: ali a altura também manda.
319
+ if (p.tipo === 'lista') {
320
+ const linhasReais = Math.max(...itens.map((it) => caber(it.texto, { largura: lwG, linhas: linhasG, max: tamItens, min: tamItens, fonte: F.texto }).linhas));
321
+ tamItens = Math.min(tamItens, Math.floor(hTexto / (1.2 * linhasReais)));
322
+ }
271
323
  itens.forEach((it, k) => {
272
324
  const cx = colunas > 1 ? M + k * (wCard + gap) : M;
273
325
  const cy = colunas > 1 ? topo : topo + k * (hCard + gap);
274
326
  // O item entra quando a voz chega nele (ou em cascata, no slide).
275
327
  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' }] };
328
+ // Entra de baixo e depois respira: nada fica parado como slide.
329
+ const entra = { opacidade: [{ t: quando, v: 0 }, { t: quando + 0.3, v: 1 }],
330
+ 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
331
  if (p.tipo === 'lista') {
278
332
  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
333
  } else if (k < n - 1 && colunas > 1) {
@@ -292,7 +346,8 @@ function comporRoteiro({ roteiro, modo = 'video', largura = 1920, altura = 1080,
292
346
  const tx = colunas > 1 ? cx + pad : cx + pad + tamI + pad;
293
347
  const ty = colunas > 1 ? cy + pad + tamI + pad * 0.8 : cy + hCard / 2;
294
348
  const lw = colunas > 1 ? wCard - 2 * pad : wCard - (tamI + 3 * pad);
295
- const fit = caber(it.texto, { largura: lw, linhas: colunas > 1 ? 4 : 2, max: Math.min(H * 0.05, hCard * 0.3), fonte: F.texto });
349
+ const fit0 = caber(it.texto, { largura: lw, linhas: linhasG, max: tamItens, min: Math.min(18, tamItens), fonte: F.texto });
350
+ const fit = { tamanho: Math.min(fit0.tamanho, tamItens), linhas: fit0.linhas };
296
351
  els.push({
297
352
  tipo: 'texto', texto: it.texto, fonte: F.texto, peso: 650, tamanho: fit.tamanho, cor: tinta, entrelinha: 1.2,
298
353
  x: Math.round(tx), y: Math.round(colunas > 1 ? ty : ty - fit.tamanho * 1.2 * fit.linhas / 2), largura: Math.round(lw), anima: entra,
@@ -310,6 +365,7 @@ function comporRoteiro({ roteiro, modo = 'video', largura = 1920, altura = 1080,
310
365
  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
366
  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
367
  titulo(p.sim || '', { y: y1 + f1.tamanho * 1.2 * f1.linhas + H * 0.05, larg, linhas: 3, max: f2.tamanho, de: atraso(meio) });
368
+ if (deitado) iconeDaIdeia(p.sim || '', W - M - W * 0.06, H * 0.22, H * 0.14, meio + 0.3);
313
369
  if (vid && sons.pop) efeitos.push({ t: r2((tp.de || 0) + meio), src: sons.pop, volume: 0.5 });
314
370
  break;
315
371
  }
@@ -329,13 +385,19 @@ function comporRoteiro({ roteiro, modo = 'video', largura = 1920, altura = 1080,
329
385
  const src = m && typeof m === 'object' ? m.src : m;
330
386
  if (src) {
331
387
  const ehVideo = m && typeof m === 'object' && m.tipo === 'video';
332
- els.push({ tipo: ehVideo ? 'video' : 'imagem', src, ...(ehVideo ? { volume: 0 } : {}), x: 0, y: 0, largura: '100%', altura: '100%', ajuste: 'cover', anima: { escala: [{ t: 0, v: 1.12 }, { t: dur, v: 1, e: 'suave' }] } });
388
+ // O enquadramento puxa para cima: é onde ficam os rostos.
389
+ els.push({ tipo: ehVideo ? 'video' : 'imagem', src, ...(ehVideo ? { volume: 0 } : {}), x: 0, y: 0, largura: '100%', altura: '100%', ajuste: 'cover', foco: 'center 28%', anima: { escala: [{ t: 0, v: 1.12 }, { t: dur, v: 1, e: 'suave' }] } });
333
390
  els.push({ tipo: 'forma', forma: 'retangulo', x: 0, y: 0, largura: '100%', altura: '100%', fundo: { tipo: 'gradiente', cores: ['rgba(0,0,0,0)', 'rgba(0,0,0,0.25)', 'rgba(0,0,0,0.85)'], angulo: 180 } });
334
391
  }
335
- const larg = W0 * 0.8;
336
- const fit = caber(p.titulo || '', { largura: larg, linhas: 2, max: H * 0.1, fonte: F.titulo });
392
+ const larg = W0 * (deitado ? 0.8 : 1);
393
+ const fit = caber(p.titulo || '', { largura: larg, linhas: 2, max: H * (deitado ? 0.1 : 0.06), fonte: F.titulo });
337
394
  const hT = fit.tamanho * 1.04 * fit.linhas;
338
- const yT = H - M - hT - (p.sub ? H * 0.08 : 0);
395
+ /* Vídeo da marca (depoimento, bastidor) costuma ter o nome da
396
+ pessoa gravado embaixo: o título vai para o alto, com o véu
397
+ de cima, para não escrever por cima dele. */
398
+ const noTopo = Boolean(m && typeof m === 'object' && m.tipo === 'video');
399
+ if (noTopo) els.push({ tipo: 'forma', forma: 'retangulo', x: 0, y: 0, largura: '100%', altura: '100%', fundo: { tipo: 'gradiente', cores: ['rgba(0,0,0,0.8)', 'rgba(0,0,0,0.2)', 'rgba(0,0,0,0)'], angulo: 180 } });
400
+ const yT = noTopo ? M * 1.2 : H - M - hT - (p.sub ? H * 0.08 : 0);
339
401
  if (p.titulo) titulo(p.titulo, { y: yT, larg, max: fit.tamanho, cor: '#FFFFFF', de: 0.3 });
340
402
  if (p.sub) linhaFina(p.sub, { y: yT + hT + H * 0.02, larg, c: 'rgba(255,255,255,0.8)', de: 0.7 });
341
403
  break;
@@ -116,7 +116,7 @@ function iconeDe(palavra) {
116
116
  if (!w || VAZIAS.has(w)) return null;
117
117
  for (const [raizes, nome] of ICONES) {
118
118
  for (const r of raizes) {
119
- if (r.length <= 3 ? w === r : w.startsWith(r)) return 'fluent-emoji-flat:' + nome;
119
+ if (r.length <= 3 ? w === r : w.startsWith(r)) return 'fluent-3d:' + nome;
120
120
  }
121
121
  }
122
122
  return null;
@@ -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
 
@@ -198,10 +199,85 @@ function listarArquivos(pasta) {
198
199
 
199
200
  // ── estudio_plano ────────────────────────────────────────────────────────
200
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
+
201
258
  async function ferramentaPlano(args) {
202
- 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
+ }
203
265
  if (!p) return semProjeto(args.id);
204
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
+
205
281
  if (args.voltarPara) return projeto.voltarPara(args.id, args.voltarPara);
206
282
 
207
283
  if (!args.plano && !args.cenas && !args.roteiro) {
@@ -217,11 +293,21 @@ async function ferramentaPlano(args) {
217
293
  if (args.roteiro) {
218
294
  const r = await prepararRoteiro(p, args);
219
295
  if (!r.ok) return r;
220
- 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
+ }
221
305
  if (!conferida.ok) {
222
306
  return { ok: false, error: 'o roteiro virou um plano com problemas (defeito do PrimoCode, não seu).', problemas: conferida.problemas.slice(0, 12) };
223
307
  }
224
308
  projeto.salvarPlano(args.id, r.plano, args.motivo || 'montado do roteiro');
309
+ // O roteiro fica guardado: ajustar uma frase é mandar o roteiro de novo, não reescrever tudo.
310
+ try { fs.writeFileSync(path.join(p.pasta, 'roteiro.json'), JSON.stringify(args.roteiro, null, 2)); } catch { /* segue */ }
225
311
  projeto.salvar(args.id, { duracao: conferida.duracao });
226
312
  return {
227
313
  ok: true, duracao: conferida.duracao, ...r.resumo, resumo: plano.resumo(r.plano),
@@ -593,12 +679,82 @@ async function baixarApoio(p, termo, { orientacao = null, video: querVideo = fal
593
679
  return null;
594
680
  }
595
681
 
682
+ /* ── A VOZ DO GEMINI ─────────────────────────────────────────────────────
683
+ * O roteiro inteiro numa chamada só (o plano grátis tem poucos pedidos por
684
+ * minuto), e as partes achadas depois na transcrição do próprio áudio: a
685
+ * primeira palavra de cada parte, procurada em ordem. Sem achar, o tempo é
686
+ * repartido pelo tamanho do texto de cada parte. */
687
+ const VOZ_GEMINI = { thalita: 'Kore', francisca: 'Aoede', antonio: 'Charon', feminina: 'Kore', masculina: 'Charon' };
688
+ const tokens = (t) => String(t || '').normalize('NFD').replace(/[̀-ͯ]/g, '').toLowerCase().split(/[^a-z0-9]+/).filter(Boolean);
689
+
690
+ function alinharPartes(lista, palavras, total) {
691
+ const ws = palavras.map((w) => ({ ...w, k: tokens(w.texto)[0] || '' }));
692
+ const chars = lista.map((x) => x.texto.length);
693
+ const soma = chars.reduce((a, b) => a + b, 0) || 1;
694
+ const inicios = [0];
695
+ let cursor = 0, acum = 0;
696
+ for (let i = 1; i < lista.length; i++) {
697
+ acum += chars[i - 1];
698
+ const esperado = Math.round(ws.length * acum / soma);
699
+ const alvo = tokens(lista[i].texto).find((w) => w.length >= 3) || tokens(lista[i].texto)[0];
700
+ let achado = -1;
701
+ // Procura perto de onde a proporção diz que a parte começa.
702
+ for (let j = Math.max(cursor + 1, esperado - 12); j < Math.min(ws.length, esperado + 12); j++) {
703
+ if (ws[j].k === alvo) { achado = j; break; }
704
+ }
705
+ const idx = achado > cursor ? achado : Math.max(cursor + 1, Math.min(ws.length - 1, esperado));
706
+ inicios.push(idx);
707
+ cursor = idx;
708
+ }
709
+ return lista.map((_, i) => {
710
+ const a = ws[inicios[i]], b = ws[(inicios[i + 1] || ws.length) - 1];
711
+ return { de: i === 0 ? 0 : (a ? a.de : 0), ate: b ? b.ate : total, palavras: [] };
712
+ });
713
+ }
714
+
715
+ async function narrarComGemini(lista, mp3, { voz } = {}) {
716
+ try {
717
+ const cfg = require('../config').loadConfig();
718
+ const api = require('../api');
719
+ const r = await api.request(cfg.server, '/api/voz', {
720
+ texto: lista.map((x) => x.texto).join('\n\n'),
721
+ voz: VOZ_GEMINI[String(voz || '').toLowerCase()] || voz || 'Charon',
722
+ }, cfg.token);
723
+ if (!r || !r.ok) return { ok: false, error: (r && r.erro) || 'sem voz no servidor' };
724
+ const pcm = mp3.replace(/\.mp3$/, '.pcm');
725
+ fs.writeFileSync(pcm, Buffer.from(r.audio, 'base64'));
726
+ const taxa = (String(r.formato).match(/rate=(\d+)/) || [])[1] || '24000';
727
+ const c = await video.rodar(video.ffmpeg(), ['-y', '-f', 's16le', '-ar', taxa, '-ac', '1', '-i', pcm,
728
+ '-af', 'loudnorm=I=-14:TP=-1.5:LRA=9', '-c:a', 'libmp3lame', '-q:a', '3', mp3], { timeout: 120000 });
729
+ try { fs.unlinkSync(pcm); } catch { /* fica */ }
730
+ if (!c.ok) return { ok: false, error: 'não consegui converter a voz' };
731
+ const med = await video.medir(mp3);
732
+ const tr = await api.requestTranscricao(cfg.server, { token: cfg.token, audio: fs.readFileSync(mp3).toString('base64'), nome: 'voz.mp3', idioma: 'pt' });
733
+ const palavras = (tr && tr.palavras) || [];
734
+ if (!palavras.length) return { ok: false, error: 'sem a transcrição da voz para achar as partes' };
735
+ return { ok: true, voz: `gemini ${r.voz}`, segundos: med.segundos, partes: alinharPartes(lista, palavras, med.segundos) };
736
+ } catch (e) {
737
+ return { ok: false, error: e.message };
738
+ }
739
+ }
740
+
596
741
  const PROPORCOES = { '16:9': [1920, 1080], '9:16': [1080, 1920], '1:1': [1080, 1080], '4:5': [1080, 1350] };
597
742
 
598
743
  /** O roteiro vira plano: baixa o que falta, narra, e chama o compositor. */
599
744
  async function prepararRoteiro(p, args) {
600
745
  const roteiro = typeof args.roteiro === 'string' ? JSON.parse(args.roteiro) : args.roteiro;
601
746
  if (Array.isArray(roteiro)) return prepararRoteiro(p, { ...args, roteiro: { partes: roteiro } });
747
+ /* O que o modelo manda e o disco não tem vira problema de render: um
748
+ "src" que é endereço da internet vira busca, e um arquivo que não
749
+ existe na pasta sai. A logo da marca vale só se estiver lá. */
750
+ const existe = (rel) => rel && !/^https?:/i.test(rel) && fs.existsSync(path.resolve(p.pasta, rel));
751
+ for (const pt of roteiro.partes || []) {
752
+ if (pt.src && !existe(pt.src)) { if (!pt.busca && /^https?:/i.test(pt.src)) pt.busca = pt.titulo || ''; delete pt.src; }
753
+ }
754
+ if (roteiro.marca) {
755
+ roteiro.marca = { ...(p.marca || {}), ...roteiro.marca };
756
+ if (roteiro.marca.logo && !existe(roteiro.marca.logo)) roteiro.marca.logo = (p.marca && existe(p.marca.logo)) ? p.marca.logo : undefined;
757
+ } else if (p.marca) roteiro.marca = p.marca;
602
758
  const partes = roteiro.partes || [];
603
759
  const modo = (args.modo || roteiro.modo || (p.formato === 'slides' || p.formato === 'arte' ? 'slides' : 'video'));
604
760
  const [largura, altura] = PROPORCOES[roteiro.proporcao || args.proporcao] || (modo === 'slides' ? PROPORCOES['16:9'] : PROPORCOES['16:9']);
@@ -607,12 +763,43 @@ async function prepararRoteiro(p, args) {
607
763
  for (const nome of composicao.iconesDoRoteiro(roteiro)) {
608
764
  try { const r = await assets.icone(nome); if (r && r.ok) iconesOk.add(nome); } catch { /* sem */ }
609
765
  }
610
- const imagens = {};
766
+ /* O ACERVO DA MARCA (guardado pelo estudio_site): as fotos reais entram
767
+ nas partes de imagem e de FUNDO nas cenas de texto; os vídeos, nas
768
+ partes de imagem do vídeo. A foto escolhida é a que mais combina, pela
769
+ descrição, com o que a parte diz. Sem acervo, vale a busca (Pexels). */
770
+ const acervo = (p.acervo && (p.acervo.fotos || []).length) ? p.acervo : { fotos: [], videos: [] };
771
+ const usadas = new Set();
772
+ const palavrasDe = (t) => new Set(String(t || '').normalize('NFD').replace(/[\u0300-\u036f]/g, '').toLowerCase().split(/[^a-z0-9]+/).filter((w) => w.length >= 4));
773
+ const melhorFoto = (texto) => {
774
+ const quer = palavrasDe(texto);
775
+ let melhor = null, pontos = -1;
776
+ for (const f of acervo.fotos) {
777
+ if (usadas.has(f.src)) continue;
778
+ const n = [...palavrasDe(f.alt)].filter((w) => quer.has(w)).length;
779
+ if (n > pontos) { melhor = f; pontos = n; }
780
+ }
781
+ if (melhor) usadas.add(melhor.src);
782
+ return melhor ? { src: melhor.src, tipo: 'imagem' } : null;
783
+ };
784
+ const videosMarca = [...(acervo.videos || [])];
785
+ const imagens = {}, fundos = {};
611
786
  for (const [i, parte] of partes.entries()) {
612
- if (parte.tipo !== 'imagem' || parte.src || !parte.busca) continue;
613
- const m = await baixarApoio(p, parte.busca, { orientacao: largura >= altura ? 'landscape' : 'portrait', video: modo === 'video' });
787
+ if (parte.tipo !== 'imagem' || parte.src) continue;
788
+ const texto = `${parte.titulo || ''} ${parte.sub || ''} ${parte.fala || ''} ${parte.busca || ''}`;
789
+ if (modo === 'video' && videosMarca.length && i % 2 === 1) { imagens[i] = { src: videosMarca.shift().src, tipo: 'video' }; continue; }
790
+ const m = melhorFoto(texto) || (parte.busca ? await baixarApoio(p, parte.busca, { orientacao: largura >= altura ? 'landscape' : 'portrait', video: modo === 'video' }) : null);
614
791
  if (m) imagens[i] = m;
615
792
  }
793
+ if (acervo.fotos.length) {
794
+ const comFundo = ['abertura', 'titulo', 'contraste', 'citacao', 'cta', 'passos'];
795
+ let alterna = 0;
796
+ for (const [i, parte] of partes.entries()) {
797
+ if (!comFundo.includes(parte.tipo)) continue;
798
+ if (i !== 0 && alterna++ % 2 === 1) continue; // uma sim, uma não: o respiro também é ritmo
799
+ const m = melhorFoto(`${parte.titulo || ''} ${parte.sim || ''} ${parte.fala || ''}`);
800
+ if (m) fundos[i] = m;
801
+ }
802
+ }
616
803
 
617
804
  let tempos = [], narracao = null, sons = {}, trilha = null;
618
805
  if (modo === 'video') {
@@ -621,7 +808,9 @@ async function prepararRoteiro(p, args) {
621
808
  fs.mkdirSync(path.dirname(arq), { recursive: true });
622
809
  fs.writeFileSync(arq, JSON.stringify(lista));
623
810
  const mp3 = path.join(p.pasta, 'midia', `narracao-${Date.now().toString(36)}.mp3`);
624
- const v = await rodarPython('narrar.py', ['roteiro', '--arquivo', arq, '--saida', mp3, '--voz', String(args.voz || roteiro.voz || 'thalita')], { timeout: 10 * 60000 });
811
+ // A voz de gente primeiro (Gemini, pelo servidor); o Edge é a reserva.
812
+ let v = await narrarComGemini(lista, mp3, { voz: args.voz || roteiro.voz });
813
+ if (!v.ok) v = await rodarPython('narrar.py', ['roteiro', '--arquivo', arq, '--saida', mp3, '--voz', String(args.voz || roteiro.voz || 'thalita')], { timeout: 10 * 60000 });
625
814
  if (!v.ok) return v;
626
815
  tempos = v.partes;
627
816
  narracao = path.relative(p.pasta, mp3).replace(/\\/g, '/');
@@ -635,7 +824,7 @@ async function prepararRoteiro(p, args) {
635
824
  }
636
825
  } catch { /* sem música */ }
637
826
  }
638
- return composicao.comporRoteiro({ roteiro, modo, largura, altura, tempos, narracao, sons, trilha, imagens, iconesOk });
827
+ return composicao.comporRoteiro({ roteiro, modo, largura, altura, tempos, narracao, sons, trilha, imagens, iconesOk, fundos });
639
828
  }
640
829
 
641
830
  /* Sem o tempo de cada palavra, cada trecho é repartido pelas palavras dele,
@@ -772,6 +961,36 @@ async function ferramentaRender(args, ctx = {}) {
772
961
 
773
962
  // ── estudio_site ─────────────────────────────────────────────────────────
774
963
 
964
+ /* A PALETA INTEIRA do site, não só a primeira cor. O fundo é o neutro
965
+ escuro que o site mais usa (#121218 na Paris Group); o destaque é a cor da
966
+ marca que mais aparece sobre ele (o laranja, não o azul escuro que some); o
967
+ apoio é a seguinte. A fonte serifada, quando existe, vai para citação. */
968
+ function paletaDoSite(r) {
969
+ const hexRgb = (h) => { const n = parseInt(String(h).slice(1).padEnd(6, '0').slice(0, 6), 16); return [n >> 16, (n >> 8) & 255, n & 255]; };
970
+ const luz = (h) => { const [a, b, c] = hexRgb(h); return (0.299 * a + 0.587 * b + 0.114 * c) / 255; };
971
+ const sat = (h) => { const v = hexRgb(h); return (Math.max(...v) - Math.min(...v)) / 255; };
972
+ const neutras = ((r.cores && (r.cores.neutras || r.cores.fundo)) || []).map((x) => x.cor || x).filter((c) => /^#/.test(c));
973
+ const fundo = neutras.find((c) => luz(c) < 0.15 && luz(c) > 0.02) || neutras.find((c) => luz(c) < 0.2) || undefined;
974
+ const base = fundo || '#121218';
975
+ const marca = ((r.cores && r.cores.marca) || []).map((x) => x.cor).filter(Boolean);
976
+ const nota = (c) => sat(c) * 1.2 + Math.abs(luz(c) - luz(base));
977
+ const ordenadas = [...marca].sort((a, b) => nota(b) - nota(a));
978
+ const destaque = ordenadas[0];
979
+ // O apoio é OUTRA cor, de outro matiz: dois laranjas não fazem paleta.
980
+ const matiz = (h) => { const [a, b, c] = hexRgb(h).map((v) => v / 255); const mx = Math.max(a, b, c), mn = Math.min(a, b, c), d = mx - mn;
981
+ if (!d) return 0; const x = mx === a ? ((b - c) / d) % 6 : mx === b ? (c - a) / d + 2 : (a - b) / d + 4; return (x * 60 + 360) % 360; };
982
+ const longe = (a, b) => { const d = Math.abs(matiz(a) - matiz(b)); return Math.min(d, 360 - d) > 60; };
983
+ const apoio = ordenadas.find((c) => c !== destaque && longe(c, destaque) && sat(c) > 0.3) || ordenadas[1];
984
+ const fontes = ((r.fontes && r.fontes.declaradas) || []).map((x) => x.fonte).filter((f) => !/mono|menlo|courier|system|arial|helvetica/i.test(f));
985
+ return {
986
+ cores: { ...(fundo ? { fundo } : {}), ...(destaque ? { destaque } : {}), ...(apoio ? { apoio } : {}) },
987
+ fontes: {
988
+ ...(fontes[0] ? { titulo: fontes.find((f) => !/serif/i.test(f) || /sans/i.test(f)) || fontes[0], texto: fontes.find((f) => !/serif/i.test(f) || /sans/i.test(f)) || fontes[0] } : {}),
989
+ ...(fontes.find((f) => /serif/i.test(f) && !/sans/i.test(f)) ? { serifa: fontes.find((f) => /serif/i.test(f) && !/sans/i.test(f)) } : {}),
990
+ },
991
+ };
992
+ }
993
+
775
994
  async function ferramentaSite(args) {
776
995
  let alvo = String(args.url || args.site || '').trim();
777
996
 
@@ -801,6 +1020,10 @@ async function ferramentaSite(args) {
801
1020
  uma pasta de projeto, é lixo que ninguém recolhe. */
802
1021
  const html = r.html;
803
1022
  delete r.html; // o HTML cru não vai para o modelo
1023
+ if (r.ok) {
1024
+ const marcaLida = paletaDoSite(r);
1025
+ ultimoSite = { url: r.url, html, quando: Date.now(), marca: marcaLida };
1026
+ }
804
1027
  if (r.ok && args.id && args.logo !== false) {
805
1028
  const p = projeto.ler(args.id);
806
1029
  if (p) {
@@ -817,11 +1040,44 @@ async function ferramentaSite(args) {
817
1040
  if (r.ok && args.id) {
818
1041
  projeto.anotar(args.id, 'referencia', `leu ${r.url}: ${(r.cores.marca[0] || {}).cor || 'sem cor'} · ${(r.fontes.declaradas[0] || {}).fonte || 'sem fonte'}`);
819
1042
  }
1043
+
1044
+ /* O ACERVO DA MARCA: as fotos e os vídeos reais do site, e o texto das
1045
+ páginas que combinam com o pedido (lib/estudio/acervo-site.js). As
1046
+ fotos ficam guardadas no projeto e o roteiro as usa sozinho; os fatos
1047
+ voltam para o modelo escrever o roteiro com dado de verdade. */
1048
+ if (r.ok && args.id && args.acervo !== false) {
1049
+ const p = projeto.ler(args.id);
1050
+ if (p) {
1051
+ try {
1052
+ const a = await acervoSite.colher({
1053
+ url: r.url, pedido: args.pedido || p.pedido || '', pasta: p.pasta, htmlInicial: html,
1054
+ buscar: identidade.buscar, baixar: assets.baixar,
1055
+ });
1056
+ const marca = { ...ultimoSite.marca, ...(r.logo && r.logo.ok ? { logo: r.logo.src } : {}) };
1057
+ projeto.salvar(args.id, { acervo: { fotos: a.fotos, videos: a.videos, fatos: a.fatos, site: r.url }, marca });
1058
+ projeto.anotar(args.id, 'midia', `acervo do site: ${a.fotos.length} fotos, ${a.videos.length} vídeos, ${a.paginas.length} páginas lidas`);
1059
+ let orcamento = 4200;
1060
+ r.fatos = a.fatos.map((f) => {
1061
+ const texto = f.texto.slice(0, Math.max(0, Math.min(1400, orcamento)));
1062
+ orcamento -= texto.length;
1063
+ return { pagina: f.pagina, texto };
1064
+ }).filter((f) => f.texto);
1065
+ r.acervo = { fotos: a.fotos.length, videos: a.videos.length,
1066
+ exemplos: a.fotos.slice(0, 6).map((f) => f.alt).filter(Boolean) };
1067
+ r.comoUsar = 'Escreva o roteiro com os FATOS acima (datas, local, números, frases da marca) — nada '
1068
+ + 'inventado. As fotos e os vídeos reais da marca entram SOZINHOS no vídeo: não precisa pôr '
1069
+ + '"busca" nas partes de imagem (use tipo "imagem" com só o título). Use as cores, a fonte e '
1070
+ + 'a logo em roteiro.marca.';
1071
+ } catch (e) {
1072
+ r.acervo = { erro: e.message };
1073
+ }
1074
+ }
1075
+ }
820
1076
  return r;
821
1077
  }
822
1078
 
823
1079
  module.exports = {
824
1080
  ferramentaProjeto, ferramentaPlano, ferramentaMidia, ferramentaNarrar,
825
1081
  ferramentaVideo, ferramentaRender, ferramentaSite,
826
- projeto, plano, remotion, assets, video, identidade, slides, python, rodarPython,
1082
+ projeto, plano, remotion, assets, video, identidade, slides, python, rodarPython, alinharPartes,
827
1083
  };
@@ -323,7 +323,7 @@ function Elemento({ el, t, dados }) {
323
323
  startFrom={el.deOrigem ? Math.round(el.deOrigem * dados.fps) : undefined}
324
324
  endAt={el.ateOrigem ? Math.round(el.ateOrigem * dados.fps) : undefined}
325
325
  volume={el.volume ?? 0}
326
- style={{ width: '100%', height: '100%', objectFit: el.ajuste || 'cover' }}
326
+ style={{ width: '100%', height: '100%', objectFit: el.ajuste || 'cover', objectPosition: el.foco || 'center' }}
327
327
  />
328
328
  </div>
329
329
  );
@@ -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.15",
3
+ "version": "9.8.0-beta.17",
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"