|
|
|
@ -1,16 +1,8 @@ |
|
|
|
"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; |
|
|
|
} |
|
|
|
|
|
|
|
export interface VideoEraseHistoryState { |
|
|
|
canUndo: boolean; |
|
|
|
canRedo: boolean; |
|
|
|
@ -42,15 +34,12 @@ type DragMode = "move" | "n" | "s" | "e" | "w" | "nw" | "ne" | "sw" | "se" | "cr |
|
|
|
const MIN_BOX_SIZE = 24; |
|
|
|
|
|
|
|
interface MediaVideoEraseOverlayProps { |
|
|
|
mediaElementRef: RefObject<HTMLVideoElement | null>; |
|
|
|
naturalWidth?: number; |
|
|
|
naturalHeight?: number; |
|
|
|
busy?: boolean; |
|
|
|
undoSignal?: number; |
|
|
|
redoSignal?: number; |
|
|
|
resetSignal?: number; |
|
|
|
onBoxesChange?: (boxes: VideoEraseRatioBox[]) => void; |
|
|
|
onFrameChange?: (rect: VideoEraseFrameRect | null) => void; |
|
|
|
onHistoryStateChange?: (state: VideoEraseHistoryState) => void; |
|
|
|
} |
|
|
|
|
|
|
|
@ -64,11 +53,6 @@ function validDimensions(width: unknown, height: unknown): { width: number; heig |
|
|
|
: null; |
|
|
|
} |
|
|
|
|
|
|
|
function readVideoNaturalDimensions(el: HTMLVideoElement | null): { width: number; height: number } | null { |
|
|
|
if (!el) return null; |
|
|
|
return validDimensions(el.videoWidth, el.videoHeight); |
|
|
|
} |
|
|
|
|
|
|
|
function cloneBoxes(boxes: EraseBox[]): EraseBox[] { |
|
|
|
return boxes.map((box) => ({ ...box })); |
|
|
|
} |
|
|
|
@ -119,15 +103,12 @@ function resizeFree(box: EraseBox, bounds: DisplayRect, mode: DragMode, pointerX |
|
|
|
* 出视频显示框(bounds),指针位移按 rect/clientWidth 还原到布局坐标系。 |
|
|
|
*/ |
|
|
|
export function MediaVideoEraseOverlay({ |
|
|
|
mediaElementRef, |
|
|
|
naturalWidth, |
|
|
|
naturalHeight, |
|
|
|
busy = false, |
|
|
|
undoSignal = 0, |
|
|
|
redoSignal = 0, |
|
|
|
resetSignal = 0, |
|
|
|
onBoxesChange, |
|
|
|
onFrameChange, |
|
|
|
onHistoryStateChange, |
|
|
|
}: MediaVideoEraseOverlayProps) { |
|
|
|
const overlayRef = useRef<HTMLDivElement>(null); |
|
|
|
@ -147,8 +128,9 @@ export function MediaVideoEraseOverlay({ |
|
|
|
const [boxes, setBoxes] = useState<EraseBox[]>([]); |
|
|
|
const [selectedId, setSelectedId] = useState<number | null>(null); |
|
|
|
const [historyState, setHistoryState] = useState<VideoEraseHistoryState>({ canUndo: false, canRedo: false }); |
|
|
|
const [naturalDimensions, setNaturalDimensions] = useState<{ width: number; height: number } | null>(() => |
|
|
|
validDimensions(naturalWidth, naturalHeight) |
|
|
|
const naturalDimensions = useMemo( |
|
|
|
() => validDimensions(naturalWidth, naturalHeight), |
|
|
|
[naturalWidth, naturalHeight] |
|
|
|
); |
|
|
|
|
|
|
|
const updateHistoryState = useCallback(() => { |
|
|
|
@ -193,23 +175,6 @@ export function MediaVideoEraseOverlay({ |
|
|
|
setSelectedId(next); |
|
|
|
}, []); |
|
|
|
|
|
|
|
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; |
|
|
|
|
|
|
|
@ -282,7 +247,7 @@ export function MediaVideoEraseOverlay({ |
|
|
|
}, []); |
|
|
|
|
|
|
|
const handleCreatePointerDown = useCallback((event: React.PointerEvent) => { |
|
|
|
if (busy || !bounds) return; |
|
|
|
if (!bounds) return; |
|
|
|
// 只处理空白区域的按下(在框上按下会被框自身的 handler 拦截并 stopPropagation)。
|
|
|
|
event.preventDefault(); |
|
|
|
const point = pointerToOverlay(event); |
|
|
|
@ -295,10 +260,10 @@ export function MediaVideoEraseOverlay({ |
|
|
|
dragRef.current = { mode: "create", boxId: id, startX, startY, origin: newBox }; |
|
|
|
event.currentTarget.setPointerCapture(event.pointerId); |
|
|
|
overlayRef.current?.focus(); |
|
|
|
}, [bounds, busy, pointerToOverlay, setBoxesWithRefs, setSelectedIdWithRef]); |
|
|
|
}, [bounds, pointerToOverlay, setBoxesWithRefs, setSelectedIdWithRef]); |
|
|
|
|
|
|
|
const handleBoxPointerDown = useCallback((event: React.PointerEvent, boxId: number, mode: DragMode) => { |
|
|
|
if (busy || !bounds) return; |
|
|
|
if (!bounds) return; |
|
|
|
event.preventDefault(); |
|
|
|
event.stopPropagation(); |
|
|
|
const origin = boxes.find((box) => box.id === boxId); |
|
|
|
@ -308,7 +273,7 @@ export function MediaVideoEraseOverlay({ |
|
|
|
dragRef.current = { mode, boxId, startX: point.x, startY: point.y, origin }; |
|
|
|
event.currentTarget.setPointerCapture(event.pointerId); |
|
|
|
overlayRef.current?.focus(); |
|
|
|
}, [bounds, boxes, busy, pointerToOverlay, setSelectedIdWithRef]); |
|
|
|
}, [bounds, boxes, pointerToOverlay, setSelectedIdWithRef]); |
|
|
|
|
|
|
|
const handlePointerMove = useCallback((event: React.PointerEvent) => { |
|
|
|
const drag = dragRef.current; |
|
|
|
@ -350,7 +315,6 @@ export function MediaVideoEraseOverlay({ |
|
|
|
}, [commitHistory]); |
|
|
|
|
|
|
|
const handleKeyDown = useCallback((event: React.KeyboardEvent<HTMLDivElement>) => { |
|
|
|
if (busy) return; |
|
|
|
if (event.key !== "Delete" && event.key !== "Backspace") return; |
|
|
|
const currentSelectedId = selectedIdRef.current; |
|
|
|
if (currentSelectedId === null) return; |
|
|
|
@ -362,13 +326,12 @@ export function MediaVideoEraseOverlay({ |
|
|
|
setBoxes(nextBoxes); |
|
|
|
setSelectedId(null); |
|
|
|
commitHistory({ boxes: nextBoxes, selectedId: null }); |
|
|
|
}, [busy, commitHistory]); |
|
|
|
}, [commitHistory]); |
|
|
|
|
|
|
|
// 归一化擦除框 + 选中框的显示矩形回调。
|
|
|
|
// 归一化擦除框回调(erase_ratio_location)。
|
|
|
|
useEffect(() => { |
|
|
|
if (!bounds || resolvedWidth <= 0 || resolvedHeight <= 0) { |
|
|
|
onBoxesChange?.([]); |
|
|
|
onFrameChange?.(null); |
|
|
|
return; |
|
|
|
} |
|
|
|
const ratioBoxes: VideoEraseRatioBox[] = boxes.map((box) => { |
|
|
|
@ -384,10 +347,7 @@ export function MediaVideoEraseOverlay({ |
|
|
|
}; |
|
|
|
}); |
|
|
|
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]); |
|
|
|
}, [boxes, bounds, onBoxesChange, resolvedHeight, resolvedWidth]); |
|
|
|
|
|
|
|
const handleModes = useMemo(() => ["nw", "ne", "sw", "se"] as const, []); |
|
|
|
|
|
|
|
|