primocode 8.38.1 → 8.40.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/studio/server.py CHANGED
@@ -374,11 +374,21 @@ def preparar_voz(doc):
374
374
  Com o ElevenLabs configurado, não há nada para baixar: a síntese é
375
375
  imediata e os 90 MB do Piper ficam para o dia em que faltarem chave ou
376
376
  internet."""
377
- if _tem_narracao(doc) and not elevenlabs_lib.configurado() and not voz_lib.pronta():
377
+ if _tem_narracao(doc) and not elevenlabs_lib.configurado():
378
+ # O Edge (pip, segundos) e o Piper (90 MB, minutos) começam JUNTOS.
379
+ # Antes só o Piper era preparado aqui, e o Edge — a melhor voz grátis —
380
+ # só era instalado quando a primeira cena já estava pedindo áudio: a
381
+ # narração caía no Google enquanto o pip rodava, e o dono ouviu a
382
+ # voz de robô no vídeo inteiro.
378
383
  try:
379
- voz_lib.instalar()
384
+ edge_lib.instalar()
380
385
  except Exception:
381
386
  pass
387
+ if not voz_lib.pronta():
388
+ try:
389
+ voz_lib.instalar()
390
+ except Exception:
391
+ pass
382
392
 
383
393
 
384
394
  # ------------------------------------------------------- narração em arquivo --
@@ -474,6 +484,7 @@ def _gravar_narracao(arq_id):
474
484
  if c.get("narracaoAudio") and not c.get("narracaoVoz"):
475
485
  continue
476
486
  audio, ext, dur = None, ".wav", None
487
+ motor_usado = None
477
488
  # A CORRENTE, na ordem de qualidade. Cada motor cai no seguinte
478
489
  # quando não devolve áudio — e o Google, no fim, garante que a
479
490
  # cena nunca fique muda. É o que faz o pré-render nunca parar.
@@ -483,14 +494,19 @@ def _gravar_narracao(arq_id):
483
494
  if elevenlabs_lib.configurado():
484
495
  audio, dur = elevenlabs_lib.sintetizar(texto, voz)
485
496
  if audio:
486
- ext = ".mp3"
497
+ ext, motor_usado = ".mp3", "elevenlabs"
487
498
 
488
499
  # 2) Edge TTS: neural, grátis, sem chave. MP3, duração medida pelo
489
- # VTT que o próprio edge escreve.
490
- if not audio and edge_lib.disponivel():
491
- dados, edur = edge_lib.sintetizar(texto, voz)
492
- if dados:
493
- audio, dur, ext = dados, edur, ".mp3"
500
+ # VTT que o próprio edge escreve. Ninguém está olhando este
501
+ # laço: se o pip ainda roda, espera por ele (até 2 min) — é de
502
+ # graça, e é o que separa voz de gente de voz de robô.
503
+ if not audio:
504
+ if not edge_lib.disponivel():
505
+ edge_lib.instalar(esperar=120)
506
+ if edge_lib.disponivel():
507
+ dados, edur = edge_lib.sintetizar(texto, voz)
508
+ if dados:
509
+ audio, dur, ext, motor_usado = dados, edur, ".mp3", "edge"
494
510
 
495
511
  # 3) Piper: neural e offline, quando baixado e compatível. WAV.
496
512
  if not audio:
@@ -503,15 +519,19 @@ def _gravar_narracao(arq_id):
503
519
  sys.stderr.write("[voz] Piper cena %d falhou: %s\n" % (i + 1, e))
504
520
  audio = None
505
521
  if audio:
506
- dur, ext = _duracao_wav(audio), ".wav"
522
+ dur, ext, motor_usado = _duracao_wav(audio), ".wav", "piper"
507
523
 
508
524
  # 4) Google: última reserva. Robótica, mas responde. MP3 — a
509
525
  # duração sai do tamanho (CBR de 24 kbps ≈ 3000 bytes/s).
526
+ # Vai MARCADA na cena (`narracaoMotor`): o agente lê e avisa a
527
+ # pessoa, em vez de ela descobrir ouvindo.
510
528
  if not audio:
511
529
  dados = _google_tts(texto)
512
530
  if dados:
513
- audio, ext = dados, ".mp3"
531
+ audio, ext, motor_usado = dados, ".mp3", "google"
514
532
  dur = round(len(dados) / 3000.0, 2)
533
+ sys.stderr.write("[voz] RESERVA na cena %d: Google (edge-tts: %s)\n"
534
+ % (i + 1, edge_lib.ultimo_erro() or "não instalado"))
515
535
 
516
536
  if not audio:
517
537
  continue
@@ -520,6 +540,7 @@ def _gravar_narracao(arq_id):
520
540
  fh.write(audio)
521
541
  c["narracaoAudio"] = "/midia/" + nome
522
542
  c["narracaoVoz"] = assinatura
543
+ c["narracaoMotor"] = motor_usado
523
544
  # A duração medida vai junto: com ela o player sabe quanto a cena
524
545
  # REALMENTE dura (a fala inteira + respiro) e a linha do tempo
525
546
  # vira determinística — sem ela, o relógio congela na beirada da
@@ -540,6 +561,8 @@ def _gravar_narracao(arq_id):
540
561
  and cenas[i].get("narracao") == c.get("narracao"):
541
562
  cenas[i]["narracaoAudio"] = c["narracaoAudio"]
542
563
  cenas[i]["narracaoVoz"] = c["narracaoVoz"]
564
+ if c.get("narracaoMotor"):
565
+ cenas[i]["narracaoMotor"] = c["narracaoMotor"]
543
566
  if c.get("narracaoDur"):
544
567
  cenas[i]["narracaoDur"] = c["narracaoDur"]
545
568
  gravar(atual)
@@ -1473,6 +1496,11 @@ class Handler(SimpleHTTPRequestHandler):
1473
1496
  # ElevenLabs porque é a melhor voz gratuita da corrente — vozes
1474
1497
  # da Microsoft, muito acima do Google. Precisa de internet.
1475
1498
  if not audio and motor in ("", "edge"):
1499
+ # Pacote ainda não instalado? ESPERA o pip (até 60 s) em vez de
1500
+ # cair para o Google. Quem pediu voz tem internet — é o que o Edge
1501
+ # precisa — e meio minuto é menos ruim que a voz de robô no vídeo.
1502
+ if not edge_lib.disponivel():
1503
+ edge_lib.instalar(esperar=60)
1476
1504
  dados, _ = edge_lib.sintetizar(texto, {"voz": voz_pedida,
1477
1505
  "grave": grave, "velocidade": velocidade})
1478
1506
  if dados:
@@ -1505,17 +1533,25 @@ class Handler(SimpleHTTPRequestHandler):
1505
1533
  except Exception as e:
1506
1534
  sys.stderr.write("[voz] Voicebox falhou (%s); indo de Google\n" % e)
1507
1535
  audio = b""
1508
- # 3) Google: uma voz só, mas responde sempre.
1536
+ # 3) Google: uma voz só, mas responde sempre. É a EXCEÇÃO, e vai
1537
+ # marcada: o cabeçalho diz qual motor falou, para o player e o
1538
+ # agente saberem que a voz saiu pela reserva — em vez de a pessoa
1539
+ # descobrir ouvindo.
1540
+ motor_usado = "elevenlabs" if (audio and tipo == "audio/mpeg" and elevenlabs_lib.configurado()) else \
1541
+ ("edge" if audio and tipo == "audio/mpeg" else ("piper" if audio and tipo == "audio/wav" else "voicebox" if audio else "google"))
1509
1542
  if not audio:
1510
1543
  tipo = "audio/mpeg"
1511
1544
  try:
1512
1545
  audio = b"".join(self._trecho_de_voz(t, idioma) for t in _picar(texto, 190))
1513
1546
  except Exception as e: # rede, bloqueio, mudança do serviço
1514
1547
  return self.js({"erro": "voz indisponível: %s" % e}, 502)
1548
+ sys.stderr.write("[voz] RESERVA: a narração saiu pelo Google (edge-tts: %s)\n"
1549
+ % (edge_lib.ultimo_erro() or "não instalado"))
1515
1550
  if not audio:
1516
1551
  return self.js({"erro": "voz não devolveu áudio"}, 502)
1517
1552
 
1518
1553
  self.send_response(200)
1554
+ self.send_header("X-Voz-Motor", motor_usado)
1519
1555
  self.send_header("Content-Type", tipo)
1520
1556
  self.send_header("Content-Length", str(len(audio)))
1521
1557
  self.send_header("Cache-Control", "no-store")
@@ -0,0 +1,28 @@
1
+ [studio] 2026-09-09T12:28:40+00:00 — iniciando na porta 7797
2
+
3
+ ██ PrimoCode Studio
4
+ ────────────────────────────────────────────────────────
5
+ Estúdio http://localhost:7797
6
+ Manual da IA http://localhost:7797/api/manual
7
+ Ferramentas Prisma, Tela, Prosa, Grade, Corte, Traço
8
+ ────────────────────────────────────────────────────────
9
+ Ctrl+C para parar
10
+
11
+ GET /api/arquivos
12
+ POST /api/arquivos
13
+ GET /api/arquivos/tela-motion-3ecaa
14
+ GET /api/voz/status
15
+ [studio] 2026-09-09T12:31:01+00:00 — iniciando na porta 7797
16
+
17
+ ██ PrimoCode Studio
18
+ ────────────────────────────────────────────────────────
19
+ Estúdio http://localhost:7797
20
+ Manual da IA http://localhost:7797/api/manual
21
+ Ferramentas Prisma, Tela, Prosa, Grade, Corte, Traço
22
+ ────────────────────────────────────────────────────────
23
+ Ctrl+C para parar
24
+
25
+ GET /api/arquivos
26
+ POST /api/arquivos
27
+ GET /api/arquivos/tela-motion-d6150
28
+ GET /api/voz/status
@@ -188,10 +188,18 @@
188
188
  "click" | "nenhum") e ganha do automático por transição. */
189
189
  if (sfx && tocando && ehVideo && atual !== anterior && doc.sfx !== false) {
190
190
  var cenaNova = cenas[atual] || {};
191
- if (cenaNova.sfx !== "nenhum") {
192
- if (cenaNova.sfx && sfx[cenaNova.sfx]) sfx[cenaNova.sfx]();
191
+ var somDaCena = cenaNova.som != null ? cenaNova.som : cenaNova.sfx;
192
+ if (somDaCena !== "nenhum") {
193
+ if (somDaCena) sfx.tocar(somDaCena);
193
194
  else sfx.transicao(cenaNova.transicao, atual);
194
195
  }
196
+ /* SOM POR ELEMENTO: `som` num elemento toca quando ELE entra — no
197
+ mesmo instante da animação de entrada (o mesmo atraso da cascata
198
+ que o render.js dá ao --atraso). Um número que faz "ding" ao
199
+ aparecer, um ícone que faz "pop", a barra que sobe com o riser: é
200
+ o que separa motion de slide animado. Nome da biblioteca ou
201
+ {arquivo, volume} — o arquivo entra no grafo e vai para o MP4. */
202
+ agendarSonsDosElementos(cenaNova);
195
203
  }
196
204
  narrar();
197
205
  pintarApresentador();
@@ -507,6 +515,26 @@
507
515
 
508
516
  var sfx = null;
509
517
 
518
+ /* Os sons dos ELEMENTOS da cena que acabou de entrar. O atraso é o mesmo
519
+ que o render.js dá à animação de entrada (cascata: i × 0,09 s + 0,1 s, ou
520
+ o `anim.atraso` declarado), então o som cai no instante em que o
521
+ elemento aparece. Trocar de cena antes cancela o que ainda não tocou —
522
+ senão o "ding" da cena 3 tocava em cima da cena 4. */
523
+ var sonsAgendados = [];
524
+ function agendarSonsDosElementos(cena) {
525
+ sonsAgendados.forEach(clearTimeout);
526
+ sonsAgendados = [];
527
+ if (!sfx || !cena || !Array.isArray(cena.elementos)) return;
528
+ cena.elementos.forEach(function (e, i) {
529
+ if (!e || !e.som) return;
530
+ var atraso = (e.anim && typeof e.anim === "object" && e.anim.atraso != null)
531
+ ? +e.anim.atraso
532
+ : (e.chaves && e.chaves.length > 1 ? 0 : i * 0.09 + 0.1);
533
+ if (typeof e.som === "object" && e.som.em != null) atraso = +e.som.em; // segundos na cena, explícito
534
+ sonsAgendados.push(setTimeout(function () { sfx.tocar(e.som); }, Math.max(0, atraso) * 1000));
535
+ });
536
+ }
537
+
510
538
  function prepararTrilha() {
511
539
  if (typeof criarTrilha !== "function") return;
512
540
  var cfg = doc.trilha;
@@ -279,8 +279,14 @@ function aplicarChaves(no, e, ctx) {
279
279
  // espera: "o que eu não toquei, fica como estava". O primeiro valor de
280
280
  // cada campo, quando nem a primeira chave o declara, é o neutro (escala e
281
281
  // opacidade em 1, deslocamento e giro em 0) — nunca zero para escala.
282
- var NEUTRO = { x: 0, y: 0, escala: 1, giro: 0, opacidade: 1, desfoque: 0 };
283
- var CAMPOS = ["x", "y", "escala", "giro", "opacidade", "desfoque"];
282
+ // giroX/giroY/prof são o 3D: viram rotateX/rotateY/translateZ com uma
283
+ // perspectiva na frente. É o que faz um cartão virar de verdade em vez de
284
+ // só encolher — pedido do dono: "quero que a IA faça coisas 3D, mexa com
285
+ // keyframes". escalaX/escalaY esticam num eixo só (a "gota" que se
286
+ // achata ao pousar). `prof`, não `z`: `z` no elemento já é a ordem das
287
+ // camadas (zIndex), e o mesmo nome com dois sentidos é armadilha.
288
+ var NEUTRO = { x: 0, y: 0, escala: 1, escalaX: 1, escalaY: 1, giro: 0, giroX: 0, giroY: 0, prof: 0, opacidade: 1, desfoque: 0 };
289
+ var CAMPOS = ["x", "y", "escala", "escalaX", "escalaY", "giro", "giroX", "giroY", "prof", "opacidade", "desfoque"];
284
290
  var herdado = {};
285
291
  CAMPOS.forEach(function (c) {
286
292
  // semente: primeiro valor declarado em qualquer chave, senão o neutro.
@@ -301,8 +307,17 @@ function aplicarChaves(no, e, ctx) {
301
307
  // Assim a chave continua valendo se a pessoa arrastar o elemento no
302
308
  // palco depois — o contrário obrigaria a refazer todas as chaves.
303
309
  var dx = herdado.x, dy = herdado.y;
310
+ var tem3d = herdado.giroX || herdado.giroY || herdado.prof;
311
+ // A perspectiva vai NA transformação, não no pai: o pai é a cena, e uma
312
+ // perspectiva lá mudaria todos os elementos. Constante em todas as
313
+ // chaves, para o navegador interpolar só o que muda.
314
+ if (tem3d) mov.push("perspective(1200px)");
304
315
  if (dx || dy) mov.push("translate(" + dx + "px," + dy + "px)");
316
+ if (herdado.prof) mov.push("translateZ(" + herdado.prof + "px)");
305
317
  if (herdado.escala !== 1) mov.push("scale(" + herdado.escala + ")");
318
+ if (herdado.escalaX !== 1 || herdado.escalaY !== 1) mov.push("scale(" + herdado.escalaX + "," + herdado.escalaY + ")");
319
+ if (herdado.giroX) mov.push("rotateX(" + herdado.giroX + "deg)");
320
+ if (herdado.giroY) mov.push("rotateY(" + herdado.giroY + "deg)");
306
321
  if (herdado.giro) mov.push("rotate(" + herdado.giro + "deg)");
307
322
 
308
323
  var linhas = ["transform:" + (mov.length ? mov.join(" ") : "none")];
@@ -328,9 +343,38 @@ function aplicarChaves(no, e, ctx) {
328
343
  var dur = _numero(ctx.duracao, 4);
329
344
  no.style.animationName = nome;
330
345
  no.style.animationDuration = dur + "s";
331
- no.style.animationTimingFunction = e.suavizar === false ? "linear" : "cubic-bezier(.4,0,.2,1)";
346
+ no.style.animationTimingFunction = curvaDe(e.suavizar);
332
347
  no.style.animationFillMode = "both";
333
348
  no.style.animationIterationCount = e.repetir ? "infinite" : "1";
349
+ if (e.repetir && e.vaiEVolta) no.style.animationDirection = "alternate";
350
+ if (tem3dEm(chaves)) no.style.transformStyle = "preserve-3d";
351
+ }
352
+
353
+ function tem3dEm(chaves) {
354
+ for (var i = 0; i < chaves.length; i++) {
355
+ if (chaves[i].giroX || chaves[i].giroY || chaves[i].prof) return true;
356
+ }
357
+ return false;
358
+ }
359
+
360
+ /* As curvas com NOME. `suavizar: false` continua sendo linear; o padrão
361
+ continua sendo a curva suave. Os nomes existem porque "elástico" e "salto"
362
+ são o que separa motion de slide animado, e pedir cubic-bezier a quem
363
+ escreve JSON é pedir chute. */
364
+ var CURVAS = {
365
+ suave: "cubic-bezier(.4,0,.2,1)",
366
+ linear: "linear",
367
+ entrada: "cubic-bezier(.4,0,1,1)", // acelera: sai devagar, chega rápido
368
+ saida: "cubic-bezier(0,0,.2,1)", // freia: chega devagar
369
+ elastico: "cubic-bezier(.34,1.56,.64,1)", // passa do ponto e volta
370
+ salto: "cubic-bezier(.68,-.55,.27,1.55)", // recua, dispara, passa e assenta
371
+ degrau: "steps(6, end)", // quadro a quadro, para o "stop motion"
372
+ };
373
+ function curvaDe(suavizar) {
374
+ if (suavizar === false) return CURVAS.linear;
375
+ if (typeof suavizar === "string" && CURVAS[suavizar]) return CURVAS[suavizar];
376
+ if (typeof suavizar === "string" && /^(cubic-bezier|steps)\(/.test(suavizar)) return suavizar;
377
+ return CURVAS.suave;
334
378
  }
335
379
 
336
380
  /* ── identidade da marca ─────────────────────────────────────────────────
@@ -527,6 +571,14 @@ function renderElemento(e, ctx) {
527
571
  if (e.opacidade != null) no.style.opacity = e.opacidade;
528
572
  if (e.sombra) no.style.boxShadow = e.sombra === true ? "0 30px 70px -25px rgba(0,0,0,.6)" : e.sombra;
529
573
  if (e.z != null) no.style.zIndex = e.z;
574
+ /* MATERIAL: "vidro" é o liquid glass — translúcido, desfocando o que está
575
+ atrás, com bisel de luz em cima e o reflexo diagonal. Vale em QUALQUER
576
+ elemento (texto, forma, ícone, imagem, lista, cartões), por isso mora
577
+ aqui e não num tipo. O CSS faz o resto por `data-material`. */
578
+ if (e.material) {
579
+ no.dataset.material = e.material;
580
+ if (e.raio != null) no.style.borderRadius = _px(e.raio);
581
+ }
530
582
 
531
583
  /* animação */
532
584
  var an = e.anim || ctx.animPadrao;
@@ -882,7 +934,14 @@ function renderElemento(e, ctx) {
882
934
 
883
935
  case "forma": {
884
936
  var f = e.forma || "retangulo";
885
- no.style.background = e.preenchimento || "var(--c-tom)";
937
+ // Forma de vidro: o `preenchimento` vira a TINTA sob o vidro (fraca),
938
+ // e o vidro em si é do CSS. Pintar o fundo inteiro aqui cobriria o
939
+ // desfoque — e um vidro opaco é um retângulo.
940
+ if (e.material === "vidro") {
941
+ if (e.preenchimento) no.style.setProperty("--tinta", e.preenchimento);
942
+ } else {
943
+ no.style.background = e.preenchimento || "var(--c-tom)";
944
+ }
886
945
  if (e.borda) no.style.border = (e.borda.largura || 2) + "px solid " + (e.borda.cor || "var(--c-linha)");
887
946
  if (f === "circulo") no.style.borderRadius = "999px";
888
947
  else no.style.borderRadius = _px(e.raio != null ? e.raio : 0);
@@ -1182,6 +1241,17 @@ function renderElemento(e, ctx) {
1182
1241
  no.style.width = _px(e.l || t);
1183
1242
  no.style.height = _px(e.a || t);
1184
1243
  no.style.color = e.cor || "var(--c-tom)";
1244
+ /* ÍCONE COMO OBJETO: `estilo: "app"` é o azulejo de aplicativo —
1245
+ gradiente da cor, brilho em cima, sombra embaixo, o traço branco —
1246
+ e `estilo: "vidro"` é o mesmo azulejo em liquid glass. Pedido do
1247
+ dono ("icon, liquid glass, bem motion"): um ícone de traço sozinho
1248
+ é sinal; num azulejo vira coisa. `fundo` pinta o azulejo; sem ele, a
1249
+ cor do tema. */
1250
+ if (e.estilo === "app" || e.estilo === "vidro") {
1251
+ no.dataset.estilo = e.estilo;
1252
+ no.style.setProperty("--azulejo", e.fundo || e.cor || "var(--c-tom)");
1253
+ if (e.estilo === "app") no.style.color = e.corTraco || "#fff";
1254
+ }
1185
1255
  // `caminho`: o ícone desenhado pelo modelo (path SVG 24×24) — ver
1186
1256
  // icones.js. Sem ele, um da lista pelo nome.
1187
1257
  no.innerHTML = e.caminho
@@ -85,6 +85,110 @@ function criarSfx(ctx, destino) {
85
85
  o.start(agora); o.stop(agora + 0.07);
86
86
  }
87
87
 
88
+ /* ── A biblioteca cresceu porque três sons não fazem motion ───────────
89
+ Pedido do dono: "efeitos sonoros… bem motion mesmo". Whoosh, impacto e
90
+ click cobrem a transição; o que entra num elemento — um número que
91
+ aparece, um ícone que pousa, uma barra que sobe — pedia mais. Todos
92
+ sintetizados, sem arquivo e sem licença, calibrados para ficar SOB a
93
+ narração. */
94
+
95
+ /* Bolha estourando: um seno que sobe rápido e some. Entrada de item. */
96
+ function pop() {
97
+ var agora = ctx.currentTime;
98
+ var o = ctx.createOscillator(), g = ctx.createGain();
99
+ o.type = "sine";
100
+ o.frequency.setValueAtTime(380, agora);
101
+ o.frequency.exponentialRampToValueAtTime(900, agora + 0.06);
102
+ g.gain.setValueAtTime(0.0001, agora);
103
+ g.gain.exponentialRampToValueAtTime(0.16, agora + 0.012);
104
+ g.gain.exponentialRampToValueAtTime(0.0001, agora + 0.14);
105
+ o.connect(g); g.connect(saida);
106
+ o.start(agora); o.stop(agora + 0.16);
107
+ }
108
+
109
+ /* Sino de notificação: dois parciais (fundamental + quinta acima), cauda
110
+ longa. Confirmação, número final, "pronto". */
111
+ function ding() {
112
+ var agora = ctx.currentTime;
113
+ [[1320, 0.11], [1980, 0.05], [2640, 0.025]].forEach(function (p, i) {
114
+ var o = ctx.createOscillator(), g = ctx.createGain();
115
+ o.type = "sine";
116
+ o.frequency.value = p[0];
117
+ g.gain.setValueAtTime(0.0001, agora);
118
+ g.gain.exponentialRampToValueAtTime(p[1], agora + 0.008);
119
+ g.gain.exponentialRampToValueAtTime(0.0001, agora + 0.9 - i * 0.15);
120
+ o.connect(g); g.connect(saida);
121
+ o.start(agora); o.stop(agora + 1.0);
122
+ });
123
+ }
124
+
125
+ /* Subida (riser): ruído filtrado que sobe por um segundo e meio e para
126
+ seco — a tensão antes do número grande ou da assinatura. */
127
+ function subida() {
128
+ var agora = ctx.currentTime;
129
+ var s = ctx.createBufferSource(), g = ctx.createGain(), f = ctx.createBiquadFilter();
130
+ s.buffer = bufferRuido();
131
+ s.loop = true;
132
+ f.type = "bandpass"; f.Q.value = 2.2;
133
+ f.frequency.setValueAtTime(180, agora);
134
+ f.frequency.exponentialRampToValueAtTime(3800, agora + 1.5);
135
+ g.gain.setValueAtTime(0.0001, agora);
136
+ g.gain.exponentialRampToValueAtTime(0.13, agora + 1.4);
137
+ g.gain.setValueAtTime(0.0001, agora + 1.52);
138
+ s.connect(f); f.connect(g); g.connect(saida);
139
+ s.start(agora); s.stop(agora + 1.6);
140
+ }
141
+
142
+ /* Tique de relógio/teclado: madeira seca. Passo de lista, contagem. */
143
+ function tique() {
144
+ var agora = ctx.currentTime;
145
+ var s = ctx.createBufferSource(), g = ctx.createGain(), f = ctx.createBiquadFilter();
146
+ s.buffer = bufferRuido();
147
+ f.type = "highpass"; f.frequency.value = 2600;
148
+ g.gain.setValueAtTime(0.14, agora);
149
+ g.gain.exponentialRampToValueAtTime(0.0001, agora + 0.04);
150
+ s.connect(f); f.connect(g); g.connect(saida);
151
+ s.start(agora); s.stop(agora + 0.05);
152
+ }
153
+
154
+ /* Brilho: arpejo rápido e agudo, o "sparkle" de um destaque ou de uma
155
+ estrela acendendo. */
156
+ function brilho() {
157
+ var agora = ctx.currentTime;
158
+ [1568, 2093, 2637, 3136].forEach(function (hz, i) {
159
+ var o = ctx.createOscillator(), g = ctx.createGain();
160
+ o.type = "triangle";
161
+ o.frequency.value = hz;
162
+ var t = agora + i * 0.045;
163
+ g.gain.setValueAtTime(0.0001, t);
164
+ g.gain.exponentialRampToValueAtTime(0.06, t + 0.01);
165
+ g.gain.exponentialRampToValueAtTime(0.0001, t + 0.35);
166
+ o.connect(g); g.connect(saida);
167
+ o.start(t); o.stop(t + 0.4);
168
+ });
169
+ }
170
+
171
+ /* Um ARQUIVO de som (o efeito baixado da internet pelo agente, ou o do
172
+ usuário), tocado dentro do MESMO grafo — é o que faz ele entrar no MP4
173
+ exportado junto da trilha. Decodificado uma vez por URL. */
174
+ var cacheArquivos = {};
175
+ function arquivo(url, volume) {
176
+ if (!url) return;
177
+ var pronto = cacheArquivos[url] || (cacheArquivos[url] = fetch(url)
178
+ .then(function (r) { return r.arrayBuffer(); })
179
+ .then(function (b) { return ctx.decodeAudioData(b); })
180
+ .catch(function () { return null; }));
181
+ pronto.then(function (buffer) {
182
+ if (!buffer) return;
183
+ var s = ctx.createBufferSource(), g = ctx.createGain();
184
+ s.buffer = buffer;
185
+ g.gain.value = volume == null ? 0.7 : Math.max(0, Math.min(1, volume));
186
+ s.connect(g); g.connect(saida);
187
+ s.start(ctx.currentTime);
188
+ });
189
+ return pronto;
190
+ }
191
+
88
192
  /* O efeito certo para a ENTRADA de uma cena, pela transição dela.
89
193
  flash e zoom pedem peso; corte seco não pede nada. */
90
194
  function transicao(tipo, indice) {
@@ -93,15 +197,37 @@ function criarSfx(ctx, destino) {
93
197
  whoosh(((indice || 0) * 0.37) % 1);
94
198
  }
95
199
 
96
- return {
200
+ /* Toca pelo NOME ou por {arquivo, volume}. É o que o player chama para o
201
+ `som` de um elemento ou de uma cena. Nome desconhecido não toca nada —
202
+ e não quebra. */
203
+ function tocar(som) {
204
+ if (!som) return;
205
+ if (typeof som === "string") { if (typeof api[som] === "function" && som !== "tocar") api[som](); return; }
206
+ if (som.arquivo) return arquivo(som.arquivo, som.volume);
207
+ if (som.nome && typeof api[som.nome] === "function") api[som.nome]();
208
+ }
209
+
210
+ var api = {
97
211
  whoosh: whoosh,
98
212
  impacto: impacto,
99
213
  click: click,
214
+ pop: pop,
215
+ ding: ding,
216
+ subida: subida,
217
+ tique: tique,
218
+ brilho: brilho,
219
+ arquivo: arquivo,
220
+ tocar: tocar,
100
221
  transicao: transicao,
101
222
  volume: function (v) { saida.gain.value = v; },
102
223
  };
224
+ return api;
103
225
  }
104
226
 
227
+ /* Os nomes que o documento pode usar em `som`/`sfx` — lidos pela spec e pelo
228
+ servidor, para recusar nome inventado com a lista certa. */
229
+ var SONS = ["whoosh", "impacto", "click", "pop", "ding", "subida", "tique", "brilho"];
230
+
105
231
  if (typeof module !== "undefined" && module.exports) {
106
- module.exports = { criarSfx: criarSfx };
232
+ module.exports = { criarSfx: criarSfx, SONS: SONS };
107
233
  }
@@ -423,6 +423,101 @@
423
423
  0 18px 44px rgba(0, 0, 0, .34);
424
424
  }
425
425
 
426
+ /* ── MATERIAL: vidro (liquid glass) ─────────────────────────────────────
427
+ Pedido do dono: "liquid glass, icon, bem motion". O que faz o vidro ser
428
+ vidro, e não um retângulo translúcido:
429
+ · DESFOCA o que está atrás (backdrop-filter) e SATURA — o fundo fica
430
+ vivo através dele, como numa lente;
431
+ · o BISEL: uma linha clara em cima (onde a luz bate) e uma escura
432
+ embaixo, dentro da própria caixa;
433
+ · o REFLEXO diagonal (::before), que no vídeo atravessa devagar — é o
434
+ "líquido" do nome;
435
+ · a TINTA opcional (--tinta), fraca, para o vidro ter cor sem ficar opaco.
436
+ Vale em qualquer elemento: texto vira pílula de vidro, forma vira painel,
437
+ ícone vira azulejo, imagem ganha moldura. ─────────────────────────────── */
438
+ .el[data-material="vidro"] {
439
+ position: absolute;
440
+ background:
441
+ linear-gradient(135deg, rgba(255, 255, 255, .26), rgba(255, 255, 255, .07) 38%,
442
+ rgba(255, 255, 255, .02) 60%, rgba(255, 255, 255, .14)),
443
+ color-mix(in srgb, var(--tinta, transparent) 28%, transparent);
444
+ -webkit-backdrop-filter: blur(22px) saturate(1.65);
445
+ backdrop-filter: blur(22px) saturate(1.65);
446
+ border: 1px solid rgba(255, 255, 255, .30);
447
+ border-radius: 28px;
448
+ box-shadow:
449
+ inset 0 1px 0 rgba(255, 255, 255, .60),
450
+ inset 0 -1px 0 rgba(0, 0, 0, .20),
451
+ inset 0 0 0 1px rgba(255, 255, 255, .05),
452
+ 0 26px 60px -22px rgba(0, 0, 0, .60);
453
+ overflow: hidden;
454
+ }
455
+ .el[data-material="vidro"]::before {
456
+ content: ""; position: absolute; inset: -40% -30%; pointer-events: none;
457
+ background: linear-gradient(115deg, transparent 40%, rgba(255, 255, 255, .30) 47%,
458
+ rgba(255, 255, 255, .07) 53%, transparent 60%);
459
+ transform: translateX(-28%);
460
+ }
461
+ .cena.animar .el[data-material="vidro"]::before { animation: brilhoVidro 7s cubic-bezier(.4, 0, .2, 1) infinite; }
462
+ @keyframes brilhoVidro {
463
+ 0%, 100% { transform: translateX(-28%); }
464
+ 50% { transform: translateX(28%); }
465
+ }
466
+ .el.txt[data-material="vidro"] { padding: .42em .85em; }
467
+ .el.forma[data-material="vidro"] { background-color: transparent; }
468
+ .el.imagem[data-material="vidro"] { padding: 14px; }
469
+ .el.imagem[data-material="vidro"] img { border-radius: calc(28px - 12px); }
470
+ .el.lista[data-material="vidro"], .el.cartoes[data-material="vidro"], .el.numeros[data-material="vidro"] { padding: 4% 5%; }
471
+ /* Tema claro: o vidro escurece um pouco, senão some no branco. */
472
+ [data-tema="claro"] .el[data-material="vidro"], .tema-claro .el[data-material="vidro"] {
473
+ background:
474
+ linear-gradient(135deg, rgba(255, 255, 255, .62), rgba(255, 255, 255, .30) 40%, rgba(255, 255, 255, .55)),
475
+ color-mix(in srgb, var(--tinta, transparent) 22%, transparent);
476
+ border-color: rgba(0, 0, 0, .10);
477
+ box-shadow: inset 0 1px 0 rgba(255, 255, 255, .9), inset 0 -1px 0 rgba(0, 0, 0, .06), 0 26px 60px -24px rgba(0, 0, 0, .28);
478
+ }
479
+
480
+ /* ── ÍCONE COMO OBJETO: o azulejo de aplicativo ──────────────────────────
481
+ `estilo: "app"`: a cor em gradiente com duas luzes, o brilho na metade de
482
+ cima, a sombra colorida embaixo e o traço branco com sombra própria — é a
483
+ gramática de ícone de app, e o olho lê "produto" em vez de "sinal".
484
+ `estilo: "vidro"`: o mesmo azulejo em liquid glass. ─────────────────── */
485
+ .el.icone[data-estilo="app"], .el.icone[data-estilo="vidro"] {
486
+ position: absolute; border-radius: 23.5%; overflow: hidden;
487
+ }
488
+ /* O traço ocupa 58% do azulejo. NÃO por padding em %: padding em
489
+ porcentagem é relativo à LARGURA DO PAI (a cena, 1920px), e 21% virava
490
+ 400px de padding num ícone de 220 — o azulejo engolia o canto da tela e
491
+ o traço sumia. Medido na primeira captura. */
492
+ .el.icone[data-estilo="app"] svg, .el.icone[data-estilo="vidro"] svg { width: 58%; height: 58%; }
493
+ .el.icone[data-estilo="app"] {
494
+ --az: var(--azulejo, var(--c-tom));
495
+ background:
496
+ radial-gradient(130% 110% at 18% 8%, rgba(255, 255, 255, .55), transparent 52%),
497
+ linear-gradient(160deg, color-mix(in srgb, var(--az) 78%, #fff), var(--az) 52%,
498
+ color-mix(in srgb, var(--az) 68%, #000));
499
+ box-shadow:
500
+ inset 0 2px 0 rgba(255, 255, 255, .55),
501
+ inset 0 -3px 0 rgba(0, 0, 0, .28),
502
+ 0 22px 44px -16px color-mix(in srgb, var(--az) 70%, #000);
503
+ }
504
+ .el.icone[data-estilo="app"]::after {
505
+ content: ""; position: absolute; left: 5%; right: 5%; top: 3%; height: 47%; pointer-events: none;
506
+ border-radius: 22% 22% 48% 48% / 30% 30% 60% 60%;
507
+ background: linear-gradient(180deg, rgba(255, 255, 255, .34), rgba(255, 255, 255, .02));
508
+ }
509
+ .el.icone[data-estilo="app"] svg { position: relative; filter: drop-shadow(0 4px 8px rgba(0, 0, 0, .35)); }
510
+ .el.icone[data-estilo="vidro"] {
511
+ --tinta: var(--azulejo, var(--c-tom));
512
+ background:
513
+ linear-gradient(135deg, rgba(255, 255, 255, .28), rgba(255, 255, 255, .07) 40%, rgba(255, 255, 255, .16)),
514
+ color-mix(in srgb, var(--tinta) 30%, transparent);
515
+ -webkit-backdrop-filter: blur(20px) saturate(1.6); backdrop-filter: blur(20px) saturate(1.6);
516
+ border: 1px solid rgba(255, 255, 255, .32);
517
+ box-shadow: inset 0 1px 0 rgba(255, 255, 255, .6), inset 0 -1px 0 rgba(0, 0, 0, .2), 0 24px 50px -20px rgba(0, 0, 0, .55);
518
+ }
519
+ .el.icone[data-estilo="vidro"] svg { position: relative; filter: drop-shadow(0 3px 10px rgba(0, 0, 0, .3)); }
520
+
426
521
  /* ── selo ──────────────────────────────────────────────────────────────── */
427
522
  .el.selo {
428
523
  display: inline-flex; align-items: center; gap: .5em;