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/[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 (