feat: add manual video tagging

This commit is contained in:
2026-04-29 21:20:21 +08:00
parent 02d37f3953
commit eac6ad2343
17 changed files with 1066 additions and 418 deletions
+130 -45
View File
@@ -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) ? (
"生成标签" "生成标签"
) : ( ) : (
"重新识别" "重新识别"
+11 -2
View File
@@ -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
View File
@@ -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);
+127
View File
@@ -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
View File
@@ -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
View File
@@ -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
+74 -50
View File
@@ -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>
);
}
+25 -9
View File
@@ -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]);
} }
+58 -45
View File
@@ -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 } },
+3
View File
@@ -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;
+143 -5
View File
@@ -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
View File
@@ -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;
+9
View File
@@ -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");
+2
View File
@@ -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 {