允许后端任务
This commit is contained in:
+143
-43
@@ -82,6 +82,60 @@ export type ExtractedVideoFrame = {
|
||||
timestampMs: number;
|
||||
};
|
||||
|
||||
export type ExtractedAudio = {
|
||||
buffer: Buffer;
|
||||
contentType: string;
|
||||
ext: string;
|
||||
};
|
||||
|
||||
export type ExtractedAudioSegment = ExtractedAudio & {
|
||||
index: number;
|
||||
startMs: number;
|
||||
endMs: number;
|
||||
};
|
||||
|
||||
type AudioFormat = "mp3" | "aac" | "wav";
|
||||
|
||||
function resolveAudioEncoding(format: AudioFormat, bitrateKbps: number) {
|
||||
if (format === "mp3") {
|
||||
return {
|
||||
ext: "mp3",
|
||||
contentType: "audio/mpeg",
|
||||
codecArgs: ["-c:a", "libmp3lame", "-b:a", `${bitrateKbps}k`],
|
||||
};
|
||||
}
|
||||
|
||||
if (format === "aac") {
|
||||
return {
|
||||
ext: "m4a",
|
||||
contentType: "audio/mp4",
|
||||
codecArgs: [
|
||||
"-c:a",
|
||||
"aac",
|
||||
"-b:a",
|
||||
`${bitrateKbps}k`,
|
||||
"-movflags",
|
||||
"+faststart",
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
ext: "wav",
|
||||
contentType: "audio/wav",
|
||||
codecArgs: [
|
||||
"-f",
|
||||
"wav",
|
||||
"-acodec",
|
||||
"pcm_s16le",
|
||||
"-ar",
|
||||
"44100",
|
||||
"-ac",
|
||||
"2",
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 使用 ffprobe 获取视频时长(毫秒)
|
||||
*/
|
||||
@@ -234,8 +288,8 @@ export async function extractVideoFrames(
|
||||
*/
|
||||
export async function extractAudio(
|
||||
videoBuffer: Buffer,
|
||||
opts?: { format?: "mp3" | "aac" | "wav"; bitrateKbps?: number },
|
||||
): Promise<{ buffer: Buffer; contentType: string; ext: string } | null> {
|
||||
opts?: { format?: AudioFormat; bitrateKbps?: number },
|
||||
): Promise<ExtractedAudio | null> {
|
||||
const ffmpegCmd = process.env.FFMPEG_PATH || "ffmpeg";
|
||||
const tmpDir = os.tmpdir();
|
||||
const base = `dy_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`;
|
||||
@@ -243,47 +297,8 @@ export async function extractAudio(
|
||||
|
||||
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",
|
||||
];
|
||||
}
|
||||
const { ext, contentType, codecArgs } = resolveAudioEncoding(format, bitrate);
|
||||
const outPath = path.join(tmpDir, `${base}.${ext}`);
|
||||
|
||||
try {
|
||||
await fs.writeFile(inPath, videoBuffer);
|
||||
@@ -320,3 +335,88 @@ export async function extractAudio(
|
||||
} catch {}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 将视频音轨按时间切成多个音频片段,避免长视频单次请求体过大。
|
||||
*/
|
||||
export async function extractAudioSegments(
|
||||
videoBuffer: Buffer,
|
||||
opts: {
|
||||
durationMs: number;
|
||||
segmentDurationMs: number;
|
||||
format?: AudioFormat;
|
||||
bitrateKbps?: number;
|
||||
},
|
||||
): Promise<ExtractedAudioSegment[]> {
|
||||
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 ?? 128));
|
||||
const segmentDurationMs = Math.max(1000, opts.segmentDurationMs);
|
||||
const durationMs = Math.max(0, opts.durationMs);
|
||||
const { ext, contentType, codecArgs } = resolveAudioEncoding(format, bitrate);
|
||||
const outPaths: string[] = [];
|
||||
|
||||
try {
|
||||
await fs.writeFile(inPath, videoBuffer);
|
||||
|
||||
const segments: ExtractedAudioSegment[] = [];
|
||||
let index = 0;
|
||||
for (let startMs = 0; startMs < durationMs; startMs += segmentDurationMs) {
|
||||
const endMs = Math.min(durationMs, startMs + segmentDurationMs);
|
||||
if (endMs - startMs < 500) continue;
|
||||
|
||||
const outPath = path.join(tmpDir, `${base}_${index}.${ext}`);
|
||||
outPaths.push(outPath);
|
||||
|
||||
const args = [
|
||||
"-hide_banner",
|
||||
"-loglevel",
|
||||
"error",
|
||||
"-ss",
|
||||
(startMs / 1000).toFixed(3),
|
||||
"-t",
|
||||
((endMs - startMs) / 1000).toFixed(3),
|
||||
"-i",
|
||||
inPath,
|
||||
"-vn",
|
||||
...codecArgs,
|
||||
"-y",
|
||||
outPath,
|
||||
];
|
||||
await execFileAsync(ffmpegCmd, args, { windowsHide: true });
|
||||
segments.push({
|
||||
buffer: await fs.readFile(outPath),
|
||||
contentType,
|
||||
ext,
|
||||
index,
|
||||
startMs,
|
||||
endMs,
|
||||
});
|
||||
index += 1;
|
||||
}
|
||||
|
||||
return segments;
|
||||
} catch (error) {
|
||||
if (isMissingExecutableError(error)) {
|
||||
console.warn(
|
||||
"系统未检测到 ffmpeg,可安装并配置 PATH 或设置 FFMPEG_PATH 后启用音频分片提取。",
|
||||
);
|
||||
return [];
|
||||
}
|
||||
console.warn(`提取音频分片失败: ${getErrorMessage(error)}`);
|
||||
throw error;
|
||||
} finally {
|
||||
try {
|
||||
await fs.unlink(inPath);
|
||||
} catch {}
|
||||
for (const outPath of outPaths) {
|
||||
try {
|
||||
await fs.unlink(outPath);
|
||||
} catch {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user