EpisodeFrameStack

The still-image sibling of EpisodeVideoStack: the same tile chrome — REC-style stamp, label, auto-detected resolution, scrub line, active ring — but each tile scrubs a sequence of <img> frames instead of seeking a <video>. Moving the pointer across a tile snaps to the nearest frame for the cursor position; the frame shown stays on screen until the next one has decoded, so scrubbing doesn't flash.

Unlike the video stack, there is no duration prop. The total span is derived from the earliest and latest frame timestamp across all sources.

Derived span

Each frame carries a timestamp — a per-frame Unix time in seconds (the upstream _ts). The component computes the cursor span from the first and last frame and maps the pointer's X fraction onto it:

  • Timestamped — when frames carry real times, duration = lastTs − firstTs and each tile shows the frame nearest the cursor's time.
  • No valid timestamp — when a frame's time isn't a real recorded value (0, negative, NaN/Infinity, or out of the representable date range), frames fall back to uniform index spacing.

Keep a single stack homogeneous: all of its sources should either carry real timestamps or none should. (A stack mixes one shared cursor span across every tile, so combining recorded and unrecorded sources in the same stack isn't meaningful.)

Timestamped

Three cameras sharing one cursor. Hover any tile — every tile snaps to the frame nearest that moment, and the top-left stamp shows the hovered frame's wall-clock time (YYYY-MM-DD HH:MM:SS, local).

2026-05-29 14:23:07
front/camera/front/image_compressed
2026-05-29 14:23:07
wrist/camera/wrist/image_compressed
2026-05-29 14:23:07
top/camera/top/image_compressed

Invalid timestamp → frame index

This specimen uses timestamp: 0, but the rule is general: any frame whose time isn't a valid recorded value — 0, negative, NaN/Infinity, or out of the representable date range — is treated the same way. With no real times to position by, the stack spaces frames uniformly by index and the stamp reads FRAME n / N instead of a date.

FRAME 1 / 106
front/camera/front/image_compressed
FRAME 1 / 106
wrist/camera/wrist/image_compressed

Bounding-box overlays

Each source can carry annotation overlays; on a frame stack they key on the displayed image (frameIndex ?? array index) and always describe the frame actually on screen. These synthetic detections are normalized (0..1 of the frame) — the cup box exists only on frames 30–75, so it appears and disappears with the data:

FRAME 1 / 106
frontsynthetic detections
FRAME 1 / 106
topno overlay

Frame source shape

ts
interface FrameImage {
  timestamp: number   // per-frame Unix seconds; invalid (0, NaN, …) = no recorded time
  image: string       // URL — remote, data:, or blob:
  alt?: string
  frameIndex?: number // index in the ORIGINAL media, for subsampled sequences
}

interface FrameImageSource {
  id: string
  frames: FrameImage[]   // sorted ascending by timestamp
  title?: string         // bottom-left label, uppercased
  subtitle?: string      // bottom-left mono sub-label
  poster?: string        // shown before the first frame loads
  overlays?: MediaOverlay[] // annotation layers — see /components/media-overlay
}

The tile's top-left stamp is computed per hovered frame: a frame with a valid recorded time shows the formatted wall-clock; any invalid time (0, negative, NaN/Infinity, or out of the representable date range) shows its FRAME n / N position instead.

Props

PropTypeDefaultDescription
sourcesFrameImageSource[]Frame sequences, one tile each, left-to-right.
timenumber | nullnullControlled cursor time (seconds from start). Internal hover overrides it while the pointer is over the stack.
onHover(t: number) => voidFires on every hover move with the time-from-start at the cursor.
onHoverEnd() => voidFires when the cursor leaves the entire stack.
activeIdstring | nullControlled active-tile id (the accent-ring tile).
defaultActiveIdstring | nullnullUncontrolled initial active-tile id.
onActiveChange(id: string) => voidFires on hover-enter of a new tile, with that tile's id.
columnsnumber3Grid column count.
gapnumber6Pixel gap between adjacent tiles.
showRecTimestampbooleantrueTop-left stamp — wall-clock time, or FRAME n / N when unrecorded.
showLabelbooleantrueBottom-left title + subtitle.
showResolutionbooleantrueBottom-right resolution, auto-detected from the rendered <img>.
showOverlaysbooleantrueMaster switch for all FrameImageSource.overlays layers.
classNamestringExtra classes on the root grid.