diff --git a/src/app/api/generate/providers/__tests__/popiserver.test.ts b/src/app/api/generate/providers/__tests__/popiserver.test.ts index dac6acd9..b5b812aa 100644 --- a/src/app/api/generate/providers/__tests__/popiserver.test.ts +++ b/src/app/api/generate/providers/__tests__/popiserver.test.ts @@ -669,6 +669,8 @@ describe("popiserver generation provider", () => { resultList: [ { images: ["https://example.com/result.png"], + originImages: ["https://example.com/origin.png"], + thumbs: ["https://example.com/result_thumb.webp"], }, ], }, @@ -687,7 +689,8 @@ describe("popiserver generation provider", () => { if (result.status === "completed") { expect(result.output.outputs?.[0]).toMatchObject({ type: "image", - url: "https://example.com/result.png", + url: "https://example.com/origin.png", + previewImg: "https://example.com/result_thumb.webp", }); } }); diff --git a/src/app/api/generate/providers/popiserver.ts b/src/app/api/generate/providers/popiserver.ts index e8505626..d88d6919 100644 --- a/src/app/api/generate/providers/popiserver.ts +++ b/src/app/api/generate/providers/popiserver.ts @@ -64,9 +64,11 @@ type PopiMediaUploadData = type PopiTaskResult = { images?: string[] | null; + thumbs?: string[] | null; video?: string | null; voice?: string | null; cover?: string | null; + coverThumb?: string | null; originImages?: string[] | null; }; @@ -652,9 +654,10 @@ export async function submitPopiTask( function extractOutputFromTask(task: PopiTask, mediaType: string): GenerationOutput { const firstResult = Array.isArray(task.resultList) ? task.resultList[0] : null; if (mediaType === "video") { - const url = firstResult?.video || firstResult?.cover || firstString(task.images || []) || null; + const url = firstResult?.video || null; + const videoCover = firstResult?.cover || firstResult?.coverThumb || null; return url - ? { success: true, outputs: [{ type: "video", data: "", url }] } + ? { success: true, outputs: [{ type: "video", data: "", url, videoCover: videoCover || undefined }] } : { success: false, error: "PopiServer video task completed without output" }; } if (mediaType === "audio") { @@ -665,20 +668,17 @@ function extractOutputFromTask(task: PopiTask, mediaType: string): GenerationOut } const images: { image: string; previewImg?: string }[] = []; task.resultList?.forEach((res) => { - if (Array.isArray(res?.originImages) && res.originImages.length > 0) { - images.push( - ...res.originImages - .filter((item): item is string => typeof item === "string" && item.length > 0) - .map((item, index) => ({ - image: item, - previewImg: res.images?.[index], - })), - ); - return; - } - - collectStrings(res?.images ?? []).forEach((image) => { - images.push({ image }); + const originImages = collectStrings(res?.originImages ?? []); + const resultImages = collectStrings(res?.images ?? []); + const thumbs = collectStrings(res?.thumbs ?? []); + const sourceImages = originImages.length > 0 ? originImages : resultImages; + + sourceImages.forEach((image, index) => { + const resultImage = resultImages[index]; + const previewImg = + thumbs[index] || + (originImages.length > 0 && resultImage && resultImage !== image ? resultImage : undefined); + images.push({ image, previewImg }); }); }); // task.images is the original input/reference image list in Popiserver task diff --git a/src/app/api/generate/route.ts b/src/app/api/generate/route.ts index 5996a7a2..3eda2829 100644 --- a/src/app/api/generate/route.ts +++ b/src/app/api/generate/route.ts @@ -169,6 +169,7 @@ export async function buildMediaResponse( return NextResponse.json({ success: true, videoUrl, + videoCover: output.videoCover, contentType: "video", }); } diff --git a/src/app/api/generate/task/poll/__tests__/route.test.ts b/src/app/api/generate/task/poll/__tests__/route.test.ts new file mode 100644 index 00000000..b06a964f --- /dev/null +++ b/src/app/api/generate/task/poll/__tests__/route.test.ts @@ -0,0 +1,59 @@ +import { describe, expect, it, vi, beforeEach } from "vitest"; +import { NextRequest } from "next/server"; +import { GET } from "../route"; +import { checkPopiTaskOnce } from "@/app/api/generate/providers/popiserver"; + +vi.mock("@/app/api/_auth", () => ({ + requireLogin: vi.fn(() => ({ token: "login-token" })), +})); + +vi.mock("@/app/api/generate/providers/popiserver", () => ({ + checkPopiTaskOnce: vi.fn(), +})); + +describe("GET /api/generate/task/poll", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("returns image task results only in the images array with thumbnails", async () => { + vi.mocked(checkPopiTaskOnce).mockResolvedValue({ + status: "completed", + output: { + success: true, + outputs: [ + { + type: "image", + data: "", + url: "https://statictest.popi.art/media/image/2026/0625/8320.png", + previewImg: "https://statictest.popi.art/media/image/2026/0625/8320_thumb.webp", + }, + ], + }, + }); + + const request = new NextRequest( + "http://localhost:3000/api/generate/task/poll?taskId=3020&mediaType=image" + ); + + const response = await GET(request); + const body = await response.json(); + + expect(response.status).toBe(200); + expect(checkPopiTaskOnce).toHaveBeenCalledWith(request, "login-token", "3020", "image"); + expect(body).toEqual({ + status: "completed", + result: { + success: true, + contentType: "image", + images: [ + { + url: "https://statictest.popi.art/media/image/2026/0625/8320.png", + previewImg: "https://statictest.popi.art/media/image/2026/0625/8320_thumb.webp", + }, + ], + }, + }); + expect(body.result.image).toBeUndefined(); + }); +}); diff --git a/src/app/api/generate/task/poll/route.ts b/src/app/api/generate/task/poll/route.ts index a2f85a17..daf478cc 100644 --- a/src/app/api/generate/task/poll/route.ts +++ b/src/app/api/generate/task/poll/route.ts @@ -15,6 +15,22 @@ async function buildResultResponse( output: GenerationOutputItem, outputs?: GenerationOutputItem[] ): Promise { + if (output.type === "image") { + const images = (outputs && outputs.length > 0 ? outputs : [output]) + .filter((item) => item.type === "image") + .map((item) => ({ + url: item.data || item.url || "", + previewImg: item.previewImg, + })) + .filter((item) => item.url.length > 0); + + return { + success: true, + images, + contentType: "image", + }; + } + const response = await buildMediaResponse(request, token, output, outputs); return (await response.json()) as GenerateResponse; } diff --git a/src/components/GlobalImageHistory.tsx b/src/components/GlobalImageHistory.tsx index cb70e449..2d6f85d6 100644 --- a/src/components/GlobalImageHistory.tsx +++ b/src/components/GlobalImageHistory.tsx @@ -19,6 +19,7 @@ type WorkflowGenerationHistoryItem = { historyIndex: number; mediaType: "image" | "video"; mediaUrl?: string; + coverUrl?: string; timestamp: number; prompt: string; model: string; @@ -79,6 +80,7 @@ export function collectWorkflowGenerationHistory(nodes: WorkflowNode[]): Workflo historyIndex, mediaType: "video", mediaUrl: item.video, + coverUrl: item.videoCover, timestamp: item.timestamp, prompt: item.prompt, model: item.model, @@ -109,6 +111,17 @@ function MediaThumb({ ); } + if (item.mediaType === "video" && item.coverUrl) { + return ( + + ); + } + return item.mediaType === "video" ? (