primocode 9.8.0-beta.8 → 9.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (42) hide show
  1. package/README.md +3 -2
  2. package/bin/primocode.js +45 -18
  3. package/lib/act.js +16 -9
  4. package/lib/api.js +8 -1
  5. package/lib/catalogo.js +11 -3
  6. package/lib/claude-engine.js +25 -3
  7. package/lib/estudio/acervo-site.js +157 -0
  8. package/lib/estudio/assets.js +77 -3
  9. package/lib/estudio/cinema.js +399 -0
  10. package/lib/estudio/composicao.js +470 -0
  11. package/lib/estudio/edicao.js +450 -0
  12. package/lib/estudio/impacto.js +263 -0
  13. package/lib/estudio/index.js +854 -5
  14. package/lib/estudio/plano.js +5 -2
  15. package/lib/estudio/remotion.js +228 -23
  16. package/lib/estudio/slides.js +34 -30
  17. package/lib/local/cerebro.json +361 -180
  18. package/lib/local/entrada.js +18 -8
  19. package/lib/local/motor-cli.js +1 -1
  20. package/lib/offline.js +168 -0
  21. package/package.json +2 -3
  22. package/skills/design-de-verdade/SKILL.md +2 -0
  23. package/skills/motion-de-primeira/SKILL.md +1 -1
  24. package/skills/site-com-motion/SKILL.md +39 -0
  25. package/studio/narrar.py +111 -10
  26. package/studio/rostos.py +52 -0
  27. package/kits/acolhedor/index.html +0 -130
  28. package/kits/acolhedor/script.js +0 -25
  29. package/kits/acolhedor/style.css +0 -146
  30. package/kits/editorial/index.html +0 -115
  31. package/kits/editorial/script.js +0 -19
  32. package/kits/editorial/style.css +0 -112
  33. package/kits/landing/index.html +0 -162
  34. package/kits/landing/script.js +0 -199
  35. package/kits/landing/style.css +0 -219
  36. package/kits/premium/index.html +0 -83
  37. package/kits/premium/script.js +0 -11
  38. package/kits/premium/style.css +0 -87
  39. package/kits/vitrine/index.html +0 -108
  40. package/kits/vitrine/script.js +0 -20
  41. package/kits/vitrine/style.css +0 -117
  42. package/lib/kits.js +0 -206
@@ -17,7 +17,7 @@
17
17
  *
18
18
  * ── A IDEIA ──────────────────────────────────────────────────────────────
19
19
  * Ninguém precisa das 45 ferramentas para fazer um slide. Quem pede um vídeo
20
- * não usa `desktop_click`; quem conserta um bug não usa `studio_criar`. Então
20
+ * não usa `desktop_click`; quem conserta um bug não usa `estudio_render`. Então
21
21
  * cada pedido leva só o que aquele pedido pode usar — ferramentas E seções do
22
22
  * prompt — e o que sobra do orçamento vira espaço para a conversa.
23
23
  *
@@ -74,10 +74,19 @@ const GRUPOS = [
74
74
  },
75
75
  {
76
76
  nome: 'estudio',
77
- tools: ['studio_manual', 'studio_spec', 'studio_criar', 'studio_ler',
78
- 'studio_atualizar', 'studio_versoes', 'studio_listar', 'studio_midia'],
79
- // O que o estúdio produz, e os apps que ele substitui.
80
- gatilho: /\b(slides?|apresenta[çc][ãa]o|deck|v[íi]deo|reels?|anima[çc][ãa]o|post|banner|capa|thumbnail|arte|cartaz|logo|planilha|tabela|or[çc]amento|documento|relat[óo]rio|artigo|proposta|contrato|diagrama|fluxograma|organograma|mapa mental|narra[çc][ãa]o|trilha|est[úu]dio|canva|powerpoint|keynote|word|docs|excel|sheets|photoshop|figma|capcut|premiere|miro)\b/i,
77
+ tools: ['estudio_projeto', 'estudio_plano', 'estudio_midia', 'estudio_narrar',
78
+ 'estudio_render', 'estudio_site', 'planejar_producao'],
79
+ // O que o estúdio produz, e os apps que ele substitui. Documento,
80
+ // planilha e PDF saíram: o Estúdio novo faz vídeo, slides e arte, e o
81
+ // resto é arquivo escrito com código.
82
+ gatilho: /\b(slides?|apresenta[çc][ãa]o|deck|v[íi]deo|reels?|anima[çc][ãa]o|motion|vinheta|teaser|an[úu]ncio|post|banner|capa|thumbnail|arte|cartaz|narra[çc][ãa]o|trilha|legenda|grava[çc][ãa]o|est[úu]dio|canva|powerpoint|keynote|photoshop|figma|capcut|premiere)\b/i,
83
+ },
84
+ {
85
+ /* A GRAVAÇÃO do usuário é outra ferramenta, e só entra quando o pedido
86
+ fala dela: "faz um vídeo" não precisa cortar silêncio de ninguém. */
87
+ nome: 'gravacao',
88
+ tools: ['estudio_video'],
89
+ gatilho: /\b(grava[çc][ãa]o|gravei|filmagem|filmei|meu v[íi]deo|edit\w* (o |a |meu |minha |esse |essa |este |esta )?(v[íi]deo|grava[çc][ãa]o)|\.mp4|\.mov|legendas?|cort(a|ar|e) (o )?sil[êe]ncio|recort(a|ar|e) (a )?pessoa|transcrev\w*|importa\w* (o )?v[íi]deo)\b/i,
81
90
  },
82
91
  {
83
92
  nome: 'navegador',
@@ -118,7 +127,7 @@ const GRUPOS = [
118
127
  quando o pedido fala do arquivo — é o que mantém o pedido de vídeo
119
128
  dentro do teto do minuto (medido: ela custa ~180 tokens). */
120
129
  nome: 'exportar',
121
- tools: ['studio_exportar'],
130
+ tools: ['estudio_render'],
122
131
  gatilho: /\b(mp4|webm|baix[ae]r?|download|exporta\w*|salv[ae]r? (o )?v[íi]deo|arquivo (do|de) v[íi]deo|me manda o v[íi]deo|manda o arquivo|renderiza\w*)\b/i,
123
132
  },
124
133
  {
@@ -196,7 +205,8 @@ function jaAprendeu(messages) {
196
205
  if (m && m.role === 'tool' && nomePorId.has(m.tool_call_id)) feitas.add(nomePorId.get(m.tool_call_id));
197
206
  }
198
207
  const dispensaveis = new Set();
199
- if (feitas.has('studio_spec') || feitas.has('studio_manual')) dispensaveis.add('estudio');
208
+ // O Estúdio novo não tem spec para ler: a seção dele é o único mapa do
209
+ // fluxo, e fica até o fim.
200
210
  if (feitas.has('pesquisar_referencia')) dispensaveis.add('pesquisa');
201
211
  return dispensaveis;
202
212
  }
@@ -253,7 +263,7 @@ function relatorio({ prompt, ferramentas, messages }) {
253
263
  // O CLI já protege as mesmas mensagens antes de mandar (lib/repeticao.js), mas
254
264
  // quem falava por último aqui era `caberNoTeto`, que não sabia disso e cortava
255
265
  // assim mesmo. Dois compactadores brigando, e o do servidor ganhava.
256
- const REFERENCIAS = new Set(['studio_manual', 'studio_spec']);
266
+ const REFERENCIAS = new Set(['planejar_producao', 'estudio_site']);
257
267
 
258
268
  // Referência tem teto GENEROSO, não infinito.
259
269
  //
@@ -37,7 +37,7 @@ function motorDe(id) {
37
37
  function perguntar(id, texto) {
38
38
  if (id === 'codex') return String(motorDe('codex').chamarCodex(texto) || '').trim();
39
39
  const claude = motorDe('claude-code');
40
- const saida = execFileSync('claude', ['-p', '--output-format', 'json', ...claude.argsDoModelo()], {
40
+ const saida = execFileSync('claude', ['-p', '--output-format', 'json', ...claude.argsDoMotor()], {
41
41
  encoding: 'utf-8', timeout: 120000, input: texto, maxBuffer: 8 * 1024 * 1024, ...SEM_APARECER,
42
42
  });
43
43
  try { return String(JSON.parse(saida).result || '').trim(); } catch { return String(saida || '').trim(); }
package/lib/offline.js ADDED
@@ -0,0 +1,168 @@
1
+ /**
2
+ * offline.js — o primocode-server rodando AQUI, no computador da pessoa.
3
+ *
4
+ * "Como eu não tenho acesso ao Railway, enquanto isso eu posso testar com o
5
+ * projeto rodando localmente, chamando as APIs pelo meu computador."
6
+ * (24/09/2026)
7
+ *
8
+ * O /offline sobe o servidor da pasta primocode-server com as MESMAS chaves
9
+ * que o Render usa (coladas em ~/.primocode/servidor.env) e aponta o CLI para
10
+ * ele até a sessão acabar. É temporário: serve para testar o servidor novo
11
+ * antes de ele ir para produção.
12
+ *
13
+ * "Offline" é o nome que o pedido deu, e ele mente um pouco: as IAs continuam
14
+ * na internet. O que sai do caminho é o servidor de produção.
15
+ *
16
+ * ── O QUE NUNCA APARECE ──────────────────────────────────────────────────
17
+ * O valor de uma chave. Mostram-se só os NOMES das variáveis que o arquivo
18
+ * tem, o bastante para ver o que falta.
19
+ */
20
+
21
+ 'use strict';
22
+
23
+ const fs = require('fs');
24
+ const os = require('os');
25
+ const path = require('path');
26
+ const http = require('http');
27
+ const { spawn } = require('child_process');
28
+
29
+ const PORTA = Number(process.env.PRIMOCODE_OFFLINE_PORTA || 3190);
30
+ const CASA = path.join(os.homedir(), '.primocode');
31
+ const ARQUIVO_ENV = path.join(CASA, 'servidor.env');
32
+ const ARQUIVO_LOG = path.join(CASA, 'servidor-local.log');
33
+
34
+ // As variáveis que o primocode-server lê. As de chave vêm primeiro: sem pelo
35
+ // menos uma delas, o servidor sobe mas não tem com quem falar.
36
+ const CHAVES = ['GROQ_API_KEY', 'OPENROUTER_API_KEY', 'GEMINI_API_KEY', 'GOOGLE_API_KEY', 'COHERE_API_KEY', 'HF_TOKEN', 'HUGGINGFACE_API_KEY', 'PG_GATEWAY_API_KEY'];
37
+ const OUTRAS = ['PG_GATEWAY_BASE_URL', 'CONECTA_API', 'PRIMOCODE_TOKEN', 'REQUIRE_TOKEN', 'RATE_LIMIT_PER_HOUR'];
38
+
39
+ const MODELO_ENV = `# As chaves do primocode-server rodando no seu computador (/offline).
40
+ # Copie do Render: o serviço primocode-server → Environment. Uma por linha,
41
+ # NOME=valor, sem aspas. Este arquivo é só seu: não mande para ninguém.
42
+ #
43
+ ${CHAVES.map((k) => `# ${k}=`).join('\n')}
44
+ #
45
+ ${OUTRAS.map((k) => `# ${k}=`).join('\n')}
46
+ `;
47
+
48
+ /** Lê um .env simples: NOME=valor, # comenta, aspas em volta saem. */
49
+ function lerEnv(texto) {
50
+ const env = {};
51
+ for (const linha of String(texto || '').split(/\r?\n/)) {
52
+ const m = linha.match(/^\s*(?:export\s+)?([A-Za-z_][A-Za-z0-9_]*)\s*=\s*(.*?)\s*$/);
53
+ if (!m) continue;
54
+ let valor = m[2];
55
+ if (/^(['"]).*\1$/.test(valor)) valor = valor.slice(1, -1);
56
+ if (valor) env[m[1]] = valor;
57
+ }
58
+ return env;
59
+ }
60
+
61
+ /** O arquivo de chaves: cria o modelo (vazio, 600) na primeira vez. */
62
+ function prepararEnv(arquivo = ARQUIVO_ENV) {
63
+ let criado = false;
64
+ if (!fs.existsSync(arquivo)) {
65
+ fs.mkdirSync(path.dirname(arquivo), { recursive: true });
66
+ fs.writeFileSync(arquivo, MODELO_ENV, { mode: 0o600 });
67
+ criado = true;
68
+ }
69
+ const env = lerEnv(fs.readFileSync(arquivo, 'utf8'));
70
+ return { arquivo, criado, env, nomes: Object.keys(env), temChave: CHAVES.some((k) => env[k]) };
71
+ }
72
+
73
+ function ehServidor(dir) {
74
+ try {
75
+ const pkg = JSON.parse(fs.readFileSync(path.join(dir, 'package.json'), 'utf8'));
76
+ return pkg.name === 'primocode-server' && fs.existsSync(path.join(dir, 'server.js'));
77
+ } catch { return false; }
78
+ }
79
+
80
+ /** Onde está a pasta do primocode-server. A primeira que existir ganha. */
81
+ function acharServidor({ pedido = '', raizCli = path.join(__dirname, '..') } = {}) {
82
+ const candidatos = [
83
+ pedido,
84
+ process.env.PRIMOCODE_SERVER_DIR,
85
+ path.join(raizCli, '..', 'primocode-server'),
86
+ path.join(os.homedir(), 'Primocode CLI', 'primocode-server'),
87
+ path.join(os.homedir(), 'primocode-server'),
88
+ ].filter(Boolean).map((p) => path.resolve(p.replace(/^~(?=$|\/)/, os.homedir())));
89
+ return candidatos.find(ehServidor) || null;
90
+ }
91
+
92
+ /** GET /health; devolve o JSON ou null. */
93
+ function saude(url, espera = 1500) {
94
+ return new Promise((resolve) => {
95
+ const req = http.get(url + '/health', { timeout: espera }, (res) => {
96
+ let corpo = '';
97
+ res.on('data', (d) => { corpo += d; });
98
+ res.on('end', () => { try { resolve(JSON.parse(corpo)); } catch { resolve(null); } });
99
+ });
100
+ req.on('timeout', () => req.destroy());
101
+ req.on('error', () => resolve(null));
102
+ });
103
+ }
104
+
105
+ let filho = null;
106
+
107
+ /**
108
+ * Sobe o servidor (ou reaproveita um que já responde na porta).
109
+ * Devolve { ok, url, versao, reaproveitado } ou { ok: false, error, comoResolver }.
110
+ */
111
+ async function ligar({ dir, env = {}, porta = PORTA, log = ARQUIVO_LOG, prazo = 20000 } = {}) {
112
+ const url = `http://localhost:${porta}`;
113
+ const ja = await saude(url);
114
+ if (ja && ja.status === 'ok') return { ok: true, url, versao: ja.version, reaproveitado: true };
115
+ if (!dir) {
116
+ return {
117
+ ok: false,
118
+ error: 'Não achei a pasta do primocode-server.',
119
+ comoResolver: 'clone o repo ao lado do primocode, ou diga onde ele está: /offline ~/caminho/primocode-server',
120
+ };
121
+ }
122
+ if (!fs.existsSync(path.join(dir, 'node_modules'))) {
123
+ return {
124
+ ok: false,
125
+ error: `O servidor em ${dir} ainda não tem as dependências.`,
126
+ comoResolver: `rode uma vez: cd "${dir}" && npm install`,
127
+ };
128
+ }
129
+ fs.mkdirSync(path.dirname(log), { recursive: true });
130
+ const saida = fs.openSync(log, 'a');
131
+ filho = spawn(process.execPath, ['server.js'], {
132
+ cwd: dir,
133
+ env: { ...process.env, ...env, PORT: String(porta) },
134
+ stdio: ['ignore', saida, saida],
135
+ });
136
+ let morreu = null;
137
+ filho.on('exit', (codigo) => { morreu = codigo; filho = null; });
138
+
139
+ const fim = Date.now() + prazo;
140
+ while (Date.now() < fim) {
141
+ if (morreu !== null) {
142
+ return { ok: false, error: `O servidor fechou sozinho (código ${morreu}).`, comoResolver: `veja o fim de ${log}` };
143
+ }
144
+ const h = await saude(url);
145
+ if (h && h.status === 'ok') return { ok: true, url, versao: h.version, reaproveitado: false };
146
+ await new Promise((r) => setTimeout(r, 300));
147
+ }
148
+ desligar();
149
+ return { ok: false, error: `O servidor não respondeu em ${Math.round(prazo / 1000)}s.`, comoResolver: `veja o fim de ${log}` };
150
+ }
151
+
152
+ /** Desliga o servidor que ESTE processo subiu. Um reaproveitado fica de pé. */
153
+ function desligar() {
154
+ if (!filho) return false;
155
+ try { filho.kill(); } catch {}
156
+ filho = null;
157
+ return true;
158
+ }
159
+
160
+ function ligado() { return Boolean(filho); }
161
+
162
+ // O servidor não pode sobrar rodando quando o CLI fecha.
163
+ process.on('exit', desligar);
164
+
165
+ module.exports = {
166
+ PORTA, ARQUIVO_ENV, ARQUIVO_LOG, CHAVES,
167
+ lerEnv, prepararEnv, acharServidor, ehServidor, saude, ligar, desligar, ligado,
168
+ };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "primocode",
3
- "version": "9.8.0-beta.8",
3
+ "version": "9.8.0",
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/kits.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"
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 && node test/impacto.test.js && node test/cinema.test.js"
12
12
  },
13
13
  "engines": {
14
14
  "node": ">=18.17.0"
@@ -30,7 +30,6 @@
30
30
  "app/",
31
31
  "studio/",
32
32
  "skills/",
33
- "kits/",
34
33
  "voz/",
35
34
  "README.md",
36
35
  "comandos/",
@@ -7,6 +7,8 @@ autor: adaptado de "frontend-design" (Anthropic, anthropics/skills, Apache-2.0
7
7
  Trabalhe como o diretor de arte de um estúdio conhecido por dar a cada cliente uma identidade que
8
8
  ninguém confunde. O cliente já recusou propostas com cara de modelo pronto.
9
9
 
10
+ **Não existe modelo.** Toda página começa do zero, e nenhuma sai com a estrutura da anterior.
11
+
10
12
  **Comece pelo assunto.** O ramo, os materiais e o vocabulário do negócio são de onde vem a escolha
11
13
  visual. Um brinquedo para meninas de 8 anos e um painel de analista financeiro não se parecem em nada.
12
14
  Use o conteúdo real do pedido em tudo, e não texto genérico.
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  nome: Motion de primeira
3
3
  descricao: como um vídeo criado do zero deixa de parecer feito por máquina
4
- quando: vídeo, video, motion, animação, animacao, anúncio, anuncio, reels, teaser, abertura, comercial, propaganda, estúdio, estudio
4
+ quando: vídeo, video, motion graphics, vídeo animado, video animado, anúncio, anuncio, reels, teaser, vinheta, comercial, propaganda, estúdio, estudio
5
5
  autor: PrimoCode
6
6
  ---
7
7
  Não existe template. Cada propriedade deste vídeo é uma decisão sua, e a
@@ -0,0 +1,39 @@
1
+ ---
2
+ nome: Site com motion
3
+ descricao: movimento em site e landing que parece de estúdio, e não efeito jogado em tudo
4
+ quando: motion, site animado, landing animada, página animada, pagina animada, animação no site, animações no site, animacao no site, com animação, com animacao, com animações, scroll, parallax, transição, transicao, microinteração, microinteracao, efeito, efeitos, gsap, hover, landing, landing page, página, pagina, site, one page
5
+ autor: PrimoCode
6
+ ---
7
+ Movimento em site serve para três coisas: **mostrar a ordem** (o que ler primeiro), **responder à
8
+ pessoa** (clicou, passou o mouse, rolou) e **dar um momento memorável**. Fora disso, é ruído.
9
+
10
+ **Planeje o movimento antes do código**, em três linhas:
11
+ - o momento marcante: UMA coisa (a abertura se montando, o título que se desenha, o produto que gira
12
+ com o scroll);
13
+ - como as seções entram: cada uma do jeito que o conteúdo pede, e não o mesmo fade em todas;
14
+ - a resposta ao toque: botão, card, link, campo.
15
+
16
+ **O que denuncia animação feita por IA:** fade subindo em toda seção; hover que aumenta todo card;
17
+ tudo com a mesma duração; `transition: all`; texto piscando; nada que responda ao scroll.
18
+
19
+ **Como se faz bem:**
20
+ - **Tempo:** resposta ao toque em 120 a 200 ms. Entrada de elemento em 400 a 700 ms. O momento
21
+ marcante pode levar 1 a 1,5 s, uma vez só.
22
+ - **Curva:** nada linear. Para entrar, `cubic-bezier(.2,.8,.2,1)`. Para sair, a curva ao contrário
23
+ e mais curta que a entrada.
24
+ - **Cascata:** itens de uma lista entram com 40 a 80 ms de diferença, e não todos juntos.
25
+ - **Scroll:** `IntersectionObserver` põe uma classe quando a seção aparece, e o CSS anima. Pra algo
26
+ que acompanha o scroll (parallax, barra de progresso, imagem que cresce), use
27
+ `animation-timeline: view()` e deixe a página parada onde isso não existe (`@supports`).
28
+ - **Só `transform` e `opacity`.** Animar `top`, `width`, `height` ou `box-shadow` trava no celular.
29
+ - **Troca de página ou de estado:** a View Transitions API (`document.startViewTransition`), com o
30
+ caminho sem animação quando o navegador não tiver.
31
+ - **Biblioteca só se precisar.** CSS e um pouco de JS resolvem quase tudo. Timeline longa ou texto
32
+ que se quebra letra a letra: GSAP pelo CDN.
33
+
34
+ **Obrigatório:** `@media (prefers-reduced-motion: reduce)` desliga o movimento grande e deixa só
35
+ troca de cor e opacidade. O conteúdo aparece mesmo sem JS: esconda o elemento só depois que o script
36
+ carregou (uma classe `js` no `<html>`).
37
+
38
+ **Confira:** abra a página, role do topo ao fim, e pergunte se cada movimento tem um dos três
39
+ motivos. O que não tiver, tire.
package/studio/narrar.py CHANGED
@@ -200,19 +200,22 @@ def falar_edge(texto, saida, voz, ritmo, tom):
200
200
 
201
201
  def falar_elevenlabs(texto, saida, voz):
202
202
  """Só quando há chave. Sem ela, nem se tenta — e não é erro."""
203
- chave = os.environ.get("ELEVENLABS_API_KEY", "").strip()
204
- if not chave:
205
- return None
203
+ # A chave mora na variável OU em ~/.primocode/elevenlabs.key — antes só a
204
+ # variável era lida, e a função chamada (elevenlabs.falar) nem existia:
205
+ # a ElevenLabs nunca narrou nada, mesmo com a chave no lugar.
206
206
  try:
207
207
  sys.path.insert(0, str(AQUI))
208
- import elevenlabs # o módulo que já existia no Studio
209
- r = elevenlabs.falar(texto, str(saida), voz=voz, chave=chave)
210
- if r and r.get("ok"):
211
- r["motor"] = "elevenlabs"
212
- return r
208
+ import elevenlabs
209
+ if not elevenlabs.configurado():
210
+ return None
211
+ dados, segundos = elevenlabs.sintetizar(texto, {"voz": voz})
212
+ if not dados:
213
+ return None
214
+ Path(saida).write_bytes(dados)
215
+ return {"ok": True, "arquivo": str(saida), "motor": "elevenlabs", "segundos": segundos,
216
+ "falas": [{"de": 0, "ate": segundos, "texto": texto}]}
213
217
  except Exception:
214
- pass
215
- return None
218
+ return None
216
219
 
217
220
 
218
221
  def comando_falar(args):
@@ -245,6 +248,97 @@ def comando_falar(args):
245
248
  return falar_edge(texto, args.saida, v["voz"], ritmo, tom)
246
249
 
247
250
 
251
+ # ── o roteiro inteiro, com entonação por parte ─────────────────────────────
252
+ # "A voz está sem entonação." (25/09/2026). Uma chamada só, com o texto todo,
253
+ # sai no mesmo ritmo e no mesmo tom do começo ao fim. Aqui cada parte do
254
+ # roteiro é falada com o registro dela — o gancho mais aceso, o número mais
255
+ # devagar, o fecho mais alto — e as partes são costuradas com um respiro. O
256
+ # tempo de CADA PALAVRA volta junto (WordBoundary do edge-tts): é o que
257
+ # sincroniza a tipografia com a voz.
258
+ REGISTRO = {
259
+ "gancho": {"ritmo": "+6%", "tom": "+3Hz", "respiro": 0.35},
260
+ "numero": {"ritmo": "-7%", "tom": "-1Hz", "respiro": 0.45},
261
+ "contraste": {"ritmo": "+2%", "tom": "+1Hz", "respiro": 0.4},
262
+ "lista": {"ritmo": "+0%", "tom": "+0Hz", "respiro": 0.35},
263
+ "citacao": {"ritmo": "-10%", "tom": "-2Hz", "respiro": 0.5},
264
+ "cta": {"ritmo": "+4%", "tom": "+2Hz", "respiro": 0.2},
265
+ "normal": {"ritmo": "+0%", "tom": "+0Hz", "respiro": 0.3},
266
+ }
267
+
268
+
269
+ def _parte_edge(texto, voz, ritmo, tom, saida):
270
+ import asyncio
271
+ import edge_tts
272
+
273
+ async def rodar():
274
+ c = edge_tts.Communicate(texto, voz, rate=ritmo, pitch=tom, boundary="WordBoundary")
275
+ palavras = []
276
+ with open(saida, "wb") as fh:
277
+ async for ch in c.stream():
278
+ if ch["type"] == "audio":
279
+ fh.write(ch["data"])
280
+ elif ch["type"] == "WordBoundary":
281
+ de = ch["offset"] / 1e7
282
+ palavras.append({"de": round(de, 3), "ate": round(de + ch["duration"] / 1e7, 3),
283
+ "texto": ch["text"]})
284
+ return palavras
285
+ return asyncio.run(rodar())
286
+
287
+
288
+ def _duracao(arquivo):
289
+ r = subprocess.run(["ffprobe", "-v", "error", "-show_entries", "format=duration",
290
+ "-of", "csv=p=0", str(arquivo)], capture_output=True, text=True, timeout=30)
291
+ try:
292
+ return float(r.stdout.strip())
293
+ except ValueError:
294
+ return 0.0
295
+
296
+
297
+ def comando_roteiro(args):
298
+ partes = json.loads(Path(args.arquivo).read_text(encoding="utf-8"))
299
+ if not partes:
300
+ return {"ok": False, "error": "o roteiro veio vazio"}
301
+ if not edge_disponivel() and not instalar_edge():
302
+ return {"ok": False, "error": "a narração precisa do pacote edge-tts",
303
+ "comoResolver": f"{sys.executable} -m pip install edge-tts"}
304
+ v = voz_de(args.voz)
305
+ with tempfile.TemporaryDirectory() as tmp:
306
+ lista, trechos, palavras, t = [], [], [], 0.0
307
+ for i, parte in enumerate(partes):
308
+ texto = re.sub(r"\s+", " ", str(parte.get("texto") or "")).strip()
309
+ if not texto:
310
+ trechos.append({"de": round(t, 3), "ate": round(t, 3), "palavras": []})
311
+ continue
312
+ reg = REGISTRO.get(parte.get("registro") or "normal", REGISTRO["normal"])
313
+ mp3 = Path(tmp) / f"p{i:02d}.mp3"
314
+ try:
315
+ ps = _parte_edge(texto, v["voz"], reg["ritmo"], reg["tom"], mp3)
316
+ except Exception as e:
317
+ return {"ok": False, "error": f"a voz falhou na parte {i + 1}: {str(e)[:200]}"}
318
+ dur = _duracao(mp3)
319
+ wav = Path(tmp) / f"p{i:02d}.wav"
320
+ # Cada parte vira wav com o respiro no fim: costurar mp3 direto
321
+ # deixa um clique e um silêncio de enchimento entre as partes.
322
+ subprocess.run(["ffmpeg", "-y", "-loglevel", "error", "-i", str(mp3), "-af",
323
+ f"apad=pad_dur={reg['respiro']}", "-ar", "44100", "-ac", "1", str(wav)],
324
+ capture_output=True, timeout=60)
325
+ lista.append(wav)
326
+ ws = [{"de": round(t + w["de"], 3), "ate": round(t + w["ate"], 3), "texto": w["texto"]} for w in ps]
327
+ palavras += ws
328
+ trechos.append({"de": round(t, 3), "ate": round(t + dur, 3), "palavras": ws})
329
+ t += dur + reg["respiro"]
330
+ roteiro_txt = Path(tmp) / "lista.txt"
331
+ roteiro_txt.write_text("".join(f"file '{w}'\n" for w in lista), encoding="utf-8")
332
+ r = subprocess.run(["ffmpeg", "-y", "-loglevel", "error", "-f", "concat", "-safe", "0",
333
+ "-i", str(roteiro_txt), "-af", "loudnorm=I=-14:TP=-1.5:LRA=9",
334
+ "-c:a", "libmp3lame", "-q:a", "3", str(args.saida)],
335
+ capture_output=True, timeout=180)
336
+ if r.returncode != 0:
337
+ return {"ok": False, "error": (r.stderr or b"").decode("utf-8", "ignore")[-300:]}
338
+ return {"ok": True, "arquivo": str(args.saida), "motor": "edge", "voz": v["voz"],
339
+ "segundos": round(t, 3), "partes": trechos, "palavras": palavras}
340
+
341
+
248
342
  def main():
249
343
  p = argparse.ArgumentParser(description="Narração do PrimoCode Studio")
250
344
  sub = p.add_subparsers(dest="comando", required=True)
@@ -258,11 +352,18 @@ def main():
258
352
  f.add_argument("--ritmo", default="")
259
353
  f.add_argument("--tom", default="")
260
354
 
355
+ r = sub.add_parser("roteiro")
356
+ r.add_argument("--arquivo", required=True)
357
+ r.add_argument("--saida", required=True)
358
+ r.add_argument("--voz", default="thalita")
359
+
261
360
  sub.add_parser("vozes")
262
361
 
263
362
  args = p.parse_args()
264
363
  if args.comando == "vozes":
265
364
  saida = {"ok": True, "vozes": VOZES, "estilos": list(ESTILOS.keys())}
365
+ elif args.comando == "roteiro":
366
+ saida = comando_roteiro(args)
266
367
  else:
267
368
  saida = comando_falar(args)
268
369
 
@@ -0,0 +1,52 @@
1
+ #!/usr/bin/env python3
2
+ """rostos.py — onde estão as pessoas em cada foto.
3
+
4
+ "Aparecem três homens ali, ela está cortada, não dá pra ver ninguém."
5
+ (25/09/2026)
6
+
7
+ Foto deitada num vídeo em pé perde dois terços da largura. Recortar pelo
8
+ centro corta as pessoas. Aqui se acha o retângulo que cobre todos os rostos
9
+ da foto (MediaPipe, o mesmo do recorte da pessoa), e o vídeo enquadra nele —
10
+ ou, quando os rostos não cabem no recorte, mostra a foto inteira.
11
+
12
+ Uso:
13
+ python3 rostos.py foto1.webp foto2.jpg …
14
+ Saída (JSON): {"ok": true, "fotos": {"foto1.webp": {"largura", "altura", "n",
15
+ "cx", "cy", "x0", "x1", "y0", "y1"}}} (tudo de 0 a 1)
16
+ """
17
+
18
+ import json
19
+ import sys
20
+
21
+
22
+ def main():
23
+ try:
24
+ import cv2
25
+ import mediapipe as mp
26
+ except Exception as e: # sem as bibliotecas, ninguém é enquadrado — e não é erro
27
+ print(json.dumps({"ok": False, "error": f"sem mediapipe: {e}"}))
28
+ return
29
+ det = mp.solutions.face_detection.FaceDetection(model_selection=1, min_detection_confidence=0.45)
30
+ fotos = {}
31
+ for caminho in sys.argv[1:]:
32
+ img = cv2.imread(caminho)
33
+ if img is None:
34
+ continue
35
+ h, w = img.shape[:2]
36
+ r = det.process(cv2.cvtColor(img, cv2.COLOR_BGR2RGB))
37
+ caixas = []
38
+ for d in (r.detections or []):
39
+ b = d.location_data.relative_bounding_box
40
+ caixas.append((max(0, b.xmin), max(0, b.ymin), min(1, b.xmin + b.width), min(1, b.ymin + b.height)))
41
+ item = {"largura": w, "altura": h, "n": len(caixas)}
42
+ if caixas:
43
+ x0 = min(c[0] for c in caixas); y0 = min(c[1] for c in caixas)
44
+ x1 = max(c[2] for c in caixas); y1 = max(c[3] for c in caixas)
45
+ item.update({"x0": round(x0, 3), "y0": round(y0, 3), "x1": round(x1, 3), "y1": round(y1, 3),
46
+ "cx": round((x0 + x1) / 2, 3), "cy": round((y0 + y1) / 2, 3)})
47
+ fotos[caminho] = item
48
+ print(json.dumps({"ok": True, "fotos": fotos}))
49
+
50
+
51
+ if __name__ == "__main__":
52
+ main()