ai-chatbot/tests/routes/video-storyboard-overview.test.ts

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