Raio-X do projeto

Como o Sapo passou a apontar para o cursor.

Este é o passo a passo completo, com os prompts reais na ordem em que foram usados, os comandos, as decisões e os erros que apareceram no caminho. Seguindo do início ao fim, você reproduz o mesmo hero: um personagem que aponta para onde o mouse está, feito só com frames reais de um clipe de 4 segundos, sem player de vídeo e sem biblioteca.

Ir direto aos prompts ↓ Baixar o kit ↓

4 s
clipe de entrada, 2206×946, 24 fps, 97 frames
381
frames depois da interpolação a 96 fps
67
frames na sheet: o caminho esquerda → parado → direita
0
bibliotecas: JavaScript puro, canvas e uma mola

Etapa 0

O que entra: um clipe de 4 segundos

O ponto de partida é um clipe gerado por IA de 4 segundos (2206×946, 24 fps, HEVC 10 bits, 97 frames) em que um sapo de moletom azul-petróleo e óculos laranja, sobre fundo laranja de estúdio, faz sinal de paz com os dois braços para cima, aponta para a esquerda, volta, aponta para a direita e volta. O arquivo original chegou pronto (nome hf_20260816_…mp4); o prompt que gerou o vídeo não foi registrado nesta conversa, então esta página começa no que aconteceu a partir do clipe.

Tudo o que acontece no hero usa somente frames desse clipe, originais ou interpolados entre dois originais. Nenhuma pose é desenhada: quando o Sapo aponta para o cursor, você está vendo um frame do vídeo.

Grade com 12 miniaturas do clipe, uma a cada 8 frames, mostrando o sapo com os braços para cima, apontando para a esquerda, voltando ao centro e apontando para a direita.
Grade de contato do clipe (1 miniatura a cada 8 frames). É a primeira coisa a gerar: mostra o que o vídeo tem antes de escrever qualquer código.

Etapa 1

Os prompts, na ordem em que foram usados

O projeto inteiro nasceu de um prompt de construção e de sete mensagens de ajuste, todas enviadas ao Claude (Claude Code, modelo Claude Fable 5.1) junto com o clipe. Os textos abaixo são exatamente os que foram usados, sem edição; depois de cada um, o que mudou no resultado. As etapas seguintes mostram como cada pedido foi resolvido.

1. O pedido inicial (em inglês, como foi enviado)

prompt · original
Create an interactive hero using the uploaded 4-second cat animation.

Use FFmpeg to extract every frame, combine them into a single horizontal sprite sheet, and build a frame-based animation system in vanilla JavaScript.

Make the character naturally track the mouse by smoothly scrubbing through the frames based on cursor position, with eased interpolation and a smooth return to the idle pose when the mouse leaves.

Preserve the original composition, keep the character centered, and make the interaction feel premium and responsive.

Em português (tradução do mesmo pedido, para quem preferir enviar assim):

prompt · tradução
Crie um hero interativo usando a animação de 4 segundos enviada (o "gato" do pedido era, na verdade, um sapo).

Use o FFmpeg para extrair todos os frames, combine-os em uma única sprite sheet horizontal e construa um sistema de animação baseado em frames em JavaScript puro (vanilla).

Faça o personagem acompanhar o mouse de forma natural, percorrendo os frames suavemente conforme a posição do cursor, com interpolação com easing e um retorno suave à pose de repouso quando o mouse sair.

Preserve a composição original, mantenha o personagem centralizado e faça a interação transmitir uma sensação premium e responsiva.

2. "o efeito não está suave"

ajuste 1
o efeito não está suave

Causa: cada transição do clipe tem só 7 frames a 24 fps, e o cross-fade entre poses tão distantes vira dupla exposição. Além disso, mover background-position numa imagem de 23 mil px repintava a camada inteira a cada quadro. Solução: interpolação de movimento para 96 fps com o FFmpeg (Etapa 3), sheet em grade (Etapa 4) e render em canvas (Etapa 5).

3. "deixe o site em portugues tb"

ajuste 2
deixe o site em portugues tb

Resultado: português como padrão e um botão PT/EN no topo que lembra a escolha (storage com prefixo sapo:). Todo o texto vive em src/data/hero.js nas duas línguas; o PT é renderizado em HTML estático no build e o EN é aplicado em runtime pelas mesmas chaves.

4. "pq vc não criou todos os frames do video ?"

ajuste 3
pq vc não criou todos os frames  do video ?

Resposta: os 97 frames originais foram extraídos desde o início (pasta frames/) e depois os 381 interpolados (frames-96fps/). O que não tem todos os frames é a sheet: 68 dos 97 originais são holds pixel-idênticos e os navegadores recusam imagens gigantes, então a sheet fica só com o caminho esquerda → parado → direita, que é o que o cursor consegue mostrar. Os dois conjuntos de frames vão no kit para download.

5. "quando eu mexo o mouse faz uma sombra, parece que está em camera lenta"

ajuste 4
quando eu mexo o mouse faz uma sombra  , parece que está em camera lenta

Sombra: o cross-fade entre duas células desenhava o braço duas vezes no meio do gesto. Como agora há 28 frames reais por transição, o cross-fade foi desligado e o canvas desenha só o frame mais próximo. Câmera lenta: a mola de tracking estava macia demais; ficou bem mais rígida (em 120 ms o Sapo chega a ~90% da posição do cursor).

6. Os textos aparecem conforme o lado

ajuste 5
outro ponto quando eu mexe o curso para esquerda quero que apareca 

Frames reais · 96 fps

Mexa.
Ele segue

e pára direita  

O Sapo segue o seu cursor percorrendo frames reais da animação: sem player de vídeo, sem biblioteca, só uma sheet de pixels e uma mola.

Resultado: os dois blocos de texto ficam escondidos no centro e aparecem conforme a pose do Sapo (esquerda → headline; direita → texto e botões). No celular e com "reduzir movimento", os dois ficam sempre visíveis.

7. "o braço ainda fica borrado" e as frases ao lado do Sapo

ajuste 6
o braço ainda fica borrado, dá pra melhorar? outro ponto eu gostaria que no computador as frases aparecessem do lado do sapo

O desfoque está gravado no vídeo (motion blur nos frames do meio do gesto). A solução foi não deixar o Sapo parar num frame borrado: cada frame tem uma nitidez medida e, quando o cursor para, a pose assenta no frame nítido mais próximo (Etapa 6). No desktop, headline e texto passaram a ficar ao lado do personagem, centralizados na altura dele.

8. Publicar

ajuste 7
maravilha sobe a na vps usando a skill C:\Users\jorge\.claude\skills\publicar-site-imersivo\SKILL.md detalha tudo , sobe os arquivos para qlq um conseguir criar tb, inclua os prompt em portugues

Resultado: este site, publicado em sites.amentedojorge.com.br/sapo/ pelo kit do programa IMERSO, esta página Raio-X com os prompts e o kit para download (Etapa 10).

Etapa 2

Extrair todos os frames e ler o movimento

Se o FFmpeg não estiver no PATH, o caminho mais rápido no Windows é o binário estático que vem com o pacote Python imageio-ffmpeg (versão 7.1, com libwebp e o filtro minterpolate):

bash
python -m pip install imageio-ffmpeg
python -c "import imageio_ffmpeg; print(imageio_ffmpeg.get_ffmpeg_exe())"

Com o executável em mãos, confira o clipe e extraia os 97 frames em resolução original:

bash
# Duração, resolução e fps
ffmpeg -hide_banner -i clipe.mp4

# Todos os frames (24 fps × 4,06 s = 97), PNG sem perdas
mkdir frames
ffmpeg -i clipe.mp4 -vsync 0 -pix_fmt rgb24 frames/frame_%03d.png

# Grade de contato: 1 miniatura a cada 8 frames, 4 colunas
ffmpeg -i clipe.mp4 -vf "select='not(mod(n,8))',scale=320:-1,tile=4x4" -frames:v 1 contato.png

Antes de programar, meça onde o personagem está em cada frame. Como o fundo é um laranja uniforme, uma máscara de cor separa o sapo e o centroide horizontal dos pixels dele diz para onde ele se inclinou. Vinte linhas de Python:

python
import numpy as np
from PIL import Image
for i in range(1, 98):
    im = np.asarray(Image.open(f"frames/frame_{i:03d}.png").convert("RGB").resize((551, 236))).astype(int)
    r, g, b = im[..., 0], im[..., 1], im[..., 2]
    frog = ~((r > 180) & (g > 40) & (g < 170) & (b < 90))   # tudo que não é o laranja do fundo
    ys, xs = np.nonzero(frog)
    print(f"f{i-1:02d} cx={xs.mean()*4:.0f} x[{xs.min()*4}..{xs.max()*4}]")

O resultado mostra a estrutura do clipe (frames 0-based):

FramesO que o Sapo fazCentroide x
0 – 17Parado, braços para cima (hold)1105
18 – 24Inclina e aponta para a esquerda1105 → 1013
25 – 42Segura a pose à esquerda (hold)1010
43 – 49Volta ao centro1030 → 1104
50 – 60Parado (hold)1105
61 – 67Inclina e aponta para a direita1109 → 1208
68 – 82Segura a pose à direita (hold)1214
83 – 89Volta ao centro1194 → 1106
90 – 96Parado (hold)1105
Três recortes lado a lado: o sapo apontando para a esquerda, parado com os braços para cima e apontando para a direita.
Frames 42, 55 e 68: os dois picos e o repouso. Repare que 42 → 55 → 68 é um caminho contínuo, em ordem cronológica, do pico esquerdo ao pico direito passando pelo repouso.

Etapa 3

Interpolar por movimento: de 7 para 28 frames por gesto

Cada transição do original tem só 7 frames. Percorridos pelo cursor, viram saltos; misturados por cross-fade, viram fantasmas. O filtro minterpolate do FFmpeg gera frames intermediários reais por compensação de movimento (modo mci, estimativa bidirecional). A 96 fps, cada gesto passa a ter 28 frames.

bash
# 4x mais frames por compensação de movimento (leva cerca de 1 minuto para 4 s de clipe)
mkdir frames-96fps
ffmpeg -i clipe.mp4 \
  -vf "scale=1104:474:flags=lanczos,minterpolate=fps=96:mi_mode=mci:mc_mode=aobmc:me_mode=bidir:me=epzs:vsbmc=1:search_param=64" \
  -vsync 0 -pix_fmt rgb24 frames-96fps/f_%04d.png
# 381 arquivos; o frame-fonte n (0-based) vira o arquivo 4n+1
Dez recortes consecutivos do sapo interpolado a 96 fps, com o braço descendo em passos pequenos.
Dez frames interpolados consecutivos (arquivos 173 a 182) durante a inclinação para a esquerda. O braço muda pouco entre vizinhos: é isso que faz o scrub parecer contínuo.

Etapa 4

Empacotar em uma sheet em grade

A sheet reúne os frames interpolados do caminho esquerda → parado → direita: arquivos 166 a 197 (esquerda até o repouso, 32 frames) e 240 a 274 (repouso até a direita, 35 frames). São 67 frames: índices 0–30 apontam para a esquerda, 31 é o repouso e 32–66 apontam para a direita.

Ela é uma grade de 12 colunas por 6 linhas, não uma tira única: navegadores não decodificam WebP com mais de 16.383 px nem JPEG/PNG com mais de ~32.767 px de largura. Duas resoluções: 1× em WebP (552×237 por frame, 0,5 MB) para a primeira pintura e 2× em JPEG (960×412, 2,1 MB) para telas grandes e densas.

bash
# Lista dos 67 frames, na ordem
for i in $(seq 166 197) $(seq 240 274); do echo "file 'frames-96fps/f_$(printf %04d $i).png'"; done > lista.txt

# Sheet 2x: 67 frames, 960x412 cada, grade 12x6 → 11520x2472 px (JPEG)
ffmpeg -f concat -safe 0 -i lista.txt -vf "scale=960:412:flags=lanczos,tile=12x6" -frames:v 1 -q:v 3 sapo-sheet-2x.jpg

# Sheet 1x: 552x237 cada → 6624x1422 px (WebP)
ffmpeg -f concat -safe 0 -i lista.txt -vf "scale=552:237:flags=lanczos,tile=12x6" -frames:v 1 -c:v libwebp -quality 90 sapo-sheet-1x.webp

# Poster do repouso (frame 55 original) para a primeira pintura
ffmpeg -i frames/frame_056.png -vf "scale=551:236:flags=lanczos" -c:v libwebp -quality 88 sapo-poster.webp
Recorte das quatro primeiras colunas e duas primeiras linhas da sheet, com o sapo apontando para a esquerda em poses quase iguais.
Recorte das 4 primeiras colunas × 2 linhas da sheet 1× (a imagem completa tem 12 × 6). O frame i fica na coluna i mod 12 e na linha ⌊i / 12⌋.

Com a mesma análise de centroide, agora sobre os frames da sheet, sai a curva de deslocamento: quanto o personagem se afastou do repouso em cada índice, normalizado de 0 a 1. Ela é a tabela CONFIG.motion do motor e é o que faz "metade do cursor" ser "metade da inclinação", e não "metade dos frames".

Etapa 5

O motor: pose, mola e canvas

O cursor vira uma pose p em [-1, 1]: -1 é apontar todo para a esquerda, 0 é o repouso, +1 é apontar todo para a direita. O X do ponteiro, relativo ao centro do hero, passa por uma curva leve (1 - (1 - |x|)^1.15) para o centro responder um pouco mais que as bordas.

js
// cursor x (relativo ao centro do hero) -> pose em [-1, 1]
function poseFromX(clientX) {
  const r = hero.getBoundingClientRect();
  const nx = clamp((clientX - (r.left + r.width / 2)) / (r.width / 2), -1, 1);
  const eased = 1 - Math.pow(1 - Math.abs(nx), CONFIG.curveExponent);
  return Math.sign(nx) * eased;
}

// pose -> frame fracionário, invertendo a curva de deslocamento medida
function poseToFrame(p) {
  const arr = p < 0 ? CONFIG.motion.left : CONFIG.motion.right;
  const dir = p < 0 ? -1 : 1;
  const m = Math.min(1, Math.abs(p));
  let k = 0;
  while (k < arr.length - 2 && arr[k + 1] < m) k++;
  const frac = (m - arr[k]) / (arr[k + 1] - arr[k]);
  return CONFIG.idle + dir * (k + frac);
}

A pose atual persegue a pose pedida por uma mola amortecida integrada por sub-passos fixos (estável em qualquer taxa de quadros). Cada situação tem a sua mola: seguir o cursor é rígido e quase crítico; voltar ao repouso é lento e criticamente amortecido; as olhadas autônomas ficam no meio.

js
class Spring {
  step(dt) {
    const n = Math.max(1, Math.ceil(dt / (1 / 240)));   // sub-passos de ~4 ms
    const h = dt / n;
    for (let i = 0; i < n; i++) {
      const a = -this.omega * this.omega * (this.x - this.target) - 2 * this.zeta * this.omega * this.v;
      this.v += a * h;
      this.x += this.v * h;
    }
  }
}

physics: {
  track:   { omega: 24,  zeta: 0.92 },   // gruda no cursor
  release: { omega: 7,   zeta: 1.00 },   // volta calma ao repouso
  auto:    { omega: 8,   zeta: 0.95 },   // olhadas sozinho
  rest:    { omega: 10,  zeta: 1.00 },   // assentar num frame nítido
}

O desenho é um único drawImage por quadro num <canvas> em alta densidade (DPR limitado a 2), copiando a célula certa da sheet. Com 28 frames reais por gesto, o frame mais próximo já é contínuo; o cross-fade entre células vizinhas existe no código (CONFIG.blend) mas fica desligado, porque duplicava o braço no meio do gesto.

js
blit(i, alpha) {                       // grade: coluna = i % 12, linha = ⌊i / 12⌋
  const [cw, ch] = this.cell;
  const sx = (i % this.cols) * cw, sy = Math.floor(i / this.cols) * ch;
  this.ctx.globalAlpha = alpha;
  this.ctx.drawImage(this.img, sx, sy, cw, ch, 0, 0, this.canvas.width, this.canvas.height);
}
Medição no navegadorValor
Tempo por quadro durante uma varredura completamediana 16,7 ms · p95 16,8 ms (60 fps cravados)
Cursor de -1 a +1: fração percorrida em 120 ms~90%
Primeira pinturaposter de 7 KB no canvas; sheet 1× (0,5 MB) quando a página fica ociosa; sheet 2× depois

Etapa 6

Nunca descansar num frame borrado

O clipe tem motion blur gravado nos frames do meio do gesto: o braço é um borrão e a nitidez cai para um quinto da pose parada. Nenhum filtro reconstrói isso, e a interpolação só cria frames entre dois originais borrados.

Três recortes do sapo: dois com o braço claramente borrado no meio do gesto e um parado, nítido.
Frames originais 46 e 47 (meio da volta ao centro) contra o 56 (repouso): o borrão está na fonte.

A saída foi medir a nitidez de cada frame da sheet (variância do Laplaciano na região do tronco e dos braços, com OpenCV) e usar a tabela no motor: em movimento, qualquer frame vale, porque o blur lê como movimento; parado, só frame nítido. Quando o ponteiro fica 140 ms sem se mover e o Sapo estaria num frame abaixo do limiar, a pose assenta no frame nítido mais próximo.

python
import cv2
def sharpness(path):
    g = cv2.cvtColor(cv2.imread(path), cv2.COLOR_BGR2GRAY)
    h, w = g.shape; sc = w / 2206
    roi = g[int(80*sc):int(600*sc), int(650*sc):int(1550*sc)]   # tronco e braços
    return cv2.Laplacian(roi, cv2.CV_64F).var()
# normalizar pelo máximo -> CONFIG.sharpness (67 valores); limiar sharpMin = 0.62
js
// Frame nítido cuja pose é a mais próxima da pedida. Distância em espaço de pose;
// frames mais para fora que o cursor são favorecidos (peso 0.7): com o cursor além
// do meio do hero, o sapo assenta "apontando", não parado.
function nearestSharp(pose) {
  let best = CONFIG.idle, bestD = Infinity;
  CONFIG.sharpness.forEach((s, i) => {
    if (s < CONFIG.sharpMin) return;
    const fp = frameToPose(i);
    if (Math.sign(fp) !== 0 && Math.sign(fp) !== Math.sign(pose)) return;   // nunca cruza de lado
    let d = Math.abs(fp - pose);
    if (Math.abs(fp) > Math.abs(pose)) d *= CONFIG.restOutwardBias;
    if (d < bestD) { bestD = d; best = i; }
  });
  return best;
}
Quatro recortes do sapo em poses limítrofes de nitidez, do meio borrado ao braço estendido nítido.
Frames na fronteira do limiar (nitidez 0,50 · 0,72 · 0,65 · 0,78). O de 0,50 ainda tem a mão direita borrada; o limiar ficou em 0,62.
Cursor (fração da largura)Onde o Sapo assentaNitidez do frame
20%Apontando todo para a esquerda (índice 3)0,90
34%Quase parado, leve inclinação (índice 23)0,72
55%Quase parado (índice 37)0,65
70% e 92%Apontando para a direita (índice 57)0,78

Etapa 7

Composição, painéis laterais e retrato

O palco é sempre o quadro original 2206×946, nunca cortado na sheet. Ele cobre o hero, mas com dois tetos: no celular, nunca amplia além de mostrar 900 px da fonte na largura (o Sapo tem 780 px de largura, então fica inteiro); no desktop alto, o personagem nunca passa de 46% da largura do hero, para não colidir com o texto. As bordas do quadro se dissolvem na página por uma máscara (laterais e topo), e o fundo da página repete o leve gradiente do estúdio.

js
function layout() {
  const r = hero.getBoundingClientRect();
  const cover = Math.max(r.width, r.height * CONFIG.aspect);
  const spanCap = r.width * 2206 / CONFIG.minSpan;                      // celular: sapo inteiro
  const shareCap = r.width * CONFIG.maxCharacterShare * 2206 / 780;     // desktop alto: longe do texto
  const w = Math.min(cover, spanCap, r.width > 860 ? shareCap : Infinity);
  stage.style.width = `${w}px`; stage.style.height = `${w / CONFIG.aspect}px`;
  sheet.resize(w, w / CONFIG.aspect);
}
  • Painéis seguem a pose. hero.dataset.side vira left, right ou vazio conforme a pose (zona morta de ±0,12). O CSS mostra a headline à esquerda e o texto com botões à direita; no centro, só o Sapo. Sem cursor (toque, telas estreitas, "reduzir movimento", sem JS), os dois ficam visíveis.
  • No desktop, headline e texto ficam ao lado do personagem, centralizados na altura dele, em colunas de até 23% da largura. No celular, a headline vai para cima e o texto para baixo, e o palco sobe um pouco (47%) para os tênis não encostarem no parágrafo.
  • Tipografia Bricolage Grotesque (display) e Azeret Mono (readout), self-hosted via @fontsource-variable. Paleta tirada dos próprios frames. Um grão suave em soft-light unifica o laranja chapado da CG com a interface.
  • Cursor customizado (ponto claro em mix-blend-mode: difference) só em dispositivos com ponteiro fino; no toque, arrastar o dedo faz o tracking enquanto pressionado.

Etapa 8

Como saber que ficou bom

  • Cursor na borda esquerda: o Sapo aponta para a esquerda; na direita, idem; no centro, braços para cima. O movimento acompanha sem atraso perceptível e sem tranco ao parar.
  • Parado em qualquer ponto, o frame exibido é nítido (readout mostra o índice; conferir contra CONFIG.sharpness).
  • Nenhuma dupla exposição em movimento (se houver, a sheet tem poucos frames ou blend está ligado).
  • Sair do hero devolve os braços para cima com calma; depois de ~3,4 s parado, ele olha para os lados sozinho.
  • Larguras 390, 768, 1440 e 2560 px; texto legível sem JavaScript (o poster fica no lugar do canvas); prefers-reduced-motion desliga intro e olhadas; saveData deixa só o poster.
  • Sem erros no console e sem violação de CSP (script-src 'self', zero script inline); nenhuma requisição fora de /sapo/.
ParâmetroValorEfeito
physics.track.omega24 rad/sresposta ao cursor; maior = mais nervoso
physics.release.omega7 rad/svolta ao repouso; menor = mais cinematográfico
sharpMin0,62quão nítido um frame precisa ser para servir de descanso
restOutwardBias0,7<1 prefere "apontando" a "parado" longe do centro
idleDelay3.400 mstempo parado até a primeira olhada autônoma
maxCharacterShare / minSpan0,46 / 900 pxtamanho do personagem no desktop / no celular
motion / sharpnesstabelas medidasmude para outro clipe: refaça as medições da Etapa 2 e 6

Etapa 9

Erros encontrados e como foram corrigidos

SintomaCausaCorreção
Movimento aos saltos, "não está suave"7 frames por gesto; cross-fade entre poses distantesInterpolação a 96 fps (28 frames por gesto) e sheet em grade
Repintura pesada a cada quadrobackground-position numa imagem de 23 mil pxCanvas com um drawImage da célula
"Sombra" no braçoCross-fade desenhava o braço duas vezesblend: false: frame mais próximo
"Câmera lenta"Mola de tracking macia (omega 11)omega 24, zeta 0,92
Braço borrado ao pararMotion blur gravado no clipeTabela de nitidez + assentar no frame nítido mais próximo
Timer de descanso nunca disparavaChrome reinjeta pointermove quando a UI muda sob o cursorIgnorar eventos sem mudança de coordenada
Sapo por cima do texto em telas altasEscala cover sem tetoTeto de 46% da largura para o personagem
Sapo cortado no celularEscala cover em retratoMínimo de 900 px da fonte visíveis na largura
Frame de descanso "quase parado" com o cursor a 70%Escolha do frame nítido por índiceEscolha em espaço de pose com viés para fora

Etapa 10

Baixe o kit e reproduza

O kit tem tudo o que esta página descreve: o clipe original, as sheets e o poster, o script de build do FFmpeg, o motor completo e o código-fonte deste site (Vite). Descompacte, rode npm install e npm run dev. Os frames extraídos e interpolados vão num segundo zip, para quem quiser pular o minterpolate ou estudar frame a frame.

Baixar sapo-kit.zipclipe original, sheets, poster, código-fonte do site e scripts · ~7 MB

Baixar sapo-frames.zipos 97 frames extraídos (2206×946) e os 381 interpolados (1104×474), em JPEG · ~27 MB

Para usar com outro clipe: extraia os frames (Etapa 2), meça o centroide para achar o caminho contínuo esquerda → repouso → direita, interpole (Etapa 3), monte a sheet e as tabelas motion e sharpness (Etapas 4 e 6) e ajuste frames, idle, cols e aspect no CONFIG.

Ver tools/build-sprite.sh (54 linhas)
bash
#!/usr/bin/env bash
# Reconstrói frames/, frames-96fps/ e public/media/ a partir do clipe com o FFmpeg.
#
#   bash tools/build-sprite.sh [caminho/do/clipe.mp4] [caminho/do/ffmpeg]
#
# 1. Extrai todos os frames do clipe (97 frames, 2206x946, 24 fps) -> frames/
# 2. Interpola o clipe por movimento para 96 fps (4x) -> frames-96fps/ (381 frames)
# 3. Empacota os 67 frames interpolados do caminho contínuo
#    pico-esquerda > parado > pico-direita numa sheet em grade de 12 colunas,
#    em 2x (JPEG) e 1x (WebP), mais o poster do repouso.
#
# Por que 67 e não tudo: o clipe é  parado(0-17) > inclina-esquerda(18-24) >
# hold(25-42) > volta(43-49) > parado(50-60) > inclina-direita(61-67) > hold(68-82)
# > volta(83-89) > parado(90-96)  (frames-fonte 0-based). Fora de 41..50 e 60..69
# tudo é hold pixel-idêntico, e navegadores recusam imagens acima de ~16k-32k px
# de lado, então a sheet guarda só os frames que a interação consegue mostrar.
set -euo pipefail

CLIP="${1:-hf_20260816_110612_822e4603-8908-41a6-a79c-3431dac84151.mp4}"
FFMPEG="${2:-ffmpeg}"
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
cd "$ROOT"

mkdir -p frames frames-96fps public/media

echo "-> extraindo todos os frames-fonte"
"$FFMPEG" -hide_banner -loglevel error -i "$CLIP" -vsync 0 -pix_fmt rgb24 -y frames/frame_%03d.png
echo "   $(ls frames | wc -l) frames"

echo "-> interpolando por movimento para 96 fps (leva cerca de um minuto)"
"$FFMPEG" -hide_banner -loglevel error -i "$CLIP" \
  -vf "scale=1104:474:flags=lanczos,minterpolate=fps=96:mi_mode=mci:mc_mode=aobmc:me_mode=bidir:me=epzs:vsbmc=1:search_param=64" \
  -vsync 0 -pix_fmt rgb24 -y frames-96fps/f_%04d.png
echo "   $(ls frames-96fps | wc -l) frames"

# O arquivo interpolado do frame-fonte n (0-based) é 4n+1.
# Trecho esquerdo: arquivos 166..197 (fonte 41,25 .. 49); direito: 240..274 (fonte 59,75 .. 68,25).
LIST="$(mktemp)"
for i in $(seq 166 197) $(seq 240 274); do
  printf "file '%s/frames-96fps/f_%04d.png'\n" "$ROOT" "$i" >> "$LIST"
done

echo "-> empacotando as sheets de 67 frames (12 colunas)"
"$FFMPEG" -hide_banner -loglevel error -f concat -safe 0 -i "$LIST" \
  -vf "scale=960:412:flags=lanczos,tile=12x6" -frames:v 1 -q:v 3 -y public/media/sapo-sheet-2x.jpg
"$FFMPEG" -hide_banner -loglevel error -f concat -safe 0 -i "$LIST" \
  -vf "scale=552:237:flags=lanczos,tile=12x6" -frames:v 1 -c:v libwebp -quality 90 -y public/media/sapo-sheet-1x.webp
"$FFMPEG" -hide_banner -loglevel error -i frames/frame_056.png \
  -vf "scale=551:236:flags=lanczos" -c:v libwebp -quality 88 -y public/media/sapo-poster.webp
rm -f "$LIST"

ls -la public/media
echo "pronto"

Código-fonte

O motor completo

Abaixo está o arquivo sprite-hero.js exatamente como roda nesta página. Ele só depende do DOM: uma <section data-hero> com um palco ([data-stage]) contendo um <canvas> e um poster, mais os elementos opcionais do readout e dos painéis laterais.

Ver sprite-hero.js (438 linhas)
js
/* ============================================================================
   SAPO — hero com sprite de frames que segue o cursor
   JavaScript puro, sem dependências. Exporta mountSpriteHero(section).

   Pipeline: o FFmpeg extraiu os 97 frames do clipe de 4 s (24 fps) e depois
   interpolou o clipe por movimento para 96 fps (minterpolate, compensação
   bidirecional). A sheet guarda os 67 frames do caminho contínuo
   pico-esquerda > parado > pico-direita, numa grade de 12 colunas:

     índice  0 … 30   inclina / aponta para a ESQUERDA (0 = hold esquerdo)
     índice 31        parado, braços para cima
     índice 32 … 66   inclina / aponta para a DIREITA  (66 = hold direito)

   Uma "pose" p em [-1, 1] (-1 = esquerda, 0 = parado, +1 = direita) é
   invertida pela curva de deslocamento medida do personagem para virar um
   frame fracionário. A pose segue o cursor através de uma mola amortecida e
   o frame mais próximo é desenhado num canvas.
   ========================================================================== */
import { asset } from './lib/asset.js';

export const CONFIG = {
  frames: 67,
  idle: 31,
  cols: 12,
  aspect: 2206 / 946,      // composição original, nunca cortada na sheet
  minSpan: 900,            // mínimo de px da fonte visíveis na largura do hero (mantém o sapo inteiro no celular)
  maxCharacterShare: 0.46, // o personagem (780 de 2206 px da fonte) nunca passa desta fração da largura do hero
  poster: '/media/sapo-poster.webp',
  sources: {
    '1x': { src: '/media/sapo-sheet-1x.webp', cell: [552, 237] },
    '2x': { src: '/media/sapo-sheet-2x.jpg',  cell: [960, 412] },
  },
  // Molas: omega = frequência angular (rad/s), zeta = razão de amortecimento
  physics: {
    track:   { omega: 24,  zeta: 0.92 },   // gruda no cursor, sem atraso visível
    release: { omega: 7,   zeta: 1.00 },   // volta calma e criticamente amortecida ao repouso
    auto:    { omega: 8,   zeta: 0.95 },   // olhadas autônomas quando não há ninguém
    rest:    { omega: 10,  zeta: 1.00 },   // assentar no frame nítido mais próximo
  },
  idleDelay: 3400,         // ms sem input até o sapo começar a olhar sozinho
  curveExponent: 1.15,     // >1 deixa o centro do hero um pouco mais sensível que as bordas
  sideDeadZone: 0.12,      // |pose| abaixo disto não mostra nenhum painel lateral
  // Nitidez de cada frame da sheet (variância do Laplaciano, normalizada). O clipe
  // tem motion blur gravado nos frames do meio do gesto; quando o cursor para, a
  // pose assenta no frame nítido mais próximo (>= sharpMin) em vez de descansar
  // num braço borrado.
  sharpness: [0.77, 0.69, 0.74, 0.9, 0.46, 0.3, 0.29, 0.45, 0.25, 0.21, 0.24, 0.43, 0.24, 0.21, 0.21, 0.37, 0.21, 0.2, 0.26, 0.5, 0.36, 0.36, 0.45, 0.72, 0.47, 0.43, 0.51, 0.8, 0.67, 0.66, 0.77, 1.0, 0.92, 1.0, 0.55, 0.45, 0.4, 0.65, 0.36, 0.28, 0.26, 0.4, 0.22, 0.2, 0.24, 0.45, 0.26, 0.23, 0.3, 0.53, 0.29, 0.28, 0.31, 0.58, 0.41, 0.41, 0.49, 0.78, 0.53, 0.55, 0.58, 0.87, 0.64, 0.57, 0.65, 0.91, 0.81],
  sharpMin: 0.62,
  restDelay: 140,          // ms sem movimento do ponteiro até assentar num frame nítido
  restOutwardBias: 0.7,    // <1 prefere a pose "apontando" à parada quando o cursor está longe do centro
  blend: false,            // cross-fade entre células vizinhas. Desligado: com frames a 96 fps o frame mais
                           // próximo já é contínuo e a mistura duplicava o braço no meio do gesto ("sombra").
  // Deslocamento horizontal medido do personagem por frame da sheet (centroide),
  // normalizado 0..1 do repouso para fora. A animação acelera forte perto dos
  // holds; mapear pose em *deslocamento* em vez de índice mantém a resposta proporcional.
  motion: {
    left:  [0.0, 0.003, 0.006, 0.008, 0.011, 0.015, 0.022, 0.028, 0.03, 0.052, 0.09, 0.147, 0.163, 0.181, 0.257, 0.343, 0.359, 0.392, 0.435, 0.499, 0.539, 0.591, 0.662, 0.739, 0.798, 0.829, 0.909, 0.967, 0.991, 0.994, 0.995, 1.0],
    right: [0.0, 0.0, 0.0, 0.001, 0.023, 0.04, 0.048, 0.066, 0.112, 0.164, 0.186, 0.217, 0.294, 0.377, 0.392, 0.423, 0.496, 0.607, 0.638, 0.671, 0.736, 0.818, 0.856, 0.87, 0.896, 0.928, 0.937, 0.94, 0.958, 0.967, 0.97, 0.977, 0.982, 0.99, 0.996, 1.0],
  },
};

const clamp = (v, a, b) => Math.min(b, Math.max(a, v));

/* ------------------------------------------------------------------ Mola */
class Spring {
  constructor(value = 0) {
    this.x = value; this.v = 0; this.target = value;
    this.omega = 10; this.zeta = 1;
  }
  tune({ omega, zeta }) { this.omega = omega; this.zeta = zeta; }
  step(dt) {
    // sub-passos fixos mantêm o integrador estável com rigidez alta
    const n = Math.max(1, Math.ceil(dt / (1 / 240)));
    const h = dt / n;
    for (let i = 0; i < n; i++) {
      const a = -this.omega * this.omega * (this.x - this.target) - 2 * this.zeta * this.omega * this.v;
      this.v += a * h;
      this.x += this.v * h;
    }
  }
  get settled() { return Math.abs(this.x - this.target) < 0.0006 && Math.abs(this.v) < 0.003; }
  snap() { this.x = this.target; this.v = 0; }
}

/* ------------------------------------------------------- Renderer da sheet */
class SpriteSheet {
  constructor(canvas, frames, cols) {
    this.canvas = canvas;
    this.ctx = canvas.getContext('2d', { alpha: false });
    this.frames = frames;
    this.cols = cols;
    this.img = null;
    this.cell = [0, 0];
    this.loaded = null;
    this.last = -1;
    this.poster = null;
  }
  static decode(src) {
    const img = new Image();
    img.decoding = 'async';
    img.src = src;
    const ready = img.decode ? img.decode() : new Promise((res, rej) => { img.onload = res; img.onerror = rej; });
    return ready.then(() => { if (!img.naturalWidth) throw new Error('decode falhou: ' + src); return img; });
  }
  load(key) {
    const { src, cell } = CONFIG.sources[key];
    return SpriteSheet.decode(asset(src)).then((img) => {
      this.img = img;
      this.cell = cell;
      this.loaded = key;
      this.last = -1;
      return key;
    });
  }
  resize(cssW, cssH) {
    const dpr = Math.min(window.devicePixelRatio || 1, 2);
    const w = Math.round(cssW * dpr);
    const h = Math.round(cssH * dpr);
    if (this.canvas.width !== w || this.canvas.height !== h) {
      this.canvas.width = w;
      this.canvas.height = h;
      this.last = -1;
    }
  }
  drawPoster() {
    if (!this.poster) return;
    this.ctx.drawImage(this.poster, 0, 0, this.canvas.width, this.canvas.height);
  }
  blit(i, alpha) {
    const [cw, ch] = this.cell;
    const sx = (i % this.cols) * cw;
    const sy = Math.floor(i / this.cols) * ch;
    this.ctx.globalAlpha = alpha;
    this.ctx.drawImage(this.img, sx, sy, cw, ch, 0, 0, this.canvas.width, this.canvas.height);
  }
  // Frame fracionário -> célula i (e, com blend ligado, célula i+1 com alpha por cima).
  draw(frame) {
    if (!this.img) { this.drawPoster(); return false; }
    const f = CONFIG.blend ? clamp(frame, 0, this.frames - 1) : clamp(Math.round(frame), 0, this.frames - 1);
    if (Math.abs(f - this.last) < 0.001) return false;
    this.last = f;
    const i = Math.floor(f);
    const t = f - i;
    const ctx = this.ctx;
    ctx.imageSmoothingEnabled = true;
    ctx.imageSmoothingQuality = 'high';
    this.blit(i, 1);
    if (CONFIG.blend && t > 0.003 && i + 1 < this.frames) this.blit(i + 1, t);
    ctx.globalAlpha = 1;
    return true;
  }
}

/* ------------------------------------------------------------- Controlador */
export function mountSpriteHero(hero) {
  const stage = hero.querySelector('[data-stage]');
  const canvas = stage.querySelector('canvas');
  const posterImg = stage.querySelector('.stage__poster');
  const readoutFrame = hero.querySelector('[data-readout="frame"]');
  const readoutPose = hero.querySelector('[data-readout="pose"]');
  const readoutBar = hero.querySelector('[data-readout="bar"]');
  const readoutMode = hero.querySelector('[data-readout="mode"]');

  const reducedMotion = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
  const saveData = !!(navigator.connection && navigator.connection.saveData);

  const sheet = new SpriteSheet(canvas, CONFIG.frames, CONFIG.cols);
  const spring = new Spring(0);
  spring.tune(CONFIG.physics.release);

  let mode = 'idle';          // 'track' | 'release' | 'auto' | 'idle'
  let pressed = false;        // contato de toque / caneta
  let autoTimer = 0;
  let lastSide = 1;
  let rafId = 0;
  let lastTime = 0;
  let lastMove = 0;           // quando o ponteiro se moveu pela última vez (modo track)
  let cursorPose = 0;         // pose crua pedida pelo cursor
  let rested = false;         // alvo já assentado num frame nítido
  let side = '';

  // pose -> frame fracionário da sheet, invertendo a curva de movimento medida:
  // |p| = 0.5 é "metade da inclinação", não "metade dos frames".
  function poseToFrame(p) {
    const arr = p < 0 ? CONFIG.motion.left : CONFIG.motion.right;
    const dir = p < 0 ? -1 : 1;
    const m = Math.min(1, Math.abs(p));
    let k = 0;
    while (k < arr.length - 2 && arr[k + 1] < m) k++;
    const span = arr[k + 1] - arr[k];
    const frac = span > 0 ? clamp((m - arr[k]) / span, 0, 1) : 0;
    return CONFIG.idle + dir * (k + frac);
  }

  // índice de frame -> pose (inverso de poseToFrame em frames inteiros)
  function frameToPose(i) {
    if (i === CONFIG.idle) return 0;
    return i < CONFIG.idle ? -CONFIG.motion.left[CONFIG.idle - i] : CONFIG.motion.right[i - CONFIG.idle];
  }

  // Frame nítido cuja pose é a mais próxima da pedida. A distância é medida em
  // espaço de pose e frames mais para fora que o cursor são favorecidos (peso < 1):
  // com o cursor além do meio do hero, o sapo deve assentar "apontando", não parado.
  function nearestSharp(pose) {
    const s = CONFIG.sharpness;
    let best = CONFIG.idle, bestD = Infinity;
    for (let i = 0; i < s.length; i++) {
      if (s[i] < CONFIG.sharpMin) continue;
      const fp = frameToPose(i);
      if (Math.sign(fp) !== 0 && Math.sign(fp) !== Math.sign(pose)) continue;   // nunca cruzar para o outro lado
      let d = Math.abs(fp - pose);
      if (Math.abs(fp) > Math.abs(pose)) d *= CONFIG.restOutwardBias;
      if (d < bestD) { bestD = d; best = i; }
    }
    return best;
  }

  // x do cursor (relativo ao centro do hero) -> pose em [-1, 1]
  function poseFromX(clientX) {
    const r = hero.getBoundingClientRect();
    const nx = clamp((clientX - (r.left + r.width / 2)) / (r.width / 2), -1, 1);
    const eased = 1 - Math.pow(1 - Math.abs(nx), CONFIG.curveExponent);
    return Math.sign(nx) * eased;
  }

  function setMode(next) {
    if (mode === next) return;
    mode = next;
    hero.dataset.mode = next;
    if (readoutMode) readoutMode.textContent = readoutMode.dataset[next] || next;
  }

  // Qual painel lateral mostrar: o texto acompanha para onde o sapo aponta.
  function updateSide(p) {
    const next = p < -CONFIG.sideDeadZone ? 'left' : p > CONFIG.sideDeadZone ? 'right' : '';
    if (next !== side) { side = next; hero.dataset.side = next; }
  }

  function wake() {
    if (!rafId) { lastTime = performance.now(); rafId = requestAnimationFrame(tick); }
  }

  function tick(now) {
    const dt = clamp((now - lastTime) / 1000, 0, 1 / 20);
    lastTime = now;
    // Ponteiro parou enquanto o sapo descansaria num frame borrado: assenta na pose nítida mais próxima.
    if (mode === 'track' && !rested && now - lastMove > CONFIG.restDelay) {
      rested = true;
      spring.target = frameToPose(nearestSharp(cursorPose));
      spring.tune(CONFIG.physics.rest);
    }
    spring.step(dt);
    render();
    if (spring.settled && (mode !== 'track' || rested)) {
      spring.snap();
      render();
      rafId = 0;                       // dorme até o próximo input ou timer
      if (mode === 'release') setMode('idle');
      return;
    }
    rafId = requestAnimationFrame(tick);
  }

  let lastReadout = '';
  function render() {
    const p = clamp(spring.x, -1, 1);
    updateSide(p);
    const frame = poseToFrame(p);
    sheet.draw(frame);
    if (readoutFrame) {
      const text = frame.toFixed(1).padStart(4, '0');
      if (text !== lastReadout) {
        lastReadout = text;
        readoutFrame.textContent = text;
        readoutPose.textContent = (p > -0.005 ? '+' : '-') + Math.abs(p).toFixed(2);
        readoutBar.style.left = `${(50 + p * 50).toFixed(2)}%`;
      }
    }
  }

  /* ------------------------------------------------ Olhadas autônomas */
  function cancelAuto() { clearTimeout(autoTimer); autoTimer = 0; }

  function scheduleGlance(delay) {
    cancelAuto();
    if (reducedMotion) return;
    autoTimer = setTimeout(glance, delay);
  }

  function glance() {
    if (mode === 'track') return;
    setMode('auto');
    spring.tune(CONFIG.physics.auto);
    lastSide = -lastSide;
    spring.target = lastSide * (0.55 + Math.random() * 0.45);
    wake();
    autoTimer = setTimeout(() => {
      if (mode !== 'auto') return;
      spring.target = 0;
      wake();
      scheduleGlance(3800 + Math.random() * 4200);
    }, 900 + Math.random() * 800);
  }

  /* ------------------------------------------------------ Entrada de ponteiro */
  let lastX = NaN, lastY = NaN;
  function track(clientX, clientY) {
    // Navegadores redisparam pointermove nas mesmas coordenadas sempre que o elemento
    // sob um cursor parado muda (painéis aparecendo, readout mudando). Isso não é
    // movimento: ignorar para o timer de descanso conseguir correr.
    if (clientX === lastX && clientY === lastY) return;
    lastX = clientX; lastY = clientY;
    cancelAuto();
    setMode('track');
    spring.tune(CONFIG.physics.track);
    cursorPose = poseFromX(clientX);
    spring.target = cursorPose;
    lastMove = performance.now();
    rested = false;
    wake();
  }

  function release() {
    pressed = false;
    lastX = NaN; lastY = NaN;
    setMode('release');
    spring.tune(reducedMotion ? { omega: 30, zeta: 1 } : CONFIG.physics.release);
    spring.target = 0;
    wake();
    scheduleGlance(CONFIG.idleDelay);
  }

  hero.addEventListener('pointerenter', (e) => { if (e.pointerType === 'mouse') track(e.clientX, e.clientY); });
  hero.addEventListener('pointermove', (e) => {
    if (e.pointerType === 'mouse' || pressed) track(e.clientX, e.clientY);
  });
  hero.addEventListener('pointerdown', (e) => {
    if (e.pointerType === 'mouse') return;
    pressed = true;
    track(e.clientX, e.clientY);
  });
  hero.addEventListener('pointerleave', (e) => { if (e.pointerType === 'mouse') release(); });
  window.addEventListener('pointerup', () => { if (pressed) release(); });
  window.addEventListener('pointercancel', () => { if (pressed) release(); });
  document.addEventListener('visibilitychange', () => {
    if (document.hidden) cancelAuto();
    else if (mode !== 'track') scheduleGlance(1200);
  });

  /* -------------------------------------------------------------- Layout */
  // O palco mantém a composição original 2206x946 e cobre o hero, mas nunca
  // amplia além do ponto em que o personagem seria cortado ou colidiria com o texto.
  function layout() {
    const r = hero.getBoundingClientRect();
    const cover = Math.max(r.width, r.height * CONFIG.aspect);
    const spanCap = r.width * 2206 / CONFIG.minSpan;                       // celular: mantém o sapo inteiro
    const shareCap = r.width * CONFIG.maxCharacterShare * 2206 / 780;      // desktop alto: longe do texto
    const w = Math.min(cover, spanCap, r.width > 860 ? shareCap : Infinity);
    const h = w / CONFIG.aspect;
    stage.style.width = `${w}px`;
    stage.style.height = `${h}px`;
    sheet.resize(w, h);
    sheet.last = -1;
    render();
    if (!sheet.img) sheet.drawPoster();
    maybeUpgrade(w);
  }

  let upgrading = false;
  function pickSource(stageWidth) {
    const need = stageWidth * Math.min(window.devicePixelRatio || 1, 2);
    return need > 720 ? '2x' : '1x';
  }
  function maybeUpgrade(stageWidth) {
    if (saveData || upgrading || sheet.loaded !== '1x' || pickSource(stageWidth) !== '2x') return;
    upgrading = true;
    sheet.load('2x')
      .then(() => { sheet.last = -1; render(); })
      .catch(() => {})
      .finally(() => { upgrading = false; });
  }

  let resizeRaf = 0;
  window.addEventListener('resize', () => {
    cancelAnimationFrame(resizeRaf);
    resizeRaf = requestAnimationFrame(layout);
  });
  layout();

  /* ---------------------------------------------------------------- Boot */
  // 1. poster (7 KB) no canvas: primeira pintura imediata.
  // 2. sheet 1x (0,5 MB) quando a página estiver ociosa; 3. sheet 2x se a tela pedir.
  // Com economia de dados, fica só o poster (hero estático, painéis sempre visíveis).
  const whenIdle = (fn) => ('requestIdleCallback' in window ? requestIdleCallback(fn, { timeout: 1500 }) : setTimeout(fn, 300));
  const start = () => {
    if (saveData) { hero.classList.add('is-ready', 'is-static'); return; }
    whenIdle(() => {
      sheet.load('1x')
        .then(() => {
          sheet.last = -1; render();
          hero.classList.add('is-ready');
          if (posterImg) posterImg.hidden = true;
          maybeUpgrade(stage.getBoundingClientRect().width);
          if (reducedMotion || mode === 'track') return;
          // Intro: uma olhada rápida para cada lado avisa que o personagem está vivo.
          setTimeout(() => {
            if (mode === 'track') return;
            setMode('auto');
            spring.tune(CONFIG.physics.auto);
            spring.target = 0.9; wake();
            autoTimer = setTimeout(() => {
              if (mode !== 'auto') return;
              spring.target = -0.9; wake();
              autoTimer = setTimeout(() => {
                if (mode !== 'auto') return;
                spring.target = 0; wake();
                scheduleGlance(4500);
              }, 1000);
            }, 900);
          }, 700);
        })
        .catch((err) => { console.error('[sapo-hero]', err); hero.classList.add('is-ready', 'is-static'); });
    });
  };
  SpriteSheet.decode(asset(CONFIG.poster))
    .then((img) => { sheet.poster = img; sheet.drawPoster(); })
    .catch(() => {})
    .finally(start);

  // Handle de depuração / ajuste
  window.SapoHero = {
    config: CONFIG, spring, sheet,
    setPose(p) { cancelAuto(); setMode('track'); spring.tune(CONFIG.physics.track); spring.target = clamp(p, -1, 1); wake(); },
    trackX(x, y = 0) { track(x, y); },
    release,
  };
}