Skip to content

Commit 9f75a14

Browse files
bfollingtonjkomorosclaude
authored
Implement image resize search with bisect (commontoolsinc#2056)
* Add maxSizeBytes auto-compression to ct-image-input Add optional client-side image compression for LLM API size limits: **New Property**: - `maxSizeBytes?: number` - Optional max size before compression - Only compresses if set and file exceeds limit - Falls back to original if compression fails **Compression Strategy**: - Uses Canvas API (OffscreenCanvas for performance) - Tries 9 progressive size/quality combinations: - 2048px @ 85% quality → 800px @ 50% quality - Stops when under size limit - Logs compression results for debugging **Implementation**: - `_compressImage()`: Robust compression with fallback - Preserves original filename - Updates size metadata to reflect compressed size - JPEG output for broad compatibility **Use Case**: Anthropic vision API has 5MB limit per image. Setting maxSizeBytes={4_500_000} ensures images compress automatically before upload. **Example**: ```typescript <ct-image-input maxSizeBytes={4500000} multiple maxImages={5} buttonText="📷 Scan Signs" /> ``` 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <[email protected]> * Add maxSizeBytes to CTImageInputAttributes types Update JSX type definitions for new compression property 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <[email protected]> * Optimize image compression with binary search algorithm Replace linear scan through 9 predefined quality/dimension combinations with an intelligent binary search approach: - Binary search on quality values (0.5-0.95) for each dimension level - Tries dimensions in descending order: 2048, 1600, 1200, 800 - Returns as soon as optimal compression is found - More efficient: typically 3-4 compressions per dimension vs 9 total - Better quality: finds optimal quality dynamically instead of using predefined values This reduces compression time while maintaining or improving output quality. * Refactor: Extract image compression logic into utility module Move the binary search image compression algorithm from ct-image-input component into a reusable utility module: - Created packages/ui/src/v2/utils/image-compression.ts with: - compressImage() function with configurable options - formatFileSize() helper function - CompressionResult interface with detailed metadata - CompressionOptions interface for customization Benefits: - Reusability: Other components can now use image compression - Testability: Logic can be tested independently - Separation of concerns: UI component focuses on presentation - Maintainability: Algorithm improvements benefit all consumers - Type safety: Proper TypeScript interfaces and return types The ct-image-input component now delegates to the utility while maintaining the same compression behavior and logging. * Fix lint * Fix logic so it works at runtime * Trim console.log output * Clarify intended logic * Fix logic * Respond to feedback --------- Co-authored-by: Alex Komoroske <[email protected]> Co-authored-by: Claude <[email protected]>
1 parent 91f4d30 commit 9f75a14

File tree

4 files changed

+314
-36
lines changed

4 files changed

+314
-36
lines changed

packages/html/src/jsx.d.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3162,6 +3162,7 @@ interface CTInputLegacyAttributes<T> extends CTHTMLAttributes<T> {
31623162
interface CTImageInputAttributes<T> extends CTHTMLAttributes<T> {
31633163
"multiple"?: boolean;
31643164
"maxImages"?: number;
3165+
"maxSizeBytes"?: number;
31653166
"capture"?: "user" | "environment" | false;
31663167
"buttonText"?: string;
31673168
"variant"?:

packages/ui/src/v2/components/ct-image-input/ct-image-input.ts

Lines changed: 80 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,10 @@ import {
1212
defaultTheme,
1313
themeContext,
1414
} from "../theme-context.ts";
15+
import {
16+
compressImage,
17+
formatFileSize,
18+
} from "../../utils/image-compression.ts";
1519
import "../ct-button/ct-button.ts";
1620

1721
/**
@@ -68,6 +72,7 @@ export interface ImageData {
6872
*
6973
* @attr {boolean} multiple - Allow multiple images (default: false)
7074
* @attr {number} maxImages - Max number of images (default: unlimited)
75+
* @attr {number} maxSizeBytes - Max size in bytes before compression (default: no compression)
7176
* @attr {string} capture - Capture mode: "user" | "environment" | false
7277
* @attr {string} buttonText - Custom button text (default: "📷 Add Photo")
7378
* @attr {string} variant - Button style variant
@@ -83,6 +88,8 @@ export interface ImageData {
8388
*
8489
* @example
8590
* <ct-image-input capture="environment" buttonText="📸 Scan"></ct-image-input>
91+
* @example
92+
* <ct-image-input maxSizeBytes={5000000} buttonText="📸 Upload"></ct-image-input>
8693
*/
8794
export class CTImageInput extends BaseElement {
8895
static override styles = [
@@ -219,6 +226,9 @@ export class CTImageInput extends BaseElement {
219226
@property({ type: Boolean })
220227
disabled = false;
221228

229+
@property({ type: Number })
230+
maxSizeBytes?: number = 5 * 1024 * 1024; // Default to 5MB
231+
222232
@property({ type: Array })
223233
images: Cell<ImageData[]> | ImageData[] = [];
224234

@@ -307,48 +317,88 @@ export class CTImageInput extends BaseElement {
307317
}
308318
}
309319

310-
private _processFile(file: File): Promise<ImageData> {
320+
/**
321+
* Compress an image file using the image compression utility
322+
* @param file - The image file to compress
323+
* @param maxSizeBytes - Target maximum size in bytes
324+
* @returns Compressed blob
325+
*/
326+
private async _compressImage(
327+
file: File,
328+
maxSizeBytes: number,
329+
): Promise<Blob> {
330+
const result = await compressImage(file, { maxSizeBytes });
331+
332+
// Log compression result
333+
if (result.compressedSize < result.originalSize) {
334+
console.log(
335+
`Compressed ${file.name}: ${formatFileSize(result.originalSize)}${
336+
formatFileSize(result.compressedSize)
337+
} (${result.width}x${result.height}, q${result.quality.toFixed(2)})`,
338+
);
339+
}
340+
341+
if (result.compressedSize > maxSizeBytes) {
342+
console.warn(
343+
`Could not compress ${file.name} below ${
344+
formatFileSize(maxSizeBytes)
345+
}. Final size: ${formatFileSize(result.compressedSize)}`,
346+
);
347+
}
348+
349+
return result.blob;
350+
}
351+
352+
private async _processFile(file: File): Promise<ImageData> {
353+
const id = this._generateId();
354+
355+
// Compress if maxSizeBytes is set and file exceeds it
356+
let fileToProcess: Blob = file;
357+
if (this.maxSizeBytes && file.size > this.maxSizeBytes) {
358+
try {
359+
fileToProcess = await this._compressImage(file, this.maxSizeBytes);
360+
} catch (error) {
361+
console.error("Compression failed, using original file:", error);
362+
// Continue with original file if compression fails
363+
}
364+
}
365+
311366
return new Promise((resolve, reject) => {
312367
const reader = new FileReader();
313-
const id = this._generateId();
314368

315369
reader.onload = () => {
316-
try {
317-
const dataUrl = reader.result as string;
318-
319-
// Get image dimensions
320-
const img = new Image();
321-
img.onload = () => {
322-
const imageData: ImageData = {
323-
id,
324-
name: file.name || `Photo-${Date.now()}.jpg`,
325-
url: dataUrl,
326-
data: dataUrl,
327-
timestamp: Date.now(),
328-
width: img.width,
329-
height: img.height,
330-
size: file.size,
331-
type: file.type,
332-
};
333-
334-
resolve(imageData);
370+
const dataUrl = reader.result as string;
371+
372+
// Get image dimensions from the data URL
373+
const img = new Image();
374+
img.onload = () => {
375+
const imageData: ImageData = {
376+
id,
377+
name: file.name || `Photo-${Date.now()}.jpg`,
378+
url: dataUrl,
379+
data: dataUrl,
380+
timestamp: Date.now(),
381+
width: img.width,
382+
height: img.height,
383+
size: fileToProcess.size, // Use compressed size
384+
type: fileToProcess.type || file.type,
335385
};
336386

337-
img.onerror = () => {
338-
reject(new Error("Failed to load image"));
339-
};
387+
resolve(imageData);
388+
};
340389

341-
img.src = dataUrl;
342-
} catch (error) {
343-
reject(error);
344-
}
390+
img.onerror = () => {
391+
reject(new Error("Failed to load image"));
392+
};
393+
394+
img.src = dataUrl;
345395
};
346396

347397
reader.onerror = () => {
348398
reject(new Error("Failed to read file"));
349399
};
350400

351-
reader.readAsDataURL(file);
401+
reader.readAsDataURL(fileToProcess);
352402
});
353403
}
354404

@@ -360,12 +410,6 @@ export class CTImageInput extends BaseElement {
360410
this.emit("ct-change", { images: updatedImages });
361411
}
362412

363-
private _formatFileSize(bytes: number): string {
364-
if (bytes < 1024) return `${bytes} B`;
365-
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
366-
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
367-
}
368-
369413
override connectedCallback() {
370414
super.connectedCallback();
371415
// CellController handles subscription automatically via ReactiveController
@@ -452,7 +496,7 @@ export class CTImageInput extends BaseElement {
452496
`
453497
: ""}
454498
<div class="image-info" title="${image.name}">
455-
${image.name} (${this._formatFileSize(image.size)})
499+
${image.name} (${formatFileSize(image.size)})
456500
</div>
457501
</div>
458502
`,

0 commit comments

Comments
 (0)