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.
798 lines
28 KiB
TypeScript
798 lines
28 KiB
TypeScript
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>
|
||
);
|
||
}
|