0041 — Discard a bad generation (studio), with an undo window¶
Status: APPROVED — all open questions resolved with Matt 2026-07-25; ready to
implement. From Matt's 2026-07-25 note while reviewing generations:
"the generated images and VOs are great, but on occasion it generates a dreadful
one. I want an icon on the corner of all the generated previews to discard
generations that are just plain wrong/bad — guarded against accidental deletion,
with a 15-second countdown notification to undo. When discarded it should remove
the generated media file both locally and from object storage."
Date: 2026-07-25
Relates: 0013 (the generate/poll/takes/pick engine this decorates),
0028 §3.1 (MediaJob — the take gallery), 0039 (media object store),
0040 (content-addressed blobs + version ledger + media gc), 0031
(spend ledger — a discarded take's spend is not refunded, see D6).
1. Problem¶
Generation is a taste loop: generate N takes → audition → promote one. The
gallery keeps every candidate take forever, which is right for the ones worth
comparing — but a plainly bad generation (a garbled VO, an illustration with
mangled text or the wrong subject) is pure noise. It clutters the filmstrip, it
gets pushed to the object store on every Commit & push, and it makes the contact
sheet harder to scan.
Today the only way to remove one is to hand-delete files from the workspace, which the studio never surfaces and which leaves the manifest/ledger to catch up.
2. Goals¶
- A discard control on every generated preview — the card/cover/portrait take thumbnails and the VO take rows.
- Guarded against accidental loss — a misclick must not destroy a take.
- A 15-second countdown notification with undo, so a wrong discard is trivially recoverable.
- On confirm, the media is removed locally and from object storage.
3. Design¶
3.1 The control¶
A small ✕ discard button in the corner of each generated preview tile
(MediaGen's image gallery + audio take rows), visible on hover/focus and
always visible on touch. It is deliberately not the primary affordance — the
tile's main click still picks the take; discard is the corner action.
Never offered for the promoted take. A take that is currently in use is protected: promote something else first. (Rationale: discarding the in-use take would leave the card/VO slot dangling mid-edit; the guard is simpler than the repair.)
3.2 Guarding + the undo window (D1 — resolved: optimistic + undo)¶
Two candidate guards were considered:
- Confirm-first — a modal "are you sure?" before anything happens.
- Optimistic + undo — the take disappears immediately, a toast counts down from 15 s, and the deletion is only committed when the timer expires.
Chosen: optimistic + undo (it is what Matt asked for, and it is the better interaction): no dialog interrupts the review flow, a misclick is one click to recover, and the destructive step is deferred rather than merely confirmed. The "guard against accidental deletion" requirement is met by the undo window plus the in-use protection (§3.1), not by a modal.
The toast (bottom-right, above the action bar): "Discarded 01-3.png —
Undo (15)", the number ticking down each second, with an explicit Undo
button. Dismissing the toast early commits the discard (it does not cancel
it). Multiple discards stack as separate toasts, each with its own timer.
Nothing is deleted during the window. The take file is moved aside to
.cache/discarded/<rel> (already-gitignored ephemera) — so undo is a rename
back, and no bytes are lost even if the studio crashes mid-window. The commit
step removes it from .cache/discarded/.
3.3 What "remove from object storage" means (D2 — the content-addressed subtlety)¶
Under 0040, blobs are content-addressed and shared: the same bytes are stored
once, so a blob may be referenced by another reel, and the version ledger
(media.log) may name it as a rollback target. A direct Delete of the take's
blob could therefore break another workspace or a rollback — exactly what
media gc's safety rules exist to prevent.
So discard is defined as dereference, then reclaim:
- Remove the take file from the workspace (local).
- Drop its entry from
media.lock(and, for a candidate take that was never promoted, it was never in the manifest to begin with). - Leave the blob in the store; it is now potentially unreferenced.
keryx media gc --prunereclaims it — with the whole-root + ledger checks that guarantee no other reel or rollback target is harmed.
This satisfies "remove from object storage" safely. The studio surfaces it
honestly: the toast's completion message reads "Discarded — run media gc to
reclaim the storage", and (D3) the studio offers a Reclaim storage action
in Settings → Storage that runs the same sweep.
Rejected alternative: immediate blob deletion on discard. It reintroduces exactly the hazards 0040 §GC solved (shared blobs, ledger rollback targets) for no user-visible benefit — the bytes are reclaimed either way, just safely.
3.4 Backend surface¶
A new endpoint pair on the existing media routes:
POST /api/v1/workspace/{slug}/{base}/takes/{take}/discard → move aside, 202
POST /api/v1/workspace/{slug}/{base}/takes/{take}/restore → undo (rename back)
DELETE /api/v1/workspace/{slug}/{base}/takes/{take} → commit (drop from .cache/discarded)
{base} is the existing media base (cards/3, cover, vo/2, portrait), so
one implementation serves every generated media kind. The server owns the
timer only in the sense that it does nothing — the countdown lives in the
browser; a window that never completes (tab closed) simply leaves the take in
.cache/discarded/, which the next reel prune or a --discarded sweep clears.
That is deliberate: no server-side scheduled deletion to reason about, and the
fail-safe direction is keep the bytes. reel prune sweeps
.cache/discarded/ (Q4) — the same verb that already clears candidate takes and
.cache/, so there is one obvious place to "clear the junk".
3.5 Frontend¶
MediaGen.sveltegains the corner control per tile/row (guarded bymedia.current !== path).ContactSheet.sveltegains the same control on its whole-board grid (Q3) — the surface where a dud illustration is spotted at a glance. Both call the sameMediaJobmethods, so the behaviour and the undo toast are identical.MediaJobgainsdiscard(path)/restore(path)/commitDiscard(path)and optimistically removes the take fromtakesso the tile vanishes at once.- A small
UndoToast.svelte(new, reusable): message, live countdown, Undo button; emitsonundo/oncommit. Stackable; timers cleared on unmount (committing on unmount, matching "dismiss = commit").
4. Decisions¶
- D1 — Optimistic + 15 s undo (fixed), not a confirm modal (§3.2).
- D2 — Dereference locally; reclaim via
media gc, never a direct blob delete (§3.3) — required by content-addressed sharing. - D3 — Studio "Reclaim storage" action on Settings → Storage, running the
same sweep as
media gc --prune, so the reclaim is reachable without the CLI. - D4 — Discarded takes are moved to
.cache/discarded/, not deleted, for the duration of the window: crash-safe undo, and nothing is destroyed by a closed tab. - D5 — The promoted take can't be discarded — promote another first.
- D7 — The control ships on BOTH the Scenes take galleries and the Contact sheet (Q3): the sheet is where a dud is spotted at a glance.
- D8 —
.cache/discarded/is swept byreel prune(Q4) — never automatically behind the user's back, so a crash-recovery undo survives. - D9 — Bulk discard is out of scope, a fast-follow (Q2).
- D6 — Spend is not refunded. A discarded take's cost stays in the 0031 ledger: the money was spent, and the ledger is an audit record, not a basket.
5. Resolved (2026-07-25, with Matt)¶
- Q1 — undo window is FIXED at 15 seconds. Not configurable: one less config key to allowlist, document and test. 15 s (rather than the 10 s first sketched) gives a beat longer to catch a misclick without the toast outstaying its welcome.
- Q2 — bulk discard is a FAST-FOLLOW, not part of 0041. Ship single-discard, live with the loop, then add "discard all unpicked takes for this card" if the tedium is real. Keeps the undo semantics simple here (one toast per take).
- Q3 — the control appears on BOTH the Scenes-tab take galleries AND the Contact sheet in v1. The contact sheet is where a dud illustration is most visible at a glance, so it is arguably the prime spot to bin one — shipping without it would put the feature somewhere other than where it gets used.
- Q4 —
.cache/discarded/is swept byreel prune, the existing disposable-cleanup verb (it already clears*/takes/and.cache/). No new lifecycle to reason about, and nothing is deleted behind the user's back — a crash-recovery undo survives until they explicitly prune.
6. Test plan¶
- Unit (Go): discard moves the file aside and leaves the blob; restore
returns it byte-identical; commit removes it from
.cache/discarded/; the promoted take is refused (409); an unknown take is a 404. - Unit (frontend): the tile vanishes optimistically; the toast counts down;
Undo restores the tile and calls
restore; letting it lapse calls the commit endpoint; dismissing early commits; unmount commits pending toasts. - e2e (godog): discard → restore round-trips a take through the studio API.
- Manual/headless: the control renders on card, cover and VO tiles, is absent on the promoted one, and the countdown is legible.