feat(canvas): 视频节点截取重生成 - 支持多段/时间线导入并调用视频模型批量重生成 (#197)

* feat(canvas): 视频节点截取重生成 - 支持多段/时间线导入并调用视频模型批量重生成

* test(canvas): 视频节点时间线导入 - 修正旧数据标题回退测试

* style(canvas): 视频节点截取重生成 - 格式化新增文件并修正测试

---------

Co-authored-by: sikongyue <3155157671@qq.com>
This commit is contained in:
爱笑的毛毛虫
2026-08-10 00:13:02 +08:00
committed by GitHub
parent ee95fbf1dd
commit eceaf7ac41
19 changed files with 1206 additions and 8 deletions

View 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);
});
});

View 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("不能超过当前模型参考视频上限");
});
});

View 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);
});
});