import { describe, expect, it, vi } from "vitest"; 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({ canvas: { enabled: true }, panels: { timeline: { enabled: true, defaultDurationSeconds: 1 } }, }), ); const canvas = createCanvas(); await expect( exportToolcraftVideoArtifact({ backendFactory: async () => { throw new Error("codec startup failed"); }, boundsProvider: undefined, canvasFactory: () => canvas, exportRenderer: undefined, productSceneRequired: false, renderRuntimeScene: async () => undefined, rendererPipeline: null, 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({ canvas: { enabled: true }, panels: { timeline: { enabled: true, defaultDurationSeconds: 1 } }, }), { 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 }, productSceneRequired: true, renderRuntimeScene: async () => undefined, rendererPipeline: null, 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(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("cancels exactly once when frame rendering fails", async () => { const state = createToolcraftState( defineToolcraft({ canvas: { enabled: true }, panels: { timeline: { enabled: true, defaultDurationSeconds: 1 } }, }), { 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"); }, }, productSceneRequired: true, renderRuntimeScene: async () => undefined, rendererPipeline: null, reportProgress: vi.fn(), state, visibility: createToolcraftRuntimeSceneVisibility({ renderDefaultImages: true, suppressedModelTargets: [], }), yieldToBrowser: async () => undefined, }), ).rejects.toMatchObject({ feedback: { code: "product-frame-render-failed" }, }); expect(cancel).toHaveBeenCalledOnce(); }); });