Skip to content

Progressively load sharper capture images while zooming in the session detail view#1375

Merged
mihow merged 8 commits into
mainfrom
feat/session-detail-progressive-zoom
Jul 23, 2026
Merged

Progressively load sharper capture images while zooming in the session detail view#1375
mihow merged 8 commits into
mainfrom
feat/session-detail-progressive-zoom

Conversation

@mihow

@mihow mihow commented Jul 21, 2026

Copy link
Copy Markdown
Collaborator

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

  1. Zooming in loads sharper image tiers automatically. The view keeps a ladder of medium (1024px) → large (2560px) → original, and requests a higher tier when the zoom demands more pixels than the current tier provides (with a debounce so a zoom gesture settles before any fetch fires).
  2. The blurry-to-sharp transition is seamless. The incoming tier renders as a second stacked image in the same zoom/pan container and crossfades in when loaded — no flicker, no lost zoom position, and the percentage-based box overlay never needs to know which tier is showing.
  3. A corner pill shows when a sharper version is loading. Non-blocking, with a small spinner ("Loading higher resolution…").
  4. A zoom readout shows the current level. 100% means one image pixel per screen pixel; the zoom cap is raised so large originals can be inspected up to 200% of their native pixels.
  5. The initial load is lighter again, and fewer large thumbnails get generated. Instead of always fetching the 2560px thumbnail (Fix misplaced bounding boxes on portrait captures and add large image thumbnail size #1374's cap), the starting tier is picked from the display: medium on a standard screen, large on a high-DPI one. This restores the pre-Fix misplaced bounding boxes on portrait captures and add large image thumbnail size #1374 default weight without the quality regression. It also narrows the cold-generation load Fix misplaced bounding boxes on portrait captures and add large image thumbnail size #1374 flagged: because thumbnails are generated lazily on first request, the 2560px derivative now gets created for captures someone actually zooms into, rather than for every capture they scroll past.
  6. Portrait originals with EXIF rotation are refused. If the browser rotates the original out of the coordinate space the boxes are drawn in (the bug Fix misplaced bounding boxes on portrait captures and add large image thumbnail size #1374 fixed), the upgrade is discarded and the view stays on the EXIF-free large thumbnail. Failed tier loads are likewise not retried in a loop.
  7. Scrubbing between captures while zoomed picks the right tier directly. The ladder resets per capture using the current zoom demand, so navigation at high zoom loads the sharp tier without stepping through the lighter ones.

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), and isTransposed() (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 is container width × devicePixelRatio × zoom scale, reported from react-zoom-pan-pinch's onTransform plus a ResizeObserver on the container. The stacked incoming image, the two corner pills, and a dynamic maxScale are 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

Initial load — medium tier, boxes aligned, zoom readout Zoomed — blurry-first upscale with the loading pill
Initial medium tier Loading pill over blurry tier
Same view after the crossfade — sharp tier committed Past 100% on a portrait capture — EXIF guard keeps the large tier
Sharp tier committed Past 100 percent with EXIF guard

Testing

  • 17 new unit tests for the ladder functions (capture-tiers.test.ts); yarn test 47/47, yarn lint, yarn type-check, and Prettier all pass.
  • Verified end-to-end on the local stack with seeded 4032×3024 captures (landscape and EXIF-portrait) on a MinIO-backed deployment: initial medium pick at 1× DPI, debounced promotion to large and then the original while zooming (each fetched exactly once), crossfade commit with no layout shift, capture navigation at high zoom picking the large tier directly, the EXIF-portrait original fetched once and refused by the transposed-dimensions guard (view stays on large, boxes stay aligned), and dimension-less captures rendering without boxes or readout.

Known caveats

  • The initial tier is picked from the first container measurement; if the layout is still settling at that instant the view may start one tier higher than strictly needed (it never downgrades). Harmless, but worth knowing when reading network traces.
  • Whether the original tier should be fetched automatically on metered/slow connections is left open (per the discussion in Progressively load higher-resolution images when zooming in the session detail view #1373) — navigator.connection support is patchy, so a settings toggle would be a follow-up.
  • Prefetching the neighbouring captures' starting tier during scrubbing is a possible follow-up that could reuse the same hook.

Summary by CodeRabbit

  • New Features

    • Added an adaptive resolution “zoom ladder” for session capture thumbnails, loading higher-resolution images progressively as you zoom.
    • Improved zoom scaling based on available rendered size and device pixel density.
    • Added a loading indicator while higher-resolution content is being fetched, and enhanced handling to avoid displaying incorrectly oriented (EXIF-transposed) images.
    • Updated capture thumbnail URLs to expose optional tier sizes instead of auto-falling back when a tier is unavailable.
  • Tests

    • Added coverage for tier selection, missing-thumbnail scenarios, and orientation/transposition detection behavior.

mihow and others added 4 commits July 20, 2026 15:06
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>
@netlify

netlify Bot commented Jul 21, 2026

Copy link
Copy Markdown

Deploy Preview for antenna-preview ready!

Name Link
🔨 Latest commit 33b1c95
🔍 Latest deploy log https://app.netlify.com/projects/antenna-preview/deploys/6a6187b922afe7000887b84e
😎 Deploy Preview https://deploy-preview-1375--antenna-preview.netlify.app
📱 Preview on mobile
Toggle QR Code...

QR Code

Use your smartphone camera to open QR code link.
Lighthouse
Lighthouse
1 paths audited
Performance: 55 (🔴 down 10 from production)
Accessibility: 81 (🔴 down 8 from production)
Best Practices: 92 (🔴 down 8 from production)
SEO: 92 (no change from production)
PWA: 80 (no change from production)
View the detailed breakdown and full score reports
🤖 Make changes Run an agent on this branch

To edit notification comments on pull requests, go to your Netlify project configuration.

@coderabbitai

coderabbitai Bot commented Jul 21, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 624672a9-9f62-4b52-99e1-fc6d22d4c95f

📥 Commits

Reviewing files that changed from the base of the PR and between 2501f3e and 33b1c95.

📒 Files selected for processing (1)
  • ui/src/pages/session-details/capture/useCaptureTiers.ts
🚧 Files skipped from review as they are similar to previous changes (1)
  • ui/src/pages/session-details/capture/useCaptureTiers.ts

📝 Walkthrough

Walkthrough

The 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.

Changes

Capture resolution ladder

Layer / File(s) Summary
Tier contracts and selection
ui/src/data-services/models/capture.ts, ui/src/pages/session-details/capture/capture-tiers.ts, ui/src/pages/session-details/capture/capture-tiers.test.ts
Capture thumbnails expose optional medium and large URLs; ladder construction, demand-based selection, and EXIF transposition detection are implemented and tested.
Tier promotion state
ui/src/pages/session-details/capture/useCaptureTiers.ts
The hook tracks displayed and incoming tiers, debounces promotions, validates loaded images, commits successful upgrades, and rejects failed or transposed sources.
Zoom rendering integration
ui/src/pages/session-details/capture/capture.tsx, ui/src/pages/session-details/session-details.tsx, ui/src/utils/language.ts
Capture receives tier sources, measures zoom demand, renders tier transitions, preserves transform state, and displays zoom and higher-resolution loading indicators.

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
Loading

Possibly related PRs

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly summarizes the core feature: progressive capture sharpening while zooming in session detail.
Description check ✅ Passed The PR description mostly matches the template and includes summary, changes, detailed description, screenshots, testing, and caveats.
Linked Issues check ✅ Passed The changes implement tiered loading, seamless swapping, EXIF guarding, and debounced zoom-driven promotion required by #1373.
Out of Scope Changes check ✅ Passed All file changes are directly tied to the capture zoom tier feature and its UI/test support.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/session-detail-progressive-zoom

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.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

…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>
Base automatically changed from fix/session-detail-thumbnail-orientation to main July 23, 2026 01:33
…rogressive-zoom

# Conflicts:
#	ui/src/pages/session-details/session-details.tsx
Copilot AI review requested due to automatic review settings July 23, 2026 02:00
@netlify

netlify Bot commented Jul 23, 2026

Copy link
Copy Markdown

Deploy Preview for antenna-ssec ready!

Name Link
🔨 Latest commit 33b1c95
🔍 Latest deploy log https://app.netlify.com/projects/antenna-ssec/deploys/6a6187b94828c90008d2da17
😎 Deploy Preview https://deploy-preview-1375--antenna-ssec.netlify.app
📱 Preview on mobile
Toggle QR Code...

QR Code

Use your smartphone camera to open QR code link.
🤖 Make changes Run an agent on this branch

To edit notification comments on pull requests, go to your Netlify project configuration.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread ui/src/pages/session-details/capture/useCaptureTiers.ts Outdated

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick comments (2)
ui/src/pages/session-details/capture/useCaptureTiers.ts (2)

25-25: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Hook file name isn't kebab-case.

useCaptureTiers.ts uses 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, not taxalist.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 value

Promoting to a new target discards an already-loaded incoming tier without ever displaying it.

When evaluatePromotion finds a higher target while the current incoming tier 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

📥 Commits

Reviewing files that changed from the base of the PR and between 0afb791 and 2501f3e.

📒 Files selected for processing (7)
  • ui/src/data-services/models/capture.ts
  • ui/src/pages/session-details/capture/capture-tiers.test.ts
  • ui/src/pages/session-details/capture/capture-tiers.ts
  • ui/src/pages/session-details/capture/capture.tsx
  • ui/src/pages/session-details/capture/useCaptureTiers.ts
  • ui/src/pages/session-details/session-details.tsx
  • ui/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>
@mihow

mihow commented Jul 23, 2026

Copy link
Copy Markdown
Collaborator Author

Claude says: Thanks for the review. Notes on the two nitpicks, both of which we looked at and are leaving as-is deliberately:

1. useCaptureTiers.ts filename not kebab-case. Left intentionally. The repo already uses camelCase for hook files, including a sibling in the same directory (useActiveOccurrences.ts, and useActiveCaptureId.ts). Matching the immediate hook-file precedent reads as more consistent here than following the general kebab-case rule and making this the only kebab-named hook. The ui/CLAUDE.md rule is really aimed at non-hook modules.

2. Promotion discards an already-loaded incoming tier. This is a deliberate tradeoff, not an oversight. When the user keeps zooming past a tier that has loaded but not yet committed, racing straight to the new (higher) target is the behavior we want for a continuous pinch/zoom gesture: it reaches the sharpest needed tier with a single crossfade instead of stepping the view through every intermediate tier with a fade at each. Committing the intermediate first would add visible crossfade churn during a fast zoom for little benefit, since the gesture is already heading somewhere sharper. The window is also small (~350ms). If in practice a stepped crossfade feels better we can revisit, but the current choice is intentional.

The separate CROSSFADE_MS + 50 magic-number note (from Copilot) is addressed in 33b1c95 — extracted to a named COMMIT_BUFFER_MS with a rationale comment.

@mihow
mihow merged commit ee357f5 into main Jul 23, 2026
7 checks passed
@mihow
mihow deleted the feat/session-detail-progressive-zoom branch July 23, 2026 04:49
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Progressively load higher-resolution images when zooming in the session detail view

2 participants