From 33b759d3b32041f088113ec23a9d2ae8f6143fcb Mon Sep 17 00:00:00 2001 From: yun Date: Thu, 2 Jul 2026 10:16:17 +0800 Subject: [PATCH] =?UTF-8?q?=E5=9B=BE=E7=89=87=E5=88=86=E5=89=B2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../composer/ComposerModelConfig.tsx | 56 ++++++++ .../composer/NodeMultiAnglePanel.tsx | 132 ++++++++---------- src/components/nodes/GenerateImageNode.tsx | 28 ++-- src/components/nodes/ImageInputNode.tsx | 28 ++-- src/utils/__tests__/generationConfig.test.ts | 70 ++++++++++ src/utils/derivedImageNodes.ts | 58 ++++++++ src/utils/generationConfig.ts | 38 ++++- src/utils/splitGridNodes.ts | 37 ++--- 8 files changed, 316 insertions(+), 131 deletions(-) create mode 100644 src/utils/derivedImageNodes.ts diff --git a/src/components/composer/ComposerModelConfig.tsx b/src/components/composer/ComposerModelConfig.tsx index 8814aa6f..1664d664 100644 --- a/src/components/composer/ComposerModelConfig.tsx +++ b/src/components/composer/ComposerModelConfig.tsx @@ -118,6 +118,36 @@ function valueMatchesOption(optionValue: unknown, value: unknown): boolean { return Object.is(optionValue, value) || String(optionValue).toLowerCase() === String(value ?? "").toLowerCase(); } +function buildModelSelectionSettingFields( + detail: PopiModelDetail, + currentConfig: GenerationNodeConfig, + nextConfig: GenerationNodeConfig +) { + const parameterFields = detail.parameters.map((parameter) => ({ + source: "parameters", + name: parameter.name, + type: parameter.type, + label: parameter.label ?? parameter.name, + options: parameter.options ?? [], + defaultValue: parameter.default, + currentValue: currentConfig.parameters?.[parameter.name], + nextValue: nextConfig.parameters?.[parameter.name], + changed: !Object.is(currentConfig.parameters?.[parameter.name], nextConfig.parameters?.[parameter.name]), + })); + const extensionFields = normalizeExtensionFields(detail.extensionFields).map((field) => ({ + source: "extraTaskParams", + name: field.name, + type: field.type, + label: field.label ?? field.name, + options: field.options ?? [], + defaultValue: field.default, + currentValue: currentConfig.extraTaskParams?.[field.name], + nextValue: nextConfig.extraTaskParams?.[field.name], + changed: !Object.is(currentConfig.extraTaskParams?.[field.name], nextConfig.extraTaskParams?.[field.name]), + })); + return [...parameterFields, ...extensionFields]; +} + export function ComposerModelConfig({ activeCapability, config, @@ -225,6 +255,19 @@ export function ComposerModelConfig({ } if (modelSelectionRequestIdRef.current !== requestId) return; if (!detail) { + console.info("[composer-model-select]", { + model: nextModel, + settingFields: [], + currentConfig: { + parameters: config.parameters ?? {}, + extraTaskParams: config.extraTaskParams ?? {}, + }, + nextConfigValues: { + parameters: baseConfig.parameters, + extraTaskParams: baseConfig.extraTaskParams, + }, + nextConfig: baseConfig, + }); onConfigChange(baseConfig); onModelChangeComplete?.(baseConfig, null); return; @@ -239,6 +282,19 @@ export function ComposerModelConfig({ parameters: config.parameters ?? {}, extraTaskParams: config.extraTaskParams ?? {}, }, detail); + console.info("[composer-model-select]", { + model: selectedModelWithInputs, + settingFields: buildModelSelectionSettingFields(detail, config, nextConfig), + currentConfig: { + parameters: config.parameters ?? {}, + extraTaskParams: config.extraTaskParams ?? {}, + }, + nextConfigValues: { + parameters: nextConfig.parameters, + extraTaskParams: nextConfig.extraTaskParams, + }, + nextConfig, + }); onConfigChange(nextConfig); onModelChangeComplete?.(nextConfig, detail); }; diff --git a/src/components/composer/NodeMultiAnglePanel.tsx b/src/components/composer/NodeMultiAnglePanel.tsx index 31a529a7..d148715e 100644 --- a/src/components/composer/NodeMultiAnglePanel.tsx +++ b/src/components/composer/NodeMultiAnglePanel.tsx @@ -11,8 +11,10 @@ import { import { useI18n } from "@/i18n"; import { useModelStore } from "@/store/modelStore"; import { useWorkflowStore } from "@/store/workflowStore"; -import type { NanoBananaNodeData, SelectedModel, WorkflowNode } from "@/types"; -import { SINGLE_INPUT_HANDLE_ID, SINGLE_OUTPUT_HANDLE_ID } from "@/utils/nodeHandles"; +import type { GenerateResponse, SelectedModel, WorkflowNode } from "@/types"; +import { buildGenerateHeaders } from "@/store/utils/buildApiHeaders"; +import { executePopiartTask } from "@/store/execution/popiartTaskClient"; +import { createDerivedImageNode } from "@/utils/derivedImageNodes"; import { toSelectedModel } from "@/utils/selectedModel"; interface NodeMultiAnglePanelProps { @@ -21,38 +23,38 @@ interface NodeMultiAnglePanelProps { onClose: () => void; } -function buildMultiAngleNodeData( - sourceImage: string, - settings: MultiAngleSettings, - selectedModel: SelectedModel -): Partial { +type GeneratedImageResult = { + image: string; + previewImage?: string; +}; + +function normalizeGeneratedImage(value: unknown): GeneratedImageResult | null { + if (typeof value === "string" && value.length > 0) { + return { image: value }; + } + if (!value || typeof value !== "object") return null; + + const record = value as { url?: unknown; previewImg?: unknown }; + if (typeof record.url !== "string" || record.url.length === 0) return null; + return { - inputImages: [sourceImage], - inputPrompt: "multi-angle image generation", - aspectRatio: "16:9", - resolution: "2K", - model: "nano-banana-pro", - selectedModel, - imageCount: 1, - parameters: { batchSize: 1 }, - extraTaskParams: { - horizontal_angle: settings.horizontal, - vertical_angle: settings.vertical, - zoom: settings.zoom, - }, - useGoogleSearch: false, - useImageSearch: false, - status: "idle", - error: null, + image: record.url, + previewImage: typeof record.previewImg === "string" ? record.previewImg : undefined, }; } +function readFirstGeneratedImage(result: GenerateResponse): GeneratedImageResult | null { + if (Array.isArray(result.images) && result.images.length > 0) { + return normalizeGeneratedImage(result.images[0]); + } + return normalizeGeneratedImage(result.image); +} + export function NodeMultiAnglePanel({ node, imageSource, onClose }: NodeMultiAnglePanelProps) { const { t } = useI18n(); const addNode = useWorkflowStore((state) => state.addNode); - const getNodeById = useWorkflowStore((state) => state.getNodeById); const onConnect = useWorkflowStore((state) => state.onConnect); - const regenerateNode = useWorkflowStore((state) => state.regenerateNode); + const providerSettings = useWorkflowStore((state) => state.providerSettings); const selectSingleNode = useWorkflowStore((state) => state.selectSingleNode); const multiAngleModel = useModelStore((state) => state.byKind.multiAngle[0] ?? null); const [settings, setSettings] = useState({ ...DEFAULT_MULTI_ANGLE_SETTINGS }); @@ -99,66 +101,56 @@ export function NodeMultiAnglePanel({ node, imageSource, onClose }: NodeMultiAng : typeof node.style?.width === "number" ? node.style.width : 300; - const newNodeId = addNode( - "nanoBanana", + const result = await executePopiartTask( + { + images: [imageSource], + prompt: "multi-angle image generation", + selectedModel, + parameters: { + batchSize: 1, + horizontal_angle: settings.horizontal, + vertical_angle: settings.vertical, + zoom: settings.zoom, + }, + mediaType: "image", + }, { + headers: buildGenerateHeaders("popiserver", providerSettings), + } + ); + const generatedImage = readFirstGeneratedImage(result); + if (!generatedImage) { + throw new Error(result.error || "Generation failed"); + } + + createDerivedImageNode({ + sourceNodeId: node.id, + position: { x: node.position.x + currentWidth + 220, y: node.position.y, }, - buildMultiAngleNodeData(imageSource, settings, selectedModel) - ); - onConnect({ - source: node.id, - sourceHandle: SINGLE_OUTPUT_HANDLE_ID, - target: newNodeId, - targetHandle: SINGLE_INPUT_HANDLE_ID, + image: generatedImage.image, + previewImage: generatedImage.previewImage, + filename: `multi-angle-${Date.now()}.png`, + dimensions: null, + label: t("imageInput.multiAngle"), + operation: "multiAngle", + addNode, + onConnect, + selectSingleNode, }); - selectSingleNode(newNodeId); onClose(); - await regenerateNode(newNodeId); - const generatedNode = getNodeById(newNodeId); - const generatedData = generatedNode?.data as NanoBananaNodeData | undefined; - if (generatedData?.outputImage) { - const resultNodeId = addNode( - "smartImage", - { - x: node.position.x + currentWidth + 560, - y: node.position.y, - }, - { - image: generatedData.outputImage, - imageRef: undefined, - previewImage: generatedData.previewImg, - filename: `multi-angle-${Date.now()}.png`, - dimensions: null, - label: t("imageInput.multiAngle"), - derivedImage: { - sourceNodeId: newNodeId, - operation: "multiAngle", - }, - disablePreviewModal: true, - } - ); - onConnect({ - source: newNodeId, - sourceHandle: SINGLE_OUTPUT_HANDLE_ID, - target: resultNodeId, - targetHandle: SINGLE_INPUT_HANDLE_ID, - }); - selectSingleNode(resultNodeId); - } } finally { setIsGenerating(false); } }, [ addNode, - getNodeById, imageSource, isGenerating, node, onClose, onConnect, - regenerateNode, + providerSettings, selectSingleNode, selectedModel, settings, diff --git a/src/components/nodes/GenerateImageNode.tsx b/src/components/nodes/GenerateImageNode.tsx index 12756a06..ce8e04f9 100644 --- a/src/components/nodes/GenerateImageNode.tsx +++ b/src/components/nodes/GenerateImageNode.tsx @@ -36,6 +36,7 @@ import { createCroppedImageBlob } from "@/components/media/imageCropUtils"; import { uploadBlobToGatewayMedia } from "@/utils/gatewayMediaUpload"; import { SplitGridCapsuleMenu, type SplitGridSelection } from "@/components/media/SplitGridCapsuleMenu"; import { createSplitGridImageNodes } from "@/utils/splitGridNodes"; +import { createDerivedImageNode } from "@/utils/derivedImageNodes"; import { SplitGridSelectionOverlay } from "@/components/media/SplitGridSelectionOverlay"; import { SplitGridSelectionToolbar } from "@/components/media/SplitGridSelectionToolbar"; @@ -220,29 +221,22 @@ export function GenerateImageNodeView({ id, data, selected }: GenerateImageNodeV : typeof currentNode?.style?.width === "number" ? currentNode.style.width : 300; - const nextNodeId = addNode("smartImage", { - x: currentPosition.x + currentWidth + 220, - y: currentPosition.y, - }, { + createDerivedImageNode({ + sourceNodeId: id, + position: { + x: currentPosition.x + currentWidth + 220, + y: currentPosition.y, + }, image: uploaded.url, - imageRef: undefined, previewImage: uploaded.previewUrl, filename, dimensions: { width: payload.outputWidth, height: payload.outputHeight }, label: t("imageInput.crop"), - derivedImage: { - sourceNodeId: id, - operation: "crop", - }, - disablePreviewModal: true, - }); - onConnect({ - source: id, - sourceHandle: SINGLE_OUTPUT_HANDLE_ID, - target: nextNodeId, - targetHandle: SINGLE_INPUT_HANDLE_ID, + operation: "crop", + addNode, + onConnect, + selectSingleNode, }); - selectSingleNode(nextNodeId); setIsCropping(false); } catch (error) { alert(error instanceof Error ? error.message : t("imageInput.cropFailed")); diff --git a/src/components/nodes/ImageInputNode.tsx b/src/components/nodes/ImageInputNode.tsx index 8cd43a7c..51fb366d 100644 --- a/src/components/nodes/ImageInputNode.tsx +++ b/src/components/nodes/ImageInputNode.tsx @@ -27,6 +27,7 @@ import { CropRatioSelect, MEDIA_EDIT_TOOL, MediaEditToolbar, type MediaEditTool, import { createCroppedImageBlob } from "@/components/media/imageCropUtils"; import { SplitGridCapsuleMenu, type SplitGridSelection } from "@/components/media/SplitGridCapsuleMenu"; import { createSplitGridImageNodes } from "@/utils/splitGridNodes"; +import { createDerivedImageNode } from "@/utils/derivedImageNodes"; import { SplitGridSelectionOverlay } from "@/components/media/SplitGridSelectionOverlay"; import { SplitGridSelectionToolbar } from "@/components/media/SplitGridSelectionToolbar"; @@ -326,29 +327,22 @@ export function ImageInputNodeView({ id, data, selected, renderInputHandle = fal : typeof currentNode?.style?.width === "number" ? currentNode.style.width : IMAGE_INPUT_DIMENSIONS.width; - const nextNodeId = addNode("smartImage", { - x: currentPosition.x + currentWidth + 220, - y: currentPosition.y, - }, { + createDerivedImageNode({ + sourceNodeId: id, + position: { + x: currentPosition.x + currentWidth + 220, + y: currentPosition.y, + }, image: uploaded.url, - imageRef: undefined, previewImage: uploaded.previewUrl, filename, dimensions: { width: payload.outputWidth, height: payload.outputHeight }, label: t("imageInput.crop"), - derivedImage: { - sourceNodeId: id, - operation: "crop", - }, - disablePreviewModal: true, - }); - onConnect({ - source: id, - sourceHandle: SINGLE_OUTPUT_HANDLE_ID, - target: nextNodeId, - targetHandle: SINGLE_INPUT_HANDLE_ID, + operation: "crop", + addNode, + onConnect, + selectSingleNode, }); - selectSingleNode(nextNodeId); setIsCropping(false); } catch (error) { alert(error instanceof Error ? error.message : t("imageInput.cropFailed")); diff --git a/src/utils/__tests__/generationConfig.test.ts b/src/utils/__tests__/generationConfig.test.ts index 7e5aab7e..d03c7c03 100644 --- a/src/utils/__tests__/generationConfig.test.ts +++ b/src/utils/__tests__/generationConfig.test.ts @@ -179,6 +179,76 @@ describe("generationConfig", () => { }); }); + it("ignores original model switch values that are not in schema options", () => { + const draft = buildGenerationConfigDraftFromModelSchema( + { + prompt: "prompt", + selectedModel: defaultModel, + inputImages: [], + imageCount: 1, + referenceSubjectList: [], + parameters: { + ratio: "4:3", + resolution: "520P", + duration: 10, + }, + extraTaskParams: { + quality: "legacy", + }, + }, + { + parameters: [ + { + name: "ratio", + type: "aspectRatio", + default: "16:9", + options: [ + { label: "16:9", value: "16:9" }, + { label: "9:16", value: "9:16" }, + ], + }, + { + name: "resolution", + type: "string", + default: "720P", + options: [ + { label: "720P", value: "720P" }, + { label: "1K", value: "1K" }, + ], + }, + { + name: "duration", + type: "integer", + default: 6, + options: [ + { label: "5秒", value: 5 }, + { label: "8秒", value: 8 }, + ], + }, + ], + extensionFields: [ + { + name: "quality", + default: "high", + options: [ + { label: "High", value: "high" }, + { label: "Ultra", value: "ultra" }, + ], + }, + ], + } + ); + + expect(draft.parameters).toEqual({ + ratio: "16:9", + resolution: "720P", + duration: 5, + }); + expect(draft.extraTaskParams).toEqual({ + quality: "high", + }); + }); + it("builds submittable configs by removing unsupported and empty schema values", () => { const config = buildSubmittableGenerationConfig( { diff --git a/src/utils/derivedImageNodes.ts b/src/utils/derivedImageNodes.ts new file mode 100644 index 00000000..c9b6326d --- /dev/null +++ b/src/utils/derivedImageNodes.ts @@ -0,0 +1,58 @@ +import type { Connection, XYPosition } from "@xyflow/react"; +import type { SmartImageNodeData } from "@/types"; +import { SINGLE_INPUT_HANDLE_ID, SINGLE_OUTPUT_HANDLE_ID } from "@/utils/nodeHandles"; + +export type DerivedImageOperation = NonNullable["operation"]; + +export interface CreateDerivedImageNodeOptions { + sourceNodeId: string; + position: XYPosition; + image: string; + previewImage?: string; + filename: string; + dimensions?: { width: number; height: number } | null; + label: string; + operation: DerivedImageOperation; + addNode: (type: "smartImage", position: XYPosition, initialData?: Partial) => string; + onConnect: (connection: Connection) => void; + selectSingleNode?: (nodeId: string) => void; +} + +export function createDerivedImageNode({ + sourceNodeId, + position, + image, + previewImage, + filename, + dimensions = null, + label, + operation, + addNode, + onConnect, + selectSingleNode, +}: CreateDerivedImageNodeOptions): string { + const nodeId = addNode("smartImage", position, { + image, + imageRef: undefined, + previewImage, + assetId: undefined, + filename, + dimensions, + label, + derivedImage: { + sourceNodeId, + operation, + }, + disablePreviewModal: true, + }); + + onConnect({ + source: sourceNodeId, + sourceHandle: SINGLE_OUTPUT_HANDLE_ID, + target: nodeId, + targetHandle: SINGLE_INPUT_HANDLE_ID, + }); + + selectSingleNode?.(nodeId); + return nodeId; +} diff --git a/src/utils/generationConfig.ts b/src/utils/generationConfig.ts index f0bd1f6a..a2093706 100644 --- a/src/utils/generationConfig.ts +++ b/src/utils/generationConfig.ts @@ -122,6 +122,32 @@ function hasOwnNonEmptyParameterValue(parameters: Record, name: return hasOwnParameterValue(parameters, name) && hasParameterValue(parameters[name]); } +function optionValueMatches(optionValue: unknown, value: unknown): boolean { + return Object.is(optionValue, value) || String(optionValue).toLowerCase() === String(value ?? "").toLowerCase(); +} + +function valueAllowedByOptions( + options: Array<{ value: unknown }> | undefined, + value: unknown +): boolean { + if (!options || options.length === 0) return true; + return options.some((option) => optionValueMatches(option.value, value)); +} + +function draftValueFromSchemaOptions( + currentValue: unknown, + defaultValue: unknown, + options: Array<{ value: unknown }> | undefined +): unknown { + if (hasParameterValue(currentValue) && valueAllowedByOptions(options, currentValue)) { + return currentValue; + } + if (hasParameterValue(defaultValue) && valueAllowedByOptions(options, defaultValue)) { + return defaultValue; + } + return options?.[0]?.value ?? null; +} + export function buildParameterDraftFromSchema( currentParameters: Record | undefined, schema: ModelParameter[] @@ -130,7 +156,11 @@ export function buildParameterDraftFromSchema( return Object.fromEntries( schema.map((parameter) => [ parameter.name, - hasOwnNonEmptyParameterValue(source, parameter.name) ? source[parameter.name] : parameter.default ?? null, + draftValueFromSchemaOptions( + hasOwnNonEmptyParameterValue(source, parameter.name) ? source[parameter.name] : undefined, + parameter.default, + parameter.options + ), ]) ); } @@ -160,7 +190,11 @@ export function buildExtraTaskParamDraftFromSchema( return Object.fromEntries( fields.map((field) => [ field.name, - hasOwnNonEmptyParameterValue(source, field.name) ? source[field.name] : field.default, + draftValueFromSchemaOptions( + hasOwnNonEmptyParameterValue(source, field.name) ? source[field.name] : undefined, + field.default, + field.options + ), ]) ); } diff --git a/src/utils/splitGridNodes.ts b/src/utils/splitGridNodes.ts index 5bdf9335..be86486e 100644 --- a/src/utils/splitGridNodes.ts +++ b/src/utils/splitGridNodes.ts @@ -3,7 +3,7 @@ import type { Connection } from "@xyflow/react"; import type { SmartImageNodeData, WorkflowNode, WorkflowNodeData } from "@/types"; import { defaultNodeDimensions } from "@/constants/nodeDimensions"; import { splitSelectedWithDimensions } from "@/utils/gridSplitter"; -import { SINGLE_INPUT_HANDLE_ID, SINGLE_OUTPUT_HANDLE_ID } from "@/utils/nodeHandles"; +import { createDerivedImageNode } from "@/utils/derivedImageNodes"; interface SplitGridNodeCreationOptions { sourceNode: WorkflowNode; @@ -69,34 +69,21 @@ export async function createSplitGridImageNodes({ if (!selectedCell) return; const rowIndex = Math.floor(index / Math.max(1, Math.ceil(Math.sqrt(images.length)))); const colIndex = index % Math.max(1, Math.ceil(Math.sqrt(images.length))); - const nodeId = addNode( - "smartImage", - { + const nodeId = createDerivedImageNode({ + sourceNodeId: sourceNode.id, + position: { x: start.x + colIndex * (nodeWidth + gap), y: start.y + rowIndex * (nodeHeight + gap), }, - { - image, - imageRef: undefined, - previewImage: undefined, - assetId: undefined, - filename: `split-${selectedCell.row}-${selectedCell.col}.png`, - dimensions: null, - label: `${selectedCell.row}-${selectedCell.col}`, - derivedImage: { - sourceNodeId: sourceNode.id, - operation: "splitGrid", - }, - disablePreviewModal: true, - } - ); - createdNodeIds.push(nodeId); - onConnect({ - source: sourceNode.id, - sourceHandle: SINGLE_OUTPUT_HANDLE_ID, - target: nodeId, - targetHandle: SINGLE_INPUT_HANDLE_ID, + image, + filename: `split-${selectedCell.row}-${selectedCell.col}.png`, + dimensions: null, + label: `${selectedCell.row}-${selectedCell.col}`, + operation: "splitGrid", + addNode, + onConnect, }); + createdNodeIds.push(nodeId); void readImageDimensions(image).then((dimensions) => { if (!dimensions) return;