primocode 8.37.0 → 8.38.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.
package/lib/ui.js CHANGED
@@ -243,28 +243,45 @@ function welcomeBox(version, contexto, opts = {}) {
243
243
  return out.join('\n');
244
244
  }
245
245
 
246
+ /* As frases que giram enquanto o Primo trabalha. Trocam sozinhas a cada
247
+ poucos segundos, em ordem, e NENHUMA fala de limite, cota, modelo ou espera
248
+ — de propósito: a mesma lista cobre o pensamento comum e a janela do minuto
249
+ que estourou. Quem olha não distingue um do outro, e é isso que o dono
250
+ pediu ("nunca mostrar limitações tipo limite do minuto, tudo deve ocorrer
251
+ ocultamente"). */
252
+ const PENSANDO = ['Primo Code pensando', 'organizando as ideias', 'juntando as peças', 'revendo o caminho', 'pensando no próximo passo'];
253
+
246
254
  class Spinner {
247
255
  constructor(text='pensando') {
248
- this.text = text;
256
+ this.textos = Array.isArray(text) ? text : [text];
257
+ this.text = this.textos[0];
249
258
  this.frames = ['⠋','⠙','⠹','⠸','⠼','⠴','⠦','⠧','⠇','⠏'];
250
- this.i = 0; this.timer = null;
259
+ this.i = 0; this.timer = null; this.desde = 0; this.largura = 0;
251
260
  }
252
261
  start() {
253
262
  // Fora de um terminal (pipe, CI) a animação só sujaria a saída.
254
263
  if (!process.stdout.isTTY) return this;
264
+ this.desde = Date.now();
255
265
  this.timer = setInterval(() => {
256
- process.stdout.write(`\r ${c.muted(this.frames[this.i])} ${c.muted(this.text)}`);
266
+ // Com mais de uma frase, troca a cada 4s. A frase nova pode ser mais
267
+ // curta que a anterior: o resto da linha velha ficaria na tela, então
268
+ // a linha é sempre preenchida até a maior largura já escrita.
269
+ if (this.textos.length > 1) this.text = this.textos[Math.floor((Date.now() - this.desde) / 4000) % this.textos.length];
270
+ const cru = ` ${this.frames[this.i]} ${this.text}`;
271
+ const falta = Math.max(0, this.largura - cru.length);
272
+ this.largura = Math.max(this.largura, cru.length);
273
+ process.stdout.write(`\r ${c.muted(this.frames[this.i])} ${c.muted(this.text)}${' '.repeat(falta)}`);
257
274
  this.i = (this.i+1) % this.frames.length;
258
275
  }, 80);
259
276
  return this;
260
277
  }
261
278
  stop() {
262
- if (this.timer) { clearInterval(this.timer); this.timer = null; process.stdout.write(`\r${' '.repeat(80)}\r`); }
279
+ if (this.timer) { clearInterval(this.timer); this.timer = null; process.stdout.write(`\r${' '.repeat(Math.max(80, this.largura + 4))}\r`); }
263
280
  }
264
281
  }
265
282
 
266
- function actionHeader(name, args) {
267
- const nice = {
283
+ // O rótulo de cada tool na tela — o que a pessoa lê no lugar do nome interno.
284
+ const ROTULOS = {
268
285
  read_file:'Read', write_file:'Write', edit_file:'Edit', multi_edit:'MultiEdit',
269
286
  list_dir:'List', glob:'Glob', grep:'Grep', run_shell:'Bash',
270
287
  run_in_new_terminal:'Terminal', open_in_browser:'Open', spawn_agent:'Agent',
@@ -281,14 +298,32 @@ function actionHeader(name, args) {
281
298
  studio_versoes:'Estúdio·histórico',
282
299
  desktop_windows:'Desktop·janelas', desktop_focus:'Desktop·foco',
283
300
  desktop_elements:'Desktop·elementos', desktop_click_element:'Desktop·click',
284
- };
285
- const label = nice[name] || name;
286
- const argStr = args ? (args.consulta || args.path || args.pattern || args.command || args.target || args.url || args.selector || args.text || args.app || args.window || args.name || args.ferramenta || args.titulo || args.description || (args.todos ? `${args.todos.length} tarefas` : '') || (args.x != null ? `${args.x},${args.y}` : '') || args.key || JSON.stringify(args).slice(0,60)) : '';
287
- return ` ${c.dim('⎿')} ${c.brand(label)}${argStr ? ' '+c.muted(argStr) : ''}`;
301
+ studio_midia:'Estúdio·mídia', studio_analisar:'Estúdio·analisar', construir_app:'Construir',
302
+ remember_general:'Lembrar',
303
+ };
304
+
305
+ /* A linha da AÇÃO: `●` na cor da marca e o rótulo em negrito. O resultado
306
+ dela vem embaixo, com `⎿`, um degrau para dentro (ver lib/fala.js). Antes
307
+ cabeçalho e resultado usavam o mesmo `⎿` no mesmo nível, e dez tools
308
+ seguidas eram uma coluna de ganchos sem dizer o que era ação e o que era
309
+ saída. O argumento vem numa linha só e cortado: um comando de três linhas
310
+ no cabeçalho empurra o resultado para fora da vista. */
311
+ function actionHeader(name, args) {
312
+ const label = ROTULOS[name] || name;
313
+ let argStr = args ? (args.consulta || args.path || args.pattern || args.command || args.target || args.url || args.selector || args.text || args.app || args.window || args.name || args.ferramenta || args.titulo || args.description || (args.todos ? `${args.todos.length} tarefas` : '') || (args.x != null ? `${args.x},${args.y}` : '') || args.key || args.arquivo || args.id || JSON.stringify(args).slice(0,60)) : '';
314
+ argStr = String(argStr || '').replace(/\s+/g, ' ').trim();
315
+ if (argStr.length > 96) argStr = argStr.slice(0, 95) + '…';
316
+ return ` ${c.brand('●')} ${c.bold(label)}${argStr ? ' '+c.muted(argStr) : ''}`;
288
317
  }
289
318
 
290
319
  // Transforma erro cru (JSON do provedor, código HTTP, erro de rede) numa frase
291
320
  // humana. O usuário nunca deveria ver um paredão de JSON.
321
+ //
322
+ // Sem emoji na frente, de propósito: um 💳 ou um 🔧 no começo da linha é a
323
+ // tela gritando "ERRO" antes de a pessoa ler a frase — e o que chega aqui é
324
+ // o que ela PRECISA fazer (crédito, chave, rede), dito com calma por
325
+ // lib/fala.js. O que é passageiro (minuto, 429, 5xx) nem chega: o agente e o
326
+ // chat esperam e repetem sem contar a ninguém.
292
327
  function friendlyError(raw) {
293
328
  const s = String(raw && raw.message ? raw.message : raw || '').trim();
294
329
  const low = s.toLowerCase();
@@ -308,7 +343,7 @@ function friendlyError(raw) {
308
343
  // é porque nem o mínimo cabe.
309
344
  if (/can only afford|requires more credits|\b402\b/i.test(low)) {
310
345
  const q = s.match(/afford\s+(\d+)/i);
311
- return '💳 A conta do OpenRouter não tem crédito para uma resposta deste tamanho'
346
+ return 'A conta do OpenRouter não tem crédito para uma resposta deste tamanho'
312
347
  + (q ? ` (dá para ${q[1]} tokens)` : '')
313
348
  + '. Adicione crédito em openrouter.ai/settings/credits, ou use /model fast'
314
349
  + ' para pedidos menores.';
@@ -318,7 +353,7 @@ function friendlyError(raw) {
318
353
  // apelidos padrão já são grátis, então quem cai aqui forçou um modelo pago.
319
354
  if (/prompt tokens limit exceeded/i.test(low)) {
320
355
  const n = s.match(/exceeded:\s*(\d+)\s*>\s*(\d+)/i);
321
- return '🆓 Este modelo é pago e a conta não tem crédito, então o provedor corta o pedido'
356
+ return 'Este modelo é pago e a conta não tem crédito, então o provedor corta o pedido'
322
357
  + (n ? ` em ${n[2]} tokens (o seu tem ${n[1]})` : '')
323
358
  + '. Use /model main — os modelos padrão são grátis e não têm esse corte.';
324
359
  }
@@ -329,8 +364,8 @@ function friendlyError(raw) {
329
364
  || /conversa passou do teto/i.test(low);
330
365
  if (porTamanho) {
331
366
  const nums = s.match(/Limit\s+(\d+),\s*Requested\s+(\d+)/i);
332
- if (/conversa passou do teto/i.test(low)) return `📏 ${inner.replace(/\s+/g, ' ').trim()}`;
333
- return '📏 Este pedido é maior que o teto do plano'
367
+ if (/conversa passou do teto/i.test(low)) return `${inner.replace(/\s+/g, ' ').trim()}`;
368
+ return 'Este pedido é maior que o teto do plano'
334
369
  + (nums ? ` (${nums[2]} tokens contra um limite de ${nums[1]} por minuto)` : '')
335
370
  + '. Esperar não resolve: use /clear para encurtar a conversa, ou divida o pedido em partes menores.';
336
371
  }
@@ -343,7 +378,7 @@ function friendlyError(raw) {
343
378
  const bruto = (s.match(/try again in ([0-9hms.]+)/i) || [])[1];
344
379
  const quando = bruto && bruto.replace(/\.+$/, '').replace(/\.\d+s$/, 's');
345
380
  const uso = s.match(/Limit\s+(\d+),\s*Used\s+(\d+)/i);
346
- return '📅 A cota de tokens do dia acabou'
381
+ return 'A cota de tokens do dia acabou'
347
382
  + (uso ? ` (${uso[2]} de ${uso[1]} tokens)` : '')
348
383
  + (quando ? `, libera em ${quando}` : '')
349
384
  + '. Enquanto isso o /mode chat gasta bem menos por pergunta.';
@@ -358,12 +393,12 @@ function friendlyError(raw) {
358
393
  // texto genérico faria o usuário achar que não havia nada a fazer.
359
394
  const semChave = s.match(/provedor de reserva \(([^)]+)\) está sem chave/i);
360
395
  if (semChave) {
361
- return `📅 A cota diária de grátis do OpenRouter acabou (são 50 por dia na conta sem crédito),`
396
+ return `A cota diária de grátis do OpenRouter acabou (são 50 por dia na conta sem crédito),`
362
397
  + ` e o provedor de reserva (${semChave[1]}) está sem chave no servidor — não houve para onde cair.`
363
398
  + ` Configure a chave dele no Render, ou adicione 10 dólares de crédito no OpenRouter`
364
399
  + ` para subir de 50 para 1.000 pedidos por dia (esse crédito não é gasto pelos modelos grátis).`;
365
400
  }
366
- return '📅 A cota diária de grátis acabou nos DOIS provedores (OpenRouter e Groq).'
401
+ return 'A cota diária de grátis acabou nos DOIS provedores (OpenRouter e Groq).'
367
402
  + ' Se chegou até aqui, o servidor já tentou os dez modelos da corrente.'
368
403
  + ' Trocar de modelo não resolve: o limite é da conta, não do modelo.'
369
404
  + ' Adicionar 10 dólares de crédito no OpenRouter sobe de 50 para 1.000 pedidos'
@@ -371,18 +406,18 @@ function friendlyError(raw) {
371
406
  + ' Sem isso, libera amanhã.';
372
407
  }
373
408
  if (/rate.?limit|too many requests|\b429\b/i.test(low)) {
374
- return '⏳ Muitos pedidos em pouco tempo. Espere alguns instantes e tente de novo.';
409
+ return 'Muitos pedidos em pouco tempo. Espere alguns instantes e tente de novo.';
375
410
  }
376
411
  // "token" solto não serve de pista: a mensagem de TPM tem a palavra e não
377
412
  // tem nada a ver com autenticação. Exige o código ou a frase inteira.
378
413
  if (/\b401\b|unauthorized|forbidden|token (inv[aá]lido|ausente|expirado)|invalid token|missing token/i.test(low)) {
379
- return '🔒 Este servidor pede um token. Configure PRIMOCODE_TOKEN ou "token" em ~/.primocode/config.json.';
414
+ return 'Este servidor pede um token. Configure PRIMOCODE_TOKEN ou "token" em ~/.primocode/config.json.';
380
415
  }
381
416
  if (/econnrefused|enotfound|etimedout|timeout|socket hang up|network|getaddrinfo|econnreset/i.test(low)) {
382
- return '📡 Não consegui falar com o servidor. Verifique sua conexão e tente de novo.';
417
+ return 'Não consegui falar com o servidor. Verifique sua conexão e tente de novo.';
383
418
  }
384
419
  if (/\b5\d\d\b|internal server|bad gateway|service unavailable|gateway timeout/i.test(low)) {
385
- return '🔧 O servidor está fora do ar no momento. Tente de novo em instantes.';
420
+ return 'O servidor está fora do ar no momento. Tente de novo em instantes.';
386
421
  }
387
422
  // Sobrou algo específico: mostra só a mensagem interna, sem o JSON gigante.
388
423
  const clean = inner.replace(/\s+/g, ' ').trim();
@@ -390,4 +425,4 @@ function friendlyError(raw) {
390
425
  }
391
426
 
392
427
 
393
- module.exports = { c, mascot, welcomeBox, Spinner, actionHeader, friendlyError, visibleWidth, stripAnsi, truncate, padRight };
428
+ module.exports = { c, mascot, welcomeBox, Spinner, PENSANDO, actionHeader, friendlyError, visibleWidth, stripAnsi, truncate, padRight };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "primocode",
3
- "version": "8.37.0",
3
+ "version": "8.38.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/modo.test.js && node test/pasta.test.js && node test/catalogo.test.js && node test/desktop.test.js && node test/effort.test.js && node test/claude-engine.test.js && node test/regressao.test.js && node test/memoria-conversa.test.js && node test/repeticao.test.js && node test/referencia.test.js && node test/parar.test.js && node test/continuar.test.js && node test/studio-spec.test.js && node test/studio-versoes.test.js && node test/voz.test.js && node test/editor.test.js && node test/estudio-projeto.test.js && node test/vocabulario.test.js && node test/video-export.test.js && node test/monotonia.test.js && node test/clipe.test.js && node test/midia-envio.test.js && node test/pausas.test.js && node test/fala-repetida.test.js && node test/trilha.test.js && node test/anuncio.test.js && node test/pipeline.test.js && node test/app.test.js && node test/elementos.test.js && node test/remotion.test.js && node test/aplicativo.test.js && node test/conta.test.mjs && node test/nuvem.test.js && node test/codex.test.js && node test/chaves.test.js && node test/prazo.test.js && node test/primeira-vez.test.js && node test/janela.test.js"
11
+ "test": "node test/tools.test.js && node test/ui.test.js && node test/modo.test.js && node test/pasta.test.js && node test/catalogo.test.js && node test/desktop.test.js && node test/effort.test.js && node test/claude-engine.test.js && node test/regressao.test.js && node test/memoria-conversa.test.js && node test/repeticao.test.js && node test/referencia.test.js && node test/parar.test.js && node test/continuar.test.js && node test/studio-spec.test.js && node test/studio-versoes.test.js && node test/voz.test.js && node test/editor.test.js && node test/estudio-projeto.test.js && node test/vocabulario.test.js && node test/video-export.test.js && node test/monotonia.test.js && node test/clipe.test.js && node test/midia-envio.test.js && node test/pausas.test.js && node test/fala-repetida.test.js && node test/trilha.test.js && node test/anuncio.test.js && node test/pipeline.test.js && node test/app.test.js && node test/elementos.test.js && node test/remotion.test.js && node test/aplicativo.test.js && node test/conta.test.mjs && node test/nuvem.test.js && node test/codex.test.js && node test/chaves.test.js && node test/prazo.test.js && node test/primeira-vez.test.js && node test/janela.test.js && node test/pareamento.test.js && node test/fala.test.js && node test/especialistas.test.js && node test/icone.test.js && node test/exportar.test.js"
12
12
  },
13
13
  "engines": {
14
14
  "node": ">=18.17.0"
@@ -85,8 +85,10 @@ um elemento flutuando por cima do layout.
85
85
  // IMAGEM
86
86
  { "tipo":"imagem", "src":"https://…", "ajuste":"cobrir", "raio":18 }
87
87
 
88
- // ÍCONE — nomes em /api/ferramentas/tela
88
+ // ÍCONE — nomes em /api/ferramentas/tela; ou DESENHE o seu: "caminho" é o
89
+ // path SVG numa caixa 24×24 (string ou lista de strings), no traço da casa
89
90
  { "tipo":"icone", "nome":"raio", "tamanho":120, "cor":"#ffb03a" }
91
+ { "tipo":"icone", "caminho":"M3 6h18l-2 10H5zM8 20h.01M16 20h.01", "tamanho":96 }
90
92
 
91
93
  // FORMA
92
94
  { "tipo":"forma", "forma":"retangulo", "x":200,"y":200,"l":400,"a":220,
@@ -332,12 +332,20 @@ depoimento é o que dá confiança, e nenhum desenho substitui.
332
332
  ```json
333
333
  { "tipo": "icone", "nome": "raio", "tamanho": 120 }
334
334
  { "tipo": "icone", "nome": "crescimento", "tamanho": 90, "cor": "#22d3ee", "traco": 2 }
335
+ { "tipo": "icone", "caminho": "M4 7h16M4 12h10M4 17h7", "tamanho": 100 }
336
+ { "tipo": "icone", "caminho": ["M12 3a9 9 0 100 18 9 9 0 000-18z", "M9 12l2 2 4-4"], "preenchido": false }
335
337
  ```
336
338
 
337
339
  O jeito mais barato de uma cena deixar de ser um slide. Um ícone grande acima
338
340
  do título, ou um por item de uma lista de três, e a mesma frase passa a ter
339
341
  imagem.
340
342
 
343
+ **O ícone que a lista não tem, você DESENHA** — `caminho` é o path SVG numa
344
+ caixa de 24×24 (uma string, ou uma lista com um traço por peça). Ele sai no
345
+ mesmo traço dos ícones prontos, na cor do tema, então o seu não destoa. Um
346
+ carrinho de compras, uma xícara, o símbolo do produto do cliente: três ou
347
+ quatro comandos `M`/`L`/`A`/`C` resolvem. Nunca emoji no lugar de ícone.
348
+
341
349
  Os nomes que existem (traço, no tom do tema):
342
350
 
343
351
  `raio` `alvo` `foguete` `escudo` `relogio` `usuario` `pessoas` `coracao`
@@ -87,6 +87,14 @@ Entre 8 e 14 cenas para 10 minutos. Uma ideia por cena.
87
87
 
88
88
  ## Detalhes que valem
89
89
 
90
+ - **A cena compõe sozinha.** Com título e conteúdo, o título ancora no alto e o
91
+ conteúdo ocupa o corpo, em escala de projetor; o rodapé com o nome do deck e
92
+ "03 / 08" entra sozinho (menos na capa e nas seções). Não simule isso com
93
+ `livre`.
94
+ - **Varie o layout entre as cenas**: `numeros` para o dado que decide,
95
+ `imagem-direita` com foto ou `dispositivo` quando há produto, `cartoes` em
96
+ `tres-colunas` para as opções, `citacao` para a voz de fora, `tabela` para
97
+ comparar. Cinco cenas `titulo` + `lista` seguidas é o deck que ninguém lembra.
90
98
  - **`tema` por cena** sobrepõe o do deck. Uma citação em `papel` no meio de um deck
91
99
  `meia-noite` cria respiro — use uma vez, não cinco.
92
100
  - **`revelar: true`** na lista faz os itens aparecerem um a um.
package/studio/server.py CHANGED
@@ -881,7 +881,8 @@ def revisar_monotonia(doc, ferramenta):
881
881
  avisos.append(
882
882
  "o vídeo é só texto e imagem: nenhum elemento de apoio em %d cenas. "
883
883
  'Um "icone" ao lado do argumento (raio, alvo, foguete, escudo, relogio, '
884
- "lampada, cadeado, crescimento, pessoas, dinheiro, globo, fogo…), um "
884
+ "lampada, cadeado, crescimento, pessoas, dinheiro, globo, fogo… ou um "
885
+ 'DESENHADO por você, com "caminho": path SVG numa caixa 24x24), um '
885
886
  '"selo" de estado, um "numeros" com "contar": true — é isto que faz a '
886
887
  "tela parecer produzida em vez de um slide com legenda." % len(cenas))
887
888
 
@@ -88,4 +88,33 @@ function nomesIcones() {
88
88
  return Object.keys(ICONES);
89
89
  }
90
90
 
91
- if (typeof module !== "undefined" && module.exports) module.exports = { ICONES: ICONES, svgIcone: svgIcone };
91
+ /* O ícone que a IA DESENHA, quando o que ela precisa não está na lista.
92
+ *
93
+ * "A IA irá desenvolver ícones."
94
+ *
95
+ * `caminho` é o path SVG numa caixa de 24×24 — uma string, ou uma lista de
96
+ * strings (um traço por peça). Sai no MESMO traço dos ícones da casa (1.7,
97
+ * cantos redondos, cor do texto), para o desenhado não destoar do pronto.
98
+ *
99
+ * Só entram os caracteres de path: letra de comando, número, espaço,
100
+ * vírgula. Nada mais — isto vira innerHTML numa página nossa, e o documento
101
+ * foi escrito por um modelo. Path inválido cai no círculo, que é o mesmo
102
+ * destino de um nome que não existe: o erro fica visível e inofensivo. */
103
+ // Começa em M/m: todo path de verdade abre com um moveto, e "42" sozinho
104
+ // não é desenho nenhum.
105
+ var CAMINHO_OK = /^[Mm][MmZzLlHhVvCcSsQqTtAa0-9\s,.\-+eE]*$/;
106
+
107
+ function svgCaminho(caminho, op) {
108
+ op = op || {};
109
+ var lista = Array.isArray(caminho) ? caminho : [caminho];
110
+ var ds = lista.map(function (d) { return String(d == null ? "" : d).trim(); })
111
+ .filter(function (d) { return d && d.length <= 4000 && CAMINHO_OK.test(d); });
112
+ if (!ds.length) return svgIcone("circulo", op.cls);
113
+ var fill = op.preenchido ? "currentColor" : "none";
114
+ return '<svg viewBox="0 0 24 24" fill="' + fill + '" stroke="currentColor" stroke-width="' +
115
+ (Number(op.traco) > 0 ? Number(op.traco) : 1.7) + '" stroke-linecap="round" stroke-linejoin="round"' +
116
+ (op.cls ? ' class="' + op.cls + '"' : "") + ">" +
117
+ ds.map(function (d) { return '<path d="' + d + '"/>'; }).join("") + "</svg>";
118
+ }
119
+
120
+ if (typeof module !== "undefined" && module.exports) module.exports = { ICONES: ICONES, svgIcone: svgIcone, svgCaminho: svgCaminho, CAMINHO_OK: CAMINHO_OK };
@@ -12,6 +12,11 @@
12
12
  var arq = null, doc = {}, cenas = [], atual = 0;
13
13
  var tocando = false, quadro = null, t0 = 0, decorrido = 0;
14
14
  var trilha = null, gravador = null, pedacos = [];
15
+ /* Resolve quando o arquivo está MONTADO no palco. É por ela que a exportação
16
+ escondida (studio_exportar) sabe quando pode começar — antes disto não há
17
+ cena para fotografar, e um export que começa cedo sai preto. */
18
+ var carregadoOk = null;
19
+ var carregado = new Promise(function (ok) { carregadoOk = ok; });
15
20
  var ehVideo = false, transporte = null;
16
21
 
17
22
  /* ------------------------------------------------------------- avisos -- */
@@ -81,8 +86,14 @@
81
86
  if (a.ferramenta === "grade") return montarGrade();
82
87
 
83
88
  cenas = doc.cenas || [];
89
+ // O rodapé (nome do deck, "03 / 08") é coisa de APRESENTAÇÃO: o vídeo e
90
+ // a arte não têm. Ver o rodapé em render.js.
91
+ var rodape = a.ferramenta === "prisma" ? { titulo: a.titulo, total: cenas.length } : null;
84
92
  cenas.forEach(function (c, i) {
85
- var no = renderCena(c, doc, { animar: true });
93
+ var no = renderCena(c, doc, {
94
+ animar: true,
95
+ rodape: rodape ? { titulo: rodape.titulo, indice: i + 1, total: rodape.total } : null,
96
+ });
86
97
  no.style.position = "absolute";
87
98
  no.style.inset = "0";
88
99
  no.style.display = i === 0 ? "block" : "none";
@@ -93,6 +104,7 @@
93
104
 
94
105
  ajustar();
95
106
  ir(0);
107
+ if (carregadoOk) { carregadoOk(a); carregadoOk = null; }
96
108
 
97
109
  if (location.hash === "#imprimir") return setTimeout(imprimirTudo, 350);
98
110
 
@@ -259,7 +271,17 @@
259
271
  que vem depois no arquivo — invadir é justamente a pausa que se
260
272
  queria cortar. */
261
273
  if (alvo >= t.ate) { if (!v.paused) v.pause(); return; }
262
- if (Math.abs(v.currentTime - alvo) > DERIVA) {
274
+ /* Uma busca por vez, e com respiro. Numa máquina lenta (ou na
275
+ exportação escondida, que rasteriza a cena a cada quadro no mesmo
276
+ fio), o <video> fica atrás do relógio por mais de DERIVA em TODO
277
+ quadro: puxá-lo de volta a cada rAF é uma busca em cima da outra, e
278
+ um vídeo que só busca nunca tem quadro pronto — readyState fica em 1,
279
+ o export não desenha o clipe e o arquivo sai com o chão da cena no
280
+ lugar da gravação. Medido quadro a quadro no diagnóstico. */
281
+ if (v.seeking) return;
282
+ var agora = performance.now();
283
+ if (Math.abs(v.currentTime - alvo) > DERIVA && (!v._ultimaBusca || agora - v._ultimaBusca > 600)) {
284
+ v._ultimaBusca = agora;
263
285
  try { v.currentTime = alvo; } catch (e) {}
264
286
  }
265
287
  if (tocando && v.paused) tocarClipe(v);
@@ -679,19 +701,17 @@ function gravaMp4() { return /mp4/.test(escolherFormato()); }
679
701
  * o mesmo grafo de sempre — trilha, narração e efeitos, já mixados com o
680
702
  * ducking — então o arquivo sai idêntico ao que se ouve aqui.
681
703
  */
682
- function iniciarGravacao() {
683
- var b = document.getElementById("tp-gravar");
684
- if (!b || b.dataset.exportando) return;
704
+ /* Monta o arquivo com o PLAYER como fonte, sem baixar. É o que o botão usa
705
+ por baixo e o que a exportação escondida (studio_exportar) chama por
706
+ fora — um caminho só, para o arquivo sair igual nos dois.
707
+
708
+ O player é a fonte: ele sabe encadear cenas, narrar no tempo certo e
709
+ tocar os efeitos. O exportador só pergunta o que está na tela. */
710
+ function gerarArquivo(op) {
711
+ op = op || {};
685
712
  if (typeof VideoExport === "undefined" || !VideoExport.disponivel()) {
686
- return aviso("este navegador não sabe gerar vídeo", 3200);
713
+ return Promise.reject(new Error("este navegador não sabe gerar vídeo"));
687
714
  }
688
-
689
- b.dataset.exportando = "1";
690
- b.classList.add("gravando");
691
- aviso("montando o vídeo — não feche esta aba", 3000);
692
-
693
- // O player é a fonte: ele sabe encadear cenas, narrar no tempo certo e
694
- // tocar os efeitos. O exportador só pergunta o que está na tela.
695
715
  var fonte = {
696
716
  duracao: total(),
697
717
  // A gravação do usuário precisa estar decodificada antes do primeiro
@@ -701,13 +721,32 @@ function gravaMp4() { return /mp4/.test(escolherFormato()); }
701
721
  parar: function () { pausar(); },
702
722
  no: function () { return palco.children[atual] || null; },
703
723
  };
704
-
705
- VideoExport.baixar(arq, {
706
- nome: arq.titulo,
724
+ // O AudioContext nasce suspenso sem gesto; a exportação escondida chega
725
+ // com um gesto emprestado, e este resume é o que o gasta.
726
+ if (trilha && trilha.ctx && trilha.ctx.state === "suspended") { try { trilha.ctx.resume(); } catch (e) {} }
727
+ return VideoExport.gerar(arq, {
707
728
  fonte: fonte,
729
+ fps: op.fps,
708
730
  fluxoAudio: trilha && trilha.fluxo,
731
+ aoAndar: op.aoAndar,
732
+ });
733
+ }
734
+
735
+ function iniciarGravacao() {
736
+ var b = document.getElementById("tp-gravar");
737
+ if (!b || b.dataset.exportando) return;
738
+ if (typeof VideoExport === "undefined" || !VideoExport.disponivel()) {
739
+ return aviso("este navegador não sabe gerar vídeo", 3200);
740
+ }
741
+
742
+ b.dataset.exportando = "1";
743
+ b.classList.add("gravando");
744
+ aviso("montando o vídeo — não feche esta aba", 3000);
745
+
746
+ gerarArquivo({
709
747
  aoAndar: function (f) { b.textContent = Math.round(f * 100) + "%"; },
710
748
  }).then(function (r) {
749
+ VideoExport.salvar(r, arq.titulo);
711
750
  aviso(r.mp4 ? "vídeo salvo em .mp4" : "salvo em .webm (este navegador não faz mp4)", 3400);
712
751
  }).catch(function (e) {
713
752
  aviso("não consegui gerar: " + e.message, 4200);
@@ -829,5 +868,8 @@ function gravaMp4() { return /mp4/.test(escolherFormato()); }
829
868
  ir: function (n) { ir(n - 1); }, proxima: proxima, anterior: anterior,
830
869
  tocar: tocar, parar: pausar, buscar: buscar, gravar: alternarGravacao,
831
870
  get cena() { return atual + 1; }, get total() { return cenas.length; },
871
+ // Para a exportação escondida: espera o arquivo montado, troca o que
872
+ // precisar (um clipe convertido) e gera pelo mesmo caminho do botão.
873
+ carregado: carregado, remontar: montar, exportar: gerarArquivo,
832
874
  };
833
875
  })();
@@ -1182,7 +1182,11 @@ function renderElemento(e, ctx) {
1182
1182
  no.style.width = _px(e.l || t);
1183
1183
  no.style.height = _px(e.a || t);
1184
1184
  no.style.color = e.cor || "var(--c-tom)";
1185
- no.innerHTML = svgIcone(e.nome || "estrela");
1185
+ // `caminho`: o ícone desenhado pelo modelo (path SVG 24×24) — ver
1186
+ // icones.js. Sem ele, um da lista pelo nome.
1187
+ no.innerHTML = e.caminho
1188
+ ? svgCaminho(e.caminho, { preenchido: !!e.preenchido, traco: e.traco })
1189
+ : svgIcone(e.nome || "estrela");
1186
1190
  var s = no.querySelector("svg");
1187
1191
  if (s) s.setAttribute("stroke-width", e.traco || 1.5);
1188
1192
  break;
@@ -1570,10 +1574,12 @@ function renderCena(cena, doc, op) {
1570
1574
  cols.style.gridTemplateColumns = "repeat(" + (layout === "duas-colunas" ? 2 : 3) + ", 1fr)";
1571
1575
  solta(corpo, cols);
1572
1576
  fluxo.appendChild(cols);
1577
+ comporCabecaCorpo(fluxo, layout, cabecalho.length, 1);
1573
1578
  } else {
1574
1579
  // um bloco só (cartoes, numeros…) já se distribui sozinho: não engaiolar
1575
1580
  solta(cabecalho, fluxo);
1576
1581
  solta(corpo, fluxo);
1582
+ comporCabecaCorpo(fluxo, layout, cabecalho.length, corpo.length);
1577
1583
  }
1578
1584
 
1579
1585
  flutuantes.forEach(function (e) {
@@ -1583,8 +1589,54 @@ function renderCena(cena, doc, op) {
1583
1589
  no.appendChild(el);
1584
1590
  });
1585
1591
 
1592
+ /* O RODAPÉ DA APRESENTAÇÃO: nome do deck à esquerda, "03 / 08" à direita.
1593
+ É o que ancora a composição no quadro — sem ele, uma cena de título e
1594
+ lista é um bloco flutuando no meio do preto, e todo deck parece o mesmo
1595
+ deck. Quem manda é quem chama (o player, quando a peça é prisma): o
1596
+ vídeo não tem rodapé, e a capa e as seções também não — são as cenas
1597
+ que se sustentam sozinhas. */
1598
+ if (op.rodape && layout !== "capa" && layout !== "secao") {
1599
+ var rodape = document.createElement("div");
1600
+ rodape.className = "rodape";
1601
+ rodape.style.left = Math.round(W * 0.075) + "px";
1602
+ rodape.style.right = Math.round(W * 0.075) + "px";
1603
+ rodape.style.bottom = Math.round(H * 0.048) + "px";
1604
+ var esq = document.createElement("span");
1605
+ esq.textContent = op.rodape.titulo || "";
1606
+ var dir = document.createElement("span");
1607
+ if (op.rodape.indice && op.rodape.total) {
1608
+ dir.textContent = String(op.rodape.indice).padStart(2, "0") + " / " + String(op.rodape.total).padStart(2, "0");
1609
+ }
1610
+ rodape.appendChild(esq);
1611
+ rodape.appendChild(dir);
1612
+ no.appendChild(rodape);
1613
+ }
1614
+
1586
1615
  desenharConectores(no, elementos, W, H);
1587
1616
  return no;
1588
1617
  }
1589
1618
 
1619
+ /* A COMPOSIÇÃO DE SLIDE: cabeça em cima, corpo no meio do que sobra.
1620
+
1621
+ Tudo centrado verticalmente — que era o padrão para todo layout — deixa um
1622
+ título com três linhas de lista ocupando 30% do quadro, com 350px de nada
1623
+ em cima e 350px embaixo: a cara de "slide genérico" que o dono apontou. A
1624
+ composição de quem faz deck de verdade ancora o título no alto e dá o resto
1625
+ do quadro ao conteúdo, que se centra ali.
1626
+
1627
+ Só quando há as duas metades (cabeça E corpo) e o layout não é o dos que se
1628
+ sustentam centrados (capa, centro, citação, seção): uma cena de vídeo com um
1629
+ título sozinho continua no meio, onde um título sozinho fica bem. O corpo é
1630
+ marcado no primeiro e no último filho (`margin: auto` no CSS), e o DOM
1631
+ continua chapado — nada de embrulho novo em volta dos elementos. */
1632
+ function comporCabecaCorpo(fluxo, layout, nCabeca, nCorpo) {
1633
+ if (!nCabeca || !nCorpo) return;
1634
+ if (layout === "capa" || layout === "centro" || layout === "citacao" || layout === "secao") return;
1635
+ var filhos = fluxo.children;
1636
+ if (filhos.length <= nCabeca) return;
1637
+ fluxo.setAttribute("data-composicao", "cabeca-corpo");
1638
+ filhos[nCabeca].classList.add("corpo-inicio");
1639
+ filhos[filhos.length - 1].classList.add("corpo-fim");
1640
+ }
1641
+
1590
1642
  if (typeof module !== "undefined" && module.exports) module.exports = { renderCena: renderCena };
@@ -347,6 +347,23 @@ var VideoExport = (function () {
347
347
  return new Promise(function (pronto) {
348
348
  var t0 = performance.now();
349
349
  var primeiro = true;
350
+ /* A camada de cima de uma cena com clipe (título, legenda, marca),
351
+ já rasterizada. Enquanto nada nela anima, só o quadro do vídeo
352
+ muda — e rasterizar o SVG inteiro trinta vezes por segundo era o
353
+ que engasgava a máquina: o decodificador do <video> ficava sem
354
+ CPU, o clipe atrasava, o player o puxava de volta e a busca não
355
+ terminava nunca (medido: seeking=true e readyState=1 do 0,7s ao
356
+ fim, arquivo com o chão da cena no lugar da gravação). Com a
357
+ camada guardada, cada quadro é dois drawImage. */
358
+ var camada = null;
359
+
360
+ function pintarComClipe(no, img) {
361
+ // chão do tema → gravação do usuário → cena sem fundo
362
+ ctx2d.fillStyle = corDeChao(no);
363
+ ctx2d.fillRect(0, 0, L, A);
364
+ desenharClipes(ctx2d, no, L, A);
365
+ ctx2d.drawImage(img, 0, 0, L, A);
366
+ }
350
367
 
351
368
  function volta() {
352
369
  if (cancelado) return pronto();
@@ -365,16 +382,20 @@ var VideoExport = (function () {
365
382
  primeiro = false;
366
383
 
367
384
  var comVideo = temClipe(no);
385
+ if (comVideo && camada && camada.no === no && !aindaAnima(no)) {
386
+ pintarComClipe(no, camada.img);
387
+ return setTimeout(volta, 1000 / FPS);
388
+ }
389
+
368
390
  Exportar.retrato(no, L, A, folhaCss, false, comVideo ? semFundo : null)
369
391
  .then(function (img) {
370
392
  if (cancelado) return pronto();
371
393
  if (comVideo) {
372
- // chão do tema → gravação do usuário → cena sem fundo
373
- ctx2d.fillStyle = corDeChao(no);
374
- ctx2d.fillRect(0, 0, L, A);
375
- desenharClipes(ctx2d, no, L, A);
394
+ camada = aindaAnima(no) ? null : { no: no, img: img };
395
+ pintarComClipe(no, img);
396
+ } else {
397
+ ctx2d.drawImage(img, 0, 0, L, A);
376
398
  }
377
- ctx2d.drawImage(img, 0, 0, L, A);
378
399
  volta();
379
400
  }).catch(function () {
380
401
  // Um quadro que não rasterizou não derruba o vídeo: segue, e o
@@ -412,25 +433,30 @@ var VideoExport = (function () {
412
433
  return promessa;
413
434
  }
414
435
 
436
+ /* Entrega ao usuário o arquivo que `gerar` montou. Separado de `baixar`
437
+ porque a exportação ESCONDIDA (studio_exportar) gera pelo mesmo caminho e
438
+ não baixa: manda o blob de volta ao estúdio. Um só `gerar` para os dois. */
439
+ function salvar(r, nome) {
440
+ var url = URL.createObjectURL(r.blob);
441
+ var a = document.createElement("a");
442
+ a.href = url;
443
+ a.download = (nome || "video")
444
+ .replace(/[^\w\-]+/g, "-").replace(/^-+|-+$/g, "").toLowerCase() + r.formato;
445
+ document.body.appendChild(a);
446
+ a.click();
447
+ a.remove();
448
+ setTimeout(function () { URL.revokeObjectURL(url); }, 8000);
449
+ return r;
450
+ }
451
+
415
452
  /* Gera e entrega o arquivo. É isto que o botão chama. */
416
453
  function baixar(arq, op) {
417
454
  op = op || {};
418
- return gerar(arq, op).then(function (r) {
419
- var url = URL.createObjectURL(r.blob);
420
- var a = document.createElement("a");
421
- a.href = url;
422
- a.download = (op.nome || arq.titulo || "video")
423
- .replace(/[^\w\-]+/g, "-").replace(/^-+|-+$/g, "").toLowerCase() + r.formato;
424
- document.body.appendChild(a);
425
- a.click();
426
- a.remove();
427
- setTimeout(function () { URL.revokeObjectURL(url); }, 8000);
428
- return r;
429
- });
455
+ return gerar(arq, op).then(function (r) { return salvar(r, op.nome || arq.titulo); });
430
456
  }
431
457
 
432
458
  return {
433
- disponivel: disponivel, formato: formato, gerar: gerar, baixar: baixar,
459
+ disponivel: disponivel, formato: formato, gerar: gerar, baixar: baixar, salvar: salvar,
434
460
  FORMATOS: FORMATOS, _aindaAnima: aindaAnima,
435
461
  };
436
462
  })();