Skip to content

Clip deletion, and M8 — AI generation via fal.ai - #34

Merged
davior merged 7 commits into
mainfrom
claude/keen-fermi-2rffhr
Oct 9, 2026
Merged

davior merged 7 commits into
mainfrom
claude/keen-fermi-2rffhr

Conversation

@davior

@davior davior commented Oct 7, 2026 •

Copy link
Copy Markdown
Owner

Clip deletion

Clips couldn't be deleted from anywhere you'd look for them. Fixed:

  • From the parent's Clip tab: each clip row links to the clip's own page and has a delete button with an inline confirm. A newly saved clip appears in the grid straight away.
  • Together with the parent: the delete guard now offers Delete clips too alongside Promote and delete. This uses DELETE /api/assets/{id}?with_clips=true, which removes the clips and the parent in one transaction. A client that leaves out the flag still gets the 409.
  • Data-loss bug fixed: a clip shares its parent's thumb_key, so deleting a clip deleted the parent's poster image, and the poster of every other clip cut from it. Files are now removed only when no surviving row still uses them.
  • Refused deletes explain themselves. A delete is refused with 409 extraction_in_progress while an extraction is running, and the reason now shows on the page that asked. Previously the optimistic store delete unmounted the panel before the refusal arrived, so the message was lost.

M8 — AI generation via fal.ai

Spec and as-built record: docs/m8-ai-generation.md.

  • What it does: text → image, image → image (several base images) and image → video (start frame, plus an end frame where the model takes one), all through fal's queue API.
  • Own worker queue: generation has a separate queue, so a video that takes minutes doesn't hold up transcription.
  • Base images are sent to fal as data URIs after downscaling. Nothing of yours is uploaded to fal's public CDN.
  • Model catalogue: admin-editable, and each entry records the endpoint's exact field names and accepted values. Nine models are seeded, three per kind, each checked against fal's generated endpoint types.
  • Admins are named in ADMIN_USERS, matched exactly. A Notes user id is recommended.
  • Provenance: model, prompt, parameters as sent, base images and seed are stored on the asset. This drives Regenerate, Regenerate with the same seed and Edit and generate.
  • Cost: x-fal-billable-units × the unit price from fal's pricing API, falling back to an estimate that is labelled as one.
  • Robustness:
    • fal's request ids are saved before polling starts, so a restart picks up the same request instead of paying for a second one.
    • A cancel or a timeout also cancels the request at fal.
    • Every request fal billed gets a usage row, even when its outputs fail to download.
    • Downloads are retried, capped and checked against SSRF at every redirect, and only uncompressed bodies are accepted.

Review

An adversarial review (four lenses, each finding independently verified) confirmed 12 findings. A hand-run live smoke test then found a 13th. All are fixed in 59f760c, each with a test that fails without the fix:

  • usage was lost when a download failed after fal had billed;
  • a download was never retried;
  • a result-fetch 5xx was polled for 30 minutes;
  • a restart during ingest duplicated an asset;
  • cancelling a recovered queued job never cancelled it at fal;
  • case-insensitive ADMIN_USERS let a renamed Notes user become admin;
  • a brotli body could exhaust memory despite the size cap;
  • the fal-key host guard had no test;
  • a refused delete was silent;
  • the outputs of a partly failed generation never reached the grid;
  • the audio checkbox showed one value and sent another;
  • plan-of-attack.md was stale;
  • the migration's downgrade failed on any populated library. SQLite ignores PRAGMA foreign_keys inside a transaction. The migration tests couldn't see this: their engine never enforced foreign keys, and they reused a connection an earlier revision had left with them off. They now enforce foreign keys and use a fresh connection, as production does.

Live run on this sandbox:

  • migrated a populated pre-M8 database up, down and up again;
  • ran 18 API checks against uvicorn (clip and parent deletes on disk, catalogue CRUD, key handling, generation error paths);
  • drove the UI in Chromium: the + panel, deleting a clip from the Clip tab, the Generate tab, Settings → Image and video generation. No console errors and no 5xx responses.

Not yet verified against fal

This sandbox cannot reach fal.ai. The first real generation will answer three questions:

  • whether queue results carry the billing header;
  • whether the pricing API accepts an ordinary key;
  • whether every seeded model accepts data-URI base images.

The first two fall back to an estimated cost if not.

Checks (local, at 59f760c)

  • backend pytest -q: 1204 passed
  • frontend: format, lint and build pass; vitest: 377 passed

🤖 Generated with Claude Code

https://claude.ai/code/session_01TPmZjuM4q96DFRfU9cHL33

claude added 7 commits October 7, 2026 12:53
The contract the backend and frontend are built against in parallel:
queue API with persisted request ids for restart safety, a separate
generation worker queue, data-URI bases, a catalogue that describes each
endpoint's parameter dialect, admins named in config, provenance columns
on the asset, and exact cost from fal's billing header.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TPmZjuM4q96DFRfU9cHL33
…t with its clips

A clip copies its parent's thumb_key, a URL import's chapter clips all copy
the site thumbnail, and a promote whose own thumbnail failed keeps the copy.
delete_asset unlinked whatever keys the deleted row held, so deleting one
clip deleted the poster of the parent and every sibling clip, and deleting
a parent took the poster of a promoted child it had just let go. A key is
now unlinked only once no surviving row of that user references it.

DELETE /api/assets/{id}?with_clips=true deletes the parent's live clips and
then the parent in one transaction; without the flag the 409 still stands,
so an older client never cascades by accident. A delete is refused with
409 extraction_in_progress while any row it would remove has a queued or
running extract_subvideo job, which would otherwise finish into a missing
row after ffmpeg had written an unreferenced file. The 409 message now says
"1 clip depends".

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TPmZjuM4q96DFRfU9cHL33
…ete guard

The Clip tab listed a video's clips with no way to remove one, and could
not even open a live clip; a freshly saved clip only reached the grid on
the next reload; and the parent's guard offered only to promote its clips.

- ClipEditor: every row's name links to /a/{id}; a live-clip row gets a
  trash button with an inline "Delete clip / Cancel" confirm (sub-video
  rows own real bytes and get none); a saved clip is prepended to the grid.
- AssetDetail: "Delete clips too" beside "Promote and delete", through a
  new non-optimistic removeWithClips so a failure leaves the guard mounted
  to say why (the remount bug M7 recorded); clip-specific wording in the
  Info-tab confirm, and no pointless clip lookup when deleting a clip.
- library store: removeWithClips, prepend, and remove restoring the
  server's total on failure (it restored the loaded-row count) and only
  counting down a row it actually had.
- docs: the poster bug and its fix in m7-clips-and-subvideos.md; the
  chapter-clip limitation in url-import.md now that the follow-up exists.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TPmZjuM4q96DFRfU9cHL33
Text → image, image → image from up to ten bases, and image → video with an
optional end frame, as specified in docs/m8-ai-generation.md. Results are
ordinary assets with source "ai_generated", carry their provenance, and are
costed from fal's billing header.

- One Alembic revision: seven ai_* columns on asset, usageevent.external_ref,
  the generationmodel catalogue (unique endpoint id) and nine seed rows whose
  field names and allowed values were checked against fal's generated
  endpoint types.
- ADMIN_USERS and an AdminUser dependency; /api/me resolves is_admin.
- Catalogue CRUD at /api/generate/models (admin writes, PATCH by fields
  present so null clears); the fal key at /api/settings/generation.
- A raw-httpx fal queue client (submit/status/result/cancel) with readable
  errors; bases normalised to data URIs; a streaming, size-capped,
  SSRF-checked download that re-checks every redirect hop.
- A second JobQueue for generation over the same table, with a kind filter
  in recover_pending and sweep_stale, routing and cancel by kind.
- The job persists fal's request ids before polling, resumes on restart,
  records created assets per output and usage once, and cancels at fal on a
  user cancel or the deadline.
- Exact cost from x-fal-billable-units × fal's pricing API, falling back to
  the catalogue list price and to units derived from the files; a mixed
  usage total now reports itself as an estimate.
- POST /api/generate and /regenerate; AssetRead.generation; generated video
  is not auto-transcribed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TPmZjuM4q96DFRfU9cHL33
One GenerateForm behind three entry points, built to the API section of
docs/m8-ai-generation.md:

- api/generate.ts: the catalogue and generation types beside generateApi
  (models, createModel, updateModel, deleteModel, start, regenerate).
  Option values stay string | number end to end, because fal's endpoints
  disagree on the type of a duration and the server checks verbatim.
- api/settings.ts gains generationSettingsApi for the fal key; Asset gains
  `generation`, User `is_admin`, ActivityJob `result_asset_ids`.
- stores/generation.ts: the active catalogue, loaded once, staleness-guarded,
  reset on sign-out.
- GenerateForm: kind follows the bases (none → text → image; one or two →
  image → image or image → video with the second as the end frame), models
  filtered to what can take these bases, every option control shown only
  when the row declares it, fal_key_missing as a Settings signpost.
- Entry points: a third section in the add panel; "Generate from these" in
  the selection bar when every pick is an image with a file (bases in pick
  order); a Generate tab leading with provenance, Regenerate, Regenerate
  with the same seed, and Edit and generate (the form, pre-filled).
- LibraryView adds every output of a finished generation to the grid.
- Settings: the fal key, and the catalogue — read-only, or full CRUD for
  admins with lists that round-trip their values' types.
- "AI cost (est.)" and the usage panel's "not a bill" now follow
  `estimated`, since fal's billing header makes an exact cost possible.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TPmZjuM4q96DFRfU9cHL33
The backend and frontend were built against docs/m8-ai-generation.md in
parallel; reading one against the other found three places they disagreed.

- "Regenerate with the same seed" needed fal's echoed seed, but the server's
  reuse_seed also falls back to the seed the request asked for, which is all
  there is for an endpoint like nano-banana-2 that echoes none. The button
  now shows whenever there is a seed the server would send.
- AssetGenerationRead.generated_at is nullable (the ai_* columns are read
  leniently); the frontend typed it as a string and would have dated a
  hand-edited row 1 January 1970.
- The catalogue form claims to mirror the server's invalid_model_entry rules
  and missed one: max_images above 1 needs image_field_is_list.

Comments and test data that said Hailuo takes a numeric duration now say Wan:
the seeded Hailuo row, checked against fal's types, takes "6" and "10".

The spec now records what the build settled or did differently: the stricter
catalogue validation, PATCH nulls, regenerate's full error order and seed
handling, num_images, the usage `estimated` change, and the limitations both
builders left open.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TPmZjuM4q96DFRfU9cHL33
Generation, once fal has billed:
- record the UsageEvent even when saving the outputs fails or is cancelled
  part-way; an errored job is never run again to record it later
- retry a download that fails with a 429, a 5xx or a dropped connection
- fail at once on a result-fetch 5xx that carries fal's request id instead of
  polling it until the deadline and reporting "did not finish"
- a restart inside ingest_file no longer leaves a provenance-less copy beside
  the finished asset: the index being saved is persisted first, and a resumed
  run discards the half-saved one by its bytes
- cancelling a queued generation recovered after a restart cancels it at fal

Security:
- ADMIN_USERS matches usernames exactly; Notes allows usernames differing only
  in case and lets anyone rename, so case-folding made a renamed user an admin
- the download asks for and accepts only an uncompressed body (httpx inflates
  brotli with no limit) and has a whole-file deadline
- test the guard that keeps the fal key on fal's own queue host

The migration's downgrade failed on any populated library: SQLite ignores
PRAGMA foreign_keys inside a transaction, and the usageevent rebuild opens one.
The pragmas now run in an autocommit block. The migration tests could not see
this — their engine never enforced foreign keys and reused a connection an
earlier revision left with them off — so they now do both as production does.

Frontend:
- a refused delete says why on the page that asked; the store's delete waits
  for the server instead of unmounting the panel first
- the library grid adds the outputs a generation saved before it failed
- "Generate audio" sends what the box shows

Docs: plan-of-attack brought up to date for clip deletion and M8.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TPmZjuM4q96DFRfU9cHL33
@davior
davior marked this pull request as ready for review October 9, 2026 14:38
@davior
davior merged commit c25b17a into main Oct 9, 2026
3 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants