Browse Source

调整首尾帧生成流程为纯视频节点连接

xuzhijie-popiart-node-canvas
xuzhijie 3 months ago
parent
commit
c5fe611d66
  1. 12
      popiart-node-interaction-enhancement-implementation.md
  2. 3
      src/components/WorkflowCanvas.tsx
  3. 23
      src/components/__tests__/VideoInputNode.test.tsx
  4. 31
      src/components/nodes/VideoInputNode.tsx
  5. 5
      src/store/workflowStore.ts

12
popiart-node-interaction-enhancement-implementation.md

@ -2044,3 +2044,15 @@ npm.cmd run test:run -- VideoInputNode.test.tsx WorkflowCanvas.test.tsx ImageNod
```
结果:5 个测试文件、87 个测试通过。
## 2026-04-30 更新:首尾帧目标节点改为视频节点
本次调整“首尾帧生成视频”的建链目标:
1. `首帧``尾帧` 仍然都是空的视频上传节点,用户后续自行上传视频。
2. 右侧被连接的目标也改为空的 `videoInput` 视频节点,不再创建 `generateVideo` 节点。
3. 自动连线保持 `video -> video`,形成 `首帧/尾帧 -> 视频节点` 的纯视频节点链路。
验证:
```powershell
npm.cmd run test:run -- VideoInputNode.test.tsx WorkflowCanvas.test.tsx
```

3
src/components/WorkflowCanvas.tsx

@ -2218,7 +2218,8 @@ export function WorkflowCanvas() {
)}
{/* Welcome Modal */}
{showQuickstart && (
{/* 新建项目设置弹窗打开时不再同时挂载欢迎弹窗,避免两个弹窗状态互相触发。 */}
{showQuickstart && !showNewProjectSetup && (
<WelcomeModal
onWorkflowGenerated={async (workflow, directoryPath) => {
await loadWorkflow(workflow, directoryPath);

23
src/components/__tests__/VideoInputNode.test.tsx

@ -8,6 +8,7 @@ const KEYFRAMES_LABEL = "\u9996\u5c3e\u5e27\u751f\u6210\u89c6\u9891";
const FIRST_FRAME_LABEL = "\u9996\u5e27\u751f\u6210\u89c6\u9891";
const FIRST_FRAME_NODE_LABEL = "\u9996\u5e27";
const LAST_FRAME_NODE_LABEL = "\u5c3e\u5e27";
const TARGET_VIDEO_NODE_LABEL = "\u89c6\u9891\u8282\u70b9";
const mockUpdateNodeData = vi.fn();
let addNodeSequence = 0;
@ -92,7 +93,7 @@ describe("VideoInputNode", () => {
expect(screen.getByRole("button", { name: FIRST_FRAME_LABEL })).toBeInTheDocument();
});
it("labels the current node as first frame and links it to a generate video node", () => {
it("labels the current node as first frame and links it to a target video node", () => {
render(
<TestWrapper>
<VideoInputNode {...defaultProps} />
@ -104,16 +105,20 @@ describe("VideoInputNode", () => {
expect(mockUpdateNodeData).toHaveBeenCalledWith("video-1", {
label: FIRST_FRAME_NODE_LABEL,
});
expect(mockAddNode).toHaveBeenCalledWith("generateVideo", { x: 600, y: 160 });
expect(mockAddNode).toHaveBeenCalledWith(
"videoInput",
{ x: 600, y: 160 },
{ label: TARGET_VIDEO_NODE_LABEL }
);
expect(mockOnConnect).toHaveBeenCalledWith({
source: "video-1",
sourceHandle: "video",
target: "generateVideo-1",
target: "videoInput-1",
targetHandle: "video",
});
});
it("creates an empty last-frame video node and links both videos to a generate video node", () => {
it("creates empty last-frame and target video nodes, then links both source videos", () => {
render(
<TestWrapper>
<VideoInputNode {...defaultProps} />
@ -130,17 +135,21 @@ describe("VideoInputNode", () => {
{ x: 120, y: 492 },
{ label: LAST_FRAME_NODE_LABEL }
);
expect(mockAddNode).toHaveBeenCalledWith("generateVideo", { x: 600, y: 316 });
expect(mockAddNode).toHaveBeenCalledWith(
"videoInput",
{ x: 600, y: 326 },
{ label: TARGET_VIDEO_NODE_LABEL }
);
expect(mockOnConnect).toHaveBeenCalledWith({
source: "video-1",
sourceHandle: "video",
target: "generateVideo-2",
target: "videoInput-2",
targetHandle: "video",
});
expect(mockOnConnect).toHaveBeenCalledWith({
source: "videoInput-1",
sourceHandle: "video",
target: "generateVideo-2",
target: "videoInput-2",
targetHandle: "video",
});
});

31
src/components/nodes/VideoInputNode.tsx

@ -20,6 +20,7 @@ const KEYFRAMES_ACTION_LABEL = "\u9996\u5c3e\u5e27\u751f\u6210\u89c6\u9891";
const FIRST_FRAME_ACTION_LABEL = "\u9996\u5e27\u751f\u6210\u89c6\u9891";
const FIRST_FRAME_LABEL = "\u9996\u5e27";
const LAST_FRAME_LABEL = "\u5c3e\u5e27";
const TARGET_VIDEO_LABEL = "\u89c6\u9891\u8282\u70b9";
export function VideoInputNode({ id, data, selected }: NodeProps<VideoInputNodeType>) {
const nodeData = data;
@ -32,8 +33,8 @@ export function VideoInputNode({ id, data, selected }: NodeProps<VideoInputNodeT
// Use blob URL for efficient playback of large base64 videos
const playbackUrl = useVideoBlobUrl(nodeData.video ?? null);
// 只负责搭建“首帧/尾帧 -> 生成视频”的节点链路,视频文件由用户后续在各节点上传。
const createGenerateVideoChain = useCallback(
// 只负责搭建“首帧/尾帧 -> 视频节点”的链路,视频文件由用户后续在各节点上传。
const createVideoNodeChain = useCallback(
(hasLastFrame: boolean) => {
const currentNode = nodes.find((node) => node.id === id);
const currentWidth =
@ -43,7 +44,7 @@ export function VideoInputNode({ id, data, selected }: NodeProps<VideoInputNodeT
currentNode?.measured?.height ??
(typeof currentNode?.style?.height === "number" ? currentNode.style.height : defaultNodeDimensions.videoInput.height);
const currentPosition = currentNode?.position ?? { x: 0, y: 0 };
const generateVideoHeight = defaultNodeDimensions.generateVideo.height;
const targetVideoHeight = defaultNodeDimensions.videoInput.height;
const verticalGap = 52;
// 复用当前空视频节点作为首帧节点,避免点击快捷入口后留下多余空节点。
@ -61,19 +62,23 @@ export function VideoInputNode({ id, data, selected }: NodeProps<VideoInputNodeT
: null;
const targetY = hasLastFrame
? currentPosition.y + currentHeight + verticalGap / 2 - generateVideoHeight / 2
? currentPosition.y + currentHeight + verticalGap / 2 - targetVideoHeight / 2
: currentPosition.y;
// 生成节点放在素材节点右侧;双帧模式下按两帧整体高度居中。
const generateVideoNodeId = addNode("generateVideo", {
x: currentPosition.x + currentWidth + 180,
y: targetY,
});
// 右侧目标同样创建为空视频节点,用户后续可以自行上传或继续接入下一步。
const targetVideoNodeId = addNode(
"videoInput",
{
x: currentPosition.x + currentWidth + 180,
y: targetY,
},
{ label: TARGET_VIDEO_LABEL }
);
onConnect({
source: id,
sourceHandle: "video",
target: generateVideoNodeId,
target: targetVideoNodeId,
targetHandle: "video",
});
@ -81,7 +86,7 @@ export function VideoInputNode({ id, data, selected }: NodeProps<VideoInputNodeT
onConnect({
source: lastFrameNodeId,
sourceHandle: "video",
target: generateVideoNodeId,
target: targetVideoNodeId,
targetHandle: "video",
});
}
@ -199,7 +204,7 @@ export function VideoInputNode({ id, data, selected }: NodeProps<VideoInputNodeT
onClick={(event) => {
event.preventDefault();
event.stopPropagation();
createGenerateVideoChain(true);
createVideoNodeChain(true);
}}
>
<svg className="h-4 w-4 shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.8}>
@ -213,7 +218,7 @@ export function VideoInputNode({ id, data, selected }: NodeProps<VideoInputNodeT
onClick={(event) => {
event.preventDefault();
event.stopPropagation();
createGenerateVideoChain(false);
createVideoNodeChain(false);
}}
>
<svg className="h-4 w-4 shrink-0" fill="currentColor" viewBox="0 0 24 24">

5
src/store/workflowStore.ts

@ -658,7 +658,10 @@ const workflowStoreImpl: StateCreator<WorkflowStore> = (set, get) => ({
},
setShowQuickstart: (show: boolean) => {
set({ showQuickstart: show });
// 避免重复写入相同状态触发无意义渲染,降低弹窗回调形成循环更新的风险。
set((state) => (
state.showQuickstart === show ? state : { showQuickstart: show }
));
},
setHoveredNodeId: (id: string | null) => {

Loading…
Cancel
Save