Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 23 additions & 1 deletion packages/limner-cma-tools/src/tools/compose.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,17 @@ function base64ToBytes(b64: string): Uint8Array {
return out;
}

// M3 parity with @limner/mcp: cap the in-isolate output pixel count so a caller
// can't request a multi-GB RGBA allocation and OOM the 128 MB V8 isolate.
const MAX_COMPOSE_PIXELS = 10_000_000;
function assertPixelCap(width: number, height: number, op: string): void {
if (width * height > MAX_COMPOSE_PIXELS) {
throw new Error(
`compose.${op}: ${width}x${height} exceeds the ${MAX_COMPOSE_PIXELS}-pixel in-isolate limit. Use cfTransform/cfSmartCrop (Cloudflare Images) for larger output.`,
);
}
}

function bytesToBase64(bytes: Uint8Array): string {
let bin = '';
for (let i = 0; i < bytes.length; i++) bin += String.fromCharCode(bytes[i]!);
Expand Down Expand Up @@ -70,11 +81,13 @@ export const composeMcpTool: CustomTool = defineTool({
switch (op) {
case 'resize': {
const i = input as { input: string; width: number; height: number; fit?: FitMode };
assertPixelCap(i.width, i.height, 'resize');
const bytes = compose.resize(base64ToBytes(i.input), i.width, i.height, i.fit ?? 'cover');
return emitImage(env, bytes, 'image/png', 'resize');
}
case 'crop': {
const i = input as { input: string; x: number; y: number; width: number; height: number };
assertPixelCap(i.width, i.height, 'crop');
const bytes = compose.crop(base64ToBytes(i.input), i.x, i.y, i.width, i.height);
return emitImage(env, bytes, 'image/png', 'crop');
}
Expand Down Expand Up @@ -110,8 +123,16 @@ export const composeMcpTool: CustomTool = defineTool({
format: 'jpeg' | 'png' | 'webp' | 'avif';
quality?: number;
};
assertPixelCap(i.raw.width, i.raw.height, 'encode');
const rawBytes = base64ToBytes(i.raw.data);
const expected = i.raw.width * i.raw.height * 4;
if (rawBytes.length !== expected) {
throw new Error(
`compose.encode: raw.data is ${rawBytes.length} bytes but ${i.raw.width}x${i.raw.height} RGBA requires ${expected}.`,
);
}
const raw = {
data: new Uint8ClampedArray(base64ToBytes(i.raw.data).buffer),
data: new Uint8ClampedArray(rawBytes.buffer, rawBytes.byteOffset, rawBytes.byteLength),
width: i.raw.width,
height: i.raw.height,
};
Expand Down Expand Up @@ -155,6 +176,7 @@ export const composeMcpTool: CustomTool = defineTool({
height: number;
fonts?: Array<{ fontId: string; name?: string; weight?: number; style?: 'normal' | 'italic' }>;
};
assertPixelCap(i.width, i.height, 'renderText');
// Fonts resolve to server-side bytes by id (no inline base64; large
// fonts truncate on the wire). Omitted fonts -> the default built-in.
const specs =
Expand Down
24 changes: 24 additions & 0 deletions packages/limner-cma-tools/test/tools/compose.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,30 @@ describe('cma compose dispatch — photon op (resize)', () => {
});
});

describe('cma compose dispatch — resource guards (M3 parity)', () => {
test('resize rejects an oversize pixel count', async () => {
const { bucket, put } = mockBucket();
await expect(
composeTool.run(
{ op: 'resize', input: b64encode(fixture), width: 20000, height: 20000 },
{ env: { BUCKET: bucket } },
),
).rejects.toThrow(/exceeds|pixel/i);
expect(put).not.toHaveBeenCalled();
});

test('encode rejects a raw buffer whose length != width*height*4', async () => {
const { bucket, put } = mockBucket();
await expect(
composeTool.run(
{ op: 'encode', raw: { data: b64encode(new Uint8Array([1, 2, 3, 4])), width: 2, height: 2 }, format: 'png' },
{ env: { BUCKET: bucket } },
),
).rejects.toThrow(/raw\.data|RGBA/i);
expect(put).not.toHaveBeenCalled();
});
});

describe('cma compose dispatch — codec op (convert)', () => {
test('routes through jsquash and uploads image/jpeg under compose-convert/', async () => {
const { bucket, put } = mockBucket();
Expand Down
107 changes: 67 additions & 40 deletions packages/limner-core/src/compose/photon-ops.ts
Original file line number Diff line number Diff line change
Expand Up @@ -55,12 +55,19 @@ export function resize(
height: number,
_fit: FitMode = 'cover',
): Uint8Array {
const img = PhotonImage.new_from_byteslice(input);
const out = photonResize(img, width, height, SamplingFilter.Lanczos3);
const bytes = out.get_bytes();
img.free();
out.free();
return bytes;
// try/finally so the decoded WASM image is freed even when an op throws
// (undecodable bytes, out-of-bounds args); otherwise the allocation leaks and
// accumulates toward the 128 MB isolate cap across requests (M2).
let img: PhotonImage | undefined;
let out: PhotonImage | undefined;
try {
img = PhotonImage.new_from_byteslice(input);
out = photonResize(img, width, height, SamplingFilter.Lanczos3);
return out.get_bytes();
} finally {
img?.free();
out?.free();
}
}

/**
Expand All @@ -79,59 +86,75 @@ export function crop(
width: number,
height: number,
): Uint8Array {
const img = PhotonImage.new_from_byteslice(input);
const out = photonCrop(img, x, y, x + width, y + height);
const bytes = out.get_bytes();
img.free();
out.free();
return bytes;
let img: PhotonImage | undefined;
let out: PhotonImage | undefined;
try {
img = PhotonImage.new_from_byteslice(input);
out = photonCrop(img, x, y, x + width, y + height);
return out.get_bytes();
} finally {
img?.free();
out?.free();
}
}

/**
* Adjust brightness by `delta`. Photon's signed-int offset semantics:
* positive brightens, negative darkens. Typical range: -50..+50.
*/
export function brightness(input: Uint8Array, delta: number): Uint8Array {
const img = PhotonImage.new_from_byteslice(input);
adjust_brightness(img, delta);
const bytes = img.get_bytes();
img.free();
return bytes;
let img: PhotonImage | undefined;
try {
img = PhotonImage.new_from_byteslice(input);
adjust_brightness(img, delta);
return img.get_bytes();
} finally {
img?.free();
}
}

/**
* Adjust contrast by `factor`. Photon's float multiplier: 1.0 is no change,
* >1 increases contrast, 0..1 decreases. Typical range: 0.5..2.0.
*/
export function contrast(input: Uint8Array, factor: number): Uint8Array {
const img = PhotonImage.new_from_byteslice(input);
adjust_contrast(img, factor);
const bytes = img.get_bytes();
img.free();
return bytes;
let img: PhotonImage | undefined;
try {
img = PhotonImage.new_from_byteslice(input);
adjust_contrast(img, factor);
return img.get_bytes();
} finally {
img?.free();
}
}

/**
* Gaussian blur with a given pixel radius. Typical range: 1..20.
*/
export function blur(input: Uint8Array, radius: number): Uint8Array {
const img = PhotonImage.new_from_byteslice(input);
gaussian_blur(img, radius);
const bytes = img.get_bytes();
img.free();
return bytes;
let img: PhotonImage | undefined;
try {
img = PhotonImage.new_from_byteslice(input);
gaussian_blur(img, radius);
return img.get_bytes();
} finally {
img?.free();
}
}

/**
* Unsharp-mask sharpening. Photon offers a parameter-free `sharpen`;
* for finer control, multiple passes can be chained.
*/
export function sharpen(input: Uint8Array): Uint8Array {
const img = PhotonImage.new_from_byteslice(input);
photonSharpen(img);
const bytes = img.get_bytes();
img.free();
return bytes;
let img: PhotonImage | undefined;
try {
img = PhotonImage.new_from_byteslice(input);
photonSharpen(img);
return img.get_bytes();
} finally {
img?.free();
}
}

/**
Expand All @@ -148,12 +171,16 @@ export function watermark(
x: number,
y: number,
): Uint8Array {
const baseImg = PhotonImage.new_from_byteslice(base);
const overlayImg = PhotonImage.new_from_byteslice(overlay);
// Photon's watermark takes BigInt for x,y (Rust u32 -> wasm i64 binding).
photonWatermark(baseImg, overlayImg, BigInt(x), BigInt(y));
const bytes = baseImg.get_bytes();
baseImg.free();
overlayImg.free();
return bytes;
let baseImg: PhotonImage | undefined;
let overlayImg: PhotonImage | undefined;
try {
baseImg = PhotonImage.new_from_byteslice(base);
overlayImg = PhotonImage.new_from_byteslice(overlay);
// Photon's watermark takes BigInt for x,y (Rust u32 -> wasm i64 binding).
photonWatermark(baseImg, overlayImg, BigInt(x), BigInt(y));
return baseImg.get_bytes();
} finally {
baseImg?.free();
overlayImg?.free();
}
}
64 changes: 64 additions & 0 deletions packages/limner-core/test/compose/photon-ops.free.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
import { describe, expect, test, vi } from 'vitest';

// M2 (release review): every photon-ops wrapper must free its WASM-allocated
// PhotonImage(s) even when the op throws (out-of-bounds crop, undecodable
// bytes), or the decoded image buffer leaks and accumulates toward the 128 MB
// isolate cap. We mock @cf-wasm/photon so the op functions throw mid-way and
// assert .free() still runs (the try/finally). This file mocks the module, so
// it is kept separate from photon-ops.test.ts which exercises the real WASM.

const freeSpy = vi.fn();

vi.mock('@cf-wasm/photon', () => {
class PhotonImage {
free = freeSpy;
get_bytes(): Uint8Array {
return new Uint8Array([1]);
}
static new_from_byteslice(): PhotonImage {
return new PhotonImage();
}
}
const boom = () => {
throw new Error('photon boom');
};
return {
PhotonImage,
SamplingFilter: { Lanczos3: 0 },
resize: boom,
crop: boom,
adjust_brightness: boom,
adjust_contrast: boom,
gaussian_blur: boom,
sharpen: boom,
watermark: boom,
};
});

const { resize, crop, brightness, contrast, blur, sharpen, watermark } = await import(
'../../src/compose/photon-ops.js'
);

const bytes = new Uint8Array([1, 2, 3]);

describe('photon-ops free WASM allocations on throw (M2)', () => {
test.each([
['resize', () => resize(bytes, 10, 10)],
['crop', () => crop(bytes, 0, 0, 10, 10)],
['brightness', () => brightness(bytes, 10)],
['contrast', () => contrast(bytes, 1.2)],
['blur', () => blur(bytes, 2)],
['sharpen', () => sharpen(bytes)],
])('%s frees its image when the op throws', (_name, call) => {
freeSpy.mockClear();
expect(call).toThrow('photon boom');
expect(freeSpy).toHaveBeenCalled();
});

test('watermark frees BOTH images when the op throws', () => {
freeSpy.mockClear();
expect(() => watermark(bytes, bytes, 8, 8)).toThrow('photon boom');
// base + overlay both allocated before the throw -> both freed.
expect(freeSpy).toHaveBeenCalledTimes(2);
});
});
37 changes: 34 additions & 3 deletions packages/limner-mcp/src/tools/compose.ts
Original file line number Diff line number Diff line change
Expand Up @@ -238,6 +238,21 @@ function errorResult(message: string): CallToolResult {
return { content: [{ type: 'text', text: message }], isError: true };
}

// M3: in-isolate ops decode/allocate raw RGBA in the 128 MB V8 isolate; photon
// documents a ~10 MP practical ceiling. Cap the output pixel count so a caller
// can't request a multi-GB allocation (e.g. 100000x100000) and OOM the isolate.
// Oversize work should route through the cf-images (network) ops.
const MAX_COMPOSE_PIXELS = 10_000_000;

function pixelCapError(width: number, height: number, op: string): CallToolResult | null {
if (width * height > MAX_COMPOSE_PIXELS) {
return errorResult(
`compose.${op}: ${width}x${height} exceeds the ${MAX_COMPOSE_PIXELS}-pixel in-isolate limit. Use cfTransform/cfSmartCrop (Cloudflare Images) for larger output.`,
);
}
return null;
}

// ---------------- handler ----------------

async function handle(raw: ComposeAdvertisedInput, ctx: ToolContext): Promise<CallToolResult> {
Expand All @@ -252,9 +267,11 @@ async function handle(raw: ComposeAdvertisedInput, ctx: ToolContext): Promise<Ca
const input: ComposeInput = parsed.data;
switch (input.op) {
case 'resize':
return imageResult(compose.resize(base64ToBytes(input.input), input.width, input.height, input.fit), 'image/png', { op: 'resize' });
return pixelCapError(input.width, input.height, 'resize')
?? imageResult(compose.resize(base64ToBytes(input.input), input.width, input.height, input.fit), 'image/png', { op: 'resize' });
case 'crop':
return imageResult(compose.crop(base64ToBytes(input.input), input.x, input.y, input.width, input.height), 'image/png', { op: 'crop' });
return pixelCapError(input.width, input.height, 'crop')
?? imageResult(compose.crop(base64ToBytes(input.input), input.x, input.y, input.width, input.height), 'image/png', { op: 'crop' });
case 'brightness':
return imageResult(compose.brightness(base64ToBytes(input.input), input.delta), 'image/png', { op: 'brightness' });
case 'contrast':
Expand All @@ -267,8 +284,20 @@ async function handle(raw: ComposeAdvertisedInput, ctx: ToolContext): Promise<Ca
return imageResult(compose.watermark(base64ToBytes(input.base), base64ToBytes(input.overlay), input.x, input.y), 'image/png', { op: 'watermark' });

case 'encode': {
const cap = pixelCapError(input.raw.width, input.raw.height, 'encode');
if (cap) return cap;
// The encoder reads width*height*4 bytes from the raw buffer; reject a
// mismatched length up front so it can't read past the supplied data
// (corrupt output) or pair a tiny buffer with huge declared dimensions.
const rawBytes = base64ToBytes(input.raw.data);
const expected = input.raw.width * input.raw.height * 4;
if (rawBytes.length !== expected) {
return errorResult(
`compose.encode: raw.data is ${rawBytes.length} bytes but ${input.raw.width}x${input.raw.height} RGBA requires ${expected}.`,
);
}
const raw = {
data: new Uint8ClampedArray(base64ToBytes(input.raw.data).buffer),
data: new Uint8ClampedArray(rawBytes.buffer, rawBytes.byteOffset, rawBytes.byteLength),
width: input.raw.width,
height: input.raw.height,
};
Expand All @@ -295,6 +324,8 @@ async function handle(raw: ComposeAdvertisedInput, ctx: ToolContext): Promise<Ca
}

case 'renderText': {
const cap = pixelCapError(input.width, input.height, 'renderText');
if (cap) return cap;
// Fonts resolve to server-side bytes by id; the tool never transits font
// bytes inline (a large base64 font truncates on the MCP wire and
// corrupts the WASM DataView). Omitted fonts -> the default built-in.
Expand Down
Loading