592 lines
23 KiB
TypeScript
592 lines
23 KiB
TypeScript
"use client";
|
|
|
|
import Image from "next/image";
|
|
import Link from "next/link";
|
|
import { useCallback, useEffect, 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[];
|
|
non_speech_summary: string | null;
|
|
content_tags: string[];
|
|
};
|
|
|
|
type Video = {
|
|
aweme_id: string;
|
|
desc: string;
|
|
cover_url: string;
|
|
duration_ms: number;
|
|
content_tags: string[];
|
|
manual_content_tags: string[];
|
|
author: {
|
|
nickname: string;
|
|
};
|
|
transcript: VideoTranscript | null;
|
|
};
|
|
|
|
type ApiResponse = {
|
|
videos: Video[];
|
|
total: number;
|
|
};
|
|
|
|
export default function AsrVideosPage() {
|
|
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" | "tagged" | "missingTags"
|
|
>("all");
|
|
const [batchTranscribing, setBatchTranscribing] = useState(false);
|
|
const [batchProgress, setBatchProgress] = useState({
|
|
current: 0,
|
|
total: 0,
|
|
completed: 0,
|
|
failed: 0,
|
|
currentAwemeId: null as string | null,
|
|
currentDesc: null as string | null,
|
|
});
|
|
const [batchMessage, setBatchMessage] = useState<string | null>(null);
|
|
|
|
const fetchVideos = useCallback(async (showLoading = true) => {
|
|
try {
|
|
if (showLoading) setLoading(true);
|
|
const response = await fetch("/api/admin/asr/videos");
|
|
if (!response.ok) {
|
|
throw new Error("Failed to fetch videos");
|
|
}
|
|
const data: ApiResponse = await response.json();
|
|
setVideos(data.videos);
|
|
} catch (err) {
|
|
setError(err instanceof Error ? err.message : "Unknown error");
|
|
} finally {
|
|
if (showLoading) setLoading(false);
|
|
}
|
|
}, []);
|
|
|
|
useEffect(() => {
|
|
fetchVideos();
|
|
}, [fetchVideos]);
|
|
|
|
// 简单的 sleep 工具
|
|
const sleep = (ms: number) =>
|
|
new Promise((resolve) => setTimeout(resolve, ms));
|
|
|
|
const hasContentTags = (video: Video) => video.content_tags.length > 0;
|
|
|
|
type PollOptions = {
|
|
intervalMs?: number;
|
|
maxAttempts?: number;
|
|
};
|
|
|
|
// 轮询某个视频的识别和标签状态直至完成或超时
|
|
const pollVideoUntilTranscribed = async (
|
|
awemeId: string,
|
|
{ intervalMs = 2000, maxAttempts = 300 }: PollOptions = {},
|
|
): Promise<boolean> => {
|
|
for (let attempt = 0; attempt < maxAttempts; attempt++) {
|
|
try {
|
|
const resp = await fetch("/api/admin/asr/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?.content_tags.length ?? 0) > 0) {
|
|
return true;
|
|
}
|
|
}
|
|
} catch {
|
|
// 忽略网络错误,继续轮询
|
|
}
|
|
await sleep(intervalMs);
|
|
}
|
|
return false;
|
|
};
|
|
|
|
const handleTranscribe = async (awemeId: string) => {
|
|
setTranscribing((prev) => new Set(prev).add(awemeId));
|
|
try {
|
|
const response = await fetch(
|
|
`/api/asr?awemeId=${encodeURIComponent(awemeId)}&async=1`,
|
|
);
|
|
if (!response.ok) {
|
|
throw new Error("Failed to transcribe video");
|
|
}
|
|
// 轮询直到该视频转写完成或超时,避免后端异步导致立即刷新拿不到最新状态
|
|
const done = await pollVideoUntilTranscribed(awemeId);
|
|
if (!done) {
|
|
// 兜底刷新一次
|
|
await fetchVideos(false);
|
|
}
|
|
} catch (err) {
|
|
alert(err instanceof Error ? err.message : "Transcription failed");
|
|
} finally {
|
|
setTranscribing((prev) => {
|
|
const next = new Set(prev);
|
|
next.delete(awemeId);
|
|
return next;
|
|
});
|
|
}
|
|
};
|
|
|
|
const handleBatchTranscribe = async () => {
|
|
const pendingVideos = videos.filter((v) => !hasContentTags(v));
|
|
if (pendingVideos.length === 0) {
|
|
alert("没有缺失标签的视频");
|
|
return;
|
|
}
|
|
|
|
if (
|
|
!confirm(
|
|
`确定要为 ${pendingVideos.length} 个视频生成 ASR 标签吗?这会重新执行识别流程,可能需要较长时间。`,
|
|
)
|
|
) {
|
|
return;
|
|
}
|
|
|
|
setBatchTranscribing(true);
|
|
setBatchMessage(null);
|
|
setBatchProgress({
|
|
current: 0,
|
|
total: pendingVideos.length,
|
|
completed: 0,
|
|
failed: 0,
|
|
currentAwemeId: null,
|
|
currentDesc: null,
|
|
});
|
|
|
|
for (let i = 0; i < pendingVideos.length; i++) {
|
|
const video = pendingVideos[i];
|
|
setBatchProgress((prev) => ({
|
|
...prev,
|
|
current: i + 1,
|
|
currentAwemeId: video.aweme_id,
|
|
currentDesc: video.desc || "无描述",
|
|
}));
|
|
setTranscribing((prev) => new Set(prev).add(video.aweme_id));
|
|
|
|
try {
|
|
const response = await fetch(
|
|
`/api/asr?awemeId=${encodeURIComponent(video.aweme_id)}&async=1`,
|
|
);
|
|
if (!response.ok) {
|
|
throw new Error(`Failed to start ASR for ${video.aweme_id}`);
|
|
}
|
|
|
|
const done = await pollVideoUntilTranscribed(video.aweme_id);
|
|
setBatchProgress((prev) => ({
|
|
...prev,
|
|
completed: done ? prev.completed + 1 : prev.completed,
|
|
failed: done ? prev.failed : prev.failed + 1,
|
|
}));
|
|
} catch (err) {
|
|
console.error(`Error transcribing video ${video.aweme_id}:`, err);
|
|
setBatchProgress((prev) => ({
|
|
...prev,
|
|
failed: prev.failed + 1,
|
|
}));
|
|
} finally {
|
|
setTranscribing((prev) => {
|
|
const next = new Set(prev);
|
|
next.delete(video.aweme_id);
|
|
return next;
|
|
});
|
|
}
|
|
}
|
|
|
|
await fetchVideos(false);
|
|
setBatchTranscribing(false);
|
|
setBatchMessage("缺失标签生成流程已结束");
|
|
};
|
|
|
|
const filteredVideos = videos.filter((v) => {
|
|
if (filter === "transcribed") return v.transcript !== null;
|
|
if (filter === "pending") return v.transcript === null;
|
|
if (filter === "tagged") return hasContentTags(v);
|
|
if (filter === "missingTags") return !hasContentTags(v);
|
|
return true;
|
|
});
|
|
|
|
const stats = {
|
|
total: videos.length,
|
|
transcribed: videos.filter((v) => v.transcript !== null).length,
|
|
pending: videos.filter((v) => v.transcript === null).length,
|
|
tagged: videos.filter((v) => hasContentTags(v)).length,
|
|
missingTags: videos.filter((v) => !hasContentTags(v)).length,
|
|
};
|
|
const processedCount = batchProgress.completed + batchProgress.failed;
|
|
const progressPercent =
|
|
batchProgress.total > 0
|
|
? Math.round((processedCount / batchProgress.total) * 100)
|
|
: 0;
|
|
|
|
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
|
|
type="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>
|
|
);
|
|
}
|
|
|
|
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>
|
|
</div>
|
|
|
|
{/* Stats */}
|
|
<div className="grid grid-cols-1 md:grid-cols-4 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 className="bg-white rounded-lg shadow p-6">
|
|
<div className="text-sm text-gray-600">已生成标签</div>
|
|
<div className="text-3xl font-bold text-blue-600 mt-2">
|
|
{stats.tagged}
|
|
</div>
|
|
<div className="mt-1 text-xs text-gray-500">
|
|
缺失 {stats.missingTags}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Filter */}
|
|
<div className="mb-6 rounded-lg bg-white p-4 shadow">
|
|
<div className="space-y-4">
|
|
<div className="flex flex-wrap items-center justify-between gap-4">
|
|
<div className="flex flex-wrap gap-3">
|
|
<button
|
|
type="button"
|
|
onClick={() => setFilter("all")}
|
|
className={`rounded-lg px-4 py-2 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
|
|
type="button"
|
|
onClick={() => setFilter("transcribed")}
|
|
className={`rounded-lg px-4 py-2 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
|
|
type="button"
|
|
onClick={() => setFilter("pending")}
|
|
className={`rounded-lg px-4 py-2 font-medium transition-colors ${
|
|
filter === "pending"
|
|
? "bg-orange-600 text-white"
|
|
: "bg-gray-100 text-gray-700 hover:bg-gray-200"
|
|
}`}
|
|
>
|
|
待转写 ({stats.pending})
|
|
</button>
|
|
<button
|
|
type="button"
|
|
onClick={() => setFilter("tagged")}
|
|
className={`rounded-lg px-4 py-2 font-medium transition-colors ${
|
|
filter === "tagged"
|
|
? "bg-blue-600 text-white"
|
|
: "bg-gray-100 text-gray-700 hover:bg-gray-200"
|
|
}`}
|
|
>
|
|
已生成标签 ({stats.tagged})
|
|
</button>
|
|
<button
|
|
type="button"
|
|
onClick={() => setFilter("missingTags")}
|
|
className={`rounded-lg px-4 py-2 font-medium transition-colors ${
|
|
filter === "missingTags"
|
|
? "bg-rose-600 text-white"
|
|
: "bg-gray-100 text-gray-700 hover:bg-gray-200"
|
|
}`}
|
|
>
|
|
缺标签 ({stats.missingTags})
|
|
</button>
|
|
</div>
|
|
|
|
<div className="flex items-center gap-4">
|
|
{batchTranscribing && (
|
|
<div className="text-sm text-gray-600">
|
|
正在生成: {batchProgress.current} / {batchProgress.total}
|
|
</div>
|
|
)}
|
|
<button
|
|
type="button"
|
|
onClick={handleBatchTranscribe}
|
|
disabled={batchTranscribing || stats.missingTags === 0}
|
|
className="rounded-lg bg-purple-600 px-6 py-2 font-medium text-white transition-colors hover:bg-purple-700 disabled:cursor-not-allowed disabled:bg-gray-400"
|
|
>
|
|
{batchTranscribing ? (
|
|
<span className="flex items-center gap-2">
|
|
<span className="inline-block h-4 w-4 animate-spin rounded-full border-b-2 border-white" />
|
|
生成中...
|
|
</span>
|
|
) : (
|
|
`一键生成缺失标签 (${stats.missingTags})`
|
|
)}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
{(batchTranscribing || batchMessage) && (
|
|
<div className="rounded-lg border border-blue-100 bg-blue-50 p-4">
|
|
<div className="mb-2 flex items-center justify-between gap-3 text-sm">
|
|
<span className="font-medium text-blue-900">
|
|
{batchTranscribing ? "正在生成缺失标签" : batchMessage}
|
|
</span>
|
|
<span className="text-blue-700">
|
|
{processedCount} / {batchProgress.total}
|
|
</span>
|
|
</div>
|
|
<div className="h-2 overflow-hidden rounded-full bg-blue-100">
|
|
<div
|
|
className="h-full rounded-full bg-blue-600 transition-all"
|
|
style={{ width: `${progressPercent}%` }}
|
|
/>
|
|
</div>
|
|
<div className="mt-2 flex flex-wrap items-center justify-between gap-2 text-xs text-blue-800">
|
|
<span>
|
|
已完成 {batchProgress.completed},失败/超时{" "}
|
|
{batchProgress.failed}
|
|
</span>
|
|
{batchTranscribing && batchProgress.currentAwemeId && (
|
|
<span className="max-w-xl truncate">
|
|
当前: {batchProgress.currentDesc} (
|
|
{batchProgress.currentAwemeId})
|
|
</span>
|
|
)}
|
|
</div>
|
|
</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">
|
|
<Image
|
|
src={video.cover_url}
|
|
alt=""
|
|
width={80}
|
|
height={112}
|
|
unoptimized
|
|
className="w-20 h-28 object-cover rounded"
|
|
/>
|
|
<div className="flex-1 min-w-0">
|
|
<Link
|
|
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">
|
|
@{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>
|
|
<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.join(" ")}
|
|
</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>
|
|
)}
|
|
{video.content_tags.length > 0 && (
|
|
<div className="flex flex-wrap gap-1 pt-1">
|
|
{video.content_tags.map((tag) => (
|
|
<span
|
|
key={tag}
|
|
className={`inline-flex items-center rounded px-2 py-0.5 text-xs font-medium ${
|
|
video.manual_content_tags.includes(tag)
|
|
? "bg-purple-50 text-purple-700"
|
|
: "bg-blue-50 text-blue-700"
|
|
}`}
|
|
>
|
|
{tag}
|
|
</span>
|
|
))}
|
|
</div>
|
|
)}
|
|
{video.content_tags.length === 0 && (
|
|
<span className="inline-flex items-center rounded bg-rose-50 px-2 py-0.5 text-xs font-medium text-rose-700">
|
|
未生成标签
|
|
</span>
|
|
)}
|
|
</div>
|
|
) : (
|
|
<div className="space-y-2">
|
|
<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>
|
|
{video.content_tags.length > 0 && (
|
|
<div className="flex flex-wrap gap-1">
|
|
{video.content_tags.map((tag) => (
|
|
<span
|
|
key={tag}
|
|
className="inline-flex items-center rounded bg-purple-50 px-2 py-0.5 text-xs font-medium text-purple-700"
|
|
>
|
|
{tag}
|
|
</span>
|
|
))}
|
|
</div>
|
|
)}
|
|
</div>
|
|
)}
|
|
</td>
|
|
<td className="px-6 py-4 whitespace-nowrap text-sm">
|
|
{video.transcript ? (
|
|
<button
|
|
type="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>
|
|
) : !hasContentTags(video) ? (
|
|
"生成标签"
|
|
) : (
|
|
"重新识别"
|
|
)}
|
|
</button>
|
|
) : (
|
|
<button
|
|
type="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>
|
|
</tr>
|
|
))}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
|
|
{filteredVideos.length === 0 && (
|
|
<div className="text-center py-12 text-gray-500">没有找到视频</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|