feat: 将视频转写重构为纯文字异步语音识别
This commit is contained in:
@@ -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 });
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user