From 0567486046fd0722151642e1619b2c1c683f7c5d Mon Sep 17 00:00:00 2001 From: Miguel Angel Simon Sierra Date: Fri, 9 Oct 2026 16:40:51 -0700 Subject: [PATCH 1/5] fix(runtime): graded and snapshotted pictures stay readable in sandboxed players --- .../core/src/runtime/colorGrading.test.ts | 57 ++++++++++++++++++ packages/core/src/runtime/colorGrading.ts | 31 ++++++++-- .../src/capture.cors.test.ts | 60 +++++++++++++++++++ packages/shader-transitions/src/capture.ts | 32 ++++++++++ 4 files changed, 176 insertions(+), 4 deletions(-) create mode 100644 packages/shader-transitions/src/capture.cors.test.ts diff --git a/packages/core/src/runtime/colorGrading.test.ts b/packages/core/src/runtime/colorGrading.test.ts index afa313f1a5..c69a3228d3 100644 --- a/packages/core/src/runtime/colorGrading.test.ts +++ b/packages/core/src/runtime/colorGrading.test.ts @@ -200,6 +200,7 @@ describe("createColorGradingRuntime", () => { runtime?.destroy(); runtime = null; vi.unstubAllGlobals(); + vi.restoreAllMocks(); getContextSpy.mockRestore(); delete window.__hfVariables; delete window.__hfVariablesByComp; @@ -719,6 +720,62 @@ describe("createColorGradingRuntime", () => { expect(lastUniform1f).toHaveBeenCalledWith("u_kuwahara", 1); }); + it("grades a CORS copy of a picture in an opaque document, whose own no-cors load WebGL can't read", () => { + vi.stubGlobal("origin", "null"); + const created: HTMLImageElement[] = []; + const createElement = document.createElement.bind(document); + vi.spyOn(document, "createElement").mockImplementation( + (tag: string, options?: ElementCreationOptions) => { + const element = createElement(tag, options); + if (element instanceof HTMLImageElement) { + Object.defineProperty(element, "complete", { value: false, configurable: true }); + created.push(element); + } + return element; + }, + ); + const image = makeDrawableImage(); + Object.defineProperty(image, "currentSrc", { value: "http://127.0.0.1/p/cutout.png" }); + document.body.appendChild(image); + runtime = createColorGradingRuntime(); + const uploads = () => texImage2DCalls.map((call) => call[5]); + + const copy = created.find((element) => element.crossOrigin === "anonymous"); + if (!copy) throw new Error("Expected a CORS copy of the picture"); + expect(copy.src).toBe("http://127.0.0.1/p/cutout.png"); + expect(uploads()).not.toContain(image); + Object.defineProperty(copy, "complete", { value: true }); + Object.defineProperty(copy, "naturalWidth", { value: 640 }); + copy.dispatchEvent(new Event("load")); + expect(uploads().at(-1)).toBe(copy); + expect(uploads()).not.toContain(image); + }); + + it("grades the picture itself when an opaque document's CORS copy is refused, as before", () => { + vi.stubGlobal("origin", "null"); + const image = makeDrawableImage(); + Object.defineProperty(image, "currentSrc", { value: "http://127.0.0.1/p/cutout.png" }); + document.body.appendChild(image); + const createElement = document.createElement.bind(document); + let copy: HTMLImageElement | null = null; + vi.spyOn(document, "createElement").mockImplementation( + (tag: string, options?: ElementCreationOptions) => { + const element = createElement(tag, options); + if (element instanceof HTMLImageElement) { + Object.defineProperty(element, "complete", { value: false, configurable: true }); + copy = element; + } + return element; + }, + ); + runtime = createColorGradingRuntime(); + if (!copy) throw new Error("Expected a CORS copy of the picture"); + const refused: HTMLImageElement = copy; + Object.defineProperty(refused, "complete", { value: true }); + refused.dispatchEvent(new Event("error")); + expect(texImage2DCalls.at(-1)?.[5]).toBe(image); + }); + it("redraws animated still images from the transport tick", () => { const image = makeDrawableImage(); document.body.appendChild(image); diff --git a/packages/core/src/runtime/colorGrading.ts b/packages/core/src/runtime/colorGrading.ts index 1ced515bb6..59c9a87844 100644 --- a/packages/core/src/runtime/colorGrading.ts +++ b/packages/core/src/runtime/colorGrading.ts @@ -2495,6 +2495,27 @@ function keepCanvasAboveSource(entry: ColorGradingEntry, source: HTMLImageElemen } } +const corsCopies = new WeakMap(); + +// An opaque (sandboxed) document loads its own no-cors, so WebGL cannot read it: upload a copy requested with +// CORS. Null while the copy loads; a refused copy leaves the element, which fails as it always did. +function readablePixels(source: TexImageSource, redraw: () => void): TexImageSource | null { + if (!isImageElement(source) || window.origin !== "null" || source.crossOrigin !== null) + return source; + if (!/^https?:/i.test(source.currentSrc)) return source; + let copy = corsCopies.get(source); + if (copy?.src !== source.currentSrc) { + copy = source.ownerDocument.createElement("img"); + copy.crossOrigin = "anonymous"; + copy.addEventListener("load", redraw, { once: true }); + copy.addEventListener("error", redraw, { once: true }); + copy.src = source.currentSrc; + corsCopies.set(source, copy); + } + if (!copy.complete) return null; + return copy.naturalWidth > 0 ? copy : source; +} + function getDrawableSource(element: ColorGradingMediaElement): TexImageSource | null { if (isVideoElement(element)) { const renderFrame = findRenderFrameImage(element); @@ -3053,7 +3074,8 @@ function bindProgramTextures( function drawEntry(entry: ColorGradingEntry): boolean { if (entry.destroyed || entry.contextLost) return false; const source = getDrawableSource(entry.element); - if (!source) { + const pixels = source && readablePixels(source, () => drawEntry(entry)); + if (!source || !pixels) { if (!entry.hasDrawn) entry.canvas.style.display = "none"; return false; } @@ -3112,7 +3134,7 @@ function drawEntry(entry: ColorGradingEntry): boolean { const lut = ensureEntryLut(entry); // Browser media elements are top-left oriented; WebGL texture coordinates // are bottom-left oriented unless the upload is flipped. - uploadSourceTexture(gl, program.texture, source); + uploadSourceTexture(gl, program.texture, pixels); const hasAnimatedKuwahara = readAnimatedValue(entry.element, ANIMATED_KUWAHARA_PROPERTY) !== null; const prepared = prepareEffectTextures(entry, grading, layout, uv, { @@ -3212,7 +3234,8 @@ function preparePreviewFrame( useMediaTime: boolean, ): PreviewFrame | null { const source = getDrawableSource(element); - if (!source) return null; + const pixels = source && readablePixels(source, () => undefined); + if (!source || !pixels) return null; const sourceSize = readSourceSize(source); if (!sourceSize) return null; const dimensions = previewDimensions(element, sourceSize, maxDimension); @@ -3228,7 +3251,7 @@ function preparePreviewFrame( style.objectFit, style.objectPosition, ); - uploadSourceTexture(renderer.gl, renderer.program.texture, source); + uploadSourceTexture(renderer.gl, renderer.program.texture, pixels); return { dimensions, uv, diff --git a/packages/shader-transitions/src/capture.cors.test.ts b/packages/shader-transitions/src/capture.cors.test.ts new file mode 100644 index 0000000000..646ac72a10 --- /dev/null +++ b/packages/shader-transitions/src/capture.cors.test.ts @@ -0,0 +1,60 @@ +// @vitest-environment jsdom +import html2canvas from "html2canvas"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { captureScene } from "./capture.js"; + +vi.mock("html2canvas", () => ({ + default: vi.fn(() => Promise.resolve(document.createElement("canvas"))), +})); + +async function captureCache() { + document.body.innerHTML = '
'; + await captureScene(document.getElementById("scene") as HTMLElement, "#000", 4, 4); + return vi.mocked(html2canvas).mock.calls.at(-1)?.[1]?.cache; +} + +function recordImages(): HTMLImageElement[] { + const created: HTMLImageElement[] = []; + const createElement = document.createElement.bind(document); + vi.spyOn(document, "createElement").mockImplementation( + (tag: string, options?: ElementCreationOptions) => { + const element = createElement(tag, options); + if (element instanceof HTMLImageElement) created.push(element); + return element; + }, + ); + return created; +} + +describe("captureScene pictures", () => { + afterEach(() => { + vi.unstubAllGlobals(); + vi.restoreAllMocks(); + vi.mocked(html2canvas).mockClear(); + }); + + it("leaves html2canvas's own picture loading to a document with an origin", async () => { + expect(await captureCache()).toBeUndefined(); + }); + + it("asks for pictures with CORS in an opaque document, and loads a refused one as before", async () => { + vi.stubGlobal("origin", "null"); + const cache = await captureCache(); + if (!cache) throw new Error("Expected a picture cache for an opaque document"); + const created = recordImages(); + + const readable = "http://127.0.0.1/p/cutout.png"; + await cache.addImage(readable); + created[0]?.dispatchEvent(new Event("load")); + expect(await cache.match(readable)).toBe(created[0]); + expect(created[0]?.crossOrigin).toBe("anonymous"); + + const refused = "http://127.0.0.1/p/other.png"; + await cache.addImage(refused); + created[1]?.dispatchEvent(new Event("error")); + await vi.waitFor(() => expect(created).toHaveLength(3)); + created[2]?.dispatchEvent(new Event("load")); + expect(await cache.match(refused)).toBe(created[2]); + expect(created[2]?.crossOrigin).toBeNull(); + }); +}); diff --git a/packages/shader-transitions/src/capture.ts b/packages/shader-transitions/src/capture.ts index 1a5af0a6a3..c30b4d30ea 100644 --- a/packages/shader-transitions/src/capture.ts +++ b/packages/shader-transitions/src/capture.ts @@ -165,6 +165,37 @@ export function captureScene( return captureSceneWithHtml2Canvas(sceneEl, bgColor, width, height, options); } +type Html2CanvasCache = NonNullable[1]>["cache"]>; + +const loadImage = (src: string, cors: boolean): Promise => + new Promise((resolve, reject) => { + const img = document.createElement("img"); + if (cors) img.crossOrigin = "anonymous"; + img.onload = () => resolve(img); + img.onerror = reject; + img.src = src; + }); + +// html2canvas judges same-origin by the page URL, which an opaque (sandboxed) document does not share, so it loads +// pictures no-cors and the snapshot can't reach WebGL. Ask with CORS first; a refused picture loads as before. +export function corsFirstImageCache(): Html2CanvasCache { + const images = new Map>(); + const cache = { + addImage(src: string): Promise { + if (!images.has(src)) { + const image = loadImage(src, true).catch(() => loadImage(src, false)); + image.catch(() => undefined); + images.set(src, image); + } + return Promise.resolve(); + }, + match: (src: string) => images.get(src), + has: (src: string) => images.has(src), + keys: () => Promise.resolve([...images.keys()]), + }; + return cache as unknown as Html2CanvasCache; +} + function captureSceneWithHtml2Canvas( sceneEl: HTMLElement, bgColor: string, @@ -207,6 +238,7 @@ function captureSceneWithHtml2Canvas( // CORS headers). useCORS: true, allowTaint: true, + cache: window.origin === "null" ? corsFirstImageCache() : undefined, onclone: (cloneDoc) => { if (!sceneEl.id) return; const clone = cloneDoc.getElementById(sceneEl.id); From 10077eb5f09be059daae42a276ae258bb9aa07d0 Mon Sep 17 00:00:00 2001 From: Miguel Angel Simon Sierra Date: Fri, 9 Oct 2026 16:44:47 -0700 Subject: [PATCH 2/5] refactor(shader-transitions): keep the picture cache private --- packages/shader-transitions/src/capture.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/shader-transitions/src/capture.ts b/packages/shader-transitions/src/capture.ts index c30b4d30ea..ce8fd54dcd 100644 --- a/packages/shader-transitions/src/capture.ts +++ b/packages/shader-transitions/src/capture.ts @@ -178,7 +178,7 @@ const loadImage = (src: string, cors: boolean): Promise => // html2canvas judges same-origin by the page URL, which an opaque (sandboxed) document does not share, so it loads // pictures no-cors and the snapshot can't reach WebGL. Ask with CORS first; a refused picture loads as before. -export function corsFirstImageCache(): Html2CanvasCache { +function corsFirstImageCache(): Html2CanvasCache { const images = new Map>(); const cache = { addImage(src: string): Promise { From e5dab1f7a94901f0690c15c31d5f42332e165eb6 Mon Sep 17 00:00:00 2001 From: Miguel Angel Simon Sierra Date: Fri, 9 Oct 2026 17:10:32 -0700 Subject: [PATCH 3/5] fix(runtime): drop the snapshot picture cache, html2canvas cannot run in a sandboxed page --- .../src/capture.cors.test.ts | 60 ------------------- packages/shader-transitions/src/capture.ts | 32 ---------- 2 files changed, 92 deletions(-) delete mode 100644 packages/shader-transitions/src/capture.cors.test.ts diff --git a/packages/shader-transitions/src/capture.cors.test.ts b/packages/shader-transitions/src/capture.cors.test.ts deleted file mode 100644 index 646ac72a10..0000000000 --- a/packages/shader-transitions/src/capture.cors.test.ts +++ /dev/null @@ -1,60 +0,0 @@ -// @vitest-environment jsdom -import html2canvas from "html2canvas"; -import { afterEach, describe, expect, it, vi } from "vitest"; -import { captureScene } from "./capture.js"; - -vi.mock("html2canvas", () => ({ - default: vi.fn(() => Promise.resolve(document.createElement("canvas"))), -})); - -async function captureCache() { - document.body.innerHTML = '
'; - await captureScene(document.getElementById("scene") as HTMLElement, "#000", 4, 4); - return vi.mocked(html2canvas).mock.calls.at(-1)?.[1]?.cache; -} - -function recordImages(): HTMLImageElement[] { - const created: HTMLImageElement[] = []; - const createElement = document.createElement.bind(document); - vi.spyOn(document, "createElement").mockImplementation( - (tag: string, options?: ElementCreationOptions) => { - const element = createElement(tag, options); - if (element instanceof HTMLImageElement) created.push(element); - return element; - }, - ); - return created; -} - -describe("captureScene pictures", () => { - afterEach(() => { - vi.unstubAllGlobals(); - vi.restoreAllMocks(); - vi.mocked(html2canvas).mockClear(); - }); - - it("leaves html2canvas's own picture loading to a document with an origin", async () => { - expect(await captureCache()).toBeUndefined(); - }); - - it("asks for pictures with CORS in an opaque document, and loads a refused one as before", async () => { - vi.stubGlobal("origin", "null"); - const cache = await captureCache(); - if (!cache) throw new Error("Expected a picture cache for an opaque document"); - const created = recordImages(); - - const readable = "http://127.0.0.1/p/cutout.png"; - await cache.addImage(readable); - created[0]?.dispatchEvent(new Event("load")); - expect(await cache.match(readable)).toBe(created[0]); - expect(created[0]?.crossOrigin).toBe("anonymous"); - - const refused = "http://127.0.0.1/p/other.png"; - await cache.addImage(refused); - created[1]?.dispatchEvent(new Event("error")); - await vi.waitFor(() => expect(created).toHaveLength(3)); - created[2]?.dispatchEvent(new Event("load")); - expect(await cache.match(refused)).toBe(created[2]); - expect(created[2]?.crossOrigin).toBeNull(); - }); -}); diff --git a/packages/shader-transitions/src/capture.ts b/packages/shader-transitions/src/capture.ts index ce8fd54dcd..1a5af0a6a3 100644 --- a/packages/shader-transitions/src/capture.ts +++ b/packages/shader-transitions/src/capture.ts @@ -165,37 +165,6 @@ export function captureScene( return captureSceneWithHtml2Canvas(sceneEl, bgColor, width, height, options); } -type Html2CanvasCache = NonNullable[1]>["cache"]>; - -const loadImage = (src: string, cors: boolean): Promise => - new Promise((resolve, reject) => { - const img = document.createElement("img"); - if (cors) img.crossOrigin = "anonymous"; - img.onload = () => resolve(img); - img.onerror = reject; - img.src = src; - }); - -// html2canvas judges same-origin by the page URL, which an opaque (sandboxed) document does not share, so it loads -// pictures no-cors and the snapshot can't reach WebGL. Ask with CORS first; a refused picture loads as before. -function corsFirstImageCache(): Html2CanvasCache { - const images = new Map>(); - const cache = { - addImage(src: string): Promise { - if (!images.has(src)) { - const image = loadImage(src, true).catch(() => loadImage(src, false)); - image.catch(() => undefined); - images.set(src, image); - } - return Promise.resolve(); - }, - match: (src: string) => images.get(src), - has: (src: string) => images.has(src), - keys: () => Promise.resolve([...images.keys()]), - }; - return cache as unknown as Html2CanvasCache; -} - function captureSceneWithHtml2Canvas( sceneEl: HTMLElement, bgColor: string, @@ -238,7 +207,6 @@ function captureSceneWithHtml2Canvas( // CORS headers). useCORS: true, allowTaint: true, - cache: window.origin === "null" ? corsFirstImageCache() : undefined, onclone: (cloneDoc) => { if (!sceneEl.id) return; const clone = cloneDoc.getElementById(sceneEl.id); From a6558339567d0ddba653b89c7a31f8ad3f6cd041 Mon Sep 17 00:00:00 2001 From: Miguel Angel Simon Sierra Date: Fri, 9 Oct 2026 18:30:32 -0700 Subject: [PATCH 4/5] fix(runtime): a CORS copy redraws every grading waiting on it --- .../core/src/runtime/colorGrading.test.ts | 121 ++++++++++-------- packages/core/src/runtime/colorGrading.ts | 34 +++-- 2 files changed, 90 insertions(+), 65 deletions(-) diff --git a/packages/core/src/runtime/colorGrading.test.ts b/packages/core/src/runtime/colorGrading.test.ts index c69a3228d3..51a1b03229 100644 --- a/packages/core/src/runtime/colorGrading.test.ts +++ b/packages/core/src/runtime/colorGrading.test.ts @@ -720,60 +720,79 @@ describe("createColorGradingRuntime", () => { expect(lastUniform1f).toHaveBeenCalledWith("u_kuwahara", 1); }); - it("grades a CORS copy of a picture in an opaque document, whose own no-cors load WebGL can't read", () => { - vi.stubGlobal("origin", "null"); - const created: HTMLImageElement[] = []; - const createElement = document.createElement.bind(document); - vi.spyOn(document, "createElement").mockImplementation( - (tag: string, options?: ElementCreationOptions) => { - const element = createElement(tag, options); - if (element instanceof HTMLImageElement) { - Object.defineProperty(element, "complete", { value: false, configurable: true }); - created.push(element); - } - return element; - }, - ); - const image = makeDrawableImage(); - Object.defineProperty(image, "currentSrc", { value: "http://127.0.0.1/p/cutout.png" }); - document.body.appendChild(image); - runtime = createColorGradingRuntime(); + describe("a picture served by URL", () => { const uploads = () => texImage2DCalls.map((call) => call[5]); - const copy = created.find((element) => element.crossOrigin === "anonymous"); - if (!copy) throw new Error("Expected a CORS copy of the picture"); - expect(copy.src).toBe("http://127.0.0.1/p/cutout.png"); - expect(uploads()).not.toContain(image); - Object.defineProperty(copy, "complete", { value: true }); - Object.defineProperty(copy, "naturalWidth", { value: 640 }); - copy.dispatchEvent(new Event("load")); - expect(uploads().at(-1)).toBe(copy); - expect(uploads()).not.toContain(image); - }); + /** The graded picture, and the pictures the runtime creates afterwards, still loading. */ + function servedPicture(): { image: HTMLImageElement; created: HTMLImageElement[] } { + const image = makeDrawableImage(); + Object.defineProperty(image, "currentSrc", { value: "http://127.0.0.1/p/cutout.png" }); + document.body.appendChild(image); + const created: HTMLImageElement[] = []; + const createElement = document.createElement.bind(document); + vi.spyOn(document, "createElement").mockImplementation( + (tag: string, options?: ElementCreationOptions) => { + const element = createElement(tag, options); + if (element instanceof HTMLImageElement) { + Object.defineProperty(element, "complete", { value: false, configurable: true }); + created.push(element); + } + return element; + }, + ); + return { image, created }; + } - it("grades the picture itself when an opaque document's CORS copy is refused, as before", () => { - vi.stubGlobal("origin", "null"); - const image = makeDrawableImage(); - Object.defineProperty(image, "currentSrc", { value: "http://127.0.0.1/p/cutout.png" }); - document.body.appendChild(image); - const createElement = document.createElement.bind(document); - let copy: HTMLImageElement | null = null; - vi.spyOn(document, "createElement").mockImplementation( - (tag: string, options?: ElementCreationOptions) => { - const element = createElement(tag, options); - if (element instanceof HTMLImageElement) { - Object.defineProperty(element, "complete", { value: false, configurable: true }); - copy = element; - } - return element; - }, - ); - runtime = createColorGradingRuntime(); - if (!copy) throw new Error("Expected a CORS copy of the picture"); - const refused: HTMLImageElement = copy; - Object.defineProperty(refused, "complete", { value: true }); - refused.dispatchEvent(new Event("error")); - expect(texImage2DCalls.at(-1)?.[5]).toBe(image); + function settle(copy: HTMLImageElement | undefined, naturalWidth: number): void { + if (!copy) throw new Error("Expected a CORS copy of the picture"); + Object.defineProperty(copy, "complete", { value: true }); + Object.defineProperty(copy, "naturalWidth", { value: naturalWidth }); + copy.dispatchEvent(new Event(naturalWidth > 0 ? "load" : "error")); + } + + it("grades the picture itself in a document with an origin", () => { + const { image, created } = servedPicture(); + runtime = createColorGradingRuntime(); + + expect(created).toEqual([]); + expect(uploads().at(-1)).toBe(image); + }); + + it("grades a CORS copy in an opaque document, whose own no-cors load WebGL can't read", () => { + vi.stubGlobal("origin", "null"); + const { image, created } = servedPicture(); + runtime = createColorGradingRuntime(); + + const [copy] = created; + expect([copy?.src, copy?.crossOrigin]).toEqual([ + "http://127.0.0.1/p/cutout.png", + "anonymous", + ]); + expect(uploads()).not.toContain(image); + settle(copy, 640); + expect(uploads().at(-1)).toBe(copy); + expect(uploads()).not.toContain(image); + }); + + it("redraws a grading started while another's copy was loading", () => { + vi.stubGlobal("origin", "null"); + const { created } = servedPicture(); + createColorGradingRuntime().destroy(); + runtime = createColorGradingRuntime(); + + expect(created).toHaveLength(1); + settle(created[0], 640); + expect(uploads().at(-1)).toBe(created[0]); + }); + + it("grades the picture itself when an opaque document's CORS copy is refused, as before", () => { + vi.stubGlobal("origin", "null"); + const { image, created } = servedPicture(); + runtime = createColorGradingRuntime(); + + settle(created[0], 0); + expect(uploads().at(-1)).toBe(image); + }); }); it("redraws animated still images from the transport tick", () => { diff --git a/packages/core/src/runtime/colorGrading.ts b/packages/core/src/runtime/colorGrading.ts index 59c9a87844..3784edd943 100644 --- a/packages/core/src/runtime/colorGrading.ts +++ b/packages/core/src/runtime/colorGrading.ts @@ -2495,25 +2495,31 @@ function keepCanvasAboveSource(entry: ColorGradingEntry, source: HTMLImageElemen } } -const corsCopies = new WeakMap(); +const corsCopiesForOpaqueDocuments = new WeakMap< + HTMLImageElement, + { image: HTMLImageElement; waiting: (() => void)[] } +>(); -// An opaque (sandboxed) document loads its own no-cors, so WebGL cannot read it: upload a copy requested with -// CORS. Null while the copy loads; a refused copy leaves the element, which fails as it always did. function readablePixels(source: TexImageSource, redraw: () => void): TexImageSource | null { if (!isImageElement(source) || window.origin !== "null" || source.crossOrigin !== null) return source; if (!/^https?:/i.test(source.currentSrc)) return source; - let copy = corsCopies.get(source); - if (copy?.src !== source.currentSrc) { - copy = source.ownerDocument.createElement("img"); - copy.crossOrigin = "anonymous"; - copy.addEventListener("load", redraw, { once: true }); - copy.addEventListener("error", redraw, { once: true }); - copy.src = source.currentSrc; - corsCopies.set(source, copy); - } - if (!copy.complete) return null; - return copy.naturalWidth > 0 ? copy : source; + let copy = corsCopiesForOpaqueDocuments.get(source); + if (copy?.image.src !== source.currentSrc) { + const next = { + image: source.ownerDocument.createElement("img"), + waiting: [] as (() => void)[], + }; + const settle = () => next.waiting.splice(0).forEach((waiter) => waiter()); + next.image.crossOrigin = "anonymous"; + next.image.addEventListener("load", settle, { once: true }); + next.image.addEventListener("error", settle, { once: true }); + next.image.src = source.currentSrc; + corsCopiesForOpaqueDocuments.set(source, (copy = next)); + } + if (copy.image.complete) return copy.image.naturalWidth > 0 ? copy.image : source; + copy.waiting.push(redraw); + return null; } function getDrawableSource(element: ColorGradingMediaElement): TexImageSource | null { From 19e659ec4c70770676f36f90ff4ccf8cdd20e556 Mon Sep 17 00:00:00 2001 From: Miguel Angel Simon Sierra Date: Fri, 9 Oct 2026 18:44:47 -0700 Subject: [PATCH 5/5] fix(runtime): a loading CORS copy redraws each waiting grading once --- .../core/src/runtime/colorGrading.test.ts | 3 ++- packages/core/src/runtime/colorGrading.ts | 21 ++++++++++++------- 2 files changed, 16 insertions(+), 8 deletions(-) diff --git a/packages/core/src/runtime/colorGrading.test.ts b/packages/core/src/runtime/colorGrading.test.ts index 51a1b03229..f7916e93a1 100644 --- a/packages/core/src/runtime/colorGrading.test.ts +++ b/packages/core/src/runtime/colorGrading.test.ts @@ -769,8 +769,9 @@ describe("createColorGradingRuntime", () => { "anonymous", ]); expect(uploads()).not.toContain(image); + for (let tick = 0; tick < 5; tick += 1) runtime.redrawAnimated(); settle(copy, 640); - expect(uploads().at(-1)).toBe(copy); + expect(uploads().filter((upload) => upload === copy)).toHaveLength(1); expect(uploads()).not.toContain(image); }); diff --git a/packages/core/src/runtime/colorGrading.ts b/packages/core/src/runtime/colorGrading.ts index 3784edd943..f2b932afb7 100644 --- a/packages/core/src/runtime/colorGrading.ts +++ b/packages/core/src/runtime/colorGrading.ts @@ -2497,10 +2497,13 @@ function keepCanvasAboveSource(entry: ColorGradingEntry, source: HTMLImageElemen const corsCopiesForOpaqueDocuments = new WeakMap< HTMLImageElement, - { image: HTMLImageElement; waiting: (() => void)[] } + { image: HTMLImageElement; waiting: Set } >(); -function readablePixels(source: TexImageSource, redraw: () => void): TexImageSource | null { +function readablePixels( + source: TexImageSource, + waiter: ColorGradingEntry | null, +): TexImageSource | null { if (!isImageElement(source) || window.origin !== "null" || source.crossOrigin !== null) return source; if (!/^https?:/i.test(source.currentSrc)) return source; @@ -2508,9 +2511,13 @@ function readablePixels(source: TexImageSource, redraw: () => void): TexImageSou if (copy?.image.src !== source.currentSrc) { const next = { image: source.ownerDocument.createElement("img"), - waiting: [] as (() => void)[], + waiting: new Set(), + }; + const settle = () => { + const waiting = [...next.waiting]; + next.waiting.clear(); + waiting.forEach(drawEntry); }; - const settle = () => next.waiting.splice(0).forEach((waiter) => waiter()); next.image.crossOrigin = "anonymous"; next.image.addEventListener("load", settle, { once: true }); next.image.addEventListener("error", settle, { once: true }); @@ -2518,7 +2525,7 @@ function readablePixels(source: TexImageSource, redraw: () => void): TexImageSou corsCopiesForOpaqueDocuments.set(source, (copy = next)); } if (copy.image.complete) return copy.image.naturalWidth > 0 ? copy.image : source; - copy.waiting.push(redraw); + if (waiter) copy.waiting.add(waiter); return null; } @@ -3080,7 +3087,7 @@ function bindProgramTextures( function drawEntry(entry: ColorGradingEntry): boolean { if (entry.destroyed || entry.contextLost) return false; const source = getDrawableSource(entry.element); - const pixels = source && readablePixels(source, () => drawEntry(entry)); + const pixels = source && readablePixels(source, entry); if (!source || !pixels) { if (!entry.hasDrawn) entry.canvas.style.display = "none"; return false; @@ -3240,7 +3247,7 @@ function preparePreviewFrame( useMediaTime: boolean, ): PreviewFrame | null { const source = getDrawableSource(element); - const pixels = source && readablePixels(source, () => undefined); + const pixels = source && readablePixels(source, null); if (!source || !pixels) return null; const sourceSize = readSourceSize(source); if (!sourceSize) return null;