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
+201 -381
View File
@@ -1,437 +1,257 @@
"use client";
import { useState, useEffect } from "react";
import Image from "next/image";
import Link from "next/link";
import { useCallback, useEffect, useRef, useState } from "react";
import BackButton from "@/app/components/BackButton";
type VideoTranscript = {
id: string;
speech_detected: boolean;
language: string | null;
audio_type: string | null;
transcript: string | null;
non_speech_summary: string | null;
};
import {
isActive,
statusLabels,
type TranscriptionState,
type TranscriptionStatus,
} from "@/lib/stt/types";
type Video = {
aweme_id: string;
desc: string;
cover_url: string;
duration_ms: number;
author: {
nickname: string;
};
transcript: VideoTranscript | null;
};
type ApiResponse = {
videos: Video[];
total: number;
author: { nickname: string };
transcription: TranscriptionState;
};
const filters: ("all" | TranscriptionStatus)[] = [
"all",
"PENDING",
"QUEUED",
"PROCESSING",
"SUCCEEDED",
"FAILED",
];
export default function SttVideosPage() {
const [videos, setVideos] = useState<Video[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [transcribing, setTranscribing] = useState<Set<string>>(new Set());
const [filter, setFilter] = useState<"all" | "transcribed" | "pending">(
"all",
);
const [batchTranscribing, setBatchTranscribing] = useState(false);
const [batchProgress, setBatchProgress] = useState({ current: 0, total: 0 });
useEffect(() => {
fetchVideos();
}, []);
const fetchVideos = async () => {
const [notice, setNotice] = useState("");
const [submitting, setSubmitting] = useState<Set<string>>(new Set());
const [batchSubmitting, setBatchSubmitting] = useState(false);
const [filter, setFilter] = useState<"all" | TranscriptionStatus>("all");
const fetching = useRef(false);
const fetchVideos = useCallback(async () => {
if (fetching.current) return;
fetching.current = true;
try {
setLoading(true);
const response = await fetch("/api/admin/stt/videos");
if (!response.ok) {
throw new Error("Failed to fetch videos");
}
const data: ApiResponse = await response.json();
const response = await fetch("/api/admin/stt/videos", {
cache: "no-store",
});
if (!response.ok) throw new Error("读取转写列表失败");
const data: { videos: Video[] } = await response.json();
setVideos(data.videos);
setError(null);
} catch (err) {
setError(err instanceof Error ? err.message : "Unknown error");
setError(err instanceof Error ? err.message : "读取失败");
} finally {
setLoading(false);
fetching.current = false;
}
};
}, []);
useEffect(() => {
void fetchVideos();
const timer = setInterval(() => {
void fetchVideos();
}, 5000);
return () => clearInterval(timer);
}, [fetchVideos]);
// 简单的 sleep 工具
const sleep = (ms: number) =>
new Promise((resolve) => setTimeout(resolve, ms));
type PollOptions = {
intervalMs?: number;
maxAttempts?: number;
};
// 轮询某个视频的转写状态直至完成或超时
const pollVideoUntilTranscribed = async (
awemeId: string,
{ intervalMs = 2000, maxAttempts = 30 }: PollOptions = {},
): Promise<boolean> => {
for (let attempt = 0; attempt < maxAttempts; attempt++) {
try {
const resp = await fetch("/api/admin/stt/videos");
if (resp.ok) {
const data: ApiResponse = await resp.json();
setVideos(data.videos);
const v = data.videos.find((x) => x.aweme_id === awemeId);
if (v && v.transcript) {
return true;
}
}
} catch (e) {
// 忽略网络错误,继续轮询
}
await sleep(intervalMs);
}
return false;
};
const handleTranscribe = async (awemeId: string) => {
setTranscribing((prev) => new Set(prev).add(awemeId));
async function handleTranscribe(awemeId: string) {
setSubmitting((prev) => new Set(prev).add(awemeId));
try {
const response = await fetch(`/api/stt?awemeId=${awemeId}`);
if (!response.ok) {
throw new Error("Failed to transcribe video");
}
// 轮询直到该视频转写完成或超时,避免后端异步导致立即刷新拿不到最新状态
const done = await pollVideoUntilTranscribed(awemeId);
if (!done) {
// 兜底刷新一次
await fetchVideos();
}
const response = await fetch("/api/stt", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ awemeId }),
});
const data = await response.json();
if (!response.ok) throw new Error(data.error || "任务入队失败");
setVideos((prev) =>
prev.map((video) =>
video.aweme_id === awemeId
? { ...video, transcription: data }
: video,
),
);
} catch (err) {
alert(err instanceof Error ? err.message : "Transcription failed");
setNotice(err instanceof Error ? err.message : "任务入队失败");
} finally {
setTranscribing((prev) => {
setSubmitting((prev) => {
const next = new Set(prev);
next.delete(awemeId);
return next;
});
}
};
const handleBatchTranscribe = async () => {
const pendingVideos = videos.filter((v) => v.transcript === null);
if (pendingVideos.length === 0) {
alert("没有待转写的视频");
return;
}
if (
!confirm(
`确定要转写 ${pendingVideos.length} 个视频吗?这可能需要较长时间。`,
)
) {
return;
}
setBatchTranscribing(true);
setBatchProgress({ current: 0, total: pendingVideos.length });
const pollers: Promise<boolean>[] = [];
for (let i = 0; i < pendingVideos.length; i++) {
const video = pendingVideos[i];
setBatchProgress({ current: i + 1, total: pendingVideos.length });
setTranscribing((prev) => new Set(prev).add(video.aweme_id));
try {
const response = await fetch(`/api/stt?awemeId=${video.aweme_id}`);
if (!response.ok) {
console.error(`Failed to transcribe video ${video.aweme_id}`);
}
// 为该视频启动后台轮询,不阻塞批处理的顺序执行
const p = pollVideoUntilTranscribed(video.aweme_id).finally(() => {
setTranscribing((prev) => {
const next = new Set(prev);
next.delete(video.aweme_id);
return next;
});
});
pollers.push(p);
} catch (err) {
console.error(`Error transcribing video ${video.aweme_id}:`, err);
}
}
// 等待所有轮询结束(完成或超时),再进行最终刷新
if (pollers.length > 0) {
await Promise.allSettled(pollers);
}
await fetchVideos();
setBatchTranscribing(false);
setBatchProgress({ current: 0, total: 0 });
alert("批量转写完成!");
};
const filteredVideos = videos.filter((v) => {
if (filter === "transcribed") return v.transcript !== null;
if (filter === "pending") return v.transcript === null;
return true;
});
const stats = {
total: videos.length,
transcribed: videos.filter((v) => v.transcript !== null).length,
pending: videos.filter((v) => v.transcript === null).length,
};
if (loading) {
return (
<div className="min-h-screen bg-gray-50 p-8">
<div className="max-w-7xl mx-auto">
<div className="text-center py-12">
<div className="inline-block animate-spin rounded-full h-12 w-12 border-b-2 border-gray-900"></div>
<p className="mt-4 text-gray-600">加载中...</p>
</div>
</div>
</div>
);
}
if (error) {
return (
<div className="min-h-screen bg-gray-50 p-8">
<div className="max-w-7xl mx-auto">
<div className="bg-red-50 border border-red-200 rounded-lg p-6 text-center">
<p className="text-red-600">错误: {error}</p>
<button
onClick={fetchVideos}
className="mt-4 px-4 py-2 bg-red-600 text-white rounded-lg hover:bg-red-700"
>
重试
</button>
</div>
</div>
</div>
);
async function handleBatch() {
setBatchSubmitting(true);
try {
const response = await fetch("/api/stt/batch", { method: "POST" });
const data = await response.json();
if (!response.ok) throw new Error(data.error || "批量入队失败");
setNotice(`已加入 ${data.queued} 个转写任务;关闭页面后仍会继续处理。`);
await fetchVideos();
} catch (err) {
setNotice(err instanceof Error ? err.message : "批量入队失败");
} finally {
setBatchSubmitting(false);
}
}
const count = (status: TranscriptionStatus) =>
videos.filter((v) => v.transcription.status === status).length;
const eligible = videos.filter(
(v) =>
v.transcription.transcript === null && !isActive(v.transcription.status),
).length;
const shown = videos.filter(
(v) => filter === "all" || v.transcription.status === filter,
);
return (
<div className="min-h-screen bg-gray-50">
<div className="max-w-7xl mx-auto p-8">
{/* Header */}
<div className="mb-8">
<BackButton />
<h1 className="text-3xl font-bold text-gray-900 mt-4">
视频转写管理
</h1>
<p className="text-gray-600 mt-2">管理视频的语音转写状态</p>
<main className="min-h-screen bg-gray-50 p-6">
<div className="mx-auto max-w-7xl space-y-4">
<BackButton />
<h1 className="text-2xl font-semibold text-gray-900">视频文字转写</h1>
<p className="text-sm text-gray-600">
已完成 {count("SUCCEEDED")} · 处理中 {count("PROCESSING")} · 排队中{" "}
{count("QUEUED")} · 失败 {count("FAILED")} · 待转写 {count("PENDING")}
</p>
{error && (
<p role="alert" className="text-red-700">
{error}
</p>
)}
{notice && (
<output className="block text-sm text-gray-700">{notice}</output>
)}
<div className="flex flex-wrap items-center justify-between gap-3">
<div className="flex flex-wrap gap-2">
{filters.map((value) => (
<button
type="button"
key={value}
onClick={() => setFilter(value)}
className={`rounded px-3 py-2 text-sm ${filter === value ? "bg-blue-600 text-white" : "bg-white text-gray-700"}`}
>
{value === "all"
? `全部 (${videos.length})`
: `${statusLabels[value]} (${count(value)})`}
</button>
))}
</div>
<button
type="button"
onClick={handleBatch}
disabled={batchSubmitting || !eligible}
className="rounded bg-blue-600 px-4 py-2 text-sm text-white disabled:opacity-50"
>
{batchSubmitting ? "正在加入队列…" : `批量补转 (${eligible})`}
</button>
</div>
{/* Stats */}
<div className="grid grid-cols-1 md:grid-cols-3 gap-6 mb-8">
<div className="bg-white rounded-lg shadow p-6">
<div className="text-sm text-gray-600">总视频数</div>
<div className="text-3xl font-bold text-gray-900 mt-2">
{stats.total}
</div>
</div>
<div className="bg-white rounded-lg shadow p-6">
<div className="text-sm text-gray-600">已转写</div>
<div className="text-3xl font-bold text-green-600 mt-2">
{stats.transcribed}
</div>
</div>
<div className="bg-white rounded-lg shadow p-6">
<div className="text-sm text-gray-600">待转写</div>
<div className="text-3xl font-bold text-orange-600 mt-2">
{stats.pending}
</div>
</div>
</div>
{/* Filter */}
<div className="bg-white rounded-lg shadow p-4 mb-6">
<div className="flex items-center justify-between">
<div className="flex gap-4">
<button
onClick={() => setFilter("all")}
className={`px-4 py-2 rounded-lg font-medium transition-colors ${
filter === "all"
? "bg-blue-600 text-white"
: "bg-gray-100 text-gray-700 hover:bg-gray-200"
}`}
>
全部 ({stats.total})
</button>
<button
onClick={() => setFilter("transcribed")}
className={`px-4 py-2 rounded-lg font-medium transition-colors ${
filter === "transcribed"
? "bg-green-600 text-white"
: "bg-gray-100 text-gray-700 hover:bg-gray-200"
}`}
>
已转写 ({stats.transcribed})
</button>
<button
onClick={() => setFilter("pending")}
className={`px-4 py-2 rounded-lg font-medium transition-colors ${
filter === "pending"
? "bg-orange-600 text-white"
: "bg-gray-100 text-gray-700 hover:bg-gray-200"
}`}
>
待转写 ({stats.pending})
</button>
</div>
<div className="flex items-center gap-4">
{batchTranscribing && (
<div className="text-sm text-gray-600">
正在转写: {batchProgress.current} / {batchProgress.total}
</div>
)}
<button
onClick={handleBatchTranscribe}
disabled={batchTranscribing || stats.pending === 0}
className="px-6 py-2 bg-purple-600 text-white rounded-lg hover:bg-purple-700 disabled:bg-gray-400 disabled:cursor-not-allowed transition-colors font-medium"
>
{batchTranscribing ? (
<span className="flex items-center gap-2">
<span className="inline-block animate-spin rounded-full h-4 w-4 border-b-2 border-white"></span>
批量转写中...
</span>
) : (
"全部转写"
)}
</button>
</div>
</div>
</div>
{/* Video List */}
<div className="bg-white rounded-lg shadow overflow-hidden">
<div className="overflow-x-auto">
<table className="min-w-full divide-y divide-gray-200">
<thead className="bg-gray-50">
<tr>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
视频
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
时长
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
转写状态
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
操作
</th>
</tr>
</thead>
<tbody className="bg-white divide-y divide-gray-200">
{filteredVideos.map((video) => (
<tr key={video.aweme_id} className="hover:bg-gray-50">
<td className="px-6 py-4">
<div className="flex items-center gap-4">
<img
src={video.cover_url}
alt=""
className="w-20 h-28 object-cover rounded"
/>
<div className="flex-1 min-w-0">
<div className="overflow-x-auto rounded border border-gray-200 bg-white">
<table className="w-full text-left text-sm">
<thead className="bg-gray-100 text-gray-600">
<tr>
<th className="p-3">视频</th>
<th className="p-3">时长</th>
<th className="p-3">状态与文字</th>
<th className="p-3">操作</th>
</tr>
</thead>
<tbody>
{shown.map((video) => {
const state = video.transcription;
const active =
isActive(state.status) || submitting.has(video.aweme_id);
return (
<tr
key={video.aweme_id}
className="border-t border-gray-100 align-top"
>
<td className="p-3">
<div className="flex max-w-sm gap-3">
{video.cover_url && (
<Image
src={video.cover_url}
alt=""
width={56}
height={80}
unoptimized
className="h-20 w-14 rounded object-cover"
/>
)}
<div>
<Link
className="line-clamp-2 text-blue-700"
href={`/aweme/${video.aweme_id}`}
className="text-sm font-medium text-blue-600 hover:text-blue-800 line-clamp-2"
>
{video.desc || "无描述"}
</Link>
<p className="text-sm text-gray-500 mt-1">
<p className="mt-1 text-gray-500">
@{video.author.nickname}
</p>
</div>
</div>
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-900">
{Math.floor(video.duration_ms / 1000)}s
<td className="whitespace-nowrap p-3 text-gray-700">
{Math.floor(video.duration_ms / 1000)} 秒
</td>
<td className="px-6 py-4">
{video.transcript ? (
<div className="space-y-1">
<div className="flex items-center gap-2">
<span className="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-green-100 text-green-800">
已转写
</span>
{video.transcript.language && (
<span className="text-xs text-gray-500">
{video.transcript.language}
</span>
)}
</div>
{video.transcript.speech_detected ? (
<div className="text-xs text-gray-600 line-clamp-5 max-w-md">
{video.transcript.transcript}
</div>
) : (
<div className="text-xs text-gray-500">
<span className="font-medium">
{video.transcript.audio_type || "非语音"}
</span>
{video.transcript.non_speech_summary && (
<span className="ml-1">
- {video.transcript.non_speech_summary}
</span>
)}
</div>
)}
</div>
) : (
<span className="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-orange-100 text-orange-800">
待转写
</span>
<td className="p-3">
<p
className={
state.status === "FAILED"
? "text-red-700"
: "text-gray-700"
}
>
{statusLabels[state.status]}
</p>
{state.error && (
<p className="mt-1 max-w-lg text-xs text-red-700">
{state.error}
</p>
)}
{state.transcript !== null && (
<p className="mt-1 max-w-lg whitespace-pre-wrap text-gray-600">
{state.transcript.join("\n") || "未识别到文字"}
</p>
)}
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm">
{video.transcript ? (
<button
onClick={() => handleTranscribe(video.aweme_id)}
disabled={transcribing.has(video.aweme_id)}
className="px-4 py-2 bg-gray-600 text-white rounded-lg hover:bg-gray-700 disabled:bg-gray-400 disabled:cursor-not-allowed transition-colors"
>
{transcribing.has(video.aweme_id) ? (
<span className="flex items-center gap-2">
<span className="inline-block animate-spin rounded-full h-4 w-4 border-b-2 border-white"></span>
重新转写...
</span>
) : (
"重新转写"
)}
</button>
) : (
<button
onClick={() => handleTranscribe(video.aweme_id)}
disabled={transcribing.has(video.aweme_id)}
className="px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 disabled:bg-blue-400 disabled:cursor-not-allowed transition-colors"
>
{transcribing.has(video.aweme_id) ? (
<span className="flex items-center gap-2">
<span className="inline-block animate-spin rounded-full h-4 w-4 border-b-2 border-white"></span>
转写中...
</span>
) : (
"开始转写"
)}
</button>
)}
<td className="whitespace-nowrap p-3">
<button
type="button"
disabled={active}
onClick={() => handleTranscribe(video.aweme_id)}
className="rounded bg-gray-100 px-3 py-2 text-gray-800 disabled:opacity-50"
>
{active
? "处理中…"
: state.status === "FAILED"
? "重试"
: state.transcript !== null
? "重新转写"
: "开始转写"}
</button>
</td>
</tr>
))}
</tbody>
</table>
</div>
{filteredVideos.length === 0 && (
<div className="text-center py-12 text-gray-500">没有找到视频</div>
);
})}
</tbody>
</table>
{!shown.length && (
<p className="p-8 text-center text-gray-500">
{loading ? "加载中…" : "没有找到视频"}
</p>
)}
</div>
</div>
</div>
</main>
);
}