Skip to content

Commit 902be6b

Browse files
Ccindy0171claude
andcommitted
Delete or restore a marked range of frames at once
POST /api/clips/{id}/frames/delete-range and /frames/restore-range ({start, end}, inclusive); the Cleanup panel offers Delete frames / Restore frames for the marked range with a count of what each would change, and keeps the mark so the other button undoes it. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
1 parent ba85620 commit 902be6b

11 files changed

Lines changed: 137 additions & 6 deletions

File tree

‎docs/api.md‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,10 @@ that object.
3535
`object_id` cannot be combined with `class_id` (relabel with the `PATCH` route below instead).
3636
- `POST /api/clips/{id}/mask` (the brush) takes the same required `object_id` and optional `class_id` and returns
3737
the object id it wrote to as `object_id`.
38+
- `POST /api/clips/{id}/frames/delete-range` and `POST /api/clips/{id}/frames/restore-range` take
39+
`{"start", "end"}` (inclusive) and delete or restore every frame in that range (frames already in that state are
40+
left alone; files are never removed) → 200 with the clip detail; 400 when `start > end` or a frame is outside the
41+
clip; 404 for an unknown clip.
3842
- `POST /api/clips/{id}/propagate` and `POST /api/clips/{id}/clear-range` take an `objects` field: `"all"`
3943
(default) or a non-empty list of object ids. Propagate resolves and validates the objects up front, so an
4044
unknown id is a 404 and a listed object with no mask on `from_frame` is a 400, both before the job starts, not

‎docs/usage.md‎

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -70,7 +70,9 @@ with the mouse wheel. Clicking a lane (or its name) selects that class: its lowe
7070
starts a new object of it, same as the class select — clicking the class already active does nothing. Hovering a
7171
frame lists every class's state on it. The Propagate panel runs **All objects** (every object with a mask on the
7272
current frame; the others are named as skipped) or **Selected** only; lost objects are named one by one. Cleanup's
73-
"Clear masks" acts on the **Active object** (default) or **All objects**. `K`, `G` and "has a mask here" follow the
73+
"Clear masks" acts on the **Active object** (default) or **All objects**. With a range marked, Cleanup also offers
74+
**Delete frames** / **Restore frames** for the whole range (counting how many frames each would change); the mark
75+
stays afterwards, so the other button undoes it at once. `K`, `G` and "has a mask here" follow the
7476
active object's own lane, not its class's.
7577

7678
## Hiding confusing areas (regions)

‎server/tests/test_api_labeling.py‎

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -437,3 +437,16 @@ def test_new_object_accept_with_an_unknown_class_writes_nothing(tmp_path):
437437
assert bad.status_code == 400
438438
after = client.get(f"/api/clips/{cid}").json()
439439
assert after["states"] == before["states"] and after["clip"]["objects"] == before["clip"]["objects"]
440+
441+
442+
def test_frame_range_delete_and_restore_routes(tmp_path):
443+
with _client(tmp_path, FakeEngine()) as client:
444+
cid = _clip(client, tmp_path, n=10)
445+
r = client.post(f"/api/clips/{cid}/frames/delete-range", json={"start": 2, "end": 5})
446+
assert r.status_code == 200 and r.json()["clip"]["deleted_frames"] == [2, 3, 4, 5]
447+
assert r.json()["states"][3] == "deleted"
448+
r = client.post(f"/api/clips/{cid}/frames/restore-range", json={"start": 4, "end": 9})
449+
assert r.json()["clip"]["deleted_frames"] == [2, 3]
450+
assert client.post(f"/api/clips/{cid}/frames/delete-range", json={"start": 5, "end": 2}).status_code == 400
451+
assert client.post(f"/api/clips/{cid}/frames/delete-range", json={"start": 0, "end": 10}).status_code == 400
452+
assert client.post("/api/clips/nope/frames/delete-range", json={"start": 0, "end": 1}).status_code == 404

‎server/tests/test_frames.py‎

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
from conftest import OBJ
33
from vdp.engine.fake import FakeEngine
44
from vdp.labeling.errors import BadRequest
5-
from vdp.labeling.frames import clear_range, delete_frame, prune_from, restore_frame
5+
from vdp.labeling.frames import clear_range, delete_frame, prune_from, restore_frame, set_range_deleted
66
from vdp.labeling.prompts import PromptSpec, accept
77
from vdp.labeling.propagate import run_propagation
88
from vdp.store import NotFound
@@ -29,6 +29,20 @@ def test_prune_from_marks_the_tail(store, make_clip):
2929
assert prune_from(store, clip.id, 3).deleted_frames == [0, 3, 4, 5]
3030

3131

32+
def test_range_delete_and_restore(store, make_clip):
33+
_, clip = make_clip(n_frames=8)
34+
delete_frame(store, clip.id, 1)
35+
assert set_range_deleted(store, clip.id, 2, 4, True).deleted_frames == [1, 2, 3, 4]
36+
assert set_range_deleted(store, clip.id, 0, 3, False).deleted_frames == [4] # already-kept frames are fine
37+
assert store.frame_path(clip.id, 3).is_file()
38+
assert set_range_deleted(store, clip.id, 5, 5, True).deleted_frames == [4, 5]
39+
with pytest.raises(BadRequest, match="after"):
40+
set_range_deleted(store, clip.id, 4, 2, True)
41+
with pytest.raises(BadRequest, match="out of range"):
42+
set_range_deleted(store, clip.id, 6, 8, True)
43+
assert store.read_clip(clip.id).deleted_frames == [4, 5] # a refused request changes nothing
44+
45+
3246
def test_index_validation(store, make_clip):
3347
_, clip = make_clip(n_frames=6)
3448
for call in (

‎server/vdp/api/labeling.py‎

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,11 @@ class RangeBody(BaseModel):
6161
objects: ObjectSelection = "all"
6262

6363

64+
class FrameRangeBody(BaseModel):
65+
start: int = Field(ge=0)
66+
end: int = Field(ge=0)
67+
68+
6469
class RelabelBody(BaseModel):
6570
class_id: int
6671

@@ -138,6 +143,16 @@ def restore_frame(clip_id: str, idx: int, state: State) -> dict:
138143
return clip_detail(state, frame_ops.restore_frame(state.store, clip_id, idx))
139144

140145

146+
@router.post("/frames/delete-range")
147+
def delete_frames(clip_id: str, body: FrameRangeBody, state: State) -> dict:
148+
return clip_detail(state, frame_ops.set_range_deleted(state.store, clip_id, body.start, body.end, True))
149+
150+
151+
@router.post("/frames/restore-range")
152+
def restore_frames(clip_id: str, body: FrameRangeBody, state: State) -> dict:
153+
return clip_detail(state, frame_ops.set_range_deleted(state.store, clip_id, body.start, body.end, False))
154+
155+
141156
@router.post("/prune")
142157
def prune(clip_id: str, body: PruneBody, state: State) -> dict:
143158
return clip_detail(state, frame_ops.prune_from(state.store, clip_id, body.from_))

‎server/vdp/labeling/frames.py‎

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,13 @@ def restore_frame(store: ProjectStore, clip_id: str, idx: int) -> Clip:
3636
return _set_deleted(store, clip_id, range(idx, idx + 1), False)
3737

3838

39+
def set_range_deleted(store: ProjectStore, clip_id: str, start: int, end: int, deleted: bool) -> Clip:
40+
"""Delete (or restore) frames start..end, inclusive. Frames already in that state are left as they are."""
41+
if start > end:
42+
raise BadRequest(f"start {start} is after end {end}")
43+
return _set_deleted(store, clip_id, range(start, end + 1), deleted)
44+
45+
3946
def prune_from(store: ProjectStore, clip_id: str, from_idx: int) -> Clip:
4047
clip = store.read_clip(clip_id)
4148
_check(clip, from_idx)

‎web/src/api/rest.ts‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -224,6 +224,11 @@ export const api = {
224224

225225
deleteFrame: (clipId: string, idx: number) => request<ClipDetail>(`/api/clips/${clipId}/frames/${idx}/delete`, post()),
226226
restoreFrame: (clipId: string, idx: number) => request<ClipDetail>(`/api/clips/${clipId}/frames/${idx}/restore`, post()),
227+
/** delete or restore frames start..end (inclusive); frames already in that state are left as they are */
228+
deleteFrames: (clipId: string, start: number, end: number) =>
229+
request<ClipDetail>(`/api/clips/${clipId}/frames/delete-range`, post({ start, end })),
230+
restoreFrames: (clipId: string, start: number, end: number) =>
231+
request<ClipDetail>(`/api/clips/${clipId}/frames/restore-range`, post({ start, end })),
227232
prune: (clipId: string, from: number) => request<ClipDetail>(`/api/clips/${clipId}/prune`, post({ from })),
228233
clearRange: (clipId: string, start: number, end: number, objects: ObjectSelection = "all") =>
229234
request<ClipDetail>(`/api/clips/${clipId}/clear-range`, post({ start, end, objects })),

‎web/src/components/CleanupPanel.test.tsx‎

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ type Props = ComponentProps<typeof CleanupPanel>;
1111
const props = (over: Partial<Props> = {}): Props => ({
1212
frame: 0, states: S("kmmde"), lanes: [S("kmmde")], scope: "active", activeLabel: "#1 cup", range: [0, 4],
1313
clearBlockedReason: null, lockReason: null, error: null, onScope: vi.fn(), onRange: vi.fn(), onToggleDelete: vi.fn(),
14-
onPrune: vi.fn(), onClearRange: vi.fn(), ...over,
14+
onPrune: vi.fn(), onClearRange: vi.fn(), onDeleteRange: vi.fn(), onRestoreRange: vi.fn(), ...over,
1515
});
1616
const group = () => within(screen.getByRole("group", { name: "Objects to clear" }));
1717

@@ -37,4 +37,23 @@ describe("CleanupPanel", () => {
3737
expect((screen.getByRole("button", { name: "Clear masks in frames 0 to 4" }) as HTMLButtonElement).disabled).toBe(true);
3838
expect(screen.getByText("There is nothing to clear in frames 0 to 4.")).toBeTruthy();
3939
});
40+
it("deletes or restores the whole marked range, counting what would change", () => {
41+
const p = props({ range: [1, 4] }); // states k m m d e: frames 1..4 hold 3 kept frames and 1 deleted
42+
render(<CleanupPanel {...p} />);
43+
expect(screen.getByText("In frames 1 to 4: 3 frames to delete, 1 deleted frame to restore.")).toBeTruthy();
44+
fireEvent.click(screen.getByRole("button", { name: "Delete frames 1 to 4" }));
45+
expect(p.onDeleteRange).toHaveBeenCalledWith(1, 4);
46+
fireEvent.click(screen.getByRole("button", { name: "Restore frames 1 to 4" }));
47+
expect(p.onRestoreRange).toHaveBeenCalledWith(1, 4);
48+
});
49+
it("disables what would change nothing, and hides the range buttons with no range marked", () => {
50+
const { rerender } = render(<CleanupPanel {...props({ range: [1, 2] })} />);
51+
expect((screen.getByRole("button", { name: "Restore frames 1 to 2" }) as HTMLButtonElement).disabled).toBe(true);
52+
rerender(<CleanupPanel {...props({ range: [3, 3] })} />);
53+
expect((screen.getByRole("button", { name: "Delete frame 3" }) as HTMLButtonElement).disabled).toBe(true);
54+
expect((screen.getByRole("button", { name: "Restore frame 3" }) as HTMLButtonElement).disabled).toBe(false);
55+
rerender(<CleanupPanel {...props({ range: null })} />);
56+
expect(screen.queryByRole("button", { name: /^Delete frames? \d/ })).toBeNull();
57+
expect(screen.getByRole("button", { name: "Delete frame" })).toBeTruthy(); // the single-frame toggle stays
58+
});
4059
});

‎web/src/components/CleanupPanel.tsx‎

Lines changed: 29 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -27,11 +27,14 @@ type Props = {
2727
onToggleDelete: () => void;
2828
onPrune: () => void;
2929
onClearRange: (start: number, end: number) => void;
30+
/** delete / restore every frame of the marked range (shown only while a range is marked) */
31+
onDeleteRange: (start: number, end: number) => void;
32+
onRestoreRange: (start: number, end: number) => void;
3033
};
3134

3235
export function CleanupPanel({
3336
frame, states, lanes, scope, activeLabel, onScope, range, clearBlockedReason, lockReason, error, onRange,
34-
onToggleDelete, onPrune, onClearRange,
37+
onToggleDelete, onPrune, onClearRange, onDeleteRange, onRestoreRange,
3538
}: Props) {
3639
const last = Math.max(0, states.length - 1);
3740
const clampIndex = (v: string) => Math.max(0, Math.min(last, Math.floor(Number(v)) || 0));
@@ -53,6 +56,10 @@ export function CleanupPanel({
5356
...(keyframes > 0 ? [plural(keyframes, "keyframe", "keyframes")] : []),
5457
...(deleted > 0 ? [`any mask under the ${plural(deleted, "deleted frame", "deleted frames")}`] : []),
5558
];
59+
// frames of the marked range that Delete / Restore would actually change
60+
const inRange = valid ? states.slice(start, end + 1) : [];
61+
const toRestore = inRange.filter((s) => s === "deleted").length;
62+
const toDelete = inRange.length - toRestore;
5663
const outcome = !valid
5764
? "The first frame must not come after the last."
5865
: parts.length === 0
@@ -71,8 +78,8 @@ export function CleanupPanel({
7178
</summary>
7279
<div className="mt-2 flex flex-col gap-2">
7380
<p className="note">
74-
To clear masks from a stretch of frames, mark it first: hold <kbd>Shift</kbd> and drag along the timeline,
75-
or press <kbd>[</kbd> and <kbd>]</kbd> on its first and last frame.
81+
To clear masks from a stretch of frames, or delete or restore it, mark it first: hold <kbd>Shift</kbd> and
82+
drag along the timeline, or press <kbd>[</kbd> and <kbd>]</kbd> on its first and last frame.
7683
</p>
7784
<div className="flex items-center gap-1">
7885
<input aria-label="Range start" type="number" min={0} max={last} className="field w-16" value={start}
@@ -99,6 +106,25 @@ export function CleanupPanel({
99106
</button>
100107
<p className="note">{lockReason ?? clearBlockedReason ?? outcome}</p>
101108

109+
{range !== null && (
110+
<>
111+
<div className="mt-1 flex gap-1">
112+
<button type="button" className="btn flex-1" disabled={!valid || toDelete === 0 || lockReason !== null}
113+
onClick={() => onDeleteRange(start, end)}>
114+
Delete {where}
115+
</button>
116+
<button type="button" className="btn flex-1" disabled={!valid || toRestore === 0 || lockReason !== null}
117+
onClick={() => onRestoreRange(start, end)}>
118+
Restore {where}
119+
</button>
120+
</div>
121+
<p className="note">
122+
{!valid
123+
? "The first frame must not come after the last."
124+
: `In ${where}: ${plural(toDelete, "frame", "frames")} to delete, ${plural(toRestore, "deleted frame", "deleted frames")} to restore.`}
125+
</p>
126+
</>
127+
)}
102128
<div className="mt-1 flex gap-1">
103129
<button type="button" aria-keyshortcuts="Delete" className="btn flex-1" disabled={lockReason !== null} onClick={onToggleDelete}>
104130
{states[frame] === "deleted" ? "Restore frame" : "Delete frame"} <kbd aria-hidden>Del</kbd>

‎web/src/pages/Label.test.tsx‎

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,8 @@ vi.mock("../api/rest", async (original) => ({
2424
deleteFrame: vi.fn(),
2525
restoreFrame: vi.fn(),
2626
prune: vi.fn(),
27+
deleteFrames: vi.fn(),
28+
restoreFrames: vi.fn(),
2729
clearRange: vi.fn(),
2830
getJob: vi.fn(),
2931
setMask: vi.fn(),
@@ -550,6 +552,19 @@ describe("Label page (propagation, cleanup, keyboard)", () => {
550552
fireEvent.click(screen.getByRole("button", { name: /Clear masks/ }));
551553
await waitFor(() => expect(api.clearRange).toHaveBeenCalledWith("c1", 5, 9, [1]));
552554
});
555+
556+
it("deletes and restores a marked range of frames, keeping the mark", async () => {
557+
vi.mocked(api.getClip).mockResolvedValue(detail);
558+
vi.mocked(api.deleteFrames).mockResolvedValue(detail);
559+
vi.mocked(api.restoreFrames).mockResolvedValue(detail);
560+
renderLabel();
561+
await screen.findByText("bench.mp4");
562+
fireEvent.change(screen.getByLabelText("Range start"), { target: { value: "5" } });
563+
fireEvent.change(screen.getByLabelText("Range end"), { target: { value: "9" } });
564+
fireEvent.click(screen.getByRole("button", { name: "Delete frames 5 to 9" }));
565+
await waitFor(() => expect(api.deleteFrames).toHaveBeenCalledWith("c1", 5, 9));
566+
expect((screen.getByLabelText("Range start") as HTMLInputElement).value).toBe("5"); // still marked
567+
});
553568
});
554569

555570
describe("Label page (brush)", () => {

0 commit comments

Comments
 (0)