feat: add manual video tagging
This commit is contained in:
+130
-45
@@ -20,6 +20,8 @@ type Video = {
|
|||||||
desc: string;
|
desc: string;
|
||||||
cover_url: string;
|
cover_url: string;
|
||||||
duration_ms: number;
|
duration_ms: number;
|
||||||
|
content_tags: string[];
|
||||||
|
manual_content_tags: string[];
|
||||||
author: {
|
author: {
|
||||||
nickname: string;
|
nickname: string;
|
||||||
};
|
};
|
||||||
@@ -40,11 +42,19 @@ export default function AsrVideosPage() {
|
|||||||
"all" | "transcribed" | "pending" | "tagged" | "missingTags"
|
"all" | "transcribed" | "pending" | "tagged" | "missingTags"
|
||||||
>("all");
|
>("all");
|
||||||
const [batchTranscribing, setBatchTranscribing] = useState(false);
|
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 {
|
try {
|
||||||
setLoading(true);
|
if (showLoading) setLoading(true);
|
||||||
const response = await fetch("/api/admin/asr/videos");
|
const response = await fetch("/api/admin/asr/videos");
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
throw new Error("Failed to fetch videos");
|
throw new Error("Failed to fetch videos");
|
||||||
@@ -54,7 +64,7 @@ export default function AsrVideosPage() {
|
|||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError(err instanceof Error ? err.message : "Unknown error");
|
setError(err instanceof Error ? err.message : "Unknown error");
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
if (showLoading) setLoading(false);
|
||||||
}
|
}
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
@@ -66,8 +76,7 @@ export default function AsrVideosPage() {
|
|||||||
const sleep = (ms: number) =>
|
const sleep = (ms: number) =>
|
||||||
new Promise((resolve) => setTimeout(resolve, ms));
|
new Promise((resolve) => setTimeout(resolve, ms));
|
||||||
|
|
||||||
const hasContentTags = (video: Video) =>
|
const hasContentTags = (video: Video) => video.content_tags.length > 0;
|
||||||
(video.transcript?.content_tags.length ?? 0) > 0;
|
|
||||||
|
|
||||||
type PollOptions = {
|
type PollOptions = {
|
||||||
intervalMs?: number;
|
intervalMs?: number;
|
||||||
@@ -77,7 +86,7 @@ export default function AsrVideosPage() {
|
|||||||
// 轮询某个视频的识别和标签状态直至完成或超时
|
// 轮询某个视频的识别和标签状态直至完成或超时
|
||||||
const pollVideoUntilTranscribed = async (
|
const pollVideoUntilTranscribed = async (
|
||||||
awemeId: string,
|
awemeId: string,
|
||||||
{ intervalMs = 2000, maxAttempts = 30 }: PollOptions = {},
|
{ intervalMs = 2000, maxAttempts = 300 }: PollOptions = {},
|
||||||
): Promise<boolean> => {
|
): Promise<boolean> => {
|
||||||
for (let attempt = 0; attempt < maxAttempts; attempt++) {
|
for (let attempt = 0; attempt < maxAttempts; attempt++) {
|
||||||
try {
|
try {
|
||||||
@@ -86,7 +95,7 @@ export default function AsrVideosPage() {
|
|||||||
const data: ApiResponse = await resp.json();
|
const data: ApiResponse = await resp.json();
|
||||||
setVideos(data.videos);
|
setVideos(data.videos);
|
||||||
const v = data.videos.find((x) => x.aweme_id === awemeId);
|
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;
|
return true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -101,7 +110,9 @@ export default function AsrVideosPage() {
|
|||||||
const handleTranscribe = async (awemeId: string) => {
|
const handleTranscribe = async (awemeId: string) => {
|
||||||
setTranscribing((prev) => new Set(prev).add(awemeId));
|
setTranscribing((prev) => new Set(prev).add(awemeId));
|
||||||
try {
|
try {
|
||||||
const response = await fetch(`/api/asr?awemeId=${awemeId}`);
|
const response = await fetch(
|
||||||
|
`/api/asr?awemeId=${encodeURIComponent(awemeId)}&async=1`,
|
||||||
|
);
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
throw new Error("Failed to transcribe video");
|
throw new Error("Failed to transcribe video");
|
||||||
}
|
}
|
||||||
@@ -109,7 +120,7 @@ export default function AsrVideosPage() {
|
|||||||
const done = await pollVideoUntilTranscribed(awemeId);
|
const done = await pollVideoUntilTranscribed(awemeId);
|
||||||
if (!done) {
|
if (!done) {
|
||||||
// 兜底刷新一次
|
// 兜底刷新一次
|
||||||
await fetchVideos();
|
await fetchVideos(false);
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
alert(err instanceof Error ? err.message : "Transcription failed");
|
alert(err instanceof Error ? err.message : "Transcription failed");
|
||||||
@@ -138,41 +149,58 @@ export default function AsrVideosPage() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
setBatchTranscribing(true);
|
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++) {
|
for (let i = 0; i < pendingVideos.length; i++) {
|
||||||
const video = pendingVideos[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));
|
setTranscribing((prev) => new Set(prev).add(video.aweme_id));
|
||||||
|
|
||||||
try {
|
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) {
|
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(() => {
|
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) => {
|
setTranscribing((prev) => {
|
||||||
const next = new Set(prev);
|
const next = new Set(prev);
|
||||||
next.delete(video.aweme_id);
|
next.delete(video.aweme_id);
|
||||||
return next;
|
return next;
|
||||||
});
|
});
|
||||||
});
|
|
||||||
pollers.push(p);
|
|
||||||
} catch (err) {
|
|
||||||
console.error(`Error transcribing video ${video.aweme_id}:`, err);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 等待所有轮询结束(完成或超时),再进行最终刷新
|
await fetchVideos(false);
|
||||||
if (pollers.length > 0) {
|
|
||||||
await Promise.allSettled(pollers);
|
|
||||||
}
|
|
||||||
await fetchVideos();
|
|
||||||
setBatchTranscribing(false);
|
setBatchTranscribing(false);
|
||||||
setBatchProgress({ current: 0, total: 0 });
|
setBatchMessage("缺失标签生成流程已结束");
|
||||||
alert("缺失标签生成完成!");
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const filteredVideos = videos.filter((v) => {
|
const filteredVideos = videos.filter((v) => {
|
||||||
@@ -190,6 +218,11 @@ export default function AsrVideosPage() {
|
|||||||
tagged: videos.filter((v) => hasContentTags(v)).length,
|
tagged: videos.filter((v) => hasContentTags(v)).length,
|
||||||
missingTags: 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) {
|
if (loading) {
|
||||||
return (
|
return (
|
||||||
@@ -212,7 +245,7 @@ export default function AsrVideosPage() {
|
|||||||
<p className="text-red-600">错误: {error}</p>
|
<p className="text-red-600">错误: {error}</p>
|
||||||
<button
|
<button
|
||||||
type="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"
|
className="mt-4 px-4 py-2 bg-red-600 text-white rounded-lg hover:bg-red-700"
|
||||||
>
|
>
|
||||||
重试
|
重试
|
||||||
@@ -267,13 +300,14 @@ export default function AsrVideosPage() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Filter */}
|
{/* Filter */}
|
||||||
<div className="bg-white rounded-lg shadow p-4 mb-6">
|
<div className="mb-6 rounded-lg bg-white p-4 shadow">
|
||||||
<div className="flex items-center justify-between">
|
<div className="space-y-4">
|
||||||
<div className="flex gap-4">
|
<div className="flex flex-wrap items-center justify-between gap-4">
|
||||||
|
<div className="flex flex-wrap gap-3">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => setFilter("all")}
|
onClick={() => setFilter("all")}
|
||||||
className={`px-4 py-2 rounded-lg font-medium transition-colors ${
|
className={`rounded-lg px-4 py-2 font-medium transition-colors ${
|
||||||
filter === "all"
|
filter === "all"
|
||||||
? "bg-blue-600 text-white"
|
? "bg-blue-600 text-white"
|
||||||
: "bg-gray-100 text-gray-700 hover:bg-gray-200"
|
: "bg-gray-100 text-gray-700 hover:bg-gray-200"
|
||||||
@@ -284,7 +318,7 @@ export default function AsrVideosPage() {
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => setFilter("transcribed")}
|
onClick={() => setFilter("transcribed")}
|
||||||
className={`px-4 py-2 rounded-lg font-medium transition-colors ${
|
className={`rounded-lg px-4 py-2 font-medium transition-colors ${
|
||||||
filter === "transcribed"
|
filter === "transcribed"
|
||||||
? "bg-green-600 text-white"
|
? "bg-green-600 text-white"
|
||||||
: "bg-gray-100 text-gray-700 hover:bg-gray-200"
|
: "bg-gray-100 text-gray-700 hover:bg-gray-200"
|
||||||
@@ -295,7 +329,7 @@ export default function AsrVideosPage() {
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => setFilter("pending")}
|
onClick={() => setFilter("pending")}
|
||||||
className={`px-4 py-2 rounded-lg font-medium transition-colors ${
|
className={`rounded-lg px-4 py-2 font-medium transition-colors ${
|
||||||
filter === "pending"
|
filter === "pending"
|
||||||
? "bg-orange-600 text-white"
|
? "bg-orange-600 text-white"
|
||||||
: "bg-gray-100 text-gray-700 hover:bg-gray-200"
|
: "bg-gray-100 text-gray-700 hover:bg-gray-200"
|
||||||
@@ -306,7 +340,7 @@ export default function AsrVideosPage() {
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => setFilter("tagged")}
|
onClick={() => setFilter("tagged")}
|
||||||
className={`px-4 py-2 rounded-lg font-medium transition-colors ${
|
className={`rounded-lg px-4 py-2 font-medium transition-colors ${
|
||||||
filter === "tagged"
|
filter === "tagged"
|
||||||
? "bg-blue-600 text-white"
|
? "bg-blue-600 text-white"
|
||||||
: "bg-gray-100 text-gray-700 hover:bg-gray-200"
|
: "bg-gray-100 text-gray-700 hover:bg-gray-200"
|
||||||
@@ -317,7 +351,7 @@ export default function AsrVideosPage() {
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => setFilter("missingTags")}
|
onClick={() => setFilter("missingTags")}
|
||||||
className={`px-4 py-2 rounded-lg font-medium transition-colors ${
|
className={`rounded-lg px-4 py-2 font-medium transition-colors ${
|
||||||
filter === "missingTags"
|
filter === "missingTags"
|
||||||
? "bg-rose-600 text-white"
|
? "bg-rose-600 text-white"
|
||||||
: "bg-gray-100 text-gray-700 hover:bg-gray-200"
|
: "bg-gray-100 text-gray-700 hover:bg-gray-200"
|
||||||
@@ -326,6 +360,7 @@ export default function AsrVideosPage() {
|
|||||||
缺标签 ({stats.missingTags})
|
缺标签 ({stats.missingTags})
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex items-center gap-4">
|
<div className="flex items-center gap-4">
|
||||||
{batchTranscribing && (
|
{batchTranscribing && (
|
||||||
<div className="text-sm text-gray-600">
|
<div className="text-sm text-gray-600">
|
||||||
@@ -336,19 +371,51 @@ export default function AsrVideosPage() {
|
|||||||
type="button"
|
type="button"
|
||||||
onClick={handleBatchTranscribe}
|
onClick={handleBatchTranscribe}
|
||||||
disabled={batchTranscribing || stats.missingTags === 0}
|
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"
|
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 ? (
|
{batchTranscribing ? (
|
||||||
<span className="flex items-center gap-2">
|
<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 className="inline-block h-4 w-4 animate-spin rounded-full border-b-2 border-white" />
|
||||||
生成中...
|
生成中...
|
||||||
</span>
|
</span>
|
||||||
) : (
|
) : (
|
||||||
"一键生成缺失标签"
|
`一键生成缺失标签 (${stats.missingTags})`
|
||||||
)}
|
)}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</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>
|
</div>
|
||||||
|
|
||||||
{/* Video List */}
|
{/* Video List */}
|
||||||
@@ -429,28 +496,46 @@ export default function AsrVideosPage() {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{video.transcript.content_tags.length > 0 && (
|
{video.content_tags.length > 0 && (
|
||||||
<div className="flex flex-wrap gap-1 pt-1">
|
<div className="flex flex-wrap gap-1 pt-1">
|
||||||
{video.transcript.content_tags.map((tag) => (
|
{video.content_tags.map((tag) => (
|
||||||
<span
|
<span
|
||||||
key={tag}
|
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}
|
{tag}
|
||||||
</span>
|
</span>
|
||||||
))}
|
))}
|
||||||
</div>
|
</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 className="inline-flex items-center rounded bg-rose-50 px-2 py-0.5 text-xs font-medium text-rose-700">
|
||||||
未生成标签
|
未生成标签
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</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 className="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-orange-100 text-orange-800">
|
||||||
待转写
|
待转写
|
||||||
</span>
|
</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>
|
||||||
<td className="px-6 py-4 whitespace-nowrap text-sm">
|
<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 className="inline-block animate-spin rounded-full h-4 w-4 border-b-2 border-white"></span>
|
||||||
生成中...
|
生成中...
|
||||||
</span>
|
</span>
|
||||||
) : video.transcript.content_tags.length === 0 ? (
|
) : !hasContentTags(video) ? (
|
||||||
"生成标签"
|
"生成标签"
|
||||||
) : (
|
) : (
|
||||||
"重新识别"
|
"重新识别"
|
||||||
|
|||||||
@@ -27,11 +27,19 @@ export async function GET() {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const formattedVideos = videos.map((v) => ({
|
const formattedVideos = videos.map((v) => {
|
||||||
|
const contentTags =
|
||||||
|
v.manual_content_tags.length > 0
|
||||||
|
? v.manual_content_tags
|
||||||
|
: (v.transcript?.content_tags ?? []);
|
||||||
|
|
||||||
|
return {
|
||||||
aweme_id: v.aweme_id,
|
aweme_id: v.aweme_id,
|
||||||
desc: v.desc,
|
desc: v.desc,
|
||||||
cover_url: getFileUrl(v.cover_url ?? ""),
|
cover_url: getFileUrl(v.cover_url ?? ""),
|
||||||
duration_ms: v.duration_ms,
|
duration_ms: v.duration_ms,
|
||||||
|
manual_content_tags: v.manual_content_tags,
|
||||||
|
content_tags: contentTags,
|
||||||
author: {
|
author: {
|
||||||
nickname: v.author.nickname,
|
nickname: v.author.nickname,
|
||||||
},
|
},
|
||||||
@@ -46,7 +54,8 @@ export async function GET() {
|
|||||||
content_tags: v.transcript.content_tags,
|
content_tags: v.transcript.content_tags,
|
||||||
}
|
}
|
||||||
: null,
|
: null,
|
||||||
}));
|
};
|
||||||
|
});
|
||||||
|
|
||||||
return NextResponse.json({
|
return NextResponse.json({
|
||||||
videos: formattedVideos,
|
videos: formattedVideos,
|
||||||
|
|||||||
+36
-1
@@ -3,6 +3,30 @@ export const runtime = "nodejs";
|
|||||||
import { type NextRequest, NextResponse } from "next/server";
|
import { type NextRequest, NextResponse } from "next/server";
|
||||||
import { transcriptAweme } from ".";
|
import { transcriptAweme } from ".";
|
||||||
|
|
||||||
|
const runningJobs = new Map<string, Promise<void>>();
|
||||||
|
|
||||||
|
function startBackgroundAsr(awemeId: string) {
|
||||||
|
if (runningJobs.has(awemeId)) return false;
|
||||||
|
|
||||||
|
const job = transcriptAweme(awemeId, {
|
||||||
|
log: (message) => console.log(`[ASR ${awemeId}] ${message}`),
|
||||||
|
})
|
||||||
|
.then((result) => {
|
||||||
|
console.log(
|
||||||
|
`[ASR ${awemeId}] done tags=${result.content_tags.join("、")}`,
|
||||||
|
);
|
||||||
|
})
|
||||||
|
.catch((error) => {
|
||||||
|
console.error(`[ASR ${awemeId}] failed:`, error);
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
runningJobs.delete(awemeId);
|
||||||
|
});
|
||||||
|
|
||||||
|
runningJobs.set(awemeId, job);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
export async function GET(req: NextRequest) {
|
export async function GET(req: NextRequest) {
|
||||||
const { searchParams } = new URL(req.url);
|
const { searchParams } = new URL(req.url);
|
||||||
const awemeId = searchParams.get("awemeId");
|
const awemeId = searchParams.get("awemeId");
|
||||||
@@ -12,8 +36,19 @@ export async function GET(req: NextRequest) {
|
|||||||
{ status: 400 },
|
{ status: 400 },
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
const asyncMode =
|
||||||
|
searchParams.get("async") === "1" || searchParams.get("mode") === "async";
|
||||||
|
|
||||||
|
if (asyncMode) {
|
||||||
|
const started = startBackgroundAsr(awemeId);
|
||||||
|
return NextResponse.json(
|
||||||
|
{ awemeId, status: started ? "started" : "running" },
|
||||||
|
{ status: 202 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const result = await transcriptAweme(awemeId, /* {log: (msg) => console.log(`[ASR] ${msg}`)} */);
|
const result = await transcriptAweme(awemeId);
|
||||||
return NextResponse.json(result);
|
return NextResponse.json(result);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("ASR failed:", error);
|
console.error("ASR failed:", error);
|
||||||
|
|||||||
@@ -0,0 +1,127 @@
|
|||||||
|
export const runtime = "nodejs";
|
||||||
|
|
||||||
|
import { type NextRequest, NextResponse } from "next/server";
|
||||||
|
import { isAsrContentTag } from "@/lib/asr-tags";
|
||||||
|
import { prisma } from "@/lib/prisma";
|
||||||
|
|
||||||
|
type UpdateMode = "set" | "add";
|
||||||
|
|
||||||
|
function uniqueValidTags(tags: unknown[]) {
|
||||||
|
const validTags: string[] = [];
|
||||||
|
for (const tag of tags) {
|
||||||
|
if (typeof tag !== "string" || !isAsrContentTag(tag)) {
|
||||||
|
throw new Error("Invalid tag");
|
||||||
|
}
|
||||||
|
if (!validTags.includes(tag)) validTags.push(tag);
|
||||||
|
}
|
||||||
|
return validTags;
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveContentTags(manualTags: string[], asrTags: string[]) {
|
||||||
|
return manualTags.length > 0 ? manualTags : asrTags;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function POST(req: NextRequest) {
|
||||||
|
let body: unknown;
|
||||||
|
try {
|
||||||
|
body = await req.json();
|
||||||
|
} catch {
|
||||||
|
return NextResponse.json({ error: "Invalid JSON body" }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!body || typeof body !== "object") {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "Invalid request body" },
|
||||||
|
{ status: 400 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const raw = body as {
|
||||||
|
awemeId?: unknown;
|
||||||
|
awemeIds?: unknown;
|
||||||
|
tag?: unknown;
|
||||||
|
contentTags?: unknown;
|
||||||
|
mode?: unknown;
|
||||||
|
};
|
||||||
|
const awemeIds = Array.isArray(raw.awemeIds)
|
||||||
|
? raw.awemeIds
|
||||||
|
: typeof raw.awemeId === "string"
|
||||||
|
? [raw.awemeId]
|
||||||
|
: [];
|
||||||
|
const normalizedAwemeIds = [...new Set(awemeIds)]
|
||||||
|
.filter(
|
||||||
|
(id): id is string => typeof id === "string" && id.trim().length > 0,
|
||||||
|
)
|
||||||
|
.map((id) => id.trim());
|
||||||
|
|
||||||
|
if (normalizedAwemeIds.length === 0 || normalizedAwemeIds.length > 200) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "awemeIds must contain 1 to 200 ids" },
|
||||||
|
{ status: 400 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const mode: UpdateMode = raw.mode === "add" ? "add" : "set";
|
||||||
|
const rawTags = Array.isArray(raw.contentTags)
|
||||||
|
? raw.contentTags
|
||||||
|
: typeof raw.tag === "string"
|
||||||
|
? [raw.tag]
|
||||||
|
: [];
|
||||||
|
|
||||||
|
let contentTags: string[];
|
||||||
|
try {
|
||||||
|
contentTags = uniqueValidTags(rawTags);
|
||||||
|
} catch {
|
||||||
|
return NextResponse.json({ error: "Invalid tag" }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (mode === "add" && contentTags.length === 0) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "add mode requires at least one tag" },
|
||||||
|
{ status: 400 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const videos = await prisma.video.findMany({
|
||||||
|
where: { aweme_id: { in: normalizedAwemeIds } },
|
||||||
|
select: {
|
||||||
|
aweme_id: true,
|
||||||
|
manual_content_tags: true,
|
||||||
|
transcript: { select: { content_tags: true } },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (videos.length === 0) {
|
||||||
|
return NextResponse.json({ error: "No videos found" }, { status: 404 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const updates = videos.map((video) => {
|
||||||
|
const asrTags = video.transcript?.content_tags ?? [];
|
||||||
|
const baseTags = resolveContentTags(video.manual_content_tags, asrTags);
|
||||||
|
const manualTags =
|
||||||
|
mode === "add"
|
||||||
|
? [...new Set([...baseTags, ...contentTags])]
|
||||||
|
: contentTags;
|
||||||
|
return { awemeId: video.aweme_id, manualTags, asrTags };
|
||||||
|
});
|
||||||
|
|
||||||
|
await prisma.$transaction(
|
||||||
|
updates.map((update) =>
|
||||||
|
prisma.video.update({
|
||||||
|
where: { aweme_id: update.awemeId },
|
||||||
|
data: { manual_content_tags: update.manualTags },
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
updated: updates.map((update) => ({
|
||||||
|
aweme_id: update.awemeId,
|
||||||
|
manual_content_tags: update.manualTags,
|
||||||
|
content_tags: resolveContentTags(update.manualTags, update.asrTags),
|
||||||
|
})),
|
||||||
|
missing: normalizedAwemeIds.filter(
|
||||||
|
(id) => !videos.some((video) => video.aweme_id === id),
|
||||||
|
),
|
||||||
|
});
|
||||||
|
}
|
||||||
+14
-3
@@ -1,6 +1,6 @@
|
|||||||
import { type NextRequest, NextResponse } from "next/server";
|
import { type NextRequest, NextResponse } from "next/server";
|
||||||
import type { FeedItem, FeedResponse } from "@/app/types/feed";
|
import type { FeedItem, FeedResponse } from "@/app/types/feed";
|
||||||
import { isAsrContentTag } from "@/lib/asr-tags";
|
import { isAsrContentTag, resolveEffectiveContentTags } from "@/lib/asr-tags";
|
||||||
import { getFileUrl } from "@/lib/minio";
|
import { getFileUrl } from "@/lib/minio";
|
||||||
import { prisma } from "@/lib/prisma";
|
import { prisma } from "@/lib/prisma";
|
||||||
|
|
||||||
@@ -24,7 +24,15 @@ export async function GET(req: NextRequest) {
|
|||||||
const videoWhere = {
|
const videoWhere = {
|
||||||
...(before ? { created_at: { lt: before } } : {}),
|
...(before ? { created_at: { lt: before } } : {}),
|
||||||
...(selectedTag
|
...(selectedTag
|
||||||
? { transcript: { is: { content_tags: { has: selectedTag } } } }
|
? {
|
||||||
|
OR: [
|
||||||
|
{ manual_content_tags: { has: selectedTag } },
|
||||||
|
{
|
||||||
|
manual_content_tags: { isEmpty: true },
|
||||||
|
transcript: { is: { content_tags: { has: selectedTag } } },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}
|
||||||
: {}),
|
: {}),
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -68,7 +76,10 @@ export async function GET(req: NextRequest) {
|
|||||||
sec_uid: v.author.sec_uid,
|
sec_uid: v.author.sec_uid,
|
||||||
},
|
},
|
||||||
likes: Number(v.digg_count),
|
likes: Number(v.digg_count),
|
||||||
content_tags: v.transcript?.content_tags ?? [],
|
content_tags: resolveEffectiveContentTags(
|
||||||
|
v.manual_content_tags,
|
||||||
|
v.transcript?.content_tags,
|
||||||
|
),
|
||||||
})),
|
})),
|
||||||
...posts.map((p) => {
|
...posts.map((p) => {
|
||||||
const coverPath = p.images?.[0]?.url;
|
const coverPath = p.images?.[0]?.url;
|
||||||
|
|||||||
+25
-10
@@ -78,7 +78,10 @@ export async function GET(req: Request) {
|
|||||||
'StartSel=<mark>,StopSel=</mark>,MaxFragments=2,MinWords=2,MaxWords=20'
|
'StartSel=<mark>,StopSel=</mark>,MaxFragments=2,MinWords=2,MaxWords=20'
|
||||||
)
|
)
|
||||||
END AS snippet,
|
END AS snippet,
|
||||||
COALESCE(vt.content_tags, ARRAY[]::text[]) AS "contentTags"
|
CASE
|
||||||
|
WHEN cardinality(v.manual_content_tags) > 0 THEN v.manual_content_tags
|
||||||
|
ELSE COALESCE(vt.content_tags, ARRAY[]::text[])
|
||||||
|
END AS "contentTags"
|
||||||
FROM "Video" v
|
FROM "Video" v
|
||||||
LEFT JOIN "VideoTranscript" vt ON vt."videoId" = v.aweme_id,
|
LEFT JOIN "VideoTranscript" vt ON vt."videoId" = v.aweme_id,
|
||||||
tsq
|
tsq
|
||||||
@@ -86,7 +89,8 @@ export async function GET(req: Request) {
|
|||||||
(vt.transcript_zh_tsv @@ tsq.query OR to_tsvector('zhcfg', v.desc) @@ tsq.query)
|
(vt.transcript_zh_tsv @@ tsq.query OR to_tsvector('zhcfg', v.desc) @@ tsq.query)
|
||||||
AND CASE
|
AND CASE
|
||||||
WHEN ${contentTag}::text IS NULL THEN TRUE
|
WHEN ${contentTag}::text IS NULL THEN TRUE
|
||||||
ELSE vt.content_tags @> ARRAY[${contentTag}]::text[]
|
WHEN cardinality(v.manual_content_tags) > 0 THEN v.manual_content_tags @> ARRAY[${contentTag}]::text[]
|
||||||
|
ELSE COALESCE(vt.content_tags, ARRAY[]::text[]) @> ARRAY[${contentTag}]::text[]
|
||||||
END
|
END
|
||||||
|
|
||||||
UNION ALL
|
UNION ALL
|
||||||
@@ -114,7 +118,7 @@ export async function GET(req: Request) {
|
|||||||
`
|
`
|
||||||
: await prisma.$queryRaw<SearchRow[]>`
|
: await prisma.$queryRaw<SearchRow[]>`
|
||||||
SELECT
|
SELECT
|
||||||
vt.id AS id,
|
COALESCE(vt.id, 'tag:' || v.aweme_id) AS id,
|
||||||
v.aweme_id AS "awemeId",
|
v.aweme_id AS "awemeId",
|
||||||
'video'::text AS type,
|
'video'::text AS type,
|
||||||
1.0::double precision AS rank,
|
1.0::double precision AS rank,
|
||||||
@@ -124,10 +128,16 @@ export async function GET(req: Request) {
|
|||||||
v.desc,
|
v.desc,
|
||||||
'标签匹配'
|
'标签匹配'
|
||||||
) AS snippet,
|
) AS snippet,
|
||||||
vt.content_tags AS "contentTags"
|
CASE
|
||||||
FROM "VideoTranscript" vt
|
WHEN cardinality(v.manual_content_tags) > 0 THEN v.manual_content_tags
|
||||||
JOIN "Video" v ON v.aweme_id = vt."videoId"
|
ELSE COALESCE(vt.content_tags, ARRAY[]::text[])
|
||||||
WHERE vt.content_tags @> ARRAY[${contentTag}]::text[]
|
END AS "contentTags"
|
||||||
|
FROM "Video" v
|
||||||
|
LEFT JOIN "VideoTranscript" vt ON vt."videoId" = v.aweme_id
|
||||||
|
WHERE CASE
|
||||||
|
WHEN cardinality(v.manual_content_tags) > 0 THEN v.manual_content_tags @> ARRAY[${contentTag}]::text[]
|
||||||
|
ELSE COALESCE(vt.content_tags, ARRAY[]::text[]) @> ARRAY[${contentTag}]::text[]
|
||||||
|
END
|
||||||
ORDER BY v.created_at DESC
|
ORDER BY v.created_at DESC
|
||||||
OFFSET ${offset}
|
OFFSET ${offset}
|
||||||
LIMIT ${limit};
|
LIMIT ${limit};
|
||||||
@@ -145,7 +155,8 @@ export async function GET(req: Request) {
|
|||||||
WHERE (vt.transcript_zh_tsv @@ tsq.query OR to_tsvector('zhcfg', v.desc) @@ tsq.query)
|
WHERE (vt.transcript_zh_tsv @@ tsq.query OR to_tsvector('zhcfg', v.desc) @@ tsq.query)
|
||||||
AND CASE
|
AND CASE
|
||||||
WHEN ${contentTag}::text IS NULL THEN TRUE
|
WHEN ${contentTag}::text IS NULL THEN TRUE
|
||||||
ELSE vt.content_tags @> ARRAY[${contentTag}]::text[]
|
WHEN cardinality(v.manual_content_tags) > 0 THEN v.manual_content_tags @> ARRAY[${contentTag}]::text[]
|
||||||
|
ELSE COALESCE(vt.content_tags, ARRAY[]::text[]) @> ARRAY[${contentTag}]::text[]
|
||||||
END)
|
END)
|
||||||
+
|
+
|
||||||
(SELECT COUNT(*)::int FROM "ImagePost" ip
|
(SELECT COUNT(*)::int FROM "ImagePost" ip
|
||||||
@@ -156,8 +167,12 @@ export async function GET(req: Request) {
|
|||||||
`
|
`
|
||||||
: await prisma.$queryRaw<{ count: number }[]>`
|
: await prisma.$queryRaw<{ count: number }[]>`
|
||||||
SELECT COUNT(*)::int AS count
|
SELECT COUNT(*)::int AS count
|
||||||
FROM "VideoTranscript" vt
|
FROM "Video" v
|
||||||
WHERE vt.content_tags @> ARRAY[${contentTag}]::text[];
|
LEFT JOIN "VideoTranscript" vt ON vt."videoId" = v.aweme_id
|
||||||
|
WHERE CASE
|
||||||
|
WHEN cardinality(v.manual_content_tags) > 0 THEN v.manual_content_tags @> ARRAY[${contentTag}]::text[]
|
||||||
|
ELSE COALESCE(vt.content_tags, ARRAY[]::text[]) @> ARRAY[${contentTag}]::text[]
|
||||||
|
END;
|
||||||
`;
|
`;
|
||||||
|
|
||||||
// 分离视频和图文ID
|
// 分离视频和图文ID
|
||||||
|
|||||||
@@ -1,15 +1,8 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
|
import { Play } from "lucide-react";
|
||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
import { useCallback, useEffect, useRef, useState } from "react";
|
import { useCallback, useEffect, useRef, useState } from "react";
|
||||||
import { Play } from "lucide-react";
|
|
||||||
import type {
|
|
||||||
AwemeData,
|
|
||||||
ImageData,
|
|
||||||
Neighbors,
|
|
||||||
VideoData,
|
|
||||||
VideoTranscript,
|
|
||||||
} from "./types.ts";
|
|
||||||
import { BackgroundCanvas } from "./components/BackgroundCanvas";
|
import { BackgroundCanvas } from "./components/BackgroundCanvas";
|
||||||
import { CommentPanel } from "./components/CommentPanel";
|
import { CommentPanel } from "./components/CommentPanel";
|
||||||
import { ImageCarousel } from "./components/ImageCarousel";
|
import { ImageCarousel } from "./components/ImageCarousel";
|
||||||
@@ -18,15 +11,37 @@ import { MediaControls } from "./components/MediaControls";
|
|||||||
import { NavigationButtons } from "./components/NavigationButtons";
|
import { NavigationButtons } from "./components/NavigationButtons";
|
||||||
import { TranscriptPanel } from "./components/TranscriptPanel";
|
import { TranscriptPanel } from "./components/TranscriptPanel";
|
||||||
import { VideoPlayer } from "./components/VideoPlayer";
|
import { VideoPlayer } from "./components/VideoPlayer";
|
||||||
|
import { VideoTagEditor } from "./components/VideoTagEditor";
|
||||||
import { useBackgroundCanvas } from "./hooks/useBackgroundCanvas";
|
import { useBackgroundCanvas } from "./hooks/useBackgroundCanvas";
|
||||||
import { useCommentState } from "./hooks/useCommentState";
|
import { useCommentState } from "./hooks/useCommentState";
|
||||||
import { useImageCarousel } from "./hooks/useImageCarousel";
|
import { useImageCarousel } from "./hooks/useImageCarousel";
|
||||||
import { useNavigation } from "./hooks/useNavigation";
|
import { useNavigation } from "./hooks/useNavigation";
|
||||||
import { usePlayerState } from "./hooks/usePlayerState";
|
import { usePlayerState } from "./hooks/usePlayerState";
|
||||||
import { useVideoPlayer } from "./hooks/useVideoPlayer";
|
import { useVideoPlayer } from "./hooks/useVideoPlayer";
|
||||||
|
import type {
|
||||||
|
AwemeData,
|
||||||
|
ImageData,
|
||||||
|
Neighbors,
|
||||||
|
VideoData,
|
||||||
|
VideoTranscript,
|
||||||
|
} from "./types.ts";
|
||||||
|
|
||||||
const SEGMENT_MS = 4000;
|
const SEGMENT_MS = 4000;
|
||||||
|
|
||||||
|
type WebkitVideoElement = HTMLVideoElement & {
|
||||||
|
webkitEnterFullscreen?: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
type MediaPositionStateInput = {
|
||||||
|
duration: number;
|
||||||
|
position: number;
|
||||||
|
playbackRate?: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
type MediaSessionWithPositionState = MediaSession & {
|
||||||
|
setPositionState?: (state: MediaPositionStateInput) => void;
|
||||||
|
};
|
||||||
|
|
||||||
interface AwemeDetailClientProps {
|
interface AwemeDetailClientProps {
|
||||||
data: AwemeData;
|
data: AwemeData;
|
||||||
neighbors: Neighbors;
|
neighbors: Neighbors;
|
||||||
@@ -45,6 +60,9 @@ export default function AwemeDetailClient({
|
|||||||
const playerState = usePlayerState();
|
const playerState = usePlayerState();
|
||||||
const commentState = useCommentState();
|
const commentState = useCommentState();
|
||||||
const [transcriptOpen, setTranscriptOpen] = useState(false);
|
const [transcriptOpen, setTranscriptOpen] = useState(false);
|
||||||
|
const [contentTags, setContentTags] = useState<string[]>(
|
||||||
|
isVideo ? (data as VideoData).content_tags : [],
|
||||||
|
);
|
||||||
|
|
||||||
// 引用
|
// 引用
|
||||||
const mediaContainerRef = useRef<HTMLDivElement | null>(null);
|
const mediaContainerRef = useRef<HTMLDivElement | null>(null);
|
||||||
@@ -68,6 +86,7 @@ export default function AwemeDetailClient({
|
|||||||
|
|
||||||
// 图文轮播状态
|
// 图文轮播状态
|
||||||
const images = isVideo ? [] : (data as ImageData).images;
|
const images = isVideo ? [] : (data as ImageData).images;
|
||||||
|
const imageCount = images.length;
|
||||||
const imageCarouselState = useImageCarousel({
|
const imageCarouselState = useImageCarousel({
|
||||||
images,
|
images,
|
||||||
isPlaying: playerState.isPlaying,
|
isPlaying: playerState.isPlaying,
|
||||||
@@ -80,8 +99,8 @@ export default function AwemeDetailClient({
|
|||||||
});
|
});
|
||||||
|
|
||||||
// 视频播放器 hooks
|
// 视频播放器 hooks
|
||||||
if (isVideo) {
|
|
||||||
useVideoPlayer({
|
useVideoPlayer({
|
||||||
|
enabled: isVideo,
|
||||||
awemeId: data.aweme_id,
|
awemeId: data.aweme_id,
|
||||||
videoRef,
|
videoRef,
|
||||||
volume: playerState.volume,
|
volume: playerState.volume,
|
||||||
@@ -93,27 +112,29 @@ export default function AwemeDetailClient({
|
|||||||
setProgress: playerState.setProgress,
|
setProgress: playerState.setProgress,
|
||||||
setProgressRestored: playerState.setProgressRestored,
|
setProgressRestored: playerState.setProgressRestored,
|
||||||
});
|
});
|
||||||
}
|
|
||||||
|
|
||||||
// 媒体操作函数
|
// 媒体操作函数
|
||||||
const seekTo = (ratio: number) => {
|
const seekTo = useCallback(
|
||||||
ratio = Math.min(1, Math.max(0, ratio));
|
(ratio: number) => {
|
||||||
|
const clampedRatio = Math.min(1, Math.max(0, ratio));
|
||||||
if (isVideo) {
|
if (isVideo) {
|
||||||
const v = videoRef.current;
|
const v = videoRef.current;
|
||||||
if (!v || !v.duration) return;
|
if (!v || !v.duration) return;
|
||||||
v.currentTime = v.duration * ratio;
|
v.currentTime = v.duration * clampedRatio;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (!images?.length) return;
|
if (!imageCount) return;
|
||||||
imageCarouselState.seekTo(ratio);
|
imageCarouselState.seekTo(clampedRatio);
|
||||||
};
|
},
|
||||||
|
[imageCarouselState.seekTo, imageCount, isVideo],
|
||||||
|
);
|
||||||
|
|
||||||
const seekImageByVisualRatio = (ratio: number) => {
|
const seekImageByVisualRatio = (ratio: number) => {
|
||||||
if (!images?.length) return;
|
if (!imageCount) return;
|
||||||
|
|
||||||
const clampedRatio = Math.min(1, Math.max(0, ratio));
|
const clampedRatio = Math.min(1, Math.max(0, ratio));
|
||||||
const rawSegment = clampedRatio * images.length;
|
const rawSegment = clampedRatio * imageCount;
|
||||||
const targetIndex = Math.min(images.length - 1, Math.floor(rawSegment));
|
const targetIndex = Math.min(imageCount - 1, Math.floor(rawSegment));
|
||||||
const segmentProgress = Math.min(1, Math.max(0, rawSegment - targetIndex));
|
const segmentProgress = Math.min(1, Math.max(0, rawSegment - targetIndex));
|
||||||
imageCarouselState.goToIndex(targetIndex, segmentProgress);
|
imageCarouselState.goToIndex(targetIndex, segmentProgress);
|
||||||
};
|
};
|
||||||
@@ -152,14 +173,12 @@ export default function AwemeDetailClient({
|
|||||||
document.body.requestFullscreen().catch(() => {});
|
document.body.requestFullscreen().catch(() => {});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const vRef = videoRef.current;
|
const vRef = videoRef.current as WebkitVideoElement | null;
|
||||||
if (vRef && vRef.requestFullscreen) {
|
if (vRef?.requestFullscreen) {
|
||||||
vRef.requestFullscreen().catch(() => {});
|
vRef.requestFullscreen().catch(() => {});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
// @ts-ignore
|
if (vRef?.webkitEnterFullscreen) {
|
||||||
if (vRef && vRef.webkitEnterFullscreen) {
|
|
||||||
// @ts-ignore
|
|
||||||
vRef.webkitEnterFullscreen();
|
vRef.webkitEnterFullscreen();
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
@@ -167,20 +186,20 @@ export default function AwemeDetailClient({
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const prevImg = () => {
|
const prevImg = useCallback(() => {
|
||||||
if (!images?.length) return;
|
if (!imageCount) return;
|
||||||
const next = Math.max(0, imageCarouselState.idxRef.current - 1);
|
const next = Math.max(0, imageCarouselState.idxRef.current - 1);
|
||||||
imageCarouselState.goToIndex(next);
|
imageCarouselState.goToIndex(next);
|
||||||
};
|
}, [imageCarouselState.goToIndex, imageCarouselState.idxRef, imageCount]);
|
||||||
|
|
||||||
const nextImg = () => {
|
const nextImg = useCallback(() => {
|
||||||
if (!images?.length) return;
|
if (!imageCount) return;
|
||||||
const next = Math.min(
|
const next = Math.min(
|
||||||
images.length - 1,
|
imageCount - 1,
|
||||||
imageCarouselState.idxRef.current + 1,
|
imageCarouselState.idxRef.current + 1,
|
||||||
);
|
);
|
||||||
imageCarouselState.goToIndex(next);
|
imageCarouselState.goToIndex(next);
|
||||||
};
|
}, [imageCarouselState.goToIndex, imageCarouselState.idxRef, imageCount]);
|
||||||
|
|
||||||
const handleDownload = () => {
|
const handleDownload = () => {
|
||||||
if (isVideo) {
|
if (isVideo) {
|
||||||
@@ -249,7 +268,7 @@ export default function AwemeDetailClient({
|
|||||||
if (typeof window === "undefined") return;
|
if (typeof window === "undefined") return;
|
||||||
if (!("mediaSession" in navigator)) return;
|
if (!("mediaSession" in navigator)) return;
|
||||||
|
|
||||||
const ms = (navigator as any).mediaSession as MediaSession;
|
const ms = navigator.mediaSession as MediaSessionWithPositionState;
|
||||||
|
|
||||||
// 元信息:标题、作者、专辑(使用创建时间),封面图(优先当前图片,其次作者头像)
|
// 元信息:标题、作者、专辑(使用创建时间),封面图(优先当前图片,其次作者头像)
|
||||||
const title = data.desc || `作品 ${data.aweme_id}`;
|
const title = data.desc || `作品 ${data.aweme_id}`;
|
||||||
@@ -282,12 +301,12 @@ export default function AwemeDetailClient({
|
|||||||
|
|
||||||
const updatePosition = () => {
|
const updatePosition = () => {
|
||||||
try {
|
try {
|
||||||
// @ts-ignore: setPositionState 可能不存在
|
|
||||||
if (!ms.setPositionState) return;
|
if (!ms.setPositionState) return;
|
||||||
if (isVideo) {
|
if (isVideo) {
|
||||||
const v = videoRef.current;
|
const v = videoRef.current;
|
||||||
if (!v || !isFinite(v.duration) || isNaN(v.duration)) return;
|
if (!v || !Number.isFinite(v.duration) || Number.isNaN(v.duration)) {
|
||||||
// @ts-ignore
|
return;
|
||||||
|
}
|
||||||
ms.setPositionState({
|
ms.setPositionState({
|
||||||
duration: Math.max(0, v.duration),
|
duration: Math.max(0, v.duration),
|
||||||
position: Math.max(0, v.currentTime),
|
position: Math.max(0, v.currentTime),
|
||||||
@@ -300,14 +319,12 @@ export default function AwemeDetailClient({
|
|||||||
0,
|
0,
|
||||||
Math.min(duration, (playerState.progress * totalMs) / 1000),
|
Math.min(duration, (playerState.progress * totalMs) / 1000),
|
||||||
);
|
);
|
||||||
// @ts-ignore
|
|
||||||
ms.setPositionState({ duration, position, playbackRate: 1 });
|
ms.setPositionState({ duration, position, playbackRate: 1 });
|
||||||
}
|
}
|
||||||
} catch {}
|
} catch {}
|
||||||
};
|
};
|
||||||
|
|
||||||
// 绑定视频事件以同步状态
|
// 绑定视频事件以同步状态
|
||||||
let timeUpdateTimer: number | null = null;
|
|
||||||
const v = videoRef.current;
|
const v = videoRef.current;
|
||||||
if (isVideo && v) {
|
if (isVideo && v) {
|
||||||
const onPlay = () => {
|
const onPlay = () => {
|
||||||
@@ -331,7 +348,6 @@ export default function AwemeDetailClient({
|
|||||||
updatePosition();
|
updatePosition();
|
||||||
|
|
||||||
// 清理
|
// 清理
|
||||||
timeUpdateTimer = null;
|
|
||||||
return () => {
|
return () => {
|
||||||
v.removeEventListener("play", onPlay);
|
v.removeEventListener("play", onPlay);
|
||||||
v.removeEventListener("pause", onPause);
|
v.removeEventListener("pause", onPause);
|
||||||
@@ -346,15 +362,10 @@ export default function AwemeDetailClient({
|
|||||||
}
|
}
|
||||||
// 依赖:当这些变化时刷新元信息与位置状态
|
// 依赖:当这些变化时刷新元信息与位置状态
|
||||||
}, [
|
}, [
|
||||||
isVideo,
|
data,
|
||||||
data.aweme_id,
|
|
||||||
data.desc,
|
|
||||||
data.author.nickname,
|
|
||||||
data.author.avatar_url,
|
|
||||||
data.created_at,
|
|
||||||
imageCarouselState.idx,
|
|
||||||
imageCarouselState.totalDurationMs,
|
imageCarouselState.totalDurationMs,
|
||||||
images,
|
images,
|
||||||
|
isVideo,
|
||||||
playerState.isPlaying,
|
playerState.isPlaying,
|
||||||
playerState.progress,
|
playerState.progress,
|
||||||
playerState.rate,
|
playerState.rate,
|
||||||
@@ -364,7 +375,7 @@ export default function AwemeDetailClient({
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (typeof window === "undefined") return;
|
if (typeof window === "undefined") return;
|
||||||
if (!("mediaSession" in navigator)) return;
|
if (!("mediaSession" in navigator)) return;
|
||||||
const ms = (navigator as any).mediaSession as MediaSession;
|
const ms = navigator.mediaSession;
|
||||||
|
|
||||||
const getImagesTotalMs = () => imageCarouselState.totalDurationMs;
|
const getImagesTotalMs = () => imageCarouselState.totalDurationMs;
|
||||||
|
|
||||||
@@ -421,7 +432,7 @@ export default function AwemeDetailClient({
|
|||||||
const handleSeekTo = (seekTimeSec: number) => {
|
const handleSeekTo = (seekTimeSec: number) => {
|
||||||
if (isVideo) {
|
if (isVideo) {
|
||||||
const v = videoRef.current;
|
const v = videoRef.current;
|
||||||
if (v && isFinite(v.duration)) {
|
if (v && Number.isFinite(v.duration)) {
|
||||||
v.currentTime = Math.max(0, Math.min(v.duration, seekTimeSec));
|
v.currentTime = Math.max(0, Math.min(v.duration, seekTimeSec));
|
||||||
}
|
}
|
||||||
} else if (images?.length) {
|
} else if (images?.length) {
|
||||||
@@ -434,13 +445,13 @@ export default function AwemeDetailClient({
|
|||||||
ms.setActionHandler("play", handlePlay);
|
ms.setActionHandler("play", handlePlay);
|
||||||
ms.setActionHandler("pause", handlePause);
|
ms.setActionHandler("pause", handlePause);
|
||||||
ms.setActionHandler("stop", handleStop);
|
ms.setActionHandler("stop", handleStop);
|
||||||
ms.setActionHandler("seekbackward", (details: any) =>
|
ms.setActionHandler("seekbackward", (details: MediaSessionActionDetails) =>
|
||||||
handleSeekDelta(-((details?.seekOffset as number) || 10)),
|
handleSeekDelta(-((details?.seekOffset as number) || 10)),
|
||||||
);
|
);
|
||||||
ms.setActionHandler("seekforward", (details: any) =>
|
ms.setActionHandler("seekforward", (details: MediaSessionActionDetails) =>
|
||||||
handleSeekDelta((details?.seekOffset as number) || 10),
|
handleSeekDelta((details?.seekOffset as number) || 10),
|
||||||
);
|
);
|
||||||
ms.setActionHandler("seekto", (details: any) => {
|
ms.setActionHandler("seekto", (details: MediaSessionActionDetails) => {
|
||||||
if (typeof details?.seekTime === "number") handleSeekTo(details.seekTime);
|
if (typeof details?.seekTime === "number") handleSeekTo(details.seekTime);
|
||||||
});
|
});
|
||||||
ms.setActionHandler("previoustrack", () => {
|
ms.setActionHandler("previoustrack", () => {
|
||||||
@@ -477,8 +488,12 @@ export default function AwemeDetailClient({
|
|||||||
neighbors.next,
|
neighbors.next,
|
||||||
router,
|
router,
|
||||||
playerState.progress,
|
playerState.progress,
|
||||||
|
playerState.setIsPlaying,
|
||||||
pauseImageMedia,
|
pauseImageMedia,
|
||||||
imageCarouselState.totalDurationMs,
|
imageCarouselState.totalDurationMs,
|
||||||
|
nextImg,
|
||||||
|
prevImg,
|
||||||
|
seekTo,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -537,6 +552,14 @@ export default function AwemeDetailClient({
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{/* 媒体控制栏 */}
|
{/* 媒体控制栏 */}
|
||||||
|
{isVideo && (
|
||||||
|
<VideoTagEditor
|
||||||
|
awemeId={data.aweme_id}
|
||||||
|
tags={contentTags}
|
||||||
|
onTagsChange={setContentTags}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
<MediaControls
|
<MediaControls
|
||||||
isVideo={isVideo}
|
isVideo={isVideo}
|
||||||
isPlaying={playerState.isPlaying}
|
isPlaying={playerState.isPlaying}
|
||||||
@@ -600,6 +623,7 @@ export default function AwemeDetailClient({
|
|||||||
open={transcriptOpen}
|
open={transcriptOpen}
|
||||||
onClose={() => setTranscriptOpen(false)}
|
onClose={() => setTranscriptOpen(false)}
|
||||||
transcript={transcript}
|
transcript={transcript}
|
||||||
|
contentTags={contentTags}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -8,14 +8,17 @@ interface TranscriptPanelProps {
|
|||||||
open: boolean;
|
open: boolean;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
transcript: VideoTranscript | null;
|
transcript: VideoTranscript | null;
|
||||||
|
contentTags?: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export function TranscriptPanel({
|
export function TranscriptPanel({
|
||||||
open,
|
open,
|
||||||
onClose,
|
onClose,
|
||||||
transcript,
|
transcript,
|
||||||
|
contentTags,
|
||||||
}: TranscriptPanelProps) {
|
}: TranscriptPanelProps) {
|
||||||
const [copiedIndex, setCopiedIndex] = useState<number | null>(null);
|
const [copiedIndex, setCopiedIndex] = useState<number | null>(null);
|
||||||
|
const displayTags = contentTags ?? transcript?.content_tags ?? [];
|
||||||
const [copiedAll, setCopiedAll] = useState(false);
|
const [copiedAll, setCopiedAll] = useState(false);
|
||||||
|
|
||||||
if (!open) return null;
|
if (!open) return null;
|
||||||
@@ -94,9 +97,9 @@ export function TranscriptPanel({
|
|||||||
语言: {transcript.language}
|
语言: {transcript.language}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
{transcript.content_tags?.length > 0 && (
|
{displayTags.length > 0 && (
|
||||||
<div className="mt-2 flex flex-wrap gap-1.5">
|
<div className="mt-2 flex flex-wrap gap-1.5">
|
||||||
{transcript.content_tags.map((tag) => (
|
{displayTags.map((tag) => (
|
||||||
<span
|
<span
|
||||||
key={tag}
|
key={tag}
|
||||||
className="rounded bg-blue-500/15 px-2 py-0.5 text-xs text-blue-200"
|
className="rounded bg-blue-500/15 px-2 py-0.5 text-xs text-blue-200"
|
||||||
|
|||||||
@@ -0,0 +1,138 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { Check, Tag, X } from "lucide-react";
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { ASR_CONTENT_TAGS } from "@/lib/asr-tags";
|
||||||
|
|
||||||
|
type TagUpdateResponse = {
|
||||||
|
updated?: {
|
||||||
|
aweme_id: string;
|
||||||
|
content_tags: string[];
|
||||||
|
manual_content_tags: string[];
|
||||||
|
}[];
|
||||||
|
};
|
||||||
|
|
||||||
|
type VideoTagEditorProps = {
|
||||||
|
awemeId: string;
|
||||||
|
tags: string[];
|
||||||
|
onTagsChange: (tags: string[]) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function VideoTagEditor({
|
||||||
|
awemeId,
|
||||||
|
tags,
|
||||||
|
onTagsChange,
|
||||||
|
}: VideoTagEditorProps) {
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
const [draftTags, setDraftTags] = useState<string[]>(tags);
|
||||||
|
const [saving, setSaving] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) setDraftTags(tags);
|
||||||
|
}, [open, tags]);
|
||||||
|
|
||||||
|
const toggleTag = (tag: string) => {
|
||||||
|
setDraftTags((current) =>
|
||||||
|
current.includes(tag)
|
||||||
|
? current.filter((item) => item !== tag)
|
||||||
|
: [...current, tag],
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const saveTags = async () => {
|
||||||
|
setSaving(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
const response = await fetch("/api/asr/tags", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({
|
||||||
|
awemeId,
|
||||||
|
contentTags: draftTags,
|
||||||
|
mode: "set",
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
if (!response.ok) throw new Error("保存标签失败");
|
||||||
|
const data: TagUpdateResponse = await response.json();
|
||||||
|
const updated = data.updated?.find((item) => item.aweme_id === awemeId);
|
||||||
|
onTagsChange(updated?.content_tags ?? draftTags);
|
||||||
|
setOpen(false);
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : "保存标签失败");
|
||||||
|
} finally {
|
||||||
|
setSaving(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="absolute right-3 top-3 z-40 flex flex-col items-end gap-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setOpen((value) => !value)}
|
||||||
|
className="inline-flex h-9 items-center gap-2 rounded-full border border-white/20 bg-black/35 px-3 text-sm font-medium text-white shadow-lg backdrop-blur transition-colors hover:bg-black/50"
|
||||||
|
aria-expanded={open}
|
||||||
|
>
|
||||||
|
<Tag size={16} />
|
||||||
|
{tags.length > 0 ? tags.join("、") : "打标签"}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{open && (
|
||||||
|
<div className="w-[min(92vw,24rem)] rounded-xl border border-white/15 bg-zinc-950/92 p-3 text-white shadow-2xl backdrop-blur-xl">
|
||||||
|
<div className="mb-3 flex items-center justify-between gap-3">
|
||||||
|
<div className="text-sm font-medium">手动标签</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setOpen(false)}
|
||||||
|
className="rounded-full p-1 text-white/60 transition-colors hover:bg-white/10 hover:text-white"
|
||||||
|
aria-label="关闭标签编辑"
|
||||||
|
>
|
||||||
|
<X size={16} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
{ASR_CONTENT_TAGS.map((tag) => {
|
||||||
|
const selected = draftTags.includes(tag);
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={tag}
|
||||||
|
type="button"
|
||||||
|
onClick={() => toggleTag(tag)}
|
||||||
|
className={`inline-flex items-center gap-1 rounded-full border px-3 py-1.5 text-xs font-medium transition-colors ${
|
||||||
|
selected
|
||||||
|
? "border-blue-400 bg-blue-500/25 text-blue-100"
|
||||||
|
: "border-white/10 bg-white/5 text-white/70 hover:border-white/25 hover:text-white"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{selected && <Check size={13} />}
|
||||||
|
{tag}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && <div className="mt-3 text-xs text-rose-300">{error}</div>}
|
||||||
|
|
||||||
|
<div className="mt-4 flex items-center justify-between gap-3">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setDraftTags([])}
|
||||||
|
className="rounded-full px-3 py-1.5 text-xs text-white/60 transition-colors hover:bg-white/10 hover:text-white"
|
||||||
|
>
|
||||||
|
清空
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={saveTags}
|
||||||
|
disabled={saving}
|
||||||
|
className="rounded-full bg-blue-600 px-4 py-1.5 text-xs font-medium text-white transition-colors hover:bg-blue-500 disabled:cursor-not-allowed disabled:bg-zinc-600"
|
||||||
|
>
|
||||||
|
{saving ? "保存中..." : "保存"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,9 +1,10 @@
|
|||||||
import { useEffect, useRef } from "react";
|
|
||||||
import type { RefObject } from "react";
|
|
||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
|
import type { RefObject } from "react";
|
||||||
|
import { useEffect } from "react";
|
||||||
import type { LoopMode, Neighbors } from "../types.ts";
|
import type { LoopMode, Neighbors } from "../types.ts";
|
||||||
|
|
||||||
interface UseVideoPlayerProps {
|
interface UseVideoPlayerProps {
|
||||||
|
enabled?: boolean;
|
||||||
awemeId: string;
|
awemeId: string;
|
||||||
videoRef: RefObject<HTMLVideoElement | null>;
|
videoRef: RefObject<HTMLVideoElement | null>;
|
||||||
volume: number;
|
volume: number;
|
||||||
@@ -17,6 +18,7 @@ interface UseVideoPlayerProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function useVideoPlayer({
|
export function useVideoPlayer({
|
||||||
|
enabled = true,
|
||||||
awemeId,
|
awemeId,
|
||||||
videoRef,
|
videoRef,
|
||||||
volume,
|
volume,
|
||||||
@@ -32,6 +34,7 @@ export function useVideoPlayer({
|
|||||||
|
|
||||||
// 恢复播放进度
|
// 恢复播放进度
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
if (!enabled) return;
|
||||||
if (progressRestored) return;
|
if (progressRestored) return;
|
||||||
const v = videoRef.current;
|
const v = videoRef.current;
|
||||||
if (!v) return;
|
if (!v) return;
|
||||||
@@ -74,10 +77,11 @@ export function useVideoPlayer({
|
|||||||
v.addEventListener("loadedmetadata", onLoadedMetadata, { once: true });
|
v.addEventListener("loadedmetadata", onLoadedMetadata, { once: true });
|
||||||
return () => v.removeEventListener("loadedmetadata", onLoadedMetadata);
|
return () => v.removeEventListener("loadedmetadata", onLoadedMetadata);
|
||||||
}
|
}
|
||||||
}, [awemeId, progressRestored, videoRef, setProgressRestored]);
|
}, [awemeId, enabled, progressRestored, videoRef, setProgressRestored]);
|
||||||
|
|
||||||
// 保存播放进度
|
// 保存播放进度
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
if (!enabled) return;
|
||||||
const v = videoRef.current;
|
const v = videoRef.current;
|
||||||
if (!v) return;
|
if (!v) return;
|
||||||
|
|
||||||
@@ -105,10 +109,11 @@ export function useVideoPlayer({
|
|||||||
window.removeEventListener("beforeunload", onBeforeUnload);
|
window.removeEventListener("beforeunload", onBeforeUnload);
|
||||||
saveProgress();
|
saveProgress();
|
||||||
};
|
};
|
||||||
}, [awemeId, videoRef]);
|
}, [awemeId, enabled, videoRef]);
|
||||||
|
|
||||||
// 监听播放状态和进度
|
// 监听播放状态和进度
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
if (!enabled) return;
|
||||||
const v = videoRef.current;
|
const v = videoRef.current;
|
||||||
if (!v) return;
|
if (!v) return;
|
||||||
|
|
||||||
@@ -137,10 +142,19 @@ export function useVideoPlayer({
|
|||||||
v.removeEventListener("pause", onPause);
|
v.removeEventListener("pause", onPause);
|
||||||
v.removeEventListener("ended", onEnded);
|
v.removeEventListener("ended", onEnded);
|
||||||
};
|
};
|
||||||
}, [loopMode, neighbors?.next, router, videoRef, setIsPlaying, setProgress]);
|
}, [
|
||||||
|
enabled,
|
||||||
|
loopMode,
|
||||||
|
neighbors?.next,
|
||||||
|
router,
|
||||||
|
videoRef,
|
||||||
|
setIsPlaying,
|
||||||
|
setProgress,
|
||||||
|
]);
|
||||||
|
|
||||||
// 自动播放检测
|
// 自动播放检测
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
if (!enabled) return;
|
||||||
const v = videoRef.current;
|
const v = videoRef.current;
|
||||||
if (!v) return;
|
if (!v) return;
|
||||||
|
|
||||||
@@ -148,7 +162,7 @@ export function useVideoPlayer({
|
|||||||
try {
|
try {
|
||||||
await v.play();
|
await v.play();
|
||||||
setIsPlaying(true);
|
setIsPlaying(true);
|
||||||
} catch (error) {
|
} catch {
|
||||||
console.log("自动播放被阻止,需要用户交互");
|
console.log("自动播放被阻止,需要用户交互");
|
||||||
setIsPlaying(false);
|
setIsPlaying(false);
|
||||||
}
|
}
|
||||||
@@ -160,16 +174,18 @@ export function useVideoPlayer({
|
|||||||
v.addEventListener("loadedmetadata", checkAutoplay, { once: true });
|
v.addEventListener("loadedmetadata", checkAutoplay, { once: true });
|
||||||
return () => v.removeEventListener("loadedmetadata", checkAutoplay);
|
return () => v.removeEventListener("loadedmetadata", checkAutoplay);
|
||||||
}
|
}
|
||||||
}, [awemeId, videoRef, setIsPlaying]);
|
}, [enabled, videoRef, setIsPlaying]);
|
||||||
|
|
||||||
// 更新音量和倍速
|
// 更新音量和倍速
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
if (!enabled) return;
|
||||||
const v = videoRef.current;
|
const v = videoRef.current;
|
||||||
if (v) v.volume = volume;
|
if (v) v.volume = volume;
|
||||||
}, [volume, videoRef]);
|
}, [enabled, volume, videoRef]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
if (!enabled) return;
|
||||||
const v = videoRef.current;
|
const v = videoRef.current;
|
||||||
if (v) v.playbackRate = rate;
|
if (v) v.playbackRate = rate;
|
||||||
}, [rate, videoRef]);
|
}, [enabled, rate, videoRef]);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
import { prisma } from "@/lib/prisma";
|
|
||||||
import BackButton from "@/app/components/BackButton";
|
|
||||||
import AwemeDetailClient from "./Client";
|
|
||||||
import type { Metadata } from "next";
|
import type { Metadata } from "next";
|
||||||
|
import BackButton from "@/app/components/BackButton";
|
||||||
|
import { resolveEffectiveContentTags } from "@/lib/asr-tags";
|
||||||
import { getFileUrl } from "@/lib/minio";
|
import { getFileUrl } from "@/lib/minio";
|
||||||
import { AwemeData, VideoTranscript } from "./types";
|
import { prisma } from "@/lib/prisma";
|
||||||
|
import AwemeDetailClient from "./Client";
|
||||||
|
import type { AwemeData, VideoTranscript } from "./types";
|
||||||
|
|
||||||
export async function generateMetadata({
|
export async function generateMetadata({
|
||||||
params,
|
params,
|
||||||
@@ -53,7 +54,7 @@ export default async function AwemeDetail({
|
|||||||
const [video, post] = await Promise.all([
|
const [video, post] = await Promise.all([
|
||||||
prisma.video.findUnique({
|
prisma.video.findUnique({
|
||||||
where: { aweme_id: id },
|
where: { aweme_id: id },
|
||||||
include: { author: true },
|
include: { author: true, transcript: true },
|
||||||
}),
|
}),
|
||||||
prisma.imagePost.findUnique({
|
prisma.imagePost.findUnique({
|
||||||
where: { aweme_id: id },
|
where: { aweme_id: id },
|
||||||
@@ -63,65 +64,77 @@ export default async function AwemeDetail({
|
|||||||
|
|
||||||
if (!video && !post) return <main className="p-8">找不到该作品</main>;
|
if (!video && !post) return <main className="p-8">找不到该作品</main>;
|
||||||
|
|
||||||
const isVideo = !!video;
|
const isVideo = Boolean(video);
|
||||||
|
|
||||||
// 获取评论总数
|
// 获取评论总数
|
||||||
const commentsCount = await prisma.comment.count({
|
const commentsCount = await prisma.comment.count({
|
||||||
where: isVideo ? { videoId: id } : { imagePostId: id },
|
where: isVideo ? { videoId: id } : { imagePostId: id },
|
||||||
});
|
});
|
||||||
const aweme = isVideo ? video : post;
|
|
||||||
|
|
||||||
const data: AwemeData = {
|
let data: AwemeData;
|
||||||
aweme_id: aweme!.aweme_id,
|
let currentCreatedAt: Date;
|
||||||
desc: aweme!.desc,
|
let transcript: VideoTranscript | null = null;
|
||||||
created_at: aweme!.created_at,
|
|
||||||
likesCount: Number(aweme!.digg_count),
|
if (video) {
|
||||||
|
transcript = video.transcript ?? null;
|
||||||
|
currentCreatedAt = video.created_at;
|
||||||
|
data = {
|
||||||
|
type: "video",
|
||||||
|
aweme_id: video.aweme_id,
|
||||||
|
desc: video.desc,
|
||||||
|
created_at: video.created_at,
|
||||||
|
likesCount: Number(video.digg_count),
|
||||||
commentsCount,
|
commentsCount,
|
||||||
author: {
|
author: {
|
||||||
nickname: aweme!.author.nickname,
|
nickname: video.author.nickname,
|
||||||
avatar_url: getFileUrl(aweme!.author.avatar_url || "default-avatar.png"),
|
avatar_url: getFileUrl(video.author.avatar_url || "default-avatar.png"),
|
||||||
sec_uid: aweme!.author.sec_uid,
|
sec_uid: video.author.sec_uid,
|
||||||
},
|
},
|
||||||
...(() => {
|
cover_url: getFileUrl(video.cover_url ?? "default-cover.png"),
|
||||||
if (isVideo) {
|
cover_size: { h: video.height ?? 0, w: video.width ?? 0 },
|
||||||
const aweme = video!;
|
duration_ms: video.duration_ms,
|
||||||
return {
|
video_url: getFileUrl(video.video_url),
|
||||||
type: "video" as const,
|
width: video.width ?? null,
|
||||||
cover_url: getFileUrl(aweme!.cover_url ?? "default-cover.png"),
|
height: video.height ?? null,
|
||||||
cover_size: { h: aweme.height ?? 0, w: aweme.width ?? 0 },
|
content_tags: resolveEffectiveContentTags(
|
||||||
duration_ms: aweme!.duration_ms,
|
video.manual_content_tags,
|
||||||
video_url: getFileUrl(aweme!.video_url),
|
transcript?.content_tags,
|
||||||
width: aweme!.width ?? null,
|
),
|
||||||
height: aweme!.height ?? null,
|
manual_content_tags: video.manual_content_tags,
|
||||||
|
asr_content_tags: transcript?.content_tags ?? [],
|
||||||
};
|
};
|
||||||
} else {
|
} else if (post) {
|
||||||
const aweme = post!;
|
const coverImage = post.images[0];
|
||||||
return {
|
currentCreatedAt = post.created_at;
|
||||||
type: "image" as const,
|
data = {
|
||||||
cover_url: getFileUrl(aweme!.images[0].url ?? "default-cover.png"),
|
type: "image",
|
||||||
cover_size: {
|
aweme_id: post.aweme_id,
|
||||||
h: aweme!.images[0].height ?? 0,
|
desc: post.desc,
|
||||||
w: aweme!.images[0].width ?? 0,
|
created_at: post.created_at,
|
||||||
|
likesCount: Number(post.digg_count),
|
||||||
|
commentsCount,
|
||||||
|
author: {
|
||||||
|
nickname: post.author.nickname,
|
||||||
|
avatar_url: getFileUrl(post.author.avatar_url || "default-avatar.png"),
|
||||||
|
sec_uid: post.author.sec_uid,
|
||||||
},
|
},
|
||||||
images: aweme!.images.map((img) => ({
|
cover_url: getFileUrl(coverImage?.url ?? "default-cover.png"),
|
||||||
|
cover_size: {
|
||||||
|
h: coverImage?.height ?? 0,
|
||||||
|
w: coverImage?.width ?? 0,
|
||||||
|
},
|
||||||
|
images: post.images.map((img) => ({
|
||||||
...img,
|
...img,
|
||||||
url: getFileUrl(img.url),
|
url: getFileUrl(img.url),
|
||||||
animated: img.animated ? getFileUrl(img.animated) : null,
|
animated: img.animated ? getFileUrl(img.animated) : null,
|
||||||
})),
|
})),
|
||||||
music_url: getFileUrl(aweme!.music_url || "default-music.mp3"),
|
music_url: getFileUrl(post.music_url || "default-music.mp3"),
|
||||||
};
|
};
|
||||||
|
} else {
|
||||||
|
return <main className="p-8">找不到该作品</main>;
|
||||||
}
|
}
|
||||||
})(),
|
|
||||||
};
|
|
||||||
|
|
||||||
const transcript: VideoTranscript | null = isVideo
|
|
||||||
? await prisma.videoTranscript.findUnique({
|
|
||||||
where: { videoId: id },
|
|
||||||
})
|
|
||||||
: null;
|
|
||||||
|
|
||||||
// Compute prev/next neighbors by created_at across videos and image posts
|
// Compute prev/next neighbors by created_at across videos and image posts
|
||||||
const currentCreatedAt = isVideo ? video!.created_at : post!.created_at;
|
|
||||||
const [newerVideo, newerPost, olderVideo, olderPost] = await Promise.all([
|
const [newerVideo, newerPost, olderVideo, olderPost] = await Promise.all([
|
||||||
prisma.video.findFirst({
|
prisma.video.findFirst({
|
||||||
where: { created_at: { gt: currentCreatedAt } },
|
where: { created_at: { gt: currentCreatedAt } },
|
||||||
|
|||||||
@@ -24,6 +24,9 @@ export type VideoData = {
|
|||||||
video_url: string;
|
video_url: string;
|
||||||
width: number | null;
|
width: number | null;
|
||||||
height: number | null;
|
height: number | null;
|
||||||
|
content_tags: string[];
|
||||||
|
manual_content_tags: string[];
|
||||||
|
asr_content_tags: string[];
|
||||||
author: User;
|
author: User;
|
||||||
commentsCount: number;
|
commentsCount: number;
|
||||||
likesCount: number;
|
likesCount: number;
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
"use client";
|
"use client";
|
||||||
import { ThumbsUp } from "lucide-react";
|
import { Check, ThumbsUp, X } from "lucide-react";
|
||||||
import Image from "next/image";
|
import Image from "next/image";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { useCallback, useEffect, useRef, useState } from "react";
|
import { useCallback, useEffect, useRef, useState } from "react";
|
||||||
import type { FeedItem, FeedResponse } from "@/app/types/feed";
|
import type { FeedItem, FeedResponse } from "@/app/types/feed";
|
||||||
|
import { ASR_CONTENT_TAGS } from "@/lib/asr-tags";
|
||||||
import HoverVideo from "./HoverVideo";
|
import HoverVideo from "./HoverVideo";
|
||||||
|
|
||||||
const COLUMN_KEYS = ["feed-col-0", "feed-col-1", "feed-col-2", "feed-col-3"];
|
const COLUMN_KEYS = ["feed-col-0", "feed-col-1", "feed-col-2", "feed-col-3"];
|
||||||
@@ -18,6 +19,13 @@ type Props = {
|
|||||||
fetchUrl?: string;
|
fetchUrl?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
type TagUpdateResponse = {
|
||||||
|
updated?: {
|
||||||
|
aweme_id: string;
|
||||||
|
content_tags: string[];
|
||||||
|
}[];
|
||||||
|
};
|
||||||
|
|
||||||
export default function FeedMasonry({
|
export default function FeedMasonry({
|
||||||
initialItems,
|
initialItems,
|
||||||
initialCursor,
|
initialCursor,
|
||||||
@@ -27,6 +35,10 @@ export default function FeedMasonry({
|
|||||||
const [cursor, setCursor] = useState<string | null>(initialCursor);
|
const [cursor, setCursor] = useState<string | null>(initialCursor);
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [ended, setEnded] = useState(!initialCursor);
|
const [ended, setEnded] = useState(!initialCursor);
|
||||||
|
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set());
|
||||||
|
const [batchTag, setBatchTag] = useState<string>(ASR_CONTENT_TAGS[0]);
|
||||||
|
const [batchSaving, setBatchSaving] = useState(false);
|
||||||
|
const [batchError, setBatchError] = useState<string | null>(null);
|
||||||
|
|
||||||
const sentinelRef = useRef<HTMLDivElement | null>(null);
|
const sentinelRef = useRef<HTMLDivElement | null>(null);
|
||||||
const containerRef = useRef<HTMLDivElement | null>(null);
|
const containerRef = useRef<HTMLDivElement | null>(null);
|
||||||
@@ -168,12 +180,89 @@ export default function FeedMasonry({
|
|||||||
return () => io.disconnect();
|
return () => io.disconnect();
|
||||||
}, [fetchMore]);
|
}, [fetchMore]);
|
||||||
|
|
||||||
|
const toggleSelection = useCallback((awemeId: string) => {
|
||||||
|
setSelectedIds((current) => {
|
||||||
|
const next = new Set(current);
|
||||||
|
if (next.has(awemeId)) next.delete(awemeId);
|
||||||
|
else next.add(awemeId);
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
setBatchError(null);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const clearSelection = useCallback(() => {
|
||||||
|
setSelectedIds(new Set());
|
||||||
|
setBatchError(null);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const applyUpdatedTags = useCallback(
|
||||||
|
(updates: TagUpdateResponse["updated"]) => {
|
||||||
|
if (!updates?.length) return;
|
||||||
|
const tagMap = new Map(
|
||||||
|
updates.map((item) => [item.aweme_id, item.content_tags] as const),
|
||||||
|
);
|
||||||
|
setColumns((currentColumns) =>
|
||||||
|
currentColumns.map((column) =>
|
||||||
|
column.map((item) =>
|
||||||
|
tagMap.has(item.aweme_id)
|
||||||
|
? { ...item, content_tags: tagMap.get(item.aweme_id) ?? [] }
|
||||||
|
: item,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleBatchAddTag = useCallback(async () => {
|
||||||
|
const awemeIds = [...selectedIds];
|
||||||
|
if (awemeIds.length === 0 || batchSaving) return;
|
||||||
|
setBatchSaving(true);
|
||||||
|
setBatchError(null);
|
||||||
|
try {
|
||||||
|
const response = await fetch("/api/asr/tags", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ awemeIds, tag: batchTag, mode: "add" }),
|
||||||
|
});
|
||||||
|
if (!response.ok) throw new Error("批量添加标签失败");
|
||||||
|
const data: TagUpdateResponse = await response.json();
|
||||||
|
applyUpdatedTags(data.updated);
|
||||||
|
clearSelection();
|
||||||
|
} catch (error) {
|
||||||
|
setBatchError(
|
||||||
|
error instanceof Error ? error.message : "批量添加标签失败",
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
setBatchSaving(false);
|
||||||
|
}
|
||||||
|
}, [applyUpdatedTags, batchSaving, batchTag, clearSelection, selectedIds]);
|
||||||
|
|
||||||
const renderCard = useCallback(
|
const renderCard = useCallback(
|
||||||
(item: FeedItem) => (
|
(item: FeedItem) => {
|
||||||
|
const selected = selectedIds.has(item.aweme_id);
|
||||||
|
return (
|
||||||
<article
|
<article
|
||||||
key={item.aweme_id}
|
key={item.aweme_id}
|
||||||
className="mb-4 group relative overflow-hidden rounded-2xl shadow-sm ring-1 ring-black/5 bg-white/80 dark:bg-zinc-900/60 backdrop-blur-sm transition-transform duration-300 hover:-translate-y-1"
|
className={`mb-4 group relative overflow-hidden rounded-2xl bg-white/80 shadow-sm backdrop-blur-sm transition-transform duration-300 hover:-translate-y-1 dark:bg-zinc-900/60 ${
|
||||||
|
selected ? "ring-2 ring-blue-500" : "ring-1 ring-black/5"
|
||||||
|
}`}
|
||||||
>
|
>
|
||||||
|
{item.type === "video" && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => toggleSelection(item.aweme_id)}
|
||||||
|
className={`absolute left-3 top-3 z-20 inline-flex h-8 w-8 items-center justify-center rounded-full border text-white shadow-lg backdrop-blur transition-colors ${
|
||||||
|
selected
|
||||||
|
? "border-blue-300 bg-blue-600"
|
||||||
|
: "border-white/25 bg-black/35 hover:bg-black/55"
|
||||||
|
}`}
|
||||||
|
aria-pressed={selected}
|
||||||
|
aria-label={selected ? "取消选择视频" : "选择视频"}
|
||||||
|
>
|
||||||
|
{selected && <Check size={17} />}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
<Link
|
<Link
|
||||||
href={`/aweme/${item.aweme_id}`}
|
href={`/aweme/${item.aweme_id}`}
|
||||||
target="_blank"
|
target="_blank"
|
||||||
@@ -273,8 +362,9 @@ export default function FeedMasonry({
|
|||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</article>
|
</article>
|
||||||
),
|
);
|
||||||
[],
|
},
|
||||||
|
[selectedIds, toggleSelection],
|
||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -299,6 +389,54 @@ export default function FeedMasonry({
|
|||||||
>
|
>
|
||||||
{ended ? "没有更多了" : loading ? "加载中…" : "下拉加载更多"}
|
{ended ? "没有更多了" : loading ? "加载中…" : "下拉加载更多"}
|
||||||
</div>
|
</div>
|
||||||
|
{selectedIds.size > 0 && (
|
||||||
|
<div className="fixed bottom-4 left-1/2 z-50 w-[min(92vw,36rem)] -translate-x-1/2 rounded-2xl border border-white/15 bg-zinc-950/92 p-3 text-white shadow-2xl backdrop-blur-xl">
|
||||||
|
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||||
|
<div className="flex items-center gap-2 text-sm">
|
||||||
|
<span className="font-medium">
|
||||||
|
已选择 {selectedIds.size} 个视频
|
||||||
|
</span>
|
||||||
|
{batchError && (
|
||||||
|
<span className="text-xs text-rose-300">{batchError}</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<select
|
||||||
|
value={batchTag}
|
||||||
|
onChange={(event) => setBatchTag(event.target.value)}
|
||||||
|
className="h-9 rounded-full border border-white/15 bg-white/10 px-3 text-sm text-white outline-none"
|
||||||
|
aria-label="选择要添加的标签"
|
||||||
|
>
|
||||||
|
{ASR_CONTENT_TAGS.map((tag) => (
|
||||||
|
<option
|
||||||
|
key={tag}
|
||||||
|
value={tag}
|
||||||
|
className="bg-zinc-900 text-white"
|
||||||
|
>
|
||||||
|
{tag}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleBatchAddTag}
|
||||||
|
disabled={batchSaving}
|
||||||
|
className="h-9 rounded-full bg-blue-600 px-4 text-sm font-medium text-white transition-colors hover:bg-blue-500 disabled:cursor-not-allowed disabled:bg-zinc-600"
|
||||||
|
>
|
||||||
|
{batchSaving ? "添加中..." : "添加标签"}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={clearSelection}
|
||||||
|
className="inline-flex h-9 w-9 items-center justify-center rounded-full border border-white/15 bg-white/10 text-white/70 transition-colors hover:bg-white/15 hover:text-white"
|
||||||
|
aria-label="清空选择"
|
||||||
|
>
|
||||||
|
<X size={17} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
+18
-3
@@ -1,6 +1,10 @@
|
|||||||
import type { Metadata } from "next";
|
import type { Metadata } from "next";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { ASR_CONTENT_TAGS, isAsrContentTag } from "@/lib/asr-tags";
|
import {
|
||||||
|
ASR_CONTENT_TAGS,
|
||||||
|
isAsrContentTag,
|
||||||
|
resolveEffectiveContentTags,
|
||||||
|
} from "@/lib/asr-tags";
|
||||||
import { getFileUrl } from "@/lib/minio";
|
import { getFileUrl } from "@/lib/minio";
|
||||||
import { prisma } from "@/lib/prisma";
|
import { prisma } from "@/lib/prisma";
|
||||||
import FeedMasonry from "./components/FeedMasonry";
|
import FeedMasonry from "./components/FeedMasonry";
|
||||||
@@ -26,7 +30,15 @@ export default async function Home({
|
|||||||
const [videos, posts] = await Promise.all([
|
const [videos, posts] = await Promise.all([
|
||||||
prisma.video.findMany({
|
prisma.video.findMany({
|
||||||
where: selectedTag
|
where: selectedTag
|
||||||
? { transcript: { is: { content_tags: { has: selectedTag } } } }
|
? {
|
||||||
|
OR: [
|
||||||
|
{ manual_content_tags: { has: selectedTag } },
|
||||||
|
{
|
||||||
|
manual_content_tags: { isEmpty: true },
|
||||||
|
transcript: { is: { content_tags: { has: selectedTag } } },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}
|
||||||
: undefined,
|
: undefined,
|
||||||
orderBy: { created_at: "desc" },
|
orderBy: { created_at: "desc" },
|
||||||
take: 60,
|
take: 60,
|
||||||
@@ -63,7 +75,10 @@ export default async function Home({
|
|||||||
sec_uid: v.author.sec_uid,
|
sec_uid: v.author.sec_uid,
|
||||||
},
|
},
|
||||||
likes: Number(v.digg_count),
|
likes: Number(v.digg_count),
|
||||||
content_tags: v.transcript?.content_tags ?? [],
|
content_tags: resolveEffectiveContentTags(
|
||||||
|
v.manual_content_tags,
|
||||||
|
v.transcript?.content_tags,
|
||||||
|
),
|
||||||
})),
|
})),
|
||||||
...posts.map((p) => {
|
...posts.map((p) => {
|
||||||
const coverPath = p.images?.[0]?.url;
|
const coverPath = p.images?.[0]?.url;
|
||||||
|
|||||||
@@ -38,3 +38,12 @@ export function normalizeAsrContentTags(tags: unknown): AsrContentTag[] {
|
|||||||
const uniqueTags = [...new Set(normalized)].slice(0, 3);
|
const uniqueTags = [...new Set(normalized)].slice(0, 3);
|
||||||
return uniqueTags.length > 0 ? uniqueTags : ["其他"];
|
return uniqueTags.length > 0 ? uniqueTags : ["其他"];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function resolveEffectiveContentTags(
|
||||||
|
manualTags: readonly string[] | null | undefined,
|
||||||
|
asrTags: readonly string[] | null | undefined,
|
||||||
|
) {
|
||||||
|
return manualTags && manualTags.length > 0
|
||||||
|
? [...manualTags]
|
||||||
|
: [...(asrTags ?? [])];
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,5 @@
|
|||||||
|
ALTER TABLE "Video"
|
||||||
|
ADD COLUMN "manual_content_tags" TEXT[] NOT NULL DEFAULT ARRAY[]::TEXT[];
|
||||||
|
|
||||||
|
CREATE INDEX "video_manual_content_tags_idx"
|
||||||
|
ON "Video" USING GIN ("manual_content_tags");
|
||||||
@@ -38,6 +38,7 @@ model Video {
|
|||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
updatedAt DateTime @updatedAt
|
updatedAt DateTime @updatedAt
|
||||||
tags String[]
|
tags String[]
|
||||||
|
manual_content_tags String[] @default([])
|
||||||
video_url String
|
video_url String
|
||||||
height Int?
|
height Int?
|
||||||
width Int?
|
width Int?
|
||||||
@@ -50,6 +51,7 @@ model Video {
|
|||||||
|
|
||||||
@@index([authorId])
|
@@index([authorId])
|
||||||
@@index([created_at])
|
@@index([created_at])
|
||||||
|
@@index([manual_content_tags], map: "video_manual_content_tags_idx", type: Gin)
|
||||||
}
|
}
|
||||||
|
|
||||||
model CommentUser {
|
model CommentUser {
|
||||||
|
|||||||
Reference in New Issue
Block a user