"use strict";Object.defineProperty(exports, "__esModule", { value: true });exports.extractVideoCover = extractVideoCover;exports.getMediaDuration = getMediaDuration;exports.getVideoResolution = getVideoResolution;exports.hasFFmpeg = hasFFmpeg;exports.probeMediaBuffer = probeMediaBuffer;var _nodeChild_process = require("node:child_process"); var _nodeUtil = require("node:util"); var _nodeFs = _interopRequireDefault(require("node:fs")); var _nodePath = _interopRequireDefault(require("node:path")); var _nodeOs = _interopRequireDefault(require("node:os")); var _nodeCrypto = _interopRequireDefault(require("node:crypto")); var _logger = require("./logger.js");function _interopRequireDefault(e) {return e && e.__esModule ? e : { default: e };} const execFileAsync = (0, _nodeUtil.promisify)(_nodeChild_process.execFile); const FFPROBE_TIMEOUT_MS = 10_000; const FFMPEG_TIMEOUT_MS = 30_000; const MAX_BUFFER_BYTES = 10 * 1024 * 1024; // ======================= ffmpeg 检测 ======================= let ffmpegAvailable = null; /** * 检测系统是否安装了 ffmpeg 和 ffprobe * 结果会被缓存,只检测一次 */ function hasFFmpeg() { if (ffmpegAvailable !== null) { return ffmpegAvailable; } const pathEnv = process.env.PATH ?? ""; const parts = pathEnv.split(_nodePath.default.delimiter).filter(Boolean); const extensions = process.platform === "win32" ? (process.env.PATHEXT ?? ".EXE;.CMD;.BAT").split(";").filter(Boolean) : [""]; let foundFfmpeg = false; let foundFfprobe = false; for (const dir of parts) { for (const ext of extensions) { if (!foundFfmpeg) { try { _nodeFs.default.accessSync(_nodePath.default.join(dir, `ffmpeg${ext}`), _nodeFs.default.constants.X_OK); foundFfmpeg = true; } catch {/* keep scanning */} } if (!foundFfprobe) { try { _nodeFs.default.accessSync(_nodePath.default.join(dir, `ffprobe${ext}`), _nodeFs.default.constants.X_OK); foundFfprobe = true; } catch {/* keep scanning */} } if (foundFfmpeg && foundFfprobe) break; } if (foundFfmpeg && foundFfprobe) break; } ffmpegAvailable = foundFfmpeg && foundFfprobe; if (ffmpegAvailable) { _logger.logger.log("[ffmpeg] 检测到 ffmpeg 和 ffprobe 已安装"); } else { _logger.logger.log(`[ffmpeg] 未检测到 ffmpeg/ffprobe(ffmpeg: ${foundFfmpeg}, ffprobe: ${foundFfprobe})`); } return ffmpegAvailable; } // ======================= ffprobe 探测 ======================= async function runFfprobe(args) { const { stdout } = await execFileAsync("ffprobe", args, { timeout: FFPROBE_TIMEOUT_MS, maxBuffer: MAX_BUFFER_BYTES }); return stdout.toString(); } async function runFfmpeg(args) { const { stdout } = await execFileAsync("ffmpeg", args, { timeout: FFMPEG_TIMEOUT_MS, maxBuffer: MAX_BUFFER_BYTES }); return stdout.toString(); } /** * 获取音频/视频的时长(毫秒) * @param filePath - 本地文件路径 * @returns 时长(毫秒),整数 */ async function getMediaDuration(filePath) { const stdout = await runFfprobe([ "-v", "error", "-show_entries", "format=duration", "-of", "csv=p=0", filePath] ); const durationSec = parseFloat(stdout.trim()); if (isNaN(durationSec)) { throw new Error(`无法解析时长: ${stdout.trim()}`); } return Math.round(durationSec * 1000); } /** * 获取视频的宽高 * @param filePath - 本地文件路径 * @returns { width, height } */ async function getVideoResolution(filePath) { const stdout = await runFfprobe([ "-v", "error", "-select_streams", "v:0", "-show_entries", "stream=width,height", "-of", "csv=p=0:s=x", filePath] ); const match = stdout.trim().match(/^(\d+)x(\d+)/); if (!match) { throw new Error(`无法解析视频分辨率: ${stdout.trim()}`); } return { width: parseInt(match[1], 10), height: parseInt(match[2], 10) }; } /** * 提取视频的第一帧作为封面图 * @param videoPath - 本地视频文件路径 * @returns 封面图片的 Buffer(JPEG 格式) */ async function extractVideoCover(videoPath) { const tmpDir = _nodeOs.default.tmpdir(); const coverPath = _nodePath.default.join(tmpDir, `dingtalk-cover-${_nodeCrypto.default.randomUUID()}.jpg`); try { await runFfmpeg([ "-y", "-i", videoPath, "-vframes", "1", "-q:v", "2", "-f", "image2", coverPath] ); const buffer = _nodeFs.default.readFileSync(coverPath); return buffer; } finally { // 清理临时文件 try { _nodeFs.default.unlinkSync(coverPath); } catch {/* ignore */} } } /** * 将 Buffer 写入临时文件,执行探测,然后清理 * @param buffer - 媒体文件内容 * @param fileName - 文件名(用于扩展名推断) * @param type - 媒体类型 "voice" | "video" */ async function probeMediaBuffer( buffer, fileName, type) { const ext = _nodePath.default.extname(fileName) || (type === "video" ? ".mp4" : ".mp3"); const tmpDir = _nodeOs.default.tmpdir(); const tmpPath = _nodePath.default.join(tmpDir, `dingtalk-probe-${_nodeCrypto.default.randomUUID()}${ext}`); _nodeFs.default.writeFileSync(tmpPath, buffer); try { const duration = await getMediaDuration(tmpPath); const result = { duration }; if (type === "video") { try { const { width, height } = await getVideoResolution(tmpPath); result.width = width; result.height = height; } catch (err) { _logger.logger.warn(`[ffmpeg] 获取视频分辨率失败: ${err}`); } try { result.coverBuffer = await extractVideoCover(tmpPath); } catch (err) { _logger.logger.warn(`[ffmpeg] 提取视频封面失败: ${err}`); } } return result; } finally { try { _nodeFs.default.unlinkSync(tmpPath); } catch {/* ignore */} } } /* v9-a390426dc22cd4ea */