primocode 11.3.0 → 11.3.2
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/app-server/cli.js +3 -0
- package/lib/app-server/servidor.js +24 -1
- package/lib/motor/fala-viva.js +77 -0
- package/lib/motor/processo-vivo.js +66 -0
- package/lib/motor/transcricao.js +26 -2
- package/lib/plugin/estudio/criar.js +23 -16
- package/lib/plugin/estudio/historico.js +1 -1
- package/lib/plugin/estudio/index.js +2 -2
- package/lib/plugin/estudio/kit-projeto.js +69 -0
- package/lib/plugin/kits.js +5 -4
- package/lib/session/runner/act.js +2 -1
- package/lib/skill/catalogo.js +2 -2
- package/package.json +2 -2
- package/studio/voz_kokoro_vivo.py +46 -0
- package/voz/primo_voz/transcrever.py +25 -0
package/README.md
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
# PrimoCode v11.3.
|
|
1
|
+
# PrimoCode v11.3.2
|
|
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 v11.3.
|
|
12
|
+
▐▛███▜▌ Primo Code v11.3.2
|
|
13
13
|
▝▜█████▛▘ Bem-vindo de volta, Joel
|
|
14
14
|
▘▘ ▝▝ ~/primocode · /dir <pasta> muda
|
|
15
15
|
```
|
package/lib/app-server/cli.js
CHANGED
|
@@ -41,7 +41,10 @@ async function main(args) {
|
|
|
41
41
|
const contaFalsa = process.env.PRIMOCODE_CONTA_FALSA;
|
|
42
42
|
// PRIMOCODE_TRANSCRITOR_FALSO=<arquivo.js>: async (wav) => ({ ok, texto }) no lugar do Whisper local. Só para teste de tela.
|
|
43
43
|
const transcritorFalso = process.env.PRIMOCODE_TRANSCRITOR_FALSO;
|
|
44
|
+
// PRIMOCODE_FALA_FALSA=<arquivo.js>: { falar(texto) -> {ok,arquivo,mime}, aquecer(), disponivel() } no lugar da voz local. Só para teste.
|
|
45
|
+
const falaFalsa = process.env.PRIMOCODE_FALA_FALSA;
|
|
44
46
|
const srv = criarServidor({
|
|
47
|
+
...(falaFalsa ? { falaViva: require(require('path').resolve(falaFalsa)) } : {}),
|
|
45
48
|
...(transcritorFalso ? { transcritor: require(require('path').resolve(transcritorFalso)) } : {}),
|
|
46
49
|
...(falso ? { motor: require(require('path').resolve(falso)) } : {}),
|
|
47
50
|
...(contaFalsa ? { conta: require(require('path').resolve(contaFalsa)) } : {}),
|
|
@@ -37,6 +37,7 @@ const PAREADO_PODE = new Set([
|
|
|
37
37
|
'account/status', // só ver se há conta; entrar, sair e o login pelo navegador são do computador
|
|
38
38
|
'project/create', 'project/update', 'project/delete', 'agent/create', 'agent/update', 'agent/delete', 'agent/toggle',
|
|
39
39
|
'brand/read', 'brand/write', 'config/write', 'voice/start', 'voice/stop', 'voice/transcribe',
|
|
40
|
+
'voice/warm', 'voice/frases', 'voice/speak', // modo voz ao vivo do celular: só gera áudio e devolve ao celular, nunca toca no computador
|
|
40
41
|
]);
|
|
41
42
|
const CAMPOS_CONFIG_PAREADO = ['effort', 'userName', 'mascot', 'reacoes', 'theme', 'preferencias'];
|
|
42
43
|
const LOOPBACK = new Set(['127.0.0.1', '::1', '::ffff:127.0.0.1']);
|
|
@@ -62,7 +63,7 @@ function githubConectado() {
|
|
|
62
63
|
} catch { return null; }
|
|
63
64
|
}
|
|
64
65
|
|
|
65
|
-
function criarServidor({ motor, casa, raizes, telaDir, abrir, voz, rede = false, enderecos, conta: contaOp = {}, transcritor } = {}) {
|
|
66
|
+
function criarServidor({ motor, casa, raizes, telaDir, abrir, voz, rede = false, enderecos, conta: contaOp = {}, transcritor, falaViva } = {}) {
|
|
66
67
|
const cfg = require('../config/config');
|
|
67
68
|
const m = motor || require('../motor');
|
|
68
69
|
const base = casa || cfg.CONFIG_DIR;
|
|
@@ -82,6 +83,8 @@ function criarServidor({ motor, casa, raizes, telaDir, abrir, voz, rede = false,
|
|
|
82
83
|
const Marca = require('../plugin/marca-kit');
|
|
83
84
|
const Piloto = require('../piloto');
|
|
84
85
|
const Voz = voz || require('../motor/voz').criarVoz({ pasta: path.join(base, 'voz') });
|
|
86
|
+
let Fala = falaViva || null; // a boca do modo voz ao vivo (lib/motor/fala-viva.js), criada só quando alguém fala
|
|
87
|
+
const falaDoModoVoz = () => (Fala = Fala || require('../motor/fala-viva').criarFalaViva({ pasta: path.join(base, 'voz') }));
|
|
85
88
|
let loginNavegador = null;
|
|
86
89
|
const statusConta = () => {
|
|
87
90
|
const a = Conta.atual();
|
|
@@ -522,6 +525,24 @@ function criarServidor({ motor, casa, raizes, telaDir, abrir, voz, rede = false,
|
|
|
522
525
|
/* ── Voz (6.9): provedor trocável em lib/motor/voz.js; só gera arquivo, nunca toca ── */
|
|
523
526
|
async 'voice/start'(p, conn) { return Voz.iniciar({ texto: conn && conn.pareado ? undefined : p && p.texto }); }, // o celular não manda o computador falar
|
|
524
527
|
'voice/stop'() { return Voz.parar(); },
|
|
528
|
+
/* Modo voz ao vivo: abrir a tela sobe o Whisper e o Kokoro em segundo plano (modelo carregado antes da primeira fala). */
|
|
529
|
+
'voice/warm'() {
|
|
530
|
+
if (!transcritor) require('../motor/transcricao').aquecer().catch(() => {});
|
|
531
|
+
falaDoModoVoz().aquecer().catch(() => {});
|
|
532
|
+
return { ok: true, fala: falaDoModoVoz().disponivel() };
|
|
533
|
+
},
|
|
534
|
+
/* A resposta do Primo em frases curtas para falar (sem markdown, código, caminho nem link). */
|
|
535
|
+
'voice/frases'(p) { return { frases: require('../motor/fala-viva').frasesParaFalar(p && p.texto, { max: 6 }) }; },
|
|
536
|
+
/* Uma frase vira áudio (arquivo gerado e devolvido em base64). Nunca toca: quem toca é a tela. */
|
|
537
|
+
async 'voice/speak'(p) {
|
|
538
|
+
const f = falaDoModoVoz();
|
|
539
|
+
const r = await f.falar(p && p.texto);
|
|
540
|
+
if (!r.ok) return { ok: false, error: r.error };
|
|
541
|
+
let audio;
|
|
542
|
+
try { audio = fs.readFileSync(r.arquivo).toString('base64'); } catch (e) { return { ok: false, error: `não li o áudio: ${e.message}` }; }
|
|
543
|
+
try { fs.rmSync(r.arquivo, { force: true }); } catch { /* sem arquivo */ }
|
|
544
|
+
return { ok: true, audio, mime: r.mime || 'audio/wav', provedor: r.provedor, ms: r.ms };
|
|
545
|
+
},
|
|
525
546
|
/* Escuta (bug 3): o renderer grava (MediaRecorder), manda o WAV em base64 e recebe o texto. Whisper local; sem rede. */
|
|
526
547
|
async 'voice/transcribe'(p) {
|
|
527
548
|
const T = require('../motor/transcricao');
|
|
@@ -794,6 +815,8 @@ function criarServidor({ motor, casa, raizes, telaDir, abrir, voz, rede = false,
|
|
|
794
815
|
if (timerPiloto) { clearInterval(timerPiloto); timerPiloto = null; }
|
|
795
816
|
for (const c of [...conexoes]) if (c.fechar) c.fechar();
|
|
796
817
|
conexoes.clear();
|
|
818
|
+
if (!transcritor) { try { require('../motor/transcricao').parar(); } catch { /* sem whisper vivo */ } }
|
|
819
|
+
if (Fala && Fala.parar) { try { Fala.parar(); } catch { /* sem voz viva */ } }
|
|
797
820
|
try { if (porta && JSON.parse(fs.readFileSync(arquivoInfo, 'utf8')).token === token) fs.rmSync(arquivoInfo, { force: true }); } catch { /* nada a limpar */ }
|
|
798
821
|
if (redeSrv) { try { redeSrv.close(); redeSrv.closeAllConnections && redeSrv.closeAllConnections(); } catch { /* já fechou */ } redeSrv = null; }
|
|
799
822
|
return new Promise((res) => { if (http_) { http_.close(() => res()); http_.closeAllConnections && http_.closeAllConnections(); } else res(); });
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
// A boca do modo voz ao vivo: o texto de uma frase vira um arquivo de áudio (WAV/AIFF). NUNCA toca som: devolve o arquivo,
|
|
2
|
+
// e quem toca é a tela (que, no teste, desvia o player). Cadeia: Kokoro local em processo vivo (modelo carregado uma vez, sem
|
|
3
|
+
// provedor pago) -> `say` do macOS -> espeak-ng. Só entra o que a máquina tem.
|
|
4
|
+
'use strict';
|
|
5
|
+
|
|
6
|
+
const fs = require('fs');
|
|
7
|
+
const os = require('os');
|
|
8
|
+
const path = require('path');
|
|
9
|
+
const { execFileSync, spawn } = require('child_process');
|
|
10
|
+
const { criarProcessoVivo } = require('./processo-vivo');
|
|
11
|
+
const K = require('../plugin/estudio/kokoro');
|
|
12
|
+
|
|
13
|
+
const SCRIPT_VIVO = path.join(__dirname, '..', '..', 'studio', 'voz_kokoro_vivo.py');
|
|
14
|
+
const TETO_FRASE = 500;
|
|
15
|
+
|
|
16
|
+
const achar = (nome) => { try { return execFileSync('which', [nome], { stdio: ['ignore', 'pipe', 'ignore'] }).toString().trim() || null; } catch { return null; } };
|
|
17
|
+
|
|
18
|
+
/** Frases que vão pro ouvido: sem markdown, código, caminho nem link; no máximo `max` frases curtas. */
|
|
19
|
+
function frasesParaFalar(texto, { max = 6 } = {}) {
|
|
20
|
+
const { paraFalar } = require('../plugin/voz');
|
|
21
|
+
const limpo = paraFalar(texto, { frases: max, teto: 900 });
|
|
22
|
+
const partes = limpo.match(/[^.!?…]+[.!?…]*/g) || [];
|
|
23
|
+
const out = [];
|
|
24
|
+
for (const p of partes) { const t = p.replace(/\s+/g, ' ').trim(); if (t.length > 1) out.push(t.slice(0, TETO_FRASE)); }
|
|
25
|
+
return out;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function criarFalaViva({ pasta, uv, voz = 'pf_dora', provedores } = {}) {
|
|
29
|
+
const dir = pasta || path.join(os.tmpdir(), 'primocode-voz');
|
|
30
|
+
let n = 0;
|
|
31
|
+
const arq = (ext) => { fs.mkdirSync(dir, { recursive: true, mode: 0o700 }); return path.join(dir, `frase-${Date.now()}-${++n}.${ext}`); };
|
|
32
|
+
|
|
33
|
+
let kokoro = null;
|
|
34
|
+
const kokoroVivo = () => {
|
|
35
|
+
if (kokoro) return kokoro;
|
|
36
|
+
const u = uv || achar('uv') || (fs.existsSync(path.join(os.homedir(), '.local', 'bin', 'uv')) ? path.join(os.homedir(), '.local', 'bin', 'uv') : null);
|
|
37
|
+
const esp = K.acharEspeak();
|
|
38
|
+
if (!u || !esp || !fs.existsSync(SCRIPT_VIVO)) return null;
|
|
39
|
+
const args = ['run', '--quiet', '--python', '3.11'];
|
|
40
|
+
for (const d of K.DEPS) args.push('--with', d);
|
|
41
|
+
args.push('python', SCRIPT_VIVO);
|
|
42
|
+
kokoro = criarProcessoVivo({ cmd: u, args, env: { ...process.env, ESPEAK_DATA_PATH: esp.dados, PHONEMIZER_ESPEAK_LIBRARY: esp.lib }, tempoPronto: 15 * 60000, tempoPedido: 60000 });
|
|
43
|
+
return kokoro;
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
const cadeia = provedores || [
|
|
47
|
+
{ nome: 'kokoro', disponivel: () => !!kokoroVivo() && process.env.PRIMOCODE_VOZ !== 'say',
|
|
48
|
+
async falar(texto) { const o = arq('wav'); const r = await kokoroVivo().pedir({ texto, saida: o, voz }); return r.ok ? { ok: true, arquivo: o, mime: 'audio/wav' } : { ok: false, error: r.error }; } },
|
|
49
|
+
{ nome: 'say', disponivel: () => process.platform === 'darwin' && !!achar('say'),
|
|
50
|
+
falar: (texto) => new Promise((res) => { const o = arq('aiff'); const p = spawn('say', ['-v', 'Luciana', '-o', o, texto]); p.on('error', (e) => res({ ok: false, error: e.message })); p.on('close', (c) => res(c === 0 && fs.existsSync(o) ? { ok: true, arquivo: o, mime: 'audio/aiff' } : { ok: false, error: 'o say não gerou o áudio' })); }) },
|
|
51
|
+
{ nome: 'espeak-ng', disponivel: () => !!achar('espeak-ng'),
|
|
52
|
+
falar: (texto) => new Promise((res) => { const o = arq('wav'); const p = spawn('espeak-ng', ['-v', 'pt-br', '-w', o, texto]); p.on('error', (e) => res({ ok: false, error: e.message })); p.on('close', (c) => res(c === 0 && fs.existsSync(o) ? { ok: true, arquivo: o, mime: 'audio/wav' } : { ok: false, error: 'o espeak-ng não gerou o áudio' })); }) },
|
|
53
|
+
];
|
|
54
|
+
|
|
55
|
+
return {
|
|
56
|
+
/** Sobe o Kokoro em segundo plano (modelo carregado antes da primeira frase). */
|
|
57
|
+
aquecer() { const k = cadeia[0].nome === 'kokoro' && cadeia[0].disponivel() ? kokoroVivo() : null; return k ? k.aquecer() : Promise.resolve({ ok: false }); },
|
|
58
|
+
disponivel() { return cadeia.find((p) => p.disponivel()) ? { ok: true, provedor: cadeia.find((p) => p.disponivel()).nome } : { ok: false, motivo: 'sem voz local: instale o uv e o espeak-ng (brew install uv espeak-ng) e rode `primocode doutor`' }; },
|
|
59
|
+
/** Uma frase vira áudio. Devolve {ok, arquivo, mime, provedor, ms} (nunca rejeita). */
|
|
60
|
+
async falar(texto) {
|
|
61
|
+
const t0 = Date.now();
|
|
62
|
+
const frase = String(texto || '').trim().slice(0, TETO_FRASE);
|
|
63
|
+
if (!frase) return { ok: false, error: 'falta texto' };
|
|
64
|
+
let ultimo = 'sem voz local: instale o uv e o espeak-ng (brew install uv espeak-ng) e rode `primocode doutor`';
|
|
65
|
+
for (const p of cadeia) {
|
|
66
|
+
if (!p.disponivel()) continue;
|
|
67
|
+
const r = await p.falar(K.pronuncia(frase));
|
|
68
|
+
if (r.ok) return { ...r, provedor: p.nome, ms: Date.now() - t0 };
|
|
69
|
+
ultimo = r.error || ultimo;
|
|
70
|
+
}
|
|
71
|
+
return { ok: false, error: ultimo };
|
|
72
|
+
},
|
|
73
|
+
parar() { if (kokoro) { kokoro.parar(); kokoro = null; } },
|
|
74
|
+
};
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
module.exports = { criarFalaViva, frasesParaFalar };
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
// Um processo filho que fica VIVO e conversa por linhas de JSON (uma linha entra, uma linha sai, casadas pelo `id`).
|
|
2
|
+
// Serve ao modo voz ao vivo: o Whisper e o Kokoro carregam o modelo uma vez e respondem de pé, sem subir um processo por trecho.
|
|
3
|
+
// Nunca toca som. Sobe na primeira chamada; `pronto` espera a linha {"pronto":true}; se o filho cai, o próximo pedido sobe outro.
|
|
4
|
+
'use strict';
|
|
5
|
+
|
|
6
|
+
const { spawn } = require('child_process');
|
|
7
|
+
|
|
8
|
+
function criarProcessoVivo({ cmd, args = [], env, cwd, tempoPronto = 180000, tempoPedido = 120000 } = {}) {
|
|
9
|
+
let filho = null, pronto = null, buf = '', id = 0;
|
|
10
|
+
const pend = new Map();
|
|
11
|
+
|
|
12
|
+
function soltarTudo(erro) { for (const p of pend.values()) { clearTimeout(p.t); p.res({ ok: false, error: erro }); } pend.clear(); }
|
|
13
|
+
|
|
14
|
+
function subir() {
|
|
15
|
+
if (filho && pronto) return pronto;
|
|
16
|
+
buf = '';
|
|
17
|
+
filho = spawn(cmd, args, { env: env || process.env, cwd, stdio: ['pipe', 'pipe', 'pipe'] });
|
|
18
|
+
const meu = filho;
|
|
19
|
+
let erro = '';
|
|
20
|
+
meu.stderr.on('data', (d) => { erro = (erro + d).slice(-600); });
|
|
21
|
+
pronto = new Promise((resolve) => {
|
|
22
|
+
const t = setTimeout(() => resolve({ ok: false, error: 'o processo de voz demorou demais para ficar pronto' }), tempoPronto);
|
|
23
|
+
meu.stdout.on('data', (d) => {
|
|
24
|
+
buf += d;
|
|
25
|
+
let i;
|
|
26
|
+
while ((i = buf.indexOf('\n')) >= 0) {
|
|
27
|
+
const linha = buf.slice(0, i).trim(); buf = buf.slice(i + 1);
|
|
28
|
+
if (!linha) continue;
|
|
29
|
+
let m; try { m = JSON.parse(linha); } catch { continue; }
|
|
30
|
+
if (m.pronto) { clearTimeout(t); resolve({ ok: true }); continue; }
|
|
31
|
+
if (m.id === undefined || m.id === null) { if (m.ok === false) { clearTimeout(t); resolve({ ok: false, error: m.error || 'o processo de voz não subiu' }); } continue; }
|
|
32
|
+
const p = pend.get(m.id);
|
|
33
|
+
if (p) { pend.delete(m.id); clearTimeout(p.t); p.res(m); }
|
|
34
|
+
}
|
|
35
|
+
});
|
|
36
|
+
meu.on('error', (e) => { clearTimeout(t); resolve({ ok: false, error: e.message }); });
|
|
37
|
+
meu.on('close', () => {
|
|
38
|
+
clearTimeout(t);
|
|
39
|
+
const era = filho === meu;
|
|
40
|
+
if (era) { filho = null; pronto = null; }
|
|
41
|
+
resolve({ ok: false, error: erro.trim().split('\n').pop() || 'o processo de voz saiu' });
|
|
42
|
+
soltarTudo('o processo de voz saiu');
|
|
43
|
+
});
|
|
44
|
+
});
|
|
45
|
+
return pronto;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
return {
|
|
49
|
+
/** Sobe (se preciso) e espera ficar pronto. Devolve {ok} ou {ok:false,error}. */
|
|
50
|
+
async aquecer() { const r = await subir(); if (!r.ok) { this.parar(); } return r; },
|
|
51
|
+
async pedir(dados) {
|
|
52
|
+
const r = await subir();
|
|
53
|
+
if (!r.ok) { this.parar(); return r; }
|
|
54
|
+
return new Promise((res) => {
|
|
55
|
+
const n = ++id;
|
|
56
|
+
const t = setTimeout(() => { pend.delete(n); res({ ok: false, error: 'a voz demorou demais' }); }, tempoPedido);
|
|
57
|
+
pend.set(n, { res, t });
|
|
58
|
+
try { filho.stdin.write(`${JSON.stringify({ ...dados, id: n })}\n`); } catch (e) { pend.delete(n); clearTimeout(t); res({ ok: false, error: e.message }); }
|
|
59
|
+
});
|
|
60
|
+
},
|
|
61
|
+
parar() { const f = filho; filho = null; pronto = null; if (f) { try { f.kill('SIGTERM'); } catch { /* já saiu */ } } soltarTudo('parado'); },
|
|
62
|
+
vivo: () => !!filho,
|
|
63
|
+
};
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
module.exports = { criarProcessoVivo };
|
package/lib/motor/transcricao.js
CHANGED
|
@@ -7,6 +7,7 @@ const fs = require('fs');
|
|
|
7
7
|
const os = require('os');
|
|
8
8
|
const path = require('path');
|
|
9
9
|
const { spawn, execFileSync } = require('child_process');
|
|
10
|
+
const { criarProcessoVivo } = require('./processo-vivo');
|
|
10
11
|
|
|
11
12
|
const PASTA_VOZ = path.join(__dirname, '..', '..', 'voz');
|
|
12
13
|
const TETO_BYTES = 11 * 1024 * 1024; // ~5 min de WAV 16 kHz mono (em base64 cabe no teto de 16 MB do WebSocket)
|
|
@@ -40,8 +41,31 @@ function transcrever(wav, { python: py, idioma = 'pt', tempoMs = TEMPO_MS } = {}
|
|
|
40
41
|
});
|
|
41
42
|
}
|
|
42
43
|
|
|
44
|
+
/* O Whisper VIVO (modo voz ao vivo): um processo Python que carrega o modelo uma vez e transcreve arquivo por arquivo. */
|
|
45
|
+
let vivo = null;
|
|
46
|
+
function whisperVivo(py) {
|
|
47
|
+
if (!vivo) vivo = criarProcessoVivo({ cmd: py, args: ['-m', 'primo_voz.transcrever', 'servir', 'pt'], cwd: PASTA_VOZ, env: { ...process.env, PYTHONPATH: PASTA_VOZ } });
|
|
48
|
+
return vivo;
|
|
49
|
+
}
|
|
50
|
+
/** Sobe o Whisper em segundo plano (o modelo carrega antes da primeira fala). Nunca rejeita. */
|
|
51
|
+
function aquecer({ python: py } = {}) {
|
|
52
|
+
py = py || require('../plugin/voz').python();
|
|
53
|
+
if (faltaAlgo(py)) return Promise.resolve({ ok: false });
|
|
54
|
+
return whisperVivo(py).aquecer();
|
|
55
|
+
}
|
|
56
|
+
function parar() { if (vivo) { vivo.parar(); vivo = null; } }
|
|
57
|
+
/** Igual a `transcrever`, mas no processo vivo; se ele não sobe, cai no processo avulso. */
|
|
58
|
+
async function transcreverVivo(wav, opts = {}) {
|
|
59
|
+
const py = opts.python || require('../plugin/voz').python();
|
|
60
|
+
const falta = faltaAlgo(py);
|
|
61
|
+
if (falta) return { ok: false, ...falta };
|
|
62
|
+
const r = await whisperVivo(py).pedir({ arquivo: wav });
|
|
63
|
+
if (r.ok) return { ok: true, texto: String(r.texto || '') };
|
|
64
|
+
return transcrever(wav, { python: py });
|
|
65
|
+
}
|
|
66
|
+
|
|
43
67
|
/** Grava o áudio (base64, WAV) num arquivo temporário, transcreve e apaga. `transcritor` troca o Whisper (teste). */
|
|
44
|
-
async function transcreverBase64(audio, { pasta, transcritor =
|
|
68
|
+
async function transcreverBase64(audio, { pasta, transcritor = transcreverVivo } = {}) {
|
|
45
69
|
if (typeof audio !== 'string' || !audio) return { ok: false, error: 'falta o áudio' };
|
|
46
70
|
const bytes = Buffer.from(audio, 'base64');
|
|
47
71
|
if (!bytes.length) return { ok: false, error: 'áudio vazio' };
|
|
@@ -54,4 +78,4 @@ async function transcreverBase64(audio, { pasta, transcritor = transcrever } = {
|
|
|
54
78
|
try { return await transcritor(arq); } finally { try { fs.rmSync(arq, { force: true }); } catch { /* sem arquivo */ } }
|
|
55
79
|
}
|
|
56
80
|
|
|
57
|
-
module.exports = { transcrever, transcreverBase64, faltaAlgo, TETO_BYTES };
|
|
81
|
+
module.exports = { transcrever, transcreverVivo, transcreverBase64, aquecer, parar, faltaAlgo, TETO_BYTES };
|
|
@@ -7,13 +7,13 @@
|
|
|
7
7
|
|
|
8
8
|
const path = require('path');
|
|
9
9
|
|
|
10
|
-
/* tipo → receita. `via`: 'roteiro' (plano pelo roteirista + render) ou '
|
|
10
|
+
/* tipo → receita. `via`: 'roteiro' (plano pelo roteirista + render) ou 'kit' (motion pelos estilos: os 50 kits ou um estilo próprio, lib/plugin/kits.js). */
|
|
11
11
|
const TIPOS = Object.freeze({
|
|
12
12
|
slides: { rotulo: 'Slides', via: 'roteiro', formato: '16:9', projeto: 'slides' },
|
|
13
13
|
video: { rotulo: 'Vídeo curto', via: 'roteiro', formato: '9:16', projeto: 'video', duracao: [15, 60, 30] },
|
|
14
|
-
motion: { rotulo: 'Motion', via: '
|
|
15
|
-
anuncio: { rotulo: 'Anúncio', via: '
|
|
16
|
-
abertura: { rotulo: 'Abertura de canal', via: '
|
|
14
|
+
motion: { rotulo: 'Motion', via: 'kit', formato: '9:16', duracao: [6, 60, 15] },
|
|
15
|
+
anuncio: { rotulo: 'Anúncio', via: 'kit', formato: '9:16', duracao: [10, 30, 15], prefixo: 'Anúncio para chamar pra comprar: ' },
|
|
16
|
+
abertura: { rotulo: 'Abertura de canal', via: 'kit', formato: '16:9', duracao: [5, 5, 5], prefixo: 'Abertura de canal com a marca: ' },
|
|
17
17
|
});
|
|
18
18
|
const FORMATOS = ['9:16', '16:9', '1:1'];
|
|
19
19
|
|
|
@@ -28,7 +28,10 @@ function normalizar(args = {}) {
|
|
|
28
28
|
if (briefing.length < 3) return { ok: false, error: 'falta o briefing: diga em uma frase o que criar.' };
|
|
29
29
|
const formato = FORMATOS.includes(String(args.formato)) ? String(args.formato) : receita.formato;
|
|
30
30
|
const duracao = receita.duracao ? limitar(args.duracao, receita.duracao) : undefined;
|
|
31
|
-
|
|
31
|
+
const estilo = String(args.estilo || '').trim().slice(0, 60) || undefined;
|
|
32
|
+
const en = args.estilo_novo && typeof args.estilo_novo === 'object' ? args.estilo_novo : null;
|
|
33
|
+
const estiloNovo = en && String(en.nome || '').trim() ? { nome: String(en.nome).trim().slice(0, 40), descricao: String(en.descricao || '').slice(0, 300), base: en.base ? String(en.base).slice(0, 60) : undefined } : undefined;
|
|
34
|
+
return { ok: true, tipo, receita, briefing: briefing.slice(0, 600), formato, duracao, estilo, estiloNovo };
|
|
32
35
|
}
|
|
33
36
|
|
|
34
37
|
/** Os arquivos entregues, com o tipo que a tela sabe abrir. */
|
|
@@ -52,12 +55,12 @@ function tipoDoArquivo(c) {
|
|
|
52
55
|
|
|
53
56
|
/**
|
|
54
57
|
* @param {object} args { tipo, briefing, duracao?, formato? }
|
|
55
|
-
* @param {object} deps { projeto,
|
|
58
|
+
* @param {object} deps { projeto, kit(args), plano(args), render(args), progresso(pct, etapa) }
|
|
56
59
|
*/
|
|
57
60
|
async function criar(args, deps) {
|
|
58
61
|
const n = normalizar(args);
|
|
59
62
|
if (!n.ok) return n;
|
|
60
|
-
const { tipo, receita, briefing, formato, duracao } = n;
|
|
63
|
+
const { tipo, receita, briefing, formato, duracao, estilo, estiloNovo } = n;
|
|
61
64
|
const progresso = deps.progresso || (() => {});
|
|
62
65
|
const falha = (error, extra) => ({ ok: false, error, ...extra });
|
|
63
66
|
|
|
@@ -69,12 +72,16 @@ async function criar(args, deps) {
|
|
|
69
72
|
const id = proj.id;
|
|
70
73
|
const base = { tipo, id, pasta: proj.pasta, formato, duracao, titulo };
|
|
71
74
|
|
|
72
|
-
if (receita.via === '
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
progresso(
|
|
77
|
-
|
|
75
|
+
if (receita.via === 'kit') {
|
|
76
|
+
/* Motion SEMPRE pelos kits (11.1): escolhe o estilo (um dos 50, um criado, ou o da tela de estilos) e monta a pasta
|
|
77
|
+
do vídeo com as peças desmontadas dele. A cena a IA compõe com as peças e renderiza com `primocode kit pecas`.
|
|
78
|
+
Nunca cai no gerador antigo (estudio_motion): quem quiser esse caminho chama a ferramenta dele. */
|
|
79
|
+
progresso(15, 'Escolhendo o estilo e montando as pastas');
|
|
80
|
+
const k = await (deps.kit || ((a) => require('./kit-projeto.js').montar(a)))({ pasta: proj.pasta, pedido, formato, duracao, estilo, estiloNovo });
|
|
81
|
+
if (!k || k.ok === false) return falha((k && k.error) || 'não consegui montar o projeto do estilo.', { ...base, ...(k && k.precisa ? { precisa: k.precisa } : {}), ...(k && k.menu ? { menu: k.menu } : {}) });
|
|
82
|
+
progresso(40, 'Pastas do estilo prontas');
|
|
83
|
+
return { ok: true, ...base, kit: k.slug, estilo: k.nome, pastaKit: k.pasta, pecas: k.pecas, categorias: k.categorias, etapa: 'cena', proximo: k.proximo,
|
|
84
|
+
resumo: `${receita.rotulo} no estilo ${k.nome}: pastas montadas, falta compor a cena e renderizar.` };
|
|
78
85
|
}
|
|
79
86
|
|
|
80
87
|
progresso(15, 'Escrevendo o roteiro');
|
|
@@ -99,9 +106,9 @@ module.exports = { criar, normalizar, TIPOS, FORMATOS, tipoDoArquivo, arquivosDe
|
|
|
99
106
|
const QUADRADOS = Object.freeze({
|
|
100
107
|
slides: 'Tipo escolhido na tela: Apresentação de slides. Use estudio_criar com tipo "slides" e o briefing do pedido (a skill slides-que-prendem ajuda no conteúdo).',
|
|
101
108
|
video: 'Tipo escolhido na tela: Vídeo curto vertical. Use estudio_criar com tipo "video" e o briefing do pedido.',
|
|
102
|
-
motion: 'Tipo escolhido na tela: Motion. Use estudio_criar com tipo "motion" e o
|
|
103
|
-
anuncio: 'Tipo escolhido na tela: Anúncio de 15 s. Use estudio_criar com tipo "anuncio" e o
|
|
104
|
-
abertura: 'Tipo escolhido na tela: Abertura de canal de 5 s. Use estudio_criar com tipo "abertura" e o
|
|
109
|
+
motion: 'Tipo escolhido na tela: Motion (pelos estilos/kits). Use estudio_criar com tipo "motion", o briefing e o estilo (um dos kits do menu, ou estilo_novo; sem estilo vale o da tela de estilos). Depois siga o caminho "primocode kit pecas <estilo>" que a ferramenta devolve.',
|
|
110
|
+
anuncio: 'Tipo escolhido na tela: Anúncio de 15 s em motion (pelos estilos/kits). Use estudio_criar com tipo "anuncio", o briefing e o estilo (um dos kits do menu, ou estilo_novo). Depois siga o caminho "primocode kit pecas <estilo>" que a ferramenta devolve.',
|
|
111
|
+
abertura: 'Tipo escolhido na tela: Abertura de canal de 5 s em motion (pelos estilos/kits). Use estudio_criar com tipo "abertura", o briefing e o estilo (um dos kits do menu, ou estilo_novo). Depois siga o caminho "primocode kit pecas <estilo>" que a ferramenta devolve.',
|
|
105
112
|
pagina: 'Tipo escolhido na tela: Página ou app. Construa com write_file na pasta do projeto, confira abrindo no navegador e entregue o arquivo principal. Não use o Estúdio.',
|
|
106
113
|
bug: 'Tipo escolhido na tela: Corrigir um bug. Ache a causa lendo o código do projeto (read_file, grep), corrija com edit_file e crie um teste que pegue o caso; rode os testes e conte o que mudou. Não use o Estúdio.',
|
|
107
114
|
pr: 'Tipo escolhido na tela: Revisar um PR. Leia o diff do PR (o número ou o link que a pessoa der; gh ou git diff), aponte os riscos do mais grave ao menos grave e sugira a correção. Não use o Estúdio.',
|
|
@@ -7,7 +7,7 @@ const path = require('path');
|
|
|
7
7
|
const { tipoDoArquivo } = require('./criar');
|
|
8
8
|
const Miniatura = require('./miniatura');
|
|
9
9
|
|
|
10
|
-
const PULAR = new Set(['node_modules', 'remotion', 'midia', 'entrada', 'versoes', '.git']);
|
|
10
|
+
const PULAR = new Set(['node_modules', 'remotion', 'midia', 'entrada', 'versoes', '.git', 'pecas']);
|
|
11
11
|
const SAIDAS = new Set(['video', 'pdf', 'imagem']);
|
|
12
12
|
|
|
13
13
|
/* Procura as saídas em até 2 níveis; imagem solta (quadro de conferência) serve de miniatura, não de entrega. */
|
|
@@ -750,12 +750,12 @@ async function renderMotion(args, p, receita) {
|
|
|
750
750
|
|
|
751
751
|
// ── estudio_criar ────────────────────────────────────────────────────────
|
|
752
752
|
|
|
753
|
-
/** A porta simples (criar.js): escolhe a receita e chama as ferramentas de cima. `ctx.progresso(pct, etapa)` alimenta o item da tela. */
|
|
753
|
+
/** A porta simples (criar.js): escolhe a receita e chama as ferramentas de cima (motion vai pelos kits: kit-projeto.js). `ctx.progresso(pct, etapa)` alimenta o item da tela. */
|
|
754
754
|
async function ferramentaCriar(args, ctx = {}) {
|
|
755
755
|
return require('./criar.js').criar(args, {
|
|
756
756
|
projeto,
|
|
757
757
|
plano: (a) => ferramentaPlano({ ...a, __pasta: ctx.projectDir }),
|
|
758
|
-
|
|
758
|
+
kit: (a) => require('./kit-projeto.js').montar({ ...a, projectDir: ctx.projectDir }, { aoFalar: ctx.aoFalar }),
|
|
759
759
|
render: (a) => ferramentaRender({ ...a, __pasta: ctx.projectDir }, ctx),
|
|
760
760
|
progresso: ctx.progresso,
|
|
761
761
|
});
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
// O motion pelo sistema de estilos (kits): o `estudio_criar` dos tipos motion, anuncio e abertura passa por aqui.
|
|
2
|
+
// Resolve o estilo (um dos 50, um que a pessoa criou, ou a escolha dela na tela de estilos) e monta a pasta do vídeo
|
|
3
|
+
// com as peças desmontadas do estilo (formas, texturas, transições, tipografia, câmera, sons, trilha, cores, fontes,
|
|
4
|
+
// regras). A cena em si a IA compõe do zero com essas peças e renderiza com `primocode kit pecas` (lib/plugin/kits.js).
|
|
5
|
+
// Nada de gerador antigo aqui: é a 11.1 de kits, só que com a porta simples na frente. Node puro.
|
|
6
|
+
'use strict';
|
|
7
|
+
|
|
8
|
+
const fs = require('fs');
|
|
9
|
+
const path = require('path');
|
|
10
|
+
|
|
11
|
+
const semReferencia = (src) => !/[\\/]referencia([\\/]|$)/.test(src);
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* @param {object} a { pasta (do projeto), pedido, formato, duracao, estilo?, estiloNovo?: {nome, descricao, base, ref?}, home?, projectDir? }
|
|
15
|
+
* @param {object} d { K (lib/plugin/kits.js), MAT (materiais.js), T (kits-tela.js), aoFalar }
|
|
16
|
+
*/
|
|
17
|
+
async function montar(a, d = {}) {
|
|
18
|
+
const K = d.K || require('../kits.js');
|
|
19
|
+
const MAT = d.MAT || require('./materiais.js');
|
|
20
|
+
const op = { home: a.home, projectDir: a.projectDir || a.pasta, env: d.env, aoFalar: d.aoFalar };
|
|
21
|
+
let slug = null, nome = null, origem = null;
|
|
22
|
+
|
|
23
|
+
// 1) estilo novo, pedido pela IA: copia as peças de um estilo base e devolve o slug
|
|
24
|
+
if (a.estiloNovo && a.estiloNovo.nome) {
|
|
25
|
+
const e = a.estiloNovo;
|
|
26
|
+
const argv = ['criar', '--nome', String(e.nome), '--descricao', String(e.descricao || e.nome)];
|
|
27
|
+
if (e.base) argv.push('--base', String(e.base));
|
|
28
|
+
if (e.ref) argv.push('--ref', String(e.ref));
|
|
29
|
+
const r = await K.main(argv, op);
|
|
30
|
+
if (!r.ok || !r.slug) return { ok: false, error: r.texto || 'não consegui criar o estilo.' };
|
|
31
|
+
slug = r.slug; origem = 'criado';
|
|
32
|
+
}
|
|
33
|
+
// 2) estilo nomeado (slug ou nome) entre os 50 e os da pessoa
|
|
34
|
+
if (!slug && a.estilo) {
|
|
35
|
+
const k = K.achar(a.estilo, op);
|
|
36
|
+
if (!k) return { ok: false, error: `o estilo "${a.estilo}" não existe. Escolha um do menu ou crie um novo (estilo_novo).`, menu: K.menuParaIA(op) };
|
|
37
|
+
slug = k.slug; nome = k.nome; origem = 'escolhido';
|
|
38
|
+
}
|
|
39
|
+
// 3) a escolha dela na tela de estilos (no primeiro motion a tela abre e a escolha volta)
|
|
40
|
+
if (!slug) {
|
|
41
|
+
const m = await K.main(['menu'], op);
|
|
42
|
+
if (m && m.escolha) { slug = m.escolha; origem = 'tela'; }
|
|
43
|
+
else return { ok: false, precisa: 'estilo', error: 'falta escolher o estilo: chame de novo com estilo=<slug> (um do menu) ou estilo_novo={nome, descricao, base}.', menu: m && m.texto ? m.texto : K.menuParaIA(op) };
|
|
44
|
+
}
|
|
45
|
+
if (!MAT.migrado(slug)) return { ok: false, error: `o estilo ${slug} não tem as peças soltas (materiais/${slug}).` };
|
|
46
|
+
if (!nome) { const k = K.achar(slug, op); nome = k ? k.nome : slug; }
|
|
47
|
+
|
|
48
|
+
// a pasta do vídeo: motion/pecas/<categorias> + kit.json + LEIA.md
|
|
49
|
+
const raiz = path.join(a.pasta, 'motion');
|
|
50
|
+
const pecas = path.join(raiz, 'pecas');
|
|
51
|
+
fs.rmSync(pecas, { recursive: true, force: true });
|
|
52
|
+
fs.mkdirSync(raiz, { recursive: true });
|
|
53
|
+
fs.cpSync(MAT.pastaDe(slug), pecas, { recursive: true, filter: semReferencia });
|
|
54
|
+
const pastas = fs.readdirSync(pecas, { withFileTypes: true }).filter((e) => e.isDirectory()).map((e) => e.name).sort();
|
|
55
|
+
const arquivos = fs.readdirSync(pecas, { withFileTypes: true }).filter((e) => e.isFile()).map((e) => e.name).sort();
|
|
56
|
+
const kit = { slug, nome, origem, formato: a.formato, duracao: a.duracao || null, pedido: a.pedido, pecas: path.relative(a.pasta, pecas), criado: new Date().toISOString() };
|
|
57
|
+
fs.writeFileSync(path.join(raiz, 'kit.json'), JSON.stringify(kit, null, 2) + '\n');
|
|
58
|
+
const proximo = [
|
|
59
|
+
`Estilo ${nome} (${slug}): as peças estão em ${pecas} (${pastas.join(', ')}).`,
|
|
60
|
+
`1) primocode kit pecas ${slug} regras (identidade e densidade mínima)`,
|
|
61
|
+
`2) primocode kit pecas ${slug} ver <peça> (código e params das peças que vai usar)`,
|
|
62
|
+
`3) escreva a cena nova em TSX com o conteúdo do pedido e grave: primocode kit pecas ${slug} escrever <nome> --arquivo cena.tsx --pasta ${a.pasta}`,
|
|
63
|
+
`4) renderize: primocode kit pecas ${slug} renderizar <nome> --pasta ${a.pasta} (o vídeo sai em ${path.join(a.pasta, 'saida')})`,
|
|
64
|
+
].join('\n');
|
|
65
|
+
fs.writeFileSync(path.join(raiz, 'LEIA.md'), `# ${nome}\n\nPedido: ${a.pedido}\n\n${proximo}\n`);
|
|
66
|
+
return { ok: true, slug, nome, origem, pasta: raiz, pecas, categorias: pastas, arquivos, proximo };
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
module.exports = { montar };
|
package/lib/plugin/kits.js
CHANGED
|
@@ -460,8 +460,9 @@ async function cmdPecas(a, op) {
|
|
|
460
460
|
const [estilo, sub, alvo] = a._;
|
|
461
461
|
if (!estilo) return { ok: true, texto: `Estilos com peças soltas: ${MATERIAIS.estilos().join(', ') || 'nenhum'}. Uso: primocode kit pecas <estilo> [ver <peça> | regras | escrever <nome> --arquivo <cena.tsx> | conferir <cena.tsx> | renderizar <nome>]` };
|
|
462
462
|
if (!MATERIAIS.migrado(estilo)) return { ok: false, texto: `O estilo "${estilo}" ainda não tem peças soltas (migrados: ${MATERIAIS.estilos().join(', ') || 'nenhum'}).` };
|
|
463
|
-
|
|
464
|
-
const
|
|
463
|
+
// --pasta <projeto>: a pasta do vídeo que o estudio_criar montou (motion/ e saida/ ficam nela)
|
|
464
|
+
const pasta = a.pasta ? path.resolve(String(a.pasta)) : (op.projectDir || process.cwd());
|
|
465
|
+
const lerArq = (f) => { const aqui = path.resolve(op.projectDir || process.cwd(), String(f)); return fs.readFileSync(fs.existsSync(aqui) ? aqui : path.resolve(pasta, String(f)), 'utf8'); };
|
|
465
466
|
try {
|
|
466
467
|
switch (sub) {
|
|
467
468
|
case undefined: case 'listar': return MATERIAIS.listar(estilo);
|
|
@@ -477,7 +478,7 @@ async function cmdPecas(a, op) {
|
|
|
477
478
|
const dir = path.join(pasta, 'motion', `.render-${alvo}`);
|
|
478
479
|
MATERIAIS.prepararRender(estilo, dir, arq, { duracao: a.quadros ? Number(a.quadros) : undefined });
|
|
479
480
|
ligarModulos(dir, est.modulos);
|
|
480
|
-
const saida = path.resolve(pasta, String(a.saida || path.join('motion', `${alvo}.mp4`)));
|
|
481
|
+
const saida = path.resolve(pasta, String(a.saida || (a.pasta ? path.join('saida', `${alvo}.mp4`) : path.join('motion', `${alvo}.mp4`))));
|
|
481
482
|
const CQ = require('./estudio/conferir-quadros.js');
|
|
482
483
|
const cenas = CQ.cenasDe(fs.readFileSync(arq, 'utf8'));
|
|
483
484
|
const r = await rodarRender(dir, saida, { escala: Number(a.escala) || 1, quadros: cenas.map((c) => c.meio), aoFalar: op.aoFalar || (() => {}) });
|
|
@@ -506,7 +507,7 @@ function opcoes(args) {
|
|
|
506
507
|
const AJUDA = `primocode kit — os estilos de motion (peças soltas)
|
|
507
508
|
menu o menu dos estilos (no primeiro motion abre a tela de estilos)
|
|
508
509
|
ver <slug> as peças e as regras do estilo (igual a pecas <slug>)
|
|
509
|
-
pecas <estilo> [ver <peça>|regras|escrever <nome> --arquivo <tsx>|conferir <tsx>|renderizar <nome>]
|
|
510
|
+
pecas <estilo> [ver <peça>|regras|escrever <nome> --arquivo <tsx>|conferir <tsx>|renderizar <nome>] [--pasta <projeto>]
|
|
510
511
|
a IA compõe a cena do zero com as peças; o conferir cobra a densidade
|
|
511
512
|
referencia abre a tela de referências e devolve o que mediu
|
|
512
513
|
criar --nome "<nome>" --descricao "<como é>" [--base <slug>] [--ref <vídeo>] [--quando "a; b; c"] [--familia "<família>"]
|
|
@@ -1200,7 +1200,8 @@ async function runActInterno(userPrompt, opts) {
|
|
|
1200
1200
|
try {
|
|
1201
1201
|
const kits = require('../../plugin/kits.js');
|
|
1202
1202
|
kits.novoTurno();
|
|
1203
|
-
|
|
1203
|
+
// a instrução do quadrado (Motion, Anúncio, Abertura) também conta: o pedido sozinho ("bolo de pote") não parece motion
|
|
1204
|
+
const cabeca = kits.cabecaDoPedido(instrucao ? `${userPrompt}\n${instrucao}` : userPrompt, { projectDir });
|
|
1204
1205
|
// o vídeo esperando o sim vai colado na resposta da pessoa (é dela que a IA decide); o menu, na frente
|
|
1205
1206
|
if (cabeca && /^\[VÍDEO (ESPERANDO|SENDO MONTADO)/.test(cabeca) && pedido.endsWith(userPrompt)) pedido = `${pedido.slice(0, pedido.length - userPrompt.length)}${cabeca}\n\nRESPOSTA DA PESSOA: ${userPrompt}`;
|
|
1206
1207
|
else if (cabeca) pedido = `${cabeca}\n\n${pedido}`;
|
package/lib/skill/catalogo.js
CHANGED
|
@@ -82,8 +82,8 @@ const GRUPOS = [
|
|
|
82
82
|
nome: 'Estúdio',
|
|
83
83
|
resumo: 'vídeo, motion, slides e arte — a IA faz, e no fim abre o resultado pronto',
|
|
84
84
|
itens: [
|
|
85
|
-
{ tool: 'estudio_criar', escreve: true, faz: 'a porta simples:
|
|
86
|
-
args: 'tipo (slides|video|motion|anuncio|abertura). briefing
|
|
85
|
+
{ tool: 'estudio_criar', escreve: true, faz: 'a porta simples: slides e vídeo vão do pedido ao arquivo; motion, anúncio e abertura escolhem um dos 50 estilos (kits) e montam as pastas do vídeo',
|
|
86
|
+
args: 'tipo (slides|video|motion|anuncio|abertura). briefing. duracao (s). formato (9:16|16:9|1:1). Motion: estilo (slug do kit) ou estilo_novo {nome, descricao, base}; depois, primocode kit pecas.' },
|
|
87
87
|
{ tool: 'estudio_projeto', escreve: true, faz: 'abre, acha e lista os projetos do estúdio — cada peça é uma pasta que não some',
|
|
88
88
|
args: 'acao: criar | abrir | procurar | listar | anotar | editar | assinatura. '
|
|
89
89
|
+ 'assinatura: quando o usuário diz o que achou de um vídeo ("adorei a virada", "texto demais"), grave com texto (e o id) — o próximo vídeo parte disso; sem texto, lê as faixas e as notas. '
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "primocode",
|
|
3
|
-
"version": "11.3.
|
|
3
|
+
"version": "11.3.2",
|
|
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": {
|
|
@@ -11,7 +11,7 @@
|
|
|
11
11
|
"postinstall": "node lib/preparar.js || true",
|
|
12
12
|
"test:server": "cd server && npm test",
|
|
13
13
|
"test": "node scripts/testes.js",
|
|
14
|
-
"test:app": "node test/app-fluxo.test.mjs && node test/app-primeira.test.mjs && node test/app-entrar.test.mjs && node test/app-tela-estudio.test.mjs && node test/app-celular-telas.test.mjs",
|
|
14
|
+
"test:app": "node test/app-fluxo.test.mjs && node test/app-primeira.test.mjs && node test/app-entrar.test.mjs && node test/app-tela-estudio.test.mjs && node test/app-celular-telas.test.mjs && node test/app-compositor-voz.test.mjs",
|
|
15
15
|
"app:build": "cd app && npm run dist",
|
|
16
16
|
"app:dev": "cd app && npm start",
|
|
17
17
|
"mobile:build": "cd mobile && npm run build"
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""voz_kokoro_vivo.py - a voz local do modo voz ao vivo: o Kokoro-82M fica CARREGADO e fala frase por frase.
|
|
3
|
+
|
|
4
|
+
Entrada (uma linha de JSON por pedido): {"id": 1, "texto": "Oi.", "saida": "/tmp/x.wav", "voz": "pf_dora"}
|
|
5
|
+
Saída (uma linha por resposta): {"id": 1, "ok": true, "arquivo": "/tmp/x.wav", "segundos": 1.2}
|
|
6
|
+
Primeira linha de saída: {"pronto": true}. Só GERA arquivo WAV; nunca toca som. Roda pelo uv (lib/motor/fala-viva.js monta o comando).
|
|
7
|
+
"""
|
|
8
|
+
import json
|
|
9
|
+
import re
|
|
10
|
+
import sys
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def main():
|
|
14
|
+
import numpy as np
|
|
15
|
+
import soundfile as sf
|
|
16
|
+
from kokoro import KPipeline
|
|
17
|
+
|
|
18
|
+
pipe = KPipeline(lang_code="p")
|
|
19
|
+
# uma passada curta aquece o modelo: a primeira frase de verdade já não paga a carga
|
|
20
|
+
try:
|
|
21
|
+
for _ in pipe("Oi.", voice="pf_dora"):
|
|
22
|
+
pass
|
|
23
|
+
except Exception:
|
|
24
|
+
pass
|
|
25
|
+
print(json.dumps({"pronto": True}), flush=True)
|
|
26
|
+
for linha in sys.stdin:
|
|
27
|
+
linha = linha.strip()
|
|
28
|
+
if not linha:
|
|
29
|
+
continue
|
|
30
|
+
pedido = {}
|
|
31
|
+
try:
|
|
32
|
+
pedido = json.loads(linha)
|
|
33
|
+
texto = re.sub(r"\s+", " ", str(pedido.get("texto") or "")).strip()
|
|
34
|
+
if not texto:
|
|
35
|
+
print(json.dumps({"id": pedido.get("id"), "ok": False, "error": "texto vazio"}), flush=True)
|
|
36
|
+
continue
|
|
37
|
+
partes = [np.asarray(a, dtype=np.float32) for _, _, a in pipe(texto, voice=pedido.get("voz") or "pf_dora")]
|
|
38
|
+
audio = np.concatenate(partes)
|
|
39
|
+
sf.write(str(pedido["saida"]), audio, 24000)
|
|
40
|
+
print(json.dumps({"id": pedido.get("id"), "ok": True, "arquivo": pedido["saida"], "segundos": round(len(audio) / 24000, 3)}), flush=True)
|
|
41
|
+
except Exception as e:
|
|
42
|
+
print(json.dumps({"id": pedido.get("id"), "ok": False, "error": str(e)[:300]}), flush=True)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
if __name__ == "__main__":
|
|
46
|
+
main()
|
|
@@ -200,6 +200,31 @@ def main() -> int:
|
|
|
200
200
|
return 1
|
|
201
201
|
print(json.dumps({"ok": True, "palavras": ws}, ensure_ascii=False))
|
|
202
202
|
return 0
|
|
203
|
+
if acao == "servir":
|
|
204
|
+
# `... servir [idioma]`: processo vivo do app (modo voz ao vivo). O modelo carrega UMA vez; cada linha de entrada é
|
|
205
|
+
# {"id":N,"arquivo":"x.wav"} e cada linha de saída é {"id":N,"ok":true,"texto":"..."}. Primeira linha: {"pronto":true}.
|
|
206
|
+
if not disponivel():
|
|
207
|
+
print(json.dumps({"ok": False, "error": "o faster-whisper não está instalado"}), flush=True)
|
|
208
|
+
return 1
|
|
209
|
+
idioma = sys.argv[2] if len(sys.argv) > 2 else "pt"
|
|
210
|
+
try:
|
|
211
|
+
carregar()
|
|
212
|
+
except Exception as e:
|
|
213
|
+
print(json.dumps({"ok": False, "error": str(e)}), flush=True)
|
|
214
|
+
return 1
|
|
215
|
+
print(json.dumps({"pronto": True, "modelo": MODELO}), flush=True)
|
|
216
|
+
for linha in sys.stdin:
|
|
217
|
+
linha = linha.strip()
|
|
218
|
+
if not linha:
|
|
219
|
+
continue
|
|
220
|
+
pedido = {}
|
|
221
|
+
try:
|
|
222
|
+
pedido = json.loads(linha)
|
|
223
|
+
texto = transcrever(Path(pedido["arquivo"]), idioma)
|
|
224
|
+
print(json.dumps({"id": pedido.get("id"), "ok": True, "texto": texto}, ensure_ascii=False), flush=True)
|
|
225
|
+
except Exception as e:
|
|
226
|
+
print(json.dumps({"id": pedido.get("id"), "ok": False, "error": str(e)}), flush=True)
|
|
227
|
+
return 0
|
|
203
228
|
if acao == "arquivo":
|
|
204
229
|
# `... arquivo <wav> [idioma]`: a fala de um arquivo vira texto (o app desktop grava no microfone e manda para cá).
|
|
205
230
|
if not disponivel():
|