@@ -3,26 +3,27 @@
|
|||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import BackButton from "@/app/components/BackButton";
|
import BackButton from "@/app/components/BackButton";
|
||||||
|
|
||||||
export default function SttAdminPage() {
|
export default function AsrAdminPage() {
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-gray-50">
|
<div className="min-h-screen bg-gray-50">
|
||||||
<div className="max-w-4xl mx-auto p-8">
|
<div className="max-w-4xl mx-auto p-8">
|
||||||
<BackButton />
|
<BackButton />
|
||||||
|
|
||||||
<div className="mt-8">
|
<div className="mt-8">
|
||||||
<h1 className="text-3xl font-bold text-gray-900">STT 管理中心</h1>
|
<h1 className="text-3xl font-bold text-gray-900">ASR 管理中心</h1>
|
||||||
<p className="text-gray-600 mt-2">管理和配置视频语音转写功能</p>
|
<p className="text-gray-600 mt-2">管理和配置视频语音识别功能</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="mt-8 grid gap-6">
|
<div className="mt-8 grid gap-6">
|
||||||
{/* 视频转写管理 */}
|
{/* 视频转写管理 */}
|
||||||
<Link
|
<Link
|
||||||
href="/admin/stt/videos"
|
href="/admin/asr/videos"
|
||||||
className="block bg-white rounded-lg shadow-md hover:shadow-lg transition-shadow p-6 border border-gray-200 hover:border-blue-500"
|
className="block bg-white rounded-lg shadow-md hover:shadow-lg transition-shadow p-6 border border-gray-200 hover:border-blue-500"
|
||||||
>
|
>
|
||||||
<div className="flex items-start gap-4">
|
<div className="flex items-start gap-4">
|
||||||
<div className="flex-shrink-0 w-12 h-12 bg-blue-100 rounded-lg flex items-center justify-center">
|
<div className="flex-shrink-0 w-12 h-12 bg-blue-100 rounded-lg flex items-center justify-center">
|
||||||
<svg
|
<svg
|
||||||
|
aria-hidden="true"
|
||||||
className="w-6 h-6 text-blue-600"
|
className="w-6 h-6 text-blue-600"
|
||||||
fill="none"
|
fill="none"
|
||||||
stroke="currentColor"
|
stroke="currentColor"
|
||||||
@@ -46,6 +47,7 @@ export default function SttAdminPage() {
|
|||||||
<div className="mt-4 text-blue-600 font-medium flex items-center gap-1">
|
<div className="mt-4 text-blue-600 font-medium flex items-center gap-1">
|
||||||
进入管理
|
进入管理
|
||||||
<svg
|
<svg
|
||||||
|
aria-hidden="true"
|
||||||
className="w-4 h-4"
|
className="w-4 h-4"
|
||||||
fill="none"
|
fill="none"
|
||||||
stroke="currentColor"
|
stroke="currentColor"
|
||||||
@@ -68,6 +70,7 @@ export default function SttAdminPage() {
|
|||||||
<div className="flex items-start gap-4">
|
<div className="flex items-start gap-4">
|
||||||
<div className="flex-shrink-0 w-12 h-12 bg-gray-100 rounded-lg flex items-center justify-center">
|
<div className="flex-shrink-0 w-12 h-12 bg-gray-100 rounded-lg flex items-center justify-center">
|
||||||
<svg
|
<svg
|
||||||
|
aria-hidden="true"
|
||||||
className="w-6 h-6 text-gray-600"
|
className="w-6 h-6 text-gray-600"
|
||||||
fill="none"
|
fill="none"
|
||||||
stroke="currentColor"
|
stroke="currentColor"
|
||||||
@@ -104,6 +107,7 @@ export default function SttAdminPage() {
|
|||||||
<div className="flex items-start gap-4">
|
<div className="flex items-start gap-4">
|
||||||
<div className="flex-shrink-0 w-12 h-12 bg-gray-100 rounded-lg flex items-center justify-center">
|
<div className="flex-shrink-0 w-12 h-12 bg-gray-100 rounded-lg flex items-center justify-center">
|
||||||
<svg
|
<svg
|
||||||
|
aria-hidden="true"
|
||||||
className="w-6 h-6 text-gray-600"
|
className="w-6 h-6 text-gray-600"
|
||||||
fill="none"
|
fill="none"
|
||||||
stroke="currentColor"
|
stroke="currentColor"
|
||||||
@@ -1,7 +1,8 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useState, useEffect } from "react";
|
import Image from "next/image";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
|
import { useCallback, useEffect, useState } from "react";
|
||||||
import BackButton from "@/app/components/BackButton";
|
import BackButton from "@/app/components/BackButton";
|
||||||
|
|
||||||
type VideoTranscript = {
|
type VideoTranscript = {
|
||||||
@@ -9,8 +10,9 @@ type VideoTranscript = {
|
|||||||
speech_detected: boolean;
|
speech_detected: boolean;
|
||||||
language: string | null;
|
language: string | null;
|
||||||
audio_type: string | null;
|
audio_type: string | null;
|
||||||
transcript: string | null;
|
transcript: string[];
|
||||||
non_speech_summary: string | null;
|
non_speech_summary: string | null;
|
||||||
|
content_tags: string[];
|
||||||
};
|
};
|
||||||
|
|
||||||
type Video = {
|
type Video = {
|
||||||
@@ -29,25 +31,21 @@ type ApiResponse = {
|
|||||||
total: number;
|
total: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function SttVideosPage() {
|
export default function AsrVideosPage() {
|
||||||
const [videos, setVideos] = useState<Video[]>([]);
|
const [videos, setVideos] = useState<Video[]>([]);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [transcribing, setTranscribing] = useState<Set<string>>(new Set());
|
const [transcribing, setTranscribing] = useState<Set<string>>(new Set());
|
||||||
const [filter, setFilter] = useState<"all" | "transcribed" | "pending">(
|
const [filter, setFilter] = useState<
|
||||||
"all",
|
"all" | "transcribed" | "pending" | "tagged" | "missingTags"
|
||||||
);
|
>("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 });
|
||||||
|
|
||||||
useEffect(() => {
|
const fetchVideos = useCallback(async () => {
|
||||||
fetchVideos();
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const fetchVideos = async () => {
|
|
||||||
try {
|
try {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
const response = await fetch("/api/admin/stt/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");
|
||||||
}
|
}
|
||||||
@@ -58,34 +56,41 @@ export default function SttVideosPage() {
|
|||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
};
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchVideos();
|
||||||
|
}, [fetchVideos]);
|
||||||
|
|
||||||
// 简单的 sleep 工具
|
// 简单的 sleep 工具
|
||||||
const sleep = (ms: number) =>
|
const sleep = (ms: number) =>
|
||||||
new Promise((resolve) => setTimeout(resolve, ms));
|
new Promise((resolve) => setTimeout(resolve, ms));
|
||||||
|
|
||||||
|
const hasContentTags = (video: Video) =>
|
||||||
|
(video.transcript?.content_tags.length ?? 0) > 0;
|
||||||
|
|
||||||
type PollOptions = {
|
type PollOptions = {
|
||||||
intervalMs?: number;
|
intervalMs?: number;
|
||||||
maxAttempts?: number;
|
maxAttempts?: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
// 轮询某个视频的转写状态直至完成或超时
|
// 轮询某个视频的识别和标签状态直至完成或超时
|
||||||
const pollVideoUntilTranscribed = async (
|
const pollVideoUntilTranscribed = async (
|
||||||
awemeId: string,
|
awemeId: string,
|
||||||
{ intervalMs = 2000, maxAttempts = 30 }: PollOptions = {},
|
{ intervalMs = 2000, maxAttempts = 30 }: PollOptions = {},
|
||||||
): Promise<boolean> => {
|
): Promise<boolean> => {
|
||||||
for (let attempt = 0; attempt < maxAttempts; attempt++) {
|
for (let attempt = 0; attempt < maxAttempts; attempt++) {
|
||||||
try {
|
try {
|
||||||
const resp = await fetch("/api/admin/stt/videos");
|
const resp = await fetch("/api/admin/asr/videos");
|
||||||
if (resp.ok) {
|
if (resp.ok) {
|
||||||
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 && v.transcript) {
|
if ((v?.transcript?.content_tags.length ?? 0) > 0) {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch {
|
||||||
// 忽略网络错误,继续轮询
|
// 忽略网络错误,继续轮询
|
||||||
}
|
}
|
||||||
await sleep(intervalMs);
|
await sleep(intervalMs);
|
||||||
@@ -96,7 +101,7 @@ export default function SttVideosPage() {
|
|||||||
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/stt?awemeId=${awemeId}`);
|
const response = await fetch(`/api/asr?awemeId=${awemeId}`);
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
throw new Error("Failed to transcribe video");
|
throw new Error("Failed to transcribe video");
|
||||||
}
|
}
|
||||||
@@ -118,15 +123,15 @@ export default function SttVideosPage() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleBatchTranscribe = async () => {
|
const handleBatchTranscribe = async () => {
|
||||||
const pendingVideos = videos.filter((v) => v.transcript === null);
|
const pendingVideos = videos.filter((v) => !hasContentTags(v));
|
||||||
if (pendingVideos.length === 0) {
|
if (pendingVideos.length === 0) {
|
||||||
alert("没有待转写的视频");
|
alert("没有缺失标签的视频");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (
|
if (
|
||||||
!confirm(
|
!confirm(
|
||||||
`确定要转写 ${pendingVideos.length} 个视频吗?这可能需要较长时间。`,
|
`确定要为 ${pendingVideos.length} 个视频生成 ASR 标签吗?这会重新执行识别流程,可能需要较长时间。`,
|
||||||
)
|
)
|
||||||
) {
|
) {
|
||||||
return;
|
return;
|
||||||
@@ -142,7 +147,7 @@ export default function SttVideosPage() {
|
|||||||
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/stt?awemeId=${video.aweme_id}`);
|
const response = await fetch(`/api/asr?awemeId=${video.aweme_id}`);
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
console.error(`Failed to transcribe video ${video.aweme_id}`);
|
console.error(`Failed to transcribe video ${video.aweme_id}`);
|
||||||
}
|
}
|
||||||
@@ -167,12 +172,14 @@ export default function SttVideosPage() {
|
|||||||
await fetchVideos();
|
await fetchVideos();
|
||||||
setBatchTranscribing(false);
|
setBatchTranscribing(false);
|
||||||
setBatchProgress({ current: 0, total: 0 });
|
setBatchProgress({ current: 0, total: 0 });
|
||||||
alert("批量转写完成!");
|
alert("缺失标签生成完成!");
|
||||||
};
|
};
|
||||||
|
|
||||||
const filteredVideos = videos.filter((v) => {
|
const filteredVideos = videos.filter((v) => {
|
||||||
if (filter === "transcribed") return v.transcript !== null;
|
if (filter === "transcribed") return v.transcript !== null;
|
||||||
if (filter === "pending") return v.transcript === null;
|
if (filter === "pending") return v.transcript === null;
|
||||||
|
if (filter === "tagged") return hasContentTags(v);
|
||||||
|
if (filter === "missingTags") return !hasContentTags(v);
|
||||||
return true;
|
return true;
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -180,6 +187,8 @@ export default function SttVideosPage() {
|
|||||||
total: videos.length,
|
total: videos.length,
|
||||||
transcribed: videos.filter((v) => v.transcript !== null).length,
|
transcribed: videos.filter((v) => v.transcript !== null).length,
|
||||||
pending: videos.filter((v) => v.transcript === null).length,
|
pending: videos.filter((v) => v.transcript === null).length,
|
||||||
|
tagged: videos.filter((v) => hasContentTags(v)).length,
|
||||||
|
missingTags: videos.filter((v) => !hasContentTags(v)).length,
|
||||||
};
|
};
|
||||||
|
|
||||||
if (loading) {
|
if (loading) {
|
||||||
@@ -202,6 +211,7 @@ export default function SttVideosPage() {
|
|||||||
<div className="bg-red-50 border border-red-200 rounded-lg p-6 text-center">
|
<div className="bg-red-50 border border-red-200 rounded-lg p-6 text-center">
|
||||||
<p className="text-red-600">错误: {error}</p>
|
<p className="text-red-600">错误: {error}</p>
|
||||||
<button
|
<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"
|
||||||
>
|
>
|
||||||
@@ -220,13 +230,13 @@ export default function SttVideosPage() {
|
|||||||
<div className="mb-8">
|
<div className="mb-8">
|
||||||
<BackButton />
|
<BackButton />
|
||||||
<h1 className="text-3xl font-bold text-gray-900 mt-4">
|
<h1 className="text-3xl font-bold text-gray-900 mt-4">
|
||||||
视频转写管理
|
视频语音识别管理
|
||||||
</h1>
|
</h1>
|
||||||
<p className="text-gray-600 mt-2">管理视频的语音转写状态</p>
|
<p className="text-gray-600 mt-2">管理视频的语音识别和自动标签状态</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Stats */}
|
{/* Stats */}
|
||||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-6 mb-8">
|
<div className="grid grid-cols-1 md:grid-cols-4 gap-6 mb-8">
|
||||||
<div className="bg-white rounded-lg shadow p-6">
|
<div className="bg-white rounded-lg shadow p-6">
|
||||||
<div className="text-sm text-gray-600">总视频数</div>
|
<div className="text-sm text-gray-600">总视频数</div>
|
||||||
<div className="text-3xl font-bold text-gray-900 mt-2">
|
<div className="text-3xl font-bold text-gray-900 mt-2">
|
||||||
@@ -245,6 +255,15 @@ export default function SttVideosPage() {
|
|||||||
{stats.pending}
|
{stats.pending}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div className="bg-white rounded-lg shadow p-6">
|
||||||
|
<div className="text-sm text-gray-600">已生成标签</div>
|
||||||
|
<div className="text-3xl font-bold text-blue-600 mt-2">
|
||||||
|
{stats.tagged}
|
||||||
|
</div>
|
||||||
|
<div className="mt-1 text-xs text-gray-500">
|
||||||
|
缺失 {stats.missingTags}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Filter */}
|
{/* Filter */}
|
||||||
@@ -252,6 +271,7 @@ export default function SttVideosPage() {
|
|||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<div className="flex gap-4">
|
<div className="flex gap-4">
|
||||||
<button
|
<button
|
||||||
|
type="button"
|
||||||
onClick={() => setFilter("all")}
|
onClick={() => setFilter("all")}
|
||||||
className={`px-4 py-2 rounded-lg font-medium transition-colors ${
|
className={`px-4 py-2 rounded-lg font-medium transition-colors ${
|
||||||
filter === "all"
|
filter === "all"
|
||||||
@@ -262,6 +282,7 @@ export default function SttVideosPage() {
|
|||||||
全部 ({stats.total})
|
全部 ({stats.total})
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
|
type="button"
|
||||||
onClick={() => setFilter("transcribed")}
|
onClick={() => setFilter("transcribed")}
|
||||||
className={`px-4 py-2 rounded-lg font-medium transition-colors ${
|
className={`px-4 py-2 rounded-lg font-medium transition-colors ${
|
||||||
filter === "transcribed"
|
filter === "transcribed"
|
||||||
@@ -272,6 +293,7 @@ export default function SttVideosPage() {
|
|||||||
已转写 ({stats.transcribed})
|
已转写 ({stats.transcribed})
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
|
type="button"
|
||||||
onClick={() => setFilter("pending")}
|
onClick={() => setFilter("pending")}
|
||||||
className={`px-4 py-2 rounded-lg font-medium transition-colors ${
|
className={`px-4 py-2 rounded-lg font-medium transition-colors ${
|
||||||
filter === "pending"
|
filter === "pending"
|
||||||
@@ -281,25 +303,48 @@ export default function SttVideosPage() {
|
|||||||
>
|
>
|
||||||
待转写 ({stats.pending})
|
待转写 ({stats.pending})
|
||||||
</button>
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setFilter("tagged")}
|
||||||
|
className={`px-4 py-2 rounded-lg font-medium transition-colors ${
|
||||||
|
filter === "tagged"
|
||||||
|
? "bg-blue-600 text-white"
|
||||||
|
: "bg-gray-100 text-gray-700 hover:bg-gray-200"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
已生成标签 ({stats.tagged})
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setFilter("missingTags")}
|
||||||
|
className={`px-4 py-2 rounded-lg font-medium transition-colors ${
|
||||||
|
filter === "missingTags"
|
||||||
|
? "bg-rose-600 text-white"
|
||||||
|
: "bg-gray-100 text-gray-700 hover:bg-gray-200"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
缺标签 ({stats.missingTags})
|
||||||
|
</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">
|
||||||
正在转写: {batchProgress.current} / {batchProgress.total}
|
正在生成: {batchProgress.current} / {batchProgress.total}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<button
|
<button
|
||||||
|
type="button"
|
||||||
onClick={handleBatchTranscribe}
|
onClick={handleBatchTranscribe}
|
||||||
disabled={batchTranscribing || stats.pending === 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="px-6 py-2 bg-purple-600 text-white rounded-lg hover:bg-purple-700 disabled:bg-gray-400 disabled:cursor-not-allowed transition-colors font-medium"
|
||||||
>
|
>
|
||||||
{batchTranscribing ? (
|
{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 animate-spin rounded-full h-4 w-4 border-b-2 border-white"></span>
|
||||||
批量转写中...
|
生成中...
|
||||||
</span>
|
</span>
|
||||||
) : (
|
) : (
|
||||||
"全部转写"
|
"一键生成缺失标签"
|
||||||
)}
|
)}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -319,7 +364,7 @@ export default function SttVideosPage() {
|
|||||||
时长
|
时长
|
||||||
</th>
|
</th>
|
||||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||||
转写状态
|
识别/标签状态
|
||||||
</th>
|
</th>
|
||||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||||
操作
|
操作
|
||||||
@@ -331,9 +376,12 @@ export default function SttVideosPage() {
|
|||||||
<tr key={video.aweme_id} className="hover:bg-gray-50">
|
<tr key={video.aweme_id} className="hover:bg-gray-50">
|
||||||
<td className="px-6 py-4">
|
<td className="px-6 py-4">
|
||||||
<div className="flex items-center gap-4">
|
<div className="flex items-center gap-4">
|
||||||
<img
|
<Image
|
||||||
src={video.cover_url}
|
src={video.cover_url}
|
||||||
alt=""
|
alt=""
|
||||||
|
width={80}
|
||||||
|
height={112}
|
||||||
|
unoptimized
|
||||||
className="w-20 h-28 object-cover rounded"
|
className="w-20 h-28 object-cover rounded"
|
||||||
/>
|
/>
|
||||||
<div className="flex-1 min-w-0">
|
<div className="flex-1 min-w-0">
|
||||||
@@ -367,7 +415,7 @@ export default function SttVideosPage() {
|
|||||||
</div>
|
</div>
|
||||||
{video.transcript.speech_detected ? (
|
{video.transcript.speech_detected ? (
|
||||||
<div className="text-xs text-gray-600 line-clamp-5 max-w-md">
|
<div className="text-xs text-gray-600 line-clamp-5 max-w-md">
|
||||||
{video.transcript.transcript}
|
{video.transcript.transcript.join(" ")}
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="text-xs text-gray-500">
|
<div className="text-xs text-gray-500">
|
||||||
@@ -381,6 +429,23 @@ export default function SttVideosPage() {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
{video.transcript.content_tags.length > 0 && (
|
||||||
|
<div className="flex flex-wrap gap-1 pt-1">
|
||||||
|
{video.transcript.content_tags.map((tag) => (
|
||||||
|
<span
|
||||||
|
key={tag}
|
||||||
|
className="inline-flex items-center rounded bg-blue-50 px-2 py-0.5 text-xs font-medium text-blue-700"
|
||||||
|
>
|
||||||
|
{tag}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{video.transcript.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>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<span className="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-orange-100 text-orange-800">
|
<span className="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-orange-100 text-orange-800">
|
||||||
@@ -391,6 +456,7 @@ export default function SttVideosPage() {
|
|||||||
<td className="px-6 py-4 whitespace-nowrap text-sm">
|
<td className="px-6 py-4 whitespace-nowrap text-sm">
|
||||||
{video.transcript ? (
|
{video.transcript ? (
|
||||||
<button
|
<button
|
||||||
|
type="button"
|
||||||
onClick={() => handleTranscribe(video.aweme_id)}
|
onClick={() => handleTranscribe(video.aweme_id)}
|
||||||
disabled={transcribing.has(video.aweme_id)}
|
disabled={transcribing.has(video.aweme_id)}
|
||||||
className="px-4 py-2 bg-gray-600 text-white rounded-lg hover:bg-gray-700 disabled:bg-gray-400 disabled:cursor-not-allowed transition-colors"
|
className="px-4 py-2 bg-gray-600 text-white rounded-lg hover:bg-gray-700 disabled:bg-gray-400 disabled:cursor-not-allowed transition-colors"
|
||||||
@@ -398,14 +464,17 @@ export default function SttVideosPage() {
|
|||||||
{transcribing.has(video.aweme_id) ? (
|
{transcribing.has(video.aweme_id) ? (
|
||||||
<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 animate-spin rounded-full h-4 w-4 border-b-2 border-white"></span>
|
||||||
重新转写...
|
生成中...
|
||||||
</span>
|
</span>
|
||||||
|
) : video.transcript.content_tags.length === 0 ? (
|
||||||
|
"生成标签"
|
||||||
) : (
|
) : (
|
||||||
"重新转写"
|
"重新识别"
|
||||||
)}
|
)}
|
||||||
</button>
|
</button>
|
||||||
) : (
|
) : (
|
||||||
<button
|
<button
|
||||||
|
type="button"
|
||||||
onClick={() => handleTranscribe(video.aweme_id)}
|
onClick={() => handleTranscribe(video.aweme_id)}
|
||||||
disabled={transcribing.has(video.aweme_id)}
|
disabled={transcribing.has(video.aweme_id)}
|
||||||
className="px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 disabled:bg-blue-400 disabled:cursor-not-allowed transition-colors"
|
className="px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 disabled:bg-blue-400 disabled:cursor-not-allowed transition-colors"
|
||||||
@@ -413,10 +482,10 @@ export default function SttVideosPage() {
|
|||||||
{transcribing.has(video.aweme_id) ? (
|
{transcribing.has(video.aweme_id) ? (
|
||||||
<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 animate-spin rounded-full h-4 w-4 border-b-2 border-white"></span>
|
||||||
转写中...
|
识别中...
|
||||||
</span>
|
</span>
|
||||||
) : (
|
) : (
|
||||||
"开始转写"
|
"开始识别"
|
||||||
)}
|
)}
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
import { NextResponse } from "next/server";
|
import { NextResponse } from "next/server";
|
||||||
import { prisma } from "@/lib/prisma";
|
|
||||||
import { getFileUrl } from "@/lib/minio";
|
import { getFileUrl } from "@/lib/minio";
|
||||||
|
import { prisma } from "@/lib/prisma";
|
||||||
|
|
||||||
export async function GET() {
|
export async function GET() {
|
||||||
try {
|
try {
|
||||||
@@ -21,6 +21,7 @@ export async function GET() {
|
|||||||
audio_type: true,
|
audio_type: true,
|
||||||
transcript: true,
|
transcript: true,
|
||||||
non_speech_summary: true,
|
non_speech_summary: true,
|
||||||
|
content_tags: true,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -42,6 +43,7 @@ export async function GET() {
|
|||||||
audio_type: v.transcript.audio_type,
|
audio_type: v.transcript.audio_type,
|
||||||
transcript: v.transcript.transcript,
|
transcript: v.transcript.transcript,
|
||||||
non_speech_summary: v.transcript.non_speech_summary,
|
non_speech_summary: v.transcript.non_speech_summary,
|
||||||
|
content_tags: v.transcript.content_tags,
|
||||||
}
|
}
|
||||||
: null,
|
: null,
|
||||||
}));
|
}));
|
||||||
@@ -0,0 +1,227 @@
|
|||||||
|
import OpenAI from "openai";
|
||||||
|
import { z } from "zod";
|
||||||
|
import { ASR_CONTENT_TAGS, normalizeAsrContentTags } from "@/lib/asr-tags";
|
||||||
|
import { downloadFile } from "@/lib/minio";
|
||||||
|
import { prisma } from "@/lib/prisma";
|
||||||
|
import {
|
||||||
|
type ExtractedVideoFrame,
|
||||||
|
extractAudio,
|
||||||
|
extractVideoFrames,
|
||||||
|
} from "../media";
|
||||||
|
import prompt from "./prompt.md";
|
||||||
|
|
||||||
|
export type AsrLogger = (message: string) => void;
|
||||||
|
|
||||||
|
type TranscriptAwemeOptions = {
|
||||||
|
log?: AsrLogger;
|
||||||
|
};
|
||||||
|
|
||||||
|
let qwenClient: OpenAI | null = null;
|
||||||
|
|
||||||
|
function getQwenClient() {
|
||||||
|
if (qwenClient) return qwenClient;
|
||||||
|
|
||||||
|
const apiKey = process.env.QWEN_API_KEY || process.env.DASHSCOPE_API_KEY;
|
||||||
|
if (!apiKey) {
|
||||||
|
throw new Error("Missing QWEN_API_KEY environment variable");
|
||||||
|
}
|
||||||
|
|
||||||
|
qwenClient = new OpenAI({
|
||||||
|
apiKey,
|
||||||
|
baseURL:
|
||||||
|
process.env.QWEN_API_BASE_URL ||
|
||||||
|
"https://dashscope.aliyuncs.com/compatible-mode/v1",
|
||||||
|
});
|
||||||
|
|
||||||
|
return qwenClient;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Zod schema aligned with prompt.md
|
||||||
|
export const AsrSchema = z
|
||||||
|
.object({
|
||||||
|
speech_detected: z.boolean(),
|
||||||
|
language: z.string().min(1).nullable(),
|
||||||
|
audio_type: z.string().nullable(),
|
||||||
|
transcript: z.array(z.string()),
|
||||||
|
non_speech_summary: z.string().nullable(),
|
||||||
|
content_tags: z.array(z.enum(ASR_CONTENT_TAGS)).min(1).max(3),
|
||||||
|
})
|
||||||
|
.strict();
|
||||||
|
|
||||||
|
const RawAsrSchema = z.object({
|
||||||
|
speech_detected: z.boolean(),
|
||||||
|
language: z.string().min(1).nullable(),
|
||||||
|
audio_type: z.string().nullable(),
|
||||||
|
transcript: z.array(z.string()),
|
||||||
|
non_speech_summary: z.string().nullable(),
|
||||||
|
content_tags: z.unknown().optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export type AsrResult = z.infer<typeof AsrSchema>;
|
||||||
|
|
||||||
|
function extractJsonObject(text: string) {
|
||||||
|
const trimmed = text.trim();
|
||||||
|
const fenced = trimmed.match(/```(?:json)?\s*([\s\S]*?)\s*```/i);
|
||||||
|
if (fenced?.[1]) return fenced[1].trim();
|
||||||
|
|
||||||
|
const start = trimmed.indexOf("{");
|
||||||
|
const end = trimmed.lastIndexOf("}");
|
||||||
|
if (start >= 0 && end > start) {
|
||||||
|
return trimmed.slice(start, end + 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
return trimmed;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseAsrResult(content: string): AsrResult {
|
||||||
|
const parsed = RawAsrSchema.parse(JSON.parse(extractJsonObject(content)));
|
||||||
|
return AsrSchema.parse({
|
||||||
|
...parsed,
|
||||||
|
content_tags: normalizeAsrContentTags(parsed.content_tags),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function transcriptAudio(
|
||||||
|
audio: Buffer,
|
||||||
|
frames: ExtractedVideoFrame[],
|
||||||
|
videoDesc: string,
|
||||||
|
log?: AsrLogger,
|
||||||
|
) {
|
||||||
|
const base64Audio = Buffer.from(audio).toString("base64");
|
||||||
|
const client = getQwenClient();
|
||||||
|
const model = process.env.QWEN_OMNI_MODEL || "qwen3.5-omni-plus";
|
||||||
|
const frameContentParts = frames.map((frame, index) => ({
|
||||||
|
type: "image_url" as const,
|
||||||
|
image_url: {
|
||||||
|
url: `data:${frame.contentType};base64,${frame.buffer.toString("base64")}`,
|
||||||
|
},
|
||||||
|
index,
|
||||||
|
}));
|
||||||
|
|
||||||
|
log?.(
|
||||||
|
`调用 Qwen Omni: model=${model}, audio=${Math.round(audio.length / 1024)}KB, frames=${frames.length}`,
|
||||||
|
);
|
||||||
|
|
||||||
|
const completion = await client.chat.completions.create({
|
||||||
|
model,
|
||||||
|
messages: [
|
||||||
|
{
|
||||||
|
role: "user",
|
||||||
|
content: [
|
||||||
|
...frameContentParts.map(({ type, image_url }) => ({
|
||||||
|
type,
|
||||||
|
image_url,
|
||||||
|
})),
|
||||||
|
{
|
||||||
|
type: "input_audio",
|
||||||
|
input_audio: { data: `data:;base64,${base64Audio}`, format: "mp3" },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "text",
|
||||||
|
text: `${prompt}\n\n视频简介:\n${videoDesc || "无"}\n\n已附带视频三帧,时间点约为:${
|
||||||
|
frames
|
||||||
|
.map((frame) => `${(frame.timestampMs / 1000).toFixed(1)}s`)
|
||||||
|
.join("、") || "无"
|
||||||
|
}。请结合画面和音频判断内容标签。`,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
modalities: ["text"],
|
||||||
|
stream: true,
|
||||||
|
stream_options: { include_usage: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
let content = "";
|
||||||
|
for await (const chunk of completion) {
|
||||||
|
const deltaContent = chunk.choices?.[0]?.delta?.content;
|
||||||
|
if (typeof deltaContent === "string") content += deltaContent;
|
||||||
|
}
|
||||||
|
|
||||||
|
log?.(`Qwen Omni 原始输出: ${content}`);
|
||||||
|
|
||||||
|
if (!content.trim()) {
|
||||||
|
throw new Error("No ASR result returned from Qwen Omni");
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
return parseAsrResult(content);
|
||||||
|
} catch {
|
||||||
|
throw new Error("Failed to parse ASR result JSON");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function transcriptAweme(
|
||||||
|
awemeId: string,
|
||||||
|
options: TranscriptAwemeOptions = {},
|
||||||
|
): Promise<AsrResult> {
|
||||||
|
const log = options.log;
|
||||||
|
log?.(`查询视频: ${awemeId}`);
|
||||||
|
const aweme = await prisma.video.findUnique({
|
||||||
|
where: { aweme_id: awemeId },
|
||||||
|
});
|
||||||
|
if (!aweme) {
|
||||||
|
throw new Error("Aweme not found or aweme is not a video post");
|
||||||
|
}
|
||||||
|
const vPath = aweme.video_url;
|
||||||
|
log?.(`下载视频文件: ${vPath}`);
|
||||||
|
const buffer = await downloadFile(vPath);
|
||||||
|
log?.(`视频下载完成: ${Math.round(buffer.length / 1024)}KB`);
|
||||||
|
|
||||||
|
log?.("提取视频三帧");
|
||||||
|
const frames = await extractVideoFrames(buffer, {
|
||||||
|
count: 3,
|
||||||
|
durationMs: aweme.duration_ms,
|
||||||
|
});
|
||||||
|
log?.(
|
||||||
|
`视频帧提取完成: ${frames
|
||||||
|
.map((frame) => `${(frame.timestampMs / 1000).toFixed(1)}s`)
|
||||||
|
.join("、")}`,
|
||||||
|
);
|
||||||
|
|
||||||
|
log?.("提取音频");
|
||||||
|
const audioDat = await extractAudio(buffer, {
|
||||||
|
format: "mp3",
|
||||||
|
bitrateKbps: 128,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!audioDat || !audioDat.buffer) {
|
||||||
|
throw new Error("Failed to extract audio from video");
|
||||||
|
}
|
||||||
|
log?.(`音频提取完成: ${Math.round(audioDat.buffer.length / 1024)}KB`);
|
||||||
|
|
||||||
|
// 调用 Qwen Omni 生成转写与固定分类标签
|
||||||
|
const result = await transcriptAudio(
|
||||||
|
audioDat.buffer,
|
||||||
|
frames,
|
||||||
|
aweme.desc,
|
||||||
|
log,
|
||||||
|
);
|
||||||
|
log?.(`解析完成: tags=${result.content_tags.join("、")}`);
|
||||||
|
|
||||||
|
// 将转写结果持久化到数据库
|
||||||
|
log?.("写入数据库 VideoTranscript");
|
||||||
|
await prisma.videoTranscript.upsert({
|
||||||
|
where: { videoId: awemeId },
|
||||||
|
update: {
|
||||||
|
speech_detected: result.speech_detected,
|
||||||
|
language: result.language,
|
||||||
|
audio_type: result.audio_type,
|
||||||
|
transcript: result.transcript,
|
||||||
|
non_speech_summary: result.non_speech_summary,
|
||||||
|
content_tags: result.content_tags,
|
||||||
|
},
|
||||||
|
create: {
|
||||||
|
videoId: awemeId,
|
||||||
|
speech_detected: result.speech_detected,
|
||||||
|
language: result.language,
|
||||||
|
audio_type: result.audio_type,
|
||||||
|
transcript: result.transcript,
|
||||||
|
non_speech_summary: result.non_speech_summary,
|
||||||
|
content_tags: result.content_tags,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
log?.("数据库写入完成");
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,39 @@
|
|||||||
|
你将接收一段音频、视频中固定抽取的三帧画面,并可能收到一段视频描述作为辅助信息。请完成:语音活动检测(VAD)、声源分类、语音转文字,以及内容标签分类。
|
||||||
|
|
||||||
|
只能输出一个合法 JSON 对象,不要输出 Markdown、解释文字或代码块。
|
||||||
|
|
||||||
|
`content_tags` 必须从以下固定标签中选择 1 到 3 个,不能自造标签:
|
||||||
|
|
||||||
|
美女、游戏、时政、美食、旅行、体育、科技、教育、宠物、音乐、影视、财经、生活、其他
|
||||||
|
|
||||||
|
**输出 JSON Schema(示例)**
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"speech_detected": true,
|
||||||
|
"language": "zh-CN",
|
||||||
|
"audio_type": null,
|
||||||
|
"transcript": ["大家好,我是xxx", "欢迎来到今天的视频", "今天我们来聊聊AI的未来"],
|
||||||
|
"non_speech_summary": null,
|
||||||
|
"content_tags": ["科技", "教育"]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**当无发言时返回:**
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"speech_detected": false,
|
||||||
|
"language": null,
|
||||||
|
"audio_type": "music | ambience | animal | mechanical | other",
|
||||||
|
"transcript": [],
|
||||||
|
"non_speech_summary": "示例:纯音乐-钢琴独奏,节奏舒缓;或 环境声-雨声伴随雷鸣。",
|
||||||
|
"content_tags": ["音乐"]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
** 规则补充 **
|
||||||
|
|
||||||
|
* 允许`language` 为多标签(如 "zh-CN,en")或为 `null`(无发言时)。
|
||||||
|
* `transcript` 按自然语义分句,每段尽量保持完整短句。
|
||||||
|
* 必须结合三帧画面、音频内容和辅助视频描述判断标签;信息不足时使用 `其他`。
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
export const runtime = "nodejs";
|
||||||
|
|
||||||
|
import { type NextRequest, NextResponse } from "next/server";
|
||||||
|
import { transcriptAweme } from ".";
|
||||||
|
|
||||||
|
export async function GET(req: NextRequest) {
|
||||||
|
const { searchParams } = new URL(req.url);
|
||||||
|
const awemeId = searchParams.get("awemeId");
|
||||||
|
if (!awemeId) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "Missing awemeId parameter" },
|
||||||
|
{ status: 400 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const result = await transcriptAweme(awemeId, /* {log: (msg) => console.log(`[ASR] ${msg}`)} */);
|
||||||
|
return NextResponse.json(result);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("ASR failed:", error);
|
||||||
|
return NextResponse.json({ error: "ASR failed" }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
+35
-10
@@ -1,7 +1,8 @@
|
|||||||
import { NextRequest, NextResponse } from "next/server";
|
import { type NextRequest, NextResponse } from "next/server";
|
||||||
import { prisma } from "@/lib/prisma";
|
|
||||||
import type { FeedItem, FeedResponse } from "@/app/types/feed";
|
import type { FeedItem, FeedResponse } from "@/app/types/feed";
|
||||||
|
import { isAsrContentTag } from "@/lib/asr-tags";
|
||||||
import { getFileUrl } from "@/lib/minio";
|
import { getFileUrl } from "@/lib/minio";
|
||||||
|
import { prisma } from "@/lib/prisma";
|
||||||
|
|
||||||
// Contract
|
// Contract
|
||||||
// Inputs: search params { before?: ISOString, limit?: number }
|
// Inputs: search params { before?: ISOString, limit?: number }
|
||||||
@@ -11,23 +12,43 @@ export async function GET(req: NextRequest) {
|
|||||||
const { searchParams } = new URL(req.url);
|
const { searchParams } = new URL(req.url);
|
||||||
const limitParam = searchParams.get("limit");
|
const limitParam = searchParams.get("limit");
|
||||||
const beforeParam = searchParams.get("before");
|
const beforeParam = searchParams.get("before");
|
||||||
|
const rawTag = (searchParams.get("tag") || "").trim();
|
||||||
|
const selectedTag = rawTag && isAsrContentTag(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 limit = Math.min(Math.max(Number(limitParam ?? "24"), 1), 60); // 1..60
|
||||||
const before = beforeParam ? new Date(beforeParam) : null;
|
const before = beforeParam ? new Date(beforeParam) : null;
|
||||||
|
const videoWhere = {
|
||||||
|
...(before ? { created_at: { lt: before } } : {}),
|
||||||
|
...(selectedTag
|
||||||
|
? { transcript: { is: { content_tags: { has: selectedTag } } } }
|
||||||
|
: {}),
|
||||||
|
};
|
||||||
|
|
||||||
// fetch chunk from both tables
|
// fetch chunk from both tables
|
||||||
const [videos, posts] = await Promise.all([
|
const [videos, posts] = await Promise.all([
|
||||||
prisma.video.findMany({
|
prisma.video.findMany({
|
||||||
where: before ? { created_at: { lt: before } } : undefined,
|
where: videoWhere,
|
||||||
orderBy: { created_at: "desc" },
|
orderBy: { created_at: "desc" },
|
||||||
take: limit,
|
take: limit,
|
||||||
include: { author: true },
|
include: {
|
||||||
|
author: true,
|
||||||
|
transcript: { select: { content_tags: true } },
|
||||||
|
},
|
||||||
}),
|
}),
|
||||||
prisma.imagePost.findMany({
|
selectedTag
|
||||||
|
? Promise.resolve([])
|
||||||
|
: prisma.imagePost.findMany({
|
||||||
where: before ? { created_at: { lt: before } } : undefined,
|
where: before ? { created_at: { lt: before } } : undefined,
|
||||||
orderBy: { created_at: "desc" },
|
orderBy: { created_at: "desc" },
|
||||||
take: limit,
|
take: limit,
|
||||||
include: { author: true, images: { orderBy: { order: "asc" }, take: 1 } },
|
include: {
|
||||||
|
author: true,
|
||||||
|
images: { orderBy: { order: "asc" }, take: 1 },
|
||||||
|
},
|
||||||
}),
|
}),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
@@ -47,13 +68,16 @@ 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 ?? [],
|
||||||
})),
|
})),
|
||||||
...posts.map((p) => ({
|
...posts.map((p) => {
|
||||||
|
const coverPath = p.images?.[0]?.url;
|
||||||
|
return {
|
||||||
type: "image" as const,
|
type: "image" as const,
|
||||||
aweme_id: p.aweme_id,
|
aweme_id: p.aweme_id,
|
||||||
created_at: p.created_at,
|
created_at: p.created_at,
|
||||||
desc: p.desc,
|
desc: p.desc,
|
||||||
cover_url: getFileUrl(p.images?.[0]?.url ?? null),
|
cover_url: coverPath ? getFileUrl(coverPath) : null,
|
||||||
width: p.images?.[0]?.width ?? null,
|
width: p.images?.[0]?.width ?? null,
|
||||||
height: p.images?.[0]?.height ?? null,
|
height: p.images?.[0]?.height ?? null,
|
||||||
author: {
|
author: {
|
||||||
@@ -62,14 +86,15 @@ export async function GET(req: NextRequest) {
|
|||||||
sec_uid: p.author.sec_uid,
|
sec_uid: p.author.sec_uid,
|
||||||
},
|
},
|
||||||
likes: Number(p.digg_count),
|
likes: Number(p.digg_count),
|
||||||
})),
|
};
|
||||||
|
}),
|
||||||
]
|
]
|
||||||
.sort((a, b) => +new Date(b.created_at) - +new Date(a.created_at))
|
.sort((a, b) => +new Date(b.created_at) - +new Date(a.created_at))
|
||||||
.slice(0, limit);
|
.slice(0, limit);
|
||||||
|
|
||||||
const nextCursor =
|
const nextCursor =
|
||||||
merged.length > 0
|
merged.length > 0
|
||||||
? new Date(merged[merged.length - 1].created_at as any).toISOString()
|
? new Date(merged[merged.length - 1].created_at).toISOString()
|
||||||
: null;
|
: null;
|
||||||
const payload: FeedResponse = { items: merged, nextCursor };
|
const payload: FeedResponse = { items: merged, nextCursor };
|
||||||
return NextResponse.json(payload);
|
return NextResponse.json(payload);
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ import {
|
|||||||
releaseIsolatedContext,
|
releaseIsolatedContext,
|
||||||
} from "@/app/api/fetcher/browser";
|
} from "@/app/api/fetcher/browser";
|
||||||
import { extractFirstFrame } from "@/app/api/media";
|
import { extractFirstFrame } from "@/app/api/media";
|
||||||
import { transcriptAweme } from "../stt";
|
import { transcriptAweme } from "../asr";
|
||||||
|
|
||||||
const DETAIL_PATH = "/aweme/v1/web/aweme/detail/";
|
const DETAIL_PATH = "/aweme/v1/web/aweme/detail/";
|
||||||
const COMMENT_PATH = "/aweme/v1/web/comment/list/";
|
const COMMENT_PATH = "/aweme/v1/web/comment/list/";
|
||||||
|
|||||||
+136
-28
@@ -1,11 +1,28 @@
|
|||||||
import { execFile } from "child_process";
|
import { execFile } from "node:child_process";
|
||||||
import { promises as fs } from "fs";
|
import { promises as fs } from "node:fs";
|
||||||
import os from "os";
|
import os from "node:os";
|
||||||
import path from "path";
|
import path from "node:path";
|
||||||
import { promisify } from "util";
|
import { promisify } from "node:util";
|
||||||
|
|
||||||
const execFileAsync = promisify(execFile);
|
const execFileAsync = promisify(execFile);
|
||||||
|
|
||||||
|
function getErrorMessage(error: unknown) {
|
||||||
|
return error instanceof Error ? error.message : String(error);
|
||||||
|
}
|
||||||
|
|
||||||
|
function getErrorCode(error: unknown) {
|
||||||
|
return typeof error === "object" && error !== null && "code" in error
|
||||||
|
? String((error as { code?: unknown }).code)
|
||||||
|
: null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isMissingExecutableError(error: unknown) {
|
||||||
|
return (
|
||||||
|
getErrorCode(error) === "ENOENT" ||
|
||||||
|
/not found|is not recognized/i.test(getErrorMessage(error))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 使用 ffmpeg 从视频二进制中提取第一帧,返回 JPEG buffer
|
* 使用 ffmpeg 从视频二进制中提取第一帧,返回 JPEG buffer
|
||||||
*/
|
*/
|
||||||
@@ -40,18 +57,14 @@ export async function extractFirstFrame(
|
|||||||
await execFileAsync(ffmpegCmd, args, { windowsHide: true });
|
await execFileAsync(ffmpegCmd, args, { windowsHide: true });
|
||||||
const img = await fs.readFile(outPath);
|
const img = await fs.readFile(outPath);
|
||||||
return { buffer: img, contentType: "image/jpeg", ext: "jpg" };
|
return { buffer: img, contentType: "image/jpeg", ext: "jpg" };
|
||||||
} catch (e: any) {
|
} catch (error) {
|
||||||
if (
|
if (isMissingExecutableError(error)) {
|
||||||
e &&
|
|
||||||
(e.code === "ENOENT" ||
|
|
||||||
/not found|is not recognized/i.test(String(e.message)))
|
|
||||||
) {
|
|
||||||
console.warn(
|
console.warn(
|
||||||
"系统未检测到 ffmpeg,可安装并配置 PATH 或设置 FFMPEG_PATH 后启用封面提取。",
|
"系统未检测到 ffmpeg,可安装并配置 PATH 或设置 FFMPEG_PATH 后启用封面提取。",
|
||||||
);
|
);
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
throw e;
|
throw error;
|
||||||
} finally {
|
} finally {
|
||||||
try {
|
try {
|
||||||
await fs.unlink(inPath);
|
await fs.unlink(inPath);
|
||||||
@@ -62,6 +75,13 @@ export async function extractFirstFrame(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type ExtractedVideoFrame = {
|
||||||
|
buffer: Buffer;
|
||||||
|
contentType: string;
|
||||||
|
ext: string;
|
||||||
|
timestampMs: number;
|
||||||
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 使用 ffprobe 获取视频时长(毫秒)
|
* 使用 ffprobe 获取视频时长(毫秒)
|
||||||
*/
|
*/
|
||||||
@@ -88,20 +108,16 @@ export async function getVideoDuration(
|
|||||||
windowsHide: true,
|
windowsHide: true,
|
||||||
});
|
});
|
||||||
const durationSeconds = parseFloat(stdout.trim());
|
const durationSeconds = parseFloat(stdout.trim());
|
||||||
if (isNaN(durationSeconds)) return null;
|
if (Number.isNaN(durationSeconds)) return null;
|
||||||
return Math.round(durationSeconds * 1000); // 转换为毫秒
|
return Math.round(durationSeconds * 1000); // 转换为毫秒
|
||||||
} catch (e: any) {
|
} catch (error) {
|
||||||
if (
|
if (isMissingExecutableError(error)) {
|
||||||
e &&
|
|
||||||
(e.code === "ENOENT" ||
|
|
||||||
/not found|is not recognized/i.test(String(e.message)))
|
|
||||||
) {
|
|
||||||
console.warn(
|
console.warn(
|
||||||
"系统未检测到 ffprobe,可安装并配置 PATH 或设置 FFPROBE_PATH 后启用时长提取。",
|
"系统未检测到 ffprobe,可安装并配置 PATH 或设置 FFPROBE_PATH 后启用时长提取。",
|
||||||
);
|
);
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
console.warn(`获取视频时长失败: ${e?.message || e}`);
|
console.warn(`获取视频时长失败: ${getErrorMessage(error)}`);
|
||||||
return null;
|
return null;
|
||||||
} finally {
|
} finally {
|
||||||
try {
|
try {
|
||||||
@@ -110,6 +126,102 @@ export async function getVideoDuration(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function getFrameTimestamps(durationMs: number | null, count: number) {
|
||||||
|
if (!durationMs || durationMs <= 0) {
|
||||||
|
return Array.from({ length: count }, (_, index) => index * 1000);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (count === 1) return [0];
|
||||||
|
|
||||||
|
const maxTimestamp = Math.max(0, durationMs - 200);
|
||||||
|
if (count === 3) {
|
||||||
|
return [
|
||||||
|
Math.min(maxTimestamp, Math.max(0, Math.round(durationMs * 0.1))),
|
||||||
|
Math.min(maxTimestamp, Math.max(0, Math.round(durationMs * 0.5))),
|
||||||
|
Math.min(maxTimestamp, Math.max(0, Math.round(durationMs * 0.9))),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
return Array.from({ length: count }, (_, index) => {
|
||||||
|
const ratio = count === 1 ? 0 : index / (count - 1);
|
||||||
|
return Math.min(maxTimestamp, Math.max(0, Math.round(durationMs * ratio)));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 使用 ffmpeg 从视频中提取固定数量的 JPEG 帧。
|
||||||
|
*/
|
||||||
|
export async function extractVideoFrames(
|
||||||
|
videoBuffer: Buffer,
|
||||||
|
opts?: { count?: number; durationMs?: number | null },
|
||||||
|
): Promise<ExtractedVideoFrame[]> {
|
||||||
|
const ffmpegCmd = process.env.FFMPEG_PATH || "ffmpeg";
|
||||||
|
const tmpDir = os.tmpdir();
|
||||||
|
const base = `dy_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`;
|
||||||
|
const inPath = path.join(tmpDir, `${base}.mp4`);
|
||||||
|
const count = Math.max(1, opts?.count ?? 3);
|
||||||
|
const outPaths = Array.from({ length: count }, (_, index) =>
|
||||||
|
path.join(tmpDir, `${base}_${index}.jpg`),
|
||||||
|
);
|
||||||
|
|
||||||
|
try {
|
||||||
|
await fs.writeFile(inPath, videoBuffer);
|
||||||
|
const durationMs =
|
||||||
|
opts?.durationMs ?? (await getVideoDuration(videoBuffer));
|
||||||
|
const timestamps = getFrameTimestamps(durationMs, count);
|
||||||
|
|
||||||
|
const frames: ExtractedVideoFrame[] = [];
|
||||||
|
for (let index = 0; index < count; index++) {
|
||||||
|
const timestampSeconds = Math.max(0, timestamps[index] / 1000).toFixed(3);
|
||||||
|
const args = [
|
||||||
|
"-hide_banner",
|
||||||
|
"-loglevel",
|
||||||
|
"error",
|
||||||
|
"-ss",
|
||||||
|
timestampSeconds,
|
||||||
|
"-i",
|
||||||
|
inPath,
|
||||||
|
"-frames:v",
|
||||||
|
"1",
|
||||||
|
"-q:v",
|
||||||
|
"3",
|
||||||
|
"-vf",
|
||||||
|
"scale=768:-2:force_original_aspect_ratio=decrease",
|
||||||
|
"-f",
|
||||||
|
"image2",
|
||||||
|
"-y",
|
||||||
|
outPaths[index],
|
||||||
|
];
|
||||||
|
await execFileAsync(ffmpegCmd, args, { windowsHide: true });
|
||||||
|
frames.push({
|
||||||
|
buffer: await fs.readFile(outPaths[index]),
|
||||||
|
contentType: "image/jpeg",
|
||||||
|
ext: "jpg",
|
||||||
|
timestampMs: timestamps[index],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return frames;
|
||||||
|
} catch (error) {
|
||||||
|
if (isMissingExecutableError(error)) {
|
||||||
|
console.warn(
|
||||||
|
"系统未检测到 ffmpeg,可安装并配置 PATH 或设置 FFMPEG_PATH 后启用视频帧提取。",
|
||||||
|
);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
|
} finally {
|
||||||
|
try {
|
||||||
|
await fs.unlink(inPath);
|
||||||
|
} catch {}
|
||||||
|
for (const outPath of outPaths) {
|
||||||
|
try {
|
||||||
|
await fs.unlink(outPath);
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 使用 ffmpeg 将视频二进制转换为音频,返回音频 Buffer
|
* 使用 ffmpeg 将视频二进制转换为音频,返回音频 Buffer
|
||||||
*
|
*
|
||||||
@@ -189,20 +301,16 @@ export async function extractAudio(
|
|||||||
await execFileAsync(ffmpegCmd, args, { windowsHide: true });
|
await execFileAsync(ffmpegCmd, args, { windowsHide: true });
|
||||||
const audio = await fs.readFile(outPath);
|
const audio = await fs.readFile(outPath);
|
||||||
return { buffer: audio, contentType, ext };
|
return { buffer: audio, contentType, ext };
|
||||||
} catch (e: any) {
|
} catch (error) {
|
||||||
if (
|
if (isMissingExecutableError(error)) {
|
||||||
e &&
|
|
||||||
(e.code === "ENOENT" ||
|
|
||||||
/not found|is not recognized/i.test(String(e.message)))
|
|
||||||
) {
|
|
||||||
console.warn(
|
console.warn(
|
||||||
"系统未检测到 ffmpeg,可安装并配置 PATH 或设置 FFMPEG_PATH 后启用音频提取。",
|
"系统未检测到 ffmpeg,可安装并配置 PATH 或设置 FFMPEG_PATH 后启用音频提取。",
|
||||||
);
|
);
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
// 一些环境可能缺少特定编码器(如 libmp3lame),提示并抛出原始错误
|
// 一些环境可能缺少特定编码器(如 libmp3lame),提示并抛出原始错误
|
||||||
console.warn(`提取音频失败: ${e?.message || e}`);
|
console.warn(`提取音频失败: ${getErrorMessage(error)}`);
|
||||||
throw e;
|
throw error;
|
||||||
} finally {
|
} finally {
|
||||||
try {
|
try {
|
||||||
await fs.unlink(inPath);
|
await fs.unlink(inPath);
|
||||||
|
|||||||
+74
-21
@@ -1,11 +1,32 @@
|
|||||||
|
import { NextResponse } from "next/server";
|
||||||
|
import { isAsrContentTag } from "@/lib/asr-tags";
|
||||||
import { json } from "@/lib/json";
|
import { json } from "@/lib/json";
|
||||||
import { getFileUrl } from "@/lib/minio";
|
import { getFileUrl } from "@/lib/minio";
|
||||||
import { prisma } from "@/lib/prisma"; // 你的 Prisma 客户端实例
|
import { prisma } from "@/lib/prisma";
|
||||||
import { NextResponse } from "next/server";
|
|
||||||
|
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>", "<mark>")
|
||||||
|
.replaceAll("</mark>", "</mark>");
|
||||||
|
}
|
||||||
|
|
||||||
export async function GET(req: Request) {
|
export async function GET(req: Request) {
|
||||||
const { searchParams } = new URL(req.url);
|
const { searchParams } = new URL(req.url);
|
||||||
const q = (searchParams.get("q") || "").trim();
|
const q = (searchParams.get("q") || "").trim();
|
||||||
|
const rawTag = (searchParams.get("tag") || "").trim();
|
||||||
|
const contentTag = rawTag && isAsrContentTag(rawTag) ? rawTag : null;
|
||||||
const page = Math.max(1, Number(searchParams.get("page") || 1));
|
const page = Math.max(1, Number(searchParams.get("page") || 1));
|
||||||
const limit = Math.min(
|
const limit = Math.min(
|
||||||
50,
|
50,
|
||||||
@@ -13,7 +34,11 @@ export async function GET(req: Request) {
|
|||||||
);
|
);
|
||||||
const offset = (page - 1) * limit;
|
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 });
|
return NextResponse.json({ results: [], total: 0 });
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -23,15 +48,8 @@ export async function GET(req: Request) {
|
|||||||
// - 合并搜索来源:VideoTranscript.transcript_zh_tsv、Video.desc 与 ImagePost.desc
|
// - 合并搜索来源:VideoTranscript.transcript_zh_tsv、Video.desc 与 ImagePost.desc
|
||||||
// - 使用 UNION ALL 合并视频和图文的搜索结果
|
// - 使用 UNION ALL 合并视频和图文的搜索结果
|
||||||
// - rank 取两者匹配得分的最大值,snippet 优先展示转写匹配,否则展示 desc 匹配
|
// - rank 取两者匹配得分的最大值,snippet 优先展示转写匹配,否则展示 desc 匹配
|
||||||
const rows = await prisma.$queryRaw<
|
const rows = q
|
||||||
{
|
? await prisma.$queryRaw<SearchRow[]>`
|
||||||
id: string;
|
|
||||||
awemeId: string;
|
|
||||||
type: "video" | "image";
|
|
||||||
rank: number;
|
|
||||||
snippet: string;
|
|
||||||
}[]
|
|
||||||
>`
|
|
||||||
WITH tsq AS (
|
WITH tsq AS (
|
||||||
SELECT websearch_to_tsquery('zhcfg', ${q}) AS query
|
SELECT websearch_to_tsquery('zhcfg', ${q}) AS query
|
||||||
)
|
)
|
||||||
@@ -59,12 +77,17 @@ export async function GET(req: Request) {
|
|||||||
tsq.query,
|
tsq.query,
|
||||||
'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"
|
||||||
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
|
||||||
WHERE
|
WHERE
|
||||||
(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
|
||||||
|
WHEN ${contentTag}::text IS NULL THEN TRUE
|
||||||
|
ELSE vt.content_tags @> ARRAY[${contentTag}]::text[]
|
||||||
|
END
|
||||||
|
|
||||||
UNION ALL
|
UNION ALL
|
||||||
|
|
||||||
@@ -79,33 +102,62 @@ export async function GET(req: Request) {
|
|||||||
ip.desc,
|
ip.desc,
|
||||||
tsq.query,
|
tsq.query,
|
||||||
'StartSel=<mark>,StopSel=</mark>,MaxFragments=2,MinWords=2,MaxWords=20'
|
'StartSel=<mark>,StopSel=</mark>,MaxFragments=2,MinWords=2,MaxWords=20'
|
||||||
) AS snippet
|
) AS snippet,
|
||||||
|
ARRAY[]::text[] AS "contentTags"
|
||||||
FROM "ImagePost" ip, tsq
|
FROM "ImagePost" ip, tsq
|
||||||
WHERE to_tsvector('zhcfg', ip.desc) @@ tsq.query
|
WHERE to_tsvector('zhcfg', ip.desc) @@ tsq.query
|
||||||
|
AND ${contentTag}::text IS NULL
|
||||||
|
|
||||||
ORDER BY rank DESC
|
ORDER BY rank DESC
|
||||||
OFFSET ${offset}
|
OFFSET ${offset}
|
||||||
LIMIT ${limit};
|
LIMIT ${limit};
|
||||||
|
`
|
||||||
|
: await prisma.$queryRaw<SearchRow[]>`
|
||||||
|
SELECT
|
||||||
|
vt.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], ' '), ''),
|
||||||
|
vt.non_speech_summary,
|
||||||
|
v.desc,
|
||||||
|
'标签匹配'
|
||||||
|
) AS snippet,
|
||||||
|
vt.content_tags AS "contentTags"
|
||||||
|
FROM "VideoTranscript" vt
|
||||||
|
JOIN "Video" v ON v.aweme_id = vt."videoId"
|
||||||
|
WHERE vt.content_tags @> ARRAY[${contentTag}]::text[]
|
||||||
|
ORDER BY v.created_at DESC
|
||||||
|
OFFSET ${offset}
|
||||||
|
LIMIT ${limit};
|
||||||
`;
|
`;
|
||||||
|
|
||||||
// 查询总数(参数化,避免注入)
|
// 查询总数(参数化,避免注入)
|
||||||
const totalRows = await prisma.$queryRaw<
|
const totalRows = q
|
||||||
{
|
? await prisma.$queryRaw<{ count: number }[]>`
|
||||||
count: number;
|
|
||||||
}[]
|
|
||||||
>`
|
|
||||||
WITH tsq AS (
|
WITH tsq AS (
|
||||||
SELECT websearch_to_tsquery('zhcfg', ${q}) AS query
|
SELECT websearch_to_tsquery('zhcfg', ${q}) AS query
|
||||||
)
|
)
|
||||||
SELECT (
|
SELECT (
|
||||||
(SELECT COUNT(*)::int FROM "Video" v
|
(SELECT COUNT(*)::int FROM "Video" v
|
||||||
LEFT JOIN "VideoTranscript" vt ON vt."videoId" = v.aweme_id
|
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))
|
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 vt.content_tags @> ARRAY[${contentTag}]::text[]
|
||||||
|
END)
|
||||||
+
|
+
|
||||||
(SELECT COUNT(*)::int FROM "ImagePost" ip
|
(SELECT COUNT(*)::int FROM "ImagePost" ip
|
||||||
WHERE to_tsvector('zhcfg', ip.desc) @@ tsq.query)
|
WHERE to_tsvector('zhcfg', ip.desc) @@ tsq.query
|
||||||
|
AND ${contentTag}::text IS NULL)
|
||||||
) AS count
|
) AS count
|
||||||
FROM tsq;
|
FROM tsq;
|
||||||
|
`
|
||||||
|
: await prisma.$queryRaw<{ count: number }[]>`
|
||||||
|
SELECT COUNT(*)::int AS count
|
||||||
|
FROM "VideoTranscript" vt
|
||||||
|
WHERE vt.content_tags @> ARRAY[${contentTag}]::text[];
|
||||||
`;
|
`;
|
||||||
|
|
||||||
// 分离视频和图文ID
|
// 分离视频和图文ID
|
||||||
@@ -176,6 +228,7 @@ export async function GET(req: Request) {
|
|||||||
return json({
|
return json({
|
||||||
results: rows.map((r) => ({
|
results: rows.map((r) => ({
|
||||||
...r,
|
...r,
|
||||||
|
snippet: sanitizeSnippetHtml(r.snippet),
|
||||||
video:
|
video:
|
||||||
r.type === "video"
|
r.type === "video"
|
||||||
? videos.find((v) => v.aweme_id === r.awemeId)
|
? videos.find((v) => v.aweme_id === r.awemeId)
|
||||||
|
|||||||
@@ -1,113 +0,0 @@
|
|||||||
import fs from "fs";
|
|
||||||
import OpenAI from "openai";
|
|
||||||
import prompt from "./prompt.md";
|
|
||||||
import { prisma } from "@/lib/prisma";
|
|
||||||
import { downloadFile } from "@/lib/minio";
|
|
||||||
import { extractAudio } from "../media";
|
|
||||||
import { z } from "zod";
|
|
||||||
import { zodResponseFormat } from "openai/helpers/zod";
|
|
||||||
import { ProxyAgent, setGlobalDispatcher } from "undici";
|
|
||||||
|
|
||||||
const proxy = process.env.HTTPS_PROXY || process.env.HTTP_PROXY;
|
|
||||||
if (proxy) {
|
|
||||||
setGlobalDispatcher(new ProxyAgent(proxy));
|
|
||||||
}
|
|
||||||
|
|
||||||
const client = new OpenAI({
|
|
||||||
apiKey: process.env.OPENAI_API_KEY,
|
|
||||||
baseURL: process.env.OPENAI_API_BASE_URL,
|
|
||||||
});
|
|
||||||
|
|
||||||
// Zod schema aligned with prompt.md
|
|
||||||
export const SttSchema = z
|
|
||||||
.object({
|
|
||||||
speech_detected: z.boolean(),
|
|
||||||
language: z.string().min(1).nullable(),
|
|
||||||
audio_type: z.string().nullable(),
|
|
||||||
transcript: z.array(z.string()),
|
|
||||||
non_speech_summary: z.string().nullable(),
|
|
||||||
})
|
|
||||||
.strict();
|
|
||||||
|
|
||||||
export type SttResult = z.infer<typeof SttSchema>;
|
|
||||||
|
|
||||||
async function transcriptAudio(audio: Buffer | string) {
|
|
||||||
if (typeof audio === "string") {
|
|
||||||
audio = fs.readFileSync(audio);
|
|
||||||
}
|
|
||||||
const base64Audio = Buffer.from(audio).toString("base64");
|
|
||||||
|
|
||||||
const completion = await client.chat.completions.create({
|
|
||||||
model: "gemini-2.5-flash",
|
|
||||||
messages: [
|
|
||||||
{
|
|
||||||
role: "user",
|
|
||||||
content: [
|
|
||||||
{ type: "text", text: prompt },
|
|
||||||
{
|
|
||||||
type: "input_audio",
|
|
||||||
input_audio: { data: base64Audio, format: "mp3" },
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
],
|
|
||||||
response_format: zodResponseFormat(SttSchema, "stt_result"),
|
|
||||||
});
|
|
||||||
|
|
||||||
const data = completion.choices?.[0]?.message;
|
|
||||||
console.log("转写结果", data.content);
|
|
||||||
|
|
||||||
if (!data || !data.content) {
|
|
||||||
throw new Error("No STT result returned from model");
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
return JSON.parse(data.content) as SttResult;
|
|
||||||
} catch (e) {
|
|
||||||
throw new Error("Failed to parse STT result JSON");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function transcriptAweme(awemeId: string): Promise<SttResult> {
|
|
||||||
const aweme = await prisma.video.findUnique({
|
|
||||||
where: { aweme_id: awemeId },
|
|
||||||
});
|
|
||||||
if (!aweme) {
|
|
||||||
throw new Error("Aweme not found or aweme is not a video post");
|
|
||||||
}
|
|
||||||
const vPath = aweme.video_url;
|
|
||||||
const buffer = await downloadFile(vPath);
|
|
||||||
|
|
||||||
const audioDat = await extractAudio(buffer, {
|
|
||||||
format: "mp3",
|
|
||||||
bitrateKbps: 128,
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!audioDat || !audioDat.buffer) {
|
|
||||||
throw new Error("Failed to extract audio from video");
|
|
||||||
}
|
|
||||||
|
|
||||||
// 调用大模型生成转写结果
|
|
||||||
const result = await transcriptAudio(audioDat.buffer);
|
|
||||||
|
|
||||||
// 将转写结果持久化到数据库
|
|
||||||
await prisma.videoTranscript.upsert({
|
|
||||||
where: { videoId: awemeId },
|
|
||||||
update: {
|
|
||||||
speech_detected: result.speech_detected,
|
|
||||||
language: result.language,
|
|
||||||
audio_type: result.audio_type,
|
|
||||||
transcript: result.transcript,
|
|
||||||
non_speech_summary: result.non_speech_summary,
|
|
||||||
},
|
|
||||||
create: {
|
|
||||||
videoId: awemeId,
|
|
||||||
speech_detected: result.speech_detected,
|
|
||||||
language: result.language,
|
|
||||||
audio_type: result.audio_type,
|
|
||||||
transcript: result.transcript,
|
|
||||||
non_speech_summary: result.non_speech_summary,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
return result;
|
|
||||||
}
|
|
||||||
@@ -1,29 +0,0 @@
|
|||||||
你将接收一段音频。请完成:你将接收一段音频。语音活动检测(VAD)与声源分类。
|
|
||||||
|
|
||||||
**输出 JSON Schema(示例)**
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"speech_detected": true,
|
|
||||||
"language": "zh-CN",
|
|
||||||
"audio_type": null,
|
|
||||||
"transcript": ["大家好,我是xxx", "欢迎来到今天的视频", "今天我们来聊聊AI的未来"],
|
|
||||||
"non_speech_summary": null,
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
**当无发言时返回:**
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"speech_detected": false,
|
|
||||||
"language": null,
|
|
||||||
"audio_type": "music | ambience | animal | mechanical | other",
|
|
||||||
"transcript": [],
|
|
||||||
"non_speech_summary": "示例:纯音乐-钢琴独奏,节奏舒缓;或 环境声-雨声伴随雷鸣。",
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
** 规则补充 **
|
|
||||||
|
|
||||||
* 允许`language` 为多标签(如 "zh-CN,en")或为 `null`(无发言时)。
|
|
||||||
@@ -1,23 +0,0 @@
|
|||||||
export const runtime = "nodejs";
|
|
||||||
import { NextRequest, NextResponse } from "next/server";
|
|
||||||
import { prisma } from "@/lib/prisma";
|
|
||||||
import type { FeedItem, FeedResponse } from "@/app/types/feed";
|
|
||||||
import { getFileUrl } from "@/lib/minio";
|
|
||||||
import { transcriptAweme } from ".";
|
|
||||||
|
|
||||||
// Contract
|
|
||||||
// Inputs: search params { before?: ISOString, limit?: number }
|
|
||||||
// Output: { items: FeedItem[], nextCursor: ISOString | null }
|
|
||||||
|
|
||||||
export async function GET(req: NextRequest) {
|
|
||||||
const { searchParams } = new URL(req.url);
|
|
||||||
const awemeId = searchParams.get("awemeId");
|
|
||||||
if (!awemeId) {
|
|
||||||
return NextResponse.json(
|
|
||||||
{ error: "Missing awemeId parameter" },
|
|
||||||
{ status: 400 },
|
|
||||||
);
|
|
||||||
}
|
|
||||||
const script = await transcriptAweme(awemeId);
|
|
||||||
return NextResponse.json(script);
|
|
||||||
}
|
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { X, Copy, CheckCheck, Check } from "lucide-react";
|
import { Check, CheckCheck, Copy, X } from "lucide-react";
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import type { VideoTranscript } from "../types";
|
import type { VideoTranscript } from "../types";
|
||||||
|
|
||||||
@@ -38,18 +38,23 @@ export function TranscriptPanel({
|
|||||||
|
|
||||||
if (!transcript?.speech_detected || !transcript.transcript?.length) {
|
if (!transcript?.speech_detected || !transcript.transcript?.length) {
|
||||||
return (
|
return (
|
||||||
<div
|
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm">
|
||||||
className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm"
|
<button
|
||||||
|
type="button"
|
||||||
|
aria-label="关闭"
|
||||||
|
className="absolute inset-0 h-full w-full cursor-default"
|
||||||
onClick={onClose}
|
onClick={onClose}
|
||||||
>
|
/>
|
||||||
<div
|
<div
|
||||||
className="relative w-full max-w-2xl max-h-[80vh] m-4 bg-zinc-900/95 backdrop-blur-xl border border-white/10 rounded-2xl shadow-2xl"
|
role="dialog"
|
||||||
onClick={(e) => e.stopPropagation()}
|
aria-modal="true"
|
||||||
|
className="relative z-10 w-full max-w-2xl max-h-[80vh] m-4 bg-zinc-900/95 backdrop-blur-xl border border-white/10 rounded-2xl shadow-2xl"
|
||||||
>
|
>
|
||||||
{/* 头部 */}
|
{/* 头部 */}
|
||||||
<div className="flex items-center justify-between p-4 border-b border-white/10">
|
<div className="flex items-center justify-between p-4 border-b border-white/10">
|
||||||
<h2 className="text-lg font-semibold text-white">语音转录</h2>
|
<h2 className="text-lg font-semibold text-white">语音转录</h2>
|
||||||
<button
|
<button
|
||||||
|
type="button"
|
||||||
onClick={onClose}
|
onClick={onClose}
|
||||||
className="p-2 rounded-full hover:bg-white/10 transition-colors"
|
className="p-2 rounded-full hover:bg-white/10 transition-colors"
|
||||||
aria-label="关闭"
|
aria-label="关闭"
|
||||||
@@ -68,13 +73,17 @@ export function TranscriptPanel({
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm">
|
||||||
className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm"
|
<button
|
||||||
|
type="button"
|
||||||
|
aria-label="关闭"
|
||||||
|
className="absolute inset-0 h-full w-full cursor-default"
|
||||||
onClick={onClose}
|
onClick={onClose}
|
||||||
>
|
/>
|
||||||
<div
|
<div
|
||||||
className="relative w-full max-w-2xl max-h-[80vh] m-4 bg-zinc-900/95 backdrop-blur-xl border border-white/10 rounded-2xl shadow-2xl flex flex-col"
|
role="dialog"
|
||||||
onClick={(e) => e.stopPropagation()}
|
aria-modal="true"
|
||||||
|
className="relative z-10 w-full max-w-2xl max-h-[80vh] m-4 bg-zinc-900/95 backdrop-blur-xl border border-white/10 rounded-2xl shadow-2xl flex flex-col"
|
||||||
>
|
>
|
||||||
{/* 头部 */}
|
{/* 头部 */}
|
||||||
<div className="flex items-center justify-between p-4 border-b border-white/10 flex-shrink-0">
|
<div className="flex items-center justify-between p-4 border-b border-white/10 flex-shrink-0">
|
||||||
@@ -85,9 +94,22 @@ export function TranscriptPanel({
|
|||||||
语言: {transcript.language}
|
语言: {transcript.language}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
|
{transcript.content_tags?.length > 0 && (
|
||||||
|
<div className="mt-2 flex flex-wrap gap-1.5">
|
||||||
|
{transcript.content_tags.map((tag) => (
|
||||||
|
<span
|
||||||
|
key={tag}
|
||||||
|
className="rounded bg-blue-500/15 px-2 py-0.5 text-xs text-blue-200"
|
||||||
|
>
|
||||||
|
{tag}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<button
|
<button
|
||||||
|
type="button"
|
||||||
onClick={handleCopyAll}
|
onClick={handleCopyAll}
|
||||||
className="flex items-center gap-2 px-3 py-2 text-sm bg-blue-600/20 text-blue-300 rounded-lg hover:bg-blue-600/30 transition-colors"
|
className="flex items-center gap-2 px-3 py-2 text-sm bg-blue-600/20 text-blue-300 rounded-lg hover:bg-blue-600/30 transition-colors"
|
||||||
>
|
>
|
||||||
@@ -104,6 +126,7 @@ export function TranscriptPanel({
|
|||||||
)}
|
)}
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
|
type="button"
|
||||||
onClick={onClose}
|
onClick={onClose}
|
||||||
className="p-2 rounded-full hover:bg-white/10 transition-colors"
|
className="p-2 rounded-full hover:bg-white/10 transition-colors"
|
||||||
aria-label="关闭"
|
aria-label="关闭"
|
||||||
@@ -117,9 +140,8 @@ export function TranscriptPanel({
|
|||||||
<div className="flex-1 overflow-y-auto p-4 space-y-2">
|
<div className="flex-1 overflow-y-auto p-4 space-y-2">
|
||||||
{transcript.transcript.map((text, index) => (
|
{transcript.transcript.map((text, index) => (
|
||||||
<div
|
<div
|
||||||
key={index}
|
key={`${index}-${text}`}
|
||||||
className="group bg-white/5 rounded-lg p-3 hover:bg-white/10 transition-colors cursor-pointer"
|
className="group bg-white/5 rounded-lg p-3 hover:bg-white/10 transition-colors"
|
||||||
onClick={() => handleCopy(text, index)}
|
|
||||||
>
|
>
|
||||||
<div className="flex items-start justify-between gap-3">
|
<div className="flex items-start justify-between gap-3">
|
||||||
<div className="flex-1 min-w-0">
|
<div className="flex-1 min-w-0">
|
||||||
@@ -133,8 +155,9 @@ export function TranscriptPanel({
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
|
type="button"
|
||||||
onClick={() => handleCopy(text, index)}
|
onClick={() => handleCopy(text, index)}
|
||||||
className="flex-shrink-0 p-2 rounded-lg opacity-0 group-hover:opacity-100 hover:bg-white/10 transition-all"
|
className="flex-shrink-0 p-2 rounded-lg opacity-0 group-hover:opacity-100 focus:opacity-100 hover:bg-white/10 transition-all"
|
||||||
aria-label="复制"
|
aria-label="复制"
|
||||||
>
|
>
|
||||||
{copiedIndex === index ? (
|
{copiedIndex === index ? (
|
||||||
|
|||||||
@@ -56,6 +56,7 @@ export type VideoTranscript = {
|
|||||||
speech_detected: boolean;
|
speech_detected: boolean;
|
||||||
language: string | null;
|
language: string | null;
|
||||||
transcript: string[];
|
transcript: string[];
|
||||||
|
content_tags: string[];
|
||||||
};
|
};
|
||||||
|
|
||||||
export type Neighbors = {
|
export type Neighbors = {
|
||||||
|
|||||||
@@ -1,9 +1,16 @@
|
|||||||
"use client";
|
"use client";
|
||||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
|
||||||
import Link from "next/link";
|
|
||||||
import HoverVideo from "./HoverVideo";
|
|
||||||
import { ThumbsUp } from "lucide-react";
|
import { ThumbsUp } from "lucide-react";
|
||||||
|
import Image from "next/image";
|
||||||
|
import Link from "next/link";
|
||||||
|
import { useCallback, useEffect, useRef, useState } from "react";
|
||||||
import type { FeedItem, FeedResponse } from "@/app/types/feed";
|
import type { FeedItem, FeedResponse } from "@/app/types/feed";
|
||||||
|
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 = {
|
type Props = {
|
||||||
initialItems: FeedItem[];
|
initialItems: FeedItem[];
|
||||||
@@ -58,7 +65,7 @@ export default function FeedMasonry({
|
|||||||
mediaH = (9 / 16) * colWidth; // 常见视频比例
|
mediaH = (9 / 16) * colWidth; // 常见视频比例
|
||||||
}
|
}
|
||||||
// 文本 + 作者栏的高度粗估
|
// 文本 + 作者栏的高度粗估
|
||||||
const textH = 48; // 标题+标签区域
|
const textH = item.content_tags?.length ? 72 : 48; // 标题+标签区域
|
||||||
const authorH = 48; // 作者行
|
const authorH = 48; // 作者行
|
||||||
const gap = 16; // 卡片内边距/间隙
|
const gap = 16; // 卡片内边距/间隙
|
||||||
return mediaH + textH + authorH + gap;
|
return mediaH + textH + authorH + gap;
|
||||||
@@ -92,8 +99,7 @@ export default function FeedMasonry({
|
|||||||
}
|
}
|
||||||
setColumns(newCols);
|
setColumns(newCols);
|
||||||
setColHeights(newHeights);
|
setColHeights(newHeights);
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
}, [columnCount, estimateItemHeight, initialItems]);
|
||||||
}, [columnCount]);
|
|
||||||
|
|
||||||
const fetchMore = useCallback(async () => {
|
const fetchMore = useCallback(async () => {
|
||||||
if (loading || ended) return;
|
if (loading || ended) return;
|
||||||
@@ -136,7 +142,15 @@ export default function FeedMasonry({
|
|||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
}, [cursor, ended, loading, columnCount, colHeights, estimateItemHeight]);
|
}, [
|
||||||
|
cursor,
|
||||||
|
ended,
|
||||||
|
loading,
|
||||||
|
columnCount,
|
||||||
|
colHeights,
|
||||||
|
estimateItemHeight,
|
||||||
|
fetchUrl,
|
||||||
|
]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const el = sentinelRef.current;
|
const el = sentinelRef.current;
|
||||||
@@ -168,29 +182,43 @@ export default function FeedMasonry({
|
|||||||
<div
|
<div
|
||||||
className="relative w-full"
|
className="relative w-full"
|
||||||
style={{
|
style={{
|
||||||
aspectRatio:
|
aspectRatio: getAspectRatio(item),
|
||||||
`${item.width && item.height ? `${item.width}/${item.height}` : ""}` as any,
|
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{item.type === "video" ? (
|
{item.type === "video" ? (
|
||||||
<HoverVideo
|
<HoverVideo
|
||||||
videoUrl={(item as any).video_url}
|
videoUrl={item.video_url}
|
||||||
coverUrl={item.cover_url}
|
coverUrl={item.cover_url}
|
||||||
className="absolute inset-0 w-full h-full"
|
className="absolute inset-0 w-full h-full"
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<img
|
<Image
|
||||||
loading="lazy"
|
fill
|
||||||
src={item.cover_url || "/placeholder.svg"}
|
src={item.cover_url || "/placeholder.svg"}
|
||||||
alt={item.desc?.slice(0, 20) || "image"}
|
alt={item.desc?.slice(0, 20) || "image"}
|
||||||
|
unoptimized
|
||||||
className="absolute inset-0 w-full h-full object-cover"
|
className="absolute inset-0 w-full h-full object-cover"
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
<div className="pointer-events-none absolute inset-0 bg-gradient-to-t from-black/50 via-black/0 to-black/0 opacity-70" />
|
<div className="pointer-events-none absolute inset-0 bg-gradient-to-t from-black/50 via-black/0 to-black/0 opacity-70" />
|
||||||
<div className="absolute left-3 bottom-3 right-3 flex items-end justify-between gap-3">
|
<div className="absolute left-3 bottom-3 right-3 flex items-end justify-between gap-3">
|
||||||
|
<div className="min-w-0">
|
||||||
<p className="text-white/95 text-sm leading-tight line-clamp-2 drop-shadow">
|
<p className="text-white/95 text-sm leading-tight line-clamp-2 drop-shadow">
|
||||||
{item.desc}
|
{item.desc}
|
||||||
</p>
|
</p>
|
||||||
|
{!!item.content_tags?.length && (
|
||||||
|
<div className="mt-2 flex flex-wrap gap-1">
|
||||||
|
{item.content_tags.map((tag) => (
|
||||||
|
<span
|
||||||
|
key={tag}
|
||||||
|
className="rounded-full bg-blue-500/80 px-2 py-0.5 text-xs text-white shadow"
|
||||||
|
>
|
||||||
|
{tag}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
<span className="shrink-0 inline-flex items-center gap-2 rounded-full bg-white/85 px-2 py-1 text-xs text-zinc-800">
|
<span className="shrink-0 inline-flex items-center gap-2 rounded-full bg-white/85 px-2 py-1 text-xs text-zinc-800">
|
||||||
{item.type === "video" ? "视频" : "图文"}
|
{item.type === "video" ? "视频" : "图文"}
|
||||||
</span>
|
</span>
|
||||||
@@ -206,9 +234,12 @@ export default function FeedMasonry({
|
|||||||
>
|
>
|
||||||
<div className="size-6 rounded-full overflow-hidden bg-zinc-200 shrink-0">
|
<div className="size-6 rounded-full overflow-hidden bg-zinc-200 shrink-0">
|
||||||
{item.author.avatar_url ? (
|
{item.author.avatar_url ? (
|
||||||
<img
|
<Image
|
||||||
src={item.author.avatar_url}
|
src={item.author.avatar_url}
|
||||||
alt="avatar"
|
alt="avatar"
|
||||||
|
width={24}
|
||||||
|
height={24}
|
||||||
|
unoptimized
|
||||||
className="w-full h-full object-cover"
|
className="w-full h-full object-cover"
|
||||||
/>
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
@@ -221,9 +252,12 @@ export default function FeedMasonry({
|
|||||||
<div className="flex items-center gap-2 min-w-0 flex-1">
|
<div className="flex items-center gap-2 min-w-0 flex-1">
|
||||||
<div className="size-6 rounded-full overflow-hidden bg-zinc-200 shrink-0">
|
<div className="size-6 rounded-full overflow-hidden bg-zinc-200 shrink-0">
|
||||||
{item.author.avatar_url ? (
|
{item.author.avatar_url ? (
|
||||||
<img
|
<Image
|
||||||
src={item.author.avatar_url}
|
src={item.author.avatar_url}
|
||||||
alt="avatar"
|
alt="avatar"
|
||||||
|
width={24}
|
||||||
|
height={24}
|
||||||
|
unoptimized
|
||||||
className="w-full h-full object-cover"
|
className="w-full h-full object-cover"
|
||||||
/>
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
@@ -254,7 +288,7 @@ export default function FeedMasonry({
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{columns.map((col, idx) => (
|
{columns.map((col, idx) => (
|
||||||
<div key={idx} className="flex flex-col">
|
<div key={COLUMN_KEYS[idx]} className="flex flex-col">
|
||||||
{col.map((item) => renderCard(item))}
|
{col.map((item) => renderCard(item))}
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useState } from "react";
|
|
||||||
import { useRouter } from "next/navigation";
|
|
||||||
import { Search } from "lucide-react";
|
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 [query, setQuery] = useState("");
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
|
||||||
@@ -12,7 +12,9 @@ export default function SearchBox() {
|
|||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
const trimmed = query.trim();
|
const trimmed = query.trim();
|
||||||
if (!trimmed) return;
|
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 (
|
return (
|
||||||
|
|||||||
+74
-16
@@ -1,9 +1,11 @@
|
|||||||
|
import type { Metadata } from "next";
|
||||||
|
import Link from "next/link";
|
||||||
|
import { ASR_CONTENT_TAGS, isAsrContentTag } from "@/lib/asr-tags";
|
||||||
|
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";
|
||||||
import SearchBox from "./components/SearchBox";
|
import SearchBox from "./components/SearchBox";
|
||||||
import type { FeedItem } from "./types/feed";
|
import type { FeedItem } from "./types/feed";
|
||||||
import type { Metadata } from "next";
|
|
||||||
import { getFileUrl } from "@/lib/minio";
|
|
||||||
|
|
||||||
export const dynamic = "force-dynamic";
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
@@ -12,17 +14,36 @@ export const metadata: Metadata = {
|
|||||||
description: "抖歪作品集,记录当下时代的精彩瞬间",
|
description: "抖歪作品集,记录当下时代的精彩瞬间",
|
||||||
};
|
};
|
||||||
|
|
||||||
export default async function Home() {
|
export default async function Home({
|
||||||
|
searchParams,
|
||||||
|
}: {
|
||||||
|
searchParams?: { tag?: string };
|
||||||
|
}) {
|
||||||
|
const requestedTag = searchParams?.tag?.trim() || "";
|
||||||
|
const selectedTag =
|
||||||
|
requestedTag && isAsrContentTag(requestedTag) ? requestedTag : null;
|
||||||
|
|
||||||
const [videos, posts] = await Promise.all([
|
const [videos, posts] = await Promise.all([
|
||||||
prisma.video.findMany({
|
prisma.video.findMany({
|
||||||
|
where: selectedTag
|
||||||
|
? { transcript: { is: { content_tags: { has: selectedTag } } } }
|
||||||
|
: undefined,
|
||||||
orderBy: { created_at: "desc" },
|
orderBy: { created_at: "desc" },
|
||||||
take: 60,
|
take: 60,
|
||||||
include: { author: true },
|
include: {
|
||||||
|
author: true,
|
||||||
|
transcript: { select: { content_tags: true } },
|
||||||
|
},
|
||||||
}),
|
}),
|
||||||
prisma.imagePost.findMany({
|
selectedTag
|
||||||
|
? Promise.resolve([])
|
||||||
|
: prisma.imagePost.findMany({
|
||||||
orderBy: { created_at: "desc" },
|
orderBy: { created_at: "desc" },
|
||||||
take: 60,
|
take: 60,
|
||||||
include: { author: true, images: { orderBy: { order: "asc" }, take: 1 } },
|
include: {
|
||||||
|
author: true,
|
||||||
|
images: { orderBy: { order: "asc" }, take: 1 },
|
||||||
|
},
|
||||||
}),
|
}),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
@@ -42,13 +63,16 @@ 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 ?? [],
|
||||||
})),
|
})),
|
||||||
...posts.map((p) => ({
|
...posts.map((p) => {
|
||||||
|
const coverPath = p.images?.[0]?.url;
|
||||||
|
return {
|
||||||
type: "image" as const,
|
type: "image" as const,
|
||||||
aweme_id: p.aweme_id,
|
aweme_id: p.aweme_id,
|
||||||
created_at: p.created_at,
|
created_at: p.created_at,
|
||||||
desc: p.desc,
|
desc: p.desc,
|
||||||
cover_url: getFileUrl(p.images?.[0]?.url ?? null),
|
cover_url: coverPath ? getFileUrl(coverPath) : null,
|
||||||
width: p.images?.[0]?.width ?? null,
|
width: p.images?.[0]?.width ?? null,
|
||||||
height: p.images?.[0]?.height ?? null,
|
height: p.images?.[0]?.height ?? null,
|
||||||
author: {
|
author: {
|
||||||
@@ -57,7 +81,8 @@ export default async function Home() {
|
|||||||
sec_uid: p.author.sec_uid,
|
sec_uid: p.author.sec_uid,
|
||||||
},
|
},
|
||||||
likes: Number(p.digg_count),
|
likes: Number(p.digg_count),
|
||||||
})),
|
};
|
||||||
|
}),
|
||||||
]
|
]
|
||||||
//.sort(() => Math.random() - 0.5)
|
//.sort(() => Math.random() - 0.5)
|
||||||
.sort((a, b) => +new Date(b.created_at) - +new Date(a.created_at));
|
.sort((a, b) => +new Date(b.created_at) - +new Date(a.created_at));
|
||||||
@@ -75,20 +100,53 @@ export default async function Home() {
|
|||||||
</h1>
|
</h1>
|
||||||
|
|
||||||
<div className="w-full max-w-xl mb-12">
|
<div className="w-full max-w-xl mb-12">
|
||||||
<SearchBox />
|
<SearchBox tag={selectedTag} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mb-8 flex w-full max-w-4xl flex-wrap justify-center gap-2">
|
||||||
|
<Link
|
||||||
|
href="/"
|
||||||
|
className={`rounded-full border px-3 py-1.5 text-sm font-medium transition-colors ${
|
||||||
|
selectedTag
|
||||||
|
? "border-zinc-200 bg-white/70 text-zinc-600 hover:border-zinc-300 dark:border-white/10 dark:bg-white/5 dark:text-zinc-300"
|
||||||
|
: "border-blue-500 bg-blue-600 text-white"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
全部
|
||||||
|
</Link>
|
||||||
|
{ASR_CONTENT_TAGS.map((tag) => (
|
||||||
|
<Link
|
||||||
|
key={tag}
|
||||||
|
href={`/?tag=${encodeURIComponent(tag)}`}
|
||||||
|
className={`rounded-full border px-3 py-1.5 text-sm font-medium transition-colors ${
|
||||||
|
selectedTag === tag
|
||||||
|
? "border-blue-500 bg-blue-600 text-white"
|
||||||
|
: "border-zinc-200 bg-white/70 text-zinc-600 hover:border-zinc-300 dark:border-white/10 dark:bg-white/5 dark:text-zinc-300 dark:hover:text-white"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{tag}
|
||||||
|
</Link>
|
||||||
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="w-full">
|
<div className="w-full">
|
||||||
{(() => {
|
{(() => {
|
||||||
const initial = feed.slice(0, 24);
|
const initial = feed.slice(0, 24);
|
||||||
const cursor =
|
const lastItem = initial.at(-1);
|
||||||
initial.length > 0
|
const cursor = lastItem
|
||||||
? new Date(
|
? new Date(lastItem.created_at).toISOString()
|
||||||
initial[initial.length - 1].created_at as any,
|
|
||||||
).toISOString()
|
|
||||||
: null;
|
: null;
|
||||||
return (
|
return (
|
||||||
<FeedMasonry initialItems={initial} initialCursor={cursor} />
|
<FeedMasonry
|
||||||
|
key={selectedTag ?? "all"}
|
||||||
|
initialItems={initial}
|
||||||
|
initialCursor={cursor}
|
||||||
|
fetchUrl={
|
||||||
|
selectedTag
|
||||||
|
? `/api/feed?tag=${encodeURIComponent(selectedTag)}`
|
||||||
|
: "/api/feed"
|
||||||
|
}
|
||||||
|
/>
|
||||||
);
|
);
|
||||||
})()}
|
})()}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
+110
-23
@@ -1,9 +1,11 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { FormEvent, useCallback, useEffect, useState } from "react";
|
import { ArrowLeft, MessageSquare, Search, X } from "lucide-react";
|
||||||
import { useSearchParams, useRouter } from "next/navigation";
|
import Image from "next/image";
|
||||||
import Link from "next/link";
|
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 { ASR_CONTENT_TAGS, isAsrContentTag } from "@/lib/asr-tags";
|
||||||
|
|
||||||
// 匹配搜索结果类型(适配后端API)
|
// 匹配搜索结果类型(适配后端API)
|
||||||
type SearchResultItem = {
|
type SearchResultItem = {
|
||||||
@@ -12,6 +14,7 @@ type SearchResultItem = {
|
|||||||
type: "video" | "image";
|
type: "video" | "image";
|
||||||
rank: number;
|
rank: number;
|
||||||
snippet: string; // 后端返回的高亮片段,已包含<mark>标签
|
snippet: string; // 后端返回的高亮片段,已包含<mark>标签
|
||||||
|
contentTags: string[];
|
||||||
video?: {
|
video?: {
|
||||||
aweme_id: string;
|
aweme_id: string;
|
||||||
desc: string;
|
desc: string;
|
||||||
@@ -47,9 +50,20 @@ export default function SearchClient({
|
|||||||
const [results, setResults] = useState<SearchResultItem[]>([]);
|
const [results, setResults] = useState<SearchResultItem[]>([]);
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [searched, setSearched] = useState(false);
|
const [searched, setSearched] = useState(false);
|
||||||
|
const [selectedTag, setSelectedTag] = useState<string | null>(null);
|
||||||
|
|
||||||
const performSearch = useCallback(async (q: string) => {
|
const buildSearchHref = useCallback((q: string, tag: string | null) => {
|
||||||
if (!q.trim()) {
|
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([]);
|
setResults([]);
|
||||||
setSearched(false);
|
setSearched(false);
|
||||||
return;
|
return;
|
||||||
@@ -57,9 +71,10 @@ export default function SearchClient({
|
|||||||
setLoading(true);
|
setLoading(true);
|
||||||
setSearched(true);
|
setSearched(true);
|
||||||
try {
|
try {
|
||||||
const res = await fetch(
|
const params = new URLSearchParams({ limit: "60" });
|
||||||
`/api/search?q=${encodeURIComponent(q.trim())}&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");
|
if (!res.ok) throw new Error("Search failed");
|
||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
setResults(data.results || []);
|
setResults(data.results || []);
|
||||||
@@ -74,32 +89,64 @@ export default function SearchClient({
|
|||||||
// 初始 & URL 变化时同步(用 searchParams 订阅 URL 更稳妥)
|
// 初始 & URL 变化时同步(用 searchParams 订阅 URL 更稳妥)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const q = searchParams.get("q") || "";
|
const q = searchParams.get("q") || "";
|
||||||
|
const tagParam = searchParams.get("tag") || "";
|
||||||
|
const tag = tagParam && isAsrContentTag(tagParam) ? tagParam : null;
|
||||||
setQuery(q);
|
setQuery(q);
|
||||||
if (q) performSearch(q);
|
setSelectedTag(tag);
|
||||||
|
if (q || tag) {
|
||||||
|
performSearch(q, tag);
|
||||||
|
} else {
|
||||||
|
setResults([]);
|
||||||
|
setSearched(false);
|
||||||
|
}
|
||||||
}, [searchParams, performSearch]);
|
}, [searchParams, performSearch]);
|
||||||
|
|
||||||
const handleSearch = (e: FormEvent) => {
|
const handleSearch = (e: FormEvent) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
const trimmed = query.trim();
|
const trimmed = query.trim();
|
||||||
if (!trimmed) return;
|
if (!trimmed && !selectedTag) return;
|
||||||
router.push(`/search?q=${encodeURIComponent(trimmed)}`);
|
router.push(buildSearchHref(trimmed, selectedTag));
|
||||||
// 可省略:上面的 push 会触发上面的 effect 从 URL 拉取并搜索
|
};
|
||||||
// performSearch(trimmed);
|
|
||||||
|
const handleTagSelect = (tag: string) => {
|
||||||
|
const nextTag = selectedTag === tag ? null : tag;
|
||||||
|
setSelectedTag(nextTag);
|
||||||
|
router.push(buildSearchHref(query, nextTag));
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleClear = () => {
|
const handleClear = () => {
|
||||||
setQuery("");
|
setQuery("");
|
||||||
|
setSelectedTag(null);
|
||||||
setResults([]);
|
setResults([]);
|
||||||
setSearched(false);
|
setSearched(false);
|
||||||
router.push("/search");
|
router.push("/search");
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const renderSnippet = (snippet: string) => {
|
||||||
|
let highlighted = false;
|
||||||
|
let offset = 0;
|
||||||
|
return snippet.split(/(<mark>|<\/mark>)/g).map((part) => {
|
||||||
|
offset += part.length;
|
||||||
|
if (part === "<mark>") {
|
||||||
|
highlighted = true;
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if (part === "</mark>") {
|
||||||
|
highlighted = false;
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if (!part) return null;
|
||||||
|
return highlighted ? <mark key={`${offset}-${part}`}>{part}</mark> : part;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-zinc-950 text-white">
|
<div className="min-h-screen bg-zinc-950 text-white">
|
||||||
{/* 顶部搜索栏 */}
|
{/* 顶部搜索栏 */}
|
||||||
<header className="sticky top-0 z-50 bg-zinc-900/80 backdrop-blur-xl border-b border-white/10">
|
<header className="sticky top-0 z-50 bg-zinc-900/80 backdrop-blur-xl border-b border-white/10">
|
||||||
<div className="max-w-7xl mx-auto px-4 py-4 flex items-center gap-4">
|
<div className="max-w-7xl mx-auto px-4 py-4 flex items-center gap-4">
|
||||||
<button
|
<button
|
||||||
|
type="button"
|
||||||
onClick={() => router.push("/")}
|
onClick={() => router.push("/")}
|
||||||
className="p-2 rounded-full hover:bg-white/10 transition-colors"
|
className="p-2 rounded-full hover:bg-white/10 transition-colors"
|
||||||
aria-label="返回首页"
|
aria-label="返回首页"
|
||||||
@@ -118,13 +165,12 @@ export default function SearchClient({
|
|||||||
onChange={(e) => setQuery(e.target.value)}
|
onChange={(e) => setQuery(e.target.value)}
|
||||||
placeholder="搜索视频、图文描述或语音内容..."
|
placeholder="搜索视频、图文描述或语音内容..."
|
||||||
className="w-full bg-white/5 border border-white/10 rounded-full px-5 py-3 pl-12 pr-12 text-white placeholder-white/40 focus:outline-none focus:ring-2 focus:ring-blue-500/50 focus:border-blue-500/50 transition-all"
|
className="w-full bg-white/5 border border-white/10 rounded-full px-5 py-3 pl-12 pr-12 text-white placeholder-white/40 focus:outline-none focus:ring-2 focus:ring-blue-500/50 focus:border-blue-500/50 transition-all"
|
||||||
autoFocus
|
|
||||||
/>
|
/>
|
||||||
<Search
|
<Search
|
||||||
size={20}
|
size={20}
|
||||||
className="absolute left-4 top-1/2 -translate-y-1/2 text-white/40"
|
className="absolute left-4 top-1/2 -translate-y-1/2 text-white/40"
|
||||||
/>
|
/>
|
||||||
{query && (
|
{(query || selectedTag) && (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={handleClear}
|
onClick={handleClear}
|
||||||
@@ -138,12 +184,30 @@ export default function SearchClient({
|
|||||||
<button
|
<button
|
||||||
type="submit"
|
type="submit"
|
||||||
className="px-6 py-3 bg-blue-600 hover:bg-blue-700 rounded-full font-medium transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
className="px-6 py-3 bg-blue-600 hover:bg-blue-700 rounded-full font-medium transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
||||||
disabled={loading || !query.trim()}
|
disabled={loading || (!query.trim() && !selectedTag)}
|
||||||
>
|
>
|
||||||
{loading ? "搜索中..." : "搜索"}
|
{loading ? "搜索中..." : "搜索"}
|
||||||
</button>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
|
<div className="max-w-7xl mx-auto px-4 pb-4">
|
||||||
|
<div className="flex gap-2 overflow-x-auto">
|
||||||
|
{ASR_CONTENT_TAGS.map((tag) => (
|
||||||
|
<button
|
||||||
|
key={tag}
|
||||||
|
type="button"
|
||||||
|
onClick={() => handleTagSelect(tag)}
|
||||||
|
className={`flex-shrink-0 rounded-full border px-3 py-1.5 text-xs font-medium transition-colors ${
|
||||||
|
selectedTag === tag
|
||||||
|
? "border-blue-400 bg-blue-500/20 text-blue-200"
|
||||||
|
: "border-white/10 bg-white/5 text-white/60 hover:border-white/20 hover:text-white"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{tag}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
{/* 结果区域 */}
|
{/* 结果区域 */}
|
||||||
@@ -165,7 +229,7 @@ export default function SearchClient({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{!loading && !searched && !query && (
|
{!loading && !searched && !query && !selectedTag && (
|
||||||
<div className="flex flex-col items-center justify-center py-20 text-white/40">
|
<div className="flex flex-col items-center justify-center py-20 text-white/40">
|
||||||
<Search size={64} className="mb-4 opacity-20" />
|
<Search size={64} className="mb-4 opacity-20" />
|
||||||
<p className="text-xl">输入关键词开始搜索</p>
|
<p className="text-xl">输入关键词开始搜索</p>
|
||||||
@@ -207,11 +271,18 @@ export default function SearchClient({
|
|||||||
target="_blank"
|
target="_blank"
|
||||||
className="flex-shrink-0 w-64 h-80 sm:w-70 sm:h-96 rounded-xl overflow-hidden bg-zinc-900 group"
|
className="flex-shrink-0 w-64 h-80 sm:w-70 sm:h-96 rounded-xl overflow-hidden bg-zinc-900 group"
|
||||||
>
|
>
|
||||||
<img
|
{content.cover_url ? (
|
||||||
src={content.cover_url || ""}
|
<Image
|
||||||
|
src={content.cover_url}
|
||||||
alt={content.desc}
|
alt={content.desc}
|
||||||
|
width={280}
|
||||||
|
height={384}
|
||||||
|
unoptimized
|
||||||
className="w-full h-full object-cover transition-transform duration-300 group-hover:scale-105"
|
className="w-full h-full object-cover transition-transform duration-300 group-hover:scale-105"
|
||||||
/>
|
/>
|
||||||
|
) : (
|
||||||
|
<div className="h-full w-full bg-zinc-800" />
|
||||||
|
)}
|
||||||
</Link>
|
</Link>
|
||||||
|
|
||||||
{/* 右侧:信息区 */}
|
{/* 右侧:信息区 */}
|
||||||
@@ -231,6 +302,14 @@ export default function SearchClient({
|
|||||||
<MessageSquare size={12} />
|
<MessageSquare size={12} />
|
||||||
匹配度: {(item.rank * 100).toFixed(1)}%
|
匹配度: {(item.rank * 100).toFixed(1)}%
|
||||||
</span>
|
</span>
|
||||||
|
{item.contentTags.map((tag) => (
|
||||||
|
<span
|
||||||
|
key={tag}
|
||||||
|
className="px-2 py-0.5 text-xs rounded bg-blue-500/15 text-blue-200"
|
||||||
|
>
|
||||||
|
{tag}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 描述 */}
|
{/* 描述 */}
|
||||||
@@ -245,11 +324,18 @@ export default function SearchClient({
|
|||||||
|
|
||||||
{/* 作者信息 */}
|
{/* 作者信息 */}
|
||||||
<div className="flex items-center gap-2 mb-4">
|
<div className="flex items-center gap-2 mb-4">
|
||||||
<img
|
{content.author.avatar_url ? (
|
||||||
src={content.author.avatar_url || ""}
|
<Image
|
||||||
|
src={content.author.avatar_url}
|
||||||
alt={content.author.nickname}
|
alt={content.author.nickname}
|
||||||
|
width={24}
|
||||||
|
height={24}
|
||||||
|
unoptimized
|
||||||
className="w-6 h-6 rounded-full ring-1 ring-white/20"
|
className="w-6 h-6 rounded-full ring-1 ring-white/20"
|
||||||
/>
|
/>
|
||||||
|
) : (
|
||||||
|
<div className="h-6 w-6 rounded-full bg-white/10 ring-1 ring-white/20" />
|
||||||
|
)}
|
||||||
<span className="text-xs text-white/60">
|
<span className="text-xs text-white/60">
|
||||||
{content.author.nickname}
|
{content.author.nickname}
|
||||||
</span>
|
</span>
|
||||||
@@ -264,11 +350,12 @@ export default function SearchClient({
|
|||||||
</h4>
|
</h4>
|
||||||
<div
|
<div
|
||||||
className="text-xs text-white/70 bg-white/5 rounded-lg p-2 leading-relaxed"
|
className="text-xs text-white/70 bg-white/5 rounded-lg p-2 leading-relaxed"
|
||||||
dangerouslySetInnerHTML={{ __html: item.snippet }}
|
|
||||||
style={{
|
style={{
|
||||||
wordBreak: "break-word",
|
wordBreak: "break-word",
|
||||||
}}
|
}}
|
||||||
/>
|
>
|
||||||
|
{renderSnippet(item.snippet)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ export type FeedItem = (
|
|||||||
cover_url: string | null;
|
cover_url: string | null;
|
||||||
width?: number | null;
|
width?: number | null;
|
||||||
height?: number | null;
|
height?: number | null;
|
||||||
|
content_tags?: string[];
|
||||||
};
|
};
|
||||||
|
|
||||||
export interface FeedResponse {
|
export interface FeedResponse {
|
||||||
|
|||||||
@@ -0,0 +1,40 @@
|
|||||||
|
export const ASR_CONTENT_TAGS = [
|
||||||
|
"美女",
|
||||||
|
"游戏",
|
||||||
|
"时政",
|
||||||
|
"美食",
|
||||||
|
"旅行",
|
||||||
|
"体育",
|
||||||
|
"科技",
|
||||||
|
"教育",
|
||||||
|
"宠物",
|
||||||
|
"音乐",
|
||||||
|
"影视",
|
||||||
|
"财经",
|
||||||
|
"生活",
|
||||||
|
"其他",
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
export type AsrContentTag = (typeof ASR_CONTENT_TAGS)[number];
|
||||||
|
|
||||||
|
const asrContentTagSet = new Set<string>(ASR_CONTENT_TAGS);
|
||||||
|
|
||||||
|
export function isAsrContentTag(value: string): value is AsrContentTag {
|
||||||
|
return asrContentTagSet.has(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function normalizeAsrContentTags(tags: unknown): AsrContentTag[] {
|
||||||
|
const rawTags = Array.isArray(tags)
|
||||||
|
? tags
|
||||||
|
: typeof tags === "string"
|
||||||
|
? tags.split(/[、,,\s]+/).filter(Boolean)
|
||||||
|
: [];
|
||||||
|
|
||||||
|
const normalized = rawTags.filter(
|
||||||
|
(tag): tag is AsrContentTag =>
|
||||||
|
typeof tag === "string" && isAsrContentTag(tag),
|
||||||
|
);
|
||||||
|
|
||||||
|
const uniqueTags = [...new Set(normalized)].slice(0, 3);
|
||||||
|
return uniqueTags.length > 0 ? uniqueTags : ["其他"];
|
||||||
|
}
|
||||||
+1
-1
@@ -6,6 +6,7 @@
|
|||||||
"dev": "next dev --turbopack",
|
"dev": "next dev --turbopack",
|
||||||
"build": "next build --turbopack",
|
"build": "next build --turbopack",
|
||||||
"start": "next start",
|
"start": "next start",
|
||||||
|
"asr": "node scripts/asr-cli.cjs",
|
||||||
"lint": "biome check",
|
"lint": "biome check",
|
||||||
"format": "biome format --write"
|
"format": "biome format --write"
|
||||||
},
|
},
|
||||||
@@ -21,7 +22,6 @@
|
|||||||
"puppeteer-extra-plugin-stealth": "^2.11.2",
|
"puppeteer-extra-plugin-stealth": "^2.11.2",
|
||||||
"react": "19.1.0",
|
"react": "19.1.0",
|
||||||
"react-dom": "19.1.0",
|
"react-dom": "19.1.0",
|
||||||
"undici": "^7.16.0",
|
|
||||||
"zod": "^4.1.12"
|
"zod": "^4.1.12"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
|||||||
@@ -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");
|
||||||
@@ -145,11 +145,13 @@ model VideoTranscript {
|
|||||||
audio_type String?
|
audio_type String?
|
||||||
transcript String[]
|
transcript String[]
|
||||||
non_speech_summary String?
|
non_speech_summary String?
|
||||||
|
content_tags String[] @default([])
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
updatedAt DateTime @updatedAt
|
updatedAt DateTime @updatedAt
|
||||||
transcript_zh_tsv Unsupported("tsvector")?
|
transcript_zh_tsv Unsupported("tsvector")?
|
||||||
video Video @relation(fields: [videoId], references: [aweme_id], onDelete: Cascade)
|
video Video @relation(fields: [videoId], references: [aweme_id], onDelete: Cascade)
|
||||||
|
|
||||||
@@index([videoId])
|
@@index([videoId])
|
||||||
|
@@index([content_tags], map: "video_transcript_content_tags_idx", type: Gin)
|
||||||
@@index([transcript_zh_tsv], map: "video_transcript_zh_idx", type: Gin)
|
@@index([transcript_zh_tsv], map: "video_transcript_zh_idx", type: Gin)
|
||||||
}
|
}
|
||||||
|
|||||||
Executable
+135
@@ -0,0 +1,135 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
const fs = require("node:fs");
|
||||||
|
const Module = require("node:module");
|
||||||
|
const path = require("node:path");
|
||||||
|
|
||||||
|
const rootDir = path.resolve(__dirname, "..");
|
||||||
|
|
||||||
|
function loadEnvFile(filePath, override = false) {
|
||||||
|
if (!fs.existsSync(filePath)) return;
|
||||||
|
|
||||||
|
const lines = fs.readFileSync(filePath, "utf8").split(/\r?\n/);
|
||||||
|
for (const line of lines) {
|
||||||
|
const trimmed = line.trim();
|
||||||
|
if (!trimmed || trimmed.startsWith("#")) continue;
|
||||||
|
|
||||||
|
const normalized = trimmed.startsWith("export ")
|
||||||
|
? trimmed.slice("export ".length).trim()
|
||||||
|
: trimmed;
|
||||||
|
const match = normalized.match(/^([A-Za-z_][A-Za-z0-9_]*)\s*=\s*(.*)$/);
|
||||||
|
if (!match) continue;
|
||||||
|
|
||||||
|
const key = match[1];
|
||||||
|
let value = match[2].trim();
|
||||||
|
if (
|
||||||
|
(value.startsWith('"') && value.endsWith('"')) ||
|
||||||
|
(value.startsWith("'") && value.endsWith("'"))
|
||||||
|
) {
|
||||||
|
value = value.slice(1, -1);
|
||||||
|
}
|
||||||
|
value = value.replace(/\\n/g, "\n");
|
||||||
|
|
||||||
|
if (override || process.env[key] === undefined) {
|
||||||
|
process.env[key] = value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function registerTypeScriptRuntime() {
|
||||||
|
const ts = require("typescript");
|
||||||
|
const originalResolveFilename = Module._resolveFilename;
|
||||||
|
|
||||||
|
Module._resolveFilename = function resolveFilename(
|
||||||
|
request,
|
||||||
|
parent,
|
||||||
|
isMain,
|
||||||
|
options,
|
||||||
|
) {
|
||||||
|
if (request.startsWith("@/")) {
|
||||||
|
return originalResolveFilename.call(
|
||||||
|
this,
|
||||||
|
path.join(rootDir, request.slice(2)),
|
||||||
|
parent,
|
||||||
|
isMain,
|
||||||
|
options,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return originalResolveFilename.call(this, request, parent, isMain, options);
|
||||||
|
};
|
||||||
|
|
||||||
|
const compile = (module, filename) => {
|
||||||
|
const source = fs.readFileSync(filename, "utf8");
|
||||||
|
const output = ts.transpileModule(source, {
|
||||||
|
fileName: filename,
|
||||||
|
compilerOptions: {
|
||||||
|
module: ts.ModuleKind.CommonJS,
|
||||||
|
target: ts.ScriptTarget.ES2022,
|
||||||
|
esModuleInterop: true,
|
||||||
|
resolveJsonModule: true,
|
||||||
|
jsx: ts.JsxEmit.ReactJSX,
|
||||||
|
moduleResolution: ts.ModuleResolutionKind.Node10,
|
||||||
|
},
|
||||||
|
}).outputText;
|
||||||
|
module._compile(output, filename);
|
||||||
|
};
|
||||||
|
|
||||||
|
require.extensions[".ts"] = compile;
|
||||||
|
require.extensions[".tsx"] = compile;
|
||||||
|
require.extensions[".md"] = (module, filename) => {
|
||||||
|
module.exports = fs.readFileSync(filename, "utf8");
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function getAwemeId() {
|
||||||
|
const args = process.argv.slice(2);
|
||||||
|
const fromFlag = args.find((arg) => arg.startsWith("--aweme-id="));
|
||||||
|
if (fromFlag) return fromFlag.slice("--aweme-id=".length).trim();
|
||||||
|
|
||||||
|
const flagIndex = args.findIndex(
|
||||||
|
(arg) => arg === "--aweme-id" || arg === "-a",
|
||||||
|
);
|
||||||
|
if (flagIndex >= 0) return (args[flagIndex + 1] || "").trim();
|
||||||
|
|
||||||
|
return (args.find((arg) => !arg.startsWith("-")) || "").trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
function usage() {
|
||||||
|
console.log("用法: npm run asr -- <aweme_id>");
|
||||||
|
console.log("或: node scripts/asr-cli.cjs --aweme-id <aweme_id>");
|
||||||
|
}
|
||||||
|
|
||||||
|
function logStep(message) {
|
||||||
|
const now = new Date().toISOString();
|
||||||
|
console.log(`[asr ${now}] ${message}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
loadEnvFile(path.join(rootDir, ".env"));
|
||||||
|
loadEnvFile(path.join(rootDir, ".env.local"), true);
|
||||||
|
registerTypeScriptRuntime();
|
||||||
|
|
||||||
|
const awemeId = getAwemeId();
|
||||||
|
if (!awemeId) {
|
||||||
|
usage();
|
||||||
|
process.exitCode = 1;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
logStep(`开始执行 aweme_id=${awemeId}`);
|
||||||
|
|
||||||
|
const { transcriptAweme } = require(path.join(rootDir, "app/api/asr"));
|
||||||
|
const { prisma } = require(path.join(rootDir, "lib/prisma"));
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await transcriptAweme(awemeId, { log: logStep });
|
||||||
|
logStep("最终结果:");
|
||||||
|
console.log(JSON.stringify(result, null, 2));
|
||||||
|
} finally {
|
||||||
|
await prisma.$disconnect();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
main().catch((error) => {
|
||||||
|
console.error("[asr] 执行失败:", error);
|
||||||
|
process.exitCode = 1;
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user