Skip to main content

Annotations

Use the annotations API to create and edit bounding-box sequences. Each annotation belongs to one video and either a persona (for type annotations) or directly to a user (for object annotations without a persona).

Endpoints

GET    /api/annotations/:videoId
POST /api/annotations
PUT /api/annotations/:id
DELETE /api/annotations/:videoId/:id

Type vs object

A type annotation has type: "type" and a label that resolves to a typeId from the persona's ontology (for example, label: "player"). It must carry a personaId.

An object annotation has type: "object" and a label that resolves to a world-state object id. The linkType column discriminates the four kinds:

linkType   | label resolves through
-----------+-------------------------------
"entity" | worldEntities
"event" | worldEvents
"time" | worldTimes
"location" | worldLocations
NULL | treated as entity-linked (legacy)

The linkType column was added in v0.1.8 (migration 20260429000000_add_annotation_link_type). Before v0.1.8 the export emitted only linkedEntityId, so any object annotation linked to an event, time, or location was silently flattened on export and on import. The v0.1.8 export emits the correct linkedEventId / linkedTimeId / linkedLocationId field, the import reads any of the four, and linkType is preserved on the round-trip.

Frames and keyframes

The frames field is an ordered array of keyframes:

{
"frames": [
{"frame": 0, "box": {"x": 120, "y": 80, "width": 60, "height": 140}},
{"frame": 60, "box": {"x": 150, "y": 85, "width": 60, "height": 140}}
]
}

Frames between keyframes are interpolated linearly at render time. The frontend renders this as a smooth box; the backend stores only the keyframes. See Concepts > Annotation model for the algorithm.

Listing scope

GET /api/annotations/:videoId is filtered by accessibleBy(request.ability, 'read').Annotation. The compiled clause matches createdByUserId = request.user.id for own-only readers and additionally pulls in project-scoped reads for project members. The pre-v0.1.8 unscoped listing surfaced foreign users' imported copies in the All Annotations tab as duplicate rows; that symptom is gone.

Mutation ownership

PUT /api/annotations/:id, DELETE /api/annotations/:videoId/:id, and POST /api/annotations go through CASL: the route fetches the target annotation (or, for create, the supplied personaId row) and calls request.ability.can(action, subject(...)). A foreign-owned target returns 404. v0.2.1 added an explicit can('read', subject('Persona', persona)) precheck on POST /api/annotations when personaId is supplied, because the generic create Annotation rule does not look at the personaId body field. See Concepts > RBAC.

Ownership columns

Write paths populate Annotation.createdByUserId (the v0.2.0 RBAC ownership column) and Annotation.userId (the v0.1.8 column, kept for back-compat) from the authenticated session. Both columns are indexed; CASL uses createdByUserId.