primocode 10.2.0 → 10.3.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.
@@ -38,6 +38,8 @@ const hibrido = require('./hibrido.js');
38
38
  const biblioteca = require('./biblioteca.js');
39
39
  const referencias = require('./referencias.js');
40
40
  const variedade = require('./variedade.js');
41
+ const diretor = require('./diretor.js');
42
+ const falantes = require('./falantes.js');
41
43
  const variacao3d = require('./variacao3d.js');
42
44
  const plano = require('./plano.js');
43
45
  const remotion = require('./remotion.js');
@@ -45,11 +47,14 @@ const assets = require('./assets.js');
45
47
  const video = require('./video.js');
46
48
  const batida = require('./batida.js');
47
49
  const revisor = require('./revisor.js');
50
+ const assinatura = require('./assinatura.js');
48
51
  const identidade = require('./identidade.js');
49
52
  const slides = require('./slides.js');
50
53
  const edicao = require('./edicao.js');
51
54
  const composicao = require('./composicao.js');
52
55
  const acervoSite = require('./acervo-site.js');
56
+ const printSite = require('./print-site.js');
57
+ const efeitos = require('./efeitos.js');
53
58
  const impacto = require('./impacto.js');
54
59
  const cinema = require('./cinema.js');
55
60
  const motion = require('./motion.js');
@@ -200,6 +205,17 @@ async function ferramentaProjeto(args, ctx = {}) {
200
205
  return { ok: true };
201
206
  }
202
207
 
208
+ /* O FEEDBACK que vira estilo: "adorei a virada", "texto demais" fica na
209
+ assinatura da pessoa (assinatura.js), e o próximo vídeo já parte dele.
210
+ Sem texto, devolve a assinatura (as faixas e as notas). */
211
+ if (acao === 'assinatura' || acao === 'feedback' || acao === 'gostei') {
212
+ const nome = args.nome || args.assinatura || 'fluido';
213
+ if (!args.texto) { const a = assinatura.ler(nome); return a ? { ok: true, assinatura: a } : { ok: false, error: `a assinatura "${nome}" não existe.` }; }
214
+ const r = assinatura.anotar(nome, args.texto);
215
+ if (r.ok && args.id) projeto.anotar(args.id, 'assinatura', `feedback gravado na assinatura ${nome}: ${args.texto}`);
216
+ return r;
217
+ }
218
+
203
219
  const todos = projeto.listar({ limite: Number(args.limite) || 20 });
204
220
  return {
205
221
  ok: true,
@@ -262,7 +278,8 @@ function roteiroValido(r) {
262
278
  const CINEMA = ['frase', 'montagem', 'numeros', 'escada', 'dia', 'depoimento', 'climax', 'final'];
263
279
  const NOVOS = ['logo', 'cores', 'produto']; // vox.js e lancamento.js
264
280
  if (!partes.every((pt) => pt && (composicao.TIPOS.includes(pt.tipo) || CINEMA.includes(pt.tipo) || NOVOS.includes(pt.tipo)))) return null;
265
- const obj = Array.isArray(x) ? { partes } : x;
281
+ // Duas vozes: roteiro.vozes e parte.falante, limpos (falantes.js). Sem eles, nada muda.
282
+ const obj = falantes.normalizar(Array.isArray(x) ? { partes } : x);
266
283
  return partes.some((pt) => CINEMA.includes(pt.tipo)) && !obj.estilo ? { ...obj, estilo: 'cinema' } : obj;
267
284
  }
268
285
 
@@ -365,6 +382,13 @@ async function ferramentaPlano(args) {
365
382
  // O roteiro fica guardado: ajustar uma frase é mandar o roteiro de novo, não reescrever tudo.
366
383
  try { fs.writeFileSync(path.join(p.pasta, 'roteiro.json'), JSON.stringify(original || args.roteiro, null, 2)); } catch { /* segue */ }
367
384
  projeto.salvar(args.id, { duracao: conferida.duracao });
385
+ /* A memória da estrutura (diretor.cardapioEstruturas): o próximo roteiro
386
+ vê o que este usou. Uma vez por projeto — mandar o roteiro de novo
387
+ para ajustar uma frase não é um vídeo novo. */
388
+ if (!p.estrutura) {
389
+ const usada = diretor.registrarEstrutura(original || args.roteiro);
390
+ projeto.salvar(args.id, { estrutura: usada.estrutura || '-' });
391
+ }
368
392
  return {
369
393
  ok: true, duracao: conferida.duracao, ...r.resumo, resumo: plano.resumo(r.plano),
370
394
  proximo: 'estudio_render com este id. Para ajustar, mande o roteiro de novo (é rápido) — não edite as cenas à mão.',
@@ -437,6 +461,26 @@ async function ferramentaMidia(args) {
437
461
  return { ok: true, src: relativo, metodo: r.metodo, aviso: r.aviso || undefined };
438
462
  }
439
463
 
464
+ /* O print de verdade do site (print-site.js), para a peça 3D `printsite`.
465
+ Só roda quando o modelo pede: o cardápio diz quando vale. */
466
+ if (acao === 'print') {
467
+ if (!p) return { ok: false, error: 'para tirar o print é preciso dizer em qual projeto (id).' };
468
+ const r = await printSite.capturarSite(args.url || args.site || args.termo, { pasta: p.pasta, celular: !!args.celular, inteira: args.inteira !== false });
469
+ if (r.ok) projeto.anotar(p.id, 'midia', `print de ${r.url} (${r.tela}): ${r.src}`);
470
+ return r;
471
+ }
472
+
473
+ // O catálogo de efeitos que a IA escreveu (efeitos.js): guardar, listar, reusar.
474
+ if (acao === 'efeitos') return { ok: true, efeitos: efeitos.cardapio() };
475
+ if (acao === 'guardar-efeito' || acao === 'usar-efeito') {
476
+ if (!p) return { ok: false, error: 'diga em qual projeto (id).' };
477
+ const r = acao === 'guardar-efeito'
478
+ ? efeitos.guardar({ pasta: p.pasta, arquivo: args.arquivo || args.src, nome: args.nome, descricao: args.descricao, tags: args.tags })
479
+ : efeitos.reusar({ pasta: p.pasta, nome: args.nome });
480
+ if (r.ok) projeto.anotar(p.id, 'midia', `${acao === 'guardar-efeito' ? 'guardou' : 'reusou'} o efeito ${r.nome || args.nome}`);
481
+ return r;
482
+ }
483
+
440
484
  if (acao === 'logo') {
441
485
  if (!p) return { ok: false, error: 'para baixar a logo é preciso dizer em qual projeto (id).' };
442
486
  const nome = String(args.marca || args.nome || args.termo || '').trim();
@@ -970,6 +1014,20 @@ function narrarReserva(lista, mp3, arq, voz, vozEdge, { soEdge = false, semKokor
970
1014
  });
971
1015
  }
972
1016
 
1017
+ /* DUAS VOZES (falantes.js): os mp3 de cada falante, um depois do outro, num
1018
+ arquivo só — reamostrados, porque o Gemini sai a 24 kHz e o Edge não. A
1019
+ duração de cada um é MEDIDA: é ela que empurra os tempos do grupo seguinte. */
1020
+ async function juntarVozes(arquivos, mp3) {
1021
+ const fil = arquivos.map((_, i) => `[${i}:a]aresample=44100,aformat=channel_layouts=mono[a${i}]`).join(';')
1022
+ + ';' + arquivos.map((_, i) => `[a${i}]`).join('') + `concat=n=${arquivos.length}:v=0:a=1[s]`;
1023
+ const c = await video.rodar(video.ffmpeg(), ['-y', ...arquivos.flatMap((a) => ['-i', a]), '-filter_complex', fil, '-map', '[s]',
1024
+ '-c:a', 'libmp3lame', '-q:a', '3', mp3], { timeout: 120000 });
1025
+ if (!c.ok) return { ok: false, error: 'não consegui juntar as vozes' };
1026
+ const duracoes = [];
1027
+ for (const a of arquivos) duracoes.push((await video.medir(a)).segundos);
1028
+ return { ok: true, duracoes };
1029
+ }
1030
+
973
1031
  /* A MÚSICA TEM LETRA? O acervo não diz se a faixa é instrumental, então ela
974
1032
  é ouvida: 25 s do meio vão para a transcrição, e mais de 6 palavras é voz
975
1033
  cantada. Sem servidor para ouvir, a faixa passa (melhor música que nenhuma). */
@@ -1015,6 +1073,14 @@ async function sonsReais(p, video) {
1015
1073
  const PROPORCOES = { '16:9': [1920, 1080], '9:16': [1080, 1920], '1:1': [1080, 1080], '4:5': [1080, 1350] };
1016
1074
 
1017
1075
  /** O roteiro vira plano: baixa o que falta, narra, e chama o compositor. */
1076
+ /* A ASSINATURA (assinatura.js): o vídeo é medido e conferido contra as
1077
+ faixas do estilo; o que saiu volta para o agente e fica no caderno. */
1078
+ function conferirAssinatura(p, r, nome) {
1079
+ const fora = assinatura.descrever(assinatura.conferir(nome, assinatura.medir(r.partitura, r.plano)).fora);
1080
+ r.resumo.assinatura = { nome, seguiu: !fora.length, fora: fora.length ? fora : undefined };
1081
+ projeto.anotar(p.id, 'assinatura', fora.length ? `fora da assinatura ${nome}: ${fora.join('; ')}` : `seguiu a assinatura ${nome}`);
1082
+ }
1083
+
1018
1084
  async function prepararRoteiro(p, args) {
1019
1085
  const roteiro = typeof args.roteiro === 'string' ? JSON.parse(args.roteiro) : args.roteiro;
1020
1086
  if (Array.isArray(roteiro)) return prepararRoteiro(p, { ...args, roteiro: { partes: roteiro } });
@@ -1066,6 +1132,14 @@ async function prepararRoteiro(p, args) {
1066
1132
  roteiro.semente = p.semente != null ? p.semente : Math.floor(Math.random() * 1e9);
1067
1133
  if (p.semente == null) projeto.salvar(p.id, { semente: roteiro.semente });
1068
1134
  }
1135
+ /* O humor, a paleta, as fontes e o gênero do gerativo, com memória entre
1136
+ vídeos (09/10, "vício em cores, músicas, fontes"): por PROJETO, como a semente. */
1137
+ // O assunto do vídeo é o rótulo da moldura (quando ela sai): o título do projeto.
1138
+ if (estilo === 'gerativo' && !roteiro.titulo && p.titulo) roteiro.titulo = p.titulo;
1139
+ if (estilo === 'gerativo' && !roteiro.escolhasGerativo) {
1140
+ roteiro.escolhasGerativo = p.escolhasGerativo || gerativo.escolherVisual(roteiro);
1141
+ if (!p.escolhasGerativo) projeto.salvar(p.id, { escolhasGerativo: roteiro.escolhasGerativo });
1142
+ }
1069
1143
  if (ref.modo === 'fechado' && !roteiro.busca && referencias.termos().length) roteiro.busca = referencias.termos();
1070
1144
  /* A BIBLIOTECA (biblioteca.js): sem fonte e cor da marca, o híbrido, o
1071
1145
  cinema e o impacto caíam sempre no Inter e no azul e roxo — "os vídeos
@@ -1135,7 +1209,13 @@ async function prepararRoteiro(p, args) {
1135
1209
  // Só as partes com voz vão para a narração; o depoimento tem a voz do aluno.
1136
1210
  // Só as partes com voz: o depoimento tem a voz do aluno, e a parte muda não tem voz.
1137
1211
  // A parte com voz forte é narrada à parte: se entrasse aqui também, seria falada duas vezes (e muda uma delas).
1138
- const lista = partes.filter((pt) => pt.tipo !== 'depoimento' && cinema.falaDe(pt) !== '' && !pt.falaForte).map((pt) => ({ texto: cinema.falaDe(pt), registro: 'normal' }));
1212
+ const comVoz = partes.filter((pt) => pt.tipo !== 'depoimento' && cinema.falaDe(pt) !== '' && !pt.falaForte);
1213
+ const lista = comVoz.map((pt) => ({ texto: cinema.falaDe(pt), registro: 'normal' }));
1214
+ // Duas vozes: a voz de cada parte pelo falante dela (roteiro.vozes).
1215
+ const vozesL = falantes.vozesDasPartes(comVoz, roteiro, args.voz || roteiro.voz);
1216
+ const variasVozes = new Set(vozesL).size > 1;
1217
+ const DIRECAO_CINEMA = 'Leia em português do Brasil como narrador de trailer de marca premium: voz grave, confiante e humana, '
1218
+ + 'ritmo firme, pausas curtas entre as frases, sem arrastar.';
1139
1219
  const mp3 = path.join(p.pasta, 'midia', `narracao-${Date.now().toString(36)}.mp3`);
1140
1220
  /* O CACHE DA VOZ: a mesma fala, na mesma voz, não é gerada duas vezes.
1141
1221
  Refazer o roteiro em outro formato, ou mexer numa cena, não gasta a
@@ -1146,7 +1226,20 @@ async function prepararRoteiro(p, args) {
1146
1226
  let v = null;
1147
1227
  // motorVoz "edge": a voz do Edge direto, sem esperar a cota do Gemini ("a narração pode manter a do Edge", 25/09).
1148
1228
  const soEdge = String(roteiro.motorVoz || args.motorVoz || '').toLowerCase() === 'edge';
1149
- if (soEdge) {
1229
+ if (variasVozes) {
1230
+ /* Um grupo por falante, cada um com a sua voz e o seu cache (a chave
1231
+ do narrarComCache leva a voz). Falhou? Fica anotado, e a reserva
1232
+ de baixo narra numa voz só — melhor o vídeo do que nada. */
1233
+ v = await falantes.narrarPorFalante(lista.map((x, i) => ({ ...x, voz: vozesL[i] })), mp3, { juntar: juntarVozes,
1234
+ narrarUm: async (l, m, voz) => {
1235
+ const g = soEdge ? { ok: false } : await narrarComCache(l, m, { voz, direcao: DIRECAO_CINEMA });
1236
+ if (g.ok) return g;
1237
+ const a = m.replace(/\.mp3$/, '.json');
1238
+ fs.writeFileSync(a, JSON.stringify(l));
1239
+ return narrarReserva(l, m, a, voz, 'antonio', { soEdge });
1240
+ } });
1241
+ if (!v.ok) projeto.anotar(p.id, 'narracao', `as duas vozes falharam (${v.error}); saiu numa voz só`);
1242
+ } else if (soEdge) {
1150
1243
  v = { ok: false, error: 'edge pedido' };
1151
1244
  } else if (fs.existsSync(cacheMp3) && fs.existsSync(cacheJson)) {
1152
1245
  fs.copyFileSync(cacheMp3, mp3);
@@ -1165,9 +1258,7 @@ async function prepararRoteiro(p, args) {
1165
1258
  } else if (kokoro.querKokoro(args.voz || roteiro.voz)) {
1166
1259
  v = { ok: false, error: 'kokoro pedido' };
1167
1260
  } else {
1168
- v = await narrarComGemini(lista, mp3, { voz: args.voz || roteiro.voz,
1169
- direcao: 'Leia em português do Brasil como narrador de trailer de marca premium: voz grave, confiante e humana, '
1170
- + 'ritmo firme, pausas curtas entre as frases, sem arrastar.' });
1261
+ v = await narrarComGemini(lista, mp3, { voz: args.voz || roteiro.voz, direcao: DIRECAO_CINEMA });
1171
1262
  if (v.ok) {
1172
1263
  try { fs.mkdirSync(cacheDir, { recursive: true }); fs.copyFileSync(mp3, cacheMp3); fs.writeFileSync(cacheJson, JSON.stringify({ voz: v.voz, segundos: v.segundos, partes: v.partes })); } catch { /* sem cache */ }
1173
1264
  }
@@ -1342,12 +1433,14 @@ async function prepararRoteiro(p, args) {
1342
1433
  // falaForte: true → a voz forte lê o texto da tela.
1343
1434
  const textoForte = pt.falaForte === true ? cinema.falaDe({ ...pt, fala: undefined }) : pt.falaForte;
1344
1435
  const soEdgeF = String(roteiro.motorVoz || args.motorVoz || '').toLowerCase() === 'edge';
1436
+ // A voz forte é a de quem fala a parte (duas vozes), não sempre a do narrador.
1437
+ const vozForte = falantes.vozesDasPartes([pt], roteiro, args.voz || roteiro.voz)[0];
1345
1438
  const vf = soEdgeF ? await (async () => {
1346
1439
  // No Edge, a ênfase é o registro "contraste": a mesma voz, um pouco mais firme.
1347
1440
  const arqJ = arqF.replace(/\.mp3$/, '.json');
1348
1441
  fs.writeFileSync(arqJ, JSON.stringify([{ texto: textoForte, registro: 'contraste' }]));
1349
- return rodarPython('narrar.py', ['roteiro', '--arquivo', arqJ, '--saida', arqF, '--voz', String(args.voz || roteiro.voz || 'antonio')], { timeout: 5 * 60000 });
1350
- })() : await narrarComCache([{ texto: textoForte }], arqF, { voz: args.voz || roteiro.voz,
1442
+ return rodarPython('narrar.py', ['roteiro', '--arquivo', arqJ, '--saida', arqF, '--voz', String(vozForte || 'antonio')], { timeout: 5 * 60000 });
1443
+ })() : await narrarComCache([{ texto: textoForte }], arqF, { voz: vozForte,
1351
1444
  // A MESMA voz do narrador, só com um pouco mais de ênfase: "não é mudar a voz, é só um pouco a entonação" (25/09).
1352
1445
  direcao: pt.entonacao || 'Leia em português do Brasil com a mesma voz e o mesmo ritmo de um narrador de trailer de marca: '
1353
1446
  + 'confiante e natural, só um pouco mais de ênfase nas palavras principais. Sem dramatizar.' });
@@ -1382,8 +1475,7 @@ async function prepararRoteiro(p, args) {
1382
1475
  const mp3 = path.join(p.pasta, 'midia', `narracao-${Date.now().toString(36)}.mp3`);
1383
1476
  // A voz de gente primeiro (Gemini, pelo servidor); o Edge é a reserva.
1384
1477
  // Com cache: refazer o vídeo (outro estilo, outra proporção) não gasta a cota da voz.
1385
- let v = await narrarComCache(lista, mp3, { voz: args.voz || roteiro.voz,
1386
- direcao: estilo === 'gerativo'
1478
+ const direcao = estilo === 'gerativo'
1387
1479
  /* O gerativo pede voz com CORPO ("narração encorpada, extravagante",
1388
1480
  30/09): cheia, quente, perto do microfone — e o ritmo segue 1,12×. */
1389
1481
  /* (A primeira versão pedia "trailer" e "ênfase sustentada": a voz saiu 25% mais lenta, medido em 30/09.) */
@@ -1397,9 +1489,19 @@ async function prepararRoteiro(p, args) {
1397
1489
  + 'voz encorpada, ênfase forte nas palavras de impacto, pausas curtas e secas entre as frases, sorrindo na voz.'
1398
1490
  // "A narração tá lenta, deixa mais viva" (29/09): todo estilo fala com energia de reels.
1399
1491
  : 'Leia em português do Brasil como narrador de reels de tecnologia: vivo, confiante e rápido, sorrindo na voz, '
1400
- + 'ênfase nas palavras que importam, pausas curtas entre as frases. Nada de leitura lenta ou solene.' });
1401
- // O Kokoro pedido já foi tentado no narrarComCache: a reserva não repete.
1402
- if (!v.ok) v = await narrarReserva(lista, mp3, arq, args.voz || roteiro.voz, 'thalita', { semKokoro: kokoro.querKokoro(args.voz || roteiro.voz) });
1492
+ + 'ênfase nas palavras que importam, pausas curtas entre as frases. Nada de leitura lenta ou solene.';
1493
+ /* Uma voz por falante (falantes.js). Com uma voz só, um grupo só: é a
1494
+ narração de sempre, com o mesmo cache. */
1495
+ const vozesL = falantes.vozesDasPartes(partes, roteiro, args.voz || roteiro.voz);
1496
+ let v = await falantes.narrarPorFalante(lista.map((x, i) => ({ ...x, voz: vozesL[i] })), mp3, { juntar: juntarVozes,
1497
+ narrarUm: async (l, m, voz) => {
1498
+ const g = await narrarComCache(l, m, { voz, direcao });
1499
+ if (g.ok) return g;
1500
+ // O Kokoro pedido já foi tentado no narrarComCache: a reserva não repete.
1501
+ const a = m === mp3 ? arq : m.replace(/\.mp3$/, '.json');
1502
+ if (a !== arq) fs.writeFileSync(a, JSON.stringify(l));
1503
+ return narrarReserva(l, m, a, voz, 'thalita', { semKokoro: kokoro.querKokoro(voz) });
1504
+ } });
1403
1505
  if (!v.ok) return v;
1404
1506
  /* O RITMO DA VOZ. Mesmo com a direção, a voz sai no passo de leitura, e
1405
1507
  o vídeo — que segue a voz palavra por palavra — sai lento junto. O
@@ -1528,13 +1630,22 @@ async function prepararRoteiro(p, args) {
1528
1630
  '-af', 'afade=t=in:st=0:d=0.4,loudnorm=I=-18:TP=-2:LRA=11', '-ar', '44100', norm], { timeout: 5 * 60000 });
1529
1631
  if (c.ok && fs.existsSync(norm)) trilha = path.relative(p.pasta, norm).replace(/\\/g, '/');
1530
1632
  }
1531
- const r = gerativo.montarGerativo({ roteiro, largura, altura, tempos, narracao, sons, trilha });
1633
+ /* O herói que o roteiro pediu do Iconify ("mdi:bread-slice"): o desenho é
1634
+ baixado aqui (fica em midia/icones); o que não baixar, a batida fica sem. */
1635
+ const icones = {};
1636
+ for (const pt of partes) {
1637
+ const h = typeof (pt.heroi != null ? pt.heroi : pt.icone) === 'string' ? String(pt.heroi != null ? pt.heroi : pt.icone).trim() : '';
1638
+ if (!/^[a-z0-9-]+:[a-z0-9-]+$/i.test(h) || icones[h]) continue;
1639
+ try { const ic = await assets.icone(h, { pasta: p.pasta }); if (ic && ic.ok) icones[h] = ic.svg; else projeto.anotar(p.id, 'icones', `herói ${h} não baixou: ${(ic && ic.error) || '?'}`); } catch { /* fica sem */ }
1640
+ }
1641
+ const r = gerativo.montarGerativo({ roteiro, largura, altura, tempos, narracao, sons, trilha, icones });
1532
1642
  if (r.ok) {
1533
1643
  const feitos = sonsLeves.prepararReceitas(p.pasta, r.somReceitas);
1534
1644
  // Som que o ffmpeg não conseguiu fazer sai do plano (o vídeo não espera um arquivo que não existe).
1535
1645
  r.plano.audio.efeitos = r.plano.audio.efeitos.filter((e) => Object.values(feitos).includes(e.src));
1536
1646
  }
1537
1647
  if (r.ok) projeto.anotar(p.id, 'plano', `estilo gerativo: ${r.resumo.batidas} batidas (${r.resumo.composicoes.join(' → ')}), ${r.resumo.segundos}s, semente ${r.resumo.semente}`);
1648
+ if (r.ok) conferirAssinatura(p, r, roteiro.assinatura || 'gerativo');
1538
1649
  return r;
1539
1650
  }
1540
1651
  if (estilo === 'fluido' && modo === 'video') {
@@ -1562,6 +1673,9 @@ async function prepararRoteiro(p, args) {
1562
1673
  p.paletaFluido = escolha;
1563
1674
  }
1564
1675
  roteiro.paleta = p.paletaFluido;
1676
+ // O trio de fontes, também por projeto e sem repetir os últimos vídeos (09/10, "vício em fontes").
1677
+ if (!p.fontesFluido) { p.fontesFluido = fluido.escolherFontes(roteiro.fontesFluido); projeto.salvar(p.id, { fontesFluido: p.fontesFluido }); }
1678
+ roteiro.fontesFluido = p.fontesFluido;
1565
1679
  /* As IMAGENS do fluido misto, só com imagem pedida: as partes com
1566
1680
  "busca", no máximo 1 em 3 e espalhadas (fluido.js decide o jeito). */
1567
1681
  const fotosFluido = {};
@@ -1580,6 +1694,7 @@ async function prepararRoteiro(p, args) {
1580
1694
  }
1581
1695
  const r = fluido.montarFluido({ imagens: fotosFluido, roteiro: { ...roteiro, pedido: roteiro.pedido || pedidoTxt }, largura, altura, tempos, narracao, sons, trilha });
1582
1696
  if (r.ok) projeto.anotar(p.id, 'plano', `estilo fluido: ${r.resumo.batidas} batidas${r.resumo.imagens.length ? ` (imagens: ${r.resumo.imagens.join(', ')})` : ''}, paleta ${r.resumo.paleta}, ${r.resumo.segundos}s, batida atrasada no máximo ${r.resumo.atraso}s, palavra atrás da voz no máximo ${r.resumo.sincronia}s`);
1697
+ if (r.ok) conferirAssinatura(p, r, roteiro.assinatura || 'fluido');
1583
1698
  return r;
1584
1699
  }
1585
1700
  if (estilo === 'motion' && modo === 'video') {
@@ -1898,5 +2013,5 @@ async function ferramentaSite(args) {
1898
2013
  module.exports = { variarIcones,
1899
2014
  ferramentaProjeto, ferramentaPlano, ferramentaMidia, ferramentaNarrar,
1900
2015
  ferramentaVideo, ferramentaRender, ferramentaSite,
1901
- projeto, plano, remotion, assets, video, identidade, slides, python, rodarPython, alinharPartes, prepararRoteiro,
2016
+ projeto, plano, remotion, assets, video, identidade, slides, python, rodarPython, alinharPartes, prepararRoteiro, roteiroValido, juntarVozes,
1902
2017
  };
@@ -284,6 +284,17 @@ function geometria(a, b, L, P) {
284
284
  ponto e anda até onde o próximo traço começa) e os estados dela. */
285
285
  function estadosDoAcento(P, L) {
286
286
  const base = Math.min(P.largura, P.altura) / 1080;
287
+ /* O ACENTO EM TRAÇO (09/10, "todo vídeo tem aquela bolinha"): sem a semente
288
+ no meio da tela, sem a viagem em ponto e sem o recolher — cada traço se
289
+ desenha no seu lugar e some com a batida (acentoEm). */
290
+ const semBola = P.fundo && P.fundo.acento === 'traco';
291
+ if (semBola) {
292
+ const out = [];
293
+ P.batidas.forEach((b, bi) => b.acento.forEach((a) => out.push({ t: a.t, dur: a.dur, g: geometria(a, b, L, P), cor: b.cores.acento, batida: bi, forma: a.forma, ate: b.ate })));
294
+ out.sort((a, b) => a.t - b.t);
295
+ out.semBola = true;
296
+ return out;
297
+ }
287
298
  const out = [{ t: 0, dur: 0.35, g: (() => { const r = 16 * base; const pts = []; for (let i = 0; i < N; i++) { const q = (i / (N - 1)) * Math.PI * 2; pts.push([P.largura / 2 + Math.cos(q) * r * 0.35, P.altura / 2 + Math.sin(q) * r * 0.35]); } return { pts, larg: r * 1.35, alfa: 1, frente: true, cx: P.largura / 2, cy: P.altura / 2 }; })(), cor: P.batidas[0].cores.acento, batida: 0 }];
288
299
  P.batidas.forEach((b, bi) => {
289
300
  b.acento.forEach((a, k) => {
@@ -295,12 +306,32 @@ function estadosDoAcento(P, L) {
295
306
  }
296
307
  out.push({ t: a.t, dur: a.dur, g, cor: b.cores.acento, batida: bi, forma: a.forma });
297
308
  });
309
+ /* No fim da batida o acento se RECOLHE num ponto: ele ficava na última
310
+ forma até a batida seguinte, e a moldura sobrava sozinha na tela depois
311
+ que a palavra saía (teste de 08/10). */
312
+ const ult = b.acento[b.acento.length - 1];
313
+ if (ult && b.ate != null) {
314
+ const g = geometria(ult, b, L, P);
315
+ const t0 = Math.max(ult.t + (ult.dur || 0), b.ate - 0.3);
316
+ if (t0 < b.ate) out.push({ t: t0, dur: 0.25, g: geometria({ forma: 'viagem', x: g.cx, y: g.cy, r: 7 * base }, b, L, P), cor: b.cores.acento, batida: bi });
317
+ }
298
318
  });
299
319
  out.sort((a, b) => a.t - b.t);
300
320
  return out;
301
321
  }
302
322
 
303
323
  function acentoEm(t, estados) {
324
+ if (estados.semBola) {
325
+ // O traço se desenha (os pontos aparecem em ordem) e some no fim da batida; antes do primeiro, nada.
326
+ let k = -1;
327
+ while (k < estados.length - 1 && estados[k + 1].t <= t) k++;
328
+ const cur = estados[k];
329
+ if (!cur) return { pts: [[0, 0], [0, 0]], larg: 0.1, alfa: 0, frente: true, cor: '#000000', cx: 0, cy: 0 };
330
+ const p = arrasto(c01((t - cur.t) / Math.max(0.01, cur.dur)));
331
+ const n = Math.max(2, Math.ceil(cur.g.pts.length * p));
332
+ const some = 1 - c01((t - (cur.ate - 0.22)) / 0.22);
333
+ return { pts: cur.g.pts.slice(0, n), larg: cur.g.larg, alfa: cur.g.alfa * some, frente: cur.g.frente, tampa: cur.g.tampa, cor: cur.cor, cx: cur.g.cx, cy: cur.g.cy };
334
+ }
304
335
  let k = 0;
305
336
  while (k < estados.length - 1 && estados[k + 1].t <= t) k++;
306
337
  const cur = estados[k], prev = estados[Math.max(0, k - 1)];
@@ -650,7 +681,7 @@ function Fundo({ P, b, prevB, t, cam, frame, origem }) {
650
681
  return (
651
682
  <div style={{ position: 'absolute', inset: 0, background: corBase, overflow: 'hidden' }}>
652
683
  {circulo}
653
- <div style={{ position: 'absolute', inset: 0, background: 'radial-gradient(ellipse at ' + bx + '% ' + by + '%, ' + alfa(claro ? '#000000' : '#ffffff', 0.05) + ' 0%, transparent 60%)' }} />
684
+ {(f.ambiente || 'bokeh') !== 'limpo' && <div style={{ position: 'absolute', inset: 0, background: 'radial-gradient(ellipse at ' + bx + '% ' + by + '%, ' + alfa(claro ? '#000000' : '#ffffff', 0.05) + ' 0%, transparent 60%)' }} />}
654
685
  {(f.particulas || []).map((q, i) => {
655
686
  const x = ((q.x - cam.x * q.z * 1.6 + t * q.v * (i % 2 ? 1 : -0.6)) % W + W) % W;
656
687
  const y = ((q.y - cam.y * q.z * 1.6 - t * q.v * 0.7) % H + H) % H;
@@ -773,25 +804,39 @@ function Heroi({ b, t, P, slot }) {
773
804
  return <svg width={W} height={H} style={{ position: 'absolute', left: 0, top: 0, overflow: 'visible', opacity: op }}>{els}</svg>;
774
805
  }
775
806
 
776
- /* O HUD do motion designer, em toda batida: cantos de enquadramento, a marca,
777
- a seção, o timecode, os quadros por segundo e a régua do vídeo. */
807
+ /* A MOLDURA (o HUD do motion designer): cantos de enquadramento, o assunto do
808
+ vídeo, a SEÇÃO ("01 · A CONVERSA"), o timecode e a régua do vídeo — fixa do
809
+ começo ao fim, como no reel de referência. Mora dentro da área segura
810
+ (P.hud.area: no 9:16, longe do status e dos botões do celular). 'cantos' é
811
+ a versão mínima: os cantos e a seção. Partitura antiga (sem área): a margem de antes. */
778
812
  const SECOES = { gigante: 'TESE', pilha: 'ARGUMENTO', divisao: 'CONTRASTE', contador: 'NÚMERO', cascata: 'LISTA', parede: 'TEXTURA', citacao: 'CITAÇÃO', marca: 'MARCA', palavras: 'FALA', produto: 'PRODUTO' };
779
813
  function HudG({ P, b, bi, t, fps }) {
780
- const W = P.largura, H = P.altura, m = Math.round(Math.min(W, H) * 0.035), tam = Math.max(11, Math.round(Math.min(W, H) * 0.0125));
814
+ const W = P.largura, H = P.altura, h = P.hud, deitado = W >= H;
815
+ const m0 = Math.round(Math.min(W, H) * 0.035);
816
+ const A = h.area || { x: m0, topo: m0, base: m0 };
817
+ const tam = Math.max(11, Math.round(Math.min(W, H) * (deitado ? 0.0125 : 0.018)));
818
+ const c = Math.round(Math.min(W, H) * 0.026);
781
819
  const cor = alfa(b.cores.texto, 0.72);
782
820
  const f = Math.round(t * fps);
783
821
  const tc = [Math.floor(t / 60), Math.floor(t) % 60, f % fps].map((n) => String(n).padStart(2, '0')).join(':');
784
- const rot = { position: 'absolute', fontFamily: familia(P.fontes.mono), fontSize: tam, letterSpacing: '0.14em', color: cor, whiteSpace: 'nowrap', textTransform: 'uppercase' };
785
- const canto = (x, y, sx, sy) => <div key={x + '-' + y} style={{ position: 'absolute', left: x - (sx < 0 ? m * 0.7 : 0), top: y - (sy < 0 ? m * 0.7 : 0), width: m * 0.7, height: m * 0.7,
822
+ const rot = { position: 'absolute', fontFamily: familia(P.fontes.mono), fontSize: tam, letterSpacing: '0.14em', color: cor, whiteSpace: 'nowrap', textTransform: 'uppercase', lineHeight: 1 };
823
+ const L = A.x, T = A.topo, R = W - A.x, B = H - A.base;
824
+ const canto = (x, y, sx, sy) => <div key={x + '-' + y} style={{ position: 'absolute', left: sx > 0 ? x : x - c, top: sy > 0 ? y : y - c, width: c, height: c,
786
825
  borderLeft: sx > 0 ? '1.5px solid ' + cor : undefined, borderRight: sx < 0 ? '1.5px solid ' + cor : undefined, borderTop: sy > 0 ? '1.5px solid ' + cor : undefined, borderBottom: sy < 0 ? '1.5px solid ' + cor : undefined }} />;
826
+ // A seção: o número e o nome dela (a partitura antiga não tem seção — vale a composição).
827
+ const k = b.secao != null ? b.secao : bi;
828
+ const nome = (h.secoes || [])[k] || SECOES[b.comp] || String(b.comp).toUpperCase();
829
+ const secao = <div style={{ ...rot, right: W - R + c * 0.6, top: T + c * 0.6 }}>{String(k + 1).padStart(2, '0') + ' · ' + nome}</div>;
830
+ if (h.tipo === 'cantos') return <div style={{ position: 'absolute', inset: 0, pointerEvents: 'none' }}>{canto(L, T, 1, 1)}{canto(R, T, -1, 1)}{canto(L, B, 1, -1)}{canto(R, B, -1, -1)}{secao}</div>;
831
+ const n = Math.max(1, (h.secoes || []).length);
787
832
  return (
788
833
  <div style={{ position: 'absolute', inset: 0, pointerEvents: 'none' }}>
789
- {canto(m, m, 1, 1)}{canto(W - m, m, -1, 1)}{canto(m, H - m, 1, -1)}{canto(W - m, H - m, -1, -1)}
790
- <div style={{ ...rot, left: m * 1.5, top: m * 1.1 }}><b>{P.hud.marca}</b><span style={{ opacity: 0.6 }}>{' · MOTION · ' + P.hud.ano}</span></div>
791
- <div style={{ ...rot, right: m * 1.5, top: m * 1.1 }}>{String(bi + 1).padStart(2, '0') + ' — ' + (SECOES[b.comp] || b.comp.toUpperCase())}</div>
792
- <div style={{ ...rot, left: m * 1.5, bottom: m * 1.1 }}>{tc}<span style={{ opacity: 0.55 }}>{' ' + fps + ' FPS'}</span></div>
793
- <div style={{ ...rot, right: m * 1.5, bottom: m * 1.1 }}>{W + '×' + H}</div>
794
- <div style={{ position: 'absolute', left: m * 1.5, bottom: m * 0.62, height: 1.5, width: (W - m * 3) * c01(t / P.duracao), background: cor }} />
834
+ {canto(L, T, 1, 1)}{canto(R, T, -1, 1)}{canto(L, B, 1, -1)}{canto(R, B, -1, -1)}
835
+ <div style={{ ...rot, left: L + c * 0.6, top: T + c * 0.6 }}><b>{h.marca}</b>{deitado && <span style={{ opacity: 0.6 }}>{' · ' + h.ano}</span>}</div>
836
+ {secao}
837
+ <div style={{ ...rot, left: L + c * 0.6, bottom: H - B + c * 0.6 }}>{tc}<span style={{ opacity: 0.55 }}>{' ' + fps + ' FPS'}</span></div>
838
+ <div style={{ ...rot, right: W - R + c * 0.6, bottom: H - B + c * 0.6 }}>{(h.secoes ? String(k + 1).padStart(2, '0') + ' / ' + String(n).padStart(2, '0') : W + '×' + H)}</div>
839
+ <div style={{ position: 'absolute', left: L + c * 0.6, top: B - c * 0.35, height: 1.5, width: (R - L - c * 1.2) * c01(t / P.duracao), background: cor }} />
795
840
  </div>
796
841
  );
797
842
  }
@@ -806,6 +851,9 @@ export function Gerativo({ el, t, fps }) {
806
851
  if (!L) return <div style={{ position: 'absolute', inset: 0, background: P.paleta.fundo }} />;
807
852
  const frame = Math.round(t * fps);
808
853
  const bs = P.batidas;
854
+ /* O AMBIENTE (09/10): o bokeh e o feixe de luz só quando a identidade pede
855
+ (P.fundo.ambiente). Partitura antiga, sem o campo: como antes (ambiente || 'bokeh'). */
856
+ const ambiente = P.fundo.ambiente || 'bokeh', comLuz = ambiente !== 'limpo';
809
857
  let bi = 0;
810
858
  while (bi < bs.length - 1 && t >= bs[bi + 1].de) bi++;
811
859
  const b = bs[bi];
@@ -914,18 +962,39 @@ export function Gerativo({ el, t, fps }) {
914
962
  if (W - M - bl.x1 >= R * 2.3) slot = { cx: bl.x1 + (W - M - bl.x1) / 2, cy: (bl.y + bl.y1) / 2, R };
915
963
  else if (bl.y - M * 1.4 >= R * 2.2) slot = { cx: W / 2, cy: M * 1.4 + (bl.y - M * 1.4) / 2, R };
916
964
  else if (H - M * 1.4 - bl.y1 >= R * 2.2) slot = { cx: W / 2, cy: bl.y1 + (H - M * 1.4 - bl.y1) / 2, R };
917
- else return <Heroi b={{ ...x, heroi: { ...x.heroi, tipo: 'aneis' } }} t={t} P={P} slot={{ cx: (bl.x + bl.x1) / 2, cy: (bl.y + bl.y1) / 2, R: Math.min(W, H) * 0.3 }} />;
965
+ // Sem vazio, não entra (09/10): o anel no meio do texto era enfeite que ninguém pediu.
966
+ else return null;
918
967
  return <Heroi b={x} t={t} P={P} slot={slot} />;
919
968
  })()}
920
969
  <Textura b={x} cx={lay.caixas[x.linhas[0].id]} t={t} P={P} periodo={lay.textura} />
921
970
  {x.icone3d && (() => {
922
- /* O ícone 3D mora no vazio do bloco: à direita (deitado) ou acima
923
- (em pé); sem espaço, não entra — nunca por cima do texto. */
971
+ /* O ícone 3D mora no vazio do bloco, no lugar que o projeto escolheu
972
+ (ic.lados, gerativo.js: direita, esquerda, acima, abaixo ou canto);
973
+ sem espaço lá, tenta o próximo; sem nenhum, não entra — nunca
974
+ por cima do texto. Partitura antiga (sem lados): direita ou acima. */
924
975
  const ic = x.icone3d, tam = ic.tamanho, M = P.margem, bl = lay.bloco;
925
- let px = null, py = null;
926
- if (ic.lado === 'direita' && W - M - bl.x1 >= tam * 0.9) { px = Math.min(W - M - tam, bl.x1 + (W - M - bl.x1 - tam) / 2); py = (bl.y + bl.y1) / 2 - tam / 2; }
927
- else if (bl.y - M * 1.2 >= tam * 0.85) { px = x.bloco.alinha === 'centro' ? (bl.x + bl.x1) / 2 - tam / 2 : bl.x - tam * 0.08; py = Math.max(M, bl.y - tam - 0.02 * H); }
928
- if (px == null) return null;
976
+ const xTopo = x.bloco.alinha === 'centro' ? (bl.x + bl.x1) / 2 - tam / 2 : bl.x - tam * 0.08;
977
+ const fora = (qx, qy) => qx + tam <= bl.x - 8 || qx >= bl.x1 + 8 || qy + tam <= bl.y - 8 || qy >= bl.y1 + 8;
978
+ const LUGAR = {
979
+ 'direita': () => (W - M - bl.x1 >= tam * 0.9 ? [Math.min(W - M - tam, bl.x1 + (W - M - bl.x1 - tam) / 2), (bl.y + bl.y1) / 2 - tam / 2] : null),
980
+ 'esquerda': () => (bl.x - M >= tam * 0.9 ? [Math.max(M * 0.6, M + (bl.x - M - tam) / 2), (bl.y + bl.y1) / 2 - tam / 2] : null),
981
+ 'acima': () => (bl.y - M * 1.2 >= tam * 0.85 ? [xTopo, Math.max(M, bl.y - tam - 0.02 * H)] : null),
982
+ 'abaixo': () => (H - M * 1.2 - bl.y1 >= tam * 0.85 ? [xTopo, Math.min(H - M - tam, bl.y1 + 0.02 * H)] : null),
983
+ // O canto livre mais longe do texto: o ícone pequeno, de assinatura.
984
+ 'canto': () => [[W - M - tam, M], [W - M - tam, H - M - tam], [M, M], [M, H - M - tam]].find(([qx, qy]) => fora(qx, qy)) || null,
985
+ };
986
+ const ordem = ic.lados || [ic.lado, 'acima'];
987
+ const achou = ordem.map((l) => LUGAR[l] && LUGAR[l]()).find(Boolean);
988
+ if (!achou) return null;
989
+ const [px, py] = achou;
990
+ /* O ÍCONE PLANO (09/10): o do Iconify que o roteiro pediu (o pão, o QR
991
+ code), na cor do acento da identidade — entra em mola e flutua de leve. */
992
+ if (ic.svg) {
993
+ const k = MOLA(t - ic.t), fl = Math.sin((t - ic.t) * 1.3) * 0.012 * tam, sai = c01((t - (x.ate - 0.25)) / 0.25);
994
+ return <div style={{ position: 'absolute', left: Math.round(px), top: Math.round(py + fl), width: tam, height: tam, color: ic.cor, opacity: c01(k * 1.4) * (1 - sai),
995
+ transform: 'translateZ(120px) scale(' + (0.6 + 0.4 * k).toFixed(4) + ') rotate(' + ((1 - k) * -8).toFixed(2) + 'deg)', transformOrigin: '50% 60%' }}
996
+ dangerouslySetInnerHTML={{ __html: ic.svg }} />;
997
+ }
929
998
  return <div style={{ position: 'absolute', inset: 0, transform: 'translateZ(150px)' }}><Icone3D el={{ ...ic, x: Math.round(px), y: Math.round(py) }} t={t} /></div>;
930
999
  })()}
931
1000
  {x.linhas.map((l, li) => {
@@ -961,7 +1030,7 @@ export function Gerativo({ el, t, fps }) {
961
1030
  </div>
962
1031
  {/* A FRENTE: manchas de luz que passam perto da lente (bokeh) e o feixe
963
1032
  de luz que varre o quadro quando uma batida chega. */}
964
- {[0, 1, 2, 3].map((k) => {
1033
+ {ambiente === 'bokeh' && [0, 1, 2, 3].map((k) => {
965
1034
  const per = 5 + acaso(P.semente % 997 + k * 7) * 4, fase = acaso(P.semente % 991 + k * 13) * per;
966
1035
  const u = ((t + fase) % per) / per;
967
1036
  const tam = Math.min(W, H) * (0.28 + acaso(k * 17 + 3) * 0.3);
@@ -970,7 +1039,7 @@ export function Gerativo({ el, t, fps }) {
970
1039
  return <div key={'bk' + k} style={{ position: 'absolute', left: x - tam / 2, top: y - tam / 2, width: tam, height: tam, borderRadius: '50%', pointerEvents: 'none',
971
1040
  background: 'radial-gradient(circle, ' + alfa(cor, k % 2 ? 0.16 : 0.08) + ' 0%, transparent 66%)', opacity: Math.sin(Math.PI * u) }} />;
972
1041
  })}
973
- {(() => {
1042
+ {comLuz && (() => {
974
1043
  const k = c01((t - b.de) / 0.95);
975
1044
  if (k <= 0 || k >= 1) return null;
976
1045
  const x = lerp(-0.7 * W, 1.2 * W, ed(k));
@@ -735,7 +735,64 @@ export function Site({ el, t, fps }) {
735
735
  );
736
736
  }
737
737
 
738
+ // ── o print do site de verdade, em 3D ───────────────────────────────────
739
+ /* A peça `site` acima DESENHA um site genérico; esta mostra o print real
740
+ (print-site.js), numa janela de navegador ou num celular, inclinados em
741
+ perspectiva, com espessura, reflexo e a página rolando devagar.
742
+ "proporcao" é altura/largura do print: é ela que diz quanto há para rolar.
743
+ Só CSS 3D — o three.js entraria aqui só para o mesmo plano com textura. */
744
+ export function PrintSite({ el, t, fps }) {
745
+ const W = el.largura || 1280, H = el.altura || (el.celular ? 900 : 820);
746
+ const t0 = el.t || 0;
747
+ const cel = !!el.celular;
748
+ const m = mola(t, t0, fps, { damping: 30, stiffness: 90, mass: 1 });
749
+ const inc = el.inclinacao ?? (cel ? 14 : 18); // graus de giro em Y no repouso
750
+ const deriva = Math.sin((t - t0) * 0.6) * 3; // a câmera que respira, sem parar
751
+ const rotY = lerp(-48, -inc, m) + deriva;
752
+ const rotX = lerp(22, cel ? 6 : 9, m);
753
+ const z = lerp(-700, 0, m);
754
+ const barra = cel ? 0 : Math.round(H * 0.06); // a barra do navegador
755
+ const borda = cel ? Math.round(W * 0.045) : 0; // a moldura do celular
756
+ const telaW = W - borda * 2, telaH = H - barra - borda * 2;
757
+ // A página rola de cima até o fim, com respiro parado nas duas pontas.
758
+ const total = Math.max(0, telaW * (el.proporcao || 0) - telaH);
759
+ const dur = el.duracao || 6;
760
+ const k = el.rolar === false ? 0 : CURVAS_KIT.camera(entre(t, t0 + 1.0, t0 + dur - 0.6));
761
+ const brilho = 30 + 50 * entre(t, t0, t0 + dur);
762
+ const raio = cel ? Math.round(W * 0.14) : Math.round(H * 0.018);
763
+ return (
764
+ <div style={{ position: 'absolute', left: el.x || 0, top: el.y || 0, width: W, height: H, perspective: 2400, opacity: t >= t0 ? Math.min(1, m * 2) : 0 }}>
765
+ <div style={{ position: 'absolute', inset: 0, transformStyle: 'preserve-3d',
766
+ transform: 'translateZ(' + z + 'px) rotateX(' + rotX + 'deg) rotateY(' + rotY + 'deg)' }}>
767
+ {/* A espessura: duas placas atrás, mais escuras — o aparelho tem corpo. */}
768
+ {[18, 9].map((d) => (
769
+ <div key={d} style={{ position: 'absolute', inset: 0, borderRadius: raio, background: d > 10 ? '#0b0c10' : '#1d1f26', transform: 'translateZ(' + -d + 'px)' }} />
770
+ ))}
771
+ <div style={{ position: 'absolute', inset: 0, borderRadius: raio, overflow: 'hidden', background: cel ? '#0e0f13' : '#f4f4f6',
772
+ boxShadow: '0 80px 160px -40px rgba(0,0,0,0.55), 0 30px 60px -30px rgba(0,0,0,0.4)' }}>
773
+ {cel ? null : (
774
+ <div style={{ height: barra, display: 'flex', alignItems: 'center', gap: barra * 0.18, padding: '0 ' + barra * 0.4 + 'px', background: '#e9e9ee' }}>
775
+ {['#ff5f57', '#febc2e', '#28c840'].map((c) => <div key={c} style={{ width: barra * 0.24, height: barra * 0.24, borderRadius: 99, background: c }} />)}
776
+ <div style={{ marginLeft: barra * 0.4, flex: 1, maxWidth: W * 0.5, height: barra * 0.5, borderRadius: 99, background: '#fff', display: 'flex', alignItems: 'center',
777
+ padding: '0 ' + barra * 0.35 + 'px', fontFamily: MONO(), fontSize: barra * 0.26, color: '#55585f', whiteSpace: 'nowrap', overflow: 'hidden' }}>
778
+ {String(el.url || '').replace(/^https?:\/\//, '').replace(/\/$/, '')}
779
+ </div>
780
+ </div>
781
+ )}
782
+ <div style={{ position: 'absolute', left: borda, top: barra + borda, width: telaW, height: telaH, overflow: 'hidden', borderRadius: cel ? raio * 0.75 : 0, background: '#fff' }}>
783
+ <Img src={staticFile(el.src)} style={{ position: 'absolute', left: 0, top: 0, width: telaW, transform: 'translateY(' + (-total * k).toFixed(1) + 'px)' }} />
784
+ </div>
785
+ {cel ? <div style={{ position: 'absolute', left: '50%', top: borda * 1.3, width: W * 0.28, height: borda * 1.1, marginLeft: -W * 0.14, borderRadius: 99, background: '#0e0f13' }} /> : null}
786
+ {/* O reflexo que atravessa o vidro enquanto o aparelho gira. */}
787
+ <div style={{ position: 'absolute', inset: 0, pointerEvents: 'none',
788
+ background: 'linear-gradient(115deg, rgba(255,255,255,0) ' + (brilho - 18) + '%, rgba(255,255,255,0.16) ' + brilho + '%, rgba(255,255,255,0) ' + (brilho + 18) + '%)' }} />
789
+ </div>
790
+ </div>
791
+ </div>
792
+ );
793
+ }
794
+
738
795
  export const MARCA = {
739
796
  orbe: Orbe, vetor: Vetor, manchete: Manchete, cromo: Cromo, cores: Cores, padrao: Padrao, particulas: Particulas,
740
- estrela: Estrela, cartoes: Cartoes, painel: Painel, esfera: Esfera, grade: Grade, site: Site,
797
+ estrela: Estrela, cartoes: Cartoes, painel: Painel, esfera: Esfera, grade: Grade, site: Site, printsite: PrintSite,
741
798
  };
@@ -278,8 +278,11 @@ const FORMAS = {
278
278
  };
279
279
 
280
280
  function Mat({ tipo, cor }) {
281
- // Metal escovado, bala (verniz) ou argila — os três refletem a sala (Ambiente).
281
+ // Metal escovado, bala (verniz), pérola, acetinado ou argila — todos refletem a sala (Ambiente).
282
282
  if (tipo === 'metal') return <meshPhysicalMaterial color={cor} roughness={0.32} metalness={0.85} clearcoat={0.4} envMapIntensity={1.2} />;
283
+ // Pérola (iridescente) e acetinado (meio metal, meio fosco): o cardápio de 09/10 (gerativo.MATERIAIS_ICONE).
284
+ if (tipo === 'perola') return <meshPhysicalMaterial color={cor} roughness={0.25} metalness={0.1} clearcoat={1} clearcoatRoughness={0.1} iridescence={1} iridescenceIOR={1.3} />;
285
+ if (tipo === 'acetinado') return <meshPhysicalMaterial color={cor} roughness={0.5} metalness={0.45} clearcoat={0.4} />;
283
286
  if (tipo === 'verniz') return <meshPhysicalMaterial color={cor} roughness={0.18} metalness={0} clearcoat={1} clearcoatRoughness={0.08} sheen={0.3} />;
284
287
  return <meshPhysicalMaterial color={cor} roughness={0.72} metalness={0} clearcoat={0.15} />; // argila: fosca, macia
285
288
  }
@@ -49,6 +49,8 @@ const PECAS = {
49
49
  esfera: {},
50
50
  grade: {},
51
51
  site: {},
52
+ // O print REAL do site (print-site.js) numa janela ou num celular em 3D, rolando.
53
+ printsite: { precisa: ['src'] },
52
54
  // O produto em 3D (kit/tres.jsx): fone, celular, lata ou caixa, de geometria.
53
55
  produto3d: { precisa: ['modelo'] },
54
56
  // Os ícones 3D de geometria (kit/tres.jsx): raio, coração, bateria, relógio, fone…
@@ -54,7 +54,9 @@ const kit = require('./kit.js');
54
54
  const TIPOS = new Set(['texto', 'forma', 'imagem', 'video', 'icone', 'legenda', 'componente', 'grupo', 'kit']);
55
55
  const FORMAS = new Set(['retangulo', 'circulo', 'linha', 'poligono', 'traco']);
56
56
  const AJUSTES = new Set(['cover', 'contain', 'preencher']);
57
- const TRANSICOES = new Set(['corte', 'esmaecer', 'deslizar', 'zoom', 'giro', 'mascara', 'morph', 'chicote', 'soco', 'flash', 'liquido']);
57
+ const TRANSICOES = new Set(['corte', 'esmaecer', 'deslizar', 'zoom', 'giro', 'mascara', 'morph', 'chicote', 'soco', 'flash', 'liquido',
58
+ // as 3D, que mexem nas duas cenas (remotion.js, estilo3d)
59
+ 'virar', 'cubo', 'profundidade', 'inclinar']);
58
60
 
59
61
  /* As propriedades que a animação por quadro-chave alcança. Cada uma é um
60
62
  número no tempo; a curva entre dois quadros é a suavização declarada.
@@ -248,6 +250,8 @@ function conferirElemento(caminho, el, pasta, problemas, duracao) {
248
250
  for (const oque of kit.conferirPeca(el)) problemas.push(erro(`${caminho}.peca`, oque));
249
251
  // A logo de uma peça é arquivo da pasta, como a imagem.
250
252
  if (el.logo) conferirArquivo(`${caminho}.logo`, el.logo, pasta, problemas);
253
+ // E o print do site (printsite) também: sem o arquivo, a janela 3D sai vazia.
254
+ if (el.src) conferirArquivo(`${caminho}.src`, el.src, pasta, problemas);
251
255
  break;
252
256
  }
253
257
  }