Skip to content

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

  1. A discard control on every generated preview — the card/cover/portrait take thumbnails and the VO take rows.
  2. Guarded against accidental loss — a misclick must not destroy a take.
  3. A 15-second countdown notification with undo, so a wrong discard is trivially recoverable.
  4. 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:

  1. Remove the take file from the workspace (local).
  2. Drop its entry from media.lock (and, for a candidate take that was never promoted, it was never in the manifest to begin with).
  3. Leave the blob in the store; it is now potentially unreferenced.
  4. keryx media gc --prune reclaims 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.svelte gains the corner control per tile/row (guarded by media.current !== path).
  • ContactSheet.svelte gains the same control on its whole-board grid (Q3) — the surface where a dud illustration is spotted at a glance. Both call the same MediaJob methods, so the behaviour and the undo toast are identical.
  • MediaJob gains discard(path) / restore(path) / commitDiscard(path) and optimistically removes the take from takes so the tile vanishes at once.
  • A small UndoToast.svelte (new, reusable): message, live countdown, Undo button; emits onundo / 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 by reel 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 by reel 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.