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:
2026-09-17 20:03:22 -05:00
parent 90e61bb333
commit bab9904fc8
14 changed files with 1721 additions and 8 deletions
+119
View File
@@ -0,0 +1,119 @@
import {
ALL_FORMATS,
BlobSource,
BufferTarget,
Conversion,
Input,
Mp4OutputFormat,
Output,
Quality,
WebMOutputFormat,
getFirstEncodableVideoCodec,
type VideoCodec,
} from "mediabunny";
import {
extensionOf,
type OptimizeOptions,
type OptimizeResult,
type ProgressReporter,
} from "./types";
const CODEC_CANDIDATES: Record<"mp4" | "webm", VideoCodec[]> = {
mp4: ["avc", "hevc"],
webm: ["vp9", "av1"],
};
function even(value: number): number {
return Math.max(2, value - (value % 2));
}
/** Transcode with WebCodecs (hardware-accelerated where available). */
export async function optimizeVideo(
blob: Blob,
filename: string,
options: OptimizeOptions,
report: ProgressReporter,
): Promise<OptimizeResult> {
const extension = extensionOf(filename);
report(0.03, "Reading the video…");
const input = new Input({
source: new BlobSource(blob),
formats: ALL_FORMATS,
});
const track = await input.getPrimaryVideoTrack();
if (!track) {
throw new Error("No video track found in this file.");
}
const sourceWidth = await track.getDisplayWidth();
const sourceHeight = await track.getDisplayHeight();
const container: "mp4" | "webm" =
options.container === "keep"
? extension === "webm"
? "webm"
: "mp4"
: options.container;
const format =
container === "webm" ? new WebMOutputFormat() : new Mp4OutputFormat();
const target = new BufferTarget();
const output = new Output({ format, target });
const quality = new Quality(options.qualityLevel);
let codec: VideoCodec | undefined =
options.codec === "auto" ? undefined : options.codec;
if (!codec) {
const candidate = await getFirstEncodableVideoCodec(
CODEC_CANDIDATES[container],
{ width: sourceWidth, height: sourceHeight, quality },
);
if (!candidate) {
throw new Error(
`This browser cannot encode ${container.toUpperCase()} video.`,
);
}
codec = candidate;
}
const outputHeight = options.height ?? sourceHeight;
const outputWidth = Math.round(sourceWidth * (outputHeight / sourceHeight));
report(0.06, "Preparing the encoder…");
const conversion = await Conversion.init({
input,
output,
video: {
codec,
quality,
height: even(outputHeight),
fit: "contain",
hardwareAcceleration: options.preferHardware
? "prefer-hardware"
: "no-preference",
forceTranscode: true,
},
});
if (!conversion.isValid) {
throw new Error(
"This conversion is not supported here — try another codec or container.",
);
}
conversion.onProgress = (progress: number) => {
report(0.08 + Math.min(Math.max(progress, 0), 1) * 0.85, "Encoding…");
};
await conversion.execute();
const buffer = target.buffer;
if (!buffer) {
throw new Error("Encoding produced no output.");
}
report(0.97, "Packaging…");
return {
blob: new Blob([buffer], {
type: container === "webm" ? "video/webm" : "video/mp4",
}),
width: even(outputWidth),
height: even(outputHeight),
note: `${codec} · ${container}`,
};
}