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,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}`,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user