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 +2 -2
- package/lib/catalogo.js +2 -1
- package/lib/estudio/acervo-site.js +157 -0
- package/lib/estudio/assets.js +34 -0
- package/lib/estudio/composicao.js +75 -13
- package/lib/estudio/edicao.js +1 -1
- package/lib/estudio/index.js +264 -8
- package/lib/estudio/remotion.js +1 -1
- 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.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.
|
|
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(/ /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 };
|
package/lib/estudio/assets.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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;
|
package/lib/estudio/edicao.js
CHANGED
|
@@ -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-
|
|
119
|
+
if (r.length <= 3 ? w === r : w.startsWith(r)) return 'fluent-3d:' + nome;
|
|
120
120
|
}
|
|
121
121
|
}
|
|
122
122
|
return null;
|
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
|
|
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
613
|
-
const
|
|
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
|
-
|
|
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
|
};
|
package/lib/estudio/remotion.js
CHANGED
|
@@ -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
|
);
|
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.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"
|