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} 个素材 + 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} 段 +
+
+ + +
+
+ + {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} 终点(秒)`} + /> +
+
+
+ ))} +
+ ) : ( +
+ 还没有片段:手动添加起点/终点,或从时间线导入该节点的已有片段。 +
+ )} + +
+ +