feat: 将视频转写重构为纯文字异步语音识别

This commit is contained in:
2026-10-03 21:58:06 +08:00
parent 040273586b
commit 6f0e38054b
34 changed files with 2283 additions and 1771 deletions
+85
View File
@@ -0,0 +1,85 @@
import { execFile } from "node:child_process";
import { createWriteStream } from "node:fs";
import { mkdtemp, rm } from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { pipeline } from "node:stream/promises";
import { promisify } from "node:util";
import { extractAudio } from "../../app/api/media";
import { getFileStream, getFileUrl, uploadTranscriptionAudio } from "../minio";
import { prisma } from "../prisma";
import { AsrError, MAX_DURATION_MS, validateMedia } from "./dashscope";
const execFileAsync = promisify(execFile);
// Whole-file transcription: download to disk, extract once, upload only audio.
export async function prepareAudio(videoId: string): Promise<string | null> {
const video = await prisma.video.findUniqueOrThrow({
where: { aweme_id: videoId },
});
if (video.duration_ms > MAX_DURATION_MS)
throw new AsrError("视频超过 ASR 的 12 小时时长限制");
const dir = await mkdtemp(path.join(os.tmpdir(), "douyin-asr-"));
try {
const input = path.join(dir, "video.mp4");
await pipeline(
await getFileStream(video.video_url),
createWriteStream(input),
{ signal: AbortSignal.timeout(60 * 60 * 1000) },
);
const { stdout } = await execFileAsync(
process.env.FFPROBE_PATH || "ffprobe",
[
"-v",
"error",
"-select_streams",
"a:0",
"-show_entries",
"stream=index:format=duration",
"-of",
"json",
input,
],
{ timeout: 60_000 },
);
const probe = JSON.parse(stdout) as {
streams?: { index: number }[];
format?: { duration?: string };
};
if (!Array.isArray(probe.streams)) throw new AsrError("无法读取视频音轨");
if (!probe.streams.length) return null;
const duration = Number(probe.format?.duration) * 1000;
if (!Number.isFinite(duration) || duration <= 0)
throw new AsrError("无法读取音频时长");
if (duration > MAX_DURATION_MS)
throw new AsrError("音频超过 ASR 的 12 小时时长限制");
const audio = await extractAudio(input, { format: "mp3", bitrateKbps: 64 });
if (!audio) throw new AsrError("未找到 ffmpeg,请安装或配置 FFMPEG_PATH");
validateMedia(duration, audio.buffer.length);
const objectPath = `transcription-audio/${encodeURIComponent(videoId)}/${Date.now()}.mp3`;
await uploadTranscriptionAudio(objectPath, audio.buffer);
const url = getFileUrl(objectPath);
let parsed: URL;
try {
parsed = new URL(url);
} catch {
throw new AsrError("请配置公网可访问的 MINIO_PUBLIC_DOMAIN");
}
if (!["http:", "https:"].includes(parsed.protocol))
throw new AsrError("音频 URL 协议无效");
const response = await fetch(url, {
method: "HEAD",
signal: AbortSignal.timeout(30_000),
});
if (!response.ok)
throw new AsrError(`音频公共地址不可访问 (HTTP ${response.status})`);
return url;
} catch (error) {
if (error instanceof AsrError) throw error;
throw new AsrError(
"音频准备失败,请检查 MinIO、ffmpeg、ffprobe 和文件可访问性后重试",
);
} finally {
await rm(dir, { recursive: true, force: true });
}
}