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
96 changes: 96 additions & 0 deletions apps/dashboard/src/components/assistant-ui/compress-image.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
import { MAX_IMAGE_BYTES_PER_FILE } from "@stackframe/stack-shared/dist/ai/image-limits";

/**
* Maximum pixel dimension (width or height) for compressed output.
* 2048px is plenty for AI chat while keeping file sizes manageable.
*/
const MAX_DIMENSION = 2048;

/**
* Target compressed size: well under the hard 3 MB server limit so the
* base-64 encoded payload (≈ 33 % larger) still fits comfortably.
*/
const COMPRESS_TARGET_BYTES = Math.floor(MAX_IMAGE_BYTES_PER_FILE / 2);

function canvasToBlob(
canvas: HTMLCanvasElement,
type: string,
quality: number,
): Promise<Blob> {
return new Promise((resolve, reject) => {
canvas.toBlob(
(blob) => {
if (blob == null) {
reject(new Error("Canvas toBlob returned null"));
return;
}
resolve(blob);
},
type,
quality,
);
});
}

/**
* Compresses an image `File` on the client so it stays well under the
* server-side size limit. Returns the original file unchanged when it is
* already small enough.
*
* Strategy:
* 1. Down-scale to at most `MAX_DIMENSION` px on the longest side.
* 2. Encode as JPEG with decreasing quality until the result fits.
* 3. If quality alone isn't enough, halve the dimensions and retry.
*/
export async function compressImageFile(file: File): Promise<File> {
if (file.size <= MAX_IMAGE_BYTES_PER_FILE) {
return file;
}

const bitmap = await createImageBitmap(file);
const dimensionScale = Math.min(
1,
MAX_DIMENSION / Math.max(bitmap.width, bitmap.height),
);
const baseWidth = Math.round(bitmap.width * dimensionScale);
const baseHeight = Math.round(bitmap.height * dimensionScale);

const canvas = document.createElement("canvas");
const ctx = canvas.getContext("2d");
if (ctx == null) {
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ImageBitmap resource not released if canvasToBlob() throws an error during compression loop

Fix on Vercel

bitmap.close();
throw new Error("Failed to get canvas 2d context for image compression");
}

// Try progressively smaller sizes until the output fits.
for (
let sizeScale = 1;
sizeScale >= 0.25;
sizeScale = Math.round((sizeScale * 0.5) * 100) / 100
) {
const w = Math.max(1, Math.round(baseWidth * sizeScale));
const h = Math.max(1, Math.round(baseHeight * sizeScale));
canvas.width = w;
canvas.height = h;
ctx.drawImage(bitmap, 0, 0, w, h);
Comment on lines +58 to +75
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Transparent PNG alpha channel is silently replaced with black

canvas.getContext("2d") draws with a default opaque black background, so any image with transparency (PNG, WebP) that exceeds 3 MB and triggers compression will have its transparent areas filled with solid black in the resulting JPEG. A user who uploads a large transparent logo or screenshot with a transparent background would see the preview and the AI would receive an image with a black background instead — no warning is shown.

Consider filling the canvas with white before drawing (a more neutral default for most transparent images): ctx.fillStyle = "#ffffff"; ctx.fillRect(0, 0, w, h); before the ctx.drawImage call.

Prompt To Fix With AI
This is a comment left during a code review.
Path: apps/dashboard/src/components/assistant-ui/compress-image.ts
Line: 58-75

Comment:
**Transparent PNG alpha channel is silently replaced with black**

`canvas.getContext("2d")` draws with a default opaque black background, so any image with transparency (PNG, WebP) that exceeds 3 MB and triggers compression will have its transparent areas filled with solid black in the resulting JPEG. A user who uploads a large transparent logo or screenshot with a transparent background would see the preview and the AI would receive an image with a black background instead — no warning is shown.

Consider filling the canvas with white before drawing (a more neutral default for most transparent images): `ctx.fillStyle = "#ffffff"; ctx.fillRect(0, 0, w, h);` before the `ctx.drawImage` call.

How can I resolve this? If you propose a fix, please make it concise.


for (let quality = 0.85; quality >= 0.15; quality -= 0.1) {
const blob = await canvasToBlob(canvas, "image/jpeg", quality);
if (blob.size <= COMPRESS_TARGET_BYTES) {
bitmap.close();
return new File([blob], file.name, {
type: "image/jpeg",
lastModified: file.lastModified,
});
Comment on lines +81 to +84
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 File name extension disagrees with the JPEG MIME type

The compressed file preserves the original name (e.g., photo.png) while type is set to "image/jpeg". The name field is surfaced in the composer UI and in the PendingAttachment, so a user will see a .png filename attached even though the payload is JPEG. The mismatch is unlikely to break the AI call, but it can be confusing and could trip any future server-side validation that checks the extension. Renaming to append .jpg (or strip the old extension and add .jpg) when compression is applied would keep things consistent.

Prompt To Fix With AI
This is a comment left during a code review.
Path: apps/dashboard/src/components/assistant-ui/compress-image.ts
Line: 81-84

Comment:
**File name extension disagrees with the JPEG MIME type**

The compressed file preserves the original name (e.g., `photo.png`) while `type` is set to `"image/jpeg"`. The `name` field is surfaced in the composer UI and in the `PendingAttachment`, so a user will see a `.png` filename attached even though the payload is JPEG. The mismatch is unlikely to break the AI call, but it can be confusing and could trip any future server-side validation that checks the extension. Renaming to append `.jpg` (or strip the old extension and add `.jpg`) when compression is applied would keep things consistent.

How can I resolve this? If you propose a fix, please make it concise.

}
}
}

// Fallback: lowest quality at the smallest attempted dimension.
const blob = await canvasToBlob(canvas, "image/jpeg", 0.1);
bitmap.close();
return new File([blob], file.name, {
type: "image/jpeg",
lastModified: file.lastModified,
});
}
Comment on lines +50 to +96
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Ensure ImageBitmap is always closed on error paths.

bitmap.close() is not guaranteed if an awaited compression step throws after Line 50. Wrap the compression loop in try/finally and close in finally to avoid leaking bitmap resources.

Suggested fix
 export async function compressImageFile(file: File): Promise<File> {
   if (file.size <= MAX_IMAGE_BYTES_PER_FILE) {
     return file;
   }

-  const bitmap = await createImageBitmap(file);
+  const bitmap = await createImageBitmap(file);
   const dimensionScale = Math.min(
     1,
     MAX_DIMENSION / Math.max(bitmap.width, bitmap.height),
   );
   const baseWidth = Math.round(bitmap.width * dimensionScale);
   const baseHeight = Math.round(bitmap.height * dimensionScale);

   const canvas = document.createElement("canvas");
   const ctx = canvas.getContext("2d");
   if (ctx == null) {
-    bitmap.close();
     throw new Error("Failed to get canvas 2d context for image compression");
   }

-  // Try progressively smaller sizes until the output fits.
-  for (
-    let sizeScale = 1;
-    sizeScale >= 0.25;
-    sizeScale = Math.round((sizeScale * 0.5) * 100) / 100
-  ) {
-    const w = Math.max(1, Math.round(baseWidth * sizeScale));
-    const h = Math.max(1, Math.round(baseHeight * sizeScale));
-    canvas.width = w;
-    canvas.height = h;
-    ctx.drawImage(bitmap, 0, 0, w, h);
-
-    for (let quality = 0.85; quality >= 0.15; quality -= 0.1) {
-      const blob = await canvasToBlob(canvas, "image/jpeg", quality);
-      if (blob.size <= COMPRESS_TARGET_BYTES) {
-        bitmap.close();
-        return new File([blob], file.name, {
-          type: "image/jpeg",
-          lastModified: file.lastModified,
-        });
-      }
-    }
-  }
-
-  // Fallback: lowest quality at the smallest attempted dimension.
-  const blob = await canvasToBlob(canvas, "image/jpeg", 0.1);
-  bitmap.close();
-  return new File([blob], file.name, {
-    type: "image/jpeg",
-    lastModified: file.lastModified,
-  });
+  try {
+    // Try progressively smaller sizes until the output fits.
+    for (
+      let sizeScale = 1;
+      sizeScale >= 0.25;
+      sizeScale = Math.round((sizeScale * 0.5) * 100) / 100
+    ) {
+      const w = Math.max(1, Math.round(baseWidth * sizeScale));
+      const h = Math.max(1, Math.round(baseHeight * sizeScale));
+      canvas.width = w;
+      canvas.height = h;
+      ctx.drawImage(bitmap, 0, 0, w, h);
+
+      for (let quality = 0.85; quality >= 0.15; quality -= 0.1) {
+        const blob = await canvasToBlob(canvas, "image/jpeg", quality);
+        if (blob.size <= COMPRESS_TARGET_BYTES) {
+          return new File([blob], file.name, {
+            type: "image/jpeg",
+            lastModified: file.lastModified,
+          });
+        }
+      }
+    }
+
+    // Fallback: lowest quality at the smallest attempted dimension.
+    const blob = await canvasToBlob(canvas, "image/jpeg", 0.1);
+    return new File([blob], file.name, {
+      type: "image/jpeg",
+      lastModified: file.lastModified,
+    });
+  } finally {
+    bitmap.close();
+  }
 }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/dashboard/src/components/assistant-ui/compress-image.ts` around lines 50
- 96, The ImageBitmap created via createImageBitmap (bitmap) may not be closed
if an awaited step (e.g., canvasToBlob) throws; wrap the compression logic that
uses bitmap (the for loops that compute w/h, call ctx.drawImage, and await
canvasToBlob) in a try/finally and call bitmap.close() in the finally block so
bitmap.close() always runs on success or error; keep the existing early-return
behavior by returning the File inside the try, and move the final fallback blob
creation into the try so the finally still closes bitmap.

Original file line number Diff line number Diff line change
@@ -1,26 +1,23 @@
import { validateComposerImageByteLength } from "@/components/assistant-ui/image-attachment-validation";
import { compressImageFile } from "@/components/assistant-ui/compress-image";
import {
type AttachmentAdapter,
type CompleteAttachment,
type PendingAttachment,
} from "@assistant-ui/react";
import { generateUuid } from "@stackframe/stack-shared/dist/utils/uuids";

/** Chat composer attachments: UUID ids, shared max file size (see `image-limits`). */
/** Chat composer attachments: UUID ids, auto-compressed to fit shared max file size (see `image-limits`). */
export class ImageAttachmentAdapter implements AttachmentAdapter {
public readonly accept = "image/*";

public async add(state: { file: File }): Promise<PendingAttachment> {
const sizeValidation = validateComposerImageByteLength(state.file.size);
if (!sizeValidation.ok) {
throw new Error(`"${state.file.name}": ${sizeValidation.reason}`);
}
const compressed = await compressImageFile(state.file);
return {
id: generateUuid(),
type: "image",
name: state.file.name,
contentType: state.file.type,
file: state.file,
contentType: compressed.type,
file: compressed,
status: { type: "requires-action", reason: "composer-send" },
};
}
Expand Down
27 changes: 2 additions & 25 deletions apps/dashboard/src/components/assistant-ui/thread.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,6 @@ import { TooltipIconButton } from "@/components/assistant-ui/tooltip-icon-button
import { Button, useToast } from "@/components/ui";
import { cn } from "@/lib/utils";
import {
validateComposerImageByteLength,
validateComposerImageCount,
} from "./image-attachment-validation";
import {
Expand All @@ -24,7 +23,6 @@ import {
import { ArrowClockwiseIcon, ArrowDownIcon, CaretLeftIcon, CaretRightIcon, CheckIcon, CopyIcon, ImageIcon, PaperPlaneRightIcon, PencilSimpleIcon, WarningCircle, XIcon } from "@phosphor-icons/react";
import {
MAX_IMAGES_PER_MESSAGE,
MAX_IMAGE_MB_PER_FILE,
} from "@stackframe/stack-shared/dist/ai/image-limits";
import { runAsynchronously } from "@stackframe/stack-shared/dist/utils/promises";
import { createContext, useContext, useEffect, useMemo, useRef, useState, type ComponentProps, type FC, type ReactNode } from "react";
Expand Down Expand Up @@ -375,16 +373,6 @@ const ComposerAttachmentsAddButton: FC = () => {
const remaining = Math.max(0, MAX_IMAGES_PER_MESSAGE - liveCount);
const picked = Array.from(files);
const selected = picked.slice(0, remaining);
const valid: File[] = [];
const oversized: File[] = [];
for (const file of selected) {
const sizeValidation = validateComposerImageByteLength(file.size);
if (sizeValidation.ok) {
valid.push(file);
} else {
oversized.push(file);
}
}

const countValidation = validateComposerImageCount(liveCount + picked.length);
if (!countValidation.ok) {
Expand All @@ -394,20 +382,9 @@ const ComposerAttachmentsAddButton: FC = () => {
});
}

if (oversized.length > 0) {
const firstOversizedValidation = validateComposerImageByteLength(oversized[0]!.size);
toast({
variant: "destructive",
description:
oversized.length === 1
? `"${oversized[0]!.name}": ${firstOversizedValidation.ok ? `Image exceeds ${MAX_IMAGE_MB_PER_FILE}MB limit.` : firstOversizedValidation.reason}`
: `${oversized.length} images exceeded the ${MAX_IMAGE_MB_PER_FILE}MB limit and were skipped.`,
});
}

runAsynchronously(
(async () => {
for (const file of valid) {
for (const file of selected) {
if (composerRuntime.getState().attachments.length >= MAX_IMAGES_PER_MESSAGE) {
break;
}
Expand Down Expand Up @@ -437,7 +414,7 @@ const ComposerAttachmentsAddButton: FC = () => {

const tooltipText = atLimit
? `Limit reached (${MAX_IMAGES_PER_MESSAGE}/${MAX_IMAGES_PER_MESSAGE})`
: `Attach image (${count}/${MAX_IMAGES_PER_MESSAGE}, max ${MAX_IMAGE_MB_PER_FILE}MB)`;
: `Attach image (${count}/${MAX_IMAGES_PER_MESSAGE})`;

return (
<TooltipIconButton
Expand Down
Loading