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:
2026-09-17 21:57:02 -05:00
parent 38e64140ed
commit a152063d76
4 changed files with 120 additions and 2 deletions
@@ -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
+14
View File
@@ -11,6 +11,8 @@ import webpSimdWasmUrl from "@jsquash/webp/codec/enc/webp_enc_simd.wasm?url";
import webpWasmUrl from "@jsquash/webp/codec/enc/webp_enc.wasm?url";
import { threads } from "wasm-feature-detect";
import { optimizeAnimation } from "./animation";
import { sniffImageKind } from "./imageformat";
import {
extensionOf,
type OptimizeOptions,
@@ -53,6 +55,18 @@ export async function optimizeImage(
): Promise<OptimizeResult> {
const extension = extensionOf(filename);
report(0.05, "Decoding the image…");
const sniffed = await sniffImageKind(blob);
if (sniffed === "apng") {
// An APNG usually has a .png extension; re-encoding it as a still image
// would drop every frame after the first.
return optimizeAnimation(blob, filename, options, report);
}
if (sniffed === "animated-webp") {
throw new Error(
"Animated WebP cannot be re-encoded in the browser (no animated WebP " +
"encoder is available) — this file was left untouched.",
);
}
const bitmap = await createImageBitmap(blob);
const sourceWidth = bitmap.width;
@@ -0,0 +1,62 @@
/**
* Header sniffing for image files, so animated formats are not silently
* flattened to a single frame (an APNG is usually just a `.png`, and the
* browser decode path only sees the first frame either way).
*
* Dependency-free so it can run in workers and in Node-based checks.
*/
export type ImageFormatKind = "apng" | "animated-webp" | "static";
const PNG_SIGNATURE = [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a];
const HEAD_BYTES = 4096;
function ascii(bytes: Uint8Array, start: number, end: number): string {
return String.fromCharCode(...bytes.slice(start, end));
}
/** APNG files carry an `acTL` chunk before the first `IDAT`. */
function isApng(head: Uint8Array): boolean {
let offset = 8;
while (offset + 8 <= head.length) {
const length =
((head[offset] << 24) |
(head[offset + 1] << 16) |
(head[offset + 2] << 8) |
head[offset + 3]) >>>
0;
const type = ascii(head, offset + 4, offset + 8);
if (type === "acTL") return true;
if (type === "IDAT") return false;
offset += 12 + length;
if (offset <= 0) return false;
}
return false;
}
/** WebP is animated when the VP8X chunk has the animation flag (bit 1). */
function isAnimatedWebp(head: Uint8Array): boolean {
if (head.length < 21) return false;
if (ascii(head, 0, 4) !== "RIFF" || ascii(head, 8, 12) !== "WEBP") {
return false;
}
if (ascii(head, 12, 16) !== "VP8X") return false;
return (head[20] & 0x02) !== 0;
}
export function sniffImageBytes(bytes: Uint8Array): ImageFormatKind {
if (
bytes.length >= 8 &&
PNG_SIGNATURE.every((byte, index) => bytes[index] === byte)
) {
return isApng(bytes) ? "apng" : "static";
}
return isAnimatedWebp(bytes) ? "animated-webp" : "static";
}
export async function sniffImageKind(file: Blob): Promise<ImageFormatKind> {
const head = new Uint8Array(
await file.slice(0, HEAD_BYTES).arrayBuffer(),
);
return sniffImageBytes(head);
}