From a152063d76a54fbf75b78f36cff06e1f552686cb Mon Sep 17 00:00:00 2001 From: JakeBreath Date: Thu, 17 Sep 2026 21:57:02 -0500 Subject: [PATCH] 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). --- ROADMAP.md | 4 ++ .../src/features/optimize/OptimizeModal.tsx | 42 ++++++++++++- frontend/src/features/optimize/image.ts | 14 +++++ frontend/src/features/optimize/imageformat.ts | 62 +++++++++++++++++++ 4 files changed, 120 insertions(+), 2 deletions(-) create mode 100644 frontend/src/features/optimize/imageformat.ts diff --git a/ROADMAP.md b/ROADMAP.md index a6b265c..80a6115 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -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) diff --git a/frontend/src/features/optimize/OptimizeModal.tsx b/frontend/src/features/optimize/OptimizeModal.tsx index 6eac06a..7c14aa0 100644 --- a/frontend/src/features/optimize/OptimizeModal.tsx +++ b/frontend/src/features/optimize/OptimizeModal.tsx @@ -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({ ...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({ /> + {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 ( @@ -733,7 +771,7 @@ export function OptimizeModal({ > Reset -