fix(capture): warn 300–799px PNG floor; hard-block only below 300

Split FLOOR_WARN_PX=800 / FLOOR_BLOCK_PX=300. Warn-band still encodes and
includes the photo (Photos-ready + batchApply ok+warn). Block excludes
siblings and clean cal. CAPTURE-105 / CAPTURE.md / PHOTOS.md updated.
This commit is contained in:
grokbot_ty
2026-09-26 09:11:34 -07:00
parent df87d97a7d
commit 1b5ad112cc
17 changed files with 246 additions and 69 deletions
+4 -2
View File
@@ -77,12 +77,14 @@
widthMinIn: eighthsToIn(r.widths.min),
widthMaxIn: eighthsToIn(r.widths.max),
widthAvgIn: eighthsToIn(r.widths.avg),
sourceBelowFloor: r.sourceBelowFloor,
sourceBelowBlock: r.sourceBelowBlock,
sourceBelowWarn: r.sourceBelowWarn,
lineup: buildPhotosReadyLineup({
inventoryPng: r.inventoryPng,
overlayPng: r.overlayPng,
batchApply: m.batchApply,
sourceBelowFloor: r.sourceBelowFloor,
sourceBelowBlock: r.sourceBelowBlock,
sourceBelowWarn: r.sourceBelowWarn,
}),
};
saving = true;
+15 -4
View File
@@ -88,7 +88,8 @@
inventoryPng: state.results.inventoryPng,
overlayPng: state.results.overlayPng,
batchApply: state.batchApply,
sourceBelowFloor: state.results.sourceBelowFloor,
sourceBelowBlock: state.results.sourceBelowBlock,
sourceBelowWarn: state.results.sourceBelowWarn,
});
lineupUrls = lineupItems.map((it) =>
it.kind === 'png' ? URL.createObjectURL(it.blob) : '',
@@ -347,8 +348,14 @@
<div><span>Scale</span><span>{state.results.scale.toFixed(2)} px/in</span></div>
<div data-testid="photos-ready-species"><span>Species</span><span>{speciesOpts.find((o) => o.id === state.speciesId)?.name ?? (state.speciesId != null ? `#${state.speciesId}` : '—')}</span></div>
</div>
{#if state.results.sourceBelowFloor}
<div class="warn">Source is below the 800px floor for the inventory PNG. Retake a larger photo.</div>
{#if state.results.sourceBelowBlock}
<div class="warn" role="alert" data-testid="size-floor-block">
Source shorter side is below 300px — inventory PNG blocked. Retake a larger photo.
</div>
{:else if state.results.sourceBelowWarn}
<div class="warn" data-testid="size-floor-warn">
Source shorter side is under 800px (300–799). Photo included; consider a closer/larger shot for best quality.
</div>
{/if}
<ol class="lineup" aria-label="Inventory PNG lineup">
@@ -365,6 +372,9 @@
Batch photo {(item.batchIndex ?? 0) + 1}
{/if}
</span>
{#if item.warn}
<span class="item-warn" data-testid="lineup-warn">{item.warn}</span>
{/if}
</li>
{:else}
<li class="lineup-item error" role="alert">
@@ -376,7 +386,7 @@
</ol>
<!-- Legacy single-PNG fallback only when lineup helper yielded nothing but pngUrl exists -->
{#if lineupItems.length === 0 && pngUrl && !state.results.sourceBelowFloor}
{#if lineupItems.length === 0 && pngUrl && !state.results.sourceBelowBlock}
<div class="png-wrap">
<img src={pngUrl} alt="Inventory PNG" />
</div>
@@ -437,6 +447,7 @@
.results .nums { font-size: 1rem; }
.results .nums div { font-size: 1rem; }
.warn { color: #ffb4a2; margin-top: 12px; font-size: 0.9rem; }
.item-warn { display: block; margin-top: 6px; color: #c07020; font-size: 0.8rem; font-weight: 600; }
.lineup {
list-style: none; padding: 0; margin: 16px 0 0;
display: flex; flex-direction: column; gap: 14px;
+5 -2
View File
@@ -82,14 +82,17 @@ export interface Measures {
inventoryPng: Blob;
/** Dimension-overlay copy of the calibration inventory PNG (#63). */
overlayPng?: Blob;
sourceBelowFloor: boolean;
/** Shorter side of slab bbox < 300 px — hard fail / exclude clean cal. */
sourceBelowBlock: boolean;
/** Shorter side in 300–799 px — encode/include with warn. */
sourceBelowWarn: boolean;
}
// Result of applying the calibration mask to one batch sibling. The
// calibration slot is null because its inventory PNG lives in Measures.
export type BatchPhotoResult =
| null
| { status: 'ok'; inventoryPng: Blob }
| { status: 'ok'; inventoryPng: Blob; /** Soft size-floor concern; photo still included. */ warn?: string }
| { status: 'error'; message: string };
// The machine. 'resume' exists only while a draft exists and has not been chosen.
+25 -4
View File
@@ -92,7 +92,7 @@ test('test(photos): batchApply errors become clear error slots (empty mask / flo
null,
{ status: 'error', message: 'empty mask: no slab pixels detected' },
{ status: 'ok', inventoryPng: ok },
{ status: 'error', message: 'PNG size floor: shorter side is below 800 px' },
{ status: 'error', message: 'PNG size floor: shorter side is below 300 px' },
];
const lineup = buildPhotosReadyLineup({
@@ -118,7 +118,7 @@ test('test(photos): batchApply errors become clear error slots (empty mask / flo
const err3 = lineup[4];
assert.equal(err3.kind, 'error');
if (err3.kind === 'error') {
assert.match(err3.message, /floor|800/i);
assert.match(err3.message, /floor|300/i);
}
// Successful sibling still appears after cal+overlay, in capture order among siblings
@@ -151,16 +151,37 @@ test('test(photos): lineup never uses source JPEG thumbs as inventory PNGs (#64)
}
});
test('test(photos): skip clean cal when sourceBelowFloor; keep overlay + siblings (#64)', () => {
test('test(photos): skip clean cal when sourceBelowBlock; keep overlay + siblings (#64)', () => {
const lineup = buildPhotosReadyLineup({
inventoryPng: png('below'),
overlayPng: png('overlay'),
batchApply: [null, { status: 'ok', inventoryPng: png('sib') }],
sourceBelowFloor: true,
sourceBelowBlock: true,
});
assert.deepEqual(roles(lineup), ['png:overlay', 'png:batch']);
});
test('test(photos): warn band keeps clean cal + attaches warn; siblings with warn stay png (#64)', () => {
const lineup = buildPhotosReadyLineup({
inventoryPng: png('warn-cal'),
overlayPng: png('overlay'),
batchApply: [
null,
{ status: 'ok', inventoryPng: png('sib'), warn: 'PNG size warn: shorter side is below 800 px (300–799); photo included' },
],
sourceBelowWarn: true,
});
assert.deepEqual(roles(lineup), ['png:calibration', 'png:overlay', 'png:batch']);
assert.equal(lineup[0].kind, 'png');
if (lineup[0].kind === 'png') {
assert.match(lineup[0].warn ?? '', /warn|800|300/i);
}
assert.equal(lineup[2].kind, 'png');
if (lineup[2].kind === 'png') {
assert.match(lineup[2].warn ?? '', /warn|800/i);
}
});
test('test(photos): empty/missing results yield empty lineup (#64)', () => {
assert.deepEqual(buildPhotosReadyLineup({}), []);
assert.deepEqual(
+30 -7
View File
@@ -8,6 +8,8 @@ export type PhotosReadyLineupItem =
blob: Blob;
/** Capture-order index for non-calibration batch inventory PNGs. */
batchIndex?: number;
/** Soft size-floor warn (300–799); photo still shown. */
warn?: string;
}
| {
kind: 'error';
@@ -19,25 +21,45 @@ export interface PhotosReadyLineupInput {
inventoryPng?: Blob | null;
overlayPng?: Blob | null;
batchApply?: BatchPhotoResult[] | null;
/** When true, omit the clean calibration PNG (M1 floor warn path). */
sourceBelowFloor?: boolean;
/** When true, omit the clean calibration PNG (hard block < 300 px). */
sourceBelowBlock?: boolean;
/** Soft warn for calibration (300–799); clean cal still included. */
sourceBelowWarn?: boolean;
/** Calibration warn message when sourceBelowWarn. */
calibrationWarn?: string;
}
/**
* Build the Photos-ready lineup:
* 1. Clean calibration inventory PNG
* 1. Clean calibration inventory PNG (skipped only on hard block)
* 2. Dimension-overlay copy
* 3. Other batch inventory PNGs in capture order (batchApply, skip cal null)
* with error slots for empty mask / floor failures.
* with error slots for empty mask / block-floor failures; warn stays on png.
*/
export function buildPhotosReadyLineup(
input: PhotosReadyLineupInput,
): PhotosReadyLineupItem[] {
const items: PhotosReadyLineupItem[] = [];
const { inventoryPng, overlayPng, batchApply, sourceBelowFloor } = input;
const {
inventoryPng,
overlayPng,
batchApply,
sourceBelowBlock,
sourceBelowWarn,
calibrationWarn,
} = input;
if (inventoryPng && !sourceBelowFloor) {
items.push({ kind: 'png', role: 'calibration', blob: inventoryPng });
if (inventoryPng && !sourceBelowBlock) {
const warn = sourceBelowWarn
? (calibrationWarn
?? 'PNG size warn: shorter side is below 800 px (300–799); photo included')
: undefined;
items.push({
kind: 'png',
role: 'calibration',
blob: inventoryPng,
...(warn ? { warn } : {}),
});
}
if (overlayPng) {
items.push({ kind: 'png', role: 'overlay', blob: overlayPng });
@@ -53,6 +75,7 @@ export function buildPhotosReadyLineup(
role: 'batch',
blob: slot.inventoryPng,
batchIndex: i,
...(slot.warn ? { warn: slot.warn } : {}),
});
} else {
items.push({
+61 -15
View File
@@ -211,7 +211,8 @@ test('#62: Calculate applies frozen mask params to every non-calibration batch p
// Distinctive PNG blob so tests cannot confuse with source JPEG thumbs
return {
blob: new Blob([`png-${input.source.widthPx}x${input.source.heightPx}`], { type: 'image/png' }),
sourceBelowFloor: false,
sourceBelowBlock: false,
sourceBelowWarn: false,
};
},
} as Parameters<typeof createCaptureSession>[0]);
@@ -269,7 +270,8 @@ test('#62: empty mask on one sibling → per-photo error; others still proceed',
draft: store,
encodeInventory: async () => ({
blob: new Blob(['ok-png'], { type: 'image/png' }),
sourceBelowFloor: false,
sourceBelowBlock: false,
sourceBelowWarn: false,
}),
} as Parameters<typeof createCaptureSession>[0]);
@@ -287,15 +289,15 @@ test('#62: empty mask on one sibling → per-photo error; others still proceed',
s.dispose();
});
test('#62: below PNG size floor on one sibling → per-photo error; others proceed', async () => {
test('#62: below block floor (<300) on one sibling → per-photo error; others proceed', async () => {
installFakes();
const a = tinyJpeg('a'); // ok
const b = tinyJpeg('b'); // cal
const c = tinyJpeg('c'); // below floor
const c = tinyJpeg('c'); // below block floor
FakeMaskWorker.behaviorFor = (blob) => {
if (blob === c) {
return { w: 400, h: 300, slabPx: 400 * 300, slabBounds: { x: 0, y: 0, w: 400, h: 300 } };
return { w: 250, h: 200, slabPx: 250 * 200, slabBounds: { x: 0, y: 0, w: 250, h: 200 } };
}
return { w: 1000, h: 900, slabPx: 1000 * 900, slabBounds: { x: 0, y: 0, w: 1000, h: 900 } };
};
@@ -303,10 +305,11 @@ test('#62: below PNG size floor on one sibling → per-photo error; others proce
const s = createCaptureSession({
draft: store,
encodeInventory: async (input) => {
const below = Math.min(input.mask.slabBounds.w, input.mask.slabBounds.h) < 800;
const short = Math.min(input.mask.slabBounds.w, input.mask.slabBounds.h);
return {
blob: new Blob([below ? 'tiny' : 'ok'], { type: 'image/png' }),
sourceBelowFloor: below,
blob: new Blob([short < 300 ? 'tiny' : 'ok'], { type: 'image/png' }),
sourceBelowBlock: short < 300,
sourceBelowWarn: short >= 300 && short < 800,
};
},
} as Parameters<typeof createCaptureSession>[0]);
@@ -317,9 +320,47 @@ test('#62: below PNG size floor on one sibling → per-photo error; others proce
const apply = batchApplyOf(get(s.capture));
assert.ok(apply);
assert.ok(apply![0] && apply![0].status === 'ok', 'above-floor sibling ok');
assert.ok(apply![2] && apply![2].status === 'error', 'below-floor → error');
assert.ok(apply![2] && apply![2].status === 'error', 'below-block → error');
if (apply![2]?.status === 'error') {
assert.match(apply![2].message, /floor|small|800|size/i, 'clear per-photo size-floor error');
assert.match(apply![2].message, /floor|300|size/i, 'clear per-photo size-floor error');
}
s.dispose();
});
test('#62: warn band (300–799) on sibling → ok with warn; photo included', async () => {
installFakes();
const a = tinyJpeg('a'); // ok
const b = tinyJpeg('b'); // cal
const c = tinyJpeg('c'); // warn band
FakeMaskWorker.behaviorFor = (blob) => {
if (blob === c) {
return { w: 500, h: 400, slabPx: 500 * 400, slabBounds: { x: 0, y: 0, w: 500, h: 400 } };
}
return { w: 1000, h: 900, slabPx: 1000 * 900, slabBounds: { x: 0, y: 0, w: 1000, h: 900 } };
};
const s = createCaptureSession({
draft: store,
encodeInventory: async (input) => {
const short = Math.min(input.mask.slabBounds.w, input.mask.slabBounds.h);
return {
blob: new Blob([short < 800 ? 'warn' : 'ok'], { type: 'image/png' }),
sourceBelowBlock: short < 300,
sourceBelowWarn: short >= 300 && short < 800,
};
},
} as Parameters<typeof createCaptureSession>[0]);
await driveToMeasure(s, [a, b, c], 1, { sheet: 'auto' });
await s.calculate();
const apply = batchApplyOf(get(s.capture));
assert.ok(apply);
assert.ok(apply![0] && apply![0].status === 'ok', 'above-floor sibling ok');
assert.ok(apply![2] && apply![2].status === 'ok', 'warn-band → still ok');
if (apply![2]?.status === 'ok') {
assert.match(apply![2].warn ?? '', /warn|800|300/i, 'warn message attached');
}
s.dispose();
});
@@ -330,7 +371,8 @@ test('#62: scale/widths/sqft/bdft stay on calibration results only (siblings are
draft: store,
encodeInventory: async () => ({
blob: new Blob(['png'], { type: 'image/png' }),
sourceBelowFloor: false,
sourceBelowBlock: false,
sourceBelowWarn: false,
}),
} as Parameters<typeof createCaptureSession>[0]);
@@ -373,7 +415,8 @@ test('#62: draft persists batchApply after Calculate (for #64 UI later)', async
draft: store,
encodeInventory: async () => ({
blob: new Blob(['draft-png'], { type: 'image/png' }),
sourceBelowFloor: false,
sourceBelowBlock: false,
sourceBelowWarn: false,
}),
} as Parameters<typeof createCaptureSession>[0]);
@@ -401,7 +444,8 @@ test('#62: single-photo batch Calculate leaves batchApply=[null] (no sibling Mas
draft: store,
encodeInventory: async () => ({
blob: new Blob(['only'], { type: 'image/png' }),
sourceBelowFloor: false,
sourceBelowBlock: false,
sourceBelowWarn: false,
}),
} as Parameters<typeof createCaptureSession>[0]);
@@ -426,7 +470,8 @@ test('#62: Calculate does not re-enter Mask / does not open Mask controls for si
draft: store,
encodeInventory: async () => ({
blob: new Blob(['x'], { type: 'image/png' }),
sourceBelowFloor: false,
sourceBelowBlock: false,
sourceBelowWarn: false,
}),
} as Parameters<typeof createCaptureSession>[0]);
@@ -463,7 +508,8 @@ test('#62: resume restores batchApply alongside calibration results', async () =
draft: store,
encodeInventory: async () => ({
blob: new Blob(['resume-png'], { type: 'image/png' }),
sourceBelowFloor: false,
sourceBelowBlock: false,
sourceBelowWarn: false,
}),
} as Parameters<typeof createCaptureSession>[0]);
+6 -3
View File
@@ -185,7 +185,8 @@ test('test(photos): Calculate stores overlayPng on results, clean inventory unto
encodeN++;
return {
blob: new Blob([`clean-${encodeN}`], { type: 'image/png' }),
sourceBelowFloor: false,
sourceBelowBlock: false,
sourceBelowWarn: false,
};
},
} as Parameters<typeof createCaptureSession>[0]);
@@ -221,7 +222,8 @@ test('test(photos): single-photo batch still yields clean + overlayPng (#63)', a
draft: store,
encodeInventory: async () => ({
blob: new Blob(['only-clean'], { type: 'image/png' }),
sourceBelowFloor: false,
sourceBelowBlock: false,
sourceBelowWarn: false,
}),
} as Parameters<typeof createCaptureSession>[0]);
@@ -243,7 +245,8 @@ test('test(photos): draft persists overlayPng alongside inventoryPng (#63)', asy
draft: store,
encodeInventory: async () => ({
blob: new Blob(['draft-clean'], { type: 'image/png' }),
sourceBelowFloor: false,
sourceBelowBlock: false,
sourceBelowWarn: false,
}),
} as Parameters<typeof createCaptureSession>[0]);
+22 -8
View File
@@ -62,7 +62,7 @@ export interface CaptureSession {
export interface SessionOpts {
draft?: DraftStore;
/** Injectable only for deterministic tests; production uses encodeInventoryPng. */
encodeInventory?: (input: InventoryInput) => Promise<{ blob: Blob; sourceBelowFloor: boolean }>;
encodeInventory?: (input: InventoryInput) => Promise<{ blob: Blob; sourceBelowBlock: boolean; sourceBelowWarn: boolean }>;
/** Injectable only for deterministic tests; production uses encodeOverlayPng. */
encodeOverlay?: (input: EncodeOverlayInput) => Promise<Blob>;
}
@@ -414,6 +414,12 @@ export function createCaptureSession(opts: SessionOpts = {}): CaptureSession {
...d.results,
inventoryPng: d.inventoryPng ?? new Blob(),
overlayPng: d.overlayPng ?? undefined,
// Migrate legacy sourceBelowFloor → warn; missing flags default false.
sourceBelowBlock: (d.results as { sourceBelowBlock?: boolean }).sourceBelowBlock ?? false,
sourceBelowWarn:
(d.results as { sourceBelowWarn?: boolean }).sourceBelowWarn
?? (d.results as { sourceBelowFloor?: boolean }).sourceBelowFloor
?? false,
}
: null,
batchApply: d.batchApply,
@@ -632,7 +638,8 @@ export function createCaptureSession(opts: SessionOpts = {}): CaptureSession {
tapeAxis,
);
let pngBlob: Blob;
let belowFloor = false;
let belowBlock = false;
let belowWarn = false;
// #99: bake photo tilt into cal inventory + overlay only; siblings untouched.
let calBitmap = bitmap;
let calMask = approvedMask;
@@ -650,7 +657,8 @@ export function createCaptureSession(opts: SessionOpts = {}): CaptureSession {
try {
const r = await encodeInventory({ bitmap: calBitmap, mask: calMask, source: calSource });
pngBlob = r.blob;
belowFloor = r.sourceBelowFloor;
belowBlock = r.sourceBelowBlock;
belowWarn = r.sourceBelowWarn;
} catch {
// CAPTURE.md: inventory is image/png even when encode is unavailable.
pngBlob = new Blob([], { type: 'image/png' });
@@ -704,7 +712,8 @@ export function createCaptureSession(opts: SessionOpts = {}): CaptureSession {
...base,
inventoryPng: pngBlob,
overlayPng: overlayBlob,
sourceBelowFloor: belowFloor,
sourceBelowBlock: belowBlock,
sourceBelowWarn: belowWarn,
};
const params = approvedMask.params;
const calIndex = calibrationIndex != null && calibrationIndex >= 0
@@ -736,16 +745,21 @@ export function createCaptureSession(opts: SessionOpts = {}): CaptureSession {
heightPx: derived.heightPx,
};
const siblingBitmap = await decodeOrientedBitmap(photo.blob);
let encoded: { blob: Blob; sourceBelowFloor: boolean };
let encoded: { blob: Blob; sourceBelowBlock: boolean; sourceBelowWarn: boolean };
try {
encoded = await encodeInventory({ bitmap: siblingBitmap, mask: derived.mask, source: siblingSource });
} finally {
siblingBitmap.close();
}
if (encoded.sourceBelowFloor) {
throw new Error('PNG size floor: shorter side is below 800 px');
if (encoded.sourceBelowBlock) {
throw new Error('PNG size floor: shorter side is below 300 px');
}
batchApply[index] = { status: 'ok', inventoryPng: encoded.blob };
const warn = encoded.sourceBelowWarn
? 'PNG size warn: shorter side is below 800 px (300–799); photo included'
: undefined;
batchApply[index] = warn
? { status: 'ok', inventoryPng: encoded.blob, warn }
: { status: 'ok', inventoryPng: encoded.blob };
} catch (error) {
const message = error instanceof Error ? error.message : 'unable to process photo';
batchApply[index] = { status: 'error', message };
+2 -1
View File
@@ -18,7 +18,8 @@ export interface CalibratedCapture {
widthMinIn: number;
widthMaxIn: number;
widthAvgIn: number;
sourceBelowFloor: boolean;
sourceBelowBlock: boolean;
sourceBelowWarn: boolean;
lineup: PhotosReadyLineupItem[];
}
+3 -2
View File
@@ -92,7 +92,7 @@ export function measure(
lengthEighths: Eighths, thicknessEighths: Eighths,
slabPx: number,
tape?: TapeAxis | null,
): Omit<Measures, 'inventoryPng'> & { sourceBelowFloor: false } {
): Omit<Measures, 'inventoryPng'> & { sourceBelowBlock: false; sourceBelowWarn: false } {
const lengthIn = lengthEighths / 8;
let start: [number, number];
let ux: number;
@@ -132,6 +132,7 @@ export function measure(
widths,
sqft,
bdft,
sourceBelowFloor: false,
sourceBelowBlock: false,
sourceBelowWarn: false,
};
}
+11 -1
View File
@@ -2,12 +2,22 @@
* Shared crop constants for inventory PNG + tape-line overlay (#99).
* Aspect is fixed 3:4; crop_fill only moves the long-axis end insets.
* Non-retro: changing fill affects future encodes only, not stored PNGs.
*
* Size floors (shorter side of slab bbox, source px):
* - FLOOR_WARN_PX (800): target frame shorter side when source allows;
* 300–799 → warn but still encode / include.
* - FLOOR_BLOCK_PX (300): hard fail / exclude below this.
*/
export const DEFAULT_CROP_FILL = 0.9;
export const ASPECT_W = 3;
export const ASPECT_H = 4;
export const FLOOR_PX = 800;
/** Target inventory frame shorter side when source can supply it. */
export const FLOOR_WARN_PX = 800;
/** Hard-block threshold: shorter side below this fails / excludes the photo. */
export const FLOOR_BLOCK_PX = 300;
/** @deprecated Prefer FLOOR_WARN_PX; kept as alias for the target frame size. */
export const FLOOR_PX = FLOOR_WARN_PX;
/** Fraction of the long axis inset from each end: (1 - fill) / 2. */
export function cropMarginFraction(fill: number = DEFAULT_CROP_FILL): number {
+37 -7
View File
@@ -30,7 +30,8 @@ test('alpha contract: removed pixels transparent, slab pixels keep RGB at alpha
const source: Pick<Source, 'widthPx' | 'heightPx'> = { widthPx: W, heightPx: H };
const out = composeInventoryFrame(src, maskFor(bitset, { x: 1, y: 1, w: 4, h: 4 }), source);
assert.equal(out.sourceBelowFloor, true);
assert.equal(out.sourceBelowBlock, true, '4px slab is below block floor');
assert.equal(out.sourceBelowWarn, false);
assert.ok(out.width < 800, 'undersized source stays small');
let opaque = 0;
@@ -66,10 +67,11 @@ test('3:4 frame at the floor: 2000×1800 slab → 800×1067', () => {
);
assert.equal(out.width, 800);
assert.equal(out.height, 1067);
assert.equal(out.sourceBelowFloor, false);
assert.equal(out.sourceBelowBlock, false);
assert.equal(out.sourceBelowWarn, false);
});
test('mid-band: slab shorter side 1000 (between 800 and 1600) → at floor, not belowFloor', () => {
test('mid-band: slab shorter side 1000 (between 800 and 1600) → at floor, no warn/block', () => {
const source: Pick<Source, 'widthPx' | 'heightPx'> = { widthPx: 1200, heightPx: 1000 };
const bitset = new Uint8Array(source.widthPx * source.heightPx).fill(1);
const out = composeInventoryFrame(
@@ -77,10 +79,24 @@ test('mid-band: slab shorter side 1000 (between 800 and 1600) → at floor, not
maskFor(bitset, { x: 0, y: 0, w: 1200, h: 1000 }),
source,
);
assert.equal(out.sourceBelowFloor, false);
assert.equal(out.sourceBelowBlock, false);
assert.equal(out.sourceBelowWarn, false);
assert.equal(out.width, 800);
});
test('warn band: shorter side 500 (300–799) → sourceBelowWarn, encode allowed, no upscale past source', () => {
const source: Pick<Source, 'widthPx' | 'heightPx'> = { widthPx: 600, heightPx: 500 };
const bitset = new Uint8Array(source.widthPx * source.heightPx).fill(1);
const out = composeInventoryFrame(
makePhoto(source.widthPx, source.heightPx),
maskFor(bitset, { x: 0, y: 0, w: 600, h: 500 }),
source,
);
assert.equal(out.sourceBelowWarn, true);
assert.equal(out.sourceBelowBlock, false);
assert.ok(out.width <= 800, `outW ${out.width} must not exceed warn floor`);
});
test('90% fill: limiting dimension maps to 0.9 × frame side (#68)', () => {
const source: Pick<Source, 'widthPx' | 'heightPx'> = { widthPx: 2200, heightPx: 2000 };
const bitset = new Uint8Array(source.widthPx * source.heightPx).fill(1);
@@ -107,7 +123,7 @@ test('90% fill: limiting dimension maps to 0.9 × frame side (#68)', () => {
assert.ok(Math.abs(hExtent - 648) <= 2, `height extent ${hExtent} ≈ 648 (1800×0.36)`);
});
test('belowFloor: small slab → sourceBelowFloor, outW capped at floor (no upscale)', () => {
test('belowWarn (was belowFloor): 900×700 → warn not block, outW capped at warn floor', () => {
const source: Pick<Source, 'widthPx' | 'heightPx'> = { widthPx: 900, heightPx: 700 };
const bitset = new Uint8Array(source.widthPx * source.heightPx).fill(1);
const out = composeInventoryFrame(
@@ -115,6 +131,20 @@ test('belowFloor: small slab → sourceBelowFloor, outW capped at floor (no upsc
maskFor(bitset, { x: 0, y: 0, w: 900, h: 700 }),
source,
);
assert.equal(out.sourceBelowFloor, true);
assert.ok(out.width <= 800, `outW ${out.width} must not exceed the floor`);
assert.equal(out.sourceBelowWarn, true);
assert.equal(out.sourceBelowBlock, false);
assert.ok(out.width <= 800, `outW ${out.width} must not exceed the warn floor`);
});
test('belowBlock: shorter side 200 → sourceBelowBlock, no upscale', () => {
const source: Pick<Source, 'widthPx' | 'heightPx'> = { widthPx: 250, heightPx: 200 };
const bitset = new Uint8Array(source.widthPx * source.heightPx).fill(1);
const out = composeInventoryFrame(
makePhoto(source.widthPx, source.heightPx),
maskFor(bitset, { x: 0, y: 0, w: 250, h: 200 }),
source,
);
assert.equal(out.sourceBelowBlock, true);
assert.equal(out.sourceBelowWarn, false);
assert.ok(out.width <= 800);
});
+18 -7
View File
@@ -1,5 +1,5 @@
import type { Mask, Source } from '../capture/capture.types';
import { ASPECT_H, ASPECT_W, FLOOR_PX, DEFAULT_CROP_FILL } from './crop';
import { ASPECT_H, ASPECT_W, FLOOR_WARN_PX, FLOOR_BLOCK_PX, DEFAULT_CROP_FILL } from './crop';
import { getCropFill } from '../settings/cropFill';
type MaskPick = Pick<Mask, 'bitset' | 'slabBounds'>;
@@ -17,7 +17,10 @@ export interface Frame {
width: number;
height: number;
rgba: Uint8Array;
sourceBelowFloor: boolean;
/** Shorter side of slab bbox is below FLOOR_BLOCK_PX (hard fail). */
sourceBelowBlock: boolean;
/** Shorter side is in [FLOOR_BLOCK_PX, FLOOR_WARN_PX) — encode/include with warn. */
sourceBelowWarn: boolean;
}
export function composeInventoryFrame(
@@ -34,8 +37,12 @@ export function composeInventoryFrame(
const y1 = Math.min(source.heightPx, y0 + Math.max(1, Math.round(b.h)));
const sw = x1 - x0;
const sh = y1 - y0;
const belowFloor = Math.min(sw, sh) < FLOOR_PX;
const outW = belowFloor ? Math.max(1, Math.min(sw, FLOOR_PX)) : FLOOR_PX;
const shortSide = Math.min(sw, sh);
const sourceBelowBlock = shortSide < FLOOR_BLOCK_PX;
const sourceBelowWarn = !sourceBelowBlock && shortSide < FLOOR_WARN_PX;
// Undersized vs warn floor: frame from available source (no upscale to 800).
const undersized = shortSide < FLOOR_WARN_PX;
const outW = undersized ? Math.max(1, Math.min(sw, FLOOR_WARN_PX)) : FLOOR_WARN_PX;
const outH = Math.round((outW * ASPECT_H) / ASPECT_W);
const scale = Math.min((outW * FILL) / Math.max(sw, 1), (outH * FILL) / Math.max(sh, 1));
const dw = sw * scale;
@@ -60,12 +67,12 @@ export function composeInventoryFrame(
rgba[di + 3] = 255;
}
}
return { width: outW, height: outH, rgba, sourceBelowFloor: belowFloor };
return { width: outW, height: outH, rgba, sourceBelowBlock, sourceBelowWarn };
}
export async function encodeInventoryPng(
input: InventoryInput,
): Promise<{ blob: Blob; sourceBelowFloor: boolean }> {
): Promise<{ blob: Blob; sourceBelowBlock: boolean; sourceBelowWarn: boolean }> {
const { bitmap, mask, source } = input;
const fill = input.cropFill ?? getCropFill();
const srcCanvas = new OffscreenCanvas(source.widthPx, source.heightPx);
@@ -82,5 +89,9 @@ export async function encodeInventoryPng(
const ctx = out.getContext('2d')!;
ctx.putImageData(new ImageData(new Uint8ClampedArray(frame.rgba), frame.width, frame.height), 0, 0);
const blob = await out.convertToBlob({ type: 'image/png' });
return { blob, sourceBelowFloor: frame.sourceBelowFloor };
return {
blob,
sourceBelowBlock: frame.sourceBelowBlock,
sourceBelowWarn: frame.sourceBelowWarn,
};
}