Skip to content
23 changes: 8 additions & 15 deletions ui/src/data-services/models/capture.ts
Original file line number Diff line number Diff line change
Expand Up @@ -175,22 +175,15 @@ export class Capture {
return this._capture.url
}

get thumbnailMedium(): string {
if (this._capture.thumbnails?.medium) {
return this._capture.thumbnails.medium
// EXIF-free thumbnail URLs for the session detail zoom ladder, with no
// original-file fallback: thumbnails are generated on request, so a missing
// size usually means the source file is unreachable too, and the ladder
// simply skips that tier.
get thumbnailSizes(): { medium?: string; large?: string } {
return {
medium: this._capture.thumbnails?.medium ?? undefined,
large: this._capture.thumbnails?.large ?? undefined,
}

return this._capture.url
}

// EXIF-free large thumbnail for the zoomable session detail view; detection
// boxes align with its pixels, unlike the EXIF-rotated original file.
get thumbnailLarge(): string {
if (this._capture.thumbnails?.large) {
return this._capture.thumbnails.large
}

return this._capture.url
}

get src(): string {
Expand Down
140 changes: 140 additions & 0 deletions ui/src/pages/session-details/capture/capture-tiers.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,140 @@
import {
buildTierLadder,
isTransposed,
pickTier,
TierSources,
} from './capture-tiers'

const SOURCES: TierSources = {
medium: 'https://example.org/thumbnails/medium/capture.jpg',
large: 'https://example.org/thumbnails/large/capture.jpg',
original: 'https://example.org/captures/capture.jpg',
}

describe('buildTierLadder', () => {
test('full ladder for a large original: medium, large, original', () => {
const tiers = buildTierLadder(SOURCES, 4096)

expect(tiers).toEqual([
{ src: SOURCES.medium, width: 1024, isOriginal: false },
{ src: SOURCES.large, width: 2560, isOriginal: false },
{ src: SOURCES.original, width: 4096, isOriginal: true },
])
})

test('drops the original when the large thumbnail already covers it', () => {
// A 2000px original produces a 2000px "large" thumbnail (thumbnails are
// never upscaled), so the EXIF-risky original adds no resolution.
const tiers = buildTierLadder(SOURCES, 2000)

expect(tiers).toEqual([
{ src: SOURCES.medium, width: 1024, isOriginal: false },
{ src: SOURCES.large, width: 2000, isOriginal: false },
])
})

test('collapses to a single tier when the original is small', () => {
const tiers = buildTierLadder(SOURCES, 800)

expect(tiers).toEqual([
{ src: SOURCES.medium, width: 800, isOriginal: false },
])
})

test('keeps the original as an unknown-width top tier when dimensions are missing', () => {
const tiers = buildTierLadder(SOURCES, null)

expect(tiers).toEqual([
{ src: SOURCES.medium, width: 1024, isOriginal: false },
{ src: SOURCES.large, width: 2560, isOriginal: false },
{ src: SOURCES.original, width: null, isOriginal: true },
])
})

test('a missing thumbnail size is skipped as a tier', () => {
// Thumbnails are generated on request, so a missing size means the
// capture's storage is likely unreachable — skip it rather than retry.
const tiers = buildTierLadder({ ...SOURCES, medium: undefined }, 4096)

expect(tiers).toEqual([
{ src: SOURCES.large, width: 2560, isOriginal: false },
{ src: SOURCES.original, width: 4096, isOriginal: true },
])
})

test('all thumbnails missing yields a single original tier', () => {
const tiers = buildTierLadder({ original: SOURCES.original }, 3000)

expect(tiers).toEqual([
{ src: SOURCES.original, width: 3000, isOriginal: true },
])
})
})

describe('pickTier', () => {
const tiers = buildTierLadder(SOURCES, 4096)

test('accepts up to 20% upscale before stepping up', () => {
// 1024 * 1.2 = 1228.8 — a 1200px demand stays on medium.
expect(pickTier(tiers, 1200)?.src).toBe(SOURCES.medium)
expect(pickTier(tiers, 1300)?.src).toBe(SOURCES.large)
})

test('picks medium on a 1x laptop and large on a retina display', () => {
expect(pickTier(tiers, 1100 * 1)?.src).toBe(SOURCES.medium)
expect(pickTier(tiers, 1100 * 2)?.src).toBe(SOURCES.large)
})

test('high zoom demand reaches the original', () => {
expect(pickTier(tiers, 4000)?.src).toBe(SOURCES.original)
})

test('demand beyond every tier returns the top tier', () => {
expect(pickTier(tiers, 100000)?.src).toBe(SOURCES.original)
})

test('unknown-width tier satisfies any demand', () => {
const openEnded = buildTierLadder(SOURCES, null)
expect(pickTier(openEnded, 100000)?.src).toBe(SOURCES.original)
})

test('returns null for an empty ladder', () => {
expect(pickTier([], 1000)).toBeNull()
})
})

describe('isTransposed', () => {
test('detects a browser-rotated portrait original', () => {
// Stored dimensions are raw pixel space; the browser applied the EXIF
// Orientation tag and swapped the rendered dimensions.
expect(
isTransposed({ width: 3456, height: 4608 }, { width: 4608, height: 3456 })
).toBe(true)
})

test('matching dimensions are not transposed', () => {
expect(
isTransposed({ width: 4608, height: 3456 }, { width: 4608, height: 3456 })
).toBe(false)
})

test('square images can never be flagged', () => {
expect(
isTransposed({ width: 2000, height: 2000 }, { width: 2000, height: 2000 })
).toBe(false)
})

test('unknown stored dimensions are never flagged', () => {
expect(
isTransposed({ width: 3456, height: 4608 }, { width: null, height: null })
).toBe(false)
})

test('a downscaled thumbnail is not flagged even for portrait captures', () => {
// Thumbnails keep raw orientation and different absolute dimensions, so
// the exact-swap check does not fire.
expect(
isTransposed({ width: 1920, height: 2560 }, { width: 3456, height: 4608 })
).toBe(false)
})
})
119 changes: 119 additions & 0 deletions ui/src/pages/session-details/capture/capture-tiers.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,119 @@
/**
* Resolution ladder for the zoomable session detail capture.
*
* The view keeps a ladder of image tiers — medium (1024) → large (2560) →
* original — and requests a higher tier whenever the zoom level demands more
* pixels than the current tier provides. Demand is expressed in device pixels:
* container CSS width × devicePixelRatio × zoom scale. See issue #1373.
*/

export interface TierSources {
// Thumbnail sizes are generated on request, so a missing URL means the
// capture's storage is likely unreachable; the ladder skips that tier.
medium?: string
large?: string
original: string
}

export interface CaptureTier {
src: string
/** Native pixel width; null when unknown (original with no stored dimensions). */
width: number | null
/**
* True when src points at the original file, which may carry an EXIF
* Orientation tag that browsers force-apply, rotating the pixels out of the
* coordinate space the detection boxes are drawn in.
*/
isOriginal: boolean
}

/** Must match THUMBNAILS["SIZES"] in config/settings/base.py. */
export const THUMBNAIL_WIDTHS = {
medium: 1024,
large: 2560,
}

/**
* Accept up to 20% upscale before requesting the next tier, so a display
* marginally wider than a tier does not force the bigger download.
*/
export const TIER_UPSCALE_TOLERANCE = 1.2

export const buildTierLadder = (
sources: TierSources,
captureWidth: number | null
): CaptureTier[] => {
// Thumbnails are never upscaled, so a tier's real width is capped by the
// original's.
const cap = (thumbnailWidth: number) =>
captureWidth ? Math.min(thumbnailWidth, captureWidth) : thumbnailWidth

const candidates: CaptureTier[] = []
if (sources.medium) {
candidates.push({
src: sources.medium,
width: cap(THUMBNAIL_WIDTHS.medium),
isOriginal: false,
})
}
if (sources.large) {
candidates.push({
src: sources.large,
width: cap(THUMBNAIL_WIDTHS.large),
isOriginal: false,
})
}
candidates.push({
src: sources.original,
width: captureWidth,
isOriginal: true,
})

// Keep only tiers that add resolution over the previous one — this drops
// the EXIF-risky original whenever the large thumbnail already covers its
// full size.
return candidates.reduce((ladder: CaptureTier[], tier) => {
const previous = ladder[ladder.length - 1]
if (
previous &&
previous.width !== null &&
tier.width !== null &&
tier.width <= previous.width
) {
return ladder
}
return [...ladder, tier]
}, [])
}

/**
* Smallest tier that satisfies the demand (within the upscale tolerance);
* the top tier when nothing does.
*/
export const pickTier = (
tiers: CaptureTier[],
demand: number
): CaptureTier | null => {
for (const tier of tiers) {
if (tier.width === null || demand <= tier.width * TIER_UPSCALE_TOLERANCE) {
return tier
}
}

return tiers.length ? tiers[tiers.length - 1] : null
}

/**
* True when the rendered image dimensions are the stored dimensions swapped —
* the signature of a browser-applied EXIF rotation. Detection boxes are drawn
* in the stored (raw pixel) space, so a transposed image must not be shown.
*/
export const isTransposed = (
natural: { width: number; height: number },
stored: { width: number | null; height: number | null }
): boolean =>
stored.width !== null &&
stored.height !== null &&
stored.width !== stored.height &&
natural.width === stored.height &&
natural.height === stored.width
Loading