Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
77f8170153 | ||
|
|
0fd0f1f637 | ||
|
|
7371cdeb94 | ||
|
|
abb18d4d8c | ||
|
|
5bf13c8701 | ||
|
|
fe6af1445f | ||
|
|
8845d1aa49 | ||
|
|
0b92b40db5 | ||
|
|
27ccf253f5 | ||
|
|
c221452a00 | ||
|
|
fe38c07e7e |
@@ -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).
|
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
|
## 0.118.0 — 2026-11-08
|
||||||
|
|
||||||
### New
|
### New
|
||||||
|
|||||||
@@ -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),
|
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.
|
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
|
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
|
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.
|
different range or audio track is a different clip.
|
||||||
@@ -24,6 +27,7 @@ import re
|
|||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Literal
|
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.downloads.edit import AAC_ARGS, EVEN_FRAME, INPUT_GUARD, X264_ARGS
|
||||||
from app.plex.sync import HDR_TRANSFERS
|
from app.plex.sync import HDR_TRANSFERS
|
||||||
|
|
||||||
@@ -70,6 +74,8 @@ ClipReason = Literal[
|
|||||||
"not_found",
|
"not_found",
|
||||||
"media_missing",
|
"media_missing",
|
||||||
"past_end",
|
"past_end",
|
||||||
|
"bad_gif",
|
||||||
|
"gif_too_long",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
@@ -90,9 +96,12 @@ def normalize(
|
|||||||
end_s: float,
|
end_s: float,
|
||||||
audio_track: int | None = None,
|
audio_track: int | None = None,
|
||||||
crop: dict | None = None,
|
crop: dict | None = None,
|
||||||
|
gif_side: object = None,
|
||||||
) -> dict:
|
) -> dict:
|
||||||
"""The validated, canonical clip spec. Times are rounded to the millisecond and a crop to four
|
"""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:
|
if source not in SOURCES:
|
||||||
raise ClipError("bad_source")
|
raise ClipError("bad_source")
|
||||||
pattern = _YOUTUBE_ID if source == "youtube" else _RATING_KEY
|
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:
|
if not (math.isfinite(start_s) and math.isfinite(end_s)) or start_s < 0:
|
||||||
raise ClipError("bad_range")
|
raise ClipError("bad_range")
|
||||||
start, end = round(start_s, 3), round(end_s, 3)
|
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")
|
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)
|
raise ClipError("too_long", limit_s=MAX_CLIP_S)
|
||||||
spec: dict = {"source": source, "ref": ref, "start_s": start, "end_s": end}
|
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:
|
if not 0 <= audio_track <= _MAX_AUDIO_TRACK:
|
||||||
raise ClipError("bad_audio")
|
raise ClipError("bad_audio")
|
||||||
spec["audio_track"] = audio_track
|
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")
|
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(
|
def build_cmd(
|
||||||
src: Path,
|
src: Path,
|
||||||
dest: Path,
|
dest: Path,
|
||||||
|
|||||||
@@ -109,6 +109,12 @@ def normalize_edit_spec(spec: dict | None) -> dict:
|
|||||||
spec = spec or {}
|
spec = spec or {}
|
||||||
out: dict = {}
|
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 applies to both a single trim and a multi-segment join.
|
||||||
crop = spec.get("crop") or {}
|
crop = spec.get("crop") or {}
|
||||||
if crop:
|
if crop:
|
||||||
@@ -197,6 +203,21 @@ def normalize_edit_spec(spec: dict | None) -> dict:
|
|||||||
return out
|
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:
|
def edit_sig(spec: dict) -> str:
|
||||||
"""Stable 24-char signature of a (normalized) edit spec — the per-user cache identity."""
|
"""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=(",", ":"))
|
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:
|
def needs_reencode(spec: dict) -> bool:
|
||||||
"""A crop always re-encodes; a trim re-encodes only when the user asked for an accurate cut."""
|
"""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("accurate"))
|
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:
|
def clip_duration(spec: dict, source_duration: int | None) -> int | None:
|
||||||
|
|||||||
@@ -68,6 +68,10 @@ CONVERTED_TOO_BIG_MESSAGE = (
|
|||||||
"free up space, then try again."
|
"free up space, then try again."
|
||||||
)
|
)
|
||||||
UPLOAD_MISSING_MESSAGE = "The uploaded file is missing."
|
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."
|
NETWORK_REDACTED_MESSAGE = "The server could not reach that address."
|
||||||
|
|
||||||
# (marker, category) — first match wins, so order most-specific → most-general. Matching is
|
# (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.
|
# first — it is our wording, so it can never be a false match on an upstream message.
|
||||||
("download storage is full", "quota_exceeded"),
|
("download storage is full", "quota_exceeded"),
|
||||||
("larger than the download storage you have left", "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.
|
# Our own redaction of a transport failure (see NETWORK_REDACTED_MESSAGE) — our wording too.
|
||||||
(NETWORK_REDACTED_MESSAGE.lower(), "network"),
|
(NETWORK_REDACTED_MESSAGE.lower(), "network"),
|
||||||
# Our own verdicts on a post link (adapters.py) — our wording too: its page holds no single video,
|
# Our own verdicts on a post link (adapters.py) — our wording too: its page holds no single video,
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -171,7 +171,7 @@ def public_meta(
|
|||||||
|
|
||||||
Resolves the owner's per-download display overrides (custom title/channel) over the asset's
|
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."""
|
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)
|
source_url = service.reference_url(job, asset)
|
||||||
# Audio-only assets (mp3 rips, extracted audio) can't play inside a <video> element on WebKit
|
# 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,
|
"height": asset.height,
|
||||||
"audio": asset.media_kind == mediakind.AUDIO,
|
"audio": asset.media_kind == mediakind.AUDIO,
|
||||||
"media_kind": asset.media_kind,
|
"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,
|
"size_bytes": asset.size_bytes,
|
||||||
"container": asset.container,
|
"container": asset.container,
|
||||||
"allow_download": link.allow_download,
|
"allow_download": link.allow_download,
|
||||||
|
|||||||
+44
-11
@@ -14,8 +14,9 @@ from pathlib import Path
|
|||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
from app.config import settings
|
from app.config import settings
|
||||||
|
from app.downloads import gif as gifmod
|
||||||
from app.downloads import links as linksmod
|
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
|
from app.downloads.models import DownloadJob, DownloadLink, FileFolder, MediaAsset
|
||||||
|
|
||||||
_OG_BLOCK = re.compile(r"<!--OG:START-->.*?<!--OG:END-->", re.DOTALL)
|
_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
|
return None
|
||||||
|
|
||||||
|
|
||||||
def _watch_tags(token: str, db: Session) -> str | None:
|
# Facebook's crawler (Messenger's previews): it never showed a GIF `og:image` and did show a JPEG one
|
||||||
"""Build the OG/Twitter meta block for a watch token, or None to keep the generic card."""
|
# (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)
|
link = linksmod.find(db, token)
|
||||||
if link is None or linksmod.is_expired(link):
|
if link is None or linksmod.is_expired(link):
|
||||||
return None
|
return None
|
||||||
@@ -112,7 +138,12 @@ def _watch_tags(token: str, db: Session) -> str | None:
|
|||||||
# non-password links, so the poster needs no grant.
|
# non-password links, so the poster needs no grant.
|
||||||
yt_id_match = re.search(r"i\.ytimg\.com/vi/([\w-]+)/", asset.thumbnail_url or "")
|
yt_id_match = re.search(r"i\.ytimg\.com/vi/([\w-]+)/", asset.thumbnail_url or "")
|
||||||
image_dims: tuple[int, int] | None = None
|
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: str | None = f"https://i.ytimg.com/vi/{yt_id_match.group(1)}/hqdefault.jpg"
|
||||||
image_dims = (480, 360)
|
image_dims = (480, 360)
|
||||||
elif asset.poster_path:
|
elif asset.poster_path:
|
||||||
@@ -133,12 +164,12 @@ def _watch_tags(token: str, db: Session) -> str | None:
|
|||||||
if image:
|
if image:
|
||||||
# A real thumbnail → a large image card; without one, a plain (text) card is served.
|
# A real thumbnail → a large image card; without one, a plain (text) card is served.
|
||||||
tags.append(_tag("og:image", image))
|
tags.append(_tag("og:image", image))
|
||||||
# Declare the image is a public HTTPS JPEG with known dimensions, so FB (which fetches
|
# Declare the image's type and known dimensions, so FB (which fetches og:image
|
||||||
# og:image asynchronously on first scrape) includes it instead of unfurling title-only. For a
|
# asynchronously on first scrape) includes it instead of unfurling title-only. For a poster
|
||||||
# poster we read the real dimensions from its JPEG header; the ytimg hqdefault is a fixed 480×360.
|
# we read the real dimensions from its JPEG header; the ytimg hqdefault is a fixed 480×360.
|
||||||
if image.startswith("https://"):
|
if image.startswith("https://"):
|
||||||
tags.append(_tag("og:image:secure_url", image))
|
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:
|
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 —
|
# 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.
|
# 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
|
"""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
|
serve the generic page unchanged (invalid/expired/not-ready link, or a template without the
|
||||||
markers)."""
|
markers). `user_agent` is the fetcher's, which picks a GIF's card picture."""
|
||||||
tags = _watch_tags(token, db)
|
tags = _watch_tags(token, db, user_agent)
|
||||||
if tags is None:
|
if tags is None:
|
||||||
return None
|
return None
|
||||||
text = page_html.read_text(encoding="utf-8")
|
text = page_html.read_text(encoding="utf-8")
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ from app.db import SessionLocal
|
|||||||
from app.downloads import clip as clipmod
|
from app.downloads import clip as clipmod
|
||||||
from app.downloads import edit as editmod
|
from app.downloads import edit as editmod
|
||||||
from app.downloads import errors as dlerrors
|
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 adapters, credentials, formats, mediakind, playable, probe, quota
|
||||||
from app.downloads import backfill, egress
|
from app.downloads import backfill, egress
|
||||||
from app.downloads import service, storage
|
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"
|
src_ext = src_path.suffix.lstrip(".").lower() or "mp4"
|
||||||
crop = editmod.has_crop(edit_spec)
|
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
|
total_s = editmod.clip_duration(edit_spec, src_dur) or src_dur or 0
|
||||||
staging = _edit_staging_dir(asset_id)
|
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}"
|
dest_staging = staging / f"out.{out_ext}"
|
||||||
|
|
||||||
_set_job(job_id, phase="editing", progress=0, speed_bps=None, eta_s=None)
|
_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(
|
cmd, prep = editmod.build_concat_plan(
|
||||||
src_path, dest_staging, edit_spec, out_ext, staging
|
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():
|
if not dest_staging.exists():
|
||||||
raise StatedFailure("ffmpeg produced no output file")
|
raise StatedFailure("ffmpeg produced no output file")
|
||||||
# A fast trim stream-copies the source's codecs and container, so the clip is only as
|
if gif:
|
||||||
# playable as its source: hold it to the same rule as every other ready file.
|
_hold_gif_cap(dest_staging)
|
||||||
dest_staging = _ensure_phone_safe(job_id, dest_staging, staging, "editing", total_s)
|
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
|
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
|
# New storage on the owner's footprint, held to what is left by its real size, as a
|
||||||
# download and a player clip are.
|
# 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)
|
storage.place_file(dest_staging, settings.files_root, rel)
|
||||||
final = storage.abs_path(settings.files_root, rel)
|
final = storage.abs_path(settings.files_root, rel)
|
||||||
size = final.stat().st_size if final.exists() else None
|
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)
|
_, _, 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.
|
# Unsized, a crop's own size stands in, cut from the frame the filters saw.
|
||||||
cut = (
|
cut = (
|
||||||
editmod.crop_stored_size(edit_spec["crop"], *_filter_frame(src_path, src_w, src_h))
|
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
|
asset.width, asset.height = cut
|
||||||
else:
|
else:
|
||||||
asset.width, asset.height = (None, None) if crop else (src_w, src_h)
|
asset.width, asset.height = (None, None) if crop else (src_w, src_h)
|
||||||
|
if poster:
|
||||||
|
asset.poster_path = poster
|
||||||
asset.error = None
|
asset.error = None
|
||||||
asset.last_access_at = datetime.now(timezone.utc)
|
asset.last_access_at = datetime.now(timezone.utc)
|
||||||
job = db.get(DownloadJob, job_id)
|
job = db.get(DownloadJob, job_id)
|
||||||
@@ -1057,6 +1086,19 @@ def _clip_crop(spec: dict, data: dict) -> str:
|
|||||||
return crop
|
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:
|
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
|
"""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."""
|
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.")
|
raise StatedFailure("The source file could not be read.")
|
||||||
audio_track = spec.get("audio_track")
|
audio_track = spec.get("audio_track")
|
||||||
tracks = clipmod.audio_track_count(data)
|
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.
|
# A silent source: the player still names its track 0, and the clip is silent too.
|
||||||
audio_track = None
|
audio_track = None
|
||||||
elif audio_track is not None and audio_track >= tracks:
|
elif audio_track is not None and audio_track >= tracks:
|
||||||
raise StatedFailure("The chosen audio track is not in the file.")
|
raise StatedFailure("The chosen audio track is not in the file.")
|
||||||
crop = _clip_crop(spec, data)
|
crop = _clip_crop(spec, data)
|
||||||
total_s = clipmod.duration(spec)
|
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)
|
_set_job(job_id, phase="editing", progress=0, speed_bps=None, eta_s=None)
|
||||||
editmod.run_ffmpeg(
|
cmd = (
|
||||||
clipmod.build_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,
|
src,
|
||||||
out,
|
out,
|
||||||
start,
|
start,
|
||||||
total_s,
|
total_s,
|
||||||
audio_track=audio_track,
|
audio_track=audio_track,
|
||||||
hdr=clipmod.is_hdr(data),
|
hdr=hdr,
|
||||||
threads=editmod.encode_thread_cap(),
|
threads=editmod.encode_thread_cap(),
|
||||||
crop=crop,
|
crop=crop,
|
||||||
),
|
)
|
||||||
|
)
|
||||||
|
editmod.run_ffmpeg(
|
||||||
|
cmd,
|
||||||
total_s,
|
total_s,
|
||||||
on_progress=_ffmpeg_progress_writer(job_id, "editing"),
|
on_progress=_ffmpeg_progress_writer(job_id, "editing"),
|
||||||
should_cancel=_cancel_poll(job_id),
|
should_cancel=_cancel_poll(job_id),
|
||||||
)
|
)
|
||||||
if not out.exists():
|
if not out.exists():
|
||||||
raise StatedFailure("ffmpeg produced no output file")
|
raise StatedFailure("ffmpeg produced no output file")
|
||||||
# The phone-safe shape by construction; still held to the rule every ready file meets.
|
if gif:
|
||||||
out = _ensure_phone_safe(job_id, out, staging, "editing", round(total_s))
|
_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
|
# 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
|
# 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.
|
# 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)
|
storage.place_file(out, settings.files_root, rel)
|
||||||
final = storage.abs_path(settings.files_root, rel)
|
final = storage.abs_path(settings.files_root, rel)
|
||||||
vcodec, acodec, width, height, _ = probe.probe_media(final)
|
vcodec, acodec, width, height, _ = probe.probe_media(final)
|
||||||
|
if gif:
|
||||||
|
width, height = gifmod.size(final) or (None, None)
|
||||||
|
|
||||||
with SessionLocal() as db:
|
with SessionLocal() as db:
|
||||||
asset = db.get(MediaAsset, asset_id)
|
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.duration_s = round(total_s)
|
||||||
asset.media_kind = mediakind.derive(vcodec, acodec)
|
asset.media_kind = mediakind.derive(vcodec, acodec)
|
||||||
# The clip's own frame as its poster: the link preview shows the moment, and a Plex
|
# The clip's own frame as its poster: the link preview shows the moment, and a Plex
|
||||||
# item has no public thumbnail at all.
|
# item has no public thumbnail at all. A GIF's is its first frame, cut from the GIF.
|
||||||
asset.poster_path = editmod.ensure_poster(asset, settings.files_root)
|
asset.poster_path = (
|
||||||
|
_gif_still(rel) if gif else editmod.ensure_poster(asset, settings.files_root)
|
||||||
|
)
|
||||||
asset.error = None
|
asset.error = None
|
||||||
asset.last_access_at = datetime.now(timezone.utc)
|
asset.last_access_at = datetime.now(timezone.utc)
|
||||||
fields: dict = {"status": JobStatus.DONE, "progress": 100, "phase": None}
|
fields: dict = {"status": JobStatus.DONE, "progress": 100, "phase": None}
|
||||||
|
|||||||
@@ -45,6 +45,16 @@ TEXT_EXTS: frozenset[str] = frozenset(
|
|||||||
|
|
||||||
TEXT_TYPE = "text/plain; charset=utf-8"
|
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.
|
# A text preview is the file's head: enough to read, never a whole log loaded into a tab.
|
||||||
TEXT_PREVIEW_BYTES = 1 << 20
|
TEXT_PREVIEW_BYTES = 1 << 20
|
||||||
# A NUL byte in the first this-many bytes means the "text" is not text.
|
# A NUL byte in the first this-many bytes means the "text" is not text.
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ from app.downloads import adapters
|
|||||||
from app.downloads import clip as clipmod
|
from app.downloads import clip as clipmod
|
||||||
from app.downloads import credentials
|
from app.downloads import credentials
|
||||||
from app.downloads import edit as editmod
|
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 import folders, formats, mediakind, quota, storage
|
||||||
from app.downloads.status import HOLDING, JobKind, JobStatus
|
from app.downloads.status import HOLDING, JobKind, JobStatus
|
||||||
from app.models import DISPLAY_TEXT_MAX, User
|
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
|
# 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).
|
# 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):
|
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.reason: EditReason = reason
|
||||||
|
self.params = params
|
||||||
super().__init__(reason)
|
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]
|
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(
|
def enqueue_edit(
|
||||||
db: Session,
|
db: Session,
|
||||||
user_id: int,
|
user_id: int,
|
||||||
@@ -280,6 +303,11 @@ def enqueue_edit(
|
|||||||
spec = editmod.normalize_edit_spec(edit_spec)
|
spec = editmod.normalize_edit_spec(edit_spec)
|
||||||
if not spec:
|
if not spec:
|
||||||
raise EditError("empty_edit")
|
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)
|
quota.check_enqueue(db, user_id)
|
||||||
|
|
||||||
@@ -294,7 +322,10 @@ def enqueue_edit(
|
|||||||
asset.duration_s = editmod.clip_duration(spec, src_asset.duration_s)
|
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);
|
# 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.
|
# 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(
|
job = DownloadJob(
|
||||||
user_id=user_id,
|
user_id=user_id,
|
||||||
@@ -384,7 +415,8 @@ def enqueue_clip(
|
|||||||
asset.uploader = facts["uploader"]
|
asset.uploader = facts["uploader"]
|
||||||
asset.thumbnail_url = facts["thumbnail_url"]
|
asset.thumbnail_url = facts["thumbnail_url"]
|
||||||
asset.duration_s = round(clipmod.duration(spec))
|
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(
|
job = DownloadJob(
|
||||||
user_id=user_id,
|
user_id=user_id,
|
||||||
|
|||||||
+5
-4
@@ -387,18 +387,19 @@ async def index() -> FileResponse:
|
|||||||
@app.get("/watch/{token}")
|
@app.get("/watch/{token}")
|
||||||
@app.get("/file/{token}")
|
@app.get("/file/{token}")
|
||||||
@app.get("/folder/{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
|
"""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
|
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
|
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
|
from app.downloads import og
|
||||||
|
|
||||||
with SessionLocal() as db:
|
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:
|
if rendered is None:
|
||||||
return FileResponse(WATCH_HTML, headers=INDEX_HEADERS)
|
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}")
|
@app.get("/{full_path:path}")
|
||||||
|
|||||||
@@ -318,7 +318,9 @@ def enqueue_edit(
|
|||||||
except quota.QuotaExceeded as e:
|
except quota.QuotaExceeded as e:
|
||||||
raise HTTPException(status_code=422, detail=quota_detail(e)) from e
|
raise HTTPException(status_code=422, detail=quota_detail(e)) from e
|
||||||
except service.EditError as 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()
|
db.commit()
|
||||||
return serialize_job(db, job)
|
return serialize_job(db, job)
|
||||||
|
|
||||||
@@ -341,6 +343,8 @@ class EnqueueClipIn(BaseModel):
|
|||||||
audio_track: int | None = None
|
audio_track: int | None = None
|
||||||
# A crop frame (downloads/clip): fractions of the box it was drawn in, and that box's aspect.
|
# A crop frame (downloads/clip): fractions of the box it was drawn in, and that box's aspect.
|
||||||
crop: ClipCropIn | None = None
|
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
|
display_name: str | None = None
|
||||||
|
|
||||||
|
|
||||||
@@ -353,7 +357,7 @@ def enqueue_clip(
|
|||||||
try:
|
try:
|
||||||
crop = body.crop.model_dump() if body.crop else None
|
crop = body.crop.model_dump() if body.crop else None
|
||||||
spec = clipmod.normalize(
|
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)
|
display_name = display_text(body.display_name)
|
||||||
job = service.enqueue_clip(db, user.id, spec, display_name=display_name)
|
job = service.enqueue_clip(db, user.id, spec, display_name=display_name)
|
||||||
|
|||||||
@@ -715,13 +715,8 @@ def get_file(
|
|||||||
return response
|
return response
|
||||||
|
|
||||||
|
|
||||||
# A preview's own headers. `sandbox` leaves the answer no script and an opaque origin, whatever the
|
# A preview's own headers, shared with a share link's picture (`preview.SANDBOX_HEADERS`).
|
||||||
# file turns out to hold; `frame-ancestors 'self'` lets the app's viewer frame a PDF (every other
|
PREVIEW_HEADERS = preview.SANDBOX_HEADERS
|
||||||
# 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",
|
|
||||||
}
|
|
||||||
# Set on a text preview cut at `preview.TEXT_PREVIEW_BYTES`, so the viewer can say it shows the start.
|
# 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"
|
TRUNCATED_HEADER = "X-Preview-Truncated"
|
||||||
|
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ from sqlalchemy.orm import Session
|
|||||||
from app.config import settings
|
from app.config import settings
|
||||||
from app.db import get_db
|
from app.db import get_db
|
||||||
from app.downloads import links as linksmod
|
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.downloads.status import JobStatus
|
||||||
from app.ratelimit import RateLimiter, client_key
|
from app.ratelimit import RateLimiter, client_key
|
||||||
from app.security import verify_password
|
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:
|
def _serve(link: "DownloadLink", asset: "MediaAsset") -> FileResponse:
|
||||||
"""Range-aware stream of a shared file. Inline by default (plays in the page); an
|
"""Range-aware stream of a shared file. Inline by default (a video plays and a picture shows in
|
||||||
`allow_download` link serves it as an attachment."""
|
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)
|
path = storage.safe_abs_path(settings.files_root, asset.rel_path)
|
||||||
if path is None:
|
if path is None:
|
||||||
raise refused(404, "video_gone")
|
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
|
# 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.
|
# in the watch page. Both are range-aware (FileResponse → 206) for seeking/resume.
|
||||||
media_type = storage.served_media_type(asset.media_kind, asset.container)
|
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:
|
if media_type is None:
|
||||||
return FileResponse(
|
return FileResponse(
|
||||||
path,
|
path,
|
||||||
@@ -248,7 +260,6 @@ def _serve(link: "DownloadLink", asset: "MediaAsset") -> FileResponse:
|
|||||||
media_type="application/octet-stream",
|
media_type="application/octet-stream",
|
||||||
content_disposition_type="attachment",
|
content_disposition_type="attachment",
|
||||||
)
|
)
|
||||||
disposition = "attachment" if link.allow_download else "inline"
|
|
||||||
return FileResponse(
|
return FileResponse(
|
||||||
path, filename=filename, media_type=media_type, content_disposition_type=disposition
|
path, filename=filename, media_type=media_type, content_disposition_type=disposition
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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(
|
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
|
db, make_user, as_user, yt_video, worker, monkeypatch
|
||||||
):
|
):
|
||||||
|
|||||||
@@ -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.CLIP_TOO_BIG_MESSAGE) == "quota_exceeded"
|
||||||
assert classify(dlerrors.CONVERTED_TOO_BIG_MESSAGE) == "quota_exceeded"
|
assert classify(dlerrors.CONVERTED_TOO_BIG_MESSAGE) == "quota_exceeded"
|
||||||
assert classify(dlerrors.DOWNLOAD_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():
|
def test_the_redacted_network_text_classifies_back_to_network():
|
||||||
|
|||||||
@@ -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"
|
||||||
|
)
|
||||||
@@ -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
|
||||||
@@ -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)
|
||||||
@@ -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
|
_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():
|
def _release():
|
||||||
scheduler._record(_JOB, running=False)
|
scheduler._record(_JOB, running=False)
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
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
|
"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).
|
`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)
|
## 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 | 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-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-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 |
|
||||||
|
|||||||
@@ -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.
|
// `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
|
// - 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
|
// `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,
|
// - 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
|
// 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).
|
// picture for a frame (ui/Avatar had that defect).
|
||||||
@@ -47,6 +48,7 @@ export default function RemoteImage({
|
|||||||
src,
|
src,
|
||||||
srcSet,
|
srcSet,
|
||||||
fallback = null,
|
fallback = null,
|
||||||
|
pending = fallback,
|
||||||
retries = IMAGE_RETRIES,
|
retries = IMAGE_RETRIES,
|
||||||
...img
|
...img
|
||||||
}: Omit<ImgHTMLAttributes<HTMLImageElement>, "src" | "srcSet" | "onError" | "referrerPolicy"> & {
|
}: Omit<ImgHTMLAttributes<HTMLImageElement>, "src" | "srcSet" | "onError" | "referrerPolicy"> & {
|
||||||
@@ -54,6 +56,9 @@ export default function RemoteImage({
|
|||||||
srcSet?: string;
|
srcSet?: string;
|
||||||
/** What stands in while there is no picture. */
|
/** What stands in while there is no picture. */
|
||||||
fallback?: ReactNode;
|
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
|
/** 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. */
|
* yet is better shown as its fallback now than after seven seconds of retries. */
|
||||||
retries?: number;
|
retries?: number;
|
||||||
@@ -75,7 +80,8 @@ export default function RemoteImage({
|
|||||||
}, [waiting, attempt, src]);
|
}, [waiting, attempt, src]);
|
||||||
|
|
||||||
const spent = current !== null && !current.waiting && current.attempt > retries;
|
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 (
|
return (
|
||||||
// eslint-disable-next-line no-restricted-syntax -- the one <img> the rest of the app goes through
|
// eslint-disable-next-line no-restricted-syntax -- the one <img> the rest of the app goes through
|
||||||
<img
|
<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",
|
"version": "0.118.0",
|
||||||
"date": "2026-11-08",
|
"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",
|
"version": "0.118.0",
|
||||||
"date": "2026-11-08",
|
"date": "2026-11-08",
|
||||||
|
|||||||
@@ -12,7 +12,16 @@ import type { ClipRange } from "../../lib/clipRange";
|
|||||||
|
|
||||||
vi.mock("react-i18next", async (importOriginal) => {
|
vi.mock("react-i18next", async (importOriginal) => {
|
||||||
const actual = await importOriginal<typeof import("react-i18next")>();
|
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>>();
|
const enqueueClip = vi.fn<(body: ClipRequest) => Promise<unknown>>();
|
||||||
@@ -271,3 +280,106 @@ describe("ClipPanel", () => {
|
|||||||
expect(screen.getByTestId("crop").textContent).toBe("none");
|
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();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -10,6 +10,18 @@ import { notifyActionError } from "../../lib/actionErrors";
|
|||||||
import { showInTray } from "../../lib/downloadQueue";
|
import { showInTray } from "../../lib/downloadQueue";
|
||||||
import { log } from "../../lib/log";
|
import { log } from "../../lib/log";
|
||||||
import { formatTimecode } from "../../lib/timecode";
|
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 { handleBounds, markIn, markOut, zoomWindow, type ClipRange } from "../../lib/clipRange";
|
||||||
import { fitRatio, fracRatio, WHOLE, type Frac } from "../../lib/cropGeometry";
|
import { fitRatio, fracRatio, WHOLE, type Frac } from "../../lib/cropGeometry";
|
||||||
import { RangeTrack, RulerTicks, type RangeWindow } from "../../components/ui/RangeTrack";
|
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
|
// (`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
|
// 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.
|
// 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. */
|
/** 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";
|
type ClipShape = "free" | "16:9" | "9:16" | "1:1";
|
||||||
@@ -110,10 +127,13 @@ export default function ClipPanel({
|
|||||||
onPlayFrom,
|
onPlayFrom,
|
||||||
onClose,
|
onClose,
|
||||||
}: Props) {
|
}: Props) {
|
||||||
const { t } = useTranslation();
|
const { t, i18n } = useTranslation();
|
||||||
const qc = useQueryClient();
|
const qc = useQueryClient();
|
||||||
const rootRef = useRef<HTMLFormElement>(null);
|
const rootRef = useRef<HTMLFormElement>(null);
|
||||||
const [name, setName] = useState("");
|
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 [busy, setBusy] = useState(false);
|
||||||
const [looping, setLooping] = useState(false);
|
const [looping, setLooping] = useState(false);
|
||||||
// The strip's window while a handle drags (null at rest: it follows the selection).
|
// 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 view = frozen ?? zoomWindow(clip, duration);
|
||||||
const bounds = handleBounds(clip, duration);
|
const bounds = handleBounds(clip, duration);
|
||||||
const length = clip.end - clip.start;
|
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 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
|
// 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 = {
|
const body: ClipRequest = {
|
||||||
source: src.source,
|
source: src.source,
|
||||||
ref: src.ref,
|
ref: src.ref,
|
||||||
start_s: Math.round(clip.start * 1000) / 1000,
|
start_s: atMs(clip.start),
|
||||||
end_s: Math.round(clip.end * 1000) / 1000,
|
end_s: atMs(clip.end),
|
||||||
...(src.audioTrack != null ? { audio_track: src.audioTrack } : {}),
|
...(src.audioTrack != null ? { audio_track: src.audioTrack } : {}),
|
||||||
...(crop
|
...(crop
|
||||||
? {
|
? {
|
||||||
@@ -185,26 +213,34 @@ export default function ClipPanel({
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
: {}),
|
: {}),
|
||||||
|
...(gif ? { gif_side: side } : {}),
|
||||||
...(name.trim() ? { display_name: name.trim() } : {}),
|
...(name.trim() ? { display_name: name.trim() } : {}),
|
||||||
};
|
};
|
||||||
const submit = async (e: SubmitEvent<HTMLFormElement>) => {
|
const submit = async (e: SubmitEvent<HTMLFormElement>) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
if (busy) return;
|
if (busy || blocked) return;
|
||||||
setBusy(true);
|
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 {
|
try {
|
||||||
const job = await api.enqueueClip(body);
|
const job = await api.enqueueClip(body);
|
||||||
invalidateDownloads(qc);
|
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({
|
notify({
|
||||||
level: "success",
|
level: "success",
|
||||||
message: msg("clip.queued"),
|
message: queued,
|
||||||
action: { label: msg("clip.view"), onClick: () => showInTray(job.id) },
|
action: { label: msg("clip.view"), onClick: () => showInTray(job.id) },
|
||||||
});
|
});
|
||||||
onClose();
|
onClose();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
// A refusal the server explains (too long, quota) is already in the error dialog; a lost
|
// 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.
|
// 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);
|
setBusy(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -313,7 +349,9 @@ export default function ClipPanel({
|
|||||||
onHere={() => moveOut(current, true)}
|
onHere={() => moveOut(current, true)}
|
||||||
/>
|
/>
|
||||||
<span className="tabular-nums text-muted" aria-live="polite">
|
<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>
|
</span>
|
||||||
<Button
|
<Button
|
||||||
variant="overlay"
|
variant="overlay"
|
||||||
@@ -370,6 +408,66 @@ export default function ClipPanel({
|
|||||||
)}
|
)}
|
||||||
</div>
|
</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">
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
<Input
|
<Input
|
||||||
size="sm"
|
size="sm"
|
||||||
@@ -383,8 +481,17 @@ export default function ClipPanel({
|
|||||||
<Button variant="overlay" size="sm" onClick={onClose}>
|
<Button variant="overlay" size="sm" onClick={onClose}>
|
||||||
{t("common.cancel")}
|
{t("common.cancel")}
|
||||||
</Button>
|
</Button>
|
||||||
<Button type="submit" variant="primary" size="sm" loading={busy} icon={<Scissors />}>
|
<Button
|
||||||
{t("clip.cut")}
|
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>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
|
|||||||
@@ -39,6 +39,13 @@ describe("the copy rule", () => {
|
|||||||
expect(offenders((v) => v.includes("✓"))).toEqual([]);
|
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')", () => {
|
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([]);
|
expect(offenders((v) => /(\d|\}\})\s?(m|h|d|s)(?![\p{L}\d/])/u.test(v))).toEqual([]);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -22,6 +22,23 @@
|
|||||||
"shape": "Frame shape",
|
"shape": "Frame shape",
|
||||||
"shapeFree": "Free",
|
"shapeFree": "Free",
|
||||||
"whole": "Whole picture",
|
"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": {
|
"errors": {
|
||||||
"bad_source": "This video can’t be clipped.",
|
"bad_source": "This video can’t be clipped.",
|
||||||
"bad_range": "The clip’s start or end isn’t valid.",
|
"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.",
|
"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.",
|
"not_found": "This item is no longer in the library.",
|
||||||
"media_missing": "The item’s file can’t be reached from here.",
|
"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",
|
"empty_edit": "Choose a trim range or crop area first",
|
||||||
"source_not_ready": "The source download isn't ready to edit",
|
"source_not_ready": "The source download isn't ready to edit",
|
||||||
"not_media": "Only video and audio can be edited",
|
"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"
|
"generic": "Couldn't create that edit"
|
||||||
},
|
},
|
||||||
"upload": {
|
"upload": {
|
||||||
@@ -312,6 +316,10 @@
|
|||||||
"short": "Couldn't download",
|
"short": "Couldn't download",
|
||||||
"body": "Something went wrong with this download. The technical details below may help."
|
"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": {
|
"quota_exceeded": {
|
||||||
"short": "Not enough storage",
|
"short": "Not enough storage",
|
||||||
"body": "This download doesn't fit in the storage you have left. Delete something from your library, then try again."
|
"body": "This download doesn't fit in the storage you have left. Delete something from your library, then try again."
|
||||||
|
|||||||
@@ -24,12 +24,31 @@
|
|||||||
"created_other": "{{count}} clips added to your downloads",
|
"created_other": "{{count}} clips added to your downloads",
|
||||||
"failed": "Couldn’t create the clip",
|
"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.",
|
"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",
|
"splitHere": "Split here",
|
||||||
"output": {
|
"output": {
|
||||||
"label": "Output",
|
"label": "Output",
|
||||||
"separate": "Separate files",
|
"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}}",
|
"segment": "Segment {{n}}",
|
||||||
"keep": "Keep",
|
"keep": "Keep",
|
||||||
"drop": "Drop",
|
"drop": "Drop",
|
||||||
|
|||||||
@@ -22,6 +22,23 @@
|
|||||||
"shape": "Keret aránya",
|
"shape": "Keret aránya",
|
||||||
"shapeFree": "Szabad",
|
"shapeFree": "Szabad",
|
||||||
"whole": "Teljes kép",
|
"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": {
|
"errors": {
|
||||||
"bad_source": "Ebből a videóból nem lehet klipet vágni.",
|
"bad_source": "Ebből a videóból nem lehet klipet vágni.",
|
||||||
"bad_range": "A klip eleje vagy vége érvénytelen.",
|
"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.",
|
"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.",
|
"not_found": "Ez az elem már nincs a könyvtárban.",
|
||||||
"media_missing": "Az elem fájlja innen nem érhető el.",
|
"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",
|
"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ő",
|
"source_not_ready": "A forrásletöltés még nem szerkeszthető",
|
||||||
"not_media": "Csak videó és hang 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"
|
"generic": "Nem sikerült létrehozni a vágást"
|
||||||
},
|
},
|
||||||
"upload": {
|
"upload": {
|
||||||
@@ -312,6 +316,10 @@
|
|||||||
"short": "Nem sikerült letölteni",
|
"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."
|
"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": {
|
"quota_exceeded": {
|
||||||
"short": "Nincs elég tárhely",
|
"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."
|
"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."
|
||||||
|
|||||||
@@ -24,12 +24,31 @@
|
|||||||
"created_other": "{{count}} klip a letöltésekhez adva",
|
"created_other": "{{count}} klip a letöltésekhez adva",
|
||||||
"failed": "Nem sikerült létrehozni a klipet",
|
"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á.",
|
"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",
|
"splitHere": "Vágás itt",
|
||||||
"output": {
|
"output": {
|
||||||
"label": "Kimenet",
|
"label": "Kimenet",
|
||||||
"separate": "Külön fájlok",
|
"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",
|
"segment": "{{n}}. szegmens",
|
||||||
"keep": "Megtart",
|
"keep": "Megtart",
|
||||||
"drop": "Eldob",
|
"drop": "Eldob",
|
||||||
|
|||||||
@@ -62,6 +62,8 @@ export interface ClipRequest {
|
|||||||
audio_track?: number;
|
audio_track?: number;
|
||||||
/** A crop frame: fractions of the box it was drawn in, and that box's shape (downloads/clip). */
|
/** 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 };
|
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;
|
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).
|
// 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 };
|
crop?: { x: number; y: number; w: number; h: number; frame_w?: number; frame_h?: number };
|
||||||
accurate?: boolean;
|
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 {
|
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: "" }));
|
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 () => {
|
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(
|
expect(await say({ code: "quota", reason: "nope" })).toBe(
|
||||||
i18n.t("downloads.errors.quota.generic"),
|
i18n.t("downloads.errors.quota.generic"),
|
||||||
|
|||||||
@@ -30,13 +30,18 @@ const byReason = new Map(
|
|||||||
const fixed = new Map<string, string>(Object.entries(REFUSAL_FIXED));
|
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
|
// 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
|
// minutes (a GIF's in seconds), an upload cap in bytes, a password's minimum length; a generic
|
||||||
// whatever values its sentence names (`{{min}}`, `{{name}}`, …).
|
// `refused` interpolates whatever values its sentence names (`{{min}}`, `{{name}}`, …).
|
||||||
function valuesOf(o: NonNullable<Detail>): Record<string, unknown> {
|
function valuesOf(o: NonNullable<Detail>): Record<string, unknown> {
|
||||||
const lang = i18n.language;
|
const lang = i18n.language;
|
||||||
if (o.code === "refused") return { ...o };
|
if (o.code === "refused") return { ...o };
|
||||||
if (o.code === "clip")
|
if (o.code === "clip" || o.code === "edit")
|
||||||
return { minutes: o.limit_s == null ? undefined : formatDecimal(o.limit_s / 60, lang, 1, 0) };
|
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")
|
if (o.code === "quota")
|
||||||
return o.reason === "max_bytes"
|
return o.reason === "max_bytes"
|
||||||
? { size: formatDecimal((o.limit || 0) / 1_073_741_824, lang, 1, 0) }
|
? { size: formatDecimal((o.limit || 0) / 1_073_741_824, lang, 1, 0) }
|
||||||
|
|||||||
@@ -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)));
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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";
|
||||||
|
}
|
||||||
@@ -423,12 +423,14 @@ describe("module URL keys", () => {
|
|||||||
});
|
});
|
||||||
const named = new Set(["MODE_URL", "PLEX_SUB_URL"]);
|
const named = new Set(["MODE_URL", "PLEX_SUB_URL"]);
|
||||||
const unnamed = (text: string) => handed(text).filter((arg) => !named.has(arg));
|
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(
|
const files = sources(SRC).filter(
|
||||||
(f) => !relative(SRC, f).split("\\").join("/").startsWith("lib/history"),
|
(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)…
|
// 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.
|
// …and the check tells an inline definition from a named one.
|
||||||
expect(unnamed('useHistorySubview(sub, { key: "zz", encode, decode })')).toEqual([
|
expect(unnamed('useHistorySubview(sub, { key: "zz", encode, decode })')).toEqual([
|
||||||
'{ key: "zz", encode, decode }',
|
'{ key: "zz", encode, decode }',
|
||||||
@@ -442,7 +444,7 @@ describe("module URL keys", () => {
|
|||||||
]);
|
]);
|
||||||
// Under another name it is a call no scan sees.
|
// Under another name it is a call no scan sees.
|
||||||
const aliased = (text: string) => /\buseHistorySubview\s+as\b/.test(text);
|
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);
|
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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -10,7 +10,10 @@ import { msg, notify } from "../../lib/notifications";
|
|||||||
import { api, type DownloadJob, type EditSpec } from "../../lib/api";
|
import { api, type DownloadJob, type EditSpec } from "../../lib/api";
|
||||||
import { cropToSourcePx, type Frac } from "../../lib/cropGeometry";
|
import { cropToSourcePx, type Frac } from "../../lib/cropGeometry";
|
||||||
import { CropFrame } from "../../components/ui/CropFrame";
|
import { CropFrame } from "../../components/ui/CropFrame";
|
||||||
|
import SegmentedToggle from "../../components/ui/SegmentedToggle";
|
||||||
import { Button } from "../../components/ui/Button";
|
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 { failureReason, notifyActionError } from "../../lib/actionErrors";
|
||||||
import { markReported } from "../../lib/api/core";
|
import { markReported } from "../../lib/api/core";
|
||||||
import { IconButton } from "../../components/ui/IconButton";
|
import { IconButton } from "../../components/ui/IconButton";
|
||||||
@@ -22,6 +25,19 @@ import { SURFACE } from "../../components/ui/surfaces";
|
|||||||
import { RangeTrack } from "../../components/ui/RangeTrack";
|
import { RangeTrack } from "../../components/ui/RangeTrack";
|
||||||
import { trackLeft } from "../../components/ui/trackPosition";
|
import { trackLeft } from "../../components/ui/trackPosition";
|
||||||
import { formatTimecode } from "../../lib/timecode";
|
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 };
|
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 }) {
|
export default function VideoEditor({ job, onClose }: { job: DownloadJob; onClose: () => void }) {
|
||||||
const { t } = useTranslation();
|
const { t, i18n } = useTranslation();
|
||||||
const qc = useQueryClient();
|
const qc = useQueryClient();
|
||||||
const videoRef = useRef<HTMLVideoElement>(null);
|
const videoRef = useRef<HTMLVideoElement>(null);
|
||||||
const trackRef = useRef<HTMLDivElement>(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 [cropOn, setCropOn] = useState(false);
|
||||||
const [crop, setCrop] = useState<Frac>({ x: 0.1, y: 0.1, w: 0.8, h: 0.8 });
|
const [crop, setCrop] = useState<Frac>({ x: 0.1, y: 0.1, w: 0.8, h: 0.8 });
|
||||||
const [accurate, setAccurate] = useState(true);
|
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 [name, setName] = useState("");
|
||||||
|
|
||||||
const fileUrl = useMemo(() => api.fileUrl(job.id), [job.id]);
|
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.length === 1 &&
|
||||||
kept[0]!.start <= 0.01 &&
|
kept[0]!.start <= 0.01 &&
|
||||||
kept[0]!.end >= duration - 0.01;
|
kept[0]!.end >= duration - 0.01;
|
||||||
const valid = kept.length >= 1 && keptDur >= MIN_SEG && (cropOn || !isFullSingle);
|
// A GIF of the whole video is no no-op: the format changes.
|
||||||
const willJoin = output === "join" && kept.length > 1;
|
const valid = kept.length >= 1 && keptDur >= MIN_SEG && (cropOn || gif || !isFullSingle);
|
||||||
const reencode = cropOn || accurate;
|
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 cropPx = (): EditSpec["crop"] => {
|
||||||
const v = videoRef.current;
|
const v = videoRef.current;
|
||||||
@@ -245,7 +304,7 @@ export default function VideoEditor({ job, onClose }: { job: DownloadJob; onClos
|
|||||||
const base = name.trim() || defaultName;
|
const base = name.trim() || defaultName;
|
||||||
if (willJoin) {
|
if (willJoin) {
|
||||||
const spec: EditSpec = {
|
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,
|
accurate,
|
||||||
};
|
};
|
||||||
if (cp) spec.crop = cp;
|
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.
|
// cannot lower a conditional or a loop inside one yet, and skipped the whole editor for it.
|
||||||
const clips = kept.map((s) => {
|
const clips = kept.map((s) => {
|
||||||
const spec: EditSpec = {
|
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;
|
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;
|
const dn = suffix.get(s.id) ? `${base}${suffix.get(s.id)}` : name.trim() || undefined;
|
||||||
return { id: s.id, spec, dn };
|
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.
|
// what the dialog does not tell.
|
||||||
notify({
|
notify({
|
||||||
level: "error",
|
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),
|
message: failureReason(e),
|
||||||
});
|
});
|
||||||
markReported(e);
|
markReported(e);
|
||||||
@@ -302,10 +365,13 @@ export default function VideoEditor({ job, onClose }: { job: DownloadJob; onClos
|
|||||||
onSuccess: ({ files }) => {
|
onSuccess: ({ files }) => {
|
||||||
// The whole library moves with it: an edit the user already made is done at once, and lists.
|
// The whole library moves with it: an edit the user already made is done at once, and lists.
|
||||||
invalidateDownloads(qc);
|
invalidateDownloads(qc);
|
||||||
notify({ level: "success", message: msg("editor.created", { count: files }) });
|
notify({
|
||||||
|
level: "success",
|
||||||
|
message: msg(gif ? "editor.createdGif" : "editor.created", { count: files }),
|
||||||
|
});
|
||||||
onClose();
|
onClose();
|
||||||
},
|
},
|
||||||
onError: (e) => notifyActionError(e, msg("editor.failed")),
|
onError: (e) => notifyActionError(e, msg(gif ? "editor.failedGif" : "editor.failed")),
|
||||||
onSettled: () => {
|
onSettled: () => {
|
||||||
creating.current = false;
|
creating.current = false;
|
||||||
},
|
},
|
||||||
@@ -533,7 +599,7 @@ export default function VideoEditor({ job, onClose }: { job: DownloadJob; onClos
|
|||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
<span className="text-xs text-muted tabular-nums w-16 text-right">
|
<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>
|
</span>
|
||||||
{(inField.invalid || outField.invalid) && (
|
{(inField.invalid || outField.invalid) && (
|
||||||
<div className="basis-full">
|
<div className="basis-full">
|
||||||
@@ -546,24 +612,97 @@ export default function VideoEditor({ job, onClose }: { job: DownloadJob; onClos
|
|||||||
{/* output mode + summary */}
|
{/* output mode + summary */}
|
||||||
<div className="flex flex-wrap items-center gap-2 text-sm">
|
<div className="flex flex-wrap items-center gap-2 text-sm">
|
||||||
<span className="text-muted">{t("editor.output.label")}:</span>
|
<span className="text-muted">{t("editor.output.label")}:</span>
|
||||||
{(["separate", "join"] as const).map((m) => (
|
{(["separate", "join"] as const).map((m) => {
|
||||||
<Button
|
// Join is unavailable with its reason in its tooltip (a GIF is never joined; a join
|
||||||
key={m}
|
// needs two kept segments), shown on focus too, and stays focusable so the reason
|
||||||
variant="outline"
|
// reaches the keyboard.
|
||||||
size="sm"
|
const reason = m === "join" ? joinReason : undefined;
|
||||||
pressed={output === m}
|
return (
|
||||||
onClick={() => setOutput(m)}
|
<Tooltip key={m} hint={reason ?? ""} always={!!reason} stable>
|
||||||
disabled={m === "join" && kept.length < 2}
|
<Button
|
||||||
>
|
variant="outline"
|
||||||
{t(`editor.output.${m}`)}
|
size="sm"
|
||||||
</Button>
|
pressed={(willJoin ? "join" : "separate") === m}
|
||||||
))}
|
onClick={() => setOutput(m)}
|
||||||
|
{...unavailable(!!reason, reason)}
|
||||||
|
>
|
||||||
|
{t(`editor.output.${m}`)}
|
||||||
|
</Button>
|
||||||
|
</Tooltip>
|
||||||
|
);
|
||||||
|
})}
|
||||||
<div className="flex-1" />
|
<div className="flex-1" />
|
||||||
<span className="text-xs text-muted">
|
<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>
|
</span>
|
||||||
</div>
|
</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 */}
|
{/* crop + precision */}
|
||||||
<div className="grid sm:grid-cols-2 gap-3">
|
<div className="grid sm:grid-cols-2 gap-3">
|
||||||
<Button
|
<Button
|
||||||
@@ -575,7 +714,11 @@ export default function VideoEditor({ job, onClose }: { job: DownloadJob; onClos
|
|||||||
>
|
>
|
||||||
{cropOn ? t("editor.cropOn") : t("editor.cropOff")}
|
{cropOn ? t("editor.cropOn") : t("editor.cropOff")}
|
||||||
</Button>
|
</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">
|
<div className="flex gap-2">
|
||||||
{(["accurate", "fast"] as const).map((m) => {
|
{(["accurate", "fast"] as const).map((m) => {
|
||||||
const on = (m === "accurate") === accurate;
|
const on = (m === "accurate") === accurate;
|
||||||
@@ -623,14 +766,19 @@ export default function VideoEditor({ job, onClose }: { job: DownloadJob; onClos
|
|||||||
size="sm"
|
size="sm"
|
||||||
onClick={() => create.mutate()}
|
onClick={() => create.mutate()}
|
||||||
disabled={!valid}
|
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}
|
loading={create.isPending}
|
||||||
icon={<Scissors className="w-4 h-4" />}
|
icon={<Scissors className="w-4 h-4" />}
|
||||||
>
|
>
|
||||||
{willJoin
|
{gif
|
||||||
? t("editor.createJoin")
|
? t("editor.createGif", { count: kept.length })
|
||||||
: kept.length > 1
|
: willJoin
|
||||||
? t("editor.createN", { count: kept.length })
|
? t("editor.createJoin")
|
||||||
: t("editor.create")}
|
: kept.length > 1
|
||||||
|
? t("editor.createN", { count: kept.length })
|
||||||
|
: t("editor.create")}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</ModalFooter>
|
</ModalFooter>
|
||||||
|
|||||||
@@ -161,6 +161,68 @@ describe("WatchPage media element", () => {
|
|||||||
expect(dl!.className).toContain("state-filled");
|
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 () => {
|
it("omits the Download for a view-only file link", async () => {
|
||||||
window.history.replaceState({}, "", "/watch/tok-file-noview");
|
window.history.replaceState({}, "", "/watch/tok-file-noview");
|
||||||
mockMeta({
|
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 () => {
|
it("offers a document for download only, and Download only where the link allows it", async () => {
|
||||||
window.history.replaceState({}, "", "/watch/tok-folder");
|
window.history.replaceState({}, "", "/watch/tok-folder");
|
||||||
mockMeta(folder());
|
mockMeta(folder());
|
||||||
|
|||||||
@@ -44,6 +44,10 @@ type FileMeta = {
|
|||||||
duration_s?: number | null;
|
duration_s?: number | null;
|
||||||
audio?: boolean;
|
audio?: boolean;
|
||||||
media_kind?: "video" | "audio" | "file" | null;
|
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;
|
size_bytes?: number | null;
|
||||||
container?: string | null;
|
container?: string | null;
|
||||||
allow_download?: boolean;
|
allow_download?: boolean;
|
||||||
@@ -335,6 +339,8 @@ function FileView({
|
|||||||
writeJSON(LS.watchRepeat, next);
|
writeJSON(LS.watchRepeat, next);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
if (meta.media_kind === "file" && meta.preview === "image")
|
||||||
|
return <SharedPicture meta={meta} nested={nested} />;
|
||||||
if (meta.media_kind === "file")
|
if (meta.media_kind === "file")
|
||||||
/* A generic uploaded file has no player — just an info card (name/type/size) and, if the
|
/* 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
|
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
|
/** 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. */
|
* song carries its length. */
|
||||||
function RowThumb({ file }: { file: FileMeta }) {
|
function RowThumb({ file }: { file: FileMeta }) {
|
||||||
@@ -536,6 +597,9 @@ function FolderView({ folder, token }: { folder: FolderMeta; token: string }) {
|
|||||||
</span>
|
</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 (
|
return (
|
||||||
<li
|
<li
|
||||||
key={f.id}
|
key={f.id}
|
||||||
@@ -544,19 +608,19 @@ function FolderView({ folder, token }: { folder: FolderMeta; token: string }) {
|
|||||||
f.id === playing && "bg-accent/10",
|
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>
|
<div className="flex min-w-0 flex-1 items-center gap-3 px-1.5 py-1.5">{body}</div>
|
||||||
) : (
|
) : (
|
||||||
<Pressable
|
<Pressable
|
||||||
layer
|
layer
|
||||||
selectable
|
selectable
|
||||||
aria-current={f.id === playing ? "true" : undefined}
|
aria-current={f.id === playing ? "true" : undefined}
|
||||||
aria-label={`${T.play}: ${title}`}
|
aria-label={`${picture ? T.open : T.play}: ${title}`}
|
||||||
onClick={() => setPlaying(f.id)}
|
onClick={() => setPlaying(f.id)}
|
||||||
className="flex min-w-0 flex-1 items-center gap-3 rounded-lg px-1.5 py-1.5"
|
className="flex min-w-0 flex-1 items-center gap-3 rounded-lg px-1.5 py-1.5"
|
||||||
>
|
>
|
||||||
{body}
|
{body}
|
||||||
<Play aria-hidden className="size-4 shrink-0 text-muted" />
|
{!picture && <Play aria-hidden className="size-4 shrink-0 text-muted" />}
|
||||||
</Pressable>
|
</Pressable>
|
||||||
)}
|
)}
|
||||||
{folder.allow_download && (
|
{folder.allow_download && (
|
||||||
|
|||||||
@@ -31,6 +31,8 @@ const STR = {
|
|||||||
open: "Open",
|
open: "Open",
|
||||||
folderEmpty: "This folder is empty.",
|
folderEmpty: "This folder is empty.",
|
||||||
play: "Play",
|
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`),
|
files: (n: number) => (n === 1 ? "1 file" : `${n} files`),
|
||||||
},
|
},
|
||||||
hu: {
|
hu: {
|
||||||
@@ -61,6 +63,8 @@ const STR = {
|
|||||||
open: "Megnyitás",
|
open: "Megnyitás",
|
||||||
folderEmpty: "Ez a mappa üres.",
|
folderEmpty: "Ez a mappa üres.",
|
||||||
play: "Lejátszás",
|
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`,
|
files: (n: number) => `${n} fájl`,
|
||||||
},
|
},
|
||||||
de: {
|
de: {
|
||||||
@@ -91,6 +95,9 @@ const STR = {
|
|||||||
open: "Öffnen",
|
open: "Öffnen",
|
||||||
folderEmpty: "Dieser Ordner ist leer.",
|
folderEmpty: "Dieser Ordner ist leer.",
|
||||||
play: "Abspielen",
|
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`),
|
files: (n: number) => (n === 1 ? "1 Datei" : `${n} Dateien`),
|
||||||
},
|
},
|
||||||
} as const;
|
} as const;
|
||||||
|
|||||||
Reference in New Issue
Block a user