Photos: timeline, scrubber, zoom levels and fullscreen viewer — very fast at 100k+ items #33

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

Owner decision (round 13, P8): Immich/Apple-Photos-style timeline — "absolutely works, just needs to be very performant with lots of photos".

  • Justified grid grouped by day with sticky date headers; a date scrubber on the right (drag to jump by month/year); pinch / ⌘± zoom: year → month → day → detail.
  • Fullscreen viewer: swipe/arrow between items, zoom/pan, EXIF info, and the linked-notes panel on the right (the shared Quick Look viewer, see the Files UI viewer issue).
  • Performance budgets (hard): first screen < 300 ms warm with 100k items; 60 fps scrolling and scrubbing; memory bounded (virtualized rows, recycled tiles, thumbhash placeholders, progressive 256 → 1024 px thumbnails); the server serves the timeline as compact day buckets (counts + tile metadata) paginated by time, not one giant list.
  • Test with a generated 150k-item library; report p50/p95 timings and a Chrome performance profile.
  • UI in the calternal.js design system; Claude reviews screenshots and motion.

Design: DESIGN §12, §18 (budgets), §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, P8): Immich/Apple-Photos-style timeline — "absolutely works, just needs to be very performant with lots of photos". - Justified grid grouped by day with sticky date headers; a date scrubber on the right (drag to jump by month/year); pinch / ⌘± zoom: year → month → day → detail. - Fullscreen viewer: swipe/arrow between items, zoom/pan, EXIF info, and the **linked-notes panel** on the right (the shared Quick Look viewer, see the Files UI viewer issue). - Performance budgets (hard): first screen < 300 ms warm with 100k items; 60 fps scrolling and scrubbing; memory bounded (virtualized rows, recycled tiles, thumbhash placeholders, progressive 256 → 1024 px thumbnails); the server serves the timeline as compact day buckets (counts + tile metadata) paginated by time, not one giant list. - Test with a generated 150k-item library; report p50/p95 timings and a Chrome performance profile. - UI in the calternal.js design system; Claude reviews screenshots and motion. Design: DESIGN §12, §18 (budgets), §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: Files already builds and serves cached 256 px and 1024 px WebP thumbnails (crates/plugins/files/src/thumbnails.rs:152-160), but the API has no ThumbHash placeholder. Photos can reuse those thumbnails and derive a compact ThumbHash during background indexing, keeping timeline requests bounded.

Finding: Files already builds and serves cached 256 px and 1024 px WebP thumbnails (`crates/plugins/files/src/thumbnails.rs:152-160`), but the API has no ThumbHash placeholder. Photos can reuse those thumbnails and derive a compact ThumbHash during background indexing, keeping timeline requests bounded.
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 on branch job/photos-core: the required generated 150k-item benchmark measured warm timeline reads at p50 291,323 µs and p95 613,030 µs, over the 300 ms p95 budget. The current page query computes COUNT(*) GROUP BY capture_day over all matching groups for every request. I am changing the rebuildable Photos Index to keep per-day bucket counts and will rerun the same benchmark.

Finding on branch `job/photos-core`: the required generated 150k-item benchmark measured warm timeline reads at p50 291,323 µs and p95 613,030 µs, over the 300 ms p95 budget. The current page query computes `COUNT(*) GROUP BY capture_day` over all matching groups for every request. I am changing the rebuildable Photos Index to keep per-day bucket counts and will rerun the same benchmark.
Author
Owner

Follow-up measurement: adding root-scoped day summaries reduced the count work, but the same 150k benchmark regressed to p50 4,244,186 µs and p95 9,157,227 µs. The remaining ROW_NUMBER() query still processes too many group rows after selecting the day page. I am replacing it with one indexed per-day query capped by LIMIT tiles_per_day, which bounds reads to the requested tile count for each bucket.

Follow-up measurement: adding root-scoped day summaries reduced the count work, but the same 150k benchmark regressed to p50 4,244,186 µs and p95 9,157,227 µs. The remaining `ROW_NUMBER()` query still processes too many group rows after selecting the day page. I am replacing it with one indexed per-day query capped by `LIMIT tiles_per_day`, which bounds reads to the requested tile count for each bucket.
Author
Owner

The timeline now selects the bounded day page, then fetches each day's tiles through the indexed (viewer_id, capture_day, capture_seconds, group_id) range with LIMIT tiles_per_day; the tile query also joins current Files item IDs, paths, and hashes. The generated 150k-item benchmark passes: p50 85,105 µs, p95 135,113 µs (300 ms p95 budget). The fixture inserts in 500-row batches to keep the ignored benchmark practical.

The timeline now selects the bounded day page, then fetches each day's tiles through the indexed `(viewer_id, capture_day, capture_seconds, group_id)` range with `LIMIT tiles_per_day`; the tile query also joins current Files item IDs, paths, and hashes. The generated 150k-item benchmark passes: p50 85,105 µs, p95 135,113 µs (300 ms p95 budget). The fixture inserts in 500-row batches to keep the ignored benchmark practical.
Author
Owner

Adversarial round 1 returned 21 findings. Every one was an existing Calendar task-storm write over the probe's 5-second threshold: 7.4–17.4 seconds, all HTTP 201. Other worktree builds, servers and probes were active at the same time. The round reported no 5xx or Photos-specific finding. Round 2 is still running. This job does not own Calendar plugin behavior, so I have not changed it.

Adversarial round 1 returned 21 findings. Every one was an existing Calendar task-storm write over the probe's 5-second threshold: 7.4–17.4 seconds, all HTTP 201. Other worktree builds, servers and probes were active at the same time. The round reported no 5xx or Photos-specific finding. Round 2 is still running. This job does not own Calendar plugin behavior, so I have not changed it.
Author
Owner

Completed server-side Photos library roots, derived index, stack routes, upload destination, and time-paginated timeline API. The day query is bounded by per-day limits and joins current Files IDs. Latest recorded 150k benchmark: p50 141,085 µs, p95 262,645 µs. Round 1's adversarial findings are only existing Calendar task writes taking 5.1–17.4 seconds while multiple worktree builds/probes were active; no Photos-specific finding or 5xx appeared. This job does not own Calendar behavior and has not changed it.

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 server-side Photos library roots, derived index, stack routes, upload destination, and time-paginated timeline API. The day query is bounded by per-day limits and joins current Files IDs. Latest recorded 150k benchmark: p50 141,085 µs, p95 262,645 µs. Round 1's adversarial findings are only existing Calendar task writes taking 5.1–17.4 seconds while multiple worktree builds/probes were active; no Photos-specific finding or 5xx appeared. This job does not own Calendar behavior and has not changed it. 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 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.

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 Photos mode UI on job/photos-ui (rebased onto main f8e93b7). Head: 9b0891e. Not merged, not pushed.

What is in it

  • Timeline (apps/web/src/lib/photos): a justified grid by day under month headings. Small days sit side by side, like Immich's date groups. The whole library is laid out from new day buckets before any tile loads. Only the day groups near the viewport are in the DOM (at most 72 tiles at 25k items). A day that loads keeps the reader's place (anchor on the day at the top edge). ThumbHash placeholders paint at once and thumbnails fade in.
  • Zoom: three levels (large, medium, dense) through the header control, Ctrl/⌘-scroll, pinch, and +/−.
  • Scrubber on the right edge: year labels, month ticks, a hover label, drag. It is a vertical slider for the keyboard. On touch, a grab handle appears while scrolling, and only the handle takes pointers.
  • Viewer: the shared Quick Look in a new fullscreen presentation. Arrows and swipe, zoom and pan, and an info panel (EXIF, tags, place with the nearest city name and an OpenStreetMap link, then the Files info panel's sharing, versions and linked notes; the panel resizes by its edge and i toggles it). Video plays from the video plugin (direct play, then HLS with a lazily loaded hls.js). On phones the actions are in a bottom bar. No editing.
  • Selection: click, ⌘-click, Shift-range (it loads the days in between first), a band across days, a day checkbox with a mixed state, long press on touch, and a Select mode. Actions: tags (the one tag space, through a reusable tag editor; replace for one photo, add for several; stack members share tags), share (Files share dialog), download (file or ZIP), Copy link, and Trash with Undo.
  • Deep links: /photos, /photos/videos, /photos/stacks, /photos[/…]/YYYY/MM[/DD] and /p/<item-id> (the viewer over the timeline at its date). One (photos) route group layout keeps the library loaded between them. Every photo, day and month has Copy link. The viewer's counter is library-wide.
  • Sidebar: All photos, Videos, Stacks and the library folders. Settings → Photos → Library folders edits the roots.

API additions (Photos plugin; kept small)

GET timeline/buckets, a filter (videos, stacks), GET timeline/days/{day} with an offset, GET items/{item_id} (group, day, the indexed EXIF and place name plus an on-request EXIF read), and tile path, name, width and height (migration 3). Migration 3 also stores each item's XMP sidecar hash.

Bugs found and fixed on the way

  • Choosing a burst's key photo never took effect (the sidecar was not re-read). The index now keys items on the sidecar's Files Index hash.
  • The Home watcher treated every inotify open and read-only close as a change. Each read of a photo (thumbnail job, download, EXIF, video) rebuilt the whole Photos index; at 25k items that stalled timeline reads for 5–7 s. Fixed in the server wiring with a test.
  • Timeline reads decoded thumbnails and queued jobs inline. That work now runs after the response.

Performance (release server, 25,000 photos, headless Chromium, 1440×900; load average 27–31 on 8 cores from other jobs)

  • Buckets p50 25 ms / p95 34 ms (1,633 days, 51 KB). Timeline page p50 108 ms / p95 150 ms; mid-library p50 131 / p95 292 ms.
  • First screen: first tiles 784 ms and all visible thumbnails 1.1 s (cold, thumbnails generated on demand); warm 725 / 776 ms.
  • Scrubber jump until the days on screen are exact: p50 455 ms, p95 904 ms.
  • Scrolling at 60 px/frame: main thread script 101 ms/s, layout 25 ms/s, style 27 ms/s, long tasks 42 ms/s; at most 72 tiles in the DOM. Frame rate is bound by software raster of the app-wide backdrop blurs: 14 fps with them, 35 fps (median frame 16.7 ms) with blur off. A plain page scrolled the same way reaches 58.6 fps here. An earlier run at load 6.5 gave 52 fps without blur.

Filed #79 (the Photos index still rebuilds the whole library for any real change).

Gates on 9b0891e: fmt, clippy, the contract check, svelte-check (0 errors, 0 warnings), web tests (209 passed) and the build pass. cargo test --workspace fails only in two tests that fail on main too: #80 and collab hostile_clients::unrepresentable_update_is_rejected_and_room_keeps_saving. The e2e (apps/web/e2e/photos.mjs) passes 19 of 19 flows. The adversarial run reports 0 findings in both rounds. Performance was measured before the last rebase (CLIP search and deletion). Screenshots (1440×900 and 390×844, Paper White and Tokyo Night) are in the orchestrator's scratchpad under ui-shots/photos/.

Finished the Photos mode UI on `job/photos-ui` (rebased onto main `f8e93b7`). Head: `9b0891e`. Not merged, not pushed. ## What is in it - **Timeline** (`apps/web/src/lib/photos`): a justified grid by day under month headings. Small days sit side by side, like Immich's date groups. The whole library is laid out from new day buckets before any tile loads. Only the day groups near the viewport are in the DOM (at most 72 tiles at 25k items). A day that loads keeps the reader's place (anchor on the day at the top edge). ThumbHash placeholders paint at once and thumbnails fade in. - **Zoom**: three levels (large, medium, dense) through the header control, Ctrl/⌘-scroll, pinch, and +/−. - **Scrubber** on the right edge: year labels, month ticks, a hover label, drag. It is a vertical slider for the keyboard. On touch, a grab handle appears while scrolling, and only the handle takes pointers. - **Viewer**: the shared Quick Look in a new fullscreen presentation. Arrows and swipe, zoom and pan, and an info panel (EXIF, tags, place with the nearest city name and an OpenStreetMap link, then the Files info panel's sharing, versions and linked notes; the panel resizes by its edge and `i` toggles it). Video plays from the video plugin (direct play, then HLS with a lazily loaded hls.js). On phones the actions are in a bottom bar. No editing. - **Selection**: click, ⌘-click, Shift-range (it loads the days in between first), a band across days, a day checkbox with a mixed state, long press on touch, and a Select mode. Actions: tags (the one tag space, through a reusable tag editor; replace for one photo, add for several; stack members share tags), share (Files share dialog), download (file or ZIP), Copy link, and Trash with Undo. - **Deep links**: `/photos`, `/photos/videos`, `/photos/stacks`, `/photos[/…]/YYYY/MM[/DD]` and `/p/<item-id>` (the viewer over the timeline at its date). One `(photos)` route group layout keeps the library loaded between them. Every photo, day and month has Copy link. The viewer's counter is library-wide. - **Sidebar**: All photos, Videos, Stacks and the library folders. **Settings → Photos → Library folders** edits the roots. ## API additions (Photos plugin; kept small) `GET timeline/buckets`, a `filter` (videos, stacks), `GET timeline/days/{day}` with an offset, `GET items/{item_id}` (group, day, the indexed EXIF and place name plus an on-request EXIF read), and tile `path`, `name`, `width` and `height` (migration 3). Migration 3 also stores each item's XMP sidecar hash. ## Bugs found and fixed on the way - Choosing a burst's key photo never took effect (the sidecar was not re-read). The index now keys items on the sidecar's Files Index hash. - The Home watcher treated every inotify open and read-only close as a change. Each read of a photo (thumbnail job, download, EXIF, video) rebuilt the whole Photos index; at 25k items that stalled timeline reads for 5–7 s. Fixed in the server wiring with a test. - Timeline reads decoded thumbnails and queued jobs inline. That work now runs after the response. ## Performance (release server, 25,000 photos, headless Chromium, 1440×900; load average 27–31 on 8 cores from other jobs) - Buckets p50 25 ms / p95 34 ms (1,633 days, 51 KB). Timeline page p50 108 ms / p95 150 ms; mid-library p50 131 / p95 292 ms. - First screen: first tiles 784 ms and all visible thumbnails 1.1 s (cold, thumbnails generated on demand); warm 725 / 776 ms. - Scrubber jump until the days on screen are exact: p50 455 ms, p95 904 ms. - Scrolling at 60 px/frame: main thread script 101 ms/s, layout 25 ms/s, style 27 ms/s, long tasks 42 ms/s; at most 72 tiles in the DOM. Frame rate is bound by software raster of the app-wide backdrop blurs: 14 fps with them, 35 fps (median frame 16.7 ms) with blur off. A plain page scrolled the same way reaches 58.6 fps here. An earlier run at load 6.5 gave 52 fps without blur. Filed #79 (the Photos index still rebuilds the whole library for any real change). Gates on `9b0891e`: fmt, clippy, the contract check, svelte-check (0 errors, 0 warnings), web tests (209 passed) and the build pass. `cargo test --workspace` fails only in two tests that fail on main too: #80 and collab `hostile_clients::unrepresentable_update_is_rejected_and_room_keeps_saving`. The e2e (`apps/web/e2e/photos.mjs`) passes 19 of 19 flows. The adversarial run reports 0 findings in both rounds. Performance was measured before the last rebase (CLIP search and deletion). Screenshots (1440×900 and 390×844, Paper White and Tokyo Night) are in the orchestrator's scratchpad under `ui-shots/photos/`.
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:44 +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#33
No description provided.