+136
-28
@@ -1,11 +1,28 @@
|
||||
import { execFile } from "child_process";
|
||||
import { promises as fs } from "fs";
|
||||
import os from "os";
|
||||
import path from "path";
|
||||
import { promisify } from "util";
|
||||
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
|
||||
*/
|
||||
@@ -40,18 +57,14 @@ export async function extractFirstFrame(
|
||||
await execFileAsync(ffmpegCmd, args, { windowsHide: true });
|
||||
const img = await fs.readFile(outPath);
|
||||
return { buffer: img, contentType: "image/jpeg", ext: "jpg" };
|
||||
} catch (e: any) {
|
||||
if (
|
||||
e &&
|
||||
(e.code === "ENOENT" ||
|
||||
/not found|is not recognized/i.test(String(e.message)))
|
||||
) {
|
||||
} catch (error) {
|
||||
if (isMissingExecutableError(error)) {
|
||||
console.warn(
|
||||
"系统未检测到 ffmpeg,可安装并配置 PATH 或设置 FFMPEG_PATH 后启用封面提取。",
|
||||
);
|
||||
return null;
|
||||
}
|
||||
throw e;
|
||||
throw error;
|
||||
} finally {
|
||||
try {
|
||||
await fs.unlink(inPath);
|
||||
@@ -62,6 +75,13 @@ export async function extractFirstFrame(
|
||||
}
|
||||
}
|
||||
|
||||
export type ExtractedVideoFrame = {
|
||||
buffer: Buffer;
|
||||
contentType: string;
|
||||
ext: string;
|
||||
timestampMs: number;
|
||||
};
|
||||
|
||||
/**
|
||||
* 使用 ffprobe 获取视频时长(毫秒)
|
||||
*/
|
||||
@@ -88,20 +108,16 @@ export async function getVideoDuration(
|
||||
windowsHide: true,
|
||||
});
|
||||
const durationSeconds = parseFloat(stdout.trim());
|
||||
if (isNaN(durationSeconds)) return null;
|
||||
if (Number.isNaN(durationSeconds)) return null;
|
||||
return Math.round(durationSeconds * 1000); // 转换为毫秒
|
||||
} catch (e: any) {
|
||||
if (
|
||||
e &&
|
||||
(e.code === "ENOENT" ||
|
||||
/not found|is not recognized/i.test(String(e.message)))
|
||||
) {
|
||||
} catch (error) {
|
||||
if (isMissingExecutableError(error)) {
|
||||
console.warn(
|
||||
"系统未检测到 ffprobe,可安装并配置 PATH 或设置 FFPROBE_PATH 后启用时长提取。",
|
||||
);
|
||||
return null;
|
||||
}
|
||||
console.warn(`获取视频时长失败: ${e?.message || e}`);
|
||||
console.warn(`获取视频时长失败: ${getErrorMessage(error)}`);
|
||||
return null;
|
||||
} finally {
|
||||
try {
|
||||
@@ -110,6 +126,102 @@ export async function getVideoDuration(
|
||||
}
|
||||
}
|
||||
|
||||
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<ExtractedVideoFrame[]> {
|
||||
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
|
||||
*
|
||||
@@ -189,20 +301,16 @@ export async function extractAudio(
|
||||
await execFileAsync(ffmpegCmd, args, { windowsHide: true });
|
||||
const audio = await fs.readFile(outPath);
|
||||
return { buffer: audio, contentType, ext };
|
||||
} catch (e: any) {
|
||||
if (
|
||||
e &&
|
||||
(e.code === "ENOENT" ||
|
||||
/not found|is not recognized/i.test(String(e.message)))
|
||||
) {
|
||||
} catch (error) {
|
||||
if (isMissingExecutableError(error)) {
|
||||
console.warn(
|
||||
"系统未检测到 ffmpeg,可安装并配置 PATH 或设置 FFMPEG_PATH 后启用音频提取。",
|
||||
);
|
||||
return null;
|
||||
}
|
||||
// 一些环境可能缺少特定编码器(如 libmp3lame),提示并抛出原始错误
|
||||
console.warn(`提取音频失败: ${e?.message || e}`);
|
||||
throw e;
|
||||
console.warn(`提取音频失败: ${getErrorMessage(error)}`);
|
||||
throw error;
|
||||
} finally {
|
||||
try {
|
||||
await fs.unlink(inPath);
|
||||
|
||||
Reference in New Issue
Block a user