Compare commits

...

4 Commits

  1. 2
      src/app/api/__tests__/popiserverModels.test.ts
  2. 1
      src/app/api/_popiserverModels.ts
  3. 5
      src/app/api/models/[modelId]/route.ts
  4. 7
      src/app/api/points/calculate-task-price/route.ts
  5. 10
      src/components/FloatingActionBar.tsx
  6. 6
      src/components/WorkflowCanvas.tsx
  7. 36
      src/components/__tests__/GenerationComposer.test.tsx
  8. 23
      src/components/__tests__/ModelParameters.test.tsx
  9. 236
      src/components/composer/GenerationComposer.tsx
  10. 139
      src/components/nodes/GenerateAudioNode.tsx
  11. 10
      src/components/nodes/GenerateImageNode.tsx
  12. 28
      src/components/nodes/GenerateVideoNode.tsx
  13. 15
      src/components/nodes/ModelParameters.tsx
  14. 4
      src/hooks/useSelectedModelSchema.ts
  15. 77
      src/utils/modelSchemaDefaults.ts
  16. 19
      src/utils/modelSelection.ts
  17. 95
      src/utils/speechVoiceParameter.ts
  18. 2
      src/utils/videoGenerationSettings.ts

2
src/app/api/__tests__/popiserverModels.test.ts

@ -84,6 +84,7 @@ describe("popiserver model helpers", () => {
code: "speech-2.8-hd",
name: "Speech 2.8 HD",
aiModelCodeAlias: "speech-2.8-hd",
billingMethod: 1,
categories: [{ taskSubType: 301 }],
});
@ -94,6 +95,7 @@ describe("popiserver model helpers", () => {
aiModelId: 30,
aiModelCode: "speech-2.8-hd",
aiModelCodeAlias: "speech-2.8-hd",
billingMethod: 1,
});
});

1
src/app/api/_popiserverModels.ts

@ -214,6 +214,7 @@ export function popiModelToProviderModel(model: PopiAIModel): ProviderModel {
duration: model.duration,
videoRatio: model.videoRatio,
resolution: model.resolution,
billingMethod: model.billingMethod,
isDefault: model.isDefault,
isSupportImages: model.isSupportImages,
isSupportVideos: model.isSupportVideos,

5
src/app/api/models/[modelId]/route.ts

@ -24,7 +24,7 @@
import { NextRequest, NextResponse } from "next/server";
import { ProviderType } from "@/types";
import { ModelParameter, ModelInput } from "@/lib/providers/types";
import { ModelParameter, ModelInput, ProviderModel } from "@/lib/providers/types";
import {
getCachedWaveSpeedSchema,
setCachedWaveSpeedSchema,
@ -37,6 +37,7 @@ import {
buildPopiModelInputs,
buildPopiModelParameters,
fetchPopiModelDetail,
popiModelToProviderModel,
} from "@/app/api/_popiserverModels";
// Cache for model schemas (10 minute TTL)
@ -125,6 +126,7 @@ interface SchemaSuccessResponse {
success: true;
parameters: ModelParameter[];
inputs: ModelInput[];
model?: ProviderModel;
cached: boolean;
}
@ -1615,6 +1617,7 @@ export async function GET(
success: true,
parameters: buildPopiModelParameters(model),
inputs: buildPopiModelInputs(model),
model: popiModelToProviderModel(model),
cached: false,
});
} catch (error) {

7
src/app/api/points/calculate-task-price/route.ts

@ -25,6 +25,8 @@ export async function POST(request: NextRequest) {
throw ApiError.badRequest("aiModelId is required");
}
console.info("[popiserver calculateTaskPrice] request body", body);
const url = getUpstreamUrl(request);
const init: RequestInit = {
method: "POST",
@ -49,6 +51,11 @@ export async function POST(request: NextRequest) {
subType: body.subType,
}, startedAt);
const payload = await response.json().catch(() => null);
console.info("[popiserver calculateTaskPrice] response", {
status: response.status,
ok: response.ok,
body: payload,
});
return NextResponse.json(payload, { status: response.status });
} catch (error) {
return apiErrorResponse(error, undefined, "Failed to calculate task price");

10
src/components/FloatingActionBar.tsx

@ -166,6 +166,7 @@ function GenerateComboButton() {
const [isOpen, setIsOpen] = useState(false);
const menuRef = useRef<HTMLDivElement>(null);
const addNode = useWorkflowStore((state) => state.addNode);
const selectSingleNode = useWorkflowStore((state) => state.selectSingleNode);
const { screenToFlowPosition } = useReactFlow();
useEffect(() => {
@ -191,7 +192,8 @@ function GenerateComboButton() {
y: center.y + Math.random() * 100 - 50,
});
addNode(type, position, await buildDefaultGenerationNodeData(type));
const nodeId = addNode(type, position, await buildDefaultGenerationNodeData(type));
selectSingleNode(nodeId);
setIsOpen(false);
};
@ -286,6 +288,7 @@ function AllNodesMenu() {
const [isOpen, setIsOpen] = useState(false);
const menuRef = useRef<HTMLDivElement>(null);
const addNode = useWorkflowStore((state) => state.addNode);
const selectSingleNode = useWorkflowStore((state) => state.selectSingleNode);
const { screenToFlowPosition } = useReactFlow();
useEffect(() => {
@ -311,9 +314,10 @@ function AllNodesMenu() {
y: center.y + Math.random() * 100 - 50,
});
addNode(type, position, await buildDefaultGenerationNodeData(type));
const nodeId = addNode(type, position, await buildDefaultGenerationNodeData(type));
selectSingleNode(nodeId);
setIsOpen(false);
}, [addNode, screenToFlowPosition]);
}, [addNode, screenToFlowPosition, selectSingleNode]);
const handleDragStart = useCallback((event: React.DragEvent, type: NodeType) => {
event.dataTransfer.setData("application/node-type", type);

6
src/components/WorkflowCanvas.tsx

@ -2042,11 +2042,12 @@ export function WorkflowCanvas() {
};
const dims = defaultDimensions[nodeType];
void (async () => {
addNode(
const nodeId = addNode(
nodeType,
{ x: centerX - dims.width / 2, y: centerY - dims.height / 2 },
await buildDefaultGenerationNodeData(nodeType)
);
selectSingleNode(nodeId);
})();
return;
}
@ -2340,7 +2341,8 @@ export function WorkflowCanvas() {
x: event.clientX,
y: event.clientY,
});
addNode(nodeType, position, await buildDefaultGenerationNodeData(nodeType));
const nodeId = addNode(nodeType, position, await buildDefaultGenerationNodeData(nodeType));
selectSingleNode(nodeId);
return;
}

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

@ -684,6 +684,7 @@ describe("GenerationComposer", () => {
aiModelCodeAlias: "gpt-image-2-all",
type: 1,
subType: 102,
billingMethod: 1,
},
},
}),
@ -764,6 +765,41 @@ describe("GenerationComposer", () => {
});
});
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, {
selectedModel: {
provider: "popiserver",
modelId: "32",
displayName: "Token Billed Image",
capabilities: ["text-to-image"],
metadata: {
aiModelId: 32,
aiModelCode: "token-image-model",
aiModelCodeAlias: "token-image-model",
type: 1,
subType: 102,
billingMethod: 2,
},
},
}),
],
});
render(<GenerationComposer />);
await waitFor(() => {
expect(screen.getByRole("button", { name: "Generate" })).toBeInTheDocument();
});
expect(fetchMock.mock.calls.some(([input]) => String(input).startsWith("/api/points/calculate-task-price"))).toBe(false);
});
it("uses the Seedance estimate endpoint for PopiServer Seedance models", async () => {
const fetchMock = vi.fn(async (input: RequestInfo | URL) => {
const url = String(input);

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

@ -100,6 +100,29 @@ describe("ModelParameters", () => {
expect(onParametersChange).toHaveBeenCalledWith({ max_images: 2 });
});
it("replaces stale enum parameter values with the current schema default", async () => {
const onParametersChange = vi.fn();
render(
<ModelParameters
{...defaultProps}
parameters={{ resolution: "1K" }}
schema={[
createMockParameter({
name: "resolution",
type: "string",
enum: ["4K"],
default: "4K",
}),
]}
onParametersChange={onParametersChange}
/>
);
await waitFor(() => {
expect(onParametersChange).toHaveBeenCalledWith({ resolution: "4K" });
});
});
it("renders number validation and boolean defaults", () => {
render(
<ModelParameters

236
src/components/composer/GenerationComposer.tsx

@ -14,6 +14,7 @@ import {
import { createPortal } from "react-dom";
import { useReactFlow, useViewport, ViewportPortal } from "@xyflow/react";
import { useProviderApiKeys, useWorkflowStore } from "@/store/workflowStore";
import { useModelStore } from "@/store/modelStore";
import {
AspectRatio,
EaseCurveNodeData,
@ -52,6 +53,7 @@ import {
VIDEO_SOUND_PARAMETER_NAMES,
} from "@/utils/videoGenerationSettings";
import { defaultNodeDimensions } from "@/store/utils/nodeDefaults";
import { isSpeechAudioModel } from "@/utils/speechVoiceParameter";
type ComposerMode = "empty-create" | "node-edit" | "process-node" | "unsupported-node" | "multi-select";
type ComposerCapability = "all" | "image" | "video" | "3d" | "audio";
@ -171,6 +173,12 @@ function getSchemaParameterName(schema: ModelParameter[] | undefined, names: str
return findSchemaParameter(schema, names)?.name ?? null;
}
function getSchemaParameterDefault(schema: ModelParameter[] | undefined, name: string | null): unknown {
if (!name) return undefined;
const param = schema?.find((item) => item.name === name);
return param?.default ?? param?.options?.[0]?.value ?? param?.enum?.[0];
}
function getSchemaSelectOptions(param: ModelParameter): SchemaSelectOption[] {
return param.options?.map((option, index) => ({
key: String(option.value ?? index),
@ -185,6 +193,10 @@ function getSelectedSchemaOptionKey(options: SchemaSelectOption[], value: unknow
)?.key;
}
function valueMatchesSchemaOptions(value: unknown, options: string[]): boolean {
return options.some((option) => String(option) === String(value));
}
function buildGatewayDimensionSelects(
schema: ModelParameter[] | undefined,
draft: ComposerDraft
@ -303,6 +315,54 @@ function selectedModelFromProvider(model: ProviderModel): SelectedModel {
};
}
function identifierFromUnknown(value: unknown): string | null {
if (typeof value === "string" && value.trim().length > 0) return value.trim();
if (typeof value === "number" && Number.isFinite(value)) return String(Math.trunc(value));
return null;
}
function getSelectedModelIdentifiers(model: SelectedModel): Set<string> {
return new Set(
[
model.modelId,
model.displayName,
identifierFromUnknown(model.metadata?.aiModelId),
identifierFromUnknown(model.metadata?.aiModelCode),
identifierFromUnknown(model.metadata?.aiModelCodeAlias),
]
.filter((value): value is string => Boolean(value))
.map((value) => value.trim().toLowerCase())
.filter(Boolean)
);
}
function providerModelMatchesSelectedModel(model: ProviderModel, selectedModel: SelectedModel): boolean {
if (model.provider !== selectedModel.provider) return false;
const identifiers = getSelectedModelIdentifiers(selectedModel);
return [
model.id,
model.name,
identifierFromUnknown(model.metadata?.aiModelId),
identifierFromUnknown(model.metadata?.aiModelCode),
identifierFromUnknown(model.metadata?.aiModelCodeAlias),
]
.filter((value): value is string => Boolean(value))
.some((value) => identifiers.has(value.trim().toLowerCase()));
}
function findProviderModelForSelectedModel(models: ProviderModel[], selectedModel: SelectedModel): ProviderModel | null {
return models.find((model) => providerModelMatchesSelectedModel(model, selectedModel)) ?? null;
}
function selectedModelNeedsPopiserverMetadataLookup(model: SelectedModel): boolean {
if (model.provider !== "popiserver") return false;
const billingMethod = finiteIntegerFromUnknown(model.metadata?.billingMethod);
if (billingMethod === null) return true;
if (billingMethod !== 1) return false;
const aiModelId = finiteIntegerFromUnknown(model.metadata?.aiModelId) ?? finiteIntegerFromUnknown(model.modelId);
return !aiModelId;
}
function isSameSelectedModel(a: SelectedModel | undefined, b: SelectedModel | undefined): boolean {
return Boolean(a?.provider && b?.provider && a.provider === b.provider && a.modelId === b.modelId);
}
@ -443,6 +503,10 @@ function getPopiserverModelName(model: SelectedModel): string {
);
}
function isPopiserverPerRunBillingModel(model: SelectedModel): boolean {
return model.provider === "popiserver" && finiteIntegerFromUnknown(model.metadata?.billingMethod) === 1;
}
function buildSeedanceVideoEstimatePayload(
draft: ComposerDraft,
inputVideoDurationSeconds: number
@ -506,6 +570,7 @@ function buildPopiserverTaskPricePayload(
): PopiserverTaskPricePayload | null {
const model = draft.selectedModel;
if (model.provider !== "popiserver") return null;
if (!isPopiserverPerRunBillingModel(model)) return null;
const aiModelId = finiteIntegerFromUnknown(model.metadata?.aiModelId) ?? finiteIntegerFromUnknown(model.modelId);
if (!aiModelId) return null;
@ -860,6 +925,8 @@ function nodeDraftFingerprint(context: ComposerContext): string {
modelId: data.selectedModel.modelId,
displayName: data.selectedModel.displayName,
capabilities: data.selectedModel.capabilities,
metadata: data.selectedModel.metadata,
pricing: data.selectedModel.pricing,
}
: null,
parameters: data.parameters,
@ -956,6 +1023,8 @@ export function GenerationComposer() {
const regenerateNode = useWorkflowStore((state) => state.regenerateNode);
const selectSingleNode = useWorkflowStore((state) => state.selectSingleNode);
const getConnectedInputs = useWorkflowStore((state) => state.getConnectedInputs);
const popiModels = useModelStore((state) => state.all);
const refreshPopiModels = useModelStore((state) => state.refreshPopiModels);
const { newApiWGBaseUrl } = useProviderApiKeys();
const { screenToFlowPosition } = useReactFlow();
const { x: viewportX, y: viewportY, zoom } = useViewport();
@ -1063,8 +1132,13 @@ export function GenerationComposer() {
const estimatedReferenceImageCount = context.mode === "node-edit" && (connectedInputs?.images.length ?? 0) > 0
? connectedInputs?.images.length ?? 0
: draft.inputImages.length;
const modelSchemaReadyForEstimate =
context.mode !== "node-edit" ||
!context.node ||
Array.isArray((context.node.data as { parameterSchema?: ModelParameter[] }).parameterSchema);
const seedanceEstimatePayload = useMemo(() => {
if (draft.selectedModel.provider === "popiserver" && !modelSchemaReadyForEstimate) return null;
if (!isSeedanceVideoEstimateModel(draft.selectedModel) || !draft.selectedModel.modelId) {
return null;
}
@ -1077,6 +1151,7 @@ export function GenerationComposer() {
draft.selectedModel.displayName,
draft.selectedModel.modelId,
draft.selectedModel.provider,
modelSchemaReadyForEstimate,
]);
const seedanceEstimateKey = seedanceEstimatePayload
@ -1087,6 +1162,7 @@ export function GenerationComposer() {
const popiserverTaskPricePayload = useMemo(() => {
if (isSeedanceVideoEstimateModel(draft.selectedModel)) return null;
if (draft.selectedModel.provider === "popiserver" && !modelSchemaReadyForEstimate) return null;
return buildPopiserverTaskPricePayload(draft, context.capability, estimatedReferenceImageCount);
}, [
context.capability,
@ -1097,12 +1173,86 @@ export function GenerationComposer() {
draft.resolution,
draft.selectedModel,
estimatedReferenceImageCount,
modelSchemaReadyForEstimate,
]);
const popiserverTaskPriceKey = popiserverTaskPricePayload
? JSON.stringify(popiserverTaskPricePayload)
: null;
useEffect(() => {
const selectedModel = draft.selectedModel;
if (!selectedModelNeedsPopiserverMetadataLookup(selectedModel)) return;
let cancelled = false;
const applyResolvedModel = (models: ProviderModel[]): boolean => {
const matchedModel = findProviderModelForSelectedModel(models, selectedModel);
if (!matchedModel || finiteIntegerFromUnknown(matchedModel.metadata?.billingMethod) === null) {
return false;
}
const resolvedModel = selectedModelFromProvider(matchedModel);
setDraft((current) => {
if (
!isSameSelectedModel(current.selectedModel, selectedModel) ||
!selectedModelNeedsPopiserverMetadataLookup(current.selectedModel)
) {
return current;
}
const next = {
...current,
selectedModel: {
...resolvedModel,
capabilities: current.selectedModel.capabilities.length > 0
? current.selectedModel.capabilities
: resolvedModel.capabilities,
},
};
draftRef.current = next;
return next;
});
const activeContext = contextRef.current;
const nodeModel = activeContext.node?.data && "selectedModel" in activeContext.node.data
? (activeContext.node.data.selectedModel as SelectedModel | undefined)
: undefined;
if (
activeContext.mode === "node-edit" &&
activeContext.node &&
isSameSelectedModel(nodeModel, selectedModel) &&
nodeModel &&
selectedModelNeedsPopiserverMetadataLookup(nodeModel)
) {
updateNodeData(activeContext.node.id, {
selectedModel: {
...resolvedModel,
capabilities: nodeModel.capabilities.length > 0 ? nodeModel.capabilities : resolvedModel.capabilities,
},
});
}
return true;
};
if (applyResolvedModel(popiModels)) return;
void refreshPopiModels().then((models) => {
if (!cancelled) applyResolvedModel(models);
});
return () => {
cancelled = true;
};
}, [
draft.selectedModel.displayName,
draft.selectedModel.metadata,
draft.selectedModel.modelId,
draft.selectedModel.provider,
popiModels,
refreshPopiModels,
updateNodeData,
]);
useEffect(() => {
const selectedModel = draft.selectedModel;
if (
@ -1413,6 +1563,32 @@ export function GenerationComposer() {
? Number(draft.parameters?.[imageCountParameterName] ?? draft.imageCount)
: draft.imageCount;
const videoSoundParameterName = getSchemaParameterName(activeParameterSchema, VIDEO_SOUND_PARAMETER_NAMES);
const audioVoiceSelect = useMemo(() => {
if (!isSpeechAudioModel(
draft.selectedModel.modelId,
draft.selectedModel.displayName,
String(draft.selectedModel.metadata?.aiModelCode ?? ""),
String(draft.selectedModel.metadata?.aiModelCodeAlias ?? "")
)) {
return null;
}
const parameter = activeParameterSchema.find((param) => param.name === "voiceId" && param.options && param.options.length > 0);
if (!parameter) return null;
const options = getSchemaSelectOptions(parameter);
const value = draft.parameters?.[parameter.name] ?? parameter.default ?? options[0]?.value;
return {
parameter,
options,
selectedKey: getSelectedSchemaOptionKey(options, value),
};
}, [
activeParameterSchema,
draft.parameters,
draft.selectedModel.displayName,
draft.selectedModel.metadata?.aiModelCode,
draft.selectedModel.metadata?.aiModelCodeAlias,
draft.selectedModel.modelId,
]);
const errorMessage = nodeData?.status === "error" ? nodeData.error : null;
const nodeEditModeLabel =
context.nodeType === "nanoBanana"
@ -1557,6 +1733,45 @@ export function GenerationComposer() {
},
[updateNodeData]
);
useEffect(() => {
const resolutionSelect = gatewayDimensionSelects.find((select) => select.parameter.name === "resolution");
if (
isProcessNode ||
activeCapability !== "image" ||
!resolutionSelect ||
resolutionSelect.options.length === 0
) {
return;
}
const currentResolution = draft.parameters?.[resolutionSelect.parameter.name] ?? draft.resolution;
if (valueMatchesSchemaOptions(
currentResolution,
resolutionSelect.options.map((option) => String(option.value))
)) {
return;
}
const nextResolution =
getSchemaParameterDefault(activeParameterSchema, resolutionSelect.parameter.name) ??
resolutionSelect.options[0]?.value;
updateSchemaDraftParameter(
resolutionSelect.parameter.name,
nextResolution,
{ resolution: String(nextResolution) },
["resolution"]
);
}, [
activeCapability,
activeParameterSchema,
draft.parameters,
draft.resolution,
gatewayDimensionSelects,
isProcessNode,
updateSchemaDraftParameter,
]);
const handleComposerBlur = useCallback(
(event: FocusEvent<HTMLElement>) => {
const nextTarget = event.relatedTarget as Node | null;
@ -2465,6 +2680,27 @@ export function GenerationComposer() {
</>
)}
{!isProcessNode && activeCapability === "audio" && audioVoiceSelect && (
<label className="flex items-center gap-1 text-xs text-neutral-400">
<span>{audioVoiceSelect.parameter.label ?? audioVoiceSelect.parameter.name}</span>
<select
aria-label={audioVoiceSelect.parameter.label ?? audioVoiceSelect.parameter.name}
value={audioVoiceSelect.selectedKey}
onChange={(event) => {
const option = audioVoiceSelect.options.find((item) => item.key === event.target.value);
updateSchemaDraftParameter(audioVoiceSelect.parameter.name, option?.value);
}}
className="max-w-[220px] rounded-md border border-transparent bg-transparent px-1.5 py-1 text-xs text-neutral-200 outline-none transition-colors hover:border-neutral-700 hover:bg-neutral-700/60"
>
{audioVoiceSelect.options.map((option) => (
<option key={option.key} value={option.key} className="bg-neutral-900 text-xs">
{option.label}
</option>
))}
</select>
</label>
)}
<div className="ml-auto flex items-center gap-2">
<button
type="button"

139
src/components/nodes/GenerateAudioNode.tsx

@ -23,8 +23,10 @@ import { HandleLabel } from "./HandleLabel";
import { isPopiProviderMode, POPI_PROVIDER_ID } from "@/lib/providerMode";
import { useI18n } from "@/i18n";
import { formatUserFacingError } from "@/utils/userFacingErrors";
import { chooseModelFromList, toSelectedModel } from "@/utils/modelSelection";
import { chooseModelFromList, shouldUpdateSelectedModelFromProviderModel, toSelectedModel } from "@/utils/modelSelection";
import { useModelStore } from "@/store/modelStore";
import { buildDefaultParametersFromSchema } from "@/utils/modelSchemaDefaults";
import { withSpeechVoiceParameter } from "@/utils/speechVoiceParameter";
type GenerateAudioNodeType = Node<GenerateAudioNodeData, "generateAudio">;
@ -136,10 +138,21 @@ export function GenerateAudioNode({ id, data, selected }: NodeProps<GenerateAudi
!popiProviderMode || popiserverAudioModels.some((model) => model.id === nodeData.selectedModel?.modelId)
),
refreshKey: nodeData.modelSchemaRequestId,
onLoaded: ({ inputs, parameters }) => {
onLoaded: ({ inputs, parameters, model }) => {
const nextParameterSchema = withSpeechVoiceParameter(
parameters,
model?.id ?? nodeData.selectedModel?.modelId,
model?.name ?? nodeData.selectedModel?.displayName,
String(model?.metadata?.aiModelCode ?? nodeData.selectedModel?.metadata?.aiModelCode ?? ""),
String(model?.metadata?.aiModelCodeAlias ?? nodeData.selectedModel?.metadata?.aiModelCodeAlias ?? "")
);
updateNodeData(id, {
inputSchema: inputs,
parameterSchema: parameters,
parameterSchema: nextParameterSchema,
parameters: buildDefaultParametersFromSchema(nextParameterSchema),
...(model && shouldUpdateSelectedModelFromProviderModel(nodeData.selectedModel, model)
? { selectedModel: toSelectedModel(model) }
: {}),
modelSchemaError: null,
});
},
@ -149,6 +162,22 @@ export function GenerateAudioNode({ id, data, selected }: NodeProps<GenerateAudi
selectedModel: nodeData.fallbackModel,
enabled: showFallbackSettings && settingsTab === "fallback" && Boolean(nodeData.fallbackModel?.modelId),
});
const audioParameterSchema = useMemo(
() => withSpeechVoiceParameter(
modelSchema.parameters,
nodeData.selectedModel?.modelId,
nodeData.selectedModel?.displayName,
String(nodeData.selectedModel?.metadata?.aiModelCode ?? ""),
String(nodeData.selectedModel?.metadata?.aiModelCodeAlias ?? "")
),
[
modelSchema.parameters,
nodeData.selectedModel?.displayName,
nodeData.selectedModel?.metadata?.aiModelCode,
nodeData.selectedModel?.metadata?.aiModelCodeAlias,
nodeData.selectedModel?.modelId,
]
);
const { setNodes } = useReactFlow();
const handleParametersExpandChange = useCallback(
@ -286,48 +315,67 @@ export function GenerateAudioNode({ id, data, selected }: NodeProps<GenerateAudi
return {
id: handleType,
type: handleType,
label: handleType === "image" ? "Image" : handleType === "video" ? "Video" : handleType === "audio" ? "Audio" : "Prompt",
label: handleType === "image"
? t("node.image")
: handleType === "video"
? t("toolbar.video")
: handleType === "audio"
? "Audio"
: t("node.prompt"),
schemaName: firstInput.name,
description: firstInput.description || firstInput.label,
count: inputs.length,
};
})
.filter((handle): handle is NonNullable<typeof handle> => handle !== null);
return handles.map((handle, index) => {
const topPx = 50 + (index - handles.length / 2 + 0.5) * 20;
const handleColor = handle.type === "image"
? "var(--handle-color-image)"
: handle.type === "audio"
? "var(--handle-color-audio)"
: handle.type === "video"
? "var(--handle-color-video)"
: "var(--handle-color-text)";
return (
<React.Fragment key={handle.id}>
const getHandleColor = (type: string) => {
if (type === "image") return "var(--handle-color-image)";
if (type === "video") return "var(--handle-color-video)";
if (type === "audio") return "var(--handle-color-audio)";
return "var(--handle-color-text)";
};
const indexedCompatHandles = handles.flatMap((handle, index) =>
Array.from({ length: handle.count }, (_, inputIndex) => ({
id: `${handle.type}-${inputIndex}`,
topPercent: ((index + 1) / (handles.length + 1)) * 100,
}))
);
return (
<>
{handles.map((handle, index) => {
const topPercent = ((index + 1) / (handles.length + 1)) * 100;
return (
<React.Fragment key={handle.id}>
<Handle
type="target"
position={Position.Left}
id={handle.id}
data-handletype={handle.type}
data-schema-name={handle.schemaName}
style={{ top: `${topPercent}%`, zIndex: 10 }}
isConnectable={true}
title={handle.description}
/>
<HandleLabel label={handle.label} side="target" color={getHandleColor(handle.type)} top={`calc(${topPercent}% - 18px)`} visible={showLabels} />
</React.Fragment>
);
})}
{indexedCompatHandles.map((handle) => (
<Handle
key={`compat-${handle.id}`}
type="target"
position={Position.Left}
id={handle.id}
data-handletype={handle.type}
data-schema-name={handle.schemaName}
style={{ top: `${topPx}px` }}
title={handle.label}
style={{ top: `${handle.topPercent}%`, opacity: 0, pointerEvents: "none" }}
isConnectable={false}
/>
<HandleLabel label={handle.label} side="target" color={handleColor} top={`${topPx - 18}px`} visible={showLabels} />
{Array.from({ length: handle.count }, (_, inputIndex) => (
<Handle
key={`${handle.type}-${inputIndex}`}
type="target"
position={Position.Left}
id={`${handle.type}-${inputIndex}`}
style={{ top: `${topPx}px`, opacity: 0, pointerEvents: "none" }}
isConnectable={false}
/>
))}
</React.Fragment>
);
});
}, [nodeData.inputSchema, showLabels]);
))}
</>
);
}, [nodeData.inputSchema, showLabels, t]);
return (
<>
@ -383,7 +431,7 @@ export function GenerateAudioNode({ id, data, selected }: NodeProps<GenerateAudi
<ModelParameters
modelId={nodeData.selectedModel.modelId}
provider={currentProvider}
schema={modelSchema.parameters}
schema={audioParameterSchema}
isLoading={modelSchema.isLoading}
error={modelSchema.error}
parameters={nodeData.parameters || {}}
@ -413,7 +461,7 @@ export function GenerateAudioNode({ id, data, selected }: NodeProps<GenerateAudi
<ModelParameters
provider={currentProvider}
modelId={nodeData.selectedModel.modelId}
schema={modelSchema.parameters}
schema={audioParameterSchema}
isLoading={modelSchema.isLoading}
error={modelSchema.error}
parameters={nodeData.parameters || {}}
@ -560,13 +608,16 @@ export function GenerateAudioNode({ id, data, selected }: NodeProps<GenerateAudi
{/* Default prompt handle (if no dynamic schema) */}
{(!nodeData.inputSchema || nodeData.inputSchema.length === 0) && (
<Handle
type="target"
position={Position.Left}
id="text"
data-handletype="text"
style={{ background: "rgb(251, 191, 36)" }}
/>
<>
<Handle
type="target"
position={Position.Left}
id="text"
data-handletype="text"
style={{ top: "50%", zIndex: 10 }}
/>
<HandleLabel label={t("node.prompt")} side="target" color="var(--handle-color-text)" top="calc(50% - 18px)" visible={showLabels} />
</>
)}
{/* Output audio handle */}
@ -575,7 +626,7 @@ export function GenerateAudioNode({ id, data, selected }: NodeProps<GenerateAudi
position={Position.Right}
id="audio"
data-handletype="audio"
style={{ background: "rgb(167, 139, 250)" }}
style={{ top: "50%", zIndex: 10 }}
/>
<HandleLabel label="Audio" side="source" color="var(--handle-color-audio)" visible={showLabels} />

10
src/components/nodes/GenerateImageNode.tsx

@ -26,7 +26,8 @@ import { useI18n } from "@/i18n";
import { MediaPreviewModal } from "@/components/MediaPreviewModal";
import { createGeminiLegacyImageModel } from "@/store/utils/defaultImageModel";
import { getProviderDisplayName, isPopiProviderMode, POPI_PROVIDER_ID } from "@/lib/providerMode";
import { chooseModelFromList, toSelectedModel } from "@/utils/modelSelection";
import { chooseModelFromList, shouldUpdateSelectedModelFromProviderModel, toSelectedModel } from "@/utils/modelSelection";
import { buildImageDefaultsFromSchema } from "@/utils/modelSchemaDefaults";
import { useModelStore } from "@/store/modelStore";
import {
isBrowserFileSystemPath,
@ -514,10 +515,15 @@ export function GenerateImageNode({ id, data, selected }: NodeProps<NanoBananaNo
selectedModel: nodeData.selectedModel,
enabled: Boolean(nodeData.selectedModel?.modelId) && !usesFirstClassImageControls && canFetchPopiModelSchema,
refreshKey: nodeData.modelSchemaRequestId,
onLoaded: ({ inputs, parameters }) => {
onLoaded: ({ inputs, parameters, model }) => {
const schemaDefaults = buildImageDefaultsFromSchema(parameters);
updateNodeData(id, {
inputSchema: inputs,
parameterSchema: parameters,
...schemaDefaults,
...(model && shouldUpdateSelectedModelFromProviderModel(nodeData.selectedModel, model)
? { selectedModel: toSelectedModel(model) }
: {}),
modelSchemaError: null,
});
},

28
src/components/nodes/GenerateVideoNode.tsx

@ -26,7 +26,8 @@ import { HandleLabel } from "./HandleLabel";
import { useI18n } from "@/i18n";
import { MediaPreviewModal } from "@/components/MediaPreviewModal";
import { getProviderDisplayName, isPopiProviderMode, POPI_PROVIDER_ID } from "@/lib/providerMode";
import { chooseModelFromList, toSelectedModel } from "@/utils/modelSelection";
import { chooseModelFromList, shouldUpdateSelectedModelFromProviderModel, toSelectedModel } from "@/utils/modelSelection";
import { buildVideoDefaultsFromSchema } from "@/utils/modelSchemaDefaults";
import { useModelStore } from "@/store/modelStore";
import { formatUserFacingError } from "@/utils/userFacingErrors";
import {
@ -260,8 +261,8 @@ export function GenerateVideoNode({ id, data, selected }: NodeProps<GenerateVide
if (!selectedModel || selectedModel.modelId === nodeData.selectedModel?.modelId) return;
updateNodeData(id, {
selectedModel,
resolution: nodeData.resolution ?? DEFAULT_VIDEO_RESOLUTION,
durationSeconds: nodeData.durationSeconds ?? DEFAULT_VIDEO_DURATION_SECONDS,
resolution: undefined,
durationSeconds: undefined,
soundEnabled: getDefaultVideoSoundEnabled(selectedModel),
parameters: {},
inputSchema: undefined, parameterSchema: undefined, modelSchemaRequestId: Date.now(),
@ -270,8 +271,6 @@ export function GenerateVideoNode({ id, data, selected }: NodeProps<GenerateVide
id,
currentProvider,
modelOptions,
nodeData.durationSeconds,
nodeData.resolution,
nodeData.selectedModel,
updateNodeData,
]);
@ -310,8 +309,8 @@ export function GenerateVideoNode({ id, data, selected }: NodeProps<GenerateVide
// Set inputSchema immediately for Veo models so handles render in the same update
updateNodeData(id, {
selectedModel: newSelectedModel,
resolution: nodeData.resolution ?? DEFAULT_VIDEO_RESOLUTION,
durationSeconds: nodeData.durationSeconds ?? DEFAULT_VIDEO_DURATION_SECONDS,
resolution: undefined,
durationSeconds: undefined,
soundEnabled: getDefaultVideoSoundEnabled(newSelectedModel),
parameters: {},
inputSchema: buildVeoInputSchema(model.id),
@ -320,7 +319,7 @@ export function GenerateVideoNode({ id, data, selected }: NodeProps<GenerateVide
});
}
},
[id, modelOptions, nodeData.durationSeconds, nodeData.resolution, updateNodeData]
[id, modelOptions, updateNodeData]
);
const handleClearVideo = useCallback(() => {
@ -343,10 +342,15 @@ export function GenerateVideoNode({ id, data, selected }: NodeProps<GenerateVide
selectedModel: nodeData.selectedModel,
enabled: Boolean(nodeData.selectedModel?.modelId) && canFetchPopiModelSchema,
refreshKey: nodeData.modelSchemaRequestId,
onLoaded: ({ inputs, parameters }) => {
onLoaded: ({ inputs, parameters, model }) => {
const schemaDefaults = buildVideoDefaultsFromSchema(parameters);
updateNodeData(id, {
inputSchema: inputs,
parameterSchema: parameters,
...schemaDefaults,
...(model && shouldUpdateSelectedModelFromProviderModel(nodeData.selectedModel, model)
? { selectedModel: toSelectedModel(model) }
: {}),
modelSchemaError: null,
});
},
@ -450,8 +454,8 @@ export function GenerateVideoNode({ id, data, selected }: NodeProps<GenerateVide
// Set inputSchema immediately for Veo models so handles render in the same update
updateNodeData(id, {
selectedModel: newSelectedModel,
resolution: nodeData.resolution ?? DEFAULT_VIDEO_RESOLUTION,
durationSeconds: nodeData.durationSeconds ?? DEFAULT_VIDEO_DURATION_SECONDS,
resolution: undefined,
durationSeconds: undefined,
soundEnabled: getDefaultVideoSoundEnabled(newSelectedModel),
parameters: {},
inputSchema: buildVeoInputSchema(model.id),
@ -459,7 +463,7 @@ export function GenerateVideoNode({ id, data, selected }: NodeProps<GenerateVide
modelSchemaRequestId: Date.now(),
});
setIsBrowseDialogOpen(false);
}, [id, nodeData.durationSeconds, nodeData.resolution, nodeData.selectedModel, updateNodeData]);
}, [id, nodeData.selectedModel, updateNodeData]);
// Dynamic title based on selected model - just the model name
const displayTitle = useMemo(() => {

15
src/components/nodes/ModelParameters.tsx

@ -86,8 +86,15 @@ function ModelParametersInner({
const defaults: Record<string, unknown> = {};
let hasNewDefaults = false;
for (const param of visibleSchema) {
if (param.default !== undefined && baseParameters[param.name] === undefined) {
defaults[param.name] = param.default;
const currentValue = baseParameters[param.name];
const enumValues = Array.isArray(param.enum) ? param.enum : [];
const hasInvalidEnumValue =
enumValues.length > 0 &&
currentValue !== undefined &&
!enumValues.some((item) => String(item) === String(currentValue));
const defaultValue = param.default ?? enumValues[0];
if ((currentValue === undefined || hasInvalidEnumValue) && defaultValue !== undefined) {
defaults[param.name] = defaultValue;
hasNewDefaults = true;
}
}
@ -293,10 +300,10 @@ function ParameterInputInner({ param, name, value, onChange, t }: ParameterInput
}))}
size="small"
popupMatchSelectWidth={false}
className="nodrag nopan flex-1 min-w-0 text-[11px]"
className="nodrag nopan flex-1 min-w-0 text-[11px] [&_.ant-select-selection-item]:text-[11px] [&_.ant-select-selector]:!text-[11px]"
classNames={{
popup: {
root: "nodrag nopan",
root: "nodrag nopan [&_.ant-select-item-option-content]:text-[11px]",
},
}}
/>

4
src/hooks/useSelectedModelSchema.ts

@ -2,12 +2,13 @@
import { useEffect, useRef, useState } from "react";
import { ModelInputDef, SelectedModel } from "@/types";
import { ModelParameter } from "@/lib/providers/types";
import { ModelParameter, ProviderModel } from "@/lib/providers/types";
import { useProviderApiKeys } from "@/store/workflowStore";
interface ModelSchemaResult {
parameters: ModelParameter[];
inputs: ModelInputDef[];
model?: ProviderModel;
}
interface UseSelectedModelSchemaOptions {
@ -89,6 +90,7 @@ export function useSelectedModelSchema({
const nextSchema = {
parameters: data.parameters || [],
inputs: data.inputs || [],
model: data.model,
};
setParameters(nextSchema.parameters);
setInputs(nextSchema.inputs);

77
src/utils/modelSchemaDefaults.ts

@ -0,0 +1,77 @@
import type { AspectRatio, ImageGenerationCount } from "@/types";
import type { ModelParameter } from "@/lib/providers/types";
import { normalizeVideoDurationSeconds, VIDEO_SOUND_PARAMETER_NAMES } from "@/utils/videoGenerationSettings";
const IMAGE_COUNT_PARAMETER_NAMES = ["imageCount", "numImages", "num_images", "numOutputs", "num_outputs", "count"];
function getParameterDefault(param: ModelParameter): unknown {
return param.default ?? param.options?.[0]?.value ?? param.enum?.[0];
}
function findParameter(schema: ModelParameter[], names: string[]): ModelParameter | undefined {
const nameSet = new Set(names);
return schema.find((param) => nameSet.has(param.name));
}
function hasValue(value: unknown): boolean {
return value !== undefined && value !== null && value !== "";
}
export function buildDefaultParametersFromSchema(schema: ModelParameter[]): Record<string, unknown> {
const parameters: Record<string, unknown> = {};
for (const param of schema) {
const value = getParameterDefault(param);
if (hasValue(value)) parameters[param.name] = value;
}
return parameters;
}
export function buildImageDefaultsFromSchema(schema: ModelParameter[]): {
aspectRatio?: AspectRatio;
resolution?: string;
imageCount?: ImageGenerationCount;
parameters: Record<string, unknown>;
} {
const parameters = buildDefaultParametersFromSchema(schema);
const ratio = findParameter(schema, ["ratio", "aspectRatio", "aspect_ratio"]);
const resolution = findParameter(schema, ["resolution"]);
const imageCount = findParameter(schema, IMAGE_COUNT_PARAMETER_NAMES);
const ratioValue = ratio ? getParameterDefault(ratio) : undefined;
const resolutionValue = resolution ? getParameterDefault(resolution) : undefined;
const imageCountValue = imageCount ? Number(getParameterDefault(imageCount)) : undefined;
return {
...(hasValue(ratioValue) ? { aspectRatio: String(ratioValue) as AspectRatio } : {}),
...(hasValue(resolutionValue) ? { resolution: String(resolutionValue) } : {}),
...(Number.isInteger(imageCountValue) && imageCountValue > 0
? { imageCount: imageCountValue as ImageGenerationCount }
: {}),
parameters,
};
}
export function buildVideoDefaultsFromSchema(schema: ModelParameter[]): {
aspectRatio?: AspectRatio;
resolution?: string;
durationSeconds?: number;
soundEnabled?: boolean;
parameters: Record<string, unknown>;
} {
const parameters = buildDefaultParametersFromSchema(schema);
const ratio = findParameter(schema, ["ratio", "aspectRatio", "aspect_ratio"]);
const resolution = findParameter(schema, ["resolution"]);
const duration = findParameter(schema, ["duration", "duration_seconds", "durationSeconds"]);
const sound = findParameter(schema, VIDEO_SOUND_PARAMETER_NAMES);
const ratioValue = ratio ? getParameterDefault(ratio) : undefined;
const resolutionValue = resolution ? getParameterDefault(resolution) : undefined;
const durationValue = duration ? getParameterDefault(duration) : undefined;
const soundValue = sound ? getParameterDefault(sound) : undefined;
return {
...(hasValue(ratioValue) ? { aspectRatio: String(ratioValue) as AspectRatio } : {}),
...(hasValue(resolutionValue) ? { resolution: String(resolutionValue) } : {}),
...(hasValue(durationValue) ? { durationSeconds: normalizeVideoDurationSeconds(durationValue) } : {}),
...(typeof soundValue === "boolean" ? { soundEnabled: soundValue } : {}),
parameters,
};
}

19
src/utils/modelSelection.ts

@ -11,6 +11,25 @@ export function toSelectedModel(model: ProviderModel): SelectedModel {
};
}
function hasValue(value: unknown): boolean {
return value !== undefined && value !== null && value !== "";
}
export function shouldUpdateSelectedModelFromProviderModel(
current: SelectedModel | undefined,
model: ProviderModel
): boolean {
if (!current) return true;
if (current.provider !== model.provider || current.modelId !== model.id) return true;
if (model.provider !== "popiserver") return false;
const currentMetadata = current.metadata ?? {};
const nextMetadata = model.metadata ?? {};
return ["billingMethod", "aiModelId", "type", "subType", "aiModelCode", "aiModelCodeAlias"].some(
(key) => !hasValue(currentMetadata[key]) && hasValue(nextMetadata[key])
);
}
export function chooseModelFromList(
models: ProviderModel[],
preferred?: SelectedModel

95
src/utils/speechVoiceParameter.ts

@ -0,0 +1,95 @@
import type { ModelParameter } from "@/lib/providers/types";
const SPEECH_VOICE_OPTIONS = [
{ value: "male-qn-qingse", label: "青涩青年音色 (male-qn-qingse)" },
{ value: "male-qn-jingying", label: "精英青年音色 (male-qn-jingying)" },
{ value: "male-qn-badao", label: "霸道青年音色 (male-qn-badao)" },
{ value: "male-qn-daxuesheng", label: "青年大学生音色 (male-qn-daxuesheng)" },
{ value: "female-shaonv", label: "少女音色 (female-shaonv)" },
{ value: "female-yujie", label: "御姐音色 (female-yujie)" },
{ value: "female-chengshu", label: "成熟女性音色 (female-chengshu)" },
{ value: "female-tianmei", label: "甜美女性音色 (female-tianmei)" },
{ value: "male-qn-qingse-jingpin", label: "青涩青年音色-beta (male-qn-qingse-jingpin)" },
{ value: "male-qn-jingying-jingpin", label: "精英青年音色-beta (male-qn-jingying-jingpin)" },
{ value: "male-qn-badao-jingpin", label: "霸道青年音色-beta (male-qn-badao-jingpin)" },
{ value: "male-qn-daxuesheng-jingpin", label: "青年大学生音色-beta (male-qn-daxuesheng-jingpin)" },
{ value: "female-shaonv-jingpin", label: "少女音色-beta (female-shaonv-jingpin)" },
{ value: "female-yujie-jingpin", label: "御姐音色-beta (female-yujie-jingpin)" },
{ value: "female-chengshu-jingpin", label: "成熟女性音色-beta (female-chengshu-jingpin)" },
{ value: "female-tianmei-jingpin", label: "甜美女性音色-beta (female-tianmei-jingpin)" },
{ value: "clever_boy", label: "聪明男童 (clever_boy)" },
{ value: "cute_boy", label: "可爱男童 (cute_boy)" },
{ value: "lovely_girl", label: "萌萌女童 (lovely_girl)" },
{ value: "cartoon_pig", label: "卡通猪小琪 (cartoon_pig)" },
{ value: "bingjiao_didi", label: "病娇弟弟 (bingjiao_didi)" },
{ value: "junlang_nanyou", label: "俊朗男友 (junlang_nanyou)" },
{ value: "chunzhen_xuedi", label: "纯真学弟 (chunzhen_xuedi)" },
{ value: "lengdan_xiongzhang", label: "冷淡学长 (lengdan_xiongzhang)" },
{ value: "badao_shaoye", label: "霸道少爷 (badao_shaoye)" },
{ value: "tianxin_xiaoling", label: "甜心小玲 (tianxin_xiaoling)" },
{ value: "qiaopi_mengmei", label: "俏皮萌妹 (qiaopi_mengmei)" },
{ value: "wumei_yujie", label: "妩媚御姐 (wumei_yujie)" },
{ value: "diadia_xuemei", label: "嗲嗲学妹 (diadia_xuemei)" },
{ value: "danya_xuejie", label: "淡雅学姐 (danya_xuejie)" },
{ value: "Chinese (Mandarin)_Reliable_Executive", label: "沉稳高管 (Chinese (Mandarin)_Reliable_Executive)" },
{ value: "Chinese (Mandarin)_News_Anchor", label: "新闻女声 (Chinese (Mandarin)_News_Anchor)" },
{ value: "Chinese (Mandarin)_Mature_Woman", label: "傲娇御姐 (Chinese (Mandarin)_Mature_Woman)" },
{ value: "Chinese (Mandarin)_Unrestrained_Young_Man", label: "不羁青年 (Chinese (Mandarin)_Unrestrained_Young_Man)" },
{ value: "Arrogant_Miss", label: "嚣张小姐 (Arrogant_Miss)" },
{ value: "Robot_Armor", label: "机械战甲 (Robot_Armor)" },
{ value: "Chinese (Mandarin)_Kind-hearted_Antie", label: "热心大婶 (Chinese (Mandarin)_Kind-hearted_Antie)" },
{ value: "Chinese (Mandarin)_HK_Flight_Attendant", label: "港普空姐 (Chinese (Mandarin)_HK_Flight_Attendant)" },
{ value: "Chinese (Mandarin)_Humorous_Elder", label: "搞笑大爷 (Chinese (Mandarin)_Humorous_Elder)" },
{ value: "Chinese (Mandarin)_Gentleman", label: "温润男声 (Chinese (Mandarin)_Gentleman)" },
{ value: "Chinese (Mandarin)_Warm_Bestie", label: "温暖闺蜜 (Chinese (Mandarin)_Warm_Bestie)" },
{ value: "Chinese (Mandarin)_Male_Announcer", label: "播报男声 (Chinese (Mandarin)_Male_Announcer)" },
{ value: "Chinese (Mandarin)_Sweet_Lady", label: "甜美女声 (Chinese (Mandarin)_Sweet_Lady)" },
{ value: "Chinese (Mandarin)_Southern_Young_Man", label: "南方小哥 (Chinese (Mandarin)_Southern_Young_Man)" },
{ value: "Chinese (Mandarin)_Wise_Women", label: "阅历姐姐 (Chinese (Mandarin)_Wise_Women)" },
{ value: "Chinese (Mandarin)_Gentle_Youth", label: "温润青年 (Chinese (Mandarin)_Gentle_Youth)" },
{ value: "Chinese (Mandarin)_Warm_Girl", label: "温暖少女 (Chinese (Mandarin)_Warm_Girl)" },
{ value: "Chinese (Mandarin)_Kind-hearted_Elder", label: "花甲奶奶 (Chinese (Mandarin)_Kind-hearted_Elder)" },
{ value: "Chinese (Mandarin)_Cute_Spirit", label: "憨憨萌兽 (Chinese (Mandarin)_Cute_Spirit)" },
{ value: "Chinese (Mandarin)_Radio_Host", label: "电台男主播 (Chinese (Mandarin)_Radio_Host)" },
{ value: "Chinese (Mandarin)_Lyrical_Voice", label: "抒情男声 (Chinese (Mandarin)_Lyrical_Voice)" },
{ value: "Chinese (Mandarin)_Straightforward_Boy", label: "率真弟弟 (Chinese (Mandarin)_Straightforward_Boy)" },
{ value: "Chinese (Mandarin)_Sincere_Adult", label: "真诚青年 (Chinese (Mandarin)_Sincere_Adult)" },
{ value: "Chinese (Mandarin)_Gentle_Senior", label: "温柔学姐 (Chinese (Mandarin)_Gentle_Senior)" },
{ value: "Chinese (Mandarin)_Stubborn_Friend", label: "嘴硬竹马 (Chinese (Mandarin)_Stubborn_Friend)" },
{ value: "Chinese (Mandarin)_Crisp_Girl", label: "清脆少女 (Chinese (Mandarin)_Crisp_Girl)" },
{ value: "Chinese (Mandarin)_Pure-hearted_Boy", label: "清澈邻家弟弟 (Chinese (Mandarin)_Pure-hearted_Boy)" },
{ value: "Chinese (Mandarin)_Soft_Girl", label: "柔和少女 (Chinese (Mandarin)_Soft_Girl)" },
{ value: "Cantonese_ProfessionalHost(F)", label: "专业女主持 (Cantonese_ProfessionalHost(F))" },
{ value: "Cantonese_GentleLady", label: "温柔女声 (Cantonese_GentleLady)" },
{ value: "Cantonese_ProfessionalHost(M)", label: "专业男主持 (Cantonese_ProfessionalHost(M))" },
{ value: "Cantonese_PlayfulMan", label: "活泼男声 (Cantonese_PlayfulMan)" },
{ value: "Cantonese_CuteGirl", label: "可爱女孩 (Cantonese_CuteGirl)" },
{ value: "Cantonese_KindWoman", label: "善良女声 (Cantonese_KindWoman)" },
] satisfies Array<{ value: string; label: string }>;
const SPEECH_VOICE_PARAMETER: ModelParameter = {
name: "voiceId",
type: "string",
label: "音色",
description: "Speech 模型合成音色",
default: "Chinese (Mandarin)_Reliable_Executive",
options: SPEECH_VOICE_OPTIONS,
};
const SPEECH_VOICE_PARAMETER_NAMES = new Set(["voiceId", "voice_id"]);
export function isSpeechAudioModel(...identifiers: Array<string | null | undefined>): boolean {
return identifiers.some((identifier) => {
const normalized = (identifier ?? "").trim().toLowerCase();
return normalized === "speech" || normalized.startsWith("speech-");
});
}
export function withSpeechVoiceParameter(
schema: ModelParameter[] | undefined,
...modelIdentifiers: Array<string | null | undefined>
): ModelParameter[] {
const nextSchema = (schema ?? []).filter((param) => !SPEECH_VOICE_PARAMETER_NAMES.has(param.name));
if (!isSpeechAudioModel(...modelIdentifiers)) return nextSchema;
return [SPEECH_VOICE_PARAMETER, ...nextSchema];
}

2
src/utils/videoGenerationSettings.ts

@ -1,6 +1,6 @@
import type { SelectedModel } from "@/types";
export const MIN_VIDEO_DURATION_SECONDS = 4;
export const MIN_VIDEO_DURATION_SECONDS = 1;
export const DEFAULT_VIDEO_DURATION_SECONDS = 5;
export const DEFAULT_VIDEO_RESOLUTION = "720p";
export const VIDEO_RESOLUTION_OPTIONS = ["480p", "720p", "1080p", "4k"] as const;

Loading…
Cancel
Save