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
|
- [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
|
the file(s) and recomputes MD5/size/perceptual hashes (409 when the
|
||||||
result matches another item, 400 when it is identical)
|
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] **Stats dashboard** (`/stats`, staff)
|
||||||
- [x] CPU / RAM / GPU / disk usage (`psutil` + `nvidia-smi`; capacity bars
|
- [x] CPU / RAM / GPU / disk usage (`psutil` + `nvidia-smi`; capacity bars
|
||||||
follow the design system's 80% / 95% colour thresholds)
|
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 { estimateVideoBitrate, VIDEO_CODEC_CANDIDATES } from "./bitrate";
|
||||||
import { browserLabel, probeVideoEncoders, workingCodecs } from "./detect";
|
import { browserLabel, probeVideoEncoders, workingCodecs } from "./detect";
|
||||||
|
import { sniffImageKind } from "./imageformat";
|
||||||
import { deleteStoredFile } from "./opfs";
|
import { deleteStoredFile } from "./opfs";
|
||||||
import {
|
import {
|
||||||
DEFAULT_OPTIONS,
|
DEFAULT_OPTIONS,
|
||||||
@@ -167,11 +168,33 @@ export function OptimizeModal({
|
|||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
}) {
|
}) {
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
const kind: OptimizeKind = kindForExtension(item.extension) ?? "image";
|
const extensionKind: OptimizeKind =
|
||||||
|
kindForExtension(item.extension) ?? "image";
|
||||||
const [options, setOptions] = useState<OptimizeOptions>({
|
const [options, setOptions] = useState<OptimizeOptions>({
|
||||||
...DEFAULT_OPTIONS,
|
...DEFAULT_OPTIONS,
|
||||||
maxDimension: null,
|
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 } =
|
const { status, progress, stage, etaMs, error, result, run, reset } =
|
||||||
useOptimizer();
|
useOptimizer();
|
||||||
const [uploading, setUploading] = useState(false);
|
const [uploading, setUploading] = useState(false);
|
||||||
@@ -354,6 +377,21 @@ export function OptimizeModal({
|
|||||||
/>
|
/>
|
||||||
</div>
|
</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 ? (
|
{truncated ? (
|
||||||
<p className="rounded-md border border-ctp-red/40 bg-ctp-red/10 px-3 py-2 text-xs text-ctp-red">
|
<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 (
|
The processed video is shorter than the original (
|
||||||
@@ -733,7 +771,7 @@ export function OptimizeModal({
|
|||||||
>
|
>
|
||||||
Reset
|
Reset
|
||||||
</Button>
|
</Button>
|
||||||
<Button onClick={process} disabled={status === "running"}>
|
<Button onClick={process} disabled={status === "running" || animatedWebp}>
|
||||||
{status === "done" ? "Process again" : "Process"}
|
{status === "done" ? "Process again" : "Process"}
|
||||||
</Button>
|
</Button>
|
||||||
<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 webpWasmUrl from "@jsquash/webp/codec/enc/webp_enc.wasm?url";
|
||||||
import { threads } from "wasm-feature-detect";
|
import { threads } from "wasm-feature-detect";
|
||||||
|
|
||||||
|
import { optimizeAnimation } from "./animation";
|
||||||
|
import { sniffImageKind } from "./imageformat";
|
||||||
import {
|
import {
|
||||||
extensionOf,
|
extensionOf,
|
||||||
type OptimizeOptions,
|
type OptimizeOptions,
|
||||||
@@ -53,6 +55,18 @@ export async function optimizeImage(
|
|||||||
): Promise<OptimizeResult> {
|
): Promise<OptimizeResult> {
|
||||||
const extension = extensionOf(filename);
|
const extension = extensionOf(filename);
|
||||||
report(0.05, "Decoding the image…");
|
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 bitmap = await createImageBitmap(blob);
|
||||||
|
|
||||||
const sourceWidth = bitmap.width;
|
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