primocode 8.12.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 +2 -2
- package/studio/ferramentas/01-modelos.md +47 -2
- package/studio/ferramentas/corte.md +27 -0
- package/studio/server.py +114 -2
- package/studio/web/core.css +133 -2
- package/studio/web/js/editor.js +614 -99
- package/studio/web/js/ferramentas.js +4 -1
- package/studio/web/js/render.js +283 -7
- 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": {
|
|
@@ -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"
|
|
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"
|
|
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`.
|
|
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
|
|
@@ -185,6 +185,33 @@ Na cena, `transicao` define como ela **entra**:
|
|
|
185
185
|
|
|
186
186
|
Vale o mesmo conselho das animações: varie. Tudo em `fade` parece slide, não vídeo.
|
|
187
187
|
|
|
188
|
+
## Chaves de animação
|
|
189
|
+
|
|
190
|
+
`anim` faz o elemento **entrar** e parar por ali. Para ele se mexer ao longo da
|
|
191
|
+
cena — atravessar, crescer devagar, sumir no fim — use `chaves`: uma lista de
|
|
192
|
+
instantes, com `t` de 0 (começo da cena) a 1 (fim).
|
|
193
|
+
|
|
194
|
+
```json
|
|
195
|
+
{ "tipo": "texto", "texto": "Atravessa a tela",
|
|
196
|
+
"chaves": [
|
|
197
|
+
{ "t": 0, "x": -400, "opacidade": 0 },
|
|
198
|
+
{ "t": 0.25, "x": 0, "opacidade": 1 },
|
|
199
|
+
{ "t": 0.75, "x": 0, "opacidade": 1 },
|
|
200
|
+
{ "t": 1, "x": 400, "opacidade": 0 }
|
|
201
|
+
] }
|
|
202
|
+
```
|
|
203
|
+
|
|
204
|
+
Campos de uma chave, todos opcionais: `x` e `y` (**deslocamento** em pixels a
|
|
205
|
+
partir de onde o elemento está — não é posição absoluta), `escala` (1 = tamanho
|
|
206
|
+
normal), `giro` (graus), `opacidade` (0 a 1) e `desfoque` (pixels).
|
|
207
|
+
|
|
208
|
+
No elemento: `"repetir": true` roda sem parar, e `"suavizar": false` dá
|
|
209
|
+
movimento constante em vez de acelerar e frear.
|
|
210
|
+
|
|
211
|
+
Duas chaves já bastam. Quem tem `chaves` ignora `anim` — as duas disputariam o
|
|
212
|
+
mesmo movimento, e a chave é mais específica. Use em **um ou dois** elementos
|
|
213
|
+
por vídeo: tudo se mexendo ao mesmo tempo cansa e esconde a mensagem.
|
|
214
|
+
|
|
188
215
|
## Acabamento
|
|
189
216
|
|
|
190
217
|
Textura por cima da cena inteira — é o que tira a cara de template:
|
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/core.css
CHANGED
|
@@ -381,9 +381,18 @@ input.titulo-arq:focus { outline: none; border-color: var(--primo); background:
|
|
|
381
381
|
}
|
|
382
382
|
.grupo { border-bottom: 1px solid var(--linha); padding: 14px; }
|
|
383
383
|
.grupo > .rotulo { display: block; margin-bottom: 11px; }
|
|
384
|
-
|
|
384
|
+
/* Rótulo em cima, controle embaixo.
|
|
385
|
+
|
|
386
|
+
A coluna do rótulo tinha 62px fixos. "Acabamento" não cabia e saía
|
|
387
|
+
cortado; "Cor do fundo" e "Tema da cena" quebravam em duas linhas e
|
|
388
|
+
desalinhavam a altura de tudo. Era metade da sensação de "estranho" no
|
|
389
|
+
painel. Empilhado, o nome cabe inteiro e o controle usa a largura toda. */
|
|
390
|
+
.linha-campo { display: grid; gap: 4px; margin-bottom: 11px; }
|
|
385
391
|
.linha-campo:last-child { margin-bottom: 0; }
|
|
386
|
-
.linha-campo > span {
|
|
392
|
+
.linha-campo > span {
|
|
393
|
+
font-size: 10.5px; color: var(--fraco2);
|
|
394
|
+
letter-spacing: .04em; text-transform: uppercase;
|
|
395
|
+
}
|
|
387
396
|
.dois { display: grid; grid-template-columns: 1fr 1fr; gap: 8px; }
|
|
388
397
|
.tres { display: grid; grid-template-columns: repeat(3, 1fr); gap: 6px; }
|
|
389
398
|
.opcoes { display: flex; gap: 4px; }
|
|
@@ -721,3 +730,125 @@ body:has(.transporte) .aviso { bottom: 80px; }
|
|
|
721
730
|
.topo { gap: 6px; padding: 0 8px; }
|
|
722
731
|
.marca em { display: none; }
|
|
723
732
|
}
|
|
733
|
+
|
|
734
|
+
/* ══════════════════════════════════════════════════════════════════════════
|
|
735
|
+
Reordenar, modelos e ações de camada
|
|
736
|
+
|
|
737
|
+
Três coisas que faltavam e faziam o editor parecer travado: arrastar não
|
|
738
|
+
dava retorno nenhum, camada não tinha ação à mão, e "nova cena" abria uma
|
|
739
|
+
tela em branco. O visual aqui é de propósito discreto — a peça que a
|
|
740
|
+
pessoa está fazendo é que tem de chamar atenção, não a ferramenta.
|
|
741
|
+
═════════════════════════════════════════════════════════════════════════ */
|
|
742
|
+
|
|
743
|
+
/* enquanto arrasta: o item sai de cena, o alvo se anuncia */
|
|
744
|
+
.arrastando { opacity: .35; }
|
|
745
|
+
.camada.alvo-solta, .miniatura.alvo-solta, .clipe.alvo-solta {
|
|
746
|
+
outline: 2px solid var(--primo);
|
|
747
|
+
outline-offset: 1px;
|
|
748
|
+
}
|
|
749
|
+
.camada, .miniatura, .clipe { -webkit-user-drag: element; }
|
|
750
|
+
.camada:active { cursor: grabbing; }
|
|
751
|
+
|
|
752
|
+
/* ações que aparecem ao passar o mouse na camada */
|
|
753
|
+
.camada { position: relative; }
|
|
754
|
+
.camada-acoes {
|
|
755
|
+
display: none; gap: 1px; margin-left: auto; flex: 0 0 auto;
|
|
756
|
+
}
|
|
757
|
+
.camada:hover .camada-acoes, .camada.ativa .camada-acoes { display: flex; }
|
|
758
|
+
.camada-acoes button {
|
|
759
|
+
background: none; border: 0; padding: 3px;
|
|
760
|
+
border-radius: 5px; cursor: pointer; color: var(--fraco2);
|
|
761
|
+
display: grid; place-items: center;
|
|
762
|
+
}
|
|
763
|
+
.camada-acoes button:hover { background: var(--sup3, rgba(255,255,255,.08)); color: var(--texto); }
|
|
764
|
+
.camada-acoes button:disabled { opacity: .25; cursor: default; }
|
|
765
|
+
.camada-acoes button:disabled:hover { background: none; color: var(--fraco2); }
|
|
766
|
+
.camada-acoes svg { width: 12px; height: 12px; opacity: 1; }
|
|
767
|
+
|
|
768
|
+
/* a transição marcada no próprio clipe da linha do tempo */
|
|
769
|
+
.clipe .tr-marca {
|
|
770
|
+
position: absolute; left: 4px; top: 4px;
|
|
771
|
+
background: rgba(6, 6, 8, .72);
|
|
772
|
+
color: #cbd5e1;
|
|
773
|
+
font-size: 9.5px; letter-spacing: .02em;
|
|
774
|
+
padding: 2px 5px; border-radius: 4px;
|
|
775
|
+
pointer-events: none;
|
|
776
|
+
max-width: calc(100% - 8px);
|
|
777
|
+
overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
|
|
778
|
+
}
|
|
779
|
+
|
|
780
|
+
/* o "+" no fim da tira */
|
|
781
|
+
.clipe-mais {
|
|
782
|
+
flex: 0 0 auto;
|
|
783
|
+
width: 40px; height: 62px;
|
|
784
|
+
border: 1px dashed var(--linha2);
|
|
785
|
+
border-radius: 8px;
|
|
786
|
+
background: none; color: var(--fraco2);
|
|
787
|
+
cursor: pointer;
|
|
788
|
+
display: grid; place-items: center;
|
|
789
|
+
}
|
|
790
|
+
.clipe-mais:hover { border-color: var(--primo); color: var(--primo); }
|
|
791
|
+
.clipe-mais svg { width: 15px; height: 15px; }
|
|
792
|
+
|
|
793
|
+
/* galeria de modelos */
|
|
794
|
+
.folha .caixa.larga { width: min(1040px, 100%); }
|
|
795
|
+
.folha .sub { color: var(--fraco); font-size: 12.5px; margin: -8px 0 18px; }
|
|
796
|
+
.modelos {
|
|
797
|
+
display: grid;
|
|
798
|
+
grid-template-columns: repeat(auto-fill, minmax(210px, 1fr));
|
|
799
|
+
gap: 14px;
|
|
800
|
+
}
|
|
801
|
+
.modelo {
|
|
802
|
+
background: none; border: 0; padding: 0;
|
|
803
|
+
cursor: pointer; text-align: left;
|
|
804
|
+
display: grid; gap: 7px;
|
|
805
|
+
}
|
|
806
|
+
.modelo .caixa-mini {
|
|
807
|
+
position: relative; width: 100%; aspect-ratio: 16/9;
|
|
808
|
+
overflow: hidden; border-radius: 9px;
|
|
809
|
+
border: 1px solid var(--linha);
|
|
810
|
+
background: var(--sup2);
|
|
811
|
+
transition: border-color .12s, transform .12s;
|
|
812
|
+
}
|
|
813
|
+
.modelo .caixa-mini .cena { position: absolute; top: 0; left: 0; transform-origin: top left; }
|
|
814
|
+
.modelo .caixa-mini.vazia { display: grid; place-items: center; border-style: dashed; }
|
|
815
|
+
.modelo .caixa-mini.vazia svg { width: 22px; height: 22px; color: var(--fraco2); }
|
|
816
|
+
.modelo:hover .caixa-mini { border-color: var(--primo); transform: translateY(-2px); }
|
|
817
|
+
.modelo span { font-size: 12.5px; color: var(--fraco); }
|
|
818
|
+
.modelo:hover span { color: var(--texto); }
|
|
819
|
+
|
|
820
|
+
/* seletor de cor compacto: amostras numa faixa, hex ao lado */
|
|
821
|
+
.cor-faixa { display: grid; grid-template-columns: 1fr 62px; gap: 6px; align-items: center; }
|
|
822
|
+
.cor-faixa .cores { flex-wrap: nowrap; overflow-x: auto; padding-bottom: 2px; }
|
|
823
|
+
.cor-faixa .cores::-webkit-scrollbar { height: 0; }
|
|
824
|
+
.cor-faixa .cores button { width: 19px; height: 19px; flex: 0 0 auto; }
|
|
825
|
+
.cor-faixa .cores button.ativa { outline: 2px solid var(--texto); outline-offset: 1px; }
|
|
826
|
+
.cor-faixa .sem-cor {
|
|
827
|
+
background: repeating-linear-gradient(45deg, var(--sup2) 0 4px, var(--linha2) 4px 8px);
|
|
828
|
+
}
|
|
829
|
+
.campo.hex { font-size: 11px; padding-left: 6px; padding-right: 6px; }
|
|
830
|
+
|
|
831
|
+
/* chaves de animação: a régua mostra QUANDO cada uma acontece */
|
|
832
|
+
.regua-chaves { position: relative; height: 26px; margin: 2px 0 2px; }
|
|
833
|
+
.trilho-chaves {
|
|
834
|
+
position: absolute; left: 5px; right: 5px; top: 11px; height: 3px;
|
|
835
|
+
background: var(--sup3); border-radius: 2px;
|
|
836
|
+
}
|
|
837
|
+
.trilho-chaves .pino {
|
|
838
|
+
position: absolute; top: 50%; width: 11px; height: 11px;
|
|
839
|
+
margin: -5.5px 0 0 -5.5px; padding: 0;
|
|
840
|
+
border-radius: 50%; border: 2px solid var(--fundo);
|
|
841
|
+
background: var(--fraco); cursor: pointer;
|
|
842
|
+
}
|
|
843
|
+
.trilho-chaves .pino:hover { background: var(--texto); }
|
|
844
|
+
.trilho-chaves .pino.ativo { background: var(--primo); transform: scale(1.25); }
|
|
845
|
+
.sub-regua { font-size: 10.5px; color: var(--fraco2); margin-bottom: 10px; }
|
|
846
|
+
.opcoes button.ativo { border-color: var(--primo); color: var(--primo); background: var(--primo-suave); }
|
|
847
|
+
.presets { display: grid; gap: 5px; margin-top: 9px; }
|
|
848
|
+
.presets button {
|
|
849
|
+
height: 30px; padding: 0 10px; text-align: left;
|
|
850
|
+
border: 1px solid var(--linha); border-radius: 8px;
|
|
851
|
+
background: var(--sup2); color: var(--fraco);
|
|
852
|
+
font: 500 12px/1 var(--sans); cursor: pointer;
|
|
853
|
+
}
|
|
854
|
+
.presets button:hover { background: var(--sup3); color: var(--texto); border-color: var(--primo); }
|