Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 1 addition & 16 deletions apps/web/src/components/ChatView.logic.ts
Original file line number Diff line number Diff line change
Expand Up @@ -795,22 +795,7 @@ export interface PullRequestDialogState {
key: number;
}

export function readFileAsDataUrl(file: File): Promise<string> {
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.addEventListener("load", () => {
if (typeof reader.result === "string") {
resolve(reader.result);
return;
}
reject(new Error("Could not read image data."));
});
reader.addEventListener("error", () => {
reject(reader.error ?? new Error("Failed to read image."));
});
reader.readAsDataURL(file);
});
}
export { readFileAsDataUrl } from "../lib/imageCompression";

export function resolveSendEnvMode(input: {
requestedEnvMode: DraftThreadEnvMode;
Expand Down
80 changes: 78 additions & 2 deletions apps/web/src/lib/imageCompression.test.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { afterEach, describe, expect, it, vi } from "vite-plus/test";
import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test";

import {
createComposerImageThumbnail,
Expand Down Expand Up @@ -34,6 +34,26 @@ vi.mock("heic-to/csp", () => ({
const originalCreateImageBitmap = globalThis.createImageBitmap;
const originalOffscreenCanvas = globalThis.OffscreenCanvas;

beforeEach(() => {
vi.stubGlobal(
"FileReader",
class extends EventTarget {
result: string | null = null;
error: unknown = null;
async readAsDataURL(blob: Blob) {
try {
const bytes = await blob.arrayBuffer();
this.result = `data:${blob.type || "application/octet-stream"};base64,${Buffer.from(bytes).toString("base64")}`;
this.dispatchEvent(new Event("load"));
} catch (error) {
this.error = error;
this.dispatchEvent(new Event("error"));
}
}
},
);
});

function makeFile(sizeBytes: number, type = "image/png"): File {
return new File([new Uint8Array(sizeBytes).fill(7)], "shot.png", { type });
}
Expand Down Expand Up @@ -85,7 +105,7 @@ function makeHeicFile(options?: {
*/
function stubCanvasPipeline(
sizeForQuality: (quality: number) => number,
options?: { supportsWebp?: boolean },
options?: { supportsWebp?: boolean; htmlCanvas?: boolean },
) {
const supportsWebp = options?.supportsWebp ?? true;
const close = vi.fn();
Expand Down Expand Up @@ -114,6 +134,17 @@ function stubCanvasPipeline(
}
},
);
if (options?.htmlCanvas) {
const Canvas = globalThis.OffscreenCanvas;
class HtmlCanvas extends Canvas {
toBlob(callback: BlobCallback, type: string, quality: number) {
void this.convertToBlob({ type, quality }).then(callback);
}
}
vi.stubGlobal("OffscreenCanvas", undefined);
vi.stubGlobal("HTMLCanvasElement", HtmlCanvas);
vi.stubGlobal("document", { createElement: () => new HtmlCanvas(0, 0) });
}
return { close, fillRect };
}

Expand Down Expand Up @@ -194,6 +225,51 @@ describe("dataUrlToFile", () => {
});

describe("compressImageForStash", () => {
it.each([true, false])(
"encodes with HTML canvas when WebP support is %s",
async (supportsWebp) => {
const { close } = stubCanvasPipeline(() => 120_000, { htmlCanvas: true, supportsWebp });

const result = await compressImageToByteLimit(makeFile(2_000_000), 1_000_000);

expect(result.ok && result.file.type).toBe(supportsWebp ? "image/webp" : "image/jpeg");
expect(result.ok && result.file.size).toBe(120_000);
expect(close).toHaveBeenCalledOnce();
},
);

it("keeps the exact data URL budget boundary and original bytes", async () => {
const original = makeFile(3);
const dataUrl = "data:image/png;base64,BwcH";

const result = await compressImageForStash(original, dataUrl.length);

expect(result.ok && result.image.dataUrl).toBe(dataUrl);
expect(result.ok && result.image.recompressed).toBe(false);
expect(await compressImageForStash(original, dataUrl.length - 1)).toEqual({
ok: false,
reason: "too-large",
});
});

it("reports unreadable if reading a small image fails", async () => {
const original = makeFile(3);
vi.spyOn(original, "arrayBuffer").mockRejectedValue(new Error("read failed"));

expect(await compressImageForStash(original)).toEqual({ ok: false, reason: "unreadable" });
});

it("compresses an oversized source before reading it as a data URL", async () => {
stubCanvasPipeline(() => 120_000);
const original = makeFile(4_000_000);
const read = vi.spyOn(original, "arrayBuffer").mockRejectedValue(new Error("unneeded read"));

const result = await compressImageForStash(original);

expect(result.ok && result.image.recompressed).toBe(true);
expect(read).not.toHaveBeenCalled();
});

it("stores a small image verbatim without re-encoding", async () => {
const bitmapSpy = vi.fn();
vi.stubGlobal("createImageBitmap", bitmapSpy);
Expand Down
135 changes: 60 additions & 75 deletions apps/web/src/lib/imageCompression.ts
Original file line number Diff line number Diff line change
Expand Up @@ -143,33 +143,27 @@ async function validateHeicImageDimensions(
return foundImageDimensions ? null : "unreadable";
}

/** Chunked so a large image can't blow the argument limit of `fromCharCode`. */
const BASE64_CHUNK_SIZE = 0x8000;

function bytesToBase64(bytes: Uint8Array): string {
let binary = "";
for (let offset = 0; offset < bytes.length; offset += BASE64_CHUNK_SIZE) {
binary += String.fromCharCode(...bytes.subarray(offset, offset + BASE64_CHUNK_SIZE));
}
return btoa(binary);
}

/**
* Blob → base64 data URL. Uses `arrayBuffer()` rather than `FileReader` so
* the module works anywhere `Blob` does (including non-DOM test runners).
*/
async function blobToDataUrl(blob: File | Blob, mimeTypeOverride?: string): Promise<string> {
const buffer = await blob.arrayBuffer();
const mimeType = mimeTypeOverride || blob.type || "application/octet-stream";
return `data:${mimeType};base64,${bytesToBase64(new Uint8Array(buffer))}`;
export function readFileAsDataUrl(file: Blob): Promise<string> {
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.addEventListener("load", () => {
if (typeof reader.result === "string") {
resolve(reader.result);
return;
}
reject(new Error("Could not read image data."));
});
reader.addEventListener("error", () => {
reject(reader.error ?? new Error("Failed to read image."));
});
reader.readAsDataURL(file);
});
}

/** Approximate decoded byte count for a base64 data URL. */
function dataUrlByteLength(dataUrl: string): number {
const commaIndex = dataUrl.indexOf(",");
const payload = commaIndex === -1 ? dataUrl : dataUrl.slice(commaIndex + 1);
const padding = payload.endsWith("==") ? 2 : payload.endsWith("=") ? 1 : 0;
return Math.max(0, Math.floor((payload.length * 3) / 4) - padding);
function dataUrlLength(blob: Blob): number {
return (
`data:${blob.type || "application/octet-stream"};base64,`.length + 4 * Math.ceil(blob.size / 3)
);
}

/** Base64 payload of a data URL decoded back into a `File`. */
Expand Down Expand Up @@ -232,19 +226,14 @@ async function encodeCanvas(
canvas: OffscreenCanvas | HTMLCanvasElement,
quality: number,
mimeType: string,
budgetChars: number,
): Promise<{ dataUrl: string | null; mimeType: string } | null> {
): Promise<Blob | null> {
let blob: Blob | null;
if (typeof HTMLCanvasElement !== "undefined" && canvas instanceof HTMLCanvasElement) {
const dataUrl = canvas.toDataURL(mimeType, quality);
// toDataURL silently returns a PNG when the requested type is unsupported.
if (!dataUrl.startsWith(`data:${mimeType}`)) return null;
return { dataUrl: dataUrl.length <= budgetChars ? dataUrl : null, mimeType };
blob = await new Promise<Blob | null>((resolve) => canvas.toBlob(resolve, mimeType, quality));
} else {
blob = await (canvas as OffscreenCanvas).convertToBlob({ type: mimeType, quality });
}
const blob = await (canvas as OffscreenCanvas).convertToBlob({ type: mimeType, quality });
if (blob.type && blob.type !== mimeType) return null;
const dataUrlLength = `data:${mimeType};base64,`.length + 4 * Math.ceil(blob.size / 3);
if (dataUrlLength > budgetChars) return { dataUrl: null, mimeType };
return { dataUrl: await blobToDataUrl(blob, mimeType), mimeType };
return blob?.type === mimeType ? blob : null;
}

const composerThumbnails = new WeakMap<File, Promise<string | null>>();
Expand Down Expand Up @@ -274,10 +263,8 @@ export function createComposerImageThumbnail(file: File): Promise<string | null>
dimension,
dimension,
);
return (
(await encodeCanvas(surface.canvas, 1, "image/png", Number.POSITIVE_INFINITY))?.dataUrl ??
null
);
const blob = await encodeCanvas(surface.canvas, 1, "image/png");
return blob ? await readFileAsDataUrl(blob) : null;
} catch {
return null;
} finally {
Expand All @@ -297,7 +284,7 @@ async function encodeWithinBudget(
maxDimension: number,
budgetChars: number,
preferredMimeType?: "image/jpeg",
): Promise<{ dataUrl: string; mimeType: string; imageSize: ImageSize } | null> {
): Promise<{ blob: Blob; mimeType: string; imageSize: ImageSize } | null> {
const scale = Math.min(1, maxDimension / Math.max(bitmap.width, bitmap.height));
const width = Math.max(1, Math.round(bitmap.width * scale));
const height = Math.max(1, Math.round(bitmap.height * scale));
Expand All @@ -308,7 +295,7 @@ async function encodeWithinBudget(
// happen before drawing and depends on which codec we end up using.
const mimeType =
preferredMimeType ??
((await encodeCanvas(target.canvas, QUALITY_STEPS[0], "image/webp", 0))
((await encodeCanvas(target.canvas, QUALITY_STEPS[0], "image/webp"))
? "image/webp"
: "image/jpeg");

Expand All @@ -319,17 +306,17 @@ async function encodeWithinBudget(
target.context.drawImage(bitmap, 0, 0, width, height);

for (const quality of QUALITY_STEPS) {
const encoded = await encodeCanvas(target.canvas, quality, mimeType, budgetChars);
const encoded = await encodeCanvas(target.canvas, quality, mimeType);
if (!encoded) break;
if (encoded.dataUrl !== null) {
return { dataUrl: encoded.dataUrl, mimeType: encoded.mimeType, imageSize: { width, height } };
if (dataUrlLength(encoded) <= budgetChars) {
return { blob: encoded, mimeType, imageSize: { width, height } };
}
}
return null;
}

type ReencodeResult =
| { ok: true; dataUrl: string; mimeType: string; imageSize: ImageSize }
| { ok: true; blob: Blob; mimeType: string; imageSize: ImageSize }
| { ok: false; reason: ImageCompressionFailureReason };

/**
Expand Down Expand Up @@ -378,10 +365,10 @@ async function reencodeWithinBudget(
continue;
}
encodeFailed = false;
if (encoded && encoded.dataUrl.length <= budgetChars) {
if (encoded) {
return {
ok: true,
dataUrl: encoded.dataUrl,
blob: encoded.blob,
mimeType: encoded.mimeType,
imageSize: encoded.imageSize,
};
Expand All @@ -405,37 +392,35 @@ export async function compressImageForStash(
file: File,
budgetChars: number = MAX_STASH_IMAGE_DATA_URL_CHARS,
): Promise<CompressStashImageResult> {
let originalDataUrl: string;
try {
originalDataUrl = await blobToDataUrl(file);
} catch {
return { ok: false, reason: "unreadable" };
}
if (originalDataUrl.length <= budgetChars) {
if (dataUrlLength(file) <= budgetChars) {
return {
ok: true,
image: {
dataUrl: await readFileAsDataUrl(file),
mimeType: file.type,
sizeBytes: file.size,
recompressed: false,
},
};
}
const reencoded = await reencodeWithinBudget(file, budgetChars);
if (!reencoded.ok) {
return reencoded;
}
return {
ok: true,
image: {
dataUrl: originalDataUrl,
mimeType: file.type,
sizeBytes: file.size,
recompressed: false,
dataUrl: await readFileAsDataUrl(reencoded.blob),
mimeType: reencoded.mimeType,
sizeBytes: reencoded.blob.size,
recompressed: true,
imageSize: reencoded.imageSize,
},
};
} catch {
return { ok: false, reason: "unreadable" };
}
const reencoded = await reencodeWithinBudget(file, budgetChars);
if (!reencoded.ok) {
return reencoded;
}
return {
ok: true,
image: {
dataUrl: reencoded.dataUrl,
mimeType: reencoded.mimeType,
sizeBytes: dataUrlByteLength(reencoded.dataUrl),
recompressed: true,
imageSize: reencoded.imageSize,
},
};
}

/**
Expand Down Expand Up @@ -468,10 +453,10 @@ export async function compressImageToByteLimit(
}
return {
ok: true,
file: dataUrlToFile(
reencoded.dataUrl,
file: new File(
[reencoded.blob],
fileNameForMimeType(file.name || "image", reencoded.mimeType),
reencoded.mimeType,
{ type: reencoded.mimeType },
),
recompressed: true,
imageSize: reencoded.imageSize,
Expand Down
Loading