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.