primocode 9.8.0-beta.20 → 9.8.0-beta.22

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 v9.8.0-beta.20
1
+ # PrimoCode v9.8.0-beta.22
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 v9.8.0-beta.20
12
+ ▐▛███▜▌ Primo Code v9.8.0-beta.22
13
13
  ▝▜█████▛▘ Bem-vindo de volta, Joel
14
14
  ▘▘ ▝▝ ~/primocode · /dir <pasta> muda
15
15
  ```
@@ -56,7 +56,7 @@ function quando(textos, palavras, dur, t0 = 0.25) {
56
56
  });
57
57
  }
58
58
 
59
- function montarCinema({ roteiro, largura = 1920, altura = 1080, tempos = [], narracao = null, sons = {}, trilha = null, fotos = [] }) {
59
+ function montarCinema({ roteiro, largura = 1920, altura = 1080, tempos = [], narracao = null, sons = {}, trilha = null, fotos = [], fortes = {} }) {
60
60
  const partes = (roteiro && roteiro.partes) || [];
61
61
  if (!partes.length) return { ok: false, error: 'o roteiro veio sem partes.' };
62
62
  const marca = roteiro.marca || {};
@@ -70,7 +70,17 @@ function montarCinema({ roteiro, largura = 1920, altura = 1080, tempos = [], nar
70
70
  const base = Math.min(W, H);
71
71
  const cenas = [], trechos = [], efeitos = [], volumes = [];
72
72
  let t = 0, fotoI = 0;
73
- const foto = () => fotos[fotoI++ % Math.max(1, fotos.length)];
73
+ /* A foto certa para o texto: "projetos", "construir", "funcionando" pedem
74
+ a TELA (o dashboard); o resto pede GENTE. Foto de tela em "uma sala, 20
75
+ cadeiras" era o que "não tem nada a ver" (25/09). */
76
+ const pool = { pessoas: fotos.filter((f) => f.cat !== 'tela'), tela: fotos.filter((f) => f.cat === 'tela') };
77
+ const idx = { pessoas: 0, tela: 0 };
78
+ const foto = (cat = 'pessoas') => {
79
+ const l = pool[cat] && pool[cat].length ? pool[cat] : (pool.pessoas.length ? pool.pessoas : fotos);
80
+ const k = pool[cat] && pool[cat].length ? cat : 'pessoas';
81
+ return l.length ? l[idx[k]++ % l.length] : null;
82
+ };
83
+ const catDe = (texto) => (/projet|constru|funcion|dashboard|sistema|ferramenta/i.test(String(texto || '')) ? 'tela' : 'pessoas');
74
84
  const som = (tt, nome, volume = 0.6) => { if (sons[nome]) efeitos.push({ t: r2(Math.max(0, tt)), src: sons[nome], volume }); };
75
85
  const vol = (tt, v) => volumes.push({ t: r2(tt), v });
76
86
 
@@ -82,16 +92,46 @@ function montarCinema({ roteiro, largura = 1920, altura = 1080, tempos = [], nar
82
92
  (d) => ({ escala: [{ t: 0, v: 1.14 }, { t: d, v: 1.14 }], x: [{ t: 0, v: W * 0.04 }, { t: d, v: -W * 0.04, e: 'linear' }] }),
83
93
  ];
84
94
  let mov = 0;
95
+ /* O ENQUADRAMENTO pelas pessoas: com os rostos achados (studio/rostos.py),
96
+ o recorte centra neles; se eles não cabem no recorte (a foto deitada num
97
+ quadro em pé perde 2/3 da largura), a foto entra INTEIRA, com ela mesma
98
+ borrada de fundo. "Aparecem três homens ali, está cortada, não dá pra
99
+ ver ninguém" (25/09). */
100
+ const enquadrar = (f) => {
101
+ const ro = f.rosto;
102
+ if (!ro || !ro.largura) return { foco: 'center 30%', inteira: false };
103
+ const aF = W / H, aI = ro.largura / ro.altura;
104
+ if (aI > aF) { // a foto é mais larga que o quadro: sobra largura
105
+ const vis = aF / aI;
106
+ if (ro.n && ro.x1 - ro.x0 > vis * 0.92) return { inteira: true };
107
+ const cx = ro.n ? ro.cx : 0.5;
108
+ const px = vis >= 1 ? 50 : Math.round(Math.max(0, Math.min(1, (cx - vis / 2) / (1 - vis))) * 100);
109
+ return { foco: `${px}% ${ro.n ? Math.round(Math.min(0.6, ro.cy) * 100) : 35}%`, inteira: false };
110
+ }
111
+ const vis = aI / aF; // mais alta que o quadro: sobra altura
112
+ const cy = ro.n ? ro.cy : 0.35;
113
+ const py = vis >= 1 ? 50 : Math.round(Math.max(0, Math.min(1, (cy - vis / 2) / (1 - vis))) * 100);
114
+ return { foco: `50% ${py}%`, inteira: false };
115
+ };
85
116
  const pFoto = (els, f, de, ate, { veu = 0.5, entrada = 'corte' } = {}) => {
86
117
  if (!f) return;
87
118
  const d = r2(ate - de), v = f.tipo === 'video';
88
- els.push({ tipo: v ? 'video' : 'imagem', src: f.src, ...(v ? { volume: 0 } : {}), x: 0, y: 0, largura: '100%', altura: '100%', ajuste: 'cover', foco: 'center 30%', de: r2(de), ate: r2(ate),
89
- anima: { ...movs[mov++ % movs.length](d), ...(entrada === 'suave' ? { opacidade: [{ t: 0, v: 0 }, { t: 0.4, v: 1 }] } : {}) } });
119
+ const q = enquadrar(f);
120
+ const suave = entrada === 'suave' ? { opacidade: [{ t: 0, v: 0 }, { t: 0.4, v: 1 }] } : {};
121
+ if (q.inteira) {
122
+ els.push({ tipo: 'imagem', src: f.src, x: 0, y: 0, largura: '100%', altura: '100%', ajuste: 'cover', de: r2(de), ate: r2(ate),
123
+ anima: { desfoque: [{ t: 0, v: 36 }], brilho: [{ t: 0, v: 0.5 }], escala: [{ t: 0, v: 1.15 }, { t: d, v: 1.25, e: 'linear' }] } });
124
+ els.push({ tipo: 'imagem', src: f.src, x: 0, y: Math.round(H * 0.22), largura: '100%', altura: Math.round(H * 0.56), ajuste: 'contain', de: r2(de), ate: r2(ate),
125
+ anima: { escala: [{ t: 0, v: 1.0 }, { t: d, v: 1.07, e: 'linear' }], ...suave } });
126
+ } else {
127
+ els.push({ tipo: v ? 'video' : 'imagem', src: f.src, ...(v ? { volume: 0 } : {}), x: 0, y: 0, largura: '100%', altura: '100%', ajuste: 'cover', foco: q.foco, de: r2(de), ate: r2(ate),
128
+ anima: { ...(deitado ? movs[mov++ % movs.length](d) : movs[(mov++ % 2)](d)), ...suave } });
129
+ }
90
130
  if (veu > 0) els.push({ tipo: 'forma', forma: 'retangulo', x: 0, y: 0, largura: '100%', altura: '100%', de: r2(de), ate: r2(ate),
91
131
  fundo: { tipo: 'gradiente', cores: [`rgba(7,7,10,${veu * 0.7})`, `rgba(7,7,10,${veu})`, `rgba(7,7,10,${Math.min(0.95, veu + 0.25)})`], angulo: 180 } });
92
132
  };
93
133
  // O texto: entra palavra por palavra no tempo da voz e fica; as palavras marcadas no gradiente.
94
- const pTexto = (els, texto, de, ate, { y = null, max = deitado ? H * 0.11 : W * 0.13, linhas = 3, peso = 700, caixa = true, marcar = [], cor = BRANCO, pal = [], alinhar = 'center', x = M, larg = W0 } = {}) => {
134
+ const pTexto = (els, texto, de, ate, { y = null, max = deitado ? H * 0.11 : W * 0.13, linhas = 3, peso = 700, caixa = true, marcar = [], cor = BRANCO, pal = [], alinhar = 'center', x = M, larg = W0, bater = false } = {}) => {
95
135
  const txt = caixa ? String(texto).toUpperCase() : String(texto);
96
136
  const fit = caber(txt, { largura: larg * 0.86, linhas, max, fonte: F.titulo });
97
137
  const h = fit.tamanho * 1.08 * fit.linhas;
@@ -104,7 +144,9 @@ function montarCinema({ roteiro, largura = 1920, altura = 1080, tempos = [], nar
104
144
  els.push({ tipo: 'texto', texto: txt, fonte: F.titulo, peso, tamanho: fit.tamanho, cor, entrelinha: 1.08, espacamento: caixa ? 1 : 0,
105
145
  x: Math.round(x), y: Math.round(y == null ? (H - h) / 2 : y), largura: Math.round(larg), alinhamento: alinhar, de: r2(de), ate: r2(ate),
106
146
  revelar: rev, ...(idx.length ? { destaques: idx, corDestaque: DEST } : {}),
107
- anima: { opacidade: [{ t: 0, v: 1 }, { t: Math.max(0.01, ate - de - 0.25), v: 1 }, { t: ate - de, v: 0 }], y: [{ t: 0, v: 10 }, { t: ate - de, v: -6, e: 'linear' }] } });
147
+ anima: { opacidade: [{ t: 0, v: 1 }, { t: Math.max(0.01, ate - de - 0.25), v: 1 }, { t: ate - de, v: 0 }], y: [{ t: 0, v: 10 }, { t: ate - de, v: -6, e: 'linear' }],
148
+ // O texto que BATE no fundo: entra grande e assenta de uma vez.
149
+ ...(bater ? { escala: [{ t: 0, v: 1.35 }, { t: 0.12, v: 0.97, e: 'saida' }, { t: 0.22, v: 1 }] } : {}) } });
108
150
  return h;
109
151
  };
110
152
  const linhaFina = (els, y, de, larg = W * 0.18) => els.push({ tipo: 'forma', forma: 'retangulo', x: Math.round((W - larg) / 2), y: Math.round(y), largura: Math.round(larg), altura: 2,
@@ -118,7 +160,7 @@ function montarCinema({ roteiro, largura = 1920, altura = 1080, tempos = [], nar
118
160
  const narrada = p.tipo !== 'depoimento';
119
161
  const tp = narrada ? (tempos[narrI++] || null) : null;
120
162
  const off = 0.35; // a voz entra um instante depois do corte
121
- const falaDur = tp ? Math.min(tp.ate + 0.6, tempos[narrI] ? tempos[narrI].de : tp.ate + 0.6) - tp.de : 0;
163
+ const falaDur = fortes[narrI - 1] ? fortes[narrI - 1].segundos + 0.3 : (tp ? (tempos[narrI] ? tempos[narrI].de : tp.ate + 0.8) - tp.de : 0);
122
164
  const rel = (w) => r2(off + w - (tp ? tp.de : 0)); // tempo da voz → tempo da cena
123
165
  const pals = tp ? tp.palavras || [] : [];
124
166
  let dur;
@@ -128,8 +170,11 @@ function montarCinema({ roteiro, largura = 1920, altura = 1080, tempos = [], nar
128
170
  que a narração não terminou", 25/09). O trecho vai até perto do
129
171
  começo da próxima fala, com folga. */
130
172
  const proxFala = tempos[narrI] ? tempos[narrI].de : null;
131
- const fimFala = tp ? r2(Math.min(proxFala != null ? proxFala - 0.02 : tp.ate + 0.6, tp.ate + 0.6)) : 0;
132
- if (tp && narracao) trechos.push({ src: narracao, t: r2(t + off), de: r2(tp.de), ate: fimFala, volume: 1 });
173
+ // Até o começo da próxima fala, sem teto: o áudio é contínuo, e cortar antes comia sílaba.
174
+ const forte = fortes[narrI - 1];
175
+ const fimFala = tp ? r2(proxFala != null ? proxFala - 0.02 : tp.ate + 0.8) : 0;
176
+ if (tp && narracao && !forte && !p.mudo) trechos.push({ src: narracao, t: r2(t + off), de: r2(tp.de), ate: fimFala, volume: 1 });
177
+ if (forte) trechos.push({ src: forte.src, t: r2(t + (p.tipo === 'climax' ? 0 : off)), de: 0, ate: r2(forte.segundos + 0.3), volume: 1 });
133
178
 
134
179
  const textosDaParte = p.tipo === 'escada' ? [...(p.itens || []), p.final].filter(Boolean)
135
180
  : p.tipo === 'numeros' ? [p.antes, ...(p.itens || [])].filter(Boolean)
@@ -142,7 +187,9 @@ function montarCinema({ roteiro, largura = 1920, altura = 1080, tempos = [], nar
142
187
  /* Cada texto garante o SEU tempo de leitura e empurra o seguinte: sincronia
143
188
  com a voz é bom, ler é obrigatório. Se a leitura pede mais que a fala,
144
189
  a cena cresce (medido: "EDIÇÕES" ficava meio segundo na tela). */
145
- for (let k = 1; k < inicios.length; k++) inicios[k] = r2(Math.max(inicios[k], inicios[k - 1] + leitura(textosDaParte[k - 1])));
190
+ // Texto curto (um número, "3 DIAS.") se lê de relance: não espera a leitura cheia e não atrasa a voz.
191
+ const minimo = (x) => Math.min(leitura(x), String(x).split(/\s+/).length <= 3 ? 0.75 : 1.6);
192
+ for (let k = 1; k < inicios.length; k++) inicios[k] = r2(Math.max(inicios[k], inicios[k - 1] + minimo(textosDaParte[k - 1])));
146
193
  if (inicios.length) dur = r2(Math.max(dur, inicios[inicios.length - 1] + leitura(textosDaParte[inicios.length - 1]) + 0.3));
147
194
  const fimDe = (k) => (k < inicios.length - 1 ? inicios[k + 1] : dur);
148
195
  const palDo = (txt) => { const ws = String(txt).split(/\s+/); const a = pals.findIndex((w) => tokens(w.texto)[0] === tokens(ws[0])[0]); return a < 0 ? [] : ws.map((_, k) => (pals[a + k] ? rel(pals[a + k].de) : null)); };
@@ -153,25 +200,50 @@ function montarCinema({ roteiro, largura = 1920, altura = 1080, tempos = [], nar
153
200
  textosDaParte.forEach((x, k) => pTexto(els, x, inicios[k], fimDe(k), { caixa: false, peso: 600, max: deitado ? H * 0.1 : W * 0.12, linhas: deitado ? 3 : 4, marcar: p.marcar || [], pal: palDo(x) }));
154
201
  } else if (p.tipo === 'montagem' || p.tipo === 'climax') {
155
202
  const rap = p.tipo === 'climax' ? 0.38 : (p.ritmo || 0.75);
156
- const fimFotos = p.tipo === 'climax' && textosDaParte.length ? Math.max(1.8, inicios[0] - 0.4) : dur;
157
- for (let a = 0; a < fimFotos; a += rap) pFoto(els, foto(), a, Math.min(fimFotos, a + rap), { veu: p.tipo === 'climax' ? 0.15 : 0.5 });
203
+ let fimFotos = p.tipo === 'climax' && textosDaParte.length ? Math.max(1.8, inicios[0] - 0.4) : dur;
204
+ const fz = p.tipo === 'climax' ? fortes[narrI - 1] : null;
205
+ if (fz) {
206
+ /* A voz forte entra DEPOIS da montagem, junto com o texto que bate. */
207
+ fimFotos = 2.2;
208
+ const ini2 = quando(textosDaParte, (fz.palavras || []).map((w) => ({ ...w, de: w.de + fimFotos })), fz.segundos, fimFotos);
209
+ ini2.forEach((v0, k) => { inicios[k] = k === 0 ? fimFotos : Math.max(v0, inicios[k - 1] + 1.2); });
210
+ dur = r2(Math.max(fimFotos + fz.segundos + 0.9, inicios[inicios.length - 1] + 1.8));
211
+ const tr = trechos[trechos.length - 1];
212
+ if (tr && tr.src === fz.src) tr.t = r2(t + fimFotos - 0.05);
213
+ }
214
+ for (let a = 0; a < fimFotos; a += rap) {
215
+ // A foto do momento acompanha o texto que está na tela.
216
+ let ativo = textosDaParte[0];
217
+ textosDaParte.forEach((x, k) => { if (a >= inicios[k] - 0.1) ativo = x; });
218
+ pFoto(els, foto(catDe(ativo)), a, Math.min(fimFotos, a + rap), { veu: p.tipo === 'climax' ? 0.15 : 0.5 });
219
+ }
158
220
  if (p.tipo === 'climax') {
159
- vol(t, 0.5); vol(t + fimFotos - 0.3, 0.75); vol(t + fimFotos, 0.0); vol(t + dur - 0.2, 0.0);
160
- som(t + fimFotos, 'impacto', 0.7);
221
+ /* A virada: a música só ABAIXA (fica perceptível), e o texto bate
222
+ no fundo com um impacto forte — "quero um som de impacto bem
223
+ forte, do texto batendo no fundo" (25/09). */
224
+ vol(t, 0.5); vol(t + fimFotos - 0.3, 0.75); vol(t + fimFotos, 0.16); vol(t + dur - 0.2, 0.2);
225
+ som(t + fimFotos + 0.02, 'estalo', 0.8);
226
+ cena.tremor = [{ de: fimFotos, ate: fimFotos + 0.45, forca: 16 }];
227
+ cena.socos = [{ t: fimFotos, forca: 0.12 }];
161
228
  } else { vol(t, 0.28); som(t, 'whoosh', 0.35); }
162
- textosDaParte.forEach((x, k) => pTexto(els, x, Math.max(inicios[k], p.tipo === 'climax' ? fimFotos : 0), fimDe(k), { peso: 800, marcar: p.marcar || [], pal: palDo(x) }));
229
+ textosDaParte.forEach((x, k) => {
230
+ if (p.tipo === 'climax') { som(t + inicios[k], 'impacto', k === 0 ? 1.0 : 0.85); if (k > 0) cena.tremor = [...(cena.tremor || []), { de: inicios[k], ate: inicios[k] + 0.4, forca: 14 }]; }
231
+ pTexto(els, x, Math.max(inicios[k], p.tipo === 'climax' ? fimFotos : 0), fimDe(k), { peso: 800, marcar: p.marcar || [], pal: palDo(x), bater: p.tipo === 'climax',
232
+ // A virada é o texto maior do vídeo.
233
+ ...(p.tipo === 'climax' ? { max: deitado ? H * 0.13 : W * 0.17, linhas: deitado ? 3 : 5 } : {}) });
234
+ });
163
235
  } else if (p.tipo === 'numeros') {
164
236
  vol(t, 0.3);
165
- for (let a = 0; a < dur; a += 1.2) pFoto(els, foto(), a, Math.min(dur, a + 1.2), { veu: 0.62 });
237
+ for (let a = 0; a < dur; a += 1.2) pFoto(els, foto(), a, Math.min(dur, a + 1.2), { veu: 0.45 });
166
238
  textosDaParte.forEach((x, k) => {
167
239
  const m = String(x).match(/^(\d+)\s+(.*)$/);
168
240
  if (k === 0 && p.antes) { pTexto(els, x, inicios[k], fimDe(k), { max: base * 0.08, peso: 700, pal: palDo(x) }); return; }
169
241
  if (!m) { pTexto(els, x, inicios[k], fimDe(k), { pal: palDo(x) }); return; }
170
242
  const tam = Math.round(deitado ? H * 0.24 : W * 0.3);
171
243
  els.push({ tipo: 'texto', texto: m[1], fonte: F.titulo, peso: 800, tamanho: tam, cor: BRANCO, x: M, y: Math.round(H * 0.5 - tam * 0.75), largura: W0, alinhamento: 'center',
172
- de: inicios[k], ate: fimDe(k), contar: { ate: Number(m[1]), dur: 0.9 }, gradienteTexto: undefined,
244
+ de: inicios[k], ate: fimDe(k), contar: { ate: Number(m[1]), dur: 0.45 }, gradienteTexto: undefined,
173
245
  anima: { opacidade: [{ t: 0, v: 0 }, { t: 0.25, v: 1 }], escala: [{ t: 0, v: 0.92 }, { t: 0.6, v: 1, e: 'saida' }] } });
174
- pTexto(els, m[2], inicios[k] + 0.3, fimDe(k), { y: H * 0.5 + tam * 0.3, max: base * 0.085, peso: 700, cor: DEST });
246
+ pTexto(els, m[2], inicios[k] + 0.1, fimDe(k), { y: H * 0.5 + tam * 0.3, max: base * 0.085, peso: 700, cor: DEST });
175
247
  });
176
248
  } else if (p.tipo === 'escada') {
177
249
  vol(t, 0.22);
@@ -208,11 +280,12 @@ function montarCinema({ roteiro, largura = 1920, altura = 1080, tempos = [], nar
208
280
  /* O aluno falando, com a voz dele; a música quase some. CADA depoimento
209
281
  é uma cena: o vídeo conta o tempo do início da cena, e com os três
210
282
  na mesma cena o segundo e o terceiro já tinham passado do fim (preto). */
211
- vol(t, 0.06);
283
+ vol(t, 0.035);
212
284
  const clipes = p.clipes || [];
213
285
  clipes.forEach((c, n) => {
214
286
  const d = r2(c.ate - c.de + 0.3);
215
- const ce = { fundo: { tipo: 'cor', valor: PRETO }, elementos: [], transicao: { tipo: n === 0 ? 'esmaecer' : 'corte', duracao: n === 0 ? 0.4 : 0 }, duracao: d };
287
+ // Entre um depoimento e outro, esmaecer: o corte seco pegava o "é…" de quem começa.
288
+ const ce = { fundo: { tipo: 'cor', valor: PRETO }, elementos: [], transicao: { tipo: 'esmaecer', duracao: 0.4 }, duracao: d };
216
289
  const el2 = ce.elementos;
217
290
  if (deitado) {
218
291
  const wV = Math.round(H * 0.88 * 9 / 16), xV = Math.round(W * 0.08), xT = xV + wV + Math.round(W * 0.05);
@@ -221,7 +294,11 @@ function montarCinema({ roteiro, largura = 1920, altura = 1080, tempos = [], nar
221
294
  pTexto(el2, `“${c.frase}”`, 0.3, d, { x: xT, larg: W - xT - M, alinhar: 'left', caixa: false, peso: 600, max: H * 0.075, linhas: 5 });
222
295
  if (c.nome) el2.push({ tipo: 'texto', texto: c.nome.toUpperCase(), fonte: F.texto, peso: 700, tamanho: Math.round(H * 0.03), cor: DEST, espacamento: 3, x: xT, y: Math.round(H * 0.8), de: 0.5 });
223
296
  } else {
224
- el2.push({ tipo: 'video', src: c.src, volume: 1, deOrigem: c.de, ateOrigem: c.ate + 0.3, x: 0, y: 0, largura: '100%', altura: '100%', ajuste: 'cover', foco: 'center 30%' });
297
+ /* O vídeo do aluno vem com a legenda gravada embaixo (~73% da
298
+ altura): aproximado pelo topo, ela sai do quadro e fica só a
299
+ nossa frase — antes eram dois textos um sobre o outro. */
300
+ el2.push({ tipo: 'video', src: c.src, volume: 1, deOrigem: c.de, ateOrigem: c.ate + 0.3, x: 0, y: 0, largura: '100%', altura: '100%', ajuste: 'cover', foco: 'center top',
301
+ origem: 'center top', anima: { escala: [{ t: 0, v: 1.42 }, { t: d, v: 1.48, e: 'linear' }] } });
225
302
  el2.push({ tipo: 'forma', forma: 'retangulo', x: 0, y: 0, largura: '100%', altura: '100%', fundo: { tipo: 'gradiente', cores: ['rgba(7,7,10,0)', 'rgba(7,7,10,0.35)', 'rgba(7,7,10,0.9)'], angulo: 180 } });
226
303
  // Tudo acima de 75% da altura: embaixo disso o Instagram põe a legenda e os botões.
227
304
  pTexto(el2, `“${c.frase}”`, 0.3, d, { y: H * 0.54, caixa: false, peso: 600, max: W * 0.08, linhas: 4 });
@@ -701,11 +701,21 @@ function alinharPartes(lista, palavras, total) {
701
701
  for (let i = 1; i < lista.length; i++) {
702
702
  acum += chars[i - 1];
703
703
  const esperado = Math.round(ws.length * acum / soma);
704
- const alvo = tokens(lista[i].texto).find((w) => w.length >= 3) || tokens(lista[i].texto)[0];
704
+ /* As DUAS primeiras palavras da parte, seguidas: achar só pela primeira
705
+ palavra longa deixava o "E" de "E sair com algo" no fim da fala
706
+ anterior, e ele soava duas vezes (medido em 25/09, aos 38 s). */
707
+ const tk = tokens(lista[i].texto);
705
708
  let achado = -1;
706
- // Procura perto de onde a proporção diz que a parte começa.
707
- for (let j = Math.max(cursor + 1, esperado - 12); j < Math.min(ws.length, esperado + 12); j++) {
708
- if (ws[j].k === alvo) { achado = j; break; }
709
+ for (let j = Math.max(cursor + 1, esperado - 14); j < Math.min(ws.length, esperado + 14); j++) {
710
+ if (ws[j].k === tk[0] && (!tk[1] || (ws[j + 1] && ws[j + 1].k === tk[1]))) { achado = j; break; }
711
+ }
712
+ if (achado < 0) {
713
+ const alvo = tk.find((w) => w.length >= 3) || tk[0];
714
+ for (let j = Math.max(cursor + 1, esperado - 12); j < Math.min(ws.length, esperado + 12); j++) {
715
+ if (ws[j].k === alvo) { achado = j; break; }
716
+ }
717
+ // A palavra curta que vem antes da achada ("e", "a", "o") é desta parte, não da anterior.
718
+ while (achado > cursor + 1 && ws[achado - 1].k.length <= 2 && tk.slice(0, 3).includes(ws[achado - 1].k)) achado--;
709
719
  }
710
720
  const idx = achado > cursor ? achado : Math.max(cursor + 1, Math.min(ws.length - 1, esperado));
711
721
  inicios.push(idx);
@@ -751,6 +761,79 @@ async function narrarComGemini(lista, mp3, { voz, direcao } = {}) {
751
761
  }
752
762
  }
753
763
 
764
+ /* Onde estão os rostos em cada foto (studio/rostos.py, pelo uv). Guardado no
765
+ projeto: a mesma foto não é analisada duas vezes. */
766
+ async function acharRostos(p, srcs) {
767
+ const guardado = (p.acervo && p.acervo.rostos) || {};
768
+ const falta = srcs.filter((s0) => !(s0 in guardado));
769
+ if (falta.length) {
770
+ const uv = acharUv();
771
+ const r = await new Promise((resolve) => {
772
+ const args = ['run', '--quiet', '--python', '3.12', '--with', 'mediapipe<0.10.22', '--with', 'opencv-python', '--with', 'numpy', 'python', path.join(PY, 'rostos.py'), ...falta.map((x) => path.join(p.pasta, x))];
773
+ const pr = uv ? spawn(uv, args) : spawn(python() || 'python3', [path.join(PY, 'rostos.py'), ...falta.map((x) => path.join(p.pasta, x))]);
774
+ let out = '';
775
+ pr.stdout.on('data', (d) => { out += d; });
776
+ pr.on('close', () => { try { resolve(JSON.parse(out.trim().split('\n').pop())); } catch { resolve({ ok: false }); } });
777
+ pr.on('error', () => resolve({ ok: false }));
778
+ });
779
+ if (r && r.ok) {
780
+ for (const [cheio, info] of Object.entries(r.fotos || {})) guardado[path.relative(p.pasta, cheio).replace(/\\/g, '/')] = info;
781
+ for (const x of falta) if (!(x in guardado)) guardado[x] = null;
782
+ projeto.salvar(p.id, { acervo: { ...(projeto.ler(p.id).acervo || {}), rostos: guardado } });
783
+ }
784
+ }
785
+ return guardado;
786
+ }
787
+
788
+ /* O DEPOIMENTO cortado na PALAVRA, não no trecho: começa na primeira palavra
789
+ da frase e termina logo depois da última, com fade, a voz normalizada e um
790
+ respiro no fim. "Fica sempre um 'é', um 'e' das pessoas… a música muito
791
+ alta e não dá pra ouvir as pessoas" (25/09). */
792
+ async function prepararDepoimento(p, c) {
793
+ const origem = path.join(p.pasta, c.src);
794
+ if (!fs.existsSync(origem) || c.pronto) return {};
795
+ let de = c.de, ate = c.ate;
796
+ try {
797
+ const cfg = require('../config').loadConfig();
798
+ const amostra = path.join(p.pasta, 'midia', `depo-amostra-${Date.now().toString(36)}.mp3`);
799
+ await video.rodar(video.ffmpeg(), ['-y', '-ss', String(Math.max(0, c.de - 1.5)), '-t', String(c.ate - c.de + 3), '-i', origem, '-ac', '1', '-ar', '16000', '-b:a', '32k', amostra], { timeout: 60000 });
800
+ const tr = await require('../api').requestTranscricao(cfg.server, { token: cfg.token, audio: fs.readFileSync(amostra).toString('base64'), nome: 'd.mp3', idioma: 'pt' });
801
+ try { fs.unlinkSync(amostra); } catch { /* fica */ }
802
+ const base0 = Math.max(0, c.de - 1.5);
803
+ const ws = ((tr && tr.palavras) || []).map((w) => ({ ...w, de: w.de + base0, ate: w.ate + base0 }));
804
+ /* Só a FRASE de efeito: começa na primeira palavra dela e termina na
805
+ última. "Fala a frase que tem que falar e continua antes de trocar"
806
+ (25/09): o resto da conversa não tem nada a ver com o vídeo. */
807
+ const alvo = tokens(c.frase || '');
808
+ const k0 = (w) => tokens(w.texto)[0] || '';
809
+ const iniW = ws.find((w) => w.de >= c.de - 1.2 && alvo.length && k0(w) === alvo[0])
810
+ || ws.find((w) => w.de >= c.de - 0.6 && alvo.includes(k0(w)));
811
+ const ultimo = alvo[alvo.length - 1];
812
+ const fimW = [...ws].reverse().find((w) => w.ate <= c.ate + 1.5 && (!iniW || w.de > iniW.de) && k0(w) === ultimo);
813
+ if (iniW) de = Math.max(0, iniW.de - 0.08);
814
+ if (fimW) ate = fimW.ate;
815
+ else { const fimWs = ws.filter((w) => w.ate <= c.ate + 0.3); if (fimWs.length) ate = fimWs[fimWs.length - 1].ate; }
816
+ } catch { /* fica o trecho pedido */ }
817
+ const dur = ate - de + 0.45; // um respiro curto depois da última palavra
818
+ const saida = path.join(p.pasta, 'midia', `depo-${path.basename(c.src, path.extname(c.src))}-${Math.round(de)}.mp4`);
819
+ const r = await video.rodar(video.ffmpeg(), ['-y', '-ss', String(de), '-t', String(dur), '-i', origem,
820
+ '-af', `loudnorm=I=-14:TP=-1.5:LRA=9,afade=t=in:d=0.1,afade=t=out:st=${Math.max(0, dur - 0.4).toFixed(2)}:d=0.4`,
821
+ '-c:v', 'libx264', '-preset', 'veryfast', '-crf', '18', '-c:a', 'aac', '-b:a', '192k', saida], { timeout: 5 * 60000 });
822
+ if (!r.ok) return {};
823
+ return { src: path.relative(p.pasta, saida).replace(/\\/g, '/'), de: 0, ate: Math.round((dur - 0.3) * 100) / 100, pronto: true, origem: { src: c.src, de: c.de, ate: c.ate } };
824
+ }
825
+
826
+ /* A narração com cache (a mesma fala, na mesma direção, não é gerada duas vezes). */
827
+ async function narrarComCache(lista, mp3, { voz, direcao } = {}) {
828
+ const chave = require('crypto').createHash('sha1').update(JSON.stringify([lista.map((x) => x.texto), voz || '', direcao || ''])).digest('hex').slice(0, 16);
829
+ const dir = path.join(os.homedir(), '.primocode', 'studio', 'cache-voz');
830
+ const cm = path.join(dir, chave + '.mp3'), cj = path.join(dir, chave + '.json');
831
+ if (fs.existsSync(cm) && fs.existsSync(cj)) { fs.copyFileSync(cm, mp3); return { ok: true, ...JSON.parse(fs.readFileSync(cj, 'utf8')) }; }
832
+ const v = await narrarComGemini(lista, mp3, { voz, direcao });
833
+ 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 */ } }
834
+ return v;
835
+ }
836
+
754
837
  /* A MÚSICA TEM LETRA? O acervo não diz se a faixa é instrumental, então ela
755
838
  é ouvida: 25 s do meio vão para a transcrição, e mais de 6 palavras é voz
756
839
  cantada. Sem servidor para ouvir, a faixa passa (melhor música que nenhuma). */
@@ -879,6 +962,17 @@ async function prepararRoteiro(p, args) {
879
962
  if (fs.existsSync(cacheMp3) && fs.existsSync(cacheJson)) {
880
963
  fs.copyFileSync(cacheMp3, mp3);
881
964
  v = { ok: true, ...JSON.parse(fs.readFileSync(cacheJson, 'utf8')), doCache: true };
965
+ // Cache sem as palavras (vindo de um plano antigo): transcreve de novo e realinha — sem gastar voz.
966
+ if (!(v.partes || []).some((pt) => (pt.palavras || []).length)) {
967
+ try {
968
+ const cfg = require('../config').loadConfig();
969
+ const tr = await require('../api').requestTranscricao(cfg.server, { token: cfg.token, audio: fs.readFileSync(mp3).toString('base64'), nome: 'voz.mp3', idioma: 'pt' });
970
+ if (tr && (tr.palavras || []).length) {
971
+ v.partes = alinharPartes(lista, tr.palavras, v.segundos);
972
+ fs.writeFileSync(cacheJson, JSON.stringify({ voz: v.voz, segundos: v.segundos, partes: v.partes }));
973
+ }
974
+ } catch { /* fica o alinhamento guardado */ }
975
+ }
882
976
  } else {
883
977
  v = await narrarComGemini(lista, mp3, { voz: args.voz || roteiro.voz,
884
978
  direcao: 'Leia em português do Brasil como narrador de trailer de marca premium: voz grave, confiante e humana, '
@@ -930,9 +1024,47 @@ async function prepararRoteiro(p, args) {
930
1024
  }
931
1025
  } catch { /* o próximo termo */ }
932
1026
  }
933
- const fotosC = acervo.fotos.map((f) => ({ src: f.src, tipo: 'imagem' }));
1027
+ /* A MÚSICA mais baixa na origem: a faixa vem masterizada alta e comprimida,
1028
+ e mesmo a 30% ela cobria a voz e o depoimento ("a música tá muito
1029
+ alta, não dá pra ouvir as pessoas", 25/09). Ela vai a -28 LUFS antes
1030
+ da mistura; os volumes do plano partem daí. */
1031
+ if (trilhaC) {
1032
+ const norm = path.join(p.pasta, 'midia', 'trilha-baixa-' + path.basename(trilhaC).replace(/\.[a-z0-9]+$/i, '.mp3'));
1033
+ const c = await video.rodar(video.ffmpeg(), ['-y', '-i', path.join(p.pasta, trilhaC), '-af', 'loudnorm=I=-28:TP=-6:LRA=11', '-ar', '44100', norm], { timeout: 5 * 60000 });
1034
+ if (c.ok) trilhaC = path.relative(p.pasta, norm).replace(/\\/g, '/');
1035
+ }
1036
+ /* AS FOTOS, separadas e enquadradas. "Uma sala, 20 cadeiras" com foto de
1037
+ dashboard "não tem nada a ver" (25/09): foto de gente e print de tela
1038
+ são coisas diferentes, e a miniatura de depoimento fica de fora. E
1039
+ onde estão os rostos, para o recorte vertical não cortar ninguém. */
1040
+ const TELA = /dashboard|pauta|kpi|tend[eê]ncias|landing|review|tela|screen|painel|relat[oó]rio/i;
1041
+ const MINI = /poster|face \d+|\bthumb/i;
1042
+ const fotosC = acervo.fotos.filter((f) => !MINI.test(f.alt || ''))
1043
+ .map((f) => ({ src: f.src, tipo: 'imagem', cat: TELA.test(f.alt || '') ? 'tela' : 'pessoas', alt: f.alt }));
1044
+ const rostos = await acharRostos(p, fotosC.map((f) => f.src));
1045
+ for (const f of fotosC) if (rostos[f.src]) f.rosto = rostos[f.src];
1046
+ // Os depoimentos: cortados na palavra, com a voz no volume certo e fade.
1047
+ for (const pt of partes) {
1048
+ if (pt.tipo !== 'depoimento') continue;
1049
+ for (const c of pt.clipes || []) Object.assign(c, await prepararDepoimento(p, c));
1050
+ }
1051
+ // A voz FORTE: a parte com "falaForte" é narrada à parte, com a direção dela.
1052
+ const fortes = {};
1053
+ let iNarr = 0;
1054
+ for (const [i, pt] of partes.entries()) {
1055
+ if (pt.tipo === 'depoimento') continue;
1056
+ if (pt.falaForte) {
1057
+ const arqF = path.join(p.pasta, 'midia', `voz-forte-${i + 1}-${Date.now().toString(36)}.mp3`);
1058
+ const vf = await narrarComCache([{ texto: pt.falaForte }], arqF, { voz: args.voz || roteiro.voz,
1059
+ // A MESMA voz do narrador, só com um pouco mais de ênfase: "não é mudar a voz, é só um pouco a entonação" (25/09).
1060
+ direcao: pt.entonacao || 'Leia em português do Brasil com a mesma voz e o mesmo ritmo de um narrador de trailer de marca: '
1061
+ + 'confiante e natural, só um pouco mais de ênfase nas palavras principais. Sem dramatizar.' });
1062
+ if (vf.ok) fortes[iNarr] = { src: path.relative(p.pasta, arqF).replace(/\\/g, '/'), segundos: vf.segundos, palavras: (vf.partes[0] || {}).palavras || [] };
1063
+ }
1064
+ iNarr++;
1065
+ }
934
1066
  const r = cinema.montarCinema({ roteiro, largura, altura, tempos: v.partes, narracao: path.relative(p.pasta, mp3).replace(/\\/g, '/'),
935
- sons: sonsC, trilha: trilhaC, fotos: fotosC });
1067
+ sons: sonsC, trilha: trilhaC, fotos: fotosC, fortes });
936
1068
  if (r.ok) projeto.anotar(p.id, 'plano', `estilo cinema: ${r.resumo.cenas} cenas, ${r.resumo.segundos}s`);
937
1069
  return r;
938
1070
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "primocode",
3
- "version": "9.8.0-beta.20",
3
+ "version": "9.8.0-beta.22",
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": {
@@ -0,0 +1,52 @@
1
+ #!/usr/bin/env python3
2
+ """rostos.py — onde estão as pessoas em cada foto.
3
+
4
+ "Aparecem três homens ali, ela está cortada, não dá pra ver ninguém."
5
+ (25/09/2026)
6
+
7
+ Foto deitada num vídeo em pé perde dois terços da largura. Recortar pelo
8
+ centro corta as pessoas. Aqui se acha o retângulo que cobre todos os rostos
9
+ da foto (MediaPipe, o mesmo do recorte da pessoa), e o vídeo enquadra nele —
10
+ ou, quando os rostos não cabem no recorte, mostra a foto inteira.
11
+
12
+ Uso:
13
+ python3 rostos.py foto1.webp foto2.jpg …
14
+ Saída (JSON): {"ok": true, "fotos": {"foto1.webp": {"largura", "altura", "n",
15
+ "cx", "cy", "x0", "x1", "y0", "y1"}}} (tudo de 0 a 1)
16
+ """
17
+
18
+ import json
19
+ import sys
20
+
21
+
22
+ def main():
23
+ try:
24
+ import cv2
25
+ import mediapipe as mp
26
+ except Exception as e: # sem as bibliotecas, ninguém é enquadrado — e não é erro
27
+ print(json.dumps({"ok": False, "error": f"sem mediapipe: {e}"}))
28
+ return
29
+ det = mp.solutions.face_detection.FaceDetection(model_selection=1, min_detection_confidence=0.45)
30
+ fotos = {}
31
+ for caminho in sys.argv[1:]:
32
+ img = cv2.imread(caminho)
33
+ if img is None:
34
+ continue
35
+ h, w = img.shape[:2]
36
+ r = det.process(cv2.cvtColor(img, cv2.COLOR_BGR2RGB))
37
+ caixas = []
38
+ for d in (r.detections or []):
39
+ b = d.location_data.relative_bounding_box
40
+ caixas.append((max(0, b.xmin), max(0, b.ymin), min(1, b.xmin + b.width), min(1, b.ymin + b.height)))
41
+ item = {"largura": w, "altura": h, "n": len(caixas)}
42
+ if caixas:
43
+ x0 = min(c[0] for c in caixas); y0 = min(c[1] for c in caixas)
44
+ x1 = max(c[2] for c in caixas); y1 = max(c[3] for c in caixas)
45
+ item.update({"x0": round(x0, 3), "y0": round(y0, 3), "x1": round(x1, 3), "y1": round(y1, 3),
46
+ "cx": round((x0 + x1) / 2, 3), "cy": round((y0 + y1) / 2, 3)})
47
+ fotos[caminho] = item
48
+ print(json.dumps({"ok": True, "fotos": fotos}))
49
+
50
+
51
+ if __name__ == "__main__":
52
+ main()