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 个测试通过。 结果: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 */} {/* Welcome Modal */}
{showQuickstart && ( {/* 新建项目设置弹窗打开时不再同时挂载欢迎弹窗,避免两个弹窗状态互相触发。 */}
{showQuickstart && !showNewProjectSetup && (
<WelcomeModal <WelcomeModal
onWorkflowGenerated={async (workflow, directoryPath) => { onWorkflowGenerated={async (workflow, directoryPath) => {
await loadWorkflow(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_LABEL = "\u9996\u5e27\u751f\u6210\u89c6\u9891";
const FIRST_FRAME_NODE_LABEL = "\u9996\u5e27"; const FIRST_FRAME_NODE_LABEL = "\u9996\u5e27";
const LAST_FRAME_NODE_LABEL = "\u5c3e\u5e27"; const LAST_FRAME_NODE_LABEL = "\u5c3e\u5e27";
const TARGET_VIDEO_NODE_LABEL = "\u89c6\u9891\u8282\u70b9";
const mockUpdateNodeData = vi.fn(); const mockUpdateNodeData = vi.fn();
let addNodeSequence = 0; let addNodeSequence = 0;
@ -92,7 +93,7 @@ describe("VideoInputNode", () => {
expect(screen.getByRole("button", { name: FIRST_FRAME_LABEL })).toBeInTheDocument(); 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( render(
<TestWrapper> <TestWrapper>
<VideoInputNode {...defaultProps} /> <VideoInputNode {...defaultProps} />
@ -104,16 +105,20 @@ describe("VideoInputNode", () => {
expect(mockUpdateNodeData).toHaveBeenCalledWith("video-1", { expect(mockUpdateNodeData).toHaveBeenCalledWith("video-1", {
label: FIRST_FRAME_NODE_LABEL, 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({ expect(mockOnConnect).toHaveBeenCalledWith({
source: "video-1", source: "video-1",
sourceHandle: "video", sourceHandle: "video",
target: "generateVideo-1", target: "videoInput-1",
targetHandle: "video", 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( render(
<TestWrapper> <TestWrapper>
<VideoInputNode {...defaultProps} /> <VideoInputNode {...defaultProps} />
@ -130,17 +135,21 @@ describe("VideoInputNode", () => {
{ x: 120, y: 492 }, { x: 120, y: 492 },
{ label: LAST_FRAME_NODE_LABEL } { 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({ expect(mockOnConnect).toHaveBeenCalledWith({
source: "video-1", source: "video-1",
sourceHandle: "video", sourceHandle: "video",
target: "generateVideo-2", target: "videoInput-2",
targetHandle: "video", targetHandle: "video",
}); });
expect(mockOnConnect).toHaveBeenCalledWith({ expect(mockOnConnect).toHaveBeenCalledWith({
source: "videoInput-1", source: "videoInput-1",
sourceHandle: "video", sourceHandle: "video",
target: "generateVideo-2", target: "videoInput-2",
targetHandle: "video", 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_ACTION_LABEL = "\u9996\u5e27\u751f\u6210\u89c6\u9891";
const FIRST_FRAME_LABEL = "\u9996\u5e27"; const FIRST_FRAME_LABEL = "\u9996\u5e27";
const LAST_FRAME_LABEL = "\u5c3e\u5e27"; const LAST_FRAME_LABEL = "\u5c3e\u5e27";
const TARGET_VIDEO_LABEL = "\u89c6\u9891\u8282\u70b9";
export function VideoInputNode({ id, data, selected }: NodeProps<VideoInputNodeType>) { export function VideoInputNode({ id, data, selected }: NodeProps<VideoInputNodeType>) {
const nodeData = data; 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 // Use blob URL for efficient playback of large base64 videos
const playbackUrl = useVideoBlobUrl(nodeData.video ?? null); const playbackUrl = useVideoBlobUrl(nodeData.video ?? null);
// 只负责搭建“首帧/尾帧 -> 生成视频”的节点链路,视频文件由用户后续在各节点上传。 // 只负责搭建“首帧/尾帧 -> 视频节点”的链路,视频文件由用户后续在各节点上传。
const createGenerateVideoChain = useCallback( const createVideoNodeChain = useCallback(
(hasLastFrame: boolean) => { (hasLastFrame: boolean) => {
const currentNode = nodes.find((node) => node.id === id); const currentNode = nodes.find((node) => node.id === id);
const currentWidth = const currentWidth =
@ -43,7 +44,7 @@ export function VideoInputNode({ id, data, selected }: NodeProps<VideoInputNodeT
currentNode?.measured?.height ?? currentNode?.measured?.height ??
(typeof currentNode?.style?.height === "number" ? currentNode.style.height : defaultNodeDimensions.videoInput.height); (typeof currentNode?.style?.height === "number" ? currentNode.style.height : defaultNodeDimensions.videoInput.height);
const currentPosition = currentNode?.position ?? { x: 0, y: 0 }; const currentPosition = currentNode?.position ?? { x: 0, y: 0 };
const generateVideoHeight = defaultNodeDimensions.generateVideo.height; const targetVideoHeight = defaultNodeDimensions.videoInput.height;
const verticalGap = 52; const verticalGap = 52;
// 复用当前空视频节点作为首帧节点,避免点击快捷入口后留下多余空节点。 // 复用当前空视频节点作为首帧节点,避免点击快捷入口后留下多余空节点。
@ -61,19 +62,23 @@ export function VideoInputNode({ id, data, selected }: NodeProps<VideoInputNodeT
: null; : null;
const targetY = hasLastFrame const targetY = hasLastFrame
? currentPosition.y + currentHeight + verticalGap / 2 - generateVideoHeight / 2 ? currentPosition.y + currentHeight + verticalGap / 2 - targetVideoHeight / 2
: currentPosition.y; : currentPosition.y;
// 生成节点放在素材节点右侧;双帧模式下按两帧整体高度居中。 // 右侧目标同样创建为空视频节点,用户后续可以自行上传或继续接入下一步。
const generateVideoNodeId = addNode("generateVideo", { const targetVideoNodeId = addNode(
x: currentPosition.x + currentWidth + 180, "videoInput",
y: targetY, {
}); x: currentPosition.x + currentWidth + 180,
y: targetY,
},
{ label: TARGET_VIDEO_LABEL }
);
onConnect({ onConnect({
source: id, source: id,
sourceHandle: "video", sourceHandle: "video",
target: generateVideoNodeId, target: targetVideoNodeId,
targetHandle: "video", targetHandle: "video",
}); });
@ -81,7 +86,7 @@ export function VideoInputNode({ id, data, selected }: NodeProps<VideoInputNodeT
onConnect({ onConnect({
source: lastFrameNodeId, source: lastFrameNodeId,
sourceHandle: "video", sourceHandle: "video",
target: generateVideoNodeId, target: targetVideoNodeId,
targetHandle: "video", targetHandle: "video",
}); });
} }
@ -199,7 +204,7 @@ export function VideoInputNode({ id, data, selected }: NodeProps<VideoInputNodeT
onClick={(event) => { onClick={(event) => {
event.preventDefault(); event.preventDefault();
event.stopPropagation(); 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}> <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) => { onClick={(event) => {
event.preventDefault(); event.preventDefault();
event.stopPropagation(); event.stopPropagation();
createGenerateVideoChain(false); createVideoNodeChain(false);
}} }}
> >
<svg className="h-4 w-4 shrink-0" fill="currentColor" viewBox="0 0 24 24"> <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) => { setShowQuickstart: (show: boolean) => {
set({ showQuickstart: show }); // 避免重复写入相同状态触发无意义渲染,降低弹窗回调形成循环更新的风险。
set((state) => (
state.showQuickstart === show ? state : { showQuickstart: show }
));
}, },
setHoveredNodeId: (id: string | null) => { setHoveredNodeId: (id: string | null) => {

Loading…
Cancel
Save