diff --git a/src/cloudflare/internal/images-api.ts b/src/cloudflare/internal/images-api.ts index 42c4aad2916f..cc5df83e7aa9 100644 --- a/src/cloudflare/internal/images-api.ts +++ b/src/cloudflare/internal/images-api.ts @@ -6,6 +6,16 @@ type Fetcher = { fetch: typeof fetch; }; +type TargetedTransform = ImageTransform & { + imageIndex: number; +}; + +// Draw image drawImageIndex on image targetImageIndex +type DrawCommand = ImageDrawOptions & { + drawImageIndex: number; + targetImageIndex: number; +}; + type RawInfoResponse = | { format: 'image/svg+xml' } | { @@ -51,8 +61,15 @@ async function streamToBlob(stream: ReadableStream): Promise { return new Response(stream).blob(); } +class DrawTransformer { + public constructor( + public readonly child: ImageTransformerImpl, + public readonly options: ImageDrawOptions + ) {} +} + class ImageTransformerImpl implements ImageTransformer { - private transforms: ImageTransform[]; + private transforms: (ImageTransform | DrawTransformer)[]; private consumed: boolean; public constructor( @@ -68,19 +85,38 @@ class ImageTransformerImpl implements ImageTransformer { return this; } - public async output( - options: ImageOutputOptions - ): Promise { - if (this.consumed) { - throw new ImagesErrorImpl( - 'IMAGES_TRANSFORM_ERROR 9525: ImageTransformer consumed; you may only call .output() once', - 9525 + public draw( + image: ReadableStream | ImageTransformer, + options?: ImageDrawOptions + ): this { + if (isTransformer(image)) { + image.consume(); + this.transforms.push(new DrawTransformer(image, options || {})); + } else { + this.transforms.push( + new DrawTransformer( + new ImageTransformerImpl( + this.fetcher, + image as ReadableStream + ), + options || {} + ) ); } - this.consumed = true; + return this; + } + + public async output( + options: ImageOutputOptions + ): Promise { const body = new FormData(); + + this.consume(); body.append('image', await streamToBlob(this.stream)); + + await this.serializeTransforms(body); + body.append('output_format', options.format); if (options.quality !== undefined) { body.append('output_quality', options.quality.toString()); @@ -90,8 +126,6 @@ class ImageTransformerImpl implements ImageTransformer { body.append('background', options.background); } - body.append('transforms', JSON.stringify(this.transforms)); - const response = await this.fetcher.fetch( 'https://js.images.cloudflare.com/transform', { @@ -104,6 +138,75 @@ class ImageTransformerImpl implements ImageTransformer { return new TransformationResultImpl(response); } + + private consume(): void { + if (this.consumed) { + throw new ImagesErrorImpl( + 'IMAGES_TRANSFORM_ERROR 9525: ImageTransformer consumed; you may only call .output() or draw a transformer once', + 9525 + ); + } + + this.consumed = true; + } + + private async serializeTransforms(body: FormData): Promise { + const transforms: (TargetedTransform | DrawCommand)[] = []; + + // image 0 is the canvas, so the first draw_image has index 1 + let drawImageIndex = 1; + function appendDrawImage(blob: Blob): number { + body.append('draw_image', blob); + return drawImageIndex++; + } + + async function walkTransforms( + targetImageIndex: number, + imageTransforms: (ImageTransform | DrawTransformer)[] + ): Promise { + for (const transform of imageTransforms) { + if (!isDrawTransformer(transform)) { + // Simple transformation - we just have to tell the backend to run it + // against this image + transforms.push({ + imageIndex: targetImageIndex, + ...transform, + }); + } else if (isDrawTransformer(transform)) { + // Drawn child image + // Set the input for the drawn image on the form + const drawImageIndex = appendDrawImage( + await streamToBlob(transform.child.stream) + ); + + // Tell the backend to run any transforms (possibly involving more draws) + // required to build this child + await walkTransforms(drawImageIndex, transform.child.transforms); + + // Draw the child image on to the canvas + transforms.push({ + drawImageIndex: drawImageIndex, + targetImageIndex: targetImageIndex, + ...transform.options, + }); + } + } + } + + await walkTransforms(0, this.transforms); + body.append('transforms', JSON.stringify(transforms)); + } +} + +// Allow any as these are type guards +// eslint-disable-next-line @typescript-eslint/no-explicit-any +function isTransformer(input: any): input is ImageTransformerImpl { + return input instanceof ImageTransformerImpl; +} + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +function isDrawTransformer(input: any): input is DrawTransformer { + return input instanceof DrawTransformer; } class ImagesBindingImpl implements ImagesBinding { diff --git a/src/cloudflare/internal/images.d.ts b/src/cloudflare/internal/images.d.ts index 6524c2f081e1..04781087cc6f 100644 --- a/src/cloudflare/internal/images.d.ts +++ b/src/cloudflare/internal/images.d.ts @@ -62,6 +62,15 @@ type ImageTransform = { zoom?: number; }; +type ImageDrawOptions = { + opacity?: number; + repeat?: boolean | string; + top?: number; + left?: number; + bottom?: number; + right?: number; +}; + type ImageOutputOptions = { format: | 'image/jpeg' @@ -93,10 +102,22 @@ interface ImagesBinding { interface ImageTransformer { /** * Apply transform next, returning a transform handle. - * You can then apply more transformations or retrieve the output. + * You can then apply more transformations, draw, or retrieve the output. * @param transform */ transform(transform: ImageTransform): ImageTransformer; + + /** + * Draw an image on this transformer, returning a transform handle. + * You can then apply more transformations, draw, or retrieve the output. + * @param image The image (or transformer that will give the image) to draw + * @param options The options configuring how to draw the image + */ + draw( + image: ReadableStream | ImageTransformer, + options?: ImageDrawOptions + ): ImageTransformer; + /** * Retrieve the image that results from applying the transforms to the * provided input diff --git a/src/cloudflare/internal/test/images/images-api-test.js b/src/cloudflare/internal/test/images/images-api-test.js index 21e42005bb9f..0a461a8dd1fe 100644 --- a/src/cloudflare/internal/test/images/images-api-test.js +++ b/src/cloudflare/internal/test/images/images-api-test.js @@ -4,6 +4,10 @@ // @ts-ignore import * as assert from 'node:assert'; +function inputStream(body) { + return new Blob([body]).stream(); +} + /** * @typedef {{'images': ImagesBinding}} Env * @@ -15,8 +19,7 @@ export const test_images_info_bitmap = { * @param {Env} env */ async test(_, env) { - const blob = new Blob(['png']); - const info = await env.images.info(blob.stream()); + const info = await env.images.info(inputStream('png')); assert.deepStrictEqual(info, { format: 'image/png', fileSize: 123, @@ -32,8 +35,7 @@ export const test_images_info_svg = { * @param {Env} env */ async test(_, env) { - const blob = new Blob(['']); - const info = await env.images.info(blob.stream()); + const info = await env.images.info(inputStream('')); assert.deepStrictEqual(info, { format: 'image/svg+xml', }); @@ -46,15 +48,13 @@ export const test_images_info_error = { * @param {Env} env */ async test(_, env) { - const blob = new Blob(['BAD']); - /** * @type {any} e; */ let e; try { - await env.images.info(blob.stream()); + await env.images.info(inputStream('BAD')); } catch (e2) { e = e2; } @@ -86,20 +86,126 @@ export const test_images_transform = { assert.deepStrictEqual(body, { image: 'png', output_format: 'image/avif', - transforms: [{ rotate: 90 }], + transforms: [{ imageIndex: 0, rotate: 90 }], }); }, }; -export const test_images_transform_bad = { +export const test_images_nested_draw = { + /** + * @param {unknown} _ + * @param {Env} env + */ + + async test(_, env) { + const result = await env.images + .input(inputStream('png')) + .transform({ rotate: 90 }) + .draw(env.images.input(inputStream('png1')).transform({ rotate: 180 })) + .draw( + env.images + .input(inputStream('png2')) + .draw(inputStream('png3')) + .transform({ rotate: 270 }) + ) + .draw(inputStream('png4')) + .output({ format: 'image/avif' }); + + // Would be image/avif in real life, but mock always returns JSON + assert.equal(result.contentType(), 'application/json'); + const body = await result.response().json(); + + assert.deepStrictEqual(body, { + image: 'png', + draw_image: ['png1', 'png2', 'png3', 'png4'], + output_format: 'image/avif', + transforms: [ + { imageIndex: 0, rotate: 90 }, + { imageIndex: 1, rotate: 180 }, + { drawImageIndex: 1, targetImageIndex: 0 }, + { drawImageIndex: 3, targetImageIndex: 2 }, + { imageIndex: 2, rotate: 270 }, + { drawImageIndex: 2, targetImageIndex: 0 }, + { drawImageIndex: 4, targetImageIndex: 0 }, + ], + }); + }, +}; + +export const test_images_transformer_draw_twice_disallowed = { + /** + * @param {unknown} _ + * @param {Env} env + */ + + async test(_, env) { + /** + * @type {any} e; + */ + let e; + + let t = env.images.input(inputStream('png1')); + + try { + await env.images + .input(inputStream('png')) + .draw(t) + .draw(t) + .output({ format: 'image/avif' }); + } catch (e1) { + e = e1; + } + + assert.equal(true, !!e); + assert.equal(e.code, 9525); + assert.equal( + e.message, + 'IMAGES_TRANSFORM_ERROR 9525: ImageTransformer consumed; you may only call .output() or draw a transformer once' + ); + }, +}; + +export const test_images_transformer_already_consumed_disallowed = { /** * @param {unknown} _ * @param {Env} env */ async test(_, env) { - const blob = new Blob(['BAD']); + /** + * @type {any} e; + */ + let e; + let t = env.images.input(inputStream('png1')); + + await t.output({}); + + try { + await env.images + .input(inputStream('png')) + .draw(t) + .output({ format: 'image/avif' }); + } catch (e1) { + e = e1; + } + + assert.equal(true, !!e); + assert.equal(e.code, 9525); + assert.equal( + e.message, + 'IMAGES_TRANSFORM_ERROR 9525: ImageTransformer consumed; you may only call .output() or draw a transformer once' + ); + }, +}; + +export const test_images_transform_bad = { + /** + * @param {unknown} _ + * @param {Env} env + */ + + async test(_, env) { /** * @type {any} e; */ @@ -107,7 +213,7 @@ export const test_images_transform_bad = { try { await env.images - .input(blob.stream()) + .input(inputStream('BAD')) .transform({ rotate: 90 }) .output({ format: 'image/avif' }); } catch (e2) { @@ -127,8 +233,6 @@ export const test_images_transform_consumed = { */ async test(_, env) { - const blob = new Blob(['png']); - /** * @type {any} e; */ @@ -136,7 +240,7 @@ export const test_images_transform_consumed = { try { let transformer = env.images - .input(blob.stream()) + .input(inputStream('png')) .transform({ rotate: 90 }); await transformer.output({ format: 'image/avif' }); @@ -149,7 +253,7 @@ export const test_images_transform_consumed = { assert.equal(e.code, 9525); assert.equal( e.message, - 'IMAGES_TRANSFORM_ERROR 9525: ImageTransformer consumed; you may only call .output() once' + 'IMAGES_TRANSFORM_ERROR 9525: ImageTransformer consumed; you may only call .output() or draw a transformer once' ); }, }; diff --git a/src/cloudflare/internal/test/images/images-upstream-mock.js b/src/cloudflare/internal/test/images/images-upstream-mock.js index a6802105e179..1245d7c9ae8b 100644 --- a/src/cloudflare/internal/test/images/images-upstream-mock.js +++ b/src/cloudflare/internal/test/images/images-upstream-mock.js @@ -6,9 +6,7 @@ * @param {FormData} form * @returns {Promise} */ -async function imageAsString(form) { - let blob = form.get('image'); - +async function imageAsString(blob) { if (blob === null) { return null; } @@ -26,7 +24,7 @@ export default { */ async fetch(request) { const form = await request.formData(); - const image = (await imageAsString(form)) || ''; + const image = (await imageAsString(form.get('image'))) || ''; if (image.includes('BAD')) { const resp = new Response('ERROR 123: Bad request', { status: 409, @@ -56,7 +54,7 @@ export default { * @type {any} */ const obj = { - image: await imageAsString(form), + image: await imageAsString(form.get('image')), // @ts-ignore transforms: JSON.parse(form.get('transforms') || '{}'), }; @@ -66,6 +64,14 @@ export default { } } + if (form.get('draw_image')) { + const drawImages = []; + for (const entry of form.getAll('draw_image')) { + drawImages.push(await imageAsString(entry)); + } + obj['draw_image'] = drawImages; + } + return Response.json(obj); }