diff --git a/src/app/api/generate/providers/__tests__/popiserver.test.ts b/src/app/api/generate/providers/__tests__/popiserver.test.ts index fa682629..06b55b56 100644 --- a/src/app/api/generate/providers/__tests__/popiserver.test.ts +++ b/src/app/api/generate/providers/__tests__/popiserver.test.ts @@ -95,11 +95,40 @@ describe("popiserver generation provider", () => { aiModelCodeAlias: "viduq2-pro", batchSize: 1, ratio: "16:9", + aspect_ratio: "16:9", resolution: "720", videoRatio: 10, }); expect(body.aspectRatio).toBeUndefined(); - expect(body.aspect_ratio).toBeUndefined(); + }); + + it("maps aspect_ratio into PopiServer task ratio fields", async () => { + vi.mocked(global.fetch).mockResolvedValue({ + ok: true, + json: () => Promise.resolve({ + status: "0000", + message: "ok", + data: { id: 1195 }, + }), + } as Response); + + const input = makeInput(); + input.parameters = { + aspect_ratio: "9:16", + resolution: "720", + }; + + await submitPopiTask(makeRequest(), "login-token", input); + + const [, init] = vi.mocked(global.fetch).mock.calls[0]; + const body = JSON.parse(String(init?.body)); + expect(body).toMatchObject({ + ratio: "9:16", + aspect_ratio: "9:16", + width: 720, + height: 1280, + }); + expect(body.aspectRatio).toBeUndefined(); }); it("embeds ratio and resolution in chatPrompt for gpt-image-2-vip", async () => { @@ -182,10 +211,10 @@ describe("popiserver generation provider", () => { chatPrompt: "比例:9:16\n清晰度:2K\n提示词:create a clean product render", model: "", ratio: "9:16", + aspect_ratio: "9:16", resolution: "2K", }); expect(body.aspectRatio).toBeUndefined(); - expect(body.aspect_ratio).toBeUndefined(); }); it("builds referenceSubjectList for image tasks without @ in names", async () => { diff --git a/src/app/api/generate/providers/popiserver.ts b/src/app/api/generate/providers/popiserver.ts index ef402354..26500509 100644 --- a/src/app/api/generate/providers/popiserver.ts +++ b/src/app/api/generate/providers/popiserver.ts @@ -332,7 +332,7 @@ function inferTaskSubType(input: GenerationInput, type: number): number { } function resolveAspectRatio(parameters: Record | undefined): string { - return asString(parameters?.ratio) ?? asString(parameters?.aspectRatio) ?? "16:9"; + return asString(parameters?.ratio) ?? asString(parameters?.aspectRatio) ?? asString(parameters?.aspect_ratio) ?? "16:9"; } function removePopiRatioAliases(parameters: Record): Record { @@ -544,6 +544,7 @@ function buildPopiTaskBody(input: GenerationInput, modelDetail: PopiAIModel | nu width: asNumber(parameters.width) ?? dimensions.width, height: asNumber(parameters.height) ?? dimensions.height, ratio: aspectRatio, + aspect_ratio: aspectRatio, duration: asNumber(parameters.duration) ?? undefined, resolution: asString(parameters.resolution) ?? "720p", }; diff --git a/src/app/api/generate/route.ts b/src/app/api/generate/route.ts index b8345116..5996a7a2 100644 --- a/src/app/api/generate/route.ts +++ b/src/app/api/generate/route.ts @@ -25,6 +25,7 @@ import { ApiError, apiErrorResponse } from "../_errors"; import { resolveUserGatewayApiKey } from "../_gatewayApiKey"; import { resolveTemporaryImageUrlsInArray } from "@/lib/images/temporaryUrls"; import { isDataUrl, uploadDataUrlToPopiMedia } from "../_popiMedia"; +import { normalizeReferenceSubjectListIds } from "@/utils/referenceSubjectList"; // Re-export for backward compatibility (test file imports from route) export const clearFalInputMappingCache = _clearFalInputMappingCache; @@ -316,7 +317,7 @@ export async function POST(request: NextRequest) { images: stringArrayFromUnknown(images), videos: stringArrayFromUnknown(videos), voices: stringArrayFromUnknown(voices), - parameters, + parameters: normalizeReferenceSubjectListIds(parameters), dynamicInputs, }; @@ -357,7 +358,7 @@ export async function POST(request: NextRequest) { images: stringArrayFromUnknown(images), videos: stringArrayFromUnknown(videos), voices: stringArrayFromUnknown(voices), - parameters, + parameters: normalizeReferenceSubjectListIds(parameters), dynamicInputs, }; diff --git a/src/app/api/generate/task/create/__tests__/route.test.ts b/src/app/api/generate/task/create/__tests__/route.test.ts index c4c5ad7f..3d6a7ab3 100644 --- a/src/app/api/generate/task/create/__tests__/route.test.ts +++ b/src/app/api/generate/task/create/__tests__/route.test.ts @@ -42,7 +42,13 @@ describe("/api/generate/task/create route", () => { isSupportAudios: true, }, }, - parameters: { resolution: "720p" }, + parameters: { + resolution: "720p", + referenceSubjectList: [ + { id: "node-image", type: "image", url: "https://cdn.example.com/ref.png", name: "图1" }, + { id: "node-video", type: "video", url: "https://cdn.example.com/ref.mp4", name: "视频2", duration: 4 }, + ], + }, dynamicInputs: { prompt: "make a video" }, })); @@ -54,7 +60,13 @@ describe("/api/generate/task/create route", () => { videos: ["https://cdn.example.com/ref.mp4"], voices: ["https://cdn.example.com/ref.mp3"], prompt: "make a video", - parameters: { resolution: "720p" }, + parameters: { + resolution: "720p", + referenceSubjectList: [ + { id: 1, type: "image", url: "https://cdn.example.com/ref.png", name: "图1" }, + { id: 2, type: "video", url: "https://cdn.example.com/ref.mp4", name: "视频2", duration: 4 }, + ], + }, dynamicInputs: { prompt: "make a video" }, model: { id: "15", diff --git a/src/app/api/generate/task/create/route.ts b/src/app/api/generate/task/create/route.ts index 912ef2db..ac94c0c6 100644 --- a/src/app/api/generate/task/create/route.ts +++ b/src/app/api/generate/task/create/route.ts @@ -4,6 +4,7 @@ import type { GenerationInput, ModelCapability } from "@/lib/providers/types"; import { submitPopiTask } from "../../providers/popiserver"; import { ApiError, apiErrorResponse } from "@/app/api/_errors"; import { requireLogin } from "@/app/api/_auth"; +import { normalizeReferenceSubjectListIds } from "@/utils/referenceSubjectList"; export const dynamic = "force-dynamic"; export const maxDuration = 60; @@ -51,7 +52,7 @@ export async function POST(request: NextRequest) { images: body.images ? [...body.images] : [], videos: body.videos ? [...body.videos] : [], voices: body.voices ? [...body.voices] : [], - parameters: body.parameters, + parameters: normalizeReferenceSubjectListIds(body.parameters), dynamicInputs: body.dynamicInputs, }; 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 index 760e2e0c..f19e8913 100644 --- a/src/app/api/points/calculate-task-price/__tests__/route.test.ts +++ b/src/app/api/points/calculate-task-price/__tests__/route.test.ts @@ -83,4 +83,42 @@ describe("/api/points/calculate-task-price route", () => { } ); }); + + it("normalizes reference subject ids before forwarding task price requests", async () => { + process.env.POPIART_API_BASE_URL = "https://popi.example"; + const payload = { + type: 1, + subType: 102, + aiModelId: 32, + referenceSubjectList: [ + { id: "subject-image-a", type: "image", url: "https://example.com/a.png", name: "image 1" }, + { id: "subject-video-b", type: "video", url: "https://example.com/b.mp4", name: "video 2" }, + ], + }; + const fetchMock = vi.fn().mockResolvedValue( + new Response(JSON.stringify({ success: true, data: { taskPrice: 24 } }), { + status: 200, + headers: { "Content-Type": "application/json" }, + }) + ); + global.fetch = fetchMock; + + const response = await POST(createPostRequest(payload, { + Authorization: "Bearer user-token", + })); + + expect(response.status).toBe(200); + expect(fetchMock).toHaveBeenCalledWith( + "https://popi.example/api_client/anime/task/calculateTaskPrice", + expect.objectContaining({ + body: JSON.stringify({ + ...payload, + referenceSubjectList: [ + { id: 1, type: "image", url: "https://example.com/a.png", name: "image 1" }, + { id: 2, type: "video", url: "https://example.com/b.mp4", name: "video 2" }, + ], + }), + }) + ); + }); }); diff --git a/src/app/api/points/calculate-task-price/route.ts b/src/app/api/points/calculate-task-price/route.ts index 3237a45a..3bffc9fa 100644 --- a/src/app/api/points/calculate-task-price/route.ts +++ b/src/app/api/points/calculate-task-price/route.ts @@ -3,6 +3,7 @@ import { requireLogin } from "@/app/api/_auth"; import { ApiError, apiErrorResponse } from "@/app/api/_errors"; import { logGatewayRequest, logGatewayResponse } from "@/app/api/_gatewayLogging"; import { getPopiartApiBaseUrl } from "@/app/api/_popiartBaseUrl"; +import { normalizeReferenceSubjectListIds } from "@/utils/referenceSubjectList"; const UPSTREAM_CALCULATE_TASK_PRICE_PATH = "/api_client/anime/task/calculateTaskPrice"; @@ -22,7 +23,9 @@ export async function POST(request: NextRequest) { throw ApiError.badRequest("aiModelId is required"); } - console.info("[popiserver calculateTaskPrice] request body", body); + const upstreamBody = normalizeReferenceSubjectListIds(body) ?? body; + + console.info("[popiserver calculateTaskPrice] request body", upstreamBody); const url = getUpstreamUrl(request); const init: RequestInit = { @@ -33,19 +36,19 @@ export async function POST(request: NextRequest) { Authorization: `Bearer ${token}`, token, }, - body: JSON.stringify(body), + body: JSON.stringify(upstreamBody), cache: "no-store", }; const startedAt = Date.now(); logGatewayRequest("Calling task price API", url, init, { - aiModelId: body.aiModelId, - subType: body.subType, + aiModelId: upstreamBody.aiModelId, + subType: upstreamBody.subType, }); const response = await fetch(url, init); await logGatewayResponse("Task price API response", url, init, response, { - aiModelId: body.aiModelId, - subType: body.subType, + aiModelId: upstreamBody.aiModelId, + subType: upstreamBody.subType, }, startedAt); const payload = await response.json().catch(() => null); console.info("[popiserver calculateTaskPrice] response", { diff --git a/src/lib/popiartTask/runTask.ts b/src/lib/popiartTask/runTask.ts index 13490088..3306cd72 100644 --- a/src/lib/popiartTask/runTask.ts +++ b/src/lib/popiartTask/runTask.ts @@ -1,6 +1,7 @@ "use client"; import type { GenerateResponse, SelectedModel } from "@/types"; +import { normalizeReferenceSubjectListIds } from "@/utils/referenceSubjectList"; export type PopiartTaskPayload = { images: string[]; @@ -43,6 +44,7 @@ function omitSelectedModelProvider(selectedModel: SelectedModel): Omit { + it("maps reference subject ids to numeric sequence values", () => { + const parameters = normalizeReferenceSubjectListIds({ + referenceSubjectList: [ + { id: "node-a", type: "image", url: "https://example.com/a.png", name: "图1" }, + { id: "node-b", type: "video", url: "https://example.com/b.mp4", name: "视频2", duration: 3 }, + ], + resolution: "720p", + }); + + expect(parameters).toEqual({ + referenceSubjectList: [ + { id: 1, type: "image", url: "https://example.com/a.png", name: "图1" }, + { id: 2, type: "video", url: "https://example.com/b.mp4", name: "视频2", duration: 3 }, + ], + resolution: "720p", + }); + }); + + it("leaves parameters without reference subjects unchanged", () => { + const parameters = { resolution: "720p" }; + + expect(normalizeReferenceSubjectListIds(parameters)).toBe(parameters); + }); +}); diff --git a/src/utils/referenceSubjectList.ts b/src/utils/referenceSubjectList.ts new file mode 100644 index 00000000..ac7c678e --- /dev/null +++ b/src/utils/referenceSubjectList.ts @@ -0,0 +1,17 @@ +export function normalizeReferenceSubjectListIds( + parameters: Record | undefined +): Record | undefined { + const referenceSubjectList = parameters?.referenceSubjectList; + if (!Array.isArray(referenceSubjectList)) return parameters; + + return { + ...parameters, + referenceSubjectList: referenceSubjectList.map((item, index) => { + if (!item || typeof item !== "object" || Array.isArray(item)) return item; + return { + ...item, + id: index + 1, + }; + }), + }; +}