diff --git a/src/app/api/generate/providers/__tests__/popiserver.test.ts b/src/app/api/generate/providers/__tests__/popiserver.test.ts index 0091e327..c5fba3e7 100644 --- a/src/app/api/generate/providers/__tests__/popiserver.test.ts +++ b/src/app/api/generate/providers/__tests__/popiserver.test.ts @@ -787,6 +787,7 @@ describe("popiserver generation provider", () => { images: ["https://example.com/result.png"], originImages: ["https://example.com/origin.png"], thumbs: ["https://example.com/result_thumb.webp"], + dimensions: [{ width: 1600, height: 900 }], }, ], }, @@ -807,6 +808,7 @@ describe("popiserver generation provider", () => { type: "image", url: "https://example.com/origin.png", previewImg: "https://example.com/result_thumb.webp", + dimensions: { width: 1600, height: 900 }, }); } }); @@ -823,8 +825,8 @@ describe("popiserver generation provider", () => { id: 1191, status: 2, resultList: [ - { images: ["https://example.com/result-1.png"] }, - { images: ["https://example.com/result-2.png"] }, + { images: ["https://example.com/result-1.png"], width: 1200, height: 800 }, + { images: ["https://example.com/result-2.png"], widths: [900], heights: [1200] }, { images: ["https://example.com/result-3.png"] }, ], }, @@ -838,8 +840,8 @@ describe("popiserver generation provider", () => { 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-1.png", dimensions: { width: 1200, height: 800 } }, + { type: "image", data: "", url: "https://example.com/result-2.png", dimensions: { width: 900, height: 1200 } }, { 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 3c431960..58baecb7 100644 --- a/src/app/api/generate/providers/popiserver.ts +++ b/src/app/api/generate/providers/popiserver.ts @@ -61,6 +61,15 @@ type PopiTaskResult = { cover?: string | null; coverThumb?: string | null; originImages?: string[] | null; + width?: number | string | null; + height?: number | string | null; + widths?: Array | null; + heights?: Array | null; + dimensions?: unknown; + imageWidth?: number | string | null; + imageHeight?: number | string | null; + imageWidths?: Array | null; + imageHeights?: Array | null; }; type PopiTask = { @@ -123,6 +132,48 @@ function collectStrings(values: unknown[]): string[] { return items; } +function readPositiveNumber(value: unknown): number | null { + const numberValue = typeof value === "number" + ? value + : typeof value === "string" && value.trim().length > 0 + ? Number(value) + : Number.NaN; + return Number.isFinite(numberValue) && numberValue > 0 ? numberValue : null; +} + +function readDimensionObject(value: unknown): { width: number; height: number } | null { + if (!value || typeof value !== "object" || Array.isArray(value)) return null; + const record = value as Record; + const width = readPositiveNumber( + record.width ?? record.w ?? record.imageWidth ?? record.originWidth ?? record.naturalWidth + ); + const height = readPositiveNumber( + record.height ?? record.h ?? record.imageHeight ?? record.originHeight ?? record.naturalHeight + ); + return width && height ? { width, height } : null; +} + +function readIndexedDimension(value: unknown, index: number): { width: number; height: number } | null { + if (!value) return null; + if (Array.isArray(value)) { + return readDimensionObject(value[index]) ?? readIndexedDimension(value[0], index); + } + return readDimensionObject(value); +} + +function readPopiImageDimensions(res: PopiTaskResult, index: number): { width: number; height: number } | null { + const dimensions = readIndexedDimension(res.dimensions, index); + if (dimensions) return dimensions; + + const indexedWidth = readPositiveNumber(res.widths?.[index] ?? res.imageWidths?.[index]); + const indexedHeight = readPositiveNumber(res.heights?.[index] ?? res.imageHeights?.[index]); + if (indexedWidth && indexedHeight) return { width: indexedWidth, height: indexedHeight }; + + const width = readPositiveNumber(res.width ?? res.imageWidth); + const height = readPositiveNumber(res.height ?? res.imageHeight); + return width && height ? { width, height } : null; +} + function referenceSubjectUrls(referenceSubjectList: unknown, type: ReferenceSubjectMediaType): string[] { if (!Array.isArray(referenceSubjectList)) return []; return referenceSubjectList.flatMap((item) => { @@ -472,7 +523,7 @@ function extractOutputFromTask(task: PopiTask, mediaType: string): GenerationOut ? { success: true, outputs: [{ type: "audio", data: "", url }] } : { success: false, error: "PopiServer audio task completed without output" }; } - const images: { image: string; previewImg?: string }[] = []; + const images: { image: string; previewImg?: string; dimensions?: { width: number; height: number } | null }[] = []; task.resultList?.forEach((res) => { const originImages = collectStrings(res?.originImages ?? []); const resultImages = collectStrings(res?.images ?? []); @@ -484,7 +535,7 @@ function extractOutputFromTask(task: PopiTask, mediaType: string): GenerationOut const previewImg = thumbs[index] || (originImages.length > 0 && resultImage && resultImage !== image ? resultImage : undefined); - images.push({ image, previewImg }); + images.push({ image, previewImg, dimensions: readPopiImageDimensions(res, index) }); }); }); // task.images is the original input/reference image list in Popiserver task @@ -492,7 +543,16 @@ function extractOutputFromTask(task: PopiTask, mediaType: string): GenerationOut // not put generated images under resultList. return images.length > 0 - ? { success: true, outputs: images.map((img) => ({ type: "image", data: "", url: img.image, previewImg: img.previewImg })) } + ? { + success: true, + outputs: images.map((img) => ({ + type: "image", + data: "", + url: img.image, + ...(img.previewImg ? { previewImg: img.previewImg } : {}), + ...(img.dimensions ? { dimensions: img.dimensions } : {}), + })), + } : { 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 3ae0de70..c3eba42a 100644 --- a/src/app/api/generate/route.ts +++ b/src/app/api/generate/route.ts @@ -108,6 +108,7 @@ export async function buildMediaResponse( .map((item) => ({ url: item.data || item.url || "", previewImg: item.previewImg, + dimensions: item.dimensions ?? null, })) .filter((item) => item.url.length > 0); @@ -116,6 +117,7 @@ export async function buildMediaResponse( image: { url: image, previewImg: output.previewImg, + dimensions: output.dimensions ?? null, }, ...(images && images.length > 1 ? { images } : {}), contentType: "image", diff --git a/src/app/api/generate/task/poll/route.ts b/src/app/api/generate/task/poll/route.ts index daf478cc..a7c8971f 100644 --- a/src/app/api/generate/task/poll/route.ts +++ b/src/app/api/generate/task/poll/route.ts @@ -21,6 +21,7 @@ async function buildResultResponse( .map((item) => ({ url: item.data || item.url || "", previewImg: item.previewImg, + dimensions: item.dimensions ?? null, })) .filter((item) => item.url.length > 0); diff --git a/src/components/nodes/GenerateImageNode.tsx b/src/components/nodes/GenerateImageNode.tsx index 3c849d19..e2d5e08c 100644 --- a/src/components/nodes/GenerateImageNode.tsx +++ b/src/components/nodes/GenerateImageNode.tsx @@ -598,7 +598,16 @@ export function GenerateImageNodeView({ id, data, selected }: GenerateImageNodeV const dimensions = { width: image.naturalWidth, height: image.naturalHeight }; setCropImageDimensions(dimensions); const currentDimensions = asImageDimensions(nodeData.dimensions); - if (currentDimensions?.width !== dimensions.width || currentDimensions?.height !== dimensions.height) { + const loadedOriginalImage = renderedImageSrc === nodeData.outputImage || displayImage === nodeData.outputImage; + const shouldPersistLoadedDimensions = + loadedOriginalImage || + !currentDimensions || + !nodeData.outputImage || + nodeData.outputImage === nodeData.previewImg; + if ( + shouldPersistLoadedDimensions && + (currentDimensions?.width !== dimensions.width || currentDimensions?.height !== dimensions.height) + ) { updateMediaNodeData(id, { dimensions }, dimensions); } } diff --git a/src/lib/providers/types.ts b/src/lib/providers/types.ts index 8c8208e7..797258f8 100644 --- a/src/lib/providers/types.ts +++ b/src/lib/providers/types.ts @@ -141,6 +141,7 @@ export interface GenerationOutputItem { videoCover?: string; previewImg?: string; // 为图片时封面图片 + dimensions?: { width: number; height: number } | null; } export interface GenerationOutput { /** Whether the generation succeeded */ diff --git a/src/store/execution/__tests__/nanoBananaExecutor.test.ts b/src/store/execution/__tests__/nanoBananaExecutor.test.ts index ed259a5d..d631dda8 100644 --- a/src/store/execution/__tests__/nanoBananaExecutor.test.ts +++ b/src/store/execution/__tests__/nanoBananaExecutor.test.ts @@ -451,6 +451,51 @@ describe("executeNanoBanana", () => { }); }); + it("passes generated image dimensions to media node sizing", async () => { + const node = makeNode({ + selectedModel: { + provider: "popiserver", + modelId: "32", + displayName: "Popi Image", + capabilities: ["text-to-image", "image-to-image"], + }, + }); + mockFetch + .mockResolvedValueOnce({ + ok: true, + json: () => Promise.resolve({ success: true, taskId: "task-1" }), + }) + .mockResolvedValueOnce({ + ok: true, + json: () => Promise.resolve({ + status: "completed", + result: { + success: true, + image: { + url: "https://cdn.example.com/generated.png", + previewImg: "https://cdn.example.com/generated-thumb.webp", + dimensions: { width: 1600, height: 900 }, + }, + }, + }), + }); + + const updateMediaNodeData = vi.fn(); + const ctx = makeCtx(node, { updateMediaNodeData }); + await executeNanoBanana(ctx); + + expect(updateMediaNodeData).toHaveBeenCalledWith( + "gen-1", + expect.objectContaining({ + outputImage: "https://cdn.example.com/generated.png", + previewImg: "https://cdn.example.com/generated-thumb.webp", + dimensions: { width: 1600, height: 900 }, + status: "complete", + }), + { width: 1600, height: 900 } + ); + }); + it("uses the generated image as multi-angle previewImage when no preview is returned", async () => { const node = makeNode({ selectedModel: { diff --git a/src/types/api.ts b/src/types/api.ts index f7cee547..e98e0b46 100644 --- a/src/types/api.ts +++ b/src/types/api.ts @@ -28,10 +28,12 @@ export interface GenerateResponse { image?: { url: string; // For large images, return URL directly previewImg?: string; // Base64 data URL for smaller images + dimensions?: { width: number; height: number } | null; }; images?: { url: string; // For large images, return URL directly previewImg?: string; // Base64 data URL for smaller images + dimensions?: { width: number; height: number } | null; }[]; video?: string; videoUrl?: string; // For large videos, return URL directly