diff --git a/src/components/CanvasTemplatePreviewModal.tsx b/src/components/CanvasTemplatePreviewModal.tsx index db5d7e46..a0f6432d 100644 --- a/src/components/CanvasTemplatePreviewModal.tsx +++ b/src/components/CanvasTemplatePreviewModal.tsx @@ -230,12 +230,7 @@ const previewNodeTypes = Object.fromEntries( function readPreviewNodeDimension(node: Node, axis: "width" | "height"): number { const defaults = defaultNodeDimensions[node.type as NodeType] || DEFAULT_NODE_DIMENSIONS; const styleValue = node.style?.[axis]; - return ( - (typeof node[axis] === "number" ? node[axis] : undefined) ?? - (typeof styleValue === "number" ? styleValue : undefined) ?? - (typeof node.measured?.[axis] === "number" ? node.measured[axis] : undefined) ?? - defaults[axis] - ); + return typeof styleValue === "number" ? styleValue : defaults[axis]; } function getPreviewNodeAbsolutePosition(node: Node, nodeById: Map): { x: number; y: number } { diff --git a/src/components/PopiaiEmbedBridge.tsx b/src/components/PopiaiEmbedBridge.tsx index d3c7b61b..e25e42e0 100644 --- a/src/components/PopiaiEmbedBridge.tsx +++ b/src/components/PopiaiEmbedBridge.tsx @@ -89,11 +89,7 @@ function readPositiveNumber(value: unknown): number | null { } function dimensionsFromStoreNode(node: WorkflowNode): NodeDimensions { - const nodeWithRuntimeSize = node as WorkflowNode & { - width?: unknown; - height?: unknown; - style?: { width?: unknown; height?: unknown }; - }; + const nodeWithStyleSize = node as WorkflowNode & { style?: { width?: unknown; height?: unknown } }; const defaults = getDefaultNodeDimensions(node.type as NodeType); return { @@ -101,14 +97,10 @@ function dimensionsFromStoreNode(node: WorkflowNode): NodeDimensions { x: node.position.x, y: node.position.y, width: - readPositiveNumber(node.measured?.width) ?? - readPositiveNumber(nodeWithRuntimeSize.width) ?? - readPositiveNumber(nodeWithRuntimeSize.style?.width) ?? + readPositiveNumber(nodeWithStyleSize.style?.width) ?? defaults.width, height: - readPositiveNumber(node.measured?.height) ?? - readPositiveNumber(nodeWithRuntimeSize.height) ?? - readPositiveNumber(nodeWithRuntimeSize.style?.height) ?? + readPositiveNumber(nodeWithStyleSize.style?.height) ?? defaults.height, }; } diff --git a/src/components/nodes/BaseNode.tsx b/src/components/nodes/BaseNode.tsx index 17cd13db..0fa244ac 100644 --- a/src/components/nodes/BaseNode.tsx +++ b/src/components/nodes/BaseNode.tsx @@ -51,7 +51,6 @@ interface BaseNodeProps { function getNodeDimension(node: Node, axis: "width" | "height"): number { return ( (node.style?.[axis] as number) ?? - (node.measured?.[axis] as number) ?? DEFAULT_NODE_DIMENSION ); } diff --git a/src/components/nodes/OutputGalleryNode.tsx b/src/components/nodes/OutputGalleryNode.tsx index 36a94c4c..f8f22b2a 100644 --- a/src/components/nodes/OutputGalleryNode.tsx +++ b/src/components/nodes/OutputGalleryNode.tsx @@ -150,7 +150,10 @@ export function OutputGalleryNode({ id, data, selected }: NodeProps n.id === id); if (!galleryNode) return; - const galleryWidth = galleryNode.measured?.width ?? defaultNodeDimensions.outputGallery.width; + const galleryWidth = + typeof galleryNode.style?.width === "number" + ? galleryNode.style.width + : defaultNodeDimensions.outputGallery.width; const startX = galleryNode.position.x + galleryWidth + 100; let currentY = galleryNode.position.y; const gap = 20; diff --git a/src/store/__tests__/workflowStore.integration.test.ts b/src/store/__tests__/workflowStore.integration.test.ts index c5a7106d..5cc2f9be 100644 --- a/src/store/__tests__/workflowStore.integration.test.ts +++ b/src/store/__tests__/workflowStore.integration.test.ts @@ -502,14 +502,13 @@ describe("workflowStore integration tests", () => { expect(useWorkflowStore.getState().hasUnsavedChanges).toBe(false); }); - it("should apply dimensions when the measured value changes", () => { + it("should ignore measured dimensions from React Flow for non-group nodes", () => { + const node = { + ...createTestNode("video-1", "generateVideo", {}), + style: { width: 360, height: 300 }, + } as WorkflowNode; useWorkflowStore.setState({ - nodes: [ - { - ...createTestNode("video-1", "generateVideo", {}), - measured: { width: 360, height: 300 }, - } as WorkflowNode, - ], + nodes: [node], hasUnsavedChanges: false, }); @@ -517,7 +516,7 @@ describe("workflowStore integration tests", () => { { id: "video-1", type: "dimensions", dimensions: { width: 420, height: 300 } }, ]); - expect(useWorkflowStore.getState().nodes[0].measured?.width).toBe(420); + expect(useWorkflowStore.getState().nodes[0]).toBe(node); expect(useWorkflowStore.getState().hasUnsavedChanges).toBe(false); }); @@ -550,8 +549,117 @@ describe("workflowStore integration tests", () => { dimensions: { width: 1600, height: 900 }, }); expect(node).toMatchObject({ style: { width: 615, height: 346 } }); - expect(node.width).toBeUndefined(); - expect(node.height).toBeUndefined(); + expect(useWorkflowStore.getState().hasUnsavedChanges).toBe(true); + }); + + it("does not use ratio sizing while a media url exists without dimensions", () => { + useWorkflowStore.setState({ + nodes: [ + { + ...createTestNode("image-1", "smartImage", { + outputImage: "https://cdn.example.com/image.png", + aspectRatio: "9:16", + dimensions: null, + }), + style: { width: 300, height: 280 }, + } as WorkflowNode, + ], + hasUnsavedChanges: false, + }); + + useWorkflowStore.getState().updateMediaNodeData( + "image-1", + { + outputImage: "https://cdn.example.com/image.png", + aspectRatio: "9:16", + dimensions: null, + }, + null + ); + + const node = useWorkflowStore.getState().nodes[0]; + expect(node).toMatchObject({ style: { width: 300, height: 280 } }); + expect(useWorkflowStore.getState().hasUnsavedChanges).toBe(false); + }); + + it("resizes from real dimensions once media metadata is available", () => { + useWorkflowStore.setState({ + nodes: [ + { + ...createTestNode("video-1", "generateVideo", { + outputVideo: "https://cdn.example.com/video.mp4", + parameters: { ratio: "9:16" }, + dimensions: null, + }), + style: { width: 300, height: 280 }, + } as WorkflowNode, + ], + hasUnsavedChanges: false, + }); + + useWorkflowStore.getState().updateMediaNodeData( + "video-1", + { + dimensions: { width: 1920, height: 1080 }, + }, + { width: 1920, height: 1080 } + ); + + const node = useWorkflowStore.getState().nodes[0]; + expect(node.data).toMatchObject({ + dimensions: { width: 1920, height: 1080 }, + }); + expect(node).toMatchObject({ style: { width: 615, height: 346 } }); + expect(useWorkflowStore.getState().hasUnsavedChanges).toBe(true); + }); + + it("updates canvas size from ratio only when the node has no media url", () => { + useWorkflowStore.setState({ + nodes: [ + { + ...createTestNode("image-1", "smartImage", { + image: null, + outputImage: null, + aspectRatio: "1:1", + dimensions: null, + }), + style: { width: 300, height: 280 }, + } as WorkflowNode, + ], + hasUnsavedChanges: false, + }); + + useWorkflowStore.getState().updateNodeData("image-1", { + aspectRatio: "9:16", + }); + + const node = useWorkflowStore.getState().nodes[0]; + expect(node).toMatchObject({ style: { width: 203, height: 360 } }); + expect(useWorkflowStore.getState().hasUnsavedChanges).toBe(true); + }); + + it("does not update canvas size from ratio when media url exists", () => { + useWorkflowStore.setState({ + nodes: [ + { + ...createTestNode("image-1", "smartImage", { + outputImage: "https://cdn.example.com/image.png", + aspectRatio: "1:1", + dimensions: null, + }), + style: { width: 300, height: 280 }, + } as WorkflowNode, + ], + hasUnsavedChanges: false, + }); + + useWorkflowStore.getState().updateNodeData("image-1", { + aspectRatio: "9:16", + }); + + const node = useWorkflowStore.getState().nodes[0]; + expect(node.data).toMatchObject({ aspectRatio: "9:16" }); + expect(node).toMatchObject({ style: { width: 300, height: 280 } }); expect(useWorkflowStore.getState().hasUnsavedChanges).toBe(true); }); }); diff --git a/src/store/workflowStore.ts b/src/store/workflowStore.ts index e8947f6d..81115520 100644 --- a/src/store/workflowStore.ts +++ b/src/store/workflowStore.ts @@ -33,8 +33,7 @@ import { logger } from "@/utils/logger"; import { EditOperation, applyEditOperations as executeEditOps } from "@/lib/chat/editOperations"; import { findNearestFreePosition } from "@/utils/spatialLayout"; import { assertCanCreateNodeType } from "@/utils/nodeCreationTypes"; -import { resolveInitialNodeSize, type NodeCreationSize } from "@/utils/nodeSizing"; -import { calculateMediaNodeSizeByShortSide } from "@/utils/nodeDimensions"; +import { resolveInitialNodeSize, resolveNodeAutoSize, type NodeCreationSize } from "@/utils/nodeSizing"; import { loadWorkflowCostData, saveWorkflowCostData, @@ -1191,15 +1190,6 @@ function getRunningGroupIdsFromTasks(tasks: Record): S ); } -function getMediaNodeCanvasSize( - dimensions: { width: number; height: number } | null | undefined -): { width: number; height: number } | null { - if (!dimensions) return null; - const { width, height } = dimensions; - if (!Number.isFinite(width) || !Number.isFinite(height) || width <= 0 || height <= 0) return null; - return calculateMediaNodeSizeByShortSide(width / height); -} - function applyWorkflowNodeStyleSize( node: WorkflowNode, size: { width: number; height: number } | null @@ -1223,6 +1213,26 @@ function applyWorkflowNodeStyleSize( } as WorkflowNode; } +function hasNodeSizingInputChange(data: Partial): boolean { + const sizingKeys = [ + "dimensions", + "image", + "outputImage", + "previewImage", + "previewImg", + "video", + "outputVideo", + "outputVideoRemoteUrl", + "previewVideoPoster", + "outputVideoCover", + "aspectRatio", + "parameters", + "config", + ]; + + return sizingKeys.some((key) => Object.prototype.hasOwnProperty.call(data, key)); +} + function syncRunningStateFromTasks(tasks: Record) { const runningNodeIds = getRunningNodeIdsFromTasks(tasks); return { @@ -1249,7 +1259,8 @@ function isDimensionValueEffective( ): boolean { if (value === undefined) return false; - if (!numbersMatch(node.measured?.[axis], value)) { + const styleValue = typeof node.style?.[axis] === "number" ? node.style[axis] : undefined; + if (!numbersMatch(styleValue, value)) { return true; } @@ -1257,7 +1268,7 @@ function isDimensionValueEffective( setAttributes === true || setAttributes === axis ) { - return !numbersMatch(node[axis], value); + return !numbersMatch(styleValue, value); } return false; @@ -1287,6 +1298,7 @@ function isNodeChangeEffective( case "dimensions": { const node = nodeById.get(change.id); if (!node) return false; + if (node.type !== "group") return false; const widthChanged = isDimensionValueEffective(node, "width", change.dimensions?.width, change.setAttributes); const heightChanged = isDimensionValueEffective(node, "height", change.dimensions?.height, change.setAttributes); const resizing = (node as WorkflowNode & { resizing?: boolean }).resizing; @@ -1568,9 +1580,6 @@ const workflowStoreImpl: StateCreator = (set, get) => ({ ...node, type, data: managedNodeData, - width, - height, - measured: undefined, style: { ...node.style, width, height }, } : node @@ -1601,7 +1610,13 @@ const workflowStoreImpl: StateCreator = (set, get) => ({ const hasDataChange = Object.entries(managedData).some( ([key, value]) => !Object.is((node.data as Record)[key], value) ); - if (!hasDataChange) { + const nextData = { ...node.data, ...managedData } as WorkflowNodeData; + const canvasSize = hasNodeSizingInputChange(managedData) + ? resolveNodeAutoSize(node.type, nextData) + : null; + const resizedNode = applyWorkflowNodeStyleSize(node, canvasSize); + const hasSizeChange = resizedNode !== node; + if (!hasDataChange && !hasSizeChange) { if (nodeId === "smartImage-7") { logSmartImage7Snapshot("updateNodeData:no-change", node, { patch: data, @@ -1633,7 +1648,10 @@ const workflowStoreImpl: StateCreator = (set, get) => ({ set((state) => ({ nodes: state.nodes.map((node) => node.id === nodeId - ? { ...node, data: { ...node.data, ...managedData } as WorkflowNodeData } + ? applyWorkflowNodeStyleSize({ + ...node, + data: { ...node.data, ...managedData } as WorkflowNodeData, + } as WorkflowNode, canvasSize) : node ) as WorkflowNode[], hasUnsavedChanges: true, @@ -1727,7 +1745,8 @@ const workflowStoreImpl: StateCreator = (set, get) => ({ if (!node) return; const managedData = buildStoreManagedGenerationData(node, data, get); - const canvasSize = getMediaNodeCanvasSize(dimensions); + const nextData = { ...node.data, ...managedData } as WorkflowNodeData; + const canvasSize = resolveNodeAutoSize(node.type, nextData, { dimensions }); const hasDataChange = Object.entries(managedData).some( ([key, value]) => !Object.is((node.data as Record)[key], value) ); @@ -2262,9 +2281,6 @@ const workflowStoreImpl: StateCreator = (set, get) => ({ // Reset height to defaults so BaseNode's ResizeObserver // can correctly add settings panel height from the right baseline style: { width: node.style?.width ?? defaults.width, height: defaults.height }, - width: undefined, - height: undefined, - measured: undefined, data: clonePreservingStrings(node.data), }; }); diff --git a/src/utils/__tests__/nodeSizing.test.ts b/src/utils/__tests__/nodeSizing.test.ts new file mode 100644 index 00000000..81b7212b --- /dev/null +++ b/src/utils/__tests__/nodeSizing.test.ts @@ -0,0 +1,56 @@ +import { describe, expect, it } from "vitest"; + +import { resolveInitialNodeSize, resolveNodeAutoSize } from "../nodeSizing"; + +describe("resolveNodeAutoSize", () => { + it("uses real media dimensions before aspect ratio settings", () => { + expect( + resolveNodeAutoSize("smartImage", { + outputImage: "https://cdn.example.com/image.png", + aspectRatio: "9:16", + dimensions: { width: 1600, height: 900 }, + }) + ).toEqual({ width: 615, height: 346 }); + }); + + it("does not resize from aspect ratio while media exists without dimensions", () => { + expect( + resolveNodeAutoSize("smartImage", { + outputImage: "https://cdn.example.com/image.png", + aspectRatio: "9:16", + dimensions: null, + }) + ).toBeNull(); + }); + + it("uses aspect ratio when no media url exists", () => { + expect( + resolveNodeAutoSize("smartImage", { + outputImage: null, + aspectRatio: "9:16", + dimensions: null, + }) + ).toEqual({ width: 203, height: 360 }); + }); + + it("reads model ratio from parameters when no media url exists", () => { + expect( + resolveNodeAutoSize("generateVideo", { + outputVideo: null, + parameters: { ratio: "16:9" }, + }) + ).toEqual({ width: 360, height: 203 }); + }); +}); + +describe("resolveInitialNodeSize", () => { + it("falls back to default size when media exists without dimensions", () => { + expect( + resolveInitialNodeSize("smartImage", { + outputImage: "https://cdn.example.com/image.png", + aspectRatio: "9:16", + dimensions: null, + }) + ).toEqual({ width: 300, height: 280 }); + }); +}); diff --git a/src/utils/__tests__/smartMediaNode.test.ts b/src/utils/__tests__/smartMediaNode.test.ts index 07c14191..ce27b6cc 100644 --- a/src/utils/__tests__/smartMediaNode.test.ts +++ b/src/utils/__tests__/smartMediaNode.test.ts @@ -135,6 +135,35 @@ describe("createSmartMediaNode", () => { expect(createElementSpy).not.toHaveBeenCalledWith("video"); }); + it("uses supplied video dimensions before poster dimensions", async () => { + vi.stubGlobal("Image", MockImage); + const createElementSpy = vi.spyOn(document, "createElement"); + const addNode = vi.fn(() => "smartVideo-1"); + + await createSmartMediaNode({ + source: { + kind: "video", + url: "https://example.com/video.mp4", + previewUrl: "https://example.com/poster.jpg", + filename: "video.mp4", + dimensions: { width: 1920, height: 1080 }, + }, + position: { x: 100, y: 200 }, + addNode, + }); + + expect(addNode).toHaveBeenCalledWith( + "smartVideo", + { x: 100, y: 200 }, + expect.objectContaining({ + video: "https://example.com/video.mp4", + previewVideoPoster: "https://example.com/poster.jpg", + dimensions: { width: 1920, height: 1080 }, + }) + ); + expect(createElementSpy).not.toHaveBeenCalledWith("video"); + }); + it("keeps default node size when media has no dimensions", async () => { const addNode = vi.fn(() => "smartAudio-1"); diff --git a/src/utils/nodeSizing.ts b/src/utils/nodeSizing.ts index c8858b41..b0d99efd 100644 --- a/src/utils/nodeSizing.ts +++ b/src/utils/nodeSizing.ts @@ -7,6 +7,9 @@ export interface NodeCreationSize { height?: number; } +type NodeSize = { width: number; height: number }; +type MediaDimensions = { width: number; height: number }; + function getPositiveFiniteNumber(value: unknown): number | null { return typeof value === "number" && Number.isFinite(value) && value > 0 ? value : null; } @@ -51,22 +54,58 @@ function readMediaDimensions(data: NodeSizingData): { width: number; height: num return width && height ? { width, height } : null; } +function readNodeMediaUrl(data: NodeSizingData): string | null { + const record = data as Record; + const candidates = [ + record.image, + record.outputImage, + record.video, + record.outputVideo, + record.outputVideoRemoteUrl, + record.previewImage, + record.previewImg, + record.previewVideoPoster, + record.outputVideoCover, + ]; + + for (const candidate of candidates) { + if (typeof candidate === "string" && candidate.trim()) return candidate; + } + + return null; +} + +function resolveMediaNodeSize(dimensions: MediaDimensions | null): NodeSize | null { + if (!dimensions) return null; + const width = getPositiveFiniteNumber(dimensions.width); + const height = getPositiveFiniteNumber(dimensions.height); + return width && height ? calculateMediaNodeSizeByShortSide(width / height) : null; +} + export function resolveNodeSizeFromAspectRatio(value: unknown): { width: number; height: number } | null { return getGenerationNodeSizeForAspectRatio(value); } -export function inferNodeSizeFromData( +export function resolveNodeAutoSize( _type: NodeType, - data: NodeSizingData -): { width: number; height: number } | null { - const mediaDimensions = readMediaDimensions(data); - if (mediaDimensions) { - return calculateMediaNodeSizeByShortSide(mediaDimensions.width / mediaDimensions.height); - } + data: NodeSizingData, + options: { dimensions?: MediaDimensions | null | undefined } = {} +): NodeSize | null { + const mediaSize = resolveMediaNodeSize(options.dimensions ?? readMediaDimensions(data)); + if (mediaSize) return mediaSize; + + if (readNodeMediaUrl(data)) return null; return resolveNodeSizeFromAspectRatio(readAspectRatio(data)); } +export function inferNodeSizeFromData( + type: NodeType, + data: NodeSizingData +): { width: number; height: number } | null { + return resolveNodeAutoSize(type, data); +} + export function resolveInitialNodeSize( type: NodeType, data: NodeSizingData, diff --git a/src/utils/smartMediaNode.ts b/src/utils/smartMediaNode.ts index 677e247f..09e5dd8f 100644 --- a/src/utils/smartMediaNode.ts +++ b/src/utils/smartMediaNode.ts @@ -275,7 +275,7 @@ export async function buildSmartMediaNodeData(source: SmartMediaSource): Promise filename: source.filename, format: source.mimeType || "video/asset", duration: source.duration ?? metadata?.duration ?? null, - dimensions: posterDimensions ?? source.dimensions ?? metadata?.dimensions ?? null, + dimensions: source.dimensions ?? metadata?.dimensions ?? posterDimensions ?? null, }, }; }