86 lines
3.2 KiB
TypeScript
86 lines
3.2 KiB
TypeScript
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 });
|
|
}
|
|
}
|