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