From ca909f1da68b1171ffbef4ad6ce6f91c4ca07009 Mon Sep 17 00:00:00 2001 From: noobydp Date: Sat, 11 Jul 2026 14:59:18 +0800 Subject: [PATCH 1/2] Add photo station capture workflow --- frontend/src/app/layout.tsx | 2 +- frontend/src/app/page.tsx | 8 +- frontend/src/app/trace/page.tsx | 510 ++++++++++++++++++ .../src/components/CaptureAreaOverlay.tsx | 156 ++++++ frontend/src/components/ImageUploader.tsx | 52 +- frontend/src/lib/api.ts | 5 +- 6 files changed, 719 insertions(+), 14 deletions(-) create mode 100644 frontend/src/app/trace/page.tsx create mode 100644 frontend/src/components/CaptureAreaOverlay.tsx diff --git a/frontend/src/app/layout.tsx b/frontend/src/app/layout.tsx index 2bec1b97..86e1ee4b 100644 --- a/frontend/src/app/layout.tsx +++ b/frontend/src/app/layout.tsx @@ -21,7 +21,7 @@ export default function RootLayout({ const [showHelp, setShowHelp] = useState(false) const pathname = usePathname() - const isFullBleed = /^\/(trace|tools|bins|stations)\//.test(pathname) + const isFullBleed = pathname === '/trace' || /^\/(trace|tools|bins|stations)\//.test(pathname) return ( diff --git a/frontend/src/app/page.tsx b/frontend/src/app/page.tsx index d646437c..fb803f69 100644 --- a/frontend/src/app/page.tsx +++ b/frontend/src/app/page.tsx @@ -401,7 +401,7 @@ export default function HomePage() { setError(null) try { const result = await uploadImage(file) - router.push(`/trace/${result.session_id}`) + router.push(`/trace/${result.session_id}?capture=1`) } catch (err) { setError(err instanceof Error ? err.message : 'upload failed') } finally { @@ -512,7 +512,11 @@ export default function HomePage() {
{/* upload */}
- + router.push('/trace')} + disabled={uploading} + />
{uploading && ( diff --git a/frontend/src/app/trace/page.tsx b/frontend/src/app/trace/page.tsx new file mode 100644 index 00000000..2630270f --- /dev/null +++ b/frontend/src/app/trace/page.tsx @@ -0,0 +1,510 @@ +'use client' + +import { Suspense, useCallback, useEffect, useRef, useState } from 'react' +import { useRouter, useSearchParams } from 'next/navigation' +import { Camera, Crop, Loader2, Upload } from 'lucide-react' +import { Alert } from '@/components/Alert' +import { CaptureAreaOverlay } from '@/components/CaptureAreaOverlay' +import { StepBar } from '@/components/StepBar' +import { getImageUrl, getPhotoStation, getSession, listPhotoStations, uploadImage } from '@/lib/api' +import type { CaptureCrop, PhotoStation } from '@/types' + +const STEPS = ['Capture', 'Corners', 'Trace', 'Save'] + +export default function CapturePage() { + return ( + }> + + + ) +} + +function CaptureFallback() { + return ( +
+ +
+ +
+
+ ) +} + +function CapturePageContent() { + const router = useRouter() + const searchParams = useSearchParams() + const existingSessionId = searchParams.get('session') + const stationParam = searchParams.get('station') + const stationApplied = searchParams.get('stationApplied') === '1' + const fileInputRef = useRef(null) + const videoRef = useRef(null) + const mediaWrapperRef = useRef(null) + const mediaContainerRef = useRef(null) + const previousStationIdRef = useRef(stationParam) + const [selectedStationId, setSelectedStationId] = useState(stationParam) + const [stations, setStations] = useState([]) + const [loadingStations, setLoadingStations] = useState(false) + const [captureArea, setCaptureArea] = useState(null) + const [fullCaptureAreaOverride, setFullCaptureAreaOverride] = useState(false) + const [editingCaptureArea, setEditingCaptureArea] = useState(false) + const [stream, setStream] = useState(null) + const [previewUrl, setPreviewUrl] = useState(null) + const [mediaSize, setMediaSize] = useState({ width: 0, height: 0 }) + const [fittedSize, setFittedSize] = useState({ width: 0, height: 0 }) + const [starting, setStarting] = useState(false) + const [loadingPreview, setLoadingPreview] = useState(false) + const [uploading, setUploading] = useState(false) + const [error, setError] = useState(null) + const autoStartedRef = useRef(false) + + const startCamera = useCallback(async () => { + setError(null) + if (!navigator.mediaDevices?.getUserMedia) { + setError('Camera capture requires localhost or HTTPS in Chrome and Edge.') + return + } + + setStarting(true) + try { + const nextStream = await navigator.mediaDevices.getUserMedia({ + video: { + facingMode: { ideal: 'environment' }, + width: { ideal: 1920 }, + height: { ideal: 1080 }, + }, + audio: false, + }) + stream?.getTracks().forEach((track) => track.stop()) + setPreviewUrl(null) + setMediaSize({ width: 0, height: 0 }) + setFittedSize({ width: 0, height: 0 }) + setStream(nextStream) + } catch (err) { + setError(err instanceof Error ? err.message : 'Camera could not be opened.') + } finally { + setStarting(false) + } + }, [stream]) + + useEffect(() => { + if (!stream || !videoRef.current) return + videoRef.current.srcObject = stream + videoRef.current.play().catch(() => setError('Camera preview could not start.')) + }, [stream]) + + useEffect(() => { + if (stationParam) { + setSelectedStationId(stationParam) + return + } + setSelectedStationId(null) + }, [stationParam]) + + useEffect(() => { + if (previousStationIdRef.current === selectedStationId) return + previousStationIdRef.current = selectedStationId + + setPreviewUrl(null) + setMediaSize({ width: 0, height: 0 }) + setFittedSize({ width: 0, height: 0 }) + startCamera() + }, [selectedStationId, startCamera]) + + useEffect(() => { + let cancelled = false + setLoadingStations(true) + listPhotoStations() + .then((items) => { + if (!cancelled) setStations(items) + }) + .catch(() => { + if (!cancelled) setStations([]) + }) + .finally(() => { + if (!cancelled) setLoadingStations(false) + }) + + return () => { cancelled = true } + }, []) + + useEffect(() => { + if (!selectedStationId) { + setCaptureArea(null) + setFullCaptureAreaOverride(false) + setEditingCaptureArea(false) + return + } + + let cancelled = false + getPhotoStation(selectedStationId) + .then((station) => { + if (cancelled) return + setCaptureArea(station.capture_crop) + setFullCaptureAreaOverride(false) + }) + .catch(() => { + if (!cancelled) setCaptureArea(null) + }) + + return () => { cancelled = true } + }, [selectedStationId]) + + useEffect(() => { + return () => { + stream?.getTracks().forEach((track) => track.stop()) + } + }, [stream]) + + useEffect(() => { + if (stream || previewUrl) return + setMediaSize({ width: 0, height: 0 }) + setFittedSize({ width: 0, height: 0 }) + }, [stream, previewUrl]) + + useEffect(() => { + function updateSize() { + if (!mediaWrapperRef.current || !mediaSize.width || !mediaSize.height) return + const availW = mediaWrapperRef.current.clientWidth + const availH = mediaWrapperRef.current.clientHeight + const aspect = mediaSize.width / mediaSize.height + let width = availW + let height = width / aspect + if (height > availH) { + height = availH + width = height * aspect + } + setFittedSize({ width: Math.floor(width), height: Math.floor(height) }) + } + + updateSize() + const observer = typeof ResizeObserver !== 'undefined' && mediaWrapperRef.current + ? new ResizeObserver(updateSize) + : null + if (observer && mediaWrapperRef.current) observer.observe(mediaWrapperRef.current) + window.addEventListener('resize', updateSize) + return () => { + observer?.disconnect() + window.removeEventListener('resize', updateSize) + } + }, [mediaSize]) + + function isFullCaptureArea(area: CaptureCrop | null) { + if (!area) return true + return area.x <= 0.001 && area.y <= 0.001 && area.width >= 0.999 && area.height >= 0.999 + } + + function beginCaptureAreaEdit() { + setCaptureArea(current => current || { x: 0.05, y: 0.05, width: 0.9, height: 0.9 }) + setFullCaptureAreaOverride(false) + setEditingCaptureArea(true) + } + + useEffect(() => { + if (existingSessionId) return + if (autoStartedRef.current) return + autoStartedRef.current = true + startCamera() + }, [existingSessionId, startCamera]) + + useEffect(() => { + if (!existingSessionId) { + setPreviewUrl(null) + return + } + + let cancelled = false + setLoadingPreview(true) + setError(null) + + getSession(existingSessionId) + .then((session) => { + if (cancelled) return + const imagePath = session.original_image_path + ? `/storage/${session.original_image_path}` + : session.corrected_image_path + ? `/storage/${session.corrected_image_path}` + : null + setPreviewUrl(imagePath ? getImageUrl(imagePath) : null) + }) + .catch((err) => { + if (!cancelled) setError(err instanceof Error ? err.message : 'Could not load previous capture.') + }) + .finally(() => { + if (!cancelled) setLoadingPreview(false) + }) + + return () => { cancelled = true } + }, [existingSessionId]) + + function traceParams(stationId: string | null) { + const params = new URLSearchParams({ capture: '1' }) + if (stationId) { + params.set('station', stationId) + params.set('stationApplied', '1') + } + return params + } + + async function uploadFile(file: File) { + setUploading(true) + setError(null) + try { + const uploadCaptureArea = captureArea && !isFullCaptureArea(captureArea) + ? captureArea + : fullCaptureAreaOverride + ? { x: 0, y: 0, width: 1, height: 1 } + : null + const result = await uploadImage(file, selectedStationId, uploadCaptureArea) + stream?.getTracks().forEach((track) => track.stop()) + const appliedStationId = result.station_id || selectedStationId + + const params = new URLSearchParams({ capture: '1' }) + if (appliedStationId) { + params.set('station', appliedStationId) + params.set('stationApplied', result.corner_source === 'station' ? '1' : '0') + } + router.push(`/trace/${result.session_id}?${params.toString()}`) + } catch (err) { + setError(err instanceof Error ? err.message : 'upload failed') + } finally { + setUploading(false) + } + } + + function captureFrame() { + const video = videoRef.current + if (!video || video.videoWidth === 0 || video.videoHeight === 0) { + setError('Camera is not ready yet.') + return + } + + const canvas = document.createElement('canvas') + canvas.width = video.videoWidth + canvas.height = video.videoHeight + const ctx = canvas.getContext('2d') + if (!ctx) { + setError('Could not capture this camera frame.') + return + } + + ctx.drawImage(video, 0, 0, canvas.width, canvas.height) + canvas.toBlob((blob) => { + if (!blob) { + setError('Could not encode this camera frame.') + return + } + uploadFile(new File([blob], `tracefinity-camera-${Date.now()}.jpg`, { type: 'image/jpeg' })) + }, 'image/jpeg', 0.92) + } + + return ( +
+ + +
+
+
+
+

+ Capture +

+
+ + + { + const file = e.target.files?.[0] + if (file) uploadFile(file) + e.target.value = '' + }} + className="hidden" + /> +
+
+ +
+

+ Photo Station +

+ + {selectedStationId && ( +

+ Saved corners and capture area will be reused. +

+ )} +
+ +
+

+ Capture Area +

+
+
+ + {isFullCaptureArea(captureArea) ? 'Full frame' : editingCaptureArea ? 'Editing' : 'Cropped'} + + {captureArea && !isFullCaptureArea(captureArea) && ( + + {Math.round(captureArea.width * 100)}% x {Math.round(captureArea.height * 100)}% + + )} +
+ + {captureArea && ( + + )} +
+
+ + {error && {error}} +
+ +
+ {previewUrl && existingSessionId && !stream ? ( + + ) : ( + + )} +
+
+ +
+
+
+ {stream ? ( +
+
+
+
+
+ ) +} diff --git a/frontend/src/components/CaptureAreaOverlay.tsx b/frontend/src/components/CaptureAreaOverlay.tsx new file mode 100644 index 00000000..896d85b7 --- /dev/null +++ b/frontend/src/components/CaptureAreaOverlay.tsx @@ -0,0 +1,156 @@ +'use client' + +import { useCallback, useEffect, useRef } from 'react' +import type { PointerEvent as ReactPointerEvent, RefObject } from 'react' +import type { CaptureCrop } from '@/types' + +type DragMode = 'move' | 'nw' | 'ne' | 'se' | 'sw' +const MIN_CAPTURE_AREA_SIZE = 0.08 + +interface Props { + area: CaptureCrop + editing: boolean + containerRef: RefObject + onChange: (area: CaptureCrop) => void +} + +function clampArea(nextArea: CaptureCrop): CaptureCrop { + const width = Math.max(MIN_CAPTURE_AREA_SIZE, Math.min(1, nextArea.width)) + const height = Math.max(MIN_CAPTURE_AREA_SIZE, Math.min(1, nextArea.height)) + return { + x: Math.max(0, Math.min(1 - width, nextArea.x)), + y: Math.max(0, Math.min(1 - height, nextArea.y)), + width, + height, + } +} + +export function CaptureAreaOverlay({ area, editing, containerRef, onChange }: Props) { + const dragRef = useRef<{ + mode: DragMode + startX: number + startY: number + startCrop: CaptureCrop + } | null>(null) + + const pointerToUnit = useCallback((clientX: number, clientY: number) => { + const rect = containerRef.current?.getBoundingClientRect() + if (!rect || rect.width <= 0 || rect.height <= 0) return null + return { + x: Math.max(0, Math.min(1, (clientX - rect.left) / rect.width)), + y: Math.max(0, Math.min(1, (clientY - rect.top) / rect.height)), + } + }, [containerRef]) + + function startDrag(mode: DragMode) { + return (event: ReactPointerEvent) => { + if (!editing) return + const point = pointerToUnit(event.clientX, event.clientY) + if (!point) return + event.preventDefault() + event.stopPropagation() + dragRef.current = { mode, startX: point.x, startY: point.y, startCrop: area } + event.currentTarget.setPointerCapture?.(event.pointerId) + } + } + + useEffect(() => { + function handlePointerMove(event: PointerEvent) { + const drag = dragRef.current + if (!drag) return + const point = pointerToUnit(event.clientX, event.clientY) + if (!point) return + + const dx = point.x - drag.startX + const dy = point.y - drag.startY + const start = drag.startCrop + let next = { ...start } + + if (drag.mode === 'move') { + next.x = start.x + dx + next.y = start.y + dy + } else { + const left = drag.mode === 'nw' || drag.mode === 'sw' + ? Math.min(start.x + start.width - MIN_CAPTURE_AREA_SIZE, start.x + dx) + : start.x + const right = drag.mode === 'ne' || drag.mode === 'se' + ? Math.max(start.x + MIN_CAPTURE_AREA_SIZE, start.x + start.width + dx) + : start.x + start.width + const top = drag.mode === 'nw' || drag.mode === 'ne' + ? Math.min(start.y + start.height - MIN_CAPTURE_AREA_SIZE, start.y + dy) + : start.y + const bottom = drag.mode === 'sw' || drag.mode === 'se' + ? Math.max(start.y + MIN_CAPTURE_AREA_SIZE, start.y + start.height + dy) + : start.y + start.height + + next = { + x: left, + y: top, + width: right - left, + height: bottom - top, + } + } + + onChange(clampArea(next)) + } + + function stopDrag() { + dragRef.current = null + } + + window.addEventListener('pointermove', handlePointerMove) + window.addEventListener('pointerup', stopDrag) + window.addEventListener('pointercancel', stopDrag) + return () => { + window.removeEventListener('pointermove', handlePointerMove) + window.removeEventListener('pointerup', stopDrag) + window.removeEventListener('pointercancel', stopDrag) + } + }, [onChange, pointerToUnit]) + + return ( +
+
+
+
+
+
+ {editing && ([ + ['nw', 'left-0 top-0 -translate-x-1/2 -translate-y-1/2 cursor-nwse-resize'], + ['ne', 'right-0 top-0 translate-x-1/2 -translate-y-1/2 cursor-nesw-resize'], + ['se', 'right-0 bottom-0 translate-x-1/2 translate-y-1/2 cursor-nwse-resize'], + ['sw', 'left-0 bottom-0 -translate-x-1/2 translate-y-1/2 cursor-nesw-resize'], + ] as const).map(([mode, className]) => ( +
+
+ ) +} diff --git a/frontend/src/components/ImageUploader.tsx b/frontend/src/components/ImageUploader.tsx index 6f89d565..9bd834ac 100644 --- a/frontend/src/components/ImageUploader.tsx +++ b/frontend/src/components/ImageUploader.tsx @@ -1,7 +1,7 @@ 'use client' import { useRef, useState } from 'react' -import { Upload } from 'lucide-react' +import { Camera, Upload } from 'lucide-react' import { useReducedMotion } from '@/hooks/useReducedMotion' function UploadIllustration({ reduceMotion }: { reduceMotion: boolean }) { @@ -152,21 +152,25 @@ function UploadIllustration({ reduceMotion }: { reduceMotion: boolean }) { interface Props { onUpload: (file: File) => void + onCaptureRequest?: () => void disabled?: boolean } -export function ImageUploader({ onUpload, disabled }: Props) { - const inputRef = useRef(null) +export function ImageUploader({ onUpload, onCaptureRequest, disabled }: Props) { + const fileInputRef = useRef(null) const [isDragging, setIsDragging] = useState(false) const reduceMotion = useReducedMotion() - function handleClick() { - if (!disabled) inputRef.current?.click() + function handleClick(e: React.MouseEvent) { + const target = e.target as HTMLElement + if (target.closest('button,input')) return + if (!disabled) fileInputRef.current?.click() } function handleChange(e: React.ChangeEvent) { const file = e.target.files?.[0] if (file && !disabled) onUpload(file) + e.target.value = '' } function handleDragOver(e: React.DragEvent) { @@ -200,7 +204,14 @@ export function ImageUploader({ onUpload, disabled }: Props) { ${disabled ? 'opacity-50 cursor-not-allowed' : ''} `} > - + e.stopPropagation()} + onChange={handleChange} + className="hidden" + /> {isDragging ? (
@@ -235,10 +246,31 @@ export function ImageUploader({ onUpload, disabled }: Props) { Take a top-down photo - - - Upload photo - +
+ + +
)} diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts index dfa4693a..280a345a 100644 --- a/frontend/src/lib/api.ts +++ b/frontend/src/lib/api.ts @@ -21,6 +21,7 @@ import type { PlacedTool, TextLabel, PaperSize, + CaptureCrop, } from '@/types' export class ApiError extends Error { @@ -66,9 +67,11 @@ async function fetchForm(path: string, body: FormData): Promise { return res.json() } -export async function uploadImage(file: File): Promise { +export async function uploadImage(file: File, stationId?: string | null, captureCrop?: CaptureCrop | null): Promise { const formData = new FormData() formData.append('image', file) + if (stationId) formData.append('station_id', stationId) + if (captureCrop) formData.append('capture_crop', JSON.stringify(captureCrop)) return fetchForm('/api/upload', formData) } From 608d9ba7e294e746e8534fc72cfd7f6b78b0fe6f Mon Sep 17 00:00:00 2001 From: noobydp Date: Sat, 11 Jul 2026 15:05:43 +0800 Subject: [PATCH 2/2] Add photo station save loop --- frontend/src/app/trace/[id]/page.tsx | 469 +++++++++++++++++++++++++-- frontend/src/app/trace/page.tsx | 112 ++++++- frontend/src/lib/api.test.ts | 106 ++++++ frontend/src/lib/api.ts | 38 ++- 4 files changed, 688 insertions(+), 37 deletions(-) create mode 100644 frontend/src/lib/api.test.ts diff --git a/frontend/src/app/trace/[id]/page.tsx b/frontend/src/app/trace/[id]/page.tsx index 30b65ea1..780b34b6 100644 --- a/frontend/src/app/trace/[id]/page.tsx +++ b/frontend/src/app/trace/[id]/page.tsx @@ -1,17 +1,17 @@ 'use client' -import { useState, useEffect, useRef, useCallback } from 'react' -import { useRouter, useParams } from 'next/navigation' +import { useState, useEffect, useRef, useCallback, useMemo } from 'react' +import { useRouter, useParams, useSearchParams } from 'next/navigation' import { useDebouncedSave } from '@/hooks/useDebouncedSave' -import { Loader2, Copy, Upload, Download, Check, ChevronDown, ChevronRight } from 'lucide-react' +import { Loader2, Copy, Upload, Download, Check, ChevronDown, ChevronRight, RotateCcw, Pencil } from 'lucide-react' import { PaperCornerEditor } from '@/components/PaperCornerEditor' import { PolygonEditor } from '@/components/PolygonEditor' import { SessionInfo } from '@/components/SessionInfo' import { Alert } from '@/components/Alert' -import { getSession, setCorners, traceTools, updatePolygons, updateSession, getImageUrl, getAvailableKeys, traceFromMask, saveToolsFromSession } from '@/lib/api' +import { getSession, setCorners, traceTools, updatePolygons, updateSession, getImageUrl, getAvailableKeys, traceFromMask, saveToolsFromSession, listPhotoStations, listPhotoStationSuggestions, reusePhotoStationCorners, redetectCorners } from '@/lib/api' import { CornersHint, TraceHint, EditHint } from '@/components/OnboardingIllustrations' import { StepBar } from '@/components/StepBar' -import type { PaperSize, Point, Polygon, Session } from '@/types' +import type { PaperSize, Point, Polygon, Session, PhotoStation, PhotoStationSuggestion } from '@/types' type Step = 'corners' | 'trace' | 'edit' @@ -34,13 +34,224 @@ const TRACE_STEPS = [ 'Generating silhouette mask...', 'Processing mask...', 'Tracing contours...', - 'Identifying tools...', + 'Finalizing outlines...', ] +const TRACER_PREFERENCE_KEY = 'tracefinity.trace.preferredTracer' + +function getPreferredTracerId(availableTracers: { id: string; label: string }[]): string | null { + if (typeof window === 'undefined') return null + const saved = window.localStorage.getItem(TRACER_PREFERENCE_KEY) + if (!saved) return null + return availableTracers.some((tracer) => tracer.id === saved) ? saved : null +} + +function rememberTracerId(tracerId: string | null | undefined) { + if (!tracerId || typeof window === 'undefined') return + window.localStorage.setItem(TRACER_PREFERENCE_KEY, tracerId) +} + +function paperSizeLabel(size: PaperSize): string { + return PAPER_SIZE_OPTIONS.find((option) => option.value === size)?.label ?? size +} + +type StateSetter = (value: T | ((current: T) => T)) => void + +function usePhotoStations({ + sessionId, + requestedStationId, + hasCaptureStep, + stationWasAppliedOnUpload, + session, + paperSize, + setSession, + setLocalCorners, + setPaperSize, + setError, +}: { + sessionId: string + requestedStationId: string | null + hasCaptureStep: boolean + stationWasAppliedOnUpload: boolean + session: Session | null + paperSize: PaperSize + setSession: StateSetter + setLocalCorners: StateSetter + setPaperSize: StateSetter + setError: StateSetter +}) { + const [photoStations, setPhotoStations] = useState([]) + const [stationSuggestions, setStationSuggestions] = useState([]) + const [photoStationCount, setPhotoStationCount] = useState(0) + const [stationNotice, setStationNotice] = useState(null) + const [stationNoticeTone, setStationNoticeTone] = useState<'success' | 'warning'>('success') + const [saveAsStation, setSaveAsStation] = useState(false) + const [stationName, setStationName] = useState(() => `Station ${new Date().toISOString().slice(0, 10)}`) + const [reusingStationId, setReusingStationId] = useState(null) + const [redetectingCorners, setRedetectingCorners] = useState(false) + const [activePhotoStationId, setActivePhotoStationId] = useState(null) + const autoStationAppliedRef = useRef(false) + + const refreshStationSuggestions = useCallback(async () => { + try { + const [result, stations] = await Promise.all([ + listPhotoStationSuggestions(sessionId), + listPhotoStations().catch(() => photoStations), + ]) + setPhotoStations(stations) + setStationSuggestions(result.suggestions) + setPhotoStationCount(stations.length || result.station_count) + } catch { + setStationSuggestions([]) + } + }, [sessionId, photoStations]) + + useEffect(() => { + let cancelled = false + Promise.all([ + listPhotoStationSuggestions(sessionId).catch(() => ({ suggestions: [], station_count: 0 })), + listPhotoStations().catch(() => []), + ]).then(([stationData, stationList]) => { + if (cancelled) return + setPhotoStations(stationList) + setStationSuggestions(stationData.suggestions) + setPhotoStationCount(stationList.length || stationData.station_count) + }) + return () => { cancelled = true } + }, [sessionId]) + + const handleReuseStation = useCallback(async (stationId: string) => { + setReusingStationId(stationId) + setError(null) + setStationNotice(null) + + try { + const result = await reusePhotoStationCorners(sessionId, stationId) + setLocalCorners(result.corners) + setPaperSize(result.paper_size) + setSession((current) => current ? { + ...current, + corners: result.corners, + paper_size: result.paper_size, + } : current) + setActivePhotoStationId(result.suggestion.station.id) + setStationSuggestions((current) => current.map((suggestion) => + suggestion.station.id === result.suggestion.station.id ? result.suggestion : suggestion + )) + setStationNoticeTone(result.suggestion.warnings.length > 0 ? 'warning' : 'success') + setStationNotice(result.suggestion.warnings.length > 0 ? result.suggestion.warnings.join(' ') : 'Station reused.') + } catch (err) { + setError(err instanceof Error ? err.message : 'failed to reuse station') + } finally { + setReusingStationId(null) + } + }, [sessionId, setError, setLocalCorners, setPaperSize, setSession]) + + const handleStationSelect = useCallback((stationId: string) => { + if (!stationId) { + setActivePhotoStationId(null) + setStationNotice(null) + return + } + const suggestion = stationSuggestions.find((item) => item.station.id === stationId) + if (!suggestion) { + setActivePhotoStationId(null) + setStationNoticeTone('warning') + setStationNotice('Selected station does not match this image.') + return + } + void handleReuseStation(stationId) + }, [handleReuseStation, stationSuggestions]) + + useEffect(() => { + if (!requestedStationId || !hasCaptureStep || !stationWasAppliedOnUpload || !session || stationSuggestions.length === 0 || autoStationAppliedRef.current) return + const suggestion = stationSuggestions.find((item) => item.station.id === requestedStationId) + if (!suggestion) return + + autoStationAppliedRef.current = true + setActivePhotoStationId(suggestion.station.id) + + if (stationWasAppliedOnUpload) { + setStationNoticeTone('success') + setStationNotice('Station reused.') + } + }, [requestedStationId, hasCaptureStep, stationWasAppliedOnUpload, session, stationSuggestions]) + + const handleRedetectCorners = useCallback(async () => { + setRedetectingCorners(true) + setError(null) + setStationNotice(null) + + try { + const result = await redetectCorners(sessionId) + setLocalCorners(result.corners) + setSession((current) => current ? { + ...current, + corners: result.corners, + paper_size: current.paper_size || paperSize, + } : current) + setActivePhotoStationId(null) + setStationNoticeTone('success') + setStationNotice('Corners redetected.') + await refreshStationSuggestions() + } catch (err) { + setError(err instanceof Error ? err.message : 'failed to redetect corners') + } finally { + setRedetectingCorners(false) + } + }, [paperSize, refreshStationSuggestions, sessionId, setError, setLocalCorners, setSession]) + + const registerCreatedStation = useCallback((station: PhotoStation) => { + setActivePhotoStationId(station.id) + setPhotoStations((current) => [station, ...current.filter((item) => item.id !== station.id)]) + setStationSuggestions((current) => [{ + station, + match_status: 'exact', + width_delta_percent: 0, + height_delta_percent: 0, + max_corner_drift_px: 0, + max_corner_drift_percent: 0, + warnings: [], + }, ...current.filter((suggestion) => suggestion.station.id !== station.id)]) + setPhotoStationCount((count) => count + 1) + setStationNoticeTone('success') + setStationNotice('Station saved.') + }, []) + + const stationSuggestionById = useMemo( + () => new Map(stationSuggestions.map((suggestion) => [suggestion.station.id, suggestion])), + [stationSuggestions], + ) + + return { + activePhotoStationId, + handleRedetectCorners, + handleStationSelect, + photoStationCount, + photoStations, + redetectingCorners, + registerCreatedStation, + reusingStationId, + saveAsStation, + setSaveAsStation, + setStationName, + stationName, + stationNotice, + stationNoticeTone, + stationSuggestionById, + } +} + export default function TracePage() { const router = useRouter() const params = useParams() + const searchParams = useSearchParams() const sessionId = params.id as string + const hasCaptureStep = searchParams.get('capture') === '1' + const requestedStationId = searchParams.get('station') + const stationWasAppliedOnUpload = searchParams.get('stationApplied') === '1' + const fromSaveAndNewLoop = searchParams.get('loop') === '1' + const skipToSaveRequested = searchParams.get('skipToSave') === '1' const [session, setSession] = useState(null) const [step, setStep] = useState('corners') @@ -71,9 +282,40 @@ export default function TracePage() { const [traceStatus, setTraceStatus] = useState(null) const [saving, setSaving] = useState(false) const [includedPolygons, setIncludedPolygons] = useState>(new Set()) + const [editingPolygonLabelId, setEditingPolygonLabelId] = useState(null) const [hoveredPolygon, setHoveredPolygon] = useState(null) const maskInputRef = useRef(null) const statusInterval = useRef(null) + const autoSelectedSkipToolsRef = useRef(false) + + const { + activePhotoStationId, + handleRedetectCorners, + handleStationSelect, + photoStationCount, + photoStations, + redetectingCorners, + registerCreatedStation, + reusingStationId, + saveAsStation, + setSaveAsStation, + setStationName, + stationName, + stationNotice, + stationNoticeTone, + stationSuggestionById, + } = usePhotoStations({ + sessionId, + requestedStationId, + hasCaptureStep, + stationWasAppliedOnUpload, + session, + paperSize, + setSession, + setLocalCorners, + setPaperSize, + setError, + }) useEffect(() => { if (!methodOpen) return @@ -95,9 +337,11 @@ export default function TracePage() { setHasEnvKey(keys.google) setProviderLabel(keys.provider_label) setProviderType(keys.provider) - setTracers(keys.tracers || []) - if (keys.tracers?.length) setSelectedTracer(keys.tracers[0].id) - + const availableTracers = keys.tracers || [] + setTracers(availableTracers) + if (availableTracers.length) { + setSelectedTracer(getPreferredTracerId(availableTracers) || availableTracers[0].id) + } if (!keys.google) { setProvider('manual') } @@ -139,6 +383,12 @@ export default function TracePage() { } }, [session, step, correctedImageUrl, sessionId]) + useEffect(() => { + if (!skipToSaveRequested || autoSelectedSkipToolsRef.current || step !== 'edit' || polygons.length === 0) return + autoSelectedSkipToolsRef.current = true + setIncludedPolygons(new Set(polygons.map((poly) => poly.id))) + }, [skipToSaveRequested, step, polygons]) + const singleTracer = tracers.length <= 1 async function handleCornersSubmit() { @@ -148,9 +398,12 @@ export default function TracePage() { setError(null) try { - const result = await setCorners(sessionId, corners, paperSize) + const result = await setCorners(sessionId, corners, paperSize, saveAsStation ? stationName : null) setCorrectedImageUrl(result.corrected_image_url) setImageVersion(Date.now()) + if (result.station) { + registerCreatedStation(result.station) + } if (singleTracer && tracers.length === 1) { // single tracer: trace immediately without changing step @@ -163,6 +416,7 @@ export default function TracePage() { try { const tid = tracers[0].id + rememberTracerId(tid) const traceResult = await traceTools( sessionId, 'google', hasEnvKey ? undefined : apiKey, @@ -198,6 +452,7 @@ export default function TracePage() { setError('please enter your API key') return } + rememberTracerId(tid) setProcessing(true) setError(null) @@ -281,6 +536,12 @@ export default function TracePage() { setPolygons(updated) }, []) + const handlePolygonLabelChange = useCallback((polygonId: string, label: string) => { + setPolygons(current => current.map(poly => ( + poly.id === polygonId ? { ...poly, label } : poly + ))) + }, []) + useDebouncedSave( () => updatePolygons(sessionId, polygons), [polygons, sessionId], @@ -298,13 +559,13 @@ export default function TracePage() { } }, []) - async function handleSaveToLibrary() { + async function handleSaveToLibrary(nextPath: string = '/') { if (includedPolygons.size === 0) return setSaving(true) setError(null) try { await saveToolsFromSession(sessionId, Array.from(includedPolygons)) - router.push('/') + router.push(nextPath) } catch (err) { setError(err instanceof Error ? err.message : 'failed to save tools') } finally { @@ -329,10 +590,15 @@ export default function TracePage() { ) } - const steps = singleTracer ? ['Corners', 'Save'] : ['Corners', 'Trace', 'Save'] - const stepIndex = singleTracer + const traceSteps = singleTracer ? ['Corners', 'Save'] : ['Corners', 'Trace', 'Save'] + const steps = hasCaptureStep ? ['Capture', ...traceSteps] : traceSteps + const traceStepIndex = singleTracer ? (step === 'corners' ? 0 : 1) : (step === 'corners' ? 0 : step === 'trace' ? 1 : 2) + const stepIndex = hasCaptureStep ? traceStepIndex + 1 : traceStepIndex + const saveAndNewPath = activePhotoStationId + ? `/trace?station=${encodeURIComponent(activePhotoStationId)}&loop=1` + : '/trace' return (
@@ -340,8 +606,18 @@ export default function TracePage() { steps={steps} current={stepIndex} onStepClick={(i) => { - if (i === 0) setStep('corners') - else if (!singleTracer && i === 1 && correctedImageUrl) setStep('trace') + if (hasCaptureStep && i === 0) { + const params = new URLSearchParams() + if (activePhotoStationId) { + params.set('station', activePhotoStationId) + } + if (fromSaveAndNewLoop) params.set('loop', '1') + router.push(`/trace?${params.toString()}`) + return + } + const traceIndex = hasCaptureStep ? i - 1 : i + if (traceIndex === 0) setStep('corners') + else if (!singleTracer && traceIndex === 1 && correctedImageUrl) setStep('trace') }} />
@@ -380,6 +656,84 @@ export default function TracePage() { ))}
+ +
+ +
+ +
+

+ Photo Station +

+ + + {reusingStationId && ( +
+ + Reusing station... +
+ )} + + {photoStationCount === 0 && ( +

+ Confirm the first setup, then save it for repeated phone photos. +

+ )} + + {stationNotice && ( +

{stationNotice}

+ )} + + + {saveAsStation && ( +
+ + setStationName(e.target.value)} + className="w-full min-h-11 px-3 mt-1.5 text-sm border border-border-subtle rounded bg-elevated text-text-primary focus:outline-none focus:border-accent" + /> +
+ )} +
)} @@ -407,7 +761,12 @@ export default function TracePage() { return ( + +
+ )} +
{polygons.length > 0 && (
@@ -552,7 +929,7 @@ export default function TracePage() { else next.add(p.id) setIncludedPolygons(next) }} - className={`flex items-center gap-2 px-2 py-1.5 rounded cursor-pointer transition-colors ${ + className={`flex items-center gap-2 px-2 py-1 rounded cursor-pointer transition-colors ${ isIncluded ? 'bg-accent-muted text-accent' : hoveredPolygon === p.id @@ -565,7 +942,37 @@ export default function TracePage() { }`}> {isIncluded && }
- {p.label} + {editingPolygonLabelId === p.id ? ( + e.stopPropagation()} + onFocus={(e) => e.currentTarget.select()} + onBlur={() => setEditingPolygonLabelId(null)} + onKeyDown={(e) => { + if (e.key === 'Enter' || e.key === 'Escape') { + e.currentTarget.blur() + } + }} + onChange={(e) => handlePolygonLabelChange(p.id, e.target.value)} + className="min-w-0 flex-1 bg-transparent border-none outline-none text-xs font-semibold text-text-primary" + /> + ) : ( + {p.label} + )} +
) })} @@ -635,13 +1042,21 @@ export default function TracePage() { {step === 'edit' && ( <> +
+ {canSkipToSave && ( + + )} {previewUrl && existingSessionId && !stream ? ( @@ -433,7 +527,7 @@ function CapturePageContent() { type="button" onClick={() => captureFrame()} disabled={!stream || uploading} - className="btn-primary w-full py-2 text-sm inline-flex items-center justify-center gap-1.5" + className={`${canSkipToSave ? 'btn-secondary' : 'btn-primary'} w-full py-2 text-sm inline-flex items-center justify-center gap-1.5`} > {uploading ? : } {uploading ? 'Uploading...' : 'Capture'} diff --git a/frontend/src/lib/api.test.ts b/frontend/src/lib/api.test.ts new file mode 100644 index 00000000..f2fa8bb1 --- /dev/null +++ b/frontend/src/lib/api.test.ts @@ -0,0 +1,106 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +import { + createPhotoStation, + reusePhotoStationCorners, + setCorners, + uploadImage, +} from './api' +import type { CaptureCrop, Point } from '@/types' + +function jsonResponse(body: unknown = {}) { + return new Response(JSON.stringify(body), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }) +} + +let fetchMock: ReturnType + +beforeEach(() => { + fetchMock = vi.fn().mockResolvedValue(jsonResponse()) + vi.stubGlobal('fetch', fetchMock) +}) + +afterEach(() => { + vi.unstubAllGlobals() +}) + +describe('api photo station requests', () => { + it('sends a station name when saving corners as a station', async () => { + const corners: Point[] = [ + { x: 1, y: 2 }, + { x: 3, y: 4 }, + { x: 5, y: 6 }, + { x: 7, y: 8 }, + ] + + await setCorners('session-1', corners, 'letter', 'Bench station') + + expect(fetchMock).toHaveBeenCalledWith( + 'http://localhost:8000/api/sessions/session-1/corners', + expect.objectContaining({ + method: 'POST', + body: JSON.stringify({ + corners, + paper_size: 'letter', + save_station_name: 'Bench station', + }), + }), + ) + }) + + it('creates photo stations with paper size and corner data', async () => { + const corners: Point[] = [ + { x: 10, y: 20 }, + { x: 30, y: 20 }, + { x: 30, y: 40 }, + { x: 10, y: 40 }, + ] + + await createPhotoStation({ + name: 'Assembly bench', + session_id: 'session-2', + paper_size: 'a3', + corners, + }) + + expect(fetchMock).toHaveBeenCalledWith( + 'http://localhost:8000/api/photo-stations', + expect.objectContaining({ + method: 'POST', + body: JSON.stringify({ + name: 'Assembly bench', + session_id: 'session-2', + paper_size: 'a3', + corners, + }), + }), + ) + }) + + it('posts the selected station id when reusing corners', async () => { + await reusePhotoStationCorners('session-3', 'station-1') + + expect(fetchMock).toHaveBeenCalledWith( + 'http://localhost:8000/api/sessions/session-3/reuse-corners', + expect.objectContaining({ + method: 'POST', + body: JSON.stringify({ station_id: 'station-1' }), + }), + ) + }) + + it('includes station and crop metadata in capture uploads', async () => { + const crop: CaptureCrop = { x: 0.1, y: 0.2, width: 0.7, height: 0.6 } + + await uploadImage(new File(['image'], 'capture.jpg', { type: 'image/jpeg' }), 'station-2', crop) + + const [, request] = fetchMock.mock.calls[0] + expect(request).toMatchObject({ method: 'POST' }) + expect(request.body).toBeInstanceOf(FormData) + const formData = request.body as FormData + expect(formData.get('station_id')).toBe('station-2') + expect(formData.get('capture_crop')).toBe(JSON.stringify(crop)) + }) +}) diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts index 280a345a..54bb7009 100644 --- a/frontend/src/lib/api.ts +++ b/frontend/src/lib/api.ts @@ -2,6 +2,9 @@ import type { UploadResponse, CornersResponse, PhotoStation, + PhotoStationSuggestionsResponse, + RedetectCornersResponse, + ReuseCornersResponse, TraceResponse, GenerateResponse, Point, @@ -79,10 +82,11 @@ export async function setCorners( sessionId: string, corners: Point[], paperSize: PaperSize, + saveStationName?: string | null, ): Promise { return fetchApi(`/api/sessions/${sessionId}/corners`, { method: 'POST', - body: JSON.stringify({ corners, paper_size: paperSize }), + body: JSON.stringify({ corners, paper_size: paperSize, save_station_name: saveStationName ?? null }), }) } @@ -91,6 +95,12 @@ export interface TracerInfo { label: string } +export async function redetectCorners(sessionId: string): Promise { + return fetchApi(`/api/sessions/${sessionId}/redetect-corners`, { + method: 'POST', + }) +} + export async function getAvailableKeys(): Promise<{ google: boolean provider: string | null @@ -315,6 +325,22 @@ export async function getPhotoStation(stationId: string): Promise return fetchApi(`/api/photo-stations/${stationId}`) } +export async function listPhotoStationSuggestions(sessionId: string): Promise { + return fetchApi(`/api/sessions/${sessionId}/station-suggestions`) +} + +export async function createPhotoStation(opts: { + name: string + session_id: string + paper_size?: PaperSize + corners?: Point[] +}): Promise { + return fetchApi('/api/photo-stations', { + method: 'POST', + body: JSON.stringify(opts), + }) +} + export async function updatePhotoStation( stationId: string, updates: { @@ -329,6 +355,16 @@ export async function updatePhotoStation( }) } +export async function reusePhotoStationCorners( + sessionId: string, + stationId: string +): Promise { + return fetchApi(`/api/sessions/${sessionId}/reuse-corners`, { + method: 'POST', + body: JSON.stringify({ station_id: stationId }), + }) +} + export async function deletePhotoStation(stationId: string): Promise { await fetchApi(`/api/photo-stations/${stationId}`, { method: 'DELETE' }) }