Browse Source

优化图片 视频工具

feature/video-erase
Luckyu_js 2 weeks ago
parent
commit
7d23777abd
  1. 71
      src/components/canvas/ActiveImageEditHost.tsx
  2. 67
      src/components/canvas/ActiveVideoEditHost.tsx
  3. 95
      src/components/canvas/useNodeCanvasFrame.ts

71
src/components/canvas/ActiveImageEditHost.tsx

@ -1,16 +1,15 @@
"use client";
import { useLayoutEffect, useMemo, useRef, type CSSProperties } from "react";
import { useRef } from "react";
import { useViewport } from "@xyflow/react";
import { ImageEditSession } from "@/components/media/image-edit/ImageEditSession";
import type { ImageEditTool } from "@/components/media/image-edit/types";
import { useNodeImageEntry } from "@/components/media/image-edit/nodeImageRegistry";
import { NodeMultiAnglePanel } from "@/components/composer/NodeMultiAnglePanel";
import { useNodeToolStore } from "@/store/nodeToolStore";
import { useWorkflowStore } from "@/store/workflowStore";
import { useModelStore } from "@/store/modelStore";
import { getNodeImageSource } from "@/utils/getNodeImageSource";
import { getWorkflowNodeStyleDimensions } from "@/utils/nodeDimensions";
import { useNodeCanvasFrame } from "@/components/canvas/useNodeCanvasFrame";
import type { WorkflowNode } from "@/types";
const IMAGE_EDIT_TOOLS: ReadonlySet<string> = new Set([
@ -21,14 +20,6 @@ const IMAGE_EDIT_TOOLS: ReadonlySet<string> = new Set([
"annotate",
]);
function asImageDimensions(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;
}
/**
* 沿宿 ViewportPortal
* transform zoom scale(1/zoom)
@ -76,34 +67,9 @@ export function ActiveImageEditHost() {
const isMultiAngle = activeTool === "multiAngle";
const targetNodeId = isImageEditTool || isMultiAngle ? activeNodeId : null;
const node = useWorkflowStore((state) =>
targetNodeId ? state.nodes.find((candidate) => candidate.id === targetNodeId) ?? null : null
);
// 组内子节点的 position 是相对父组的偏移;宿主在 ViewportPortal 里是 portal 的
// 直接子元素、不在组 DOM 内,需沿 parentId 累加父组坐标才是画布绝对位置。
// 根节点无 parentId,偏移为 0。只读父链坐标(深度 1-2),不建 Map、不订阅全量。
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 frame = useNodeCanvasFrame(targetNodeId);
const candidateNode = frame?.node ?? null;
const outpaintingModel = useModelStore((state) => state.byKind.outpainting[0] ?? null);
const inpaintingModel = useModelStore((state) => state.byKind.inpainting[0] ?? null);
@ -111,35 +77,18 @@ export function ActiveImageEditHost() {
const mediaFrameEl = entry?.mediaFrameEl ?? null;
const imageEl = entry?.imageEl ?? null;
// 保持一个稳定的 ref 对象供 session 读取图片自然尺寸(不用于指针数学)
// 保持一个稳定的 ref 对象供 session 读取图片自然尺寸(不用于指针数学)
const imageElRef = useRef<HTMLImageElement | null>(null);
imageElRef.current = imageEl;
const imageSource = getNodeImageSource(node);
const dimensions = useMemo(() => asImageDimensions(node?.data?.dimensions), [node?.data?.dimensions]);
const styleDims = node ? getWorkflowNodeStyleDimensions(node) : null;
// 活动节点已被删除:清理悬空的工具状态。
useLayoutEffect(() => {
if (targetNodeId && !node) {
closeTool();
}
}, [closeTool, node, targetNodeId]);
const imageSource = getNodeImageSource(candidateNode);
if (!node || !imageSource || !mediaFrameEl || !styleDims) {
// 定位交给 useNodeCanvasFrame(纯 style);此处只额外要求图源与已注册的媒体框就绪。
if (!frame || !imageSource || !mediaFrameEl) {
return null;
}
// flow 坐标系定位:媒体框在图片显示态铺满节点(w-full h-full、无 header),
// 故节点绝对位置 + 节点样式尺寸即媒体框在画布坐标中的框,无需额外偏移。
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",
};
const { node, dimensions, frameStyle } = frame;
if (isMultiAngle) {
return (

67
src/components/canvas/ActiveVideoEditHost.tsx

@ -1,28 +1,18 @@
"use client";
import { useLayoutEffect, useMemo, 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";
import { useNodeCanvasFrame } from "@/components/canvas/useNodeCanvasFrame";
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;
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
* 画布级视频编辑宿主: ActiveImageEditHost ,/
* overlay useNodeCanvasFrame( style:flow + ),
* / ViewportPortal transform ,overlay
*/
export function ActiveVideoEditHost() {
const activeNodeId = useNodeToolStore((state) => state.activeNodeId);
@ -32,57 +22,20 @@ export function ActiveVideoEditHost() {
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 frame = useNodeCanvasFrame(targetNodeId);
const videoModels = useModelStore((state) => state.byKind.video);
const videoSource = node ? getNodeVideoSource(node) : null;
const styleDims = node ? getWorkflowNodeStyleDimensions(node) : null;
const dimensions = useMemo(() => asVideoDimensions(node?.data?.dimensions), [node?.data?.dimensions]);
const videoSource = frame ? getNodeVideoSource(frame.node) : null;
useLayoutEffect(() => {
if (targetNodeId && !node) closeTool();
}, [closeTool, node, targetNodeId]);
if (!isVideoEditTool || !node || !videoSource || !styleDims) {
// 定位交给 useNodeCanvasFrame(纯 style);此处只额外要求视频源就绪。
if (!frame || !videoSource) {
return null;
}
const { node, dimensions, styleDims, frameStyle } = frame;
// 框选 overlay 用节点 dimensions letterbox 出显示框;缺失时退化为按节点比例。
const sessionDimensions = dimensions ?? styleDims;
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">

95
src/components/canvas/useNodeCanvasFrame.ts

@ -0,0 +1,95 @@
"use client";
import { useLayoutEffect, useMemo, type CSSProperties } from "react";
import { useNodeToolStore } from "@/store/nodeToolStore";
import { useWorkflowStore } from "@/store/workflowStore";
import { getWorkflowNodeStyleDimensions } from "@/utils/nodeDimensions";
import type { WorkflowNode } from "@/types";
export interface NodeCanvasFrame {
node: WorkflowNode;
/** 节点样式盒子尺寸(= 显示态媒体框尺寸,媒体 h-full w-full 铺满节点)。 */
styleDims: { width: number; height: number };
/** 媒体自然尺寸(来自 node.data.dimensions),供 overlay letterbox;可能为 null。 */
dimensions: { width: number; height: number } | null;
/** 画布级宿主定位样式:flow 坐标 + 父组偏移 + 样式尺寸,缩放/平移由 ViewportPortal 施加。 */
frameStyle: CSSProperties;
}
function asDimensions(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;
}
/**
* 宿 hook style
*
* 前提:编辑工具仅在"有媒体的显示态", `h-full w-full`
* (BaseNode fullBleed header), `节点 flow 坐标 + 父组偏移 + 节点样式尺寸`
* , DOM overlay
* letterbox宿 <ViewportPortal> ,/ portal transform
*
* null 宿()
*
*/
export function useNodeCanvasFrame(targetNodeId: string | null): NodeCanvasFrame | null {
const closeTool = useNodeToolStore((state) => state.closeTool);
const node = useWorkflowStore((state) =>
targetNodeId ? state.nodes.find((candidate) => candidate.id === targetNodeId) ?? null : null
);
// 组内子节点的 position 是相对父组的偏移;宿主在 ViewportPortal 里是 portal 的
// 直接子元素、不在组 DOM 内,需沿 parentId 累加父组坐标才是画布绝对位置。
// 根节点无 parentId,偏移为 0。只读父链坐标(深度 1-2),不建 Map、不订阅全量。
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 dimensions = useMemo(() => asDimensions(node?.data?.dimensions), [node?.data?.dimensions]);
const styleDims = node ? getWorkflowNodeStyleDimensions(node) : null;
// 活动节点已被删除:清理悬空的工具状态。
useLayoutEffect(() => {
if (targetNodeId && !node) {
closeTool();
}
}, [closeTool, node, targetNodeId]);
if (!node || !styleDims) {
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 { node, styleDims, dimensions, frameStyle };
}
Loading…
Cancel
Save