import { execFile } from "node:child_process"; import { promises as fs } from "node:fs"; import os from "node:os"; import path from "node:path"; import { promisify } from "node:util"; const execFileAsync = promisify(execFile); function getErrorMessage(error: unknown) { return error instanceof Error ? error.message : String(error); } function getErrorCode(error: unknown) { return typeof error === "object" && error !== null && "code" in error ? String((error as { code?: unknown }).code) : null; } function isMissingExecutableError(error: unknown) { return ( getErrorCode(error) === "ENOENT" || /not found|is not recognized/i.test(getErrorMessage(error)) ); } /** * 使用 ffmpeg 从视频二进制中提取第一帧,返回 JPEG buffer */ export async function extractFirstFrame( videoBuffer: Buffer, ): Promise<{ buffer: Buffer; contentType: string; ext: string } | null> { const ffmpegCmd = process.env.FFMPEG_PATH || "ffmpeg"; const tmpDir = os.tmpdir(); const base = `dy_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`; const inPath = path.join(tmpDir, `${base}.mp4`); const outPath = path.join(tmpDir, `${base}.jpg`); try { await fs.writeFile(inPath, videoBuffer); const args = [ "-hide_banner", "-loglevel", "error", "-ss", "0", "-i", inPath, "-frames:v", "1", "-q:v", "2", "-f", "image2", "-y", outPath, ]; await execFileAsync(ffmpegCmd, args, { windowsHide: true }); const img = await fs.readFile(outPath); return { buffer: img, contentType: "image/jpeg", ext: "jpg" }; } catch (error) { if (isMissingExecutableError(error)) { console.warn( "系统未检测到 ffmpeg,可安装并配置 PATH 或设置 FFMPEG_PATH 后启用封面提取。", ); return null; } throw error; } finally { try { await fs.unlink(inPath); } catch {} try { await fs.unlink(outPath); } catch {} } } export type ExtractedVideoFrame = { buffer: Buffer; contentType: string; ext: string; timestampMs: number; }; /** * 使用 ffprobe 获取视频时长(毫秒) */ export async function getVideoDuration( videoBuffer: Buffer, ): Promise { const ffprobeCmd = process.env.FFPROBE_PATH || "ffprobe"; const tmpDir = os.tmpdir(); const base = `dy_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`; const inPath = path.join(tmpDir, `${base}.mp4`); try { await fs.writeFile(inPath, videoBuffer); const args = [ "-v", "error", "-show_entries", "format=duration", "-of", "default=noprint_wrappers=1:nokey=1", inPath, ]; const { stdout } = await execFileAsync(ffprobeCmd, args, { windowsHide: true, }); const durationSeconds = parseFloat(stdout.trim()); if (Number.isNaN(durationSeconds)) return null; return Math.round(durationSeconds * 1000); // 转换为毫秒 } catch (error) { if (isMissingExecutableError(error)) { console.warn( "系统未检测到 ffprobe,可安装并配置 PATH 或设置 FFPROBE_PATH 后启用时长提取。", ); return null; } console.warn(`获取视频时长失败: ${getErrorMessage(error)}`); return null; } finally { try { await fs.unlink(inPath); } catch {} } } function getFrameTimestamps(durationMs: number | null, count: number) { if (!durationMs || durationMs <= 0) { return Array.from({ length: count }, (_, index) => index * 1000); } if (count === 1) return [0]; const maxTimestamp = Math.max(0, durationMs - 200); if (count === 3) { return [ Math.min(maxTimestamp, Math.max(0, Math.round(durationMs * 0.1))), Math.min(maxTimestamp, Math.max(0, Math.round(durationMs * 0.5))), Math.min(maxTimestamp, Math.max(0, Math.round(durationMs * 0.9))), ]; } return Array.from({ length: count }, (_, index) => { const ratio = count === 1 ? 0 : index / (count - 1); return Math.min(maxTimestamp, Math.max(0, Math.round(durationMs * ratio))); }); } /** * 使用 ffmpeg 从视频中提取固定数量的 JPEG 帧。 */ export async function extractVideoFrames( videoBuffer: Buffer, opts?: { count?: number; durationMs?: number | null }, ): Promise { const ffmpegCmd = process.env.FFMPEG_PATH || "ffmpeg"; const tmpDir = os.tmpdir(); const base = `dy_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`; const inPath = path.join(tmpDir, `${base}.mp4`); const count = Math.max(1, opts?.count ?? 3); const outPaths = Array.from({ length: count }, (_, index) => path.join(tmpDir, `${base}_${index}.jpg`), ); try { await fs.writeFile(inPath, videoBuffer); const durationMs = opts?.durationMs ?? (await getVideoDuration(videoBuffer)); const timestamps = getFrameTimestamps(durationMs, count); const frames: ExtractedVideoFrame[] = []; for (let index = 0; index < count; index++) { const timestampSeconds = Math.max(0, timestamps[index] / 1000).toFixed(3); const args = [ "-hide_banner", "-loglevel", "error", "-ss", timestampSeconds, "-i", inPath, "-frames:v", "1", "-q:v", "3", "-vf", "scale=768:-2:force_original_aspect_ratio=decrease", "-f", "image2", "-y", outPaths[index], ]; await execFileAsync(ffmpegCmd, args, { windowsHide: true }); frames.push({ buffer: await fs.readFile(outPaths[index]), contentType: "image/jpeg", ext: "jpg", timestampMs: timestamps[index], }); } return frames; } catch (error) { if (isMissingExecutableError(error)) { console.warn( "系统未检测到 ffmpeg,可安装并配置 PATH 或设置 FFMPEG_PATH 后启用视频帧提取。", ); return []; } throw error; } finally { try { await fs.unlink(inPath); } catch {} for (const outPath of outPaths) { try { await fs.unlink(outPath); } catch {} } } } /** * 使用 ffmpeg 将视频二进制转换为音频,返回音频 Buffer * * 默认输出 mp3,可通过 opts.format 指定 'mp3' | 'aac' | 'wav' * - mp3: 使用 libmp3lame,contentType 为 'audio/mpeg' * - aac: 输出 m4a 容器(AAC 编码),contentType 为 'audio/mp4' * - wav: PCM 16-bit,44100Hz,双声道,contentType 为 'audio/wav' * * 若系统未安装 ffmpeg 或未在 PATH 中,返回 null 并给出提示。 */ export async function extractAudio( videoBuffer: Buffer, opts?: { format?: "mp3" | "aac" | "wav"; bitrateKbps?: number }, ): Promise<{ buffer: Buffer; contentType: string; ext: string } | null> { const ffmpegCmd = process.env.FFMPEG_PATH || "ffmpeg"; const tmpDir = os.tmpdir(); const base = `dy_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`; const inPath = path.join(tmpDir, `${base}.mp4`); const format = opts?.format ?? "mp3"; const bitrate = Math.max(32, Math.min(512, opts?.bitrateKbps ?? 192)); // 安全范围 32~512 kbps // 根据目标格式设置输出路径、MIME 与编码参数 let outPath = ""; let contentType = ""; let ext = ""; let codecArgs: string[] = []; if (format === "mp3") { ext = "mp3"; contentType = "audio/mpeg"; outPath = path.join(tmpDir, `${base}.${ext}`); codecArgs = ["-c:a", "libmp3lame", "-b:a", `${bitrate}k`]; } else if (format === "aac") { // 使用 m4a 容器更通用 ext = "m4a"; contentType = "audio/mp4"; outPath = path.join(tmpDir, `${base}.${ext}`); codecArgs = [ "-c:a", "aac", "-b:a", `${bitrate}k`, "-movflags", "+faststart", ]; } else { // wav ext = "wav"; contentType = "audio/wav"; outPath = path.join(tmpDir, `${base}.${ext}`); codecArgs = [ "-f", "wav", "-acodec", "pcm_s16le", "-ar", "44100", "-ac", "2", ]; } try { await fs.writeFile(inPath, videoBuffer); const args = [ "-hide_banner", "-loglevel", "error", "-i", inPath, "-vn", // 丢弃视频流 ...codecArgs, "-y", outPath, ]; await execFileAsync(ffmpegCmd, args, { windowsHide: true }); const audio = await fs.readFile(outPath); return { buffer: audio, contentType, ext }; } catch (error) { if (isMissingExecutableError(error)) { console.warn( "系统未检测到 ffmpeg,可安装并配置 PATH 或设置 FFMPEG_PATH 后启用音频提取。", ); return null; } // 一些环境可能缺少特定编码器(如 libmp3lame),提示并抛出原始错误 console.warn(`提取音频失败: ${getErrorMessage(error)}`); throw error; } finally { try { await fs.unlink(inPath); } catch {} try { if (outPath) await fs.unlink(outPath); } catch {} } }