| ... | ... | @@ -143,33 +143,27 @@ async function validateHeicImageDimensions( |
| 143 | 143 | return foundImageDimensions ? null : "unreadable"; |
| 144 | 144 | } |
| 145 | 145 | |
| 146 | | /** Chunked so a large image can't blow the argument limit of `fromCharCode`. */ |
| 147 | | const BASE64_CHUNK_SIZE = 0x8000; |
| 148 | | |
| 149 | | function bytesToBase64(bytes: Uint8Array): string { |
| 150 | | let binary = ""; |
| 151 | | for (let offset = 0; offset < bytes.length; offset += BASE64_CHUNK_SIZE) { |
| 152 | | binary += String.fromCharCode(...bytes.subarray(offset, offset + BASE64_CHUNK_SIZE)); |
| 153 | | } |
| 154 | | return btoa(binary); |
| 155 | | } |
| 156 | | |
| 157 | | /** |
| 158 | | * Blob → base64 data URL. Uses `arrayBuffer()` rather than `FileReader` so |
| 159 | | * the module works anywhere `Blob` does (including non-DOM test runners). |
| 160 | | */ |
| 161 | | async function blobToDataUrl(blob: File | Blob, mimeTypeOverride?: string): Promise<string> { |
| 162 | | const buffer = await blob.arrayBuffer(); |
| 163 | | const mimeType = mimeTypeOverride || blob.type || "application/octet-stream"; |
| 164 | | return `data:${mimeType};base64,${bytesToBase64(new Uint8Array(buffer))}`; |
| 146 | export function readFileAsDataUrl(file: Blob): Promise<string> { |
| 147 | return new Promise((resolve, reject) => { |
| 148 | const reader = new FileReader(); |
| 149 | reader.addEventListener("load", () => { |
| 150 | if (typeof reader.result === "string") { |
| 151 | resolve(reader.result); |
| 152 | return; |
| 153 | } |
| 154 | reject(new Error("Could not read image data.")); |
| 155 | }); |
| 156 | reader.addEventListener("error", () => { |
| 157 | reject(reader.error ?? new Error("Failed to read image.")); |
| 158 | }); |
| 159 | reader.readAsDataURL(file); |
| 160 | }); |
| 165 | 161 | } |
| 166 | 162 | |
| 167 | | /** Approximate decoded byte count for a base64 data URL. */ |
| 168 | | function dataUrlByteLength(dataUrl: string): number { |
| 169 | | const commaIndex = dataUrl.indexOf(","); |
| 170 | | const payload = commaIndex === -1 ? dataUrl : dataUrl.slice(commaIndex + 1); |
| 171 | | const padding = payload.endsWith("==") ? 2 : payload.endsWith("=") ? 1 : 0; |
| 172 | | return Math.max(0, Math.floor((payload.length * 3) / 4) - padding); |
| 163 | function dataUrlLength(blob: Blob): number { |
| 164 | return ( |
| 165 | `data:${blob.type || "application/octet-stream"};base64,`.length + 4 * Math.ceil(blob.size / 3) |
| 166 | ); |
| 173 | 167 | } |
| 174 | 168 | |
| 175 | 169 | /** Base64 payload of a data URL decoded back into a `File`. */ |
| ... | ... | @@ -232,19 +226,14 @@ async function encodeCanvas( |
| 232 | 226 | canvas: OffscreenCanvas | HTMLCanvasElement, |
| 233 | 227 | quality: number, |
| 234 | 228 | mimeType: string, |
| 235 | | budgetChars: number, |
| 236 | | ): Promise<{ dataUrl: string | null; mimeType: string } | null> { |
| 229 | ): Promise<Blob | null> { |
| 230 | let blob: Blob | null; |
| 237 | 231 | if (typeof HTMLCanvasElement !== "undefined" && canvas instanceof HTMLCanvasElement) { |
| 238 | | const dataUrl = canvas.toDataURL(mimeType, quality); |
| 239 | | // toDataURL silently returns a PNG when the requested type is unsupported. |
| 240 | | if (!dataUrl.startsWith(`data:${mimeType}`)) return null; |
| 241 | | return { dataUrl: dataUrl.length <= budgetChars ? dataUrl : null, mimeType }; |
| 232 | blob = await new Promise<Blob | null>((resolve) => canvas.toBlob(resolve, mimeType, quality)); |
| 233 | } else { |
| 234 | blob = await (canvas as OffscreenCanvas).convertToBlob({ type: mimeType, quality }); |
| 242 | 235 | } |
| 243 | | const blob = await (canvas as OffscreenCanvas).convertToBlob({ type: mimeType, quality }); |
| 244 | | if (blob.type && blob.type !== mimeType) return null; |
| 245 | | const dataUrlLength = `data:${mimeType};base64,`.length + 4 * Math.ceil(blob.size / 3); |
| 246 | | if (dataUrlLength > budgetChars) return { dataUrl: null, mimeType }; |
| 247 | | return { dataUrl: await blobToDataUrl(blob, mimeType), mimeType }; |
| 236 | return blob?.type === mimeType ? blob : null; |
| 248 | 237 | } |
| 249 | 238 | |
| 250 | 239 | const composerThumbnails = new WeakMap<File, Promise<string | null>>(); |
| ... | ... | @@ -274,10 +263,8 @@ export function createComposerImageThumbnail(file: File): Promise<string | null> |
| 274 | 263 | dimension, |
| 275 | 264 | dimension, |
| 276 | 265 | ); |
| 277 | | return ( |
| 278 | | (await encodeCanvas(surface.canvas, 1, "image/png", Number.POSITIVE_INFINITY))?.dataUrl ?? |
| 279 | | null |
| 280 | | ); |
| 266 | const blob = await encodeCanvas(surface.canvas, 1, "image/png"); |
| 267 | return blob ? await readFileAsDataUrl(blob) : null; |
| 281 | 268 | } catch { |
| 282 | 269 | return null; |
| 283 | 270 | } finally { |
| ... | ... | @@ -297,7 +284,7 @@ async function encodeWithinBudget( |
| 297 | 284 | maxDimension: number, |
| 298 | 285 | budgetChars: number, |
| 299 | 286 | preferredMimeType?: "image/jpeg", |
| 300 | | ): Promise<{ dataUrl: string; mimeType: string; imageSize: ImageSize } | null> { |
| 287 | ): Promise<{ blob: Blob; mimeType: string; imageSize: ImageSize } | null> { |
| 301 | 288 | const scale = Math.min(1, maxDimension / Math.max(bitmap.width, bitmap.height)); |
| 302 | 289 | const width = Math.max(1, Math.round(bitmap.width * scale)); |
| 303 | 290 | const height = Math.max(1, Math.round(bitmap.height * scale)); |
| ... | ... | @@ -308,7 +295,7 @@ async function encodeWithinBudget( |
| 308 | 295 | // happen before drawing and depends on which codec we end up using. |
| 309 | 296 | const mimeType = |
| 310 | 297 | preferredMimeType ?? |
| 311 | | ((await encodeCanvas(target.canvas, QUALITY_STEPS[0], "image/webp", 0)) |
| 298 | ((await encodeCanvas(target.canvas, QUALITY_STEPS[0], "image/webp")) |
| 312 | 299 | ? "image/webp" |
| 313 | 300 | : "image/jpeg"); |
| 314 | 301 | |
| ... | ... | @@ -319,17 +306,17 @@ async function encodeWithinBudget( |
| 319 | 306 | target.context.drawImage(bitmap, 0, 0, width, height); |
| 320 | 307 | |
| 321 | 308 | for (const quality of QUALITY_STEPS) { |
| 322 | | const encoded = await encodeCanvas(target.canvas, quality, mimeType, budgetChars); |
| 309 | const encoded = await encodeCanvas(target.canvas, quality, mimeType); |
| 323 | 310 | if (!encoded) break; |
| 324 | | if (encoded.dataUrl !== null) { |
| 325 | | return { dataUrl: encoded.dataUrl, mimeType: encoded.mimeType, imageSize: { width, height } }; |
| 311 | if (dataUrlLength(encoded) <= budgetChars) { |
| 312 | return { blob: encoded, mimeType, imageSize: { width, height } }; |
| 326 | 313 | } |
| 327 | 314 | } |
| 328 | 315 | return null; |
| 329 | 316 | } |
| 330 | 317 | |
| 331 | 318 | type ReencodeResult = |
| 332 | | | { ok: true; dataUrl: string; mimeType: string; imageSize: ImageSize } |
| 319 | | { ok: true; blob: Blob; mimeType: string; imageSize: ImageSize } |
| 333 | 320 | | { ok: false; reason: ImageCompressionFailureReason }; |
| 334 | 321 | |
| 335 | 322 | /** |
| ... | ... | @@ -378,10 +365,10 @@ async function reencodeWithinBudget( |
| 378 | 365 | continue; |
| 379 | 366 | } |
| 380 | 367 | encodeFailed = false; |
| 381 | | if (encoded && encoded.dataUrl.length <= budgetChars) { |
| 368 | if (encoded) { |
| 382 | 369 | return { |
| 383 | 370 | ok: true, |
| 384 | | dataUrl: encoded.dataUrl, |
| 371 | blob: encoded.blob, |
| 385 | 372 | mimeType: encoded.mimeType, |
| 386 | 373 | imageSize: encoded.imageSize, |
| 387 | 374 | }; |
| ... | ... | @@ -405,37 +392,35 @@ export async function compressImageForStash( |
| 405 | 392 | file: File, |
| 406 | 393 | budgetChars: number = MAX_STASH_IMAGE_DATA_URL_CHARS, |
| 407 | 394 | ): Promise<CompressStashImageResult> { |
| 408 | | let originalDataUrl: string; |
| 409 | 395 | try { |
| 410 | | originalDataUrl = await blobToDataUrl(file); |
| 411 | | } catch { |
| 412 | | return { ok: false, reason: "unreadable" }; |
| 413 | | } |
| 414 | | if (originalDataUrl.length <= budgetChars) { |
| 396 | if (dataUrlLength(file) <= budgetChars) { |
| 397 | return { |
| 398 | ok: true, |
| 399 | image: { |
| 400 | dataUrl: await readFileAsDataUrl(file), |
| 401 | mimeType: file.type, |
| 402 | sizeBytes: file.size, |
| 403 | recompressed: false, |
| 404 | }, |
| 405 | }; |
| 406 | } |
| 407 | const reencoded = await reencodeWithinBudget(file, budgetChars); |
| 408 | if (!reencoded.ok) { |
| 409 | return reencoded; |
| 410 | } |
| 415 | 411 | return { |
| 416 | 412 | ok: true, |
| 417 | 413 | image: { |
| 418 | | dataUrl: originalDataUrl, |
| 419 | | mimeType: file.type, |
| 420 | | sizeBytes: file.size, |
| 421 | | recompressed: false, |
| 414 | dataUrl: await readFileAsDataUrl(reencoded.blob), |
| 415 | mimeType: reencoded.mimeType, |
| 416 | sizeBytes: reencoded.blob.size, |
| 417 | recompressed: true, |
| 418 | imageSize: reencoded.imageSize, |
| 422 | 419 | }, |
| 423 | 420 | }; |
| 421 | } catch { |
| 422 | return { ok: false, reason: "unreadable" }; |
| 424 | 423 | } |
| 425 | | const reencoded = await reencodeWithinBudget(file, budgetChars); |
| 426 | | if (!reencoded.ok) { |
| 427 | | return reencoded; |
| 428 | | } |
| 429 | | return { |
| 430 | | ok: true, |
| 431 | | image: { |
| 432 | | dataUrl: reencoded.dataUrl, |
| 433 | | mimeType: reencoded.mimeType, |
| 434 | | sizeBytes: dataUrlByteLength(reencoded.dataUrl), |
| 435 | | recompressed: true, |
| 436 | | imageSize: reencoded.imageSize, |
| 437 | | }, |
| 438 | | }; |
| 439 | 424 | } |
| 440 | 425 | |
| 441 | 426 | /** |
| ... | ... | @@ -468,10 +453,10 @@ export async function compressImageToByteLimit( |
| 468 | 453 | } |
| 469 | 454 | return { |
| 470 | 455 | ok: true, |
| 471 | | file: dataUrlToFile( |
| 472 | | reencoded.dataUrl, |
| 456 | file: new File( |
| 457 | [reencoded.blob], |
| 473 | 458 | fileNameForMimeType(file.name || "image", reencoded.mimeType), |
| 474 | | reencoded.mimeType, |
| 459 | { type: reencoded.mimeType }, |
| 475 | 460 | ), |
| 476 | 461 | recompressed: true, |
| 477 | 462 | imageSize: reencoded.imageSize, |