23-Jun-2026 — run resize on the integration worker, defer drop-time resize until after cropping, and... #257
build 718 — commit 58114fc
Worker move (the original #257 scope): Per-site 512MB Fly VMs were being OOM-killed by sharp during large album resizes, taking the whole site process down. The sharp/S3 work now runs on the 1024MB shared integration worker, mirroring the existing migration job pattern: the site dispatches a job, the worker runs it and posts progress/result callbacks, and the site relays those onto the existing WebSocket so the editor UI is unchanged.
- Extract the sharp/S3 core into image-resize-engine.ts, taking an injected S3 client and a progress reporter (no ws, no Mongo).
- bulk-image-resizer.ts becomes a thin local-path wrapper over the engine, preserving the local-dev fallback when no worker is configured.
- Worker intake at /api/integration-worker/resize/jobs builds its S3 client from per-site AWS credentials passed encrypted in the job, so the shared worker stays multi-tenant and reads/writes the correct site bucket.
- Content-metadata Mongo writes stay on the site: the worker returns the updated metadata and the site result callback commits it.
- WebSocket message contract preserved (PROGRESS/COMPLETE/ERROR), so the album editor needs no changes.
- The worker logs every resize operation to its own debug log: job start (mode, image count, bucket, target size), each per-image step with sequential op number, percent and elapsed ms, and job finish/failure with totals and elapsed time.
Drop-then-crop resize UX: Dropping images into the album editor immediately compressed each whole image to the maximum size, so cropping then kept a region of an already-degraded image and the byte budget was spent on the whole frame rather than the part kept.
- On drop, images are only downscaled by dimension (max 2400px wide, high quality) to keep the cropper responsive and bound memory on bulk drops - no byte-target compression.
- The byte-target compression is deferred to save and applied to each image's current base64 (the cropped result if it was cropped), so the kept crop gets the full size budget.
- New file-utils downscaleBase64Image helper does the dimension-only scaling.
Resize byte-budget fix: resizeBase64Image compared the base64 string length against a budget derived straight from the byte target. Base64 is ~4/3 larger than the bytes it encodes, so a 250kb cap produced ~169kb images (roughly target x 0.74). The budget now converts bytes to base64 chars (x 4/3, with a 2% safety margin), so output lands just under the cap instead of well below it - and the dimension grow-step can use the full budget for sharper images.
Bulk-drop read resilience: Dropping many files at once read them all concurrently via Promise.all; a single FileReader NotFoundError (common when reading 50+ dropped files at once) rejected the whole batch as an unhandled error, so nothing was inserted. Reads now run in bounded-concurrency batches (6 at a time) with a single retry per file; unreadable files are skipped with a warning showing how many, instead of failing the entire drop. onFileSelectOrDropped is wrapped so it can never surface an unhandled rejection.
Editor display fixes:
- "Cropped Size NaN undefinedb" appeared for freshly dropped (uncropped) images because croppedSize() read only the cropped file size. It now falls back to the dropped image's actual size from base64, and the row is hidden when there is no size.
- humanFileSize returns "" for non-finite input instead of "NaN undefinedb", so this class of display bug cannot recur anywhere it is used; the function was also restructured to if/else-if/else (no scattered early returns) and off let.
Lint hardening (prevent recurrence): Both Date.now() and Object.entries() were documented as forbidden in AGENTS.md but had no AST selector, so they passed lint and were only caught at review. Audited the whole AGENTS.md forbidden table; these two were the only documented-but-unenforced rules (new Date, DateTime.now, typeof checks, Array.isArray, the loop rules and inline comments were already enforced).
- Added no-restricted-syntax selectors for Date.now() and Object.entries() to the server and frontend blocks.
- Migrated existing Date.now() usages to dateTimeNowAsValue() (backend) / this.dateUtils.nowAsValue() (frontend) across integration-worker server/runner/client/routes, fly-commands, email-composition, brevo-events-webhook, site-migration-ws-handler, the two deploy scripts, the s3-utils spec and banner.component.ts.
- Migrated existing Object.entries() usages to toPairs() from es-toolkit/compat across 28 files (geojson, brevo, migrations, salesforce, venue, map-routes, walks, area-map, mail, content admin, etc.).
- Corrected AGENTS.md: the es-toolkit/compat equivalent of Object.entries is toPairs(), not entries() (which it does not export).