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 {