Raio-X do projeto
Como o filhote passou a olhar para o cursor.
Este é o passo a passo completo, com os comandos, as decisões e os erros que apareceram no caminho. Seguindo do início ao fim, você reproduz a mesma hero: vídeo em loop no fundo, personagem que acompanha o mouse e volta ao vídeo quando você para.
- 10 s
- clipe de entrada, 1280×720, 24 fps
- 240
- frames extraídos e usados na sprite
- 0
- bibliotecas: JavaScript puro e canvas
- ~1,1 s
- para assentar de uma ponta à outra
Etapa 0
O que entra: um clipe e uma imagem
O ponto de partida é um clipe gerado por IA de 10 segundos (1280×720, 24 fps, H.264, 240 frames) em que um filhote sentado numa almofada segura um biscoito, vira a cabeça para os lados, olha para cima e mordisca o biscoito. A imagem de referência é o primeiro frame: filhote de frente, língua de fora.
Tudo o que acontece na hero usa somente esses frames. Nenhuma pose é gerada ou desenhada: quando o personagem olha para o cursor, você está vendo um frame real do vídeo.

Etapa 1
Extrair todos os frames e montar a sprite em grade
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:
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 240 frames em resolução original:
# Duração, resolução e fps
ffmpeg -hide_banner -i clipe.mp4
# Todos os frames (24 fps × 10 s = 240), JPEG quase sem perdas
mkdir frames
ffmpeg -i clipe.mp4 -qscale:v 2 frames/frame_%03d.jpgA sprite sheet reúne todos os 240 frames numa única imagem, na taxa original. Ela fica em grade de 60 colunas por 4 linhas, e não em tira única, por um motivo concreto: navegadores não decodificam imagens com mais de cerca de 32.767 px de largura. Em uma linha só, 240 frames caberiam com 136 px cada, inutilizáveis. Em 4 linhas, cabem a 384×216.
# Sprite: 240 frames, 384x216 cada, grade 60x4 → 23040x864 px (~20 MP, ~2,8 MB)
ffmpeg -i clipe.mp4 -vf "scale=384:216:flags=lanczos,tile=60x4" -frames:v 1 -qscale:v 3 puppy-sprite.jpg
# Poster do frame de repouso (t = 4,0 s → frame 97) em 1280x720
ffmpeg -i frames/frame_097.jpg -qscale:v 4 puppy-poster.jpg
# Cópia do vídeo sem áudio, otimizada para web
ffmpeg -i clipe.mp4 -an -c:v copy -movflags +faststart puppy-loop.mp4
Etapa 2
Analisar os frames antes de programar
Esta é a etapa que separa um scrub de um personagem que olha para você. Um mapeamento linear do mouse para a linha do tempo faria o filhote passar por todas as poses em qualquer ordem. Em vez disso, monte grades densas e anote em que instantes ele olha para cada lado.
# Grade densa: 3 miniaturas por segundo, 6 colunas x 5 linhas
ffmpeg -i clipe.mp4 -vf "fps=3,scale=400:-1,tile=6x5" -frames:v 1 grade-densa.jpg
Para confirmar as direções, recorte os frames-chave em tamanho maior. Um script Python de dez linhas resolve:
from PIL import Image, ImageDraw
idx = [1, 25, 41, 57, 73, 97, 121, 137, 145, 161, 201, 217] # números de frame (1-based)
tiles = []
for i in idx:
im = Image.open(f"frames/frame_{i:03d}.jpg").crop((320, 0, 960, 720)).resize((320, 360))
ImageDraw.Draw(im).text((4, 4), f"f{i} t={(i-1)/24:.2f}s", fill="white")
tiles.append(im)
sheet = Image.new("RGB", (320 * 6, 360 * 2))
for k, t in enumerate(tiles):
sheet.paste(t, ((k % 6) * 320, (k // 6) * 360))
sheet.save("frames-chave.jpg", quality=90)
| Instante | O que o filhote faz | Para onde o cursor deve estar |
|---|---|---|
| 0,0 s | De frente, feliz, língua de fora | Imagem de referência (não usada como repouso) |
| 1,0 – 2,3 s | Cabeça virada para a esquerda da tela | Cursor à esquerda |
| 4,0 s | De frente, sorriso calmo | Cursor no centro · pose de repouso |
| 5,0 – 5,7 s | Cabeça virada para a direita da tela | Cursor à direita |
| 6,0 – 6,7 s | Olhar para cima | Cursor na faixa superior |
| 8,0 – 8,6 s | Mordisca o biscoito | Cursor sobre o biscoito |
| 9,0 – 10 s | De frente, boca aberta | Só no vídeo em loop |
Repare que a sequência esquerda → frente → direita (1,0 s → 4,0 s → 5,7 s) está em ordem cronológica. Isso é ouro: quando o alvo muda de uma ponta à outra, o motor atravessa poses intermediárias reais, e a transição parece o personagem se movendo, não um corte.
// Mapa de olhar: posição do cursor (0..1 na composição) → instante do clipe (s)
const REST_T = 4.0; // de frente
const SWEEP = [ // eixo x, em ordem cronológica
[0.00, 1.05], [0.18, 1.70], [0.32, 2.35], // esquerda
[0.50, 4.00], // frente
[0.70, 5.00], [1.00, 5.70], // direita
];
const UP_ZONE = { maxY: 0.12, tFrom: 6.65, tTo: 6.00 };
const BONE_ZONE = { x0: 0.37, x1: 0.63, y0: 0.50, y1: 0.84, tFrom: 8.00, tTo: 8.62 };
function gazeTime(x, y) {
if (x >= BONE_ZONE.x0 && x <= BONE_ZONE.x1 && y >= BONE_ZONE.y0 && y <= BONE_ZONE.y1) {
const u = (y - BONE_ZONE.y0) / (BONE_ZONE.y1 - BONE_ZONE.y0);
return lerp(BONE_ZONE.tFrom, BONE_ZONE.tTo, u);
}
if (y <= UP_ZONE.maxY) return lerp(UP_ZONE.tFrom, UP_ZONE.tTo, x);
return piecewise(SWEEP, x); // interpolação smoothstep entre keypoints
}Etapa 3
O motor: canvas, cross-fade e easing
O desenho acontece num <canvas> em alta densidade (DPR limitado a 2). O tempo é um número fracionário; a fração entre dois frames vizinhos vira globalAlpha do segundo frame, o que dá a interpolação visual.
function draw(time) {
const idx = clamp(time * FPS, 0, FRAMES - 1);
const a = Math.floor(idx), b = Math.min(a + 1, FRAMES - 1), f = idx - a;
blit(a); // frame inferior
if (f > 0.004 && b !== a) { // fração → cross-fade com o vizinho
ctx.globalAlpha = f; blit(b); ctx.globalAlpha = 1;
}
}
function blit(i) { // grade: coluna = i % 60, linha = ⌊i / 60⌋
const sx = (i % COLS) * FW, sy = Math.floor(i / COLS) * FH;
ctx.drawImage(sprite, sx, sy, FW, FH, dest.x, dest.y, dest.w, dest.h);
}O easing é um amortecimento exponencial dependente de dt: a cada quadro, o tempo atual se aproxima do alvo por uma fração que não depende da taxa de quadros do monitor. Uma velocidade máxima evita strobing quando o alvo pula de uma ponta à outra. Não há overshoot.
const FOLLOW_RATE = 9.0; // 1/s — quanto maior, mais rápido assenta
const MAX_SPEED = 6.0; // segundos de clipe por segundo real
function tick(ts) {
requestAnimationFrame(tick);
const dt = Math.min((ts - last) / 1000, 0.1); last = ts;
const k = 1 - Math.exp(-FOLLOW_RATE * dt);
let step = (target - current) * k;
step = Math.max(-MAX_SPEED * dt, Math.min(MAX_SPEED * dt, step));
current += step;
draw(current);
}| Trajeto | Tempo para assentar (60 fps) | Overshoot |
|---|---|---|
| Esquerda → direita (1,05 s → 5,7 s) | 1,07 s | não |
| Cima → biscoito (6,3 s → 8,4 s) | 0,63 s | não |
| Ajuste fino (4,0 s → 4,3 s) | 0,32 s | não |
Etapa 4
Vídeo em loop e a troca sem emenda
Em repouso, quem aparece é o vídeo original, mudo e em loop, na resolução completa. A sprite só entra quando o cursor se move. Como os dois compartilham a mesma linha do tempo de 10 s, a troca é uma troca de fonte no mesmo instante.
- Cursor se move → pause o vídeo, leia
currentTime, quantize ao frame (Math.round(t × 24) / 24), desenhe esse frame no canvas e mostre o canvas em ~70 ms. Ele já exibe a mesma imagem do vídeo, por isso não há sobreposição de poses. Só então o alvo passa a seguir o cursor. - Cursor parado por 900 ms ou fora da área → congele o alvo, faça
video.currentTime = tempoDaSprite, espereseeked(com timeout de 600 ms), dêplay()e esconda o canvas em ~220 ms. - Cursor voltou a se mover durante a retomada → cancele a retomada (um token incremental resolve) e volte a seguir.
function enterFollow() {
resumeToken++; // cancela retomada pendente
if (mode === 'video' || mode === 'resume') {
if (mode === 'video') current = clamp(video.currentTime, 0, MAX_T);
video.pause();
current = Math.round(current * FPS) / FPS; // frame exato do vídeo
target = current;
draw(current); hero.classList.add('is-scrubbing');
}
mode = 'follow';
}
function resumePlayback() {
mode = 'resume'; target = current;
const token = ++resumeToken;
const go = () => {
if (token !== resumeToken) return;
video.removeEventListener('seeked', go);
video.play().then(() => { if (token === resumeToken) { mode = 'video'; hero.classList.remove('is-scrubbing'); } });
};
video.addEventListener('seeked', go);
video.currentTime = clamp(current, 0, video.duration - 0.05);
setTimeout(go, 600);
}Se o autoplay for bloqueado ou o vídeo falhar, a própria sprite toca em loop em tempo real. Com prefers-reduced-motion ou saveData, nada disso carrega: fica o poster.
Etapa 5
Composição, retrato e o que fica em volta
Em paisagem, o quadro 16:9 preenche a seção com corte cover simétrico: o personagem continua no centro. O <video> é posicionado por JavaScript exatamente no retângulo que o canvas usa, para vídeo e sprite ficarem alinhados pixel a pixel.
Em retrato, o corte cover deixaria o filhote gigante com a cabeça cortada. A composição fica ancorada no topo com 66% da altura, o texto desce para o fundo escuro, e o eixo x do cursor passa a cobrir a largura visível. Sem isso, as poses laterais ficam inalcançáveis no celular.
const portrait = h > w * 1.05;
if (portrait) { dh = h * 0.66; dw = dh * ASPECT; if (dw < w) { dw = w; dh = dw / ASPECT; } dy = 0; }
else { dw = w; dh = w / ASPECT; if (dh < h) { dh = h; dw = h * ASPECT; } dy = (h - dh) / 2; }
dest = { x: (w - dw) / 2, y: dy, w: dw, h: dh };
// cursor → coordenadas da composição
const x = portrait ? px / w : (px - dest.x) / dest.w;
const y = (py - dest.y) / dest.h;- Coluna de texto estreita (≈ 31vw, máx. 520 px) no canto inferior esquerdo, para não cobrir o personagem.
- Serifada Fraunces para o título, Inter para o corpo, ambas self-hosted (sem CDN). Paleta quente tirada do próprio quadro.
- Gradientes de leitura só nas bordas; o centro fica limpo. Cursor em anel e uma luz suave que acompanha o ponteiro, ambos discretos.
- Poster do frame de repouso como fundo enquanto a sprite carrega; a sprite (2,8 MB) só é baixada depois que a página está pronta.
Etapa 6
Como saber que ficou bom
- Vídeo em loop ao abrir; mover o mouse pausa e o canvas assume no mesmo frame; parar retoma o vídeo do ponto exato.
- Cursor na borda esquerda chega à pose de cabeça virada; na direita, idem; topo faz o olhar subir; sobre o biscoito, a mordida.
- Nenhuma dupla exposição ao virar a cabeça (se houver, a sprite tem poucos frames).
- Sem erros no console e sem violação de CSP; nenhuma requisição fora da base do site.
- Larguras 390, 768, 1440 e 2560 px; texto legível sem JavaScript;
prefers-reduced-motionmostra o poster. curl -sI -H "Range: bytes=0-99"no MP4 devolve206.
| Parâmetro | Valor | Efeito |
|---|---|---|
FOLLOW_RATE | 9 /s | resposta ao cursor; maior = mais nervoso |
MAX_SPEED | 6 s/s | limite de varredura; menor = mais cinematográfico |
IDLE_MS | 900 ms | tempo parado até o vídeo retomar |
SWEEP / UP_ZONE / BONE_ZONE | instantes do clipe | o mapa de olhar: mude para outro vídeo |
Reproduzir com IA
Os dois prompts que geram este resultado
São duas etapas com IA, nesta ordem: primeiro o vídeo nasce de uma imagem no Google Flow; depois o Claude transforma esse vídeo na hero interativa. Os dois prompts estão abaixo exatamente como foram usados.
1. Gerar o vídeo no Google Flow
Abra o Google Flow, adicione a imagem do personagem como imagem inicial e cole o prompt abaixo. O que importa nele: câmera travada, nada de elementos novos no quadro, olhar percorrendo todas as direções com pausas, e primeiro e último frame idênticos para o loop fechar. O clipe deste projeto saiu com 10 s, 1280×720, 24 fps.
Trate a imagem enviada como uma referência rígida. Mantenha o personagem, a expressão, as proporções, a iluminação, o ângulo da câmera, o enquadramento e o fundo exatamente como mostrado. A câmera deve permanecer totalmente travada: sem zoom, pan, rotação, reenquadramento ou qualquer mudança de cena em nenhum momento.
Anime apenas o personagem. Nunca adicione cursor, ponteiro, ponto, inseto, brinquedo, mão, sombra, partícula ou qualquer outro elemento novo no quadro.
O personagem deve reagir como se algo estivesse flutuando ao seu redor, percorrendo 360 graus completos da sua atenção. Os olhos iniciam o movimento, e a cabeça acompanha um instante depois. As orelhas giram sutilmente, o pescoço se move de forma natural, e os bigodes apresentam um leve movimento secundário.
Faça com que ele olhe para a extrema esquerda, depois para a parte inferior esquerda, depois para a parte superior esquerda, retornando suavemente pelo centro; em seguida para a extrema direita, parte inferior direita, parte superior direita, e por fim volte exatamente para a pose inicial e coma um pedaço do biscoito (isso deve parecer real). Inclua pequenas pausas antes de cada mudança de direção, um leve excesso natural de movimento (overshoot) e transições suaves com desaceleração/aceleração fluida (easing) em todos os movimentos.
O corpo, os ombros, o peito, as patas e a postura sentada devem permanecer quase totalmente imóveis. Não faça alongamento, deformação facial, giros, rotações exageradas, respiração visível ou movimento de bombeamento do tronco. O primeiro e o último frame devem ser idênticos, para que o clipe possa ser reproduzido em loop de forma limpa, quadro a quadro.2. Criar a hero com o Claude
Envie o vídeo gerado junto com o prompt abaixo. A primeira versão deste prompt pedia uma tira horizontal e um scrub linear; este é o texto ajustado depois do resultado final, com as quatro mudanças que fizeram diferença: sprite em grade com todos os frames, vídeo em loop com troca no frame exato, mapa de olhar a partir da análise dos frames e teste no navegador.
Crie uma hero section interativa a partir da animação enviada.
Use FFmpeg para extrair todos os frames do clipe e reuni-los em uma única sprite sheet em grade (todos os frames, na taxa original do vídeo, distribuídos em várias linhas para respeitar o limite de largura dos navegadores). Depois, crie um sistema de animação baseado em frames usando JavaScript puro (vanilla JavaScript), desenhando em canvas com cross-fade entre frames vizinhos.
Coloque o vídeo original em loop no fundo. Quando o mouse se mover, pause o vídeo e faça a sprite assumir exatamente no mesmo frame, sem sobreposição; quando o mouse parar ou sair da área, o vídeo retoma a partir da pose em que o personagem ficou. Carregue o vídeo como Blob para o seek ser preciso.
Faça o personagem olhar para o cursor de verdade: antes de programar, analise os frames e identifique em que instantes ele olha para a esquerda, direita, frente, cima e para o objeto que segura. Monte um mapa cursor → instante do clipe com esses pontos, e percorra a sprite com interpolação com easing (amortecimento dependente de dt, velocidade máxima limitada).
Mantenha a composição original, preserve o personagem centralizado em paisagem e em retrato, e faça toda a interação transmitir uma sensação premium, fluida e responsiva. Teste no navegador antes de entregar.Código-fonte
O motor completo
Abaixo está o arquivo sprite-hero.js exatamente como roda nesta página. Ele não depende de nada além do DOM: um <section data-sprite-hero> com um <video> e um <canvas> dentro.
Ver sprite-hero.js (426 linhas)
/* ============================================================
Sprite Hero — vídeo em loop + personagem que acompanha o cursor
Vanilla JS, sem dependências. Programa IMERSO (/pipoca-cia/).
Modos
- 'static' : poster fixo (prefers-reduced-motion, saveData ou sem mídia).
- 'video' : o clipe original toca em loop no fundo (canvas oculto).
- 'follow' : o ponteiro se move → o vídeo pausa no frame exato, o canvas
da sprite assume desse mesmo frame e o personagem persegue
o alvo definido pelo mapa de olhar.
- 'resume' : o ponteiro parou (ou saiu) → o vídeo é posicionado na pose
em que a sprite ficou e volta a tocar; o canvas some em fade.
- 'sprite-play' : fallback quando o vídeo não pode tocar (autoplay
bloqueado, erro de mídia): a própria sprite toca em loop.
Sprite e vídeo compartilham a mesma linha do tempo (0–10 s), então o
handoff entre eles é só uma troca de fonte no mesmo instante.
============================================================ */
import { asset } from './lib/asset.js';
export function mountSpriteHero(hero) {
// ---------- Configuração da sprite sheet ----------
// Todos os 240 frames do clipe (24 fps), em grade de 60 colunas x 4 linhas.
const SPRITE = {
src: asset('/media/puppy-sprite.jpg'),
frames: 240,
fps: 24,
cols: 60,
frameW: 384,
frameH: 216,
};
const VIDEO_SRC = asset('/media/puppy-loop.mp4');
const CLIP_DURATION = SPRITE.frames / SPRITE.fps; // 10 s
const SPRITE_MAX_T = (SPRITE.frames - 1) / SPRITE.fps;
const ASPECT = SPRITE.frameW / SPRITE.frameH; // 16:9
// ---------- Mapa de olhar (cursor → tempo no clipe) ----------
const REST_T = 4.0; // pose frontal (fallback sem vídeo)
const SWEEP = [
[0.00, 1.05], // cabeça virada para a esquerda da tela
[0.18, 1.70],
[0.32, 2.35],
[0.50, 4.00], // de frente
[0.70, 5.00], // cabeça virada para a direita da tela
[1.00, 5.70],
];
const UP_ZONE = { maxY: 0.12, tFrom: 6.65, tTo: 6.00 };
const BONE_ZONE = { x0: 0.37, x1: 0.63, y0: 0.50, y1: 0.84, tFrom: 8.00, tTo: 8.62 };
// ---------- Dinâmica ----------
const FOLLOW_RATE = 9.0; // 1/s — resposta ao cursor
const MAX_SPEED = 6.0; // s de clipe por s real (evita strobing)
const IDLE_MS = 900; // ponteiro parado por este tempo → vídeo retoma
const SEEK_TIMEOUT_MS = 600; // espera máxima pelo 'seeked' antes de tocar
const PLAYBACK_CAP_S = 120; // teto de reprodução contínua sem interação
// ---------- Utilidades ----------
const clamp = (v, a, b) => (v < a ? a : v > b ? b : v);
const lerp = (a, b, t) => a + (b - a) * t;
function piecewise(points, x) {
if (x <= points[0][0]) return points[0][1];
for (let i = 1; i < points.length; i++) {
const x1 = points[i][0], y1 = points[i][1];
if (x <= x1) {
const x0 = points[i - 1][0], y0 = points[i - 1][1];
const u = (x - x0) / (x1 - x0);
const s = u * u * (3 - 2 * u); // smoothstep
return lerp(y0, y1, s);
}
}
return points[points.length - 1][1];
}
function gazeTime(cx, cy) {
const x = clamp(cx, 0, 1);
const y = clamp(cy, 0, 1);
if (x >= BONE_ZONE.x0 && x <= BONE_ZONE.x1 && y >= BONE_ZONE.y0 && y <= BONE_ZONE.y1) {
const u = (y - BONE_ZONE.y0) / (BONE_ZONE.y1 - BONE_ZONE.y0);
return lerp(BONE_ZONE.tFrom, BONE_ZONE.tTo, u);
}
if (y <= UP_ZONE.maxY) return lerp(UP_ZONE.tFrom, UP_ZONE.tTo, x);
return piecewise(SWEEP, x);
}
// ---------- DOM ----------
const canvas = hero.querySelector('canvas');
const video = hero.querySelector('video');
const hint = hero.querySelector('[data-hint]');
const glow = hero.querySelector('[data-glow]');
if (!canvas) return null;
const ctx = canvas.getContext('2d', { alpha: false });
const reduceMotion = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
const saveData = !!(navigator.connection && navigator.connection.saveData);
const isStatic = reduceMotion || saveData || !video;
const BG_COLOR = (getComputedStyle(document.documentElement).getPropertyValue('--bg') || '#17100a').trim();
// ---------- Estado ----------
const state = {
mode: isStatic ? 'static' : 'video',
current: REST_T,
target: REST_T,
videoOk: false,
spriteReady: false,
lastDraw: { a: -1, b: -1, f: -1 },
dpr: 1,
w: 0, h: 0,
dest: { x: 0, y: 0, w: 0, h: 0 },
portrait: false,
idleTimer: 0,
resumeToken: 0,
playedSeconds: 0,
inView: true,
capped: false,
};
if (isStatic) {
hero.classList.add('is-static', 'is-ready');
if (hint) hint.hidden = true;
return { state, gazeTime, SPRITE };
}
// ---------- Layout ----------
function layout() {
const rect = hero.getBoundingClientRect();
state.w = Math.max(1, Math.round(rect.width));
state.h = Math.max(1, Math.round(rect.height));
state.dpr = Math.min(window.devicePixelRatio || 1, 2);
canvas.width = Math.round(state.w * state.dpr);
canvas.height = Math.round(state.h * state.dpr);
canvas.style.width = state.w + 'px';
canvas.style.height = state.h + 'px';
const portrait = state.h > state.w * 1.05;
let dw, dh, dy;
if (portrait) {
// Retrato: composição ancorada no topo (66% da altura); texto abaixo.
dh = state.h * 0.66; dw = dh * ASPECT;
if (dw < state.w) { dw = state.w; dh = dw / ASPECT; }
dy = 0;
} else {
// Paisagem (cover): 16:9 preenchendo a seção, personagem centralizado.
dw = state.w; dh = state.w / ASPECT;
if (dh < state.h) { dh = state.h; dw = state.h * ASPECT; }
dy = (state.h - dh) / 2;
}
state.portrait = portrait;
state.dest = { x: (state.w - dw) / 2, y: dy, w: dw, h: dh };
hero.classList.toggle('is-portrait', portrait);
// O vídeo ocupa exatamente o mesmo retângulo da composição
video.style.left = state.dest.x + 'px';
video.style.top = state.dest.y + 'px';
video.style.width = state.dest.w + 'px';
video.style.height = state.dest.h + 'px';
ctx.setTransform(state.dpr, 0, 0, state.dpr, 0, 0);
ctx.imageSmoothingEnabled = true;
ctx.imageSmoothingQuality = 'high';
state.lastDraw.a = -1;
}
// ---------- Desenho ----------
const img = new Image();
img.decoding = 'async';
function blit(i, d) {
const sx = (i % SPRITE.cols) * SPRITE.frameW;
const sy = Math.floor(i / SPRITE.cols) * SPRITE.frameH;
ctx.drawImage(img, sx, sy, SPRITE.frameW, SPRITE.frameH, d.x, d.y, d.w, d.h);
}
function draw(time, force) {
if (!state.spriteReady) return;
const idx = clamp(time * SPRITE.fps, 0, SPRITE.frames - 1);
const a = Math.floor(idx);
const b = Math.min(a + 1, SPRITE.frames - 1);
const f = idx - a;
const ld = state.lastDraw;
if (!force && ld.a === a && ld.b === b && Math.abs(ld.f - f) < 0.004) return;
ld.a = a; ld.b = b; ld.f = f;
const d = state.dest;
ctx.globalAlpha = 1;
if (state.portrait) {
ctx.fillStyle = BG_COLOR;
ctx.fillRect(0, 0, state.w, state.h);
}
blit(a, d);
if (f > 0.004 && b !== a) {
ctx.globalAlpha = f; // cross-fade → interpolação entre frames vizinhos
blit(b, d);
ctx.globalAlpha = 1;
}
}
// ---------- Vídeo ----------
function showCanvas(show) { hero.classList.toggle('is-scrubbing', show); }
function videoTime() {
if (!isFinite(video.currentTime)) return state.current;
return clamp(video.currentTime, 0, SPRITE_MAX_T);
}
function enterFollow() {
state.resumeToken++; // cancela qualquer retomada em andamento
clearTimeout(state.idleTimer);
state.playedSeconds = 0;
if (state.capped) { state.capped = false; hero.classList.remove('is-capped'); }
if (state.mode === 'video' || state.mode === 'resume') {
if (state.mode === 'video') state.current = videoTime();
video.pause();
// Quantiza ao frame exato que o vídeo exibe: o canvas entra com a mesma imagem.
state.current = Math.round(state.current * SPRITE.fps) / SPRITE.fps;
state.target = state.current;
draw(state.current, true);
showCanvas(true);
}
state.mode = 'follow';
}
function scheduleResume() {
clearTimeout(state.idleTimer);
state.idleTimer = setTimeout(resumePlayback, IDLE_MS);
}
function resumePlayback() {
if (state.mode !== 'follow') return;
if (!state.videoOk) { state.mode = 'sprite-play'; return; }
state.mode = 'resume';
state.target = state.current; // congela a pose durante o handoff
const token = ++state.resumeToken;
const t = clamp(state.current, 0, Math.max(0, (video.duration || CLIP_DURATION) - 0.05));
let done = false;
const go = () => {
if (done || token !== state.resumeToken) return;
done = true;
video.removeEventListener('seeked', go);
video.play().then(() => {
if (token !== state.resumeToken) return;
state.mode = 'video';
showCanvas(false);
if (glow) glow.style.opacity = '0';
}).catch(() => {
if (token !== state.resumeToken) return;
state.mode = 'sprite-play';
});
};
video.addEventListener('seeked', go);
try { video.currentTime = t; } catch (e) { /* metadata ainda não carregada */ }
setTimeout(go, SEEK_TIMEOUT_MS);
}
function startVideo() {
video.muted = true;
video.src = VIDEO_SRC;
video.play().then(() => {
state.videoOk = true;
hero.classList.add('is-ready');
if (state.mode === 'sprite-play') {
try { video.currentTime = clamp(state.current, 0, SPRITE_MAX_T); } catch (e) { /* ignore */ }
state.mode = 'video';
showCanvas(false);
}
}).catch(() => {
// Autoplay bloqueado → a sprite toca em loop; tenta o vídeo no primeiro gesto
state.mode = 'sprite-play';
hero.classList.add('is-ready');
loadSprite();
showCanvas(true);
const retry = () => {
video.play().then(() => {
state.videoOk = true;
hero.removeEventListener('pointerdown', retry);
}).catch(() => {});
};
hero.addEventListener('pointerdown', retry);
});
}
video.addEventListener('playing', () => { state.videoOk = true; });
video.addEventListener('error', () => {
state.videoOk = false;
if (state.mode === 'video' || state.mode === 'resume') { state.mode = 'sprite-play'; loadSprite(); showCanvas(true); }
});
video.addEventListener('timeupdate', () => {
// Teto de reprodução: depois de PLAYBACK_CAP_S sem interação, pausa no frame.
if (state.mode !== 'video' || state.capped) return;
state.playedSeconds += 0.25;
if (state.playedSeconds >= PLAYBACK_CAP_S) {
state.capped = true;
hero.classList.add('is-capped');
video.pause();
}
});
// Fora da tela: pausa o loop; de volta: retoma
if ('IntersectionObserver' in window) {
new IntersectionObserver((entries) => {
for (const e of entries) {
state.inView = e.isIntersecting && e.intersectionRatio >= 0.2;
if (state.mode !== 'video') continue;
if (!state.inView) video.pause();
else if (!state.capped && video.paused) video.play().catch(() => {});
}
}, { threshold: [0, 0.2, 0.5] }).observe(hero);
}
// ---------- Sprite (carregada depois da primeira dobra) ----------
let spriteRequested = false;
function loadSprite() {
if (spriteRequested) return;
spriteRequested = true;
const ready = () => {
if (state.spriteReady) return;
state.spriteReady = true;
layout();
if (state.mode !== 'video') draw(state.current, true);
hero.classList.add('has-sprite');
};
img.src = SPRITE.src;
if (img.decode) img.decode().then(ready).catch(() => { img.onload = ready; });
else img.onload = ready;
}
// ---------- Loop ----------
let lastTs = 0;
function tick(ts) {
requestAnimationFrame(tick);
const dt = lastTs ? Math.min((ts - lastTs) / 1000, 0.1) : 0.016;
lastTs = ts;
if (state.mode === 'video' || !state.spriteReady) return;
if (state.mode === 'sprite-play') {
state.current += dt;
if (state.current >= CLIP_DURATION) state.current -= CLIP_DURATION;
state.target = state.current;
draw(clamp(state.current, 0, SPRITE_MAX_T));
return;
}
// 'follow' e 'resume'
const k = 1 - Math.exp(-FOLLOW_RATE * dt);
let step = (state.target - state.current) * k;
const maxStep = MAX_SPEED * dt;
if (step > maxStep) step = maxStep;
if (step < -maxStep) step = -maxStep;
state.current += step;
draw(clamp(state.current, 0, SPRITE_MAX_T));
}
// ---------- Interação ----------
function toComposition(clientX, clientY) {
const rect = hero.getBoundingClientRect();
const px = clientX - rect.left;
const py = clientY - rect.top;
const d = state.dest;
// Em retrato só o centro da composição é visível: x cobre a largura visível.
const x = state.portrait ? px / state.w : (px - d.x) / d.w;
return { x, y: (py - d.y) / d.h, px, py };
}
function onPointerMove(e) {
const c = toComposition(e.clientX, e.clientY);
if (glow) {
glow.style.transform = 'translate3d(' + c.px + 'px, ' + c.py + 'px, 0)';
glow.style.opacity = '1';
}
if (hint && !hint.classList.contains('is-hidden')) hint.classList.add('is-hidden');
if (!state.spriteReady) { loadSprite(); return; } // vídeo segue tocando até a sprite chegar
enterFollow();
state.target = gazeTime(c.x, c.y);
scheduleResume();
}
function onPointerLeave() {
if (glow) glow.style.opacity = '0';
if (state.mode === 'follow') {
clearTimeout(state.idleTimer);
resumePlayback();
}
}
hero.addEventListener('pointerenter', () => loadSprite(), { passive: true });
hero.addEventListener('pointermove', onPointerMove, { passive: true });
hero.addEventListener('pointerdown', onPointerMove, { passive: true });
hero.addEventListener('pointerleave', onPointerLeave);
hero.addEventListener('pointercancel', onPointerLeave);
hero.addEventListener('pointerup', (e) => { if (e.pointerType !== 'mouse') onPointerLeave(); });
window.addEventListener('blur', onPointerLeave);
document.addEventListener('visibilitychange', () => { if (document.hidden) onPointerLeave(); });
// ---------- Resize ----------
let resizeRaf = 0;
const onResize = () => {
cancelAnimationFrame(resizeRaf);
resizeRaf = requestAnimationFrame(() => {
layout();
if (state.mode !== 'video') draw(state.current, true);
});
};
window.addEventListener('resize', onResize);
if ('ResizeObserver' in window) new ResizeObserver(onResize).observe(hero);
// ---------- Arranque ----------
layout();
startVideo();
requestAnimationFrame(tick);
// A sprite (2,8 MB) fica fora da primeira dobra: entra quando a página está ociosa
// ou no primeiro gesto do ponteiro, o que vier antes.
const idle = window.requestIdleCallback || ((fn) => setTimeout(fn, 1200));
window.addEventListener('load', () => idle(() => loadSprite(), { timeout: 3000 }), { once: true });
if (document.readyState === 'complete') idle(() => loadSprite(), { timeout: 3000 });
const api = { state, gazeTime, SPRITE, video };
window.__spriteHero = api; // depuração / QA
return api;
}