Skip to content

Commit 682ce6b

Browse files
committed
feat(scanner): fill the screen with the camera and add preview zoom and pan
The camera preview now covers the whole screen behind the floating controls instead of letterboxing in black, and the page background follows the palette. Scanner settings gain a Camera preview section with zoom and horizontal/vertical pan, so a phone on a scanning stand can centre the card between the controls. Only the preview moves: the recognizer still scans the whole camera image. The corner brackets frame the on-screen part inside the system bars, and tap-to-focus and detected card outlines follow the preview crop. Amp-Thread-ID: https://ampcode.com/threads/T-01a0eaaa-22a0-77a9-8d6c-ddfed62424d8
1 parent 1af517f commit 682ce6b

8 files changed

Lines changed: 318 additions & 33 deletions

File tree

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
export type Size = { width: number; height: number }
2+
/** A rectangle in the camera image's pixel space. */
3+
export type Rect = { x: number; y: number; width: number; height: number }
4+
5+
/**
6+
* How the camera preview fills the screen. The preview always covers the screen; `zoom`
7+
* enlarges it beyond that, and `panX`/`panY` (0–1 of the camera image) pick the point shown at
8+
* the middle of the screen, as far as the image's edges allow. Only the preview changes: the
9+
* recognizer always scans the whole camera image.
10+
*/
11+
export interface PreviewFraming {
12+
zoom: number
13+
panX: number
14+
panY: number
15+
}
16+
17+
/** The part of the camera `image` the preview shows in a `box` of the given size. */
18+
export function previewRect(image: Size, box: Size, framing: PreviewFraming): Rect {
19+
const scale = Math.max(box.width / image.width, box.height / image.height) * framing.zoom
20+
const width = Math.min(image.width, box.width / scale)
21+
const height = Math.min(image.height, box.height / scale)
22+
const clamp = (value: number, max: number) => Math.min(Math.max(value, 0), max)
23+
return {
24+
x: clamp(framing.panX * image.width - width / 2, image.width - width),
25+
y: clamp(framing.panY * image.height - height / 2, image.height - height),
26+
width,
27+
height,
28+
}
29+
}
30+
31+
/** A `box` on screen (relative to the preview's `container`) in camera image pixels. */
32+
export function inImage(box: Rect, visible: Rect, container: Rect): Rect {
33+
const scale = visible.width / container.width
34+
return {
35+
x: visible.x + box.x * scale,
36+
y: visible.y + box.y * scale,
37+
width: box.width * scale,
38+
height: box.height * scale,
39+
}
40+
}

‎assets/react/src/pages/scan/scan-page.tsx‎

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -58,7 +58,7 @@ export function ScanPage() {
5858

5959
return (
6060
<div
61-
className="scan-page fixed inset-0 z-40 overflow-hidden bg-black text-base-content"
61+
className="scan-page fixed inset-0 z-40 overflow-hidden bg-base-100 text-base-content"
6262
data-theme="dark"
6363
data-palette={
6464
typeof document === "undefined"
@@ -69,10 +69,15 @@ export function ScanPage() {
6969
<ScanViewfinder
7070
videoRef={camera.videoRef}
7171
view={view}
72+
framing={{
73+
zoom: settings.previewZoom,
74+
panX: settings.previewPanX,
75+
panY: settings.previewPanY,
76+
}}
7277
onFocusAt={(x, y) => void camera.focusAt(x, y)}
7378
/>
7479

75-
<header className="absolute inset-x-0 top-0 z-10 flex items-center gap-2 bg-gradient-to-b from-black/70 to-transparent px-3 pb-8 pt-[calc(var(--safe-top)_+_0.75rem)]">
80+
<header className="absolute inset-x-0 top-0 z-10 flex items-center gap-2 bg-gradient-to-b from-base-100/80 to-transparent px-3 pb-8 pt-[calc(var(--safe-top)_+_0.75rem)]">
7681
<Link
7782
to="/collection"
7883
search={{ importFile: false }}
@@ -116,7 +121,7 @@ export function ScanPage() {
116121
{camera.state.status === "error" ? (
117122
<CameraErrorPanel message={camera.state.message} onRetry={start} />
118123
) : (
119-
<div className="absolute inset-x-0 bottom-0 mx-auto flex max-w-xl flex-col gap-2 bg-gradient-to-t from-black/70 to-transparent px-3 pb-[calc(var(--safe-bottom)_+_0.75rem)] pt-10">
124+
<div className="absolute inset-x-0 bottom-0 mx-auto flex max-w-xl flex-col gap-2 bg-gradient-to-t from-base-100/80 to-transparent px-3 pb-[calc(var(--safe-bottom)_+_0.75rem)] pt-10">
120125
<div className="flex items-center justify-center gap-2">
121126
<StatusPill camera={camera.state} recognizer={recognizer.state} view={view} />
122127
{recognizer.state.status === "ready" && camera.state.status === "live" ? (

‎assets/react/src/pages/scan/scan-settings-sheet.tsx‎

Lines changed: 104 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,13 @@ import { Input } from "../../components/ui/input"
1212
import { Switch } from "../../components/ui/switch"
1313
import { SetCombobox } from "../collection/set-combobox"
1414
import type { RecognizerState } from "./recognition/use-recognizer"
15-
import { normalizeScanSettings, type ScanSettings } from "./scan-settings"
15+
import {
16+
DEFAULT_SCAN_SETTINGS,
17+
normalizeScanSettings,
18+
PREVIEW_ZOOM_MAX,
19+
PREVIEW_ZOOM_MIN,
20+
type ScanSettings,
21+
} from "./scan-settings"
1622
import { playScanSound, unlockScanSounds } from "./scan-sounds"
1723

1824
export function ScanSettingsSheet({
@@ -74,6 +80,57 @@ export function ScanSettingsSheet({
7480
/>
7581
</section>
7682

83+
<section className="space-y-3 px-5 py-4">
84+
<div>
85+
<h3 className="text-sm font-bold">Camera preview</h3>
86+
<p className="text-sm text-base-content/70">
87+
Zoom and pan the preview to where cards sit, for example with the phone on a
88+
scanning stand. Scanning still uses the whole camera image.
89+
</p>
90+
</div>
91+
<RangeField
92+
label="Zoom"
93+
value={settings.previewZoom}
94+
min={PREVIEW_ZOOM_MIN}
95+
max={PREVIEW_ZOOM_MAX}
96+
step={0.05}
97+
format={(zoom) => `${zoom.toFixed(2)}×`}
98+
onChange={(previewZoom) => update({ previewZoom })}
99+
/>
100+
<RangeField
101+
label="Pan across"
102+
value={settings.previewPanX}
103+
min={0}
104+
max={1}
105+
step={0.01}
106+
format={formatPan}
107+
onChange={(previewPanX) => update({ previewPanX })}
108+
/>
109+
<RangeField
110+
label="Pan down"
111+
value={settings.previewPanY}
112+
min={0}
113+
max={1}
114+
step={0.01}
115+
format={formatPan}
116+
onChange={(previewPanY) => update({ previewPanY })}
117+
/>
118+
<Button
119+
type="button"
120+
variant="outline"
121+
size="sm"
122+
onClick={() =>
123+
update({
124+
previewZoom: DEFAULT_SCAN_SETTINGS.previewZoom,
125+
previewPanX: DEFAULT_SCAN_SETTINGS.previewPanX,
126+
previewPanY: DEFAULT_SCAN_SETTINGS.previewPanY,
127+
})
128+
}
129+
>
130+
Reset preview
131+
</Button>
132+
</section>
133+
77134
<section className="space-y-3 px-5 py-4">
78135
<ToggleRow
79136
label="Sounds"
@@ -116,6 +173,52 @@ export function ScanSettingsSheet({
116173
)
117174
}
118175

176+
/** Pan as an offset from the middle of the camera image: -50% to +50%. */
177+
function formatPan(pan: number) {
178+
const offset = Math.round((pan - 0.5) * 100)
179+
return offset > 0 ? `+${offset}%` : `${offset}%`
180+
}
181+
182+
function RangeField({
183+
label,
184+
value,
185+
min,
186+
max,
187+
step,
188+
format,
189+
onChange,
190+
}: {
191+
label: string
192+
value: number
193+
min: number
194+
max: number
195+
step: number
196+
format: (value: number) => string
197+
onChange: (value: number) => void
198+
}) {
199+
const id = useId()
200+
return (
201+
<div className="flex items-center gap-3">
202+
<label htmlFor={id} className="w-24 shrink-0 text-sm font-bold">
203+
{label}
204+
</label>
205+
<input
206+
id={id}
207+
type="range"
208+
min={min}
209+
max={max}
210+
step={step}
211+
value={value}
212+
onChange={(event) => onChange(Number.parseFloat(event.target.value))}
213+
className="range range-primary range-sm flex-1"
214+
/>
215+
<span className="w-14 shrink-0 text-right font-mono text-xs tabular-nums text-base-content/70">
216+
{format(value)}
217+
</span>
218+
</div>
219+
)
220+
}
221+
119222
function testSound(sound: "ding" | "big-ding") {
120223
unlockScanSounds()
121224
playScanSound(sound)

‎assets/react/src/pages/scan/scan-settings.ts‎

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,8 +12,16 @@ export interface ScanSettings {
1212
showTotal: boolean
1313
/** Upload each logged scan's camera frame to this server for training recognition. */
1414
collectTraining: boolean
15+
/** Camera preview zoom beyond filling the screen, for phones on a scanning stand. */
16+
previewZoom: number
17+
/** The point of the camera image (0–1) shown at the middle of the preview. */
18+
previewPanX: number
19+
previewPanY: number
1520
}
1621

22+
export const PREVIEW_ZOOM_MIN = 1
23+
export const PREVIEW_ZOOM_MAX = 2.5
24+
1725
export const DEFAULT_SCAN_SETTINGS: ScanSettings = {
1826
lockedSets: [],
1927
ignorePromos: true,
@@ -23,6 +31,9 @@ export const DEFAULT_SCAN_SETTINGS: ScanSettings = {
2331
bigDingThresholdCents: 1000,
2432
showTotal: true,
2533
collectTraining: false,
34+
previewZoom: 1,
35+
previewPanX: 0.5,
36+
previewPanY: 0.5,
2637
}
2738

2839
/** Stored settings from an older version or a hand-edited value fall back field by field. */
@@ -38,6 +49,12 @@ export function normalizeScanSettings(value: unknown): ScanSettings {
3849
? Math.round(number)
3950
: DEFAULT_SCAN_SETTINGS[key]
4051
}
52+
const within = (key: "previewZoom" | "previewPanX" | "previewPanY", min: number, max: number) => {
53+
const number = stored[key]
54+
return typeof number === "number" && Number.isFinite(number)
55+
? Math.min(Math.max(number, min), max)
56+
: DEFAULT_SCAN_SETTINGS[key]
57+
}
4158
return {
4259
lockedSets: Array.isArray(stored.lockedSets)
4360
? [
@@ -55,6 +72,9 @@ export function normalizeScanSettings(value: unknown): ScanSettings {
5572
bigDingThresholdCents: cents("bigDingThresholdCents"),
5673
showTotal: bool("showTotal"),
5774
collectTraining: bool("collectTraining"),
75+
previewZoom: within("previewZoom", PREVIEW_ZOOM_MIN, PREVIEW_ZOOM_MAX),
76+
previewPanX: within("previewPanX", 0, 1),
77+
previewPanY: within("previewPanY", 0, 1),
5878
}
5979
}
6080

0 commit comments

Comments
 (0)