Keep animated images animated in the optimizer
- Animated WebP (J-82) was being flattened to its first frame: browsers have no animated WebP encoder, so the modal now sniffs the file header (4 KB range request), explains the limitation and disables Process instead of overwriting the file with a single frame. - APNGs saved as .png took the still-image path and lost their frames; the header sniff looks for the acTL chunk, routes them to the animation pipeline (all frames + delays) and switches the UI to the animation options. The worker double-checks the header too, so no path can flatten an APNG. - New dependency-free imageformat module, verified against real files (J-82 animated, static WebP/PNG, and a generated APNG named .png).
This commit is contained in:
@@ -10,6 +10,7 @@ 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,
|
||||
@@ -167,11 +168,33 @@ export function OptimizeModal({
|
||||
onClose: () => void;
|
||||
}) {
|
||||
const queryClient = useQueryClient();
|
||||
const kind: OptimizeKind = kindForExtension(item.extension) ?? "image";
|
||||
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);
|
||||
@@ -354,6 +377,21 @@ export function OptimizeModal({
|
||||
/>
|
||||
</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 (
|
||||
@@ -733,7 +771,7 @@ export function OptimizeModal({
|
||||
>
|
||||
Reset
|
||||
</Button>
|
||||
<Button onClick={process} disabled={status === "running"}>
|
||||
<Button onClick={process} disabled={status === "running" || animatedWebp}>
|
||||
{status === "done" ? "Process again" : "Process"}
|
||||
</Button>
|
||||
<Button
|
||||
|
||||
Reference in New Issue
Block a user