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.
124 lines
4.1 KiB
TypeScript
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,
|
|
};
|
|
}
|