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.
This commit is contained in:
@@ -0,0 +1,123 @@
|
||||
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,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user