Skip to content

Commit 787811f

Browse files
authored
perf(core): resize images in a worker and release photon memory when idle (#58820)
1 parent 2903845 commit 787811f

9 files changed

Lines changed: 544 additions & 133 deletions

File tree

‎packages/cli/script/build.ts‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -155,7 +155,7 @@ export default { path: file, version: ${JSON.stringify(opencodePty.version)}, sh
155155
const executablePath = await compileExecutable(item)
156156
console.log(`building ${name}`)
157157
const result = await Bun.build({
158-
entrypoints: ["./src/index.ts"],
158+
entrypoints: ["./src/index.ts", "./src/photon-worker.ts"],
159159
tsconfig: "./tsconfig.json",
160160
plugins: [
161161
appAssetsPlugin,

‎packages/cli/src/photon-worker.ts‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
// Second compile entrypoint: it lands next to index.js and the shared chunks in
2+
// the executable, where Image.Photon resolves "./photon-worker.js".
3+
import "../../core/src/image/photon-worker"
Lines changed: 148 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,148 @@
1+
export * as PhotonNormalize from "./photon-normalize.js"
2+
3+
import photonWasm from "#photon-wasm"
4+
import path from "node:path"
5+
import { fileURLToPath } from "node:url"
6+
import type { FileSystem } from "../filesystem.js"
7+
import type { Limits } from "../image.js"
8+
9+
// Plain data on purpose: this module runs on the server thread and inside the
10+
// photon worker, and its results cross postMessage.
11+
12+
export type Photon = typeof import("@silvia-odwyer/photon-node")
13+
export type Content = FileSystem.Content & { readonly encoding: "base64" }
14+
export type Size = {
15+
readonly width: number
16+
readonly height: number
17+
readonly bytes: number
18+
readonly maxWidth: number
19+
readonly maxHeight: number
20+
readonly maxBytes: number
21+
}
22+
// Unchanged lets the worker skip copying the caller's base64 back.
23+
export type Result =
24+
| { readonly _tag: "Unchanged" }
25+
| { readonly _tag: "Content"; readonly content: Content }
26+
| { readonly _tag: "DecodeError" }
27+
| ({ readonly _tag: "SizeError" } & Size)
28+
29+
const PNG_PASSTHROUGH_MAX_BASE64_BYTES = 384 * 1024
30+
const SMALL_PNG_MAX_PIXELS = 256 * 256
31+
const JPEG_QUALITIES = [80, 70, 55, 40]
32+
33+
// A runtime without a photon wasm artifact (#photon-wasm resolves to the empty
34+
// string on workerd) has no resizer, by declaration.
35+
export const available = photonWasm !== ""
36+
37+
export async function load(): Promise<Photon> {
38+
if (!available) throw new Error("photon wasm is unavailable")
39+
;(globalThis as typeof globalThis & { __OPENCODE_PHOTON_WASM_PATH?: string }).__OPENCODE_PHOTON_WASM_PATH =
40+
path.isAbsolute(photonWasm) ? photonWasm : fileURLToPath(new URL(photonWasm, import.meta.url))
41+
return await import("@silvia-odwyer/photon-node")
42+
}
43+
44+
// Base64 uses four bytes per three input bytes, including padding.
45+
const base64Length = (bytes: Uint8Array) => Math.ceil(bytes.length / 3) * 4
46+
47+
// Samples an evenly spaced grid of up to 64×64 pixels.
48+
function hasTransparency(pixels: Uint8Array, width: number, height: number) {
49+
const columns = Math.min(64, width)
50+
const rows = Math.min(64, height)
51+
let transparent = 0
52+
for (let row = 0; row < rows; row++) {
53+
const y = Math.floor(((row + 0.5) * height) / rows)
54+
for (let column = 0; column < columns; column++) {
55+
const x = Math.floor(((column + 0.5) * width) / columns)
56+
if (pixels[(y * width + x) * 4 + 3]! < 250) transparent++
57+
}
58+
}
59+
return transparent / (columns * rows) > 0.05
60+
}
61+
62+
export function normalize(photon: Photon, content: Content, limits: Readonly<Limits>): Result {
63+
let decoded: InstanceType<Photon["PhotonImage"]>
64+
try {
65+
decoded = photon.PhotonImage.new_from_byteslice(Buffer.from(content.content, "base64"))
66+
} catch {
67+
return { _tag: "DecodeError" }
68+
}
69+
try {
70+
const width = decoded.get_width()
71+
const height = decoded.get_height()
72+
const bytes = Buffer.byteLength(content.content, "utf-8")
73+
const sizeError: Result = {
74+
_tag: "SizeError",
75+
width,
76+
height,
77+
bytes,
78+
maxWidth: limits.maxWidth,
79+
maxHeight: limits.maxHeight,
80+
maxBytes: limits.maxBase64Bytes,
81+
}
82+
const fits = width <= limits.maxWidth && height <= limits.maxHeight && bytes <= limits.maxBase64Bytes
83+
const smallPng = bytes <= PNG_PASSTHROUGH_MAX_BASE64_BYTES || width * height <= SMALL_PNG_MAX_PIXELS
84+
if (fits && (!limits.autoResize || content.mime !== "image/png" || smallPng)) return { _tag: "Unchanged" }
85+
if (!limits.autoResize) return sizeError
86+
const transparent = content.mime !== "image/jpeg" && hasTransparency(decoded.get_raw_pixels(), width, height)
87+
// An image that already fits only changes when JPEG makes it smaller.
88+
if (fits) {
89+
if (transparent) return { _tag: "Unchanged" }
90+
const jpeg = decoded.get_bytes_jpeg(JPEG_QUALITIES[0]!)
91+
return base64Length(jpeg) < bytes
92+
? {
93+
_tag: "Content",
94+
content: {
95+
...content,
96+
content: Buffer.from(jpeg).toString("base64"),
97+
encoding: "base64",
98+
mime: "image/jpeg",
99+
},
100+
}
101+
: { _tag: "Unchanged" }
102+
}
103+
const scale = Math.min(1, limits.maxWidth / width, limits.maxHeight / height)
104+
const sizes = Array.from({ length: 32 }).reduce<Array<{ width: number; height: number }>>((acc) => {
105+
const previous = acc.at(-1) ?? {
106+
width: Math.max(1, Math.round(width * scale)),
107+
height: Math.max(1, Math.round(height * scale)),
108+
}
109+
const next =
110+
acc.length === 0
111+
? previous
112+
: {
113+
width: previous.width === 1 ? 1 : Math.max(1, Math.floor(previous.width * 0.75)),
114+
height: previous.height === 1 ? 1 : Math.max(1, Math.floor(previous.height * 0.75)),
115+
}
116+
return acc.some((item) => item.width === next.width && item.height === next.height) ? acc : [...acc, next]
117+
}, [])
118+
for (const size of sizes) {
119+
const resized =
120+
size.width === width && size.height === height
121+
? decoded
122+
: photon.resize(decoded, size.width, size.height, photon.SamplingFilter.Lanczos3)
123+
try {
124+
const pngEncoder = ["image/png", () => resized.get_bytes()] as const
125+
const jpegEncoders = JPEG_QUALITIES.map(
126+
(quality) => ["image/jpeg", () => resized.get_bytes_jpeg(quality)] as const,
127+
)
128+
const pngFirst = transparent || size.width * size.height <= SMALL_PNG_MAX_PIXELS
129+
const encoders: Array<readonly [mime: string, encode: () => Uint8Array]> = pngFirst
130+
? [pngEncoder, ...jpegEncoders]
131+
: [...jpegEncoders, pngEncoder]
132+
for (const [mime, encode] of encoders) {
133+
const candidate = encode()
134+
if (base64Length(candidate) <= limits.maxBase64Bytes)
135+
return {
136+
_tag: "Content",
137+
content: { ...content, content: Buffer.from(candidate).toString("base64"), encoding: "base64", mime },
138+
}
139+
}
140+
} finally {
141+
if (resized !== decoded) resized.free()
142+
}
143+
}
144+
return sizeError
145+
} finally {
146+
decoded.free()
147+
}
148+
}
Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
import { parentPort } from "node:worker_threads"
2+
import type { Limits } from "../image.js"
3+
import { PhotonNormalize } from "./photon-normalize.js"
4+
5+
export type Request = {
6+
readonly content: PhotonNormalize.Content
7+
readonly limits: Readonly<Limits>
8+
}
9+
export type Reply = { readonly _tag: "Ready" } | { readonly _tag: "Done"; readonly result: PhotonNormalize.Result }
10+
11+
const port = parentPort
12+
if (port) {
13+
// A photon load failure throws out of the worker before Ready, so the server
14+
// falls back to resizing in-process. A throw while resizing (a wasm panic
15+
// leaves the instance unusable) is left uncaught so it kills the worker
16+
// instead of serving later requests from a poisoned heap.
17+
void PhotonNormalize.load().then((photon) => {
18+
port.on("message", (request: Request) => {
19+
port.postMessage({
20+
_tag: "Done",
21+
result: PhotonNormalize.normalize(photon, request.content, request.limits),
22+
} satisfies Reply)
23+
})
24+
port.postMessage({ _tag: "Ready" } satisfies Reply)
25+
})
26+
}

0 commit comments

Comments
 (0)