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

- A GIF squares its pixels before the size, so an anamorphic source keeps
  its shown shape (a 720x480 DVD at 16:9 was 480x320).
- An editor GIF of an HDR upload is tonemapped, as a clip GIF is.
- The length caps are judged in whole milliseconds: a range exactly at a
  cap is no longer refused for a float hair over it.
- A GIF is made of a video only: an audio file is refused with its reason
  and the editor offers no GIF for it.
- A GIF clip keeps no audio track: the track neither splits its cache key
  nor fails it.
- The editor's GIF notes keep their live region mounted; one size reads
  as one ("about 1 MB"), none is shown with nothing kept; the size choices
  show their unit; a shared picture that fails to load says so.
- Copy: the two-sentence GIF notes keep their last stop (a copy-rule test
  now holds it); the English warns of "only a still picture".
This commit is contained in:
2026-11-12 23:24:25 +01:00
parent 0b92b40db5
commit 8845d1aa49
23 changed files with 403 additions and 78 deletions
+8 -4
View File
@@ -110,7 +110,10 @@ def normalize(
if not (math.isfinite(start_s) and math.isfinite(end_s)) or start_s < 0:
raise ClipError("bad_range")
start, end = round(start_s, 3), round(end_s, 3)
if end - start < MIN_CLIP_S:
# Judged in whole milliseconds, as the ends are kept: a float difference lands a hair off
# (60.529 - 30.529 = 30.000000000000004), which would refuse a range exactly at a cap.
span_ms = round((end - start) * 1000)
if span_ms < MIN_CLIP_S * 1000:
raise ClipError("too_short")
side = None
if gif_side is not None:
@@ -118,15 +121,16 @@ def normalize(
side = gifmod.normalize_side(gif_side)
except ValueError as e:
raise ClipError("bad_gif") from e
if side is not None and end - start > gifmod.MAX_S:
if side is not None and gifmod.too_long(start, end):
# Its own reason: a GIF's cap is said in seconds, a clip's in minutes.
raise ClipError("gif_too_long", limit_s=gifmod.MAX_S)
if end - start > MAX_CLIP_S:
if span_ms > MAX_CLIP_S * 1000:
raise ClipError("too_long", limit_s=MAX_CLIP_S)
spec: dict = {"source": source, "ref": ref, "start_s": start, "end_s": end}
if side is not None:
spec["gif"] = {"side": side}
if source == "plex" and audio_track is not None:
# A GIF has no sound: the track the player was on is not part of what it is (its cache key).
if source == "plex" and audio_track is not None and side is None:
if not 0 <= audio_track <= _MAX_AUDIO_TRACK:
raise ClipError("bad_audio")
spec["audio_track"] = audio_track
+17 -2
View File
@@ -39,6 +39,19 @@ def normalize_side(side: object) -> int:
return side
def too_long(start_s: float, end_s: float) -> bool:
"""Over `MAX_S`, counted in whole milliseconds: the ends are kept to the millisecond, and their
float difference may land a hair over (60.529 - 30.529 = 30.000000000000004), which would refuse
the very 30 s the panels allow."""
return round((end_s - start_s) * 1000) > MAX_S * 1000
# A GIF has no pixel shape a viewer honours (browsers ignore its aspect byte), so a source stored
# with wide or narrow pixels (an anamorphic DVD: 720x480 shown at 16:9) is first resampled to square
# ones, or it shows squeezed. `sar` is 1 for a source that names none; even, as a 4:2:0 frame wants.
_SQUARE_PIXELS = "scale=w='trunc(iw*sar/2)*2':h=ih,setsar=1"
def _scale(side: int) -> str:
"""Fit the long side into `side`, never upscaling a smaller picture; the short side keeps the
aspect (-1: a GIF has no chroma grid that wants it even)."""
@@ -50,8 +63,10 @@ def _scale(side: int) -> str:
def filter_graph(pre: str, side: int) -> str:
"""The GIF's filters: `pre` (a crop, an HDR tonemap) on the source's own frame first, then the
frame rate and the size, then a palette made from these frames and applied to them."""
head = ",".join([*([pre] if pre else []), f"fps={FPS}", _scale(side)])
frame rate, square pixels and the size, then a palette made from these frames and applied to them.
A crop counts the stored frame (both callers' crops are fractions of `iw`/`ih`), so it comes
before the pixels are squared."""
head = ",".join([*([pre] if pre else []), f"fps={FPS}", _SQUARE_PIXELS, _scale(side)])
return (
f"[0:v:0]{head},split[a][b];"
"[a]palettegen=stats_mode=diff[p];"
+13 -4
View File
@@ -888,15 +888,21 @@ def _process_edit(job_id: int, asset_id: int) -> None:
_set_job(job_id, phase="editing", progress=0, speed_bps=None, eta_s=None)
if gif:
# One range with an end (the service's check), cropped first if a frame was drawn.
# One range with an end (the service's check), cropped first if a frame was drawn, then
# tonemapped as a clip GIF is: an HDR upload keeps its HLG/PQ tags, and a GIF has none,
# so a palette made from those frames would be washed out.
trim = edit_spec["trim"]
start = trim.get("start_s") or 0.0
data = probe.probe_json(src_path)
cmd = gifmod.build_cmd(
src_path,
dest_staging,
start,
trim["end_s"] - start,
pre=editmod.crop_filter(edit_spec["crop"]) if crop else "",
pre=clipmod.gif_pre(
editmod.crop_filter(edit_spec["crop"]) if crop else "",
bool(data) and clipmod.is_hdr(data),
),
side=gif["side"],
)
elif edit_spec.get("segments"):
@@ -1115,14 +1121,17 @@ def _process_clip(job_id: int, asset_id: int) -> None:
raise StatedFailure("The source file could not be read.")
audio_track = spec.get("audio_track")
tracks = clipmod.audio_track_count(data)
if tracks == 0:
gif = spec.get("gif")
if gif:
# A GIF has no sound: no track is chosen, so none can be missing.
audio_track = None
elif tracks == 0:
# A silent source: the player still names its track 0, and the clip is silent too.
audio_track = None
elif audio_track is not None and audio_track >= tracks:
raise StatedFailure("The chosen audio track is not in the file.")
crop = _clip_crop(spec, data)
total_s = clipmod.duration(spec)
gif = spec.get("gif")
hdr = clipmod.is_hdr(data)
out = staging / ("clip.gif" if gif else "clip.mp4")
_set_job(job_id, phase="editing", progress=0, speed_bps=None, eta_s=None)
+11 -2
View File
@@ -44,7 +44,13 @@ from app.userscope import owned_by, owned_row
# Every reason an edit is refused; the UI names each one (`downloads.errors.edit.<reason>`, held by
# the frontend's backendIds contract, which reads this Literal).
EditReason = Literal[
"source_not_ready", "not_media", "empty_edit", "bad_gif", "gif_range", "gif_too_long"
"source_not_ready",
"not_media",
"empty_edit",
"bad_gif",
"gif_range",
"gif_too_long",
"gif_not_video",
]
@@ -268,7 +274,7 @@ def _check_gif_edit(spec: dict) -> None:
trim = spec.get("trim") or {}
if spec.get("segments") or trim.get("end_s") is None:
raise EditError("gif_range")
if trim["end_s"] - trim.get("start_s", 0.0) > gifmod.MAX_S:
if gifmod.too_long(trim.get("start_s", 0.0), trim["end_s"]):
raise EditError("gif_too_long", limit_s=gifmod.MAX_S)
@@ -298,6 +304,9 @@ def enqueue_edit(
if not spec:
raise EditError("empty_edit")
if "gif" in (edit_spec or {}):
# A GIF is made of the first video stream: audio has none, or only its cover picture.
if src_asset.media_kind != mediakind.VIDEO:
raise EditError("gif_not_video")
_check_gif_edit(spec)
quota.check_enqueue(db, user_id)
+37
View File
@@ -740,6 +740,30 @@ 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
):
@@ -810,6 +834,19 @@ def test_a_gif_clip_is_cut_through_the_gif_command_and_stored_as_an_image_file(
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
):
+81
View File
@@ -3,12 +3,15 @@ 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
@@ -63,6 +66,84 @@ def test_a_gif_edit_it_cannot_make_is_refused_with_its_reason(
assert e.value.reason == reason
@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
+33 -1
View File
@@ -66,11 +66,25 @@ def test_a_crop_or_a_tonemap_comes_before_the_scale_and_the_palette():
assert graph.index("crop=100:100:0:0") < graph.index("scale=") < graph.index("palettegen")
def _video(path: Path, size: str, seconds: int = 2) -> Path:
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,
@@ -78,6 +92,24 @@ def _video(path: Path, size: str, seconds: int = 2) -> Path:
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"),
@@ -14,7 +14,11 @@ vi.mock("react-i18next", async (importOriginal) => {
const actual = await importOriginal<typeof import("react-i18next")>();
return {
...actual,
useTranslation: () => ({ t: (k: string) => k, i18n: { language: "en" } }),
// A size option reads as its own words with its unit ("480 px"); every other text is its key.
useTranslation: () => ({
t: (k: string, o?: { n?: string }) => (k === "clip.gif.side" ? `${o?.n} px` : k),
i18n: { language: "en" },
}),
};
});
@@ -281,7 +285,7 @@ describe("ClipPanel GIF", () => {
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" }));
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());
@@ -298,7 +302,9 @@ describe("ClipPanel GIF", () => {
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" }).getAttribute("aria-pressed")).toBe("true");
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());
@@ -333,7 +339,21 @@ describe("ClipPanel GIF", () => {
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" }));
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();
});
});
+7 -2
View File
@@ -10,6 +10,7 @@ import { notifyActionError } from "../../lib/actionErrors";
import { showInTray } from "../../lib/downloadQueue";
import { log } from "../../lib/log";
import { formatTimecode } from "../../lib/timecode";
import { formatNumber } from "../../lib/format";
import {
estimateGif,
gifEstimateText,
@@ -146,6 +147,7 @@ export default function ClipPanel({
const shape = crop ? (crop.rect.w * picture) / crop.rect.h : picture;
const estimate = estimateGif(length, side, shape);
const verdict = gif ? gifVerdict(length, 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
@@ -421,10 +423,13 @@ export default function ClipPanel({
ariaLabel={t("clip.gif.size")}
value={String(side)}
onChange={(v) => setSide(Number(v) as GifSide)}
options={GIF_SIDES.map((s) => ({ value: String(s), label: String(s) }))}
options={GIF_SIDES.map((s) => ({
value: String(s),
label: t("clip.gif.side", { n: formatNumber(s, i18n.language) }),
}))}
/>
<span className="tabular-nums text-muted" aria-live="polite">
{t("clip.gif.estimate", gifEstimateText(estimate, i18n.language))}
{t(estimateText.single ? "clip.gif.estimateOne" : "clip.gif.estimate", estimateText)}
</span>
</>
)}
+7
View File
@@ -39,6 +39,13 @@ describe("the copy rule", () => {
expect(offenders((v) => v.includes("✓"))).toEqual([]);
});
it("keeps the stops of a message of two sentences, the last one too", () => {
// A sentence break: a stop, a space, then a capital ("kb. {{n}}" is an abbreviation, not one). A
// value ending in a space is the head of a sentence a link or a number completes on screen.
const twoSentences = (v: string) => /[.!?] \p{Lu}/u.test(v) && !v.endsWith(" ");
expect(offenders((v) => twoSentences(v) && !/[.!?…]["”)]?$/u.test(v))).toEqual([]);
});
it("spells no unit as a bare letter after a number ('90m', '24h', '{{count}}d', '10 s')", () => {
expect(offenders((v) => /(\d|\}\})\s?(m|h|d|s)(?![\p{L}\d/])/u.test(v))).toEqual([]);
});
+5 -3
View File
@@ -28,11 +28,13 @@
"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 it still. The downloaded file moves 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",
"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"
@@ -227,6 +227,7 @@
"bad_gif": "That GIF size isn’t available",
"gif_range": "A GIF is made from one range with a start and an end",
"gif_too_long": "A GIF is at most {{seconds}} seconds long",
"gif_not_video": "A GIF is made from a video",
"generic": "Couldn't create that edit"
},
"upload": {
+2 -1
View File
@@ -37,10 +37,11 @@
"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 it still"
"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",
+5 -3
View File
@@ -28,11 +28,13 @@
"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",
"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"
@@ -227,6 +227,7 @@
"bad_gif": "Ez a GIF-méret nem választható",
"gif_range": "GIF egyetlen szakaszból készül, aminek eleje és vége is van",
"gif_too_long": "A GIF legfeljebb {{seconds}} másodperc lehet",
"gif_not_video": "GIF csak videóból készülhet",
"generic": "Nem sikerült létrehozni a vágást"
},
"upload": {
+1
View File
@@ -37,6 +37,7 @@
"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",
+15
View File
@@ -33,6 +33,21 @@ describe("estimateGif", () => {
});
});
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(30.2, { hi: 1 })).toBe("too_long");
+3 -2
View File
@@ -41,11 +41,12 @@ export function estimateGif(
}
/** 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). */
* 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) };
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
@@ -9,7 +9,11 @@ import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
vi.mock("react-i18next", async (importOriginal) => ({
...(await importOriginal<typeof import("react-i18next")>()),
useTranslation: () => ({ t: (key: string) => key, i18n: { language: "en" } }),
// A size option reads as its own words with its unit ("480 px"); every other text is its key.
useTranslation: () => ({
t: (key: string, o?: { n?: string }) => (key === "clip.gif.side" ? `${o?.n} px` : key),
i18n: { language: "en" },
}),
}));
const { notify } = vi.hoisted(() => ({ notify: vi.fn() }));
vi.mock("../../lib/notifications", async (importOriginal) => ({
@@ -39,20 +43,20 @@ afterEach(() => {
notify.mockReset();
});
const jobOf = (duration: number) =>
const jobOf = (duration: number, kind = "video") =>
({
id: 7,
display_name: "Talk",
asset: { duration_s: duration, width: 1920, height: 1080, title: "Talk" },
asset: { duration_s: duration, width: 1920, height: 1080, title: "Talk", media_kind: kind },
}) as unknown as DownloadJob;
function open(duration: number) {
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)} onClose={onClose} />
<VideoEditor job={jobOf(duration, kind)} onClose={onClose} />
</QueryClientProvider>,
);
const video = document.querySelector("video")!;
@@ -67,7 +71,7 @@ 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" }));
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" }));
@@ -116,6 +120,37 @@ describe("VideoEditor — GIF", () => {
).toBe(null);
});
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");
});
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", () => {
open(20);
const join = screen.getByRole("button", { name: "editor.output.join" });
+62 -41
View File
@@ -23,6 +23,7 @@ import { SURFACE } from "../../components/ui/surfaces";
import { RangeTrack } from "../../components/ui/RangeTrack";
import { trackLeft } from "../../components/ui/trackPosition";
import { formatTimecode } from "../../lib/timecode";
import { formatNumber } from "../../lib/format";
import {
estimateGif,
gifEstimateText,
@@ -95,7 +96,10 @@ export default function VideoEditor({ job, onClose }: { job: DownloadJob; onClos
const [accurate, setAccurate] = useState(true);
const [format, setFormat] = useState<"video" | "gif">("video");
const [side, setSide] = useState<GifSide>(GIF_DEFAULT_SIDE);
const gif = format === "gif";
// 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("");
@@ -268,6 +272,7 @@ export default function VideoEditor({ job, onClose }: { job: DownloadJob; onClos
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
@@ -625,46 +630,62 @@ export default function VideoEditor({ job, onClose }: { job: DownloadJob; onClos
</div>
{/* format: a video, or each kept segment a GIF */}
<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: String(s) }))}
/>
<span className="text-xs text-muted tabular-nums" aria-live="polite">
{t("editor.format.estimate", gifEstimateText(gifTotal, i18n.language))}
</span>
</>
)}
</div>
{gif && gifNotes.length > 0 && (
<ul id={gifNoteId} className="space-y-0.5 text-xs" aria-live="polite">
{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>
{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) }),
}))}
/>
{kept.length > 0 && (
<span className="text-xs text-muted tabular-nums" aria-live="polite">
{t(
gifTotalText.single ? "editor.format.estimateOne" : "editor.format.estimate",
gifTotalText,
)}
</span>
)}
</>
)}
</div>
)}
{/* Mounted for as long as GIF is chosen: a live region announces what changes inside it,
not what arrives together with it, and the first note is the one that counts. */}
{gif && (
<div id={gifNoteId} aria-live="polite">
{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 */}
+16
View File
@@ -184,6 +184,22 @@ describe("WatchPage media element", () => {
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" }));
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({
+8 -1
View File
@@ -488,7 +488,14 @@ function SharedPicture({ meta, nested }: { meta: FileMeta; nested: boolean }) {
width={meta.width ?? undefined}
height={meta.height ?? undefined}
retries={1}
fallback={<FileIcon className="m-12 w-12 h-12 text-muted" aria-hidden />}
// 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.
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]",
+5 -2
View File
@@ -31,7 +31,8 @@ const STR = {
open: "Open",
folderEmpty: "This folder is empty.",
play: "Play",
gifTip: "To share it in a chat, drag the downloaded file in: it moves everywhere.",
gifTip: "To share it in a chat, drag the downloaded file in: it plays everywhere.",
pictureFailed: "Couldn't load the picture.",
files: (n: number) => (n === 1 ? "1 file" : `${n} files`),
},
hu: {
@@ -63,6 +64,7 @@ const STR = {
folderEmpty: "Ez a mappa üres.",
play: "Lejátszás",
gifTip: "Csetbe a letöltött fájlt húzd be: az mindenhol mozog.",
pictureFailed: "Nem sikerült betölteni a képet.",
files: (n: number) => `${n} fájl`,
},
de: {
@@ -94,7 +96,8 @@ const STR = {
folderEmpty: "Dieser Ordner ist leer.",
play: "Abspielen",
gifTip:
"Zum Teilen in einem Chat die heruntergeladene Datei hineinziehen: Sie bewegt sich überall.",
"Zum Teilen in einem Chat die heruntergeladene Datei hineinziehen: Sie läuft überall als Animation.",
pictureFailed: "Das Bild konnte nicht geladen werden.",
files: (n: number) => (n === 1 ? "1 Datei" : `${n} Dateien`),
},
} as const;