fix(downloads): GIF review round 1 — shape, HDR, caps, audio, copy

- A GIF squares its pixels before the size, so an anamorphic source keeps
  its shown shape (a 720x480 DVD at 16:9 was 480x320).
- An editor GIF of an HDR upload is tonemapped, as a clip GIF is.
- The length caps are judged in whole milliseconds: a range exactly at a
  cap is no longer refused for a float hair over it.
- A GIF is made of a video only: an audio file is refused with its reason
  and the editor offers no GIF for it.
- A GIF clip keeps no audio track: the track neither splits its cache key
  nor fails it.
- The editor's GIF notes keep their live region mounted; one size reads
  as one ("about 1 MB"), none is shown with nothing kept; the size choices
  show their unit; a shared picture that fails to load says so.
- Copy: the two-sentence GIF notes keep their last stop (a copy-rule test
  now holds it); the English warns of "only a still picture".
This commit is contained in:
2026-11-12 23:24:25 +01:00
parent 0b92b40db5
commit 8845d1aa49
23 changed files with 403 additions and 78 deletions
+8 -4
View File
@@ -110,7 +110,10 @@ def normalize(
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:
# Judged in whole milliseconds, as the ends are kept: a float difference lands a hair off
# (60.529 - 30.529 = 30.000000000000004), which would refuse a range exactly at a cap.
span_ms = round((end - start) * 1000)
if span_ms < MIN_CLIP_S * 1000:
raise ClipError("too_short")
side = None
if gif_side is not None:
@@ -118,15 +121,16 @@ def normalize(
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:
if side is not None and gifmod.too_long(start, end):
# 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:
if span_ms > MAX_CLIP_S * 1000:
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:
# A GIF has no sound: the track the player was on is not part of what it is (its cache key).
if source == "plex" and audio_track is not None and side is None:
if not 0 <= audio_track <= _MAX_AUDIO_TRACK:
raise ClipError("bad_audio")
spec["audio_track"] = audio_track
+17 -2
View File
@@ -39,6 +39,19 @@ def normalize_side(side: object) -> int:
return side
def too_long(start_s: float, end_s: float) -> bool:
"""Over `MAX_S`, counted in whole milliseconds: the ends are kept to the millisecond, and their
float difference may land a hair over (60.529 - 30.529 = 30.000000000000004), which would refuse
the very 30 s the panels allow."""
return round((end_s - start_s) * 1000) > MAX_S * 1000
# A GIF has no pixel shape a viewer honours (browsers ignore its aspect byte), so a source stored
# with wide or narrow pixels (an anamorphic DVD: 720x480 shown at 16:9) is first resampled to square
# ones, or it shows squeezed. `sar` is 1 for a source that names none; even, as a 4:2:0 frame wants.
_SQUARE_PIXELS = "scale=w='trunc(iw*sar/2)*2':h=ih,setsar=1"
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)."""
@@ -50,8 +63,10 @@ def _scale(side: int) -> str:
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)])
frame rate, square pixels and the size, then a palette made from these frames and applied to them.
A crop counts the stored frame (both callers' crops are fractions of `iw`/`ih`), so it comes
before the pixels are squared."""
head = ",".join([*([pre] if pre else []), f"fps={FPS}", _SQUARE_PIXELS, _scale(side)])
return (
f"[0:v:0]{head},split[a][b];"
"[a]palettegen=stats_mode=diff[p];"
+13 -4
View File
@@ -888,15 +888,21 @@ def _process_edit(job_id: int, asset_id: int) -> None:
_set_job(job_id, phase="editing", progress=0, speed_bps=None, eta_s=None)
if gif:
# One range with an end (the service's check), cropped first if a frame was drawn.
# One range with an end (the service's check), cropped first if a frame was drawn, then
# tonemapped as a clip GIF is: an HDR upload keeps its HLG/PQ tags, and a GIF has none,
# so a palette made from those frames would be washed out.
trim = edit_spec["trim"]
start = trim.get("start_s") or 0.0
data = probe.probe_json(src_path)
cmd = gifmod.build_cmd(
src_path,
dest_staging,
start,
trim["end_s"] - start,
pre=editmod.crop_filter(edit_spec["crop"]) if crop else "",
pre=clipmod.gif_pre(
editmod.crop_filter(edit_spec["crop"]) if crop else "",
bool(data) and clipmod.is_hdr(data),
),
side=gif["side"],
)
elif edit_spec.get("segments"):
@@ -1115,14 +1121,17 @@ def _process_clip(job_id: int, asset_id: int) -> None:
raise StatedFailure("The source file could not be read.")
audio_track = spec.get("audio_track")
tracks = clipmod.audio_track_count(data)
if tracks == 0:
gif = spec.get("gif")
if gif:
# A GIF has no sound: no track is chosen, so none can be missing.
audio_track = None
elif tracks == 0:
# A silent source: the player still names its track 0, and the clip is silent too.
audio_track = None
elif audio_track is not None and audio_track >= tracks:
raise StatedFailure("The chosen audio track is not in the file.")
crop = _clip_crop(spec, data)
total_s = clipmod.duration(spec)
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)
+11 -2
View File
@@ -44,7 +44,13 @@ 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", "bad_gif", "gif_range", "gif_too_long"
"source_not_ready",
"not_media",
"empty_edit",
"bad_gif",
"gif_range",
"gif_too_long",
"gif_not_video",
]
@@ -268,7 +274,7 @@ def _check_gif_edit(spec: dict) -> None:
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:
if gifmod.too_long(trim.get("start_s", 0.0), trim["end_s"]):
raise EditError("gif_too_long", limit_s=gifmod.MAX_S)
@@ -298,6 +304,9 @@ def enqueue_edit(
if not spec:
raise EditError("empty_edit")
if "gif" in (edit_spec or {}):
# A GIF is made of the first video stream: audio has none, or only its cover picture.
if src_asset.media_kind != mediakind.VIDEO:
raise EditError("gif_not_video")
_check_gif_edit(spec)
quota.check_enqueue(db, user_id)