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; }; const supportedImageUploadMimeTypeSet: ReadonlySet = new Set( SUPPORTED_IMAGE_UPLOAD_FORMATS.flatMap((format) => format.mimeTypes), ); const supportedImageUploadExtensionSet: ReadonlySet = new Set( SUPPORTED_IMAGE_UPLOAD_FORMATS.flatMap((format) => format.extensions), ); const imageUploadProgressState = reactive({ visible: false, fileName: "", percent: 0, stage: "idle", }); let activeProgressToken = 0; let webpEncoderReady: Promise | null = null; function ensureWebpEncoderReady(): Promise { 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 { 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((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 { 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); }