Browse Source
- 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
7 changed files with 662 additions and 1 deletions
@ -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> |
|||
); |
|||
} |
|||
@ -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> |
|||
); |
|||
} |
|||
@ -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> |
|||
); |
|||
} |
|||
@ -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} |
|||
/> |
|||
</> |
|||
); |
|||
} |
|||
@ -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…
Reference in new issue