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:
@@ -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" />
|
||||
|
||||
Reference in New Issue
Block a user