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.

Grade com 16 miniaturas do clipe, uma a cada 0,6 segundo, mostrando o filhote virando a cabeça para os lados e mordendo o biscoito.
Grade de contato do clipe (1 miniatura a cada 0,6 s). É a primeira coisa a gerar: dá a noção do que o vídeo tem antes de escrever qualquer código.

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:

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 240 frames em resolução original:

bash
# 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.jpg

A 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.

bash
# 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
Recorte das seis primeiras colunas da sprite sheet, com quatro linhas de frames do filhote.
Recorte das 6 primeiras colunas da sprite (a imagem completa tem 60 colunas). O frame i fica na coluna i mod 60 e na linha ⌊i / 60⌋.

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.

bash
# 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
Grade 6 por 5 com 30 miniaturas do clipe, uma a cada 0,33 segundo, usada para anotar a direção do olhar.
Grade densa (1 miniatura a cada 0,33 s). Lida em ordem de leitura: t = 0, 0,33, 0,67… até 9,67 s.

Para confirmar as direções, recorte os frames-chave em tamanho maior. Um script Python de dez linhas resolve:

python
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)
Doze recortes de frames-chave com o tempo anotado, mostrando o filhote olhando para a esquerda, de frente, para a direita, para cima e mordendo o biscoito.
Frames-chave com o instante anotado. É a partir deles que sai a tabela abaixo.
InstanteO que o filhote fazPara onde o cursor deve estar
0,0 sDe frente, feliz, língua de foraImagem de referência (não usada como repouso)
1,0 – 2,3 sCabeça virada para a esquerda da telaCursor à esquerda
4,0 sDe frente, sorriso calmoCursor no centro · pose de repouso
5,0 – 5,7 sCabeça virada para a direita da telaCursor à direita
6,0 – 6,7 sOlhar para cimaCursor na faixa superior
8,0 – 8,6 sMordisca o biscoitoCursor sobre o biscoito
9,0 – 10 sDe frente, boca abertaSó 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.

js
// 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.

js
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.

js
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);
}
TrajetoTempo para assentar (60 fps)Overshoot
Esquerda → direita (1,05 s → 5,7 s)1,07 snão
Cima → biscoito (6,3 s → 8,4 s)0,63 snão
Ajuste fino (4,0 s → 4,3 s)0,32 snã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.

  1. 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.
  2. Cursor parado por 900 ms ou fora da área → congele o alvo, faça video.currentTime = tempoDaSprite, espere seeked (com timeout de 600 ms), dê play() e esconda o canvas em ~220 ms.
  3. Cursor voltou a se mover durante a retomada → cancele a retomada (um token incremental resolve) e volte a seguir.
js
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.

js
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-motion mostra o poster.
  • curl -sI -H "Range: bytes=0-99" no MP4 devolve 206.
ParâmetroValorEfeito
FOLLOW_RATE9 /sresposta ao cursor; maior = mais nervoso
MAX_SPEED6 s/slimite de varredura; menor = mais cinematográfico
IDLE_MS900 mstempo parado até o vídeo retomar
SWEEP / UP_ZONE / BONE_ZONEinstantes do clipeo 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.

text
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.

text
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)
js
/* ============================================================
   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;
}