fix(deps): remove photon root runtime

This commit is contained in:
Vincent Koc
2026-05-30 16:27:48 +01:00
parent 6c7642b532
commit 84385898ec
8 changed files with 197 additions and 458 deletions
-1
View File
@@ -25,7 +25,6 @@
"@mozilla/readability": "0.6.0",
"@openclaw/fs-safe": "0.3.0",
"@openclaw/proxyline": "0.3.3",
"@silvia-odwyer/photon-node": "0.3.4",
"chalk": "5.6.2",
"chokidar": "5.0.0",
"clawpdf": "0.2.0",
-1
View File
@@ -1865,7 +1865,6 @@
"@mozilla/readability": "0.6.0",
"@openclaw/fs-safe": "0.3.0",
"@openclaw/proxyline": "0.3.3",
"@silvia-odwyer/photon-node": "0.3.4",
"chalk": "5.6.2",
"chokidar": "5.0.0",
"clawpdf": "0.2.0",
-3
View File
@@ -86,9 +86,6 @@ importers:
'@openclaw/proxyline':
specifier: 0.3.3
version: 0.3.3(undici@8.3.0)
'@silvia-odwyer/photon-node':
specifier: 0.3.4
version: 0.3.4
chalk:
specifier: 5.6.2
version: 5.6.2
@@ -28,10 +28,6 @@ const ROOT_OWNED_EXTENSION_RUNTIME_DEPENDENCIES = new Map([
"playwright-core",
"keep at root; the internal browser runtime is shipped with core even though downloadable browser-adjacent plugins also declare it",
],
[
"@silvia-odwyer/photon-node",
"keep at root; the internal media understanding runtime is shipped with packaged image-processing surfaces even though the bundled plugin also declares it",
],
]);
function readJson(filePath) {
-220
View File
@@ -1,220 +0,0 @@
import type { PhotonImageType } from "./photon.js";
type Photon = typeof import("@silvia-odwyer/photon-node");
function readOrientationFromTiff(bytes: Uint8Array, tiffStart: number): number {
if (tiffStart + 8 > bytes.length) {
return 1;
}
const byteOrder = (bytes[tiffStart] << 8) | bytes[tiffStart + 1];
const le = byteOrder === 0x4949;
const read16 = (pos: number): number => {
if (le) {
return bytes[pos] | (bytes[pos + 1] << 8);
}
return (bytes[pos] << 8) | bytes[pos + 1];
};
const read32 = (pos: number): number => {
if (le) {
return bytes[pos] | (bytes[pos + 1] << 8) | (bytes[pos + 2] << 16) | (bytes[pos + 3] << 24);
}
return (
((bytes[pos] << 24) | (bytes[pos + 1] << 16) | (bytes[pos + 2] << 8) | bytes[pos + 3]) >>> 0
);
};
const ifdOffset = read32(tiffStart + 4);
const ifdStart = tiffStart + ifdOffset;
if (ifdStart + 2 > bytes.length) {
return 1;
}
const entryCount = read16(ifdStart);
for (let i = 0; i < entryCount; i++) {
const entryPos = ifdStart + 2 + i * 12;
if (entryPos + 12 > bytes.length) {
return 1;
}
if (read16(entryPos) === 0x0112) {
const value = read16(entryPos + 8);
return value >= 1 && value <= 8 ? value : 1;
}
}
return 1;
}
function findJpegTiffOffset(bytes: Uint8Array): number {
let offset = 2;
while (offset < bytes.length - 1) {
if (bytes[offset] !== 0xff) {
return -1;
}
const marker = bytes[offset + 1];
if (marker === 0xff) {
offset++;
continue;
}
if (marker === 0xe1) {
if (offset + 4 >= bytes.length) {
return -1;
}
const segmentStart = offset + 4;
if (segmentStart + 6 > bytes.length) {
return -1;
}
if (!hasExifHeader(bytes, segmentStart)) {
return -1;
}
return segmentStart + 6;
}
if (offset + 4 > bytes.length) {
return -1;
}
const length = (bytes[offset + 2] << 8) | bytes[offset + 3];
offset += 2 + length;
}
return -1;
}
function findWebpTiffOffset(bytes: Uint8Array): number {
let offset = 12;
while (offset + 8 <= bytes.length) {
const chunkId = String.fromCharCode(
bytes[offset],
bytes[offset + 1],
bytes[offset + 2],
bytes[offset + 3],
);
const chunkSize =
bytes[offset + 4] |
(bytes[offset + 5] << 8) |
(bytes[offset + 6] << 16) |
(bytes[offset + 7] << 24);
const dataStart = offset + 8;
if (chunkId === "EXIF") {
if (dataStart + chunkSize > bytes.length) {
return -1;
}
// Some WebP files have "Exif\0\0" prefix before the TIFF header
const tiffStart =
chunkSize >= 6 && hasExifHeader(bytes, dataStart) ? dataStart + 6 : dataStart;
return tiffStart;
}
// RIFF chunks are padded to even size
offset = dataStart + chunkSize + (chunkSize % 2);
}
return -1;
}
function hasExifHeader(bytes: Uint8Array, offset: number): boolean {
return (
bytes[offset] === 0x45 &&
bytes[offset + 1] === 0x78 &&
bytes[offset + 2] === 0x69 &&
bytes[offset + 3] === 0x66 &&
bytes[offset + 4] === 0x00 &&
bytes[offset + 5] === 0x00
);
}
function getExifOrientation(bytes: Uint8Array): number {
let tiffOffset = -1;
// JPEG: starts with FF D8
if (bytes.length >= 2 && bytes[0] === 0xff && bytes[1] === 0xd8) {
tiffOffset = findJpegTiffOffset(bytes);
}
// WebP: starts with RIFF....WEBP
else if (
bytes.length >= 12 &&
bytes[0] === 0x52 &&
bytes[1] === 0x49 &&
bytes[2] === 0x46 &&
bytes[3] === 0x46 &&
bytes[8] === 0x57 &&
bytes[9] === 0x45 &&
bytes[10] === 0x42 &&
bytes[11] === 0x50
) {
tiffOffset = findWebpTiffOffset(bytes);
}
if (tiffOffset === -1) {
return 1;
}
return readOrientationFromTiff(bytes, tiffOffset);
}
type DstIndexFn = (x: number, y: number, w: number, h: number) => number;
function rotate90(photon: Photon, image: PhotonImageType, dstIndex: DstIndexFn): PhotonImageType {
const w = image.get_width();
const h = image.get_height();
const src = image.get_raw_pixels();
const dst = new Uint8Array(src.length);
for (let y = 0; y < h; y++) {
for (let x = 0; x < w; x++) {
const srcIdx = (y * w + x) * 4;
const dstIdx = dstIndex(x, y, w, h) * 4;
dst[dstIdx] = src[srcIdx];
dst[dstIdx + 1] = src[srcIdx + 1];
dst[dstIdx + 2] = src[srcIdx + 2];
dst[dstIdx + 3] = src[srcIdx + 3];
}
}
return new photon.PhotonImage(dst, h, w);
}
// Flip orientations mutate in-place. Rotations return a new image (caller must free the old one if different).
export function applyExifOrientation(
photon: Photon,
image: PhotonImageType,
originalBytes: Uint8Array,
): PhotonImageType {
const orientation = getExifOrientation(originalBytes);
if (orientation === 1) {
return image;
}
switch (orientation) {
case 2:
photon.fliph(image);
return image;
case 3:
photon.fliph(image);
photon.flipv(image);
return image;
case 4:
photon.flipv(image);
return image;
case 5: {
const rotated = rotate90(photon, image, (x, y, _w, h) => x * h + (h - 1 - y));
photon.fliph(rotated);
return rotated;
}
case 6:
return rotate90(photon, image, (x, y, _w, h) => x * h + (h - 1 - y));
case 7: {
const rotated = rotate90(photon, image, (x, y, w, h) => (w - 1 - x) * h + y);
photon.fliph(rotated);
return rotated;
}
case 8:
return rotate90(photon, image, (x, y, w, h) => (w - 1 - x) * h + y);
default:
return image;
}
}
+145
View File
@@ -0,0 +1,145 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
const mocks = vi.hoisted(() => ({
encode: vi.fn(),
probe: vi.fn(),
}));
vi.mock("../../media/image-ops.js", () => ({
createImageProcessor: () => ({
encode: mocks.encode,
probe: mocks.probe,
}),
isImageProcessorUnavailableError: (error: unknown) =>
typeof error === "object" &&
error !== null &&
"code" in error &&
error.code === "IMAGE_PROCESSOR_UNAVAILABLE",
}));
import { formatDimensionNote, resizeImage } from "./image-resize.js";
describe("image resize utility", () => {
beforeEach(() => {
mocks.encode.mockReset();
mocks.probe.mockReset();
});
it("keeps images that already fit the inline limits", async () => {
const input = Buffer.from("small image").toString("base64");
mocks.probe.mockResolvedValue({
bytes: 11,
format: "png",
hasAlpha: false,
height: 20,
orientation: null,
width: 10,
});
const resized = await resizeImage(
{ type: "image", data: input, mimeType: "image/png" },
{ maxWidth: 100, maxHeight: 100, maxBytes: 1_000 },
);
expect(resized).toMatchObject({
data: input,
height: 20,
mimeType: "image/png",
originalHeight: 20,
originalWidth: 10,
wasResized: false,
width: 10,
});
expect(mocks.encode).not.toHaveBeenCalled();
});
it("uses Rastermill limits, base64 budget, and orientation-aware source dimensions", async () => {
const inputBuffer = Buffer.from("large image");
const outputBuffer = Buffer.from("jpeg output");
mocks.probe.mockResolvedValue({
bytes: inputBuffer.byteLength,
format: "jpeg",
hasAlpha: false,
height: 1200,
orientation: 6,
width: 3000,
});
mocks.encode.mockResolvedValue({
base64Bytes: Buffer.byteLength(outputBuffer.toString("base64"), "utf8"),
bytes: outputBuffer.byteLength,
chosen: { format: "jpeg", quality: 70 },
data: outputBuffer,
format: "jpeg",
height: 1600,
metadata: "stripped",
mimeType: "image/jpeg",
resized: true,
width: 640,
withinBudget: true,
});
const resized = await resizeImage(
{ type: "image", data: inputBuffer.toString("base64"), mimeType: "image/jpeg" },
{ maxWidth: 2_000, maxHeight: 2_000, maxBytes: 4_000, jpegQuality: 70 },
);
expect(mocks.encode).toHaveBeenCalledWith(inputBuffer, {
format: "auto",
limits: {
maxHeight: 2_000,
maxWidth: 2_000,
},
maxBase64Bytes: 4_000,
opaque: { format: "jpeg", quality: 70 },
search: {
compressionLevel: [6, 9],
quality: [70, 85, 55, 40, 35],
},
transparent: { format: "png" },
});
expect(resized).toMatchObject({
data: outputBuffer.toString("base64"),
height: 1600,
mimeType: "image/jpeg",
originalHeight: 3000,
originalWidth: 1200,
wasResized: true,
width: 640,
});
expect(formatDimensionNote(resized!)).toBe(
"[Image: original 1200x3000, displayed at 640x1600. Multiply coordinates by 1.88 to map to original image.]",
);
});
it("returns null when Rastermill cannot satisfy the base64 budget", async () => {
const inputBuffer = Buffer.from("too large");
mocks.probe.mockResolvedValue({
bytes: inputBuffer.byteLength,
format: "png",
hasAlpha: false,
height: 4000,
orientation: null,
width: 4000,
});
mocks.encode.mockResolvedValue({
base64Bytes: 120,
bytes: 90,
chosen: { format: "png" },
data: Buffer.alloc(90),
format: "png",
height: 1,
metadata: "stripped",
mimeType: "image/png",
resized: true,
width: 1,
withinBudget: false,
});
await expect(
resizeImage(
{ type: "image", data: inputBuffer.toString("base64"), mimeType: "image/png" },
{ maxWidth: 100, maxHeight: 100, maxBytes: 50 },
),
).resolves.toBeNull();
});
});
+52 -90
View File
@@ -1,6 +1,9 @@
import type { ImageContent } from "../../llm/types.js";
import { applyExifOrientation } from "./exif-orientation.js";
import { loadPhoton } from "./photon.js";
import {
createImageProcessor,
isImageProcessorUnavailableError,
type ImageProbe,
} from "../../media/image-ops.js";
export interface ImageResizeOptions {
maxWidth?: number; // Default: 2000
@@ -35,8 +38,8 @@ interface EncodedCandidate {
mimeType: string;
}
function encodeCandidate(buffer: Uint8Array, mimeType: string): EncodedCandidate {
const data = Buffer.from(buffer).toString("base64");
function encodeCandidate(buffer: Buffer, mimeType: string): EncodedCandidate {
const data = buffer.toString("base64");
return {
data,
encodedSize: Buffer.byteLength(data, "utf-8"),
@@ -44,18 +47,24 @@ function encodeCandidate(buffer: Uint8Array, mimeType: string): EncodedCandidate
};
}
function orientedDimensions(probe: ImageProbe): { width: number; height: number } {
return probe.orientation && probe.orientation >= 5 && probe.orientation <= 8
? { width: probe.height, height: probe.width }
: { width: probe.width, height: probe.height };
}
/**
* Resize an image to fit within the specified max dimensions and encoded file size.
* Returns null if the image cannot be resized below maxBytes.
*
* Uses Photon (Rust/WASM) for image processing. If Photon is not available,
* Uses Rastermill for image processing. If no Rastermill backend is available,
* returns null.
*
* Strategy for staying under maxBytes:
* 1. First resize to maxWidth/maxHeight
* 2. Try both PNG and JPEG formats, pick the smaller one
* 3. If still too large, try JPEG with decreasing quality
* 4. If still too large, progressively reduce dimensions until 1x1
* 2. Let Rastermill choose JPEG or PNG for the image transparency profile
* 3. If still too large, search decreasing quality/compression settings
* 4. If still too large, progressively reduce dimensions
*/
export async function resizeImage(
img: ImageContent,
@@ -64,23 +73,14 @@ export async function resizeImage(
const opts = { ...DEFAULT_OPTIONS, ...options };
const inputBuffer = Buffer.from(img.data, "base64");
const inputBase64Size = Buffer.byteLength(img.data, "utf-8");
const processor = createImageProcessor();
const photon = await loadPhoton();
if (!photon) {
return null;
}
let image: ReturnType<typeof photon.PhotonImage.new_from_byteslice> | undefined;
try {
const inputBytes = new Uint8Array(inputBuffer);
const rawImage = photon.PhotonImage.new_from_byteslice(inputBytes);
image = applyExifOrientation(photon, rawImage, inputBytes);
if (image !== rawImage) {
rawImage.free();
const probe = await processor.probe(inputBuffer);
if (!probe) {
return null;
}
const originalWidth = image.get_width();
const originalHeight = image.get_height();
const { width: originalWidth, height: originalHeight } = orientedDimensions(probe);
const format = img.mimeType?.split("/")[1] ?? "png";
// Check if already within all limits (dimensions AND encoded size)
@@ -100,78 +100,40 @@ export async function resizeImage(
};
}
// Calculate initial dimensions respecting max limits
let targetWidth = originalWidth;
let targetHeight = originalHeight;
if (targetWidth > opts.maxWidth) {
targetHeight = Math.round((targetHeight * opts.maxWidth) / targetWidth);
targetWidth = opts.maxWidth;
}
if (targetHeight > opts.maxHeight) {
targetWidth = Math.round((targetWidth * opts.maxHeight) / targetHeight);
targetHeight = opts.maxHeight;
const qualitySteps = Array.from(new Set([opts.jpegQuality, 85, 70, 55, 40, 35]));
const output = await processor.encode(inputBuffer, {
format: "auto",
limits: {
maxWidth: opts.maxWidth,
maxHeight: opts.maxHeight,
},
maxBase64Bytes: opts.maxBytes,
opaque: { format: "jpeg", quality: opts.jpegQuality },
transparent: { format: "png" },
search: {
quality: qualitySteps,
compressionLevel: [6, 9],
},
});
const candidate = encodeCandidate(output.data, output.mimeType);
if (candidate.encodedSize > opts.maxBytes || output.withinBudget === false) {
return null;
}
function tryEncodings(
width: number,
height: number,
jpegQualities: number[],
): EncodedCandidate[] {
const resized = photon!.resize(image!, width, height, photon!.SamplingFilter.Lanczos3);
try {
const candidates: EncodedCandidate[] = [encodeCandidate(resized.get_bytes(), "image/png")];
for (const quality of jpegQualities) {
candidates.push(encodeCandidate(resized.get_bytes_jpeg(quality), "image/jpeg"));
}
return candidates;
} finally {
resized.free();
}
return {
data: candidate.data,
mimeType: candidate.mimeType,
originalWidth,
originalHeight,
width: output.width,
height: output.height,
wasResized: !output.data.equals(inputBuffer),
};
} catch (error) {
if (isImageProcessorUnavailableError(error)) {
return null;
}
const qualitySteps = Array.from(new Set([opts.jpegQuality, 85, 70, 55, 40]));
let currentWidth = targetWidth;
let currentHeight = targetHeight;
while (true) {
const candidates = tryEncodings(currentWidth, currentHeight, qualitySteps);
for (const candidate of candidates) {
if (candidate.encodedSize < opts.maxBytes) {
return {
data: candidate.data,
mimeType: candidate.mimeType,
originalWidth,
originalHeight,
width: currentWidth,
height: currentHeight,
wasResized: true,
};
}
}
if (currentWidth === 1 && currentHeight === 1) {
break;
}
const nextWidth = currentWidth === 1 ? 1 : Math.max(1, Math.floor(currentWidth * 0.75));
const nextHeight = currentHeight === 1 ? 1 : Math.max(1, Math.floor(currentHeight * 0.75));
if (nextWidth === currentWidth && nextHeight === currentHeight) {
break;
}
currentWidth = nextWidth;
currentHeight = nextHeight;
}
return null;
} catch {
return null;
} finally {
if (image) {
image.free();
}
}
}
-139
View File
@@ -1,139 +0,0 @@
/**
* Photon image processing wrapper.
*
* This module provides a unified interface to @silvia-odwyer/photon-node that works in:
* 1. Node.js (development, npm run build)
* 2. Bun compiled binaries (standalone distribution)
*
* The challenge: photon-node's CJS entry uses fs.readFileSync(__dirname + '/photon_rs_bg.wasm')
* which bakes the build machine's absolute path into Bun compiled binaries.
*
* Solution:
* 1. Patch fs.readFileSync to redirect missing photon_rs_bg.wasm reads
* 2. Copy photon_rs_bg.wasm next to the executable in standalone binary builds
*/
import type { PathOrFileDescriptor } from "node:fs";
import { createRequire } from "node:module";
import * as path from "node:path";
import { fileURLToPath } from "node:url";
const require = createRequire(import.meta.url);
const fs = require("node:fs") as typeof import("fs");
// Re-export types from the main package
export type { PhotonImage as PhotonImageType } from "@silvia-odwyer/photon-node";
type ReadFileSync = typeof fs.readFileSync;
const WASM_FILENAME = "photon_rs_bg.wasm";
// Lazy-loaded photon module
let photonModule: typeof import("@silvia-odwyer/photon-node") | null = null;
let loadPromise: Promise<typeof import("@silvia-odwyer/photon-node") | null> | null = null;
function pathOrNull(file: PathOrFileDescriptor): string | null {
if (typeof file === "string") {
return file;
}
if (file instanceof URL) {
return fileURLToPath(file);
}
return null;
}
function getFallbackWasmPaths(): string[] {
const execDir = path.dirname(process.execPath);
return [
path.join(execDir, WASM_FILENAME),
path.join(execDir, "photon", WASM_FILENAME),
path.join(process.cwd(), WASM_FILENAME),
];
}
function patchPhotonWasmRead(): () => void {
const originalReadFileSync: ReadFileSync = fs.readFileSync.bind(fs);
const fallbackPaths = getFallbackWasmPaths();
const mutableFs = fs as { readFileSync: ReadFileSync };
const patchedReadFileSync: ReadFileSync = ((...args: Parameters<ReadFileSync>) => {
const [file, options] = args;
const resolvedPath = pathOrNull(file);
if (resolvedPath?.endsWith(WASM_FILENAME)) {
try {
return originalReadFileSync(...args);
} catch (error) {
const err = error as NodeJS.ErrnoException;
if (err?.code && err.code !== "ENOENT") {
throw error;
}
for (const fallbackPath of fallbackPaths) {
if (!fs.existsSync(fallbackPath)) {
continue;
}
if (options === undefined) {
return originalReadFileSync(fallbackPath);
}
return originalReadFileSync(fallbackPath, options);
}
throw error;
}
}
return originalReadFileSync(...args);
}) as ReadFileSync;
try {
mutableFs.readFileSync = patchedReadFileSync;
} catch {
Object.defineProperty(fs, "readFileSync", {
value: patchedReadFileSync,
writable: true,
configurable: true,
});
}
return () => {
try {
mutableFs.readFileSync = originalReadFileSync;
} catch {
Object.defineProperty(fs, "readFileSync", {
value: originalReadFileSync,
writable: true,
configurable: true,
});
}
};
}
/**
* Load the photon module asynchronously.
* Returns cached module on subsequent calls.
*/
export async function loadPhoton(): Promise<typeof import("@silvia-odwyer/photon-node") | null> {
if (photonModule) {
return photonModule;
}
if (loadPromise) {
return loadPromise;
}
loadPromise = (async () => {
const restoreReadFileSync = patchPhotonWasmRead();
try {
photonModule = await import("@silvia-odwyer/photon-node");
return photonModule;
} catch {
photonModule = null;
return photonModule;
} finally {
restoreReadFileSync();
}
})();
return loadPromise;
}