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