fa905519f6
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>
257 lines
7.9 KiB
TypeScript
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);
|
|
}
|