feat(api): 火山模型协议 - 接入方舟与即梦图片视频生成并支持 AK/SK 凭证
This commit is contained in:
@@ -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 }));
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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 张主参考图");
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user