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 ( ); } 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(null); function report(next: MediaInfo) { setInfo(next); onMetadata?.(next); } return (
{title} {size !== null ? ( {formatBytes(size)} ) : null}
{url && state !== "empty" ? ( isVideo ? (
{info ? `${info.width}×${info.height}${ info.duration !== null ? ` · ${info.duration.toFixed(1)} s` : "" }` : "…"} {note ? ` · ${note}` : ""}
); } export function OptimizeModal({ item, onClose, }: { item: MediaItem; onClose: () => void; }) { const queryClient = useQueryClient(); const extensionKind: OptimizeKind = kindForExtension(item.extension) ?? "image"; const [options, setOptions] = useState({ ...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(null); const [applied, setApplied] = useState(false); const [originalInfo, setOriginalInfo] = useState(null); const [resultInfo, setResultInfo] = useState(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( 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 (
event.stopPropagation()} >

Optimize{" "} {item.j_id} · {item.extension} · {formatBytes(originalSize)}

Processed in your browser (WebCodecs/WASM) — the server only stores the result. Applying overwrites the file and recomputes its hashes.

{animatedWebp ? (

This is an animated WebP. Browsers have no animated WebP encoder, so re-encoding would keep only the first frame — nothing was changed.

) : null} {apngAsPng ? (

Animated PNG detected — the animation options (scale, frame rate, palette) apply.

) : null} {truncated ? (

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.

) : null} {status === "running" ? (
{stage || "Processing the file…"} {Math.round(progress * 100)}%
{etaMs !== null ? (

about {formatEta(etaMs)}

) : null}
) : null} {status === "error" && error ? (

{error}

) : null} {result && savedPercent !== null ? (

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)`}

) : null} {uploading ? (
uploading… {uploadPercent}%
) : null} {applyError ? (

{applyError}

) : null} {applied ? (

Applied — the item now stores the processed file.

) : null}
); }