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.
- package/README.md +3 -2
- package/bin/primocode.js +45 -18
- package/lib/act.js +16 -9
- package/lib/api.js +8 -1
- package/lib/catalogo.js +11 -3
- package/lib/claude-engine.js +25 -3
- package/lib/estudio/acervo-site.js +157 -0
- package/lib/estudio/assets.js +77 -3
- package/lib/estudio/cinema.js +399 -0
- package/lib/estudio/composicao.js +470 -0
- package/lib/estudio/edicao.js +450 -0
- package/lib/estudio/impacto.js +263 -0
- package/lib/estudio/index.js +854 -5
- package/lib/estudio/plano.js +5 -2
- package/lib/estudio/remotion.js +228 -23
- package/lib/estudio/slides.js +34 -30
- package/lib/local/cerebro.json +361 -180
- package/lib/local/entrada.js +18 -8
- package/lib/local/motor-cli.js +1 -1
- package/lib/offline.js +168 -0
- package/package.json +2 -3
- package/skills/design-de-verdade/SKILL.md +2 -0
- package/skills/motion-de-primeira/SKILL.md +1 -1
- package/skills/site-com-motion/SKILL.md +39 -0
- package/studio/narrar.py +111 -10
- package/studio/rostos.py +52 -0
- package/kits/acolhedor/index.html +0 -130
- package/kits/acolhedor/script.js +0 -25
- package/kits/acolhedor/style.css +0 -146
- package/kits/editorial/index.html +0 -115
- package/kits/editorial/script.js +0 -19
- package/kits/editorial/style.css +0 -112
- package/kits/landing/index.html +0 -162
- package/kits/landing/script.js +0 -199
- package/kits/landing/style.css +0 -219
- package/kits/premium/index.html +0 -83
- package/kits/premium/script.js +0 -11
- package/kits/premium/style.css +0 -87
- package/kits/vitrine/index.html +0 -108
- package/kits/vitrine/script.js +0 -20
- package/kits/vitrine/style.css +0 -117
- package/lib/kits.js +0 -206
package/lib/local/entrada.js
CHANGED
|
@@ -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 `
|
|
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: ['
|
|
78
|
-
'
|
|
79
|
-
// O que o estúdio produz, e os apps que ele substitui.
|
|
80
|
-
|
|
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: ['
|
|
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
|
-
|
|
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(['
|
|
266
|
+
const REFERENCIAS = new Set(['planejar_producao', 'estudio_site']);
|
|
257
267
|
|
|
258
268
|
// Referência tem teto GENEROSO, não infinito.
|
|
259
269
|
//
|
package/lib/local/motor-cli.js
CHANGED
|
@@ -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.
|
|
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
|
|
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/
|
|
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,
|
|
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
|
|
204
|
-
|
|
205
|
-
|
|
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
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
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
|
-
|
|
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
|
|
package/studio/rostos.py
ADDED
|
@@ -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()
|