Browse Source

数量字段增加

feature/071
yun 2 weeks ago
parent
commit
7fc3251dc6
  1. 50
      docs/multi-image-generation-implementation.md
  2. 6
      src/app/api/generate/route.ts
  3. 4
      src/app/api/generate/task/create/route.ts
  4. 2
      src/app/api/llm/route.ts
  5. 6
      src/components/__tests__/GenerationComposer.test.tsx
  6. 18
      src/components/composer/ComposerModelConfig.tsx
  7. 12
      src/components/composer/ComposerPromptMaterials.tsx
  8. 6
      src/components/composer/GenerationComposer.tsx
  9. 42
      src/components/composer/GenerationSettingsPopover.tsx
  10. 10
      src/i18n/index.tsx
  11. 6
      src/lib/chat/nodeSchema.ts
  12. 4
      src/lib/popiartTask/runTask.ts
  13. 4
      src/lib/providers/types.ts
  14. 2
      src/lib/quickstart/validation.ts
  15. 2
      src/store/execution/__tests__/generateAudioExecutor.test.ts
  16. 2
      src/store/execution/__tests__/generateVideoExecutor.test.ts
  17. 10
      src/store/execution/__tests__/nanoBananaExecutor.test.ts
  18. 2
      src/store/execution/generate3dExecutor.ts
  19. 8
      src/store/generationPreferenceStore.ts
  20. 2
      src/store/utils/__tests__/nodeDefaults.test.ts
  21. 5
      src/store/utils/nodeDefaults.ts
  22. 12
      src/store/workflowStore.ts
  23. 7
      src/types/nodes.ts
  24. 4
      src/utils/__tests__/composerPricing.test.ts
  25. 2
      src/utils/__tests__/composerPricingContext.test.ts
  26. 16
      src/utils/__tests__/generationConfig.test.ts
  27. 4
      src/utils/composerPricing.ts
  28. 1
      src/utils/defaultGenerationNodeModel.ts
  29. 21
      src/utils/generationConfig.ts
  30. 4
      src/utils/popiserverTaskPayload.ts

50
docs/multi-image-generation-implementation.md

@ -2,7 +2,7 @@
## 背景
本功能在 `TEST-s` 分支中为底部生成对话框增加类似 libTV 的图片张数选择能力。用户可以在图片生成模式下选择 `1`、`2` 或 `4`,一次生成得到多张候选图。
本功能在 `TEST-s` 分支中为底部生成对话框增加类似 libTV 的图片张数选择能力。用户可以在图片或视频生成模式下选择 `1`、`2` 或 `4`,一次生成得到多张候选图。
产品语义采用“单节点多候选图”:一次多图生成只创建或更新一个 `nanoBanana` 图片节点,多张结果进入该节点的轮播历史,当前选中的图片作为节点输出。
@ -20,11 +20,11 @@
已实现:
- 底部生成对话框在图片模式下显示张数下拉
- 张数选项固定为 `1`、`2`、`4`。
- 新建图片节点时写入 `imageCount`
- 编辑已有图片节点时可以修改 `imageCount`
- `nanoBanana` 执行器按 `imageCount` 顺序请求现有 `/api/generate`
- 底部生成对话框在图片模式下显示数量选择
- 张数选项固定为 `1`、`2`、`4`。
- 新建图片节点时写入 `batchSize`
- 编辑已有图片节点时可以修改 `batchSize`
- `nanoBanana` 执行器按 `batchSize` 顺序请求现有 `/api/generate`
- 多张结果写入同一个节点的 `imageHistory`
- 本次生成的第一张图写入 `outputImage`,并作为当前选中输出。
- 每张成功结果都会进入全局图片历史。
@ -42,13 +42,13 @@
## 状态模型
图片生成张数由 `NanoBananaNodeData.imageCount` 表示:
生成数量由 `NanoBananaNodeData.batchSize` 表示:
```ts
export type ImageGenerationCount = 1 | 2 | 4;
export type GenerationBatchSize = 1 | 2 | 4;
export interface NanoBananaNodeData extends BaseNodeData {
imageCount?: ImageGenerationCount;
batchSize?: GenerationBatchSize;
}
```
@ -57,37 +57,37 @@ export interface NanoBananaNodeData extends BaseNodeData {
新建 `nanoBanana` 节点时默认写入:
```ts
imageCount: 1
batchSize: 1
```
底部对话框内部草稿 `ComposerDraft` 同步维护同名字段,避免用户在提交前切换节点或刷新草稿时丢失选择。
## 底部对话框
`GenerationComposer` 在图片能力模式下显示三个控制:
`GenerationComposer` 在图片或视频能力模式下显示三个控制:
```text
模型 / 画幅比例 / 清晰度 / 张数
模型 / 参数 / 数量
```
张数下拉定义为:
数量选择定义为:
```ts
const IMAGE_COUNTS: ImageGenerationCount[] = [1, 2, 4];
const BATCH_SIZE_OPTIONS: GenerationBatchSize[] = [1, 2, 4];
```
提交新节点时,`buildInitialDataForNode("nanoBanana", draft)` 会把 `draft.imageCount` 写入节点数据。
提交新节点时,`buildInitialDataForNode("nanoBanana", draft)` 会把 `draft.batchSize` 写入节点数据。
编辑已有图片节点时,`imageComposerAdapter.readDraft()` 从节点读取 `imageCount`,`buildPatch()` 在字段变脏时回写 `imageCount`
编辑已有图片节点时,`imageComposerAdapter.readDraft()` 从节点读取 `batchSize`,`buildPatch()` 在字段变脏时回写 `batchSize`
## 运行逻辑
`executeNanoBanana()` 不改变 provider 请求协议。执行器会根据节点的 `imageCount` 做顺序循环:
`executeNanoBanana()` 不改变 provider 请求协议。执行器会根据节点的 `batchSize` 做顺序循环:
```text
imageCount = 1 -> 请求 1 次
imageCount = 2 -> 请求 2 次
imageCount = 4 -> 请求 4 次
batchSize = 1 -> 请求 1 次
batchSize = 2 -> 请求 2 次
batchSize = 4 -> 请求 4 次
```
每次请求仍然使用现有 `/api/generate` payload,包括:
@ -160,7 +160,7 @@ appendOutputGalleryImage(target.id, image)
- 节点已有 `inputImages`
- 当前 prompt
- 当前参数
- 当前 `imageCount`
- 当前 `batchSize`
用户切换到第 2/4 张候选图,不会自动把这张图变成下一次生成的参考图。后续如需迭代能力,应单独设计显式动作,例如“以当前图继续生成”。
@ -176,9 +176,9 @@ src/store/utils/__tests__/nodeDefaults.test.ts
覆盖点:
- 新建图片节点会保存用户选择的 `imageCount`
- 默认 `nanoBanana` 节点数据包含 `imageCount: 1`
- `imageCount=4` 时执行器请求 4 次。
- 新建图片节点会保存用户选择的 `batchSize`
- 默认 `nanoBanana` 节点数据包含 `batchSize: 1`
- `batchSize=4` 时执行器请求 4 次。
- 多张结果写入同一个节点历史。
- 多张结果中的第一张成为 `outputImage`
- 批次中后续失败时保留已成功候选图。
@ -192,7 +192,7 @@ npm run test:run -- src/components/__tests__/GenerationComposer.test.tsx src/sto
## 维护注意点
- 不要把 `imageCount` 扩展为任意数字,当前产品选项只允许 `1 | 2 | 4`
- 不要把 `batchSize` 扩展为任意数字,当前产品选项只允许 `1 | 2 | 4`
- 如果未来接入 provider 原生多图参数,需要保留当前循环实现作为兼容 fallback。
- 如果未来新增“展开为多个图片节点”,应作为显式用户动作,不应改变默认生成行为。
- 如果未来新增“以当前图继续生成”,应明确写入 `inputImages`,不要让普通轮播切换隐式改变参考图。

6
src/app/api/generate/route.ts

@ -14,7 +14,7 @@ interface PopiserverGenerateRequest extends GenerateRequest {
selectedModel?: SelectedModel;
parameters?: Record<string, unknown>;
referenceSubjectList?: unknown;
imageCount?: number;
batchSize?: number;
audioVoiceId?: string;
videos?: string[];
voices?: string[];
@ -171,7 +171,7 @@ export async function POST(request: NextRequest) {
selectedModel,
parameters,
referenceSubjectList,
imageCount,
batchSize,
audioVoiceId,
videos,
voices,
@ -211,7 +211,7 @@ export async function POST(request: NextRequest) {
voices: stringArrayFromUnknown(voices),
parameters,
referenceSubjectList,
imageCount,
batchSize,
audioVoiceId,
dynamicInputs,
};

4
src/app/api/generate/task/create/route.ts

@ -13,7 +13,7 @@ type TaskCreateRequest = GenerateRequest & {
selectedModel?: SelectedModel;
parameters?: Record<string, unknown>;
referenceSubjectList?: unknown;
imageCount?: number;
batchSize?: number;
audioVoiceId?: string;
dynamicInputs?: Record<string, string | string[]>;
videos?: string[];
@ -59,7 +59,7 @@ export async function POST(request: NextRequest) {
type: body.type,
subType: body.subType,
referenceSubjectList: body.referenceSubjectList,
imageCount: body.imageCount,
batchSize: body.batchSize,
audioVoiceId: body.audioVoiceId,
dynamicInputs: body.dynamicInputs,
};

2
src/app/api/llm/route.ts

@ -103,7 +103,7 @@ export async function POST(request: NextRequest) {
provider: "popiserver",
model,
hasImages: !!(images && images.length > 0),
imageCount: images?.length || 0,
inputImageCount: images?.length || 0,
hasVideos: !!(videos && videos.length > 0),
videoCount: videos?.length || 0,
prompt,

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

@ -1591,7 +1591,7 @@ describe("GenerationComposer", () => {
useWorkflowStore.setState({
nodes: [
imageNode("img-1", true, {
imageCount: 4,
batchSize: 4,
parameterSchema: [],
selectedModel: {
provider: "popiserver",
@ -1656,7 +1656,7 @@ describe("GenerationComposer", () => {
config: {
aspectRatio: "16:9",
resolution: "2K",
imageCount: 1,
batchSize: 1,
selectedModel: {
provider: "popiserver",
modelId: "33",
@ -1770,7 +1770,7 @@ describe("GenerationComposer", () => {
useWorkflowStore.setState({
nodes: [
imageNode("img-1", true, {
imageCount: 4,
batchSize: 4,
parameterSchema: [],
selectedModel: {
provider: "popiserver",

18
src/components/composer/ComposerModelConfig.tsx

@ -14,7 +14,6 @@ import {
type ModelParameterSelect,
} from "@/components/composer/GenerationSettingsPopover";
import type { ComposerCapability } from "@/utils/composerNodeDescriptor";
import type { ImageGenerationCount } from "@/types";
import {
buildGenerationConfigDraftFromModelSchema,
normalizeExtensionFields,
@ -24,6 +23,7 @@ import { isSpeechAudioModel } from "@/utils/speechVoiceParameter";
import { selectedModelCapabilities, selectedModelMetadataValue, toSelectedModel } from "@/utils/selectedModel";
import type { ModelParameter } from "@/lib/providers/types";
import type { ComposerGenerationConfig } from "@/components/composer/GenerationComposer";
import type { GenerationBatchSize } from "@/types";
interface ComposerAudioVoiceSelect {
parameter: {
@ -63,7 +63,7 @@ interface SchemaSelectOption {
value: unknown;
}
const DEFAULT_IMAGE_COUNT_OPTIONS: ImageGenerationCount[] = [1, 2, 4];
const DEFAULT_BATCH_SIZE_OPTIONS: GenerationBatchSize[] = [1, 2, 4];
const AUDIO_VOICE_PARAMETER_NAMES = ["voiceId", "voice_id"];
function toModelDialogFilter(capability: ComposerCapability): PopiModelKind | undefined {
@ -185,7 +185,7 @@ export function ComposerModelConfig({
};
});
}, [extraTaskParams, extensionFields]);
const visibleImageCountOptions = DEFAULT_IMAGE_COUNT_OPTIONS;
const visibleBatchSizeOptions = DEFAULT_BATCH_SIZE_OPTIONS;
const audioVoiceSelect = useMemo<ComposerAudioVoiceSelect | null>(() => {
if (!isSpeechAudioModel(
config.selectedModel.modelId,
@ -261,8 +261,8 @@ export function ComposerModelConfig({
});
};
const handleImageCountChange = (count: ImageGenerationCount) => {
updateConfig({ imageCount: count });
const handleBatchSizeChange = (count: GenerationBatchSize) => {
updateConfig({ batchSize: count });
};
if (isProcessNode) {
@ -287,12 +287,12 @@ export function ComposerModelConfig({
capability={activeCapability}
parameterSelects={parameterSelects}
extensionFieldSelects={extensionFieldSelects}
imageCountOptions={activeCapability === "image" ? visibleImageCountOptions : []}
imageCount={config.imageCount}
selectedImageCountValue={config.imageCount}
batchSizeOptions={activeCapability === "image" || activeCapability === "video" ? visibleBatchSizeOptions : []}
batchSize={config.batchSize}
selectedBatchSizeValue={config.batchSize}
onParameterChange={handleParameterChange}
onExtensionFieldChange={handleExtensionFieldChange}
onImageCountChange={handleImageCountChange}
onBatchSizeChange={handleBatchSizeChange}
/>
)}

12
src/components/composer/ComposerPromptMaterials.tsx

@ -51,11 +51,11 @@ function selectedModelSubTypes(model: SelectedModel): number[] {
function isVideoSubTypeAllowedForMaterials(
subType: number,
imageCount: number,
referenceImageCount: number,
hasVideoOrAudio: boolean
): boolean {
if (subType === POPI_VIDEO_SUBTYPE_IMAGE_TO_VIDEO) return imageCount === 1 && !hasVideoOrAudio;
if (subType === POPI_VIDEO_SUBTYPE_FIRST_LAST_FRAME) return imageCount === 2 && !hasVideoOrAudio;
if (subType === POPI_VIDEO_SUBTYPE_IMAGE_TO_VIDEO) return referenceImageCount === 1 && !hasVideoOrAudio;
if (subType === POPI_VIDEO_SUBTYPE_FIRST_LAST_FRAME) return referenceImageCount === 2 && !hasVideoOrAudio;
return true;
}
@ -100,7 +100,7 @@ interface ComposerPromptMaterialsProps {
promptMaxHeightPx: number;
referenceItems: ComposerMentionReferenceItem[];
onDraftPatch: (patch: Partial<GenerationNodeConfig>, fields: Array<keyof GenerationNodeConfig>) => void;
onPromptFocus: () => void;
onPromptFocus?: () => void;
onSubmitShortcut: () => void;
}
@ -193,10 +193,10 @@ export function ComposerPromptMaterials({
if (activeCapability !== "video") return [];
const subTypes = selectedModelSubTypes(selectedModel);
const shouldDisableInvalidOptions = subTypes.length > 1;
const imageCount = referenceMaterials.filter((material) => material.type === "image").length;
const referenceImageCount = referenceMaterials.filter((material) => material.type === "image").length;
const hasVideoOrAudio = referenceMaterials.some((material) => material.type === "video" || material.type === "audio");
return subTypes.flatMap((subType): ComposerVideoSubTypeOption[] => {
const disabledReason = shouldDisableInvalidOptions && !isVideoSubTypeAllowedForMaterials(subType, imageCount, hasVideoOrAudio)
const disabledReason = shouldDisableInvalidOptions && !isVideoSubTypeAllowedForMaterials(subType, referenceImageCount, hasVideoOrAudio)
? subType === POPI_VIDEO_SUBTYPE_IMAGE_TO_VIDEO
? t("composer.videoSubTypeImageToVideoImageCountRequired")
: t("composer.videoSubTypeFirstLastFrameImageCountRequired")

6
src/components/composer/GenerationComposer.tsx

@ -189,7 +189,7 @@ function createEmptyDraft(): ComposerDraft {
prompt: "",
selectedModel: EMPTY_SELECTED_MODEL,
inputImages: [],
imageCount: 1,
batchSize: 1,
audioVoiceId: undefined,
referenceSubjectList: [],
parameters: {},
@ -321,7 +321,7 @@ function buildComposerConfigPersistPatch(node: WorkflowNode, config: GenerationN
return {
inputPrompt: config.prompt.trim(),
selectedModel: config.selectedModel,
imageCount: config.imageCount,
batchSize: config.batchSize,
parameters,
extraTaskParams: config.extraTaskParams ?? {},
} as Partial<NanoBananaNodeData>;
@ -1013,7 +1013,7 @@ function ComposerEditor({
clearScheduledPriceRefresh,
config.audioVoiceId,
config.extraTaskParams,
config.imageCount,
config.batchSize,
config.parameters,
config.referenceSubjectList,
config.selectedModel.modelId,

42
src/components/composer/GenerationSettingsPopover.tsx

@ -4,7 +4,7 @@ import { useMemo, useState, type ReactNode } from "react";
import { DownOutlined } from "@ant-design/icons";
import { Popover } from "antd";
import type { ModelParameter } from "@/lib/providers/types";
import type { ImageGenerationCount } from "@/types";
import type { GenerationBatchSize } from "@/types";
import { type ExtensionFieldSchema } from "@/utils/generationConfig";
import { normalizeVideoDurationSeconds } from "@/utils/videoGenerationSettings";
import { useI18n } from "@/i18n";
@ -33,12 +33,12 @@ interface GenerationSettingsPopoverProps {
capability: GenerationSettingsCapability;
parameterSelects: ModelParameterSelect[];
extensionFieldSelects?: ExtensionFieldSelect[];
imageCountOptions?: ImageGenerationCount[];
imageCount?: ImageGenerationCount;
selectedImageCountValue?: string | number;
batchSizeOptions?: GenerationBatchSize[];
batchSize?: GenerationBatchSize;
selectedBatchSizeValue?: string | number;
onParameterChange: (select: ModelParameterSelect, option: GenerationSettingsOption | undefined) => void;
onExtensionFieldChange?: (select: ExtensionFieldSelect, option: GenerationSettingsOption | undefined) => void;
onImageCountChange?: (count: ImageGenerationCount) => void;
onBatchSizeChange?: (count: GenerationBatchSize) => void;
}
const ASPECT_ICON_CLASS = "mx-auto mb-1 rounded-[2px] border border-current opacity-80";
@ -89,17 +89,21 @@ export function GenerationSettingsPopover({
capability,
parameterSelects,
extensionFieldSelects = [],
imageCountOptions = [],
imageCount,
selectedImageCountValue,
batchSizeOptions = [],
batchSize,
selectedBatchSizeValue,
onParameterChange,
onExtensionFieldChange,
onImageCountChange,
onBatchSizeChange,
}: GenerationSettingsPopoverProps) {
const { t } = useI18n();
const [open, setOpen] = useState(false);
const hasImageCount = capability === "image" && imageCountOptions.length > 0 && Boolean(onImageCountChange);
const hasSettings = parameterSelects.length > 0 || extensionFieldSelects.length > 0 || hasImageCount;
const hasBatchSize =
(capability === "image" || capability === "video") &&
batchSizeOptions.length > 0 &&
Boolean(onBatchSizeChange);
const hasSettings = parameterSelects.length > 0 || extensionFieldSelects.length > 0 || hasBatchSize;
const batchSizeLabel = capability === "video" ? t("composer.videoBatchSize") : t("composer.imageBatchSize");
const summaryParts = useMemo(() => {
return [
@ -110,9 +114,9 @@ export function GenerationSettingsPopover({
: optionLabel(option);
}),
...extensionFieldSelects.map((select) => optionLabel(selectedExtensionOption(select))),
hasImageCount && imageCount ? t("composer.imageCountOption", { count: imageCount }) : null,
hasBatchSize && batchSize ? t("composer.batchSizeOption", { count: batchSize }) : null,
].filter((part): part is string => Boolean(part));
}, [extensionFieldSelects, hasImageCount, imageCount, parameterSelects, t]);
}, [extensionFieldSelects, hasBatchSize, batchSize, parameterSelects, t]);
if (!hasSettings) return null;
@ -219,24 +223,24 @@ export function GenerationSettingsPopover({
{parameterSelects.map(renderParameterSelect)}
{extensionFieldSelects.map(renderExtensionFieldSelect)}
{hasImageCount && onImageCountChange && (
{hasBatchSize && onBatchSizeChange && (
<section>
<div className="mb-2 text-xs text-[var(--text-muted)]">{t("composer.imageCount")}</div>
<div className="mb-2 text-xs text-[var(--text-muted)]">{batchSizeLabel}</div>
<div className="grid grid-cols-3 gap-2">
{imageCountOptions.map((count) => {
const selected = String(selectedImageCountValue ?? imageCount) === String(count);
{batchSizeOptions.map((count) => {
const selected = String(selectedBatchSizeValue ?? batchSize) === String(count);
return (
<button
key={count}
type="button"
onClick={() => onImageCountChange(count)}
onClick={() => onBatchSizeChange(count)}
className={`h-8 rounded-lg border px-3 text-xs transition-colors ${
selected
? "border-[var(--text-primary)] bg-[var(--surface-hover)] text-[var(--text-primary)]"
: "border-[var(--border-subtle)] text-[var(--text-muted)] hover:border-[var(--border-strong)] hover:bg-[var(--surface-hover)] hover:text-[var(--text-primary)]"
}`}
>
{t("composer.imageCountOption", { count })}
{t("composer.batchSizeOption", { count })}
</button>
);
})}

10
src/i18n/index.tsx

@ -752,8 +752,9 @@ const en = {
"composer.imageNode": "Image node",
"composer.videoNode": "Video node",
"composer.audioNode": "Audio node",
"composer.imageCount": "Image count",
"composer.imageCountOption": "{count} image(s)",
"composer.imageBatchSize": "Image count",
"composer.videoBatchSize": "Video count",
"composer.batchSizeOption": "{count}",
"composer.newGeneration": "New generation",
"composer.selectedNodes": "{count} nodes selected",
"composer.currentNode": "Current node",
@ -1809,8 +1810,9 @@ const zhCN: Dictionary = {
"composer.imageNode": "图片节点",
"composer.videoNode": "视频节点",
"composer.audioNode": "声音节点",
"composer.imageCount": "生成张数",
"composer.imageCountOption": "{count}张",
"composer.imageBatchSize": "图片张数",
"composer.videoBatchSize": "视频数量",
"composer.batchSizeOption": "{count}",
"composer.newGeneration": "新建生成",
"composer.selectedNodes": "已选择 {count} 个节点",
"composer.currentNode": "当前节点",

6
src/lib/chat/nodeSchema.ts

@ -71,8 +71,8 @@ const EDITABLE_KEYS_BY_NODE_TYPE: Partial<Record<NodeType, readonly string[]>> =
smartText: ["manualLocked", "manualText", "inputPrompt", "provider", "model", "temperature", "maxTokens"],
array: ["inputText", "splitMode", "delimiter", "regexPattern", "trimItems", "removeEmpty", "batchMode", "selectedOutputIndex"],
promptConstructor: ["template"],
nanoBanana: ["inputPrompt", "aspectRatio", "resolution", "imageCount", "useGoogleSearch", "useImageSearch", "selectedModel", "parameters"],
smartImage: ["image", "inputPrompt", "aspectRatio", "resolution", "imageCount", "useGoogleSearch", "useImageSearch", "selectedModel", "parameters"],
nanoBanana: ["inputPrompt", "aspectRatio", "resolution", "batchSize", "useGoogleSearch", "useImageSearch", "selectedModel", "parameters"],
smartImage: ["image", "inputPrompt", "aspectRatio", "resolution", "batchSize", "useGoogleSearch", "useImageSearch", "selectedModel", "parameters"],
generateVideo: ["inputPrompt", "aspectRatio", "resolution", "durationSeconds", "fps", "soundEnabled", "selectedModel", "parameters"],
smartVideo: ["video", "inputPrompt", "aspectRatio", "resolution", "durationSeconds", "fps", "soundEnabled", "selectedModel", "parameters"],
smartAudio: ["audioFile", "inputPrompt", "selectedModel", "parameters"],
@ -162,7 +162,7 @@ export function describeEditableNodeProperties(): string {
"smartText: manualText, inputPrompt, provider, model, temperature, maxTokens",
"promptConstructor: template",
"array: inputText, splitMode, delimiter, regexPattern, trimItems, removeEmpty, batchMode, selectedOutputIndex",
"nanoBanana: inputPrompt, aspectRatio, resolution, imageCount, useGoogleSearch, useImageSearch, selectedModel, parameters",
"nanoBanana: inputPrompt, aspectRatio, resolution, batchSize, useGoogleSearch, useImageSearch, selectedModel, parameters",
"generateVideo: inputPrompt, aspectRatio, resolution, durationSeconds, fps, soundEnabled, selectedModel, parameters",
"generate3d/generateAudio: inputPrompt, selectedModel, parameters",
"llmGenerate: inputPrompt, provider, model, temperature, maxTokens",

4
src/lib/popiartTask/runTask.ts

@ -73,11 +73,11 @@ async function readBuiltInTaskFields(nodeId: string | undefined) {
const node = useWorkflowStore.getState().nodes.find((candidate) => candidate.id === nodeId);
const data = node?.data as Record<string, unknown> | undefined;
if (!data) return {};
const imageCount = positiveIntegerFromUnknown(data.imageCount);
const batchSize = positiveIntegerFromUnknown(data.batchSize);
const audioVoiceId = stringFromUnknown(data.audioVoiceId);
return {
...(imageCount ? { imageCount } : {}),
...(batchSize ? { batchSize } : {}),
...(audioVoiceId ? { audioVoiceId } : {}),
};
}

4
src/lib/providers/types.ts

@ -116,8 +116,8 @@ export interface GenerationInput {
subType?: number;
/** Canvas reference media metadata, kept outside model-specific parameters. */
referenceSubjectList?: unknown;
/** Built-in image batch count, kept outside model parameters. */
imageCount?: number;
/** Built-in media batch count, kept outside model parameters. */
batchSize?: number;
/** Built-in audio voice selection, kept outside model parameters. */
audioVoiceId?: string;
/** Dynamic inputs mapped from schema (e.g., { "image_url": "data:...", "tail_image_url": "data:..." }) */

2
src/lib/quickstart/validation.ts

@ -217,7 +217,7 @@ function createDefaultNodeData(type: NodeType): WorkflowNodeData {
aspectRatio: "1:1",
resolution: "1K",
model: "nano-banana-pro",
imageCount: 1,
batchSize: 1,
useGoogleSearch: false,
useImageSearch: false,
status: "idle",

2
src/store/execution/__tests__/generateAudioExecutor.test.ts

@ -120,7 +120,7 @@ describe("executeGenerateAudio", () => {
capabilities: ["text-to-audio"],
},
inputImages: [],
imageCount: 1,
batchSize: 1,
durationSeconds: 5,
parameters: { voiceId: "config" },
},

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

@ -461,7 +461,7 @@ describe("executeGenerateVideo", () => {
capabilities: ["text-to-video"],
},
inputImages: [],
imageCount: 1,
batchSize: 1,
soundEnabled: true,
parameters: {
motion: "config",

10
src/store/execution/__tests__/nanoBananaExecutor.test.ts

@ -169,7 +169,7 @@ describe("executeNanoBanana", () => {
inputPrompt: "legacy prompt",
aspectRatio: "1:1",
resolution: "1024x1024",
imageCount: 1,
batchSize: 1,
parameters: {
resolution: "legacy-resolution",
batchSize: 1,
@ -179,7 +179,7 @@ describe("executeNanoBanana", () => {
prompt: "config prompt",
aspectRatio: "9:16",
resolution: "4K",
imageCount: 3,
batchSize: 3,
selectedModel: {
provider: "popiserver",
modelId: "32",
@ -449,7 +449,7 @@ describe("executeNanoBanana", () => {
});
it("should generate multiple image candidates into one node history", async () => {
const node = makeNode({ imageCount: 4 });
const node = makeNode({ batchSize: 4 });
mockFetch
.mockResolvedValueOnce({
ok: true,
@ -489,7 +489,7 @@ describe("executeNanoBanana", () => {
});
it("should keep successful candidates when a later image in the batch fails", async () => {
const node = makeNode({ imageCount: 4 });
const node = makeNode({ batchSize: 4 });
mockFetch
.mockResolvedValueOnce({
ok: true,
@ -741,7 +741,7 @@ describe("executeNanoBanana", () => {
});
it("should push every generated candidate to downstream outputGallery nodes", async () => {
const node = makeNode({ imageCount: 2 });
const node = makeNode({ batchSize: 2 });
const galleryNode = {
id: "gal-1",
type: "outputGallery",

2
src/store/execution/generate3dExecutor.ts

@ -110,7 +110,7 @@ export async function executeGenerate3D(
prompt: promptText || "",
selectedModel: modelToUse,
inputImages: images,
imageCount: 1,
batchSize: 1,
referenceSubjectList: nodeData.referenceSubjectList ?? [],
parameters: parametersOverride ?? nodeData.parameters ?? {},
extraTaskParams: nodeData.extraTaskParams ?? {},

8
src/store/generationPreferenceStore.ts

@ -1,5 +1,5 @@
import { create } from "zustand";
import type { ImageGenerationCount, SelectedModel } from "@/types";
import type { GenerationBatchSize, SelectedModel } from "@/types";
import {
createPopiserverDefaultAudioModel,
createPopiserverDefaultImageModel,
@ -14,7 +14,7 @@ export interface ImageGenerationPreference {
model: SelectedModel;
aspectRatio: string;
resolution: string;
imageCount: ImageGenerationCount;
batchSize: GenerationBatchSize;
}
export interface VideoGenerationPreference {
@ -24,6 +24,7 @@ export interface VideoGenerationPreference {
parameters?: Record<string, unknown>;
extraTaskParams?: Record<string, unknown>;
subType?: number;
batchSize: GenerationBatchSize;
}
export interface AudioGenerationPreference {
@ -48,7 +49,7 @@ function createInitialImagePreference(): ImageGenerationPreference {
model: createPopiserverDefaultImageModel(),
aspectRatio: "9:16",
resolution: "1K",
imageCount: 1,
batchSize: 1,
};
}
@ -60,6 +61,7 @@ function createInitialVideoPreference(): VideoGenerationPreference {
parameters: {},
extraTaskParams: {},
subType: model.subType,
batchSize: 1,
};
}

2
src/store/utils/__tests__/nodeDefaults.test.ts

@ -144,7 +144,7 @@ describe("nodeDefaults utilities", () => {
expect(data).toHaveProperty("resolution");
expect(data).toHaveProperty("model");
expect(data).toHaveProperty("selectedModel");
expect(data).toHaveProperty("imageCount", 1);
expect(data).toHaveProperty("batchSize", 1);
expect(data).toHaveProperty("useGoogleSearch");
expect(data).toHaveProperty("status", "idle");
expect(data).toHaveProperty("error", null);

5
src/store/utils/nodeDefaults.ts

@ -98,7 +98,7 @@ export const createDefaultNodeData = (type: NodeType): WorkflowNodeData => {
resolution,
model: "nano-banana-pro",
selectedModel,
imageCount: imagePreference.imageCount,
batchSize: imagePreference.batchSize,
useGoogleSearch: false,
useImageSearch: false,
status: "idle",
@ -230,7 +230,7 @@ export const createDefaultNodeData = (type: NodeType): WorkflowNodeData => {
resolution,
model: "nano-banana-pro",
selectedModel,
imageCount: imagePreference.imageCount,
batchSize: imagePreference.batchSize,
useGoogleSearch: false,
useImageSearch: false,
status: "idle",
@ -247,6 +247,7 @@ export const createDefaultNodeData = (type: NodeType): WorkflowNodeData => {
inputPrompt: null,
outputVideo: null,
selectedModel,
batchSize: videoPreference.batchSize,
parameters: videoPreference.parameters ?? {},
status: "idle",
error: null,

12
src/store/workflowStore.ts

@ -716,10 +716,10 @@ function isVideoSubTypeAllowed(
): boolean {
if (supportedSubTypes.length > 0 && !supportedSubTypes.includes(subType)) return false;
if (supportedSubTypes.length === 1) return supportedSubTypes[0] === subType;
const imageCount = materials.filter((material) => material.type === "image").length;
const referenceImageCount = materials.filter((material) => material.type === "image").length;
const hasVideoOrAudio = materials.some((material) => material.type === "video" || material.type === "audio");
if (subType === POPI_VIDEO_SUBTYPE_IMAGE_TO_VIDEO) return imageCount === 1 && !hasVideoOrAudio;
if (subType === POPI_VIDEO_SUBTYPE_FIRST_LAST_FRAME) return imageCount === 2 && !hasVideoOrAudio;
if (subType === POPI_VIDEO_SUBTYPE_IMAGE_TO_VIDEO) return referenceImageCount === 1 && !hasVideoOrAudio;
if (subType === POPI_VIDEO_SUBTYPE_FIRST_LAST_FRAME) return referenceImageCount === 2 && !hasVideoOrAudio;
return subType === POPI_VIDEO_SUBTYPE_OMNI_REFERENCE;
}
@ -752,11 +752,11 @@ function normalizeVideoGenerationSubType(
if (typeof preferredSubType === "number" && Number.isFinite(preferredSubType)) {
return chooseAvailableVideoSubType(preferredSubType, supportedSubTypes, materials);
}
const imageCount = materials.filter((material) => material.type === "image").length;
const referenceImageCount = materials.filter((material) => material.type === "image").length;
const hasVideoOrAudio = materials.some((material) => material.type === "video" || material.type === "audio");
if (hasVideoOrAudio) return chooseAvailableVideoSubType(POPI_VIDEO_SUBTYPE_OMNI_REFERENCE, supportedSubTypes, materials);
if (imageCount === 1) return chooseAvailableVideoSubType(POPI_VIDEO_SUBTYPE_IMAGE_TO_VIDEO, supportedSubTypes, materials);
if (imageCount === 2) return chooseAvailableVideoSubType(POPI_VIDEO_SUBTYPE_FIRST_LAST_FRAME, supportedSubTypes, materials);
if (referenceImageCount === 1) return chooseAvailableVideoSubType(POPI_VIDEO_SUBTYPE_IMAGE_TO_VIDEO, supportedSubTypes, materials);
if (referenceImageCount === 2) return chooseAvailableVideoSubType(POPI_VIDEO_SUBTYPE_FIRST_LAST_FRAME, supportedSubTypes, materials);
return chooseAvailableVideoSubType(POPI_VIDEO_SUBTYPE_OMNI_REFERENCE, supportedSubTypes, materials);
}

7
src/types/nodes.ts

@ -225,9 +225,9 @@ export interface ModelInputDef {
}
/**
* Nano Banana node - AI image generation
* Built-in media generation batch size.
*/
export type ImageGenerationCount = number;
export type GenerationBatchSize = number;
export interface NanoBananaNodeData extends BaseNodeData {
inputImages: string[]; // Now supports multiple images
@ -242,7 +242,7 @@ export interface NanoBananaNodeData extends BaseNodeData {
model: ModelType;
selectedModel?: SelectedModel; // Multi-provider model selection (optional for backward compat)
lastUsedModel?: SelectedModel; // The model that produced the current output
imageCount?: ImageGenerationCount; // Number of candidate images to generate per run
batchSize?: GenerationBatchSize; // Number of candidate outputs to generate per run
useGoogleSearch: boolean; // Only available for Nano Banana Pro and Nano Banana 2
useImageSearch: boolean; // Only available for Nano Banana 2
referenceSubjectList?: ComposerReferenceSubject[];
@ -276,6 +276,7 @@ export interface GenerateVideoNodeData extends BaseNodeData {
fps?: number;
selectedModel?: SelectedModel; // Required for video generation (no legacy fallback)
lastUsedModel?: SelectedModel; // The model that produced the current output
batchSize?: GenerationBatchSize; // Number of candidate outputs to generate per run
referenceSubjectList?: ComposerReferenceSubject[];
parameters?: Record<string, unknown>; // Model-specific dynamic parameters only
extraTaskParams?: Record<string, unknown>;

4
src/utils/__tests__/composerPricing.test.ts

@ -22,7 +22,7 @@ function draft(overrides: Partial<GenerationNodeConfig> = {}): GenerationNodeCon
prompt: "prompt",
selectedModel: model(),
inputImages: [],
imageCount: 1,
batchSize: 1,
referenceSubjectList: [],
parameters: {},
extraTaskParams: {},
@ -64,7 +64,7 @@ describe("composerPricing", () => {
duration: 8,
input_video_duration_seconds: 12,
},
imageCount: 1,
batchSize: 1,
input_video_duration_seconds: 12,
},
});

2
src/utils/__tests__/composerPricingContext.test.ts

@ -29,7 +29,7 @@ function config(overrides: Partial<GenerationNodeConfig> = {}): GenerationNodeCo
capabilities: ["text-to-image"],
},
inputImages: [],
imageCount: 1,
batchSize: 1,
referenceSubjectList: [],
parameters: {},
extraTaskParams: {},

16
src/utils/__tests__/generationConfig.test.ts

@ -47,7 +47,7 @@ describe("generationConfig", () => {
prompt: "prompt",
selectedModel: defaultModel,
inputImages: [],
imageCount: 1,
batchSize: 1,
referenceSubjectList: [],
parameters: { ratio: "16:9", resolution: "1080p", duration: 8 },
extraTaskParams: { generate_audio: false },
@ -89,7 +89,7 @@ describe("generationConfig", () => {
prompt: "prompt",
selectedModel: defaultModel,
inputImages: [],
imageCount: 1,
batchSize: 1,
referenceSubjectList: [],
parameters: {
resolution: "720P",
@ -140,7 +140,7 @@ describe("generationConfig", () => {
prompt: "prompt",
selectedModel: defaultModel,
inputImages: [],
imageCount: 1,
batchSize: 1,
referenceSubjectList: [],
parameters: {
resolution: null,
@ -186,7 +186,7 @@ describe("generationConfig", () => {
prompt: "prompt",
selectedModel: defaultModel,
inputImages: [],
imageCount: 1,
batchSize: 1,
referenceSubjectList: [],
parameters: {
ratio: "4:3",
@ -256,7 +256,7 @@ describe("generationConfig", () => {
prompt: "prompt",
selectedModel: defaultModel,
inputImages: [],
imageCount: 1,
batchSize: 1,
referenceSubjectList: [],
parameters: {
resolution: "720P",
@ -302,7 +302,7 @@ describe("generationConfig", () => {
prompt: "prompt",
selectedModel: defaultModel,
inputImages: [],
imageCount: 1,
batchSize: 1,
referenceSubjectList: [],
parameters: {
resolution: "720P",
@ -328,7 +328,7 @@ describe("generationConfig", () => {
prompt: "prompt",
selectedModel: defaultModel,
inputImages: [],
imageCount: 1,
batchSize: 1,
referenceSubjectList: [],
parameters: {
resolution: "720P",
@ -357,7 +357,7 @@ describe("generationConfig", () => {
aiModelCode: "seedance",
},
inputImages: [],
imageCount: 4,
batchSize: 4,
subType: POPI_VIDEO_SUBTYPE_IMAGE_TO_VIDEO,
referenceSubjectList: [],
parameters: {

4
src/utils/composerPricing.ts

@ -26,7 +26,7 @@ export type PopiserverPointsEstimatePayload = {
model_name: string;
estimation_type: "auto";
parameters: Record<string, unknown>;
imageCount?: number;
batchSize?: number;
audioVoiceId?: string;
input_video_duration_seconds?: number;
} | {
@ -131,7 +131,7 @@ function buildPopiserverPointsEstimatePayload(
...(media.voices?.length ? { voices: media.voices } : {}),
...(inputVideoDurationSeconds > 0 ? { input_video_duration_seconds: inputVideoDurationSeconds } : {}),
},
...(capability === "image" && config.imageCount ? { imageCount: config.imageCount } : {}),
...(config.batchSize ? { batchSize: config.batchSize } : {}),
...(config.audioVoiceId ? { audioVoiceId: config.audioVoiceId } : {}),
...(inputVideoDurationSeconds > 0 ? { input_video_duration_seconds: inputVideoDurationSeconds } : {}),
};

1
src/utils/defaultGenerationNodeModel.ts

@ -83,7 +83,6 @@ async function buildGenerationPreferenceNodeData(type: NodeType): Promise<Partia
aspectRatio,
resolution: preferences.image.resolution,
selectedModel,
imageCount: preferences.image.imageCount,
parameters: filterTaskRoutingParameters(preferences.image.parameters as Record<string, unknown> | undefined),
} as Partial<WorkflowNodeData>;
}

21
src/utils/generationConfig.ts

@ -2,7 +2,7 @@ import type {
Generate3DNodeData,
GenerateAudioNodeData,
GenerateVideoNodeData,
ImageGenerationCount,
GenerationBatchSize,
NanoBananaNodeData,
SelectedModel,
WorkflowNodeData,
@ -12,9 +12,8 @@ import { selectedModelCapabilities, selectedModelMetadataValue } from "@/utils/s
import type { GenerationInput, ModelCapability, ModelParameter } from "@/lib/providers/types";
import { buildPopiTaskBody } from "@/utils/popiserverTaskPayload";
export const IMAGE_COUNT_PARAMETER_NAMES = [
export const BATCH_SIZE_PARAMETER_NAMES = [
"batchSize",
"imageCount",
"numImages",
"num_images",
"numOutputs",
@ -22,8 +21,8 @@ export const IMAGE_COUNT_PARAMETER_NAMES = [
"count",
];
export function isImageCountParameterName(name: string): boolean {
return IMAGE_COUNT_PARAMETER_NAMES.includes(name);
export function isBatchSizeParameterName(name: string): boolean {
return BATCH_SIZE_PARAMETER_NAMES.includes(name);
}
export function updateModelParameterValue(
@ -203,7 +202,7 @@ export interface GenerationNodeConfig {
prompt: string;
selectedModel: SelectedModel;
inputImages: string[];
imageCount: ImageGenerationCount;
batchSize: GenerationBatchSize;
subType?: number;
audioVoiceId?: string;
referenceSubjectList: ComposerReferenceSubject[];
@ -270,7 +269,7 @@ export function readImageGenerationConfig(
prompt: data.inputPrompt ?? "",
selectedModel: data.selectedModel ?? defaultModel,
inputImages: data.inputImages ?? [],
imageCount: data.imageCount ?? 1,
batchSize: data.batchSize ?? 1,
subType: data.subType,
audioVoiceId: undefined,
referenceSubjectList: data.referenceSubjectList ?? [],
@ -288,7 +287,7 @@ export function readVideoGenerationConfig(
prompt: data.inputPrompt ?? "",
selectedModel,
inputImages: data.inputImages ?? [],
imageCount: 1,
batchSize: data.batchSize ?? 1,
subType: data.subType,
audioVoiceId: undefined,
referenceSubjectList: data.referenceSubjectList ?? [],
@ -305,7 +304,7 @@ export function readAudioGenerationConfig(
prompt: data.inputPrompt ?? "",
selectedModel: data.selectedModel ?? defaultModel,
inputImages: [],
imageCount: 1,
batchSize: 1,
subType: data.subType,
audioVoiceId: data.audioVoiceId ?? stringFromUnknown(data.parameters?.voiceId) ?? stringFromUnknown(data.parameters?.voice_id) ?? undefined,
referenceSubjectList: data.referenceSubjectList ?? [],
@ -324,7 +323,7 @@ export function buildInitialGenerationNodeData(
inputPrompt: config.prompt.trim(),
model: "nano-banana-pro",
selectedModel: config.selectedModel,
imageCount: config.imageCount,
batchSize: config.batchSize,
referenceSubjectList: config.referenceSubjectList,
parameters: filterTaskRoutingParameters(config.parameters),
extraTaskParams: config.extraTaskParams ?? {},
@ -340,6 +339,7 @@ export function buildInitialGenerationNodeData(
inputImages: config.inputImages,
inputPrompt: config.prompt.trim(),
selectedModel: config.selectedModel,
batchSize: config.batchSize,
referenceSubjectList: config.referenceSubjectList,
parameters: filterTaskRoutingParameters(config.parameters),
extraTaskParams: config.extraTaskParams ?? {},
@ -469,6 +469,7 @@ export function buildPopiserverPricingParameterPayload(
parameters: dynamicParameters,
referenceSubjectList: config.referenceSubjectList,
audioVoiceId: config.audioVoiceId,
batchSize: config.batchSize,
};
const payload = buildPopiTaskBody(input, null);

4
src/utils/popiserverTaskPayload.ts

@ -245,8 +245,8 @@ export function buildPopiTaskBody(
aiModelCode: modelCode,
aiModelCodeAlias: modelAlias,
subType,
...(asNumber(input.imageCount) !== null || asNumber(parameters.batchSize) !== null
? { batchSize: asNumber(input.imageCount) ?? asNumber(parameters.batchSize) }
...(asNumber(input.batchSize) !== null || asNumber(parameters.batchSize) !== null
? { batchSize: asNumber(input.batchSize) ?? asNumber(parameters.batchSize) }
: {}),
};

Loading…
Cancel
Save