Browse Source

feat(video-erase): 新增框选擦除画布级编辑宿主与多框选区 overlay

- MediaVideoEraseOverlay:视频上绘制多个矩形擦除框(新建/移动/缩放/删除),
  归一化输出 [0,1] 的 erase_ratio_location
- VideoBoxEraseSession + MediaVideoEraseToolbar:选区确认后派生 208 节点并生成
- ActiveVideoEditHost:仿 ActiveImageEditHost 的 flow 坐标单例宿主,挂载于画布
- nodeToolStore 新增 videoBoxErase 工具类型
- getNodeVideoSource 工具函数

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
feature/video-erase
Luckyu_js 2 weeks ago
parent
commit
c35affed62
  1. 2
      src/components/WorkflowCanvas.tsx
  2. 100
      src/components/canvas/ActiveVideoEditHost.tsx
  3. 338
      src/components/media/MediaVideoEraseOverlay.tsx
  4. 90
      src/components/media/MediaVideoEraseToolbar.tsx
  5. 101
      src/components/media/video-edit/VideoBoxEraseSession.tsx
  6. 3
      src/store/nodeToolStore.ts
  7. 29
      src/utils/getNodeVideoSource.ts

2
src/components/WorkflowCanvas.tsx

@ -41,6 +41,7 @@ import { NodeHeaderProvider } from "./nodes/NodeHeaderContext";
import { NodeHandleMenuProvider, type NodeHandleMenuRequest } from "./nodes/NodeHandleMenuContext";
import { ControlPanel } from "./nodes/ControlPanel";
import { ActiveImageEditHost } from "./canvas/ActiveImageEditHost";
import { ActiveVideoEditHost } from "./canvas/ActiveVideoEditHost";
import { logger } from "@/utils/logger";
import { uploadFileToGatewayMedia } from "@/utils/gatewayMediaUpload";
import { createSmartMediaNode, detectSmartMediaKind, readSmartImageDimensions, readSmartVideoMetadata } from "@/utils/smartMediaNode";
@ -3089,6 +3090,7 @@ export function WorkflowCanvas() {
/>
)}
<ActiveImageEditHost />
<ActiveVideoEditHost />
</ViewportPortal>
</ReactFlow>
</NodeDoubleClickZoomProvider>

100
src/components/canvas/ActiveVideoEditHost.tsx

@ -0,0 +1,100 @@
"use client";
import { useLayoutEffect, useMemo, useRef, type CSSProperties } from "react";
import { VideoBoxEraseSession } from "@/components/media/video-edit/VideoBoxEraseSession";
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"]);
function asVideoDimensions(value: unknown): { width: number; height: number } | null {
if (!value || typeof value !== "object") return null;
const dimensions = value as { width?: unknown; height?: unknown };
return typeof dimensions.width === "number" && typeof dimensions.height === "number"
? { width: dimensions.width, height: dimensions.height }
: null;
}
/**
* 画布级视频编辑宿主: ActiveImageEditHost ,
* overlay沿 flow (node.position + ),/ ViewportPortal
* transform ,overlay
*/
export function ActiveVideoEditHost() {
const activeNodeId = useNodeToolStore((state) => state.activeNodeId);
const activeTool = useNodeToolStore((state) => (state.visible ? state.activeTool : null));
const closeTool = useNodeToolStore((state) => state.closeTool);
const isVideoEditTool = Boolean(activeTool && VIDEO_EDIT_TOOLS.has(activeTool));
const targetNodeId = isVideoEditTool ? activeNodeId : null;
const node = useWorkflowStore((state) =>
targetNodeId ? state.nodes.find((candidate) => candidate.id === targetNodeId) ?? null : null
);
// 组内子节点坐标是相对父组的偏移,宿主在 ViewportPortal 里需沿 parentId 累加。
const parentOffsetX = useWorkflowStore((state) => {
let x = 0;
let parentId = node?.parentId ?? null;
while (parentId) {
const parent = state.nodes.find((candidate) => candidate.id === parentId);
if (!parent) break;
x += parent.position.x;
parentId = parent.parentId ?? null;
}
return x;
});
const parentOffsetY = useWorkflowStore((state) => {
let y = 0;
let parentId = node?.parentId ?? null;
while (parentId) {
const parent = state.nodes.find((candidate) => candidate.id === parentId);
if (!parent) break;
y += parent.position.y;
parentId = parent.parentId ?? null;
}
return y;
});
const videoModels = useModelStore((state) => state.byKind.video);
// 框选 overlay 用节点 dimensions letterbox 出显示框,无需真实 <video> 元素。
// 保留一个恒定为 null 的稳定 ref 供 session 类型契约使用。
const videoElRef = useRef<HTMLVideoElement | null>(null);
const videoSource = node ? getNodeVideoSource(node) : null;
const dimensions = useMemo(() => asVideoDimensions(node?.data?.dimensions), [node?.data?.dimensions]);
const styleDims = node ? getWorkflowNodeStyleDimensions(node) : null;
useLayoutEffect(() => {
if (targetNodeId && !node) closeTool();
}, [closeTool, node, targetNodeId]);
if (!isVideoEditTool || !node || !videoSource || !styleDims || !dimensions) {
return null;
}
const frameStyle: CSSProperties = {
position: "absolute",
transform: `translate(${node.position.x + parentOffsetX}px, ${node.position.y + parentOffsetY}px)`,
width: styleDims.width,
height: styleDims.height,
zIndex: 10020,
pointerEvents: "none",
};
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}
/>
</div>
</div>
);
}

338
src/components/media/MediaVideoEraseOverlay.tsx

@ -0,0 +1,338 @@
"use client";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import type { RefObject } from "react";
import type { VideoEraseRatioBox } from "@/utils/videoEraseNodes";
export interface VideoEraseFrameRect {
left: number;
top: number;
width: number;
height: number;
}
interface DisplayRect {
left: number;
top: number;
width: number;
height: number;
}
/** 单个擦除框,坐标存储在 overlay 显示坐标系(像素)。 */
interface EraseBox {
id: number;
x: number;
y: number;
width: number;
height: number;
}
type DragMode = "move" | "n" | "s" | "e" | "w" | "nw" | "ne" | "sw" | "se" | "create";
const MIN_BOX_SIZE = 24;
interface MediaVideoEraseOverlayProps {
mediaElementRef: RefObject<HTMLVideoElement | null>;
naturalWidth?: number;
naturalHeight?: number;
busy?: boolean;
clearSignal?: number;
deleteSelectedSignal?: number;
onBoxesChange?: (boxes: VideoEraseRatioBox[]) => void;
onFrameChange?: (rect: VideoEraseFrameRect | null) => void;
}
function clamp(value: number, min: number, max: number) {
return Math.min(max, Math.max(min, value));
}
function validDimensions(width: unknown, height: unknown): { width: number; height: number } | null {
return typeof width === "number" && typeof height === "number" && width > 0 && height > 0
? { width, height }
: null;
}
function readVideoNaturalDimensions(el: HTMLVideoElement | null): { width: number; height: number } | null {
if (!el) return null;
return validDimensions(el.videoWidth, el.videoHeight);
}
function fitBoxToBounds(box: EraseBox, bounds: DisplayRect): EraseBox {
const width = Math.min(Math.max(box.width, MIN_BOX_SIZE), bounds.width);
const height = Math.min(Math.max(box.height, MIN_BOX_SIZE), bounds.height);
return {
...box,
width,
height,
x: clamp(box.x, bounds.left, bounds.left + bounds.width - width),
y: clamp(box.y, bounds.top, bounds.top + bounds.height - height),
};
}
function resizeFree(box: EraseBox, bounds: DisplayRect, mode: DragMode, pointerX: number, pointerY: number): EraseBox {
let left = box.x;
let top = box.y;
let right = box.x + box.width;
let bottom = box.y + box.height;
if (mode.includes("w")) left = clamp(pointerX, bounds.left, right - MIN_BOX_SIZE);
if (mode.includes("e")) right = clamp(pointerX, left + MIN_BOX_SIZE, bounds.left + bounds.width);
if (mode.includes("n")) top = clamp(pointerY, bounds.top, bottom - MIN_BOX_SIZE);
if (mode.includes("s")) bottom = clamp(pointerY, top + MIN_BOX_SIZE, bounds.top + bounds.height);
return { ...box, x: left, y: top, width: right - left, height: bottom - top };
}
/**
* overlay:允许在视频显示区域内绘制多个矩形擦除框,
* [0,1] (erase_ratio_location)
*
* /沿 MediaCropOverlay: overlay letterbox
* (bounds), rect/clientWidth
*/
export function MediaVideoEraseOverlay({
mediaElementRef,
naturalWidth,
naturalHeight,
busy = false,
clearSignal = 0,
deleteSelectedSignal = 0,
onBoxesChange,
onFrameChange,
}: MediaVideoEraseOverlayProps) {
const overlayRef = useRef<HTMLDivElement>(null);
const idRef = useRef(0);
const dragRef = useRef<{
mode: DragMode;
boxId: number;
startX: number;
startY: number;
origin: EraseBox;
} | null>(null);
const [bounds, setBounds] = useState<DisplayRect | null>(null);
const [boxes, setBoxes] = useState<EraseBox[]>([]);
const [selectedId, setSelectedId] = useState<number | null>(null);
const [naturalDimensions, setNaturalDimensions] = useState<{ width: number; height: number } | null>(() =>
validDimensions(naturalWidth, naturalHeight)
);
useEffect(() => {
const explicit = validDimensions(naturalWidth, naturalHeight);
if (explicit) {
setNaturalDimensions(explicit);
return;
}
const el = mediaElementRef.current;
const update = () => {
const next = readVideoNaturalDimensions(el);
if (next) setNaturalDimensions(next);
};
update();
if (!el) return;
el.addEventListener("loadedmetadata", update);
return () => el.removeEventListener("loadedmetadata", update);
}, [mediaElementRef, naturalHeight, naturalWidth]);
const resolvedWidth = naturalDimensions?.width ?? 0;
const resolvedHeight = naturalDimensions?.height ?? 0;
const measureBounds = useCallback((): DisplayRect | null => {
const overlay = overlayRef.current;
if (!overlay || resolvedWidth <= 0 || resolvedHeight <= 0) return null;
const width = overlay.clientWidth;
const height = overlay.clientHeight;
if (width <= 0 || height <= 0) return null;
const scale = Math.min(width / resolvedWidth, height / resolvedHeight);
const displayWidth = resolvedWidth * scale;
const displayHeight = resolvedHeight * scale;
return {
left: (width - displayWidth) / 2,
top: (height - displayHeight) / 2,
width: displayWidth,
height: displayHeight,
};
}, [resolvedHeight, resolvedWidth]);
useEffect(() => {
const overlay = overlayRef.current;
if (!overlay) return;
const apply = () => {
const next = measureBounds();
if (next) setBounds(next);
};
apply();
const observer = new ResizeObserver(apply);
observer.observe(overlay);
return () => observer.disconnect();
}, [measureBounds]);
useEffect(() => {
if (clearSignal === 0) return;
setBoxes([]);
setSelectedId(null);
}, [clearSignal]);
useEffect(() => {
if (deleteSelectedSignal === 0) return;
setBoxes((current) => current.filter((box) => box.id !== selectedId));
setSelectedId(null);
}, [deleteSelectedSignal]); // eslint-disable-line react-hooks/exhaustive-deps
const pointerToOverlay = useCallback((event: React.PointerEvent) => {
const el = overlayRef.current;
const rect = el?.getBoundingClientRect();
if (!rect || !el) return { x: 0, y: 0 };
const scaleX = el.clientWidth > 0 ? rect.width / el.clientWidth : 1;
const scaleY = el.clientHeight > 0 ? rect.height / el.clientHeight : 1;
return { x: (event.clientX - rect.left) / scaleX, y: (event.clientY - rect.top) / scaleY };
}, []);
const handleCreatePointerDown = useCallback((event: React.PointerEvent) => {
if (busy || !bounds) return;
// 只处理空白区域的按下(在框上按下会被框自身的 handler 拦截并 stopPropagation)。
event.preventDefault();
const point = pointerToOverlay(event);
const startX = clamp(point.x, bounds.left, bounds.left + bounds.width);
const startY = clamp(point.y, bounds.top, bounds.top + bounds.height);
const id = ++idRef.current;
const newBox: EraseBox = { id, x: startX, y: startY, width: MIN_BOX_SIZE, height: MIN_BOX_SIZE };
setBoxes((current) => [...current, newBox]);
setSelectedId(id);
dragRef.current = { mode: "create", boxId: id, startX, startY, origin: newBox };
event.currentTarget.setPointerCapture(event.pointerId);
}, [bounds, busy, pointerToOverlay]);
const handleBoxPointerDown = useCallback((event: React.PointerEvent, boxId: number, mode: DragMode) => {
if (busy || !bounds) return;
event.preventDefault();
event.stopPropagation();
const origin = boxes.find((box) => box.id === boxId);
if (!origin) return;
const point = pointerToOverlay(event);
setSelectedId(boxId);
dragRef.current = { mode, boxId, startX: point.x, startY: point.y, origin };
event.currentTarget.setPointerCapture(event.pointerId);
}, [bounds, boxes, busy, pointerToOverlay]);
const handlePointerMove = useCallback((event: React.PointerEvent) => {
const drag = dragRef.current;
if (!drag || !bounds) return;
event.preventDefault();
const point = pointerToOverlay(event);
setBoxes((current) => current.map((box) => {
if (box.id !== drag.boxId) return box;
if (drag.mode === "create") {
const left = Math.min(drag.startX, point.x);
const top = Math.min(drag.startY, point.y);
const right = Math.max(drag.startX, point.x);
const bottom = Math.max(drag.startY, point.y);
return fitBoxToBounds({
...box,
x: left,
y: top,
width: Math.max(right - left, MIN_BOX_SIZE),
height: Math.max(bottom - top, MIN_BOX_SIZE),
}, bounds);
}
if (drag.mode === "move") {
return fitBoxToBounds({
...drag.origin,
x: drag.origin.x + point.x - drag.startX,
y: drag.origin.y + point.y - drag.startY,
}, bounds);
}
return fitBoxToBounds(resizeFree(drag.origin, bounds, drag.mode, point.x, point.y), bounds);
}));
}, [bounds, pointerToOverlay]);
const handlePointerUp = useCallback((event: React.PointerEvent) => {
if (!dragRef.current) return;
event.preventDefault();
dragRef.current = null;
}, []);
// 归一化擦除框 + 选中框的显示矩形回调。
useEffect(() => {
if (!bounds || resolvedWidth <= 0 || resolvedHeight <= 0) {
onBoxesChange?.([]);
onFrameChange?.(null);
return;
}
const ratioBoxes: VideoEraseRatioBox[] = boxes.map((box) => {
const topLeftX = (box.x - bounds.left) / bounds.width;
const topLeftY = (box.y - bounds.top) / bounds.height;
const bottomRightX = (box.x + box.width - bounds.left) / bounds.width;
const bottomRightY = (box.y + box.height - bounds.top) / bounds.height;
return {
top_left_x: clamp(topLeftX, 0, 1),
top_left_y: clamp(topLeftY, 0, 1),
bottom_right_x: clamp(bottomRightX, 0, 1),
bottom_right_y: clamp(bottomRightY, 0, 1),
};
});
onBoxesChange?.(ratioBoxes);
const selected = boxes.find((box) => box.id === selectedId) ?? boxes[boxes.length - 1] ?? null;
onFrameChange?.(selected ? { left: selected.x, top: selected.y, width: selected.width, height: selected.height } : bounds);
}, [boxes, bounds, onBoxesChange, onFrameChange, resolvedHeight, resolvedWidth, selectedId]);
const handleModes = useMemo(() => ["nw", "ne", "sw", "se"] as const, []);
return (
<div
ref={overlayRef}
className="nodrag nopan nowheel absolute inset-0 z-[10004] touch-none select-none"
onPointerDown={handleCreatePointerDown}
onPointerMove={handlePointerMove}
onPointerUp={handlePointerUp}
onPointerCancel={handlePointerUp}
data-media-video-erase-overlay
>
{bounds && (
<div
className="pointer-events-none absolute border border-[var(--border-strong)]/40"
style={{ left: bounds.left, top: bounds.top, width: bounds.width, height: bounds.height }}
/>
)}
{bounds && boxes.map((box) => {
const isSelected = box.id === selectedId;
return (
<div
key={box.id}
role="presentation"
className={[
"absolute cursor-move border-2",
isSelected ? "border-[var(--accent)] shadow-[0_0_0_1px_var(--accent-ring)]" : "border-[var(--accent)]/70",
].join(" ")}
style={{ left: box.x, top: box.y, width: box.width, height: box.height, background: "rgba(56,132,255,0.12)" }}
onPointerDown={(event) => handleBoxPointerDown(event, box.id, "move")}
>
{isSelected && handleModes.map((mode) => (
<button
key={mode}
type="button"
aria-label={`调整擦除框 ${mode}`}
className={[
"absolute h-6 w-6 bg-transparent",
mode.includes("n") ? "-top-3" : "-bottom-3",
mode.includes("w") ? "-left-3" : "-right-3",
mode === "nw" || mode === "se" ? "cursor-nwse-resize" : "cursor-nesw-resize",
].join(" ")}
onPointerDown={(event) => handleBoxPointerDown(event, box.id, mode)}
>
<span
className={[
"pointer-events-none absolute h-3 w-3 border-[var(--accent)]",
mode.includes("n") ? "top-3 border-t-2" : "bottom-3 border-b-2",
mode.includes("w") ? "left-3 border-l-2" : "right-3 border-r-2",
].join(" ")}
/>
</button>
))}
</div>
);
})}
</div>
);
}

90
src/components/media/MediaVideoEraseToolbar.tsx

@ -0,0 +1,90 @@
"use client";
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 type { VideoEraseFrameRect } from "@/components/media/MediaVideoEraseOverlay";
interface MediaVideoEraseToolbarProps {
frameRect: VideoEraseFrameRect | null;
boxCount: number;
canConfirm: boolean;
onDeleteSelected: () => void;
onClear: () => void;
onCancel: () => void;
onConfirm: () => void;
}
/**
* 框选擦除工具条:锚定在当前选中框()沿
* MediaInpaintingToolbar
*/
export function MediaVideoEraseToolbar({
frameRect,
boxCount,
canConfirm,
onDeleteSelected,
onClear,
onCancel,
onConfirm,
}: MediaVideoEraseToolbarProps) {
if (!frameRect) return null;
const centerX = frameRect.left + frameRect.width / 2;
const topToolbarStyle = {
left: centerX,
top: frameRect.top,
transform: "translate(-50%, calc(-100% - 10px))",
width: "max-content",
maxWidth: "calc(100vw - 32px)",
} as CSSProperties;
return (
<div
className="nodrag nopan nowheel absolute z-[10005] flex items-center gap-2 whitespace-nowrap rounded-xl border border-[var(--border-subtle)] bg-[var(--surface-2)] px-3 py-2 text-sm text-[var(--text-primary)] shadow-[var(--shadow-panel)]"
style={topToolbarStyle}
>
<button
type="button"
aria-label="关闭框选擦除"
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)]"
>
<CloseOutlined />
</button>
<span className="shrink-0 text-sm font-medium text-[var(--text-primary)]"></span>
<span className="shrink-0 text-xs text-[var(--text-muted)]">{boxCount} </span>
<div className="mx-1 h-6 w-px shrink-0 bg-[var(--border-subtle)]" />
<Tooltip title="删除选中框">
<Button
type="text"
icon={<DeleteOutlined />}
disabled={boxCount === 0}
onClick={onDeleteSelected}
className="!h-9 !w-9 !shrink-0 !rounded-md"
/>
</Tooltip>
<Tooltip title="清空全部">
<Button
type="text"
icon={<ClearOutlined />}
disabled={boxCount === 0}
onClick={onClear}
className="!h-9 !w-9 !shrink-0 !rounded-md"
/>
</Tooltip>
<div className="mx-1 h-6 w-px shrink-0 bg-[var(--border-subtle)]" />
<button
type="button"
aria-label="生成框选擦除"
disabled={!canConfirm}
onClick={onConfirm}
className="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-not-allowed disabled:bg-[var(--surface-active)] disabled:text-[var(--text-disabled)]"
>
<GenerateActionIcon />
<span></span>
</button>
</div>
);
}

101
src/components/media/video-edit/VideoBoxEraseSession.tsx

@ -0,0 +1,101 @@
"use client";
import { useCallback, useRef, useState } from "react";
import type { RefObject } from "react";
import type { ProviderModel } from "@/lib/providers/types";
import { useWorkflowStore } from "@/store/workflowStore";
import {
MediaVideoEraseOverlay,
type VideoEraseFrameRect,
} from "@/components/media/MediaVideoEraseOverlay";
import { MediaVideoEraseToolbar } from "@/components/media/MediaVideoEraseToolbar";
import {
chooseVideoBoxEraseModel,
createVideoBoxEraseNode,
type VideoEraseRatioBox,
} from "@/utils/videoEraseNodes";
import { defaultNodeDimensions } from "@/constants/nodeDimensions";
interface VideoBoxEraseSessionProps {
nodeId: string;
videoRef: RefObject<HTMLVideoElement | null>;
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;
}
/**
* 框选擦除编辑会话:在视频上绘制多个擦除框, 208
* overlay [0,1] erase_ratio_location
*/
export function VideoBoxEraseSession({
nodeId,
videoRef,
dimensions,
models,
onClose,
}: VideoBoxEraseSessionProps) {
const [ratioBoxes, setRatioBoxes] = useState<VideoEraseRatioBox[]>([]);
const [frameRect, setFrameRect] = useState<VideoEraseFrameRect | null>(null);
const [clearSignal, setClearSignal] = useState(0);
const [deleteSelectedSignal, setDeleteSelectedSignal] = useState(0);
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;
if (ratioBoxes.length === 0) return;
const sourceNode = getNodeById(nodeId);
if (!sourceNode) return;
busyRef.current = true;
const currentPosition = sourceNode.position ?? { x: 0, y: 0 };
createVideoBoxEraseNode({
sourceNodeId: nodeId,
position: {
x: currentPosition.x + getNodeWidth(sourceNode) + 220,
y: currentPosition.y,
},
dimensions: dimensions ?? null,
ratioBoxes,
selectedModel: chooseVideoBoxEraseModel(models),
addNode,
onConnect,
selectSingleNode,
regenerateNode,
});
onClose();
}, [addNode, dimensions, getNodeById, models, nodeId, onConnect, onClose, ratioBoxes, regenerateNode, selectSingleNode]);
return (
<>
<MediaVideoEraseOverlay
mediaElementRef={videoRef}
naturalWidth={dimensions?.width}
naturalHeight={dimensions?.height}
clearSignal={clearSignal}
deleteSelectedSignal={deleteSelectedSignal}
onBoxesChange={setRatioBoxes}
onFrameChange={setFrameRect}
/>
<MediaVideoEraseToolbar
frameRect={frameRect}
boxCount={ratioBoxes.length}
canConfirm={ratioBoxes.length > 0}
onDeleteSelected={() => setDeleteSelectedSignal((value) => value + 1)}
onClear={() => setClearSignal((value) => value + 1)}
onCancel={onClose}
onConfirm={handleConfirm}
/>
</>
);
}

3
src/store/nodeToolStore.ts

@ -9,7 +9,8 @@ export type NodeToolType =
| "splitGrid"
| "outpainting"
| "inpainting"
| "annotate";
| "annotate"
| "videoBoxErase";
interface NodeToolState {
activeNodeId: string | null;

29
src/utils/getNodeVideoSource.ts

@ -0,0 +1,29 @@
import type {
GenerateVideoNodeData,
SmartVideoNodeData,
VideoInputNodeData,
WorkflowNode,
} from "@/types";
/**
* /(data URL URL)
* 宿使, connectedInputs
*/
export function getNodeVideoSource(node: WorkflowNode | null): string | null {
if (!node) return null;
if (node.type === "videoInput") {
return (node.data as VideoInputNodeData).video || null;
}
if (node.type === "generateVideo") {
return (node.data as GenerateVideoNodeData).outputVideo || null;
}
if (node.type === "smartVideo") {
const data = node.data as SmartVideoNodeData;
return data.outputVideo || data.video || null;
}
return null;
}
Loading…
Cancel
Save