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;
+}