authorgravatar for Taras.Fomin@gmail.comTaras <Taras.Fomin@gmail.com> 2026-08-11 06:33:08+03:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-08-10 23:33:08-04:00
logf9730979c1046e3e69ac89aa86882ebf8b2be9a6
tree039f49049f671a30c01696e05e7cb0e6cf270df6
parent59d0c922e9363b209bcb004444c05e70fa9fdd8b
signature Signed by PGP key B5690EEEBB952194

perf(web): skip base64 for oversized image candidates (#5220)


1 files changed, 12 insertions(+), 15 deletions(-)

apps/web/src/lib/imageCompression.ts+12-15
...@@ -140,27 +140,28 @@ function createCanvas(width: number, height: number): Canvas2D | null {...@@ -140,27 +140,28 @@ function createCanvas(width: number, height: number): Canvas2D | null {
140 * and it keeps alpha, so screenshots with transparency survive intact.140 * and it keeps alpha, so screenshots with transparency survive intact.
141 * Browsers that can't encode it silently fall back to JPEG.141 * Browsers that can't encode it silently fall back to JPEG.
142 */142 */
143async function encodeToDataUrl(143async function encodeCanvas(
144 canvas: OffscreenCanvas | HTMLCanvasElement,144 canvas: OffscreenCanvas | HTMLCanvasElement,
145 quality: number,145 quality: number,
146 mimeType: string,146 mimeType: string,
147): Promise<{ dataUrl: string; mimeType: string } | null> {147 budgetChars: number,
148): Promise<{ dataUrl: string | null; mimeType: string } | null> {
148 if (typeof HTMLCanvasElement !== "undefined" && canvas instanceof HTMLCanvasElement) {149 if (typeof HTMLCanvasElement !== "undefined" && canvas instanceof HTMLCanvasElement) {
149 const dataUrl = canvas.toDataURL(mimeType, quality);150 const dataUrl = canvas.toDataURL(mimeType, quality);
150 // toDataURL silently returns a PNG when the requested type is unsupported.151 // toDataURL silently returns a PNG when the requested type is unsupported.
151 if (!dataUrl.startsWith(`data:${mimeType}`)) return null;152 if (!dataUrl.startsWith(`data:${mimeType}`)) return null;
152 return { dataUrl, mimeType };153 return { dataUrl: dataUrl.length <= budgetChars ? dataUrl : null, mimeType };
153 }154 }
154 const blob = await (canvas as OffscreenCanvas).convertToBlob({ type: mimeType, quality });155 const blob = await (canvas as OffscreenCanvas).convertToBlob({ type: mimeType, quality });
155 if (blob.type && blob.type !== mimeType) return null;156 if (blob.type && blob.type !== mimeType) return null;
157 const dataUrlLength = `data:${mimeType};base64,`.length + 4 * Math.ceil(blob.size / 3);
158 if (dataUrlLength > budgetChars) return { dataUrl: null, mimeType };
156 return { dataUrl: await blobToDataUrl(blob, mimeType), mimeType };159 return { dataUrl: await blobToDataUrl(blob, mimeType), mimeType };
157}160}
158161
159/**162/**
160 * Draws `bitmap` scaled to fit `maxDimension` and encodes it, stepping163 * Draws `bitmap` scaled to fit `maxDimension` and encodes it, stepping
161 * quality down until the data URL fits `budgetChars`. Returns the smallest164 * quality down until the data URL fits `budgetChars`.
162 * encoding produced, even if it still exceeds the budget, so the caller can
163 * decide whether to keep or drop it.
164 */165 */
165async function encodeWithinBudget(166async function encodeWithinBudget(
166 bitmap: ImageBitmap,167 bitmap: ImageBitmap,
...@@ -175,7 +176,7 @@ async function encodeWithinBudget(...@@ -175,7 +176,7 @@ async function encodeWithinBudget(
175176
176 // Probe WebP once; JPEG (no alpha) needs a white matte, so the fill has to177 // Probe WebP once; JPEG (no alpha) needs a white matte, so the fill has to
177 // happen before drawing and depends on which codec we end up using.178 // happen before drawing and depends on which codec we end up using.
178 const probe = await encodeToDataUrl(target.canvas, QUALITY_STEPS[0], "image/webp");179 const probe = await encodeCanvas(target.canvas, QUALITY_STEPS[0], "image/webp", 0);
179 const mimeType = probe ? "image/webp" : "image/jpeg";180 const mimeType = probe ? "image/webp" : "image/jpeg";
180181
181 if (mimeType === "image/jpeg") {182 if (mimeType === "image/jpeg") {
...@@ -184,18 +185,14 @@ async function encodeWithinBudget(...@@ -184,18 +185,14 @@ async function encodeWithinBudget(
184 }185 }
185 target.context.drawImage(bitmap, 0, 0, width, height);186 target.context.drawImage(bitmap, 0, 0, width, height);
186187
187 let smallest: { dataUrl: string; mimeType: string } | null = null;
188 for (const quality of QUALITY_STEPS) {188 for (const quality of QUALITY_STEPS) {
189 const encoded = await encodeToDataUrl(target.canvas, quality, mimeType);189 const encoded = await encodeCanvas(target.canvas, quality, mimeType, budgetChars);
190 if (!encoded) break;190 if (!encoded) break;
191 if (smallest === null || encoded.dataUrl.length < smallest.dataUrl.length) {191 if (encoded.dataUrl !== null) {
192 smallest = encoded;192 return { dataUrl: encoded.dataUrl, mimeType: encoded.mimeType };
193 }
194 if (encoded.dataUrl.length <= budgetChars) {
195 return encoded;
196 }193 }
197 }194 }
198 return smallest;195 return null;
199}196}
200197
201type ReencodeResult =198type ReencodeResult =