diff --git a/docs/content/architecture/frontend/ui-core.md b/docs/content/architecture/frontend/ui-core.md index 714491c2..023b1945 100644 --- a/docs/content/architecture/frontend/ui-core.md +++ b/docs/content/architecture/frontend/ui-core.md @@ -89,6 +89,48 @@ how it is grown instead. > something this browser can do. They are not two spellings of one idea, and neither is > derivable from the other. +## The displayed asset is the browser pixel source + +`AssetImage` fetches an asset once through the credentialed data client, retains that exact Blob +only while the asset is mounted, and owns the object URL it gives the existing annotator image. +The URL is revoked and an unfinished request is aborted when the asset is replaced or unmounted. +There is no JavaScript Blob cache: revisiting an asset relies on the server's immutable HTTP cache. + +The React annotator adapter can report a generation-scoped lease over the same visible decoded +`` — `AnnotatorCanvas`'s `onImageReady`, fired from that image's own `onLoad`. A future host +that needs pixels reads them lazily from that lease: nothing draws to a canvas or calls +`getImageData` until a caller asks for `readRgb(width, height)`, in the asset descriptor's +coordinate frame, and ordinary mounting and display never asks. It does not fetch or decode a +second copy of the asset, and it does not construct a second `Image` to read from — the element +handed back is the exact node the person is looking at. A lease refuses after its image source is +replaced *or* after its owning `AnnotatorCanvas` instance unmounts, which prevents a retained +reference for one asset from reading pixels of the next asset either through a reused DOM node or +through the detached one a host's real asset-switch (unmount the old canvas, mount a fresh one) +leaves behind. + +This is resource plumbing only. It neither selects a browser model nor exposes an inference +target — no capability in this phase lets a host ask for local inference at all; composing pixels +with a browser runtime remains a later host decision. + +**What is proved, and what is not claimed.** `e2e/assetPixels.spec.ts` drives a real Chromium +against a tiny runtime-generated image and confirms, in that browser, on that image: one content +request, the same decoded `` handed back as the pixel source, and the exact descriptor-frame +RGB a real 2D context produced. That is evidence about this seam's wiring, not a claim that a +browser's canvas decode agrees with the server's own (Pillow) decode byte-for-byte in general — +this phase changes no server decoding, and takes no position on cross-decoder parity beyond what +is measured here. + +**Measured browser-canvas-vs-Pillow divergence.** A separate, informal comparison against the +server's `convert("RGB")` decode found three real categories of disagreement: alpha-premultiplication +rounding loss on partially-transparent pixels, EXIF-orientation auto-rotation (a browser applies an +image's EXIF orientation when decoding to canvas; the server's direct-bytes decode path does not), +and AdobeRGB ICC-profile color management (a browser color-manages a tagged profile toward sRGB on +decode; the server's path does not). None of these are exact figures worth repeating here — they +are a known limitation, not a benchmark — and no consumer of this lease has yet needed to reconcile +them, since nothing in this phase reads pixels for inference. A future phase that feeds this lease's +`readRgb` output to a model must account for these divergences before treating browser-decoded +pixels as equivalent to the server's own decode of the same asset. + ## Asking for a suggestion is not sending one `inference/suggestionExecutor.ts` is the seam the runtime above would plug into, and it exists diff --git a/frontend/annotator/src/adapters/react/AnnotatorCanvas.tsx b/frontend/annotator/src/adapters/react/AnnotatorCanvas.tsx index e8cd32a7..24913823 100644 --- a/frontend/annotator/src/adapters/react/AnnotatorCanvas.tsx +++ b/frontend/annotator/src/adapters/react/AnnotatorCanvas.tsx @@ -207,6 +207,7 @@ import { } from "../viewport"; import type { Viewport } from "../viewport"; import { AnnotationLayer } from "./AnnotationLayer"; +import { createDecodedAssetImage, type DecodedAssetImage } from "./decodedAssetImage"; import { useAnnotatorSnapshot } from "./hooks"; import { digitFromCode, isComposing, isTextEntry } from "./keyboard"; import { classColor, editedId, paintAnnotation, paintSuggestions } from "./paint"; @@ -257,6 +258,20 @@ export interface AnnotatorCanvasProps { * individually plausible and uniformly wrong. */ readonly imageSrc: string; + /** + * The existing rendered image once its exact source has decoded. + * + * The source is generation-scoped: after `imageSrc` changes on a live + * instance, or after this component unmounts, a previously delivered source + * refuses pixel reads rather than reading the replacement through React's + * reused image node or a detached one. + * + * Every host today switches assets by unmounting this component rather than + * changing `imageSrc` in place — see the comment beside the `onLoad` handler + * for the one race that leaves open on a live-instance switch this contract + * has never had to close. + */ + readonly onImageReady?: (source: DecodedAssetImage) => void; /** The class a drawing gesture will carry. `null` is select mode. */ readonly activeClass: string | null; /** @@ -488,6 +503,7 @@ const EMPTY_SUGGESTIONS: readonly PaintedSuggestion[] = []; export function AnnotatorCanvas({ store, imageSrc, + onImageReady, activeClass, activeTool = null, onActivateClass, @@ -514,6 +530,25 @@ export function AnnotatorCanvas({ const rootRef = useRef(null); const paneRef = useRef(null); + const imageSrcNow = useRef(imageSrc); + const imageGeneration = useRef(0); + if (imageSrcNow.current !== imageSrc) { + imageSrcNow.current = imageSrc; + imageGeneration.current += 1; + } + + // A host switches assets by unmounting this component and mounting a fresh + // one for the next asset, not by changing `imageSrc` on a live instance — + // so the ordinary generation bump above never runs. Without this, a lease + // handed out before teardown still finds its captured generation equal to + // `imageGeneration.current` and its detached `` still `complete` with + // its old `src` attribute intact, and `readRgb` would hand back the wrong + // asset's pixels from a component nothing renders any more. + useEffect(() => { + return () => { + imageGeneration.current += 1; + }; + }, []); // The fallback, built once — see the prop's docstring for why `useState`. const [ownClipboard] = useState(createClipboard); @@ -1465,6 +1500,31 @@ export function AnnotatorCanvas({ > { + // React always calls the handler from the most recently committed + // render, never the one attached when this particular `load` was + // queued — so both checks below compare the latest `imageSrc` + // against itself on a live instance whose source changed twice in + // a row before the first `load` fired, and cannot by themselves + // refuse a stale event delivered after such a change. No caller + // does this today: every host switches assets by unmounting this + // component (the effect below covers that), and a delayed load + // for an abandoned request is a case browsers do not dispatch — + // they fire `load`/`error` only for an image element's current + // request. A future host that mutates `imageSrc` on a live + // instance without remounting should re-examine this before + // relying on it. + const image = event.currentTarget; + if (imageSrcNow.current !== imageSrc || image.getAttribute("src") !== imageSrc) return; + onImageReady?.( + createDecodedAssetImage( + image, + imageSrc, + imageGeneration.current, + () => imageGeneration.current, + ), + ); + }} alt="" aria-hidden="true" // Named so the pixelated-at-depth rule is asserted against the diff --git a/frontend/annotator/src/adapters/react/decodedAssetImage.test.ts b/frontend/annotator/src/adapters/react/decodedAssetImage.test.ts new file mode 100644 index 00000000..91387bcb --- /dev/null +++ b/frontend/annotator/src/adapters/react/decodedAssetImage.test.ts @@ -0,0 +1,41 @@ +import { describe, expect, it, vi } from "vitest"; + +import { createDecodedAssetImage, rgbPixelsFromDecodedImage } from "./decodedAssetImage"; + +describe("decoded asset image", () => { + it("refuses a retained A source once its image generation is replaced by B", () => { + let current = 1; + const image = {} as HTMLImageElement; + const read = vi.fn(() => ({ width: 1, height: 1, rgb: new Uint8Array([1, 2, 3]) })); + const source = createDecodedAssetImage(image, "blob:a", 1, () => current, read); + + current = 2; + + expect(() => source.readRgb(1, 1)).toThrow("image source is no longer current"); + expect(read).not.toHaveBeenCalled(); + }); + + it("converts descriptor-frame RGBA pixels into exact row-major RGB", () => { + // A distinct object, unlike `{}` — asserted below by identity, not merely by + // shape. `rgbPixelsFromDecodedImage` must draw the exact rendered image it + // was handed, never a second `Image` it constructs to read from. + const image = { tag: "the rendered image" } as unknown as HTMLImageElement; + const drawImage = vi.fn(); + const getImageData = vi.fn(() => ({ data: new Uint8ClampedArray([1, 2, 3, 4, 5, 6, 7, 8]) })); + const context = { drawImage, getImageData }; + vi.stubGlobal("document", { + createElement: () => ({ width: 0, height: 0, getContext: () => context }), + }); + + const pixels = rgbPixelsFromDecodedImage(image, 2, 1); + + expect(pixels).toEqual({ width: 2, height: 1, rgb: new Uint8Array([1, 2, 3, 5, 6, 7]) }); + // The exact requested frame, not the image's own natural size (`image` above + // carries no `naturalWidth`/`naturalHeight` at all, so a mutation reading + // those instead would call both of these with `undefined`) and not a + // transposed pair either. + expect(drawImage).toHaveBeenCalledWith(image, 0, 0, 2, 1); + expect(getImageData).toHaveBeenCalledWith(0, 0, 2, 1); + vi.unstubAllGlobals(); + }); +}); diff --git a/frontend/annotator/src/adapters/react/decodedAssetImage.ts b/frontend/annotator/src/adapters/react/decodedAssetImage.ts new file mode 100644 index 00000000..d888625a --- /dev/null +++ b/frontend/annotator/src/adapters/react/decodedAssetImage.ts @@ -0,0 +1,55 @@ +/** A browser-only, generation-scoped lease over the image the adapter already renders. */ + +export interface RgbPixels { + readonly width: number; + readonly height: number; + readonly rgb: Uint8Array; +} + +export interface DecodedAssetImage { + readonly image: HTMLImageElement; + readonly src: string; + readRgb(width: number, height: number): RgbPixels; +} + +type RgbReader = (image: HTMLImageElement, width: number, height: number) => RgbPixels; + +export function createDecodedAssetImage( + image: HTMLImageElement, + src: string, + generation: number, + currentGeneration: () => number, + read: RgbReader = rgbPixelsFromDecodedImage, +): DecodedAssetImage { + return { + image, + src, + readRgb(width, height) { + if (currentGeneration() !== generation || image.getAttribute("src") !== src) { + throw new Error("image source is no longer current"); + } + return read(image, width, height); + }, + }; +} + +export function rgbPixelsFromDecodedImage( + image: HTMLImageElement, + width: number, + height: number, +): RgbPixels { + const canvas = document.createElement("canvas"); + canvas.width = width; + canvas.height = height; + const context = canvas.getContext("2d"); + if (context === null) throw new Error("2D canvas context is unavailable"); + context.drawImage(image, 0, 0, width, height); + const rgba = context.getImageData(0, 0, width, height).data; + const rgb = new Uint8Array(width * height * 3); + for (let source = 0, target = 0; source < rgba.length; source += 4, target += 3) { + rgb[target] = rgba[source]; + rgb[target + 1] = rgba[source + 1]; + rgb[target + 2] = rgba[source + 2]; + } + return { width, height, rgb }; +} diff --git a/frontend/annotator/src/adapters/react/index.ts b/frontend/annotator/src/adapters/react/index.ts index 9265b40d..035b28e0 100644 --- a/frontend/annotator/src/adapters/react/index.ts +++ b/frontend/annotator/src/adapters/react/index.ts @@ -12,6 +12,7 @@ export { type AnnotatorCanvasProps, type AnnotatorView, } from "./AnnotatorCanvas"; +export type { DecodedAssetImage, RgbPixels } from "./decodedAssetImage"; export { useAnnotatorSnapshot, useAnnotatorStore, diff --git a/frontend/annotator/src/index.ts b/frontend/annotator/src/index.ts index a0fe7e86..fa6e5c24 100644 --- a/frontend/annotator/src/index.ts +++ b/frontend/annotator/src/index.ts @@ -344,12 +344,14 @@ export { type AnnotatorCanvasProps, type AnnotatorView, type CompositionProbe, + type DecodedAssetImage, type PaintedAnnotation, type PaintedSuggestion, type PendingIndicator, type PendingIndicatorState, type PendingPhase, type PendingPolygon, + type RgbPixels, type TextEntryProbe, type TransientLayerProps, } from "./adapters/react"; diff --git a/frontend/app/e2e/assetPixels.spec.ts b/frontend/app/e2e/assetPixels.spec.ts new file mode 100644 index 00000000..a219e588 --- /dev/null +++ b/frontend/app/e2e/assetPixels.spec.ts @@ -0,0 +1,28 @@ +/** + * The displayed asset is the browser pixel source — proved in a real browser. + * + * `docs/content/architecture/frontend/ui-core.md` states the rule and the unit + * tests beside `AssetImage` and `decodedAssetImage.ts` hold most of it, but jsdom's + * canvas does not decode a real image: nothing in that suite can show that a real + * ``, drawn through a real 2D context, yields the exact bytes the descriptor + * frame promises. `/demo?scene=asset-pixels` (`src/demo/AssetPixelsFixture.tsx`) is a + * test-only host that composes the real `AssetImage` and `AnnotatorCanvas` against + * a tiny RGBA image encoded to a PNG at runtime, and this is the one scenario that + * reads it. + */ + +import { expect, test } from "@playwright/test"; + +test("reuses the visible decoded image for exact descriptor-frame RGB", async ({ page }) => { + await page.goto("/demo?scene=asset-pixels"); + await expect(page.getByTestId("annotator-image")).toBeVisible(); + await expect(page.getByTestId("pixel-source-same-image")).toHaveText("true"); + await expect(page.getByTestId("pixel-rgb")).toHaveText("1,2,3,5,6,7,9,10,11"); + await expect + .poll(() => + page.evaluate( + () => (window as unknown as { __assetContentRequests?: number }).__assetContentRequests, + ), + ) + .toBe(1); +}); diff --git a/frontend/app/src/demo/AssetPixelsFixture.tsx b/frontend/app/src/demo/AssetPixelsFixture.tsx new file mode 100644 index 00000000..3ba6ab76 --- /dev/null +++ b/frontend/app/src/demo/AssetPixelsFixture.tsx @@ -0,0 +1,168 @@ +/** + * A test-only harness proving the displayed asset is the browser pixel source. + * + * `docs/content/architecture/frontend/ui-core.md`'s rule — the visible `` is + * reused as the browser pixel source, with no second fetch and no independent + * decode — has one half no unit test can reach: jsdom's canvas does not decode a + * real image, so `getImageData` there is either absent or a stub a test supplies + * itself. This page composes the real `AssetImage` and `AnnotatorCanvas` exactly + * as `AnnotationPage` does, against a tiny RGBA image **encoded to a PNG blob at + * runtime** (no binary fixture file, so the exact bytes are never stale or + * regenerated by hand) — so `e2e/assetPixels.spec.ts` can prove the seam in a + * real browser: one content request, the same decoded `` handed back as the + * pixel source, and the exact descriptor-frame RGB a real canvas produced. + * + * No token, no server, not linked from anywhere in the product — the same + * standing as `/demo` and `/styleguide`. + */ + +import { AnnotatorCanvas, AnnotatorStore, documentFromWire } from "@visionset/annotator"; +import { AssetImage, VisionSetDataProvider } from "@visionset/ui-core"; +import type { DataResult, VisionSetDataClient } from "@visionset/ui-core"; +import { useEffect, useMemo, useState, type JSX } from "react"; + +declare global { + interface Window { + /** How many times the stub client has delivered content. Reset on load. */ + __assetContentRequests?: number; + } +} + +const PROJECT_ID = "fixture-project"; +const ASSET_ID = "fixture-asset"; +const WIDTH = 3; +const HEIGHT = 1; + +/** + * Three RGBA pixels, alpha fully opaque. + * + * A low alpha would make the PNG round-trip lossy — canvas premultiplies alpha + * on encode, and an opaque pixel is the one value that survives unpremultiplying + * exactly. `1,2,3` / `5,6,7` / `9,10,11` is what `pixel-rgb` below must read back + * once the fourth (alpha) byte of each RGBA quadruple is dropped. + */ +const RGBA = new Uint8ClampedArray([1, 2, 3, 255, 5, 6, 7, 255, 9, 10, 11, 255]); + +function fixtureStore(): AnnotatorStore { + return new AnnotatorStore( + documentFromWire({ + asset: { id: ASSET_ID, width: WIDTH, height: HEIGHT }, + schema: { project_id: PROJECT_ID, version: 1, classes: [] }, + annotations: [], + }), + ); +} + +/** A real PNG, built from the exact bytes above — never a checked-in fixture. */ +async function encodeFixturePng(): Promise { + const canvas = document.createElement("canvas"); + canvas.width = WIDTH; + canvas.height = HEIGHT; + const context = canvas.getContext("2d"); + if (context === null) throw new Error("2D canvas context is unavailable"); + context.putImageData(new ImageData(RGBA, WIDTH, HEIGHT), 0, 0); + return await new Promise((resolve, reject) => { + canvas.toBlob((blob) => { + if (blob === null) reject(new Error("canvas produced no blob")); + else resolve(blob); + }, "image/png"); + }); +} + +/** + * A minimal `VisionSetDataClient`: it answers the one route `AssetImage` calls, + * counts a delivery only once it actually settles, and rejects a request whose + * signal is already or becomes aborted — the same contract clause a real host + * honours (`data/port.ts`'s "a request cancelled through its signal rejects"). + * + * Delivery is deferred one macrotask so a synchronous `StrictMode` remount's + * cleanup — the abort of the first mount's controller — has already landed on + * `signal` before this settles, the ordering a real network transfer has for + * free. Without it, a mutation that dropped the abort could still read as one + * request here purely by winning a race, which would prove nothing. + */ +function fixtureClient(png: Blob, onDelivered: () => void): VisionSetDataClient { + const GET = (path: string, init?: { readonly signal?: AbortSignal }): Promise => { + if (!path.endsWith("/content")) return Promise.resolve({ ok: false, failure: "unreachable" }); + const signal = init?.signal; + return new Promise((resolve, reject) => { + if (signal?.aborted === true) { + reject(new DOMException("The operation was aborted.", "AbortError")); + return; + } + const onAbort = (): void => { + window.clearTimeout(timer); + reject(new DOMException("The operation was aborted.", "AbortError")); + }; + const timer = window.setTimeout(() => { + signal?.removeEventListener("abort", onAbort); + if (signal?.aborted === true) { + reject(new DOMException("The operation was aborted.", "AbortError")); + return; + } + onDelivered(); + resolve({ ok: true, data: png }); + }, 0); + signal?.addEventListener("abort", onAbort, { once: true }); + }); + }; + const refuse = (): Promise => Promise.resolve({ ok: false, failure: "unreachable" }); + return { + GET, + POST: refuse, + PUT: refuse, + PATCH: refuse, + DELETE: refuse, + } as unknown as VisionSetDataClient; +} + +export function AssetPixelsFixture(): JSX.Element { + const [png, setPng] = useState(null); + const [sameImage, setSameImage] = useState("pending"); + const [rgb, setRgb] = useState("pending"); + const store = useMemo(fixtureStore, []); + + useEffect(() => { + window.__assetContentRequests = 0; + let cancelled = false; + void encodeFixturePng().then((blob) => { + if (!cancelled) setPng(blob); + }); + return () => { + cancelled = true; + }; + }, []); + + const client = useMemo(() => { + if (png === null) return null; + return fixtureClient(png, () => { + window.__assetContentRequests = (window.__assetContentRequests ?? 0) + 1; + }); + }, [png]); + + if (client === null) return
; + + return ( + +
+ + {(src) => ( + {}} + onImageReady={(source) => { + const visible = window.document.querySelector('[data-testid="annotator-image"]'); + setSameImage(source.image === visible ? "true" : "false"); + setRgb(Array.from(source.readRgb(WIDTH, HEIGHT).rgb).join(",")); + }} + /> + )} + +
+ {sameImage} + {rgb} +
+ ); +} diff --git a/frontend/app/src/routes.tsx b/frontend/app/src/routes.tsx index 2e9475e1..d394e8f9 100644 --- a/frontend/app/src/routes.tsx +++ b/frontend/app/src/routes.tsx @@ -30,7 +30,9 @@ * The benchmark keeps its query parameter (`/demo?scene=bench`) rather than * gaining a route: it is an instrument, its recorded numbers were taken against * that exact page, and moving it would change what it measures for no reason - * anybody asked for. + * anybody asked for. `e2e/assetPixels.spec.ts`'s fixture rides the same route + * behind `/demo?scene=asset-pixels`, for the same reason: test-only, not linked + * from the product, so it earns no route of its own either. * * ## Every route has a screen * @@ -59,6 +61,7 @@ import { Navigate, Route, Routes, useNavigate, useParams, useSearchParams } from import type { JSX } from "react"; import { AnnotatorDemo } from "./demo/AnnotatorDemo"; +import { AssetPixelsFixture } from "./demo/AssetPixelsFixture"; import { BenchmarkHost } from "./demo/BenchmarkHost"; import { ShowcaseFrame } from "./demo/ShowcaseFrame"; import { AppShell, FullBleedPane, PaddedPane, ProjectPane } from "./shell/AppShell"; @@ -139,10 +142,15 @@ export function AppRoutes(): JSX.Element { ); } -/** The showcase, and the benchmark behind its query parameter. */ +/** + * The showcase, the benchmark and the asset-pixels test fixture, each behind + * its own `scene` query value. + */ function Showcase(): JSX.Element { const [query] = useSearchParams(); - const bench = query.get("scene") === "bench"; + const scene = query.get("scene"); + if (scene === "asset-pixels") return ; + const bench = scene === "bench"; return ( {bench ? : } diff --git a/frontend/ui-core/src/annotator/AssetImage.tsx b/frontend/ui-core/src/annotator/AssetImage.tsx index 4e58784c..3f84252d 100644 --- a/frontend/ui-core/src/annotator/AssetImage.tsx +++ b/frontend/ui-core/src/annotator/AssetImage.tsx @@ -33,9 +33,14 @@ export interface AssetImageProps { readonly children: (src: string) => ReactNode; } +interface AssetImageResource { + readonly src: string; + readonly blob: Blob; +} + export function AssetImage({ projectId, assetId, children }: AssetImageProps): JSX.Element { const client = useApiClient(); - const [src, setSrc] = useState(null); + const [resource, setResource] = useState(null); const [failed, setFailed] = useState(false); useEffect(() => { @@ -46,7 +51,7 @@ export function AssetImage({ projectId, assetId, children }: AssetImageProps): J // running to completion (#572). const controller = new AbortController(); let objectUrl: string | null = null; - setSrc(null); + setResource(null); setFailed(false); void (async () => { @@ -63,8 +68,9 @@ export function AssetImage({ projectId, assetId, children }: AssetImageProps): J setFailed(true); return; } - objectUrl = URL.createObjectURL(result.data as unknown as Blob); - setSrc(objectUrl); + const blob = result.data as unknown as Blob; + objectUrl = URL.createObjectURL(blob); + setResource({ src: objectUrl, blob }); } catch { // The abort lands here by design. Anything else is a network that // died, which the error state answers better than an eternal skeleton. @@ -92,9 +98,9 @@ export function AssetImage({ projectId, assetId, children }: AssetImageProps): J ); } - if (src === null) { + if (resource === null) { return
; } - return <>{children(src)}; + return <>{children(resource.src)}; } diff --git a/frontend/ui-core/src/annotator/assetImage.test.tsx b/frontend/ui-core/src/annotator/assetImage.test.tsx index 25a5597b..8889276b 100644 --- a/frontend/ui-core/src/annotator/assetImage.test.tsx +++ b/frontend/ui-core/src/annotator/assetImage.test.tsx @@ -3,11 +3,12 @@ * honest about a network that is down (#572). */ -import { screen, waitFor } from "@testing-library/react"; +import { render, screen, waitFor } from "@testing-library/react"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import type { ReactNode } from "react"; +import type { JSX, ReactNode } from "react"; -import { renderWithData } from "../testing/dataHarness"; +import { VisionSetDataProvider } from "../data/VisionSetDataProvider"; +import { harnessClient, harnessQueryClient, renderWithData } from "../testing/dataHarness"; import { AssetImage } from "./AssetImage"; const PROJECT = "11111111-1111-4111-8111-111111111111"; @@ -56,6 +57,14 @@ function image(): ReactNode { ); } +function asset(assetId: string): ReactNode { + return ( + + {(src) => frame} + + ); +} + describe("the asset's pixels", () => { it("fetches them with the credential and hands an object URL to the child", async () => { on("GET", /\/content$/, { status: 200, body: null }); @@ -65,6 +74,35 @@ describe("the asset's pixels", () => { expect(frame.getAttribute("src") ?? "").toMatch(/^blob:/); }); + it("hands createObjectURL a Blob carrying the exact fetched bytes, not merely a Blob", async () => { + // A trivial `expect.any(Blob)`-shaped check would pass for any Blob at all + // (jsdom's blob has no enumerable own properties, so `toHaveBeenCalledWith` + // would even accept one with unrelated content) — the content check below is + // what actually distinguishes "the fetched resource" from "a Blob". + const bytes = "the-exact-fetched-bytes"; + vi.stubGlobal("fetch", async (request: Request) => { + sent.push(request); + // A string body, not a `Blob` one: this jsdom/undici pairing mangles a + // `Response` constructed directly from a `Blob` (its `.blob()` comes back + // stringified to "[object Blob]"), a test-environment quirk unrelated to + // the code under test. A string body round-trips through `.blob()` + // correctly and is what a real network response looks like anyway. + return new Response(bytes, { status: 200, headers: { "content-type": "image/png" } }); + }); + const createObjectURL = vi.spyOn(URL, "createObjectURL"); + + renderWithData(image()); + await screen.findByTestId("the-frame"); + + expect(sent).toHaveLength(1); + expect(createObjectURL).toHaveBeenCalledTimes(1); + const received = createObjectURL.mock.calls[0]?.[0]; + expect(received).toBeInstanceOf(Blob); + await expect((received as Blob).text()).resolves.toBe(bytes); + + createObjectURL.mockRestore(); + }); + it("aborts the transfer when it unmounts mid-flight (#572)", async () => { // Walking a job with the arrow keys unmounts each frame's image; before // the abort, every skipped frame's full-size download ran to completion. @@ -76,6 +114,28 @@ describe("the asset's pixels", () => { expect(sent[0].signal.aborted).toBe(true); }); + it("revokes the replaced asset URL and aborts its completed request", async () => { + on("GET", /\/content$/, { status: 200, body: null }); + const revoke = vi.spyOn(URL, "revokeObjectURL"); + const client = harnessClient(); + const queries = harnessQueryClient(); + const scope = Symbol("asset-image-test"); + const wrap = (assetId: string): JSX.Element => ( + queries}> + {asset(assetId)} + + ); + const view = render(wrap("asset-a")); + const first = await screen.findByTestId("the-frame"); + const firstSrc = first.getAttribute("src"); + + view.rerender(wrap("asset-b")); + await waitFor(() => expect(sent).toHaveLength(2)); + + expect(sent[0]?.signal.aborted).toBe(true); + expect(revoke).toHaveBeenCalledWith(firstSrc); + }); + it("shows the failure state when the fetch itself throws", async () => { // A rejected fetch (network down) used to be an unhandled rejection and an // eternal loading skeleton; the abort turned rejection into an ordinary diff --git a/frontend/ui-core/src/annotator/decodedImageLease.test.tsx b/frontend/ui-core/src/annotator/decodedImageLease.test.tsx new file mode 100644 index 00000000..07363dd3 --- /dev/null +++ b/frontend/ui-core/src/annotator/decodedImageLease.test.tsx @@ -0,0 +1,90 @@ +import { + AnnotatorCanvas, + AnnotatorStore, + documentFromWire, + type DecodedAssetImage, + type RgbPixels, +} from "@visionset/annotator"; +import { fireEvent, render, screen } from "@testing-library/react"; +import { describe, expect, it, vi } from "vitest"; + +function store(): AnnotatorStore { + return new AnnotatorStore( + documentFromWire({ + asset: { id: "asset", width: 2, height: 1 }, + schema: { project_id: "project", version: 1, classes: [] }, + annotations: [], + }), + ); +} + +// `DecodedAssetImage` resolving here, from the package's real entry point +// rather than a path into its internals, is the regression guard: the type +// is otherwise easy to re-export from the React adapter alone and miss the +// package barrel that actually reaches a host. +function canvas(imageSrc: string, ready: (source: DecodedAssetImage) => void) { + return ; +} + +describe("decoded image lease", () => { + it("hands the exact rendered image to the ready callback", () => { + const ready = vi.fn(); + render(canvas("blob:a", ready)); + const image = screen.getByTestId("annotator-image") as HTMLImageElement; + fireEvent.load(image); + expect(ready.mock.calls[0][0].image).toBe(image); + }); + + it("does not let retained A read through the DOM node reused for B", () => { + const ready = vi.fn(); + const view = render(canvas("blob:a", ready)); + const image = screen.getByTestId("annotator-image") as HTMLImageElement; + fireEvent.load(image); + const sourceA = ready.mock.calls[0][0]; + view.rerender(canvas("blob:b", ready)); + expect(screen.getByTestId("annotator-image")).toBe(image); + expect(() => sourceA.readRgb(2, 1)).toThrow("image source is no longer current"); + }); + + it("refuses a retained lease once its owning component has unmounted", () => { + // The real host swaps assets by unmounting the old `AnnotatorCanvas` + // instance and mounting a fresh one, not by changing `imageSrc` on a live + // instance — so this is the path the generation-bump-on-`imageSrc`-change + // above does not cover. A lease taken out before teardown must refuse + // afterward even though nothing ever changed its `src` attribute. + const ready = vi.fn(); + const view = render(canvas("blob:a", ready)); + const image = screen.getByTestId("annotator-image") as HTMLImageElement; + fireEvent.load(image); + const source = ready.mock.calls[0][0]; + + view.unmount(); + + expect(() => source.readRgb(2, 1)).toThrow("image source is no longer current"); + }); + + it("surfaces RgbPixels from the package entry point, shaped as readRgb returns it", () => { + // A compile-time proof standing in beside the runtime ones above: if + // `RgbPixels` stopped being re-exported from `@visionset/annotator`, this + // file would fail to typecheck rather than merely fail at runtime. + const pixels: RgbPixels = { width: 1, height: 1, rgb: new Uint8Array([1, 2, 3]) }; + expect(pixels.rgb.length).toBe(pixels.width * pixels.height * 3); + }); + + it("never touches canvas pixel extraction during ordinary rendering", () => { + // `rgbPixelsFromDecodedImage` unconditionally calls `getContext("2d")` on a + // canvas it creates, so a spy that observes zero calls to that prototype + // method is proof the lazy helper never ran — ordinary mounting and an + // image `load` only ever hand back the lease, they never read it. + const getContext = vi.spyOn(HTMLCanvasElement.prototype, "getContext"); + const ready = vi.fn(); + render(canvas("blob:a", ready)); + const image = screen.getByTestId("annotator-image") as HTMLImageElement; + fireEvent.load(image); + + expect(ready).toHaveBeenCalledTimes(1); + expect(getContext).not.toHaveBeenCalled(); + + getContext.mockRestore(); + }); +});