Browse Source

增加模型积分展示

TEST-s
weige 2 months ago
parent
commit
2efbe9abdb
  1. 26
      src/app/api/generate/__tests__/route.test.ts
  2. 2
      src/app/api/generate/poll/route.ts
  3. 34
      src/app/api/generate/providers/__tests__/popiserver.test.ts
  4. 25
      src/app/api/generate/providers/popiserver.ts
  5. 14
      src/app/api/generate/route.ts
  6. 86
      src/app/api/points/calculate-task-price/__tests__/route.test.ts
  7. 56
      src/app/api/points/calculate-task-price/route.ts
  8. 135
      src/components/__tests__/GenerationComposer.test.tsx
  9. 206
      src/components/composer/GenerationComposer.tsx
  10. 36
      src/store/execution/nanoBananaExecutor.ts
  11. 1
      src/types/api.ts

26
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";

2
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)

34
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" },
]);
}
});
});

25
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" };
}

14
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<GenerateResponse>({
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<GenerateResponse>({
success: true,
image: output.data || output.url,
image,
...(images && images.length > 1 ? { images } : {}),
contentType: "image",
});
}

86
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<string, string>): 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",
}
);
});
});

56
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<string, unknown> | 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");
}
}

135
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(<GenerationComposer />);
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(<GenerationComposer />);
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(<GenerationComposer />);
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,

206
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<TData extends WorkflowNodeData> {
nodeType: EditableNodeType;
capability: Exclude<ComposerCapability, "all" | "3d">;
@ -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<string, unknown>;
@ -754,6 +884,7 @@ export function GenerationComposer() {
const [isComposerCollapsed, setIsComposerCollapsed] = useState(false);
const [referencePreviewIndex, setReferencePreviewIndex] = useState<number | null>(null);
const [seedanceEstimatedPointAmount, setSeedanceEstimatedPointAmount] = useState<number | null>(null);
const [popiserverEstimatedPointAmount, setPopiserverEstimatedPointAmount] = useState<number | null>(null);
const referenceImageInputRef = useRef<HTMLInputElement>(null);
const contextRef = useRef(context);
@ -761,6 +892,7 @@ export function GenerationComposer() {
const dirtyFieldsRef = useRef(dirtyFields);
const newApiWGPricingCacheRef = useRef<Map<string, SelectedModel["pricing"]>>(new Map());
const seedanceEstimateCacheRef = useRef<Map<string, number>>(new Map());
const popiserverTaskPriceCacheRef = useRef<Map<string, number>>(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

36
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,16 +245,25 @@ export async function executeNanoBanana(
});
}
if (result.success && result.image) {
const timestamp = batchTimestamp + imageIndex;
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}-${imageIndex + 1}`;
: `${batchTimestamp}-${itemIndex + 1}`;
generatedItems.push({
id: imageId,
image: result.image,
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, {

1
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

Loading…
Cancel
Save