From 7fc3251dc6d30ec6d7267b3b230681fe67714297 Mon Sep 17 00:00:00 2001 From: yun Date: Thu, 2 Jul 2026 12:58:36 +0800 Subject: [PATCH] =?UTF-8?q?=E6=95=B0=E9=87=8F=E5=AD=97=E6=AE=B5=E5=A2=9E?= =?UTF-8?q?=E5=8A=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/multi-image-generation-implementation.md | 50 +++++++++---------- src/app/api/generate/route.ts | 6 +-- src/app/api/generate/task/create/route.ts | 4 +- src/app/api/llm/route.ts | 2 +- .../__tests__/GenerationComposer.test.tsx | 6 +-- .../composer/ComposerModelConfig.tsx | 18 +++---- .../composer/ComposerPromptMaterials.tsx | 12 ++--- .../composer/GenerationComposer.tsx | 6 +-- .../composer/GenerationSettingsPopover.tsx | 42 +++++++++------- src/i18n/index.tsx | 10 ++-- src/lib/chat/nodeSchema.ts | 6 +-- src/lib/popiartTask/runTask.ts | 4 +- src/lib/providers/types.ts | 4 +- src/lib/quickstart/validation.ts | 2 +- .../__tests__/generateAudioExecutor.test.ts | 2 +- .../__tests__/generateVideoExecutor.test.ts | 2 +- .../__tests__/nanoBananaExecutor.test.ts | 10 ++-- src/store/execution/generate3dExecutor.ts | 2 +- src/store/generationPreferenceStore.ts | 8 +-- .../utils/__tests__/nodeDefaults.test.ts | 2 +- src/store/utils/nodeDefaults.ts | 5 +- src/store/workflowStore.ts | 12 ++--- src/types/nodes.ts | 7 +-- src/utils/__tests__/composerPricing.test.ts | 4 +- .../__tests__/composerPricingContext.test.ts | 2 +- src/utils/__tests__/generationConfig.test.ts | 16 +++--- src/utils/composerPricing.ts | 4 +- src/utils/defaultGenerationNodeModel.ts | 1 - src/utils/generationConfig.ts | 21 ++++---- src/utils/popiserverTaskPayload.ts | 4 +- 30 files changed, 142 insertions(+), 132 deletions(-) diff --git a/docs/multi-image-generation-implementation.md b/docs/multi-image-generation-implementation.md index e8b7f577..3bd7866d 100644 --- a/docs/multi-image-generation-implementation.md +++ b/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`,不要让普通轮播切换隐式改变参考图。 diff --git a/src/app/api/generate/route.ts b/src/app/api/generate/route.ts index 9f417b41..3ae0de70 100644 --- a/src/app/api/generate/route.ts +++ b/src/app/api/generate/route.ts @@ -14,7 +14,7 @@ interface PopiserverGenerateRequest extends GenerateRequest { selectedModel?: SelectedModel; parameters?: Record; 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, }; diff --git a/src/app/api/generate/task/create/route.ts b/src/app/api/generate/task/create/route.ts index 778a45be..ae803418 100644 --- a/src/app/api/generate/task/create/route.ts +++ b/src/app/api/generate/task/create/route.ts @@ -13,7 +13,7 @@ type TaskCreateRequest = GenerateRequest & { selectedModel?: SelectedModel; parameters?: Record; referenceSubjectList?: unknown; - imageCount?: number; + batchSize?: number; audioVoiceId?: string; dynamicInputs?: Record; 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, }; diff --git a/src/app/api/llm/route.ts b/src/app/api/llm/route.ts index 639fe367..1cdbb6ad 100644 --- a/src/app/api/llm/route.ts +++ b/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, diff --git a/src/components/__tests__/GenerationComposer.test.tsx b/src/components/__tests__/GenerationComposer.test.tsx index 44ddc087..87a7dda0 100644 --- a/src/components/__tests__/GenerationComposer.test.tsx +++ b/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", diff --git a/src/components/composer/ComposerModelConfig.tsx b/src/components/composer/ComposerModelConfig.tsx index 14791d5a..22a66e2a 100644 --- a/src/components/composer/ComposerModelConfig.tsx +++ b/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(() => { 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} /> )} diff --git a/src/components/composer/ComposerPromptMaterials.tsx b/src/components/composer/ComposerPromptMaterials.tsx index b0baa153..b608cea6 100644 --- a/src/components/composer/ComposerPromptMaterials.tsx +++ b/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, fields: Array) => 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") diff --git a/src/components/composer/GenerationComposer.tsx b/src/components/composer/GenerationComposer.tsx index 5a74ed9e..f7907c6c 100644 --- a/src/components/composer/GenerationComposer.tsx +++ b/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; @@ -1013,7 +1013,7 @@ function ComposerEditor({ clearScheduledPriceRefresh, config.audioVoiceId, config.extraTaskParams, - config.imageCount, + config.batchSize, config.parameters, config.referenceSubjectList, config.selectedModel.modelId, diff --git a/src/components/composer/GenerationSettingsPopover.tsx b/src/components/composer/GenerationSettingsPopover.tsx index 38a047b9..4a41a94b 100644 --- a/src/components/composer/GenerationSettingsPopover.tsx +++ b/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 && (
-
{t("composer.imageCount")}
+
{batchSizeLabel}
- {imageCountOptions.map((count) => { - const selected = String(selectedImageCountValue ?? imageCount) === String(count); + {batchSizeOptions.map((count) => { + const selected = String(selectedBatchSizeValue ?? batchSize) === String(count); return ( ); })} diff --git a/src/i18n/index.tsx b/src/i18n/index.tsx index 1f26f717..3367985d 100644 --- a/src/i18n/index.tsx +++ b/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": "当前节点", diff --git a/src/lib/chat/nodeSchema.ts b/src/lib/chat/nodeSchema.ts index 9db84f04..daf8f532 100644 --- a/src/lib/chat/nodeSchema.ts +++ b/src/lib/chat/nodeSchema.ts @@ -71,8 +71,8 @@ const EDITABLE_KEYS_BY_NODE_TYPE: Partial> = 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", diff --git a/src/lib/popiartTask/runTask.ts b/src/lib/popiartTask/runTask.ts index b11bd433..ae53a6f8 100644 --- a/src/lib/popiartTask/runTask.ts +++ b/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 | 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 } : {}), }; } diff --git a/src/lib/providers/types.ts b/src/lib/providers/types.ts index f2d14912..d83b5aa8 100644 --- a/src/lib/providers/types.ts +++ b/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:..." }) */ diff --git a/src/lib/quickstart/validation.ts b/src/lib/quickstart/validation.ts index b831bed1..a727c36e 100644 --- a/src/lib/quickstart/validation.ts +++ b/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", diff --git a/src/store/execution/__tests__/generateAudioExecutor.test.ts b/src/store/execution/__tests__/generateAudioExecutor.test.ts index 0335569f..1f16a9c7 100644 --- a/src/store/execution/__tests__/generateAudioExecutor.test.ts +++ b/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" }, }, diff --git a/src/store/execution/__tests__/generateVideoExecutor.test.ts b/src/store/execution/__tests__/generateVideoExecutor.test.ts index bd25a932..0e0d9fb0 100644 --- a/src/store/execution/__tests__/generateVideoExecutor.test.ts +++ b/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", diff --git a/src/store/execution/__tests__/nanoBananaExecutor.test.ts b/src/store/execution/__tests__/nanoBananaExecutor.test.ts index 69a498d9..a49abc65 100644 --- a/src/store/execution/__tests__/nanoBananaExecutor.test.ts +++ b/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", diff --git a/src/store/execution/generate3dExecutor.ts b/src/store/execution/generate3dExecutor.ts index 694d4a1e..58bc6cbb 100644 --- a/src/store/execution/generate3dExecutor.ts +++ b/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 ?? {}, diff --git a/src/store/generationPreferenceStore.ts b/src/store/generationPreferenceStore.ts index f1a4311e..f592073f 100644 --- a/src/store/generationPreferenceStore.ts +++ b/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; extraTaskParams?: Record; 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, }; } diff --git a/src/store/utils/__tests__/nodeDefaults.test.ts b/src/store/utils/__tests__/nodeDefaults.test.ts index 403d3933..2e48f147 100644 --- a/src/store/utils/__tests__/nodeDefaults.test.ts +++ b/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); diff --git a/src/store/utils/nodeDefaults.ts b/src/store/utils/nodeDefaults.ts index daaf68ad..5f92475a 100644 --- a/src/store/utils/nodeDefaults.ts +++ b/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, diff --git a/src/store/workflowStore.ts b/src/store/workflowStore.ts index 4af84449..9631a62a 100644 --- a/src/store/workflowStore.ts +++ b/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); } diff --git a/src/types/nodes.ts b/src/types/nodes.ts index c3ac6473..ce4ff0ac 100644 --- a/src/types/nodes.ts +++ b/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; // Model-specific dynamic parameters only extraTaskParams?: Record; diff --git a/src/utils/__tests__/composerPricing.test.ts b/src/utils/__tests__/composerPricing.test.ts index 396162b5..46b9175b 100644 --- a/src/utils/__tests__/composerPricing.test.ts +++ b/src/utils/__tests__/composerPricing.test.ts @@ -22,7 +22,7 @@ function draft(overrides: Partial = {}): 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, }, }); diff --git a/src/utils/__tests__/composerPricingContext.test.ts b/src/utils/__tests__/composerPricingContext.test.ts index 8648f0f7..7854cf7a 100644 --- a/src/utils/__tests__/composerPricingContext.test.ts +++ b/src/utils/__tests__/composerPricingContext.test.ts @@ -29,7 +29,7 @@ function config(overrides: Partial = {}): GenerationNodeCo capabilities: ["text-to-image"], }, inputImages: [], - imageCount: 1, + batchSize: 1, referenceSubjectList: [], parameters: {}, extraTaskParams: {}, diff --git a/src/utils/__tests__/generationConfig.test.ts b/src/utils/__tests__/generationConfig.test.ts index aa86c778..53fef73f 100644 --- a/src/utils/__tests__/generationConfig.test.ts +++ b/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: { diff --git a/src/utils/composerPricing.ts b/src/utils/composerPricing.ts index e02937fa..c7642482 100644 --- a/src/utils/composerPricing.ts +++ b/src/utils/composerPricing.ts @@ -26,7 +26,7 @@ export type PopiserverPointsEstimatePayload = { model_name: string; estimation_type: "auto"; parameters: Record; - 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 } : {}), }; diff --git a/src/utils/defaultGenerationNodeModel.ts b/src/utils/defaultGenerationNodeModel.ts index 584b93fc..35b2263e 100644 --- a/src/utils/defaultGenerationNodeModel.ts +++ b/src/utils/defaultGenerationNodeModel.ts @@ -83,7 +83,6 @@ async function buildGenerationPreferenceNodeData(type: NodeType): Promise | undefined), } as Partial; } diff --git a/src/utils/generationConfig.ts b/src/utils/generationConfig.ts index 22f659df..153ea661 100644 --- a/src/utils/generationConfig.ts +++ b/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); diff --git a/src/utils/popiserverTaskPayload.ts b/src/utils/popiserverTaskPayload.ts index 4c5260df..8d4e7189 100644 --- a/src/utils/popiserverTaskPayload.ts +++ b/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) } : {}), };