POST /api/downloads/clip takes a source (a YouTube id or a Plex rating key), a range of at most ten minutes and, for Plex, the audio track being heard. The clip becomes a per-user asset in the library like an editor clip, shareable as soon as it is ready. A YouTube clip fetches only its range (yt-dlp download_ranges: 3-7 s whatever the video's length); a Plex clip reads the library file in place. Both then go through one re-encode: frame-exact at the marks, H.264 at most 1080p with HDR tonemapped to SDR, stereo AAC, no chapter track, .mp4. Job kinds are a JobKind StrEnum now, and Re-download refuses every kind but a download.
170 lines
6.2 KiB
Python
170 lines
6.2 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.
|
|
|
|
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
|
|
|
|
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
|
|
# The transfer characteristics of HDR video (PQ, HLG): an 8-bit SDR encode must tonemap them.
|
|
_HDR_TRANSFERS = frozenset({"smpte2084", "arib-std-b67"})
|
|
# 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)'"
|
|
|
|
|
|
class ClipError(Exception):
|
|
"""A clip request that cannot be cut; `reason` is an i18n-mappable key."""
|
|
|
|
def __init__(self, reason: str):
|
|
self.reason = reason
|
|
super().__init__(reason)
|
|
|
|
|
|
def normalize(
|
|
source: str, ref: str, start_s: float, end_s: float, audio_track: int | 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."""
|
|
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")
|
|
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
|
|
return spec
|
|
|
|
|
|
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":
|
|
return s.get("color_transfer") in _HDR_TRANSFERS
|
|
return False
|
|
|
|
|
|
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,
|
|
) -> 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])
|
|
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),
|
|
]
|