mirror of
https://github.com/djdevin/recflare.git
synced 2026-09-09 07:01:27 -07:00
[api,econ,img] shirts fix #35
This commit is contained in:
+34
-8
@@ -36,6 +36,18 @@ const ALLOWED_DIMENSIONS = new Set([128, 256, 512, 1024])
|
||||
/** JPEG quality used when re-encoding a resized image. */
|
||||
const RESIZE_JPEG_QUALITY = 90
|
||||
|
||||
/** The eight-byte PNG signature. */
|
||||
const PNG_SIGNATURE = [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]
|
||||
|
||||
/**
|
||||
* Whether these bytes are a PNG, by signature rather than the stored content-type: the
|
||||
* type on an R2 object is whatever the uploader claimed, and a custom avatar item's
|
||||
* design is posted under a `.bin` filename.
|
||||
*/
|
||||
function isPng(bytes: Uint8Array): boolean {
|
||||
return PNG_SIGNATURE.every((b, i) => bytes[i] === b)
|
||||
}
|
||||
|
||||
/** A requested transform, from `?width=`/`?height=`/`?cropSquare=1`. At least one applies. */
|
||||
interface Transform {
|
||||
width?: number
|
||||
@@ -65,13 +77,22 @@ function parseTransform(
|
||||
return { width: w, height: h, cropSquare: square }
|
||||
}
|
||||
|
||||
/** A transformed image and the type it was encoded as. */
|
||||
interface Transformed {
|
||||
bytes: Uint8Array
|
||||
contentType: 'image/png' | 'image/jpeg'
|
||||
}
|
||||
|
||||
/**
|
||||
* 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
|
||||
* and re-encode. A PNG source stays PNG — JPEG has no alpha channel, and flattening
|
||||
* a transparent design onto black is what broke custom avatar items in-game;
|
||||
* anything else becomes 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): Transformed {
|
||||
const png = isPng(input)
|
||||
let img = PhotonImage.new_from_byteslice(input)
|
||||
// Every PhotonImage we allocate (source + each stage) must be freed.
|
||||
const owned = [img]
|
||||
@@ -99,7 +120,9 @@ function resizeImage(input: Uint8Array, transform: Transform): Uint8Array {
|
||||
owned.push(img)
|
||||
}
|
||||
|
||||
return img.get_bytes_jpeg(RESIZE_JPEG_QUALITY)
|
||||
return png
|
||||
? { bytes: img.get_bytes(), contentType: 'image/png' }
|
||||
: { bytes: img.get_bytes_jpeg(RESIZE_JPEG_QUALITY), contentType: 'image/jpeg' }
|
||||
} finally {
|
||||
for (const image of owned) image.free()
|
||||
}
|
||||
@@ -238,9 +261,11 @@ async function finalizeImage(
|
||||
): Promise<Response> {
|
||||
let body: BufferSource = bytes
|
||||
if (transform) {
|
||||
body = resizeImage(new Uint8Array(bytes), transform)
|
||||
// Output is always JPEG, and the source etag no longer describes the body.
|
||||
headers.set('content-type', 'image/jpeg')
|
||||
const out = resizeImage(new Uint8Array(bytes), transform)
|
||||
body = out.bytes
|
||||
// The output is re-encoded (PNG stays PNG, everything else is JPEG), and the
|
||||
// source etag no longer describes the body.
|
||||
headers.set('content-type', out.contentType)
|
||||
headers.delete('etag')
|
||||
}
|
||||
|
||||
@@ -370,8 +395,9 @@ app.get(
|
||||
'Responses carry `Cache-Control: public, max-age=31536000, immutable` — an uploaded',
|
||||
'image is never rewritten in place, a new image gets a new key.',
|
||||
'',
|
||||
'`?width`/`?height`/`?cropSquare=1` run the body through the Photon codec and always',
|
||||
'return JPEG with no `ETag` (the source etag no longer describes the body), and the',
|
||||
'`?width`/`?height`/`?cropSquare=1` run the body through the Photon codec and',
|
||||
're-encode it — a PNG source stays PNG (alpha preserved), anything else becomes JPEG —',
|
||||
'with no `ETag` (the source etag no longer describes the body), and the',
|
||||
'`If-None-Match` precondition is skipped. An out-of-range or non-integer dimension is',
|
||||
'ignored and the original is served — never an error.',
|
||||
'',
|
||||
|
||||
@@ -28,7 +28,8 @@ export function json(schema: z.ZodType, description: string) {
|
||||
/**
|
||||
* An image-bytes response. The stored object's own content type is served verbatim
|
||||
* (usually `image/jpeg`, occasionally `image/png`); any response that went through the
|
||||
* Photon resize/crop path is re-encoded and is always `image/jpeg`.
|
||||
* Photon resize/crop path is re-encoded: `image/png` when the source is a PNG (so alpha
|
||||
* survives), `image/jpeg` otherwise.
|
||||
*/
|
||||
export function imageBytes(description: string): OpenAPIV3_1.ResponseObject {
|
||||
const schema: OpenAPIV3_1.SchemaObject = { type: 'string', format: 'binary' }
|
||||
|
||||
@@ -344,6 +344,45 @@ describe('img endpoints', () => {
|
||||
expect(size.height).toBe(256)
|
||||
})
|
||||
|
||||
it('keeps a PNG source as PNG when resizing, alpha intact', async () => {
|
||||
// A 64×64 RGBA PNG: left half opaque red, right half fully transparent. Built
|
||||
// through Photon so the bytes are a real PNG with an alpha channel.
|
||||
const side = 64
|
||||
const rgba = new Uint8Array(side * side * 4)
|
||||
for (let y = 0; y < side; y++) {
|
||||
for (let x = 0; x < side; x++) {
|
||||
const i = (y * side + x) * 4
|
||||
rgba[i] = 255
|
||||
rgba[i + 3] = x < side / 2 ? 255 : 0
|
||||
}
|
||||
}
|
||||
const src = new PhotonImage(rgba, side, side)
|
||||
const pngBytes = src.get_bytes()
|
||||
src.free()
|
||||
// Stored under a `.bin` name with a claimed PNG type, like a custom avatar item's
|
||||
// design; the codec must go by the bytes, not the name.
|
||||
await env.IMAGES.put('avatar-item/2026-08-26/abc-design.bin', pngBytes, {
|
||||
httpMetadata: { contentType: 'image/png' },
|
||||
})
|
||||
|
||||
const res = await SELF.fetch(`${ORIGIN}/avatar-item/2026-08-26/abc-design.bin?width=128`)
|
||||
expect(res.status).toBe(200)
|
||||
expect(res.headers.get('content-type')).toBe('image/png')
|
||||
expect(res.headers.get('etag')).toBeNull()
|
||||
|
||||
const out = PhotonImage.new_from_byteslice(new Uint8Array(await res.arrayBuffer()))
|
||||
try {
|
||||
expect(out.get_width()).toBe(128)
|
||||
const px = out.get_raw_pixels()
|
||||
const alphaAt = (x: number, y: number) => px[(y * 128 + x) * 4 + 3]
|
||||
// Deep inside each half, away from the Lanczos edge: opaque left, transparent right.
|
||||
expect(alphaAt(16, 64)).toBe(255)
|
||||
expect(alphaAt(112, 64)).toBe(0)
|
||||
} finally {
|
||||
out.free()
|
||||
}
|
||||
})
|
||||
|
||||
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())
|
||||
|
||||
Reference in New Issue
Block a user