primocode 8.36.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.36.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/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,
@@ -227,17 +227,125 @@ baratos de escrever e mudam completamente o resultado.
227
227
  último, é o vídeo que o usuário chamou de "sem efeito nenhum" — e não é falta
228
228
  de recurso: é este bloco não ter sido lido.
229
229
 
230
+ ## Anúncio de marca — o kit
231
+
232
+ Quando o pedido é o COMERCIAL de um produto (um cartão, um app, um plano), o
233
+ que separa uma peça de agência de um slide com música são quatro coisas, e as
234
+ quatro têm elemento próprio. Uma peça de marca sem elas sai genérica por mais
235
+ bonito que esteja o texto.
236
+
237
+ **Primeiro, declare a marca no documento.** Ela pinta o destaque de tudo —
238
+ número, botão, detalhe, o cartão — e libera o fundo `"marca"`:
239
+
240
+ ```json
241
+ "marca": { "cor": "#21C25E", "logo": "/midia/logo.svg" },
242
+ "tema": "claro"
243
+ ```
244
+
245
+ Tema CLARO na maioria dos anúncios de marca. O escuro é para a cena de
246
+ produto e para a vitrine; o resto da peça respira no branco.
247
+
248
+ ### `cartao` — o produto
249
+
250
+ ```json
251
+ { "tipo": "cartao", "nome": "PicPay", "variante": "Gold", "movimento": "flutuar" }
252
+ ```
253
+
254
+ Sai um cartão desenhado: chip, símbolo de aproximação, numeração mascarada,
255
+ nome e validade, com luz e sombra. `cor` (sem ela, a da marca), `largura`,
256
+ `numero` (só os 4 últimos aparecem), `titular`, `validade`, `face: "verso"`,
257
+ `movimento`: `flutuar` | `girar`.
258
+
259
+ **Mostre o produto antes de falar dele.** Um anúncio de cartão que só tem
260
+ frases é um anúncio sem produto.
261
+
262
+ ### `app` — a tela do aplicativo
263
+
264
+ ```json
265
+ { "tipo": "app", "topo": "Minha conta", "rotulo": "saldo disponível",
266
+ "valor": "R$ 1.284,90",
267
+ "linhas": [["Café da manhã", "R$ 18,90"], ["Mercado", "R$ 92,40"]],
268
+ "botao": "Pagar com o cartão" }
269
+ ```
270
+
271
+ Cabeçalho, um número grande, linhas com o nome à esquerda e o valor à direita
272
+ separadas por fio, e o botão cheio embaixo — que é o que faz o olho ler
273
+ "aplicativo". Aceita `["nome", "valor"]` e `"nome|valor"`. `escuro: true` para
274
+ a versão noturna.
275
+
276
+ **Ele vai DENTRO do `dispositivo`**, e aí ocupa a tela inteira do aparelho:
277
+
278
+ ```json
279
+ { "tipo": "dispositivo", "forma": "celular", "dentro": [ { "tipo": "app", ... } ] }
280
+ ```
281
+
282
+ Solto na cena ele vira o print da campanha. É o mesmo elemento de propósito:
283
+ com dois, a tela do celular e o print divergiriam na primeira mudança.
284
+
285
+ ### `vitrine` — a linha de produtos
286
+
287
+ ```json
288
+ { "tipo": "vitrine", "titulo": "Escolha seu cartão", "atual": 1, "nome": "PicPay",
289
+ "itens": [ { "nome": "Gold", "cor": "#C9A227", "detalhe": "sem anuidade" },
290
+ { "nome": "Verde", "detalhe": "cashback" },
291
+ { "nome": "Black", "cor": "#181B1F", "detalhe": "sala VIP" } ] }
292
+ ```
293
+
294
+ Os cartões numa fila, o `atual` à frente e os outros recuados. Vai bem sobre
295
+ fundo escuro (`"fundo": "#0b0f0c"`), que é como toda tela de "escolha o seu" é
296
+ desenhada.
297
+
298
+ ### `assinatura` — o ponto final
299
+
300
+ ```json
301
+ { "tipo": "assinatura", "nome": "PicPay", "frase": "o jeito fácil de pagar", "sobre": "marca" }
302
+ ```
303
+
304
+ Ela toma a CENA INTEIRA: o logo sozinho, grande, sobre a cor da casa. `sobre`:
305
+ `marca` | `claro` | `escuro`. Com `logo` ela usa a imagem; sem, o `nome`.
306
+
307
+ **Todo anúncio termina assim.** Sem a assinatura a peça acaba numa frase, e
308
+ ninguém fica sabendo quem falou.
309
+
310
+ ### A ordem que funciona
311
+
312
+ Uma peça de marca de 20 a 30 segundos, com 8 a 10 cenas:
313
+
314
+ | # | cena | o que entra |
315
+ |---|---|---|
316
+ | 1 | a promessa, em uma frase curta | `texto` pequeno, muito ar, fundo claro |
317
+ | 2 | o produto | `cartao` com `movimento` |
318
+ | 3 | o mote, na cor da casa | `fundo: "marca"`, `texto` branco, `anim: "palavra"` |
319
+ | 4 | funcionando | `dispositivo` + `app` |
320
+ | 5 | o benefício | `texto` |
321
+ | 6 | a linha | `vitrine` sobre fundo escuro |
322
+ | 7 | a última razão | `texto` |
323
+ | 8 | assinatura | `assinatura` |
324
+
325
+ E o rosto de quem fala, quando houver, entra por `clipe` entre a 4 e a 5 — o
326
+ depoimento é o que dá confiança, e nenhum desenho substitui.
327
+
328
+ ---
329
+
230
330
  ### `icone` — o desenho ao lado do argumento
231
331
 
232
332
  ```json
233
333
  { "tipo": "icone", "nome": "raio", "tamanho": 120 }
234
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 }
235
337
  ```
236
338
 
237
339
  O jeito mais barato de uma cena deixar de ser um slide. Um ícone grande acima
238
340
  do título, ou um por item de uma lista de três, e a mesma frase passa a ter
239
341
  imagem.
240
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
+
241
349
  Os nomes que existem (traço, no tom do tema):
242
350
 
243
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
 
@@ -893,6 +894,28 @@ def revisar_monotonia(doc, ferramenta):
893
894
  "(aproximar, afastar, esquerda, direita, cima), e uma ou duas peças por "
894
895
  "vídeo levam chaves de animação.")
895
896
 
897
+ # ── ANÚNCIO DE MARCA SEM PRODUTO E SEM ASSINATURA ───────────────────
898
+ #
899
+ # "Ainda não tá bom, tá genérico. Quero nesse nível." — o dono, sobre um
900
+ # comercial de fintech mandado como referência.
901
+ #
902
+ # O que separava a peça daqui daquela não era tema nem cor: era não ter o
903
+ # PRODUTO na tela e não assinar no fim. Quem declarou uma `marca` está
904
+ # fazendo peça de marca, e aí estas duas ausências são o defeito inteiro.
905
+ if doc.get("marca") and len(cenas) >= 4:
906
+ if not (tipos & {"cartao", "app", "vitrine", "dispositivo", "imagem", "clipe"}):
907
+ avisos.append(
908
+ "esta peça tem marca declarada e nenhuma cena mostra o PRODUTO — são %d cenas "
909
+ "de texto sobre fundo. Um anúncio de cartão mostra o cartão (`cartao`), o app "
910
+ "funcionando dentro do celular (`dispositivo` + `app`) e a linha de produtos "
911
+ "(`vitrine`). É essa ausência que se lê como genérico." % len(cenas))
912
+ if "assinatura" not in tipos:
913
+ avisos.append(
914
+ 'o anúncio acaba sem assinar. A última cena de uma peça de marca é '
915
+ '{"tipo":"assinatura","nome":"…","sobre":"marca"} — o logo sozinho, grande, '
916
+ "sobre a cor da casa. Sem ela a peça termina numa frase e ninguém fica "
917
+ "sabendo quem falou.")
918
+
896
919
  # Só a voz com tela montada: existe e quase nunca é lembrado, porque é o
897
920
  # contrário do `mudo` e o contrário não estava no vocabulário até agora.
898
921
  clipes = [e for e in todos if e.get("tipo") == "clipe"]
@@ -1061,9 +1084,16 @@ def validar_doc(modelo, doc):
1061
1084
  # default do renderizador: texto cru de 16px num canvas de 1920 — ou nada,
1062
1085
  # quando o texto estava noutro campo. Era o "vídeo com música mas tudo
1063
1086
  # preto": documento válido na forma, invisível na tela.
1087
+ # O kit de anúncio (cartao, app, vitrine, assinatura) entra na lista abaixo.
1088
+ #
1089
+ # A LISTA É LIDA POR UM TESTE (`vocabulario.test.js`), que a recorta com uma
1090
+ # expressão regular e pega as palavras — então comentário DENTRO dela vira
1091
+ # "tipo que o render precisa desenhar", e o teste passa a cobrar `produto`,
1092
+ # `linha` e `fim`. Texto sobre os tipos mora aqui fora.
1064
1093
  TIPOS_VALIDOS = {"texto", "lista", "cartoes", "numeros", "tabela", "codigo",
1065
1094
  "citacao", "forma", "imagem", "icone", "grafico", "no", "conector",
1066
1095
  "objeto3d", "clipe",
1096
+ "cartao", "app", "vitrine", "assinatura",
1067
1097
  "selo", "progresso", "avatar", "dispositivo", "conversa",
1068
1098
  "passos", "opcoes"}
1069
1099
  # Os sete últimos são o vocabulário do vídeo de referência. O comentário
@@ -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
  })();