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
@@ -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;