Photos: pairs and stacks (RAW+JPEG, Live Photos, bursts) #29

Closed
opened 2026-09-24 14:45:06 +00:00 by kayg · 11 comments
Owner

Owner decision (round 13, P4): one item per logical photo.

  • RAW+JPEG pairs (e.g. Canon R6 Mk III .CR3 + Lightroom .JPG, same base name, capture time within 2 s) show as one tile with a RAW badge; the JPEG is the display image, the RAW is available in the viewer.
  • Live Photos: HEIC/JPEG + MOV paired by Apple's ContentIdentifier (fallback base name + time); tile shows LIVE, the viewer plays the motion on press/hover.
  • Bursts: grouped by burst ID; tile shows a count; the viewer lets the user pick the key photo.
  • Delete asks "delete both / just the RAW / just the JPEG" (or the whole burst); nothing is deleted silently.
  • Pairing is derived data (index), recomputed from files; user choices (key photo of a burst) are stored in XMP sidecars.

Design: DESIGN §12, §28.

Context for the owning job

  • Repo: kayg/calternal (~/Developer/calternal). Read CLAUDE.md, CONTEXT.md and docs/DESIGN.md first; this issue's section is cited below.
  • Owner rules that always apply: file over app (plain files are the truth, the DB is an index); the server is the single writer; data loss is unacceptable; performance first, never at the cost of finesse; UI is the calternal.js design system (copy components verbatim, compare side by side with calternal.js reference screenshots; Claude does visual review); never ship sample/mock data; atomic commits; adversarial testing after API work; good enough, not perfect (merge blockers: crash/DoS, data loss, security, sync collisions).
  • Comment on this issue when you start (branch, base SHA), on each finding, when blocked, and when finished (head SHA + gate output). Never close it.
Owner decision (round 13, P4): one item per logical photo. - RAW+JPEG pairs (e.g. Canon R6 Mk III `.CR3` + Lightroom `.JPG`, same base name, capture time within 2 s) show as one tile with a `RAW` badge; the JPEG is the display image, the RAW is available in the viewer. - Live Photos: HEIC/JPEG + MOV paired by Apple's `ContentIdentifier` (fallback base name + time); tile shows `LIVE`, the viewer plays the motion on press/hover. - Bursts: grouped by burst ID; tile shows a count; the viewer lets the user pick the key photo. - Delete asks "delete both / just the RAW / just the JPEG" (or the whole burst); nothing is deleted silently. - Pairing is derived data (index), recomputed from files; user choices (key photo of a burst) are stored in XMP sidecars. Design: DESIGN §12, §28. ## Context for the owning job - Repo: kayg/calternal (~/Developer/calternal). Read CLAUDE.md, CONTEXT.md and docs/DESIGN.md first; this issue's section is cited below. - Owner rules that always apply: file over app (plain files are the truth, the DB is an index); the server is the single writer; data loss is unacceptable; performance first, never at the cost of finesse; UI is the calternal.js design system (copy components verbatim, compare side by side with calternal.js reference screenshots; Claude does visual review); never ship sample/mock data; atomic commits; adversarial testing after API work; good enough, not perfect (merge blockers: crash/DoS, data loss, security, sync collisions). - Comment on this issue when you start (branch, base SHA), on each finding, when blocked, and when finished (head SHA + gate output). Never close it.
Author
Owner

Starting server-side implementation for #27, #28, #29, and #33.

Branch: job/photos-core
Base SHA: 57118d9648582e682f0a0e1997fc8ad9f84bab35

I have read the issue bodies and repository design/context. I am mapping the existing Files upload, thumbnail, identity, and plugin registration APIs before defining the photos plugin boundary.

Starting server-side implementation for #27, #28, #29, and #33. Branch: `job/photos-core` Base SHA: `57118d9648582e682f0a0e1997fc8ad9f84bab35` I have read the issue bodies and repository design/context. I am mapping the existing Files upload, thumbnail, identity, and plugin registration APIs before defining the photos plugin boundary.
Author
Owner

Finding: the existing files_index rows contain path, hash, size, modified time and MIME, but no capture date, ContentIdentifier, burst ID or pairing (crates/plugins/files/migrations/0001_files.sql). Photos therefore needs a rebuildable derived metadata/grouping index keyed to the Files item IDs; user burst-key choices will remain XMP sidecars.

Finding: the existing `files_index` rows contain path, hash, size, modified time and MIME, but no capture date, ContentIdentifier, burst ID or pairing (`crates/plugins/files/migrations/0001_files.sql`). Photos therefore needs a rebuildable derived metadata/grouping index keyed to the Files item IDs; user burst-key choices will remain XMP sidecars.
Author
Owner

Additional finding: Apple documents PHAsset.burstIdentifier as a property on Photos-library assets (Apple documentation), while this job indexes ordinary files in a Home. The identifier is not guaranteed in EXIF/XMP file metadata, and the server cannot query PhotoKit. I can group bursts when an identifier exists in file metadata or an XMP sidecar, but complete Apple Photos burst grouping would need a calternald/PhotoKit sidecar contract outside this server-only job.

Additional finding: Apple documents `PHAsset.burstIdentifier` as a property on Photos-library assets ([Apple documentation](https://developer.apple.com/documentation/photos/phasset/burstidentifier?language=objc)), while this job indexes ordinary files in a Home. The identifier is not guaranteed in EXIF/XMP file metadata, and the server cannot query PhotoKit. I can group bursts when an identifier exists in file metadata or an XMP sidecar, but complete Apple Photos burst grouping would need a `calternald`/PhotoKit sidecar contract outside this server-only job.
Author
Owner

Starting server-side implementation for #27, #28, #29, and #33.

Branch: job/photos-core
Base SHA: 41aa77499e0a090371ff252458722fd80b4e6a44

I read each issue body and comment thread, plus CLAUDE.md, docs/DESIGN.md, and CONTEXT.md. The approved scope includes a rebuildable Photos index keyed by Files item IDs, opt-in library roots in .calternal/settings.json, date-routed Photos uploads using the existing tus staging/install pipeline, pairing and compact paginated timeline APIs with ThumbHash placeholders, and source-mtime metadata from calternald through server installation.

Starting server-side implementation for #27, #28, #29, and #33. Branch: `job/photos-core` Base SHA: `41aa77499e0a090371ff252458722fd80b4e6a44` I read each issue body and comment thread, plus CLAUDE.md, docs/DESIGN.md, and CONTEXT.md. The approved scope includes a rebuildable Photos index keyed by Files item IDs, opt-in library roots in `.calternal/settings.json`, date-routed Photos uploads using the existing tus staging/install pipeline, pairing and compact paginated timeline APIs with ThumbHash placeholders, and source-mtime metadata from `calternald` through server installation.
Author
Owner

Finding: nom-exif 3.8.0 exposes the EXIF MakerNote field, but its CR3 parser explicitly skips the proprietary CMT3 MakerNotes block (src/exif.rs:155 in the crate source). The Photos grouping model therefore accepts an extracted burst ID when available and implements the approved fallback as same-camera images in a burst spanning at most one second. RAW+JPEG grouping uses the same folder and basename, with the JPEG as display item. The focused four-test pairing suite passes.

Finding: `nom-exif` 3.8.0 exposes the EXIF MakerNote field, but its CR3 parser explicitly skips the proprietary CMT3 MakerNotes block (`src/exif.rs:155` in the crate source). The Photos grouping model therefore accepts an extracted burst ID when available and implements the approved fallback as same-camera images in a burst spanning at most one second. RAW+JPEG grouping uses the same folder and basename, with the JPEG as display item. The focused four-test pairing suite passes.
Author
Owner

Additional pairing evidence: Apple's MakerNote stores BurstUUID as tag 0x000b. The Photos parser now reads that one string from the Apple iOS nested MakerNote IFD (bounded to 256 entries, accepting only UUID-shaped values), when nom-exif exposes the payload. Test coverage uses a constructed Apple MakerNote IFD. Canon CR3 CMT3 MakerNotes remain inaccessible through nom-exif 3.8.0, so those files use the same-camera, one-second fallback.

Additional pairing evidence: Apple's MakerNote stores `BurstUUID` as tag 0x000b. The Photos parser now reads that one string from the `Apple iOS` nested MakerNote IFD (bounded to 256 entries, accepting only UUID-shaped values), when nom-exif exposes the payload. Test coverage uses a constructed Apple MakerNote IFD. Canon CR3 CMT3 MakerNotes remain inaccessible through nom-exif 3.8.0, so those files use the same-camera, one-second fallback.
Author
Owner

Completed pairing for RAW+JPEG, Live Photos, and bursts, with XMP key-photo choices. Pairing uses ContentIdentifier when available, same-basename/capture-time fallback for Live Photos, and same-camera frames within one second when no BurstUUID is exposed. Known gap: nom-exif does not expose Canon CR3 CMT3 MakerNotes, so those files use the same-camera/time fallback.

Head SHA: f77c8d135e.

Gate output (commands use CARGO_PROFILE_DEV_DEBUG=line-tables-only and CARGO_INCREMENTAL=0):

  • cargo fmt --all --check: exit 0, no output.
  • cargo clippy --all-targets -- -D warnings (exit 0):
    Finished `dev` profile [unoptimized + debuginfo] target(s) in 14.64s
  • cargo test (workspace, exit 0):
    Finished `test` profile [unoptimized + debuginfo] target(s) in 3m 25s
test result: ok. 13 passed; 0 failed; 1 ignored; 0 measured; 0 filtered out; finished in 0.04s
  • bash packages/api-client/check-generated.sh (exit 0, no generated diff):
    Finished `dev` profile [unoptimized + debuginfo] target(s) in 3m 40s
     Running `target/debug/calternal-server openapi`
$ bunx --package openapi-typescript@7.13.0 openapi-typescript ../../contracts/openapi.json -o src/generated.ts
Resolving dependencies
Resolved, downloaded and extracted [24]
Saved lockfile
✨ openapi-typescript 7.13.0
🚀 ../../contracts/openapi.json → src/generated.ts [1.4s]
  • bash tests/adversarial/run.sh (exit 1 because Round 1 reported Calendar task latency; Round 2 reported zero findings):
==== FINDINGS 21
 - Task storm 3 :: SLOW 7.4s status 201
 - Task storm 4 :: SLOW 5.1s status 201
 - Task storm 5 :: SLOW 6.9s status 201
 - Task storm 6 :: SLOW 9.6s status 201
 - Task storm 7 :: SLOW 8.3s status 201
 - Task storm 8 :: SLOW 7.9s status 201
 - Task storm 9 :: SLOW 12.9s status 201
 - Task storm 10 :: SLOW 12.1s status 201
 - Task storm 11 :: SLOW 13.4s status 201
 - Task storm 12 :: SLOW 14.3s status 201
 - Task storm 13 :: SLOW 14.3s status 201
 - Task storm 14 :: SLOW 12.9s status 201
 - Task storm 15 :: SLOW 12.4s status 201
 - Task storm 16 :: SLOW 11.9s status 201
 - Task storm 17 :: SLOW 13.7s status 201
 - Task storm 18 :: SLOW 14.3s status 201
 - Task storm 19 :: SLOW 17.4s status 201
 - Task storm 20 :: SLOW 16.7s status 201
 - Task storm 21 :: SLOW 14.8s status 201
 - Task storm 22 :: SLOW 17.2s status 201
 - Task storm 23 :: SLOW 17.3s status 201
==== ROUND 2 FINDINGS 0
Completed pairing for RAW+JPEG, Live Photos, and bursts, with XMP key-photo choices. Pairing uses ContentIdentifier when available, same-basename/capture-time fallback for Live Photos, and same-camera frames within one second when no BurstUUID is exposed. Known gap: nom-exif does not expose Canon CR3 CMT3 MakerNotes, so those files use the same-camera/time fallback. Head SHA: f77c8d135eeec4f1a30da6034e61217df5b9a04b. Gate output (commands use CARGO_PROFILE_DEV_DEBUG=line-tables-only and CARGO_INCREMENTAL=0): - cargo fmt --all --check: exit 0, no output. - cargo clippy --all-targets -- -D warnings (exit 0): ``` Finished `dev` profile [unoptimized + debuginfo] target(s) in 14.64s ``` - cargo test (workspace, exit 0): ``` Finished `test` profile [unoptimized + debuginfo] target(s) in 3m 25s test result: ok. 13 passed; 0 failed; 1 ignored; 0 measured; 0 filtered out; finished in 0.04s ``` - bash packages/api-client/check-generated.sh (exit 0, no generated diff): ``` Finished `dev` profile [unoptimized + debuginfo] target(s) in 3m 40s Running `target/debug/calternal-server openapi` $ bunx --package openapi-typescript@7.13.0 openapi-typescript ../../contracts/openapi.json -o src/generated.ts Resolving dependencies Resolved, downloaded and extracted [24] Saved lockfile ✨ openapi-typescript 7.13.0 🚀 ../../contracts/openapi.json → src/generated.ts [1.4s] ``` - bash tests/adversarial/run.sh (exit 1 because Round 1 reported Calendar task latency; Round 2 reported zero findings): ``` ==== FINDINGS 21 - Task storm 3 :: SLOW 7.4s status 201 - Task storm 4 :: SLOW 5.1s status 201 - Task storm 5 :: SLOW 6.9s status 201 - Task storm 6 :: SLOW 9.6s status 201 - Task storm 7 :: SLOW 8.3s status 201 - Task storm 8 :: SLOW 7.9s status 201 - Task storm 9 :: SLOW 12.9s status 201 - Task storm 10 :: SLOW 12.1s status 201 - Task storm 11 :: SLOW 13.4s status 201 - Task storm 12 :: SLOW 14.3s status 201 - Task storm 13 :: SLOW 14.3s status 201 - Task storm 14 :: SLOW 12.9s status 201 - Task storm 15 :: SLOW 12.4s status 201 - Task storm 16 :: SLOW 11.9s status 201 - Task storm 17 :: SLOW 13.7s status 201 - Task storm 18 :: SLOW 14.3s status 201 - Task storm 19 :: SLOW 17.4s status 201 - Task storm 20 :: SLOW 16.7s status 201 - Task storm 21 :: SLOW 14.8s status 201 - Task storm 22 :: SLOW 17.2s status 201 - Task storm 23 :: SLOW 17.3s status 201 ==== ROUND 2 FINDINGS 0 ```
Author
Owner

Starting the search window UI job for #57, #61, #63 and #64 (one job, one worktree). Branch: job/search-ui; base SHA: 8c216759f01d704e69ecf37ef8293f422abf5552.

Plan: extend the existing ⌘K dialog and registry in apps/web (no second palette) into one floating search window with pills, previews and an in-place expanded view; add Files ⌘F and Calendar contextual search; add a saved-search CRUD API (JSON files through calternal-fs) and smart folders. I will post findings and a finish comment here.

Starting the search window UI job for #57, #61, #63 and #64 (one job, one worktree). Branch: `job/search-ui`; base SHA: `8c216759f01d704e69ecf37ef8293f422abf5552`. Plan: extend the existing ⌘K dialog and registry in apps/web (no second palette) into one floating search window with pills, previews and an in-place expanded view; add Files ⌘F and Calendar contextual search; add a saved-search CRUD API (JSON files through `calternal-fs`) and smart folders. I will post findings and a finish comment here.
Author
Owner

Correction: the previous comment on this issue was posted by mistake (a stale file from the search-ui job). Please ignore it.

Starting the Photos mode UI (web) for #33, the UI half of #29 and the web upload of #28.

Branch: job/photos-ui
Base SHA: 4ba968912ea40c5f5ee90e169276fbb6b9e24420

Scope: virtualized justified timeline with day headers, scrubber and zoom levels; fullscreen viewer on the shared QuickLook (info panel, Live Photos, video, RAW toggle, stacks and key-photo choice); selection with tags, trash with Undo, download, share and Copy link; upload into Photos through the Photos destination; deep links /photos, /photos/<year>/<month> and /p/<item-id>. Playwright e2e and a 20k+ item scroll measurement follow.

Correction: the previous comment on this issue was posted by mistake (a stale file from the search-ui job). Please ignore it. Starting the Photos mode UI (web) for #33, the UI half of #29 and the web upload of #28. Branch: `job/photos-ui` Base SHA: `4ba968912ea40c5f5ee90e169276fbb6b9e24420` Scope: virtualized justified timeline with day headers, scrubber and zoom levels; fullscreen viewer on the shared QuickLook (info panel, Live Photos, video, RAW toggle, stacks and key-photo choice); selection with tags, trash with Undo, download, share and Copy link; upload into Photos through the Photos destination; deep links `/photos`, `/photos/<year>/<month>` and `/p/<item-id>`. Playwright e2e and a 20k+ item scroll measurement follow.
Author
Owner

Finished the UI half on job/photos-ui (head 9b0891e, rebased onto main f8e93b7; not merged, not pushed). Details are in the finish comment on #33.

  • A pair or stack shows as one tile. The badge shows RAW, Live, or the burst count.
  • In the viewer:
    • RAW+JPEG has a RAW badge and a toggle (key R).
    • A Live Photo plays its motion clip.
    • A burst opens a strip of its members. "Use as key photo" writes through the key-photo API.
  • Server fix: the index now keys items on the Files Index hash of the XMP sidecar. Before this fix, a key-photo choice was never re-read.
  • Trashing a stack asks which members to move.
  • Tags on a stack apply to every member.
  • The e2e covers a RAW+JPEG pair, a burst of 5, and a Live Photo JPEG+MOV (apps/web/e2e/photos.mjs, 19/19 PASS).
Finished the UI half on `job/photos-ui` (head `9b0891e`, rebased onto main `f8e93b7`; not merged, not pushed). Details are in the finish comment on #33. - A pair or stack shows as one tile. The badge shows RAW, Live, or the burst count. - In the viewer: - RAW+JPEG has a RAW badge and a toggle (key R). - A Live Photo plays its motion clip. - A burst opens a strip of its members. "Use as key photo" writes through the key-photo API. - Server fix: the index now keys items on the Files Index hash of the XMP sidecar. Before this fix, a key-photo choice was never re-read. - Trashing a stack asks which members to move. - Tags on a stack apply to every member. - The e2e covers a RAW+JPEG pair, a burst of 5, and a Live Photo JPEG+MOV (`apps/web/e2e/photos.mjs`, 19/19 PASS).
Author
Owner

Completed on dev in 9e3c59976f (Merge job/photos-ui: Photos timeline, scrubber, zoom, viewer, stacks, upload (#33, #29, #28)).

Completed on dev in 9e3c59976f163c640790d1d627b13a2bd925ffa9 (Merge job/photos-ui: Photos timeline, scrubber, zoom, viewer, stacks, upload (#33, #29, #28)).
kayg closed this issue 2026-10-01 05:08:41 +00:00
Sign in to join this conversation.
No labels
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set

Reference
kayg/calternal#29
No description provided.