import { describe, expect, it, vi } from "vitest"; import { timelineModule } from "../modules/built-ins/timeline/timeline-module"; import { createToolcraftRuntimeSceneVisibility } from "../scene"; import { defineToolcraft } from "../schema/define-toolcraft"; import { createToolcraftState } from "../state/create-template-state"; import { toolcraftVideoExportFormatTarget, toolcraftVideoExportResolutionTarget, } from "./artifact-export-settings"; import { exportToolcraftVideoArtifact } from "./video-artifact-export"; function createCanvas() { const context = { clearRect: vi.fn(), fillRect: vi.fn(), fillStyle: "", restore: vi.fn(), save: vi.fn(), scale: vi.fn(), setTransform: vi.fn(), translate: vi.fn(), }; return { getContext: vi.fn(() => context), height: 0, width: 0, } as unknown as HTMLCanvasElement; } describe("exportToolcraftVideoArtifact", () => { it("normalizes encoder startup failures and releases the export canvas", async () => { const state = createToolcraftState( defineToolcraft({ base: { identity: { id: "runtime-test", title: "Toolcraft App" }, canvas: { enabled: true }, panels: {}, }, modules: [ timelineModule({ defaultDurationSeconds: 1, mode: "keyframes", }), ], }), ); const canvas = createCanvas(); await expect( exportToolcraftVideoArtifact({ backendFactory: async () => { throw new Error("codec startup failed"); }, boundsProvider: undefined, canvasFactory: () => canvas, exportRenderer: undefined, renderRuntimeScene: async () => undefined, rendererPipeline: null, signal: new AbortController().signal, reportProgress: vi.fn(), state, visibility: createToolcraftRuntimeSceneVisibility({ renderDefaultImages: true, suppressedModelTargets: [], }), }), ).rejects.toMatchObject({ feedback: { code: "video-encode-failed" }, }); expect(canvas.width).toBe(1); expect(canvas.height).toBe(1); }); it("renders a slow product once per exact timeline frame", async () => { const state = createToolcraftState( defineToolcraft({ base: { identity: { id: "runtime-test", title: "Toolcraft App" }, canvas: { enabled: true }, panels: {}, }, modules: [ timelineModule({ defaultDurationSeconds: 1, mode: "keyframes", }), ], }), { timeline: { durationSeconds: 1 }, values: { [toolcraftVideoExportFormatTarget]: "mp4", [toolcraftVideoExportResolutionTarget]: "current", }, }, ); const added: Array<{ durationSeconds: number; timeSeconds: number; }> = []; const cancel = vi.fn(async () => undefined); const finalize = vi.fn( async () => new Blob(["video"], { type: "video/mp4" }), ); const renderFrame = vi.fn(async () => Promise.resolve()); const result = await exportToolcraftVideoArtifact({ backendFactory: async () => ({ addFrame: async (timeSeconds, durationSeconds) => { added.push({ durationSeconds, timeSeconds }); }, cancel, extension: ".mp4", finalize, mediaType: "video/mp4", }), boundsProvider: undefined, canvasFactory: createCanvas, downloadArtifact: vi.fn(() => ({ fileName: "scene.mp4" })), exportRenderer: { baseFileName: "scene", renderFrame }, renderRuntimeScene: async () => undefined, rendererPipeline: null, signal: new AbortController().signal, reportProgress: vi.fn(), state, visibility: createToolcraftRuntimeSceneVisibility({ renderDefaultImages: true, suppressedModelTargets: [], }), yieldToBrowser: async () => undefined, }); expect(result.frameCount).toBe(30); expect(result.durationSeconds).toBe(1); expect(renderFrame).toHaveBeenCalledTimes(30); expect(renderFrame).toHaveBeenNthCalledWith( 1, expect.objectContaining({ frame: { height: 1080, width: 1920, x: -960, y: -540 }, }), ); expect(renderFrame).toHaveBeenLastCalledWith( expect.objectContaining({ frame: { height: 1080, width: 1920, x: -960, y: -540, }, }), ); expect(added).toHaveLength(30); expect(added[0]).toEqual({ durationSeconds: 1 / 30, timeSeconds: 0 }); expect(added.at(-1)?.timeSeconds).toBe(29 / 30); expect(finalize).toHaveBeenCalledOnce(); expect(cancel).not.toHaveBeenCalled(); }); it("ignores an unused throwing provider for runtime-only video export", async () => { const state = createToolcraftState( defineToolcraft({ base: { identity: { id: "runtime-test", title: "Toolcraft fixture", }, canvas: { enabled: true }, panels: {}, }, modules: [ timelineModule({ mode: "playback", defaultDurationSeconds: 1 }), ], }), { timeline: { durationSeconds: 1 } }, ); const boundsProvider = vi.fn(() => { throw new Error("unused product provider"); }); const renderRuntimeScene = vi.fn(async () => undefined); await expect( exportToolcraftVideoArtifact({ backendFactory: async () => ({ addFrame: async () => undefined, cancel: vi.fn(async () => undefined), extension: ".webm", finalize: async () => new Blob(["video"]), mediaType: "video/webm", }), boundsProvider, canvasFactory: createCanvas, downloadArtifact: vi.fn(), exportRenderer: undefined, renderRuntimeScene, rendererPipeline: null, signal: new AbortController().signal, reportProgress: vi.fn(), state, visibility: createToolcraftRuntimeSceneVisibility({ renderDefaultImages: true, suppressedModelTargets: [], }), yieldToBrowser: async () => undefined, }), ).resolves.toMatchObject({ extension: ".webm", frameCount: 30 }); expect(boundsProvider).not.toHaveBeenCalled(); expect(renderRuntimeScene).toHaveBeenCalledTimes(30); }); it("cancels exactly once when frame rendering fails", async () => { const state = createToolcraftState( defineToolcraft({ base: { identity: { id: "runtime-test", title: "Toolcraft App" }, canvas: { enabled: true }, panels: {}, }, modules: [ timelineModule({ defaultDurationSeconds: 1, mode: "keyframes", }), ], }), { timeline: { durationSeconds: 1 } }, ); const cancel = vi.fn(async () => undefined); await expect( exportToolcraftVideoArtifact({ backendFactory: async () => ({ addFrame: async () => undefined, cancel, extension: ".webm", finalize: async () => new Blob(), mediaType: "video/webm", }), boundsProvider: undefined, canvasFactory: createCanvas, downloadArtifact: vi.fn(), exportRenderer: { baseFileName: "scene", renderFrame: () => { throw new Error("broken"); }, }, renderRuntimeScene: async () => undefined, rendererPipeline: null, signal: new AbortController().signal, reportProgress: vi.fn(), state, visibility: createToolcraftRuntimeSceneVisibility({ renderDefaultImages: true, suppressedModelTargets: [], }), yieldToBrowser: async () => undefined, }), ).rejects.toMatchObject({ feedback: { code: "product-frame-render-failed" }, }); expect(cancel).toHaveBeenCalledOnce(); }); it("requires bounds for a renderer-present infinite video export", async () => { const state = createToolcraftState( defineToolcraft({ base: { identity: { id: "runtime-test", title: "Toolcraft fixture", }, canvas: { enabled: true }, panels: {}, }, modules: [ timelineModule({ mode: "playback", defaultDurationSeconds: 1 }), ], }), { canvas: { mode: "infinite" } }, ); const backendFactory = vi.fn(); await expect( exportToolcraftVideoArtifact({ backendFactory, boundsProvider: undefined, canvasFactory: createCanvas, downloadArtifact: vi.fn(), exportRenderer: { baseFileName: "scene", renderFrame: vi.fn() }, renderRuntimeScene: async () => undefined, rendererPipeline: null, signal: new AbortController().signal, reportProgress: vi.fn(), state, visibility: createToolcraftRuntimeSceneVisibility({ renderDefaultImages: true, suppressedModelTargets: [], }), }), ).rejects.toMatchObject({ feedback: { code: "scene-bounds-unavailable" }, }); expect(backendFactory).not.toHaveBeenCalled(); }); it.each([ () => { throw new Error("provider failed"); }, () => [{ height: 10, width: Number.NaN, x: 0, y: 0 }], ])( "cancels the encoder when finite per-frame bounds are invalid", async (boundsProvider) => { const state = createToolcraftState( defineToolcraft({ base: { identity: { id: "runtime-test", title: "Toolcraft fixture", }, canvas: { enabled: true }, panels: {}, }, modules: [ timelineModule({ mode: "playback", defaultDurationSeconds: 1 }), ], }), ); const cancel = vi.fn(async () => undefined); const addFrame = vi.fn(); const backendFactory = vi.fn(async () => ({ addFrame, cancel, extension: ".mp4" as const, finalize: vi.fn(), mediaType: "video/mp4" as const, })); await expect( exportToolcraftVideoArtifact({ backendFactory, boundsProvider, canvasFactory: createCanvas, downloadArtifact: vi.fn(), exportRenderer: { baseFileName: "scene", renderFrame: vi.fn() }, renderRuntimeScene: async () => undefined, rendererPipeline: null, signal: new AbortController().signal, reportProgress: vi.fn(), state, visibility: createToolcraftRuntimeSceneVisibility({ renderDefaultImages: true, suppressedModelTargets: [], }), }), ).rejects.toMatchObject({ feedback: { code: "scene-bounds-unavailable" }, }); expect(backendFactory).toHaveBeenCalledOnce(); expect(cancel).toHaveBeenCalledOnce(); expect(addFrame).not.toHaveBeenCalled(); }, ); });