feat(downloads): make a GIF in the clip panel and the editor, and show a shared one as itself

The clip panel and the editor get a Video / GIF choice with the GIF's size, the size it is expected
to come out at, and what that size means (no moving link preview over 15 MB, refused over 30 MB).
A range longer than a GIF may be leaves Make unavailable with its reason. The editor makes one GIF
per kept segment; Join says why it is unavailable instead of going dead. The share page shows a
picture as itself with its Download, and a picture in a folder opens above the list.
This commit is contained in:
2026-11-11 02:10:07 +01:00
parent c221452a00
commit 27ccf253f5
15 changed files with 775 additions and 37 deletions
+136 -24
View File
@@ -10,6 +10,7 @@ import { msg, notify } from "../../lib/notifications";
import { api, type DownloadJob, type EditSpec } from "../../lib/api";
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 { failureReason, notifyActionError } from "../../lib/actionErrors";
import { markReported } from "../../lib/api/core";
@@ -22,6 +23,16 @@ import { SURFACE } from "../../components/ui/surfaces";
import { RangeTrack } from "../../components/ui/RangeTrack";
import { trackLeft } from "../../components/ui/trackPosition";
import { formatTimecode } from "../../lib/timecode";
import {
estimateGif,
gifEstimateText,
gifVerdict,
GIF_DEFAULT_SIDE,
GIF_MAX_S,
GIF_SIDES,
type GifSide,
type GifVerdict,
} from "../../lib/gif";
type Seg = { id: number; start: number; end: number; keep: boolean };
@@ -40,8 +51,18 @@ async function enqueueClips(
}
}
// A GIF is cut per kept segment (the server makes one GIF of one range, never a joined cut-list), so
// what the editor says before Create is said per segment: the worst first — one too long stops
// Create — then one that may pass the server's cap, then one too big to move in a link preview.
const GIF_NOTE_ORDER: GifVerdict[] = ["too_long", "may_fail", "no_preview"];
const GIF_NOTE_KEY = {
too_long: "editor.format.segTooLong",
may_fail: "editor.format.segMayFail",
no_preview: "editor.format.segNoPreview",
} as const;
export default function VideoEditor({ job, onClose }: { job: DownloadJob; onClose: () => void }) {
const { t } = useTranslation();
const { t, i18n } = useTranslation();
const qc = useQueryClient();
const videoRef = useRef<HTMLVideoElement>(null);
const trackRef = useRef<HTMLDivElement>(null);
@@ -72,6 +93,10 @@ export default function VideoEditor({ job, onClose }: { job: DownloadJob; onClos
const [cropOn, setCropOn] = useState(false);
const [crop, setCrop] = useState<Frac>({ x: 0.1, y: 0.1, w: 0.8, h: 0.8 });
const [accurate, setAccurate] = useState(true);
const [format, setFormat] = useState<"video" | "gif">("video");
const [side, setSide] = useState<GifSide>(GIF_DEFAULT_SIDE);
const gif = format === "gif";
const gifNoteId = useId();
const [name, setName] = useState("");
const fileUrl = useMemo(() => api.fileUrl(job.id), [job.id]);
@@ -222,9 +247,34 @@ export default function VideoEditor({ job, onClose }: { job: DownloadJob; onClos
kept.length === 1 &&
kept[0]!.start <= 0.01 &&
kept[0]!.end >= duration - 0.01;
const valid = kept.length >= 1 && keptDur >= MIN_SEG && (cropOn || !isFullSingle);
const willJoin = output === "join" && kept.length > 1;
const reencode = cropOn || accurate;
// A GIF of the whole video is no no-op: the format changes.
const valid = kept.length >= 1 && keptDur >= MIN_SEG && (cropOn || gif || !isFullSingle);
const willJoin = !gif && output === "join" && kept.length > 1;
const reencode = cropOn || accurate || gif;
// The GIF's shape is the crop's when there is one (the long side is capped, so the shape decides
// how many pixels it holds).
const gifShape = cropOn ? (crop.w * va) / crop.h : va;
const gifSegs = kept.map((s) => {
const est = estimateGif(s.end - s.start, side, gifShape);
return {
n: segments.indexOf(s) + 1,
length: s.end - s.start,
est,
verdict: gifVerdict(s.end - s.start, est),
};
});
const gifTotal = gifSegs.reduce((a, g) => ({ lo: a.lo + g.est.lo, hi: a.hi + g.est.hi }), {
lo: 0,
hi: 0,
});
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
? t("editor.output.joinGif")
: kept.length < 2
? t("editor.output.joinNeedsTwo")
: undefined;
const cropPx = (): EditSpec["crop"] => {
const v = videoRef.current;
@@ -270,7 +320,8 @@ export default function VideoEditor({ job, onClose }: { job: DownloadJob; onClos
trim: { start_s: +s.start.toFixed(3), end_s: +s.end.toFixed(3) },
};
if (cp) spec.crop = cp;
else spec.accurate = accurate;
if (gif) spec.gif = { side };
else if (!cp) spec.accurate = accurate;
const dn = suffix.get(s.id) ? `${base}${suffix.get(s.id)}` : name.trim() || undefined;
return { id: s.id, spec, dn };
});
@@ -302,7 +353,10 @@ export default function VideoEditor({ job, onClose }: { job: DownloadJob; onClos
onSuccess: ({ files }) => {
// The whole library moves with it: an edit the user already made is done at once, and lists.
invalidateDownloads(qc);
notify({ level: "success", message: msg("editor.created", { count: files }) });
notify({
level: "success",
message: msg(gif ? "editor.createdGif" : "editor.created", { count: files }),
});
onClose();
},
onError: (e) => notifyActionError(e, msg("editor.failed")),
@@ -546,24 +600,73 @@ export default function VideoEditor({ job, onClose }: { job: DownloadJob; onClos
{/* output mode + summary */}
<div className="flex flex-wrap items-center gap-2 text-sm">
<span className="text-muted">{t("editor.output.label")}:</span>
{(["separate", "join"] as const).map((m) => (
<Button
key={m}
variant="outline"
size="sm"
pressed={output === m}
onClick={() => setOutput(m)}
disabled={m === "join" && kept.length < 2}
>
{t(`editor.output.${m}`)}
</Button>
))}
{(["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;
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>
);
})}
<div className="flex-1" />
<span className="text-xs text-muted">
{t("editor.keptSummary", { n: kept.length, dur: tc(keptDur) })}
</span>
</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>
)}
{/* crop + precision */}
<div className="grid sm:grid-cols-2 gap-3">
<Button
@@ -575,7 +678,11 @@ export default function VideoEditor({ job, onClose }: { job: DownloadJob; onClos
>
{cropOn ? t("editor.cropOn") : t("editor.cropOff")}
</Button>
{!cropOn ? (
{gif ? (
<div className="flex items-center px-3 text-xs text-muted">
{t("editor.format.reencode")}
</div>
) : !cropOn ? (
<div className="flex gap-2">
{(["accurate", "fast"] as const).map((m) => {
const on = (m === "accurate") === accurate;
@@ -623,14 +730,19 @@ export default function VideoEditor({ job, onClose }: { job: DownloadJob; onClos
size="sm"
onClick={() => create.mutate()}
disabled={!valid}
// A GIF segment too long: unavailable with its reason (the notes above), focusable.
aria-disabled={gifBlocked || undefined}
aria-describedby={gifBlocked ? gifNoteId : undefined}
loading={create.isPending}
icon={<Scissors className="w-4 h-4" />}
>
{willJoin
? t("editor.createJoin")
: kept.length > 1
? t("editor.createN", { count: kept.length })
: t("editor.create")}
{gif
? t("editor.createGif", { count: kept.length })
: willJoin
? t("editor.createJoin")
: kept.length > 1
? t("editor.createN", { count: kept.length })
: t("editor.create")}
</Button>
</div>
</ModalFooter>