354 lines
12 KiB
TypeScript
354 lines
12 KiB
TypeScript
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" },
|
||
]);
|
||
});
|