import { afterEach, describe, expect, it, vi } from "vitest"; import { createHydrationModelAsset } from "../../model-import/model-persistence-hydration-test-support"; import { renderToolcraftArtifactFrame } from "../../export/artifact-frame-renderer"; import { createToolcraftArtifactExportResources } from "../../export/artifact-export-resources"; import { createToolcraftArtifactSnapshot } from "../../export/artifact-export-snapshot"; import { defineToolcraft } from "../../schema/define-toolcraft"; import { createToolcraftState } from "../../state/create-template-state"; import { createContext, createHost } from "./runtime-scene-export.test-support"; import { renderToolcraftRuntimeSceneToCanvas } from "./runtime-scene-export"; afterEach(() => { vi.unstubAllGlobals(); }); describe("runtime scene export", () => { it("draws 30 frames through one job-scoped decode and releases it after settlement", async () => { class LoadedImage { onerror: (() => void) | null = null; onload: (() => void) | null = null; set src(_value: string) { queueMicrotask(() => this.onload?.()); } } const createObjectURL = vi.fn(() => "blob:toolcraft-export"); const revokeObjectURL = vi.fn(); vi.stubGlobal("Image", LoadedImage); vi.stubGlobal("URL", { createObjectURL, revokeObjectURL }); const resourceRef = `media:image:sha256:${"a".repeat(64)}`; const state = createToolcraftState( defineToolcraft({ base: { identity: { id: "runtime-test", title: "Toolcraft App" }, canvas: { enabled: true }, panels: {}, }, modules: [], }), { mediaAssets: [ { assetKind: "image", lifecycle: "ready", fileName: "resource.png", id: "resource-image", layerId: "resource-layer", mimeType: "image/png", position: { x: 0, y: 0 }, resourceRef, size: { height: 20, unit: "px", width: 20 }, sourceSize: { height: 20, unit: "px", width: 20 }, }, ], }, ); const context = createContext([]); const canvas = { getContext: vi.fn(() => context), height: 100, width: 100, } as unknown as HTMLCanvasElement; const resolveImageResource = vi.fn(async () => new Uint8Array([1, 2, 3])); const signal = new AbortController().signal; const resources = createToolcraftArtifactExportResources({ state: createToolcraftArtifactSnapshot(state), signal, resolveResource: resolveImageResource, retainResourceRef: () => () => undefined, }); for (let index = 0; index < 30; index += 1) await expect( renderToolcraftRuntimeSceneToCanvas({ canvas, host: null, outputFrame: { height: 100, width: 100, x: 0, y: 0 }, loadImage: resources.loadImage, signal, state, visibility: { renderDefaultImages: true, suppressedModelTargets: [], }, }), ).resolves.toEqual({ imageCount: 1, modelCount: 0 }); expect(resolveImageResource).toHaveBeenCalledWith(resourceRef, { signal: expect.any(AbortSignal), }); expect(createObjectURL).toHaveBeenCalledOnce(); expect(resolveImageResource).toHaveBeenCalledOnce(); expect(revokeObjectURL).not.toHaveBeenCalled(); expect(context.drawImage).toHaveBeenCalledTimes(30); await resources.dispose(); expect(revokeObjectURL).toHaveBeenCalledWith("blob:toolcraft-export"); }); it("shares a decode between two assets without sharing their draw geometry", async () => { const state = createToolcraftState( defineToolcraft({ base: { identity: { id: "runtime-test", title: "Toolcraft fixture", }, canvas: { enabled: true }, panels: {}, }, modules: [], }), { mediaAssets: [20, 40].map((width, index) => ({ assetKind: "image" as const, fileName: "shared.png", id: `shared-${index}`, layerId: `layer-${index}`, lifecycle: "ready" as const, mimeType: "image/png", position: { x: index * 50, y: index * 25 }, resourceRef: `media:image:sha256:${"d".repeat(64)}`, size: { width, height: width, unit: "px" as const }, sourceSize: { width: 100, height: 100, unit: "px" as const }, })), }, ); const context = createContext([]); const canvas = { getContext: () => context, width: 100, height: 100, } as unknown as HTMLCanvasElement; const source = {} as CanvasImageSource; const close = vi.fn(); const decodeImage = vi.fn(async () => ({ source, dispose: close })); const resolveResource = vi.fn(async () => new Uint8Array([1])); const signal = new AbortController().signal; const resources = createToolcraftArtifactExportResources({ state: createToolcraftArtifactSnapshot(state), signal, decodeImage, resolveResource, retainResourceRef: () => () => undefined, }); await renderToolcraftRuntimeSceneToCanvas({ canvas, host: null, outputFrame: { x: 0, y: 0, width: 100, height: 100 }, loadImage: resources.loadImage, signal, state, visibility: { renderDefaultImages: true, suppressedModelTargets: [] }, }); expect(decodeImage).toHaveBeenCalledOnce(); expect(resolveResource).toHaveBeenCalledOnce(); expect(context.translate.mock.calls).toEqual([ [0, 0], [50, 25], ]); expect(context.drawImage.mock.calls).toEqual([ [source, 0, 0, 100, 100, -10, -10, 20, 20], [source, 0, 0, 100, 100, -20, -20, 40, 40], ]); await resources.dispose(); expect(close).toHaveBeenCalledOnce(); }); it("draws runtime models before images in preview order", async () => { const schema = defineToolcraft({ base: { identity: { id: "runtime-test", title: "Toolcraft App" }, canvas: { enabled: true }, panels: {}, }, modules: [], }); const state = createToolcraftState(schema, { canvas: { mode: "infinite" }, layers: [ { displayName: "Model", id: "model-layer", kind: "layer", name: "model", visible: true, }, { displayName: "Image", id: "image-layer", kind: "layer", name: "image", visible: true, }, ], mediaAssets: [ createHydrationModelAsset("document:model", { id: "model", layerId: "model-layer", lifecycle: "clean", position: { x: 0, y: 0 }, size: { height: 20, unit: "px", width: 20 }, sourceTarget: "source.model", }), { sourceSize: { height: 10, unit: "px", width: 20 }, assetKind: "image", lifecycle: "ready", resourceRef: "data:image/png;base64,test", fileName: "image.png", id: "image", layerId: "image-layer", mimeType: "image/png", position: { x: 50, y: 25 }, size: { height: 10, unit: "px", width: 20 }, }, ], }); const events: string[] = []; const context = createContext(events); const canvas = { getContext: vi.fn(() => context), height: 200, width: 400, } as unknown as HTMLCanvasElement; const modelSource = { kind: "model" } as unknown as HTMLCanvasElement; const imageSource = { kind: "image" } as unknown as CanvasImageSource; await expect( renderToolcraftRuntimeSceneToCanvas({ signal: new AbortController().signal, canvas, host: createHost(modelSource), loadImage: vi.fn(async () => imageSource), outputFrame: { height: 50, width: 100, x: 0, y: 0 }, state, visibility: { renderDefaultImages: true, suppressedModelTargets: [], }, }), ).resolves.toEqual({ imageCount: 1, modelCount: 1 }); expect(events).toEqual(["model", "image"]); expect(context.translate).toHaveBeenCalledWith(50, 25); expect(context.drawImage).toHaveBeenLastCalledWith( imageSource, 0, 0, 20, 10, -10, -5, 20, 10, ); }); it("samples source pixels by sourceSize and places the transformed scene frame", async () => { const state = createToolcraftState( defineToolcraft({ base: { identity: { id: "runtime-test", title: "Toolcraft fixture", }, canvas: { enabled: true }, panels: {}, }, modules: [], }), { canvas: { mode: "infinite" }, mediaAssets: [ { assetKind: "image", fileName: "square-source.png", id: "image", layerId: "image-layer", lifecycle: "ready", mimeType: "image/png", position: { x: 50, y: 25 }, resourceRef: `media:image:sha256:${"c".repeat(64)}`, size: { height: 100, unit: "px", width: 200 }, sourceSize: { height: 400, unit: "px", width: 400 }, transform: { rotationDeg: 90 }, }, ], }, ); const context = createContext([]); const canvas = { getContext: vi.fn(() => context), height: 400, width: 800, } as unknown as HTMLCanvasElement; const source = {} as CanvasImageSource; await renderToolcraftRuntimeSceneToCanvas({ signal: new AbortController().signal, canvas, host: null, loadImage: async () => source, outputFrame: { height: 200, width: 400, x: -100, y: -50 }, state, visibility: { renderDefaultImages: true, suppressedModelTargets: [], }, }); expect(context.translate).toHaveBeenCalledWith(50, 25); expect(context.rotate).toHaveBeenCalledWith(Math.PI / 2); expect(context.drawImage).toHaveBeenCalledWith( source, 0, 100, 400, 200, -100, -50, 200, 100, ); }); it("maps a rotated non-square image through one non-uniform world transform", async () => { const state = createToolcraftState( defineToolcraft({ base: { identity: { id: "runtime-test", title: "Toolcraft fixture", }, canvas: { enabled: true }, panels: {}, }, modules: [], }), { canvas: { mode: "infinite" }, mediaAssets: [ { assetKind: "image", fileName: "rotated.png", id: "image", layerId: "image-layer", lifecycle: "ready", mimeType: "image/png", position: { x: 50, y: 25 }, resourceRef: `media:image:sha256:${"d".repeat(64)}`, size: { height: 100, unit: "px", width: 200 }, sourceSize: { height: 400, unit: "px", width: 400 }, transform: { rotationDeg: 90 }, }, ], }, ); const context = createContext([]); const canvas = { getContext: vi.fn(() => context), height: 601, width: 1001, } as unknown as HTMLCanvasElement; const source = {} as CanvasImageSource; const outputFrame = { height: 200, width: 333, x: -111, y: -70 }; await renderToolcraftArtifactFrame({ signal: new AbortController().signal, backgroundColor: "#000000", canvas, includeBackground: false, outputFrame, pixelRatio: 3, productFrame: { kind: "empty", rect: null }, renderProductFrame: null, renderRuntimeScene: (target, frame, frameState) => renderToolcraftRuntimeSceneToCanvas({ signal: new AbortController().signal, canvas: target, host: null, loadImage: async () => source, outputFrame: frame, state: frameState, visibility: { renderDefaultImages: true, suppressedModelTargets: [], }, }), rendererPipeline: null, state, }); expect(context.setTransform).toHaveBeenNthCalledWith( 2, 1001 / 333, 0, 0, 601 / 200, (111 * 1001) / 333, (70 * 601) / 200, ); expect(context.translate).toHaveBeenCalledWith(50, 25); expect(context.rotate).toHaveBeenCalledWith(Math.PI / 2); expect(context.drawImage).toHaveBeenCalledWith( source, 0, 100, 400, 200, -100, -50, 200, 100, ); }); });