feat: add bltcy unified video provider
This commit is contained in:
parent
0808d1963b
commit
d6d8bf021f
@ -214,6 +214,10 @@ import {
|
||||
BLTCY_IMAGE_OPENAI_BASE_URL,
|
||||
BLTCY_IMAGE_PROVIDER_PRESET,
|
||||
} from "@/lib/ai/bltcy-image-models";
|
||||
import {
|
||||
BLTCY_VIDEO_MODEL_PRESETS,
|
||||
BLTCY_VIDEO_PROVIDER_PRESET,
|
||||
} from "@/lib/ai/bltcy-video-models";
|
||||
import {
|
||||
CANVAS_SKILL_ICON_OPTIONS,
|
||||
getCanvasSkillIconComponent,
|
||||
@ -455,6 +459,7 @@ type AiProvider = {
|
||||
| "custom"
|
||||
| "local"
|
||||
| "gemini"
|
||||
| "bltcy-video"
|
||||
| "grsai-gpt-image"
|
||||
| "grsai-video"
|
||||
| "huanke-gemini"
|
||||
@ -1188,6 +1193,7 @@ const PROVIDER_TYPES = [
|
||||
{ value: "ruxa-image", label: "RUXA生图" },
|
||||
{ value: "fal-image", label: "FAL 生图模型" },
|
||||
{ value: "openai-image", label: "OpenAI 图片兼容" },
|
||||
{ value: "bltcy-video", label: "柏拉图统一视频" },
|
||||
{ value: "grsai-gpt-image", label: "Grsai GPT Image" },
|
||||
{ value: "grsai-video", label: "Grsai 视频模型" },
|
||||
{ value: "midjourney", label: "Midjourney" },
|
||||
@ -1219,6 +1225,7 @@ const PROVIDER_DEFAULT_BASE_URL: Record<string, string> = {
|
||||
"ruxa-image": "https://api.ruxa.ai",
|
||||
"fal-image": "https://fal.run",
|
||||
"openai-image": "https://api.openai.com/v1",
|
||||
"bltcy-video": "https://api.bltcy.ai",
|
||||
"grsai-gpt-image": "https://grsai.dakka.com.cn",
|
||||
"grsai-video": "https://grsai.dakka.com.cn",
|
||||
midjourney: "https://your-midjourney-api.example.com",
|
||||
@ -3014,6 +3021,7 @@ export function AdminSettingsClient() {
|
||||
| "custom"
|
||||
| "local"
|
||||
| "gemini"
|
||||
| "bltcy-video"
|
||||
| "grsai-gpt-image"
|
||||
| "grsai-video"
|
||||
| "huanke-gemini"
|
||||
@ -4985,6 +4993,7 @@ export function AdminSettingsClient() {
|
||||
const isNewProvider = !editingProvider;
|
||||
const isVolcanoVideo = providerForm.type === "volcano-video";
|
||||
const isKreaProvider = providerForm.type === "krea";
|
||||
const isBltcyVideoProvider = providerForm.type === "bltcy-video";
|
||||
const isLemonProvider =
|
||||
providerForm.type === "openai" &&
|
||||
isLemonApiBaseUrl(
|
||||
@ -5089,6 +5098,17 @@ export function AdminSettingsClient() {
|
||||
}
|
||||
}
|
||||
|
||||
if (isNewProvider && isBltcyVideoProvider && providerId) {
|
||||
try {
|
||||
await handleQuickAddBltcyVideoModels(providerId, { skipConfirm: true });
|
||||
} catch (modelError) {
|
||||
console.error("[Auto Add BLTCY Video Models] Error:", modelError);
|
||||
toast.warning(
|
||||
'柏拉图视频预设模型添加失败,请手动点击"快速添加视频预设"按钮'
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (isNewProvider && isLemonProvider && providerId) {
|
||||
try {
|
||||
await handleQuickAddLemonModels(providerId);
|
||||
@ -5871,6 +5891,63 @@ export function AdminSettingsClient() {
|
||||
}
|
||||
};
|
||||
|
||||
const handleUseBltcyVideoProviderPreset = () => {
|
||||
setProviderForm({
|
||||
id: "",
|
||||
name: BLTCY_VIDEO_PROVIDER_PRESET.name,
|
||||
type: BLTCY_VIDEO_PROVIDER_PRESET.type,
|
||||
baseUrl: BLTCY_VIDEO_PROVIDER_PRESET.baseUrl,
|
||||
apiKey: "",
|
||||
isEnabled: true,
|
||||
});
|
||||
setEditingProvider(null);
|
||||
setShowProviderDialog(true);
|
||||
};
|
||||
|
||||
const handleQuickAddBltcyVideoModels = async (
|
||||
providerId: string,
|
||||
options?: { skipConfirm?: boolean }
|
||||
) => {
|
||||
if (
|
||||
!options?.skipConfirm &&
|
||||
!confirm(
|
||||
`将为此服务商添加 ${BLTCY_VIDEO_MODEL_PRESETS.length} 个柏拉图统一视频模型预设。\n\n继续吗?`
|
||||
)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
toast.info("正在添加柏拉图统一视频预设模型...");
|
||||
|
||||
const response = await fetch("/api/admin/bltcy-video-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, errors } = data;
|
||||
|
||||
if (addedCount > 0) {
|
||||
toast.success(`成功添加 ${addedCount} 个柏拉图视频预设模型`);
|
||||
mutateModels();
|
||||
}
|
||||
|
||||
if (errors && errors.length > 0) {
|
||||
toast.warning(`${errors.length} 个模型添加失败(可能已存在)`);
|
||||
}
|
||||
} catch (error: any) {
|
||||
console.error("[Quick Add BLTCY Video Models] Error:", error);
|
||||
toast.error(error.message || "添加柏拉图视频预设模型失败");
|
||||
}
|
||||
};
|
||||
|
||||
const handleUseBltcyImageProviderPreset = () => {
|
||||
setProviderForm({
|
||||
id: "",
|
||||
@ -8012,6 +8089,7 @@ export function AdminSettingsClient() {
|
||||
{(() => {
|
||||
const providerColors: Record<string, string> = {
|
||||
gemini: "bg-purple-500",
|
||||
"bltcy-video": "bg-cyan-500",
|
||||
"volcano-vision": "bg-orange-500",
|
||||
"volcano-video": "bg-red-500",
|
||||
"doubao-video": "bg-red-400",
|
||||
@ -12733,6 +12811,12 @@ export function AdminSettingsClient() {
|
||||
<CardDescription>配置 AI 服务提供商</CardDescription>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={handleUseBltcyVideoProviderPreset}
|
||||
>
|
||||
<Video className="mr-2 h-4 w-4" /> 柏拉图视频
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={handleUseBltcyImageProviderPreset}
|
||||
@ -12896,6 +12980,12 @@ export function AdminSettingsClient() {
|
||||
OpenAPI 中的图片、图片增强和视频模型预设。
|
||||
</p>
|
||||
)}
|
||||
{providerForm.type === "bltcy-video" && (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
柏拉图统一视频使用 Bearer Token 认证,走
|
||||
`/v2/videos/generations` 异步任务接口。保存后会自动添加当前支持的统一视频模型预设。
|
||||
</p>
|
||||
)}
|
||||
{providerForm.type === "openai-image" &&
|
||||
isBltcyImageBaseUrl(providerForm.baseUrl) && (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
@ -12986,6 +13076,19 @@ export function AdminSettingsClient() {
|
||||
快速添加 Krea 预设模型
|
||||
</Button>
|
||||
)}
|
||||
{provider.type === "bltcy-video" && (
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
className="mt-2"
|
||||
onClick={() =>
|
||||
handleQuickAddBltcyVideoModels(provider.id)
|
||||
}
|
||||
>
|
||||
<Video className="h-4 w-4 mr-1" />
|
||||
快速添加视频预设
|
||||
</Button>
|
||||
)}
|
||||
{provider.type === "openai-image" &&
|
||||
isBltcyImageBaseUrl(provider.baseUrl) && (
|
||||
<Button
|
||||
|
||||
@ -10478,7 +10478,7 @@ export async function POST(request: Request) {
|
||||
|
||||
// ========== 千问/FAL/RUXA 视频生成独立处理 ==========
|
||||
if (
|
||||
["qianwen-video", "fal-video", "ruxa-video", "runway", "krea"].includes((languageModel as any).provider) &&
|
||||
["qianwen-video", "fal-video", "ruxa-video", "runway", "krea", "bltcy-video"].includes((languageModel as any).provider) &&
|
||||
typeof (languageModel as any).doGenerate === "function"
|
||||
) {
|
||||
const currentVideoProvider = (languageModel as any).provider as
|
||||
@ -10486,7 +10486,8 @@ export async function POST(request: Request) {
|
||||
| "fal-video"
|
||||
| "ruxa-video"
|
||||
| "runway"
|
||||
| "krea";
|
||||
| "krea"
|
||||
| "bltcy-video";
|
||||
console.log(`[Chat API] Using ${currentVideoProvider} video generation`);
|
||||
|
||||
const videoGenerationStream = createUIMessageStream({
|
||||
|
||||
@ -8,6 +8,7 @@ import {
|
||||
deleteAiProvider,
|
||||
} from "@/lib/db/queries";
|
||||
import { ChatSDKError } from "@/lib/errors";
|
||||
import { BLTCY_VIDEO_BASE_URL, isBltcyVideoBaseUrl } from "@/lib/ai/bltcy-video-models";
|
||||
|
||||
const providerTypes = [
|
||||
"alibaba",
|
||||
@ -15,6 +16,7 @@ const providerTypes = [
|
||||
"custom",
|
||||
"local",
|
||||
"gemini",
|
||||
"bltcy-video",
|
||||
"grsai-gpt-image",
|
||||
"grsai-video",
|
||||
"huanke-gemini",
|
||||
@ -39,7 +41,10 @@ const providerTypes = [
|
||||
"volcano-multimodal",
|
||||
] as const;
|
||||
type ProviderType = (typeof providerTypes)[number];
|
||||
type StoredProviderType = Exclude<ProviderType, "zhipu" | "volcano-multimodal">;
|
||||
type StoredProviderType = Exclude<
|
||||
ProviderType,
|
||||
"zhipu" | "volcano-multimodal" | "bltcy-video"
|
||||
>;
|
||||
|
||||
const createProviderSchema = z.object({
|
||||
name: z.string().min(1).max(128),
|
||||
@ -79,7 +84,9 @@ function normalizeProviderTypeForStorage(
|
||||
type?: ProviderType
|
||||
): StoredProviderType | undefined {
|
||||
if (!type) return undefined;
|
||||
if (type === "zhipu" || type === "volcano-multimodal") return "custom";
|
||||
if (type === "zhipu" || type === "volcano-multimodal" || type === "bltcy-video") {
|
||||
return "custom";
|
||||
}
|
||||
return type;
|
||||
}
|
||||
|
||||
@ -112,6 +119,13 @@ function isVolcanoMultimodalBaseUrl(baseUrl: string) {
|
||||
}
|
||||
|
||||
function normalizeProviderTypeForResponse(provider: Record<string, any>) {
|
||||
if (
|
||||
provider.type === "custom" &&
|
||||
typeof provider.baseUrl === "string" &&
|
||||
isBltcyVideoBaseUrl(provider.baseUrl)
|
||||
) {
|
||||
return { ...provider, type: "bltcy-video" };
|
||||
}
|
||||
if (provider.type === "custom" && typeof provider.baseUrl === "string" && isZhipuBaseUrl(provider.baseUrl)) {
|
||||
return { ...provider, type: "zhipu" };
|
||||
}
|
||||
@ -202,6 +216,8 @@ export async function POST(request: Request) {
|
||||
baseUrl:
|
||||
data.type === "zhipu"
|
||||
? data.baseUrl || ZHIPU_BASE_URL
|
||||
: data.type === "bltcy-video"
|
||||
? data.baseUrl || BLTCY_VIDEO_BASE_URL
|
||||
: data.type === "volcano-multimodal"
|
||||
? data.baseUrl || VOLCANO_MULTIMODAL_BASE_URL
|
||||
: data.type === "doubao-video"
|
||||
|
||||
139
app/api/admin/bltcy-video-models/route.ts
Normal file
139
app/api/admin/bltcy-video-models/route.ts
Normal file
@ -0,0 +1,139 @@
|
||||
import { and, eq } from "drizzle-orm";
|
||||
import { NextResponse } from "next/server";
|
||||
import {
|
||||
BLTCY_VIDEO_MODEL_PRESETS,
|
||||
isBltcyVideoBaseUrl,
|
||||
} from "@/lib/ai/bltcy-video-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: BLTCY_VIDEO_MODEL_PRESETS,
|
||||
counts: {
|
||||
video: BLTCY_VIDEO_MODEL_PRESETS.length,
|
||||
total: BLTCY_VIDEO_MODEL_PRESETS.length,
|
||||
},
|
||||
});
|
||||
} catch (error: any) {
|
||||
console.error("[BLTCY Video Models API] GET error:", error);
|
||||
return NextResponse.json(
|
||||
{ error: error.message || "Failed to fetch BLTCY video 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 !== "custom" ||
|
||||
!isBltcyVideoBaseUrl(provider.baseUrl)
|
||||
) {
|
||||
return NextResponse.json(
|
||||
{ error: "Provider must be a 柏拉图统一视频 provider" },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
const presetsToAdd = modelIds
|
||||
? BLTCY_VIDEO_MODEL_PRESETS.filter((preset) =>
|
||||
modelIds.includes(preset.modelId)
|
||||
)
|
||||
: BLTCY_VIDEO_MODEL_PRESETS;
|
||||
|
||||
const addedModels: 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) {
|
||||
errors.push({
|
||||
modelId: preset.modelId,
|
||||
error: "Model already exists",
|
||||
});
|
||||
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(
|
||||
`[BLTCY Video Models API] Failed to add ${preset.modelId}:`,
|
||||
error
|
||||
);
|
||||
errors.push({
|
||||
modelId: preset.modelId,
|
||||
error: error.message || "Insert failed",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
addedCount: addedModels.length,
|
||||
addedModels,
|
||||
errors: errors.length > 0 ? errors : undefined,
|
||||
counts: {
|
||||
requested: presetsToAdd.length,
|
||||
total: BLTCY_VIDEO_MODEL_PRESETS.length,
|
||||
},
|
||||
});
|
||||
} catch (error: any) {
|
||||
console.error("[BLTCY Video Models API] POST error:", error);
|
||||
return NextResponse.json(
|
||||
{ error: error.message || "Failed to add BLTCY video models" },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
550
lib/ai/bltcy-video-models.ts
Normal file
550
lib/ai/bltcy-video-models.ts
Normal file
@ -0,0 +1,550 @@
|
||||
import {
|
||||
recommendedVideoDurationOptions,
|
||||
recommendedVideoDurationReferenceCosts,
|
||||
recommendedVideoResolutionBaseCosts,
|
||||
} from "@/lib/ai/video-model-config";
|
||||
|
||||
export type BltcyVideoModelPreset = {
|
||||
modelId: string;
|
||||
displayName: string;
|
||||
description: string;
|
||||
family: string;
|
||||
supportsVision: boolean;
|
||||
supportsImageGen: boolean;
|
||||
capabilities?: Record<string, any>;
|
||||
defaultParams: Record<string, any>;
|
||||
imageGenConfig?: Record<string, any>;
|
||||
sortOrder: number;
|
||||
};
|
||||
|
||||
type BltcyVideoMode =
|
||||
| "text-to-video"
|
||||
| "first-frame"
|
||||
| "first-last-frame"
|
||||
| "reference-images";
|
||||
|
||||
type BltcyVideoResolution = "480p" | "720p" | "1080p";
|
||||
|
||||
type ModelDefinition = {
|
||||
sortOrder: number;
|
||||
modelId: string;
|
||||
displayName: string;
|
||||
family: string;
|
||||
providerName: string;
|
||||
summary: string;
|
||||
supportedModes: BltcyVideoMode[];
|
||||
supportedRatios?: string[];
|
||||
supportedResolutions?: BltcyVideoResolution[];
|
||||
durationOptions?: number[];
|
||||
priceNote?: string;
|
||||
};
|
||||
|
||||
const TRAILING_SLASHES_REGEX = /\/+$/;
|
||||
const TRAILING_V2_REGEX = /\/v2$/i;
|
||||
|
||||
export const BLTCY_VIDEO_BASE_URL = "https://api.bltcy.ai";
|
||||
export const BLTCY_VIDEO_PRICE_PAGE_URL =
|
||||
"https://api.bltcy.ai/models?category=%E8%A7%86%E9%A2%91";
|
||||
export const BLTCY_VIDEO_UNIFIED_DOC_URL =
|
||||
"https://gpt-best.apifox.cn/doc-7324259";
|
||||
export const BLTCY_VIDEO_PRICE_CHECKED_AT = "2026-04-23";
|
||||
|
||||
export const BLTCY_VIDEO_PROVIDER_PRESET = {
|
||||
name: "柏拉图统一视频",
|
||||
type: "bltcy-video" as const,
|
||||
baseUrl: BLTCY_VIDEO_BASE_URL,
|
||||
};
|
||||
|
||||
const STANDARD_VIDEO_RATIOS = ["21:9", "16:9", "4:3", "1:1", "3:4", "9:16"];
|
||||
const SEEDANCE_VIDEO_RATIOS = ["16:9", "9:16", "1:1"];
|
||||
const STANDARD_VIDEO_RESOLUTIONS: BltcyVideoResolution[] = [
|
||||
"480p",
|
||||
"720p",
|
||||
"1080p",
|
||||
];
|
||||
const HD_VIDEO_RESOLUTIONS: BltcyVideoResolution[] = ["720p", "1080p"];
|
||||
|
||||
function createModeConstraints(
|
||||
modes: BltcyVideoMode[],
|
||||
ratios: string[],
|
||||
resolutions: BltcyVideoResolution[],
|
||||
durations: number[]
|
||||
) {
|
||||
return modes.reduce<Record<string, any>>((acc, mode) => {
|
||||
acc[mode] = {
|
||||
ratioOptions: ratios,
|
||||
durationOptions: durations,
|
||||
resolutionOptions: resolutions,
|
||||
defaults: {
|
||||
mode,
|
||||
ratio: ratios.includes("16:9") ? "16:9" : ratios[0],
|
||||
duration: durations.includes(5) ? 5 : durations[0],
|
||||
resolution: resolutions.includes("720p") ? "720p" : resolutions[0],
|
||||
},
|
||||
};
|
||||
return acc;
|
||||
}, {});
|
||||
}
|
||||
|
||||
function createPreset(definition: ModelDefinition): BltcyVideoModelPreset {
|
||||
const supportedRatios =
|
||||
definition.supportedRatios && definition.supportedRatios.length > 0
|
||||
? definition.supportedRatios
|
||||
: STANDARD_VIDEO_RATIOS;
|
||||
const supportedResolutions =
|
||||
definition.supportedResolutions && definition.supportedResolutions.length > 0
|
||||
? definition.supportedResolutions
|
||||
: STANDARD_VIDEO_RESOLUTIONS;
|
||||
const durationOptions =
|
||||
definition.durationOptions && definition.durationOptions.length > 0
|
||||
? definition.durationOptions
|
||||
: [...recommendedVideoDurationOptions];
|
||||
const supportsVision = definition.supportedModes.some((mode) => mode !== "text-to-video");
|
||||
|
||||
return {
|
||||
modelId: definition.modelId,
|
||||
displayName: definition.displayName,
|
||||
description: `${definition.displayName},柏拉图统一视频接口模型(${definition.providerName} / ${definition.family})。${definition.summary}${
|
||||
definition.priceNote ? ` ${definition.priceNote}` : ""
|
||||
},实际计费以柏拉图价格页为准。`,
|
||||
family: definition.family,
|
||||
supportsVision,
|
||||
supportsImageGen: false,
|
||||
capabilities: {
|
||||
provider: "bltcy",
|
||||
family: definition.family,
|
||||
apiFormat: "bltcy-unified-video",
|
||||
supportedModes: definition.supportedModes,
|
||||
supportedRatios,
|
||||
supportedResolutions,
|
||||
durationRange: {
|
||||
min: Math.min(...durationOptions),
|
||||
max: Math.max(...durationOptions),
|
||||
},
|
||||
durationOptions,
|
||||
constraints: createModeConstraints(
|
||||
definition.supportedModes,
|
||||
supportedRatios,
|
||||
supportedResolutions,
|
||||
durationOptions
|
||||
),
|
||||
maxReferenceImages: definition.supportedModes.includes("reference-images")
|
||||
? 8
|
||||
: definition.supportedModes.includes("first-last-frame")
|
||||
? 2
|
||||
: definition.supportedModes.includes("first-frame")
|
||||
? 1
|
||||
: 0,
|
||||
supportReturnLastFrame: definition.supportedModes.includes("first-last-frame"),
|
||||
supportSeed: false,
|
||||
endpoints: {
|
||||
submit: "/v2/videos/generations",
|
||||
task: "/v2/videos/generations/{task_id}",
|
||||
},
|
||||
docs: BLTCY_VIDEO_UNIFIED_DOC_URL,
|
||||
pricePage: BLTCY_VIDEO_PRICE_PAGE_URL,
|
||||
priceCheckedAt: BLTCY_VIDEO_PRICE_CHECKED_AT,
|
||||
},
|
||||
defaultParams: {
|
||||
provider: "bltcy",
|
||||
family: definition.family,
|
||||
kind: "video",
|
||||
apiFormat: "bltcy-unified-video",
|
||||
submitEndpoint: "/v2/videos/generations",
|
||||
taskEndpoint: "/v2/videos/generations/{task_id}",
|
||||
docs: BLTCY_VIDEO_UNIFIED_DOC_URL,
|
||||
pricePage: BLTCY_VIDEO_PRICE_PAGE_URL,
|
||||
priceCheckedAt: BLTCY_VIDEO_PRICE_CHECKED_AT,
|
||||
supportedModes: definition.supportedModes,
|
||||
supportsVision,
|
||||
providerName: definition.providerName,
|
||||
},
|
||||
imageGenConfig: {
|
||||
creditCostPerVideo: recommendedVideoResolutionBaseCosts["480p"],
|
||||
videoDurationOptions: durationOptions,
|
||||
creditCostPerVideoByDuration: recommendedVideoDurationReferenceCosts,
|
||||
creditCostPerVideoByResolution: recommendedVideoResolutionBaseCosts,
|
||||
enableLoadingPlaceholder: true,
|
||||
loadingColor: "cyan",
|
||||
},
|
||||
sortOrder: definition.sortOrder,
|
||||
};
|
||||
}
|
||||
|
||||
export function normalizeBltcyVideoBaseUrl(value?: string | null) {
|
||||
const normalized = String(value || "")
|
||||
.trim()
|
||||
.replace(TRAILING_SLASHES_REGEX, "")
|
||||
.replace(TRAILING_V2_REGEX, "");
|
||||
return normalized || BLTCY_VIDEO_BASE_URL;
|
||||
}
|
||||
|
||||
export function isBltcyVideoBaseUrl(value?: string | null) {
|
||||
const normalized = normalizeBltcyVideoBaseUrl(value);
|
||||
return normalized === BLTCY_VIDEO_BASE_URL;
|
||||
}
|
||||
|
||||
const MODEL_DEFINITIONS: ModelDefinition[] = [
|
||||
{
|
||||
sortOrder: 10,
|
||||
modelId: "veo3.1",
|
||||
displayName: "Veo 3.1",
|
||||
family: "veo",
|
||||
providerName: "Google",
|
||||
summary: "支持文生视频、图生视频和多参考图输入的通用 Veo 3.1 模型。",
|
||||
supportedModes: ["text-to-video", "first-frame", "first-last-frame", "reference-images"],
|
||||
supportedRatios: STANDARD_VIDEO_RATIOS,
|
||||
supportedResolutions: HD_VIDEO_RESOLUTIONS,
|
||||
durationOptions: [4, 5, 6, 8],
|
||||
priceNote: "柏拉图当前页为按次计费模型。",
|
||||
},
|
||||
{
|
||||
sortOrder: 20,
|
||||
modelId: "veo3.1-fast",
|
||||
displayName: "Veo 3.1 Fast",
|
||||
family: "veo",
|
||||
providerName: "Google",
|
||||
summary: "偏速度优先的 Veo 3.1 Fast,适合快速出样。",
|
||||
supportedModes: ["text-to-video", "first-frame", "first-last-frame", "reference-images"],
|
||||
supportedRatios: STANDARD_VIDEO_RATIOS,
|
||||
supportedResolutions: HD_VIDEO_RESOLUTIONS,
|
||||
durationOptions: [4, 5, 6, 8],
|
||||
priceNote: "柏拉图当前页为按次计费模型。",
|
||||
},
|
||||
{
|
||||
sortOrder: 30,
|
||||
modelId: "veo3.1-pro",
|
||||
displayName: "Veo 3.1 Pro",
|
||||
family: "veo",
|
||||
providerName: "Google",
|
||||
summary: "质量优先的 Veo 3.1 Pro。",
|
||||
supportedModes: ["text-to-video", "first-frame", "first-last-frame", "reference-images"],
|
||||
supportedRatios: STANDARD_VIDEO_RATIOS,
|
||||
supportedResolutions: HD_VIDEO_RESOLUTIONS,
|
||||
durationOptions: [4, 5, 6, 8],
|
||||
priceNote: "柏拉图当前页为按次计费模型。",
|
||||
},
|
||||
{
|
||||
sortOrder: 40,
|
||||
modelId: "veo3.1-pro-4k",
|
||||
displayName: "Veo 3.1 Pro 4K",
|
||||
family: "veo",
|
||||
providerName: "Google",
|
||||
summary: "4K 版本 Veo 3.1 Pro,当前项目侧仍沿用统一分辨率控件。",
|
||||
supportedModes: ["text-to-video", "first-frame", "first-last-frame", "reference-images"],
|
||||
supportedRatios: STANDARD_VIDEO_RATIOS,
|
||||
supportedResolutions: ["1080p"],
|
||||
durationOptions: [4, 5, 6, 8],
|
||||
priceNote: "柏拉图当前页为按次计费模型。",
|
||||
},
|
||||
{
|
||||
sortOrder: 50,
|
||||
modelId: "veo3.1-components",
|
||||
displayName: "Veo 3.1 Components",
|
||||
family: "veo",
|
||||
providerName: "Google",
|
||||
summary: "组件化 Veo 3.1 版本,适合多参考图驱动。",
|
||||
supportedModes: ["text-to-video", "first-frame", "first-last-frame", "reference-images"],
|
||||
supportedRatios: STANDARD_VIDEO_RATIOS,
|
||||
supportedResolutions: HD_VIDEO_RESOLUTIONS,
|
||||
durationOptions: [4, 5, 6, 8],
|
||||
priceNote: "柏拉图当前页为按次计费模型。",
|
||||
},
|
||||
{
|
||||
sortOrder: 100,
|
||||
modelId: "doubao-seedance-1-0-lite-t2v-250428",
|
||||
displayName: "Seedance 1.0 Lite T2V",
|
||||
family: "seedance",
|
||||
providerName: "火山豆包",
|
||||
summary: "轻量文生视频模型。",
|
||||
supportedModes: ["text-to-video"],
|
||||
supportedRatios: SEEDANCE_VIDEO_RATIOS,
|
||||
supportedResolutions: STANDARD_VIDEO_RESOLUTIONS,
|
||||
durationOptions: [5, 10],
|
||||
priceNote: "柏拉图当前页为阶梯/按秒计费。",
|
||||
},
|
||||
{
|
||||
sortOrder: 110,
|
||||
modelId: "doubao-seedance-1-0-lite-i2v-250428",
|
||||
displayName: "Seedance 1.0 Lite I2V",
|
||||
family: "seedance",
|
||||
providerName: "火山豆包",
|
||||
summary: "轻量图生视频模型。",
|
||||
supportedModes: ["first-frame"],
|
||||
supportedRatios: SEEDANCE_VIDEO_RATIOS,
|
||||
supportedResolutions: STANDARD_VIDEO_RESOLUTIONS,
|
||||
durationOptions: [5, 10],
|
||||
priceNote: "柏拉图当前页为阶梯/按秒计费。",
|
||||
},
|
||||
{
|
||||
sortOrder: 120,
|
||||
modelId: "doubao-seedance-1-0-pro-250528",
|
||||
displayName: "Seedance 1.0 Pro",
|
||||
family: "seedance",
|
||||
providerName: "火山豆包",
|
||||
summary: "Seedance Pro 通用视频模型。",
|
||||
supportedModes: ["text-to-video", "first-frame", "first-last-frame", "reference-images"],
|
||||
supportedRatios: SEEDANCE_VIDEO_RATIOS,
|
||||
supportedResolutions: STANDARD_VIDEO_RESOLUTIONS,
|
||||
durationOptions: [5, 10],
|
||||
priceNote: "柏拉图当前页为阶梯/按秒计费。",
|
||||
},
|
||||
{
|
||||
sortOrder: 130,
|
||||
modelId: "doubao-seedance-1-0-pro-fast-251015",
|
||||
displayName: "Seedance 1.0 Pro Fast",
|
||||
family: "seedance",
|
||||
providerName: "火山豆包",
|
||||
summary: "速度优先的 Seedance Pro Fast。",
|
||||
supportedModes: ["text-to-video", "first-frame", "first-last-frame", "reference-images"],
|
||||
supportedRatios: SEEDANCE_VIDEO_RATIOS,
|
||||
supportedResolutions: STANDARD_VIDEO_RESOLUTIONS,
|
||||
durationOptions: [5, 10],
|
||||
priceNote: "柏拉图当前页为阶梯/按秒计费。",
|
||||
},
|
||||
{
|
||||
sortOrder: 140,
|
||||
modelId: "doubao-seedance-1-5-pro-251215",
|
||||
displayName: "Seedance 1.5 Pro",
|
||||
family: "seedance",
|
||||
providerName: "火山豆包",
|
||||
summary: "更高质量的 Seedance 1.5 Pro。",
|
||||
supportedModes: ["text-to-video", "first-frame", "first-last-frame", "reference-images"],
|
||||
supportedRatios: SEEDANCE_VIDEO_RATIOS,
|
||||
supportedResolutions: STANDARD_VIDEO_RESOLUTIONS,
|
||||
durationOptions: [5, 10],
|
||||
priceNote: "柏拉图当前页为阶梯/按秒计费。",
|
||||
},
|
||||
{
|
||||
sortOrder: 150,
|
||||
modelId: "doubao-seedance-2-0-260128",
|
||||
displayName: "Seedance 2.0",
|
||||
family: "seedance",
|
||||
providerName: "火山豆包",
|
||||
summary: "Seedance 2.0 通用视频模型。",
|
||||
supportedModes: ["text-to-video", "first-frame", "first-last-frame", "reference-images"],
|
||||
supportedRatios: SEEDANCE_VIDEO_RATIOS,
|
||||
supportedResolutions: STANDARD_VIDEO_RESOLUTIONS,
|
||||
durationOptions: [5, 10],
|
||||
priceNote: "柏拉图当前页为按秒计费。",
|
||||
},
|
||||
{
|
||||
sortOrder: 160,
|
||||
modelId: "doubao-seedance-2-0-fast-260128",
|
||||
displayName: "Seedance 2.0 Fast",
|
||||
family: "seedance",
|
||||
providerName: "火山豆包",
|
||||
summary: "速度优先的 Seedance 2.0 Fast。",
|
||||
supportedModes: ["text-to-video", "first-frame", "first-last-frame", "reference-images"],
|
||||
supportedRatios: SEEDANCE_VIDEO_RATIOS,
|
||||
supportedResolutions: STANDARD_VIDEO_RESOLUTIONS,
|
||||
durationOptions: [5, 10],
|
||||
priceNote: "柏拉图当前页为按秒计费。",
|
||||
},
|
||||
{
|
||||
sortOrder: 200,
|
||||
modelId: "wanx2.1-t2v-plus",
|
||||
displayName: "WanX 2.1 T2V Plus",
|
||||
family: "wan",
|
||||
providerName: "Aliyun(Qwen)",
|
||||
summary: "万相 2.1 文生视频模型。",
|
||||
supportedModes: ["text-to-video"],
|
||||
durationOptions: [5, 10],
|
||||
priceNote: "柏拉图当前页为按次计费。",
|
||||
},
|
||||
{
|
||||
sortOrder: 210,
|
||||
modelId: "wanx2.1-t2v-turbo",
|
||||
displayName: "WanX 2.1 T2V Turbo",
|
||||
family: "wan",
|
||||
providerName: "Aliyun(Qwen)",
|
||||
summary: "万相 2.1 文生视频极速版。",
|
||||
supportedModes: ["text-to-video"],
|
||||
durationOptions: [5, 10],
|
||||
priceNote: "柏拉图当前页为按次计费。",
|
||||
},
|
||||
{
|
||||
sortOrder: 220,
|
||||
modelId: "wanx2.1-i2v-plus",
|
||||
displayName: "WanX 2.1 I2V Plus",
|
||||
family: "wan",
|
||||
providerName: "Aliyun(Qwen)",
|
||||
summary: "万相 2.1 图生视频模型。",
|
||||
supportedModes: ["first-frame"],
|
||||
durationOptions: [5, 10],
|
||||
priceNote: "柏拉图当前页为按次计费。",
|
||||
},
|
||||
{
|
||||
sortOrder: 230,
|
||||
modelId: "wanx2.1-i2v-turbo",
|
||||
displayName: "WanX 2.1 I2V Turbo",
|
||||
family: "wan",
|
||||
providerName: "Aliyun(Qwen)",
|
||||
summary: "万相 2.1 图生视频极速版。",
|
||||
supportedModes: ["first-frame"],
|
||||
durationOptions: [5, 10],
|
||||
priceNote: "柏拉图当前页为按次计费。",
|
||||
},
|
||||
{
|
||||
sortOrder: 240,
|
||||
modelId: "wanx2.1-kf2v-plus",
|
||||
displayName: "WanX 2.1 KF2V Plus",
|
||||
family: "wan",
|
||||
providerName: "Aliyun(Qwen)",
|
||||
summary: "万相 2.1 关键帧视频模型,适合多图/关键帧驱动。",
|
||||
supportedModes: ["first-last-frame", "reference-images", "first-frame"],
|
||||
durationOptions: [5, 10],
|
||||
priceNote: "柏拉图当前页为按次计费。",
|
||||
},
|
||||
{
|
||||
sortOrder: 250,
|
||||
modelId: "wanx2.1-vace-plus",
|
||||
displayName: "WanX 2.1 VACE Plus",
|
||||
family: "wan",
|
||||
providerName: "Aliyun(Qwen)",
|
||||
summary: "万相 2.1 视频编辑类模型,可结合参考图/视频使用。",
|
||||
supportedModes: ["reference-images", "first-frame", "text-to-video"],
|
||||
durationOptions: [5, 10],
|
||||
priceNote: "柏拉图当前页为按次计费。",
|
||||
},
|
||||
{
|
||||
sortOrder: 260,
|
||||
modelId: "wan2.2-t2v-plus",
|
||||
displayName: "Wan 2.2 T2V Plus",
|
||||
family: "wan",
|
||||
providerName: "Aliyun(Qwen)",
|
||||
summary: "万相 2.2 文生视频模型。",
|
||||
supportedModes: ["text-to-video"],
|
||||
durationOptions: [5, 10],
|
||||
priceNote: "柏拉图当前页为阶梯/按次计费。",
|
||||
},
|
||||
{
|
||||
sortOrder: 270,
|
||||
modelId: "wan2.2-i2v-plus",
|
||||
displayName: "Wan 2.2 I2V Plus",
|
||||
family: "wan",
|
||||
providerName: "Aliyun(Qwen)",
|
||||
summary: "万相 2.2 图生视频模型。",
|
||||
supportedModes: ["first-frame"],
|
||||
durationOptions: [5, 10],
|
||||
priceNote: "柏拉图当前页为阶梯/按次计费。",
|
||||
},
|
||||
{
|
||||
sortOrder: 280,
|
||||
modelId: "wan2.2-i2v-flash",
|
||||
displayName: "Wan 2.2 I2V Flash",
|
||||
family: "wan",
|
||||
providerName: "Aliyun(Qwen)",
|
||||
summary: "万相 2.2 图生视频极速版。",
|
||||
supportedModes: ["first-frame"],
|
||||
durationOptions: [5, 10],
|
||||
priceNote: "柏拉图当前页为阶梯/按次计费。",
|
||||
},
|
||||
{
|
||||
sortOrder: 290,
|
||||
modelId: "wan2.2-kf2v-flash",
|
||||
displayName: "Wan 2.2 KF2V Flash",
|
||||
family: "wan",
|
||||
providerName: "Aliyun(Qwen)",
|
||||
summary: "万相 2.2 关键帧视频极速版。",
|
||||
supportedModes: ["first-last-frame", "reference-images", "first-frame"],
|
||||
durationOptions: [5, 10],
|
||||
priceNote: "柏拉图当前页为阶梯/按次计费。",
|
||||
},
|
||||
{
|
||||
sortOrder: 300,
|
||||
modelId: "wan2.2-s2v",
|
||||
displayName: "Wan 2.2 S2V",
|
||||
family: "wan",
|
||||
providerName: "Aliyun(Qwen)",
|
||||
summary: "万相 2.2 视频生成模型。",
|
||||
supportedModes: ["text-to-video", "first-frame", "reference-images"],
|
||||
durationOptions: [5, 10],
|
||||
priceNote: "柏拉图当前页为按次计费。",
|
||||
},
|
||||
{
|
||||
sortOrder: 310,
|
||||
modelId: "wan2.2-animate-mix",
|
||||
displayName: "Wan 2.2 Animate Mix",
|
||||
family: "wan",
|
||||
providerName: "Aliyun(Qwen)",
|
||||
summary: "万相 2.2 动画类视频模型。",
|
||||
supportedModes: ["reference-images", "first-frame", "text-to-video"],
|
||||
durationOptions: [5, 10],
|
||||
priceNote: "柏拉图当前页为按次计费。",
|
||||
},
|
||||
{
|
||||
sortOrder: 320,
|
||||
modelId: "wan2.2-animate-move",
|
||||
displayName: "Wan 2.2 Animate Move",
|
||||
family: "wan",
|
||||
providerName: "Aliyun(Qwen)",
|
||||
summary: "万相 2.2 动画运动类视频模型。",
|
||||
supportedModes: ["reference-images", "first-frame", "text-to-video"],
|
||||
durationOptions: [5, 10],
|
||||
priceNote: "柏拉图当前页为阶梯/按次计费。",
|
||||
},
|
||||
{
|
||||
sortOrder: 330,
|
||||
modelId: "wan2.5-t2v-preview",
|
||||
displayName: "Wan 2.5 T2V Preview",
|
||||
family: "wan",
|
||||
providerName: "Aliyun(Qwen)",
|
||||
summary: "万相 2.5 文生视频预览版。",
|
||||
supportedModes: ["text-to-video"],
|
||||
supportedResolutions: HD_VIDEO_RESOLUTIONS,
|
||||
durationOptions: [5, 10],
|
||||
priceNote: "柏拉图当前页为阶梯/按次计费。",
|
||||
},
|
||||
{
|
||||
sortOrder: 340,
|
||||
modelId: "wan2.5-i2v-preview",
|
||||
displayName: "Wan 2.5 I2V Preview",
|
||||
family: "wan",
|
||||
providerName: "Aliyun(Qwen)",
|
||||
summary: "万相 2.5 图生视频预览版。",
|
||||
supportedModes: ["first-frame"],
|
||||
supportedResolutions: HD_VIDEO_RESOLUTIONS,
|
||||
durationOptions: [5, 10],
|
||||
priceNote: "柏拉图当前页为阶梯/按次计费。",
|
||||
},
|
||||
{
|
||||
sortOrder: 350,
|
||||
modelId: "wan2.6-t2v",
|
||||
displayName: "Wan 2.6 T2V",
|
||||
family: "wan",
|
||||
providerName: "Aliyun(Qwen)",
|
||||
summary: "万相 2.6 文生视频模型。",
|
||||
supportedModes: ["text-to-video"],
|
||||
supportedResolutions: HD_VIDEO_RESOLUTIONS,
|
||||
durationOptions: [5, 10, 15],
|
||||
priceNote: "柏拉图当前页为按次计费。",
|
||||
},
|
||||
{
|
||||
sortOrder: 360,
|
||||
modelId: "wan2.6-i2v",
|
||||
displayName: "Wan 2.6 I2V",
|
||||
family: "wan",
|
||||
providerName: "Aliyun(Qwen)",
|
||||
summary: "万相 2.6 图生视频模型。",
|
||||
supportedModes: ["first-frame"],
|
||||
supportedResolutions: HD_VIDEO_RESOLUTIONS,
|
||||
durationOptions: [5, 10, 15],
|
||||
priceNote: "柏拉图当前页为按次计费。",
|
||||
},
|
||||
{
|
||||
sortOrder: 370,
|
||||
modelId: "wan2.6-r2v",
|
||||
displayName: "Wan 2.6 R2V",
|
||||
family: "wan",
|
||||
providerName: "Aliyun(Qwen)",
|
||||
summary: "万相 2.6 参考驱动视频模型。",
|
||||
supportedModes: ["reference-images", "first-frame"],
|
||||
supportedResolutions: HD_VIDEO_RESOLUTIONS,
|
||||
durationOptions: [5, 10, 15],
|
||||
priceNote: "柏拉图当前页为按次计费。",
|
||||
},
|
||||
];
|
||||
|
||||
export const BLTCY_VIDEO_MODEL_PRESETS: BltcyVideoModelPreset[] =
|
||||
MODEL_DEFINITIONS.map(createPreset);
|
||||
@ -28,11 +28,13 @@ import { createRuxaVideo } from "./providers/ruxa-video";
|
||||
import { createRunway } from "./providers/runway";
|
||||
import { createKrea } from "./providers/krea";
|
||||
import { createVolcanoMultimodal } from "./providers/volcano-multimodal";
|
||||
import { createBltcyVideo } from "./providers/bltcy-video";
|
||||
import { getProviderDefaultBaseUrl } from "./provider-defaults";
|
||||
import {
|
||||
createMoonshotKimiFetch,
|
||||
isMoonshotOfficialBaseUrl,
|
||||
} from "./detail-agent-kimi-profile";
|
||||
import { isBltcyVideoBaseUrl } from "./bltcy-video-models";
|
||||
|
||||
// 缓存已创建的 provider 实例
|
||||
const providerCache = new Map<string, any>();
|
||||
@ -287,6 +289,14 @@ function getOrCreateProvider(provider: Pick<AiProvider, "id" | "type" | "baseUrl
|
||||
|
||||
case "custom":
|
||||
default: {
|
||||
if (isBltcyVideoBaseUrl(resolvedBaseUrl)) {
|
||||
const bltcyVideoProvider = createBltcyVideo({
|
||||
baseURL: resolvedBaseUrl,
|
||||
apiKey: provider.apiKey,
|
||||
});
|
||||
console.log(`[Provider] Created BLTCY unified video provider: ${resolvedBaseUrl}`);
|
||||
return bltcyVideoProvider as any;
|
||||
}
|
||||
if (isVolcanoMultimodalBaseUrl(resolvedBaseUrl)) {
|
||||
const volcanoMultimodalProvider = createVolcanoMultimodal({
|
||||
baseURL: resolvedBaseUrl,
|
||||
|
||||
@ -1,6 +1,7 @@
|
||||
import { inferKreaModelKind } from "@/lib/ai/krea-models";
|
||||
|
||||
const STATIC_VIDEO_PROVIDER_TYPES = new Set([
|
||||
"bltcy-video",
|
||||
"doubao-video",
|
||||
"volcano-video",
|
||||
"qianwen-video",
|
||||
|
||||
@ -177,7 +177,7 @@ const INTENT_MODEL_MAP: Record<
|
||||
|
||||
// 视频生成
|
||||
video_gen: {
|
||||
providerTypes: ["doubao-video", "volcano-video", "qianwen-video", "fal-video", "ruxa-video", "runway", "krea"],
|
||||
providerTypes: ["doubao-video", "volcano-video", "qianwen-video", "fal-video", "ruxa-video", "runway", "krea", "custom"],
|
||||
description: "视频生成",
|
||||
},
|
||||
|
||||
|
||||
530
lib/ai/providers/bltcy-video.ts
Normal file
530
lib/ai/providers/bltcy-video.ts
Normal file
@ -0,0 +1,530 @@
|
||||
import "server-only";
|
||||
|
||||
import { uploadFile } from "@/lib/storage/service";
|
||||
import { normalizeBltcyVideoBaseUrl } from "@/lib/ai/bltcy-video-models";
|
||||
|
||||
interface BltcyVideoConfig {
|
||||
apiKey: string;
|
||||
baseURL: string;
|
||||
}
|
||||
|
||||
interface BltcyVideoParams {
|
||||
mode?: "text-to-video" | "first-frame" | "first-last-frame" | "reference-images";
|
||||
ratio?: string;
|
||||
duration?: number;
|
||||
resolution?: string;
|
||||
}
|
||||
|
||||
const POLL_INTERVAL_MS = 3000;
|
||||
const POLL_MAX_ATTEMPTS = 240;
|
||||
const DEFAULT_RATIO = "16:9";
|
||||
const DEFAULT_DURATION = 5;
|
||||
|
||||
function normalizeText(value?: string | null) {
|
||||
return String(value || "")
|
||||
.replace(/\u00a0/g, " ")
|
||||
.trim()
|
||||
.normalize("NFKC");
|
||||
}
|
||||
|
||||
function normalizeApiKey(value: string) {
|
||||
const normalized = normalizeText(value)
|
||||
.replace(/^bearer\s+/i, "")
|
||||
.replace(/^key\s+/i, "")
|
||||
.replace(/^(?:bltcy[_-]?api[_-]?key|api[_-]?key|token)\s*[::]\s*/i, "");
|
||||
return normalized.replace(/[^\x20-\x7E]/g, "");
|
||||
}
|
||||
|
||||
function joinApiPath(baseURL: string, path: string) {
|
||||
const cleanBase = normalizeBltcyVideoBaseUrl(baseURL).replace(/\/+$/, "");
|
||||
const cleanPath = path.startsWith("/") ? path : `/${path}`;
|
||||
return `${cleanBase}${cleanPath}`;
|
||||
}
|
||||
|
||||
function normalizeRatio(value?: string) {
|
||||
const normalized = normalizeText(value);
|
||||
if (
|
||||
normalized === "21:9" ||
|
||||
normalized === "16:9" ||
|
||||
normalized === "4:3" ||
|
||||
normalized === "1:1" ||
|
||||
normalized === "3:4" ||
|
||||
normalized === "9:16"
|
||||
) {
|
||||
return normalized;
|
||||
}
|
||||
return DEFAULT_RATIO;
|
||||
}
|
||||
|
||||
function normalizeDuration(value?: number) {
|
||||
if (!Number.isFinite(value)) return DEFAULT_DURATION;
|
||||
return Math.max(3, Math.min(20, Math.round(Number(value))));
|
||||
}
|
||||
|
||||
function normalizeResolution(value?: string) {
|
||||
const normalized = normalizeText(value).toLowerCase();
|
||||
if (normalized === "480p") return "480P";
|
||||
if (normalized === "720p") return "720P";
|
||||
if (normalized === "1080p") return "1080P";
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function detectExtensionFromContentType(contentType?: string | null) {
|
||||
const normalized = String(contentType || "").toLowerCase();
|
||||
if (normalized.includes("jpeg") || normalized.includes("jpg")) return "jpg";
|
||||
if (normalized.includes("webp")) return "webp";
|
||||
if (normalized.includes("gif")) return "gif";
|
||||
if (normalized.includes("png")) return "png";
|
||||
if (normalized.includes("mp4")) return "mp4";
|
||||
if (normalized.includes("quicktime")) return "mov";
|
||||
if (normalized.includes("webm")) return "webm";
|
||||
return "bin";
|
||||
}
|
||||
|
||||
function generateFilename(prefix: string, extension: string) {
|
||||
const timestamp = Date.now();
|
||||
const random = Math.random().toString(36).slice(2, 8);
|
||||
return `${prefix}-${timestamp}-${random}.${extension}`;
|
||||
}
|
||||
|
||||
function extractDataUriParts(dataUri: string) {
|
||||
const match = dataUri.match(/^data:([^;]+);base64,(.+)$/);
|
||||
if (!match) {
|
||||
throw new Error("不支持的 Data URI 格式");
|
||||
}
|
||||
return {
|
||||
mimeType: match[1],
|
||||
buffer: Buffer.from(match[2], "base64"),
|
||||
};
|
||||
}
|
||||
|
||||
async function uploadDataUriAsset(dataUri: string, prefix: "bltcy-input" | "bltcy-video") {
|
||||
const { mimeType, buffer } = extractDataUriParts(dataUri);
|
||||
const extension = detectExtensionFromContentType(mimeType);
|
||||
const arrayBuffer = buffer.buffer.slice(
|
||||
buffer.byteOffset,
|
||||
buffer.byteOffset + buffer.byteLength
|
||||
) as ArrayBuffer;
|
||||
const uploaded = await uploadFile(
|
||||
generateFilename(prefix, extension === "bin" ? (prefix === "bltcy-video" ? "mp4" : "png") : extension),
|
||||
arrayBuffer
|
||||
);
|
||||
return uploaded.url;
|
||||
}
|
||||
|
||||
function isLikelyVideoUrl(url: string, mediaType?: string | null) {
|
||||
const normalizedMediaType = String(mediaType || "").toLowerCase();
|
||||
if (normalizedMediaType.startsWith("video/")) return true;
|
||||
return /\.(mp4|mov|webm|m4v)(\?|#|$)/i.test(url) || /^data:video\//i.test(url);
|
||||
}
|
||||
|
||||
async function normalizeAssetUrl(url?: string | null, kind?: "image" | "video") {
|
||||
const normalized = normalizeText(url);
|
||||
if (!normalized) return "";
|
||||
if (/^https?:\/\//i.test(normalized)) return normalized;
|
||||
if (/^data:image\//i.test(normalized)) {
|
||||
return uploadDataUriAsset(normalized, "bltcy-input");
|
||||
}
|
||||
if (/^data:video\//i.test(normalized)) {
|
||||
return uploadDataUriAsset(normalized, "bltcy-video");
|
||||
}
|
||||
if (kind === "video") {
|
||||
throw new Error("柏拉图统一视频的参考视频需要可访问的 URL");
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
|
||||
function extractPromptAndAssets(messages: Array<{ role: string; content: any }>) {
|
||||
const lastUserMessage = messages.filter((message) => message.role === "user").pop();
|
||||
if (!lastUserMessage) {
|
||||
throw new Error("未找到用户输入内容");
|
||||
}
|
||||
|
||||
let prompt = "";
|
||||
const imageUrls: string[] = [];
|
||||
const videoUrls: string[] = [];
|
||||
|
||||
const pushAsset = (value?: string, mediaType?: string | null) => {
|
||||
if (!value) return;
|
||||
const normalized = String(value || "").trim();
|
||||
if (!normalized) return;
|
||||
if (isLikelyVideoUrl(normalized, mediaType)) {
|
||||
if (!videoUrls.includes(normalized)) {
|
||||
videoUrls.push(normalized);
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (!imageUrls.includes(normalized)) {
|
||||
imageUrls.push(normalized);
|
||||
}
|
||||
};
|
||||
|
||||
const readPart = (part: any) => {
|
||||
if (!part) return;
|
||||
if (part.type === "text" && typeof part.text === "string") {
|
||||
prompt = `${prompt} ${part.text}`.trim();
|
||||
return;
|
||||
}
|
||||
if (part.type === "image_url" && part.image_url?.url) {
|
||||
pushAsset(part.image_url.url, "image/jpeg");
|
||||
return;
|
||||
}
|
||||
if (part.type === "file") {
|
||||
pushAsset(part.url || part.data, part.mediaType || part.mimeType);
|
||||
return;
|
||||
}
|
||||
if (part.type === "image") {
|
||||
pushAsset(part.image || part.url || part.data, part.mediaType || "image/jpeg");
|
||||
return;
|
||||
}
|
||||
if (part.type === "video") {
|
||||
pushAsset(part.video || part.url || part.data, part.mediaType || "video/mp4");
|
||||
}
|
||||
};
|
||||
|
||||
if (typeof lastUserMessage.content === "string") {
|
||||
prompt = normalizeText(lastUserMessage.content);
|
||||
} else if (Array.isArray(lastUserMessage.content)) {
|
||||
for (const part of lastUserMessage.content) {
|
||||
readPart(part);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
prompt: normalizeText(prompt) || "生成视频",
|
||||
imageUrls,
|
||||
videoUrls,
|
||||
};
|
||||
}
|
||||
|
||||
function extractTaskId(payload: any): string | undefined {
|
||||
const candidates = [
|
||||
payload?.task_id,
|
||||
payload?.taskId,
|
||||
payload?.id,
|
||||
payload?.data?.task_id,
|
||||
payload?.data?.taskId,
|
||||
payload?.data?.id,
|
||||
];
|
||||
return candidates.find((value) => typeof value === "string" && value.trim())?.trim();
|
||||
}
|
||||
|
||||
function extractStatus(payload: any): string | undefined {
|
||||
const raw =
|
||||
payload?.status ||
|
||||
payload?.task_status ||
|
||||
payload?.taskStatus ||
|
||||
payload?.state ||
|
||||
payload?.data?.status ||
|
||||
payload?.data?.task_status ||
|
||||
payload?.data?.taskStatus ||
|
||||
payload?.data?.state;
|
||||
if (typeof raw !== "string") return undefined;
|
||||
return raw.trim().toLowerCase();
|
||||
}
|
||||
|
||||
function extractErrorMessage(payload: any): string {
|
||||
return (
|
||||
payload?.fail_reason ||
|
||||
payload?.message ||
|
||||
payload?.error ||
|
||||
payload?.data?.fail_reason ||
|
||||
payload?.data?.message ||
|
||||
payload?.data?.error ||
|
||||
"柏拉图视频任务执行失败"
|
||||
);
|
||||
}
|
||||
|
||||
function collectHttpUrls(payload: unknown): string[] {
|
||||
const urls = new Set<string>();
|
||||
const visit = (value: unknown) => {
|
||||
if (typeof value === "string") {
|
||||
const normalized = value.trim();
|
||||
if (/^https?:\/\//i.test(normalized)) {
|
||||
urls.add(normalized);
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (Array.isArray(value)) {
|
||||
value.forEach(visit);
|
||||
return;
|
||||
}
|
||||
if (value && typeof value === "object") {
|
||||
Object.values(value as Record<string, unknown>).forEach(visit);
|
||||
}
|
||||
};
|
||||
visit(payload);
|
||||
return Array.from(urls);
|
||||
}
|
||||
|
||||
async function readJsonOrText(response: Response) {
|
||||
const rawText = await response.text();
|
||||
if (!rawText) return null;
|
||||
try {
|
||||
return JSON.parse(rawText);
|
||||
} catch {
|
||||
return rawText;
|
||||
}
|
||||
}
|
||||
|
||||
async function downloadRemoteAsset(url: string) {
|
||||
const controller = new AbortController();
|
||||
const timeoutId = setTimeout(() => controller.abort(), 120000);
|
||||
|
||||
try {
|
||||
const response = await fetch(url, {
|
||||
cache: "no-store",
|
||||
signal: controller.signal,
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw new Error(`下载柏拉图视频输出失败: ${response.status}`);
|
||||
}
|
||||
const buffer = Buffer.from(await response.arrayBuffer());
|
||||
return {
|
||||
buffer,
|
||||
contentType: response.headers.get("content-type"),
|
||||
};
|
||||
} finally {
|
||||
clearTimeout(timeoutId);
|
||||
}
|
||||
}
|
||||
|
||||
async function uploadVideoToStorage(remoteVideoUrl: string) {
|
||||
try {
|
||||
const { buffer, contentType } = await downloadRemoteAsset(remoteVideoUrl);
|
||||
const extension = detectExtensionFromContentType(contentType || "video/mp4");
|
||||
const arrayBuffer = buffer.buffer.slice(
|
||||
buffer.byteOffset,
|
||||
buffer.byteOffset + buffer.byteLength
|
||||
) as ArrayBuffer;
|
||||
const uploaded = await uploadFile(
|
||||
generateFilename("bltcy-video", extension === "bin" ? "mp4" : extension),
|
||||
arrayBuffer
|
||||
);
|
||||
return uploaded.url;
|
||||
} catch (error) {
|
||||
console.warn("[BLTCY Video] Failed to archive video output, using temporary URL:", error);
|
||||
return remoteVideoUrl;
|
||||
}
|
||||
}
|
||||
|
||||
function formatVideoMarkdown(videoUrl: string) {
|
||||
return `<video src="${videoUrl}" controls style="max-width: 100%; border-radius: 8px; margin-bottom: 12px;"></video>\n\n[下载视频](${videoUrl})`;
|
||||
}
|
||||
|
||||
async function waitForVideoUrl(baseURL: string, apiKey: string, taskId: string) {
|
||||
const endpoint = joinApiPath(baseURL, `/v2/videos/generations/${encodeURIComponent(taskId)}`);
|
||||
|
||||
for (let attempt = 1; attempt <= POLL_MAX_ATTEMPTS; attempt++) {
|
||||
const response = await fetch(endpoint, {
|
||||
headers: {
|
||||
Authorization: `Bearer ${apiKey}`,
|
||||
Accept: "application/json",
|
||||
},
|
||||
cache: "no-store",
|
||||
});
|
||||
|
||||
const payload = await readJsonOrText(response);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(
|
||||
`${extractErrorMessage(payload)} (HTTP ${response.status})`
|
||||
);
|
||||
}
|
||||
|
||||
const status = extractStatus(payload);
|
||||
const urls = collectHttpUrls(payload);
|
||||
const remoteVideoUrl =
|
||||
urls.find((url) => /\.(mp4|mov|webm|m4v)(\?|#|$)/i.test(url) || /video/i.test(url)) ||
|
||||
urls[0];
|
||||
|
||||
if (remoteVideoUrl && (!status || ["success", "succeeded", "completed", "done", "finished"].includes(status))) {
|
||||
return uploadVideoToStorage(remoteVideoUrl);
|
||||
}
|
||||
|
||||
if (status && ["failed", "error", "cancelled", "canceled"].includes(status)) {
|
||||
throw new Error(extractErrorMessage(payload));
|
||||
}
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, POLL_INTERVAL_MS));
|
||||
}
|
||||
|
||||
throw new Error("柏拉图视频任务超时,请稍后重试");
|
||||
}
|
||||
|
||||
function buildSubmitBody(params: {
|
||||
modelId: string;
|
||||
prompt: string;
|
||||
imageUrls: string[];
|
||||
videoUrls: string[];
|
||||
videoParams?: BltcyVideoParams;
|
||||
}) {
|
||||
const { modelId, prompt, imageUrls, videoUrls, videoParams } = params;
|
||||
const mode = videoParams?.mode || (imageUrls[0] || videoUrls[0] ? "first-frame" : "text-to-video");
|
||||
const images =
|
||||
mode === "first-frame"
|
||||
? imageUrls.slice(0, 1)
|
||||
: mode === "first-last-frame"
|
||||
? imageUrls.slice(0, 2)
|
||||
: mode === "reference-images"
|
||||
? imageUrls.slice(0, 8)
|
||||
: [];
|
||||
const videos = videoUrls.slice(0, 2);
|
||||
|
||||
if (mode === "first-frame" && images.length < 1 && videos.length < 1) {
|
||||
throw new Error("柏拉图图生视频至少需要 1 张参考图或 1 段参考视频");
|
||||
}
|
||||
if (mode === "first-last-frame" && images.length < 2) {
|
||||
throw new Error("柏拉图首尾帧视频至少需要 2 张图片");
|
||||
}
|
||||
if (mode === "reference-images" && images.length < 1 && videos.length < 1) {
|
||||
throw new Error("柏拉图多参考视频至少需要 1 张图片或 1 段视频");
|
||||
}
|
||||
|
||||
const body: Record<string, any> = {
|
||||
model: normalizeText(modelId),
|
||||
prompt: normalizeText(prompt) || "生成视频",
|
||||
duration: normalizeDuration(videoParams?.duration),
|
||||
aspect_ratio: normalizeRatio(videoParams?.ratio),
|
||||
watermark: false,
|
||||
};
|
||||
|
||||
const resolution = normalizeResolution(videoParams?.resolution);
|
||||
if (resolution) {
|
||||
body.resolution = resolution;
|
||||
}
|
||||
|
||||
if (images.length > 0) {
|
||||
body.images = images;
|
||||
}
|
||||
|
||||
if (videos.length > 0) {
|
||||
body.videos = videos;
|
||||
}
|
||||
|
||||
return body;
|
||||
}
|
||||
|
||||
export function createBltcyVideo({ apiKey, baseURL }: BltcyVideoConfig) {
|
||||
return {
|
||||
provider: "bltcy-video",
|
||||
chat: (modelId: string) => ({
|
||||
provider: "bltcy-video",
|
||||
doGenerate: async (options: {
|
||||
messages: Array<{ role: string; content: any }>;
|
||||
videoParams?: BltcyVideoParams;
|
||||
overrideModelId?: string;
|
||||
}) => {
|
||||
try {
|
||||
const credentials = normalizeApiKey(apiKey);
|
||||
if (!credentials) {
|
||||
throw new Error("柏拉图 API Key 未配置或格式无效");
|
||||
}
|
||||
|
||||
const activeModelId = normalizeText(options.overrideModelId || modelId);
|
||||
const { prompt, imageUrls, videoUrls } = extractPromptAndAssets(options.messages);
|
||||
const normalizedImageUrls = (
|
||||
await Promise.all(imageUrls.map((url) => normalizeAssetUrl(url, "image")))
|
||||
).filter(Boolean);
|
||||
const normalizedVideoUrls = (
|
||||
await Promise.all(videoUrls.map((url) => normalizeAssetUrl(url, "video")))
|
||||
).filter(Boolean);
|
||||
const requestBody = buildSubmitBody({
|
||||
modelId: activeModelId,
|
||||
prompt,
|
||||
imageUrls: normalizedImageUrls,
|
||||
videoUrls: normalizedVideoUrls,
|
||||
videoParams: options.videoParams,
|
||||
});
|
||||
|
||||
console.log("[BLTCY Video] Submitting task:", {
|
||||
model: activeModelId,
|
||||
baseURL: normalizeBltcyVideoBaseUrl(baseURL),
|
||||
hasImages: Array.isArray(requestBody.images) ? requestBody.images.length : 0,
|
||||
hasVideos: Array.isArray(requestBody.videos) ? requestBody.videos.length : 0,
|
||||
duration: requestBody.duration,
|
||||
aspectRatio: requestBody.aspect_ratio,
|
||||
resolution: requestBody.resolution || null,
|
||||
});
|
||||
|
||||
const submitResponse = await fetch(joinApiPath(baseURL, "/v2/videos/generations"), {
|
||||
method: "POST",
|
||||
headers: {
|
||||
Authorization: `Bearer ${credentials}`,
|
||||
"Content-Type": "application/json",
|
||||
Accept: "application/json",
|
||||
},
|
||||
body: JSON.stringify(requestBody),
|
||||
});
|
||||
|
||||
const submitPayload = await readJsonOrText(submitResponse);
|
||||
|
||||
if (!submitResponse.ok) {
|
||||
throw new Error(
|
||||
`${extractErrorMessage(submitPayload)} (HTTP ${submitResponse.status})`
|
||||
);
|
||||
}
|
||||
|
||||
const taskId = extractTaskId(submitPayload);
|
||||
if (!taskId) {
|
||||
const directVideoUrl = collectHttpUrls(submitPayload).find((url) =>
|
||||
/\.(mp4|mov|webm|m4v)(\?|#|$)/i.test(url)
|
||||
);
|
||||
if (directVideoUrl) {
|
||||
const archivedUrl = await uploadVideoToStorage(directVideoUrl);
|
||||
const text = formatVideoMarkdown(archivedUrl);
|
||||
return {
|
||||
text,
|
||||
finishReason: "stop" as const,
|
||||
usage: {
|
||||
promptTokens: 0,
|
||||
completionTokens: text.length,
|
||||
},
|
||||
};
|
||||
}
|
||||
throw new Error("柏拉图创建任务成功,但未返回任务 ID");
|
||||
}
|
||||
|
||||
const archivedVideoUrl = await waitForVideoUrl(baseURL, credentials, taskId);
|
||||
const text = formatVideoMarkdown(archivedVideoUrl);
|
||||
|
||||
return {
|
||||
text,
|
||||
finishReason: "stop" as const,
|
||||
usage: {
|
||||
promptTokens: 0,
|
||||
completionTokens: text.length,
|
||||
},
|
||||
};
|
||||
} catch (error) {
|
||||
console.error("[BLTCY Video] Generation failed:", error);
|
||||
return {
|
||||
text:
|
||||
error instanceof Error
|
||||
? `视频生成失败: ${error.message}`
|
||||
: "柏拉图视频生成失败,请稍后重试",
|
||||
finishReason: "error" as const,
|
||||
usage: {
|
||||
promptTokens: 0,
|
||||
completionTokens: 0,
|
||||
},
|
||||
};
|
||||
}
|
||||
},
|
||||
doStream: async function* (options: {
|
||||
messages: Array<{ role: string; content: any }>;
|
||||
videoParams?: BltcyVideoParams;
|
||||
overrideModelId?: string;
|
||||
}) {
|
||||
const result = await this.doGenerate(options);
|
||||
yield {
|
||||
type: "text-delta",
|
||||
textDelta: result.text,
|
||||
};
|
||||
},
|
||||
modelId,
|
||||
specificationVersion: "v1" as const,
|
||||
defaultObjectGenerationMode: "json" as const,
|
||||
}),
|
||||
};
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user