diff --git a/src/components/FloatingActionBar.tsx b/src/components/FloatingActionBar.tsx index 936d2ac2..bdc5de03 100644 --- a/src/components/FloatingActionBar.tsx +++ b/src/components/FloatingActionBar.tsx @@ -7,7 +7,7 @@ import { useWorkflowStore } from "@/store/workflowStore"; import { NodeType } from "@/types"; import { useReactFlow } from "@xyflow/react"; import { useI18n } from "@/i18n"; -import { buildDefaultGenerationNodeData } from "@/utils/defaultGenerationNodeModel"; +import { buildDefaultGenerationNodeCreation } from "@/utils/defaultGenerationNodeModel"; import { GenerateNodeMenu } from "./GenerateNodeMenu"; import { ResourceManagerDrawer } from "./ResourceManagerDrawer"; import { MyCreationPickerModal } from "./MyCreationPickerModal"; @@ -34,12 +34,13 @@ function GenerateComboButton() { const handleAddNode = async (type: NodeType) => { const center = getPaneCenter(); + const { data, size } = await buildDefaultGenerationNodeCreation(type); const position = screenToFlowPosition({ - x: center.x + Math.random() * 100 - 50, - y: center.y + Math.random() * 100 - 50, + x: center.x - (size?.width ?? 0) / 2 + Math.random() * 100 - 50, + y: center.y - (size?.height ?? 0) / 2 + Math.random() * 100 - 50, }); - const nodeId = addNode(type, position, await buildDefaultGenerationNodeData(type)); + const nodeId = addNode(type, position, data, size); selectSingleNode(nodeId); setIsOpen(false); }; diff --git a/src/components/Header.tsx b/src/components/Header.tsx index 479e9e1f..a4349f30 100644 --- a/src/components/Header.tsx +++ b/src/components/Header.tsx @@ -2,6 +2,7 @@ import { useState, useMemo, useCallback, useEffect, type ReactNode } from "react"; import Link from "next/link"; +import { useSearchParams } from "next/navigation"; import { CommentOutlined, DownloadOutlined, @@ -96,9 +97,10 @@ function HeaderIconButton({ export function Header() { const { t } = useI18n(); + const searchParams = useSearchParams(); + const urlWorkflowId = searchParams?.get("workflowId") ?? null; const { workflowName, - workflowId, nodes, edges, edgeStyle, @@ -109,7 +111,6 @@ export function Header() { revertToSnapshot, } = useWorkflowStore(useShallow((state) => ({ workflowName: state.workflowName, - workflowId: state.workflowId, nodes: state.nodes, edges: state.edges, edgeStyle: state.edgeStyle, @@ -130,7 +131,7 @@ export function Header() { const handleDownloadWorkflowJson = useCallback(() => { const currentWorkflowName = workflowNameDraft.trim() || workflowName?.trim() || "未命名"; const workflow = buildSerializedWorkflow({ - workflowId, + workflowId: urlWorkflowId, workflowName, nameOverride: currentWorkflowName, nodes, @@ -150,7 +151,7 @@ export function Header() { document.body.removeChild(link); URL.revokeObjectURL(url); markAsSaved(); - }, [workflowId, workflowName, workflowNameDraft, nodes, edges, edgeStyle, groups, markAsSaved]); + }, [urlWorkflowId, workflowName, workflowNameDraft, nodes, edges, edgeStyle, groups, markAsSaved]); useEffect(() => { setWorkflowNameDraft(workflowName || ""); @@ -164,7 +165,7 @@ export function Header() { } setWorkflowName(nextName); - if (workflowId) { + if (urlWorkflowId) { setCurrentCanvasWorkflow({ title: nextName, description: currentCanvasWorkflowDescription, @@ -173,7 +174,7 @@ export function Header() { }, [ workflowNameDraft, workflowName, - workflowId, + urlWorkflowId, setWorkflowName, currentCanvasWorkflowDescription, setCurrentCanvasWorkflow, diff --git a/src/components/PopiaiEmbedBridge.tsx b/src/components/PopiaiEmbedBridge.tsx index a9762d82..d3c7b61b 100644 --- a/src/components/PopiaiEmbedBridge.tsx +++ b/src/components/PopiaiEmbedBridge.tsx @@ -272,12 +272,17 @@ function saveSessionWorkflow(sessionId: string) { ); } +function getUrlWorkflowId(): string | null { + if (typeof window === "undefined") return null; + return new URLSearchParams(window.location.search).get("workflowId"); +} + function buildSnapshot(sessionId: string | null) { const state = useWorkflowStore.getState(); return { sessionId, - workflowId: state.workflowId, + workflowId: getUrlWorkflowId(), workflowName: state.workflowName, nodeCount: state.nodes.length, edgeCount: state.edges.length, diff --git a/src/components/WorkflowCanvas.tsx b/src/components/WorkflowCanvas.tsx index d6141f73..09d71e39 100644 --- a/src/components/WorkflowCanvas.tsx +++ b/src/components/WorkflowCanvas.tsx @@ -1,6 +1,7 @@ "use client"; import { useCallback, useRef, useState, useEffect, DragEvent, useMemo, MouseEvent as ReactMouseEvent } from "react"; +import { useSearchParams } from "next/navigation"; import { ReactFlow, Background, @@ -66,7 +67,7 @@ import { TutorialOverlay } from "./onboarding/TutorialOverlay"; import { useFTUXStore } from "@/store/ftuxStore"; import { useI18n } from "@/i18n"; import { isPopiProviderMode } from "@/lib/providerMode"; -import { buildDefaultGenerationNodeData } from "@/utils/defaultGenerationNodeModel"; +import { buildDefaultGenerationNodeCreation } from "@/utils/defaultGenerationNodeModel"; import { isSmartImageGenerateMode } from "@/utils/smartImageMode"; import { isSmartAudioGenerateMode } from "@/utils/smartAudioMode"; import { deriveSmartTextMode, isSmartTextLlmMode } from "@/utils/smartTextMode"; @@ -519,6 +520,8 @@ function getCanvasPanOnDrag(disabled: boolean): PanOnDragValue { export function WorkflowCanvas() { const { t } = useI18n(); + const searchParams = useSearchParams(); + const urlWorkflowId = searchParams?.get("workflowId") ?? null; const popiProviderMode = isPopiProviderMode(); const { nodes, edges, groups, isModalOpen, navigationTarget, canvasNavigationSettings, dimmedNodeIds, skippedNodeIds, providerSettings, isRunning, runningNodeIds } = useWorkflowStore(useShallow((state) => ({ @@ -541,6 +544,7 @@ export function WorkflowCanvas() { const addNodeToGroup = useWorkflowStore((state) => state.addNodeToGroup); const updateNodeData = useWorkflowStore((state) => state.updateNodeData); const loadWorkflow = useWorkflowStore((state) => state.loadWorkflow); + const markAsUnsaved = useWorkflowStore((state) => state.markAsUnsaved); const getNodeById = useWorkflowStore((state) => state.getNodeById); const setNodeGroupId = useWorkflowStore((state) => state.setNodeGroupId); const executeSelectedNodes = useWorkflowStore((state) => state.executeSelectedNodes); @@ -1208,10 +1212,11 @@ export function WorkflowCanvas() { const handleCanvasNodeMenuSelect = useCallback(async (type: NodeType) => { if (!canvasNodeMenu) return; - const nodeId = addNode(type, canvasNodeMenu.flowPosition, await buildDefaultGenerationNodeData(type)); + const { data, size } = await buildDefaultGenerationNodeCreation(type); + const nodeId = addNode(type, canvasNodeMenu.flowPosition, data, size); selectSingleNode(nodeId); setCanvasNodeMenu(null); - }, [addNode, canvasNodeMenu, selectSingleNode]); + }, [addNode, buildDefaultGenerationNodeCreation, canvasNodeMenu, selectSingleNode]); // Inline parameters mode (for showing Browse in header) const { inlineParametersEnabled } = useInlineParameters(); @@ -1581,7 +1586,15 @@ export function WorkflowCanvas() { if (data.success && data.workflow) { captureSnapshot(); // Capture BEFORE loading new workflow - await loadWorkflow(data.workflow, undefined, { preserveSnapshot: true }); + await loadWorkflow( + { + ...data.workflow, + ...(urlWorkflowId ? { id: urlWorkflowId } : { id: undefined }), + }, + undefined, + { preserveSnapshot: true } + ); + markAsUnsaved(); closeCanvasAssistant(); showToast(t("workflow.generatedSuccess"), "success"); } else { @@ -1593,7 +1606,7 @@ export function WorkflowCanvas() { } finally { setIsBuildingWorkflow(false); } - }, [loadWorkflow, showToast, captureSnapshot, t, providerSettings, closeCanvasAssistant]); + }, [loadWorkflow, showToast, captureSnapshot, t, providerSettings, closeCanvasAssistant, urlWorkflowId, markAsUnsaved]); // Create lightweight workflow state for chat (strip base64 images) const chatWorkflowState = useMemo(() => { @@ -1669,11 +1682,13 @@ export function WorkflowCanvas() { const nodeType = selection.type as NodeType; // Create the new node at the drop position (empty - tutorial will populate after connection) + const { data, size } = await buildDefaultGenerationNodeCreation(nodeType); const newNodeId = addNodeWithOptionalGroup( nodeType, flowPosition, sourceGroupId, - await buildDefaultGenerationNodeData(nodeType) + data, + size ); // Tutorial tracking @@ -1913,7 +1928,7 @@ export function WorkflowCanvas() { selectSingleNode(newNodeId); setConnectionDrop(null); }, - [connectionDrop, addNodeWithOptionalGroup, onConnect, nodes, edges, getImageFromNode, updateNodeData, tutorialActive, selectSingleNode] + [connectionDrop, addNodeWithOptionalGroup, buildDefaultGenerationNodeCreation, onConnect, nodes, edges, getImageFromNode, updateNodeData, tutorialActive, selectSingleNode] ); const handleCloseDropMenu = useCallback(() => { @@ -2124,12 +2139,14 @@ export function WorkflowCanvas() { if (nodeType) { event.preventDefault(); const { centerX, centerY } = getViewportCenter(); - const dims = defaultNodeDimensions[nodeType]; void (async () => { + const { data, size } = await buildDefaultGenerationNodeCreation(nodeType); + const dims = size ?? defaultNodeDimensions[nodeType]; const nodeId = addNode( nodeType, { x: centerX - dims.width / 2, y: centerY - dims.height / 2 }, - await buildDefaultGenerationNodeData(nodeType) + data, + size ); selectSingleNode(nodeId); })(); @@ -2510,7 +2527,8 @@ export function WorkflowCanvas() { x: event.clientX, y: event.clientY, }); - const nodeId = addNode(nodeType, position, await buildDefaultGenerationNodeData(nodeType)); + const { data, size } = await buildDefaultGenerationNodeCreation(nodeType); + const nodeId = addNode(nodeType, position, data, size); selectSingleNode(nodeId); return; } @@ -2614,7 +2632,11 @@ export function WorkflowCanvas() { try { const workflow = JSON.parse(e.target?.result as string) as WorkflowFile; if (workflow.version && workflow.nodes && workflow.edges) { - await loadWorkflow(workflow); + await loadWorkflow({ + ...workflow, + ...(urlWorkflowId ? { id: urlWorkflowId } : { id: undefined }), + }); + markAsUnsaved(); } else { alert(t("workflow.invalidWorkflowFile")); } diff --git a/src/components/WorkflowSaveModal.tsx b/src/components/WorkflowSaveModal.tsx index a3e7fdf9..79bfe740 100644 --- a/src/components/WorkflowSaveModal.tsx +++ b/src/components/WorkflowSaveModal.tsx @@ -2,6 +2,7 @@ import { useEffect, useState } from "react"; import { App, Form, Input, Modal } from "antd"; +import { useSearchParams } from "next/navigation"; import { useShallow } from "zustand/shallow"; import { useI18n } from "@/i18n"; import { updateCanvasWorkflow } from "@/lib/canvasWorkflowApi"; @@ -23,10 +24,11 @@ interface WorkflowSaveFormValues { export function WorkflowSaveModal({ isOpen, onClose }: WorkflowSaveModalProps) { const { t } = useI18n(); const { message } = App.useApp(); + const searchParams = useSearchParams(); + const urlWorkflowId = searchParams?.get("workflowId") ?? null; const [form] = Form.useForm(); const [isSubmitting, setIsSubmitting] = useState(false); const { - workflowId, workflowName, nodes, edges, @@ -34,7 +36,6 @@ export function WorkflowSaveModal({ isOpen, onClose }: WorkflowSaveModalProps) { groups, markAsSaved, } = useWorkflowStore(useShallow((state) => ({ - workflowId: state.workflowId, workflowName: state.workflowName, nodes: state.nodes, edges: state.edges, @@ -52,7 +53,7 @@ export function WorkflowSaveModal({ isOpen, onClose }: WorkflowSaveModalProps) { setCurrentCanvasWorkflow: state.setCurrentCanvasWorkflow, }))); - const numericWorkflowId = workflowId ? Number(workflowId) : null; + const numericWorkflowId = urlWorkflowId ? Number(urlWorkflowId) : null; const isUpdate = Boolean(numericWorkflowId && Number.isInteger(numericWorkflowId)); useEffect(() => { @@ -81,7 +82,7 @@ export function WorkflowSaveModal({ isOpen, onClose }: WorkflowSaveModalProps) { } const workflow = buildSerializedWorkflow({ - workflowId, + workflowId: urlWorkflowId, workflowName, nameOverride: title, nodes, diff --git a/src/components/__tests__/FloatingActionBar.test.tsx b/src/components/__tests__/FloatingActionBar.test.tsx index b257e500..9b1babed 100644 --- a/src/components/__tests__/FloatingActionBar.test.tsx +++ b/src/components/__tests__/FloatingActionBar.test.tsx @@ -85,7 +85,7 @@ vi.mock("@/store/workflowStore", () => ({ })); vi.mock("@/utils/defaultGenerationNodeModel", () => ({ - buildDefaultGenerationNodeData: vi.fn(async () => undefined), + buildDefaultGenerationNodeCreation: vi.fn(async () => ({ data: undefined, size: undefined })), })); vi.mock("@/components/modals/ModelSearchDialog", () => ({ @@ -219,7 +219,7 @@ describe("FloatingActionBar", () => { fireEvent.click(screen.getByText("Annotate")); await waitFor(() => { - expect(mockAddNode).toHaveBeenCalledWith("annotation", expect.any(Object), undefined); + expect(mockAddNode).toHaveBeenCalledWith("annotation", expect.any(Object), undefined, undefined); }); expect(mockSelectSingleNode).toHaveBeenCalledWith("new-node-id"); }); diff --git a/src/components/composer/GenerationComposer.tsx b/src/components/composer/GenerationComposer.tsx index 8c7ac456..1d365175 100644 --- a/src/components/composer/GenerationComposer.tsx +++ b/src/components/composer/GenerationComposer.tsx @@ -83,6 +83,10 @@ type ComposerDraft = GenerationNodeConfig; export type ComposerGenerationConfig = Omit; type ComposerConfig = ComposerGenerationConfig; +type ComposerPreferenceOptions = { + nodeSize?: { width: number; height: number } | null; +}; + type PricingInputsSnapshot = { config: ComposerConfig; prompt: string; @@ -309,6 +313,27 @@ function buildInitialDataForNode(nodeType: NodeType, draft: ComposerDraft): Part return {}; } +function readWorkflowNodeSize(node: WorkflowNode | null | undefined): { width: number; height: number } | null { + if (!node) return null; + const width = typeof node.measured?.width === "number" + ? node.measured.width + : typeof node.style?.width === "number" + ? node.style.width + : typeof node.width === "number" + ? node.width + : null; + const height = typeof node.measured?.height === "number" + ? node.measured.height + : typeof node.style?.height === "number" + ? node.style.height + : typeof node.height === "number" + ? node.height + : null; + return width && height && Number.isFinite(width) && Number.isFinite(height) && width > 0 && height > 0 + ? { width, height } + : null; +} + function buildComposerPersistParameters(node: WorkflowNode, config: ComposerConfig): ComposerConfig["parameters"] { void node; return filterTaskRoutingParameters(config.parameters); @@ -532,11 +557,16 @@ export function GenerationComposer({ targetNodeId, embedded = false, isHidden = }); }, [screenToFlowPosition]); - const rememberGenerationPreference = useCallback((capability: ComposerCapability, nextConfig: ComposerConfig) => { + const rememberGenerationPreference = useCallback(( + capability: ComposerCapability, + nextConfig: ComposerConfig, + options: ComposerPreferenceOptions = {} + ) => { const preferenceType = preferenceTypeForCapability(capability); const patch = { ...nextConfig, model: nextConfig.selectedModel, + ...(options.nodeSize ? { nodeSize: options.nodeSize } : {}), }; if (preferenceType === "image") { updateImagePreference(patch); @@ -572,8 +602,9 @@ export function GenerationComposer({ targetNodeId, embedded = false, isHidden = const submitCapability = runContext.mode === "empty-create" ? inferCapabilityFromModel(submitConfig.selectedModel) : runContext.capability; - if (submitCapability) rememberGenerationPreference(submitCapability, submitConfig); const submitAspectRatio = readComposerAspectRatio(submitConfig); + const submitNodeSize = getGenerationNodeSizeForAspectRatio(submitAspectRatio) ?? readWorkflowNodeSize(submitNode); + if (submitCapability) rememberGenerationPreference(submitCapability, submitConfig, { nodeSize: submitNodeSize }); if (runContext.mode === "unsupported-node" && runContext.node?.type === "imageInput") { const imageData = runContext.node.data as ImageInputNodeData; @@ -582,16 +613,15 @@ export function GenerationComposer({ targetNodeId, embedded = false, isHidden = ? submitConfig.selectedModel : EMPTY_SELECTED_MODEL; if (!selectedModel.modelId) return; - const nodeSize = getGenerationNodeSizeForAspectRatio(submitAspectRatio); const nextNodeId = addNode( "smartImage", - buildTargetPosition(nodeSize ?? undefined), + buildTargetPosition(submitNodeSize ?? undefined), buildInitialDataForNode("smartImage", { ...submitConfig, selectedModel, inputImages: [imageData.image], }), - nodeSize ?? undefined + submitNodeSize ?? undefined ); selectSingleNode(nextNodeId); await regenerateNode(nextNodeId); @@ -600,12 +630,11 @@ export function GenerationComposer({ targetNodeId, embedded = false, isHidden = if (runContext.mode === "empty-create") { if (!submitNodeType) return; - const nodeSize = getGenerationNodeSizeForAspectRatio(submitAspectRatio); const nextNodeId = addNode( submitNodeType, - buildTargetPosition(nodeSize ?? undefined), + buildTargetPosition(submitNodeSize ?? undefined), buildInitialDataForNode(submitNodeType, submitConfig), - nodeSize ?? undefined + submitNodeSize ?? undefined ); selectSingleNode(nextNodeId); await regenerateNode(nextNodeId); @@ -652,12 +681,14 @@ export function GenerationComposer({ targetNodeId, embedded = false, isHidden = ]); const persistComposerConfig = useCallback((persistContext: ComposerContext, config: GenerationNodeConfig) => { - rememberGenerationPreference(persistContext.capability, config); - if (!persistContext.node || persistContext.mode !== "node-edit") return; - const currentNode = useWorkflowStore.getState().nodes.find((node) => node.id === persistContext.node?.id) ?? persistContext.node; + const currentNode = persistContext.node + ? useWorkflowStore.getState().nodes.find((node) => node.id === persistContext.node?.id) ?? persistContext.node + : null; + rememberGenerationPreference(persistContext.capability, config, { nodeSize: readWorkflowNodeSize(currentNode) }); + if (!currentNode || persistContext.mode !== "node-edit") return; const patch = buildComposerConfigPersistPatch(currentNode, config); if (Object.keys(patch).length === 0) return; - updateNodeData(persistContext.node.id, patch); + updateNodeData(currentNode.id, patch); }, [rememberGenerationPreference, updateNodeData]); const cancelRunning = useCallback(() => { diff --git a/src/hooks/useCanvasWorkflowAutoUpdate.ts b/src/hooks/useCanvasWorkflowAutoUpdate.ts index c215f7e8..d384d27b 100644 --- a/src/hooks/useCanvasWorkflowAutoUpdate.ts +++ b/src/hooks/useCanvasWorkflowAutoUpdate.ts @@ -2,6 +2,7 @@ import { useEffect, useRef } from "react"; import { App } from "antd"; +import { useSearchParams } from "next/navigation"; import { useShallow } from "zustand/shallow"; import { useI18n } from "@/i18n"; import { updateCanvasWorkflow } from "@/lib/canvasWorkflowApi"; @@ -15,6 +16,8 @@ const CANVAS_WORKFLOW_AUTO_UPDATE_DELAY_MS = 5_000; export function useCanvasWorkflowAutoUpdate() { const { t } = useI18n(); const { message } = App.useApp(); + const searchParams = useSearchParams(); + const urlWorkflowId = searchParams?.get("workflowId") ?? null; const saveVersionRef = useRef(0); const timerRef = useRef | null>(null); @@ -29,7 +32,6 @@ export function useCanvasWorkflowAutoUpdate() { }))); const { - workflowId, workflowName, nodes, edges, @@ -38,7 +40,6 @@ export function useCanvasWorkflowAutoUpdate() { hasUnsavedChanges, markAsSaved, } = useWorkflowStore(useShallow((state) => ({ - workflowId: state.workflowId, workflowName: state.workflowName, nodes: state.nodes, edges: state.edges, @@ -54,7 +55,7 @@ export function useCanvasWorkflowAutoUpdate() { timerRef.current = null; } - const numericWorkflowId = workflowId ? Number(workflowId) : null; + const numericWorkflowId = urlWorkflowId ? Number(urlWorkflowId) : null; if (!numericWorkflowId || !Number.isInteger(numericWorkflowId) || !hasUnsavedChanges || isLoadingCanvasWorkflow) { return; } @@ -64,7 +65,7 @@ export function useCanvasWorkflowAutoUpdate() { timerRef.current = setTimeout(() => { const workflow = buildSerializedWorkflow({ - workflowId, + workflowId: urlWorkflowId, workflowName, nameOverride: workflowName || currentCanvasWorkflowTitle || undefined, nodes, @@ -104,7 +105,7 @@ export function useCanvasWorkflowAutoUpdate() { currentCanvasWorkflowTitle, currentCanvasWorkflowDescription, isLoadingCanvasWorkflow, - workflowId, + urlWorkflowId, workflowName, nodes, edges, diff --git a/src/store/__tests__/generationPreferenceStore.test.ts b/src/store/__tests__/generationPreferenceStore.test.ts new file mode 100644 index 00000000..f921a36d --- /dev/null +++ b/src/store/__tests__/generationPreferenceStore.test.ts @@ -0,0 +1,104 @@ +import { describe, expect, it, beforeEach } from "vitest"; +import { useGenerationPreferenceStore, createInitialGenerationPreferences } from "@/store/generationPreferenceStore"; +import { buildDefaultGenerationNodeCreation, buildDefaultGenerationNodeData } from "@/utils/defaultGenerationNodeModel"; +import type { SelectedModel } from "@/types"; + +const selectedModel: SelectedModel = { + provider: "popiserver", + modelId: "image-model", + displayName: "Image Model", + capabilities: ["text-to-image"], +}; + +type GenerationPreferenceState = ReturnType; +type ImagePreferencePatch = Parameters[0]; + +describe("generationPreferenceStore", () => { + beforeEach(() => { + useGenerationPreferenceStore.setState(createInitialGenerationPreferences()); + }); + + it("keeps only model configuration fields when remembering image preferences", async () => { + useGenerationPreferenceStore.getState().updateImagePreference({ + selectedModel, + batchSize: 3, + aspectRatio: "16:9", + resolution: "2K", + parameters: { + ratio: "16:9", + images: ["parameter-image-should-stay-if-schema-owns-it"], + type: 1, + }, + extraTaskParams: { generate_audio: false }, + subType: 101, + nodeSize: { width: 512, height: 288 }, + prompt: "do not remember prompts", + inputImages: ["https://example.com/reference.png"], + inputVideos: ["https://example.com/reference.mp4"], + voices: ["https://example.com/reference.wav"], + referenceSubjectList: [{ id: "1", type: "image", name: "Reference" }], + } as ImagePreferencePatch); + + const imagePreference = useGenerationPreferenceStore.getState().image as unknown as Record; + expect(imagePreference.model).toEqual(selectedModel); + expect(imagePreference.batchSize).toBe(3); + expect(imagePreference.aspectRatio).toBe("16:9"); + expect(imagePreference.resolution).toBe("2K"); + expect(imagePreference.parameters).toEqual({ + ratio: "16:9", + images: ["parameter-image-should-stay-if-schema-owns-it"], + type: 1, + }); + expect(imagePreference.extraTaskParams).toEqual({ generate_audio: false }); + expect(imagePreference.subType).toBe(101); + expect(imagePreference.nodeSize).toEqual({ width: 512, height: 288 }); + + expect(imagePreference).not.toHaveProperty("prompt"); + expect(imagePreference).not.toHaveProperty("inputImages"); + expect(imagePreference).not.toHaveProperty("inputVideos"); + expect(imagePreference).not.toHaveProperty("voices"); + expect(imagePreference).not.toHaveProperty("referenceSubjectList"); + + const newNodeData = await buildDefaultGenerationNodeData("nanoBanana"); + expect(newNodeData).toMatchObject({ + selectedModel, + batchSize: 3, + aspectRatio: "16:9", + resolution: "2K", + parameters: { + ratio: "16:9", + images: ["parameter-image-should-stay-if-schema-owns-it"], + }, + extraTaskParams: { generate_audio: false }, + subType: 101, + }); + expect(newNodeData).not.toHaveProperty("prompt"); + expect(newNodeData).not.toHaveProperty("inputImages"); + expect(newNodeData).not.toHaveProperty("inputVideos"); + expect(newNodeData).not.toHaveProperty("voices"); + expect(newNodeData).not.toHaveProperty("referenceSubjectList"); + + await expect(buildDefaultGenerationNodeCreation("nanoBanana")).resolves.toEqual({ + data: expect.any(Object), + size: undefined, + }); + }); + + it("uses remembered node size only when no aspect ratio can drive creation size", async () => { + useGenerationPreferenceStore.getState().updateVideoPreference({ + selectedModel: { + provider: "popiserver", + modelId: "video-model", + displayName: "Video Model", + capabilities: ["text-to-video"], + }, + parameters: { duration: 5 }, + nodeSize: { width: 640, height: 360 }, + }); + + await expect(buildDefaultGenerationNodeCreation("generateVideo")).resolves.toEqual({ + data: expect.any(Object), + size: { width: 640, height: 360 }, + }); + }); +}); diff --git a/src/store/__tests__/workflowStore.integration.test.ts b/src/store/__tests__/workflowStore.integration.test.ts index e2883a60..bdcfbe92 100644 --- a/src/store/__tests__/workflowStore.integration.test.ts +++ b/src/store/__tests__/workflowStore.integration.test.ts @@ -509,6 +509,17 @@ describe("workflowStore integration tests", () => { expect(node?.style).toMatchObject({ width: 203, height: 360 }); }); + it("infers generation node dimensions from model parameter ratio", () => { + const nodeId = useWorkflowStore.getState().addNode( + "generateVideo", + { x: 120, y: 160 }, + { parameters: { ratio: "16:9" } } + ); + + const node = useWorkflowStore.getState().nodes.find((candidate) => candidate.id === nodeId); + expect(node?.style).toMatchObject({ width: 360, height: 203 }); + }); + it("infers media node dimensions from initial media dimensions", () => { const nodeId = useWorkflowStore.getState().addNode( "smartImage", diff --git a/src/store/generationPreferenceStore.ts b/src/store/generationPreferenceStore.ts index f592073f..309b2f25 100644 --- a/src/store/generationPreferenceStore.ts +++ b/src/store/generationPreferenceStore.ts @@ -8,39 +8,116 @@ import { export type GenerationPreferenceType = "image" | "video" | "audio"; +export interface GenerationPreferenceNodeSize { + width: number; + height: number; +} + export interface ImageGenerationPreference { - [key: string]: unknown; type: "image"; model: SelectedModel; aspectRatio: string; resolution: string; batchSize: GenerationBatchSize; + parameters?: Record; + extraTaskParams?: Record; + subType?: number; + nodeSize?: GenerationPreferenceNodeSize; } export interface VideoGenerationPreference { - [key: string]: unknown; type: "video"; model: SelectedModel; parameters?: Record; extraTaskParams?: Record; subType?: number; batchSize: GenerationBatchSize; + nodeSize?: GenerationPreferenceNodeSize; } export interface AudioGenerationPreference { - [key: string]: unknown; type: "audio"; model: SelectedModel; voiceId?: string; + parameters?: Record; + extraTaskParams?: Record; + subType?: number; + batchSize?: GenerationBatchSize; + nodeSize?: GenerationPreferenceNodeSize; } +type GenerationPreferencePatch = { + model?: SelectedModel; + selectedModel?: SelectedModel; + aspectRatio?: unknown; + resolution?: unknown; + parameters?: unknown; + extraTaskParams?: unknown; + subType?: unknown; + batchSize?: unknown; + voiceId?: unknown; + audioVoiceId?: unknown; + nodeSize?: unknown; +}; + interface GenerationPreferenceState { image: ImageGenerationPreference; video: VideoGenerationPreference; audio: AudioGenerationPreference; - updateImagePreference: (patch: Partial>) => void; - updateVideoPreference: (patch: Partial>) => void; - updateAudioPreference: (patch: Partial>) => void; + updateImagePreference: (patch: GenerationPreferencePatch) => void; + updateVideoPreference: (patch: GenerationPreferencePatch) => void; + updateAudioPreference: (patch: GenerationPreferencePatch) => void; +} + +function isRecord(value: unknown): value is Record { + return Boolean(value && typeof value === "object" && !Array.isArray(value)); +} + +function normalizeCommonPreferencePatch(patch: GenerationPreferencePatch) { + const next: { + model?: SelectedModel; + parameters?: Record; + extraTaskParams?: Record; + subType?: number; + batchSize?: GenerationBatchSize; + nodeSize?: GenerationPreferenceNodeSize; + } = {}; + const model = patch.model ?? patch.selectedModel; + if (model) next.model = model; + if (isRecord(patch.parameters)) next.parameters = { ...patch.parameters }; + if (isRecord(patch.extraTaskParams)) next.extraTaskParams = { ...patch.extraTaskParams }; + if (typeof patch.subType === "number") next.subType = patch.subType; + if (typeof patch.batchSize === "number") next.batchSize = patch.batchSize; + if (isRecord(patch.nodeSize)) { + const width = patch.nodeSize.width; + const height = patch.nodeSize.height; + if (typeof width === "number" && Number.isFinite(width) && width > 0 && + typeof height === "number" && Number.isFinite(height) && height > 0) { + next.nodeSize = { width, height }; + } + } + return next; +} + +function normalizeImagePreferencePatch(patch: GenerationPreferencePatch): Partial> { + const next: Partial> = normalizeCommonPreferencePatch(patch); + if (typeof patch.aspectRatio === "string") next.aspectRatio = patch.aspectRatio; + if (typeof patch.resolution === "string") next.resolution = patch.resolution; + return next; +} + +function normalizeVideoPreferencePatch(patch: GenerationPreferencePatch): Partial> { + return normalizeCommonPreferencePatch(patch); +} + +function normalizeAudioPreferencePatch(patch: GenerationPreferencePatch): Partial> { + const next: Partial> = normalizeCommonPreferencePatch(patch); + if (Object.prototype.hasOwnProperty.call(patch, "voiceId")) { + next.voiceId = typeof patch.voiceId === "string" ? patch.voiceId : undefined; + } else if (Object.prototype.hasOwnProperty.call(patch, "audioVoiceId")) { + next.voiceId = typeof patch.audioVoiceId === "string" ? patch.audioVoiceId : undefined; + } + return next; } function createInitialImagePreference(): ImageGenerationPreference { @@ -84,13 +161,13 @@ export const useGenerationPreferenceStore = create()( (set) => ({ ...createInitialGenerationPreferences(), updateImagePreference: (patch) => set((state) => ({ - image: { ...state.image, ...patch, type: "image" }, + image: { ...state.image, ...normalizeImagePreferencePatch(patch), type: "image" }, })), updateVideoPreference: (patch) => set((state) => ({ - video: { ...state.video, ...patch, type: "video" }, + video: { ...state.video, ...normalizeVideoPreferencePatch(patch), type: "video" }, })), updateAudioPreference: (patch) => set((state) => ({ - audio: { ...state.audio, ...patch, type: "audio" }, + audio: { ...state.audio, ...normalizeAudioPreferencePatch(patch), type: "audio" }, })), }) ); diff --git a/src/store/workflowStore.ts b/src/store/workflowStore.ts index 9631a62a..1a132576 100644 --- a/src/store/workflowStore.ts +++ b/src/store/workflowStore.ts @@ -273,9 +273,28 @@ function readNodeDataDimensions(data: WorkflowNodeData): { width: number; height function readNodeDataAspectRatio(data: WorkflowNodeData): unknown { const directAspectRatio = (data as { aspectRatio?: unknown }).aspectRatio; if (typeof directAspectRatio === "string" && directAspectRatio.trim()) return directAspectRatio; + const parameters = (data as { parameters?: unknown }).parameters; + if (parameters && typeof parameters === "object") { + const record = parameters as Record; + const parameterAspectRatio = + record.ratio ?? + record.aspectRatio ?? + record.aspect_ratio ?? + record.videoRatio ?? + record.video_ratio ?? + record.imageRatio ?? + record.image_ratio; + if (typeof parameterAspectRatio === "string" && parameterAspectRatio.trim()) return parameterAspectRatio; + } const config = (data as { config?: unknown }).config; if (!config || typeof config !== "object") return null; - return (config as { aspectRatio?: unknown }).aspectRatio; + const configRecord = config as { aspectRatio?: unknown; parameters?: unknown }; + if (typeof configRecord.aspectRatio === "string" && configRecord.aspectRatio.trim()) return configRecord.aspectRatio; + if (configRecord.parameters && typeof configRecord.parameters === "object") { + const record = configRecord.parameters as Record; + return record.ratio ?? record.aspectRatio ?? record.aspect_ratio ?? record.videoRatio ?? record.video_ratio; + } + return null; } function resolveNodeCreationSize( diff --git a/src/utils/defaultGenerationNodeModel.ts b/src/utils/defaultGenerationNodeModel.ts index 35b2263e..6847d783 100644 --- a/src/utils/defaultGenerationNodeModel.ts +++ b/src/utils/defaultGenerationNodeModel.ts @@ -10,9 +10,10 @@ import { POPISERVER_DEFAULT_LLM_MODEL_ID, POPISERVER_DEFAULT_VIDEO_MODEL_ID, } from "@/store/utils/defaultImageModel"; -import { useGenerationPreferenceStore } from "@/store/generationPreferenceStore"; +import { useGenerationPreferenceStore, type GenerationPreferenceNodeSize } from "@/store/generationPreferenceStore"; import { toSelectedModel } from "@/utils/selectedModel"; import { filterTaskRoutingParameters } from "@/utils/generationConfig"; +import { getGenerationNodeSizeForAspectRatio } from "@/utils/nodeDimensions"; type GenerationNodeType = "smartImage" | "smartVideo" | "smartAudio" | "smartText" | "nanoBanana" | "generateVideo" | "generateAudio" | "llmGenerate"; @@ -75,38 +76,40 @@ async function buildGenerationPreferenceNodeData(type: NodeType): Promise | undefined), } as Partial; } if (type === "smartVideo" || type === "generateVideo") { - const { type: _type, model: _model, ...preferenceConfig } = preferences.video; const selectedModel = resolvePreferenceModel("video", preferences.video.model); return { - ...preferenceConfig, selectedModel, + batchSize: preferences.video.batchSize, + extraTaskParams: preferences.video.extraTaskParams ?? {}, subType: preferences.video.subType ?? selectedModel.subType, parameters: filterTaskRoutingParameters(preferences.video.parameters), } as Partial; } if (type === "smartAudio" || type === "generateAudio") { - const { type: _type, model: _model, ...preferenceConfig } = preferences.audio; const parameters = preferences.audio.parameters && typeof preferences.audio.parameters === "object" ? preferences.audio.parameters as Record : {}; const selectedModel = resolvePreferenceModel("audio", preferences.audio.model); return { - ...preferenceConfig, selectedModel, + batchSize: preferences.audio.batchSize, + extraTaskParams: preferences.audio.extraTaskParams ?? {}, + subType: preferences.audio.subType, audioVoiceId: preferences.audio.voiceId, parameters: filterTaskRoutingParameters(parameters), } as Partial; @@ -115,6 +118,45 @@ async function buildGenerationPreferenceNodeData(type: NodeType): Promise | undefined): unknown { + if (!data) return null; + const directAspectRatio = (data as { aspectRatio?: unknown }).aspectRatio; + if (typeof directAspectRatio === "string" && directAspectRatio.trim()) return directAspectRatio; + const parameters = (data as { parameters?: unknown }).parameters; + if (!parameters || typeof parameters !== "object") return null; + const record = parameters as Record; + return ( + record.ratio ?? + record.aspectRatio ?? + record.aspect_ratio ?? + record.videoRatio ?? + record.video_ratio ?? + record.imageRatio ?? + record.image_ratio ?? + null + ); +} + +export async function buildDefaultGenerationNodeCreation(type: NodeType): Promise<{ + data: Partial | undefined; + size: GenerationPreferenceNodeSize | undefined; +}> { + const data = await buildDefaultGenerationNodeData(type); + const aspectRatio = readPreferredAspectRatio(data); + return { + data, + size: getGenerationNodeSizeForAspectRatio(aspectRatio) ? undefined : buildDefaultGenerationNodeSize(type), + }; +} + export async function buildDefaultGenerationNodeData( type: NodeType ): Promise | undefined> {