From 051ef25bbb0012d4f9910c6350cb0496e98990d3 Mon Sep 17 00:00:00 2001 From: yun Date: Sat, 4 Jul 2026 11:51:49 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E4=BB=B7=E6=A0=BC=E9=97=AE?= =?UTF-8?q?=E9=A2=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/app/api/points/quote/route.ts | 4 + .../__tests__/GenerationComposer.test.tsx | 78 ++++++++++++++++ .../composer/GenerationComposer.tsx | 63 ++++++++++++- .../generationPreferenceStore.test.ts | 11 ++- src/utils/__tests__/generationConfig.test.ts | 89 ++++++++++++------- src/utils/defaultGenerationNodeModel.ts | 4 +- src/utils/generationConfig.ts | 3 +- 7 files changed, 211 insertions(+), 41 deletions(-) diff --git a/src/app/api/points/quote/route.ts b/src/app/api/points/quote/route.ts index 7dd4bf53..a068fa6d 100644 --- a/src/app/api/points/quote/route.ts +++ b/src/app/api/points/quote/route.ts @@ -91,6 +91,10 @@ export async function POST(request: NextRequest) { const source = readSource(body); const upstreamBody = buildUpstreamBody(source, readPayload(body)); + console.info("[points quote] upstream params", { + source, + payload: upstreamBody, + }); const url = getPopiartApiBaseUrl() + ( source === "estimate" ? UPSTREAM_ESTIMATE_POINTS_PATH : UPSTREAM_CALCULATE_TASK_PRICE_PATH ); diff --git a/src/components/__tests__/GenerationComposer.test.tsx b/src/components/__tests__/GenerationComposer.test.tsx index 45cfe4e5..120a72bf 100644 --- a/src/components/__tests__/GenerationComposer.test.tsx +++ b/src/components/__tests__/GenerationComposer.test.tsx @@ -1521,6 +1521,84 @@ describe("GenerationComposer", () => { }); }); + it("hydrates missing image node settings from model detail defaults when opened", async () => { + useModelStore.setState({ + detailsById: { + "apiyi_nano_banana_2": { + loadedAt: Date.now(), + parameters: [ + { + name: "ratio", + type: "string", + label: "Ratio", + options: [ + { label: "16:9", value: "16:9" }, + { label: "9:16", value: "9:16" }, + ], + default: "9:16", + }, + ], + inputs: [], + }, + }, + }); + useWorkflowStore.setState({ + nodes: [ + { + ...imageNode("img-1", true, { aspectRatio: "16:9", parameters: {} }), + width: 360, + height: 203, + measured: { width: 360, height: 203 }, + } as WorkflowNode, + ], + }); + + render(); + + await waitFor(() => { + const node = useWorkflowStore.getState().nodes[0]; + const data = node.data as NanoBananaNodeData; + expect(data.parameters).toEqual({ ratio: "9:16" }); + expect(data.aspectRatio).toBe("9:16"); + expect(node.width).toBe(203); + expect(node.height).toBe(360); + }); + }); + + it("does not overwrite existing image node settings when hydrating model detail defaults", async () => { + useModelStore.setState({ + detailsById: { + "apiyi_nano_banana_2": { + loadedAt: Date.now(), + parameters: [ + { + name: "ratio", + type: "string", + label: "Ratio", + options: [ + { label: "16:9", value: "16:9" }, + { label: "9:16", value: "9:16" }, + ], + default: "9:16", + }, + ], + inputs: [], + }, + }, + }); + useWorkflowStore.setState({ + nodes: [imageNode("img-1", true, { aspectRatio: "16:9", parameters: { ratio: "16:9" } })], + }); + + render(); + + await waitFor(() => { + const data = useWorkflowStore.getState().nodes[0].data as NanoBananaNodeData; + expect(data.parameters).toEqual({ ratio: "16:9" }); + expect(data.aspectRatio).toBe("16:9"); + }); + }); + it("renders extension fields in the settings popover and submits them as extraTaskParams", async () => { const regenerateNode = vi.fn().mockResolvedValue(undefined) as typeof originalActions.regenerateNode; useWorkflowStore.setState({ diff --git a/src/components/composer/GenerationComposer.tsx b/src/components/composer/GenerationComposer.tsx index a66d3fc6..03b4eaeb 100644 --- a/src/components/composer/GenerationComposer.tsx +++ b/src/components/composer/GenerationComposer.tsx @@ -23,6 +23,7 @@ import { type ComposerContext, } from "@/utils/composerNodeDescriptor"; import { + buildGenerationConfigDraftFromModelSchema, buildInitialGenerationNodeData, buildSubmittableGenerationConfig, filterTaskRoutingParameters, @@ -99,6 +100,7 @@ const PROMPT_LINE_HEIGHT_PX = 24; const PROMPT_MIN_HEIGHT_PX = PROMPT_LINE_HEIGHT_PX * 2; const PROMPT_MAX_HEIGHT_PX = PROMPT_LINE_HEIGHT_PX * 4; const EMPTY_EXTRA_TASK_PARAMS: Record = {}; +const ASPECT_RATIO_PARAMETER_NAMES = ["ratio", "aspectRatio", "aspect_ratio", "imageRatio", "image_ratio"]; const EMPTY_SELECTED_MODEL: SelectedModel = { provider: "popiserver", @@ -208,6 +210,18 @@ function buildComposerGenerationConfig(config: ComposerConfig, prompt: string): }; } +function readAspectRatioFromComposerConfig(config: Pick): string | undefined { + for (const name of ASPECT_RATIO_PARAMETER_NAMES) { + const value = config.parameters?.[name]; + if (typeof value === "string" && value.trim()) return value.trim(); + } + return undefined; +} + +function composerConfigEquals(a: ComposerConfig, b: ComposerConfig): boolean { + return JSON.stringify(a) === JSON.stringify(b); +} + function referenceSubjectsToMaterials(subjects: ComposerConfig["referenceSubjectList"]): ComposerReferenceMaterial[] { return readReferenceSubjectList(subjects).map((subject, index) => ({ id: index + 1, @@ -317,12 +331,14 @@ function buildComposerConfigPersistPatch(node: WorkflowNode, config: GenerationN const nodeType = node.type; const parameters = buildComposerPersistParameters(node, config); if (nodeType === "smartImage" || nodeType === "nanoBanana") { + const aspectRatio = readAspectRatioFromComposerConfig(config); return { inputPrompt: config.prompt.trim(), selectedModel: config.selectedModel, batchSize: config.batchSize, parameters, extraTaskParams: config.extraTaskParams ?? {}, + ...(aspectRatio ? { aspectRatio } : {}), } as Partial; } if (nodeType === "smartVideo" || nodeType === "generateVideo") { @@ -713,6 +729,7 @@ function ComposerEditor({ const suppressAutoPriceRefreshRef = useRef(false); const modelChangeCompletionTimerRef = useRef(null); const priceRefreshTimerRef = useRef(null); + const hydratedModelDefaultsRef = useRef>(new Set()); const pricingInputsRef = useRef({ config, prompt, @@ -777,6 +794,41 @@ function ComposerEditor({ : storedModelFitsCapability(config.selectedModel, context.capability); const canChooseModel = context.mode === "empty-create" || context.mode === "node-edit"; + useEffect(() => { + if (isHidden) return; + if (context.mode !== "node-edit" || !context.node) return; + if (!activeModelDetail || !config.selectedModel.modelId) return; + const hydrationKey = `${context.node.id}:${config.selectedModel.modelId}`; + if (hydratedModelDefaultsRef.current.has(hydrationKey)) return; + hydratedModelDefaultsRef.current.add(hydrationKey); + + const { prompt: _prompt, ...hydratedConfig } = buildGenerationConfigDraftFromModelSchema( + buildGenerationConfigForContext(context), + activeModelDetail + ); + let shouldPersistAspectRatio = false; + if (context.capability === "image") { + const aspectRatio = readAspectRatioFromComposerConfig(hydratedConfig); + if (aspectRatio) { + onApplyNodeSize(context.node.id, aspectRatio); + shouldPersistAspectRatio = (context.node.data as { aspectRatio?: unknown }).aspectRatio !== aspectRatio; + } + } + if (composerConfigEquals(config, hydratedConfig) && !shouldPersistAspectRatio) return; + + setConfig(hydratedConfig); + onPersistConfig(context, buildGenerationConfigForContext(context, hydratedConfig, prompt)); + }, [ + activeModelDetail, + buildGenerationConfigForContext, + config, + context, + isHidden, + onApplyNodeSize, + onPersistConfig, + prompt, + ]); + const connectedReferenceImages = context.mode === "node-edit" ? connectedInputs?.images ?? [] : []; const connectedReferenceAudio = context.mode === "node-edit" ? connectedInputs?.audio ?? [] : []; const referenceImages = connectedReferenceImages.length > 0 ? connectedReferenceImages : config.inputImages; @@ -910,7 +962,7 @@ function ComposerEditor({ generationConfig, detail ?? inputs.activeModelDetail ?? { parameters: inputs.modelSchema, extensionFields: inputs.extensionFields } ); - const quoteRequest = buildPopiserverPriceQuoteRequest(buildComposerPricingContext({ + const pricingContextInput = { config: normalizedConfig, prompt: nextPrompt, capability: context.capability, @@ -918,12 +970,17 @@ function ComposerEditor({ modelDetail: detail, media: inputs.popiserverPricingMedia, inputVideoDurationSeconds: inputs.connectedVideoDurationSeconds, - })); + }; + console.info("[points quote] build input params", pricingContextInput); + + const quoteRequest = buildPopiserverPriceQuoteRequest(buildComposerPricingContext(pricingContextInput)); if (!quoteRequest) { setPriceState({ amount: null, isLoading: false }); return; } + console.info("[points quote] request params", quoteRequest); + try { const response = await fetch("/api/points/quote", { method: "POST", @@ -961,7 +1018,6 @@ function ComposerEditor({ }, [config, context.node, updateNodeExtraTaskParams]); const schedulePriceRefresh = useCallback(() => { - console.log('priceRefreshTimerRef -----------', priceRefreshTimerRef.current) if (priceRefreshTimerRef.current !== null) { window.clearTimeout(priceRefreshTimerRef.current); } @@ -971,7 +1027,6 @@ function ComposerEditor({ if (latest.isHidden) return; if (suppressAutoPriceRefreshRef.current) return; if (!latest.config.selectedModel.modelId) return; - console.log('refreshPrice ---------', latest) void refreshPrice(latest.config, latest.prompt, latest.activeModelDetail, latest); }, 200); }, [refreshPrice]); diff --git a/src/store/__tests__/generationPreferenceStore.test.ts b/src/store/__tests__/generationPreferenceStore.test.ts index ff7f6e43..2c12366d 100644 --- a/src/store/__tests__/generationPreferenceStore.test.ts +++ b/src/store/__tests__/generationPreferenceStore.test.ts @@ -156,6 +156,7 @@ describe("generationPreferenceStore", () => { provider: "popiserver", capabilities: ["text-to-video", "image-to-video"], metadata: { + billingMethod: 2, aiModelId: 9001, aiModelCode: "seedance-video-pro", aiModelCodeAlias: "seedance-pro", @@ -185,15 +186,17 @@ describe("generationPreferenceStore", () => { provider: "popiserver", modelId: "44", displayName: "seedance 2.0-fast", + billingMethod: 2, }, }); expect(useGenerationPreferenceStore.getState().video.model.modelId).toBe("44"); expect(useGenerationPreferenceStore.getState().video.model.displayName).toBe("seedance 2.0-fast"); + expect(useGenerationPreferenceStore.getState().video.model.billingMethod).toBe(2); await expect(buildDefaultGenerationNodeCreation("generateVideo")).resolves.toEqual({ data: expect.objectContaining({ parameters: { ratio: "9:16" }, - selectedModel: expect.objectContaining({ modelId: "44" }), + selectedModel: expect.objectContaining({ modelId: "44", billingMethod: 2 }), }), size: { width: 203, height: 360 }, }); @@ -230,6 +233,7 @@ describe("generationPreferenceStore", () => { description: null, provider: "popiserver", capabilities: ["text-to-image"], + metadata: { billingMethod: 2 }, }, { id: "vid-44", @@ -237,6 +241,7 @@ describe("generationPreferenceStore", () => { description: null, provider: "popiserver", capabilities: ["text-to-video"], + metadata: { billingMethod: 1 }, }, { id: "aud-55", @@ -265,10 +270,10 @@ describe("generationPreferenceStore", () => { const llmData = await buildDefaultGenerationNodeData("llmGenerate"); expect(imageData).toMatchObject({ - selectedModel: { modelId: "img-32", displayName: DEFAULT_IMAGE_GENERATION_MODEL_NAME }, + selectedModel: { modelId: "img-32", displayName: DEFAULT_IMAGE_GENERATION_MODEL_NAME, billingMethod: 2 }, }); expect(videoData).toMatchObject({ - selectedModel: { modelId: "vid-44", displayName: DEFAULT_VIDEO_GENERATION_MODEL_NAME }, + selectedModel: { modelId: "vid-44", displayName: DEFAULT_VIDEO_GENERATION_MODEL_NAME, billingMethod: 1 }, }); expect(audioData).toMatchObject({ selectedModel: { modelId: "aud-55", displayName: DEFAULT_AUDIO_GENERATION_MODEL_NAME }, diff --git a/src/utils/__tests__/generationConfig.test.ts b/src/utils/__tests__/generationConfig.test.ts index 233d0da7..27e4e4e7 100644 --- a/src/utils/__tests__/generationConfig.test.ts +++ b/src/utils/__tests__/generationConfig.test.ts @@ -85,7 +85,7 @@ describe("generationConfig", () => { }); }); - it("merges extra task params into runtime generation parameters", () => { + it("keeps model params and extra task params in separate runtime buckets", () => { const config = { prompt: "prompt", selectedModel: defaultModel, @@ -117,9 +117,6 @@ describe("generationConfig", () => { expect(buildRuntimeGenerationParameters(config)).toEqual({ width: 1024, height: 768, - horizontal_angle: 15, - vertical_angle: 30, - zoom: 5, }); }); @@ -380,36 +377,62 @@ describe("generationConfig", () => { ).toEqual({ resolution: "720P" }); }); - it("builds Popiserver task price payload from stored model parameters", () => { + it("keeps extra task params separate from runtime generation parameters", () => { expect( - buildPopiserverTaskPricePayload( - { - prompt: "prompt", - selectedModel: { - provider: "popiserver", - modelId: "37", - displayName: "Popi Video", - capabilities: ["text-to-video"], - billingMethod: 1, - aiModelId: 37, - type: 2, - subType: POPI_VIDEO_SUBTYPE_IMAGE_TO_VIDEO, - aiModelCode: "seedance", - }, - inputImages: [], - batchSize: 4, + buildRuntimeGenerationParameters({ + prompt: "prompt", + selectedModel: defaultModel, + inputImages: [], + batchSize: 1, + referenceSubjectList: [], + parameters: { + resolution: "std", + ratio: "16:9", + duration: 4, + }, + extraTaskParams: { + sound: "on", + }, + }) + ).toEqual({ + resolution: "std", + ratio: "16:9", + duration: 4, + }); + }); + + it("builds Popiserver task price payload from stored model parameters", () => { + const payload = buildPopiserverTaskPricePayload( + { + prompt: "prompt", + selectedModel: { + provider: "popiserver", + modelId: "37", + displayName: "Popi Video", + capabilities: ["text-to-video"], + billingMethod: 1, + aiModelId: 37, + type: 2, subType: POPI_VIDEO_SUBTYPE_IMAGE_TO_VIDEO, - referenceSubjectList: [], - parameters: { - resolution: "720p", - duration: 4, - videoRatio: 20, - }, - extraTaskParams: {}, + aiModelCode: "seedance", }, - "video" - ) - ).toMatchObject({ + inputImages: [], + batchSize: 4, + subType: POPI_VIDEO_SUBTYPE_IMAGE_TO_VIDEO, + referenceSubjectList: [], + parameters: { + resolution: "720p", + duration: 4, + videoRatio: 20, + }, + extraTaskParams: { + sound: "on", + }, + }, + "video" + ); + + expect(payload).toMatchObject({ type: 2, subType: POPI_VIDEO_SUBTYPE_IMAGE_TO_VIDEO, aiModelId: 37, @@ -420,6 +443,10 @@ describe("generationConfig", () => { resolution: "720p", duration: 4, videoRatio: 20, + extraTaskParams: { + sound: "on", + }, }); + expect(payload?.sound).toBeUndefined(); }); }); diff --git a/src/utils/defaultGenerationNodeModel.ts b/src/utils/defaultGenerationNodeModel.ts index 4202dfbc..3f5b91c2 100644 --- a/src/utils/defaultGenerationNodeModel.ts +++ b/src/utils/defaultGenerationNodeModel.ts @@ -7,6 +7,7 @@ import { useGenerationPreferenceStore } from "@/store/generationPreferenceStore" import { filterTaskRoutingParameters } from "@/utils/generationConfig"; import { inferNodeSizeFromData } from "@/utils/nodeSizing"; import { DEFAULT_POPISERVER_LLM_MODEL_NAME } from "@/lib/llmModels"; +import { toSelectedModel } from "@/utils/selectedModel"; type GenerationNodeType = "smartImage" | "smartVideo" | "smartAudio" | "smartText" | "nanoBanana" | "generateVideo" | "generateAudio" | "llmGenerate"; @@ -44,8 +45,7 @@ async function resolvePreferenceModel(type: DefaultModelKind, selectedModel: Sel if (!model) return selectedModel; const resolved: SelectedModel = { ...selectedModel, - provider: model.provider, - modelId: model.id, + ...toSelectedModel(model), displayName: selectedModel.displayName || model.name, }; const preferences = useGenerationPreferenceStore.getState(); diff --git a/src/utils/generationConfig.ts b/src/utils/generationConfig.ts index f1e86d4d..365379a1 100644 --- a/src/utils/generationConfig.ts +++ b/src/utils/generationConfig.ts @@ -382,7 +382,6 @@ export function buildRuntimeGenerationParameters(config: GenerationNodeConfig): const referenceSubjectList = config.referenceSubjectList ?? []; return { ...buildRuntimeModelParameters(config), - ...buildRuntimeExtraTaskParams(config), ...(referenceSubjectList.length > 0 ? { referenceSubjectList } : {}), }; } @@ -457,6 +456,7 @@ export function buildPopiserverPricingParameterPayload( if (model.provider !== "popiserver") return null; const dynamicParameters = buildRuntimeGenerationParameters(config); + const dynamicExtraTaskParams = buildRuntimeExtraTaskParams(config); const input: GenerationInput = { model: { id: model.modelId, @@ -480,6 +480,7 @@ export function buildPopiserverPricingParameterPayload( voices: media.voices ?? [], subType: config.subType, parameters: dynamicParameters, + extraTaskParams: dynamicExtraTaskParams, referenceSubjectList: config.referenceSubjectList, audioVoiceId: config.audioVoiceId, batchSize: config.batchSize,