ai-chatbot/tests/routes/video-canvas-groups-upscale.test.ts

354 lines
12 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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" },
]);
});