- The cut file is measured against what its owner has left, for a YouTube range (whose fetch never reads yt-dlp's size ceiling) and for a Plex clip (which fetches nothing), before the fetch and again before it is placed; a refusal leaves the asset pending for a Retry. - A silent Plex file is cut silent; a file the probe cannot read says so. - A second job on a failed or renamed clip gets the same YouTube diagnosis and the fetched title. - The "too long" refusal names the server's cap; a contract test holds the client's copies of the clip limits to clip.py; the shipped player clients are pinned against the installed yt-dlp. - One HDR transfer set and one rating_key lookup; job status words from JobStatus; the display-name guard sees subscript writes and the clip route; stale comments and types follow the clip job.
290 lines
12 KiB
Python
290 lines
12 KiB
Python
"""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),
|
||
]
|