diff --git a/frontend/src/create/review/Review.svelte b/frontend/src/create/review/Review.svelte index 9911fa1..5df62c0 100644 --- a/frontend/src/create/review/Review.svelte +++ b/frontend/src/create/review/Review.svelte @@ -8,7 +8,7 @@ import type { CalibratedCapture } from '../flow'; import MultiSelect from './MultiSelect.svelte'; import { - keptInOrder, moveItem, missingMinimums, roleLabel, + keptInOrder, moveItem, missingMinimums, roleLabel, resolvePhotoUrl, type LineupItem, } from './lineup'; @@ -171,7 +171,15 @@ return m; }; + // Object URLs minted from in-memory blobs; revoked on unmount/reload so the + // src stays valid. A raw Blob assigned to src renders broken — it must + // be a blob: URL (bug: the first/Primary photo was the one carrying a blob). + let photoUrls: string[] = []; + async function loadPhotos() { + // Revoke any previous object URLs before rebuilding the strip. + for (const u of photoUrls) URL.revokeObjectURL(u); + photoUrls = []; try { const rows: PhotoRow[] = await listPhotos(slabId); const byRole = inMemByRole(); @@ -180,9 +188,11 @@ const role = r.role ?? 'batch'; const n = seqByRole[role]++; const blob = (byRole.get(role)?.[n]) ?? null; - const url = - blob ?? - (photoUrl(slabId, r.id, (r.has_processed ? 'processed' : 'original') as 'original' | 'processed')); + // Prefer the in-memory blob (fresh capture), as an object URL. Otherwise + // fall back to the stored photo endpoint (a plain string URL). + const endpoint = photoUrl(slabId, r.id, (r.has_processed ? 'processed' : 'original') as 'original' | 'processed'); + const url = resolvePhotoUrl(blob, endpoint); + if (blob && blob.size > 0) photoUrls.push(url); return { id: r.id, role, @@ -190,7 +200,7 @@ kept: true, original: blob ?? new Blob(), processed: blob ?? null, - url: String(url), + url, }; }); photosLoaded = true; @@ -201,6 +211,11 @@ } } + onDestroy(() => { + for (const u of photoUrls) URL.revokeObjectURL(u); + photoUrls = []; + }); + // Whole-card tap = keep/remove toggle. A real drag (>6px) reorders and does NOT // toggle keep/remove — the threshold lives in the pointer handlers below. function toggleKeep(id: string) { @@ -778,7 +793,12 @@ .ph-hint { color: #8a8a8a; font-size: .72rem; margin: 0 0 8px; } .ph-grid { display: grid; grid-template-columns: repeat(3, 1fr); gap: 8px; } @media (max-width: 420px) { .ph-grid { grid-template-columns: repeat(2, 1fr); } } - .pcard { position: relative; aspect-ratio: 1; border: 2px solid #2a2a2a; border-radius: 3px; overflow: hidden; cursor: grab; background: #141414; } + .pcard { position: relative; aspect-ratio: 1; border: 2px solid #2a2a2a; border-radius: 3px; overflow: hidden; cursor: grab; + /* Gray checkerboard, matching the Capture lineup (MeasurePanel .lineup-item.png) + so transparent inventory PNGs read the same on both screens (bug2). */ + background-color: #eee; + background-image: linear-gradient(45deg,#ccc 25%,transparent 25%),linear-gradient(-45deg,#ccc 25%,transparent 25%),linear-gradient(45deg,transparent 75%,#ccc 75%),linear-gradient(-45deg,transparent 75%,#ccc 75%); + background-size: 16px 16px; background-position: 0 0,0 8px,8px -8px,-8px 0; } .pcard:active { cursor: grabbing; } .pcard:focus-visible { outline: 2px solid #00e5ff; outline-offset: 2px; } .pcard.kept { border-color: #444; } diff --git a/frontend/src/create/review/lineup.test.ts b/frontend/src/create/review/lineup.test.ts index 7899d6b..27be45d 100644 --- a/frontend/src/create/review/lineup.test.ts +++ b/frontend/src/create/review/lineup.test.ts @@ -6,6 +6,7 @@ import { missingMinimums, lineupValid, roleLabel, + resolvePhotoUrl, type LineupItem, } from './lineup'; @@ -69,3 +70,14 @@ test('lineup: roleLabel is owner-facing', () => { assert.match(roleLabel('calibration'), /clean/); assert.match(roleLabel('overlay'), /overlay/); }); + +test('lineup: resolvePhotoUrl turns an in-memory blob into a blob: URL (bug #1)', () => { + // The first/Primary (calibration) photo carries a fresh capture blob. It must + // become a valid object URL — never a raw Blob, never String(blob)="object Blob". + const blob = new Blob(['png-bytes'], { type: 'image/png' }); + const fake = (b: Blob) => `blob:fake-${b.size}`; + assert.equal(resolvePhotoUrl(blob, '/api/endpoints/photo', fake), 'blob:fake-9'); + // Empty/absent blob falls back to the stored endpoint. + assert.equal(resolvePhotoUrl(new Blob(), '/api/endpoints/photo', fake), '/api/endpoints/photo'); + assert.equal(resolvePhotoUrl(null, '/api/endpoints/photo', fake), '/api/endpoints/photo'); +}); diff --git a/frontend/src/create/review/lineup.ts b/frontend/src/create/review/lineup.ts index b4fd348..bc931c8 100644 --- a/frontend/src/create/review/lineup.ts +++ b/frontend/src/create/review/lineup.ts @@ -42,7 +42,8 @@ export function moveItem(arr: T[], from: number, to: number): T[] { * clean calibration photo and one dimension-overlay copy. Returns the missing * role(s) so the UI can name them in a nudge. */ -export function missingMinimums(kept: LineupItem[]): PhotoRole[] { +export function missingMinimums(items: LineupItem[]): PhotoRole[] { + const kept = items.filter((it) => it.kept); const missing: PhotoRole[] = []; if (!kept.some((it) => it.role === 'calibration')) missing.push('calibration'); if (!kept.some((it) => it.role === 'overlay')) missing.push('overlay'); @@ -60,3 +61,22 @@ export function roleLabel(role: PhotoRole): string { if (role === 'overlay') return 'its dimension-overlay copy'; return 'a batch photo'; } + +/** + * Resolve the src for a strip photo. The in-memory capture blob (fresh + * photo) must become a `blob:` object URL — assigning a raw Blob (or + * `String(blob)` = "object Blob") to `src` renders a broken image. That was + * bug #1: the first/Primary (calibration) photo is the one carrying a blob. + * When there is no usable blob, fall back to the stored photo endpoint. + * + * Pure + injectable: `createObjectUrl` defaults to `URL.createObjectURL` so it + * is unit-testable without a real DOM/Blob. + */ +export function resolvePhotoUrl( + blob: Blob | null, + endpoint: string, + createObjectUrl: (b: Blob) => string = (b) => URL.createObjectURL(b), +): string { + if (blob && blob.size > 0) return createObjectUrl(blob); + return endpoint; +}