Browse Source

比例缩放问题修改

feature/071
yun 1 week ago
parent
commit
83ec539632
  1. 7
      src/components/CanvasTemplatePreviewModal.tsx
  2. 14
      src/components/PopiaiEmbedBridge.tsx
  3. 1
      src/components/nodes/BaseNode.tsx
  4. 5
      src/components/nodes/OutputGalleryNode.tsx
  5. 128
      src/store/__tests__/workflowStore.integration.test.ts
  6. 60
      src/store/workflowStore.ts
  7. 56
      src/utils/__tests__/nodeSizing.test.ts
  8. 29
      src/utils/__tests__/smartMediaNode.test.ts
  9. 53
      src/utils/nodeSizing.ts
  10. 2
      src/utils/smartMediaNode.ts

7
src/components/CanvasTemplatePreviewModal.tsx

@ -230,12 +230,7 @@ const previewNodeTypes = Object.fromEntries(
function readPreviewNodeDimension(node: Node, axis: "width" | "height"): number { function readPreviewNodeDimension(node: Node, axis: "width" | "height"): number {
const defaults = defaultNodeDimensions[node.type as NodeType] || DEFAULT_NODE_DIMENSIONS; const defaults = defaultNodeDimensions[node.type as NodeType] || DEFAULT_NODE_DIMENSIONS;
const styleValue = node.style?.[axis]; const styleValue = node.style?.[axis];
return ( return typeof styleValue === "number" ? styleValue : defaults[axis];
(typeof node[axis] === "number" ? node[axis] : undefined) ??
(typeof styleValue === "number" ? styleValue : undefined) ??
(typeof node.measured?.[axis] === "number" ? node.measured[axis] : undefined) ??
defaults[axis]
);
} }
function getPreviewNodeAbsolutePosition(node: Node, nodeById: Map<string, Node>): { x: number; y: number } { function getPreviewNodeAbsolutePosition(node: Node, nodeById: Map<string, Node>): { x: number; y: number } {

14
src/components/PopiaiEmbedBridge.tsx

@ -89,11 +89,7 @@ function readPositiveNumber(value: unknown): number | null {
} }
function dimensionsFromStoreNode(node: WorkflowNode): NodeDimensions { function dimensionsFromStoreNode(node: WorkflowNode): NodeDimensions {
const nodeWithRuntimeSize = node as WorkflowNode & { const nodeWithStyleSize = node as WorkflowNode & { style?: { width?: unknown; height?: unknown } };
width?: unknown;
height?: unknown;
style?: { width?: unknown; height?: unknown };
};
const defaults = getDefaultNodeDimensions(node.type as NodeType); const defaults = getDefaultNodeDimensions(node.type as NodeType);
return { return {
@ -101,14 +97,10 @@ function dimensionsFromStoreNode(node: WorkflowNode): NodeDimensions {
x: node.position.x, x: node.position.x,
y: node.position.y, y: node.position.y,
width: width:
readPositiveNumber(node.measured?.width) ?? readPositiveNumber(nodeWithStyleSize.style?.width) ??
readPositiveNumber(nodeWithRuntimeSize.width) ??
readPositiveNumber(nodeWithRuntimeSize.style?.width) ??
defaults.width, defaults.width,
height: height:
readPositiveNumber(node.measured?.height) ?? readPositiveNumber(nodeWithStyleSize.style?.height) ??
readPositiveNumber(nodeWithRuntimeSize.height) ??
readPositiveNumber(nodeWithRuntimeSize.style?.height) ??
defaults.height, defaults.height,
}; };
} }

1
src/components/nodes/BaseNode.tsx

@ -51,7 +51,6 @@ interface BaseNodeProps {
function getNodeDimension(node: Node, axis: "width" | "height"): number { function getNodeDimension(node: Node, axis: "width" | "height"): number {
return ( return (
(node.style?.[axis] as number) ?? (node.style?.[axis] as number) ??
(node.measured?.[axis] as number) ??
DEFAULT_NODE_DIMENSION DEFAULT_NODE_DIMENSION
); );
} }

5
src/components/nodes/OutputGalleryNode.tsx

@ -150,7 +150,10 @@ export function OutputGalleryNode({ id, data, selected }: NodeProps<OutputGaller
const galleryNode = getNodes().find((n) => n.id === id); const galleryNode = getNodes().find((n) => n.id === id);
if (!galleryNode) return; 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; const startX = galleryNode.position.x + galleryWidth + 100;
let currentY = galleryNode.position.y; let currentY = galleryNode.position.y;
const gap = 20; const gap = 20;

128
src/store/__tests__/workflowStore.integration.test.ts

@ -502,14 +502,13 @@ describe("workflowStore integration tests", () => {
expect(useWorkflowStore.getState().hasUnsavedChanges).toBe(false); 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({ useWorkflowStore.setState({
nodes: [ nodes: [node],
{
...createTestNode("video-1", "generateVideo", {}),
measured: { width: 360, height: 300 },
} as WorkflowNode,
],
hasUnsavedChanges: false, hasUnsavedChanges: false,
}); });
@ -517,7 +516,7 @@ describe("workflowStore integration tests", () => {
{ id: "video-1", type: "dimensions", dimensions: { width: 420, height: 300 } }, { 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); expect(useWorkflowStore.getState().hasUnsavedChanges).toBe(false);
}); });
@ -550,8 +549,117 @@ describe("workflowStore integration tests", () => {
dimensions: { width: 1600, height: 900 }, dimensions: { width: 1600, height: 900 },
}); });
expect(node).toMatchObject({ style: { width: 615, height: 346 } }); expect(node).toMatchObject({ style: { width: 615, height: 346 } });
expect(node.width).toBeUndefined(); expect(useWorkflowStore.getState().hasUnsavedChanges).toBe(true);
expect(node.height).toBeUndefined(); });
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); expect(useWorkflowStore.getState().hasUnsavedChanges).toBe(true);
}); });
}); });

60
src/store/workflowStore.ts

@ -33,8 +33,7 @@ import { logger } from "@/utils/logger";
import { EditOperation, applyEditOperations as executeEditOps } from "@/lib/chat/editOperations"; import { EditOperation, applyEditOperations as executeEditOps } from "@/lib/chat/editOperations";
import { findNearestFreePosition } from "@/utils/spatialLayout"; import { findNearestFreePosition } from "@/utils/spatialLayout";
import { assertCanCreateNodeType } from "@/utils/nodeCreationTypes"; import { assertCanCreateNodeType } from "@/utils/nodeCreationTypes";
import { resolveInitialNodeSize, type NodeCreationSize } from "@/utils/nodeSizing"; import { resolveInitialNodeSize, resolveNodeAutoSize, type NodeCreationSize } from "@/utils/nodeSizing";
import { calculateMediaNodeSizeByShortSide } from "@/utils/nodeDimensions";
import { import {
loadWorkflowCostData, loadWorkflowCostData,
saveWorkflowCostData, saveWorkflowCostData,
@ -1191,15 +1190,6 @@ function getRunningGroupIdsFromTasks(tasks: Record<string, RunningTaskEntry>): 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( function applyWorkflowNodeStyleSize(
node: WorkflowNode, node: WorkflowNode,
size: { width: number; height: number } | null size: { width: number; height: number } | null
@ -1223,6 +1213,26 @@ function applyWorkflowNodeStyleSize(
} as WorkflowNode; } as WorkflowNode;
} }
function hasNodeSizingInputChange(data: Partial<WorkflowNodeData>): 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<string, RunningTaskEntry>) { function syncRunningStateFromTasks(tasks: Record<string, RunningTaskEntry>) {
const runningNodeIds = getRunningNodeIdsFromTasks(tasks); const runningNodeIds = getRunningNodeIdsFromTasks(tasks);
return { return {
@ -1249,7 +1259,8 @@ function isDimensionValueEffective(
): boolean { ): boolean {
if (value === undefined) return false; 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; return true;
} }
@ -1257,7 +1268,7 @@ function isDimensionValueEffective(
setAttributes === true || setAttributes === true ||
setAttributes === axis setAttributes === axis
) { ) {
return !numbersMatch(node[axis], value); return !numbersMatch(styleValue, value);
} }
return false; return false;
@ -1287,6 +1298,7 @@ function isNodeChangeEffective(
case "dimensions": { case "dimensions": {
const node = nodeById.get(change.id); const node = nodeById.get(change.id);
if (!node) return false; if (!node) return false;
if (node.type !== "group") return false;
const widthChanged = isDimensionValueEffective(node, "width", change.dimensions?.width, change.setAttributes); const widthChanged = isDimensionValueEffective(node, "width", change.dimensions?.width, change.setAttributes);
const heightChanged = isDimensionValueEffective(node, "height", change.dimensions?.height, change.setAttributes); const heightChanged = isDimensionValueEffective(node, "height", change.dimensions?.height, change.setAttributes);
const resizing = (node as WorkflowNode & { resizing?: boolean }).resizing; const resizing = (node as WorkflowNode & { resizing?: boolean }).resizing;
@ -1568,9 +1580,6 @@ const workflowStoreImpl: StateCreator<WorkflowStore> = (set, get) => ({
...node, ...node,
type, type,
data: managedNodeData, data: managedNodeData,
width,
height,
measured: undefined,
style: { ...node.style, width, height }, style: { ...node.style, width, height },
} }
: node : node
@ -1601,7 +1610,13 @@ const workflowStoreImpl: StateCreator<WorkflowStore> = (set, get) => ({
const hasDataChange = Object.entries(managedData).some( const hasDataChange = Object.entries(managedData).some(
([key, value]) => !Object.is((node.data as Record<string, unknown>)[key], value) ([key, value]) => !Object.is((node.data as Record<string, unknown>)[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") { if (nodeId === "smartImage-7") {
logSmartImage7Snapshot("updateNodeData:no-change", node, { logSmartImage7Snapshot("updateNodeData:no-change", node, {
patch: data, patch: data,
@ -1633,7 +1648,10 @@ const workflowStoreImpl: StateCreator<WorkflowStore> = (set, get) => ({
set((state) => ({ set((state) => ({
nodes: state.nodes.map((node) => nodes: state.nodes.map((node) =>
node.id === nodeId node.id === nodeId
? { ...node, data: { ...node.data, ...managedData } as WorkflowNodeData } ? applyWorkflowNodeStyleSize({
...node,
data: { ...node.data, ...managedData } as WorkflowNodeData,
} as WorkflowNode, canvasSize)
: node : node
) as WorkflowNode[], ) as WorkflowNode[],
hasUnsavedChanges: true, hasUnsavedChanges: true,
@ -1727,7 +1745,8 @@ const workflowStoreImpl: StateCreator<WorkflowStore> = (set, get) => ({
if (!node) return; if (!node) return;
const managedData = buildStoreManagedGenerationData(node, data, get); 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( const hasDataChange = Object.entries(managedData).some(
([key, value]) => !Object.is((node.data as Record<string, unknown>)[key], value) ([key, value]) => !Object.is((node.data as Record<string, unknown>)[key], value)
); );
@ -2262,9 +2281,6 @@ const workflowStoreImpl: StateCreator<WorkflowStore> = (set, get) => ({
// Reset height to defaults so BaseNode's ResizeObserver // Reset height to defaults so BaseNode's ResizeObserver
// can correctly add settings panel height from the right baseline // can correctly add settings panel height from the right baseline
style: { width: node.style?.width ?? defaults.width, height: defaults.height }, style: { width: node.style?.width ?? defaults.width, height: defaults.height },
width: undefined,
height: undefined,
measured: undefined,
data: clonePreservingStrings(node.data), data: clonePreservingStrings(node.data),
}; };
}); });

56
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 });
});
});

29
src/utils/__tests__/smartMediaNode.test.ts

@ -135,6 +135,35 @@ describe("createSmartMediaNode", () => {
expect(createElementSpy).not.toHaveBeenCalledWith("video"); 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 () => { it("keeps default node size when media has no dimensions", async () => {
const addNode = vi.fn(() => "smartAudio-1"); const addNode = vi.fn(() => "smartAudio-1");

53
src/utils/nodeSizing.ts

@ -7,6 +7,9 @@ export interface NodeCreationSize {
height?: number; height?: number;
} }
type NodeSize = { width: number; height: number };
type MediaDimensions = { width: number; height: number };
function getPositiveFiniteNumber(value: unknown): number | null { function getPositiveFiniteNumber(value: unknown): number | null {
return typeof value === "number" && Number.isFinite(value) && value > 0 ? value : 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; return width && height ? { width, height } : null;
} }
function readNodeMediaUrl(data: NodeSizingData): string | null {
const record = data as Record<string, unknown>;
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 { export function resolveNodeSizeFromAspectRatio(value: unknown): { width: number; height: number } | null {
return getGenerationNodeSizeForAspectRatio(value); return getGenerationNodeSizeForAspectRatio(value);
} }
export function inferNodeSizeFromData( export function resolveNodeAutoSize(
_type: NodeType, _type: NodeType,
data: NodeSizingData data: NodeSizingData,
): { width: number; height: number } | null { options: { dimensions?: MediaDimensions | null | undefined } = {}
const mediaDimensions = readMediaDimensions(data); ): NodeSize | null {
if (mediaDimensions) { const mediaSize = resolveMediaNodeSize(options.dimensions ?? readMediaDimensions(data));
return calculateMediaNodeSizeByShortSide(mediaDimensions.width / mediaDimensions.height); if (mediaSize) return mediaSize;
}
if (readNodeMediaUrl(data)) return null;
return resolveNodeSizeFromAspectRatio(readAspectRatio(data)); return resolveNodeSizeFromAspectRatio(readAspectRatio(data));
} }
export function inferNodeSizeFromData(
type: NodeType,
data: NodeSizingData
): { width: number; height: number } | null {
return resolveNodeAutoSize(type, data);
}
export function resolveInitialNodeSize( export function resolveInitialNodeSize(
type: NodeType, type: NodeType,
data: NodeSizingData, data: NodeSizingData,

2
src/utils/smartMediaNode.ts

@ -275,7 +275,7 @@ export async function buildSmartMediaNodeData(source: SmartMediaSource): Promise
filename: source.filename, filename: source.filename,
format: source.mimeType || "video/asset", format: source.mimeType || "video/asset",
duration: source.duration ?? metadata?.duration ?? null, duration: source.duration ?? metadata?.duration ?? null,
dimensions: posterDimensions ?? source.dimensions ?? metadata?.dimensions ?? null, dimensions: source.dimensions ?? metadata?.dimensions ?? posterDimensions ?? null,
}, },
}; };
} }

Loading…
Cancel
Save