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 { 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 }); } }