"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; 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 }); const fetchVideos = useCallback(async () => { try { 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 { setLoading(false); } }, []); useEffect(() => { fetchVideos(); }, [fetchVideos]); // 简单的 sleep 工具 const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)); const hasContentTags = (video: Video) => (video.transcript?.content_tags.length ?? 0) > 0; type PollOptions = { intervalMs?: number; maxAttempts?: number; }; // 轮询某个视频的识别和标签状态直至完成或超时 const pollVideoUntilTranscribed = async ( awemeId: string, { intervalMs = 2000, maxAttempts = 30 }: 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?.transcript?.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=${awemeId}`); if (!response.ok) { throw new Error("Failed to transcribe video"); } // 轮询直到该视频转写完成或超时,避免后端异步导致立即刷新拿不到最新状态 const done = await pollVideoUntilTranscribed(awemeId); if (!done) { // 兜底刷新一次 await fetchVideos(); } } 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); setBatchProgress({ current: 0, total: pendingVideos.length }); const pollers: Promise[] = []; 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/asr?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; 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, }; 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}
)}
{/* 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.transcript.content_tags.length > 0 && (
{video.transcript.content_tags.map((tag) => ( {tag} ))}
)} {video.transcript.content_tags.length === 0 && ( 未生成标签 )}
) : ( 待转写 )}
{video.transcript ? ( ) : ( )}
{filteredVideos.length === 0 && (
没有找到视频
)}
); }