133 lines
5.0 KiB
TypeScript
133 lines
5.0 KiB
TypeScript
import "server-only";
|
||
import { mkdtemp, readFile, rm } from "node:fs/promises";
|
||
import os from "node:os";
|
||
import path from "node:path";
|
||
import { getEnabledAiModelsWithProvider } from "@/lib/db/queries";
|
||
import { uploadFile } from "@/lib/storage/service";
|
||
import { downloadCanvasMedia } from "./download-media";
|
||
import {
|
||
audioContract,
|
||
audioCheckpointSchema,
|
||
audioRequestSchema,
|
||
buildAudioRequest,
|
||
officialAudioBase,
|
||
} from "./audio-contract";
|
||
import { ProviderTaskUncertainError } from "./video-task-lifecycle";
|
||
export async function listCanvasAudioModels() {
|
||
return (await getEnabledAiModelsWithProvider()).filter(({ model, provider }) => {
|
||
try {
|
||
return (
|
||
provider.type === "alibaba" &&
|
||
!!audioContract(model.modelId) &&
|
||
!!officialAudioBase(provider.baseUrl, model.modelId)
|
||
);
|
||
} catch {
|
||
return false;
|
||
}
|
||
});
|
||
}
|
||
export async function executeCanvasAudio(
|
||
params: unknown,
|
||
lifecycle: {
|
||
resume?: unknown;
|
||
assertActive: () => Promise<void>;
|
||
checkpoint: (value: Record<string, unknown>) => Promise<void>;
|
||
},
|
||
) {
|
||
const input = audioRequestSchema.parse(params);
|
||
let checkpoint = lifecycle.resume ? audioCheckpointSchema.parse(lifecycle.resume) : undefined;
|
||
if (checkpoint && checkpoint.modelRef !== input.modelRef) throw Error("音频记录与原模型不匹配");
|
||
if (!checkpoint) {
|
||
const item = (await listCanvasAudioModels()).find((x) => x.model.id === input.modelRef);
|
||
if (!item) throw Error("音频模型未启用,请先在阿里云和模型设置中开通");
|
||
const request = buildAudioRequest(input, item.model.modelId);
|
||
await lifecycle.assertActive();
|
||
let response: Response;
|
||
try {
|
||
response = await fetch(
|
||
officialAudioBase(item.provider.baseUrl, item.model.modelId) + request.path,
|
||
{
|
||
method: "POST",
|
||
headers: {
|
||
Authorization: `Bearer ${item.provider.apiKey}`,
|
||
"Content-Type": "application/json",
|
||
},
|
||
body: JSON.stringify(request.body),
|
||
signal: AbortSignal.timeout(input.mode === "music" ? 600000 : 180000),
|
||
},
|
||
);
|
||
} catch {
|
||
throw new ProviderTaskUncertainError(
|
||
"音频请求连接中断,无法确认是否已受理;请核对服务商记录,系统不会自动重发",
|
||
);
|
||
}
|
||
let data: any;
|
||
try {
|
||
data = await response.json();
|
||
} catch {
|
||
throw new ProviderTaskUncertainError("音频响应无法解析,请核对服务商记录,系统不会自动重发");
|
||
}
|
||
if (!response.ok)
|
||
throw Error(
|
||
`阿里云音频请求失败(${response.status},${String(data.code || data.error?.code || "UnknownError").slice(0, 120)})${input.mode === "music" ? ",请确认 Fun-Music 邀测权限已开通" : ""}`,
|
||
);
|
||
const url = data.output?.audio?.url;
|
||
if (typeof url !== "string" || !/^https?:\/\//i.test(url))
|
||
throw new ProviderTaskUncertainError("音频服务未返回有效文件地址,请核对原请求,勿重复生成");
|
||
checkpoint = {
|
||
operation: "audio",
|
||
url,
|
||
format: input.format,
|
||
modelRef: item.model.id,
|
||
modelId: item.model.modelId,
|
||
requestId: data.request_id,
|
||
expiresAt:
|
||
typeof data.output.audio.expires_at === "number" ? data.output.audio.expires_at : undefined,
|
||
};
|
||
try {
|
||
await lifecycle.assertActive();
|
||
await lifecycle.checkpoint({ upstreamAudio: checkpoint });
|
||
} catch {
|
||
throw new ProviderTaskUncertainError(
|
||
"音频已生成,但保存任务记录失败;请核对原请求,系统不会自动重发",
|
||
);
|
||
}
|
||
}
|
||
if (checkpoint.expiresAt && checkpoint.expiresAt * 1000 < Date.now())
|
||
throw Error("阿里云音频临时地址已过期,无法继续转存");
|
||
await lifecycle.assertActive();
|
||
const dir = await mkdtemp(path.join(os.tmpdir(), "canvas-audio-"));
|
||
try {
|
||
const file = path.join(dir, "audio." + checkpoint.format);
|
||
await downloadCanvasMedia(checkpoint.url, file);
|
||
const bytes = await readFile(file);
|
||
if (bytes.length < 12) throw Error("音频内容为空");
|
||
const wav =
|
||
bytes.toString("ascii", 0, 4) === "RIFF" && bytes.toString("ascii", 8, 12) === "WAVE";
|
||
const mp3 =
|
||
bytes.toString("ascii", 0, 3) === "ID3" || (bytes[0] === 0xff && (bytes[1] & 0xe0) === 0xe0);
|
||
if (!(checkpoint.format === "wav" ? wav : mp3)) throw Error("返回文件不是有效音频格式");
|
||
await lifecycle.assertActive();
|
||
const saved = await uploadFile(
|
||
`audio-${crypto.randomUUID()}.${checkpoint.format}`,
|
||
Uint8Array.from(bytes).buffer,
|
||
);
|
||
return {
|
||
url: saved.url,
|
||
modelRef: checkpoint.modelRef,
|
||
modelId: checkpoint.modelId,
|
||
metadata: {
|
||
storageStatus: "archived",
|
||
format: checkpoint.format,
|
||
requestId: checkpoint.requestId,
|
||
},
|
||
};
|
||
} catch (error) {
|
||
throw new ProviderTaskUncertainError(
|
||
`音频已生成,转存暂未完成,将继续转存原文件:${error instanceof Error ? error.message : "存储失败"}`,
|
||
);
|
||
} finally {
|
||
await rm(dir, { recursive: true, force: true });
|
||
}
|
||
}
|