diff --git a/src/app/api/generate/providers/__tests__/popiserver.test.ts b/src/app/api/generate/providers/__tests__/popiserver.test.ts index 23359b82..d441a7af 100644 --- a/src/app/api/generate/providers/__tests__/popiserver.test.ts +++ b/src/app/api/generate/providers/__tests__/popiserver.test.ts @@ -93,11 +93,92 @@ describe("popiserver generation provider", () => { aiModelCodeAlias: "viduq2-pro", batchSize: 1, ratio: "16:9", + aspectRatio: "16:9", resolution: "720", videoRatio: 10, }); }); + it("embeds ratio and resolution in chatPrompt for gpt-image-2-vip", async () => { + vi.mocked(global.fetch).mockResolvedValue({ + ok: true, + json: () => Promise.resolve({ + status: "0000", + message: "ok", + data: { id: 1188 }, + }), + } as Response); + + const input = makeInput(); + input.model = { + ...input.model, + id: "gpt-image-2-vip", + name: "GPT Image 2 VIP", + capabilities: ["text-to-image"], + metadata: { + type: 1, + subType: 102, + aiModelId: 88, + aiModelCode: "gpt-image-2-vip", + aiModelCodeAlias: "gpt-image-2-vip", + }, + }; + input.prompt = "create a cinematic cat"; + input.parameters = { + ratio: "16:9", + resolution: "1K", + }; + + await submitPopiTask(makeRequest(), "login-token", input); + + const [, init] = vi.mocked(global.fetch).mock.calls[0]; + expect(JSON.parse(String(init?.body))).toMatchObject({ + chatPrompt: "比例:16:9\n清晰度:1K\n提示词:create a cinematic cat", + ratio: "16:9", + resolution: "1K", + }); + }); + + it("embeds ratio and resolution in chatPrompt for gpt-image-2-all", async () => { + vi.mocked(global.fetch).mockResolvedValue({ + ok: true, + json: () => Promise.resolve({ + status: "0000", + message: "ok", + data: { id: 1187 }, + }), + } as Response); + + const input = makeInput(); + input.model = { + ...input.model, + id: "gpt-image-2-all", + name: "GPT Image 2 All", + capabilities: ["text-to-image"], + metadata: { + type: 1, + subType: 102, + aiModelId: 87, + aiModelCode: "gpt-image-2-all", + aiModelCodeAlias: "gpt-image-2-all", + }, + }; + input.prompt = "create a clean product render"; + input.parameters = { + aspectRatio: "9:16", + resolution: "2K", + }; + + await submitPopiTask(makeRequest(), "login-token", input); + + const [, init] = vi.mocked(global.fetch).mock.calls[0]; + expect(JSON.parse(String(init?.body))).toMatchObject({ + chatPrompt: "比例:9:16\n清晰度:2K\n提示词:create a clean product render", + aspectRatio: "9:16", + resolution: "2K", + }); + }); + it("submits audio task create with the gateway audio request shape", async () => { vi.mocked(global.fetch).mockResolvedValue({ ok: true, diff --git a/src/app/api/generate/providers/popiserver.ts b/src/app/api/generate/providers/popiserver.ts index 763524b1..3a3093bc 100644 --- a/src/app/api/generate/providers/popiserver.ts +++ b/src/app/api/generate/providers/popiserver.ts @@ -387,7 +387,7 @@ function inferTaskSubType(input: GenerationInput, type: number): number { } function resolveAspectRatio(parameters: Record | undefined): string { - return asString(parameters?.ratio) ?? "16:9"; + return asString(parameters?.ratio) ?? asString(parameters?.aspectRatio) ?? "16:9"; } function dimensionsForAspectRatio(aspectRatio: string): { width: number; height: number } { @@ -396,6 +396,32 @@ function dimensionsForAspectRatio(aspectRatio: string): { width: number; height: return { width: 1280, height: 720 }; } +function needsPromptEmbeddedParameters(modelCode: string, modelAlias: string): boolean { + return ( + modelCode === "gpt-image-2-vip" || + modelCode === "gpt-image-2-all" || + modelAlias === "gpt-image-2-vip" || + modelAlias === "gpt-image-2-all" + ); +} + +function buildPopiChatPrompt( + prompt: string, + parameters: Record, + aspectRatio: string, + modelCode: string, + modelAlias: string +): string { + if (!needsPromptEmbeddedParameters(modelCode, modelAlias)) return prompt; + + const resolution = asString(parameters.resolution) ?? "1K"; + return [ + `比例:${aspectRatio}`, + `清晰度:${resolution}`, + `提示词:${prompt}`, + ].join("\n"); +} + function buildPopiTaskBody(input: GenerationInput, modelDetail: PopiAIModel | null): Record { const parameters = input.parameters || {}; const type = asNumber(parameters.type) ?? inferTaskType(input); @@ -427,7 +453,7 @@ function buildPopiTaskBody(input: GenerationInput, modelDetail: PopiAIModel | nu const commonBody = { type, - chatPrompt: input.prompt, + chatPrompt: buildPopiChatPrompt(input.prompt, parameters, aspectRatio, modelCode, modelAlias), model: modelAlias, origin: "canvas", aiPlatform: "GATEWAY", diff --git a/src/components/__tests__/GenerationComposer.test.tsx b/src/components/__tests__/GenerationComposer.test.tsx index 69ba2c2a..6215587f 100644 --- a/src/components/__tests__/GenerationComposer.test.tsx +++ b/src/components/__tests__/GenerationComposer.test.tsx @@ -714,11 +714,63 @@ describe("GenerationComposer", () => { }); }); + it("renders gateway image dimension options by schema name", () => { + useWorkflowStore.setState({ + nodes: [ + imageNode("img-1", true, { + selectedModel: { + provider: "popiserver", + modelId: "37", + displayName: "Gateway Image", + capabilities: ["text-to-image", "image-to-image"], + }, + parameterSchema: [ + { + name: "ratio", + type: "string", + label: "Ratio", + options: [ + { label: "16:9", value: "16:9" }, + { label: "9:16", value: "9:16" }, + ], + default: "16:9", + }, + { + name: "resolution", + type: "string", + label: "Resolution", + options: [ + { label: "720p", value: "720" }, + { label: "4k", value: "4k" }, + ], + default: "720", + }, + ], + parameters: {}, + }), + ], + }); + + render(); + + act(() => { + fireEvent.change(screen.getByLabelText("Ratio"), { target: { value: "9:16" } }); + fireEvent.change(screen.getByLabelText("Resolution"), { target: { value: "4k" } }); + }); + + const data = useWorkflowStore.getState().nodes[0].data as NanoBananaNodeData; + expect(data.parameters).toMatchObject({ + ratio: "9:16", + resolution: "4k", + }); + }); + it("does not show PopiServer task price estimates for token-billed models", async () => { const fetchMock = vi.fn(async (input: RequestInfo | URL) => { throw new Error(`Unexpected fetch: ${String(input)}`); }); vi.stubGlobal("fetch", fetchMock); + useWorkflowStore.setState({ nodes: [ imageNode("img-1", true, { @@ -918,6 +970,82 @@ describe("GenerationComposer", () => { expect(data.soundEnabled).toBe(false); }); + it("renders gateway video dimension options with raw values", () => { + useWorkflowStore.setState({ + nodes: [ + videoNode("vid-1", true, { + selectedModel: { + provider: "popiserver", + modelId: "38", + displayName: "Gateway Video", + capabilities: ["text-to-video", "image-to-video"], + }, + parameterSchema: [ + { + name: "ratio", + type: "string", + label: "Ratio", + options: [ + { label: "16:9", value: "16:9" }, + { label: "9:16", value: "9:16" }, + ], + default: "16:9", + }, + { + name: "videoRatio", + type: "integer", + label: "Video ratio", + options: [ + { label: "10-10", value: 10 }, + { label: "20-20", value: 20 }, + ], + default: 10, + }, + { + name: "resolution", + type: "string", + label: "Resolution", + options: [ + { label: "720p", value: "720" }, + { label: "1024p", value: "1024" }, + ], + default: "720", + }, + { + name: "duration", + type: "integer", + label: "Duration", + options: [ + { label: "5", value: 5 }, + { label: "4", value: 4 }, + ], + default: 5, + }, + ], + parameters: {}, + }), + ], + }); + + render(); + + act(() => { + fireEvent.change(screen.getByLabelText("Ratio"), { target: { value: "9:16" } }); + fireEvent.change(screen.getByLabelText("Video ratio"), { target: { value: "20" } }); + fireEvent.change(screen.getByLabelText("Resolution"), { target: { value: "1024" } }); + fireEvent.change(screen.getByLabelText("Duration"), { target: { value: "4" } }); + }); + + const data = useWorkflowStore.getState().nodes[0].data as GenerateVideoNodeData; + expect(data.parameters).toEqual({ + ratio: "9:16", + videoRatio: 20, + resolution: "1024", + duration: 4, + }); + expect(data.durationSeconds).toBe(4); + }); + it("shows custom selected video duration in quick settings", () => { useWorkflowStore.setState({ nodes: [videoNode("vid-1", true, { durationSeconds: 6 })], diff --git a/src/components/composer/GenerationComposer.tsx b/src/components/composer/GenerationComposer.tsx index 4f28e85c..ca63d6c2 100644 --- a/src/components/composer/GenerationComposer.tsx +++ b/src/components/composer/GenerationComposer.tsx @@ -207,13 +207,12 @@ function buildGatewayDimensionSelects( .filter((param) => nameSet.has(param.name) && param.options && param.options.length > 0) .map((parameter) => { const options = getSchemaSelectOptions(parameter); - let value = draft.parameters?.[parameter.name]; - if (value === undefined) { - if (parameter.name === "ratio") value = draft.aspectRatio; - if (parameter.name === "resolution") value = draft.resolution; - if (parameter.name === "duration") value = draft.durationSeconds; - } - value ??= parameter.default ?? options[0]?.value; + const value = draft.parameters?.[parameter.name] + ?? (parameter.name === "ratio" ? draft.aspectRatio : undefined) + ?? (parameter.name === "resolution" ? draft.resolution : undefined) + ?? (parameter.name === "duration" ? draft.durationSeconds : undefined) + ?? parameter.default + ?? options[0]?.value; return { parameter, options,