Client-side optimization pipeline with apply-to-J-ID

Backend:
- POST /api/files/J-x/optimize/ applies a browser-processed file: replaces
  every copy (renaming when the extension changes), recomputes MD5, size
  and perceptual hashes, seeds guest visibility; 400 when identical,
  409 when the result matches another item, owner/staff only.

Frontend (no server-side processing by design):
- optimize.worker.ts + pipelines: Mediabunny/WebCodecs for video with a
  prefer-hardware hint and per-browser codec detection; MozJPEG/OxiPNG/
  libwebp (jSquash) for images; gifuct-js+gifenc and UPNG for GIF/APNG.
- OptimizeModal: per-file-type options, original vs processed previews
  with sizes/savings, progress bar with ETA, then Apply (overwrite).
- Optimize button on the library detail for the uploader/staff.
This commit is contained in:
2026-09-17 20:03:22 -05:00
parent 90e61bb333
commit bab9904fc8
14 changed files with 1721 additions and 8 deletions
@@ -0,0 +1,622 @@
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 { detectVideoCodecs } from "./detect";
import {
DEFAULT_OPTIONS,
kindForExtension,
type OptimizeKind,
type OptimizeOptions,
type VideoQualityLevel,
} from "./types";
import { useOptimizer } from "./useOptimizer";
const QUALITY_LEVELS: { value: VideoQualityLevel; label: string }[] = [
{ value: "very-high", label: "Visually lossless" },
{ value: "high", label: "High (recommended)" },
{ value: "medium", label: "Balanced" },
{ value: "low", label: "Small" },
{ value: "very-low", label: "Smallest" },
];
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`;
}
function PreviewPanel({
title,
url,
isVideo,
size,
dimensions,
note,
state,
}: {
title: string;
url: string | null;
isVideo: boolean;
size: number | null;
dimensions: { width: number | null; height: number | null } | null;
note?: string | null;
state: "original" | "result" | "empty";
}) {
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
src={url}
controls
loop
muted
preload="metadata"
className="max-h-56 w-full object-contain"
/>
) : (
<img
src={url}
alt={title}
className="max-h-56 w-full object-contain"
/>
)
) : (
<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">
{dimensions?.width && dimensions?.height
? `${dimensions.width}×${dimensions.height}`
: "…"}
{note ? ` · ${note}` : ""}
</div>
</section>
);
}
export function OptimizeModal({
item,
onClose,
}: {
item: MediaItem;
onClose: () => void;
}) {
const queryClient = useQueryClient();
const kind: OptimizeKind = kindForExtension(item.extension) ?? "image";
const [options, setOptions] = useState<OptimizeOptions>({
...DEFAULT_OPTIONS,
maxDimension: null,
});
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 codecQuery = useQuery({
queryKey: ["optimize-codecs"],
queryFn: detectVideoCodecs,
enabled: kind === "video",
staleTime: Infinity,
retry: 0,
});
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: () => {
setApplied(true);
setApplyError(null);
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) => setApplyError(errorMessage(err)),
});
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 hardwareAvailable =
codecQuery.data?.some((entry) => entry.supported && entry.hardware) ??
false;
function process() {
setApplied(false);
setApplyError(null);
run({
url: item.raw_url,
filename: item.filename,
kind,
options,
});
}
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-4xl 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)_280px]">
<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}
dimensions={{ width: null, height: null }}
state="original"
/>
<PreviewPanel
title="Processed"
url={result?.url ?? null}
isVideo={isVideo}
size={resultSize}
dimensions={
result
? { width: result.width, height: result.height }
: null
}
note={result?.note ?? null}
state={result ? "result" : "empty"}
/>
</div>
{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">
<select
className={inputClass}
value={options.qualityLevel}
onChange={(event) =>
setOptions((current) => ({
...current,
qualityLevel: event.target.value as VideoQualityLevel,
}))
}
>
{QUALITY_LEVELS.map((level) => (
<option key={level.value} value={level.value}>
{level.label}
</option>
))}
</select>
</Field>
<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>
{(codecQuery.data ?? [])
.filter((entry) => entry.supported)
.map((entry) => (
<option key={entry.codec} value={entry.codec}>
{entry.codec.toUpperCase()}
{entry.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>
<p className="text-[11px] leading-relaxed text-ctp-overlay0">
{codecQuery.isPending
? "Checking the encoders this browser offers…"
: hardwareAvailable
? "Hardware-accelerated encoders are available on this machine."
: "No hardware encoder detected — encoding will use the CPU."}
</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={reset}
disabled={status === "running" || (!result && !error)}
>
Reset
</Button>
<Button onClick={process} disabled={status === "running"}>
{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>
);
}