Annotation model
An annotation is a sequence of keyframe boxes plus a label. The frontend renders the box at every frame between keyframes by linear interpolation; the backend stores only the keyframes. This keeps storage proportional to user effort, not to video length.
Keyframes and interpolation
frame 0 box A
frame 60 box B
-> rendered: box at frame f for 0 <= f <= 60
is the linear interpolation of A and B
weighted by f / 60.
The keyframe array is ordered by frame number. Frames before the
first keyframe and after the last are not rendered (the
annotation is "absent" outside its keyframe range). Visibility
within the keyframe range is stored separately on the annotation
sequence as visibilityRanges: Array<{ startFrame: number; endFrame: number; visible: boolean }>. To hide an annotation
across a span within its keyframe range, add a visibilityRanges
entry with visible: false covering that range; the interpolator
skips frames where getVisibilityAtFrame returns false rather
than reading a per-keyframe flag.
Type vs object
Two annotation types share the same row shape:
type label persona
------ ----------------- --------
"type" ontology typeId required
"object" worldObject id optional (linked via linkType)
The linkType column on object annotations discriminates which
world list the label resolves through:
linkType resolves through
--------- ------------------
"entity" worldEntities
"event" worldEvents
"time" worldTimes
"location" worldLocations
NULL treated as entity-linked (legacy)
The linkType column was introduced by the migration
20260505000000_add_annotation_link_type (nullable). The
frontend, the export handler, and the import handler write and
read whichever linked-id field matches the column, so object
annotations linked to events, times, or locations round-trip
without flattening to entity-linked.
Ownership columns
Annotations carry two ownership columns. Annotation.userId
was introduced by 20260310000000_add_annotation_userid to
support user-scoped object annotations (object annotations have
an optional personaId, so the persona-side ownership check
does not apply). Annotation.createdByUserId is the RBAC
ownership column, populated for historical rows by the backfill
migration 20260415000000_backfill_rbac_ownership. CASL's
ability builder matches against createdByUserId; the legacy
userId is still populated by write paths so legacy clients
see consistent data.
GET /api/annotations/:videoId is filtered by
accessibleBy(request.ability, 'read').Annotation, which compiles
to createdByUserId = request.user.id for own-only readers and to
the union with project-scoped reads for project members. The
listing was previously unscoped, which is what let cross-user
imports show up as duplicate rows. See
Concepts > RBAC.
Confidence and source
confidence Float? model confidence (NULL for hand-drawn)
source String "manual" | "ai-assisted" | "automatic"
The source column defaults to "manual" and is documented in
the schema as one of manual, ai-assisted, or automatic.
Today every write path persists "manual"; the other values are
reserved for future automated pipelines (for example, tracker
fill-in between keyframes or detection runs initiated through the
detect route). A manual annotation came from a user drawing it.