Browse Source

Merge branch 'feature/add_provider_popi' of https://git.yuanzoo.cn/weitao/popiart-node-canvas into feature/add_provider_popi

TEST-s
weige 2 months ago
parent
commit
ecda6debb8
  1. 67
      src/app/api/__tests__/popiserverModels.test.ts
  2. 79
      src/app/api/_popiserverModels.ts
  3. 8
      src/app/api/generate/providers/__tests__/popiserver.test.ts
  4. 6
      src/app/api/generate/providers/popiserver.ts
  5. 2
      src/app/api/generate/route.ts
  6. 42
      src/components/__tests__/GenerationComposer.test.tsx
  7. 21
      src/components/__tests__/ModelParameters.test.tsx
  8. 231
      src/components/composer/GenerationComposer.tsx
  9. 8
      src/components/nodes/GenerateVideoNode.tsx
  10. 62
      src/components/nodes/ModelParameters.tsx
  11. 2
      src/lib/providers/types.ts
  12. 29
      src/store/execution/__tests__/generateVideoExecutor.test.ts
  13. 4
      src/store/execution/generateVideoExecutor.ts

67
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;
@ -98,4 +98,69 @@ describe("popiserver model helpers", () => {
billingMethod: 1,
});
});
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,
}),
]);
});
});

79
src/app/api/_popiserverModels.ts

@ -37,6 +37,7 @@ export type PopiAIModel = {
name?: string;
description?: string;
icon?: string;
displayDimensions?: string;
ratio?: string[];
duration?: number[];
videoRatio?: number[];
@ -51,6 +52,20 @@ export type PopiAIModel = {
categories?: PopiCategory[];
};
type PopiDisplayDimensionOption = {
value?: unknown;
label?: unknown;
};
type PopiDisplayDimension = {
name?: unknown;
options?: PopiDisplayDimensionOption[];
};
type PopiDisplayDimensionsPayload = {
dimensions?: Record<string, PopiDisplayDimension>;
};
type PopiserverModelListData = PopiAIModel[] | {
list?: PopiAIModel[];
total?: number;
@ -274,14 +289,68 @@ export function buildPopiModelInputs(model: PopiAIModel): ModelInput[] {
return inputs;
}
const POPI_DIMENSION_PARAMETER_TYPES: Record<string, ModelParameter["type"]> = {
duration: "integer",
videoRatio: "integer",
};
function parsePopiDisplayDimensions(displayDimensions: string | undefined): Record<string, PopiDisplayDimension> | 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",
@ -292,7 +361,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",
@ -303,7 +372,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",
@ -314,7 +383,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",

8
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,
});
});

6
src/app/api/generate/providers/popiserver.ts

@ -387,7 +387,7 @@ function inferTaskSubType(input: GenerationInput, type: number): number {
}
function resolveAspectRatio(parameters: Record<string, unknown> | 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",
};
}

2
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,
};

42
src/components/__tests__/GenerationComposer.test.tsx

@ -166,8 +166,25 @@ function videoNode(id: string, selected = false, data: Partial<GenerateVideoNode
},
parameters: {},
parameterSchema: [
{ name: "duration", type: "integer", label: "Duration", enum: ["4", "6", "8"] },
{ name: "resolution", type: "string", label: "Resolution", enum: ["720p", "1080p"] },
{
name: "duration",
type: "integer",
label: "Duration",
options: [
{ label: "4", value: "4" },
{ label: "6", value: "6" },
{ label: "8", value: "8" },
],
},
{
name: "resolution",
type: "string",
label: "Resolution",
options: [
{ label: "720p", value: "720p" },
{ label: "1080p", value: "1080p" },
],
},
{ name: "sound", type: "boolean", label: "Sound" },
],
status: "idle",
@ -196,8 +213,25 @@ function audioNode(id: string, selected = false, data: Partial<GenerateAudioNode
},
parameters: {},
parameterSchema: [
{ name: "duration", type: "integer", label: "Duration", enum: ["4", "6", "8"] },
{ name: "resolution", type: "string", label: "Resolution", enum: ["720p", "1080p"] },
{
name: "duration",
type: "integer",
label: "Duration",
options: [
{ label: "4", value: "4" },
{ label: "6", value: "6" },
{ label: "8", value: "8" },
],
},
{
name: "resolution",
type: "string",
label: "Resolution",
options: [
{ label: "720p", value: "720p" },
{ label: "1080p", value: "1080p" },
],
},
{ name: "sound", type: "boolean", label: "Sound" },
],
status: "idle",

21
src/components/__tests__/ModelParameters.test.tsx

@ -75,7 +75,7 @@ describe("ModelParameters", () => {
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(
<ModelParameters
@ -84,14 +84,19 @@ describe("ModelParameters", () => {
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 });
});
@ -144,13 +149,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",
}),
]}

231
src/components/composer/GenerationComposer.tsx

@ -50,7 +50,6 @@ import {
getDefaultVideoSoundEnabled,
modelSupportsVideoSound,
normalizeVideoDurationSeconds,
VIDEO_DURATION_PARAMETER_NAMES,
VIDEO_SOUND_PARAMETER_NAMES,
} from "@/utils/videoGenerationSettings";
import { defaultNodeDimensions } from "@/store/utils/nodeDefaults";
@ -143,25 +142,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<string, unknown> | 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);
}
@ -1482,24 +1527,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 =
@ -2428,48 +2464,40 @@ export function GenerationComposer() {
{!isProcessNode && activeCapability === "image" && (
<>
{imageAspectRatioParameterName && imageAspectRatioOptions.length > 0 && (
<select
aria-label={t("node.aspectRatio")}
value={String(draft.parameters?.[imageAspectRatioParameterName] ?? draft.aspectRatio)}
onChange={(event) =>
updateSchemaDraftParameter(
imageAspectRatioParameterName,
event.target.value,
{ aspectRatio: event.target.value as AspectRatio },
["aspectRatio"]
)
}
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"
>
{imageAspectRatioOptions.map((ratio) => (
<option key={ratio} value={ratio} className="bg-neutral-900">
{ratio}
</option>
))}
</select>
)}
{imageResolutionParameterName && imageResolutionOptions.length > 0 && (
<select
aria-label={t("node.resolution")}
value={String(draft.parameters?.[imageResolutionParameterName] ?? draft.resolution)}
onChange={(event) =>
updateSchemaDraftParameter(
imageResolutionParameterName,
event.target.value,
{ resolution: event.target.value },
["resolution"]
)
}
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"
>
{imageResolutionOptions.map((item) => (
<option key={item} value={item} className="bg-neutral-900">
{item}
</option>
))}
</select>
)}
{gatewayDimensionSelects.map(({ parameter, options, selectedKey }) => (
<label key={parameter.name} className="flex items-center gap-1 text-xs text-neutral-400">
<span>{parameter.label ?? parameter.name}</span>
<select
aria-label={parameter.label ?? parameter.name}
value={selectedKey}
onChange={(event) => {
const option = options.find((item) => item.key === event.target.value);
const patch: Partial<ComposerDraft> = {};
const fields: DraftField[] = [];
if (parameter.name === "ratio") {
patch.aspectRatio = String(option?.value ?? "") as AspectRatio;
fields.push("aspectRatio");
}
if (parameter.name === "resolution") {
patch.resolution = String(option?.value ?? "");
fields.push("resolution");
}
if (parameter.name === "duration") {
patch.durationSeconds = normalizeVideoDurationSeconds(option?.value);
fields.push("durationSeconds");
}
updateSchemaDraftParameter(parameter.name, option?.value, patch, fields);
}}
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"
>
{options.map((option) => (
<option key={option.key} value={option.key} className="bg-neutral-900">
{option.label}
</option>
))}
</select>
</label>
))}
{visibleImageCountOptions.length > 0 && (
<select
aria-label={t("composer.imageCount")}
@ -2501,65 +2529,40 @@ export function GenerationComposer() {
{!isProcessNode && activeCapability === "video" && (
<>
{videoDurationParameterName && videoDurationOptionsFromSchema.length > 0 && (
<select
aria-label={t("node.duration")}
value={String(draft.parameters?.[videoDurationParameterName] ?? draft.durationSeconds)}
onChange={(event) =>
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 (
<option key={value} value={value} className="bg-neutral-900">
{Number.isFinite(seconds) ? t("node.seconds", { count: seconds }) : value}
{gatewayDimensionSelects.map(({ parameter, options, selectedKey }) => (
<label key={parameter.name} className="flex items-center gap-1 text-xs text-neutral-400">
<span>{parameter.label ?? parameter.name}</span>
<select
aria-label={parameter.label ?? parameter.name}
value={selectedKey}
onChange={(event) => {
const option = options.find((item) => item.key === event.target.value);
const patch: Partial<ComposerDraft> = {};
const fields: DraftField[] = [];
if (parameter.name === "ratio") {
patch.aspectRatio = String(option?.value ?? "") as AspectRatio;
fields.push("aspectRatio");
}
if (parameter.name === "resolution") {
patch.resolution = String(option?.value ?? "");
fields.push("resolution");
}
if (parameter.name === "duration") {
patch.durationSeconds = normalizeVideoDurationSeconds(option?.value);
fields.push("durationSeconds");
}
updateSchemaDraftParameter(parameter.name, option?.value, patch, fields);
}}
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"
>
{options.map((option) => (
<option key={option.key} value={option.key} className="bg-neutral-900">
{option.label}
</option>
);
})}
</select>
)}
{videoResolutionParameterName && videoResolutionOptions.length > 0 && (
<select
aria-label={t("node.resolution")}
value={String(draft.parameters?.[videoResolutionParameterName] ?? draft.resolution)}
onChange={(event) =>
updateSchemaDraftParameter(
videoResolutionParameterName,
event.target.value,
{ resolution: event.target.value },
["resolution"]
)
}
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"
>
{videoResolutionOptions.map((item) => (
<option key={item} value={item} className="bg-neutral-900">
{item}
</option>
))}
</select>
)}
{videoRatioParameterName && videoRatioOptions.length > 0 && (
<select
aria-label={t("node.aspectRatio")}
value={String(draft.parameters?.[videoRatioParameterName] ?? videoRatioOptions[0])}
onChange={(event) => updateSchemaDraftParameter(videoRatioParameterName, event.target.value)}
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"
>
{videoRatioOptions.map((ratio) => (
<option key={ratio} value={ratio} className="bg-neutral-900">
{ratio}
</option>
))}
</select>
)}
))}
</select>
</label>
))}
{videoSoundParameterName && (
<button
type="button"

8
src/components/nodes/GenerateVideoNode.tsx

@ -477,8 +477,12 @@ export function GenerateVideoNode({ id, data, selected }: NodeProps<GenerateVide
const displayError = formatUserFacingError(nodeData.error, t, displayTitle);
const videoSoundSupported = modelSupportsVideoSound(nodeData.selectedModel);
const hiddenVideoParameterNames = useMemo(
() => videoSoundSupported ? VIDEO_FIRST_CLASS_PARAMETER_NAMES : VIDEO_BASIC_PARAMETER_NAMES,
[videoSoundSupported]
() => currentProvider === POPI_PROVIDER_ID
? undefined
: videoSoundSupported
? VIDEO_FIRST_CLASS_PARAMETER_NAMES
: VIDEO_BASIC_PARAMETER_NAMES,
[currentProvider, videoSoundSupported]
);
// Provider badge as title prefix

62
src/components/nodes/ModelParameters.tsx

@ -1,6 +1,7 @@
"use client";
import React, { useState, useEffect, useCallback, useRef, useMemo } from "react";
import { Select } from "antd";
import { ModelInputDef, ProviderType } from "@/types";
import { ModelParameter } from "@/lib/providers/types";
import { TranslationKey, useI18n } from "@/i18n";
@ -136,7 +137,7 @@ function ModelParametersInner({
return [...visibleSchema].sort((a, b) => {
// Sort order: dropdowns first, then numbers, then strings, then checkboxes last
const typeOrder = (p: ModelParameter) => {
if (p.enum && p.enum.length > 0) return 0; // dropdowns first
if (p.options && p.options.length > 0) return 0; // dropdowns first
if (p.type === "number" || p.type === "integer") return 1;
if (p.type === "boolean") return 3; // checkboxes last
return 2; // string and other
@ -249,9 +250,9 @@ function ParameterInputInner({ param, name, value, onChange, t }: ParameterInput
with_audio: "node.sound",
audio_enabled: "node.sound",
};
const displayName = labelMap[param.name]
const displayName = param.label ?? (labelMap[param.name]
? t(labelMap[param.name])
: param.name.replace(/_/g, " ").replace(/\b\w/g, (c) => c.toUpperCase());
: param.name.replace(/_/g, " ").replace(/\b\w/g, (c) => c.toUpperCase()));
// Local state for text/number inputs to prevent cursor jumping
const [localValue, setLocalValue] = useState<string>(() => {
@ -268,8 +269,17 @@ function ParameterInputInner({ param, name, value, onChange, t }: ParameterInput
}, [value]);
// Determine input type and render accordingly
if (param.enum && param.enum.length > 0) {
// Enum: render as select
if (param.options && param.options.length > 0) {
const selectOptions = param.options.map((option, index) => ({
value: String(index),
label: option.label,
rawValue: option.value,
}));
const selectedOption = selectOptions.find((option) =>
Object.is(option.rawValue, value) || String(option.rawValue) === String(value ?? "")
);
// Render label/value option pairs from the model schema.
return (
<div className="flex items-center gap-2">
<label
@ -278,31 +288,25 @@ function ParameterInputInner({ param, name, value, onChange, t }: ParameterInput
>
{displayName}
</label>
<select
value={(value as string) ?? ""}
onChange={(e) => {
const val = e.target.value;
if (val === "") {
handleChange(undefined);
} else if (param.type === "integer") {
handleChange(parseInt(val, 10));
} else if (param.type === "number") {
handleChange(parseFloat(val));
} else if (param.type === "boolean") {
handleChange(val === "true");
} else {
handleChange(val);
}
<Select
value={selectedOption?.value}
onChange={(optionKey) => {
const option = selectOptions.find((item) => item.value === optionKey);
handleChange(option?.rawValue);
}}
className="nodrag nopan flex-1 min-w-0 text-[11px] py-1 px-2 rounded-md bg-[#1a1a1a] focus:outline-none focus:ring-1 focus:ring-neutral-600 text-white"
>
<option value="">{t("node.default")}</option>
{param.enum.map((opt) => (
<option key={String(opt)} value={String(opt)}>
{String(opt)}
</option>
))}
</select>
options={selectOptions.map((option) => ({
value: option.value,
label: option.label,
}))}
size="small"
popupMatchSelectWidth={false}
className="nodrag nopan flex-1 min-w-0 text-[11px]"
classNames={{
popup: {
root: "nodrag nopan",
},
}}
/>
</div>
);
}

2
src/lib/providers/types.ts

@ -29,11 +29,13 @@ export type ModelCapability =
export interface ModelParameter {
name: string;
type: "string" | "number" | "integer" | "boolean" | "array";
label?: string;
description?: string;
default?: unknown;
minimum?: number;
maximum?: number;
enum?: unknown[];
options?: Array<{ label: string; value: unknown }>;
required?: boolean;
}

29
src/store/execution/__tests__/generateVideoExecutor.test.ts

@ -287,6 +287,35 @@ describe("executeGenerateVideo", () => {
});
});
it("uses PopiServer schema parameters without first-class video overrides", async () => {
const node = makeNode({
selectedModel: { provider: "popiserver", modelId: "37", displayName: "Ernie Image Turbo" },
resolution: "1080p",
durationSeconds: 8,
parameters: {
ratio: "9:16",
resolution: "1024",
duration: 4,
videoRatio: 10,
},
});
mockFetch.mockResolvedValueOnce({
ok: true,
json: () => Promise.resolve({ success: true, video: "data:video/mp4;base64,output" }),
});
const ctx = makeCtx(node);
await executeGenerateVideo(ctx);
const body = JSON.parse(mockFetch.mock.calls[0][1].body);
expect(body.parameters).toEqual({
ratio: "9:16",
resolution: "1024",
duration: 4,
videoRatio: 10,
});
});
it("rejects video duration shorter than 4 seconds", async () => {
const node = makeNode({ durationSeconds: 3 });
const ctx = makeCtx(node);

4
src/store/execution/generateVideoExecutor.ts

@ -42,6 +42,10 @@ function buildVideoParameters(
parametersOverride?: Record<string, unknown>
): Record<string, unknown> | undefined {
const parameters = { ...(parametersOverride ?? nodeData.parameters ?? {}) };
if (modelToUse.provider === "popiserver") {
return Object.keys(parameters).length > 0 ? parameters : undefined;
}
const durationSeconds = normalizeVideoDurationSeconds(nodeData.durationSeconds);
const resolution = nodeData.resolution ?? DEFAULT_VIDEO_RESOLUTION;
const soundParameterName = getVideoSoundParameterName(modelToUse);

Loading…
Cancel
Save