From a1523d3dc35052fe1453d828f12f7e029600514e Mon Sep 17 00:00:00 2001 From: Josema Date: Mon, 27 Jul 2026 11:26:04 +0200 Subject: [PATCH 1/2] fix: respect source origin in copyExternalImageToTexture --- packages/webgpu/cpp/rnwgpu/api/GPUQueue.cpp | 40 ++-- .../src/__tests__/ImageBitmapOrigin.spec.ts | 219 ++++++++++++++++++ packages/webgpu/src/__tests__/setup.ts | 40 ++-- 3 files changed, 272 insertions(+), 27 deletions(-) create mode 100644 packages/webgpu/src/__tests__/ImageBitmapOrigin.spec.ts diff --git a/packages/webgpu/cpp/rnwgpu/api/GPUQueue.cpp b/packages/webgpu/cpp/rnwgpu/api/GPUQueue.cpp index ee85d3fe6..f4701be3e 100644 --- a/packages/webgpu/cpp/rnwgpu/api/GPUQueue.cpp +++ b/packages/webgpu/cpp/rnwgpu/api/GPUQueue.cpp @@ -128,6 +128,18 @@ void GPUQueue::copyExternalImageToTexture( throw std::runtime_error("Invalid input for GPUQueue::writeTexture()"); } + const auto origin = source->origin.value_or(nullptr); + const size_t sourceOriginX = origin ? origin->x : 0; + const size_t sourceOriginY = origin ? origin->y : 0; + if (sourceOriginX > source->source->getWidth() || + sz.width > source->source->getWidth() - sourceOriginX || + sourceOriginY > source->source->getHeight() || + sz.height > source->source->getHeight() - sourceOriginY || + sz.depthOrArrayLayers > 1) { + throw std::runtime_error( + "The source copy region is outside the ImageBitmap."); + } + const bool flipY = source->flipY.value_or(false); // premultipliedAlpha defaults to false per the WebGPU spec: an untagged // destination expects straight alpha. Convert only when the ImageBitmap's @@ -138,28 +150,30 @@ void GPUQueue::copyExternalImageToTexture( const bool needsAlphaConversion = bytesPerPixel == 4 && sourcePremultiplied != destinationPremultiplied; - if (flipY || needsAlphaConversion) { - uint32_t rowSize = bytesPerPixel * source->source->getWidth(); - uint32_t totalSize = source->source->getSize(); + if (sourceOriginX != 0 || sourceOriginY != 0 || flipY || + needsAlphaConversion) { + uint32_t sourceRowSize = bytesPerPixel * source->source->getWidth(); + uint32_t rowSize = bytesPerPixel * sz.width; + uint32_t totalSize = rowSize * sz.height; - // Stage a mutable copy so flipping and/or alpha conversion never touch the - // ImageBitmap's backing store. + // Stage only the selected subregion so transformations never touch the + // ImageBitmap's backing store or pixels outside the requested copy. std::vector staged(totalSize); const uint8_t *src = static_cast(source->source->getData()); - if (flipY) { - for (uint32_t row = 0; row < source->source->getHeight(); ++row) { - std::memcpy(staged.data() + - (source->source->getHeight() - 1 - row) * rowSize, - src + row * rowSize, rowSize); - } - } else { - std::memcpy(staged.data(), src, totalSize); + for (uint32_t row = 0; row < sz.height; ++row) { + const uint32_t sourceRow = + sourceOriginY + (flipY ? sz.height - 1 - row : row); + const uint32_t sourceOffset = + sourceRow * sourceRowSize + sourceOriginX * bytesPerPixel; + std::memcpy(staged.data() + row * rowSize, src + sourceOffset, rowSize); } if (needsAlphaConversion) { convertAlpha(staged.data(), totalSize, sourcePremultiplied, destinationPremultiplied); } + layout.bytesPerRow = rowSize; + layout.rowsPerImage = sz.height; _instance.WriteTexture(&dst, staged.data(), totalSize, &layout, &sz); } else { _instance.WriteTexture(&dst, source->source->getData(), diff --git a/packages/webgpu/src/__tests__/ImageBitmapOrigin.spec.ts b/packages/webgpu/src/__tests__/ImageBitmapOrigin.spec.ts new file mode 100644 index 000000000..5755561bb --- /dev/null +++ b/packages/webgpu/src/__tests__/ImageBitmapOrigin.spec.ts @@ -0,0 +1,219 @@ +import { client, fixtureUrl } from "./setup"; + +interface OriginCase { + name: string; + fileName: string; + origin: [number, number]; + copySize: [number, number]; + flipY: boolean; + sourcePremultiplied: boolean; +} + +const cases: OriginCase[] = [ + { + name: "uses a non-zero horizontal origin", + fileName: "opaque-gradient.png", + origin: [8, 0], + copySize: [16, 32], + flipY: false, + sourcePremultiplied: false, + }, + { + name: "uses a non-zero vertical origin", + fileName: "opaque-gradient.png", + origin: [0, 8], + copySize: [32, 16], + flipY: false, + sourcePremultiplied: false, + }, + { + name: "combines origin with alpha conversion", + fileName: "alpha-gradient.png", + origin: [8, 4], + copySize: [16, 20], + flipY: false, + sourcePremultiplied: true, + }, + { + name: "flips only the selected source region", + fileName: "opaque-gradient.png", + origin: [8, 4], + copySize: [16, 20], + flipY: true, + sourcePremultiplied: false, + }, + { + name: "preserves the zero-origin path", + fileName: "opaque-gradient.png", + origin: [0, 0], + copySize: [16, 16], + flipY: false, + sourcePremultiplied: false, + }, +]; + +describe("copyExternalImageToTexture source origin", () => { + it.each(cases)("$name", async (testCase) => { + const result = await client.eval( + ({ device, url, origin, copySize, flipY, sourcePremultiplied }) => { + return fetch(url) + .then((response) => response.blob()) + .then((blob) => + createImageBitmap(blob, { + premultiplyAlpha: sourcePremultiplied ? "premultiply" : "none", + }), + ) + .then((bitmap) => { + const fullTexture = device.createTexture({ + size: [bitmap.width, bitmap.height], + format: "rgba8unorm", + usage: + GPUTextureUsage.COPY_DST | + GPUTextureUsage.COPY_SRC | + GPUTextureUsage.RENDER_ATTACHMENT, + }); + const regionTexture = device.createTexture({ + size: copySize, + format: "rgba8unorm", + usage: + GPUTextureUsage.COPY_DST | + GPUTextureUsage.COPY_SRC | + GPUTextureUsage.RENDER_ATTACHMENT, + }); + device.queue.copyExternalImageToTexture( + { source: bitmap }, + { texture: fullTexture, premultipliedAlpha: false }, + [bitmap.width, bitmap.height], + ); + device.queue.copyExternalImageToTexture( + { source: bitmap, origin, flipY }, + { texture: regionTexture, premultipliedAlpha: false }, + copySize, + ); + + const bytesPerRow = 256; + const fullOutput = device.createBuffer({ + size: bytesPerRow * bitmap.height, + usage: GPUBufferUsage.COPY_DST | GPUBufferUsage.MAP_READ, + }); + const regionOutput = device.createBuffer({ + size: bytesPerRow * copySize[1], + usage: GPUBufferUsage.COPY_DST | GPUBufferUsage.MAP_READ, + }); + const encoder = device.createCommandEncoder(); + encoder.copyTextureToBuffer( + { texture: fullTexture }, + { buffer: fullOutput, bytesPerRow }, + [bitmap.width, bitmap.height], + ); + encoder.copyTextureToBuffer( + { texture: regionTexture }, + { buffer: regionOutput, bytesPerRow }, + copySize, + ); + device.queue.submit([encoder.finish()]); + + return Promise.all([ + fullOutput.mapAsync(GPUMapMode.READ), + regionOutput.mapAsync(GPUMapMode.READ), + ]).then(() => { + const readRows = ( + buffer: GPUBuffer, + width: number, + height: number, + ) => { + const mapped = new Uint8Array(buffer.getMappedRange()); + const data: number[] = []; + for (let row = 0; row < height; row++) { + for (let column = 0; column < width * 4; column++) { + data.push(mapped[row * bytesPerRow + column]); + } + } + return data; + }; + const full = readRows(fullOutput, bitmap.width, bitmap.height); + const region = readRows(regionOutput, copySize[0], copySize[1]); + const sourceWidth = bitmap.width; + + fullOutput.unmap(); + regionOutput.unmap(); + fullOutput.destroy(); + regionOutput.destroy(); + fullTexture.destroy(); + regionTexture.destroy(); + bitmap.close(); + + return { full, region, sourceWidth }; + }); + }); + }, + { + url: fixtureUrl(testCase.fileName), + origin: testCase.origin, + copySize: testCase.copySize, + flipY: testCase.flipY, + sourcePremultiplied: testCase.sourcePremultiplied, + }, + ); + + const expected: number[] = []; + const [originX, originY] = testCase.origin; + const [copyWidth, copyHeight] = testCase.copySize; + for (let row = 0; row < copyHeight; row++) { + const sourceRow = originY + (testCase.flipY ? copyHeight - 1 - row : row); + const start = (sourceRow * result.sourceWidth + originX) * 4; + expected.push(...result.full.slice(start, start + copyWidth * 4)); + } + expect(result.region).toEqual(expected); + }); + + it.each([ + { + name: "horizontal", + origin: [17, 0] as [number, number], + copySize: [16, 32] as [number, number], + }, + { + name: "vertical", + origin: [0, 17] as [number, number], + copySize: [32, 16] as [number, number], + }, + ])("rejects an out-of-bounds $name region", async (testCase) => { + const didThrow = await client.eval( + ({ device, url, origin, copySize }) => { + return fetch(url) + .then((response) => response.blob()) + .then((blob) => createImageBitmap(blob)) + .then((bitmap) => { + const texture = device.createTexture({ + size: copySize, + format: "rgba8unorm", + usage: + GPUTextureUsage.COPY_DST | GPUTextureUsage.RENDER_ATTACHMENT, + }); + let threw = false; + try { + device.queue.copyExternalImageToTexture( + { source: bitmap, origin }, + { texture }, + copySize, + ); + } catch { + threw = true; + } finally { + texture.destroy(); + bitmap.close(); + } + return threw; + }); + }, + { + url: fixtureUrl("opaque-gradient.png"), + origin: testCase.origin, + copySize: testCase.copySize, + }, + ); + + expect(didThrow).toBe(true); + }); +}); diff --git a/packages/webgpu/src/__tests__/setup.ts b/packages/webgpu/src/__tests__/setup.ts index 34abf396a..8df92ad67 100644 --- a/packages/webgpu/src/__tests__/setup.ts +++ b/packages/webgpu/src/__tests__/setup.ts @@ -697,13 +697,26 @@ class NodeTestingClient implements TestingClient { height: number; premultiplied?: boolean; }; + origin?: number[]; flipY?: boolean; }, destination: GPUTexelCopyTextureInfo & { premultipliedAlpha?: boolean }, copySize: GPUExtent3DStrict, ) => { const { data, width, height, premultiplied } = source.source; - const rowSize = width * 4; + const [originX = 0, originY = 0] = source.origin ?? []; + const [copyWidth, copyHeight = 1, copyDepth = 1] = + copySize as Iterable; + if ( + originX + copyWidth > width || + originY + copyHeight > height || + copyDepth > 1 + ) { + throw new Error("The source copy region is outside the ImageBitmap."); + } + + const sourceRowSize = width * 4; + let rowSize = sourceRowSize; let bytes = new Uint8Array( data.buffer as ArrayBuffer, data.byteOffset, @@ -714,26 +727,25 @@ class NodeTestingClient implements TestingClient { const needsConversion = premultiplied !== undefined && premultiplied !== (destination.premultipliedAlpha === true); - if (flipY || needsConversion) { - const converted = new Uint8Array(bytes.length); - if (flipY) { - for (let row = 0; row < height; row++) { - converted.set( - bytes.subarray(row * rowSize, (row + 1) * rowSize), - (height - 1 - row) * rowSize, - ); - } - } else { - converted.set(bytes); + if (originX !== 0 || originY !== 0 || flipY || needsConversion) { + rowSize = copyWidth * 4; + const staged = new Uint8Array(rowSize * copyHeight); + for (let row = 0; row < copyHeight; row++) { + const sourceRow = originY + (flipY ? copyHeight - 1 - row : row); + const sourceOffset = sourceRow * sourceRowSize + originX * 4; + staged.set( + bytes.subarray(sourceOffset, sourceOffset + rowSize), + row * rowSize, + ); } if (needsConversion) { convertAlpha( - converted, + staged, premultiplied === true, destination.premultipliedAlpha === true, ); } - bytes = converted; + bytes = staged; } device.queue.writeTexture( destination, From 6900185ba87525061ec36408896c56dc5c778d11 Mon Sep 17 00:00:00 2001 From: William Candillon Date: Wed, 29 Jul 2026 11:22:24 +0200 Subject: [PATCH 2/2] :wrench: --- packages/webgpu/cpp/rnwgpu/api/GPUQueue.cpp | 16 +++++++++++-- .../src/__tests__/ImageBitmapOrigin.spec.ts | 5 ++++ packages/webgpu/src/__tests__/setup.ts | 24 +++++++++++++++---- 3 files changed, 39 insertions(+), 6 deletions(-) diff --git a/packages/webgpu/cpp/rnwgpu/api/GPUQueue.cpp b/packages/webgpu/cpp/rnwgpu/api/GPUQueue.cpp index f4701be3e..4ef347e11 100644 --- a/packages/webgpu/cpp/rnwgpu/api/GPUQueue.cpp +++ b/packages/webgpu/cpp/rnwgpu/api/GPUQueue.cpp @@ -129,8 +129,20 @@ void GPUQueue::copyExternalImageToTexture( } const auto origin = source->origin.value_or(nullptr); - const size_t sourceOriginX = origin ? origin->x : 0; - const size_t sourceOriginY = origin ? origin->y : 0; + // GPUOrigin2D coordinates are [EnforceRange] unsigned long: negative, + // NaN, or out-of-range doubles must be rejected here because casting + // them to an unsigned integer is undefined behavior. + constexpr double kMaxOrigin = + static_cast(std::numeric_limits::max()); + if (origin && (!(origin->x >= 0) || !(origin->y >= 0) || + origin->x > kMaxOrigin || origin->y > kMaxOrigin)) { + throw std::runtime_error( + "The source origin must be a non-negative integer coordinate."); + } + const size_t sourceOriginX = + origin ? static_cast(origin->x) : 0; + const size_t sourceOriginY = + origin ? static_cast(origin->y) : 0; if (sourceOriginX > source->source->getWidth() || sz.width > source->source->getWidth() - sourceOriginX || sourceOriginY > source->source->getHeight() || diff --git a/packages/webgpu/src/__tests__/ImageBitmapOrigin.spec.ts b/packages/webgpu/src/__tests__/ImageBitmapOrigin.spec.ts index 5755561bb..4b899b32a 100644 --- a/packages/webgpu/src/__tests__/ImageBitmapOrigin.spec.ts +++ b/packages/webgpu/src/__tests__/ImageBitmapOrigin.spec.ts @@ -178,6 +178,11 @@ describe("copyExternalImageToTexture source origin", () => { origin: [0, 17] as [number, number], copySize: [32, 16] as [number, number], }, + { + name: "negative-origin", + origin: [-1, 0] as [number, number], + copySize: [16, 16] as [number, number], + }, ])("rejects an out-of-bounds $name region", async (testCase) => { const didThrow = await client.eval( ({ device, url, origin, copySize }) => { diff --git a/packages/webgpu/src/__tests__/setup.ts b/packages/webgpu/src/__tests__/setup.ts index 8df92ad67..82de0824f 100644 --- a/packages/webgpu/src/__tests__/setup.ts +++ b/packages/webgpu/src/__tests__/setup.ts @@ -697,16 +697,32 @@ class NodeTestingClient implements TestingClient { height: number; premultiplied?: boolean; }; - origin?: number[]; + origin?: number[] | { x?: number; y?: number }; flipY?: boolean; }, destination: GPUTexelCopyTextureInfo & { premultipliedAlpha?: boolean }, copySize: GPUExtent3DStrict, ) => { const { data, width, height, premultiplied } = source.source; - const [originX = 0, originY = 0] = source.origin ?? []; - const [copyWidth, copyHeight = 1, copyDepth = 1] = - copySize as Iterable; + // Both origin and copySize accept the sequence and dictionary forms. + const origin = source.origin ?? {}; + const originX = (Array.isArray(origin) ? origin[0] : origin.x) ?? 0; + const originY = (Array.isArray(origin) ? origin[1] : origin.y) ?? 0; + const extent = Array.isArray(copySize) + ? { + width: copySize[0], + height: copySize[1], + depthOrArrayLayers: copySize[2], + } + : (copySize as GPUExtent3DDictStrict); + const copyWidth = extent.width; + const copyHeight = extent.height ?? 1; + const copyDepth = extent.depthOrArrayLayers ?? 1; + if (originX < 0 || originY < 0) { + throw new Error( + "The source origin must be a non-negative integer coordinate.", + ); + } if ( originX + copyWidth > width || originY + copyHeight > height ||