primocode 8.13.0 → 8.15.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/bin/primocode.js CHANGED
@@ -425,10 +425,28 @@ function pastaSegura(dir) {
425
425
  // memory.md dele não carrega neste turno.
426
426
  // 3. projeto novo — fica PENDENTE: age em ~/primocode e só cria a pasta se o
427
427
  // agente for mexer em arquivo. Pedir para abrir o Chrome não cria projeto.
428
+ // Pedido que se resolve DENTRO do estúdio: vídeo, slides, arte, documento,
429
+ // planilha, diagrama. O que sai dali já é guardado pelo próprio estúdio, em
430
+ // ~/.primocode/studio/arquivos — não precisa (e não deve) virar pasta de
431
+ // código em ~/primocode, cheia de memory.md sobre um vídeo.
432
+ const PEDIDO_DE_ESTUDIO = /\b(v[íi]deo|reels?|anima[çc][ãa]o|slides?|apresenta[çc][ãa]o|deck|post|banner|capa|thumbnail|arte|cartaz|planilha|documento|relat[óo]rio|diagrama|fluxograma|narra[çc][ãa]o|est[úu]dio)\b/i;
433
+ // ...a menos que ele também peça código ou página: aí é projeto de verdade.
434
+ const PEDIDO_DE_CODIGO = /\b(html|css|javascript|js|react|next|python|api|site|p[áa]gina|app|aplicativo|script|reposit[óo]rio|c[óo]digo|programa|servidor|banco de dados)\b/i;
435
+
436
+ function ehSoEstudio(texto) {
437
+ const t = String(texto || '');
438
+ return PEDIDO_DE_ESTUDIO.test(t) && !PEDIDO_DE_CODIGO.test(t);
439
+ }
440
+
428
441
  function planWorkDir(userText) {
429
442
  if (['/dir', 'detectada', 'projeto'].includes(state.workDirOrigem)) {
430
443
  return { dir: state.workDir, pending: false, name: state.currentProject };
431
444
  }
445
+ // "Quando eu peço um vídeo, não é pra ficar com projeto. Só vai criar
446
+ // projeto quando tiver um HTML, quando tiver um código."
447
+ if (ehSoEstudio(userText)) {
448
+ return { dir: cfg.ROOT_DIR, pending: false, name: state.currentProject, estudio: true };
449
+ }
432
450
  const proposto = deriveProjectName(userText);
433
451
  const parecido = cfg.findSimilarProject(proposto);
434
452
  if (parecido) return { dir: parecido.dir, pending: false, name: parecido.name, similar: true };
package/lib/act.js CHANGED
@@ -668,6 +668,23 @@ async function runAct(userPrompt, opts) {
668
668
  cfg.appendProjectMemory(pastaAtual, `**Pedido:** ${userPrompt.slice(0, 300)}\n**Resultado:** ${finalText.slice(0, 500)}`);
669
669
  }
670
670
 
671
+ // A memória GERAL é gravada sempre, tenha havido pasta ou não.
672
+ //
673
+ // Antes ela só era escrita se o agente chamasse `remember_general` — e ele
674
+ // quase nunca chamava. Pior: a memória de projeto acima depende de
675
+ // `temPasta`, e tarefa de estúdio (vídeo, slides) não cria pasta nenhuma.
676
+ // Resultado prático: quem só fazia vídeo NUNCA teve memória gravada, de
677
+ // tipo nenhum. O usuário notou: "nunca atualiza os arquivos de memória, e
678
+ // ela deve atualizar independente do projeto".
679
+ //
680
+ // Fica curto de propósito — isto entra em todo pedido seguinte. Uma linha
681
+ // por tarefa concluída, e podada por config.js quando passa do limite.
682
+ if (!subagent && concluiu && finalText.trim()) {
683
+ const pedido = userPrompt.replace(/\s+/g, ' ').trim().slice(0, 160);
684
+ const feito = finalText.replace(/\s+/g, ' ').trim().slice(0, 240);
685
+ cfg.appendGeneralMemory(`- **${pedido}** → ${feito}`);
686
+ }
687
+
671
688
  return {
672
689
  userMessage: { role: 'user', content: userPrompt },
673
690
  assistantMessage: { role: 'assistant', content: finalText },
@@ -290,15 +290,73 @@ function ultimaMensagem(messages) {
290
290
  /** Devolve a decisão pro onEvent — tool_call, ou assistant_message (com
291
291
  * degradação pra texto puro quando o bloco marcado não veio, em vez de
292
292
  * travar o turno em silêncio). */
293
- function emitirDecisao(textoLido, onEvent) {
293
+ /* ─────────────────────────────────────────────── o que não deu para ler ──
294
+ O motor às vezes devolve algo que este lado não sabe processar: bloco com
295
+ sintaxe torta, ou uma ferramenta que não existe. O usuário via isso na
296
+ tela — "O motor Claude Code devolveu uma decisão que não consegui ler" —
297
+ e a tarefa parava.
298
+
299
+ Erro de forma não é assunto do usuário. Quem errou a forma foi o motor, e
300
+ quem sabe consertar é ele. Então o problema volta PARA ELE, dizendo
301
+ exatamente o que estava errado, e ele reescreve a ação. Só depois de
302
+ algumas tentativas seguidas é que alguém precisa saber.
303
+
304
+ `pedirCorrecao` é preenchido por quem chamou (o caminho headless ou o
305
+ ORCA), porque só eles sabem como falar com o motor de novo. */
306
+ function diagnosticar(cru, nomesValidos) {
307
+ if (cru.includes(MARCA_INICIO) && !cru.includes(MARCA_FIM)) {
308
+ return 'você abriu ' + MARCA_INICIO + ' e nunca fechou com ' + MARCA_FIM + '.';
309
+ }
310
+ if (!cru.includes(MARCA_INICIO) && cru.includes(MARCA_FIM)) {
311
+ return 'apareceu ' + MARCA_FIM + ' sem o ' + MARCA_INICIO + ' antes.';
312
+ }
313
+ const dentro = /<<<PRIMOCODE>>>([\s\S]*?)<<<FIM>>>/.exec(cru);
314
+ if (dentro) {
315
+ try {
316
+ JSON.parse(dentro[1].trim());
317
+ return 'o JSON até é válido, mas não tem "tool_call" nem "assistant_message" na raiz.';
318
+ } catch (e) {
319
+ return 'o conteúdo entre as marcas não é JSON válido: ' + e.message
320
+ + '. Lembre de escapar quebra de linha como \\n dentro das strings.';
321
+ }
322
+ }
323
+ if (nomesValidos) return 'a ferramenta pedida não existe. As que existem: ' + nomesValidos + '.';
324
+ return 'não achei um bloco marcado na sua resposta.';
325
+ }
326
+
327
+ function textoDeCorrecao(cru, motivo) {
328
+ return 'A sua última resposta não pôde ser executada: ' + motivo
329
+ + '\n\nO que você mandou foi:\n---\n' + String(cru).slice(0, 1200) + '\n---\n\n'
330
+ + 'Reescreva AGORA a mesma intenção numa ação que o sistema reconheça. '
331
+ + 'Responda com exatamente UM bloco:\n'
332
+ + MARCA_INICIO + '\n{"tool_call":{"name":"<uma ferramenta do catálogo>","arguments":{...}}}\n' + MARCA_FIM
333
+ + '\nou, se era só texto para a pessoa, ' + MARCA_INICIO
334
+ + '\n{"assistant_message":"..."}\n' + MARCA_FIM
335
+ + '\nNada fora do bloco.';
336
+ }
337
+
338
+ const MAX_CORRECOES = 3;
339
+
340
+ async function emitirDecisao(textoLido, onEvent, pedirCorrecao, tentativa, validos) {
341
+ tentativa = tentativa || 0;
294
342
  const decisao = extrairDecisao(textoLido);
295
343
  if (!decisao) {
296
344
  const cru = String(textoLido || '').trim();
297
345
  // Traz a marca do protocolo mas o bloco não dá para ler? O motor
298
- // TENTOU chamar ferramenta e errou a sintaxe. Antes isso virava
299
- // desistência na tela; agora vira o mesmo erro que o loop já sabe
300
- // repetir ("Failed to call a function" dispara retry da rodada).
346
+ // TENTOU chamar ferramenta e errou a sintaxe. Em vez de mostrar isso
347
+ // ao usuário, devolvemos o problema ao motor com o diagnóstico e
348
+ // pedimos a mesma intenção escrita direito.
301
349
  if (cru.includes(MARCA_INICIO) || cru.includes(MARCA_FIM)) {
350
+ if (pedirCorrecao && tentativa < MAX_CORRECOES) {
351
+ const motivo = diagnosticar(cru);
352
+ try {
353
+ const novo = await pedirCorrecao(textoDeCorrecao(cru, motivo));
354
+ return await emitirDecisao(novo, onEvent, pedirCorrecao, tentativa + 1, validos);
355
+ } catch (e) {
356
+ // A correção em si falhou (motor fora do ar, tempo
357
+ // esgotado). Cai no caminho antigo, que o loop repete.
358
+ }
359
+ }
302
360
  onEvent({ erro: 'Failed to call a function: o motor Claude devolveu um bloco ilegível — repetindo a rodada.' });
303
361
  return;
304
362
  }
@@ -307,10 +365,27 @@ function emitirDecisao(textoLido, onEvent) {
307
365
  return;
308
366
  }
309
367
  if (decisao.tool_call) {
368
+ // Ferramenta que não existe é o outro jeito de o motor devolver algo
369
+ // que este lado não sabe processar. Antes viraria uma ação falhada
370
+ // com "ferramenta desconhecida" — ruído para o usuário por um erro
371
+ // que é do motor. Volta para ele, com a lista do que existe.
372
+ const nome = decisao.tool_call.name;
373
+ if (validos && validos.length && validos.indexOf(nome) === -1) {
374
+ if (pedirCorrecao && tentativa < MAX_CORRECOES) {
375
+ try {
376
+ const novo = await pedirCorrecao(textoDeCorrecao(
377
+ JSON.stringify(decisao),
378
+ 'a ferramenta "' + nome + '" não existe. As que existem: ' + validos.join(', ') + '.'));
379
+ return await emitirDecisao(novo, onEvent, pedirCorrecao, tentativa + 1, validos);
380
+ } catch (e) { /* motor mudo: segue e deixa o loop tratar */ }
381
+ }
382
+ onEvent({ erro: 'Failed to call a function: ferramenta "' + nome + '" não existe — repetindo a rodada.' });
383
+ return;
384
+ }
310
385
  // lib/act.js usa tc.id pra parear a mensagem role:'tool' com este
311
386
  // tool_call (messages.push({ tool_call_id: tc.id, ... })) — sem id
312
387
  // aqui, esse pareamento quebra.
313
- onEvent({ tool_call: { id: proximoId(), name: decisao.tool_call.name, arguments: decisao.tool_call.arguments || {} } });
388
+ onEvent({ tool_call: { id: proximoId(), name: nome, arguments: decisao.tool_call.arguments || {} } });
314
389
  }
315
390
  else if (decisao.assistant_message) {
316
391
  // O modelo pode confundir a forma com a do tool_call (que É um
@@ -359,7 +434,19 @@ async function requestActViaOrca(payload, onEvent) {
359
434
  // uma string única (`text`). Confirmado rodando de verdade (Task 9).
360
435
  const t = leitura.result.terminal || {};
361
436
  sessao.cursor = t.nextCursor != null ? String(t.nextCursor) : sessao.cursor;
362
- emitirDecisao((t.tail || []).join('\n'), onEvent);
437
+ // No terminal a correção é só mais uma mensagem na MESMA sessão: o
438
+ // motor já tem o protocolo e a conversa toda em contexto, então
439
+ // basta dizer o que saiu errado.
440
+ const pedirCorrecao = (aviso) => {
441
+ rodarOrca(['terminal', 'send', '--terminal', sessao.handle, '--text', aviso, '--enter']);
442
+ esperarIdleOrca(sessao.handle, 120000);
443
+ const outra = JSON.parse(rodarOrca(['terminal', 'read', '--terminal', sessao.handle,
444
+ '--cursor', sessao.cursor, '--json']));
445
+ const t2 = outra.result.terminal || {};
446
+ sessao.cursor = t2.nextCursor != null ? String(t2.nextCursor) : sessao.cursor;
447
+ return (t2.tail || []).join('\n');
448
+ };
449
+ await emitirDecisao((t.tail || []).join('\n'), onEvent, pedirCorrecao, 0, nomesDasTools(payload));
363
450
  } catch (e) {
364
451
  // Handle obsoleto (terminal fechado, ORCA reiniciou) — documentado
365
452
  // pelo próprio `orca agent-context` como terminal_handle_stale. Zera
@@ -414,7 +501,26 @@ async function requestActViaHeadless(payload, onEvent) {
414
501
  '--dangerously-skip-permissions',
415
502
  ], { encoding: 'utf-8', timeout: 180000, input: prompt, maxBuffer: 32 * 1024 * 1024 });
416
503
  const r = JSON.parse(saida);
417
- emitirDecisao(r.result || '', onEvent);
504
+ // A correção é uma segunda ida ao motor, com o mesmo protocolo e o
505
+ // diagnóstico do que saiu errado. Barata: o prompt é curto, e evita que
506
+ // um erro de sintaxe do motor vire erro na cara do usuário.
507
+ const pedirCorrecao = (aviso) => {
508
+ const s = execFileSync('claude', [
509
+ '-p', '--output-format', 'json', '--dangerously-skip-permissions',
510
+ ], {
511
+ encoding: 'utf-8', timeout: 120000, maxBuffer: 32 * 1024 * 1024,
512
+ input: corpoBootstrap + '\n\n' + aviso,
513
+ });
514
+ return JSON.parse(s).result || '';
515
+ };
516
+ await emitirDecisao(r.result || '', onEvent, pedirCorrecao, 0, nomesDasTools(payload));
517
+ }
518
+
519
+ /** Os nomes que o sistema realmente sabe executar, tirados do payload. */
520
+ function nomesDasTools(payload) {
521
+ return ((payload && payload.tools) || [])
522
+ .map((t) => (t && t.function && t.function.name) || t.name)
523
+ .filter(Boolean);
418
524
  }
419
525
 
420
526
  /** Mesma forma de api.requestAct(server, payload, onEvent) — `server` não
@@ -440,4 +546,5 @@ module.exports = {
440
546
  SKILL_DIR, SKILL_PATH, garantirSkill,
441
547
  temClaudeCode, temOrca, orcaAlcancavel, escolherCaminho,
442
548
  garantirEngine, requestAct, emitirDecisao,
549
+ _emitirDecisao: emitirDecisao,
443
550
  };
package/lib/config.js CHANGED
@@ -165,6 +165,30 @@ function appendGeneralMemory(entry) {
165
165
  try {
166
166
  ensureStructure();
167
167
  fs.appendFileSync(GENERAL_MEMORY, `\n### ${today()}\n${entry}\n`, 'utf-8');
168
+ podarMemoriaGeral();
169
+ } catch {}
170
+ }
171
+
172
+ // A memória geral entra em TODO pedido, então tamanho aqui é custo por
173
+ // chamada. Sem poda ela cresceria para sempre e, ironicamente, seria cortada
174
+ // no meio pelo orçamento de tokens — perdendo justo o mais recente. Podando
175
+ // pelo fim, o que sobrevive é o que vale: o começo escrito à mão pelo usuário
176
+ // e as últimas coisas aprendidas.
177
+ const LIMITE_MEMORIA_GERAL = 12000;
178
+
179
+ function podarMemoriaGeral() {
180
+ try {
181
+ const texto = fs.readFileSync(GENERAL_MEMORY, 'utf-8');
182
+ if (texto.length <= LIMITE_MEMORIA_GERAL) return;
183
+ const blocos = texto.split(/\n(?=### )/);
184
+ const cabeca = blocos.shift() || ''; // o template e o que o usuário escreveu
185
+ let corpo = '';
186
+ // De trás para frente: os mais novos entram primeiro e ficam.
187
+ for (let i = blocos.length - 1; i >= 0; i--) {
188
+ if (cabeca.length + corpo.length + blocos[i].length > LIMITE_MEMORIA_GERAL) break;
189
+ corpo = '\n' + blocos[i] + corpo;
190
+ }
191
+ fs.writeFileSync(GENERAL_MEMORY, cabeca + corpo, 'utf-8');
168
192
  } catch {}
169
193
  }
170
194
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "primocode",
3
- "version": "8.13.0",
3
+ "version": "8.15.0",
4
4
  "description": "PrimoCode — agente de engenharia com IA e cursor próprio. Modelos grátis. 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"
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"
12
12
  },
13
13
  "engines": {
14
14
  "node": ">=18.17.0"
@@ -137,8 +137,53 @@ Sem declarar nada, os elementos já entram em cascata na ordem em que aparecem.
137
137
  `meia-noite` `claro` `papel` `neon` `brasa` `floresta` `retro` `oceano` `doce` `mono`
138
138
 
139
139
  São esses dez, e só. Nome fora da lista é recusado: escuro é `meia-noite`, não
140
- `noite`. Cor de marca não vira tema — ela entra em `cor` nos elementos.
141
- Vale no documento ou por cena. Uma cena com tema diferente no meio cria respiro —
140
+ `noite`. Vale no documento ou por cena.
141
+
142
+ ### Identidade — o que separa um vídeo genérico de um da marca
143
+
144
+ Uma peça sem identidade sai igual a todas as outras. Três coisas resolvem, e
145
+ custam uma linha cada:
146
+
147
+ ```json
148
+ "marca": { "logo": "/midia/logo.png", "cor": "#ff5a36", "canto": "inferior-direita" }
149
+ ```
150
+
151
+ `marca` vale no **documento**: o logo aparece em todas as cenas, no canto, e
152
+ `cor` vira a cor de destaque de números, gráficos e detalhes. Ponha
153
+ `"semMarca": true` na capa se ela já tiver o logo grande no meio.
154
+
155
+ Se o usuário citar a empresa dele e não houver logo na galeria, **peça o
156
+ arquivo** — `studio_midia` sem `arquivo` lista o que já existe. Não invente
157
+ logo, e não deixe o vídeo sem nenhum quando ele é institucional.
158
+
159
+ E varie de cena para cena:
160
+
161
+ - **`fundo` diferente** ao longo do vídeo. Onze existem: `malha` `brilho`
162
+ `grade` `pontos` `suave` `vinheta` `aurora` `nevoa` `ondas` `raios`
163
+ `xadrez`. Dez cenas com o mesmo fundo é o que faz parecer template.
164
+ - **`tom`** por cena muda a cor do destaque só ali — bom para separar
165
+ capítulos.
166
+ - **`cor`** no elemento de texto, quando uma palavra precisa saltar.
167
+ - **`tema` por cena**: uma citação em `papel` no meio de um deck escuro é
168
+ respiro. Uma vez, não cinco.
169
+
170
+ ### Objetos 3D
171
+
172
+ Volume de verdade, com perspectiva — serve para produto, capa e abertura.
173
+
174
+ ```json
175
+ { "tipo": "objeto3d", "forma": "cubo", "tamanho": 320, "girar": 14,
176
+ "faces": ["Produto", "Dados", "IA", "Automação", "Nuvem", "Escala"] }
177
+ ```
178
+
179
+ `forma`: `cubo` · `caixa` (achatada) · `cartao` (com espessura) · `prisma` ·
180
+ `painel` (superfície flutuando). `faces` aceita texto **ou** URL de imagem —
181
+ o logo nas seis faces de um cubo girando é abertura pronta. `girar` é a volta
182
+ completa em segundos (`0` deixa parado no `angulo` dado), `inclinar` levanta
183
+ ou baixa a câmera, `cores` pinta face a face.
184
+
185
+ Aceita `chaves` como qualquer elemento: dá para o cubo atravessar a cena
186
+ girando. Use **um** por cena — dois objetos girando disputam o olho. Uma cena com tema diferente no meio cria respiro —
142
187
  use uma vez, não cinco.
143
188
 
144
189
  ### Mídia do computador
package/studio/server.py CHANGED
@@ -16,6 +16,7 @@ import mimetypes
16
16
  import os
17
17
  import re
18
18
  import sys
19
+ import threading
19
20
  import time
20
21
  import unicodedata
21
22
  import uuid
@@ -299,6 +300,113 @@ def preparar_voz(doc):
299
300
  pass
300
301
 
301
302
 
303
+ # ------------------------------------------------------- narração em arquivo --
304
+ #
305
+ # O usuário testou e disse: "as vozes tá a mesma merda". Estava certo, e a
306
+ # causa era tempo, não motor. A narração era sintetizada NA HORA DE TOCAR:
307
+ # quem apertava play antes de o Piper terminar de baixar ouvia o motor de
308
+ # reserva — a voz robótica de sempre. Como o download leva minutos e o vídeo
309
+ # fica pronto em segundos, era o que acontecia SEMPRE na primeira vez.
310
+ #
311
+ # Agora a narração vira ARQUIVO assim que o roteiro é gravado. O trabalho
312
+ # roda no fundo, espera o motor bom ficar pronto (esperar é de graça: ninguém
313
+ # está olhando) e grava um .wav por cena. O player já preferia
314
+ # `cena.narracaoAudio` quando existe — passa a existir sempre.
315
+ #
316
+ # De quebra, conserta duas coisas que ninguém tinha ligado: o vídeo gravado
317
+ # em MP4 carrega a voz boa, e tocar de novo não re-sintetiza nada.
318
+
319
+ _narrando = set()
320
+ _tranca_narracao = threading.Lock()
321
+
322
+
323
+ def _assinatura_voz(texto, voz):
324
+ corpo = "%s|%s|%s|%s" % (texto, voz.get("voz") or "", voz.get("grave") or "",
325
+ voz.get("velocidade") or "")
326
+ return hashlib.sha1(corpo.encode("utf-8")).hexdigest()[:10]
327
+
328
+
329
+ def _gravar_narracao(arq_id):
330
+ """Sintetiza a narração de cada cena e guarda em midia/."""
331
+ try:
332
+ # Esperar o motor: são minutos, mas ninguém está esperando na frente
333
+ # da tela — e o resultado é a diferença entre voz de gente e voz de
334
+ # robô no vídeo que a pessoa vai apresentar.
335
+ for _ in range(120):
336
+ if voz_lib.pronta():
337
+ break
338
+ voz_lib.instalar()
339
+ time.sleep(5)
340
+ if not voz_lib.pronta():
341
+ return
342
+
343
+ a = ler(arq_id)
344
+ if not a:
345
+ return
346
+ doc = a.get("doc") or {}
347
+ voz = doc.get("voz") if isinstance(doc.get("voz"), dict) else {}
348
+ mudou = False
349
+
350
+ for i, c in enumerate(doc.get("cenas") or []):
351
+ if not isinstance(c, dict):
352
+ continue
353
+ texto = c.get("narracao")
354
+ if not isinstance(texto, str) or not texto.strip():
355
+ continue
356
+ assinatura = _assinatura_voz(texto, voz)
357
+ # Já gravada e com o mesmo texto e a mesma voz: nada a fazer.
358
+ if c.get("narracaoVoz") == assinatura and c.get("narracaoAudio"):
359
+ continue
360
+ # Gravação do próprio usuário nunca é sobrescrita — a voz dele
361
+ # vale mais que qualquer síntese.
362
+ if c.get("narracaoAudio") and not c.get("narracaoVoz"):
363
+ continue
364
+ try:
365
+ audio = voz_lib.sintetizar(
366
+ texto, voz.get("voz"),
367
+ grave=bool(voz.get("grave")),
368
+ velocidade=float(voz.get("velocidade") or 1))
369
+ except Exception as e:
370
+ sys.stderr.write("[voz] cena %d falhou: %s\n" % (i + 1, e))
371
+ continue
372
+ if not audio:
373
+ continue
374
+ nome = "narr-%s-%d-%s.wav" % (arq_id[:28], i + 1, assinatura)
375
+ with open(os.path.join(MIDIA, nome), "wb") as fh:
376
+ fh.write(audio)
377
+ c["narracaoAudio"] = "/midia/" + nome
378
+ c["narracaoVoz"] = assinatura
379
+ mudou = True
380
+
381
+ if mudou:
382
+ # Relê antes de gravar: o agente pode ter mexido no documento
383
+ # enquanto a síntese rodava, e o trabalho dele vale mais.
384
+ atual = ler(arq_id)
385
+ if atual:
386
+ cenas = (atual.get("doc") or {}).get("cenas") or []
387
+ for i, c in enumerate(doc.get("cenas") or []):
388
+ if i < len(cenas) and c.get("narracaoAudio") \
389
+ and cenas[i].get("narracao") == c.get("narracao"):
390
+ cenas[i]["narracaoAudio"] = c["narracaoAudio"]
391
+ cenas[i]["narracaoVoz"] = c["narracaoVoz"]
392
+ gravar(atual)
393
+ except Exception as e:
394
+ sys.stderr.write("[voz] narração em arquivo falhou: %s\n" % e)
395
+ finally:
396
+ with _tranca_narracao:
397
+ _narrando.discard(arq_id)
398
+
399
+
400
+ def narrar_em_arquivo(arq_id, doc):
401
+ if not _tem_narracao(doc):
402
+ return
403
+ with _tranca_narracao:
404
+ if arq_id in _narrando:
405
+ return
406
+ _narrando.add(arq_id)
407
+ threading.Thread(target=_gravar_narracao, args=(arq_id,), daemon=True).start()
408
+
409
+
302
410
  def mesclar(antigo, novo):
303
411
  """O documento novo por cima do antigo, chave a chave.
304
412
 
@@ -436,12 +544,14 @@ def criar(dados):
436
544
 
437
545
  preparar_voz(doc)
438
546
  arq_id = ferramenta + "-" + slugify(titulo) + "-" + uuid.uuid4().hex[:5]
439
- return gravar({
547
+ salvo = gravar({
440
548
  "id": arq_id,
441
549
  "ferramenta": ferramenta,
442
550
  "titulo": titulo,
443
551
  "doc": doc or exemplo(ferramenta),
444
552
  })
553
+ narrar_em_arquivo(arq_id, salvo.get("doc") or {})
554
+ return salvo
445
555
 
446
556
 
447
557
  # Frases que só existem nos EXEMPLOS das specs. Documento que chega com uma
@@ -512,7 +622,8 @@ def validar_doc(modelo, doc):
512
622
  # quando o texto estava noutro campo. Era o "vídeo com música mas tudo
513
623
  # preto": documento válido na forma, invisível na tela.
514
624
  TIPOS_VALIDOS = {"texto", "lista", "cartoes", "numeros", "tabela", "codigo",
515
- "citacao", "forma", "imagem", "icone", "grafico", "no", "conector"}
625
+ "citacao", "forma", "imagem", "icone", "grafico", "no", "conector",
626
+ "objeto3d"}
516
627
 
517
628
  # Tema inventado tinha o mesmo destino silencioso do tipo inventado: o CSS
518
629
  # não casa com [data-tema="noite"], nenhuma cor é definida e a peça sai
@@ -538,9 +649,16 @@ def validar_doc(modelo, doc):
538
649
  if problema:
539
650
  return problema
540
651
  dur = c.get("duracao")
541
- if dur is not None and not isinstance(dur, (int, float)):
652
+ if dur is not None and (isinstance(dur, bool) or not isinstance(dur, (int, float))):
542
653
  return ('cena %d com "duracao" que não é número (veio %r). Use número de segundos: '
543
654
  '"duracao": 3' % (i + 1, dur))
655
+ # Duração zero passava: era número, afinal. E um vídeo cujas cenas
656
+ # somam zero é o "vídeo de 0 segundos" que o usuário viu mais de
657
+ # uma vez — arquivo válido, conteúdo inalcançável.
658
+ if isinstance(dur, (int, float)) and not isinstance(dur, bool) and dur <= 0:
659
+ return ('cena %d tem "duracao": %r. Zero (ou negativo) faz o vídeo inteiro durar '
660
+ 'nada — foi assim que saiu um vídeo de 0 segundos. Use pelo menos 1.'
661
+ % (i + 1, dur))
544
662
  for j, e in enumerate(els):
545
663
  if not isinstance(e, dict) or not e.get("tipo"):
546
664
  return 'elemento %d da cena %d sem "tipo" (texto, imagem, lista, numeros, forma...)' % (j + 1, i + 1)
@@ -1154,7 +1272,21 @@ class Handler(SimpleHTTPRequestHandler):
1154
1272
  problema = validar_doc(
1155
1273
  POR_SLUG.get(a.get("ferramenta"), {}).get("modelo"), novo)
1156
1274
  if problema:
1157
- return self.js({"erro": problema}, 400)
1275
+ # As frases de validar_doc foram escritas para a CRIAÇÃO:
1276
+ # elas terminam em "crie de novo" e "refaça". Devolver isso
1277
+ # numa ATUALIZAÇÃO manda o agente fazer exatamente a coisa
1278
+ # errada — e foi o que aconteceu: ele recebia o erro, jogava
1279
+ # fora o vídeo pronto e recomeçava do zero, às vezes saindo
1280
+ # pior. Aqui a instrução é a oposta, e vem por último para
1281
+ # ser a última coisa que ele lê.
1282
+ problema = re.sub(r"(crie de novo|e refaça)\.?", "", problema).strip()
1283
+ return self.js({
1284
+ "erro": problema
1285
+ + ' Isto é uma ATUALIZAÇÃO: NÃO crie outro arquivo e NÃO recomece do zero. '
1286
+ 'Corrija só o que está errado acima e chame studio_atualizar de novo '
1287
+ 'com o MESMO id "%s". O arquivo continua intacto — nada foi perdido.' % a["id"],
1288
+ "id": a["id"],
1289
+ }, 400)
1158
1290
 
1159
1291
  preparar_voz(novo)
1160
1292
  versao = guardar_versao(a)
@@ -1163,6 +1295,7 @@ class Handler(SimpleHTTPRequestHandler):
1163
1295
  if "doc" in dados:
1164
1296
  a["doc"] = novo
1165
1297
  salvo = gravar(a)
1298
+ narrar_em_arquivo(a["id"], salvo.get("doc") or {})
1166
1299
  if versao:
1167
1300
  # O agente vê isto no resultado da tool: se ele estragou algo, o
1168
1301
  # caminho de volta está na resposta, não numa doc que ele não leu.
@@ -47,8 +47,16 @@ function _porPalavra(html) {
47
47
  function aplicarFundo(no, fundo) {
48
48
  if (!fundo) return;
49
49
  if (typeof fundo === "string") {
50
- if (/^(malha|brilho|grade|pontos|suave|vinheta)$/.test(fundo)) no.setAttribute("data-fundo", fundo);
51
- else no.style.background = fundo;
50
+ // Os ONZE que o render.css define.
51
+ //
52
+ // A lista aqui tinha seis. Os outros cinco — aurora, raios, ondas,
53
+ // nevoa, xadrez — caíam no `else` e viravam `background: "aurora"`:
54
+ // CSS inválido, fundo NENHUM. E são justamente os que a spec recomenda
55
+ // para vídeo, então toda cena que pedia aurora ou raios saía chapada.
56
+ // Era metade do "os vídeos ficaram todos iguais".
57
+ if (/^(malha|brilho|grade|pontos|suave|vinheta|aurora|nevoa|ondas|raios|xadrez)$/.test(fundo)) {
58
+ no.setAttribute("data-fundo", fundo);
59
+ } else no.style.background = fundo;
52
60
  return;
53
61
  }
54
62
  if (fundo.tipo === "gradiente") {
@@ -148,7 +156,8 @@ function svgGrafico(e, l, a) {
148
156
  /* -------------------------------------------------------------- elementos */
149
157
 
150
158
  var TIPOS_CONHECIDOS = { texto: 1, lista: 1, cartoes: 1, numeros: 1, tabela: 1, codigo: 1,
151
- citacao: 1, forma: 1, imagem: 1, icone: 1, grafico: 1, no: 1, conector: 1 };
159
+ citacao: 1, forma: 1, imagem: 1, icone: 1, grafico: 1, no: 1, conector: 1,
160
+ objeto3d: 1 };
152
161
 
153
162
  /* ── chaves de animação ──────────────────────────────────────────────────
154
163
  Transforma a lista de instantes de um elemento numa regra @keyframes de
@@ -212,6 +221,157 @@ function aplicarChaves(no, e, ctx) {
212
221
  no.style.animationIterationCount = e.repetir ? "infinite" : "1";
213
222
  }
214
223
 
224
+ /* ── identidade da marca ─────────────────────────────────────────────────
225
+ "Ele não escolhe as cores dos textos, não escolhe tema, não escolhe uma
226
+ imagem de fundo, não coloca uma logo. Ficou horrível."
227
+
228
+ O logo era o mais gritante: não havia NENHUM jeito de pôr um. Dava para
229
+ subir a imagem e posicionar à mão, cena por cena — o que ninguém faz num
230
+ vídeo de dez cenas, e a IA nunca fazia.
231
+
232
+ `doc.marca` resolve de uma vez: o logo aparece em todas as cenas, no
233
+ canto, no tamanho certo, sem entrar no fluxo do layout. E `marca.cor`
234
+ define a cor de destaque do documento inteiro, que é o que dá identidade
235
+ aos números, às barras do gráfico e aos detalhes.
236
+
237
+ "marca": { "logo": "/midia/logo.png", "cor": "#ff5a36",
238
+ "canto": "inferior-direita", "opacidade": 0.85 }
239
+
240
+ `cena.semMarca: true` tira de uma cena específica — a capa costuma querer
241
+ o logo grande no meio, não pequeno no canto. */
242
+ var CANTOS = {
243
+ "superior-esquerda": "top:5.2%;left:5%",
244
+ "superior-direita": "top:5.2%;right:5%",
245
+ "inferior-esquerda": "bottom:5.2%;left:5%",
246
+ "inferior-direita": "bottom:5.2%;right:5%",
247
+ };
248
+
249
+ function aplicarMarca(no, cena, doc, H) {
250
+ var m = doc && doc.marca;
251
+ if (!m || typeof m !== "object") return;
252
+ // A cor da marca vira a cor de destaque de tudo, a menos que a cena diga
253
+ // outra — é ela que pinta número, barra de gráfico e detalhe.
254
+ if (m.cor && !cena.tom) no.style.setProperty("--c-tom", m.cor);
255
+ if (!m.logo || cena.semMarca) return;
256
+
257
+ var caixa = document.createElement("div");
258
+ caixa.className = "marca-dagua";
259
+ caixa.style.cssText = "position:absolute;z-index:6;pointer-events:none;"
260
+ + (CANTOS[m.canto] || CANTOS["inferior-direita"])
261
+ + ";opacity:" + (m.opacidade != null ? m.opacidade : 0.85);
262
+ var img = document.createElement("img");
263
+ img.src = m.logo;
264
+ img.alt = "";
265
+ // Altura proporcional à tela, resolvida em PIXELS: a caixa é absoluta e
266
+ // sem altura própria, e porcentagem dentro dela não resolveria — o logo
267
+ // sairia no tamanho original, gigante num caso e sumido no outro.
268
+ var alturaPx = Math.round((H || 1080) * ((m.altura || 4.6) / 100));
269
+ img.style.cssText = "height:" + alturaPx + "px;width:auto;display:block";
270
+ caixa.appendChild(img);
271
+ no.appendChild(caixa);
272
+ }
273
+
274
+ /* ── objetos 3D ──────────────────────────────────────────────────────────
275
+ "Quero que tu coloque a função ali no vídeo, na apresentação de slides,
276
+ pra fazer objeto 3D com animação, tudo assim, bem top."
277
+
278
+ Isto é 3D de verdade — perspectiva, faces, profundidade — feito com
279
+ transform 3D do próprio navegador. Sem WebGL e sem biblioteca: o Studio
280
+ não tem dependência nenhuma e não vai passar a ter por causa disto. O que
281
+ dá para fazer assim cobre o que um vídeo institucional pede: um cubo
282
+ girando com o logo nas faces, um cartão com espessura, uma pilha de
283
+ camadas em profundidade.
284
+
285
+ { "tipo": "objeto3d", "forma": "cubo", "tamanho": 320,
286
+ "faces": ["Produto", "Dados", "IA"], "girar": 14 }
287
+
288
+ `girar` é a volta completa em segundos; 0 deixa parado no ângulo dado.
289
+ `faces` aceita texto ou URL de imagem. E como o elemento é um nó normal,
290
+ as chaves de animação valem aqui também: dá para o cubo atravessar a cena
291
+ girando. */
292
+ var FORMAS_3D = { cubo: 1, caixa: 1, cartao: 1, prisma: 1, painel: 1 };
293
+
294
+ function _face3d(conteudo, transform, cor) {
295
+ var f = document.createElement("div");
296
+ f.className = "face3d";
297
+ f.style.transform = transform;
298
+ if (cor) f.style.background = cor;
299
+ if (/^(https?:|\/)/.test(String(conteudo || ""))) {
300
+ var im = document.createElement("img");
301
+ im.src = conteudo;
302
+ im.alt = "";
303
+ f.appendChild(im);
304
+ } else if (conteudo) {
305
+ f.textContent = conteudo;
306
+ }
307
+ return f;
308
+ }
309
+
310
+ function montar3d(no, e) {
311
+ var forma = FORMAS_3D[e.forma] ? e.forma : "cubo";
312
+ var t = +e.tamanho || 320;
313
+ // A caixa e o prisma são o cubo achatado num eixo: uma medida só muda a
314
+ // silhueta inteira, e o autor não precisa pensar em seis faces.
315
+ var prof = forma === "cartao" || forma === "painel" ? Math.max(6, Math.round(t * 0.045))
316
+ : (forma === "caixa" ? Math.round(t * 0.62) : t);
317
+ var faces = Array.isArray(e.faces) ? e.faces : [];
318
+ var cores = Array.isArray(e.cores) ? e.cores : [];
319
+
320
+ no.classList.add("obj3d", "f-" + forma);
321
+ no.style.width = _px(t);
322
+ no.style.height = _px(forma === "cartao" ? Math.round(t * 0.62) : t);
323
+ no.style.perspective = _px(e.perspectiva || t * 3.4);
324
+
325
+ var corpo = document.createElement("div");
326
+ corpo.className = "corpo3d";
327
+ corpo.style.width = _px(t);
328
+ corpo.style.height = _px(forma === "cartao" ? Math.round(t * 0.62) : t);
329
+
330
+ var meia = prof / 2;
331
+ var h = forma === "cartao" ? Math.round(t * 0.62) : t;
332
+ var lados = [
333
+ ["translateZ(" + meia + "px)", 0],
334
+ ["rotateY(180deg) translateZ(" + meia + "px)", 1],
335
+ ["rotateY(90deg) translateZ(" + (t / 2) + "px)", 2],
336
+ ["rotateY(-90deg) translateZ(" + (t / 2) + "px)", 3],
337
+ ["rotateX(90deg) translateZ(" + (h / 2) + "px)", 4],
338
+ ["rotateX(-90deg) translateZ(" + (h / 2) + "px)", 5],
339
+ ];
340
+ // Cartão e painel têm só frente e verso: as bordas viram espessura, e
341
+ // desenhar seis faces num objeto de 6px de profundidade só faria sujeira.
342
+ var quantas = (forma === "cartao" || forma === "painel") ? 2 : 6;
343
+ for (var i = 0; i < quantas; i++) {
344
+ var lado = lados[i];
345
+ var f = _face3d(faces[lado[1]] != null ? faces[lado[1]] : (faces[0] || ""),
346
+ lado[0], cores[lado[1] % (cores.length || 1)]);
347
+ // As laterais são estreitas (a profundidade) e o topo/base são baixos.
348
+ //
349
+ // Só mudar a medida não bastava: a face é `inset: 0`, então encostava na
350
+ // borda esquerda em vez de ficar no meio — e a rotação, que gira em
351
+ // torno do centro, abria o objeto. O cubo saía com as faces soltas, sem
352
+ // fechar. Reposicionar a face no centro antes de girar fecha a caixa.
353
+ if (i >= 2 && i < 4) {
354
+ f.style.width = _px(prof);
355
+ f.style.left = _px(Math.round((t - prof) / 2));
356
+ }
357
+ if (i >= 4) {
358
+ f.style.height = _px(prof);
359
+ f.style.top = _px(Math.round((h - prof) / 2));
360
+ }
361
+ corpo.appendChild(f);
362
+ }
363
+
364
+ var giro = e.girar == null ? 14 : +e.girar;
365
+ var inclina = e.inclinar == null ? -14 : +e.inclinar;
366
+ if (giro > 0) {
367
+ corpo.style.animation = "giro3d " + giro + "s linear infinite";
368
+ corpo.style.setProperty("--inc", inclina + "deg");
369
+ } else {
370
+ corpo.style.transform = "rotateX(" + inclina + "deg) rotateY(" + (e.angulo || -28) + "deg)";
371
+ }
372
+ no.appendChild(corpo);
373
+ }
374
+
215
375
  function renderElemento(e, ctx) {
216
376
  var no = document.createElement("div");
217
377
  var tipo = e.tipo || "texto";
@@ -451,6 +611,11 @@ function renderElemento(e, ctx) {
451
611
  break;
452
612
  }
453
613
 
614
+ case "objeto3d": {
615
+ montar3d(no, e);
616
+ break;
617
+ }
618
+
454
619
  case "no": {
455
620
  if (e.destaque) no.classList.add("destaque");
456
621
  if (e.formato === "circulo") no.classList.add("circulo");
@@ -642,6 +807,7 @@ function renderCena(cena, doc, op) {
642
807
  if (cena.tom) no.style.setProperty("--c-tom", cena.tom);
643
808
  if (cena.tom2) no.style.setProperty("--c-tom2", cena.tom2);
644
809
  aplicarFundo(no, cena.fundo);
810
+ aplicarMarca(no, cena, doc, H);
645
811
 
646
812
  var layout = cena.layout || (op.layoutPadrao || "titulo");
647
813
  var elementos = (cena.elementos || []).slice();
@@ -582,3 +582,50 @@
582
582
  .cena .el { animation: none !important; opacity: 1 !important; }
583
583
  .folha-doc { padding: 0; width: auto; min-height: 0; }
584
584
  }
585
+
586
+ /* ══════════════════════════════════════════════════════════════════════════
587
+ Objetos 3D
588
+
589
+ Perspectiva de verdade, com transform 3D do navegador. Sem WebGL e sem
590
+ biblioteca — o Studio não tem dependência e não vai passar a ter por isto.
591
+ ═════════════════════════════════════════════════════════════════════════ */
592
+ .el.obj3d { display: grid; place-items: center; }
593
+ .corpo3d {
594
+ position: relative;
595
+ transform-style: preserve-3d;
596
+ transform: rotateX(-14deg) rotateY(-28deg);
597
+ }
598
+ .face3d {
599
+ /* Sem isto a borda e o padding SOMAM ao tamanho pedido: cada face fica
600
+ maior do que a medida usada no translateZ, e o cubo não fecha — as
601
+ faces ficam soltas, com fresta. O render.css é usado sozinho no HTML
602
+ exportado, então não dá para contar com o reset do core.css. */
603
+ box-sizing: border-box;
604
+ position: absolute; inset: 0;
605
+ display: grid; place-items: center;
606
+ background: var(--c-tom, #ff5a36);
607
+ color: #fff;
608
+ font: 700 clamp(14px, 2.2vw, 40px)/1.15 var(--f-display, inherit);
609
+ text-align: center;
610
+ padding: 6%;
611
+ border: 1px solid rgba(255, 255, 255, .16);
612
+ border-radius: 6px;
613
+ box-shadow: inset 0 0 90px rgba(0, 0, 0, .34);
614
+ backface-visibility: visible;
615
+ overflow: hidden;
616
+ }
617
+ .face3d img { width: 100%; height: 100%; object-fit: cover; display: block; }
618
+ /* cada face pega uma luz diferente, senão o cubo vira uma mancha só */
619
+ .face3d:nth-child(2) { filter: brightness(.78); }
620
+ .face3d:nth-child(3) { filter: brightness(.88); }
621
+ .face3d:nth-child(4) { filter: brightness(.7); }
622
+ .face3d:nth-child(5) { filter: brightness(1.1); }
623
+ .face3d:nth-child(6) { filter: brightness(.6); }
624
+
625
+ @keyframes giro3d {
626
+ from { transform: rotateX(var(--inc, -14deg)) rotateY(0deg); }
627
+ to { transform: rotateX(var(--inc, -14deg)) rotateY(360deg); }
628
+ }
629
+
630
+ /* painel: sem moldura, é uma superfície flutuando em profundidade */
631
+ .el.obj3d.f-painel .face3d { border: 0; box-shadow: 0 40px 90px -30px rgba(0,0,0,.7); }