primocode 10.3.0 → 10.4.1
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/README.md +29 -2
- package/bin/primocode.js +111 -41
- package/lib/act.js +19 -5
- package/lib/catalogo.js +10 -4
- package/lib/entrada.js +261 -67
- package/lib/estudio/acervo.js +21 -1
- package/lib/estudio/estilo.js +12 -5
- package/lib/estudio/gerativo.js +18 -0
- package/lib/estudio/index.js +35 -3
- package/lib/estudio/kit/gerativo.jsx +62 -14
- package/lib/estudio/kit/index.jsx +2 -1
- package/lib/estudio/kit/personagem.jsx +582 -0
- package/lib/estudio/kit.js +3 -1
- package/lib/estudio/lancamento.js +5 -2
- package/lib/estudio/margem.js +235 -0
- package/lib/estudio/personagem.js +232 -0
- package/lib/estudio/print-site.js +190 -1
- package/lib/tela.js +223 -0
- package/lib/ui.js +70 -88
- package/package.json +2 -2
package/lib/entrada.js
CHANGED
|
@@ -40,6 +40,7 @@
|
|
|
40
40
|
const readline = require('readline');
|
|
41
41
|
const { EventEmitter } = require('events');
|
|
42
42
|
const { c, visibleWidth, truncate } = require('./ui.js');
|
|
43
|
+
const tela = require('./tela.js');
|
|
43
44
|
|
|
44
45
|
// ── O desenho ────────────────────────────────────────────────────────────
|
|
45
46
|
|
|
@@ -47,7 +48,26 @@ const { c, visibleWidth, truncate } = require('./ui.js');
|
|
|
47
48
|
const RE_ETIQUETA = /\[Colado #\d+ · [^\]]+\]/g;
|
|
48
49
|
function pintarEtiquetas(s) { return String(s).replace(RE_ETIQUETA, (m) => c.brand(m) + '\x1b[38;5;254m'); }
|
|
49
50
|
|
|
50
|
-
|
|
51
|
+
/* As cores do painel, no cubo de 256 cores. O fundo é um degrau acima do
|
|
52
|
+
preto do terminal (235); a barra é o azul do OpenCode; o escolhido do menu,
|
|
53
|
+
o pêssego dele com texto escuro. */
|
|
54
|
+
const COR = {
|
|
55
|
+
fundo: '\x1b[48;5;235m',
|
|
56
|
+
barra: '\x1b[38;5;75m',
|
|
57
|
+
warn: '\x1b[38;5;214m',
|
|
58
|
+
selFundo: '\x1b[48;5;216m',
|
|
59
|
+
selTexto: '\x1b[38;5;234m',
|
|
60
|
+
reset: '\x1b[0m',
|
|
61
|
+
};
|
|
62
|
+
/* Onde o texto começa: a barra, o espaço, e as duas colunas da marca ("$ "
|
|
63
|
+
no modo bash, vazias no normal). O cursor conta a partir daqui. */
|
|
64
|
+
const PREFIXO = 4;
|
|
65
|
+
|
|
66
|
+
/** Uma linha do painel: fundo até `w` colunas, religado depois de cada reset. */
|
|
67
|
+
function painel(s, w) {
|
|
68
|
+
const corpo = String(s).split(COR.reset).join(COR.reset + COR.fundo);
|
|
69
|
+
return COR.fundo + corpo + ' '.repeat(Math.max(0, w - visibleWidth(s))) + COR.reset;
|
|
70
|
+
}
|
|
51
71
|
|
|
52
72
|
/** Preenche até `w` colunas VISÍVEIS (códigos de cor não ocupam coluna). */
|
|
53
73
|
function encher(s, w) {
|
|
@@ -95,9 +115,13 @@ function quebrar(texto, w) {
|
|
|
95
115
|
*/
|
|
96
116
|
function montar(e) {
|
|
97
117
|
const colunas = Math.max(24, Math.min(e.colunas || 80, 200));
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
118
|
+
/* Na ABERTURA (nada mandado ainda) a caixa é estreita e centrada embaixo
|
|
119
|
+
do logo, como a home do OpenCode; depois do primeiro pedido ela ocupa a
|
|
120
|
+
largura toda. `recuo` é a margem da esquerda, que entra em toda linha. */
|
|
121
|
+
const largura = e.centro ? Math.min(colunas - 1, 80) : colunas - 1; // a última coluna
|
|
122
|
+
// fica livre: escrever nela faz o terminal quebrar a linha
|
|
123
|
+
const recuo = e.centro ? Math.max(0, Math.floor((colunas - largura) / 2)) : 0;
|
|
124
|
+
const w = Math.max(8, largura - PREFIXO);
|
|
101
125
|
|
|
102
126
|
const cursor = Math.max(0, Math.min(e.cursor | 0, String(e.texto).length));
|
|
103
127
|
const linhas = quebrar(e.texto, w);
|
|
@@ -112,41 +136,39 @@ function montar(e) {
|
|
|
112
136
|
if (cursor >= l.de && cursor <= fim) { cursorLinha = i; cursorColuna = cursor - l.de; }
|
|
113
137
|
}
|
|
114
138
|
|
|
115
|
-
/*
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
139
|
+
/* O DESENHO DO OPENCODE, que é a referência pedida: um PAINEL — fundo um
|
|
140
|
+
tom mais claro que o terminal, uma barra grossa na esquerda na cor do
|
|
141
|
+
modo — e não réguas nem caixa de cantos. Dentro dele, o texto; uma
|
|
142
|
+
linha em branco; e embaixo, ainda dentro, o modo e o esforço ("Auto ·
|
|
143
|
+
alto"), que é o que muda o que acontece no Enter. Fora do painel, uma
|
|
144
|
+
linha só: a pasta à esquerda, os atalhos à direita.
|
|
145
|
+
|
|
146
|
+
O fundo vai até a última coluna útil em toda linha do painel. É ele que
|
|
147
|
+
desenha a caixa — sem borda nenhuma — e por isso cada `reset` de cor no
|
|
148
|
+
meio do texto tem de religar o fundo (ver `painel`). */
|
|
149
|
+
/* MODO BASH: a linha que começa com "!" roda no shell, e o painel muda de
|
|
150
|
+
cor para avisar. Um comando de shell faz coisa irreversível, e a
|
|
126
151
|
diferença entre "pedir ao agente" e "rodar direto" não pode depender de
|
|
127
|
-
a pessoa lembrar do que digitou
|
|
128
|
-
|
|
129
|
-
mesma cor que o resto do programa usa para "atenção, não é o caminho
|
|
130
|
-
comum". */
|
|
152
|
+
a pessoa lembrar do que digitou: ela tem de VER, enquanto digita, que
|
|
153
|
+
aquele Enter vai ao shell. Âmbar é o "atenção" do resto do programa. */
|
|
131
154
|
const bash = /^\s*!/.test(String(e.texto));
|
|
155
|
+
const st = Array.isArray(e.status) ? { linhas: e.status } : (e.status || {});
|
|
156
|
+
const corBarra = bash ? COR.warn : (st.cor || COR.barra);
|
|
157
|
+
const barra = `${corBarra}┃${COR.reset}`;
|
|
158
|
+
const linhaDoPainel = (conteudo) => painel(barra + conteudo, largura);
|
|
132
159
|
|
|
133
160
|
const out = [];
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
que espera a linha fechar para se redesenhar, ficava apagada enquanto
|
|
138
|
-
ele girasse: o que se digitava não aparecia, e a barra de status
|
|
139
|
-
sumia ("não consigo digitar enquanto ele trabalha"). */
|
|
161
|
+
/* O SPINNER MORA AQUI, na linha logo acima do painel. Ele escrevia direto
|
|
162
|
+
no terminal com "\r" a cada 80 ms — e a caixa, que espera a linha fechar
|
|
163
|
+
para se redesenhar, ficava apagada enquanto ele girasse. */
|
|
140
164
|
if (e.spinner) out.push(' ' + truncate(e.spinner, largura - 2));
|
|
141
|
-
|
|
165
|
+
|
|
166
|
+
out.push(linhaDoPainel('')); // o respiro de cima
|
|
142
167
|
|
|
143
168
|
const vazio = !String(e.texto).length;
|
|
144
169
|
linhas.forEach((l, i) => {
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
: c.dim('┆ ');
|
|
148
|
-
// O "!" some do texto desenhado: quem vê "$ " já sabe que é shell, e
|
|
149
|
-
// mostrar os dois é mostrar a mesma informação duas vezes.
|
|
170
|
+
// O "!" some do texto desenhado: quem vê "$ " já sabe que é shell.
|
|
171
|
+
const marca = i === 0 && bash ? c.warn('$ ') : ' ';
|
|
150
172
|
let conteudo = pintarEtiquetas(c.white(i === 0 && bash ? l.texto.replace(/^\s*!/, '') : l.texto));
|
|
151
173
|
// O fantasma só existe na última linha e só com o cursor no fim dela:
|
|
152
174
|
// desenhado no meio do texto, ele empurraria o que vem depois.
|
|
@@ -155,56 +177,80 @@ function montar(e) {
|
|
|
155
177
|
}
|
|
156
178
|
if (i === 0 && vazio && e.ocupado) conteudo = c.dim('mande um recado — ele entra na tarefa depois do passo atual');
|
|
157
179
|
else if (i === 0 && vazio && e.vazio) conteudo = c.dim(e.vazio);
|
|
158
|
-
|
|
159
|
-
// espaço sobrando só apagaria o que estivesse embaixo.
|
|
160
|
-
out.push(' ' + marca + conteudo);
|
|
180
|
+
out.push(linhaDoPainel(' ' + marca + truncate(conteudo, w)));
|
|
161
181
|
});
|
|
162
182
|
|
|
163
|
-
out.push(
|
|
183
|
+
out.push(linhaDoPainel(''));
|
|
184
|
+
// O modo e o esforço, dentro do painel — "Build · Nemotron" no OpenCode.
|
|
185
|
+
out.push(linhaDoPainel(' ' + truncate(bash
|
|
186
|
+
? c.warn('roda no shell') + c.dim(`, em ${e.pasta || 'a pasta atual'} · apague o ! para voltar ao normal`)
|
|
187
|
+
: (st.modo || c.dim('PrimoCode')), largura - 4)));
|
|
188
|
+
|
|
189
|
+
// ── a linha de baixo: pasta à esquerda, atalhos à direita ────────────
|
|
190
|
+
if (st.linhas) {
|
|
191
|
+
for (const linha of st.linhas) out.push(' ' + truncate(linha, largura - 2));
|
|
192
|
+
} else {
|
|
193
|
+
const esq = st.esquerda || '';
|
|
194
|
+
const dir = st.direita || '';
|
|
195
|
+
const sobra = largura - 2 - visibleWidth(esq) - visibleWidth(dir);
|
|
196
|
+
out.push(' ' + (sobra >= 2 ? esq + ' '.repeat(sobra) + dir : truncate(esq, largura - 2)));
|
|
197
|
+
}
|
|
164
198
|
|
|
165
199
|
// ── o menu dos comandos ──────────────────────────────────────────────
|
|
166
|
-
// Fica ABAIXO
|
|
167
|
-
// digitar para
|
|
168
|
-
// de lugar enquanto ela escreve.
|
|
200
|
+
// Fica ABAIXO de tudo, não em cima do painel: em cima, ele empurraria o
|
|
201
|
+
// campo de digitar para baixo a cada tecla, e o texto que a pessoa
|
|
202
|
+
// escreve saltaria de lugar enquanto ela escreve. O desenho é o do
|
|
203
|
+
// OpenCode: fundo de painel, e o escolhido numa faixa pêssego.
|
|
204
|
+
/* NA TELA CHEIA (`menuAcima`) ele abre EM CIMA do painel, como no
|
|
205
|
+
OpenCode: lá a caixa é presa no pé da tela e nada salta — o menu só
|
|
206
|
+
cobre a conversa. */
|
|
207
|
+
const menu = [];
|
|
169
208
|
if (e.menu && e.menu.itens.length) {
|
|
170
|
-
const
|
|
209
|
+
const out = menu;
|
|
210
|
+
const cabem = Math.max(1, Math.min(e.menu.itens.length, e.menuAcima ? 10 : 8));
|
|
171
211
|
// A janela acompanha a seleção: descer na lista rola, em vez de o
|
|
172
212
|
// item escolhido sumir embaixo.
|
|
173
213
|
let topo = Math.max(0, Math.min(e.menu.sel - Math.floor(cabem / 2),
|
|
174
214
|
e.menu.itens.length - cabem));
|
|
215
|
+
const nomeW = 16;
|
|
175
216
|
for (let i = topo; i < topo + cabem; i++) {
|
|
176
217
|
const [nome, desc] = e.menu.itens[i];
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
+ c.dim(desc || '');
|
|
182
|
-
|
|
218
|
+
if (i === e.menu.sel) {
|
|
219
|
+
const corpo = ' ' + encher(nome, nomeW) + (desc || '');
|
|
220
|
+
out.push(`${COR.selFundo}${COR.selTexto}${encher(truncate(corpo, largura), largura)}${COR.reset}`);
|
|
221
|
+
} else {
|
|
222
|
+
out.push(painel(' ' + c.white(encher(nome, nomeW)) + c.dim(desc || ''), largura));
|
|
223
|
+
}
|
|
183
224
|
}
|
|
184
225
|
if (e.menu.itens.length > cabem) {
|
|
185
|
-
out.push('
|
|
226
|
+
out.push(painel(' ' + c.dim(`… e mais ${e.menu.itens.length - cabem}`), largura));
|
|
186
227
|
}
|
|
187
228
|
}
|
|
188
229
|
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
if (bash) {
|
|
193
|
-
out.push(' ' + c.warn('roda no shell') + c.dim(`, em ${e.pasta || 'a pasta atual'}`)
|
|
194
|
-
+ c.dim(' · apague o ! para voltar ao normal'));
|
|
195
|
-
}
|
|
196
|
-
for (const linha of (e.status || [])) out.push(' ' + linha);
|
|
230
|
+
let acima = 0;
|
|
231
|
+
if (menu.length && e.menuAcima) { out.splice(e.spinner ? 1 : 0, 0, ...menu); acima = menu.length; }
|
|
232
|
+
else out.push(...menu);
|
|
197
233
|
|
|
234
|
+
const margem = ' '.repeat(recuo);
|
|
198
235
|
return {
|
|
199
|
-
linhas: out,
|
|
200
|
-
cursorLinha: 1 + cursorLinha,
|
|
236
|
+
linhas: recuo ? out.map((l) => margem + l) : out,
|
|
237
|
+
cursorLinha: acima + (e.spinner ? 1 : 0) + 1 + cursorLinha, // +1 pelo respiro de cima
|
|
201
238
|
/* No modo bash o "!" não é desenhado, então tudo depois dele anda uma
|
|
202
239
|
coluna para a esquerda — e o cursor tem de andar junto, ou ele
|
|
203
240
|
aparece um caractere à frente de onde o texto realmente está. */
|
|
204
|
-
cursorColuna:
|
|
241
|
+
cursorColuna: recuo + PREFIXO + cursorColuna - (bash && cursorLinha === 0 ? 1 : 0),
|
|
205
242
|
};
|
|
206
243
|
}
|
|
207
244
|
|
|
245
|
+
/** O pedido que foi mandado, como fica no histórico da tela: um painel. */
|
|
246
|
+
function pedidoNaTela(texto, colunas) {
|
|
247
|
+
const largura = Math.max(24, Math.min(colunas || 80, 200)) - 1;
|
|
248
|
+
const barra = `${COR.barra}┃${COR.reset}`;
|
|
249
|
+
const linhas = quebrar(texto, Math.max(8, largura - PREFIXO))
|
|
250
|
+
.map((l) => painel(barra + ' ' + pintarEtiquetas(c.white(l.texto)), largura));
|
|
251
|
+
return [painel(barra, largura), ...linhas, painel(barra, largura)].join('\n');
|
|
252
|
+
}
|
|
253
|
+
|
|
208
254
|
// ── A caixa viva ─────────────────────────────────────────────────────────
|
|
209
255
|
|
|
210
256
|
class Entrada extends EventEmitter {
|
|
@@ -232,6 +278,12 @@ class Entrada extends EventEmitter {
|
|
|
232
278
|
this.oculto = false; // a próxima resposta é senha: ver `ocultarProxima`
|
|
233
279
|
this.tty = !!(process.stdin.isTTY && process.stdout.isTTY);
|
|
234
280
|
this.escreverCru = process.stdout.write.bind(process.stdout);
|
|
281
|
+
/* A TELA CHEIA (lib/tela.js): a conversa vive aqui, em linhas, e a
|
|
282
|
+
tela inteira é redesenhada a cada mudança — em vez de o texto subir
|
|
283
|
+
pelo terminal com a caixa atrás. */
|
|
284
|
+
this.cheia = !!(op.telaCheia && this.tty);
|
|
285
|
+
this.conversa = [];
|
|
286
|
+
this.rolagem = 0;
|
|
235
287
|
}
|
|
236
288
|
|
|
237
289
|
// ── ciclo de vida ────────────────────────────────────────────────────
|
|
@@ -254,7 +306,7 @@ class Entrada extends EventEmitter {
|
|
|
254
306
|
this._aoTeclar = (ch, key) => this._tecla(ch, key);
|
|
255
307
|
process.stdin.on('keypress', this._aoTeclar);
|
|
256
308
|
|
|
257
|
-
this._aoRedimensionar = () => { if (this.desenhado) this._redesenhar(); };
|
|
309
|
+
this._aoRedimensionar = () => { if (this.cheia) this._pintar(); else if (this.desenhado) this._redesenhar(); };
|
|
258
310
|
process.stdout.on('resize', this._aoRedimensionar);
|
|
259
311
|
|
|
260
312
|
/* Todo `console.log` do programa passa a apagar a caixa antes de
|
|
@@ -264,8 +316,20 @@ class Entrada extends EventEmitter {
|
|
|
264
316
|
por cima da caixa. */
|
|
265
317
|
this._stdoutOriginal = process.stdout.write;
|
|
266
318
|
const eu = this;
|
|
319
|
+
if (this.cheia) this._entrarNaTela();
|
|
267
320
|
process.stdout.write = function (texto, ...resto) {
|
|
268
321
|
if (eu._pintando) return eu._stdoutOriginal.call(this, texto, ...resto);
|
|
322
|
+
/* Na tela cheia, o que o programa escreve vira linha da conversa,
|
|
323
|
+
e quem põe na tela é o próximo quadro. Suspensa (o painel do
|
|
324
|
+
/effort desenhando por cima), passa direto. */
|
|
325
|
+
if (eu.cheia) {
|
|
326
|
+
if (eu.suspenso) return eu._stdoutOriginal.call(this, texto, ...resto);
|
|
327
|
+
tela.acrescentar(eu.conversa, texto);
|
|
328
|
+
eu._agendar();
|
|
329
|
+
const fim = resto.find((r) => typeof r === 'function');
|
|
330
|
+
if (fim) fim();
|
|
331
|
+
return true;
|
|
332
|
+
}
|
|
269
333
|
eu._apagar();
|
|
270
334
|
const r = eu._stdoutOriginal.call(this, texto, ...resto);
|
|
271
335
|
/* ── O TEXTO QUE CHEGA EM PEDAÇOS ────────────────────────────
|
|
@@ -310,6 +374,7 @@ class Entrada extends EventEmitter {
|
|
|
310
374
|
|
|
311
375
|
parar() {
|
|
312
376
|
if (!this.tty) { if (this._rlPipe) this._rlPipe.close(); return; }
|
|
377
|
+
if (this.cheia) this._sairDaTela();
|
|
313
378
|
this._apagar();
|
|
314
379
|
this.escreverCru('\x1b[?2004l');
|
|
315
380
|
if (this._stdoutOriginal) process.stdout.write = this._stdoutOriginal;
|
|
@@ -323,8 +388,88 @@ class Entrada extends EventEmitter {
|
|
|
323
388
|
/* Enquanto outro desenho é dono da tela — o painel do /effort, uma tela
|
|
324
389
|
do marketplace — a caixa sai da frente e larga o teclado. Sem isso, os
|
|
325
390
|
dois desenhariam no mesmo lugar e as setas moveriam os dois. */
|
|
326
|
-
suspender() { this.suspenso = true; this._apagar(); }
|
|
327
|
-
retomar() {
|
|
391
|
+
suspender() { this.suspenso = true; if (this.cheia) this._sairDaTela(); else this._apagar(); }
|
|
392
|
+
retomar() {
|
|
393
|
+
this.suspenso = false;
|
|
394
|
+
if (this.cheia && this.ativo && !this.fechado) { this._entrarNaTela(); return this._pintar(); }
|
|
395
|
+
if (this.ativo && !this.fechado) this._desenhar();
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
// ── a tela cheia ─────────────────────────────────────────────────────
|
|
399
|
+
|
|
400
|
+
/* A tela alternativa (a do vim): o que estava no terminal fica guardado e
|
|
401
|
+
volta inteiro na saída. Com ela, a roda do mouse — senão o terminal
|
|
402
|
+
transformaria a roda em seta para cima e a seta é o histórico. */
|
|
403
|
+
_entrarNaTela() {
|
|
404
|
+
if (this._naTela) return;
|
|
405
|
+
this._naTela = true;
|
|
406
|
+
/* O resto do programa (o markdown, a fala) quebra linha pela largura
|
|
407
|
+
do terminal. Na tela cheia a conversa é só a coluna da esquerda —
|
|
408
|
+
sem a barra da direita — então, enquanto ela está no ar, é ESSA a
|
|
409
|
+
largura que o programa vê. A real fica guardada para o desenho. */
|
|
410
|
+
if (!this._colunasReais) {
|
|
411
|
+
const eu = this;
|
|
412
|
+
this._colunasReais = process.stdout.columns || 80;
|
|
413
|
+
Object.defineProperty(process.stdout, 'columns', {
|
|
414
|
+
configurable: true,
|
|
415
|
+
get() { return eu._naTela && !eu.suspenso ? tela.larguraDaConversa(eu._colunasReais) : eu._colunasReais; },
|
|
416
|
+
set(v) { eu._colunasReais = v; }, // o Node escreve aqui quando a janela muda
|
|
417
|
+
});
|
|
418
|
+
}
|
|
419
|
+
this.escreverCru('\x1b[?1049h\x1b[?1000h\x1b[?1006h\x1b[2J');
|
|
420
|
+
if (!this._aoSairDoProcesso) {
|
|
421
|
+
// Um erro que derruba o programa não pode deixar o terminal na
|
|
422
|
+
// tela alternativa, sem cursor e capturando o mouse.
|
|
423
|
+
this._aoSairDoProcesso = () => this._sairDaTela();
|
|
424
|
+
process.on('exit', this._aoSairDoProcesso);
|
|
425
|
+
}
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
_sairDaTela() {
|
|
429
|
+
if (!this._naTela) return;
|
|
430
|
+
this._naTela = false;
|
|
431
|
+
this.escreverCru('\x1b[?1000l\x1b[?1006l\x1b[?25h\x1b[?1049l');
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
/** Junta as mudanças de um mesmo instante num quadro só. */
|
|
435
|
+
_agendar() {
|
|
436
|
+
if (this._agendado) return;
|
|
437
|
+
this._agendado = setTimeout(() => { this._agendado = null; this._pintar(); }, 16);
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
/** O estado do quadro, para a tela cheia (função pura em lib/tela.js). */
|
|
441
|
+
_quadroCheio() {
|
|
442
|
+
const conversa = this.conversa.some((l) => /\S/.test(l));
|
|
443
|
+
return tela.quadro({
|
|
444
|
+
colunas: this._colunasReais || 80,
|
|
445
|
+
linhas: process.stdout.rows || 24,
|
|
446
|
+
conversa: this.conversa,
|
|
447
|
+
rolagem: this.rolagem,
|
|
448
|
+
home: !this.mandou && !conversa,
|
|
449
|
+
caixa: (colunas, centro) => montar({ ...this._estado(), colunas, centro, menuAcima: true }),
|
|
450
|
+
lateral: { titulo: this.titulo, ...(this.op.lateral ? this.op.lateral() : {}) },
|
|
451
|
+
versao: this.op.versao || '',
|
|
452
|
+
pasta: this.op.pasta ? this.op.pasta() : '',
|
|
453
|
+
});
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
_pintar() {
|
|
457
|
+
if (!this.cheia || this.suspenso || this.fechado || !this._naTela) return;
|
|
458
|
+
const q = this._quadroCheio();
|
|
459
|
+
this.rolagem = Math.min(this.rolagem, q.rolagemMax);
|
|
460
|
+
let out = '\x1b[?25l';
|
|
461
|
+
q.linhas.forEach((l, i) => { out += `\x1b[${i + 1};1H${l}\x1b[0m\x1b[K`; });
|
|
462
|
+
out += `\x1b[${q.cursor.linha + 1};${q.cursor.coluna + 1}H\x1b[?25h`;
|
|
463
|
+
this._pintando = true;
|
|
464
|
+
this.escreverCru(out);
|
|
465
|
+
this._pintando = false;
|
|
466
|
+
this.desenhado = { linhas: q.linhas, cursorLinha: q.cursor.linha };
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
_rolar(passo) {
|
|
470
|
+
this.rolagem = Math.max(0, this.rolagem + passo);
|
|
471
|
+
this._pintar();
|
|
472
|
+
}
|
|
328
473
|
|
|
329
474
|
/** O agente está trabalhando: a caixa esmaece, e o Enter manda um RECADO
|
|
330
475
|
* que entra na tarefa em curso (a caixa de entrada de lib/act.js). */
|
|
@@ -342,11 +487,13 @@ class Entrada extends EventEmitter {
|
|
|
342
487
|
const novo = texto || '';
|
|
343
488
|
if (novo === (this.spinnerTexto || '')) return;
|
|
344
489
|
this.spinnerTexto = novo;
|
|
490
|
+
if (this.cheia) return this._agendar();
|
|
345
491
|
if (this.desenhado && !this._meiaLinha) this._redesenhar();
|
|
346
492
|
}
|
|
347
493
|
|
|
348
494
|
/** Escreve texto ACIMA da caixa — é por onde todo o resto do programa fala. */
|
|
349
495
|
escreverAcima(texto) {
|
|
496
|
+
if (this.cheia) { tela.acrescentar(this.conversa, texto); return this._agendar(); }
|
|
350
497
|
if (!this.desenhado) { this.escreverCru(texto); return; }
|
|
351
498
|
this._apagar();
|
|
352
499
|
this.escreverCru(texto);
|
|
@@ -405,8 +552,10 @@ class Entrada extends EventEmitter {
|
|
|
405
552
|
|
|
406
553
|
// ── desenho ──────────────────────────────────────────────────────────
|
|
407
554
|
|
|
408
|
-
_quadro() {
|
|
409
|
-
|
|
555
|
+
_quadro() { return montar(this._estado()); }
|
|
556
|
+
|
|
557
|
+
_estado() {
|
|
558
|
+
return ({
|
|
410
559
|
texto: this.oculto ? '•'.repeat(this.line.length) : this.line,
|
|
411
560
|
cursor: this.cursor,
|
|
412
561
|
colunas: process.stdout.columns || 80,
|
|
@@ -416,11 +565,17 @@ class Entrada extends EventEmitter {
|
|
|
416
565
|
menu: this.menu,
|
|
417
566
|
ocupado: this.ocupado,
|
|
418
567
|
spinner: this.spinnerTexto || '',
|
|
568
|
+
centro: !this.mandou,
|
|
419
569
|
});
|
|
420
570
|
}
|
|
421
571
|
|
|
422
572
|
_desenhar() {
|
|
573
|
+
if (this.cheia) return this._pintar();
|
|
423
574
|
if (!this.tty || this.suspenso || this.fechado) return;
|
|
575
|
+
/* Já desenhada (um `prompt()` de quem não sabe que ela está no ar):
|
|
576
|
+
apaga antes. Sem isso o quadro novo começava na linha do cursor —
|
|
577
|
+
a do texto — e o respiro de cima do painel ficava dobrado. */
|
|
578
|
+
if (this.desenhado && !this._meiaLinha) this._apagar();
|
|
424
579
|
const q = this._quadro();
|
|
425
580
|
this._pintando = true;
|
|
426
581
|
/* Cursor no meio de uma linha (fluxo que ainda não fechou): desce uma
|
|
@@ -441,6 +596,7 @@ class Entrada extends EventEmitter {
|
|
|
441
596
|
}
|
|
442
597
|
|
|
443
598
|
_apagar() {
|
|
599
|
+
if (this.cheia) return;
|
|
444
600
|
if (!this.desenhado || !this.tty) return;
|
|
445
601
|
this._pintando = true;
|
|
446
602
|
// Sobe até a primeira linha do quadro e limpa daí para baixo.
|
|
@@ -497,6 +653,19 @@ class Entrada extends EventEmitter {
|
|
|
497
653
|
return;
|
|
498
654
|
}
|
|
499
655
|
|
|
656
|
+
/* A roda do mouse chega como "\x1b[<64;x;yM", e o readline a entrega
|
|
657
|
+
picada, tecla por tecla. Daqui até o M (ou m), tudo é do mouse. */
|
|
658
|
+
if (key && key.code === '[<') { this._mouse = ''; return; }
|
|
659
|
+
if (this._mouse != null) {
|
|
660
|
+
if (/^[mM]$/.test(seq)) {
|
|
661
|
+
const botao = parseInt(this._mouse, 10);
|
|
662
|
+
this._mouse = null;
|
|
663
|
+
if (botao === 64) this._rolar(3);
|
|
664
|
+
else if (botao === 65) this._rolar(-3);
|
|
665
|
+
} else this._mouse += seq;
|
|
666
|
+
return;
|
|
667
|
+
}
|
|
668
|
+
|
|
500
669
|
if (!key) { if (ch) this.write(ch); return; }
|
|
501
670
|
|
|
502
671
|
// Esc Esc e o resto das teclas de fora: quem escuta decide. A caixa
|
|
@@ -525,6 +694,9 @@ class Entrada extends EventEmitter {
|
|
|
525
694
|
|
|
526
695
|
case 'tab': {
|
|
527
696
|
if (this.menu) return this._escolherDoMenu();
|
|
697
|
+
/* Tab com a caixa vazia troca o modo — o "tab agents" do
|
|
698
|
+
OpenCode. Com texto, continua completando o comando. */
|
|
699
|
+
if (!this.line && !this.ocupado) { this.emit('trocar-modo'); return; }
|
|
528
700
|
const f = this._fantasma();
|
|
529
701
|
if (f) return this.write(f);
|
|
530
702
|
if (this.line.startsWith('/')) { this._filtrarMenu(); this._redesenhar(); }
|
|
@@ -563,6 +735,8 @@ class Entrada extends EventEmitter {
|
|
|
563
735
|
if (this.cursor < this.line.length) { this.cursor++; this._redesenhar(); }
|
|
564
736
|
return;
|
|
565
737
|
}
|
|
738
|
+
case 'pageup': return this._rolar(Math.max(3, (process.stdout.rows || 24) - 8));
|
|
739
|
+
case 'pagedown': return this._rolar(-Math.max(3, (process.stdout.rows || 24) - 8));
|
|
566
740
|
case 'home': this.cursor = 0; this._redesenhar(); return;
|
|
567
741
|
case 'end': this.cursor = this.line.length; this._redesenhar(); return;
|
|
568
742
|
|
|
@@ -603,8 +777,16 @@ class Entrada extends EventEmitter {
|
|
|
603
777
|
this.cursor = antes.length;
|
|
604
778
|
return this._mudou();
|
|
605
779
|
}
|
|
606
|
-
case 'l':
|
|
780
|
+
case 'l':
|
|
781
|
+
if (this.cheia) { this.conversa = []; this.rolagem = 0; return this._pintar(); }
|
|
782
|
+
this._apagar(); this.escreverCru('\x1b[2J\x1b[H'); return this._desenhar();
|
|
607
783
|
case 'j': return this._novaLinha();
|
|
784
|
+
// Ctrl+P abre os comandos, como no OpenCode: o mesmo menu do "/".
|
|
785
|
+
case 'p':
|
|
786
|
+
if (this.line && !this.line.startsWith('/')) return;
|
|
787
|
+
if (!this.line) { this.line = '/'; this.cursor = 1; }
|
|
788
|
+
this._filtrarMenu();
|
|
789
|
+
return this._redesenhar();
|
|
608
790
|
default: return;
|
|
609
791
|
}
|
|
610
792
|
}
|
|
@@ -660,10 +842,22 @@ class Entrada extends EventEmitter {
|
|
|
660
842
|
senão o pedido some no instante em que a resposta começa, e quem
|
|
661
843
|
rola para cima não acha o que perguntou. */
|
|
662
844
|
this._apagar();
|
|
845
|
+
if (texto.trim()) this.mandou = true; // a caixa sai do centro e ocupa a largura
|
|
846
|
+
// O título da sessão, na barra da direita: o primeiro pedido.
|
|
847
|
+
if (texto.trim() && !oculto && !this.titulo && !texto.trim().startsWith('/')) {
|
|
848
|
+
const t = visto.replace(/\s+/g, ' ').trim();
|
|
849
|
+
this.titulo = t.length > 70 ? t.slice(0, 69).replace(/\s+\S*$/, '') + '…' : t;
|
|
850
|
+
}
|
|
663
851
|
if (texto.trim() && !oculto) {
|
|
664
|
-
|
|
665
|
-
//
|
|
666
|
-
|
|
852
|
+
// Na tela fica a etiqueta (o que se viu ao mandar); quem vai ao agente
|
|
853
|
+
// é o texto inteiro. O desenho é o do pedido no OpenCode: o mesmo
|
|
854
|
+
// painel da caixa, com a barra, e um respiro em cima e embaixo.
|
|
855
|
+
if (this.cheia) {
|
|
856
|
+
const W = this._colunasReais || 80;
|
|
857
|
+
const mainW = W >= tela.LATERAL_MIN ? W - tela.LATERAL_W : W;
|
|
858
|
+
this.rolagem = 0;
|
|
859
|
+
tela.acrescentar(this.conversa, (this.conversa.some((l) => /\S/.test(l)) ? '\n' : '') + pedidoNaTela(visto, mainW - 1) + '\n');
|
|
860
|
+
} else this.escreverCru(pedidoNaTela(visto, process.stdout.columns || 80) + '\n');
|
|
667
861
|
if (this.historico[this.historico.length - 1] !== texto) this.historico.push(texto);
|
|
668
862
|
}
|
|
669
863
|
this._desenhar();
|
|
@@ -673,4 +867,4 @@ class Entrada extends EventEmitter {
|
|
|
673
867
|
|
|
674
868
|
function criar(op) { return new Entrada(op).iniciar(); }
|
|
675
869
|
|
|
676
|
-
module.exports = { criar, Entrada, montar, quebrar, encher, pintarEtiquetas };
|
|
870
|
+
module.exports = { criar, Entrada, montar, quebrar, encher, pintarEtiquetas, painel, pedidoNaTela, PREFIXO, COR };
|
package/lib/estudio/acervo.js
CHANGED
|
@@ -320,14 +320,28 @@ function creditos(arquivos = null, pasta = PASTA()) {
|
|
|
320
320
|
* Qual ferramenta desta máquina sabe recortar. A decisão fica separada do
|
|
321
321
|
* processo para poder ser testada sem recortar nada.
|
|
322
322
|
*/
|
|
323
|
-
function comoTirarFundo({ temRembg, temBackgroundRemover, temPython, temFfmpeg } = {}) {
|
|
323
|
+
function comoTirarFundo({ temRembg, temBackgroundRemover, temPython, temUv, temFfmpeg } = {}) {
|
|
324
324
|
if (temRembg) return 'rembg';
|
|
325
325
|
if (temBackgroundRemover) return 'backgroundremover';
|
|
326
326
|
if (temPython) return 'python-rembg';
|
|
327
|
+
/* Com o uv (o mesmo do recorte de pessoa), o rembg roda sem instalar nada:
|
|
328
|
+
o uv baixa o pacote num ambiente à parte. Antes, sem `pip install rembg`,
|
|
329
|
+
a foto caía no corte por cor e saía com o fundo inteiro (10/10). */
|
|
330
|
+
if (temUv) return 'uv-rembg';
|
|
327
331
|
if (temFfmpeg) return 'ffmpeg-chroma'; // só resolve fundo de cor chapada
|
|
328
332
|
return null;
|
|
329
333
|
}
|
|
330
334
|
|
|
335
|
+
let _uv;
|
|
336
|
+
function acharUv() {
|
|
337
|
+
if (_uv !== undefined) return _uv;
|
|
338
|
+
const os = require('os');
|
|
339
|
+
for (const c of ['uv', path.join(os.homedir(), '.local', 'bin', 'uv'), '/opt/homebrew/bin/uv', '/usr/local/bin/uv']) {
|
|
340
|
+
try { if (spawnSync(c, ['--version'], { stdio: 'ignore', timeout: 5000 }).status === 0) return (_uv = c); } catch { /* próximo */ }
|
|
341
|
+
}
|
|
342
|
+
return (_uv = null);
|
|
343
|
+
}
|
|
344
|
+
|
|
331
345
|
function existe(cmd) {
|
|
332
346
|
try { return spawnSync(cmd, ['--help'], { stdio: 'ignore', timeout: 8000 }).status != null; }
|
|
333
347
|
catch { return false; }
|
|
@@ -354,6 +368,7 @@ async function tirarFundo(arquivo, { destino, cor = '0x00FF00', tolerancia = 0.3
|
|
|
354
368
|
try { return spawnSync('python3', ['-c', 'import rembg'], { stdio: 'ignore', timeout: 20000 }).status === 0; }
|
|
355
369
|
catch { return false; }
|
|
356
370
|
})(),
|
|
371
|
+
temUv: !!acharUv(),
|
|
357
372
|
temFfmpeg: existe('ffmpeg'),
|
|
358
373
|
});
|
|
359
374
|
if (!metodo) {
|
|
@@ -381,6 +396,11 @@ async function tirarFundo(arquivo, { destino, cor = '0x00FF00', tolerancia = 0.3
|
|
|
381
396
|
deu = await rodar('python3', ['-c',
|
|
382
397
|
'import sys;from rembg import remove;open(sys.argv[2],"wb").write(remove(open(sys.argv[1],"rb").read()))',
|
|
383
398
|
arquivo, saida]);
|
|
399
|
+
} else if (metodo === 'uv-rembg') {
|
|
400
|
+
// A primeira vez baixa o modelo (~170 MB, fica em ~/.u2net); depois é segundos por imagem.
|
|
401
|
+
deu = await rodar(acharUv(), ['run', '--quiet', '--python', '3.12', '--with', 'rembg[cpu]', '--with', 'pillow', 'python', '-c',
|
|
402
|
+
'import sys;from rembg import remove, new_session;open(sys.argv[2],"wb").write(remove(open(sys.argv[1],"rb").read(), session=new_session("isnet-general-use")))',
|
|
403
|
+
arquivo, saida]);
|
|
384
404
|
} else {
|
|
385
405
|
deu = await rodar('ffmpeg', ['-y', '-i', arquivo, '-vf',
|
|
386
406
|
`colorkey=${cor}:${tolerancia}:0.1`, '-c:a', 'copy', saida]);
|
package/lib/estudio/estilo.js
CHANGED
|
@@ -25,20 +25,25 @@
|
|
|
25
25
|
|
|
26
26
|
const variedade = require('./variedade.js');
|
|
27
27
|
|
|
28
|
-
const ESTILOS = ['hibrido', 'gerativo', 'motion', 'vox', 'marca', 'lancamento', 'impacto', 'cinema', 'composicao', 'fluido'];
|
|
28
|
+
const ESTILOS = ['hibrido', 'gerativo', 'motion', 'vox', 'marca', 'lancamento', 'impacto', 'cinema', 'composicao', 'fluido', 'personagem'];
|
|
29
29
|
// Nomes que o modelo usa para os mesmos estilos.
|
|
30
30
|
/* "motion" agora é o GERATIVO ("não pode ter template", 29–30/09); o motion
|
|
31
31
|
antigo segue pelo nome "classico" (e "limpo", o nome mais antigo dele). */
|
|
32
32
|
const APELIDOS = { motion: 'gerativo', generativo: 'gerativo', kinetic: 'gerativo', cinetico: 'gerativo', 'cinético': 'gerativo',
|
|
33
33
|
tipografico: 'gerativo', 'tipográfico': 'gerativo', animacao: 'gerativo', 'animação': 'gerativo',
|
|
34
34
|
classico: 'motion', 'clássico': 'motion', 'motion-classico': 'motion', motion3d: 'motion',
|
|
35
|
-
absurdo: 'hibrido', misto: 'hibrido', mix: 'hibrido', 'híbrido': 'hibrido', limpo: 'motion', explainer: 'vox', editorial: 'vox', brand: 'marca', branding: 'marca', 'brand-reveal': 'marca', minimalista: 'fluido', minimal: 'fluido', clean: 'fluido', apple: 'fluido', launch: 'lancamento', 'lançamento': 'lancamento', produto: 'lancamento'
|
|
35
|
+
absurdo: 'hibrido', misto: 'hibrido', mix: 'hibrido', 'híbrido': 'hibrido', limpo: 'motion', explainer: 'vox', editorial: 'vox', brand: 'marca', branding: 'marca', 'brand-reveal': 'marca', minimalista: 'fluido', minimal: 'fluido', clean: 'fluido', apple: 'fluido', launch: 'lancamento', 'lançamento': 'lancamento', produto: 'lancamento',
|
|
36
|
+
personagens: 'personagem', boneco: 'personagem', bonecos: 'personagem', conversa: 'personagem', dialogo: 'personagem', 'diálogo': 'personagem', podcast: 'personagem', gravado: 'personagem' };
|
|
36
37
|
|
|
37
38
|
const PEDE_FLUIDO = /\bfluid[oa]\b|minimalista|\bminimal\b|\bclean\b|\bapple\b/i;
|
|
38
39
|
const PEDE_MOTION = /\bmotion\b|anima[çc][ãa]o|animad[oa]/i;
|
|
39
40
|
const PEDE_VOX = /\bvox\b|explica|explainer|document[aá]rio|editorial|hist[oó]ria d[aoe]s?\b/i;
|
|
40
41
|
const PEDE_MARCA = /\bbrand|identidade visual|revela[çc][ãa]o d[ae] marca|\blogo(tipo|marca)?\b|vinheta de marca/i;
|
|
41
42
|
const PEDE_LANCAMENTO = /lan[çc]amento|\blaunch|\bsaas\b|produto novo|apresenta(ndo|r|ção d[oe]) (o |a )?(app|produto|plataforma|ferramenta)/i;
|
|
43
|
+
/* "Uma animação em 3D do Claude falando com o usuário... como se fosse gravado"
|
|
44
|
+
(10/10): personagem, boneco, avatar, conversa ou podcast em 3D vira o vídeo
|
|
45
|
+
GRAVADO em 3D (personagem.js). O 3D de produto continua no gerativo. */
|
|
46
|
+
const PEDE_PERSONAGEM = /personage(m|ns)|\bbonec[oa]s?\b|\bavatar|mascote falando|\bpodcast\b|entrevista|(conversa|di[aá]logo|falando|explicando)[^.]{0,60}\b3\s?d\b|\b3\s?d\b[^.]{0,60}(conversa|di[aá]logo|falando|explicando|pessoa|gente)/i;
|
|
42
47
|
const PEDE_IMPACTO = /an[uú]nci|reels?|divulg|promo|lan[çc]a|vend|campanha|convite|inscri/i;
|
|
43
48
|
|
|
44
49
|
const normal = (e) => {
|
|
@@ -73,7 +78,9 @@ function escolherEstilo({ pedido = '', agente = null, roteiro = null, girar = fa
|
|
|
73
78
|
const doRoteiro = normal(roteiro);
|
|
74
79
|
/* Pedir MOTION (ou animação) é pedir o gerativo — antes do vox, da marca e
|
|
75
80
|
do lançamento, que são leiaute fixo ("não pode ter template", 30/09). */
|
|
76
|
-
|
|
81
|
+
// O personagem também é animação: "animação 3D do Claude falando" não pode cair no gerativo.
|
|
82
|
+
if (doRoteiro && !(!['gerativo', 'fluido', 'personagem'].includes(doRoteiro) && PEDE_MOTION.test(pedido))) return doRoteiro;
|
|
83
|
+
if (PEDE_PERSONAGEM.test(pedido)) return 'personagem';
|
|
77
84
|
// O fluido é um jeito de motion: "motion minimalista" é o fluido, não o gerativo.
|
|
78
85
|
if (PEDE_FLUIDO.test(pedido)) return 'fluido';
|
|
79
86
|
if (PEDE_MOTION.test(pedido)) return 'gerativo';
|
|
@@ -88,6 +95,6 @@ function escolherEstilo({ pedido = '', agente = null, roteiro = null, girar = fa
|
|
|
88
95
|
}
|
|
89
96
|
|
|
90
97
|
/** Os estilos que usam os efeitos leves e a trilha do ende.app (os de motion, não os de corte). */
|
|
91
|
-
const LEVES = new Set(['gerativo', 'motion', 'vox', 'marca', 'lancamento', 'fluido']);
|
|
98
|
+
const LEVES = new Set(['gerativo', 'motion', 'vox', 'marca', 'lancamento', 'fluido', 'personagem']);
|
|
92
99
|
|
|
93
|
-
module.exports = { escolherEstilo, estiloDaVez, ESTILOS, LEVES, PEDE_MOTION, PEDE_FLUIDO, RODIZIO, RODIZIO_SEM_IMAGEM };
|
|
100
|
+
module.exports = { escolherEstilo, estiloDaVez, ESTILOS, LEVES, PEDE_MOTION, PEDE_FLUIDO, RODIZIO, RODIZIO_SEM_IMAGEM, PEDE_PERSONAGEM };
|
package/lib/estudio/gerativo.js
CHANGED
|
@@ -42,6 +42,7 @@
|
|
|
42
42
|
const { misturarCor, ehCor } = require('./morfar.js');
|
|
43
43
|
const { volumesDaTrilha } = require('./ritmo.js');
|
|
44
44
|
const { falaDe } = require('./composicao.js');
|
|
45
|
+
const { limitesNaCamera, MARGEM } = require('./margem.js');
|
|
45
46
|
|
|
46
47
|
const FPS = 30;
|
|
47
48
|
const QUADRO = 1 / FPS;
|
|
@@ -1255,6 +1256,23 @@ function partituraDe({ roteiro, largura = 1920, altura = 1080, tempos = [], icon
|
|
|
1255
1256
|
} else camera.push({ t: r3(b.ate), x: 0, y: 0, zoom: r3(z1 + 0.03), rot: 0, e: 'camera' });
|
|
1256
1257
|
});
|
|
1257
1258
|
|
|
1259
|
+
/* ── a área segura de cada batida (margem.js) ──────────────────────
|
|
1260
|
+
O bloco parado não pode encostar na borda DA TELA — e a tela vê o
|
|
1261
|
+
mundo pela câmera: com zoom 1,1 e a batida 110 px mais perto da lente,
|
|
1262
|
+
a margem de 8,5% do mundo virava 3% na tela ("pensam" colado na borda,
|
|
1263
|
+
10/10). Aqui o limite da tela vira limite do mundo, batida a batida; o
|
|
1264
|
+
componente encaixa o bloco MEDIDO dentro dele. O ícone vive mais perto
|
|
1265
|
+
da lente (translateZ 150), então tem o limite dele. */
|
|
1266
|
+
const D = Math.round(Math.max(W, H) * 0.85);
|
|
1267
|
+
batidas.forEach((b) => {
|
|
1268
|
+
const assenta = Math.min(b.de + 0.55, (b.de + b.ate) / 2);
|
|
1269
|
+
const fimCalmo = Math.max(assenta + 0.1, b.ate - 0.32);
|
|
1270
|
+
const op = { W, H, camera, de: assenta, ate: fimCalmo, D };
|
|
1271
|
+
b.seguro = limitesNaCamera({ ...op, z: 110 });
|
|
1272
|
+
b.seguroIcone = limitesNaCamera({ ...op, z: 260 });
|
|
1273
|
+
b.vao = Math.round(Math.min(W, H) * MARGEM.vao);
|
|
1274
|
+
});
|
|
1275
|
+
|
|
1258
1276
|
// ── o produto em 3D: mora nas batidas que o hospedam ─────────────────
|
|
1259
1277
|
const produtos = batidas.filter((b) => b.produto3d).map((b) => ({ ...b.produto3d, de: b.de, ate: b.ate, modelo: roteiro.modelo3d,
|
|
1260
1278
|
cor: roteiro.cor3d || '#1b1c20', corAcento: P.acento, ...(roteiro.variacao3d || {}) }));
|