diff --git a/apps/img/src/img.app.ts b/apps/img/src/img.app.ts index 0527d66..5ca806f 100644 --- a/apps/img/src/img.app.ts +++ b/apps/img/src/img.app.ts @@ -1,4 +1,4 @@ -import { PhotonImage, resize, SamplingFilter } from '@cf-wasm/photon' +import { crop, PhotonImage, resize, SamplingFilter } from '@cf-wasm/photon' import { Hono } from 'hono' import { useWorkersLogger } from 'workers-tagged-logger' @@ -12,16 +12,25 @@ const SIGNATURE_KEY_ID = 'KEY:RSA:p1.rec.net' /** Static asset served (200) when the requested key is missing from R2. */ const FALLBACK_ASSET_PATH = '/DefaultProfileImage.jpg' +/** + * Cache-Control for served images. Uploaded images are immutable once written, + * so cache for a year and mark `immutable` so browsers never revalidate. A new + * image simply uses a new key. + */ +const CACHE_CONTROL = 'public, max-age=31536000, immutable' + /** Upper bound on a requested output dimension; guards against abuse. */ const MAX_DIMENSION = 4096 /** JPEG quality used when re-encoding a resized image. */ const RESIZE_JPEG_QUALITY = 90 -/** A requested resize, from `?width=`/`?height=`. At least one is set. */ +/** A requested transform, from `?width=`/`?height=`/`?cropSquare=1`. At least one applies. */ interface Transform { width?: number height?: number + /** Center-crop the source to a square before resizing (`?cropSquare=1`). */ + cropSquare: boolean } /** Parse a positive-integer dimension query param, or `undefined` if invalid/absent. */ @@ -33,37 +42,55 @@ function parseDimension(value: string | undefined): number | undefined { } /** Build a `Transform` from the request query, or `null` when none is requested. */ -function parseTransform(width: string | undefined, height: string | undefined): Transform | null { +function parseTransform( + width: string | undefined, + height: string | undefined, + cropSquare: string | undefined +): Transform | null { const w = parseDimension(width) const h = parseDimension(height) - if (w === undefined && h === undefined) return null - return { width: w, height: h } + const square = cropSquare === '1' + if (w === undefined && h === undefined && !square) return null + return { width: w, height: h, cropSquare: square } } /** - * Decode `input`, resize (preserving aspect ratio when only one dimension is - * given), and re-encode as JPEG. Runs the Photon WASM codec in-isolate — there - * is no caching yet, so every request pays the full decode/resize/encode cost. + * Decode `input`, apply the requested transform (optional center-crop to a + * square, then resize preserving aspect ratio when only one dimension is given), + * and re-encode as JPEG. Runs the Photon WASM codec in-isolate; edge caching + * (see `wrangler.jsonc`) means each variant only pays this cost once. */ function resizeImage(input: Uint8Array, transform: Transform): Uint8Array { - const img = PhotonImage.new_from_byteslice(input) + let img = PhotonImage.new_from_byteslice(input) + // Every PhotonImage we allocate (source + each stage) must be freed. + const owned = [img] try { - const srcW = img.get_width() - const srcH = img.get_height() + if (transform.cropSquare) { + const w = img.get_width() + const h = img.get_height() + const side = Math.min(w, h) + const x = Math.floor((w - side) / 2) + const y = Math.floor((h - side) / 2) + img = crop(img, x, y, x + side, y + side) + owned.push(img) + } + let { width, height } = transform - if (width !== undefined && height === undefined) { - height = Math.max(1, Math.round((srcH / srcW) * width)) - } else if (height !== undefined && width === undefined) { - width = Math.max(1, Math.round((srcW / srcH) * height)) - } - const resized = resize(img, width!, height!, SamplingFilter.Lanczos3) - try { - return resized.get_bytes_jpeg(RESIZE_JPEG_QUALITY) - } finally { - resized.free() + if (width !== undefined || height !== undefined) { + const srcW = img.get_width() + const srcH = img.get_height() + if (width !== undefined && height === undefined) { + height = Math.max(1, Math.round((srcH / srcW) * width)) + } else if (height !== undefined && width === undefined) { + width = Math.max(1, Math.round((srcW / srcH) * height)) + } + img = resize(img, width!, height!, SamplingFilter.Lanczos3) + owned.push(img) } + + return img.get_bytes_jpeg(RESIZE_JPEG_QUALITY) } finally { - img.free() + for (const image of owned) image.free() } } @@ -142,7 +169,7 @@ async function serveStaticAsset( const headers = new Headers() const contentType = asset.headers.get('content-type') if (contentType) headers.set('content-type', contentType) - headers.set('cache-control', 'public, max-age=3600') + headers.set('cache-control', CACHE_CONTROL) if (transform || wantsSignature) { const bytes = await asset.arrayBuffer() @@ -180,7 +207,11 @@ const app = new Hono() if (key.includes('..')) return c.body(null, 400) const wantsSignature = c.req.query('sig') === 'p1' - const transform = parseTransform(c.req.query('width'), c.req.query('height')) + const transform = parseTransform( + c.req.query('width'), + c.req.query('height'), + c.req.query('cropSquare') + ) // Prefer a bundled static asset when one exists for this key, before hitting // R2. This lets us ship canonical images (e.g. room thumbnails in `static/`) @@ -209,7 +240,7 @@ const app = new Hono() const headers = new Headers() object.writeHttpMetadata(headers) headers.set('etag', object.httpEtag) - headers.set('cache-control', 'public, max-age=3600') + headers.set('cache-control', CACHE_CONTROL) // Precondition matched (If-None-Match) → R2 returns no body. if (!('body' in object)) return new Response(null, { status: 304, headers }) diff --git a/apps/img/src/test/integration/api.test.ts b/apps/img/src/test/integration/api.test.ts index 514b427..6b6cb20 100644 --- a/apps/img/src/test/integration/api.test.ts +++ b/apps/img/src/test/integration/api.test.ts @@ -1,3 +1,4 @@ +import { PhotonImage } from '@cf-wasm/photon' import { env, SELF } from 'cloudflare:test' import { beforeAll, describe, expect, it } from 'vitest' @@ -11,32 +12,14 @@ declare module 'cloudflare:test' { const ORIGIN = 'https://example.com' -/** Read the pixel dimensions out of a JPEG's SOF marker, independent of Photon. */ +/** Decode a JPEG and read back its pixel dimensions. */ function jpegSize(bytes: Uint8Array): { width: number; height: number } { - let i = 2 // skip SOI (FFD8) - while (i + 1 < bytes.length) { - if (bytes[i] !== 0xff) { - i++ - continue - } - const marker = bytes[i + 1] - // Standalone markers carry no length payload. - if (marker === 0xd8 || marker === 0xd9 || (marker >= 0xd0 && marker <= 0xd7)) { - i += 2 - continue - } - const len = (bytes[i + 2] << 8) | bytes[i + 3] - const isSOF = - marker >= 0xc0 && marker <= 0xcf && marker !== 0xc4 && marker !== 0xc8 && marker !== 0xcc - if (isSOF) { - return { - height: (bytes[i + 5] << 8) | bytes[i + 6], - width: (bytes[i + 7] << 8) | bytes[i + 8], - } - } - i += 2 + len + const img = PhotonImage.new_from_byteslice(bytes) + try { + return { width: img.get_width(), height: img.get_height() } + } finally { + img.free() } - throw new Error('no SOF marker found') } // A tiny valid JPEG magic-number blob — enough to assert round-tripping. @@ -183,6 +166,29 @@ describe('img endpoints', () => { expect(resized.height).toBeCloseTo(Math.round((original.height / original.width) * 512), -0.5) }) + it('center-crops to a square and resizes with ?cropSquare=1&width', async () => { + const res = await SELF.fetch(`${ORIGIN}/RecCenter.jpg?width=256&cropSquare=1`) + expect(res.status).toBe(200) + expect(res.headers.get('content-type')).toBe('image/jpeg') + + const size = jpegSize(new Uint8Array(await res.arrayBuffer())) + expect(size.width).toBe(256) + expect(size.height).toBe(256) + }) + + it('crops to a square at native size with ?cropSquare=1 alone', async () => { + const full = jpegSize( + new Uint8Array(await (await SELF.fetch(`${ORIGIN}/RecCenter.jpg`)).arrayBuffer()) + ) + const res = await SELF.fetch(`${ORIGIN}/RecCenter.jpg?cropSquare=1`) + expect(res.status).toBe(200) + + const size = jpegSize(new Uint8Array(await res.arrayBuffer())) + expect(size.width).toBe(size.height) + // The square's side is the shorter source dimension. + expect(size.width).toBe(Math.min(full.width, full.height)) + }) + it('ignores an invalid ?width and serves the original', async () => { const full = new Uint8Array(await (await SELF.fetch(`${ORIGIN}/RecCenter.jpg`)).arrayBuffer()) const res = await SELF.fetch(`${ORIGIN}/RecCenter.jpg?width=0`)