feat: 将视频转写重构为纯文字异步语音识别

This commit is contained in:
2026-10-03 21:58:06 +08:00
parent 040273586b
commit 6f0e38054b
34 changed files with 2283 additions and 1771 deletions
+85
View File
@@ -0,0 +1,85 @@
import { execFile } from "node:child_process";
import { createWriteStream } from "node:fs";
import { mkdtemp, rm } from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { pipeline } from "node:stream/promises";
import { promisify } from "node:util";
import { extractAudio } from "../../app/api/media";
import { getFileStream, getFileUrl, uploadTranscriptionAudio } from "../minio";
import { prisma } from "../prisma";
import { AsrError, MAX_DURATION_MS, validateMedia } from "./dashscope";
const execFileAsync = promisify(execFile);
// Whole-file transcription: download to disk, extract once, upload only audio.
export async function prepareAudio(videoId: string): Promise<string | null> {
const video = await prisma.video.findUniqueOrThrow({
where: { aweme_id: videoId },
});
if (video.duration_ms > MAX_DURATION_MS)
throw new AsrError("视频超过 ASR 的 12 小时时长限制");
const dir = await mkdtemp(path.join(os.tmpdir(), "douyin-asr-"));
try {
const input = path.join(dir, "video.mp4");
await pipeline(
await getFileStream(video.video_url),
createWriteStream(input),
{ signal: AbortSignal.timeout(60 * 60 * 1000) },
);
const { stdout } = await execFileAsync(
process.env.FFPROBE_PATH || "ffprobe",
[
"-v",
"error",
"-select_streams",
"a:0",
"-show_entries",
"stream=index:format=duration",
"-of",
"json",
input,
],
{ timeout: 60_000 },
);
const probe = JSON.parse(stdout) as {
streams?: { index: number }[];
format?: { duration?: string };
};
if (!Array.isArray(probe.streams)) throw new AsrError("无法读取视频音轨");
if (!probe.streams.length) return null;
const duration = Number(probe.format?.duration) * 1000;
if (!Number.isFinite(duration) || duration <= 0)
throw new AsrError("无法读取音频时长");
if (duration > MAX_DURATION_MS)
throw new AsrError("音频超过 ASR 的 12 小时时长限制");
const audio = await extractAudio(input, { format: "mp3", bitrateKbps: 64 });
if (!audio) throw new AsrError("未找到 ffmpeg,请安装或配置 FFMPEG_PATH");
validateMedia(duration, audio.buffer.length);
const objectPath = `transcription-audio/${encodeURIComponent(videoId)}/${Date.now()}.mp3`;
await uploadTranscriptionAudio(objectPath, audio.buffer);
const url = getFileUrl(objectPath);
let parsed: URL;
try {
parsed = new URL(url);
} catch {
throw new AsrError("请配置公网可访问的 MINIO_PUBLIC_DOMAIN");
}
if (!["http:", "https:"].includes(parsed.protocol))
throw new AsrError("音频 URL 协议无效");
const response = await fetch(url, {
method: "HEAD",
signal: AbortSignal.timeout(30_000),
});
if (!response.ok)
throw new AsrError(`音频公共地址不可访问 (HTTP ${response.status})`);
return url;
} catch (error) {
if (error instanceof AsrError) throw error;
throw new AsrError(
"音频准备失败,请检查 MinIO、ffmpeg、ffprobe 和文件可访问性后重试",
);
} finally {
await rm(dir, { recursive: true, force: true });
}
}
+159
View File
@@ -0,0 +1,159 @@
export class AsrError extends Error {
constructor(
message: string,
public retryable = false,
public definitiveRejection = false,
) {
super(message);
}
}
export const MAX_DURATION_MS = 12 * 60 * 60 * 1000;
export const MAX_BYTES = 2 * 1024 * 1024 * 1024;
export function validateMedia(durationMs: number, size: number) {
if (durationMs > MAX_DURATION_MS)
throw new AsrError("音频超过 ASR 的 12 小时时长限制");
if (!Number.isFinite(size) || size <= 0)
throw new AsrError("音频文件为空或大小未知");
if (size > MAX_BYTES) throw new AsrError("音频超过 ASR 的 2 GB 大小限制");
}
export function getAsrConfig() {
const apiKey = process.env.DASHSCOPE_API_KEY?.trim();
if (!apiKey) throw new AsrError("未配置 DASHSCOPE_API_KEY,请配置后重试");
const baseURL = (
process.env.DASHSCOPE_BASE_URL || "https://dashscope.aliyuncs.com/api/v1"
).replace(/\/+$/, "");
let url: URL;
try {
url = new URL(baseURL);
} catch {
throw new AsrError("DASHSCOPE_BASE_URL 不是有效地址");
}
if (
!["https:", "http:"].includes(url.protocol) ||
!url.pathname.endsWith("/api/v1")
) {
throw new AsrError("DASHSCOPE_BASE_URL 应为原生 API 地址,以 /api/v1 结尾");
}
const model = process.env.DASHSCOPE_ASR_MODEL || "qwen3-asr-flash-filetrans";
if (!/^qwen3-asr-flash-filetrans(?:-\d{4}-\d{2}-\d{2})?$/.test(model)) {
throw new AsrError(
"DASHSCOPE_ASR_MODEL 必须是 qwen3-asr-flash-filetrans 或其日期版本",
);
}
return { apiKey, baseURL, model };
}
type ObjectValue = Record<string, unknown>;
function object(value: unknown): ObjectValue {
if (!value || typeof value !== "object" || Array.isArray(value))
throw new AsrError("ASR 返回格式异常");
return value as ObjectValue;
}
function safeCode(value: unknown) {
return typeof value === "string" && /^[A-Za-z0-9_.-]{1,100}$/.test(value)
? value
: "UNKNOWN";
}
async function request(url: string, init: RequestInit = {}): Promise<unknown> {
let response: Response;
try {
response = await fetch(url, {
...init,
signal: AbortSignal.timeout(30_000),
});
} catch {
throw new AsrError("ASR 网络请求失败或超时", true);
}
if (!response.ok) {
const raw = await response.json().catch(() => null);
const body = raw && typeof raw === "object" ? (raw as ObjectValue) : {};
throw new AsrError(
`ASR HTTP ${response.status} (${safeCode(body.code)})`,
response.status === 429 || response.status >= 500,
response.status >= 400 &&
response.status < 500 &&
response.status !== 429,
);
}
try {
return await response.json();
} catch {
throw new AsrError("ASR 返回非 JSON 数据", true);
}
}
export async function submitTranscription(fileURL: string): Promise<string> {
const { apiKey, baseURL, model } = getAsrConfig();
const data = object(
await request(`${baseURL}/services/audio/asr/transcription`, {
method: "POST",
headers: {
Authorization: `Bearer ${apiKey}`,
"Content-Type": "application/json",
"X-DashScope-Async": "enable",
},
body: JSON.stringify({
model,
input: { file_url: fileURL },
parameters: { channel_id: [0], enable_itn: false, enable_words: false },
}),
}),
);
const output = object(data.output);
if (typeof output.task_id !== "string" || !output.task_id)
throw new AsrError("ASR 未返回任务 ID,提交结果不确定");
return output.task_id;
}
export type AsrTask =
| { status: "PENDING" | "RUNNING" }
| { status: "SUCCEEDED"; resultURL: string };
export async function queryTranscription(taskId: string): Promise<AsrTask> {
const { apiKey, baseURL } = getAsrConfig();
const data = object(
await request(`${baseURL}/tasks/${encodeURIComponent(taskId)}`, {
headers: { Authorization: `Bearer ${apiKey}` },
}),
);
const output = object(data.output);
if (output.task_status === "PENDING" || output.task_status === "RUNNING")
return { status: output.task_status };
if (output.task_status === "SUCCEEDED") {
const result = object(output.result);
if (typeof result.transcription_url !== "string")
throw new AsrError("ASR 缺少结果下载地址");
return { status: "SUCCEEDED", resultURL: result.transcription_url };
}
throw new AsrError(
`ASR 任务失败 (${safeCode(output.code || output.task_status)})`,
);
}
export function extractTranscript(value: unknown): string[] {
const data = object(value);
if (!Array.isArray(data.transcripts))
throw new AsrError("ASR 缺少 transcripts,不能作为空文字保存");
return data.transcripts.flatMap((entry: unknown) => {
const track = object(entry);
if (track.sentences !== undefined && !Array.isArray(track.sentences))
throw new AsrError("ASR 句子格式异常");
const sentences = ((track.sentences ?? []) as unknown[])
.map((sentence) => {
const text = object(sentence).text;
if (typeof text !== "string") throw new AsrError("ASR 句子缺少文字");
return text.trim();
})
.filter(Boolean);
if (sentences.length) return sentences;
if (typeof track.text !== "string") throw new AsrError("ASR 音轨缺少文字");
return track.text.trim() ? [track.text.trim()] : [];
});
}
export async function downloadTranscript(url: string): Promise<string[]> {
let parsed: URL;
try {
parsed = new URL(url);
} catch {
throw new AsrError("ASR 结果地址无效");
}
if (!["http:", "https:"].includes(parsed.protocol))
throw new AsrError("ASR 结果地址协议无效");
// Result storage is not the API host: never forward the API key.
return extractTranscript(await request(url));
}
+141
View File
@@ -0,0 +1,141 @@
import { randomUUID } from "node:crypto";
import { Prisma, type TranscriptionTask } from "@prisma/client";
import { prisma } from "../prisma";
import { getAsrConfig } from "./dashscope";
import { isActive, transcriptionState } from "./types";
function configurationError(): string | null {
try {
getAsrConfig();
return null;
} catch (error) {
return (error as Error).message;
}
}
export async function enqueueTranscription(videoId: string, force = false) {
return prisma.$transaction(async (tx) => {
const videos = await tx.$queryRaw<
{ aweme_id: string }[]
>`SELECT aweme_id FROM "Video" WHERE aweme_id = ${videoId} FOR UPDATE`;
if (!videos.length) return null;
const [transcript, task] = await Promise.all([
tx.videoTranscript.findUnique({ where: { videoId } }),
tx.transcriptionTask.findUnique({ where: { videoId } }),
]);
if ((task && isActive(task.status)) || (!force && transcript))
return transcriptionState(transcript, task);
const lastError = configurationError();
const data = {
status: lastError ? "FAILED" : "QUEUED",
lastError,
providerTaskId: null,
submittingAt: null,
submittedAt: null,
leaseUntil: null,
leaseToken: null,
attempts: 0,
nextPollAt: new Date(),
};
const saved = await tx.transcriptionTask.upsert({
where: { videoId },
create: { videoId, ...data },
update: data,
});
return transcriptionState(transcript, saved);
});
}
export async function enqueueMissingTranscriptions() {
const error = configurationError();
if (error) throw new Error(error);
// One durable, atomic batch. Completed historical transcripts are never requeued.
return prisma.$executeRaw`
INSERT INTO "TranscriptionTask" ("videoId", status, "updatedAt")
SELECT v.aweme_id, 'QUEUED', (CURRENT_TIMESTAMP AT TIME ZONE 'UTC') FROM "Video" v
WHERE NOT EXISTS (SELECT 1 FROM "VideoTranscript" t WHERE t."videoId" = v.aweme_id)
ON CONFLICT ("videoId") DO UPDATE SET status = 'QUEUED', "providerTaskId" = NULL,
"submittingAt" = NULL, "submittedAt" = NULL, "leaseUntil" = NULL, "leaseToken" = NULL,
attempts = 0, "lastError" = NULL, "nextPollAt" = (CURRENT_TIMESTAMP AT TIME ZONE 'UTC'), "updatedAt" = (CURRENT_TIMESTAMP AT TIME ZONE 'UTC')
WHERE "TranscriptionTask".status = 'FAILED'
`;
}
export async function readTranscription(videoId: string) {
const video = await prisma.video.findUnique({
where: { aweme_id: videoId },
include: { transcript: true, transcriptionTask: true },
});
return video
? transcriptionState(video.transcript, video.transcriptionTask)
: null;
}
export async function claimTask(): Promise<TranscriptionTask | null> {
return prisma.$transaction(async (tx) => {
// Serialize admission across all worker processes, including PM2 restarts.
await tx.$queryRaw`SELECT pg_advisory_xact_lock(74196321)::text`;
const running = await tx.transcriptionTask.count({
where: { status: "PROCESSING" },
});
const eligible =
running < 2
? Prisma.sql`status IN ('QUEUED', 'PROCESSING')`
: Prisma.sql`status = 'PROCESSING'`;
const rows = await tx.$queryRaw<TranscriptionTask[]>`
SELECT * FROM "TranscriptionTask" WHERE ${eligible}
AND "nextPollAt" <= (CURRENT_TIMESTAMP AT TIME ZONE 'UTC') AND ("leaseUntil" IS NULL OR "leaseUntil" < (CURRENT_TIMESTAMP AT TIME ZONE 'UTC'))
ORDER BY CASE WHEN status = 'PROCESSING' THEN 0 ELSE 1 END, "nextPollAt", "createdAt"
LIMIT 1 FOR UPDATE SKIP LOCKED
`;
if (!rows.length) return null;
return tx.transcriptionTask.update({
where: { videoId: rows[0].videoId },
data: {
status: "PROCESSING",
leaseToken: randomUUID(),
leaseUntil: new Date(Date.now() + 180_000),
},
});
});
}
export async function updateOwnedTask(
task: TranscriptionTask,
data: Prisma.TranscriptionTaskUpdateManyMutationInput,
) {
const result = await prisma.transcriptionTask.updateMany({
where: {
videoId: task.videoId,
leaseToken: task.leaseToken,
status: "PROCESSING",
leaseUntil: { gt: new Date() },
},
data,
});
if (!result.count) throw new Error("转写任务租约已失效");
}
export async function completeTask(
task: TranscriptionTask,
transcript: string[],
) {
await prisma.$transaction(async (tx) => {
const result = await tx.transcriptionTask.updateMany({
where: {
videoId: task.videoId,
leaseToken: task.leaseToken,
status: "PROCESSING",
leaseUntil: { gt: new Date() },
},
data: {
status: "SUCCEEDED",
lastError: null,
leaseToken: null,
leaseUntil: null,
attempts: 0,
},
});
if (!result.count) throw new Error("转写任务租约已失效");
await tx.videoTranscript.upsert({
where: { videoId: task.videoId },
create: { videoId: task.videoId, transcript },
update: { transcript },
});
});
}
+33
View File
@@ -0,0 +1,33 @@
export type TranscriptionStatus =
| "PENDING"
| "QUEUED"
| "PROCESSING"
| "SUCCEEDED"
| "FAILED";
export type Transcript = { transcript: string[] };
export type TranscriptionState = {
status: TranscriptionStatus;
error: string | null;
transcript: string[] | null;
};
export const statusLabels: Record<TranscriptionStatus, string> = {
PENDING: "待转写",
QUEUED: "排队中",
PROCESSING: "处理中",
SUCCEEDED: "已完成",
FAILED: "失败",
};
export function isActive(status: string) {
return status === "QUEUED" || status === "PROCESSING";
}
export function transcriptionState(
transcript: Transcript | null,
task: { status: string; lastError: string | null } | null,
): TranscriptionState {
return {
status: (task?.status ??
(transcript ? "SUCCEEDED" : "PENDING")) as TranscriptionStatus,
error: task?.lastError ?? null,
transcript: transcript?.transcript ?? null,
};
}
+112
View File
@@ -0,0 +1,112 @@
import type { TranscriptionTask } from "@prisma/client";
import { prepareAudio } from "./audio";
import {
AsrError,
downloadTranscript,
getAsrConfig,
queryTranscription,
submitTranscription,
} from "./dashscope";
import { claimTask, completeTask, updateOwnedTask } from "./tasks";
export const defaultServices = {
prepareAudio,
submitTranscription,
queryTranscription,
downloadTranscript,
};
export async function processTask(
task: TranscriptionTask,
services = defaultServices,
) {
let submitting = false;
const heartbeat = setInterval(() => {
void updateOwnedTask(task, {
leaseUntil: new Date(Date.now() + 180_000),
}).catch(() => {});
}, 30_000);
try {
getAsrConfig();
if (!task.providerTaskId) {
if (task.submittingAt)
throw new AsrError(
"上次提交结果不确定,请核对百炼任务后手动重试,避免重复计费",
);
const url = await services.prepareAudio(task.videoId);
if (url === null) {
await completeTask(task, []);
return;
}
await updateOwnedTask(task, { submittingAt: new Date() });
submitting = true;
const providerTaskId = await services.submitTranscription(url);
await updateOwnedTask(task, {
providerTaskId,
submittedAt: new Date(),
nextPollAt: new Date(Date.now() + 10_000),
leaseToken: null,
leaseUntil: null,
lastError: null,
});
return;
}
// Provider result URLs expire after 24 hours; do not poll forever.
if (
task.submittedAt &&
Date.now() - task.submittedAt.getTime() > 24 * 60 * 60 * 1000
) {
throw new AsrError("ASR 任务超过 24 小时,请手动重试");
}
const result = await services.queryTranscription(task.providerTaskId);
if (result.status === "SUCCEEDED") {
const transcript = await services.downloadTranscript(result.resultURL);
await completeTask(task, transcript);
} else {
await updateOwnedTask(task, {
nextPollAt: new Date(Date.now() + 10_000),
leaseToken: null,
leaseUntil: null,
lastError: null,
attempts: 0,
});
}
} catch (error) {
// Unknown exceptions (e.g. database outage) leave the lease and submitting marker
// intact. Recovery can query a saved ID, but must never blindly submit again.
if (!(error instanceof AsrError)) throw error;
const retry = !submitting && !!task.providerTaskId && error.retryable;
await updateOwnedTask(task, {
status: retry ? "PROCESSING" : "FAILED",
lastError:
submitting && !error.definitiveRejection
? `${error.message};提交结果不确定,请核对百炼任务后手动重试`
: error.message,
attempts: { increment: 1 },
leaseToken: null,
leaseUntil: null,
nextPollAt: new Date(
Date.now() +
Math.min(300_000, 10_000 * 2 ** Math.min(task.attempts, 5)),
),
});
console.warn(`[ASR] ${task.videoId}: ${retry ? "等待重试" : "失败"}`);
} finally {
clearInterval(heartbeat);
}
}
export async function workerTick(services = defaultServices) {
const tasks: TranscriptionTask[] = [];
for (let i = 0; i < 2; i++) {
const task = await claimTask();
if (task) tasks.push(task);
}
await Promise.all(
tasks.map(async (task) => {
try {
await processTask(task, services);
} catch {
console.error(`[ASR] ${task.videoId}: 执行中断,租约到期后恢复`);
}
}),
);
}