Browse Source

fix(video-erase): 统一擦除为编辑态交互,进入时隐藏节点操作条

- 智能擦除改为进入编辑态(videoSmartErase):覆盖'AI一键去字幕'提示工具条,
  点击生成才派生 207 节点,不再点击即建
- 视频节点读取 activeNodeTool,进入任一擦除态时隐藏 NodeActionCapsule,
  与图片节点裁剪/擦除的互斥一致
- ActiveVideoEditHost 分发智能/框选两种擦除会话
- 新增 MediaVideoSmartEraseToolbar

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
feature/video-erase
Luckyu_js 2 weeks ago
parent
commit
a4a6856e33
  1. 26
      src/components/canvas/ActiveVideoEditHost.tsx
  2. 44
      src/components/media/MediaVideoEraseToolbar.tsx
  3. 60
      src/components/media/video-edit/VideoSmartEraseSession.tsx
  4. 33
      src/components/nodes/GenerateVideoNode.tsx
  5. 32
      src/components/nodes/VideoInputNode.tsx
  6. 1
      src/store/nodeToolStore.ts

26
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<string> = new Set(["videoBoxErase"]);
const VIDEO_EDIT_TOOLS: ReadonlySet<string> = 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 (
<div data-active-video-edit-host className="nodrag nopan nowheel" style={frameStyle}>
<div className="pointer-events-auto absolute inset-0">
<VideoBoxEraseSession
nodeId={node.id}
videoRef={videoElRef}
dimensions={dimensions}
models={videoModels}
onClose={closeTool}
/>
{activeTool === "videoBoxErase" ? (
<VideoBoxEraseSession
nodeId={node.id}
videoRef={videoElRef}
dimensions={dimensions}
models={videoModels}
onClose={closeTool}
/>
) : (
<VideoSmartEraseSession
nodeId={node.id}
dimensions={dimensions}
models={videoModels}
onClose={closeTool}
/>
)}
</div>
</div>
);

44
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({
</div>
);
}
interface MediaVideoSmartEraseToolbarProps {
busy?: boolean;
onCancel: () => void;
onConfirm: () => void;
}
/**
* 智能擦除工具条:锚定在媒体框下沿( NodeActionCapsuleShell),
* "AI一键去除视频字幕", 207
*/
export function MediaVideoSmartEraseToolbar({ busy = false, onCancel, onConfirm }: MediaVideoSmartEraseToolbarProps) {
return (
<NodeActionCapsuleShell
topClassName="top-[calc(100%+12px)] z-[10005]"
className="nowheel min-w-[260px] justify-between gap-3 whitespace-nowrap px-3 py-2 text-sm text-[var(--text-primary)]"
>
<button
type="button"
aria-label="关闭智能擦除"
disabled={busy}
onClick={onCancel}
className="flex h-9 w-9 shrink-0 items-center justify-center rounded-md text-base text-[var(--text-secondary)] hover:bg-[var(--surface-hover)] hover:text-[var(--text-primary)] disabled:opacity-70"
>
<CloseOutlined />
</button>
<div className="flex min-w-0 flex-col leading-tight">
<span className="text-sm font-semibold text-[var(--text-primary)]"></span>
<span className="text-xs text-[var(--text-muted)]">AI ,</span>
</div>
<button
type="button"
aria-label="生成智能擦除"
disabled={busy}
onClick={onConfirm}
className="ml-auto flex h-9 shrink-0 items-center gap-2 rounded-lg bg-[var(--text-primary)] px-4 text-sm font-semibold text-[var(--text-inverse)] transition-colors hover:bg-[var(--text-secondary)] disabled:cursor-wait disabled:bg-[var(--surface-active)] disabled:text-[var(--text-disabled)]"
>
<GenerateActionIcon />
<span></span>
</button>
</NodeActionCapsuleShell>
);
}

60
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 <MediaVideoSmartEraseToolbar onCancel={onClose} onConfirm={handleConfirm} />;
}

33
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[] = [
{

32
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
? [
{

1
src/store/nodeToolStore.ts

@ -10,6 +10,7 @@ export type NodeToolType =
| "outpainting"
| "inpainting"
| "annotate"
| "videoSmartErase"
| "videoBoxErase";
interface NodeToolState {

Loading…
Cancel
Save