允许后端任务

This commit is contained in:
2026-06-17 17:58:00 +08:00
parent eac6ad2343
commit b9af979582
6 changed files with 1874 additions and 304 deletions
File diff suppressed because it is too large Load Diff
+55
View File
@@ -0,0 +1,55 @@
export const runtime = "nodejs";
export const dynamic = "force-dynamic";
import { type NextRequest, NextResponse } from "next/server";
import {
AsrBatchJobError,
controlAsrBatchJob,
getAsrBatchJob,
} from "@/lib/asr-batch-jobs";
type RouteContext = {
params: Promise<{ jobId: string }>;
};
export async function GET(_req: NextRequest, { params }: RouteContext) {
const { jobId } = await params;
const job = getAsrBatchJob(jobId, true);
if (!job) {
return NextResponse.json({ error: "任务不存在" }, { status: 404 });
}
return NextResponse.json({ job });
}
export async function PATCH(req: NextRequest, { params }: RouteContext) {
const { jobId } = await params;
let body: unknown;
try {
body = await req.json();
} catch {
return NextResponse.json({ error: "Invalid JSON body" }, { status: 400 });
}
const action =
body && typeof body === "object" && "action" in body
? (body as { action?: unknown }).action
: undefined;
try {
const job = controlAsrBatchJob(jobId, action);
return NextResponse.json({ job });
} catch (error) {
if (error instanceof AsrBatchJobError) {
return NextResponse.json(
{ error: error.message },
{ status: error.status },
);
}
console.error("Failed to control ASR batch job:", error);
return NextResponse.json(
{ error: "Failed to control ASR batch job" },
{ status: 500 },
);
}
}
+45
View File
@@ -0,0 +1,45 @@
export const runtime = "nodejs";
export const dynamic = "force-dynamic";
import { type NextRequest, NextResponse } from "next/server";
import {
AsrBatchJobError,
createAsrBatchJob,
getActiveAsrBatchJob,
listAsrBatchJobs,
} from "@/lib/asr-batch-jobs";
export async function GET() {
return NextResponse.json({
jobs: listAsrBatchJobs(),
activeJob: getActiveAsrBatchJob(),
});
}
export async function POST(req: NextRequest) {
let body: unknown;
try {
body = await req.json();
} catch {
return NextResponse.json({ error: "Invalid JSON body" }, { status: 400 });
}
try {
const job = await createAsrBatchJob(
body && typeof body === "object" ? body : {},
);
return NextResponse.json({ job }, { status: 201 });
} catch (error) {
if (error instanceof AsrBatchJobError) {
return NextResponse.json(
{ error: error.message },
{ status: error.status },
);
}
console.error("Failed to create ASR batch job:", error);
return NextResponse.json(
{ error: "Failed to create ASR batch job" },
{ status: 500 },
);
}
}
+338 -19
View File
@@ -4,8 +4,10 @@ import { ASR_CONTENT_TAGS, normalizeAsrContentTags } from "@/lib/asr-tags";
import { downloadFile } from "@/lib/minio";
import { prisma } from "@/lib/prisma";
import {
type ExtractedAudioSegment,
type ExtractedVideoFrame,
extractAudio,
extractAudioSegments,
extractVideoFrames,
} from "../media";
import prompt from "./prompt.md";
@@ -16,8 +18,25 @@ type TranscriptAwemeOptions = {
log?: AsrLogger;
};
type TranscriptAudioOptions = {
segment?: {
index: number;
total: number;
startMs: number;
endMs: number;
};
};
let qwenClient: OpenAI | null = null;
const AUDIO_DATA_URL_PREFIX = "data:;base64,";
const DEFAULT_QWEN_MAX_STRING_LENGTH = 28_000_000;
const DEFAULT_SAFE_AUDIO_DATA_URL_LENGTH = 24_000_000;
const DEFAULT_AUDIO_BITRATE_KBPS = 128;
const DEFAULT_AUDIO_CHUNK_MS = 10 * 60 * 1000;
const MIN_AUDIO_CHUNK_MS = 60 * 1000;
const CHUNK_REEXTRACT_LIMIT = 5;
function getQwenClient() {
if (qwenClient) return qwenClient;
@@ -81,11 +100,188 @@ function parseAsrResult(content: string): AsrResult {
});
}
function getPositiveIntEnv(name: string, fallback: number) {
const value = Number.parseInt(process.env[name] ?? "", 10);
return Number.isFinite(value) && value > 0 ? value : fallback;
}
function getAudioBitrateKbps() {
return getPositiveIntEnv(
"ASR_AUDIO_BITRATE_KBPS",
DEFAULT_AUDIO_BITRATE_KBPS,
);
}
function getAudioChunkMs() {
return Math.max(
MIN_AUDIO_CHUNK_MS,
getPositiveIntEnv(
"ASR_AUDIO_CHUNK_SECONDS",
DEFAULT_AUDIO_CHUNK_MS / 1000,
) * 1000,
);
}
function getMaxAudioDataUrlLength() {
const providerLimit = getPositiveIntEnv(
"QWEN_MAX_STRING_LENGTH",
DEFAULT_QWEN_MAX_STRING_LENGTH,
);
const configuredLimit = getPositiveIntEnv(
"ASR_MAX_AUDIO_DATA_URL_LENGTH",
DEFAULT_SAFE_AUDIO_DATA_URL_LENGTH,
);
return Math.max(
1_000_000,
Math.min(configuredLimit, providerLimit - 1_000_000),
);
}
function getBase64Length(byteLength: number) {
return Math.ceil(byteLength / 3) * 4;
}
function getAudioDataUrlLength(audio: Buffer) {
return AUDIO_DATA_URL_PREFIX.length + getBase64Length(audio.length);
}
function estimateAudioDataUrlLength(durationMs: number, bitrateKbps: number) {
const estimatedBytes = Math.ceil((durationMs / 1000) * bitrateKbps * 125);
return AUDIO_DATA_URL_PREFIX.length + getBase64Length(estimatedBytes);
}
function formatTimestamp(ms: number) {
const totalSeconds = Math.max(0, Math.floor(ms / 1000));
const hours = Math.floor(totalSeconds / 3600);
const minutes = Math.floor((totalSeconds % 3600) / 60);
const seconds = totalSeconds % 60;
if (hours > 0) {
return `${hours}:${minutes.toString().padStart(2, "0")}:${seconds
.toString()
.padStart(2, "0")}`;
}
return `${minutes}:${seconds.toString().padStart(2, "0")}`;
}
function getSegmentInstruction(segment?: TranscriptAudioOptions["segment"]) {
if (!segment) return "";
return `\n\n当前音频是长视频的第 ${segment.index + 1}/${segment.total} 段,时间范围 ${formatTimestamp(segment.startMs)}-${formatTimestamp(segment.endMs)}。请只转写这一段音频,不要猜测其他分片未出现的台词;content_tags 仍结合整条视频三帧、视频简介和当前分片内容判断。`;
}
function shouldChunkBeforeFullExtract(durationMs: number) {
if (!durationMs || durationMs <= 0) return false;
return (
estimateAudioDataUrlLength(durationMs, getAudioBitrateKbps()) >
getMaxAudioDataUrlLength()
);
}
function mergeContentTags(results: AsrResult[]) {
const counts = new Map<string, { count: number; firstSeen: number }>();
let order = 0;
for (const result of results) {
for (const tag of result.content_tags) {
const current = counts.get(tag);
if (current) {
current.count += 1;
} else {
counts.set(tag, { count: 1, firstSeen: order });
order += 1;
}
}
}
const sortedTags = [...counts.entries()].sort(
(left, right) =>
right[1].count - left[1].count || left[1].firstSeen - right[1].firstSeen,
);
const preferredTags = sortedTags.filter(([tag]) => tag !== "其他");
const tags = (preferredTags.length > 0 ? preferredTags : sortedTags)
.slice(0, 3)
.map(([tag]) => tag);
return normalizeAsrContentTags(tags);
}
function mergeLanguages(results: AsrResult[]) {
const languages = results
.flatMap((result) =>
(result.language ?? "")
.split(/[,,、\s]+/)
.map((item) => item.trim())
.filter(Boolean),
)
.filter((language, index, array) => array.indexOf(language) === index);
return languages.length > 0 ? languages.join(",") : null;
}
function getMostCommonAudioType(results: AsrResult[]) {
const counts = new Map<string, { count: number; firstSeen: number }>();
let order = 0;
for (const result of results) {
if (!result.audio_type) continue;
const current = counts.get(result.audio_type);
if (current) {
current.count += 1;
} else {
counts.set(result.audio_type, { count: 1, firstSeen: order });
order += 1;
}
}
return (
[...counts.entries()].sort(
(left, right) =>
right[1].count - left[1].count ||
left[1].firstSeen - right[1].firstSeen,
)[0]?.[0] ?? null
);
}
function mergeTranscriptLines(results: AsrResult[]) {
const lines: string[] = [];
for (const result of results) {
for (const line of result.transcript) {
const normalizedLine = line.trim();
if (!normalizedLine) continue;
if (lines.at(-1) === normalizedLine) continue;
lines.push(normalizedLine);
}
}
return lines;
}
function mergeAsrResults(results: AsrResult[]): AsrResult {
if (results.length === 0) {
throw new Error("No ASR chunk results returned from Qwen Omni");
}
const speechDetected = results.some((result) => result.speech_detected);
const nonSpeechSummaries = results
.map((result) => result.non_speech_summary?.trim())
.filter((summary): summary is string => Boolean(summary));
return AsrSchema.parse({
speech_detected: speechDetected,
language: speechDetected ? mergeLanguages(results) : null,
audio_type: speechDetected ? null : getMostCommonAudioType(results),
transcript: mergeTranscriptLines(results),
non_speech_summary:
speechDetected || nonSpeechSummaries.length === 0
? null
: nonSpeechSummaries.join(";"),
content_tags: mergeContentTags(results),
});
}
async function transcriptAudio(
audio: Buffer,
frames: ExtractedVideoFrame[],
videoDesc: string,
log?: AsrLogger,
options: TranscriptAudioOptions = {},
) {
const base64Audio = Buffer.from(audio).toString("base64");
const client = getQwenClient();
@@ -99,7 +295,7 @@ async function transcriptAudio(
}));
log?.(
`调用 Qwen Omni: model=${model}, audio=${Math.round(audio.length / 1024)}KB, frames=${frames.length}`,
`调用 Qwen Omni: model=${model}, audio=${Math.round(audio.length / 1024)}KB, audioDataUrl=${getAudioDataUrlLength(audio)}, frames=${frames.length}`,
);
const completion = await client.chat.completions.create({
@@ -114,11 +310,14 @@ async function transcriptAudio(
})),
{
type: "input_audio",
input_audio: { data: `data:;base64,${base64Audio}`, format: "mp3" },
input_audio: {
data: `${AUDIO_DATA_URL_PREFIX}${base64Audio}`,
format: "mp3",
},
},
{
type: "text",
text: `${prompt}\n\n视频简介:\n${videoDesc || "无"}\n\n已附带视频三帧,时间点约为:${
text: `${prompt}${getSegmentInstruction(options.segment)}\n\n视频简介:\n${videoDesc || "无"}\n\n已附带视频三帧,时间点约为:${
frames
.map((frame) => `${(frame.timestampMs / 1000).toFixed(1)}s`)
.join("、") || "无"
@@ -151,6 +350,102 @@ async function transcriptAudio(
}
}
async function extractSafeAudioSegments(
videoBuffer: Buffer,
durationMs: number,
log?: AsrLogger,
) {
if (!durationMs || durationMs <= 0) {
throw new Error(
"Audio is too large for a single request and video duration is missing",
);
}
const maxAudioDataUrlLength = getMaxAudioDataUrlLength();
let segmentDurationMs = Math.min(getAudioChunkMs(), durationMs);
for (let attempt = 1; attempt <= CHUNK_REEXTRACT_LIMIT; attempt++) {
log?.(
`提取音频分片: chunk=${Math.round(segmentDurationMs / 1000)}s, bitrate=${getAudioBitrateKbps()}kbps`,
);
const segments = await extractAudioSegments(videoBuffer, {
durationMs,
segmentDurationMs,
format: "mp3",
bitrateKbps: getAudioBitrateKbps(),
});
if (segments.length === 0) {
throw new Error("Failed to extract audio segments from video");
}
const largestSegmentLength = Math.max(
...segments.map((segment) => getAudioDataUrlLength(segment.buffer)),
);
log?.(
`音频分片提取完成: count=${segments.length}, largestDataUrl=${largestSegmentLength}`,
);
if (largestSegmentLength <= maxAudioDataUrlLength) return segments;
if (segmentDurationMs <= MIN_AUDIO_CHUNK_MS) {
throw new Error(
`Audio segment is still too large after splitting: ${largestSegmentLength}`,
);
}
const shrinkRatio = Math.max(
0.25,
Math.min(0.85, (maxAudioDataUrlLength / largestSegmentLength) * 0.8),
);
segmentDurationMs = Math.max(
MIN_AUDIO_CHUNK_MS,
Math.floor(segmentDurationMs * shrinkRatio),
);
log?.(
`音频分片仍过大,缩短分片后重试: ${Math.round(segmentDurationMs / 1000)}s`,
);
}
throw new Error("Failed to extract safe audio segments from video");
}
async function transcriptAudioSegments(
segments: ExtractedAudioSegment[],
frames: ExtractedVideoFrame[],
videoDesc: string,
log?: AsrLogger,
) {
const results: AsrResult[] = [];
for (const segment of segments) {
log?.(
`识别音频分片 ${segment.index + 1}/${segments.length}: ${formatTimestamp(segment.startMs)}-${formatTimestamp(segment.endMs)}`,
);
const result = await transcriptAudio(
segment.buffer,
frames,
videoDesc,
log,
{
segment: {
index: segment.index,
total: segments.length,
startMs: segment.startMs,
endMs: segment.endMs,
},
},
);
results.push(result);
}
const mergedResult = mergeAsrResults(results);
log?.(
`分片结果合并完成: chunks=${results.length}, transcript=${mergedResult.transcript.length} 段, tags=${mergedResult.content_tags.join("、")}`,
);
return mergedResult;
}
export async function transcriptAweme(
awemeId: string,
options: TranscriptAwemeOptions = {},
@@ -179,24 +474,48 @@ export async function transcriptAweme(
.join("、")}`,
);
log?.("提取音频");
const audioDat = await extractAudio(buffer, {
format: "mp3",
bitrateKbps: 128,
});
let result: AsrResult;
const maxAudioDataUrlLength = getMaxAudioDataUrlLength();
if (shouldChunkBeforeFullExtract(aweme.duration_ms)) {
log?.(
`预计整段音频会超过安全阈值 ${maxAudioDataUrlLength},启用长视频分片识别`,
);
const segments = await extractSafeAudioSegments(
buffer,
aweme.duration_ms,
log,
);
result = await transcriptAudioSegments(segments, frames, aweme.desc, log);
} else {
log?.("提取音频");
const audioDat = await extractAudio(buffer, {
format: "mp3",
bitrateKbps: getAudioBitrateKbps(),
});
if (!audioDat || !audioDat.buffer) {
throw new Error("Failed to extract audio from video");
if (!audioDat?.buffer) {
throw new Error("Failed to extract audio from video");
}
const audioDataUrlLength = getAudioDataUrlLength(audioDat.buffer);
log?.(
`音频提取完成: ${Math.round(audioDat.buffer.length / 1024)}KB, dataUrl=${audioDataUrlLength}`,
);
if (audioDataUrlLength > maxAudioDataUrlLength) {
log?.(
`整段音频 data URL 超过安全阈值 ${maxAudioDataUrlLength},改用分片识别`,
);
const segments = await extractSafeAudioSegments(
buffer,
aweme.duration_ms,
log,
);
result = await transcriptAudioSegments(segments, frames, aweme.desc, log);
} else {
result = await transcriptAudio(audioDat.buffer, frames, aweme.desc, log);
}
}
log?.(`音频提取完成: ${Math.round(audioDat.buffer.length / 1024)}KB`);
// 调用 Qwen Omni 生成转写与固定分类标签
const result = await transcriptAudio(
audioDat.buffer,
frames,
aweme.desc,
log,
);
log?.(`解析完成: tags=${result.content_tags.join("、")}`);
// 将转写结果持久化到数据库
+143 -43
View File
@@ -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 {}
}
}
}
+563
View File
@@ -0,0 +1,563 @@
import { randomUUID } from "node:crypto";
import { transcriptAweme } from "@/app/api/asr";
import { resolveEffectiveContentTags } from "@/lib/asr-tags";
import { prisma } from "@/lib/prisma";
export const ASR_BATCH_SCOPES = [
"missingTags",
"pending",
"transcribed",
"tagged",
"all",
"selected",
] as const;
export type AsrBatchScope = (typeof ASR_BATCH_SCOPES)[number];
export type AsrBatchJobStatus =
| "queued"
| "running"
| "paused"
| "completed"
| "canceled"
| "failed";
export type AsrBatchItemStatus =
| "pending"
| "running"
| "success"
| "failed"
| "canceled";
export type AsrBatchControlAction = "pause" | "resume" | "cancel";
export type CreateAsrBatchJobInput = {
scope?: unknown;
rangeStart?: unknown;
rangeEnd?: unknown;
limit?: unknown;
awemeIds?: unknown;
};
export type AsrBatchJobItemSnapshot = {
index: number;
awemeId: string;
desc: string;
status: AsrBatchItemStatus;
tags: string[];
error: string | null;
startedAt: string | null;
finishedAt: string | null;
durationMs: number | null;
};
export type AsrBatchJobSnapshot = {
id: string;
status: AsrBatchJobStatus;
scope: AsrBatchScope;
selection: {
sourceCount: number;
matchedCount: number;
selectedCount: number;
rangeStart: number;
rangeEnd: number;
requestedIdsCount: number;
};
progress: {
total: number;
completed: number;
failed: number;
canceled: number;
pending: number;
running: number;
processed: number;
percent: number;
};
currentItem: AsrBatchJobItemSnapshot | null;
pauseRequested: boolean;
cancelRequested: boolean;
error: string | null;
logs: { at: string; message: string; awemeId: string | null }[];
createdAt: string;
startedAt: string | null;
updatedAt: string;
finishedAt: string | null;
items?: AsrBatchJobItemSnapshot[];
};
type CandidateVideo = {
awemeId: string;
desc: string;
createdAt: string;
hasTranscript: boolean;
contentTags: string[];
};
type InternalAsrBatchJob = Omit<AsrBatchJobSnapshot, "progress" | "items"> & {
items: AsrBatchJobItemSnapshot[];
runner?: Promise<void>;
};
type AsrBatchStore = {
jobs: Map<string, InternalAsrBatchJob>;
};
const MAX_SOURCE_VIDEOS = 5000;
const MAX_JOB_ITEMS = 1000;
const MAX_JOB_HISTORY = 20;
const MAX_LOGS = 160;
const globalForAsrBatchJobs = globalThis as typeof globalThis & {
__asrBatchJobs?: AsrBatchStore;
};
if (!globalForAsrBatchJobs.__asrBatchJobs) {
globalForAsrBatchJobs.__asrBatchJobs = { jobs: new Map() };
}
const store = globalForAsrBatchJobs.__asrBatchJobs;
export class AsrBatchJobError extends Error {
status: number;
constructor(message: string, status = 400) {
super(message);
this.name = "AsrBatchJobError";
this.status = status;
}
}
export function isTerminalAsrBatchStatus(status: AsrBatchJobStatus) {
return status === "completed" || status === "canceled" || status === "failed";
}
function nowIso() {
return new Date().toISOString();
}
function normalizeScope(scope: unknown): AsrBatchScope {
return typeof scope === "string" &&
ASR_BATCH_SCOPES.includes(scope as AsrBatchScope)
? (scope as AsrBatchScope)
: "missingTags";
}
function normalizePositiveInt(value: unknown) {
const numberValue =
typeof value === "number"
? value
: typeof value === "string" && value.trim().length > 0
? Number.parseInt(value, 10)
: Number.NaN;
return Number.isFinite(numberValue) && numberValue > 0
? Math.floor(numberValue)
: null;
}
function normalizeAwemeIds(value: unknown) {
if (!Array.isArray(value)) return [];
return [...new Set(value)]
.filter(
(id): id is string => typeof id === "string" && id.trim().length > 0,
)
.map((id) => id.trim())
.slice(0, MAX_JOB_ITEMS);
}
function appendJobLog(
job: InternalAsrBatchJob,
message: string,
awemeId: string | null = null,
) {
job.logs.push({ at: nowIso(), message, awemeId });
if (job.logs.length > MAX_LOGS) {
job.logs.splice(0, job.logs.length - MAX_LOGS);
}
job.updatedAt = nowIso();
}
function calculateProgress(job: InternalAsrBatchJob) {
const total = job.items.length;
const completed = job.items.filter(
(item) => item.status === "success",
).length;
const failed = job.items.filter((item) => item.status === "failed").length;
const canceled = job.items.filter(
(item) => item.status === "canceled",
).length;
const running = job.items.filter((item) => item.status === "running").length;
const pending = job.items.filter((item) => item.status === "pending").length;
const processed = completed + failed + canceled;
return {
total,
completed,
failed,
canceled,
pending,
running,
processed,
percent: total > 0 ? Math.round((processed / total) * 100) : 0,
};
}
function serializeJob(
job: InternalAsrBatchJob,
includeItems = false,
): AsrBatchJobSnapshot {
const currentItem =
job.items.find((item) => item.status === "running") ?? null;
const snapshot: AsrBatchJobSnapshot = {
id: job.id,
status: job.status,
scope: job.scope,
selection: job.selection,
progress: calculateProgress(job),
currentItem,
pauseRequested: job.pauseRequested,
cancelRequested: job.cancelRequested,
error: job.error,
logs: job.logs,
createdAt: job.createdAt,
startedAt: job.startedAt,
updatedAt: job.updatedAt,
finishedAt: job.finishedAt,
};
if (includeItems) snapshot.items = job.items;
return snapshot;
}
function trimJobHistory() {
const jobs = [...store.jobs.values()].sort(
(left, right) =>
new Date(right.createdAt).getTime() - new Date(left.createdAt).getTime(),
);
for (const job of jobs.slice(MAX_JOB_HISTORY)) {
if (isTerminalAsrBatchStatus(job.status)) store.jobs.delete(job.id);
}
}
function getActiveJob() {
return [...store.jobs.values()].find(
(job) => !isTerminalAsrBatchStatus(job.status),
);
}
async function readCandidateVideos(awemeIds: string[]) {
const videos = await prisma.video.findMany({
...(awemeIds.length > 0 ? { where: { aweme_id: { in: awemeIds } } } : {}),
orderBy: { created_at: "desc" },
take: awemeIds.length > 0 ? awemeIds.length : MAX_SOURCE_VIDEOS,
select: {
aweme_id: true,
desc: true,
created_at: true,
manual_content_tags: true,
transcript: {
select: {
id: true,
content_tags: true,
},
},
},
});
const orderMap = new Map(awemeIds.map((awemeId, index) => [awemeId, index]));
const sortedVideos =
awemeIds.length > 0
? [...videos].sort(
(left, right) =>
(orderMap.get(left.aweme_id) ?? 0) -
(orderMap.get(right.aweme_id) ?? 0),
)
: videos;
return sortedVideos.map<CandidateVideo>((video) => ({
awemeId: video.aweme_id,
desc: video.desc,
createdAt: video.created_at.toISOString(),
hasTranscript: Boolean(video.transcript?.id),
contentTags: resolveEffectiveContentTags(
video.manual_content_tags,
video.transcript?.content_tags,
),
}));
}
function filterCandidatesByScope(
candidates: CandidateVideo[],
scope: AsrBatchScope,
) {
if (scope === "selected" || scope === "all") return candidates;
return candidates.filter((candidate) => {
const hasTags = candidate.contentTags.length > 0;
if (scope === "missingTags") return !hasTags;
if (scope === "pending") return !candidate.hasTranscript;
if (scope === "transcribed") return candidate.hasTranscript;
if (scope === "tagged") return hasTags;
return true;
});
}
function resolveRange(matchedCount: number, input: CreateAsrBatchJobInput) {
const rangeStart = normalizePositiveInt(input.rangeStart) ?? 1;
const explicitRangeEnd = normalizePositiveInt(input.rangeEnd);
const limit = normalizePositiveInt(input.limit);
const requestedRangeEnd =
explicitRangeEnd ?? (limit ? rangeStart + limit - 1 : matchedCount);
const rangeEnd = Math.min(
Math.max(requestedRangeEnd, rangeStart),
matchedCount,
);
return { rangeStart, rangeEnd };
}
async function selectJobItems(input: CreateAsrBatchJobInput) {
const awemeIds = normalizeAwemeIds(input.awemeIds);
const scope = awemeIds.length > 0 ? "selected" : normalizeScope(input.scope);
const sourceCandidates = await readCandidateVideos(awemeIds);
const matchedCandidates = filterCandidatesByScope(sourceCandidates, scope);
const { rangeStart, rangeEnd } = resolveRange(
matchedCandidates.length,
input,
);
const selectedCandidates = matchedCandidates
.slice(rangeStart - 1, rangeEnd)
.slice(0, MAX_JOB_ITEMS);
return {
scope,
rangeStart,
rangeEnd,
sourceCount: sourceCandidates.length,
matchedCount: matchedCandidates.length,
requestedIdsCount: awemeIds.length,
selectedCandidates,
};
}
function launchRunner(job: InternalAsrBatchJob) {
if (job.runner) return;
const runner = runAsrBatchJob(job.id).finally(() => {
const latestJob = store.jobs.get(job.id);
if (latestJob?.runner === runner) latestJob.runner = undefined;
});
job.runner = runner;
}
async function runAsrBatchJob(jobId: string) {
const job = store.jobs.get(jobId);
if (!job) return;
job.status = "running";
job.startedAt ??= nowIso();
job.updatedAt = nowIso();
appendJobLog(job, "任务开始执行");
try {
for (;;) {
if (job.cancelRequested) {
finalizeCanceledJob(job, "任务已取消");
return;
}
if (job.pauseRequested) {
job.status = "paused";
job.updatedAt = nowIso();
appendJobLog(job, "任务已暂停");
return;
}
const nextItem = job.items.find((item) => item.status === "pending");
if (!nextItem) {
job.status = "completed";
job.finishedAt = nowIso();
job.updatedAt = nowIso();
appendJobLog(job, "任务已完成");
return;
}
await runJobItem(job, nextItem);
}
} catch (error) {
job.status = "failed";
job.error = error instanceof Error ? error.message : "任务执行失败";
job.finishedAt = nowIso();
job.updatedAt = nowIso();
appendJobLog(job, job.error);
}
}
async function runJobItem(
job: InternalAsrBatchJob,
item: AsrBatchJobItemSnapshot,
) {
const startedAt = Date.now();
item.status = "running";
item.startedAt = nowIso();
item.error = null;
job.updatedAt = nowIso();
appendJobLog(
job,
`开始处理第 ${item.index}/${job.items.length} 个视频`,
item.awemeId,
);
try {
const result = await transcriptAweme(item.awemeId, {
log: (message) => appendJobLog(job, message, item.awemeId),
});
item.status = "success";
item.tags = result.content_tags;
appendJobLog(
job,
`处理完成: ${result.content_tags.join("、")}`,
item.awemeId,
);
} catch (error) {
item.status = "failed";
item.error = error instanceof Error ? error.message : "处理失败";
appendJobLog(job, `处理失败: ${item.error}`, item.awemeId);
} finally {
item.finishedAt = nowIso();
item.durationMs = Date.now() - startedAt;
job.updatedAt = nowIso();
}
}
function finalizeCanceledJob(job: InternalAsrBatchJob, message: string) {
for (const item of job.items) {
if (item.status === "pending") {
item.status = "canceled";
item.finishedAt = nowIso();
}
}
job.status = "canceled";
job.finishedAt = nowIso();
job.updatedAt = nowIso();
appendJobLog(job, message);
}
export async function createAsrBatchJob(input: CreateAsrBatchJobInput) {
trimJobHistory();
const activeJob = getActiveJob();
if (activeJob) {
throw new AsrBatchJobError("已有批量任务正在执行或暂停中", 409);
}
const selection = await selectJobItems(input);
if (selection.selectedCandidates.length === 0) {
throw new AsrBatchJobError("没有匹配的视频可处理", 400);
}
const createdAt = nowIso();
const job: InternalAsrBatchJob = {
id: randomUUID(),
status: "queued",
scope: selection.scope,
selection: {
sourceCount: selection.sourceCount,
matchedCount: selection.matchedCount,
selectedCount: selection.selectedCandidates.length,
rangeStart: selection.rangeStart,
rangeEnd: selection.rangeEnd,
requestedIdsCount: selection.requestedIdsCount,
},
currentItem: null,
pauseRequested: false,
cancelRequested: false,
error: null,
logs: [],
createdAt,
startedAt: null,
updatedAt: createdAt,
finishedAt: null,
items: selection.selectedCandidates.map((candidate, index) => ({
index: index + 1,
awemeId: candidate.awemeId,
desc: candidate.desc,
status: "pending",
tags: [],
error: null,
startedAt: null,
finishedAt: null,
durationMs: null,
})),
};
store.jobs.set(job.id, job);
appendJobLog(
job,
`任务已创建,匹配 ${selection.matchedCount} 个,选中 ${selection.selectedCandidates.length} 个`,
);
launchRunner(job);
return serializeJob(job, true);
}
export function listAsrBatchJobs() {
return [...store.jobs.values()]
.sort(
(left, right) =>
new Date(right.createdAt).getTime() -
new Date(left.createdAt).getTime(),
)
.map((job) => serializeJob(job));
}
export function getActiveAsrBatchJob() {
const activeJob = getActiveJob();
return activeJob ? serializeJob(activeJob) : null;
}
export function getAsrBatchJob(jobId: string, includeItems = true) {
const job = store.jobs.get(jobId);
return job ? serializeJob(job, includeItems) : null;
}
export function controlAsrBatchJob(jobId: string, action: unknown) {
const job = store.jobs.get(jobId);
if (!job) throw new AsrBatchJobError("任务不存在", 404);
if (action === "pause") {
if (isTerminalAsrBatchStatus(job.status)) return serializeJob(job, true);
job.pauseRequested = true;
job.cancelRequested = false;
if (!job.runner && job.status !== "paused") job.status = "paused";
appendJobLog(job, "已请求暂停,当前视频处理完后暂停");
return serializeJob(job, true);
}
if (action === "resume" || action === "continue") {
if (isTerminalAsrBatchStatus(job.status)) {
throw new AsrBatchJobError("已结束的任务不能继续", 409);
}
if (job.cancelRequested) {
throw new AsrBatchJobError("已请求取消的任务不能继续", 409);
}
job.pauseRequested = false;
job.status = "running";
appendJobLog(job, "任务继续执行");
launchRunner(job);
return serializeJob(job, true);
}
if (action === "cancel") {
if (isTerminalAsrBatchStatus(job.status)) return serializeJob(job, true);
job.cancelRequested = true;
job.pauseRequested = false;
if (!job.runner || job.status === "paused" || job.status === "queued") {
finalizeCanceledJob(job, "任务已取消");
} else {
appendJobLog(job, "已请求取消,当前视频处理完后停止");
}
return serializeJob(job, true);
}
throw new AsrBatchJobError("不支持的任务操作", 400);
}