fix(review): #90 photo strip — blob: URL for Primary photo, checkerboard thumbs

Bug #1: the first/Primary (calibration) photo rendered broken because its
in-memory capture Blob was assigned to <img src> directly (String(Blob)=
"object Blob"). resolvePhotoUrl() now mints a blob: object URL (revoked on
unload) and falls back to the stored photo endpoint when there is no blob.
Bug #2: strip thumbnails used a flat dark bg; now carry the gray checkerboard
from the Capture lineup (MeasurePanel) so transparent inventory PNGs read the
same on both screens.
Also harden missingMinimums to filter kept internally (pre-existing test 7
failed on the clean base because it passed un-kept items).
This commit is contained in:
Ty
2026-09-21 01:48:43 -07:00
parent 7b7adbdb34
commit 0612ce66a6
3 changed files with 59 additions and 7 deletions
+26 -6
View File
@@ -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
// <img> 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; }
+12
View File
@@ -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');
});
+21 -1
View File
@@ -42,7 +42,8 @@ export function moveItem<T>(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 <img> 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;
}