DreamLake

VideoAnnotator

A video player with an editable, contiguous segment timeline — the labeling surface for splitting a clip into consecutive phases and captioning each one. It owns the <video> element, a transport bar (prev/next segment, frame-step, play/pause, playback speed) and a percentage-positioned timeline strip with drag-to-move boundaries and click-to-scrub. Segments always tile the whole clip with no gaps or overlaps (end === next.start); any structural edit resets the verified flag on the affected segments. Playback runs continuously across segments — the selection follows the playhead and stops at the last segment's end (no per-segment loop).

The component is controlled on segments + selectedIndex: it runs the split/merge/boundary invariants internally and hands the host a fresh array via onSegmentsChange, so the host owns state and persistence.

Demo

Click the video frame to play/pause. Drag a boundary to move it, double-click a boundary to merge, click a segment to select it and seek to its start (click the already-selected segment again to seek to that exact spot within it), drag anywhere on the track to scrub. Selecting never auto-plays — it preserves the current play/pause state. Use the − N× + control in the transport bar to magnify the timeline 1→2→4→8→16× when segments are crowded; the view centers on the playhead and can be scrolled horizontally, and follows the playhead during playback. Keyboard: Space play/pause, ←/→ step frame (Shift = 1s, Alt = nudge playhead to boundary), ,/. or j/k prev/next segment, s split, Backspace merge, a approve.

pour liquidvideos/pour_liquid.mp4
0:00.00 / 0:10.00 · f0
1.00×
1Reaches for and grasps the wine bottle on the table
2Tilts bottle over red mug, pouring liquid into it
3Rights the bottle and lifts it away from the filled mug
0s
10 wordsphase 1 · 0:00.00–0:03.50 · frames 1–19

Multiple tracks

Pass tracks instead of segments to label several parallel lanes over the same clip (e.g. phase / gripper / events). Each track is a full editor — its own split / merge / boundary-drag / select — but only the active track (bright lane, highlighted header) takes edits at a time. Click any lane's segment or its header to make it active; the left × removes a track and + Track appends a new full-length lane. The ruler, playhead and hover readout span every lane. Single-track callers (segments) are unaffected.

pour liquidvideos/pour_liquid.mp4
0:00.00 / 0:10.00 · f0
1.00×
1Reaches for and grasps the wine bottle
2Tilts bottle over the mug, pouring
3Rights the bottle and lifts away
1Open
2Closed
3Open
0s
7 wordsphase 1 · 0:00.00–0:03.50 · frames 1–19

Driving it from a ref

Set enableKeyboard={false} and drive the widget through its imperative handle (split, merge, stepFrame, gotoBoundary, goToSegment, play, pause, toggleApprove) when the host owns its own transport chrome or a global shortcut scheme. goToSegment(index) selects a segment and seeks to its start — the same action as clicking it — so a host list can drive selection and let the component own the seek.

0:00.00 / 0:10.00 · f0
1.00×
1Approach
2Pour
0s

Props

PropTypeDefaultDescription
videoUrlstring | null—Video source URL. Empty shows a placeholder in the stage.
videoTitlestring—Title shown in a header row above the video (e.g. the clip name).
videoSubtitlestring—Monospace subtitle beside the title (e.g. the file path).
headerLeadingReactNode—Element at the start of the header row (e.g. a "show list" button).
showDescriptionbooleanfalseRender the boxed description editor for the selected segment below the timeline.
onDescriptionChange(index: number, value: string) => void—Fired when the description editor changes. Required for it to be interactive.
segmentsSegment[]—Controlled segment list (single-track mode). Provide segments or tracks.
selectedIndexnumber—Controlled index of the active segment (within the active track).
onSegmentsChange(next: Segment[]) => void—Fired after a structural edit in single-track mode.
onSelectedChange(index: number) => void—Fired when the active segment changes.
onApproveToggle(index: number, verified: boolean) => void—Fired when the user toggles verification (a).
tracksTrack[]—Controlled track list (multi-track mode). Stacks one editable lane per track; takes precedence over segments.
activeTrackIndexnumber0Controlled index of the active (editable) track.
onTracksChange(next: Track[]) => void—Fired after a structural edit in multi-track mode (full track list).
onActiveTrackChange(index: number) => void—Fired when the active track changes (click a lane/header, add/remove).
allowAddTracksbooleantrueShow the + Track control in multi-track mode.
onAddTrack() => void—Custom add-track handler. If omitted, appends a default full-length track via onTracksChange.
onRemoveTrack(index: number) => void—Custom remove-track handler. If omitted, drops the track by index via onTracksChange.
durationnumber—Authoritative clip duration (s). Falls back to the video's loadedmetadata duration, then the largest segment end.
extractFpsnumber | null30Frames-per-second for frame-stepping granularity (←/→).
srcFpsnumber | nullextractFps → 30Source fps for the "· fN" frame readout.
speedsnumber[][0.25,0.5,1,1.5,2]Playback-speed options for the speed dropdown.
enableKeyboardbooleantrueInstall the document-level keyboard shortcuts. Set false to drive only via the ref.
classNamestring—Extra classes on the root element.

Segment

FieldTypeDescription
idstring (optional)Stable identity preserved across split/merge (split keeps the left id + mints one for the right half; merge keeps the earlier id). Track review flags / React keys by this, not the array index. Backfilled if omitted.
startnumberSegment start time in seconds.
endnumberSegment end time in seconds (= next.start after normalization).
descriptionstringFree-text caption for the phase.
verifiedbooleanHuman-confirmed flag; reset by structural edits.

Track

FieldTypeDescription
idstringStable identity for the lane (used as the React key).
namestringLabel shown in the track header gutter.
segmentsSegment[]The lane's contiguous segments.