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 +2 -2
- package/lib/estudio/cinema.js +98 -21
- package/lib/estudio/index.js +97 -2
- package/package.json +1 -1
- package/studio/rostos.py +52 -0
package/README.md
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
# PrimoCode v9.8.0-beta.
|
|
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.
|
|
12
|
+
▐▛███▜▌ Primo Code v9.8.0-beta.21
|
|
13
13
|
▝▜█████▛▘ Bem-vindo de volta, Joel
|
|
14
14
|
▘▘ ▝▝ ~/primocode · /dir <pasta> muda
|
|
15
15
|
```
|
package/lib/estudio/cinema.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
89
|
-
|
|
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 =
|
|
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
|
-
|
|
132
|
-
|
|
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
|
-
|
|
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
|
-
|
|
157
|
-
|
|
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
|
-
|
|
160
|
-
|
|
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) =>
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
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 });
|
package/lib/estudio/index.js
CHANGED
|
@@ -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
|
-
|
|
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.
|
|
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": {
|
package/studio/rostos.py
ADDED
|
@@ -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()
|