Files
NianAIGC/lib/bailian/client.ts
T
2026-10-02 19:56:32 +08:00

179 lines
8.8 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import type { EnabledImageCapability, GenerationStatus } from "@/lib/types";
import type { PromptMaterial } from "@/lib/prompt/assembler";
import { toAbsoluteUrl } from "@/lib/server/runtime";
export const BAILIAN_DEFAULT_BASE_URL = "https://llm-126wneubbdo6dbr5.cn-beijing.maas.aliyuncs.com/compatible-mode/v1";
export const BAILIAN_IMAGE_MODEL = "wan2.7-image-pro";
export const BAILIAN_VIDEO_MODEL = "wan2.7-i2v-2026-04-25";
export type BailianTaskResponse = Record<string, unknown> & {
output?: Record<string, unknown>;
request_id?: string;
code?: string;
message?: string;
};
export function bailianOutputVideoSeconds(response: BailianTaskResponse): number | undefined {
const usage = response.usage && typeof response.usage === "object" && !Array.isArray(response.usage)
? response.usage as Record<string, unknown>
: {};
for (const value of [usage.output_video_duration, usage.duration]) {
const seconds = typeof value === "number" ? value : NaN;
if (Number.isFinite(seconds) && seconds > 0) return seconds;
}
return undefined;
}
export function getBailianConfig() {
const compatibleBaseUrl = (process.env.BAILIAN_BASE_URL || BAILIAN_DEFAULT_BASE_URL).replace(/\/+$/, "");
return {
apiKey: process.env.BAILIAN_API_KEY?.trim() || process.env.DASHSCOPE_API_KEY?.trim(),
compatibleBaseUrl,
nativeBaseUrl: deriveBailianNativeBaseUrl(compatibleBaseUrl),
imageModel: process.env.BAILIAN_IMAGE_MODEL || BAILIAN_IMAGE_MODEL,
videoModel: process.env.BAILIAN_VIDEO_MODEL || BAILIAN_VIDEO_MODEL
};
}
export function deriveBailianNativeBaseUrl(baseUrl: string) {
return baseUrl.replace(/\/+$/, "").replace(/\/compatible-mode\/v1$/i, "");
}
export function buildBailianImagePayload(capability: EnabledImageCapability, input: Record<string, unknown>) {
if (capability !== "image.generate") throw new Error("百炼渠道当前仅支持图片生成与参考图生图。");
const prompt = String(input.prompt || "").trim();
if (!prompt) throw new Error("图片生成提示词不能为空。");
const imageUrls = stringArray(input.imageUrls);
if (imageUrls.length > 9) throw new Error("百炼参考图生图最多支持 9 张图片。");
const width = numberValue(input.width);
const height = numberValue(input.height);
validateBailianImageSize(width, height, imageUrls.length > 0);
return {
model: getBailianConfig().imageModel,
input: {
messages: [{
role: "user",
content: [...imageUrls.map((image) => ({ image })), { text: prompt }]
}]
},
parameters: {
size: width && height ? `${width}*${height}` : "2K",
n: 1,
watermark: false,
...(imageUrls.length ? {} : { thinking_mode: true })
}
};
}
export function validateBailianImageSize(width?: number, height?: number, editing = false) {
if (!width && !height) return;
if (!width || !height || !Number.isInteger(width) || !Number.isInteger(height)) throw new Error("百炼图片宽高必须为整数。");
const pixels = width * height;
const minPixels = 768 * 768;
const maxPixels = (editing ? 2048 : 4096) ** 2;
if (pixels < minPixels || pixels > maxPixels) throw new Error(`百炼${editing ? "参考图生图" : "文生图"}总像素须在 768×768 至 ${editing ? "2048×2048" : "4096×4096"} 之间。`);
const ratio = width / height;
if (ratio < 1 / 8 || ratio > 8) throw new Error("百炼图片宽高比须在 1:8 至 8:1 之间。");
}
export function buildBailianVideoPayload(input: { prompt: string; materials: PromptMaterial[]; origin: string; settings: Record<string, unknown>; model?: string }) {
const model = input.model || getBailianConfig().videoModel;
const wan30 = model === "wan3.0-video";
const mode = String(input.settings.inputMode || (wan30 ? "reference" : "frames"));
if (mode !== "frames" && mode !== "reference") throw new Error("百炼请选择首尾帧或多图参考模式。");
if (!wan30 && mode === "reference") throw new Error("Wan 2.7 仅支持首尾帧;多图参考请选择 Wan 3.0。");
const images = input.materials.filter((item) => item.type === "image");
if (input.materials.some((item) => item.type !== "image")) {
throw new Error("百炼当前视频模式仅支持图片素材,请移除视频或音频。");
}
if (mode === "reference" && images.length > 10) {
throw new Error("Wan 3.0 多图参考模式最多支持 10 张图片。");
}
if (mode === "frames" && (images.length < 1 || images.length > 2)) {
throw new Error("百炼图生视频仅支持 1 张首帧图,或 2 张首尾帧图。");
}
const duration = numberValue(input.settings.duration) ?? 10;
if (!Number.isInteger(duration) || duration < 2 || duration > (wan30 ? 30 : 15)) throw new Error(`百炼视频时长须为 2–${wan30 ? 30 : 15} 秒。`);
const resolution = String(input.settings.resolution || "720P").toUpperCase();
if (!["720P", "1080P", ...(wan30 ? ["480P"] : [])].includes(resolution)) throw new Error("百炼视频分辨率不支持当前设置。");
const ratio = input.settings.ratio === undefined ? undefined : String(input.settings.ratio);
if (wan30 && ratio !== undefined && !["adaptive", "21:9", "16:9", "4:3", "1:1", "3:4", "9:16"].includes(ratio)) {
throw new Error("Wan 3.0 画面比例支持自适应、21:9、16:9、4:3、1:1、3:4 或 9:16。");
}
return {
model,
input: {
prompt: input.prompt,
...(images.length > 0 ? { media: images.map((item, index) => ({
type: mode === "reference" ? "reference_image" : index === 0 ? "first_frame" : "last_frame",
url: toAbsoluteUrl(item.url, input.origin)
})) } : {})
},
parameters: { resolution, duration, prompt_extend: true, watermark: false, ...(wan30 && ratio ? { ratio } : {}) }
};
}
export async function submitBailianTask(kind: "image" | "video", payload: Record<string, unknown>) {
const config = requiredConfig();
const path = kind === "image"
? "/api/v1/services/aigc/image-generation/generation"
: "/api/v1/services/aigc/video-generation/video-synthesis";
return bailianFetch(`${config.nativeBaseUrl}${path}`, config.apiKey, {
method: "POST",
headers: { "X-DashScope-Async": "enable" },
body: JSON.stringify(payload)
});
}
export async function queryBailianTask(taskId: string) {
const config = requiredConfig();
return bailianFetch(`${config.nativeBaseUrl}/api/v1/tasks/${encodeURIComponent(taskId)}`, config.apiKey);
}
export function bailianTaskId(response: BailianTaskResponse) {
return stringValue(response.output?.task_id) || stringValue(response.task_id);
}
export function bailianStatus(response: BailianTaskResponse): GenerationStatus {
const value = String(response.output?.task_status || response.status || "").toUpperCase();
if (["SUCCEEDED", "SUCCESS", "COMPLETED"].includes(value)) return "succeeded";
if (["FAILED", "UNKNOWN"].includes(value)) return "failed";
if (["CANCELED", "CANCELLED"].includes(value)) return "cancelled";
if (["RUNNING", "PROCESSING"].includes(value)) return "running";
return "queued";
}
export function bailianResultUrls(response: BailianTaskResponse, kind: "image" | "video") {
const output = response.output || {};
if (kind === "video") return [stringValue(output.video_url)].filter(Boolean) as string[];
const results = Array.isArray(output.results) ? output.results : [];
const resultUrls = results.map((item) => stringValue((item as Record<string, unknown>)?.url)).filter(Boolean) as string[];
const choices = Array.isArray(output.choices) ? output.choices : [];
const choiceUrls = choices.flatMap((choice) => {
const message = (choice as Record<string, unknown>)?.message as Record<string, unknown> | undefined;
const content = Array.isArray(message?.content) ? message.content : [];
return content.map((item) => stringValue((item as Record<string, unknown>)?.image)).filter(Boolean) as string[];
});
return [...resultUrls, ...choiceUrls];
}
async function bailianFetch(url: string, apiKey: string, init: RequestInit = {}) {
const response = await fetch(url, {
...init,
headers: { Authorization: `Bearer ${apiKey}`, "Content-Type": "application/json", ...(init.headers || {}) }
});
const json = await response.json().catch(() => ({})) as BailianTaskResponse;
if (!response.ok) throw new Error(`百炼 API 请求失败:${response.status} ${json.message || JSON.stringify(json)}`);
return json;
}
function requiredConfig() {
const config = getBailianConfig();
if (!config.apiKey) throw new Error("缺少 BAILIAN_API_KEY。");
return { ...config, apiKey: config.apiKey };
}
function stringArray(value: unknown) { return Array.isArray(value) ? value.filter((item): item is string => typeof item === "string" && Boolean(item)) : []; }
function stringValue(value: unknown) { return typeof value === "string" && value ? value : undefined; }
function numberValue(value: unknown) { const number = Number(value); return Number.isFinite(number) ? number : undefined; }