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.

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)
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):
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"
o efeito não está suaveCausa: 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"
deixe o site em portugues tbResultado: 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 ?"
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"
quando eu mexo o mouse faz uma sombra , parece que está em camera lentaSombra: 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
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
o braço ainda fica borrado, dá pra melhorar? outro ponto eu gostaria que no computador as frases aparecessem do lado do sapoO 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
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 portuguesResultado: 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):
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:
# 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.pngAntes 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:
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):
| Frames | O que o Sapo faz | Centroide x |
|---|---|---|
| 0 – 17 | Parado, braços para cima (hold) | 1105 |
| 18 – 24 | Inclina e aponta para a esquerda | 1105 → 1013 |
| 25 – 42 | Segura a pose à esquerda (hold) | 1010 |
| 43 – 49 | Volta ao centro | 1030 → 1104 |
| 50 – 60 | Parado (hold) | 1105 |
| 61 – 67 | Inclina e aponta para a direita | 1109 → 1208 |
| 68 – 82 | Segura a pose à direita (hold) | 1214 |
| 83 – 89 | Volta ao centro | 1194 → 1106 |
| 90 – 96 | Parado (hold) | 1105 |

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.
# 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
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.
# 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
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.
// 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.
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.
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 navegador | Valor |
|---|---|
| Tempo por quadro durante uma varredura completa | mediana 16,7 ms · p95 16,8 ms (60 fps cravados) |
| Cursor de -1 a +1: fração percorrida em 120 ms | ~90% |
| Primeira pintura | poster 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.

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.
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// 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;
}
| Cursor (fração da largura) | Onde o Sapo assenta | Nitidez 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.
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.sideviraleft,rightou 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 emsoft-lightunifica 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
blendestá 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-motiondesliga intro e olhadas;saveDatadeixa 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âmetro | Valor | Efeito |
|---|---|---|
physics.track.omega | 24 rad/s | resposta ao cursor; maior = mais nervoso |
physics.release.omega | 7 rad/s | volta ao repouso; menor = mais cinematográfico |
sharpMin | 0,62 | quão nítido um frame precisa ser para servir de descanso |
restOutwardBias | 0,7 | <1 prefere "apontando" a "parado" longe do centro |
idleDelay | 3.400 ms | tempo parado até a primeira olhada autônoma |
maxCharacterShare / minSpan | 0,46 / 900 px | tamanho do personagem no desktop / no celular |
motion / sharpness | tabelas medidas | mude para outro clipe: refaça as medições da Etapa 2 e 6 |
Etapa 9
Erros encontrados e como foram corrigidos
| Sintoma | Causa | Correção |
|---|---|---|
| Movimento aos saltos, "não está suave" | 7 frames por gesto; cross-fade entre poses distantes | Interpolação a 96 fps (28 frames por gesto) e sheet em grade |
| Repintura pesada a cada quadro | background-position numa imagem de 23 mil px | Canvas com um drawImage da célula |
| "Sombra" no braço | Cross-fade desenhava o braço duas vezes | blend: false: frame mais próximo |
| "Câmera lenta" | Mola de tracking macia (omega 11) | omega 24, zeta 0,92 |
| Braço borrado ao parar | Motion blur gravado no clipe | Tabela de nitidez + assentar no frame nítido mais próximo |
| Timer de descanso nunca disparava | Chrome reinjeta pointermove quando a UI muda sob o cursor | Ignorar eventos sem mudança de coordenada |
| Sapo por cima do texto em telas altas | Escala cover sem teto | Teto de 46% da largura para o personagem |
| Sapo cortado no celular | Escala cover em retrato | Mí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 índice | Escolha 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)
#!/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)
/* ============================================================================
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,
};
}