Appearance
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
| Column | Notes |
|---|---|
id | text UUID PK |
cover_media_id | the 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. |
source | how the group was formed: manual / filename / near-dup (provenance for a confirmed suggestion). |
created_at, updated_at | ISO timestamps. |
media_group_members
| Column | Notes |
|---|---|
id | autoincrement PK |
group_id | FK → media_shot_groups, cascade. |
media_id | FK → media_assets, cascade. Unique — an asset belongs to at most one shot group. |
variation_state | the frame's variation, from the controlled vocabulary (below), or null. |
frame_order | scrub 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 heuristic —
parseFilenameStemstrips directory/extension, normalises separators, and peels a trailing frame number and/or a trailing variation word, sodoor-01.jpg,door-open.jpganddoor-closed.jpgall reduce to the stemdoor.clusterByStemgroups ≥2 sharing a stem. - Near-dup —
clusterByEmbeddinggreedily clusters assets by cosine similarity ≥ threshold (default0.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 viaaddMediaTag(idempotent, provenancehuman). 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/removeTagPropagatingwrap the single-asset write: a shared-classification tag on a grouped asset fans out to every member (delegating toclassifyGroup/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 truth
SHARED_CLASSIFICATION_TAG_TYPES($lib/media/shot-groups.ts): everymedia_tagsaxis 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) carrysource(human/dump) — an AI run never writes this table directly; a proposal only becomes live through a human accept. - AI proposals (
media_visual_tags) carryproposed_by_model(the analyzer, e.g.stub-v1) andproposed_for_ref—fnv:<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;
createShotGroupis the only insert path, withsourcerecording 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 aproject— 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 indrizzle/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 withshowingPrimary: false), passes ungrouped rows through, and attaches the dropped members ascollapsedGroup.siblingsfor 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 (threeIN (…)queries) that resolves group id, cover, and full member count for a set of assets, feeding the helper server-side.- Surface wiring —
GET /admin/media/library?collapse=1applies 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/mediascreen omits the flag and lists every asset; the factor/attribute illustration picker (#1244) enables it.LibraryBrowserrenders a group badge + expand control, and a Make primary action per sibling that calls the shared manage endpoint'sset-coveraction (audited) and re-fetches, so the new cover leads subsequent result sets. - Pagination caveat — collapse runs on the fetched page and
totalremains the pre-collapse count, so a collapsed page can render fewer thanlimitrows. 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.