Files
reTraining/frontend/src/pages/ReviewPage.jsx
T
asus 9c95a4a759 fix: unchain track 5 frames so one bad read cannot poison the rest
The chained attempt (1530bbc) fed each run's result into the next frame's
seed, so a wrong read on one frame carried into the ones after it. Runs are
independent again: every frame is seeded from the drawn shape, frames 2-5
with it grown x1.5 about its centre for motion tolerance. REQ-189, ui-spec
and tasks.md updated to the independent contract.
2026-10-05 12:35:17 +07:00

773 lines
33 KiB
React

import { useCallback, useEffect, useRef, useState } from 'react'
import { api } from '../api'
import { navigate } from '../App'
import AnnotationCanvas from '../components/AnnotationCanvas'
import Filmstrip from '../components/Filmstrip'
import { AlertIcon, CheckIcon, XIcon } from '../components/Icons'
import QuickReclassBar from '../components/QuickReclassBar'
import ReviewSidebar from '../components/ReviewSidebar'
import ExemplarFilterPanel from '../components/ExemplarFilterPanel'
import useExemplarPool from '../hooks/useExemplarPool'
// How much bigger the seed box gets for each frame after the first, so the
// object has room to have moved. Hand-tuned, not learned (REQ-189). The box
// always derives from the drawn shape — never from a previous run's result.
const SEED_GROWTH = 1.5
// SAM3 assist wants one normalized [x0, y0, x1, y1] box (REQ-043). A bbox
// shape already is that; a polygon is reduced to its bounding box.
function shapeBox(geometry) {
const pts = geometry?.points
if (geometry?.type === 'bbox' && pts?.length === 4) return pts
if (geometry?.type === 'polygon' && pts?.length) {
let minX = 1, minY = 1, maxX = 0, maxY = 0
for (const [x, y] of pts) {
if (x < minX) minX = x
if (x > maxX) maxX = x
if (y < minY) minY = y
if (y > maxY) maxY = y
}
return [minX, minY, maxX, maxY]
}
return null
}
function growBox([x0, y0, x1, y1], k) {
const cx = (x0 + x1) / 2
const cy = (y0 + y1) / 2
const hw = ((x1 - x0) * k) / 2
const hh = ((y1 - y0) * k) / 2
const clamp = (v) => Math.min(1, Math.max(0, v))
return [clamp(cx - hw), clamp(cy - hh), clamp(cx + hw), clamp(cy + hh)]
}
export default function ReviewPage({ batchId: rawBatchId, projectId, onProject }) {
const [batch, setBatch] = useState(null)
const [project, setProject] = useState(null)
const [frames, setFrames] = useState([])
const [index, setIndex] = useState(0)
const [annotations, setAnnotations] = useState([])
const [selectedId, setSelectedId] = useState(null)
const [mode, setMode] = useState('draw')
const [markedIds, setMarkedIds] = useState([])
const [activeClass, setActiveClass] = useState(0)
const [assistMode, setAssistMode] = useState(false)
const [busy, setBusy] = useState(false)
const [error, setError] = useState('')
const [activeJob, setActiveJob] = useState(null)
const [hiddenClasses, setHiddenClasses] = useState(() => new Set())
const [hiddenShapeIds, setHiddenShapeIds] = useState(() => new Set())
const [overriddenShapeIds, setOverriddenShapeIds] = useState(() => new Set())
const batchId = batch?.id || rawBatchId
const stripRef = useRef(null)
const hasInitialAutoJump = useRef(false)
const frame = frames[index]
const reload = useCallback(async () => {
let targetBatchId = rawBatchId
if (!targetBatchId && projectId) {
const bList = await api.listBatches(projectId)
if (bList?.batches?.length > 0) targetBatchId = bList.batches[0].id
}
if (!targetBatchId) throw new Error('No batch found to review')
const loaded = await api.getBatch(targetBatchId)
setBatch(loaded)
const payload = await api.listFrames(targetBatchId)
setFrames(payload.frames)
if (!hasInitialAutoJump.current && payload.frames?.length > 0) {
hasInitialAutoJump.current = true
const firstAnnotated = payload.frames.findIndex((f) => (f.annotation_count ?? 0) > 0)
if (firstAnnotated > 0) setIndex(firstAnnotated)
}
const jobsPayload = await api.listJobs(loaded.project_id).catch(() => ({ jobs: [] }))
const currentJob = jobsPayload.jobs?.find(
(j) => j.batch_id === Number(targetBatchId) && ['queued', 'running'].includes(j.status)
)
setActiveJob(currentJob || null)
return loaded
}, [rawBatchId, projectId])
useEffect(() => {
reload().then((loaded) => api.getProject(loaded.project_id)).then((loadedProject) => {
setProject(loadedProject)
onProject?.(loadedProject)
}).catch((exc) => setError(exc.message))
}, [rawBatchId, projectId, reload])
const prevJobId = useRef(activeJob?.id)
useEffect(() => {
if (prevJobId.current && !activeJob) {
hasInitialAutoJump.current = false
reload().catch(() => {})
}
prevJobId.current = activeJob?.id
if (!activeJob) return
const timer = setInterval(() => {
reload().catch(() => {})
if (frame) api.frameAnnotations(frame.id).then((p) => setAnnotations(p.annotations)).catch(() => {})
}, 2000)
return () => clearInterval(timer)
}, [activeJob, reload, frame?.id])
useEffect(() => {
if (!frame) return
let cancelled = false
api.frameAnnotations(frame.id).then((payload) => !cancelled && setAnnotations(payload.annotations)).catch((exc) => !cancelled && setError(exc.message))
setSelectedId(null)
setMarkedIds([])
return () => { cancelled = true }
}, [frame?.id])
useEffect(() => {
const active = stripRef.current?.querySelector('[aria-current="true"]')
active?.scrollIntoView({ block: 'nearest', inline: 'center' })
}, [index])
function patchFrameLocally(frameId, patch) {
setFrames((rows) => rows.map((row) => (row.id === frameId ? { ...row, ...patch } : row)))
}
const setStatus = useCallback(async (status) => {
if (!frame) return
patchFrameLocally(frame.id, { review_status: status })
try {
await api.setFrameStatus(frame.id, status)
setBatch(await api.getBatch(batchId))
} catch (exc) { setError(exc.message) }
setIndex((current) => Math.min(current + 1, frames.length - 1))
}, [frame, frames.length, batchId])
async function createShape(geometry) {
try {
const created = await api.addAnnotation(frame.id, { class_id: activeClass, geometry })
setAnnotations((rows) => [...rows, created])
setSelectedId(created.id)
if (hiddenClasses.has(created.class_id)) setOverriddenShapeIds((current) => new Set(current).add(created.id))
patchFrameLocally(frame.id, { annotation_count: (frame.annotation_count ?? 0) + 1 })
} catch (exc) { setError(exc.message) }
}
// The exemplar pool (REQ-173/174/175) owns the drag gesture in draw mode.
const onExemplarApplied = useCallback((rows) => {
setAnnotations(rows)
setSelectedId(null)
if (frame) patchFrameLocally(frame.id, { annotation_count: rows.length })
}, [frame])
const pool = useExemplarPool({
frameId: frame?.id, classId: activeClass,
onApplied: onExemplarApplied, onError: setError, onBusy: setBusy,
})
async function assist(box) {
setBusy(true); setError('')
try {
const created = await api.assist(frame.id, { box, class_id: activeClass })
setAnnotations((rows) => [...rows, created])
setSelectedId(created.id)
if (hiddenClasses.has(created.class_id)) setOverriddenShapeIds((current) => new Set(current).add(created.id))
} catch (exc) { setError(exc.message) } finally { setBusy(false) }
}
async function updateShape(id, geometry, { local, commit } = {}) {
if (local && geometry) {
setAnnotations((rows) => rows.map((row) => (row.id === id ? { ...row, geometry } : row)))
return
}
if (!commit) return
const previous = annotations.find((row) => row.id === id)
if (!previous) return
// A commit may carry its own geometry (delete-vertex sends the shortened
// polygon); falling back to the row's geometry covers drag/resize, which
// already wrote through the {local:true} path.
const next = geometry ?? previous.geometry
setAnnotations((rows) => rows.map((row) => (row.id === id ? { ...row, geometry: next } : row)))
try {
await api.patchAnnotation(id, { geometry: next })
} catch (exc) {
setAnnotations((rows) => rows.map((row) => (row.id === id ? previous : row)))
setError(exc.message)
}
}
const removeAnnotation = useCallback(async (id) => {
if (id == null || !frame) return
const previous = annotations.find((row) => row.id === id)
if (!previous) return
setAnnotations((rows) => rows.filter((row) => row.id !== id))
setSelectedId((current) => (current === id ? null : current))
setFrames((rows) => rows.map((row) => (row.id === frame.id ? { ...row, annotation_count: Math.max(0, (row.annotation_count ?? 1) - 1) } : row)))
try {
await api.deleteAnnotation(id)
} catch (exc) {
setAnnotations((rows) => [...rows, previous])
setFrames((rows) => rows.map((row) => (row.id === frame.id ? { ...row, annotation_count: (row.annotation_count ?? 0) + 1 } : row)))
setError(exc.message)
}
}, [annotations, frame])
const removeSelected = useCallback(() => removeAnnotation(selectedId), [removeAnnotation, selectedId])
const onMarquee = useCallback((ids, { add = false, toggle = false } = {}) => {
setMarkedIds((current) => {
if (toggle) {
const id = ids[0]
return current.includes(id) ? current.filter((row) => row !== id) : [...current, id]
}
if (!add) return ids
return [...new Set([...current, ...ids])]
})
}, [])
const toggleHide = useCallback((classId) => {
setHiddenClasses((current) => {
const next = new Set(current)
if (next.has(classId)) next.delete(classId)
else next.add(classId)
return next
})
// REQ-185: toggling a class drops its per-shape overrides — re-hiding a
// class starts from a clean slate.
const classShapeIds = annotations.filter((row) => row.class_id === classId).map((row) => row.id)
setOverriddenShapeIds((current) => {
if (!classShapeIds.some((id) => current.has(id))) return current
const next = new Set(current)
classShapeIds.forEach((id) => next.delete(id))
return next
})
}, [annotations])
// REQ-185: the eye first un-hides an H-hidden shape, otherwise it overrides
// the class hide — one rule, in one place.
function restoreShape(id) {
if (hiddenShapeIds.has(id)) {
setHiddenShapeIds((current) => {
const next = new Set(current)
next.delete(id)
return next
})
} else {
setOverriddenShapeIds((current) => (current.has(id) ? current : new Set(current).add(id)))
}
}
// REQ-185: neither the shape's own hide nor its class's hide (without an
// override) may reach the canvas. Session-only, never persisted.
const isShapeVisible = (row) =>
!hiddenShapeIds.has(row.id) &&
(!hiddenClasses.has(row.class_id) || overriddenShapeIds.has(row.id))
const visibleAnnotations = annotations.filter(isShapeVisible)
// REQ-185: H toggles hide on the single selection and every marked shape.
function toggleHideSelected() {
const ids = [...new Set([selectedId, ...markedIds].filter((id) => id != null))]
const rows = ids.map((id) => annotations.find((row) => row.id === id)).filter(Boolean)
if (!rows.length) return
const toHide = rows.filter((row) => isShapeVisible(row)).map((row) => row.id)
const toUnhide = rows.filter((row) => !isShapeVisible(row)).map((row) => row.id)
if (toHide.length) {
setHiddenShapeIds((current) => {
const next = new Set(current)
toHide.forEach((id) => next.add(id))
return next
})
// Flags must not contradict: hiding drops any class-hide override.
setOverriddenShapeIds((current) => {
if (!toHide.some((id) => current.has(id))) return current
const next = new Set(current)
toHide.forEach((id) => next.delete(id))
return next
})
}
if (toUnhide.length) {
setHiddenShapeIds((current) => {
if (!toUnhide.some((id) => current.has(id))) return current
const next = new Set(current)
toUnhide.forEach((id) => next.delete(id))
return next
})
}
}
const markAll = useCallback(() => setMarkedIds(visibleAnnotations.map((row) => row.id)), [visibleAnnotations])
const removeMarked = useCallback(async () => {
if (!markedIds.length || !frame) return
if (!window.confirm(`Delete ${markedIds.length} selected shape(s) on this frame?`)) return
const previous = annotations
const doomed = new Set(markedIds)
setAnnotations((rows) => rows.filter((row) => !doomed.has(row.id)))
setMarkedIds([])
patchFrameLocally(frame.id, {
annotation_count: Math.max(0, (frame.annotation_count ?? doomed.size) - doomed.size),
})
try {
await api.bulkDeleteAnnotations([...doomed])
setBatch(await api.getBatch(batchId))
} catch (exc) {
setAnnotations(previous)
patchFrameLocally(frame.id, { annotation_count: previous.length })
setError(exc.message)
}
}, [markedIds, annotations, frame, batchId])
const reclassMarked = useCallback(async (classId) => {
if (!markedIds.length) return
const previous = annotations
const targets = new Set(markedIds)
setAnnotations((rows) => rows.map((row) => (targets.has(row.id) ? { ...row, class_id: classId } : row)))
try {
await api.bulkReclassAnnotations([...targets], classId)
} catch (exc) {
setAnnotations(previous)
setError(exc.message)
}
}, [markedIds, annotations])
const reclass = useCallback(async (classId) => {
setActiveClass(classId)
if (selectedId == null) return
try {
const updated = await api.patchAnnotation(selectedId, { class_id: classId })
setAnnotations((rows) => rows.map((row) => (row.id === updated.id ? updated : row)))
} catch (exc) { setError(exc.message) }
}, [selectedId])
// Review signs frames off; it no longer merges. Data Prep is the gate that
// turns a selection of batches into a dataset (REQ-131).
function toDataPrep() {
navigate(`/projects/${batch.project_id}/data-prep?batches=${batchId}`)
}
async function jumpToPending() {
try {
const { frame_id: frameId } = await api.nextPending(batchId, frame?.idx ?? -1)
const position = frames.findIndex((row) => row.id === frameId)
if (position >= 0) setIndex(position)
} catch (exc) { setError(exc.message) }
}
const jumpToNextAnnotated = useCallback(() => {
if (!frames?.length) return
const nextIdx = frames.findIndex((f, idx) => idx > index && (f.annotation_count ?? 0) > 0)
if (nextIdx >= 0) setIndex(nextIdx)
else {
const firstIdx = frames.findIndex((f) => (f.annotation_count ?? 0) > 0)
if (firstIdx >= 0) setIndex(firstIdx)
}
}, [frames, index])
const copyPrevious = useCallback(async () => {
if (!frames?.length || index === 0 || !frame) return
const prevFrame = frames[index - 1]
if (!prevFrame || (prevFrame.annotation_count ?? 0) === 0) return
try {
const payload = await api.frameAnnotations(prevFrame.id)
if (!payload.annotations?.length) return
const created = await Promise.all(
payload.annotations.map(a => api.addAnnotation(frame.id, { class_id: a.class_id, geometry: a.geometry }))
)
setAnnotations(rows => [...rows, ...created])
patchFrameLocally(frame.id, { annotation_count: (frame.annotation_count ?? 0) + created.length })
if (created.length > 0) setSelectedId(created[0].id)
const overridden = created.filter((row) => hiddenClasses.has(row.class_id)).map((row) => row.id)
if (overridden.length) setOverriddenShapeIds((current) => {
const next = new Set(current)
overridden.forEach((id) => next.add(id))
return next
})
} catch (exc) {
setError(exc.message)
}
}, [frames, index, frame, hiddenClasses])
const trackForward = useCallback(async () => {
if (busy) return
if (!frames?.length) return
if (selectedId == null) {
setError('Select a shape first — Track 5 Frames follows one shape forward.')
return
}
const current = annotations.find((a) => a.id === selectedId)
if (!current) {
setError('That shape is gone — select another one.')
return
}
const firstBox = shapeBox(current.geometry)
if (!firstBox) {
setError('That shape has no usable box — Track 5 Frames needs one.')
return
}
const ahead = frames.slice(index + 1, index + 6)
if (!ahead.length) {
setError('Last frame — Track 5 Frames has no frames ahead to follow into.')
return
}
setBusy(true)
setError('')
try {
let tracked = 0
const failures = []
for (let i = 0; i < ahead.length; i++) {
const targetFrame = ahead[i]
// Every frame is seeded from the drawn box, never from a previous
// result, so a bad read on one frame cannot carry into the next.
// Frames 2-5 get it grown about its centre — the object has had
// time to move by then.
const seedBox = i === 0 ? firstBox : growBox(firstBox, SEED_GROWTH)
try {
await api.assist(targetFrame.id, {
box: seedBox, class_id: current.class_id,
})
tracked++
patchFrameLocally(targetFrame.id, { annotation_count: (targetFrame.annotation_count ?? 0) + 1 })
} catch (exc) {
failures.push(`frame ${index + i + 2}: ${exc.message}`)
}
}
if (failures.length) {
setError(`Tracked ${tracked} of ${ahead.length} — ${failures.join(' · ')}`)
}
} finally {
setBusy(false)
}
}, [annotations, selectedId, index, frames, busy])
const stateRef = useRef({})
stateRef.current = { frames, index, project, selectedId, setStatus, removeSelected, reclass, jumpToPending, jumpToNextAnnotated, setAssistMode, copyPrevious, trackForward, mode, setMode, markedIds, removeMarked, reclassMarked, setMarkedIds, toggleHideSelected }
useEffect(() => {
function onKeyDown(event) {
if (event.target?.matches?.('input, textarea, select, [contenteditable="true"]')) return
// Ctrl/Cmd/Alt combos belong to the browser and the OS: without this,
// Ctrl+S approves the frame and Ctrl+A/C/X/N/T all fire review actions.
if (event.ctrlKey || event.metaKey || event.altKey) return
const { frames, project, setStatus, removeSelected, reclass, jumpToPending, jumpToNextAnnotated, setAssistMode, copyPrevious, trackForward, mode, setMode, markedIds, removeMarked, reclassMarked, setMarkedIds, toggleHideSelected } = stateRef.current
const key = event.key
const isShortcutKey = /^[1-9]$/.test(key) || ['ArrowLeft', 'ArrowRight', 'ArrowUp', 'ArrowDown', 'Delete', 'Backspace', 'a', 'A', 'x', 'X', 'u', 'U', 's', 'S', 'n', 'N', 'c', 'C', 't', 'T', 'v', 'V', 'h', 'H', 'Escape'].includes(key)
if (isShortcutKey) { event.preventDefault(); event.stopPropagation() }
// In select mode the marquee owns Delete and the digits, otherwise a
// 40-box selection would be thrown away by one keystroke meant for it.
if (mode === 'select') {
if (key === 'v' || key === 'V') { setMode?.('draw'); setMarkedIds?.([]); return }
if (key === 'Escape') { setMarkedIds?.([]); return }
if ((key === 'Delete' || key === 'Backspace') && markedIds?.length) { removeMarked?.(); return }
if (/^[1-9]$/.test(key) && markedIds?.length) {
const classId = Number(key) - 1
if (project && classId < project.classes.length) reclassMarked?.(classId)
return
}
} else if (key === 'v' || key === 'V') { setMode?.('select'); return }
if (key === 's' || key === 'S') setAssistMode?.(true)
else if (key === 'ArrowLeft') setIndex((i) => Math.max(0, i - 1))
else if (key === 'ArrowRight') setIndex((i) => Math.min((frames?.length || 1) - 1, i + 1))
else if (key === 'a' || key === 'A') setStatus?.('approved')
else if (key === 'x' || key === 'X') setStatus?.('rejected')
else if (key === 'u' || key === 'U') jumpToPending?.()
else if (key === 'n' || key === 'N') jumpToNextAnnotated?.()
else if (key === 'c' || key === 'C') copyPrevious?.()
else if (key === 't' || key === 'T') trackForward?.()
else if (key === 'h' || key === 'H') toggleHideSelected?.()
else if (key === 'Delete' || key === 'Backspace') removeSelected?.()
else if (/^[1-9]$/.test(key)) {
const classId = Number(key) - 1
if (project && classId < project.classes.length) reclass?.(classId)
}
}
function onKeyUp(event) {
if (event.target?.matches?.('input, textarea, select, [contenteditable="true"]')) return
if (event.key === 's' || event.key === 'S') { event.preventDefault(); event.stopPropagation(); stateRef.current.setAssistMode?.(false) }
}
document.addEventListener('keydown', onKeyDown, true)
document.addEventListener('keyup', onKeyUp, true)
return () => {
document.removeEventListener('keydown', onKeyDown, true)
document.removeEventListener('keyup', onKeyUp, true)
}
}, [])
if (error && !batch) return <p className="error-banner" style={{ margin: 20 }}><AlertIcon size={14} /> {error}</p>
if (!batch || !project) return <p className="empty" style={{ margin: 20, color: 'var(--text-muted)' }}>Loading batch data…</p>
const reviewed = (batch.review?.approved ?? 0) + (batch.review?.rejected ?? 0)
const classesList = project.classes ?? []
const activeClassName =
classesList.find((item) => item.class_id === activeClass)?.name ?? 'this class'
async function approveAllFrames() {
if (!window.confirm(`Mark all ${batch.review?.pending ?? 0} pending frames as approved?`)) return
try { await api.approveAllBatchFrames(batchId); await reload() } catch (exc) { setError(exc.message) }
}
async function clearClassInBatch(item) {
if (!window.confirm(`Clear all shapes of class "${item.name}" across ALL frames in this batch?`)) return
try {
await api.clearBatchClassAnnotations(batchId, item.class_id)
await reload()
if (frame) {
const payload = await api.frameAnnotations(frame.id)
setAnnotations(payload.annotations)
}
} catch (exc) { setError(exc.message) }
}
async function clearClassInFrame(item) {
const doomed = annotations.filter((row) => row.class_id === item.class_id)
if (!doomed.length || !frame) return
const ids = new Set(doomed.map((row) => row.id))
const previous = annotations
const previousCount = frame.annotation_count ?? 0
setAnnotations((rows) => rows.filter((row) => !ids.has(row.id)))
setMarkedIds((rows) => rows.filter((id) => !ids.has(id)))
patchFrameLocally(frame.id, { annotation_count: Math.max(0, previousCount - doomed.length) })
try {
await api.bulkDeleteAnnotations(doomed.map((row) => row.id))
setBatch(await api.getBatch(batchId))
} catch (exc) {
setAnnotations(previous)
setMarkedIds((rows) => [...rows, ...doomed.filter((row) => !rows.includes(row.id)).map((row) => row.id)])
patchFrameLocally(frame.id, { annotation_count: previousCount })
setError(exc.message)
}
}
return (
<>
<div className="page-head">
<div>
<h1>{batch.date_label} · {batch.batch_label}</h1>
<p className="muted">
{batch.frame_count} frames · {reviewed}/{batch.frame_count} reviewed ·
{' '}<strong style={{ color: batch.annotation_count > 0 ? '#c084fc' : '#a1a1aa' }}>{batch.annotation_count} shapes</strong> · status {batch.status}
</p>
</div>
<span className="spacer" />
{(batch.review?.pending ?? 0) > 0 && batch.status !== 'merged' && (
<button className="btn" style={{ marginRight: 8 }} title="Mark all pending frames in this batch as approved" onClick={approveAllFrames}>
Approve All Frames ({batch.review?.pending})
</button>
)}
<button
className="btn btn-primary"
disabled={(batch.review?.pending ?? 0) > 0 || batch.status === 'merged'}
title={(batch.review?.pending ?? 0) > 0 ? `${batch.review?.pending} frame(s) still pending` : 'Tune the filter for this batch, then merge it into a dataset'}
onClick={toDataPrep}
>
{batch.status === 'merged' ? 'Merged' : 'Prepare & merge'}
</button>
</div>
{activeJob && (
<div className="panel side-panel" style={{ marginBottom: 12, border: '1px solid rgba(168, 85, 247, 0.4)', background: 'rgba(24, 24, 27, 0.8)' }}>
<div className="row" style={{ fontSize: '0.85rem' }}>
<span className="dot ok" />
<strong style={{ textTransform: 'capitalize' }}>Auto-labeling in progress…</strong>
<span className="spacer" />
<span className="mono">{activeJob.progress}/{activeJob.total || '—'} frames</span>
</div>
<div className="progress" style={{ margin: '6px 0' }}>
<span style={{ width: `${activeJob.total ? (activeJob.progress / activeJob.total) * 100 : 50}%` }} />
</div>
</div>
)}
{error && <p className="error-banner" style={{ marginBottom: 12 }}><AlertIcon size={14} /> {error}</p>}
<div className="review">
<div className="review-main">
{frame && (
<AnnotationCanvas
frame={frame}
imageUrl={api.frameUrl(frame.id)}
annotations={visibleAnnotations}
selectedId={selectedId}
activeClass={activeClass}
assistMode={assistMode}
classes={classesList}
mode={mode}
selectedIds={markedIds}
exemplars={pool.exemplars}
preview={pool.preview?.shapes ?? null}
onSelect={setSelectedId}
onCreate={createShape}
onUpdate={updateShape}
onAssist={assist}
onExemplar={pool.add}
onMarquee={onMarquee}
/>
)}
<div className="frame-bar" style={{ gap: 8 }}>
<div className="mode-switch" role="group" aria-label="Canvas mode">
{[['draw', 'Draw'], ['select', 'Select']].map(([value, label]) => (
<button
key={value}
type="button"
className={`btn${mode === value ? ' btn-primary' : ''}`}
aria-pressed={mode === value}
style={{ padding: '2px 10px', fontSize: '0.78rem' }}
onClick={() => { setMode(value); setMarkedIds([]) }}
title={value === 'draw'
? 'Drag on empty canvas to draw a new box [V to switch]'
: 'Drag to select every box you touch, then delete or reclass them at once [V to switch]'}
>
{label}
</button>
))}
</div>
{mode === 'select' && (
<>
<button
type="button"
className="btn"
style={{ padding: '2px 8px', fontSize: '0.78rem' }}
onClick={markAll}
disabled={!visibleAnnotations.length}
>
Select all ({visibleAnnotations.length})
</button>
<span className="muted" style={{ fontSize: '0.78rem' }}>
{markedIds.length
? `${markedIds.length} selected · Shift-drag adds · Esc clears`
: 'Drag across the boxes you want'}
</span>
</>
)}
{mode === 'draw' && (
<span className="muted" style={{ fontSize: '0.78rem' }}>
{pool.exemplars.length
? `${pool.exemplars.filter((e) => e.positive).length} example(s), `
+ `${pool.exemplars.filter((e) => !e.positive).length} negative — `
+ 'tune the filters, then Apply'
: 'Drag or double-click an example · Shift-drag/click = not this'}
</span>
)}
</div>
{mode === 'select' && markedIds.length > 0 && (
<div className="frame-bar" style={{ gap: 8, borderColor: 'rgba(56, 189, 248, 0.4)' }}>
<strong style={{ fontSize: '0.82rem', color: '#38bdf8' }}>
{markedIds.length} shape{markedIds.length === 1 ? '' : 's'}
</strong>
<span className="muted" style={{ fontSize: '0.78rem' }}>reclass to</span>
{classesList.map((item) => (
<button
key={item.class_id}
type="button"
className="btn"
style={{ padding: '2px 8px', fontSize: '0.78rem' }}
onClick={() => reclassMarked(item.class_id)}
title={`Move all ${markedIds.length} into "${item.name}" [${item.class_id + 1}]`}
>
[{item.class_id + 1}] {item.name}
</button>
))}
<span className="spacer" />
<button type="button" className="btn" style={{ padding: '2px 8px', fontSize: '0.78rem' }} onClick={() => setMarkedIds([])}>
Clear [Esc]
</button>
<button type="button" className="btn btn-danger" style={{ padding: '2px 8px', fontSize: '0.78rem' }} onClick={removeMarked}>
<XIcon size={13} /> Delete {markedIds.length} [Del]
</button>
</div>
)}
<div className="frame-bar">
<button className="btn" onClick={() => setIndex((i) => Math.max(0, i - 1))} disabled={index === 0}>←</button>
<span className="mono">
{index + 1} / {frames.length}
{frame && <span className={`status-pill ${frame.review_status}`}>{frame.review_status}</span>}
</span>
<button className="btn" onClick={() => setIndex((i) => Math.min(frames.length - 1, i + 1))} disabled={index >= frames.length - 1}>→</button>
<button
type="button"
className="btn"
style={{ padding: '2px 8px', fontSize: '0.78rem', borderColor: 'rgba(168, 85, 247, 0.5)', color: '#c084fc', background: 'rgba(168, 85, 247, 0.1)' }}
onClick={jumpToNextAnnotated}
title="Jump to next frame with annotations [N]"
>
🏷️ Next Shape [N]
</button>
<button
type="button"
className="btn"
style={{ padding: '2px 8px', fontSize: '0.78rem', borderColor: 'rgba(168, 85, 247, 0.5)', color: '#c084fc', background: 'rgba(168, 85, 247, 0.1)' }}
onClick={copyPrevious}
disabled={index === 0 || !(frames?.[index - 1]?.annotation_count > 0)}
title="Copy all annotations from the previous frame [C]"
>
📋 Copy Prev [C]
</button>
<button
type="button"
className="btn"
style={{ padding: '2px 8px', fontSize: '0.78rem', borderColor: 'rgba(56, 189, 248, 0.5)', color: '#38bdf8', background: 'rgba(56, 189, 248, 0.1)' }}
onClick={trackForward}
disabled={selectedId == null || index >= frames.length - 1}
title="SAM3 auto-track selected shape for next 5 frames [T]"
>
🚀 Track 5 Frames [T]
</button>
<span className="spacer" />
{busy && <span className="muted">asking SAM3…</span>}
<button className="btn btn-danger" onClick={() => setStatus('rejected')}><XIcon size={14} /> Reject [X]</button>
<button className="btn btn-primary" onClick={() => setStatus('approved')}><CheckIcon size={14} /> Approve [A]</button>
</div>
{selectedId != null && (
<QuickReclassBar classesList={classesList} reclass={reclass} removeSelected={removeSelected} />
)}
<Filmstrip frames={frames} index={index} onSelectIndex={setIndex} stripRef={stripRef} />
</div>
<ReviewSidebar
exemplarPanel={mode === 'draw' && pool.active && (
<ExemplarFilterPanel
filters={pool.filters}
preview={pool.preview}
busy={busy}
className={activeClassName}
negatives={pool.exemplars.filter((item) => !item.positive).length}
replacing={annotations.filter((row) => row.class_id === activeClass).length}
onFilter={pool.setFilter}
onReset={pool.resetFilters}
onApply={pool.apply}
onDiscard={pool.discard}
onUndo={pool.undo}
/>
)}
classesList={classesList}
activeClass={activeClass}
reclass={reclass}
clearClassInBatch={clearClassInBatch}
clearClassInFrame={clearClassInFrame}
hiddenClasses={hiddenClasses}
onToggleHide={toggleHide}
hiddenShapeIds={hiddenShapeIds}
overriddenShapeIds={overriddenShapeIds}
onRestoreShape={restoreShape}
annotations={annotations}
selectedId={selectedId}
setSelectedId={setSelectedId}
removeAnnotation={removeAnnotation}
project={project}
jumpToNextAnnotated={jumpToNextAnnotated}
batchAnnotationCount={batch?.annotation_count ?? 0}
/>
</div>
</>
)
}