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 +18 -0
- package/lib/act.js +17 -0
- package/lib/claude-engine.js +114 -7
- package/lib/config.js +24 -0
- package/package.json +2 -2
- package/studio/ferramentas/01-modelos.md +47 -2
- package/studio/server.py +137 -4
- package/studio/web/js/render.js +169 -3
- package/studio/web/render.css +47 -0
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 },
|
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/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.
|
|
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`.
|
|
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
|
|
@@ -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
|
-
|
|
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.
|
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); }
|