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