primocode 8.13.0 → 8.14.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/claude-engine.js +114 -7
- package/package.json +1 -1
- package/studio/ferramentas/01-modelos.md +47 -2
- package/studio/server.py +114 -2
- package/studio/web/js/render.js +169 -3
- package/studio/web/render.css +47 -0
package/lib/claude-engine.js
CHANGED
|
@@ -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
|
-
|
|
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.
|
|
299
|
-
//
|
|
300
|
-
//
|
|
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:
|
|
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
|
-
|
|
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
|
-
|
|
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/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "primocode",
|
|
3
|
-
"version": "8.
|
|
3
|
+
"version": "8.14.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": {
|
|
@@ -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`.
|
|
141
|
-
|
|
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
|
-
|
|
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
|
|
@@ -1163,6 +1274,7 @@ class Handler(SimpleHTTPRequestHandler):
|
|
|
1163
1274
|
if "doc" in dados:
|
|
1164
1275
|
a["doc"] = novo
|
|
1165
1276
|
salvo = gravar(a)
|
|
1277
|
+
narrar_em_arquivo(a["id"], salvo.get("doc") or {})
|
|
1166
1278
|
if versao:
|
|
1167
1279
|
# O agente vê isto no resultado da tool: se ele estragou algo, o
|
|
1168
1280
|
# caminho de volta está na resposta, não numa doc que ele não leu.
|
package/studio/web/js/render.js
CHANGED
|
@@ -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
|
-
|
|
51
|
-
|
|
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();
|
package/studio/web/render.css
CHANGED
|
@@ -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); }
|