primocode 8.37.0 → 8.38.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/bin/primocode.js +65 -50
- package/lib/act.js +121 -78
- package/lib/area.js +104 -0
- package/lib/browser.js +110 -2
- package/lib/catalogo.js +7 -2
- package/lib/conta.js +122 -29
- package/lib/especialistas.js +123 -0
- package/lib/fala.js +146 -0
- package/lib/studio.js +319 -10
- package/lib/tools.js +19 -5
- package/lib/ui.js +57 -22
- package/package.json +2 -2
- package/studio/ferramentas/01-modelos.md +3 -1
- package/studio/ferramentas/corte.md +8 -0
- package/studio/ferramentas/prisma.md +8 -0
- package/studio/server.py +2 -1
- package/studio/web/js/icones.js +30 -1
- package/studio/web/js/player.js +58 -16
- package/studio/web/js/render.js +53 -1
- package/studio/web/js/video.js +44 -18
- package/studio/web/render.css +80 -0
package/lib/ui.js
CHANGED
|
@@ -243,28 +243,45 @@ function welcomeBox(version, contexto, opts = {}) {
|
|
|
243
243
|
return out.join('\n');
|
|
244
244
|
}
|
|
245
245
|
|
|
246
|
+
/* As frases que giram enquanto o Primo trabalha. Trocam sozinhas a cada
|
|
247
|
+
poucos segundos, em ordem, e NENHUMA fala de limite, cota, modelo ou espera
|
|
248
|
+
— de propósito: a mesma lista cobre o pensamento comum e a janela do minuto
|
|
249
|
+
que estourou. Quem olha não distingue um do outro, e é isso que o dono
|
|
250
|
+
pediu ("nunca mostrar limitações tipo limite do minuto, tudo deve ocorrer
|
|
251
|
+
ocultamente"). */
|
|
252
|
+
const PENSANDO = ['Primo Code pensando', 'organizando as ideias', 'juntando as peças', 'revendo o caminho', 'pensando no próximo passo'];
|
|
253
|
+
|
|
246
254
|
class Spinner {
|
|
247
255
|
constructor(text='pensando') {
|
|
248
|
-
this.
|
|
256
|
+
this.textos = Array.isArray(text) ? text : [text];
|
|
257
|
+
this.text = this.textos[0];
|
|
249
258
|
this.frames = ['⠋','⠙','⠹','⠸','⠼','⠴','⠦','⠧','⠇','⠏'];
|
|
250
|
-
this.i = 0; this.timer = null;
|
|
259
|
+
this.i = 0; this.timer = null; this.desde = 0; this.largura = 0;
|
|
251
260
|
}
|
|
252
261
|
start() {
|
|
253
262
|
// Fora de um terminal (pipe, CI) a animação só sujaria a saída.
|
|
254
263
|
if (!process.stdout.isTTY) return this;
|
|
264
|
+
this.desde = Date.now();
|
|
255
265
|
this.timer = setInterval(() => {
|
|
256
|
-
|
|
266
|
+
// Com mais de uma frase, troca a cada 4s. A frase nova pode ser mais
|
|
267
|
+
// curta que a anterior: o resto da linha velha ficaria na tela, então
|
|
268
|
+
// a linha é sempre preenchida até a maior largura já escrita.
|
|
269
|
+
if (this.textos.length > 1) this.text = this.textos[Math.floor((Date.now() - this.desde) / 4000) % this.textos.length];
|
|
270
|
+
const cru = ` ${this.frames[this.i]} ${this.text}`;
|
|
271
|
+
const falta = Math.max(0, this.largura - cru.length);
|
|
272
|
+
this.largura = Math.max(this.largura, cru.length);
|
|
273
|
+
process.stdout.write(`\r ${c.muted(this.frames[this.i])} ${c.muted(this.text)}${' '.repeat(falta)}`);
|
|
257
274
|
this.i = (this.i+1) % this.frames.length;
|
|
258
275
|
}, 80);
|
|
259
276
|
return this;
|
|
260
277
|
}
|
|
261
278
|
stop() {
|
|
262
|
-
if (this.timer) { clearInterval(this.timer); this.timer = null; process.stdout.write(`\r${' '.repeat(80)}\r`); }
|
|
279
|
+
if (this.timer) { clearInterval(this.timer); this.timer = null; process.stdout.write(`\r${' '.repeat(Math.max(80, this.largura + 4))}\r`); }
|
|
263
280
|
}
|
|
264
281
|
}
|
|
265
282
|
|
|
266
|
-
|
|
267
|
-
|
|
283
|
+
// O rótulo de cada tool na tela — o que a pessoa lê no lugar do nome interno.
|
|
284
|
+
const ROTULOS = {
|
|
268
285
|
read_file:'Read', write_file:'Write', edit_file:'Edit', multi_edit:'MultiEdit',
|
|
269
286
|
list_dir:'List', glob:'Glob', grep:'Grep', run_shell:'Bash',
|
|
270
287
|
run_in_new_terminal:'Terminal', open_in_browser:'Open', spawn_agent:'Agent',
|
|
@@ -281,14 +298,32 @@ function actionHeader(name, args) {
|
|
|
281
298
|
studio_versoes:'Estúdio·histórico',
|
|
282
299
|
desktop_windows:'Desktop·janelas', desktop_focus:'Desktop·foco',
|
|
283
300
|
desktop_elements:'Desktop·elementos', desktop_click_element:'Desktop·click',
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
301
|
+
studio_midia:'Estúdio·mídia', studio_analisar:'Estúdio·analisar', construir_app:'Construir',
|
|
302
|
+
remember_general:'Lembrar',
|
|
303
|
+
};
|
|
304
|
+
|
|
305
|
+
/* A linha da AÇÃO: `●` na cor da marca e o rótulo em negrito. O resultado
|
|
306
|
+
dela vem embaixo, com `⎿`, um degrau para dentro (ver lib/fala.js). Antes
|
|
307
|
+
cabeçalho e resultado usavam o mesmo `⎿` no mesmo nível, e dez tools
|
|
308
|
+
seguidas eram uma coluna de ganchos sem dizer o que era ação e o que era
|
|
309
|
+
saída. O argumento vem numa linha só e cortado: um comando de três linhas
|
|
310
|
+
no cabeçalho empurra o resultado para fora da vista. */
|
|
311
|
+
function actionHeader(name, args) {
|
|
312
|
+
const label = ROTULOS[name] || name;
|
|
313
|
+
let argStr = args ? (args.consulta || args.path || args.pattern || args.command || args.target || args.url || args.selector || args.text || args.app || args.window || args.name || args.ferramenta || args.titulo || args.description || (args.todos ? `${args.todos.length} tarefas` : '') || (args.x != null ? `${args.x},${args.y}` : '') || args.key || args.arquivo || args.id || JSON.stringify(args).slice(0,60)) : '';
|
|
314
|
+
argStr = String(argStr || '').replace(/\s+/g, ' ').trim();
|
|
315
|
+
if (argStr.length > 96) argStr = argStr.slice(0, 95) + '…';
|
|
316
|
+
return ` ${c.brand('●')} ${c.bold(label)}${argStr ? ' '+c.muted(argStr) : ''}`;
|
|
288
317
|
}
|
|
289
318
|
|
|
290
319
|
// Transforma erro cru (JSON do provedor, código HTTP, erro de rede) numa frase
|
|
291
320
|
// humana. O usuário nunca deveria ver um paredão de JSON.
|
|
321
|
+
//
|
|
322
|
+
// Sem emoji na frente, de propósito: um 💳 ou um 🔧 no começo da linha é a
|
|
323
|
+
// tela gritando "ERRO" antes de a pessoa ler a frase — e o que chega aqui é
|
|
324
|
+
// o que ela PRECISA fazer (crédito, chave, rede), dito com calma por
|
|
325
|
+
// lib/fala.js. O que é passageiro (minuto, 429, 5xx) nem chega: o agente e o
|
|
326
|
+
// chat esperam e repetem sem contar a ninguém.
|
|
292
327
|
function friendlyError(raw) {
|
|
293
328
|
const s = String(raw && raw.message ? raw.message : raw || '').trim();
|
|
294
329
|
const low = s.toLowerCase();
|
|
@@ -308,7 +343,7 @@ function friendlyError(raw) {
|
|
|
308
343
|
// é porque nem o mínimo cabe.
|
|
309
344
|
if (/can only afford|requires more credits|\b402\b/i.test(low)) {
|
|
310
345
|
const q = s.match(/afford\s+(\d+)/i);
|
|
311
|
-
return '
|
|
346
|
+
return 'A conta do OpenRouter não tem crédito para uma resposta deste tamanho'
|
|
312
347
|
+ (q ? ` (dá para ${q[1]} tokens)` : '')
|
|
313
348
|
+ '. Adicione crédito em openrouter.ai/settings/credits, ou use /model fast'
|
|
314
349
|
+ ' para pedidos menores.';
|
|
@@ -318,7 +353,7 @@ function friendlyError(raw) {
|
|
|
318
353
|
// apelidos padrão já são grátis, então quem cai aqui forçou um modelo pago.
|
|
319
354
|
if (/prompt tokens limit exceeded/i.test(low)) {
|
|
320
355
|
const n = s.match(/exceeded:\s*(\d+)\s*>\s*(\d+)/i);
|
|
321
|
-
return '
|
|
356
|
+
return 'Este modelo é pago e a conta não tem crédito, então o provedor corta o pedido'
|
|
322
357
|
+ (n ? ` em ${n[2]} tokens (o seu tem ${n[1]})` : '')
|
|
323
358
|
+ '. Use /model main — os modelos padrão são grátis e não têm esse corte.';
|
|
324
359
|
}
|
|
@@ -329,8 +364,8 @@ function friendlyError(raw) {
|
|
|
329
364
|
|| /conversa passou do teto/i.test(low);
|
|
330
365
|
if (porTamanho) {
|
|
331
366
|
const nums = s.match(/Limit\s+(\d+),\s*Requested\s+(\d+)/i);
|
|
332
|
-
if (/conversa passou do teto/i.test(low)) return
|
|
333
|
-
return '
|
|
367
|
+
if (/conversa passou do teto/i.test(low)) return `${inner.replace(/\s+/g, ' ').trim()}`;
|
|
368
|
+
return 'Este pedido é maior que o teto do plano'
|
|
334
369
|
+ (nums ? ` (${nums[2]} tokens contra um limite de ${nums[1]} por minuto)` : '')
|
|
335
370
|
+ '. Esperar não resolve: use /clear para encurtar a conversa, ou divida o pedido em partes menores.';
|
|
336
371
|
}
|
|
@@ -343,7 +378,7 @@ function friendlyError(raw) {
|
|
|
343
378
|
const bruto = (s.match(/try again in ([0-9hms.]+)/i) || [])[1];
|
|
344
379
|
const quando = bruto && bruto.replace(/\.+$/, '').replace(/\.\d+s$/, 's');
|
|
345
380
|
const uso = s.match(/Limit\s+(\d+),\s*Used\s+(\d+)/i);
|
|
346
|
-
return '
|
|
381
|
+
return 'A cota de tokens do dia acabou'
|
|
347
382
|
+ (uso ? ` (${uso[2]} de ${uso[1]} tokens)` : '')
|
|
348
383
|
+ (quando ? `, libera em ${quando}` : '')
|
|
349
384
|
+ '. Enquanto isso o /mode chat gasta bem menos por pergunta.';
|
|
@@ -358,12 +393,12 @@ function friendlyError(raw) {
|
|
|
358
393
|
// texto genérico faria o usuário achar que não havia nada a fazer.
|
|
359
394
|
const semChave = s.match(/provedor de reserva \(([^)]+)\) está sem chave/i);
|
|
360
395
|
if (semChave) {
|
|
361
|
-
return
|
|
396
|
+
return `A cota diária de grátis do OpenRouter acabou (são 50 por dia na conta sem crédito),`
|
|
362
397
|
+ ` e o provedor de reserva (${semChave[1]}) está sem chave no servidor — não houve para onde cair.`
|
|
363
398
|
+ ` Configure a chave dele no Render, ou adicione 10 dólares de crédito no OpenRouter`
|
|
364
399
|
+ ` para subir de 50 para 1.000 pedidos por dia (esse crédito não é gasto pelos modelos grátis).`;
|
|
365
400
|
}
|
|
366
|
-
return '
|
|
401
|
+
return 'A cota diária de grátis acabou nos DOIS provedores (OpenRouter e Groq).'
|
|
367
402
|
+ ' Se chegou até aqui, o servidor já tentou os dez modelos da corrente.'
|
|
368
403
|
+ ' Trocar de modelo não resolve: o limite é da conta, não do modelo.'
|
|
369
404
|
+ ' Adicionar 10 dólares de crédito no OpenRouter sobe de 50 para 1.000 pedidos'
|
|
@@ -371,18 +406,18 @@ function friendlyError(raw) {
|
|
|
371
406
|
+ ' Sem isso, libera amanhã.';
|
|
372
407
|
}
|
|
373
408
|
if (/rate.?limit|too many requests|\b429\b/i.test(low)) {
|
|
374
|
-
return '
|
|
409
|
+
return 'Muitos pedidos em pouco tempo. Espere alguns instantes e tente de novo.';
|
|
375
410
|
}
|
|
376
411
|
// "token" solto não serve de pista: a mensagem de TPM tem a palavra e não
|
|
377
412
|
// tem nada a ver com autenticação. Exige o código ou a frase inteira.
|
|
378
413
|
if (/\b401\b|unauthorized|forbidden|token (inv[aá]lido|ausente|expirado)|invalid token|missing token/i.test(low)) {
|
|
379
|
-
return '
|
|
414
|
+
return 'Este servidor pede um token. Configure PRIMOCODE_TOKEN ou "token" em ~/.primocode/config.json.';
|
|
380
415
|
}
|
|
381
416
|
if (/econnrefused|enotfound|etimedout|timeout|socket hang up|network|getaddrinfo|econnreset/i.test(low)) {
|
|
382
|
-
return '
|
|
417
|
+
return 'Não consegui falar com o servidor. Verifique sua conexão e tente de novo.';
|
|
383
418
|
}
|
|
384
419
|
if (/\b5\d\d\b|internal server|bad gateway|service unavailable|gateway timeout/i.test(low)) {
|
|
385
|
-
return '
|
|
420
|
+
return 'O servidor está fora do ar no momento. Tente de novo em instantes.';
|
|
386
421
|
}
|
|
387
422
|
// Sobrou algo específico: mostra só a mensagem interna, sem o JSON gigante.
|
|
388
423
|
const clean = inner.replace(/\s+/g, ' ').trim();
|
|
@@ -390,4 +425,4 @@ function friendlyError(raw) {
|
|
|
390
425
|
}
|
|
391
426
|
|
|
392
427
|
|
|
393
|
-
module.exports = { c, mascot, welcomeBox, Spinner, actionHeader, friendlyError, visibleWidth, stripAnsi, truncate, padRight };
|
|
428
|
+
module.exports = { c, mascot, welcomeBox, Spinner, PENSANDO, actionHeader, friendlyError, visibleWidth, stripAnsi, truncate, padRight };
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "primocode",
|
|
3
|
-
"version": "8.
|
|
3
|
+
"version": "8.38.0",
|
|
4
4
|
"description": "PrimoCode — agente de engenharia com IA e cursor próprio. Requer conta Conecta Primo AI (Premium ou Super). Cria arquivos, roda comandos, controla navegador e desktop: abre apps, clica em botões e ícones pelo nome, digita e usa atalhos.",
|
|
5
5
|
"main": "bin/primocode.js",
|
|
6
6
|
"bin": {
|
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
},
|
|
9
9
|
"scripts": {
|
|
10
10
|
"start": "node bin/primocode.js",
|
|
11
|
-
"test": "node test/tools.test.js && node test/ui.test.js && node test/modo.test.js && node test/pasta.test.js && node test/catalogo.test.js && node test/desktop.test.js && node test/effort.test.js && node test/claude-engine.test.js && node test/regressao.test.js && node test/memoria-conversa.test.js && node test/repeticao.test.js && node test/referencia.test.js && node test/parar.test.js && node test/continuar.test.js && node test/studio-spec.test.js && node test/studio-versoes.test.js && node test/voz.test.js && node test/editor.test.js && node test/estudio-projeto.test.js && node test/vocabulario.test.js && node test/video-export.test.js && node test/monotonia.test.js && node test/clipe.test.js && node test/midia-envio.test.js && node test/pausas.test.js && node test/fala-repetida.test.js && node test/trilha.test.js && node test/anuncio.test.js && node test/pipeline.test.js && node test/app.test.js && node test/elementos.test.js && node test/remotion.test.js && node test/aplicativo.test.js && node test/conta.test.mjs && node test/nuvem.test.js && node test/codex.test.js && node test/chaves.test.js && node test/prazo.test.js && node test/primeira-vez.test.js && node test/janela.test.js"
|
|
11
|
+
"test": "node test/tools.test.js && node test/ui.test.js && node test/modo.test.js && node test/pasta.test.js && node test/catalogo.test.js && node test/desktop.test.js && node test/effort.test.js && node test/claude-engine.test.js && node test/regressao.test.js && node test/memoria-conversa.test.js && node test/repeticao.test.js && node test/referencia.test.js && node test/parar.test.js && node test/continuar.test.js && node test/studio-spec.test.js && node test/studio-versoes.test.js && node test/voz.test.js && node test/editor.test.js && node test/estudio-projeto.test.js && node test/vocabulario.test.js && node test/video-export.test.js && node test/monotonia.test.js && node test/clipe.test.js && node test/midia-envio.test.js && node test/pausas.test.js && node test/fala-repetida.test.js && node test/trilha.test.js && node test/anuncio.test.js && node test/pipeline.test.js && node test/app.test.js && node test/elementos.test.js && node test/remotion.test.js && node test/aplicativo.test.js && node test/conta.test.mjs && node test/nuvem.test.js && node test/codex.test.js && node test/chaves.test.js && node test/prazo.test.js && node test/primeira-vez.test.js && node test/janela.test.js && node test/pareamento.test.js && node test/fala.test.js && node test/especialistas.test.js && node test/icone.test.js && node test/exportar.test.js"
|
|
12
12
|
},
|
|
13
13
|
"engines": {
|
|
14
14
|
"node": ">=18.17.0"
|
|
@@ -85,8 +85,10 @@ um elemento flutuando por cima do layout.
|
|
|
85
85
|
// IMAGEM
|
|
86
86
|
{ "tipo":"imagem", "src":"https://…", "ajuste":"cobrir", "raio":18 }
|
|
87
87
|
|
|
88
|
-
// ÍCONE — nomes em /api/ferramentas/tela
|
|
88
|
+
// ÍCONE — nomes em /api/ferramentas/tela; ou DESENHE o seu: "caminho" é o
|
|
89
|
+
// path SVG numa caixa 24×24 (string ou lista de strings), no traço da casa
|
|
89
90
|
{ "tipo":"icone", "nome":"raio", "tamanho":120, "cor":"#ffb03a" }
|
|
91
|
+
{ "tipo":"icone", "caminho":"M3 6h18l-2 10H5zM8 20h.01M16 20h.01", "tamanho":96 }
|
|
90
92
|
|
|
91
93
|
// FORMA
|
|
92
94
|
{ "tipo":"forma", "forma":"retangulo", "x":200,"y":200,"l":400,"a":220,
|
|
@@ -332,12 +332,20 @@ depoimento é o que dá confiança, e nenhum desenho substitui.
|
|
|
332
332
|
```json
|
|
333
333
|
{ "tipo": "icone", "nome": "raio", "tamanho": 120 }
|
|
334
334
|
{ "tipo": "icone", "nome": "crescimento", "tamanho": 90, "cor": "#22d3ee", "traco": 2 }
|
|
335
|
+
{ "tipo": "icone", "caminho": "M4 7h16M4 12h10M4 17h7", "tamanho": 100 }
|
|
336
|
+
{ "tipo": "icone", "caminho": ["M12 3a9 9 0 100 18 9 9 0 000-18z", "M9 12l2 2 4-4"], "preenchido": false }
|
|
335
337
|
```
|
|
336
338
|
|
|
337
339
|
O jeito mais barato de uma cena deixar de ser um slide. Um ícone grande acima
|
|
338
340
|
do título, ou um por item de uma lista de três, e a mesma frase passa a ter
|
|
339
341
|
imagem.
|
|
340
342
|
|
|
343
|
+
**O ícone que a lista não tem, você DESENHA** — `caminho` é o path SVG numa
|
|
344
|
+
caixa de 24×24 (uma string, ou uma lista com um traço por peça). Ele sai no
|
|
345
|
+
mesmo traço dos ícones prontos, na cor do tema, então o seu não destoa. Um
|
|
346
|
+
carrinho de compras, uma xícara, o símbolo do produto do cliente: três ou
|
|
347
|
+
quatro comandos `M`/`L`/`A`/`C` resolvem. Nunca emoji no lugar de ícone.
|
|
348
|
+
|
|
341
349
|
Os nomes que existem (traço, no tom do tema):
|
|
342
350
|
|
|
343
351
|
`raio` `alvo` `foguete` `escudo` `relogio` `usuario` `pessoas` `coracao`
|
|
@@ -87,6 +87,14 @@ Entre 8 e 14 cenas para 10 minutos. Uma ideia por cena.
|
|
|
87
87
|
|
|
88
88
|
## Detalhes que valem
|
|
89
89
|
|
|
90
|
+
- **A cena compõe sozinha.** Com título e conteúdo, o título ancora no alto e o
|
|
91
|
+
conteúdo ocupa o corpo, em escala de projetor; o rodapé com o nome do deck e
|
|
92
|
+
"03 / 08" entra sozinho (menos na capa e nas seções). Não simule isso com
|
|
93
|
+
`livre`.
|
|
94
|
+
- **Varie o layout entre as cenas**: `numeros` para o dado que decide,
|
|
95
|
+
`imagem-direita` com foto ou `dispositivo` quando há produto, `cartoes` em
|
|
96
|
+
`tres-colunas` para as opções, `citacao` para a voz de fora, `tabela` para
|
|
97
|
+
comparar. Cinco cenas `titulo` + `lista` seguidas é o deck que ninguém lembra.
|
|
90
98
|
- **`tema` por cena** sobrepõe o do deck. Uma citação em `papel` no meio de um deck
|
|
91
99
|
`meia-noite` cria respiro — use uma vez, não cinco.
|
|
92
100
|
- **`revelar: true`** na lista faz os itens aparecerem um a um.
|
package/studio/server.py
CHANGED
|
@@ -881,7 +881,8 @@ def revisar_monotonia(doc, ferramenta):
|
|
|
881
881
|
avisos.append(
|
|
882
882
|
"o vídeo é só texto e imagem: nenhum elemento de apoio em %d cenas. "
|
|
883
883
|
'Um "icone" ao lado do argumento (raio, alvo, foguete, escudo, relogio, '
|
|
884
|
-
"lampada, cadeado, crescimento, pessoas, dinheiro, globo, fogo…
|
|
884
|
+
"lampada, cadeado, crescimento, pessoas, dinheiro, globo, fogo… ou um "
|
|
885
|
+
'DESENHADO por você, com "caminho": path SVG numa caixa 24x24), um '
|
|
885
886
|
'"selo" de estado, um "numeros" com "contar": true — é isto que faz a '
|
|
886
887
|
"tela parecer produzida em vez de um slide com legenda." % len(cenas))
|
|
887
888
|
|
package/studio/web/js/icones.js
CHANGED
|
@@ -88,4 +88,33 @@ function nomesIcones() {
|
|
|
88
88
|
return Object.keys(ICONES);
|
|
89
89
|
}
|
|
90
90
|
|
|
91
|
-
|
|
91
|
+
/* O ícone que a IA DESENHA, quando o que ela precisa não está na lista.
|
|
92
|
+
*
|
|
93
|
+
* "A IA irá desenvolver ícones."
|
|
94
|
+
*
|
|
95
|
+
* `caminho` é o path SVG numa caixa de 24×24 — uma string, ou uma lista de
|
|
96
|
+
* strings (um traço por peça). Sai no MESMO traço dos ícones da casa (1.7,
|
|
97
|
+
* cantos redondos, cor do texto), para o desenhado não destoar do pronto.
|
|
98
|
+
*
|
|
99
|
+
* Só entram os caracteres de path: letra de comando, número, espaço,
|
|
100
|
+
* vírgula. Nada mais — isto vira innerHTML numa página nossa, e o documento
|
|
101
|
+
* foi escrito por um modelo. Path inválido cai no círculo, que é o mesmo
|
|
102
|
+
* destino de um nome que não existe: o erro fica visível e inofensivo. */
|
|
103
|
+
// Começa em M/m: todo path de verdade abre com um moveto, e "42" sozinho
|
|
104
|
+
// não é desenho nenhum.
|
|
105
|
+
var CAMINHO_OK = /^[Mm][MmZzLlHhVvCcSsQqTtAa0-9\s,.\-+eE]*$/;
|
|
106
|
+
|
|
107
|
+
function svgCaminho(caminho, op) {
|
|
108
|
+
op = op || {};
|
|
109
|
+
var lista = Array.isArray(caminho) ? caminho : [caminho];
|
|
110
|
+
var ds = lista.map(function (d) { return String(d == null ? "" : d).trim(); })
|
|
111
|
+
.filter(function (d) { return d && d.length <= 4000 && CAMINHO_OK.test(d); });
|
|
112
|
+
if (!ds.length) return svgIcone("circulo", op.cls);
|
|
113
|
+
var fill = op.preenchido ? "currentColor" : "none";
|
|
114
|
+
return '<svg viewBox="0 0 24 24" fill="' + fill + '" stroke="currentColor" stroke-width="' +
|
|
115
|
+
(Number(op.traco) > 0 ? Number(op.traco) : 1.7) + '" stroke-linecap="round" stroke-linejoin="round"' +
|
|
116
|
+
(op.cls ? ' class="' + op.cls + '"' : "") + ">" +
|
|
117
|
+
ds.map(function (d) { return '<path d="' + d + '"/>'; }).join("") + "</svg>";
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
if (typeof module !== "undefined" && module.exports) module.exports = { ICONES: ICONES, svgIcone: svgIcone, svgCaminho: svgCaminho, CAMINHO_OK: CAMINHO_OK };
|
package/studio/web/js/player.js
CHANGED
|
@@ -12,6 +12,11 @@
|
|
|
12
12
|
var arq = null, doc = {}, cenas = [], atual = 0;
|
|
13
13
|
var tocando = false, quadro = null, t0 = 0, decorrido = 0;
|
|
14
14
|
var trilha = null, gravador = null, pedacos = [];
|
|
15
|
+
/* Resolve quando o arquivo está MONTADO no palco. É por ela que a exportação
|
|
16
|
+
escondida (studio_exportar) sabe quando pode começar — antes disto não há
|
|
17
|
+
cena para fotografar, e um export que começa cedo sai preto. */
|
|
18
|
+
var carregadoOk = null;
|
|
19
|
+
var carregado = new Promise(function (ok) { carregadoOk = ok; });
|
|
15
20
|
var ehVideo = false, transporte = null;
|
|
16
21
|
|
|
17
22
|
/* ------------------------------------------------------------- avisos -- */
|
|
@@ -81,8 +86,14 @@
|
|
|
81
86
|
if (a.ferramenta === "grade") return montarGrade();
|
|
82
87
|
|
|
83
88
|
cenas = doc.cenas || [];
|
|
89
|
+
// O rodapé (nome do deck, "03 / 08") é coisa de APRESENTAÇÃO: o vídeo e
|
|
90
|
+
// a arte não têm. Ver o rodapé em render.js.
|
|
91
|
+
var rodape = a.ferramenta === "prisma" ? { titulo: a.titulo, total: cenas.length } : null;
|
|
84
92
|
cenas.forEach(function (c, i) {
|
|
85
|
-
var no = renderCena(c, doc, {
|
|
93
|
+
var no = renderCena(c, doc, {
|
|
94
|
+
animar: true,
|
|
95
|
+
rodape: rodape ? { titulo: rodape.titulo, indice: i + 1, total: rodape.total } : null,
|
|
96
|
+
});
|
|
86
97
|
no.style.position = "absolute";
|
|
87
98
|
no.style.inset = "0";
|
|
88
99
|
no.style.display = i === 0 ? "block" : "none";
|
|
@@ -93,6 +104,7 @@
|
|
|
93
104
|
|
|
94
105
|
ajustar();
|
|
95
106
|
ir(0);
|
|
107
|
+
if (carregadoOk) { carregadoOk(a); carregadoOk = null; }
|
|
96
108
|
|
|
97
109
|
if (location.hash === "#imprimir") return setTimeout(imprimirTudo, 350);
|
|
98
110
|
|
|
@@ -259,7 +271,17 @@
|
|
|
259
271
|
que vem depois no arquivo — invadir é justamente a pausa que se
|
|
260
272
|
queria cortar. */
|
|
261
273
|
if (alvo >= t.ate) { if (!v.paused) v.pause(); return; }
|
|
262
|
-
|
|
274
|
+
/* Uma busca por vez, e com respiro. Numa máquina lenta (ou na
|
|
275
|
+
exportação escondida, que rasteriza a cena a cada quadro no mesmo
|
|
276
|
+
fio), o <video> fica atrás do relógio por mais de DERIVA em TODO
|
|
277
|
+
quadro: puxá-lo de volta a cada rAF é uma busca em cima da outra, e
|
|
278
|
+
um vídeo que só busca nunca tem quadro pronto — readyState fica em 1,
|
|
279
|
+
o export não desenha o clipe e o arquivo sai com o chão da cena no
|
|
280
|
+
lugar da gravação. Medido quadro a quadro no diagnóstico. */
|
|
281
|
+
if (v.seeking) return;
|
|
282
|
+
var agora = performance.now();
|
|
283
|
+
if (Math.abs(v.currentTime - alvo) > DERIVA && (!v._ultimaBusca || agora - v._ultimaBusca > 600)) {
|
|
284
|
+
v._ultimaBusca = agora;
|
|
263
285
|
try { v.currentTime = alvo; } catch (e) {}
|
|
264
286
|
}
|
|
265
287
|
if (tocando && v.paused) tocarClipe(v);
|
|
@@ -679,19 +701,17 @@ function gravaMp4() { return /mp4/.test(escolherFormato()); }
|
|
|
679
701
|
* o mesmo grafo de sempre — trilha, narração e efeitos, já mixados com o
|
|
680
702
|
* ducking — então o arquivo sai idêntico ao que se ouve aqui.
|
|
681
703
|
*/
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
704
|
+
/* Monta o arquivo com o PLAYER como fonte, sem baixar. É o que o botão usa
|
|
705
|
+
por baixo e o que a exportação escondida (studio_exportar) chama por
|
|
706
|
+
fora — um caminho só, para o arquivo sair igual nos dois.
|
|
707
|
+
|
|
708
|
+
O player é a fonte: ele sabe encadear cenas, narrar no tempo certo e
|
|
709
|
+
tocar os efeitos. O exportador só pergunta o que está na tela. */
|
|
710
|
+
function gerarArquivo(op) {
|
|
711
|
+
op = op || {};
|
|
685
712
|
if (typeof VideoExport === "undefined" || !VideoExport.disponivel()) {
|
|
686
|
-
return
|
|
713
|
+
return Promise.reject(new Error("este navegador não sabe gerar vídeo"));
|
|
687
714
|
}
|
|
688
|
-
|
|
689
|
-
b.dataset.exportando = "1";
|
|
690
|
-
b.classList.add("gravando");
|
|
691
|
-
aviso("montando o vídeo — não feche esta aba", 3000);
|
|
692
|
-
|
|
693
|
-
// O player é a fonte: ele sabe encadear cenas, narrar no tempo certo e
|
|
694
|
-
// tocar os efeitos. O exportador só pergunta o que está na tela.
|
|
695
715
|
var fonte = {
|
|
696
716
|
duracao: total(),
|
|
697
717
|
// A gravação do usuário precisa estar decodificada antes do primeiro
|
|
@@ -701,13 +721,32 @@ function gravaMp4() { return /mp4/.test(escolherFormato()); }
|
|
|
701
721
|
parar: function () { pausar(); },
|
|
702
722
|
no: function () { return palco.children[atual] || null; },
|
|
703
723
|
};
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
724
|
+
// O AudioContext nasce suspenso sem gesto; a exportação escondida chega
|
|
725
|
+
// com um gesto emprestado, e este resume é o que o gasta.
|
|
726
|
+
if (trilha && trilha.ctx && trilha.ctx.state === "suspended") { try { trilha.ctx.resume(); } catch (e) {} }
|
|
727
|
+
return VideoExport.gerar(arq, {
|
|
707
728
|
fonte: fonte,
|
|
729
|
+
fps: op.fps,
|
|
708
730
|
fluxoAudio: trilha && trilha.fluxo,
|
|
731
|
+
aoAndar: op.aoAndar,
|
|
732
|
+
});
|
|
733
|
+
}
|
|
734
|
+
|
|
735
|
+
function iniciarGravacao() {
|
|
736
|
+
var b = document.getElementById("tp-gravar");
|
|
737
|
+
if (!b || b.dataset.exportando) return;
|
|
738
|
+
if (typeof VideoExport === "undefined" || !VideoExport.disponivel()) {
|
|
739
|
+
return aviso("este navegador não sabe gerar vídeo", 3200);
|
|
740
|
+
}
|
|
741
|
+
|
|
742
|
+
b.dataset.exportando = "1";
|
|
743
|
+
b.classList.add("gravando");
|
|
744
|
+
aviso("montando o vídeo — não feche esta aba", 3000);
|
|
745
|
+
|
|
746
|
+
gerarArquivo({
|
|
709
747
|
aoAndar: function (f) { b.textContent = Math.round(f * 100) + "%"; },
|
|
710
748
|
}).then(function (r) {
|
|
749
|
+
VideoExport.salvar(r, arq.titulo);
|
|
711
750
|
aviso(r.mp4 ? "vídeo salvo em .mp4" : "salvo em .webm (este navegador não faz mp4)", 3400);
|
|
712
751
|
}).catch(function (e) {
|
|
713
752
|
aviso("não consegui gerar: " + e.message, 4200);
|
|
@@ -829,5 +868,8 @@ function gravaMp4() { return /mp4/.test(escolherFormato()); }
|
|
|
829
868
|
ir: function (n) { ir(n - 1); }, proxima: proxima, anterior: anterior,
|
|
830
869
|
tocar: tocar, parar: pausar, buscar: buscar, gravar: alternarGravacao,
|
|
831
870
|
get cena() { return atual + 1; }, get total() { return cenas.length; },
|
|
871
|
+
// Para a exportação escondida: espera o arquivo montado, troca o que
|
|
872
|
+
// precisar (um clipe convertido) e gera pelo mesmo caminho do botão.
|
|
873
|
+
carregado: carregado, remontar: montar, exportar: gerarArquivo,
|
|
832
874
|
};
|
|
833
875
|
})();
|
package/studio/web/js/render.js
CHANGED
|
@@ -1182,7 +1182,11 @@ function renderElemento(e, ctx) {
|
|
|
1182
1182
|
no.style.width = _px(e.l || t);
|
|
1183
1183
|
no.style.height = _px(e.a || t);
|
|
1184
1184
|
no.style.color = e.cor || "var(--c-tom)";
|
|
1185
|
-
|
|
1185
|
+
// `caminho`: o ícone desenhado pelo modelo (path SVG 24×24) — ver
|
|
1186
|
+
// icones.js. Sem ele, um da lista pelo nome.
|
|
1187
|
+
no.innerHTML = e.caminho
|
|
1188
|
+
? svgCaminho(e.caminho, { preenchido: !!e.preenchido, traco: e.traco })
|
|
1189
|
+
: svgIcone(e.nome || "estrela");
|
|
1186
1190
|
var s = no.querySelector("svg");
|
|
1187
1191
|
if (s) s.setAttribute("stroke-width", e.traco || 1.5);
|
|
1188
1192
|
break;
|
|
@@ -1570,10 +1574,12 @@ function renderCena(cena, doc, op) {
|
|
|
1570
1574
|
cols.style.gridTemplateColumns = "repeat(" + (layout === "duas-colunas" ? 2 : 3) + ", 1fr)";
|
|
1571
1575
|
solta(corpo, cols);
|
|
1572
1576
|
fluxo.appendChild(cols);
|
|
1577
|
+
comporCabecaCorpo(fluxo, layout, cabecalho.length, 1);
|
|
1573
1578
|
} else {
|
|
1574
1579
|
// um bloco só (cartoes, numeros…) já se distribui sozinho: não engaiolar
|
|
1575
1580
|
solta(cabecalho, fluxo);
|
|
1576
1581
|
solta(corpo, fluxo);
|
|
1582
|
+
comporCabecaCorpo(fluxo, layout, cabecalho.length, corpo.length);
|
|
1577
1583
|
}
|
|
1578
1584
|
|
|
1579
1585
|
flutuantes.forEach(function (e) {
|
|
@@ -1583,8 +1589,54 @@ function renderCena(cena, doc, op) {
|
|
|
1583
1589
|
no.appendChild(el);
|
|
1584
1590
|
});
|
|
1585
1591
|
|
|
1592
|
+
/* O RODAPÉ DA APRESENTAÇÃO: nome do deck à esquerda, "03 / 08" à direita.
|
|
1593
|
+
É o que ancora a composição no quadro — sem ele, uma cena de título e
|
|
1594
|
+
lista é um bloco flutuando no meio do preto, e todo deck parece o mesmo
|
|
1595
|
+
deck. Quem manda é quem chama (o player, quando a peça é prisma): o
|
|
1596
|
+
vídeo não tem rodapé, e a capa e as seções também não — são as cenas
|
|
1597
|
+
que se sustentam sozinhas. */
|
|
1598
|
+
if (op.rodape && layout !== "capa" && layout !== "secao") {
|
|
1599
|
+
var rodape = document.createElement("div");
|
|
1600
|
+
rodape.className = "rodape";
|
|
1601
|
+
rodape.style.left = Math.round(W * 0.075) + "px";
|
|
1602
|
+
rodape.style.right = Math.round(W * 0.075) + "px";
|
|
1603
|
+
rodape.style.bottom = Math.round(H * 0.048) + "px";
|
|
1604
|
+
var esq = document.createElement("span");
|
|
1605
|
+
esq.textContent = op.rodape.titulo || "";
|
|
1606
|
+
var dir = document.createElement("span");
|
|
1607
|
+
if (op.rodape.indice && op.rodape.total) {
|
|
1608
|
+
dir.textContent = String(op.rodape.indice).padStart(2, "0") + " / " + String(op.rodape.total).padStart(2, "0");
|
|
1609
|
+
}
|
|
1610
|
+
rodape.appendChild(esq);
|
|
1611
|
+
rodape.appendChild(dir);
|
|
1612
|
+
no.appendChild(rodape);
|
|
1613
|
+
}
|
|
1614
|
+
|
|
1586
1615
|
desenharConectores(no, elementos, W, H);
|
|
1587
1616
|
return no;
|
|
1588
1617
|
}
|
|
1589
1618
|
|
|
1619
|
+
/* A COMPOSIÇÃO DE SLIDE: cabeça em cima, corpo no meio do que sobra.
|
|
1620
|
+
|
|
1621
|
+
Tudo centrado verticalmente — que era o padrão para todo layout — deixa um
|
|
1622
|
+
título com três linhas de lista ocupando 30% do quadro, com 350px de nada
|
|
1623
|
+
em cima e 350px embaixo: a cara de "slide genérico" que o dono apontou. A
|
|
1624
|
+
composição de quem faz deck de verdade ancora o título no alto e dá o resto
|
|
1625
|
+
do quadro ao conteúdo, que se centra ali.
|
|
1626
|
+
|
|
1627
|
+
Só quando há as duas metades (cabeça E corpo) e o layout não é o dos que se
|
|
1628
|
+
sustentam centrados (capa, centro, citação, seção): uma cena de vídeo com um
|
|
1629
|
+
título sozinho continua no meio, onde um título sozinho fica bem. O corpo é
|
|
1630
|
+
marcado no primeiro e no último filho (`margin: auto` no CSS), e o DOM
|
|
1631
|
+
continua chapado — nada de embrulho novo em volta dos elementos. */
|
|
1632
|
+
function comporCabecaCorpo(fluxo, layout, nCabeca, nCorpo) {
|
|
1633
|
+
if (!nCabeca || !nCorpo) return;
|
|
1634
|
+
if (layout === "capa" || layout === "centro" || layout === "citacao" || layout === "secao") return;
|
|
1635
|
+
var filhos = fluxo.children;
|
|
1636
|
+
if (filhos.length <= nCabeca) return;
|
|
1637
|
+
fluxo.setAttribute("data-composicao", "cabeca-corpo");
|
|
1638
|
+
filhos[nCabeca].classList.add("corpo-inicio");
|
|
1639
|
+
filhos[filhos.length - 1].classList.add("corpo-fim");
|
|
1640
|
+
}
|
|
1641
|
+
|
|
1590
1642
|
if (typeof module !== "undefined" && module.exports) module.exports = { renderCena: renderCena };
|
package/studio/web/js/video.js
CHANGED
|
@@ -347,6 +347,23 @@ var VideoExport = (function () {
|
|
|
347
347
|
return new Promise(function (pronto) {
|
|
348
348
|
var t0 = performance.now();
|
|
349
349
|
var primeiro = true;
|
|
350
|
+
/* A camada de cima de uma cena com clipe (título, legenda, marca),
|
|
351
|
+
já rasterizada. Enquanto nada nela anima, só o quadro do vídeo
|
|
352
|
+
muda — e rasterizar o SVG inteiro trinta vezes por segundo era o
|
|
353
|
+
que engasgava a máquina: o decodificador do <video> ficava sem
|
|
354
|
+
CPU, o clipe atrasava, o player o puxava de volta e a busca não
|
|
355
|
+
terminava nunca (medido: seeking=true e readyState=1 do 0,7s ao
|
|
356
|
+
fim, arquivo com o chão da cena no lugar da gravação). Com a
|
|
357
|
+
camada guardada, cada quadro é dois drawImage. */
|
|
358
|
+
var camada = null;
|
|
359
|
+
|
|
360
|
+
function pintarComClipe(no, img) {
|
|
361
|
+
// chão do tema → gravação do usuário → cena sem fundo
|
|
362
|
+
ctx2d.fillStyle = corDeChao(no);
|
|
363
|
+
ctx2d.fillRect(0, 0, L, A);
|
|
364
|
+
desenharClipes(ctx2d, no, L, A);
|
|
365
|
+
ctx2d.drawImage(img, 0, 0, L, A);
|
|
366
|
+
}
|
|
350
367
|
|
|
351
368
|
function volta() {
|
|
352
369
|
if (cancelado) return pronto();
|
|
@@ -365,16 +382,20 @@ var VideoExport = (function () {
|
|
|
365
382
|
primeiro = false;
|
|
366
383
|
|
|
367
384
|
var comVideo = temClipe(no);
|
|
385
|
+
if (comVideo && camada && camada.no === no && !aindaAnima(no)) {
|
|
386
|
+
pintarComClipe(no, camada.img);
|
|
387
|
+
return setTimeout(volta, 1000 / FPS);
|
|
388
|
+
}
|
|
389
|
+
|
|
368
390
|
Exportar.retrato(no, L, A, folhaCss, false, comVideo ? semFundo : null)
|
|
369
391
|
.then(function (img) {
|
|
370
392
|
if (cancelado) return pronto();
|
|
371
393
|
if (comVideo) {
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
394
|
+
camada = aindaAnima(no) ? null : { no: no, img: img };
|
|
395
|
+
pintarComClipe(no, img);
|
|
396
|
+
} else {
|
|
397
|
+
ctx2d.drawImage(img, 0, 0, L, A);
|
|
376
398
|
}
|
|
377
|
-
ctx2d.drawImage(img, 0, 0, L, A);
|
|
378
399
|
volta();
|
|
379
400
|
}).catch(function () {
|
|
380
401
|
// Um quadro que não rasterizou não derruba o vídeo: segue, e o
|
|
@@ -412,25 +433,30 @@ var VideoExport = (function () {
|
|
|
412
433
|
return promessa;
|
|
413
434
|
}
|
|
414
435
|
|
|
436
|
+
/* Entrega ao usuário o arquivo que `gerar` montou. Separado de `baixar`
|
|
437
|
+
porque a exportação ESCONDIDA (studio_exportar) gera pelo mesmo caminho e
|
|
438
|
+
não baixa: manda o blob de volta ao estúdio. Um só `gerar` para os dois. */
|
|
439
|
+
function salvar(r, nome) {
|
|
440
|
+
var url = URL.createObjectURL(r.blob);
|
|
441
|
+
var a = document.createElement("a");
|
|
442
|
+
a.href = url;
|
|
443
|
+
a.download = (nome || "video")
|
|
444
|
+
.replace(/[^\w\-]+/g, "-").replace(/^-+|-+$/g, "").toLowerCase() + r.formato;
|
|
445
|
+
document.body.appendChild(a);
|
|
446
|
+
a.click();
|
|
447
|
+
a.remove();
|
|
448
|
+
setTimeout(function () { URL.revokeObjectURL(url); }, 8000);
|
|
449
|
+
return r;
|
|
450
|
+
}
|
|
451
|
+
|
|
415
452
|
/* Gera e entrega o arquivo. É isto que o botão chama. */
|
|
416
453
|
function baixar(arq, op) {
|
|
417
454
|
op = op || {};
|
|
418
|
-
return gerar(arq, op).then(function (r) {
|
|
419
|
-
var url = URL.createObjectURL(r.blob);
|
|
420
|
-
var a = document.createElement("a");
|
|
421
|
-
a.href = url;
|
|
422
|
-
a.download = (op.nome || arq.titulo || "video")
|
|
423
|
-
.replace(/[^\w\-]+/g, "-").replace(/^-+|-+$/g, "").toLowerCase() + r.formato;
|
|
424
|
-
document.body.appendChild(a);
|
|
425
|
-
a.click();
|
|
426
|
-
a.remove();
|
|
427
|
-
setTimeout(function () { URL.revokeObjectURL(url); }, 8000);
|
|
428
|
-
return r;
|
|
429
|
-
});
|
|
455
|
+
return gerar(arq, op).then(function (r) { return salvar(r, op.nome || arq.titulo); });
|
|
430
456
|
}
|
|
431
457
|
|
|
432
458
|
return {
|
|
433
|
-
disponivel: disponivel, formato: formato, gerar: gerar, baixar: baixar,
|
|
459
|
+
disponivel: disponivel, formato: formato, gerar: gerar, baixar: baixar, salvar: salvar,
|
|
434
460
|
FORMATOS: FORMATOS, _aindaAnima: aindaAnima,
|
|
435
461
|
};
|
|
436
462
|
})();
|