Files
J621/frontend/src/features/optimize/OptimizeModal.tsx
T
JakeBreath e97c3b9da0 Toast action results and confirm destructive actions in one dialog
Inline banners and per-row status text reported action results all over
the app; they are replaced by a small toast stack (bottom-right, Level 3
floating well styling) that only speaks for actions: successes fade,
errors stay until dismissed, and form-field validation stays inline.

Destructive actions no longer use bespoke inline confirm steps (the
library detail's Confirm delete button) or fire immediately (duplicate
copies/items, delete page selections, temp cleanup, upload discard, job
cancellation): they all go through one promise-based confirm dialog
(confirmAction) with a danger variant, Escape/backdrop to cancel.
2026-09-17 22:19:54 -05:00

798 lines
28 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { X } from "lucide-react";
import { useState, type ReactNode } from "react";
import { Button, Spinner, inputClass } from "@/components/ui";
import { errorMessage, uploadFile } from "@/lib/api";
import { cn } from "@/lib/cn";
import { formatBytes } from "@/lib/format";
import type { MediaItem } from "@/lib/types";
import { estimateVideoBitrate, VIDEO_CODEC_CANDIDATES } from "./bitrate";
import { browserLabel, probeVideoEncoders, workingCodecs } from "./detect";
import { sniffImageKind } from "./imageformat";
import { deleteStoredFile } from "./opfs";
import {
DEFAULT_OPTIONS,
kindForExtension,
type OptimizeKind,
type OptimizeOptions,
} from "./types";
import { useOptimizer } from "./useOptimizer";
import { toast } from "@/store/toasts";
const DIMENSION_CHOICES: { value: number | null; label: string }[] = [
{ value: null, label: "Keep original" },
{ value: 2560, label: "Max 2560 px" },
{ value: 1920, label: "Max 1920 px" },
{ value: 1280, label: "Max 1280 px" },
{ value: 800, label: "Max 800 px" },
];
const VIDEO_HEIGHTS: { value: number | null; label: string }[] = [
{ value: null, label: "Keep original" },
{ value: 2160, label: "2160p" },
{ value: 1440, label: "1440p" },
{ value: 1080, label: "1080p" },
{ value: 720, label: "720p" },
{ value: 480, label: "480p" },
{ value: 360, label: "360p" },
];
function Field({
label,
children,
}: {
label: string;
children: ReactNode;
}) {
return (
<label className="flex flex-col gap-1.5">
<span className="text-[11px] font-medium uppercase tracking-wide text-ctp-overlay1">
{label}
</span>
{children}
</label>
);
}
function formatEta(ms: number): string {
const seconds = Math.max(1, Math.round(ms / 1000));
if (seconds < 60) return `${seconds}s left`;
const minutes = Math.round(seconds / 60);
return `${minutes} min left`;
}
interface MediaInfo {
width: number;
height: number;
duration: number | null;
}
function PreviewPanel({
title,
url,
isVideo,
size,
note,
state,
onMetadata,
}: {
title: string;
url: string | null;
isVideo: boolean;
size: number | null;
note?: string | null;
state: "original" | "result" | "empty";
onMetadata?: (info: MediaInfo) => void;
}) {
const [info, setInfo] = useState<MediaInfo | null>(null);
function report(next: MediaInfo) {
setInfo(next);
onMetadata?.(next);
}
return (
<section className="flex flex-col overflow-hidden rounded-lg border border-ctp-surface0 bg-ctp-base">
<div className="flex items-center justify-between border-b border-ctp-surface0 px-3 py-1.5">
<span className="text-xs font-semibold text-ctp-subtext1">{title}</span>
{size !== null ? (
<span className="font-mono text-[11px] text-ctp-overlay0">
{formatBytes(size)}
</span>
) : null}
</div>
<div className="flex h-56 items-center justify-center overflow-hidden bg-ctp-mantle">
{url && state !== "empty" ? (
isVideo ? (
<video
key={url}
src={url}
controls
loop
muted
preload="metadata"
className="max-h-56 w-full object-contain"
onLoadedMetadata={(event) => {
const element = event.currentTarget;
report({
width: element.videoWidth,
height: element.videoHeight,
duration: Number.isFinite(element.duration)
? element.duration
: null,
});
}}
/>
) : (
<img
key={url}
src={url}
alt={title}
className="max-h-56 w-full object-contain"
onLoad={(event) => {
const element = event.currentTarget;
report({
width: element.naturalWidth,
height: element.naturalHeight,
duration: null,
});
}}
/>
)
) : (
<span className="px-4 text-center text-xs text-ctp-overlay0">
{state === "empty"
? "Process the file to see the result here"
: "No preview"}
</span>
)}
</div>
<div className="px-3 py-2 text-[11px] text-ctp-overlay0">
{info
? `${info.width}×${info.height}${
info.duration !== null ? ` · ${info.duration.toFixed(1)} s` : ""
}`
: "…"}
{note ? ` · ${note}` : ""}
</div>
</section>
);
}
export function OptimizeModal({
item,
onClose,
}: {
item: MediaItem;
onClose: () => void;
}) {
const queryClient = useQueryClient();
const extensionKind: OptimizeKind =
kindForExtension(item.extension) ?? "image";
const [options, setOptions] = useState<OptimizeOptions>({
...DEFAULT_OPTIONS,
maxDimension: null,
});
// Sniff the header (4 KB range request) so APNGs saved as .png get the
// animation options and animated WebP is caught before a wasted run.
const sniffQuery = useQuery({
queryKey: ["image-kind", item.j_id, item.updated_at],
queryFn: async () => {
const response = await fetch(item.raw_url, {
headers: { Range: "bytes=0-4095" },
});
if (!response.ok && response.status !== 206) {
throw new Error(`HTTP ${response.status}`);
}
return sniffImageKind(await response.blob());
},
enabled: extensionKind === "image",
staleTime: Infinity,
retry: 0,
});
const sniffed = sniffQuery.data ?? null;
const kind: OptimizeKind = sniffed === "apng" ? "animation" : extensionKind;
const animatedWebp = sniffed === "animated-webp";
const apngAsPng = sniffed === "apng" && item.extension !== "apng";
const { status, progress, stage, etaMs, error, result, run, reset } =
useOptimizer();
const [uploading, setUploading] = useState(false);
const [uploadPercent, setUploadPercent] = useState(0);
const [applyError, setApplyError] = useState<string | null>(null);
const [applied, setApplied] = useState(false);
const [originalInfo, setOriginalInfo] = useState<MediaInfo | null>(null);
const [resultInfo, setResultInfo] = useState<MediaInfo | null>(null);
const codecQuery = useQuery({
queryKey: ["optimize-encoders"],
queryFn: () => probeVideoEncoders(),
enabled: kind === "video",
staleTime: Infinity,
retry: 0,
});
const probes = codecQuery.data ?? [];
const container: "mp4" | "webm" =
options.container === "keep"
? item.extension === "webm"
? "webm"
: "mp4"
: options.container;
const usableCodecs = workingCodecs(probes, container);
const isFirefox = browserLabel().startsWith("Firefox");
const applyMutation = useMutation({
mutationFn: async () => {
if (!result) throw new Error("Nothing to apply yet.");
const extension =
kind === "video" && options.container !== "keep"
? options.container
: item.extension;
const file = new File([result.blob], `${item.j_id}.${extension}`, {
type: result.blob.type,
});
setUploading(true);
setUploadPercent(0);
try {
return await uploadFile<MediaItem>(
file,
{},
setUploadPercent,
`/api/files/${item.j_id}/optimize/`,
);
} finally {
setUploading(false);
}
},
onSuccess: () => {
if (result?.storageKey) void deleteStoredFile(result.storageKey);
setApplied(true);
setApplyError(null);
toast.ok(`${item.j_id} optimized.`);
void queryClient.invalidateQueries({ queryKey: ["file", item.j_id] });
void queryClient.invalidateQueries({ queryKey: ["files"] });
void queryClient.invalidateQueries({ queryKey: ["storage"] });
void queryClient.invalidateQueries({ queryKey: ["duplicates-md5"] });
},
onError: (err) => {
const message = errorMessage(err);
setApplyError(message);
toast.error(message);
},
});
const originalSize = item.size;
const resultSize = result?.blob.size ?? null;
const savedPercent =
resultSize !== null && originalSize > 0
? Math.round((1 - resultSize / originalSize) * 100)
: null;
const isVideo = kind === "video";
const truncated =
isVideo &&
originalInfo?.duration != null &&
resultInfo?.duration != null &&
resultInfo.duration + 1 < originalInfo.duration;
// Predicted output size, mirroring Mediabunny's quality -> bitrate mapping.
const estimateCodec =
options.codec !== "auto"
? options.codec
: (usableCodecs[0] ?? VIDEO_CODEC_CANDIDATES[container][0]);
const estimate =
isVideo &&
originalInfo?.duration &&
originalInfo.width &&
originalInfo.height
? (() => {
const sourceHeight = originalInfo.height as number;
const sourceWidth = originalInfo.width as number;
const targetHeight =
options.height && options.height < sourceHeight
? options.height
: sourceHeight;
const targetWidth = Math.round(
sourceWidth * (targetHeight / sourceHeight),
);
const bitrate = estimateVideoBitrate(
estimateCodec,
targetWidth,
targetHeight,
options.quality,
);
return {
bitrate,
size: ((bitrate + 128_000) / 8) * (originalInfo.duration as number),
};
})()
: null;
function process() {
setApplied(false);
setApplyError(null);
if (result?.storageKey) void deleteStoredFile(result.storageKey);
setResultInfo(null);
run({
url: item.raw_url,
filename: item.filename,
kind,
options,
preferredCodecs: usableCodecs,
});
}
function resetRun() {
if (result?.storageKey) void deleteStoredFile(result.storageKey);
reset();
}
return (
<div
className="fixed inset-0 z-[70] flex items-start justify-center overflow-y-auto bg-ctp-crust/70 px-4 py-8 backdrop-blur-sm"
onClick={onClose}
>
<div
className="w-full max-w-6xl overflow-hidden rounded-lg border border-ctp-surface1 bg-ctp-surface0 shadow-[0_4px_12px_rgba(17,17,27,0.6)]"
onClick={(event) => event.stopPropagation()}
>
<header className="flex items-start justify-between gap-3 border-b border-ctp-surface1 px-4 py-3">
<div>
<h2 className="text-sm font-semibold text-ctp-text">
Optimize{" "}
<span className="font-mono text-xs text-ctp-overlay0">
{item.j_id} · {item.extension} · {formatBytes(originalSize)}
</span>
</h2>
<p className="mt-0.5 text-[11px] text-ctp-overlay0">
Processed in your browser (WebCodecs/WASM) — the server only
stores the result. Applying overwrites the file and recomputes
its hashes.
</p>
</div>
<button
type="button"
onClick={onClose}
className="rounded p-1 text-ctp-overlay0 transition hover:bg-ctp-surface1 hover:text-ctp-text"
title="Close"
>
<X className="h-4 w-4" />
</button>
</header>
<div className="grid gap-4 p-4 lg:grid-cols-[minmax(0,1fr)_320px]">
<div className="flex min-w-0 flex-col gap-4">
<div className="grid gap-3 sm:grid-cols-2">
<PreviewPanel
title="Original"
url={item.raw_url}
isVideo={isVideo}
size={originalSize}
state="original"
onMetadata={setOriginalInfo}
/>
<PreviewPanel
title="Processed"
url={result?.url ?? null}
isVideo={isVideo}
size={resultSize}
note={result?.note ?? null}
state={result ? "result" : "empty"}
onMetadata={setResultInfo}
/>
</div>
{animatedWebp ? (
<p className="rounded-md border border-ctp-red/40 bg-ctp-red/10 px-3 py-2 text-xs text-ctp-red">
This is an animated WebP. Browsers have no animated WebP
encoder, so re-encoding would keep only the first frame —
nothing was changed.
</p>
) : null}
{apngAsPng ? (
<p className="rounded-md border border-ctp-blue/40 bg-ctp-blue/10 px-3 py-2 text-xs text-ctp-blue">
Animated PNG detected — the animation options (scale, frame
rate, palette) apply.
</p>
) : null}
{truncated ? (
<p className="rounded-md border border-ctp-red/40 bg-ctp-red/10 px-3 py-2 text-xs text-ctp-red">
The processed video is shorter than the original (
{resultInfo?.duration?.toFixed(1)} s vs{" "}
{originalInfo?.duration?.toFixed(1)} s) — encoding may have
stopped early. Don't apply it.
</p>
) : null}
{status === "running" ? (
<div className="rounded-md border border-ctp-surface1 bg-ctp-base p-3">
<div className="flex items-center gap-3">
<Spinner className="h-4 w-4" />
<span className="text-sm text-ctp-subtext1">
{stage || "Processing the file…"}
</span>
<span className="ml-auto font-mono text-xs text-ctp-overlay0">
{Math.round(progress * 100)}%
</span>
</div>
<div className="mt-2 h-1.5 overflow-hidden rounded-full bg-ctp-surface0">
<div
className="h-full bg-ctp-mauve transition-all"
style={{ width: `${Math.round(progress * 100)}%` }}
/>
</div>
{etaMs !== null ? (
<p className="mt-1 text-[11px] text-ctp-overlay0">
about {formatEta(etaMs)}
</p>
) : null}
</div>
) : null}
{status === "error" && error ? (
<p className="rounded-md border border-ctp-red/40 bg-ctp-red/10 px-3 py-2 text-xs text-ctp-red">
{error}
</p>
) : null}
{result && savedPercent !== null ? (
<p
className={cn(
"rounded-md border px-3 py-2 text-xs",
savedPercent > 0
? "border-ctp-green/40 bg-ctp-green/10 text-ctp-green"
: "border-ctp-peach/40 bg-ctp-peach/10 text-ctp-peach",
)}
>
{formatBytes(originalSize)} → {formatBytes(result.blob.size)}{" "}
{savedPercent > 0
? `(−${savedPercent}%)`
: `(+${Math.abs(savedPercent)}% — larger than the original)`}
</p>
) : null}
{uploading ? (
<div className="flex items-center gap-3">
<div className="h-1.5 w-40 overflow-hidden rounded-full bg-ctp-surface0">
<div
className="h-full bg-ctp-blue transition-all"
style={{ width: `${uploadPercent}%` }}
/>
</div>
<span className="font-mono text-xs text-ctp-overlay0">
uploading… {uploadPercent}%
</span>
</div>
) : null}
{applyError ? (
<p className="rounded-md border border-ctp-red/40 bg-ctp-red/10 px-3 py-2 text-xs text-ctp-red">
{applyError}
</p>
) : null}
{applied ? (
<p className="rounded-md border border-ctp-green/40 bg-ctp-green/10 px-3 py-2 text-xs text-ctp-green">
Applied — the item now stores the processed file.
</p>
) : null}
</div>
<aside className="flex flex-col gap-3">
{kind === "image" ? (
<>
{item.extension !== "png" ? (
<Field label={`Quality — ${options.quality}`}>
<input
type="range"
min={10}
max={100}
value={options.quality}
onChange={(event) =>
setOptions((current) => ({
...current,
quality: Number(event.target.value),
}))
}
className="w-full accent-ctp-mauve"
/>
</Field>
) : (
<Field label={`PNG optimization — level ${options.pngLevel}`}>
<input
type="range"
min={0}
max={6}
value={options.pngLevel}
onChange={(event) =>
setOptions((current) => ({
...current,
pngLevel: Number(event.target.value),
}))
}
className="w-full accent-ctp-mauve"
/>
</Field>
)}
<Field label="Resolution">
<select
className={inputClass}
value={options.maxDimension ?? ""}
onChange={(event) =>
setOptions((current) => ({
...current,
maxDimension: event.target.value
? Number(event.target.value)
: null,
}))
}
>
{DIMENSION_CHOICES.map((choice) => (
<option
key={choice.value ?? "keep"}
value={choice.value ?? ""}
>
{choice.label}
</option>
))}
</select>
</Field>
</>
) : null}
{kind === "animation" ? (
<>
<Field label="Scale">
<select
className={inputClass}
value={options.scale}
onChange={(event) =>
setOptions((current) => ({
...current,
scale: Number(event.target.value),
}))
}
>
<option value={1}>100%</option>
<option value={0.75}>75%</option>
<option value={0.5}>50%</option>
<option value={0.33}>33%</option>
</select>
</Field>
<Field label="Frame rate">
<select
className={inputClass}
value={options.fps ?? ""}
onChange={(event) =>
setOptions((current) => ({
...current,
fps: event.target.value
? Number(event.target.value)
: null,
}))
}
>
<option value="">Keep original</option>
<option value={30}>30 fps</option>
<option value={20}>20 fps</option>
<option value={15}>15 fps</option>
<option value={12}>12 fps</option>
<option value={10}>10 fps</option>
</select>
</Field>
<Field label={`Colors — ${options.colors}`}>
<select
className={inputClass}
value={options.colors}
onChange={(event) =>
setOptions((current) => ({
...current,
colors: Number(event.target.value),
}))
}
>
<option value={256}>256</option>
<option value={192}>192</option>
<option value={128}>128</option>
<option value={96}>96</option>
<option value={64}>64</option>
<option value={32}>32</option>
</select>
</Field>
</>
) : null}
{kind === "video" ? (
<>
<Field label={`Quality — ${options.quality}`}>
<input
type="range"
min={10}
max={100}
value={options.quality}
onChange={(event) =>
setOptions((current) => ({
...current,
quality: Number(event.target.value),
}))
}
className="w-full accent-ctp-mauve"
/>
</Field>
{estimate ? (
<p className="-mt-2 text-[11px] text-ctp-overlay0">
≈ {(estimate.bitrate / 1_000_000).toFixed(1)} Mbps · about{" "}
{formatBytes(estimate.size)} for{" "}
{originalInfo?.duration?.toFixed(1)} s
</p>
) : null}
<Field label="Resolution">
<select
className={inputClass}
value={options.height ?? ""}
onChange={(event) =>
setOptions((current) => ({
...current,
height: event.target.value
? Number(event.target.value)
: null,
}))
}
>
{VIDEO_HEIGHTS.map((choice) => (
<option
key={choice.value ?? "keep"}
value={choice.value ?? ""}
>
{choice.label}
</option>
))}
</select>
</Field>
<Field label="Codec">
<select
className={inputClass}
value={options.codec}
onChange={(event) =>
setOptions((current) => ({
...current,
codec: event.target.value as OptimizeOptions["codec"],
}))
}
>
<option value="auto">Auto (best available)</option>
{usableCodecs.map((codec) => {
const probe = probes.find((entry) => entry.codec === codec);
return (
<option key={codec} value={codec}>
{codec.toUpperCase()}
{probe?.hardware ? " (hardware)" : ""}
</option>
);
})}
</select>
</Field>
<Field label="Container">
<select
className={inputClass}
value={options.container}
onChange={(event) =>
setOptions((current) => ({
...current,
container: event.target
.value as OptimizeOptions["container"],
}))
}
>
<option value="keep">Keep ({item.extension})</option>
<option value="mp4">MP4</option>
<option value="webm">WebM</option>
</select>
</Field>
<label className="flex items-start gap-2 text-xs text-ctp-subtext0">
<input
type="checkbox"
checked={options.preferHardware}
onChange={(event) =>
setOptions((current) => ({
...current,
preferHardware: event.target.checked,
}))
}
className="mt-0.5 accent-ctp-mauve"
/>
Prefer hardware encoding
</label>
{codecQuery.isPending ? (
<p className="text-[11px] leading-relaxed text-ctp-overlay0">
Test-encoding a few frames to see which encoders really
work here…
</p>
) : usableCodecs.length === 0 ? (
<div className="rounded-md border border-ctp-red/40 bg-ctp-red/10 px-2 py-1.5 text-[11px] leading-relaxed text-ctp-red">
<p>
{browserLabel()} cannot produce{" "}
{container.toUpperCase()} video samples
{container === "mp4" && isFirefox
? " — Firefox does not implement H.264/HEVC encoding"
: ""}
.
</p>
<button
type="button"
onClick={() =>
setOptions((current) => ({
...current,
container: container === "mp4" ? "webm" : "mp4",
}))
}
className="mt-1 font-medium text-ctp-blue underline"
>
Switch to {container === "mp4" ? "WebM" : "MP4"}
</button>
</div>
) : (
<p className="text-[11px] leading-relaxed text-ctp-overlay0">
{browserLabel()} · encoders:{" "}
{probes
.map(
(probe) =>
`${probe.codec.toUpperCase()}${
probe.supported && probe.works
? probe.hardware
? " (hardware)"
: " ✓"
: " ✗"
}`,
)
.join(" · ")}
</p>
)}
</>
) : null}
<p className="mt-auto text-[11px] leading-relaxed text-ctp-overlay0">
Two-pass encoding and arbitrary FFmpeg filters are not available
in the browser; the result is muxed by WebCodecs.
</p>
</aside>
</div>
<footer className="flex flex-wrap items-center justify-between gap-3 border-t border-ctp-surface1 px-4 py-3">
<span className="font-mono text-[11px] text-ctp-overlay0">
{result && savedPercent !== null
? `${savedPercent > 0 ? "−" : "+"}${Math.abs(savedPercent)}%`
: status === "running"
? "processing…"
: "not processed yet"}
</span>
<div className="flex flex-wrap items-center gap-2">
<Button
variant="ghost"
onClick={resetRun}
disabled={status === "running" || (!result && !error)}
>
Reset
</Button>
<Button onClick={process} disabled={status === "running" || animatedWebp}>
{status === "done" ? "Process again" : "Process"}
</Button>
<Button
variant="secondary"
onClick={() => applyMutation.mutate()}
disabled={
!result || applied || uploading || status === "running"
}
>
Apply (overwrite {item.j_id})
</Button>
</div>
</footer>
</div>
</div>
);
}