feat(mobile): large + multi-image uploads from the camera-roll picker

The mobile copy/paste overlay's "🖼 Image" button (and drag-drop / paste)
now handles real-world photo batches:

- Up to 20 images per batch, uploaded with bounded concurrency (3) and a
  live "Uploading N/M…" progress toast; a final summary reports successes,
  any failures, and whether the 20-cap trimmed the selection (no silent
  truncation).
- Per-file upload limit raised 10MB → 50MB (MAX_PASTE_IMAGE_BYTES in
  buffer-limits.ts, env-overridable) so full-resolution phone photos and
  large screenshots aren't rejected.
- Very large images are downscaled to <=4096px longest edge before upload:
  fixes iOS Safari's ~16.7M-px <canvas> limit (which made huge photos fail
  to re-encode and fall back to an original that tripped the magic-byte
  check), and keeps batch uploads fast and small.
- Fix a latent concurrency bug the batch path exposed: the first parallel
  uploads to a session raced on `mkdir(.claude-images)` and the EEXIST
  losers 500'd. mkdir now treats an existing real directory as success
  (re-verifying it isn't a planted symlink), so concurrent uploads succeed.

Verified end-to-end in a real browser (Playwright): downscale, >10MB
server acceptance, 20-cap, 20/20 concurrent uploads landing on disk.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Codeman maintainer
2026-06-23 23:12:41 +02:00
parent 1255e28f6f
commit 50b8f1d9a0
4 changed files with 122 additions and 34 deletions
+80 -24
View File
@@ -104,34 +104,78 @@ Object.assign(CodemanApp.prototype, {
document.execCommand('paste');
},
async _uploadAndInsertImages(files) {
// Max images accepted in one batch (paste / drop / mobile picker). Each is
// uploaded as its own request, so 20 stays under the server's 30 uploads/min
// rate limit while covering "select a bunch of photos at once".
_maxBatchImages: 20,
// How many uploads to run concurrently. Small enough that decoding several
// large images through <canvas> at once won't OOM a phone, large enough that
// 20 photos don't crawl through serially.
_uploadConcurrency: 3,
async _uploadAndInsertImages(fileList) {
const sessionId = this.activeSessionId;
if (!sessionId) return;
this.showToast('Uploading ' + files.length + ' image' + (files.length > 1 ? 's' : '') + '...', 'info');
let files = Array.from(fileList || []);
if (files.length === 0) return;
const paths = [];
for (const file of files) {
try {
// Re-encode to a standard JPEG/PNG before upload. Galleries on some
// phones (notably Android/MIUI) hand back a WebP/HEIF whose filename and
// MIME claim "image/jpeg", which passes the server's extension allowlist
// but fails its magic-byte check ("bytes do not match declared type").
// Decoding through the browser and re-encoding guarantees the bytes
// match the extension we send.
const normalized = await this._normalizeImageForUpload(file);
const path = await this._uploadPasteImage(sessionId, normalized);
paths.push(path);
} catch (err) {
this.showToast('Upload failed: ' + (err.message || 'unknown error'), 'error');
// Cap the batch and tell the user what got dropped (no silent truncation).
let capped = false;
if (files.length > this._maxBatchImages) {
files = files.slice(0, this._maxBatchImages);
capped = true;
}
const total = files.length;
let done = 0;
let failed = 0;
const results = new Array(total); // preserve selection order for insertion
const progress = () =>
this.showToast(`Uploading ${Math.min(done + 1, total)}/${total} image${total > 1 ? 's' : ''}…`, 'info');
progress();
// Bounded-concurrency worker pool over the file list.
let next = 0;
const worker = async () => {
for (;;) {
const i = next++;
if (i >= total) return;
try {
// Re-encode to a standard JPEG/PNG (and downscale very large images)
// before upload. Galleries on some phones (notably Android/MIUI) hand
// back a WebP/HEIF whose filename and MIME claim "image/jpeg", which
// passes the server's extension allowlist but fails its magic-byte
// check. Decoding through the browser and re-encoding guarantees the
// bytes match the extension we send — and shrinks huge photos so they
// fit the upload limit and iOS's <canvas> area cap.
const normalized = await this._normalizeImageForUpload(files[i]);
results[i] = await this._uploadPasteImage(sessionId, normalized);
} catch (err) {
failed++;
console.warn('Image upload failed:', err);
results[i] = null;
} finally {
done++;
if (done < total) progress();
}
}
};
await Promise.all(Array.from({ length: Math.min(this._uploadConcurrency, total) }, () => worker()));
const paths = results.filter(Boolean);
if (paths.length > 0) {
// Insert all paths in one shot, space-separated, in selection order.
await this.sendInput(paths.join(' '));
}
if (paths.length > 0) {
const pathStr = paths.join(' ');
await this.sendInput(pathStr);
this.showToast(paths.length + ' image' + (paths.length > 1 ? 's' : '') + ' ready', 'success');
}
// Final status: successes, plus any failures / cap so nothing is silent.
const parts = [];
if (paths.length > 0) parts.push(`${paths.length} image${paths.length > 1 ? 's' : ''} ready`);
if (failed > 0) parts.push(`${failed} failed`);
if (capped) parts.push(`max ${this._maxBatchImages} per batch`);
const tone = paths.length > 0 ? (failed > 0 || capped ? 'info' : 'success') : 'error';
this.showToast(parts.join(' · ') || 'No images uploaded', tone);
},
async _uploadPasteImage(sessionId, file) {
@@ -176,12 +220,24 @@ Object.assign(CodemanApp.prototype, {
const height = img.naturalHeight;
if (!width || !height) return file;
// Downscale very large images. Two reasons: (1) iOS Safari refuses to
// render a <canvas> larger than ~16.7M px (it returns a blank/null
// blob), so a 48MP photo would otherwise fail to re-encode and fall back
// to the original — which then trips the server's magic-byte check for
// HEIF mislabeled as JPEG. (2) It keeps multi-photo uploads fast and well
// under the size limit. Cap the longest edge so area stays safely below
// the canvas limit while still uploading a large, high-quality image.
const MAX_EDGE = 4096;
const scale = Math.min(1, MAX_EDGE / Math.max(width, height));
const w = Math.max(1, Math.round(width * scale));
const h = Math.max(1, Math.round(height * scale));
const canvas = document.createElement('canvas');
canvas.width = width;
canvas.height = height;
canvas.width = w;
canvas.height = h;
const ctx = canvas.getContext('2d');
if (!ctx) return file;
ctx.drawImage(img, 0, 0);
ctx.drawImage(img, 0, 0, w, h);
const mime = toPng ? 'image/png' : 'image/jpeg';
const blob = await new Promise((resolve) => canvas.toBlob(resolve, mime, 0.92));