feat(downloads): a clip or an edit can be a GIF, shared as a picture
A clip and an editor range can come out as a silent GIF (long side 320/480/640, 12 fps, at most 30 s, one palette per GIF); one over 30 MB is not kept. A shared picture is served inline under the preview's sandbox, and a GIF's link card is the GIF itself up to 15 MB, the still for Facebook's crawler.
This commit is contained in:
@@ -12,6 +12,9 @@ given in fractions of the box it was drawn in, with that box's aspect: the YouTu
|
||||
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.
|
||||
|
||||
A clip may be a GIF instead (`spec["gif"]`, downloads/gif): the same range, crop and tonemap, then a
|
||||
silent animated picture of at most 30 s rather than a video.
|
||||
|
||||
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.
|
||||
@@ -24,6 +27,7 @@ import re
|
||||
from pathlib import Path
|
||||
from typing import Literal
|
||||
|
||||
from app.downloads import gif as gifmod
|
||||
from app.downloads.edit import AAC_ARGS, EVEN_FRAME, INPUT_GUARD, X264_ARGS
|
||||
from app.plex.sync import HDR_TRANSFERS
|
||||
|
||||
@@ -70,6 +74,8 @@ ClipReason = Literal[
|
||||
"not_found",
|
||||
"media_missing",
|
||||
"past_end",
|
||||
"bad_gif",
|
||||
"gif_too_long",
|
||||
]
|
||||
|
||||
|
||||
@@ -90,9 +96,12 @@ def normalize(
|
||||
end_s: float,
|
||||
audio_track: int | None = None,
|
||||
crop: dict | None = None,
|
||||
gif_side: object = 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."""
|
||||
decimals, so the same marks always give the same cache key. A `gif_side` makes it a GIF of that
|
||||
size (`downloads/gif`), held to the GIF's own length cap; without one it is the video clip it
|
||||
always was, spec and cache key alike. Raises ClipError."""
|
||||
if source not in SOURCES:
|
||||
raise ClipError("bad_source")
|
||||
pattern = _YOUTUBE_ID if source == "youtube" else _RATING_KEY
|
||||
@@ -103,9 +112,20 @@ def normalize(
|
||||
start, end = round(start_s, 3), round(end_s, 3)
|
||||
if end - start < MIN_CLIP_S:
|
||||
raise ClipError("too_short")
|
||||
side = None
|
||||
if gif_side is not None:
|
||||
try:
|
||||
side = gifmod.normalize_side(gif_side)
|
||||
except ValueError as e:
|
||||
raise ClipError("bad_gif") from e
|
||||
if side is not None and end - start > gifmod.MAX_S:
|
||||
# Its own reason: a GIF's cap is said in seconds, a clip's in minutes.
|
||||
raise ClipError("gif_too_long", limit_s=gifmod.MAX_S)
|
||||
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 side is not None:
|
||||
spec["gif"] = {"side": side}
|
||||
if source == "plex" and audio_track is not None:
|
||||
if not 0 <= audio_track <= _MAX_AUDIO_TRACK:
|
||||
raise ClipError("bad_audio")
|
||||
@@ -257,6 +277,12 @@ def audio_track_count(probe: dict) -> int:
|
||||
return sum(1 for s in probe.get("streams") or [] if s.get("codec_type") == "audio")
|
||||
|
||||
|
||||
def gif_pre(crop: str, hdr: bool) -> str:
|
||||
"""What a GIF clip's frames go through before the GIF's own size and palette: the crop, then an HDR
|
||||
source's tonemap (a palette made from PQ frames is as washed out as the frames are)."""
|
||||
return ",".join([*([crop] if crop else []), *([_TONEMAP] if hdr else [])])
|
||||
|
||||
|
||||
def build_cmd(
|
||||
src: Path,
|
||||
dest: Path,
|
||||
|
||||
@@ -109,6 +109,12 @@ def normalize_edit_spec(spec: dict | None) -> dict:
|
||||
spec = spec or {}
|
||||
out: dict = {}
|
||||
|
||||
# A GIF output (downloads/gif) keeps its size; one it cannot name is dropped here and refused by
|
||||
# the service, which reads the raw spec for exactly that (`gif_side`).
|
||||
side = gif_side(spec)
|
||||
if side is not None:
|
||||
out["gif"] = {"side": side}
|
||||
|
||||
# Crop applies to both a single trim and a multi-segment join.
|
||||
crop = spec.get("crop") or {}
|
||||
if crop:
|
||||
@@ -197,6 +203,21 @@ def normalize_edit_spec(spec: dict | None) -> dict:
|
||||
return out
|
||||
|
||||
|
||||
def gif_side(spec: dict | None) -> int | None:
|
||||
"""The GIF size a raw edit spec asks for, or None when it asks for none — or for one that is not
|
||||
offered (the service tells the two apart by `"gif" in spec`)."""
|
||||
# Imported here: `gif` builds its command on this module's input guard.
|
||||
from app.downloads import gif as gifmod
|
||||
|
||||
raw = (spec or {}).get("gif")
|
||||
if not isinstance(raw, dict):
|
||||
return None
|
||||
try:
|
||||
return gifmod.normalize_side(raw.get("side"))
|
||||
except ValueError: # silent-ok: an unoffered size is refused by the service (`bad_gif`)
|
||||
return None
|
||||
|
||||
|
||||
def edit_sig(spec: dict) -> str:
|
||||
"""Stable 24-char signature of a (normalized) edit spec — the per-user cache identity."""
|
||||
payload = json.dumps(normalize_edit_spec(spec), sort_keys=True, separators=(",", ":"))
|
||||
@@ -208,8 +229,8 @@ def has_crop(spec: dict) -> bool:
|
||||
|
||||
|
||||
def needs_reencode(spec: dict) -> bool:
|
||||
"""A crop always re-encodes; a trim re-encodes only when the user asked for an accurate cut."""
|
||||
return bool(spec.get("crop")) or bool(spec.get("accurate"))
|
||||
"""A crop or a GIF always re-encodes; a trim only when the user asked for an accurate cut."""
|
||||
return bool(spec.get("crop")) or bool(spec.get("gif")) or bool(spec.get("accurate"))
|
||||
|
||||
|
||||
def clip_duration(spec: dict, source_duration: int | None) -> int | None:
|
||||
|
||||
@@ -68,6 +68,10 @@ CONVERTED_TOO_BIG_MESSAGE = (
|
||||
"free up space, then try again."
|
||||
)
|
||||
UPLOAD_MISSING_MESSAGE = "The uploaded file is missing."
|
||||
# A GIF over `gif.MAX_BYTES`: not the storage left, a cap of the GIF's own (its own category).
|
||||
GIF_TOO_BIG_MESSAGE = (
|
||||
"The GIF came out larger than a GIF may be — mark a shorter range or choose a smaller size."
|
||||
)
|
||||
NETWORK_REDACTED_MESSAGE = "The server could not reach that address."
|
||||
|
||||
# (marker, category) — first match wins, so order most-specific → most-general. Matching is
|
||||
@@ -77,6 +81,8 @@ _RULES: tuple[tuple[str, str], ...] = (
|
||||
# first — it is our wording, so it can never be a false match on an upstream message.
|
||||
("download storage is full", "quota_exceeded"),
|
||||
("larger than the download storage you have left", "quota_exceeded"),
|
||||
# Our own refusal of a GIF over its size cap — the range or the size, not the storage left.
|
||||
("larger than a gif may be", "gif_too_big"),
|
||||
# Our own redaction of a transport failure (see NETWORK_REDACTED_MESSAGE) — our wording too.
|
||||
(NETWORK_REDACTED_MESSAGE.lower(), "network"),
|
||||
# Our own verdicts on a post link (adapters.py) — our wording too: its page holds no single video,
|
||||
|
||||
@@ -0,0 +1,124 @@
|
||||
"""The GIF output of a clip or an edit: its limits and the one ffmpeg command every GIF goes through.
|
||||
|
||||
A GIF is a picture, not a video: no sound, a library `file` that previews as an image (ffprobe's `gif`
|
||||
format is one of `probe._IMAGE_FORMATS`), and never the source of another edit. The numbers were
|
||||
measured on real sources (2026-11-09/11):
|
||||
|
||||
- 12 frames a second, the long side capped (320 / 480 / 640): a width cap made a phone's portrait
|
||||
video three times the size of a landscape one (44 MB for 21 s at "480").
|
||||
- One palette per GIF, made from the GIF's own frames (`palettegen` → `paletteuse`, light ordered
|
||||
dither): the generic 256-colour palette bands every gradient.
|
||||
- 30 s at most. 480 px fills 12–29 MB in 30 s, depending on the picture; a GIF over `MAX_BYTES` is
|
||||
not kept, and the owner is told to cut less or smaller.
|
||||
- A chat link preview: Teams moves a GIF `og:image` up to 15 MB (22 MB showed nothing), so
|
||||
`PREVIEW_MAX_BYTES` decides whether the share page offers the GIF itself or its still (`ensure_still`).
|
||||
"""
|
||||
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
from app.downloads.edit import INPUT_GUARD
|
||||
|
||||
SIDES = (320, 480, 640)
|
||||
DEFAULT_SIDE = 480
|
||||
FPS = 12
|
||||
MAX_S = 30
|
||||
PREVIEW_MAX_BYTES = 15 * 1024 * 1024
|
||||
MAX_BYTES = 30 * 1024 * 1024
|
||||
|
||||
# The still is cut from the GIF we made, read with the GIF demuxer alone: a library file named .gif
|
||||
# whose bytes are a playlist must not open the paths inside it (see `edit.MEDIA_DEMUXERS`).
|
||||
_GIF_GUARD = ["-format_whitelist", "gif"]
|
||||
_STILL_TIMEOUT_S = 30
|
||||
|
||||
|
||||
def normalize_side(side: object) -> int:
|
||||
"""One of `SIDES`, or ValueError. A bool or a float is not a size, even when it equals one."""
|
||||
if type(side) is not int or side not in SIDES:
|
||||
raise ValueError(side)
|
||||
return side
|
||||
|
||||
|
||||
def _scale(side: int) -> str:
|
||||
"""Fit the long side into `side`, never upscaling a smaller picture; the short side keeps the
|
||||
aspect (-1: a GIF has no chroma grid that wants it even)."""
|
||||
return (
|
||||
f"scale=w='if(gte(iw,ih),min({side},iw),-1)'"
|
||||
f":h='if(gte(iw,ih),-1,min({side},ih))':flags=lanczos"
|
||||
)
|
||||
|
||||
|
||||
def filter_graph(pre: str, side: int) -> str:
|
||||
"""The GIF's filters: `pre` (a crop, an HDR tonemap) on the source's own frame first, then the
|
||||
frame rate and the size, then a palette made from these frames and applied to them."""
|
||||
head = ",".join([*([pre] if pre else []), f"fps={FPS}", _scale(side)])
|
||||
return (
|
||||
f"[0:v:0]{head},split[a][b];"
|
||||
"[a]palettegen=stats_mode=diff[p];"
|
||||
"[b][p]paletteuse=dither=bayer:bayer_scale=5:diff_mode=rectangle"
|
||||
)
|
||||
|
||||
|
||||
def build_cmd(
|
||||
src: Path, dest: Path, start_s: float, dur_s: float, *, pre: str, side: int
|
||||
) -> list[str]:
|
||||
"""ffmpeg for one GIF: an input seek (the GIF starts on the marked frame), the first video stream
|
||||
through `filter_graph`, nothing else — no sound, subtitles, data or chapters."""
|
||||
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}",
|
||||
"-filter_complex",
|
||||
filter_graph(pre, side),
|
||||
"-an",
|
||||
"-sn",
|
||||
"-dn",
|
||||
"-map_chapters",
|
||||
"-1",
|
||||
str(dest),
|
||||
]
|
||||
|
||||
|
||||
def size(path: Path) -> tuple[int, int] | None:
|
||||
"""(width, height) from a GIF's header — ffprobe reports none for an image format, and the share
|
||||
page's preview card wants them. None for anything that is not a GIF."""
|
||||
try:
|
||||
with path.open("rb") as f:
|
||||
head = f.read(10)
|
||||
# silent-ok: no size is what an unreadable file has; the card is then built without one.
|
||||
except OSError:
|
||||
return None
|
||||
if len(head) < 10 or head[:6] not in (b"GIF87a", b"GIF89a"):
|
||||
return None
|
||||
return int.from_bytes(head[6:8], "little"), int.from_bytes(head[8:10], "little")
|
||||
|
||||
|
||||
def ensure_still(path: Path) -> Path | None:
|
||||
"""A JPEG still of the GIF's first frame beside it (`<name>.jpg`, the poster sidecar every library
|
||||
file uses): the link preview for a chat that shows no GIF (Messenger) or a GIF too big to move.
|
||||
None when it cannot be cut — the card is then built without a picture."""
|
||||
dest = path.with_suffix(".jpg")
|
||||
cmd = [
|
||||
"ffmpeg", "-y", "-hide_banner", "-loglevel", "error", "-nostdin",
|
||||
*_GIF_GUARD, "-i", str(path), "-frames:v", "1", "-q:v", "3", str(dest),
|
||||
] # fmt: skip
|
||||
try:
|
||||
subprocess.run(cmd, capture_output=True, timeout=_STILL_TIMEOUT_S, check=True)
|
||||
# silent-ok: the still is best-effort; without it the card has no picture, which is true.
|
||||
except (subprocess.TimeoutExpired, subprocess.CalledProcessError, OSError):
|
||||
dest.unlink(missing_ok=True)
|
||||
return None
|
||||
return dest if dest.exists() else None
|
||||
@@ -171,7 +171,7 @@ def public_meta(
|
||||
|
||||
Resolves the owner's per-download display overrides (custom title/channel) over the asset's
|
||||
auto-extracted values, and surfaces the source + any extra reference links as clickable URLs."""
|
||||
from app.downloads import mediakind, service
|
||||
from app.downloads import mediakind, preview, service
|
||||
|
||||
source_url = service.reference_url(job, asset)
|
||||
# Audio-only assets (mp3 rips, extracted audio) can't play inside a <video> element on WebKit
|
||||
@@ -189,6 +189,8 @@ def public_meta(
|
||||
"height": asset.height,
|
||||
"audio": asset.media_kind == mediakind.AUDIO,
|
||||
"media_kind": asset.media_kind,
|
||||
# A picture is shown as itself (the file route serves it inline); other files download.
|
||||
"preview": "image" if preview.kind(asset.media_kind, asset.container) == "image" else None,
|
||||
"size_bytes": asset.size_bytes,
|
||||
"container": asset.container,
|
||||
"allow_download": link.allow_download,
|
||||
|
||||
+44
-11
@@ -14,8 +14,9 @@ from pathlib import Path
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.config import settings
|
||||
from app.downloads import gif as gifmod
|
||||
from app.downloads import links as linksmod
|
||||
from app.downloads import storage
|
||||
from app.downloads import preview, storage
|
||||
from app.downloads.models import DownloadJob, DownloadLink, FileFolder, MediaAsset
|
||||
|
||||
_OG_BLOCK = re.compile(r"<!--OG:START-->.*?<!--OG:END-->", re.DOTALL)
|
||||
@@ -71,8 +72,33 @@ def _jpeg_size(path: Path) -> tuple[int, int] | None:
|
||||
return None
|
||||
|
||||
|
||||
def _watch_tags(token: str, db: Session) -> str | None:
|
||||
"""Build the OG/Twitter meta block for a watch token, or None to keep the generic card."""
|
||||
# Facebook's crawler (Messenger's previews): it never showed a GIF `og:image` and did show a JPEG one
|
||||
# (measured 2026-11-11), so it is given the still. Matched on its documented agent names.
|
||||
_FACEBOOK_AGENTS = ("facebookexternalhit", "facebot")
|
||||
|
||||
|
||||
def _is_facebook(user_agent: str | None) -> bool:
|
||||
agent = (user_agent or "").lower()
|
||||
return any(name in agent for name in _FACEBOOK_AGENTS)
|
||||
|
||||
|
||||
def _moving_image(
|
||||
token: str, asset: MediaAsset, user_agent: str | None
|
||||
) -> tuple[str, tuple[int, int] | None] | None:
|
||||
"""The shared GIF itself as the card's picture, and its size from its header — when a chat can
|
||||
move it: not for Facebook's crawler, and not over `gif.PREVIEW_MAX_BYTES` (Teams moved 15 MB and
|
||||
showed nothing at 22). None: the card takes the still, as any other file's does."""
|
||||
if preview.kind(asset.media_kind, asset.container) != "image" or asset.container != "gif":
|
||||
return None
|
||||
if _is_facebook(user_agent) or (asset.size_bytes or 0) > gifmod.PREVIEW_MAX_BYTES:
|
||||
return None
|
||||
path = storage.safe_abs_path(settings.files_root, asset.rel_path)
|
||||
return f"{settings.app_base}/api/public/watch/{token}/file", gifmod.size(path) if path else None
|
||||
|
||||
|
||||
def _watch_tags(token: str, db: Session, user_agent: str | None = None) -> str | None:
|
||||
"""Build the OG/Twitter meta block for a watch token, or None to keep the generic card.
|
||||
`user_agent` is the crawler's: a GIF's card is the GIF for a chat that moves it (`_moving_image`)."""
|
||||
link = linksmod.find(db, token)
|
||||
if link is None or linksmod.is_expired(link):
|
||||
return None
|
||||
@@ -112,7 +138,12 @@ def _watch_tags(token: str, db: Session) -> str | None:
|
||||
# non-password links, so the poster needs no grant.
|
||||
yt_id_match = re.search(r"i\.ytimg\.com/vi/([\w-]+)/", asset.thumbnail_url or "")
|
||||
image_dims: tuple[int, int] | None = None
|
||||
if yt_id_match:
|
||||
image_type = "image/jpeg"
|
||||
moving = _moving_image(token, asset, user_agent)
|
||||
if moving:
|
||||
image, image_dims = moving
|
||||
image_type = "image/gif"
|
||||
elif yt_id_match:
|
||||
image: str | None = f"https://i.ytimg.com/vi/{yt_id_match.group(1)}/hqdefault.jpg"
|
||||
image_dims = (480, 360)
|
||||
elif asset.poster_path:
|
||||
@@ -133,12 +164,12 @@ def _watch_tags(token: str, db: Session) -> str | None:
|
||||
if image:
|
||||
# A real thumbnail → a large image card; without one, a plain (text) card is served.
|
||||
tags.append(_tag("og:image", image))
|
||||
# Declare the image is a public HTTPS JPEG with known dimensions, so FB (which fetches
|
||||
# og:image asynchronously on first scrape) includes it instead of unfurling title-only. For a
|
||||
# poster we read the real dimensions from its JPEG header; the ytimg hqdefault is a fixed 480×360.
|
||||
# Declare the image's type and known dimensions, so FB (which fetches og:image
|
||||
# asynchronously on first scrape) includes it instead of unfurling title-only. For a poster
|
||||
# we read the real dimensions from its JPEG header; the ytimg hqdefault is a fixed 480×360.
|
||||
if image.startswith("https://"):
|
||||
tags.append(_tag("og:image:secure_url", image))
|
||||
tags.append(_tag("og:image:type", "image/jpeg"))
|
||||
tags.append(_tag("og:image:type", image_type))
|
||||
if image_dims is None and image.endswith("poster.jpg") and asset.poster_path:
|
||||
# safe_abs_path returns None if the poster file is missing (gc'd) or escapes the root —
|
||||
# guard it, else _jpeg_size(None) would raise an uncaught AttributeError on this public page.
|
||||
@@ -178,11 +209,13 @@ def _folder_tags(db: Session, link: DownloadLink, url: str) -> str | None:
|
||||
)
|
||||
|
||||
|
||||
def render_watch_html(token: str, page_html: Path, db: Session) -> str | None:
|
||||
def render_watch_html(
|
||||
token: str, page_html: Path, db: Session, user_agent: str | None = None
|
||||
) -> str | None:
|
||||
"""Return the watch page (watch.html) with the OG block replaced for this token, or None to
|
||||
serve the generic page unchanged (invalid/expired/not-ready link, or a template without the
|
||||
markers)."""
|
||||
tags = _watch_tags(token, db)
|
||||
markers). `user_agent` is the fetcher's, which picks a GIF's card picture."""
|
||||
tags = _watch_tags(token, db, user_agent)
|
||||
if tags is None:
|
||||
return None
|
||||
text = page_html.read_text(encoding="utf-8")
|
||||
|
||||
@@ -16,6 +16,7 @@ from app.db import SessionLocal
|
||||
from app.downloads import clip as clipmod
|
||||
from app.downloads import edit as editmod
|
||||
from app.downloads import errors as dlerrors
|
||||
from app.downloads import gif as gifmod
|
||||
from app.downloads import adapters, credentials, formats, mediakind, playable, probe, quota
|
||||
from app.downloads import backfill, egress
|
||||
from app.downloads import service, storage
|
||||
@@ -874,7 +875,8 @@ def _process_edit(job_id: int, asset_id: int) -> None:
|
||||
|
||||
src_ext = src_path.suffix.lstrip(".").lower() or "mp4"
|
||||
crop = editmod.has_crop(edit_spec)
|
||||
out_ext = "mp4" if editmod.needs_reencode(edit_spec) else src_ext
|
||||
gif = edit_spec.get("gif")
|
||||
out_ext = "gif" if gif else "mp4" if editmod.needs_reencode(edit_spec) else src_ext
|
||||
total_s = editmod.clip_duration(edit_spec, src_dur) or src_dur or 0
|
||||
staging = _edit_staging_dir(asset_id)
|
||||
|
||||
@@ -885,7 +887,19 @@ def _process_edit(job_id: int, asset_id: int) -> None:
|
||||
dest_staging = staging / f"out.{out_ext}"
|
||||
|
||||
_set_job(job_id, phase="editing", progress=0, speed_bps=None, eta_s=None)
|
||||
if edit_spec.get("segments"):
|
||||
if gif:
|
||||
# One range with an end (the service's check), cropped first if a frame was drawn.
|
||||
trim = edit_spec["trim"]
|
||||
start = trim.get("start_s") or 0.0
|
||||
cmd = gifmod.build_cmd(
|
||||
src_path,
|
||||
dest_staging,
|
||||
start,
|
||||
trim["end_s"] - start,
|
||||
pre=editmod.crop_filter(edit_spec["crop"]) if crop else "",
|
||||
side=gif["side"],
|
||||
)
|
||||
elif edit_spec.get("segments"):
|
||||
cmd, prep = editmod.build_concat_plan(
|
||||
src_path, dest_staging, edit_spec, out_ext, staging
|
||||
)
|
||||
@@ -901,9 +915,12 @@ def _process_edit(job_id: int, asset_id: int) -> None:
|
||||
)
|
||||
if not dest_staging.exists():
|
||||
raise StatedFailure("ffmpeg produced no output file")
|
||||
# A fast trim stream-copies the source's codecs and container, so the clip is only as
|
||||
# playable as its source: hold it to the same rule as every other ready file.
|
||||
dest_staging = _ensure_phone_safe(job_id, dest_staging, staging, "editing", total_s)
|
||||
if gif:
|
||||
_hold_gif_cap(dest_staging)
|
||||
else:
|
||||
# A fast trim stream-copies the source's codecs and container, so the clip is only as
|
||||
# playable as its source: hold it to the same rule as every other ready file.
|
||||
dest_staging = _ensure_phone_safe(job_id, dest_staging, staging, "editing", total_s)
|
||||
out_ext = dest_staging.suffix.lstrip(".").lower() or out_ext
|
||||
# New storage on the owner's footprint, held to what is left by its real size, as a
|
||||
# download and a player clip are.
|
||||
@@ -917,8 +934,12 @@ def _process_edit(job_id: int, asset_id: int) -> None:
|
||||
storage.place_file(dest_staging, settings.files_root, rel)
|
||||
final = storage.abs_path(settings.files_root, rel)
|
||||
size = final.stat().st_size if final.exists() else None
|
||||
# The clip's real frame: a re-encode evens an odd crop or source (`editmod.EVEN_FRAME`).
|
||||
# The clip's real frame: a re-encode evens an odd crop or source (`editmod.EVEN_FRAME`). A
|
||||
# GIF's is in its header (ffprobe reports no stream size for an image format).
|
||||
_, _, out_w, out_h, _ = probe.probe_media(final)
|
||||
if gif:
|
||||
out_w, out_h = gifmod.size(final) or (None, None)
|
||||
poster = _gif_still(rel) if gif else None
|
||||
# Unsized, a crop's own size stands in, cut from the frame the filters saw.
|
||||
cut = (
|
||||
editmod.crop_stored_size(edit_spec["crop"], *_filter_frame(src_path, src_w, src_h))
|
||||
@@ -940,6 +961,8 @@ def _process_edit(job_id: int, asset_id: int) -> None:
|
||||
asset.width, asset.height = cut
|
||||
else:
|
||||
asset.width, asset.height = (None, None) if crop else (src_w, src_h)
|
||||
if poster:
|
||||
asset.poster_path = poster
|
||||
asset.error = None
|
||||
asset.last_access_at = datetime.now(timezone.utc)
|
||||
job = db.get(DownloadJob, job_id)
|
||||
@@ -1057,6 +1080,19 @@ def _clip_crop(spec: dict, data: dict) -> str:
|
||||
return crop
|
||||
|
||||
|
||||
def _hold_gif_cap(out: Path) -> None:
|
||||
"""A GIF over its own size cap is not kept (`gif.MAX_BYTES`): a stated failure that names the
|
||||
range and the size, the two things that make it smaller."""
|
||||
if out.stat().st_size > gifmod.MAX_BYTES:
|
||||
raise StatedFailure(dlerrors.GIF_TOO_BIG_MESSAGE)
|
||||
|
||||
|
||||
def _gif_still(rel: str) -> str | None:
|
||||
"""The placed GIF's still (`gif.ensure_still`), as the poster's rel path beside it."""
|
||||
still = gifmod.ensure_still(storage.abs_path(settings.files_root, rel))
|
||||
return str(Path(rel).with_suffix(".jpg")) if still else None
|
||||
|
||||
|
||||
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."""
|
||||
@@ -1086,27 +1122,39 @@ def _process_clip(job_id: int, asset_id: int) -> None:
|
||||
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"
|
||||
gif = spec.get("gif")
|
||||
hdr = clipmod.is_hdr(data)
|
||||
out = staging / ("clip.gif" if gif else "clip.mp4")
|
||||
_set_job(job_id, phase="editing", progress=0, speed_bps=None, eta_s=None)
|
||||
editmod.run_ffmpeg(
|
||||
clipmod.build_cmd(
|
||||
cmd = (
|
||||
gifmod.build_cmd(
|
||||
src, out, start, total_s, pre=clipmod.gif_pre(crop, hdr), side=gif["side"]
|
||||
)
|
||||
if gif
|
||||
else clipmod.build_cmd(
|
||||
src,
|
||||
out,
|
||||
start,
|
||||
total_s,
|
||||
audio_track=audio_track,
|
||||
hdr=clipmod.is_hdr(data),
|
||||
hdr=hdr,
|
||||
threads=editmod.encode_thread_cap(),
|
||||
crop=crop,
|
||||
),
|
||||
)
|
||||
)
|
||||
editmod.run_ffmpeg(
|
||||
cmd,
|
||||
total_s,
|
||||
on_progress=_ffmpeg_progress_writer(job_id, "editing"),
|
||||
should_cancel=_cancel_poll(job_id),
|
||||
)
|
||||
if not out.exists():
|
||||
raise StatedFailure("ffmpeg produced no output file")
|
||||
# The phone-safe shape by construction; still held to the rule every ready file meets.
|
||||
out = _ensure_phone_safe(job_id, out, staging, "editing", round(total_s))
|
||||
if gif:
|
||||
_hold_gif_cap(out)
|
||||
else:
|
||||
# The phone-safe shape by construction; still held to the rule every ready file meets.
|
||||
out = _ensure_phone_safe(job_id, out, staging, "editing", round(total_s))
|
||||
# Held to the cap by its real size: a range fetch passes yt-dlp's `max_filesize` to its
|
||||
# ffmpeg downloader, which never reads it, and a Plex clip fetches nothing at all. Read
|
||||
# again, since other jobs of this owner may have landed during the encode.
|
||||
@@ -1118,6 +1166,8 @@ def _process_clip(job_id: int, asset_id: int) -> None:
|
||||
storage.place_file(out, settings.files_root, rel)
|
||||
final = storage.abs_path(settings.files_root, rel)
|
||||
vcodec, acodec, width, height, _ = probe.probe_media(final)
|
||||
if gif:
|
||||
width, height = gifmod.size(final) or (None, None)
|
||||
|
||||
with SessionLocal() as db:
|
||||
asset = db.get(MediaAsset, asset_id)
|
||||
@@ -1135,8 +1185,10 @@ def _process_clip(job_id: int, asset_id: int) -> None:
|
||||
asset.duration_s = round(total_s)
|
||||
asset.media_kind = mediakind.derive(vcodec, acodec)
|
||||
# The clip's own frame as its poster: the link preview shows the moment, and a Plex
|
||||
# item has no public thumbnail at all.
|
||||
asset.poster_path = editmod.ensure_poster(asset, settings.files_root)
|
||||
# item has no public thumbnail at all. A GIF's is its first frame, cut from the GIF.
|
||||
asset.poster_path = (
|
||||
_gif_still(rel) if gif else editmod.ensure_poster(asset, settings.files_root)
|
||||
)
|
||||
asset.error = None
|
||||
asset.last_access_at = datetime.now(timezone.utc)
|
||||
fields: dict = {"status": JobStatus.DONE, "progress": 100, "phase": None}
|
||||
|
||||
@@ -45,6 +45,16 @@ TEXT_EXTS: frozenset[str] = frozenset(
|
||||
|
||||
TEXT_TYPE = "text/plain; charset=utf-8"
|
||||
|
||||
# Every preview served inline — the library's viewer and a share link's picture — goes with these.
|
||||
# `sandbox` leaves the answer no script and an opaque origin, whatever the file turns out to hold (a
|
||||
# "gif" that is really a page); `frame-ancestors 'self'` lets the app's viewer frame a PDF (every
|
||||
# other answer keeps the app-wide 'none', main.py), with X-Frame-Options saying the same for old
|
||||
# engines.
|
||||
SANDBOX_HEADERS: dict[str, str] = {
|
||||
"Content-Security-Policy": "sandbox; frame-ancestors 'self'",
|
||||
"X-Frame-Options": "SAMEORIGIN",
|
||||
}
|
||||
|
||||
# A text preview is the file's head: enough to read, never a whole log loaded into a tab.
|
||||
TEXT_PREVIEW_BYTES = 1 << 20
|
||||
# A NUL byte in the first this-many bytes means the "text" is not text.
|
||||
|
||||
@@ -23,6 +23,7 @@ from app.downloads import adapters
|
||||
from app.downloads import clip as clipmod
|
||||
from app.downloads import credentials
|
||||
from app.downloads import edit as editmod
|
||||
from app.downloads import gif as gifmod
|
||||
from app.downloads import folders, formats, mediakind, quota, storage
|
||||
from app.downloads.status import HOLDING, JobKind, JobStatus
|
||||
from app.models import DISPLAY_TEXT_MAX, User
|
||||
@@ -42,14 +43,18 @@ from app.userscope import owned_by, owned_row
|
||||
|
||||
# Every reason an edit is refused; the UI names each one (`downloads.errors.edit.<reason>`, held by
|
||||
# the frontend's backendIds contract, which reads this Literal).
|
||||
EditReason = Literal["source_not_ready", "not_media", "empty_edit"]
|
||||
EditReason = Literal[
|
||||
"source_not_ready", "not_media", "empty_edit", "bad_gif", "gif_range", "gif_too_long"
|
||||
]
|
||||
|
||||
|
||||
class EditError(Exception):
|
||||
"""Bad edit request (empty spec / source not ready / not media); `reason` names the sentence."""
|
||||
"""Bad edit request (empty spec / source not ready / not media / a GIF it cannot make); `reason`
|
||||
names the sentence, `params` the numbers it names (a cap travels with its refusal)."""
|
||||
|
||||
def __init__(self, reason: EditReason):
|
||||
def __init__(self, reason: EditReason, **params: float):
|
||||
self.reason: EditReason = reason
|
||||
self.params = params
|
||||
super().__init__(reason)
|
||||
|
||||
|
||||
@@ -255,6 +260,18 @@ def clip_title(source_title: str | None, display_name: str | None) -> str:
|
||||
return f"{base} (clip)"[:DISPLAY_TEXT_MAX]
|
||||
|
||||
|
||||
def _check_gif_edit(spec: dict) -> None:
|
||||
"""A GIF edit is one marked range with an end, at most `gif.MAX_S` long, of a size `gif` offers
|
||||
(a size it does not offer was dropped by `normalize_edit_spec`). Raises EditError."""
|
||||
if not spec.get("gif"):
|
||||
raise EditError("bad_gif")
|
||||
trim = spec.get("trim") or {}
|
||||
if spec.get("segments") or trim.get("end_s") is None:
|
||||
raise EditError("gif_range")
|
||||
if trim["end_s"] - trim.get("start_s", 0.0) > gifmod.MAX_S:
|
||||
raise EditError("gif_too_long", limit_s=gifmod.MAX_S)
|
||||
|
||||
|
||||
def enqueue_edit(
|
||||
db: Session,
|
||||
user_id: int,
|
||||
@@ -280,6 +297,8 @@ def enqueue_edit(
|
||||
spec = editmod.normalize_edit_spec(edit_spec)
|
||||
if not spec:
|
||||
raise EditError("empty_edit")
|
||||
if "gif" in (edit_spec or {}):
|
||||
_check_gif_edit(spec)
|
||||
|
||||
quota.check_enqueue(db, user_id)
|
||||
|
||||
@@ -294,7 +313,10 @@ def enqueue_edit(
|
||||
asset.duration_s = editmod.clip_duration(spec, src_asset.duration_s)
|
||||
# A clip inherits its source's kind (a video edit stays video, an audio trim stays audio);
|
||||
# the ffmpeg edit worker doesn't re-probe codecs, so set it here where the source is known.
|
||||
asset.media_kind = src_asset.media_kind or mediakind.VIDEO
|
||||
# A GIF is a picture whatever it was cut from (downloads/gif).
|
||||
asset.media_kind = (
|
||||
mediakind.FILE if spec.get("gif") else src_asset.media_kind or mediakind.VIDEO
|
||||
)
|
||||
|
||||
job = DownloadJob(
|
||||
user_id=user_id,
|
||||
@@ -384,7 +406,8 @@ def enqueue_clip(
|
||||
asset.uploader = facts["uploader"]
|
||||
asset.thumbnail_url = facts["thumbnail_url"]
|
||||
asset.duration_s = round(clipmod.duration(spec))
|
||||
asset.media_kind = mediakind.VIDEO
|
||||
# A GIF is a picture: no player and no editor, an image preview (downloads/gif).
|
||||
asset.media_kind = mediakind.FILE if spec.get("gif") else mediakind.VIDEO
|
||||
|
||||
job = DownloadJob(
|
||||
user_id=user_id,
|
||||
|
||||
+5
-4
@@ -387,18 +387,19 @@ async def index() -> FileResponse:
|
||||
@app.get("/watch/{token}")
|
||||
@app.get("/file/{token}")
|
||||
@app.get("/folder/{token}")
|
||||
async def share_page(token: str):
|
||||
async def share_page(token: str, request: Request):
|
||||
"""Serve the public page of a share link, with per-link Open Graph tags injected so the
|
||||
link unfurls richly (title/channel/thumbnail) in Messenger/social crawlers. A real browser
|
||||
ignores the extra tags and boots WatchPage; invalid/expired/password links fall back to the
|
||||
generic card."""
|
||||
generic card. A GIF's card picture depends on which crawler asks (`og._moving_image`), so the
|
||||
answer varies by User-Agent."""
|
||||
from app.downloads import og
|
||||
|
||||
with SessionLocal() as db:
|
||||
rendered = og.render_watch_html(token, WATCH_HTML, db)
|
||||
rendered = og.render_watch_html(token, WATCH_HTML, db, request.headers.get("user-agent"))
|
||||
if rendered is None:
|
||||
return FileResponse(WATCH_HTML, headers=INDEX_HEADERS)
|
||||
return HTMLResponse(rendered, headers=INDEX_HEADERS)
|
||||
return HTMLResponse(rendered, headers={**INDEX_HEADERS, "Vary": "User-Agent"})
|
||||
|
||||
|
||||
@app.get("/{full_path:path}")
|
||||
|
||||
@@ -318,7 +318,9 @@ def enqueue_edit(
|
||||
except quota.QuotaExceeded as e:
|
||||
raise HTTPException(status_code=422, detail=quota_detail(e)) from e
|
||||
except service.EditError as e:
|
||||
raise HTTPException(status_code=400, detail={"code": "edit", "reason": e.reason}) from e
|
||||
raise HTTPException(
|
||||
status_code=400, detail={**e.params, "code": "edit", "reason": e.reason}
|
||||
) from e
|
||||
db.commit()
|
||||
return serialize_job(db, job)
|
||||
|
||||
@@ -341,6 +343,8 @@ class EnqueueClipIn(BaseModel):
|
||||
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
|
||||
# A GIF of this long side (downloads/gif) instead of a video; None is the video clip.
|
||||
gif_side: int | None = None
|
||||
display_name: str | None = None
|
||||
|
||||
|
||||
@@ -353,7 +357,7 @@ def enqueue_clip(
|
||||
try:
|
||||
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
|
||||
body.source, body.ref, body.start_s, body.end_s, body.audio_track, crop, body.gif_side
|
||||
)
|
||||
display_name = display_text(body.display_name)
|
||||
job = service.enqueue_clip(db, user.id, spec, display_name=display_name)
|
||||
|
||||
@@ -715,13 +715,8 @@ def get_file(
|
||||
return response
|
||||
|
||||
|
||||
# A preview's own headers. `sandbox` leaves the answer no script and an opaque origin, whatever the
|
||||
# file turns out to hold; `frame-ancestors 'self'` lets the app's viewer frame a PDF (every other
|
||||
# answer keeps the app-wide 'none', main.py), with X-Frame-Options saying the same for old engines.
|
||||
PREVIEW_HEADERS = {
|
||||
"Content-Security-Policy": "sandbox; frame-ancestors 'self'",
|
||||
"X-Frame-Options": "SAMEORIGIN",
|
||||
}
|
||||
# A preview's own headers, shared with a share link's picture (`preview.SANDBOX_HEADERS`).
|
||||
PREVIEW_HEADERS = preview.SANDBOX_HEADERS
|
||||
# Set on a text preview cut at `preview.TEXT_PREVIEW_BYTES`, so the viewer can say it shows the start.
|
||||
TRUNCATED_HEADER = "X-Preview-Truncated"
|
||||
|
||||
|
||||
@@ -16,7 +16,7 @@ from sqlalchemy.orm import Session
|
||||
from app.config import settings
|
||||
from app.db import get_db
|
||||
from app.downloads import links as linksmod
|
||||
from app.downloads import queries, storage
|
||||
from app.downloads import preview, queries, storage
|
||||
from app.downloads.status import JobStatus
|
||||
from app.ratelimit import RateLimiter, client_key
|
||||
from app.security import verify_password
|
||||
@@ -226,8 +226,9 @@ def _granted(link: "DownloadLink", token: str, g: str | None) -> None:
|
||||
|
||||
|
||||
def _serve(link: "DownloadLink", asset: "MediaAsset") -> FileResponse:
|
||||
"""Range-aware stream of a shared file. Inline by default (plays in the page); an
|
||||
`allow_download` link serves it as an attachment."""
|
||||
"""Range-aware stream of a shared file. Inline by default (a video plays and a picture shows in
|
||||
the page, and a chat's link preview can fetch the picture); an `allow_download` link serves it
|
||||
as an attachment."""
|
||||
path = storage.safe_abs_path(settings.files_root, asset.rel_path)
|
||||
if path is None:
|
||||
raise refused(404, "video_gone")
|
||||
@@ -241,6 +242,17 @@ def _serve(link: "DownloadLink", asset: "MediaAsset") -> FileResponse:
|
||||
# served with its real audio/video type, inline unless the link forces a download so it can play
|
||||
# in the watch page. Both are range-aware (FileResponse → 206) for seeking/resume.
|
||||
media_type = storage.served_media_type(asset.media_kind, asset.container)
|
||||
disposition = "attachment" if link.allow_download else "inline"
|
||||
if media_type is None and preview.kind(asset.media_kind, asset.container) == "image":
|
||||
# A picture (a GIF made from a clip, an uploaded photo) is shown as itself: its type from the
|
||||
# preview allow-list, under the preview's sandbox, so a mislabelled one still runs nothing.
|
||||
return FileResponse(
|
||||
path,
|
||||
filename=filename,
|
||||
media_type=preview.media_type("image", asset.container),
|
||||
content_disposition_type=disposition,
|
||||
headers=preview.SANDBOX_HEADERS,
|
||||
)
|
||||
if media_type is None:
|
||||
return FileResponse(
|
||||
path,
|
||||
@@ -248,7 +260,6 @@ def _serve(link: "DownloadLink", asset: "MediaAsset") -> FileResponse:
|
||||
media_type="application/octet-stream",
|
||||
content_disposition_type="attachment",
|
||||
)
|
||||
disposition = "attachment" if link.allow_download else "inline"
|
||||
return FileResponse(
|
||||
path, filename=filename, media_type=media_type, content_disposition_type=disposition
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user