feat(clips): M7 — clips, sub-video extraction, delete guard - #26
Merged
Merged
Conversation
… 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
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
M7. A non-destructive clip is an Asset row with no
storage_keyof its own — a window into its parent's bytes, bounded byin_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 namedmedia/{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 aenrichment/<kind>.pyorchestration file with a sibling mechanics-only file (transcribe.py→audio.py), not amedia/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
source("clip"/"sub_video"), not a newasset_type.asset_typestays the parent's real media type, so the existing player, type filters andASSET_TYPESvalidation 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;sourceis the frontend-facing label built on top, written in the same two places that touchstorage_keyso it can't drift.parent_asset_id→asset.id,batch_op.create_foreign_key, not justadd_column) so SQLite itself is the backstop behind the guard — the same belt-and-suspenders role it already plays forassettag/suggestion. No prior migration here added an FK to an already-existing table; verified against a real SQLitePRAGMA foreign_key_list, not just the model.enrichment/source.py::gather()gained a clip guard M6 never anticipated. A clip inherits its parent'sthumb_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.GET /api/activity/{kind}/{job_id}, newly exposed — the activity list's cap and the store's cappedjobsarray are both real limits when waiting on several specific jobs).promotealready 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
promote_clipalmost keptin_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.stores/library.ts'sremove()optimistically drops the asset from the store before the API call resolves;AssetViewrendersnullwhile the asset is briefly missing, unmountingAssetDetailand 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:confirmDeletenow checks for dependent clips before callingremove()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
PATHin this sandbox at first — installed it for real (apt-get install --no-install-recommends ffmpeg) rather than trusting theneeds_ffmpegskip 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
parent_asset_id/in_point/out_pointtoassetwith a real FK and has a testeddowngrade().alembic upgrade headdoesn'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