mirror of
https://github.com/openclaw/openclaw.git
synced 2026-07-21 10:16:44 +00:00
fix(deps): remove photon root runtime
This commit is contained in:
Generated
-1
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
Generated
-3
@@ -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) {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user