Browse Source

图片顺序改变问题处理

feature/071
yun 1 week ago
parent
commit
b315d2c060
  1. 173
      src/app/api/generate/providers/__tests__/popiserver.test.ts
  2. 99
      src/utils/popiserverTaskPayload.ts

173
src/app/api/generate/providers/__tests__/popiserver.test.ts

@ -35,6 +35,10 @@ function makeInput(): GenerationInput {
}, },
prompt: "@角色1 和 @角色2 在一起吃火锅", prompt: "@角色1 和 @角色2 在一起吃火锅",
images: ["https://example.com/1.png", "https://example.com/2.png"], images: ["https://example.com/1.png", "https://example.com/2.png"],
referenceSubjectList: [
{ id: 1, type: "image", url: "https://example.com/1.png", name: "图1" },
{ id: 2, type: "image", url: "https://example.com/2.png", name: "图2" },
],
subType: POPI_VIDEO_SUBTYPE_OMNI_REFERENCE, subType: POPI_VIDEO_SUBTYPE_OMNI_REFERENCE,
parameters: { parameters: {
ratio: "16:9", ratio: "16:9",
@ -97,15 +101,11 @@ describe("popiserver generation provider", () => {
expect(body).toMatchObject({ expect(body).toMatchObject({
type: 2, type: 2,
subType: POPI_VIDEO_SUBTYPE_OMNI_REFERENCE, subType: POPI_VIDEO_SUBTYPE_OMNI_REFERENCE,
aiPlatform: "GATEWAY",
origin: "canvas", origin: "canvas",
model: "",
aiModelId: 15, aiModelId: 15,
aiModelCode: "viduq2-pro", aiModelCode: "viduq2-pro",
aiModelCodeAlias: "viduq2-pro", aiModelCodeAlias: "viduq2-pro",
batchSize: 1,
ratio: "16:9", ratio: "16:9",
aspect_ratio: "16:9",
resolution: "720", resolution: "720",
videoRatio: 10, videoRatio: 10,
}); });
@ -197,12 +197,48 @@ describe("popiserver generation provider", () => {
}, },
referenceSubjectList: [ referenceSubjectList: [
{ id: 1, type: "image", url: "https://example.com/1.png", name: "图1" }, { id: 1, type: "image", url: "https://example.com/1.png", name: "图1" },
{ id: 2, type: "image", url: "https://example.com/2.png", name: "图2" },
], ],
}); });
expect(body.generate_audio).toBeUndefined(); expect(body.generate_audio).toBeUndefined();
}); });
it("uses the frontend referenceSubjectList order without rebuilding it from images", async () => {
vi.mocked(global.fetch).mockResolvedValue({
ok: true,
json: () => Promise.resolve({
status: "0000",
message: "ok",
data: { id: 1198 },
}),
} as Response);
const input = makeInput();
input.images = [
"https://popitest-public-1313913486.cos.ap-guangzhou.myqcloud.com/media/image/2026/0702/9535.jpeg",
"https://popitest-public-1313913486.cos.ap-guangzhou.myqcloud.com/media/image/2026/0701/9225.png",
];
input.referenceSubjectList = [
{
id: 1,
type: "image",
url: "https://popitest-public-1313913486.cos.ap-guangzhou.myqcloud.com/media/image/2026/0701/9225.png",
name: "图1",
},
{
id: 2,
type: "image",
url: "https://popitest-public-1313913486.cos.ap-guangzhou.myqcloud.com/media/image/2026/0702/9535.jpeg",
name: "图2",
},
];
await submitPopiTask(makeRequest(), "login-token", input);
const [, init] = vi.mocked(global.fetch).mock.calls[0];
const body = JSON.parse(String(init?.body));
expect(body.referenceSubjectList).toEqual(input.referenceSubjectList);
});
it("collects all task ids from batched task create responses", async () => { it("collects all task ids from batched task create responses", async () => {
vi.mocked(global.fetch).mockResolvedValue({ vi.mocked(global.fetch).mockResolvedValue({
ok: true, ok: true,
@ -239,10 +275,7 @@ describe("popiserver generation provider", () => {
const [, init] = vi.mocked(global.fetch).mock.calls[0]; const [, init] = vi.mocked(global.fetch).mock.calls[0];
const body = JSON.parse(String(init?.body)); const body = JSON.parse(String(init?.body));
expect(body).toMatchObject({ expect(body).toMatchObject({
ratio: "9:16",
aspect_ratio: "9:16", aspect_ratio: "9:16",
width: 720,
height: 1280,
}); });
expect(body.aspectRatio).toBeUndefined(); expect(body.aspectRatio).toBeUndefined();
}); });
@ -283,7 +316,6 @@ describe("popiserver generation provider", () => {
const body = JSON.parse(String(init?.body)); const body = JSON.parse(String(init?.body));
expect(body).toMatchObject({ expect(body).toMatchObject({
chatPrompt: "比例:16:9\n清晰度:1K\n提示词:create a cinematic cat", chatPrompt: "比例:16:9\n清晰度:1K\n提示词:create a cinematic cat",
model: "",
ratio: "16:9", ratio: "16:9",
resolution: "1K", resolution: "1K",
}); });
@ -325,15 +357,13 @@ describe("popiserver generation provider", () => {
const body = JSON.parse(String(init?.body)); const body = JSON.parse(String(init?.body));
expect(body).toMatchObject({ expect(body).toMatchObject({
chatPrompt: "比例:9:16\n清晰度:2K\n提示词:create a clean product render", chatPrompt: "比例:9:16\n清晰度:2K\n提示词:create a clean product render",
model: "",
ratio: "9:16", ratio: "9:16",
aspect_ratio: "9:16",
resolution: "2K", resolution: "2K",
}); });
expect(body.aspectRatio).toBeUndefined(); expect(body.aspectRatio).toBeUndefined();
}); });
it("builds referenceSubjectList for image tasks without @ in names", async () => { it("uses frontend referenceSubjectList for image tasks", async () => {
vi.mocked(global.fetch).mockResolvedValue({ vi.mocked(global.fetch).mockResolvedValue({
ok: true, ok: true,
json: () => Promise.resolve({ json: () => Promise.resolve({
@ -359,18 +389,18 @@ describe("popiserver generation provider", () => {
}; };
input.prompt = "@图1 作为人物参考生成海报"; input.prompt = "@图1 作为人物参考生成海报";
input.images = ["https://statictest.popi.art/media/ani/character/2026/0512/4540.jpg"]; input.images = ["https://statictest.popi.art/media/ani/character/2026/0512/4540.jpg"];
input.subType = 103; input.referenceSubjectList = [
input.parameters = {
ratio: "16:9",
resolution: "1K",
referenceSubjectList: [
{ {
id: 1, id: 1,
type: "image", type: "image",
url: "stale-image-url", url: "https://statictest.popi.art/media/ani/character/2026/0512/4540.jpg",
name: "@图1", name: "图1",
}, },
], ];
input.subType = 103;
input.parameters = {
ratio: "16:9",
resolution: "1K",
}; };
const result = await submitPopiTask(makeRequest(), "login-token", input); const result = await submitPopiTask(makeRequest(), "login-token", input);
@ -410,15 +440,14 @@ describe("popiserver generation provider", () => {
expect(JSON.parse(String(init?.body))).toEqual({ expect(JSON.parse(String(init?.body))).toEqual({
type: 3, type: 3,
chatPrompt: "我是一个小白兔,白白的小白兔", chatPrompt: "我是一个小白兔,白白的小白兔",
model: "",
origin: "canvas", origin: "canvas",
aiPlatform: "GATEWAY",
aiModelName: "Speech 2.8 HD", aiModelName: "Speech 2.8 HD",
aiModelId: 30, aiModelId: 30,
aiModelCode: "speech-2.8-hd", aiModelCode: "speech-2.8-hd",
aiModelCodeAlias: "speech-2.8-hd", aiModelCodeAlias: "speech-2.8-hd",
subType: 301, subType: 301,
batchSize: 1, referenceSubjectList: undefined,
extraTaskParams: {},
}); });
}); });
@ -585,7 +614,7 @@ describe("popiserver generation provider", () => {
]); ]);
}); });
it("builds referenceSubjectList from final image and video URLs", async () => { it("uses frontend referenceSubjectList with image, video, and audio references", async () => {
vi.mocked(global.fetch).mockResolvedValue({ vi.mocked(global.fetch).mockResolvedValue({
ok: true, ok: true,
json: () => Promise.resolve({ json: () => Promise.resolve({
@ -612,39 +641,39 @@ describe("popiserver generation provider", () => {
}; };
input.prompt = "@图1的人物参考@视频2的人物的动作跳舞"; input.prompt = "@图1的人物参考@视频2的人物的动作跳舞";
input.images = ["https://statictest.popi.art/media/ani/character/2026/0512/4540.jpg"]; input.images = ["https://statictest.popi.art/media/ani/character/2026/0512/4540.jpg"];
input.dynamicInputs = { input.referenceSubjectList = [
videos: ["https://statictest.popi.art/media/aiGen/2026/0603/5470.mp4"],
voices: ["https://statictest.popi.art/media/audio/2026/0603/5470.mp3"],
};
input.parameters = {
aiModelName: "seedance 2.0-fast",
aiPlatform: "seedance 2.0",
model: "seedance 2.0",
ratio: "16:9",
resolution: "480P",
duration: 5,
batchSize: 1,
referenceSubjectList: [
{ {
id: 1, id: 1,
type: "image", type: "image",
url: "stale-image-url", url: "https://statictest.popi.art/media/ani/character/2026/0512/4540.jpg",
name: "@图1", name: "图1",
}, },
{ {
id: 2, id: 2,
type: "video", type: "video",
url: "stale-video-url", url: "https://statictest.popi.art/media/aiGen/2026/0603/5470.mp4",
name: "@视频2", name: "视频2",
duration: 5.033, duration: 5.033,
}, },
{ {
id: 3, id: 3,
type: "audio", type: "audio",
url: "stale-audio-url", url: "https://statictest.popi.art/media/audio/2026/0603/5470.mp3",
name: "@音频3", name: "音频3",
}, },
], ];
input.dynamicInputs = {
videos: ["https://statictest.popi.art/media/aiGen/2026/0603/5470.mp4"],
voices: ["https://statictest.popi.art/media/audio/2026/0603/5470.mp3"],
};
input.parameters = {
aiModelName: "seedance 2.0-fast",
aiPlatform: "seedance 2.0",
model: "seedance 2.0",
ratio: "16:9",
resolution: "480P",
duration: 5,
batchSize: 1,
}; };
input.subType = POPI_VIDEO_SUBTYPE_OMNI_REFERENCE; input.subType = POPI_VIDEO_SUBTYPE_OMNI_REFERENCE;
@ -666,33 +695,11 @@ describe("popiserver generation provider", () => {
resolution: "480P", resolution: "480P",
duration: 5, duration: 5,
batchSize: 1, batchSize: 1,
width: 1280,
height: 720,
chatPrompt: "@图1的人物参考@视频2的人物的动作跳舞", chatPrompt: "@图1的人物参考@视频2的人物的动作跳舞",
images: ["https://statictest.popi.art/media/ani/character/2026/0512/4540.jpg"], images: ["https://statictest.popi.art/media/ani/character/2026/0512/4540.jpg"],
videos: ["https://statictest.popi.art/media/aiGen/2026/0603/5470.mp4"], videos: ["https://statictest.popi.art/media/aiGen/2026/0603/5470.mp4"],
voices: ["https://statictest.popi.art/media/audio/2026/0603/5470.mp3"], voices: ["https://statictest.popi.art/media/audio/2026/0603/5470.mp3"],
referenceSubjectList: [ referenceSubjectList: input.referenceSubjectList,
{
id: 1,
type: "image",
url: "https://statictest.popi.art/media/ani/character/2026/0512/4540.jpg",
name: "图1",
},
{
id: 2,
type: "video",
url: "https://statictest.popi.art/media/aiGen/2026/0603/5470.mp4",
name: "视频2",
duration: 5.033,
},
{
id: 3,
type: "audio",
url: "https://statictest.popi.art/media/audio/2026/0603/5470.mp3",
name: "音频3",
},
],
}); });
}); });
@ -714,43 +721,13 @@ describe("popiserver generation provider", () => {
input.dynamicInputs = { input.dynamicInputs = {
videos: ["https://statictest.popi.art/media/aiGen/2026/0603/5470.mp4"], videos: ["https://statictest.popi.art/media/aiGen/2026/0603/5470.mp4"],
}; };
input.parameters = {
...input.parameters,
referenceSubjectList: [
{
id: 1,
type: "image",
url: "stale-image-url",
name: "@图1",
},
{
id: 2,
type: "video",
url: "stale-video-url",
name: "@视频2",
},
],
};
await submitPopiTask(makeRequest(), "login-token", input); await submitPopiTask(makeRequest(), "login-token", input);
const [, init] = vi.mocked(global.fetch).mock.calls[0]; const [, init] = vi.mocked(global.fetch).mock.calls[0];
const body = JSON.parse(String(init?.body)); const body = JSON.parse(String(init?.body));
expect(body.videos).toBeUndefined(); expect(body.videos).toBeUndefined();
expect(body.referenceSubjectList).toEqual([ expect(body.referenceSubjectList).toEqual(input.referenceSubjectList);
{
id: 1,
type: "image",
url: "https://example.com/1.png",
name: "图1",
},
{
id: 2,
type: "image",
url: "https://example.com/2.png",
name: "图2",
},
]);
}); });
it("extracts completed image task output from task list", async () => { it("extracts completed image task output from task list", async () => {

99
src/utils/popiserverTaskPayload.ts

@ -8,14 +8,6 @@ export type PopiModelDetailForTaskPayload = {
aiModelCodeAlias?: string; aiModelCodeAlias?: string;
}; };
type ReferenceSubject = {
id: number;
type: "image" | "video" | "audio";
url: string;
name: string;
duration?: number;
};
const POPISERVER_LEGACY_AUDIO_KEYS = [ const POPISERVER_LEGACY_AUDIO_KEYS = [
"audio", "audio",
"audios", "audios",
@ -98,39 +90,6 @@ function buildPopiChatPrompt(
].join("\n"); ].join("\n");
} }
function normalizeReferenceSubjectName(name: string): string {
return name.replace(/^@+/, "");
}
function referenceSubjectMetadata(referenceSubjectList: unknown): ReferenceSubject[] {
if (!Array.isArray(referenceSubjectList)) return [];
return referenceSubjectList.flatMap((item) => {
if (!item || typeof item !== "object" || Array.isArray(item)) return [];
const subject = item as Record<string, unknown>;
const type =
subject.type === "video"
? "video"
: subject.type === "image"
? "image"
: subject.type === "audio"
? "audio"
: null;
if (!type) return [];
const id = asNumber(subject.id);
const name = asString(subject.name);
const url = asString(subject.url);
const duration = asNumber(subject.duration);
return [{
id: id ?? 0,
type,
url: url ?? "",
name: normalizeReferenceSubjectName(name ?? ""),
...(duration !== null ? { duration } : {}),
}];
});
}
function removeLegacyAudioParameters(parameters: Record<string, unknown>): Record<string, unknown> { function removeLegacyAudioParameters(parameters: Record<string, unknown>): Record<string, unknown> {
return Object.fromEntries( return Object.fromEntries(
Object.entries(parameters).filter( Object.entries(parameters).filter(
@ -139,57 +98,6 @@ function removeLegacyAudioParameters(parameters: Record<string, unknown>): Recor
); );
} }
function buildReferenceSubjectList(
images: string[],
videos: string[],
voices: string[],
referenceSubjectList: unknown
): ReferenceSubject[] {
const metadata = referenceSubjectMetadata(referenceSubjectList);
const metadataByType = {
image: metadata.filter((item) => item.type === "image"),
video: metadata.filter((item) => item.type === "video"),
audio: metadata.filter((item) => item.type === "audio"),
};
const subjects: ReferenceSubject[] = [];
images.forEach((url, index) => {
const id = subjects.length + 1;
const meta = metadataByType.image[index];
subjects.push({
id,
type: "image",
url,
name: normalizeReferenceSubjectName(meta?.name || `${id}`),
});
});
videos.forEach((url, index) => {
const id = subjects.length + 1;
const meta = metadataByType.video[index];
subjects.push({
id,
type: "video",
url,
name: normalizeReferenceSubjectName(meta?.name || `视频${id}`),
...(meta?.duration ? { duration: meta.duration } : {}),
});
});
voices.forEach((url, index) => {
const id = subjects.length + 1;
const meta = metadataByType.audio[index];
subjects.push({
id,
type: "audio",
url,
name: normalizeReferenceSubjectName(meta?.name || `音频${id}`),
});
});
return subjects;
}
export function buildPopiTaskBody( export function buildPopiTaskBody(
input: GenerationInput, input: GenerationInput,
modelDetail: PopiModelDetailForTaskPayload | null modelDetail: PopiModelDetailForTaskPayload | null
@ -197,7 +105,7 @@ export function buildPopiTaskBody(
const rawParameters = removeLegacyAudioParameters(input.parameters || {}); const rawParameters = removeLegacyAudioParameters(input.parameters || {});
const rawExtraTaskParams = removeLegacyAudioParameters(input.extraTaskParams || {}); const rawExtraTaskParams = removeLegacyAudioParameters(input.extraTaskParams || {});
const aspectRatio = resolveAspectRatio(rawParameters); const aspectRatio = resolveAspectRatio(rawParameters);
const { referenceSubjectList: _referenceSubjectList, ...parameters } = rawParameters; const parameters = rawParameters;
const selectedModel = { const selectedModel = {
provider: "popiserver" as const, provider: "popiserver" as const,
modelId: input.model.id, modelId: input.model.id,
@ -226,9 +134,6 @@ export function buildPopiTaskBody(
const voices = modelSupportsReferenceMedia(selectedModel, "audio", rawParameters) const voices = modelSupportsReferenceMedia(selectedModel, "audio", rawParameters)
? input.voices || [] ? input.voices || []
: []; : [];
const referenceSubjectList = type === 1 || type === 2
? buildReferenceSubjectList([...new Set(images)], [...new Set(videos)], [...new Set(voices)], input.referenceSubjectList)
: [];
const voiceId = const voiceId =
asString(input.audioVoiceId) ?? asString(input.audioVoiceId) ??
asString(parameters.voiceId) ?? asString(parameters.voiceId) ??
@ -249,7 +154,7 @@ export function buildPopiTaskBody(
...(asNumber(input.batchSize) !== null || asNumber(parameters.batchSize) !== null ...(asNumber(input.batchSize) !== null || asNumber(parameters.batchSize) !== null
? { batchSize: asNumber(input.batchSize) ?? asNumber(parameters.batchSize) } ? { batchSize: asNumber(input.batchSize) ?? asNumber(parameters.batchSize) }
: {}), : {}),
referenceSubjectList, referenceSubjectList: input.referenceSubjectList,
extraTaskParams: rawExtraTaskParams, extraTaskParams: rawExtraTaskParams,
}; };

Loading…
Cancel
Save