fix(downloads): GIF review round 2 — cap as sent, shown length, retry, Join reason
The panels judge a GIF's length in whole milliseconds between the ends as sent, the server's own rule, and a refused length reads rounded up. A shared picture says it failed only once its retry failed too. Join's reason shows on a key's focus; the editor's failure toasts name GIFs. The edit route's cap is test-held, and DESIGN.md records the GIF UI.
This commit is contained in:
@@ -66,6 +66,26 @@ def test_a_gif_edit_it_cannot_make_is_refused_with_its_reason(
|
|||||||
assert e.value.reason == 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
|
@needs_ffmpeg
|
||||||
def test_a_gif_edit_of_exactly_thirty_seconds_is_allowed(db, make_user, tmp_path, monkeypatch):
|
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"))
|
monkeypatch.setattr(settings, "files_root", str(tmp_path / "root"))
|
||||||
|
|||||||
@@ -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
|
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 (`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)
|
## 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 | 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
|
||||||
|
|||||||
@@ -12,8 +12,10 @@ import { log } from "../../lib/log";
|
|||||||
import { formatTimecode } from "../../lib/timecode";
|
import { formatTimecode } from "../../lib/timecode";
|
||||||
import { formatNumber } from "../../lib/format";
|
import { formatNumber } from "../../lib/format";
|
||||||
import {
|
import {
|
||||||
|
atMs,
|
||||||
estimateGif,
|
estimateGif,
|
||||||
gifEstimateText,
|
gifEstimateText,
|
||||||
|
gifShownLength,
|
||||||
gifVerdict,
|
gifVerdict,
|
||||||
GIF_DEFAULT_SIDE,
|
GIF_DEFAULT_SIDE,
|
||||||
GIF_MAX_S,
|
GIF_MAX_S,
|
||||||
@@ -146,7 +148,7 @@ export default function ClipPanel({
|
|||||||
// how many pixels it holds.
|
// how many pixels it holds.
|
||||||
const shape = crop ? (crop.rect.w * picture) / crop.rect.h : picture;
|
const shape = crop ? (crop.rect.w * picture) / crop.rect.h : picture;
|
||||||
const estimate = estimateGif(length, side, shape);
|
const estimate = estimateGif(length, side, shape);
|
||||||
const verdict = gif ? gifVerdict(length, estimate) : "ok";
|
const verdict = gif ? gifVerdict(clip.start, clip.end, estimate) : "ok";
|
||||||
const estimateText = gifEstimateText(estimate, i18n.language);
|
const estimateText = gifEstimateText(estimate, i18n.language);
|
||||||
const blocked = verdict === "too_long";
|
const blocked = verdict === "too_long";
|
||||||
|
|
||||||
@@ -197,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
|
||||||
? {
|
? {
|
||||||
@@ -347,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"
|
||||||
|
|||||||
@@ -24,6 +24,8 @@
|
|||||||
"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",
|
||||||
|
|||||||
@@ -24,6 +24,8 @@
|
|||||||
"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",
|
||||||
|
|||||||
@@ -4,8 +4,10 @@ import {
|
|||||||
gifEstimateText,
|
gifEstimateText,
|
||||||
GIF_MAX_BYTES,
|
GIF_MAX_BYTES,
|
||||||
GIF_PREVIEW_MAX_BYTES,
|
GIF_PREVIEW_MAX_BYTES,
|
||||||
|
gifShownLength,
|
||||||
gifVerdict,
|
gifVerdict,
|
||||||
} from "./gif";
|
} from "./gif";
|
||||||
|
import { formatTimecode } from "./timecode";
|
||||||
|
|
||||||
const MB = 1024 * 1024;
|
const MB = 1024 * 1024;
|
||||||
|
|
||||||
@@ -50,22 +52,42 @@ describe("gifEstimateText", () => {
|
|||||||
|
|
||||||
describe("gifVerdict", () => {
|
describe("gifVerdict", () => {
|
||||||
it("refuses past the length cap, whatever the size", () => {
|
it("refuses past the length cap, whatever the size", () => {
|
||||||
expect(gifVerdict(30.2, { hi: 1 })).toBe("too_long");
|
expect(gifVerdict(0, 30.2, { hi: 1 })).toBe("too_long");
|
||||||
expect(gifVerdict(30, { hi: 1 })).toBe("ok");
|
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", () => {
|
it("warns past the server's cap, then past the link preview's", () => {
|
||||||
expect(gifVerdict(10, { hi: GIF_MAX_BYTES + 0.6 * MB })).toBe("may_fail");
|
expect(gifVerdict(0, 10, { hi: GIF_MAX_BYTES + 0.6 * MB })).toBe("may_fail");
|
||||||
expect(gifVerdict(10, { hi: GIF_MAX_BYTES })).toBe("no_preview");
|
expect(gifVerdict(0, 10, { hi: GIF_MAX_BYTES })).toBe("no_preview");
|
||||||
expect(gifVerdict(10, { hi: GIF_PREVIEW_MAX_BYTES + 0.6 * MB })).toBe("no_preview");
|
expect(gifVerdict(0, 10, { hi: GIF_PREVIEW_MAX_BYTES + 0.6 * MB })).toBe("no_preview");
|
||||||
expect(gifVerdict(10, { hi: GIF_PREVIEW_MAX_BYTES })).toBe("ok");
|
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", () => {
|
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".
|
// 30 s at 480 lands a hair over 30 MiB, and reads "about 12–30 MB".
|
||||||
const hi = GIF_MAX_BYTES + 0.4 * MB;
|
const hi = GIF_MAX_BYTES + 0.4 * MB;
|
||||||
expect(gifEstimateText({ lo: 12 * MB, hi }, "en").hi).toBe("30");
|
expect(gifEstimateText({ lo: 12 * MB, hi }, "en").hi).toBe("30");
|
||||||
expect(gifVerdict(30, { hi })).toBe("no_preview");
|
expect(gifVerdict(0, 30, { hi })).toBe("no_preview");
|
||||||
expect(gifVerdict(10, { hi: GIF_PREVIEW_MAX_BYTES + 0.4 * MB })).toBe("ok");
|
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);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
+18
-2
@@ -53,8 +53,24 @@ export function gifEstimateText(est: { lo: number; hi: number }, lang: string) {
|
|||||||
* pass the cap (refused when it does), it may be too big to move in a link preview, or fine. */
|
* 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";
|
export type GifVerdict = "too_long" | "may_fail" | "no_preview" | "ok";
|
||||||
|
|
||||||
export function gifVerdict(seconds: number, est: { hi: number }): GifVerdict {
|
/** A time as the request sends it: to the millisecond. */
|
||||||
if (seconds > GIF_MAX_S + 0.0005) return "too_long";
|
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. */
|
||||||
|
export 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"
|
// 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.
|
// would contradict itself, and the estimate is no finer than that anyway.
|
||||||
const hi = Math.round(est.hi / MB) * MB;
|
const hi = Math.round(est.hi / MB) * MB;
|
||||||
|
|||||||
@@ -9,9 +9,15 @@ import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
|||||||
|
|
||||||
vi.mock("react-i18next", async (importOriginal) => ({
|
vi.mock("react-i18next", async (importOriginal) => ({
|
||||||
...(await importOriginal<typeof import("react-i18next")>()),
|
...(await importOriginal<typeof import("react-i18next")>()),
|
||||||
// A size option reads as its own words with its unit ("480 px"); every other text is its key.
|
// A size option reads as its own words with its unit ("480 px"), a too-long note with the length
|
||||||
|
// it gives; every other text is its key.
|
||||||
useTranslation: () => ({
|
useTranslation: () => ({
|
||||||
t: (key: string, o?: { n?: string }) => (key === "clip.gif.side" ? `${o?.n} px` : key),
|
t: (key: string, o?: { n?: string; length?: string }) =>
|
||||||
|
key === "clip.gif.side"
|
||||||
|
? `${o?.n} px`
|
||||||
|
: key === "editor.format.segTooLong"
|
||||||
|
? `${key} ${o?.length}`
|
||||||
|
: key,
|
||||||
i18n: { language: "en" },
|
i18n: { language: "en" },
|
||||||
}),
|
}),
|
||||||
}));
|
}));
|
||||||
@@ -22,9 +28,16 @@ vi.mock("../../lib/notifications", async (importOriginal) => ({
|
|||||||
}));
|
}));
|
||||||
|
|
||||||
import VideoEditor from "./VideoEditor";
|
import VideoEditor from "./VideoEditor";
|
||||||
import { api, type DownloadJob } from "../../lib/api";
|
import { api, HttpError, type DownloadJob } from "../../lib/api";
|
||||||
|
import { markReported } from "../../lib/api/core";
|
||||||
import { msg } from "../../lib/notifications";
|
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(() => {
|
beforeAll(() => {
|
||||||
class Stub {
|
class Stub {
|
||||||
observe() {}
|
observe() {}
|
||||||
@@ -94,7 +107,7 @@ describe("VideoEditor — GIF", () => {
|
|||||||
fireEvent.click(screen.getByRole("button", { name: "editor.format.gif" }));
|
fireEvent.click(screen.getByRole("button", { name: "editor.format.gif" }));
|
||||||
const join = screen.getByRole("button", { name: "editor.output.join" });
|
const join = screen.getByRole("button", { name: "editor.output.join" });
|
||||||
expect(join.getAttribute("aria-disabled")).toBe("true");
|
expect(join.getAttribute("aria-disabled")).toBe("true");
|
||||||
expect(join.getAttribute("title")).toBe("editor.output.joinGif");
|
expect(join.getAttribute("aria-description")).toBe("editor.output.joinGif");
|
||||||
expect(join.getAttribute("aria-pressed")).toBe("false");
|
expect(join.getAttribute("aria-pressed")).toBe("false");
|
||||||
fireEvent.click(screen.getByRole("button", { name: "editor.createGif" }));
|
fireEvent.click(screen.getByRole("button", { name: "editor.createGif" }));
|
||||||
await waitFor(() => expect(enqueue).toHaveBeenCalledTimes(2));
|
await waitFor(() => expect(enqueue).toHaveBeenCalledTimes(2));
|
||||||
@@ -110,7 +123,7 @@ describe("VideoEditor — GIF", () => {
|
|||||||
const create = screen.getByRole("button", { name: "editor.createGif" });
|
const create = screen.getByRole("button", { name: "editor.createGif" });
|
||||||
expect(create.getAttribute("aria-disabled")).toBe("true");
|
expect(create.getAttribute("aria-disabled")).toBe("true");
|
||||||
const notes = document.getElementById(create.getAttribute("aria-describedby")!)!;
|
const notes = document.getElementById(create.getAttribute("aria-describedby")!)!;
|
||||||
expect(notes.textContent).toBe("editor.format.segTooLong");
|
expect(notes.textContent).toBe("editor.format.segTooLong 1:40.0");
|
||||||
fireEvent.click(create);
|
fireEvent.click(create);
|
||||||
expect(enqueue).not.toHaveBeenCalled();
|
expect(enqueue).not.toHaveBeenCalled();
|
||||||
// A video of the same range has no such limit.
|
// A video of the same range has no such limit.
|
||||||
@@ -120,6 +133,43 @@ describe("VideoEditor — GIF", () => {
|
|||||||
).toBe(null);
|
).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 0:30.1");
|
||||||
|
});
|
||||||
|
|
||||||
|
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", () => {
|
it("offers no GIF for an audio file, which has no picture to make one of", () => {
|
||||||
open(20, "audio");
|
open(20, "audio");
|
||||||
expect(screen.queryByRole("button", { name: "editor.format.gif" })).toBeNull();
|
expect(screen.queryByRole("button", { name: "editor.format.gif" })).toBeNull();
|
||||||
@@ -151,11 +201,15 @@ describe("VideoEditor — GIF", () => {
|
|||||||
expect(screen.queryByText("editor.format.estimate")).toBeNull();
|
expect(screen.queryByText("editor.format.estimate")).toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("Join needs two kept segments, and says so instead of going dead", () => {
|
it("Join needs two kept segments, and says so instead of going dead", async () => {
|
||||||
open(20);
|
open(20);
|
||||||
const join = screen.getByRole("button", { name: "editor.output.join" });
|
const join = screen.getByRole("button", { name: "editor.output.join" });
|
||||||
expect(join.hasAttribute("disabled")).toBe(false);
|
expect(join.hasAttribute("disabled")).toBe(false);
|
||||||
expect(join.getAttribute("aria-disabled")).toBe("true");
|
expect(join.getAttribute("aria-disabled")).toBe("true");
|
||||||
expect(join.getAttribute("title")).toBe("editor.output.joinNeedsTwo");
|
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();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -12,6 +12,8 @@ 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 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";
|
||||||
@@ -25,8 +27,10 @@ import { trackLeft } from "../../components/ui/trackPosition";
|
|||||||
import { formatTimecode } from "../../lib/timecode";
|
import { formatTimecode } from "../../lib/timecode";
|
||||||
import { formatNumber } from "../../lib/format";
|
import { formatNumber } from "../../lib/format";
|
||||||
import {
|
import {
|
||||||
|
atMs,
|
||||||
estimateGif,
|
estimateGif,
|
||||||
gifEstimateText,
|
gifEstimateText,
|
||||||
|
gifShownLength,
|
||||||
gifVerdict,
|
gifVerdict,
|
||||||
GIF_DEFAULT_SIDE,
|
GIF_DEFAULT_SIDE,
|
||||||
GIF_MAX_S,
|
GIF_MAX_S,
|
||||||
@@ -263,9 +267,9 @@ export default function VideoEditor({ job, onClose }: { job: DownloadJob; onClos
|
|||||||
const est = estimateGif(s.end - s.start, side, gifShape);
|
const est = estimateGif(s.end - s.start, side, gifShape);
|
||||||
return {
|
return {
|
||||||
n: segments.indexOf(s) + 1,
|
n: segments.indexOf(s) + 1,
|
||||||
length: s.end - s.start,
|
length: gifShownLength(s.start, s.end),
|
||||||
est,
|
est,
|
||||||
verdict: gifVerdict(s.end - s.start, 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 }), {
|
const gifTotal = gifSegs.reduce((a, g) => ({ lo: a.lo + g.est.lo, hi: a.hi + g.est.hi }), {
|
||||||
@@ -300,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;
|
||||||
@@ -322,7 +326,7 @@ 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;
|
||||||
if (gif) spec.gif = { side };
|
if (gif) spec.gif = { side };
|
||||||
@@ -346,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);
|
||||||
@@ -364,7 +371,7 @@ export default function VideoEditor({ job, onClose }: { job: DownloadJob; onClos
|
|||||||
});
|
});
|
||||||
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;
|
||||||
},
|
},
|
||||||
@@ -607,20 +614,21 @@ export default function VideoEditor({ job, onClose }: { job: DownloadJob; onClos
|
|||||||
<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) => {
|
||||||
// Join is unavailable with its reason in its tooltip (a GIF is never joined; a join
|
// Join is unavailable with its reason in its tooltip (a GIF is never joined; a join
|
||||||
// needs two kept segments), and stays focusable so the reason reaches the keyboard.
|
// needs two kept segments), shown on focus too, and stays focusable so the reason
|
||||||
const unavailable = m === "join" ? joinReason : undefined;
|
// reaches the keyboard.
|
||||||
|
const reason = m === "join" ? joinReason : undefined;
|
||||||
return (
|
return (
|
||||||
<Button
|
<Tooltip key={m} hint={reason ?? ""} always={!!reason} stable>
|
||||||
key={m}
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
size="sm"
|
size="sm"
|
||||||
pressed={(willJoin ? "join" : "separate") === m}
|
pressed={(willJoin ? "join" : "separate") === m}
|
||||||
onClick={() => setOutput(m)}
|
onClick={() => setOutput(m)}
|
||||||
aria-disabled={unavailable ? true : undefined}
|
{...unavailable(!!reason, reason)}
|
||||||
title={unavailable}
|
>
|
||||||
>
|
{t(`editor.output.${m}`)}
|
||||||
{t(`editor.output.${m}`)}
|
</Button>
|
||||||
</Button>
|
</Tooltip>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
<div className="flex-1" />
|
<div className="flex-1" />
|
||||||
|
|||||||
@@ -197,6 +197,12 @@ describe("WatchPage media element", () => {
|
|||||||
});
|
});
|
||||||
const page = render(<WatchPage />);
|
const page = render(<WatchPage />);
|
||||||
fireEvent.error(await page.findByRole("img", { name: "Gone" }));
|
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.");
|
expect((await page.findByRole("status")).textContent).toBe("Couldn't load the picture.");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -489,7 +489,9 @@ function SharedPicture({ meta, nested }: { meta: FileMeta; nested: boolean }) {
|
|||||||
height={meta.height ?? undefined}
|
height={meta.height ?? undefined}
|
||||||
retries={1}
|
retries={1}
|
||||||
// A picture link always has a picture: a missing one failed to load, and says so —
|
// 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={<div aria-hidden className="w-48 h-32" />}
|
||||||
fallback={
|
fallback={
|
||||||
<div role="status" className="flex flex-col items-center gap-2 px-12 py-10 text-muted">
|
<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 />
|
<FileIcon className="w-12 h-12" aria-hidden />
|
||||||
|
|||||||
Reference in New Issue
Block a user