feat(canvas): 视频节点截取重生成 - 支持多段/时间线导入并调用视频模型批量重生成 (#197)
* feat(canvas): 视频节点截取重生成 - 支持多段/时间线导入并调用视频模型批量重生成 * test(canvas): 视频节点时间线导入 - 修正旧数据标题回退测试 * style(canvas): 视频节点截取重生成 - 格式化新增文件并修正测试 --------- Co-authored-by: sikongyue <3155157671@qq.com>
This commit is contained in:
52
web/test/canvas-resource-references.test.ts
Normal file
52
web/test/canvas-resource-references.test.ts
Normal file
@@ -0,0 +1,52 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
|
||||
import { collectUpstreamVideoNodes } from "../src/lib/canvas/canvas-resource-references";
|
||||
import { CanvasNodeType, type CanvasConnection, type CanvasNodeData } from "../src/types/canvas";
|
||||
|
||||
function videoNode(id: string): CanvasNodeData {
|
||||
return {
|
||||
id,
|
||||
type: CanvasNodeType.Video,
|
||||
title: id,
|
||||
position: { x: 0, y: 0 },
|
||||
width: 100,
|
||||
height: 100,
|
||||
metadata: { content: `data:video/mp4;base64,${id}` },
|
||||
};
|
||||
}
|
||||
|
||||
function textNode(id: string): CanvasNodeData {
|
||||
return {
|
||||
id,
|
||||
type: CanvasNodeType.Text,
|
||||
title: id,
|
||||
position: { x: 0, y: 0 },
|
||||
width: 100,
|
||||
height: 60,
|
||||
metadata: { content: id },
|
||||
};
|
||||
}
|
||||
|
||||
function connection(fromNodeId: string, toNodeId: string): CanvasConnection {
|
||||
return { id: `conn-${fromNodeId}-${toNodeId}`, fromNodeId, toNodeId };
|
||||
}
|
||||
|
||||
describe("collectUpstreamVideoNodes", () => {
|
||||
test("下游视频节点能回溯到上游视频源", () => {
|
||||
const source = videoNode("source-video");
|
||||
const segment = videoNode("segment-video");
|
||||
const target = videoNode("target-video");
|
||||
const text = textNode("script");
|
||||
const nodes = [target, segment, source, text];
|
||||
const connections = [connection("source-video", "segment-video"), connection("segment-video", "target-video"), connection("script", "segment-video")];
|
||||
expect(collectUpstreamVideoNodes("target-video", nodes, connections).map((node) => node.id)).toEqual(["target-video", "segment-video", "source-video"]);
|
||||
});
|
||||
|
||||
test("存在环时不会死循环", () => {
|
||||
const a = videoNode("a");
|
||||
const b = videoNode("b");
|
||||
const nodes = [a, b];
|
||||
const connections = [connection("a", "b"), connection("b", "a")];
|
||||
expect(collectUpstreamVideoNodes("a", nodes, connections).length).toBe(2);
|
||||
});
|
||||
});
|
||||
128
web/test/canvas-video-regeneration.test.ts
Normal file
128
web/test/canvas-video-regeneration.test.ts
Normal file
@@ -0,0 +1,128 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
|
||||
import { listVideoReferenceModels, validateVideoSegmentBatch, videoReferenceOperationError, videoReferenceRegenerationError, videoReferenceSegmentError } from "../src/lib/canvas/canvas-video-regeneration";
|
||||
import { defaultModelCapabilityConfig } from "../src/lib/model-capabilities";
|
||||
import { defaultConfig, type AiConfig, type ModelChannel } from "../src/stores/use-config-store";
|
||||
|
||||
function supportedVideoConfig(overrides: { model?: string; maxVideos?: number; maxVideoDurationSeconds?: number; operations?: string[]; baseUrl?: string } = {}): AiConfig {
|
||||
const model = overrides.model || "seedance-1.0-pro";
|
||||
const capability = defaultModelCapabilityConfig("newapi-channel-2", model);
|
||||
if (capability.video) {
|
||||
capability.video.references.maxVideos = overrides.maxVideos ?? 3;
|
||||
capability.video.references.maxVideoDurationSeconds = overrides.maxVideoDurationSeconds ?? 15;
|
||||
capability.video.operations = overrides.operations ?? ["text_to_video", "image_to_video", "extend"];
|
||||
}
|
||||
const channel: ModelChannel = {
|
||||
id: "test",
|
||||
name: "测试渠道",
|
||||
baseUrl: overrides.baseUrl || "https://api.example.com",
|
||||
apiKey: "test-key",
|
||||
apiFormat: "openai",
|
||||
interfaceType: "newapi-channel-2",
|
||||
models: [model],
|
||||
scope: "user",
|
||||
modelCosts: [
|
||||
{
|
||||
model,
|
||||
capability: "video",
|
||||
protocol: "newapi-channel-2",
|
||||
billingMode: "per_second",
|
||||
unitPriceMicrocredits: 1,
|
||||
capabilityConfig: capability,
|
||||
},
|
||||
],
|
||||
};
|
||||
return {
|
||||
...defaultConfig,
|
||||
model: `test::${model}`,
|
||||
videoModel: `test::${model}`,
|
||||
models: [`test::${model}`],
|
||||
videoModels: [`test::${model}`],
|
||||
channels: [channel],
|
||||
baseUrl: channel.baseUrl,
|
||||
};
|
||||
}
|
||||
|
||||
describe("videoReferenceRegenerationError", () => {
|
||||
test("模型不支持参考视频时返回提示", () => {
|
||||
const config: AiConfig = { ...defaultConfig, model: "default::grok-imagine-video", videoModel: "default::grok-imagine-video" };
|
||||
expect(videoReferenceRegenerationError(config)).toContain("不支持参考视频");
|
||||
});
|
||||
|
||||
test("模型没有可用生成模式时返回提示", () => {
|
||||
const config = supportedVideoConfig({ operations: [] });
|
||||
expect(videoReferenceRegenerationError(config)).toContain("没有可用的视频生成模式");
|
||||
});
|
||||
|
||||
test("模型支持参考视频且有可用模式时无错误", () => {
|
||||
expect(videoReferenceRegenerationError(supportedVideoConfig())).toBe("");
|
||||
});
|
||||
});
|
||||
|
||||
describe("videoReferenceOperationError", () => {
|
||||
test("所选模式不在模型能力中时返回提示", () => {
|
||||
const config = supportedVideoConfig({ operations: ["text_to_video", "image_to_video", "extend"] });
|
||||
expect(videoReferenceOperationError(config, "inpaint")).toContain("不支持所选生成模式");
|
||||
});
|
||||
|
||||
test("所选模式在模型能力中时无错误", () => {
|
||||
expect(videoReferenceOperationError(supportedVideoConfig(), "extend")).toBe("");
|
||||
});
|
||||
});
|
||||
|
||||
describe("listVideoReferenceModels", () => {
|
||||
test("默认配置没有可用的参考视频模型", () => {
|
||||
const config: AiConfig = { ...defaultConfig, model: "default::grok-imagine-video", videoModel: "default::grok-imagine-video" };
|
||||
expect(listVideoReferenceModels(config)).toEqual([]);
|
||||
});
|
||||
|
||||
test("只返回支持参考视频且有可用模式的模型", () => {
|
||||
expect(listVideoReferenceModels(supportedVideoConfig())).toEqual(["test::seedance-1.0-pro"]);
|
||||
});
|
||||
|
||||
test("过滤掉没有可用生成模式的模型", () => {
|
||||
const config = supportedVideoConfig({ operations: [] });
|
||||
expect(listVideoReferenceModels(config)).toEqual([]);
|
||||
});
|
||||
|
||||
test("过滤掉未配置 API Key 的渠道模型", () => {
|
||||
const config = supportedVideoConfig();
|
||||
config.channels[0].apiKey = "";
|
||||
expect(listVideoReferenceModels(config)).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("videoReferenceSegmentError", () => {
|
||||
test("片段超过模型参考视频上限时返回提示", () => {
|
||||
const config = supportedVideoConfig({ maxVideoDurationSeconds: 15 });
|
||||
expect(videoReferenceSegmentError(config, 16000)).toContain("不能超过当前模型参考视频上限(15 秒)");
|
||||
});
|
||||
|
||||
test("Seedance 单段少于 2 秒时返回提示", () => {
|
||||
const config = supportedVideoConfig({ model: "seedance-1.0-pro" });
|
||||
expect(videoReferenceSegmentError(config, 1500)).toContain("至少 2 秒");
|
||||
});
|
||||
|
||||
test("合法片段时长无错误", () => {
|
||||
expect(videoReferenceSegmentError(supportedVideoConfig(), 5000)).toBe("");
|
||||
});
|
||||
});
|
||||
|
||||
describe("validateVideoSegmentBatch", () => {
|
||||
test("模型、模式与片段时长都合法时无错误", () => {
|
||||
expect(validateVideoSegmentBatch(supportedVideoConfig(), [{ startMs: 2000, endMs: 5000 }], "extend")).toBe("");
|
||||
});
|
||||
|
||||
test("模型不支持参考视频时返回首个错误", () => {
|
||||
const config: AiConfig = { ...defaultConfig, model: "default::grok-imagine-video", videoModel: "default::grok-imagine-video" };
|
||||
expect(validateVideoSegmentBatch(config, [{ startMs: 2000, endMs: 5000 }], "extend")).toContain("不支持参考视频");
|
||||
});
|
||||
|
||||
test("模式不在模型能力中时返回操作错误", () => {
|
||||
expect(validateVideoSegmentBatch(supportedVideoConfig(), [{ startMs: 2000, endMs: 5000 }], "inpaint")).toContain("不支持所选生成模式");
|
||||
});
|
||||
|
||||
test("片段超过模型时长上限时返回片段错误", () => {
|
||||
expect(validateVideoSegmentBatch(supportedVideoConfig({ maxVideoDurationSeconds: 15 }), [{ startMs: 0, endMs: 16000 }], "extend")).toContain("不能超过当前模型参考视频上限");
|
||||
});
|
||||
});
|
||||
76
web/test/canvas-video-timeline-segments.test.ts
Normal file
76
web/test/canvas-video-timeline-segments.test.ts
Normal file
@@ -0,0 +1,76 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
|
||||
import { buildTimelineImportSegments } from "../src/lib/canvas/canvas-video-timeline-segments";
|
||||
import { CanvasNodeType, type CanvasConnection, type CanvasNodeData } from "../src/types/canvas";
|
||||
import type { TimelineProject } from "../src/types/timeline";
|
||||
|
||||
function videoNode(id: string, title: string): CanvasNodeData {
|
||||
return {
|
||||
id,
|
||||
type: CanvasNodeType.Video,
|
||||
title,
|
||||
position: { x: 0, y: 0 },
|
||||
width: 100,
|
||||
height: 100,
|
||||
metadata: { content: `data:video/mp4;base64,${id}`, durationMs: 10_000 },
|
||||
};
|
||||
}
|
||||
|
||||
function connection(fromNodeId: string, toNodeId: string): CanvasConnection {
|
||||
return { id: `conn-${fromNodeId}-${toNodeId}`, fromNodeId, toNodeId };
|
||||
}
|
||||
|
||||
const timeline: TimelineProject = {
|
||||
version: 2,
|
||||
tracks: [],
|
||||
clips: [
|
||||
{
|
||||
id: "clip-1",
|
||||
kind: "video",
|
||||
nodeId: "source-video",
|
||||
trackId: "video",
|
||||
startMs: 0,
|
||||
durationMs: 3_000,
|
||||
title: "源视频",
|
||||
sourceStartMs: 2_000,
|
||||
sourceDurationMs: 3_000,
|
||||
},
|
||||
],
|
||||
durationMs: 3_000,
|
||||
};
|
||||
|
||||
describe("buildTimelineImportSegments", () => {
|
||||
test("下游节点能从上游视频节点导入时间线片段", () => {
|
||||
const source = videoNode("source-video", "源视频");
|
||||
const segment = videoNode("segment-video", "片段");
|
||||
const target = videoNode("target-video", "结果");
|
||||
const nodes = [target, segment, source];
|
||||
const connections = [connection("source-video", "segment-video"), connection("segment-video", "target-video")];
|
||||
const result = buildTimelineImportSegments(target, nodes, connections, timeline, 10_000);
|
||||
expect(result.ok).toBe(true);
|
||||
if (!result.ok) return;
|
||||
expect(result.segments).toHaveLength(1);
|
||||
expect(result.segments[0]).toMatchObject({ startMs: 2_000, endMs: 5_000, sourceNodeId: "source-video" });
|
||||
});
|
||||
|
||||
test("旧数据 nodeId 不一致时按唯一标题回退匹配", () => {
|
||||
const source = videoNode("source-video", "源视频");
|
||||
const target = videoNode("target-video", "结果");
|
||||
const nodes = [target, source];
|
||||
const connections = [connection("source-video", "target-video")];
|
||||
const oldTimeline: TimelineProject = {
|
||||
...timeline,
|
||||
clips: [{ ...timeline.clips[0], nodeId: "legacy-source-id" }],
|
||||
};
|
||||
const result = buildTimelineImportSegments(target, nodes, connections, oldTimeline, 10_000);
|
||||
expect(result.ok).toBe(true);
|
||||
if (!result.ok) return;
|
||||
expect(result.segments[0].sourceNodeId).toBe("source-video");
|
||||
});
|
||||
|
||||
test("没有匹配片段时返回错误", () => {
|
||||
const target = videoNode("target-video", "结果");
|
||||
const result = buildTimelineImportSegments(target, [target], [], timeline, 10_000);
|
||||
expect(result.ok).toBe(false);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user