使用 qwen 进行语音识别,加入 tag

Co-authored-by: Copilot <copilot@github.com>
This commit is contained in:
2026-04-29 20:10:08 +08:00
co-authored by Copilot
parent 040273586b
commit 206a561ee7
26 changed files with 3720 additions and 443 deletions
+136 -28
View File
@@ -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);