primocode 10.10.0 → 10.11.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.
@@ -70,10 +70,13 @@ const glifos = require('./glifos.js');
70
70
  const personagem = require('./personagem.js');
71
71
  const mundo = require('./mundo.js');
72
72
  const cenarios = require('./cenarios.js');
73
+ const estilos = require('./estilos.js');
74
+ const { conferirCena } = require('./cena.js');
73
75
  const movimentos = require('./movimentos.js');
74
76
  const colecao = require('./colecao.js');
75
77
  const { escolherEstilo, LEVES } = require('./estilo.js');
76
78
  const sonsLeves = require('./sons-leves.js');
79
+ const semNarracao = require('./sem-narracao.js');
77
80
  const ende = require('./ende.js');
78
81
  const producao = require('./producao.js');
79
82
 
@@ -403,6 +406,13 @@ async function ferramentaPlano(args) {
403
406
  try { original = JSON.parse(typeof args.roteiro === 'string' ? args.roteiro : JSON.stringify(args.roteiro)); } catch { original = args.roteiro; }
404
407
  const r = await prepararRoteiro(p, args);
405
408
  if (!r.ok) return r;
409
+ /* SEM NARRAÇÃO: o som sobe de papel — um efeito leve em cada corte e em
410
+ cada entrada marcante, variando (sem-narracao.js, sons-leves.js). */
411
+ if (args.__semNarracao) {
412
+ const n = semNarracao.sonorizarPlano(r, { tempos: args.__semNarracao.tempos, sons: sonsLeves.preparar(p.pasta) });
413
+ if (r.resumo) r.resumo.semNarracao = true;
414
+ projeto.anotar(p.id, 'plano', `sem narração: ${n} efeitos leves nos cortes e entradas`);
415
+ }
406
416
  const ic = await variarIcones(r.plano, original);
407
417
  if (ic.trocados) projeto.anotar(p.id, 'icones', `coleção ${ic.colecao}: ${ic.trocados} ícones`);
408
418
  let conferida = plano.conferir(r.plano, { pasta: p.pasta });
@@ -428,9 +438,13 @@ async function ferramentaPlano(args) {
428
438
  const usada = diretor.registrarEstrutura(original || args.roteiro);
429
439
  projeto.salvar(args.id, { estrutura: usada.estrutura || '-' });
430
440
  }
441
+ const quebradas = args.__cenasQuebradas || [];
431
442
  return {
432
443
  ok: true, duracao: conferida.duracao, ...r.resumo, resumo: plano.resumo(r.plano),
433
- proximo: 'estudio_render com este id. Para ajustar, mande o roteiro de novo (é rápido) — não edite as cenas à mão.',
444
+ ...(quebradas.length ? { cenasQuebradas: quebradas } : {}),
445
+ proximo: quebradas.length
446
+ ? `${quebradas.length} cena(s) sua(s) não compilaram e ficaram no gerativo: conserte o arquivo (o erro está em cenasQuebradas) e mande o roteiro de novo antes do render.`
447
+ : 'estudio_render com este id. Para ajustar, mande o roteiro de novo (é rápido) — não edite as cenas à mão.',
434
448
  };
435
449
  }
436
450
 
@@ -530,7 +544,9 @@ async function ferramentaMidia(args) {
530
544
  }
531
545
 
532
546
  // O catálogo de efeitos que a IA escreveu (efeitos.js): guardar, listar, reusar.
533
- if (acao === 'efeitos') return { ok: true, efeitos: efeitos.cardapio() };
547
+ if (acao === 'efeitos') return { ok: true, efeitos: efeitos.cardapio(),
548
+ // A lista de técnicas mora aqui, e não na skill: a skill vai em todo pedido de vídeo e o teto é curto.
549
+ tecnicas: 'cor (ondas que inundam, paleta que vira na batida) · luz (faixa no título, bloom que respira, feixes, partículas) · física (antecipa, cai com peso, amassa no pouso, rebote; spring com massa, nunca linear) · 3D (perspective + rotateX: entra deitado e levanta, sombra no chão, câmera que orbita) · tipografia (letras que se montam, peso que pulsa, máscara que revela). Vídeo sobre site: tire a peça que conta a história com estudio_site elemento (PNG sem fundo) e anime ELA — peca-impacto-3d é ponto de partida, não molde.' };
534
550
  if (acao === 'guardar-efeito' || acao === 'usar-efeito') {
535
551
  if (!p) return { ok: false, error: 'diga em qual projeto (id).' };
536
552
  const r = acao === 'guardar-efeito'
@@ -575,6 +591,10 @@ async function ferramentaMidia(args) {
575
591
  if (acao === 'salvar_cenario' || acao === 'salvar_cenário') {
576
592
  return cenarios.salvar({ nome: args.nome, cenario: args.cenario, autorizado: args.autorizado, bases: Object.keys(mundo.LUGARES) });
577
593
  }
594
+ /* OS ESTILOS (estilos.js, 14/10): as peças do gerativo como cardápio — a IA
595
+ monta, mistura e cria; guardar só com o sim da pessoa. */
596
+ if (acao === 'estilos' || acao === 'estilo') return estilos.cardapio();
597
+ if (acao === 'salvar_estilo') return estilos.salvar({ nome: args.nome, pecas: args.pecas || args.estilo, descricao: args.descricao, autorizado: args.autorizado });
578
598
  if (acao === 'biblioteca' || acao === 'cardapio' || acao === 'cardápio') return colecao.cardapio();
579
599
  if (acao === 'icones3d' || acao === 'icone3d' || acao === 'animacoes' || acao === 'animacao' || acao === 'animação' || acao === 'animações') {
580
600
  const ehAnim = /^anima/.test(acao);
@@ -1263,6 +1283,15 @@ async function sonsReais(p, video) {
1263
1283
  }
1264
1284
 
1265
1285
  const PROPORCOES = { '16:9': [1920, 1080], '9:16': [1080, 1920], '1:1': [1080, 1080], '4:5': [1080, 1350] };
1286
+ /* A proporção que o PEDIDO disse (14/10: "vertical 9:16" saiu deitado, porque
1287
+ só valia a proporção que o modelo passasse). A do modelo ainda manda. */
1288
+ function proporcaoDoPedido(pedido) {
1289
+ const t = String(pedido || '');
1290
+ if (/\b9\s*[:x×]\s*16\b|\bvertical\b|\breels?\b|\bstor(y|ies)\b|\btik\s?tok\b|\bshorts\b/i.test(t)) return '9:16';
1291
+ if (/\b4\s*[:x×]\s*5\b/.test(t)) return '4:5';
1292
+ if (/\b1\s*[:x×]\s*1\b|\bquadrad[oa]\b/i.test(t)) return '1:1';
1293
+ return null;
1294
+ }
1266
1295
 
1267
1296
  /** O roteiro vira plano: baixa o que falta, narra, e chama o compositor. */
1268
1297
  /* A ASSINATURA (assinatura.js): o vídeo é medido e conferido contra as
@@ -1301,7 +1330,8 @@ function importarReferencia(p, arquivo) {
1301
1330
 
1302
1331
  async function prepararRoteiro(p, args) {
1303
1332
  const roteiro = typeof args.roteiro === 'string' ? JSON.parse(args.roteiro) : args.roteiro;
1304
- if (Array.isArray(roteiro)) return prepararRoteiro(p, { ...args, roteiro: { partes: roteiro } });
1333
+ // O mesmo args (não uma cópia): o __semNarracao e o __cenasQuebradas voltam para quem chamou.
1334
+ if (Array.isArray(roteiro)) { args.roteiro = { partes: roteiro }; return prepararRoteiro(p, args); }
1305
1335
  /* O que o modelo manda e o disco não tem vira problema de render: um
1306
1336
  "src" que é endereço da internet vira busca, e um arquivo que não
1307
1337
  existe na pasta sai. A logo da marca vale só se estiver lá. */
@@ -1326,6 +1356,13 @@ async function prepararRoteiro(p, args) {
1326
1356
  fs.copyFileSync(achado, destino);
1327
1357
  pt.componente = path.relative(p.pasta, destino).replace(/\\/g, '/');
1328
1358
  } else pt.componente = path.relative(p.pasta, achado).replace(/\\/g, '/');
1359
+ // A cena quebrada não derruba o vídeo: sai, a parte fica no gerativo, e o erro volta para a IA (cena.js).
1360
+ const conf = conferirCena(path.join(p.pasta, pt.componente));
1361
+ if (!conf.ok) {
1362
+ (args.__cenasQuebradas = args.__cenasQuebradas || []).push({ componente: pt.componente, erro: conf.erro });
1363
+ projeto.anotar(p.id, 'plano', `componente ${pt.componente} quebrado (${conf.erro}): a parte ficou no gerativo`);
1364
+ delete pt.componente;
1365
+ }
1329
1366
  }
1330
1367
  // As imagens do pedido (o terminal guardou em referencias.imagens) entram sozinhas, uma vez por projeto.
1331
1368
  const jaTem = new Set((p.referencias || []).map((x) => x.de));
@@ -1354,14 +1391,10 @@ async function prepararRoteiro(p, args) {
1354
1391
  const semImagem = referencias.semImagem(ref);
1355
1392
  let estilo = escolherEstilo({ pedido: pedidoTxt, agente: args.estilo, roteiro: roteiro.estilo, girar: true, semImagem });
1356
1393
  if (semImagem && (estilo === 'hibrido' || estilo === 'cinema')) estilo = 'gerativo';
1357
- /* AS FONTES DA CASA (13/10, o primocode-ele-constroi.mp4 que o Joel aprovou):
1358
- no gerativo, título em Inter e texto em JetBrains Mono, com as CORES da
1359
- marca. Só muda se o pedido falar de fonte. */
1360
- if (estilo === 'gerativo' && !/\bfontes?\b|tipografia|\bserifa/i.test(pedidoTxt)) {
1361
- roteiro.marca = { ...(roteiro.marca || {}), fontes: { titulo: 'Inter', texto: 'JetBrains Mono' } };
1362
- }
1363
- // O estilo da casa no gerativo: chão escuro com brilho, frase média, palavra que entra desfocando (gerativo.js).
1364
- if (estilo === 'gerativo') roteiro.casa = true;
1394
+ /* SEM NARRAÇÃO (14/10, "só o motion e os efeitos sonoros, os barulhinhos"):
1395
+ roteiro.semNarracao ou o pedido. O cinema e o híbrido vivem da voz: caem no gerativo. */
1396
+ const semVoz = semNarracao.ativo(roteiro, pedidoTxt);
1397
+ if (semVoz && (estilo === 'hibrido' || estilo === 'cinema')) estilo = 'gerativo';
1365
1398
  /* "O vídeo deve ter um 3D do fone" (30/09): pedido de 3D vira o gerativo
1366
1399
  com o produto modelado girando numa batida (kit/tres.jsx) — a menos que
1367
1400
  o agente tenha escolhido outro estilo pelo nome. */
@@ -1393,10 +1426,27 @@ async function prepararRoteiro(p, args) {
1393
1426
  vídeos (09/10, "vício em cores, músicas, fontes"): por PROJETO, como a semente. */
1394
1427
  // O assunto do vídeo é o rótulo da moldura (quando ela sai): o título do projeto.
1395
1428
  if (estilo === 'gerativo' && !roteiro.titulo && p.titulo) roteiro.titulo = p.titulo;
1429
+ // O estilo que a IA montou (estilos.js) vence o sorteio e o que o projeto tinha guardado.
1430
+ if (estilo === 'gerativo' && roteiro.estiloVisual && !roteiro.escolhasGerativo) {
1431
+ const r = estilos.montar(roteiro.estiloVisual, roteiro);
1432
+ roteiro.escolhasGerativo = r.escolhas;
1433
+ projeto.salvar(p.id, { escolhasGerativo: r.escolhas });
1434
+ if (r.avisos.length) projeto.anotar(p.id, 'estilo', r.avisos.join(' · '));
1435
+ }
1396
1436
  if (estilo === 'gerativo' && !roteiro.escolhasGerativo) {
1397
1437
  roteiro.escolhasGerativo = p.escolhasGerativo || gerativo.escolherVisual(roteiro);
1398
1438
  if (!p.escolhasGerativo) projeto.salvar(p.id, { escolhasGerativo: roteiro.escolhasGerativo });
1399
1439
  }
1440
+ /* O ESTILO DA CASA (13/10, o primocode-ele-constroi.mp4 que o Joel aprovou):
1441
+ chão escuro com brilho, frase média, palavra que entra desfocando, Inter
1442
+ e JetBrains Mono. Vale para o vídeo de TECNOLOGIA (o do PrimoCode) quando
1443
+ a IA não montou estilo: em todo vídeo, a padaria saía igual — "animações
1444
+ tudo igual, cores sempre as mesmas, sem ícones" (14/10). */
1445
+ const casa = estilo === 'gerativo' && !roteiro.estiloVisual && (roteiro.escolhasGerativo || {}).humor === 'tecnologia';
1446
+ if (casa && !/\bfontes?\b|tipografia|\bserifa/i.test(pedidoTxt)) {
1447
+ roteiro.marca = { ...(roteiro.marca || {}), fontes: { titulo: 'Inter', texto: 'JetBrains Mono' } };
1448
+ }
1449
+ if (casa) roteiro.casa = true;
1400
1450
  if (ref.modo === 'fechado' && !roteiro.busca && referencias.termos().length) roteiro.busca = referencias.termos();
1401
1451
  /* A BIBLIOTECA (biblioteca.js): sem fonte e cor da marca, o híbrido, o
1402
1452
  cinema e o impacto caíam sempre no Inter e no azul e roxo — "os vídeos
@@ -1409,7 +1459,7 @@ async function prepararRoteiro(p, args) {
1409
1459
  // Os estilos de motion usam os efeitos leves e a trilha do ende.app; os de corte, os cheios.
1410
1460
  const ehMotion = LEVES.has(estilo);
1411
1461
  const modo = (args.modo || roteiro.modo || (p.formato === 'slides' || p.formato === 'arte' ? 'slides' : 'video'));
1412
- const [largura, altura] = PROPORCOES[roteiro.proporcao || args.proporcao] || (modo === 'slides' ? PROPORCOES['16:9'] : PROPORCOES['16:9']);
1462
+ const [largura, altura] = PROPORCOES[roteiro.proporcao || args.proporcao] || (modo === 'video' && PROPORCOES[proporcaoDoPedido(p.pedido)]) || PROPORCOES['16:9'];
1413
1463
 
1414
1464
  const iconesOk = new Set();
1415
1465
  for (const nome of composicao.iconesDoRoteiro(roteiro)) {
@@ -1737,7 +1787,17 @@ async function prepararRoteiro(p, args) {
1737
1787
  if (r.ok) projeto.anotar(p.id, 'plano', `estilo ${hib ? 'hibrido' : 'cinema'}: ${r.resumo.cenas} cenas, ${r.resumo.segundos}s`);
1738
1788
  return r;
1739
1789
  }
1740
- if (modo === 'video') {
1790
+ if (modo === 'video' && semVoz) {
1791
+ /* Sem voz, sem tempo medido: cada parte dura o `duracao` dela (ou o que o
1792
+ texto da tela pede), e as palavras são repartidas na parte. Os sons
1793
+ leves entram depois do plano, em cada corte e entrada (sem-narracao.js). */
1794
+ tempos = semNarracao.tempos(partes, { falaDe: composicao.falaDe });
1795
+ args.__semNarracao = { tempos };
1796
+ const total = tempos.length ? tempos[tempos.length - 1].ate : 0;
1797
+ projeto.anotar(p.id, 'narracao', `sem narração: ${Math.round(total * 10) / 10}s em ${partes.length} partes, só motion e efeitos sonoros`);
1798
+ sons = estilo === 'gerativo' ? {} : ehMotion ? sonsLeves.preparar(p.pasta) : await edicao.prepararSons(p.pasta, video);
1799
+ }
1800
+ if (modo === 'video' && !semVoz) {
1741
1801
  const lista = partes.map((pt, i) => ({ texto: composicao.falaDe(pt), registro: composicao.registroDe(pt, i, partes.length) }));
1742
1802
  const arq = path.join(p.pasta, 'midia', `roteiro-${Date.now().toString(36)}.json`);
1743
1803
  fs.mkdirSync(path.dirname(arq), { recursive: true });
@@ -1817,6 +1877,9 @@ async function prepararRoteiro(p, args) {
1817
1877
  // O gerativo desenha os sons DEPOIS da partitura (um arquivo por evento, da própria trilha).
1818
1878
  sons = estilo === 'gerativo' ? {}
1819
1879
  : ehMotion ? sonsLeves.preparar(p.pasta) : await edicao.prepararSons(p.pasta, video);
1880
+ }
1881
+ // A trilha vale com e sem voz: o vídeo sem narração ainda tem música.
1882
+ if (modo === 'video') {
1820
1883
  // A trilha do motion: ende.app (CC BY 4.0, sem Content ID), clima tecnologia.
1821
1884
  if (ehMotion) {
1822
1885
  try {
@@ -1919,6 +1982,17 @@ async function prepararRoteiro(p, args) {
1919
1982
  '-af', 'afade=t=in:st=0:d=0.4,loudnorm=I=-18:TP=-2:LRA=11', '-ar', '44100', norm], { timeout: 5 * 60000 });
1920
1983
  if (c.ok && fs.existsSync(norm)) trilha = path.relative(p.pasta, norm).replace(/\\/g, '/');
1921
1984
  }
1985
+ /* SEM ÍCONE NENHUM (14/10, a padaria: "sem ícones"): o modelo grátis não
1986
+ dá herói, e o gerativo de comida não tem forma abstrata — o vídeo
1987
+ saía só com texto. Na parte em que o modelo não disse nada (nem
1988
+ "nenhum"), o ícone 3D que É a coisa que a fala nomeia ("pão" → bread). */
1989
+ const autoHerois = [];
1990
+ for (const pt of partes) {
1991
+ if (!pt || pt.heroi != null || pt.icone != null) continue;
1992
+ const id = colecao.melhorIcone3d([pt.titulo, pt.destaque, pt.fala, pt.texto].filter(Boolean).join(' '), 80);
1993
+ if (id && !autoHerois.includes(id)) { pt.heroi = id; autoHerois.push(id); }
1994
+ }
1995
+ if (autoHerois.length) projeto.anotar(p.id, 'icones', `sem herói do modelo, a biblioteca achou: ${autoHerois.join(', ')}`);
1922
1996
  /* O herói que o roteiro pediu do Iconify ("mdi:bread-slice"): o desenho é
1923
1997
  baixado aqui (fica em midia/icones); o que não baixar, a batida fica sem. */
1924
1998
  const icones = {};
@@ -2402,7 +2476,7 @@ async function ferramentaSite(args) {
2402
2476
  return r;
2403
2477
  }
2404
2478
 
2405
- module.exports = {
2479
+ module.exports = { proporcaoDoPedido,
2406
2480
  vazamentoNoComeco, variarIcones, objetosDoRoteiro,
2407
2481
  ferramentaProjeto, ferramentaPlano, ferramentaMidia, ferramentaNarrar,
2408
2482
  ferramentaVideo, ferramentaRender, ferramentaSite,
@@ -1150,6 +1150,22 @@ export function Gerativo({ el, t, fps }) {
1150
1150
  return (
1151
1151
  <div key={l.id} style={{ position: 'absolute', inset: 0, ...eLinha }}>
1152
1152
  <Linha l={l} b={x} cx={cx} t={t} fps={fps} P={P} frame={frame} alvoSaida={alvo} centroBloco={{ x: (lay.bloco.x + lay.bloco.x1) / 2, y: (lay.bloco.y + lay.bloco.y1) / 2 }} />
1153
+ {l.riscado && (() => {
1154
+ /* O RISCO EM TODAS AS LINHAS (14/10, "quando tem quebra de linha no
1155
+ motion, ele não risca"): o acento é UM traço e só vai a uma linha.
1156
+ As outras linhas riscadas ganham o próprio risco, na mesma hora. */
1157
+ const ar = (x.acento || []).find((a) => a.forma === 'risco');
1158
+ if (ar && ar.alvo && ar.alvo.linha === l.id) return null;
1159
+ const rs = x.linhas.filter((q) => q.riscado);
1160
+ const t0 = (ar ? ar.t : l.t + 0.4) + Math.max(0, rs.indexOf(l)) * 0.1;
1161
+ const k = ed(c01((t - t0) / 0.35));
1162
+ if (k <= 0) return null;
1163
+ const s = cx.s, y = cx.y + cx.h * 0.56, x0 = cx.x, x1 = cx.x + cx.w * k;
1164
+ return <svg style={{ position: 'absolute', inset: 0, overflow: 'visible' }} width="100%" height="100%">
1165
+ <path d={`M${x0.toFixed(1)} ${(y + s * 0.04).toFixed(1)} L${x1.toFixed(1)} ${(y + s * 0.04 - (x1 - x0) / Math.max(1, cx.w) * s * 0.08).toFixed(1)}`}
1166
+ stroke={x.cores.acento} strokeWidth={Math.max(4, s * 0.07)} strokeLinecap="round" fill="none" />
1167
+ </svg>;
1168
+ })()}
1153
1169
  </div>
1154
1170
  );
1155
1171
  })}
@@ -13,6 +13,8 @@ import React, { useMemo, useLayoutEffect, useRef, useState } from 'react';
13
13
  import * as THREE from 'three';
14
14
  import { delayRender, continueRender, staticFile } from 'remotion';
15
15
  import { RoundedBoxGeometry } from 'three/examples/jsm/geometries/RoundedBoxGeometry.js';
16
+ // O pulo com peso (agacha, estica, cai acelerando, amassa no pouso): kit/natural.js.
17
+ import { salto, respiracao } from './natural';
16
18
 
17
19
  const clamp = (x, a = 0, b = 1) => Math.max(a, Math.min(b, x));
18
20
  const hash = (n) => { const s = Math.sin(n * 127.1 + 311.7) * 43758.5453; return s - Math.floor(s); };
@@ -903,15 +905,17 @@ function Quadrupede({ e, est, cor }) {
903
905
  const [W, H, D] = e.corpo;
904
906
  const anda = est.acao === 'andar' || est.acao === 'correr' || est.acao === 'pular';
905
907
  const passo = est.acao === 'correr' ? 14 : 8;
906
- const pulo = est.acao === 'pular' || est.acao === 'comemorar' ? Math.abs(Math.sin(est.t * 6)) * 0.25 : 0;
907
- const respira = Math.sin(est.t * 2.2) * 0.008;
908
+ const ka = Math.min(1, est.forcaAcao ?? 1);
909
+ const sl = est.acao === 'pular' || est.acao === 'comemorar' ? salto(est.t, { periodo: 0.85, altura: 0.25 }) : { y: 0, sx: 1, sy: 1 };
910
+ const pulo = sl.y * ka, sy = 1 + (sl.sy - 1) * ka, sx = 1 + (sl.sx - 1) * ka;
911
+ const respira = (respiracao(est.t, (est.semente || 0) + 2) - 0.5) * 0.016;
908
912
  const y0 = e.perna + H / 2;
909
913
  const [cy, cp] = est.cabeca;
910
914
  const pescoco = e.pescoco || 0;
911
915
  const cabPos = [0, y0 + H * 0.35 + pescoco * 0.85, D / 2 + e.cab * 0.25 + pescoco * 0.25];
912
916
  // O corpo vira de lado, e a cabeça volta para a câmera: o bicho se lê inteiro em 3/4.
913
917
  return (
914
- <group position={[0, pulo, 0]}>
918
+ <group position={[0, pulo, 0]} scale={[sx, sy, sx]}>
915
919
  <group rotation={[0, 0.55, 0]}>
916
920
  <B s={[W, H, D]} p={[0, y0 + respira, 0]} c={cor} r={0.06} rough={e.fofo ? 1 : 0.75} />
917
921
  {e.fofo && [0, 1, 2].map((i) => <B key={i} s={[W * 1.06, H * 0.32, D * 0.3]} p={[0, y0 + H * 0.2, -D * 0.3 + i * D * 0.3]} c={e.cor} r={0.08} rough={1} />)}
@@ -1044,8 +1048,10 @@ export function Animal({ p, est }) {
1044
1048
  export function Coisa({ p, est, icones = {} }) {
1045
1049
  const [abre] = est.boca;
1046
1050
  const bob = Math.sin(est.t * 1.9 + 0.7) * 0.05;
1047
- const estica = 1 + abre * 0.08, achata = 1 - abre * 0.04;
1048
- const pulo = est.acao === 'pular' || est.acao === 'comemorar' ? Math.abs(Math.sin(est.t * 6)) * 0.25 : 0;
1051
+ const ka = Math.min(1, est.forcaAcao ?? 1);
1052
+ const sl = est.acao === 'pular' || est.acao === 'comemorar' ? salto(est.t, { periodo: 0.85, altura: 0.25 }) : { y: 0, sx: 1, sy: 1 };
1053
+ const pulo = sl.y * ka;
1054
+ const estica = (1 + abre * 0.08) * (1 + (sl.sy - 1) * ka), achata = (1 - abre * 0.04) * (1 + (sl.sx - 1) * ka);
1049
1055
  if (!p.src) return null;
1050
1056
  return (
1051
1057
  <group position={[0, 0.45 + bob + pulo, 0]} rotation={[0, est.cabeca[0] * 0.6, Math.sin(est.t * 1.3) * 0.04]}>
@@ -0,0 +1,161 @@
1
+ /* kit/natural.js — o movimento com PESO: as curvas que tiram o 3D do robótico.
2
+ *
3
+ * "a animação do boneco, a movimentação, é muito robótico" (Joel, 14/10)
4
+ *
5
+ * O boneco mexia em seno puro: todo membro no mesmo tempo, a mesma volta
6
+ * idêntica para sempre, parada seca quando a ação acabava e o corpo congelado
7
+ * quando não falava. Aqui moram os princípios de animação, em funções puras
8
+ * (o node testa, o kit/voxel.jsx e o kit/personagem.jsx desenham):
9
+ * - mola: massa e amortecimento — acelera, passa um pouco do ponto e assenta;
10
+ * - antecipa: recua antes de ir e passa um tico depois (follow-through);
11
+ * - atraso: tronco, cabeça, braço e mão com tempos diferentes (sobreposição);
12
+ * - ruido: a micro variação que nunca repete, igual em todo render (semente);
13
+ * - respiracao: inspira mais rápido do que expira, o ritmo varia um pouco;
14
+ * - salto: agacha, estica no impulso, cai acelerando, amassa no pouso e assenta;
15
+ * - passo: o quadril que balança, o corpo que desce no contato, o tronco que
16
+ * gira contra o quadril e o braço que vai atrasado da perna.
17
+ * Nada de Math.random nem Date.now: o mesmo quadro sai igual sempre. */
18
+ 'use strict';
19
+
20
+ const clamp = (x, a = 0, b = 1) => Math.max(a, Math.min(b, x));
21
+ const suave = (x) => { const k = clamp(x); return k * k * (3 - 2 * k); };
22
+
23
+ /** Um número de 0 a 1 a partir de um número inteiro e de uma semente (texto ou número). */
24
+ function sementeDe(s) {
25
+ if (typeof s === 'number') return s;
26
+ let h = 2166136261;
27
+ for (const c of String(s || '')) { h ^= c.charCodeAt(0); h = Math.imul(h, 16777619); }
28
+ return (h >>> 0) % 100000;
29
+ }
30
+ function sorte(n, semente = 0) {
31
+ const x = Math.sin(n * 127.1 + sementeDe(semente) * 311.7) * 43758.5453;
32
+ return x - Math.floor(x);
33
+ }
34
+
35
+ /** Ruído suave de -1 a 1 (dois tons somados): a deriva que nunca repete igual. */
36
+ function ruido(t, semente = 0) {
37
+ const tom = (x, s) => {
38
+ const i = Math.floor(x), f = x - i;
39
+ const a = sorte(i, s) * 2 - 1, b = sorte(i + 1, s) * 2 - 1;
40
+ const k = f * f * f * (f * (f * 6 - 15) + 10); // o quíntico: sem quina na emenda
41
+ return a + (b - a) * k;
42
+ };
43
+ const s = sementeDe(semente);
44
+ return tom(t, s) * 0.68 + tom(t * 2.17 + 13.3, s + 7) * 0.32;
45
+ }
46
+
47
+ /**
48
+ * A MOLA: a resposta de massa-mola-amortecedor indo de 0 a 1, `s` segundos
49
+ * depois do gatilho. `freq` em Hz, `amort` < 1 passa do ponto e volta.
50
+ */
51
+ function mola(s, { freq = 2.2, amort = 0.5 } = {}) {
52
+ if (s <= 0) return 0;
53
+ const w = 2 * Math.PI * freq;
54
+ if (amort >= 1) return 1 - Math.exp(-w * s) * (1 + w * s);
55
+ const wd = w * Math.sqrt(1 - amort * amort);
56
+ return 1 - Math.exp(-amort * w * s) * (Math.cos(wd * s) + (amort * w / wd) * Math.sin(wd * s));
57
+ }
58
+
59
+ /**
60
+ * A curva de 0 a 1 que RECUA antes de ir (antecipação) e passa um tico do fim
61
+ * antes de assentar (follow-through). Lenta nas pontas, rápida no meio.
62
+ */
63
+ function antecipa(x, { recuo = 0.1, passa = 0.06 } = {}) {
64
+ const k = clamp(x);
65
+ const base = suave(k);
66
+ const antes = -recuo * Math.sin(Math.PI * clamp(k / 0.3)) * (k < 0.3 ? 1 : 0);
67
+ const depois = passa * Math.sin(Math.PI * clamp((k - 0.62) / 0.38)) * (k > 0.62 ? 1 : 0);
68
+ return base + antes + depois;
69
+ }
70
+
71
+ /**
72
+ * O PESO DE UMA AÇÃO entre `de` e `ate` (segundos): entra com mola (passa e
73
+ * assenta), sai devagar, e nunca corta seco. `atraso` é a sobreposição: o
74
+ * membro que vem depois (cotovelo depois do ombro, cabeça depois do tronco).
75
+ */
76
+ function envelope(t, de, ate, { atraso = 0, entra = 2.4, sai = 0.55, amort = 0.55 } = {}) {
77
+ const tt = t - atraso;
78
+ if (tt < de) return 0;
79
+ const dentro = mola(tt - de, { freq: entra, amort }); // passa de 1 e volta: o membro assenta
80
+ const fora = tt > ate ? 1 - suave((tt - ate) / sai) : 1;
81
+ return Math.max(0, dentro * fora);
82
+ }
83
+
84
+ /**
85
+ * O GESTO de ênfase, `s` segundos depois de começar: o braço cai um pouco
86
+ * (antecipação), sobe rápido passando do ponto, segura e desce devagar.
87
+ * Dura ~1,1 s; fora disso é 0.
88
+ */
89
+ function golpe(s) {
90
+ if (s <= 0 || s >= 1.15) return 0;
91
+ const sobe = s < 0.1 ? -0.14 * Math.sin(Math.PI * s / 0.1) : mola(s - 0.1, { freq: 2.6, amort: 0.5 });
92
+ const desce = 1 - suave((s - 0.55) / 0.6);
93
+ return sobe * desce;
94
+ }
95
+
96
+ /**
97
+ * A RESPIRAÇÃO de 0 a 1: inspira em ~40% do ciclo, expira no resto, e o
98
+ * ciclo (≈3,4 s) varia um pouco a cada volta, pela semente.
99
+ */
100
+ function respiracao(t, semente = 0) {
101
+ const periodo = 3.4;
102
+ const fase = t / periodo + 0.12 * ruido(t * 0.15, semente) + sorte(1, semente);
103
+ const p = fase - Math.floor(fase);
104
+ return p < 0.4 ? suave(p / 0.4) : 1 - suave((p - 0.4) / 0.6);
105
+ }
106
+
107
+ /**
108
+ * O SALTO, num ciclo de `periodo` s: agacha (amassa), impulsiona (estica),
109
+ * sobe e cai em parábola (a gravidade: a queda acelera), amassa no pouso e
110
+ * assenta. Devolve {y, sx, sy}: a altura e a escala de amassar/esticar
111
+ * (o volume fica: sx ≈ 1/√sy).
112
+ */
113
+ function salto(t, { periodo = 0.95, altura = 0.32, fase = 0 } = {}) {
114
+ const p = ((t / periodo + fase) % 1 + 1) % 1;
115
+ const AGACHA = 0.16, AR = 0.6, POUSO = 1 - AGACHA - AR;
116
+ let y = 0, sy = 1;
117
+ if (p < AGACHA) {
118
+ const k = p / AGACHA;
119
+ const amassa = Math.sin(Math.PI * Math.min(1, k * 1.25)) * (k < 0.8 ? 1 : 0);
120
+ sy = 1 - 0.14 * amassa;
121
+ y = -0.05 * altura * amassa;
122
+ if (k >= 0.8) sy = 1 + 0.12 * suave((k - 0.8) / 0.2); // o impulso: estica antes de sair do chão
123
+ } else if (p < AGACHA + AR) {
124
+ const q = (p - AGACHA) / AR;
125
+ y = altura * 4 * q * (1 - q); // a parábola: sobe freando, desce acelerando
126
+ sy = 1 + 0.12 * Math.pow(Math.abs(1 - 2 * q), 1.5); // estica com a velocidade, redondo no alto
127
+ } else {
128
+ const k = (p - AGACHA - AR) / POUSO;
129
+ const amortece = Math.exp(-4.2 * k) * Math.cos(k * Math.PI * 2.2);
130
+ sy = 1 - 0.18 * amortece; // o pouso amassa e assenta em mola
131
+ y = -0.04 * altura * Math.max(0, amortece);
132
+ }
133
+ return { y, sy, sx: 1 / Math.sqrt(Math.max(0.5, sy)) };
134
+ }
135
+
136
+ /**
137
+ * O PASSO, `t` em s, `ritmo` em passos por segundo (andar ≈ 1,8, correr ≈ 3).
138
+ * Devolve os ângulos (rad) e o deslocamento do corpo:
139
+ * perna[esq,dir] balanço da coxa (para frente é negativo no eixo X)
140
+ * braco[esq,dir] o braço oposto à perna, atrasado (sobreposição)
141
+ * quadril o rolar do quadril para o lado da perna que apoia
142
+ * giro o tronco que gira contra o quadril
143
+ * sobe o corpo desce no contato e sobe na passagem
144
+ */
145
+ function passo(t, { ritmo = 1.8, amplitude = 0.42, correr = false, semente = 0 } = {}) {
146
+ const f = Math.PI * ritmo * t + sorte(3, semente) * 0.3;
147
+ // A perna vai para frente mais rápido do que volta no apoio: a onda torta.
148
+ const onda = (x) => Math.sin(x + 0.22 * Math.sin(x));
149
+ const a = amplitude * (correr ? 1.6 : 1);
150
+ const atrasoBraco = correr ? 0.18 : 0.32; // o braço chega depois da perna
151
+ return {
152
+ perna: [onda(f) * a, onda(f + Math.PI) * a],
153
+ braco: [onda(f + Math.PI - atrasoBraco) * a * 0.9, onda(f - atrasoBraco) * a * 0.9],
154
+ quadril: Math.sin(f - 0.35) * (correr ? 0.03 : 0.055),
155
+ giro: -Math.sin(f - 0.15) * (correr ? 0.12 : 0.07),
156
+ sobe: (0.5 + 0.5 * Math.cos(2 * f)) * (correr ? 0.09 : 0.045),
157
+ inclina: correr ? 0.14 : 0.03, // quem corre vai com o tronco para frente
158
+ };
159
+ }
160
+
161
+ module.exports = { clamp, suave, sorte, ruido, mola, antecipa, envelope, golpe, respiracao, salto, passo };
@@ -16,6 +16,8 @@ import { ThreeCanvas } from '@remotion/three';
16
16
  import * as THREE from 'three';
17
17
  import { useThree } from '@react-three/fiber';
18
18
  import { RoomEnvironment } from 'three/examples/jsm/environments/RoomEnvironment.js';
19
+ // O movimento com peso (mola, antecipação, sobreposição, respiração): kit/natural.js.
20
+ import { ruido, envelope, golpe, respiracao } from './natural';
19
21
 
20
22
  const clamp = (x, a = 0, b = 1) => Math.max(a, Math.min(b, x));
21
23
  const lerp = (a, b, k) => a + (b - a) * k;
@@ -93,10 +95,10 @@ function gesto(falas, quem, t) {
93
95
  if (f.quem !== quem || t < f.de - 0.5 || t > f.ate + 1) continue;
94
96
  f.palavras.forEach((w, k) => {
95
97
  if (String(w.t).replace(/[^\p{L}\d]/gu, '').length < 6 && !/\d/.test(w.t)) return;
96
- const x = (t - (w.de - 0.15)) / 0.9;
97
- if (x < 0 || x > 1) return;
98
- const b = Math.sin(Math.PI * x);
99
- if (k % 2) esq = Math.max(esq, b); else dir = Math.max(dir, b);
98
+ // Recua, sobe passando do ponto e desce devagar (natural.golpe), não um seno.
99
+ const b = golpe(t - (w.de - 0.15));
100
+ if (b === 0) return;
101
+ if (k % 2) esq = Math.abs(b) > Math.abs(esq) ? b : esq; else dir = Math.abs(b) > Math.abs(dir) ? b : dir;
100
102
  });
101
103
  }
102
104
  return [esq, dir];
@@ -256,12 +258,14 @@ function RostoRobo({ p, est, acento }) {
256
258
  }
257
259
 
258
260
  /* ── O CORPO, OS BRAÇOS E O PERSONAGEM INTEIRO ───────────────────────────── */
259
- function Braco({ lado, g, cor, pele, robo, t }) {
260
- // Repouso: o antebraço deitado na mesa. Gesto: a mão sobe e mexe um pouco.
261
- const ombroX = lerp(-0.3, -0.62, g) + Math.sin(t * 6 + lado) * 0.05 * g;
262
- const ombroZ = lado * lerp(0.08, 0.28, g);
263
- const cotovelo = lerp(-1.17, -1.75, g) + Math.sin(t * 7.3 + lado * 2) * 0.12 * g;
264
- const pulso = Math.sin(t * 9 + lado) * 0.25 * g;
261
+ function Braco({ lado, g, gc = g, gp = gc, cor, pele, robo, t, sem = 0 }) {
262
+ // Repouso: o antebraço deitado na mesa, que deriva de leve (nunca congelado).
263
+ // Gesto: o ombro leva, o cotovelo (gc) e o pulso (gp) vêm depois — a sobreposição.
264
+ const deriva = ruido(t * 0.4, sem + lado * 5) * 0.03;
265
+ const ombroX = lerp(-0.3, -0.62, g) + Math.sin(t * 6 + lado) * 0.05 * Math.max(0, g) + deriva;
266
+ const ombroZ = lado * lerp(0.08, 0.28, Math.max(0, g));
267
+ const cotovelo = lerp(-1.17, -1.75, gc) + Math.sin(t * 7.3 + lado * 2 - 0.8) * 0.12 * Math.max(0, gc) + deriva * 0.5;
268
+ const pulso = Math.sin(t * 9 + lado - 1.4) * 0.25 * Math.max(0, gp) + ruido(t * 0.7, sem + lado) * 0.06;
265
269
  const tecido = robo ? <Mat c={cor} r={0.3} m={0.1} cc={1} /> : <Mat c={cor} r={0.85} sheen="#ffffff" />;
266
270
  return (
267
271
  <group position={[lado * 0.44, 1.18, 0]} rotation={[ombroX, 0, ombroZ]}>
@@ -279,7 +283,8 @@ function Braco({ lado, g, cor, pele, robo, t }) {
279
283
  function Personagem({ p, est, acento, pos, giro }) {
280
284
  const robo = p.tipo === 'robo';
281
285
  const corpo = robo ? p.cor : p.roupa;
282
- const resp = 1 + Math.sin(est.t * 1.6 + pos[0]) * 0.012;
286
+ // O fôlego: inspira rápido, expira devagar, o ritmo varia (natural.respiracao).
287
+ const resp = 1 + respiracao(est.t, (est.semente || 0) + pos[0]) * 0.016;
283
288
  const [cy, cp, cr] = est.cabeca;
284
289
  return (
285
290
  <group position={pos} rotation={[0, giro, 0]}>
@@ -293,8 +298,7 @@ function Personagem({ p, est, acento, pos, giro }) {
293
298
  : <mesh position={[0, 1.27, 0]} rotation={[Math.PI / 2, 0, 0]}><torusGeometry args={[0.15, 0.04, 12, 32]} /><Mat c={new THREE.Color(corpo).multiplyScalar(0.7).getStyle()} r={0.85} /></mesh>}
294
299
  <mesh position={[0, 1.3, 0]}><cylinderGeometry args={[0.12, 0.13, 0.25, 24]} />{robo ? <Mat c="#3a3e48" r={0.4} m={0.6} /> : <Mat c={p.pele} r={0.6} sheen="#ff9a8a" />}</mesh>
295
300
  </group>
296
- <Braco lado={-1} g={est.gesto[0]} cor={corpo} pele={p.pele} robo={robo} t={est.t} />
297
- <Braco lado={1} g={est.gesto[1]} cor={corpo} pele={p.pele} robo={robo} t={est.t} />
301
+ {[-1, 1].map((l) => { const k = l > 0 ? 1 : 0; return <Braco key={l} lado={l} g={est.gesto[k]} gc={(est.gestoAtras || est.gesto)[k]} gp={(est.gestoMao || est.gesto)[k]} cor={corpo} pele={p.pele} robo={robo} t={est.t} sem={est.semente || 0} />; })}
298
302
  <group position={[0, 1.62, 0]} rotation={[cp, cy, cr]} castShadow>
299
303
  {robo ? <RostoRobo p={p} est={est} acento={acento} /> : <Rosto p={p} est={est} />}
300
304
  </group>
@@ -529,19 +533,31 @@ export function PersonagemCena({ el, t }) {
529
533
  for (const f of falas) if (f.quem === i && f.de <= t + 0.2) e = t <= f.ate + 2.5 ? f.emocao : 'neutro';
530
534
  return e;
531
535
  };
536
+ // O quanto cada um está falando (0 a 1, sem degrau): os acenos trocam com mola.
537
+ const pesoDaFala = (i, reacao = 0) => falas.reduce((m, f) => (f.quem === i ? Math.max(m, Math.min(1, envelope(t, f.de + reacao, f.ate, { entra: 2.6, sai: 0.45, amort: 0.7 }))) : m), 0);
532
538
  const estado = (i) => {
539
+ const sem = i * 13 + 1;
533
540
  const fala = falandoAgora(i);
534
- const outro = dois && falandoAgora(1 - i);
541
+ const wFala = pesoDaFala(i);
542
+ // Quem ouve reage ~0,2 s depois, com mola, e volta devagar.
543
+ const wOuve = dois ? pesoDaFala(1 - i, 0.2) : 0;
535
544
  const enfase = gesto(falas, i, t);
536
- const nod = Math.sin(t * (fala ? 5.2 : 1.4) + i) * (fala ? 0.035 : 0.015) + (outro ? Math.max(0, Math.sin(t * 2.1 + i)) * 0.04 : 0);
545
+ const nod = lerp(ruido(t * 0.5, sem + 3) * 0.015, Math.sin(t * 5.2 + i) * 0.028 + ruido(t * 2.4, sem + 2) * 0.016, wFala)
546
+ + wOuve * Math.max(0, Math.sin(t * 2.1 + i)) * 0.04;
537
547
  const emo = emocaoDe(i);
538
- const e = EMO[emo] || EMO.neutro;
539
- const micro = (hash(Math.floor(t * 1.5) + i * 7) - 0.5) * 0.08;
548
+ // A inclinação da emoção chega e vai embora com peso, não troca no quadro.
549
+ let inclina = 0;
550
+ for (const f of falas) {
551
+ if (f.quem !== i || f.de > t + 0.2) continue;
552
+ inclina = ((EMO[f.emocao] || EMO.neutro).inclina || 0) * Math.min(1.05, envelope(t, f.de - 0.2, f.ate + 2.5, { entra: 1.4, sai: 0.8, amort: 0.6 }));
553
+ }
554
+ const micro = (hash(Math.floor(t * 1.5 + hash(i) * 0.9) + i * 7) - 0.5) * 0.08;
540
555
  return {
541
- t, falando: fala, emocao: emo, boca: boca(falas, i, t), pisca: piscada(i, t),
542
- gesto: enfase, enfase: Math.max(enfase[0], enfase[1]),
556
+ t, semente: sem, falando: fala, emocao: emo, boca: boca(falas, i, t), pisca: piscada(i, t),
557
+ gesto: enfase, gestoAtras: gesto(falas, i, t - 0.08), gestoMao: gesto(falas, i, t - 0.15), enfase: Math.max(enfase[0], enfase[1], 0),
543
558
  olhar: dois ? [micro, 0.02] : [micro * 0.5, 0.04],
544
- cabeca: [Math.sin(t * 0.7 + i * 2) * 0.08 + (outro ? 0.05 : 0), nod + (fala ? 0 : 0.03), (e.inclina || 0) * (i ? -1 : 1) + Math.sin(t * 0.5 + i) * 0.02],
559
+ // A cabeça deriva pelo ruído (nunca a mesma volta) e vem ~0,06 s atrás do corpo.
560
+ cabeca: [ruido((t - 0.06) * 0.35, sem + 5) * 0.09 + wOuve * 0.05, nod + (1 - wFala) * 0.03, inclina * (i ? -1 : 1) + ruido((t - 0.06) * 0.25, sem + 7) * 0.025],
545
561
  };
546
562
  };
547
563
  const posDe = (i) => (dois ? [(elenco[i].lado || 0) * 0.95, 0, 0] : [0, 0, 0]);