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
+91 -67
View File
@@ -1,15 +1,8 @@
"use client";
import { Play } from "lucide-react";
import { useRouter } from "next/navigation";
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 { CommentPanel } from "./components/CommentPanel";
import { ImageCarousel } from "./components/ImageCarousel";
@@ -18,15 +11,37 @@ import { MediaControls } from "./components/MediaControls";
import { NavigationButtons } from "./components/NavigationButtons";
import { TranscriptPanel } from "./components/TranscriptPanel";
import { VideoPlayer } from "./components/VideoPlayer";
import { VideoTagEditor } from "./components/VideoTagEditor";
import { useBackgroundCanvas } from "./hooks/useBackgroundCanvas";
import { useCommentState } from "./hooks/useCommentState";
import { useImageCarousel } from "./hooks/useImageCarousel";
import { useNavigation } from "./hooks/useNavigation";
import { usePlayerState } from "./hooks/usePlayerState";
import { useVideoPlayer } from "./hooks/useVideoPlayer";
import type {
AwemeData,
ImageData,
Neighbors,
VideoData,
VideoTranscript,
} from "./types.ts";
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 {
data: AwemeData;
neighbors: Neighbors;
@@ -45,6 +60,9 @@ export default function AwemeDetailClient({
const playerState = usePlayerState();
const commentState = useCommentState();
const [transcriptOpen, setTranscriptOpen] = useState(false);
const [contentTags, setContentTags] = useState<string[]>(
isVideo ? (data as VideoData).content_tags : [],
);
// 引用
const mediaContainerRef = useRef<HTMLDivElement | null>(null);
@@ -68,6 +86,7 @@ export default function AwemeDetailClient({
// 图文轮播状态
const images = isVideo ? [] : (data as ImageData).images;
const imageCount = images.length;
const imageCarouselState = useImageCarousel({
images,
isPlaying: playerState.isPlaying,
@@ -80,40 +99,42 @@ export default function AwemeDetailClient({
});
// 视频播放器 hooks
if (isVideo) {
useVideoPlayer({
awemeId: data.aweme_id,
videoRef,
volume: playerState.volume,
rate: playerState.rate,
loopMode: playerState.loopMode,
neighbors,
progressRestored: playerState.progressRestored,
setIsPlaying: playerState.setIsPlaying,
setProgress: playerState.setProgress,
setProgressRestored: playerState.setProgressRestored,
});
}
useVideoPlayer({
enabled: isVideo,
awemeId: data.aweme_id,
videoRef,
volume: playerState.volume,
rate: playerState.rate,
loopMode: playerState.loopMode,
neighbors,
progressRestored: playerState.progressRestored,
setIsPlaying: playerState.setIsPlaying,
setProgress: playerState.setProgress,
setProgressRestored: playerState.setProgressRestored,
});
// 媒体操作函数
const seekTo = (ratio: number) => {
ratio = Math.min(1, Math.max(0, ratio));
if (isVideo) {
const v = videoRef.current;
if (!v || !v.duration) return;
v.currentTime = v.duration * ratio;
return;
}
if (!images?.length) return;
imageCarouselState.seekTo(ratio);
};
const seekTo = useCallback(
(ratio: number) => {
const clampedRatio = Math.min(1, Math.max(0, ratio));
if (isVideo) {
const v = videoRef.current;
if (!v || !v.duration) return;
v.currentTime = v.duration * clampedRatio;
return;
}
if (!imageCount) return;
imageCarouselState.seekTo(clampedRatio);
},
[imageCarouselState.seekTo, imageCount, isVideo],
);
const seekImageByVisualRatio = (ratio: number) => {
if (!images?.length) return;
if (!imageCount) return;
const clampedRatio = Math.min(1, Math.max(0, ratio));
const rawSegment = clampedRatio * images.length;
const targetIndex = Math.min(images.length - 1, Math.floor(rawSegment));
const rawSegment = clampedRatio * imageCount;
const targetIndex = Math.min(imageCount - 1, Math.floor(rawSegment));
const segmentProgress = Math.min(1, Math.max(0, rawSegment - targetIndex));
imageCarouselState.goToIndex(targetIndex, segmentProgress);
};
@@ -152,14 +173,12 @@ export default function AwemeDetailClient({
document.body.requestFullscreen().catch(() => {});
return;
}
const vRef = videoRef.current;
if (vRef && vRef.requestFullscreen) {
const vRef = videoRef.current as WebkitVideoElement | null;
if (vRef?.requestFullscreen) {
vRef.requestFullscreen().catch(() => {});
return;
}
// @ts-ignore
if (vRef && vRef.webkitEnterFullscreen) {
// @ts-ignore
if (vRef?.webkitEnterFullscreen) {
vRef.webkitEnterFullscreen();
}
} else {
@@ -167,20 +186,20 @@ export default function AwemeDetailClient({
}
};
const prevImg = () => {
if (!images?.length) return;
const prevImg = useCallback(() => {
if (!imageCount) return;
const next = Math.max(0, imageCarouselState.idxRef.current - 1);
imageCarouselState.goToIndex(next);
};
}, [imageCarouselState.goToIndex, imageCarouselState.idxRef, imageCount]);
const nextImg = () => {
if (!images?.length) return;
const nextImg = useCallback(() => {
if (!imageCount) return;
const next = Math.min(
images.length - 1,
imageCount - 1,
imageCarouselState.idxRef.current + 1,
);
imageCarouselState.goToIndex(next);
};
}, [imageCarouselState.goToIndex, imageCarouselState.idxRef, imageCount]);
const handleDownload = () => {
if (isVideo) {
@@ -249,7 +268,7 @@ export default function AwemeDetailClient({
if (typeof window === "undefined") 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}`;
@@ -282,12 +301,12 @@ export default function AwemeDetailClient({
const updatePosition = () => {
try {
// @ts-ignore: setPositionState 可能不存在
if (!ms.setPositionState) return;
if (isVideo) {
const v = videoRef.current;
if (!v || !isFinite(v.duration) || isNaN(v.duration)) return;
// @ts-ignore
if (!v || !Number.isFinite(v.duration) || Number.isNaN(v.duration)) {
return;
}
ms.setPositionState({
duration: Math.max(0, v.duration),
position: Math.max(0, v.currentTime),
@@ -300,14 +319,12 @@ export default function AwemeDetailClient({
0,
Math.min(duration, (playerState.progress * totalMs) / 1000),
);
// @ts-ignore
ms.setPositionState({ duration, position, playbackRate: 1 });
}
} catch {}
};
// 绑定视频事件以同步状态
let timeUpdateTimer: number | null = null;
const v = videoRef.current;
if (isVideo && v) {
const onPlay = () => {
@@ -331,7 +348,6 @@ export default function AwemeDetailClient({
updatePosition();
// 清理
timeUpdateTimer = null;
return () => {
v.removeEventListener("play", onPlay);
v.removeEventListener("pause", onPause);
@@ -346,15 +362,10 @@ export default function AwemeDetailClient({
}
// 依赖:当这些变化时刷新元信息与位置状态
}, [
isVideo,
data.aweme_id,
data.desc,
data.author.nickname,
data.author.avatar_url,
data.created_at,
imageCarouselState.idx,
data,
imageCarouselState.totalDurationMs,
images,
isVideo,
playerState.isPlaying,
playerState.progress,
playerState.rate,
@@ -364,7 +375,7 @@ export default function AwemeDetailClient({
useEffect(() => {
if (typeof window === "undefined") return;
if (!("mediaSession" in navigator)) return;
const ms = (navigator as any).mediaSession as MediaSession;
const ms = navigator.mediaSession;
const getImagesTotalMs = () => imageCarouselState.totalDurationMs;
@@ -421,7 +432,7 @@ export default function AwemeDetailClient({
const handleSeekTo = (seekTimeSec: number) => {
if (isVideo) {
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));
}
} else if (images?.length) {
@@ -434,13 +445,13 @@ export default function AwemeDetailClient({
ms.setActionHandler("play", handlePlay);
ms.setActionHandler("pause", handlePause);
ms.setActionHandler("stop", handleStop);
ms.setActionHandler("seekbackward", (details: any) =>
ms.setActionHandler("seekbackward", (details: MediaSessionActionDetails) =>
handleSeekDelta(-((details?.seekOffset as number) || 10)),
);
ms.setActionHandler("seekforward", (details: any) =>
ms.setActionHandler("seekforward", (details: MediaSessionActionDetails) =>
handleSeekDelta((details?.seekOffset as number) || 10),
);
ms.setActionHandler("seekto", (details: any) => {
ms.setActionHandler("seekto", (details: MediaSessionActionDetails) => {
if (typeof details?.seekTime === "number") handleSeekTo(details.seekTime);
});
ms.setActionHandler("previoustrack", () => {
@@ -477,8 +488,12 @@ export default function AwemeDetailClient({
neighbors.next,
router,
playerState.progress,
playerState.setIsPlaying,
pauseImageMedia,
imageCarouselState.totalDurationMs,
nextImg,
prevImg,
seekTo,
]);
return (
@@ -537,6 +552,14 @@ export default function AwemeDetailClient({
)}
{/* 媒体控制栏 */}
{isVideo && (
<VideoTagEditor
awemeId={data.aweme_id}
tags={contentTags}
onTagsChange={setContentTags}
/>
)}
<MediaControls
isVideo={isVideo}
isPlaying={playerState.isPlaying}
@@ -600,6 +623,7 @@ export default function AwemeDetailClient({
open={transcriptOpen}
onClose={() => setTranscriptOpen(false)}
transcript={transcript}
contentTags={contentTags}
/>
</div>
</div>
@@ -8,14 +8,17 @@ interface TranscriptPanelProps {
open: boolean;
onClose: () => void;
transcript: VideoTranscript | null;
contentTags?: string[];
}
export function TranscriptPanel({
open,
onClose,
transcript,
contentTags,
}: TranscriptPanelProps) {
const [copiedIndex, setCopiedIndex] = useState<number | null>(null);
const displayTags = contentTags ?? transcript?.content_tags ?? [];
const [copiedAll, setCopiedAll] = useState(false);
if (!open) return null;
@@ -94,9 +97,9 @@ export function TranscriptPanel({
语言: {transcript.language}
</p>
)}
{transcript.content_tags?.length > 0 && (
{displayTags.length > 0 && (
<div className="mt-2 flex flex-wrap gap-1.5">
{transcript.content_tags.map((tag) => (
{displayTags.map((tag) => (
<span
key={tag}
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 type { RefObject } from "react";
import { useEffect } from "react";
import type { LoopMode, Neighbors } from "../types.ts";
interface UseVideoPlayerProps {
enabled?: boolean;
awemeId: string;
videoRef: RefObject<HTMLVideoElement | null>;
volume: number;
@@ -17,6 +18,7 @@ interface UseVideoPlayerProps {
}
export function useVideoPlayer({
enabled = true,
awemeId,
videoRef,
volume,
@@ -32,6 +34,7 @@ export function useVideoPlayer({
// 恢复播放进度
useEffect(() => {
if (!enabled) return;
if (progressRestored) return;
const v = videoRef.current;
if (!v) return;
@@ -74,10 +77,11 @@ export function useVideoPlayer({
v.addEventListener("loadedmetadata", onLoadedMetadata, { once: true });
return () => v.removeEventListener("loadedmetadata", onLoadedMetadata);
}
}, [awemeId, progressRestored, videoRef, setProgressRestored]);
}, [awemeId, enabled, progressRestored, videoRef, setProgressRestored]);
// 保存播放进度
useEffect(() => {
if (!enabled) return;
const v = videoRef.current;
if (!v) return;
@@ -105,10 +109,11 @@ export function useVideoPlayer({
window.removeEventListener("beforeunload", onBeforeUnload);
saveProgress();
};
}, [awemeId, videoRef]);
}, [awemeId, enabled, videoRef]);
// 监听播放状态和进度
useEffect(() => {
if (!enabled) return;
const v = videoRef.current;
if (!v) return;
@@ -137,10 +142,19 @@ export function useVideoPlayer({
v.removeEventListener("pause", onPause);
v.removeEventListener("ended", onEnded);
};
}, [loopMode, neighbors?.next, router, videoRef, setIsPlaying, setProgress]);
}, [
enabled,
loopMode,
neighbors?.next,
router,
videoRef,
setIsPlaying,
setProgress,
]);
// 自动播放检测
useEffect(() => {
if (!enabled) return;
const v = videoRef.current;
if (!v) return;
@@ -148,7 +162,7 @@ export function useVideoPlayer({
try {
await v.play();
setIsPlaying(true);
} catch (error) {
} catch {
console.log("自动播放被阻止,需要用户交互");
setIsPlaying(false);
}
@@ -160,16 +174,18 @@ export function useVideoPlayer({
v.addEventListener("loadedmetadata", checkAutoplay, { once: true });
return () => v.removeEventListener("loadedmetadata", checkAutoplay);
}
}, [awemeId, videoRef, setIsPlaying]);
}, [enabled, videoRef, setIsPlaying]);
// 更新音量和倍速
useEffect(() => {
if (!enabled) return;
const v = videoRef.current;
if (v) v.volume = volume;
}, [volume, videoRef]);
}, [enabled, volume, videoRef]);
useEffect(() => {
if (!enabled) return;
const v = videoRef.current;
if (v) v.playbackRate = rate;
}, [rate, videoRef]);
}, [enabled, rate, videoRef]);
}
+68 -55
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 BackButton from "@/app/components/BackButton";
import { resolveEffectiveContentTags } from "@/lib/asr-tags";
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({
params,
@@ -53,7 +54,7 @@ export default async function AwemeDetail({
const [video, post] = await Promise.all([
prisma.video.findUnique({
where: { aweme_id: id },
include: { author: true },
include: { author: true, transcript: true },
}),
prisma.imagePost.findUnique({
where: { aweme_id: id },
@@ -63,65 +64,77 @@ export default async function AwemeDetail({
if (!video && !post) return <main className="p-8">找不到该作品</main>;
const isVideo = !!video;
const isVideo = Boolean(video);
// 获取评论总数
const commentsCount = await prisma.comment.count({
where: isVideo ? { videoId: id } : { imagePostId: id },
});
const aweme = isVideo ? video : post;
const data: AwemeData = {
aweme_id: aweme!.aweme_id,
desc: aweme!.desc,
created_at: aweme!.created_at,
likesCount: Number(aweme!.digg_count),
commentsCount,
author: {
nickname: aweme!.author.nickname,
avatar_url: getFileUrl(aweme!.author.avatar_url || "default-avatar.png"),
sec_uid: aweme!.author.sec_uid,
},
...(() => {
if (isVideo) {
const aweme = video!;
return {
type: "video" as const,
cover_url: getFileUrl(aweme!.cover_url ?? "default-cover.png"),
cover_size: { h: aweme.height ?? 0, w: aweme.width ?? 0 },
duration_ms: aweme!.duration_ms,
video_url: getFileUrl(aweme!.video_url),
width: aweme!.width ?? null,
height: aweme!.height ?? null,
};
} else {
const aweme = post!;
return {
type: "image" as const,
cover_url: getFileUrl(aweme!.images[0].url ?? "default-cover.png"),
cover_size: {
h: aweme!.images[0].height ?? 0,
w: aweme!.images[0].width ?? 0,
},
images: aweme!.images.map((img) => ({
...img,
url: getFileUrl(img.url),
animated: img.animated ? getFileUrl(img.animated) : null,
})),
music_url: getFileUrl(aweme!.music_url || "default-music.mp3"),
};
}
})(),
};
let data: AwemeData;
let currentCreatedAt: Date;
let transcript: VideoTranscript | null = null;
const transcript: VideoTranscript | null = isVideo
? await prisma.videoTranscript.findUnique({
where: { videoId: id },
})
: null;
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,
author: {
nickname: video.author.nickname,
avatar_url: getFileUrl(video.author.avatar_url || "default-avatar.png"),
sec_uid: video.author.sec_uid,
},
cover_url: getFileUrl(video.cover_url ?? "default-cover.png"),
cover_size: { h: video.height ?? 0, w: video.width ?? 0 },
duration_ms: video.duration_ms,
video_url: getFileUrl(video.video_url),
width: video.width ?? null,
height: video.height ?? null,
content_tags: resolveEffectiveContentTags(
video.manual_content_tags,
transcript?.content_tags,
),
manual_content_tags: video.manual_content_tags,
asr_content_tags: transcript?.content_tags ?? [],
};
} else if (post) {
const coverImage = post.images[0];
currentCreatedAt = post.created_at;
data = {
type: "image",
aweme_id: post.aweme_id,
desc: post.desc,
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,
},
cover_url: getFileUrl(coverImage?.url ?? "default-cover.png"),
cover_size: {
h: coverImage?.height ?? 0,
w: coverImage?.width ?? 0,
},
images: post.images.map((img) => ({
...img,
url: getFileUrl(img.url),
animated: img.animated ? getFileUrl(img.animated) : null,
})),
music_url: getFileUrl(post.music_url || "default-music.mp3"),
};
} else {
return <main className="p-8">找不到该作品</main>;
}
// 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([
prisma.video.findFirst({
where: { created_at: { gt: currentCreatedAt } },
+3
View File
@@ -24,6 +24,9 @@ export type VideoData = {
video_url: string;
width: number | null;
height: number | null;
content_tags: string[];
manual_content_tags: string[];
asr_content_tags: string[];
author: User;
commentsCount: number;
likesCount: number;