primocode 10.4.3 → 10.4.7

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.
@@ -896,6 +896,32 @@ function alinharPartes(lista, palavras, total) {
896
896
  });
897
897
  }
898
898
 
899
+ /**
900
+ * Onde o roteiro COMEÇA na fala transcrita, em segundos — 0 se começa logo.
901
+ *
902
+ * Dois jeitos de o Gemini vazar a direção, os dois vistos no vídeo do Joel
903
+ * (10/10): falar "Rápido e contínuo." ANTES do roteiro, e falar NO LUGAR da
904
+ * primeira frase ("Rápido e contínuo. Você pergunta…", sem o "IA é pra tirar
905
+ * dúvida"). Então: acha o primeiro par de palavras seguidas que existe no
906
+ * roteiro (não só o par do começo), e corta o que veio antes — mas só se o que
907
+ * veio antes for, na maior parte, palavra da DIREÇÃO. Fala do roteiro que o
908
+ * transcritor errou não é vazamento e não sai.
909
+ */
910
+ function vazamentoNoComeco(lista, palavras, direcao = '') {
911
+ const norm = (t) => String(t || '').toLowerCase().normalize('NFD').replace(/[\u0300-\u036f]/g, '').replace(/[^a-z0-9]+/g, ' ').trim();
912
+ const roteiro = norm(lista.slice(0, 2).map((x) => x.texto).join(' ')).split(' ').filter(Boolean);
913
+ if (roteiro.length < 2 || !palavras.length) return 0;
914
+ const pares = new Set(roteiro.slice(0, -1).map((w, i) => w + ' ' + roteiro[i + 1]));
915
+ const ditas = palavras.slice(0, 14).map((w) => norm(w.texto));
916
+ let i = 0;
917
+ while (i < ditas.length - 1 && !pares.has(ditas[i] + ' ' + ditas[i + 1])) i++;
918
+ if (i === 0 || i >= ditas.length - 1) return 0;
919
+ const daDirecao = new Set(norm(direcao).split(' ').filter(Boolean));
920
+ const antes = ditas.slice(0, i).join(' ').split(' ').filter(Boolean);
921
+ const vazou = !direcao || antes.filter((w) => daDirecao.has(w)).length / antes.length >= 0.6;
922
+ return vazou ? Math.max(0, palavras[i].de - 0.08) : 0;
923
+ }
924
+
899
925
  async function narrarComGemini(lista, mp3, { voz, direcao } = {}) {
900
926
  try {
901
927
  const cfg = require('../config').loadConfig();
@@ -931,9 +957,25 @@ async function narrarComGemini(lista, mp3, { voz, direcao } = {}) {
931
957
  const chars = lista.reduce((n, x) => n + x.texto.length, 0);
932
958
  if (med.segundos < chars / 22) return { ok: false, error: `a voz veio cortada (${med.segundos.toFixed(1)} s para ${chars} caracteres)` };
933
959
  const tr = await api.requestTranscricao(cfg.server, { token: cfg.token, audio: fs.readFileSync(mp3).toString('base64'), nome: 'voz.mp3', idioma: 'pt' });
934
- const palavras = (tr && tr.palavras) || [];
960
+ let palavras = (tr && tr.palavras) || [];
935
961
  if (!palavras.length) return { ok: false, error: 'sem a transcrição da voz para achar as partes' };
936
- return { ok: true, voz: `gemini ${r.voz}`, segundos: med.segundos, partes: alinharPartes(lista, palavras, med.segundos) };
962
+ let segundos = med.segundos;
963
+ /* A DIREÇÃO LIDA EM VOZ ALTA. O servidor manda a direção ("leia RÁPIDO e
964
+ contínuo…") e o texto no mesmo pedido, e o Gemini às vezes fala o
965
+ começo dela: o vídeo do Joel abria com "Rápido e contínuo." antes do
966
+ roteiro (10/10). Se o roteiro só começa depois de algumas palavras,
967
+ o que vem antes é vazamento — e sai do áudio. */
968
+ const corte = vazamentoNoComeco(lista, palavras, direcao);
969
+ if (corte > 0.15) {
970
+ const cortado = mp3.replace(/\.mp3$/, '-c.mp3');
971
+ const k = await video.rodar(video.ffmpeg(), ['-y', '-ss', corte.toFixed(3), '-i', mp3, '-c:a', 'libmp3lame', '-q:a', '3', cortado], { timeout: 120000 });
972
+ if (k.ok && fs.existsSync(cortado)) {
973
+ fs.renameSync(cortado, mp3);
974
+ palavras = palavras.filter((w) => w.de >= corte - 0.02).map((w) => ({ ...w, de: Math.max(0, w.de - corte), ate: Math.max(0, w.ate - corte) }));
975
+ segundos = Math.max(0, segundos - corte);
976
+ }
977
+ }
978
+ return { ok: true, voz: `gemini ${r.voz}`, segundos, partes: alinharPartes(lista, palavras, segundos), ...(corte > 0.15 ? { cortouDirecao: corte } : {}) };
937
979
  } catch (e) {
938
980
  return { ok: false, error: e.message };
939
981
  }
@@ -1011,7 +1053,10 @@ async function prepararDepoimento(p, c) {
1011
1053
 
1012
1054
  /* A narração com cache (a mesma fala, na mesma direção, não é gerada duas vezes). */
1013
1055
  async function narrarComCache(lista, mp3, { voz, direcao } = {}) {
1014
- const chave = require('crypto').createHash('sha1').update(JSON.stringify([lista.map((x) => x.texto), voz || '', direcao || ''])).digest('hex').slice(0, 16);
1056
+ /* 'v2' (10/10): as narrações guardadas antes do corte da direção lida em
1057
+ voz alta ("Rápido e contínuo.") ficam para trás — reaproveitar uma delas
1058
+ trazia o vazamento de volta mesmo com o conserto. */
1059
+ const chave = require('crypto').createHash('sha1').update(JSON.stringify(['v2', lista.map((x) => x.texto), voz || '', direcao || ''])).digest('hex').slice(0, 16);
1015
1060
  const dir = path.join(os.homedir(), '.primocode', 'studio', 'cache-voz');
1016
1061
  const cm = path.join(dir, chave + '.mp3'), cj = path.join(dir, chave + '.json');
1017
1062
  if (fs.existsSync(cm) && fs.existsSync(cj)) { fs.copyFileSync(cm, mp3); return { ok: true, ...JSON.parse(fs.readFileSync(cj, 'utf8')) }; }
@@ -1497,7 +1542,9 @@ async function prepararRoteiro(p, args) {
1497
1542
  /* O gerativo pede voz com CORPO ("narração encorpada, extravagante",
1498
1543
  30/09): cheia, quente, perto do microfone — e o ritmo segue 1,12×. */
1499
1544
  /* (A primeira versão pedia "trailer" e "ênfase sustentada": a voz saiu 25% mais lenta, medido em 30/09.) */
1500
- ? 'Leia em português do Brasil como locutor de reels de tecnologia: RÁPIDO e contínuo, com voz cheia, quente e encorpada, '
1545
+ /* Sem a ênfase em caixa alta logo no começo ("rápido" gritado): era
1546
+ exatamente o trecho que o Gemini lia em voz alta (10/10). */
1547
+ ? 'Leia em português do Brasil como locutor de reels de tecnologia, num ritmo rápido e sem pausas, com voz cheia, quente e encorpada, '
1501
1548
  + 'bem perto do microfone, muita energia e sorriso na voz. Ataque firme e ênfase curta e forte nas palavras de impacto; '
1502
1549
  + 'quase sem pausa entre as frases. Nunca arraste, nunca leia devagar nem solene.'
1503
1550
  // O fluido é o vídeo de produto: voz limpa e segura, e rápida ("mais rápido", 06/10).
@@ -2042,7 +2089,8 @@ async function ferramentaSite(args) {
2042
2089
  return r;
2043
2090
  }
2044
2091
 
2045
- module.exports = { variarIcones,
2092
+ module.exports = {
2093
+ vazamentoNoComeco, variarIcones,
2046
2094
  ferramentaProjeto, ferramentaPlano, ferramentaMidia, ferramentaNarrar,
2047
2095
  ferramentaVideo, ferramentaRender, ferramentaSite,
2048
2096
  projeto, plano, remotion, assets, video, identidade, slides, python, rodarPython, alinharPartes, prepararRoteiro, roteiroValido, juntarVozes,
@@ -14,7 +14,7 @@
14
14
  import React, { useState, useEffect, useMemo } from 'react';
15
15
  import { delayRender, continueRender } from 'remotion';
16
16
  import { SNAPPY, CURVAS_KIT, acaso, suave, alfa, mistura } from './base';
17
- import { Produto3D, Icone3D } from './tres';
17
+ import { Produto3D, Icone3D, Heroi3D } from './tres';
18
18
 
19
19
  const ed = CURVAS_KIT.editorial, curvaCam = CURVAS_KIT.camera, arrasto = CURVAS_KIT.arrasto, acelera = CURVAS_KIT.acelera;
20
20
  const c01 = (x) => (x < 0 ? 0 : x > 1 ? 1 : x);
@@ -193,7 +193,9 @@ function leiaute(P, med) {
193
193
  if (!l.encher || !med[l.id]) continue;
194
194
  const k = l.encher + (l.coluna != null ? l.coluna : '');
195
195
  // O grid manda: a tese ocupa até ~74% da largura do bloco; só a sangria vai a 97%.
196
- const s = Math.min(l.max || l.tamanho, (bl.largura * (b.cheio ? 0.97 : 0.74)) / med[l.id].w);
196
+ /* (10/10) A tese gigante, a parede e a marca ENCHEM o bloco (97%): com fonte
197
+ larga, os 74% de antes paravam "CLAUDE CODE" em 9% da altura. O resto, 80%. */
198
+ const s = Math.min(l.max || l.tamanho, (bl.largura * (b.cheio || ['gigante', 'parede', 'marca'].includes(b.comp) ? 0.97 : 0.8)) / med[l.id].w);
197
199
  grupo[k] = grupo[k] == null ? s : Math.min(grupo[k], s);
198
200
  }
199
201
  const tam = (l) => {
@@ -950,9 +952,24 @@ export function Gerativo({ el, t, fps }) {
950
952
  e do texto — e calmo: desenhado por cima, brigava com os dois (05/10). */}
951
953
  {noAr.filter((x) => x.heroi && x.heroi.modo === 'campo' && L[x.id]).map((x) => {
952
954
  const bl = L[x.id].bloco, some = 1 - c01((t - x.ate) / 0.14);
955
+ let campo3d = null;
956
+ if (x.heroi.tres) {
957
+ /* As colunas NUNCA encostam no texto: só existem numa faixa abaixo
958
+ (ou acima, de ponta-cabeça) do bloco MEDIDO, com folga. O campo mora
959
+ mais fundo que o texto (translateZ -200), então na tela ele encolhe
960
+ para o centro mais que o texto: a folga medida aqui só cresce lá. */
961
+ const folga = Math.max(x.vao || 0, 0.07 * H);
962
+ const ds = DUR_SAIDA[x.saida] || 0;
963
+ const saiDe = x.ate - ds - 0.15;
964
+ if (H - (bl.y1 + folga) >= 0.34 * H) campo3d = { op: 0.7, faixa: { y0: bl.y1 + folga, y1: H }, saiDe };
965
+ else if (bl.y - folga >= 0.34 * H) campo3d = { op: 0.7, faixa: { y0: 0, y1: bl.y - folga }, teto: true, saiDe };
966
+ if (!campo3d) return null; // sem faixa livre: sem colunas
967
+ }
953
968
  return (
954
969
  <div key={'campo' + x.id} style={{ position: 'absolute', left: 0, top: 0, width: W, height: H, transform: 'translateZ(-200px)', opacity: some }}>
955
- <Heroi b={x} t={t} P={P} slot={{ cx: (bl.x + bl.x1) / 2, cy: (bl.y + bl.y1) / 2, R: Math.min(W, H) * 0.32, op: 0.18 }} />
970
+ {x.heroi.tres
971
+ ? <Heroi3D h={x.heroi} t={t} de={x.de} ate={x.ate} W={W} H={H} slot={campo3d} />
972
+ : <Heroi b={x} t={t} P={P} slot={{ cx: (bl.x + bl.x1) / 2, cy: (bl.y + bl.y1) / 2, R: Math.min(W, H) * 0.32, op: 0.18 }} />}
956
973
  </div>
957
974
  );
958
975
  })}
@@ -998,10 +1015,31 @@ export function Gerativo({ el, t, fps }) {
998
1015
  // O lugar do herói: o vazio do bloco (como o ícone); sem vazio, atrás do texto e mais fraco.
999
1016
  const bl = lay.bloco, M = P.margem;
1000
1017
  if (x.heroi.modo === 'campo') return null; // desenhado atrás, antes do produto
1001
- const R = Math.min(W, H) * (W >= H ? 0.2 : 0.17);
1018
+ const tres = x.heroi.modo === 'objeto3d';
1019
+ // O 3D é GRANDE ("um 3d bem presente"): o raio pede o vazio inteiro.
1020
+ const R = Math.min(W, H) * (tres ? (W >= H ? 0.26 : 0.22) : (W >= H ? 0.2 : 0.17));
1002
1021
  let slot = null;
1003
1022
  // A área segura (margem.js), com o vão do bloco; partitura antiga: a margem do mundo.
1004
1023
  const S = x.seguro || { x0: M, y0: M * 1.4, x1: W - M, y1: H - M * 1.4 }, V = x.vao || 0;
1024
+ if (tres) {
1025
+ /* O 3D NUNCA cruza o texto: mora no maior retângulo livre ao lado do
1026
+ bloco MEDIDO (com o vão e uma folga), dentro da área segura; o raio
1027
+ é o círculo que cabe nele, e o Heroi3D garante que a projeção inteira
1028
+ (anel, tubo, dolly, perspectiva) fica dentro desse raio. Sem
1029
+ retângulo grande o bastante: sem 3D nesta batida. */
1030
+ const folga = Math.max(V, 0.035 * Math.min(W, H));
1031
+ const livres = [
1032
+ { x0: bl.x1 + folga, y0: S.y0, x1: S.x1, y1: S.y1 },
1033
+ { x0: S.x0, y0: S.y0, x1: bl.x - folga, y1: S.y1 },
1034
+ { x0: S.x0, y0: S.y0, x1: S.x1, y1: bl.y - folga },
1035
+ { x0: S.x0, y0: bl.y1 + folga, x1: S.x1, y1: S.y1 },
1036
+ ].map((q) => ({ ...q, r: Math.min(q.x1 - q.x0, q.y1 - q.y0) / 2 })).sort((a, c) => c.r - a.r);
1037
+ const q = livres[0];
1038
+ if (!q || q.r < Math.min(W, H) * 0.12) return null;
1039
+ const ds = DUR_SAIDA[x.saida] || 0;
1040
+ slot = { cx: (q.x0 + q.x1) / 2, cy: (q.y0 + q.y1) / 2, R: Math.min(q.r, Math.min(W, H) * 0.3), saiDe: x.ate - ds - 0.12 };
1041
+ return <Heroi3D h={x.heroi} t={t} de={x.de} ate={x.ate} W={W} H={H} slot={slot} />;
1042
+ }
1005
1043
  if (S.x1 - bl.x1 - V >= R * 2.3) slot = { cx: bl.x1 + V + (S.x1 - bl.x1 - V) / 2, cy: (bl.y + bl.y1) / 2, R };
1006
1044
  else if (bl.y - V - S.y0 >= R * 2.2) slot = { cx: W / 2, cy: S.y0 + (bl.y - V - S.y0) / 2, R };
1007
1045
  else if (S.y1 - bl.y1 - V >= R * 2.2) slot = { cx: W / 2, cy: bl.y1 + V + (S.y1 - bl.y1 - V) / 2, R };
@@ -375,4 +375,160 @@ export function Icone3D({ el, t }) {
375
375
  );
376
376
  }
377
377
 
378
+
379
+ /* ── OS HERÓIS EM 3D DE VERDADE (10/10) ───────────────────────────────────
380
+ * "As animações são muito iguais, quero um 3d bem presente" (Joel, com um
381
+ * reel de motion de referência). O herói abstrato era SVG — pontos girando
382
+ * num plano. Estes três são three.js: perspectiva, profundidade, luz e
383
+ * câmera que anda. Grandes: o raio vem do vazio do bloco (gerativo.jsx).
384
+ *
385
+ * globo3d a esfera de pontos e malha, girando inclinada, com um anel
386
+ * de órbita; os pontos de perto maiores (perspectiva real);
387
+ * fita3d o nó que se torce, em material com verniz e a luz de
388
+ * recorte na cor de acento;
389
+ * colunas3d o campo de colunas que sobe em onda, sobrevoado pela câmera
390
+ * e sumindo na névoa da cor do fundo — fica ATRÁS do texto.
391
+ *
392
+ * Canvas do quadro inteiro, transparente: o objeto é posto no lugar do
393
+ * vazio convertendo pixel → mundo (câmera fixa, fov 30, a 12 unidades).
394
+ */
395
+ const FOV3D = 30, DIST3D = 12;
396
+ const VIS3D = 2 * DIST3D * Math.tan((FOV3D / 2) * Math.PI / 180);
397
+ const mola3d = (x) => { const k = Math.max(0, Math.min(1, x)); return 1 - Math.pow(1 - k, 3) * Math.cos(k * 4.2); };
398
+
399
+ function CameraFixa({ pos, alvo, teto }) {
400
+ const { camera } = useThree();
401
+ // De ponta-cabeça: o campo pende do alto (o texto mora embaixo).
402
+ camera.up.set(0, teto ? -1 : 1, 0);
403
+ camera.position.set(pos[0], pos[1], pos[2]);
404
+ camera.near = 0.1; camera.far = 80;
405
+ camera.lookAt(alvo[0], alvo[1], alvo[2]);
406
+ camera.updateProjectionMatrix();
407
+ return null;
408
+ }
409
+
410
+ function Globo({ r, cor, cor2, sem, tl }) {
411
+ const geo = useMemo(() => {
412
+ const N = 1100, pos = new Float32Array(N * 3), col = new Float32Array(N * 3);
413
+ const c1 = new THREE.Color(cor2), c2 = new THREE.Color(cor);
414
+ const ouro = Math.PI * (3 - Math.sqrt(5));
415
+ for (let i = 0; i < N; i++) {
416
+ const y = 1 - (i / (N - 1)) * 2, rr = Math.sqrt(1 - y * y), a = i * ouro;
417
+ pos.set([Math.cos(a) * rr * r, y * r, Math.sin(a) * rr * r], i * 3);
418
+ const c = (i * 7 + sem) % 6 === 0 ? c2 : c1;
419
+ col.set([c.r, c.g, c.b], i * 3);
420
+ }
421
+ const g = new THREE.BufferGeometry();
422
+ g.setAttribute('position', new THREE.BufferAttribute(pos, 3));
423
+ g.setAttribute('color', new THREE.BufferAttribute(col, 3));
424
+ return g;
425
+ }, [r, cor, cor2, sem]);
426
+ return (
427
+ <group rotation={[0.38 + Math.sin(tl * 0.5) * 0.08, tl * 0.55 + sem * 0.01, 0.12]}>
428
+ <points geometry={geo}><pointsMaterial size={r * 0.06} vertexColors sizeAttenuation /></points>
429
+ <mesh><sphereGeometry args={[r * 0.99, 32, 20]} /><meshBasicMaterial color={cor2} wireframe transparent opacity={0.22} /></mesh>
430
+ <mesh rotation={[1.2, 0.3, tl * 0.4]}><torusGeometry args={[r * 1.14, r * 0.008, 8, 160]} /><meshBasicMaterial color={cor} transparent opacity={0.85} /></mesh>
431
+ <mesh position={[Math.cos(tl * 1.3) * r * 1.14, 0, Math.sin(tl * 1.3) * r * 1.14]} rotation={[1.2, 0.3, 0]}>
432
+ <sphereGeometry args={[r * 0.05, 16, 12]} /><meshBasicMaterial color={cor} />
433
+ </mesh>
434
+ </group>
435
+ );
436
+ }
437
+
438
+ function Fita({ r, cor, cor2, sem, tl }) {
439
+ return (
440
+ <group rotation={[0.5 + tl * 0.35, tl * 0.6 + sem * 0.02, 0.2]}>
441
+ <mesh>
442
+ <torusKnotGeometry args={[r * 0.52, r * 0.13, 280, 32, 2 + (sem % 2), 3]} />
443
+ <meshPhysicalMaterial color={cor2} roughness={0.28} metalness={0.35} clearcoat={0.9} clearcoatRoughness={0.2} />
444
+ </mesh>
445
+ <mesh scale={[1.02, 1.02, 1.02]}>
446
+ <torusKnotGeometry args={[r * 0.52, r * 0.13, 140, 12, 2 + (sem % 2), 3]} />
447
+ <meshBasicMaterial color={cor} wireframe transparent opacity={0.22} />
448
+ </mesh>
449
+ </group>
450
+ );
451
+ }
452
+
453
+ function Colunas({ cor, cor2, fundo, sem, tl }) {
454
+ const n = 15, passo = 0.9;
455
+ const base = new THREE.Color(fundo).lerp(new THREE.Color(cor2), 0.22).getStyle();
456
+ const acesa = new THREE.Color(cor).getStyle();
457
+ const els = [];
458
+ for (let i = 0; i < n; i++) for (let j = 0; j < n; j++) {
459
+ const x = (i - (n - 1) / 2) * passo, z = (j - (n - 1) / 2) * passo - tl * 1.1;
460
+ const d = Math.hypot(i - n / 2, j - n / 2);
461
+ const onda = 0.5 + 0.5 * Math.sin(d * 0.85 - tl * 2.6 + (sem % 7));
462
+ const h = 0.25 + 2.2 * onda * onda;
463
+ const crista = onda > 0.93;
464
+ els.push(
465
+ <mesh key={i + '-' + j} position={[x, h / 2 - 1.6, z]} scale={[passo * 0.78, h, passo * 0.78]}>
466
+ <boxGeometry args={[1, 1, 1]} />
467
+ <meshStandardMaterial color={crista ? acesa : base} roughness={0.55} metalness={0.15}
468
+ {...(crista ? { emissive: acesa, emissiveIntensity: 0.6 } : {})} />
469
+ </mesh>,
470
+ );
471
+ }
472
+ return <group>{els}</group>;
473
+ }
474
+
475
+ /* O quanto cada forma passa do raio r, no pior quadro: o anel e a lua do
476
+ globo (1,14 r + a lua), o nó (o tubo vai a 1,5 × 0,52 r + 0,13 r). */
477
+ const ALCANCE3D = { globo3d: 1.2, fita3d: 0.92 };
478
+
479
+ export function Heroi3D({ h, t, de, ate, W, H, slot }) {
480
+ const tl = Math.max(0, t - de), dur = Math.max(0.8, ate - de);
481
+ const k = Math.min(1.02, mola3d(tl / 0.8));
482
+ // Some ANTES de o texto sair (a saída dele voa pela tela): o herói nunca cruza a letra.
483
+ const saiDe = slot.saiDe != null ? slot.saiDe : ate - 0.25;
484
+ const sai = Math.max(0, Math.min(1, (t - saiDe) / 0.2));
485
+ const sem = h.semente || 0;
486
+ const cor = h.cor || '#ff5a1f', cor2 = h.cor2 || '#eeeeee', fundo = h.fundo || '#111111';
487
+ if (h.tipo === 'colunas3d') {
488
+ // O sobrevoo: a câmera alta, olhando para a frente e para baixo, andando sobre o campo.
489
+ const avanca = tl * 0.25;
490
+ return (
491
+ <div style={{ position: 'absolute', left: 0, top: 0, width: W, height: H, opacity: (slot.op != null ? slot.op : 0.85) * Math.min(1, tl / 0.5) * (1 - sai),
492
+ /* A FAIXA: as colunas só existem fora do texto (clip), com a borda esfumada. */
493
+ ...(slot.faixa ? { clipPath: `inset(${slot.faixa.y0}px 0 ${H - slot.faixa.y1}px 0)`,
494
+ WebkitMaskImage: slot.teto ? `linear-gradient(to top, transparent ${H - slot.faixa.y1}px, #000 ${H - slot.faixa.y1 + 0.06 * H}px)` : `linear-gradient(to bottom, transparent ${slot.faixa.y0}px, #000 ${slot.faixa.y0 + 0.06 * H}px)` } : {}) }}>
495
+ <ThreeCanvas width={W} height={H} camera={{ position: [0, 4, 9], fov: 38 }} gl={{ antialias: true, preserveDrawingBuffer: true, alpha: true }}>
496
+ {/* Câmera baixa olhando quase reto: o campo ocupa a metade de baixo e some na névoa — o meio fica para o texto. */}
497
+ <CameraFixa teto={slot.teto} pos={[Math.sin(tl * 0.3) * 1.2, 2.3, 8 - avanca]} alvo={[0, 0.9, -4 - avanca]} />
498
+ <fog attach="fog" args={[fundo, 6, 19]} />
499
+ <ambientLight intensity={0.45} />
500
+ <directionalLight position={[4, 8, 5]} intensity={2.2} />
501
+ <directionalLight position={[-6, 2, -4]} intensity={2.6} color={cor} />
502
+ <Colunas cor={cor} cor2={cor2} fundo={fundo} sem={sem} tl={tl} />
503
+ </ThreeCanvas>
504
+ </div>
505
+ );
506
+ }
507
+ const { cx, cy, R } = slot;
508
+ const wx = ((cx - W / 2) / H) * VIS3D, wy = (-(cy - H / 2) / H) * VIS3D;
509
+ /* R é o TETO do que aparece na tela (o vão livre ao lado do texto). O raio
510
+ no mundo sai dele de trás para a frente: o alcance da forma, a escala
511
+ máxima e a perspectiva do ponto mais perto da lente no fim do dolly. */
512
+ const F = ALCANCE3D[h.tipo] || 1.2, zMax = 0.45;
513
+ let r = (R / H) * VIS3D / (F * 1.02);
514
+ for (let i = 0; i < 3; i++) r = ((R / H) * VIS3D / (F * 1.02)) * ((DIST3D - zMax - F * r) / DIST3D);
515
+ // O dolly: o objeto chega do fundo e vem devagar para a lente durante a batida.
516
+ const z = -3.5 * (1 - Math.min(1, k)) + zMax * Math.min(1, tl / dur);
517
+ const esc = 0.55 + 0.45 * k;
518
+ return (
519
+ <div style={{ position: 'absolute', left: 0, top: 0, width: W, height: H, opacity: (slot.op != null ? slot.op : 1) * Math.min(1, tl / 0.3) * (1 - sai) }}>
520
+ <ThreeCanvas width={W} height={H} camera={{ position: [0, 0, DIST3D], fov: FOV3D }} gl={{ antialias: true, preserveDrawingBuffer: true, alpha: true }}>
521
+ <Ambiente forca={0.9} />
522
+ <ambientLight intensity={0.3} />
523
+ <directionalLight position={[3, 4, 6]} intensity={2.6} />
524
+ <directionalLight position={[-5, -1, -3]} intensity={3.2} color={cor} />
525
+ <pointLight position={[0, 0, 5]} intensity={0.6} />
526
+ <group position={[wx, wy, z]} scale={[esc, esc, esc]}>
527
+ {h.tipo === 'fita3d' ? <Fita r={r} cor={cor} cor2={cor2} sem={sem} tl={tl} /> : <Globo r={r} cor={cor} cor2={cor2} sem={sem} tl={tl} />}
528
+ </group>
529
+ </ThreeCanvas>
530
+ </div>
531
+ );
532
+ }
533
+
378
534
  export const TRES = { produto3d: Produto3D, icone3d: Icone3D };
@@ -16,11 +16,8 @@
16
16
 
17
17
  'use strict';
18
18
 
19
- const { execFileSync } = require('child_process');
20
19
  const cerebro = require('./cerebro.json');
21
20
 
22
- const SEM_APARECER = { stdio: ['pipe', 'pipe', 'pipe'], windowsHide: true };
23
-
24
21
  function motorDe(id) {
25
22
  if (id === 'codex') {
26
23
  /* O padrão antigo do motor (gpt-5-mini) é recusado por conta de
@@ -33,13 +30,19 @@ function motorDe(id) {
33
30
  return require('../claude-engine.js');
34
31
  }
35
32
 
36
- /** Uma pergunta de texto, resposta de texto. Sem protocolo, sem ferramenta. */
37
- function perguntar(id, texto) {
38
- if (id === 'codex') return String(motorDe('codex').chamarCodex(texto) || '').trim();
33
+ /** Uma pergunta de texto, resposta de texto. Sem protocolo, sem ferramenta.
34
+ *
35
+ * ASSÍNCRONA. Era `execFileSync('claude', …)`: enquanto o Claude pensava, o
36
+ * PrimoCode inteiro parava — a tela cheia congelava em "Trabalhando · 0s",
37
+ * sem animação, sem Esc Esc, sem dar para digitar. O agente (requestAct) já
38
+ * rodava em paralelo; o chat e o resumo eram os que travavam. E o Codex
39
+ * devolvia "[object Promise]": a chamada dele já era assíncrona e o
40
+ * resultado virava texto sem esperar. */
41
+ async function perguntar(id, texto) {
42
+ if (id === 'codex') return String((await motorDe('codex').chamarCodex(texto)) || '').trim();
39
43
  const claude = motorDe('claude-code');
40
- const saida = execFileSync('claude', ['-p', '--output-format', 'json', ...claude.argsDoMotor()], {
41
- encoding: 'utf-8', timeout: 120000, input: texto, maxBuffer: 8 * 1024 * 1024, ...SEM_APARECER,
42
- });
44
+ const saida = await claude.rodarClaude(['-p', '--output-format', 'json', ...claude.argsDoMotor()], texto,
45
+ { timeout: 180000, tentativas: 1 });
43
46
  try { return String(JSON.parse(saida).result || '').trim(); } catch { return String(saida || '').trim(); }
44
47
  }
45
48
 
@@ -54,7 +57,7 @@ function criar(id) {
54
57
  const conversa = turnos.filter((m) => m && m.role && typeof m.content === 'string')
55
58
  .map((m) => `${m.role === 'user' ? 'PESSOA' : 'VOCÊ'}: ${m.content}`).join('\n');
56
59
  try {
57
- const resposta = perguntar(id, `${cerebro.SYSTEM_CHAT}\n\nResponda só à última fala da PESSOA.\n\n${conversa}`);
60
+ const resposta = await perguntar(id, `${cerebro.SYSTEM_CHAT}\n\nResponda só à última fala da PESSOA.\n\n${conversa}`);
58
61
  if (resposta) emit({ delta: resposta });
59
62
  } catch (e) {
60
63
  emit({ erro: e.message });
@@ -69,7 +72,7 @@ function criar(id) {
69
72
  + novas.map((m) => `${m.role === 'user' ? 'USUÁRIO' : m.role === 'assistant' ? 'AGENTE' : 'FERRAMENTA'}: `
70
73
  + String(typeof m.content === 'string' ? m.content : JSON.stringify(m.content || '')).slice(0, 1200)).join('\n');
71
74
  try {
72
- return { resumo: perguntar(id, `${cerebro.SYSTEM_RESUMO}\n\n${corpo}`) || String(resumo || '') };
75
+ return { resumo: (await perguntar(id, `${cerebro.SYSTEM_RESUMO}\n\n${corpo}`)) || String(resumo || '') };
73
76
  } catch (e) {
74
77
  return { resumo: String(resumo || ''), erro: e.message };
75
78
  }
package/lib/tela.js CHANGED
@@ -172,7 +172,9 @@ function quadro(e) {
172
172
  while (linhas.length < H - 1) linhas.push('');
173
173
  linhas.length = H - 1;
174
174
  const esq = ' ' + c.dim(e.pasta || '');
175
- const dir = c.dim(e.versao || '') + ' ';
175
+ // Folga à direita: terminais como o Orca põem um botão flutuante no
176
+ // canto de baixo, e ele cobria a versão ("10.").
177
+ const dir = c.dim(e.versao || '') + ' ';
176
178
  linhas.push(esq + ' '.repeat(Math.max(1, W - visibleWidth(esq) - visibleWidth(dir))) + dir);
177
179
  return {
178
180
  linhas: linhas.map((l) => caber(l, W)),
package/lib/voz.js CHANGED
@@ -278,6 +278,8 @@ function abrir({ python: py, servidor, token, voz, aoOuvir, mostrar = () => {},
278
278
  pronto,
279
279
  falar: (texto) => { const t = paraFalar(texto); if (t) manda({ tipo: 'falar', texto: t }); },
280
280
  calar: () => manda({ tipo: 'calar' }),
281
+ // A bolinha na tela: 'esperando' quando o terminal termina sem falar.
282
+ estado: (e) => manda({ tipo: 'estado', estado: e }),
281
283
  };
282
284
  }
283
285
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "primocode",
3
- "version": "10.4.3",
3
+ "version": "10.4.7",
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/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/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/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/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"
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/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/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/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/vazamento.test.js"
12
12
  },
13
13
  "engines": {
14
14
  "node": ">=18.17.0"
@@ -44,6 +44,50 @@ sys.path.insert(0, str(AQUI.parent))
44
44
  from primo_voz import escuta, transcrever # noqa: E402
45
45
 
46
46
 
47
+ class Bolinha:
48
+ """A orb (orbe.py) num processo à parte: este só diz o estado a ela.
49
+
50
+ Sem tkinter, ou se ela morrer, o modo voz segue igual — a bolinha é o
51
+ rosto, não o ouvido. Mudanças repetidas não são reenviadas, e o nível da
52
+ voz vai no máximo 15 vezes por segundo."""
53
+
54
+ def __init__(self) -> None:
55
+ self.p = None
56
+ self.ultimo = None
57
+ self.quando = 0.0
58
+ if os.environ.get("PRIMOCODE_SEM_ORB") == "1":
59
+ return
60
+ try:
61
+ self.p = subprocess.Popen([sys.executable, "-m", "primo_voz.orbe"], cwd=str(AQUI.parent),
62
+ stdin=subprocess.PIPE, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL,
63
+ env={**os.environ, "PYTHONPATH": str(AQUI.parent)}, text=True)
64
+ except Exception:
65
+ self.p = None
66
+
67
+ def estado(self, estado: str, nivel: float = None) -> None:
68
+ if not self.p or self.p.poll() is not None:
69
+ return
70
+ agora = time.time()
71
+ if estado == self.ultimo and (nivel is None or agora - self.quando < 1 / 15):
72
+ return
73
+ self.ultimo, self.quando = estado, agora
74
+ msg = {"estado": estado}
75
+ if nivel is not None:
76
+ msg["nivel"] = round(min(1.0, nivel), 3)
77
+ try:
78
+ self.p.stdin.write(json.dumps(msg) + "\n")
79
+ self.p.stdin.flush()
80
+ except Exception:
81
+ pass
82
+
83
+ def fechar(self) -> None:
84
+ if self.p:
85
+ try:
86
+ self.p.stdin.close()
87
+ except Exception:
88
+ pass
89
+
90
+
47
91
  def falar_para_o_node(obj: dict) -> None:
48
92
  """Uma linha de JSON no stdout. Nada mais pode sair por aí."""
49
93
  sys.stdout.write(json.dumps(obj, ensure_ascii=False) + "\n")
@@ -100,6 +144,7 @@ class Voz:
100
144
  transcrever.aquecer()
101
145
  self.gravador = escuta.Gravador()
102
146
  self.corte = escuta.Corte()
147
+ self.bolinha = Bolinha()
103
148
  self._blocos: "queue.Queue" = queue.Queue()
104
149
  self._falas: "queue.Queue" = queue.Queue()
105
150
  self._vivo = True
@@ -138,6 +183,7 @@ class Voz:
138
183
  self.parar()
139
184
  return
140
185
  falar_para_o_node({"tipo": "pronto"})
186
+ self.bolinha.estado("esperando")
141
187
  while self._vivo:
142
188
  self.gravador.comecar()
143
189
  acabou = False
@@ -147,6 +193,10 @@ class Voz:
147
193
  except queue.Empty:
148
194
  continue
149
195
  acabou = self.gravador.alimentar(bloco)
196
+ # A bolinha acende com a voz da pessoa, no tamanho da voz.
197
+ if self.gravador._falando and not self._falando:
198
+ lim = max(1e-6, self.gravador._limiar())
199
+ self.bolinha.estado("ouvindo", min(1.0, escuta.rms(bloco) / lim * 0.35))
150
200
  if not self._vivo:
151
201
  break
152
202
  if self.gravador.encerrou_por == "ninguem-falou" or self.gravador.segundos < 0.4:
@@ -154,9 +204,14 @@ class Voz:
154
204
  wav = self.gravador.gravar_wav(Path(tempfile.gettempdir()) / "primo-voz-fala.wav")
155
205
  if not wav:
156
206
  continue
207
+ self.bolinha.estado("pensando")
157
208
  texto = transcrever.limpar(self._transcrever(wav))
158
209
  if texto:
210
+ # Pensando até o terminal dizer o contrário (a resposta em
211
+ # voz, ou {"tipo":"estado"} quando ele termina sem falar).
159
212
  falar_para_o_node({"tipo": "ouvi", "texto": texto})
213
+ else:
214
+ self.bolinha.estado("esperando")
160
215
  mic.fechar()
161
216
 
162
217
  def _transcrever(self, wav: Path) -> str:
@@ -213,10 +268,12 @@ class Voz:
213
268
  return
214
269
  self._falando = True
215
270
  self._parar_fala.clear()
271
+ self.bolinha.estado("falando", 0.6)
216
272
  try:
217
273
  self._tocar(mp3)
218
274
  finally:
219
275
  self._falando = False
276
+ self.bolinha.estado("esperando")
220
277
  self.corte.nivel_dele = 0.0
221
278
  # O que o microfone pegou enquanto ele falava é a voz dele.
222
279
  self._esvaziar_blocos()
@@ -283,6 +340,8 @@ class Voz:
283
340
  self._falas.get_nowait()
284
341
  except queue.Empty:
285
342
  break
343
+ elif tipo == "estado":
344
+ self.bolinha.estado(str(msg.get("estado") or "esperando"))
286
345
  elif tipo == "fim":
287
346
  break
288
347
  self.parar()
@@ -292,6 +351,7 @@ class Voz:
292
351
  return
293
352
  self._vivo = False
294
353
  self._parar_fala.set()
354
+ self.bolinha.fechar()
295
355
  falar_para_o_node({"tipo": "fim"})
296
356
 
297
357
  def rodar(self) -> None: