primocode 10.0.9 → 10.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (46) hide show
  1. package/README.md +4 -4
  2. package/bin/primocode.js +25 -28
  3. package/lib/act.js +5 -2
  4. package/lib/catalogo.js +7 -4
  5. package/lib/config.js +10 -2
  6. package/lib/estudio/assets.js +86 -6
  7. package/lib/estudio/batida.js +184 -0
  8. package/lib/estudio/biblioteca.js +214 -0
  9. package/lib/estudio/cinema.js +37 -17
  10. package/lib/estudio/composicao.js +2 -1
  11. package/lib/estudio/decisao.js +106 -0
  12. package/lib/estudio/edicao.js +36 -17
  13. package/lib/estudio/estilo.js +35 -8
  14. package/lib/estudio/fluido.js +352 -0
  15. package/lib/estudio/gerativo.js +21 -3
  16. package/lib/estudio/hibrido.js +16 -12
  17. package/lib/estudio/impacto.js +66 -31
  18. package/lib/estudio/index.js +245 -42
  19. package/lib/estudio/kit/fluido.jsx +614 -0
  20. package/lib/estudio/kit/gerativo.jsx +15 -5
  21. package/lib/estudio/kit/index.jsx +2 -1
  22. package/lib/estudio/kit/tres.jsx +88 -21
  23. package/lib/estudio/kit.js +3 -1
  24. package/lib/estudio/kokoro.js +111 -0
  25. package/lib/estudio/lancamento.js +3 -4
  26. package/lib/estudio/legibilidade.js +20 -0
  27. package/lib/estudio/referencias.js +36 -25
  28. package/lib/estudio/remotion.js +53 -6
  29. package/lib/estudio/revisor.js +117 -0
  30. package/lib/estudio/variacao3d.js +46 -0
  31. package/lib/estudio/variedade.js +140 -8
  32. package/lib/portao-skill.js +150 -0
  33. package/lib/skills.js +15 -1
  34. package/lib/tools.js +31 -5
  35. package/lib/video/filters.js +6 -1
  36. package/lib/video/ops.js +4 -3
  37. package/lib/video/timeline.js +1 -1
  38. package/lib/video/zoom.js +275 -0
  39. package/package.json +2 -2
  40. package/skills/escrever-em-portugues/SKILL.md +3 -0
  41. package/skills/interface-revisada/SKILL.md +7 -1
  42. package/skills/motion-de-primeira/SKILL.md +3 -5
  43. package/skills/projeto-no-github/SKILL.md +6 -0
  44. package/skills/publicar-na-internet/SKILL.md +3 -0
  45. package/skills/site-com-motion/SKILL.md +2 -0
  46. package/studio/voz_kokoro.py +98 -0
@@ -0,0 +1,275 @@
1
+ /**
2
+ * zoom.js — o zoom que segue o cursor numa gravação de tela.
3
+ *
4
+ * Três peças, como conta pura e testável sem vídeo nenhum:
5
+ *
6
+ * · os FOCOS: o clique, quando há registro dele; sem clique, o lugar onde
7
+ * o movimento PARA (o cursor anda, assenta e alguém vai clicar ali);
8
+ * · os TRECHOS: focos próximos no tempo viram um zoom só; uma pausa longa
9
+ * ou um salto para longe (com tempo de sobra) fecham o zoom e abrem outro;
10
+ * · a CÂMERA: dentro do trecho ela fica parada enquanto o cursor anda numa
11
+ * zona morta no meio da vista, e só anda quando ele sai dali — puxada por
12
+ * uma mola criticamente amortecida, sem tremer e sem passar do ponto.
13
+ *
14
+ * Reimplementação independente da ideia (curva, regra de clique e mola):
15
+ * nenhuma linha vem de outro projeto.
16
+ *
17
+ * Saída: keyframes de zoom e do FOCO do zoom (fx, fy: 0–1 do clipe), que o
18
+ * filters.js já sabe desenhar no zoompan.
19
+ */
20
+
21
+ 'use strict';
22
+
23
+ const { spawn } = require('child_process');
24
+
25
+ const lim = (v, a, b) => Math.max(a, Math.min(b, v));
26
+
27
+ // ── medir: onde a tela mexeu (quando ninguém gravou o cursor) ────────────
28
+
29
+ /**
30
+ * Quadros cinza (Uint8Array w*h, um por amostra) → [{t, x, y, move}].
31
+ * x,y = centro do que mudou desde o quadro anterior (o cursor, numa tela
32
+ * parada). Mudança grande demais (rolagem, troca de janela) não é cursor.
33
+ */
34
+ function movimento(quadros, w, h, fps, { limiar = 24, maxFracao = 0.2 } = {}) {
35
+ const out = [];
36
+ for (let i = 1; i < quadros.length; i++) {
37
+ const a = quadros[i - 1];
38
+ const b = quadros[i];
39
+ let n = 0;
40
+ let sx = 0;
41
+ let sy = 0;
42
+ for (let k = 0; k < w * h; k++) {
43
+ if (Math.abs(a[k] - b[k]) > limiar) { n++; sx += k % w; sy += Math.floor(k / w); }
44
+ }
45
+ const t = i / fps;
46
+ if (n < 2 || n > maxFracao * w * h) out.push({ t, x: null, y: null, move: false });
47
+ else out.push({ t, x: (sx / n + 0.5) / w, y: (sy / n + 0.5) / h, move: true });
48
+ }
49
+ return out;
50
+ }
51
+
52
+ /** Trilha de cursor [{t,x,y,click?}] → amostras com `move` (andou desde a anterior). */
53
+ function doCursor(cursor, { parado = 0.004 } = {}) {
54
+ const c = cursor.filter((p) => Number.isFinite(p.t) && Number.isFinite(p.x) && Number.isFinite(p.y)).sort((a, b) => a.t - b.t);
55
+ return c.map((p, i) => ({ ...p, move: i > 0 && Math.hypot(p.x - c[i - 1].x, p.y - c[i - 1].y) > parado }));
56
+ }
57
+
58
+ /**
59
+ * Os focos: os cliques, se houver; senão, onde o movimento para — andou por
60
+ * `minAndou` s e depois ficou parado por `minParado` s. [{t, x, y}].
61
+ */
62
+ function focos(amostras, { minAndou = 0.15, minParado = 0.3 } = {}) {
63
+ const cliques = amostras.filter((a) => a.click);
64
+ if (cliques.length) return cliques.map(({ t, x, y }) => ({ t, x, y }));
65
+ const out = [];
66
+ let ini = null;
67
+ let ult = null;
68
+ for (const a of amostras) {
69
+ if (a.move) { if (ini === null) ini = a.t; ult = a; continue; }
70
+ if (ult && ini !== null && ult.t - ini >= minAndou - 1e-9 && a.t - ult.t >= minParado - 1e-9) {
71
+ out.push({ t: ult.t, x: ult.x, y: ult.y });
72
+ ini = null;
73
+ ult = null;
74
+ }
75
+ }
76
+ return out;
77
+ }
78
+
79
+ // ── os trechos e a câmera ────────────────────────────────────────────────
80
+
81
+ /**
82
+ * Focos (t em QUADROS do clipe) → trechos [{de, ate, focos}]. Fecha o trecho
83
+ * na pausa maior que `juntar` ou no salto maior que `longe` quando há tempo
84
+ * de sair e voltar (2 rampas); salto rápido fica no mesmo trecho e vira pan.
85
+ */
86
+ function trechos(fs, { fps, juntar = 2.5, longe = 0.35, antes = 0.4, segura = 1.2, rampa = 0.5 } = {}) {
87
+ const out = [];
88
+ const R = rampa * fps;
89
+ for (const f of [...fs].sort((a, b) => a.t - b.t)) {
90
+ const u = out[out.length - 1];
91
+ const ult = u && u.focos[u.focos.length - 1];
92
+ const gap = ult ? f.t - ult.t : Infinity;
93
+ if (u && gap <= juntar * fps && !(Math.hypot(f.x - ult.x, f.y - ult.y) > longe && gap > 2 * R + segura * fps)) { u.focos.push(f); continue; }
94
+ out.push({ focos: [f] });
95
+ }
96
+ for (const s of out) {
97
+ s.de = Math.max(0, Math.round(s.focos[0].t - antes * fps - R));
98
+ s.ate = Math.round(s.focos[s.focos.length - 1].t + segura * fps);
99
+ }
100
+ // Um trecho não invade a saída do anterior.
101
+ for (let i = 1; i < out.length; i++) out[i - 1].ate = Math.max(out[i - 1].focos[out[i - 1].focos.length - 1].t, Math.min(out[i - 1].ate, out[i].de - R));
102
+ return out;
103
+ }
104
+
105
+ /**
106
+ * A câmera, quadro a quadro: [{z, x, y}] para `dur` quadros. `pos(f)` = onde
107
+ * o cursor está no quadro f (ou null). Zona morta = fração da meia-vista.
108
+ */
109
+ function camera(dur, ts, pos, { fps, zoom = 1.8, rampa = 0.5, zonaMorta = 0.45, hz = 1.4 } = {}) {
110
+ const R = Math.max(1, Math.round(rampa * fps));
111
+ const out = [];
112
+ const w = 2 * Math.PI * hz;
113
+ const dt = 1 / fps;
114
+ let cam = { x: 0.5, y: 0.5 };
115
+ let vel = { x: 0, y: 0 };
116
+ let alvo = { ...cam };
117
+ let s = null;
118
+ for (let f = 0; f < dur; f++) {
119
+ const novo = ts.find((t) => f >= t.de && f < t.ate + R);
120
+ if (novo !== s) {
121
+ s = novo;
122
+ if (s) { cam = { x: s.focos[0].x, y: s.focos[0].y }; alvo = { ...cam }; vel = { x: 0, y: 0 }; }
123
+ }
124
+ let z = 1;
125
+ if (s) {
126
+ const p = f < s.de + R ? (f - s.de) / R : f >= s.ate ? 1 - (f - s.ate) / R : 1;
127
+ z = 1 + (zoom - 1) * p * p * (3 - 2 * p);
128
+ // Segue o cursor só entre o primeiro e o último foco: antes, mira o foco; depois, segura.
129
+ const c = f >= s.focos[0].t && f <= s.focos[s.focos.length - 1].t ? pos(f) : null;
130
+ const meia = 0.5 / zoom;
131
+ if (c) {
132
+ for (const k of ['x', 'y']) {
133
+ const d = c[k] - alvo[k];
134
+ const zm = zonaMorta * meia;
135
+ if (Math.abs(d) > zm) alvo[k] = c[k] - Math.sign(d) * zm;
136
+ alvo[k] = lim(alvo[k], meia, 1 - meia);
137
+ }
138
+ }
139
+ for (const k of ['x', 'y']) {
140
+ vel[k] += (w * w * (alvo[k] - cam[k]) - 2 * w * vel[k]) * dt; // mola crítica: chega sem passar
141
+ cam[k] += vel[k] * dt;
142
+ }
143
+ }
144
+ const meiaZ = 0.5 / z;
145
+ out.push({ z, x: lim(cam.x, meiaZ, 1 - meiaZ), y: lim(cam.y, meiaZ, 1 - meiaZ) });
146
+ }
147
+ return out;
148
+ }
149
+
150
+ /** Ramer–Douglas–Peucker numa série: os quadros que bastam para a reta não errar mais que `tol`. */
151
+ function enxugar(v, tol) {
152
+ const manter = new Set([0, v.length - 1]);
153
+ const pilha = [[0, v.length - 1]];
154
+ while (pilha.length) {
155
+ const [a, b] = pilha.pop();
156
+ let pior = -1;
157
+ let max = tol;
158
+ for (let i = a + 1; i < b; i++) {
159
+ const d = Math.abs(v[i] - (v[a] + ((v[b] - v[a]) * (i - a)) / (b - a)));
160
+ if (d > max) { max = d; pior = i; }
161
+ }
162
+ if (pior >= 0) { manter.add(pior); pilha.push([a, pior], [pior, b]); }
163
+ }
164
+ return [...manter].sort((x, y) => x - y);
165
+ }
166
+
167
+ /** Câmera → keyframes {zoom, fx, fy} em quadros do clipe ({frame, value, interp}). */
168
+ function keyframes(cam, { tol = 0.002 } = {}) {
169
+ const out = {};
170
+ for (const [prop, k] of [['zoom', 'z'], ['fx', 'x'], ['fy', 'y']]) {
171
+ const v = cam.map((c) => c[k]);
172
+ out[prop] = enxugar(v, prop === 'zoom' ? 0.01 : tol).map((f) => ({ frame: f, value: Math.round(v[f] * 10000) / 10000, interp: 'linear' }));
173
+ }
174
+ return out;
175
+ }
176
+
177
+ /**
178
+ * Tudo junto. `amostras` com t em QUADROS do clipe e x,y em 0–1 do clipe.
179
+ * Devolve {focos, trechos, cam, keys}.
180
+ */
181
+ function planejar(amostras, dur, opts) {
182
+ const fs = focos(amostras.map((a) => ({ ...a, t: a.t / opts.fps })), opts).map((f) => ({ ...f, t: Math.round(f.t * opts.fps) }));
183
+ const ts = trechos(fs, opts);
184
+ const comPos = amostras.filter((a) => a.x !== null && a.x !== undefined);
185
+ let i = 0;
186
+ const pos = (f) => { // a última posição conhecida até f (f só cresce)
187
+ while (i + 1 < comPos.length && comPos[i + 1].t <= f) i++;
188
+ return comPos[i] && comPos[i].t <= f ? comPos[i] : null;
189
+ };
190
+ const cam = camera(dur, ts, pos, opts);
191
+ return { focos: fs, trechos: ts, cam, keys: keyframes(cam) };
192
+ }
193
+
194
+ // ── o ffmpeg mede ────────────────────────────────────────────────────────
195
+
196
+ /** Quadros cinza pequenos da origem [de, ate) segundos, a `fps` por segundo. */
197
+ function quadrosCinza(arquivo, de, ate, { fps = 15, w = 160, h = 90 } = {}) {
198
+ const video = require('../estudio/video.js');
199
+ return new Promise((resolve, reject) => {
200
+ const p = spawn(video.ffmpeg(), ['-hide_banner', '-loglevel', 'error', '-ss', String(de), '-t', String(ate - de), '-i', arquivo,
201
+ '-vf', `fps=${fps},scale=${w}:${h}:flags=area,format=gray`, '-f', 'rawvideo', '-']);
202
+ const partes = [];
203
+ p.stdout.on('data', (d) => partes.push(d));
204
+ p.on('error', reject);
205
+ p.on('close', (code) => {
206
+ if (code !== 0) return reject(new Error('não consegui ler os quadros'));
207
+ const b = Buffer.concat(partes);
208
+ const out = [];
209
+ for (let k = 0; k + w * h <= b.length; k += w * h) out.push(b.subarray(k, k + w * h));
210
+ resolve(out);
211
+ });
212
+ });
213
+ }
214
+
215
+ // ── a operação do editor ─────────────────────────────────────────────────
216
+
217
+ /** Ponto da ORIGEM (0–1) → ponto do clipe (0–1), depois do encaixe (cover/contain). */
218
+ function paraClipe(tl, clip, asset, x, y) {
219
+ if (clip.fit === 'stretch') return { x, y };
220
+ const bw = clip.transform.w * tl.width;
221
+ const bh = clip.transform.h * tl.height;
222
+ const aw = asset.width || bw;
223
+ const ah = asset.height || bh;
224
+ const esc = clip.fit === 'contain' ? Math.min(bw / aw, bh / ah) : Math.max(bw / aw, bh / ah);
225
+ return { x: lim(0.5 + ((x - 0.5) * aw * esc) / bw, 0, 1), y: lim(0.5 + ((y - 0.5) * ah * esc) / bh, 0, 1) };
226
+ }
227
+
228
+ const OPS = {
229
+ auto_zoom: {
230
+ uso: 'auto_zoom {id?,zoom?:1.8,cursor?:[{t(s da origem),x,y(0–1),click?}]|cursorFile?,deadZone?:0.45,remove?} — gravação de tela: entra no clique (sem cursor: onde o movimento para), segue com zona morta e mola, sai na pausa',
231
+ async run(ctx, a) {
232
+ const T = require('./timeline.js');
233
+ const E = require('./edits.js');
234
+ const { mutar } = require('./ops.js');
235
+ if (!ctx.tl) throw T.erro('nenhum projeto aberto: comece com import {"path":"..."}');
236
+ const primeiro = T.todosOsClipes(ctx.tl).find((x) => x.clip.type === 'video');
237
+ if (!a.id && !primeiro) throw T.erro('não há clipe de vídeo');
238
+ const { clip } = T.achar(ctx.tl, a.id || primeiro.clip.id);
239
+ if (a.remove) return mutar(ctx, 'auto_zoom', (tl) => { for (const p of ['zoom', 'fx', 'fy']) E.keyframes(tl, clip.id, p, []); });
240
+ const asset = ctx.tl.assets[clip.asset];
241
+ const fps = ctx.tl.fps;
242
+ const { de, ate } = T.janelaOrigem(ctx.tl, clip);
243
+ let cru;
244
+ let fonte;
245
+ if (a.cursor || a.cursorFile) {
246
+ cru = doCursor(a.cursor || JSON.parse(require('fs').readFileSync(a.cursorFile, 'utf8')));
247
+ fonte = cru.some((x) => x.click) ? 'cliques' : 'cursor';
248
+ } else {
249
+ const mfps = 15;
250
+ const h = 2 * Math.round((80 * (asset.height || 9)) / (asset.width || 16));
251
+ cru = movimento(await quadrosCinza(asset.path, de, ate, { fps: mfps, w: 160, h }), 160, h, mfps).map((x) => ({ ...x, t: x.t + de }));
252
+ fonte = 'movimento';
253
+ }
254
+ const amostras = [];
255
+ for (const x of cru) {
256
+ const f = T.origemParaQuadro(ctx.tl, clip, x.t);
257
+ if (f === null) continue;
258
+ const p = x.x === null ? { x: null, y: null } : paraClipe(ctx.tl, clip, asset, x.x, x.y);
259
+ amostras.push({ ...x, ...p, t: f - clip.startFrame });
260
+ }
261
+ const pl = planejar(amostras, clip.durationFrames, { fps, zoom: lim(Number(a.zoom) || 1.8, 1.1, 3), zonaMorta: a.deadZone ?? 0.45 });
262
+ const zooms = pl.trechos.map((t) => [t.de + clip.startFrame, t.ate + clip.startFrame]);
263
+ if (!pl.focos.length) return { ok: true, nada: true, fonte, focos: 0, aviso: 'nenhum clique nem parada do cursor: nada para aproximar' };
264
+ return mutar(ctx, 'auto_zoom', (tl) => {
265
+ for (const [p, ks] of Object.entries(pl.keys)) E.keyframes(tl, clip.id, p, ks.map((k) => ({ ...k, frame: k.frame + clip.startFrame })));
266
+ return { fonte, focos: pl.focos.length, zooms };
267
+ });
268
+ },
269
+ },
270
+ };
271
+
272
+ // Sem linha de exemplo: a ajuda principal cabe em 60 linhas, e o `uso` já mostra a forma.
273
+ const EX = {};
274
+
275
+ module.exports = { movimento, doCursor, focos, trechos, camera, enxugar, keyframes, planejar, quadrosCinza, paraClipe, OPS, EX };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "primocode",
3
- "version": "10.0.9",
3
+ "version": "10.2.0",
4
4
  "description": "PrimoCode — agente de engenharia com IA e cursor próprio. Requer conta Conecta Primo AI (Premium ou Super). Cria arquivos, roda comandos, controla navegador e desktop: abre apps, clica em botões e ícones pelo nome, digita e usa atalhos.",
5
5
  "main": "bin/primocode.js",
6
6
  "bin": {
@@ -8,7 +8,7 @@
8
8
  },
9
9
  "scripts": {
10
10
  "start": "node bin/primocode.js",
11
- "test": "node test/tools.test.js && node test/ui.test.js && node test/entrada.test.js && node test/transparencia.test.js && node test/permissao.test.js && node test/compatibilidade.test.js && node test/skills.test.js && node test/skills-de-fora.test.js && node test/skills-novas.test.js && node test/comandos.test.js && node test/estudio.test.js && node test/voz.test.js && node test/modo.test.js && node test/pasta.test.js && node test/projeto.test.js && node test/nome-projeto.test.js && node test/api-propria.test.js && node test/claude-engine.test.js && node test/codex.test.js && node test/conferir.test.js && node test/catalogo.test.js && node test/desktop.test.js && node test/seta.test.js && node test/effort.test.js && node test/regressao.test.js && node test/memoria-conversa.test.js && node test/repeticao.test.js && node test/casar.test.js && node test/recado.test.js && node test/releitura.test.js && node test/snapshot.test.js && node test/video.test.js && node test/limpeza.test.js && node test/autonomia.test.js && node test/desfazer.test.js && node test/desfazer-git.test.js && node test/referencia.test.js && node test/parar.test.js && node test/continuar.test.js && node test/pipeline.test.js && node test/conta.test.mjs && node test/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/hibrido.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"
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/hibrido.test.js && node test/batida.test.js && node test/revisor.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"
12
12
  },
13
13
  "engines": {
14
14
  "node": ">=18.17.0"
@@ -25,4 +25,7 @@ superlativo sem número.
25
25
  Número concreto vence adjetivo: "responde em 2 segundos" vale mais que
26
26
  "ultrarrápido".
27
27
 
28
+ **Revisando um texto pronto**: aponte o trecho que soa errado e reescreva só
29
+ ele. O resto, que já funciona, fica como está.
30
+
28
31
  Não use emoji para dar emoção a um texto que não tem nenhuma.
@@ -12,7 +12,8 @@ a correção. Primeiro o que impede alguém de usar; acabamento vai no fim.
12
12
  - O que é clicável é `button` ou `a`, com hover, estado pressionado e foco
13
13
  visível (`:focus-visible`).
14
14
  - Alvo de toque de pelo menos 44×44px. Nada importante escondido só no hover.
15
- - Ação que demora mostra que está trabalhando e não pode ser enviada duas vezes.
15
+ - Todo clique responde em até 200 ms. Ação que demora mostra que está
16
+ trabalhando e não pode ser enviada duas vezes.
16
17
  - Ação que apaga pede confirmação ou oferece desfazer.
17
18
  - O endereço guarda o estado (aba, filtro, página): o voltar do navegador e o
18
19
  link compartilhado levam ao mesmo lugar.
@@ -32,6 +33,7 @@ novo) e cheio (texto longo quebra ou termina em `…`; mil itens não travam).
32
33
  **Layout**
33
34
  - De 360px a 1440px sem rolagem para o lado; respeita a área segura do celular
34
35
  (`env(safe-area-inset-bottom)`).
36
+ - Tela cheia é `100dvh`, nunca `100vh` (no celular, `vh` some sob a barra).
35
37
  - Imagem com `width`/`height` ou `aspect-ratio`, para a página não pular.
36
38
  - Número em coluna com `tabular-nums`; título com `text-wrap: balance`.
37
39
 
@@ -43,5 +45,9 @@ rolagem acompanham) e contraste de 4.5:1 nos dois temas. Animação só em
43
45
  `transform` e `opacity`, curta (150 a 300ms) e desligada em
44
46
  `prefers-reduced-motion`. Nunca `transition: all`.
45
47
 
48
+ **Pior caso** (teste com o dado ruim): Texto enorme (`min-width: 0` e
49
+ `overflow-wrap: anywhere`); Lista vazia; largura de 320px; Rede lenta (DevTools,
50
+ Slow 3G); Mil itens (paginar ou virtualizar).
51
+
46
52
  **Feche com a contagem**: quantos impedem o uso, quantos atrapalham, quantos são
47
53
  acabamento. E conserte nessa mesma ordem.
@@ -57,11 +57,9 @@ Em vídeo de produto, anúncio e vinheta, escreva **uma cena de 8 a 15 s** no
57
57
  - **A troca é no mesmo lugar e instante**: o foguete some onde o ícone nasce; o
58
58
  app nasce do tamanho e da COR do ícone.
59
59
 
60
- Por roteiro, o padrão é `hibrido` (dê `busca` em inglês por parte); `gerativo` (o motion), `vox` (explicar; dê
61
- `destaque` e `fonte`), `marca`, `lancamento` pelo nome; `visual` troca a peça de uma parte.
62
-
63
- Pedido de motion (ou sem referências): `estilo: "gerativo"`, só o roteiro
64
- (`fala`, `destaque`); `roteiro.semente` sorteia outro, `modelo3d` traz o 3D.
60
+ Sem imagem é o padrão (foto só se pedirem). Decida `clima`, o impacto (`destaque`, ≤1 em 3 partes), o
61
+ respiro e o `estilo` deste roteiro: `gerativo`, `fluido`, `impacto`, `vox`, `marca`, `lancamento`; vazio, gira.
62
+ `hibrido` só com imagem pedida. Leia os avisos da revisão.
65
63
 
66
64
  ## Cor e tipo
67
65
 
@@ -27,6 +27,12 @@ Tudo pelo `gh` (GitHub CLI) e pelo `git`, rodando no terminal do projeto.
27
27
  4. `gh pr create --fill` (ou `--title` e `--body` dizendo o que e por quê).
28
28
  Diga o link do PR.
29
29
 
30
+ **Segredo:** `.env` só existe na máquina. Nunca crie, edite ou renomeie
31
+ `.env` pelo site do GitHub (renomear `.env.example` para `.env` lá já vazou
32
+ chave em repositório público). O `.env.example` leva só os nomes, sem valor.
33
+ Antes do push: `git ls-files | grep '\.env$'` tem de vir vazio. Vazou? Troque a
34
+ chave na hora; apagar o arquivo não basta, ela fica no histórico.
35
+
30
36
  **Nunca** faça `push --force` em `main`, nunca apague branch dos outros, nunca
31
37
  commite `.env`. Conflito: resolva lendo os dois lados, não escolhendo um às
32
38
  cegas.
@@ -31,6 +31,9 @@ ela quem entra na conta dela.
31
31
  **3. Confira:** abra a URL publicada com browser_open e leia a página. Só diga
32
32
  que está no ar se ela abriu com o conteúdo certo. Termine dizendo o LINK.
33
33
 
34
+ **Link compartilhável:** `og:image` com URL absoluta (`https://.../capa.png`),
35
+ nunca `/capa.png`; sem isso o link sai sem imagem no WhatsApp.
36
+
34
37
  **Nunca** publique `.env`, chave ou senha: confira se `.gitignore` e o
35
38
  `.vercelignore`/`.netlifyignore` os deixam de fora. Variáveis secretas vão no
36
39
  painel do serviço (ou `vercel env add`), não no código.
@@ -26,6 +26,8 @@ tudo com a mesma duração; `transition: all`; texto piscando; nada que responda
26
26
  que acompanha o scroll (parallax, barra de progresso, imagem que cresce), use
27
27
  `animation-timeline: view()` e deixe a página parada onde isso não existe (`@supports`).
28
28
  - **Só `transform` e `opacity`.** Animar `top`, `width`, `height` ou `box-shadow` trava no celular.
29
+ Confira com grep: zero `transition: all` no CSS.
30
+ - **Seção de tela cheia** usa `100dvh`, e não `100vh`, pra não ficar atrás da barra do celular.
29
31
  - **Troca de página ou de estado:** a View Transitions API (`document.startViewTransition`), com o
30
32
  caminho sem animação quando o navegador não tiver.
31
33
  - **Biblioteca só se precisar.** CSS e um pouco de JS resolvem quase tudo. Timeline longa ou texto
@@ -0,0 +1,98 @@
1
+ #!/usr/bin/env python3
2
+ """voz_kokoro.py — a voz local, sem cota e sem servidor (Kokoro-82M, na CPU).
3
+
4
+ O Gemini é a voz de gente, mas a cota grátis é pequena e às vezes o áudio
5
+ volta cortado (medido em 25/09). O Kokoro roda nesta máquina: 11 s de áudio
6
+ em 3,5 s, mais 4,3 s para carregar o modelo, sem corte no fim (05/10).
7
+
8
+ Roda pelo `uv` (lib/estudio/kokoro.js monta o comando), com as versões
9
+ fixadas: sem `transformers>=4.45` o uv escolhe a 4.12 e o tokenizers não
10
+ compila; sem ESPEAK_DATA_PATH o espeak embutido aponta para uma pasta que
11
+ não existe.
12
+
13
+ Não se chama kokoro.py: a pasta do script entra no sys.path e o nome
14
+ esconderia o pacote `kokoro` (medido em 05/10).
15
+
16
+ Uso (a mesma entrada e a mesma saída do `narrar.py roteiro`):
17
+ voz_kokoro.py roteiro --arquivo partes.json --saida narracao.mp3 [--voz pf_dora]
18
+ """
19
+
20
+ import argparse
21
+ import json
22
+ import re
23
+ import subprocess
24
+ import sys
25
+ import tempfile
26
+ from pathlib import Path
27
+
28
+ TAXA = 24000
29
+ RESPIRO = 0.12 # o mesmo respiro curto entre as partes do narrar.py
30
+
31
+
32
+ def _palavras(texto, de, ate):
33
+ """O Kokoro em português não devolve o tempo de cada palavra: ele é
34
+ repartido pelo tamanho da palavra dentro da parte (melhor que nada para
35
+ a tipografia seguir a voz)."""
36
+ ws = texto.split()
37
+ total = sum(len(w) for w in ws) or 1
38
+ out, t = [], de
39
+ for w in ws:
40
+ d = (ate - de) * len(w) / total
41
+ out.append({"de": round(t, 3), "ate": round(t + d, 3), "texto": w})
42
+ t += d
43
+ return out
44
+
45
+
46
+ def comando_roteiro(args):
47
+ import numpy as np
48
+ import soundfile as sf
49
+ from kokoro import KPipeline
50
+
51
+ partes = json.loads(Path(args.arquivo).read_text(encoding="utf-8"))
52
+ if not partes:
53
+ return {"ok": False, "error": "o roteiro veio vazio"}
54
+ pipe = KPipeline(lang_code="p") # português do Brasil
55
+ pausa = np.zeros(int(TAXA * RESPIRO), dtype=np.float32)
56
+ blocos, trechos, palavras, t = [], [], [], 0.0
57
+ for parte in partes:
58
+ texto = re.sub(r"\s+", " ", str(parte.get("texto") or "")).strip()
59
+ if not texto:
60
+ trechos.append({"de": round(t, 3), "ate": round(t, 3), "palavras": []})
61
+ continue
62
+ audio = np.concatenate([np.asarray(a, dtype=np.float32) for _, _, a in pipe(texto, voice=args.voz)])
63
+ dur = len(audio) / TAXA
64
+ ws = _palavras(texto, t, t + dur)
65
+ palavras += ws
66
+ trechos.append({"de": round(t, 3), "ate": round(t + dur, 3), "palavras": ws})
67
+ blocos += [audio, pausa]
68
+ t += dur + RESPIRO
69
+ with tempfile.TemporaryDirectory() as tmp:
70
+ wav = Path(tmp) / "voz.wav"
71
+ sf.write(str(wav), np.concatenate(blocos), TAXA)
72
+ r = subprocess.run(["ffmpeg", "-y", "-loglevel", "error", "-i", str(wav),
73
+ "-af", "loudnorm=I=-14:TP=-1.5:LRA=9", "-c:a", "libmp3lame", "-q:a", "3",
74
+ str(args.saida)], capture_output=True, timeout=180)
75
+ if r.returncode != 0:
76
+ return {"ok": False, "error": (r.stderr or b"").decode("utf-8", "ignore")[-300:]}
77
+ return {"ok": True, "arquivo": str(args.saida), "motor": "kokoro", "voz": f"kokoro {args.voz}",
78
+ "segundos": round(t, 3), "partes": trechos, "palavras": palavras}
79
+
80
+
81
+ def main():
82
+ p = argparse.ArgumentParser(description="Narração local do PrimoCode Studio (Kokoro)")
83
+ sub = p.add_subparsers(dest="comando", required=True)
84
+ r = sub.add_parser("roteiro")
85
+ r.add_argument("--arquivo", required=True)
86
+ r.add_argument("--saida", required=True)
87
+ r.add_argument("--voz", default="pm_alex")
88
+ args = p.parse_args()
89
+ try:
90
+ saida = comando_roteiro(args)
91
+ except Exception as e: # nunca derruba a corrente: o JS passa para a próxima voz
92
+ saida = {"ok": False, "error": f"o kokoro falhou: {str(e)[:300]}"}
93
+ print(json.dumps(saida, ensure_ascii=False))
94
+ sys.exit(0 if saida.get("ok") else 1)
95
+
96
+
97
+ if __name__ == "__main__":
98
+ main()