Progressively load sharper capture images while zooming in the session detail view#1375
Conversation
Portrait photos are typically stored as landscape pixels plus an EXIF Orientation tag. Browsers force-apply that rotation to cross-origin images, while detections, image dimensions, and crops all live in the raw (unrotated) pixel space - so the session detail view drew bounding boxes 90 degrees out of place on portrait captures. This regressed in PR #1339, which switched the view from the medium thumbnail to the original image URL for zoom quality. Thumbnails are re-encoded without EXIF metadata, so they always render in the same pixel space as the boxes. Add a "large" (2560px) thumbnail size for zoom quality and point the session detail view at it, falling back to the original URL when thumbnails are unavailable. Co-Authored-By: Claude <noreply@anthropic.com>
Box coordinates are stored in the original image's pixel space, and the session detail view now renders a downscaled thumbnail, so the previous fallback to the rendered image's natural size would draw boxes at the wrong scale for captures with no stored width/height. Skip drawing instead. Also document in the thumbnail generator that EXIF must not propagate to the output, since box alignment depends on it. Co-Authored-By: Claude <noreply@anthropic.com>
Co-Authored-By: Claude <noreply@anthropic.com>
Load the session detail capture through a resolution ladder (medium 1024 → large 2560 → original) driven by zoom demand: container width × devicePixelRatio × zoom scale. The initial tier is picked from the display (medium on a 1x screen, large on retina), and zooming in promotes to higher tiers with a debounced fetch, a blurry-first crossfade, and a corner loading pill. A zoom readout shows the current level relative to the original's pixels (100% = one image pixel per device pixel) and maxScale is raised so large originals can be inspected past 100%. The original tier is guarded against browser-applied EXIF rotation: if its rendered dimensions are the stored dimensions transposed, the upgrade is refused and the view stays on the EXIF-free large thumbnail, keeping detection boxes aligned (the bug PR #1374 fixed). Closes #1373 Co-Authored-By: Claude <noreply@anthropic.com>
✅ Deploy Preview for antenna-preview ready!
To edit notification comments on pull requests, go to your Netlify project configuration. |
|
No actionable comments were generated in the recent review. 🎉 ℹ️ Recent review info⚙️ Run configurationConfiguration used: defaults Review profile: CHILL Plan: Pro Plus Run ID: 📒 Files selected for processing (1)
🚧 Files skipped from review as they are similar to previous changes (1)
📝 WalkthroughWalkthroughThe session detail capture view now builds a resolution ladder from medium, large, and original sources, promotes tiers during sustained zoom, validates image orientation, and swaps loaded tiers without resetting zoom state. ChangesCapture resolution ladder
Estimated code review effort: 4 (Complex) | ~45 minutes Sequence Diagram(s)sequenceDiagram
participant SessionDetailsPage
participant Capture
participant useCaptureTiers
participant Image
SessionDetailsPage->>Capture: pass capture sources
Capture->>useCaptureTiers: update zoom demand
useCaptureTiers->>Image: load higher-resolution tier
Image->>useCaptureTiers: report load result
useCaptureTiers->>Capture: commit validated tier
Possibly related PRs
🚥 Pre-merge checks | ✅ 5✅ Passed checks (5 passed)
✨ Finishing Touches📝 Generate docstrings
🧪 Generate unit tests (beta)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
…ladder Thumbnail sizes are generated on request, so a missing URL means the capture's storage is likely unreachable — the ladder now skips that tier instead of aliasing it to the original file. This removes the URL-identity width derivation, sorting, and dedupe that only existed to handle the model getters' original-URL fallback, and replaces the thumbnailLarge getter with a thumbnailSizes getter that has no fallback. Co-Authored-By: Claude <noreply@anthropic.com>
The getter's only consumer was the session detail image source, which #1339 switched to the original URL in the same commit that renamed the getter to camelCase. It has had no callers since. The medium thumbnail size itself is still in use — it is the base tier of the session detail zoom ladder. What this removes is only the unused single-URL-with-fallback shape, leaving the model's thumbnail accessors matched to real consumers: thumbnailSmall for the captures list and thumbnailSizes for the zoom ladder. Co-Authored-By: Claude <noreply@anthropic.com>
…rogressive-zoom # Conflicts: # ui/src/pages/session-details/session-details.tsx
✅ Deploy Preview for antenna-ssec ready!
To edit notification comments on pull requests, go to your Netlify project configuration. |
There was a problem hiding this comment.
Pull request overview
This PR enhances the session detail capture viewer to progressively load higher-resolution image sources as the user zooms, while preserving detection box alignment (including guarding against EXIF-rotated originals).
Changes:
- Add a resolution “tier ladder” (medium → large → original) and selection logic based on current pixel demand.
- Introduce a hook-driven state machine to debounce tier promotion and crossfade from blurry upscales to newly loaded sharp tiers.
- Update the session detail view to supply tier sources (thumbnail sizes + original) and add UI readouts (zoom percent + “loading higher resolution” indicator).
Reviewed changes
Copilot reviewed 7 out of 7 changed files in this pull request and generated 1 comment.
Show a summary per file
| File | Description |
|---|---|
| ui/src/utils/language.ts | Adds a new localized string for the higher-resolution loading indicator. |
| ui/src/pages/session-details/session-details.tsx | Switches the capture viewer input from a single src to a sources map containing thumbnail tiers plus the original. |
| ui/src/pages/session-details/capture/useCaptureTiers.ts | New hook implementing tier selection, debounced promotion, EXIF-rotation refusal, and crossfade commit logic. |
| ui/src/pages/session-details/capture/capture.tsx | Integrates the tier hook into the zoom/pan viewer, computes pixel demand, overlays stacked images for crossfade, and adds zoom/loading UI pills. |
| ui/src/pages/session-details/capture/capture-tiers.ts | New pure functions/constants to build the tier ladder, pick the best tier for demand, and detect transposed (EXIF-rotated) originals. |
| ui/src/pages/session-details/capture/capture-tiers.test.ts | Unit tests covering ladder construction, tier picking behavior, and EXIF transposition detection. |
| ui/src/data-services/models/capture.ts | Replaces single-thumbnail getters with a thumbnailSizes accessor intended for the tier ladder inputs. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
There was a problem hiding this comment.
🧹 Nitpick comments (2)
ui/src/pages/session-details/capture/useCaptureTiers.ts (2)
25-25: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low valueHook file name isn't kebab-case.
useCaptureTiers.tsuses camelCase rather than kebab-case, though this matches existing hook-file precedent in the codebase (useActiveCaptureId.ts,useActiveOccurrences.ts), so this may be an accepted exception for hook files.As per coding guidelines, "File names must be kebab-case (e.g.,
taxa-list.ts, nottaxalist.ts)."🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@ui/src/pages/session-details/capture/useCaptureTiers.ts` at line 25, Rename the hook file containing useCaptureTiers to the kebab-case filename use-capture-tiers.ts, and update all imports or references to useCaptureTiers accordingly while leaving the hook implementation unchanged.Source: Coding guidelines
97-119: 🚀 Performance & Scalability | 🔵 Trivial | 💤 Low valuePromoting to a new target discards an already-loaded
incomingtier without ever displaying it.When
evaluatePromotionfinds a higher target while the currentincomingtier has already finished loading (incomingLoaded === true) but hasn't committed yet, this branch clears the pending commit and swaps straight to the new target — the already-downloaded, ready-to-show tier is thrown away instead of being shown first. During a fast continuous zoom this can leave the display stuck on the blurriest tier noticeably longer while a bigger download completes, even though a sharper image was already sitting in the browser cache.♻️ Possible fix: commit an already-loaded incoming tier before superseding it
if (targetIndex > currentIndex) { if (incomingRef.current?.src !== target.src) { - clearTimeout(commitTimerRef.current) - setIncoming(target) - setIncomingLoaded(false) + if (incomingLoadedRef.current) { + // Show the already-loaded tier immediately instead of discarding it. + clearTimeout(commitTimerRef.current) + setDisplayed(incomingRef.current) + } + setIncoming(target) + setIncomingLoaded(false) } } else if (incomingRef.current && !incomingLoadedRef.current) {🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@ui/src/pages/session-details/capture/useCaptureTiers.ts` around lines 97 - 119, Update evaluatePromotion so that when a higher target would supersede an incoming tier that is already loaded, it commits/displays the loaded incoming tier first instead of clearing or replacing it. Only replace the incoming request and reset its loaded state when the existing incoming tier is not yet loaded; preserve the current no-demotion behavior for zooming back out.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Nitpick comments:
In `@ui/src/pages/session-details/capture/useCaptureTiers.ts`:
- Line 25: Rename the hook file containing useCaptureTiers to the kebab-case
filename use-capture-tiers.ts, and update all imports or references to
useCaptureTiers accordingly while leaving the hook implementation unchanged.
- Around line 97-119: Update evaluatePromotion so that when a higher target
would supersede an incoming tier that is already loaded, it commits/displays the
loaded incoming tier first instead of clearing or replacing it. Only replace the
incoming request and reset its loaded state when the existing incoming tier is
not yet loaded; preserve the current no-demotion behavior for zooming back out.
ℹ️ Review info
⚙️ Run configuration
Configuration used: defaults
Review profile: CHILL
Plan: Pro Plus
Run ID: 06865ebe-22cc-4fb4-97f3-298252691dd2
📒 Files selected for processing (7)
ui/src/data-services/models/capture.tsui/src/pages/session-details/capture/capture-tiers.test.tsui/src/pages/session-details/capture/capture-tiers.tsui/src/pages/session-details/capture/capture.tsxui/src/pages/session-details/capture/useCaptureTiers.tsui/src/pages/session-details/session-details.tsxui/src/utils/language.ts
Extract the 50ms margin after the crossfade into a named COMMIT_BUFFER_MS constant with a comment explaining why it exists: the commit must land after the opacity transition finishes, or the incoming image is promoted a frame early and the layer swap flashes. Addresses a review note about the magic number. Co-Authored-By: Claude <noreply@anthropic.com>
|
Claude says: Thanks for the review. Notes on the two nitpicks, both of which we looked at and are leaving as-is deliberately: 1. 2. Promotion discards an already-loaded The separate |

Summary
When reviewing a session, zooming into a capture now progressively loads sharper versions of the image instead of being capped at one fixed size. The view starts with a lightweight thumbnail sized for the screen, and as you zoom in it fetches the next resolution tier in the background — showing the upscaled (blurry) image immediately, then crossfading to the sharp one when it arrives. A small readout shows the current zoom level relative to the original image's pixels, and you can zoom past 100%. Detection boxes stay aligned throughout, including for portrait captures whose original files carry an EXIF rotation.
This builds on #1374 (which capped the view at the EXIF-free "large" thumbnail to fix portrait box misalignment) and implements the resolution-ladder follow-up discussed there. Closes #1373. The two changes are intended to merge and deploy together: this one lifts the resolution cap #1374 introduced, and it also narrows #1374's cold-thumbnail-generation exposure (see change 5 below).
List of Changes
Detailed Description
The ladder logic lives in three new frontend files, with no backend changes:
ui/src/pages/session-details/capture/capture-tiers.ts— pure functions:buildTierLadder()(takes the thumbnail sizes that are present, caps each at the original's width since thumbnails are never upscaled, and drops any tier that adds no resolution over the one below it — the EXIF-risky original is dropped entirely when the large thumbnail already covers its full size),pickTier()(smallest tier satisfying the demand, with a 1.2× upscale tolerance), andisTransposed()(the EXIF guard: rendered dimensions equal to the stored dimensions swapped).ui/src/pages/session-details/capture/useCaptureTiers.ts— the state machine: displayed tier, in-flight tier, debounced promotion (300 ms), crossfade commit, and per-capture reset. Tiers are only ever promoted; a downloaded tier is never traded back for a blurrier one.capture.tsx— pixel demand iscontainer width × devicePixelRatio × zoom scale, reported fromreact-zoom-pan-pinch'sonTransformplus aResizeObserveron the container. The stacked incoming image, the two corner pills, and a dynamicmaxScaleare added here.Edge cases verified: captures with no stored dimensions (boxes skipped per #1374's guard, zoom readout hidden, ladder still works using thumbnail widths), missing thumbnail sizes (skipped as tiers — thumbnails are generated on request, so an absent size usually means the capture's storage is unreachable anyway), and zooming back out before an upgrade arrives (the pending request is dropped).
Screenshots
Testing
capture-tiers.test.ts);yarn test47/47,yarn lint,yarn type-check, and Prettier all pass.Known caveats
navigator.connectionsupport is patchy, so a settings toggle would be a follow-up.Summary by CodeRabbit
New Features
Tests