diff --git a/backend/tests/test_edit_gif.py b/backend/tests/test_edit_gif.py index 9ef0193a..c393c6b1 100644 --- a/backend/tests/test_edit_gif.py +++ b/backend/tests/test_edit_gif.py @@ -66,6 +66,26 @@ def test_a_gif_edit_it_cannot_make_is_refused_with_its_reason( 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")) diff --git a/docs/DESIGN.md b/docs/DESIGN.md index 27a63d6c..5c41dfb6 100644 --- a/docs/DESIGN.md +++ b/docs/DESIGN.md @@ -646,6 +646,18 @@ of a full-width panel and nothing on the right. press outside the frame is still the video's. A cut is a toast with a Downloads link and the usual "Ready to watch" notification. Limits are the server's: 1 s to 10 min (`lib/clipRange`, held to `downloads/clip.py` by `clipLimits.contract.test.ts`; the "too long" message names the server's cap). + **GIF (owner's picks D1 A, D2 A, D3):** a GIF is made only here and in the Downloads editor, never from the + download dialog. The panel's Format · Video / GIF toggle sits above the name row; with GIF it shows the size + (the long side: 320 / 480 / 640 px), the expected size ("about 4–10 MB"; one number when both ends read the + same), a note over 15 MB (a chat's link preview will not move) or 30 MB (it may be refused), and over 30 s + the reason, with Make GIF unavailable. A refused length is shown rounded up, never as one the cap allows. The + cap is judged in whole milliseconds between the ends as sent, the server's own rule (`lib/gif`, held to + `downloads/gif.py` by `gif.contract.test.ts`). The editor has its own Format row (a video only): with GIF + each kept segment is a GIF of its own, Join is unavailable with its reason in a tooltip, the precise / fast + choice gives way to one line (a GIF is always re-encoded), and the notes name each segment over a limit, + in one live region. A shared GIF's page shows the picture itself (no player), its name, kind and size, its + Download, and a tip: the downloaded file moves in any chat. A picture that fails says so only once its + retry failed too. ## Dialogs — _Decided_ (C4, built in S3f) @@ -878,3 +890,6 @@ of a full-width panel and nothing on the right. | 2026-11-07 | The panel over the picture is darker, its error the media red (owner a) | `OVER_VIDEO` is black 85 % under `media-scope`: an error on it is the media red in both themes | Only the red changed (3.75:1 on a white frame); black 90 % | A light theme's red was 1.6:1 on the 75 % panel over a white frame; now 5.47:1 at worst, white text too | | 2026-11-07 | A total's left-out videos are "without length" (owner b) | A summed length names what it left out as "+N without length" / "+N hossz nélkül" | "+N live" as before; "+N stream" | The count holds archives and upcoming streams too, which a card calls a stream or upcoming: "live" there named only one of them (One name per feature) | | 2026-11-08 | The Channels table's Length is the sum alone (owner) | The table's Length cell shows the summed hours only; the "+N without length" stays on a playlist's total | The line wrapped in the column; the column widened to 6.5 rem | The 4.5 rem column is sized for the sum: the longer line spilled into Tags (88 px in 56), and the owner wants no such line there | +| 2026-11-11 | A GIF from the clip panel (owner D1 A) | A Format · Video / GIF toggle above the name row; GIF shows the size (long side 320 / 480 / 640), the expected size, a note over 15 MB or 30 MB, and over 30 s the reason with Make GIF unavailable; a GIF is never made from the download dialog | A GIF format in the download dialog with its own range picker; the size as a free number | The range is already marked in the panel; a size people can judge beats pixels to type, and a chat's link preview stops moving over 15 MB | +| 2026-11-11 | A GIF from the Downloads editor (owner D2 A) | Its own Format row (a video only); with GIF each kept segment is its own GIF, Join unavailable with its reason, one line in place of precise / fast, a note per segment over a limit | One GIF of the joined segments; GIF as an output mode beside Separate / Join | A GIF longer than 30 s is refused anyway, and a joined GIF hides which segment is over the limit | +| 2026-11-11 | A shared GIF shows itself (owner D3) | The share page shows the picture itself, no player; Download beside it, and a tip that the downloaded file moves in any chat; its failure is said only once its retry failed too | The watch player; a file row that only downloads | A GIF plays and repeats on its own and nobody pauses one; a chat's link preview is the chat's to draw, the dragged-in file moves everywhere | diff --git a/frontend/src/components/ui/RemoteImage.tsx b/frontend/src/components/ui/RemoteImage.tsx index 0c0406b1..6e330341 100644 --- a/frontend/src/components/ui/RemoteImage.tsx +++ b/frontend/src/components/ui/RemoteImage.tsx @@ -12,7 +12,8 @@ import { useEffect, useState, type ImgHTMLAttributes, type ReactNode } from "rea // `r` parameter (measured 2026-10-14). A 429 from a burst of cards heals this way. // - While there is no picture — none given, a retry still waiting, every retry spent — the caller's // `fallback` stands in (nothing by default: the card's own empty frame, which is what a card with no -// thumbnail has always shown). Never the broken-image mark. +// thumbnail has always shown), or its `pending` while a retry waits when the fallback says the +// picture failed. Never the broken-image mark. // - The failure is remembered BY URL, in render: these sit in virtualized grids whose slots are recycled, // and a failure stored as a flag an effect resets painted one item's fallback over the next one's // picture for a frame (ui/Avatar had that defect). @@ -47,6 +48,7 @@ export default function RemoteImage({ src, srcSet, fallback = null, + pending = fallback, retries = IMAGE_RETRIES, ...img }: Omit, "src" | "srcSet" | "onError" | "referrerPolicy"> & { @@ -54,6 +56,9 @@ export default function RemoteImage({ srcSet?: string; /** What stands in while there is no picture. */ fallback?: ReactNode; + /** What stands in while a retry waits, when that is not the fallback: a fallback that says the + * picture failed would say it of one that may still come. */ + pending?: ReactNode; /** Fewer retries for a picture whose failure is an answer, not a hiccup — a file that is not there * yet is better shown as its fallback now than after seven seconds of retries. */ retries?: number; @@ -75,7 +80,8 @@ export default function RemoteImage({ }, [waiting, attempt, src]); const spent = current !== null && !current.waiting && current.attempt > retries; - if (!src || waiting || spent) return <>{fallback}; + if (waiting && src) return <>{pending}; + if (!src || spent) return <>{fallback}; return ( // eslint-disable-next-line no-restricted-syntax -- the one the rest of the app goes through moveOut(current, true)} /> - {t("clip.length", { length: formatTimecode(length) })} + {t("clip.length", { + length: formatTimecode(gif ? gifShownLength(clip.start, clip.end) : length), + })} + + + ); })}
diff --git a/frontend/src/watch/WatchPage.dom.test.tsx b/frontend/src/watch/WatchPage.dom.test.tsx index 253280ab..a7de026a 100644 --- a/frontend/src/watch/WatchPage.dom.test.tsx +++ b/frontend/src/watch/WatchPage.dom.test.tsx @@ -197,6 +197,12 @@ describe("WatchPage media element", () => { }); const page = render(); 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."); }); diff --git a/frontend/src/watch/WatchPage.tsx b/frontend/src/watch/WatchPage.tsx index 960f3a7e..356b268e 100644 --- a/frontend/src/watch/WatchPage.tsx +++ b/frontend/src/watch/WatchPage.tsx @@ -489,7 +489,9 @@ function SharedPicture({ meta, nested }: { meta: FileMeta; nested: boolean }) { 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. + // 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={
} fallback={