159 lines
7.4 KiB
TypeScript
159 lines
7.4 KiB
TypeScript
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("百炼视频时长须为 2–15 秒。");
|
||
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; }
|