Skip to main content

Time Travel on a Canvas

"Did that smoke column grow over the last hour?" is a question a still image can't answer. Timelapse can: pick a duration — 1 minute to 6 hours — and the camera's recent frames play back like video. Under the hood this is a small hand-built video player, and its engineering constraints are worth learning because they're classic frontend physics: a 6-hour timelapse is ~700 JPEG frames, and you can neither download them all up front nor hold them all in memory.

Why a canvas

The naive version — swap an <img> tag's src per frame — flickers, stutters on decode, and gives you no drawing control. TimelapseCanvasPlayer instead draws each frame onto a <canvas> with ctx.drawImage() inside a requestAnimationFrame loop at 15 FPS (× the speed multiplier). RAF means the browser schedules drawing with its own paint cycle — smooth, and battery-friendly when the tab is hidden. A ResizeObserver keeps the canvas's pixel size matched to its container.

The loading strategy: start fast, stay ahead, forget the past

Three numbers define the player's relationship with the network and memory:

  • Start after 30. The first 30 frames load, then onReadyChange(true) fires and playback begins — nobody waits for 700 frames to watch the first minute.
  • Stay 30 ahead. During playback, frames are preloaded ahead of the cursor in background batches of 25, with concurrent fetches capped at 6 (the browser's per-origin limit — going higher just queues).
  • Remember ~200. A sliding window keeps roughly 200 frames in memory; older frames are evicted. A 6-hour timelapse peaks around 100 MB instead of growing unboundedly.

And when the network loses the race anyway, the player degrades instead of stalling: a frame that failed to load is recorded and skipped; a frame not yet cached makes the player look backward for the last good frame, or skip ahead up to 5 frames to find a cached one. You might see a brief stutter — never a freeze.

The layering trick

Here's an integration detail that saves a whole class of visual glitches: when playback starts, the live camera image (IImage/SignalRIImage) stays mounted underneath, and the canvas player sits on top at z-index: 4. Close playback and the live view is already there — no grey flash, no re-fetch, no skeleton. Meanwhile every other overlay (close/expand buttons, camera controls) hides behind !getIsInPlaybackMode() guards, because the player brings its own controls: duration picker, play/pause, loop, a scrubber with hover timestamps, speed (0.5×–4×), and native fullscreen — plus keyboard shortcuts (Space, ←/→ frame-step, F, Escape).

The flow, end to end

duration picked (PlaybackButton) → GetLatestTimelapseImages(cameraId, durationMs)
→ timelapseStore.currentPlaybackImageList → TimelapseCanvasPlayer mounts
→ 30 frames in → auto-play → background batches → RAF draw loop

CameraImageWrapper orchestrates: it only mounts the player once the fetch succeeded and frames exist — and, as covered in Rendering the camera view, it refuses to enter playback mode at all for an empty result.

Wrapping up

A RAF-driven canvas, a 30/25/6/200 loading discipline, skip-don't-stall error handling, and a live image kept warm underneath — that's the whole player. The numbers are tuned, not arbitrary; measure before you change them.

Next: alerts and notifications.