581 lines
17 KiB
TypeScript
581 lines
17 KiB
TypeScript
import { expect, test } from "@playwright/test";
|
|
import {
|
|
filterVideoCanvasKeyElements,
|
|
filterVideoCanvasStoryboardVideos,
|
|
getVideoCanvasStoryboardColumnLayout,
|
|
getVideoCanvasKeyElementCategoryLabel,
|
|
projectVideoCanvasStoryboard,
|
|
projectVideoCanvasStoryboardCompositions,
|
|
resolveVideoCanvasViewMode,
|
|
VIDEO_CANVAS_KEY_ELEMENT_CATEGORY_OPTIONS,
|
|
VIDEO_CANVAS_STORYBOARD_VIDEO_FILTER_OPTIONS,
|
|
VIDEO_CANVAS_VIEW_MODE_KEY,
|
|
} from "@/lib/video-canvas/storyboard-overview";
|
|
import {
|
|
createVideoCanvasCompositionClip,
|
|
getVideoCanvasCompositionDuration,
|
|
normalizeVideoCanvasStoryboardComposition,
|
|
splitVideoCanvasCompositionClip,
|
|
} from "@/lib/video-canvas/storyboard-composition";
|
|
import { getVideoCanvasCompositionOutputSize } from "@/lib/video-canvas/storyboard-composition-render";
|
|
import { normalizeVideoCanvasStoryboardReference } from "@/lib/video-canvas/storyboard-reference";
|
|
|
|
test.describe("video canvas storyboard overview", () => {
|
|
test("restores storyboard mode only from the persisted project field", () => {
|
|
expect(resolveVideoCanvasViewMode(null)).toBe("workflow");
|
|
expect(resolveVideoCanvasViewMode({})).toBe("workflow");
|
|
expect(
|
|
resolveVideoCanvasViewMode({ [VIDEO_CANVAS_VIEW_MODE_KEY]: "invalid" }),
|
|
).toBe("workflow");
|
|
expect(
|
|
resolveVideoCanvasViewMode({ [VIDEO_CANVAS_VIEW_MODE_KEY]: "storyboard" }),
|
|
).toBe("storyboard");
|
|
});
|
|
|
|
test("matches the competitor media-panel expansion ratios", () => {
|
|
expect(getVideoCanvasStoryboardColumnLayout(null)).toEqual({
|
|
image: 1,
|
|
text: 1,
|
|
video: 1,
|
|
});
|
|
expect(getVideoCanvasStoryboardColumnLayout("image")).toEqual({
|
|
image: 2,
|
|
text: 1,
|
|
video: 0,
|
|
});
|
|
expect(getVideoCanvasStoryboardColumnLayout("video")).toEqual({
|
|
image: 1,
|
|
text: 0,
|
|
video: 2,
|
|
});
|
|
});
|
|
|
|
test("projects text, image, video, and audio nodes in source order", () => {
|
|
const projection = projectVideoCanvasStoryboard([
|
|
{ id: "text-1", kind: "text", title: "提示词 1" },
|
|
{
|
|
draftValues: { mediaType: "image", mediaUrl: "/shot-1.png" },
|
|
id: "image-1",
|
|
kind: "media",
|
|
title: "镜头 1",
|
|
},
|
|
{ id: "text-2", inputType: "text", title: "剧本" },
|
|
{ id: "video-1", inputType: "video", title: "成品" },
|
|
{ id: "audio-1", kind: "audio", title: "配音" },
|
|
]);
|
|
|
|
expect(projection.text.map((item) => item.id)).toEqual([
|
|
"text-1",
|
|
"text-2",
|
|
]);
|
|
expect(projection.image[0]).toMatchObject({
|
|
id: "image-1",
|
|
mediaUrl: "/shot-1.png",
|
|
});
|
|
expect(projection.video.map((item) => item.id)).toEqual(["video-1"]);
|
|
expect(projection.audio.map((item) => item.id)).toEqual(["audio-1"]);
|
|
expect(projection.audio[0]).toMatchObject({
|
|
contentType: "audio/wav",
|
|
mediaType: "audio",
|
|
});
|
|
});
|
|
|
|
test("projects persisted text content, model, prompt, and generation state", () => {
|
|
const projection = projectVideoCanvasStoryboard([
|
|
{
|
|
draftValues: {
|
|
action: "TEXT_GENERATE",
|
|
content: ["# 剧本\n\n第一幕"],
|
|
generationStatus: "complete",
|
|
mediaType: "text",
|
|
modelDisplayName: "GVLM 3.1",
|
|
params: { model: "gvlm-3.1", prompt: "写一个穿越故事" },
|
|
prompt: "写一个穿越故事",
|
|
},
|
|
id: "text-ready",
|
|
inputType: "text",
|
|
title: "剧本",
|
|
},
|
|
]);
|
|
|
|
expect(projection.text[0]).toMatchObject({
|
|
contentType: "text/plain",
|
|
generationStatus: "complete",
|
|
modelName: "GVLM 3.1",
|
|
modelRef: "gvlm-3.1",
|
|
prompt: "写一个穿越故事",
|
|
textContent: "# 剧本\n\n第一幕",
|
|
});
|
|
});
|
|
|
|
test("projects ordinary image references and generator settings", () => {
|
|
const projection = projectVideoCanvasStoryboard([
|
|
{
|
|
draftValues: {
|
|
mediaType: "image",
|
|
params: {
|
|
aspectRatio: "9:16",
|
|
imageCount: 4,
|
|
imageQuality: "高画质",
|
|
imageSize: "4K",
|
|
referenceImages: [
|
|
"https://example.com/role.png",
|
|
"https://example.com/scene.png",
|
|
"https://example.com/role.png",
|
|
],
|
|
},
|
|
prompt: "宫殿大门外的长安城",
|
|
},
|
|
id: "shot-pending",
|
|
inputType: "image",
|
|
title: "镜头 16",
|
|
},
|
|
]);
|
|
|
|
expect(projection.image[0]).toMatchObject({
|
|
aspectRatio: "9:16",
|
|
imageCount: 4,
|
|
imageQuality: "高画质",
|
|
imageSize: "4K",
|
|
prompt: "宫殿大门外的长安城",
|
|
referenceImages: [
|
|
"https://example.com/role.png",
|
|
"https://example.com/scene.png",
|
|
],
|
|
});
|
|
});
|
|
|
|
test("keeps storyboard video media and preview urls separate", () => {
|
|
const projection = projectVideoCanvasStoryboard([
|
|
{
|
|
draftValues: {
|
|
contentType: "video/mp4",
|
|
durationSeconds: 5,
|
|
mediaType: "video",
|
|
mediaUrl: "https://example.com/clip.mp4",
|
|
params: {
|
|
mode: "first-frame",
|
|
referenceImages: ["https://example.com/first-frame.png"],
|
|
},
|
|
poster: "https://example.com/poster.jpg",
|
|
},
|
|
id: "video-ready",
|
|
inputType: "video",
|
|
title: "视频37",
|
|
},
|
|
{
|
|
draftValues: {
|
|
mediaType: "video",
|
|
previewImageUrl: "https://example.com/pending-preview.jpg",
|
|
thumbnailUrl: "https://example.com/thumbnail.jpg",
|
|
},
|
|
id: "video-pending",
|
|
inputType: "video",
|
|
title: "视频节点 3",
|
|
},
|
|
]);
|
|
|
|
expect(projection.video[0]).toMatchObject({
|
|
contentType: "video/mp4",
|
|
mediaUrl: "https://example.com/clip.mp4",
|
|
previewUrl: "https://example.com/poster.jpg",
|
|
referenceImages: ["https://example.com/first-frame.png"],
|
|
});
|
|
expect(projection.video[1]).toMatchObject({
|
|
mediaUrl: "",
|
|
previewUrl: "https://example.com/thumbnail.jpg",
|
|
});
|
|
});
|
|
|
|
test("filters storyboard films from ordinary generated clips by metadata", () => {
|
|
const projection = projectVideoCanvasStoryboard([
|
|
{
|
|
draftValues: { mediaType: "video" },
|
|
id: "misleading-title",
|
|
title: "成品",
|
|
},
|
|
{
|
|
draftValues: {
|
|
mediaType: "video",
|
|
storyboardVideoCategory: "film",
|
|
},
|
|
id: "explicit-film",
|
|
title: "剪辑导出",
|
|
},
|
|
{
|
|
draftValues: {
|
|
action: "VIDEO_EDITOR_EXPORT",
|
|
mediaType: "video",
|
|
},
|
|
id: "editor-export",
|
|
title: "最终输出",
|
|
},
|
|
]);
|
|
|
|
expect(VIDEO_CANVAS_STORYBOARD_VIDEO_FILTER_OPTIONS).toEqual([
|
|
{ label: "全部", value: "all" },
|
|
{ label: "成片", value: "film" },
|
|
{ label: "片段", value: "clip" },
|
|
]);
|
|
expect(
|
|
filterVideoCanvasStoryboardVideos(projection.video, "clip").map(
|
|
(item) => item.id,
|
|
),
|
|
).toEqual(["misleading-title"]);
|
|
expect(
|
|
filterVideoCanvasStoryboardVideos(projection.video, "film").map(
|
|
(item) => item.id,
|
|
),
|
|
).toEqual(["explicit-film", "editor-export"]);
|
|
expect(
|
|
filterVideoCanvasStoryboardVideos(projection.video, "all").map(
|
|
(item) => item.id,
|
|
),
|
|
).toEqual(["misleading-title", "explicit-film", "editor-export"]);
|
|
});
|
|
|
|
test("separates composition projects from ordinary storyboard videos", () => {
|
|
const nodes = [
|
|
{
|
|
draftValues: {
|
|
action: "VIDEO_CLIP_RESOURCE",
|
|
nodeType: "video-clip",
|
|
},
|
|
id: "composition-1",
|
|
inputType: "video" as const,
|
|
title: "视频合成 11",
|
|
},
|
|
{
|
|
draftValues: {
|
|
durationSeconds: 5,
|
|
mediaType: "video",
|
|
mediaUrl: "/clip.mp4",
|
|
},
|
|
id: "video-1",
|
|
inputType: "video" as const,
|
|
title: "成品",
|
|
},
|
|
];
|
|
|
|
expect(projectVideoCanvasStoryboardCompositions(nodes)).toEqual([
|
|
{ id: "composition-1", title: "视频合成 11" },
|
|
]);
|
|
expect(projectVideoCanvasStoryboard(nodes).video).toEqual([
|
|
expect.objectContaining({
|
|
durationSeconds: 5,
|
|
id: "video-1",
|
|
}),
|
|
]);
|
|
});
|
|
|
|
test("normalizes and splits persisted composition timelines", () => {
|
|
const clip = createVideoCanvasCompositionClip({
|
|
durationSeconds: 5,
|
|
existingIds: [],
|
|
mediaUrl: "/clip.mp4",
|
|
nodeId: "video-1",
|
|
title: "视频 1",
|
|
});
|
|
const project = normalizeVideoCanvasStoryboardComposition({
|
|
clips: [clip],
|
|
subtitles: [],
|
|
});
|
|
const split = splitVideoCanvasCompositionClip(project, clip.id, 2);
|
|
|
|
expect(getVideoCanvasCompositionDuration(project)).toBe(5);
|
|
expect(split.clips).toHaveLength(2);
|
|
expect(split.clips[0].outPointSeconds).toBe(2);
|
|
expect(split.clips[1]).toMatchObject({
|
|
inPointSeconds: 2,
|
|
mediaUrl: "/clip.mp4",
|
|
nodeId: "video-1",
|
|
});
|
|
expect(getVideoCanvasCompositionDuration(split)).toBe(5);
|
|
});
|
|
|
|
test("derives an even export canvas from the first video without upscaling", () => {
|
|
expect(getVideoCanvasCompositionOutputSize(1920, 1080)).toEqual({
|
|
height: 720,
|
|
width: 1280,
|
|
});
|
|
expect(getVideoCanvasCompositionOutputSize(720, 1280)).toEqual({
|
|
height: 1280,
|
|
width: 720,
|
|
});
|
|
expect(getVideoCanvasCompositionOutputSize(641, 359)).toEqual({
|
|
height: 360,
|
|
width: 642,
|
|
});
|
|
});
|
|
|
|
test("separates explicit roles and scenes from ordinary image outputs", () => {
|
|
const projection = projectVideoCanvasStoryboard([
|
|
{
|
|
draftValues: {
|
|
elementType: "人物",
|
|
keyElement: true,
|
|
mediaType: "image",
|
|
},
|
|
id: "role-1",
|
|
title: "沈昭昭",
|
|
},
|
|
{
|
|
draftValues: { mediaType: "image" },
|
|
id: "scene-1",
|
|
subtitle: "关键场景",
|
|
title: "长安城夜景",
|
|
},
|
|
{
|
|
draftValues: {
|
|
elementType: "物品",
|
|
keyElement: true,
|
|
mediaType: "image",
|
|
},
|
|
id: "object-1",
|
|
title: "侍卫长刀",
|
|
},
|
|
{
|
|
draftValues: { keyElement: true, mediaType: "image" },
|
|
id: "other-1",
|
|
title: "抽象元素",
|
|
},
|
|
{
|
|
draftValues: { mediaType: "image" },
|
|
id: "shot-1",
|
|
title: "镜头 9",
|
|
},
|
|
]);
|
|
|
|
expect(projection.keyElements.map((item) => item.id)).toEqual([
|
|
"role-1",
|
|
"scene-1",
|
|
"object-1",
|
|
"other-1",
|
|
]);
|
|
expect(
|
|
filterVideoCanvasKeyElements(projection.keyElements, "character").map(
|
|
(item) => item.id,
|
|
),
|
|
).toEqual(["role-1"]);
|
|
expect(
|
|
filterVideoCanvasKeyElements(projection.keyElements, "scene").map(
|
|
(item) => item.id,
|
|
),
|
|
).toEqual(["scene-1"]);
|
|
expect(
|
|
filterVideoCanvasKeyElements(projection.keyElements, "object"),
|
|
).toEqual([
|
|
expect.objectContaining({
|
|
id: "object-1",
|
|
keyElementCategory: "object",
|
|
}),
|
|
]);
|
|
expect(
|
|
filterVideoCanvasKeyElements(projection.keyElements, "other"),
|
|
).toEqual([
|
|
expect.objectContaining({
|
|
id: "other-1",
|
|
keyElementCategory: "other",
|
|
}),
|
|
]);
|
|
expect(projection.image.map((item) => item.id)).toEqual(["shot-1"]);
|
|
});
|
|
|
|
test("projects key-element detail metadata from persisted node values", () => {
|
|
const projection = projectVideoCanvasStoryboard([
|
|
{
|
|
draftValues: {
|
|
elementType: "人物",
|
|
keyElement: true,
|
|
mediaType: "image",
|
|
mediaUrl: "https://example.com/role.png",
|
|
modelDisplayName: "Seedream 4.0",
|
|
originalHeight: 1536,
|
|
originalWidth: 1024,
|
|
prompt: "古装少女站在宫殿内",
|
|
visualStyle: "电影写实",
|
|
},
|
|
id: "role-detail",
|
|
title: "沈昭昭",
|
|
},
|
|
]);
|
|
|
|
expect(projection.keyElements[0]).toMatchObject({
|
|
mediaUrl: "https://example.com/role.png",
|
|
modelName: "Seedream 4.0",
|
|
prompt: "古装少女站在宫殿内",
|
|
resolutionLabel: "1024 x 1536",
|
|
styleLabel: "电影写实",
|
|
});
|
|
});
|
|
|
|
test("keeps the competitor key-element category order and labels", () => {
|
|
expect(VIDEO_CANVAS_KEY_ELEMENT_CATEGORY_OPTIONS).toEqual([
|
|
{ label: "人物", value: "character" },
|
|
{ label: "场景", value: "scene" },
|
|
{ label: "物品", value: "object" },
|
|
{ label: "其他", value: "other" },
|
|
]);
|
|
expect(getVideoCanvasKeyElementCategoryLabel("scene")).toBe("场景");
|
|
expect(getVideoCanvasKeyElementCategoryLabel("other")).toBe("其他");
|
|
});
|
|
|
|
test("accepts valid storyboard references without deduplicating request ids", () => {
|
|
const createReference = (requestId: string) =>
|
|
normalizeVideoCanvasStoryboardReference({
|
|
mediaUrl: "https://example.com/role.png",
|
|
nodeId: "role-1",
|
|
requestId,
|
|
title: "沈昭昭",
|
|
});
|
|
|
|
expect(createReference("reference-1")).toMatchObject({
|
|
activation: "preview",
|
|
contentType: "image/jpeg",
|
|
requestId: "reference-1",
|
|
});
|
|
expect(createReference("reference-2")?.nodeId).toBe("role-1");
|
|
expect(
|
|
normalizeVideoCanvasStoryboardReference({
|
|
mediaUrl: "/local-only.png",
|
|
nodeId: "role-1",
|
|
requestId: "reference-3",
|
|
title: "沈昭昭",
|
|
}),
|
|
).toBeNull();
|
|
|
|
expect(
|
|
normalizeVideoCanvasStoryboardReference({
|
|
contentType: "audio/mpeg",
|
|
mediaUrl: "https://example.com/audio.mp3",
|
|
nodeId: "audio-1",
|
|
requestId: "reference-audio-1",
|
|
title: "音频10",
|
|
}),
|
|
).toEqual({
|
|
activation: "locate",
|
|
contentType: "audio/mpeg",
|
|
mediaType: "audio",
|
|
mediaUrl: "https://example.com/audio.mp3",
|
|
nodeId: "audio-1",
|
|
requestId: "reference-audio-1",
|
|
title: "音频10",
|
|
});
|
|
|
|
expect(
|
|
normalizeVideoCanvasStoryboardReference({
|
|
mediaType: "text",
|
|
nodeId: "text-1",
|
|
requestId: "reference-text-1",
|
|
textContent: "# 剧本\n\n第一幕",
|
|
title: "剧本",
|
|
}),
|
|
).toEqual({
|
|
activation: "locate",
|
|
contentType: "text/plain",
|
|
mediaType: "text",
|
|
mediaUrl: "",
|
|
nodeId: "text-1",
|
|
requestId: "reference-text-1",
|
|
textContent: "# 剧本\n\n第一幕",
|
|
title: "剧本",
|
|
});
|
|
expect(
|
|
normalizeVideoCanvasStoryboardReference({
|
|
mediaType: "text",
|
|
nodeId: "text-empty",
|
|
requestId: "reference-text-empty",
|
|
title: "空文本",
|
|
}),
|
|
).toBeNull();
|
|
|
|
expect(
|
|
normalizeVideoCanvasStoryboardReference({
|
|
activation: "locate",
|
|
mediaType: "image",
|
|
nodeId: "shot-pending",
|
|
requestId: "reference-image-pending",
|
|
textContent: "宫殿大门外的长安城",
|
|
title: "镜头 16",
|
|
}),
|
|
).toEqual({
|
|
activation: "locate",
|
|
contentType: "image/jpeg",
|
|
mediaType: "image",
|
|
mediaUrl: "",
|
|
nodeId: "shot-pending",
|
|
requestId: "reference-image-pending",
|
|
textContent: "宫殿大门外的长安城",
|
|
title: "镜头 16",
|
|
});
|
|
|
|
expect(
|
|
normalizeVideoCanvasStoryboardReference({
|
|
activation: "locate",
|
|
mediaType: "image",
|
|
mediaUrl: "https://example.com/shot.png",
|
|
nodeId: "shot-ready",
|
|
requestId: "reference-image-ready",
|
|
textContent: "人物三视图",
|
|
title: "图片节点 3",
|
|
}),
|
|
).toMatchObject({
|
|
activation: "locate",
|
|
mediaUrl: "https://example.com/shot.png",
|
|
textContent: "人物三视图",
|
|
});
|
|
|
|
expect(
|
|
normalizeVideoCanvasStoryboardReference({
|
|
contentType: "video/mp4",
|
|
mediaType: "video",
|
|
mediaUrl: "https://example.com/clip.mp4",
|
|
nodeId: "video-ready",
|
|
previewUrl: "https://example.com/poster.jpg",
|
|
requestId: "reference-video-ready",
|
|
title: "成品",
|
|
}),
|
|
).toEqual({
|
|
activation: "locate",
|
|
contentType: "video/mp4",
|
|
mediaType: "video",
|
|
mediaUrl: "https://example.com/clip.mp4",
|
|
nodeId: "video-ready",
|
|
previewUrl: "https://example.com/poster.jpg",
|
|
requestId: "reference-video-ready",
|
|
title: "成品",
|
|
});
|
|
expect(
|
|
normalizeVideoCanvasStoryboardReference({
|
|
mediaType: "video",
|
|
nodeId: "video-pending",
|
|
requestId: "reference-video-pending",
|
|
title: "视频节点 3",
|
|
}),
|
|
).toEqual({
|
|
activation: "locate",
|
|
contentType: "video/mp4",
|
|
mediaType: "video",
|
|
mediaUrl: "",
|
|
nodeId: "video-pending",
|
|
requestId: "reference-video-pending",
|
|
title: "视频节点 3",
|
|
});
|
|
});
|
|
|
|
test("ignores operational nodes that have no storyboard media meaning", () => {
|
|
const projection = projectVideoCanvasStoryboard([
|
|
{ id: "workflow", kind: "workflow", title: "工作流" },
|
|
{ id: "save", kind: "save", title: "保存" },
|
|
]);
|
|
|
|
expect(projection).toEqual({
|
|
audio: [],
|
|
image: [],
|
|
keyElements: [],
|
|
text: [],
|
|
video: [],
|
|
});
|
|
});
|
|
});
|