Files
J621/frontend/src/features/optimize/image.ts
T
JakeBreath bab9904fc8 Client-side optimization pipeline with apply-to-J-ID
Backend:
- POST /api/files/J-x/optimize/ applies a browser-processed file: replaces
  every copy (renaming when the extension changes), recomputes MD5, size
  and perceptual hashes, seeds guest visibility; 400 when identical,
  409 when the result matches another item, owner/staff only.

Frontend (no server-side processing by design):
- optimize.worker.ts + pipelines: Mediabunny/WebCodecs for video with a
  prefer-hardware hint and per-browser codec detection; MozJPEG/OxiPNG/
  libwebp (jSquash) for images; gifuct-js+gifenc and UPNG for GIF/APNG.
- OptimizeModal: per-file-type options, original vs processed previews
  with sizes/savings, progress bar with ETA, then Apply (overwrite).
- Optimize button on the library detail for the uploader/staff.
2026-09-17 20:03:22 -05:00

124 lines
4.1 KiB
TypeScript

import encodeJpeg, { init as initJpeg } from "@jsquash/jpeg/encode.js";
import encodeWebp, { init as initWebp } from "@jsquash/webp/encode.js";
import {
default as oxipng,
init as initOxipng,
} from "@jsquash/oxipng/optimise.js";
import oxipngMtWasmUrl from "@jsquash/oxipng/codec/pkg-parallel/squoosh_oxipng_bg.wasm?url";
import oxipngStWasmUrl from "@jsquash/oxipng/codec/pkg/squoosh_oxipng_bg.wasm?url";
import jpegWasmUrl from "@jsquash/jpeg/codec/enc/mozjpeg_enc.wasm?url";
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 {
extensionOf,
type OptimizeOptions,
type OptimizeResult,
type ProgressReporter,
} from "./types";
/**
* wasm-bindgen resolves its own wasm relative to the module, which bundlers
* rewrite; hand it the asset URL that matches the threaded/single-threaded
* build it is about to pick. This pipeline only ever runs inside the
* optimization worker, so the threading branch mirrors jSquash's own check
* (worker context + multiple cores + shared-memory support).
*/
let oxipngReady: Promise<unknown> | null = null;
async function ensureOxipng(): Promise<void> {
if (!oxipngReady) {
const cores = globalThis.navigator?.hardwareConcurrency ?? 1;
const useThreads = cores > 1 && (await threads());
oxipngReady = initOxipng({
module_or_path: useThreads ? oxipngMtWasmUrl : oxipngStWasmUrl,
});
}
await oxipngReady;
}
/** Copy into a fresh ArrayBuffer so shared wasm memory never reaches Blob. */
function toArrayBuffer(value: ArrayBuffer | Uint8Array): ArrayBuffer {
const view = value instanceof Uint8Array ? value : new Uint8Array(value);
const copy = new Uint8Array(view.byteLength);
copy.set(view);
return copy.buffer;
}
/** Decode with the browser, scale on a canvas, re-encode with jSquash codecs. */
export async function optimizeImage(
blob: Blob,
filename: string,
options: OptimizeOptions,
report: ProgressReporter,
): Promise<OptimizeResult> {
const extension = extensionOf(filename);
report(0.05, "Decoding the image…");
const bitmap = await createImageBitmap(blob);
const sourceWidth = bitmap.width;
const sourceHeight = bitmap.height;
let width = sourceWidth;
let height = sourceHeight;
if (options.maxDimension && Math.max(width, height) > options.maxDimension) {
const factor = options.maxDimension / Math.max(width, height);
width = Math.max(1, Math.round(width * factor));
height = Math.max(1, Math.round(height * factor));
}
const resized = width !== sourceWidth || height !== sourceHeight;
let imageData: ImageData | null = null;
if (resized || extension !== "png") {
const canvas = new OffscreenCanvas(width, height);
const context = canvas.getContext("2d");
if (!context) {
throw new Error("This browser does not support OffscreenCanvas.");
}
context.imageSmoothingEnabled = true;
context.imageSmoothingQuality = "high";
context.drawImage(bitmap, 0, 0, width, height);
imageData = context.getImageData(0, 0, width, height);
}
bitmap.close();
report(0.4, "Encoding…");
let buffer: ArrayBuffer;
let type: string;
if (extension === "png") {
report(0.35, "Optimizing the PNG…");
await ensureOxipng();
buffer =
imageData !== null
? toArrayBuffer(await oxipng(imageData, { level: options.pngLevel }))
: toArrayBuffer(
await oxipng(await blob.arrayBuffer(), {
level: options.pngLevel,
}),
);
type = "image/png";
} else if (extension === "webp") {
await initWebp({
locateFile: (path: string) =>
path.includes("simd") ? webpSimdWasmUrl : webpWasmUrl,
});
buffer = await encodeWebp(imageData as ImageData, {
quality: options.quality,
});
type = "image/webp";
} else {
await initJpeg({ locateFile: () => jpegWasmUrl });
buffer = await encodeJpeg(imageData as ImageData, {
quality: options.quality,
});
type = "image/jpeg";
}
report(0.95, "Packaging…");
return {
blob: new Blob([buffer], { type }),
width,
height,
};
}