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 {
140140 * and it keeps alpha, so screenshots with transparency survive intact.
141141 * Browsers that can't encode it silently fall back to JPEG.
142142 */
143async function encodeToDataUrl(
143async function encodeCanvas(
144144 canvas: OffscreenCanvas | HTMLCanvasElement,
145145 quality: number,
146146 mimeType: string,
147): Promise<{ dataUrl: string; mimeType: string } | null> {
147 budgetChars: number,
148): Promise<{ dataUrl: string | null; mimeType: string } | null> {
148149 if (typeof HTMLCanvasElement !== "undefined" && canvas instanceof HTMLCanvasElement) {
149150 const dataUrl = canvas.toDataURL(mimeType, quality);
150151 // toDataURL silently returns a PNG when the requested type is unsupported.
151152 if (!dataUrl.startsWith(`data:${mimeType}`)) return null;
152 return { dataUrl, mimeType };
153 return { dataUrl: dataUrl.length <= budgetChars ? dataUrl : null, mimeType };
153154 }
154155 const blob = await (canvas as OffscreenCanvas).convertToBlob({ type: mimeType, quality });
155156 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 };
156159 return { dataUrl: await blobToDataUrl(blob, mimeType), mimeType };
157160}
158161
159162/**
160163 * Draws `bitmap` scaled to fit `maxDimension` and encodes it, stepping
161 * quality down until the data URL fits `budgetChars`. Returns the smallest
162 * encoding produced, even if it still exceeds the budget, so the caller can
163 * decide whether to keep or drop it.
164 * quality down until the data URL fits `budgetChars`.
164165 */
165166async function encodeWithinBudget(
166167 bitmap: ImageBitmap,
......@@ -175,7 +176,7 @@ async function encodeWithinBudget(
175176
176177 // Probe WebP once; JPEG (no alpha) needs a white matte, so the fill has to
177178 // 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);
179180 const mimeType = probe ? "image/webp" : "image/jpeg";
180181
181182 if (mimeType === "image/jpeg") {
......@@ -184,18 +185,14 @@ async function encodeWithinBudget(
184185 }
185186 target.context.drawImage(bitmap, 0, 0, width, height);
186187
187 let smallest: { dataUrl: string; mimeType: string } | null = null;
188188 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);
190190 if (!encoded) break;
191 if (smallest === null || encoded.dataUrl.length < smallest.dataUrl.length) {
192 smallest = encoded;
193 }
194 if (encoded.dataUrl.length <= budgetChars) {
195 return encoded;
191 if (encoded.dataUrl !== null) {
192 return { dataUrl: encoded.dataUrl, mimeType: encoded.mimeType };
196193 }
197194 }
198 return smallest;
195 return null;
199196}
200197
201198type ReencodeResult =