From 520b0afb906510358fb515021ee00ecd8ca2c110 Mon Sep 17 00:00:00 2001 From: npeter83 Date: Sat, 10 Oct 2026 12:52:59 +0200 Subject: [PATCH] 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. --- backend/app/downloads/clip.py | 128 ++++++++++- backend/app/downloads/pipeline.py | 16 ++ backend/app/routes/downloads.py | 15 +- backend/tests/test_clip.py | 132 +++++++++++ docs/DESIGN.md | 16 +- .../src/components/ClipPanel.dom.test.tsx | 44 +++- frontend/src/components/ClipPanel.tsx | 126 ++++++++++- .../src/components/PlayerModal.dom.test.tsx | 86 +++++++- frontend/src/components/PlayerModal.tsx | 128 ++++++++--- .../PlexPlayer.controls.dom.test.tsx | 40 ++++ frontend/src/components/PlexPlayer.tsx | 63 +++++- frontend/src/components/VideoEditor.tsx | 96 +------- .../src/components/ui/CropFrame.dom.test.tsx | 113 ++++++++++ frontend/src/components/ui/CropFrame.tsx | 207 ++++++++++++++++++ frontend/src/components/ui/README.md | 14 +- frontend/src/i18n/backendIds.contract.test.ts | 2 +- frontend/src/i18n/locales/en/clip.json | 5 + frontend/src/i18n/locales/en/common.json | 7 + frontend/src/i18n/locales/hu/clip.json | 5 + frontend/src/i18n/locales/hu/common.json | 7 + frontend/src/index.css | 3 + frontend/src/lib/api/downloads.ts | 2 + frontend/src/lib/cropGeometry.test.ts | 74 ++++++- frontend/src/lib/cropGeometry.ts | 84 +++++++ 24 files changed, 1260 insertions(+), 153 deletions(-) create mode 100644 frontend/src/components/ui/CropFrame.dom.test.tsx create mode 100644 frontend/src/components/ui/CropFrame.tsx diff --git a/backend/app/downloads/clip.py b/backend/app/downloads/clip.py index e0340059..19bfa12f 100644 --- a/backend/app/downloads/clip.py +++ b/backend/app/downloads/clip.py @@ -7,6 +7,11 @@ range then goes through ONE re-encode built here, so every clip has the same sha source: frame-exact at both ends, H.264 at most 1080p (an HDR source tonemapped to SDR — kept as 10-bit PQ it played washed out), stereo AAC, in an .mp4 that plays on any phone. +A clip may also crop the picture to a frame the user drew over the playing video. The frame is +given in fractions of the box it was drawn in, with that box's aspect: the YouTube iframe never +tells its picture's size, so the frame there is drawn over the player's 16:9 box (a short's 9:16), +and the cut maps it onto the real picture (`crop_filter`) — what lies over a letterbox bar is dropped. + The spec is stored on the job (`DownloadJob.edit_spec`) and is the clip's whole identity: its signature is the asset's cache key, so marking the same range again is an instant cache hit, and a different range or audio track is a different clip. @@ -43,6 +48,14 @@ _TONEMAP = ( "zscale=t=bt709:m=bt709:r=tv,format=yuv420p" ) _SCALE = f"scale=-2:'min({MAX_HEIGHT},ih)'" +# A crop frame is at least this share of its box on each side (the player's frame holds the same +# floor), and what is left of it on the real picture at least _MIN_VISIBLE of it. +MIN_CROP = 0.1 +_MIN_VISIBLE = 0.05 +# The box aspects a frame may be drawn in: a 9:16 short to a 21:9 film, with room either side. +_CROP_ASPECTS = (0.25, 4.0) +# Float slack on the frame's far edges (x + w, y + h), which the client rounds to 4 decimals. +_EDGE_SLACK = 1e-3 class ClipError(Exception): @@ -54,10 +67,15 @@ class ClipError(Exception): def normalize( - source: str, ref: str, start_s: float, end_s: float, audio_track: int | None = None + source: str, + ref: str, + start_s: float, + end_s: float, + audio_track: int | None = None, + crop: dict | None = None, ) -> dict: - """The validated, canonical clip spec. Times are rounded to the millisecond so the same marks - always give the same cache key. Raises ClipError.""" + """The validated, canonical clip spec. Times are rounded to the millisecond and a crop to four + decimals, so the same marks always give the same cache key. Raises ClipError.""" if source not in SOURCES: raise ClipError("bad_source") pattern = _YOUTUBE_ID if source == "youtube" else _RATING_KEY @@ -75,9 +93,37 @@ def normalize( if not 0 <= audio_track <= _MAX_AUDIO_TRACK: raise ClipError("bad_audio") spec["audio_track"] = audio_track + frame = _crop(crop) + if frame is not None: + spec["crop"] = frame return spec +def _crop(raw: dict | None) -> dict | None: + """A crop frame, canonical — or None for no crop, which a frame over the whole box also is (the + same clip as one cut without a frame, so the same cache key).""" + if raw is None: + return None + try: + x, y, w, h, aspect = (float(raw[k]) for k in ("x", "y", "w", "h", "aspect")) + except (KeyError, TypeError, ValueError) as e: + raise ClipError("bad_crop") from e + if not all(math.isfinite(v) for v in (x, y, w, h, aspect)): + raise ClipError("bad_crop") + lo, hi = _CROP_ASPECTS + if not lo <= aspect <= hi or x < 0 or y < 0: + raise ClipError("bad_crop") + if w < MIN_CROP - _EDGE_SLACK or h < MIN_CROP - _EDGE_SLACK: + raise ClipError("bad_crop") + if x + w > 1 + _EDGE_SLACK or y + h > 1 + _EDGE_SLACK: + raise ClipError("bad_crop") + frame = {"x": x, "y": y, "w": min(w, 1 - x), "h": min(h, 1 - y), "aspect": aspect} + frame = {k: round(v, 4) for k, v in frame.items()} + if (frame["x"], frame["y"], frame["w"], frame["h"]) == (0, 0, 1, 1): + return None + return frame + + def duration(spec: dict) -> float: return spec["end_s"] - spec["start_s"] @@ -109,6 +155,76 @@ def is_hdr(probe: dict) -> bool: return False +def display_aspect(probe: dict) -> float | None: + """The picture's shape as a player shows it, from an ffprobe `-show_streams` JSON: the first video + stream's width × its sample aspect ratio over its height, turned a quarter when the stream says + it is rotated (ffmpeg turns such a stream upright before any filter of ours, so a crop is cut + from the upright picture). None when the stream has no size.""" + for s in probe.get("streams") or []: + if s.get("codec_type") != "video": + continue + w, h = s.get("width"), s.get("height") + if not isinstance(w, int) or not isinstance(h, int) or w <= 0 or h <= 0: + return None + dar = w * (_ratio(s.get("sample_aspect_ratio")) or 1.0) / h + return 1 / dar if _quarter_turned(s) else dar + return None + + +def _ratio(v: object) -> float | None: + """'32:27' → 1.185…; '0:1', 'N/A', a missing or malformed value → None.""" + if not isinstance(v, str) or ":" not in v: + return None + num, _, den = v.partition(":") + try: + n, d = float(num), float(den) + except ValueError: # silent-ok: a malformed ratio reads as none given; the caller takes 1:1 + return None + return n / d if n > 0 and d > 0 else None + + +def _quarter_turned(stream: dict) -> bool: + rotation: object = (stream.get("tags") or {}).get("rotate") + for sd in stream.get("side_data_list") or []: + if "rotation" in sd: + rotation = sd["rotation"] + try: + return round(float(str(rotation))) % 180 == 90 + except ValueError: # silent-ok: no (None) or a malformed rotation: the stream is upright + return False + + +def crop_filter(crop: dict, dar: float) -> str | None: + """The ffmpeg `crop` for a frame drawn in a box of `crop["aspect"]`, over a picture of display + aspect `dar` fitted into that box as a player fits it (whole, centred, bars on the short sides). + The frame's part over a bar is dropped: "" when what is left is the whole picture, None when too + little of the frame lies on the picture at all. + + In fractions of the input's own width and height, so a sample aspect ratio other than 1:1 (an + anamorphic DVD) needs nothing of its own, and floored to even pixels: an even 4:2:0 frame, and a + window that never runs past the picture's far edge.""" + box = crop["aspect"] + pic_w, pic_h = (1.0, box / dar) if dar > box else (dar / box, 1.0) + + def span(start: float, size: float, extent: float) -> tuple[float, float]: + origin = (1 - extent) / 2 + a = min(1.0, max(0.0, (start - origin) / extent)) + b = min(1.0, max(0.0, (start + size - origin) / extent)) + return a, b - a + + x, w = span(crop["x"], crop["w"], pic_w) + y, h = span(crop["y"], crop["h"], pic_h) + if w < _MIN_VISIBLE or h < _MIN_VISIBLE: + return None + if w > 1 - 1e-4 and h > 1 - 1e-4: + return "" + + def px(axis: str, f: float) -> str: + return f"trunc({axis}*{f:.6f}/2)*2" + + return f"crop={px('iw', w)}:{px('ih', h)}:{px('iw', x)}:{px('ih', y)}" + + def audio_track_count(probe: dict) -> int: return sum(1 for s in probe.get("streams") or [] if s.get("codec_type") == "audio") @@ -122,11 +238,13 @@ def build_cmd( audio_track: int | None, hdr: bool, threads: int, + crop: str = "", ) -> list[str]: """ffmpeg for one clip: an input seek (decodes from the keyframe before `start_s` and drops the frames up to it, so the clip starts on the marked frame), the first video stream, the chosen audio - track (the first when none was chosen; none at all is fine), no subtitles, data or chapters.""" - vf = ",".join([*([_TONEMAP] if hdr else []), _SCALE, EVEN_FRAME]) + track (the first when none was chosen; none at all is fine), no subtitles, data or chapters. + A `crop` (crop_filter) comes first: the tonemap and the scale then work on the frame it keeps.""" + vf = ",".join([*([crop] if crop else []), *([_TONEMAP] if hdr else []), _SCALE, EVEN_FRAME]) audio = f"0:a:{audio_track}" if audio_track is not None else "0:a:0?" return [ "ffmpeg", diff --git a/backend/app/downloads/pipeline.py b/backend/app/downloads/pipeline.py index 591ab6e1..eb23339a 100644 --- a/backend/app/downloads/pipeline.py +++ b/backend/app/downloads/pipeline.py @@ -985,6 +985,20 @@ def _clip_source( return src, spec["start_s"], None +def _clip_crop(spec: dict, data: dict) -> str: + """The clip's crop filter, from its frame and the picture the file really holds ("" for none).""" + frame = spec.get("crop") + if not frame: + return "" + dar = clipmod.display_aspect(data) + if dar is None: + raise StatedFailure("The picture's size could not be read, so the crop cannot be placed.") + crop = clipmod.crop_filter(frame, dar) + if crop is None: + raise StatedFailure("The crop frame lies outside the picture.") + return crop + + def _process_clip(job_id: int, asset_id: int) -> None: """Cut an ad-hoc clip (downloads/clip): fetch or open its source, ONE re-encode to the clip shape, then into the user's library beside their edits.""" @@ -1004,6 +1018,7 @@ def _process_clip(job_id: int, asset_id: int) -> None: audio_track = spec.get("audio_track") if audio_track is not None and audio_track >= clipmod.audio_track_count(data): raise StatedFailure("The chosen audio track is not in the file.") + crop = _clip_crop(spec, data) total_s = clipmod.duration(spec) out = staging / "clip.mp4" _set_job(job_id, phase="editing", progress=0, speed_bps=None, eta_s=None) @@ -1016,6 +1031,7 @@ def _process_clip(job_id: int, asset_id: int) -> None: audio_track=audio_track, hdr=clipmod.is_hdr(data), threads=editmod.encode_thread_cap(), + crop=crop, ), total_s, on_progress=_ffmpeg_progress_writer(job_id, "editing"), diff --git a/backend/app/routes/downloads.py b/backend/app/routes/downloads.py index 2f513ef8..afe93a18 100644 --- a/backend/app/routes/downloads.py +++ b/backend/app/routes/downloads.py @@ -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 diff --git a/backend/tests/test_clip.py b/backend/tests/test_clip.py index 8371a11f..8e2b5780 100644 --- a/backend/tests/test_clip.py +++ b/backend/tests/test_clip.py @@ -126,6 +126,99 @@ def test_hdr_is_read_from_the_first_video_stream(transfer, hdr): assert clipmod.is_hdr(data) is hdr +# --- the crop ---------------------------------------------------------------------------------------- + +_FRAME = {"x": 0.5, "y": 0.25, "w": 0.25, "h": 0.5, "aspect": 16 / 9} + + +def test_a_crop_frame_is_canonical_and_a_frame_over_the_whole_box_is_no_crop(): + a = clipmod.normalize("plex", "42", 0, 10, crop={**_FRAME, "x": 0.50004}) + b = clipmod.normalize("plex", "42", 0, 10, crop=_FRAME) + assert a["crop"] == b["crop"] == {"x": 0.5, "y": 0.25, "w": 0.25, "h": 0.5, "aspect": 1.7778} + assert clipmod.sig(a) == clipmod.sig(b) + whole = clipmod.normalize( + "plex", "42", 0, 10, crop={"x": 0, "y": 0, "w": 1, "h": 1, "aspect": 1} + ) + assert whole == clipmod.normalize("plex", "42", 0, 10) + # A far edge a hair past the box (the client's rounding) is the box's edge. + assert clipmod.normalize("plex", "42", 0, 10, crop={**_FRAME, "w": 0.5004})["crop"]["w"] == 0.5 + + +@pytest.mark.parametrize( + "frame", + [ + {**_FRAME, "w": 0.05}, + {**_FRAME, "h": 0.09}, + {**_FRAME, "x": -0.1}, + {**_FRAME, "x": 0.8}, + {**_FRAME, "y": 0.6}, + {**_FRAME, "aspect": 0}, + {**_FRAME, "aspect": 9}, + {**_FRAME, "x": float("nan")}, + {"x": 0.1, "y": 0.1, "w": 0.5}, + {**_FRAME, "h": "half"}, + ], +) +def test_a_crop_frame_it_cannot_cut_is_refused(frame): + with pytest.raises(clipmod.ClipError) as e: + clipmod.normalize("plex", "42", 0, 10, crop=frame) + assert e.value.reason == "bad_crop" + + +@pytest.mark.parametrize( + ("stream", "dar"), + [ + ({"width": 1920, "height": 1080}, 16 / 9), + # An anamorphic DVD: 720×480 stored, shown 16:9. + ({"width": 720, "height": 480, "sample_aspect_ratio": "32:27"}, 16 / 9), + ({"width": 640, "height": 480, "sample_aspect_ratio": "0:1"}, 4 / 3), + # A phone video stored landscape, shown upright. + ({"width": 1280, "height": 720, "side_data_list": [{"rotation": -90}]}, 9 / 16), + ({"width": 1280, "height": 720, "tags": {"rotate": "270"}}, 9 / 16), + ({"width": 1280, "height": 720, "side_data_list": [{"rotation": 180}]}, 16 / 9), + ({"width": None, "height": 720}, None), + ], +) +def test_the_display_aspect_is_the_picture_as_a_player_shows_it(stream, dar): + got = clipmod.display_aspect( + {"streams": [{"codec_type": "audio"}, {"codec_type": "video", **stream}]} + ) + assert got == pytest.approx(dar) if dar else got is None + + +def _crop_args(f: str) -> list[float]: + # crop=trunc(iw*W/2)*2:trunc(ih*H/2)*2:trunc(iw*X/2)*2:trunc(ih*Y/2)*2 → [W, H, X, Y] + assert f.startswith("crop=") + return [float(p.split("*")[1].split("/")[0]) for p in f[5:].split(":")] + + +def test_a_frame_on_a_picture_that_fills_its_box_is_cut_as_drawn(): + assert _crop_args(clipmod.crop_filter(_FRAME, 16 / 9)) == pytest.approx([0.25, 0.5, 0.5, 0.25]) + + +def test_a_frame_drawn_in_a_16_9_box_over_a_4_3_picture_maps_onto_the_picture(): + # The 4:3 picture fills 3/4 of the box's width from 1/8 in: a frame over its right half's middle. + frame = {"x": 0.5, "y": 0.25, "w": 0.1875, "h": 0.5, "aspect": 16 / 9} + assert _crop_args(clipmod.crop_filter(frame, 4 / 3)) == pytest.approx([0.25, 0.5, 0.5, 0.25]) + + +def test_the_part_of_a_frame_over_a_bar_is_dropped_and_a_frame_on_a_bar_alone_is_no_cut(): + over = {"x": 0.0, "y": 0.0, "w": 0.25, "h": 0.5, "aspect": 16 / 9} + w, h, x, y = _crop_args(clipmod.crop_filter(over, 4 / 3)) + assert (w, h, x, y) == pytest.approx([1 / 6, 0.5, 0, 0], abs=1e-6) + assert clipmod.crop_filter({**over, "w": 0.13}, 4 / 3) is None + # A letterboxed film: the frame over the whole box is the whole picture. + whole = {"x": 0.0, "y": 0.0, "w": 1.0, "h": 1.0, "aspect": 16 / 9} + assert clipmod.crop_filter(whole, 2.39) == "" + + +def test_the_crop_comes_first_so_the_tonemap_and_the_scale_work_on_what_it_keeps(): + f = clipmod.crop_filter(_FRAME, 16 / 9) + vf = _cmd(hdr=True, crop=f)[_cmd(hdr=True, crop=f).index("-vf") + 1] + assert vf.startswith(f + ",") and vf.index("tonemap") < vf.index("min(1080,ih)") + assert "crop" not in _cmd()[_cmd().index("-vf") + 1] + + # --- the route --------------------------------------------------------------------------------------- @@ -351,6 +444,45 @@ def test_a_plex_clip_is_cut_from_the_library_file_with_its_audio_track( assert asset.rel_path.startswith(".edits/") and asset.rel_path.endswith(".mp4") +def test_a_cropped_plex_clip_cuts_the_frame_from_the_picture_and_is_its_own_clip( + db, make_user, as_user, plex_episode, worker +): + worker["probe"] = { + "streams": [ + {"codec_type": "video", "width": 720, "height": 480, "sample_aspect_ratio": "32:27"} + ] + } + c = as_user(make_user()) + plain = _clip(c, source="plex", ref="777").json() + job = _clip(c, source="plex", ref="777", crop=_FRAME).json() + assert job["asset"]["id"] != plain["asset"]["id"] + assert job["edit_spec"]["crop"]["w"] == 0.25 + job, asset = worker["run"](job) + assert (job.status, asset.status) == ("done", "ready") + vf = worker["cmd"][worker["cmd"].index("-vf") + 1] + # Stored to four decimals, the box's 16:9 is a hair off the file's: well under a pixel. + assert _crop_args(vf.split(",")[0]) == pytest.approx([0.25, 0.5, 0.5, 0.25], abs=1e-4) + + +def test_a_crop_frame_that_misses_the_picture_fails_and_says_so( + db, make_user, as_user, plex_episode, worker +): + # A 4:3 file, a frame drawn over its pillar bar in a 16:9 box. + worker["probe"] = {"streams": [{"codec_type": "video", "width": 640, "height": 480}]} + frame = {"x": 0.0, "y": 0.0, "w": 0.12, "h": 0.5, "aspect": 16 / 9} + job = _clip(as_user(make_user()), source="plex", ref="777", crop=frame).json() + job, asset = worker["run"](job) + assert (job.status, asset.status) == ("error", "error") + assert "crop" in job.error + assert "cmd" not in worker + + +def test_a_crop_frame_the_route_cannot_cut_says_why(db, make_user, as_user, yt_video): + r = _clip(as_user(make_user()), source="youtube", ref=yt_video, crop={**_FRAME, "w": 0.01}) + assert r.status_code == 422 + assert r.json()["detail"] == {"code": "clip", "reason": "bad_crop"} + + def test_a_plex_clip_of_a_track_the_file_does_not_have_fails_and_says_so( db, make_user, as_user, plex_episode, worker ): diff --git a/docs/DESIGN.md b/docs/DESIGN.md index 8dbca4d4..d5ceb395 100644 --- a/docs/DESIGN.md +++ b/docs/DESIGN.md @@ -548,16 +548,23 @@ of a full-width panel and nothing on the right. - **Clip mode (both players, owner's picks A + P1 + a):** a scissors in the control row (Plex: the C5 row, before the ⋯; YouTube: the actions beside Download, and the docked bar) or `C` opens a clip panel IN the player; `[` / `]` mark the start / end at the playhead and open it when shut. The video being watched is the - preview — no second stream. Plex: the panel takes the seek bar's and the row's place. YouTube: it sits under - the video (the card's fit counts it, so the video shrinks to make room), and a big view steps back to the - card. The panel: an overview of the whole item (the selection and the zoomed window on it), a zoomed strip + preview — no second stream. Plex: the panel takes the seek bar's and the row's place, and the picture stands + above it. YouTube: it sits under the video (the card's fit counts it, so the video shrinks to make room) + and the wide side panel shuts to its strip while the clip panel is open (owner; it opens again after, unless + the user opened it meanwhile); maximised, the panel takes the docked bar's place; the browser's fullscreen + steps back to maximised. The panel: an overview of the whole item (the selection and the zoomed window on it), a zoomed strip around the selection with the two ends (`ui/RangeTrack`: each end a slider — arrows step a tenth, Shift a second; drawn 4px, hit 1.5rem), Start / End fields with a tenth back / forward and "to the playhead", the length, Play selection (loops), a name, Cancel / Cut clip. On the always-dark chrome the ends are white and the playhead amber (a light theme's accent all but vanished there). The strip's window follows the selection when it settles, never under a dragging pointer. Plex seeks once a drag is released (a seek may restart the transcode); YouTube follows the drag. Escape closes the panel before the - player; the scissors gets the focus back. A cut is a toast with a Downloads link and the usual + player; the scissors gets the focus back. **Crop (owner's picks K1 + a):** a Crop toggle in the panel puts a + frame over the picture (`ui/CropFrame`: on the picture, not its bars; the outside dimmed; drag to move, edges + and corners to resize, thirds while dragging; the arrows move it, a focused corner's resize it, Shift + further; focus ring amber) with a shape — Free, 16:9, 9:16, 1:1 (the largest of it the picture holds) — and + Whole picture; off, the clip is the whole picture. Escape turns the crop off before it closes the panel. A + press outside the frame is still the video's. A cut is a toast with a Downloads link and the usual "Ready to watch" notification. Limits are the server's: 1 s to 10 min (`lib/clipRange`). ## Dialogs — _Decided_ (C4, built in S3f) @@ -745,3 +752,4 @@ of a full-width panel and nothing on the right. | 2026-10-08 | The wheel over the YouTube modal | Over the video: volume. Over a panel with something to scroll: scroll only, never volume, not even at its end. Over a panel with nothing to scroll: volume (owner) | Volume everywhere; a scroll that hands over to the volume at its end | Reading a long description turned the volume down once it hit the end | | 2026-10-09 | A Plex show card plays | Its picture plays the show's next episode everywhere (an episode with a resume point → Continue + Restart, else the first unwatched, else S1·E1), resolved on the click; the preview names it once up; title and ⓘ open the show (option A + hover ask) | The picture opens the show page (as before); every card carrying `next` | A show card on Discover had no Play while Home's had one; carrying `next` costs a grid page up to 180 queries for the few cards anyone plays | | 2026-10-10 | Clip mode in the players (E38) | Scissors in the control row + `C`, `[` `]`; the panel in the player with the watched video as preview; overview + zoomed strip on `ui/RangeTrack` (ends are sliders); a toast + the Ready notification after Cut | A ⋯ menu entry; permanent In / Out buttons; a big dialog with its own preview (a second Plex transcode / YouTube iframe) | Owner's picks A, P1, a; a film is hours and a clip seconds, so only a zoomed strip gives a hand the precision. The Downloads editor's cuts moved onto the same track (keyboard-steppable now) | +| 2026-10-10 | Crop in clip mode (E38) | A Crop toggle in the clip panel: a frame over the playing picture (`ui/CropFrame`, the editor's crop too), Free / 16:9 / 9:16 / 1:1, Whole picture; maximised YouTube keeps the panel in the bar's place; the wide side panel shuts while clipping | A frame always on the picture in clip mode (K2); a free shape only | Owner's K1 + a: a crop is rare, so it never covers the picture unasked; a phone or square clip needs a held shape. The iframe hides its picture's size: drawn on the 16:9 box, the cut fits it to the real picture | diff --git a/frontend/src/components/ClipPanel.dom.test.tsx b/frontend/src/components/ClipPanel.dom.test.tsx index bb6f3ea2..d37ff282 100644 --- a/frontend/src/components/ClipPanel.dom.test.tsx +++ b/frontend/src/components/ClipPanel.dom.test.tsx @@ -31,7 +31,7 @@ vi.mock("../lib/actionErrors", async (importOriginal) => { return { ...actual, notifyActionError: (e: unknown, t: string) => notifyActionError(e, t) }; }); -import ClipPanel, { type ClipSource } from "./ClipPanel"; +import ClipPanel, { type ClipCrop, type ClipSource } from "./ClipPanel"; afterEach(() => { cleanup(); @@ -58,15 +58,20 @@ function Harness({ onSeek?: (t: number, final: boolean) => void; }) { const [clip, setClip] = useState(start); + const [crop, setCrop] = useState(null); return ( {`${clip.start}-${clip.end}`} + {crop ? JSON.stringify(crop) : "none"} { act(() => rerender()); expect(onPlayFrom).toHaveBeenLastCalledWith(100); }); + + it("cuts the whole picture until the crop is turned on, then the frame with its box's shape", async () => { + render(); + expect(screen.queryByRole("group", { name: "clip.shape" })).toBeNull(); + fireEvent.click(screen.getByRole("button", { name: "clip.crop" })); + expect(screen.getByRole("button", { name: "clip.crop" }).getAttribute("aria-pressed")).toBe( + "true", + ); + fireEvent.click(screen.getByRole("button", { name: "clip.cut" })); + await waitFor(() => expect(enqueueClip).toHaveBeenCalled()); + expect(enqueueClip.mock.calls[0]![0].crop).toEqual({ + x: 0.1, + y: 0.1, + w: 0.8, + h: 0.8, + aspect: 1.7778, + }); + }); + + it("holds the frame to a shape without cutting, and goes back to the whole picture", () => { + render(); + fireEvent.click(screen.getByRole("button", { name: "clip.crop" })); + // A segmented choice inside the panel's form: never a submit. + fireEvent.click(screen.getByRole("button", { name: "9:16" })); + expect(enqueueClip).not.toHaveBeenCalled(); + const tall = JSON.parse(screen.getByTestId("crop").textContent!) as ClipCrop; + expect(tall.shape).toBe("9:16"); + expect(tall.rect.h).toBeCloseTo(1, 6); + expect((tall.rect.w * 16) / 9 / tall.rect.h).toBeCloseTo(9 / 16, 6); + fireEvent.click(screen.getByRole("button", { name: "clip.whole" })); + expect(JSON.parse(screen.getByTestId("crop").textContent!)).toEqual({ + rect: { x: 0, y: 0, w: 1, h: 1 }, + shape: "free", + }); + fireEvent.click(screen.getByRole("button", { name: "clip.crop" })); + expect(screen.getByTestId("crop").textContent).toBe("none"); + }); }); diff --git a/frontend/src/components/ClipPanel.tsx b/frontend/src/components/ClipPanel.tsx index 0c5f35c2..7479e785 100644 --- a/frontend/src/components/ClipPanel.tsx +++ b/frontend/src/components/ClipPanel.tsx @@ -1,7 +1,7 @@ import { useEffect, useEffectEvent, useRef, useState, type SubmitEvent } from "react"; import { useTranslation } from "react-i18next"; import { useQueryClient } from "@tanstack/react-query"; -import { Crosshair, Minus, Plus, Repeat, Scissors, X } from "lucide-react"; +import { Crop, Crosshair, Maximize, Minus, Plus, Repeat, Scissors, X } from "lucide-react"; import { api, type ClipRequest } from "../lib/api"; import { invalidateDownloads } from "../lib/downloads"; import { notify } from "../lib/notifications"; @@ -10,7 +10,10 @@ import { navigateTo } from "../lib/nav"; import { log } from "../lib/log"; import { formatTimecode, parseTimecode } from "../lib/timecode"; import { handleBounds, markIn, markOut, zoomWindow, type ClipRange } from "../lib/clipRange"; +import { fitRatio, fracRatio, WHOLE, type Frac } from "../lib/cropGeometry"; import { RangeTrack, RulerTicks, type RangeWindow } from "./ui/RangeTrack"; +import { CropFrame } from "./ui/CropFrame"; +import SegmentedToggle from "./ui/SegmentedToggle"; import { Button } from "./ui/Button"; import { IconButton } from "./ui/IconButton"; import { Input } from "./ui/Input"; @@ -27,6 +30,30 @@ import { Input } from "./ui/Input"; // // The range itself is the caller's state (`clip`), so the player's `[` / `]` keys and this panel // mark the same range by the same rules (lib/clipRange). +// +// A clip is the whole picture unless the user turns the crop on: then a frame over the video +// (`ClipCropFrame`, which the player places over its picture) marks the part to keep, free or held +// to an output shape. The crop is the caller's state too, so the player's Escape can turn it off +// before it closes the panel. + +/** The output shapes a crop frame can be held to (width / height of the clip), or a free frame. */ +type ClipShape = "free" | "16:9" | "9:16" | "1:1"; +const SHAPES: Record, number> = { + "16:9": 16 / 9, + "9:16": 9 / 16, + "1:1": 1, +}; + +/** A clip's crop: the frame, in fractions of the picture, and the shape it is held to. */ +export interface ClipCrop { + rect: Frac; + shape: ClipShape; +} + +const CROP_START: ClipCrop = { rect: { x: 0.1, y: 0.1, w: 0.8, h: 0.8 }, shape: "free" }; +const round4 = (v: number) => Math.round(v * 10_000) / 10_000; +const lockOf = (crop: ClipCrop, picture: number) => + crop.shape === "free" ? null : fracRatio(SHAPES[crop.shape], picture); export interface ClipSource { source: ClipRequest["source"]; @@ -42,6 +69,10 @@ interface Props { duration: number; /** The player's position, live. */ current: number; + /** The shape (width / height) of the picture the crop frame is drawn on. */ + picture: number; + crop: ClipCrop | null; + onCrop: (c: ClipCrop | null) => void; /** Move the player; `final` false while a pointer still drags (a player whose seek is costly waits). */ onSeek: (time: number, final: boolean) => void; /** Play from `time` (the loop's restart, and "Play selection"). */ @@ -60,6 +91,9 @@ export default function ClipPanel({ onChange, duration, current, + picture, + crop, + onCrop, onSeek, onPlayFrom, onClose, @@ -79,11 +113,25 @@ export default function ClipPanel({ const length = clip.end - clip.start; // Opened from a button or a key, the panel takes the focus to its In handle: the button that - // opened it is gone (its row is this panel now), and the arrows then move the mark at once. + // opened it is gone (its row is this panel now), and the arrows then move the mark at once. Without + // scrolling there: the player's card is still fitting itself around the new panel at this point, + // and a card that has to scroll (a short window) scrolled the top of the video out of sight. useEffect(() => { - rootRef.current?.querySelector('[data-handle="in"]')?.focus(); + rootRef.current + ?.querySelector('[data-handle="in"]') + ?.focus({ preventScroll: true }); }, []); + // The crop turned off from its frame (Escape there): the frame took the focus with it, so it goes to + // the Crop toggle rather than to , where the player's dialog no longer holds it. + const cropButtonRef = useRef(null); + const cropOn = crop != null; + useEffect(() => { + if (cropOn) return; + const lost = document.activeElement == null || document.activeElement === document.body; + if (lost) cropButtonRef.current?.focus(); + }, [cropOn]); + // "Play selection" loops: past Out, back to In. const restart = useEffectEvent(() => onPlayFrom(clip.start)); useEffect(() => { @@ -107,6 +155,17 @@ export default function ClipPanel({ start_s: Math.round(clip.start * 1000) / 1000, end_s: Math.round(clip.end * 1000) / 1000, ...(src.audioTrack != null ? { audio_track: src.audioTrack } : {}), + ...(crop + ? { + crop: { + x: round4(crop.rect.x), + y: round4(crop.rect.y), + w: round4(crop.rect.w), + h: round4(crop.rect.h), + aspect: round4(picture), + }, + } + : {}), ...(name.trim() ? { display_name: name.trim() } : {}), }; const submit = async (e: SubmitEvent) => { @@ -249,6 +308,46 @@ export default function ClipPanel({ +
+ + {crop && ( + <> + { + const next = { ...crop, shape }; + const lock = lockOf(next, picture); + onCrop(lock == null ? next : { ...next, rect: fitRatio(crop.rect, lock) }); + }} + options={[ + { value: "free", label: t("clip.shapeFree") }, + { value: "16:9", label: "16:9" }, + { value: "9:16", label: "9:16" }, + { value: "1:1", label: "1:1" }, + ]} + /> + + + )} +
+
); } + +/** The clip's crop frame, over the player's picture box (the caller positions this layer over it). */ +export function ClipCropFrame({ + picture, + crop, + onCrop, +}: { + picture: number; + crop: ClipCrop; + onCrop: (c: ClipCrop) => void; +}) { + return ( + onCrop({ ...crop, rect })} + className="z-base" + /> + ); +} diff --git a/frontend/src/components/PlayerModal.dom.test.tsx b/frontend/src/components/PlayerModal.dom.test.tsx index 0224f1d8..4ea6909e 100644 --- a/frontend/src/components/PlayerModal.dom.test.tsx +++ b/frontend/src/components/PlayerModal.dom.test.tsx @@ -691,6 +691,39 @@ describe("PlayerModal — the side panel on a wide window", () => { expect(row().style.maxWidth).toBe("calc((100dvh - 3rem - 0px) * 16 / 9 + 20rem)"); }); + it("shuts while a clip is marked and opens again after it, unless the user opened it meanwhile", async () => { + wideWindow(true); + vi.spyOn(api, "videoDetail").mockReturnValue(new Promise(() => {})); + const A = vid("A", 0); + renderModal( + {}} onState={() => {}} />, + ); + await waitFor(() => expect(lastStart()?.videoId).toBe("A")); + fireEvent.click(screen.getByRole("button", { name: "clip.open" })); + expect(screen.getByTestId("player-strip")).toBeTruthy(); + // The clip panel sits under the video here too: the stacked card's floor, and its scroll past it + // (without them the fit ran the card down to a sliver at 133% with the largest text). + const stage = document.querySelector(".player-stage")!; + const cardEl = stage.parentElement!.parentElement!; + expect(cardEl.parentElement!.style.maxWidth).toBe( + "calc(max(36rem, (100dvh - 3rem - 0px) * 16 / 9) + 2.75rem)", + ); + expect(cardEl.className).toContain("overflow-y-auto"); + fireEvent.click(screen.getByRole("button", { name: "clip.close" })); + expect(screen.getByTestId("player-info")).toBeTruthy(); + expect(cardEl.className).toContain("overflow-hidden"); + // Opened again by hand while clipping: it stays open, and closing the clip leaves it so. + fireEvent.click(screen.getByRole("button", { name: "clip.open" })); + fireEvent.click( + within(screen.getByTestId("player-strip")).getByRole("button", { + name: "player.expandPanel", + }), + ); + fireEvent.click(screen.getByRole("button", { name: "player.collapsePanel" })); + fireEvent.click(screen.getByRole("button", { name: "clip.close" })); + expect(screen.getByTestId("player-strip")).toBeTruthy(); + }); + it("gives a panel with something to scroll the wheel for that alone, the volume elsewhere", async () => { wideWindow(true); vi.spyOn(api, "videoDetail").mockResolvedValue({ @@ -827,8 +860,8 @@ describe("PlayerModal — the docked bar's fullscreen", () => { }); // Clip mode: the panel sits under the video (the video is its preview), Escape closes the panel and -// not the player, the scissors gets the focus back, and a mark made in the big view steps back to -// the card, where the panel lives. +// not the player, the scissors gets the focus back; in the maximised view the panel takes the docked +// bar's place; a crop frame lies over the video and Escape turns it off before the panel closes. describe("PlayerModal — clip mode", () => { const key = (k: string) => act( @@ -865,16 +898,59 @@ describe("PlayerModal — clip mode", () => { ); }); - it("marks in the big view by stepping back to the card", async () => { + it("marks in the maximised view without leaving it: the panel takes the docked bar's place", async () => { clock.current = 10; open(); await waitFor(() => expect(lastStart()?.videoId).toBe("A")); key("f"); - expect(document.querySelector(".player-stage--max")).toBeTruthy(); + const stage = document.querySelector(".player-stage--max")!; + expect(stage).toBeTruthy(); key("]"); - expect(document.querySelector(".player-stage--max")).toBeNull(); + expect(document.querySelector(".player-stage--max")).toBe(stage); + expect(stage.contains(screen.getByTestId("clip-panel"))).toBe(true); + expect(screen.queryByRole("button", { name: "player.exitMaximize" })).toBeNull(); expect(screen.getByRole("slider", { name: "clip.out" }).getAttribute("aria-valuenow")).toBe( "10", ); + // Escape: the panel first, then the maximised view, then (not here) the player. + key("Escape"); + expect(screen.queryByTestId("clip-panel")).toBeNull(); + expect(document.querySelector(".player-stage--max")).toBe(stage); + expect(screen.getByRole("button", { name: "player.exitMaximize" })).toBeTruthy(); + }); + + it("draws a short's frame on its 9:16 picture and tells the cut so", async () => { + const enqueue = vi.spyOn(api, "enqueueClip").mockResolvedValue({ id: 9 } as never); + vi.spyOn(api, "videoDetail").mockReturnValue(new Promise(() => {})); + const S = { ...vid("S", 0), is_short: true } as Video; + renderModal( + {}} onState={() => {}} />, + ); + await waitFor(() => expect(lastStart()?.videoId).toBe("S")); + key("c"); + fireEvent.click(screen.getByRole("button", { name: "clip.crop" })); + fireEvent.click(screen.getByRole("button", { name: "clip.cut" })); + await waitFor(() => expect(enqueue).toHaveBeenCalled()); + expect(enqueue.mock.calls[0]![0].crop?.aspect).toBe(0.5625); + }); + + it("lays the crop frame over the video, and Escape turns it off before closing the panel", async () => { + // jsdom lays nothing out: the frame's layer measures a 16:9 box. + vi.spyOn(Element.prototype, "clientWidth", "get").mockReturnValue(1600); + vi.spyOn(Element.prototype, "clientHeight", "get").mockReturnValue(900); + open(); + await waitFor(() => expect(lastStart()?.videoId).toBe("A")); + key("c"); + fireEvent.click(screen.getByRole("button", { name: "clip.crop" })); + const frame = screen.getByTestId("crop-frame"); + expect(document.querySelector(".player-stage")!.contains(frame)).toBe(true); + act(() => screen.getByRole("group", { name: "common.cropFrame.frame" }).focus()); + key("Escape"); + expect(screen.queryByTestId("crop-frame")).toBeNull(); + expect(screen.getByTestId("clip-panel")).toBeTruthy(); + // The frame took the focus with it: it is on the Crop toggle, not on . + expect(document.activeElement).toBe(screen.getByRole("button", { name: "clip.crop" })); + key("Escape"); + expect(screen.queryByTestId("clip-panel")).toBeNull(); }); }); diff --git a/frontend/src/components/PlayerModal.tsx b/frontend/src/components/PlayerModal.tsx index 8ac0246c..e5eb765f 100644 --- a/frontend/src/components/PlayerModal.tsx +++ b/frontend/src/components/PlayerModal.tsx @@ -51,7 +51,7 @@ import { } from "../lib/layerStack"; import { trapTab, useDialogChrome } from "./ui/dialogFocus"; import DownloadButton from "./DownloadButton"; -import ClipPanel from "./ClipPanel"; +import ClipPanel, { ClipCropFrame, type ClipCrop } from "./ClipPanel"; import { DEFAULT_CLIP_S, markIn, markOut, openAt, type ClipRange } from "../lib/clipRange"; import { api, type Video, type VideoStatus } from "../lib/api"; import type { YTPlayer } from "../lib/youtube"; @@ -274,6 +274,10 @@ export default function PlayerModal({ // queue steps on (a next video plays in this same modal), and a video opened from a card mounts a // fresh player, which starts with the panel open (owner). const [panelShut, setPanelShut] = useState(false); + // Shut by clip mode rather than by the user (owner): the clip panel and the video take its width + // while a clip is marked, and it opens again when the clip panel closes. A press on its own toggle + // in between is the user's choice and holds. + const [shutForClip, setShutForClip] = useState(false); // The toggle that was pressed is gone after the switch (shut → the strip's "open", and back): focus // goes to its counterpart, not to , where the player's keys would no longer reach it. const panelToggleRef = useRef(null); @@ -281,6 +285,7 @@ export default function PlayerModal({ const togglePanel = (shut: boolean) => { refocusToggle.current = true; setPanelShut(shut); + setShutForClip(false); }; useEffect(() => { if (!refocusToggle.current) return; @@ -344,12 +349,37 @@ export default function PlayerModal({ // Clip mode: the range being marked of the video that is playing, or null. Its panel sits under // the video, which is its preview; another video starts without one. const [clip, setClip] = useState(null); + // The clip's crop frame over the picture, or null for the whole picture. + const [crop, setCrop] = useState(null); const [clipFor, setClipFor] = useState(currentVideoId); if (clipFor !== currentVideoId) { setClipFor(currentVideoId); setClip(null); + setCrop(null); + if (shutForClip) { + setPanelShut(false); + setShutForClip(false); + } } const clipOpen = clip != null; + // The picture the crop frame is drawn on. The iframe never tells its video's size: a short is + // 9:16, anything else is taken as the player's 16:9, and the cut fits the frame to the real picture + // (downloads/clip) — so a 4:3 video's frame drawn over its bars loses only what lies on the bars. + const picture = !navigated && active.is_short ? 9 / 16 : 16 / 9; + // The docked clip panel's height, measured (it wraps with the window and the text size): the video + // above it ends there. + const clipDockRef = useRef(null); + const [clipDockH, setClipDockH] = useState(0); + const clipDocked = clipOpen && (maximized || isFullscreen); + useLayoutEffect(() => { + const el = clipDockRef.current; + if (!clipDocked || !el) return; + const measure = () => setClipDockH(el.offsetHeight); + measure(); + const ro = new ResizeObserver(measure); + ro.observe(el); + return () => ro.disconnect(); + }, [clipDocked]); // The player's clock, read while the panel is open: the iframe reports no time of its own. const [clipClock, setClipClock] = useState({ now: 0, duration: 0 }); useEffect(() => { @@ -372,6 +402,11 @@ export default function PlayerModal({ const closeClip = () => { refocusClip.current = true; setClip(null); + setCrop(null); + if (shutForClip) { + setPanelShut(false); + setShutForClip(false); + } }; useEffect(() => { if (clipOpen || !refocusClip.current) return; @@ -379,14 +414,19 @@ export default function PlayerModal({ clipButtonRef.current?.focus(); }, [clipOpen]); /** Mark where the player is now (`mark` gets the range so far, or null when the panel is shut). The - * panel lives under the video, so a big view steps back to the card first. */ + * panel lives under the video in the card, and in place of the docked bar when maximised; the + * browser's fullscreen steps back to the maximised view, which has room for it. */ const markClip = (mark: (r: ClipRange | null, t: number, d: number) => ClipRange) => { const p = playerRef.current; if (!p || typeof p.getCurrentTime !== "function") return; const t = p.getCurrentTime() || 0; const d = p.getDuration() || 0; if (d <= 0) return; - if (maximized || isFullscreen) exitBigView(); + if (isFullscreen) toggleTrueFullscreen(); + if (!clip && wide && !panelShut) { + setPanelShut(true); + setShutForClip(true); + } setClipClock({ now: t, duration: d }); setClip((r) => mark(r, t, d)); }; @@ -669,10 +709,12 @@ export default function PlayerModal({ // stack synchronously, so `anyLayerAbove` alone would miss it and close the player too. if (escapeHandled(ev)) return; if (layerIdRef.current != null && anyLayerAbove(layerIdRef.current)) return; - // The clip panel is the player's own layer: it closes before the player does. + // The clip panel is the player's own layer: it closes before the player does, and a crop + // turns off before the panel closes. if (clip) { ev.preventDefault(); - closeClip(); + if (crop) setCrop(null); + else closeClip(); return; } // In fullscreen, let the browser's Esc exit fullscreen only — don't also close the modal. @@ -751,6 +793,29 @@ export default function PlayerModal({ onLoadVideo: loadVideo, }; + // The clip panel: under the video in the card, in the docked bar's place in the big views. + const clipPanel = clip && ( + { + // A seek past what is buffered fetches from there: only once the drag settles. + playerRef.current?.seekTo?.(time, final); + setClipClock((c) => ({ ...c, now: time })); + }} + onPlayFrom={(time) => { + seekTo(time); + setClipClock((c) => ({ ...c, now: time })); + }} + onClose={closeClip} + /> + ); // The card's width budget (its maxWidth, below): the video's, and the side panel's when wide. const fitWidth = `(100dvh - 3rem - ${below}px) * 16 / 9`; const panelWidth = panelShut ? PANEL_STRIP : SIDE_PANEL; @@ -792,23 +857,26 @@ export default function PlayerModal({ // window's height less the backdrop's padding (`p-6` = 3rem). // Wide: no design cap — the video is as wide as the window's height allows, plus the side panel, // whose content sits beside the video (`below` is ~0), so the fit is exact and the card, which - // does not scroll there, always holds the whole video. + // does not scroll there, always holds the whole video. Clip mode is the exception: its panel + // sits under the video, so the wide card takes the stacked one's floor and scroll too. // Stacked: never narrower than MIN_VIDEO. The chrome under the video grows as the card narrows // (its text wraps), so on a short window fitting it was a loop that ran the card down to 2 px - // (a laptop at 100%, a 1080p screen at 175%). Past the floor the stacked card scrolls instead. + // (a laptop at 100%, a 1080p screen at 175%; the wide clip panel did the same at 133% with the + // largest text). Past the floor the card scrolls instead. style={{ maxWidth: wide - ? `calc(${fitWidth} + ${panelWidth})` + ? `calc(${clipOpen ? `max(${MIN_VIDEO}, ${fitWidth})` : fitWidth} + ${panelWidth})` : `min(56rem, max(${MIN_VIDEO}, ${fitWidth}))`, }} >
e.stopPropagation()} > @@ -832,9 +900,11 @@ export default function PlayerModal({
))} + {/* The clip's crop frame, over the picture (this box is the iframe's). Pointers pass + it outside the frame, so a click elsewhere on the video is still play / pause. */} + {crop && playerError == null && ( + + )}
{/* Our own control bar for the big views (maximise / our fullscreen), where the card's controls are off-screen. It's IN the stage (so it survives true fullscreen, where a @@ -879,7 +954,12 @@ export default function PlayerModal({ the exit button also re-arms our shortcuts. Both of its dialogs work in true fullscreen: add-to-playlist's popover and download's Modal portal INTO the fullscreen element. */} {isFullscreen && } - {(maximized || isFullscreen) && ( + {clipDocked && ( +
+ {clipPanel} +
+ )} + {(maximized || isFullscreen) && !clipDocked && (
e.stopPropagation()} className="absolute inset-x-0 bottom-0 z-menu flex h-14 items-center justify-start gap-x-3 overflow-x-auto border-t border-white/10 bg-media-chrome/95 px-3 text-sm text-white" @@ -907,7 +987,7 @@ export default function PlayerModal({ {!navigated && ( )} - {/* The clip panel lives under the video in the card: this steps out of the big view. */} + {/* Clip mode here: the clip panel takes this bar's place. */} {playerError == null && ( @@ -1006,25 +1086,7 @@ export default function PlayerModal({ )}
- {clip && !maximized && !isFullscreen && ( - { - // A seek past what is buffered fetches from there: only once the drag settles. - playerRef.current?.seekTo?.(time, final); - setClipClock((c) => ({ ...c, now: time })); - }} - onPlayFrom={(time) => { - seekTo(time); - setClipClock((c) => ({ ...c, now: time })); - }} - onClose={closeClip} - /> - )} + {!clipDocked && clipPanel}
{/* Wide: the side panel. Its content scrolls inside a box the video's height, so a long description never makes the card taller than the video. */} diff --git a/frontend/src/components/PlexPlayer.controls.dom.test.tsx b/frontend/src/components/PlexPlayer.controls.dom.test.tsx index bdc7b0e8..81e91931 100644 --- a/frontend/src/components/PlexPlayer.controls.dom.test.tsx +++ b/frontend/src/components/PlexPlayer.controls.dom.test.tsx @@ -983,4 +983,44 @@ describe("PlexPlayer — clip mode", () => { audio_track: 0, }); }); + it("crops on the picture's own shape, and Escape turns the crop off before the panel", async () => { + const enqueue = vi.spyOn(api, "enqueueClip").mockResolvedValue({ id: 9 } as never); + // A 720×480 stream: the frame is drawn on a 3:2 picture, and the cut is told so. + const proto = HTMLVideoElement.prototype; + const own = ["videoWidth", "videoHeight"].map( + (k) => [k, Object.getOwnPropertyDescriptor(proto, k)] as const, + ); + Object.defineProperty(proto, "videoWidth", { configurable: true, get: () => 720 }); + Object.defineProperty(proto, "videoHeight", { configurable: true, get: () => 480 }); + try { + mount({ details: { a: item("a") } }); + await playingAt("/media/a", 60); + key("c"); + fireEvent.click(screen.getByRole("button", { name: "clip.crop" })); + // The frame's layer is the video's box: both end above the panel, both at the player's top + // (its flex box centred the shortened video, and the frame sat 155 px above the picture). + const video = document.querySelector("video")!; + const layer = screen.getByTestId("crop-frame").parentElement!; + expect(video.style.alignSelf).toBe("flex-start"); + expect(layer.className).toContain("top-0"); + expect(layer.style.height).toBe(video.style.height); + key("Escape"); + expect(screen.queryByTestId("crop-frame")).toBeNull(); + expect(screen.getByTestId("clip-panel")).toBeTruthy(); + fireEvent.click(screen.getByRole("button", { name: "clip.crop" })); + fireEvent.click(screen.getByRole("button", { name: "clip.cut" })); + await waitFor(() => expect(enqueue).toHaveBeenCalled()); + expect(enqueue.mock.calls[0]![0].crop).toEqual({ + x: 0.1, + y: 0.1, + w: 0.8, + h: 0.8, + aspect: 1.5, + }); + } finally { + for (const [k, d] of own) + if (d) Object.defineProperty(proto, k, d); + else delete (proto as unknown as Record)[k]; + } + }); }); diff --git a/frontend/src/components/PlexPlayer.tsx b/frontend/src/components/PlexPlayer.tsx index ce402233..728faacb 100644 --- a/frontend/src/components/PlexPlayer.tsx +++ b/frontend/src/components/PlexPlayer.tsx @@ -49,7 +49,7 @@ import { type PlexPlayerPrefs, } from "../lib/plexPlayerPrefs"; import { Ctrl, PanelHead } from "./plexPlayerControls"; -import ClipPanel from "./ClipPanel"; +import ClipPanel, { ClipCropFrame, type ClipCrop } from "./ClipPanel"; import { DEFAULT_CLIP_S, markIn, markOut, openAt, type ClipRange } from "../lib/clipRange"; import { PlexPlayerSettings } from "./PlexPlayerSettings"; import { PlexStatsOverlay } from "./PlexStatsOverlay"; @@ -294,19 +294,29 @@ export default function PlexPlayer({ itemId, onClose, queue, fromStart = false } // Clip mode: the range being marked, or null. Its panel takes the control band's place, and a // next item starts without one (reset against the item it was opened on). const [clip, setClip] = useState(null); + // The clip's crop frame over the picture, or null for the whole picture. + const [crop, setCrop] = useState(null); const [clipFor, setClipFor] = useState(id); if (clipFor !== id) { setClipFor(id); setClip(null); + setCrop(null); } const clipRef = useLatest(clip); + const cropRef = useLatest(crop); // The scissors that opened the panel is gone while it is open; closing it gives the focus back. const clipButtonRef = useRef(null); const refocusClip = useRef(false); const closeClip = useCallback(() => { refocusClip.current = true; setClip(null); + setCrop(null); }, []); + // Escape and Back inside clip mode: a crop turns off first, then the panel closes. + const stepOutOfClip = useCallback(() => { + if (cropRef.current) setCrop(null); + else closeClip(); + }, [cropRef, closeClip]); useEffect(() => { if (clip != null || !refocusClip.current) return; refocusClip.current = false; @@ -626,6 +636,36 @@ export default function PlexPlayer({ itemId, onClose, queue, fromStart = false } // and play intent and read its own clock: the element's starts at the stream's start (a transcode // begins at the resume point), and a seek outside the stream restarts it. const [videoEl, setVideoEl] = useState(null); + // The picture's shape, for the clip's crop frame: the element's own once it knows it (a stream + // switch can change it), 16:9 until then. + const [picture, setPicture] = useState(16 / 9); + useEffect(() => { + if (!videoEl) return; + const read = () => { + if (videoEl.videoWidth > 0 && videoEl.videoHeight > 0) + setPicture(videoEl.videoWidth / videoEl.videoHeight); + }; + read(); + videoEl.addEventListener("loadedmetadata", read); + videoEl.addEventListener("resize", read); + return () => { + videoEl.removeEventListener("loadedmetadata", read); + videoEl.removeEventListener("resize", read); + }; + }, [videoEl]); + // In clip mode the picture stands above the clip panel rather than under it, so the crop frame + // reaches every edge of it: the panel's height, measured (it wraps with the window and the text). + const [clipBand, setClipBand] = useState(0); + const clipOpen = clip != null; + useLayoutEffect(() => { + const bar = bottomBarRef.current; + if (!clipOpen || !bar) return; + const measure = () => setClipBand(bar.offsetHeight); + measure(); + const ro = new ResizeObserver(measure); + ro.observe(bar); + return () => ro.disconnect(); + }, [clipOpen]); useMediaSession( videoEl, detail && { @@ -1064,7 +1104,7 @@ export default function PlexPlayer({ itemId, onClose, queue, fromStart = false } return; ev.preventDefault(); // The clip panel is the player's own too, and closes before the player does. - if (clipRef.current) closeClip(); + if (clipRef.current) stepOutOfClip(); else back("key"); }, escape: (ev) => { @@ -1075,7 +1115,7 @@ export default function PlexPlayer({ itemId, onClose, queue, fromStart = false } // one and its close can pop it off the stack synchronously, so `anyLayerAbove` alone misses it. if (escapeHandled(ev)) return; if (layerIdRef.current != null && anyLayerAbove(layerIdRef.current)) return; - if (clipRef.current) closeClip(); + if (clipRef.current) stepOutOfClip(); else back("escape"); }, }); @@ -1109,6 +1149,7 @@ export default function PlexPlayer({ itemId, onClose, queue, fromStart = false } statsOpenRef, clipRef, closeClip, + stepOutOfClip, absRef, durationRef, ]); @@ -1191,6 +1232,11 @@ export default function PlexPlayer({ itemId, onClose, queue, fromStart = false } // (a 720p stream → 1280×720) and never scales it UP, so in fullscreen it sat tiny and centred. // `touch-manipulation`: a double-tap is the skip, never the browser's zoom. className="w-full h-full object-contain touch-manipulation" + // In clip mode the picture stands above the clip panel: shorter, and at the top of the player, + // whose flex box would otherwise centre it and leave its bottom under the panel. + style={ + clipOpen ? { height: `calc(100% - ${clipBand}px)`, alignSelf: "flex-start" } : undefined + } onClick={onPictureClick} playsInline > @@ -1211,6 +1257,14 @@ export default function PlexPlayer({ itemId, onClose, queue, fromStart = false } /> )} + {crop && ( +
+ +
+ )} { if (final) seekTo(time); }} diff --git a/frontend/src/components/VideoEditor.tsx b/frontend/src/components/VideoEditor.tsx index b38f6f34..32fb3c93 100644 --- a/frontend/src/components/VideoEditor.tsx +++ b/frontend/src/components/VideoEditor.tsx @@ -7,7 +7,8 @@ import clsx from "clsx"; import Modal, { ModalFooter } from "./ui/Modal"; import { notify } from "../lib/notifications"; import { api, type DownloadJob, type EditSpec } from "../lib/api"; -import { contentRect, cropToSourcePx, type Frac } from "../lib/cropGeometry"; +import { cropToSourcePx, type Frac } from "../lib/cropGeometry"; +import { CropFrame } from "./ui/CropFrame"; import { Button } from "./ui/Button"; import { failureReason, notifyActionError } from "../lib/actionErrors"; import { markReported } from "../lib/api/core"; @@ -48,11 +49,9 @@ export default function VideoEditor({ job, onClose }: { job: DownloadJob; onClos const [current, setCurrent] = useState(0); const [playing, setPlaying] = useState(false); const [trackW, setTrackW] = useState(0); - // Intrinsic (source) video dims and the video element's rendered box, both needed to map the - // crop marquee onto the letterboxed picture. `vdim` starts from asset metadata so the overlay - // can size before the media decodes; `box` is measured off the element (max-h can clamp it). + // Intrinsic (source) video dims: the crop frame's picture shape, and the crop's source pixels. + // From the asset's metadata, so the frame sizes before the media decodes. const [vdim, setVdim] = useState({ w: job.asset?.width ?? 0, h: job.asset?.height ?? 0 }); - const [box, setBox] = useState({ w: 0, h: 0 }); // The cut-list: contiguous segments covering [0, duration]. Starts as the whole video, kept. const [segments, setSegments] = useState([{ id: 0, start: 0, end: srcDur, keep: true }]); @@ -93,21 +92,9 @@ export default function VideoEditor({ job, onClose }: { job: DownloadJob; onClos return () => ro.disconnect(); }, []); - // Measure the video element's rendered box (max-h can shrink it below its intrinsic size, which - // then pillarboxes the picture). The crop overlay is anchored to the picture, not this box. - useEffect(() => { - const v = videoRef.current; - if (!v || typeof ResizeObserver === "undefined") return; - const ro = new ResizeObserver(() => setBox({ w: v.clientWidth, h: v.clientHeight })); - ro.observe(v); - setBox({ w: v.clientWidth, h: v.clientHeight }); - return () => ro.disconnect(); - }, []); - - // The picture's rendered rectangle inside the (possibly letterboxed) video box — the overlay's - // frame, so a crop fraction is a fraction of the real picture, not of the black bars. + // The picture's shape, which the crop frame is drawn on (it finds the picture inside the video's + // box itself: max-h can pillarbox it). const va = vdim.w && vdim.h ? vdim.w / vdim.h : aspect; - const picRect = contentRect(box.w, box.h, va); const onLoaded = () => { const v = videoRef.current; @@ -187,47 +174,6 @@ export default function VideoEditor({ job, onClose }: { job: DownloadJob; onClos // render never reads the track's geometry (a DOM read in render made the React Compiler skip it). const [hover, setHover] = useState<{ x: number; time: number } | null>(null); - // --- crop drag --- - const overlayRef = useRef(null); - const cropDrag = useRef( - null, - ); - const onCropDown = (e: React.PointerEvent, mode: "move" | "resize") => { - e.preventDefault(); - e.stopPropagation(); - (e.target as Element).setPointerCapture?.(e.pointerId); - cropDrag.current = { mode, px: e.clientX, py: e.clientY, orig: { ...crop } }; - }; - useEffect(() => { - const move = (e: PointerEvent) => { - const d = cropDrag.current; - const el = overlayRef.current; - if (!d || !el) return; - const r = el.getBoundingClientRect(); - const dx = (e.clientX - d.px) / r.width; - const dy = (e.clientY - d.py) / r.height; - if (d.mode === "move") - setCrop({ - ...d.orig, - x: Math.max(0, Math.min(1 - d.orig.w, d.orig.x + dx)), - y: Math.max(0, Math.min(1 - d.orig.h, d.orig.y + dy)), - }); - else - setCrop({ - ...d.orig, - w: Math.max(0.05, Math.min(1 - d.orig.x, d.orig.w + dx)), - h: Math.max(0.05, Math.min(1 - d.orig.y, d.orig.h + dy)), - }); - }; - const up = () => (cropDrag.current = null); - window.addEventListener("pointermove", move); - window.addEventListener("pointerup", up); - return () => { - window.removeEventListener("pointermove", move); - window.removeEventListener("pointerup", up); - }; - }, []); - // --- filmstrip tile helpers (percentage sprite positioning; aspect-correct cells) --- const tileStyle = (idx: number): React.CSSProperties => { const cols = strip?.cols ?? 12; @@ -370,35 +316,7 @@ export default function VideoEditor({ job, onClose }: { job: DownloadJob; onClos className="w-full max-h-[42dvh] mx-auto block object-contain" playsInline /> - {cropOn && picRect && ( -
-
onCropDown(e, "move")} - > -
onCropDown(e, "resize")} - /> -
-
- )} + {cropOn && }
{/* transport */} diff --git a/frontend/src/components/ui/CropFrame.dom.test.tsx b/frontend/src/components/ui/CropFrame.dom.test.tsx new file mode 100644 index 00000000..e344bc80 --- /dev/null +++ b/frontend/src/components/ui/CropFrame.dom.test.tsx @@ -0,0 +1,113 @@ +// @vitest-environment jsdom +// +// The crop frame: it sits on the picture, not on the box's bars; the arrows move it and, on a focused +// corner, resize it, and the player underneath never sees those arrows; a drag moves it in picture +// fractions; and the layer lets every press outside the frame through. jsdom lays nothing out, so the +// box's size is stubbed. +import { afterEach, beforeAll, describe, expect, it, vi } from "vitest"; +import { cleanup, fireEvent, render, screen } from "@testing-library/react"; +import { useState } from "react"; + +vi.mock("react-i18next", async (importOriginal) => { + const actual = await importOriginal(); + return { ...actual, useTranslation: () => ({ t: (k: string) => k }) }; +}); +import { CropFrame } from "./CropFrame"; +import type { Frac } from "../../lib/cropGeometry"; + +afterEach(cleanup); + +beforeAll(() => { + class PointerEventStub extends MouseEvent { + pointerId: number; + constructor(type: string, init: PointerEventInit = {}) { + super(type, init); + this.pointerId = init.pointerId ?? 1; + } + } + globalThis.PointerEvent ??= PointerEventStub as unknown as typeof PointerEvent; + // A 1600×900 box for every element: the frame's layer measures its own. + Object.defineProperty(HTMLElement.prototype, "clientWidth", { configurable: true, value: 1600 }); + Object.defineProperty(HTMLElement.prototype, "clientHeight", { configurable: true, value: 900 }); +}); + +const WIDE = 16 / 9; + +function Harness({ aspect = WIDE, ratio = null }: { aspect?: number; ratio?: number | null }) { + const [rect, setRect] = useState({ x: 0.2, y: 0.2, w: 0.4, h: 0.4 }); + return ( +
+ + {[rect.x, rect.y, rect.w, rect.h].map((v) => v.toFixed(3)).join(" ")} + + +
+ ); +} + +const rect = () => screen.getByTestId("rect").textContent; +const picture = () => screen.getByRole("group", { name: "common.cropFrame.frame" }).parentElement!; + +describe("CropFrame", () => { + it("lies on the picture, between a 4:3 picture's bars in a 16:9 box", () => { + render(); + const p = picture(); + expect([p.style.left, p.style.top, p.style.width, p.style.height]).toEqual([ + "200px", + "0px", + "1200px", + "900px", + ]); + }); + + it("moves with the arrows, further with Shift, and keeps them from the player", () => { + const player = vi.fn(); + render( +
+ +
, + ); + const frame = screen.getByRole("group", { name: "common.cropFrame.frame" }); + fireEvent.keyDown(frame, { key: "ArrowRight" }); + expect(rect()).toBe("0.210 0.200 0.400 0.400"); + fireEvent.keyDown(frame, { key: "ArrowUp", shiftKey: true }); + expect(rect()).toBe("0.210 0.100 0.400 0.400"); + expect(player).not.toHaveBeenCalled(); + // Not an arrow: the player's (Escape steps out of the crop there). + fireEvent.keyDown(frame, { key: "Escape" }); + expect(player).toHaveBeenCalledTimes(1); + }); + + it("resizes from a focused corner, the opposite corner staying put", () => { + render(); + const se = screen.getByRole("group", { name: "common.cropFrame.se" }); + fireEvent.keyDown(se, { key: "ArrowRight", shiftKey: true }); + fireEvent.keyDown(se, { key: "ArrowDown" }); + expect(rect()).toBe("0.200 0.200 0.500 0.410"); + const nw = screen.getByRole("group", { name: "common.cropFrame.nw" }); + fireEvent.keyDown(nw, { key: "ArrowLeft" }); + expect(rect()).toBe("0.190 0.200 0.510 0.410"); + }); + + it("moves with a drag, in fractions of the picture", () => { + render(); + const frame = screen.getByRole("group", { name: "common.cropFrame.frame" }); + fireEvent.pointerDown(frame, { button: 0, clientX: 500, clientY: 300 }); + fireEvent.pointerMove(frame, { clientX: 660, clientY: 390 }); + fireEvent.pointerUp(frame, { clientX: 660, clientY: 390 }); + // 160 px of 1600 and 90 px of 900: a tenth each way. + expect(rect()).toBe("0.300 0.300 0.400 0.400"); + }); + + it("lets a press outside the frame through to the player, and keeps one on the frame", () => { + const click = vi.fn(); + render( +
+ +
, + ); + expect(screen.getByTestId("crop-frame").className).toContain("pointer-events-none"); + fireEvent.click(screen.getByRole("group", { name: "common.cropFrame.frame" })); + expect(click).not.toHaveBeenCalled(); + }); +}); diff --git a/frontend/src/components/ui/CropFrame.tsx b/frontend/src/components/ui/CropFrame.tsx new file mode 100644 index 00000000..460f7c04 --- /dev/null +++ b/frontend/src/components/ui/CropFrame.tsx @@ -0,0 +1,207 @@ +import { + useEffect, + useRef, + useState, + type KeyboardEvent, + type MouseEvent, + type PointerEvent, +} from "react"; +import { useTranslation } from "react-i18next"; +import clsx from "clsx"; +import { + contentRect, + moveCrop, + resizeCrop, + type CropHandle, + type Frac, +} from "../../lib/cropGeometry"; + +// A crop frame over a picture: drawn on the picture itself, not on the box it is shown in (a +// letterboxed video leaves bars there), in fractions of it. The frame is dragged to move, its edges +// and corners to resize; from the keyboard the frame moves with the arrows and each corner, focused, +// resizes with them (Shift for a bigger step). Outside the frame the picture is dimmed. The frame and +// its corners are groups, not buttons: nothing happens on Enter, and the name says what the arrows do. +// +// It fills its positioned parent and measures it, so the caller only places it over the picture's +// box (a