Files
NianAIGC/lib/bailian/client.ts
2026-07-14 12:59:07 +08:00

159 lines
7.4 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 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 shouldMockBailian() {
const flag = (process.env.BAILIAN_MOCK || "auto").trim().toLowerCase();
if (flag === "1" || flag === "true") return true;
return false;
}
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> }) {
const images = input.materials.filter((item) => item.type === "image");
if (images.length < 1 || images.length > 2 || input.materials.some((item) => item.type !== "image")) {
throw new Error("百炼图生视频仅支持 1 张首帧图,或 2 张首尾帧图。");
}
const duration = numberValue(input.settings.duration) ?? 10;
if (!Number.isInteger(duration) || duration < 2 || duration > 15) throw new Error("百炼视频时长须为 215 秒。");
const resolution = String(input.settings.resolution || "720P").toUpperCase();
if (resolution !== "720P" && resolution !== "1080P") throw new Error("百炼视频分辨率仅支持 720P 或 1080P。");
return {
model: getBailianConfig().videoModel,
input: {
prompt: input.prompt,
media: images.map((item, index) => ({
type: index === 0 ? "first_frame" : "last_frame",
url: toAbsoluteUrl(item.url, input.origin)
}))
},
parameters: { resolution, duration, prompt_extend: true, watermark: false }
};
}
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; }