Skip to content

Shot-group model (reference)

Issue #1240. Part of the media-curation workbench (#1238).

A shot group models near-identical frames of the same subject — the same door shown open vs closed, by day vs night, from a different angle, or as a detail crop — as one first-class group. The frames share a single classification and differ only by a per-frame variation. Grouping lets an operator classify once and have it apply to every frame, and powers the public "scrubber" that flips between variations (#410).

Data model

Two tables (migration 0060), FK-less on the controlled vocabularies to stay consistent with the tag spine (#1112):

media_shot_groups

ColumnNotes
idtext UUID PK
cover_media_idthe canonical frame — the group's default thumbnail. FK → media_assets, set null on asset delete so the group survives and a new cover can be chosen.
sourcehow the group was formed: manual / filename / near-dup (provenance for a confirmed suggestion).
created_at, updated_atISO timestamps.

media_group_members

ColumnNotes
idautoincrement PK
group_idFK → media_shot_groups, cascade.
media_idFK → media_assets, cascade. Unique — an asset belongs to at most one shot group.
variation_statethe frame's variation, from the controlled vocabulary (below), or null.
frame_orderscrub order within the group (0 first).

There is no denormalised classification column. A group's shared classification lives in media_tags on the members and is kept in sync by classify-once-propagate — a single source of truth for what a frame depicts.

Variation vocabulary

The controlled variation states a member can carry ($lib/media/shot-groups.ts, pure/client-safe, SHOT_VARIATION_STATES). Extensible — add a value there and it is selectable everywhere; app-validated, not a DB CHECK:

open · closed · folded · stacked · day · night · interior · exterior · angle · detail

A variation is distinct from product classification (the media_tags axes: system / project / attribute / factor / application). Variation says which frame this is; classification says what the subject is.

Auto-clustering (suggestions)

Two passes propose candidate groups for human confirmation ($lib/server/admin/shot-group-suggest.ts). Both consider only ungrouped assets, so a confirmed group is never re-suggested. Nothing is written until an operator confirms — on the per-asset edit page, Shot group → Find suggested groups → Create group.

  • Filename heuristicparseFilenameStem strips directory/extension, normalises separators, and peels a trailing frame number and/or a trailing variation word, so door-01.jpg, door-open.jpg and door-closed.jpg all reduce to the stem door. clusterByStem groups ≥2 sharing a stem.
  • Near-dupclusterByEmbedding greedily clusters assets by cosine similarity ≥ threshold (default 0.92, near-identical), reusing the #1113 media embeddings (cosineSimilarity + loadEmbeddings). This catches same-shot frames whose filenames differ.

Classify-once-propagate

Because a group's members are the same subject, a classification is a property of the group, not the frame ($lib/server/admin/shot-groups.ts):

  • classifyGroup(db, groupId, { tagType, tagRef }) — fans the tag out to every member via addMediaTag (idempotent, provenance human). A per-member failure is collected, never aborts the fan-out.
  • propagateMemberTagsToGroup(db, groupId, mediaId) — the reverse direction: a tag on one member is applied to the whole group.
  • sharedGroupTags(db, groupId) — the tags present on every member (the intersection); this is what "classify once" produces, surfaced in the edit page's Shot group tab.

Auto-propagation on single-asset edits (#1375)

Single-asset tag edits participate in the same contract — a curator can no longer silently diverge a member from its siblings:

  • addTagPropagating / removeTagPropagating wrap the single-asset write: a shared-classification tag on a grouped asset fans out to every member (delegating to classifyGroup / unclassifyGroup, so there is one fan-out path, not two); an ungrouped asset takes the plain single-asset path unchanged.
  • Both single-asset edit surfaces route through it — the edit page's Classification tab and the library's inline editor — one propagation contract, not two. (Bulk multi-select tagging remains an explicit multi-asset operation and does not additionally fan out per group.)
  • The partition of what propagates is the single source of truthSHARED_CLASSIFICATION_TAG_TYPES ($lib/media/shot-groups.ts): every media_tags axis is shared classification; per-frame variation (open/closed, day/night) is not a tag type at all — it lives on the membership row and never propagates. A drift-guard spec forces a future tag type to pick its side of the partition deliberately.
  • Propagation is visible and reversible: the UI reports "applied to all N shot-group members", an audit row records the fan-out (entityType: 'media-shot-group'), and removing the tag from any member clears it group-wide (the symmetric undo).

Project-tag provenance & the AI evidence bar (#1429)

Every project-tag write is attributable to what produced it, and propagation never runs against an unconfirmed group:

  • Live tags (media_tags) carry source (human / dump) — an AI run never writes this table directly; a proposal only becomes live through a human accept.
  • AI proposals (media_visual_tags) carry proposed_by_model (the analyzer, e.g. stub-v1) and proposed_for_reffnv:<hash> for a single-asset run, group:<groupId>:<coverRef> for a group run-on-cover (#1373) — so a group-propagated proposal is inspectably distinct from a per-asset one.
  • Groups exist only by human confirmation: the suggestion passes above write nothing; createShotGroup is the only insert path, with source recording which heuristic the operator confirmed (manual / filename / near-dup). Classification propagation (classifyGroup, addTagPropagating, group run-on-cover) therefore always operates within a confirmed group.
  • Identity axes need real evidence (MIN_HITS_BY_TAG_TYPE, $lib/server/media-analysis.ts): the lexical stub requires ≥2 distinct label-token hits before proposing a project — a single generic token ("residence", a city name) used to fan out to every project label containing it and fill assets' AI tabs with unrelated projects (bug #1429; corrective cleanup in drizzle/0069_stub_project_proposal_cleanup.sql).

Cover frame

setCoverFrame(db, groupId, mediaId) sets the canonical/default thumbnail; it must be a current member. Removing the cover frame re-points the cover at the first remaining member; a group that drops below two members is dissolved. Every cover change is audited (entityType: 'media-shot-group', action: 'update') — from the edit page and from any result surface (#1276).

Collapse to primary (#1276)

The payoff of grouping: a result / search / gallery surface shows one representative per shot group — its cover/primary frame — instead of five near-identical frames of the same subject.

  • collapseToPrimary(rows) ($lib/media/collapse.ts) — a pure, order-preserving helper. Given candidate rows carrying their group membership ({ id, group: { groupId, coverMediaId, memberCount } }), it yields one row per group (the cover when present, else the first on-page member with showingPrimary: false), passes ungrouped rows through, and attaches the dropped members as collapsedGroup.siblings for expand-in-place. Deterministic and client-safe, so it is unit-tested (collapse.spec.ts) without a DB.
  • shotGroupInfoForAssets(db, ids) (server/admin/shot-groups.ts) — the bounded batch read (three IN (…) queries) that resolves group id, cover, and full member count for a set of assets, feeding the helper server-side.
  • Surface wiringGET /admin/media/library?collapse=1 applies the collapse (pulling in any off-page cover so the representative is always the primary) and returns { group, siblings } per collapsed row. Opt-in: the main /admin/media screen omits the flag and lists every asset; the factor/attribute illustration picker (#1244) enables it. LibraryBrowser renders a group badge + expand control, and a Make primary action per sibling that calls the shared manage endpoint's set-cover action (audited) and re-fetches, so the new cover leads subsequent result sets.
  • Pagination caveat — collapse runs on the fetched page and total remains the pre-collapse count, so a collapsed page can render fewer than limit rows. This is acceptable for the picker; a fully collapse-aware paginator is out of scope.

Wistia thumbnails

A Wistia video thumbnail is an ordinary media_assets row (type='image' carrying embed_provider='wistia' + embed_video_id, see referenced-media / #361). Membership gates on existence, not type, so a Wistia thumbnail is a valid group member with no special handling.

Surface

The per-asset edit page (/admin/media/[id], managing media) hosts the Shot group tab: for a grouped asset it shows the members (with cover, variation and order), the shared classification, a classify-whole-group control, and a leave-group action; for an ungrouped asset it finds and confirms suggested groups.