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:
@@ -113,6 +113,10 @@ Files now stage first and are resolved before entering the library.
|
||||
- [x] Apply overwrites the same J-ID: `POST /api/files/J-x/optimize/` replaces
|
||||
the file(s) and recomputes MD5/size/perceptual hashes (409 when the
|
||||
result matches another item, 400 when it is identical)
|
||||
- [x] Animated formats are detected by header (APNG `acTL` chunk, WebP
|
||||
`VP8X` flag), so APNGs keep their frames and animated WebP is refused
|
||||
with a notice instead of being flattened (browsers have no animated
|
||||
WebP encoder)
|
||||
- [x] **Stats dashboard** (`/stats`, staff)
|
||||
- [x] CPU / RAM / GPU / disk usage (`psutil` + `nvidia-smi`; capacity bars
|
||||
follow the design system's 80% / 95% colour thresholds)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
Reference in New Issue
Block a user