mirror of
https://github.com/djdevin/recflare.git
synced 2026-09-08 22:51:30 -07:00
crop images
This commit is contained in:
+56
-25
@@ -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 { Hono } from 'hono'
|
||||||
import { useWorkersLogger } from 'workers-tagged-logger'
|
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. */
|
/** Static asset served (200) when the requested key is missing from R2. */
|
||||||
const FALLBACK_ASSET_PATH = '/DefaultProfileImage.jpg'
|
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. */
|
/** Upper bound on a requested output dimension; guards against abuse. */
|
||||||
const MAX_DIMENSION = 4096
|
const MAX_DIMENSION = 4096
|
||||||
|
|
||||||
/** JPEG quality used when re-encoding a resized image. */
|
/** JPEG quality used when re-encoding a resized image. */
|
||||||
const RESIZE_JPEG_QUALITY = 90
|
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 {
|
interface Transform {
|
||||||
width?: number
|
width?: number
|
||||||
height?: 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. */
|
/** 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. */
|
/** 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 w = parseDimension(width)
|
||||||
const h = parseDimension(height)
|
const h = parseDimension(height)
|
||||||
if (w === undefined && h === undefined) return null
|
const square = cropSquare === '1'
|
||||||
return { width: w, height: h }
|
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
|
* Decode `input`, apply the requested transform (optional center-crop to a
|
||||||
* given), and re-encode as JPEG. Runs the Photon WASM codec in-isolate — there
|
* square, then resize preserving aspect ratio when only one dimension is given),
|
||||||
* is no caching yet, so every request pays the full decode/resize/encode cost.
|
* 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 {
|
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 {
|
try {
|
||||||
const srcW = img.get_width()
|
if (transform.cropSquare) {
|
||||||
const srcH = img.get_height()
|
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
|
let { width, height } = transform
|
||||||
if (width !== undefined && height === undefined) {
|
if (width !== undefined || height !== undefined) {
|
||||||
height = Math.max(1, Math.round((srcH / srcW) * width))
|
const srcW = img.get_width()
|
||||||
} else if (height !== undefined && width === undefined) {
|
const srcH = img.get_height()
|
||||||
width = Math.max(1, Math.round((srcW / srcH) * height))
|
if (width !== undefined && height === undefined) {
|
||||||
}
|
height = Math.max(1, Math.round((srcH / srcW) * width))
|
||||||
const resized = resize(img, width!, height!, SamplingFilter.Lanczos3)
|
} else if (height !== undefined && width === undefined) {
|
||||||
try {
|
width = Math.max(1, Math.round((srcW / srcH) * height))
|
||||||
return resized.get_bytes_jpeg(RESIZE_JPEG_QUALITY)
|
}
|
||||||
} finally {
|
img = resize(img, width!, height!, SamplingFilter.Lanczos3)
|
||||||
resized.free()
|
owned.push(img)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return img.get_bytes_jpeg(RESIZE_JPEG_QUALITY)
|
||||||
} finally {
|
} finally {
|
||||||
img.free()
|
for (const image of owned) image.free()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -142,7 +169,7 @@ async function serveStaticAsset(
|
|||||||
const headers = new Headers()
|
const headers = new Headers()
|
||||||
const contentType = asset.headers.get('content-type')
|
const contentType = asset.headers.get('content-type')
|
||||||
if (contentType) headers.set('content-type', contentType)
|
if (contentType) headers.set('content-type', contentType)
|
||||||
headers.set('cache-control', 'public, max-age=3600')
|
headers.set('cache-control', CACHE_CONTROL)
|
||||||
|
|
||||||
if (transform || wantsSignature) {
|
if (transform || wantsSignature) {
|
||||||
const bytes = await asset.arrayBuffer()
|
const bytes = await asset.arrayBuffer()
|
||||||
@@ -180,7 +207,11 @@ const app = new Hono<App>()
|
|||||||
if (key.includes('..')) return c.body(null, 400)
|
if (key.includes('..')) return c.body(null, 400)
|
||||||
|
|
||||||
const wantsSignature = c.req.query('sig') === 'p1'
|
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
|
// 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/`)
|
// R2. This lets us ship canonical images (e.g. room thumbnails in `static/`)
|
||||||
@@ -209,7 +240,7 @@ const app = new Hono<App>()
|
|||||||
const headers = new Headers()
|
const headers = new Headers()
|
||||||
object.writeHttpMetadata(headers)
|
object.writeHttpMetadata(headers)
|
||||||
headers.set('etag', object.httpEtag)
|
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.
|
// Precondition matched (If-None-Match) → R2 returns no body.
|
||||||
if (!('body' in object)) return new Response(null, { status: 304, headers })
|
if (!('body' in object)) return new Response(null, { status: 304, headers })
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { PhotonImage } from '@cf-wasm/photon'
|
||||||
import { env, SELF } from 'cloudflare:test'
|
import { env, SELF } from 'cloudflare:test'
|
||||||
import { beforeAll, describe, expect, it } from 'vitest'
|
import { beforeAll, describe, expect, it } from 'vitest'
|
||||||
|
|
||||||
@@ -11,32 +12,14 @@ declare module 'cloudflare:test' {
|
|||||||
|
|
||||||
const ORIGIN = 'https://example.com'
|
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 } {
|
function jpegSize(bytes: Uint8Array): { width: number; height: number } {
|
||||||
let i = 2 // skip SOI (FFD8)
|
const img = PhotonImage.new_from_byteslice(bytes)
|
||||||
while (i + 1 < bytes.length) {
|
try {
|
||||||
if (bytes[i] !== 0xff) {
|
return { width: img.get_width(), height: img.get_height() }
|
||||||
i++
|
} finally {
|
||||||
continue
|
img.free()
|
||||||
}
|
|
||||||
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
|
|
||||||
}
|
}
|
||||||
throw new Error('no SOF marker found')
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// A tiny valid JPEG magic-number blob — enough to assert round-tripping.
|
// 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)
|
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 () => {
|
it('ignores an invalid ?width and serves the original', async () => {
|
||||||
const full = new Uint8Array(await (await SELF.fetch(`${ORIGIN}/RecCenter.jpg`)).arrayBuffer())
|
const full = new Uint8Array(await (await SELF.fetch(`${ORIGIN}/RecCenter.jpg`)).arrayBuffer())
|
||||||
const res = await SELF.fetch(`${ORIGIN}/RecCenter.jpg?width=0`)
|
const res = await SELF.fetch(`${ORIGIN}/RecCenter.jpg?width=0`)
|
||||||
|
|||||||
Reference in New Issue
Block a user