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 +2 -2
- package/lib/catalogo.js +2 -1
- package/lib/estudio/acervo-site.js +157 -0
- package/lib/estudio/composicao.js +41 -5
- package/lib/estudio/edicao.js +44 -8
- package/lib/estudio/index.js +293 -11
- package/lib/local/cerebro.json +11 -3
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
# PrimoCode v9.8.0-beta.
|
|
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.
|
|
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(/ /g, ' ').replace(/&/g, '&').replace(/"/g, '"').replace(/'/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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
}
|
package/lib/estudio/edicao.js
CHANGED
|
@@ -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
|
-
|
|
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 };
|
package/lib/estudio/index.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
|
558
|
-
const
|
|
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
|
-
|
|
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
|
};
|
package/lib/local/cerebro.json
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
{
|
|
2
|
-
"origem": "primocode-server 8.
|
|
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.
|
|
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"
|