<VideoAnnotator> — scrub, zoom, draw, and export a single annotated frame (hive spec)
Play/scrub a clip, zoom+pan into it, draw shapes/points/labels over the video, and export the current frame + its annotations as a PNG. Plug-and-play, importable into any app, publicly showcased at
/components/video-annotator.
Design discussion (RESOLVED architecture — read first):
.pHive/epics/video-annotator/stories/video-annotator-draw-and-export.yaml — this doc
documents those decisions, it does not re-derive them.
Why (operator intent — honor this)
CLAUDE.md's original Phase-1 component list: "<VideoAnnotator> — play a clip, scrub,
zoom, draw shapes/points/labels over the video, export." Mathew already had repo parts
that did video annotation/overlay work; this component is the reusable, plug-and-play
version of that idea — not tied to any one property or footage source.
The contract
export interface VideoAnnotatorProps {
/** mp4 (or any progressive format the browser plays natively) or m3u8 URL. */
src: string;
/** poster shown before playback starts / while the video loads. */
poster?: string;
/** Fired with the full current annotation list whenever it changes
* (add or delete). This repo has no backend — there is no server-side
* persistence anywhere in it — so this callback is the ONLY mechanism
* a consuming app has to own persistence (localStorage, an API call,
* whatever it wants) if it wants annotations to survive beyond this
* component's own lifetime. Optional and side-effect-free on this
* component's own behavior if omitted. */
onAnnotationsChange?: (shapes: VideoAnnotation[]) => void;
className?: string;
}
export type AnnotationTool = "point" | "rectangle" | "freehand" | "text";
export interface NormalizedPoint {
u: number; // 0-1, video-frame-relative, independent of zoom/pan
v: number;
}
export type VideoAnnotation =
| { id: string; tool: "point"; point: NormalizedPoint }
| { id: string; tool: "rectangle"; start: NormalizedPoint; end: NormalizedPoint }
| { id: string; tool: "freehand"; points: NormalizedPoint[] }
| { id: string; tool: "text"; point: NormalizedPoint; text: string };
src/poster/onAnnotationsChange/className is the whole prop surface — same
"deliberately minimal props" convention as <Gallery>/<FileUpload>. There's no
annotations controlled-prop / initialAnnotations — the component owns its own
annotation state internally; onAnnotationsChange is read-only visibility into it, not a
two-way binding.
Behavior
- Playback. Play/pause button, a scrub
<input type="range">bound tocurrentTime/durationvia real'timeupdate'/'loadedmetadata'video events (not optimistic state) — the same pattern<VideoTour>'s<SpinVideo>controls bar uses. Plain progressive sources (mp4/webm) are assigned directly;.m3u8sources go through native HLS (Safari) orhls.js(already apackage.jsondependency, unused elsewhere in this repo before this component). - Zoom + pan. A visible zoom slider (1x–3x) applies a pure CSS
transform: translate(...) scale(...)to a wrapper around the<video>— the video's own decode/render resolution is never touched, only what portion of the rendered frame is visible. Click-drag pans once zoomed in past 1x, clamped to a bounded range. A "Reset zoom" button returns to 1x/no pan. - Drawing tools. A toolbar (point / rectangle / freehand / text) toggles the active
tool; clicking it again deselects (back to pan/idle) — the same "click active to turn
off" affordance
<LayerViewer>'sAnnotationLayertoolbar uses. Every annotation's geometry is stored video-frame-normalized (u/vin[0,1], relative to the video's own unzoomed rendered box) — independent of zoom/pan and of the video's intrinsic decode resolution — so a placed annotation stays glued to the correct spot on the underlying frame across any later zoom/pan change. A drag used to pan the zoomed video never places a stray annotation: the drawing<canvas>ispointer-events-nonewhenever no draw tool is active, so that gesture falls straight through to the pan wrapper underneath, structurally (not just via the click-vs-drag heuristic below). - Click-vs-drag disambiguation. A gesture counts as a "click" (placing a point/text
annotation) only if it moves under 6px and completes within 500ms — otherwise it's a
genuine drag (rectangle/freehand) and no stray point/text is dropped; symmetrically, a
negligible drag with the rectangle/freehand tool active is discarded rather than
committing a degenerate zero-size shape. Same constants and rationale as
components/Model3D/Model3D.tsx'sMeasureController. - Legend + delete. A panel lists every placed annotation with a per-row delete action, removing it from both the canvas and the list.
Export — a composited PNG snapshot, not a re-encoded video
Clicking "Export snapshot" downloads a PNG of the current video frame + every currently -drawn annotation composited on top — it does NOT produce an annotated video file. This is a deliberate scope decision, not an oversight:
- This repo has no video-encoding library installed — no
ffmpeg.wasmor equivalent anywhere inpackage.json. Re-encoding a video (even just re-muxing an annotation overlay onto every frame of a clip) needs one. - Adding one would be a heavy new dependency at odds with CLAUDE.md's Vercel-bandwidth
-conscious stack posture —
ffmpeg.wasmalone ships several MB of WASM to the client, a poor fit for a component meant to be dropped into any app without a second thought about bundle size. - A single annotated-frame PNG is a real, useful, in-scope deliverable on its own (a drone-shot still with a hazard circled and labeled, e.g.) and needs nothing beyond the Canvas 2D API already used to draw the live overlay.
Mechanically: handleExport draws the <video> element onto a fresh offscreen canvas at
the video's native decode resolution (video.videoWidth/videoHeight, not the
currently-zoomed viewport — export always composites the whole current frame, never a
zoomed-in crop of it), replays every annotation's normalized coordinates scaled directly
to that resolution, and downloads the result via canvas.toBlob(..., "image/png") + an
anchor download link.
Usage
import { VideoAnnotator } from "@/components/VideoAnnotator";
<VideoAnnotator
src="/showcase-samples/2806-prado-tour/entry.mp4"
onAnnotationsChange={(shapes) => console.log(shapes)}
className="h-full w-full"
/>
No next/dynamic({ ssr: false }) wrapping is strictly required for module-scope reasons
(unlike <LayerViewer>/<Model3D>, <VideoAnnotator> touches no WebGL/map API at module
scope) — the showcase page still dynamic-imports it, matching this repo's blanket "every
heavy viewer = next/dynamic({ssr:false})" convention for anything with real playback/
canvas state.
Copy-portable into a standalone consumer like personal-site: everything this component
needs is hls.js + the DOM <video>/<canvas> APIs, no app/ or gating dependency —
same precedent as components/VideoTour/index.ts and components/Gallery/index.ts.
What this component deliberately does NOT do
- No re-encoded annotated video export — see "Export" above.
- No server-side persistence of annotations —
onAnnotationsChangeis the only hook; what a consuming app does with it (localStorage, an API call, nothing) is its own concern, same "no-backend posture" every other component in this repo follows. - No fetch/XHR, no storage SDK, no auth check.
Acceptance criteria
- Given the point/rectangle/freehand/label tool is active, when the user interacts
with the canvas, then the corresponding annotation shape is drawn and appears in the
legend/list.
Verified:
VideoAnnotator.test.tsx's per-tool drawing specs. - Given the video is zoomed and panned, when an annotation is drawn, then it visually
aligns with the correct point on the underlying video frame, not the un-zoomed
coordinate space.
Verified:
normalizedToCanvasPoint/canvasPointToNormalizedround-trip specs plus a zoomed-draw integration spec inVideoAnnotator.test.tsx. - Given an annotation exists in the legend/list, when its delete action is clicked,
then it's removed from both the canvas and the list.
Verified:
VideoAnnotator.test.tsx's delete spec. - Given
onAnnotationsChangeis provided, when annotations are added or removed, then it's called with the current full shape set. Verified:VideoAnnotator.test.tsx's callback specs. - Clicking Export downloads a PNG containing the current video frame composited with
all currently-drawn annotations.
Verified:
VideoAnnotator.test.tsx's export spec asserts the realdrawImage/annotation draw calls against a mocked canvas context, not just "a download was triggered." - A drag gesture used to pan the zoomed video does not also place a stray annotation
shape.
Verified:
VideoAnnotator.test.tsx's pan-vs-draw specs — the overlay canvas ispointer-events-nonewith no tool active, and the click-vs-drag movement/duration threshold covers the tool-active case. - Given
npm run buildandnpm test, when run after this story, then both pass cleanly, and/components/video-annotator+/docs/components/video-annotatorboth render with zero errors.
Phase fit
- This story (
video-annotator-showcase-integration,video-annotatorepic): this doc + the showcase page + the/docs/components/[slug]and root ToC wiring — closes out thevideo-annotatorepic (core player + zoom/pan, then drawing/export, both already shipped on this branch). - Deliberately out of scope, permanently (not deferred): re-encoded annotated-video
export — see "Export" above; revisit only if a lightweight (non-
ffmpeg.wasm-class) video-encoding approach becomes a real option, not by default.