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 {
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<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 {
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,
};
}

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

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

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

60
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<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(
node: WorkflowNode,
size: { width: number; height: number } | null
@ -1223,6 +1213,26 @@ function applyWorkflowNodeStyleSize(
} 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>) {
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<WorkflowStore> = (set, get) => ({
...node,
type,
data: managedNodeData,
width,
height,
measured: undefined,
style: { ...node.style, width, height },
}
: node
@ -1601,7 +1610,13 @@ const workflowStoreImpl: StateCreator<WorkflowStore> = (set, get) => ({
const hasDataChange = Object.entries(managedData).some(
([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") {
logSmartImage7Snapshot("updateNodeData:no-change", node, {
patch: data,
@ -1633,7 +1648,10 @@ const workflowStoreImpl: StateCreator<WorkflowStore> = (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<WorkflowStore> = (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<string, unknown>)[key], value)
);
@ -2262,9 +2281,6 @@ const workflowStoreImpl: StateCreator<WorkflowStore> = (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),
};
});

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

53
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<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 {
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,

2
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,
},
};
}

Loading…
Cancel
Save