"""Ad-hoc clips: a marked range of a YouTube video or a Plex item, cut into a new per-user asset. A clip never needs the whole source first. A YouTube clip fetches only its range (yt-dlp's `download_ranges`; measured: 3-7 s whether the video runs four minutes or an hour, and the fetched range starts on the marked frame), and a Plex clip reads the library file in place. Either way the range then goes through ONE re-encode built here, so every clip has the same shape whatever its 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. """ import hashlib import json import math import re from pathlib import Path from app.downloads.edit import AAC_ARGS, EVEN_FRAME, INPUT_GUARD, X264_ARGS from app.plex.sync import HDR_TRANSFERS SOURCES = ("youtube", "plex") # Owner's cap: a clip is a moment, not a way to export a whole film to a public link. MAX_CLIP_S = 600 MIN_CLIP_S = 1 MAX_HEIGHT = 1080 # What yt-dlp fetches the range in (formats.normalize fills the rest): the clip's own ceiling, so the # re-encode never upscales and never throws away a better source than it keeps. YOUTUBE_FETCH_SPEC = {"mode": "av", "max_height": MAX_HEIGHT, "container": "mp4"} # Bump when the spec → output-bytes mapping changes, so a cached clip re-derives. _SIG_VERSION = 1 _YOUTUBE_ID = re.compile(r"[A-Za-z0-9_-]{11}") _RATING_KEY = re.compile(r"[0-9]{1,32}") _MAX_AUDIO_TRACK = 63 # HDR → SDR as measured on the owner's 4K HDR files (2026-10-10): linearise, tonemap (hable), # convert the primaries and transfer to BT.709 and back to 8-bit 4:2:0. _TONEMAP = ( "zscale=t=linear:npl=100,format=gbrpf32le,zscale=p=bt709,tonemap=hable:desat=0," "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): """A clip request that cannot be cut; `reason` is an i18n-mappable key, `params` the numbers its message names (the cap itself, so the client never says a stale one).""" def __init__(self, reason: str, **params: float): self.reason = reason self.params = params super().__init__(reason) def normalize( 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 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 if not isinstance(ref, str) or not pattern.fullmatch(ref): raise ClipError("bad_source") if not (math.isfinite(start_s) and math.isfinite(end_s)) or start_s < 0: raise ClipError("bad_range") start, end = round(start_s, 3), round(end_s, 3) if end - start < MIN_CLIP_S: raise ClipError("too_short") if end - start > MAX_CLIP_S: raise ClipError("too_long", limit_s=MAX_CLIP_S) spec: dict = {"source": source, "ref": ref, "start_s": start, "end_s": end} if source == "plex" and audio_track is not None: 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"] def sig(spec: dict) -> str: """The clip's cache signature: the spec itself, versioned.""" body = json.dumps({**spec, "_v": _SIG_VERSION}, sort_keys=True, separators=(",", ":")) return hashlib.sha256(body.encode()).hexdigest()[:24] def asset_ref(user_id: int, spec: dict) -> str: """The asset's `source_ref`. The user id makes a clip private to whoever cut it, like an edit: never dedup-shared, never cache-served to someone else.""" return f"{user_id}:{spec['source']}:{spec['ref']}" def reference_url(spec: dict) -> str | None: """Where a YouTube clip came from: its video at the clip's start. A Plex clip has no public page.""" if spec.get("source") != "youtube" or not spec.get("ref"): return None return f"https://www.youtube.com/watch?v={spec['ref']}&t={int(spec.get('start_s') or 0)}s" def is_hdr(probe: dict) -> bool: """Whether the first video stream of an ffprobe `-show_streams` JSON carries an HDR transfer.""" for s in probe.get("streams") or []: if s.get("codec_type") == "video": # PQ / HLG: an 8-bit SDR encode must tonemap them (the one set the Plex stream uses). return s.get("color_transfer") in HDR_TRANSFERS 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") def build_cmd( src: Path, dest: Path, start_s: float, dur_s: float, *, 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. 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", "-y", "-hide_banner", "-loglevel", "error", "-nostdin", "-progress", "pipe:1", "-nostats", "-ss", f"{start_s:.3f}", *INPUT_GUARD, "-i", str(src), "-t", f"{dur_s:.3f}", "-map", "0:v:0", "-map", audio, "-sn", "-dn", # A source's chapters would ride into the .mp4 as a data track of their own (an mkv's # chapter list came out as `bin_data`); a clip of a moment has no chapters to keep. "-map_chapters", "-1", "-vf", vf, *X264_ARGS, "-threads", str(threads), *AAC_ARGS, "-ac", "2", "-movflags", "+faststart", str(dest), ]