feat: add manual video tagging
This commit is contained in:
+204
-119
@@ -20,6 +20,8 @@ type Video = {
|
||||
desc: string;
|
||||
cover_url: string;
|
||||
duration_ms: number;
|
||||
content_tags: string[];
|
||||
manual_content_tags: string[];
|
||||
author: {
|
||||
nickname: string;
|
||||
};
|
||||
@@ -40,11 +42,19 @@ export default function AsrVideosPage() {
|
||||
"all" | "transcribed" | "pending" | "tagged" | "missingTags"
|
||||
>("all");
|
||||
const [batchTranscribing, setBatchTranscribing] = useState(false);
|
||||
const [batchProgress, setBatchProgress] = useState({ current: 0, total: 0 });
|
||||
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 () => {
|
||||
const fetchVideos = useCallback(async (showLoading = true) => {
|
||||
try {
|
||||
setLoading(true);
|
||||
if (showLoading) setLoading(true);
|
||||
const response = await fetch("/api/admin/asr/videos");
|
||||
if (!response.ok) {
|
||||
throw new Error("Failed to fetch videos");
|
||||
@@ -54,7 +64,7 @@ export default function AsrVideosPage() {
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "Unknown error");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
if (showLoading) setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
@@ -66,8 +76,7 @@ export default function AsrVideosPage() {
|
||||
const sleep = (ms: number) =>
|
||||
new Promise((resolve) => setTimeout(resolve, ms));
|
||||
|
||||
const hasContentTags = (video: Video) =>
|
||||
(video.transcript?.content_tags.length ?? 0) > 0;
|
||||
const hasContentTags = (video: Video) => video.content_tags.length > 0;
|
||||
|
||||
type PollOptions = {
|
||||
intervalMs?: number;
|
||||
@@ -77,7 +86,7 @@ export default function AsrVideosPage() {
|
||||
// 轮询某个视频的识别和标签状态直至完成或超时
|
||||
const pollVideoUntilTranscribed = async (
|
||||
awemeId: string,
|
||||
{ intervalMs = 2000, maxAttempts = 30 }: PollOptions = {},
|
||||
{ intervalMs = 2000, maxAttempts = 300 }: PollOptions = {},
|
||||
): Promise<boolean> => {
|
||||
for (let attempt = 0; attempt < maxAttempts; attempt++) {
|
||||
try {
|
||||
@@ -86,7 +95,7 @@ export default function AsrVideosPage() {
|
||||
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) {
|
||||
if ((v?.content_tags.length ?? 0) > 0) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -101,7 +110,9 @@ export default function AsrVideosPage() {
|
||||
const handleTranscribe = async (awemeId: string) => {
|
||||
setTranscribing((prev) => new Set(prev).add(awemeId));
|
||||
try {
|
||||
const response = await fetch(`/api/asr?awemeId=${awemeId}`);
|
||||
const response = await fetch(
|
||||
`/api/asr?awemeId=${encodeURIComponent(awemeId)}&async=1`,
|
||||
);
|
||||
if (!response.ok) {
|
||||
throw new Error("Failed to transcribe video");
|
||||
}
|
||||
@@ -109,7 +120,7 @@ export default function AsrVideosPage() {
|
||||
const done = await pollVideoUntilTranscribed(awemeId);
|
||||
if (!done) {
|
||||
// 兜底刷新一次
|
||||
await fetchVideos();
|
||||
await fetchVideos(false);
|
||||
}
|
||||
} catch (err) {
|
||||
alert(err instanceof Error ? err.message : "Transcription failed");
|
||||
@@ -138,41 +149,58 @@ export default function AsrVideosPage() {
|
||||
}
|
||||
|
||||
setBatchTranscribing(true);
|
||||
setBatchProgress({ current: 0, total: pendingVideos.length });
|
||||
setBatchMessage(null);
|
||||
setBatchProgress({
|
||||
current: 0,
|
||||
total: pendingVideos.length,
|
||||
completed: 0,
|
||||
failed: 0,
|
||||
currentAwemeId: null,
|
||||
currentDesc: null,
|
||||
});
|
||||
|
||||
const pollers: Promise<boolean>[] = [];
|
||||
for (let i = 0; i < pendingVideos.length; i++) {
|
||||
const video = pendingVideos[i];
|
||||
setBatchProgress({ current: i + 1, total: pendingVideos.length });
|
||||
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=${video.aweme_id}`);
|
||||
const response = await fetch(
|
||||
`/api/asr?awemeId=${encodeURIComponent(video.aweme_id)}&async=1`,
|
||||
);
|
||||
if (!response.ok) {
|
||||
console.error(`Failed to transcribe video ${video.aweme_id}`);
|
||||
throw new Error(`Failed to start ASR for ${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);
|
||||
|
||||
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;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// 等待所有轮询结束(完成或超时),再进行最终刷新
|
||||
if (pollers.length > 0) {
|
||||
await Promise.allSettled(pollers);
|
||||
}
|
||||
await fetchVideos();
|
||||
await fetchVideos(false);
|
||||
setBatchTranscribing(false);
|
||||
setBatchProgress({ current: 0, total: 0 });
|
||||
alert("缺失标签生成完成!");
|
||||
setBatchMessage("缺失标签生成流程已结束");
|
||||
};
|
||||
|
||||
const filteredVideos = videos.filter((v) => {
|
||||
@@ -190,6 +218,11 @@ export default function AsrVideosPage() {
|
||||
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 (
|
||||
@@ -212,7 +245,7 @@ export default function AsrVideosPage() {
|
||||
<p className="text-red-600">错误: {error}</p>
|
||||
<button
|
||||
type="button"
|
||||
onClick={fetchVideos}
|
||||
onClick={() => fetchVideos()}
|
||||
className="mt-4 px-4 py-2 bg-red-600 text-white rounded-lg hover:bg-red-700"
|
||||
>
|
||||
重试
|
||||
@@ -267,87 +300,121 @@ export default function AsrVideosPage() {
|
||||
</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
|
||||
type="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
|
||||
type="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
|
||||
type="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>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setFilter("tagged")}
|
||||
className={`px-4 py-2 rounded-lg 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={`px-4 py-2 rounded-lg 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="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>
|
||||
) : (
|
||||
"一键生成缺失标签"
|
||||
<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>
|
||||
<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>
|
||||
|
||||
@@ -429,28 +496,46 @@ export default function AsrVideosPage() {
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{video.transcript.content_tags.length > 0 && (
|
||||
{video.content_tags.length > 0 && (
|
||||
<div className="flex flex-wrap gap-1 pt-1">
|
||||
{video.transcript.content_tags.map((tag) => (
|
||||
{video.content_tags.map((tag) => (
|
||||
<span
|
||||
key={tag}
|
||||
className="inline-flex items-center rounded bg-blue-50 px-2 py-0.5 text-xs font-medium text-blue-700"
|
||||
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.transcript.content_tags.length === 0 && (
|
||||
{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>
|
||||
) : (
|
||||
<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>
|
||||
<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">
|
||||
@@ -466,7 +551,7 @@ export default function AsrVideosPage() {
|
||||
<span className="inline-block animate-spin rounded-full h-4 w-4 border-b-2 border-white"></span>
|
||||
生成中...
|
||||
</span>
|
||||
) : video.transcript.content_tags.length === 0 ? (
|
||||
) : !hasContentTags(video) ? (
|
||||
"生成标签"
|
||||
) : (
|
||||
"重新识别"
|
||||
|
||||
Reference in New Issue
Block a user