feat: add Bailian image generation support

This commit is contained in:
inman
2026-07-14 12:58:50 +08:00
parent 9a3070c749
commit f642b5e71f
18 changed files with 464 additions and 74 deletions

158
lib/bailian/client.ts Normal file
View File

@@ -0,0 +1,158 @@
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; }