import { describe, expect, it, vi } from "vitest"; import { defineToolcraft } from "../schema/define-toolcraft"; import { createHydrationModelAsset } from "../model-import/model-persistence-hydration-test-support"; import { createToolcraftState } from "../state/create-template-state"; import { createToolcraftRuntimeSceneVisibility } from "../scene"; import { ToolcraftSceneExportError } from "./export-frame"; import { resolveToolcraftStillArtifactFrame, resolveToolcraftVideoArtifactFrame, } from "./artifact-scene-frame"; import { createToolcraftVideoFrameSchedule } from "./video-frame-schedule"; const visibility = createToolcraftRuntimeSceneVisibility({ renderDefaultImages: true, suppressedModelTargets: [], }); function createState(mode: "finite" | "infinite") { return createToolcraftState( defineToolcraft({ base: { identity: { id: "runtime-test", title: "Toolcraft App" }, canvas: { enabled: true }, panels: {}, }, modules: [], }), { canvas: { mode } }, ); } describe("artifact scene frame", () => { it("returns the centered finite output and exact declared product frame", () => { const state = createState("finite"); const boundsProvider = vi.fn(() => [ { height: 80, width: 120, x: -20, y: 15 }, ]); expect( resolveToolcraftStillArtifactFrame({ boundsProvider, productSceneRequired: true, state, visibility, }), ).toEqual({ outputFrame: { height: state.canvas.size.height, width: state.canvas.size.width, x: -state.canvas.size.width / 2, y: -state.canvas.size.height / 2, }, productFrame: { kind: "ready", rect: { height: 80, width: 120, x: -20, y: 15 }, }, }); expect(boundsProvider).toHaveBeenCalledWith({ state }); }); it("uses the centered finite artboard as the no-provider product fallback", () => { const state = createState("finite"); expect( resolveToolcraftStillArtifactFrame({ boundsProvider: undefined, productSceneRequired: true, state, visibility, }), ).toEqual({ outputFrame: { height: state.canvas.size.height, width: state.canvas.size.width, x: -state.canvas.size.width / 2, y: -state.canvas.size.height / 2, }, productFrame: { kind: "ready", rect: { height: state.canvas.size.height, width: state.canvas.size.width, x: -state.canvas.size.width / 2, y: -state.canvas.size.height / 2, }, }, }); }); it("unions moving infinite-scene bounds without retaining frame states", async () => { const state = createState("infinite"); const boundsProvider = vi.fn(({ state: frameState }) => [ { height: 10, width: 20, x: frameState.timeline.currentTimeSeconds * 300, y: -5, }, ]); expect( await resolveToolcraftVideoArtifactFrame({ boundsProvider, state, schedule: createToolcraftVideoFrameSchedule(0.1), signal: new AbortController().signal, yieldToBrowser: async () => undefined, productSceneRequired: true, visibility, }), ).toMatchObject({ outputFrame: { height: 10, width: 40, x: 0, y: -5 }, productFrames: [ { kind: "ready", rect: { height: 10, width: 20, x: 0, y: -5 }, }, { kind: "ready", rect: { height: 10, width: 20, x: 10, y: -5 }, }, { kind: "ready", rect: { height: 10, width: 20, x: 20, y: -5 }, }, ], }); expect(boundsProvider).toHaveBeenCalledTimes(3); expect( boundsProvider.mock.calls[1]?.[0].state.timeline.currentTimeSeconds, ).toBe(1 / 30); }); it("does not evaluate 1800 finite frame states or bounds during planning", async () => { const boundsProvider = vi.fn(() => []); const yieldToBrowser = vi.fn(async () => undefined); const plan = await resolveToolcraftVideoArtifactFrame({ state: createState("finite"), schedule: createToolcraftVideoFrameSchedule(60), boundsProvider, productSceneRequired: true, visibility, signal: new AbortController().signal, yieldToBrowser, }); expect(plan.productFrames).toBeNull(); expect(Object.keys(plan)).toEqual(["productFrames", "outputFrame"]); expect(boundsProvider).not.toHaveBeenCalled(); expect(yieldToBrowser).not.toHaveBeenCalled(); }); it("yields and cancels the Infinity prepass before evaluating subsequent frames", async () => { const controller = new AbortController(); const boundsProvider = vi.fn(() => [{ x: 0, y: 0, width: 10, height: 10 }]); const yieldToBrowser = vi.fn(async () => { controller.abort(); }); await expect( resolveToolcraftVideoArtifactFrame({ state: createState("infinite"), schedule: createToolcraftVideoFrameSchedule(60), boundsProvider, productSceneRequired: true, visibility, signal: controller.signal, yieldToBrowser, }), ).rejects.toBe(controller.signal.reason); expect(boundsProvider).toHaveBeenCalledOnce(); expect(yieldToBrowser).toHaveBeenCalledOnce(); }); it("unions product, image, and model contributors for infinite output", () => { const state = createToolcraftState( defineToolcraft({ base: { identity: { id: "runtime-test", title: "Toolcraft fixture", }, canvas: { enabled: true }, panels: {}, }, modules: [], }), { canvas: { mode: "infinite" }, layers: [ { displayName: "Image", id: "image-layer", kind: "layer", name: "image", visible: true, }, { displayName: "Model", id: "model-layer", kind: "layer", name: "model", visible: true, }, ], mediaAssets: [ { assetKind: "image", fileName: "image.png", id: "image", layerId: "image-layer", lifecycle: "ready", mimeType: "image/png", position: { x: -80, y: 0 }, resourceRef: `media:image:sha256:${"a".repeat(64)}`, size: { height: 20, unit: "px", width: 40 }, sourceSize: { height: 20, unit: "px", width: 40 }, }, createHydrationModelAsset("document:model", { id: "model", layerId: "model-layer", lifecycle: "clean", position: { x: 100, y: 30 }, size: { height: 40, unit: "px", width: 20 }, sourceTarget: "source.model", }), ], }, ); expect( resolveToolcraftStillArtifactFrame({ boundsProvider: () => [{ height: 30, width: 50, x: -10, y: -60 }], productSceneRequired: true, state, visibility, }), ).toEqual({ outputFrame: { height: 110, width: 210, x: -100, y: -60 }, productFrame: { kind: "ready", rect: { height: 30, width: 50, x: -10, y: -60 }, }, }); }); it("ignores unused product bounds for a runtime-only infinite scene", () => { const state = createToolcraftState( defineToolcraft({ base: { identity: { id: "runtime-test", title: "Toolcraft fixture", }, canvas: { enabled: true }, panels: {}, }, modules: [], }), { canvas: { mode: "infinite" }, layers: [ { displayName: "Image", id: "image-layer", kind: "layer", name: "image", visible: true, }, ], mediaAssets: [ { assetKind: "image", fileName: "runtime-only.png", id: "runtime-only-image", layerId: "image-layer", lifecycle: "ready", mimeType: "image/png", position: { x: 30, y: -10 }, resourceRef: `media:image:sha256:${"f".repeat(64)}`, size: { height: 20, unit: "px", width: 40 }, sourceSize: { height: 20, unit: "px", width: 40 }, }, ], }, ); const boundsProvider = vi.fn(() => { throw new Error("unused product provider"); }); expect( resolveToolcraftStillArtifactFrame({ boundsProvider, productSceneRequired: false, state, visibility, }), ).toEqual({ outputFrame: { height: 20, width: 40, x: 10, y: -20 }, productFrame: { kind: "empty", rect: null }, }); expect(boundsProvider).not.toHaveBeenCalled(); }); it.each([ () => { throw new Error("provider failed"); }, () => [{ height: 10, width: Number.NaN, x: 0, y: 0 }], ])( "normalizes throwing and invalid providers to unavailable", (boundsProvider) => { expect(() => resolveToolcraftStillArtifactFrame({ boundsProvider, productSceneRequired: true, state: createState("finite"), visibility, }), ).toThrowError( expect.objectContaining({ feedback: expect.objectContaining({ code: "scene-bounds-unavailable", }), }), ); }, ); it("fails visibly when an infinite product scene has no bounds provider", () => { expect(() => resolveToolcraftStillArtifactFrame({ boundsProvider: undefined, productSceneRequired: true, state: createState("infinite"), visibility, }), ).toThrow(ToolcraftSceneExportError); }); });