feat: 将视频转写重构为纯文字异步语音识别
This commit is contained in:
+201
-381
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,26 +1,23 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { getFileUrl } from "@/lib/minio";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { transcriptionState } from "@/lib/stt/types";
|
||||
|
||||
export async function GET() {
|
||||
try {
|
||||
const videos = await prisma.video.findMany({
|
||||
orderBy: { created_at: "desc" },
|
||||
take: 1000,
|
||||
include: {
|
||||
author: {
|
||||
select: {
|
||||
nickname: true,
|
||||
},
|
||||
},
|
||||
transcriptionTask: true,
|
||||
transcript: {
|
||||
select: {
|
||||
id: true,
|
||||
speech_detected: true,
|
||||
language: true,
|
||||
audio_type: true,
|
||||
transcript: true,
|
||||
non_speech_summary: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -29,21 +26,12 @@ export async function GET() {
|
||||
const formattedVideos = videos.map((v) => ({
|
||||
aweme_id: v.aweme_id,
|
||||
desc: v.desc,
|
||||
cover_url: getFileUrl(v.cover_url ?? ""),
|
||||
cover_url: v.cover_url ? getFileUrl(v.cover_url) : "",
|
||||
duration_ms: v.duration_ms,
|
||||
author: {
|
||||
nickname: v.author.nickname,
|
||||
},
|
||||
transcript: v.transcript
|
||||
? {
|
||||
id: v.transcript.id,
|
||||
speech_detected: v.transcript.speech_detected,
|
||||
language: v.transcript.language,
|
||||
audio_type: v.transcript.audio_type,
|
||||
transcript: v.transcript.transcript,
|
||||
non_speech_summary: v.transcript.non_speech_summary,
|
||||
}
|
||||
: null,
|
||||
transcription: transcriptionState(v.transcript, v.transcriptionTask),
|
||||
}));
|
||||
|
||||
return NextResponse.json({
|
||||
|
||||
@@ -21,7 +21,7 @@ import {
|
||||
releaseIsolatedContext,
|
||||
} from "@/app/api/fetcher/browser";
|
||||
import { extractFirstFrame } from "@/app/api/media";
|
||||
import { transcriptAweme } from "../stt";
|
||||
import { enqueueTranscription } from "@/lib/stt/tasks";
|
||||
|
||||
const DETAIL_PATH = "/aweme/v1/web/aweme/detail/";
|
||||
const COMMENT_PATH = "/aweme/v1/web/comment/list/";
|
||||
@@ -429,7 +429,11 @@ export async function scrapeDouyin(url: string) {
|
||||
fps ?? undefined,
|
||||
);
|
||||
console.log(chalk.green.bold("✓ 视频作品保存成功"));
|
||||
transcriptAweme(detail.aweme_detail.aweme_id).catch((e) => {}); // 异步转写,不阻塞主流程
|
||||
try {
|
||||
await enqueueTranscription(detail.aweme_detail.aweme_id);
|
||||
} catch {
|
||||
console.error("视频已保存,但转写任务入队失败,可在后台批量补转");
|
||||
}
|
||||
return { type: "video", ...saved };
|
||||
} else {
|
||||
throw new ScrapeError(
|
||||
|
||||
+16
-6
@@ -111,17 +111,17 @@ export async function getVideoDuration(
|
||||
}
|
||||
|
||||
/**
|
||||
* 使用 ffmpeg 将视频二进制转换为音频,返回音频 Buffer
|
||||
* 使用 ffmpeg 将视频 Buffer 或本地文件转换为音频,返回音频 Buffer
|
||||
*
|
||||
* 默认输出 mp3,可通过 opts.format 指定 'mp3' | 'aac' | 'wav'
|
||||
* - mp3: 使用 libmp3lame,contentType 为 'audio/mpeg'
|
||||
* - mp3: 使用 libmp3lame,16 kHz 单声道,contentType 为 'audio/mpeg'
|
||||
* - aac: 输出 m4a 容器(AAC 编码),contentType 为 'audio/mp4'
|
||||
* - wav: PCM 16-bit,44100Hz,双声道,contentType 为 'audio/wav'
|
||||
*
|
||||
* 若系统未安装 ffmpeg 或未在 PATH 中,返回 null 并给出提示。
|
||||
*/
|
||||
export async function extractAudio(
|
||||
videoBuffer: Buffer,
|
||||
videoBuffer: Buffer | string,
|
||||
opts?: { format?: "mp3" | "aac" | "wav"; bitrateKbps?: number },
|
||||
): Promise<{ buffer: Buffer; contentType: string; ext: string } | null> {
|
||||
const ffmpegCmd = process.env.FFMPEG_PATH || "ffmpeg";
|
||||
@@ -129,6 +129,7 @@ export async function extractAudio(
|
||||
const base = `dy_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`;
|
||||
const inPath = path.join(tmpDir, `${base}.mp4`);
|
||||
|
||||
const inputPath = typeof videoBuffer === "string" ? videoBuffer : inPath;
|
||||
const format = opts?.format ?? "mp3";
|
||||
const bitrate = Math.max(32, Math.min(512, opts?.bitrateKbps ?? 192)); // 安全范围 32~512 kbps
|
||||
|
||||
@@ -174,19 +175,28 @@ export async function extractAudio(
|
||||
}
|
||||
|
||||
try {
|
||||
await fs.writeFile(inPath, videoBuffer);
|
||||
if (typeof videoBuffer !== "string") await fs.writeFile(inPath, videoBuffer);
|
||||
const args = [
|
||||
"-hide_banner",
|
||||
"-loglevel",
|
||||
"error",
|
||||
"-i",
|
||||
inPath,
|
||||
inputPath,
|
||||
"-map",
|
||||
"0:a:0",
|
||||
"-ac",
|
||||
"1",
|
||||
"-ar",
|
||||
"16000",
|
||||
"-vn", // 丢弃视频流
|
||||
...codecArgs,
|
||||
"-y",
|
||||
outPath,
|
||||
];
|
||||
await execFileAsync(ffmpegCmd, args, { windowsHide: true });
|
||||
await execFileAsync(ffmpegCmd, args, {
|
||||
windowsHide: true,
|
||||
timeout: 60 * 60 * 1000,
|
||||
});
|
||||
const audio = await fs.readFile(outPath);
|
||||
return { buffer: audio, contentType, ext };
|
||||
} catch (e: any) {
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { getAsrConfig } from "@/lib/stt/dashscope";
|
||||
import { enqueueMissingTranscriptions } from "@/lib/stt/tasks";
|
||||
export const runtime = "nodejs";
|
||||
export async function POST() {
|
||||
try {
|
||||
getAsrConfig();
|
||||
} catch (error) {
|
||||
return NextResponse.json(
|
||||
{ error: (error as Error).message },
|
||||
{ status: 503 },
|
||||
);
|
||||
}
|
||||
try {
|
||||
return NextResponse.json(
|
||||
{ queued: await enqueueMissingTranscriptions() },
|
||||
{ status: 202 },
|
||||
);
|
||||
} catch {
|
||||
return NextResponse.json({ error: "批量任务入队失败" }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -1,113 +0,0 @@
|
||||
import fs from "fs";
|
||||
import OpenAI from "openai";
|
||||
import prompt from "./prompt.md";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { downloadFile } from "@/lib/minio";
|
||||
import { extractAudio } from "../media";
|
||||
import { z } from "zod";
|
||||
import { zodResponseFormat } from "openai/helpers/zod";
|
||||
import { ProxyAgent, setGlobalDispatcher } from "undici";
|
||||
|
||||
const proxy = process.env.HTTPS_PROXY || process.env.HTTP_PROXY;
|
||||
if (proxy) {
|
||||
setGlobalDispatcher(new ProxyAgent(proxy));
|
||||
}
|
||||
|
||||
const client = new OpenAI({
|
||||
apiKey: process.env.OPENAI_API_KEY,
|
||||
baseURL: process.env.OPENAI_API_BASE_URL,
|
||||
});
|
||||
|
||||
// Zod schema aligned with prompt.md
|
||||
export const SttSchema = z
|
||||
.object({
|
||||
speech_detected: z.boolean(),
|
||||
language: z.string().min(1).nullable(),
|
||||
audio_type: z.string().nullable(),
|
||||
transcript: z.array(z.string()),
|
||||
non_speech_summary: z.string().nullable(),
|
||||
})
|
||||
.strict();
|
||||
|
||||
export type SttResult = z.infer<typeof SttSchema>;
|
||||
|
||||
async function transcriptAudio(audio: Buffer | string) {
|
||||
if (typeof audio === "string") {
|
||||
audio = fs.readFileSync(audio);
|
||||
}
|
||||
const base64Audio = Buffer.from(audio).toString("base64");
|
||||
|
||||
const completion = await client.chat.completions.create({
|
||||
model: "gemini-2.5-flash",
|
||||
messages: [
|
||||
{
|
||||
role: "user",
|
||||
content: [
|
||||
{ type: "text", text: prompt },
|
||||
{
|
||||
type: "input_audio",
|
||||
input_audio: { data: base64Audio, format: "mp3" },
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
response_format: zodResponseFormat(SttSchema, "stt_result"),
|
||||
});
|
||||
|
||||
const data = completion.choices?.[0]?.message;
|
||||
console.log("转写结果", data.content);
|
||||
|
||||
if (!data || !data.content) {
|
||||
throw new Error("No STT result returned from model");
|
||||
}
|
||||
try {
|
||||
return JSON.parse(data.content) as SttResult;
|
||||
} catch (e) {
|
||||
throw new Error("Failed to parse STT result JSON");
|
||||
}
|
||||
}
|
||||
|
||||
export async function transcriptAweme(awemeId: string): Promise<SttResult> {
|
||||
const aweme = await prisma.video.findUnique({
|
||||
where: { aweme_id: awemeId },
|
||||
});
|
||||
if (!aweme) {
|
||||
throw new Error("Aweme not found or aweme is not a video post");
|
||||
}
|
||||
const vPath = aweme.video_url;
|
||||
const buffer = await downloadFile(vPath);
|
||||
|
||||
const audioDat = await extractAudio(buffer, {
|
||||
format: "mp3",
|
||||
bitrateKbps: 128,
|
||||
});
|
||||
|
||||
if (!audioDat || !audioDat.buffer) {
|
||||
throw new Error("Failed to extract audio from video");
|
||||
}
|
||||
|
||||
// 调用大模型生成转写结果
|
||||
const result = await transcriptAudio(audioDat.buffer);
|
||||
|
||||
// 将转写结果持久化到数据库
|
||||
await prisma.videoTranscript.upsert({
|
||||
where: { videoId: awemeId },
|
||||
update: {
|
||||
speech_detected: result.speech_detected,
|
||||
language: result.language,
|
||||
audio_type: result.audio_type,
|
||||
transcript: result.transcript,
|
||||
non_speech_summary: result.non_speech_summary,
|
||||
},
|
||||
create: {
|
||||
videoId: awemeId,
|
||||
speech_detected: result.speech_detected,
|
||||
language: result.language,
|
||||
audio_type: result.audio_type,
|
||||
transcript: result.transcript,
|
||||
non_speech_summary: result.non_speech_summary,
|
||||
},
|
||||
});
|
||||
|
||||
return result;
|
||||
}
|
||||
@@ -1,29 +0,0 @@
|
||||
你将接收一段音频。请完成:你将接收一段音频。语音活动检测(VAD)与声源分类。
|
||||
|
||||
**输出 JSON Schema(示例)**
|
||||
|
||||
```json
|
||||
{
|
||||
"speech_detected": true,
|
||||
"language": "zh-CN",
|
||||
"audio_type": null,
|
||||
"transcript": ["大家好,我是xxx", "欢迎来到今天的视频", "今天我们来聊聊AI的未来"],
|
||||
"non_speech_summary": null,
|
||||
}
|
||||
```
|
||||
|
||||
**当无发言时返回:**
|
||||
|
||||
```json
|
||||
{
|
||||
"speech_detected": false,
|
||||
"language": null,
|
||||
"audio_type": "music | ambience | animal | mechanical | other",
|
||||
"transcript": [],
|
||||
"non_speech_summary": "示例:纯音乐-钢琴独奏,节奏舒缓;或 环境声-雨声伴随雷鸣。",
|
||||
}
|
||||
```
|
||||
|
||||
** 规则补充 **
|
||||
|
||||
* 允许`language` 为多标签(如 "zh-CN,en")或为 `null`(无发言时)。
|
||||
+29
-19
@@ -1,23 +1,33 @@
|
||||
import { type NextRequest, NextResponse } from "next/server";
|
||||
import { enqueueTranscription, readTranscription } from "@/lib/stt/tasks";
|
||||
import { isActive } from "@/lib/stt/types";
|
||||
export const runtime = "nodejs";
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import type { FeedItem, FeedResponse } from "@/app/types/feed";
|
||||
import { getFileUrl } from "@/lib/minio";
|
||||
import { transcriptAweme } from ".";
|
||||
|
||||
// Contract
|
||||
// Inputs: search params { before?: ISOString, limit?: number }
|
||||
// Output: { items: FeedItem[], nextCursor: ISOString | null }
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
const validId = (id: unknown): id is string =>
|
||||
typeof id === "string" && /^[A-Za-z0-9_-]{1,128}$/.test(id);
|
||||
export async function GET(req: NextRequest) {
|
||||
const { searchParams } = new URL(req.url);
|
||||
const awemeId = searchParams.get("awemeId");
|
||||
if (!awemeId) {
|
||||
return NextResponse.json(
|
||||
{ error: "Missing awemeId parameter" },
|
||||
{ status: 400 },
|
||||
);
|
||||
const id = req.nextUrl.searchParams.get("awemeId");
|
||||
if (!validId(id))
|
||||
return NextResponse.json({ error: "需要有效的 awemeId" }, { status: 400 });
|
||||
try {
|
||||
const state = await readTranscription(id);
|
||||
return state
|
||||
? NextResponse.json(state)
|
||||
: NextResponse.json({ error: "视频不存在" }, { status: 404 });
|
||||
} catch {
|
||||
return NextResponse.json({ error: "读取转写状态失败" }, { status: 500 });
|
||||
}
|
||||
}
|
||||
export async function POST(req: NextRequest) {
|
||||
const body = await req.json().catch(() => null);
|
||||
if (!validId(body?.awemeId))
|
||||
return NextResponse.json({ error: "需要有效的 awemeId" }, { status: 400 });
|
||||
try {
|
||||
const state = await enqueueTranscription(body.awemeId, true);
|
||||
return state
|
||||
? NextResponse.json(state, { status: isActive(state.status) ? 202 : 200 })
|
||||
: NextResponse.json({ error: "视频不存在" }, { status: 404 });
|
||||
} catch {
|
||||
return NextResponse.json({ error: "转写任务入队失败" }, { status: 500 });
|
||||
}
|
||||
const script = await transcriptAweme(awemeId);
|
||||
return NextResponse.json(script);
|
||||
}
|
||||
|
||||
@@ -556,7 +556,7 @@ export default function AwemeDetailClient({
|
||||
segmentProgress={imageCarouselState.segProgress}
|
||||
musicUrl={isVideo ? undefined : (data as ImageData).music_url}
|
||||
audioRef={audioRef}
|
||||
hasTranscript={isVideo && !!transcript?.speech_detected}
|
||||
hasTranscript={isVideo && !!transcript}
|
||||
onShowTranscript={() => setTranscriptOpen(true)}
|
||||
onTogglePlay={togglePlay}
|
||||
onSeek={handleControlSeek}
|
||||
|
||||
@@ -36,7 +36,7 @@ export function TranscriptPanel({
|
||||
});
|
||||
};
|
||||
|
||||
if (!transcript?.speech_detected || !transcript.transcript?.length) {
|
||||
if (!transcript?.transcript?.length) {
|
||||
return (
|
||||
<div
|
||||
className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm"
|
||||
@@ -60,7 +60,7 @@ export function TranscriptPanel({
|
||||
|
||||
{/* 内容 */}
|
||||
<div className="p-8 text-center text-white/60">
|
||||
<p>该视频暂无语音转录内容</p>
|
||||
<p>{transcript ? "未识别到文字" : "该视频尚未转写"}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -80,11 +80,7 @@ export function TranscriptPanel({
|
||||
<div className="flex items-center justify-between p-4 border-b border-white/10 flex-shrink-0">
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold text-white">语音转录</h2>
|
||||
{transcript.language && (
|
||||
<p className="text-xs text-white/50 mt-1">
|
||||
语言: {transcript.language}
|
||||
</p>
|
||||
)}
|
||||
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
|
||||
@@ -53,8 +53,6 @@ export type ImageData = {
|
||||
export type AwemeData = VideoData | ImageData;
|
||||
|
||||
export type VideoTranscript = {
|
||||
speech_detected: boolean;
|
||||
language: string | null;
|
||||
transcript: string[];
|
||||
};
|
||||
|
||||
|
||||
Reference in New Issue
Block a user