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:
@@ -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; }
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user