diff --git a/src/app/api/generate/__tests__/route.test.ts b/src/app/api/generate/__tests__/route.test.ts index d28fdfb5..88b58eaa 100644 --- a/src/app/api/generate/__tests__/route.test.ts +++ b/src/app/api/generate/__tests__/route.test.ts @@ -976,6 +976,32 @@ describe("/api/generate route", () => { }); }); + it("should include all image URLs when provider output has multiple images", async () => { + const response = buildMediaResponse( + { + type: "image", + data: "", + url: "https://example.com/generated-1.png", + }, + [ + { type: "image", data: "", url: "https://example.com/generated-1.png" }, + { type: "image", data: "", url: "https://example.com/generated-2.png" }, + ] + ); + + const data = await response.json(); + + expect(data).toEqual({ + success: true, + image: "https://example.com/generated-1.png", + images: [ + "https://example.com/generated-1.png", + "https://example.com/generated-2.png", + ], + contentType: "image", + }); + }); + it("should return proper response structure with image", async () => { process.env.GEMINI_API_KEY = "test-gemini-key"; diff --git a/src/app/api/generate/poll/route.ts b/src/app/api/generate/poll/route.ts index db72fbaa..4fe9db4f 100644 --- a/src/app/api/generate/poll/route.ts +++ b/src/app/api/generate/poll/route.ts @@ -70,7 +70,7 @@ export async function POST(request: NextRequest) { ); } - return buildMediaResponse(output); + return buildMediaResponse(output, pollResult.output.outputs); } // Get API key (same pattern as route.ts) diff --git a/src/app/api/generate/providers/__tests__/popiserver.test.ts b/src/app/api/generate/providers/__tests__/popiserver.test.ts index 287c867e..ca38ce68 100644 --- a/src/app/api/generate/providers/__tests__/popiserver.test.ts +++ b/src/app/api/generate/providers/__tests__/popiserver.test.ts @@ -159,4 +159,38 @@ describe("popiserver generation provider", () => { }); } }); + + it("extracts all completed image task outputs from batched result list", async () => { + vi.mocked(global.fetch).mockResolvedValue({ + ok: true, + json: () => Promise.resolve({ + status: "0000", + message: "ok", + data: { + list: [ + { + id: 1191, + status: 2, + resultList: [ + { images: ["https://example.com/result-1.png"] }, + { images: ["https://example.com/result-2.png"] }, + { images: ["https://example.com/result-3.png"] }, + ], + }, + ], + }, + }), + } as Response); + + const result = await checkPopiTaskOnce(makeRequest(), "login-token", "1191", "image"); + + expect(result.status).toBe("completed"); + if (result.status === "completed") { + expect(result.output.outputs).toEqual([ + { type: "image", data: "", url: "https://example.com/result-1.png" }, + { type: "image", data: "", url: "https://example.com/result-2.png" }, + { type: "image", data: "", url: "https://example.com/result-3.png" }, + ]); + } + }); }); diff --git a/src/app/api/generate/providers/popiserver.ts b/src/app/api/generate/providers/popiserver.ts index e9a80a4a..58db8091 100644 --- a/src/app/api/generate/providers/popiserver.ts +++ b/src/app/api/generate/providers/popiserver.ts @@ -75,6 +75,20 @@ function firstString(values: unknown[]): string | null { return null; } +function collectStrings(values: unknown[]): string[] { + const items: string[] = []; + for (const value of values) { + if (typeof value === "string" && value.length > 0) { + items.push(value); + continue; + } + if (Array.isArray(value)) { + items.push(...collectStrings(value)); + } + } + return items; +} + function stringArray(value: string | string[] | undefined): string[] { if (typeof value === "string") return value.length > 0 ? [value] : []; if (Array.isArray(value)) return value.filter((item) => typeof item === "string" && item.length > 0); @@ -286,9 +300,14 @@ function extractOutputFromTask(task: PopiTask, mediaType: string): GenerationOut ? { success: true, outputs: [{ type: "audio", data: "", url }] } : { success: false, error: "PopiServer audio task completed without output" }; } - const image = firstString([firstResult?.images, task.images]); - return image - ? { success: true, outputs: [{ type: "image", data: "", url: image }] } + const images = [ + ...new Set(collectStrings([ + ...(Array.isArray(task.resultList) ? task.resultList.map((result) => result.images) : []), + task.images, + ])), + ]; + return images.length > 0 + ? { success: true, outputs: images.map((url) => ({ type: "image", data: "", url })) } : { success: false, error: "PopiServer image task completed without output" }; } diff --git a/src/app/api/generate/route.ts b/src/app/api/generate/route.ts index fbefaf56..012d2681 100644 --- a/src/app/api/generate/route.ts +++ b/src/app/api/generate/route.ts @@ -110,7 +110,10 @@ function requireSelectedModel( return selectedModel as RequiredSelectedModel; } -export function buildMediaResponse(output: { type: string; data: string; url?: string }): NextResponse { +export function buildMediaResponse( + output: { type: string; data: string; url?: string }, + outputs?: Array<{ type: string; data: string; url?: string }> +): NextResponse { if (output.type === "3d") { return NextResponse.json({ success: true, @@ -139,9 +142,16 @@ export function buildMediaResponse(output: { type: string; data: string; url?: s }); } + const image = output.data || output.url; + const images = outputs + ?.filter((item) => item.type === "image") + .map((item) => item.data || item.url) + .filter((item): item is string => typeof item === "string" && item.length > 0); + return NextResponse.json({ success: true, - image: output.data || output.url, + image, + ...(images && images.length > 1 ? { images } : {}), contentType: "image", }); } diff --git a/src/app/api/points/calculate-task-price/__tests__/route.test.ts b/src/app/api/points/calculate-task-price/__tests__/route.test.ts new file mode 100644 index 00000000..760e2e0c --- /dev/null +++ b/src/app/api/points/calculate-task-price/__tests__/route.test.ts @@ -0,0 +1,86 @@ +import { NextRequest } from "next/server"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { POST } from "../route"; + +const originalFetch = global.fetch; +const originalBaseUrl = process.env.POPIART_API_BASE_URL; + +function createPostRequest(body: unknown, headers?: Record): NextRequest { + return new NextRequest("http://localhost/api/points/calculate-task-price", { + method: "POST", + headers: new Headers({ + "Content-Type": "application/json", + ...(headers || {}), + }), + body: JSON.stringify(body), + }); +} + +describe("/api/points/calculate-task-price route", () => { + afterEach(() => { + global.fetch = originalFetch; + process.env.POPIART_API_BASE_URL = originalBaseUrl; + vi.restoreAllMocks(); + }); + + it("returns 401 when user token is missing", async () => { + const response = await POST(createPostRequest({ aiModelId: 32 })); + + await expect(response.json()).resolves.toEqual({ + success: false, + code: "AUTH_REQUIRED", + error: "请先登录", + }); + expect(response.status).toBe(401); + }); + + it("forwards task price requests to PopiServer with user auth", async () => { + process.env.POPIART_API_BASE_URL = "https://popi.example"; + const payload = { + type: 1, + subType: 102, + aiModelId: 32, + model: "gpt-image-2-all", + aspectRatio: "16:9", + ratio: "16:9", + resolution: "2K", + width: 1280, + height: 720, + batchSize: 4, + }; + const upstreamBody = { + success: true, + data: { + taskPrice: 24, + }, + }; + const fetchMock = vi.fn().mockResolvedValue( + new Response(JSON.stringify(upstreamBody), { + status: 200, + headers: { "Content-Type": "application/json" }, + }) + ); + global.fetch = fetchMock; + + const response = await POST(createPostRequest(payload, { + Authorization: "Bearer user-token", + })); + + await expect(response.json()).resolves.toEqual(upstreamBody); + expect(response.status).toBe(200); + expect(fetchMock).toHaveBeenCalledWith( + "https://popi.example/api_client/anime/task/calculateTaskPrice", + { + method: "POST", + headers: { + Accept: "application/json", + "Content-Type": "application/json", + Authorization: "Bearer user-token", + token: "user-token", + }, + body: JSON.stringify(payload), + cache: "no-store", + } + ); + }); +}); diff --git a/src/app/api/points/calculate-task-price/route.ts b/src/app/api/points/calculate-task-price/route.ts new file mode 100644 index 00000000..3db7f557 --- /dev/null +++ b/src/app/api/points/calculate-task-price/route.ts @@ -0,0 +1,56 @@ +import { NextRequest, NextResponse } from "next/server"; +import { requireLogin } from "@/app/api/_auth"; +import { ApiError, apiErrorResponse } from "@/app/api/_errors"; +import { logGatewayRequest, logGatewayResponse } from "@/app/api/_gatewayLogging"; + +const UPSTREAM_CALCULATE_TASK_PRICE_PATH = "/api_client/anime/task/calculateTaskPrice"; + +function getUpstreamUrl(request: NextRequest): string { + const baseUrl = + process.env.POPIART_API_BASE_URL || + process.env.POPISERVER_BASE_URL || + process.env.NEXT_PUBLIC_APP_URL || + request.nextUrl.origin; + return baseUrl.replace(/\/+$/, "") + UPSTREAM_CALCULATE_TASK_PRICE_PATH; +} + +export async function POST(request: NextRequest) { + try { + const { token } = requireLogin(request); + const body = (await request.json().catch(() => null)) as Record | null; + if (!body || typeof body !== "object") { + throw ApiError.badRequest("Task price request body is required"); + } + if (!body.aiModelId) { + throw ApiError.badRequest("aiModelId is required"); + } + + const url = getUpstreamUrl(request); + const init: RequestInit = { + method: "POST", + headers: { + Accept: "application/json", + "Content-Type": "application/json", + Authorization: `Bearer ${token}`, + token, + }, + body: JSON.stringify(body), + cache: "no-store", + }; + const startedAt = Date.now(); + logGatewayRequest("Calling task price API", url, init, { + aiModelId: body.aiModelId, + subType: body.subType, + }); + + const response = await fetch(url, init); + await logGatewayResponse("Task price API response", url, init, response, { + aiModelId: body.aiModelId, + subType: body.subType, + }, startedAt); + const payload = await response.json().catch(() => null); + return NextResponse.json(payload, { status: response.status }); + } catch (error) { + return apiErrorResponse(error, undefined, "Failed to calculate task price"); + } +} diff --git a/src/components/__tests__/GenerationComposer.test.tsx b/src/components/__tests__/GenerationComposer.test.tsx index e902e9fc..336fefed 100644 --- a/src/components/__tests__/GenerationComposer.test.tsx +++ b/src/components/__tests__/GenerationComposer.test.tsx @@ -611,6 +611,141 @@ describe("GenerationComposer", () => { }); }); + it("uses PopiServer task price estimates for selected image models", async () => { + const fetchMock = vi.fn(async (input: RequestInfo | URL) => { + const url = String(input); + if (url.startsWith("/api/points/calculate-task-price")) { + return new Response(JSON.stringify({ + success: true, + data: { + taskPrice: 36, + }, + }), { status: 200 }); + } + throw new Error(`Unexpected fetch: ${url}`); + }); + vi.stubGlobal("fetch", fetchMock); + useWorkflowStore.setState({ + nodes: [ + imageNode("img-1", true, { + imageCount: 4, + selectedModel: { + provider: "popiserver", + modelId: "32", + displayName: "Popi Image", + capabilities: ["text-to-image", "image-to-image"], + metadata: { + aiModelId: 32, + aiModelCode: "gpt-image-2-all", + aiModelCodeAlias: "gpt-image-2-all", + type: 1, + subType: 102, + }, + }, + }), + ], + }); + + render(); + + await waitFor(() => { + expect(screen.getByRole("button", { name: "36 points" })).toBeInTheDocument(); + }); + const priceCall = fetchMock.mock.calls.find(([input]) => String(input).startsWith("/api/points/calculate-task-price")); + expect(priceCall).toBeDefined(); + expect(JSON.parse(String((priceCall?.[1] as RequestInit).body))).toMatchObject({ + type: 1, + subType: 102, + aiModelId: 32, + aiModelCode: "gpt-image-2-all", + aiModelCodeAlias: "gpt-image-2-all", + model: "gpt-image-2-all", + aspectRatio: "16:9", + ratio: "16:9", + resolution: "2K", + width: 1280, + height: 720, + batchSize: 4, + }); + }); + + it("uses the Seedance estimate endpoint for PopiServer Seedance models", async () => { + const fetchMock = vi.fn(async (input: RequestInfo | URL) => { + const url = String(input); + if (url.startsWith("/api/points/estimate")) { + return new Response(JSON.stringify({ + success: true, + data: { + estimated_points: 88, + }, + }), { status: 200 }); + } + throw new Error(`Unexpected fetch: ${url}`); + }); + vi.stubGlobal("fetch", fetchMock); + useWorkflowStore.setState({ + nodes: [ + videoNode("vid-1", true, { + durationSeconds: 8, + resolution: "1080p", + selectedModel: { + provider: "popiserver", + modelId: "51", + displayName: "Seedance Pro", + capabilities: ["text-to-video", "image-to-video"], + metadata: { + aiModelId: 51, + aiModelCode: "doubao-seedance-2-0-260128", + aiModelCodeAlias: "seedance-video-pro", + type: 2, + subType: 203, + }, + }, + }), + ], + }); + + render(); + + await waitFor(() => { + expect(screen.getByRole("button", { name: "88 points" })).toBeInTheDocument(); + }); + expect(fetchMock.mock.calls.some(([input]) => String(input).startsWith("/api/points/calculate-task-price"))).toBe(false); + const estimateCall = fetchMock.mock.calls.find(([input]) => String(input).startsWith("/api/points/estimate")); + expect(JSON.parse(String((estimateCall?.[1] as RequestInit).body))).toMatchObject({ + modelName: "seedance-video-pro", + estimationType: "seedance_video", + seedanceVideo: { + resolution: "1080p", + aspect_ratio: "16:9", + input_video_duration_seconds: 0, + output_video_duration_seconds: 8, + }, + }); + }); + + it("does not show PopiServer points when the model cannot be priced", async () => { + const fetchMock = vi.fn(); + vi.stubGlobal("fetch", fetchMock); + useWorkflowStore.setState({ + nodes: [ + imageNode("img-1", true, { + selectedModel: { + provider: "popiserver", + modelId: "", + displayName: "Select model", + capabilities: ["text-to-image", "image-to-image"], + }, + }), + ], + }); + + render(); + + expect(screen.queryByRole("button", { name: /points/ })).not.toBeInTheDocument(); + expect(fetchMock).not.toHaveBeenCalled(); + }); + it("hydrates NewApiWG point cost for stored models without pricing", async () => { vi.stubGlobal("fetch", vi.fn(async () => new Response(JSON.stringify({ success: true, diff --git a/src/components/composer/GenerationComposer.tsx b/src/components/composer/GenerationComposer.tsx index 1bea0883..95f4880d 100644 --- a/src/components/composer/GenerationComposer.tsx +++ b/src/components/composer/GenerationComposer.tsx @@ -91,6 +91,13 @@ type PointsEstimateResponse = { }; }; +type TaskPriceResponse = { + success?: boolean; + data?: { + taskPrice?: unknown; + }; +}; + type SeedanceVideoEstimatePayload = { modelName: string; estimationType: "seedance_video"; @@ -102,6 +109,22 @@ type SeedanceVideoEstimatePayload = { }; }; +type PopiserverTaskPricePayload = { + type: number; + subType: number; + aiModelId: number; + aiModelCode?: string; + aiModelCodeAlias?: string; + model: string; + aspectRatio: string; + ratio: string; + resolution: string; + duration?: number; + width: number; + height: number; + batchSize: number; +}; + interface ComposerAdapter { nodeType: EditableNodeType; capability: Exclude; @@ -231,6 +254,15 @@ function finiteNumberFromUnknown(value: unknown): number | null { return null; } +function finiteIntegerFromUnknown(value: unknown): number | null { + const number = finiteNumberFromUnknown(value); + return number === null ? null : Math.trunc(number); +} + +function stringFromUnknown(value: unknown): string | null { + return typeof value === "string" && value.trim().length > 0 ? value.trim() : null; +} + function normalizePricingDimensionValue(value: string | number | boolean): string { return String(value).trim().toLowerCase().replace(/\s+/g, ""); } @@ -319,7 +351,20 @@ function getSelectedPointAmount(draft: ComposerDraft, referenceImageCount: numbe } function isSeedanceVideoEstimateModel(model: SelectedModel): boolean { - return model.provider === "newapiwg" && /seedance/i.test(`${model.modelId} ${model.displayName}`); + return /seedance/i.test([ + model.modelId, + model.displayName, + stringFromUnknown(model.metadata?.aiModelCode), + stringFromUnknown(model.metadata?.aiModelCodeAlias), + ].filter(Boolean).join(" ")); +} + +function getPopiserverModelName(model: SelectedModel): string { + return ( + stringFromUnknown(model.metadata?.aiModelCodeAlias) || + stringFromUnknown(model.metadata?.aiModelCode) || + model.modelId + ); } function buildSeedanceVideoEstimatePayload( @@ -327,7 +372,9 @@ function buildSeedanceVideoEstimatePayload( inputVideoDurationSeconds: number ): SeedanceVideoEstimatePayload { return { - modelName: draft.selectedModel.modelId, + modelName: draft.selectedModel.provider === "popiserver" + ? getPopiserverModelName(draft.selectedModel) + : draft.selectedModel.modelId, estimationType: "seedance_video", seedanceVideo: { resolution: draft.resolution || DEFAULT_VIDEO_RESOLUTION, @@ -342,6 +389,89 @@ function getEstimatedPointAmount(response: PointsEstimateResponse): number | nul return finiteNumberFromUnknown(response.data?.estimated_points); } +function getTaskPricePointAmount(response: TaskPriceResponse): number | null { + const amount = finiteNumberFromUnknown(response.data?.taskPrice); + return amount !== null && amount > 0 ? amount : null; +} + +function inferPopiserverTaskType(model: SelectedModel, capability: ComposerCapability): number | null { + const metadataType = finiteIntegerFromUnknown(model.metadata?.type); + if (metadataType) return metadataType; + if (capability === "video" || modelSupportsCapability(model, "video")) return 2; + if (capability === "audio" || modelSupportsCapability(model, "audio")) return 3; + if (capability === "image" || modelSupportsCapability(model, "image")) return 1; + return null; +} + +function inferPopiserverTaskSubType(model: SelectedModel, type: number, referenceImageCount: number): number | null { + const metadataSubType = finiteIntegerFromUnknown(model.metadata?.subType); + if (metadataSubType) return metadataSubType; + const metadataSubTypes = model.metadata?.subTypes; + if (Array.isArray(metadataSubTypes)) { + const first = metadataSubTypes.map(finiteIntegerFromUnknown).find((value): value is number => value !== null); + if (first) return first; + } + if (type === 3) return 301; + if (type === 2) return referenceImageCount > 0 ? 203 : 201; + if (type === 1) return referenceImageCount > 0 ? 103 : 102; + return null; +} + +function dimensionsForAspectRatio(aspectRatio: string): { width: number; height: number } { + if (aspectRatio === "9:16") return { width: 720, height: 1280 }; + if (aspectRatio === "1:1") return { width: 1024, height: 1024 }; + return { width: 1280, height: 720 }; +} + +function buildPopiserverTaskPricePayload( + draft: ComposerDraft, + capability: ComposerCapability, + referenceImageCount: number +): PopiserverTaskPricePayload | null { + const model = draft.selectedModel; + if (model.provider !== "popiserver") return null; + + const aiModelId = finiteIntegerFromUnknown(model.metadata?.aiModelId) ?? finiteIntegerFromUnknown(model.modelId); + if (!aiModelId) return null; + + const type = finiteIntegerFromUnknown(draft.parameters?.type) ?? inferPopiserverTaskType(model, capability); + if (!type) return null; + + const subType = + finiteIntegerFromUnknown(draft.parameters?.subType) ?? + inferPopiserverTaskSubType(model, type, referenceImageCount); + if (!subType) return null; + + const aspectRatio = + stringFromUnknown(draft.parameters?.aspectRatio) || + stringFromUnknown(draft.parameters?.aspect_ratio) || + draft.aspectRatio || + "16:9"; + const dimensions = dimensionsForAspectRatio(aspectRatio); + const duration = finiteIntegerFromUnknown(draft.parameters?.duration) ?? + (type === 2 ? normalizeVideoDurationSeconds(draft.durationSeconds) : null); + const modelName = getPopiserverModelName(model); + + return { + type, + subType, + aiModelId, + aiModelCode: stringFromUnknown(model.metadata?.aiModelCode) || undefined, + aiModelCodeAlias: stringFromUnknown(model.metadata?.aiModelCodeAlias) || undefined, + model: modelName, + aspectRatio, + ratio: aspectRatio, + resolution: + stringFromUnknown(draft.parameters?.resolution) || + draft.resolution || + (type === 2 ? DEFAULT_VIDEO_RESOLUTION : "2K"), + duration: duration ?? undefined, + width: finiteIntegerFromUnknown(draft.parameters?.width) ?? dimensions.width, + height: finiteIntegerFromUnknown(draft.parameters?.height) ?? dimensions.height, + batchSize: finiteIntegerFromUnknown(draft.parameters?.batchSize) ?? (type === 1 ? draft.imageCount : 1), + }; +} + function getNodeVideoDurationSeconds(node: WorkflowNode | undefined): number { if (!node) return 0; const data = node.data as Record; @@ -754,6 +884,7 @@ export function GenerationComposer() { const [isComposerCollapsed, setIsComposerCollapsed] = useState(false); const [referencePreviewIndex, setReferencePreviewIndex] = useState(null); const [seedanceEstimatedPointAmount, setSeedanceEstimatedPointAmount] = useState(null); + const [popiserverEstimatedPointAmount, setPopiserverEstimatedPointAmount] = useState(null); const referenceImageInputRef = useRef(null); const contextRef = useRef(context); @@ -761,6 +892,7 @@ export function GenerationComposer() { const dirtyFieldsRef = useRef(dirtyFields); const newApiWGPricingCacheRef = useRef>(new Map()); const seedanceEstimateCacheRef = useRef>(new Map()); + const popiserverTaskPriceCacheRef = useRef>(new Map()); useEffect(() => { draftRef.current = draft; @@ -842,6 +974,10 @@ export function GenerationComposer() { return getConnectedVideoDurationSeconds(context.node.id, nodes, edges); }, [context, edges, nodes]); + const estimatedReferenceImageCount = context.mode === "node-edit" && (connectedInputs?.images.length ?? 0) > 0 + ? connectedInputs?.images.length ?? 0 + : draft.inputImages.length; + const seedanceEstimatePayload = useMemo(() => { if (!isSeedanceVideoEstimateModel(draft.selectedModel) || !draft.selectedModel.modelId) { return null; @@ -863,6 +999,24 @@ export function GenerationComposer() { }) : null; + const popiserverTaskPricePayload = useMemo(() => { + if (isSeedanceVideoEstimateModel(draft.selectedModel)) return null; + return buildPopiserverTaskPricePayload(draft, context.capability, estimatedReferenceImageCount); + }, [ + context.capability, + draft.aspectRatio, + draft.durationSeconds, + draft.imageCount, + draft.parameters, + draft.resolution, + draft.selectedModel, + estimatedReferenceImageCount, + ]); + + const popiserverTaskPriceKey = popiserverTaskPricePayload + ? JSON.stringify(popiserverTaskPricePayload) + : null; + const imageResolutionOptions = useMemo( () => getImageResolutionOptions(draft.selectedModel), [draft.selectedModel.modelId, draft.selectedModel.provider] @@ -1015,6 +1169,49 @@ export function GenerationComposer() { }; }, [seedanceEstimateKey, seedanceEstimatePayload]); + useEffect(() => { + if (!popiserverTaskPricePayload || !popiserverTaskPriceKey) { + setPopiserverEstimatedPointAmount(null); + return; + } + + const cachedAmount = popiserverTaskPriceCacheRef.current.get(popiserverTaskPriceKey); + if (cachedAmount !== undefined) { + setPopiserverEstimatedPointAmount(cachedAmount); + return; + } + + let cancelled = false; + setPopiserverEstimatedPointAmount(null); + + const loadTaskPrice = async () => { + try { + const response = await fetch("/api/points/calculate-task-price", { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify(popiserverTaskPricePayload), + }); + if (!response.ok) return; + + const data = (await response.json()) as TaskPriceResponse; + const taskPrice = getTaskPricePointAmount(data); + if (taskPrice === null) return; + + popiserverTaskPriceCacheRef.current.set(popiserverTaskPriceKey, taskPrice); + if (!cancelled) setPopiserverEstimatedPointAmount(taskPrice); + } catch { + // Estimation failures should not block generation; the run request still performs the real charge. + } + }; + + void loadTaskPrice(); + return () => { + cancelled = true; + }; + }, [popiserverTaskPriceKey, popiserverTaskPricePayload]); + const isVideoStitchNode = context.mode === "process-node" && context.node?.type === "videoStitch"; const isEaseCurveNode = context.mode === "process-node" && context.node?.type === "easeCurve"; const isProcessNode = isVideoStitchNode || isEaseCurveNode; @@ -1175,8 +1372,9 @@ export function GenerationComposer() { : errorMessage ? t("composer.retryCurrentNode") : nodeEditGenerateTitle; - const selectedUnitPointAmount = seedanceEstimatedPointAmount ?? getSelectedPointAmount(draft, referenceImages.length); - const selectedPointAmount = selectedUnitPointAmount !== null && seedanceEstimatedPointAmount === null && activeCapability === "image" + const dynamicEstimatedPointAmount = seedanceEstimatedPointAmount ?? popiserverEstimatedPointAmount; + const selectedUnitPointAmount = dynamicEstimatedPointAmount ?? getSelectedPointAmount(draft, referenceImages.length); + const selectedPointAmount = selectedUnitPointAmount !== null && dynamicEstimatedPointAmount === null && activeCapability === "image" ? selectedUnitPointAmount * draft.imageCount : selectedUnitPointAmount; const modelPointCostLabel = selectedPointAmount !== null diff --git a/src/store/execution/nanoBananaExecutor.ts b/src/store/execution/nanoBananaExecutor.ts index 2df32027..7890ece0 100644 --- a/src/store/execution/nanoBananaExecutor.ts +++ b/src/store/execution/nanoBananaExecutor.ts @@ -179,6 +179,8 @@ export async function executeNanoBanana( const requestedImageCount = nodeData.imageCount === 2 || nodeData.imageCount === 4 ? nodeData.imageCount : 1; + const usePopiserverBatch = provider === "popiserver" && requestedImageCount > 1; + const requestCount = usePopiserverBatch ? 1 : requestedImageCount; const usedModelId = modelToUse.modelId || nodeData.model; const usedModelDisplayName = modelToUse.displayName || usedModelId; const batchTimestamp = Date.now(); @@ -189,11 +191,20 @@ export async function executeNanoBanana( }> = []; let partialError: string | null = null; - for (let imageIndex = 0; imageIndex < requestedImageCount; imageIndex++) { + for (let requestIndex = 0; requestIndex < requestCount; requestIndex++) { + const payloadForRequest = usePopiserverBatch + ? { + ...requestPayload, + parameters: { + ...(requestPayload.parameters || {}), + batchSize: requestedImageCount, + }, + } + : requestPayload; const response = await fetch("/api/generate", { method: "POST", headers, - body: JSON.stringify(requestPayload), + body: JSON.stringify(payloadForRequest), ...(signal ? { signal } : {}), }); @@ -234,15 +245,24 @@ export async function executeNanoBanana( }); } - if (result.success && result.image) { - const timestamp = batchTimestamp + imageIndex; - const imageId = requestedImageCount === 1 - ? `${timestamp}` - : `${batchTimestamp}-${imageIndex + 1}`; - generatedItems.push({ - id: imageId, - image: result.image, - timestamp, + const resultImages: string[] = result.success && Array.isArray(result.images) && result.images.length > 0 + ? result.images.filter((image: unknown): image is string => typeof image === "string" && image.length > 0) + : result.success && typeof result.image === "string" && result.image.length > 0 + ? [result.image] + : []; + + if (resultImages.length > 0) { + resultImages.forEach((image) => { + const itemIndex = generatedItems.length; + const timestamp = batchTimestamp + itemIndex; + const imageId = requestedImageCount === 1 + ? `${timestamp}` + : `${batchTimestamp}-${itemIndex + 1}`; + generatedItems.push({ + id: imageId, + image, + timestamp, + }); }); continue; } @@ -260,6 +280,10 @@ export async function executeNanoBanana( throw new Error(errorMessage); } + if (usePopiserverBatch && generatedItems.length > 0 && generatedItems.length < requestedImageCount) { + partialError = `Expected ${requestedImageCount} images, got ${generatedItems.length}`; + } + if (generatedItems.length === 0) { const errorMessage = partialError || "Generation failed"; updateNodeData(node.id, { diff --git a/src/types/api.ts b/src/types/api.ts index 5c23fcfc..0e76d679 100644 --- a/src/types/api.ts +++ b/src/types/api.ts @@ -24,6 +24,7 @@ export interface GenerateResponse { success: boolean; requestId?: string; image?: string; + images?: string[]; video?: string; videoUrl?: string; // For large videos, return URL directly audio?: string; // Base64 audio data