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