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.
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.
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.
Props
| Prop | Type | Default | Description |
|---|---|---|---|
videoUrl | string | null | — | Video source URL. Empty shows a placeholder in the stage. |
videoTitle | string | — | Title shown in a header row above the video (e.g. the clip name). |
videoSubtitle | string | — | Monospace subtitle beside the title (e.g. the file path). |
headerLeading | ReactNode | — | Element at the start of the header row (e.g. a "show list" button). |
showDescription | boolean | false | Render 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. |
segments | Segment[] | — | Controlled segment list (single-track mode). Provide segments or tracks. |
selectedIndex | number | — | 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). |
tracks | Track[] | — | Controlled track list (multi-track mode). Stacks one editable lane per track; takes precedence over segments. |
activeTrackIndex | number | 0 | Controlled 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). |
allowAddTracks | boolean | true | Show 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. |
duration | number | — | Authoritative clip duration (s). Falls back to the video's loadedmetadata duration, then the largest segment end. |
extractFps | number | null | 30 | Frames-per-second for frame-stepping granularity (←/→). |
srcFps | number | null | extractFps → 30 | Source fps for the "· fN" frame readout. |
speeds | number[] | [0.25,0.5,1,1.5,2] | Playback-speed options for the speed dropdown. |
enableKeyboard | boolean | true | Install the document-level keyboard shortcuts. Set false to drive only via the ref. |
className | string | — | Extra classes on the root element. |
Segment
| Field | Type | Description |
|---|---|---|
id | string (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. |
start | number | Segment start time in seconds. |
end | number | Segment end time in seconds (= next.start after normalization). |
description | string | Free-text caption for the phase. |
verified | boolean | Human-confirmed flag; reset by structural edits. |
Track
| Field | Type | Description |
|---|---|---|
id | string | Stable identity for the lane (used as the React key). |
name | string | Label shown in the track header gutter. |
segments | Segment[] | The lane's contiguous segments. |