From babfb3dc7f97cae015981355b7b5a180eda7b303 Mon Sep 17 00:00:00 2001
From: ahyang <53590964+zAhYAng@users.noreply.github.com>
Date: Sun, 9 Aug 2026 08:58:21 +0800
Subject: [PATCH 1/3] =?UTF-8?q?fix(assets):=20=E7=B4=A0=E6=9D=90=E5=BA=93?=
=?UTF-8?q?=20-=20=E4=BF=AE=E5=A4=8D=E4=B8=BB=E9=A2=98=E6=8C=89=E9=92=AE?=
=?UTF-8?q?=E5=B9=B6=E6=96=B0=E5=A2=9E=E6=89=B9=E9=87=8F=E5=85=A8=E9=80=89?=
=?UTF-8?q?=20(#193)?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
web/src/pages/assets/index.tsx | 5 ++++-
web/src/styles/globals.css | 10 ++++++----
web/test/asset-header-button-style.test.ts | 17 +++++++++++++++++
web/test/assets-page-batch-toolbar.test.ts | 15 +++++++++++++++
4 files changed, 42 insertions(+), 5 deletions(-)
create mode 100644 web/test/asset-header-button-style.test.ts
create mode 100644 web/test/assets-page-batch-toolbar.test.ts
diff --git a/web/src/pages/assets/index.tsx b/web/src/pages/assets/index.tsx
index 5835b1092..1d8526400 100644
--- a/web/src/pages/assets/index.tsx
+++ b/web/src/pages/assets/index.tsx
@@ -1,4 +1,4 @@
-import { Copy, Download, FileUp, MoreHorizontal, PencilLine, Plus, Search, Trash2, Upload } from "lucide-react";
+import { CheckCheck, Copy, Download, FileUp, MoreHorizontal, PencilLine, Plus, Search, Trash2, Upload } from "lucide-react";
import { useEffect, useMemo, useRef, useState } from "react";
import { App, Button, Drawer, Dropdown, Form, Input, Modal, Select, Space, Tag, Typography } from "antd";
@@ -95,6 +95,8 @@ export default function AssetsPage() {
return assetSearchText(asset).includes(query);
});
}, [validAssets, keyword, kindFilter, categoryFilter]);
+ const filteredAssetIds = useMemo(() => filteredAssets.map((asset) => asset.id), [filteredAssets]);
+ const allFilteredSelected = filteredAssetIds.length > 0 && filteredAssetIds.every((id) => selectedIds.includes(id));
const visibleAssets = useMemo(() => {
const start = (page - 1) * pageSize;
@@ -283,6 +285,7 @@ export default function AssetsPage() {
{selectedAssets.length ? (
已选择 {selectedAssets.length} 个素材
+ } disabled={allFilteredSelected} onClick={() => setSelectedIds((current) => Array.from(new Set([...current, ...filteredAssetIds])))}>全选
} onClick={() => void exportSelectedAssets()}>导出
} onClick={() => setBatchDeleteOpen(true)}>删除
diff --git a/web/src/styles/globals.css b/web/src/styles/globals.css
index 67c18eccd..5ea8093d9 100644
--- a/web/src/styles/globals.css
+++ b/web/src/styles/globals.css
@@ -3419,17 +3419,19 @@
.library-page .app-page-header p { max-width: 560px; }
/* 页面头部按钮优化 - 去除重边框 */
- .library-page .app-page-header .ant-btn,
- .canvas-library-page .app-page-header .ant-btn {
+ html:not(.dark) .library-page .app-page-header .ant-btn,
+ html:not(.dark) .canvas-library-page .app-page-header .ant-btn {
border: 1px solid rgba(17, 17, 17, 0.08) !important;
background: #ffffff !important;
+ color: #171717 !important;
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.04) !important;
transition: all 180ms ease-out !important;
}
- .library-page .app-page-header .ant-btn:hover,
- .canvas-library-page .app-page-header .ant-btn:hover {
+ html:not(.dark) .library-page .app-page-header .ant-btn:hover,
+ html:not(.dark) .canvas-library-page .app-page-header .ant-btn:hover {
border-color: rgba(17, 17, 17, 0.12) !important;
background: #ffffff !important;
+ color: #171717 !important;
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.08) !important;
transform: translateY(-1px);
}
diff --git a/web/test/asset-header-button-style.test.ts b/web/test/asset-header-button-style.test.ts
new file mode 100644
index 000000000..0b9ad39e9
--- /dev/null
+++ b/web/test/asset-header-button-style.test.ts
@@ -0,0 +1,17 @@
+import { describe, expect, test } from "bun:test";
+import { readFileSync } from "node:fs";
+import { resolve } from "node:path";
+
+describe("asset library header buttons", () => {
+ test("only forces white header buttons in light mode", () => {
+ const css = readFileSync(resolve(import.meta.dir, "../src/styles/globals.css"), "utf8");
+ const headerButtonRule = css.match(/html:not\(\.dark\) \.library-page \.app-page-header \.ant-btn,[\s\S]*?transition: all 180ms ease-out !important;\s*}/)?.[0] || "";
+ const headerButtonHoverRule = css.match(/html:not\(\.dark\) \.library-page \.app-page-header \.ant-btn:hover,[\s\S]*?transform: translateY\(-1px\);\s*}/)?.[0] || "";
+
+ expect(headerButtonRule).toContain("background: #ffffff !important;");
+ expect(headerButtonRule).toContain("color: #171717 !important;");
+ expect(headerButtonHoverRule).toContain("background: #ffffff !important;");
+ expect(headerButtonHoverRule).toContain("color: #171717 !important;");
+ expect(css).not.toContain("\n .library-page .app-page-header .ant-btn,\n");
+ });
+});
diff --git a/web/test/assets-page-batch-toolbar.test.ts b/web/test/assets-page-batch-toolbar.test.ts
new file mode 100644
index 000000000..479b67499
--- /dev/null
+++ b/web/test/assets-page-batch-toolbar.test.ts
@@ -0,0 +1,15 @@
+import { describe, expect, test } from "bun:test";
+import { readFileSync } from "node:fs";
+import { resolve } from "node:path";
+
+describe("assets page batch toolbar", () => {
+ test("places select all before cancel selection", () => {
+ const source = readFileSync(resolve(import.meta.dir, "../src/pages/assets/index.tsx"), "utf8");
+ const selectAllIndex = source.indexOf(">全选");
+ const clearSelectionIndex = source.indexOf(">取消选择");
+
+ expect(selectAllIndex).toBeGreaterThanOrEqual(0);
+ expect(clearSelectionIndex).toBeGreaterThanOrEqual(0);
+ expect(selectAllIndex).toBeLessThan(clearSelectionIndex);
+ });
+});
From 690cb61bf2958a4debe4d22f44d88707c6d4920f Mon Sep 17 00:00:00 2001
From: ahyang <53590964+zAhYAng@users.noreply.github.com>
Date: Sun, 9 Aug 2026 09:00:35 +0800
Subject: [PATCH 2/3] =?UTF-8?q?fix(create):=20=E5=88=9B=E4=BD=9C=E7=B4=A0?=
=?UTF-8?q?=E6=9D=90=E5=BA=93=20-=20=E4=B8=8A=E4=BC=A0=E7=B4=A0=E6=9D=90?=
=?UTF-8?q?=E4=BB=85=E5=9C=A8=E7=A1=AE=E8=AE=A4=E5=90=8E=E5=9B=9E=E5=A1=AB?=
=?UTF-8?q?=E5=8F=82=E8=80=83=20(#194)?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
web/src/pages/create/index.tsx | 58 +++++++++++++++++++++-----
web/test/create-library-button.test.ts | 28 +++++++++++++
2 files changed, 75 insertions(+), 11 deletions(-)
create mode 100644 web/test/create-library-button.test.ts
diff --git a/web/src/pages/create/index.tsx b/web/src/pages/create/index.tsx
index 93cb2ac26..81b00a32a 100644
--- a/web/src/pages/create/index.tsx
+++ b/web/src/pages/create/index.tsx
@@ -255,6 +255,20 @@ export default function CreatePage() {
};
const maxReferences = mode === "video" ? videoProfile.operations.includes("image_to_video") ? videoProfile.references.maxImages : 0 : mode === "image" ? imageProfile.references.maxImages : 6;
+ const uploadCreationAsset = async (file: File) => {
+ if (file.type.startsWith("video/")) {
+ const uploaded = await uploadMediaFile(file, "create-upload");
+ return {
+ asset: creationVideoAsset({ title: file.name, uploaded, metadata: { source: "create-upload", fileName: file.name } }),
+ attachment: creationAttachmentFromVideo(file, uploaded),
+ };
+ }
+ const uploaded = await uploadImage(file);
+ return {
+ asset: creationImageAsset({ title: file.name, uploaded, metadata: { source: "create-upload", fileName: file.name } }),
+ attachment: creationAttachmentFromImage(file, uploaded),
+ };
+ };
const addAttachments = (files: FileList | File[]) => {
if ((mode === "image" || mode === "video") && maxReferences === 0) {
toast.warning(mode === "image" ? "当前图片模型不支持参考图" : "当前模型不支持图生视频");
@@ -265,14 +279,9 @@ export default function CreatePage() {
.slice(0, Math.max(0, maxReferences - attachments.length));
if (!next.length) return;
void Promise.allSettled(next.map(async (file) => {
- if (file.type.startsWith("video/")) {
- const uploaded = await uploadMediaFile(file, "create-upload");
- addAsset(creationVideoAsset({ title: file.name, uploaded, metadata: { source: "create-upload", fileName: file.name } }));
- return creationAttachmentFromVideo(file, uploaded);
- }
- const uploaded = await uploadImage(file);
- addAsset(creationImageAsset({ title: file.name, uploaded, metadata: { source: "create-upload", fileName: file.name } }));
- return creationAttachmentFromImage(file, uploaded);
+ const { asset, attachment } = await uploadCreationAsset(file);
+ addAsset(asset);
+ return attachment;
})).then((settled) => {
const items = settled.flatMap((entry) => entry.status === "fulfilled" ? [entry.value] : []);
const failed = settled.filter((entry) => entry.status === "rejected");
@@ -281,6 +290,19 @@ export default function CreatePage() {
});
};
+ const uploadLibraryAssets = async (files: FileList | File[]) => {
+ const next = Array.from(files).filter((file) => file.type.startsWith("image/") || (mode === "video" && file.type.startsWith("video/")));
+ if (!next.length) return [];
+ const settled = await Promise.allSettled(next.map(async (file) => {
+ const { asset } = await uploadCreationAsset(file);
+ return addAsset(asset);
+ }));
+ const assetIds = settled.flatMap((entry) => entry.status === "fulfilled" ? [entry.value] : []);
+ const failed = settled.filter((entry) => entry.status === "rejected");
+ if (failed.length) toast.error(`${failed.length} 个素材上传失败,请重试`);
+ return assetIds;
+ };
+
const handleFileChange = (event: ChangeEvent) => {
if (event.target.files) addAttachments(event.target.files);
event.target.value = "";
@@ -558,16 +580,18 @@ export default function CreatePage() {
setHistoryOpen(false)} onSelect={selectConversation} />
- item.id.startsWith("asset:")).map((item) => item.id.slice(6)))} onClose={() => setLibraryOpen(false)} onConfirm={handleLibrarySelect} onUpload={() => fileInputRef.current?.click()} />
+ item.id.startsWith("asset:")).map((item) => item.id.slice(6)))} onClose={() => setLibraryOpen(false)} onConfirm={handleLibrarySelect} onUpload={uploadLibraryAssets} />
>;
}
const creationAssetCategoryLabels: Record = { all: "全部素材", character: "角色", environment: "场景", wardrobe: "服饰", prop: "道具", weapon: "武器", style: "画风", other: "其他" };
-function CreationAssetLibraryModal({ open, assets, mode, selectedIds, onClose, onConfirm, onUpload }: { open: boolean; assets: Asset[]; mode: CreationMode; selectedIds: Set; onClose: () => void; onConfirm: (assets: Asset[]) => void; onUpload: () => void }) {
+function CreationAssetLibraryModal({ open, assets, mode, selectedIds, onClose, onConfirm, onUpload }: { open: boolean; assets: Asset[]; mode: CreationMode; selectedIds: Set; onClose: () => void; onConfirm: (assets: Asset[]) => void; onUpload: (files: FileList | File[]) => Promise }) {
const [category, setCategory] = useState("all");
const [keyword, setKeyword] = useState("");
const [selected, setSelected] = useState>(new Set());
+ const [uploading, setUploading] = useState(false);
+ const uploadInputRef = useRef(null);
const mediaAssets = useMemo(() => assets.filter((asset): asset is Extract => asset.kind === "image" || asset.kind === "video"), [assets]);
const categories = useMemo(() => ["all", ...Array.from(new Set(mediaAssets.map((asset) => asset.category || "other")))], [mediaAssets]);
const visibleAssets = useMemo(() => {
@@ -590,6 +614,17 @@ function CreationAssetLibraryModal({ open, assets, mode, selectedIds, onClose, o
return next;
});
};
+ const handleUpload = async (event: ChangeEvent) => {
+ if (!event.target.files || uploading) return;
+ setUploading(true);
+ try {
+ const assetIds = await onUpload(event.target.files);
+ if (assetIds.length) setSelected((current) => new Set([...current, ...assetIds]));
+ } finally {
+ event.target.value = "";
+ setUploading(false);
+ }
+ };
const selectedAssets = mediaAssets.filter((asset) => selected.has(asset.id) && (mode === "video" || asset.kind === "image"));
const count = category === "all" ? mediaAssets.length : mediaAssets.filter((asset) => (asset.category || "other") === category).length;
@@ -600,7 +635,7 @@ function CreationAssetLibraryModal({ open, assets, mode, selectedIds, onClose, o
{visibleAssets.length ? visibleAssets.map((asset) =>
toggle(asset)} />) : 这个分类还没有素材换个分类,或从底部上传一份新素材。
}
-
+
;
}
@@ -755,6 +790,7 @@ function CreationComposer(props: ComposerProps) {
onTranscribed={(text) => props.setPrompt(props.prompt.trim() ? `${props.prompt} ${text}` : text)}
/>
+
{props.mode === "video" || (props.mode === "image" && imageSettingsSupported) ? : null}
{props.mode === "video" ? : null}
diff --git a/web/test/create-library-button.test.ts b/web/test/create-library-button.test.ts
new file mode 100644
index 000000000..90164b37e
--- /dev/null
+++ b/web/test/create-library-button.test.ts
@@ -0,0 +1,28 @@
+import { describe, expect, test } from "bun:test";
+import { readFileSync } from "node:fs";
+import { resolve } from "node:path";
+
+describe("creation library button", () => {
+ test("places a library control beside the generation mode picker", () => {
+ const source = readFileSync(resolve(import.meta.dir, "../src/pages/create/index.tsx"), "utf8");
+ const modePickerIndex = source.indexOf("");
+ const libraryButtonIndex = source.indexOf('className="creation-chat-control" onClick={props.onOpenLibrary} disabled={props.busy || !referencesSupported} aria-label="打开素材库选择参考内容"');
+
+ expect(modePickerIndex).toBeGreaterThanOrEqual(0);
+ expect(libraryButtonIndex).toBeGreaterThan(modePickerIndex);
+ expect(source.slice(libraryButtonIndex, libraryButtonIndex + 180)).toContain("");
+ expect(source.slice(libraryButtonIndex, libraryButtonIndex + 180)).toContain("素材库");
+ });
+
+ test("uploads from the library without adding a reference before confirmation", () => {
+ const source = readFileSync(resolve(import.meta.dir, "../src/pages/create/index.tsx"), "utf8");
+ const uploadStart = source.indexOf("const uploadLibraryAssets = async");
+ const uploadEnd = source.indexOf("const handleFileChange", uploadStart);
+
+ expect(uploadStart).toBeGreaterThanOrEqual(0);
+ expect(uploadEnd).toBeGreaterThan(uploadStart);
+ expect(source.slice(uploadStart, uploadEnd)).not.toContain("setAttachments");
+ expect(source).toContain("onUpload={uploadLibraryAssets}");
+ expect(source).not.toContain("onUpload={() => fileInputRef.current?.click()}");
+ });
+});
From d33c6389d4df7ba473371f0e9191289113aa6fef Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?=E7=88=B1=E7=AC=91=E7=9A=84=E6=AF=9B=E6=AF=9B=E8=99=AB?=
<315515767@qq.com>
Date: Mon, 10 Aug 2026 00:13:02 +0800
Subject: [PATCH 3/3] =?UTF-8?q?feat(canvas):=20=E8=A7=86=E9=A2=91=E8=8A=82?=
=?UTF-8?q?=E7=82=B9=E6=88=AA=E5=8F=96=E9=87=8D=E7=94=9F=E6=88=90=20-=20?=
=?UTF-8?q?=E6=94=AF=E6=8C=81=E5=A4=9A=E6=AE=B5/=E6=97=B6=E9=97=B4?=
=?UTF-8?q?=E7=BA=BF=E5=AF=BC=E5=85=A5=E5=B9=B6=E8=B0=83=E7=94=A8=E8=A7=86?=
=?UTF-8?q?=E9=A2=91=E6=A8=A1=E5=9E=8B=E6=89=B9=E9=87=8F=E9=87=8D=E7=94=9F?=
=?UTF-8?q?=E6=88=90=20(#197)?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
* feat(canvas): 视频节点截取重生成 - 支持多段/时间线导入并调用视频模型批量重生成
* test(canvas): 视频节点时间线导入 - 修正旧数据标题回退测试
* style(canvas): 视频节点截取重生成 - 格式化新增文件并修正测试
---------
Co-authored-by: sikongyue <3155157671@qq.com>
---
web/package.json | 2 +-
.../components/canvas/canvas-node-toolbar.tsx | 12 +-
web/src/components/canvas/canvas-toolbar.tsx | 4 +-
.../canvas/canvas-video-segment-dialog.tsx | 422 ++++++++++++++++++
.../toolbars/toolbar-settings-modal.tsx | 2 +
.../lib/canvas/canvas-resource-references.ts | 16 +
.../lib/canvas/canvas-video-regeneration.ts | 54 +++
web/src/lib/canvas/canvas-video-segment.ts | 104 +++++
.../canvas/canvas-video-timeline-segments.ts | 54 +++
.../definitions/node-hover-tools.tsx | 28 +-
.../canvas/tool-registry/tool-definition.ts | 5 +
.../canvas-project-selection-toolbar.tsx | 2 +
web/src/pages/canvas/project.tsx | 33 +-
web/src/pages/canvas/shared.tsx | 2 +-
.../pages/canvas/use-canvas-media-tools.ts | 217 ++++++++-
web/src/styles/globals.css | 1 +
web/test/canvas-resource-references.test.ts | 52 +++
web/test/canvas-video-regeneration.test.ts | 128 ++++++
.../canvas-video-timeline-segments.test.ts | 76 ++++
19 files changed, 1206 insertions(+), 8 deletions(-)
create mode 100644 web/src/components/canvas/canvas-video-segment-dialog.tsx
create mode 100644 web/src/lib/canvas/canvas-video-regeneration.ts
create mode 100644 web/src/lib/canvas/canvas-video-segment.ts
create mode 100644 web/src/lib/canvas/canvas-video-timeline-segments.ts
create mode 100644 web/test/canvas-resource-references.test.ts
create mode 100644 web/test/canvas-video-regeneration.test.ts
create mode 100644 web/test/canvas-video-timeline-segments.test.ts
diff --git a/web/package.json b/web/package.json
index a530a1691..3e8ce83c9 100644
--- a/web/package.json
+++ b/web/package.json
@@ -7,7 +7,7 @@
"dev": "vite --host 0.0.0.0 --port 3000",
"build": "tsc --noEmit && vite build",
"typecheck": "tsc --noEmit",
- "test": "bun test test/agent-prompt-cache.test.ts test/canvas-character-reference.test.ts test/canvas-document.test.ts test/canvas-drawing-engine.test.ts test/canvas-generation-layout.test.ts test/canvas-leafer-viewport.test.ts test/client-id.test.ts",
+ "test": "bun test test/agent-prompt-cache.test.ts test/canvas-character-reference.test.ts test/canvas-document.test.ts test/canvas-drawing-engine.test.ts test/canvas-generation-layout.test.ts test/canvas-leafer-viewport.test.ts test/canvas-resource-references.test.ts test/canvas-video-regeneration.test.ts test/canvas-video-timeline-segments.test.ts test/client-id.test.ts",
"start": "vite preview --host 0.0.0.0 --port 3000",
"format": "prettier --write .",
"format:check": "prettier --check ."
diff --git a/web/src/components/canvas/canvas-node-toolbar.tsx b/web/src/components/canvas/canvas-node-toolbar.tsx
index dffd7da6d..2bc6f98de 100644
--- a/web/src/components/canvas/canvas-node-toolbar.tsx
+++ b/web/src/components/canvas/canvas-node-toolbar.tsx
@@ -42,9 +42,13 @@ type CanvasNodeToolbarProps = {
onAngle: (node: CanvasNodeData) => void;
onViewImage: (node: CanvasNodeData) => void;
onExtractVideoLastFrame: (node: CanvasNodeData) => void;
+ onExtractAudioFromVideo: (node: CanvasNodeData) => void;
+ onTrimVideoRegenerate: (node: CanvasNodeData) => void;
onSubtitles: (node: CanvasNodeData) => void;
onTimeline: (node: CanvasNodeData) => void;
extractingVideoFrame: boolean;
+ extractingAudio: boolean;
+ trimmingVideo: boolean;
onReversePrompt: (node: CanvasNodeData) => void;
onRetry: (node: CanvasNodeData) => void;
onToggleFreeResize: (node: CanvasNodeData) => void;
@@ -105,9 +109,13 @@ export function CanvasNodeToolbar({
onAngle,
onViewImage,
onExtractVideoLastFrame,
+ onExtractAudioFromVideo,
+ onTrimVideoRegenerate,
onSubtitles,
onTimeline,
extractingVideoFrame,
+ extractingAudio,
+ trimmingVideo,
onReversePrompt,
onRetry,
onToggleFreeResize,
@@ -243,7 +251,7 @@ export function CanvasNodeToolbar({
onNodeToggleDialog: onToggleDialog, onNodeAnnotate: onAnnotate, onNodeGenerateImage: onGenerateImage, onNodeUpload: onUpload, onNodeDownload: onDownload,
onNodeSaveAsset: onSaveAsset, onNodeMaskEdit: onMaskEdit, onNodeEmotion: onEmotion, onNodePortraitTexture: onPortraitTexture, onNodeCrop: onCrop,
onNodeSplit: onSplit, onNodeUpscale: onUpscale, onNodeSuperResolve: onSuperResolve, onNodeAngle: onAngle, onNodeViewImage: onViewImage,
- onNodeExtractVideoLastFrame: onExtractVideoLastFrame, onNodeReversePrompt: onReversePrompt, onNodeToggleFreeResize: onToggleFreeResize,
+ onNodeExtractVideoLastFrame: onExtractVideoLastFrame, onNodeExtractAudioFromVideo: onExtractAudioFromVideo, onNodeTrimVideoRegenerate: onTrimVideoRegenerate, onNodeReversePrompt: onReversePrompt, onNodeToggleFreeResize: onToggleFreeResize,
onNodeSubtitles: onSubtitles, onNodeTimeline: onTimeline, onNodeToggleLocked: onToggleLocked, onNodeCopyPrompt: copyImagePrompt,
} as Partial as ToolbarHandlers;
@@ -259,6 +267,8 @@ export function CanvasNodeToolbar({
node,
nodeMetadata: node.metadata,
extractingVideoFrame,
+ extractingAudio,
+ trimmingVideo,
mergingVideos: false,
addPanelOpen: false,
appearancePanelOpen: false,
diff --git a/web/src/components/canvas/canvas-toolbar.tsx b/web/src/components/canvas/canvas-toolbar.tsx
index f9e2ad7ee..0805bb523 100644
--- a/web/src/components/canvas/canvas-toolbar.tsx
+++ b/web/src/components/canvas/canvas-toolbar.tsx
@@ -139,7 +139,7 @@ export function CanvasToolbar({
onNodeInfo: () => {}, onNodeDelete: () => {}, onNodeRetry: () => {}, onNodeEditText: () => {}, onNodeDecreaseFont: () => {}, onNodeIncreaseFont: () => {},
onNodeToggleDialog: () => {}, onNodeAnnotate: () => {}, onNodeGenerateImage: () => {}, onNodeUpload: () => {}, onNodeDownload: () => {}, onNodeSaveAsset: () => {},
onNodeMaskEdit: () => {}, onNodeEmotion: () => {}, onNodePortraitTexture: () => {}, onNodeCrop: () => {}, onNodeSplit: () => {}, onNodeUpscale: () => {},
- onNodeSuperResolve: () => {}, onNodeAngle: () => {}, onNodeViewImage: () => {}, onNodeExtractVideoLastFrame: () => {}, onNodeSubtitles: () => {}, onNodeTimeline: () => {}, onNodeReversePrompt: () => {},
+ onNodeSuperResolve: () => {}, onNodeAngle: () => {}, onNodeViewImage: () => {}, onNodeExtractVideoLastFrame: () => {}, onNodeExtractAudioFromVideo: () => {}, onNodeTrimVideoRegenerate: () => {}, onNodeSubtitles: () => {}, onNodeTimeline: () => {}, onNodeReversePrompt: () => {},
onNodeToggleFreeResize: () => {}, onNodeToggleLocked: () => {}, onNodeCopyPrompt: () => {},
} as ToolbarHandlers;
@@ -153,6 +153,8 @@ export function CanvasToolbar({
canUndo,
canRedo,
extractingVideoFrame: false,
+ extractingAudio: false,
+ trimmingVideo: false,
mergingVideos: false,
addPanelOpen: addOpen,
appearancePanelOpen: appearanceOpen,
diff --git a/web/src/components/canvas/canvas-video-segment-dialog.tsx b/web/src/components/canvas/canvas-video-segment-dialog.tsx
new file mode 100644
index 000000000..13f2d91aa
--- /dev/null
+++ b/web/src/components/canvas/canvas-video-segment-dialog.tsx
@@ -0,0 +1,422 @@
+import { useEffect, useMemo, useRef, useState } from "react";
+import { App, Button, Input, InputNumber, Modal, Select } from "antd";
+import { AudioLines, Check, ListVideo, Plus, Scissors, SkipBack, SkipForward, Trash2 } from "lucide-react";
+import { nanoid } from "nanoid";
+
+import { ModelPicker } from "@/components/model-picker";
+import { canvasThemes } from "@/lib/canvas-theme";
+import { buildTimelineImportSegments, type CanvasTimelineSegmentItem } from "@/lib/canvas/canvas-video-timeline-segments";
+import { listVideoReferenceModels } from "@/lib/canvas/canvas-video-regeneration";
+import { modelCapabilityConfigFor } from "@/lib/model-capabilities";
+import { navigateToSettings } from "@/lib/settings-navigation";
+import { useThemeStore } from "@/stores/use-theme-store";
+import { resolveMediaUrl } from "@/services/file-storage";
+import { cacheResourceObjectUrl } from "@/services/resource-blob-cache";
+import { resourceIdFromStorageKey } from "@/services/api/resources";
+import { modelDisplayName, type AiConfig } from "@/stores/use-config-store";
+import { type CanvasConnection, type CanvasNodeData, type CanvasVideoEditOperation } from "@/types/canvas";
+import type { TimelineProject } from "@/types/timeline";
+
+export type CanvasVideoSegmentItem = CanvasTimelineSegmentItem;
+
+export type CanvasVideoSegmentParams = {
+ mode: "audio" | "video";
+ startMs: number;
+ endMs: number;
+ prompt?: string;
+ segments?: CanvasVideoSegmentItem[];
+ model?: string;
+ operation?: CanvasVideoEditOperation;
+};
+
+type CanvasVideoSegmentDialogProps = {
+ node: CanvasNodeData;
+ nodes: CanvasNodeData[];
+ connections: CanvasConnection[];
+ open: boolean;
+ mode: "audio" | "video";
+ config: AiConfig;
+ timeline?: TimelineProject | null;
+ onClose: () => void;
+ onConfirm: (params: CanvasVideoSegmentParams) => void;
+};
+
+const MIN_SEGMENT_MS = 100;
+
+export function CanvasVideoSegmentDialog({ node, nodes, connections, open, mode, config, timeline, onClose, onConfirm }: CanvasVideoSegmentDialogProps) {
+ const { message } = App.useApp();
+ const theme = canvasThemes[useThemeStore((state) => state.theme)];
+ const videoRef = useRef(null);
+ const segmentsSeededRef = useRef(false);
+ const [videoUrl, setVideoUrl] = useState("");
+ const [videoError, setVideoError] = useState(false);
+ const [durationMs, setDurationMs] = useState(0);
+ const [startSec, setStartSec] = useState(0);
+ const [endSec, setEndSec] = useState(0);
+ const [prompt, setPrompt] = useState("");
+ const [segments, setSegments] = useState([]);
+ const [model, setModel] = useState("");
+ const [operation, setOperation] = useState("extend");
+ const eligibleModels = useMemo(() => listVideoReferenceModels(config), [config]);
+
+ // 打开弹窗时解析视频地址,读取时长并初始化起止时间。
+ useEffect(() => {
+ if (!open) return;
+ let cancelled = false;
+ setVideoUrl("");
+ setVideoError(false);
+ setDurationMs(0);
+ setStartSec(0);
+ setEndSec(0);
+ setPrompt("");
+ setSegments([]);
+ segmentsSeededRef.current = false;
+ const defaultModel = config.videoModel || config.model || "";
+ const initialModel = eligibleModels.includes(defaultModel) ? defaultModel : eligibleModels[0] || defaultModel;
+ setModel(initialModel);
+ const profile = initialModel ? modelCapabilityConfigFor(config, initialModel).video : undefined;
+ setOperation(profile?.operations.includes("extend") ? "extend" : (profile?.operations[0] as CanvasVideoEditOperation | undefined) || "extend");
+ const storageKey = node.metadata?.storageKey || "";
+ const fallback = node.metadata?.content || "";
+ const applyUrl = (url: string) => {
+ if (!cancelled) setVideoUrl(url);
+ };
+ if (resourceIdFromStorageKey(storageKey)) {
+ void cacheResourceObjectUrl(storageKey)
+ .then((cached) => {
+ if (cancelled) return;
+ if (cached) setVideoUrl(cached);
+ else void resolveMediaUrl(storageKey, fallback).then(applyUrl);
+ })
+ .catch(() => {
+ if (!cancelled) void resolveMediaUrl(storageKey, fallback).then(applyUrl);
+ });
+ } else {
+ void resolveMediaUrl(storageKey, fallback).then(applyUrl);
+ }
+ return () => {
+ cancelled = true;
+ };
+ }, [config, eligibleModels, node, open]);
+
+ // 视频模式等待时长元数据就绪后,默认放入一个完整时长的片段。
+ useEffect(() => {
+ if (!open || mode !== "video" || segmentsSeededRef.current || !durationMs) return;
+ segmentsSeededRef.current = true;
+ setSegments([{ id: nanoid(), startMs: 0, endMs: durationMs, sourceNodeId: node.id }]);
+ }, [durationMs, mode, open]);
+
+ const isVideoMode = mode === "video";
+ const durationSec = durationMs > 0 ? durationMs / 1000 : 0;
+ const hasTimelineVideoClips = Boolean(timeline?.clips.some((clip) => clip.kind === "video"));
+ const videoProfile = useMemo(() => (model ? modelCapabilityConfigFor(config, model).video : undefined), [config, model]);
+ const defaultModel = config.videoModel || config.model || "";
+ const defaultModelSupported = eligibleModels.includes(defaultModel);
+ const hasEligibleModels = eligibleModels.length > 0;
+ const firstEligibleModel = eligibleModels[0] || "";
+
+ // 切换模型后保持所选模式仍然有效,优先回退到“视频续写”。
+ useEffect(() => {
+ if (!videoProfile?.operations.length || videoProfile.operations.includes(operation)) return;
+ setOperation(videoProfile.operations.includes("extend") ? "extend" : (videoProfile.operations[0] as CanvasVideoEditOperation));
+ }, [operation, videoProfile]);
+
+ const operationOptions = (videoProfile?.operations || []).map((value) => {
+ const operation = value as CanvasVideoEditOperation;
+ return { value: operation, label: videoOperationLabel(operation) };
+ });
+
+ const rangeMs = useMemo(() => {
+ const start = Math.max(0, Math.round((startSec || 0) * 1000));
+ const end = durationMs > 0 ? Math.min(durationMs, Math.round((endSec || 0) * 1000)) : Math.round((endSec || 0) * 1000);
+ return { startMs: start, endMs: Math.max(start, end) };
+ }, [durationMs, endSec, startSec]);
+
+ const addManualSegment = () => {
+ const last = segments[segments.length - 1];
+ const maxEndMs = durationMs || last?.endMs || 0;
+ const startMs = last ? Math.min(maxEndMs, last.endMs) : 0;
+ const endMs = Math.max(startMs + MIN_SEGMENT_MS, maxEndMs);
+ setSegments((current) => [...current, { id: nanoid(), startMs, endMs, sourceNodeId: node.id }]);
+ };
+
+ const importTimelineSegments = () => {
+ try {
+ const result = buildTimelineImportSegments(node, nodes, connections, timeline, durationMs);
+ if (!result.ok) {
+ message.warning(result.error);
+ return;
+ }
+ setSegments(result.segments);
+ setPrompt((current) => current || `按时间线截取的 ${result.segments.length} 段视频,保持画面主体与镜头,重新生成每一段`);
+ message.success(`已从时间线导入 ${result.segments.length} 个片段`);
+ } catch (error) {
+ console.warn("时间线片段导入失败", error);
+ message.warning("读取时间线片段失败,请刷新后重试");
+ }
+ };
+
+ const updateSegment = (id: string, patch: Partial>) => {
+ setSegments((current) => current.map((item) => (item.id === id ? { ...item, ...patch } : item)));
+ };
+
+ const removeSegment = (id: string) => {
+ setSegments((current) => current.filter((item) => item.id !== id));
+ };
+
+ const handleConfirm = () => {
+ if (isVideoMode) {
+ if (!model) {
+ message.warning("请选择视频模型");
+ return;
+ }
+ if (!segments.length) {
+ message.warning("请至少添加一个截取片段");
+ return;
+ }
+ for (const segment of segments) {
+ if (segment.endMs - segment.startMs < MIN_SEGMENT_MS) {
+ message.warning("片段时长至少 0.1 秒");
+ return;
+ }
+ }
+ onConfirm({
+ mode: "video",
+ startMs: 0,
+ endMs: 0,
+ segments: segments.map(({ id, startMs, endMs, sourceNodeId, sourceStorageKey, sourceUrl }) => ({ id, startMs, endMs, sourceNodeId, sourceStorageKey, sourceUrl })),
+ model,
+ operation,
+ prompt: prompt.trim(),
+ });
+ return;
+ }
+ if (!durationMs) {
+ message.warning("视频时长未就绪,请稍候再试");
+ return;
+ }
+ if (rangeMs.endMs - rangeMs.startMs < MIN_SEGMENT_MS) {
+ message.warning("片段时长至少 0.1 秒");
+ return;
+ }
+ onConfirm({ mode: "audio", startMs: rangeMs.startMs, endMs: rangeMs.endMs });
+ };
+
+ const title = (
+
+
+ {isVideoMode ? : }
+
+
+
{isVideoMode ? "截取片段并批量重生成" : "从视频提取声音"}
+
{node.title || "视频节点"}
+
+
+ );
+
+ return (
+
+
+
+ {videoUrl ? (
+
+
+ {isVideoMode ? (
+
+ {!defaultModelSupported ? (
+
+
+ {hasEligibleModels
+ ? `当前默认模型${defaultModel ? `「${modelDisplayName(config, defaultModel)}」` : ""}不支持参考视频,已自动切换到「${modelDisplayName(config, firstEligibleModel)}」。`
+ : "当前配置中没有支持参考视频的视频模型,请先到设置里配置 Seedance / Agent Plan / NewAPI 渠道。"}
+
+ {!hasEligibleModels ? (
+
+ ) : null}
+
+ ) : null}
+
+
+
+ 时长
+ {durationSec ? formatSegmentTime(durationSec) : "未知"}
+ 已选
+ {segments.length} 段
+
+
+ } disabled={!durationSec} onClick={addManualSegment}>
+ 添加片段
+
+ } disabled={!hasTimelineVideoClips} onClick={importTimelineSegments}>
+ 从时间线导入
+
+
+
+
+ {segments.length ? (
+
+ {segments.map((segment, index) => (
+
+
+
片段 {index + 1}
+
+ 起点
+ updateSegment(segment.id, { startMs: Math.round((value ?? 0) * 1000) })}
+ className="w-full"
+ aria-label={`片段 ${index + 1} 起点(秒)`}
+ />
+
+
+ 终点
+ updateSegment(segment.id, { endMs: Math.round((value ?? 0) * 1000) })}
+ className="w-full"
+ aria-label={`片段 ${index + 1} 终点(秒)`}
+ />
+
+
} aria-label={`删除片段 ${index + 1}`} onClick={() => removeSegment(segment.id)} />
+
+
+ ))}
+
+ ) : (
+
+ 还没有片段:手动添加起点/终点,或从时间线导入该节点的已有片段。
+
+ )}
+
+
+
+
+
+
+
+
+ ) : (
+
+
+ 时长
+ {durationSec ? formatSegmentTime(durationSec) : "未知"}
+
+
+ 起点
+ setStartSec(value ?? 0)} className="w-28" aria-label="片段起点(秒)" />
+ }
+ aria-label="跳转到起点"
+ onClick={() => {
+ if (videoRef.current) videoRef.current.currentTime = startSec;
+ }}
+ />
+
+
+ 终点
+ setEndSec(value ?? 0)} className="w-28" aria-label="片段终点(秒)" />
+ }
+ aria-label="跳转到终点"
+ onClick={() => {
+ if (videoRef.current) videoRef.current.currentTime = endSec;
+ }}
+ />
+
+
+ 片段
+ {formatSegmentTime((rangeMs.endMs - rangeMs.startMs) / 1000)}
+
+
+
提取的 MP3 会保存到素材库,并生成一个音频节点放在当前视频节点下游。
+
+ )}
+
+
+
+ } disabled={isVideoMode ? !segments.length : !durationSec} onClick={handleConfirm}>
+ {isVideoMode ? (segments.length > 1 ? `截取 ${segments.length} 段并生成` : "截取并生成") : "提取音频"}
+
+
+
+
+ );
+}
+
+function videoOperationLabel(operation: CanvasVideoEditOperation) {
+ const labels: Record = {
+ text_to_video: "文生视频",
+ image_to_video: "图生视频",
+ extend: "视频续写",
+ inpaint: "局部修改",
+ replace_element: "元素替换",
+ camera_motion: "运镜调整",
+ style_transfer: "风格迁移",
+ audio_to_video: "音频生视频",
+ compare_versions: "版本对比",
+ concat: "拼接成片",
+ };
+ return labels[operation] || operation;
+}
+
+function formatSegmentTime(seconds: number) {
+ const total = Math.max(0, seconds);
+ const hours = Math.floor(total / 3600);
+ const minutes = Math.floor((total % 3600) / 60);
+ const rawSeconds = total % 60;
+ const secs = rawSeconds % 1 === 0 ? String(Math.floor(rawSeconds)).padStart(2, "0") : rawSeconds.toFixed(1).padStart(4, "0");
+ return hours > 0 ? `${hours}:${String(minutes).padStart(2, "0")}:${secs}` : `${minutes}:${secs}`;
+}
diff --git a/web/src/components/canvas/toolbars/toolbar-settings-modal.tsx b/web/src/components/canvas/toolbars/toolbar-settings-modal.tsx
index 017db4bc2..d7487b29f 100644
--- a/web/src/components/canvas/toolbars/toolbar-settings-modal.tsx
+++ b/web/src/components/canvas/toolbars/toolbar-settings-modal.tsx
@@ -24,6 +24,8 @@ const settingsMockContext: ToolContext = {
canUndo: false,
canRedo: false,
extractingVideoFrame: false,
+ extractingAudio: false,
+ trimmingVideo: false,
mergingVideos: false,
addPanelOpen: false,
appearancePanelOpen: false,
diff --git a/web/src/lib/canvas/canvas-resource-references.ts b/web/src/lib/canvas/canvas-resource-references.ts
index 51ae0b917..120d5d4a0 100644
--- a/web/src/lib/canvas/canvas-resource-references.ts
+++ b/web/src/lib/canvas/canvas-resource-references.ts
@@ -52,6 +52,22 @@ export function getGenerationResourceNodes(nodeId: string, nodes: CanvasNodeData
return [];
}
+/** 收集节点自身及其上游链路中的视频节点,用于时间线片段导入定位真正的视频源。 */
+export function collectUpstreamVideoNodes(nodeId: string, nodes: CanvasNodeData[], connections: CanvasConnection[]): CanvasNodeData[] {
+ const queue = [nodeId];
+ const visited = new Set();
+ const result: CanvasNodeData[] = [];
+ while (queue.length) {
+ const currentId = queue.shift()!;
+ if (visited.has(currentId)) continue;
+ visited.add(currentId);
+ const node = nodes.find((item) => item.id === currentId);
+ if (node?.type === CanvasNodeType.Video && Boolean(node.metadata?.content || node.metadata?.storageKey)) result.push(node);
+ connections.filter((connection) => connection.toNodeId === currentId).forEach((connection) => queue.push(connection.fromNodeId));
+ }
+ return result;
+}
+
function getContextResourceNodes(nodeId: string, nodes: CanvasNodeData[], connections: CanvasConnection[]) {
return connections
.filter((connection) => connection.toNodeId === nodeId)
diff --git a/web/src/lib/canvas/canvas-video-regeneration.ts b/web/src/lib/canvas/canvas-video-regeneration.ts
new file mode 100644
index 000000000..112a30d01
--- /dev/null
+++ b/web/src/lib/canvas/canvas-video-regeneration.ts
@@ -0,0 +1,54 @@
+import { modelCapabilityConfigFor } from "@/lib/model-capabilities";
+import { isSeedanceVideoConfig } from "@/lib/seedance-video";
+import type { CanvasVideoEditOperation } from "@/types/canvas";
+import { resolveModelChannel, selectableModelsByCapability, type AiConfig } from "@/stores/use-config-store";
+
+export function listVideoReferenceModels(config: AiConfig): string[] {
+ return selectableModelsByCapability(config, "video").filter((model) => {
+ const profile = modelCapabilityConfigFor(config, model).video;
+ if (!profile || profile.references.maxVideos < 1 || !profile.operations.length) return false;
+ const channel = resolveModelChannel(config, model);
+ return Boolean(channel.baseUrl.trim() && channel.apiKey.trim());
+ });
+}
+
+export function videoReferenceRegenerationError(config: AiConfig): string {
+ const videoProfile = modelCapabilityConfigFor(config, config.model).video;
+ if (!videoProfile || videoProfile.references.maxVideos < 1) {
+ return "当前所选视频模型不支持参考视频,无法使用截取重生成。请选择支持参考视频的模型,或在设置中配置 Seedance / Agent Plan / NewAPI 渠道。";
+ }
+ if (!videoProfile.operations.length) return "当前视频模型没有可用的视频生成模式";
+ return "";
+}
+
+export function videoReferenceOperationError(config: AiConfig, operation: CanvasVideoEditOperation): string {
+ const videoProfile = modelCapabilityConfigFor(config, config.model).video;
+ if (!videoProfile?.operations.includes(operation)) return "当前视频模型不支持所选生成模式";
+ return "";
+}
+
+export function validateVideoSegmentBatch(config: AiConfig, segments: Array<{ startMs: number; endMs: number }>, operation?: CanvasVideoEditOperation): string {
+ const referenceError = videoReferenceRegenerationError(config);
+ if (referenceError) return referenceError;
+ if (operation) {
+ const operationError = videoReferenceOperationError(config, operation);
+ if (operationError) return operationError;
+ }
+ for (const segment of segments) {
+ const segmentError = videoReferenceSegmentError(config, segment.endMs - segment.startMs);
+ if (segmentError) return segmentError;
+ }
+ return "";
+}
+
+export function videoReferenceSegmentError(config: AiConfig, durationMs: number): string {
+ const videoProfile = modelCapabilityConfigFor(config, config.model).video;
+ const maxSeconds = videoProfile?.references.maxVideoDurationSeconds || 0;
+ if (maxSeconds > 0 && durationMs > maxSeconds * 1000) {
+ return `截取片段不能超过当前模型参考视频上限(${maxSeconds} 秒)`;
+ }
+ if (isSeedanceVideoConfig(config) && durationMs < 2000) {
+ return "Seedance 参考视频单段至少 2 秒,请扩大截取范围";
+ }
+ return "";
+}
diff --git a/web/src/lib/canvas/canvas-video-segment.ts b/web/src/lib/canvas/canvas-video-segment.ts
new file mode 100644
index 000000000..de50d76f5
--- /dev/null
+++ b/web/src/lib/canvas/canvas-video-segment.ts
@@ -0,0 +1,104 @@
+import { fetchFile } from "@ffmpeg/util";
+
+import { getMediaBlob } from "@/services/file-storage";
+import { loadFFmpeg } from "./canvas-video-merge";
+
+export type VideoSegmentRange = {
+ startMs: number;
+ endMs: number;
+};
+
+export type VideoSegmentSource = {
+ url?: string;
+ storageKey?: string;
+};
+
+export type VideoSegmentProgress = {
+ phase: "loading" | "reading" | "encoding";
+ progress: number;
+};
+
+const INPUT_NAME = "segment-input.mp4";
+const OUTPUT_NAME = "segment-output.mp4";
+
+function assertValidRange(range: VideoSegmentRange, durationMs?: number) {
+ const startMs = Math.max(0, Math.round(range.startMs));
+ const endMs = Math.round(range.endMs);
+ if (endMs <= startMs) throw new Error("片段结束时间必须晚于开始时间");
+ if (durationMs !== undefined && endMs > Math.round(durationMs)) throw new Error("片段结束时间超过视频时长");
+}
+
+async function readVideoSourceBlob(source: VideoSegmentSource) {
+ if (source.storageKey) {
+ const stored = await getMediaBlob(source.storageKey);
+ if (stored) return stored;
+ }
+ if (source.url) {
+ const response = await fetch(source.url);
+ if (!response.ok) throw new Error(`视频资源请求失败(${response.status})`);
+ return response.blob();
+ }
+ throw new Error("找不到视频素材,请重新上传后再操作");
+}
+
+async function runSegmentJob(
+ source: VideoSegmentSource,
+ range: VideoSegmentRange,
+ durationMs: number | undefined,
+ buildArgs: (startSec: string, durationSec: string) => string[],
+ onProgress?: (progress: VideoSegmentProgress) => void,
+ outputType = "video/mp4",
+) {
+ assertValidRange(range, durationMs);
+ const ffmpeg = await loadFFmpeg(({ phase, progress }) => onProgress?.({ phase: phase === "loading" ? "loading" : "reading", progress }));
+ const blob = await readVideoSourceBlob(source);
+ onProgress?.({ phase: "reading", progress: 45 });
+ await ffmpeg.writeFile(INPUT_NAME, await fetchFile(blob));
+ const startSec = String(range.startMs / 1000);
+ const durationSec = String((range.endMs - range.startMs) / 1000);
+ onProgress?.({ phase: "encoding", progress: 55 });
+ try {
+ const exitCode = await ffmpeg.exec(["-y", ...buildArgs(startSec, durationSec)]);
+ if (exitCode !== 0) throw new Error("媒体处理失败,请确认视频编码格式兼容");
+ const output = await ffmpeg.readFile(OUTPUT_NAME);
+ onProgress?.({ phase: "encoding", progress: 100 });
+ return new Blob([output as BlobPart], { type: outputType });
+ } finally {
+ await Promise.all([INPUT_NAME, OUTPUT_NAME].map((file) => ffmpeg.deleteFile(file).catch(() => undefined)));
+ }
+}
+
+/** 按片段范围截取视频,输出统一编码 MP4(复用时间线 trim 的参数模板)。 */
+export async function trimVideoSegment(source: VideoSegmentSource, range: VideoSegmentRange, durationMs?: number, onProgress?: (progress: VideoSegmentProgress) => void) {
+ return runSegmentJob(
+ source,
+ range,
+ durationMs,
+ (startSec, durationSec) => ["-ss", startSec, "-i", INPUT_NAME, "-t", durationSec, "-c:v", "libx264", "-preset", "veryfast", "-crf", "20", "-c:a", "aac", "-movflags", "+faststart", OUTPUT_NAME],
+ onProgress,
+ "video/mp4",
+ );
+}
+
+/** 从视频片段提取声音为 MP3;优先 libmp3lame,内核不支持时回退默认 mp3 编码器。 */
+export async function extractVideoAudio(source: VideoSegmentSource, range: VideoSegmentRange, durationMs?: number, onProgress?: (progress: VideoSegmentProgress) => void) {
+ const ffmpeg = await loadFFmpeg(({ phase, progress }) => onProgress?.({ phase: phase === "loading" ? "loading" : "reading", progress }));
+ const blob = await readVideoSourceBlob(source);
+ onProgress?.({ phase: "reading", progress: 45 });
+ await ffmpeg.writeFile(INPUT_NAME, await fetchFile(blob));
+ assertValidRange(range, durationMs);
+ const startSec = String(range.startMs / 1000);
+ const durationSec = String((range.endMs - range.startMs) / 1000);
+ onProgress?.({ phase: "encoding", progress: 55 });
+ try {
+ const args = (audioCodec: string) => ["-ss", startSec, "-i", INPUT_NAME, "-t", durationSec, "-vn", "-c:a", audioCodec, "-q:a", "2", OUTPUT_NAME];
+ let exitCode = await ffmpeg.exec(["-y", ...args("libmp3lame")]);
+ if (exitCode !== 0) exitCode = await ffmpeg.exec(["-y", ...args("mp3")]);
+ if (exitCode !== 0) throw new Error("音频提取失败:当前 FFmpeg 内核不支持 MP3 编码");
+ const output = await ffmpeg.readFile(OUTPUT_NAME);
+ onProgress?.({ phase: "encoding", progress: 100 });
+ return new Blob([output as BlobPart], { type: "audio/mpeg" });
+ } finally {
+ await Promise.all([INPUT_NAME, OUTPUT_NAME].map((file) => ffmpeg.deleteFile(file).catch(() => undefined)));
+ }
+}
diff --git a/web/src/lib/canvas/canvas-video-timeline-segments.ts b/web/src/lib/canvas/canvas-video-timeline-segments.ts
new file mode 100644
index 000000000..82a3b421c
--- /dev/null
+++ b/web/src/lib/canvas/canvas-video-timeline-segments.ts
@@ -0,0 +1,54 @@
+import { collectUpstreamVideoNodes } from "@/lib/canvas/canvas-resource-references";
+import type { CanvasConnection, CanvasNodeData } from "@/types/canvas";
+import type { TimelineClip, TimelineProject } from "@/types/timeline";
+
+export type CanvasTimelineSegmentItem = {
+ id: string;
+ startMs: number;
+ endMs: number;
+ sourceNodeId?: string;
+ sourceStorageKey?: string;
+ sourceUrl?: string;
+};
+
+export type TimelineImportResult = { ok: true; segments: CanvasTimelineSegmentItem[] } | { ok: false; error: string };
+
+const MIN_TIMELINE_SEGMENT_MS = 100;
+
+export function buildTimelineImportSegments(node: CanvasNodeData, nodes: CanvasNodeData[], connections: CanvasConnection[], timeline: TimelineProject | null | undefined, durationMs: number): TimelineImportResult {
+ const candidateNodes = collectUpstreamVideoNodes(node.id, nodes, connections);
+ const matchesCandidate = (clip: TimelineClip, candidate: CanvasNodeData) => {
+ if (candidate.id === clip.nodeId) return true;
+ if (candidate.metadata?.assetId && clip.nodeId === candidate.metadata.assetId) return true;
+ if (candidate.metadata?.storageKey && clip.directMedia?.storageKey === candidate.metadata.storageKey) return true;
+ // 旧数据里 nodeId 可能被重新生成过;标题一致且唯一时才回退,避免同名视频误导入。
+ if (candidate.title && clip.title === candidate.title && !clip.directMedia) {
+ return candidateNodes.filter((item) => item.title === candidate.title).length === 1;
+ }
+ return false;
+ };
+ const clips = (timeline?.clips || []).filter((clip) => clip.kind === "video" && candidateNodes.some((candidate) => matchesCandidate(clip, candidate)));
+ if (!clips.length) {
+ return { ok: false, error: `未找到「${node.title || "当前视频节点"}」或其上游视频节点在时间线中的片段,请先在时间线中加入对应视频节点` };
+ }
+ const segments = clips.flatMap((clip) => {
+ const matchedNode = candidateNodes.find((candidate) => matchesCandidate(clip, candidate));
+ const startMs = Math.max(0, Math.round(clip.sourceStartMs ?? 0));
+ const rawEndMs = startMs + Math.max(MIN_TIMELINE_SEGMENT_MS, Math.round(clip.sourceDurationMs || clip.durationMs));
+ const sourceDurationMs = matchedNode?.metadata?.durationMs || durationMs;
+ const endMs = sourceDurationMs > 0 ? Math.min(sourceDurationMs, rawEndMs) : rawEndMs;
+ if (!Number.isFinite(startMs) || !Number.isFinite(endMs) || endMs <= startMs) return [];
+ return [
+ {
+ id: `timeline-${clip.id}`,
+ startMs,
+ endMs,
+ sourceNodeId: matchedNode?.id,
+ sourceStorageKey: clip.directMedia?.storageKey || matchedNode?.metadata?.storageKey,
+ sourceUrl: clip.directMedia?.url || matchedNode?.metadata?.content,
+ },
+ ];
+ });
+ if (!segments.length) return { ok: false, error: "时间线片段数据不完整,请先在时间线中重新分割或保存" };
+ return { ok: true, segments };
+}
diff --git a/web/src/lib/canvas/tool-registry/definitions/node-hover-tools.tsx b/web/src/lib/canvas/tool-registry/definitions/node-hover-tools.tsx
index 807d14856..7dc860a59 100644
--- a/web/src/lib/canvas/tool-registry/definitions/node-hover-tools.tsx
+++ b/web/src/lib/canvas/tool-registry/definitions/node-hover-tools.tsx
@@ -1,4 +1,4 @@
-import { Captions, Clapperboard, Download, FolderPlus, GalleryHorizontalEnd, Image as ImageIcon, Info, LoaderCircle, Lock, Maximize2, MessageSquare, Minus, Music2, Plus, RefreshCw, Settings2, Trash2, Unlock, Upload, UserRound, Video } from "lucide-react";
+import { AudioLines, Captions, Clapperboard, Download, FolderPlus, GalleryHorizontalEnd, Image as ImageIcon, Info, LoaderCircle, Lock, Maximize2, MessageSquare, Minus, Music2, Plus, RefreshCw, Scissors, Settings2, Trash2, Unlock, Upload, UserRound, Video } from "lucide-react";
import { CONTENT_MODERATION_ERROR_CODE, isContentModerationError } from "@/lib/generation-error";
import { registerToolbarTools, type ToolContext, type ToolDefinition } from "@/lib/canvas/tool-registry";
@@ -73,6 +73,32 @@ export const nodeHoverToolbarTools: ToolDefinition[] = [
disabled: (ctx) => ctx.extractingVideoFrame,
run: (ctx) => ctx.handlers.onNodeExtractVideoLastFrame(ctx.node!),
},
+ {
+ id: "extractAudio",
+ toolbar: "node-hover",
+ category: "node-state",
+ label: (ctx) => ctx.extractingAudio ? "正在提取声音为 MP3" : "提取声音为 MP3",
+ displayLabel: (ctx) => ctx.extractingAudio ? "提取中" : "提取音频",
+ icon: (ctx) => ctx.extractingAudio ? : ,
+ defaultVisible: true,
+ defaultOrder: 42,
+ applicable: (ctx) => hasVideo(ctx) && !simpleMode(ctx),
+ disabled: (ctx) => ctx.extractingAudio || ctx.trimmingVideo,
+ run: (ctx) => ctx.handlers.onNodeExtractAudioFromVideo(ctx.node!),
+ },
+ {
+ id: "trimRegenerate",
+ toolbar: "node-hover",
+ category: "node-state",
+ label: (ctx) => ctx.trimmingVideo ? "正在截取片段" : "按段截取并重生成",
+ displayLabel: (ctx) => ctx.trimmingVideo ? "截取中" : "截取重生成",
+ icon: (ctx) => ctx.trimmingVideo ? : ,
+ defaultVisible: true,
+ defaultOrder: 44,
+ applicable: (ctx) => hasVideo(ctx) && !simpleMode(ctx),
+ disabled: (ctx) => ctx.extractingAudio || ctx.trimmingVideo,
+ run: (ctx) => ctx.handlers.onNodeTrimVideoRegenerate(ctx.node!),
+ },
{
id: "saveAsset",
toolbar: "node-hover",
diff --git a/web/src/lib/canvas/tool-registry/tool-definition.ts b/web/src/lib/canvas/tool-registry/tool-definition.ts
index 72c85b309..dd51e38e2 100644
--- a/web/src/lib/canvas/tool-registry/tool-definition.ts
+++ b/web/src/lib/canvas/tool-registry/tool-definition.ts
@@ -81,6 +81,8 @@ export type ToolbarHandlers = {
onNodeAngle: (node: CanvasNodeData) => void;
onNodeViewImage: (node: CanvasNodeData) => void;
onNodeExtractVideoLastFrame: (node: CanvasNodeData) => void;
+ onNodeExtractAudioFromVideo: (node: CanvasNodeData) => void;
+ onNodeTrimVideoRegenerate: (node: CanvasNodeData) => void;
onNodeSubtitles: (node: CanvasNodeData) => void;
onNodeTimeline: (node: CanvasNodeData) => void;
onNodeReversePrompt: (node: CanvasNodeData) => void;
@@ -105,6 +107,9 @@ export type ToolContext = {
nodeMetadata?: CanvasNodeMetadata;
/** 视频尾帧提取中(节点悬停工具栏用) */
extractingVideoFrame: boolean;
+ /** 视频音频提取/片段截取进行中(节点悬停工具栏用) */
+ extractingAudio: boolean;
+ trimmingVideo: boolean;
/** 合并视频中(多选工具栏用) */
mergingVideos: boolean;
/** 主工具栏面板开关状态(仅主工具栏使用) */
diff --git a/web/src/pages/canvas/canvas-project-selection-toolbar.tsx b/web/src/pages/canvas/canvas-project-selection-toolbar.tsx
index 87d0bcca3..6c79712bd 100644
--- a/web/src/pages/canvas/canvas-project-selection-toolbar.tsx
+++ b/web/src/pages/canvas/canvas-project-selection-toolbar.tsx
@@ -38,6 +38,8 @@ export function CanvasProjectSelectionToolbar({ anchorRef, containerRef, count,
canUndo: false,
canRedo: false,
extractingVideoFrame: false,
+ extractingAudio: false,
+ trimmingVideo: false,
mergingVideos,
addPanelOpen: false,
appearancePanelOpen: false,
diff --git a/web/src/pages/canvas/project.tsx b/web/src/pages/canvas/project.tsx
index 8175dac64..1b72c52d7 100644
--- a/web/src/pages/canvas/project.tsx
+++ b/web/src/pages/canvas/project.tsx
@@ -31,6 +31,7 @@ import { resolveProjectCanvasStyle } from "@/components/canvas/canvas-style-pick
import { createStyleProfileSnapshot, resolveStyleProfile, serializeStyleProfile } from "@/lib/canvas/style-profile";
import { CanvasNodeToolbar, CanvasNodeInfoModal } from "@/components/canvas/canvas-node-toolbar";
import { CanvasSubtitleDialog } from "@/components/canvas/canvas-subtitle-dialog";
+import { CanvasVideoSegmentDialog } from "@/components/canvas/canvas-video-segment-dialog";
import { CanvasTimelineDialog } from "@/components/canvas/canvas-timeline-dialog";
import { syncNodeSubtitlesToTimeline } from "@/lib/timeline/timeline-build";
import type { TimelineDirectMedia } from "@/types/timeline";
@@ -583,16 +584,23 @@ function InfiniteCanvasPage() {
generatePortraitTextureNode,
cropImageNode,
cropNodeId,
+ closeSegmentDialog,
+ extractAudioFromVideo,
extractVideoLastFrame,
extractingVideoFrameNodeId,
generateAngleNode,
generateEmotionNode,
+ handleSegmentConfirm,
maskEditImageNode,
maskEditNodeId,
mergeSelectedVideos,
mergeVideosByIds,
mergeVideoProgress,
saveAnnotatedImageNode,
+ segmentDialogMode,
+ segmentDialogNodeId,
+ segmentRunningMode,
+ setSegmentDialogNodeId,
setAngleNodeId,
setEmotionNodeId,
setAnnotationNodeId,
@@ -602,10 +610,12 @@ function InfiniteCanvasPage() {
setUpscaleNodeId,
splitImageNode,
splitNodeId,
+ trimVideoAndRegenerate,
upscaleImageNode,
upscaleNodeId,
} = useCanvasMediaTools({
projectId,
+ domainProjectId: linkedProjectId,
nodesRef,
connectionsRef,
selectedNodeIdsRef,
@@ -622,6 +632,7 @@ function InfiniteCanvasPage() {
startGenerationRequest,
finishGenerationRequest,
bindGenerationTask,
+ onGenerateVideoNode: (nodeId, mode, prompt) => generateNodeRef.current?.(nodeId, mode, prompt),
});
const handleNodesDeleted = useCallback(
@@ -635,6 +646,7 @@ function InfiniteCanvasPage() {
setDrawingNodeId(clearDeletedId);
setInfoNodeId(clearDeletedId);
setSubtitleNodeId(clearDeletedId);
+ setSegmentDialogNodeId(clearDeletedId);
setCropNodeId(clearDeletedId);
setMaskEditNodeId(clearDeletedId);
setAnnotationNodeId(clearDeletedId);
@@ -656,7 +668,7 @@ function InfiniteCanvasPage() {
}
cleanupCanvasFiles({ projectId, nodes: nextNodes, chatSessions });
},
- [chatSessions, cleanupCanvasFiles, message, projectId, setAngleNodeId, setAnnotationNodeId, setCropNodeId, setEmotionNodeId, setMaskEditNodeId, setSplitNodeId, setUpscaleNodeId, setRunningNodeId],
+ [chatSessions, cleanupCanvasFiles, message, projectId, setAngleNodeId, setAnnotationNodeId, setCropNodeId, setEmotionNodeId, setMaskEditNodeId, setSegmentDialogNodeId, setSplitNodeId, setUpscaleNodeId, setRunningNodeId],
);
const {
@@ -875,6 +887,7 @@ function InfiniteCanvasPage() {
const dialogNode = dialogNodeId ? nodeById.get(dialogNodeId) || null : null;
const subtitleNode = subtitleNodeId ? nodeById.get(subtitleNodeId) || null : null;
const timelineNode = timelineNodeId ? nodeById.get(timelineNodeId) || null : null;
+ const segmentNode = segmentDialogNodeId ? nodeById.get(segmentDialogNodeId) || null : null;
const textEditorNode = textEditorNodeId ? nodeById.get(textEditorNodeId) || null : null;
const characterReferenceNode = characterReferenceNodeId ? nodeById.get(characterReferenceNodeId) || null : null;
const drawingNode = drawingNodeId ? nodeById.get(drawingNodeId) || null : null;
@@ -1840,9 +1853,13 @@ function InfiniteCanvasPage() {
}}
onViewImage={(node) => setPreviewNodeId(node.id)}
onExtractVideoLastFrame={(node) => void extractVideoLastFrame(node)}
+ onExtractAudioFromVideo={(node) => void extractAudioFromVideo(node)}
+ onTrimVideoRegenerate={(node) => void trimVideoAndRegenerate(node)}
onSubtitles={(node) => setSubtitleNodeId(node.id)}
onTimeline={(node) => setTimelineNodeId(node.id)}
extractingVideoFrame={toolbarNode?.id === extractingVideoFrameNodeId}
+ extractingAudio={segmentRunningMode === "audio"}
+ trimmingVideo={segmentRunningMode === "video"}
onReversePrompt={createImageReversePromptNodes}
onRetry={(node) => void handleRetryNode(node)}
onToggleFreeResize={(node) => toggleNodeFreeResize(node.id)}
@@ -1944,6 +1961,20 @@ function InfiniteCanvasPage() {
/>
) : null}
+ {segmentNode && segmentDialogMode ? (
+ void handleSegmentConfirm(segmentNode, params)}
+ />
+ ) : null}
+
{timelineNode ? (
undefined} onContentChange={unauthorized} onRetry={unauthorized} onCancelTask={unauthorized} onOpenTaskDetails={unauthorized} onViewImage={(target) => setInfoNodeId(target.id)} onContextMenu={(event, nodeId) => openContextMenu(event, nodeId)} />)}
- setInfoNodeId(node.id)} onEditText={unauthorized} onDecreaseFont={unauthorized} onIncreaseFont={unauthorized} onToggleDialog={unauthorized} onAnnotate={unauthorized} onGenerateImage={unauthorized} onUpload={unauthorized} onDownload={unauthorized} onSaveAsset={unauthorized} onMaskEdit={unauthorized} onEmotion={unauthorized} onPortraitTexture={unauthorized} onCrop={unauthorized} onSplit={unauthorized} onUpscale={unauthorized} onSuperResolve={unauthorized} onAngle={unauthorized} onViewImage={unauthorized} onExtractVideoLastFrame={unauthorized} extractingVideoFrame={false} onSubtitles={unauthorized} onTimeline={unauthorized} onReversePrompt={unauthorized} onRetry={unauthorized} onToggleFreeResize={unauthorized} onToggleLocked={unauthorized} onDelete={unauthorized} />
+ setInfoNodeId(node.id)} onEditText={unauthorized} onDecreaseFont={unauthorized} onIncreaseFont={unauthorized} onToggleDialog={unauthorized} onAnnotate={unauthorized} onGenerateImage={unauthorized} onUpload={unauthorized} onDownload={unauthorized} onSaveAsset={unauthorized} onMaskEdit={unauthorized} onEmotion={unauthorized} onPortraitTexture={unauthorized} onCrop={unauthorized} onSplit={unauthorized} onUpscale={unauthorized} onSuperResolve={unauthorized} onAngle={unauthorized} onViewImage={unauthorized} onExtractVideoLastFrame={unauthorized} onExtractAudioFromVideo={unauthorized} onTrimVideoRegenerate={unauthorized} extractingVideoFrame={false} extractingAudio={false} trimmingVideo={false} onSubtitles={unauthorized} onTimeline={unauthorized} onReversePrompt={unauthorized} onRetry={unauthorized} onToggleFreeResize={unauthorized} onToggleLocked={unauthorized} onDelete={unauthorized} />
访客操作仅在当前页面临时生效
diff --git a/web/src/pages/canvas/use-canvas-media-tools.ts b/web/src/pages/canvas/use-canvas-media-tools.ts
index 8057d3646..3e383197f 100644
--- a/web/src/pages/canvas/use-canvas-media-tools.ts
+++ b/web/src/pages/canvas/use-canvas-media-tools.ts
@@ -8,10 +8,12 @@ import type { CanvasImageSplitParams } from "@/components/canvas/canvas-node-spl
import type { CanvasImageUpscaleParams } from "@/components/canvas/canvas-node-upscale-dialog";
import type { CanvasImageAngleParams } from "@/components/canvas/canvas-node-angle-dialog";
import type { CanvasImageEmotionPayload } from "@/components/canvas/canvas-node-emotion-panel";
+import type { CanvasVideoSegmentParams } from "@/components/canvas/canvas-video-segment-dialog";
import { NODE_DEFAULT_SIZE } from "@/constant/canvas";
import { cropDataUrl, splitDataUrl, upscaleDataUrl } from "@/lib/canvas/canvas-image-data";
-import { imageMetadata, videoMetadata } from "@/lib/canvas/canvas-generation-task-sync";
+import { audioMetadata, imageMetadata, videoMetadata } from "@/lib/canvas/canvas-generation-task-sync";
import { buildAngleLabel, buildAnglePrompt, createCanvasNode } from "@/lib/canvas/canvas-project-domain";
+import { validateVideoSegmentBatch } from "@/lib/canvas/canvas-video-regeneration";
import { resolveCanvasStyleExecution } from "@/lib/canvas/canvas-style-execution";
import {
buildGenerationConfig,
@@ -25,11 +27,13 @@ import { compositeEmotionImage, emotionGenerationSize } from "@/lib/canvas/canva
import { DEFAULT_PORTRAIT_TEXTURE_SETTINGS, buildPortraitTexturePrompt } from "@/lib/canvas/canvas-portrait-texture";
import { captureVideoLastFrame } from "@/lib/canvas/canvas-video-frame";
import { mergeVideos, type MergeVideoProgress } from "@/lib/canvas/canvas-video-merge";
+import { extractVideoAudio, trimVideoSegment } from "@/lib/canvas/canvas-video-segment";
import { generationErrorMessage } from "@/lib/generation-error";
import { navigateToSettings } from "@/lib/settings-navigation";
import { storeGeneratedVideo } from "@/services/api/video";
-import { getMediaBlob } from "@/services/file-storage";
+import { getMediaBlob, uploadMediaFile } from "@/services/file-storage";
import { uploadImage } from "@/services/image-storage";
+import { ensureCanvasNodeAsset } from "@/services/project-asset-sync";
import type { GenerationTask } from "@/services/api/task-center";
import { defaultConfig, resolveModelRequestConfig, useConfigStore, useEffectiveConfig, type AiConfig } from "@/stores/use-config-store";
import { CanvasNodeType, type CanvasConnection, type CanvasNodeData, type ContextMenuState } from "@/types/canvas";
@@ -37,6 +41,7 @@ import type { StartCanvasUploadStatus } from "./use-canvas-upload";
type UseCanvasMediaToolsOptions = {
projectId: string;
+ domainProjectId?: string;
nodesRef: { current: CanvasNodeData[] };
connectionsRef: { current: CanvasConnection[] };
selectedNodeIdsRef: { current: Set };
@@ -53,6 +58,8 @@ type UseCanvasMediaToolsOptions = {
startGenerationRequest: (targetNodeId: string, originNodeId: string, runningId?: string, controller?: AbortController) => AbortController;
finishGenerationRequest: (targetNodeId: string, controller: AbortController) => void;
bindGenerationTask: (targetNodeId: string, task: GenerationTask) => void;
+ /** 创建空视频节点后触发既有生成执行器(由页面层接线),用于“截取片段后调用视频模型重生成” */
+ onGenerateVideoNode?: (nodeId: string, mode: "video", prompt: string) => Promise | void;
};
const NODE_STATUS_LOADING = "loading" as const;
@@ -67,6 +74,7 @@ const IMAGE_PROMPT_REVERSE_PRESET = `请根据参考图片反推一段适合用
export function useCanvasMediaTools({
projectId,
+ domainProjectId,
nodesRef,
connectionsRef,
selectedNodeIdsRef,
@@ -83,6 +91,7 @@ export function useCanvasMediaTools({
startGenerationRequest,
finishGenerationRequest,
bindGenerationTask,
+ onGenerateVideoNode,
}: UseCanvasMediaToolsOptions) {
const { message } = App.useApp();
const effectiveConfig = useEffectiveConfig();
@@ -98,6 +107,10 @@ export function useCanvasMediaTools({
const [emotionNodeId, setEmotionNodeId] = useState(null);
const [extractingVideoFrameNodeId, setExtractingVideoFrameNodeId] = useState(null);
const [mergeVideoProgress, setMergeVideoProgress] = useState(null);
+ const [segmentDialogNodeId, setSegmentDialogNodeId] = useState(null);
+ const [segmentDialogMode, setSegmentDialogMode] = useState<"audio" | "video" | null>(null);
+ const [segmentRunningMode, setSegmentRunningMode] = useState<"audio" | "video" | null>(null);
+ const segmentRunningRef = useRef(false);
const resolveImageEditStyle = useCallback((node: CanvasNodeData, prompt: string, config: AiConfig) => {
try {
@@ -259,6 +272,198 @@ export function useCanvasMediaTools({
}
}, [message, setConnections, setHoveredNodeId, setNodes, setSelectedConnectionId, setSelectedNodeIds, setToolbarNodeId, startUploadStatus]);
+ const extractAudioFromVideo = useCallback((node: CanvasNodeData) => {
+ if (!node.metadata?.content) {
+ message.warning("视频节点为空,无法提取声音");
+ return;
+ }
+ if (segmentRunningRef.current) return;
+ setHoveredNodeId(null);
+ setToolbarNodeId(null);
+ setSegmentDialogNodeId(node.id);
+ setSegmentDialogMode("audio");
+ }, [message, setHoveredNodeId, setToolbarNodeId]);
+
+ const trimVideoAndRegenerate = useCallback((node: CanvasNodeData) => {
+ if (!node.metadata?.content) {
+ message.warning("视频节点为空,无法截取片段");
+ return;
+ }
+ if (segmentRunningRef.current) return;
+ setHoveredNodeId(null);
+ setToolbarNodeId(null);
+ setSegmentDialogNodeId(node.id);
+ setSegmentDialogMode("video");
+ }, [message, setHoveredNodeId, setToolbarNodeId]);
+
+ const closeSegmentDialog = useCallback(() => {
+ if (segmentRunningRef.current) return;
+ setSegmentDialogNodeId(null);
+ setSegmentDialogMode(null);
+ }, []);
+
+ // 从视频片段提取声音:FFmpeg 提取 MP3 → 上传为音频资源 → 创建音频节点 → 写入素材库/项目资产。
+ const runExtractVideoAudio = useCallback(async (node: CanvasNodeData, params: CanvasVideoSegmentParams) => {
+ const progress = startUploadStatus("提取音频", "加载 FFmpeg", 4);
+ try {
+ const mp3 = await extractVideoAudio({ url: node.metadata?.content, storageKey: node.metadata?.storageKey }, { startMs: params.startMs, endMs: params.endMs }, node.metadata?.durationMs, (status) => {
+ progress.update(status.phase === "loading" ? "加载 FFmpeg" : status.phase === "reading" ? "读取视频资源" : "正在提取音频", status.phase === "encoding" ? 3 : 2);
+ });
+ progress.update("上传音频到服务器", 4);
+ const uploaded = await uploadMediaFile(mp3, "audio");
+ const spec = NODE_DEFAULT_SIZE[CanvasNodeType.Audio];
+ const audioNode = createCanvasNode(
+ CanvasNodeType.Audio,
+ { x: node.position.x + node.width + 96 + spec.width / 2, y: node.position.y + node.height / 2 },
+ { ...audioMetadata(uploaded), prompt: `从「${node.title || "视频"}」提取的声音`, status: NODE_STATUS_SUCCESS },
+ );
+ audioNode.title = `声音 · ${node.title || "视频"}`;
+ const audioNodeId = audioNode.id;
+ setNodes((current) => [...current, audioNode]);
+ setConnections((current) => [...current, { id: nanoid(), fromNodeId: node.id, toNodeId: audioNodeId }]);
+ setSelectedNodeIds(new Set([audioNodeId]));
+ setSelectedConnectionId(null);
+ try {
+ const result = await ensureCanvasNodeAsset({ canvasId: projectId, domainProjectId, node: audioNode, source: "canvas-manual" });
+ setNodes((current) => current.map((item) => (item.id === audioNodeId ? { ...item, metadata: { ...item.metadata, assetId: result.assetId } } : item)));
+ progress.done(result.linkedToProject ? "声音已提取并加入素材库与项目资产" : "声音已提取并加入素材库");
+ } catch (assetError) {
+ progress.done(`声音已提取并生成音频节点,素材库写入失败:${assetError instanceof Error ? assetError.message : "未知错误"}`);
+ }
+ } catch (error) {
+ const details = error instanceof Error ? error.message : "音频提取失败";
+ progress.fail(details);
+ message.error(details);
+ }
+ }, [domainProjectId, message, projectId, setConnections, setSelectedConnectionId, setSelectedNodeIds, setNodes, startUploadStatus]);
+
+ // 按段截取视频:FFmpeg 批量截取 → 每段创建片段节点与空结果节点 → 调用视频模型逐段重生成。
+ const runTrimVideoAndRegenerate = useCallback(async (node: CanvasNodeData, params: CanvasVideoSegmentParams) => {
+ const segments = params.segments || [];
+ if (!segments.length) {
+ message.warning("请至少添加一个截取片段");
+ return;
+ }
+ const generationConfig = buildGenerationConfig(effectiveConfig, node, "video");
+ const selectedConfig = { ...generationConfig, model: params.model || generationConfig.model };
+ const batchError = validateVideoSegmentBatch(selectedConfig, segments, params.operation);
+ if (batchError) {
+ message.warning(batchError);
+ return;
+ }
+ const progress = startUploadStatus("截取视频片段", "加载 FFmpeg", segments.length * 4);
+ try {
+ const prepared: Array<{ segmentNode: CanvasNodeData; targetNode: CanvasNodeData }> = [];
+ const failedSegments: string[] = [];
+ const spec = NODE_DEFAULT_SIZE[CanvasNodeType.Video];
+ const baseX = node.position.x + node.width + 96;
+ const baseY = node.position.y;
+ const effectivePrompt = (params.prompt || "保持画面主体与镜头,重新生成这一段视频").trim();
+ for (let index = 0; index < segments.length; index += 1) {
+ const segment = segments[index];
+ try {
+ const sourceNode = segment.sourceNodeId ? nodesRef.current.find((item) => item.id === segment.sourceNodeId) : undefined;
+ const trimSource = sourceNode
+ ? { url: sourceNode.metadata?.content, storageKey: sourceNode.metadata?.storageKey }
+ : segment.sourceStorageKey || segment.sourceUrl
+ ? { url: segment.sourceUrl, storageKey: segment.sourceStorageKey }
+ : { url: node.metadata?.content, storageKey: node.metadata?.storageKey };
+ const trimDurationMs = sourceNode?.metadata?.durationMs || node.metadata?.durationMs;
+ progress.update(`加载 FFmpeg(${index + 1}/${segments.length})`, index * 4 + 1);
+ const mp4 = await trimVideoSegment(trimSource, { startMs: segment.startMs, endMs: segment.endMs }, trimDurationMs, (status) => {
+ progress.update(status.phase === "loading" ? `加载 FFmpeg(${index + 1}/${segments.length})` : status.phase === "reading" ? `读取视频资源(${index + 1}/${segments.length})` : `正在截取片段(${index + 1}/${segments.length})`, status.phase === "encoding" ? index * 4 + 3 : index * 4 + 2);
+ });
+ progress.update(`上传片段到服务器(${index + 1}/${segments.length})`, index * 4 + 3);
+ const uploaded = await uploadMediaFile(mp4, "video");
+ const size = fitNodeSize(uploaded.width || 1280, uploaded.height || 720, VIDEO_NODE_MAX_SIZE.width, VIDEO_NODE_MAX_SIZE.height);
+ const segmentId = nanoid();
+ const segmentNode: CanvasNodeData = {
+ id: segmentId,
+ type: CanvasNodeType.Video,
+ title: `片段 ${index + 1} · ${sourceNode?.title || node.title || "视频"}`,
+ position: { x: baseX, y: baseY + index * (Math.max(size.height, spec.height) + 24) },
+ width: size.width,
+ height: size.height,
+ metadata: { ...videoMetadata(uploaded), prompt: `从「${sourceNode?.title || node.title || "视频"}」截取的片段 ${index + 1}`, status: NODE_STATUS_SUCCESS },
+ };
+ const targetId = nanoid();
+ const targetNode: CanvasNodeData = {
+ id: targetId,
+ type: CanvasNodeType.Video,
+ title: `重生成 ${index + 1} · ${sourceNode?.title || node.title || "视频"}`,
+ position: { x: segmentNode.position.x + size.width + 96, y: segmentNode.position.y + (size.height - spec.height) / 2 },
+ width: spec.width,
+ height: spec.height,
+ metadata: { prompt: effectivePrompt, status: "idle", generationMode: "video", model: selectedConfig.model, videoEditOperation: params.operation, seconds: generationConfig.videoSeconds, size: generationConfig.size },
+ };
+ prepared.push({ segmentNode, targetNode });
+ } catch (segmentError) {
+ failedSegments.push(segmentError instanceof Error ? segmentError.message : "视频截取失败");
+ }
+ }
+ if (!prepared.length) throw new Error(failedSegments[0] || "视频截取失败");
+ const segmentNodes = prepared.map((item) => item.segmentNode);
+ const targetNodes = prepared.map((item) => item.targetNode);
+ // 先同步 ref 再 setState,保证生成执行器能立即读到新节点与连接。
+ const nextNodes = [...nodesRef.current, ...segmentNodes, ...targetNodes];
+ const nextConnections = [
+ ...connectionsRef.current,
+ ...prepared.flatMap((item) => [
+ { id: nanoid(), fromNodeId: node.id, toNodeId: item.segmentNode.id },
+ { id: nanoid(), fromNodeId: item.segmentNode.id, toNodeId: item.targetNode.id },
+ ]),
+ ];
+ nodesRef.current = nextNodes;
+ connectionsRef.current = nextConnections;
+ setNodes(nextNodes);
+ setConnections(nextConnections);
+ setSelectedNodeIds(new Set(targetNodes.map((item) => item.id)));
+ setSelectedConnectionId(null);
+ progress.update("创建生成任务", segments.length * 4);
+ progress.done(`已截取 ${prepared.length}/${segments.length} 段,正在调用视频模型重新生成`);
+ segmentNodes.forEach((segmentNode) => {
+ void ensureCanvasNodeAsset({ canvasId: projectId, domainProjectId, node: segmentNode, source: "canvas-manual" })
+ .then((result) => setNodes((current) => current.map((item) => (item.id === segmentNode.id ? { ...item, metadata: { ...item.metadata, assetId: result.assetId } } : item))))
+ .catch((assetError) => message.warning(`片段已截取并开始重生成,但素材库写入失败:${assetError instanceof Error ? assetError.message : "未知错误"}`));
+ });
+ // 任务提交瞬时打满后端会触发限流;先错峰提交,后续可替换为批次调度的容量控制。
+ for (let index = 0; index < targetNodes.length; index += 1) {
+ const targetNode = targetNodes[index];
+ void onGenerateVideoNode?.(targetNode.id, "video", effectivePrompt);
+ if (index + 1 < targetNodes.length) await new Promise((resolve) => setTimeout(resolve, 300));
+ }
+ if (failedSegments.length) message.warning(`${failedSegments.length} 段截取失败,已为其余 ${prepared.length} 段创建生成任务`);
+ } catch (error) {
+ const details = error instanceof Error ? error.message : "视频截取失败";
+ progress.fail(details);
+ message.error(details);
+ }
+ }, [connectionsRef, domainProjectId, effectiveConfig, message, nodesRef, onGenerateVideoNode, projectId, setConnections, setSelectedConnectionId, setSelectedNodeIds, setNodes, startUploadStatus]);
+
+ const handleSegmentConfirm = useCallback(async (node: CanvasNodeData, params: CanvasVideoSegmentParams) => {
+ if (segmentRunningRef.current || !node.metadata?.content) return;
+ if (params.mode === "video") {
+ const generationConfig = buildGenerationConfig(effectiveConfig, node, "video");
+ const selectedConfig = { ...generationConfig, model: params.model || generationConfig.model };
+ const batchError = validateVideoSegmentBatch(selectedConfig, params.segments || [], params.operation);
+ if (batchError) {
+ message.warning(batchError);
+ return;
+ }
+ }
+ segmentRunningRef.current = true;
+ setSegmentRunningMode(params.mode);
+ setSegmentDialogNodeId(null);
+ setSegmentDialogMode(null);
+ try {
+ if (params.mode === "video") await runTrimVideoAndRegenerate(node, params);
+ else await runExtractVideoAudio(node, params);
+ } finally {
+ segmentRunningRef.current = false;
+ setSegmentRunningMode(null);
+ }
+ }, [effectiveConfig, message, runExtractVideoAudio, runTrimVideoAndRegenerate]);
+
const mergeVideosByIds = useCallback(async (videoNodeIds: string[]) => {
if (mergeVideoRunningRef.current) return;
const requestedIds = new Set(videoNodeIds);
@@ -505,8 +710,11 @@ export function useCanvasMediaTools({
generatePortraitTextureNode,
cropImageNode,
cropNodeId,
+ closeSegmentDialog,
+ extractAudioFromVideo,
extractVideoLastFrame,
extractingVideoFrameNodeId,
+ handleSegmentConfirm,
generateAngleNode,
maskEditImageNode,
maskEditNodeId,
@@ -514,6 +722,10 @@ export function useCanvasMediaTools({
mergeVideosByIds,
mergeVideoProgress,
saveAnnotatedImageNode,
+ segmentDialogMode,
+ segmentDialogNodeId,
+ segmentRunningMode,
+ setSegmentDialogNodeId,
setAngleNodeId,
generateEmotionNode,
setEmotionNodeId,
@@ -524,6 +736,7 @@ export function useCanvasMediaTools({
setUpscaleNodeId,
splitImageNode,
splitNodeId,
+ trimVideoAndRegenerate,
upscaleImageNode,
upscaleNodeId,
};
diff --git a/web/src/styles/globals.css b/web/src/styles/globals.css
index 5ea8093d9..cfab9ffbc 100644
--- a/web/src/styles/globals.css
+++ b/web/src/styles/globals.css
@@ -696,6 +696,7 @@
/* Canvas 布局(画布页浮层定位,对应审计 P0-8.1/P1-2.1)*/
--canvas-sidebar-width: 232px; /* 项目侧栏宽度(短剧模式)*/
--canvas-panel-width: min(332px, calc(100vw - 24px)); /* 浮动面板默认宽度(活动任务等)*/
+ --video-segment-preview-max-height: min(46vh, 480px);
/* Prompt 面板(对应 #98 决策2 渐进式三区)*/
--prompt-panel-max-height: min(70vh, 640px);
diff --git a/web/test/canvas-resource-references.test.ts b/web/test/canvas-resource-references.test.ts
new file mode 100644
index 000000000..7376fec54
--- /dev/null
+++ b/web/test/canvas-resource-references.test.ts
@@ -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);
+ });
+});
diff --git a/web/test/canvas-video-regeneration.test.ts b/web/test/canvas-video-regeneration.test.ts
new file mode 100644
index 000000000..fbbae345f
--- /dev/null
+++ b/web/test/canvas-video-regeneration.test.ts
@@ -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("不能超过当前模型参考视频上限");
+ });
+});
diff --git a/web/test/canvas-video-timeline-segments.test.ts b/web/test/canvas-video-timeline-segments.test.ts
new file mode 100644
index 000000000..bb7c11a4e
--- /dev/null
+++ b/web/test/canvas-video-timeline-segments.test.ts
@@ -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);
+ });
+});