feat(api): 火山模型协议 - 接入方舟与即梦图片视频生成并支持 AK/SK 凭证

This commit is contained in:
ddCat
2026-07-29 18:08:01 +08:00
parent 9ad2fb1b99
commit c33e05ff63
29 changed files with 1739 additions and 310 deletions

View File

@@ -328,7 +328,7 @@ export function logout() {
return request<{ ok: boolean }>(api.post("/auth/logout"));
}
export type AdminListParams = { keyword?: string; status?: string; role?: string; interfaceType?: string; page?: number; limit?: number };
export type AdminListParams = { keyword?: string; status?: string; role?: string; page?: number; limit?: number };
export function listAdminUsers(params: AdminListParams = {}) {
return request<{ users: AdminUser[]; total: number; page: number; limit: number }>(api.get("/admin/users", { params }));

View File

@@ -121,6 +121,7 @@ const IMAGE_MAX_PIXELS = 8294400;
const IMAGE_MAX_EDGE = 3840;
const IMAGE_MAX_RATIO = 3;
const IMAGE_OUTPUT_FORMAT = "png";
const VOLCENGINE_ARK_IMAGE_MAX_PIXELS = 4624220;
function normalizeQuality(quality: string) {
const value = quality.trim().toLowerCase();
@@ -190,6 +191,20 @@ function resolveRequestSize(quality: string | undefined, size: string) {
throw new Error("图像尺寸格式不支持,请使用 auto、9:16 或 1024x1024");
}
function normalizeVolcengineArkImageSize(size: string | undefined) {
if (!size) return undefined;
const dimensions = parseImageDimensions(size);
if (!dimensions || dimensions.width * dimensions.height <= VOLCENGINE_ARK_IMAGE_MAX_PIXELS) return size;
const scale = Math.sqrt(VOLCENGINE_ARK_IMAGE_MAX_PIXELS / (dimensions.width * dimensions.height));
let width = Math.floor((dimensions.width * scale) / 2) * 2;
let height = Math.floor((dimensions.height * scale) / 2) * 2;
while (width > 2 && height > 2 && width * height > VOLCENGINE_ARK_IMAGE_MAX_PIXELS) {
if (width >= height) width -= 2;
else height -= 2;
}
return `${width}x${height}`;
}
function resolveImageDataUrl(item: Record<string, unknown>) {
if (typeof item.b64_json === "string" && item.b64_json) {
return `data:image/png;base64,${item.b64_json}`;
@@ -250,6 +265,18 @@ function aiHeaders(config: AiConfig, contentType?: string) {
};
}
async function postVolcengineArkImage(config: ReturnType<typeof resolveModelRequestConfig>, payload: Record<string, unknown>, options?: RequestOptions) {
const upstreamUrl = aiApiUrl(config, "/images/generations");
const request = channelRequest(config, upstreamUrl, aiHeaders(config, "application/json"));
return (
await axios.post<ImageApiResponse>(request.url, payload, {
headers: request.headers,
withCredentials: request.credentials === "include",
signal: options?.signal,
})
).data;
}
function geminiBaseUrl(config: Pick<AiConfig, "baseUrl">) {
const normalizedBaseUrl = resolveBackendApiUrl(config.baseUrl).replace(/\/+$/, "");
const lowerBaseUrl = normalizedBaseUrl.toLowerCase();
@@ -768,25 +795,35 @@ export async function requestGeneration(config: AiConfig, prompt: string, option
}
const quality = normalizeQuality(config.quality);
const requestSize = resolveRequestSize(quality, config.size);
const isVolcengineArk = requestConfig.interfaceType === "volcengine-ark-image";
const normalizedRequestSize = isVolcengineArk ? normalizeVolcengineArkImageSize(requestSize) : requestSize;
try {
const response = await axios.post<ImageApiResponse>(
aiApiUrl(requestConfig, "/images/generations"),
{
model: requestConfig.model,
prompt: withSystemPrompt(requestConfig, prompt),
n,
...(quality ? { quality } : {}),
...(requestSize ? { size: requestSize } : {}),
response_format: "b64_json",
output_format: IMAGE_OUTPUT_FORMAT,
...(config.transparentBackground === "true" ? { background: "transparent" } : {}),
},
{
headers: aiHeaders(requestConfig, "application/json"),
signal: options?.signal,
},
);
const images = parseImagePayload(response.data);
const payload = isVolcengineArk
? {
model: requestConfig.model,
prompt: withSystemPrompt(requestConfig, prompt),
n,
...(normalizedRequestSize ? { size: normalizedRequestSize } : {}),
}
: {
model: requestConfig.model,
prompt: withSystemPrompt(requestConfig, prompt),
n,
...(quality ? { quality } : {}),
...(requestSize ? { size: requestSize } : {}),
response_format: "b64_json",
output_format: IMAGE_OUTPUT_FORMAT,
...(config.transparentBackground === "true" ? { background: "transparent" } : {}),
};
const responseData = isVolcengineArk
? await postVolcengineArkImage(requestConfig, payload, options)
: (
await axios.post<ImageApiResponse>(aiApiUrl(requestConfig, "/images/generations"), payload, {
headers: aiHeaders(requestConfig, "application/json"),
signal: options?.signal,
})
).data;
const images = parseImagePayload(responseData);
return images;
} catch (error) {
throw new Error(readAxiosError(error, "请求失败"));
@@ -806,6 +843,28 @@ export async function requestEdit(config: AiConfig, prompt: string, references:
throw new Error(readAxiosError(error, "请求失败"));
}
}
if (requestConfig.interfaceType === "volcengine-ark-image") {
if (mask) throw new Error("火山方舟图片协议不支持蒙版编辑,请移除蒙版后重试");
const quality = normalizeQuality(config.quality);
const requestSize = normalizeVolcengineArkImageSize(resolveRequestSize(quality, config.size));
try {
const images = await Promise.all(references.map((image) => imageToDataUrl(image)));
const response = await postVolcengineArkImage(
requestConfig,
{
model: requestConfig.model,
prompt: withSystemPrompt(requestConfig, requestPrompt),
n,
...(requestSize ? { size: requestSize } : {}),
...(images.length === 1 ? { image: images[0] } : images.length > 1 ? { image: images } : {}),
},
options,
);
return parseImagePayload(response);
} catch (error) {
throw new Error(readAxiosError(error, "火山方舟图片生成失败"));
}
}
const quality = normalizeQuality(config.quality);
const requestSize = resolveRequestSize(quality, config.size);
const formData = new FormData();

View File

@@ -65,6 +65,9 @@ export async function createVideoGenerationTask(config: AiConfig, prompt: string
if (requestConfig.interfaceType === "gemini-veo") {
return createGeminiVeoTask(requestConfig, selectedModel, prompt, references, videoReferences, audioReferences, options);
}
if (requestConfig.interfaceType === "volcengine-ark-video") {
return createSeedanceTask(requestConfig, selectedModel, prompt, references, videoReferences, audioReferences, options);
}
if (isSeedanceVideoConfig(requestConfig)) {
return createSeedanceTask(requestConfig, selectedModel, prompt, references, videoReferences, audioReferences, options);
}
@@ -299,13 +302,20 @@ async function pollOpenAIVideoTask(config: ResolvedAiConfig, task: VideoGenerati
}
}
async function createSeedanceTask(config: AiConfig, model: string, prompt: string, references: ReferenceImage[], videoReferences: ReferenceVideo[], audioReferences: ReferenceAudio[], options?: RequestOptions): Promise<VideoGenerationTask> {
async function createSeedanceTask(config: ResolvedAiConfig, model: string, prompt: string, references: ReferenceImage[], videoReferences: ReferenceVideo[], audioReferences: ReferenceAudio[], options?: RequestOptions): Promise<VideoGenerationTask> {
assertSeedanceVideoReferences(videoReferences);
assertSeedanceAudioReferences(audioReferences);
const payload = isArkPlanBaseUrl(config.baseUrl) ? await buildSeedanceAgentPlanPayload(config, model, prompt, references, videoReferences, audioReferences) : await buildSeedanceVideosPayload(config, model, prompt, references, videoReferences, audioReferences);
const isVolcengineArk = config.interfaceType === "volcengine-ark-video";
const payload =
isVolcengineArk || isArkPlanBaseUrl(config.baseUrl)
? await buildSeedanceAgentPlanPayload(config, model, prompt, references, videoReferences, audioReferences)
: await buildSeedanceVideosPayload(config, model, prompt, references, videoReferences, audioReferences);
try {
const created = unwrapSeedanceTask((await axios.post<ApiEnvelope<SeedanceTask>>(seedanceApiUrl(config), payload, { headers: aiHeaders(config, "application/json"), signal: options?.signal })).data);
const raw = isVolcengineArk
? await channelPost<ApiEnvelope<SeedanceTask>>(config, seedanceApiUrl(config), payload, options)
: (await axios.post<ApiEnvelope<SeedanceTask>>(seedanceApiUrl(config), payload, { headers: aiHeaders(config, "application/json"), signal: options?.signal })).data;
const created = unwrapSeedanceTask(raw);
const id = created.id || created.task_id;
if (!id) throw new Error("Seedance 接口没有返回任务 ID");
return { id, provider: "seedance", model };
@@ -314,9 +324,13 @@ async function createSeedanceTask(config: AiConfig, model: string, prompt: strin
}
}
async function pollSeedanceTask(config: AiConfig, task: VideoGenerationTask, options?: RequestOptions): Promise<VideoGenerationTaskState> {
async function pollSeedanceTask(config: ResolvedAiConfig, task: VideoGenerationTask, options?: RequestOptions): Promise<VideoGenerationTaskState> {
try {
const state = unwrapSeedanceTask((await axios.get<ApiEnvelope<SeedanceTask>>(seedanceApiUrl(config, task.id), { headers: aiHeaders(config), signal: options?.signal })).data);
const raw =
config.interfaceType === "volcengine-ark-video"
? await channelGet<ApiEnvelope<SeedanceTask>>(config, seedanceApiUrl(config, task.id), options)
: (await axios.get<ApiEnvelope<SeedanceTask>>(seedanceApiUrl(config, task.id), { headers: aiHeaders(config), signal: options?.signal })).data;
const state = unwrapSeedanceTask(raw);
if (state.status === "succeeded" || state.status === "completed") {
const url = state.video_url || state.content?.video_url;
if (url) return { status: "completed", result: await videoResultFromUrl(url, options) };
@@ -354,16 +368,16 @@ function assertSeedanceAudioReferences(audioReferences: ReferenceAudio[]) {
if (total > 15000) throw new Error("Seedance 参考音频总时长不能超过 15 秒");
}
function seedanceApiUrl(config: AiConfig, taskId?: string) {
if (isArkPlanBaseUrl(config.baseUrl)) return buildApiUrl(config.baseUrl, `/contents/generations/tasks${taskId ? `/${encodeURIComponent(taskId)}` : ""}`);
function seedanceApiUrl(config: ResolvedAiConfig, taskId?: string) {
if (config.interfaceType === "volcengine-ark-video" || isArkPlanBaseUrl(config.baseUrl)) return buildApiUrl(config.baseUrl, `/contents/generations/tasks${taskId ? `/${encodeURIComponent(taskId)}` : ""}`);
return buildApiUrl(config.baseUrl, `/videos${taskId ? `/${encodeURIComponent(taskId)}` : ""}`);
}
async function buildSeedanceAgentPlanPayload(config: AiConfig, model: string, prompt: string, references: ReferenceImage[], videoReferences: ReferenceVideo[], audioReferences: ReferenceAudio[]) {
if (audioReferences.length && !references.length && !videoReferences.length) {
async function buildSeedanceAgentPlanPayload(config: ResolvedAiConfig, model: string, prompt: string, references: ReferenceImage[], videoReferences: ReferenceVideo[], audioReferences: ReferenceAudio[]) {
if (config.interfaceType !== "volcengine-ark-video" && audioReferences.length && !references.length && !videoReferences.length) {
throw new Error("Seedance 参考音频不能单独使用,请同时添加参考图或参考视频");
}
const content = await buildSeedanceContent(config, prompt, references, videoReferences, audioReferences);
const content = config.interfaceType === "volcengine-ark-video" ? await buildVolcengineArkContent(prompt, references, videoReferences, audioReferences) : await buildSeedanceContent(config, prompt, references, videoReferences, audioReferences);
if (!content.length) throw new Error("请输入视频提示词,或连接参考图片/视频/音频");
return {
model: modelOptionName(model),
@@ -376,6 +390,27 @@ async function buildSeedanceAgentPlanPayload(config: AiConfig, model: string, pr
};
}
async function buildVolcengineArkContent(prompt: string, references: ReferenceImage[], videoReferences: ReferenceVideo[], audioReferences: ReferenceAudio[]) {
const content: Array<Record<string, unknown>> = [];
if (prompt.trim()) content.push({ type: "text", text: prompt.trim() });
for (const image of references.slice(0, SEEDANCE_REFERENCE_LIMITS.images)) {
content.push({ type: "image_url", image_url: { url: await resolveVolcengineArkReferenceUrl(image.url || image.dataUrl, image.storageKey) }, role: "reference_image" });
}
for (const video of videoReferences.slice(0, SEEDANCE_REFERENCE_LIMITS.videos)) {
content.push({ type: "video_url", video_url: { url: await resolveVolcengineArkReferenceUrl(video.url, video.storageKey) }, role: "reference_video" });
}
for (const audio of audioReferences.slice(0, SEEDANCE_REFERENCE_LIMITS.audios)) {
content.push({ type: "audio_url", audio_url: { url: await resolveVolcengineArkReferenceUrl(audio.url, audio.storageKey) }, role: "reference_audio" });
}
return content;
}
async function resolveVolcengineArkReferenceUrl(value: string | undefined, storageKey?: string) {
if (storageKey?.startsWith("resource:")) return getResourceOSSUrl(storageKey);
if (isPublicMediaUrl(value || "") || String(value || "").startsWith("asset://")) return String(value);
throw new Error("火山方舟视频参考素材需要公网 URL 或 asset:// 素材 ID;请先将本地素材保存到 OSS");
}
async function buildSeedanceVideosPayload(config: AiConfig, model: string, prompt: string, references: ReferenceImage[], videoReferences: ReferenceVideo[], audioReferences: ReferenceAudio[]) {
if ((videoReferences.length || audioReferences.length) && !references.length) {
throw new Error("Seedance 参考视频或参考音频需要同时连接至少 1 张主参考图");

View File

@@ -64,8 +64,8 @@ export type ChannelModel = {
channelId: string;
modelKey: string;
displayName: string;
capability: "text" | "image" | "video" | "audio";
protocol: import("@/lib/model-protocols").ModelProtocol;
capability: "text" | "image" | "video" | "audio" | "";
protocol?: import("@/lib/model-protocols").ModelProtocol;
billingMode: "fixed_request" | "per_second";
unitPriceMicrocredits: number;
priceConfigured: boolean;