feat: add SnapGen image provider
This commit is contained in:
parent
1af8a29b58
commit
ddfe911812
@ -86,6 +86,7 @@ export type AdminAiProvider = {
|
||||
| "bfl-image"
|
||||
| "ruxa-image"
|
||||
| "fal-image"
|
||||
| "snapgen-image"
|
||||
| "openai-image"
|
||||
| "stepfun-image"
|
||||
| "midjourney"
|
||||
|
||||
@ -159,6 +159,7 @@ import {
|
||||
} from "@/lib/ai/detail-agent-model-config";
|
||||
import { FAL_IMAGE_MODEL_PRESETS } from "@/lib/ai/fal-image-models";
|
||||
import { RUXA_IMAGE_MODEL_PRESETS } from "@/lib/ai/ruxa-image-models";
|
||||
import { SNAPGEN_IMAGE_MODEL_PRESETS } from "@/lib/ai/snapgen-image-models";
|
||||
import {
|
||||
formatImageModelCreditDiscountLabel,
|
||||
getGenerationCreditCostFromImageGenConfig,
|
||||
@ -611,6 +612,7 @@ type AiProvider = {
|
||||
| "bfl-image"
|
||||
| "ruxa-image"
|
||||
| "fal-image"
|
||||
| "snapgen-image"
|
||||
| "openai-image"
|
||||
| "stepfun-image"
|
||||
| "midjourney"
|
||||
@ -1564,6 +1566,7 @@ const PROVIDER_TYPES = [
|
||||
{ value: "bfl-image", label: "BFL FLUX.2 图片编辑" },
|
||||
{ value: "ruxa-image", label: "RUXA生图" },
|
||||
{ value: "fal-image", label: "FAL 图片生成/编辑" },
|
||||
{ value: "snapgen-image", label: "SnapGen 生图" },
|
||||
{ value: "openai-image", label: "OpenAI 图片兼容" },
|
||||
{ value: "stepfun-image", label: "阶跃星辰 Step Image Edit" },
|
||||
{ value: "bltcy-video", label: "柏拉图统一视频" },
|
||||
@ -1597,6 +1600,7 @@ const PROVIDER_DEFAULT_BASE_URL: Record<string, string> = {
|
||||
"bfl-image": "https://api.bfl.ai",
|
||||
"ruxa-image": "https://api.ruxa.ai",
|
||||
"fal-image": "https://fal.run",
|
||||
"snapgen-image": "https://api.snapgen.ai",
|
||||
"openai-image": "https://api.openai.com/v1",
|
||||
"stepfun-image": "https://api.stepfun.com",
|
||||
"bltcy-video": "https://api.bltcy.ai",
|
||||
@ -3996,6 +4000,7 @@ export function AdminSettingsClient() {
|
||||
| "bfl-image"
|
||||
| "ruxa-image"
|
||||
| "fal-image"
|
||||
| "snapgen-image"
|
||||
| "openai-image"
|
||||
| "stepfun-image"
|
||||
| "midjourney"
|
||||
@ -6232,6 +6237,7 @@ export function AdminSettingsClient() {
|
||||
const isKreaProvider = providerForm.type === "krea";
|
||||
const isFalImageProvider = providerForm.type === "fal-image";
|
||||
const isRuxaImageProvider = providerForm.type === "ruxa-image";
|
||||
const isSnapgenImageProvider = providerForm.type === "snapgen-image";
|
||||
const isBltcyVideoProvider = providerForm.type === "bltcy-video";
|
||||
const isLemonProvider =
|
||||
providerForm.type === "openai" &&
|
||||
@ -6363,6 +6369,17 @@ export function AdminSettingsClient() {
|
||||
}
|
||||
}
|
||||
|
||||
if (isNewProvider && isSnapgenImageProvider && providerId) {
|
||||
try {
|
||||
await handleQuickAddSnapgenImageModels(providerId, { skipConfirm: true });
|
||||
} catch (modelError) {
|
||||
console.error("[Auto Add SnapGen Image Models] Error:", modelError);
|
||||
toast.warning(
|
||||
'SnapGen 生图预设模型添加失败,请手动点击"快速添加 SnapGen 图片预设"按钮'
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (isNewProvider && isBltcyVideoProvider && providerId) {
|
||||
try {
|
||||
await handleQuickAddBltcyVideoModels(providerId, {
|
||||
@ -7291,6 +7308,51 @@ export function AdminSettingsClient() {
|
||||
}
|
||||
};
|
||||
|
||||
const handleQuickAddSnapgenImageModels = async (
|
||||
providerId: string,
|
||||
options?: { skipConfirm?: boolean }
|
||||
) => {
|
||||
if (
|
||||
!options?.skipConfirm &&
|
||||
!confirm(
|
||||
`将为此服务商添加 ${SNAPGEN_IMAGE_MODEL_PRESETS.length} 个 SnapGen 生图预设模型(Nano Banana Pro、Nano Banana 2、Grok Image、Meta AI Image、GPT Image 2)。\n\n继续吗?`
|
||||
)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
toast.info("正在添加 SnapGen 生图预设模型...");
|
||||
|
||||
const response = await fetch("/api/admin/snapgen-image-models", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ providerId }),
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(data.error || "添加失败");
|
||||
}
|
||||
|
||||
const { addedCount, updatedCount, errors } = data;
|
||||
const totalSynced = Number(addedCount || 0) + Number(updatedCount || 0);
|
||||
|
||||
if (totalSynced > 0) {
|
||||
toast.success(`成功同步 ${totalSynced} 个 SnapGen 生图预设模型`);
|
||||
mutateModels();
|
||||
}
|
||||
|
||||
if (errors && errors.length > 0) {
|
||||
toast.warning(`${errors.length} 个模型同步失败`);
|
||||
}
|
||||
} catch (error: any) {
|
||||
console.error("[Quick Add SnapGen Image Models] Error:", error);
|
||||
toast.error(error.message || "添加 SnapGen 生图预设模型失败");
|
||||
}
|
||||
};
|
||||
|
||||
const handleUseFalImageProviderPreset = () => {
|
||||
setProviderForm({
|
||||
id: "",
|
||||
@ -15471,6 +15533,12 @@ export function AdminSettingsClient() {
|
||||
可填写 step-image-edit-2, 支持文生图和图片编辑。
|
||||
</p>
|
||||
)}
|
||||
{providerForm.type === "snapgen-image" && (
|
||||
<p className="text-muted-foreground text-xs">
|
||||
SnapGen 使用 x-api-key 认证,默认地址
|
||||
https://api.snapgen.ai。保存后会自动添加文档里的全部生图模型预设。
|
||||
</p>
|
||||
)}
|
||||
{providerForm.type === "bfl-image" &&
|
||||
isBltcyBflBaseUrl(providerForm.baseUrl) && (
|
||||
<p className="text-muted-foreground text-xs">
|
||||
@ -15578,6 +15646,19 @@ export function AdminSettingsClient() {
|
||||
快速添加 FAL 图片预设
|
||||
</Button>
|
||||
)}
|
||||
{provider.type === "snapgen-image" && (
|
||||
<Button
|
||||
className="mt-2"
|
||||
onClick={() =>
|
||||
handleQuickAddSnapgenImageModels(provider.id)
|
||||
}
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
>
|
||||
<ImageIcon className="mr-1 h-4 w-4" />
|
||||
快速添加 SnapGen 图片预设
|
||||
</Button>
|
||||
)}
|
||||
{provider.type === "bltcy-video" && (
|
||||
<Button
|
||||
className="mt-2"
|
||||
|
||||
@ -12,6 +12,7 @@ export const IMAGE_FAILOVER_PROVIDER_ALLOWLIST = new Set([
|
||||
"bfl-image",
|
||||
"ruxa-image",
|
||||
"fal-image",
|
||||
"snapgen-image",
|
||||
"openai-image",
|
||||
"stepfun-image",
|
||||
"runway",
|
||||
@ -229,6 +230,7 @@ export function getImageFailoverConvertProvider(providerType?: string | null) {
|
||||
case "bfl-image":
|
||||
case "ruxa-image":
|
||||
case "fal-image":
|
||||
case "snapgen-image":
|
||||
case "openai-image":
|
||||
case "stepfun-image":
|
||||
case "midjourney":
|
||||
|
||||
@ -515,6 +515,7 @@ export async function convertMessagesWithImages(
|
||||
normalizedProviderType === "bfl-image" ||
|
||||
normalizedProviderType === "ruxa-image" ||
|
||||
normalizedProviderType === "fal-image" ||
|
||||
normalizedProviderType === "snapgen-image" ||
|
||||
normalizedProviderType === "openai-image" ||
|
||||
normalizedProviderType === "stepfun-image" ||
|
||||
normalizedProviderType === "midjourney" ||
|
||||
|
||||
@ -185,6 +185,7 @@ function normalizeImageLoadingProviderType(providerType?: string | null): string
|
||||
normalized === "bfl-image" ||
|
||||
normalized === "ruxa-image" ||
|
||||
normalized === "fal-image" ||
|
||||
normalized === "snapgen-image" ||
|
||||
normalized === "openai-image" ||
|
||||
normalized === "stepfun-image" ||
|
||||
normalized === "midjourney" ||
|
||||
|
||||
@ -8888,13 +8888,14 @@ export async function POST(request: Request) {
|
||||
"bfl-image": 2,
|
||||
"ruxa-image": 3,
|
||||
"fal-image": 4,
|
||||
"openai-image": 5,
|
||||
"stepfun-image": 6,
|
||||
"grsai-gpt-image": 7,
|
||||
runway: 8,
|
||||
"snapgen-image": 5,
|
||||
"openai-image": 6,
|
||||
"stepfun-image": 7,
|
||||
"grsai-gpt-image": 8,
|
||||
runway: 9,
|
||||
krea: 9,
|
||||
gemini: 9,
|
||||
"huanke-gemini": 10,
|
||||
gemini: 10,
|
||||
"huanke-gemini": 11,
|
||||
};
|
||||
const crossProviderCandidates = getImageCrossProviderCandidates(allModels, {
|
||||
currentModelId: finalModelId,
|
||||
@ -9278,6 +9279,7 @@ export async function POST(request: Request) {
|
||||
"bfl-image",
|
||||
"ruxa-image",
|
||||
"fal-image",
|
||||
"snapgen-image",
|
||||
"openai-image",
|
||||
"stepfun-image",
|
||||
"midjourney",
|
||||
@ -9495,6 +9497,8 @@ export async function POST(request: Request) {
|
||||
? "ruxa-image"
|
||||
: (languageModel as any).provider === "fal-image"
|
||||
? "fal-image"
|
||||
: (languageModel as any).provider === "snapgen-image"
|
||||
? "snapgen-image"
|
||||
: (languageModel as any).provider === "openai-image"
|
||||
? "openai-image"
|
||||
: (languageModel as any).provider === "stepfun-image"
|
||||
@ -9598,12 +9602,13 @@ export async function POST(request: Request) {
|
||||
"bfl-image": 2,
|
||||
"ruxa-image": 3,
|
||||
"fal-image": 4,
|
||||
"openai-image": 5,
|
||||
"stepfun-image": 6,
|
||||
runway: 7,
|
||||
krea: 8,
|
||||
gemini: 8,
|
||||
"huanke-gemini": 9,
|
||||
"snapgen-image": 5,
|
||||
"openai-image": 6,
|
||||
"stepfun-image": 7,
|
||||
runway: 8,
|
||||
krea: 9,
|
||||
gemini: 9,
|
||||
"huanke-gemini": 10,
|
||||
};
|
||||
const crossProviderCandidates = getImageCrossProviderCandidates(allModels, {
|
||||
currentModelId: finalModelId,
|
||||
@ -9638,6 +9643,8 @@ export async function POST(request: Request) {
|
||||
? "ruxa-image"
|
||||
: candidate.provider.type === "fal-image"
|
||||
? "fal-image"
|
||||
: candidate.provider.type === "snapgen-image"
|
||||
? "snapgen-image"
|
||||
: candidate.provider.type === "openai-image"
|
||||
? "openai-image"
|
||||
: candidate.provider.type === "stepfun-image"
|
||||
@ -10024,12 +10031,13 @@ export async function POST(request: Request) {
|
||||
"bfl-image": 1,
|
||||
"ruxa-image": 2,
|
||||
"fal-image": 3,
|
||||
"openai-image": 4,
|
||||
"stepfun-image": 5,
|
||||
runway: 6,
|
||||
krea: 7,
|
||||
gemini: 7,
|
||||
"huanke-gemini": 8,
|
||||
"snapgen-image": 4,
|
||||
"openai-image": 5,
|
||||
"stepfun-image": 6,
|
||||
runway: 7,
|
||||
krea: 8,
|
||||
gemini: 8,
|
||||
"huanke-gemini": 9,
|
||||
"volcano-vision": 9,
|
||||
};
|
||||
const crossProviderCandidates = getImageCrossProviderCandidates(allModels, {
|
||||
@ -10065,6 +10073,8 @@ export async function POST(request: Request) {
|
||||
? "ruxa-image"
|
||||
: candidate.provider.type === "fal-image"
|
||||
? "fal-image"
|
||||
: candidate.provider.type === "snapgen-image"
|
||||
? "snapgen-image"
|
||||
: candidate.provider.type === "openai-image"
|
||||
? "openai-image"
|
||||
: candidate.provider.type === "stepfun-image"
|
||||
@ -10894,12 +10904,13 @@ export async function POST(request: Request) {
|
||||
"bfl-image": 2,
|
||||
"ruxa-image": 3,
|
||||
"fal-image": 4,
|
||||
"openai-image": 5,
|
||||
"stepfun-image": 6,
|
||||
runway: 7,
|
||||
krea: 8,
|
||||
gemini: 8,
|
||||
"huanke-gemini": 9,
|
||||
"snapgen-image": 5,
|
||||
"openai-image": 6,
|
||||
"stepfun-image": 7,
|
||||
runway: 8,
|
||||
krea: 9,
|
||||
gemini: 9,
|
||||
"huanke-gemini": 10,
|
||||
};
|
||||
const crossProviderCandidates = getImageCrossProviderCandidates(allModels, {
|
||||
currentModelId: finalModelId,
|
||||
@ -10937,6 +10948,8 @@ export async function POST(request: Request) {
|
||||
? "ruxa-image"
|
||||
: candidate.provider.type === "fal-image"
|
||||
? "fal-image"
|
||||
: candidate.provider.type === "snapgen-image"
|
||||
? "snapgen-image"
|
||||
: candidate.provider.type === "openai-image"
|
||||
? "openai-image"
|
||||
: candidate.provider.type === "stepfun-image"
|
||||
|
||||
@ -30,6 +30,7 @@ const providerTypes = [
|
||||
"bfl-image",
|
||||
"ruxa-image",
|
||||
"fal-image",
|
||||
"snapgen-image",
|
||||
"openai-image",
|
||||
"stepfun-image",
|
||||
"midjourney",
|
||||
@ -70,6 +71,7 @@ const VOLCANO_MULTIMODAL_BASE_URL = "https://ark.cn-beijing.volces.com/api/v3";
|
||||
const DOUBAO_VIDEO_BASE_URL = "https://ark.cn-beijing.volces.com/api/v3";
|
||||
const RUXA_VIDEO_BASE_URL = "https://api.ruxa.ai";
|
||||
const RUXA_IMAGE_BASE_URL = "https://api.ruxa.ai";
|
||||
const SNAPGEN_IMAGE_BASE_URL = "https://api.snapgen.ai";
|
||||
const OPENAI_IMAGE_BASE_URL = "https://api.openai.com/v1";
|
||||
const STEPFUN_IMAGE_BASE_URL = "https://api.stepfun.com";
|
||||
const BFL_IMAGE_BASE_URL = "https://api.bfl.ai";
|
||||
@ -231,6 +233,8 @@ export async function POST(request: Request) {
|
||||
? data.baseUrl || BFL_IMAGE_BASE_URL
|
||||
: data.type === "ruxa-image"
|
||||
? data.baseUrl || RUXA_IMAGE_BASE_URL
|
||||
: data.type === "snapgen-image"
|
||||
? data.baseUrl || SNAPGEN_IMAGE_BASE_URL
|
||||
: data.type === "openai-image"
|
||||
? data.baseUrl || OPENAI_IMAGE_BASE_URL
|
||||
: data.type === "stepfun-image"
|
||||
|
||||
155
app/api/admin/snapgen-image-models/route.ts
Normal file
155
app/api/admin/snapgen-image-models/route.ts
Normal file
@ -0,0 +1,155 @@
|
||||
import { and, eq } from "drizzle-orm";
|
||||
import { NextResponse } from "next/server";
|
||||
import { SNAPGEN_IMAGE_MODEL_PRESETS } from "@/lib/ai/snapgen-image-models";
|
||||
import { db } from "@/lib/db";
|
||||
import { getAiProviderById } from "@/lib/db/queries";
|
||||
import { type AiModel, aiModel } from "@/lib/db/schema";
|
||||
import { requireAdmin } from "@/lib/middleware/admin";
|
||||
|
||||
export async function GET() {
|
||||
try {
|
||||
await requireAdmin();
|
||||
return NextResponse.json({
|
||||
presets: SNAPGEN_IMAGE_MODEL_PRESETS,
|
||||
counts: {
|
||||
total: SNAPGEN_IMAGE_MODEL_PRESETS.length,
|
||||
},
|
||||
});
|
||||
} catch (error: any) {
|
||||
console.error("[SnapGen Image Models API] GET error:", error);
|
||||
return NextResponse.json(
|
||||
{ error: error.message || "Failed to fetch SnapGen image model presets" },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export async function POST(request: Request) {
|
||||
try {
|
||||
await requireAdmin();
|
||||
|
||||
const body = await request.json();
|
||||
const providerId = String(body?.providerId || "").trim();
|
||||
const modelIds = Array.isArray(body?.modelIds)
|
||||
? body.modelIds
|
||||
.map((item: unknown) => String(item || "").trim())
|
||||
.filter(Boolean)
|
||||
: null;
|
||||
|
||||
if (!providerId) {
|
||||
return NextResponse.json(
|
||||
{ error: "Provider ID is required" },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
const provider = await getAiProviderById({ id: providerId });
|
||||
if (!provider || provider.type !== "snapgen-image") {
|
||||
return NextResponse.json(
|
||||
{ error: "Provider must be a SnapGen image provider" },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
const presetsToAdd = modelIds
|
||||
? SNAPGEN_IMAGE_MODEL_PRESETS.filter((preset) => modelIds.includes(preset.modelId))
|
||||
: SNAPGEN_IMAGE_MODEL_PRESETS;
|
||||
|
||||
const addedModels: AiModel[] = [];
|
||||
const updatedModels: AiModel[] = [];
|
||||
const errors: { modelId: string; error: string }[] = [];
|
||||
|
||||
for (const preset of presetsToAdd) {
|
||||
try {
|
||||
const existing = await db.query.aiModel.findFirst({
|
||||
where: and(
|
||||
eq(aiModel.providerId, providerId),
|
||||
eq(aiModel.modelId, preset.modelId)
|
||||
),
|
||||
});
|
||||
|
||||
if (existing) {
|
||||
const [updated] = await db
|
||||
.update(aiModel)
|
||||
.set({
|
||||
displayName: preset.displayName,
|
||||
description: preset.description,
|
||||
supportsVision: preset.supportsVision,
|
||||
supportsImageGen: preset.supportsImageGen,
|
||||
isEnabled: true,
|
||||
isHidden: false,
|
||||
sortOrder: preset.sortOrder,
|
||||
capabilities: preset.capabilities || null,
|
||||
defaultParams: preset.defaultParams || null,
|
||||
imageGenConfig: preset.imageGenConfig || null,
|
||||
})
|
||||
.where(
|
||||
and(
|
||||
eq(aiModel.providerId, providerId),
|
||||
eq(aiModel.modelId, preset.modelId)
|
||||
)
|
||||
)
|
||||
.returning();
|
||||
|
||||
if (updated) {
|
||||
updatedModels.push(updated);
|
||||
} else {
|
||||
errors.push({ modelId: preset.modelId, error: "Model exists but failed to sync" });
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
const [newModel] = await db
|
||||
.insert(aiModel)
|
||||
.values({
|
||||
providerId,
|
||||
modelId: preset.modelId,
|
||||
displayName: preset.displayName,
|
||||
description: preset.description,
|
||||
supportsVision: preset.supportsVision,
|
||||
supportsImageGen: preset.supportsImageGen,
|
||||
isEnabled: true,
|
||||
isHidden: false,
|
||||
isDefault: false,
|
||||
sortOrder: preset.sortOrder,
|
||||
capabilities: preset.capabilities || null,
|
||||
defaultParams: preset.defaultParams || null,
|
||||
imageGenConfig: preset.imageGenConfig || null,
|
||||
isSubModel: false,
|
||||
parentModelId: null,
|
||||
})
|
||||
.returning();
|
||||
|
||||
addedModels.push(newModel);
|
||||
} catch (error: any) {
|
||||
console.error(
|
||||
`[SnapGen Image Models API] Failed to sync ${preset.modelId}:`,
|
||||
error
|
||||
);
|
||||
errors.push({
|
||||
modelId: preset.modelId,
|
||||
error: error.message || "Insert failed",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
addedCount: addedModels.length,
|
||||
updatedCount: updatedModels.length,
|
||||
addedModels,
|
||||
updatedModels,
|
||||
errors: errors.length > 0 ? errors : undefined,
|
||||
counts: {
|
||||
requested: presetsToAdd.length,
|
||||
total: SNAPGEN_IMAGE_MODEL_PRESETS.length,
|
||||
},
|
||||
});
|
||||
} catch (error: any) {
|
||||
console.error("[SnapGen Image Models API] POST error:", error);
|
||||
return NextResponse.json(
|
||||
{ error: error.message || "Failed to add SnapGen image models" },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -9,6 +9,7 @@ import { createBflImage } from "@/lib/ai/providers/bfl-image";
|
||||
import { createRuxaImage } from "@/lib/ai/providers/ruxa-image";
|
||||
import { resolveModelRuntimeId } from "@/lib/ai/model-runtime";
|
||||
import { createFalImage } from "@/lib/ai/providers/fal-image";
|
||||
import { createSnapgenImage } from "@/lib/ai/providers/snapgen-image";
|
||||
import { createOpenAIImage } from "@/lib/ai/providers/openai-image";
|
||||
import { createStepfunImage } from "@/lib/ai/providers/stepfun-image";
|
||||
import { createMidjourney } from "@/lib/ai/providers/midjourney";
|
||||
@ -48,6 +49,7 @@ const SUPPORTED_TEXT_EDIT_PROVIDER_TYPES = new Set([
|
||||
"bfl-image",
|
||||
"ruxa-image",
|
||||
"fal-image",
|
||||
"snapgen-image",
|
||||
"openai-image",
|
||||
"stepfun-image",
|
||||
"midjourney",
|
||||
@ -109,6 +111,8 @@ function createTextEditProvider(
|
||||
return createRuxaImage({ baseURL, apiKey });
|
||||
case "fal-image":
|
||||
return createFalImage({ baseURL, apiKey });
|
||||
case "snapgen-image":
|
||||
return createSnapgenImage({ baseURL, apiKey });
|
||||
case "openai-image":
|
||||
return createOpenAIImage({ baseURL, apiKey });
|
||||
case "stepfun-image":
|
||||
|
||||
@ -2,7 +2,7 @@
|
||||
* 场景裂变 API
|
||||
*
|
||||
* 将一张产品图批量融合到多个场景图中
|
||||
* 支持多种生图模型:阿里生图、RUXA、Gemini、既梦
|
||||
* 支持多种生图模型:阿里生图、RUXA、SnapGen、Gemini、既梦
|
||||
*/
|
||||
|
||||
import { NextResponse } from "next/server";
|
||||
@ -23,6 +23,7 @@ import { createGeminiImage } from "@/lib/ai/providers/gemini-image";
|
||||
import { createVolcanoVision } from "@/lib/ai/providers/volcano-vision";
|
||||
import { createRuxaImage } from "@/lib/ai/providers/ruxa-image";
|
||||
import { createFalImage } from "@/lib/ai/providers/fal-image";
|
||||
import { createSnapgenImage } from "@/lib/ai/providers/snapgen-image";
|
||||
import { createOpenAIImage } from "@/lib/ai/providers/openai-image";
|
||||
import { callQwen3Vl } from "@/lib/ai/qwen3-vl";
|
||||
import {
|
||||
@ -181,6 +182,9 @@ function getSceneFusionCandidatePriority(candidate: SceneFusionModelCandidate):
|
||||
case "fal-image":
|
||||
score += 120;
|
||||
break;
|
||||
case "snapgen-image":
|
||||
score += 116;
|
||||
break;
|
||||
case "alibaba-image":
|
||||
score += 112;
|
||||
break;
|
||||
@ -1508,6 +1512,17 @@ async function processSceneWithProvider(
|
||||
case "fal-image":
|
||||
return processWithFalImage(productImageUrl, sceneImageUrl, prompt, provider, modelId, aspectRatio, imageSize);
|
||||
|
||||
case "snapgen-image":
|
||||
return processWithSnapgenImage(
|
||||
productImageUrl,
|
||||
sceneImageUrl,
|
||||
prompt,
|
||||
provider,
|
||||
runtimeModelId || modelId,
|
||||
aspectRatio,
|
||||
imageSize,
|
||||
);
|
||||
|
||||
case "openai-image":
|
||||
return processWithOpenAIImage(productImageUrl, sceneImageUrl, prompt, provider, modelId, aspectRatio, imageSize);
|
||||
|
||||
@ -1686,6 +1701,46 @@ async function processWithFalImage(
|
||||
return { thumbnail, original, prompt };
|
||||
}
|
||||
|
||||
async function processWithSnapgenImage(
|
||||
productImageUrl: string,
|
||||
sceneImageUrl: string,
|
||||
prompt: string,
|
||||
provider: { baseUrl: string; apiKey: string },
|
||||
modelId: string,
|
||||
aspectRatio?: string,
|
||||
imageSize?: string
|
||||
): Promise<{ thumbnail: string; original: string; prompt: string }> {
|
||||
logger.info("provider_processing", { providerType: "snapgen-image", modelId });
|
||||
|
||||
const snapgenProvider = createSnapgenImage({
|
||||
baseURL: provider.baseUrl,
|
||||
apiKey: provider.apiKey,
|
||||
});
|
||||
|
||||
const messages = [
|
||||
{
|
||||
role: "user",
|
||||
content: [
|
||||
{ type: "image_url", image_url: { url: productImageUrl } },
|
||||
{ type: "image_url", image_url: { url: sceneImageUrl } },
|
||||
{ type: "text", text: prompt },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
const result = await snapgenProvider.chat(modelId).doGenerate({
|
||||
messages,
|
||||
imageParams: {
|
||||
aspectRatio: aspectRatio || "1:1",
|
||||
imageSize: imageSize || "2K",
|
||||
imageCount: 1,
|
||||
},
|
||||
});
|
||||
|
||||
const { thumbnail, original } = parseImageFromMarkdown(result.text);
|
||||
return { thumbnail, original, prompt };
|
||||
}
|
||||
|
||||
async function processWithOpenAIImage(
|
||||
productImageUrl: string,
|
||||
sceneImageUrl: string,
|
||||
|
||||
@ -24,6 +24,7 @@ const PROVIDER_MODEL_LABEL: Record<string, string> = {
|
||||
"alibaba-image": "千问图片",
|
||||
"ruxa-image": "RUXA 图片",
|
||||
"fal-image": "FAL 图片",
|
||||
"snapgen-image": "SnapGen 图片",
|
||||
"openai-image": "OpenAI 图片",
|
||||
"stepfun-image": "StepFun 图片",
|
||||
midjourney: "Midjourney",
|
||||
|
||||
@ -47,6 +47,7 @@ interface ImageParamsBarProps {
|
||||
supportsNegativePrompt?: boolean;
|
||||
supportsWatermark?: boolean;
|
||||
supportsSeed?: boolean;
|
||||
supportedAspectRatios?: string[];
|
||||
};
|
||||
}
|
||||
|
||||
@ -64,6 +65,7 @@ const ASPECT_RATIO_OPTIONS = [
|
||||
{ value: "4:5", label: "4:5" },
|
||||
{ value: "21:9", label: "21:9" },
|
||||
] as const;
|
||||
type AspectRatioValue = (typeof ASPECT_RATIO_OPTIONS)[number]["value"];
|
||||
|
||||
// 图片大小选项(仅支持 nano-banana-pro)
|
||||
const IMAGE_SIZE_OPTIONS = [
|
||||
@ -165,10 +167,36 @@ export function ImageParamsBar({
|
||||
const getFallbackImageSize = () =>
|
||||
(["2K", "1K", "4K"] as const).find(isImageSizeAllowed) || "2K";
|
||||
|
||||
const isAspectRatioAllowed = (aspectRatio: string) => {
|
||||
const configured = imageGenConfig?.supportedAspectRatios;
|
||||
if (Array.isArray(configured) && configured.length > 0) {
|
||||
return configured.includes(aspectRatio);
|
||||
}
|
||||
|
||||
if (providerType === "snapgen-image") {
|
||||
return ["1:1", "16:9", "9:16", "4:3", "3:4"].includes(aspectRatio);
|
||||
}
|
||||
|
||||
return true;
|
||||
};
|
||||
|
||||
const availableAspectRatioOptions = ASPECT_RATIO_OPTIONS.filter((option) =>
|
||||
isAspectRatioAllowed(option.value)
|
||||
);
|
||||
|
||||
const getFallbackAspectRatio = (): AspectRatioValue =>
|
||||
(availableAspectRatioOptions.find((option) => option.value === "1:1") ||
|
||||
availableAspectRatioOptions[0] ||
|
||||
ASPECT_RATIO_OPTIONS[1])!.value;
|
||||
|
||||
const clampParamsByProvider = (raw: ImageGenerationParams): ImageGenerationParams => {
|
||||
const options = getImageCountOptions();
|
||||
const fallbackCount = options[0] ?? 1;
|
||||
const imageCount = options.includes(raw.imageCount || fallbackCount) ? (raw.imageCount || fallbackCount) : fallbackCount;
|
||||
let aspectRatio = raw.aspectRatio;
|
||||
if (!isAspectRatioAllowed(aspectRatio)) {
|
||||
aspectRatio = getFallbackAspectRatio();
|
||||
}
|
||||
// 根据模型配置校正分辨率:如果当前选择的档位被禁用,则降级到可用的档位
|
||||
let imageSize = normalizeImageSizeValue(raw.imageSize);
|
||||
if (!isImageSizeAllowed(imageSize)) {
|
||||
@ -176,6 +204,7 @@ export function ImageParamsBar({
|
||||
}
|
||||
return {
|
||||
...raw,
|
||||
aspectRatio,
|
||||
imageSize,
|
||||
imageCount,
|
||||
};
|
||||
@ -296,6 +325,7 @@ export function ImageParamsBar({
|
||||
imageGenConfig?.supports1K,
|
||||
imageGenConfig?.supports2K,
|
||||
imageGenConfig?.supports4K,
|
||||
imageGenConfig?.supportedAspectRatios,
|
||||
initialParams,
|
||||
onParamsChange,
|
||||
providerType,
|
||||
@ -341,7 +371,8 @@ export function ImageParamsBar({
|
||||
{ label: "9:16(4K)", aspectRatio: "9:16", imageSize: "4K" },
|
||||
{ label: "auto", aspectRatio: "auto", imageSize: params.imageSize || "1K" },
|
||||
].filter((option) =>
|
||||
option.aspectRatio === "auto" || isImageSizeAllowed(normalizeImageSizeValue(option.imageSize))
|
||||
isAspectRatioAllowed(option.aspectRatio) &&
|
||||
(option.aspectRatio === "auto" || isImageSizeAllowed(normalizeImageSizeValue(option.imageSize)))
|
||||
);
|
||||
const imageCountOptions = getImageCountOptions();
|
||||
const selectedAspectLabel = params.aspectRatio === "auto" ? "auto" : params.aspectRatio;
|
||||
@ -555,7 +586,7 @@ export function ImageParamsBar({
|
||||
isDarkInline ? "bg-white/[0.04]" : "bg-[#eceff2]/90"
|
||||
)}
|
||||
>
|
||||
{ASPECT_RATIO_OPTIONS.map((option) => (
|
||||
{availableAspectRatioOptions.map((option) => (
|
||||
<button
|
||||
key={option.value}
|
||||
type="button"
|
||||
|
||||
@ -164,6 +164,9 @@ export function ImagePreview({
|
||||
} else if (src.includes("fal-image-thumb-")) {
|
||||
originalUrl = src.replace("fal-image-thumb-", "fal-image-orig-");
|
||||
console.log("[ImagePreview] Inferred original URL from fal-image-thumb filename");
|
||||
} else if (src.includes("snapgen-image-thumb-")) {
|
||||
originalUrl = src.replace("snapgen-image-thumb-", "snapgen-image-orig-");
|
||||
console.log("[ImagePreview] Inferred original URL from snapgen-image-thumb filename");
|
||||
} else if (src.includes("/thumb/") || src.includes("-thumb")) {
|
||||
// 尝试从S3 URL中推断原图URL(常见格式:xxx-thumb.jpg -> xxx.jpg 或 xxx/thumb/xxx -> xxx/xxx)
|
||||
originalUrl = src.replace(/-thumb\./g, ".").replace(/\/thumb\//g, "/");
|
||||
|
||||
@ -22,6 +22,7 @@ import { createAlibabaImage } from "./providers/alibaba-image";
|
||||
import { createBflImage } from "./providers/bfl-image";
|
||||
import { createRuxaImage } from "./providers/ruxa-image";
|
||||
import { createFalImage } from "./providers/fal-image";
|
||||
import { createSnapgenImage } from "./providers/snapgen-image";
|
||||
import { createOpenAIImage } from "./providers/openai-image";
|
||||
import { createStepfunImage } from "./providers/stepfun-image";
|
||||
import { createMidjourney } from "./providers/midjourney";
|
||||
@ -275,6 +276,15 @@ function getOrCreateProvider(provider: Pick<AiProvider, "id" | "type" | "baseUrl
|
||||
return falImageProvider as any;
|
||||
}
|
||||
|
||||
case "snapgen-image": {
|
||||
const snapgenImageProvider = createSnapgenImage({
|
||||
baseURL: resolvedBaseUrl,
|
||||
apiKey: provider.apiKey,
|
||||
});
|
||||
logProviderCreated("snapgen-image", resolvedBaseUrl);
|
||||
return snapgenImageProvider as any;
|
||||
}
|
||||
|
||||
case "openai-image": {
|
||||
const openAIImageProvider = createOpenAIImage({
|
||||
baseURL: resolvedBaseUrl,
|
||||
@ -445,6 +455,9 @@ export async function getDynamicModel(
|
||||
// FAL 图片编辑生成使用 chat() 方法
|
||||
baseModel = providerInstance.chat(resolvedModelId);
|
||||
logChatModelResult("fal-image", baseModel);
|
||||
} else if (provider.type === "snapgen-image") {
|
||||
baseModel = providerInstance.chat(resolvedModelId);
|
||||
logChatModelResult("snapgen-image", baseModel);
|
||||
} else if (provider.type === "openai-image") {
|
||||
// OpenAI Images 兼容图片生成/编辑使用 chat() 方法
|
||||
baseModel = providerInstance.chat(resolvedModelId);
|
||||
|
||||
@ -16,6 +16,7 @@ const STATIC_IMAGE_PROVIDER_TYPES = new Set([
|
||||
"bfl-image",
|
||||
"ruxa-image",
|
||||
"fal-image",
|
||||
"snapgen-image",
|
||||
"grsai-gpt-image",
|
||||
"openai-image",
|
||||
"stepfun-image",
|
||||
|
||||
@ -51,6 +51,7 @@ const IMAGE_ONLY_PROVIDER_TYPES = new Set([
|
||||
"bfl-image",
|
||||
"ruxa-image",
|
||||
"fal-image",
|
||||
"snapgen-image",
|
||||
"openai-image",
|
||||
"stepfun-image",
|
||||
"midjourney",
|
||||
@ -156,7 +157,7 @@ const INTENT_MODEL_MAP: Record<
|
||||
> = {
|
||||
// 图片编辑:优先 alibaba-image(灵魂4.5,支持提示词扩展)
|
||||
image_edit: {
|
||||
providerTypes: ["alibaba-image", "bfl-image", "ruxa-image", "fal-image", "openai-image", "stepfun-image", "runway", "krea", "gemini", "huanke-gemini"],
|
||||
providerTypes: ["alibaba-image", "bfl-image", "ruxa-image", "fal-image", "snapgen-image", "openai-image", "stepfun-image", "runway", "krea", "gemini", "huanke-gemini"],
|
||||
requireVision: true,
|
||||
requireImageGen: true,
|
||||
description: "图片编辑",
|
||||
@ -171,7 +172,7 @@ const INTENT_MODEL_MAP: Record<
|
||||
|
||||
// 图片生成:优先灵魂4.5
|
||||
image_gen: {
|
||||
providerTypes: ["alibaba-image", "bfl-image", "ruxa-image", "fal-image", "openai-image", "stepfun-image", "runway", "krea", "gemini", "huanke-gemini", "volcano-vision"],
|
||||
providerTypes: ["alibaba-image", "bfl-image", "ruxa-image", "fal-image", "snapgen-image", "openai-image", "stepfun-image", "runway", "krea", "gemini", "huanke-gemini", "volcano-vision"],
|
||||
requireImageGen: true,
|
||||
description: "图片生成",
|
||||
},
|
||||
@ -199,25 +200,25 @@ const SUB_INTENT_OVERRIDES: Record<
|
||||
> = {
|
||||
// 抠图:alibaba-image 效果最好
|
||||
remove_background: {
|
||||
providerTypes: ["alibaba-image", "bfl-image", "ruxa-image", "fal-image", "openai-image", "stepfun-image", "runway", "krea", "gemini"],
|
||||
providerTypes: ["alibaba-image", "bfl-image", "ruxa-image", "fal-image", "snapgen-image", "openai-image", "stepfun-image", "runway", "krea", "gemini"],
|
||||
reason: "抠图首选灵魂4.5",
|
||||
},
|
||||
|
||||
// 换背景:alibaba-image 支持场景生成
|
||||
replace_background: {
|
||||
providerTypes: ["alibaba-image", "bfl-image", "ruxa-image", "fal-image", "openai-image", "stepfun-image", "runway", "krea", "gemini"],
|
||||
providerTypes: ["alibaba-image", "bfl-image", "ruxa-image", "fal-image", "snapgen-image", "openai-image", "stepfun-image", "runway", "krea", "gemini"],
|
||||
reason: "换背景首选灵魂4.5",
|
||||
},
|
||||
|
||||
// 图片增强
|
||||
enhance: {
|
||||
providerTypes: ["alibaba-image", "bfl-image", "ruxa-image", "fal-image", "openai-image", "stepfun-image", "runway", "krea", "gemini", "volcano-vision"],
|
||||
providerTypes: ["alibaba-image", "bfl-image", "ruxa-image", "fal-image", "snapgen-image", "openai-image", "stepfun-image", "runway", "krea", "gemini", "volcano-vision"],
|
||||
reason: "增强首选灵魂4.5",
|
||||
},
|
||||
|
||||
// 风格迁移:gemini 可能更有创意
|
||||
style_transfer: {
|
||||
providerTypes: ["gemini", "alibaba-image", "bfl-image", "ruxa-image", "fal-image", "openai-image", "stepfun-image", "runway", "krea"],
|
||||
providerTypes: ["gemini", "alibaba-image", "bfl-image", "ruxa-image", "fal-image", "snapgen-image", "openai-image", "stepfun-image", "runway", "krea"],
|
||||
reason: "风格迁移优选Gemini",
|
||||
},
|
||||
};
|
||||
|
||||
@ -15,6 +15,7 @@ const PROVIDER_DEFAULT_BASE_URL: Partial<Record<AiProvider["type"], string>> = {
|
||||
"https://dashscope.aliyuncs.com/api/v1/services/aigc/multimodal-generation/generation",
|
||||
"ruxa-image": "https://api.ruxa.ai",
|
||||
"fal-image": "https://fal.run",
|
||||
"snapgen-image": "https://api.snapgen.ai",
|
||||
"openai-image": "https://api.openai.com/v1",
|
||||
"stepfun-image": "https://api.stepfun.com",
|
||||
"bfl-image": "https://api.bfl.ai",
|
||||
|
||||
773
lib/ai/providers/snapgen-image.ts
Normal file
773
lib/ai/providers/snapgen-image.ts
Normal file
@ -0,0 +1,773 @@
|
||||
import "server-only";
|
||||
|
||||
import sharp from "sharp";
|
||||
import { uploadFile } from "@/lib/storage/service";
|
||||
import { resolveSnapgenImageRuntimeModelId } from "@/lib/ai/snapgen-image-models";
|
||||
|
||||
interface SnapgenImageConfig {
|
||||
apiKey: string;
|
||||
baseURL: string;
|
||||
}
|
||||
|
||||
interface SnapgenImageParams {
|
||||
aspectRatio?: string;
|
||||
imageSize?: string;
|
||||
imageCount?: number;
|
||||
outputFormat?: "png" | "jpg" | "jpeg" | "webp";
|
||||
quality?: string;
|
||||
}
|
||||
|
||||
interface SnapgenImageModelConfig {
|
||||
maxImages?: number;
|
||||
supportsSize?: boolean;
|
||||
supports1K?: boolean;
|
||||
supports2K?: boolean;
|
||||
supports4K?: boolean;
|
||||
}
|
||||
|
||||
interface UploadedImage {
|
||||
thumbnail: string;
|
||||
original: string;
|
||||
}
|
||||
|
||||
type SnapgenApiFormat =
|
||||
| "snapgen-generic-image"
|
||||
| "snapgen-grok-image"
|
||||
| "snapgen-meta-ai-image"
|
||||
| "snapgen-gpt-image-2";
|
||||
|
||||
const DEFAULT_OUTPUT_FORMAT = "png";
|
||||
const POLL_INTERVAL_MS = 2000;
|
||||
const POLL_MAX_ATTEMPTS = 240;
|
||||
|
||||
function joinApiPath(baseURL: string, path: string) {
|
||||
const cleanBase = baseURL.replace(/\/+$/, "");
|
||||
const cleanPath = path.startsWith("/") ? path : `/${path}`;
|
||||
return `${cleanBase}${cleanPath}`;
|
||||
}
|
||||
|
||||
function normalizeSnapgenModelId(modelId?: string) {
|
||||
const raw = String(modelId || "").trim();
|
||||
if (!raw) {
|
||||
throw new Error("SnapGen 模型 ID 未配置");
|
||||
}
|
||||
return raw;
|
||||
}
|
||||
|
||||
function normalizeUrlForCompare(url: string) {
|
||||
try {
|
||||
const parsed = new URL(url);
|
||||
parsed.search = "";
|
||||
parsed.hash = "";
|
||||
return parsed.toString();
|
||||
} catch {
|
||||
return url.split("?")[0]?.split("#")[0] || url;
|
||||
}
|
||||
}
|
||||
|
||||
function filterSourceImages(urls: string[], sourceUrls: string[]) {
|
||||
if (sourceUrls.length === 0) return urls;
|
||||
const sourceSet = new Set(sourceUrls.map((url) => normalizeUrlForCompare(url)));
|
||||
return urls.filter((url) => !sourceSet.has(normalizeUrlForCompare(url)));
|
||||
}
|
||||
|
||||
function normalizeAspectRatio(aspectRatio?: string) {
|
||||
if (!aspectRatio) return "1:1";
|
||||
const ratio = aspectRatio.trim();
|
||||
if (ratio.toLowerCase() === "auto") return "1:1";
|
||||
if (["1:1", "16:9", "9:16", "4:3", "3:4", "21:9", "3:2", "2:3"].includes(ratio)) {
|
||||
return ratio;
|
||||
}
|
||||
return "1:1";
|
||||
}
|
||||
|
||||
function normalizeResolution(imageSize?: string, apiFormat?: SnapgenApiFormat) {
|
||||
const defaultResolution = apiFormat === "snapgen-gpt-image-2" ? "2K" : "1K";
|
||||
if (!imageSize) return defaultResolution;
|
||||
const normalized = imageSize.trim().toUpperCase();
|
||||
if (apiFormat === "snapgen-gpt-image-2") {
|
||||
if (["1K", "2K", "4K", "8K", "10K", "12K"].includes(normalized)) {
|
||||
return normalized;
|
||||
}
|
||||
return defaultResolution;
|
||||
}
|
||||
if (["1K", "2K", "4K"].includes(normalized)) return normalized;
|
||||
return defaultResolution;
|
||||
}
|
||||
|
||||
function normalizeOutputFormat(outputFormat?: string): "png" | "jpeg" {
|
||||
if (!outputFormat) return DEFAULT_OUTPUT_FORMAT;
|
||||
const normalized = outputFormat.trim().toLowerCase();
|
||||
if (normalized === "jpg" || normalized === "jpeg") return "jpeg";
|
||||
return "png";
|
||||
}
|
||||
|
||||
function normalizeOrientation(aspectRatio: string): "landscape" | "portrait" | "square" {
|
||||
if (aspectRatio === "1:1") return "square";
|
||||
if (["9:16", "3:4", "2:3"].includes(aspectRatio)) return "portrait";
|
||||
return "landscape";
|
||||
}
|
||||
|
||||
function normalizeGrokMode(value?: string): "SPEED" | "QUALITY" {
|
||||
const normalized = String(value || "").trim().toUpperCase();
|
||||
return normalized === "QUALITY" ? "QUALITY" : "SPEED";
|
||||
}
|
||||
|
||||
function normalizeGptImage2Mode(value?: string): "low" | "medium" | "high" {
|
||||
const normalized = String(value || "").trim().toLowerCase();
|
||||
if (normalized === "low" || normalized === "medium" || normalized === "high") {
|
||||
return normalized;
|
||||
}
|
||||
return "low";
|
||||
}
|
||||
|
||||
function clampImageCount(count?: number, maxImages = 1) {
|
||||
const normalized = Math.floor(Number(count || 1));
|
||||
if (!Number.isFinite(normalized) || normalized < 1) return 1;
|
||||
return Math.min(normalized, Math.max(1, maxImages));
|
||||
}
|
||||
|
||||
function inferApiFormat(modelId: string): SnapgenApiFormat {
|
||||
const normalized = modelId.toLowerCase();
|
||||
if (normalized.includes("gpt-image-2")) return "snapgen-gpt-image-2";
|
||||
if (normalized.includes("meta_ai") || normalized.includes("meta-ai") || normalized.includes("meta_ai/generate")) {
|
||||
return "snapgen-meta-ai-image";
|
||||
}
|
||||
if (normalized.includes("grok")) return "snapgen-grok-image";
|
||||
return "snapgen-generic-image";
|
||||
}
|
||||
|
||||
function resolveSnapgenRuntimeModelId(modelId: string) {
|
||||
const normalized = modelId.trim();
|
||||
return resolveSnapgenImageRuntimeModelId(normalized);
|
||||
}
|
||||
|
||||
function getEndpointPath(apiFormat: SnapgenApiFormat) {
|
||||
switch (apiFormat) {
|
||||
case "snapgen-grok-image":
|
||||
return "/uapi/v1/imagen/grok";
|
||||
case "snapgen-meta-ai-image":
|
||||
return "/uapi/v1/meta_ai/generate";
|
||||
case "snapgen-gpt-image-2":
|
||||
return "/uapi/v1/imagen/gpt-image-2";
|
||||
case "snapgen-generic-image":
|
||||
default:
|
||||
return "/uapi/v1/generate_image";
|
||||
}
|
||||
}
|
||||
|
||||
function getGenericEndpointModel(runtimeModelId: string) {
|
||||
const normalized = runtimeModelId.trim();
|
||||
if (normalized === "gemini-3.1-flash-image-preview") return "nano-banana-2";
|
||||
if (normalized === "gemini-3-pro-image-preview") return "nano-banana-pro";
|
||||
if (normalized.includes("/")) return normalized.split("/").pop() || normalized;
|
||||
return normalized;
|
||||
}
|
||||
|
||||
function extractPromptAndImages(messages: Array<{ role: string; content?: any; parts?: any[] }>) {
|
||||
const lastUserMessage = messages.filter((message) => message.role === "user").pop();
|
||||
if (!lastUserMessage) {
|
||||
throw new Error("No user message found");
|
||||
}
|
||||
|
||||
let prompt = "";
|
||||
const imageUrls: string[] = [];
|
||||
|
||||
const pushImageUrl = (url?: string) => {
|
||||
if (!url) return;
|
||||
const normalized = String(url).trim();
|
||||
if (normalized.startsWith("data:")) {
|
||||
throw new Error("SnapGen 暂不支持直接传输 Base64 图片,请先上传图片后再作为参考图使用");
|
||||
}
|
||||
if (!normalized.startsWith("http")) return;
|
||||
if (!imageUrls.includes(normalized)) {
|
||||
imageUrls.push(normalized);
|
||||
}
|
||||
};
|
||||
|
||||
const readPart = (part: any) => {
|
||||
if (part?.type === "text" && typeof part.text === "string") {
|
||||
prompt = `${prompt} ${part.text}`.trim();
|
||||
} else if (part?.type === "image_url" && part.image_url?.url) {
|
||||
pushImageUrl(part.image_url.url);
|
||||
} else if (part?.type === "image") {
|
||||
pushImageUrl(part.image || part.url || part.imageUrl);
|
||||
} else if (part?.type === "file") {
|
||||
pushImageUrl(part.url || part.data);
|
||||
}
|
||||
};
|
||||
|
||||
if (typeof lastUserMessage.content === "string") {
|
||||
prompt = lastUserMessage.content.trim();
|
||||
} else if (Array.isArray(lastUserMessage.content)) {
|
||||
for (const part of lastUserMessage.content) {
|
||||
readPart(part);
|
||||
}
|
||||
}
|
||||
|
||||
if (Array.isArray(lastUserMessage.parts)) {
|
||||
for (const part of lastUserMessage.parts) {
|
||||
readPart(part);
|
||||
}
|
||||
}
|
||||
|
||||
if (!prompt) {
|
||||
throw new Error("No prompt found in user message");
|
||||
}
|
||||
|
||||
return { prompt, imageUrls };
|
||||
}
|
||||
|
||||
function extractErrorMessage(payload: any): string {
|
||||
return (
|
||||
payload?.detail?.error_message ||
|
||||
payload?.detail?.error ||
|
||||
payload?.detail?.message ||
|
||||
payload?.error_message ||
|
||||
payload?.error?.message ||
|
||||
payload?.error ||
|
||||
payload?.message ||
|
||||
payload?.msg ||
|
||||
"SnapGen 任务执行失败"
|
||||
);
|
||||
}
|
||||
|
||||
function compactPayloadSnippet(payload: any): string {
|
||||
try {
|
||||
const json = JSON.stringify(payload);
|
||||
return json.length > 320 ? `${json.slice(0, 320)}...` : json;
|
||||
} catch {
|
||||
return String(payload || "");
|
||||
}
|
||||
}
|
||||
|
||||
function extractUuid(payload: any): string | undefined {
|
||||
const raw =
|
||||
payload?.uuid ||
|
||||
payload?.conversion_uuid ||
|
||||
payload?.id ||
|
||||
payload?.data?.uuid ||
|
||||
payload?.data?.conversion_uuid ||
|
||||
payload?.data?.id;
|
||||
return typeof raw === "string" && raw.trim() ? raw.trim() : undefined;
|
||||
}
|
||||
|
||||
function extractStatusCode(payload: any): number | undefined {
|
||||
const raw =
|
||||
payload?.status ||
|
||||
payload?.data?.status ||
|
||||
payload?.detail?.status ||
|
||||
payload?.state ||
|
||||
payload?.data?.state;
|
||||
if (typeof raw === "number" && Number.isFinite(raw)) return raw;
|
||||
if (typeof raw === "string") {
|
||||
const normalized = raw.trim().toLowerCase();
|
||||
if (normalized === "processing" || normalized === "pending" || normalized === "running") return 1;
|
||||
if (normalized === "completed" || normalized === "complete" || normalized === "success" || normalized === "succeeded") return 2;
|
||||
if (normalized === "failed" || normalized === "fail" || normalized === "error") return 3;
|
||||
const numeric = Number(normalized);
|
||||
if (Number.isFinite(numeric)) return numeric;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function isLikelyImageUrl(value: string, parentKey = "") {
|
||||
if (!/^https?:\/\//i.test(value)) return false;
|
||||
return (
|
||||
/\.(png|jpg|jpeg|webp|gif)(\?|#|$)/i.test(value) ||
|
||||
/image|img|url|output|result|artifact|file|download|thumbnail|uri/i.test(parentKey) ||
|
||||
/\/(image|img|generated|download|media|files?)\//i.test(value)
|
||||
);
|
||||
}
|
||||
|
||||
function collectImageUrls(payload: any): string[] {
|
||||
const urls = new Set<string>();
|
||||
|
||||
const walk = (value: any, parentKey = "") => {
|
||||
if (typeof value === "string") {
|
||||
if (isLikelyImageUrl(value, parentKey)) {
|
||||
urls.add(value);
|
||||
return;
|
||||
}
|
||||
|
||||
const trimmed = value.trim();
|
||||
if (
|
||||
trimmed.length > 1 &&
|
||||
trimmed.length < 200000 &&
|
||||
(trimmed.startsWith("{") || trimmed.startsWith("["))
|
||||
) {
|
||||
try {
|
||||
walk(JSON.parse(trimmed), parentKey);
|
||||
} catch {
|
||||
// ignore non-JSON strings
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (Array.isArray(value)) {
|
||||
for (const item of value) {
|
||||
walk(item, parentKey);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (value && typeof value === "object") {
|
||||
const preferredKeys = [
|
||||
"image_url",
|
||||
"file_download_url",
|
||||
"image_uri",
|
||||
"generate_result",
|
||||
"url",
|
||||
];
|
||||
for (const key of preferredKeys) {
|
||||
const nested = (value as Record<string, unknown>)[key];
|
||||
if (typeof nested === "string" && isLikelyImageUrl(nested, key)) {
|
||||
urls.add(nested);
|
||||
}
|
||||
}
|
||||
for (const [key, nestedValue] of Object.entries(value)) {
|
||||
walk(nestedValue, key);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
walk(payload);
|
||||
return Array.from(urls);
|
||||
}
|
||||
|
||||
function parseBase64Image(value: string): { buffer: Buffer; mimeType: string } | null {
|
||||
const trimmed = value.trim();
|
||||
const dataUrlMatch = trimmed.match(/^data:([^;,]+);base64,(.+)$/i);
|
||||
const mimeType = dataUrlMatch?.[1] || "image/png";
|
||||
const base64Value = dataUrlMatch?.[2] || trimmed;
|
||||
|
||||
if (!/^[A-Za-z0-9+/=\s]+$/.test(base64Value) || base64Value.length < 64) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
return { buffer: Buffer.from(base64Value.replace(/\s/g, ""), "base64"), mimeType };
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function collectBase64Images(payload: any): Array<{ buffer: Buffer; mimeType: string }> {
|
||||
const images: Array<{ buffer: Buffer; mimeType: string }> = [];
|
||||
const seen = new Set<string>();
|
||||
|
||||
const walk = (value: any, parentKey = "") => {
|
||||
if (typeof value === "string") {
|
||||
if (/base64/i.test(parentKey) || value.trim().startsWith("data:image/")) {
|
||||
const parsed = parseBase64Image(value);
|
||||
if (parsed) {
|
||||
const key = `${parsed.mimeType}:${parsed.buffer.length}:${parsed.buffer.subarray(0, 16).toString("hex")}`;
|
||||
if (!seen.has(key)) {
|
||||
seen.add(key);
|
||||
images.push(parsed);
|
||||
}
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (Array.isArray(value)) {
|
||||
for (const item of value) {
|
||||
walk(item, parentKey);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (value && typeof value === "object") {
|
||||
for (const [key, nestedValue] of Object.entries(value)) {
|
||||
walk(nestedValue, key);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
walk(payload);
|
||||
return images;
|
||||
}
|
||||
|
||||
function detectImageExtension(imageUrl: string, contentType?: string | null) {
|
||||
const type = (contentType || "").toLowerCase();
|
||||
if (type.includes("jpeg") || type.includes("jpg")) return "jpg";
|
||||
if (type.includes("webp")) return "webp";
|
||||
if (type.includes("gif")) return "gif";
|
||||
if (type.includes("png")) return "png";
|
||||
|
||||
const cleanUrl = imageUrl.split("?")[0]?.toLowerCase() || "";
|
||||
if (cleanUrl.endsWith(".jpg") || cleanUrl.endsWith(".jpeg")) return "jpg";
|
||||
if (cleanUrl.endsWith(".webp")) return "webp";
|
||||
if (cleanUrl.endsWith(".gif")) return "gif";
|
||||
if (cleanUrl.endsWith(".png")) return "png";
|
||||
return "png";
|
||||
}
|
||||
|
||||
function detectMimeExtension(mimeType?: string | null) {
|
||||
const type = (mimeType || "").toLowerCase();
|
||||
if (type.includes("jpeg") || type.includes("jpg")) return "jpg";
|
||||
if (type.includes("webp")) return "webp";
|
||||
if (type.includes("gif")) return "gif";
|
||||
return "png";
|
||||
}
|
||||
|
||||
async function createThumbnail(buffer: Buffer): Promise<Buffer> {
|
||||
try {
|
||||
return await sharp(buffer)
|
||||
.resize(400, 400, { fit: "inside", withoutEnlargement: true })
|
||||
.jpeg({ quality: 80 })
|
||||
.toBuffer();
|
||||
} catch (error) {
|
||||
console.error("[SnapGen Image] Failed to create thumbnail:", error);
|
||||
return buffer;
|
||||
}
|
||||
}
|
||||
|
||||
function generateFilename(prefix: string, extension: string) {
|
||||
const ts = Date.now();
|
||||
const rand = Math.random().toString(36).slice(2, 8);
|
||||
return `${prefix}-${ts}-${rand}.${extension}`;
|
||||
}
|
||||
|
||||
async function uploadImageBuffer(buffer: Buffer, extension: string): Promise<UploadedImage> {
|
||||
const thumbnailBuffer = await createThumbnail(buffer);
|
||||
|
||||
const originalFilename = generateFilename("snapgen-image-orig", extension);
|
||||
const originalArrayBuffer = buffer.buffer.slice(
|
||||
buffer.byteOffset,
|
||||
buffer.byteOffset + buffer.byteLength
|
||||
) as ArrayBuffer;
|
||||
const originalUploaded = await uploadFile(originalFilename, originalArrayBuffer);
|
||||
|
||||
const thumbFilename = generateFilename("snapgen-image-thumb", "jpg");
|
||||
const thumbArrayBuffer = thumbnailBuffer.buffer.slice(
|
||||
thumbnailBuffer.byteOffset,
|
||||
thumbnailBuffer.byteOffset + thumbnailBuffer.byteLength
|
||||
) as ArrayBuffer;
|
||||
const thumbUploaded = await uploadFile(thumbFilename, thumbArrayBuffer);
|
||||
|
||||
return {
|
||||
thumbnail: thumbUploaded.url,
|
||||
original: originalUploaded.url,
|
||||
};
|
||||
}
|
||||
|
||||
async function downloadAndUploadImage(imageUrl: string): Promise<UploadedImage> {
|
||||
const response = await fetch(imageUrl);
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to download SnapGen image: ${response.status}`);
|
||||
}
|
||||
|
||||
const arrayBuffer = await response.arrayBuffer();
|
||||
const buffer = Buffer.from(arrayBuffer);
|
||||
const originalExt = detectImageExtension(imageUrl, response.headers.get("content-type"));
|
||||
return uploadImageBuffer(buffer, originalExt);
|
||||
}
|
||||
|
||||
async function downloadReferenceImageFile(imageUrl: string, index: number) {
|
||||
const controller = new AbortController();
|
||||
const timeout = setTimeout(() => controller.abort(), 20_000);
|
||||
try {
|
||||
const response = await fetch(imageUrl, { signal: controller.signal });
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to download reference image: ${response.status}`);
|
||||
}
|
||||
const mimeType = response.headers.get("content-type") || "image/png";
|
||||
const buffer = Buffer.from(await response.arrayBuffer());
|
||||
const extension = detectImageExtension(imageUrl, mimeType);
|
||||
return {
|
||||
blob: new Blob([new Uint8Array(buffer)], { type: mimeType }),
|
||||
filename: `snapgen-reference-${index + 1}.${extension}`,
|
||||
};
|
||||
} finally {
|
||||
clearTimeout(timeout);
|
||||
}
|
||||
}
|
||||
|
||||
function buildHeaders(apiKey: string) {
|
||||
return {
|
||||
"x-api-key": apiKey,
|
||||
};
|
||||
}
|
||||
|
||||
async function parseJsonResponse(response: Response) {
|
||||
const rawText = await response.text();
|
||||
if (!rawText.trim()) return null;
|
||||
try {
|
||||
return JSON.parse(rawText);
|
||||
} catch {
|
||||
return rawText;
|
||||
}
|
||||
}
|
||||
|
||||
async function requestSnapgenMultipart({
|
||||
baseURL,
|
||||
apiKey,
|
||||
path,
|
||||
formData,
|
||||
}: {
|
||||
baseURL: string;
|
||||
apiKey: string;
|
||||
path: string;
|
||||
formData: FormData;
|
||||
}) {
|
||||
const response = await fetch(joinApiPath(baseURL, path), {
|
||||
method: "POST",
|
||||
headers: buildHeaders(apiKey),
|
||||
body: formData,
|
||||
});
|
||||
const payload = await parseJsonResponse(response);
|
||||
if (!response.ok) {
|
||||
throw new Error(`${extractErrorMessage(payload)} (HTTP ${response.status})`);
|
||||
}
|
||||
return payload;
|
||||
}
|
||||
|
||||
function appendGenericFields({
|
||||
formData,
|
||||
prompt,
|
||||
runtimeModelId,
|
||||
imageUrls,
|
||||
aspectRatio,
|
||||
resolution,
|
||||
outputFormat,
|
||||
}: {
|
||||
formData: FormData;
|
||||
prompt: string;
|
||||
runtimeModelId: string;
|
||||
imageUrls: string[];
|
||||
aspectRatio: string;
|
||||
resolution: string;
|
||||
outputFormat: string;
|
||||
}) {
|
||||
formData.append("prompt", prompt);
|
||||
formData.append("model", getGenericEndpointModel(runtimeModelId));
|
||||
formData.append("aspect_ratio", aspectRatio);
|
||||
formData.append("resolution", resolution);
|
||||
formData.append("output_format", outputFormat);
|
||||
for (const imageUrl of imageUrls.slice(0, 8)) {
|
||||
formData.append("file_urls", imageUrl);
|
||||
}
|
||||
}
|
||||
|
||||
async function appendReferenceFiles(formData: FormData, imageUrls: string[]) {
|
||||
for (const [index, imageUrl] of imageUrls.slice(0, 8).entries()) {
|
||||
const file = await downloadReferenceImageFile(imageUrl, index);
|
||||
formData.append("files", file.blob, file.filename);
|
||||
}
|
||||
}
|
||||
|
||||
async function buildRequestForm({
|
||||
apiFormat,
|
||||
prompt,
|
||||
runtimeModelId,
|
||||
imageUrls,
|
||||
aspectRatio,
|
||||
resolution,
|
||||
imageCount,
|
||||
outputFormat,
|
||||
quality,
|
||||
}: {
|
||||
apiFormat: SnapgenApiFormat;
|
||||
prompt: string;
|
||||
runtimeModelId: string;
|
||||
imageUrls: string[];
|
||||
aspectRatio: string;
|
||||
resolution: string;
|
||||
imageCount: number;
|
||||
outputFormat: string;
|
||||
quality?: string;
|
||||
}) {
|
||||
const formData = new FormData();
|
||||
|
||||
switch (apiFormat) {
|
||||
case "snapgen-grok-image": {
|
||||
formData.append("prompt", prompt);
|
||||
formData.append("orientation", normalizeOrientation(aspectRatio));
|
||||
formData.append("num_result", String(imageCount));
|
||||
formData.append("mode", normalizeGrokMode(quality));
|
||||
await appendReferenceFiles(formData, imageUrls);
|
||||
return formData;
|
||||
}
|
||||
case "snapgen-meta-ai-image": {
|
||||
formData.append("prompt", prompt);
|
||||
formData.append("orientation", normalizeOrientation(aspectRatio));
|
||||
formData.append("num_result", String(imageCount));
|
||||
await appendReferenceFiles(formData, imageUrls);
|
||||
return formData;
|
||||
}
|
||||
case "snapgen-gpt-image-2": {
|
||||
formData.append("prompt", prompt);
|
||||
formData.append("aspect_ratio", aspectRatio);
|
||||
formData.append("resolution", resolution);
|
||||
formData.append("mode", normalizeGptImage2Mode(quality));
|
||||
await appendReferenceFiles(formData, imageUrls);
|
||||
return formData;
|
||||
}
|
||||
case "snapgen-generic-image":
|
||||
default:
|
||||
appendGenericFields({
|
||||
formData,
|
||||
prompt,
|
||||
runtimeModelId,
|
||||
imageUrls,
|
||||
aspectRatio,
|
||||
resolution,
|
||||
outputFormat,
|
||||
});
|
||||
return formData;
|
||||
}
|
||||
}
|
||||
|
||||
async function waitForHistoryResult(baseURL: string, apiKey: string, uuid: string, sourceUrls: string[]) {
|
||||
for (let attempt = 1; attempt <= POLL_MAX_ATTEMPTS; attempt++) {
|
||||
const response = await fetch(
|
||||
joinApiPath(baseURL, `/uapi/v1/history/${encodeURIComponent(uuid)}`),
|
||||
{ headers: buildHeaders(apiKey) }
|
||||
);
|
||||
const payload = await parseJsonResponse(response);
|
||||
|
||||
if (!response.ok) {
|
||||
if (attempt === POLL_MAX_ATTEMPTS) {
|
||||
throw new Error(`${extractErrorMessage(payload)} (HTTP ${response.status})`);
|
||||
}
|
||||
await new Promise((resolve) => setTimeout(resolve, POLL_INTERVAL_MS));
|
||||
continue;
|
||||
}
|
||||
|
||||
const imageUrls = filterSourceImages(collectImageUrls(payload), sourceUrls);
|
||||
const base64Images = collectBase64Images(payload);
|
||||
if (imageUrls.length > 0 || base64Images.length > 0) {
|
||||
return { imageUrls, base64Images };
|
||||
}
|
||||
|
||||
const statusCode = extractStatusCode(payload);
|
||||
if (statusCode === 3) {
|
||||
throw new Error(extractErrorMessage(payload));
|
||||
}
|
||||
if (statusCode === 2) {
|
||||
throw new Error("SnapGen 任务已完成但未返回图片地址");
|
||||
}
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, POLL_INTERVAL_MS));
|
||||
}
|
||||
|
||||
throw new Error("SnapGen 任务超时,请稍后重试");
|
||||
}
|
||||
|
||||
function formatMarkdown(images: UploadedImage[], resolution: string) {
|
||||
return images
|
||||
.map((image) => ``)
|
||||
.join("\n\n");
|
||||
}
|
||||
|
||||
export function createSnapgenImage({ apiKey, baseURL }: SnapgenImageConfig) {
|
||||
return {
|
||||
provider: "snapgen-image",
|
||||
chat: (modelId: string) => ({
|
||||
provider: "snapgen-image",
|
||||
doGenerate: async (options: {
|
||||
messages: Array<{ role: string; content?: any; parts?: any[] }>;
|
||||
imageParams?: SnapgenImageParams;
|
||||
imageGenConfig?: SnapgenImageModelConfig | null;
|
||||
overrideModelId?: string;
|
||||
}) => {
|
||||
const { prompt, imageUrls } = extractPromptAndImages(options.messages);
|
||||
const requestedModelId = normalizeSnapgenModelId(options.overrideModelId || modelId);
|
||||
const runtimeModelId = resolveSnapgenRuntimeModelId(requestedModelId);
|
||||
const apiFormat = inferApiFormat(runtimeModelId);
|
||||
const aspectRatio = normalizeAspectRatio(options.imageParams?.aspectRatio);
|
||||
const resolution = normalizeResolution(options.imageParams?.imageSize, apiFormat);
|
||||
const outputFormat = normalizeOutputFormat(options.imageParams?.outputFormat);
|
||||
const maxImages = options.imageGenConfig?.maxImages || (apiFormat === "snapgen-grok-image" ? 8 : apiFormat === "snapgen-meta-ai-image" ? 4 : 1);
|
||||
const imageCount = clampImageCount(options.imageParams?.imageCount, maxImages);
|
||||
const formData = await buildRequestForm({
|
||||
apiFormat,
|
||||
prompt,
|
||||
runtimeModelId,
|
||||
imageUrls,
|
||||
aspectRatio,
|
||||
resolution,
|
||||
imageCount,
|
||||
outputFormat,
|
||||
quality: options.imageParams?.quality,
|
||||
});
|
||||
|
||||
console.log("[SnapGen Image] Creating task:", {
|
||||
model: runtimeModelId,
|
||||
apiFormat,
|
||||
aspectRatio,
|
||||
resolution,
|
||||
imageCount,
|
||||
referenceImageCount: imageUrls.length,
|
||||
});
|
||||
|
||||
const createPayload = await requestSnapgenMultipart({
|
||||
baseURL,
|
||||
apiKey,
|
||||
path: getEndpointPath(apiFormat),
|
||||
formData,
|
||||
});
|
||||
|
||||
let outputUrls = filterSourceImages(collectImageUrls(createPayload), imageUrls);
|
||||
let base64Images = collectBase64Images(createPayload);
|
||||
const statusCode = extractStatusCode(createPayload);
|
||||
if (statusCode === 3) {
|
||||
throw new Error(extractErrorMessage(createPayload));
|
||||
}
|
||||
|
||||
if (outputUrls.length === 0 && base64Images.length === 0) {
|
||||
const uuid = extractUuid(createPayload);
|
||||
if (!uuid) {
|
||||
throw new Error(
|
||||
`SnapGen 创建任务成功,但未返回 uuid 或图片结果,响应片段: ${compactPayloadSnippet(createPayload)}`
|
||||
);
|
||||
}
|
||||
const historyResult = await waitForHistoryResult(baseURL, apiKey, uuid, imageUrls);
|
||||
outputUrls = historyResult.imageUrls;
|
||||
base64Images = historyResult.base64Images;
|
||||
}
|
||||
|
||||
const uploadedImages: UploadedImage[] = [];
|
||||
for (const outputUrl of outputUrls.slice(0, imageCount)) {
|
||||
uploadedImages.push(await downloadAndUploadImage(outputUrl));
|
||||
}
|
||||
if (uploadedImages.length < imageCount) {
|
||||
for (const image of base64Images.slice(0, imageCount - uploadedImages.length)) {
|
||||
uploadedImages.push(
|
||||
await uploadImageBuffer(image.buffer, detectMimeExtension(image.mimeType))
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (uploadedImages.length === 0) {
|
||||
throw new Error("SnapGen 未返回可用图片");
|
||||
}
|
||||
|
||||
const text = formatMarkdown(uploadedImages, resolution);
|
||||
return {
|
||||
text,
|
||||
finishReason: "stop" as const,
|
||||
usage: {
|
||||
promptTokens: 0,
|
||||
completionTokens: text.length,
|
||||
},
|
||||
};
|
||||
},
|
||||
doStream: async function* (options: {
|
||||
messages: Array<{ role: string; content?: any; parts?: any[] }>;
|
||||
imageParams?: SnapgenImageParams;
|
||||
imageGenConfig?: SnapgenImageModelConfig | null;
|
||||
overrideModelId?: string;
|
||||
}) {
|
||||
const result = await this.doGenerate(options);
|
||||
yield {
|
||||
type: "text-delta",
|
||||
textDelta: result.text,
|
||||
};
|
||||
},
|
||||
}),
|
||||
};
|
||||
}
|
||||
@ -57,6 +57,8 @@ export function parseImageAltMetadata(altText: string, thumbnailUrl: string): {
|
||||
originalUrl = thumbnailUrl.replace("stepfun-image-thumb-", "stepfun-image-orig-");
|
||||
} else if (thumbnailUrl.includes("fal-image-thumb-")) {
|
||||
originalUrl = thumbnailUrl.replace("fal-image-thumb-", "fal-image-orig-");
|
||||
} else if (thumbnailUrl.includes("snapgen-image-thumb-")) {
|
||||
originalUrl = thumbnailUrl.replace("snapgen-image-thumb-", "snapgen-image-orig-");
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
236
lib/ai/snapgen-image-models.ts
Normal file
236
lib/ai/snapgen-image-models.ts
Normal file
@ -0,0 +1,236 @@
|
||||
export const SNAPGEN_IMAGE_BASE_URL = "https://api.snapgen.ai";
|
||||
|
||||
export const SNAPGEN_NANO_BANANA_PRO_MODEL_ID = "snapgen/nano-banana-pro";
|
||||
export const SNAPGEN_NANO_BANANA_2_MODEL_ID = "snapgen/nano-banana-2";
|
||||
export const SNAPGEN_GROK_IMAGE_MODEL_ID = "snapgen/grok-image";
|
||||
export const SNAPGEN_META_AI_IMAGE_MODEL_ID = "snapgen/meta-ai-image";
|
||||
export const SNAPGEN_GPT_IMAGE_2_MODEL_ID = "snapgen/gpt-image-2";
|
||||
|
||||
export interface SnapgenImageModelPreset {
|
||||
modelId: string;
|
||||
displayName: string;
|
||||
description: string;
|
||||
supportsVision: boolean;
|
||||
supportsImageGen: boolean;
|
||||
capabilities?: Record<string, any>;
|
||||
defaultParams?: Record<string, any>;
|
||||
imageGenConfig?: Record<string, any>;
|
||||
sortOrder: number;
|
||||
}
|
||||
|
||||
const SNAPGEN_COMMON_IMAGE_CONFIG = {
|
||||
supportsSize: true,
|
||||
supports1K: true,
|
||||
supports2K: true,
|
||||
supports4K: true,
|
||||
supportsPromptExtend: false,
|
||||
supportsNegativePrompt: false,
|
||||
supportsWatermark: false,
|
||||
supportsSeed: false,
|
||||
creditCostPerImage: 0,
|
||||
enableLoadingPlaceholder: true,
|
||||
loadingColor: "blue",
|
||||
};
|
||||
|
||||
const SNAPGEN_STANDARD_ASPECT_RATIOS = ["1:1", "16:9", "9:16", "4:3", "3:4"];
|
||||
const SNAPGEN_GPT_IMAGE_2_ASPECT_RATIOS = [
|
||||
"1:1",
|
||||
"16:9",
|
||||
"9:16",
|
||||
"4:3",
|
||||
"3:4",
|
||||
"21:9",
|
||||
"3:2",
|
||||
"2:3",
|
||||
];
|
||||
|
||||
export const SNAPGEN_IMAGE_MODEL_PRESETS: SnapgenImageModelPreset[] = [
|
||||
{
|
||||
modelId: SNAPGEN_NANO_BANANA_PRO_MODEL_ID,
|
||||
displayName: "SnapGen Nano Banana Pro",
|
||||
description:
|
||||
"SnapGen Gemini 3 Pro Image Preview 生图模型,面向高保真、文字渲染和复杂提示词场景。",
|
||||
supportsVision: true,
|
||||
supportsImageGen: true,
|
||||
capabilities: {
|
||||
provider: "snapgen",
|
||||
kind: "image-generation",
|
||||
endpoint: "nano-banana-pro",
|
||||
apiFormat: "snapgen-generic-image",
|
||||
officialUrl: "https://docs.snapgen.ai/resources/0.image-generation",
|
||||
requiredFields: ["prompt", "model"],
|
||||
optionalFields: ["aspect_ratio", "resolution", "style", "file_urls", "output_format"],
|
||||
supportsMultiImageReference: true,
|
||||
maxReferenceImages: 8,
|
||||
},
|
||||
defaultParams: {
|
||||
provider: "snapgen",
|
||||
endpoint: "nano-banana-pro",
|
||||
apiFormat: "snapgen-generic-image",
|
||||
imageSize: "2K",
|
||||
aspectRatio: "1:1",
|
||||
outputFormat: "png",
|
||||
},
|
||||
imageGenConfig: {
|
||||
...SNAPGEN_COMMON_IMAGE_CONFIG,
|
||||
maxImages: 1,
|
||||
supportedAspectRatios: SNAPGEN_STANDARD_ASPECT_RATIOS,
|
||||
supportedImageSizes: ["1K", "2K", "4K"],
|
||||
},
|
||||
sortOrder: 10,
|
||||
},
|
||||
{
|
||||
modelId: SNAPGEN_NANO_BANANA_2_MODEL_ID,
|
||||
displayName: "SnapGen Nano Banana 2",
|
||||
description:
|
||||
"SnapGen Gemini 3 Flash Image Preview 生图模型,适合快速生成和高频图片任务。",
|
||||
supportsVision: true,
|
||||
supportsImageGen: true,
|
||||
capabilities: {
|
||||
provider: "snapgen",
|
||||
kind: "image-generation",
|
||||
endpoint: "nano-banana-2",
|
||||
apiFormat: "snapgen-generic-image",
|
||||
officialUrl: "https://docs.snapgen.ai/resources/0.image-generation",
|
||||
requiredFields: ["prompt", "model"],
|
||||
optionalFields: ["aspect_ratio", "resolution", "style", "file_urls", "output_format"],
|
||||
supportsMultiImageReference: true,
|
||||
maxReferenceImages: 8,
|
||||
},
|
||||
defaultParams: {
|
||||
provider: "snapgen",
|
||||
endpoint: "nano-banana-2",
|
||||
apiFormat: "snapgen-generic-image",
|
||||
imageSize: "1K",
|
||||
aspectRatio: "1:1",
|
||||
outputFormat: "png",
|
||||
},
|
||||
imageGenConfig: {
|
||||
...SNAPGEN_COMMON_IMAGE_CONFIG,
|
||||
maxImages: 1,
|
||||
supportedAspectRatios: SNAPGEN_STANDARD_ASPECT_RATIOS,
|
||||
supportedImageSizes: ["1K", "2K", "4K"],
|
||||
},
|
||||
sortOrder: 20,
|
||||
},
|
||||
{
|
||||
modelId: SNAPGEN_GROK_IMAGE_MODEL_ID,
|
||||
displayName: "SnapGen Grok Image",
|
||||
description:
|
||||
"SnapGen Grok Image 生成接口,支持 SPEED / QUALITY 模式、横竖方图和最多 8 张输出。",
|
||||
supportsVision: true,
|
||||
supportsImageGen: true,
|
||||
capabilities: {
|
||||
provider: "snapgen",
|
||||
kind: "image-generation",
|
||||
endpoint: "/uapi/v1/imagen/grok",
|
||||
apiFormat: "snapgen-grok-image",
|
||||
officialUrl: "https://docs.snapgen.ai/resources/1.grok_image",
|
||||
requiredFields: ["prompt"],
|
||||
optionalFields: ["orientation", "num_result", "mode", "ref_history", "files"],
|
||||
supportsMultiImageReference: true,
|
||||
maxReferenceImages: 8,
|
||||
},
|
||||
defaultParams: {
|
||||
provider: "snapgen",
|
||||
endpoint: "/uapi/v1/imagen/grok",
|
||||
apiFormat: "snapgen-grok-image",
|
||||
imageSize: "1K",
|
||||
aspectRatio: "1:1",
|
||||
mode: "SPEED",
|
||||
},
|
||||
imageGenConfig: {
|
||||
...SNAPGEN_COMMON_IMAGE_CONFIG,
|
||||
maxImages: 8,
|
||||
supportedAspectRatios: SNAPGEN_STANDARD_ASPECT_RATIOS,
|
||||
supportedImageSizes: ["1K"],
|
||||
supportedQualities: ["SPEED", "QUALITY"],
|
||||
},
|
||||
sortOrder: 30,
|
||||
},
|
||||
{
|
||||
modelId: SNAPGEN_META_AI_IMAGE_MODEL_ID,
|
||||
displayName: "SnapGen Meta AI Image",
|
||||
description:
|
||||
"SnapGen Meta AI 图片生成接口,支持横图、竖图、方图和最多 4 张输出。",
|
||||
supportsVision: true,
|
||||
supportsImageGen: true,
|
||||
capabilities: {
|
||||
provider: "snapgen",
|
||||
kind: "image-generation",
|
||||
endpoint: "/uapi/v1/meta_ai/generate",
|
||||
apiFormat: "snapgen-meta-ai-image",
|
||||
officialUrl: "https://docs.snapgen.ai/resources/2.image_meta-ai",
|
||||
requiredFields: ["prompt"],
|
||||
optionalFields: ["orientation", "num_result", "ref_history", "files"],
|
||||
supportsMultiImageReference: true,
|
||||
maxReferenceImages: 8,
|
||||
},
|
||||
defaultParams: {
|
||||
provider: "snapgen",
|
||||
endpoint: "/uapi/v1/meta_ai/generate",
|
||||
apiFormat: "snapgen-meta-ai-image",
|
||||
imageSize: "1K",
|
||||
aspectRatio: "1:1",
|
||||
},
|
||||
imageGenConfig: {
|
||||
...SNAPGEN_COMMON_IMAGE_CONFIG,
|
||||
maxImages: 4,
|
||||
supportedAspectRatios: SNAPGEN_STANDARD_ASPECT_RATIOS,
|
||||
supportedImageSizes: ["1K"],
|
||||
},
|
||||
sortOrder: 40,
|
||||
},
|
||||
{
|
||||
modelId: SNAPGEN_GPT_IMAGE_2_MODEL_ID,
|
||||
displayName: "SnapGen GPT Image 2",
|
||||
description:
|
||||
"SnapGen GPT Image 2 高质量图片生成接口,支持多种比例、1K-12K 分辨率和低/中/高质量模式。",
|
||||
supportsVision: true,
|
||||
supportsImageGen: true,
|
||||
capabilities: {
|
||||
provider: "snapgen",
|
||||
kind: "image-generation",
|
||||
endpoint: "/uapi/v1/imagen/gpt-image-2",
|
||||
apiFormat: "snapgen-gpt-image-2",
|
||||
officialUrl: "https://docs.snapgen.ai/resources/3.gpt-image-2",
|
||||
requiredFields: ["prompt"],
|
||||
optionalFields: ["aspect_ratio", "resolution", "mode", "ref_history", "files"],
|
||||
supportsMultiImageReference: true,
|
||||
maxReferenceImages: 8,
|
||||
},
|
||||
defaultParams: {
|
||||
provider: "snapgen",
|
||||
endpoint: "/uapi/v1/imagen/gpt-image-2",
|
||||
apiFormat: "snapgen-gpt-image-2",
|
||||
imageSize: "2K",
|
||||
aspectRatio: "1:1",
|
||||
mode: "low",
|
||||
},
|
||||
imageGenConfig: {
|
||||
...SNAPGEN_COMMON_IMAGE_CONFIG,
|
||||
maxImages: 1,
|
||||
supportedAspectRatios: SNAPGEN_GPT_IMAGE_2_ASPECT_RATIOS,
|
||||
supportedImageSizes: ["1K", "2K", "4K", "8K", "10K", "12K"],
|
||||
supportedQualities: ["low", "medium", "high"],
|
||||
},
|
||||
sortOrder: 50,
|
||||
},
|
||||
];
|
||||
|
||||
export function getSnapgenImageModelPresetById(modelId: string) {
|
||||
return SNAPGEN_IMAGE_MODEL_PRESETS.find(
|
||||
(preset) => preset.modelId === String(modelId || "").trim()
|
||||
);
|
||||
}
|
||||
|
||||
export function resolveSnapgenImageRuntimeModelId(modelId: string) {
|
||||
const preset = getSnapgenImageModelPresetById(modelId);
|
||||
const endpoint = preset?.defaultParams?.endpoint;
|
||||
|
||||
if (typeof endpoint === "string" && endpoint.trim()) {
|
||||
return endpoint.trim();
|
||||
}
|
||||
|
||||
return modelId;
|
||||
}
|
||||
@ -8,6 +8,7 @@ import { createAlibabaImage } from "@/lib/ai/providers/alibaba-image";
|
||||
import { createBflImage } from "@/lib/ai/providers/bfl-image";
|
||||
import { createRuxaImage } from "@/lib/ai/providers/ruxa-image";
|
||||
import { createFalImage } from "@/lib/ai/providers/fal-image";
|
||||
import { createSnapgenImage } from "@/lib/ai/providers/snapgen-image";
|
||||
import { createOpenAIImage } from "@/lib/ai/providers/openai-image";
|
||||
import { createStepfunImage } from "@/lib/ai/providers/stepfun-image";
|
||||
import { createMidjourney } from "@/lib/ai/providers/midjourney";
|
||||
@ -143,6 +144,7 @@ const SUPPORTED_CANVAS_PROVIDER_TYPES = new Set([
|
||||
"bfl-image",
|
||||
"ruxa-image",
|
||||
"fal-image",
|
||||
"snapgen-image",
|
||||
"openai-image",
|
||||
"stepfun-image",
|
||||
"midjourney",
|
||||
@ -1186,6 +1188,8 @@ function getProviderBatchLimit(providerType: string) {
|
||||
case "midjourney":
|
||||
case "runway":
|
||||
return 1;
|
||||
case "snapgen-image":
|
||||
return 8;
|
||||
case "krea":
|
||||
return 4;
|
||||
case "gemini":
|
||||
@ -1515,7 +1519,7 @@ export async function generateCanvasImage(
|
||||
if (!SUPPORTED_CANVAS_PROVIDER_TYPES.has(provider.type)) {
|
||||
if (request.allowFailover === false) {
|
||||
throw new Error(
|
||||
"当前图片模型类型暂不支持画布生成,请在后台选择 Gemini/Grsai GPT Image/火山 Seedream/阿里/BFL/RUXA/FAL/OpenAI/StepFun/Runway/Krea 图片模型。"
|
||||
"当前图片模型类型暂不支持画布生成,请在后台选择 Gemini/Grsai GPT Image/火山 Seedream/阿里/BFL/RUXA/FAL/SnapGen/OpenAI/StepFun/Runway/Krea 图片模型。"
|
||||
);
|
||||
}
|
||||
|
||||
@ -1531,7 +1535,7 @@ export async function generateCanvasImage(
|
||||
}
|
||||
if (!SUPPORTED_CANVAS_PROVIDER_TYPES.has(fallbackProvider.type)) {
|
||||
throw new Error(
|
||||
"当前图片模型类型暂不支持画布生成,请在后台选择 Gemini/Grsai GPT Image/火山 Seedream/阿里/BFL/RUXA/FAL/OpenAI/StepFun/Runway/Krea 图片模型。"
|
||||
"当前图片模型类型暂不支持画布生成,请在后台选择 Gemini/Grsai GPT Image/火山 Seedream/阿里/BFL/RUXA/FAL/SnapGen/OpenAI/StepFun/Runway/Krea 图片模型。"
|
||||
);
|
||||
}
|
||||
if (fallbackModel.model.id !== selectedModel.model.id) {
|
||||
@ -1722,6 +1726,26 @@ export async function generateCanvasImage(
|
||||
});
|
||||
return result.text || "";
|
||||
};
|
||||
} else if (provider.type === "snapgen-image") {
|
||||
const snapgenProvider = createSnapgenImage({
|
||||
apiKey: provider.apiKey,
|
||||
baseURL: provider.baseUrl,
|
||||
});
|
||||
const runtimeModelId = resolveModelRuntimeId(selectedModel.model, {
|
||||
providerType: provider.type,
|
||||
});
|
||||
const modelInstance = snapgenProvider.chat(runtimeModelId);
|
||||
executeGenerateBatch = async (batchImageCount) => {
|
||||
const result = await modelInstance.doGenerate({
|
||||
messages,
|
||||
imageParams: {
|
||||
...imageParams,
|
||||
imageCount: batchImageCount,
|
||||
},
|
||||
imageGenConfig: selectedModel.model.imageGenConfig || null,
|
||||
});
|
||||
return result.text || "";
|
||||
};
|
||||
} else if (provider.type === "openai-image") {
|
||||
const openAIImageProvider = createOpenAIImage({
|
||||
apiKey: provider.apiKey,
|
||||
@ -1814,7 +1838,7 @@ export async function generateCanvasImage(
|
||||
};
|
||||
} else {
|
||||
throw new Error(
|
||||
`不支持的图片生成模型类型: ${provider.type || "unknown"},请改用 Gemini/Grsai GPT Image/火山 Seedream/阿里/BFL/RUXA/FAL/OpenAI/StepFun/Midjourney/Runway/Krea 图片模型`
|
||||
`不支持的图片生成模型类型: ${provider.type || "unknown"},请改用 Gemini/Grsai GPT Image/火山 Seedream/阿里/BFL/RUXA/FAL/SnapGen/OpenAI/StepFun/Midjourney/Runway/Krea 图片模型`
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@ -31,6 +31,7 @@ const providerBaseUrlDefaults = [
|
||||
{ type: "bfl-image", baseUrl: "https://api.bfl.ai" },
|
||||
{ type: "ruxa-image", baseUrl: "https://api.ruxa.ai" },
|
||||
{ type: "fal-image", baseUrl: "https://fal.run" },
|
||||
{ type: "snapgen-image", baseUrl: "https://api.snapgen.ai" },
|
||||
{ type: "openai-image", baseUrl: "https://api.openai.com/v1" },
|
||||
{ type: "stepfun-image", baseUrl: "https://api.stepfun.com" },
|
||||
{ type: "midjourney", baseUrl: "https://your-midjourney-api.example.com" },
|
||||
@ -149,7 +150,7 @@ async function runMigrationStatements(db: ReturnType<typeof drizzle>) {
|
||||
ADD CONSTRAINT "AiProvider_type_check"
|
||||
CHECK (type IN (
|
||||
'alibaba', 'openai', 'custom', 'local', 'gemini',
|
||||
'grsai-gpt-image', 'grsai-video', 'huanke-gemini', 'qianwen-video', 'doubao-video', 'ruxa-video', 'volcano-vision', 'volcano-video', 'alibaba-image', 'bfl-image', 'ruxa-image', 'fal-image', 'openai-image', 'stepfun-image', 'midjourney', 'fal-video', 'zhipu', 'tripo-3d', 'worldlabs-3d', 'kittycad-cad', 'runway', 'krea'
|
||||
'grsai-gpt-image', 'grsai-video', 'huanke-gemini', 'qianwen-video', 'doubao-video', 'ruxa-video', 'volcano-vision', 'volcano-video', 'alibaba-image', 'bfl-image', 'ruxa-image', 'fal-image', 'snapgen-image', 'openai-image', 'stepfun-image', 'midjourney', 'fal-video', 'zhipu', 'tripo-3d', 'worldlabs-3d', 'kittycad-cad', 'runway', 'krea'
|
||||
));
|
||||
`);
|
||||
await db.execute(sql`
|
||||
|
||||
@ -3876,7 +3876,7 @@ export async function createAiProvider({
|
||||
updatedBy,
|
||||
}: {
|
||||
name: string;
|
||||
type: "alibaba" | "openai" | "custom" | "local" | "gemini" | "grsai-gpt-image" | "grsai-video" | "huanke-gemini" | "qianwen-video" | "doubao-video" | "ruxa-video" | "volcano-vision" | "volcano-video" | "alibaba-image" | "bfl-image" | "ruxa-image" | "fal-image" | "openai-image" | "stepfun-image" | "midjourney" | "fal-video" | "zhipu" | "tripo-3d" | "worldlabs-3d" | "kittycad-cad" | "runway" | "krea";
|
||||
type: "alibaba" | "openai" | "custom" | "local" | "gemini" | "grsai-gpt-image" | "grsai-video" | "huanke-gemini" | "qianwen-video" | "doubao-video" | "ruxa-video" | "volcano-vision" | "volcano-video" | "alibaba-image" | "bfl-image" | "ruxa-image" | "fal-image" | "snapgen-image" | "openai-image" | "stepfun-image" | "midjourney" | "fal-video" | "zhipu" | "tripo-3d" | "worldlabs-3d" | "kittycad-cad" | "runway" | "krea";
|
||||
baseUrl: string;
|
||||
apiKey: string;
|
||||
isEnabled?: boolean;
|
||||
@ -3914,7 +3914,7 @@ export async function updateAiProvider({
|
||||
}: {
|
||||
id: string;
|
||||
name?: string;
|
||||
type?: "alibaba" | "openai" | "custom" | "local" | "gemini" | "grsai-gpt-image" | "grsai-video" | "huanke-gemini" | "qianwen-video" | "doubao-video" | "ruxa-video" | "volcano-vision" | "volcano-video" | "alibaba-image" | "bfl-image" | "ruxa-image" | "fal-image" | "openai-image" | "stepfun-image" | "midjourney" | "fal-video" | "zhipu" | "tripo-3d" | "worldlabs-3d" | "kittycad-cad" | "runway" | "krea";
|
||||
type?: "alibaba" | "openai" | "custom" | "local" | "gemini" | "grsai-gpt-image" | "grsai-video" | "huanke-gemini" | "qianwen-video" | "doubao-video" | "ruxa-video" | "volcano-vision" | "volcano-video" | "alibaba-image" | "bfl-image" | "ruxa-image" | "fal-image" | "snapgen-image" | "openai-image" | "stepfun-image" | "midjourney" | "fal-video" | "zhipu" | "tripo-3d" | "worldlabs-3d" | "kittycad-cad" | "runway" | "krea";
|
||||
baseUrl?: string;
|
||||
apiKey?: string;
|
||||
isEnabled?: boolean;
|
||||
|
||||
@ -308,7 +308,7 @@ export type ToolConfig = InferSelectModel<typeof toolConfig>;
|
||||
export const aiProvider = pgTable("AiProvider", {
|
||||
id: uuid("id").primaryKey().notNull().defaultRandom(),
|
||||
name: varchar("name", { length: 128 }).notNull(), // 提供商名称,如 "阿里云通义千问"
|
||||
type: varchar("type", { enum: ["alibaba", "openai", "custom", "local", "gemini", "grsai-gpt-image", "grsai-video", "huanke-gemini", "qianwen-video", "doubao-video", "ruxa-video", "volcano-vision", "volcano-video", "alibaba-image", "ruxa-image", "fal-image", "openai-image", "stepfun-image", "midjourney", "fal-video", "zhipu", "tripo-3d", "worldlabs-3d", "kittycad-cad", "runway", "krea", "bfl-image"] })
|
||||
type: varchar("type", { enum: ["alibaba", "openai", "custom", "local", "gemini", "grsai-gpt-image", "grsai-video", "huanke-gemini", "qianwen-video", "doubao-video", "ruxa-video", "volcano-vision", "volcano-video", "alibaba-image", "ruxa-image", "fal-image", "snapgen-image", "openai-image", "stepfun-image", "midjourney", "fal-video", "zhipu", "tripo-3d", "worldlabs-3d", "kittycad-cad", "runway", "krea", "bfl-image"] })
|
||||
.notNull()
|
||||
.default("custom"), // 提供商类型
|
||||
baseUrl: text("baseUrl").notNull(), // API 基础 URL
|
||||
|
||||
@ -337,7 +337,8 @@ function supportsImageEditForReferenceCount(
|
||||
const providerImpliesReferenceEdit =
|
||||
providerType === "openai-image" ||
|
||||
providerType === "grsai-gpt-image" ||
|
||||
providerType === "ruxa-image";
|
||||
providerType === "ruxa-image" ||
|
||||
providerType === "snapgen-image";
|
||||
|
||||
const supportsReferenceEdit =
|
||||
providerImpliesReferenceEdit ||
|
||||
|
||||
@ -52,7 +52,8 @@ export function isLikelyThumbnailUrl(url: string): boolean {
|
||||
lowerUrl.includes("alibaba-image-thumb-") ||
|
||||
lowerUrl.includes("ruxa-image-thumb-") ||
|
||||
lowerUrl.includes("stepfun-image-thumb-") ||
|
||||
lowerUrl.includes("fal-image-thumb-")
|
||||
lowerUrl.includes("fal-image-thumb-") ||
|
||||
lowerUrl.includes("snapgen-image-thumb-")
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
@ -92,6 +93,9 @@ export function inferOriginalImageUrl(url: string): string {
|
||||
if (url.includes("fal-image-thumb-")) {
|
||||
return url.replace("fal-image-thumb-", "fal-image-orig-");
|
||||
}
|
||||
if (url.includes("snapgen-image-thumb-")) {
|
||||
return url.replace("snapgen-image-thumb-", "snapgen-image-orig-");
|
||||
}
|
||||
|
||||
if (url.includes("/thumb/")) {
|
||||
return url.replace("/thumb/", "/");
|
||||
|
||||
@ -73,6 +73,7 @@ export function canProcessSceneFusionProviderType(providerType: string): boolean
|
||||
case "alibaba-image":
|
||||
case "ruxa-image":
|
||||
case "fal-image":
|
||||
case "snapgen-image":
|
||||
case "openai-image":
|
||||
case "gemini":
|
||||
case "volcano-vision":
|
||||
@ -92,6 +93,7 @@ function getSupportedAspectRatiosByProvider(providerType: string): SceneFusionAs
|
||||
case "volcano-vision":
|
||||
return [];
|
||||
case "ruxa-image":
|
||||
case "snapgen-image":
|
||||
return ["1:1", "4:3", "3:4", "16:9", "9:16"];
|
||||
case "alibaba-image":
|
||||
case "fal-image":
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user