Compare commits

...
11 Commits
Author SHA1 Message Date
peter 77f8170153 release: 0.119.0 (feature/e61-gif) 2026-11-13 02:32:25 +01:00
peter 0fd0f1f637 release: 0.119.0 2026-11-13 02:18:55 +01:00
peter 7371cdeb94 refactor(downloads): the GIF span helper stays inside lib/gif 2026-11-13 01:42:36 +01:00
peter abb18d4d8c fix(downloads): GIF review round 3 — every length readout, live regions in place
A refused GIF's length reads rounded up everywhere the editor shows it (the segment and the kept summary of one), and the GIF notes and estimates sit in live regions that are there before GIF is picked, so a note already true then is said. Tests hold the clip panel's readout and which segment a note names; DESIGN.md claims only what the contract test holds.
2026-11-13 01:16:21 +01:00
peter 5bf13c8701 test(scheduler): runs started in a row start in that order
The scheduler tests open several runs within microseconds and read them back newest first, by start time. A container's clock on a VM can step back between two readings, which sorted the third of three runs before the second; the tests now run on a clock that never repeats or goes back.
2026-11-13 00:31:57 +01:00
peter fe6af1445f fix(downloads): GIF review round 2 — cap as sent, shown length, retry, Join reason
The panels judge a GIF's length in whole milliseconds between the ends as sent, the server's own rule, and a refused length reads rounded up. A shared picture says it failed only once its retry failed too. Join's reason shows on a key's focus; the editor's failure toasts name GIFs. The edit route's cap is test-held, and DESIGN.md records the GIF UI.
2026-11-13 00:19:53 +01:00
peter 8845d1aa49 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".
2026-11-12 23:24:25 +01:00
peter 0b92b40db5 fix(downloads): a GIF's size warning judges the size it shows 2026-11-11 02:19:56 +01:00
peter 27ccf253f5 feat(downloads): make a GIF in the clip panel and the editor, and show a shared one as itself
The clip panel and the editor get a Video / GIF choice with the GIF's size, the size it is expected
to come out at, and what that size means (no moving link preview over 15 MB, refused over 30 MB).
A range longer than a GIF may be leaves Make unavailable with its reason. The editor makes one GIF
per kept segment; Join says why it is unavailable instead of going dead. The share page shows a
picture as itself with its Download, and a picture in a folder opens above the list.
2026-11-11 02:10:07 +01:00
peter c221452a00 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.
2026-11-11 01:50:55 +01:00
peter fe38c07e7e test: the sub-view census reads each source once
Read three times, the scan ran past the 5 s test timeout when the gate
loaded the machine.
2026-11-11 01:50:54 +01:00
46 changed files with 2305 additions and 114 deletions
+7
View File
@@ -7,6 +7,13 @@ edit the release notes and re-run the generator.
Upgrading, pinning a version and rolling back: [docs/self-hosting.md](docs/self-hosting.md#upgrading).
## 0.119.0 — 2026-11-13
### New
- Make a GIF of up to 30 seconds: in a player's Clip panel, or in the Downloads editor, where each kept segment becomes a GIF of its own. Both say the expected size before it is made.
- A shared GIF's link opens the moving picture itself, with Download beside it. In Teams its preview moves when the GIF is 15 MB or less.
## 0.118.0 — 2026-11-08
### New
+1 -1
View File
@@ -1 +1 @@
0.118.0
0.119.0
+34 -4
View File
@@ -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
@@ -101,12 +110,27 @@ 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")
if end - start > MAX_CLIP_S:
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 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 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 source == "plex" and audio_track is not None:
if side is not None:
spec["gif"] = {"side": side}
# 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
@@ -257,6 +281,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,
+23 -2
View File
@@ -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:
+6
View File
@@ -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,
+139
View File
@@ -0,0 +1,139 @@
"""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 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)."""
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, 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];"
"[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
+3 -1
View File
@@ -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
View File
@@ -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")
+77 -16
View File
@@ -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,25 @@ 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, 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=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"):
cmd, prep = editmod.build_concat_plan(
src_path, dest_staging, edit_spec, out_ext, staging
)
@@ -901,9 +921,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 +940,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 +967,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 +1086,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."""
@@ -1079,34 +1121,49 @@ 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)
out = staging / "clip.mp4"
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 +1175,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 +1194,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}
+10
View File
@@ -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.
+37 -5
View File
@@ -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,24 @@ 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",
"gif_not_video",
]
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 +266,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 gifmod.too_long(trim.get("start_s", 0.0), trim["end_s"]):
raise EditError("gif_too_long", limit_s=gifmod.MAX_S)
def enqueue_edit(
db: Session,
user_id: int,
@@ -280,6 +303,11 @@ def enqueue_edit(
spec = editmod.normalize_edit_spec(edit_spec)
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)
@@ -294,7 +322,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 +415,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
View File
@@ -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}")
+6 -2
View File
@@ -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)
+2 -7
View File
@@ -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"
+15 -4
View File
@@ -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
)
+154
View File
@@ -710,6 +710,160 @@ def test_a_clip_larger_than_what_its_owner_has_left_is_refused_and_not_placed(
)
# --- a GIF clip ------------------------------------------------------------------------------------------
def test_a_gif_clip_spec_names_its_size_and_is_another_clip_than_the_video():
mp4 = clipmod.normalize("youtube", "abcdefghijk", 10, 20)
gif = clipmod.normalize("youtube", "abcdefghijk", 10, 20, gif_side=320)
assert gif == {**mp4, "gif": {"side": 320}}
# A video clip's spec (and so its cache key) is the one it always was.
assert "gif" not in mp4
assert clipmod.sig(gif) != clipmod.sig(mp4)
@pytest.mark.parametrize(
("kw", "reason", "params"),
[
({"end_s": 40.001, "gif_side": 480}, "gif_too_long", {"limit_s": 30}),
({"end_s": 20, "gif_side": 500}, "bad_gif", {}),
({"end_s": 20, "gif_side": True}, "bad_gif", {}),
],
)
def test_a_gif_clip_it_cannot_make_is_refused_with_its_reason(kw, reason, params):
with pytest.raises(clipmod.ClipError) as e:
clipmod.normalize("youtube", "abcdefghijk", 10, **kw)
assert (e.value.reason, e.value.params) == (reason, params)
def test_the_thirty_second_gif_cap_itself_is_allowed():
assert clipmod.duration(clipmod.normalize("plex", "42", 0, 30, gif_side=480)) == 30
@pytest.mark.parametrize(
("start", "end", "kw"),
[
# Each a range exactly at a cap whose float difference lands a hair past it.
(30.529, 60.529, {"gif_side": 480}),
(424.005, 1024.005, {}),
(0.001, 1.001, {}),
],
)
def test_a_range_exactly_at_a_cap_is_judged_in_milliseconds(start, end, kw):
assert end - start not in (1, 30, 600) # the float difference is off the cap itself
assert round((end - start) * 1000) in (1000, 30000, 600000)
clipmod.normalize("youtube", "abcdefghijk", start, end, **kw)
def test_a_gif_clip_keeps_no_audio_track_so_the_track_does_not_make_another_gif():
first = clipmod.normalize("plex", "42", 10, 20, audio_track=0, gif_side=480)
other = clipmod.normalize("plex", "42", 10, 20, audio_track=1, gif_side=480)
assert "audio_track" not in first
assert clipmod.sig(first) == clipmod.sig(other)
# A video clip keeps the track it was heard on.
assert clipmod.normalize("plex", "42", 10, 20, audio_track=1)["audio_track"] == 1
def test_a_gif_clip_is_queued_as_a_file_and_too_long_says_its_own_cap(
db, make_user, as_user, yt_video
):
c = as_user(make_user())
r = _clip(c, source="youtube", ref=yt_video, gif_side=480)
assert r.status_code == 200, r.text
job = r.json()
assert job["edit_spec"]["gif"] == {"side": 480}
# A GIF is a picture: no player, no editor; it previews as an image.
assert db.get(MediaAsset, job["asset"]["id"]).media_kind == "file"
r = _clip(c, source="youtube", ref=yt_video, gif_side=480, end_s=45)
assert r.status_code == 422
assert r.json()["detail"] == {"code": "clip", "reason": "gif_too_long", "limit_s": 30}
def _gif_bytes(w: int, h: int, n: int = 0) -> bytes:
return b"GIF89a" + w.to_bytes(2, "little") + h.to_bytes(2, "little") + b"\x00" * (1 + n)
@pytest.fixture
def gif_worker(worker, monkeypatch):
"""The worker stand-in, writing a GIF header where a GIF is asked for; the still is a sidecar."""
seen = worker
seen["gif_extra"] = 0
def ffmpeg(cmd, *_a, **_k):
seen["cmd"] = cmd
out = Path(cmd[-1])
out.write_bytes(_gif_bytes(270, 480, seen["gif_extra"]) if out.suffix == ".gif" else b"x")
def still(path):
dest = path.with_suffix(".jpg")
dest.write_bytes(b"\xff\xd8\xff")
return dest
def phone_safe(*_a, **_k):
raise AssertionError("a GIF is not a video: no phone-safe pass")
monkeypatch.setattr(pipeline.editmod, "run_ffmpeg", ffmpeg)
monkeypatch.setattr(pipeline.gifmod, "ensure_still", still)
monkeypatch.setattr(pipeline, "_ensure_phone_safe", phone_safe)
monkeypatch.setattr(probemod, "probe_media", lambda _p: (None, None, None, None, None))
return seen
def test_a_gif_clip_is_cut_through_the_gif_command_and_stored_as_an_image_file(
db, make_user, as_user, plex_episode, gif_worker
):
gif_worker["probe"] = {
"streams": [{"codec_type": "video", "color_transfer": "smpte2084"}, {"codec_type": "audio"}]
}
job = _clip(as_user(make_user()), source="plex", ref="777", gif_side=480, end_s=25).json()
job, asset = gif_worker["run"](job)
assert (job.status, asset.status, asset.container, asset.media_kind) == (
"done",
"ready",
"gif",
"file",
)
assert asset.rel_path.endswith(".gif")
assert (asset.width, asset.height) == (270, 480)
assert asset.poster_path == str(Path(asset.rel_path).with_suffix(".jpg"))
cmd = gif_worker["cmd"]
graph = cmd[cmd.index("-filter_complex") + 1]
# The HDR source is tonemapped before the palette is made from it; no sound rides along.
assert graph.index("tonemap") < graph.index("palettegen")
assert "-an" in cmd and "-map" not in cmd
assert cmd[cmd.index("-t") + 1] == "15.000"
def test_a_gif_clip_is_never_refused_for_an_audio_track(
db, make_user, as_user, plex_episode, gif_worker
):
# A GIF queued with a track (as one made before GIFs dropped it was): the file has one track,
# the spec names a fourth, and a GIF, which has no sound, is made all the same.
job = _clip(as_user(make_user()), source="plex", ref="777", gif_side=480, end_s=25).json()
row = db.get(DownloadJob, job["id"])
row.edit_spec = {**row.edit_spec, "audio_track": 3}
db.commit()
job, asset = gif_worker["run"](job)
assert (job.status, asset.status) == ("done", "ready")
def test_a_gif_over_its_size_cap_is_not_kept_and_says_why(
db, make_user, as_user, plex_episode, gif_worker, monkeypatch
):
from app.downloads import gif as gifmod
monkeypatch.setattr(gifmod, "MAX_BYTES", 20)
gif_worker["gif_extra"] = 50
job = _clip(as_user(make_user()), source="plex", ref="777", gif_side=640).json()
job, asset = gif_worker["run"](job)
assert (job.status, job.error_code, job.error, asset.rel_path) == (
"error",
"gif_too_big",
dlerrors.GIF_TOO_BIG_MESSAGE,
None,
)
def test_a_youtube_clip_range_yt_dlp_refuses_for_its_cap_says_it_is_too_big(
db, make_user, as_user, yt_video, worker, monkeypatch
):
+2
View File
@@ -75,6 +75,8 @@ def test_our_own_worker_refusals_classify_as_themselves():
assert classify(dlerrors.CLIP_TOO_BIG_MESSAGE) == "quota_exceeded"
assert classify(dlerrors.CONVERTED_TOO_BIG_MESSAGE) == "quota_exceeded"
assert classify(dlerrors.DOWNLOAD_TOO_BIG_MESSAGE) == "quota_exceeded"
# A GIF over its own cap is not the storage: its own category, its own sentence.
assert classify(dlerrors.GIF_TOO_BIG_MESSAGE) == "gif_too_big"
def test_the_redacted_network_text_classifies_back_to_network():
+197
View File
@@ -0,0 +1,197 @@
"""The editor's GIF output: one marked range of a library video made into a GIF (downloads/gif), held
to the GIF's limits at enqueue, and made by the worker with real ffmpeg."""
import shutil
import subprocess
from pathlib import Path
import pytest
from app.config import settings
from app.downloads import clip as clipmod
from app.downloads import edit as editmod
from app.downloads import pipeline, service
from app.downloads import probe as probemod
from app.downloads import gif as gifmod
from app.downloads import upload as uploadmod
from app.downloads.models import DownloadJob, MediaAsset
needs_ffmpeg = pytest.mark.skipif(shutil.which("ffmpeg") is None, reason="ffmpeg not available")
def test_a_gif_edit_spec_keeps_its_size_and_is_another_edit_than_the_video():
video = editmod.normalize_edit_spec({"trim": {"start_s": 1, "end_s": 5}})
gif = editmod.normalize_edit_spec({"trim": {"start_s": 1, "end_s": 5}, "gif": {"side": 320}})
assert gif["gif"] == {"side": 320} and "gif" not in video
assert editmod.edit_sig(gif) != editmod.edit_sig(video)
# A GIF is always made, never stream-copied.
assert editmod.needs_reencode(gif)
def _source(db, user, tmp_path, seconds=40):
src = tmp_path / "in.mp4"
subprocess.run(
[
"ffmpeg", "-y", "-loglevel", "error",
"-f", "lavfi", "-i", f"testsrc2=size=640x360:rate=10:duration={seconds}",
"-c:v", "libx264", "-pix_fmt", "yuv420p", str(src),
],
check=True,
) # fmt: skip
return uploadmod.ingest_upload(
db, user.id, filename="in.mp4", src_path=src, size=src.stat().st_size
)
@needs_ffmpeg
@pytest.mark.parametrize(
("spec", "reason"),
[
# A GIF is one range with an end: no join, no open end.
({"segments": [{"start_s": 0, "end_s": 2}, {"start_s": 4, "end_s": 6}]}, "gif_range"),
({"trim": {"start_s": 3}}, "gif_range"),
({"trim": {"start_s": 0, "end_s": 30.5}}, "gif_too_long"),
({"trim": {"start_s": 0, "end_s": 5}, "gif_side": 999}, "bad_gif"),
],
)
def test_a_gif_edit_it_cannot_make_is_refused_with_its_reason(
db, make_user, tmp_path, monkeypatch, spec, reason
):
monkeypatch.setattr(settings, "files_root", str(tmp_path / "root"))
u = make_user()
source = _source(db, u, tmp_path)
side = spec.pop("gif_side", 480)
with pytest.raises(service.EditError) as e:
service.enqueue_edit(db, u.id, source, {**spec, "gif": {"side": side}})
assert e.value.reason == reason
@needs_ffmpeg
def test_the_edit_route_says_a_gif_too_long_with_its_cap(
db, make_user, as_user, tmp_path, monkeypatch
):
monkeypatch.setattr(settings, "files_root", str(tmp_path / "root"))
u = make_user()
c = as_user(u)
source = _source(db, u, tmp_path)
r = c.post(
"/api/downloads/edit",
json={
"source_job_id": source.id,
"edit_spec": {"trim": {"start_s": 0, "end_s": 30.5}, "gif": {"side": 480}},
},
)
assert r.status_code == 400, r.text
# The cap goes along: the refusal's sentence says it ("at most 30 seconds").
assert r.json()["detail"] == {"code": "edit", "reason": "gif_too_long", "limit_s": 30}
@needs_ffmpeg
def test_a_gif_edit_of_exactly_thirty_seconds_is_allowed(db, make_user, tmp_path, monkeypatch):
monkeypatch.setattr(settings, "files_root", str(tmp_path / "root"))
u = make_user()
source = _source(db, u, tmp_path)
# Ends kept to the millisecond whose float difference lands a hair over 30 s.
assert 32.02 - 2.02 > gifmod.MAX_S
job = service.enqueue_edit(
db, u.id, source, {"trim": {"start_s": 2.02, "end_s": 32.02}, "gif": {"side": 480}}
)
assert job.edit_spec["gif"] == {"side": 480}
@needs_ffmpeg
def test_a_gif_is_made_of_a_video_never_of_audio(db, make_user, tmp_path, monkeypatch):
monkeypatch.setattr(settings, "files_root", str(tmp_path / "root"))
u = make_user()
src = tmp_path / "in.m4a"
subprocess.run(
[
"ffmpeg", "-y", "-loglevel", "error",
"-f", "lavfi", "-i", "sine=frequency=440:duration=10", "-c:a", "aac", str(src),
],
check=True,
) # fmt: skip
source = uploadmod.ingest_upload(
db, u.id, filename="in.m4a", src_path=src, size=src.stat().st_size
)
assert db.get(MediaAsset, source.asset_id).media_kind == "audio"
spec = {"trim": {"start_s": 1, "end_s": 5}}
with pytest.raises(service.EditError) as e:
service.enqueue_edit(db, u.id, source, {**spec, "gif": {"side": 480}})
assert e.value.reason == "gif_not_video"
# The same range as audio is an edit as it always was.
assert service.enqueue_edit(db, u.id, source, spec).edit_spec["trim"]["end_s"] == 5
@needs_ffmpeg
def test_an_hdr_source_is_tonemapped_before_its_gif_palette(db, make_user, tmp_path, monkeypatch):
monkeypatch.setattr(settings, "files_root", str(tmp_path / "root"))
u = make_user()
src = tmp_path / "hlg.mp4"
subprocess.run(
[
"ffmpeg", "-y", "-loglevel", "error",
"-f", "lavfi", "-i", "testsrc2=size=640x360:rate=10:duration=4",
"-vf", "setparams=color_trc=arib-std-b67:color_primaries=bt2020:colorspace=bt2020nc",
"-c:v", "libx264", "-pix_fmt", "yuv420p", str(src),
],
check=True,
) # fmt: skip
source = uploadmod.ingest_upload(
db, u.id, filename="hlg.mp4", src_path=src, size=src.stat().st_size
)
# The upload keeps its HLG tag, as an HDR phone video's does.
placed = Path(settings.files_root) / db.get(MediaAsset, source.asset_id).rel_path
assert clipmod.is_hdr(probemod.probe_json(placed) or {})
job = service.enqueue_edit(
db, u.id, source, {"trim": {"start_s": 1, "end_s": 3}, "gif": {"side": 320}}
)
asset = db.get(MediaAsset, job.asset_id)
job.status, asset.status = "running", "downloading"
db.commit()
seen: dict = {}
build = gifmod.build_cmd
def spy(*a, **k):
seen["pre"] = k["pre"]
return build(*a, **k)
monkeypatch.setattr(pipeline.gifmod, "build_cmd", spy)
pipeline._process_edit(job.id, asset.id)
assert "tonemap" in seen["pre"]
db.expire_all()
assert db.get(DownloadJob, job.id).status == "done"
@needs_ffmpeg
def test_a_gif_edit_is_made_by_the_worker_and_kept_as_an_image_file(
db, make_user, tmp_path, monkeypatch
):
monkeypatch.setattr(settings, "files_root", str(tmp_path / "root"))
u = make_user()
source = _source(db, u, tmp_path, seconds=6)
spec = {"trim": {"start_s": 1, "end_s": 3}, "crop": {"x": 0, "y": 0, "w": 320, "h": 360}}
job = service.enqueue_edit(db, u.id, source, {**spec, "gif": {"side": 320}})
asset = db.get(MediaAsset, job.asset_id)
assert asset.media_kind == "file"
job.status, asset.status = "running", "downloading" # the worker's claim
db.commit()
pipeline._process_edit(job.id, asset.id)
db.expire_all()
job, asset = db.get(DownloadJob, job.id), db.get(MediaAsset, job.asset_id)
assert (job.status, asset.status, asset.container, asset.media_kind) == (
"done",
"ready",
"gif",
"file",
)
placed = tmp_path / "root" / asset.rel_path
assert placed.read_bytes()[:6] == b"GIF89a"
# The crop's 320x360 has its long side (360) fitted into 320.
assert (asset.width, asset.height) == gifmod.size(placed) == (284, 320)
assert asset.poster_path and (tmp_path / "root" / asset.poster_path).read_bytes()[:2] == (
b"\xff\xd8"
)
+167
View File
@@ -0,0 +1,167 @@
"""The GIF output (downloads/gif): its limits, the one ffmpeg command every GIF goes through, and what
that command really makes — run with real ffmpeg, since the size, the palette and the frame are the
encoder's, not the argv's."""
import shutil
import subprocess
from pathlib import Path
import pytest
from app.downloads import gif as gifmod
from app.downloads import mediakind
from app.downloads import probe as probemod
from app.downloads.edit import INPUT_GUARD
needs_ffmpeg = pytest.mark.skipif(shutil.which("ffmpeg") is None, reason="ffmpeg not available")
def test_a_side_is_one_of_the_offered_sizes_and_anything_else_is_refused():
assert gifmod.SIDES == (320, 480, 640) and gifmod.DEFAULT_SIDE == 480
for side in gifmod.SIDES:
assert gifmod.normalize_side(side) == side
for bad in (0, 100, 481, 1080, -480, True, "480", None, 480.5):
with pytest.raises(ValueError):
gifmod.normalize_side(bad)
def test_the_limits_are_the_measured_ones():
# 30 s at most; a link preview moves in Teams up to 15 MB (measured: 15 moved, 22 did not); a GIF
# over 30 MB is not kept.
assert gifmod.MAX_S == 30
assert gifmod.PREVIEW_MAX_BYTES == 15 * 1024 * 1024
assert gifmod.MAX_BYTES == 30 * 1024 * 1024
assert gifmod.FPS == 12
def _cmd(**kw):
args = {"pre": "", "side": 480, **kw}
return gifmod.build_cmd(Path("/m/in.mkv"), Path("/s/out.gif"), 61.5, 12.0, **args)
def test_the_seek_is_on_the_input_and_the_library_read_is_guarded():
cmd = _cmd()
i = cmd.index("-i")
assert cmd[cmd.index("-ss") + 1] == "61.500" and cmd.index("-ss") < i
assert cmd[i + 1] == "/m/in.mkv"
assert cmd[cmd.index("-t") + 1] == "12.000" and cmd.index("-t") > i
g = cmd.index(INPUT_GUARD[0])
assert cmd[g : g + len(INPUT_GUARD)] == INPUT_GUARD and g < i
assert cmd[-1] == "/s/out.gif"
def test_a_gif_has_no_sound_and_one_palette_of_its_own():
cmd = _cmd()
assert "-an" in cmd and "-sn" in cmd and "-dn" in cmd
graph = cmd[cmd.index("-filter_complex") + 1]
assert f"fps={gifmod.FPS}" in graph
assert "palettegen=stats_mode=diff" in graph
assert "paletteuse=dither=bayer:bayer_scale=5:diff_mode=rectangle" in graph
assert "flags=lanczos" in graph
def test_a_crop_or_a_tonemap_comes_before_the_scale_and_the_palette():
cmd = _cmd(pre="crop=100:100:0:0,format=yuv420p")
graph = cmd[cmd.index("-filter_complex") + 1]
assert graph.index("crop=100:100:0:0") < graph.index("scale=") < graph.index("palettegen")
def test_the_length_cap_counts_whole_milliseconds():
# Two ends kept to the millisecond whose float difference lands a hair over 30 s.
assert 60.529 - 30.529 > gifmod.MAX_S
assert not gifmod.too_long(30.529, 60.529)
assert not gifmod.too_long(0, 30)
assert gifmod.too_long(0, 30.001)
def test_the_pixels_are_squared_after_the_crop_and_before_the_size():
graph = gifmod.filter_graph("crop=100:100:0:0", 480)
assert graph.index("crop=") < graph.index("setsar=1") < graph.index("flags=lanczos")
def _video(path: Path, size: str, seconds: int = 2, sar: str = "1") -> Path:
subprocess.run(
[
"ffmpeg", "-y", "-loglevel", "error",
"-f", "lavfi", "-i", f"testsrc2=size={size}:rate=25:duration={seconds}",
"-vf", f"setsar={sar}",
"-c:v", "libx264", "-pix_fmt", "yuv420p", str(path),
],
check=True,
) # fmt: skip
return path
@needs_ffmpeg
@pytest.mark.parametrize(
("size", "sar", "expected"),
[
# A DVD stored 720x480 and shown at 16:9: a GIF shows its stored pixels as they are, so
# they are squared first, or the picture comes out 480x320 (3:2), squeezed.
("720x480", "32/27", (480, 270)),
# HDV: 1440x1080 shown at 16:9.
("1440x1080", "4/3", (480, 270)),
],
)
def test_a_source_with_wide_pixels_keeps_its_shown_shape(tmp_path, size, sar, expected):
src = _video(tmp_path / "in.mp4", size, sar=sar)
out = tmp_path / "out.gif"
subprocess.run(gifmod.build_cmd(src, out, 0.0, 1.0, pre="", side=480), check=True)
assert gifmod.size(out) == expected
@needs_ffmpeg
@pytest.mark.parametrize(
("size", "side", "expected"),
[
# The long side is capped, so a phone's portrait video is as small as a landscape one.
("1280x720", 480, (480, 270)),
("720x1280", 480, (270, 480)),
("1280x720", 320, (320, 180)),
# A picture already smaller than the size is never blown up.
("160x120", 640, (160, 120)),
],
)
def test_the_long_side_is_capped_and_a_small_picture_is_kept(tmp_path, size, side, expected):
src = _video(tmp_path / "in.mp4", size)
out = tmp_path / "out.gif"
subprocess.run(gifmod.build_cmd(src, out, 0.5, 1.0, pre="", side=side), check=True)
assert out.read_bytes()[:6] == b"GIF89a"
assert gifmod.size(out) == expected
@needs_ffmpeg
def test_a_made_gif_is_a_library_file_that_previews_as_an_image(tmp_path):
src = _video(tmp_path / "in.mp4", "320x240")
out = tmp_path / "out.gif"
subprocess.run(gifmod.build_cmd(src, out, 0.0, 1.0, pre="", side=320), check=True)
vcodec, acodec, *_ = probemod.probe_media(out)
assert mediakind.derive(vcodec, acodec) == mediakind.FILE
@needs_ffmpeg
def test_a_gif_gets_a_jpeg_still_for_link_previews(tmp_path):
src = _video(tmp_path / "in.mp4", "320x240")
out = tmp_path / "out.gif"
subprocess.run(gifmod.build_cmd(src, out, 0.0, 1.0, pre="", side=320), check=True)
still = gifmod.ensure_still(out)
assert still == out.with_suffix(".jpg")
assert still.read_bytes()[:3] == b"\xff\xd8\xff"
@needs_ffmpeg
def test_the_still_is_cut_only_from_a_real_gif(tmp_path):
# The still's read is held to the GIF demuxer: a "gif" that is something else gives no still.
fake = tmp_path / "out.gif"
fake.write_bytes(b"ffconcat version 1.0\nfile '/etc/passwd'\n")
assert gifmod.ensure_still(fake) is None
def test_the_size_is_read_from_the_gif_header_and_nothing_else_has_one(tmp_path):
g = tmp_path / "a.gif"
g.write_bytes(b"GIF89a" + (300).to_bytes(2, "little") + (170).to_bytes(2, "little") + b"\x00")
assert gifmod.size(g) == (300, 170)
other = tmp_path / "b.gif"
other.write_bytes(b"\x89PNG\r\n\x1a\n")
assert gifmod.size(other) is None
assert gifmod.size(tmp_path / "missing.gif") is None
+137
View File
@@ -0,0 +1,137 @@
"""A shared GIF (or any shared picture): served inline as itself on the public page, and its link
preview card. Teams moves a GIF `og:image` up to 15 MB; Messenger never showed one and showed a JPEG
(measured 2026-11-09/11), so Facebook's crawler and a GIF too big to move get the still. DB-lane."""
import pytest
from app import main
from app.config import settings
from app.downloads import og
from app.downloads import probe as probemod
from app.downloads.models import DownloadJob, MediaAsset
_PAGE = """<!doctype html><html><head>
<!--OG:START--><meta property="og:title" content="Siftlode" /><!--OG:END-->
</head><body><script src="/assets/watch.js"></script></body></html>"""
_GIF_PROBE = {
"streams": [{"codec_type": "video", "codec_name": "gif", "width": 300, "height": 170}],
"format": {"format_name": "gif"},
}
_GIF = b"GIF89a" + (300).to_bytes(2, "little") + (170).to_bytes(2, "little") + b"\x00" * 16
_TEAMS = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) SkypeUriPreview Preview/0.5 skype-url-preview@microsoft.com"
_FACEBOOK = "facebookexternalhit/1.1 (+http://www.facebook.com/externalhit_uatext.php)"
@pytest.fixture
def pages(tmp_path, monkeypatch):
watch = tmp_path / "watch.html"
watch.write_text(_PAGE, encoding="utf-8")
monkeypatch.setattr(main, "WATCH_HTML", watch)
@pytest.fixture
def shared(as_user, make_user, tmp_path, monkeypatch, upload_headers):
"""Uploads `content` as `name` (ffprobe reading it as `data`) and shares it."""
monkeypatch.setattr(settings, "files_root", str(tmp_path / "media"))
owner = as_user(make_user())
def share(name: str, content: bytes, data: dict | None, **link: object) -> dict:
monkeypatch.setattr(probemod, "probe_json", lambda _p: data)
job = owner.post(
"/api/files/upload", content=content, headers=upload_headers(name, display_name=name)
)
assert job.status_code == 200, job.text
made = owner.post(f"/api/downloads/{job.json()['id']}/links", json=link)
assert made.status_code == 200, made.text
return {**made.json(), "job_id": job.json()["id"]}
return share
def _asset(db, made) -> MediaAsset:
return db.get(MediaAsset, db.get(DownloadJob, made["job_id"]).asset_id)
def test_a_shared_gif_is_served_inline_as_a_gif_and_can_run_nothing(shared, client):
made = shared("wave.gif", _GIF, _GIF_PROBE, allow_download=False)
r = client.get(f"/api/public/watch/{made['token']}/file")
assert r.status_code == 200
assert r.headers["content-type"] == "image/gif"
assert r.headers["content-disposition"].startswith("inline")
# A "gif" whose bytes are a page must not run on this login-free origin.
assert r.headers["x-content-type-options"] == "nosniff"
assert "sandbox" in r.headers["content-security-policy"]
assert r.content == _GIF
def test_a_gif_link_that_allows_download_hands_it_over_as_a_file(shared, client):
made = shared("wave.gif", _GIF, _GIF_PROBE, allow_download=True)
r = client.get(f"/api/public/watch/{made['token']}/file")
assert r.headers["content-type"] == "image/gif"
assert r.headers["content-disposition"].startswith("attachment")
def test_any_other_file_is_still_only_a_download(shared, client):
made = shared("notes.html", b"<script>alert(1)</script>", None)
r = client.get(f"/api/public/watch/{made['token']}/file")
assert r.headers["content-type"] == "application/octet-stream"
assert r.headers["content-disposition"].startswith("attachment")
def test_the_page_is_told_a_picture_is_a_picture(shared, client):
made = shared("wave.gif", _GIF, _GIF_PROBE)
meta = client.get(f"/api/public/watch/{made['token']}").json()
assert meta["preview"] == "image"
other = shared("backup.tar.gz", b"x", None)
assert client.get(f"/api/public/watch/{other['token']}").json()["preview"] is None
def _og(text: str, prop: str) -> str | None:
marker = f'property="{prop}" content="'
if marker not in text:
return None
return text.split(marker, 1)[1].split('"', 1)[0]
def test_a_gif_link_previews_as_the_moving_gif_itself(pages, shared, client):
made = shared("wave.gif", _GIF, _GIF_PROBE)
page = client.get(f"/file/{made['token']}", headers={"User-Agent": _TEAMS}).text
assert _og(page, "og:image") == (f"{settings.app_base}/api/public/watch/{made['token']}/file")
assert _og(page, "og:image:type") == "image/gif"
# Its size from its own header: a card wants it up front.
assert (_og(page, "og:image:width"), _og(page, "og:image:height")) == ("300", "170")
def test_facebooks_crawler_gets_the_still_of_a_gif(pages, shared, client, db):
made = shared("wave.gif", _GIF, _GIF_PROBE)
asset = _asset(db, made)
asset.poster_path = "wave.jpg"
db.commit()
page = client.get(f"/file/{made['token']}", headers={"User-Agent": _FACEBOOK}).text
assert _og(page, "og:image") == (
f"{settings.app_base}/api/public/watch/{made['token']}/poster.jpg"
)
assert _og(page, "og:image:type") == "image/jpeg"
def test_a_gif_too_big_to_move_previews_as_its_still(pages, shared, client, db):
made = shared("wave.gif", _GIF, _GIF_PROBE)
asset = _asset(db, made)
asset.poster_path = "wave.jpg"
asset.size_bytes = 15 * 1024 * 1024 + 1
db.commit()
page = client.get(f"/file/{made['token']}", headers={"User-Agent": _TEAMS}).text
assert _og(page, "og:image").endswith("/poster.jpg")
def test_a_password_gif_link_shows_nothing_of_it(pages, shared, client):
made = shared("wave.gif", _GIF, _GIF_PROBE, password="pw")
page = client.get(f"/file/{made['token']}", headers={"User-Agent": _TEAMS}).text
assert _og(page, "og:image") is None
def test_the_card_builder_takes_no_agent_as_a_browser(shared, db):
made = shared("wave.gif", _GIF, _GIF_PROBE)
assert 'content="image/gif"' in og._watch_tags(made["token"], db)
+25
View File
@@ -22,6 +22,31 @@ from app.runstatus import RunStatus, RunTrigger
_JOB = "unit_test_claim" # not a real job id — exercises the primitive without touching live jobs
class _StrictlyLater(datetime):
"""A clock that never repeats or goes back: each reading at least a microsecond after the last."""
last: datetime | None = None
clock: type[datetime] = datetime
@classmethod
def now(cls, tz=None):
t = cls.clock.now(tz)
if cls.last is not None and t <= cls.last:
t = cls.last + timedelta(microseconds=1)
cls.last = t
return t
@pytest.fixture(autouse=True)
def _runs_start_in_order(monkeypatch):
"""Runs started in a row start in that order. These tests open several runs within microseconds
and read them back newest first, by start time; the wall clock of a container on a VM can step
back between two readings (seen: the third of three runs sorted before the second)."""
_StrictlyLater.last = None
_StrictlyLater.clock = jobruns.datetime
monkeypatch.setattr(jobruns, "datetime", _StrictlyLater)
def _release():
scheduler._record(_JOB, running=False)
+16
View File
@@ -646,6 +646,19 @@ of a full-width panel and nothing on the right.
press outside the frame is still the video's. A cut is a toast with a Downloads link and the usual
"Ready to watch" notification. Limits are the server's: 1 s to 10 min (`lib/clipRange`, held to
`downloads/clip.py` by `clipLimits.contract.test.ts`; the "too long" message names the server's cap).
**GIF (owner's picks D1 A, D2 A, D3):** a GIF is made only here and in the Downloads editor, never from the
download dialog. The panel's Format · Video / GIF toggle sits above the name row; with GIF it shows the size
(the long side: 320 / 480 / 640 px), the expected size ("about 4–10 MB"; one number when both ends read the
same), a note over 15 MB (a chat's link preview will not move) or 30 MB (it may be refused), and over 30 s
the reason, with Make GIF unavailable. A refused length is shown rounded up, never as one the cap allows. The
cap is judged in whole milliseconds between the ends as sent, the server's own rule (the limits in `lib/gif`
are held to `downloads/gif.py` by `gif.contract.test.ts`; the rule itself is written on each side and
tested there against the same boundary cases). The editor has its own Format row (a video only): with GIF
each kept segment is a GIF of its own, Join is unavailable with its reason in a tooltip, the precise / fast
choice gives way to one line (a GIF is always re-encoded), and the notes name each segment over a limit,
in one live region. A shared GIF's page shows the picture itself (no player), its name, kind and size, its
Download, and a tip: the downloaded file moves in any chat. A picture that fails says so only once its
retry failed too.
## Dialogs — _Decided_ (C4, built in S3f)
@@ -878,3 +891,6 @@ of a full-width panel and nothing on the right.
| 2026-11-07 | The panel over the picture is darker, its error the media red (owner a) | `OVER_VIDEO` is black 85 % under `media-scope`: an error on it is the media red in both themes | Only the red changed (3.75:1 on a white frame); black 90 % | A light theme's red was 1.6:1 on the 75 % panel over a white frame; now 5.47:1 at worst, white text too |
| 2026-11-07 | A total's left-out videos are "without length" (owner b) | A summed length names what it left out as "+N without length" / "+N hossz nélkül" | "+N live" as before; "+N stream" | The count holds archives and upcoming streams too, which a card calls a stream or upcoming: "live" there named only one of them (One name per feature) |
| 2026-11-08 | The Channels table's Length is the sum alone (owner) | The table's Length cell shows the summed hours only; the "+N without length" stays on a playlist's total | The line wrapped in the column; the column widened to 6.5 rem | The 4.5 rem column is sized for the sum: the longer line spilled into Tags (88 px in 56), and the owner wants no such line there |
| 2026-11-11 | A GIF from the clip panel (owner D1 A) | A Format · Video / GIF toggle above the name row; GIF shows the size (long side 320 / 480 / 640), the expected size, a note over 15 MB or 30 MB, and over 30 s the reason with Make GIF unavailable; a GIF is never made from the download dialog | A GIF format in the download dialog with its own range picker; the size as a free number | The range is already marked in the panel; a size people can judge beats pixels to type, and a chat's link preview stops moving over 15 MB |
| 2026-11-11 | A GIF from the Downloads editor (owner D2 A) | Its own Format row (a video only); with GIF each kept segment is its own GIF, Join unavailable with its reason, one line in place of precise / fast, a note per segment over a limit | One GIF of the joined segments; GIF as an output mode beside Separate / Join | A GIF longer than 30 s is refused anyway, and a joined GIF hides which segment is over the limit |
| 2026-11-11 | A shared GIF shows itself (owner D3) | The share page shows the picture itself, no player; Download beside it, and a tip that the downloaded file moves in any chat; its failure is said only once its retry failed too | The watch player; a file row that only downloads | A GIF plays and repeats on its own and nobody pauses one; a chat's link preview is the chat's to draw, the dragged-in file moves everywhere |
+8 -2
View File
@@ -12,7 +12,8 @@ import { useEffect, useState, type ImgHTMLAttributes, type ReactNode } from "rea
// `r` parameter (measured 2026-10-14). A 429 from a burst of cards heals this way.
// - While there is no picture — none given, a retry still waiting, every retry spent — the caller's
// `fallback` stands in (nothing by default: the card's own empty frame, which is what a card with no
// thumbnail has always shown). Never the broken-image mark.
// thumbnail has always shown), or its `pending` while a retry waits when the fallback says the
// picture failed. Never the broken-image mark.
// - The failure is remembered BY URL, in render: these sit in virtualized grids whose slots are recycled,
// and a failure stored as a flag an effect resets painted one item's fallback over the next one's
// picture for a frame (ui/Avatar had that defect).
@@ -47,6 +48,7 @@ export default function RemoteImage({
src,
srcSet,
fallback = null,
pending = fallback,
retries = IMAGE_RETRIES,
...img
}: Omit<ImgHTMLAttributes<HTMLImageElement>, "src" | "srcSet" | "onError" | "referrerPolicy"> & {
@@ -54,6 +56,9 @@ export default function RemoteImage({
srcSet?: string;
/** What stands in while there is no picture. */
fallback?: ReactNode;
/** What stands in while a retry waits, when that is not the fallback: a fallback that says the
* picture failed would say it of one that may still come. */
pending?: ReactNode;
/** Fewer retries for a picture whose failure is an answer, not a hiccup — a file that is not there
* yet is better shown as its fallback now than after seven seconds of retries. */
retries?: number;
@@ -75,7 +80,8 @@ export default function RemoteImage({
}, [waiting, attempt, src]);
const spent = current !== null && !current.waiting && current.attempt > retries;
if (!src || waiting || spent) return <>{fallback}</>;
if (waiting && src) return <>{pending}</>;
if (!src || spent) return <>{fallback}</>;
return (
// eslint-disable-next-line no-restricted-syntax -- the one <img> the rest of the app goes through
<img
@@ -1,4 +1,13 @@
[
{
"version": "0.119.0",
"date": "2026-11-13",
"new": [
"Make a GIF of up to 30 seconds: in a player's Clip panel, or in the Downloads editor, where each kept segment becomes a GIF of its own. Both say the expected size before it is made.",
"A shared GIF's link opens the moving picture itself, with Download beside it. In Teams its preview moves when the GIF is 15 MB or less."
],
"fixed": []
},
{
"version": "0.118.0",
"date": "2026-11-08",
@@ -1,4 +1,13 @@
[
{
"version": "0.119.0",
"date": "2026-11-13",
"new": [
"Legfeljebb 30 másodperces GIF készíthető: a lejátszó Klip paneljén, vagy a Letöltések szerkesztőjében, ahol minden megtartott szegmensből külön GIF lesz. Mindkettő előre megmondja a várható méretet.",
"Egy megosztott GIF linkje magát a mozgó képet nyitja meg, mellette a Letöltéssel. Teamsben az előnézete mozog, ha a GIF legfeljebb 15 MB."
],
"fixed": []
},
{
"version": "0.118.0",
"date": "2026-11-08",
@@ -12,7 +12,16 @@ import type { ClipRange } from "../../lib/clipRange";
vi.mock("react-i18next", async (importOriginal) => {
const actual = await importOriginal<typeof import("react-i18next")>();
return { ...actual, useTranslation: () => ({ t: (k: string) => k }) };
return {
...actual,
// A size option reads as its own words with its unit ("480 px"), the length with the length it
// gives; every other text is its key.
useTranslation: () => ({
t: (k: string, o?: { n?: string; length?: string }) =>
k === "clip.gif.side" ? `${o?.n} px` : k === "clip.length" ? `${k} ${o?.length}` : k,
i18n: { language: "en" },
}),
};
});
const enqueueClip = vi.fn<(body: ClipRequest) => Promise<unknown>>();
@@ -271,3 +280,106 @@ describe("ClipPanel", () => {
expect(screen.getByTestId("crop").textContent).toBe("none");
});
});
describe("ClipPanel GIF", () => {
it("makes a GIF at the size picked, and says a GIF is being made", async () => {
const onClose = vi.fn();
render(<Harness onClose={onClose} start={{ start: 100, end: 108 }} />);
expect(screen.queryByRole("group", { name: "clip.gif.size" })).toBeNull();
fireEvent.click(screen.getByRole("button", { name: "clip.formatGif" }));
fireEvent.click(screen.getByRole("button", { name: "320 px" }));
expect(enqueueClip).not.toHaveBeenCalled();
fireEvent.click(screen.getByRole("button", { name: "clip.gif.make" }));
await waitFor(() => expect(onClose).toHaveBeenCalled());
expect(enqueueClip.mock.calls[0]![0]).toMatchObject({
start_s: 100,
end_s: 108,
gif_side: 320,
});
expect(notify).toHaveBeenCalledWith(
expect.objectContaining({ level: "success", message: msg("clip.gif.queued") }),
);
});
it("starts at the default size, and a video again sends no GIF", async () => {
render(<Harness start={{ start: 100, end: 108 }} />);
fireEvent.click(screen.getByRole("button", { name: "clip.formatGif" }));
expect(screen.getByRole("button", { name: "480 px" }).getAttribute("aria-pressed")).toBe(
"true",
);
fireEvent.click(screen.getByRole("button", { name: "clip.formatVideo" }));
fireEvent.click(screen.getByRole("button", { name: "clip.cut" }));
await waitFor(() => expect(enqueueClip).toHaveBeenCalled());
expect(enqueueClip.mock.calls[0]![0]).not.toHaveProperty("gif_side");
});
it("a range longer than a GIF may be leaves Make unavailable, with its reason", () => {
render(<Harness start={{ start: 100, end: 131 }} />);
fireEvent.click(screen.getByRole("button", { name: "clip.formatGif" }));
const make = screen.getByRole("button", { name: "clip.gif.make" });
expect(make.getAttribute("aria-disabled")).toBe("true");
const note = document.getElementById(make.getAttribute("aria-describedby")!)!;
expect(note.textContent).toBe("clip.gif.tooLong");
fireEvent.click(make);
expect(enqueueClip).not.toHaveBeenCalled();
});
it("says a refused GIF length so it never reads as one the cap allows", () => {
render(<Harness start={{ start: 100, end: 130.03 }} />);
// A video of it is 0:30.0 long, as it always read…
expect(screen.getByText(/^clip\.length /).textContent).toBe("clip.length 0:30.0");
fireEvent.click(screen.getByRole("button", { name: "clip.formatGif" }));
// …and a GIF of it, refused, reads 0:30.1.
expect(screen.getByText(/^clip\.length /).textContent).toBe("clip.length 0:30.1");
const make = screen.getByRole("button", { name: "clip.gif.make" });
expect(make.getAttribute("aria-disabled")).toBe("true");
});
it("says a note already true when GIF is picked inside a live region that was there before", () => {
render(<Harness start={{ start: 100, end: 131 }} />);
const before = new Map(
[...document.querySelectorAll("[aria-live='polite']")].map((r) => [r, r.textContent]),
);
fireEvent.click(screen.getByRole("button", { name: "clip.formatGif" }));
// The reason and the estimate change inside regions that were there, empty, before: what
// changes inside a region is announced, a region that arrives with its text is not.
const note = screen.getByText("clip.gif.tooLong").closest("[aria-live='polite']")!;
const estimate = screen.getByText("clip.gif.estimate").closest("[aria-live='polite']")!;
for (const region of [note, estimate]) expect(before.get(region)).toBe("");
});
it("says what the expected size means: fine, no moving preview, or past the cap", () => {
render(<Harness start={{ start: 100, end: 110 }} />);
fireEvent.click(screen.getByRole("button", { name: "clip.formatGif" }));
const note = () =>
document.getElementById(
screen.getByRole("button", { name: "clip.gif.make" }).getAttribute("aria-describedby")!,
)!.textContent;
// 10 s at 480: about 4–10 MB.
expect(note()).toBe("clip.gif.hint");
// 20 s at 480: up to about 20 MB, past a link preview's 15.
fireEvent.change(screen.getByRole("textbox", { name: "clip.out" }), {
target: { value: "2:00" },
});
fireEvent.keyDown(screen.getByRole("textbox", { name: "clip.out" }), { key: "Enter" });
expect(range()).toBe("100-120");
expect(note()).toBe("clip.gif.noPreview");
// 20 s at 640: up to about 32 MB, past the server's 30.
fireEvent.click(screen.getByRole("button", { name: "640 px" }));
expect(note()).toBe("clip.gif.mayFail");
});
it("says one size, not a range of one, when both ends read the same", () => {
// 1 s at 480: 0.4–1.0 MB, both shown as 1.
render(<Harness start={{ start: 100, end: 101 }} />);
fireEvent.click(screen.getByRole("button", { name: "clip.formatGif" }));
expect(screen.getByText("clip.gif.estimateOne")).toBeTruthy();
expect(screen.queryByText("clip.gif.estimate")).toBeNull();
// 10 s: a range.
fireEvent.change(screen.getByRole("textbox", { name: "clip.out" }), {
target: { value: "1:50" },
});
fireEvent.keyDown(screen.getByRole("textbox", { name: "clip.out" }), { key: "Enter" });
expect(screen.getByText("clip.gif.estimate")).toBeTruthy();
});
});
+117 -10
View File
@@ -10,6 +10,18 @@ import { notifyActionError } from "../../lib/actionErrors";
import { showInTray } from "../../lib/downloadQueue";
import { log } from "../../lib/log";
import { formatTimecode } from "../../lib/timecode";
import { formatNumber } from "../../lib/format";
import {
atMs,
estimateGif,
gifEstimateText,
gifShownLength,
gifVerdict,
GIF_DEFAULT_SIDE,
GIF_MAX_S,
GIF_SIDES,
type GifSide,
} from "../../lib/gif";
import { handleBounds, markIn, markOut, zoomWindow, type ClipRange } from "../../lib/clipRange";
import { fitRatio, fracRatio, WHOLE, type Frac } from "../../lib/cropGeometry";
import { RangeTrack, RulerTicks, type RangeWindow } from "../../components/ui/RangeTrack";
@@ -38,6 +50,11 @@ import { useTimecodeField, type TimecodeDraft } from "../../components/ui/useTim
// (`ClipCropFrame`, which the player places over its picture) marks the part to keep, free or held
// to an output shape. The crop is the caller's state too, so the player's Escape can turn it off
// before it closes the panel.
//
// The clip is an mp4 unless the user picks GIF: then the size (the long side) and the size the GIF
// is expected to come out at show before it is made, with what that size means — over 15 MB a Teams
// link preview shows it still, over 30 MB the server refuses it. A range longer than a GIF may be
// leaves Cut unavailable, with the reason beside it.
/** The output shapes a crop frame can be held to (width / height of the clip), or a free frame. */
type ClipShape = "free" | "16:9" | "9:16" | "1:1";
@@ -110,10 +127,13 @@ export default function ClipPanel({
onPlayFrom,
onClose,
}: Props) {
const { t } = useTranslation();
const { t, i18n } = useTranslation();
const qc = useQueryClient();
const rootRef = useRef<HTMLFormElement>(null);
const [name, setName] = useState("");
const [format, setFormat] = useState<"video" | "gif">("video");
const [side, setSide] = useState<GifSide>(GIF_DEFAULT_SIDE);
const gifNoteId = useId();
const [busy, setBusy] = useState(false);
const [looping, setLooping] = useState(false);
// The strip's window while a handle drags (null at rest: it follows the selection).
@@ -123,6 +143,14 @@ export default function ClipPanel({
const view = frozen ?? zoomWindow(clip, duration);
const bounds = handleBounds(clip, duration);
const length = clip.end - clip.start;
const gif = format === "gif";
// The GIF's shape is the crop's when there is one: the long side is capped, so the shape decides
// how many pixels it holds.
const shape = crop ? (crop.rect.w * picture) / crop.rect.h : picture;
const estimate = estimateGif(length, side, shape);
const verdict = gif ? gifVerdict(clip.start, clip.end, estimate) : "ok";
const estimateText = gifEstimateText(estimate, i18n.language);
const blocked = verdict === "too_long";
// Opened from a button or a key, the panel takes the focus to its In handle: the button that
// opened it is gone (its row is this panel now), and the arrows then move the mark at once. Without
@@ -171,8 +199,8 @@ export default function ClipPanel({
const body: ClipRequest = {
source: src.source,
ref: src.ref,
start_s: Math.round(clip.start * 1000) / 1000,
end_s: Math.round(clip.end * 1000) / 1000,
start_s: atMs(clip.start),
end_s: atMs(clip.end),
...(src.audioTrack != null ? { audio_track: src.audioTrack } : {}),
...(crop
? {
@@ -185,26 +213,34 @@ export default function ClipPanel({
},
}
: {}),
...(gif ? { gif_side: side } : {}),
...(name.trim() ? { display_name: name.trim() } : {}),
};
const submit = async (e: SubmitEvent<HTMLFormElement>) => {
e.preventDefault();
if (busy) return;
if (busy || blocked) return;
setBusy(true);
// Before the try: a `?:` inside one makes the React Compiler skip the whole panel.
const queued = msg(gif ? "clip.gif.queued" : "clip.queued");
const failed = msg(gif ? "clip.gif.failed" : "clip.failed");
try {
const job = await api.enqueueClip(body);
invalidateDownloads(qc);
log.info("clip.queued", "clip queued", { source: src.source, seconds: Math.round(length) });
log.info("clip.queued", "clip queued", {
source: src.source,
seconds: Math.round(length),
format,
});
notify({
level: "success",
message: msg("clip.queued"),
message: queued,
action: { label: msg("clip.view"), onClick: () => showInTray(job.id) },
});
onClose();
} catch (err) {
// A refusal the server explains (too long, quota) is already in the error dialog; a lost
// connection or a 404 is not, and must not leave the panel looking as if nothing happened.
notifyActionError(err, msg("clip.failed"));
notifyActionError(err, failed);
setBusy(false);
}
};
@@ -313,7 +349,9 @@ export default function ClipPanel({
onHere={() => moveOut(current, true)}
/>
<span className="tabular-nums text-muted" aria-live="polite">
{t("clip.length", { length: formatTimecode(length) })}
{t("clip.length", {
length: formatTimecode(gif ? gifShownLength(clip.start, clip.end) : length),
})}
</span>
<Button
variant="overlay"
@@ -370,6 +408,66 @@ export default function ClipPanel({
)}
</div>
<div className="flex flex-wrap items-center gap-2">
<SegmentedToggle
ariaLabel={t("clip.format")}
value={format}
onChange={setFormat}
options={[
{ value: "video", label: t("clip.formatVideo") },
{ value: "gif", label: t("clip.formatGif") },
]}
/>
{gif && (
<>
<span className="ml-2 text-muted" aria-hidden="true">
{t("clip.gif.sizeLabel")}
</span>
<SegmentedToggle
ariaLabel={t("clip.gif.size")}
value={String(side)}
onChange={(v) => setSide(Number(v) as GifSide)}
options={GIF_SIDES.map((s) => ({
value: String(s),
label: t("clip.gif.side", { n: formatNumber(s, i18n.language) }),
}))}
/>
</>
)}
{/* The live regions stay mounted whatever the format (laid out as their content alone): a
live region announces what changes inside it, not what arrives together with it, so
a note already true when GIF is picked is said too. */}
<span className="contents" aria-live="polite">
{gif && (
<span className="tabular-nums text-muted">
{t(estimateText.single ? "clip.gif.estimateOne" : "clip.gif.estimate", estimateText)}
</span>
)}
</span>
</div>
<div className="contents" aria-live="polite">
{gif && (
<p
id={gifNoteId}
className={`text-xs ${
verdict === "too_long"
? "text-danger"
: verdict === "ok"
? "text-muted"
: "text-media-mark"
}`}
>
{verdict === "too_long"
? t("clip.gif.tooLong", { seconds: GIF_MAX_S })
: verdict === "may_fail"
? t("clip.gif.mayFail")
: verdict === "no_preview"
? t("clip.gif.noPreview")
: t("clip.gif.hint")}
</p>
)}
</div>
<div className="flex flex-wrap items-center gap-2">
<Input
size="sm"
@@ -383,8 +481,17 @@ export default function ClipPanel({
<Button variant="overlay" size="sm" onClick={onClose}>
{t("common.cancel")}
</Button>
<Button type="submit" variant="primary" size="sm" loading={busy} icon={<Scissors />}>
{t("clip.cut")}
<Button
type="submit"
variant="primary"
size="sm"
loading={busy}
icon={<Scissors />}
// Unavailable with its reason (the note above), and still focusable: not native disabled.
aria-disabled={blocked || undefined}
aria-describedby={gif ? gifNoteId : undefined}
>
{gif ? t("clip.gif.make") : t("clip.cut")}
</Button>
</div>
</form>
+7
View File
@@ -39,6 +39,13 @@ describe("the copy rule", () => {
expect(offenders((v) => v.includes("✓"))).toEqual([]);
});
it("keeps the stops of a message of two sentences, the last one too", () => {
// A sentence break: a stop, a space, then a capital ("kb. {{n}}" is an abbreviation, not one). A
// value ending in a space is the head of a sentence a link or a number completes on screen.
const twoSentences = (v: string) => /[.!?] \p{Lu}/u.test(v) && !v.endsWith(" ");
expect(offenders((v) => twoSentences(v) && !/[.!?…]["”)]?$/u.test(v))).toEqual([]);
});
it("spells no unit as a bare letter after a number ('90m', '24h', '{{count}}d', '10 s')", () => {
expect(offenders((v) => /(\d|\}\})\s?(m|h|d|s)(?![\p{L}\d/])/u.test(v))).toEqual([]);
});
+20 -1
View File
@@ -22,6 +22,23 @@
"shape": "Frame shape",
"shapeFree": "Free",
"whole": "Whole picture",
"format": "Format",
"formatVideo": "Video",
"formatGif": "GIF",
"gif": {
"size": "GIF size, the longer side in pixels",
"sizeLabel": "Size",
"side": "{{n}} px",
"estimate": "about {{lo}}–{{hi}} MB",
"estimateOne": "about {{lo}} MB",
"hint": "No sound, 12 frames a second",
"noPreview": "It may pass 15 MB, and then a Teams link preview shows only a still picture. The downloaded file plays everywhere.",
"mayFail": "It may pass 30 MB, and then it isn’t saved. A shorter range or a smaller size helps.",
"tooLong": "A GIF is at most {{seconds}} seconds long. Shorten the range.",
"make": "Make GIF",
"queued": "The GIF is being made — it will be in your downloads",
"failed": "Couldn’t make the GIF"
},
"errors": {
"bad_source": "This video can’t be clipped.",
"bad_range": "The clip’s start or end isn’t valid.",
@@ -31,6 +48,8 @@
"bad_crop": "The crop frame isn’t valid. Make it bigger, or go back to the whole picture.",
"not_found": "This item is no longer in the library.",
"media_missing": "The item’s file can’t be reached from here.",
"past_end": "The clip runs past the end of the video."
"past_end": "The clip runs past the end of the video.",
"bad_gif": "That GIF size isn’t available.",
"gif_too_long": "A GIF is at most {{seconds}} seconds long."
}
}
@@ -224,6 +224,10 @@
"empty_edit": "Choose a trim range or crop area first",
"source_not_ready": "The source download isn't ready to edit",
"not_media": "Only video and audio can be edited",
"bad_gif": "That GIF size isn’t available",
"gif_range": "A GIF is made from one range with a start and an end",
"gif_too_long": "A GIF is at most {{seconds}} seconds long",
"gif_not_video": "A GIF is made from a video",
"generic": "Couldn't create that edit"
},
"upload": {
@@ -312,6 +316,10 @@
"short": "Couldn't download",
"body": "Something went wrong with this download. The technical details below may help."
},
"gif_too_big": {
"short": "The GIF is too large",
"body": "This GIF came out larger than a GIF may be, so it wasn’t kept. Mark a shorter range or choose a smaller size, then make it again."
},
"quota_exceeded": {
"short": "Not enough storage",
"body": "This download doesn't fit in the storage you have left. Delete something from your library, then try again."
+20 -1
View File
@@ -24,12 +24,31 @@
"created_other": "{{count}} clips added to your downloads",
"failed": "Couldn’t create the clip",
"partlyCreated": "Only {{done}} of {{total}} clips were added — the rest are still selected, so creating again adds just those.",
"failedGif": "Couldn’t create the GIF",
"partlyCreatedGif": "Only {{done}} of {{total}} GIFs were added — the rest are still selected, so creating again adds just those.",
"splitHere": "Split here",
"output": {
"label": "Output",
"separate": "Separate files",
"join": "Join into one"
"join": "Join into one",
"joinNeedsTwo": "Keep at least two segments to join them",
"joinGif": "A GIF can’t be joined: each kept segment becomes a GIF of its own"
},
"format": {
"label": "Format",
"video": "Video",
"gif": "GIF",
"estimate": "about {{lo}}–{{hi}} MB in all",
"estimateOne": "about {{lo}} MB in all",
"reencode": "A GIF is always re-encoded: no sound, 12 frames a second",
"segTooLong": "Segment {{n}} is {{length}} long: a GIF is at most {{seconds}} seconds",
"segMayFail": "Segment {{n}} may pass 30 MB as a GIF, and then it isn’t saved",
"segNoPreview": "Segment {{n}} may pass 15 MB as a GIF, and then a Teams link preview shows only a still picture"
},
"createGif_one": "Create {{count}} GIF",
"createGif_other": "Create {{count}} GIFs",
"createdGif_one": "{{count}} GIF added to your downloads",
"createdGif_other": "{{count}} GIFs added to your downloads",
"segment": "Segment {{n}}",
"keep": "Keep",
"drop": "Drop",
+20 -1
View File
@@ -22,6 +22,23 @@
"shape": "Keret aránya",
"shapeFree": "Szabad",
"whole": "Teljes kép",
"format": "Formátum",
"formatVideo": "Videó",
"formatGif": "GIF",
"gif": {
"size": "A GIF mérete, a hosszabbik oldal pixelben",
"sizeLabel": "Méret",
"side": "{{n}} px",
"estimate": "kb. {{lo}}–{{hi}} MB",
"estimateOne": "kb. {{lo}} MB",
"hint": "Hang nélkül, 12 kép másodpercenként",
"noPreview": "15 MB fölé mehet, és akkor a Teams link-előnézetben állókép lesz. A letöltött fájl mindenhol mozog.",
"mayFail": "30 MB fölé mehet, és akkor nem készül el. Rövidebb rész vagy kisebb méret segít.",
"tooLong": "A GIF legfeljebb {{seconds}} másodperc lehet. Rövidítsd a kijelölést.",
"make": "GIF készítése",
"queued": "Készül a GIF — a letöltéseid közé kerül",
"failed": "Nem sikerült elkészíteni a GIF-et"
},
"errors": {
"bad_source": "Ebből a videóból nem lehet klipet vágni.",
"bad_range": "A klip eleje vagy vége érvénytelen.",
@@ -31,6 +48,8 @@
"bad_crop": "A kivágókeret érvénytelen. Nagyítsd meg, vagy térj vissza a teljes képre.",
"not_found": "Ez az elem már nincs a könyvtárban.",
"media_missing": "Az elem fájlja innen nem érhető el.",
"past_end": "A klip túlnyúlik a videó végén."
"past_end": "A klip túlnyúlik a videó végén.",
"bad_gif": "Ez a GIF-méret nem választható.",
"gif_too_long": "A GIF legfeljebb {{seconds}} másodperc lehet."
}
}
@@ -224,6 +224,10 @@
"empty_edit": "Előbb válassz vágási tartományt vagy körbevágási területet",
"source_not_ready": "A forrásletöltés még nem szerkeszthető",
"not_media": "Csak videó és hang szerkeszthető",
"bad_gif": "Ez a GIF-méret nem választható",
"gif_range": "GIF egyetlen szakaszból készül, aminek eleje és vége is van",
"gif_too_long": "A GIF legfeljebb {{seconds}} másodperc lehet",
"gif_not_video": "GIF csak videóból készülhet",
"generic": "Nem sikerült létrehozni a vágást"
},
"upload": {
@@ -312,6 +316,10 @@
"short": "Nem sikerült letölteni",
"body": "Valami hiba történt ennél a letöltésnél. Az alábbi technikai részletek segíthetnek."
},
"gif_too_big": {
"short": "Túl nagy a GIF",
"body": "Ez a GIF nagyobb lett, mint amekkora egy GIF lehet, ezért nem maradt meg. Jelölj ki rövidebb szakaszt, vagy válassz kisebb méretet, és készítsd el újra."
},
"quota_exceeded": {
"short": "Nincs elég tárhely",
"body": "Ez a letöltés nem fér bele a megmaradt tárhelyedbe. Törölj valamit a könyvtáradból, aztán próbáld újra."
+20 -1
View File
@@ -24,12 +24,31 @@
"created_other": "{{count}} klip a letöltésekhez adva",
"failed": "Nem sikerült létrehozni a klipet",
"partlyCreated": "{{total}} klipből csak {{done}} került a letöltésekhez — a többi kijelölve maradt, így az újbóli létrehozás csak azokat adja hozzá.",
"failedGif": "Nem sikerült létrehozni a GIF-et",
"partlyCreatedGif": "{{total}} GIF-ből csak {{done}} került a letöltésekhez — a többi kijelölve maradt, így az újbóli létrehozás csak azokat adja hozzá.",
"splitHere": "Vágás itt",
"output": {
"label": "Kimenet",
"separate": "Külön fájlok",
"join": "Egy fájlba fűzve"
"join": "Egy fájlba fűzve",
"joinNeedsTwo": "Az összefűzéshez legalább két szegmenst tarts meg",
"joinGif": "GIF-et nem lehet összefűzni: minden megtartott szegmensből külön GIF lesz"
},
"format": {
"label": "Formátum",
"video": "Videó",
"gif": "GIF",
"estimate": "összesen kb. {{lo}}–{{hi}} MB",
"estimateOne": "összesen kb. {{lo}} MB",
"reencode": "A GIF mindig újrakódol: hang nélkül, 12 kép másodpercenként",
"segTooLong": "{{n}}. szegmens: {{length}} hosszú, de a GIF legfeljebb {{seconds}} másodperc lehet",
"segMayFail": "{{n}}. szegmens: GIF-ként 30 MB fölé mehet, és akkor nem készül el",
"segNoPreview": "{{n}}. szegmens: GIF-ként 15 MB fölé mehet, és akkor a Teams link-előnézetben állókép lesz"
},
"createGif_one": "{{count}} GIF létrehozása",
"createGif_other": "{{count}} GIF létrehozása",
"createdGif_one": "{{count}} GIF a letöltésekhez adva",
"createdGif_other": "{{count}} GIF a letöltésekhez adva",
"segment": "{{n}}. szegmens",
"keep": "Megtart",
"drop": "Eldob",
+5
View File
@@ -62,6 +62,8 @@ export interface ClipRequest {
audio_track?: number;
/** A crop frame: fractions of the box it was drawn in, and that box's shape (downloads/clip). */
crop?: { x: number; y: number; w: number; h: number; aspect: number };
/** Make it a GIF, this long side in pixels (`lib/gif` GIF_SIDES); left out, an mp4. */
gif_side?: number;
display_name?: string;
}
@@ -74,6 +76,9 @@ export interface EditSpec {
// scales them to the file's own pixels (a video's stored and shown sizes can differ).
crop?: { x: number; y: number; w: number; h: number; frame_w?: number; frame_h?: number };
accurate?: boolean;
/** Make it a GIF (a single trim with an end, `lib/gif` GIF_MAX_S long at most): a GIF is always
* re-encoded, so `accurate` does not apply. */
gif?: { side: number };
}
export interface StoryboardMeta {
@@ -55,6 +55,15 @@ describe.each(["en", "hu"])("localizeDetail (%s)", (lng) => {
expect(upload).not.toBe(i18n.t("downloads.errors.upload.tooLarge", { size: "" }));
});
it("says a clip's cap in minutes and a GIF's in seconds, from a clip or an edit", async () => {
expect(await say({ code: "clip", reason: "too_long", limit_s: 600 })).toContain("10");
for (const code of ["clip", "edit"]) {
const said = await say({ code, reason: "gif_too_long", limit_s: 30 });
expect(said).toContain("30");
expect(said).not.toContain("{{");
}
});
it("says a shared-sentence code's own fallback for a reason it does not know", async () => {
expect(await say({ code: "quota", reason: "nope" })).toBe(
i18n.t("downloads.errors.quota.generic"),
+9 -4
View File
@@ -30,13 +30,18 @@ const byReason = new Map(
const fixed = new Map<string, string>(Object.entries(REFUSAL_FIXED));
// The numbers a refusal carries, in the reader's number format: a download cap in GB, a clip cap in
// minutes, an upload cap in bytes, a password's minimum length; a generic `refused` interpolates
// whatever values its sentence names (`{{min}}`, `{{name}}`, …).
// minutes (a GIF's in seconds), an upload cap in bytes, a password's minimum length; a generic
// `refused` interpolates whatever values its sentence names (`{{min}}`, `{{name}}`, …).
function valuesOf(o: NonNullable<Detail>): Record<string, unknown> {
const lang = i18n.language;
if (o.code === "refused") return { ...o };
if (o.code === "clip")
return { minutes: o.limit_s == null ? undefined : formatDecimal(o.limit_s / 60, lang, 1, 0) };
if (o.code === "clip" || o.code === "edit")
return o.limit_s == null
? {}
: {
minutes: formatDecimal(o.limit_s / 60, lang, 1, 0),
seconds: formatDecimal(o.limit_s, lang, 0, 0),
};
if (o.code === "quota")
return o.reason === "max_bytes"
? { size: formatDecimal((o.limit || 0) / 1_073_741_824, lang, 1, 0) }
+52
View File
@@ -0,0 +1,52 @@
import { readFileSync } from "node:fs";
import { describe, expect, it } from "vitest";
// The GIF's sizes and limits, held in two languages: the panels offer the sizes the server takes and
// stop a range it would refuse. Neither side can see the other's copy, so the gate reads both (as
// `clipLimits.contract.test.ts` does for a clip's). Read as TEXT, so a renamed constant fails here
// instead of comparing two undefineds.
const src = (u: string) => readFileSync(new URL(u, import.meta.url), "utf-8");
const backend = src("../../../backend/app/downloads/gif.py");
const frontend = src("./gif.ts");
// A product of integers ("15 * 1024 * 1024"), as both files write a byte cap.
const product = (expr: string) =>
expr
.split("*")
.map((n) => Number(n.trim()))
.reduce((a, b) => a * b, 1);
function pyConst(name: string): string {
const m = backend.match(new RegExp(`^${name}\\s*=\\s*(.+)$`, "m"));
expect(m, `backend/app/downloads/gif.py no longer defines ${name}`).toBeTruthy();
return m![1]!.trim();
}
function tsConst(name: string): string {
const m = frontend.match(
new RegExp(`^export const ${name}(?:: \\w+)? = (.+?)(?: as const)?;$`, "m"),
);
expect(m, `lib/gif.ts no longer defines ${name}`).toBeTruthy();
return m![1]!.trim();
}
const list = (s: string) =>
s
.replace(/[[\]()]/g, "")
.split(",")
.map((n) => Number(n.trim()));
describe("GIF limits", () => {
it("offers the sizes the server takes", () => {
expect(list(tsConst("GIF_SIDES"))).toEqual(list(pyConst("SIDES")));
expect(Number(tsConst("GIF_DEFAULT_SIDE"))).toBe(Number(pyConst("DEFAULT_SIDE")));
});
it.each([
["GIF_MAX_S", "MAX_S"],
["GIF_PREVIEW_MAX_BYTES", "PREVIEW_MAX_BYTES"],
["GIF_MAX_BYTES", "MAX_BYTES"],
])("%s is the server's %s", (ts, py) => {
expect(product(tsConst(ts))).toBe(product(pyConst(py)));
});
});
+93
View File
@@ -0,0 +1,93 @@
import { describe, expect, it } from "vitest";
import {
estimateGif,
gifEstimateText,
GIF_MAX_BYTES,
GIF_PREVIEW_MAX_BYTES,
gifShownLength,
gifVerdict,
} from "./gif";
import { formatTimecode } from "./timecode";
const MB = 1024 * 1024;
describe("estimateGif", () => {
it("gives the measured range for a 16:9 picture", () => {
const e = estimateGif(30, 480, 16 / 9);
expect(e.lo / MB).toBeCloseTo(12, 5);
expect(e.hi / MB).toBeCloseTo(30, 5);
});
it("grows with the length and the size", () => {
expect(estimateGif(20, 480, 16 / 9).hi).toBeGreaterThan(estimateGif(10, 480, 16 / 9).hi);
expect(estimateGif(10, 640, 16 / 9).hi).toBeGreaterThan(estimateGif(10, 480, 16 / 9).hi);
});
it("counts the pixels of the shape: a square holds more than 16:9, a tall one as many", () => {
const wide = estimateGif(10, 480, 16 / 9).hi;
expect(estimateGif(10, 480, 1).hi).toBeCloseTo(wide * (16 / 9), 3);
expect(estimateGif(10, 480, 9 / 16).hi).toBeCloseTo(wide, 3);
});
it("falls back to 16:9 for a shape it cannot read", () => {
expect(estimateGif(10, 480, 0).hi).toBe(estimateGif(10, 480, 16 / 9).hi);
expect(estimateGif(10, 480, Number.NaN).hi).toBe(estimateGif(10, 480, 16 / 9).hi);
});
});
describe("gifEstimateText", () => {
it("says whether both ends read the same, so a panel says one size", () => {
expect(gifEstimateText(estimateGif(1, 480, 16 / 9), "en")).toEqual({
lo: "1",
hi: "1",
single: true,
});
expect(gifEstimateText(estimateGif(10, 480, 16 / 9), "en")).toEqual({
lo: "4",
hi: "10",
single: false,
});
});
});
describe("gifVerdict", () => {
it("refuses past the length cap, whatever the size", () => {
expect(gifVerdict(0, 30.2, { hi: 1 })).toBe("too_long");
expect(gifVerdict(0, 30, { hi: 1 })).toBe("ok");
});
it("judges the length the server judges: whole milliseconds between the ends as sent", () => {
// A float difference a hair over 30 s is exactly 30 s once the ends are sent.
expect(60.529 - 30.529).toBeGreaterThan(30);
expect(gifVerdict(30.529, 60.529, { hi: 1 })).toBe("ok");
expect(gifVerdict(0, 30.001, { hi: 1 })).toBe("too_long");
// Each end is sent rounded on its own: 10.0004–40.0008 goes as 10–40.001, 30001 ms, refused…
expect(gifVerdict(10.0004, 40.0008, { hi: 1 })).toBe("too_long");
// …and 10.0006–40.0012 as 10.001–40.001, exactly 30 s, made.
expect(gifVerdict(10.0006, 40.0012, { hi: 1 })).toBe("ok");
});
it("warns past the server's cap, then past the link preview's", () => {
expect(gifVerdict(0, 10, { hi: GIF_MAX_BYTES + 0.6 * MB })).toBe("may_fail");
expect(gifVerdict(0, 10, { hi: GIF_MAX_BYTES })).toBe("no_preview");
expect(gifVerdict(0, 10, { hi: GIF_PREVIEW_MAX_BYTES + 0.6 * MB })).toBe("no_preview");
expect(gifVerdict(0, 10, { hi: GIF_PREVIEW_MAX_BYTES })).toBe("ok");
});
it("judges the size the reader is shown: a high end shown as 30 MB is not past 30 MB", () => {
// 30 s at 480 lands a hair over 30 MiB, and reads "about 12–30 MB".
const hi = GIF_MAX_BYTES + 0.4 * MB;
expect(gifEstimateText({ lo: 12 * MB, hi }, "en").hi).toBe("30");
expect(gifVerdict(0, 30, { hi })).toBe("no_preview");
expect(gifVerdict(0, 10, { hi: GIF_PREVIEW_MAX_BYTES + 0.4 * MB })).toBe("ok");
});
});
describe("gifShownLength", () => {
it("never shows a refused range as one the cap allows", () => {
expect(formatTimecode(gifShownLength(0, 30.03))).toBe("0:30.1");
expect(formatTimecode(gifShownLength(0, 30.001))).toBe("0:30.1");
expect(gifShownLength(30.529, 60.529)).toBe(30);
expect(gifShownLength(0, 12.34)).toBe(12.34);
});
});
+79
View File
@@ -0,0 +1,79 @@
// A GIF cut from a clip or an edit: the sizes it may be made at and the limits it is held to, the
// server's (backend downloads/gif.py; `gif.contract.test.ts` holds the two copies together), and the
// size it is expected to come out at, which the panels show before anything is made.
import { formatNumber } from "./format";
/** The longest side of the GIF, in pixels: the choices the server takes. */
export const GIF_SIDES = [320, 480, 640] as const;
export type GifSide = (typeof GIF_SIDES)[number];
export const GIF_DEFAULT_SIDE: GifSide = 480;
/** The longest GIF the server makes, in seconds. */
export const GIF_MAX_S = 30;
/** Over this a chat's link preview (Teams) shows no moving picture: the share page sends a still. */
export const GIF_PREVIEW_MAX_BYTES = 15 * 1024 * 1024;
/** Over this the server refuses the finished GIF. */
export const GIF_MAX_BYTES = 30 * 1024 * 1024;
// Bytes a second, measured on real films at 12 frames a second, at each size of a 16:9 picture: the
// low and the high end of what four sources gave. The same settings spread 2.5× by what is in the
// picture, so only a range can be said before the GIF is made.
const MB = 1024 * 1024;
const RATE: Record<GifSide, readonly [number, number]> = {
320: [0.23 * MB, 0.47 * MB],
480: [0.4 * MB, 1.0 * MB],
640: [0.7 * MB, 1.6 * MB],
};
/** The size a GIF is expected to come out at, low and high, in bytes. `aspect` is the picture's
* width / height after any crop: the long side is capped, so a square or tall picture holds more
* or fewer pixels than the 16:9 one the rates were measured on. */
export function estimateGif(
seconds: number,
side: GifSide,
aspect: number,
): { lo: number; hi: number } {
const shape = aspect > 0 && Number.isFinite(aspect) ? aspect : 16 / 9;
const area = shape >= 1 ? 1 / shape : shape;
const scale = area / (9 / 16);
const [lo, hi] = RATE[side];
const s = Math.max(0, seconds);
return { lo: lo * s * scale, hi: hi * s * scale };
}
/** An estimate's ends as whole megabytes in the reader's number format (at least 1, as a GIF of a
* second or two still takes one in the reader's eyes). `single` when both ends read the same: the
* panels then say one number ("about 1 MB"), not a range of one ("about 1–1 MB"). */
export function gifEstimateText(est: { lo: number; hi: number }, lang: string) {
const lo = Math.max(1, Math.round(est.lo / MB));
const hi = Math.max(lo, Math.round(est.hi / MB));
return { lo: formatNumber(lo, lang), hi: formatNumber(hi, lang), single: lo === hi };
}
/** What the panels say about a GIF before it is made: too long (refused, it does not start), it may
* pass the cap (refused when it does), it may be too big to move in a link preview, or fine. */
export type GifVerdict = "too_long" | "may_fail" | "no_preview" | "ok";
/** A time as the request sends it: to the millisecond. */
export const atMs = (s: number) => Math.round(s * 1000) / 1000;
/** A range's length in whole milliseconds, between its ends as the request sends them: the length
* the server judges (gif.too_long), so a range the panel allows is never refused for its length,
* and none it refuses would have been made. */
const spanMs = (start: number, end: number) => Math.round(end * 1000) - Math.round(start * 1000);
/** A range's length to show beside a verdict: over the cap it is rounded up to the tenth, so a
* refused range never reads as one the cap allows ("0:30.0" for 30.03 s). */
export function gifShownLength(start: number, end: number): number {
const ms = spanMs(start, end);
return ms > GIF_MAX_S * 1000 ? Math.ceil(ms / 100) / 10 : ms / 1000;
}
export function gifVerdict(start: number, end: number, est: { hi: number }): GifVerdict {
if (spanMs(start, end) > GIF_MAX_S * 1000) return "too_long";
// Judged on the high end as shown (whole megabytes): "about 12–30 MB" beside "it may pass 30 MB"
// would contradict itself, and the estimate is no finer than that anyway.
const hi = Math.round(est.hi / MB) * MB;
if (hi > GIF_MAX_BYTES) return "may_fail";
if (hi > GIF_PREVIEW_MAX_BYTES) return "no_preview";
return "ok";
}
+5 -3
View File
@@ -423,12 +423,14 @@ describe("module URL keys", () => {
});
const named = new Set(["MODE_URL", "PLEX_SUB_URL"]);
const unnamed = (text: string) => handed(text).filter((arg) => !named.has(arg));
// Each source read once: read three times, the scan ran past the test timeout under a loaded gate.
const files = sources(SRC).filter(
(f) => !relative(SRC, f).split("\\").join("/").startsWith("lib/history"),
);
expect(files.flatMap((f) => unnamed(readFileSync(f, "utf8")))).toEqual([]);
const texts = files.map((f) => readFileSync(f, "utf8"));
expect(texts.flatMap(unnamed)).toEqual([]);
// The call sites are seen (a scan of none would pass)…
expect(new Set(files.flatMap((f) => handed(readFileSync(f, "utf8"))))).toEqual(named);
expect(new Set(texts.flatMap(handed))).toEqual(named);
// …and the check tells an inline definition from a named one.
expect(unnamed('useHistorySubview(sub, { key: "zz", encode, decode })')).toEqual([
'{ key: "zz", encode, decode }',
@@ -442,7 +444,7 @@ describe("module URL keys", () => {
]);
// Under another name it is a call no scan sees.
const aliased = (text: string) => /\buseHistorySubview\s+as\b/.test(text);
expect(files.filter((f) => aliased(readFileSync(f, "utf8")))).toEqual([]);
expect(files.filter((_f, i) => aliased(texts[i]!))).toEqual([]);
expect(aliased('import { useHistorySubview as useSub } from "./history";')).toBe(true);
});
@@ -0,0 +1,249 @@
// @vitest-environment jsdom
//
// The editor's GIF: each kept segment becomes a GIF of its own at the size picked (never a joined
// cut-list, and never with the precise/fast choice, which a GIF does not have), a whole short video
// is a GIF too, and a segment longer than a GIF may be leaves Create unavailable with its reason.
import { describe, it, expect, vi, beforeAll, afterEach } from "vitest";
import { render, screen, fireEvent, waitFor, cleanup, within } from "@testing-library/react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
vi.mock("react-i18next", async (importOriginal) => ({
...(await importOriginal<typeof import("react-i18next")>()),
// A size option reads as its own words with its unit ("480 px"), a segment's note with the
// segment it names (and a too-long one with the length it gives), the kept summary with its
// length; every other text is its key.
useTranslation: () => ({
t: (key: string, o?: { n?: string; length?: string; dur?: string }) =>
key === "clip.gif.side"
? `${o?.n} px`
: key === "editor.format.segTooLong"
? `${key} #${o?.n} ${o?.length}`
: key === "editor.format.segMayFail" || key === "editor.format.segNoPreview"
? `${key} #${o?.n}`
: key === "editor.keptSummary"
? `${key} ${o?.dur}`
: key,
i18n: { language: "en" },
}),
}));
const { notify } = vi.hoisted(() => ({ notify: vi.fn() }));
vi.mock("../../lib/notifications", async (importOriginal) => ({
...(await importOriginal<typeof import("../../lib/notifications")>()),
notify,
}));
import VideoEditor from "./VideoEditor";
import { api, HttpError, type DownloadJob } from "../../lib/api";
import { markReported } from "../../lib/api/core";
import { msg } from "../../lib/notifications";
// A refusal as `req` delivers it: already shown in its error dialog, and marked so.
const markedReported = (e: HttpError) => {
markReported(e);
return e;
};
beforeAll(() => {
class Stub {
observe() {}
unobserve() {}
disconnect() {}
takeRecords() {
return [];
}
}
globalThis.ResizeObserver ??= Stub as unknown as typeof ResizeObserver;
globalThis.IntersectionObserver ??= Stub as unknown as typeof IntersectionObserver;
});
afterEach(() => {
cleanup();
vi.restoreAllMocks();
notify.mockReset();
});
const jobOf = (duration: number, kind = "video") =>
({
id: 7,
display_name: "Talk",
asset: { duration_s: duration, width: 1920, height: 1080, title: "Talk", media_kind: kind },
}) as unknown as DownloadJob;
function open(duration: number, kind = "video") {
vi.spyOn(api, "downloadStoryboard").mockResolvedValue({ available: false } as never);
const enqueue = vi.spyOn(api, "enqueueEdit").mockResolvedValue({} as never);
const onClose = vi.fn();
render(
<QueryClientProvider client={new QueryClient()}>
<VideoEditor job={jobOf(duration, kind)} onClose={onClose} />
</QueryClientProvider>,
);
const video = document.querySelector("video")!;
const playhead = (s: number) => {
Object.defineProperty(video, "currentTime", { value: s, configurable: true, writable: true });
fireEvent.timeUpdate(video);
};
return { enqueue, onClose, playhead };
}
describe("VideoEditor — GIF", () => {
it("makes a whole short video a GIF at the size picked, with no precise/fast choice", async () => {
const { enqueue, onClose } = open(20);
fireEvent.click(screen.getByRole("button", { name: "editor.format.gif" }));
fireEvent.click(screen.getByRole("button", { name: "640 px" }));
expect(screen.queryByText("editor.accurate.label")).toBeNull();
expect(screen.getByText("editor.format.reencode")).toBeTruthy();
fireEvent.click(screen.getByRole("button", { name: "editor.createGif" }));
await waitFor(() => expect(onClose).toHaveBeenCalled());
expect(enqueue).toHaveBeenCalledTimes(1);
expect(enqueue.mock.calls[0]![0].edit_spec).toEqual({
trim: { start_s: 0, end_s: 20 },
gif: { side: 640 },
});
expect(notify).toHaveBeenCalledWith(
expect.objectContaining({ message: msg("editor.createdGif", { count: 1 }) }),
);
});
it("cuts each kept segment into a GIF of its own, even with Join picked before", async () => {
const { enqueue, playhead } = open(20);
playhead(8);
fireEvent.click(screen.getByText("editor.splitHere"));
fireEvent.click(screen.getByRole("button", { name: "editor.output.join" }));
fireEvent.click(screen.getByRole("button", { name: "editor.format.gif" }));
const join = screen.getByRole("button", { name: "editor.output.join" });
expect(join.getAttribute("aria-disabled")).toBe("true");
expect(join.getAttribute("aria-description")).toBe("editor.output.joinGif");
expect(join.getAttribute("aria-pressed")).toBe("false");
fireEvent.click(screen.getByRole("button", { name: "editor.createGif" }));
await waitFor(() => expect(enqueue).toHaveBeenCalledTimes(2));
expect(enqueue.mock.calls.map((c) => c[0].edit_spec)).toEqual([
{ trim: { start_s: 0, end_s: 8 }, gif: { side: 480 } },
{ trim: { start_s: 8, end_s: 20 }, gif: { side: 480 } },
]);
});
it("a segment longer than a GIF may be leaves Create unavailable, with its reason", () => {
const { enqueue } = open(100);
fireEvent.click(screen.getByRole("button", { name: "editor.format.gif" }));
const create = screen.getByRole("button", { name: "editor.createGif" });
expect(create.getAttribute("aria-disabled")).toBe("true");
const notes = document.getElementById(create.getAttribute("aria-describedby")!)!;
expect(notes.textContent).toBe("editor.format.segTooLong #1 1:40.0");
fireEvent.click(create);
expect(enqueue).not.toHaveBeenCalled();
// A video of the same range has no such limit.
fireEvent.click(screen.getByRole("button", { name: "editor.format.video" }));
expect(
screen.getByRole("button", { name: "editor.create" }).getAttribute("aria-disabled"),
).toBe(null);
});
it("says a refused length so it never reads as one the cap allows", () => {
// A phone video of 30.03 s: refused, and said as 0:30.1, not 0:30.0.
open(30.03);
fireEvent.click(screen.getByRole("button", { name: "editor.format.gif" }));
const create = screen.getByRole("button", { name: "editor.createGif" });
expect(create.getAttribute("aria-disabled")).toBe("true");
const notes = document.getElementById(create.getAttribute("aria-describedby")!)!;
expect(notes.textContent).toBe("editor.format.segTooLong #1 0:30.1");
// The segment's own length and the kept summary say it the same way, never 0:30.0.
expect(screen.getByText("0:30.1")).toBeTruthy();
expect(screen.getByText("editor.keptSummary 0:30.1")).toBeTruthy();
expect(screen.queryByText("0:30.0")).toBeNull();
});
it("names each segment by its place, the worst note first, a dropped one left out", () => {
const { playhead } = open(80);
playhead(10);
fireEvent.click(screen.getByText("editor.splitHere"));
playhead(30);
fireEvent.click(screen.getByText("editor.splitHere"));
// Segment 1 (0–10) dropped; 2 (10–30) and 3 (30–80) kept.
const track = screen.getByRole("group", { name: "editor.timeline" });
fireEvent.pointerDown(within(track).getAllByRole("button", { pressed: true })[0]!);
fireEvent.click(screen.getByRole("button", { name: "editor.format.gif" }));
fireEvent.click(screen.getByRole("button", { name: "640 px" }));
const notes = [...document.querySelectorAll("li")].map((l) => l.textContent);
// 50 s is too long, 20 s at 640 may pass 30 MB; the dropped segment says nothing.
expect(notes).toEqual(["editor.format.segTooLong #3 0:50.0", "editor.format.segMayFail #2"]);
});
it("says a note already true when GIF is picked inside a live region that was there before", () => {
open(100);
const region = document.querySelector("div[aria-live='polite']")!;
expect(region.textContent).toBe("");
fireEvent.click(screen.getByRole("button", { name: "editor.format.gif" }));
expect(document.querySelector("div[aria-live='polite']")).toBe(region);
expect(region.textContent).toBe("editor.format.segTooLong #1 1:40.0");
});
it("says a GIF failure as one of GIFs, not of clips", async () => {
const { enqueue, playhead } = open(20);
enqueue
.mockReset()
.mockResolvedValueOnce({} as never)
.mockRejectedValueOnce(
markedReported(new HttpError(422, "errors.generic", { code: "quota" })),
)
.mockRejectedValueOnce(new Error("offline"));
playhead(8);
fireEvent.click(screen.getByText("editor.splitHere"));
fireEvent.click(screen.getByRole("button", { name: "editor.format.gif" }));
fireEvent.click(screen.getByRole("button", { name: "editor.createGif" }));
await waitFor(() =>
expect(notify).toHaveBeenCalledWith(
expect.objectContaining({ title: msg("editor.partlyCreatedGif", { done: 1, total: 2 }) }),
),
);
// The one left fails on its own: said as a GIF too.
fireEvent.click(screen.getByRole("button", { name: "editor.createGif" }));
await waitFor(() =>
expect(notify).toHaveBeenCalledWith(
expect.objectContaining({ title: msg("editor.failedGif") }),
),
);
});
it("offers no GIF for an audio file, which has no picture to make one of", () => {
open(20, "audio");
expect(screen.queryByRole("button", { name: "editor.format.gif" })).toBeNull();
expect(screen.getByRole("button", { name: "editor.create" })).toBeTruthy();
});
it("keeps its notes' live region in place, so the first note is read out", () => {
open(20);
fireEvent.click(screen.getByRole("button", { name: "editor.format.gif" }));
// 20 s at 320: about 5–9 MB, nothing to say — but the region is already there.
fireEvent.click(screen.getByRole("button", { name: "320 px" }));
const region = document.querySelector("div[aria-live='polite']")!;
expect(region).toBeTruthy();
expect(region.textContent).toBe("");
// 20 s at 640: up to about 32 MB, said inside the same region.
fireEvent.click(screen.getByRole("button", { name: "640 px" }));
expect(document.querySelector("div[aria-live='polite']")).toBe(region);
expect(region.textContent).toBe("editor.format.segMayFail #1");
});
it("says one size for a short GIF, and none when nothing is kept", () => {
open(2);
fireEvent.click(screen.getByRole("button", { name: "editor.format.gif" }));
fireEvent.click(screen.getByRole("button", { name: "320 px" }));
// 2 s at 320: 0.46–0.94 MB, both shown as 1.
expect(screen.getByText("editor.format.estimateOne")).toBeTruthy();
fireEvent.click(screen.getByRole("button", { name: "editor.kept" }));
expect(screen.queryByText("editor.format.estimateOne")).toBeNull();
expect(screen.queryByText("editor.format.estimate")).toBeNull();
});
it("Join needs two kept segments, and says so instead of going dead", async () => {
open(20);
const join = screen.getByRole("button", { name: "editor.output.join" });
expect(join.hasAttribute("disabled")).toBe(false);
expect(join.getAttribute("aria-disabled")).toBe("true");
expect(join.getAttribute("aria-description")).toBe("editor.output.joinNeedsTwo");
// The reason is seen on a key's focus too, not only under a mouse (a native title is not).
expect(join.hasAttribute("title")).toBe(false);
fireEvent.focus(join);
expect(await screen.findByText("editor.output.joinNeedsTwo")).toBeTruthy();
});
});
+178 -30
View File
@@ -10,7 +10,10 @@ import { msg, notify } from "../../lib/notifications";
import { api, type DownloadJob, type EditSpec } from "../../lib/api";
import { cropToSourcePx, type Frac } from "../../lib/cropGeometry";
import { CropFrame } from "../../components/ui/CropFrame";
import SegmentedToggle from "../../components/ui/SegmentedToggle";
import { Button } from "../../components/ui/Button";
import Tooltip from "../../components/ui/Tooltip";
import { unavailable } from "../../components/ui/unavailable";
import { failureReason, notifyActionError } from "../../lib/actionErrors";
import { markReported } from "../../lib/api/core";
import { IconButton } from "../../components/ui/IconButton";
@@ -22,6 +25,19 @@ import { SURFACE } from "../../components/ui/surfaces";
import { RangeTrack } from "../../components/ui/RangeTrack";
import { trackLeft } from "../../components/ui/trackPosition";
import { formatTimecode } from "../../lib/timecode";
import { formatNumber } from "../../lib/format";
import {
atMs,
estimateGif,
gifEstimateText,
gifShownLength,
gifVerdict,
GIF_DEFAULT_SIDE,
GIF_MAX_S,
GIF_SIDES,
type GifSide,
type GifVerdict,
} from "../../lib/gif";
type Seg = { id: number; start: number; end: number; keep: boolean };
@@ -40,8 +56,18 @@ async function enqueueClips(
}
}
// A GIF is cut per kept segment (the server makes one GIF of one range, never a joined cut-list), so
// what the editor says before Create is said per segment: the worst first — one too long stops
// Create — then one that may pass the server's cap, then one too big to move in a link preview.
const GIF_NOTE_ORDER: GifVerdict[] = ["too_long", "may_fail", "no_preview"];
const GIF_NOTE_KEY = {
too_long: "editor.format.segTooLong",
may_fail: "editor.format.segMayFail",
no_preview: "editor.format.segNoPreview",
} as const;
export default function VideoEditor({ job, onClose }: { job: DownloadJob; onClose: () => void }) {
const { t } = useTranslation();
const { t, i18n } = useTranslation();
const qc = useQueryClient();
const videoRef = useRef<HTMLVideoElement>(null);
const trackRef = useRef<HTMLDivElement>(null);
@@ -72,6 +98,13 @@ export default function VideoEditor({ job, onClose }: { job: DownloadJob; onClos
const [cropOn, setCropOn] = useState(false);
const [crop, setCrop] = useState<Frac>({ x: 0.1, y: 0.1, w: 0.8, h: 0.8 });
const [accurate, setAccurate] = useState(true);
const [format, setFormat] = useState<"video" | "gif">("video");
const [side, setSide] = useState<GifSide>(GIF_DEFAULT_SIDE);
// A GIF is made of the picture: an audio file has none (or only its cover), so it is offered for
// a video alone, as the server holds it.
const canGif = job.asset?.media_kind === "video";
const gif = canGif && format === "gif";
const gifNoteId = useId();
const [name, setName] = useState("");
const fileUrl = useMemo(() => api.fileUrl(job.id), [job.id]);
@@ -222,9 +255,35 @@ export default function VideoEditor({ job, onClose }: { job: DownloadJob; onClos
kept.length === 1 &&
kept[0]!.start <= 0.01 &&
kept[0]!.end >= duration - 0.01;
const valid = kept.length >= 1 && keptDur >= MIN_SEG && (cropOn || !isFullSingle);
const willJoin = output === "join" && kept.length > 1;
const reencode = cropOn || accurate;
// A GIF of the whole video is no no-op: the format changes.
const valid = kept.length >= 1 && keptDur >= MIN_SEG && (cropOn || gif || !isFullSingle);
const willJoin = !gif && output === "join" && kept.length > 1;
const reencode = cropOn || accurate || gif;
// The GIF's shape is the crop's when there is one (the long side is capped, so the shape decides
// how many pixels it holds).
const gifShape = cropOn ? (crop.w * va) / crop.h : va;
const gifSegs = kept.map((s) => {
const est = estimateGif(s.end - s.start, side, gifShape);
return {
n: segments.indexOf(s) + 1,
length: gifShownLength(s.start, s.end),
est,
verdict: gifVerdict(s.start, s.end, est),
};
});
const gifTotal = gifSegs.reduce((a, g) => ({ lo: a.lo + g.est.lo, hi: a.hi + g.est.hi }), {
lo: 0,
hi: 0,
});
const gifTotalText = gifEstimateText(gifTotal, i18n.language);
const gifNotes = GIF_NOTE_ORDER.flatMap((v) => gifSegs.filter((g) => g.verdict === v));
const gifBlocked = gif && gifSegs.some((g) => g.verdict === "too_long");
const joinReason = gif
? t("editor.output.joinGif")
: kept.length < 2
? t("editor.output.joinNeedsTwo")
: undefined;
const cropPx = (): EditSpec["crop"] => {
const v = videoRef.current;
@@ -245,7 +304,7 @@ export default function VideoEditor({ job, onClose }: { job: DownloadJob; onClos
const base = name.trim() || defaultName;
if (willJoin) {
const spec: EditSpec = {
segments: kept.map((s) => ({ start_s: +s.start.toFixed(3), end_s: +s.end.toFixed(3) })),
segments: kept.map((s) => ({ start_s: atMs(s.start), end_s: atMs(s.end) })),
accurate,
};
if (cp) spec.crop = cp;
@@ -267,10 +326,11 @@ export default function VideoEditor({ job, onClose }: { job: DownloadJob; onClos
// cannot lower a conditional or a loop inside one yet, and skipped the whole editor for it.
const clips = kept.map((s) => {
const spec: EditSpec = {
trim: { start_s: +s.start.toFixed(3), end_s: +s.end.toFixed(3) },
trim: { start_s: atMs(s.start), end_s: atMs(s.end) },
};
if (cp) spec.crop = cp;
else spec.accurate = accurate;
if (gif) spec.gif = { side };
else if (!cp) spec.accurate = accurate;
const dn = suffix.get(s.id) ? `${base}${suffix.get(s.id)}` : name.trim() || undefined;
return { id: s.id, spec, dn };
});
@@ -290,7 +350,10 @@ export default function VideoEditor({ job, onClose }: { job: DownloadJob; onClos
// what the dialog does not tell.
notify({
level: "error",
title: msg("editor.partlyCreated", { done: done.length, total: N }),
title: msg(gif ? "editor.partlyCreatedGif" : "editor.partlyCreated", {
done: done.length,
total: N,
}),
message: failureReason(e),
});
markReported(e);
@@ -302,10 +365,13 @@ export default function VideoEditor({ job, onClose }: { job: DownloadJob; onClos
onSuccess: ({ files }) => {
// The whole library moves with it: an edit the user already made is done at once, and lists.
invalidateDownloads(qc);
notify({ level: "success", message: msg("editor.created", { count: files }) });
notify({
level: "success",
message: msg(gif ? "editor.createdGif" : "editor.created", { count: files }),
});
onClose();
},
onError: (e) => notifyActionError(e, msg("editor.failed")),
onError: (e) => notifyActionError(e, msg(gif ? "editor.failedGif" : "editor.failed")),
onSettled: () => {
creating.current = false;
},
@@ -533,7 +599,7 @@ export default function VideoEditor({ job, onClose }: { job: DownloadJob; onClos
/>
</label>
<span className="text-xs text-muted tabular-nums w-16 text-right">
{tc(sel.end - sel.start)}
{tc(gif ? gifShownLength(sel.start, sel.end) : sel.end - sel.start)}
</span>
{(inField.invalid || outField.invalid) && (
<div className="basis-full">
@@ -546,24 +612,97 @@ export default function VideoEditor({ job, onClose }: { job: DownloadJob; onClos
{/* output mode + summary */}
<div className="flex flex-wrap items-center gap-2 text-sm">
<span className="text-muted">{t("editor.output.label")}:</span>
{(["separate", "join"] as const).map((m) => (
<Button
key={m}
variant="outline"
size="sm"
pressed={output === m}
onClick={() => setOutput(m)}
disabled={m === "join" && kept.length < 2}
>
{t(`editor.output.${m}`)}
</Button>
))}
{(["separate", "join"] as const).map((m) => {
// Join is unavailable with its reason in its tooltip (a GIF is never joined; a join
// needs two kept segments), shown on focus too, and stays focusable so the reason
// reaches the keyboard.
const reason = m === "join" ? joinReason : undefined;
return (
<Tooltip key={m} hint={reason ?? ""} always={!!reason} stable>
<Button
variant="outline"
size="sm"
pressed={(willJoin ? "join" : "separate") === m}
onClick={() => setOutput(m)}
{...unavailable(!!reason, reason)}
>
{t(`editor.output.${m}`)}
</Button>
</Tooltip>
);
})}
<div className="flex-1" />
<span className="text-xs text-muted">
{t("editor.keptSummary", { n: kept.length, dur: tc(keptDur) })}
{t("editor.keptSummary", {
n: kept.length,
// One GIF: its length as the notes say it (a refused one never reads as allowed).
dur: tc(
gif && kept.length === 1 ? gifShownLength(kept[0]!.start, kept[0]!.end) : keptDur,
),
})}
</span>
</div>
{/* format: a video, or each kept segment a GIF */}
{canGif && (
<div className="flex flex-wrap items-center gap-2 text-sm">
<span className="text-muted">{t("editor.format.label")}:</span>
<SegmentedToggle
ariaLabel={t("editor.format.label")}
value={format}
onChange={setFormat}
options={[
{ value: "video", label: t("editor.format.video") },
{ value: "gif", label: t("editor.format.gif") },
]}
/>
{gif && (
<>
<span className="ml-2 text-muted" aria-hidden="true">
{t("clip.gif.sizeLabel")}
</span>
<SegmentedToggle
ariaLabel={t("clip.gif.size")}
value={String(side)}
onChange={(v) => setSide(Number(v) as GifSide)}
options={GIF_SIDES.map((s) => ({
value: String(s),
label: t("clip.gif.side", { n: formatNumber(s, i18n.language) }),
}))}
/>
</>
)}
{/* The live regions stay mounted whatever the format (laid out as their content
alone): a live region announces what changes inside it, not what arrives together
with it, so a note already true when GIF is picked is said too. */}
<span className="contents" aria-live="polite">
{gif && kept.length > 0 && (
<span className="text-xs text-muted tabular-nums">
{t(
gifTotalText.single ? "editor.format.estimateOne" : "editor.format.estimate",
gifTotalText,
)}
</span>
)}
</span>
</div>
)}
<div id={gifNoteId} className="contents" aria-live="polite">
{gif && gifNotes.length > 0 && (
<ul className="space-y-0.5 text-xs">
{gifNotes.map((g) => (
<li key={g.n} className={g.verdict === "too_long" ? "text-danger" : "text-warn"}>
{t(GIF_NOTE_KEY[g.verdict as keyof typeof GIF_NOTE_KEY], {
n: g.n,
length: tc(g.length),
seconds: GIF_MAX_S,
})}
</li>
))}
</ul>
)}
</div>
{/* crop + precision */}
<div className="grid sm:grid-cols-2 gap-3">
<Button
@@ -575,7 +714,11 @@ export default function VideoEditor({ job, onClose }: { job: DownloadJob; onClos
>
{cropOn ? t("editor.cropOn") : t("editor.cropOff")}
</Button>
{!cropOn ? (
{gif ? (
<div className="flex items-center px-3 text-xs text-muted">
{t("editor.format.reencode")}
</div>
) : !cropOn ? (
<div className="flex gap-2">
{(["accurate", "fast"] as const).map((m) => {
const on = (m === "accurate") === accurate;
@@ -623,14 +766,19 @@ export default function VideoEditor({ job, onClose }: { job: DownloadJob; onClos
size="sm"
onClick={() => create.mutate()}
disabled={!valid}
// A GIF segment too long: unavailable with its reason (the notes above), focusable.
aria-disabled={gifBlocked || undefined}
aria-describedby={gifBlocked ? gifNoteId : undefined}
loading={create.isPending}
icon={<Scissors className="w-4 h-4" />}
>
{willJoin
? t("editor.createJoin")
: kept.length > 1
? t("editor.createN", { count: kept.length })
: t("editor.create")}
{gif
? t("editor.createGif", { count: kept.length })
: willJoin
? t("editor.createJoin")
: kept.length > 1
? t("editor.createN", { count: kept.length })
: t("editor.create")}
</Button>
</div>
</ModalFooter>
+86
View File
@@ -161,6 +161,68 @@ describe("WatchPage media element", () => {
expect(dl!.className).toContain("state-filled");
});
it("shows a shared picture as itself, no player, with its kind, size and the chat tip", async () => {
window.history.replaceState({}, "", "/watch/tok-gif");
mockMeta({
needs_password: false,
media_kind: "file",
preview: "image",
title: "Evening clip",
container: "gif",
width: 480,
height: 270,
size_bytes: 6_500_000,
allow_download: true,
file_url: "/f.gif",
});
const page = render(<WatchPage />);
const img = await page.findByRole("img", { name: "Evening clip" });
expect(img.getAttribute("src")).toBe("/f.gif");
expect(page.container.querySelector("video")).toBeNull();
expect(page.getByText(/GIF · 480×270 · 6\.2 MB/)).toBeTruthy();
expect(page.getByRole("link", { name: "Download" }).getAttribute("href")).toBe("/f.gif");
expect(page.getByText(/drag the downloaded file in/)).toBeTruthy();
});
it("a picture that does not load says so, rather than looking like a file without one", async () => {
window.history.replaceState({}, "", "/watch/tok-gif-gone");
mockMeta({
needs_password: false,
media_kind: "file",
preview: "image",
title: "Gone",
container: "gif",
allow_download: true,
file_url: "/gone.gif",
});
const page = render(<WatchPage />);
fireEvent.error(await page.findByRole("img", { name: "Gone" }));
// The first miss may be a hiccup: nothing is said while the retry waits…
expect(page.queryByRole("status")).toBeNull();
// …and when the retry fails too, the page says so.
const retry = await page.findByRole("img", { name: "Gone" }, { timeout: 3000 });
expect(retry.getAttribute("src")).toBe("/gone.gif?r=1");
fireEvent.error(retry);
expect((await page.findByRole("status")).textContent).toBe("Couldn't load the picture.");
});
it("a view-only picture link shows the picture, without Download or the chat tip", async () => {
window.history.replaceState({}, "", "/watch/tok-gif-view");
mockMeta({
needs_password: false,
media_kind: "file",
preview: "image",
title: "Still",
container: "gif",
allow_download: false,
file_url: "/f.gif",
});
const page = render(<WatchPage />);
await page.findByRole("img", { name: "Still" });
expect(page.queryByRole("link", { name: "Download" })).toBeNull();
expect(page.queryByText(/drag the downloaded file in/)).toBeNull();
});
it("omits the Download for a view-only file link", async () => {
window.history.replaceState({}, "", "/watch/tok-file-noview");
mockMeta({
@@ -418,6 +480,30 @@ describe("WatchPage folder", () => {
);
});
it("opens a picture above the list, shown rather than played", async () => {
window.history.replaceState({}, "", "/watch/tok-folder");
mockMeta(
folder({
files: [
file(1),
file(4, {
title: "Wave.gif",
media_kind: "file",
preview: "image",
container: "gif",
duration_s: null,
}),
],
}),
);
const page = render(<WatchPage />);
fireEvent.click(await page.findByRole("button", { name: "Open: Wave.gif" }));
const img = await page.findByRole("img", { name: "Wave.gif" });
expect(img.getAttribute("src")).toBe("/api/public/watch/tok-folder/files/4/file");
expect(page.container.querySelector("video")).toBeNull();
expect(page.getByRole("heading", { level: 2 }).textContent).toBe("Wave.gif");
});
it("offers a document for download only, and Download only where the link allows it", async () => {
window.history.replaceState({}, "", "/watch/tok-folder");
mockMeta(folder());
+67 -3
View File
@@ -44,6 +44,10 @@ type FileMeta = {
duration_s?: number | null;
audio?: boolean;
media_kind?: "video" | "audio" | "file" | null;
/** "image": a picture (a GIF cut from a clip, an uploaded photo), which the server serves as itself. */
preview?: "image" | null;
width?: number | null;
height?: number | null;
size_bytes?: number | null;
container?: string | null;
allow_download?: boolean;
@@ -335,6 +339,8 @@ function FileView({
writeJSON(LS.watchRepeat, next);
};
if (meta.media_kind === "file" && meta.preview === "image")
return <SharedPicture meta={meta} nested={nested} />;
if (meta.media_kind === "file")
/* A generic uploaded file has no player — just an info card (name/type/size) and, if the
link allows it, a Download. The backend serves the file force-download regardless, so
@@ -465,6 +471,61 @@ function FileView({
);
}
/** A shared picture: the picture itself, no player (a GIF plays and repeats on its own, and nobody
* pauses one), its name and kind under it, and the Download the link allows. A GIF says how to get
* it moving in a chat: a link's preview is the chat's to draw, the dragged-in file moves everywhere. */
function SharedPicture({ meta, nested }: { meta: FileMeta; nested: boolean }) {
// In a folder the page's own heading is the folder's.
const Heading = nested ? "h2" : "h1";
const gif = meta.container?.toLowerCase() === "gif";
const dims = meta.width && meta.height ? `${meta.width}×${meta.height}` : null;
return (
<div>
<div className="rounded-xl overflow-hidden bg-black border border-border w-fit max-w-full mx-auto">
<RemoteImage
src={meta.file_url}
alt={meta.title || T.file}
width={meta.width ?? undefined}
height={meta.height ?? undefined}
retries={1}
// A picture link always has a picture: a missing one failed to load, and says so —
// the bare file glyph would read as a file with no preview. Only once the retry failed
// too: while it waits, the frame stays empty.
pending={<div aria-hidden className="w-48 h-32" />}
fallback={
<div role="status" className="flex flex-col items-center gap-2 px-12 py-10 text-muted">
<FileIcon className="w-12 h-12" aria-hidden />
<p className="text-sm">{T.pictureFailed}</p>
</div>
}
className={clsx(
"max-w-full h-auto w-auto mx-auto block",
nested ? "max-h-[60dvh]" : "max-h-[80dvh]",
)}
/>
</div>
<div className={`${SURFACE.panel} mt-3 flex flex-wrap items-start gap-x-3 gap-y-2 p-3`}>
<div className="min-w-0 flex-1 basis-60">
<Heading className="text-lg font-semibold leading-snug break-words">
{meta.title || T.file}
</Heading>
<div className="text-sm text-muted">
{[meta.container?.toUpperCase(), dims, formatBytes(meta.size_bytes, lang)]
.filter(Boolean)
.join(" · ")}
</div>
</div>
{meta.allow_download && (
<LinkButton href={meta.file_url} icon={<Download />} size="sm" variant="glass">
{T.download}
</LinkButton>
)}
</div>
{gif && meta.allow_download && <p className="mt-3 text-sm text-muted">{T.gifTip}</p>}
</div>
);
}
/** A file's picture in the folder list: its poster frame, else a glyph of its kind; a video or a
* song carries its length. */
function RowThumb({ file }: { file: FileMeta }) {
@@ -536,6 +597,9 @@ function FolderView({ folder, token }: { folder: FolderMeta; token: string }) {
</span>
</>
);
// A picture opens above the list as a video does (it is shown, not played); any other
// file only downloads.
const picture = f.media_kind === "file" && f.preview === "image";
return (
<li
key={f.id}
@@ -544,19 +608,19 @@ function FolderView({ folder, token }: { folder: FolderMeta; token: string }) {
f.id === playing && "bg-accent/10",
)}
>
{f.media_kind === "file" ? (
{f.media_kind === "file" && !picture ? (
<div className="flex min-w-0 flex-1 items-center gap-3 px-1.5 py-1.5">{body}</div>
) : (
<Pressable
layer
selectable
aria-current={f.id === playing ? "true" : undefined}
aria-label={`${T.play}: ${title}`}
aria-label={`${picture ? T.open : T.play}: ${title}`}
onClick={() => setPlaying(f.id)}
className="flex min-w-0 flex-1 items-center gap-3 rounded-lg px-1.5 py-1.5"
>
{body}
<Play aria-hidden className="size-4 shrink-0 text-muted" />
{!picture && <Play aria-hidden className="size-4 shrink-0 text-muted" />}
</Pressable>
)}
{folder.allow_download && (
+7
View File
@@ -31,6 +31,8 @@ const STR = {
open: "Open",
folderEmpty: "This folder is empty.",
play: "Play",
gifTip: "To share it in a chat, drag the downloaded file in: it plays everywhere.",
pictureFailed: "Couldn't load the picture.",
files: (n: number) => (n === 1 ? "1 file" : `${n} files`),
},
hu: {
@@ -61,6 +63,8 @@ const STR = {
open: "Megnyitás",
folderEmpty: "Ez a mappa üres.",
play: "Lejátszás",
gifTip: "Csetbe a letöltött fájlt húzd be: az mindenhol mozog.",
pictureFailed: "Nem sikerült betölteni a képet.",
files: (n: number) => `${n} fájl`,
},
de: {
@@ -91,6 +95,9 @@ const STR = {
open: "Öffnen",
folderEmpty: "Dieser Ordner ist leer.",
play: "Abspielen",
gifTip:
"Zum Teilen in einem Chat die heruntergeladene Datei hineinziehen: Sie läuft überall als Animation.",
pictureFailed: "Das Bild konnte nicht geladen werden.",
files: (n: number) => (n === 1 ? "1 Datei" : `${n} Dateien`),
},
} as const;