import { test, expect } from "@playwright/test"; import { randomUUID } from "node:crypto"; import { makeCanvasGroup, groupMembers, moveCanvasSelection, ungroupCanvasNodes, layoutCanvasGroup, exportCanvasGroup, instantiateCanvasGroup, migrateLegacyGroups, assignGroupMembers, type GroupNode, } from "@/lib/video-canvas/groups"; import { compileGroupRun, validateGroupBatch, groupDependencyState, resolveGroupInputs, } from "@/lib/video-canvas/group-execution"; import { upscaleDimensions, videoUpscaleSchema, } from "@/lib/video-canvas/video-upscale-contract"; import { reconcileCanvasTasks, type CanvasGenerationTask, type CanvasTaskRequest, } from "@/lib/video-canvas/task-contract"; import { hasRecoverableCanvasTask } from "@/lib/video-canvas/task-recovery"; const n = (id: string, x = 100, y = 100): GroupNode => ({ id, title: id, kind: "media", inputType: "image", x, y, width: 200, height: 100, draftValues: { action: "IMAGE_RESOURCE", mediaUrl: `https://media.example/${id}.png`, }, }); const edge = (source: string, target: string) => ({ id: `${source}-${target}`, source, target, }); const task = ( request: CanvasTaskRequest, status = "pending", result: any = null ): CanvasGenerationTask => ({ id: randomUUID(), requestId: request.requestId, request, status, result, createdAt: new Date().toISOString(), }); function batch() { const source = n("source"), image = { ...n("image", 400), draftValues: { action: "IMAGE_GENERATE", params: { prompt: "产品" }, mediaUrl: "https://media.example/OLD.png", }, }, video = { ...n("video", 700), inputType: "video", draftValues: { action: "VIDEO_GENERATE" }, }, hd = { ...n("hd", 1000), inputType: "video", draftValues: { action: "VIDEO_HD_UPSCALE" }, }; const nodes = makeCanvasGroup( [source, image, video, hd], ["source", "image", "video", "hd"], "group" ); return compileGroupRun( nodes, [edge("source", "image"), edge("image", "video"), edge("video", "hd")], "group", randomUUID(), (node) => node.id === "image" ? { kind: "image", params: { prompt: "产品" } } : node.id === "video" ? { kind: "video", params: { prompt: "运镜", mode: "first-frame" } } : { kind: "video-upscale", params: { resolution: "4K" } }, randomUUID ); } test("nested groups move descendants exactly once and ungroup preserves edges/member coordinates", () => { let nodes = makeCanvasGroup( [n("a"), n("b", 400), n("c", 800)], ["a", "b"], "inner" ); nodes = makeCanvasGroup(nodes, ["inner", "c"], "outer"); const before = nodes; nodes = moveCanvasSelection(nodes, ["outer", "inner", "a"], 50, 75); for (const node of nodes) { const old = before.find((n) => n.id === node.id)!; expect([node.x, node.y]).toEqual([old.x + 50, old.y + 75]); } const ungrouped = ungroupCanvasNodes(nodes, "inner"); expect( groupMembers(ungrouped, "outer") .map((n) => n.id) .sort() ).toEqual(["a", "b", "c"]); expect(ungrouped.find((n) => n.id === "a")?.x).toBe( nodes.find((n) => n.id === "a")?.x ); expect(() => assignGroupMembers(before, ["outer"], "inner")).toThrow("子组"); expect( groupMembers(assignGroupMembers(before, ["a"], ""), "inner") ).toHaveLength(1); }); test("layout fits frame, legacy preset migrates once, export/import remaps internal IDs only", () => { const nodes = makeCanvasGroup([n("a"), n("b", 400)], ["a", "b"], "g"); const laid = layoutCanvasGroup(nodes, "g", "vertical"); expect(laid.find((n) => n.id === "a")?.x).toBe( laid.find((n) => n.id === "b")?.x ); const template = exportCanvasGroup( [...laid, n("external", 900)], [edge("a", "b"), edge("external", "a")], "g" ); const imported = instantiateCanvasGroup(template, randomUUID, 900, 300); expect(imported.edges).toHaveLength(2); expect( imported.nodes.every((n) => !["a", "b", "g"].includes(n.id)) ).toBeTruthy(); expect(groupMembers(imported.nodes, imported.groupId)).toHaveLength(3); expect( imported.nodes.some( (n) => n.draftValues?.templateInput && !n.draftValues?.mediaUrl ) ).toBeTruthy(); const legacy = nodes.map((n) => ({ ...n, draftValues: { ...n.draftValues, parentGroupId: undefined, groupVersion: undefined, }, })); expect(groupMembers(migrateLegacyGroups(legacy), "g")).toHaveLength(2); }); test("invalid imported member cycles and missing endpoints are rejected", () => { const nodes = makeCanvasGroup([n("a"), n("b", 400)], ["a", "b"], "g"), template = exportCanvasGroup(nodes, [], "g"); expect(() => instantiateCanvasGroup( { ...template, edges: [edge("missing", "a")] }, randomUUID, 0, 0 ) ).toThrow("不存在"); const cyclic = template.nodes.map((n) => n.id === "g" ? { ...n, draftValues: { ...n.draftValues, parentGroupId: "g" } } : n ); expect(() => instantiateCanvasGroup({ ...template, nodes: cyclic }, randomUUID, 0, 0) ).toThrow("循环"); }); test("whole-group run resolves this run's fresh output through image > video > HD", () => { const requests = batch(); validateGroupBatch(requests); expect(requests).toHaveLength(3); const tasks = requests.map((r) => task(r)); expect(groupDependencyState(requests[0], tasks)).toBe("ready"); expect(groupDependencyState(requests[1], tasks)).toBe("waiting"); tasks[0].status = "succeeded"; tasks[0].result = { images: [{ originalUrl: "https://media.example/FRESH.png" }], }; expect(resolveGroupInputs(requests[1], tasks).images).toEqual([ "https://media.example/FRESH.png", ]); tasks[1].status = "succeeded"; tasks[1].result = { url: "https://media.example/NEW.mp4" }; expect(resolveGroupInputs(requests[2], tasks).url).toBe( "https://media.example/NEW.mp4" ); }); test("image group execution keeps an uploaded character reference alongside connected images", () => { const requests = batch(); requests[0].params.referenceImages = ["https://media.example/character.png"]; const tasks = requests.map((request) => task(request)); expect(resolveGroupInputs(requests[0], tasks)).toMatchObject({ referenceImage: "https://media.example/source.png", referenceImages: ["https://media.example/character.png"], }); const standalone = { ...requests[0], edges: [], workflow: { ...requests[0].workflow!, inputs: [] }, }; expect(resolveGroupInputs(standalone, tasks)).toMatchObject({ referenceImage: "https://media.example/character.png", referenceImages: [], }); }); test("failed/cancelled/uncertain upstream blocks downstream without using old output", () => { const requests = batch(); for (const status of ["failed", "cancelled", "interrupted"]) { const tasks = requests.map((r) => task(r)); tasks[0].status = status; expect(groupDependencyState(requests[1], tasks)).toBe("failed"); expect(() => resolveGroupInputs(requests[1], tasks)).toThrow("尚未成功"); } }); test("batch rejects foreign dependency, wrong edge, cycle and mixed projects", () => { let r = batch(); r[1].workflow!.inputs[0].requestId = randomUUID(); expect(() => validateGroupBatch(r)).toThrow("本次编组"); r = batch(); r[1].edges = []; expect(() => validateGroupBatch(r)).toThrow("连线"); r = batch(); r[1].projectId = randomUUID(); expect(() => validateGroupBatch(r)).toThrow("同一画布"); r = batch().slice(0, 2); r[0].workflow!.inputs = [{ node: r[1].node, requestId: r[1].requestId }]; r[0].edges = [edge(r[1].node.id, r[0].node.id)]; r[0].node.inputType = "text"; r[1].node.inputType = "text"; r[0].workflow!.inputs[0].node.inputType = "text"; r[1].workflow!.inputs[0].node.inputType = "text"; expect(() => validateGroupBatch(r)).toThrow("循环"); }); test("video HD dimensions preserve aspect and enforce meaningful 1–4x AI scale", () => { expect(upscaleDimensions(960, 540, "4K")).toEqual({ width: 3840, height: 2160, }); expect(upscaleDimensions(540, 960, "4K")).toEqual({ width: 2160, height: 3840, }); expect(upscaleDimensions(640, 480, "2K")).toEqual({ width: 1920, height: 1440, }); expect(() => upscaleDimensions(1920, 1080, "1080P")).toThrow("已达到"); expect(() => upscaleDimensions(320, 180, "4K")).toThrow("4 倍"); expect( videoUpscaleSchema.safeParse({ url: "file:///etc/passwd", resolution: "4K", }).success ).toBeFalsy(); }); test("HD task survives refresh with remote ID and becomes playable video", () => { const request = batch()[2], t = task(request, "interrupted", { upscale: { remote: { id: "a".repeat(64), endpointHash: "b".repeat(64) }, stage: "第 5 帧", }, }); expect(hasRecoverableCanvasTask(t)).toBeTruthy(); t.status = "succeeded"; t.result = { url: "https://media.example/hd.mp4", metadata: { width: 3840, height: 2160 }, }; const result = reconcileCanvasTasks([request.node], [t], "2020-01-01")[0]; expect(result.inputType).toBe("video"); expect(result.draftValues?.mediaUrl).toBe(t.result.url); expect(result.draftValues?.generationStatus).toBe("complete"); }); test("generated video feeds fresh smart-edit plan and automatic render as durable stages", () => { const video = { ...n("v"), inputType: "video", draftValues: { action: "VIDEO_GENERATE" }, }, edit = { ...n("edit", 400), inputType: "video", draftValues: { action: "SMART_EDIT_WORKFLOW", nodeType: "smart-edit" }, }; const nodes = makeCanvasGroup([video, edit], ["v", "edit"], "g"); const requests = compileGroupRun( nodes, [edge("v", "edit")], "g", randomUUID(), (node) => node.id === "v" ? { kind: "video", params: { mode: "text-to-video", prompt: "走动" } } : { kind: "smart-edit", params: { operation: "plan", autoRender: true, mode: "sequence" }, }, randomUUID ); expect(requests).toHaveLength(3); validateGroupBatch(requests); const tasks = requests.map((r) => task(r)); tasks[0].status = "succeeded"; tasks[0].result = { url: "https://media.example/fresh.mp4" }; expect(resolveGroupInputs(requests[1], tasks).assets).toEqual([ { id: "group-asset-1", name: "v", url: "https://media.example/fresh.mp4" }, ]); tasks[1].status = "succeeded"; tasks[1].result = { plans: [{ title: "fresh plan" }] }; expect(resolveGroupInputs(requests[2], tasks).plan).toEqual({ title: "fresh plan", }); expect(requests[1].hiddenNodeIds).toContain(requests[1].node.id); }); test("existing video resources enter a semantic edit group without regeneration", () => { const inputs = ["a", "b"].map((id, index) => ({ ...n(id, 100 + index * 300), inputType: "video" as const, draftValues: { action: "VIDEO_RESOURCE", mediaUrl: `https://media.example/${id}.mp4` }, })); const edit = { ...n("edit", 900), inputType: "video" as const, draftValues: { action: "SMART_EDIT_WORKFLOW", nodeType: "smart-edit" }, }; const nodes = makeCanvasGroup([...inputs, edit], ["a", "b", "edit"], "g"); const requests = compileGroupRun(nodes, [edge("a", "edit"), edge("b", "edit")], "g", randomUUID(), () => ({ kind: "smart-edit", params: { operation: "plan", autoRender: true, mode: "sequence", semantic: true, targetDuration: 6, shotDurations: [3, 3], prompt: "选高光", } }), randomUUID); expect(requests).toHaveLength(2); validateGroupBatch(requests); expect(resolveGroupInputs(requests[0], []).assets).toEqual([ { id: "group-asset-1", name: "a", url: "https://media.example/a.mp4" }, { id: "group-asset-2", name: "b", url: "https://media.example/b.mp4" }, ]); });