From a4a6856e3366cee484daea1140b0aaf9a9d6c234 Mon Sep 17 00:00:00 2001 From: Luckyu_js <11670186+luckyu-js@user.noreply.gitee.com> Date: Wed, 15 Jul 2026 12:07:27 +0800 Subject: [PATCH] =?UTF-8?q?fix(video-erase):=20=E7=BB=9F=E4=B8=80=E6=93=A6?= =?UTF-8?q?=E9=99=A4=E4=B8=BA=E7=BC=96=E8=BE=91=E6=80=81=E4=BA=A4=E4=BA=92?= =?UTF-8?q?,=E8=BF=9B=E5=85=A5=E6=97=B6=E9=9A=90=E8=97=8F=E8=8A=82?= =?UTF-8?q?=E7=82=B9=E6=93=8D=E4=BD=9C=E6=9D=A1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 智能擦除改为进入编辑态(videoSmartErase):覆盖'AI一键去字幕'提示工具条, 点击生成才派生 207 节点,不再点击即建 - 视频节点读取 activeNodeTool,进入任一擦除态时隐藏 NodeActionCapsule, 与图片节点裁剪/擦除的互斥一致 - ActiveVideoEditHost 分发智能/框选两种擦除会话 - 新增 MediaVideoSmartEraseToolbar Co-Authored-By: Claude Opus 4.8 (1M context) --- src/components/canvas/ActiveVideoEditHost.tsx | 26 +++++--- .../media/MediaVideoEraseToolbar.tsx | 44 ++++++++++++++ .../video-edit/VideoSmartEraseSession.tsx | 60 +++++++++++++++++++ src/components/nodes/GenerateVideoNode.tsx | 33 +++------- src/components/nodes/VideoInputNode.tsx | 32 ++-------- src/store/nodeToolStore.ts | 1 + 6 files changed, 136 insertions(+), 60 deletions(-) create mode 100644 src/components/media/video-edit/VideoSmartEraseSession.tsx diff --git a/src/components/canvas/ActiveVideoEditHost.tsx b/src/components/canvas/ActiveVideoEditHost.tsx index f0495b40..58913ce0 100644 --- a/src/components/canvas/ActiveVideoEditHost.tsx +++ b/src/components/canvas/ActiveVideoEditHost.tsx @@ -2,13 +2,14 @@ import { useLayoutEffect, useMemo, useRef, type CSSProperties } from "react"; import { VideoBoxEraseSession } from "@/components/media/video-edit/VideoBoxEraseSession"; +import { VideoSmartEraseSession } from "@/components/media/video-edit/VideoSmartEraseSession"; import { useNodeToolStore } from "@/store/nodeToolStore"; import { useWorkflowStore } from "@/store/workflowStore"; import { useModelStore } from "@/store/modelStore"; import { getNodeVideoSource } from "@/utils/getNodeVideoSource"; import { getWorkflowNodeStyleDimensions } from "@/utils/nodeDimensions"; -const VIDEO_EDIT_TOOLS: ReadonlySet = new Set(["videoBoxErase"]); +const VIDEO_EDIT_TOOLS: ReadonlySet = new Set(["videoSmartErase", "videoBoxErase"]); function asVideoDimensions(value: unknown): { width: number; height: number } | null { if (!value || typeof value !== "object") return null; @@ -87,13 +88,22 @@ export function ActiveVideoEditHost() { return (
- + {activeTool === "videoBoxErase" ? ( + + ) : ( + + )}
); diff --git a/src/components/media/MediaVideoEraseToolbar.tsx b/src/components/media/MediaVideoEraseToolbar.tsx index ad32bef7..cd757c5c 100644 --- a/src/components/media/MediaVideoEraseToolbar.tsx +++ b/src/components/media/MediaVideoEraseToolbar.tsx @@ -4,6 +4,7 @@ import type { CSSProperties } from "react"; import { Button, Tooltip } from "antd"; import { ClearOutlined, CloseOutlined, DeleteOutlined } from "@ant-design/icons"; import { GenerateActionIcon } from "@/components/media/MediaToolbarIcons"; +import { NodeActionCapsuleShell } from "@/components/nodes/NodeActionCapsule"; import type { VideoEraseFrameRect } from "@/components/media/MediaVideoEraseOverlay"; interface MediaVideoEraseToolbarProps { @@ -88,3 +89,46 @@ export function MediaVideoEraseToolbar({ ); } + +interface MediaVideoSmartEraseToolbarProps { + busy?: boolean; + onCancel: () => void; + onConfirm: () => void; +} + +/** + * 智能擦除工具条:锚定在媒体框下沿(与裁剪工具条一致的 NodeActionCapsuleShell), + * 提示"AI一键去除视频字幕",点击生成派生 207 节点。 + */ +export function MediaVideoSmartEraseToolbar({ busy = false, onCancel, onConfirm }: MediaVideoSmartEraseToolbarProps) { + return ( + + +
+ 智能擦除 + AI 一键去除视频字幕,仅支持中英文字幕 +
+ +
+ ); +} diff --git a/src/components/media/video-edit/VideoSmartEraseSession.tsx b/src/components/media/video-edit/VideoSmartEraseSession.tsx new file mode 100644 index 00000000..2a2f27c5 --- /dev/null +++ b/src/components/media/video-edit/VideoSmartEraseSession.tsx @@ -0,0 +1,60 @@ +"use client"; + +import { useCallback, useRef } from "react"; +import type { ProviderModel } from "@/lib/providers/types"; +import { useWorkflowStore } from "@/store/workflowStore"; +import { MediaVideoSmartEraseToolbar } from "@/components/media/MediaVideoEraseToolbar"; +import { + chooseVideoSmartEraseModel, + createVideoSmartEraseNode, +} from "@/utils/videoEraseNodes"; +import { defaultNodeDimensions } from "@/constants/nodeDimensions"; + +interface VideoSmartEraseSessionProps { + nodeId: string; + dimensions?: { width: number; height: number } | null; + models: ProviderModel[]; + onClose: () => void; +} + +function getNodeWidth(node: { style?: { width?: unknown } | undefined }) { + if (typeof node.style?.width === "number") return node.style.width; + return defaultNodeDimensions.videoInput.width; +} + +/** + * 智能擦除编辑会话:进入编辑态后覆盖一层"AI一键去字幕"提示,点击生成才派生 + * 207 智能擦除节点并立即运行。与图片编辑一致 —— 不隐藏视频、无选区、只有生成。 + */ +export function VideoSmartEraseSession({ nodeId, dimensions, models, onClose }: VideoSmartEraseSessionProps) { + const busyRef = useRef(false); + const addNode = useWorkflowStore((state) => state.addNode); + const onConnect = useWorkflowStore((state) => state.onConnect); + const getNodeById = useWorkflowStore((state) => state.getNodeById); + const selectSingleNode = useWorkflowStore((state) => state.selectSingleNode); + const regenerateNode = useWorkflowStore((state) => state.regenerateNode); + + const handleConfirm = useCallback(() => { + if (busyRef.current) return; + const sourceNode = getNodeById(nodeId); + if (!sourceNode) return; + busyRef.current = true; + const currentPosition = sourceNode.position ?? { x: 0, y: 0 }; + createVideoSmartEraseNode({ + sourceNodeId: nodeId, + position: { + x: currentPosition.x + getNodeWidth(sourceNode) + 220, + y: currentPosition.y, + }, + dimensions: dimensions ?? null, + selectedModel: chooseVideoSmartEraseModel(models), + addNode, + onConnect, + selectSingleNode, + regenerateNode, + }); + onClose(); + }, [addNode, dimensions, getNodeById, models, nodeId, onConnect, onClose, regenerateNode, selectSingleNode]); + + return ; +} diff --git a/src/components/nodes/GenerateVideoNode.tsx b/src/components/nodes/GenerateVideoNode.tsx index c595a477..b50a9d66 100644 --- a/src/components/nodes/GenerateVideoNode.tsx +++ b/src/components/nodes/GenerateVideoNode.tsx @@ -34,12 +34,8 @@ import { asMediaDimensions, getAutoMediaElementClassName, getAutoMediaFrameClass import { CustomVideoPlayer } from "@/components/media/CustomVideoPlayer"; import { createVideoFrameSmartImageNode } from "@/utils/videoFrameNode"; import { chooseHighDefinitionVideoModel, createHighDefinitionVideoNode } from "@/utils/highDefinitionVideoNodes"; -import { - chooseVideoSmartEraseModel, - createVideoSmartEraseNode, - isVideoEraseDerivedNode, -} from "@/utils/videoEraseNodes"; -import { useNodeToolStore } from "@/store/nodeToolStore"; +import { isVideoEraseDerivedNode } from "@/utils/videoEraseNodes"; +import { getActiveNodeTool, useNodeToolStore } from "@/store/nodeToolStore"; import { createMediaEditActionDropdown } from "@/components/media/MediaEditActionDropdown"; import { BoxEraseMenuIcon, SmartEraseMenuIcon, VideoEraseMenuIcon } from "@/components/media/MediaToolbarIcons"; @@ -379,25 +375,9 @@ export function GenerateVideoNodeView({ id, data, selected, suppressEmptyStateDe const switchNodeTool = useNodeToolStore((state) => state.switchTool); const handleSmartErase = useCallback(() => { - const currentNode = getNodeById(id); - const videoSource = nodeData.outputVideo; - if (!currentNode || !videoSource) return; - const currentPosition = currentNode.position ?? { x: 0, y: 0 }; - const currentWidth = typeof currentNode.style?.width === "number" ? currentNode.style.width : 300; - createVideoSmartEraseNode({ - sourceNodeId: id, - position: { - x: currentPosition.x + currentWidth + 220, - y: currentPosition.y, - }, - dimensions: asStrictMediaDimensions(nodeData.dimensions), - selectedModel: chooseVideoSmartEraseModel(popiserverVideoModels), - addNode, - onConnect, - selectSingleNode, - regenerateNode, - }); - }, [addNode, getNodeById, id, nodeData.dimensions, nodeData.outputVideo, onConnect, popiserverVideoModels, regenerateNode, selectSingleNode]); + if (!nodeData.outputVideo) return; + switchNodeTool(id, "videoSmartErase"); + }, [id, nodeData.outputVideo, switchNodeTool]); const handleBoxErase = useCallback(() => { if (!nodeData.outputVideo) return; @@ -416,7 +396,8 @@ export function GenerateVideoNodeView({ id, data, selected, suppressEmptyStateDe }, [nodeData.status, displayError, t]); const selectedNodeCount = useSelectedNodeCount(); - const showSelectedActions = Boolean(selected && selectedNodeCount === 1 && nodeData.outputVideo); + const activeNodeTool = useNodeToolStore((state) => getActiveNodeTool(state, id)); + const showSelectedActions = Boolean(selected && selectedNodeCount === 1 && nodeData.outputVideo && !activeNodeTool); const showEmptyStateDelete = Boolean(selected && selectedNodeCount === 1 && !nodeData.outputVideo && !suppressEmptyStateDelete); const emptyStateActions: NodeActionCapsuleAction[] = [ { diff --git a/src/components/nodes/VideoInputNode.tsx b/src/components/nodes/VideoInputNode.tsx index 3e8eb4e6..d98daf34 100644 --- a/src/components/nodes/VideoInputNode.tsx +++ b/src/components/nodes/VideoInputNode.tsx @@ -24,11 +24,7 @@ import { CustomVideoPlayer } from "@/components/media/CustomVideoPlayer"; import { createVideoFrameSmartImageNode } from "@/utils/videoFrameNode"; import { useModelStore } from "@/store/modelStore"; import { chooseHighDefinitionVideoModel, createHighDefinitionVideoNode } from "@/utils/highDefinitionVideoNodes"; -import { - chooseVideoSmartEraseModel, - createVideoSmartEraseNode, -} from "@/utils/videoEraseNodes"; -import { useNodeToolStore } from "@/store/nodeToolStore"; +import { getActiveNodeTool, useNodeToolStore } from "@/store/nodeToolStore"; import { createMediaEditActionDropdown } from "@/components/media/MediaEditActionDropdown"; import { BoxEraseMenuIcon, SmartEraseMenuIcon, VideoEraseMenuIcon } from "@/components/media/MediaToolbarIcons"; @@ -296,26 +292,9 @@ export function VideoInputNodeView({ id, data, selected }: VideoInputNodeViewPro const switchNodeTool = useNodeToolStore((state) => state.switchTool); const handleSmartErase = useCallback(() => { - const currentNode = getNodeById(id); - if (!currentNode || !nodeData.video) return; - const currentPosition = currentNode.position ?? { x: 0, y: 0 }; - const currentWidth = typeof currentNode.style?.width === "number" - ? currentNode.style.width - : VIDEO_INPUT_DIMENSIONS.width; - createVideoSmartEraseNode({ - sourceNodeId: id, - position: { - x: currentPosition.x + currentWidth + 220, - y: currentPosition.y, - }, - dimensions: nodeData.dimensions, - selectedModel: chooseVideoSmartEraseModel(popiserverVideoModels), - addNode, - onConnect, - selectSingleNode, - regenerateNode, - }); - }, [addNode, getNodeById, id, nodeData.dimensions, nodeData.video, onConnect, popiserverVideoModels, regenerateNode, selectSingleNode]); + if (!nodeData.video) return; + switchNodeTool(id, "videoSmartErase"); + }, [id, nodeData.video, switchNodeTool]); const handleBoxErase = useCallback(() => { if (!nodeData.video) return; @@ -323,7 +302,8 @@ export function VideoInputNodeView({ id, data, selected }: VideoInputNodeViewPro }, [id, nodeData.video, switchNodeTool]); const selectedNodeCount = useSelectedNodeCount(); - const showSelectedActions = Boolean(selected && selectedNodeCount === 1); + const activeNodeTool = useNodeToolStore((state) => getActiveNodeTool(state, id)); + const showSelectedActions = Boolean(selected && selectedNodeCount === 1 && !activeNodeTool); const selectedActions: NodeActionCapsuleAction[] = hasVideoAsset ? [ { diff --git a/src/store/nodeToolStore.ts b/src/store/nodeToolStore.ts index ecddc533..c521381e 100644 --- a/src/store/nodeToolStore.ts +++ b/src/store/nodeToolStore.ts @@ -10,6 +10,7 @@ export type NodeToolType = | "outpainting" | "inpainting" | "annotate" + | "videoSmartErase" | "videoBoxErase"; interface NodeToolState {