Skip to content

spec: Bursts — group photos of the same scene by camera label or feature similarity, show one cover per burst #296

Closed
3 of 3 issues completed

Description

@bitbiter-dev

Problem Statement

When I look at a flashback, the same moment shows up again and again: an iPhone burst puts ten nearly identical frames into On This Day, and so do the two or three shots I take of the same scene a few seconds or a minute apart because the first one was slightly off. The flashback should show one memory per moment, not a strip of near-duplicates, yet I still want to be able to look at every frame of that moment when I choose to.

The Bursts table already exists, but nothing detects bursts, so in a running system it is empty and every frame is shown on its own (#26).

Solution

The Worker detects bursts: photos of the same scene from one storage config, taken moments apart (see GLOSSARY.md). A photo joins a burst either because the camera labelled it as part of one (Apple's BurstUUID) or because it looks like a neighbouring frame from the same camera taken shortly before or after it. Similarity is judged on the stored thumbnail by feature matching, so a frame that is slightly panned or re-framed still counts, while a different scene does not.

Detection runs as its own work item per storage config, after each crawl and periodically, and only looks at photos it has not checked yet.

On This Day then shows each burst once, as its cover (the earliest member the viewer has not hidden), marked with how many frames it holds, and a new endpoint lists all of a burst's frames so the UI can let me page through them.

User Stories

  1. As a user, I want an iPhone burst to appear in On This Day as one item, so that ten near-identical frames do not crowd out my other memories.
  2. As a user, I want two or three shots of the same scene taken a few seconds or a minute apart to appear as one item, so that my retakes do not repeat the same memory.
  3. As a user, I want a slightly panned or re-framed retake to still count as the same scene, so that small camera movements do not split a moment in two.
  4. As a user, I want photos of different scenes taken a minute apart to stay separate, so that distinct memories are never hidden behind each other.
  5. As a user, I want a photo that looks like a frame of an iPhone burst to join that burst even though the camera did not label it, so that a retake right after a burst is grouped with it.
  6. As a user, I want two camera bursts that a similar photo connects to become one burst, so that one moment is never shown twice.
  7. As a user, I want a chain of similar shots (each like the one before) to form one burst, so that a short sequence where the scene drifts slowly is still one memory.
  8. As a user, I want a burst to stop growing once its frames span five minutes, or once two consecutive frames are more than two minutes apart, so that a long event (a dinner, a party) is not swallowed into a single item.
  9. As a user, I want only photos from the same camera to be grouped, so that my phone's photo and my partner's camera's photo of the same scene stay separate memories.
  10. As a user, I want screenshots, messenger images without camera data and scans never to be grouped, so that unrelated images taken near each other are not mistaken for a burst.
  11. As a user, I want videos never to be part of a burst, so that a clip is always shown as itself.
  12. As a user, I want the photo half of a Live Photo to be able to join a burst, so that Live Photos are grouped like any other photo.
  13. As a user, I want a burst to only ever contain photos from one of my storage configs, so that grouping respects how my library is organised.
  14. As a user, I want the earliest frame I have not hidden to be the burst's cover, so that the item I see is predictable and never one I chose to hide.
  15. As a user, I want a burst whose frames I have all hidden to disappear from my flashbacks, so that hiding works for bursts the way it does for single photos.
  16. As a user, I want a burst to count as starred if I starred any visible frame of it, so that starring my favourite frame lifts the whole moment to the top of its year.
  17. As a user, I want a burst to count as liked if I liked any visible frame of it, so that liking a frame weights the moment like a liked photo.
  18. As a user, I want to see how many frames a burst has, counting only those I can see, so that I know there is more to look at.
  19. As a user, I want to open a burst and see all its visible frames, oldest first, so that I can pick through the whole moment when I want to.
  20. As a user, I want a burst to appear on the day its cover was taken, so that a burst taken across midnight shows up once, on a sensible day.
  21. As a user, I want a burst across midnight to move to the next day when I have hidden every frame before midnight, so that the day always follows the frame I actually see.
  22. As a user, I want another user's burst id to look exactly like an unknown one, so that nobody can probe which bursts exist.
  23. As a user, I want a burst whose other frames were deleted to fall apart once only one frame is left, so that a lone photo is never shown as a "burst of 1".
  24. As a user, I want a burst whose cover was deleted to get a new cover, so that the burst keeps showing.
  25. As a user, I want a photo I edited in place to be checked again, so that its new content is grouped by what it now shows.
  26. As a user, I want a photo that was deleted and later restored to be checked again, so that it can rejoin the burst it belongs to.
  27. As a user, I want a photo I moved to another folder to stay in its burst, so that reorganising my folders does not change my flashbacks.
  28. As a user, I want my existing library to be grouped after the feature is deployed, without re-importing anything, so that older flashbacks benefit too.
  29. As a user, I want new photos to be grouped soon after a crawl picks them up, so that I do not wait a whole interval to see them grouped.
  30. As an admin, I want burst detection to show up as a work item per storage config, with its progress and result, so that I can see that it ran, how far it got and what it did.
  31. As an admin, I want to cancel a running burst detection like any other work item, so that a long first run on a big library does not block other work.
  32. As an admin, I want a crawl and a periodic schedule never to queue burst detection twice for the same storage config, so that the queue does not fill with duplicate work.
  33. As an admin, I want to tune the similarity threshold, the gap and the span through Worker settings, so that I can adapt grouping to my photos without a code change.
  34. As an admin, I want each compared pair's match score in the debug log and the run's counts in its result, so that I can tune the threshold from evidence in my own library.
  35. As an admin, I want a changed threshold to apply to photos checked from then on, and to know that already checked photos keep their grouping, so that I understand what a setting change does.
  36. As an operator, I want the Worker image to run burst detection on both amd64 and arm64, so that my NAS's architecture does not matter.

Implementation Decisions

Decided by the maintainer in conversation (2026-10-23, /grill-with-docs). How the parts are built belongs to the design note.

Domain language

  • Burst stays the term (table, entity, API) for the widened concept: photos of the same scene from one storage config, taken moments apart, shown as one memory. Avoid "moment", "similar group", "near-duplicates".
  • Cover is the term for the representing member, per viewing user: the earliest member that user has not hidden. The stored PrimaryAssetId keeps its column name and holds the earliest member overall. The API says isCover, not isPrimary.
  • Both terms are in GLOSSARY.md (branch docs/26-burst-design).

Membership

  • Members are photos only. Videos and a Live Photo's paired MOV never join; the Live Photo's photo half can.
  • A member needs an EXIF capture time. Two frames are only compared when both have the same camera make and model; two photos with no camera data count as different cameras.
  • All members of a burst belong to one storage config.

Grouping rule

  • Photos with the same Apple BurstUUID (EXIF maker note, already readable through MetadataExtractor) form one burst, regardless of time or similarity.
  • Every other membership needs similarity: a photo joins when it matches a neighbouring frame. Time alone never groups.
  • Matching chains: A matches B and B matches C puts all three in one burst.
  • Consecutive frames at most 2 minutes apart; a whole burst spans at most 5 minutes. Both are settings.
  • A similar photo also joins a BurstUUID burst; a photo matching frames of two bursts merges them. The merged burst's stored cover is its earliest member.

Similarity

  • Feature matching (ORB keypoints, RANSAC for one consistent transform) on the stored 300 px thumbnail, through OpenCV in .NET (OpenCvSharp or Emgu CV). Two frames are similar when the share of keypoints that match one consistent transform reaches a configurable threshold, default about 25%, to be tuned on real photos. Originals are never re-read.

  • Perceptual hashes were rejected on measurement. On the maintainer's sample pair (IMG_4386/4387, 3 s apart, panned about 5%, dense foliage) against 8 unrelated images (OpenCV samples), all on 300 px thumbnails:

    Method Sample pair Closest unrelated pair
    dHash, bits differing of 64 22 18 (no separation)
    wHash 10 18
    ORB + RANSAC, matching share 0.52 (260 inliers) 0.07 (the same wall from about 40° apart)

    Nine images do not give a precision figure; same place, different content (a dinner table with people moving) is the expected false-positive case, which the gap, span and threshold settings bound.

  • Before committing to a library, the design note confirms a native linux-arm64 build exists and the licence is compatible with AGPL-3.0. If neither library qualifies, the design note brings it back to the maintainer before choosing another approach. The OpenCV dependency gets an ADR.

Running it

  • A new work kind, BurstDetection, one item per storage config, scoped to it (StorageConfigId), with a dedup key per config.
  • The config's Crawl handler enqueues it when the crawl finishes; a periodic schedule per active config (the crawl's interval) enqueues it as a fallback. Both share the dedup key.
  • Runs are incremental: each photo is marked once it has been checked. A run compares only unchecked photos against their time neighbours (checked or not) and joins or merges existing bursts. The first run after deployment checks the whole library.
  • The mark is cleared when a file is edited in place (ContentChanged). A soft-deleted asset restored with its content unchanged (Auto-restore a soft-deleted asset when its file is back at its path or its content turns up elsewhere #37) loses only its BurstChecks row: it keeps its burst and is checked again. A moved file keeps its mark and its burst. (Edited after feat(worker): Missing files are soft-deleted and restored when they reappear (#303, #37, #301, #34) #324.)
  • Changing a setting applies to photos checked from then on. Recalculating everything is out of scope (see below).
  • Every run repairs the bursts it touches: fewer than 2 active members dissolves the burst; a missing or inactive stored cover is replaced by the earliest remaining member. Soft-delete (Soft-delete assets whose file a complete crawl did not find #34) does not need to know about bursts; a single-member burst may exist until the next run.
  • The run's result reports photos checked, bursts created, merged and dissolved, and pairs matched and not matched. Each compared pair's score is logged at Debug.
  • Settings are Worker-wide (Worker:Burst…): gap, span and similarity threshold. Not per user, not per storage config.

API

  • On This Day shows one item per burst: the viewer's cover, carrying burst { burstId, isCover, count }, where count is the number of members visible to the caller (VisibleTo and not hidden for them).
  • A burst whose members are all hidden for the caller does not appear.
  • A burst counts as starred, and as liked (DisplayWeight), when any visible member is.
  • The cover's capture date decides the day the burst appears on. If every member before midnight is hidden, the burst appears on the next day. On This Day therefore cannot select burst members by month and day alone.
  • GET /api/v1/bursts/{id}/assets returns the burst's members visible to the caller, oldest first, as asset views, through a new IAssetSelection read by IAssetViewReader. An unknown burst, another user's burst and a burst with no visible member all return 404.
  • Paging through a burst's frames is a UI concern; the API only lists them.

First caller

  • The Worker's BurstDetection handler is the first caller of the picture comparison and of the checked mark; the work scheduler and the Crawl handler are its own callers.
  • On This Day (GET /api/v1/flashbacks/on-this-day) is the first caller of the burst collapse and the burst field; the UI (Epic 7) is the first caller of GET /api/v1/bursts/{id}/assets, which ships in the same PR as the collapse so the burstId it hands out can be followed.

Design

Design note: #296 (comment)
Decision (maintainer, 2026-10-23): #296 (comment)

Testing Decisions

  • A good test drives a module through its public behaviour and asserts on what the user or caller sees (bursts and their members in the database, the response body), with expected values from the rules above, never recomputed the way the code does. Time-dependent behaviour goes through Timeline; no real-time waits.
  • BurstDetection handler (Worker, unit): in-memory data in, bursts out. Covers BurstUUID grouping, chaining, the 2 min gap and 5 min span limits, same camera only, EXIF-only membership, photos only (videos and paired MOVs excluded), merging two bursts, repair (dissolve below 2, new stored cover), incremental marks (checked photos not re-compared, cleared marks re-checked), and the run's result counts. The picture comparison is a substitute that returns scripted scores. Prior art: CrawlTests, SharedContentReporterTests.
  • Picture comparison (Worker, unit): the real OpenCV implementation on committed fixture thumbnails: a 300 px copy of IMG_4386/4387 without EXIF (approved by the maintainer) must score above the default threshold, and clearly different images below it. This is the one new seam, there because OpenCV is native.
  • Members selection and the On This Day fetch across midnight (Core, unit): AssetViewReaderTests with ToQueryString() on AnichronDbContexts.NpgsqlModel() for the SQL shape, InMemory for data.
  • Collapse, per-user cover, count, starred and liked (API, unit): FlashbackYearsTests.
  • GET /api/v1/bursts/{id}/assets and its 404s, burst field on On This Day (API, unit): FlashbackEndpointsTests.
  • Scheduling and dedup of the new kind: the existing work-queue and schedule tests' patterns (WorkExecutorTests, the schedule source tests); WorkHandlerRegistry already fails startup if the kind lacks a payload or handler.
  • Mutation testing at 100% on the changed files, as everywhere.

Out of Scope

Further Notes

  • Supersedes the one-line scope of Detect burst sequences and group into Burst record #26 and its 2026-10-16 scope note (the isPrimary field there becomes isCover).
  • Precision is measured once a prototype runs on a folder of the maintainer's photos; the default threshold is tuned from the run's debug scores.
  • The maintainer will split this with /to-tickets: detection (Worker, with the picture comparison and the ADR) first, then the On This Day collapse with the members endpoint (API).

🤖 Generated with Claude Code

Activity

  1. added
    enhancementNew feature or request
    v1.0Required for the v1.0 release
    needs-designNeeds a design note (options, first caller, reversibility) before ready-for-agent
    on Oct 23, 2026
  2. bitbiter-dev commented on Oct 23, 2026

    @bitbiter-dev
    OwnerAuthor

    Design note

    Covers #296 and its tickets #297 (camera-labelled bursts, scaffolding), #298 (similarity) and #299 (API). Terms as in GLOSSARY.md (branch docs/26-burst-design): Burst, Cover.

    Problem and constraints:

    • Requirements are fixed by spec: Bursts — group photos of the same scene by camera label or feature similarity, show one cover per burst #296; this note decides only how.
    • Only the Worker writes media, and the API reads it only through IAssetViewReader and IAssetSelection (api-reads-media-through-views).
    • Work-item handlers are delivered at least once: a run must start from scratch and converge.
    • One Worker runs one item at a time, and a storage config is served by one Worker. A config's BurstDetection therefore never overlaps its Crawl, and no locking is needed.
    • What the store does not know today:
      • whether DateCaptured came from EXIF or from the file's last-write time (CaptureDate.FromExifOrLastWrite keeps no source);
      • Apple's BurstUUID (never read).
    • Every photo already in the library needs both, without a re-import.
    • DateCaptured has whole-second precision, which is enough for a 2-minute gap.
    • The Worker image is multi-arch (amd64, arm64, Ubuntu noble), and developers run the unit tests on macOS arm64.
    • The existing Bursts schema breaks once rows exist. Bursts.PrimaryAssetId is ON DELETE RESTRICT, while MediaAssets cascades from StorageConfigs. The first delete of a storage config holding a burst would fail on the cover, and so would spec: See soft-deleted assets and purge them #47's hard delete of a cover. The API cannot delete bursts first, because it never writes media. The fix belongs at the foreign key (section 1).
    • Burst's global query filter goes through PrimaryAsset, so a burst whose cover was soft-deleted is invisible to normal queries. Burst has no StorageConfigId, so a config's bursts can only be found through their members.

    1. Where the check state and the camera label live

    Option A: one table owned by detection, BurstChecks. One row per checked photo:

    • AssetId: primary key, foreign key to MediaAssets, cascades on delete.
    • CheckedAt.
    • IsCandidate: a photo with an EXIF capture time, camera make and model, and a thumbnail.
    • CameraBurstId: Apple's BurstUUID, nullable.

    A missing row means unchecked. Clearing the mark deletes the row. Ingestion is untouched apart from the two clearing points (section 5). Cost: one more table, and one join when the run loads neighbours.

    Option B: columns written at ingest. MediaAssets.BurstCheckedAt, plus Metadata.CameraBurstId and Metadata.HasExifCaptureTime, filled by ExifExtractionMiddleware and ProcessedContentWriter. Cost:

    • Ingestion changes in three places.
    • Photos already stored need a separate backfill that reads their originals. Ingest and backfill become two writers of the same columns, and a null label is ambiguous ("no label" or "never read") until the backfill has run.

    In both options, fix the cover's foreign key. PrimaryAssetId becomes nullable with ON DELETE SET NULL, and repair picks a new cover. The alternative, CASCADE, would delete the whole burst with its cover: its members would lose their grouping, and being already checked, would never be grouped again. Deleting a storage config then leaves Bursts rows without members, which repair deletes (section 5). The Burst query filter becomes "cover is null or not soft-deleted", the column comment is rewritten, and BurstChecks gets the same asset-scoped soft-delete filter as Metadata and ProxyFile, so the single-global-filter invariant ModelInvariantsTests pins keeps holding.

    2. Who reads BurstUUID and the EXIF capture time

    Option A: detection. When the run checks a photo, it reads that photo's original once (EXIF and the Apple maker note through MetadataExtractor, :ro mount) and stores the result in its BurstChecks row. Each photo is read once in its life, or again after its mark is cleared. The first run reads every photo once. That costs less than a crawl, which hashes every file in full. An unreadable original makes the photo a non-candidate (logged), not a failed run.

    Option B: ingestion, as in 1B, plus a one-time backfill.

    3. Scheduling

    Option A: copy the pattern. Add BurstDetectionSchedules beside CrawlSchedules: same served-config lookup, different payload.

    Option B: change the pattern. Extract the served-config lookup into one schedule source per payload type, used by both Crawl and BurstDetection, so a third per-config kind (#174's family) adds nothing. Cost: touches the crawl's scheduling and its tests.

    Either way:

    • BurstDetectionPayload(StorageConfigId) is scoped, with dedup key burst-detection:{id}, and runs on the crawl interval.
    • CrawlHandler enqueues it through IWorkQueue after a crawl that ran (not after a skipped or failed one). The queue coalesces with a queued item, and the scheduler's due-ness then sees the finished run, so the two never double up.

    4. Picture comparison (#298)

    The seam. IThumbnailMatcher in the Worker: ScoreAsync(thumbnail A, thumbnail B, ct) → double in 0–1. It is the share of keypoints that fit one consistent transform (ORB, 500 features, ratio test, RANSAC homography, as in the measurement on #296).

    • It reads the stored Thumbnail proxy bytes through IFileSystem and decodes them with OpenCV.
    • It caches each thumbnail's keypoints for the run, so a photo compared with five neighbours is decoded once.
    • A photo without a thumbnail is not a candidate.

    Option A: OpenCvSharp4.

    • Licence: Apache-2.0, compatible with AGPL-3.0.
    • Native runtimes from the same maintainer at the same version (4.13.0.20260627), checked on nuget.org 2026-10-23: OpenCvSharp4.official.runtime.linux-x64, OpenCvSharp4.runtime.linux-arm64, OpenCvSharp4.runtime.osx.arm64.
    • Cost: a native library of tens of MB in the Worker image, and a risk that it needs system libraries noble lacks.

    Option B: Emgu CV.

    • Licence: GPL-3.0 or commercial. GPL-3.0 can be combined with AGPL-3.0, but it is a heavier obligation.
    • Runtimes are at different versions per platform: debian-arm64 4.12, ubuntu-24.04-x64 5.0, mini.macos 5.0. Cost: version skew between the image and developer machines.

    Option C: our own matcher on ImageSharp (FAST corners plus BRIEF, our own RANSAC). No native dependency, but we would write and maintain a computer-vision algorithm ourselves. Under the 100% mutation gate it would also be a large mutation surface: every constant and branch of the keypoint detector, the descriptor and RANSAC would need a killing test.

    Guards for any OpenCV option:

    • An architecture rule keeps the OpenCV namespace inside the matcher's implementation.
    • The matcher's tuning constants (500 features, the 0.75 ratio test, the 5.0 px RANSAC threshold) are named constants in the one OpenCV class. They are pinned by the fixture tests: the sample pair above the threshold, the unrelated images below it. Any surviving mutant of them is either killed by a further fixture or shown equivalent under ADR 0005.
    • feat(worker): Camera-labelled bursts — BurstDetection work item, checked mark and repair #297 first confirms that MetadataExtractor yields an AppleMakernoteDirectory from a HEIC (the maintainer's two files will do), since the camera-label rule depends on it.
    • feat(worker): Similar photos join bursts — OpenCV feature matching on thumbnails #298 starts with a smoke test: load the native library and score the fixture pair inside the built Worker image on amd64 and arm64. If that fails, it stops and comes back here.

    5. Grouping, clearing and repair (#297, #298)

    A run for config C:

    1. Repair every burst in C. Bursts are loaded through MediaAssets.BurstId of C's rows with IgnoreQueryFilters(), because a burst with a soft-deleted cover is filtered out otherwise.
      • Fewer than 2 active members: delete the burst. MediaAssets.BurstId is set to null by the foreign key.
      • Otherwise: set PrimaryAssetId to the earliest active member.
      • Bursts with no member rows left at all (after a storage config was deleted) are deleted too. This is the one repair not scoped to C: a single statement, harmless to run from any config's run.
    2. Check the unchecked active photos of C, in DateCaptured order and in batches. Photos are MediaType photo; a paired MOV is a video. For each photo P, read its original (section 2) and write its BurstChecks row.
    3. Group each candidate P:
      • Camera label: P joins every burst holding a candidate with the same CameraBurstId.
      • Similarity: P is compared with candidates of the same camera make and model whose capture time is within the gap (2 min) of P's. That covers checked ones, and unchecked ones earlier than P (later unchecked ones compare with P on their own turn), so no pair is compared twice in a run, and never again once both are checked.
      • A score at or above the threshold joins P to that neighbour (feat(worker): Similar photos join bursts — OpenCV feature matching on thumbnails #298). In feat(worker): Camera-labelled bursts — BurstDetection work item, checked mark and repair #297 the similarity step does not exist yet.
      • Merging: joins are unions, so P can merge two bursts. A similarity join that would make the burst span more than 5 minutes is skipped. Camera-label joins ignore the span.
      • A new burst's stored cover is its earliest member.
    4. Save P's row and its burst changes together in one SaveChanges. A re-run after a crash finds P checked and its burst consistent, so the run converges.

    Clearing the mark sets BurstId to null and deletes the BurstChecks row. This happens:

    A restore with unchanged content (#37) deletes only the BurstChecks row and keeps BurstId: the photo is the same, so it stays in its burst if that burst still exists, and the next run checks it again. (Edited after #324.)

    A cleared photo leaves its old burst, and step 1 of the next run dissolves the burst if only one member is left. A moved file keeps both.

    Result: checked, created, merged, dissolved, and (#298) matched and not matched. Progress reports the photos checked. Each score is logged at Debug.

    Settings: Worker:BurstGapSeconds (120), Worker:BurstSpanSeconds (300) and Worker:BurstSimilarityThreshold (0.25), validated at startup.

    6. API (#299)

    Reader changes:

    • AssetView gains BurstId.
    • OnThisDay.Apply also selects every member of a burst that has a member on that day. It uses the assets it is given (already VisibleTo and NotHiddenFor the caller) for that check, so only visible members can pull a burst in, and members across midnight come along.
    • New BurstMembers(BurstId) selection.

    Option A: collapse in memory in the API, as FlashbackYears already groups and orders:

    • Group the views that have a BurstId by it; views without one stay individual items. The cover is the earliest view by DateCaptured, then Id.
    • Keep the group only if the cover's month and day are the requested day and its year is earlier.
    • count is the group's size; starred and liked are any member's.
    • The resulting item is the cover's AssetDto plus burst { burstId, isCover: true, count }.

    GET /api/v1/bursts/{id}/assets reads BurstMembers, orders oldest first, maps each member with isCover set on the earliest one, and returns 404 on an empty result.

    Option B: collapse in SQL with window functions over the caller's visible members. One query, but the per-user cover and the day rule move into a query that is hard to test, and it breaks with the reader's "a view is one asset" shape.

    Recommendation

    First caller

    Hard to reverse

    • The BurstChecks table and its migration.
    • Bursts.PrimaryAssetId becomes nullable with SET NULL instead of RESTRICT, and the Burst query filter changes with it.
    • The public contract: burst { burstId, isCover, count } on assets, and GET /api/v1/bursts/{id}/assets.
    • A native OpenCV dependency in the Worker image (amd64 and arm64), recorded in an ADR in feat(worker): Similar photos join bursts — OpenCV feature matching on thumbnails #298.
    • Burst rows written under one threshold stay until the admin "recalculate all" request (out of scope).

    Decision needed

    1. Detection owns its check state and reads the label from originals (1A + 2A), or ingestion stores the label and the EXIF source with a backfill (1B + 2B)? Recommendation: 1A + 2A.
    2. The image-matching library: OpenCvSharp4 (Apache-2.0, version-aligned runtimes), Emgu CV (GPL-3.0, version skew) or our own matcher on ImageSharp? Recommendation: OpenCvSharp4.
    3. Generalize the per-config schedule source (3B) or copy it (3A)? Recommendation: 3B.

    🤖 Generated with Claude Code

  3. added
    needs-decisionWaiting on a decision from the maintainer
    and removed
    needs-designNeeds a design note (options, first caller, reversibility) before ready-for-agent
    on Oct 23, 2026
  4. bitbiter-dev commented on Oct 23, 2026

    @bitbiter-dev
    OwnerAuthor

    Decision

    Decided by the maintainer on 2026-10-23, on the design note:

    1. 1A + 2A: detection owns its state in a new BurstChecks table and reads BurstUUID and the EXIF capture time from each photo's original when it checks it. Ingestion stays untouched apart from clearing the mark; no backfill.
    2. 4A: OpenCvSharp4 (Apache-2.0; official.runtime.linux-x64, runtime.linux-arm64, runtime.osx.arm64 at one version). feat(worker): Similar photos join bursts — OpenCV feature matching on thumbnails #298 starts with the smoke test in the Worker image on amd64 and arm64; if it fails, this comes back to the maintainer. The ADR is written in feat(worker): Similar photos join bursts — OpenCV feature matching on thumbnails #298.
    3. 3B: the per-config schedule source is generalized and shared by Crawl and BurstDetection.

    Sections 5 (grouping, clearing, repair, the cover's foreign key becoming nullable with SET NULL) and 6A (collapse in memory in the API) stand as written. #297, #298 and #299 move to ready-for-agent.

  5. bitbiter-dev commented on Oct 28, 2026

    @bitbiter-dev
    OwnerAuthor

    All three tickets are done: #297 (camera-labelled bursts, #300), #298 (similar photos join bursts, #308), #299 (On This Day shows one cover per burst; burst members endpoint, #317). ADR 0009 records the OpenCV decision, and GLOSSARY.md defines Burst and Cover. Out of scope, as the spec lists: an admin recalculation after a setting change, and the burst-paging UI (Epic 7).

    🤖 Generated with Claude Code

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    enhancementNew feature or requestv1.0Required for the v1.0 release

    Projects

    No projects

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions