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 -