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:
2026-11-13 00:19:53 +01:00
parent 8845d1aa49
commit fe6af1445f
12 changed files with 200 additions and 43 deletions
+27 -19
View File
@@ -12,6 +12,8 @@ import { cropToSourcePx, type Frac } from "../../lib/cropGeometry";
import { CropFrame } from "../../components/ui/CropFrame";
import SegmentedToggle from "../../components/ui/SegmentedToggle";
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 { markReported } from "../../lib/api/core";
import { IconButton } from "../../components/ui/IconButton";
@@ -25,8 +27,10 @@ import { trackLeft } from "../../components/ui/trackPosition";
import { formatTimecode } from "../../lib/timecode";
import { formatNumber } from "../../lib/format";
import {
atMs,
estimateGif,
gifEstimateText,
gifShownLength,
gifVerdict,
GIF_DEFAULT_SIDE,
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);
return {
n: segments.indexOf(s) + 1,
length: s.end - s.start,
length: gifShownLength(s.start, s.end),
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 }), {
@@ -300,7 +304,7 @@ export default function VideoEditor({ job, onClose }: { job: DownloadJob; onClos
const base = name.trim() || defaultName;
if (willJoin) {
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,
};
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.
const clips = kept.map((s) => {
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 (gif) spec.gif = { side };
@@ -346,7 +350,10 @@ export default function VideoEditor({ job, onClose }: { job: DownloadJob; onClos
// what the dialog does not tell.
notify({
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),
});
markReported(e);
@@ -364,7 +371,7 @@ export default function VideoEditor({ job, onClose }: { job: DownloadJob; onClos
});
onClose();
},
onError: (e) => notifyActionError(e, msg("editor.failed")),
onError: (e) => notifyActionError(e, msg(gif ? "editor.failedGif" : "editor.failed")),
onSettled: () => {
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>
{(["separate", "join"] as const).map((m) => {
// 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.
const unavailable = m === "join" ? joinReason : undefined;
// needs two kept segments), shown on focus too, and stays focusable so the reason
// reaches the keyboard.
const reason = m === "join" ? joinReason : undefined;
return (
<Button
key={m}
variant="outline"
size="sm"
pressed={(willJoin ? "join" : "separate") === m}
onClick={() => setOutput(m)}
aria-disabled={unavailable ? true : undefined}
title={unavailable}
>
{t(`editor.output.${m}`)}
</Button>
<Tooltip key={m} hint={reason ?? ""} always={!!reason} stable>
<Button
variant="outline"
size="sm"
pressed={(willJoin ? "join" : "separate") === m}
onClick={() => setOutput(m)}
{...unavailable(!!reason, reason)}
>
{t(`editor.output.${m}`)}
</Button>
</Tooltip>
);
})}
<div className="flex-1" />