primocode 11.1.0 → 11.2.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.
@@ -0,0 +1,77 @@
1
+ // Eventos do motor (docs/estrutura.md, seção 3). O motor só EMITE; quem
2
+ // desenha (terminal, app desktop, -p) assina. Node puro, sem dependência.
3
+ 'use strict';
4
+
5
+ const EVENTOS = Object.freeze({
6
+ TURNO_COMECOU: 'turn/started',
7
+ TURNO_TERMINOU: 'turn/completed',
8
+ TURNO_INTERROMPIDO: 'turn/interrupted',
9
+ ITEM_COMECOU: 'item/started',
10
+ ITEM_DELTA: 'item/delta',
11
+ ITEM_TERMINOU: 'item/completed',
12
+ STATUS: 'status/changed',
13
+ USO: 'usage/updated',
14
+ APROVACAO: 'approval/requested',
15
+ PERGUNTA: 'question/requested',
16
+ AVISO: 'notice/created',
17
+ });
18
+
19
+ const TIPOS = Object.freeze(Object.values(EVENTOS));
20
+
21
+ /* Cada "vista" é uma coisa que a tela do terminal sabe desenhar. O evento
22
+ carrega os campos do contrato (`evento`, `id`, `tipo`, `estado`) e os dados
23
+ que a tela precisa. Estados: rodando · ok · erro · interrompido. */
24
+ const VISTAS = Object.freeze({
25
+ 'spinner-inicia': { evento: EVENTOS.STATUS, tipo: 'status', estado: 'rodando' },
26
+ 'spinner-detalhe': { evento: EVENTOS.STATUS, tipo: 'status', estado: 'rodando' },
27
+ 'spinner-para': { evento: EVENTOS.STATUS, tipo: 'status', estado: 'parado' },
28
+ 'pensamento': { evento: EVENTOS.ITEM_TERMINOU, tipo: 'raciocinio', estado: 'ok' },
29
+ 'resposta': { evento: EVENTOS.ITEM_TERMINOU, tipo: 'mensagem', estado: 'ok' },
30
+ 'fluxo-inicia': { evento: EVENTOS.ITEM_COMECOU, tipo: 'mensagem', estado: 'rodando' },
31
+ 'fluxo-escreve': { evento: EVENTOS.ITEM_DELTA, tipo: 'mensagem', estado: 'rodando' },
32
+ 'fluxo-fecha': { evento: EVENTOS.ITEM_TERMINOU, tipo: 'mensagem', estado: 'ok' },
33
+ 'acao': { evento: EVENTOS.ITEM_COMECOU, tipo: 'chamada', estado: 'rodando' },
34
+ 'resultado': { evento: EVENTOS.ITEM_TERMINOU, tipo: 'chamada', estado: 'ok' },
35
+ 'resultado-linha': { evento: EVENTOS.ITEM_TERMINOU, tipo: 'chamada', estado: 'ok' },
36
+ 'paralelo': { evento: EVENTOS.ITEM_COMECOU, tipo: 'agente', estado: 'rodando' },
37
+ 'agente': { evento: EVENTOS.ITEM_COMECOU, tipo: 'agente', estado: 'rodando' },
38
+ 'agente-voltou': { evento: EVENTOS.ITEM_TERMINOU, tipo: 'agente', estado: 'ok' },
39
+ 'estudio-progresso': { evento: EVENTOS.ITEM_DELTA, tipo: 'estudio', estado: 'rodando' },
40
+ 'aviso': { evento: EVENTOS.AVISO, tipo: 'aviso', estado: 'ok' },
41
+ 'problema': { evento: EVENTOS.AVISO, tipo: 'aviso', estado: 'erro' },
42
+ 'linha': { evento: EVENTOS.AVISO, tipo: 'aviso', estado: 'ok' },
43
+ });
44
+
45
+ let SEQ = 0;
46
+ const novoId = () => `i${++SEQ}`;
47
+
48
+ /* Monta um evento a partir de uma vista. `dados.id` junta eventos do mesmo
49
+ item (spinner, fluxo); sem ele nasce um id novo. */
50
+ function montar(vista, dados) {
51
+ const base = VISTAS[vista];
52
+ if (!base) throw new Error(`vista desconhecida: ${vista}`);
53
+ return Object.assign({ vista, id: novoId() }, base, dados || {});
54
+ }
55
+
56
+ /* Emissor mínimo. on('*', fn) escuta tudo. Um assinante que quebra não
57
+ derruba o turno. emitir(evento) aceita o objeto montado por `montar`. */
58
+ function criarEmissor() {
59
+ const ouvintes = new Map();
60
+ const on = (tipo, fn) => {
61
+ if (!ouvintes.has(tipo)) ouvintes.set(tipo, new Set());
62
+ ouvintes.get(tipo).add(fn);
63
+ return () => off(tipo, fn);
64
+ };
65
+ const off = (tipo, fn) => { const s = ouvintes.get(tipo); if (s) s.delete(fn); };
66
+ const emitir = (evento) => {
67
+ if (!evento || !evento.evento) return;
68
+ for (const chave of [evento.evento, '*']) {
69
+ for (const fn of [...(ouvintes.get(chave) || [])]) {
70
+ try { fn(evento); } catch (e) { if (process.env.PRIMOCODE_DEBUG) console.error('[motor] ouvinte falhou:', e && e.message); }
71
+ }
72
+ }
73
+ };
74
+ return { on, off, emitir };
75
+ }
76
+
77
+ module.exports = { EVENTOS, TIPOS, VISTAS, montar, novoId, criarEmissor };
@@ -0,0 +1,8 @@
1
+ 'use strict';
2
+
3
+ module.exports = {
4
+ ...require('./eventos'),
5
+ ...require('./turno'),
6
+ ...require('./aprovacao'),
7
+ telaTerminal: require('./tela-terminal'),
8
+ };
@@ -0,0 +1,64 @@
1
+ // A tela de terminal: assina os eventos do motor e chama exatamente o que
2
+ // `runAct` chamava antes de a tela sair do turno (fala.*, Spinner, logResult).
3
+ // Mesma saída, mesma ordem. O motor não sabe que isto existe.
4
+ 'use strict';
5
+
6
+ const { criarEmissor } = require('./eventos');
7
+
8
+ /* Uma tela por turno: guarda os spinners e os fluxos abertos, por id. */
9
+ function conectar(emissor) {
10
+ const em = emissor || criarEmissor();
11
+ const spinners = new Map();
12
+ const fluxos = new Map();
13
+
14
+ em.on('*', (e) => {
15
+ // Carregados na hora de usar: act.js carrega este arquivo, e o contrário seria um ciclo.
16
+ const fala = require('../fala');
17
+ const { c, Spinner } = require('../tui/ui');
18
+ const act = require('../session/runner/act');
19
+ const p = e.profundidade;
20
+ switch (e.vista) {
21
+ case 'spinner-inicia':
22
+ spinners.set(e.id, new Spinner(e.rotulo).start());
23
+ break;
24
+ case 'spinner-detalhe': {
25
+ const sp = spinners.get(e.id);
26
+ if (sp) sp.detalhe = e.detalhe;
27
+ break;
28
+ }
29
+ case 'spinner-para': {
30
+ const sp = spinners.get(e.id);
31
+ if (sp) sp.stop();
32
+ break;
33
+ }
34
+ case 'pensamento': fala.pensamento(e.texto, p); break;
35
+ case 'resposta': fala.resposta(e.texto); break;
36
+ case 'fluxo-inicia': {
37
+ const f = fala.fluxo();
38
+ fluxos.set(e.id, f);
39
+ if (e.texto) f.escrever(e.texto);
40
+ break;
41
+ }
42
+ case 'fluxo-escreve': { const f = fluxos.get(e.id); if (f) f.escrever(e.texto); break; }
43
+ case 'fluxo-fecha': { const f = fluxos.get(e.id); if (f) { f.fechar(); fluxos.delete(e.id); } break; }
44
+ case 'acao': require('../cli-nova').contar(); fala.acao(e.nome, e.args, p); break;
45
+ case 'resultado': act.logResult(e.nome, e.resultado, p); break;
46
+ case 'resultado-linha':
47
+ console.log(fala.resultado(p) + (e.nivel === 'aviso' ? c.warn(e.texto) : c.muted(e.texto)));
48
+ break;
49
+ case 'paralelo': fala.paralelo(e.quantos, p); break;
50
+ case 'agente': fala.agente(e.rotulo, e.descricao, p); break;
51
+ case 'agente-voltou': fala.agenteVoltou(e.texto, p); break;
52
+ case 'aviso': fala.aviso(e.texto); break;
53
+ case 'problema': fala.problema(e.erro); break;
54
+ case 'linha': console.log(e.nivel === 'aviso' ? c.warn(e.texto) : e.nivel === 'ok' ? c.ok(e.texto) : e.nivel === 'dim' ? c.dim(e.texto) : e.texto); break;
55
+ default: break;
56
+ }
57
+ });
58
+ return em;
59
+ }
60
+
61
+ /* A função `emitir` pronta para o runAct: uma tela de terminal nova. */
62
+ function criar() { return conectar().emitir; }
63
+
64
+ module.exports = { conectar, criar };
@@ -0,0 +1,43 @@
1
+ // Um turno sem tela: chama o runAct e emite o ciclo do turno.
2
+ 'use strict';
3
+
4
+ const { EVENTOS, novoId } = require('./eventos');
5
+
6
+ /* `iniciarTurno({ texto, ...opts, emitir })`. Sem `emitir`, usa a tela de terminal.
7
+ `opts` são os mesmos do runAct (server, model, token, history, projectDir…). */
8
+ async function iniciarTurno({ texto, emitir, portao, ...opts }) {
9
+ const act = require('../session/runner/act');
10
+ const saida = emitir || require('./tela-terminal').criar();
11
+ const id = novoId();
12
+ const t0 = Date.now();
13
+ saida({ evento: EVENTOS.TURNO_COMECOU, id, tipo: 'turno', estado: 'rodando', texto });
14
+ let r;
15
+ try {
16
+ // Com `portao` (lib/motor/aprovacao.js), aprovações e perguntas viram eventos.
17
+ const viaPortao = portao ? { autorizar: portao.autorizar, mudarPasta: opts.mudarPasta || portao.mudarPasta } : {};
18
+ r = await act.runAct(texto, { ...opts, ...viaPortao, emitir: saida });
19
+ } catch (erro) {
20
+ if (portao) portao.cancelar();
21
+ saida({ evento: EVENTOS.TURNO_INTERROMPIDO, id, tipo: 'turno', estado: 'erro', duracao: Date.now() - t0, erro });
22
+ throw erro;
23
+ }
24
+ const duracao = Date.now() - t0;
25
+ if (portao) portao.cancelar();
26
+ // Contexto da conversa para a status line (régua do /compact, lib/memoria-conversa.js).
27
+ try {
28
+ const memoria = require('../session/memoria-conversa');
29
+ const hist = (opts.history || []).concat(r && r.userMessage ? [r.userMessage] : [], r && r.assistantMessage ? [r.assistantMessage] : []);
30
+ saida({ evento: EVENTOS.USO, id: novoId(), tipo: 'uso', estado: 'ok', contexto: memoria.usoDoContexto(memoria.nova(), hist) });
31
+ } catch { /* sem medida, a status line fica sem o número */ }
32
+ if (act.interrompido()) {
33
+ saida({ evento: EVENTOS.TURNO_INTERROMPIDO, id, tipo: 'turno', estado: 'interrompido', duracao, resultado: r });
34
+ } else {
35
+ saida({ evento: EVENTOS.TURNO_TERMINOU, id, tipo: 'turno', estado: 'ok', duracao, resultado: r });
36
+ }
37
+ return r;
38
+ }
39
+
40
+ const interromper = (...a) => require('../session/runner/act').interromper(...a);
41
+ const mandarDuranteATarefa = (...a) => require('../session/runner/act').mandarDuranteATarefa(...a);
42
+
43
+ module.exports = { iniciarTurno, interromper, mandarDuranteATarefa };
@@ -0,0 +1,65 @@
1
+ // Voz do app (docs/estrutura.md 6.9): interface com provedor trocável.
2
+ // DECISÃO FINAL DO PROVEDOR PENDENTE com o dono. Aqui só o mínimo que não trava a escolha:
3
+ // - fala (TTS): provedor "kokoro" (voz local do Estúdio, grátis, gera arquivo de áudio; este módulo NUNCA toca som);
4
+ // - escuta (STT): fica no renderer (Web Speech API do navegador); o motor só informa que é do navegador.
5
+ // Provedor = { nome, disponivel() -> {ok, motivo?}, falar(texto, arquivo) -> Promise<{ok, error?}> }.
6
+ 'use strict';
7
+
8
+ const fs = require('fs');
9
+ const os = require('os');
10
+ const path = require('path');
11
+
12
+ const TETO_TEXTO = 600;
13
+
14
+ function provedorKokoro({ uv, voz, executar } = {}) {
15
+ const K = require('../plugin/estudio/kokoro');
16
+ const achar = () => uv || (() => { try { return require('child_process').execFileSync('which', ['uv'], { stdio: ['ignore', 'pipe', 'ignore'] }).toString().trim() || null; } catch { return null; } })();
17
+ return {
18
+ nome: 'kokoro',
19
+ disponivel() {
20
+ if (!achar()) return { ok: false, motivo: 'falta o uv (voz local)' };
21
+ if (!K.acharEspeak()) return { ok: false, motivo: 'falta o espeak-ng (brew install espeak-ng)' };
22
+ return { ok: true };
23
+ },
24
+ async falar(texto, arquivo) {
25
+ const r = await K.narrar([{ texto }], arquivo, { voz, uv: achar(), ...(executar ? { executar } : {}) });
26
+ return r && r.ok ? { ok: true } : { ok: false, error: (r && r.error) || 'a voz não respondeu' };
27
+ },
28
+ };
29
+ }
30
+
31
+ const PROVEDORES = { kokoro: (o) => provedorKokoro(o) };
32
+
33
+ function criarVoz({ provedor, pasta, escuta = 'navegador' } = {}) {
34
+ const prov = provedor || PROVEDORES.kokoro();
35
+ const saida = pasta || path.join(os.tmpdir(), 'primocode-voz');
36
+ let ativa = false;
37
+ let n = 0;
38
+
39
+ const estado = () => {
40
+ const d = prov.disponivel();
41
+ return {
42
+ ativa, provedor: prov.nome, fala: { disponivel: !!d.ok, motivo: d.ok ? undefined : d.motivo },
43
+ escuta, decisaoPendente: true,
44
+ };
45
+ };
46
+
47
+ return {
48
+ estado,
49
+ /** Liga o modo voz; com `texto`, gera o arquivo de áudio da fala (não toca). */
50
+ async iniciar({ texto } = {}) {
51
+ ativa = true;
52
+ const e = estado();
53
+ const t = typeof texto === 'string' ? texto.trim().slice(0, TETO_TEXTO) : '';
54
+ if (!t) return e;
55
+ if (!e.fala.disponivel) return { ...e, fala: { ...e.fala, gerou: false } };
56
+ fs.mkdirSync(saida, { recursive: true });
57
+ const arquivo = path.join(saida, `fala-${Date.now()}-${++n}.mp3`);
58
+ const r = await prov.falar(t, arquivo);
59
+ return r.ok ? { ...e, arquivo } : { ...e, fala: { ...e.fala, gerou: false, motivo: r.error } };
60
+ },
61
+ parar() { ativa = false; return estado(); },
62
+ };
63
+ }
64
+
65
+ module.exports = { criarVoz, provedorKokoro, PROVEDORES, TETO_TEXTO };
package/lib/piloto.js ADDED
@@ -0,0 +1,39 @@
1
+ // Piloto automático (docs/estrutura.md, seção 9): os agentes ligados procuram o que fazer e fazem.
2
+ // Este módulo só decide QUEM roda e QUANDO parar; quem executa é injetado (o app-server passa o motor).
3
+ // Node puro. Um ciclo roda os agentes ligados um de cada vez e para no freio da cota.
4
+ 'use strict';
5
+
6
+ /* O que o agente recebe quando ninguém pediu nada: procurar trabalho no seu terreno. */
7
+ function pedidoDoPiloto(agente, { grupo = false } = {}) {
8
+ return `[piloto automático] Você é @${agente.nome}. Procure UMA coisa útil para fazer no seu terreno `
9
+ + `(${agente.onde && agente.onde !== 'fora' ? `projeto ${agente.onde}` : 'fora de projetos'}) e faça. `
10
+ + 'Tudo que escrever arquivo vai para a pessoa aprovar antes de valer. Se não houver nada a fazer, diga isso em uma frase.'
11
+ + (grupo ? ' Se precisar de outro agente ligado, chame com spawn_agent (profundidade 1).' : '');
12
+ }
13
+
14
+ /* Freio padrão: a cota do plano (lib/limites.js). Sem rede vale o modo local generoso. */
15
+ async function freioDaCota() {
16
+ try {
17
+ const c = await require('./config/limites').situacao();
18
+ if (c && (c.ok === false || c.liberado === false || c.restam === 0)) return { parar: true, motivo: 'a cota do plano acabou' };
19
+ } catch { /* sem leitura, não barra */ }
20
+ return { parar: false };
21
+ }
22
+
23
+ /* `ciclo({ agentes, executar, freio, ocupado, grupo })` → { executados:[{agente,ok,resumo}], parou? }.
24
+ `executar(agente, pedido)` devolve o resumo (texto) ou lança. */
25
+ async function ciclo({ agentes = [], executar, freio = freioDaCota, ocupado = () => false, grupo = false } = {}) {
26
+ const ligados = agentes.filter((a) => a.ligado);
27
+ const r = { executados: [], parou: null };
28
+ if (!ligados.length) { r.parou = 'nenhum agente ligado'; return r; }
29
+ for (const a of ligados) {
30
+ if (ocupado()) { r.parou = 'a pessoa está trabalhando'; break; }
31
+ const f = await freio();
32
+ if (f && f.parar) { r.parou = f.motivo || 'freio'; break; }
33
+ try { r.executados.push({ agente: a.nome, ok: true, resumo: String(await executar(a, pedidoDoPiloto(a, { grupo })) || '').slice(0, 400) }); }
34
+ catch (e) { r.executados.push({ agente: a.nome, ok: false, resumo: (e && e.message) || 'falhou' }); }
35
+ }
36
+ return r;
37
+ }
38
+
39
+ module.exports = { ciclo, pedidoDoPiloto, freioDaCota };
@@ -0,0 +1,113 @@
1
+ // estudio_criar: a porta de entrada simples do Estúdio (docs/estrutura.md, seção 8).
2
+ // Quatro parâmetros fixos (tipo, briefing, duracao, formato); por dentro escolhe a receita,
3
+ // abre o projeto e chama as ferramentas que já existem (estudio_plano, estudio_motion,
4
+ // estudio_render). Não reescreve nada do Estúdio. As dependências entram por parâmetro,
5
+ // para o teste rodar sem rede e sem render.
6
+ 'use strict';
7
+
8
+ const path = require('path');
9
+
10
+ /* tipo → receita. `via`: 'roteiro' (plano pelo roteirista + render) ou 'motion' (a máquina de motion). */
11
+ const TIPOS = Object.freeze({
12
+ slides: { rotulo: 'Slides', via: 'roteiro', formato: '16:9', projeto: 'slides' },
13
+ video: { rotulo: 'Vídeo curto', via: 'roteiro', formato: '9:16', projeto: 'video', duracao: [15, 60, 30] },
14
+ motion: { rotulo: 'Motion', via: 'motion', formato: '9:16', duracao: [6, 60, 15] },
15
+ anuncio: { rotulo: 'Anúncio', via: 'motion', formato: '9:16', duracao: [10, 30, 15], prefixo: 'Anúncio para chamar pra comprar: ' },
16
+ abertura: { rotulo: 'Abertura de canal', via: 'motion', formato: '16:9', duracao: [5, 5, 5], prefixo: 'Abertura de canal com a marca: ' },
17
+ });
18
+ const FORMATOS = ['9:16', '16:9', '1:1'];
19
+
20
+ const limitar = (n, [min, max, padrao]) => { n = Number(n); return Number.isFinite(n) && n > 0 ? Math.min(max, Math.max(min, Math.round(n))) : padrao; };
21
+
22
+ /** Confere os parâmetros. Devolve { ok, tipo, briefing, duracao, formato } ou { ok:false, error }. */
23
+ function normalizar(args = {}) {
24
+ const tipo = String(args.tipo || '').toLowerCase().trim();
25
+ const receita = TIPOS[tipo];
26
+ if (!receita) return { ok: false, error: `tipo "${args.tipo || ''}" não existe. Use um de: ${Object.keys(TIPOS).join(', ')}.` };
27
+ const briefing = String(args.briefing || '').replace(/\s+/g, ' ').trim();
28
+ if (briefing.length < 3) return { ok: false, error: 'falta o briefing: diga em uma frase o que criar.' };
29
+ const formato = FORMATOS.includes(String(args.formato)) ? String(args.formato) : receita.formato;
30
+ const duracao = receita.duracao ? limitar(args.duracao, receita.duracao) : undefined;
31
+ return { ok: true, tipo, receita, briefing: briefing.slice(0, 600), formato, duracao };
32
+ }
33
+
34
+ /** Os arquivos entregues, com o tipo que a tela sabe abrir. */
35
+ function arquivosDe(r) {
36
+ const lista = [];
37
+ const por = (caminho, extra) => { if (caminho) lista.push({ nome: path.basename(caminho), caminho, tipo: tipoDoArquivo(caminho), ...extra }); };
38
+ por(r.arquivo || r.video);
39
+ if (r.entrega && r.entrega.pasta && Array.isArray(r.entrega.arquivos)) {
40
+ for (const n of r.entrega.arquivos) { const c = path.join(r.entrega.pasta, n); if (!lista.some((x) => x.caminho === c)) por(c); }
41
+ }
42
+ return lista;
43
+ }
44
+ function tipoDoArquivo(c) {
45
+ const e = path.extname(c).toLowerCase();
46
+ if (e === '.mp4' || e === '.webm' || e === '.mov') return 'video';
47
+ if (e === '.pdf') return 'pdf';
48
+ if (['.png', '.jpg', '.jpeg', '.webp', '.gif'].includes(e)) return 'imagem';
49
+ if (e === '.html') return 'pagina';
50
+ return 'arquivo';
51
+ }
52
+
53
+ /**
54
+ * @param {object} args { tipo, briefing, duracao?, formato? }
55
+ * @param {object} deps { projeto, plano(args), motion(args), render(args), progresso(pct, etapa) }
56
+ */
57
+ async function criar(args, deps) {
58
+ const n = normalizar(args);
59
+ if (!n.ok) return n;
60
+ const { tipo, receita, briefing, formato, duracao } = n;
61
+ const progresso = deps.progresso || (() => {});
62
+ const falha = (error, extra) => ({ ok: false, error, ...extra });
63
+
64
+ progresso(5, 'Abrindo o projeto');
65
+ const pedido = `${receita.prefixo || ''}${briefing}`;
66
+ const titulo = briefing.slice(0, 60);
67
+ const proj = deps.projeto.criar({ titulo, pedido: `${pedido} (${receita.rotulo}, ${formato}${duracao ? `, ${duracao} s` : ''})`, formato: receita.projeto || 'video' });
68
+ if (!proj || proj.ok === false) return falha((proj && proj.error) || 'não consegui abrir o projeto.');
69
+ const id = proj.id;
70
+ const base = { tipo, id, pasta: proj.pasta, formato, duracao, titulo };
71
+
72
+ if (receita.via === 'motion') {
73
+ progresso(15, 'Montando as cenas de motion');
74
+ const m = await deps.motion({ id, assunto: pedido, formato, duracao });
75
+ if (!m || m.ok === false) return falha((m && m.error) || 'o motion não saiu.', base);
76
+ progresso(100, 'Pronto');
77
+ return { ok: true, ...base, duracao: m.duracao || duracao, arquivo: m.video, arquivos: arquivosDe(m), resumo: `${receita.rotulo} de ${m.duracao || duracao} s.` };
78
+ }
79
+
80
+ progresso(15, 'Escrevendo o roteiro');
81
+ const pl = await deps.plano({ id, pedido: `${pedido}. Formato ${formato}.`, segundos: duracao });
82
+ if (!pl || pl.ok === false) return falha((pl && pl.error) || 'não consegui escrever o plano.', base);
83
+ progresso(40, tipo === 'slides' ? 'Desenhando os slides' : 'Montando o vídeo');
84
+ const r = await deps.render({ id });
85
+ if (!r || r.ok === false) return falha((r && r.error) || 'o render não saiu.', { ...base, problemas: r && r.problemas });
86
+ progresso(100, 'Pronto');
87
+ return {
88
+ ok: true, ...base, arquivo: r.arquivo, arquivos: arquivosDe(r), paginas: r.paginas,
89
+ duracao: r.segundos || duracao,
90
+ resumo: r.paginas ? `${r.paginas} slides em PDF.` : `${receita.rotulo} de ${r.segundos || duracao} s.`,
91
+ };
92
+ }
93
+
94
+ module.exports = { criar, normalizar, TIPOS, FORMATOS, tipoDoArquivo, arquivosDe };
95
+
96
+ /* Os "quadrados" do Studio no app viram `tipo` no turn/start. O motor traduz o tipo em instrução:
97
+ o modelo escolhe o caminho (a IA decide); aqui só o cardápio do tipo. Carrossel não tem receita no Estúdio
98
+ (conferido na fase 7), por isso não há quadrado para ele. */
99
+ const QUADRADOS = Object.freeze({
100
+ 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
+ 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 briefing do pedido.',
103
+ anuncio: 'Tipo escolhido na tela: Anúncio de 15 s. Use estudio_criar com tipo "anuncio" e o briefing do pedido.',
104
+ abertura: 'Tipo escolhido na tela: Abertura de canal de 5 s. Use estudio_criar com tipo "abertura" e o briefing do pedido.',
105
+ 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
+ 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
+ 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.',
108
+ app: '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.',
109
+ });
110
+ const instrucaoDoTipo = (t) => QUADRADOS[String(t || '').toLowerCase()] || (t ? `Tipo de trabalho escolhido na tela: ${String(t).slice(0, 40)}` : '');
111
+
112
+ module.exports.QUADRADOS = QUADRADOS;
113
+ module.exports.instrucaoDoTipo = instrucaoDoTipo;
@@ -0,0 +1,60 @@
1
+ // Histórico do Studio no app (studio/history): o que o Estúdio já gerou, com miniatura.
2
+ // Lê as pastas de projeto (lib/estudio/projeto.js) sem mudar nada. Node puro.
3
+ 'use strict';
4
+
5
+ const fs = require('fs');
6
+ const path = require('path');
7
+ const { tipoDoArquivo } = require('./criar');
8
+ const Miniatura = require('./miniatura');
9
+
10
+ const PULAR = new Set(['node_modules', 'remotion', 'midia', 'entrada', 'versoes', '.git']);
11
+ const SAIDAS = new Set(['video', 'pdf', 'imagem']);
12
+
13
+ /* Procura as saídas em até 2 níveis; imagem solta (quadro de conferência) serve de miniatura, não de entrega. */
14
+ function varrer(pasta, nivel = 0, achados = []) {
15
+ let itens = [];
16
+ try { itens = fs.readdirSync(pasta, { withFileTypes: true }); } catch { return achados; }
17
+ for (const d of itens) {
18
+ const c = path.join(pasta, d.name);
19
+ if (d.isDirectory()) { if (nivel < 2 && !PULAR.has(d.name) && !d.name.startsWith('.')) varrer(c, nivel + 1, achados); continue; }
20
+ const tipo = tipoDoArquivo(c);
21
+ if (!SAIDAS.has(tipo)) continue;
22
+ let st; try { st = fs.statSync(c); } catch { continue; }
23
+ achados.push({ nome: d.name, caminho: c, tipo, bytes: st.size, quando: st.mtimeMs, dentro: path.relative(pasta, c) });
24
+ }
25
+ return achados;
26
+ }
27
+
28
+ /** { itens, total }. `busca` filtra por título e pedido (sem acento); `limite` padrão 60. */
29
+ function historico({ busca = '', limite = 60, projeto, miniaturaDeVideo } = {}) {
30
+ // Vídeo na grade vira um JPG (cache ao lado do vídeo). Até 4 saem na hora; o resto sai em segundo plano e aparece na próxima abertura.
31
+ const quadro = miniaturaDeVideo || Miniatura.gerar;
32
+ let gerados = 0;
33
+ const P = projeto || require('./projeto');
34
+ const sem = (s) => String(s || '').normalize('NFD').replace(/[̀-ͯ]/g, '').toLowerCase();
35
+ const q = sem(busca).trim();
36
+ const itens = [];
37
+ for (const p of P.listar({ limite: 400 })) {
38
+ if (q && !sem(`${p.titulo} ${p.pedido}`).includes(q)) continue;
39
+ const todos = varrer(p.pasta).sort((a, b) => b.quando - a.quando);
40
+ const entregas = todos.filter((a) => a.tipo !== 'imagem' || /^saida\b/.test(a.dentro) || !todos.some((x) => x.tipo !== 'imagem'));
41
+ const principal = entregas.find((a) => a.tipo === 'video') || entregas.find((a) => a.tipo === 'pdf') || entregas[0] || null;
42
+ const imagem = todos.find((a) => a.tipo === 'imagem');
43
+ let miniatura = null;
44
+ if (principal && principal.tipo === 'imagem') miniatura = { tipo: 'imagem', caminho: principal.caminho };
45
+ else if (principal && principal.tipo === 'video') {
46
+ let jpg = Miniatura.emCache(principal.caminho);
47
+ if (!jpg && gerados < 4) { gerados++; jpg = quadro(principal.caminho); }
48
+ else if (!jpg && !miniaturaDeVideo) Miniatura.gerarDepois(principal.caminho);
49
+ miniatura = jpg ? { tipo: 'imagem', caminho: jpg, deVideo: true } : (imagem ? { tipo: 'imagem', caminho: imagem.caminho } : { tipo: 'video-sem-quadro', caminho: principal.caminho });
50
+ } else if (imagem) miniatura = { tipo: 'imagem', caminho: imagem.caminho };
51
+ itens.push({
52
+ id: p.id, titulo: p.titulo, formato: p.formato || 'video', criado: p.criado, mudado: p.mudado || p.criado,
53
+ pasta: p.pasta, arquivos: entregas.map(({ dentro, ...a }) => a), principal: principal ? principal.caminho : null, miniatura,
54
+ pronto: entregas.length > 0,
55
+ });
56
+ }
57
+ return { itens: itens.slice(0, Math.max(1, Number(limite) || 60)), total: itens.length };
58
+ }
59
+
60
+ module.exports = { historico, varrer };
@@ -748,6 +748,19 @@ async function renderMotion(args, p, receita) {
748
748
  return { ...r, ...(gostoResumo ? { gosto: gostoResumo } : {}), nota: 'Abra os quadros (png em ae/) para conferir. Se algo ficou torto, mude o JSON e rode de novo.' };
749
749
  }
750
750
 
751
+ // ── estudio_criar ────────────────────────────────────────────────────────
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. */
754
+ async function ferramentaCriar(args, ctx = {}) {
755
+ return require('./criar.js').criar(args, {
756
+ projeto,
757
+ plano: (a) => ferramentaPlano({ ...a, __pasta: ctx.projectDir }),
758
+ motion: (a) => ferramentaMotion(a),
759
+ render: (a) => ferramentaRender({ ...a, __pasta: ctx.projectDir }, ctx),
760
+ progresso: ctx.progresso,
761
+ });
762
+ }
763
+
751
764
  // ── estudio_editar ───────────────────────────────────────────────────────
752
765
 
753
766
  /** O editor por comando: reabre o plano da máquina de motion, aplica as mudanças e guarda a versão (editor.js). */
@@ -2706,7 +2719,7 @@ async function ferramentaSite(args) {
2706
2719
 
2707
2720
  module.exports = { proporcaoDoPedido,
2708
2721
  vazamentoNoComeco, variarIcones, objetosDoRoteiro,
2709
- ferramentaProjeto, ferramentaPlano, ferramentaMidia, ferramentaAE, ferramentaMotion, ferramentaEditar, ferramentaNarrar,
2722
+ ferramentaProjeto, ferramentaPlano, ferramentaMidia, ferramentaAE, ferramentaMotion, ferramentaEditar, ferramentaNarrar, ferramentaCriar,
2710
2723
  ferramentaVideo, ferramentaRender, ferramentaSite,
2711
2724
  projeto, plano, remotion, assets, video, identidade, slides, python, rodarPython, alinharPartes, prepararRoteiro, roteiroValido, juntarVozes,
2712
2725
  };
@@ -0,0 +1,56 @@
1
+ // Miniatura de vídeo para a grade do Studio: um quadro em JPG, gerado pelo ffmpeg e guardado em cache ao lado
2
+ // do vídeo (<pasta do vídeo>/.miniaturas/<nome>.jpg). A tela mostra <img>, nunca <video> na grade: vídeo em formato
3
+ // que o Chromium não decodifica loga "Unsupported pixel format" só de abrir. Node puro, só `ffmpeg` do sistema.
4
+ 'use strict';
5
+
6
+ const fs = require('fs');
7
+ const path = require('path');
8
+ const { spawn, spawnSync } = require('child_process');
9
+
10
+ const PASTA = '.miniaturas';
11
+ const LARGURA = 480;
12
+ const emAndamento = new Set();
13
+
14
+ const destinoDe = (video) => path.join(path.dirname(video), PASTA, `${path.basename(video)}.jpg`);
15
+ const argsDe = (video, saida) => ['-v', 'error', '-ss', '0.5', '-i', video, '-frames:v', '1', '-vf', `scale=${LARGURA}:-2`, '-q:v', '4', '-y', saida];
16
+
17
+ /* O cache vale enquanto o JPG existe, tem conteúdo e não é mais velho que o vídeo. */
18
+ function emCache(video) {
19
+ const jpg = destinoDe(video);
20
+ try {
21
+ const j = fs.statSync(jpg); const v = fs.statSync(video);
22
+ return j.size > 0 && j.mtimeMs >= v.mtimeMs ? jpg : null;
23
+ } catch { return null; }
24
+ }
25
+
26
+ function ffmpegExiste(ffmpeg = 'ffmpeg') {
27
+ try { return spawnSync(ffmpeg, ['-version'], { stdio: 'ignore', timeout: 4000 }).status === 0; } catch { return false; }
28
+ }
29
+
30
+ /* Gera agora (bloqueia até uns segundos). Devolve o caminho do JPG, ou null se não deu. */
31
+ function gerar(video, { ffmpeg = 'ffmpeg', espera = 8000 } = {}) {
32
+ const hit = emCache(video);
33
+ if (hit) return hit;
34
+ const jpg = destinoDe(video);
35
+ try { fs.mkdirSync(path.dirname(jpg), { recursive: true }); } catch { return null; }
36
+ try { spawnSync(ffmpeg, argsDe(video, jpg), { stdio: 'ignore', timeout: espera }); } catch { /* sem ffmpeg */ }
37
+ const ok = emCache(video);
38
+ if (!ok) { try { fs.rmSync(jpg, { force: true }); } catch { /* nada */ } }
39
+ return ok;
40
+ }
41
+
42
+ /* Gera sem bloquear (a próxima abertura do histórico já acha o JPG). Um por vídeo ao mesmo tempo. */
43
+ function gerarDepois(video, { ffmpeg = 'ffmpeg' } = {}) {
44
+ if (emAndamento.has(video) || emCache(video)) return;
45
+ const jpg = destinoDe(video);
46
+ try { fs.mkdirSync(path.dirname(jpg), { recursive: true }); } catch { return; }
47
+ emAndamento.add(video);
48
+ let p;
49
+ try { p = spawn(ffmpeg, argsDe(video, jpg), { stdio: 'ignore' }); } catch { emAndamento.delete(video); return; }
50
+ const fim = () => { emAndamento.delete(video); if (!emCache(video)) { try { fs.rmSync(jpg, { force: true }); } catch { /* nada */ } } };
51
+ p.on('error', fim); p.on('close', fim);
52
+ const t = setTimeout(() => { try { p.kill('SIGKILL'); } catch { /* já saiu */ } }, 20000);
53
+ if (t.unref) t.unref();
54
+ }
55
+
56
+ module.exports = { gerar, gerarDepois, emCache, destinoDe, ffmpegExiste, PASTA };
@@ -301,7 +301,7 @@ const OPS = {
301
301
  const ver = plano.passos.map((p) => ({ op: p.op, motivo: p.motivo, args: p.args }));
302
302
  if (a.dryRun) return { ok: true, plano: ver, legenda: plano.legenda, segundos: 'da gravação' };
303
303
 
304
- const { OPS: TODAS } = require('./ops.js');
304
+ const TODAS = require('./catalogo.js').catalogo();
305
305
  const feitos = [];
306
306
  const roda = async (op, args, motivo) => {
307
307
  const res = await TODAS[op].run(ctx, args);
@@ -0,0 +1,20 @@
1
+ /**
2
+ * catalogo.js — o catálogo completo das operações. ops.js registra aqui ao
3
+ * carregar; auto.js e pedido.js leem daqui para encadear operações sem
4
+ * importar ops.js (que os importa: isso fechava um ciclo).
5
+ */
6
+
7
+ 'use strict';
8
+
9
+ let todas = null;
10
+
11
+ function registrar(ops) {
12
+ todas = ops;
13
+ }
14
+
15
+ function catalogo() {
16
+ if (!todas) throw new Error('catálogo de operações vazio: carregue lib/video/ops.js antes');
17
+ return todas;
18
+ }
19
+
20
+ module.exports = { registrar, catalogo };
@@ -0,0 +1,28 @@
1
+ /**
2
+ * mutar.js — o caminho de toda operação que MUDA o projeto: foto de antes,
3
+ * edição, validação, gravação, delta. Se qualquer passo falha, nada é gravado.
4
+ *
5
+ * Mora fora de ops.js para os módulos de operação (ops-fx, zoom) usarem sem
6
+ * importar o catálogo que os importa.
7
+ */
8
+
9
+ 'use strict';
10
+
11
+ const T = require('./timeline.js');
12
+ const { diff } = require('./delta.js');
13
+ const P = require('./projeto.js');
14
+
15
+ async function mutar(ctx, op, fn) {
16
+ const antes = T.clone(ctx.tl);
17
+ const extra = (await fn(ctx.tl)) || {};
18
+ T.ordenar(ctx.tl);
19
+ const probs = T.problemas(ctx.tl);
20
+ if (probs.length) throw T.erro(`edição recusada (nada mudou): ${probs.slice(0, 4).join('; ')}`);
21
+ P.fotografar(ctx.p, antes, op);
22
+ P.salvar(ctx.p, ctx.tl);
23
+ const d = diff(antes, ctx.tl, extra);
24
+ P.anotar(ctx.p, op, { dur: d.dur, added: (d.added || []).length, removed: (d.removed || []).length });
25
+ return d;
26
+ }
27
+
28
+ module.exports = { mutar };