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
+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 */}