diff --git a/docs/REQUIREMENTS.md b/docs/REQUIREMENTS.md
index 4f962bb..96e60e9 100644
--- a/docs/REQUIREMENTS.md
+++ b/docs/REQUIREMENTS.md
@@ -35,7 +35,7 @@ Sources reconciled 2026-09-18 from the current branch docs plus the `pre-simplif
| CAPTURE-90 | bdft equals sqft times thickness_in, rounded half-up to 2 decimals, with no divide-by-12; 1 sqft at 1 inch equals 1 bdft. | shipped | frontend/src/measure/measure.test.ts |
| CAPTURE-95 | The inventory PNG is 8-bit RGBA with alpha preserved through encode and upload. | shipped | frontend/src/png/inventory.test.ts |
| CAPTURE-100 | Crop is 3:4 portrait, slab centered, 90 percent fill at the widest and tallest extremes, on a transparent background. | shipped | frontend/src/png/inventory.test.ts |
-| CAPTURE-105 | After crop the shorter side is at least 800 px when the source allows; otherwise warn and require a new photo, and never upscale. | shipped | frontend/src/png/inventory.test.ts |
+| CAPTURE-105 | After crop the shorter side targets 800 px when the source allows; warn (still encode/include) when 300–799 px; hard-block below 300 px; never upscale. | shipped | frontend/src/png/inventory.test.ts |
| CAPTURE-110 | A non-calibration photo that yields an empty mask or fails the size floor is excluded with a per-photo error while the rest proceed. | shipped | frontend/src/capture/session.batchApply.test.ts |
| CAPTURE-115 | Scale, widths, sqft, and bdft stay tied to the calibration photo; non-calibration photos are visual inventory only. | shipped | frontend/src/capture/session.batchApply.test.ts |
| CAPTURE-120 | The dimension overlay is black and oriented to the approved length axis, with the length graphic beside the wood on the right in axis space. | shipped | frontend/src/png/overlay.test.ts |
diff --git a/docs/capabilities/CAPTURE.md b/docs/capabilities/CAPTURE.md
index 309e223..02257c7 100644
--- a/docs/capabilities/CAPTURE.md
+++ b/docs/capabilities/CAPTURE.md
@@ -123,7 +123,7 @@ On Calculate, the app builds the inventory image from the approved mask:
1. Keep slab pixels from the source photo. Removed pixels are transparent.
2. Place the slab in a 3:4 portrait frame, centered.
3. Scale so the widest and tallest points of the slab fill 90% of the frame. Extra margin where the slab is narrower.
-4. After that crop, shorter side ≥ 800px when the source can supply it. Otherwise warn and require a new photo. No upscale from an undersized source.
+4. After that crop, shorter side targets ≥ 800px when the source can supply it. If the slab bbox shorter side is 300–799 px, warn but still encode and include the photo. Below 300 px is a hard block (exclude / retake). No upscale from an undersized source.
5. Encode as PNG with alpha.
## Device draft
@@ -147,6 +147,6 @@ The calibration photo is the measurement reference (`CAPTURE-25`, `CAPTURE-115`)
| Alpha | Kept through encode and upload |
| Aspect | 3:4 portrait |
| Layout | Slab centered, 90% fill at the widest and tallest points |
-| Size | After crop, shorter side ≥ 800px when the source can supply it |
+| Size | After crop, shorter side targets ≥ 800px when the source can supply it |
-Source below that floor: warn and require a new photo. No upscale from an undersized source.
+Shorter side 300–799 px: warn but still encode/include. Below 300 px: hard block (exclude / retake). No upscale from an undersized source.
diff --git a/docs/capabilities/PHOTOS.md b/docs/capabilities/PHOTOS.md
index 640ca93..0f5e7f4 100644
--- a/docs/capabilities/PHOTOS.md
+++ b/docs/capabilities/PHOTOS.md
@@ -48,7 +48,7 @@ For each non-calibration photo:
- Reuse the approved Mask sheet (Auto / Green / Black) and sensitivity from the calibration photo. Do not re-open Mask controls for that photo in this milestone.
- Run background removal with those params; compose inventory PNG with the same crop rules as Milestone 1 Calculate.
-- If a photo yields an empty mask or fails the PNG size floor, keep it out of the ready set and show a clear per-photo error; other photos still proceed.
+- If a photo yields an empty mask or fails the PNG size block floor (<300 px shorter side), keep it out of the ready set and show a clear per-photo error; other photos still proceed. A warn-band size (300–799 px) still includes the photo with a clear per-photo warn.
Scale, widths, sqft, and bdft stay tied to the calibration photo and user `L` / thickness. Non-calibration photos are visual inventory only.
diff --git a/frontend/src/App.svelte b/frontend/src/App.svelte
index 6159256..87fbf9c 100644
--- a/frontend/src/App.svelte
+++ b/frontend/src/App.svelte
@@ -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;
diff --git a/frontend/src/capture/MeasurePanel.svelte b/frontend/src/capture/MeasurePanel.svelte
index b293a44..353afe4 100644
--- a/frontend/src/capture/MeasurePanel.svelte
+++ b/frontend/src/capture/MeasurePanel.svelte
@@ -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 @@
Scale {state.results.scale.toFixed(2)} px/in
Species {speciesOpts.find((o) => o.id === state.speciesId)?.name ?? (state.speciesId != null ? `#${state.speciesId}` : '—')}
- {#if state.results.sourceBelowFloor}
- Source is below the 800px floor for the inventory PNG. Retake a larger photo.
+ {#if state.results.sourceBelowBlock}
+
+ Source shorter side is below 300px — inventory PNG blocked. Retake a larger photo.
+
+ {:else if state.results.sourceBelowWarn}
+
+ Source shorter side is under 800px (300–799). Photo included; consider a closer/larger shot for best quality.
+
{/if}
@@ -365,6 +372,9 @@
Batch photo {(item.batchIndex ?? 0) + 1}
{/if}
+ {#if item.warn}
+ {item.warn}
+ {/if}
{:else}
@@ -376,7 +386,7 @@
- {#if lineupItems.length === 0 && pngUrl && !state.results.sourceBelowFloor}
+ {#if lineupItems.length === 0 && pngUrl && !state.results.sourceBelowBlock}
@@ -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;
diff --git a/frontend/src/capture/capture.types.ts b/frontend/src/capture/capture.types.ts
index 498fe9c..9eecc13 100644
--- a/frontend/src/capture/capture.types.ts
+++ b/frontend/src/capture/capture.types.ts
@@ -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.
diff --git a/frontend/src/capture/photosReadyLineup.test.ts b/frontend/src/capture/photosReadyLineup.test.ts
index 42d9c5c..bfd072c 100644
--- a/frontend/src/capture/photosReadyLineup.test.ts
+++ b/frontend/src/capture/photosReadyLineup.test.ts
@@ -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(
diff --git a/frontend/src/capture/photosReadyLineup.ts b/frontend/src/capture/photosReadyLineup.ts
index c3dfb5d..f7a4bd9 100644
--- a/frontend/src/capture/photosReadyLineup.ts
+++ b/frontend/src/capture/photosReadyLineup.ts
@@ -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({
diff --git a/frontend/src/capture/session.batchApply.test.ts b/frontend/src/capture/session.batchApply.test.ts
index 131cbe8..351f833 100644
--- a/frontend/src/capture/session.batchApply.test.ts
+++ b/frontend/src/capture/session.batchApply.test.ts
@@ -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[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[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[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[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[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[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[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[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[0]);
diff --git a/frontend/src/capture/session.overlay.test.ts b/frontend/src/capture/session.overlay.test.ts
index c43d1ce..5f3ff51 100644
--- a/frontend/src/capture/session.overlay.test.ts
+++ b/frontend/src/capture/session.overlay.test.ts
@@ -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[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[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[0]);
diff --git a/frontend/src/capture/session.ts b/frontend/src/capture/session.ts
index 766578e..1794330 100644
--- a/frontend/src/capture/session.ts
+++ b/frontend/src/capture/session.ts
@@ -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;
}
@@ -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 };
diff --git a/frontend/src/create/flow.ts b/frontend/src/create/flow.ts
index 33651bd..51d236e 100644
--- a/frontend/src/create/flow.ts
+++ b/frontend/src/create/flow.ts
@@ -18,7 +18,8 @@ export interface CalibratedCapture {
widthMinIn: number;
widthMaxIn: number;
widthAvgIn: number;
- sourceBelowFloor: boolean;
+ sourceBelowBlock: boolean;
+ sourceBelowWarn: boolean;
lineup: PhotosReadyLineupItem[];
}
diff --git a/frontend/src/measure/measure.ts b/frontend/src/measure/measure.ts
index 4f8284b..aecebb1 100644
--- a/frontend/src/measure/measure.ts
+++ b/frontend/src/measure/measure.ts
@@ -92,7 +92,7 @@ export function measure(
lengthEighths: Eighths, thicknessEighths: Eighths,
slabPx: number,
tape?: TapeAxis | null,
-): Omit & { sourceBelowFloor: false } {
+): Omit & { 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,
};
}
diff --git a/frontend/src/png/crop.ts b/frontend/src/png/crop.ts
index 8a7a890..b1c1d0a 100644
--- a/frontend/src/png/crop.ts
+++ b/frontend/src/png/crop.ts
@@ -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 {
diff --git a/frontend/src/png/inventory.test.ts b/frontend/src/png/inventory.test.ts
index 669c042..0f77213 100644
--- a/frontend/src/png/inventory.test.ts
+++ b/frontend/src/png/inventory.test.ts
@@ -30,7 +30,8 @@ test('alpha contract: removed pixels transparent, slab pixels keep RGB at alpha
const source: Pick = { 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 = { 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 = { 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 = { 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 = { 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 = { 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);
});
diff --git a/frontend/src/png/inventory.ts b/frontend/src/png/inventory.ts
index 37fe911..174a957 100644
--- a/frontend/src/png/inventory.ts
+++ b/frontend/src/png/inventory.ts
@@ -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;
@@ -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,
+ };
}
diff --git a/frontend/tests/e2e/helpers/draft.ts b/frontend/tests/e2e/helpers/draft.ts
index 492d585..8fb96b3 100644
--- a/frontend/tests/e2e/helpers/draft.ts
+++ b/frontend/tests/e2e/helpers/draft.ts
@@ -73,7 +73,8 @@ export async function seedMeasureDraft(
},
sqft: payload.sqft,
bdft: payload.bdft,
- sourceBelowFloor: false,
+ sourceBelowBlock: false,
+ sourceBelowWarn: false,
},
inventoryPng: png,
overlayPng: null,