Files
douyin-archive/app/api/media.ts
T
2026-06-17 17:58:00 +08:00

423 lines
11 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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;
};
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 获取视频时长(毫秒)
*/
export async function getVideoDuration(
videoBuffer: Buffer,
): Promise<number | null> {
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<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
*
* 默认输出 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?: 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)}`;
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
const { ext, contentType, codecArgs } = resolveAudioEncoding(format, bitrate);
const outPath = path.join(tmpDir, `${base}.${ext}`);
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 {}
}
}
/**
* 将视频音轨按时间切成多个音频片段,避免长视频单次请求体过大。
*/
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 {}
}
}
}