diff --git a/README.md b/README.md index 2950c50..361646c 100644 --- a/README.md +++ b/README.md @@ -104,3 +104,9 @@ npx tsx scripts/stt-smoke.ts 7682010237578136858 ``` 脚本将供应商任务 ID 和结果保存在 `/tmp/douyin-asr-<视频ID>.json`。已有未完成任务时应查询原任务,不要重复提交。 + +### 手动标签兼容 + +保留服务器版本的手动标签、首页批量打标签、详情页编辑及标签筛选;标签只来自 `Video.manual_content_tags`,不再回退到模型自动分类。转写成功、失败或重试都不会修改手动标签。后台转写列表支持手动标签筛选与批量添加/清空。 + +已有 `/admin/asr`、`/admin/asr/videos`、`/api/admin/asr/videos` 地址继续可用;`/api/asr` 与 `/api/stt` 一样,POST 入队、GET 只读。旧内存批处理和分块模型调用已移除。 diff --git a/app/admin/asr/page.tsx b/app/admin/asr/page.tsx new file mode 100644 index 0000000..49b7813 --- /dev/null +++ b/app/admin/asr/page.tsx @@ -0,0 +1,139 @@ +"use client"; + +import Link from "next/link"; +import BackButton from "@/app/components/BackButton"; + +export default function AsrAdminPage() { + return ( +
+
+ + +
+

ASR 管理中心

+

管理和配置视频语音识别功能

+
+ +
+ {/* 视频转写管理 */} + +
+
+ +
+
+

+ 视频转写管理 +

+

+ 查看和管理所有视频的转写状态,对待转写的视频进行批量或单个转写 +

+
+ 进入管理 + +
+
+
+ + + {/* 转写设置(预留) */} +
+
+
+ +
+
+

+ 转写设置 +

+

+ 配置转写 API、模型参数和其他高级选项 +

+
即将推出
+
+
+
+ + {/* 转写统计(预留) */} +
+
+
+ +
+
+

+ 转写统计 +

+

+ 查看转写使用情况、成功率、语言分布等统计数据 +

+
即将推出
+
+
+
+
+
+
+ ); +} diff --git a/app/admin/asr/videos/page.tsx b/app/admin/asr/videos/page.tsx new file mode 100644 index 0000000..367dbfb --- /dev/null +++ b/app/admin/asr/videos/page.tsx @@ -0,0 +1 @@ +export { default } from "../../stt/videos/page"; diff --git a/app/admin/stt/page.tsx b/app/admin/stt/page.tsx index 5214bff..d809a49 100644 --- a/app/admin/stt/page.tsx +++ b/app/admin/stt/page.tsx @@ -1,135 +1 @@ -"use client"; - -import Link from "next/link"; -import BackButton from "@/app/components/BackButton"; - -export default function SttAdminPage() { - return ( -
-
- - -
-

STT 管理中心

-

管理和配置视频语音转写功能

-
- -
- {/* 视频转写管理 */} - -
-
- - - -
-
-

- 视频转写管理 -

-

- 查看和管理所有视频的转写状态,对待转写的视频进行批量或单个转写 -

-
- 进入管理 - - - -
-
-
- - - {/* 转写设置(预留) */} -
-
-
- - - - -
-
-

- 转写设置 -

-

- 配置转写 API、模型参数和其他高级选项 -

-
即将推出
-
-
-
- - {/* 转写统计(预留) */} -
-
-
- - - -
-
-

- 转写统计 -

-

- 查看转写使用情况、成功率、语言分布等统计数据 -

-
即将推出
-
-
-
-
-
-
- ); -} +export { default } from "../asr/page"; diff --git a/app/admin/stt/videos/page.tsx b/app/admin/stt/videos/page.tsx index 06ce3a5..dd31885 100644 --- a/app/admin/stt/videos/page.tsx +++ b/app/admin/stt/videos/page.tsx @@ -3,6 +3,7 @@ import Image from "next/image"; import Link from "next/link"; import { useCallback, useEffect, useRef, useState } from "react"; +import { VIDEO_CONTENT_TAGS } from "@/lib/video-tags"; import BackButton from "@/app/components/BackButton"; import { isActive, @@ -18,6 +19,7 @@ type Video = { duration_ms: number; author: { nickname: string }; transcription: TranscriptionState; + content_tags: string[]; }; const filters: ("all" | TranscriptionStatus)[] = [ "all", @@ -36,6 +38,10 @@ export default function SttVideosPage() { const [submitting, setSubmitting] = useState>(new Set()); const [batchSubmitting, setBatchSubmitting] = useState(false); const [filter, setFilter] = useState<"all" | TranscriptionStatus>("all"); + const [selected, setSelected] = useState>(new Set()); + const [tag, setTag] = useState(VIDEO_CONTENT_TAGS[0]); + const [tagFilter, setTagFilter] = useState(""); + const [savingTags, setSavingTags] = useState(false); const fetching = useRef(false); const fetchVideos = useCallback(async () => { if (fetching.current) return; @@ -104,6 +110,28 @@ export default function SttVideosPage() { setBatchSubmitting(false); } } + async function saveTags(clear = false) { + setSavingTags(true); + try { + const response = await fetch("/api/asr/tags", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + awemeIds: [...selected], + contentTags: clear ? [] : [tag], + mode: clear ? "set" : "add", + }), + }); + if (!response.ok) throw new Error("保存手动标签失败"); + setNotice(`已更新 ${selected.size} 个视频的手动标签`); + setSelected(new Set()); + await fetchVideos(); + } catch (err) { + setNotice(err instanceof Error ? err.message : "保存失败"); + } finally { + setSavingTags(false); + } + } const count = (status: TranscriptionStatus) => videos.filter((v) => v.transcription.status === status).length; const eligible = videos.filter( @@ -111,7 +139,9 @@ export default function SttVideosPage() { v.transcription.transcript === null && !isActive(v.transcription.status), ).length; const shown = videos.filter( - (v) => filter === "all" || v.transcription.status === filter, + (v) => + (filter === "all" || v.transcription.status === filter) && + (!tagFilter || v.content_tags.includes(tagFilter)), ); return ( @@ -155,10 +185,70 @@ export default function SttVideosPage() { {batchSubmitting ? "正在加入队列…" : `批量补转 (${eligible})`} +
+ + 已选 {selected.size} 个(最多 200 个) + + + +
+ @@ -175,6 +265,24 @@ export default function SttVideosPage() { key={video.aweme_id} className="border-t border-gray-100 align-top" > + diff --git a/app/api/admin/asr/videos/route.ts b/app/api/admin/asr/videos/route.ts new file mode 100644 index 0000000..8088631 --- /dev/null +++ b/app/api/admin/asr/videos/route.ts @@ -0,0 +1 @@ +export { GET } from "../../stt/videos/route"; diff --git a/app/api/admin/stt/videos/route.ts b/app/api/admin/stt/videos/route.ts index cff2223..78bc5df 100644 --- a/app/api/admin/stt/videos/route.ts +++ b/app/api/admin/stt/videos/route.ts @@ -31,6 +31,7 @@ export async function GET() { author: { nickname: v.author.nickname, }, + content_tags: v.manual_content_tags, transcription: transcriptionState(v.transcript, v.transcriptionTask), })); diff --git a/app/api/asr/route.ts b/app/api/asr/route.ts new file mode 100644 index 0000000..64a93cf --- /dev/null +++ b/app/api/asr/route.ts @@ -0,0 +1 @@ +export { GET, POST } from "../stt/route"; diff --git a/app/api/asr/tags/route.ts b/app/api/asr/tags/route.ts new file mode 100644 index 0000000..f6e3b65 --- /dev/null +++ b/app/api/asr/tags/route.ts @@ -0,0 +1,121 @@ +export const runtime = "nodejs"; + +import { type NextRequest, NextResponse } from "next/server"; +import { isVideoContentTag } from "@/lib/video-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" || !isVideoContentTag(tag)) { + throw new Error("Invalid tag"); + } + if (!validTags.includes(tag)) validTags.push(tag); + } + return validTags; +} + +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, + }, + }); + + if (videos.length === 0) { + return NextResponse.json({ error: "No videos found" }, { status: 404 }); + } + + const updates = videos.map((video) => { + const baseTags = video.manual_content_tags; + const manualTags = + mode === "add" + ? [...new Set([...baseTags, ...contentTags])] + : contentTags; + return { awemeId: video.aweme_id, manualTags }; + }); + + 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: update.manualTags, + })), + missing: normalizedAwemeIds.filter( + (id) => !videos.some((video) => video.aweme_id === id), + ), + }); +} diff --git a/app/api/feed/route.ts b/app/api/feed/route.ts index 1ef8ec4..241adf5 100644 --- a/app/api/feed/route.ts +++ b/app/api/feed/route.ts @@ -1,7 +1,8 @@ -import { NextRequest, NextResponse } from "next/server"; -import { prisma } from "@/lib/prisma"; +import { type NextRequest, NextResponse } from "next/server"; import type { FeedItem, FeedResponse } from "@/app/types/feed"; +import { isVideoContentTag } from "@/lib/video-tags"; import { getFileUrl } from "@/lib/minio"; +import { prisma } from "@/lib/prisma"; // Contract // Inputs: search params { before?: ISOString, limit?: number } @@ -11,24 +12,45 @@ export async function GET(req: NextRequest) { const { searchParams } = new URL(req.url); const limitParam = searchParams.get("limit"); const beforeParam = searchParams.get("before"); + const rawTag = (searchParams.get("tag") || "").trim(); + const selectedTag = rawTag && isVideoContentTag(rawTag) ? rawTag : null; + + if (rawTag && !selectedTag) { + return NextResponse.json({ error: "Invalid tag" }, { status: 400 }); + } const limit = Math.min(Math.max(Number(limitParam ?? "24"), 1), 60); // 1..60 const before = beforeParam ? new Date(beforeParam) : null; + const videoWhere = { + ...(before ? { created_at: { lt: before } } : {}), + ...(selectedTag + ? { + manual_content_tags: { has: selectedTag }, + } + : {}), + }; // fetch chunk from both tables const [videos, posts] = await Promise.all([ prisma.video.findMany({ - where: before ? { created_at: { lt: before } } : undefined, + where: videoWhere, orderBy: { created_at: "desc" }, take: limit, - include: { author: true }, - }), - prisma.imagePost.findMany({ - where: before ? { created_at: { lt: before } } : undefined, - orderBy: { created_at: "desc" }, - take: limit, - include: { author: true, images: { orderBy: { order: "asc" }, take: 1 } }, + include: { + author: true, + }, }), + selectedTag + ? Promise.resolve([]) + : prisma.imagePost.findMany({ + where: before ? { created_at: { lt: before } } : undefined, + orderBy: { created_at: "desc" }, + take: limit, + include: { + author: true, + images: { orderBy: { order: "asc" }, take: 1 }, + }, + }), ]); const merged: FeedItem[] = [ @@ -47,29 +69,33 @@ export async function GET(req: NextRequest) { sec_uid: v.author.sec_uid, }, likes: Number(v.digg_count), + content_tags: v.manual_content_tags, })), - ...posts.map((p) => ({ - type: "image" as const, - aweme_id: p.aweme_id, - created_at: p.created_at, - desc: p.desc, - cover_url: getFileUrl(p.images?.[0]?.url ?? null), - width: p.images?.[0]?.width ?? null, - height: p.images?.[0]?.height ?? null, - author: { - nickname: p.author.nickname, - avatar_url: getFileUrl(p.author.avatar_url ?? ""), - sec_uid: p.author.sec_uid, - }, - likes: Number(p.digg_count), - })), + ...posts.map((p) => { + const coverPath = p.images?.[0]?.url; + return { + type: "image" as const, + aweme_id: p.aweme_id, + created_at: p.created_at, + desc: p.desc, + cover_url: coverPath ? getFileUrl(coverPath) : null, + width: p.images?.[0]?.width ?? null, + height: p.images?.[0]?.height ?? null, + author: { + nickname: p.author.nickname, + avatar_url: getFileUrl(p.author.avatar_url ?? ""), + sec_uid: p.author.sec_uid, + }, + likes: Number(p.digg_count), + }; + }), ] .sort((a, b) => +new Date(b.created_at) - +new Date(a.created_at)) .slice(0, limit); const nextCursor = merged.length > 0 - ? new Date(merged[merged.length - 1].created_at as any).toISOString() + ? new Date(merged[merged.length - 1].created_at).toISOString() : null; const payload: FeedResponse = { items: merged, nextCursor }; return NextResponse.json(payload); diff --git a/app/api/search/route.ts b/app/api/search/route.ts index 4bc728c..d9ed851 100644 --- a/app/api/search/route.ts +++ b/app/api/search/route.ts @@ -1,11 +1,32 @@ +import { NextResponse } from "next/server"; +import { isVideoContentTag } from "@/lib/video-tags"; import { json } from "@/lib/json"; import { getFileUrl } from "@/lib/minio"; -import { prisma } from "@/lib/prisma"; // 你的 Prisma 客户端实例 -import { NextResponse } from "next/server"; +import { prisma } from "@/lib/prisma"; + +type SearchRow = { + id: string; + awemeId: string; + type: "video" | "image"; + rank: number; + snippet: string; + contentTags: string[]; +}; + +function sanitizeSnippetHtml(snippet: string) { + return snippet + .replaceAll("&", "&") + .replaceAll("<", "<") + .replaceAll(">", ">") + .replaceAll("<mark>", "") + .replaceAll("</mark>", ""); +} export async function GET(req: Request) { const { searchParams } = new URL(req.url); const q = (searchParams.get("q") || "").trim(); + const rawTag = (searchParams.get("tag") || "").trim(); + const contentTag = rawTag && isVideoContentTag(rawTag) ? rawTag : null; const page = Math.max(1, Number(searchParams.get("page") || 1)); const limit = Math.min( 50, @@ -13,7 +34,11 @@ export async function GET(req: Request) { ); const offset = (page - 1) * limit; - if (!q) { + if (rawTag && !contentTag) { + return NextResponse.json({ error: "Invalid tag" }, { status: 400 }); + } + + if (!q && !contentTag) { return NextResponse.json({ results: [], total: 0 }); } @@ -23,90 +48,117 @@ export async function GET(req: Request) { // - 合并搜索来源:VideoTranscript.transcript_zh_tsv、Video.desc 与 ImagePost.desc // - 使用 UNION ALL 合并视频和图文的搜索结果 // - rank 取两者匹配得分的最大值,snippet 优先展示转写匹配,否则展示 desc 匹配 - const rows = await prisma.$queryRaw< - { - id: string; - awemeId: string; - type: "video" | "image"; - rank: number; - snippet: string; - }[] - >` - WITH tsq AS ( - SELECT websearch_to_tsquery('zhcfg', ${q}) AS query - ) - -- 视频搜索结果 - SELECT - COALESCE(vt.id, 'desc:' || v.aweme_id) AS id, - v.aweme_id AS "awemeId", - 'video'::text AS type, - GREATEST( - COALESCE(ts_rank(vt.transcript_zh_tsv, tsq.query), 0), - COALESCE(ts_rank(to_tsvector('zhcfg', v.desc), tsq.query), 0) - ) AS rank, - CASE - WHEN vt.transcript_zh_tsv @@ tsq.query THEN + const rows = q + ? await prisma.$queryRaw` + WITH tsq AS ( + SELECT websearch_to_tsquery('zhcfg', ${q}) AS query + ) + -- 视频搜索结果 + SELECT + COALESCE(vt.id, 'desc:' || v.aweme_id) AS id, + v.aweme_id AS "awemeId", + 'video'::text AS type, + GREATEST( + COALESCE(ts_rank(vt.transcript_zh_tsv, tsq.query), 0), + COALESCE(ts_rank(to_tsvector('zhcfg', v.desc), tsq.query), 0) + ) AS rank, + CASE + WHEN vt.transcript_zh_tsv @@ tsq.query THEN + ts_headline( + 'zhcfg', + array_to_string(vt.transcript, ' '), + tsq.query, + 'StartSel=,StopSel=,MaxFragments=2,MinWords=2,MaxWords=20' + ) + ELSE + ts_headline( + 'zhcfg', + v.desc, + tsq.query, + 'StartSel=,StopSel=,MaxFragments=2,MinWords=2,MaxWords=20' + ) + END AS snippet, + v.manual_content_tags AS "contentTags" + FROM "Video" v + LEFT JOIN "VideoTranscript" vt ON vt."videoId" = v.aweme_id, + tsq + WHERE + (vt.transcript_zh_tsv @@ tsq.query OR to_tsvector('zhcfg', v.desc) @@ tsq.query) + AND CASE + WHEN ${contentTag}::text IS NULL THEN TRUE + ELSE v.manual_content_tags @> ARRAY[${contentTag}]::text[] + END + + UNION ALL + + -- 图文搜索结果 + SELECT + 'img_desc:' || ip.aweme_id AS id, + ip.aweme_id AS "awemeId", + 'image'::text AS type, + ts_rank(to_tsvector('zhcfg', ip.desc), tsq.query) AS rank, ts_headline( 'zhcfg', - array_to_string(vt.transcript, ' '), + ip.desc, tsq.query, 'StartSel=,StopSel=,MaxFragments=2,MinWords=2,MaxWords=20' - ) - ELSE - ts_headline( - 'zhcfg', + ) AS snippet, + ARRAY[]::text[] AS "contentTags" + FROM "ImagePost" ip, tsq + WHERE to_tsvector('zhcfg', ip.desc) @@ tsq.query + AND ${contentTag}::text IS NULL + + ORDER BY rank DESC + OFFSET ${offset} + LIMIT ${limit}; + ` + : await prisma.$queryRaw` + SELECT + COALESCE(vt.id, 'tag:' || v.aweme_id) AS id, + v.aweme_id AS "awemeId", + 'video'::text AS type, + 1.0::double precision AS rank, + COALESCE( + NULLIF(array_to_string(vt.transcript[1:2], ' '), ''), v.desc, - tsq.query, - 'StartSel=,StopSel=,MaxFragments=2,MinWords=2,MaxWords=20' - ) - END AS snippet - FROM "Video" v - LEFT JOIN "VideoTranscript" vt ON vt."videoId" = v.aweme_id, - tsq - WHERE - (vt.transcript_zh_tsv @@ tsq.query OR to_tsvector('zhcfg', v.desc) @@ tsq.query) - - UNION ALL - - -- 图文搜索结果 - SELECT - 'img_desc:' || ip.aweme_id AS id, - ip.aweme_id AS "awemeId", - 'image'::text AS type, - ts_rank(to_tsvector('zhcfg', ip.desc), tsq.query) AS rank, - ts_headline( - 'zhcfg', - ip.desc, - tsq.query, - 'StartSel=,StopSel=,MaxFragments=2,MinWords=2,MaxWords=20' - ) AS snippet - FROM "ImagePost" ip, tsq - WHERE to_tsvector('zhcfg', ip.desc) @@ tsq.query - - ORDER BY rank DESC - OFFSET ${offset} - LIMIT ${limit}; - `; + '标签匹配' + ) AS snippet, + v.manual_content_tags AS "contentTags" + FROM "Video" v + LEFT JOIN "VideoTranscript" vt ON vt."videoId" = v.aweme_id + WHERE v.manual_content_tags @> ARRAY[${contentTag}]::text[] + ORDER BY v.created_at DESC + OFFSET ${offset} + LIMIT ${limit}; + `; // 查询总数(参数化,避免注入) - const totalRows = await prisma.$queryRaw< - { - count: number; - }[] - >` - WITH tsq AS ( - SELECT websearch_to_tsquery('zhcfg', ${q}) AS query - ) - SELECT ( - (SELECT COUNT(*)::int FROM "Video" v - LEFT JOIN "VideoTranscript" vt ON vt."videoId" = v.aweme_id - WHERE (vt.transcript_zh_tsv @@ tsq.query OR to_tsvector('zhcfg', v.desc) @@ tsq.query)) - + - (SELECT COUNT(*)::int FROM "ImagePost" ip - WHERE to_tsvector('zhcfg', ip.desc) @@ tsq.query) - ) AS count - FROM tsq; - `; + const totalRows = q + ? await prisma.$queryRaw<{ count: number }[]>` + WITH tsq AS ( + SELECT websearch_to_tsquery('zhcfg', ${q}) AS query + ) + SELECT ( + (SELECT COUNT(*)::int FROM "Video" v + LEFT JOIN "VideoTranscript" vt ON vt."videoId" = v.aweme_id + WHERE (vt.transcript_zh_tsv @@ tsq.query OR to_tsvector('zhcfg', v.desc) @@ tsq.query) + AND CASE + WHEN ${contentTag}::text IS NULL THEN TRUE + ELSE v.manual_content_tags @> ARRAY[${contentTag}]::text[] + END) + + + (SELECT COUNT(*)::int FROM "ImagePost" ip + WHERE to_tsvector('zhcfg', ip.desc) @@ tsq.query + AND ${contentTag}::text IS NULL) + ) AS count + FROM tsq; + ` + : await prisma.$queryRaw<{ count: number }[]>` + SELECT COUNT(*)::int AS count + FROM "Video" v + LEFT JOIN "VideoTranscript" vt ON vt."videoId" = v.aweme_id + WHERE v.manual_content_tags @> ARRAY[${contentTag}]::text[]; + `; // 分离视频和图文ID const videoIds = rows @@ -176,6 +228,7 @@ export async function GET(req: Request) { return json({ results: rows.map((r) => ({ ...r, + snippet: sanitizeSnippetHtml(r.snippet), video: r.type === "video" ? videos.find((v) => v.aweme_id === r.awemeId) diff --git a/app/aweme/[awemeId]/Client.tsx b/app/aweme/[awemeId]/Client.tsx index 904f68f..02e7a36 100644 --- a/app/aweme/[awemeId]/Client.tsx +++ b/app/aweme/[awemeId]/Client.tsx @@ -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( + isVideo ? (data as VideoData).content_tags : [], + ); // 引用 const mediaContainerRef = useRef(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 && ( + + )} + void; +}; + +export function VideoTagEditor({ + awemeId, + tags, + onTagsChange, +}: VideoTagEditorProps) { + const [open, setOpen] = useState(false); + const [draftTags, setDraftTags] = useState(tags); + const [saving, setSaving] = useState(false); + const [error, setError] = useState(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 ( +
+ + + {open && ( +
+
+
手动标签
+ +
+ +
+ {VIDEO_CONTENT_TAGS.map((tag) => { + const selected = draftTags.includes(tag); + return ( + + ); + })} +
+ + {error &&
{error}
} + +
+ + +
+
+ )} +
+ ); +} diff --git a/app/aweme/[awemeId]/hooks/useVideoPlayer.ts b/app/aweme/[awemeId]/hooks/useVideoPlayer.ts index 39defba..719869c 100644 --- a/app/aweme/[awemeId]/hooks/useVideoPlayer.ts +++ b/app/aweme/[awemeId]/hooks/useVideoPlayer.ts @@ -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; 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]); } diff --git a/app/aweme/[awemeId]/page.tsx b/app/aweme/[awemeId]/page.tsx index 58ed4eb..9e40f67 100644 --- a/app/aweme/[awemeId]/page.tsx +++ b/app/aweme/[awemeId]/page.tsx @@ -1,9 +1,9 @@ -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 { 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 +53,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 +63,73 @@ export default async function AwemeDetail({ if (!video && !post) return
找不到该作品
; - 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: video.manual_content_tags, + manual_content_tags: video.manual_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
找不到该作品
; + } // 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 } }, diff --git a/app/aweme/[awemeId]/types.ts b/app/aweme/[awemeId]/types.ts index a1630a1..29c1a4f 100644 --- a/app/aweme/[awemeId]/types.ts +++ b/app/aweme/[awemeId]/types.ts @@ -24,6 +24,8 @@ export type VideoData = { video_url: string; width: number | null; height: number | null; + content_tags: string[]; + manual_content_tags: string[]; author: User; commentsCount: number; likesCount: number; diff --git a/app/components/FeedMasonry.tsx b/app/components/FeedMasonry.tsx index b97d0bd..3b7540d 100644 --- a/app/components/FeedMasonry.tsx +++ b/app/components/FeedMasonry.tsx @@ -1,9 +1,17 @@ "use client"; -import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { Check, ThumbsUp, X } from "lucide-react"; +import Image from "next/image"; import Link from "next/link"; -import HoverVideo from "./HoverVideo"; -import { ThumbsUp } from "lucide-react"; +import { useCallback, useEffect, useRef, useState } from "react"; import type { FeedItem, FeedResponse } from "@/app/types/feed"; +import { VIDEO_CONTENT_TAGS } from "@/lib/video-tags"; +import HoverVideo from "./HoverVideo"; + +const COLUMN_KEYS = ["feed-col-0", "feed-col-1", "feed-col-2", "feed-col-3"]; + +function getAspectRatio(item: FeedItem) { + return item.width && item.height ? `${item.width}/${item.height}` : undefined; +} type Props = { initialItems: FeedItem[]; @@ -11,6 +19,13 @@ type Props = { fetchUrl?: string; }; +type TagUpdateResponse = { + updated?: { + aweme_id: string; + content_tags: string[]; + }[]; +}; + export default function FeedMasonry({ initialItems, initialCursor, @@ -20,6 +35,10 @@ export default function FeedMasonry({ const [cursor, setCursor] = useState(initialCursor); const [loading, setLoading] = useState(false); const [ended, setEnded] = useState(!initialCursor); + const [selectedIds, setSelectedIds] = useState>(new Set()); + const [batchTag, setBatchTag] = useState(VIDEO_CONTENT_TAGS[0]); + const [batchSaving, setBatchSaving] = useState(false); + const [batchError, setBatchError] = useState(null); const sentinelRef = useRef(null); const containerRef = useRef(null); @@ -58,7 +77,7 @@ export default function FeedMasonry({ mediaH = (9 / 16) * colWidth; // 常见视频比例 } // 文本 + 作者栏的高度粗估 - const textH = 48; // 标题+标签区域 + const textH = item.content_tags?.length ? 72 : 48; // 标题+标签区域 const authorH = 48; // 作者行 const gap = 16; // 卡片内边距/间隙 return mediaH + textH + authorH + gap; @@ -92,8 +111,7 @@ export default function FeedMasonry({ } setColumns(newCols); setColHeights(newHeights); - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [columnCount]); + }, [columnCount, estimateItemHeight, initialItems]); const fetchMore = useCallback(async () => { if (loading || ended) return; @@ -136,7 +154,15 @@ export default function FeedMasonry({ } finally { setLoading(false); } - }, [cursor, ended, loading, columnCount, colHeights, estimateItemHeight]); + }, [ + cursor, + ended, + loading, + columnCount, + colHeights, + estimateItemHeight, + fetchUrl, + ]); useEffect(() => { const el = sentinelRef.current; @@ -154,95 +180,193 @@ export default function FeedMasonry({ return () => io.disconnect(); }, [fetchMore]); - const renderCard = useCallback( - (item: FeedItem) => ( -
- -
- {item.type === "video" ? ( - - ) : ( - {item.desc?.slice(0, - )} -
-
-

- {item.desc} -

- - {item.type === "video" ? "视频" : "图文"} - -
-
- + 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); + }, []); -
- {item.author.sec_uid ? ( - -
- {item.author.avatar_url ? ( - avatar - ) : null} -
- - {item.author.nickname} - - - ) : ( -
-
- {item.author.avatar_url ? ( - avatar - ) : null} -
- - {item.author.nickname} - -
- )} - - {item.likes}{" "} - - -
-
- ), + 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( + (item: FeedItem) => { + const selected = selectedIds.has(item.aweme_id); + return ( +
+ {item.type === "video" && ( + + )} + +
+ {item.type === "video" ? ( + + ) : ( + + )} +
+
+
+

+ {item.desc} +

+ {!!item.content_tags?.length && ( +
+ {item.content_tags.map((tag) => ( + + {tag} + + ))} +
+ )} +
+ + {item.type === "video" ? "视频" : "图文"} + +
+
+ + +
+ {item.author.sec_uid ? ( + +
+ {item.author.avatar_url ? ( + + ) : null} +
+ + {item.author.nickname} + + + ) : ( +
+
+ {item.author.avatar_url ? ( + + ) : null} +
+ + {item.author.nickname} + +
+ )} + + {item.likes}{" "} + + +
+
+ ); + }, + [selectedIds, toggleSelection], + ); + return ( <> {/* Masonry(按列渲染,动态分配到最短列) */} @@ -254,7 +378,7 @@ export default function FeedMasonry({ }} > {columns.map((col, idx) => ( -
+
{col.map((item) => renderCard(item))}
))} @@ -265,6 +389,54 @@ export default function FeedMasonry({ > {ended ? "没有更多了" : loading ? "加载中…" : "下拉加载更多"}
+ {selectedIds.size > 0 && ( +
+
+
+ + 已选择 {selectedIds.size} 个视频 + + {batchError && ( + {batchError} + )} +
+
+ + + +
+
+
+ )} ); } diff --git a/app/components/SearchBox.tsx b/app/components/SearchBox.tsx index cf6f24f..396c123 100644 --- a/app/components/SearchBox.tsx +++ b/app/components/SearchBox.tsx @@ -1,10 +1,10 @@ "use client"; -import { useState } from "react"; -import { useRouter } from "next/navigation"; import { Search } from "lucide-react"; +import { useRouter } from "next/navigation"; +import { useState } from "react"; -export default function SearchBox() { +export default function SearchBox({ tag }: { tag?: string | null }) { const [query, setQuery] = useState(""); const router = useRouter(); @@ -12,7 +12,9 @@ export default function SearchBox() { e.preventDefault(); const trimmed = query.trim(); if (!trimmed) return; - router.push(`/search?q=${encodeURIComponent(trimmed)}`); + const params = new URLSearchParams({ q: trimmed }); + if (tag) params.set("tag", tag); + router.push(`/search?${params.toString()}`); }; return ( diff --git a/app/layout.tsx b/app/layout.tsx index 57f518b..35b5f80 100644 --- a/app/layout.tsx +++ b/app/layout.tsx @@ -8,7 +8,7 @@ export const metadata: Metadata = { }, description: "记录当下时代的精彩瞬间", icons: { - icon: "/favicon.png", + icon: "/favicon.webp", }, }; diff --git a/app/page.tsx b/app/page.tsx index 462f32b..f91c374 100644 --- a/app/page.tsx +++ b/app/page.tsx @@ -1,9 +1,11 @@ +import type { Metadata } from "next"; +import Link from "next/link"; +import { VIDEO_CONTENT_TAGS, isVideoContentTag } from "@/lib/video-tags"; +import { getFileUrl } from "@/lib/minio"; import { prisma } from "@/lib/prisma"; import FeedMasonry from "./components/FeedMasonry"; import SearchBox from "./components/SearchBox"; import type { FeedItem } from "./types/feed"; -import type { Metadata } from "next"; -import { getFileUrl } from "@/lib/minio"; export const dynamic = "force-dynamic"; @@ -12,18 +14,38 @@ export const metadata: Metadata = { description: "抖歪作品集,记录当下时代的精彩瞬间", }; -export default async function Home() { +export default async function Home({ + searchParams, +}: { + searchParams?: { tag?: string }; +}) { + const requestedTag = searchParams?.tag?.trim() || ""; + const selectedTag = + requestedTag && isVideoContentTag(requestedTag) ? requestedTag : null; + const [videos, posts] = await Promise.all([ prisma.video.findMany({ + where: selectedTag + ? { + manual_content_tags: { has: selectedTag }, + } + : undefined, orderBy: { created_at: "desc" }, take: 60, - include: { author: true }, - }), - prisma.imagePost.findMany({ - orderBy: { created_at: "desc" }, - take: 60, - include: { author: true, images: { orderBy: { order: "asc" }, take: 1 } }, + include: { + author: true, + }, }), + selectedTag + ? Promise.resolve([]) + : prisma.imagePost.findMany({ + orderBy: { created_at: "desc" }, + take: 60, + include: { + author: true, + images: { orderBy: { order: "asc" }, take: 1 }, + }, + }), ]); const feed: FeedItem[] = [ @@ -42,22 +64,26 @@ export default async function Home() { sec_uid: v.author.sec_uid, }, likes: Number(v.digg_count), + content_tags: v.manual_content_tags, })), - ...posts.map((p) => ({ - type: "image" as const, - aweme_id: p.aweme_id, - created_at: p.created_at, - desc: p.desc, - cover_url: getFileUrl(p.images?.[0]?.url ?? null), - width: p.images?.[0]?.width ?? null, - height: p.images?.[0]?.height ?? null, - author: { - nickname: p.author.nickname, - avatar_url: getFileUrl(p.author.avatar_url ?? ""), - sec_uid: p.author.sec_uid, - }, - likes: Number(p.digg_count), - })), + ...posts.map((p) => { + const coverPath = p.images?.[0]?.url; + return { + type: "image" as const, + aweme_id: p.aweme_id, + created_at: p.created_at, + desc: p.desc, + cover_url: coverPath ? getFileUrl(coverPath) : null, + width: p.images?.[0]?.width ?? null, + height: p.images?.[0]?.height ?? null, + author: { + nickname: p.author.nickname, + avatar_url: getFileUrl(p.author.avatar_url ?? ""), + sec_uid: p.author.sec_uid, + }, + likes: Number(p.digg_count), + }; + }), ] //.sort(() => Math.random() - 0.5) .sort((a, b) => +new Date(b.created_at) - +new Date(a.created_at)); @@ -75,20 +101,53 @@ export default async function Home() {
- + +
+ +
+ + 全部 + + {VIDEO_CONTENT_TAGS.map((tag) => ( + + {tag} + + ))}
{(() => { const initial = feed.slice(0, 24); - const cursor = - initial.length > 0 - ? new Date( - initial[initial.length - 1].created_at as any, - ).toISOString() - : null; + const lastItem = initial.at(-1); + const cursor = lastItem + ? new Date(lastItem.created_at).toISOString() + : null; return ( - + ); })()}
diff --git a/app/search/SearchClient.tsx b/app/search/SearchClient.tsx index 1defd57..3167fe8 100644 --- a/app/search/SearchClient.tsx +++ b/app/search/SearchClient.tsx @@ -1,9 +1,11 @@ "use client"; -import { FormEvent, useCallback, useEffect, useState } from "react"; -import { useSearchParams, useRouter } from "next/navigation"; +import { ArrowLeft, MessageSquare, Search, X } from "lucide-react"; +import Image from "next/image"; import Link from "next/link"; -import { Search, ArrowLeft, X, MessageSquare } from "lucide-react"; +import { useRouter, useSearchParams } from "next/navigation"; +import { type FormEvent, useCallback, useEffect, useState } from "react"; +import { VIDEO_CONTENT_TAGS, isVideoContentTag } from "@/lib/video-tags"; // 匹配搜索结果类型(适配后端API) type SearchResultItem = { @@ -12,6 +14,7 @@ type SearchResultItem = { type: "video" | "image"; rank: number; snippet: string; // 后端返回的高亮片段,已包含标签 + contentTags: string[]; video?: { aweme_id: string; desc: string; @@ -47,9 +50,20 @@ export default function SearchClient({ const [results, setResults] = useState([]); const [loading, setLoading] = useState(false); const [searched, setSearched] = useState(false); + const [selectedTag, setSelectedTag] = useState(null); - const performSearch = useCallback(async (q: string) => { - if (!q.trim()) { + const buildSearchHref = useCallback((q: string, tag: string | null) => { + const params = new URLSearchParams(); + const trimmed = q.trim(); + if (trimmed) params.set("q", trimmed); + if (tag) params.set("tag", tag); + const queryString = params.toString(); + return queryString ? `/search?${queryString}` : "/search"; + }, []); + + const performSearch = useCallback(async (q: string, tag: string | null) => { + const trimmed = q.trim(); + if (!trimmed && !tag) { setResults([]); setSearched(false); return; @@ -57,9 +71,10 @@ export default function SearchClient({ setLoading(true); setSearched(true); try { - const res = await fetch( - `/api/search?q=${encodeURIComponent(q.trim())}&limit=60`, - ); + const params = new URLSearchParams({ limit: "60" }); + if (trimmed) params.set("q", trimmed); + if (tag) params.set("tag", tag); + const res = await fetch(`/api/search?${params.toString()}`); if (!res.ok) throw new Error("Search failed"); const data = await res.json(); setResults(data.results || []); @@ -74,32 +89,64 @@ export default function SearchClient({ // 初始 & URL 变化时同步(用 searchParams 订阅 URL 更稳妥) useEffect(() => { const q = searchParams.get("q") || ""; + const tagParam = searchParams.get("tag") || ""; + const tag = tagParam && isVideoContentTag(tagParam) ? tagParam : null; setQuery(q); - if (q) performSearch(q); + setSelectedTag(tag); + if (q || tag) { + performSearch(q, tag); + } else { + setResults([]); + setSearched(false); + } }, [searchParams, performSearch]); const handleSearch = (e: FormEvent) => { e.preventDefault(); const trimmed = query.trim(); - if (!trimmed) return; - router.push(`/search?q=${encodeURIComponent(trimmed)}`); - // 可省略:上面的 push 会触发上面的 effect 从 URL 拉取并搜索 - // performSearch(trimmed); + if (!trimmed && !selectedTag) return; + router.push(buildSearchHref(trimmed, selectedTag)); + }; + + const handleTagSelect = (tag: string) => { + const nextTag = selectedTag === tag ? null : tag; + setSelectedTag(nextTag); + router.push(buildSearchHref(query, nextTag)); }; const handleClear = () => { setQuery(""); + setSelectedTag(null); setResults([]); setSearched(false); router.push("/search"); }; + const renderSnippet = (snippet: string) => { + let highlighted = false; + let offset = 0; + return snippet.split(/(|<\/mark>)/g).map((part) => { + offset += part.length; + if (part === "") { + highlighted = true; + return null; + } + if (part === "") { + highlighted = false; + return null; + } + if (!part) return null; + return highlighted ? {part} : part; + }); + }; + return (
{/* 顶部搜索栏 */}
+
+
+ {VIDEO_CONTENT_TAGS.map((tag) => ( + + ))} +
+
{/* 结果区域 */} @@ -165,7 +229,7 @@ export default function SearchClient({
)} - {!loading && !searched && !query && ( + {!loading && !searched && !query && !selectedTag && (

输入关键词开始搜索

@@ -207,11 +271,18 @@ export default function SearchClient({ target="_blank" className="flex-shrink-0 w-64 h-80 sm:w-70 sm:h-96 rounded-xl overflow-hidden bg-zinc-900 group" > - {content.desc} + {content.cover_url ? ( + + ) : ( +
+ )} {/* 右侧:信息区 */} @@ -231,6 +302,14 @@ export default function SearchClient({ 匹配度: {(item.rank * 100).toFixed(1)}% + {item.contentTags.map((tag) => ( + + {tag} + + ))}
{/* 描述 */} @@ -245,11 +324,18 @@ export default function SearchClient({ {/* 作者信息 */}
- {content.author.nickname} + {content.author.avatar_url ? ( + + ) : ( +
+ )} {content.author.nickname} @@ -264,11 +350,12 @@ export default function SearchClient({
+ > + {renderSnippet(item.snippet)} +
diff --git a/app/types/feed.ts b/app/types/feed.ts index 2483c31..179ae4e 100644 --- a/app/types/feed.ts +++ b/app/types/feed.ts @@ -15,6 +15,7 @@ export type FeedItem = ( cover_url: string | null; width?: number | null; height?: number | null; + content_tags?: string[]; }; export interface FeedResponse { diff --git a/lib/video-tags.ts b/lib/video-tags.ts new file mode 100644 index 0000000..81a71ca --- /dev/null +++ b/lib/video-tags.ts @@ -0,0 +1,24 @@ +export const VIDEO_CONTENT_TAGS = [ + "美女", + "游戏", + "时政", + "美食", + "旅行", + "体育", + "科技", + "教育", + "宠物", + "音乐", + "影视", + "财经", + "生活", + "其他", +] as const; + +export type VideoContentTag = (typeof VIDEO_CONTENT_TAGS)[number]; + +const videoContentTagSet = new Set(VIDEO_CONTENT_TAGS); + +export function isVideoContentTag(value: string): value is VideoContentTag { + return videoContentTagSet.has(value); +} diff --git a/prisma/migrations/20260429_add_asr_content_tags/migration.sql b/prisma/migrations/20260429_add_asr_content_tags/migration.sql new file mode 100644 index 0000000..dd2b163 --- /dev/null +++ b/prisma/migrations/20260429_add_asr_content_tags/migration.sql @@ -0,0 +1,5 @@ +ALTER TABLE "VideoTranscript" +ADD COLUMN "content_tags" TEXT[] NOT NULL DEFAULT ARRAY[]::TEXT[]; + +CREATE INDEX "video_transcript_content_tags_idx" +ON "VideoTranscript" USING GIN ("content_tags"); \ No newline at end of file diff --git a/prisma/migrations/20260429_add_manual_video_tags/migration.sql b/prisma/migrations/20260429_add_manual_video_tags/migration.sql new file mode 100644 index 0000000..171ae8b --- /dev/null +++ b/prisma/migrations/20260429_add_manual_video_tags/migration.sql @@ -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"); \ No newline at end of file diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 92ace21..b34c4ee 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -38,6 +38,7 @@ model Video { createdAt DateTime @default(now()) updatedAt DateTime @updatedAt tags String[] + manual_content_tags String[] @default([]) video_url String height Int? width Int? @@ -49,6 +50,8 @@ model Video { transcript VideoTranscript? transcriptionTask TranscriptionTask? + @@index([manual_content_tags], map: "video_manual_content_tags_idx", type: Gin) + @@index([authorId]) @@index([created_at]) } diff --git a/public/favicon.png b/public/favicon.png deleted file mode 100644 index 8997f3e..0000000 Binary files a/public/favicon.png and /dev/null differ diff --git a/public/favicon.webp b/public/favicon.webp new file mode 100644 index 0000000..3b78c32 Binary files /dev/null and b/public/favicon.webp differ diff --git a/tests/stt.integration.test.ts b/tests/stt.integration.test.ts index e64bc3a..1627297 100644 --- a/tests/stt.integration.test.ts +++ b/tests/stt.integration.test.ts @@ -16,6 +16,9 @@ import { } from "../lib/stt/tasks"; import { processTask } from "../lib/stt/worker"; +import { POST as updateTags } from "../app/api/asr/tags/route"; +import { GET as searchVideos } from "../app/api/search/route"; + const database = process.env.TEST_DATABASE_URL; const migration = readFileSync( "prisma/migrations/20261003000000_plain_transcription/migration.sql", @@ -67,6 +70,12 @@ describe("PostgreSQL transcription lifecycle", { skip: !database }, () => { sql( readFileSync("prisma/migrations/20251025_baseline/migration.sql", "utf8"), ); + sql( + readFileSync( + "prisma/migrations/20260429_add_manual_video_tags/migration.sql", + "utf8", + ), + ); await prisma.author.create({ data: { sec_uid: "test-author", @@ -104,6 +113,9 @@ describe("PostgreSQL transcription lifecycle", { skip: !database }, () => { CREATE TEXT SEARCH CONFIGURATION public.zhcfg (COPY = pg_catalog.simple); CREATE FUNCTION public.vt_update_transcript_zh_tsv() RETURNS trigger AS $$ BEGIN RETURN NEW; END; $$ LANGUAGE plpgsql; CREATE TRIGGER trg_vt_transcript_zh_tsv BEFORE INSERT OR UPDATE OF transcript ON "VideoTranscript" FOR EACH ROW EXECUTE FUNCTION public.vt_update_transcript_zh_tsv();`); + sql( + `UPDATE "Video" SET manual_content_tags = ARRAY['科技'] WHERE aweme_id = 'historical';`, + ); sql(migration); sql(migration); // migration DDL is repeatable, historical text is unchanged }); @@ -120,6 +132,14 @@ describe("PostgreSQL transcription lifecycle", { skip: !database }, () => { await prisma.$disconnect(); }); test("migration preserves historical and empty text and rebuilds search", async () => { + assert.deepEqual( + ( + await prisma.video.findUniqueOrThrow({ + where: { aweme_id: "historical" }, + }) + ).manual_content_tags, + ["科技"], + ); assert.equal( (await readTranscription("empty-history"))?.status, "SUCCEEDED", @@ -132,6 +152,42 @@ describe("PostgreSQL transcription lifecycle", { skip: !database }, () => { >`SELECT COUNT(*) FROM "VideoTranscript" WHERE transcript_zh_tsv @@ websearch_to_tsquery('zhcfg', 'original')`; assert.equal(Number(rows[0].count), 1); }); + test("manual tags survive migration and remain editable and searchable without ASR tags", async () => { + const result = await updateTags( + request("http://local/api/asr/tags", { + awemeId: "v1", + mode: "add", + contentTags: ["科技"], + }), + ); + assert.equal(result.status, 200); + assert.deepEqual((await result.json()).updated[0].content_tags, ["科技"]); + const search = await searchVideos( + new Request("http://local/api/search?tag=" + encodeURIComponent("科技")), + ); + assert.equal(search.status, 200); + const found = await search.json(); + assert.ok( + found.results.some((r: { awemeId: string }) => r.awemeId === "v1"), + ); + assert.ok( + found.results.some( + (r: { awemeId: string }) => r.awemeId === "historical", + ), + ); + await updateTags( + request("http://local/api/asr/tags", { + awemeId: "v1", + mode: "set", + contentTags: [], + }), + ); + assert.deepEqual( + (await prisma.video.findUniqueOrThrow({ where: { aweme_id: "v1" } })) + .manual_content_tags, + [], + ); + }); test("concurrent enqueue is idempotent; automatic enqueue never retranscribes history", async () => { await Promise.all( Array.from({ length: 10 }, () => enqueueTranscription("v1", true)),
+ 0 && + shown.slice(0, 200).every((v) => selected.has(v.aweme_id)) + } + onChange={(e) => + setSelected( + e.target.checked + ? new Set(shown.slice(0, 200).map((v) => v.aweme_id)) + : new Set(), + ) + } + /> + 视频 时长 状态与文字 + = 200 + } + onChange={(e) => + setSelected((previous) => { + const next = new Set(previous); + if (e.target.checked) next.add(video.aweme_id); + else next.delete(video.aweme_id); + return next; + }) + } + /> +
{video.cover_url && ( @@ -197,6 +305,9 @@ export default function SttVideosPage() {

@{video.author.nickname}

+

+ {video.content_tags.join("、")} +