Files
geo/apps/admin-web/src/lib/image-webp.ts
T
root fa905519f6 fix(admin-web/image-webp): bundle jsquash wasm via vite resolver
Vite 在生产构建里没法解析 @jsquash/webp 自带的相对 wasm 路径,导致 WebP 编码
首次调用就失败。改成直接 import encode.js + ?url 的 wasm 资源,并在 vite.config
里把 specifier 显式 alias 到 node_modules 的真实文件,运行前再调用 init({locateFile})
告诉 wasm 加载器去拿打包后的 URL。

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-28 18:35:46 +08:00

257 lines
7.9 KiB
TypeScript

import encode, { init as initWebpEncoder } from "@jsquash/webp/encode.js";
import webpEncoderWasmUrl from "@jsquash/webp/codec/enc/webp_enc.wasm?url";
import webpEncoderSimdWasmUrl from "@jsquash/webp/codec/enc/webp_enc_simd.wasm?url";
import { reactive, readonly } from "vue";
// upload limit set to 10MB
export const MAX_IMAGE_UPLOAD_SIZE_BYTES = 10 * 1024 * 1024;
const WEBP_MIME_TYPE = "image/webp";
const SUPPORTED_IMAGE_UPLOAD_FORMATS = [
{ mimeTypes: ["image/png"], extensions: ["png"] },
{ mimeTypes: ["image/jpeg"], extensions: ["jpg", "jpeg"] },
{ mimeTypes: ["image/gif"], extensions: ["gif"] },
{ mimeTypes: ["image/webp"], extensions: ["webp"] },
{ mimeTypes: ["image/bmp", "image/x-ms-bmp"], extensions: ["bmp"] },
{ mimeTypes: ["image/svg+xml"], extensions: ["svg"] },
{ mimeTypes: ["image/avif"], extensions: ["avif"] },
] as const;
export const IMAGE_UPLOAD_ACCEPT = SUPPORTED_IMAGE_UPLOAD_FORMATS
.flatMap((format) => format.mimeTypes)
.join(",");
export type ImageUploadProgressStage =
| "idle"
| "validating"
| "decoding"
| "encoding"
| "uploading"
| "finishing";
type ImageUploadProgressState = {
visible: boolean;
fileName: string;
percent: number;
stage: ImageUploadProgressStage;
};
type ImageUploadProgressUpdate = {
percent: number;
stage: Exclude<ImageUploadProgressStage, "idle">;
};
const supportedImageUploadMimeTypeSet: ReadonlySet<string> = new Set(
SUPPORTED_IMAGE_UPLOAD_FORMATS.flatMap((format) => format.mimeTypes),
);
const supportedImageUploadExtensionSet: ReadonlySet<string> = new Set(
SUPPORTED_IMAGE_UPLOAD_FORMATS.flatMap((format) => format.extensions),
);
const imageUploadProgressState = reactive<ImageUploadProgressState>({
visible: false,
fileName: "",
percent: 0,
stage: "idle",
});
let activeProgressToken = 0;
let webpEncoderReady: Promise<unknown> | null = null;
function ensureWebpEncoderReady(): Promise<unknown> {
if (webpEncoderReady) {
return webpEncoderReady;
}
const ready = initWebpEncoder({
locateFile(path: string) {
if (path === "webp_enc_simd.wasm") {
return webpEncoderSimdWasmUrl;
}
if (path === "webp_enc.wasm") {
return webpEncoderWasmUrl;
}
return path;
},
}).catch((error: unknown) => {
webpEncoderReady = null;
throw error;
});
webpEncoderReady = ready;
return ready;
}
function ensureWebpFileName(fileName: string, fallbackBaseName = "image"): string {
const trimmed = fileName.trim();
const normalized = trimmed.replace(/\.[^.]+$/, "").trim();
const baseName = normalized || fallbackBaseName;
return `${baseName}.webp`;
}
function resolveFileExtension(fileName: string): string {
return fileName.includes(".") ? fileName.split(".").pop()?.trim().toLowerCase() ?? "" : "";
}
function resetImageUploadProgressState(): void {
imageUploadProgressState.visible = false;
imageUploadProgressState.fileName = "";
imageUploadProgressState.percent = 0;
imageUploadProgressState.stage = "idle";
}
function updateImageUploadProgress(token: number, update: ImageUploadProgressUpdate): void {
if (token !== activeProgressToken) {
return;
}
imageUploadProgressState.visible = true;
imageUploadProgressState.percent = Math.max(0, Math.min(100, Math.round(update.percent)));
imageUploadProgressState.stage = update.stage;
}
export function useImageUploadProgress() {
return readonly(imageUploadProgressState);
}
export function validateImageUploadFile(file: File): void {
if (!(file instanceof File) || !file.name.trim()) {
throw new Error("invalid_image");
}
if (file.size <= 0) {
throw new Error("invalid_image");
}
if (file.size > MAX_IMAGE_UPLOAD_SIZE_BYTES) {
throw new Error("image_upload_too_large");
}
const normalizedMimeType = file.type.trim().toLowerCase();
const extension = resolveFileExtension(file.name);
const mimeSupported = normalizedMimeType ? supportedImageUploadMimeTypeSet.has(normalizedMimeType) : false;
const extensionSupported = extension ? supportedImageUploadExtensionSet.has(extension) : false;
if (!mimeSupported && !extensionSupported) {
throw new Error("image_upload_type_not_supported");
}
}
function startImageUploadProgress(fileName: string): number {
const token = ++activeProgressToken;
imageUploadProgressState.visible = true;
imageUploadProgressState.fileName = fileName;
imageUploadProgressState.percent = 0;
imageUploadProgressState.stage = "validating";
return token;
}
function finishImageUploadProgress(token: number): void {
updateImageUploadProgress(token, { percent: 100, stage: "finishing" });
window.setTimeout(() => {
if (token === activeProgressToken) {
resetImageUploadProgressState();
}
}, 280);
}
function cancelImageUploadProgress(token: number): void {
window.setTimeout(() => {
if (token === activeProgressToken) {
resetImageUploadProgressState();
}
}, 0);
}
async function createImageDataFromBlob(
blob: Blob,
token: number,
): Promise<ImageData> {
updateImageUploadProgress(token, { percent: 28, stage: "decoding" });
if (typeof createImageBitmap === "function") {
const bitmap = await createImageBitmap(blob);
try {
const canvas = document.createElement("canvas");
canvas.width = bitmap.width;
canvas.height = bitmap.height;
const context = canvas.getContext("2d", { willReadFrequently: true });
if (!context) {
throw new Error("image_canvas_context_unavailable");
}
context.drawImage(bitmap, 0, 0);
return context.getImageData(0, 0, bitmap.width, bitmap.height);
} finally {
bitmap.close();
}
}
const objectUrl = URL.createObjectURL(blob);
try {
const image = await new Promise<HTMLImageElement>((resolve, reject) => {
const nextImage = new Image();
nextImage.onload = () => resolve(nextImage);
nextImage.onerror = () => reject(new Error("image_load_failed"));
nextImage.src = objectUrl;
});
const canvas = document.createElement("canvas");
canvas.width = image.naturalWidth || image.width;
canvas.height = image.naturalHeight || image.height;
const context = canvas.getContext("2d", { willReadFrequently: true });
if (!context) {
throw new Error("image_canvas_context_unavailable");
}
context.drawImage(image, 0, 0);
return context.getImageData(0, 0, canvas.width, canvas.height);
} finally {
URL.revokeObjectURL(objectUrl);
}
}
export async function convertImageFileToWebp(file: File): Promise<File> {
validateImageUploadFile(file);
const token = startImageUploadProgress(file.name);
try {
updateImageUploadProgress(token, { percent: 12, stage: "validating" });
const normalizedName = ensureWebpFileName(file.name);
if (file.type.trim().toLowerCase() === WEBP_MIME_TYPE) {
updateImageUploadProgress(token, { percent: 86, stage: "finishing" });
return new File([file], normalizedName, {
type: WEBP_MIME_TYPE,
lastModified: file.lastModified,
});
}
const imageData = await createImageDataFromBlob(file, token);
updateImageUploadProgress(token, { percent: 72, stage: "encoding" });
await ensureWebpEncoderReady();
const encoded = await encode(imageData, { quality: 82 });
updateImageUploadProgress(token, { percent: 88, stage: "finishing" });
return new File([encoded], normalizedName, {
type: WEBP_MIME_TYPE,
lastModified: file.lastModified,
});
} catch (error) {
cancelImageUploadProgress(token);
throw error;
}
}
export function markImageUploadRequestStarted(fileName: string): number {
const token = activeProgressToken || startImageUploadProgress(fileName);
imageUploadProgressState.fileName = fileName;
updateImageUploadProgress(token, { percent: 94, stage: "uploading" });
return token;
}
export function markImageUploadRequestFinished(token: number): void {
finishImageUploadProgress(token);
}
export function markImageUploadRequestFailed(token: number): void {
cancelImageUploadProgress(token);
}