From e8fd649f56ad84f2009292ab0e2a891b951ffb8c Mon Sep 17 00:00:00 2001 From: TianYun Date: Thu, 28 May 2026 15:15:25 +0800 Subject: [PATCH] =?UTF-8?q?=E5=AE=8C=E5=85=A8=E4=BD=BF=E7=94=A8=E5=90=8E?= =?UTF-8?q?=E7=AB=AF=E8=BF=94=E5=9B=9E=E7=9A=84=E5=8F=82=E6=95=B0=E6=B8=B2?= =?UTF-8?q?=E6=9F=93=E9=80=89=E9=A1=B9=E5=88=97?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../api/__tests__/popiserverModels.test.ts | 67 ++++- src/app/api/_popiserverModels.ts | 79 +++++- .../providers/__tests__/popiserver.test.ts | 8 +- src/app/api/generate/providers/popiserver.ts | 6 +- src/app/api/generate/route.ts | 2 +- .../__tests__/GenerationComposer.test.tsx | 42 +++- .../__tests__/ModelParameters.test.tsx | 21 +- .../composer/GenerationComposer.tsx | 231 +++++++++--------- src/components/nodes/GenerateVideoNode.tsx | 8 +- src/components/nodes/ModelParameters.tsx | 62 ++--- src/lib/providers/types.ts | 2 + .../__tests__/generateVideoExecutor.test.ts | 29 +++ src/store/execution/generateVideoExecutor.ts | 4 + 13 files changed, 396 insertions(+), 165 deletions(-) diff --git a/src/app/api/__tests__/popiserverModels.test.ts b/src/app/api/__tests__/popiserverModels.test.ts index 0be78c4e..a95e42b2 100644 --- a/src/app/api/__tests__/popiserverModels.test.ts +++ b/src/app/api/__tests__/popiserverModels.test.ts @@ -1,6 +1,6 @@ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; import type { NextRequest } from "next/server"; -import { fetchPopiModelDetail, fetchPopiModels, popiModelToProviderModel } from "../_popiserverModels"; +import { buildPopiModelParameters, fetchPopiModelDetail, fetchPopiModels, popiModelToProviderModel } from "../_popiserverModels"; const originalFetch = global.fetch; @@ -96,4 +96,69 @@ describe("popiserver model helpers", () => { aiModelCodeAlias: "speech-2.8-hd", }); }); + + it("uses displayDimensions label/value options for model parameters", () => { + const parameters = buildPopiModelParameters({ + id: 37, + code: "ernie-image-turbo", + displayDimensions: JSON.stringify({ + dimensions: { + ratio: { + name: "Ratio", + options: [ + { value: "16:9", label: "16:9" }, + { value: "9:16", label: "9:16" }, + ], + }, + resolution: { + name: "Resolution", + options: [ + { value: "720", label: "720p" }, + { value: "1024", label: "1024p" }, + ], + }, + duration: { + name: "Duration", + options: [ + { value: 5, label: "5" }, + { value: 4, label: "4" }, + ], + }, + }, + }), + ratio: ["1:1"], + resolution: ["480"], + duration: [8], + }); + + expect(parameters).toEqual([ + expect.objectContaining({ + name: "ratio", + label: "Ratio", + options: [ + { value: "16:9", label: "16:9" }, + { value: "9:16", label: "9:16" }, + ], + default: "16:9", + }), + expect.objectContaining({ + name: "resolution", + label: "Resolution", + options: [ + { value: "720", label: "720p" }, + { value: "1024", label: "1024p" }, + ], + default: "720", + }), + expect.objectContaining({ + name: "duration", + label: "Duration", + options: [ + { value: 5, label: "5" }, + { value: 4, label: "4" }, + ], + default: 5, + }), + ]); + }); }); diff --git a/src/app/api/_popiserverModels.ts b/src/app/api/_popiserverModels.ts index e204f4c4..59d55a14 100644 --- a/src/app/api/_popiserverModels.ts +++ b/src/app/api/_popiserverModels.ts @@ -32,6 +32,7 @@ export type PopiAIModel = { name?: string; description?: string; icon?: string; + displayDimensions?: string; ratio?: string[]; duration?: number[]; videoRatio?: number[]; @@ -46,6 +47,20 @@ export type PopiAIModel = { categories?: PopiCategory[]; }; +type PopiDisplayDimensionOption = { + value?: unknown; + label?: unknown; +}; + +type PopiDisplayDimension = { + name?: unknown; + options?: PopiDisplayDimensionOption[]; +}; + +type PopiDisplayDimensionsPayload = { + dimensions?: Record; +}; + type PopiserverModelListData = PopiAIModel[] | { list?: PopiAIModel[]; total?: number; @@ -299,14 +314,68 @@ export function buildPopiModelInputs(model: PopiAIModel): ModelInput[] { return inputs; } +const POPI_DIMENSION_PARAMETER_TYPES: Record = { + duration: "integer", + videoRatio: "integer", +}; + +function parsePopiDisplayDimensions(displayDimensions: string | undefined): Record | null { + if (!displayDimensions) return null; + try { + const parsed = JSON.parse(displayDimensions) as PopiDisplayDimensionsPayload; + return parsed.dimensions && typeof parsed.dimensions === "object" + ? parsed.dimensions + : null; + } catch { + return null; + } +} + +function buildPopiDisplayDimensionParameters(model: PopiAIModel): ModelParameter[] { + const dimensions = parsePopiDisplayDimensions(model.displayDimensions); + if (!dimensions) return []; + + const parameters: ModelParameter[] = []; + for (const [dimensionKey, dimension] of Object.entries(dimensions)) { + const options = Array.isArray(dimension.options) + ? dimension.options + .filter((option) => option.value !== undefined) + .map((option) => ({ + value: option.value, + label: option.label === undefined ? String(option.value) : String(option.label), + })) + : []; + if (options.length === 0) continue; + + const type = POPI_DIMENSION_PARAMETER_TYPES[dimensionKey] + ?? (typeof options[0]?.value === "number" ? "number" : "string"); + + parameters.push({ + name: dimensionKey, + type, + label: typeof dimension.name === "string" ? dimension.name : undefined, + options, + default: options[0]?.value, + required: false, + }); + } + + return parameters; +} + export function buildPopiModelParameters(model: PopiAIModel): ModelParameter[] { + const displayDimensionParameters = buildPopiDisplayDimensionParameters(model); + if (displayDimensionParameters.length > 0) { + return displayDimensionParameters; + } + const parameters: ModelParameter[] = []; if (Array.isArray(model.ratio) && model.ratio.length > 0) { parameters.push({ - name: "aspectRatio", + name: "ratio", type: "string", - enum: model.ratio, + options: model.ratio.map((value) => ({ value, label: value })), default: model.ratio[0], required: false, description: "Aspect ratio", @@ -317,7 +386,7 @@ export function buildPopiModelParameters(model: PopiAIModel): ModelParameter[] { parameters.push({ name: "resolution", type: "string", - enum: model.resolution, + options: model.resolution.map((value) => ({ value, label: value })), default: model.resolution[0], required: false, description: "Resolution", @@ -328,7 +397,7 @@ export function buildPopiModelParameters(model: PopiAIModel): ModelParameter[] { parameters.push({ name: "duration", type: "integer", - enum: model.duration, + options: model.duration.map((value) => ({ value, label: String(value) })), default: model.duration[0], required: false, description: "Duration", @@ -339,7 +408,7 @@ export function buildPopiModelParameters(model: PopiAIModel): ModelParameter[] { parameters.push({ name: "videoRatio", type: "integer", - enum: model.videoRatio, + options: model.videoRatio.map((value) => ({ value, label: String(value) })), default: model.videoRatio[0], required: false, description: "Video ratio: 1 portrait, 2 landscape, 3 auto", diff --git a/src/app/api/generate/providers/__tests__/popiserver.test.ts b/src/app/api/generate/providers/__tests__/popiserver.test.ts index dbeb7617..23359b82 100644 --- a/src/app/api/generate/providers/__tests__/popiserver.test.ts +++ b/src/app/api/generate/providers/__tests__/popiserver.test.ts @@ -31,8 +31,9 @@ function makeInput(): GenerationInput { prompt: "@角色1 和 @角色2 在一起吃火锅", images: ["https://example.com/1.png", "https://example.com/2.png"], parameters: { - aspectRatio: "16:9", - resolution: "720p", + ratio: "16:9", + resolution: "720", + videoRatio: 10, }, }; } @@ -91,6 +92,9 @@ describe("popiserver generation provider", () => { aiModelCode: "viduq2-pro", aiModelCodeAlias: "viduq2-pro", batchSize: 1, + ratio: "16:9", + resolution: "720", + videoRatio: 10, }); }); diff --git a/src/app/api/generate/providers/popiserver.ts b/src/app/api/generate/providers/popiserver.ts index 0ca5d9e5..763524b1 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?.aspectRatio) ?? asString(parameters?.aspect_ratio) ?? "16:9"; + return asString(parameters?.ratio) ?? "16:9"; } function dimensionsForAspectRatio(aspectRatio: string): { width: number; height: number } { @@ -440,6 +440,7 @@ function buildPopiTaskBody(input: GenerationInput, modelDetail: PopiAIModel | nu if (type === 3) { return { + ...parameters, ...commonBody, ...(audios.length > 0 ? { audios: [...new Set(audios)] } : {}), ...(voiceId ? { voiceId } : {}), @@ -447,6 +448,7 @@ function buildPopiTaskBody(input: GenerationInput, modelDetail: PopiAIModel | nu } return { + ...parameters, ...commonBody, ...(images.length > 0 ? { images: [...new Set(images)] } : {}), ...(videos.length > 0 ? { videos: [...new Set(videos)] } : {}), @@ -454,8 +456,8 @@ function buildPopiTaskBody(input: GenerationInput, modelDetail: PopiAIModel | nu styleId: asNumber(parameters.styleId) ?? 0, width: asNumber(parameters.width) ?? dimensions.width, height: asNumber(parameters.height) ?? dimensions.height, - duration: asNumber(parameters.duration) ?? undefined, aspectRatio, + duration: asNumber(parameters.duration) ?? undefined, resolution: asString(parameters.resolution) ?? "720p", }; } diff --git a/src/app/api/generate/route.ts b/src/app/api/generate/route.ts index 012d2681..8e6b1a6e 100644 --- a/src/app/api/generate/route.ts +++ b/src/app/api/generate/route.ts @@ -269,7 +269,7 @@ export async function POST(request: NextRequest) { }, prompt: prompt || "", images: images ? [...images] : [], - parameters: mergeImageGenerationParameters(parameters, aspectRatio, resolution), + parameters, dynamicInputs, }; diff --git a/src/components/__tests__/GenerationComposer.test.tsx b/src/components/__tests__/GenerationComposer.test.tsx index cfa9f53c..9d335a86 100644 --- a/src/components/__tests__/GenerationComposer.test.tsx +++ b/src/components/__tests__/GenerationComposer.test.tsx @@ -166,8 +166,25 @@ function videoNode(id: string, selected = false, data: Partial { expect(onParametersChange).toHaveBeenCalledWith({ prompt: "new value" }); }); - it("renders enum options and coerces numeric selections", () => { + it("renders schema options and preserves numeric values", async () => { const onParametersChange = vi.fn(); render( { createMockParameter({ name: "max_images", type: "integer", - enum: [1, 2, 3], + options: [ + { value: 1, label: "1 image" }, + { value: 2, label: "2 images" }, + { value: 3, label: "3 images" }, + ], }), ]} onParametersChange={onParametersChange} /> ); - fireEvent.change(screen.getByRole("combobox"), { target: { value: "2" } }); + fireEvent.mouseDown(screen.getByRole("combobox")); + fireEvent.click(await screen.findByText("2 images")); expect(onParametersChange).toHaveBeenCalledWith({ max_images: 2 }); }); @@ -121,13 +126,19 @@ describe("ModelParameters", () => { createMockParameter({ name: "quality", type: "string", - enum: ["auto", "high"], + options: [ + { value: "auto", label: "Auto" }, + { value: "high", label: "High" }, + ], default: "auto", }), createMockParameter({ name: "size", type: "string", - enum: ["1024x1024", "1024x1536"], + options: [ + { value: "1024x1024", label: "1024 x 1024" }, + { value: "1024x1536", label: "1024 x 1536" }, + ], default: "1024x1024", }), ]} diff --git a/src/components/composer/GenerationComposer.tsx b/src/components/composer/GenerationComposer.tsx index 3b67dc3e..70cc1f1a 100644 --- a/src/components/composer/GenerationComposer.tsx +++ b/src/components/composer/GenerationComposer.tsx @@ -49,7 +49,6 @@ import { getDefaultVideoSoundEnabled, modelSupportsVideoSound, normalizeVideoDurationSeconds, - VIDEO_DURATION_PARAMETER_NAMES, VIDEO_SOUND_PARAMETER_NAMES, } from "@/utils/videoGenerationSettings"; import { defaultNodeDimensions } from "@/store/utils/nodeDefaults"; @@ -142,25 +141,71 @@ const COMPOSER_COLLAPSED_WIDTH = 560; const COMPOSER_EXPANDED_HEIGHT_ESTIMATE = 300; const COMPOSER_COLLAPSED_HEIGHT_ESTIMATE = 80; const IMAGE_COUNT_PARAMETER_NAMES = ["imageCount", "numImages", "num_images", "numOutputs", "num_outputs", "count"]; +const GATEWAY_DIMENSION_PARAMETER_NAMES = ["ratio", "videoRatio", "resolution", "duration"]; const DEFAULT_IMAGE_COUNT_OPTIONS: ImageGenerationCount[] = [1, 2, 4]; +interface SchemaSelectOption { + key: string; + label: string; + value: unknown; +} + +interface GatewayDimensionSelect { + parameter: ModelParameter; + options: SchemaSelectOption[]; + selectedKey: string | undefined; +} + function findSchemaParameter(schema: ModelParameter[] | undefined, names: string[]): ModelParameter | null { if (!schema || schema.length === 0) return null; const nameSet = new Set(names); return schema.find((param) => nameSet.has(param.name)) ?? null; } -function getSchemaEnumOptions(schema: ModelParameter[] | undefined, names: string[]): string[] { +function getSchemaOptionValues(schema: ModelParameter[] | undefined, names: string[]): string[] { const param = findSchemaParameter(schema, names); - return param?.enum?.map((item) => String(item)).filter(Boolean) ?? []; + return param?.options?.map((item) => String(item.value)).filter(Boolean) ?? []; } function getSchemaParameterName(schema: ModelParameter[] | undefined, names: string[]): string | null { return findSchemaParameter(schema, names)?.name ?? null; } +function getSchemaSelectOptions(param: ModelParameter): SchemaSelectOption[] { + return param.options?.map((option, index) => ({ + key: String(option.value ?? index), + label: option.label, + value: option.value, + })) ?? []; +} + +function getSelectedSchemaOptionKey(options: SchemaSelectOption[], value: unknown): string | undefined { + return options.find((option) => + Object.is(option.value, value) || String(option.value) === String(value ?? "") + )?.key; +} + +function buildGatewayDimensionSelects( + schema: ModelParameter[] | undefined, + parameters: Record | undefined +): GatewayDimensionSelect[] { + if (!schema || schema.length === 0) return []; + const nameSet = new Set(GATEWAY_DIMENSION_PARAMETER_NAMES); + return schema + .filter((param) => nameSet.has(param.name) && param.options && param.options.length > 0) + .map((parameter) => { + const options = getSchemaSelectOptions(parameter); + const value = parameters?.[parameter.name] ?? parameter.default ?? options[0]?.value; + return { + parameter, + options, + selectedKey: getSelectedSchemaOptionKey(options, value), + }; + }); +} + function getImageCountOptions(schema: ModelParameter[] | undefined): ImageGenerationCount[] { - return getSchemaEnumOptions(schema, IMAGE_COUNT_PARAMETER_NAMES) + return getSchemaOptionValues(schema, IMAGE_COUNT_PARAMETER_NAMES) .map((value) => Number(value)) .filter((value): value is ImageGenerationCount => Number.isInteger(value) && value > 0); } @@ -1353,24 +1398,15 @@ export function GenerationComposer() { ? context.node?.data as NanoBananaNodeData | GenerateVideoNodeData | GenerateAudioNodeData | undefined : undefined; const activeParameterSchema = generationNodeData?.parameterSchema ?? []; - const imageAspectRatioOptions = getSchemaEnumOptions(activeParameterSchema, ["aspectRatio", "aspect_ratio"]); - const imageResolutionOptions = getSchemaEnumOptions(activeParameterSchema, ["resolution"]); + const gatewayDimensionSelects = buildGatewayDimensionSelects(activeParameterSchema, draft.parameters); const imageCountOptions = getImageCountOptions(activeParameterSchema); const visibleImageCountOptions = imageCountOptions.length > 0 ? imageCountOptions : DEFAULT_IMAGE_COUNT_OPTIONS; - const imageAspectRatioParameterName = getSchemaParameterName(activeParameterSchema, ["aspectRatio", "aspect_ratio"]); - const imageResolutionParameterName = getSchemaParameterName(activeParameterSchema, ["resolution"]); const imageCountParameterName = getSchemaParameterName(activeParameterSchema, IMAGE_COUNT_PARAMETER_NAMES); const selectedImageCountValue = imageCountParameterName ? Number(draft.parameters?.[imageCountParameterName] ?? draft.imageCount) : draft.imageCount; - const videoDurationOptionsFromSchema = getSchemaEnumOptions(activeParameterSchema, VIDEO_DURATION_PARAMETER_NAMES); - const videoResolutionOptions = getSchemaEnumOptions(activeParameterSchema, ["resolution"]); - const videoRatioOptions = getSchemaEnumOptions(activeParameterSchema, ["videoRatio"]); - const videoDurationParameterName = getSchemaParameterName(activeParameterSchema, VIDEO_DURATION_PARAMETER_NAMES); - const videoResolutionParameterName = getSchemaParameterName(activeParameterSchema, ["resolution"]); - const videoRatioParameterName = getSchemaParameterName(activeParameterSchema, ["videoRatio"]); const videoSoundParameterName = getSchemaParameterName(activeParameterSchema, VIDEO_SOUND_PARAMETER_NAMES); const errorMessage = nodeData?.status === "error" ? nodeData.error : null; const nodeEditModeLabel = @@ -2299,48 +2335,40 @@ export function GenerationComposer() { {!isProcessNode && activeCapability === "image" && ( <> - {imageAspectRatioParameterName && imageAspectRatioOptions.length > 0 && ( - - )} - {imageResolutionParameterName && imageResolutionOptions.length > 0 && ( - - )} + {gatewayDimensionSelects.map(({ parameter, options, selectedKey }) => ( + + ))} {visibleImageCountOptions.length > 0 && ( - updateSchemaDraftParameter( - videoDurationParameterName, - event.target.value, - { durationSeconds: normalizeVideoDurationSeconds(event.target.value) }, - ["durationSeconds"] - ) - } - className="rounded-md border border-transparent bg-transparent px-1.5 py-1 text-neutral-200 outline-none transition-colors hover:border-neutral-700 hover:bg-neutral-700/60" - > - {videoDurationOptionsFromSchema.map((value) => { - const seconds = Number(value); - return ( -