feat: configurable track forward, track to end, skip-and-mark (REQ-189)

Track 5 Frames becomes Track Forward with a 1-100 count input, remembered in
localStorage (default 5). A separate -> End action runs to the last frame
behind a confirm and can be cancelled mid-flight.

Frames that already hold a shape of the tracked class are skipped instead of
re-annotated: hidden and dimmed shapes count as present. Skips are reported in
the result banner and marked with a dot plus a class name on the filmstrip
thumbnail. frames() now returns the class ids each frame holds, the same query
row, no migration.

Chaining and seed growth stay out: every frame is still seeded from the drawn
box only.
This commit is contained in:
asus committed 2026-10-05 13:46:24 +07:00
1 parent f6b0335c93
commit 4f15335092
9 files changed
+226 -52

No files matched your search

+3 -3
View File
@@ -3,8 +3,8 @@
* FastAPI reports failures as {"detail": "..."} — unwrapped here so callers can
* show the message the backend actually wrote instead of "500". */
async function request(path, { method = 'GET', body, form } = {}) {
const options = { method, headers: {} }
async function request(path, { method = 'GET', body, form, signal } = {}) {
const options = { method, headers: {}, signal }
if (form) {
options.body = form
} else if (body !== undefined) {
@@ -135,7 +135,7 @@ export const api = {
method: 'POST',
body: { annotation_ids: annotationIds, class_id: classId },
}),
assist: (frameId, body) => request(`/frames/${frameId}/assist`, { method: 'POST', body }),
assist: (frameId, body, { signal } = {}) => request(`/frames/${frameId}/assist`, { method: 'POST', body, signal }),
// The whole frame-local exemplar pool, re-sent on every drag (REQ-173/174).
exemplarLabel: (frameId, body) =>
request(`/frames/${frameId}/exemplar-label`, { method: 'POST', body }),
+29
View File
@@ -558,6 +558,7 @@ main.page {
.frame-bar {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 10px;
padding: 8px 12px;
background: rgba(17, 24, 39, 0.45);
@@ -628,6 +629,34 @@ main.page {
line-height: 16px;
}
/* Track-forward skipped this frame: it already held the class (REQ-189). */
.thumb .skip-dot {
position: absolute;
left: 3px;
top: 3px;
width: 8px;
height: 8px;
border-radius: 999px;
background: var(--warn);
box-shadow: 0 0 0 2px rgba(0, 0, 0, 0.75);
}
/* Frame count for Track Forward, in the frame bar next to the button. */
.track-count {
display: inline-flex;
align-items: center;
gap: 4px;
font-size: 0.78rem;
color: var(--text-muted);
white-space: nowrap;
}
.track-count input {
width: 64px;
padding: 2px 6px;
font-size: 0.78rem;
}
.review-side {
display: flex;
flex-direction: column;
+17 -13
View File
@@ -1,21 +1,25 @@
import React from 'react'
import { api } from '../api'
export default function Filmstrip({ frames, index, onSelectIndex, stripRef }) {
export default function Filmstrip({ frames, index, onSelectIndex, stripRef, markedIds, markLabel = 'already has it' }) {
return (
<div className="filmstrip" ref={stripRef}>
{frames.map((item, position) => (
<button
key={item.id}
className={`thumb ${item.review_status}`}
aria-current={position === index}
onClick={() => onSelectIndex(position)}
title={`${item.filename} — ${item.review_status}`}
>
<img src={api.frameUrl(item.id, 120)} alt="" loading="lazy" />
{item.annotation_count > 0 && <span className="badge">{item.annotation_count}</span>}
</button>
))}
{frames.map((item, position) => {
const marked = markedIds?.has(item.id)
return (
<button
key={item.id}
className={`thumb ${item.review_status}`}
aria-current={position === index}
onClick={() => onSelectIndex(position)}
title={`${item.filename} — ${item.review_status}${marked ? ` — ${markLabel}` : ''}`}
>
<img src={api.frameUrl(item.id, 120)} alt="" loading="lazy" />
{item.annotation_count > 0 && <span className="badge">{item.annotation_count}</span>}
{marked && <span className="skip-dot" aria-hidden="true" />}
</button>
)
})}
</div>
)
}
+1 -1
View File
@@ -61,7 +61,7 @@ export default function ShortcutsPanel() {
</div>
<div>
<dt><kbd>C</kbd> <kbd>T</kbd></dt>
<dd>Copy prev / Track 5 frames</dd>
<dd>Copy prev / Track forward</dd>
</div>
</dl>
</div>
+92 -16
View File
@@ -26,6 +26,13 @@ export default function ReviewPage({ batchId: rawBatchId, projectId, onProject }
const [hiddenClasses, setHiddenClasses] = useState(() => new Set())
const [hiddenShapeIds, setHiddenShapeIds] = useState(() => new Set())
const [overriddenShapeIds, setOverriddenShapeIds] = useState(() => new Set())
const [trackFrames, setTrackFrames] = useState(() => {
const stored = Number(localStorage.getItem('trackFrames'))
return Number.isFinite(stored) && stored >= 1 ? Math.min(100, Math.round(stored)) : 5
})
const [trackAbort, setTrackAbort] = useState(null)
const [skippedIds, setSkippedIds] = useState(() => new Set())
const [skippedLabel, setSkippedLabel] = useState('')
const batchId = batch?.id || rawBatchId
const stripRef = useRef(null)
@@ -354,11 +361,11 @@ export default function ReviewPage({ batchId: rawBatchId, projectId, onProject }
}
}, [frames, index, frame, hiddenClasses])
const trackForward = useCallback(async () => {
const trackForward = useCallback(async (toEnd = false) => {
if (busy) return
if (!frames?.length) return
if (selectedId == null) {
setError('Select a shape first — Track 5 Frames follows one shape forward.')
setError('Select a shape first — Track Forward follows one shape forward.')
return
}
const current = annotations.find((a) => a.id === selectedId)
@@ -384,37 +391,71 @@ export default function ReviewPage({ batchId: rawBatchId, projectId, onProject }
box = [minX, minY, maxX, maxY]
}
if (!box) {
setError('That shape has no usable box — Track 5 Frames needs one.')
setError('That shape has no usable box — Track Forward needs one.')
return
}
const ahead = frames.slice(index + 1, index + 6)
const count = Number(trackFrames) || 5
const ahead = toEnd ? frames.slice(index + 1) : frames.slice(index + 1, index + 1 + count)
if (!ahead.length) {
setError('Last frame — Track 5 Frames has no frames ahead to follow into.')
setError('Last frame — nothing ahead to follow into.')
return
}
if (toEnd && !window.confirm(`Track ${ahead.length} frames to the end of the batch? That is one GPU call per frame.`)) {
return
}
const className = (project?.classes ?? []).find((item) => item.class_id === current.class_id)?.name || 'that class'
const controller = new AbortController()
setTrackAbort(controller)
setBusy(true)
setError('')
setSkippedIds(new Set())
setSkippedLabel('')
try {
let tracked = 0
let skipped = 0
const failures = []
const marked = new Set()
for (let i = 0; i < ahead.length; i++) {
const targetFrame = ahead[i]
// A frame that already holds this class is left alone — hidden or
// dimmed shapes count. It is reported, never silently passed over.
if ((targetFrame.class_ids ?? []).includes(current.class_id)) {
skipped++
marked.add(targetFrame.id)
continue
}
try {
await api.assist(targetFrame.id, { box, class_id: current.class_id })
await api.assist(targetFrame.id, { box, class_id: current.class_id }, { signal: controller.signal })
tracked++
patchFrameLocally(targetFrame.id, { annotation_count: (targetFrame.annotation_count ?? 0) + 1 })
patchFrameLocally(targetFrame.id, {
annotation_count: (targetFrame.annotation_count ?? 0) + 1,
// Keeps the skip check honest for a second run in this session.
class_ids: (targetFrame.class_ids ?? []).includes(current.class_id)
? targetFrame.class_ids
: [...(targetFrame.class_ids ?? []), current.class_id],
})
} catch (exc) {
if (controller.signal.aborted) {
failures.push(`cancelled — frame ${index + i + 2} may still be saved by the server`)
break
}
failures.push(`frame ${index + i + 2}: ${exc.message}`)
}
}
if (failures.length) {
setError(`Tracked ${tracked} of ${ahead.length} — ${failures.join(' · ')}`)
if (marked.size) {
setSkippedIds(marked)
setSkippedLabel(className)
}
const parts = [`Tracked ${tracked} of ${ahead.length}`]
if (skipped) parts.push(`skipped ${skipped} already had ${className}`)
if (failures.length) parts.push(failures.join(' · '))
if (tracked === 0 || skipped || failures.length) setError(parts.join(' — '))
} finally {
setTrackAbort(null)
setBusy(false)
}
}, [annotations, selectedId, index, frames, busy])
}, [annotations, selectedId, index, frames, busy, trackFrames, project])
const stateRef = useRef({})
stateRef.current = { frames, index, project, selectedId, setStatus, removeSelected, reclass, jumpToPending, jumpToNextAnnotated, setAssistMode, copyPrevious, trackForward, mode, setMode, markedIds, removeMarked, reclassMarked, setMarkedIds, toggleHideSelected }
@@ -451,7 +492,7 @@ export default function ReviewPage({ batchId: rawBatchId, projectId, onProject }
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 === 't' || key === 'T') trackForward?.(false)
else if (key === 'h' || key === 'H') toggleHideSelected?.()
else if (key === 'Delete' || key === 'Backspace') removeSelected?.()
else if (/^[1-9]$/.test(key)) {
@@ -685,18 +726,49 @@ export default function ReviewPage({ batchId: rawBatchId, projectId, onProject }
>
📋 Copy Prev [C]
</button>
<label className="track-count" htmlFor="track-count-input">
Track
<input
id="track-count-input" type="number" min="1" max="100" step="1"
value={trackFrames}
onChange={(e) => setTrackFrames(e.target.value)}
onBlur={() => {
// Clamp and persist here, not in an effect: an effect would
// save the half-typed value on every keystroke.
const n = Number(trackFrames)
const next = Number.isFinite(n) && n >= 1 ? Math.min(100, Math.round(n)) : 5
setTrackFrames(next)
localStorage.setItem('trackFrames', String(next))
}}
/>
frames
</label>
<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}
onClick={() => trackForward(false)}
disabled={selectedId == null || index >= frames.length - 1}
title="SAM3 auto-track selected shape for next 5 frames [T]"
title={`SAM3 auto-track selected shape forward ${trackFrames} frames [T]`}
>
🚀 Track 5 Frames [T]
🚀 Track Forward [T]
</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(true)}
disabled={selectedId == null || index >= frames.length - 1}
title="SAM3 auto-track selected shape forward to the end of the batch"
>
→ End
</button>
<span className="spacer" />
{busy && <span className="muted">asking SAM3…</span>}
{busy && (
trackAbort
? <button type="button" className="btn" onClick={() => trackAbort.abort()}>Cancel</button>
: <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>
@@ -705,7 +777,11 @@ export default function ReviewPage({ batchId: rawBatchId, projectId, onProject }
<QuickReclassBar classesList={classesList} reclass={reclass} removeSelected={removeSelected} />
)}
<Filmstrip frames={frames} index={index} onSelectIndex={setIndex} stripRef={stripRef} />
<Filmstrip
frames={frames} index={index} onSelectIndex={setIndex} stripRef={stripRef}
markedIds={skippedIds}
markLabel={`already has ${skippedLabel}`}
/>
</div>
<ReviewSidebar