Browse Source

feat: 更新生成响应格式,添加预览图支持

feature/group
huangmin 1 month ago
parent
commit
25421123f0
  1. 2
      src/app/api/generate/providers/gemini.ts
  2. 26
      src/app/api/generate/providers/popiserver.ts
  3. 20
      src/app/api/generate/route.ts
  4. 11
      src/components/nodes/GenerateImageNode.tsx
  5. 16
      src/lib/providers/types.ts
  6. 7
      src/store/execution/generateVideoExecutor.ts
  7. 13
      src/store/execution/nanoBananaExecutor.ts
  8. 10
      src/types/api.ts
  9. 2
      src/types/nodes.ts

2
src/app/api/generate/providers/gemini.ts

@ -153,7 +153,7 @@ export async function generateWithGemini(
const dataUrl = `data:${mimeType};base64,${imgData}`;
const responsePayload = { success: true, image: dataUrl };
const responsePayload: GenerateResponse = { success: true, image: { url: dataUrl } };
const responseSize = JSON.stringify(responsePayload).length;
const responseSizeMB = (responseSize / (1024 * 1024)).toFixed(2);

26
src/app/api/generate/providers/popiserver.ts

@ -88,6 +88,7 @@ type PopiTaskResult = {
video?: string | null;
voice?: string | null;
cover?: string | null;
originImages?: string[] | null;
};
type PopiTask = {
@ -557,17 +558,30 @@ function extractOutputFromTask(task: PopiTask, mediaType: string): GenerationOut
? { success: true, outputs: [{ type: "audio", data: "", url }] }
: { success: false, error: "PopiServer audio task completed without output" };
}
const resultImages = collectStrings(
Array.isArray(task.resultList) ? task.resultList.map((result) => result.images) : []
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 });
});
});
// task.images is the original input/reference image list in Popiserver task
// payloads. Only use it as a defensive fallback for older responses that do
// not put generated images under resultList.
const images = [
...new Set(resultImages.length > 0 ? resultImages : collectStrings(task.images ?? [])),
];
return images.length > 0
? { success: true, outputs: images.map((url) => ({ type: "image", data: "", url })) }
? { success: true, outputs: images.map((img) => ({ type: "image", data: "", url: img.image, previewImg: img.previewImg })) }
: { success: false, error: "PopiServer image task completed without output" };
}

20
src/app/api/generate/route.ts

@ -12,7 +12,7 @@
*/
import { NextRequest, NextResponse } from "next/server";
import { GenerateRequest, GenerateResponse, ModelType, SelectedModel, ProviderType } from "@/types";
import { GenerationInput, ModelCapability } from "@/lib/providers/types";
import { GenerationInput, GenerationOutput, GenerationOutputItem, ModelCapability } from "@/lib/providers/types";
import { generateWithGemini, generateWithGeminiVideo } from "./providers/gemini";
import { generateWithReplicate } from "./providers/replicate";
import { clearFalInputMappingCache as _clearFalInputMappingCache, generateWithFalQueue } from "./providers/fal";
@ -111,8 +111,8 @@ function requireSelectedModel(
}
export function buildMediaResponse(
output: { type: string; data: string; url?: string },
outputs?: Array<{ type: string; data: string; url?: string }>
output: GenerationOutputItem,
outputs?: GenerationOutput["outputs"]
): NextResponse {
if (output.type === "3d") {
return NextResponse.json<GenerateResponse>({
@ -142,15 +142,21 @@ export function buildMediaResponse(
});
}
const image = output.data || output.url;
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);
.map((item) => ({
url: item.data || item.url || "",
previewImg: item.previewImg,
}))
.filter((item) => item.url.length > 0);
return NextResponse.json<GenerateResponse>({
success: true,
image,
image: {
url: image,
previewImg: output.previewImg,
},
...(images && images.length > 1 ? { images } : {}),
contentType: "image",
});

11
src/components/nodes/GenerateImageNode.tsx

@ -122,7 +122,8 @@ type NanoBananaNodeType = Node<NanoBananaNodeData, "nanoBanana">;
export function GenerateImageNode({ id, data, selected }: NodeProps<NanoBananaNodeType>) {
const nodeData = data;
const { t } = useI18n();
const adaptiveOutputImage = useAdaptiveImageSrc(data.outputImage, id);
const displayImage = data.previewImg || data.outputImage;
const adaptiveOutputImage = useAdaptiveImageSrc(displayImage, id);
const updateNodeData = useWorkflowStore((state) => state.updateNodeData);
const popiserverImageModels = useModelStore((state) => state.byKind.image);
const generationsPath = useWorkflowStore((state) => state.generationsPath);
@ -401,7 +402,7 @@ export function GenerateImageNode({ id, data, selected }: NodeProps<NanoBananaNo
const { setNodes } = useReactFlow();
const handleClearImage = useCallback(() => {
updateNodeData(id, { outputImage: null, status: "idle", error: null });
updateNodeData(id, { outputImage: null, previewImg: undefined, status: "idle", error: null });
}, [id, updateNodeData]);
const regenerateNode = useWorkflowStore((state) => state.regenerateNode);
@ -464,6 +465,7 @@ export function GenerateImageNode({ id, data, selected }: NodeProps<NanoBananaNo
if (image) {
updateNodeData(id, {
outputImage: image,
previewImg: imageItem.previewImg,
selectedHistoryIndex: newIndex,
status: "idle",
error: null,
@ -486,6 +488,7 @@ export function GenerateImageNode({ id, data, selected }: NodeProps<NanoBananaNo
if (image) {
updateNodeData(id, {
outputImage: image,
previewImg: imageItem.previewImg,
selectedHistoryIndex: newIndex,
status: "idle",
error: null,
@ -942,7 +945,7 @@ export function GenerateImageNode({ id, data, selected }: NodeProps<NanoBananaNo
data-tutorial="generate-output-area"
>
{/* Preview area */}
{nodeData.outputImage ? (
{displayImage ? (
<>
<img
src={adaptiveOutputImage ?? undefined}
@ -1117,7 +1120,7 @@ export function GenerateImageNode({ id, data, selected }: NodeProps<NanoBananaNo
{isPreviewOpen && nodeData.outputImage && (
<MediaPreviewModal
type="image"
src={adaptiveOutputImage ?? nodeData.outputImage}
src={adaptiveOutputImage ?? displayImage ?? nodeData.outputImage}
downloadSrc={nodeData.outputImage}
alt="Generated preview"
onClose={() => setIsPreviewOpen(false)}

16
src/lib/providers/types.ts

@ -109,18 +109,22 @@ export interface GenerationInput {
/**
* Unified output format for generation results
*/
export interface GenerationOutput {
/** Whether the generation succeeded */
success: boolean;
/** Generated outputs (images, videos, or audio) */
outputs?: Array<{
export interface GenerationOutputItem {
/** Type of output */
type: "image" | "video" | "3d" | "audio";
/** Base64 data URL of the output (empty string for 3D/large video URL-only responses) */
data: string;
/** Original URL if applicable (e.g., from provider CDN) */
url?: string;
}>;
previewImg?: string; // 为图片时封面图片
}
export interface GenerationOutput {
/** Whether the generation succeeded */
success: boolean;
/** Generated outputs (images, videos, or audio) */
outputs?: Array<GenerationOutputItem>;
/** Error message if success is false */
error?: string;
}

7
src/store/execution/generateVideoExecutor.ts

@ -336,8 +336,9 @@ export async function executeGenerateVideo(
// Handle video response (video or videoUrl field)
const videoData = result.video || result.videoUrl;
if (result.success && (videoData || result.image)) {
const outputContent = videoData || result.image;
const imageData = result.image?.url;
if (result.success && (videoData || imageData)) {
const outputContent = videoData || imageData;
const timestamp = Date.now();
const videoId = `${timestamp}`;
const usedModelId = modelToUse.modelId || "";
@ -383,7 +384,7 @@ export async function executeGenerateVideo(
if (generationsPath) {
const saveContent = videoData
? { video: videoData }
: { image: result.image };
: { image: imageData };
const savePromise = fetch("/api/save-generation", {
method: "POST",

13
src/store/execution/nanoBananaExecutor.ts

@ -234,6 +234,7 @@ export async function executeNanoBanana(
const generatedItems: Array<{
id: string;
image: string;
previewImg?: string;
timestamp: number;
}> = [];
let partialError: string | null = null;
@ -282,10 +283,9 @@ export async function executeNanoBanana(
signal,
});
}
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
const resultImages = result.success && Array.isArray(result.images) && result.images.length > 0
? result.images
: result.success && result.image
? [result.image]
: [];
@ -298,7 +298,8 @@ export async function executeNanoBanana(
: `${batchTimestamp}-${itemIndex + 1}`;
generatedItems.push({
id: imageId,
image,
image: image.url,
previewImg: image.previewImg,
timestamp,
});
});
@ -334,6 +335,7 @@ export async function executeNanoBanana(
const newHistoryItems = generatedItems.map((item) => ({
id: item.id,
image: item.image,
previewImg: item.previewImg,
timestamp: item.timestamp,
prompt: finalPrompt,
aspectRatio: nodeConfig.aspectRatio,
@ -345,6 +347,7 @@ export async function executeNanoBanana(
updateNodeData(node.id, {
outputImage: generatedItems[0].image,
previewImg: generatedItems[0].previewImg,
outputImageRef: undefined,
outputImageStorageStatus: isHttpMediaUrl(generatedItems[0].image) ? "remote-only" : "localized",
status: "complete",

10
src/types/api.ts

@ -23,8 +23,14 @@ export interface GenerateRequest {
export interface GenerateResponse {
success: boolean;
requestId?: string;
image?: string;
images?: string[];
image?: {
url: string; // For large images, return URL directly
previewImg?: string; // Base64 data URL for smaller images
};
images?: {
url: string; // For large images, return URL directly
previewImg?: string; // Base64 data URL for smaller images
}[];
video?: string;
videoUrl?: string; // For large videos, return URL directly
audio?: string; // Base64 audio data

2
src/types/nodes.ts

@ -162,6 +162,7 @@ export interface ImageHistoryItem {
export interface CarouselImageItem {
id: string;
image?: string; // Current-session fallback; stripped when externalizing workflow media
previewImg?: string;
timestamp: number;
prompt: string;
aspectRatio: AspectRatio;
@ -204,6 +205,7 @@ export interface NanoBananaNodeData extends BaseNodeData {
inputImageRefs?: string[]; // External image references for storage optimization
inputPrompt: string | null;
outputImage: string | null;
previewImg?: string;
outputImageRef?: string; // External image reference for storage optimization
outputImageStorageStatus?: "remote-only" | "localizing" | "localized" | "missing";
aspectRatio: AspectRatio;

Loading…
Cancel
Save