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