"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([]); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); const [transcribing, setTranscribing] = useState>(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(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 => { 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 (

加载中...

); } if (error) { return (

错误: {error}

); } return (
{/* Header */}

视频语音识别管理

管理视频的语音识别和自动标签状态

{/* Stats */}
总视频数
{stats.total}
已转写
{stats.transcribed}
待转写
{stats.pending}
已生成标签
{stats.tagged}
缺失 {stats.missingTags}
{/* Filter */}
{batchTranscribing && (
正在生成: {batchProgress.current} / {batchProgress.total}
)}
{(batchTranscribing || batchMessage) && (
{batchTranscribing ? "正在生成缺失标签" : batchMessage} {processedCount} / {batchProgress.total}
已完成 {batchProgress.completed},失败/超时{" "} {batchProgress.failed} {batchTranscribing && batchProgress.currentAwemeId && ( 当前: {batchProgress.currentDesc} ( {batchProgress.currentAwemeId}) )}
)}
{/* Video List */}
{filteredVideos.map((video) => ( ))}
视频 时长 识别/标签状态 操作
{video.desc || "无描述"}

@{video.author.nickname}

{Math.floor(video.duration_ms / 1000)}s {video.transcript ? (
已转写 {video.transcript.language && ( {video.transcript.language} )}
{video.transcript.speech_detected ? (
{video.transcript.transcript.join(" ")}
) : (
{video.transcript.audio_type || "非语音"} {video.transcript.non_speech_summary && ( - {video.transcript.non_speech_summary} )}
)} {video.content_tags.length > 0 && (
{video.content_tags.map((tag) => ( {tag} ))}
)} {video.content_tags.length === 0 && ( 未生成标签 )}
) : (
待转写 {video.content_tags.length > 0 && (
{video.content_tags.map((tag) => ( {tag} ))}
)}
)}
{video.transcript ? ( ) : ( )}
{filteredVideos.length === 0 && (
没有找到视频
)}
); }