primocode 10.12.0 → 10.13.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 CHANGED
@@ -1,4 +1,4 @@
1
- # PrimoCode v10.12.0
1
+ # PrimoCode v10.13.0
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 v10.12.0
12
+ ▐▛███▜▌ Primo Code v10.13.0
13
13
  ▝▜█████▛▘ Bem-vindo de volta, Joel
14
14
  ▘▘ ▝▝ ~/primocode · /dir <pasta> muda
15
15
  ```
@@ -17,6 +17,14 @@ está no limite de pedidos, o próximo atende. Você não vê o erro, e não pag
17
17
  Personalize o mascote (`primo`, `fox`, `cat`) e o nome com que ele te chama —
18
18
  veja `/mascot` e `/name`.
19
19
 
20
+ ## O que mudou na 10.13
21
+
22
+ - **Motion pelas referências:** o título enche a tela (rótulo nunca abaixo de 2% da altura), a cena dura de 1 a 2 s, o texto entra com ataque rápido e freio forte, e a frase sai inteira. As saídas que espalhavam letras saíram. O círculo de caneta na palavra-chave ganha o ✓. Sem quadro morto entre as cenas. A conta está em `docs/planos/2026-10-15-motion-pelas-referencias.md`.
23
+ - **Revisão do texto:** antes do render, o PrimoCode avisa texto pequeno demais para o celular e contraste abaixo de 3:1.
24
+ - **Camadas sem legenda dobrada:** a cena escrita pela IA não repete a fala por cima da legenda.
25
+ - **Voz do VoiceStudio:** com o app aberto no Mac, ele narra primeiro. Fechado, a narração segue para o Gemini (`PRIMOCODE_SEM_VOICESTUDIO=1` desliga).
26
+ - **Edição de vídeo:** num vídeo sem pedido falado, a edição não para mais na legenda. O agente corta, dá zoom e cor.
27
+
20
28
  ## O que mudou na 10.12
21
29
 
22
30
  - **Vídeo em camadas:** você grava só a fala e o PrimoCode monta o resto. O rosto vira cartão, bolinha no canto, some e volta, com mola, e o motion ocupa o espaço que sobra. A legenda vai palavra por palavra, com a palavra forte em serif itálico. O áudio nunca pula. Use `estudio_video acao:"camadas"`: sem batidas, ele propõe pela transcrição.
@@ -4,7 +4,7 @@
4
4
  "origem": "o vídeo que o Joel aprovou em 08/10/2026 (teste-e2e-sono, o gerativo do PR #113, 30,3 s), medido",
5
5
  "descricao": "Batida curta e seca, pouco texto por linha, numa identidade só (2 chãos, 1 acento, 2 fontes): o fundo troca na virada de seção, a primeira por corte seco. A palavra gigante entra em ~1 de cada 3 batidas. O som é desenhado da semente e fica mais raro que no fluido.",
6
6
  "faixas": {
7
- "batida_mediana_s": { "min": 2, "max": 4.5, "medido": 3.18, "diz": "cada batida dura de 1,7 a 4,5 s, o normal é ~3 s" },
7
+ "batida_mediana_s": { "min": 1, "max": 3, "medido": 3.18, "revisto": "15/10/2026: as referências do Joel (\"motion devem sair assim\") cortam a cada 1 a 2 s; a faixa saiu de 2–4,5 para 1–3 s.", "diz": "cada batida dura de 1 a 3 s, o normal é ~2 s" },
8
8
  "palavras_por_frase_max": { "max": 5, "medido": 3, "diz": "no máximo 3 palavras por linha" },
9
9
  "palavras_por_frase_mediana": { "max": 3, "medido": 2, "diz": "o normal é 1 a 2 palavras por linha" },
10
10
  "cortes_secos": { "min": 1, "medido": 5, "diz": "o fundo troca por corte seco (5 das 9 trocas)" },
@@ -299,6 +299,10 @@ function montar({ src, duracao, palavras = [], batidas, largura = 1080, altura =
299
299
  tipo: 'componente', src: b.cena,
300
300
  dados: {
301
301
  largura: area.w, altura: area.h, duracao: r3(dur - entra),
302
+ /* 15/10: a cena escrevia a fala, e a legenda do PrimoCode vinha
303
+ por cima ("provou / provou o bolo"). A fala é para SINCRONIA. */
304
+ legendaNoQuadro: true,
305
+ regra: 'a legenda da fala já está no quadro: não escreva a fala; use as palavras só para o tempo, e desenhe a ideia',
302
306
  fala: ws.map((p) => p.texto).join(' '),
303
307
  palavras: ws.map((p) => ({ texto: p.texto, t: r3(p.de - b.de - entra) })),
304
308
  destaque: (Array.isArray(b.destaque) ? b.destaque : b.destaque ? [b.destaque] : []),
@@ -110,7 +110,8 @@ function vozPara({ tom = 'neutro', idioma = 'pt-BR', genero } = {}) {
110
110
  /** Quantas cenas cabem, e de que tamanho, num vídeo desta duração. */
111
111
  function ritmoPara(segundos) {
112
112
  const s = Math.max(5, Number(segundos) || 30);
113
- const media = s <= 20 ? 2.6 : s <= 45 ? 3.4 : s <= 90 ? 4.2 : 5;
113
+ // (15/10) As referências cortam a cada 1 a 2 s; o PrimoCode pedia 2,6 a 5 s e saía lento.
114
+ const media = s <= 20 ? 1.8 : s <= 45 ? 2.2 : s <= 90 ? 2.6 : 3;
114
115
  return { cenas: Math.max(3, Math.round(s / media)), duracaoMedia: media,
115
116
  palavrasPorCena: Math.round(media * 2.6) }; // ~155 palavras/minuto de narração
116
117
  }
@@ -16,7 +16,8 @@ export default function TerminalDigitando({ el, t }) {
16
16
  const c = d.cores || {};
17
17
  const f = d.fontes || {};
18
18
  const u = t - (d.atraso || 0);
19
- const linhas = (Array.isArray(d.linhas) && d.linhas.length ? d.linhas : [d.titulo || d.fala || 'faz o vídeo pra mim']).map(String).slice(0, 6);
19
+ // O prompt "›" é da janela: a linha que já vem com "> " ou "$ " saía com ele dobrado ("› > faz um site", 15/10).
20
+ const linhas = (Array.isArray(d.linhas) && d.linhas.length ? d.linhas : [d.titulo || d.fala || 'faz o vídeo pra mim']).map((x) => String(x).replace(/^\s*[>›$#]\s+/, '')).slice(0, 6);
20
21
  const vel = d.velocidade || 26;
21
22
  const k = mola(u);
22
23
  const largura = Math.min(W * 0.96, H * 1.5);
@@ -510,7 +510,9 @@ function tituloDaFala(fala) {
510
510
 
511
511
  function conteudoDe(p, marca = {}) {
512
512
  const titulo = p.titulo || p.texto || tituloDaFala(p.fala);
513
- const itens = itensDe(p);
513
+ // O item que repete o título ("Sem troco" / "sem troco", 15/10) sai: o quadro dizia a mesma coisa duas vezes.
514
+ const igual = (a, b) => tokens(a).join(' ') === tokens(b).join(' ');
515
+ const itens = itensDe(p).filter((it) => !igual(it, titulo));
514
516
  if (p.tipo === 'numero' && numeroDe(p.valor)) return { tipo: 'numero', valor: String(p.valor), rotulo: p.rotulo || titulo, sub: p.sub || '' };
515
517
  if (itens.length >= 2) return { tipo: 'lista', titulo: p.tipo === 'cores' ? titulo : titulo, itens: itens.slice(0, 5), numerada: p.tipo === 'passos' };
516
518
  if (p.tipo === 'contraste' && (p.nao || p.sim)) return { tipo: 'contraste', nao: p.nao || '', sim: p.sim || titulo };
@@ -528,7 +530,7 @@ const textoDoConteudo = (c) => [c.titulo, c.sub, c.valor, c.rotulo, c.nao, c.sim
528
530
  const COMPOSICOES = ['gigante', 'pilha', 'divisao', 'contador', 'cascata', 'parede', 'citacao', 'marca', 'palavras', 'produto'];
529
531
  const COMPORTAMENTOS = ['carimbo', 'mascara', 'onda', 'decodifica', 'peso', 'largura', 'tracking', 'desfoque', 'flap', 'pontos', 'contorno', 'monta'];
530
532
  const SAIDAS = ['pontos', 'evapora', 'atravessa', 'desliza', 'comprime', 'explode', 'chicote', 'inunda', 'corte'];
531
- const ACENTOS = ['ponto', 'sublinha', 'anel', 'risco', 'barra', 'bala', 'marca', 'moldura', 'seta'];
533
+ const ACENTOS = ['ponto', 'sublinha', 'anel', 'risco', 'barra', 'bala', 'marca', 'moldura', 'seta', 'visto'];
532
534
 
533
535
  function pesosDe(c, { curto, palavras, primeira, ultima, tem3d, pergunta, exclama, numeroNoTitulo }) {
534
536
  switch (c.tipo) {
@@ -1055,13 +1057,18 @@ function partituraDe({ roteiro, largura = 1920, altura = 1080, tempos = [], icon
1055
1057
  const M = Math.round(W * (deitado ? 0.085 : 0.095));
1056
1058
  const larguraUtil = W - 2 * M;
1057
1059
  const base = Math.min(W, H) / 1080;
1058
- const tamRotulo = Math.round((deitado ? 24 : 30) * base);
1060
+ /* (15/10, as referências) O rótulo de 24–30 px (1,6% da altura no 9:16)
1061
+ não se lia no celular ("NO COADOR", "pra você, de graça"). O rótulo
1062
+ fica em ~2% da altura, e a segunda voz (o sub, a frase de apoio) em
1063
+ 3–4%, como a anotação das referências. */
1064
+ const tamRotulo = Math.round(Math.max((deitado ? 24 : 30) * base, H * (deitado ? 0.026 : 0.02)));
1059
1065
  const tem3d = !!roteiro.modelo3d;
1060
1066
  const ws = temposDasPartes(partes, tempos);
1061
1067
 
1062
1068
  // ── os limites de cada parte na linha do tempo ────────────────────────
1063
1069
  const inicio = [];
1064
- partes.forEach((_, i) => inicio.push(i === 0 ? 0 : r3(Math.max(inicio[i - 1] + 1.4, ws[i].de - 0.12))));
1070
+ // 0,9 s (eram 1,4): nas referências a cena dura 1 a 2 s; com fala rápida a parte segue a voz.
1071
+ partes.forEach((_, i) => inicio.push(i === 0 ? 0 : r3(Math.max(inicio[i - 1] + 0.9, ws[i].de - 0.12))));
1065
1072
  const ultimaFala = ws[ws.length - 1];
1066
1073
  const duracao = r1(Math.max(inicio[inicio.length - 1] + 2.0, (ultimaFala.ate || 0) + 1.15));
1067
1074
  const fimParte = partes.map((_, i) => (i < partes.length - 1 ? inicio[i + 1] : duracao));
@@ -1544,14 +1551,18 @@ function partituraDe({ roteiro, largura = 1920, altura = 1080, tempos = [], icon
1544
1551
  resto); no fim, cada gesto vira o seu par limpo. */
1545
1552
  /* O substituto é um limpo que não aparece nas 3 batidas de cada lado: a
1546
1553
  passagem e o gesto da tese não voltam antes de 3 batidas. */
1547
- const TORTOS = ['decodifica', 'flap', 'onda', 'monta'];
1548
- const GESTOS_LIMPOS = ['mascara', 'desfoque', 'tracking', 'pontos', 'contorno', 'carimbo'];
1549
- const SAIDAS_LIMPAS = ['evapora', 'desliza', 'comprime', 'pontos', 'atravessa'];
1554
+ /* (15/10, as referências) Os pontos, o evapora e o comprime também
1555
+ espalhavam as letras ("dad|o", "· p o n d e", parecia glitch). Nas
1556
+ referências a frase sai inteira: desliza, atravessa ou corta seco. */
1557
+ const TORTOS = ['decodifica', 'flap', 'onda', 'monta', 'pontos'];
1558
+ const GESTOS_LIMPOS = ['mascara', 'desfoque', 'tracking', 'contorno', 'carimbo'];
1559
+ const SAIDAS_LIMPAS = ['desliza', 'atravessa', 'corte'];
1560
+ const SAIDAS_SUJAS = ['explode', 'chicote', 'pontos', 'evapora', 'comprime'];
1550
1561
  const vizinhas = (i) => batidas.filter((_, j) => j !== i && Math.abs(j - i) <= 3);
1551
1562
  const principal = (b) => (b.linhas || []).find((l) => (l.papel === 'tese' || l.papel === 'numero') && !l.marca);
1552
1563
  const comGesto = batidas.filter(principal);
1553
1564
  batidas.forEach((b, i) => {
1554
- if (b.saida === 'explode' || b.saida === 'chicote') {
1565
+ if (SAIDAS_SUJAS.includes(b.saida)) {
1555
1566
  const perto = vizinhas(i).map((x) => x.saida);
1556
1567
  b.saida = SAIDAS_LIMPAS.find((x) => !perto.includes(x)) || 'corte';
1557
1568
  if (batidas[i + 1]) batidas[i + 1].entrada = b.saida;
@@ -1626,6 +1637,11 @@ function molduraDe(roteiro, E, { W, H, deitado, marca, secoes }) {
1626
1637
  const pesoDe = (fonte) => (VARIAVEIS[fonte] && VARIAVEIS[fonte].eixos.wght) || [400, 400];
1627
1638
 
1628
1639
  // ── as composições: de conteúdo + contexto, uma batida ─────────────────
1640
+ /** A segunda voz (sub, a frase de apoio): 3–4% da altura, como a anotação das referências. */
1641
+ function tamSegunda({ H, deitado, tamRotulo }) {
1642
+ return Math.round(Math.max(tamRotulo, H * (deitado ? 0.042 : 0.03)));
1643
+ }
1644
+
1629
1645
  function compor(comp, br, ctx) {
1630
1646
  const { s, W, H, M, deitado, base, larguraUtil, tamRotulo, fontes } = ctx;
1631
1647
  const c = br.conteudo;
@@ -1739,6 +1755,8 @@ function compor(comp, br, ctx) {
1739
1755
  else {
1740
1756
  const lt = comTese || teses[teses.length - 1];
1741
1757
  b.acento.push({ forma, alvo: { linha: lt.id, palavra: comTese ? lt.palavras.findIndex((p) => p.papel === 'tese') : null } });
1758
+ // O círculo de caneta na tese ganha o ✓ (15/10, as referências), sem gastar sorteio.
1759
+ if (forma === 'anel' && comTese) b.acento.push({ forma: 'visto', alvo: { linha: lt.id, palavra: lt.palavras.findIndex((p) => p.papel === 'tese') } });
1742
1760
  }
1743
1761
  }
1744
1762
  // O ThreeCanvas só aceita pixel inteiro; ele ocupa a altura toda, para o chão não cortar.
@@ -1759,7 +1777,13 @@ function compor(comp, br, ctx) {
1759
1777
  }
1760
1778
  for (const t of fit.linhas) { const l = linha(t, 'apoio', { tamanho: fit.tamanho, eixos: { wght: 560 } }); misturarPesos(l, { maiorTese: 1.14 }); }
1761
1779
  const lt = b.linhas.find((l) => l.palavras.some((p) => p.papel === 'tese'));
1762
- if (lt) { tese(lt); b.acento.push({ forma: s.chance(0.6) ? 'sublinha' : 'anel', alvo: { linha: lt.id, palavra: lt.palavras.findIndex((p) => p.papel === 'tese') } }); }
1780
+ if (lt) {
1781
+ tese(lt);
1782
+ const alvo = { linha: lt.id, palavra: lt.palavras.findIndex((p) => p.papel === 'tese') };
1783
+ const forma = s.chance(0.6) ? 'sublinha' : 'anel';
1784
+ b.acento.push({ forma, alvo });
1785
+ if (forma === 'anel') b.acento.push({ forma: 'visto', alvo: { ...alvo } });
1786
+ }
1763
1787
  else pontoFinal(b.linhas[b.linhas.length - 1]);
1764
1788
  break;
1765
1789
  }
@@ -1774,7 +1798,7 @@ function compor(comp, br, ctx) {
1774
1798
  const l = linha(c.valor, 'numero', { fonte, tamanho: tam, eixos });
1775
1799
  const tRot = Math.max(30 * base, Math.min(tam * 0.22, 64 * base));
1776
1800
  if (c.rotulo) linha(c.rotulo, 'apoio', { tamanho: tRot, eixos: { wght: 600 }, fonte: fontes.display });
1777
- if (c.sub) linha(c.sub, 'rotulo', { fonte: fontes.mono, tamanho: tamRotulo, tracking: 0.08, eixos: { wght: 400 } });
1801
+ if (c.sub) linha(c.sub, 'rotulo', { fonte: fontes.mono, tamanho: tamSegunda(ctx), tracking: 0.02, eixos: { wght: 400 } });
1778
1802
  b.contador = { linha: l.id, ...n, dur: 0.9 };
1779
1803
  b.acento.push({ forma: c.valor.length <= 4 ? 'anel' : 'sublinha', alvo: { linha: l.id } });
1780
1804
  break;
@@ -1844,7 +1868,7 @@ function compor(comp, br, ctx) {
1844
1868
  const tam = tamanhoQueCabe(nome, lg, (deitado ? 0.3 : 0.16) * H, { fonte, wght: 800 });
1845
1869
  b.bloco = { x: W / 2 + tam * 0.3, y: H * 0.5, alinha: 'centro', vert: 'meio', largura: lg, gap: 0.2 };
1846
1870
  linha(nome, 'tese', { fonte, tamanho: tam, eixos: { wght: Math.min(800, pesoDe(fonte)[1]) }, comportamento: 'contorno' }).marca = true;
1847
- if (c.sub || c.link) linha(c.sub || c.link, 'rotulo', { fonte: fontes.mono, tamanho: tamRotulo, tracking: 0.2, maiusc: true, eixos: { wght: 400 } });
1871
+ if (c.sub || c.link) linha(c.sub || c.link, 'rotulo', { fonte: fontes.mono, tamanho: tamSegunda(ctx), tracking: 0.06, eixos: { wght: 400 } });
1848
1872
  b.acento.push({ forma: 'ponto', alvo: { linha: b.linhas[0].id, antes: true } });
1849
1873
  break;
1850
1874
  }
@@ -1952,9 +1976,10 @@ function tempoDaBatida(b, palavras, ctx) {
1952
1976
  l.t = r3(Math.max(i === 0 ? b.de + 0.1 : t + REGRAS.stagger[0] * Q, falado != null ? falado : (i === 0 ? b.de + 0.12 : t + passo)));
1953
1977
  t = l.t;
1954
1978
  });
1955
- /* Quadro morto não: a primeira coisa da batida entra em até 0,35 s, mesmo
1956
- que a voz ainda não a tenha dito (o resto segue a voz). */
1957
- if (ordem[0] && ordem[0].t > b.de + 0.35) ordem[0].t = r3(b.de + 0.35);
1979
+ /* Quadro morto não: a primeira coisa da batida entra em até 0,12 s, mesmo
1980
+ que a voz ainda não a tenha dito (o resto segue a voz). Eram 0,35 s, e
1981
+ com a saída da anterior o teste de 15/10 tinha 23% de quadro vazio. */
1982
+ if (ordem[0] && ordem[0].t > b.de + 0.12) ordem[0].t = r3(b.de + 0.12);
1958
1983
  for (let i = 1; i < ordem.length; i++) if (ordem[i].t < ordem[i - 1].t + REGRAS.stagger[0] * Q) ordem[i].t = r3(ordem[i - 1].t + REGRAS.stagger[0] * Q);
1959
1984
  // Nada entra tarde demais para ser lido: de trás para frente, com o stagger mínimo.
1960
1985
  for (let i = ordem.length - 1; i >= 0; i--) {
@@ -1985,7 +2010,11 @@ function tempoDaBatida(b, palavras, ctx) {
1985
2010
  let tw = l.t;
1986
2011
  l.palavras.forEach((p, j) => {
1987
2012
  const alvo = qs[j] != null ? qs[j] : (j === 0 ? l.t : tw + 2 * Q);
1988
- p.t = r3(Math.min(Math.max(j === 0 ? l.t : tw + 2 * Q, alvo), Math.max(l.t, fim)));
2013
+ /* Quadro morto não, também na palavra (15/10): a voz pode dizer
2014
+ outra coisa antes ("Aponta a câmera, paga, e o pão…"); a primeira
2015
+ palavra da batida entra com a linha, as outras seguem a voz. */
2016
+ const abre = j === 0 && l === ordem[0];
2017
+ p.t = r3(abre ? l.t : Math.min(Math.max(j === 0 ? l.t : tw + 2 * Q, alvo), Math.max(l.t, fim)));
1989
2018
  tw = p.t;
1990
2019
  });
1991
2020
  }
@@ -65,6 +65,7 @@ const gerativo = require('./gerativo.js');
65
65
  const fluido = require('./fluido.js');
66
66
  const vox = require('./vox.js');
67
67
  const kokoro = require('./kokoro.js');
68
+ const voicestudio = require('./voicestudio.js');
68
69
  const lancamento = require('./lancamento.js');
69
70
  const glifos = require('./glifos.js');
70
71
  const personagem = require('./personagem.js');
@@ -887,7 +888,7 @@ async function ferramentaVideo(args, ctx = {}) {
887
888
  ok: true, proposta: camadas.propor(palavras, m.segundos), duracao: m.segundos,
888
889
  comoUsar: 'é uma SUGESTÃO pela transcrição. Decida você: mude as formas, junte ou separe batidas, '
889
890
  + 'escreva a cena de motion de cada ideia (componentes/x.jsx, recebe {el, t, fps}; el.dados traz largura/altura do espaço livre, '
890
- + 'palavras com t, destaque, cores e fontes) ou use um efeito de fábrica pelo nome (estudio_midia acao:"efeitos"), '
891
+ + 'palavras com t, destaque, cores e fontes; a LEGENDA da fala já está no quadro, então a cena NÃO escreve a fala: desenha a ideia) ou use um efeito de fábrica pelo nome (estudio_midia acao:"efeitos"), '
891
892
  + 'e chame de novo com batidas:[{de, ate, rosto, cena?, destaque?, canto?, dados?}].',
892
893
  };
893
894
  }
@@ -1177,6 +1178,35 @@ async function narrarComGemini(lista, mp3, { voz, direcao } = {}) {
1177
1178
  }
1178
1179
  }
1179
1180
 
1181
+ /* A VOZ DO VOICESTUDIO (15/10): o app local fala o roteiro inteiro, e o resto
1182
+ é o caminho do Gemini — volume de Reels, a transcrição para achar as partes.
1183
+ Fechado ou falhando, devolve ok:false e a cadeia segue para o Gemini. */
1184
+ async function narrarComVoiceStudio(lista, mp3, { voz } = {}) {
1185
+ if (process.env.PRIMOCODE_SEM_VOICESTUDIO) return { ok: false, indisponivel: true, error: 'voicestudio desligado' };
1186
+ try {
1187
+ const texto = lista.map((x) => x.texto).join('\n\n');
1188
+ // A voz do Gemini ("Kore", "Charon") não existe lá: só um perfil do próprio app.
1189
+ const perfil = /^vs:/i.test(String(voz || '')) ? String(voz).slice(3) : undefined;
1190
+ const r = await voicestudio.falar(texto, { voz: perfil });
1191
+ if (!r.ok) return r;
1192
+ const bruto = mp3.replace(/\.mp3$/, '-vs.mp3');
1193
+ fs.writeFileSync(bruto, r.audio);
1194
+ const c = await video.rodar(video.ffmpeg(), ['-y', '-i', bruto, '-af', 'loudnorm=I=-14:TP=-1.5:LRA=9', '-c:a', 'libmp3lame', '-q:a', '3', mp3], { timeout: 120000 });
1195
+ try { fs.unlinkSync(bruto); } catch { /* fica */ }
1196
+ if (!c.ok) return { ok: false, error: 'voicestudio: não consegui converter a voz' };
1197
+ const med = await video.medir(mp3);
1198
+ const chars = lista.reduce((n, x) => n + x.texto.length, 0);
1199
+ if (med.segundos < chars / 22) return { ok: false, error: `voicestudio: a voz veio cortada (${med.segundos.toFixed(1)} s para ${chars} caracteres)` };
1200
+ const cfg = require('../config').loadConfig();
1201
+ const tr = await require('../api').requestTranscricao(cfg.server, { token: cfg.token, audio: fs.readFileSync(mp3).toString('base64'), nome: 'voz.mp3', idioma: 'pt' });
1202
+ const palavras = (tr && tr.palavras) || [];
1203
+ if (!palavras.length) return { ok: false, error: 'voicestudio: sem a transcrição da voz para achar as partes' };
1204
+ return { ok: true, voz: `voicestudio ${perfil || 'default'}`, segundos: med.segundos, partes: alinharPartes(lista, palavras, med.segundos) };
1205
+ } catch (e) {
1206
+ return { ok: false, error: 'voicestudio: ' + e.message };
1207
+ }
1208
+ }
1209
+
1180
1210
  /* Onde estão os rostos em cada foto (studio/rostos.py, pelo uv). Guardado no
1181
1211
  projeto: a mesma foto não é analisada duas vezes. */
1182
1212
  async function acharRostos(p, srcs) {
@@ -1270,7 +1300,9 @@ async function narrarComCache(lista, mp3, { voz, direcao } = {}) {
1270
1300
  if (fs.existsSync(cm) && fs.existsSync(cj)) { fs.copyFileSync(cm, mp3); return { ok: true, ...JSON.parse(fs.readFileSync(cj, 'utf8')) }; }
1271
1301
  // A voz local pedida (voz: 'kokoro'): direto nela, sem gastar a cota do Gemini.
1272
1302
  if (kokoro.querKokoro(voz)) return kokoro.narrar(lista, mp3, { voz, uv: acharUv() });
1273
- const v = await narrarComGemini(lista, mp3, { voz, direcao });
1303
+ // O VoiceStudio local primeiro (15/10); fechado ou falhando, o Gemini.
1304
+ let v = await narrarComVoiceStudio(lista, mp3, { voz });
1305
+ if (!v.ok) v = await narrarComGemini(lista, mp3, { voz, direcao });
1274
1306
  if (v.ok) { try { fs.mkdirSync(dir, { recursive: true }); fs.copyFileSync(mp3, cm); fs.writeFileSync(cj, JSON.stringify({ voz: v.voz, segundos: v.segundos, partes: v.partes })); } catch { /* sem cache */ } }
1275
1307
  return v;
1276
1308
  }
@@ -1289,6 +1321,7 @@ async function produzirNecessidade(p, roteiro, pedido, provedores) {
1289
1321
  direção dele), o Kokoro local e o Edge. A falha de uma passa para a próxima. */
1290
1322
  function provedoresDeVoz(lista, mp3, arq, voz, vozEdge, { soEdge = false, gemini, escolha } = {}) {
1291
1323
  const todos = {
1324
+ voicestudio: () => narrarComVoiceStudio(lista, mp3, { voz }),
1292
1325
  gemini: () => gemini(),
1293
1326
  kokoro: () => kokoro.narrar(lista, mp3, { voz, uv: acharUv() }),
1294
1327
  edge: () => {
@@ -1307,7 +1340,7 @@ function provedoresDeVoz(lista, mp3, arq, voz, vozEdge, { soEdge = false, gemini
1307
1340
  /* A VOZ DE RESERVA, quando o Gemini falha: o Kokoro (local, sem cota) e
1308
1341
  depois o Edge. A falha de uma passa para a próxima, nunca derruba. */
1309
1342
  function narrarReserva(lista, mp3, arq, voz, vozEdge, { soEdge = false, semKokoro = false } = {}) {
1310
- const passos = kokoro.ordem(voz, process.env, { soEdge }).filter((n) => n !== 'gemini' && !(semKokoro && n === 'kokoro'));
1343
+ const passos = kokoro.ordem(voz, process.env, { soEdge }).filter((n) => n !== 'gemini' && n !== 'voicestudio' && !(semKokoro && n === 'kokoro'));
1311
1344
  return kokoro.corrente(passos, {
1312
1345
  kokoro: () => kokoro.narrar(lista, mp3, { voz, uv: acharUv() }),
1313
1346
  edge: () => rodarPython('narrar.py', ['roteiro', '--arquivo', arq, '--saida', mp3, '--voz', kokoro.vozNoEdge(voz, vozEdge)], { timeout: 10 * 60000 }),
@@ -295,6 +295,21 @@ function geometria(a, b, L, P) {
295
295
  for (let i = 0; i < N; i++) { const q = -2.3 + (i / (N - 1)) * Math.PI * 2 * 1.1; const e = 1 + (i / N) * 0.07; pts.push([ccx + Math.cos(q) * rx * e, ccy + Math.sin(q) * ry * e]); }
296
296
  return { pts, larg: Math.max(4 * base, cx.s * 0.03), alfa: 1, frente: true, cx: ccx, cy: ccy };
297
297
  }
298
+ if (f === 'visto') {
299
+ /* O ✓ de caneta (15/10, as referências): depois do círculo, ao lado da
300
+ palavra, em dois traços; se não cabe à direita, encosta na margem. */
301
+ const pw = alvoPalavra();
302
+ const s = cx.s, cy0 = cx.y + cx.h * 0.5;
303
+ const A = [pw.x + pw.w + s * 0.16, cy0], B = [pw.x + pw.w + s * 0.27, cy0 + s * 0.2], C = [pw.x + pw.w + s * 0.52, cy0 - s * 0.3];
304
+ const dx = Math.min(0, P.largura - 24 * base - C[0]);
305
+ const q = 0.3;
306
+ for (let i = 0; i < N; i++) {
307
+ const k = i / (N - 1);
308
+ const [p0, p1, u] = k < q ? [A, B, k / q] : [B, C, (k - q) / (1 - q)];
309
+ pts.push([lerp(p0[0], p1[0], u) + dx, lerp(p0[1], p1[1], u)]);
310
+ }
311
+ return { pts, larg: Math.max(5 * base, s * 0.045), alfa: 1, frente: true, tampa: 'round', cx: B[0] + dx, cy: B[1] };
312
+ }
298
313
  if (f === 'barra') {
299
314
  // O vão da barra é o respiro do vídeo (margem.js): colada, ela riscava a primeira letra.
300
315
  // A deriva da vida abre as letras até 3% da linha para fora: a barra recua junto.
@@ -94,7 +94,9 @@ async function narrar(lista, mp3, { voz, uv, espeak = acharEspeak(), executar =
94
94
  roteiro pula as outras. */
95
95
  function ordem(voz, env = process.env, { soEdge = false } = {}) {
96
96
  if (soEdge) return ['edge'];
97
- return querKokoro(voz, env) ? ['kokoro', 'edge'] : ['gemini', 'edge', 'kokoro'];
97
+ if (querKokoro(voz, env)) return ['kokoro', 'edge'];
98
+ // O VoiceStudio local vem primeiro (15/10); fechado, a cadeia segue para o Gemini na hora.
99
+ return env.PRIMOCODE_SEM_VOICESTUDIO ? ['gemini', 'edge', 'kokoro'] : ['voicestudio', 'gemini', 'edge', 'kokoro'];
98
100
  }
99
101
 
100
102
  /* A voz do Gemini no Edge: o narrar.py só conhece antonio, francisca e
@@ -108,9 +108,9 @@ import { KIT, CURVAS_KIT, Filtros } from './kit';
108
108
  import { COMPONENTES } from './componentes';
109
109
 
110
110
  /* ── SUAVIZAÇÃO ──────────────────────────────────────────────────────────
111
- * Cada curva é uma função de 0..1 em 0..1. "suave" é o padrão porque
112
- * movimento linear é o que mais denuncia animação feita por máquina: nada no
113
- * mundo físico começa e para na mesma velocidade. */
111
+ * Cada curva é uma função de 0..1 em 0..1. A "editorial" é o padrão (15/10):
112
+ * movimento linear denuncia máquina, e o entra-e-sai devagar da "suave" lia
113
+ * como fade; as referências atacam rápido e desaceleram forte. */
114
114
  const CURVAS = {
115
115
  linear: (t) => t,
116
116
  suave: (t) => (t < 0.5 ? 2 * t * t : 1 - Math.pow(-2 * t + 2, 2) / 2),
@@ -143,7 +143,9 @@ function valorEm(trilha, t, padrao) {
143
143
  const a = trilha[i], b = trilha[i + 1];
144
144
  if (t >= a.t && t <= b.t) {
145
145
  const bruto = b.t === a.t ? 1 : (t - a.t) / (b.t - a.t);
146
- const curva = CURVAS[b.e || a.e || 'suave'] || CURVAS.suave;
146
+ /* (15/10, as referências) Sem curva dita, a editorial: ataque rápido e
147
+ desaceleração forte. A "suave" (entra e sai devagar) lia como fade. */
148
+ const curva = CURVAS[b.e || a.e || 'editorial'] || CURVAS.editorial;
147
149
  const k = curva(bruto);
148
150
  /* Texto no lugar do número: é COR ("#ff6a00") ou CAMINHO (o "d" de um
149
151
  SVG). A cor mistura; o caminho se deforma (morfar.js): a bolinha
@@ -16,6 +16,8 @@
16
16
  * avisa a mesma sequência de elementos em 4+ cenas seguidas (cara de template)
17
17
  * avisa imagem sem a pessoa pedir, ou imagem em mais da metade das cenas
18
18
  * conserta efeito sonoro demais (mais de 1 a cada 1,5 s) → ficam os mais altos
19
+ * avisa texto pequeno demais para o celular (< 2% da altura no 9:16)
20
+ * avisa texto com contraste abaixo de 3:1 contra o fundo da cena
19
21
  * Puro: o plano entra, o plano consertado e a lista saem.
20
22
  */
21
23
 
@@ -34,6 +36,62 @@ const palavras = (t) => String(t || '').split(/\s+/).filter(Boolean).length;
34
36
  // Imagem ou vídeo DE FORA: o vídeo da própria gravação (deOrigem) e o recorte da pessoa não contam.
35
37
  const imagemDeFora = (el) => el && (el.tipo === 'imagem' || (el.tipo === 'video' && el.deOrigem == null && !/pessoa-/.test(el.src || '')));
36
38
 
39
+ /* O TEXTO QUE NÃO SE LÊ (15/10, as referências): rótulo de 1,6% da altura
40
+ ("NO COADOR") e laranja sobre bege. Nas referências a palavra principal
41
+ ocupa 55–85% da largura e a segunda voz ~5% da altura, sempre em contraste
42
+ duro. Contraste é o da WCAG para texto grande (3:1). */
43
+ const hex6 = (c) => {
44
+ const m = /^#?([0-9a-f]{3}|[0-9a-f]{6})$/i.exec(String(c || '').trim());
45
+ if (!m) return null;
46
+ const h = m[1].length === 3 ? m[1].split('').map((x) => x + x).join('') : m[1];
47
+ return [0, 2, 4].map((i) => parseInt(h.slice(i, i + 2), 16) / 255);
48
+ };
49
+ const luminancia = (c) => {
50
+ const v = hex6(c);
51
+ if (!v) return null;
52
+ const [r, g, b] = v.map((x) => (x <= 0.03928 ? x / 12.92 : Math.pow((x + 0.055) / 1.055, 2.4)));
53
+ return 0.2126 * r + 0.7152 * g + 0.0722 * b;
54
+ };
55
+ function contraste(a, b) {
56
+ const la = luminancia(a), lb = luminancia(b);
57
+ if (la == null || lb == null) return null;
58
+ return (Math.max(la, lb) + 0.05) / (Math.min(la, lb) + 0.05);
59
+ }
60
+ const corDoFundo = (f) => (typeof f === 'string' ? f : f && (f.valor || f.cor)) || null;
61
+
62
+ function revisarLeitura(plano, avisos) {
63
+ const W = Number(plano.formato && plano.formato.largura) || 1920, H = Number(plano.formato && plano.formato.altura) || 1080;
64
+ const piso = H * (W > H ? 0.024 : 0.019);
65
+ const curto = (t) => String(t || '').slice(0, 24);
66
+ const pequenos = [], apagados = [];
67
+ (plano.cenas || []).forEach((c, i) => {
68
+ const fundo = corDoFundo(c.fundo) || plano.fundoGeral;
69
+ for (const e of c.elementos || []) {
70
+ if (!e) continue;
71
+ if (e.tipo === 'texto') {
72
+ const tam = Number(e.tamanho || (e.estilo && e.estilo.tamanho));
73
+ if (tam && tam < piso) pequenos.push(`cena ${i + 1} "${curto(e.texto)}" ${r2((tam / H) * 100)}%`);
74
+ const k = contraste(e.cor || (e.estilo && e.estilo.cor), fundo);
75
+ if (k != null && k < 3) apagados.push(`cena ${i + 1} "${curto(e.texto)}" ${r2(k)}:1`);
76
+ }
77
+ if (e.tipo === 'kit' && e.peca === 'gerativo' && e.partitura) {
78
+ const Hp = Number(e.partitura.altura) || H;
79
+ const pisoP = Hp * ((Number(e.partitura.largura) || W) > Hp ? 0.024 : 0.019);
80
+ for (const b of e.partitura.batidas || []) {
81
+ const cores = b.cores || {};
82
+ for (const l of b.linhas || []) {
83
+ if (l.tamanho && l.tamanho < pisoP) pequenos.push(`batida ${r2(b.de)}s "${curto(l.texto)}" ${r2((l.tamanho / Hp) * 100)}%`);
84
+ }
85
+ const k = contraste(cores.texto, cores.fundo);
86
+ if (k != null && k < 3) apagados.push(`batida ${r2(b.de)}s ${cores.texto} sobre ${cores.fundo} ${r2(k)}:1`);
87
+ }
88
+ }
89
+ }
90
+ });
91
+ if (pequenos.length) avisos.push(`texto pequeno demais para o celular (piso ~2% da altura): ${pequenos.slice(0, 3).join('; ')}${pequenos.length > 3 ? ` e mais ${pequenos.length - 3}` : ''}`);
92
+ if (apagados.length) avisos.push(`texto com contraste abaixo de 3:1 contra o fundo: ${apagados.slice(0, 3).join('; ')}${apagados.length > 3 ? ` e mais ${apagados.length - 3}` : ''}`);
93
+ }
94
+
37
95
  function revisar(plano, { semImagem = false } = {}) {
38
96
  const consertos = [], avisos = [];
39
97
  const cenas = (plano && plano.cenas) || [];
@@ -114,6 +172,9 @@ function revisar(plano, { semImagem = false } = {}) {
114
172
  if (semImagem && comImagem) avisos.push(`${comImagem} cena(s) com imagem de fora, mas a pessoa não pediu imagem`);
115
173
  else if (cenas.length >= 4 && comImagem / cenas.length > 0.5) avisos.push(`imagem em ${comImagem} de ${cenas.length} cenas — a imagem é um momento, não o vídeo inteiro`);
116
174
 
175
+ // 7. O texto que não se lê: pequeno demais ou sem contraste.
176
+ revisarLeitura(plano, avisos);
177
+
117
178
  return { plano, consertos, avisos };
118
179
  }
119
180
 
@@ -0,0 +1,81 @@
1
+ /**
2
+ * voicestudio.js — a voz local do VoiceStudio, a primeira da cadeia.
3
+ *
4
+ * "achei isso: https://voicestudio.sh/ podemos usar pra narração do motion,
5
+ * ele entra primeiro na cadeia e depois, se falhar, o Gemini" (Joel, 15/10)
6
+ *
7
+ * O VoiceStudio é um app de desktop (AGPL-3.0) que a pessoa instala e deixa
8
+ * aberto; ele serve uma API no formato da OpenAI em localhost:3900, sem chave
9
+ * no loopback (docs/api-auth.md do repo dele). O PrimoCode só CHAMA a API —
10
+ * não copia nem distribui o código dele, então a licença não pega.
11
+ *
12
+ * Fechado, a porta recusa na hora e a cadeia segue para o Gemini sem esperar.
13
+ * Puro de rede: http do Node, sem dependência.
14
+ *
15
+ * VOICESTUDIO_URL outro endereço (padrão http://127.0.0.1:3900)
16
+ * VOICESTUDIO_VOZ o perfil de voz do app (padrão "default")
17
+ * VOICESTUDIO_MODELO o motor (padrão "omnivoice")
18
+ */
19
+
20
+ 'use strict';
21
+
22
+ const http = require('http');
23
+ const https = require('https');
24
+
25
+ const URL_PADRAO = 'http://127.0.0.1:3900';
26
+ const LIMITE = 4096; // o input máximo do /v1/audio/speech
27
+
28
+ function base(url) { return String(url || process.env.VOICESTUDIO_URL || URL_PADRAO).replace(/\/+$/, ''); }
29
+
30
+ function pedir(metodo, url, { corpo = null, timeout = 800 } = {}) {
31
+ return new Promise((ok) => {
32
+ let u;
33
+ try { u = new URL(url); } catch { ok({ ok: false, error: 'endereço inválido' }); return; }
34
+ const lib = u.protocol === 'https:' ? https : http;
35
+ const dados = corpo ? Buffer.from(JSON.stringify(corpo)) : null;
36
+ const req = lib.request(u, {
37
+ method: metodo,
38
+ headers: dados ? { 'content-type': 'application/json', 'content-length': dados.length } : {},
39
+ }, (res) => {
40
+ const partes = [];
41
+ res.on('data', (c) => partes.push(c));
42
+ res.on('end', () => ok({ ok: res.statusCode >= 200 && res.statusCode < 300, status: res.statusCode, corpo: Buffer.concat(partes) }));
43
+ });
44
+ req.setTimeout(timeout, () => req.destroy(new Error('tempo esgotado')));
45
+ req.on('error', (e) => ok({ ok: false, error: e.message, recusou: /ECONNREFUSED|EADDRNOTAVAIL|ENOTFOUND/.test(e.code || e.message) }));
46
+ if (dados) req.write(dados);
47
+ req.end();
48
+ });
49
+ }
50
+
51
+ /** O app está aberto? Pergunta rápida: fechado, a porta recusa na hora. */
52
+ async function disponivel({ url } = {}) {
53
+ const r = await pedir('GET', base(url) + '/v1/audio/voices', { timeout: 800 });
54
+ return r.ok === true;
55
+ }
56
+
57
+ /**
58
+ * Um texto em áudio (mp3). Nunca lança: devolve {ok, audio} ou {ok:false, error}.
59
+ * O primeiro pedido carrega o modelo no app, por isso o tempo é longo.
60
+ */
61
+ async function falar(texto, { url, voz, modelo, idioma = 'pt', timeout = 10 * 60000 } = {}) {
62
+ const input = String(texto || '').trim();
63
+ if (!input) return { ok: false, error: 'voicestudio: texto vazio' };
64
+ if (input.length > LIMITE) return { ok: false, error: `voicestudio: o texto passa de ${LIMITE} caracteres` };
65
+ if (!(await disponivel({ url }))) return { ok: false, indisponivel: true, error: 'voicestudio: o app não está aberto' };
66
+ const r = await pedir('POST', base(url) + '/v1/audio/speech', {
67
+ corpo: {
68
+ model: modelo || process.env.VOICESTUDIO_MODELO || 'omnivoice',
69
+ input,
70
+ voice: voz || process.env.VOICESTUDIO_VOZ || 'default',
71
+ response_format: 'mp3',
72
+ language: idioma,
73
+ },
74
+ timeout,
75
+ });
76
+ if (!r.ok) return { ok: false, error: `voicestudio: ${r.error || 'status ' + r.status}${r.corpo && r.corpo.length < 400 ? ' ' + r.corpo.toString() : ''}` };
77
+ if (!r.corpo || r.corpo.length < 8) return { ok: false, error: 'voicestudio: áudio vazio' };
78
+ return { ok: true, audio: r.corpo };
79
+ }
80
+
81
+ module.exports = { disponivel, falar, URL_PADRAO, LIMITE };
package/lib/video/auto.js CHANGED
@@ -254,9 +254,22 @@ function juntarSilencios(sil) {
254
254
  return out;
255
255
  }
256
256
 
257
+ /* O que vem depois do auto. Sem pedido falado (15/10: um vídeo do Instagram,
258
+ "edita pra mim?"), o plano é vazio e só a legenda entra — e o agente
259
+ exportava assim. Aí a edição volta para ele decidir, com o cardápio. */
260
+ function depois(passos) {
261
+ if ((passos || []).length) return { semPedido: false, texto: 'inspect para OLHAR os momentos; export renderiza (o 3D sai no export)' };
262
+ return {
263
+ semPedido: true,
264
+ texto: 'A gravação não tem pedido falado: só a legenda entrou, e isso ainda não é a edição. '
265
+ + 'Se a pessoa pediu para editar, a edição é sua: leia a fala (get_transcript) e decida — por exemplo cortar os silêncios (remove_silence), '
266
+ + 'zoom nos momentos fortes (auto_zoom), apoio quando ela cita uma coisa (add_clips), cor (grade). Olhe com inspect antes do export.',
267
+ };
268
+ }
269
+
257
270
  const OPS = {
258
271
  auto: {
259
- uso: 'auto {asset?,dryRun?} — O VÍDEO É O PEDIDO: lê o que a pessoa FALA na gravação ("separe em camadas", "coloca o logo do Claude aqui", "com o estalar de dedos me coloca numa praia", "junta e vira um cérebro", "cortou o silêncio", "o vídeo acaba") e aplica cada efeito no momento em que ela diz, ancorado na palavra, na mão, no estalo e no juntar das mãos; legenda sempre. Use logo depois do import quando pedirem "edite esse vídeo" sem dizer o quê',
272
+ uso: 'auto {asset?,dryRun?} — O VÍDEO É O PEDIDO: lê o que a pessoa FALA na gravação ("separe em camadas", "coloca o logo do Claude aqui", "com o estalar de dedos me coloca numa praia", "junta e vira um cérebro", "cortou o silêncio", "o vídeo acaba") e aplica cada efeito no momento em que ela diz, ancorado na palavra, na mão, no estalo e no juntar das mãos; legenda sempre. Serve quando a gravação TRAZ pedidos falados. Sem pedido falado (vídeo de outra pessoa, ou só fala), ele põe só a legenda, e o resto da edição é decisão sua',
260
273
  async run(ctx, a) {
261
274
  const T = require('./timeline.js');
262
275
  const C = require('./camadas.js');
@@ -317,13 +330,15 @@ const OPS = {
317
330
  if (L.position && ids.length) await roda('style_captions', { ids, position: L.position }, desde !== null ? 'em cima da cabeça, desde o momento dito' : 'em cima da cabeça');
318
331
  }
319
332
  const falhou = feitos.filter((f) => !f.ok);
333
+ const prox = depois(plano.passos);
320
334
  return { ok: !falhou.length, feitos, tookS: Math.round((Date.now() - inicio) / 100) / 10,
321
335
  ...(falhou.length ? { error: falhou.map((f) => `${f.op}: ${f.error}`).join('; ') } : {}),
322
- depois: 'inspect para OLHAR os momentos; export renderiza (o 3D sai no export)' };
336
+ ...(prox.semPedido ? { semPedido: true } : {}),
337
+ depois: prox.texto };
323
338
  },
324
339
  },
325
340
  };
326
341
 
327
342
  const EX = { auto: '{}' };
328
343
 
329
- module.exports = { planejar, frases, normal, fundoPedido, juntarSilencios, OPS, EX };
344
+ module.exports = { planejar, frases, normal, fundoPedido, juntarSilencios, depois, OPS, EX };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "primocode",
3
- "version": "10.12.0",
3
+ "version": "10.13.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/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/casar.test.js && node test/recado.test.js && node test/releitura.test.js && node test/snapshot.test.js && node test/video.test.js && node test/limpeza.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/conta.test.mjs && node test/planos-tela.test.js && node test/nuvem.test.js && node test/prazo.test.js && node test/primeira-vez.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 && node test/producao.test.js && node test/ende.test.js && node test/capa.test.js && node test/broll.test.js && node test/morfar.test.js && node test/motion.test.js && node test/motion-roteiro.test.js && node test/kit-motion.test.js && node test/gerativo.test.js && node test/gerativo-identidade.test.js && node test/gerativo-moldura.test.js && node test/gerativo-heroi.test.js && node test/gerativo-ambiente.test.js && node test/gerativo-variedade.test.js && node test/hibrido.test.js && node test/batida.test.js && node test/estudio-acervo.test.js && node test/estudio-diretor.test.js && node test/estudio-marca.test.js && node test/estudio-novo-projeto.test.js && node test/estudio-plano.test.js && node test/estudio-slides.test.js && node test/estudio-video.test.js && node test/revisor.test.js && node test/conferencia.test.js && node test/cor.test.js && node test/assinatura.test.js && node test/biblioteca.test.js && node test/mcp.test.js && node test/personas.test.js && node test/efeitos.test.js && node test/auto.test.js && node test/colar.test.js && node test/rolar.test.js && node test/orquestra.test.js && node test/shell-limpo.test.js && node test/editar-remotion.test.js && node test/variacao3d.test.js && node test/portao-skill.test.js && node test/clones.test.js && node test/kokoro.test.js && node test/fluido.test.js && node test/zoom.test.js && node test/personagem.test.js && node test/tela.test.js && node test/margem.test.js && node test/travado.test.js && node test/leitura.test.js && node test/producao-provedores.test.js && node test/broll-frase.test.js && node test/vazamento.test.js && node test/colecao.test.js && node test/mundo.test.js && node test/mcp-procurar.test.js && node test/selecao.test.js && node test/nome-curto.test.js && node test/agents-md.test.js && node test/outra-pasta.test.js && node test/estudio-sem-id.test.js && node test/estudio-objeto-do-site.test.js && node test/cenarios.test.js && node test/movimentos.test.js && node test/gerativo-so-fala.test.js && node test/estilos.test.js && node test/proporcao.test.js && node test/cena.test.js && node test/sem-narracao.test.js && node test/camadas.test.js"
11
+ "test": "node test/tools.test.js && node test/ui.test.js && node test/entrada.test.js && node test/transparencia.test.js && node test/permissao.test.js && node test/compatibilidade.test.js && node test/skills.test.js && node test/skills-de-fora.test.js && node test/skills-novas.test.js && node test/comandos.test.js && node test/estudio.test.js && node test/voz.test.js && node test/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/casar.test.js && node test/recado.test.js && node test/releitura.test.js && node test/snapshot.test.js && node test/video.test.js && node test/limpeza.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/conta.test.mjs && node test/planos-tela.test.js && node test/nuvem.test.js && node test/prazo.test.js && node test/primeira-vez.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 && node test/producao.test.js && node test/ende.test.js && node test/capa.test.js && node test/broll.test.js && node test/morfar.test.js && node test/motion.test.js && node test/motion-roteiro.test.js && node test/kit-motion.test.js && node test/gerativo.test.js && node test/gerativo-identidade.test.js && node test/gerativo-moldura.test.js && node test/gerativo-heroi.test.js && node test/gerativo-ambiente.test.js && node test/gerativo-variedade.test.js && node test/hibrido.test.js && node test/batida.test.js && node test/estudio-acervo.test.js && node test/estudio-diretor.test.js && node test/estudio-marca.test.js && node test/estudio-novo-projeto.test.js && node test/estudio-plano.test.js && node test/estudio-slides.test.js && node test/estudio-video.test.js && node test/revisor.test.js && node test/conferencia.test.js && node test/cor.test.js && node test/assinatura.test.js && node test/biblioteca.test.js && node test/mcp.test.js && node test/personas.test.js && node test/efeitos.test.js && node test/auto.test.js && node test/colar.test.js && node test/rolar.test.js && node test/orquestra.test.js && node test/shell-limpo.test.js && node test/editar-remotion.test.js && node test/variacao3d.test.js && node test/portao-skill.test.js && node test/clones.test.js && node test/kokoro.test.js && node test/fluido.test.js && node test/zoom.test.js && node test/personagem.test.js && node test/tela.test.js && node test/margem.test.js && node test/travado.test.js && node test/leitura.test.js && node test/producao-provedores.test.js && node test/broll-frase.test.js && node test/vazamento.test.js && node test/colecao.test.js && node test/mundo.test.js && node test/mcp-procurar.test.js && node test/selecao.test.js && node test/nome-curto.test.js && node test/agents-md.test.js && node test/outra-pasta.test.js && node test/estudio-sem-id.test.js && node test/estudio-objeto-do-site.test.js && node test/cenarios.test.js && node test/movimentos.test.js && node test/gerativo-so-fala.test.js && node test/estilos.test.js && node test/proporcao.test.js && node test/cena.test.js && node test/sem-narracao.test.js && node test/camadas.test.js && node test/gerativo-referencias.test.js && node test/voicestudio.test.js"
12
12
  },
13
13
  "engines": {
14
14
  "node": ">=18.17.0"
@@ -7,43 +7,35 @@ autor: PrimoCode
7
7
  Não existe template. Cada propriedade deste vídeo é uma decisão sua, e a
8
8
  primeira decisão é **não repetir o vídeo anterior**.
9
9
 
10
- ## O que denuncia vídeo feito por IA
11
-
12
- Não faça nada disto, em nenhum vídeo:
13
-
14
- - gradiente roxo-para-azul, e gradiente de duas
15
- cores que não têm relação com o assunto;
16
- - tudo centralizado, do primeiro ao último quadro;
17
- - uma fonte só, em todos os tamanhos;
18
- - texto que aparece com `opacidade 0→1` e mais nada, cena após cena;
19
- - ícone genérico flutuando ao lado de cada frase;
20
- - tempo igual em todas as cenas;
21
- - transição igual entre todas.
22
-
23
- ## O que separa motion bom de slide animado
24
-
25
- **A câmera existe.** Um zoom lento de 1.0 para 1.08 na cena inteira dá vida a
26
- um quadro parado.
27
-
28
- **Nada entra do nada.** Um elemento entra vindo de algum lugar (de baixo, de
29
- trás, de fora do quadro), com `spring` do Remotion ou easing de saída. Movimento
30
- linear é a assinatura da máquina: no mundo físico nada começa e para na mesma
31
- velocidade.
32
-
33
- **Profundidade.** Parallax (fundo devagar, frente rápido); `perspective` com
34
- `rotateX`/`translateZ`, ou three, para 3D de verdade.
35
-
36
- **Ritmo.** Informação simples 1,5–2,5 s; frase para ler 3–4 s; fecho 2 s.
37
-
38
- **Silêncio visual.** Um quadro com uma frase e mais nada vale mais que um com
39
- seis elementos. Espaço vazio é composição, não desperdício.
10
+ ## O que denuncia IA (nunca)
11
+
12
+ Gradiente roxo-azul ou sem relação com o assunto; tudo centralizado; uma fonte
13
+ só; texto que só faz `opacidade 0→1`; ícone genérico ao lado de cada frase;
14
+ tempo e transição iguais em todas as cenas; letra cortada ou embaralhada na troca.
15
+
16
+ ## A régua (medida nas referências que o Joel aprovou)
17
+
18
+ Números, não estilo pronto: o estilo continua seu.
19
+
20
+ - **Escala:** título de 1 a 3 palavras ocupando 55–85% da largura. Nada abaixo de 2% da altura.
21
+ - **Ritmo:** uma ideia por cena, 1 a 2 s (frase para ler 2–3 s). Com trilha, corte na batida.
22
+ - **Entrada:** máscara (sobe de baixo, recorte reto), palavra por palavra de desfocado
23
+ para nítido (0,15–0,3 s cada) ou esticando com borrão; `spring` ou ataque rápido e
24
+ freio forte. **Saída:** a frase sai inteira (desliza, recua, corte seco).
25
+ - **Cor:** fundo chapado, 1 tinta + 1 acento, contraste de 3:1 ou mais; até 5 cores.
26
+ - **Segunda voz:** o apoio é menor (~4% da altura), na segunda fonte, e chega 0,25–0,5 s depois.
27
+ - **À mão:** na palavra que é a tese, um círculo de caneta que se desenha (com ✓ ou seta).
28
+ - **Acento vivo e câmera:** um ponto ou forma acompanha o título e vira o elemento da
29
+ próxima cena (a transição é o elemento). Zoom lento de 1.0 a 1.08 e parallax dão vida.
30
+ - **Produto:** a interface é código (cartão, toggle, cursor que clica com resultado, número que conta).
31
+ - **Explicar:** uma cena ensina desenhando (curva, gráfico, contorno com âncoras).
32
+ - **Fecho:** a marca sozinha, centralizada, 1,5–2 s. Moldura de ferramenta (cantos,
33
+ timecode) só em reel de marca ou tecnologia: `moldura:"cantos"`.
40
34
 
41
35
  ## O caminho: você escreve cada cena
42
36
 
43
- Motion nesse nível não sai de molde: cada cena é UMA ideia desenhada para a
44
- fala — a interface do produto montando, o número que cresce, o objeto em 3D,
45
- o gráfico que mostra a ideia, a palavra gigante que se transforma. Quem
46
- desenha é você, em React.
37
+ Cada cena é UMA ideia desenhada para a fala (a interface montando, o número
38
+ que cresce, o objeto 3D, a palavra gigante). Quem desenha é você, em React.
47
39
 
48
40
  1. `estudio_projeto` (criar). A marca tem site? `estudio_site` mede cores e fontes.
49
41
  2. `estudio_midia acao:"estilos"`: monte a identidade pelo assunto em
@@ -62,23 +54,13 @@ desenha é você, em React.
62
54
  arquivo e mande o roteiro de novo.
63
55
  6. `estudio_render`. A voz, a trilha, os sons e o fundo são do Estúdio.
64
56
 
65
- **Variedade:** `estudio_midia acao:"efeitos"` antes das cenas; adapte e use
66
- como `componente`. Uma técnica nova por vídeo. Risco em 2+ linhas: traço por linha.
67
-
68
- Parte sem `componente` cai no gerativo (a tipografia anima sozinha): é a
69
- reserva, não o objetivo. Sem imagem é o padrão (foto só se pedirem).
70
-
71
- ## Cor e tipo
72
-
73
- A paleta sai do ASSUNTO, no `estiloVisual` (um nome do cardápio ou 5 cores
74
- suas). Se a marca existe, as cores medidas pelo `estudio_site` mandam — não
75
- as que você imagina que ela usa.
57
+ **Variedade:** `estudio_midia acao:"efeitos"`, adapte e use como `componente`; uma
58
+ técnica nova por vídeo. Parte sem `componente` cai no gerativo (a reserva). Foto só se pedirem.
76
59
 
77
- Duas fontes, no máximo: display para o que grita, neutra para o que se lê.
60
+ ## Cor, tipo e conferência
78
61
 
79
- ## Antes de renderizar
62
+ A paleta sai do ASSUNTO (`estiloVisual`); a marca existe? As cores medidas pelo
63
+ `estudio_site` mandam. Duas fontes: display para o que grita, neutra para o que se lê.
80
64
 
81
- - Os tempos das cenas batem com os tempos MEDIDOS da narração.
82
- - Nenhuma cena tem a mesma composição da anterior.
83
- - Leia o seu próprio plano e pergunte: em que este vídeo é diferente do
84
- último que eu fiz? Se não houver resposta, refaça antes de renderizar.
65
+ Antes do render: os tempos batem com a narração MEDIDA, nenhuma cena repete a
66
+ composição da anterior, e este vídeo é diferente do último. Senão, refaça.