179 lines
8.8 KiB
TypeScript
179 lines
8.8 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 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; }
|