Skip to content

feat(clips): M7 — clips, sub-video extraction, delete guard - #26

Merged
davior merged 3 commits into
mainfrom
claude/relaxed-ride-20boyt
Sep 18, 2026
Merged

davior merged 3 commits into
mainfrom
claude/relaxed-ride-20boyt

Conversation

@davior

@davior davior commented Sep 18, 2026

Copy link
Copy Markdown
Owner

M7. A non-destructive clip is an Asset row with no storage_key of its own — a window into its parent's bytes, bounded by in_point/out_point, zero storage and zero processing. A sub-video is a real ffmpeg cut, either extracted fresh into a standalone asset or promoted in place from an existing clip. Deleting an asset with live clips is blocked (409, names them); promoting each one clears the block.

Full design writeup, including two bugs found and fixed along the way, is in docs/m7-clips-and-subvideos.md.

Where this diverges from the plan doc

docs/plan-of-attack.md's sketch named media/{clips,extract,ranged}.py. Neither held up: services/assets.py's own docstring already claimed clip/sub-video lifecycle ("an extracted sub-video (M7) ... land[s] here"), and every existing ffmpeg-touching job is a enrichment/<kind>.py orchestration file with a sibling mechanics-only file (transcribe.py → audio.py), not a media/ file — media/ holds only Range-response streaming. This PR follows the established pattern (enrichment/subvideo.py + enrichment/extract_subvideo.py) instead of the stale sketch.

Design

  • A clip is a source ("clip"/"sub_video"), not a new asset_type. asset_type stays the parent's real media type, so the existing player, type filters and ASSET_TYPES validation needed zero changes. The physical fact a clip is identified by — storage_key IS NULL AND parent_asset_id IS NOT NULL — is what the delete guard actually checks; source is the frontend-facing label built on top, written in the same two places that touch storage_key so it can't drift.
  • The migration adds a real foreign key (parent_asset_id → asset.id, batch_op.create_foreign_key, not just add_column) so SQLite itself is the backstop behind the guard — the same belt-and-suspenders role it already plays for assettag/suggestion. No prior migration here added an FK to an already-existing table; verified against a real SQLite PRAGMA foreign_key_list, not just the model.
  • enrichment/source.py::gather() gained a clip guard M6 never anticipated. A clip inherits its parent's thumb_key, so without this, describe/summarize/autotag would silently "succeed" on a clip via the poster-fallback branch — describing a generic frame instead of ever refusing, which is worse than a clean 400.
  • Two independent flows, matching the acceptance test: fresh extraction creates a brand-new standalone asset only once the job succeeds (parent genuinely untouched); promote updates the existing clip row in place. No aggregate "promote all" job — the frontend fires one promote per dependent clip and polls each by id (GET /api/activity/{kind}/{job_id}, newly exposed — the activity list's cap and the store's capped jobs array are both real limits when waiting on several specific jobs).
  • Scope cut, argued over and kept: clipping a clip is refused in both directions. Extracting a sub-video from a clip isn't technically hard, but promote already covers "turn this clip into a real file" — a second path to the same end state isn't worth a third coordinate-flattening code path.

Two bugs, found before they shipped

  1. promote_clip almost kept in_point/out_point "for provenance." Wrong — they're coordinates into the parent's timeline, and the freshly extracted file has its own starting at 0. Left in place, the player's bounding logic would seek a 9-second standalone file to its old absolute second 61 the moment it was promoted. Cleared instead.
  2. The delete-guard UI's own state was getting wiped by a remount. stores/library.ts's remove() optimistically drops the asset from the store before the API call resolves; AssetView renders null while the asset is briefly missing, unmounting AssetDetail and losing all local state, then remounts fresh once the store restores it on failure. Three new frontend tests failed against this before it was diagnosed. Fixed architecturally: confirmDelete now checks for dependent clips before calling remove() at all, so the guarded path never triggers that cycle — the backend's own guard stays the authoritative check either way.

Verification

829 backend, 264 frontend, clean tsc --noEmit/eslint --max-warnings 0/vite build.

ffmpeg was not on PATH in this sandbox at first — installed it for real (apt-get install --no-install-recommends ffmpeg) rather than trusting the needs_ffmpeg skip marker, which caught two test bugs a skipped run would have hidden entirely (fixture assumptions that only held with ffprobe unavailable). The stream-copy/re-encode fallback is verified against two synthetic fixtures with different keyframe intervals, not just the checked-in sample video, so the trigger condition is deterministic.

Notes

  • The migration adds parent_asset_id/in_point/out_point to asset with a real FK and has a tested downgrade(). alembic upgrade head doesn't run outside Docker — needs running by hand after pulling this.
  • docs/plan-of-attack.md's status table isn't updated with this PR's own number yet (couldn't know it in advance); a small follow-up commit will do that once this lands, matching how M6's table entries were filled in. The M5 "carried by the user" note is updated in this PR with the user's report that embedding is confirmed operating well in production — phrased conservatively, since that's a different claim from having run the specific Giordano/Schwab acceptance queries.

🤖 Generated with Claude Code

https://claude.ai/code/session_01KzCKjg6yBtAwMFwZmezuw1


Generated by Claude Code

… guard

Non-destructive clips are Asset rows with no storage_key of their own —
a window into the parent's bytes, bounded by in_point/out_point, zero
storage and zero processing. Sub-video extraction is a real ffmpeg cut,
run as a job: a fast stream-copy path with an automatic re-encode
fallback when the requested range doesn't land on a keyframe closely
enough. Deleting an asset with live clips is now blocked with a 409
naming them, and promoting each one in place clears the block.

Backend: parent_asset_id/in_point/out_point on Asset (with a real
foreign key, so SQLite itself enforces the guard); KIND_EXTRACT_SUBVIDEO
dispatched through the existing job queue; a clip guard in
enrichment/source.py so describe/summarize/autotag/generate_all refuse
a clip instead of silently describing a generic poster frame; a
result_asset_id on finished jobs so the frontend can learn what a fresh
extraction created.

Frontend: a Clip tab (in/out point editor, save-as-clip, extract-as-
subvideo), playback bounded to a clip's range, AI enrichment actions
hidden for a clip, and a promote-then-retry flow on the delete guard.

Verified against real ffmpeg (installed for this session rather than
relying on the test suite's skip marker), which caught two test bugs
a skipped run would have hidden. 829 backend tests, 264 frontend tests,
clean typecheck/lint/build.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KzCKjg6yBtAwMFwZmezuw1
Follow-up to e2479f1 — the status table couldn't carry this PR's own
number until GitHub assigned it.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KzCKjg6yBtAwMFwZmezuw1
format:check failed on #26's first CI run — I'd run tsc/
eslint/vitest/build locally but not npm run format:check itself.
No logic changes, prettier --write only.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KzCKjg6yBtAwMFwZmezuw1
@davior
davior marked this pull request as ready for review September 18, 2026 05:54
@davior
davior merged commit 87adf4e into main Sep 18, 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