feat(clip): crop a clip to a frame drawn on the picture, and give the clip panel the room it needs

An optional crop in clip mode: a Crop toggle puts a frame on the picture (free, 16:9, 9:16 or 1:1;
the whole picture stays the default). The frame is sent in fractions of the box it was drawn in,
with that box's shape; the worker fits the real picture into the box by its display aspect (sample
aspect and rotation aware), drops the part over the bars, and crops first in the filter chain. A
frame covering the whole box is no crop at all, and one off the picture is refused by name.

The crop frame is a ui primitive (CropFrame) shared with the video editor, keyboard-operable.
YouTube: the wide side panel shuts while clipping and comes back after; maximised, the clip panel
docks in the stage. Plex: the picture sits above the clip panel so the frame lies on it.
This commit is contained in:
2026-10-10 12:52:59 +02:00
parent e301632765
commit 520b0afb90
24 changed files with 1260 additions and 153 deletions
+14 -1
View File
@@ -578,6 +578,14 @@ def enqueue_edit(
return _serialize_job(db, job)
class ClipCropIn(BaseModel):
x: float
y: float
w: float
h: float
aspect: float
class EnqueueClipIn(BaseModel):
source: Literal["youtube", "plex"]
# The YouTube video id or the Plex rating key.
@@ -586,6 +594,8 @@ class EnqueueClipIn(BaseModel):
end_s: float
# Plex only: which of the file's audio tracks (0-based, in file order) — the one being heard.
audio_track: int | None = None
# A crop frame (downloads/clip): fractions of the box it was drawn in, and that box's aspect.
crop: ClipCropIn | None = None
display_name: str | None = None
@@ -596,7 +606,10 @@ def enqueue_clip(
"""Queue an ad-hoc clip: the marked range of a YouTube video or a Plex item, cut straight from
the source into a new asset in this user's library (downloads/clip)."""
try:
spec = clipmod.normalize(body.source, body.ref, body.start_s, body.end_s, body.audio_track)
crop = body.crop.model_dump() if body.crop else None
spec = clipmod.normalize(
body.source, body.ref, body.start_s, body.end_s, body.audio_track, crop
)
job = service.enqueue_clip(db, user.id, spec, display_text(body.display_name))
except clipmod.ClipError as e:
status = 404 if e.reason == "not_found" else 422