primocode 9.8.0-beta.20 → 9.8.0-beta.21

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.21
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.21
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 });
@@ -751,6 +751,73 @@ async function narrarComGemini(lista, mp3, { voz, direcao } = {}) {
751
751
  }
752
752
  }
753
753
 
754
+ /* Onde estão os rostos em cada foto (studio/rostos.py, pelo uv). Guardado no
755
+ projeto: a mesma foto não é analisada duas vezes. */
756
+ async function acharRostos(p, srcs) {
757
+ const guardado = (p.acervo && p.acervo.rostos) || {};
758
+ const falta = srcs.filter((s0) => !(s0 in guardado));
759
+ if (falta.length) {
760
+ const uv = acharUv();
761
+ const r = await new Promise((resolve) => {
762
+ 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))];
763
+ const pr = uv ? spawn(uv, args) : spawn(python() || 'python3', [path.join(PY, 'rostos.py'), ...falta.map((x) => path.join(p.pasta, x))]);
764
+ let out = '';
765
+ pr.stdout.on('data', (d) => { out += d; });
766
+ pr.on('close', () => { try { resolve(JSON.parse(out.trim().split('\n').pop())); } catch { resolve({ ok: false }); } });
767
+ pr.on('error', () => resolve({ ok: false }));
768
+ });
769
+ if (r && r.ok) {
770
+ for (const [cheio, info] of Object.entries(r.fotos || {})) guardado[path.relative(p.pasta, cheio).replace(/\\/g, '/')] = info;
771
+ for (const x of falta) if (!(x in guardado)) guardado[x] = null;
772
+ projeto.salvar(p.id, { acervo: { ...(projeto.ler(p.id).acervo || {}), rostos: guardado } });
773
+ }
774
+ }
775
+ return guardado;
776
+ }
777
+
778
+ /* O DEPOIMENTO cortado na PALAVRA, não no trecho: começa na primeira palavra
779
+ da frase e termina logo depois da última, com fade, a voz normalizada e um
780
+ respiro no fim. "Fica sempre um 'é', um 'e' das pessoas… a música muito
781
+ alta e não dá pra ouvir as pessoas" (25/09). */
782
+ async function prepararDepoimento(p, c) {
783
+ const origem = path.join(p.pasta, c.src);
784
+ if (!fs.existsSync(origem) || c.pronto) return {};
785
+ let de = c.de, ate = c.ate;
786
+ try {
787
+ const cfg = require('../config').loadConfig();
788
+ const amostra = path.join(p.pasta, 'midia', `depo-amostra-${Date.now().toString(36)}.mp3`);
789
+ 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 });
790
+ const tr = await require('../api').requestTranscricao(cfg.server, { token: cfg.token, audio: fs.readFileSync(amostra).toString('base64'), nome: 'd.mp3', idioma: 'pt' });
791
+ try { fs.unlinkSync(amostra); } catch { /* fica */ }
792
+ const base0 = Math.max(0, c.de - 1.5);
793
+ const ws = ((tr && tr.palavras) || []).map((w) => ({ ...w, de: w.de + base0, ate: w.ate + base0 }));
794
+ const alvo = tokens(c.frase || '');
795
+ // A primeira palavra da frase depois do começo pedido, e a última antes do fim pedido.
796
+ const iniW = ws.find((w) => w.de >= c.de - 0.6 && (!alvo.length || alvo.includes(tokens(w.texto)[0])));
797
+ const fimWs = ws.filter((w) => w.ate <= c.ate + 0.8);
798
+ if (iniW) de = Math.max(0, iniW.de - 0.08);
799
+ if (fimWs.length) ate = fimWs[fimWs.length - 1].ate;
800
+ } catch { /* fica o trecho pedido */ }
801
+ const dur = ate - de + 0.7; // o respiro depois da última palavra
802
+ const saida = path.join(p.pasta, 'midia', `depo-${path.basename(c.src, path.extname(c.src))}-${Math.round(de)}.mp4`);
803
+ const r = await video.rodar(video.ffmpeg(), ['-y', '-ss', String(de), '-t', String(dur), '-i', origem,
804
+ '-af', `loudnorm=I=-14:TP=-1.5:LRA=9,afade=t=in:d=0.12,afade=t=out:st=${Math.max(0, dur - 0.55).toFixed(2)}:d=0.55`,
805
+ '-c:v', 'libx264', '-preset', 'veryfast', '-crf', '18', '-c:a', 'aac', '-b:a', '192k', saida], { timeout: 5 * 60000 });
806
+ if (!r.ok) return {};
807
+ return { src: path.relative(p.pasta, saida).replace(/\\/g, '/'), de: 0, ate: Math.round((dur - 0.3) * 100) / 100, pronto: true };
808
+ }
809
+
810
+ /* A narração com cache (a mesma fala, na mesma direção, não é gerada duas vezes). */
811
+ async function narrarComCache(lista, mp3, { voz, direcao } = {}) {
812
+ const chave = require('crypto').createHash('sha1').update(JSON.stringify([lista.map((x) => x.texto), voz || '', direcao || ''])).digest('hex').slice(0, 16);
813
+ const dir = path.join(os.homedir(), '.primocode', 'studio', 'cache-voz');
814
+ const cm = path.join(dir, chave + '.mp3'), cj = path.join(dir, chave + '.json');
815
+ if (fs.existsSync(cm) && fs.existsSync(cj)) { fs.copyFileSync(cm, mp3); return { ok: true, ...JSON.parse(fs.readFileSync(cj, 'utf8')) }; }
816
+ const v = await narrarComGemini(lista, mp3, { voz, direcao });
817
+ 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 */ } }
818
+ return v;
819
+ }
820
+
754
821
  /* A MÚSICA TEM LETRA? O acervo não diz se a faixa é instrumental, então ela
755
822
  é ouvida: 25 s do meio vão para a transcrição, e mais de 6 palavras é voz
756
823
  cantada. Sem servidor para ouvir, a faixa passa (melhor música que nenhuma). */
@@ -930,9 +997,37 @@ async function prepararRoteiro(p, args) {
930
997
  }
931
998
  } catch { /* o próximo termo */ }
932
999
  }
933
- const fotosC = acervo.fotos.map((f) => ({ src: f.src, tipo: 'imagem' }));
1000
+ /* AS FOTOS, separadas e enquadradas. "Uma sala, 20 cadeiras" com foto de
1001
+ dashboard "não tem nada a ver" (25/09): foto de gente e print de tela
1002
+ são coisas diferentes, e a miniatura de depoimento fica de fora. E
1003
+ onde estão os rostos, para o recorte vertical não cortar ninguém. */
1004
+ const TELA = /dashboard|pauta|kpi|tend[eê]ncias|landing|review|tela|screen|painel|relat[oó]rio/i;
1005
+ const MINI = /poster|face \d+|\bthumb/i;
1006
+ const fotosC = acervo.fotos.filter((f) => !MINI.test(f.alt || ''))
1007
+ .map((f) => ({ src: f.src, tipo: 'imagem', cat: TELA.test(f.alt || '') ? 'tela' : 'pessoas', alt: f.alt }));
1008
+ const rostos = await acharRostos(p, fotosC.map((f) => f.src));
1009
+ for (const f of fotosC) if (rostos[f.src]) f.rosto = rostos[f.src];
1010
+ // Os depoimentos: cortados na palavra, com a voz no volume certo e fade.
1011
+ for (const pt of partes) {
1012
+ if (pt.tipo !== 'depoimento') continue;
1013
+ for (const c of pt.clipes || []) Object.assign(c, await prepararDepoimento(p, c));
1014
+ }
1015
+ // A voz FORTE: a parte com "falaForte" é narrada à parte, com a direção dela.
1016
+ const fortes = {};
1017
+ let iNarr = 0;
1018
+ for (const [i, pt] of partes.entries()) {
1019
+ if (pt.tipo === 'depoimento') continue;
1020
+ if (pt.falaForte) {
1021
+ const arqF = path.join(p.pasta, 'midia', `voz-forte-${i + 1}-${Date.now().toString(36)}.mp3`);
1022
+ const vf = await narrarComCache([{ texto: pt.falaForte }], arqF, { voz: args.voz || roteiro.voz,
1023
+ direcao: pt.entonacao || 'Leia em português do Brasil como a VIRADA de um trailer: voz grave, intensa e muito firme, '
1024
+ + 'com peso em cada palavra, pausa dramática no meio, sem gritar.' });
1025
+ if (vf.ok) fortes[iNarr] = { src: path.relative(p.pasta, arqF).replace(/\\/g, '/'), segundos: vf.segundos, palavras: (vf.partes[0] || {}).palavras || [] };
1026
+ }
1027
+ iNarr++;
1028
+ }
934
1029
  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 });
1030
+ sons: sonsC, trilha: trilhaC, fotos: fotosC, fortes });
936
1031
  if (r.ok) projeto.anotar(p.id, 'plano', `estilo cinema: ${r.resumo.cenas} cenas, ${r.resumo.segundos}s`);
937
1032
  return r;
938
1033
  }
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.21",
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()