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 lives on the Annotation table (migration 20260505000000_add_annotation_link_type). The export emits the correct linkedEntityId / linkedEventId / linkedTimeId / linkedLocationId field for each annotation, 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.

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. POST /api/annotations also runs an explicit can('read', subject('Persona', persona)) precheck 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 RBAC ownership column) and Annotation.userId (the legacy ownership column, kept for back-compat) from the authenticated session. Both columns are indexed; CASL uses createdByUserId.