Compare commits

..
24 Commits
Author SHA1 Message Date
feie9454andClaude Opus 5.5 2391abdeea feat: hashtag 列表显示按共现计算的相关 tag
- 新增 /api/hashtag/related:从作品描述实时解析 hashtag,按「同一作品里一起出现」计算相关 tag
- 打分取子话题(候选多与目标同现)与上位话题(目标多与热门候选同现)两者较大值,
  过滤 cos、二次元等万金油 tag 与平台活动、纯数字 tag
- 播放页侧栏的 hashtag 列表顶部显示相关 tag,点击在侧栏内预览(不打断当前播放),
  在预览里点作品才切换到该 tag 的列表

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-09 01:18:04 +08:00
feie9454andClaude Opus 5.5 b84fcec77b feat: 播放页按来源列表切换,新增作者/搜索/hashtag 侧栏
- 播放上下文:作品链接带上来源(搜索、标签、作者、hashtag),上一条/下一条
  在该列表内移动;当前作品不在列表中时退回全部作品的发布时间顺序
- 顶部提示标签显示当前列表与位置,点击打开侧栏列表,✕ 退出
- 侧栏改为「评论 | TA的作品 | 搜索结果/#hashtag」标签页,作品封面网格可就地切换
- 作者模式:打开「TA的作品」进入,关闭侧栏不退出,点提示 ✕ 后回到原来的列表
- 从搜索结果进入或点 hashtag 时自动打开对应列表
- 描述中的 hashtag 可点击,按完整 hashtag 精确匹配(#丰川祥子 不含 #丰川祥子cos)
- 新增 /api/playlist;搜索 SQL 抽到 lib/search-query.ts 与播放页共用,保证顺序一致
- 移动端:底部抽屉、点作品后收起,媒体区支持上下滑切换

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-09 00:50:09 +08:00
feie9454andClaude Opus 5.5 182ca9cf05 feat: 搜索改为子串匹配,支持多关键词、排除词与分页加载
- 不再依赖 zhparser 分词:关键词按空格拆分,全部需在描述或语音转写中以子串出现;-词 表示排除
- 英文/数字关键词要求词首对齐,避免 ai 命中 said
- 按描述/转写命中次数打分,同分按发布时间倒序;总数随查询一并返回
- 返回结构化高亮片段(descParts / snippet),前端高亮描述与转写命中位置
- 前端显示描述/语音命中标记、真实结果总数,并支持加载更多

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-08 22:43:15 +08:00
feie9454andCursor 11d9d35002 feat: 支持长按右键/D键二倍速播放与视频AB循环
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-11-04 02:00:05 +08:00
feie9454andCursor bcf1e6e349 fix: 修复图文动图播放被重置与进度停滞问题
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-11-04 01:41:47 +08:00
feie9454andCursor 10f2520279 refactor: 重构作品播放页性能、媒体同步与内存管理
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-11-04 01:31:10 +08:00
feie9454 990e1b1804 加入作者页抖音直链 2026-10-20 01:03:56 +08:00
feie9454 d3896522ec fix: 合并服务器未推送改动并保留手动视频标签 2026-10-03 22:09:09 +08:00
feie9454 6f0e38054b feat: 将视频转写重构为纯文字异步语音识别 2026-10-03 21:58:06 +08:00
feie9454 b9af979582 允许后端任务 2026-06-17 17:58:00 +08:00
feie9454 eac6ad2343 feat: add manual video tagging 2026-04-29 21:20:21 +08:00
feie9454 02d37f3953 修改icon 2026-04-29 20:25:19 +08:00
feie9454andCopilot 206a561ee7 使用 qwen 进行语音识别,加入 tag
Co-authored-by: Copilot <copilot@github.com>
2026-04-29 20:10:08 +08:00
feie9454 040273586b 优化图文页 2026-04-28 17:11:42 +08:00
feie9454 674c202264 作者主页 2025-11-29 21:56:38 +08:00
feie9454 63ed49e762 修复首页ssr不更新 2025-11-29 21:29:07 +08:00
feie9454 edd0a85439 chore: add undici as a dependency 2025-11-29 21:26:22 +08:00
feie9454 823df2cbf6 集成媒体会话API,支持视频和图文的播放状态同步与元信息更新;重构搜索页面,避免编译警告 2025-10-25 20:18:19 +08:00
feie9454 0d18595b68 完成转录功能,中文脚本搜索,播放界面显示脚本 2025-10-25 19:22:41 +08:00
feie9454 d1b4fb6be0 语音转文本接口,bgm路径修复 2025-10-25 13:06:17 +08:00
feie9454 e4339a5b91 修改资源URL模式,不再存储https://domain 前缀 2025-10-25 12:19:53 +08:00
feie9454 f94ef73518 添加详情页的点赞计数,添加网站图标 2025-10-23 14:22:12 +08:00
feie9454 5a5eba19e4 反爬虫优化,评论图片支持 2025-10-23 13:25:51 +08:00
feie9454 590b26c420 优化动图 2025-10-23 10:30:43 +08:00
126 changed files with 14157 additions and 2411 deletions
+18
View File
@@ -0,0 +1,18 @@
DATABASE_URL=postgresql://postgres:password@localhost:5432/douyin_archive
PORT=3000
MINIO_ENDPOINT=localhost
MINIO_PORT=9000
MINIO_USE_SSL=false
MINIO_ACCESS_KEY=
MINIO_SECRET_KEY=
MINIO_BUCKET_NAME=home-page
MINIO_PUBLIC_DOMAIN=https://your-public-storage.example.com
# Native DashScope ASR, not the OpenAI compatible-mode endpoint.
DASHSCOPE_API_KEY=
DASHSCOPE_BASE_URL=https://dashscope.aliyuncs.com/api/v1
DASHSCOPE_ASR_MODEL=qwen3-asr-flash-filetrans
FFMPEG_PATH=ffmpeg
FFPROBE_PATH=ffprobe
CHROMIUM_HEADLESS=true
+1
View File
@@ -32,6 +32,7 @@ yarn-error.log*
# env files (can opt-in for committing if needed) # env files (can opt-in for committing if needed)
.env* .env*
!.env.example
# vercel # vercel
.vercel .vercel
+3 -11
View File
@@ -5,23 +5,15 @@
"label": "tsc-check", "label": "tsc-check",
"type": "shell", "type": "shell",
"command": "node", "command": "node",
"args": [ "args": ["-e", "require('typescript').transpile('const x: number = 1;')"],
"-e", "problemMatcher": ["$tsc"],
"require('typescript').transpile('const x: number = 1;')"
],
"problemMatcher": [
"$tsc"
],
"group": "build" "group": "build"
}, },
{ {
"label": "tsc-check (one-off)", "label": "tsc-check (one-off)",
"type": "shell", "type": "shell",
"command": "node", "command": "node",
"args": [ "args": ["-e", "require('typescript').transpile('const x: number = 1;')"]
"-e",
"require('typescript').transpile('const x: number = 1;')"
]
} }
] ]
} }
+77 -1
View File
@@ -18,7 +18,7 @@ Open [http://localhost:3000](http://localhost:3000) with your browser to see the
You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file. You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file.
This project uses [`next/font`](https://nextjs.org/docs/app/building-your-application/optimizing/fonts) to automatically optimize and load [Geist](https://vercel.com/font), a new font family for Vercel. This project uses local system font stacks so production builds do not depend on fetching Google Fonts.
## Learn More ## Learn More
@@ -34,3 +34,79 @@ You can check out [the Next.js GitHub repository](https://github.com/vercel/next
The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js. The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js.
Check out our [Next.js deployment documentation](https://nextjs.org/docs/app/building-your-application/deploying) for more details. Check out our [Next.js deployment documentation](https://nextjs.org/docs/app/building-your-application/deploying) for more details.
## 视频文字转写(DashScope ASR)
仅使用 `qwen3-asr-flash-filetrans` 做文字识别,不使用通用模型理解、分类、总结或润色。新视频保存后入队;历史文字保留,后台可单条重转或批量补转。关闭页面不影响已入队任务。
处理流程:从 MinIO 将原视频流式下载到临时文件 → ffprobe 检查音轨及实际时长 → ffmpeg 提取整段 **16 kHz 单声道、64 kbps MP3** → 上传至 MinIO → 将 **音频 URL** 提交到 DashScope → 后台取回文字。没有分块、视频上传到模型或二次模型调用。无音轨直接保存空文字;ASR 空结果显示“未识别到文字”。
### 配置与启动
参考 `.env.example`。模型配置统一为:
```dotenv
DASHSCOPE_API_KEY=你的百炼Key
DASHSCOPE_BASE_URL=https://dashscope.aliyuncs.com/api/v1
DASHSCOPE_ASR_MODEL=qwen3-asr-flash-filetrans
```
这是 DashScope **原生 API** 地址,不是 `/compatible-mode/v1`。Key 和端点地域必须匹配。也可使用北京业务空间域名的 `/api/v1` 地址。旧 `OPENAI_*`、`QWEN_*` 和转写模块的全局代理配置不再使用;已有 Omni 的百炼 Key 可用于 ASR,仍需账号开通相应模型权限。
需要 ffmpeg、ffprobe(可通过 `FFMPEG_PATH`、`FFPROBE_PATH` 指定)。`MINIO_ENDPOINT`/端口/SSL 必须是运行机器可鉴权读写的 S3 API;`MINIO_PUBLIC_DOMAIN` 必须让 DashScope 能下载所上传的音频。执行器不修改 Bucket 策略。音频存储于 `transcription-audio/<视频ID>/<时间戳>.mp3`;独立对象防止手动重试覆盖仍在使用的音频。可为该前缀配置不少于 7 天的生命周期,视频原文件不受影响。本地视频临时文件在成功或失败后都会清理。
```bash
npm ci
npx prisma generate
npm run dev
# 另一个终端(开发与生产均需执行器)
npm run stt:worker
```
生产使用 `pm2 startOrReload pm2.config.cjs --env production --update-env`,配置同时包含 `DouyinArchive` 和 `DouyinArchive-ASR`。全局最多两个处理中任务,每 10 秒查一次供应商状态。任务保存在 PostgreSQL,不需要 Redis。未配置 Key 不阻止网站启动,转写会显示配置错误。
### 数据库迁移与发布顺序
1. 备份现有数据库,保存旧版本用于回退。
2. 在停止服务前安装依赖、生成 Prisma Client 并运行 `npm run build`。
3. 停止旧网站及旧后台执行器,运行 `npx prisma migrate deploy`。
4. 用上述 PM2 命令启动新网站和 ASR 执行器,检查两者日志;确认转写与搜索后再完成发布验收。
迁移保留 `VideoTranscript` 的历史文字、关联和时间字段;删除 `speech_detected`、`language`、`audio_type`、`non_speech_summary`,以及部分部署存在的 `content_tags`。不自动补转或重转历史视频。新建 `TranscriptionTask` 保存状态、任务 ID、租约和重试信息。重转成功时原子替换文字,失败保留旧文字。
迁移会替换已知的旧全文检索触发器,并维护 `transcript_zh_tsv` 与 GIN 索引。**已有 `public.zhcfg` 中文分词配置会保留**;新库没有该配置时创建 `simple` 回退配置,它不提供中文分词。新部署若需要与旧库相同的中文搜索效果,先安装同样的中文分词扩展和 `zhcfg`;后续调整分词配置后应重新计算现有索引字段。不要在迁移后直接回滚到依赖旧字段的应用;需要回退时恢复数据库备份和旧应用版本。
### 接口和任务行为
- `POST /api/stt`,JSON `{ "awemeId": "视频ID" }`:首次转写或手动重转;处理中重复点击复用当前任务。
- `GET /api/stt?awemeId=视频ID`:只读取 `{ status, error, transcript }`,不触发转写。`transcript` 为 `string[] | null`;`null` 表示没有完成记录,`[]` 表示完成但没有文字。
- `POST /api/stt/batch`:一次将所有没有完成记录、且未在排队或处理中的视频加入数据库队列,失败记录可补转,历史完成记录不重转。
- 状态为 `PENDING`(尚未入队)、`QUEUED`、`PROCESSING`、`SUCCEEDED`、`FAILED`。后台显示各状态数量、错误和重试按钮。
音频限制为 12 小时、2 GB,超限失败而非截断。执行器重启后复用已有供应商任务 ID;租约到期可恢复领取。提交前写入标记,提交阶段崩溃或超时无法确认任务 ID 时,标为失败并要求核对百炼任务后手动重试,避免盲目重复付费。查询和结果下载的网络错误/429/5xx 按 10 秒至 5 分钟退避重试;鉴权和明确的供应商失败不自动重提。超过 24 小时的任务标为失败。
### 验证
```bash
npm run test:stt
# 必须是独立本地库 douyin_asr_test;此测试会重建该库的 public schema
TEST_DATABASE_URL=postgresql://你的本地用户名@127.0.0.1:5432/douyin_asr_test npm run test:stt:integration
npx tsc --noEmit
npm run build
```
部署后可用管理页测试短视频、超过 5 分钟的视频、无发言/无音轨视频。检查新归档自动入队、关页面继续处理、重启续查、失败保留旧文字及文字搜索。
也可对已归档的视频独立实测(会上传音频、调用付费 ASR;**不修改数据库里的原转写**):
```bash
npx tsx scripts/stt-smoke.ts 7682010237578136858
```
脚本将供应商任务 ID 和结果保存在 `/tmp/douyin-asr-<视频ID>.json`。已有未完成任务时应查询原任务,不要重复提交。
### 手动标签兼容
保留服务器版本的手动标签、首页批量打标签、详情页编辑及标签筛选;标签只来自 `Video.manual_content_tags`,不再回退到模型自动分类。转写成功、失败或重试都不会修改手动标签。后台转写列表支持手动标签筛选与批量添加/清空。
已有 `/admin/asr`、`/admin/asr/videos`、`/api/admin/asr/videos` 地址继续可用;`/api/asr` 与 `/api/stt` 一样,POST 入队、GET 只读。旧内存批处理和分块模型调用已移除。
+139
View File
@@ -0,0 +1,139 @@
"use client";
import Link from "next/link";
import BackButton from "@/app/components/BackButton";
export default function AsrAdminPage() {
return (
<div className="min-h-screen bg-gray-50">
<div className="max-w-4xl mx-auto p-8">
<BackButton />
<div className="mt-8">
<h1 className="text-3xl font-bold text-gray-900">ASR 管理中心</h1>
<p className="text-gray-600 mt-2">管理和配置视频语音识别功能</p>
</div>
<div className="mt-8 grid gap-6">
{/* 视频转写管理 */}
<Link
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"
>
<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">
<svg
aria-hidden="true"
className="w-6 h-6 text-blue-600"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M15 10l4.553-2.276A1 1 0 0121 8.618v6.764a1 1 0 01-1.447.894L15 14M5 18h8a2 2 0 002-2V8a2 2 0 00-2-2H5a2 2 0 00-2 2v8a2 2 0 002 2z"
/>
</svg>
</div>
<div className="flex-1">
<h2 className="text-xl font-semibold text-gray-900">
视频转写管理
</h2>
<p className="text-gray-600 mt-1">
查看和管理所有视频的转写状态,对待转写的视频进行批量或单个转写
</p>
<div className="mt-4 text-blue-600 font-medium flex items-center gap-1">
进入管理
<svg
aria-hidden="true"
className="w-4 h-4"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M9 5l7 7-7 7"
/>
</svg>
</div>
</div>
</div>
</Link>
{/* 转写设置(预留) */}
<div className="block bg-white rounded-lg shadow-md p-6 border border-gray-200 opacity-60">
<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">
<svg
aria-hidden="true"
className="w-6 h-6 text-gray-600"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z"
/>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"
/>
</svg>
</div>
<div className="flex-1">
<h2 className="text-xl font-semibold text-gray-900">
转写设置
</h2>
<p className="text-gray-600 mt-1">
配置转写 API、模型参数和其他高级选项
</p>
<div className="mt-4 text-gray-400 font-medium">即将推出</div>
</div>
</div>
</div>
{/* 转写统计(预留) */}
<div className="block bg-white rounded-lg shadow-md p-6 border border-gray-200 opacity-60">
<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">
<svg
aria-hidden="true"
className="w-6 h-6 text-gray-600"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z"
/>
</svg>
</div>
<div className="flex-1">
<h2 className="text-xl font-semibold text-gray-900">
转写统计
</h2>
<p className="text-gray-600 mt-1">
查看转写使用情况、成功率、语言分布等统计数据
</p>
<div className="mt-4 text-gray-400 font-medium">即将推出</div>
</div>
</div>
</div>
</div>
</div>
</div>
);
}
+1
View File
@@ -0,0 +1 @@
export { default } from "../../stt/videos/page";
+1
View File
@@ -0,0 +1 @@
export { default } from "../asr/page";
+368
View File
@@ -0,0 +1,368 @@
"use client";
import Image from "next/image";
import Link from "next/link";
import { useCallback, useEffect, useRef, useState } from "react";
import { VIDEO_CONTENT_TAGS } from "@/lib/video-tags";
import BackButton from "@/app/components/BackButton";
import {
isActive,
statusLabels,
type TranscriptionState,
type TranscriptionStatus,
} from "@/lib/stt/types";
type Video = {
aweme_id: string;
desc: string;
cover_url: string;
duration_ms: number;
author: { nickname: string };
transcription: TranscriptionState;
content_tags: string[];
};
const filters: ("all" | TranscriptionStatus)[] = [
"all",
"PENDING",
"QUEUED",
"PROCESSING",
"SUCCEEDED",
"FAILED",
];
export default function SttVideosPage() {
const [videos, setVideos] = useState<Video[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [notice, setNotice] = useState("");
const [submitting, setSubmitting] = useState<Set<string>>(new Set());
const [batchSubmitting, setBatchSubmitting] = useState(false);
const [filter, setFilter] = useState<"all" | TranscriptionStatus>("all");
const [selected, setSelected] = useState<Set<string>>(new Set());
const [tag, setTag] = useState<string>(VIDEO_CONTENT_TAGS[0]);
const [tagFilter, setTagFilter] = useState("");
const [savingTags, setSavingTags] = useState(false);
const fetching = useRef(false);
const fetchVideos = useCallback(async () => {
if (fetching.current) return;
fetching.current = true;
try {
const response = await fetch("/api/admin/stt/videos", {
cache: "no-store",
});
if (!response.ok) throw new Error("读取转写列表失败");
const data: { videos: Video[] } = await response.json();
setVideos(data.videos);
setError(null);
} catch (err) {
setError(err instanceof Error ? err.message : "读取失败");
} finally {
setLoading(false);
fetching.current = false;
}
}, []);
useEffect(() => {
void fetchVideos();
const timer = setInterval(() => {
void fetchVideos();
}, 5000);
return () => clearInterval(timer);
}, [fetchVideos]);
async function handleTranscribe(awemeId: string) {
setSubmitting((prev) => new Set(prev).add(awemeId));
try {
const response = await fetch("/api/stt", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ awemeId }),
});
const data = await response.json();
if (!response.ok) throw new Error(data.error || "任务入队失败");
setVideos((prev) =>
prev.map((video) =>
video.aweme_id === awemeId
? { ...video, transcription: data }
: video,
),
);
} catch (err) {
setNotice(err instanceof Error ? err.message : "任务入队失败");
} finally {
setSubmitting((prev) => {
const next = new Set(prev);
next.delete(awemeId);
return next;
});
}
}
async function handleBatch() {
setBatchSubmitting(true);
try {
const response = await fetch("/api/stt/batch", { method: "POST" });
const data = await response.json();
if (!response.ok) throw new Error(data.error || "批量入队失败");
setNotice(`已加入 ${data.queued} 个转写任务;关闭页面后仍会继续处理。`);
await fetchVideos();
} catch (err) {
setNotice(err instanceof Error ? err.message : "批量入队失败");
} finally {
setBatchSubmitting(false);
}
}
async function saveTags(clear = false) {
setSavingTags(true);
try {
const response = await fetch("/api/asr/tags", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
awemeIds: [...selected],
contentTags: clear ? [] : [tag],
mode: clear ? "set" : "add",
}),
});
if (!response.ok) throw new Error("保存手动标签失败");
setNotice(`已更新 ${selected.size} 个视频的手动标签`);
setSelected(new Set());
await fetchVideos();
} catch (err) {
setNotice(err instanceof Error ? err.message : "保存失败");
} finally {
setSavingTags(false);
}
}
const count = (status: TranscriptionStatus) =>
videos.filter((v) => v.transcription.status === status).length;
const eligible = videos.filter(
(v) =>
v.transcription.transcript === null && !isActive(v.transcription.status),
).length;
const shown = videos.filter(
(v) =>
(filter === "all" || v.transcription.status === filter) &&
(!tagFilter || v.content_tags.includes(tagFilter)),
);
return (
<main className="min-h-screen bg-gray-50 p-6">
<div className="mx-auto max-w-7xl space-y-4">
<BackButton />
<h1 className="text-2xl font-semibold text-gray-900">视频文字转写</h1>
<p className="text-sm text-gray-600">
已完成 {count("SUCCEEDED")} · 处理中 {count("PROCESSING")} · 排队中{" "}
{count("QUEUED")} · 失败 {count("FAILED")} · 待转写 {count("PENDING")}
</p>
{error && (
<p role="alert" className="text-red-700">
{error}
</p>
)}
{notice && (
<output className="block text-sm text-gray-700">{notice}</output>
)}
<div className="flex flex-wrap items-center justify-between gap-3">
<div className="flex flex-wrap gap-2">
{filters.map((value) => (
<button
type="button"
key={value}
onClick={() => setFilter(value)}
className={`rounded px-3 py-2 text-sm ${filter === value ? "bg-blue-600 text-white" : "bg-white text-gray-700"}`}
>
{value === "all"
? `全部 (${videos.length})`
: `${statusLabels[value]} (${count(value)})`}
</button>
))}
</div>
<button
type="button"
onClick={handleBatch}
disabled={batchSubmitting || !eligible}
className="rounded bg-blue-600 px-4 py-2 text-sm text-white disabled:opacity-50"
>
{batchSubmitting ? "正在加入队列…" : `批量补转 (${eligible})`}
</button>
</div>
<div className="flex flex-wrap items-center gap-2 text-sm text-gray-700">
<select
aria-label="筛选手动标签"
value={tagFilter}
onChange={(e) => {
setTagFilter(e.target.value);
setSelected(new Set());
}}
className="rounded border p-2"
>
<option value="">全部手动标签</option>
{VIDEO_CONTENT_TAGS.map((value) => (
<option key={value}>{value}</option>
))}
</select>
<span>已选 {selected.size} 个(最多 200 个)</span>
<select
aria-label="批量添加的标签"
value={tag}
onChange={(e) => setTag(e.target.value)}
className="rounded border p-2"
>
{VIDEO_CONTENT_TAGS.map((value) => (
<option key={value}>{value}</option>
))}
</select>
<button
type="button"
disabled={!selected.size || savingTags}
onClick={() => saveTags()}
className="rounded border px-3 py-2 disabled:opacity-50"
>
添加手动标签
</button>
<button
type="button"
disabled={!selected.size || savingTags}
onClick={() => saveTags(true)}
className="rounded border px-3 py-2 disabled:opacity-50"
>
清空手动标签
</button>
</div>
<div className="overflow-x-auto rounded border border-gray-200 bg-white">
<table className="w-full text-left text-sm">
<thead className="bg-gray-100 text-gray-600">
<tr>
<th className="p-3">
<input
type="checkbox"
aria-label="选择当前列表前200个视频"
checked={
shown.length > 0 &&
shown.slice(0, 200).every((v) => selected.has(v.aweme_id))
}
onChange={(e) =>
setSelected(
e.target.checked
? new Set(shown.slice(0, 200).map((v) => v.aweme_id))
: new Set(),
)
}
/>
</th>
<th className="p-3">视频</th>
<th className="p-3">时长</th>
<th className="p-3">状态与文字</th>
<th className="p-3">操作</th>
</tr>
</thead>
<tbody>
{shown.map((video) => {
const state = video.transcription;
const active =
isActive(state.status) || submitting.has(video.aweme_id);
return (
<tr
key={video.aweme_id}
className="border-t border-gray-100 align-top"
>
<td className="p-3">
<input
type="checkbox"
aria-label={`选择视频 ${video.aweme_id}`}
checked={selected.has(video.aweme_id)}
disabled={
!selected.has(video.aweme_id) && selected.size >= 200
}
onChange={(e) =>
setSelected((previous) => {
const next = new Set(previous);
if (e.target.checked) next.add(video.aweme_id);
else next.delete(video.aweme_id);
return next;
})
}
/>
</td>
<td className="p-3">
<div className="flex max-w-sm gap-3">
{video.cover_url && (
<Image
src={video.cover_url}
alt=""
width={56}
height={80}
unoptimized
className="h-20 w-14 rounded object-cover"
/>
)}
<div>
<Link
className="line-clamp-2 text-blue-700"
href={`/aweme/${video.aweme_id}`}
>
{video.desc || "无描述"}
</Link>
<p className="mt-1 text-gray-500">
@{video.author.nickname}
</p>
<p className="mt-1 text-xs text-blue-700">
{video.content_tags.join("、")}
</p>
</div>
</div>
</td>
<td className="whitespace-nowrap p-3 text-gray-700">
{Math.floor(video.duration_ms / 1000)} 秒
</td>
<td className="p-3">
<p
className={
state.status === "FAILED"
? "text-red-700"
: "text-gray-700"
}
>
{statusLabels[state.status]}
</p>
{state.error && (
<p className="mt-1 max-w-lg text-xs text-red-700">
{state.error}
</p>
)}
{state.transcript !== null && (
<p className="mt-1 max-w-lg whitespace-pre-wrap text-gray-600">
{state.transcript.join("\n") || "未识别到文字"}
</p>
)}
</td>
<td className="whitespace-nowrap p-3">
<button
type="button"
disabled={active}
onClick={() => handleTranscribe(video.aweme_id)}
className="rounded bg-gray-100 px-3 py-2 text-gray-800 disabled:opacity-50"
>
{active
? "处理中…"
: state.status === "FAILED"
? "重试"
: state.transcript !== null
? "重新转写"
: "开始转写"}
</button>
</td>
</tr>
);
})}
</tbody>
</table>
{!shown.length && (
<p className="p-8 text-center text-gray-500">
{loading ? "加载中…" : "没有找到视频"}
</p>
)}
</div>
</div>
</main>
);
}
+1
View File
@@ -0,0 +1 @@
export { GET } from "../../stt/videos/route";
+49
View File
@@ -0,0 +1,49 @@
import { NextResponse } from "next/server";
import { getFileUrl } from "@/lib/minio";
import { prisma } from "@/lib/prisma";
import { transcriptionState } from "@/lib/stt/types";
export async function GET() {
try {
const videos = await prisma.video.findMany({
orderBy: { created_at: "desc" },
include: {
author: {
select: {
nickname: true,
},
},
transcriptionTask: true,
transcript: {
select: {
id: true,
transcript: true,
},
},
},
});
const formattedVideos = videos.map((v) => ({
aweme_id: v.aweme_id,
desc: v.desc,
cover_url: v.cover_url ? getFileUrl(v.cover_url) : "",
duration_ms: v.duration_ms,
author: {
nickname: v.author.nickname,
},
content_tags: v.manual_content_tags,
transcription: transcriptionState(v.transcript, v.transcriptionTask),
}));
return NextResponse.json({
videos: formattedVideos,
total: videos.length,
});
} catch (error) {
console.error("Failed to fetch videos:", error);
return NextResponse.json(
{ error: "Failed to fetch videos" },
{ status: 500 },
);
}
}
+1
View File
@@ -0,0 +1 @@
export { GET, POST } from "../stt/route";
+121
View File
@@ -0,0 +1,121 @@
export const runtime = "nodejs";
import { type NextRequest, NextResponse } from "next/server";
import { isVideoContentTag } from "@/lib/video-tags";
import { prisma } from "@/lib/prisma";
type UpdateMode = "set" | "add";
function uniqueValidTags(tags: unknown[]) {
const validTags: string[] = [];
for (const tag of tags) {
if (typeof tag !== "string" || !isVideoContentTag(tag)) {
throw new Error("Invalid tag");
}
if (!validTags.includes(tag)) validTags.push(tag);
}
return validTags;
}
export async function POST(req: NextRequest) {
let body: unknown;
try {
body = await req.json();
} catch {
return NextResponse.json({ error: "Invalid JSON body" }, { status: 400 });
}
if (!body || typeof body !== "object") {
return NextResponse.json(
{ error: "Invalid request body" },
{ status: 400 },
);
}
const raw = body as {
awemeId?: unknown;
awemeIds?: unknown;
tag?: unknown;
contentTags?: unknown;
mode?: unknown;
};
const awemeIds = Array.isArray(raw.awemeIds)
? raw.awemeIds
: typeof raw.awemeId === "string"
? [raw.awemeId]
: [];
const normalizedAwemeIds = [...new Set(awemeIds)]
.filter(
(id): id is string => typeof id === "string" && id.trim().length > 0,
)
.map((id) => id.trim());
if (normalizedAwemeIds.length === 0 || normalizedAwemeIds.length > 200) {
return NextResponse.json(
{ error: "awemeIds must contain 1 to 200 ids" },
{ status: 400 },
);
}
const mode: UpdateMode = raw.mode === "add" ? "add" : "set";
const rawTags = Array.isArray(raw.contentTags)
? raw.contentTags
: typeof raw.tag === "string"
? [raw.tag]
: [];
let contentTags: string[];
try {
contentTags = uniqueValidTags(rawTags);
} catch {
return NextResponse.json({ error: "Invalid tag" }, { status: 400 });
}
if (mode === "add" && contentTags.length === 0) {
return NextResponse.json(
{ error: "add mode requires at least one tag" },
{ status: 400 },
);
}
const videos = await prisma.video.findMany({
where: { aweme_id: { in: normalizedAwemeIds } },
select: {
aweme_id: true,
manual_content_tags: true,
},
});
if (videos.length === 0) {
return NextResponse.json({ error: "No videos found" }, { status: 404 });
}
const updates = videos.map((video) => {
const baseTags = video.manual_content_tags;
const manualTags =
mode === "add"
? [...new Set([...baseTags, ...contentTags])]
: contentTags;
return { awemeId: video.aweme_id, manualTags };
});
await prisma.$transaction(
updates.map((update) =>
prisma.video.update({
where: { aweme_id: update.awemeId },
data: { manual_content_tags: update.manualTags },
}),
),
);
return NextResponse.json({
updated: updates.map((update) => ({
aweme_id: update.awemeId,
manual_content_tags: update.manualTags,
content_tags: update.manualTags,
})),
missing: normalizedAwemeIds.filter(
(id) => !videos.some((video) => video.aweme_id === id),
),
});
}
+82
View File
@@ -0,0 +1,82 @@
import { NextRequest, NextResponse } from "next/server";
import { prisma } from "@/lib/prisma";
import type { FeedItem, FeedResponse } from "@/app/types/feed";
import { getFileUrl } from "@/lib/minio";
export async function GET(
req: NextRequest,
{ params }: { params: Promise<{ secUid: string }> },
) {
const secUid = (await params).secUid;
const { searchParams } = new URL(req.url);
const limitParam = searchParams.get("limit");
const beforeParam = searchParams.get("before");
const limit = Math.min(Math.max(Number(limitParam ?? "24"), 1), 60); // 1..60
const before = beforeParam ? new Date(beforeParam) : null;
// fetch chunk from both tables
const [videos, posts] = await Promise.all([
prisma.video.findMany({
where: {
authorId: secUid,
...(before ? { created_at: { lt: before } } : {}),
},
orderBy: { created_at: "desc" },
take: limit,
include: { author: true },
}),
prisma.imagePost.findMany({
where: {
authorId: secUid,
...(before ? { created_at: { lt: before } } : {}),
},
orderBy: { created_at: "desc" },
take: limit,
include: { author: true, images: { orderBy: { order: "asc" }, take: 1 } },
}),
]);
const merged: FeedItem[] = [
...videos.map((v) => ({
type: "video" as const,
aweme_id: v.aweme_id,
created_at: v.created_at,
desc: v.desc,
video_url: getFileUrl(v.video_url),
cover_url: getFileUrl(v.cover_url ?? "default_cover.png"),
width: v.width ?? null,
height: v.height ?? null,
author: {
nickname: v.author.nickname,
avatar_url: getFileUrl(v.author.avatar_url ?? ""),
sec_uid: v.author.sec_uid,
},
likes: Number(v.digg_count),
})),
...posts.map((p) => ({
type: "image" as const,
aweme_id: p.aweme_id,
created_at: p.created_at,
desc: p.desc,
cover_url: getFileUrl(p.images?.[0]?.url ?? null),
width: p.images?.[0]?.width ?? null,
height: p.images?.[0]?.height ?? null,
author: {
nickname: p.author.nickname,
avatar_url: getFileUrl(p.author.avatar_url ?? ""),
sec_uid: p.author.sec_uid,
},
likes: Number(p.digg_count),
})),
]
.sort((a, b) => +new Date(b.created_at) - +new Date(a.created_at))
.slice(0, limit);
const nextCursor =
merged.length > 0
? new Date(merged[merged.length - 1].created_at as any).toISOString()
: null;
const payload: FeedResponse = { items: merged, nextCursor };
return NextResponse.json(payload);
}
+52 -10
View File
@@ -12,8 +12,14 @@ export async function GET(req: NextRequest) {
// Find current item timestamp from either table // Find current item timestamp from either table
const [video, post] = await Promise.all([ const [video, post] = await Promise.all([
prisma.video.findUnique({ where: { aweme_id: awemeId }, select: { aweme_id: true, created_at: true } }), prisma.video.findUnique({
prisma.imagePost.findUnique({ where: { aweme_id: awemeId }, select: { aweme_id: true, created_at: true } }), where: { aweme_id: awemeId },
select: { aweme_id: true, created_at: true },
}),
prisma.imagePost.findUnique({
where: { aweme_id: awemeId },
select: { aweme_id: true, created_at: true },
}),
]); ]);
const current = video ?? post; const current = video ?? post;
@@ -52,25 +58,61 @@ export async function GET(req: NextRequest) {
]); ]);
const pickPrev = (() => { const pickPrev = (() => {
const cands: { type: "video" | "image"; aweme_id: string; created_at: Date }[] = []; const cands: {
if (newerVideo) cands.push({ type: "video", aweme_id: newerVideo.aweme_id, created_at: newerVideo.created_at as unknown as Date }); type: "video" | "image";
if (newerPost) cands.push({ type: "image", aweme_id: newerPost.aweme_id, created_at: newerPost.created_at as unknown as Date }); aweme_id: string;
created_at: Date;
}[] = [];
if (newerVideo)
cands.push({
type: "video",
aweme_id: newerVideo.aweme_id,
created_at: newerVideo.created_at as unknown as Date,
});
if (newerPost)
cands.push({
type: "image",
aweme_id: newerPost.aweme_id,
created_at: newerPost.created_at as unknown as Date,
});
if (cands.length === 0) return undefined; if (cands.length === 0) return undefined;
// nearest newer -> minimal created_at // nearest newer -> minimal created_at
cands.sort((a, b) => +a.created_at - +b.created_at); cands.sort((a, b) => +a.created_at - +b.created_at);
const h = cands[0]; const h = cands[0];
return { type: h.type, aweme_id: h.aweme_id, created_at: h.created_at.toISOString() }; return {
type: h.type,
aweme_id: h.aweme_id,
created_at: h.created_at.toISOString(),
};
})(); })();
const pickNext = (() => { const pickNext = (() => {
const cands: { type: "video" | "image"; aweme_id: string; created_at: Date }[] = []; const cands: {
if (olderVideo) cands.push({ type: "video", aweme_id: olderVideo.aweme_id, created_at: olderVideo.created_at as unknown as Date }); type: "video" | "image";
if (olderPost) cands.push({ type: "image", aweme_id: olderPost.aweme_id, created_at: olderPost.created_at as unknown as Date }); aweme_id: string;
created_at: Date;
}[] = [];
if (olderVideo)
cands.push({
type: "video",
aweme_id: olderVideo.aweme_id,
created_at: olderVideo.created_at as unknown as Date,
});
if (olderPost)
cands.push({
type: "image",
aweme_id: olderPost.aweme_id,
created_at: olderPost.created_at as unknown as Date,
});
if (cands.length === 0) return undefined; if (cands.length === 0) return undefined;
// nearest older -> maximal created_at among older // nearest older -> maximal created_at among older
cands.sort((a, b) => +b.created_at - +a.created_at); cands.sort((a, b) => +b.created_at - +a.created_at);
const h = cands[0]; const h = cands[0];
return { type: h.type, aweme_id: h.aweme_id, created_at: h.created_at.toISOString() }; return {
type: h.type,
aweme_id: h.aweme_id,
created_at: h.created_at.toISOString(),
};
})(); })();
return NextResponse.json({ prev: pickPrev ?? null, next: pickNext ?? null }); return NextResponse.json({ prev: pickPrev ?? null, next: pickNext ?? null });
+163
View File
@@ -0,0 +1,163 @@
import { getFileUrl } from "@/lib/minio";
import { prisma } from "@/lib/prisma";
import { Prisma } from "@prisma/client";
import { NextRequest, NextResponse } from "next/server";
export async function GET(
request: NextRequest,
{ params }: { params: Promise<{ awemeId: string }> },
) {
const awemeId = (await params).awemeId;
const searchParams = request.nextUrl.searchParams;
const skip = parseInt(searchParams.get("skip") || "0", 10);
const take = parseInt(searchParams.get("take") || "20", 10);
// ranked 模式参数(均为可选,提供合理默认值)
const seed =
searchParams.get("seed") || new Date().toISOString().slice(0, 10); // 默认按日期稳定
const snapshotIso = searchParams.get("snapshot");
const snapshot = snapshotIso ? new Date(snapshotIso) : new Date(); // 用于时间衰减的基准时间,确保单次会话稳定
const halfLifeHours = parseFloat(searchParams.get("halfLifeHours") || "24");
const wPop = parseFloat(searchParams.get("wPop") || "1"); // 热度权重
const wTime = parseFloat(searchParams.get("wTime") || "2"); // 时间衰减权重
const wJit = parseFloat(searchParams.get("wJit") || "10"); // 随机扰动权重(稳定随机)
try {
// 查找是视频还是图文
const [video, post] = await Promise.all([
prisma.video.findUnique({
where: { aweme_id: awemeId },
select: { aweme_id: true },
}),
prisma.imagePost.findUnique({
where: { aweme_id: awemeId },
select: { aweme_id: true },
}),
]);
if (!video && !post) {
return NextResponse.json({ error: "作品不存在" }, { status: 404 });
}
// 构建查询条件
const where = video ? { videoId: awemeId } : { imagePostId: awemeId };
// 按「热度 + 时间衰减 + 稳定随机扰动」打分排序;否则使用稳定的时间排序
const total = await prisma.comment.count({ where });
// 计算稳定随机扰动:基于 (cid, seed) 的 md5 -> 取前4字节 -> 映射到 [0,1)
const jitterExpr = Prisma.sql`(
(
get_byte(decode(md5(c."cid" || ':' || ${seed}), 'hex'), 0)::double precision * 16777216.0 +
get_byte(decode(md5(c."cid" || ':' || ${seed}), 'hex'), 1)::double precision * 65536.0 +
get_byte(decode(md5(c."cid" || ':' || ${seed}), 'hex'), 2)::double precision * 256.0 +
get_byte(decode(md5(c."cid" || ':' || ${seed}), 'hex'), 3)::double precision
) / 4294967295.0
)`;
const snapshotTs = snapshot.toISOString();
// popularity: ln(1 + digg_count)
// time decay: exp(- age_hours / halfLifeHours),使用固定 snapshot 基准保证会话内稳定
const scoreExpr = Prisma.sql`(
${wPop} * ln(1 + c."digg_count"::numeric) +
${wTime} * exp(- (extract(epoch from (${snapshotTs}::timestamptz - c."created_at")) / 3600.0) / ${halfLifeHours}) +
${wJit} * ${jitterExpr}
)`;
const whereField = (await video)
? Prisma.sql`c."videoId"`
: Prisma.sql`c."imagePostId"`;
const rows: Array<{
cid: string;
text: string;
created_at: Date;
digg_count: bigint;
nickname: string;
avatar_url: string | null;
}> = await prisma.$queryRaw(
Prisma.sql`
SELECT
c."cid",
c."text",
c."created_at",
c."digg_count",
u."nickname",
u."avatar_url"
FROM "Comment" c
JOIN "CommentUser" u ON u."id" = c."userId"
WHERE ${whereField} = ${awemeId}
ORDER BY ${scoreExpr} DESC, c."created_at" DESC, c."cid" ASC
OFFSET ${skip}
LIMIT ${take}
`,
);
// 批量查询每条评论的配图/贴纸
const cids = rows.map((r) => r.cid);
const images = cids.length
? await prisma.commentImage.findMany({
where: { commentId: { in: cids } },
orderBy: { order: "asc" },
select: {
commentId: true,
url: true,
width: true,
height: true,
order: true,
},
})
: [];
const group = new Map<
string,
{
url: string;
width?: number | null;
height?: number | null;
order: number;
}[]
>();
for (const img of images) {
const arr = group.get(img.commentId) || [];
arr.push({
url: img.url,
width: img.width,
height: img.height,
order: img.order,
});
group.set(img.commentId, arr);
}
const formattedComments = rows.map((c) => ({
cid: c.cid,
text: c.text,
created_at: c.created_at,
digg_count: Number(c.digg_count),
user: {
nickname: c.nickname,
avatar_url: getFileUrl(c.avatar_url || "default_avatar.png"),
},
images: (group.get(c.cid) || [])
.sort((a, b) => a.order - b.order)
.map((i) => ({
url: getFileUrl(i.url),
width: i.width ?? undefined,
height: i.height ?? undefined,
})),
}));
return NextResponse.json({
comments: formattedComments,
total,
hasMore: skip + take < total,
// 回传用于稳定排序的参数,前端可在后续请求中透传
seed,
snapshot: snapshotTs,
weights: { wPop, wTime, wJit, halfLifeHours },
mode: "ranked",
});
} catch (error) {
console.error("获取评论失败:", error);
return NextResponse.json({ error: "获取评论失败" }, { status: 500 });
}
}
+61 -23
View File
@@ -1,6 +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 { isVideoContentTag } from "@/lib/video-tags";
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 }
@@ -8,57 +10,93 @@ import type { FeedItem, FeedResponse } from '@/app/types/feed';
export async function GET(req: NextRequest) { export async function GET(req: NextRequest) {
const { searchParams } = new URL(req.url); const { searchParams } = new URL(req.url);
const 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 && isVideoContentTag(rawTag) ? rawTag : null;
const limit = Math.min(Math.max(Number(limitParam ?? '24'), 1), 60); // 1..60 if (rawTag && !selectedTag) {
return NextResponse.json({ error: "Invalid tag" }, { status: 400 });
}
const limit = Math.min(Math.max(Number(limitParam ?? "24"), 1), 60); // 1..60
const before = beforeParam ? new Date(beforeParam) : null; const before = beforeParam ? new Date(beforeParam) : null;
const videoWhere = {
...(before ? { created_at: { lt: before } } : {}),
...(selectedTag
? {
manual_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,
},
}), }),
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 },
},
}), }),
]); ]);
const merged: FeedItem[] = [ const merged: FeedItem[] = [
...videos.map((v) => ({ ...videos.map((v) => ({
type: 'video' as const, type: "video" as const,
aweme_id: v.aweme_id, aweme_id: v.aweme_id,
created_at: v.created_at, created_at: v.created_at,
desc: v.desc, desc: v.desc,
video_url: v.video_url, video_url: getFileUrl(v.video_url),
cover_url: v.cover_url ?? null, cover_url: getFileUrl(v.cover_url ?? "default_cover.png"),
width: v.width ?? null, width: v.width ?? null,
height: v.height ?? null, height: v.height ?? null,
author: { nickname: v.author.nickname, avatar_url: v.author.avatar_url ?? null }, author: {
nickname: v.author.nickname,
avatar_url: getFileUrl(v.author.avatar_url ?? ""),
sec_uid: v.author.sec_uid,
},
likes: Number(v.digg_count), likes: Number(v.digg_count),
content_tags: v.manual_content_tags,
})), })),
...posts.map((p) => ({ ...posts.map((p) => {
type: 'image' as const, const coverPath = p.images?.[0]?.url;
return {
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: 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: { nickname: p.author.nickname, avatar_url: p.author.avatar_url ?? null }, author: {
nickname: p.author.nickname,
avatar_url: getFileUrl(p.author.avatar_url ?? ""),
sec_uid: p.author.sec_uid,
},
likes: Number(p.digg_count), likes: Number(p.digg_count),
})), };
}),
] ]
.sort((a, b) => +new Date(b.created_at as any) - +new Date(a.created_at as any)) .sort((a, b) => +new Date(b.created_at) - +new Date(a.created_at))
.slice(0, limit); .slice(0, limit);
const nextCursor = merged.length > 0 ? new Date(merged[merged.length - 1].created_at as any).toISOString() : null; const nextCursor =
merged.length > 0
? new Date(merged[merged.length - 1].created_at).toISOString()
: null;
const payload: FeedResponse = { items: merged, nextCursor }; const payload: FeedResponse = { items: merged, nextCursor };
return NextResponse.json(payload); return NextResponse.json(payload);
} }
+17
View File
@@ -0,0 +1,17 @@
// index.js
import { chromium } from "playwright-extra";
import stealth from "puppeteer-extra-plugin-stealth";
chromium.use(stealth());
(async () => {
const browser = await chromium.launch({ headless: false }); // 需要可视化就 false
const context = await browser.newContext();
const page = await context.newPage();
await page.goto("https://bot.sannysoft.com/"); // 常用自测页
console.log("Title:", await page.title());
await page.screenshot({ path: "stealth.png", fullPage: true });
setTimeout(async () => {
await browser.close();
}, 1000_000);
})();
+104 -43
View File
@@ -1,79 +1,140 @@
import { chromium, type BrowserContext } from 'playwright' export const runtime = "nodejs";
// A simple singleton manager for a persistent Chromium context with ref-counting. import { type Browser, type BrowserContext } from "playwright";
// Prevents concurrent tasks from closing the shared window prematurely. import { chromium } from "playwright-extra"; // A simple singleton manager for a persistent Chromium context with ref-counting. // Prevents concurrent tasks from closing the shared window prematurely.
import stealth from "puppeteer-extra-plugin-stealth";
chromium.use(stealth());
let contextPromise: Promise<BrowserContext> | null = null let contextPromise: Promise<BrowserContext> | null = null;
let context: BrowserContext | null = null let context: BrowserContext | null = null;
let refCount = 0 let refCount = 0;
let idleCloseTimer: NodeJS.Timeout | null = null let idleCloseTimer: NodeJS.Timeout | null = null;
const USER_DATA_DIR = 'chrome-profile/douyin' const USER_DATA_DIR = process.env.USER_DATA_DIR ?? "chrome-profile/douyin";
async function launchContext(): Promise<BrowserContext> { async function launchContext(): Promise<BrowserContext> {
const ctx = await chromium.launchPersistentContext( const ctx = await chromium.launchPersistentContext(USER_DATA_DIR, {
USER_DATA_DIR, headless: process.env.CHROMIUM_HEADLESS === "true",
{
headless: Boolean(process.env.CHROMIUM_HEADLESS ?? 'false'),
viewport: { viewport: {
width: Number(process.env.CHROMIUM_VIEWPORT_WIDTH ?? 1280), width: Number(process.env.CHROMIUM_VIEWPORT_WIDTH ?? 1280),
height: Number(process.env.CHROMIUM_VIEWPORT_HEIGHT ?? 1080) height: Number(process.env.CHROMIUM_VIEWPORT_HEIGHT ?? 1080),
} },
} });
)
// When the context is closed externally, reset manager state // When the context is closed externally, reset manager state
ctx.on('close', () => { ctx.on("close", () => {
context = null context = null;
contextPromise = null contextPromise = null;
refCount = 0 refCount = 0;
if (idleCloseTimer) { if (idleCloseTimer) {
clearTimeout(idleCloseTimer) clearTimeout(idleCloseTimer);
idleCloseTimer = null idleCloseTimer = null;
} }
}) });
return ctx return ctx;
} }
export async function acquireBrowserContext(): Promise<BrowserContext> { export async function acquireBrowserContext(): Promise<BrowserContext> {
// Cancel any pending idle close if a new consumer arrives // Cancel any pending idle close if a new consumer arrives
if (idleCloseTimer) { if (idleCloseTimer) {
clearTimeout(idleCloseTimer) clearTimeout(idleCloseTimer);
idleCloseTimer = null idleCloseTimer = null;
} }
if (context) { if (context) {
refCount += 1 refCount += 1;
return context return context;
} }
if (!contextPromise) { if (!contextPromise) {
contextPromise = launchContext() contextPromise = launchContext();
} }
context = await contextPromise context = await contextPromise;
refCount += 1 refCount += 1;
return context return context;
} }
export async function releaseBrowserContext(options?: { idleMillis?: number }): Promise<void> { export async function releaseBrowserContext(options?: {
const idleMillis = options?.idleMillis ?? 15_000 idleMillis?: number;
refCount = Math.max(0, refCount - 1) }): Promise<void> {
const idleMillis = options?.idleMillis ?? 15_000;
refCount = Math.max(0, refCount - 1);
if (refCount > 0 || !context) return if (refCount > 0 || !context) return;
// Delay the close to allow bursty workloads to reuse the context // Delay the close to allow bursty workloads to reuse the context
if (idleCloseTimer) { if (idleCloseTimer) {
clearTimeout(idleCloseTimer) clearTimeout(idleCloseTimer);
idleCloseTimer = null idleCloseTimer = null;
} }
idleCloseTimer = setTimeout(async () => { idleCloseTimer = setTimeout(async () => {
try { try {
if (context && refCount === 0) { if (context && refCount === 0) {
await context.close() await context.close();
} }
} finally { } finally {
context = null context = null;
contextPromise = null contextPromise = null;
idleCloseTimer = null idleCloseTimer = null;
}
}, idleMillis);
}
// --- Isolated context support for per-scrape independence ---
const isolatedMap = new WeakMap<BrowserContext, Browser>();
async function getSharedStorageState(): Promise<any | undefined> {
try {
const shared = await acquireBrowserContext();
const state = await shared.storageState();
// Do not force-close immediately; keep ref-counting behavior
await releaseBrowserContext();
return state;
} catch {
// If shared context not available, proceed without storageState
return undefined;
}
}
/**
* Create a brand-new, independent Browser + Context for a single scrape.
* Attempts to import cookies/localStorage from the shared persistent profile
* so you remain logged-in, but isolates network events, cache and listeners.
*/
export async function acquireIsolatedContext(): Promise<BrowserContext> {
const storageState = await getSharedStorageState();
const browser = await chromium.launch({
headless: process.env.CHROMIUM_HEADLESS === "true",
});
const ctx = await browser.newContext({
storageState,
viewport: {
width: Number(process.env.CHROMIUM_VIEWPORT_WIDTH ?? 1280),
height: Number(process.env.CHROMIUM_VIEWPORT_HEIGHT ?? 1080),
},
});
isolatedMap.set(ctx, browser);
ctx.on("close", () => {
const b = isolatedMap.get(ctx);
if (b) {
b.close().catch(() => {});
isolatedMap.delete(ctx);
}
});
return ctx;
}
export async function releaseIsolatedContext(
ctx: BrowserContext | null | undefined,
): Promise<void> {
if (!ctx) return;
try {
await ctx.close();
} finally {
const b = isolatedMap.get(ctx);
if (b) {
await b.close().catch(() => {});
isolatedMap.delete(ctx);
}
} }
}, idleMillis)
} }
+362 -126
View File
@@ -1,54 +1,151 @@
export const runtime = "nodejs";
// src/scrapeDouyin.ts // src/scrapeDouyin.ts
import { BrowserContext, Page, chromium, type Response } from 'playwright'; import { BrowserContext, Page, type Response } from "playwright";
import { prisma } from '@/lib/prisma'; import { chromium } from "playwright-extra";
import { uploadFile, generateUniqueFileName } from '@/lib/minio'; import { prisma } from "@/lib/prisma";
import { createCamelCompatibleProxy } from '@/app/api/fetcher/utils'; import { uploadFile, generateUniqueFileName } from "@/lib/minio";
import { waitForFirstResponse, waitForResponseWithTimeout, safeJson, downloadBinary } from '@/app/api/fetcher/network'; import { createCamelCompatibleProxy } from "@/app/api/fetcher/utils";
import { pickBestPlayAddr, extractFirstFrame } from '@/app/api/fetcher/media'; import {
import { handleImagePost } from '@/app/api/fetcher/uploader'; waitForFirstResponse,
import { saveToDB, saveImagePostToDB } from '@/app/api/fetcher/persist'; waitForResponseWithTimeout,
import chalk from 'chalk'; safeJson,
import { acquireBrowserContext, releaseBrowserContext } from '@/app/api/fetcher/browser'; downloadBinary,
collectResponsesWithinTime,
} from "@/app/api/fetcher/network";
import { pickBestPlayAddr } from "@/app/api/fetcher/media";
import { handleImagePost } from "@/app/api/fetcher/uploader";
import { saveToDB, saveImagePostToDB } from "@/app/api/fetcher/persist";
import chalk from "chalk";
import {
acquireIsolatedContext,
releaseIsolatedContext,
} from "@/app/api/fetcher/browser";
import { extractFirstFrame } from "@/app/api/media";
import { enqueueTranscription } from "@/lib/stt/tasks";
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/";
const POST_PATH = '/aweme/v1/web/aweme/post/' const POST_PATH = "/aweme/v1/web/aweme/post/";
async function readPostMem(context: BrowserContext, page: Page) { /**
const md = await page.evaluate(() => { * 滚动页面并收集评论
// @ts-ignore * @param context 浏览器上下文
let data = window.__pace_captured__.find(i => i[1] && i[1].includes(`"awemeId":`))[1] * @param page 页面对象
return JSON.parse(data.slice(data.indexOf("{")).replaceAll("]\n", '')) * @param durationMs 持续时间(毫秒)
// return {aweme: { detail: {} } }; * @returns 收集到的所有评论响应
}).catch(() => null); */
async function scrollAndCollectComments(
context: BrowserContext,
page: Page,
durationMs: number = 10_000,
): Promise<Response[]> {
console.log(
chalk.blue(`📜 开始滚动页面收集评论(持续 ${durationMs / 1000} 秒)...`),
);
let aweme_mem = md?.aweme?.detail as DouyinImageAweme; // 启动评论响应收集器
if (!aweme_mem) throw new Error('页面内存数据中未找到作品详情'); const commentResponsesPromise = collectResponsesWithinTime(
context,
(r: Response) =>
r.url().includes(COMMENT_PATH) &&
r.status() === 200 &&
r.request().frame()?.page() === page,
durationMs,
);
// @ts-ignore // 在指定时间内持续滚动页面
aweme_mem.author = aweme_mem.authorInfo const startTime = Date.now();
const comments = md.comment ? createCamelCompatibleProxy<DouyinCommentResponse>(md.comment) : null; const scrollInterval = 500;
const aweme = createCamelCompatibleProxy(aweme_mem); let scrollCount = 0;
const selector = "div[data-e2e='comment-list']";
return { aweme, comments } // 1) 等元素出现并可见
await page.waitForSelector(selector, { state: "visible", timeout: 5000 });
// 2) 确保滚动到可见区域
const list = page.locator(selector);
await list.scrollIntoViewIfNeeded();
// 3) 执行 hover(推荐用 locator 的 hover)
list.hover({ timeout: 5000 }).catch(() => {});
while (Date.now() - startTime < durationMs - 500) {
// 留 500ms 缓冲
try {
list.hover({ timeout: 2000 }).catch(() => {});
// 使用 Playwright 的 mouse.wheel 方法滚动
// 每次滚动一大段距离
// await list.hover();
const scrollAmount = 1500;
await page.mouse.wheel(0, scrollAmount);
scrollCount++;
console.log(chalk.gray(` ↓ 第 ${scrollCount} 次滚动`));
// 等待一段时间,让评论加载
await page.waitForTimeout(scrollInterval);
} catch (e) {
console.warn(
chalk.yellow(` ⚠ 滚动时出现警告: ${(e as Error)?.message}`),
);
}
} }
// 等待收集器完成
const commentResponses = await commentResponsesPromise;
console.log(
chalk.green(
`✓ 评论收集完成,共收集到 ${commentResponses.length} 个评论响应`,
),
);
return commentResponses;
}
async function readPostMem(context: BrowserContext, page: Page) {
const md = await page
.evaluate(() => {
const captured = (window as any).__pace_captured__ as Array<unknown[]>;
let data = captured.find(
(item) => typeof item[1] === "string" && item[1].includes(`"awemeId":`),
)?.[1] as string | undefined;
if (!data) return null;
return JSON.parse(data.slice(data.indexOf("{")).replaceAll("]\n", ""));
// return {aweme: { detail: {} } };
})
.catch(() => null);
// await new Promise((res) => setTimeout(res, 1000000));
let aweme_mem = md?.aweme?.detail as DouyinImageAweme;
if (!aweme_mem) throw new Error("页面内存数据中未找到作品详情");
// @ts-ignore
aweme_mem.author = aweme_mem.authorInfo;
// @ts-ignore
aweme_mem.statistics = aweme_mem.stats;
const comments = md.comment
? createCamelCompatibleProxy<DouyinCommentResponse>(md.comment)
: null;
const aweme = createCamelCompatibleProxy(aweme_mem);
return { aweme, comments };
}
export class ScrapeError extends Error { export class ScrapeError extends Error {
constructor( constructor(
message: string, message: string,
public statusCode: number = 500, public statusCode: number = 500,
public code?: string public code?: string,
) { ) {
super(message); super(message);
this.name = 'ScrapeError'; this.name = "ScrapeError";
} }
} }
export async function scrapeDouyin(url: string) { export async function scrapeDouyin(url: string) {
console.log(chalk.blue('🚀 启动共享 Chromium 浏览器...')); console.log(chalk.blue("🚀 启动共享 Chromium 浏览器..."));
let context: BrowserContext | null = await acquireIsolatedContext();
const context = await acquireBrowserContext();
const page = await context.newPage(); const page = await context.newPage();
console.log(chalk.cyan(`📄 正在访问: ${chalk.underline(url)}`)); console.log(chalk.cyan(`📄 正在访问: ${chalk.underline(url)}`));
@@ -60,9 +157,11 @@ export async function scrapeDouyin(url: string) {
const captured = (window as any).__pace_captured__; const captured = (window as any).__pace_captured__;
const proxyArr = new Proxy([] as any[], { const proxyArr = new Proxy([] as any[], {
get(target, prop, receiver) { get(target, prop, receiver) {
if (prop === 'push') { if (prop === "push") {
return (...items: any[]) => { return (...items: any[]) => {
try { captured.push(...items); } catch { } try {
captured.push(...items);
} catch {}
return Array.prototype.push.apply(target, items); return Array.prototype.push.apply(target, items);
}; };
} }
@@ -70,9 +169,10 @@ export async function scrapeDouyin(url: string) {
}, },
set(target, prop, value, receiver) { set(target, prop, value, receiver) {
// 兼容站点可能直接赋初始数组: self.__pace_f = [a,b] // 兼容站点可能直接赋初始数组: self.__pace_f = [a,b]
if (prop === 'length') return Reflect.set(target, prop, value, receiver); if (prop === "length")
return Reflect.set(target, prop, value, receiver); return Reflect.set(target, prop, value, receiver);
} return Reflect.set(target, prop, value, receiver);
},
}); });
(self as any).__pace_f = proxyArr; (self as any).__pace_f = proxyArr;
@@ -81,135 +181,266 @@ export async function scrapeDouyin(url: string) {
try { try {
// 先注册“先到先得”的监听,再导航,避免漏包 // 先注册“先到先得”的监听,再导航,避免漏包
const firstTypePromise = waitForFirstResponse(context, [ const firstTypePromise = waitForFirstResponse(
{ key: 'detail', test: (r: Response) => r.url().includes(DETAIL_PATH) && r.status() === 200 }, context,
{ key: 'post', test: (r: Response) => r.url().includes(POST_PATH) && r.status() === 200 }, [
], 40_000); {
key: "detail",
test: (r: Response) =>
r.url().includes(DETAIL_PATH) &&
r.status() === 200 &&
r.request().frame()?.page() === page,
},
{
key: "post",
test: (r: Response) =>
r.url().includes(POST_PATH) &&
r.status() === 200 &&
r.request().frame()?.page() === page,
},
],
10_000,
);
// 评论只做短时“有就用、没有不等”的监听 await page.goto(url, { waitUntil: "domcontentloaded", timeout: 20_000 });
const commentPromise = waitForResponseWithTimeout(
context, (r: Response) => r.url().includes(COMMENT_PATH) && r.status() === 200, 40_000
).catch(() => null);
await page.goto(url, { waitUntil: 'domcontentloaded', timeout: 60_000 });
// 查找页面中是否存在 "视频不存在" 的提示 // 查找页面中是否存在 "视频不存在" 的提示
const isNotFound = await page.locator('text=视频不存在').count().then(count => count > 0).catch(() => false); const isNotFound = await page
.locator("text=视频不存在")
.count()
.then((count) => count > 0)
.catch(() => false);
if (isNotFound) { if (isNotFound) {
console.error(chalk.red('✗ 视频不存在或已被删除')); console.error(chalk.red("✗ 视频不存在或已被删除"));
throw new ScrapeError('视频不存在或已被删除', 404, 'VIDEO_NOT_FOUND'); throw new ScrapeError("视频不存在或已被删除", 404, "VIDEO_NOT_FOUND");
} }
try { // 等待作品类型判定
// 优先尝试从内存读取图文数据
let { aweme, comments } = await readPostMem(context, page);
console.log(chalk.green('✓ 从内存读取图文数据成功'));
const uploads = await handleImagePost(context, aweme);
if (!comments) {
console.warn(chalk.yellow('⚠ 从内存读取评论数据失败,尝试从网络请求获取评论数据'));
const commentRes = await commentPromise;
comments = commentRes && await safeJson<DouyinCommentResponse>(commentRes);
if (!comments) {
console.warn(chalk.yellow('⚠ 无法从内存读取评论数据,且网络请求也未返回评论数据'));
comments = { comments: [], total: 0, status_code: 0 };
} else {
console.log(chalk.green('✓ 从网络请求获取评论数据成功'));
}
} else {
console.log(chalk.green('✓ 从内存读取评论数据成功'));
}
const saved = await saveImagePostToDB(context, aweme, comments, uploads); // 传递完整 JSON
console.log(chalk.green.bold('✓ 图文作品保存成功'));
return { type: "image", ...saved };
} catch {
}
const commentRes = await commentPromise;
const firstType = await firstTypePromise; const firstType = await firstTypePromise;
if (!firstType) { // 尝试从内存读取图文数据(如果是图文作品)
console.error(chalk.red('✗ 既无法从内存读取数据,也无法从网络获得数据')); let memoryData: {
throw new ScrapeError('无法获取作品数据,可能是网络问题或作品已下架', 404, 'NO_DATA'); aweme: any;
comments: DouyinCommentResponse | null;
} | null = null;
try {
memoryData = await readPostMem(context, page);
console.log(chalk.green("✓ 从内存读取图文数据成功"));
} catch {
// 内存读取失败,稍后通过网络获取
} }
console.log(chalk.cyan(`📡 检测到作品类型: ${chalk.bold(firstType.key === 'post' ? '图文' : '视频')}`)); if (!firstType && !memoryData) {
console.error(chalk.red("✗ 既无法从内存读取数据,也无法从网络获得数据"));
throw new ScrapeError(
"无法获取作品数据,可能是网络问题或作品已下架",
404,
"NO_DATA",
);
}
let comments = commentRes && await safeJson<DouyinCommentResponse>(commentRes); console.log(
if (!comments) { chalk.cyan(
console.warn(chalk.yellow('⚠ 无法从内存读取评论数据,且网络请求也未返回评论数据')); `📡 检测到作品类型: ${chalk.bold(firstType?.key === "post" || memoryData ? "图文" : "视频")}`,
comments = { comments: [], total: 0, status_code: 0 }; ),
);
let allComments: DouyinComment[] = [];
try {
// 开始滚动并收集评论
const commentResponses = await scrollAndCollectComments(context, page);
// 解析所有收集到的评论响应
for (const commentRes of commentResponses) {
try {
const commentData = await safeJson<DouyinCommentResponse>(commentRes);
if (commentData?.comments?.length) {
allComments.push(...commentData.comments);
}
} catch (e) {
console.warn(
chalk.yellow(`⚠ 解析评论响应失败: ${(e as Error)?.message}`),
);
}
}
} catch (error) {
console.warn(
chalk.yellow(`⚠ 评论收集失败: ${(error as Error)?.message}`),
);
}
// 去重评论(根据 cid)
const uniqueComments = Array.from(
new Map(allComments.map((c) => [c.cid, c])).values(),
);
console.log(
chalk.green(
`✓ 共收集到 ${uniqueComments.length} 条独立评论(去重前: ${allComments.length})`,
),
);
// 如果从内存读取到了评论,合并进来作为兜底
let comments: DouyinCommentResponse;
if (memoryData?.comments?.comments?.length) {
console.log(
chalk.blue(
`📝 合并内存中的 ${memoryData.comments.comments.length} 条评论`,
),
);
const memComments = memoryData.comments.comments;
const mergedMap = new Map(uniqueComments.map((c) => [c.cid, c]));
for (const c of memComments) {
if (!mergedMap.has(c.cid)) {
mergedMap.set(c.cid, c);
}
}
comments = {
comments: Array.from(mergedMap.values()),
total: mergedMap.size,
status_code: 0,
};
console.log(chalk.green(`✓ 合并后共 ${comments.comments.length} 条评论`));
} else {
comments = {
comments: uniqueComments,
total: uniqueComments.length,
status_code: 0,
};
} }
// 分支:视频 or 图文(两者只会有一个命中,先到先得) // 分支:视频 or 图文(两者只会有一个命中,先到先得)
if (firstType.key === 'post') { // 优先处理内存数据(图文)
// 图文作品 if (memoryData) {
const postJson = await safeJson<DouyinPostListResponse>(firstType.response); const aweme = memoryData.aweme;
if (!postJson?.aweme_list?.length) throw new ScrapeError('图文作品响应为空', 404, 'EMPTY_POST_RESPONSE'); const uploads = await handleImagePost(context, aweme);
const saved = await saveImagePostToDB(context, aweme, comments, uploads); // 传递完整 JSON
console.log(chalk.green.bold("✓ 图文作品保存成功"));
return { type: "image", ...saved };
} else if (firstType?.key === "post") {
// 图文作品(网络)
const postJson = await safeJson<DouyinPostListResponse>(
firstType.response,
);
if (!postJson?.aweme_list?.length)
throw new ScrapeError("图文作品响应为空", 404, "EMPTY_POST_RESPONSE");
const currentURL = page.url(); const currentURL = page.url();
const target_aweme_id = currentURL.split('/').at(-1); const target_aweme_id = currentURL.split("/").at(-1);
const awemeList = postJson.aweme_list as unknown as DouyinImageAweme[]; const awemeList = postJson.aweme_list as unknown as DouyinImageAweme[];
let aweme = awemeList.find((pt: DouyinImageAweme) => pt.aweme_id === target_aweme_id); let aweme = awemeList.find(
(pt: DouyinImageAweme) => pt.aweme_id === target_aweme_id,
);
if (!aweme) { if (!aweme) {
throw new ScrapeError('无法找到目标作品,可能已被删除', 404, 'POST_NOT_FOUND'); throw new ScrapeError(
"无法找到目标作品,可能已被删除",
404,
"POST_NOT_FOUND",
);
} }
const uploads = await handleImagePost(context, aweme); const uploads = await handleImagePost(context, aweme);
const saved = await saveImagePostToDB(context, aweme, comments, uploads, postJson); // 传递完整 JSON const saved = await saveImagePostToDB(
console.log(chalk.green.bold('✓ 图文作品保存成功')); context,
aweme,
comments,
uploads,
postJson,
); // 传递完整 JSON
console.log(chalk.green.bold("✓ 图文作品保存成功"));
return { type: "image", ...saved }; return { type: "image", ...saved };
} else if (firstType.key === 'detail') { } else if (firstType?.key === "detail") {
// 视频作品 // 视频作品
const detail = (await safeJson<DouyinVideoDetailResponse>(firstType.response))!; const detail = (await safeJson<DouyinVideoDetailResponse>(
firstType.response,
))!;
// 找到比特率最高的 url // 找到比特率最高的 url
const bestPlayAddr = pickBestPlayAddr( const bestPlayAddr = pickBestPlayAddr(
detail?.aweme_detail?.video.bit_rate detail?.aweme_detail?.video.bit_rate,
); );
const bestVUrl = bestPlayAddr?.url_list?.[0]; const bestVUrl = bestPlayAddr?.url_list?.[0];
const fps = bestPlayAddr?.FPS ?? null; // 提取 FPS const fps = bestPlayAddr?.FPS ?? null; // 提取 FPS
console.log(chalk.cyan(`📹 最佳视频 URL: ${chalk.dim(bestVUrl)}`)); console.log(chalk.cyan(`📹 最佳视频 URL: ${chalk.dim(bestVUrl)}`));
console.log(chalk.cyan(`🎞️ 视频帧率: ${chalk.bold(fps || 'N/A')} FPS`)); console.log(chalk.cyan(`🎞️ 视频帧率: ${chalk.bold(fps || "N/A")} FPS`));
if (bestPlayAddr?.width && bestPlayAddr?.height) { if (bestPlayAddr?.width && bestPlayAddr?.height) {
console.log(chalk.cyan(`📐 视频分辨率: ${chalk.bold(`${bestPlayAddr.width}x${bestPlayAddr.height}`)}`)); console.log(
chalk.cyan(
`📐 视频分辨率: ${chalk.bold(`${bestPlayAddr.width}x${bestPlayAddr.height}`)}`,
),
);
} }
// 下载视频并上传至 MinIO,获取外链 // 下载视频并上传至 MinIO,获取外链
let uploadedUrl: string | undefined; let uploadedUrl: string | undefined;
let coverUrl: string | undefined; let coverUrl: string | undefined;
if (bestVUrl && detail?.aweme_detail) { if (bestVUrl && detail?.aweme_detail) {
console.log(chalk.blue('⬇️ 正在下载视频...')); console.log(chalk.blue("⬇️ 正在下载视频..."));
const { buffer, contentType, ext } = await downloadBinary(context, bestVUrl); const { buffer, contentType, ext } = await downloadBinary(
context,
bestVUrl,
);
const awemeId = detail.aweme_detail.aweme_id; const awemeId = detail.aweme_detail.aweme_id;
const fileName = generateUniqueFileName(`${awemeId}.${ext}`, 'douyin/videos'); const fileName = generateUniqueFileName(
`${awemeId}.${ext}`,
"douyin/videos",
);
console.log(chalk.blue('⬆️ 正在上传视频到 MinIO...')); console.log(chalk.blue("⬆️ 正在上传视频到 MinIO..."));
uploadedUrl = await uploadFile(buffer, fileName, { 'Content-Type': contentType }); uploadedUrl = await uploadFile(buffer, fileName, {
console.log(chalk.green(`✓ 视频上传成功: ${chalk.underline(uploadedUrl)}`)); "Content-Type": contentType,
});
console.log(
chalk.green(`✓ 视频上传成功: ${chalk.underline(uploadedUrl)}`),
);
// 提取首帧作为封面并上传 // 提取首帧作为封面并上传
try { try {
console.log(chalk.blue('🖼️ 正在提取视频封面...')); console.log(chalk.blue("🖼️ 正在提取视频封面..."));
const cover = await extractFirstFrame(buffer); const cover = await extractFirstFrame(buffer);
if (cover) { if (cover) {
const coverName = generateUniqueFileName(`${awemeId}.jpg`, 'douyin/covers'); const coverName = generateUniqueFileName(
coverUrl = await uploadFile(cover.buffer, coverName, { 'Content-Type': cover.contentType }); `${awemeId}.jpg`,
console.log(chalk.green(`✓ 封面上传成功: ${chalk.underline(coverUrl)}`)); "douyin/covers",
);
coverUrl = await uploadFile(cover.buffer, coverName, {
"Content-Type": cover.contentType,
});
console.log(
chalk.green(`✓ 封面上传成功: ${chalk.underline(coverUrl)}`),
);
} }
} catch (e) { } catch (e) {
console.warn(chalk.yellow(`⚠ 提取封面失败,跳过: ${(e as Error)?.message || e}`)); console.warn(
chalk.yellow(`⚠ 提取封面失败,跳过: ${(e as Error)?.message || e}`),
);
} }
} }
const saved = await saveToDB(context, detail, comments, uploadedUrl, bestPlayAddr?.width, bestPlayAddr?.height, coverUrl, fps ?? undefined); const saved = await saveToDB(
console.log(chalk.green.bold('✓ 视频作品保存成功')); context,
detail,
comments,
uploadedUrl,
bestPlayAddr?.width,
bestPlayAddr?.height,
coverUrl,
fps ?? undefined,
);
console.log(chalk.green.bold("✓ 视频作品保存成功"));
try {
await enqueueTranscription(detail.aweme_detail.aweme_id);
} catch {
console.error("视频已保存,但转写任务入队失败,可在后台批量补转");
}
return { type: "video", ...saved }; return { type: "video", ...saved };
} else { } else {
throw new ScrapeError('无法判定作品类型,接口响应异常', 500, 'UNKNOWN_TYPE'); throw new ScrapeError(
"无法判定作品类型,接口响应异常",
500,
"UNKNOWN_TYPE",
);
} }
} catch (error) { } catch (error) {
// 如果是我们自定义的错误,直接抛出 // 如果是我们自定义的错误,直接抛出
@@ -222,25 +453,30 @@ export async function scrapeDouyin(url: string) {
console.error(chalk.red(`✗ 爬取失败: ${errMsg}`)); console.error(chalk.red(`✗ 爬取失败: ${errMsg}`));
// 根据错误类型返回不同的状态码 // 根据错误类型返回不同的状态码
if (errMsg.includes('timeout') || errMsg.includes('超时')) { if (errMsg.includes("timeout") || errMsg.includes("超时")) {
throw new ScrapeError('请求超时,请稍后重试', 408, 'TIMEOUT'); throw new ScrapeError("请求超时,请稍后重试", 408, "TIMEOUT");
} }
if (errMsg.includes('页面内存数据中未找到作品详情')) { if (errMsg.includes("页面内存数据中未找到作品详情")) {
throw new ScrapeError('作品数据加载失败', 404, 'DATA_NOT_LOADED'); throw new ScrapeError("作品数据加载失败", 404, "DATA_NOT_LOADED");
} }
if (errMsg.includes('net::')) { if (errMsg.includes("net::")) {
throw new ScrapeError('网络连接失败', 503, 'NETWORK_ERROR'); throw new ScrapeError("网络连接失败", 503, "NETWORK_ERROR");
} }
// 默认服务器错误 // 默认服务器错误
throw new ScrapeError(errMsg || '爬取过程中发生未知错误', 500, 'UNKNOWN_ERROR'); throw new ScrapeError(
errMsg || "爬取过程中发生未知错误",
500,
"UNKNOWN_ERROR",
);
} finally { } finally {
console.log(chalk.gray('🧹 清理资源...')); console.log(chalk.gray("🧹 清理资源..."));
try { await page.close({ runBeforeUnload: true }); } catch {} try {
// 仅释放共享上下文的引用,不直接关闭窗口 await page.close({ runBeforeUnload: true });
await releaseBrowserContext(); } catch {}
// 关闭本次任务的隔离上下文与浏览器
await releaseIsolatedContext(context);
await prisma.$disconnect(); await prisma.$disconnect();
console.log(chalk.gray('✓ 资源清理完成')); console.log(chalk.gray("✓ 资源清理完成"));
} }
} }
+3 -44
View File
@@ -1,10 +1,7 @@
import { promises as fs } from 'fs'; export const runtime = "nodejs";
import os from 'os';
import path from 'path';
import { execFile } from 'child_process';
import { promisify } from 'util';
const execFileAsync = promisify(execFile); import { execFile } from "child_process";
import { promisify } from "util";
export function pickBestPlayAddr(variants: PlayVariant[] | undefined | null) { export function pickBestPlayAddr(variants: PlayVariant[] | undefined | null) {
if (!variants?.length) return null; if (!variants?.length) return null;
@@ -17,41 +14,3 @@ export function pickBestPlayAddr(variants: PlayVariant[] | undefined | null) {
return best?.play_addr ?? null; return best?.play_addr ?? null;
} }
/**
* 使用 ffmpeg 从视频二进制中提取第一帧,返回 JPEG buffer
*/
export async function extractFirstFrame(videoBuffer: Buffer): Promise<{ buffer: Buffer; contentType: string; ext: string } | null> {
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 outPath = path.join(tmpDir, `${base}.jpg`);
try {
await fs.writeFile(inPath, videoBuffer);
const args = [
'-hide_banner',
'-loglevel', 'error',
'-ss', '0',
'-i', inPath,
'-frames:v', '1',
'-q:v', '2',
'-f', 'image2',
'-y',
outPath,
];
await execFileAsync(ffmpegCmd, args, { windowsHide: true });
const img = await fs.readFile(outPath);
return { buffer: img, contentType: 'image/jpeg', ext: 'jpg' };
} catch (e: any) {
if (e && (e.code === 'ENOENT' || /not found|is not recognized/i.test(String(e.message)))) {
console.warn('系统未检测到 ffmpeg,可安装并配置 PATH 或设置 FFMPEG_PATH 后启用封面提取。');
return null;
}
throw e;
} finally {
try { await fs.unlink(inPath); } catch { }
try { await fs.unlink(outPath); } catch { }
}
}
+61 -16
View File
@@ -1,8 +1,10 @@
import type { BrowserContext, Response } from 'playwright'; export const runtime = "nodejs";
import type { BrowserContext, Response } from "playwright";
export async function safeJson<T>(res: Response): Promise<T | null> { export async function safeJson<T>(res: Response): Promise<T | null> {
const ctype = res.headers()['content-type'] || ''; const ctype = res.headers()["content-type"] || "";
if (ctype.includes('application/json')) { if (ctype.includes("application/json")) {
return (await res.json()) as T; return (await res.json()) as T;
} }
const t = await res.text(); const t = await res.text();
@@ -20,12 +22,12 @@ export async function safeJson<T>(res: Response): Promise<T | null> {
*/ */
export async function downloadBinary( export async function downloadBinary(
context: BrowserContext, context: BrowserContext,
url: string url: string,
): Promise<{ buffer: Buffer; contentType: string; ext: string }> { ): Promise<{ buffer: Buffer; contentType: string; ext: string }> {
console.log('下载:', url); console.log("下载:", url);
const headers = { const headers = {
referer: url, referer: "https://www.douyin.com/",
} as Record<string, string>; } as Record<string, string>;
const res = await context.request.get(url, { const res = await context.request.get(url, {
@@ -40,8 +42,9 @@ export async function downloadBinary(
} }
const buffer = await res.body(); const buffer = await res.body();
const contentType = res.headers()['content-type'] || 'application/octet-stream'; const contentType =
const ext = (contentType.split('/')[1] || 'bin').split(';')[0] || 'bin'; res.headers()["content-type"] || "application/octet-stream";
const ext = (contentType.split("/")[1] || "bin").split(";")[0] || "bin";
return { buffer, contentType, ext }; return { buffer, contentType, ext };
} }
@@ -52,7 +55,7 @@ export async function downloadBinary(
export function waitForFirstResponse( export function waitForFirstResponse(
context: BrowserContext, context: BrowserContext,
candidates: { key: string; test: (r: Response) => boolean }[], candidates: { key: string; test: (r: Response) => boolean }[],
timeoutMs = 20_000 timeoutMs = 20_000,
): Promise<{ key: string; response: Response } | null> { ): Promise<{ key: string; response: Response } | null> {
return new Promise((resolve) => { return new Promise((resolve) => {
let resolved = false; let resolved = false;
@@ -75,11 +78,11 @@ export function waitForFirstResponse(
}; };
const cleanup = () => { const cleanup = () => {
context.off('response', handler); context.off("response", handler);
if (timer) clearTimeout(timer); if (timer) clearTimeout(timer);
}; };
context.on('response', handler); context.on("response", handler);
if (timeoutMs > 0) { if (timeoutMs > 0) {
timer = setTimeout(() => { timer = setTimeout(() => {
if (!resolved) { if (!resolved) {
@@ -93,12 +96,54 @@ export function waitForFirstResponse(
} }
/** /**
* 等待符合条件的单个 Response,带短超时;用于评论等“可有可无”的数据。 * 在指定时间内持续收集所有符合条件的 Response
* 用于评论等需要滚动加载的数据
*/
export function collectResponsesWithinTime(
context: BrowserContext,
predicate: (r: Response) => boolean,
durationMs: number,
): Promise<Response[]> {
return new Promise((resolve) => {
const collected: Response[] = [];
const seenUrls = new Set<string>();
let timer: NodeJS.Timeout | undefined;
const handler = (res: Response) => {
try {
if (predicate(res)) {
// 使用 URL 去重,避免重复收集同一个请求
const url = res.url();
if (!seenUrls.has(url)) {
seenUrls.add(url);
collected.push(res);
}
}
} catch {
// ignore predicate errors
}
};
const cleanup = () => {
context.off("response", handler);
if (timer) clearTimeout(timer);
};
context.on("response", handler);
timer = setTimeout(() => {
cleanup();
resolve(collected);
}, durationMs);
});
}
/**
* 等待符合条件的单个 Response,带短超时;用于评论等"可有可无"的数据。
*/ */
export function waitForResponseWithTimeout( export function waitForResponseWithTimeout(
context: BrowserContext, context: BrowserContext,
predicate: (r: Response) => boolean, predicate: (r: Response) => boolean,
timeoutMs = 5_000 timeoutMs = 5_000,
): Promise<Response> { ): Promise<Response> {
return new Promise<Response>((resolve, reject) => { return new Promise<Response>((resolve, reject) => {
let timer: NodeJS.Timeout | undefined; let timer: NodeJS.Timeout | undefined;
@@ -115,15 +160,15 @@ export function waitForResponseWithTimeout(
}; };
const cleanup = () => { const cleanup = () => {
context.off('response', handler); context.off("response", handler);
if (timer) clearTimeout(timer); if (timer) clearTimeout(timer);
}; };
context.on('response', handler); context.on("response", handler);
if (timeoutMs > 0) { if (timeoutMs > 0) {
timer = setTimeout(() => { timer = setTimeout(() => {
cleanup(); cleanup();
reject(new Error('timeout')); reject(new Error("timeout"));
}, timeoutMs); }, timeoutMs);
} }
}); });
+191 -37
View File
@@ -1,7 +1,7 @@
import type { BrowserContext } from 'playwright'; import type { BrowserContext } from "playwright";
import { prisma } from '@/lib/prisma'; import { prisma } from "@/lib/prisma";
import { uploadAvatarFromUrl } from './uploader'; import { uploadAvatarFromUrl, uploadImageFromUrl } from "./uploader";
import { firstUrl } from './utils'; import { firstUrl } from "./utils";
export async function saveToDB( export async function saveToDB(
context: BrowserContext, context: BrowserContext,
@@ -11,14 +11,18 @@ export async function saveToDB(
width?: number, width?: number,
height?: number, height?: number,
coverUrl?: string, coverUrl?: string,
fps?: number fps?: number,
) { ) {
if (!detailResp?.aweme_detail) throw new Error('视频详情为空'); if (!detailResp?.aweme_detail) throw new Error("视频详情为空");
const d = detailResp.aweme_detail; const d = detailResp.aweme_detail;
// 1) Upsert Author // 1) Upsert Author
const authorAvatarSrc = firstUrl(d.author.avatar_thumb?.url_list); const authorAvatarSrc = firstUrl(d.author.avatar_thumb?.url_list);
const authorAvatarUploaded = await uploadAvatarFromUrl(context, authorAvatarSrc, `authors/${d.author.sec_uid}`); const authorAvatarUploaded = await uploadAvatarFromUrl(
context,
authorAvatarSrc,
`authors/${d.author.sec_uid}`,
);
const author = await prisma.author.upsert({ const author = await prisma.author.upsert({
where: { sec_uid: d.author.sec_uid }, where: { sec_uid: d.author.sec_uid },
create: { create: {
@@ -59,8 +63,8 @@ export async function saveToDB(
share_count: BigInt(d.statistics?.share_count || 0), share_count: BigInt(d.statistics?.share_count || 0),
collect_count: BigInt(d.statistics?.collect_count || 0), collect_count: BigInt(d.statistics?.collect_count || 0),
authorId: author.sec_uid, authorId: author.sec_uid,
tags: (d.tags?.map(t => t.tag_name) ?? []), tags: d.tags?.map((t) => t.tag_name) ?? [],
video_url: videoUrl ?? '', video_url: videoUrl ?? "",
width: width ?? null, width: width ?? null,
height: height ?? null, height: height ?? null,
cover_url: coverUrl ?? null, cover_url: coverUrl ?? null,
@@ -90,20 +94,25 @@ export async function saveToDB(
// 3) Upsert Comments + CommentUser // 3) Upsert Comments + CommentUser
const comments = commentResp?.comments ?? []; const comments = commentResp?.comments ?? [];
for (const c of comments) { for (const c of comments) {
const origAvatar: string | null = firstUrl(c.user?.avatar_thumb?.url_list) ?? null; const origAvatar: string | null =
const nameHint = `comment-users/${(c.user?.nickname || 'unknown').replace(/\s+/g, '_')}-${c.cid}`; firstUrl(c.user?.avatar_thumb?.url_list) ?? null;
const uploadedAvatar = await uploadAvatarFromUrl(context, origAvatar ?? undefined, nameHint); const nameHint = `comment-users/${(c.user?.nickname || "unknown").replace(/\s+/g, "_")}-${c.cid}`;
const uploadedAvatar = await uploadAvatarFromUrl(
context,
origAvatar ?? undefined,
nameHint,
);
const finalAvatar = uploadedAvatar ?? origAvatar; // string | null const finalAvatar = uploadedAvatar ?? origAvatar; // string | null
const finalAvatarKey = finalAvatar ?? ''; const finalAvatarKey = finalAvatar ?? "";
const cu = await prisma.commentUser.upsert({ const cu = await prisma.commentUser.upsert({
where: { where: {
nickname_avatar_url: { nickname_avatar_url: {
nickname: c.user?.nickname || '未知用户', nickname: c.user?.nickname || "未知用户",
avatar_url: finalAvatarKey, avatar_url: finalAvatarKey,
}, },
}, },
create: { create: {
nickname: c.user?.nickname || '未知用户', nickname: c.user?.nickname || "未知用户",
avatar_url: finalAvatar ?? null, avatar_url: finalAvatar ?? null,
}, },
update: { update: {
@@ -111,7 +120,7 @@ export async function saveToDB(
}, },
}); });
await prisma.comment.upsert({ const savedComment = await prisma.comment.upsert({
where: { cid: c.cid }, where: { cid: c.cid },
create: { create: {
cid: c.cid, cid: c.cid,
@@ -129,23 +138,96 @@ export async function saveToDB(
userId: cu.id, userId: cu.id,
}, },
}); });
// 处理评论贴纸/配图上传与入库
try {
const sources: {
url?: string | null;
width?: number;
height?: number;
}[] = [];
// 贴纸(当作第一张)
const stickerUrl = firstUrl(c.sticker?.animate_url?.url_list);
if (stickerUrl) {
sources.push({
url: stickerUrl,
width: c.sticker?.animate_url?.width,
height: c.sticker?.animate_url?.height,
});
}
// 配图列表
const imgs = c.image_list || [];
for (const it of imgs) {
const u = firstUrl(it?.origin_url?.url_list);
if (u)
sources.push({
url: u,
width: it?.origin_url?.width as any,
height: it?.origin_url?.height as any,
});
} }
return { aweme_id: video.aweme_id, author_sec_uid: author.sec_uid, comment_count: comments.length }; for (let i = 0; i < sources.length; i++) {
const s = sources[i];
const uploaded = await uploadImageFromUrl(
context,
s.url ?? undefined,
`comments/${c.cid}/${i}`,
);
if (!uploaded) continue;
await prisma.commentImage.upsert({
where: { commentId_order: { commentId: savedComment.cid, order: i } },
create: {
commentId: savedComment.cid,
order: i,
url: uploaded,
width: typeof s.width === "number" ? s.width : null,
height: typeof s.height === "number" ? s.height : null,
},
update: {
url: uploaded,
width: typeof s.width === "number" ? s.width : null,
height: typeof s.height === "number" ? s.height : null,
},
});
}
} catch (e) {
console.warn("[comment-images] 保存失败:", (e as Error)?.message || e);
}
}
return {
aweme_id: video.aweme_id,
author_sec_uid: author.sec_uid,
comment_count: comments.length,
};
} }
export async function saveImagePostToDB( export async function saveImagePostToDB(
context: BrowserContext, context: BrowserContext,
aweme: DouyinImageAweme, aweme: DouyinImageAweme,
commentResp: DouyinCommentResponse, commentResp: DouyinCommentResponse,
uploads: { images: { url: string; width?: number; height?: number, video?: string }[]; musicUrl?: string }, uploads: {
rawJson?: any images: {
url: string;
width?: number;
height?: number;
video?: string;
duration?: number;
}[];
musicUrl?: string;
},
rawJson?: any,
) { ) {
if (!aweme?.author?.sec_uid) throw new Error('作者 sec_uid 缺失'); if (!aweme?.author?.sec_uid) throw new Error("作者 sec_uid 缺失");
// Upsert Author(与视频一致) // Upsert Author(与视频一致)
const authorAvatarSrc = firstUrl(aweme.author.avatar_thumb?.url_list); const authorAvatarSrc = firstUrl(aweme.author.avatar_thumb?.url_list);
const authorAvatarUploaded = await uploadAvatarFromUrl(context, authorAvatarSrc, `authors/${aweme.author.sec_uid}`); const authorAvatarUploaded = await uploadAvatarFromUrl(
context,
authorAvatarSrc,
`authors/${aweme.author.sec_uid}`,
);
const author = await prisma.author.upsert({ const author = await prisma.author.upsert({
where: { sec_uid: aweme.author.sec_uid }, where: { sec_uid: aweme.author.sec_uid },
create: { create: {
@@ -178,13 +260,13 @@ export async function saveImagePostToDB(
aweme_id: aweme.aweme_id, aweme_id: aweme.aweme_id,
desc: aweme.desc, desc: aweme.desc,
created_at: new Date((aweme.create_time || 0) * 1000), created_at: new Date((aweme.create_time || 0) * 1000),
share_url: aweme.share_url || '', share_url: aweme.share_url || "",
digg_count: BigInt(aweme.statistics?.digg_count || 0), digg_count: BigInt(aweme.statistics?.digg_count || 0),
comment_count: BigInt(aweme.statistics?.comment_count || 0), comment_count: BigInt(aweme.statistics?.comment_count || 0),
share_count: BigInt(aweme.statistics?.share_count || 0), share_count: BigInt(aweme.statistics?.share_count || 0),
collect_count: BigInt(aweme.statistics?.collect_count || 0), collect_count: BigInt(aweme.statistics?.collect_count || 0),
authorId: author.sec_uid, authorId: author.sec_uid,
tags: (aweme.video_tag?.map(t => t.tag_name) ?? []), tags: aweme.video_tag?.map((t) => t.tag_name) ?? [],
music_url: uploads.musicUrl ?? null, music_url: uploads.musicUrl ?? null,
raw_json: rawJson ?? null, // 保存完整接口 JSON raw_json: rawJson ?? null, // 保存完整接口 JSON
}, },
@@ -197,7 +279,7 @@ export async function saveImagePostToDB(
share_count: BigInt(aweme.statistics?.share_count || 0), share_count: BigInt(aweme.statistics?.share_count || 0),
collect_count: BigInt(aweme.statistics?.collect_count || 0), collect_count: BigInt(aweme.statistics?.collect_count || 0),
authorId: author.sec_uid, authorId: author.sec_uid,
tags: (aweme.video_tag?.map(t => t.tag_name) ?? []), tags: aweme.video_tag?.map((t) => t.tag_name) ?? [],
music_url: uploads.musicUrl ?? undefined, music_url: uploads.musicUrl ?? undefined,
raw_json: rawJson ?? undefined, // 更新完整接口 JSON raw_json: rawJson ?? undefined, // 更新完整接口 JSON
}, },
@@ -205,22 +287,28 @@ export async function saveImagePostToDB(
// Upsert ImageFiles(按顺序) // Upsert ImageFiles(按顺序)
for (let i = 0; i < uploads.images.length; i++) { for (let i = 0; i < uploads.images.length; i++) {
const { url, width, height, video } = uploads.images[i]; const { url, width, height, video, duration } = uploads.images[i];
const durationMs =
typeof duration === "number" && Number.isFinite(duration)
? Math.max(1, Math.round(duration))
: null;
await prisma.imageFile.upsert({ await prisma.imageFile.upsert({
where: { postId_order: { postId: imagePost.aweme_id, order: i } }, where: { postId_order: { postId: imagePost.aweme_id, order: i } },
create: { create: {
postId: imagePost.aweme_id, postId: imagePost.aweme_id,
order: i, order: i,
url, url,
width: typeof width === 'number' ? width : null, width: typeof width === "number" ? width : null,
height: typeof height === 'number' ? height : null, height: typeof height === "number" ? height : null,
animated: video || null, animated: video || null,
duration: durationMs,
}, },
update: { update: {
url, url,
width: typeof width === 'number' ? width : null, width: typeof width === "number" ? width : null,
height: typeof height === 'number' ? height : null, height: typeof height === "number" ? height : null,
animated: video || null, animated: video || null,
duration: durationMs,
}, },
}); });
} }
@@ -228,20 +316,25 @@ export async function saveImagePostToDB(
// 评论入库:关联到 ImagePost // 评论入库:关联到 ImagePost
const comments = commentResp?.comments ?? []; const comments = commentResp?.comments ?? [];
for (const c of comments) { for (const c of comments) {
const origAvatar: string | null = firstUrl(c.user?.avatar_thumb?.url_list) ?? null; const origAvatar: string | null =
const nameHint = `comment-users/${(c.user?.nickname || 'unknown').replace(/\s+/g, '_')}-${c.cid}`; firstUrl(c.user?.avatar_thumb?.url_list) ?? null;
const uploadedAvatar = await uploadAvatarFromUrl(context, origAvatar ?? undefined, nameHint); const nameHint = `comment-users/${(c.user?.nickname || "unknown").replace(/\s+/g, "_")}-${c.cid}`;
const uploadedAvatar = await uploadAvatarFromUrl(
context,
origAvatar ?? undefined,
nameHint,
);
const finalAvatar = uploadedAvatar ?? origAvatar; // string | null const finalAvatar = uploadedAvatar ?? origAvatar; // string | null
const finalAvatarKey = finalAvatar ?? ''; const finalAvatarKey = finalAvatar ?? "";
const cu = await prisma.commentUser.upsert({ const cu = await prisma.commentUser.upsert({
where: { where: {
nickname_avatar_url: { nickname_avatar_url: {
nickname: c.user?.nickname || '未知用户', nickname: c.user?.nickname || "未知用户",
avatar_url: finalAvatarKey, avatar_url: finalAvatarKey,
}, },
}, },
create: { create: {
nickname: c.user?.nickname || '未知用户', nickname: c.user?.nickname || "未知用户",
avatar_url: finalAvatar ?? null, avatar_url: finalAvatar ?? null,
}, },
update: { update: {
@@ -249,7 +342,7 @@ export async function saveImagePostToDB(
}, },
}); });
await prisma.comment.upsert({ const savedComment = await prisma.comment.upsert({
where: { cid: c.cid }, where: { cid: c.cid },
create: { create: {
cid: c.cid, cid: c.cid,
@@ -267,7 +360,68 @@ export async function saveImagePostToDB(
userId: cu.id, userId: cu.id,
}, },
}); });
// 处理评论贴纸/配图上传与入库
try {
const sources: {
url?: string | null;
width?: number;
height?: number;
}[] = [];
// 贴纸(当作第一张)
const stickerUrl = firstUrl(c.sticker?.animate_url?.url_list);
if (stickerUrl) {
sources.push({
url: stickerUrl,
width: c.sticker?.animate_url?.width,
height: c.sticker?.animate_url?.height,
});
}
// 配图列表
const imgs = c.image_list || [];
for (const it of imgs) {
const u = firstUrl(it?.origin_url?.url_list);
if (u)
sources.push({
url: u,
width: it?.origin_url?.width as any,
height: it?.origin_url?.height as any,
});
} }
return { aweme_id: imagePost.aweme_id, author_sec_uid: author.sec_uid, image_count: uploads.images.length, comment_count: comments.length }; for (let i = 0; i < sources.length; i++) {
const s = sources[i];
const uploaded = await uploadImageFromUrl(
context,
s.url ?? undefined,
`comments/${c.cid}/${i}`,
);
if (!uploaded) continue;
await prisma.commentImage.upsert({
where: { commentId_order: { commentId: savedComment.cid, order: i } },
create: {
commentId: savedComment.cid,
order: i,
url: uploaded,
width: typeof s.width === "number" ? s.width : null,
height: typeof s.height === "number" ? s.height : null,
},
update: {
url: uploaded,
width: typeof s.width === "number" ? s.width : null,
height: typeof s.height === "number" ? s.height : null,
},
});
}
} catch (e) {
console.warn("[comment-images] 保存失败:", (e as Error)?.message || e);
}
}
return {
aweme_id: imagePost.aweme_id,
author_sec_uid: author.sec_uid,
image_count: uploads.images.length,
comment_count: comments.length,
};
} }
+16 -14
View File
@@ -1,15 +1,17 @@
import { NextRequest, NextResponse } from 'next/server' export const runtime = "nodejs";
import { prisma } from '@/lib/prisma'
import { scrapeDouyin, ScrapeError } from '.'; import { NextRequest, NextResponse } from "next/server";
import { prisma } from "@/lib/prisma";
import { scrapeDouyin, ScrapeError } from ".";
async function handleDouyinScrape(req: NextRequest) { async function handleDouyinScrape(req: NextRequest) {
const { searchParams } = new URL(req.url); const { searchParams } = new URL(req.url);
const videoUrl = searchParams.get('url'); const videoUrl = searchParams.get("url");
if (!videoUrl) { if (!videoUrl) {
return NextResponse.json( return NextResponse.json(
{ error: '缺少视频URL', code: 'MISSING_URL' }, { error: "缺少视频URL", code: "MISSING_URL" },
{ status: 400 } { status: 400 },
); );
} }
@@ -18,7 +20,7 @@ async function handleDouyinScrape(req: NextRequest) {
const result = await scrapeDouyin(videoUrl); const result = await scrapeDouyin(videoUrl);
return NextResponse.json({ return NextResponse.json({
success: true, success: true,
data: result data: result,
}); });
} catch (error) { } catch (error) {
// 处理自定义的 ScrapeError // 处理自定义的 ScrapeError
@@ -27,23 +29,23 @@ async function handleDouyinScrape(req: NextRequest) {
{ {
success: false, success: false,
error: error.message, error: error.message,
code: error.code code: error.code,
}, },
{ status: error.statusCode } { status: error.statusCode },
); );
} }
// 处理未知错误 // 处理未知错误
console.error('未捕获的错误:', error); console.error("未捕获的错误:", error);
return NextResponse.json( return NextResponse.json(
{ {
success: false, success: false,
error: '服务器内部错误', error: "服务器内部错误",
code: 'INTERNAL_ERROR' code: "INTERNAL_ERROR",
}, },
{ status: 500 } { status: 500 },
); );
} }
} }
export const GET = handleDouyinScrape export const GET = handleDouyinScrape;
+17 -1
View File
@@ -12,6 +12,22 @@ interface DouyinComment {
digg_count: number; // 点赞数 digg_count: number; // 点赞数
create_time: number; // 创建时间(时间戳) create_time: number; // 创建时间(时间戳)
user: DouyinUser; // 评论用户 user: DouyinUser; // 评论用户
sticker?: {
id: number;
animate_url: {
width: number;
height: number;
url_list: string[];
};
};
image_list?: {
origin_url: {
width: number;
height: number;
url_list: string[];
};
}[];
} }
/** 用户信息(精简版) */ /** 用户信息(精简版) */
@@ -127,7 +143,7 @@ interface DouyinImageInfo {
width: number; width: number;
height: number; height: number;
video: { video: {
play_addr: { src: string }[] play_addr: { src: string }[];
} | null; // 如果是动图,会有 video 信息 } | null; // 如果是动图,会有 video 信息
} }
+125 -27
View File
@@ -1,7 +1,10 @@
import type { BrowserContext } from 'playwright'; export const runtime = "nodejs";
import { uploadFile, generateUniqueFileName } from '@/lib/minio';
import { downloadBinary } from './network'; import type { BrowserContext } from "playwright";
import { pickFirstUrl } from './utils'; import { uploadFile, generateUniqueFileName } from "@/lib/minio";
import { downloadBinary } from "./network";
import { pickFirstUrl } from "./utils";
import { getVideoDuration } from "@/app/api/media";
/** /**
* 下载头像并上传到 MinIO,返回外链;失败时回退为原始链接。 * 下载头像并上传到 MinIO,返回外链;失败时回退为原始链接。
@@ -9,18 +12,50 @@ import { pickFirstUrl } from './utils';
export async function uploadAvatarFromUrl( export async function uploadAvatarFromUrl(
context: BrowserContext, context: BrowserContext,
srcUrl?: string | null, srcUrl?: string | null,
nameHint?: string nameHint?: string,
): Promise<string | undefined> { ): Promise<string | undefined> {
if (!srcUrl) return undefined; if (!srcUrl) return undefined;
try { try {
const { buffer, contentType, ext } = await downloadBinary(context, srcUrl); const { buffer, contentType, ext } = await downloadBinary(context, srcUrl);
const safeExt = ext || 'jpg'; const safeExt = ext || "jpg";
const baseName = nameHint ? `${nameHint}.${safeExt}` : `avatar.${safeExt}`; const baseName = nameHint ? `${nameHint}.${safeExt}` : `avatar.${safeExt}`;
const fileName = generateUniqueFileName(baseName, 'douyin/avatars'); const fileName = generateUniqueFileName(baseName, "douyin/avatars");
const uploaded = await uploadFile(buffer, fileName, { 'Content-Type': contentType }); const uploaded = await uploadFile(buffer, fileName, {
"Content-Type": contentType,
});
return uploaded; return uploaded;
} catch (e) { } catch (e) {
console.warn('[avatar] 上传失败,使用原始链接:', (e as Error)?.message || e); console.warn(
"[avatar] 上传失败,使用原始链接:",
(e as Error)?.message || e,
);
return srcUrl || undefined;
}
}
/**
* 下载任意图片并上传到 MinIO,返回外链;失败时回退为原始链接。
*/
export async function uploadImageFromUrl(
context: BrowserContext,
srcUrl?: string | null,
nameHint?: string,
): Promise<string | undefined> {
if (!srcUrl) return undefined;
try {
const { buffer, contentType, ext } = await downloadBinary(context, srcUrl);
const safeExt = ext || "jpg";
const baseName = nameHint ? `${nameHint}.${safeExt}` : `image.${safeExt}`;
const fileName = generateUniqueFileName(baseName, "douyin/comment-images");
const uploaded = await uploadFile(buffer, fileName, {
"Content-Type": contentType,
});
return uploaded;
} catch (e) {
console.warn(
"[image] 上传失败,使用原始链接:",
(e as Error)?.message || e,
);
return srcUrl || undefined; return srcUrl || undefined;
} }
} }
@@ -28,10 +63,25 @@ export async function uploadAvatarFromUrl(
/** 下载图文作品的图片和音乐并上传到 MinIO */ /** 下载图文作品的图片和音乐并上传到 MinIO */
export async function handleImagePost( export async function handleImagePost(
context: BrowserContext, context: BrowserContext,
aweme: DouyinImageAweme aweme: DouyinImageAweme,
): Promise<{ images: { url: string; width?: number; height?: number }[]; musicUrl?: string }> { ): Promise<{
images: {
url: string;
width?: number;
height?: number;
video?: string;
duration?: number;
}[];
musicUrl?: string;
}> {
const awemeId = aweme.aweme_id; const awemeId = aweme.aweme_id;
const uploadedImages: { url: string; width?: number; height?: number, video?: string }[] = []; const uploadedImages: {
url: string;
width?: number;
height?: number;
video?: string;
duration?: number;
}[] = [];
// 下载图片(顺序保持) // 下载图片(顺序保持)
for (let i = 0; i < (aweme.images?.length || 0); i++) { for (let i = 0; i < (aweme.images?.length || 0); i++) {
@@ -39,27 +89,67 @@ export async function handleImagePost(
const url = pickFirstUrl(img?.url_list); const url = pickFirstUrl(img?.url_list);
if (!url) continue; if (!url) continue;
const { buffer, contentType, ext } = await downloadBinary(context, url); const { buffer, contentType, ext } = await downloadBinary(context, url);
const safeExt = ext || 'jpg'; const safeExt = ext || "jpg";
const fileName = generateUniqueFileName(`${awemeId}/${i}.${safeExt}`, 'douyin/images'); const fileName = generateUniqueFileName(
const uploaded = await uploadFile(buffer, fileName, { 'Content-Type': contentType }); `${awemeId}/${i}.${safeExt}`,
"douyin/images",
);
const uploaded = await uploadFile(buffer, fileName, {
"Content-Type": contentType,
});
if (img.video?.play_addr) { if (img.video?.play_addr) {
// 如果是动图,下载 video 并上传 // 如果是动图,下载 video 并上传
const videoUrl = img.video.play_addr[0]?.src; const videoUrl = img.video.play_addr[0]?.src;
if (videoUrl) { if (videoUrl) {
try { try {
const { buffer: videoBuffer, contentType: videoContentType, ext: videoExt } = await downloadBinary(context, videoUrl); const {
const safeVideoExt = videoExt || 'mp4'; buffer: videoBuffer,
const videoFileName = generateUniqueFileName(`${awemeId}/${i}_animated.${safeVideoExt}`, 'douyin/images'); contentType: videoContentType,
const uploadedVideo = await uploadFile(videoBuffer, videoFileName, { 'Content-Type': videoContentType }); ext: videoExt,
// 将动图的 video URL 也存储起来 } = await downloadBinary(context, videoUrl);
uploadedImages.push({ url: uploaded, width: img?.width, height: img?.height, video: uploadedVideo }); const safeVideoExt = videoExt || "mp4";
const videoFileName = generateUniqueFileName(
`${awemeId}/${i}_animated.${safeVideoExt}`,
"douyin/images",
);
const uploadedVideo = await uploadFile(videoBuffer, videoFileName, {
"Content-Type": videoContentType,
});
// 获取动图时长
const duration = await getVideoDuration(videoBuffer);
// 将动图的 video URL 和 duration 也存储起来
uploadedImages.push({
url: uploaded,
width: img?.width,
height: img?.height,
video: uploadedVideo,
duration: duration ?? undefined,
});
if (duration) {
console.log(`[image] 动图 ${i} 时长: ${duration}ms`);
}
} catch (e) { } catch (e) {
console.warn(`[image] 动图视频上传失败,跳过:`, (e as Error)?.message || e); console.warn(
`[image] 动图视频上传失败,跳过:`,
(e as Error)?.message || e,
);
uploadedImages.push({
url: uploaded,
width: img?.width,
height: img?.height,
});
} }
} }
} else { } else {
uploadedImages.push({ url: uploaded, width: img?.width, height: img?.height }); uploadedImages.push({
url: uploaded,
width: img?.width,
height: img?.height,
});
} }
} }
@@ -67,10 +157,18 @@ export async function handleImagePost(
let musicUrl: string | undefined; let musicUrl: string | undefined;
const audioSrc = pickFirstUrl(aweme.music?.play_url?.url_list); const audioSrc = pickFirstUrl(aweme.music?.play_url?.url_list);
if (audioSrc) { if (audioSrc) {
const { buffer, contentType, ext } = await downloadBinary(context, audioSrc); const { buffer, contentType, ext } = await downloadBinary(
const safeExt = ext || 'mp3'; context,
const fileName = generateUniqueFileName(`${awemeId}.${safeExt}`, 'douyin/audios'); audioSrc,
musicUrl = await uploadFile(buffer, fileName, { 'Content-Type': contentType }); );
const safeExt = ext || "mp3";
const fileName = generateUniqueFileName(
`${awemeId}.${safeExt}`,
"douyin/audios",
);
musicUrl = await uploadFile(buffer, fileName, {
"Content-Type": contentType,
});
} }
return { images: uploadedImages, musicUrl }; return { images: uploadedImages, musicUrl };
+9 -6
View File
@@ -1,3 +1,5 @@
export const runtime = "nodejs";
export function toCamelCaseKey(key: string): string { export function toCamelCaseKey(key: string): string {
return key.replace(/_([a-zA-Z])/g, (_, c: string) => c.toUpperCase()); return key.replace(/_([a-zA-Z])/g, (_, c: string) => c.toUpperCase());
} }
@@ -14,7 +16,7 @@ export function createCamelCompatibleProxy<T extends object>(root: T): T {
const seen = new WeakMap<object, any>(); const seen = new WeakMap<object, any>();
const wrap = (value: any): any => { const wrap = (value: any): any => {
if (value === null || typeof value !== 'object') return value; if (value === null || typeof value !== "object") return value;
if (seen.has(value)) return seen.get(value); if (seen.has(value)) return seen.get(value);
const proxied = new Proxy(value, handler); const proxied = new Proxy(value, handler);
seen.set(value, proxied); seen.set(value, proxied);
@@ -24,13 +26,14 @@ export function createCamelCompatibleProxy<T extends object>(root: T): T {
const handler: ProxyHandler<any> = { const handler: ProxyHandler<any> = {
get(target, prop, receiver) { get(target, prop, receiver) {
// 非字符串属性(如 Symbol、数字索引)直接透传 // 非字符串属性(如 Symbol、数字索引)直接透传
if (typeof prop !== 'string') { if (typeof prop !== "string") {
return wrap(Reflect.get(target, prop, receiver)); return wrap(Reflect.get(target, prop, receiver));
} }
const primary = prop; const primary = prop;
if (primary in target) return wrap(Reflect.get(target, primary, receiver)); if (primary in target)
return wrap(Reflect.get(target, primary, receiver));
const camel = toCamelCaseKey(primary); const camel = toCamelCaseKey(primary);
if (camel in target) return wrap(Reflect.get(target, camel, receiver)); if (camel in target) return wrap(Reflect.get(target, camel, receiver));
@@ -41,14 +44,14 @@ export function createCamelCompatibleProxy<T extends object>(root: T): T {
return wrap(Reflect.get(target, prop, receiver)); return wrap(Reflect.get(target, prop, receiver));
}, },
has(target, prop) { has(target, prop) {
if (typeof prop !== 'string') return prop in target; if (typeof prop !== "string") return prop in target;
const primary = prop === 'auther' ? 'autherInfo' : prop; const primary = prop === "auther" ? "autherInfo" : prop;
return ( return (
primary in target || primary in target ||
toCamelCaseKey(primary) in target || toCamelCaseKey(primary) in target ||
toSnakeCaseKey(primary) in target toSnakeCaseKey(primary) in target
); );
} },
}; };
return wrap(root); return wrap(root);
+28
View File
@@ -0,0 +1,28 @@
import { NextResponse } from "next/server";
import { normalizeHashtag } from "@/lib/hashtag";
import { relatedHashtags } from "@/lib/hashtag-related.server";
import { json } from "@/lib/json";
// 相关 hashtag(按共现计算):GET /api/hashtag/related?name=丰川祥子
export async function GET(req: Request) {
const { searchParams } = new URL(req.url);
const name = normalizeHashtag(searchParams.get("name") || "");
if (!name) {
return NextResponse.json({ error: "Invalid hashtag" }, { status: 400 });
}
const limit = Math.min(
30,
Math.max(1, Math.floor(Number(searchParams.get("limit")) || 12)),
);
try {
return json(await relatedHashtags(name, limit));
} catch (err) {
console.error("Related hashtags error:", err);
return NextResponse.json(
{ error: "Related hashtags failed" },
{ status: 500 },
);
}
}
export const dynamic = "force-dynamic";
+224
View File
@@ -0,0 +1,224 @@
import { execFile } from "child_process";
import { promises as fs } from "fs";
import os from "os";
import path from "path";
import { promisify } from "util";
const execFileAsync = promisify(execFile);
/**
* 使用 ffmpeg 从视频二进制中提取第一帧,返回 JPEG buffer
*/
export async function extractFirstFrame(
videoBuffer: Buffer,
): Promise<{ buffer: Buffer; contentType: string; ext: string } | null> {
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 outPath = path.join(tmpDir, `${base}.jpg`);
try {
await fs.writeFile(inPath, videoBuffer);
const args = [
"-hide_banner",
"-loglevel",
"error",
"-ss",
"0",
"-i",
inPath,
"-frames:v",
"1",
"-q:v",
"2",
"-f",
"image2",
"-y",
outPath,
];
await execFileAsync(ffmpegCmd, args, { windowsHide: true });
const img = await fs.readFile(outPath);
return { buffer: img, contentType: "image/jpeg", ext: "jpg" };
} catch (e: any) {
if (
e &&
(e.code === "ENOENT" ||
/not found|is not recognized/i.test(String(e.message)))
) {
console.warn(
"系统未检测到 ffmpeg,可安装并配置 PATH 或设置 FFMPEG_PATH 后启用封面提取。",
);
return null;
}
throw e;
} finally {
try {
await fs.unlink(inPath);
} catch {}
try {
await fs.unlink(outPath);
} catch {}
}
}
/**
* 使用 ffprobe 获取视频时长(毫秒)
*/
export async function getVideoDuration(
videoBuffer: Buffer,
): Promise<number | null> {
const ffprobeCmd = process.env.FFPROBE_PATH || "ffprobe";
const tmpDir = os.tmpdir();
const base = `dy_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`;
const inPath = path.join(tmpDir, `${base}.mp4`);
try {
await fs.writeFile(inPath, videoBuffer);
const args = [
"-v",
"error",
"-show_entries",
"format=duration",
"-of",
"default=noprint_wrappers=1:nokey=1",
inPath,
];
const { stdout } = await execFileAsync(ffprobeCmd, args, {
windowsHide: true,
});
const durationSeconds = parseFloat(stdout.trim());
if (isNaN(durationSeconds)) return null;
return Math.round(durationSeconds * 1000); // 转换为毫秒
} catch (e: any) {
if (
e &&
(e.code === "ENOENT" ||
/not found|is not recognized/i.test(String(e.message)))
) {
console.warn(
"系统未检测到 ffprobe,可安装并配置 PATH 或设置 FFPROBE_PATH 后启用时长提取。",
);
return null;
}
console.warn(`获取视频时长失败: ${e?.message || e}`);
return null;
} finally {
try {
await fs.unlink(inPath);
} catch {}
}
}
/**
* 使用 ffmpeg 将视频 Buffer 或本地文件转换为音频,返回音频 Buffer
*
* 默认输出 mp3,可通过 opts.format 指定 'mp3' | 'aac' | 'wav'
* - mp3: 使用 libmp3lame,16 kHz 单声道,contentType 为 'audio/mpeg'
* - aac: 输出 m4a 容器(AAC 编码),contentType 为 'audio/mp4'
* - wav: PCM 16-bit,44100Hz,双声道,contentType 为 'audio/wav'
*
* 若系统未安装 ffmpeg 或未在 PATH 中,返回 null 并给出提示。
*/
export async function extractAudio(
videoBuffer: Buffer | string,
opts?: { format?: "mp3" | "aac" | "wav"; bitrateKbps?: number },
): Promise<{ buffer: Buffer; contentType: string; ext: string } | null> {
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 inputPath = typeof videoBuffer === "string" ? videoBuffer : inPath;
const format = opts?.format ?? "mp3";
const bitrate = Math.max(32, Math.min(512, opts?.bitrateKbps ?? 192)); // 安全范围 32~512 kbps
// 根据目标格式设置输出路径、MIME 与编码参数
let outPath = "";
let contentType = "";
let ext = "";
let codecArgs: string[] = [];
if (format === "mp3") {
ext = "mp3";
contentType = "audio/mpeg";
outPath = path.join(tmpDir, `${base}.${ext}`);
codecArgs = ["-c:a", "libmp3lame", "-b:a", `${bitrate}k`];
} else if (format === "aac") {
// 使用 m4a 容器更通用
ext = "m4a";
contentType = "audio/mp4";
outPath = path.join(tmpDir, `${base}.${ext}`);
codecArgs = [
"-c:a",
"aac",
"-b:a",
`${bitrate}k`,
"-movflags",
"+faststart",
];
} else {
// wav
ext = "wav";
contentType = "audio/wav";
outPath = path.join(tmpDir, `${base}.${ext}`);
codecArgs = [
"-f",
"wav",
"-acodec",
"pcm_s16le",
"-ar",
"44100",
"-ac",
"2",
];
}
try {
if (typeof videoBuffer !== "string") await fs.writeFile(inPath, videoBuffer);
const args = [
"-hide_banner",
"-loglevel",
"error",
"-i",
inputPath,
"-map",
"0:a:0",
"-ac",
"1",
"-ar",
"16000",
"-vn", // 丢弃视频流
...codecArgs,
"-y",
outPath,
];
await execFileAsync(ffmpegCmd, args, {
windowsHide: true,
timeout: 60 * 60 * 1000,
});
const audio = await fs.readFile(outPath);
return { buffer: audio, contentType, ext };
} catch (e: any) {
if (
e &&
(e.code === "ENOENT" ||
/not found|is not recognized/i.test(String(e.message)))
) {
console.warn(
"系统未检测到 ffmpeg,可安装并配置 PATH 或设置 FFMPEG_PATH 后启用音频提取。",
);
return null;
}
// 一些环境可能缺少特定编码器(如 libmp3lame),提示并抛出原始错误
console.warn(`提取音频失败: ${e?.message || e}`);
throw e;
} finally {
try {
await fs.unlink(inPath);
} catch {}
try {
if (outPath) await fs.unlink(outPath);
} catch {}
}
}
+21
View File
@@ -0,0 +1,21 @@
import { NextResponse } from "next/server";
import { json } from "@/lib/json";
import { parsePlaybackContext } from "@/lib/playback-context";
import { contextItems } from "@/lib/playback-context.server";
// 按播放上下文的顺序列出作品,供播放页侧栏的网格使用。
// 参数与 /aweme/<id> 上的上下文参数相同:?ctx=search&q=…、?ctx=hashtag&hashtag=…、?ctx=author&author=… 等。
export async function GET(req: Request) {
const ctx = parsePlaybackContext(new URL(req.url).searchParams);
if (!ctx) {
return NextResponse.json({ error: "Invalid context" }, { status: 400 });
}
try {
return json(await contextItems(ctx));
} catch (err) {
console.error("Playlist error:", err);
return NextResponse.json({ error: "Playlist failed" }, { status: 500 });
}
}
export const dynamic = "force-dynamic";
+135
View File
@@ -0,0 +1,135 @@
import { NextResponse } from "next/server";
import { json } from "@/lib/json";
import { getFileUrl } from "@/lib/minio";
import { prisma } from "@/lib/prisma";
import { buildSnippet, highlightAll, parseSearchQuery } from "@/lib/search";
import { searchRows } from "@/lib/search-query";
import { isVideoContentTag } from "@/lib/video-tags";
export async function GET(req: Request) {
const { searchParams } = new URL(req.url);
const q = (searchParams.get("q") || "").trim();
const rawTag = (searchParams.get("tag") || "").trim();
const contentTag = rawTag && isVideoContentTag(rawTag) ? rawTag : null;
const page = Math.max(1, Math.floor(Number(searchParams.get("page")) || 1));
const limit = Math.min(
50,
Math.max(1, Math.floor(Number(searchParams.get("limit")) || 20)),
);
const offset = (page - 1) * limit;
if (rawTag && !contentTag) {
return NextResponse.json({ error: "Invalid tag" }, { status: 400 });
}
const { terms, excludes } = parseSearchQuery(q);
if (terms.length === 0 && !contentTag) {
return json({ results: [], total: 0, page, limit, terms });
}
try {
const rows = await searchRows(
{ terms, excludes, contentTag },
offset,
limit,
);
const videoIds = rows
.filter((r) => r.type === "video")
.map((r) => r.awemeId);
const imagePostIds = rows
.filter((r) => r.type === "image")
.map((r) => r.awemeId);
const [videos, imagePosts] = await Promise.all([
videoIds.length > 0
? prisma.video.findMany({
where: { aweme_id: { in: videoIds } },
select: {
aweme_id: true,
desc: true,
cover_url: true,
video_url: true,
duration_ms: true,
author: true,
},
})
: [],
imagePostIds.length > 0
? prisma.imagePost.findMany({
where: { aweme_id: { in: imagePostIds } },
select: {
aweme_id: true,
desc: true,
author: true,
images: {
orderBy: { order: "asc" },
take: 1,
select: { url: true, width: true, height: true },
},
},
})
: [],
]);
const videoMap = new Map(
videos.map((v) => [
v.aweme_id,
{
...v,
cover_url: getFileUrl(v.cover_url || ""),
video_url: getFileUrl(v.video_url || ""),
author: {
...v.author,
avatar_url: getFileUrl(v.author.avatar_url || ""),
},
},
]),
);
const imagePostMap = new Map(
imagePosts.map((ip) => [
ip.aweme_id,
{
...ip,
author: {
...ip.author,
avatar_url: getFileUrl(ip.author.avatar_url || ""),
},
cover_url: ip.images[0] ? getFileUrl(ip.images[0].url) : null,
},
]),
);
return json({
results: rows.map((r) => {
const matchedIn: ("desc" | "transcript")[] = [];
if (r.inDesc) matchedIn.push("desc");
if (r.inTranscript) matchedIn.push("transcript");
return {
id: `${r.type}:${r.awemeId}`,
awemeId: r.awemeId,
type: r.type,
score: r.score,
matchedIn,
contentTags: r.contentTags,
descParts: highlightAll(r.rawDesc, terms),
// 转写命中时给出命中片段;只按标签筛选时给出转写开头作为预览
snippet:
r.inTranscript || (terms.length === 0 && r.rawTranscript)
? buildSnippet(r.rawTranscript, terms)
: null,
video: r.type === "video" ? videoMap.get(r.awemeId) : undefined,
imagePost:
r.type === "image" ? imagePostMap.get(r.awemeId) : undefined,
};
}),
total: rows[0]?.total ?? 0,
page,
limit,
terms,
});
} catch (err) {
console.error("Search error:", err);
return NextResponse.json({ error: "Search failed" }, { status: 500 });
}
}
+22
View File
@@ -0,0 +1,22 @@
import { NextResponse } from "next/server";
import { getAsrConfig } from "@/lib/stt/dashscope";
import { enqueueMissingTranscriptions } from "@/lib/stt/tasks";
export const runtime = "nodejs";
export async function POST() {
try {
getAsrConfig();
} catch (error) {
return NextResponse.json(
{ error: (error as Error).message },
{ status: 503 },
);
}
try {
return NextResponse.json(
{ queued: await enqueueMissingTranscriptions() },
{ status: 202 },
);
} catch {
return NextResponse.json({ error: "批量任务入队失败" }, { status: 500 });
}
}
+33
View File
@@ -0,0 +1,33 @@
import { type NextRequest, NextResponse } from "next/server";
import { enqueueTranscription, readTranscription } from "@/lib/stt/tasks";
import { isActive } from "@/lib/stt/types";
export const runtime = "nodejs";
export const dynamic = "force-dynamic";
const validId = (id: unknown): id is string =>
typeof id === "string" && /^[A-Za-z0-9_-]{1,128}$/.test(id);
export async function GET(req: NextRequest) {
const id = req.nextUrl.searchParams.get("awemeId");
if (!validId(id))
return NextResponse.json({ error: "需要有效的 awemeId" }, { status: 400 });
try {
const state = await readTranscription(id);
return state
? NextResponse.json(state)
: NextResponse.json({ error: "视频不存在" }, { status: 404 });
} catch {
return NextResponse.json({ error: "读取转写状态失败" }, { status: 500 });
}
}
export async function POST(req: NextRequest) {
const body = await req.json().catch(() => null);
if (!validId(body?.awemeId))
return NextResponse.json({ error: "需要有效的 awemeId" }, { status: 400 });
try {
const state = await enqueueTranscription(body.awemeId, true);
return state
? NextResponse.json(state, { status: isActive(state.status) ? 202 : 200 })
: NextResponse.json({ error: "视频不存在" }, { status: 404 });
} catch {
return NextResponse.json({ error: "转写任务入队失败" }, { status: 500 });
}
}
+157
View File
@@ -0,0 +1,157 @@
import { prisma } from "@/lib/prisma";
import { getFileUrl } from "@/lib/minio";
import { playbackContextQuery } from "@/lib/playback-context";
import FeedMasonry from "@/app/components/FeedMasonry";
import BackButton from "@/app/components/BackButton";
import { FeedItem } from "@/app/types/feed";
import { notFound } from "next/navigation";
import Image from "next/image";
import { ExternalLink } from "lucide-react";
export default async function AuthorPage({
params,
}: {
params: Promise<{ secUid: string }>;
}) {
const secUid = (await params).secUid;
const author = await prisma.author.findUnique({
where: { sec_uid: secUid },
});
if (!author) {
notFound();
}
// Initial feed fetch
const limit = 24;
const [videos, posts] = await Promise.all([
prisma.video.findMany({
where: { authorId: secUid },
orderBy: { created_at: "desc" },
take: limit,
include: { author: true },
}),
prisma.imagePost.findMany({
where: { authorId: secUid },
orderBy: { created_at: "desc" },
take: limit,
include: { author: true, images: { orderBy: { order: "asc" }, take: 1 } },
}),
]);
const initialItems: FeedItem[] = [
...videos.map((v) => ({
type: "video" as const,
aweme_id: v.aweme_id,
created_at: v.created_at,
desc: v.desc,
video_url: getFileUrl(v.video_url),
cover_url: getFileUrl(v.cover_url ?? "default_cover.png"),
width: v.width ?? null,
height: v.height ?? null,
author: {
nickname: v.author.nickname,
avatar_url: getFileUrl(v.author.avatar_url ?? ""),
sec_uid: v.author.sec_uid,
},
likes: Number(v.digg_count),
})),
...posts.map((p) => ({
type: "image" as const,
aweme_id: p.aweme_id,
created_at: p.created_at,
desc: p.desc,
cover_url: getFileUrl(p.images?.[0]?.url ?? null),
width: p.images?.[0]?.width ?? null,
height: p.images?.[0]?.height ?? null,
author: {
nickname: p.author.nickname,
avatar_url: getFileUrl(p.author.avatar_url ?? ""),
sec_uid: p.author.sec_uid,
},
likes: Number(p.digg_count),
})),
]
.sort((a, b) => +new Date(b.created_at) - +new Date(a.created_at))
.slice(0, limit);
const initialCursor =
initialItems.length > 0
? new Date(
initialItems[initialItems.length - 1].created_at as any,
).toISOString()
: null;
return (
<main className="min-h-screen bg-white dark:bg-black text-black dark:text-white">
<div className="sticky top-0 z-10 p-4 bg-white/80 dark:bg-black/80 backdrop-blur-md border-b border-gray-200 dark:border-gray-800 flex items-center gap-4">
<BackButton />
<h1 className="text-lg font-bold truncate">{author.nickname}</h1>
</div>
<div className="max-w-7xl mx-auto px-4 py-8">
{/* Author Profile Header */}
<div className="flex flex-col md:flex-row items-center md:items-start gap-6 mb-12">
<div className="relative w-24 h-24 md:w-32 md:h-32 shrink-0">
<Image
src={getFileUrl(author.avatar_url || "default-avatar.png")}
alt={author.nickname}
fill
className="rounded-full object-cover border-2 border-gray-200 dark:border-gray-800"
/>
</div>
<div className="flex-1 text-center md:text-left space-y-4">
<div>
<h2 className="text-2xl font-bold">{author.nickname}</h2>
<p className="text-sm text-gray-500 dark:text-gray-400 mt-1">
抖音号:{author.unique_id || author.short_id || "未知"}
</p>
<a
href={`https://www.douyin.com/user/${encodeURIComponent(secUid)}`}
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-1.5 mt-3 text-sm text-gray-600 hover:text-black dark:text-gray-400 dark:hover:text-white underline underline-offset-4"
>
打开抖音主页
<ExternalLink size={14} aria-hidden="true" />
</a>
</div>
{author.signature && (
<p className="text-gray-700 dark:text-gray-300 whitespace-pre-wrap max-w-2xl">
{author.signature}
</p>
)}
<div className="flex items-center justify-center md:justify-start gap-6 text-sm">
<div className="flex items-center gap-1">
<span className="font-bold text-lg">
{Number(author.total_favorited).toLocaleString()}
</span>
<span className="text-gray-500">获赞</span>
</div>
<div className="flex items-center gap-1">
<span className="font-bold text-lg">
{Number(author.follower_count).toLocaleString()}
</span>
<span className="text-gray-500">粉丝</span>
</div>
</div>
</div>
</div>
<div className="border-t border-gray-200 dark:border-gray-800 pt-8">
<h3 className="text-lg font-semibold mb-6">作品</h3>
<FeedMasonry
initialItems={initialItems}
initialCursor={initialCursor}
fetchUrl={`/api/author/${secUid}/feed`}
linkQuery={playbackContextQuery({ kind: "author", secUid })}
/>
</div>
</div>
</main>
);
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,63 @@
import { ChevronRight } from "lucide-react";
import Link from "next/link";
import { memo } from "react";
import type { PlaylistState } from "../hooks/usePlaylist";
import type { User } from "../types.ts";
import { WorksGrid } from "./WorksGrid";
interface AuthorWorksTabProps {
/** 侧栏打开且停在「TA的作品」标签页 */
active: boolean;
author: User;
works: PlaylistState;
currentId: string;
/** 跳转时原样带上的播放上下文查询串 */
contextQuery: string;
onSelect: () => void;
}
// 侧栏「TA的作品」:作者在库里的全部作品。点作品就地切换;点作者名字才去独立的作者页。
export const AuthorWorksTab = memo(function AuthorWorksTab({
active,
author,
works,
currentId,
contextQuery,
onSelect,
}: AuthorWorksTabProps) {
const header = author.sec_uid ? (
<Link
href={`/author/${author.sec_uid}`}
className="group flex min-w-0 flex-1 items-center gap-2.5"
title="打开作者主页"
>
{/* biome-ignore lint/performance/noImgElement: 头像来自对象存储,不走 next/image */}
<img
src={author.avatar_url ?? ""}
alt=""
className="h-9 w-9 shrink-0 rounded-full bg-white/10 object-cover"
/>
<span className="truncate font-semibold text-white group-hover:underline">
@{author.nickname}
</span>
<ChevronRight size={16} className="shrink-0 text-white/60" />
<span className="ml-auto shrink-0 text-xs text-white/40">作者主页</span>
</Link>
) : (
<span className="truncate font-semibold text-white">
@{author.nickname}
</span>
);
return (
<WorksGrid
active={active}
list={works}
currentId={currentId}
hrefQuery={contextQuery}
onSelect={onSelect}
singleHint="库里只存了这位作者的这一个作品"
header={header}
/>
);
});
@@ -1,15 +1,15 @@
import { forwardRef } from "react"; import { forwardRef, memo } from "react";
interface BackgroundCanvasProps {} export const BackgroundCanvas = memo(
forwardRef<HTMLCanvasElement>((_props, ref) => {
export const BackgroundCanvas = forwardRef<HTMLCanvasElement, BackgroundCanvasProps>((props, ref) => {
return ( return (
<canvas <canvas
ref={ref} ref={ref}
className="fixed inset-0 w-full h-full -z-10" className="pointer-events-none fixed inset-0 w-full h-full -z-10 scale-110"
style={{ filter: "blur(40px)" }} style={{ filter: "blur(40px)" }}
/> />
); );
}); }),
);
BackgroundCanvas.displayName = "BackgroundCanvas"; BackgroundCanvas.displayName = "BackgroundCanvas";
+89 -12
View File
@@ -1,6 +1,7 @@
import { ThumbsUp } from "lucide-react"; import { ThumbsUp, X } from "lucide-react";
import type { Comment, User } from "../types"; import { memo, useState } from "react";
import { formatRelativeTime, formatAbsoluteUTC } from "../utils"; import type { Comment, User } from "../types.ts";
import { formatAbsoluteUTC, formatRelativeTime } from "../utils";
import { CommentText } from "./CommentText"; import { CommentText } from "./CommentText";
interface CommentListProps { interface CommentListProps {
@@ -9,18 +10,35 @@ interface CommentListProps {
comments: Comment[]; comments: Comment[];
} }
export function CommentList({ author, createdAt, comments }: CommentListProps) { export const CommentList = memo(function CommentList({
author,
createdAt,
comments,
}: CommentListProps) {
const [previewImage, setPreviewImage] = useState<string | null>(null);
return ( return (
<> <>
<header className="flex items-center gap-4 mb-5"> <header className="flex items-center gap-4 mb-5">
<div className="size-10 rounded-full overflow-hidden bg-zinc-700/60"> <div className="size-10 rounded-full overflow-hidden bg-zinc-700/60">
{author.avatar_url ? ( {author.avatar_url ? (
<img src={author.avatar_url} alt="avatar" className="w-full h-full object-cover" /> <img
src={author.avatar_url}
alt="avatar"
loading="lazy"
decoding="async"
className="w-full h-full object-cover"
/>
) : null} ) : null}
</div> </div>
<div> <div>
<div className="font-medium text-white/95 text-sm sm:text-base">{author.nickname}</div> <div className="font-medium text-white/95 text-sm sm:text-base">
<div className="text-xs text-white/50" title={formatAbsoluteUTC(createdAt)}> {author.nickname}
</div>
<div
className="text-xs text-white/50"
title={formatAbsoluteUTC(createdAt)}
>
发布于 {formatRelativeTime(createdAt)} 发布于 {formatRelativeTime(createdAt)}
</div> </div>
</div> </div>
@@ -31,17 +49,50 @@ export function CommentList({ author, createdAt, comments }: CommentListProps) {
<li key={c.cid} className="flex items-start gap-3 sm:gap-4"> <li key={c.cid} className="flex items-start gap-3 sm:gap-4">
<div className="size-8 rounded-full overflow-hidden bg-zinc-700/60 shrink-0"> <div className="size-8 rounded-full overflow-hidden bg-zinc-700/60 shrink-0">
{c.user.avatar_url ? ( {c.user.avatar_url ? (
<img src={c.user.avatar_url} alt="avatar" className="w-full h-full object-cover" /> <img
src={c.user.avatar_url}
alt="avatar"
loading="lazy"
decoding="async"
className="w-full h-full object-cover"
/>
) : null} ) : null}
</div> </div>
<div className="min-w-0 flex-1"> <div className="min-w-0 flex-1">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<span className="font-medium text-white/95 text-sm">{c.user.nickname}</span> <span className="font-medium text-white/95 text-sm">
<span className="text-xs text-white/50">{formatRelativeTime(c.created_at)}</span> {c.user.nickname}
</span>
<span className="text-xs text-white/50">
{formatRelativeTime(c.created_at)}
</span>
</div> </div>
<p className="mt-1 text-sm leading-relaxed text-white/90 break-words"> <p className="mt-1 text-sm leading-relaxed text-white/90 break-words">
<CommentText text={c.text} /> <CommentText text={c.text} />
</p> </p>
{c.images && c.images.length > 0 ? (
<div className="mt-2 grid grid-cols-3 gap-1.5 sm:gap-2">
{c.images.map((img, idx) => (
<button
key={`${c.cid}-img-${idx}`}
type="button"
onClick={() => setPreviewImage(img.url)}
className="block overflow-hidden rounded-md bg-zinc-800/60 cursor-pointer hover:opacity-80 transition-opacity"
title="点击预览大图"
>
<img
src={img.url}
alt="comment-image"
width={img.width}
height={img.height}
className="w-full h-auto"
loading="lazy"
decoding="async"
/>
</button>
))}
</div>
) : null}
<div className="mt-2 inline-flex items-center gap-1 text-xs text-white/70"> <div className="mt-2 inline-flex items-center gap-1 text-xs text-white/70">
<ThumbsUp size={14} /> <ThumbsUp size={14} />
<span>{c.digg_count}</span> <span>{c.digg_count}</span>
@@ -49,8 +100,34 @@ export function CommentList({ author, createdAt, comments }: CommentListProps) {
</div> </div>
</li> </li>
))} ))}
{comments.length === 0 ? <li className="text-sm text-white/60">暂无评论</li> : null} {comments.length === 0 ? (
<li className="text-sm text-white/60">暂无评论</li>
) : null}
</ul> </ul>
{/* 图片预览灯箱 */}
{previewImage && (
<div
className="fixed inset-0 z-[100] flex items-center justify-center bg-black/90"
onClick={() => setPreviewImage(null)}
>
<button
type="button"
className="absolute top-4 right-4 w-10 h-10 rounded-full bg-white/15 text-white border border-white/20 hover:bg-white/25 transition-colors flex items-center justify-center"
onClick={() => setPreviewImage(null)}
aria-label="关闭预览"
>
<X size={20} />
</button>
<img
src={previewImage}
alt="preview"
className="max-w-[90vw] max-h-[90vh] object-contain"
onClick={(e) => e.stopPropagation()}
/>
</div>
)}
</> </>
); );
} });
+183 -58
View File
@@ -1,71 +1,196 @@
import { X } from "lucide-react"; import { memo, useCallback, useEffect, useRef, useState } from "react";
import type { Comment, User } from "../types"; import type { Comment, User } from "../types.ts";
import { CommentList } from "./CommentList"; import { CommentList } from "./CommentList";
interface CommentPanelProps { interface CommentPanelProps {
/** 侧栏打开且停在「评论」标签页 */
open: boolean; open: boolean;
onClose: () => void;
author: User; author: User;
createdAt: string | Date; createdAt: string | Date;
comments: Comment[]; awemeId: string;
mounted: boolean;
} }
export function CommentPanel({ open, onClose, author, createdAt, comments, mounted }: CommentPanelProps) { interface CommentsApiResponse {
return ( comments?: Comment[];
<> total?: number;
{/* 横屏评论面板:并排分栏 */} hasMore?: boolean;
<aside mode?: string;
className={` seed?: string | number;
hidden landscape:flex snapshot?: string | number;
z-30 flex-col bg-[rgba(22,22,22,0.92)] text-white }
relative h-full overflow-hidden
${mounted ? "transition-[width] duration-200 ease-out" : ""}
${open ? "w-[min(420px,36vw)] border-l border-white/10" : "w-0"}
`}
>
<div className="flex items-center justify-between px-3 py-3 border-b border-white/10">
<button
className="w-8 h-8 inline-flex items-center justify-center rounded-full bg-white/15 text-white/90 border border-white/20 hover:bg-white/25 transition-colors"
onClick={onClose}
aria-label="关闭评论"
>
<X size={18} />
</button>
<div className="text-white font-semibold">评论 {comments.length > 0 ? `(${comments.length})` : ""}</div>
</div>
<div className="p-3 overflow-auto"> // 侧栏「评论」标签页的内容(外壳与标签切换在 SidePanel 里)
<CommentList author={author} createdAt={createdAt} comments={comments} /> export const CommentPanel = memo(function CommentPanel({
</div> open,
</aside> author,
createdAt,
awemeId,
}: CommentPanelProps) {
const [comments, setComments] = useState<Comment[]>([]);
const [loading, setLoading] = useState(false);
const [hasMore, setHasMore] = useState(true);
const [hasOpened, setHasOpened] = useState(open);
{/* 竖屏评论面板:bottom sheet */} const scrollRef = useRef<HTMLDivElement>(null);
<aside const sentinelRef = useRef<HTMLDivElement>(null);
className={` const loadingRef = useRef(false);
landscape:hidden const abortControllerRef = useRef<AbortController | null>(null);
z-30 flex flex-col bg-[rgba(22,22,22,0.92)] text-white const commentsCountRef = useRef(0);
fixed inset-x-0 bottom-0 w-full h-[min(80vh,88dvh)] const hasMoreRef = useRef(true);
${mounted ? "transition-transform duration-200 ease-out" : ""} const rankParamsRef = useRef<{ seed: string; snapshot: string } | null>(null);
border-t border-white/10
${open ? "translate-y-0" : "translate-y-full"}
`}
>
<div className="flex items-center justify-between px-3 py-3 border-b border-white/10">
<div className="text-white font-semibold">评论 {comments.length > 0 ? `(${comments.length})` : ""}</div>
<button
className="w-8 h-8 inline-flex items-center justify-center rounded-full bg-white/15 text-white/90 border border-white/20 hover:bg-white/25 transition-colors"
onClick={onClose}
aria-label="关闭评论"
>
<X size={18} />
</button>
</div>
<div className="p-3 overflow-auto"> useEffect(() => {
<CommentList author={author} createdAt={createdAt} comments={comments} /> if (open) setHasOpened(true);
</div> }, [open]);
</aside>
</> const loadComments = useCallback(
async (reset = false): Promise<void> => {
if (loadingRef.current && !reset) return;
if (!reset && !hasMoreRef.current) return;
abortControllerRef.current?.abort();
const controller = new AbortController();
abortControllerRef.current = controller;
loadingRef.current = true;
setLoading(true);
try {
const skip = reset ? 0 : commentsCountRef.current;
const query = new URLSearchParams({
skip: String(skip),
take: String(20),
mode: "ranked",
});
const currentRank = reset ? null : rankParamsRef.current;
if (currentRank) {
query.set("seed", currentRank.seed);
query.set("snapshot", currentRank.snapshot);
}
const response = await fetch(
`/api/comments/${awemeId}?${query.toString()}`,
{ signal: controller.signal },
); );
const data = (await response.json()) as CommentsApiResponse;
if (controller.signal.aborted) return;
const incoming = Array.isArray(data.comments) ? data.comments : [];
setComments((prev) => {
const source = reset ? incoming : [...prev, ...incoming];
const seen = new Set<string>();
const deduped = source.filter((c) => {
if (seen.has(c.cid)) return false;
seen.add(c.cid);
return true;
});
commentsCountRef.current = deduped.length;
return deduped;
});
const nextHasMore = Boolean(data.hasMore);
hasMoreRef.current = nextHasMore;
setHasMore(nextHasMore);
if (data.mode === "ranked" && data.seed && data.snapshot) {
if (reset || !rankParamsRef.current) {
rankParamsRef.current = {
seed: String(data.seed),
snapshot: String(data.snapshot),
};
} }
} else if (reset) {
rankParamsRef.current = null;
}
} catch (error) {
if (error instanceof DOMException && error.name === "AbortError") {
return;
}
console.error("加载评论失败:", error);
} finally {
if (abortControllerRef.current === controller) {
setLoading(false);
loadingRef.current = false;
}
}
},
[awemeId],
);
// 切换作品时清理旧评论状态与未完成请求
useEffect(() => {
abortControllerRef.current?.abort();
loadingRef.current = false;
commentsCountRef.current = 0;
hasMoreRef.current = true;
rankParamsRef.current = null;
setComments([]);
setHasMore(true);
setLoading(false);
if (open) {
void loadComments(true);
}
return () => {
abortControllerRef.current?.abort();
};
}, [awemeId, open, loadComments]);
// Intersection Observer 监听底部触发分页加载(单容器)
useEffect(() => {
if (!open) return;
const rootEl = scrollRef.current;
const targetEl = sentinelRef.current;
if (!rootEl || !targetEl) return;
const io = new IntersectionObserver(
(entries) => {
const entry = entries[0];
if (
entry?.isIntersecting &&
!loadingRef.current &&
hasMoreRef.current
) {
void loadComments(false);
}
},
{
root: rootEl,
rootMargin: "0px 0px 200px 0px",
threshold: 0,
},
);
io.observe(targetEl);
return () => io.disconnect();
}, [open, hasMore, loadComments]);
return (
<div ref={scrollRef} className="p-3 overflow-auto comment-scroll flex-1">
{(open || hasOpened) && (
<>
<CommentList
author={author}
createdAt={createdAt}
comments={comments}
/>
{hasMore && <div ref={sentinelRef} className="h-1 w-full" />}
{loading && (
<div className="py-4 text-center text-white/60 text-sm">
加载中...
</div>
)}
{!hasMore && comments.length > 0 && (
<div className="py-4 text-center text-white/40 text-sm">
没有更多评论了
</div>
)}
</>
)}
</div>
);
});
@@ -20,7 +20,10 @@ export function CommentText({ text }: { text: string }) {
// 如果图片加载失败,显示原始文本 // 如果图片加载失败,显示原始文本
e.currentTarget.style.display = "none"; e.currentTarget.style.display = "none";
const textNode = document.createTextNode(`[${part.name}]`); const textNode = document.createTextNode(`[${part.name}]`);
e.currentTarget.parentNode?.insertBefore(textNode, e.currentTarget); e.currentTarget.parentNode?.insertBefore(
textNode,
e.currentTarget,
);
}} }}
/> />
); );
@@ -0,0 +1,64 @@
import { ChevronUp, ListVideo, X } from "lucide-react";
import { memo } from "react";
interface ContextChipProps {
label: string;
position: number;
total: number;
/** 提供时整个标签可点:展开 / 打开侧栏里的完整列表 */
onExpand?: () => void;
expandLabel?: string;
onExit: () => void;
exitLabel: string;
}
// 顶部的模式提示:说明上下切换当前在哪个列表里移动,避免「为什么跳到这里了」的困惑
export const ContextChip = memo(function ContextChip({
label,
position,
total,
onExpand,
expandLabel,
onExit,
exitLabel,
}: ContextChipProps) {
const content = (
<>
<ListVideo size={14} className="shrink-0 text-rose-300" />
<span className="truncate">{label}</span>
<span className="shrink-0 tabular-nums text-white/60">
{position}/{total}
</span>
{onExpand && <ChevronUp size={14} className="shrink-0 text-white/70" />}
</>
);
return (
<div className="absolute left-1/2 top-14 z-40 flex max-w-[calc(100%-1.5rem)] sm:top-3 sm:max-w-[calc(100%-14rem)] -translate-x-1/2 items-center rounded-full border border-white/20 bg-black/60 text-xs font-medium text-white shadow-lg backdrop-blur">
{onExpand ? (
<button
type="button"
onClick={onExpand}
className="flex min-w-0 items-center gap-1.5 py-1.5 pl-3 pr-1 cursor-pointer"
aria-label={expandLabel}
title={expandLabel}
>
{content}
</button>
) : (
<div className="flex min-w-0 items-center gap-1.5 py-1.5 pl-3 pr-1">
{content}
</div>
)}
<button
type="button"
onClick={onExit}
className="mr-1 grid h-6 w-6 shrink-0 place-items-center rounded-full text-white/70 hover:bg-white/15 hover:text-white cursor-pointer"
aria-label={exitLabel}
title={exitLabel}
>
<X size={14} />
</button>
</div>
);
});
@@ -0,0 +1,121 @@
import { ArrowLeft, Hash, Search } from "lucide-react";
import Link from "next/link";
import { memo, useState } from "react";
import { playbackContextQuery } from "@/lib/playback-context";
import { type PlaylistState, usePlaylist } from "../hooks/usePlaylist";
import { useRelatedHashtags } from "../hooks/useRelatedHashtags";
import type { PlaybackContextInfo } from "../types.ts";
import { WorksGrid } from "./WorksGrid";
interface ContextListTabProps {
active: boolean;
context: PlaybackContextInfo;
list: PlaylistState;
currentId: string;
contextQuery: string;
/** hashtag 上下文时的 tag 名(用来取相关 tag) */
hashtagName: string | null;
/** 对应的搜索结果页链接(搜索上下文才有) */
searchPageHref: string | null;
onSelect: () => void;
}
// 侧栏的「搜索结果 / #hashtag / 标签」列表:和上下切换的顺序完全一致。
// hashtag 列表上方显示相关 tag(按共现计算);点相关 tag 只在侧栏里预览它的作品,不打断当前播放,
// 在预览里点作品才切换到那个 tag 的列表。
export const ContextListTab = memo(function ContextListTab({
active,
context,
list,
currentId,
contextQuery,
hashtagName,
searchPageHref,
onSelect,
}: ContextListTabProps) {
// 正在预览的相关 tag(null 表示显示当前列表本身)
const [browsing, setBrowsing] = useState<string | null>(null);
const viewTag = browsing ?? hashtagName;
const related = useRelatedHashtags(viewTag, active);
const browseQuery = browsing
? playbackContextQuery({ kind: "hashtag", name: browsing })
: "";
const browseList = usePlaylist(browseQuery, active && Boolean(browsing));
const pickTag = (name: string) => {
setBrowsing(
name.toLowerCase() === hashtagName?.toLowerCase() ? null : name,
);
};
const Icon = context.kind === "hashtag" ? Hash : Search;
const header = browsing ? (
<>
<button
type="button"
onClick={() => setBrowsing(null)}
className="grid h-8 w-8 shrink-0 place-items-center rounded-full bg-white/10 text-white/80 hover:bg-white/20 cursor-pointer"
aria-label={`返回 ${context.label}`}
title={`返回 ${context.label}`}
>
<ArrowLeft size={16} />
</button>
<div className="min-w-0 flex-1">
<div className="truncate font-semibold text-white">#{browsing}</div>
<div className="text-xs text-white/50">
{related ? `共 ${related.count} 个 · ` : ""}点作品切换到这个 tag
</div>
</div>
</>
) : (
<>
<Icon size={18} className="shrink-0 text-rose-300" />
<div className="min-w-0 flex-1">
<div className="truncate font-semibold text-white">{context.label}</div>
<div className="text-xs text-white/50">
第 {context.position} 个 · 共 {context.total} 个
</div>
</div>
{searchPageHref && (
<Link
href={searchPageHref}
className="shrink-0 text-xs text-white/50 hover:text-white hover:underline"
>
在搜索页查看
</Link>
)}
</>
);
const relatedRow =
viewTag && related && related.related.length > 0 ? (
<div className="flex items-center gap-1.5 overflow-x-auto px-3 py-2 border-b border-white/10 comment-scroll">
<span className="shrink-0 text-xs text-white/40">相关</span>
{related.related.map((tag) => (
<button
key={tag.name}
type="button"
onClick={() => pickTag(tag.name)}
className="shrink-0 rounded-full border border-white/15 bg-white/5 px-2.5 py-1 text-xs text-white/80 hover:border-white/30 hover:text-white cursor-pointer"
title={`一起出现在 ${tag.together} 个作品里,#${tag.name} 共 ${tag.count} 个作品`}
>
#{tag.name}
<span className="ml-1 text-white/40">{tag.together}</span>
</button>
))}
</div>
) : null;
return (
<WorksGrid
active={active}
list={browsing ? browseList : list}
currentId={currentId}
hrefQuery={browsing ? browseQuery : contextQuery}
onSelect={onSelect}
header={header}
subheader={relatedRow}
/>
);
});
+468 -19
View File
@@ -1,40 +1,489 @@
import { forwardRef } from "react"; import {
import type { ImageData } from "../types"; forwardRef,
memo,
type RefObject,
useCallback,
useEffect,
useMemo,
useRef,
} from "react";
import type { ImageData, LoopMode, ObjectFit } from "../types.ts";
import { releaseMediaElement } from "../utils";
interface ImageCarouselProps { interface ImageCarouselProps {
images: ImageData["images"]; images: ImageData["images"];
currentIndex: number; currentIndex: number;
isPlaying: boolean;
playbackRate?: number;
segmentProgressRef: RefObject<number>;
mediaSyncToken: number;
loopMode: LoopMode;
hasNextNeighbor: boolean;
rotation: number;
objectFit: ObjectFit;
failedAnimatedMap: Record<string, boolean>;
onTogglePlay: () => void; onTogglePlay: () => void;
onAnimatedDuration: (imageId: string, durationMs: number) => void;
onMediaReady: (imageId: string) => void;
onAnimatedFailed: (imageId: string) => void;
onAnimatedProgress: (imageId: string, progress: number) => void;
onAnimatedEnded: (imageId: string) => void;
} }
export const ImageCarousel = forwardRef<HTMLDivElement, ImageCarouselProps>( export const ImageCarousel = memo(
({ images, currentIndex, onTogglePlay }, ref) => { forwardRef<HTMLDivElement, ImageCarouselProps>(
(
{
images,
currentIndex,
isPlaying,
playbackRate = 1,
segmentProgressRef,
mediaSyncToken,
loopMode,
hasNextNeighbor,
rotation,
objectFit,
failedAnimatedMap,
onTogglePlay,
onAnimatedDuration,
onMediaReady,
onAnimatedFailed,
onAnimatedProgress,
onAnimatedEnded,
},
ref,
) => {
const videoRefs = useRef<Map<string, HTMLVideoElement>>(new Map());
const videoRefCallbacks = useRef<
Map<string, (el: HTMLVideoElement | null) => void>
>(new Map());
const activeVideoIdRef = useRef<string | null>(null);
const lastMediaSyncTokenRef = useRef(mediaSyncToken);
const playbackRateRef = useRef(playbackRate);
playbackRateRef.current = playbackRate;
const pendingSeekRef = useRef<{
imageId: string;
progress: number;
} | null>(null);
const shouldLoopCurrentVideo =
loopMode === "single" ||
(images.length === 1 &&
(loopMode !== "sequential" || !hasNextNeighbor));
const seekVideoToProgress = useCallback(
(videoEl: HTMLVideoElement, progress: number): boolean => {
const duration = videoEl.duration;
if (!Number.isFinite(duration) || duration <= 0) return false;
const clampedProgress = Math.max(0, Math.min(1, progress));
const maxTime = Math.max(0, duration - 0.05);
const targetTime = Math.min(maxTime, duration * clampedProgress);
if (Math.abs(videoEl.currentTime - targetTime) > 0.08) {
try {
videoEl.currentTime = targetTime;
} catch {
return false;
}
}
return true;
},
[],
);
const tryPlayVideo = useCallback(
(videoEl: HTMLVideoElement, imageId: string): void => {
videoEl.play().catch((err: unknown) => {
const errorName = err instanceof DOMException ? err.name : "";
if (
errorName === "NotAllowedError" ||
errorName === "NotSupportedError"
) {
onAnimatedFailed(imageId);
}
});
},
[onAnimatedFailed],
);
// 虚拟窗口:同时渲染前一张、当前张、后一张静态图以保证切页无白屏,同时仅当前张挂载单个 <video>
const visibleIndices = useMemo((): number[] => {
const indices: number[] = [];
if (currentIndex > 0) indices.push(currentIndex - 1);
if (currentIndex >= 0 && currentIndex < images.length) {
indices.push(currentIndex);
}
if (currentIndex < images.length - 1) indices.push(currentIndex + 1);
return indices;
}, [currentIndex, images.length]);
// 组件卸载时释放所有残留的 <video> 硬件解码缓冲
useEffect(() => {
const map = videoRefs.current;
const callbacks = videoRefCallbacks.current;
return () => {
map.forEach((videoEl) => {
releaseMediaElement(videoEl);
});
map.clear();
callbacks.clear();
};
}, []);
// 管理当前动图的播放、暂停与切换
useEffect(() => {
const currentImage = images[currentIndex];
const isAnimated =
Boolean(currentImage?.animated) &&
!failedAnimatedMap[currentImage?.id ?? ""];
if (!currentImage || !isAnimated) {
activeVideoIdRef.current = null;
videoRefs.current.forEach((videoEl) => {
videoEl.pause();
});
return;
}
const videoEl = videoRefs.current.get(currentImage.id);
videoRefs.current.forEach((itemVideoEl, imageId) => {
if (imageId !== currentImage.id || !isPlaying) {
itemVideoEl.pause();
}
});
if (!videoEl) return;
videoEl.loop = shouldLoopCurrentVideo;
videoEl.playbackRate = playbackRate;
if (activeVideoIdRef.current !== currentImage.id) {
activeVideoIdRef.current = currentImage.id;
const initialProgress = segmentProgressRef.current;
if (initialProgress > 0.01) {
const applied = seekVideoToProgress(videoEl, initialProgress);
if (!applied) {
pendingSeekRef.current = {
imageId: currentImage.id,
progress: initialProgress,
};
}
} else {
pendingSeekRef.current = null;
if (videoEl.currentTime > 0.05) {
try {
videoEl.currentTime = 0;
} catch {}
}
}
} else if (
isPlaying &&
(videoEl.ended ||
(Number.isFinite(videoEl.duration) &&
videoEl.duration > 0 &&
videoEl.currentTime >= videoEl.duration - 0.05))
) {
try {
videoEl.currentTime = 0;
} catch {}
}
if (isPlaying) {
tryPlayVideo(videoEl, currentImage.id);
} else {
videoEl.pause();
}
}, [
currentIndex,
failedAnimatedMap,
images,
isPlaying,
playbackRate,
seekVideoToProgress,
segmentProgressRef,
shouldLoopCurrentVideo,
tryPlayVideo,
]);
// 响应用户主动 Seek 或外部强制同步
useEffect(() => {
if (lastMediaSyncTokenRef.current === mediaSyncToken) return;
lastMediaSyncTokenRef.current = mediaSyncToken;
const currentImage = images[currentIndex];
if (!currentImage?.animated || failedAnimatedMap[currentImage.id]) {
return;
}
const targetProgress = segmentProgressRef.current;
const videoEl = videoRefs.current.get(currentImage.id);
if (!videoEl) {
pendingSeekRef.current = {
imageId: currentImage.id,
progress: targetProgress,
};
return;
}
const applied = seekVideoToProgress(videoEl, targetProgress);
if (applied) {
pendingSeekRef.current = null;
} else {
pendingSeekRef.current = {
imageId: currentImage.id,
progress: targetProgress,
};
}
if (isPlaying) {
tryPlayVideo(videoEl, currentImage.id);
}
}, [
currentIndex,
failedAnimatedMap,
images,
isPlaying,
mediaSyncToken,
seekVideoToProgress,
segmentProgressRef,
tryPlayVideo,
]);
// 使用 rAF 跟踪当前动图真实播放进度,只有动图实际走帧时进度条才前进
useEffect(() => {
if (!isPlaying) return;
const currentImage = images[currentIndex];
if (!currentImage?.animated || failedAnimatedMap[currentImage.id]) {
return;
}
let rafId: number | null = null;
const imageId = currentImage.id;
const updateLoop = (): void => {
const videoEl = videoRefs.current.get(imageId);
if (videoEl) {
const duration = videoEl.duration;
if (Number.isFinite(duration) && duration > 0 && !videoEl.paused) {
const progress = Math.max(
0,
Math.min(1, videoEl.currentTime / duration),
);
onAnimatedProgress(imageId, progress);
}
}
rafId = window.requestAnimationFrame(updateLoop);
};
rafId = window.requestAnimationFrame(updateLoop);
return () => {
if (rafId !== null) {
window.cancelAnimationFrame(rafId);
}
};
}, [
currentIndex,
failedAnimatedMap,
images,
isPlaying,
onAnimatedProgress,
]);
const getVideoRefCallback = useCallback(
(imageId: string): ((el: HTMLVideoElement | null) => void) => {
const existing = videoRefCallbacks.current.get(imageId);
if (existing) return existing;
const callback = (el: HTMLVideoElement | null): void => {
if (el) {
el.playbackRate = playbackRateRef.current;
videoRefs.current.set(imageId, el);
if (Number.isFinite(el.duration) && el.duration > 0) {
onAnimatedDuration(imageId, Math.round(el.duration * 1000));
}
} else {
const prevEl = videoRefs.current.get(imageId);
if (prevEl) {
releaseMediaElement(prevEl);
videoRefs.current.delete(imageId);
}
videoRefCallbacks.current.delete(imageId);
}
};
videoRefCallbacks.current.set(imageId, callback);
return callback;
},
[onAnimatedDuration],
);
const handleImgRef = useCallback(
(el: HTMLImageElement | null, imageId: string): void => {
if (el?.complete) {
onMediaReady(imageId);
}
},
[onMediaReady],
);
const mediaStyle = useMemo(
() => ({
transform:
rotation % 180 === 0
? `rotate(${rotation}deg)`
: `rotate(${rotation}deg) scale(0.85)`,
objectFit,
}),
[objectFit, rotation],
);
return (
<div ref={ref} className="relative h-full w-full overflow-hidden">
<div className="relative h-full w-full">
{visibleIndices.map((i) => {
const img = images[i];
if (!img) return null;
const isCurrent = i === currentIndex;
const canShowAnimated =
isCurrent &&
Boolean(img.animated) &&
!failedAnimatedMap[img.id];
const animatedSrc = canShowAnimated ? img.animated : null;
return ( return (
<div
ref={ref}
className="flex h-full w-full snap-x snap-mandatory overflow-x-auto overflow-y-hidden [&::-webkit-scrollbar]:hidden [-ms-overflow-style:none] [scrollbar-width:none]"
>
{images.map((img, i) => (
<div <div
key={img.id} key={img.id}
className="relative h-full min-w-full snap-center flex items-center justify-center bg-black/70 cursor-pointer" data-active={isCurrent ? "true" : undefined}
className="absolute inset-0 h-full w-full flex items-center justify-center bg-black/70 cursor-pointer"
style={{
transform: `translateX(${(i - currentIndex) * 100}%)`,
transition: "transform 240ms ease-out",
zIndex: isCurrent ? 1 : 0,
}}
onClick={onTogglePlay} onClick={onTogglePlay}
>{ >
img.animated ? <video src={img.animated} autoPlay muted playsInline className="max-w-full max-h-full object-contain" /> : <img {animatedSrc ? (
src={img.url} <video
alt={`image-${i + 1}`} ref={getVideoRefCallback(img.id)}
className="max-w-full max-h-full object-contain" src={animatedSrc}
poster={img.url}
muted
playsInline
autoPlay={isPlaying}
loop={shouldLoopCurrentVideo}
preload="auto"
controls={false}
disablePictureInPicture
className="max-w-full max-h-full"
style={{ style={{
width: img.width ? `${img.width}px` : undefined, width: img.width ? `${img.width}px` : undefined,
height: img.height ? `${img.height}px` : undefined, height: img.height ? `${img.height}px` : undefined,
...mediaStyle,
}} }}
/> onLoadedMetadata={(e) => {
e.currentTarget.playbackRate = playbackRateRef.current;
const duration = e.currentTarget.duration;
if (Number.isFinite(duration) && duration > 0) {
onAnimatedDuration(
img.id,
Math.round(duration * 1000),
);
if (
pendingSeekRef.current &&
pendingSeekRef.current.imageId === img.id
) {
const applied = seekVideoToProgress(
e.currentTarget,
pendingSeekRef.current.progress,
);
if (applied) {
pendingSeekRef.current = null;
} }
</div> }
))} }
</div> }}
onLoadedData={() => {
onMediaReady(img.id);
}}
onCanPlay={(e) => {
onMediaReady(img.id);
if (
pendingSeekRef.current &&
pendingSeekRef.current.imageId === img.id
) {
const applied = seekVideoToProgress(
e.currentTarget,
pendingSeekRef.current.progress,
);
if (applied) {
pendingSeekRef.current = null;
}
}
if (isCurrent && isPlaying && e.currentTarget.paused) {
tryPlayVideo(e.currentTarget, img.id);
}
}}
onTimeUpdate={(e) => {
const duration = e.currentTarget.duration;
if (Number.isFinite(duration) && duration > 0) {
onAnimatedProgress(
img.id,
Math.max(
0,
Math.min(
1,
e.currentTarget.currentTime / duration,
),
),
); );
} }
}}
onEnded={(e) => {
if (shouldLoopCurrentVideo) {
try {
e.currentTarget.currentTime = 0;
} catch {}
if (isPlaying) {
tryPlayVideo(e.currentTarget, img.id);
}
onAnimatedEnded(img.id);
return;
}
e.currentTarget.pause();
onAnimatedEnded(img.id);
}}
onError={() => {
onAnimatedFailed(img.id);
}}
/>
) : (
<img
ref={(el) => handleImgRef(el, img.id)}
src={img.url}
alt={`image-${i + 1}`}
loading="eager"
decoding="async"
onLoad={() => onMediaReady(img.id)}
onError={() => onMediaReady(img.id)}
className="max-w-full max-h-full"
style={{
width: img.width ? `${img.width}px` : undefined,
height: img.height ? `${img.height}px` : undefined,
...mediaStyle,
}}
/>
)}
</div>
);
})}
</div>
</div>
);
},
),
); );
ImageCarousel.displayName = "ImageCarousel"; ImageCarousel.displayName = "ImageCarousel";
@@ -1,4 +1,5 @@
import { ChevronLeft, ChevronRight } from "lucide-react"; import { ChevronLeft, ChevronRight } from "lucide-react";
import { memo } from "react";
interface ImageNavigationButtonsProps { interface ImageNavigationButtonsProps {
onPrev: () => void; onPrev: () => void;
@@ -7,7 +8,7 @@ interface ImageNavigationButtonsProps {
totalImages: number; totalImages: number;
} }
export function ImageNavigationButtons({ export const ImageNavigationButtons = memo(function ImageNavigationButtons({
onPrev, onPrev,
onNext, onNext,
currentIndex, currentIndex,
@@ -20,13 +21,14 @@ export function ImageNavigationButtons({
<> <>
{/* 左侧按钮 */} {/* 左侧按钮 */}
<button <button
type="button"
className={` className={`
absolute left-4 top-7/16 -translate-y-1/2 z-20 absolute left-4 top-6/17 -translate-y-1/2 z-20
w-12 h-12 rounded-full w-12 h-12 rounded-full
bg-black/40 backdrop-blur-sm border border-white/20 bg-black/55 border border-white/20
flex items-center justify-center flex items-center justify-center
text-white transition-all text-white transition-opacity
${hasPrev ? "opacity-100 hover:bg-black/60 cursor-pointer" : "opacity-30 cursor-not-allowed"} ${hasPrev ? "opacity-100 hover:bg-black/70 cursor-pointer" : "opacity-30 cursor-not-allowed"}
`} `}
onClick={onPrev} onClick={onPrev}
disabled={!hasPrev} disabled={!hasPrev}
@@ -37,13 +39,14 @@ export function ImageNavigationButtons({
{/* 右侧按钮 */} {/* 右侧按钮 */}
<button <button
type="button"
className={` className={`
absolute right-4 top-7/16 -translate-y-1/2 z-20 absolute right-4 top-6/17 -translate-y-1/2 z-20
w-12 h-12 rounded-full w-12 h-12 rounded-full
bg-black/40 backdrop-blur-sm border border-white/20 bg-black/55 border border-white/20
flex items-center justify-center flex items-center justify-center
text-white transition-all text-white transition-opacity
${hasNext ? "opacity-100 hover:bg-black/60 cursor-pointer" : "opacity-30 cursor-not-allowed"} ${hasNext ? "opacity-100 hover:bg-black/70 cursor-pointer" : "opacity-30 cursor-not-allowed"}
`} `}
onClick={onNext} onClick={onNext}
disabled={!hasNext} disabled={!hasNext}
@@ -53,4 +56,4 @@ export function ImageNavigationButtons({
</button> </button>
</> </>
); );
} });
+278 -37
View File
@@ -1,21 +1,26 @@
import { import {
ArrowDownUp, ArrowDownUp,
Download, Download,
FileText,
Maximize, Maximize,
Maximize2, Maximize2,
Minimize, Minimize,
Minimize2, Minimize2,
Pause, Pause,
Play, Play,
Repeat,
Repeat1, Repeat1,
RotateCcw, RotateCcw,
RotateCw, RotateCw,
Volume2, Volume2,
VolumeX, VolumeX,
} from "lucide-react"; } from "lucide-react";
import type { RefObject } from "react"; import Link from "next/link";
import type { LoopMode, ObjectFit, User } from "../types"; import { memo, type ReactNode, type RefObject, useMemo } from "react";
import { formatRelativeTime, formatTime, formatAbsoluteUTC } from "../utils"; import { parseDescHashtags } from "@/lib/hashtag";
import type { AbLoop, LoopMode, ObjectFit, User } from "../types.ts";
import { formatAbsoluteUTC, formatRelativeTime, formatTime } from "../utils";
import { MoreMenu, MoreMenuItem } from "./MoreMenu";
import { ProgressBar } from "./ProgressBar"; import { ProgressBar } from "./ProgressBar";
import { SegmentedProgressBar } from "./SegmentedProgressBar"; import { SegmentedProgressBar } from "./SegmentedProgressBar";
@@ -34,12 +39,17 @@ interface MediaControlsProps {
desc: string; desc: string;
// 视频专用 // 视频专用
videoRef?: RefObject<HTMLVideoElement | null>; videoRef?: RefObject<HTMLVideoElement | null>;
abLoop?: AbLoop;
onClearAbLoop?: () => void;
// 图文专用 // 图文专用
currentIndex?: number; currentIndex?: number;
totalSegments?: number; totalSegments?: number;
segmentProgress?: number; segmentProgress?: number;
musicUrl?: string | null; musicUrl?: string | null;
audioRef?: RefObject<HTMLAudioElement | null>; audioRef?: RefObject<HTMLAudioElement | null>;
// 转录相关
hasTranscript?: boolean;
onShowTranscript?: () => void;
// 回调 // 回调
onTogglePlay: () => void; onTogglePlay: () => void;
onSeek: (ratio: number) => void; onSeek: (ratio: number) => void;
@@ -50,9 +60,16 @@ interface MediaControlsProps {
onLoopModeChange: (mode: LoopMode) => void; onLoopModeChange: (mode: LoopMode) => void;
onDownload: () => void; onDownload: () => void;
onToggleFullscreen: () => void; onToggleFullscreen: () => void;
/** 点作者头像/名字:在侧栏打开「TA的作品」 */
onAuthorClick?: () => void;
/** 点描述里的 hashtag:切到该 hashtag 的作品列表 */
onHashtagClick?: (name: string) => void;
} }
export function MediaControls({ const CONTROL_BTN_CLASS =
"w-[34px] h-[34px] inline-flex items-center justify-center rounded-full bg-white/15 text-white border border-white/20 cursor-pointer hover:bg-white/25 transition-colors";
export const MediaControls = memo(function MediaControls({
isVideo, isVideo,
isPlaying, isPlaying,
progress, progress,
@@ -66,11 +83,15 @@ export function MediaControls({
createdAt, createdAt,
desc, desc,
videoRef, videoRef,
abLoop,
onClearAbLoop,
currentIndex = 0, currentIndex = 0,
totalSegments = 0, totalSegments = 0,
segmentProgress = 0, segmentProgress = 0,
musicUrl, musicUrl,
audioRef, audioRef,
hasTranscript = false,
onShowTranscript,
onTogglePlay, onTogglePlay,
onSeek, onSeek,
onVolumeChange, onVolumeChange,
@@ -80,14 +101,93 @@ export function MediaControls({
onLoopModeChange, onLoopModeChange,
onDownload, onDownload,
onToggleFullscreen, onToggleFullscreen,
onAuthorClick,
onHashtagClick,
}: MediaControlsProps) { }: MediaControlsProps) {
const descParts = useMemo(() => parseDescHashtags(desc || ""), [desc]);
const normalizedLoopMode =
isVideo && loopMode === "single" ? "loop" : loopMode;
const loopLabel = isVideo
? normalizedLoopMode === "loop"
? "循环播放"
: "顺序播放"
: loopMode === "single"
? "单页循环"
: loopMode === "loop"
? "图文循环"
: "顺序播放";
const handleLoopModeToggle = (): void => {
if (isVideo) {
onLoopModeChange(normalizedLoopMode === "loop" ? "sequential" : "loop");
return;
}
onLoopModeChange(
loopMode === "loop"
? "single"
: loopMode === "single"
? "sequential"
: "loop",
);
};
const renderLoopIcon = (): ReactNode => {
if (!isVideo && loopMode === "single") return <Repeat1 size={18} />;
if (normalizedLoopMode === "loop") return <Repeat size={18} />;
return <ArrowDownUp size={18} />;
};
return ( return (
<div className="absolute left-0 right-0 bottom-0 px-3 pb-4 pt-2 bg-gradient-to-b from-black/0 via-black/45 to-black/65 flex flex-col gap-2.5"> <div
data-no-swipe
className="absolute left-0 right-0 bottom-0 z-40 px-3 pb-4 pt-2 bg-gradient-to-b from-black/0 via-black/45 to-black/65 flex flex-col gap-2.5"
>
{/* 描述行 */} {/* 描述行 */}
<div className="pointer-events-none flex items-center gap-2.5 mb-1"> <div className="flex items-center gap-2.5 mb-1 pointer-events-none">
<img src={author.avatar_url!} alt="" className="w-8 h-8 rounded-full" /> {author.sec_uid && onAuthorClick ? (
<span className="text-[15px] leading-tight text-white/95 drop-shadow">{author.nickname}</span> <button
<span className="text-[13px] leading-tight text-white/95 drop-shadow">·</span> type="button"
onClick={onAuthorClick}
className="flex items-center gap-2.5 pointer-events-auto hover:opacity-80 transition-opacity cursor-pointer"
title="查看TA的作品"
>
<img
src={author.avatar_url ?? ""}
alt=""
className="w-8 h-8 rounded-full"
/>
<span className="text-[15px] leading-tight text-white/95 drop-shadow font-medium">
{author.nickname}
</span>
</button>
) : author.sec_uid ? (
<Link
href={`/author/${author.sec_uid}`}
className="flex items-center gap-2.5 pointer-events-auto hover:opacity-80 transition-opacity"
>
<img
src={author.avatar_url ?? ""}
alt=""
className="w-8 h-8 rounded-full"
/>
<span className="text-[15px] leading-tight text-white/95 drop-shadow font-medium">
{author.nickname}
</span>
</Link>
) : (
<div className="flex items-center gap-2.5">
<img
src={author.avatar_url ?? ""}
alt=""
className="w-8 h-8 rounded-full"
/>
<span className="text-[15px] leading-tight text-white/95 drop-shadow">
{author.nickname}
</span>
</div>
)}
<span className="text-[13px] leading-tight text-white/95 drop-shadow">
·
</span>
<span <span
className="text-[11px] leading-tight text-white/95 drop-shadow" className="text-[11px] leading-tight text-white/95 drop-shadow"
title={formatAbsoluteUTC(createdAt)} title={formatAbsoluteUTC(createdAt)}
@@ -99,7 +199,23 @@ export function MediaControls({
{desc ? ( {desc ? (
<div className="pointer-events-none"> <div className="pointer-events-none">
<p className="text-[13px] leading-tight text-white/95 max-h-[3.9em] overflow-hidden [display:-webkit-box] [-webkit-line-clamp:3] [-webkit-box-orient:vertical] drop-shadow"> <p className="text-[13px] leading-tight text-white/95 max-h-[3.9em] overflow-hidden [display:-webkit-box] [-webkit-line-clamp:3] [-webkit-box-orient:vertical] drop-shadow">
{desc} {descParts.map((part, i) =>
part.type === "hashtag" && onHashtagClick ? (
<button
// biome-ignore lint/suspicious/noArrayIndexKey: 描述片段顺序固定
key={i}
type="button"
onClick={() => onHashtagClick(part.name)}
className="pointer-events-auto font-semibold text-white hover:underline cursor-pointer"
title={`查看所有带 #${part.name} 的作品`}
>
{part.text}
</button>
) : (
// biome-ignore lint/suspicious/noArrayIndexKey: 描述片段顺序固定
<span key={i}>{part.text}</span>
),
)}
</p> </p>
</div> </div>
) : null} ) : null}
@@ -113,7 +229,12 @@ export function MediaControls({
onSeek={onSeek} onSeek={onSeek}
/> />
) : ( ) : (
<ProgressBar progress={progress} onSeek={onSeek} /> <ProgressBar
progress={progress}
onSeek={onSeek}
abLoop={isVideo ? abLoop : undefined}
duration={isVideo ? (videoRef?.current?.duration ?? 0) : 0}
/>
)} )}
{/* 控制按钮行 - 响应式布局 */} {/* 控制按钮行 - 响应式布局 */}
@@ -121,7 +242,8 @@ export function MediaControls({
{/* 左侧:播放控制 + 时间/进度 */} {/* 左侧:播放控制 + 时间/进度 */}
<div className="inline-flex items-center gap-1.5 sm:gap-2 min-w-0"> <div className="inline-flex items-center gap-1.5 sm:gap-2 min-w-0">
<button <button
className="w-[34px] h-[34px] shrink-0 inline-flex items-center justify-center rounded-full bg-white/15 text-white border border-white/20 backdrop-blur-sm cursor-pointer" type="button"
className={`${CONTROL_BTN_CLASS} shrink-0`}
onClick={onTogglePlay} onClick={onTogglePlay}
aria-label={isPlaying ? "暂停" : "播放"} aria-label={isPlaying ? "暂停" : "播放"}
> >
@@ -130,22 +252,23 @@ export function MediaControls({
{/* 播放进度显示 - 所有设备都显示 */} {/* 播放进度显示 - 所有设备都显示 */}
<div className="text-[13px] text-white/90 font-mono min-w-[70px] sm:min-w-[80px]"> <div className="text-[13px] text-white/90 font-mono min-w-[70px] sm:min-w-[80px]">
{isVideo ? ( {isVideo
(() => { ? (() => {
const v = videoRef?.current; const v = videoRef?.current;
const current = v?.currentTime ?? 0; const current = v?.currentTime ?? 0;
const total = v?.duration ?? 0; const total = v?.duration ?? 0;
return total > 0 ? `${formatTime(current)} / ${formatTime(total)}` : "--:-- / --:--"; return total > 0
? `${formatTime(current)} / ${formatTime(total)}`
: "--:-- / --:--";
})() })()
) : ( : `${currentIndex + 1} / ${totalSegments}`}
`${currentIndex + 1} / ${totalSegments}`
)}
</div> </div>
{/* 倍速 - 中等屏幕以上显示,仅视频 */} {/* 倍速 - 中等屏幕以上显示,仅视频 */}
{isVideo && ( {isVideo && (
<button <button
className="hidden md:block h-[30px] px-[10px] rounded-full text-[12px] bg-white/15 text-white border border-white/20 backdrop-blur-sm cursor-pointer shrink-0" type="button"
className="hidden md:block h-[30px] px-[10px] rounded-full text-[12px] bg-white/15 text-white border border-white/20 cursor-pointer shrink-0 hover:bg-white/25 transition-colors"
onClick={() => { onClick={() => {
const steps = [1, 1.25, 1.5, 2, 0.75, 0.5]; const steps = [1, 1.25, 1.5, 2, 0.75, 0.5];
const i = steps.indexOf(rate); const i = steps.indexOf(rate);
@@ -157,13 +280,35 @@ export function MediaControls({
{rate}x {rate}x
</button> </button>
)} )}
{/* AB 循环状态提示与快捷清除 */}
{isVideo && abLoop && (abLoop.a !== null || abLoop.b !== null) && (
<button
type="button"
onClick={onClearAbLoop}
className="h-[30px] px-2.5 rounded-full text-[11px] font-mono bg-amber-400/20 text-amber-300 border border-amber-400/50 cursor-pointer shrink-0 hover:bg-amber-400/30 transition-colors inline-flex items-center gap-1"
title="点击清除 AB 循环 (快捷键 \)"
aria-label="清除 AB 循环"
>
<span>
AB {formatTime(abLoop.a ?? 0)}-
{abLoop.b !== null
? formatTime(abLoop.b)
: videoRef?.current?.duration
? formatTime(videoRef.current.duration)
: "末"}
</span>
<span className="text-amber-200/80 text-xs leading-none">×</span>
</button>
)}
</div> </div>
{/* 中间:音量控制 - 中等屏幕以上显示 */} {/* 中间:音量控制 - 中等屏幕以上显示 */}
<div className="hidden md:inline-flex items-center gap-2 shrink-0"> <div className="hidden md:inline-flex items-center gap-2 shrink-0">
{/* 旋转按钮 - 小屏幕以上显示 */} {/* 旋转按钮 - 小屏幕以上显示 */}
<button <button
className="hidden sm:inline-flex w-[34px] h-[34px] items-center justify-center rounded-full bg-white/15 text-white border border-white/20 backdrop-blur-sm cursor-pointer" type="button"
className={`hidden sm:inline-flex ${CONTROL_BTN_CLASS}`}
onClick={() => onRotationChange((rotation + 270) % 360)} onClick={() => onRotationChange((rotation + 270) % 360)}
aria-label="向左旋转 90 度" aria-label="向左旋转 90 度"
title="向左旋转 90 度" title="向左旋转 90 度"
@@ -171,7 +316,8 @@ export function MediaControls({
<RotateCcw size={18} /> <RotateCcw size={18} />
</button> </button>
<button <button
className="w-[34px] h-[34px] inline-flex items-center justify-center rounded-full bg-white/15 text-white border border-white/20 backdrop-blur-sm cursor-pointer" type="button"
className={CONTROL_BTN_CLASS}
onClick={() => onVolumeChange(volume > 0 ? 0 : 1)} onClick={() => onVolumeChange(volume > 0 ? 0 : 1)}
aria-label={volume > 0 ? "静音" : "取消静音"} aria-label={volume > 0 ? "静音" : "取消静音"}
> >
@@ -188,7 +334,8 @@ export function MediaControls({
aria-label="音量" aria-label="音量"
/> />
<button <button
className="hidden sm:inline-flex w-[34px] h-[34px] items-center justify-center rounded-full bg-white/15 text-white border border-white/20 backdrop-blur-sm cursor-pointer" type="button"
className={`hidden sm:inline-flex ${CONTROL_BTN_CLASS}`}
onClick={() => onRotationChange((rotation + 90) % 360)} onClick={() => onRotationChange((rotation + 90) % 360)}
aria-label="向右旋转 90 度" aria-label="向右旋转 90 度"
title="向右旋转 90 度" title="向右旋转 90 度"
@@ -201,7 +348,8 @@ export function MediaControls({
<div className="inline-flex items-center gap-1 sm:gap-1.5 lg:gap-2 shrink-0"> <div className="inline-flex items-center gap-1 sm:gap-1.5 lg:gap-2 shrink-0">
{/* 音量按钮 - 仅在小屏幕显示(中等屏幕以上有滑块) */} {/* 音量按钮 - 仅在小屏幕显示(中等屏幕以上有滑块) */}
<button <button
className="md:hidden w-[34px] h-[34px] inline-flex items-center justify-center rounded-full bg-white/15 text-white border border-white/20 backdrop-blur-sm cursor-pointer" type="button"
className={`md:hidden ${CONTROL_BTN_CLASS}`}
onClick={() => onVolumeChange(volume > 0 ? 0 : 1)} onClick={() => onVolumeChange(volume > 0 ? 0 : 1)}
aria-label={volume > 0 ? "静音" : "取消静音"} aria-label={volume > 0 ? "静音" : "取消静音"}
> >
@@ -210,27 +358,53 @@ export function MediaControls({
{/* 循环模式 - 中等屏幕以上显示 */} {/* 循环模式 - 中等屏幕以上显示 */}
<button <button
className="hidden md:inline-flex w-[34px] h-[34px] items-center justify-center rounded-full bg-white/15 text-white border border-white/20 backdrop-blur-sm cursor-pointer" type="button"
onClick={() => onLoopModeChange(loopMode === "loop" ? "sequential" : "loop")} className={`hidden md:inline-flex ${CONTROL_BTN_CLASS}`}
aria-label={loopMode === "loop" ? "循环播放" : "顺序播放"} onClick={handleLoopModeToggle}
title={loopMode === "loop" ? "循环播放" : "顺序播放"} aria-label={loopLabel}
title={loopLabel}
> >
{loopMode === "loop" ? <Repeat1 size={18} /> : <ArrowDownUp size={18} />} {renderLoopIcon()}
</button> </button>
{/* 适配模式 - 小屏幕以上显示 */} {/* 适配模式 - 小屏幕以上显示 */}
<button <button
className="hidden sm:inline-flex w-[34px] h-[34px] items-center justify-center rounded-full bg-white/15 text-white border border-white/20 backdrop-blur-sm cursor-pointer" type="button"
onClick={() => onObjectFitChange(objectFit === "contain" ? "cover" : "contain")} className={`hidden sm:inline-flex ${CONTROL_BTN_CLASS}`}
aria-label={objectFit === "contain" ? "切换到填充模式" : "切换到适应模式"} onClick={() =>
title={objectFit === "contain" ? "切换到填充模式" : "切换到适应模式"} onObjectFitChange(objectFit === "contain" ? "cover" : "contain")
}
aria-label={
objectFit === "contain" ? "切换到填充模式" : "切换到适应模式"
}
title={
objectFit === "contain" ? "切换到填充模式" : "切换到适应模式"
}
> >
{objectFit === "contain" ? <Maximize2 size={18} /> : <Minimize size={18} />} {objectFit === "contain" ? (
<Maximize2 size={18} />
) : (
<Minimize size={18} />
)}
</button> </button>
{/* 转录文本 - 仅视频且有转录时显示,中等屏幕以上 */}
{isVideo && hasTranscript && onShowTranscript && (
<button
type="button"
className={`hidden md:inline-flex ${CONTROL_BTN_CLASS}`}
onClick={onShowTranscript}
aria-label="显示转录文本"
title="显示转录文本"
>
<FileText size={18} />
</button>
)}
{/* 下载 - 中等屏幕以上显示 */} {/* 下载 - 中等屏幕以上显示 */}
<button <button
className="hidden md:inline-flex w-[34px] h-[34px] items-center justify-center rounded-full bg-white/15 text-white border border-white/20 backdrop-blur-sm cursor-pointer" type="button"
className={`hidden md:inline-flex ${CONTROL_BTN_CLASS}`}
onClick={onDownload} onClick={onDownload}
aria-label={isVideo ? "下载视频" : "下载当前图片"} aria-label={isVideo ? "下载视频" : "下载当前图片"}
title={isVideo ? "下载视频" : "下载当前图片"} title={isVideo ? "下载视频" : "下载当前图片"}
@@ -238,9 +412,74 @@ export function MediaControls({
<Download size={18} /> <Download size={18} />
</button> </button>
{/* 更多菜单 - 只在有按钮被折叠时显示 */}
<div className="sm:block md:hidden">
<MoreMenu>
<div className="sm:hidden">
<MoreMenuItem
icon={
objectFit === "contain" ? (
<Maximize2 size={18} />
) : (
<Minimize size={18} />
)
}
label={objectFit === "contain" ? "填充模式" : "适应模式"}
onClick={() =>
onObjectFitChange(
objectFit === "contain" ? "cover" : "contain",
)
}
/>
</div>
<div className="md:hidden">
<MoreMenuItem
icon={renderLoopIcon()}
label={loopLabel}
onClick={handleLoopModeToggle}
/>
</div>
{isVideo && hasTranscript && onShowTranscript && (
<div className="md:hidden">
<MoreMenuItem
icon={<FileText size={18} />}
label="显示转录"
onClick={onShowTranscript}
/>
</div>
)}
<div className="md:hidden">
<MoreMenuItem
icon={<Download size={18} />}
label={isVideo ? "下载视频" : "下载图片"}
onClick={onDownload}
/>
</div>
{isVideo && (
<div className="md:hidden">
<MoreMenuItem
icon={<span className="text-sm font-mono">{rate}x</span>}
label="播放速度"
onClick={() => {
const steps = [1, 1.25, 1.5, 2, 0.75, 0.5];
const i = steps.indexOf(rate);
const next = steps[(i + 1) % steps.length];
onRateChange(next);
}}
/>
</div>
)}
</MoreMenu>
</div>
{/* 全屏 - 所有设备都显示 */} {/* 全屏 - 所有设备都显示 */}
<button <button
className="w-[34px] h-[34px] inline-flex items-center justify-center rounded-full bg-white/15 text-white border border-white/20 backdrop-blur-sm cursor-pointer" type="button"
className={CONTROL_BTN_CLASS}
onClick={onToggleFullscreen} onClick={onToggleFullscreen}
aria-label="切换全屏" aria-label="切换全屏"
> >
@@ -250,7 +489,9 @@ export function MediaControls({
</div> </div>
{/* 图文 BGM(隐藏控件,仅用于播放) */} {/* 图文 BGM(隐藏控件,仅用于播放) */}
{!isVideo && musicUrl ? <audio ref={audioRef} src={musicUrl} loop preload="metadata" /> : null} {!isVideo && musicUrl ? (
<audio ref={audioRef} src={musicUrl} loop preload="metadata" />
) : null}
</div> </div>
); );
} });
@@ -0,0 +1,77 @@
"use client";
import { MoreVertical, X } from "lucide-react";
import { useEffect, useRef, useState } from "react";
interface MoreMenuProps {
children: React.ReactNode;
}
export function MoreMenu({ children }: MoreMenuProps) {
const [isOpen, setIsOpen] = useState(false);
const menuRef = useRef<HTMLDivElement>(null);
const buttonRef = useRef<HTMLButtonElement>(null);
// 点击外部关闭菜单
useEffect(() => {
if (!isOpen) return;
const handleClickOutside = (event: MouseEvent) => {
if (
menuRef.current &&
buttonRef.current &&
!menuRef.current.contains(event.target as Node) &&
!buttonRef.current.contains(event.target as Node)
) {
setIsOpen(false);
}
};
document.addEventListener("mousedown", handleClickOutside);
return () => document.removeEventListener("mousedown", handleClickOutside);
}, [isOpen]);
return (
<div className="relative">
{/* 更多按钮 */}
<button
ref={buttonRef}
className="w-[34px] h-[34px] inline-flex items-center justify-center rounded-full bg-white/15 text-white border border-white/20 backdrop-blur-sm cursor-pointer"
onClick={() => setIsOpen(!isOpen)}
aria-label="更多选项"
title="更多选项"
>
{isOpen ? <X size={18} /> : <MoreVertical size={18} />}
</button>
{/* 弹出菜单 */}
{isOpen && (
<div
ref={menuRef}
className="absolute bottom-full right-0 mb-2 bg-zinc-900/95 backdrop-blur-xl border border-white/20 rounded-xl shadow-2xl overflow-hidden animate-in fade-in slide-in-from-bottom-2 duration-200"
style={{ minWidth: "200px" }}
>
<div className="p-2 flex flex-col gap-1">{children}</div>
</div>
)}
</div>
);
}
interface MoreMenuItemProps {
icon: React.ReactNode;
label: string;
onClick: () => void;
}
export function MoreMenuItem({ icon, label, onClick }: MoreMenuItemProps) {
return (
<button
className="flex items-center gap-3 px-3 py-2.5 text-white/90 hover:bg-white/10 rounded-lg transition-colors text-left w-full"
onClick={onClick}
>
<span className="flex-shrink-0">{icon}</span>
<span className="text-sm">{label}</span>
</button>
);
}
@@ -1,43 +1,72 @@
import { ChevronDown, ChevronUp, MessageSquareText } from "lucide-react"; import {
import type { Neighbors } from "../types"; ChevronDown,
ChevronUp,
MessageSquareText,
ThumbsUp,
} from "lucide-react";
import { memo } from "react";
import type { Neighbors } from "../types.ts";
interface NavigationButtonsProps { interface NavigationButtonsProps {
neighbors: Neighbors; neighbors: Neighbors;
commentsCount: number; commentsCount: number;
likesCount: number;
onNavigatePrev: () => void; onNavigatePrev: () => void;
onNavigateNext: () => void; onNavigateNext: () => void;
onToggleComments: () => void; onToggleComments: () => void;
} }
export function NavigationButtons({ export const NavigationButtons = memo(function NavigationButtons({
neighbors, neighbors,
commentsCount, commentsCount,
likesCount,
onNavigatePrev, onNavigatePrev,
onNavigateNext, onNavigateNext,
onToggleComments, onToggleComments,
}: NavigationButtonsProps) { }: NavigationButtonsProps) {
return ( return (
<> <>
<div className="absolute right-4 top-8/14 flex flex-col items-center gap-8 z-10">
<button
type="button"
className="grid place-items-center w-[54px] h-[54px] rounded-full -translate-y-1/2"
>
<div className="grid place-items-center gap-1 drop-shadow-lg">
<ThumbsUp size={40} className="" />
<span className="text-[16px] font-semibold text-white/90 drop-shadow">
{likesCount}
</span>
</div>
</button>
{/* 评论开关(右侧中部) */} {/* 评论开关(右侧中部) */}
<button <button
className="z-10 grid place-items-center w-[54px] h-[54px] rounded-full absolute right-4 top-2/3 -translate-y-1/2 cursor-pointer" type="button"
className="grid place-items-center w-[54px] h-[54px] rounded-full -translate-y-1/2 cursor-pointer"
onClick={onToggleComments} onClick={onToggleComments}
aria-label="切换评论" aria-label="切换评论"
> >
<div className="grid place-items-center gap-1 drop-shadow-lg"> <div className="grid place-items-center gap-1 drop-shadow-lg">
<MessageSquareText size={40} className="" /> <MessageSquareText size={40} className="" />
{commentsCount > 0 ? <span className="text-[18px] font-semibold text-white/90 drop-shadow">{commentsCount}</span> : {commentsCount > 0 ? (
<span className="text-[12px] font-semibold text-white/90 drop-shadow">暂无评论</span> <span className="text-[16px] font-semibold text-white/90 drop-shadow">
} {commentsCount}
</span>
) : (
<span className="text-[12px] font-semibold text-white/90 drop-shadow">
暂无评论
</span>
)}
</div> </div>
</button> </button>
</div>
{/* 上下切换按钮(右侧胶囊形状) */} {/* 上下切换按钮(右侧胶囊形状) */}
<div className="absolute right-4 top-6/11 -translate-y-1/2 z-10"> <div className="absolute right-4 top-5/11 -translate-y-1/2 z-10">
<div className="flex flex-col rounded-full bg-black/40 backdrop-blur-sm border border-white/20 overflow-hidden"> <div className="flex flex-col rounded-full bg-black/55 border border-white/20 overflow-hidden">
<button <button
type="button"
className="w-[44px] h-[44px] inline-flex items-center justify-center text-white disabled:opacity-40 cursor-pointer disabled:cursor-not-allowed hover:bg-white/10 transition-colors" className="w-[44px] h-[44px] inline-flex items-center justify-center text-white disabled:opacity-40 cursor-pointer disabled:cursor-not-allowed hover:bg-white/10 transition-colors"
onClick={onNavigatePrev} onClick={onNavigatePrev}
disabled={!neighbors.prev} disabled={!neighbors.prev}
@@ -46,6 +75,7 @@ export function NavigationButtons({
<ChevronUp size={24} /> <ChevronUp size={24} />
</button> </button>
<button <button
type="button"
className="w-[44px] h-[44px] inline-flex items-center justify-center text-white disabled:opacity-40 cursor-pointer disabled:cursor-not-allowed hover:bg-white/10 transition-colors border-t border-white/20" className="w-[44px] h-[44px] inline-flex items-center justify-center text-white disabled:opacity-40 cursor-pointer disabled:cursor-not-allowed hover:bg-white/10 transition-colors border-t border-white/20"
onClick={onNavigateNext} onClick={onNavigateNext}
disabled={!neighbors.next} disabled={!neighbors.next}
@@ -57,4 +87,4 @@ export function NavigationButtons({
</div> </div>
</> </>
); );
} });
+108 -6
View File
@@ -1,18 +1,120 @@
import { memo } from "react";
import type { AbLoop } from "../types.ts";
interface ProgressBarProps { interface ProgressBarProps {
progress: number; progress: number;
onSeek: (ratio: number) => void; onSeek: (ratio: number) => void;
abLoop?: AbLoop;
duration?: number;
} }
export function ProgressBar({ progress, onSeek }: ProgressBarProps) { export const ProgressBar = memo(function ProgressBar({
progress,
onSeek,
abLoop,
duration = 0,
}: ProgressBarProps) {
const seekFromClientX = (clientX: number, element: HTMLElement): void => {
const rect = element.getBoundingClientRect();
if (rect.width <= 0) return;
onSeek((clientX - rect.left) / rect.width);
};
const hasAbLoop = Boolean(
abLoop && (abLoop.a !== null || abLoop.b !== null) && duration > 0,
);
const startRatio =
hasAbLoop && abLoop
? Math.max(0, Math.min(1, (abLoop.a ?? 0) / duration))
: 0;
const endRatio =
hasAbLoop && abLoop
? Math.max(startRatio, Math.min(1, (abLoop.b ?? duration) / duration))
: 1;
return ( return (
<div <div
className="relative h-1.5 rounded-full bg-white/25 overflow-hidden cursor-pointer" className="relative h-1.5 cursor-pointer touch-none select-none"
onClick={(e) => { onClick={(e) => {
const rect = (e.currentTarget as HTMLElement).getBoundingClientRect(); seekFromClientX(e.clientX, e.currentTarget as HTMLElement);
onSeek((e.clientX - rect.left) / rect.width); }}
onPointerDown={(e) => {
if (e.pointerType === "mouse" && e.button !== 0) return;
e.currentTarget.setPointerCapture(e.pointerId);
seekFromClientX(e.clientX, e.currentTarget);
}}
onPointerMove={(e) => {
if (!e.currentTarget.hasPointerCapture(e.pointerId)) return;
seekFromClientX(e.clientX, e.currentTarget);
}}
onPointerUp={(e) => {
if (e.currentTarget.hasPointerCapture(e.pointerId)) {
e.currentTarget.releasePointerCapture(e.pointerId);
}
}}
onPointerCancel={(e) => {
if (e.currentTarget.hasPointerCapture(e.pointerId)) {
e.currentTarget.releasePointerCapture(e.pointerId);
}
}} }}
> >
<div className="origin-left h-full bg-white" style={{ transform: `scaleX(${progress || 0})` }} /> {/* 轨道主体 */}
<div className="relative h-full w-full rounded-full bg-white/25 overflow-hidden">
{/* AB 循环区间底色高亮 */}
{hasAbLoop && (
<div
className="absolute top-0 bottom-0 bg-amber-400/40"
style={{
left: `${startRatio * 100}%`,
width: `${Math.max(0, endRatio - startRatio) * 100}%`,
}}
/>
)}
{/* 播放进度填充 */}
<div
className={`origin-left h-full will-change-transform ${
hasAbLoop ? "bg-amber-300" : "bg-white"
}`}
style={{ transform: `scaleX(${progress || 0})` }}
/>
{/* AB 循环区间顶部与底部亮边 */}
{hasAbLoop && (
<div
className="pointer-events-none absolute top-0 bottom-0 border-y border-amber-300/90 bg-amber-300/20"
style={{
left: `${startRatio * 100}%`,
width: `${Math.max(0, endRatio - startRatio) * 100}%`,
}}
/>
)}
</div>
{/* A / B 端点指示标记 */}
{hasAbLoop && (
<>
<div
className="pointer-events-none absolute top-1/2 -translate-y-1/2 -translate-x-1/2 flex flex-col items-center"
style={{ left: `${startRatio * 100}%` }}
>
<span className="mb-2.5 rounded bg-amber-400 px-1 py-[1px] text-[9px] font-bold leading-none text-black shadow">
A
</span>
<div className="h-3 w-[3px] rounded-full bg-amber-400 shadow-[0_0_4px_rgba(251,191,36,0.9)]" />
</div>
<div
className="pointer-events-none absolute top-1/2 -translate-y-1/2 -translate-x-1/2 flex flex-col items-center"
style={{ left: `${endRatio * 100}%` }}
>
<span className="mb-2.5 rounded bg-amber-400 px-1 py-[1px] text-[9px] font-bold leading-none text-black shadow">
B
</span>
<div className="h-3 w-[3px] rounded-full bg-amber-400 shadow-[0_0_4px_rgba(251,191,36,0.9)]" />
</div>
</>
)}
</div> </div>
); );
} });
@@ -1,3 +1,5 @@
import { memo } from "react";
interface SegmentedProgressBarProps { interface SegmentedProgressBarProps {
totalSegments: number; totalSegments: number;
currentIndex: number; currentIndex: number;
@@ -5,18 +7,64 @@ interface SegmentedProgressBarProps {
onSeek: (ratio: number) => void; onSeek: (ratio: number) => void;
} }
export function SegmentedProgressBar({ const SegmentBarItem = memo(function SegmentBarItem({
index,
fill,
}: {
index: number;
fill: number;
}) {
return (
<div
aria-label={`第 ${index + 1} 段`}
className="relative flex-1 h-full rounded-full bg-white/25 overflow-hidden"
>
<div
className="h-full origin-left bg-white will-change-transform"
style={{
transform: `scaleX(${Math.max(0, Math.min(1, fill))})`,
}}
/>
</div>
);
});
export const SegmentedProgressBar = memo(function SegmentedProgressBar({
totalSegments, totalSegments,
currentIndex, currentIndex,
segmentProgress, segmentProgress,
onSeek, onSeek,
}: SegmentedProgressBarProps) { }: SegmentedProgressBarProps) {
const seekFromClientX = (clientX: number, element: HTMLElement): void => {
const rect = element.getBoundingClientRect();
if (rect.width <= 0) return;
onSeek((clientX - rect.left) / rect.width);
};
return ( return (
<div <div
className="relative h-1.5 cursor-pointer" className="relative h-1.5 cursor-pointer touch-none"
onClick={(e) => { onClick={(e) => {
const rect = (e.currentTarget as HTMLElement).getBoundingClientRect(); seekFromClientX(e.clientX, e.currentTarget as HTMLElement);
onSeek((e.clientX - rect.left) / rect.width); }}
onPointerDown={(e) => {
if (e.pointerType === "mouse" && e.button !== 0) return;
e.currentTarget.setPointerCapture(e.pointerId);
seekFromClientX(e.clientX, e.currentTarget);
}}
onPointerMove={(e) => {
if (!e.currentTarget.hasPointerCapture(e.pointerId)) return;
seekFromClientX(e.clientX, e.currentTarget);
}}
onPointerUp={(e) => {
if (e.currentTarget.hasPointerCapture(e.pointerId)) {
e.currentTarget.releasePointerCapture(e.pointerId);
}
}}
onPointerCancel={(e) => {
if (e.currentTarget.hasPointerCapture(e.pointerId)) {
e.currentTarget.releasePointerCapture(e.pointerId);
}
}} }}
> >
<div className="flex gap-1.5 h-full"> <div className="flex gap-1.5 h-full">
@@ -24,17 +72,9 @@ export function SegmentedProgressBar({
let fill = 0; let fill = 0;
if (i < currentIndex) fill = 1; if (i < currentIndex) fill = 1;
else if (i === currentIndex) fill = segmentProgress; else if (i === currentIndex) fill = segmentProgress;
return ( return <SegmentBarItem key={`seg-${i}`} index={i} fill={fill} />;
<div
key={i}
aria-label={`第 ${i + 1} 段`}
className="relative flex-1 h-full rounded-full bg-white/25 overflow-hidden"
>
<div className="h-full origin-left bg-white" style={{ transform: `scaleX(${fill})` }} />
</div>
);
})} })}
</div> </div>
</div> </div>
); );
} });
@@ -0,0 +1,97 @@
import { X } from "lucide-react";
import { memo, type ReactNode } from "react";
import type { SidePanelTab } from "../hooks/useSidePanelState";
export type SidePanelTabConfig = {
key: SidePanelTab;
label: string;
count: number;
content: ReactNode;
};
interface SidePanelProps {
open: boolean;
mounted: boolean;
tab: SidePanelTab;
tabs: SidePanelTabConfig[];
onTabChange: (tab: SidePanelTab) => void;
onClose: () => void;
}
// 右侧(竖屏为底部抽屉)的侧栏外壳:评论 / TA的作品 / 搜索结果(或 hashtag)几个标签页
export const SidePanel = memo(function SidePanel({
open,
mounted,
tab,
tabs,
onTabChange,
onClose,
}: SidePanelProps) {
const visibility = open
? "translate-y-0 pointer-events-auto landscape:w-[min(420px,36vw)] landscape:border-l landscape:border-white/10"
: "translate-y-full pointer-events-none landscape:w-0 landscape:border-l-0";
// 竖屏时作品网格不需要那么高,给视频多留点位置
const portraitHeight =
tab === "comments" ? "h-[min(80vh,88dvh)]" : "h-[min(62vh,64dvh)]";
return (
<aside
className={`
z-30 flex flex-col bg-[rgba(22,22,22,0.92)] text-white overflow-hidden
fixed inset-x-0 bottom-0 w-full ${portraitHeight} border-t border-white/10 rounded-t-2xl
landscape:relative landscape:inset-auto landscape:h-full landscape:translate-y-0 landscape:border-t-0 landscape:rounded-none
${mounted ? "transition-transform landscape:transition-[width] duration-200 ease-out" : ""}
${visibility}
`}
>
<div className="flex items-center gap-1 px-3 border-b border-white/10 shrink-0">
<div
className="flex min-w-0 flex-1 items-center gap-5 overflow-x-auto"
role="tablist"
>
{tabs.map((t) => (
<button
key={t.key}
type="button"
role="tab"
aria-selected={tab === t.key}
onClick={() => onTabChange(t.key)}
className={`relative shrink-0 max-w-[10em] truncate py-3 text-[15px] transition-colors cursor-pointer ${
tab === t.key
? "font-semibold text-white"
: "text-white/55 hover:text-white/80"
}`}
>
{t.label}
{t.count > 0 && (
<span className="ml-1 text-xs font-normal text-white/50">
{t.count}
</span>
)}
{tab === t.key && (
<span className="absolute inset-x-0 bottom-0 h-0.5 rounded-full bg-rose-400" />
)}
</button>
))}
</div>
<button
type="button"
className="w-8 h-8 shrink-0 inline-flex items-center justify-center rounded-full bg-white/15 text-white/90 border border-white/20 hover:bg-white/25 transition-colors cursor-pointer"
onClick={onClose}
aria-label="关闭侧栏"
>
<X size={18} />
</button>
</div>
{tabs.map((t) => (
<div
key={t.key}
className={tab === t.key ? "flex min-h-0 flex-1 flex-col" : "hidden"}
>
{t.content}
</div>
))}
</aside>
);
});
@@ -0,0 +1,168 @@
"use client";
import { Check, CheckCheck, Copy, X } from "lucide-react";
import { memo, useEffect, useRef, useState } from "react";
import type { VideoTranscript } from "../types";
interface TranscriptPanelProps {
open: boolean;
onClose: () => void;
transcript: VideoTranscript | null;
}
export const TranscriptPanel = memo(function TranscriptPanel({
open,
onClose,
transcript,
}: TranscriptPanelProps) {
const [copiedIndex, setCopiedIndex] = useState<number | null>(null);
const [copiedAll, setCopiedAll] = useState(false);
const copyTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const copyAllTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
useEffect(() => {
return () => {
if (copyTimerRef.current !== null) clearTimeout(copyTimerRef.current);
if (copyAllTimerRef.current !== null)
clearTimeout(copyAllTimerRef.current);
};
}, []);
if (!open) return null;
const handleCopy = (text: string, index: number): void => {
void navigator.clipboard.writeText(text).then(() => {
setCopiedIndex(index);
if (copyTimerRef.current !== null) clearTimeout(copyTimerRef.current);
copyTimerRef.current = setTimeout(() => setCopiedIndex(null), 2000);
});
};
const handleCopyAll = (): void => {
if (!transcript?.transcript) return;
const allText = transcript.transcript.join("\n");
void navigator.clipboard.writeText(allText).then(() => {
setCopiedAll(true);
if (copyAllTimerRef.current !== null)
clearTimeout(copyAllTimerRef.current);
copyAllTimerRef.current = setTimeout(() => setCopiedAll(false), 2000);
});
};
if (!transcript?.transcript?.length) {
return (
<div
className="fixed inset-0 z-50 flex items-center justify-center bg-black/70"
onClick={onClose}
>
<div
className="relative w-full max-w-2xl max-h-[80vh] m-4 bg-zinc-900/95 border border-white/10 rounded-2xl shadow-2xl"
onClick={(e) => e.stopPropagation()}
>
<div className="flex items-center justify-between p-4 border-b border-white/10">
<h2 className="text-lg font-semibold text-white">语音转录</h2>
<button
type="button"
onClick={onClose}
className="p-2 rounded-full hover:bg-white/10 transition-colors"
aria-label="关闭"
>
<X size={20} className="text-white/80" />
</button>
</div>
<div className="p-8 text-center text-white/60">
<p>{transcript ? "未识别到文字" : "该视频尚未转写"}</p>
</div>
</div>
</div>
);
}
return (
<div
className="fixed inset-0 z-50 flex items-center justify-center bg-black/70"
onClick={onClose}
>
<div
className="relative w-full max-w-2xl max-h-[80vh] m-4 bg-zinc-900/95 border border-white/10 rounded-2xl shadow-2xl flex flex-col"
onClick={(e) => e.stopPropagation()}
>
<div className="flex items-center justify-between p-4 border-b border-white/10 flex-shrink-0">
<div>
<h2 className="text-lg font-semibold text-white">语音转录</h2>
</div>
<div className="flex items-center gap-2">
<button
type="button"
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"
>
{copiedAll ? (
<>
<CheckCheck size={16} />
已复制全部
</>
) : (
<>
<Copy size={16} />
复制全部
</>
)}
</button>
<button
type="button"
onClick={onClose}
className="p-2 rounded-full hover:bg-white/10 transition-colors"
aria-label="关闭"
>
<X size={20} className="text-white/80" />
</button>
</div>
</div>
<div className="flex-1 overflow-y-auto p-4 space-y-2">
{transcript.transcript.map((text, index) => (
<div
key={`transcript-${index}`}
className="group bg-white/5 rounded-lg p-3 hover:bg-white/10 transition-colors cursor-pointer"
onClick={() => handleCopy(text, index)}
>
<div className="flex items-start justify-between gap-3">
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-1">
<span className="text-xs font-mono text-blue-400">
#{index + 1}
</span>
</div>
<p className="text-sm text-white/90 leading-relaxed break-words">
{text}
</p>
</div>
<button
type="button"
onClick={(e) => {
e.stopPropagation();
handleCopy(text, index);
}}
className="flex-shrink-0 p-2 rounded-lg opacity-0 group-hover:opacity-100 hover:bg-white/10 transition-all"
aria-label="复制"
>
{copiedIndex === index ? (
<Check size={16} className="text-green-400" />
) : (
<Copy size={16} className="text-white/60" />
)}
</button>
</div>
</div>
))}
</div>
<div className="flex-shrink-0 p-3 border-t border-white/10 text-center text-xs text-white/50">
共 {transcript.transcript.length} 段转录内容
</div>
</div>
</div>
);
});
@@ -1,5 +1,5 @@
import { forwardRef } from "react"; import { forwardRef, memo } from "react";
import type { ObjectFit } from "../types"; import type { ObjectFit } from "../types.ts";
interface VideoPlayerProps { interface VideoPlayerProps {
videoUrl: string; videoUrl: string;
@@ -9,19 +9,18 @@ interface VideoPlayerProps {
onTogglePlay: () => void; onTogglePlay: () => void;
} }
export const VideoPlayer = forwardRef<HTMLVideoElement, VideoPlayerProps>( export const VideoPlayer = memo(
forwardRef<HTMLVideoElement, VideoPlayerProps>(
({ videoUrl, rotation, objectFit, loop, onTogglePlay }, ref) => { ({ videoUrl, rotation, objectFit, loop, onTogglePlay }, ref) => {
return ( return (
<video <video
ref={ref} ref={ref}
src={videoUrl} src={videoUrl}
className={[ className={
// 旋转 0/180:充满容器盒子;
// 旋转 90/270:用中心定位 + 100vh/100vw,保证铺满全屏
rotation % 180 === 0 rotation % 180 === 0
? "absolute inset-0 h-full w-full bg-black/70 cursor-pointer" ? "absolute inset-0 h-full w-full bg-black/70 cursor-pointer"
: "absolute top-1/2 left-1/2 h-[100vw] w-[100vh] bg-black/70 cursor-pointer", : "absolute top-1/2 left-1/2 h-[100vw] w-[100vh] bg-black/70 cursor-pointer"
].join(" ")} }
style={{ style={{
transform: transform:
rotation % 180 === 0 rotation % 180 === 0
@@ -35,7 +34,8 @@ export const VideoPlayer = forwardRef<HTMLVideoElement, VideoPlayerProps>(
onClick={onTogglePlay} onClick={onTogglePlay}
/> />
); );
} },
),
); );
VideoPlayer.displayName = "VideoPlayer"; VideoPlayer.displayName = "VideoPlayer";
@@ -0,0 +1,141 @@
"use client";
import { Check, Tag, X } from "lucide-react";
import { memo, useEffect, useState } from "react";
import { VIDEO_CONTENT_TAGS } from "@/lib/video-tags";
type TagUpdateResponse = {
updated?: {
aweme_id: string;
content_tags: string[];
manual_content_tags: string[];
}[];
};
type VideoTagEditorProps = {
awemeId: string;
tags: string[];
onTagsChange: (tags: string[]) => void;
};
export const VideoTagEditor = memo(function VideoTagEditor({
awemeId,
tags,
onTagsChange,
}: VideoTagEditorProps) {
const [open, setOpen] = useState(false);
const [draftTags, setDraftTags] = useState<string[]>(tags);
const [saving, setSaving] = useState(false);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
if (!open) setDraftTags(tags);
}, [open, tags]);
const toggleTag = (tag: string): void => {
setDraftTags((current) =>
current.includes(tag)
? current.filter((item) => item !== tag)
: [...current, tag],
);
};
const saveTags = async (): Promise<void> => {
setSaving(true);
setError(null);
try {
const response = await fetch("/api/asr/tags", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
awemeId,
contentTags: draftTags,
mode: "set",
}),
});
if (!response.ok) throw new Error("保存标签失败");
const data: TagUpdateResponse = await response.json();
const updated = data.updated?.find((item) => item.aweme_id === awemeId);
onTagsChange(updated?.content_tags ?? draftTags);
setOpen(false);
} catch (err) {
setError(err instanceof Error ? err.message : "保存标签失败");
} finally {
setSaving(false);
}
};
return (
<div
data-no-swipe
className="absolute right-3 top-3 z-40 flex flex-col items-end gap-2"
>
<button
type="button"
onClick={() => setOpen((value) => !value)}
className="inline-flex h-9 items-center gap-2 rounded-full border border-white/20 bg-black/55 px-3 text-sm font-medium text-white shadow-lg transition-colors hover:bg-black/70"
aria-expanded={open}
>
<Tag size={16} />
{tags.length > 0 ? tags.join("、") : "打标签"}
</button>
{open && (
<div className="w-[min(92vw,24rem)] rounded-xl border border-white/15 bg-zinc-950/95 p-3 text-white shadow-2xl">
<div className="mb-3 flex items-center justify-between gap-3">
<div className="text-sm font-medium">手动标签</div>
<button
type="button"
onClick={() => setOpen(false)}
className="rounded-full p-1 text-white/60 transition-colors hover:bg-white/10 hover:text-white"
aria-label="关闭标签编辑"
>
<X size={16} />
</button>
</div>
<div className="flex flex-wrap gap-2">
{VIDEO_CONTENT_TAGS.map((tag) => {
const selected = draftTags.includes(tag);
return (
<button
key={tag}
type="button"
onClick={() => toggleTag(tag)}
className={`inline-flex items-center gap-1 rounded-full border px-3 py-1.5 text-xs font-medium transition-colors ${
selected
? "border-blue-400 bg-blue-500/25 text-blue-100"
: "border-white/10 bg-white/5 text-white/70 hover:border-white/25 hover:text-white"
}`}
>
{selected && <Check size={13} />}
{tag}
</button>
);
})}
</div>
{error && <div className="mt-3 text-xs text-rose-300">{error}</div>}
<div className="mt-4 flex items-center justify-between gap-3">
<button
type="button"
onClick={() => setDraftTags([])}
className="rounded-full px-3 py-1.5 text-xs text-white/60 transition-colors hover:bg-white/10 hover:text-white"
>
清空
</button>
<button
type="button"
onClick={saveTags}
disabled={saving}
className="rounded-full bg-blue-600 px-4 py-1.5 text-xs font-medium text-white transition-colors hover:bg-blue-500 disabled:cursor-not-allowed disabled:bg-zinc-600"
>
{saving ? "保存中..." : "保存"}
</button>
</div>
</div>
)}
</div>
);
});
@@ -0,0 +1,153 @@
import { Heart, Images } from "lucide-react";
import Link from "next/link";
import { memo, type ReactNode, useEffect, useRef } from "react";
import { awemeHref } from "@/lib/playback-context";
import type { PlaylistState } from "../hooks/usePlaylist";
interface WorksGridProps {
/** 所在标签页当前可见 */
active: boolean;
list: PlaylistState;
currentId: string;
/** 点击作品跳转时带上的播放上下文查询串 */
hrefQuery: string;
onSelect: () => void;
/** 列表只有当前这一个作品时的提示 */
singleHint?: string;
/** 网格上方的标题栏 */
header: ReactNode;
/** 标题栏下方的附加内容(如相关 tag) */
subheader?: ReactNode;
}
function formatCount(n: number): string {
if (n >= 10000) return `${(n / 10000).toFixed(n >= 100000 ? 0 : 1)}万`;
return String(n);
}
function PlayingBars() {
return (
<span className="flex h-4 items-end gap-0.5" aria-hidden>
{[60, 100, 40].map((height, i) => (
<span
key={height}
className="w-1 rounded-sm bg-white animate-pulse"
style={{ height: `${height}%`, animationDelay: `${i * 150}ms` }}
/>
))}
</span>
);
}
// 侧栏里的作品封面网格(TA的作品 / 搜索结果 / hashtag 共用):当前作品标「播放中」,点作品就地切换
export const WorksGrid = memo(function WorksGrid({
active,
list,
currentId,
hrefQuery,
onSelect,
singleHint,
header,
subheader,
}: WorksGridProps) {
const scrollRef = useRef<HTMLDivElement>(null);
const currentRef = useRef<HTMLAnchorElement>(null);
const items = list.items ?? [];
// 让「播放中」的作品保持在可见区域(切换作品或列表加载完成时重新定位)
// biome-ignore lint/correctness/useExhaustiveDependencies: currentId / items.length 只用来触发重新定位
useEffect(() => {
if (!active) return;
const container = scrollRef.current;
const el = currentRef.current;
if (!container || !el) return;
const top = el.offsetTop - container.offsetTop;
const bottom = top + el.offsetHeight;
if (
top < container.scrollTop ||
bottom > container.scrollTop + container.clientHeight
) {
container.scrollTop =
top - (container.clientHeight - el.offsetHeight) / 2;
}
}, [active, currentId, items.length]);
return (
<div className="flex min-h-0 flex-1 flex-col">
<div className="flex items-center gap-3 px-3 py-3 border-b border-white/10 shrink-0">
{header}
</div>
{subheader}
<div ref={scrollRef} className="comment-scroll flex-1 overflow-auto p-2">
{list.loading && !list.items && (
<div className="py-10 text-center text-sm text-white/60">
加载中...
</div>
)}
{list.error && !list.items && (
<div className="py-10 text-center text-sm text-red-300/80">
{list.error}
</div>
)}
<div className="grid grid-cols-3 gap-1.5">
{items.map((item) => {
const isCurrent = item.aweme_id === currentId;
return (
<Link
key={item.aweme_id}
ref={isCurrent ? currentRef : undefined}
href={awemeHref(item.aweme_id, hrefQuery)}
scroll={false}
onClick={onSelect}
title={item.desc}
aria-current={isCurrent ? "true" : undefined}
className={`relative block aspect-[3/4] overflow-hidden rounded-md bg-white/5 ${
isCurrent ? "ring-2 ring-rose-400" : "hover:opacity-90"
}`}
>
{item.cover_url ? (
// biome-ignore lint/performance/noImgElement: 封面来自对象存储,不走 next/image
<img
src={item.cover_url}
alt={item.desc?.slice(0, 20) || ""}
loading="lazy"
className="absolute inset-0 h-full w-full object-cover"
/>
) : null}
<div className="pointer-events-none absolute inset-0 bg-gradient-to-t from-black/60 via-transparent to-transparent" />
{item.type === "image" && (
<span className="absolute right-1 top-1 inline-flex items-center gap-0.5 rounded bg-black/55 px-1 py-0.5 text-[10px] text-white/90">
<Images size={10} />
图文
</span>
)}
{isCurrent ? (
<div className="absolute inset-0 flex flex-col items-center justify-center gap-1.5 bg-black/55 text-xs text-white">
<PlayingBars />
播放中
</div>
) : (
<span className="absolute bottom-1 left-1 inline-flex items-center gap-0.5 text-[11px] text-white/90 drop-shadow">
<Heart size={11} />
{formatCount(item.likes)}
</span>
)}
</Link>
);
})}
</div>
{list.items && items.length <= 1 && singleHint && (
<div className="py-6 text-center text-xs text-white/40">
{singleHint}
</div>
)}
{list.items && list.total > items.length && (
<div className="py-6 text-center text-xs text-white/40">
只列出前 {items.length} 个,共 {list.total} 个
</div>
)}
</div>
</div>
);
});
+215 -2
View File
@@ -1,3 +1,216 @@
export const emojiList = [ export const emojiList = [
"微笑", "色", "发呆", "酷拽", "抠鼻", "流泪", "捂脸", "发怒", "呲牙", "尬笑", "害羞", "调皮", "舔屏", "看", "爱心", "比心", "赞", "鼓掌", "感谢", "抱抱你", "玫瑰", "尴尬流汗", "戳手手", "星星眼", "杀马特", "黄脸干杯", "抱紧自己", "拜拜", "热化了", "黄脸祈祷", "懵", "举手", "加功德", "摊手", "无语流汗", "续火花吧", "点火", "哭哭", "吐舌小狗", "送花", "爱心手", "贴贴", "灵机一动", "耶", "打脸", "大笑", "机智", "送心", "666", "闭嘴", "来看我", "一起加油", "哈欠", "震惊", "晕", "衰", "困", "疑问", "泣不成声", "小鼓掌", "大金牙", "偷笑", "石化", "思考", "吐血", "可怜", "嘘", "撇嘴", "笑哭", "奸笑", "得意", "憨笑", "坏笑", "抓狂", "泪奔", "钱", "恐惧", "愉快", "快哭了", "翻白眼", "互粉", "我想静静", "委屈", "鄙视", "飞吻", "再见", "紫薇别走", "听歌", "求抱抱", "绝望的凝视", "不失礼貌的微笑", "不看", "裂开", "干饭人", "庆祝", "吐舌", "呆无辜", "白眼", "猪头", "冷漠", "暗中观察", "二哈", "菜狗", "黑脸", "展开说说", "蜜蜂狗", "柴犬", "摸头", "皱眉", "擦汗", "红脸", "做鬼脸", "强", "如花", "吐", "惊喜", "敲打", "奋斗", "吐彩虹", "大哭", "嘿哈", "惊恐", "囧", "难过", "斜眼", "阴险", "悠闲", "咒骂", "吃瓜群众", "绿帽子", "敢怒不敢言", "求求了", "眼含热泪", "叹气", "好开心", "不是吧", "鞠躬", "躺平", "九转大肠", "不你不想", "一头乱麻", "kisskiss", "你不大行", "噢买尬", "宕机", "苦涩", "逞强落泪", "求机位-黄脸", "求机位3", "点赞", "精选", "强壮", "碰拳", "OK", "击掌", "左上", "握手", "抱拳", "勾引", "拳头", "弱", "胜利", "右边", "左边", "嘴唇", "心碎", "凋谢", "愤怒", "垃圾", "啤酒", "咖啡", "蛋糕", "礼物", "撒花", "加一", "减一", "okk", "V5", "绝", "给力", "红包", "屎", "发", "18禁", "炸弹", "西瓜", "加鸡腿", "握爪", "太阳", "月亮", "给跪了", "蕉绿", "扎心", "胡瓜", "打call", "栓Q", "雪花", "圣诞树", "平安果", "圣诞帽", "气球", "烟花", "福", "candy", "糖葫芦", "鞭炮", "元宝", "灯笼", "锦鲤", "巧克力", "戒指", "棒棒糖", "纸飞机", "粽子" "微笑",
] "色",
"发呆",
"酷拽",
"抠鼻",
"流泪",
"捂脸",
"发怒",
"呲牙",
"尬笑",
"害羞",
"调皮",
"舔屏",
"看",
"爱心",
"比心",
"赞",
"鼓掌",
"感谢",
"抱抱你",
"玫瑰",
"尴尬流汗",
"戳手手",
"星星眼",
"杀马特",
"黄脸干杯",
"抱紧自己",
"拜拜",
"热化了",
"黄脸祈祷",
"懵",
"举手",
"加功德",
"摊手",
"无语流汗",
"续火花吧",
"点火",
"哭哭",
"吐舌小狗",
"送花",
"爱心手",
"贴贴",
"灵机一动",
"耶",
"打脸",
"大笑",
"机智",
"送心",
"666",
"闭嘴",
"来看我",
"一起加油",
"哈欠",
"震惊",
"晕",
"衰",
"困",
"疑问",
"泣不成声",
"小鼓掌",
"大金牙",
"偷笑",
"石化",
"思考",
"吐血",
"可怜",
"嘘",
"撇嘴",
"笑哭",
"奸笑",
"得意",
"憨笑",
"坏笑",
"抓狂",
"泪奔",
"钱",
"恐惧",
"愉快",
"快哭了",
"翻白眼",
"互粉",
"我想静静",
"委屈",
"鄙视",
"飞吻",
"再见",
"紫薇别走",
"听歌",
"求抱抱",
"绝望的凝视",
"不失礼貌的微笑",
"不看",
"裂开",
"干饭人",
"庆祝",
"吐舌",
"呆无辜",
"白眼",
"猪头",
"冷漠",
"暗中观察",
"二哈",
"菜狗",
"黑脸",
"展开说说",
"蜜蜂狗",
"柴犬",
"摸头",
"皱眉",
"擦汗",
"红脸",
"做鬼脸",
"强",
"如花",
"吐",
"惊喜",
"敲打",
"奋斗",
"吐彩虹",
"大哭",
"嘿哈",
"惊恐",
"囧",
"难过",
"斜眼",
"阴险",
"悠闲",
"咒骂",
"吃瓜群众",
"绿帽子",
"敢怒不敢言",
"求求了",
"眼含热泪",
"叹气",
"好开心",
"不是吧",
"鞠躬",
"躺平",
"九转大肠",
"不你不想",
"一头乱麻",
"kisskiss",
"你不大行",
"噢买尬",
"宕机",
"苦涩",
"逞强落泪",
"求机位-黄脸",
"求机位3",
"点赞",
"精选",
"强壮",
"碰拳",
"OK",
"击掌",
"左上",
"握手",
"抱拳",
"勾引",
"拳头",
"弱",
"胜利",
"右边",
"左边",
"嘴唇",
"心碎",
"凋谢",
"愤怒",
"垃圾",
"啤酒",
"咖啡",
"蛋糕",
"礼物",
"撒花",
"加一",
"减一",
"okk",
"V5",
"绝",
"给力",
"红包",
"屎",
"发",
"18禁",
"炸弹",
"西瓜",
"加鸡腿",
"握爪",
"太阳",
"月亮",
"给跪了",
"蕉绿",
"扎心",
"胡瓜",
"打call",
"栓Q",
"雪花",
"圣诞树",
"平安果",
"圣诞帽",
"气球",
"烟花",
"福",
"candy",
"糖葫芦",
"鞭炮",
"元宝",
"灯笼",
"锦鲤",
"巧克力",
"戒指",
"棒棒糖",
"纸飞机",
"粽子",
];
+1 -1
View File
@@ -1,6 +1,6 @@
// Hooks 导出 // Hooks 导出
export { useBackgroundCanvas } from "./useBackgroundCanvas"; export { useBackgroundCanvas } from "./useBackgroundCanvas";
export { useCommentState } from "./useCommentState"; export { useSidePanelState } from "./useSidePanelState";
export { useImageCarousel } from "./useImageCarousel"; export { useImageCarousel } from "./useImageCarousel";
export { useNavigation } from "./useNavigation"; export { useNavigation } from "./useNavigation";
export { usePlayerState } from "./usePlayerState"; export { usePlayerState } from "./usePlayerState";
@@ -1,73 +1,90 @@
import { useEffect } from "react";
import type { RefObject } from "react"; import type { RefObject } from "react";
import { useEffect } from "react";
interface UseBackgroundCanvasProps { interface UseBackgroundCanvasProps {
isVideo: boolean; isVideo: boolean;
idx: number; isPlaying: boolean;
videoRef: RefObject<HTMLVideoElement | null>; videoRef: RefObject<HTMLVideoElement | null>;
scrollerRef: RefObject<HTMLDivElement | null>;
backgroundCanvasRef: RefObject<HTMLCanvasElement | null>; backgroundCanvasRef: RefObject<HTMLCanvasElement | null>;
} }
const CANVAS_DRAW_INTERVAL_MS = 100; // 10fps 对 1/10 分辨率 + blur(40px) 已完全平滑,大幅降低 GPU 负载
export function useBackgroundCanvas({ export function useBackgroundCanvas({
isVideo, isVideo,
idx, isPlaying,
videoRef, videoRef,
scrollerRef,
backgroundCanvasRef, backgroundCanvasRef,
}: UseBackgroundCanvasProps) { }: UseBackgroundCanvasProps): void {
// 初始化尺寸与卸载清理
useEffect(() => { useEffect(() => {
const canvas = backgroundCanvasRef.current; const canvas = backgroundCanvasRef.current;
if (!canvas) return; if (!canvas) return;
const ctx = canvas.getContext("2d"); const ctx = canvas.getContext("2d");
if (!ctx) return; if (!ctx || !isVideo) {
canvas.width = 0;
canvas.height = 0;
return;
}
const updateCanvasSize = () => { const updateCanvasSize = (): void => {
canvas.width = Math.floor(window.innerWidth / 10); canvas.width = Math.max(1, Math.floor(window.innerWidth / 10));
canvas.height = Math.floor(window.innerHeight / 10); canvas.height = Math.max(1, Math.floor(window.innerHeight / 10));
}; };
updateCanvasSize(); updateCanvasSize();
let resizeTimeout: NodeJS.Timeout; let resizeTimeout: ReturnType<typeof setTimeout> | null = null;
const debouncedResize = () => { const debouncedResize = (): void => {
clearTimeout(resizeTimeout); if (resizeTimeout !== null) clearTimeout(resizeTimeout);
resizeTimeout = setTimeout(updateCanvasSize, 300); resizeTimeout = setTimeout(updateCanvasSize, 300);
}; };
window.addEventListener("resize", debouncedResize); window.addEventListener("resize", debouncedResize);
const drawMediaToCanvas = () => { return () => {
window.removeEventListener("resize", debouncedResize);
if (resizeTimeout !== null) clearTimeout(resizeTimeout);
ctx.clearRect(0, 0, canvas.width, canvas.height);
canvas.width = 0;
canvas.height = 0;
};
}, [isVideo, backgroundCanvasRef]);
// 仅在视频实际播放时进行低频采样绘制,暂停时不占 CPU/GPU
useEffect(() => {
if (!isVideo || !isPlaying) return;
const canvas = backgroundCanvasRef.current;
if (!canvas) return;
const ctx = canvas.getContext("2d");
if (!ctx) return; if (!ctx) return;
let sourceElement: HTMLVideoElement | HTMLImageElement | null = null; const drawMediaToCanvas = (): void => {
if (document.hidden) return;
if (isVideo) { const sourceElement = videoRef.current;
sourceElement = videoRef.current; if (
} else { !sourceElement ||
const scroller = scrollerRef.current; sourceElement.readyState < 2 ||
if (scroller) { sourceElement.paused
const currentImgContainer = scroller.children[idx] as HTMLElement; ) {
if (currentImgContainer) { return;
sourceElement = currentImgContainer.querySelector("img");
} }
}
}
if (!sourceElement || (sourceElement instanceof HTMLVideoElement && sourceElement.readyState < 2)) return;
const canvasWidth = canvas.width; const canvasWidth = canvas.width;
const canvasHeight = canvas.height; const canvasHeight = canvas.height;
const sourceWidth = const sourceWidth = sourceElement.videoWidth;
sourceElement instanceof HTMLVideoElement ? sourceElement.videoWidth : sourceElement.naturalWidth; const sourceHeight = sourceElement.videoHeight;
const sourceHeight =
sourceElement instanceof HTMLVideoElement ? sourceElement.videoHeight : sourceElement.naturalHeight;
if (!sourceWidth || !sourceHeight) return; if (!canvasWidth || !canvasHeight || !sourceWidth || !sourceHeight) {
return;
}
const canvasRatio = canvasWidth / canvasHeight; const canvasRatio = canvasWidth / canvasHeight;
const sourceRatio = sourceWidth / sourceHeight; const sourceRatio = sourceWidth / sourceHeight;
let drawWidth: number, drawHeight: number, offsetX: number, offsetY: number; let drawWidth: number;
let drawHeight: number;
let offsetX: number;
let offsetY: number;
if (canvasRatio > sourceRatio) { if (canvasRatio > sourceRatio) {
drawWidth = canvasWidth; drawWidth = canvasWidth;
@@ -84,12 +101,14 @@ export function useBackgroundCanvas({
ctx.drawImage(sourceElement, offsetX, offsetY, drawWidth, drawHeight); ctx.drawImage(sourceElement, offsetX, offsetY, drawWidth, drawHeight);
}; };
const intervalId = setInterval(drawMediaToCanvas, 20); drawMediaToCanvas();
const intervalId = window.setInterval(
drawMediaToCanvas,
CANVAS_DRAW_INTERVAL_MS,
);
return () => { return () => {
clearInterval(intervalId); window.clearInterval(intervalId);
window.removeEventListener("resize", debouncedResize);
clearTimeout(resizeTimeout);
}; };
}, [isVideo, idx, videoRef, scrollerRef, backgroundCanvasRef]); }, [isVideo, isPlaying, videoRef, backgroundCanvasRef]);
} }
@@ -1,30 +0,0 @@
import { useEffect, useState } from "react";
import { getStringFromStorage, saveToStorage } from "../utils";
export function useCommentState() {
const [open, setOpen] = useState(false);
const [mounted, setMounted] = useState(false);
// 从 localStorage 恢复评论区状态
useEffect(() => {
if (typeof window === "undefined") return;
const saved = getStringFromStorage("aweme_player_comments_open", "false");
if (saved === "true") {
setOpen(true);
}
// 短暂延迟后标记为已挂载,启用动画
requestAnimationFrame(() => {
requestAnimationFrame(() => {
setMounted(true);
});
});
}, []);
// 持久化评论区状态
useEffect(() => {
if (typeof window === "undefined") return;
saveToStorage("aweme_player_comments_open", open.toString());
}, [open]);
return { open, setOpen, mounted };
}
+381 -46
View File
@@ -1,44 +1,339 @@
import { useEffect, useRef, useState } from "react";
import type { RefObject } from "react";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import type { ImageData, LoopMode, Neighbors } from "../types"; import type { RefObject } from "react";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import type { ImageData, LoopMode, Neighbors } from "../types.ts";
import { releaseMediaElement } from "../utils";
const MIN_SEGMENT_MS = 250;
const UI_UPDATE_INTERVAL_MS = 33;
const PROGRESS_EPSILON = 0.001;
function normalizeDuration(
duration: number | null | undefined,
fallback: number,
): number {
if (
typeof duration !== "number" ||
!Number.isFinite(duration) ||
duration <= 0
) {
return fallback;
}
return Math.max(MIN_SEGMENT_MS, Math.round(duration));
}
function sumDurations(durations: number[], end: number): number {
let total = 0;
for (let i = 0; i < end; i++) total += durations[i] ?? 0;
return total;
}
interface UseImageCarouselProps { interface UseImageCarouselProps {
images: ImageData["images"]; images: ImageData["images"];
isPlaying: boolean; isPlaying: boolean;
playbackRate?: number;
loopMode: LoopMode; loopMode: LoopMode;
neighbors: Neighbors; neighbors: Neighbors;
volume: number; volume: number;
audioRef: RefObject<HTMLAudioElement | null>; audioRef: RefObject<HTMLAudioElement | null>;
scrollerRef: RefObject<HTMLDivElement | null>;
setProgress: (progress: number) => void; setProgress: (progress: number) => void;
/** 单张图片显示时长(毫秒),默认 5000ms */ /** 单张图片显示时长(毫秒),默认 5000ms */
segmentMs?: number; segmentMs?: number;
} }
export interface UseImageCarouselReturn {
idx: number;
setIdx: (idx: number) => void;
segProgress: number;
segProgressRef: RefObject<number>;
segmentDurations: number[];
totalDurationMs: number;
mediaSyncToken: number;
segStartRef: RefObject<number | null>;
idxRef: RefObject<number>;
failedAnimatedMap: Record<string, boolean>;
setSegmentDuration: (imageId: string, durationMs: number) => void;
markMediaReady: (imageId: string) => void;
markAnimatedFailed: (imageId: string) => void;
handleAnimatedProgress: (imageId: string, progress: number) => void;
handleAnimatedEnded: (imageId: string) => void;
goToIndex: (index: number, segmentProgress?: number) => void;
seekTo: (ratio: number) => void;
}
export function useImageCarousel({ export function useImageCarousel({
images, images,
isPlaying, isPlaying,
playbackRate = 1,
loopMode, loopMode,
neighbors, neighbors,
volume, volume,
audioRef, audioRef,
scrollerRef,
setProgress, setProgress,
segmentMs = 5000, segmentMs = 5000,
}: UseImageCarouselProps) { }: UseImageCarouselProps): UseImageCarouselReturn {
const router = useRouter(); const router = useRouter();
const [idx, setIdx] = useState(0); const [idx, setIdx] = useState(0);
const [segProgress, setSegProgress] = useState(0); const [segProgress, setSegProgress] = useState(0);
const [durationOverrides, setDurationOverrides] = useState<
Record<string, number>
>({});
const [readyMediaMap, setReadyMediaMap] = useState<Record<string, boolean>>(
{},
);
const [failedAnimatedMap, setFailedAnimatedMap] = useState<
Record<string, boolean>
>({});
const [mediaSyncToken, setMediaSyncToken] = useState(0);
const imagesRef = useRef(images);
imagesRef.current = images;
const segStartRef = useRef<number | null>(null); const segStartRef = useRef<number | null>(null);
const idxRef = useRef<number>(0); const idxRef = useRef<number>(0);
const rafRef = useRef<number | null>(null); const timerRef = useRef<number | null>(null);
const lastUiUpdateRef = useRef(0);
const segProgressRef = useRef(0);
const totalProgressRef = useRef(0);
const prevImageKeyRef = useRef<string | null>(null);
const fallbackDuration = normalizeDuration(segmentMs, 5000);
const segmentDurations = useMemo(
() =>
images.map((img) =>
normalizeDuration(
durationOverrides[img.id] ?? img.duration,
fallbackDuration,
),
),
[durationOverrides, fallbackDuration, images],
);
const imageKey = useMemo(
() => images.map((img) => img.id).join("|"),
[images],
);
const totalDurationMs = useMemo(
() => segmentDurations.reduce((sum, duration) => sum + duration, 0),
[segmentDurations],
);
const syncProgress = useCallback(
(index: number, segmentProgress: number, force = false): void => {
const segmentDuration = segmentDurations[index] ?? fallbackDuration;
const completedMs = sumDurations(segmentDurations, index);
const totalProgress =
totalDurationMs > 0
? (completedMs + segmentDuration * segmentProgress) / totalDurationMs
: 0;
const nextSegProgress = Math.max(0, Math.min(1, segmentProgress));
const nextTotalProgress = Math.max(0, Math.min(1, totalProgress));
if (
force ||
Math.abs(nextSegProgress - segProgressRef.current) > PROGRESS_EPSILON
) {
segProgressRef.current = nextSegProgress;
setSegProgress(nextSegProgress);
}
if (
force ||
Math.abs(nextTotalProgress - totalProgressRef.current) >
PROGRESS_EPSILON
) {
totalProgressRef.current = nextTotalProgress;
setProgress(nextTotalProgress);
}
},
[fallbackDuration, segmentDurations, setProgress, totalDurationMs],
);
const setSegmentDuration = useCallback(
(imageId: string, durationMs: number): void => {
if (!Number.isFinite(durationMs) || durationMs <= 0) return;
const normalizedDuration = normalizeDuration(
durationMs,
fallbackDuration,
);
setDurationOverrides((current) => {
if (current[imageId] === normalizedDuration) return current;
return { ...current, [imageId]: normalizedDuration };
});
},
[fallbackDuration],
);
const markMediaReady = useCallback((imageId: string): void => {
setReadyMediaMap((current) => {
if (current[imageId]) return current;
return { ...current, [imageId]: true };
});
}, []);
const markAnimatedFailed = useCallback((imageId: string): void => {
setFailedAnimatedMap((current) => {
if (current[imageId]) return current;
return { ...current, [imageId]: true };
});
setReadyMediaMap((current) => {
if (current[imageId]) return current;
return { ...current, [imageId]: true };
});
}, []);
const advanceSegment = useCallback(
(fromIndex: number): void => {
const currentImages = imagesRef.current;
if (!currentImages.length || idxRef.current !== fromIndex) return;
const now = performance.now();
if (loopMode === "single") {
segStartRef.current = now;
lastUiUpdateRef.current = now;
syncProgress(fromIndex, 0, true);
return;
}
if (fromIndex >= currentImages.length - 1) {
if (loopMode === "sequential" && neighbors?.next) {
router.push(neighbors.next.href);
return;
}
idxRef.current = 0;
setIdx(0);
segStartRef.current = now;
lastUiUpdateRef.current = now;
syncProgress(0, 0, true);
if (currentImages.length === 1) {
setMediaSyncToken((token) => token + 1);
}
return;
}
const nextIndex = fromIndex + 1;
idxRef.current = nextIndex;
setIdx(nextIndex);
segStartRef.current = now;
lastUiUpdateRef.current = now;
syncProgress(nextIndex, 0, true);
},
[loopMode, neighbors?.next, router, syncProgress],
);
const handleAnimatedProgress = useCallback(
(imageId: string, progress: number): void => {
const currentImage = imagesRef.current[idxRef.current];
if (!currentImage || currentImage.id !== imageId) return;
const now = performance.now();
const clamped = Math.max(0, Math.min(1, progress));
const wrapped = clamped < segProgressRef.current - 0.2;
if (
wrapped ||
clamped >= 0.995 ||
now - lastUiUpdateRef.current >= UI_UPDATE_INTERVAL_MS
) {
lastUiUpdateRef.current = now;
syncProgress(idxRef.current, clamped, wrapped);
}
},
[syncProgress],
);
const handleAnimatedEnded = useCallback(
(imageId: string): void => {
const currentImage = imagesRef.current[idxRef.current];
if (!currentImage || currentImage.id !== imageId) return;
advanceSegment(idxRef.current);
},
[advanceSegment],
);
const goToIndex = useCallback(
(index: number, segmentProgress = 0): void => {
const count = imagesRef.current.length;
if (!count) return;
const nextIndex = Math.max(0, Math.min(count - 1, index));
const nextSegmentProgress = Math.max(0, Math.min(1, segmentProgress));
const now = performance.now();
const segmentDuration = segmentDurations[nextIndex] ?? fallbackDuration;
idxRef.current = nextIndex;
setIdx(nextIndex);
segStartRef.current = now - nextSegmentProgress * segmentDuration;
lastUiUpdateRef.current = now;
syncProgress(nextIndex, nextSegmentProgress, true);
setMediaSyncToken((token) => token + 1);
},
[fallbackDuration, segmentDurations, syncProgress],
);
const seekTo = useCallback(
(ratio: number): void => {
if (!imagesRef.current.length || totalDurationMs <= 0) return;
const clampedRatio = Math.max(0, Math.min(1, ratio));
const targetMs = Math.min(
totalDurationMs - 1,
clampedRatio * totalDurationMs,
);
let accumulatedMs = 0;
let targetIndex = 0;
let segmentElapsedMs = 0;
for (let i = 0; i < segmentDurations.length; i++) {
const duration = segmentDurations[i] ?? fallbackDuration;
if (
targetMs < accumulatedMs + duration ||
i === segmentDurations.length - 1
) {
targetIndex = i;
segmentElapsedMs = Math.max(
0,
Math.min(duration, targetMs - accumulatedMs),
);
break;
}
accumulatedMs += duration;
}
const targetDuration = segmentDurations[targetIndex] ?? fallbackDuration;
const nextSegmentProgress =
targetDuration > 0 ? segmentElapsedMs / targetDuration : 0;
goToIndex(targetIndex, nextSegmentProgress);
},
[fallbackDuration, goToIndex, segmentDurations, totalDurationMs],
);
useEffect(() => { useEffect(() => {
idxRef.current = idx; idxRef.current = idx;
}, [idx]); }, [idx]);
// 仅当真正切换到另一个作品(imageKey 变化)时重置状态,避免首次挂载覆盖子组件已上报的 ready 状态
useEffect(() => {
if (prevImageKeyRef.current === null) {
prevImageKeyRef.current = imageKey;
return;
}
if (prevImageKeyRef.current === imageKey) return;
prevImageKeyRef.current = imageKey;
idxRef.current = 0;
setIdx(0);
segStartRef.current = null;
segProgressRef.current = 0;
totalProgressRef.current = 0;
setSegProgress(0);
setProgress(0);
setDurationOverrides({});
setReadyMediaMap({});
setFailedAnimatedMap({});
setMediaSyncToken((token) => token + 1);
}, [imageKey, setProgress]);
// BGM 控制 // BGM 控制
useEffect(() => { useEffect(() => {
const el = audioRef.current; const el = audioRef.current;
@@ -51,64 +346,104 @@ export function useImageCarousel({
} }
}, [audioRef, isPlaying, volume]); }, [audioRef, isPlaying, volume]);
// 自动切页
useEffect(() => { useEffect(() => {
if (!images?.length) return; const el = audioRef.current;
return () => {
releaseMediaElement(el);
};
}, [audioRef]);
if (segStartRef.current == null) segStartRef.current = performance.now(); // 静态图自动切页(仅在当前静态图已加载完成后开始计时;动图由 <video> 自身进度与 onEnded 驱动)
let lastTs = performance.now(); const currentImage = images[idx];
const isCurrentAnimated =
Boolean(currentImage?.animated) &&
!failedAnimatedMap[currentImage?.id ?? ""];
const isCurrentStaticReady = currentImage
? Boolean(readyMediaMap[currentImage.id])
: false;
const tick = (ts: number) => { useEffect(() => {
if (!images?.length) return; if (!images.length || !segmentDurations.length || totalDurationMs <= 0) {
if (!isPlaying) segStartRef.current! += ts - lastTs;
lastTs = ts;
let start = segStartRef.current!;
let localIdx = idxRef.current;
let elapsed = ts - start;
while (elapsed >= segmentMs) {
elapsed -= segmentMs;
if (localIdx >= images.length - 1) {
if (loopMode === "sequential" && neighbors?.next) {
router.push(`/aweme/${neighbors.next.aweme_id}`);
return; return;
} }
localIdx = 0;
} else {
localIdx = localIdx + 1;
}
}
segStartRef.current = ts - elapsed;
if (localIdx !== idxRef.current) { // 当前页为正常动图时,交由 ImageCarousel 内 <video> 的真实播放进度和 ended 事件推进
idxRef.current = localIdx; if (isCurrentAnimated) {
setIdx(localIdx); return;
const el = scrollerRef.current;
if (el) el.scrollTo({ left: localIdx * el.clientWidth, behavior: "smooth" });
} }
const localSeg = Math.max(0, Math.min(1, elapsed / segmentMs)); // 静态图尚未加载完成或处于暂停态时,冻结分段进度,避免未加载完就偷跑进度裁切展示时长
setSegProgress(localSeg); if (!isPlaying || !isCurrentStaticReady) {
setProgress((localIdx + localSeg) / images.length); return;
}
rafRef.current = requestAnimationFrame(tick); const safeRate = playbackRate > 0 ? playbackRate : 1;
const now = performance.now();
const currentSegmentDuration =
(segmentDurations[idx] ?? fallbackDuration) / safeRate;
segStartRef.current = now - segProgressRef.current * currentSegmentDuration;
const tick = (): void => {
const currentImages = imagesRef.current;
if (!currentImages.length) return;
const ts = performance.now();
const start = segStartRef.current ?? ts;
const activeIdx = idxRef.current;
const duration =
(segmentDurations[activeIdx] ?? fallbackDuration) / safeRate;
const elapsed = ts - start;
if (elapsed >= duration) {
advanceSegment(activeIdx);
return;
}
const localSeg = Math.max(0, Math.min(1, elapsed / duration));
if (ts - lastUiUpdateRef.current >= UI_UPDATE_INTERVAL_MS) {
lastUiUpdateRef.current = ts;
syncProgress(activeIdx, localSeg, false);
}
}; };
rafRef.current = requestAnimationFrame(tick); timerRef.current = window.setInterval(tick, UI_UPDATE_INTERVAL_MS);
return () => { return () => {
if (rafRef.current) cancelAnimationFrame(rafRef.current); if (timerRef.current !== null) {
rafRef.current = null; window.clearInterval(timerRef.current);
timerRef.current = null;
}
}; };
}, [images?.length, isPlaying, loopMode, neighbors?.next, router, scrollerRef, setProgress, segmentMs]); }, [
advanceSegment,
fallbackDuration,
idx,
images.length,
isCurrentAnimated,
isCurrentStaticReady,
isPlaying,
playbackRate,
segmentDurations,
syncProgress,
totalDurationMs,
]);
return { return {
idx, idx,
setIdx, setIdx,
segProgress, segProgress,
segProgressRef,
segmentDurations,
totalDurationMs,
mediaSyncToken,
segStartRef, segStartRef,
idxRef, idxRef,
failedAnimatedMap,
setSegmentDuration,
markMediaReady,
markAnimatedFailed,
handleAnimatedProgress,
handleAnimatedEnded,
goToIndex,
seekTo,
}; };
} }
+256 -28
View File
@@ -1,7 +1,9 @@
import { useEffect, useRef } from "react";
import type { RefObject } from "react";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import type { Neighbors } from "../types"; import type { RefObject } from "react";
import { useEffect, useRef } from "react";
import type { Neighbors } from "../types.ts";
const LONG_PRESS_DELAY_MS = 220;
interface UseNavigationProps { interface UseNavigationProps {
neighbors: Neighbors; neighbors: Neighbors;
@@ -11,6 +13,10 @@ interface UseNavigationProps {
prevImg?: () => void; prevImg?: () => void;
nextImg?: () => void; nextImg?: () => void;
togglePlay: () => void; togglePlay: () => void;
onFastForwardChange?: (active: boolean) => void;
onSetAbStart?: () => void;
onSetAbEnd?: () => void;
onClearAbLoop?: () => void;
} }
export function useNavigation({ export function useNavigation({
@@ -21,15 +27,31 @@ export function useNavigation({
prevImg, prevImg,
nextImg, nextImg,
togglePlay, togglePlay,
}: UseNavigationProps) { onFastForwardChange,
onSetAbStart,
onSetAbEnd,
onClearAbLoop,
}: UseNavigationProps): void {
const router = useRouter(); const router = useRouter();
const wheelCooldownRef = useRef<number>(0); const wheelCooldownRef = useRef<number>(0);
const rightPressKeyRef = useRef<string | null>(null);
const longPressActiveRef = useRef<boolean>(false);
const longPressTimerRef = useRef<number | null>(null);
const onFastForwardChangeRef = useRef(onFastForwardChange);
onFastForwardChangeRef.current = onFastForwardChange;
const onSetAbStartRef = useRef(onSetAbStart);
onSetAbStartRef.current = onSetAbStart;
const onSetAbEndRef = useRef(onSetAbEnd);
onSetAbEndRef.current = onSetAbEnd;
const onClearAbLoopRef = useRef(onClearAbLoop);
onClearAbLoopRef.current = onClearAbLoop;
// 预取路由 // 预取路由
useEffect(() => { useEffect(() => {
if (!neighbors) return; if (!neighbors) return;
if (neighbors.next) router.prefetch(`/aweme/${neighbors.next.aweme_id}`); if (neighbors.next) router.prefetch(neighbors.next.href);
if (neighbors.prev) router.prefetch(`/aweme/${neighbors.prev.aweme_id}`); if (neighbors.prev) router.prefetch(neighbors.prev.href);
}, [neighbors, router]); }, [neighbors, router]);
// 鼠标滚轮切换 // 鼠标滚轮切换
@@ -37,7 +59,7 @@ export function useNavigation({
const el = mediaContainerRef.current; const el = mediaContainerRef.current;
if (!el) return; if (!el) return;
const onWheel = (e: WheelEvent) => { const onWheel = (e: WheelEvent): void => {
if (e.ctrlKey) return; if (e.ctrlKey) return;
const now = performance.now(); const now = performance.now();
if (now - wheelCooldownRef.current < 700) return; if (now - wheelCooldownRef.current < 700) return;
@@ -50,44 +72,153 @@ export function useNavigation({
if (dy > 0 && neighbors?.next) { if (dy > 0 && neighbors?.next) {
wheelCooldownRef.current = now; wheelCooldownRef.current = now;
router.push(`/aweme/${neighbors.next.aweme_id}`); router.push(neighbors.next.href);
} else if (dy < 0 && neighbors?.prev) { } else if (dy < 0 && neighbors?.prev) {
wheelCooldownRef.current = now; wheelCooldownRef.current = now;
router.push(`/aweme/${neighbors.prev.aweme_id}`); router.push(neighbors.prev.href);
} }
}; };
el.addEventListener("wheel", onWheel, { passive: false }); el.addEventListener("wheel", onWheel, { passive: false });
return () => el.removeEventListener("wheel", onWheel as any); return () => el.removeEventListener("wheel", onWheel);
}, [neighbors, mediaContainerRef, router]); }, [neighbors, mediaContainerRef, router]);
// 键盘快捷键 // 触屏上下滑切换(上滑下一条,下滑上一条)。只认明显的竖向手势,
// 不影响图文的左右滑动;从按钮、链接、控制栏(data-no-swipe)上开始的触摸不处理。
useEffect(() => { useEffect(() => {
const onKeyDown = (e: KeyboardEvent) => { const el = mediaContainerRef.current;
const target = e.target as HTMLElement; if (!el) return;
if (target.tagName === "INPUT" || target.tagName === "TEXTAREA" || target.isContentEditable) {
let start: { x: number; y: number; t: number } | null = null;
let vertical = false;
const onTouchStart = (e: TouchEvent): void => {
const target = e.target as HTMLElement | null;
if (
e.touches.length !== 1 ||
target?.closest(
"button, a, input, textarea, select, [role='slider'], [data-no-swipe]",
)
) {
start = null;
return;
}
const touch = e.touches[0];
start = { x: touch.clientX, y: touch.clientY, t: performance.now() };
vertical = false;
};
const onTouchMove = (e: TouchEvent): void => {
if (!start) return;
if (e.touches.length !== 1) {
start = null;
return;
}
const touch = e.touches[0];
const dx = touch.clientX - start.x;
const dy = touch.clientY - start.y;
if (!vertical && Math.abs(dy) > 12 && Math.abs(dy) > Math.abs(dx) * 1.5) {
vertical = true;
}
// 竖向手势由我们处理,阻止浏览器下拉刷新 / 回弹
if (vertical && e.cancelable) e.preventDefault();
};
const onTouchEnd = (e: TouchEvent): void => {
if (!start) return;
const touch = e.changedTouches[0];
const dy = touch.clientY - start.y;
const elapsed = performance.now() - start.t;
const wasVertical = vertical;
start = null;
vertical = false;
if (!wasVertical) return;
// 快速轻扫,或者慢慢拖过屏幕高度的四分之一
const flick = Math.abs(dy) > 60 && elapsed < 800;
const longDrag = Math.abs(dy) > window.innerHeight * 0.25;
if (!flick && !longDrag) return;
const now = performance.now();
if (now - wheelCooldownRef.current < 500) return;
if (dy < 0 && neighbors?.next) {
wheelCooldownRef.current = now;
router.push(neighbors.next.href);
} else if (dy > 0 && neighbors?.prev) {
wheelCooldownRef.current = now;
router.push(neighbors.prev.href);
}
};
const onTouchCancel = (): void => {
start = null;
vertical = false;
};
el.addEventListener("touchstart", onTouchStart, { passive: true });
el.addEventListener("touchmove", onTouchMove, { passive: false });
el.addEventListener("touchend", onTouchEnd);
el.addEventListener("touchcancel", onTouchCancel);
return () => {
el.removeEventListener("touchstart", onTouchStart);
el.removeEventListener("touchmove", onTouchMove);
el.removeEventListener("touchend", onTouchEnd);
el.removeEventListener("touchcancel", onTouchCancel);
};
}, [neighbors, mediaContainerRef, router]);
// 键盘快捷键(支持向右箭头 / D 键区分短按与长按 2 倍速,以及视频 [ ] 设置 AB 循环)
useEffect(() => {
const clearLongPressTimer = (): void => {
if (longPressTimerRef.current !== null) {
window.clearTimeout(longPressTimerRef.current);
longPressTimerRef.current = null;
}
};
const resetRightKeyPress = (): void => {
clearLongPressTimer();
const wasLongPress = longPressActiveRef.current;
rightPressKeyRef.current = null;
longPressActiveRef.current = false;
if (wasLongPress) {
onFastForwardChangeRef.current?.(false);
}
};
const onKeyDown = (e: KeyboardEvent): void => {
if (e.ctrlKey || e.metaKey || e.altKey || e.isComposing) return;
const target = e.target as HTMLElement | null;
if (
target &&
(target.tagName === "INPUT" ||
target.tagName === "TEXTAREA" ||
target.isContentEditable)
) {
return; return;
} }
const key = e.key.toLowerCase(); const key = e.key.toLowerCase();
const code = e.code;
if (key === "arrowup" || key === "w") { if (key === "arrowup" || key === "w" || code === "KeyW") {
e.preventDefault(); e.preventDefault();
const now = performance.now(); const now = performance.now();
if (now - wheelCooldownRef.current < 700) return; if (now - wheelCooldownRef.current < 700) return;
if (neighbors?.prev) { if (neighbors?.prev) {
wheelCooldownRef.current = now; wheelCooldownRef.current = now;
router.push(`/aweme/${neighbors.prev.aweme_id}`); router.push(neighbors.prev.href);
} }
} else if (key === "arrowdown" || key === "s") { } else if (key === "arrowdown" || key === "s" || code === "KeyS") {
e.preventDefault(); e.preventDefault();
const now = performance.now(); const now = performance.now();
if (now - wheelCooldownRef.current < 700) return; if (now - wheelCooldownRef.current < 700) return;
if (neighbors?.next) { if (neighbors?.next) {
wheelCooldownRef.current = now; wheelCooldownRef.current = now;
router.push(`/aweme/${neighbors.next.aweme_id}`); router.push(neighbors.next.href);
} }
} else if (key === "arrowleft" || key === "a") { } else if (key === "arrowleft" || key === "a" || code === "KeyA") {
e.preventDefault(); e.preventDefault();
if (isVideo) { if (isVideo) {
const v = videoRef?.current; const v = videoRef?.current;
@@ -97,8 +228,73 @@ export function useNavigation({
} else { } else {
prevImg?.(); prevImg?.();
} }
} else if (key === "arrowright" || key === "d") { } else if (key === "arrowright" || key === "d" || code === "KeyD") {
e.preventDefault(); e.preventDefault();
const normalizedKey = code === "KeyD" ? "d" : key;
// 忽略系统长按重复触发的 keydown,交由定时器统一判定长按阈值
if (e.repeat || rightPressKeyRef.current !== null) return;
rightPressKeyRef.current = normalizedKey;
longPressActiveRef.current = false;
clearLongPressTimer();
longPressTimerRef.current = window.setTimeout(() => {
longPressTimerRef.current = null;
if (
rightPressKeyRef.current !== null &&
!longPressActiveRef.current
) {
longPressActiveRef.current = true;
onFastForwardChangeRef.current?.(true);
}
}, LONG_PRESS_DELAY_MS);
} else if (
isVideo &&
(code === "BracketLeft" || key === "[" || key === "【" || key === "「")
) {
if (e.repeat) return;
e.preventDefault();
onSetAbStartRef.current?.();
} else if (
isVideo &&
(code === "BracketRight" || key === "]" || key === "】" || key === "」")
) {
if (e.repeat) return;
e.preventDefault();
onSetAbEndRef.current?.();
} else if (
isVideo &&
(code === "Backslash" || key === "\\" || key === "、")
) {
if (e.repeat) return;
e.preventDefault();
onClearAbLoopRef.current?.();
} else if (key === " " || code === "Space") {
if (e.repeat) return;
e.preventDefault();
togglePlay();
}
};
const onKeyUp = (e: KeyboardEvent): void => {
const key = e.key.toLowerCase();
const code = e.code;
const normalizedKey = code === "KeyD" ? "d" : key;
if (normalizedKey !== "arrowright" && normalizedKey !== "d") return;
if (rightPressKeyRef.current !== normalizedKey) return;
e.preventDefault();
clearLongPressTimer();
const wasLongPress = longPressActiveRef.current;
rightPressKeyRef.current = null;
longPressActiveRef.current = false;
if (wasLongPress) {
onFastForwardChangeRef.current?.(false);
return;
}
// 短按(单击):视频快进 5 秒,图文切换下一张图片
if (isVideo) { if (isVideo) {
const v = videoRef?.current; const v = videoRef?.current;
if (v && v.duration) { if (v && v.duration) {
@@ -107,27 +303,59 @@ export function useNavigation({
} else { } else {
nextImg?.(); nextImg?.();
} }
} else if (key === " ") { };
e.preventDefault();
togglePlay(); const handleVisibilityChange = (): void => {
} if (document.hidden) resetRightKeyPress();
}; };
window.addEventListener("keydown", onKeyDown); window.addEventListener("keydown", onKeyDown);
return () => window.removeEventListener("keydown", onKeyDown); window.addEventListener("keyup", onKeyUp);
window.addEventListener("blur", resetRightKeyPress);
document.addEventListener("visibilitychange", handleVisibilityChange);
return () => {
window.removeEventListener("keydown", onKeyDown);
window.removeEventListener("keyup", onKeyUp);
window.removeEventListener("blur", resetRightKeyPress);
document.removeEventListener("visibilitychange", handleVisibilityChange);
};
}, [isVideo, neighbors, router, videoRef, prevImg, nextImg, togglePlay]); }, [isVideo, neighbors, router, videoRef, prevImg, nextImg, togglePlay]);
// 浏览器返回事件 // 组件卸载时确保清理长按定时器并恢复倍速
useEffect(() => { useEffect(() => {
window.history.pushState({ interceptBack: true }, ""); return () => {
if (longPressTimerRef.current !== null) {
window.clearTimeout(longPressTimerRef.current);
longPressTimerRef.current = null;
}
if (longPressActiveRef.current) {
longPressActiveRef.current = false;
rightPressKeyRef.current = null;
onFastForwardChangeRef.current?.(false);
}
};
}, []);
const handlePopState = () => { // 仅在无历史记录的独立新标签页首次进入时注入一次返回兜底,避免连续刷视频时无限叠加 history 栈
useEffect(() => {
const currentState = window.history.state as
| { interceptBack?: boolean }
| null
| undefined;
if (window.history.length <= 1 && !currentState?.interceptBack) {
window.history.pushState({ ...currentState, interceptBack: true }, "");
}
const handlePopState = (): void => {
if (window.history.length <= 1) {
window.close(); window.close();
setTimeout(() => { setTimeout(() => {
if (!document.hidden) { if (!document.hidden) {
router.push("/"); router.push("/");
} }
}, 100); }, 100);
}
}; };
window.addEventListener("popstate", handlePopState); window.addEventListener("popstate", handlePopState);
+14 -5
View File
@@ -1,19 +1,28 @@
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import type { LoopMode, ObjectFit } from "../types"; import type { LoopMode, ObjectFit } from "../types.ts";
import { getNumberFromStorage, getStringFromStorage, saveToStorage } from "../utils"; import {
getNumberFromStorage,
getStringFromStorage,
saveToStorage,
} from "../utils";
export function usePlayerState() { export function usePlayerState() {
const [isPlaying, setIsPlaying] = useState(true); const [isPlaying, setIsPlaying] = useState(true);
const [isFullscreen, setIsFullscreen] = useState(false); const [isFullscreen, setIsFullscreen] = useState(false);
const [volume, setVolume] = useState(() => getNumberFromStorage("aweme_player_volume", 1)); const [volume, setVolume] = useState(() =>
const [rate, setRate] = useState(() => getNumberFromStorage("aweme_player_rate", 1)); getNumberFromStorage("aweme_player_volume", 1),
);
const [rate, setRate] = useState(() =>
getNumberFromStorage("aweme_player_rate", 1),
);
const [progress, setProgress] = useState(0); const [progress, setProgress] = useState(0);
const [rotation, setRotation] = useState(0); const [rotation, setRotation] = useState(0);
const [progressRestored, setProgressRestored] = useState(false); const [progressRestored, setProgressRestored] = useState(false);
const [objectFit, setObjectFit] = useState<ObjectFit>("contain"); const [objectFit, setObjectFit] = useState<ObjectFit>("contain");
const [loopMode, setLoopMode] = useState<LoopMode>(() => { const [loopMode, setLoopMode] = useState<LoopMode>(() => {
const saved = getStringFromStorage("aweme_player_loop_mode", "loop"); const saved = getStringFromStorage("aweme_player_loop_mode", "loop");
return saved === "sequential" ? "sequential" : "loop"; if (saved === "single" || saved === "sequential") return saved;
return "loop";
}); });
// 持久化音量 // 持久化音量
+87
View File
@@ -0,0 +1,87 @@
import { useEffect, useState } from "react";
import type { PlaylistItem, PlaylistResponse } from "@/lib/playback-context";
// 侧栏列表(作者作品 / 搜索结果 / hashtag)缓存在模块里:切换作品时播放页会重新挂载,
// 但客户端导航不会清掉 JS 内存,所以在列表里上下切换时可以立刻拿到列表,不用每次重新请求。
const STALE_MS = 60_000;
type CacheEntry = PlaylistResponse & { fetchedAt: number };
const cache = new Map<string, CacheEntry>();
const inflight = new Map<string, Promise<PlaylistResponse>>();
function loadPlaylist(query: string): Promise<PlaylistResponse> {
const pending = inflight.get(query);
if (pending) return pending;
const promise = fetch(`/api/playlist${query}`)
.then(async (res) => {
if (!res.ok) throw new Error(`加载列表失败 (${res.status})`);
const data = (await res.json()) as PlaylistResponse;
cache.set(query, { ...data, fetchedAt: Date.now() });
return data;
})
.finally(() => inflight.delete(query));
inflight.set(query, promise);
return promise;
}
export type PlaylistState = {
items: PlaylistItem[] | null;
/** 列表总数(可能多于 items:侧栏最多列出 500 个) */
total: number;
loading: boolean;
error: string | null;
};
/**
* 按播放上下文(/api/playlist 的查询串,含 ?)取作品列表。
* query 为空或 enabled 为 false 时不请求。
*/
export function usePlaylist(query: string, enabled: boolean): PlaylistState {
const [state, setState] = useState<PlaylistState>(() => {
const cached = query ? cache.get(query) : undefined;
return {
items: cached?.items ?? null,
total: cached?.total ?? 0,
loading: false,
error: null,
};
});
useEffect(() => {
if (!query || !enabled) return;
const cached = cache.get(query);
setState({
items: cached?.items ?? null,
total: cached?.total ?? 0,
loading: !cached,
error: null,
});
if (cached && Date.now() - cached.fetchedAt < STALE_MS) return;
let cancelled = false;
loadPlaylist(query)
.then((data) => {
if (!cancelled) {
setState({
items: data.items,
total: data.total,
loading: false,
error: null,
});
}
})
.catch((err: unknown) => {
if (cancelled) return;
setState((prev) => ({
...prev,
loading: false,
error: err instanceof Error ? err.message : "加载失败",
}));
});
return () => {
cancelled = true;
};
}, [query, enabled]);
return state;
}
@@ -0,0 +1,37 @@
import { useEffect, useState } from "react";
import type { RelatedHashtagsResult } from "@/lib/hashtag-related.server";
const cache = new Map<string, RelatedHashtagsResult>();
/** 取某个 hashtag 的相关 tag(按共现计算);name 为空或 enabled 为 false 时不请求。 */
export function useRelatedHashtags(
name: string | null,
enabled: boolean,
): RelatedHashtagsResult | null {
const key = name?.toLowerCase() ?? "";
const [result, setResult] = useState<RelatedHashtagsResult | null>(
() => cache.get(key) ?? null,
);
useEffect(() => {
if (!key || !enabled) return;
const cached = cache.get(key);
setResult(cached ?? null);
if (cached) return;
let cancelled = false;
fetch(`/api/hashtag/related?name=${encodeURIComponent(key)}`)
.then((res) => (res.ok ? res.json() : null))
.then((data: RelatedHashtagsResult | null) => {
if (!data) return;
cache.set(key, data);
if (!cancelled) setResult(data);
})
.catch(() => {});
return () => {
cancelled = true;
};
}, [key, enabled]);
return result?.name === key ? result : null;
}
@@ -0,0 +1,126 @@
import { useCallback, useEffect, useState } from "react";
/** comments:评论;works:TA的作品;list:搜索结果 / hashtag / 标签列表 */
export type SidePanelTab = "comments" | "works" | "list";
// 侧栏开关沿用评论区原来的 localStorage 键;停在哪个标签页、是否处于作者模式只在本次会话内记住,
// 这样下次从首页点进来不会莫名其妙停在作者模式。
// 切换作品时播放页会整体重新挂载,靠这些存储把状态带到下一个作品。
const OPEN_KEY = "aweme_player_comments_open";
const TAB_KEY = "aweme_player_panel_tab";
const AUTHOR_MODE_KEY = "aweme_player_author_mode";
function readStorage(storage: () => Storage, key: string): string | null {
try {
return storage().getItem(key);
} catch {
return null;
}
}
function writeStorage(
storage: () => Storage,
key: string,
value: string | null,
) {
try {
if (value === null) storage().removeItem(key);
else storage().setItem(key, value);
} catch {}
}
const local = () => window.localStorage;
const session = () => window.sessionStorage;
export function isPortraitViewport(): boolean {
return (
typeof window !== "undefined" &&
window.matchMedia("(orientation: portrait)").matches
);
}
/**
* 侧栏状态与作者模式。
* 作者模式和侧栏开关是分开的:打开「TA的作品」进入作者模式(上下切换在该作者的作品里移动),
* 关掉侧栏不会退出,只有点顶部提示的 ✕、或切到「搜索结果 / #hashtag」列表才退出。
*/
export function useSidePanelState(authorSecUid: string | undefined) {
const [open, setOpen] = useState(false);
const [tab, setTab] = useState<SidePanelTab>("comments");
// 处于作者模式时记下是哪位作者;切到别的作者的作品(例如浏览器后退)时自动不生效
const [authorModeFor, setAuthorModeFor] = useState<string | null>(null);
const [restored, setRestored] = useState(false);
const [mounted, setMounted] = useState(false);
useEffect(() => {
setOpen(readStorage(local, OPEN_KEY) === "true");
const savedTab = readStorage(session, TAB_KEY);
setTab(savedTab === "works" || savedTab === "list" ? savedTab : "comments");
setAuthorModeFor(readStorage(session, AUTHOR_MODE_KEY));
setRestored(true);
// 恢复完成后再开启过渡动画,避免进入页面时侧栏滑入
requestAnimationFrame(() => {
requestAnimationFrame(() => setMounted(true));
});
}, []);
useEffect(() => {
if (!restored) return;
writeStorage(local, OPEN_KEY, String(open));
writeStorage(session, TAB_KEY, tab);
writeStorage(session, AUTHOR_MODE_KEY, authorModeFor);
}, [open, tab, authorModeFor, restored]);
// 切到哪个列表标签页,上下切换就改用哪个列表的顺序;「评论」不影响顺序
const applyTabMode = useCallback(
(nextTab: SidePanelTab) => {
if (nextTab === "works" && authorSecUid) setAuthorModeFor(authorSecUid);
else if (nextTab === "list") setAuthorModeFor(null);
},
[authorSecUid],
);
const selectTab = useCallback(
(nextTab: SidePanelTab) => {
setTab(nextTab);
applyTabMode(nextTab);
},
[applyTabMode],
);
const show = useCallback(
(nextTab: SidePanelTab) => {
selectTab(nextTab);
setOpen(true);
},
[selectTab],
);
const close = useCallback(() => setOpen(false), []);
/** 打开到指定标签页;已经打开在这个标签页时则关闭(关闭不影响顺序) */
const toggle = useCallback(
(nextTab: SidePanelTab) => {
if (open && tab === nextTab) close();
else show(nextTab);
},
[close, open, show, tab],
);
const exitAuthorMode = useCallback(() => setAuthorModeFor(null), []);
return {
open,
tab,
mounted,
/** 上下切换在当前作者的作品里移动 */
authorMode: Boolean(authorSecUid) && authorModeFor === authorSecUid,
/** 已从存储里恢复完状态(之后才能安全地自动打开侧栏) */
restored,
selectTab,
show,
close,
toggle,
exitAuthorMode,
};
}
+157 -43
View File
@@ -1,14 +1,17 @@
import { useEffect, useRef } from "react";
import type { RefObject } from "react";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import type { LoopMode, Neighbors } from "../types"; import type { RefObject } from "react";
import { useEffect, useRef } from "react";
import type { AbLoop, LoopMode, Neighbors } from "../types.ts";
import { cleanupExpiredProgressStorage, releaseMediaElement } from "../utils";
interface UseVideoPlayerProps { interface UseVideoPlayerProps {
enabled?: boolean;
awemeId: string; awemeId: string;
videoRef: RefObject<HTMLVideoElement | null>; videoRef: RefObject<HTMLVideoElement | null>;
volume: number; volume: number;
rate: number; rate: number;
loopMode: LoopMode; loopMode: LoopMode;
abLoop?: AbLoop;
neighbors: Neighbors; neighbors: Neighbors;
progressRestored: boolean; progressRestored: boolean;
setIsPlaying: (playing: boolean) => void; setIsPlaying: (playing: boolean) => void;
@@ -17,27 +20,41 @@ interface UseVideoPlayerProps {
} }
export function useVideoPlayer({ export function useVideoPlayer({
enabled = true,
awemeId, awemeId,
videoRef, videoRef,
volume, volume,
rate, rate,
loopMode, loopMode,
abLoop,
neighbors, neighbors,
progressRestored, progressRestored,
setIsPlaying, setIsPlaying,
setProgress, setProgress,
setProgressRestored, setProgressRestored,
}: UseVideoPlayerProps) { }: UseVideoPlayerProps): void {
const router = useRouter(); const router = useRouter();
const restoredAwemeIdRef = useRef<string | null>(null);
const lastProgressRef = useRef(0);
const abLoopRef = useRef<AbLoop | undefined>(abLoop);
abLoopRef.current = abLoop;
// 挂载时清理过期的播放进度缓存
useEffect(() => {
if (!enabled) return;
cleanupExpiredProgressStorage();
}, [enabled]);
// 恢复播放进度 // 恢复播放进度
useEffect(() => { useEffect(() => {
if (progressRestored) return; if (!enabled) return;
if (progressRestored && restoredAwemeIdRef.current === awemeId) return;
const v = videoRef.current; const v = videoRef.current;
if (!v) return; if (!v) return;
const onLoadedMetadata = () => { const onLoadedMetadata = (): void => {
if (progressRestored) return; if (restoredAwemeIdRef.current === awemeId) return;
restoredAwemeIdRef.current = awemeId;
try { try {
const key = `aweme_progress_${awemeId}`; const key = `aweme_progress_${awemeId}`;
@@ -47,13 +64,22 @@ export function useVideoPlayer({
return; return;
} }
const { time, timestamp } = JSON.parse(saved); const parsed = JSON.parse(saved) as {
time?: unknown;
timestamp?: unknown;
};
const time = typeof parsed.time === "number" ? parsed.time : 0;
const timestamp =
typeof parsed.timestamp === "number" ? parsed.timestamp : 0;
const now = Date.now(); const now = Date.now();
const fiveMinutes = 5 * 60 * 1000; const fiveMinutes = 5 * 60 * 1000;
if (now - timestamp < fiveMinutes && time > 1 && time < v.duration - 1) { if (
now - timestamp < fiveMinutes &&
time > 1 &&
time < v.duration - 1
) {
v.currentTime = time; v.currentTime = time;
console.log(`恢复播放进度: ${Math.round(time)}s`);
} else if (now - timestamp >= fiveMinutes) { } else if (now - timestamp >= fiveMinutes) {
localStorage.removeItem(key); localStorage.removeItem(key);
} }
@@ -66,18 +92,20 @@ export function useVideoPlayer({
if (v.readyState >= 1) { if (v.readyState >= 1) {
onLoadedMetadata(); onLoadedMetadata();
} else { return;
}
v.addEventListener("loadedmetadata", onLoadedMetadata, { once: true }); v.addEventListener("loadedmetadata", onLoadedMetadata, { once: true });
return () => v.removeEventListener("loadedmetadata", onLoadedMetadata); return () => v.removeEventListener("loadedmetadata", onLoadedMetadata);
} }, [awemeId, enabled, progressRestored, videoRef, setProgressRestored]);
}, [awemeId, progressRestored, videoRef, setProgressRestored]);
// 保存播放进度 // 保存播放进度并在卸载/切页时主动释放媒体解码缓冲区
useEffect(() => { useEffect(() => {
if (!enabled) return;
const v = videoRef.current; const v = videoRef.current;
if (!v) return; if (!v) return;
const saveProgress = () => { const saveProgress = (): void => {
if (!v.duration || Number.isNaN(v.duration) || v.currentTime < 1) return; if (!v.duration || Number.isNaN(v.duration) || v.currentTime < 1) return;
try { try {
@@ -92,80 +120,166 @@ export function useVideoPlayer({
} }
}; };
const interval = setInterval(saveProgress, 2000); const interval = window.setInterval(saveProgress, 2000);
const onBeforeUnload = () => saveProgress(); const onBeforeUnload = (): void => saveProgress();
window.addEventListener("beforeunload", onBeforeUnload); window.addEventListener("beforeunload", onBeforeUnload);
return () => { return () => {
clearInterval(interval); window.clearInterval(interval);
window.removeEventListener("beforeunload", onBeforeUnload); window.removeEventListener("beforeunload", onBeforeUnload);
saveProgress(); saveProgress();
releaseMediaElement(v);
}; };
}, [awemeId, videoRef]); }, [awemeId, enabled, videoRef]);
// 监听播放状态和进度 // 监听播放状态和进度(含 AB 循环区间控制)
useEffect(() => { useEffect(() => {
if (!enabled) return;
const v = videoRef.current; const v = videoRef.current;
if (!v) return; if (!v) return;
const onTime = () => { const syncVideoProgress = (): void => {
if (!v.duration || Number.isNaN(v.duration)) return; if (!v.duration || Number.isNaN(v.duration)) return;
setProgress(v.currentTime / v.duration); const currentAb = abLoopRef.current;
}; const hasAb = Boolean(
const onPlay = () => setIsPlaying(true); currentAb && (currentAb.a !== null || currentAb.b !== null),
const onPause = () => setIsPlaying(false); );
const onEnded = () => {
if (loopMode === "sequential" && neighbors?.next) { if (hasAb && currentAb) {
router.push(`/aweme/${neighbors.next.aweme_id}`); const startTime = Math.max(0, currentAb.a ?? 0);
const endTime =
currentAb.b !== null ? Math.min(v.duration, currentAb.b) : v.duration;
if (
endTime > startTime &&
(v.currentTime >= endTime ||
(currentAb.b === null && v.currentTime >= v.duration - 0.03))
) {
v.currentTime = startTime;
if (v.paused) {
void v.play().catch(() => {});
}
}
}
const nextProgress = Math.max(0, Math.min(1, v.currentTime / v.duration));
if (Math.abs(nextProgress - lastProgressRef.current) >= 0.001) {
lastProgressRef.current = nextProgress;
setProgress(nextProgress);
} }
}; };
v.addEventListener("timeupdate", onTime); const onPlay = (): void => setIsPlaying(true);
v.addEventListener("loadedmetadata", onTime); const onPause = (): void => setIsPlaying(false);
const onEnded = (): void => {
const currentAb = abLoopRef.current;
if (currentAb && (currentAb.a !== null || currentAb.b !== null)) {
const startTime = Math.max(0, currentAb.a ?? 0);
v.currentTime = startTime;
void v.play().catch(() => {});
return;
}
if (loopMode === "sequential" && neighbors?.next) {
router.push(neighbors.next.href);
}
};
v.addEventListener("timeupdate", syncVideoProgress);
v.addEventListener("loadedmetadata", syncVideoProgress);
v.addEventListener("play", onPlay); v.addEventListener("play", onPlay);
v.addEventListener("pause", onPause); v.addEventListener("pause", onPause);
v.addEventListener("ended", onEnded); v.addEventListener("ended", onEnded);
return () => { return () => {
v.removeEventListener("timeupdate", onTime); v.removeEventListener("timeupdate", syncVideoProgress);
v.removeEventListener("loadedmetadata", onTime); v.removeEventListener("loadedmetadata", syncVideoProgress);
v.removeEventListener("play", onPlay); v.removeEventListener("play", onPlay);
v.removeEventListener("pause", onPause); v.removeEventListener("pause", onPause);
v.removeEventListener("ended", onEnded); v.removeEventListener("ended", onEnded);
}; };
}, [loopMode, neighbors?.next, router, videoRef, setIsPlaying, setProgress]); }, [
enabled,
loopMode,
neighbors?.next,
router,
videoRef,
setIsPlaying,
setProgress,
]);
// 当启用 AB 循环时,使用 rAF 精确检测终点 B 并无缝跳回起点 A
useEffect(() => {
if (!enabled) return;
if (!abLoop || (abLoop.a === null && abLoop.b === null)) return;
let rafId: number | null = null;
const checkAbLoop = (): void => {
const v = videoRef.current;
if (v && !v.paused && Number.isFinite(v.duration) && v.duration > 0) {
const startTime = Math.max(0, abLoop.a ?? 0);
const endTime =
abLoop.b !== null ? Math.min(v.duration, abLoop.b) : v.duration;
if (
endTime > startTime &&
(v.currentTime >= endTime ||
(abLoop.b === null && v.currentTime >= v.duration - 0.03))
) {
v.currentTime = startTime;
const nextProgress = Math.max(0, Math.min(1, startTime / v.duration));
lastProgressRef.current = nextProgress;
setProgress(nextProgress);
}
}
rafId = window.requestAnimationFrame(checkAbLoop);
};
rafId = window.requestAnimationFrame(checkAbLoop);
return () => {
if (rafId !== null) {
window.cancelAnimationFrame(rafId);
}
};
}, [abLoop, enabled, setProgress, videoRef]);
// 自动播放检测 // 自动播放检测
useEffect(() => { useEffect(() => {
if (!enabled) return;
const v = videoRef.current; const v = videoRef.current;
if (!v) return; if (!v) return;
const checkAutoplay = async () => { const checkAutoplay = async (): Promise<void> => {
try { try {
await v.play(); await v.play();
setIsPlaying(true); setIsPlaying(true);
} catch (error) { } catch {
console.log("自动播放被阻止,需要用户交互");
setIsPlaying(false); setIsPlaying(false);
} }
}; };
if (v.readyState >= 1) { if (v.readyState >= 1) {
checkAutoplay(); void checkAutoplay();
} else { return;
v.addEventListener("loadedmetadata", checkAutoplay, { once: true });
return () => v.removeEventListener("loadedmetadata", checkAutoplay);
} }
}, [awemeId, videoRef, setIsPlaying]);
const onLoaded = (): void => {
void checkAutoplay();
};
v.addEventListener("loadedmetadata", onLoaded, { once: true });
return () => v.removeEventListener("loadedmetadata", onLoaded);
}, [awemeId, enabled, videoRef, setIsPlaying]);
// 更新音量和倍速 // 更新音量和倍速
useEffect(() => { useEffect(() => {
if (!enabled) return;
const v = videoRef.current; const v = videoRef.current;
if (v) v.volume = volume; if (v) v.volume = volume;
}, [volume, videoRef]); }, [enabled, volume, videoRef]);
useEffect(() => { useEffect(() => {
if (!enabled) return;
const v = videoRef.current; const v = videoRef.current;
if (v) v.playbackRate = rate; if (v) v.playbackRate = rate;
}, [rate, videoRef]); }, [enabled, rate, videoRef]);
} }
+107 -80
View File
@@ -1,17 +1,17 @@
import { prisma } from "@/lib/prisma";
import BackButton from "@/app/components/BackButton";
import AwemeDetailClient from "./Client";
import type { Metadata } from "next"; import type { Metadata } from "next";
import BackButton from "@/app/components/BackButton";
import { getFileUrl } from "@/lib/minio";
import { parsePlaybackContext } from "@/lib/playback-context";
import { resolvePlayback } from "@/lib/playback-context.server";
import { prisma } from "@/lib/prisma";
import AwemeDetailClient from "./Client";
import type { AwemeData, VideoTranscript } from "./types";
function ms(v?: number | null) { export async function generateMetadata({
if (!v) return ""; params,
const s = Math.round(v / 1000); }: {
const m = Math.floor(s / 60); params: Promise<{ awemeId: string }>;
const r = s % 60; }): Promise<Metadata> {
return `${m}:${r.toString().padStart(2, "0")}`;
}
export async function generateMetadata({ params }: { params: Promise<{ awemeId: string }> }): Promise<Metadata> {
const id = (await params).awemeId; const id = (await params).awemeId;
const [video, post] = await Promise.all([ const [video, post] = await Promise.all([
@@ -22,7 +22,7 @@ export async function generateMetadata({ params }: { params: Promise<{ awemeId:
prisma.imagePost.findUnique({ prisma.imagePost.findUnique({
where: { aweme_id: id }, where: { aweme_id: id },
select: { desc: true, author: { select: { nickname: true } } }, select: { desc: true, author: { select: { nickname: true } } },
}) }),
]); ]);
const data = video || post; const data = video || post;
@@ -34,7 +34,10 @@ export async function generateMetadata({ params }: { params: Promise<{ awemeId:
const desc = data.desc || "查看作品详情"; const desc = data.desc || "查看作品详情";
const author = data.author.nickname; const author = data.author.nickname;
const title = desc.length > 50 ? `${desc.slice(0, 50)}... - ${author}` : `${desc} - ${author}`; const title =
desc.length > 50
? `${desc.slice(0, 50)}... - ${author}`
: `${desc} - ${author}`;
return { return {
title, title,
@@ -42,87 +45,104 @@ export async function generateMetadata({ params }: { params: Promise<{ awemeId:
}; };
} }
export default async function AwemeDetail({ params }: { params: Promise<{ awemeId: string }> }) { export default async function AwemeDetail({
params,
searchParams,
}: {
params: Promise<{ awemeId: string }>;
searchParams: Promise<Record<string, string | string[] | undefined>>;
}) {
const id = (await params).awemeId; const id = (await params).awemeId;
const [video, post] = await Promise.all([ const [video, post] = await Promise.all([
prisma.video.findUnique({ prisma.video.findUnique({
where: { aweme_id: id }, where: { aweme_id: id },
include: { author: true, comments: { orderBy: { created_at: "desc" }, include: { user: true }, take: 50 } }, include: { author: true, transcript: true },
}), }),
prisma.imagePost.findUnique({ prisma.imagePost.findUnique({
where: { aweme_id: id }, where: { aweme_id: id },
include: { author: true, images: { orderBy: { order: "asc" } }, comments: { orderBy: { created_at: "desc" }, include: { user: true }, take: 50 } }, include: { author: true, images: { orderBy: { order: "asc" } } },
}) }),
]); ]);
if (!video && !post) return <main className="p-8">找不到该作品</main>; if (!video && !post) return <main className="p-8">找不到该作品</main>;
const isVideo = !!video; const isVideo = Boolean(video);
const data = isVideo
? {
type: "video" as const,
aweme_id: video!.aweme_id,
desc: video!.desc,
created_at: video!.created_at,
duration_ms: video!.duration_ms,
video_url: video!.video_url,
width: video!.width ?? null,
height: video!.height ?? null,
author: { nickname: video!.author.nickname, avatar_url: video!.author.avatar_url },
comments: video!.comments.map((c) => ({
cid: c.cid,
text: c.text,
created_at: c.created_at,
digg_count: c.digg_count,
user: { nickname: c.user.nickname, avatar_url: c.user.avatar_url },
})),
}
: {
type: "image" as const,
aweme_id: post!.aweme_id,
desc: post!.desc,
created_at: post!.created_at,
images: post!.images,
music_url: post!.music_url,
author: { nickname: post!.author.nickname, avatar_url: post!.author.avatar_url },
comments: post!.comments.map((c) => ({
cid: c.cid,
text: c.text,
created_at: c.created_at,
digg_count: c.digg_count,
user: { nickname: c.user.nickname, avatar_url: c.user.avatar_url },
})),
};
// Compute prev/next neighbors by created_at across videos and image posts // 获取评论总数
const currentCreatedAt = (isVideo ? video!.created_at : post!.created_at) as unknown as Date; const commentsCount = await prisma.comment.count({
const [newerVideo, newerPost, olderVideo, olderPost] = await Promise.all([ where: isVideo ? { videoId: id } : { imagePostId: id },
prisma.video.findFirst({ where: { created_at: { gt: currentCreatedAt } }, orderBy: { created_at: "asc" }, select: { aweme_id: true, created_at: true } }), });
prisma.imagePost.findFirst({ where: { created_at: { gt: currentCreatedAt } }, orderBy: { created_at: "asc" }, select: { aweme_id: true, created_at: true } }),
prisma.video.findFirst({ where: { created_at: { lt: currentCreatedAt } }, orderBy: { created_at: "desc" }, select: { aweme_id: true, created_at: true } }), let data: AwemeData;
prisma.imagePost.findFirst({ where: { created_at: { lt: currentCreatedAt } }, orderBy: { created_at: "desc" }, select: { aweme_id: true, created_at: true } }), let transcript: VideoTranscript | null = null;
if (video) {
transcript = video.transcript ?? null;
data = {
type: "video",
aweme_id: video.aweme_id,
desc: video.desc,
created_at: video.created_at,
likesCount: Number(video.digg_count),
commentsCount,
author: {
nickname: video.author.nickname,
avatar_url: getFileUrl(video.author.avatar_url || "default-avatar.png"),
sec_uid: video.author.sec_uid,
},
cover_url: getFileUrl(video.cover_url ?? "default-cover.png"),
cover_size: { h: video.height ?? 0, w: video.width ?? 0 },
duration_ms: video.duration_ms,
video_url: getFileUrl(video.video_url),
width: video.width ?? null,
height: video.height ?? null,
content_tags: video.manual_content_tags,
manual_content_tags: video.manual_content_tags,
};
} else if (post) {
const coverImage = post.images[0];
data = {
type: "image",
aweme_id: post.aweme_id,
desc: post.desc,
created_at: post.created_at,
likesCount: Number(post.digg_count),
commentsCount,
author: {
nickname: post.author.nickname,
avatar_url: getFileUrl(post.author.avatar_url || "default-avatar.png"),
sec_uid: post.author.sec_uid,
},
cover_url: getFileUrl(coverImage?.url ?? "default-cover.png"),
cover_size: {
h: coverImage?.height ?? 0,
w: coverImage?.width ?? 0,
},
images: post.images.map((img) => ({
...img,
url: getFileUrl(img.url),
animated: img.animated ? getFileUrl(img.animated) : null,
})),
music_url: getFileUrl(post.music_url || "default-music.mp3"),
};
} else {
return <main className="p-8">找不到该作品</main>;
}
const authorId = video?.authorId ?? post?.authorId ?? null;
const [playback, authorWorksCount] = await Promise.all([
resolvePlayback(id, parsePlaybackContext(await searchParams)),
authorId
? Promise.all([
prisma.video.count({ where: { authorId } }),
prisma.imagePost.count({ where: { authorId } }),
]).then(([videos, posts]) => videos + posts)
: 0,
]); ]);
const pickPrev = (() => {
const cands: { aweme_id: string; created_at: Date }[] = [];
if (newerVideo) cands.push({ aweme_id: newerVideo.aweme_id, created_at: newerVideo.created_at as unknown as Date });
if (newerPost) cands.push({ aweme_id: newerPost.aweme_id, created_at: newerPost.created_at as unknown as Date });
if (cands.length === 0) return null;
cands.sort((a, b) => +a.created_at - +b.created_at);
return { aweme_id: cands[0].aweme_id };
})();
const pickNext = (() => {
const cands: { aweme_id: string; created_at: Date }[] = [];
if (olderVideo) cands.push({ aweme_id: olderVideo.aweme_id, created_at: olderVideo.created_at as unknown as Date });
if (olderPost) cands.push({ aweme_id: olderPost.aweme_id, created_at: olderPost.created_at as unknown as Date });
if (cands.length === 0) return null;
cands.sort((a, b) => +b.created_at - +a.created_at);
return { aweme_id: cands[0].aweme_id };
})();
const neighbors: { prev: { aweme_id: string } | null; next: { aweme_id: string } | null } = { prev: pickPrev, next: pickNext };
return ( return (
<main className="min-h-screen w-full overflow-hidden"> <main className="min-h-screen w-full">
{/* 顶部条改为悬浮在媒体区域之上,避免 sticky 造成 Y 方向滚动条 */} {/* 顶部条改为悬浮在媒体区域之上,避免 sticky 造成 Y 方向滚动条 */}
<div className="fixed left-3 top-3 z-30"> <div className="fixed left-3 top-3 z-30">
<BackButton <BackButton
@@ -130,7 +150,14 @@ export default async function AwemeDetail({ params }: { params: Promise<{ awemeI
className="inline-flex items-center justify-center w-9 h-9 rounded-full bg-white/15 text-white border border-white/20 backdrop-blur hover:bg-white/25" className="inline-flex items-center justify-center w-9 h-9 rounded-full bg-white/15 text-white border border-white/20 backdrop-blur hover:bg-white/25"
/> />
</div> </div>
<AwemeDetailClient data={data as any} neighbors={neighbors as any} /> <AwemeDetailClient
data={data}
neighbors={playback.neighbors}
playbackContext={playback.context}
contextQuery={playback.contextQuery}
authorWorksCount={authorWorksCount}
transcript={transcript}
/>
</main> </main>
); );
} }
+54 -12
View File
@@ -1,4 +1,8 @@
export type User = { nickname: string; avatar_url: string | null }; export type User = {
nickname: string;
avatar_url: string | null;
sec_uid?: string;
};
export type Comment = { export type Comment = {
cid: string; cid: string;
@@ -6,38 +10,76 @@ export type Comment = {
digg_count: number; digg_count: number;
created_at: string | Date; created_at: string | Date;
user: User; user: User;
images?: { url: string; width?: number; height?: number }[];
}; };
export type VideoData = { export type VideoData = {
type: "video"; type: "video";
aweme_id: string; aweme_id: string;
cover_url: string;
cover_size: { w: number; h: number };
desc: string; desc: string;
created_at: string | Date; created_at: string | Date;
duration_ms?: number | null; duration_ms: number | null;
video_url: string; video_url: string;
width?: number | null; width: number | null;
height?: number | null; height: number | null;
content_tags: string[];
manual_content_tags: string[];
author: User; author: User;
comments: Comment[]; commentsCount: number;
likesCount: number;
}; };
export type ImageData = { export type ImageData = {
type: "image"; type: "image";
aweme_id: string; aweme_id: string;
cover_url: string;
cover_size: { w: number; h: number };
desc: string; desc: string;
created_at: string | Date; created_at: string | Date;
images: { id: string; url: string; width?: number; height?: number, animated?: string }[]; images: {
music_url?: string | null; id: string;
url: string;
width: number | null;
height: number | null;
animated: string | null;
duration: number | null;
}[];
music_url: string | null;
author: User; author: User;
comments: Comment[]; commentsCount: number;
likesCount: number;
}; };
export type AwemeData = VideoData | ImageData; export type AwemeData = VideoData | ImageData;
export type Neighbors = { export type VideoTranscript = {
prev: { aweme_id: string } | null; transcript: string[];
next: { aweme_id: string } | null;
}; };
export type LoopMode = "loop" | "sequential"; export type NeighborLink = { aweme_id: string; href: string };
export type Neighbors = {
prev: NeighborLink | null;
next: NeighborLink | null;
};
export type PlaybackContextInfo = {
kind: "search" | "tag" | "hashtag" | "author";
/** 顶部提示里的说明,如「搜索「百姓」」 */
label: string;
/** 侧栏列表标签页的标题,如「搜索结果」「#丰川祥子」 */
listTitle: string;
/** 从 1 开始 */
position: number;
total: number;
};
export type LoopMode = "loop" | "single" | "sequential";
export type ObjectFit = "contain" | "cover"; export type ObjectFit = "contain" | "cover";
export type AbLoop = {
a: number | null;
b: number | null;
};
+67 -3
View File
@@ -26,7 +26,9 @@ export function formatTime(seconds: number): string {
} }
// 处理评论文本中的表情占位符 // 处理评论文本中的表情占位符
export function parseCommentText(text: string): (string | { type: "emoji"; name: string })[] { export function parseCommentText(
text: string,
): (string | { type: "emoji"; name: string })[] {
const parts: (string | { type: "emoji"; name: string })[] = []; const parts: (string | { type: "emoji"; name: string })[] = [];
const regex = /\[([^\]]+)\]/g; const regex = /\[([^\]]+)\]/g;
let lastIndex = 0; let lastIndex = 0;
@@ -51,7 +53,10 @@ export function parseCommentText(text: string): (string | { type: "emoji"; name:
} }
// 从 localStorage 获取数值 // 从 localStorage 获取数值
export function getNumberFromStorage(key: string, defaultValue: number): number { export function getNumberFromStorage(
key: string,
defaultValue: number,
): number {
if (typeof window === "undefined") return defaultValue; if (typeof window === "undefined") return defaultValue;
const saved = localStorage.getItem(key); const saved = localStorage.getItem(key);
if (!saved) return defaultValue; if (!saved) return defaultValue;
@@ -60,7 +65,10 @@ export function getNumberFromStorage(key: string, defaultValue: number): number
} }
// 从 localStorage 获取字符串 // 从 localStorage 获取字符串
export function getStringFromStorage(key: string, defaultValue: string): string { export function getStringFromStorage(
key: string,
defaultValue: string,
): string {
if (typeof window === "undefined") return defaultValue; if (typeof window === "undefined") return defaultValue;
return localStorage.getItem(key) || defaultValue; return localStorage.getItem(key) || defaultValue;
} }
@@ -83,3 +91,59 @@ export function formatAbsoluteUTC(date: string | Date): string {
const s = String(d.getUTCSeconds()).padStart(2, "0"); const s = String(d.getUTCSeconds()).padStart(2, "0");
return `${Y}-${M}-${D} ${h}:${m}:${s} UTC`; return `${Y}-${M}-${D} ${h}:${m}:${s} UTC`;
} }
// 主动释放 HTMLMediaElement 解码缓冲与网络流,防止 SPA 连续切页导致内存/硬件解码器泄漏
export function releaseMediaElement(
el: HTMLMediaElement | null | undefined,
): void {
if (!el) return;
try {
el.pause();
el.removeAttribute("src");
el.load();
} catch {}
}
const PROGRESS_STORAGE_PREFIX = "aweme_progress_";
const PROGRESS_TTL_MS = 5 * 60 * 1000;
const MAX_PROGRESS_ENTRIES = 30;
// 清理过期或超量的视频播放进度缓存,避免长期浏览堆积 localStorage
export function cleanupExpiredProgressStorage(): void {
if (typeof window === "undefined") return;
try {
const now = Date.now();
const validEntries: { key: string; timestamp: number }[] = [];
for (let i = localStorage.length - 1; i >= 0; i--) {
const key = localStorage.key(i);
if (!key || !key.startsWith(PROGRESS_STORAGE_PREFIX)) continue;
const raw = localStorage.getItem(key);
if (!raw) {
localStorage.removeItem(key);
continue;
}
try {
const parsed = JSON.parse(raw) as { timestamp?: unknown };
const timestamp =
typeof parsed.timestamp === "number" ? parsed.timestamp : 0;
if (now - timestamp >= PROGRESS_TTL_MS) {
localStorage.removeItem(key);
} else {
validEntries.push({ key, timestamp });
}
} catch {
localStorage.removeItem(key);
}
}
if (validEntries.length > MAX_PROGRESS_ENTRIES) {
validEntries.sort((a, b) => b.timestamp - a.timestamp);
for (let i = MAX_PROGRESS_ENTRIES; i < validEntries.length; i++) {
localStorage.removeItem(validEntries[i].key);
}
}
} catch {}
}
+19 -10
View File
@@ -1,9 +1,9 @@
'use client'; "use client";
import React from 'react'; import React from "react";
import Link from 'next/link'; import Link from "next/link";
import { useRouter } from 'next/navigation'; import { useRouter } from "next/navigation";
import { ArrowLeft } from 'lucide-react'; import { ArrowLeft } from "lucide-react";
type BackButtonProps = { type BackButtonProps = {
className?: string; className?: string;
@@ -18,17 +18,24 @@ type BackButtonProps = {
* - Fallback: if close fails (e.g., not opened by script), navigates to '/' * - Fallback: if close fails (e.g., not opened by script), navigates to '/'
* - Uses <Link> so that Ctrl/Cmd-click or middle-click opens the fallback URL in a new tab naturally * - Uses <Link> so that Ctrl/Cmd-click or middle-click opens the fallback URL in a new tab naturally
*/ */
export default function BackButton({ className, ariaLabel = '返回', hrefFallback = '/', children }: BackButtonProps) { export default function BackButton({
className,
ariaLabel = "返回",
hrefFallback = "/",
children,
}: BackButtonProps) {
const router = useRouter(); const router = useRouter();
const onClick = React.useCallback<React.MouseEventHandler<HTMLAnchorElement>>((e) => { const onClick = React.useCallback<React.MouseEventHandler<HTMLAnchorElement>>(
(e) => {
// Respect modifier clicks (new tab/window) and non-left clicks // Respect modifier clicks (new tab/window) and non-left clicks
if (e.button !== 0 || e.metaKey || e.ctrlKey || e.shiftKey || e.altKey) return; if (e.button !== 0 || e.metaKey || e.ctrlKey || e.shiftKey || e.altKey)
return;
e.preventDefault(); e.preventDefault();
// Try to close the window first // Try to close the window first
if (typeof window !== 'undefined') { if (typeof window !== "undefined") {
window.close(); window.close();
// If window.close() didn't work (window still open after a short delay), // If window.close() didn't work (window still open after a short delay),
@@ -39,7 +46,9 @@ export default function BackButton({ className, ariaLabel = '返回', hrefFallba
} }
}, 80); }, 80);
} }
}, [router, hrefFallback]); },
[router, hrefFallback],
);
return ( return (
<Link <Link
+296 -46
View File
@@ -1,39 +1,70 @@
"use client"; "use client";
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { Check, ThumbsUp, X } from "lucide-react";
import Link from 'next/link'; import Image from "next/image";
import HoverVideo from './HoverVideo'; import Link from "next/link";
import { ThumbsUp } from 'lucide-react'; import { useCallback, useEffect, useRef, useState } from "react";
import type { FeedItem, FeedResponse } from '@/app/types/feed'; import type { FeedItem, FeedResponse } from "@/app/types/feed";
import { VIDEO_CONTENT_TAGS } from "@/lib/video-tags";
import HoverVideo from "./HoverVideo";
const COLUMN_KEYS = ["feed-col-0", "feed-col-1", "feed-col-2", "feed-col-3"];
function getAspectRatio(item: FeedItem) {
return item.width && item.height ? `${item.width}/${item.height}` : undefined;
}
type Props = { type Props = {
initialItems: FeedItem[]; initialItems: FeedItem[];
initialCursor: string | null; initialCursor: string | null;
fetchUrl?: string;
/** 附加在作品链接上的播放上下文查询串(含 ?),决定播放页上下切换的列表 */
linkQuery?: string;
}; };
export default function FeedMasonry({ initialItems, initialCursor }: Props) { type TagUpdateResponse = {
updated?: {
aweme_id: string;
content_tags: string[];
}[];
};
export default function FeedMasonry({
initialItems,
initialCursor,
fetchUrl = "/api/feed",
linkQuery = "",
}: Props) {
// 哨兵与容器 // 哨兵与容器
const [cursor, setCursor] = useState<string | null>(initialCursor); const [cursor, setCursor] = useState<string | null>(initialCursor);
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [ended, setEnded] = useState(!initialCursor); const [ended, setEnded] = useState(!initialCursor);
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set());
const [batchTag, setBatchTag] = useState<string>(VIDEO_CONTENT_TAGS[0]);
const [batchSaving, setBatchSaving] = useState(false);
const [batchError, setBatchError] = useState<string | null>(null);
const sentinelRef = useRef<HTMLDivElement | null>(null); const sentinelRef = useRef<HTMLDivElement | null>(null);
const containerRef = useRef<HTMLDivElement | null>(null); const containerRef = useRef<HTMLDivElement | null>(null);
// 响应式列数:<640:1, >=640:2, >=1024:3, >=1280:4 // 响应式列数:<640:1, >=640:2, >=1024:3, >=1280:4
const getColumnCount = useCallback(() => { const getColumnCount = useCallback(() => {
if (typeof window === 'undefined') return 1; if (typeof window === "undefined") return 1;
const w = window.innerWidth; const w = window.innerWidth;
if (w >= 1280) return 4; // xl if (w >= 1280) return 4; // xl
if (w >= 1024) return 3; // lg if (w >= 1024) return 3; // lg
if (w >= 640) return 2; // sm if (w >= 640) return 2; // sm
return 1; return 1;
}, []); }, []);
const [columnCount, setColumnCount] = useState<number>(getColumnCount()); // 为避免 SSR 与客户端初次渲染不一致(window 未定义导致服务端为 1 列,客户端首次渲染为多列),
// 这里将初始列数固定为 1,待挂载后再根据窗口宽度更新。
const [columnCount, setColumnCount] = useState<number>(1);
useEffect(() => { useEffect(() => {
// 挂载后立即根据当前窗口宽度更新一次列数
setColumnCount(getColumnCount());
const onResize = () => setColumnCount(getColumnCount()); const onResize = () => setColumnCount(getColumnCount());
window.addEventListener('resize', onResize); window.addEventListener("resize", onResize);
return () => window.removeEventListener('resize', onResize); return () => window.removeEventListener("resize", onResize);
}, [getColumnCount]); }, [getColumnCount]);
// 估算卡片高度(用于分配到“最短列”) // 估算卡片高度(用于分配到“最短列”)
@@ -41,12 +72,15 @@ export default function FeedMasonry({ initialItems, initialCursor }: Props) {
// 媒体区域高度 // 媒体区域高度
let mediaH = 200; // fallback let mediaH = 200; // fallback
if (item.width && item.height) { if (item.width && item.height) {
mediaH = Math.max(80, (Number(item.height) / Number(item.width)) * colWidth); mediaH = Math.max(
} else if (item.type === 'video') { 80,
(Number(item.height) / Number(item.width)) * colWidth,
);
} else if (item.type === "video") {
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;
@@ -57,12 +91,15 @@ export default function FeedMasonry({ initialItems, initialCursor }: Props) {
const cols: FeedItem[][] = Array.from({ length: columnCount }, () => []); const cols: FeedItem[][] = Array.from({ length: columnCount }, () => []);
return cols; return cols;
}); });
const [colHeights, setColHeights] = useState<number[]>(() => Array.from({ length: columnCount }, () => 0)); const [colHeights, setColHeights] = useState<number[]>(() =>
Array.from({ length: columnCount }, () => 0),
);
// 初始化与当列数变化时重排 // 初始化与当列数变化时重排
useEffect(() => { useEffect(() => {
const containerWidth = containerRef.current?.clientWidth ?? 0; const containerWidth = containerRef.current?.clientWidth ?? 0;
const colWidth = columnCount > 0 ? containerWidth / columnCount : containerWidth; const colWidth =
columnCount > 0 ? containerWidth / columnCount : containerWidth;
// 用 initialItems 重排 // 用 initialItems 重排
const newCols: FeedItem[][] = Array.from({ length: columnCount }, () => []); const newCols: FeedItem[][] = Array.from({ length: columnCount }, () => []);
const newHeights: number[] = Array.from({ length: columnCount }, () => 0); const newHeights: number[] = Array.from({ length: columnCount }, () => 0);
@@ -77,23 +114,26 @@ export default function FeedMasonry({ initialItems, initialCursor }: Props) {
} }
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;
setLoading(true); setLoading(true);
try { try {
const params = new URLSearchParams(); const params = new URLSearchParams();
if (cursor) params.set('before', cursor); if (cursor) params.set("before", cursor);
params.set('limit', '24'); params.set("limit", "24");
const res = await fetch(`/api/feed?${params.toString()}`, { cache: 'no-store' }); const url = fetchUrl.includes("?")
? `${fetchUrl}&${params.toString()}`
: `${fetchUrl}?${params.toString()}`;
const res = await fetch(url, { cache: "no-store" });
if (!res.ok) throw new Error(`HTTP ${res.status}`); if (!res.ok) throw new Error(`HTTP ${res.status}`);
const data: FeedResponse = await res.json(); const data: FeedResponse = await res.json();
// 将新数据按最短列分配 // 将新数据按最短列分配
setColumns((prevCols) => { setColumns((prevCols) => {
const containerWidth = containerRef.current?.clientWidth ?? 0; const containerWidth = containerRef.current?.clientWidth ?? 0;
const colWidth = columnCount > 0 ? containerWidth / columnCount : containerWidth; const colWidth =
columnCount > 0 ? containerWidth / columnCount : containerWidth;
const cols = prevCols.map((c) => [...c]); const cols = prevCols.map((c) => [...c]);
const heights = [...colHeights]; const heights = [...colHeights];
for (const item of data.items) { for (const item of data.items) {
@@ -111,85 +151,295 @@ export default function FeedMasonry({ initialItems, initialCursor }: Props) {
setCursor(data.nextCursor); setCursor(data.nextCursor);
if (!data.nextCursor || data.items.length === 0) setEnded(true); if (!data.nextCursor || data.items.length === 0) setEnded(true);
} catch (e) { } catch (e) {
console.error('fetch more feed failed', e); console.error("fetch more feed failed", e);
// 失败也不要死循环 // 失败也不要死循环
setEnded(true); setEnded(true);
} 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;
if (!el) return; if (!el) return;
const io = new IntersectionObserver((entries) => { const io = new IntersectionObserver(
(entries) => {
const entry = entries[0]; const entry = entries[0];
if (entry.isIntersecting) { if (entry.isIntersecting) {
fetchMore(); fetchMore();
} }
}, { rootMargin: '800px 0px 800px 0px' }); },
{ rootMargin: "800px 0px 800px 0px" },
);
io.observe(el); io.observe(el);
return () => io.disconnect(); return () => io.disconnect();
}, [fetchMore]); }, [fetchMore]);
const renderCard = useCallback((item: FeedItem) => ( const toggleSelection = useCallback((awemeId: string) => {
<Link key={item.aweme_id} href={`/aweme/${item.aweme_id}`} target="_blank" className="mb-4 block group"> setSelectedIds((current) => {
<article className="relative overflow-hidden rounded-2xl shadow-sm ring-1 ring-black/5 bg-white/80 dark:bg-zinc-900/60 backdrop-blur-sm transition-transform duration-300 group-hover:-translate-y-1"> const next = new Set(current);
if (next.has(awemeId)) next.delete(awemeId);
else next.add(awemeId);
return next;
});
setBatchError(null);
}, []);
const clearSelection = useCallback(() => {
setSelectedIds(new Set());
setBatchError(null);
}, []);
const applyUpdatedTags = useCallback(
(updates: TagUpdateResponse["updated"]) => {
if (!updates?.length) return;
const tagMap = new Map(
updates.map((item) => [item.aweme_id, item.content_tags] as const),
);
setColumns((currentColumns) =>
currentColumns.map((column) =>
column.map((item) =>
tagMap.has(item.aweme_id)
? { ...item, content_tags: tagMap.get(item.aweme_id) ?? [] }
: item,
),
),
);
},
[],
);
const handleBatchAddTag = useCallback(async () => {
const awemeIds = [...selectedIds];
if (awemeIds.length === 0 || batchSaving) return;
setBatchSaving(true);
setBatchError(null);
try {
const response = await fetch("/api/asr/tags", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ awemeIds, tag: batchTag, mode: "add" }),
});
if (!response.ok) throw new Error("批量添加标签失败");
const data: TagUpdateResponse = await response.json();
applyUpdatedTags(data.updated);
clearSelection();
} catch (error) {
setBatchError(
error instanceof Error ? error.message : "批量添加标签失败",
);
} finally {
setBatchSaving(false);
}
}, [applyUpdatedTags, batchSaving, batchTag, clearSelection, selectedIds]);
const renderCard = useCallback(
(item: FeedItem) => {
const selected = selectedIds.has(item.aweme_id);
return (
<article
key={item.aweme_id}
className={`mb-4 group relative overflow-hidden rounded-2xl bg-white/80 shadow-sm backdrop-blur-sm transition-transform duration-300 hover:-translate-y-1 dark:bg-zinc-900/60 ${
selected ? "ring-2 ring-blue-500" : "ring-1 ring-black/5"
}`}
>
{item.type === "video" && (
<button
type="button"
onClick={() => toggleSelection(item.aweme_id)}
className={`absolute left-3 top-3 z-20 inline-flex h-8 w-8 items-center justify-center rounded-full border text-white shadow-lg backdrop-blur transition-colors ${
selected
? "border-blue-300 bg-blue-600"
: "border-white/25 bg-black/35 hover:bg-black/55"
}`}
aria-pressed={selected}
aria-label={selected ? "取消选择视频" : "选择视频"}
>
{selected && <Check size={17} />}
</button>
)}
<Link
href={`/aweme/${item.aweme_id}${linkQuery}`}
target="_blank"
className="block relative w-full"
>
<div <div
className="relative w-full" className="relative w-full"
style={{ aspectRatio: `${(item.width && item.height) ? `${item.width}/${item.height}` : ''}` as any }} style={{
aspectRatio: getAspectRatio(item),
}}
> >
{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>
</div> </div>
</div> </div>
</Link>
<div className="flex items-center gap-2 p-3"> <div className="flex items-center gap-2 p-3">
<div className="size-6 rounded-full overflow-hidden bg-zinc-200"> {item.author.sec_uid ? (
<Link
href={`/author/${item.author.sec_uid}`}
className="flex items-center gap-2 min-w-0 flex-1 hover:opacity-80 transition-opacity"
>
<div className="size-6 rounded-full overflow-hidden bg-zinc-200 shrink-0">
{item.author.avatar_url ? ( {item.author.avatar_url ? (
<img src={item.author.avatar_url} alt="avatar" className="w-full h-full object-cover" /> <Image
src={item.author.avatar_url}
alt="avatar"
width={24}
height={24}
unoptimized
className="w-full h-full object-cover"
/>
) : null} ) : null}
</div> </div>
<span className="text-sm text-zinc-700 dark:text-zinc-300 truncate">{item.author.nickname}</span> <span className="text-sm text-zinc-700 dark:text-zinc-300 truncate">
<span className="ml-auto text-sm text-zinc-700 dark:text-zinc-300">{item.likes} </span><ThumbsUp size={16} style={{ color: 'var(--color-zinc-700)' }} /> {item.author.nickname}
</span>
</Link>
) : (
<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">
{item.author.avatar_url ? (
<Image
src={item.author.avatar_url}
alt="avatar"
width={24}
height={24}
unoptimized
className="w-full h-full object-cover"
/>
) : null}
</div>
<span className="text-sm text-zinc-700 dark:text-zinc-300 truncate">
{item.author.nickname}
</span>
</div>
)}
<span className="ml-auto text-sm text-zinc-700 dark:text-zinc-300 flex items-center gap-1">
{item.likes}{" "}
<ThumbsUp size={16} style={{ color: "var(--color-zinc-700)" }} />
</span>
</div> </div>
</article> </article>
</Link> );
), []); },
[selectedIds, toggleSelection],
);
return ( return (
<> <>
{/* Masonry(按列渲染,动态分配到最短列) */} {/* Masonry(按列渲染,动态分配到最短列) */}
<div ref={containerRef} className="grid gap-4" style={{ gridTemplateColumns: `repeat(${columnCount}, minmax(0, 1fr))` }}> <div
ref={containerRef}
className="grid gap-4"
style={{
gridTemplateColumns: `repeat(${columnCount}, minmax(0, 1fr))`,
}}
>
{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>
))} ))}
</div> </div>
<div ref={sentinelRef} className="h-10 flex items-center justify-center text-sm text-zinc-500"> <div
{ended ? '没有更多了' : (loading ? '加载中…' : '下拉加载更多')} ref={sentinelRef}
className="h-10 flex items-center justify-center text-sm text-zinc-500"
>
{ended ? "没有更多了" : loading ? "加载中…" : "下拉加载更多"}
</div> </div>
{selectedIds.size > 0 && (
<div className="fixed bottom-4 left-1/2 z-50 w-[min(92vw,36rem)] -translate-x-1/2 rounded-2xl border border-white/15 bg-zinc-950/92 p-3 text-white shadow-2xl backdrop-blur-xl">
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<div className="flex items-center gap-2 text-sm">
<span className="font-medium">
已选择 {selectedIds.size} 个视频
</span>
{batchError && (
<span className="text-xs text-rose-300">{batchError}</span>
)}
</div>
<div className="flex items-center gap-2">
<select
value={batchTag}
onChange={(event) => setBatchTag(event.target.value)}
className="h-9 rounded-full border border-white/15 bg-white/10 px-3 text-sm text-white outline-none"
aria-label="选择要添加的标签"
>
{VIDEO_CONTENT_TAGS.map((tag) => (
<option
key={tag}
value={tag}
className="bg-zinc-900 text-white"
>
{tag}
</option>
))}
</select>
<button
type="button"
onClick={handleBatchAddTag}
disabled={batchSaving}
className="h-9 rounded-full bg-blue-600 px-4 text-sm font-medium text-white transition-colors hover:bg-blue-500 disabled:cursor-not-allowed disabled:bg-zinc-600"
>
{batchSaving ? "添加中..." : "添加标签"}
</button>
<button
type="button"
onClick={clearSelection}
className="inline-flex h-9 w-9 items-center justify-center rounded-full border border-white/15 bg-white/10 text-white/70 transition-colors hover:bg-white/15 hover:text-white"
aria-label="清空选择"
>
<X size={17} />
</button>
</div>
</div>
</div>
)}
</> </>
); );
} }
+16 -6
View File
@@ -1,6 +1,6 @@
'use client'; "use client";
import React, { useCallback, useRef, useState } from 'react'; import React, { useCallback, useRef, useState } from "react";
type HoverVideoProps = { type HoverVideoProps = {
videoUrl: string; videoUrl: string;
@@ -12,7 +12,12 @@ type HoverVideoProps = {
/** /**
* 鼠标移入才加载视频并自动播放,移出暂停并释放资源;默认显示封面。 * 鼠标移入才加载视频并自动播放,移出暂停并释放资源;默认显示封面。
*/ */
export default function HoverVideo({ videoUrl, coverUrl, className, style }: HoverVideoProps) { export default function HoverVideo({
videoUrl,
coverUrl,
className,
style,
}: HoverVideoProps) {
const [active, setActive] = useState(false); const [active, setActive] = useState(false);
const videoRef = useRef<HTMLVideoElement | null>(null); const videoRef = useRef<HTMLVideoElement | null>(null);
@@ -38,14 +43,19 @@ export default function HoverVideo({ videoUrl, coverUrl, className, style }: Hov
}, []); }, []);
return ( return (
<div className={className} style={style} onMouseEnter={onEnter} onMouseLeave={onLeave}> <div
className={className}
style={style}
onMouseEnter={onEnter}
onMouseLeave={onLeave}
>
{/* 封面始终渲染在底层 */} {/* 封面始终渲染在底层 */}
<img <img
src={coverUrl || '/placeholder.svg'} src={coverUrl || "/placeholder.svg"}
alt="cover" alt="cover"
className="absolute inset-0 w-full h-full object-cover" className="absolute inset-0 w-full h-full object-cover"
draggable={false} draggable={false}
loading='lazy' loading="lazy"
/> />
{/* 仅在激活后渲染视频;初始不设置 src,防止提前加载 */} {/* 仅在激活后渲染视频;初始不设置 src,防止提前加载 */}
+42
View File
@@ -0,0 +1,42 @@
"use client";
import { Search } from "lucide-react";
import { useRouter } from "next/navigation";
import { useState } from "react";
export default function SearchBox({ tag }: { tag?: string | null }) {
const [query, setQuery] = useState("");
const router = useRouter();
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
const trimmed = query.trim();
if (!trimmed) return;
const params = new URLSearchParams({ q: trimmed });
if (tag) params.set("tag", tag);
router.push(`/search?${params.toString()}`);
};
return (
<form onSubmit={handleSubmit} className="relative w-full">
<input
type="text"
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder="搜索视频、图文描述或语音内容..."
className="w-full bg-white/80 dark:bg-zinc-800/80 border border-zinc-300 dark:border-zinc-700 rounded-full px-5 py-3 pl-12 pr-12 text-zinc-900 dark:text-white placeholder-zinc-500 dark:placeholder-zinc-400 focus:outline-none focus:ring-2 focus:ring-blue-500/50 focus:border-blue-500 transition-all shadow-sm"
/>
<Search
size={20}
className="absolute left-4 top-1/2 -translate-y-1/2 text-zinc-500 dark:text-zinc-400"
/>
<button
type="submit"
className="absolute right-2 top-1/2 -translate-y-1/2 px-4 py-1.5 bg-blue-600 hover:bg-blue-700 text-white text-sm rounded-full font-medium transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
disabled={!query.trim()}
>
搜索
</button>
</form>
);
}
BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 25 KiB

+46 -3
View File
@@ -3,6 +3,11 @@
:root { :root {
--background: #161823; /* theme background */ --background: #161823; /* theme background */
--foreground: #ededed; --foreground: #ededed;
--font-geist-sans:
-apple-system, BlinkMacSystemFont, "Segoe UI", "PingFang SC",
"Hiragino Sans GB", "Microsoft YaHei", sans-serif;
--font-geist-mono:
"SFMono-Regular", Consolas, "Liberation Mono", Menlo, monospace;
} }
@theme inline { @theme inline {
@@ -23,12 +28,26 @@ body {
margin: 0; margin: 0;
background: var(--background); background: var(--background);
color: var(--foreground); color: var(--foreground);
font-family: Arial, Helvetica, sans-serif; font-family: var(--font-geist-sans);
} }
/* 滚动条隐藏 */ /* 滚动条隐藏 */
.no-scrollbar::-webkit-scrollbar { display: none; } .no-scrollbar::-webkit-scrollbar {
.no-scrollbar { -ms-overflow-style: none; scrollbar-width: none; } display: none;
}
/* 搜索结果高亮标记样式 */
mark {
background-color: rgb(234 179 8 / 0.3); /* yellow-500/30 */
color: rgb(253 224 71); /* yellow-300 */
padding: 0.125rem 0.25rem;
border-radius: 0.25rem;
font-weight: 500;
}
.no-scrollbar {
-ms-overflow-style: none;
scrollbar-width: none;
}
.h-screen { .h-screen {
height: 100dvh; height: 100dvh;
@@ -37,3 +56,27 @@ body {
.min-h-screen { .min-h-screen {
min-height: 100dvh; min-height: 100dvh;
} }
/* 评论面板自定义滚动条样式 */
.comment-scroll::-webkit-scrollbar {
width: 6px;
}
.comment-scroll::-webkit-scrollbar-track {
background: transparent;
}
.comment-scroll::-webkit-scrollbar-thumb {
background: rgba(255, 255, 255, 0.2);
border-radius: 3px;
transition: background 0.2s;
}
.comment-scroll::-webkit-scrollbar-thumb:hover {
background: rgba(255, 255, 255, 0.3);
}
.comment-scroll {
scrollbar-width: thin;
scrollbar-color: rgba(255, 255, 255, 0.2) transparent;
}
+4 -16
View File
@@ -1,23 +1,15 @@
import type { Metadata } from "next"; import type { Metadata } from "next";
import { Geist, Geist_Mono } from "next/font/google";
import "./globals.css"; import "./globals.css";
const geistSans = Geist({
variable: "--font-geist-sans",
subsets: ["latin"],
});
const geistMono = Geist_Mono({
variable: "--font-geist-mono",
subsets: ["latin"],
});
export const metadata: Metadata = { export const metadata: Metadata = {
title: { title: {
default: "抖歪 - 记录当下时代", default: "抖歪 - 记录当下时代",
template: "%s - 抖歪", template: "%s - 抖歪",
}, },
description: "记录当下时代的精彩瞬间", description: "记录当下时代的精彩瞬间",
icons: {
icon: "/favicon.webp",
},
}; };
export default function RootLayout({ export default function RootLayout({
@@ -27,11 +19,7 @@ export default function RootLayout({
}>) { }>) {
return ( return (
<html lang="zh-CN"> <html lang="zh-CN">
<body <body className="antialiased">{children}</body>
className={`${geistSans.variable} ${geistMono.variable} antialiased`}
>
{children}
</body>
</html> </html>
); );
} }
+116 -21
View File
@@ -1,24 +1,51 @@
import { prisma } from "@/lib/prisma";
import FeedMasonry from "./components/FeedMasonry";
import type { FeedItem } from "./types/feed";
import type { Metadata } from "next"; import type { Metadata } from "next";
import Link from "next/link";
import { getFileUrl } from "@/lib/minio";
import { playbackContextQuery } from "@/lib/playback-context";
import { prisma } from "@/lib/prisma";
import { isVideoContentTag, VIDEO_CONTENT_TAGS } from "@/lib/video-tags";
import FeedMasonry from "./components/FeedMasonry";
import SearchBox from "./components/SearchBox";
import type { FeedItem } from "./types/feed";
export const dynamic = "force-dynamic";
export const metadata: Metadata = { export const metadata: Metadata = {
title: "作品集 - 抖歪", title: "作品集 - 抖歪",
description: "抖歪作品集,记录当下时代的精彩瞬间", description: "抖歪作品集,记录当下时代的精彩瞬间",
}; };
export default async function Home() { export default async function Home({
searchParams,
}: {
searchParams?: { tag?: string };
}) {
const requestedTag = searchParams?.tag?.trim() || "";
const selectedTag =
requestedTag && isVideoContentTag(requestedTag) ? requestedTag : null;
const [videos, posts] = await Promise.all([ const [videos, posts] = await Promise.all([
prisma.video.findMany({ prisma.video.findMany({
where: selectedTag
? {
manual_content_tags: { has: selectedTag },
}
: undefined,
orderBy: { created_at: "desc" }, orderBy: { created_at: "desc" },
take: 60, take: 60,
include: { author: true }, include: {
author: 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 },
},
}), }),
]); ]);
@@ -28,39 +55,107 @@ export default async function Home() {
aweme_id: v.aweme_id, aweme_id: v.aweme_id,
created_at: v.created_at, created_at: v.created_at,
desc: v.desc, desc: v.desc,
video_url: v.video_url, video_url: getFileUrl(v.video_url),
cover_url: v.cover_url ?? null, cover_url: getFileUrl(v.cover_url ?? ""),
width: v.width ?? null, width: v.width ?? null,
height: v.height ?? null, height: v.height ?? null,
author: { nickname: v.author.nickname, avatar_url: v.author.avatar_url ?? null }, author: {
likes: Number(v.digg_count) nickname: v.author.nickname,
avatar_url: getFileUrl(v.author.avatar_url ?? ""),
sec_uid: v.author.sec_uid,
},
likes: Number(v.digg_count),
content_tags: v.manual_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: 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: { nickname: p.author.nickname, avatar_url: p.author.avatar_url ?? null }, author: {
likes: Number(p.digg_count) nickname: p.author.nickname,
})), avatar_url: getFileUrl(p.author.avatar_url ?? ""),
sec_uid: p.author.sec_uid,
},
likes: Number(p.digg_count),
};
}),
] ]
//.sort(() => Math.random() - 0.5) //.sort(() => 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));
return ( return (
<main className="min-h-screen w-full px-4 py-8 md:py-12"> <main className="min-h-screen w-full bg-gray-50 dark:bg-black transition-colors duration-300">
<h1 className="text-2xl md:text-3xl font-semibold tracking-tight mb-6"> <div className="relative pt-16 pb-12 px-4 sm:px-6 lg:px-8 flex flex-col items-center">
作品集 {/* 装饰背景 */}
<div className="absolute inset-0 -z-10 overflow-hidden pointer-events-none">
<div className="absolute left-[50%] top-0 h-[40rem] w-[40rem] -translate-x-1/2 -translate-y-1/2 rounded-full bg-gradient-to-b from-indigo-500/20 to-transparent blur-3xl dark:from-indigo-500/10" />
</div>
<h1 className="text-4xl md:text-5xl font-bold tracking-tight text-center mb-8 bg-clip-text text-transparent bg-gradient-to-r from-gray-900 to-gray-600 dark:from-white dark:to-gray-400">
Douyin Archive
</h1> </h1>
<div className="w-full max-w-xl mb-12">
<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>
{VIDEO_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 className="w-full">
{(() => { {(() => {
const initial = feed.slice(0, 24); const initial = feed.slice(0, 24);
const cursor = initial.length > 0 ? new Date(initial[initial.length - 1].created_at as any).toISOString() : null; const lastItem = initial.at(-1);
return <FeedMasonry initialItems={initial} initialCursor={cursor} />; const cursor = lastItem
? new Date(lastItem.created_at).toISOString()
: null;
return (
<FeedMasonry
key={selectedTag ?? "all"}
initialItems={initial}
initialCursor={cursor}
linkQuery={playbackContextQuery(
selectedTag ? { kind: "tag", tag: selectedTag } : null,
)}
fetchUrl={
selectedTag
? `/api/feed?tag=${encodeURIComponent(selectedTag)}`
: "/api/feed"
}
/>
);
})()} })()}
</div>
</div>
</main> </main>
); );
} }
+474
View File
@@ -0,0 +1,474 @@
"use client";
import { ArrowLeft, FileText, Mic, Search, X } from "lucide-react";
import Image from "next/image";
import Link from "next/link";
import { useRouter, useSearchParams } from "next/navigation";
import {
type FormEvent,
useCallback,
useEffect,
useMemo,
useRef,
useState,
} from "react";
import { playbackContextQuery } from "@/lib/playback-context";
import type { SnippetPart } from "@/lib/search";
import { isVideoContentTag, VIDEO_CONTENT_TAGS } from "@/lib/video-tags";
// 匹配搜索结果类型(适配后端API)
type SearchResultItem = {
id: string;
awemeId: string;
type: "video" | "image";
score: number;
matchedIn: ("desc" | "transcript")[];
descParts: SnippetPart[]; // 完整描述,标出命中位置
snippet: SnippetPart[] | null; // 语音转写的命中片段
contentTags: string[];
video?: {
aweme_id: string;
desc: string;
cover_url: string | null;
video_url: string | null;
duration_ms: number;
author: {
sec_uid: string;
nickname: string;
avatar_url: string | null;
};
};
imagePost?: {
aweme_id: string;
desc: string;
cover_url: string | null;
author: {
sec_uid: string;
nickname: string;
avatar_url: string | null;
};
};
};
const PAGE_SIZE = 30;
function renderParts(parts: SnippetPart[]) {
let offset = 0;
return parts.map((part) => {
const key = `${offset}-${part.text}`;
offset += part.text.length;
return part.hit ? (
<mark
key={key}
className="rounded-sm bg-yellow-400/80 px-0.5 text-zinc-950"
>
{part.text}
</mark>
) : (
<span key={key}>{part.text}</span>
);
});
}
export default function SearchClient({
initialQuery,
}: {
initialQuery: string;
}) {
const searchParams = useSearchParams();
const router = useRouter();
const [query, setQuery] = useState(initialQuery);
const [results, setResults] = useState<SearchResultItem[]>([]);
const [total, setTotal] = useState(0);
const [page, setPage] = useState(1);
const [loading, setLoading] = useState(false);
const [loadingMore, setLoadingMore] = useState(false);
// 只采用最近一次请求的结果,避免快速切换关键词时旧结果覆盖新结果
const requestIdRef = useRef(0);
const [searched, setSearched] = useState(false);
const [selectedTag, setSelectedTag] = useState<string | null>(null);
const buildSearchHref = useCallback((q: string, tag: string | null) => {
const params = new URLSearchParams();
const trimmed = q.trim();
if (trimmed) params.set("q", trimmed);
if (tag) params.set("tag", tag);
const queryString = params.toString();
return queryString ? `/search?${queryString}` : "/search";
}, []);
const fetchPage = useCallback(
async (q: string, tag: string | null, pageNum: number) => {
const params = new URLSearchParams({
limit: String(PAGE_SIZE),
page: String(pageNum),
});
if (q) params.set("q", q);
if (tag) params.set("tag", tag);
const res = await fetch(`/api/search?${params.toString()}`);
if (!res.ok) throw new Error("Search failed");
return (await res.json()) as {
results?: SearchResultItem[];
total?: number;
};
},
[],
);
const performSearch = useCallback(
async (q: string, tag: string | null) => {
const trimmed = q.trim();
const requestId = ++requestIdRef.current;
if (!trimmed && !tag) {
setResults([]);
setTotal(0);
setSearched(false);
return;
}
setLoading(true);
setSearched(true);
setPage(1);
try {
const data = await fetchPage(trimmed, tag, 1);
if (requestId !== requestIdRef.current) return;
setResults(data.results || []);
setTotal(data.total ?? 0);
} catch (err) {
if (requestId !== requestIdRef.current) return;
console.error("Search error:", err);
setResults([]);
setTotal(0);
} finally {
if (requestId === requestIdRef.current) setLoading(false);
}
},
[fetchPage],
);
const loadMore = async () => {
const q = (searchParams.get("q") || "").trim();
const tagParam = searchParams.get("tag") || "";
const tag = tagParam && isVideoContentTag(tagParam) ? tagParam : null;
const requestId = requestIdRef.current;
const nextPage = page + 1;
setLoadingMore(true);
try {
const data = await fetchPage(q, tag, nextPage);
if (requestId !== requestIdRef.current) return;
setResults((prev) => {
const seen = new Set(prev.map((r) => r.id));
return [
...prev,
...(data.results || []).filter((r) => !seen.has(r.id)),
];
});
setTotal(data.total ?? 0);
setPage(nextPage);
} catch (err) {
console.error("Search error:", err);
} finally {
if (requestId === requestIdRef.current) setLoadingMore(false);
}
};
// 初始 & URL 变化时同步(用 searchParams 订阅 URL 更稳妥)
useEffect(() => {
const q = searchParams.get("q") || "";
const tagParam = searchParams.get("tag") || "";
const tag = tagParam && isVideoContentTag(tagParam) ? tagParam : null;
setQuery(q);
setSelectedTag(tag);
if (q || tag) {
performSearch(q, tag);
} else {
requestIdRef.current++;
setResults([]);
setTotal(0);
setSearched(false);
}
}, [searchParams, performSearch]);
const handleSearch = (e: FormEvent) => {
e.preventDefault();
const trimmed = query.trim();
if (!trimmed && !selectedTag) return;
router.push(buildSearchHref(trimmed, selectedTag));
};
const handleTagSelect = (tag: string) => {
const nextTag = selectedTag === tag ? null : tag;
setSelectedTag(nextTag);
router.push(buildSearchHref(query, nextTag));
};
const handleClear = () => {
setQuery("");
setSelectedTag(null);
setResults([]);
setTotal(0);
setSearched(false);
router.push("/search");
};
// 从搜索结果进入播放页时,上下切换在这次的搜索结果里移动(与当前 URL 上的搜索条件一致)
const resultLinkQuery = useMemo(() => {
const q = (searchParams.get("q") || "").trim();
const tagParam = searchParams.get("tag") || "";
const tag = tagParam && isVideoContentTag(tagParam) ? tagParam : null;
if (!q && !tag) return "";
return playbackContextQuery({ kind: "search", q, tag });
}, [searchParams]);
return (
<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">
<div className="max-w-7xl mx-auto px-4 py-4 flex items-center gap-4">
<button
type="button"
onClick={() => router.push("/")}
className="p-2 rounded-full hover:bg-white/10 transition-colors"
aria-label="返回首页"
>
<ArrowLeft size={24} />
</button>
<form
onSubmit={handleSearch}
className="flex-1 flex items-center gap-3"
>
<div className="relative flex-1">
<input
type="text"
value={query}
onChange={(e) => setQuery(e.target.value)}
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"
/>
<Search
size={20}
className="absolute left-4 top-1/2 -translate-y-1/2 text-white/40"
/>
{(query || selectedTag) && (
<button
type="button"
onClick={handleClear}
className="absolute right-4 top-1/2 -translate-y-1/2 p-1 rounded-full hover:bg-white/10 transition-colors"
aria-label="清空"
>
<X size={18} className="text-white/60" />
</button>
)}
</div>
<button
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"
disabled={loading || (!query.trim() && !selectedTag)}
>
{loading ? "搜索中..." : "搜索"}
</button>
</form>
</div>
<div className="max-w-7xl mx-auto px-4 pb-4">
<div className="flex gap-2 overflow-x-auto">
{VIDEO_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>
{/* 结果区域 */}
<main className="max-w-7xl mx-auto px-4 py-8">
{loading && (
<div className="flex items-center justify-center py-20">
<div className="flex flex-col items-center gap-4">
<div className="w-12 h-12 border-4 border-blue-500/30 border-t-blue-500 rounded-full animate-spin" />
<p className="text-white/60">正在搜索...</p>
</div>
</div>
)}
{!loading && searched && results.length === 0 && (
<div className="flex flex-col items-center justify-center py-20 text-white/60">
<Search size={64} className="mb-4 opacity-20" />
<p className="text-xl">未找到相关内容</p>
<p className="text-sm mt-2">试试其他关键词</p>
</div>
)}
{!loading && !searched && !query && !selectedTag && (
<div className="flex flex-col items-center justify-center py-20 text-white/40">
<Search size={64} className="mb-4 opacity-20" />
<p className="text-xl">输入关键词开始搜索</p>
<p className="text-sm mt-2">
按原文匹配描述和语音转写,多个关键词用空格分隔,以 - 开头表示排除
</p>
</div>
)}
{!loading && results.length > 0 && (
<div>
<div className="mb-6 flex items-center justify-between">
<h2 className="text-lg text-white/80">
找到 <span className="text-white font-semibold">{total}</span>{" "}
个结果
</h2>
</div>
{/* 单列列表布局 */}
<div className="space-y-4">
{results.map((item) => {
const content =
item.type === "video" ? item.video : item.imagePost;
if (!content) return null;
return (
<div
key={item.id}
className="bg-white/5 backdrop-blur-sm border border-white/10 rounded-2xl overflow-hidden hover:border-white/20 transition-all hover:-translate-y-1"
>
{/* 主内容区 */}
<div className="flex gap-4 p-4">
{/* 左侧:封面预览 */}
<Link
href={`/aweme/${content.aweme_id}${resultLinkQuery}`}
target="_blank"
className="flex-shrink-0 w-64 h-80 sm:w-70 sm:h-96 rounded-xl overflow-hidden bg-zinc-900 group"
>
{content.cover_url ? (
<Image
src={content.cover_url}
alt={content.desc}
width={280}
height={384}
unoptimized
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>
{/* 右侧:信息区 */}
<div className="flex-1 min-w-0 flex flex-col">
{/* 类型标签 */}
<div className="flex items-center gap-2 mb-2">
<span
className={`px-2 py-0.5 text-xs rounded ${
item.type === "video"
? "bg-blue-600/20 text-blue-300"
: "bg-purple-600/20 text-purple-300"
}`}
>
{item.type === "video" ? "视频" : "图文"}
</span>
{item.matchedIn.includes("desc") && (
<span className="flex items-center gap-1 px-2 py-0.5 bg-green-600/20 text-green-300 text-xs rounded">
<FileText size={12} />
描述命中
</span>
)}
{item.matchedIn.includes("transcript") && (
<span className="flex items-center gap-1 px-2 py-0.5 bg-green-600/20 text-green-300 text-xs rounded">
<Mic size={12} />
语音命中
</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>
{/* 描述 */}
<Link
href={`/aweme/${content.aweme_id}${resultLinkQuery}`}
target="_blank"
>
<p className="text-white/90 text-sm sm:text-base line-clamp-3 mb-3 hover:text-white transition-colors">
{item.descParts.length > 0
? renderParts(item.descParts)
: "无描述"}
</p>
</Link>
{/* 作者信息 */}
<div className="flex items-center gap-2 mb-4">
{content.author.avatar_url ? (
<Image
src={content.author.avatar_url}
alt={content.author.nickname}
width={24}
height={24}
unoptimized
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">
{content.author.nickname}
</span>
</div>
{/* 语音转写片段:命中时展示命中位置附近的文字 */}
{item.snippet && item.snippet.length > 0 && (
<div>
<h4 className="flex items-center gap-2 text-xs font-medium text-green-300 mb-1.5">
<Mic size={14} />
{item.matchedIn.includes("transcript")
? "语音内容"
: "语音转写"}
</h4>
<div
className="text-xs text-white/70 bg-white/5 rounded-lg p-2 leading-relaxed"
style={{ wordBreak: "break-word" }}
>
{renderParts(item.snippet)}
</div>
</div>
)}
</div>
</div>
</div>
);
})}
</div>
{results.length < total && (
<div className="mt-8 flex justify-center">
<button
type="button"
onClick={loadMore}
disabled={loadingMore}
className="rounded-full border border-white/10 bg-white/5 px-6 py-2.5 text-sm text-white/80 transition-colors hover:border-white/20 hover:text-white disabled:opacity-50"
>
{loadingMore
? "加载中..."
: `加载更多(还有 ${total - results.length} 个)`}
</button>
</div>
)}
</div>
)}
</main>
</div>
);
}
+16
View File
@@ -0,0 +1,16 @@
// 不要写 "use client"
import { Suspense } from "react";
import SearchClient from "./SearchClient";
export default function Page({
searchParams,
}: {
searchParams: { q?: string };
}) {
const q = typeof searchParams.q === "string" ? searchParams.q : "";
return (
<Suspense fallback={<div className="p-6 text-zinc-400">Loading…</div>}>
<SearchClient initialQuery={q} />
</Suspense>
);
}
+243 -68
View File
@@ -1,7 +1,20 @@
"use client"; "use client";
import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { AlertTriangle, CheckCircle2, Clock, ExternalLink, Link2, Loader2, PlayCircle, Plus, Square, Trash2, X } from "lucide-react"; import {
AlertTriangle,
CheckCircle2,
Clipboard,
Clock,
ExternalLink,
Link2,
Loader2,
PlayCircle,
Plus,
Square,
Trash2,
X,
} from "lucide-react";
type TaskStatus = "pending" | "running" | "success" | "error"; type TaskStatus = "pending" | "running" | "success" | "error";
@@ -22,7 +35,7 @@ const extractDouyinLinks = (text: string): string[] => {
const trailing = /[)\]】>。,、!!??\s]+$/; // 去掉常见中文/英文结尾符号 const trailing = /[)\]】>。,、!!??\s]+$/; // 去掉常见中文/英文结尾符号
const cleaned = matches const cleaned = matches
.map((m) => m.replace(trailing, "")) .map((m) => m.replace(trailing, ""))
.map((m) => m.endsWith("/") ? m : m); // 保持原样,通常短链以 / 结尾 .map((m) => (m.endsWith("/") ? m : m)); // 保持原样,通常短链以 / 结尾
// 去重 // 去重
return Array.from(new Set(cleaned)); return Array.from(new Set(cleaned));
}; };
@@ -43,16 +56,22 @@ export default function TasksPage() {
}, []); }, []);
const inProgressUrls = useMemo( const inProgressUrls = useMemo(
() => new Set(tasks.filter(t => t.status === "pending" || t.status === "running").map(t => t.url)), () =>
[tasks] new Set(
tasks
.filter((t) => t.status === "pending" || t.status === "running")
.map((t) => t.url),
),
[tasks],
); );
const addTasks = useCallback((urls: string[]) => { const addTasks = useCallback(
(urls: string[]) => {
if (!urls.length) return; if (!urls.length) return;
const now = Date.now(); const now = Date.now();
setTasks((prev) => { setTasks((prev) => {
const existing = new Set(prev.map((t) => t.id)); const existing = new Set(prev.map((t) => t.id));
const notDuplicated = urls.filter(u => !inProgressUrls.has(u)); const notDuplicated = urls.filter((u) => !inProgressUrls.has(u));
const newTasks: Task[] = []; const newTasks: Task[] = [];
for (const url of notDuplicated) { for (const url of notDuplicated) {
const id = `${now}-${Math.random().toString(36).slice(2, 8)}`; const id = `${now}-${Math.random().toString(36).slice(2, 8)}`;
@@ -61,40 +80,86 @@ export default function TasksPage() {
// 新任务添加到最前面 // 新任务添加到最前面
return [...newTasks, ...prev]; return [...newTasks, ...prev];
}); });
}, [inProgressUrls]); },
[inProgressUrls],
);
const handleSubmit = useCallback((e?: React.FormEvent) => { const handleSubmit = useCallback(
(e?: React.FormEvent) => {
e?.preventDefault(); e?.preventDefault();
const urls = extractDouyinLinks(input); const urls = extractDouyinLinks(input);
if (!urls.length) { if (!urls.length) {
alert("未检测到 Douyin 短链,请粘贴包含 https://v.douyin.com/... 的文本"); alert(
"未检测到 Douyin 短链,请粘贴包含 https://v.douyin.com/... 的文本",
);
return; return;
} }
addTasks(urls); addTasks(urls);
setInput(""); setInput("");
}, [input, addTasks]); },
[input, addTasks],
);
const handlePasteAndAdd = useCallback(async () => {
try {
const text = await navigator.clipboard.readText();
const urls = extractDouyinLinks(text);
if (!urls.length) {
alert(
"剪贴板中未检测到 Douyin 短链,请复制包含 https://v.douyin.com/... 的文本",
);
return;
}
addTasks(urls);
} catch (err) {
alert("无法读取剪贴板内容,请检查浏览器权限设置");
console.error("Clipboard read error:", err);
}
}, [addTasks]);
const startTask = useCallback(async (task: Task) => { const startTask = useCallback(async (task: Task) => {
// 若已存在控制器,避免重复启动 // 若已存在控制器,避免重复启动
if (controllers.current.has(task.id)) return; if (controllers.current.has(task.id)) return;
const ctrl = new AbortController(); const ctrl = new AbortController();
controllers.current.set(task.id, ctrl); controllers.current.set(task.id, ctrl);
setTasks(prev => prev.map(t => t.id === task.id ? { ...t, status: "running", startedAt: Date.now(), error: undefined } : t)); setTasks((prev) =>
prev.map((t) =>
t.id === task.id
? { ...t, status: "running", startedAt: Date.now(), error: undefined }
: t,
),
);
try { try {
const res = await fetch(`/api/fetcher?url=${encodeURIComponent(task.url)}`, { signal: ctrl.signal, method: "GET" }); const res = await fetch(
`/api/fetcher?url=${encodeURIComponent(task.url)}`,
{ signal: ctrl.signal, method: "GET" },
);
const data = await res.json().catch(() => null); const data = await res.json().catch(() => null);
if (!res.ok) { if (!res.ok) {
// 使用后端返回的结构化错误信息 // 使用后端返回的结构化错误信息
const errorMsg = data?.error || `请求失败: ${res.status}`; const errorMsg = data?.error || `请求失败: ${res.status}`;
const errorCode = data?.code || 'UNKNOWN'; const errorCode = data?.code || "UNKNOWN";
throw new Error(`${errorMsg} (${errorCode})`); throw new Error(`${errorMsg} (${errorCode})`);
} }
setTasks(prev => prev.map(t => t.id === task.id ? { ...t, status: "success", finishedAt: Date.now(), result: data } : t)); setTasks((prev) =>
prev.map((t) =>
t.id === task.id
? { ...t, status: "success", finishedAt: Date.now(), result: data }
: t,
),
);
} catch (err: any) { } catch (err: any) {
const msg = err?.name === 'AbortError' ? '已取消' : (err?.message || String(err)); const msg =
setTasks(prev => prev.map(t => t.id === task.id ? { ...t, status: "error", finishedAt: Date.now(), error: msg } : t)); err?.name === "AbortError" ? "已取消" : err?.message || String(err);
setTasks((prev) =>
prev.map((t) =>
t.id === task.id
? { ...t, status: "error", finishedAt: Date.now(), error: msg }
: t,
),
);
} finally { } finally {
controllers.current.delete(task.id); controllers.current.delete(task.id);
} }
@@ -102,17 +167,17 @@ export default function TasksPage() {
// 自动拉起 pending 任务(使用 effect 防止每次 render 重复触发) // 自动拉起 pending 任务(使用 effect 防止每次 render 重复触发)
useEffect(() => { useEffect(() => {
const pending = tasks.filter(t => t.status === "pending"); const pending = tasks.filter((t) => t.status === "pending");
pending.forEach((t) => startTask(t)); pending.forEach((t) => startTask(t));
}, [tasks, startTask]); }, [tasks, startTask]);
// 定时器更新运行中任务的耗时显示 // 定时器更新运行中任务的耗时显示
useEffect(() => { useEffect(() => {
const hasRunningTasks = tasks.some(t => t.status === "running"); const hasRunningTasks = tasks.some((t) => t.status === "running");
if (!hasRunningTasks) return; if (!hasRunningTasks) return;
const timer = setInterval(() => { const timer = setInterval(() => {
setTick(prev => prev + 1); setTick((prev) => prev + 1);
}, 1000); // 每秒更新一次 }, 1000); // 每秒更新一次
return () => clearInterval(timer); return () => clearInterval(timer);
@@ -125,31 +190,45 @@ export default function TasksPage() {
}, []); }, []);
const retryTask = useCallback((taskId: string) => { const retryTask = useCallback((taskId: string) => {
setTasks(prev => prev.map(t => { setTasks((prev) =>
prev.map((t) => {
if (t.id === taskId) { if (t.id === taskId) {
return { ...t, status: "pending" as TaskStatus, error: undefined, result: undefined }; return {
...t,
status: "pending" as TaskStatus,
error: undefined,
result: undefined,
};
} }
return t; return t;
})); }),
);
}, []); }, []);
const clearFinished = useCallback(() => { const clearFinished = useCallback(() => {
setTasks(prev => prev.filter(t => t.status === "pending" || t.status === "running")); setTasks((prev) =>
prev.filter((t) => t.status === "pending" || t.status === "running"),
);
}, []); }, []);
const toggleOpen = useCallback((id: string) => { const toggleOpen = useCallback((id: string) => {
setOpenDetails(prev => { setOpenDetails((prev) => {
const next = new Set(prev); const next = new Set(prev);
if (next.has(id)) next.delete(id); else next.add(id); if (next.has(id)) next.delete(id);
else next.add(id);
return next; return next;
}) });
}, []); }, []);
const extractedCount = useMemo(() => extractDouyinLinks(input).length, [input]); const extractedCount = useMemo(
() => extractDouyinLinks(input).length,
[input],
);
const formatDuration = (t?: number) => { const formatDuration = (startTime?: number, endTime?: number) => {
if (!t) return ""; if (!startTime) return "";
const secs = Math.max(0, Math.round((Date.now() - t) / 1000)); const end = endTime || Date.now();
const secs = Math.max(0, Math.round((end - startTime) / 1000));
if (secs < 60) return `${secs}s`; if (secs < 60) return `${secs}s`;
const m = Math.floor(secs / 60); const m = Math.floor(secs / 60);
const s = secs % 60; const s = secs % 60;
@@ -157,11 +236,31 @@ export default function TasksPage() {
}; };
const StatusBadge = ({ status }: { status: TaskStatus }) => { const StatusBadge = ({ status }: { status: TaskStatus }) => {
const base = "inline-flex items-center gap-1 rounded-full px-2 py-0.5 text-xs font-medium"; const base =
if (status === 'running') return <span className={`${base} bg-indigo-500/15 text-indigo-300`}><Loader2 className="h-3.5 w-3.5 animate-spin"/> 进行中</span>; "inline-flex items-center gap-1 rounded-full px-2 py-0.5 text-xs font-medium";
if (status === 'pending') return <span className={`${base} bg-yellow-500/15 text-yellow-300`}><Clock className="h-3.5 w-3.5"/> 待开始</span>; if (status === "running")
if (status === 'success') return <span className={`${base} bg-emerald-500/15 text-emerald-300`}><CheckCircle2 className="h-3.5 w-3.5"/> 完成</span>; return (
return <span className={`${base} bg-red-500/15 text-red-300`}><AlertTriangle className="h-3.5 w-3.5"/> 失败</span>; <span className={`${base} bg-indigo-500/15 text-indigo-300`}>
<Loader2 className="h-3.5 w-3.5 animate-spin" /> 进行中
</span>
);
if (status === "pending")
return (
<span className={`${base} bg-yellow-500/15 text-yellow-300`}>
<Clock className="h-3.5 w-3.5" /> 待开始
</span>
);
if (status === "success")
return (
<span className={`${base} bg-emerald-500/15 text-emerald-300`}>
<CheckCircle2 className="h-3.5 w-3.5" /> 完成
</span>
);
return (
<span className={`${base} bg-red-500/15 text-red-300`}>
<AlertTriangle className="h-3.5 w-3.5" /> 失败
</span>
);
}; };
return ( return (
@@ -176,11 +275,16 @@ export default function TasksPage() {
<h1 className="text-3xl font-semibold tracking-tight text-white"> <h1 className="text-3xl font-semibold tracking-tight text-white">
抖音采集任务 抖音采集任务
</h1> </h1>
<p className="mt-2 text-sm text-neutral-400">粘贴任意文本,我会自动提取 Douyin 短链并提交到后端处理。</p> <p className="mt-2 text-sm text-neutral-400">
粘贴任意文本,我会自动提取 Douyin 短链并提交到后端处理。
</p>
</div> </div>
{/* 输入卡片 */} {/* 输入卡片 */}
<form onSubmit={handleSubmit} className="rounded-xl border border-white/10 bg-white/5 p-4 shadow-[0_0_0_1px_rgba(255,255,255,0.03)] backdrop-blur"> <form
onSubmit={handleSubmit}
className="rounded-xl border border-white/10 bg-white/5 p-4 shadow-[0_0_0_1px_rgba(255,255,255,0.03)] backdrop-blur"
>
<div className="flex items-start gap-3"> <div className="flex items-start gap-3">
<div className="mt-1 rounded-md bg-neutral-900/60 p-2 ring-1 ring-white/5"> <div className="mt-1 rounded-md bg-neutral-900/60 p-2 ring-1 ring-white/5">
<Link2 className="h-5 w-5 text-neutral-400" /> <Link2 className="h-5 w-5 text-neutral-400" />
@@ -194,15 +298,31 @@ export default function TasksPage() {
/> />
<div className="mt-2 flex items-center justify-between text-xs text-neutral-500"> <div className="mt-2 flex items-center justify-between text-xs text-neutral-500">
<span>已提取 {extractedCount} 个短链</span> <span>已提取 {extractedCount} 个短链</span>
<span className="hidden sm:inline">快捷键:Ctrl / Cmd + Enter 提交</span> <span className="hidden sm:inline">
快捷键:Ctrl / Cmd + Enter 提交
</span>
</div> </div>
</div> </div>
</div> </div>
<div className="mt-4 flex items-center gap-3"> <div className="mt-4 flex flex-wrap items-center gap-3">
<button type="submit" className="inline-flex items-center gap-2 rounded-md bg-indigo-600/90 px-3 py-2 text-sm font-medium text-white transition-colors hover:bg-indigo-600 focus:outline-none focus:ring-2 focus:ring-indigo-400/40"> <button
type="submit"
className="inline-flex items-center gap-2 rounded-md bg-indigo-600/90 px-3 py-2 text-sm font-medium text-white transition-colors hover:bg-indigo-600 focus:outline-none focus:ring-2 focus:ring-indigo-400/40"
>
<Plus className="h-4 w-4" /> 添加任务 <Plus className="h-4 w-4" /> 添加任务
</button> </button>
<button type="button" onClick={clearFinished} className="inline-flex items-center gap-2 rounded-md bg-neutral-800 px-3 py-2 text-sm text-neutral-200 transition-colors hover:bg-neutral-700 focus:outline-none focus:ring-2 focus:ring-white/10"> <button
type="button"
onClick={handlePasteAndAdd}
className="inline-flex items-center gap-2 rounded-md bg-emerald-600/90 px-3 py-2 text-sm font-medium text-white transition-colors hover:bg-emerald-600 focus:outline-none focus:ring-2 focus:ring-emerald-400/40"
>
<Clipboard className="h-4 w-4" /> 粘贴并添加
</button>
<button
type="button"
onClick={clearFinished}
className="inline-flex items-center gap-2 rounded-md bg-neutral-800 px-3 py-2 text-sm text-neutral-200 transition-colors hover:bg-neutral-700 focus:outline-none focus:ring-2 focus:ring-white/10"
>
<Trash2 className="h-4 w-4" /> 清除已完成 <Trash2 className="h-4 w-4" /> 清除已完成
</button> </button>
</div> </div>
@@ -212,34 +332,50 @@ export default function TasksPage() {
<section className="mt-8"> <section className="mt-8">
<div className="mb-3 flex items-center justify-between"> <div className="mb-3 flex items-center justify-between">
<h2 className="text-lg font-medium">进行中的任务</h2> <h2 className="text-lg font-medium">进行中的任务</h2>
<span className="text-xs text-neutral-400">共 {tasks.length} 个</span> <span className="text-xs text-neutral-400">
共 {tasks.length} 个
</span>
</div> </div>
<ul className="space-y-3"> <ul className="space-y-3">
{tasks.map((t) => { {tasks.map((t) => {
const isOpen = openDetails.has(t.id); const isOpen = openDetails.has(t.id);
return ( return (
<li key={t.id} className="rounded-xl border border-white/10 bg-white/5 p-4 shadow-[0_0_0_1px_rgba(255,255,255,0.03)] backdrop-blur"> <li
key={t.id}
className="rounded-xl border border-white/10 bg-white/5 p-4 shadow-[0_0_0_1px_rgba(255,255,255,0.03)] backdrop-blur"
>
<div className="flex items-start justify-between gap-4"> <div className="flex items-start justify-between gap-4">
<div className="min-w-0 flex-1"> <div className="min-w-0 flex-1">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<StatusBadge status={t.status} /> <StatusBadge status={t.status} />
{t.status === 'running' && ( {t.status === "running" && (
<span className="text-xs text-neutral-400">已耗时 {formatDuration(t.startedAt)}</span> <span className="text-xs text-neutral-400">
已耗时 {formatDuration(t.startedAt)}
</span>
)} )}
{t.status === 'success' && ( {t.status === "success" && (
<span className="text-xs text-neutral-400">用时 {t.startedAt ? formatDuration(t.startedAt) : '--'}</span> <span className="text-xs text-neutral-400">
用时 {formatDuration(t.startedAt, t.finishedAt)}
</span>
)} )}
</div> </div>
<div className="mt-1 flex items-center gap-2 text-sm text-neutral-200"> <div className="mt-1 flex items-center gap-2 text-sm text-neutral-200">
<span className="truncate" title={t.url}>{t.url}</span> <span className="truncate" title={t.url}>
<a className="shrink-0 text-neutral-400 hover:text-neutral-200" href={t.url} target="_blank" rel="noreferrer"> {t.url}
</span>
<a
className="shrink-0 text-neutral-400 hover:text-neutral-200"
href={t.url}
target="_blank"
rel="noreferrer"
>
<ExternalLink className="h-4 w-4" /> <ExternalLink className="h-4 w-4" />
</a> </a>
</div> </div>
</div> </div>
<div className="flex shrink-0 items-center gap-2"> <div className="flex shrink-0 items-center gap-2">
{t.status === 'success' && t.result?.data?.aweme_id && ( {t.status === "success" && t.result?.data?.aweme_id && (
<a <a
href={`/aweme/${t.result.data.aweme_id}`} href={`/aweme/${t.result.data.aweme_id}`}
target="_blank" target="_blank"
@@ -249,7 +385,7 @@ export default function TasksPage() {
<ExternalLink className="h-3.5 w-3.5" /> 查看作品 <ExternalLink className="h-3.5 w-3.5" /> 查看作品
</a> </a>
)} )}
{t.status === 'error' && ( {t.status === "error" && (
<button <button
onClick={() => retryTask(t.id)} onClick={() => retryTask(t.id)}
className="inline-flex items-center gap-1 rounded-md bg-amber-600/80 px-2 py-1 text-xs text-white transition-colors hover:bg-amber-600" className="inline-flex items-center gap-1 rounded-md bg-amber-600/80 px-2 py-1 text-xs text-white transition-colors hover:bg-amber-600"
@@ -257,19 +393,30 @@ export default function TasksPage() {
<PlayCircle className="h-3.5 w-3.5" /> 重试 <PlayCircle className="h-3.5 w-3.5" /> 重试
</button> </button>
)} )}
{t.status === 'running' && ( {t.status === "running" && (
<button onClick={() => cancelTask(t.id)} className="inline-flex items-center gap-1 rounded-md bg-red-600/80 px-2 py-1 text-xs text-white transition-colors hover:bg-red-600"> <button
onClick={() => cancelTask(t.id)}
className="inline-flex items-center gap-1 rounded-md bg-red-600/80 px-2 py-1 text-xs text-white transition-colors hover:bg-red-600"
>
<Square className="h-3.5 w-3.5" /> 取消 <Square className="h-3.5 w-3.5" /> 取消
</button> </button>
)} )}
<button onClick={() => toggleOpen(t.id)} className="inline-flex items-center gap-1 rounded-md bg-neutral-800 px-2 py-1 text-xs text-neutral-200 transition-colors hover:bg-neutral-700"> <button
{isOpen ? <X className="h-3.5 w-3.5"/> : <PlayCircle className="h-3.5 w-3.5" />} {isOpen ? '收起' : '详情'} onClick={() => toggleOpen(t.id)}
className="inline-flex items-center gap-1 rounded-md bg-neutral-800 px-2 py-1 text-xs text-neutral-200 transition-colors hover:bg-neutral-700"
>
{isOpen ? (
<X className="h-3.5 w-3.5" />
) : (
<PlayCircle className="h-3.5 w-3.5" />
)}{" "}
{isOpen ? "收起" : "详情"}
</button> </button>
</div> </div>
</div> </div>
{/* 进度条 */} {/* 进度条 */}
{t.status === 'running' && ( {t.status === "running" && (
<div className="mt-3 h-1 w-full overflow-hidden rounded bg-neutral-800"> <div className="mt-3 h-1 w-full overflow-hidden rounded bg-neutral-800">
<div className="h-full w-1/3 animate-[progress_1.2s_ease_infinite] rounded bg-indigo-500/70" /> <div className="h-full w-1/3 animate-[progress_1.2s_ease_infinite] rounded bg-indigo-500/70" />
</div> </div>
@@ -277,13 +424,17 @@ export default function TasksPage() {
{isOpen && ( {isOpen && (
<div className="mt-3 rounded-md border border-neutral-800/60 bg-neutral-900/60 p-3"> <div className="mt-3 rounded-md border border-neutral-800/60 bg-neutral-900/60 p-3">
{t.status === 'error' && t.error && ( {t.status === "error" && t.error && (
<div className="mb-3 space-y-2"> <div className="mb-3 space-y-2">
<div className="flex items-start gap-2 rounded-md border border-red-500/30 bg-red-500/10 p-3"> <div className="flex items-start gap-2 rounded-md border border-red-500/30 bg-red-500/10 p-3">
<AlertTriangle className="mt-0.5 h-4 w-4 shrink-0 text-red-400" /> <AlertTriangle className="mt-0.5 h-4 w-4 shrink-0 text-red-400" />
<div className="min-w-0 flex-1"> <div className="min-w-0 flex-1">
<div className="text-sm font-medium text-red-200">爬取失败</div> <div className="text-sm font-medium text-red-200">
<div className="mt-1 text-xs text-red-300/90">{t.error}</div> 爬取失败
</div>
<div className="mt-1 text-xs text-red-300/90">
{t.error}
</div>
</div> </div>
</div> </div>
<div className="text-xs text-neutral-400"> <div className="text-xs text-neutral-400">
@@ -296,33 +447,57 @@ export default function TasksPage() {
</div> </div>
</div> </div>
)} )}
{typeof t.result !== 'undefined' && ( {typeof t.result !== "undefined" && (
<div> <div>
{t.result?.data?.aweme && ( {t.result?.data?.aweme && (
<div className="mb-3 space-y-2 rounded-md border border-emerald-500/20 bg-emerald-500/5 p-3 text-xs"> <div className="mb-3 space-y-2 rounded-md border border-emerald-500/20 bg-emerald-500/5 p-3 text-xs">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<CheckCircle2 className="h-4 w-4 text-emerald-400" /> <CheckCircle2 className="h-4 w-4 text-emerald-400" />
<span className="font-medium text-emerald-200">作品信息</span> <span className="font-medium text-emerald-200">
作品信息
</span>
</div> </div>
<div className="space-y-1 text-neutral-300"> <div className="space-y-1 text-neutral-300">
<div><span className="text-neutral-400">ID:</span> {t.result.data.aweme.aweme_id}</div> <div>
<div><span className="text-neutral-400">描述:</span> {t.result.data.aweme.desc || '(无)'}</div> <span className="text-neutral-400">ID:</span>{" "}
{t.result.data.aweme.aweme_id}
</div>
<div>
<span className="text-neutral-400">
描述:
</span>{" "}
{t.result.data.aweme.desc || "(无)"}
</div>
{t.result.data.aweme.author && ( {t.result.data.aweme.author && (
<div><span className="text-neutral-400">作者:</span> {t.result.data.aweme.author.nickname}</div> <div>
<span className="text-neutral-400">
作者:
</span>{" "}
{t.result.data.aweme.author.nickname}
</div>
)} )}
</div> </div>
</div> </div>
)} )}
<details className="group"> <details className="group">
<summary className="cursor-pointer text-xs text-neutral-400 hover:text-neutral-300"> <summary className="cursor-pointer text-xs text-neutral-400 hover:text-neutral-300">
查看完整响应数据 <span className="group-open:hidden">▶</span><span className="hidden group-open:inline">▼</span> 查看完整响应数据{" "}
<span className="group-open:hidden">▶</span>
<span className="hidden group-open:inline">
▼
</span>
</summary> </summary>
<pre className="mt-2 max-h-64 overflow-auto rounded bg-neutral-950 p-2 text-xs text-neutral-300">{JSON.stringify(t.result, null, 2)}</pre> <pre className="mt-2 max-h-64 overflow-auto rounded bg-neutral-950 p-2 text-xs text-neutral-300">
{JSON.stringify(t.result, null, 2)}
</pre>
</details> </details>
</div> </div>
)} )}
{typeof t.result === 'undefined' && t.status !== 'error' && ( {typeof t.result === "undefined" &&
<div className="text-xs text-neutral-400">暂无输出</div> t.status !== "error" && (
<div className="text-xs text-neutral-400">
暂无输出
</div>
)} )}
</div> </div>
)} )}
+8 -5
View File
@@ -1,18 +1,21 @@
export type FeedItem = export type FeedItem = (
| ({ | {
type: "video"; type: "video";
video_url: string; video_url: string;
} | { }
| {
type: "image"; type: "image";
}) & { }
) & {
likes: number; likes: number;
author: { nickname: string; avatar_url: string | null }; author: { nickname: string; avatar_url: string | null; sec_uid?: string };
aweme_id: string; aweme_id: string;
created_at: Date | string; created_at: Date | string;
desc: string; desc: string;
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 {
+155 -12
View File
@@ -1,17 +1,22 @@
{ {
"lockfileVersion": 1, "lockfileVersion": 1,
"configVersion": 0,
"workspaces": { "workspaces": {
"": { "": {
"name": "douyin-archive", "name": "douyin-archive",
"dependencies": { "dependencies": {
"@prisma/client": "^6.16.3", "@prisma/client": "^6.16.3",
"chalk": "^5.6.2", "chalk": "^5.6.2",
"dotenv": "^16.6.1",
"lucide-react": "^0.546.0", "lucide-react": "^0.546.0",
"minio": "^8.0.6", "minio": "^8.0.6",
"next": "15.5.6", "next": "15.5.7",
"playwright": "^1.56.1", "playwright": "1.56.1",
"playwright-extra": "^4.3.6",
"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",
"tsx": "^4.20.6",
}, },
"devDependencies": { "devDependencies": {
"@biomejs/biome": "2.2.0", "@biomejs/biome": "2.2.0",
@@ -48,6 +53,58 @@
"@emnapi/runtime": ["@emnapi/runtime@1.5.0", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-97/BJ3iXHww3djw6hYIfErCZFee7qCtrneuLa20UXFCOTCfBM2cvQHjWJ2EG0s0MtdNwInarqCTz35i4wWXHsQ=="], "@emnapi/runtime": ["@emnapi/runtime@1.5.0", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-97/BJ3iXHww3djw6hYIfErCZFee7qCtrneuLa20UXFCOTCfBM2cvQHjWJ2EG0s0MtdNwInarqCTz35i4wWXHsQ=="],
"@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.28.2", "", { "os": "aix", "cpu": "ppc64" }, "sha512-XExcO+dvLKvVtNTibSTBej1NCAbaGhWn9Ww1ZPx80qsahhPFe/8jgWP0IchNe0F3HwkU7n8ejhH8bjonqht8mQ=="],
"@esbuild/android-arm": ["@esbuild/android-arm@0.28.2", "", { "os": "android", "cpu": "arm" }, "sha512-kXXoiPVVGQcnIYGOeaovwOURpniDBpSq4A03qkQ+BMQqtGG6HYap3xne9C1O1yo4TR3qxlCX5IqqmX6fFo2Lqg=="],
"@esbuild/android-arm64": ["@esbuild/android-arm64@0.28.2", "", { "os": "android", "cpu": "arm64" }, "sha512-5YfKeeI8qWfBZIX+u2xZC3Zlb3Os/gLS2sbEKM+I4ZOcsWmHS2WLysCcQZDAFRslDUU5Oiq44gf6PYN1vGwG5A=="],
"@esbuild/android-x64": ["@esbuild/android-x64@0.28.2", "", { "os": "android", "cpu": "x64" }, "sha512-O387ite7SzUyCcy3JQX4P4bLtEA7bLLkx+esve5JHnyYfNTxcVpXZo9jhdB0lTKN44gztELTdU7nS8Nr16Fs1Q=="],
"@esbuild/darwin-arm64": ["@esbuild/darwin-arm64@0.28.2", "", { "os": "darwin", "cpu": "arm64" }, "sha512-n4KqkOQrraxHJcgjM1RvwbigfQKIKJVpM7xp+KsxiyUSrRdIXnt73VhrPAx0fV44hgfmIVKjxMN9J1t5jySVkw=="],
"@esbuild/darwin-x64": ["@esbuild/darwin-x64@0.28.2", "", { "os": "darwin", "cpu": "x64" }, "sha512-uq6suIWYP37qzGddBKPw5QEQPi6HiLGsO7UmkpfyaYNQ3D+rN6w6WfwH+nuqcGXWvawGwxOEroO4YGnFh95azw=="],
"@esbuild/freebsd-arm64": ["@esbuild/freebsd-arm64@0.28.2", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-n+I0BTSRIoy+d6RPKnEVwql5UwBJolytvY4mAOIEJorKlqgPII8ix6slVVrfZ5Tnj7glIZvloylbB/EJPMWEXw=="],
"@esbuild/freebsd-x64": ["@esbuild/freebsd-x64@0.28.2", "", { "os": "freebsd", "cpu": "x64" }, "sha512-78XJTJkvPs0kz2w61301PJjXl4g7q3JqiYMZ/M/yVI73EHBrCRTgkhu9oqG7vPqq+a/yadEW8aD+agKlk5xrmg=="],
"@esbuild/linux-arm": ["@esbuild/linux-arm@0.28.2", "", { "os": "linux", "cpu": "arm" }, "sha512-XlDnu2q5yoqems+xay6wSAcg9DDD7K9RLKZEBOMZm3ckNpJBvOX20tSfby8KfrrhINDyv9V2YVZKY/SpoGJI8w=="],
"@esbuild/linux-arm64": ["@esbuild/linux-arm64@0.28.2", "", { "os": "linux", "cpu": "arm64" }, "sha512-pW4AC0P3it8c7do9MVM4p51FzHzdM/TZrerurgRcHJ2WTa1VQ1CIq18xncfpBJw4ojkiZZrKW2yIBWBP92j6Ug=="],
"@esbuild/linux-ia32": ["@esbuild/linux-ia32@0.28.2", "", { "os": "linux", "cpu": "ia32" }, "sha512-CYbnj78HsIeA+DhgUKgFCfvNsTHFhMMrinUrMZpDXJXKN8T3XViTZ/+wtHeVxEWY8ewSzTFN+nRmSwO2tZaLUQ=="],
"@esbuild/linux-loong64": ["@esbuild/linux-loong64@0.28.2", "", { "os": "linux", "cpu": "none" }, "sha512-buwkd8nsph4R+ajRvw0qM5Hja/TXQow3ptzWO2EbG/cqcIkHloRrdlBtQlshyYGTNFvfkfJ5tpPLVkY4DtsPfQ=="],
"@esbuild/linux-mips64el": ["@esbuild/linux-mips64el@0.28.2", "", { "os": "linux", "cpu": "none" }, "sha512-ZVykbDyk7519VwiNb9Lcj9m8XM6v5V9uKPvrEMkkEedVewf+0itkhahp4HDpgERXhwLRpWFypsGbG/J8s0QjJA=="],
"@esbuild/linux-ppc64": ["@esbuild/linux-ppc64@0.28.2", "", { "os": "linux", "cpu": "ppc64" }, "sha512-CAXl+Dtd9UUuJd8pKKdwh6MLm3MUMiqMPmhZ3tTSXPqfyQ3vDl6R5hZdZ/kYojK4ofXtdfSv1tFq8XzWx3heNQ=="],
"@esbuild/linux-riscv64": ["@esbuild/linux-riscv64@0.28.2", "", { "os": "linux", "cpu": "none" }, "sha512-GeXCej4IQtU1B+QlDV8W/RRvbzI3O/Stss+/bCXv4lZls5WGRtu2a+3JkA3i4qIUlMXpcHebWpF8AkJhATowuA=="],
"@esbuild/linux-s390x": ["@esbuild/linux-s390x@0.28.2", "", { "os": "linux", "cpu": "s390x" }, "sha512-3H1weTYZPxt/WOhByszQZybS9w5lKzUn1FDMsgEChbHWQwHYQQRfBxgCcZvPhjHfKyJjIievvMmEUawJrdY9Dg=="],
"@esbuild/linux-x64": ["@esbuild/linux-x64@0.28.2", "", { "os": "linux", "cpu": "x64" }, "sha512-4xTZr1FUmSoQW4XIWmit3tzQrUTZM+N3P0XV8xROKYF50XfI7xeO90+1bZvNwxIufQ9hDQVRJH5YhgPVF8A/HQ=="],
"@esbuild/netbsd-arm64": ["@esbuild/netbsd-arm64@0.28.2", "", { "os": "none", "cpu": "arm64" }, "sha512-sSATRjPeDBg3pdgHoQfoYBob11Kk1FGa9lui5RIHZCoCkJa9QKlvl3/vKz2usCmYYjs7ymJR/2Nnsqe+Hjt5nw=="],
"@esbuild/netbsd-x64": ["@esbuild/netbsd-x64@0.28.2", "", { "os": "none", "cpu": "x64" }, "sha512-lqnzCV+mM0gIADaKihiCg6ifgfU2L3h5E33rNQBN1Y4MaVGnzryzmvvf7UHxprpQdE8hpqLolJ9Rl+SkIRDpyw=="],
"@esbuild/openbsd-arm64": ["@esbuild/openbsd-arm64@0.28.2", "", { "os": "openbsd", "cpu": "arm64" }, "sha512-AL2qJILH7lNjrDmCQDvdxMfAUIv8KMNZOvrwAQ8i8//ntL9FflhOyMJ8OZSMBb8/AWXe3/5v5S20y3zCoZWKoQ=="],
"@esbuild/openbsd-x64": ["@esbuild/openbsd-x64@0.28.2", "", { "os": "openbsd", "cpu": "x64" }, "sha512-QtiuPytchRyC4rwUKhexJdQKvDuZ6hWloi3igqPQNUJCS1/v9EiO3UTOXR6A3FoMo4fnAKbWJdqaIwhOzh8qEw=="],
"@esbuild/openharmony-arm64": ["@esbuild/openharmony-arm64@0.28.2", "", { "os": "none", "cpu": "arm64" }, "sha512-WkhYDmpTjLvGlScA1rwjRUmhl4k8oXR3cIbtqWmELgU/dFeHHlEllxDvdWcNJV9rbzCexB5vz8gtNewWLgCT7Q=="],
"@esbuild/sunos-x64": ["@esbuild/sunos-x64@0.28.2", "", { "os": "sunos", "cpu": "x64" }, "sha512-GPMSkTOtMnv2U2F8gxe4Io6qmVs+YKyp832Etqqxr0hFngmXQ3rzwytelm3GIn7T4VviRUlf3sOgBOiTdvaf7g=="],
"@esbuild/win32-arm64": ["@esbuild/win32-arm64@0.28.2", "", { "os": "win32", "cpu": "arm64" }, "sha512-PIhhEkE9uPBleRBrQEJpUn7MBnibZzbGzYWPmY3x+YoVg/95zbjB4CxPPOQ8l5tYYM4mMaCthF8/1DIfBQQyWQ=="],
"@esbuild/win32-ia32": ["@esbuild/win32-ia32@0.28.2", "", { "os": "win32", "cpu": "ia32" }, "sha512-YmJbfTlvU7Sdn9BB+4PRES4oB6pxgS37MAONj+hBr/cpXS1aBPKXxNnDbu+QCWPj0o9dgyxeq79g6c5P8KeuYA=="],
"@esbuild/win32-x64": ["@esbuild/win32-x64@0.28.2", "", { "os": "win32", "cpu": "x64" }, "sha512-5ebpxr3nWMzrL/rnUI755Jkuee0bHL/Gq0WTF9lvcpv73wAp5eu8MfBUgWK9bhWvZjj7yX8etf/8tI8Ney695g=="],
"@img/colour": ["@img/colour@1.0.0", "", {}, "sha512-A5P/LfWGFSl6nsckYtjw9da+19jB8hkJ6ACTGcDfEJ0aE+l2n2El7dsVM7UVHZQ9s2lmYMWlrS21YLy2IR1LUw=="], "@img/colour": ["@img/colour@1.0.0", "", {}, "sha512-A5P/LfWGFSl6nsckYtjw9da+19jB8hkJ6ACTGcDfEJ0aE+l2n2El7dsVM7UVHZQ9s2lmYMWlrS21YLy2IR1LUw=="],
"@img/sharp-darwin-arm64": ["@img/sharp-darwin-arm64@0.34.4", "", { "optionalDependencies": { "@img/sharp-libvips-darwin-arm64": "1.2.3" }, "os": "darwin", "cpu": "arm64" }, "sha512-sitdlPzDVyvmINUdJle3TNHl+AG9QcwiAMsXmccqsCOMZNIdW2/7S26w0LyU8euiLVzFBL3dXPwVCq/ODnf2vA=="], "@img/sharp-darwin-arm64": ["@img/sharp-darwin-arm64@0.34.4", "", { "optionalDependencies": { "@img/sharp-libvips-darwin-arm64": "1.2.3" }, "os": "darwin", "cpu": "arm64" }, "sha512-sitdlPzDVyvmINUdJle3TNHl+AG9QcwiAMsXmccqsCOMZNIdW2/7S26w0LyU8euiLVzFBL3dXPwVCq/ODnf2vA=="],
@@ -106,23 +163,23 @@
"@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.31", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/sourcemap-codec": "^1.4.14" } }, "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw=="], "@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.31", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/sourcemap-codec": "^1.4.14" } }, "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw=="],
"@next/env": ["@next/env@15.5.6", "", {}, "sha512-3qBGRW+sCGzgbpc5TS1a0p7eNxnOarGVQhZxfvTdnV0gFI61lX7QNtQ4V1TSREctXzYn5NetbUsLvyqwLFJM6Q=="], "@next/env": ["@next/env@15.5.7", "https://registry.npmmirror.com/@next/env/-/env-15.5.7.tgz", {}, "sha512-4h6Y2NyEkIEN7Z8YxkA27pq6zTkS09bUSYC0xjd0NpwFxjnIKeZEeH591o5WECSmjpUhLn3H2QLJcDye3Uzcvg=="],
"@next/swc-darwin-arm64": ["@next/swc-darwin-arm64@15.5.6", "", { "os": "darwin", "cpu": "arm64" }, "sha512-ES3nRz7N+L5Umz4KoGfZ4XX6gwHplwPhioVRc25+QNsDa7RtUF/z8wJcbuQ2Tffm5RZwuN2A063eapoJ1u4nPg=="], "@next/swc-darwin-arm64": ["@next/swc-darwin-arm64@15.5.7", "https://registry.npmmirror.com/@next/swc-darwin-arm64/-/swc-darwin-arm64-15.5.7.tgz", { "os": "darwin", "cpu": "arm64" }, "sha512-IZwtxCEpI91HVU/rAUOOobWSZv4P2DeTtNaCdHqLcTJU4wdNXgAySvKa/qJCgR5m6KI8UsKDXtO2B31jcaw1Yw=="],
"@next/swc-darwin-x64": ["@next/swc-darwin-x64@15.5.6", "", { "os": "darwin", "cpu": "x64" }, "sha512-JIGcytAyk9LQp2/nuVZPAtj8uaJ/zZhsKOASTjxDug0SPU9LAM3wy6nPU735M1OqacR4U20LHVF5v5Wnl9ptTA=="], "@next/swc-darwin-x64": ["@next/swc-darwin-x64@15.5.7", "https://registry.npmmirror.com/@next/swc-darwin-x64/-/swc-darwin-x64-15.5.7.tgz", { "os": "darwin", "cpu": "x64" }, "sha512-UP6CaDBcqaCBuiq/gfCEJw7sPEoX1aIjZHnBWN9v9qYHQdMKvCKcAVs4OX1vIjeE+tC5EIuwDTVIoXpUes29lg=="],
"@next/swc-linux-arm64-gnu": ["@next/swc-linux-arm64-gnu@15.5.6", "", { "os": "linux", "cpu": "arm64" }, "sha512-qvz4SVKQ0P3/Im9zcS2RmfFL/UCQnsJKJwQSkissbngnB/12c6bZTCB0gHTexz1s6d/mD0+egPKXAIRFVS7hQg=="], "@next/swc-linux-arm64-gnu": ["@next/swc-linux-arm64-gnu@15.5.7", "https://registry.npmmirror.com/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-15.5.7.tgz", { "os": "linux", "cpu": "arm64" }, "sha512-NCslw3GrNIw7OgmRBxHtdWFQYhexoUCq+0oS2ccjyYLtcn1SzGzeM54jpTFonIMUjNbHmpKpziXnpxhSWLcmBA=="],
"@next/swc-linux-arm64-musl": ["@next/swc-linux-arm64-musl@15.5.6", "", { "os": "linux", "cpu": "arm64" }, "sha512-FsbGVw3SJz1hZlvnWD+T6GFgV9/NYDeLTNQB2MXoPN5u9VA9OEDy6fJEfePfsUKAhJufFbZLgp0cPxMuV6SV0w=="], "@next/swc-linux-arm64-musl": ["@next/swc-linux-arm64-musl@15.5.7", "https://registry.npmmirror.com/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-15.5.7.tgz", { "os": "linux", "cpu": "arm64" }, "sha512-nfymt+SE5cvtTrG9u1wdoxBr9bVB7mtKTcj0ltRn6gkP/2Nu1zM5ei8rwP9qKQP0Y//umK+TtkKgNtfboBxRrw=="],
"@next/swc-linux-x64-gnu": ["@next/swc-linux-x64-gnu@15.5.6", "", { "os": "linux", "cpu": "x64" }, "sha512-3QnHGFWlnvAgyxFxt2Ny8PTpXtQD7kVEeaFat5oPAHHI192WKYB+VIKZijtHLGdBBvc16tiAkPTDmQNOQ0dyrA=="], "@next/swc-linux-x64-gnu": ["@next/swc-linux-x64-gnu@15.5.7", "https://registry.npmmirror.com/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-15.5.7.tgz", { "os": "linux", "cpu": "x64" }, "sha512-hvXcZvCaaEbCZcVzcY7E1uXN9xWZfFvkNHwbe/n4OkRhFWrs1J1QV+4U1BN06tXLdaS4DazEGXwgqnu/VMcmqw=="],
"@next/swc-linux-x64-musl": ["@next/swc-linux-x64-musl@15.5.6", "", { "os": "linux", "cpu": "x64" }, "sha512-OsGX148sL+TqMK9YFaPFPoIaJKbFJJxFzkXZljIgA9hjMjdruKht6xDCEv1HLtlLNfkx3c5w2GLKhj7veBQizQ=="], "@next/swc-linux-x64-musl": ["@next/swc-linux-x64-musl@15.5.7", "https://registry.npmmirror.com/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-15.5.7.tgz", { "os": "linux", "cpu": "x64" }, "sha512-4IUO539b8FmF0odY6/SqANJdgwn1xs1GkPO5doZugwZ3ETF6JUdckk7RGmsfSf7ws8Qb2YB5It33mvNL/0acqA=="],
"@next/swc-win32-arm64-msvc": ["@next/swc-win32-arm64-msvc@15.5.6", "", { "os": "win32", "cpu": "arm64" }, "sha512-ONOMrqWxdzXDJNh2n60H6gGyKed42Ieu6UTVPZteXpuKbLZTH4G4eBMsr5qWgOBA+s7F+uB4OJbZnrkEDnZ5Fg=="], "@next/swc-win32-arm64-msvc": ["@next/swc-win32-arm64-msvc@15.5.7", "https://registry.npmmirror.com/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-15.5.7.tgz", { "os": "win32", "cpu": "arm64" }, "sha512-CpJVTkYI3ZajQkC5vajM7/ApKJUOlm6uP4BknM3XKvJ7VXAvCqSjSLmM0LKdYzn6nBJVSjdclx8nYJSa3xlTgQ=="],
"@next/swc-win32-x64-msvc": ["@next/swc-win32-x64-msvc@15.5.6", "", { "os": "win32", "cpu": "x64" }, "sha512-pxK4VIjFRx1MY92UycLOOw7dTdvccWsNETQ0kDHkBlcFH1GrTLUjSiHU1ohrznnux6TqRHgv5oflhfIWZwVROQ=="], "@next/swc-win32-x64-msvc": ["@next/swc-win32-x64-msvc@15.5.7", "https://registry.npmmirror.com/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-15.5.7.tgz", { "os": "win32", "cpu": "x64" }, "sha512-gMzgBX164I6DN+9/PGA+9dQiwmTkE4TloBNx8Kv9UiGARsr9Nba7IpcBRA1iTV9vwlYnrE3Uy6I7Aj6qLjQuqw=="],
"@prisma/client": ["@prisma/client@6.17.1", "", { "peerDependencies": { "prisma": "*", "typescript": ">=5.1.0" }, "optionalPeers": ["prisma", "typescript"] }, "sha512-zL58jbLzYamjnNnmNA51IOZdbk5ci03KviXCuB0Tydc9btH2kDWsi1pQm2VecviRTM7jGia0OPPkgpGnT3nKvw=="], "@prisma/client": ["@prisma/client@6.17.1", "", { "peerDependencies": { "prisma": "*", "typescript": ">=5.1.0" }, "optionalPeers": ["prisma", "typescript"] }, "sha512-zL58jbLzYamjnNnmNA51IOZdbk5ci03KviXCuB0Tydc9btH2kDWsi1pQm2VecviRTM7jGia0OPPkgpGnT3nKvw=="],
@@ -172,6 +229,10 @@
"@tailwindcss/postcss": ["@tailwindcss/postcss@4.1.14", "", { "dependencies": { "@alloc/quick-lru": "^5.2.0", "@tailwindcss/node": "4.1.14", "@tailwindcss/oxide": "4.1.14", "postcss": "^8.4.41", "tailwindcss": "4.1.14" } }, "sha512-BdMjIxy7HUNThK87C7BC8I1rE8BVUsfNQSI5siQ4JK3iIa3w0XyVvVL9SXLWO//CtYTcp1v7zci0fYwJOjB+Zg=="], "@tailwindcss/postcss": ["@tailwindcss/postcss@4.1.14", "", { "dependencies": { "@alloc/quick-lru": "^5.2.0", "@tailwindcss/node": "4.1.14", "@tailwindcss/oxide": "4.1.14", "postcss": "^8.4.41", "tailwindcss": "4.1.14" } }, "sha512-BdMjIxy7HUNThK87C7BC8I1rE8BVUsfNQSI5siQ4JK3iIa3w0XyVvVL9SXLWO//CtYTcp1v7zci0fYwJOjB+Zg=="],
"@types/debug": ["@types/debug@4.1.12", "", { "dependencies": { "@types/ms": "*" } }, "sha512-vIChWdVG3LG1SMxEvI/AK+FWJthlrqlTu7fbrlywTkkaONwk/UAGaULXRlf8vkzFBLVm0zkMdCquhL5aOjhXPQ=="],
"@types/ms": ["@types/ms@2.1.0", "", {}, "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA=="],
"@types/node": ["@types/node@20.19.22", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-hRnu+5qggKDSyWHlnmThnUqg62l29Aj/6vcYgUaSFL9oc7DVjeWEQN3PRgdSc6F8d9QRMWkf36CLMch1Do/+RQ=="], "@types/node": ["@types/node@20.19.22", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-hRnu+5qggKDSyWHlnmThnUqg62l29Aj/6vcYgUaSFL9oc7DVjeWEQN3PRgdSc6F8d9QRMWkf36CLMch1Do/+RQ=="],
"@types/react": ["@types/react@19.2.2", "", { "dependencies": { "csstype": "^3.0.2" } }, "sha512-6mDvHUFSjyT2B2yeNx2nUgMxh9LtOWvkhIU3uePn2I2oyNymUAX1NIsdgviM4CH+JSrp2D2hsMvJOkxY+0wNRA=="], "@types/react": ["@types/react@19.2.2", "", { "dependencies": { "csstype": "^3.0.2" } }, "sha512-6mDvHUFSjyT2B2yeNx2nUgMxh9LtOWvkhIU3uePn2I2oyNymUAX1NIsdgviM4CH+JSrp2D2hsMvJOkxY+0wNRA=="],
@@ -180,12 +241,18 @@
"@zxing/text-encoding": ["@zxing/text-encoding@0.9.0", "", {}, "sha512-U/4aVJ2mxI0aDNI8Uq0wEhMgY+u4CNtEb0om3+y3+niDAsoTCOB33UF0sxpzqzdqXLqmvc+vZyAt4O8pPdfkwA=="], "@zxing/text-encoding": ["@zxing/text-encoding@0.9.0", "", {}, "sha512-U/4aVJ2mxI0aDNI8Uq0wEhMgY+u4CNtEb0om3+y3+niDAsoTCOB33UF0sxpzqzdqXLqmvc+vZyAt4O8pPdfkwA=="],
"arr-union": ["arr-union@3.1.0", "", {}, "sha512-sKpyeERZ02v1FeCZT8lrfJq5u6goHCtpTAzPwJYe7c8SPFOboNjNg1vz2L4VTn9T4PQxEx13TbXLmYUcS6Ug7Q=="],
"async": ["async@3.2.6", "", {}, "sha512-htCUDlxyyCLMgaM3xXg0C0LW2xqfuQ6p05pCEIsXuyQ+a1koYKTuBMzRNwmybfLgvJDMd0r1LTn4+E0Ti6C2AA=="], "async": ["async@3.2.6", "", {}, "sha512-htCUDlxyyCLMgaM3xXg0C0LW2xqfuQ6p05pCEIsXuyQ+a1koYKTuBMzRNwmybfLgvJDMd0r1LTn4+E0Ti6C2AA=="],
"available-typed-arrays": ["available-typed-arrays@1.0.7", "", { "dependencies": { "possible-typed-array-names": "^1.0.0" } }, "sha512-wvUjBtSGN7+7SjNpq/9M2Tg350UZD3q62IFZLbRAR1bSMlCo1ZaeW+BJ+D090e4hIIZLBcTDWe4Mh4jvUDajzQ=="], "available-typed-arrays": ["available-typed-arrays@1.0.7", "", { "dependencies": { "possible-typed-array-names": "^1.0.0" } }, "sha512-wvUjBtSGN7+7SjNpq/9M2Tg350UZD3q62IFZLbRAR1bSMlCo1ZaeW+BJ+D090e4hIIZLBcTDWe4Mh4jvUDajzQ=="],
"balanced-match": ["balanced-match@1.0.2", "", {}, "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="],
"block-stream2": ["block-stream2@2.1.0", "", { "dependencies": { "readable-stream": "^3.4.0" } }, "sha512-suhjmLI57Ewpmq00qaygS8UgEq2ly2PCItenIyhMqVjo4t4pGzqMvfgJuX8iWTeSDdfSSqS6j38fL4ToNL7Pfg=="], "block-stream2": ["block-stream2@2.1.0", "", { "dependencies": { "readable-stream": "^3.4.0" } }, "sha512-suhjmLI57Ewpmq00qaygS8UgEq2ly2PCItenIyhMqVjo4t4pGzqMvfgJuX8iWTeSDdfSSqS6j38fL4ToNL7Pfg=="],
"brace-expansion": ["brace-expansion@1.1.12", "", { "dependencies": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" } }, "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg=="],
"browser-or-node": ["browser-or-node@2.1.1", "", {}, "sha512-8CVjaLJGuSKMVTxJ2DpBl5XnlNDiT4cQFeuCJJrvJmts9YrTZDizTX7PjC2s6W4x+MBGZeEY6dGMrF04/6Hgqg=="], "browser-or-node": ["browser-or-node@2.1.1", "", {}, "sha512-8CVjaLJGuSKMVTxJ2DpBl5XnlNDiT4cQFeuCJJrvJmts9YrTZDizTX7PjC2s6W4x+MBGZeEY6dGMrF04/6Hgqg=="],
"buffer-crc32": ["buffer-crc32@1.0.0", "", {}, "sha512-Db1SbgBS/fg/392AblrMJk97KggmvYhr4pB5ZIMTWtaivCPMWLkmb7m21cJvpvgK+J3nsU2CmmixNBZx4vFj/w=="], "buffer-crc32": ["buffer-crc32@1.0.0", "", {}, "sha512-Db1SbgBS/fg/392AblrMJk97KggmvYhr4pB5ZIMTWtaivCPMWLkmb7m21cJvpvgK+J3nsU2CmmixNBZx4vFj/w=="],
@@ -210,14 +277,22 @@
"client-only": ["client-only@0.0.1", "", {}, "sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA=="], "client-only": ["client-only@0.0.1", "", {}, "sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA=="],
"clone-deep": ["clone-deep@0.2.4", "", { "dependencies": { "for-own": "^0.1.3", "is-plain-object": "^2.0.1", "kind-of": "^3.0.2", "lazy-cache": "^1.0.3", "shallow-clone": "^0.1.2" } }, "sha512-we+NuQo2DHhSl+DP6jlUiAhyAjBQrYnpOk15rN6c6JSPScjiCLh8IbSU+VTcph6YS3o7mASE8a0+gbZ7ChLpgg=="],
"concat-map": ["concat-map@0.0.1", "", {}, "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg=="],
"confbox": ["confbox@0.2.2", "", {}, "sha512-1NB+BKqhtNipMsov4xI/NnhCKp9XG9NamYp5PVm9klAT0fsrNPjaFICsCFhNhwZJKNh7zB/3q8qXz0E9oaMNtQ=="], "confbox": ["confbox@0.2.2", "", {}, "sha512-1NB+BKqhtNipMsov4xI/NnhCKp9XG9NamYp5PVm9klAT0fsrNPjaFICsCFhNhwZJKNh7zB/3q8qXz0E9oaMNtQ=="],
"consola": ["consola@3.4.2", "", {}, "sha512-5IKcdX0nnYavi6G7TtOhwkYzyjfJlatbjMjuLSfE2kYT5pMDOilZ4OvMhi637CcDICTmz3wARPoyhqyX1Y+XvA=="], "consola": ["consola@3.4.2", "", {}, "sha512-5IKcdX0nnYavi6G7TtOhwkYzyjfJlatbjMjuLSfE2kYT5pMDOilZ4OvMhi637CcDICTmz3wARPoyhqyX1Y+XvA=="],
"csstype": ["csstype@3.1.3", "", {}, "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw=="], "csstype": ["csstype@3.1.3", "", {}, "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw=="],
"debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="],
"decode-uri-component": ["decode-uri-component@0.2.2", "", {}, "sha512-FqUYQ+8o158GyGTrMFJms9qh3CqTKvAqgqsTnkLI8sKu0028orqBhxNMFkFen0zGyg6epACD32pjVk58ngIErQ=="], "decode-uri-component": ["decode-uri-component@0.2.2", "", {}, "sha512-FqUYQ+8o158GyGTrMFJms9qh3CqTKvAqgqsTnkLI8sKu0028orqBhxNMFkFen0zGyg6epACD32pjVk58ngIErQ=="],
"deepmerge": ["deepmerge@4.3.1", "", {}, "sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A=="],
"deepmerge-ts": ["deepmerge-ts@7.1.5", "", {}, "sha512-HOJkrhaYsweh+W+e74Yn7YStZOilkoPb6fycpwNLKzSPtruFs48nYis0zy5yJz1+ktUhHxoRDJ27RQAWLIJVJw=="], "deepmerge-ts": ["deepmerge-ts@7.1.5", "", {}, "sha512-HOJkrhaYsweh+W+e74Yn7YStZOilkoPb6fycpwNLKzSPtruFs48nYis0zy5yJz1+ktUhHxoRDJ27RQAWLIJVJw=="],
"define-data-property": ["define-data-property@1.1.4", "", { "dependencies": { "es-define-property": "^1.0.0", "es-errors": "^1.3.0", "gopd": "^1.0.1" } }, "sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A=="], "define-data-property": ["define-data-property@1.1.4", "", { "dependencies": { "es-define-property": "^1.0.0", "es-errors": "^1.3.0", "gopd": "^1.0.1" } }, "sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A=="],
@@ -244,6 +319,8 @@
"es-object-atoms": ["es-object-atoms@1.1.1", "", { "dependencies": { "es-errors": "^1.3.0" } }, "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA=="], "es-object-atoms": ["es-object-atoms@1.1.1", "", { "dependencies": { "es-errors": "^1.3.0" } }, "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA=="],
"esbuild": ["esbuild@0.28.2", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.28.2", "@esbuild/android-arm": "0.28.2", "@esbuild/android-arm64": "0.28.2", "@esbuild/android-x64": "0.28.2", "@esbuild/darwin-arm64": "0.28.2", "@esbuild/darwin-x64": "0.28.2", "@esbuild/freebsd-arm64": "0.28.2", "@esbuild/freebsd-x64": "0.28.2", "@esbuild/linux-arm": "0.28.2", "@esbuild/linux-arm64": "0.28.2", "@esbuild/linux-ia32": "0.28.2", "@esbuild/linux-loong64": "0.28.2", "@esbuild/linux-mips64el": "0.28.2", "@esbuild/linux-ppc64": "0.28.2", "@esbuild/linux-riscv64": "0.28.2", "@esbuild/linux-s390x": "0.28.2", "@esbuild/linux-x64": "0.28.2", "@esbuild/netbsd-arm64": "0.28.2", "@esbuild/netbsd-x64": "0.28.2", "@esbuild/openbsd-arm64": "0.28.2", "@esbuild/openbsd-x64": "0.28.2", "@esbuild/openharmony-arm64": "0.28.2", "@esbuild/sunos-x64": "0.28.2", "@esbuild/win32-arm64": "0.28.2", "@esbuild/win32-ia32": "0.28.2", "@esbuild/win32-x64": "0.28.2" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-HKVLS8dvII+xoKW9kmqxbRKrnWEXfJJr/FZhhJmiqIB0e053QNYFqOBouTMO/k5sID4MvCiUCvv8b9M4h32wIA=="],
"eventemitter3": ["eventemitter3@5.0.1", "", {}, "sha512-GWkBvjiSZK87ELrYOSESUYeVIc9mvLLf/nXalMOS5dYrgZq9o5OVkbZAVM06CVxYsCwH9BDZFPlQTlPA1j4ahA=="], "eventemitter3": ["eventemitter3@5.0.1", "", {}, "sha512-GWkBvjiSZK87ELrYOSESUYeVIc9mvLLf/nXalMOS5dYrgZq9o5OVkbZAVM06CVxYsCwH9BDZFPlQTlPA1j4ahA=="],
"exsolve": ["exsolve@1.0.7", "", {}, "sha512-VO5fQUzZtI6C+vx4w/4BWJpg3s/5l+6pRQEHzFRM8WFi4XffSP1Z+4qi7GbjWbvRQEbdIco5mIMq+zX4rPuLrw=="], "exsolve": ["exsolve@1.0.7", "", {}, "sha512-VO5fQUzZtI6C+vx4w/4BWJpg3s/5l+6pRQEHzFRM8WFi4XffSP1Z+4qi7GbjWbvRQEbdIco5mIMq+zX4rPuLrw=="],
@@ -256,6 +333,14 @@
"for-each": ["for-each@0.3.5", "", { "dependencies": { "is-callable": "^1.2.7" } }, "sha512-dKx12eRCVIzqCxFGplyFKJMPvLEWgmNtUrpTiJIR5u97zEhRG8ySrtboPHZXx7daLxQVrl643cTzbab2tkQjxg=="], "for-each": ["for-each@0.3.5", "", { "dependencies": { "is-callable": "^1.2.7" } }, "sha512-dKx12eRCVIzqCxFGplyFKJMPvLEWgmNtUrpTiJIR5u97zEhRG8ySrtboPHZXx7daLxQVrl643cTzbab2tkQjxg=="],
"for-in": ["for-in@1.0.2", "", {}, "sha512-7EwmXrOjyL+ChxMhmG5lnW9MPt1aIeZEwKhQzoBUdTV0N3zuwWDZYVJatDvZ2OyzPUvdIAZDsCetk3coyMfcnQ=="],
"for-own": ["for-own@0.1.5", "", { "dependencies": { "for-in": "^1.0.1" } }, "sha512-SKmowqGTJoPzLO1T0BBJpkfp3EMacCMOuH40hOUbrbzElVktk4DioXVM99QkLCyKoiuOmyjgcWMpVz2xjE7LZw=="],
"fs-extra": ["fs-extra@10.1.0", "", { "dependencies": { "graceful-fs": "^4.2.0", "jsonfile": "^6.0.1", "universalify": "^2.0.0" } }, "sha512-oRXApq54ETRj4eMiFzGnHWGy+zo5raudjuxN0b8H7s/RU2oW0Wvsx9O0ACRN/kRq9E8Vu/ReskGB5o3ji+FzHQ=="],
"fs.realpath": ["fs.realpath@1.0.0", "", {}, "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw=="],
"fsevents": ["fsevents@2.3.2", "", { "os": "darwin" }, "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA=="], "fsevents": ["fsevents@2.3.2", "", { "os": "darwin" }, "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA=="],
"function-bind": ["function-bind@1.1.2", "", {}, "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA=="], "function-bind": ["function-bind@1.1.2", "", {}, "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA=="],
@@ -268,6 +353,8 @@
"giget": ["giget@2.0.0", "", { "dependencies": { "citty": "^0.1.6", "consola": "^3.4.0", "defu": "^6.1.4", "node-fetch-native": "^1.6.6", "nypm": "^0.6.0", "pathe": "^2.0.3" }, "bin": { "giget": "dist/cli.mjs" } }, "sha512-L5bGsVkxJbJgdnwyuheIunkGatUF/zssUoxxjACCseZYAVbaqdh9Tsmmlkl8vYan09H7sbvKt4pS8GqKLBrEzA=="], "giget": ["giget@2.0.0", "", { "dependencies": { "citty": "^0.1.6", "consola": "^3.4.0", "defu": "^6.1.4", "node-fetch-native": "^1.6.6", "nypm": "^0.6.0", "pathe": "^2.0.3" }, "bin": { "giget": "dist/cli.mjs" } }, "sha512-L5bGsVkxJbJgdnwyuheIunkGatUF/zssUoxxjACCseZYAVbaqdh9Tsmmlkl8vYan09H7sbvKt4pS8GqKLBrEzA=="],
"glob": ["glob@7.2.3", "", { "dependencies": { "fs.realpath": "^1.0.0", "inflight": "^1.0.4", "inherits": "2", "minimatch": "^3.1.1", "once": "^1.3.0", "path-is-absolute": "^1.0.0" } }, "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q=="],
"gopd": ["gopd@1.2.0", "", {}, "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg=="], "gopd": ["gopd@1.2.0", "", {}, "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg=="],
"graceful-fs": ["graceful-fs@4.2.11", "", {}, "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ=="], "graceful-fs": ["graceful-fs@4.2.11", "", {}, "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ=="],
@@ -280,22 +367,38 @@
"hasown": ["hasown@2.0.2", "", { "dependencies": { "function-bind": "^1.1.2" } }, "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ=="], "hasown": ["hasown@2.0.2", "", { "dependencies": { "function-bind": "^1.1.2" } }, "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ=="],
"inflight": ["inflight@1.0.6", "", { "dependencies": { "once": "^1.3.0", "wrappy": "1" } }, "sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA=="],
"inherits": ["inherits@2.0.4", "", {}, "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ=="], "inherits": ["inherits@2.0.4", "", {}, "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ=="],
"ipaddr.js": ["ipaddr.js@2.2.0", "", {}, "sha512-Ag3wB2o37wslZS19hZqorUnrnzSkpOVy+IiiDEiTqNubEYpYuHWIf6K4psgN2ZWKExS4xhVCrRVfb/wfW8fWJA=="], "ipaddr.js": ["ipaddr.js@2.2.0", "", {}, "sha512-Ag3wB2o37wslZS19hZqorUnrnzSkpOVy+IiiDEiTqNubEYpYuHWIf6K4psgN2ZWKExS4xhVCrRVfb/wfW8fWJA=="],
"is-arguments": ["is-arguments@1.2.0", "", { "dependencies": { "call-bound": "^1.0.2", "has-tostringtag": "^1.0.2" } }, "sha512-7bVbi0huj/wrIAOzb8U1aszg9kdi3KN/CyU19CTI7tAoZYEZoL9yCDXpbXN+uPsuWnP02cyug1gleqq+TU+YCA=="], "is-arguments": ["is-arguments@1.2.0", "", { "dependencies": { "call-bound": "^1.0.2", "has-tostringtag": "^1.0.2" } }, "sha512-7bVbi0huj/wrIAOzb8U1aszg9kdi3KN/CyU19CTI7tAoZYEZoL9yCDXpbXN+uPsuWnP02cyug1gleqq+TU+YCA=="],
"is-buffer": ["is-buffer@1.1.6", "", {}, "sha512-NcdALwpXkTm5Zvvbk7owOUSvVvBKDgKP5/ewfXEznmQFfs4ZRmanOeKBTjRVjka3QFoN6XJ+9F3USqfHqTaU5w=="],
"is-callable": ["is-callable@1.2.7", "", {}, "sha512-1BC0BVFhS/p0qtw6enp8e+8OD0UrK0oFLztSjNzhcKA3WDuJxxAPXzPuPtKkjEY9UUoEWlX/8fgKeu2S8i9JTA=="], "is-callable": ["is-callable@1.2.7", "", {}, "sha512-1BC0BVFhS/p0qtw6enp8e+8OD0UrK0oFLztSjNzhcKA3WDuJxxAPXzPuPtKkjEY9UUoEWlX/8fgKeu2S8i9JTA=="],
"is-extendable": ["is-extendable@0.1.1", "", {}, "sha512-5BMULNob1vgFX6EjQw5izWDxrecWK9AM72rugNr0TFldMOi0fj6Jk+zeKIt0xGj4cEfQIJth4w3OKWOJ4f+AFw=="],
"is-generator-function": ["is-generator-function@1.1.2", "", { "dependencies": { "call-bound": "^1.0.4", "generator-function": "^2.0.0", "get-proto": "^1.0.1", "has-tostringtag": "^1.0.2", "safe-regex-test": "^1.1.0" } }, "sha512-upqt1SkGkODW9tsGNG5mtXTXtECizwtS2kA161M+gJPc1xdb/Ax629af6YrTwcOeQHbewrPNlE5Dx7kzvXTizA=="], "is-generator-function": ["is-generator-function@1.1.2", "", { "dependencies": { "call-bound": "^1.0.4", "generator-function": "^2.0.0", "get-proto": "^1.0.1", "has-tostringtag": "^1.0.2", "safe-regex-test": "^1.1.0" } }, "sha512-upqt1SkGkODW9tsGNG5mtXTXtECizwtS2kA161M+gJPc1xdb/Ax629af6YrTwcOeQHbewrPNlE5Dx7kzvXTizA=="],
"is-plain-object": ["is-plain-object@2.0.4", "", { "dependencies": { "isobject": "^3.0.1" } }, "sha512-h5PpgXkWitc38BBMYawTYMWJHFZJVnBquFE57xFpjB8pJFiF6gZ+bU+WyI/yqXiFR5mdLsgYNaPe8uao6Uv9Og=="],
"is-regex": ["is-regex@1.2.1", "", { "dependencies": { "call-bound": "^1.0.2", "gopd": "^1.2.0", "has-tostringtag": "^1.0.2", "hasown": "^2.0.2" } }, "sha512-MjYsKHO5O7mCsmRGxWcLWheFqN9DJ/2TmngvjKXihe6efViPqc274+Fx/4fYj/r03+ESvBdTXK0V6tA3rgez1g=="], "is-regex": ["is-regex@1.2.1", "", { "dependencies": { "call-bound": "^1.0.2", "gopd": "^1.2.0", "has-tostringtag": "^1.0.2", "hasown": "^2.0.2" } }, "sha512-MjYsKHO5O7mCsmRGxWcLWheFqN9DJ/2TmngvjKXihe6efViPqc274+Fx/4fYj/r03+ESvBdTXK0V6tA3rgez1g=="],
"is-typed-array": ["is-typed-array@1.1.15", "", { "dependencies": { "which-typed-array": "^1.1.16" } }, "sha512-p3EcsicXjit7SaskXHs1hA91QxgTw46Fv6EFKKGS5DRFLD8yKnohjF3hxoju94b/OcMZoQukzpPpBE9uLVKzgQ=="], "is-typed-array": ["is-typed-array@1.1.15", "", { "dependencies": { "which-typed-array": "^1.1.16" } }, "sha512-p3EcsicXjit7SaskXHs1hA91QxgTw46Fv6EFKKGS5DRFLD8yKnohjF3hxoju94b/OcMZoQukzpPpBE9uLVKzgQ=="],
"isobject": ["isobject@3.0.1", "", {}, "sha512-WhB9zCku7EGTj/HQQRz5aUQEUeoQZH2bWcltRErOpymJ4boYE6wL9Tbr23krRPSZ+C5zqNSrSw+Cc7sZZ4b7vg=="],
"jiti": ["jiti@2.6.1", "", { "bin": { "jiti": "lib/jiti-cli.mjs" } }, "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ=="], "jiti": ["jiti@2.6.1", "", { "bin": { "jiti": "lib/jiti-cli.mjs" } }, "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ=="],
"jsonfile": ["jsonfile@6.2.0", "", { "dependencies": { "universalify": "^2.0.0" }, "optionalDependencies": { "graceful-fs": "^4.1.6" } }, "sha512-FGuPw30AdOIUTRMC2OMRtQV+jkVj2cfPqSeWXv1NEAJ1qZ5zb1X6z1mFhbfOB/iy3ssJCD+3KuZ8r8C3uVFlAg=="],
"kind-of": ["kind-of@3.2.2", "", { "dependencies": { "is-buffer": "^1.1.5" } }, "sha512-NOW9QQXMoZGg/oqnVNoNTTIFEIid1627WCffUBJEdMxYApq7mNE7CpzucIPc+ZQg25Phej7IJSmX3hO+oblOtQ=="],
"lazy-cache": ["lazy-cache@1.0.4", "", {}, "sha512-RE2g0b5VGZsOCFOCgP7omTRYFqydmZkBwl5oNnQ1lDYC57uyO9KqNnNVxT7COSHTxrRCWVcAVOcbjk+tvh/rgQ=="],
"lightningcss": ["lightningcss@1.30.1", "", { "dependencies": { "detect-libc": "^2.0.3" }, "optionalDependencies": { "lightningcss-darwin-arm64": "1.30.1", "lightningcss-darwin-x64": "1.30.1", "lightningcss-freebsd-x64": "1.30.1", "lightningcss-linux-arm-gnueabihf": "1.30.1", "lightningcss-linux-arm64-gnu": "1.30.1", "lightningcss-linux-arm64-musl": "1.30.1", "lightningcss-linux-x64-gnu": "1.30.1", "lightningcss-linux-x64-musl": "1.30.1", "lightningcss-win32-arm64-msvc": "1.30.1", "lightningcss-win32-x64-msvc": "1.30.1" } }, "sha512-xi6IyHML+c9+Q3W0S4fCQJOym42pyurFiJUHEcEyHS0CeKzia4yZDEsLlqOFykxOdHpNy0NmvVO31vcSqAxJCg=="], "lightningcss": ["lightningcss@1.30.1", "", { "dependencies": { "detect-libc": "^2.0.3" }, "optionalDependencies": { "lightningcss-darwin-arm64": "1.30.1", "lightningcss-darwin-x64": "1.30.1", "lightningcss-freebsd-x64": "1.30.1", "lightningcss-linux-arm-gnueabihf": "1.30.1", "lightningcss-linux-arm64-gnu": "1.30.1", "lightningcss-linux-arm64-musl": "1.30.1", "lightningcss-linux-x64-gnu": "1.30.1", "lightningcss-linux-x64-musl": "1.30.1", "lightningcss-win32-arm64-msvc": "1.30.1", "lightningcss-win32-x64-msvc": "1.30.1" } }, "sha512-xi6IyHML+c9+Q3W0S4fCQJOym42pyurFiJUHEcEyHS0CeKzia4yZDEsLlqOFykxOdHpNy0NmvVO31vcSqAxJCg=="],
"lightningcss-darwin-arm64": ["lightningcss-darwin-arm64@1.30.1", "", { "os": "darwin", "cpu": "arm64" }, "sha512-c8JK7hyE65X1MHMN+Viq9n11RRC7hgin3HhYKhrMyaXflk5GVplZ60IxyoVtzILeKr+xAJwg6zK6sjTBJ0FKYQ=="], "lightningcss-darwin-arm64": ["lightningcss-darwin-arm64@1.30.1", "", { "os": "darwin", "cpu": "arm64" }, "sha512-c8JK7hyE65X1MHMN+Viq9n11RRC7hgin3HhYKhrMyaXflk5GVplZ60IxyoVtzILeKr+xAJwg6zK6sjTBJ0FKYQ=="],
@@ -326,19 +429,27 @@
"math-intrinsics": ["math-intrinsics@1.1.0", "", {}, "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g=="], "math-intrinsics": ["math-intrinsics@1.1.0", "", {}, "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g=="],
"merge-deep": ["merge-deep@3.0.3", "", { "dependencies": { "arr-union": "^3.1.0", "clone-deep": "^0.2.4", "kind-of": "^3.0.2" } }, "sha512-qtmzAS6t6grwEkNrunqTBdn0qKwFgNWvlxUbAV8es9M7Ot1EbyApytCnvE0jALPa46ZpKDUo527kKiaWplmlFA=="],
"mime-db": ["mime-db@1.52.0", "", {}, "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg=="], "mime-db": ["mime-db@1.52.0", "", {}, "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg=="],
"mime-types": ["mime-types@2.1.35", "", { "dependencies": { "mime-db": "1.52.0" } }, "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw=="], "mime-types": ["mime-types@2.1.35", "", { "dependencies": { "mime-db": "1.52.0" } }, "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw=="],
"minimatch": ["minimatch@3.1.2", "", { "dependencies": { "brace-expansion": "^1.1.7" } }, "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw=="],
"minio": ["minio@8.0.6", "", { "dependencies": { "async": "^3.2.4", "block-stream2": "^2.1.0", "browser-or-node": "^2.1.1", "buffer-crc32": "^1.0.0", "eventemitter3": "^5.0.1", "fast-xml-parser": "^4.4.1", "ipaddr.js": "^2.0.1", "lodash": "^4.17.21", "mime-types": "^2.1.35", "query-string": "^7.1.3", "stream-json": "^1.8.0", "through2": "^4.0.2", "web-encoding": "^1.1.5", "xml2js": "^0.5.0 || ^0.6.2" } }, "sha512-sOeh2/b/XprRmEtYsnNRFtOqNRTPDvYtMWh+spWlfsuCV/+IdxNeKVUMKLqI7b5Dr07ZqCPuaRGU/rB9pZYVdQ=="], "minio": ["minio@8.0.6", "", { "dependencies": { "async": "^3.2.4", "block-stream2": "^2.1.0", "browser-or-node": "^2.1.1", "buffer-crc32": "^1.0.0", "eventemitter3": "^5.0.1", "fast-xml-parser": "^4.4.1", "ipaddr.js": "^2.0.1", "lodash": "^4.17.21", "mime-types": "^2.1.35", "query-string": "^7.1.3", "stream-json": "^1.8.0", "through2": "^4.0.2", "web-encoding": "^1.1.5", "xml2js": "^0.5.0 || ^0.6.2" } }, "sha512-sOeh2/b/XprRmEtYsnNRFtOqNRTPDvYtMWh+spWlfsuCV/+IdxNeKVUMKLqI7b5Dr07ZqCPuaRGU/rB9pZYVdQ=="],
"minipass": ["minipass@7.1.2", "", {}, "sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw=="], "minipass": ["minipass@7.1.2", "", {}, "sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw=="],
"minizlib": ["minizlib@3.1.0", "", { "dependencies": { "minipass": "^7.1.2" } }, "sha512-KZxYo1BUkWD2TVFLr0MQoM8vUUigWD3LlD83a/75BqC+4qE0Hb1Vo5v1FgcfaNXvfXzr+5EhQ6ing/CaBijTlw=="], "minizlib": ["minizlib@3.1.0", "", { "dependencies": { "minipass": "^7.1.2" } }, "sha512-KZxYo1BUkWD2TVFLr0MQoM8vUUigWD3LlD83a/75BqC+4qE0Hb1Vo5v1FgcfaNXvfXzr+5EhQ6ing/CaBijTlw=="],
"mixin-object": ["mixin-object@2.0.1", "", { "dependencies": { "for-in": "^0.1.3", "is-extendable": "^0.1.1" } }, "sha512-ALGF1Jt9ouehcaXaHhn6t1yGWRqGaHkPFndtFVHfZXOvkIZ/yoGaSi0AHVTafb3ZBGg4dr/bDwnaEKqCXzchMA=="],
"ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="],
"nanoid": ["nanoid@3.3.11", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w=="], "nanoid": ["nanoid@3.3.11", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w=="],
"next": ["next@15.5.6", "", { "dependencies": { "@next/env": "15.5.6", "@swc/helpers": "0.5.15", "caniuse-lite": "^1.0.30001579", "postcss": "8.4.31", "styled-jsx": "5.1.6" }, "optionalDependencies": { "@next/swc-darwin-arm64": "15.5.6", "@next/swc-darwin-x64": "15.5.6", "@next/swc-linux-arm64-gnu": "15.5.6", "@next/swc-linux-arm64-musl": "15.5.6", "@next/swc-linux-x64-gnu": "15.5.6", "@next/swc-linux-x64-musl": "15.5.6", "@next/swc-win32-arm64-msvc": "15.5.6", "@next/swc-win32-x64-msvc": "15.5.6", "sharp": "^0.34.3" }, "peerDependencies": { "@opentelemetry/api": "^1.1.0", "@playwright/test": "^1.51.1", "babel-plugin-react-compiler": "*", "react": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0", "react-dom": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0", "sass": "^1.3.0" }, "optionalPeers": ["@opentelemetry/api", "@playwright/test", "babel-plugin-react-compiler", "sass"], "bin": { "next": "dist/bin/next" } }, "sha512-zTxsnI3LQo3c9HSdSf91O1jMNsEzIXDShXd4wVdg9y5shwLqBXi4ZtUUJyB86KGVSJLZx0PFONvO54aheGX8QQ=="], "next": ["next@15.5.7", "https://registry.npmmirror.com/next/-/next-15.5.7.tgz", { "dependencies": { "@next/env": "15.5.7", "@swc/helpers": "0.5.15", "caniuse-lite": "^1.0.30001579", "postcss": "8.4.31", "styled-jsx": "5.1.6" }, "optionalDependencies": { "@next/swc-darwin-arm64": "15.5.7", "@next/swc-darwin-x64": "15.5.7", "@next/swc-linux-arm64-gnu": "15.5.7", "@next/swc-linux-arm64-musl": "15.5.7", "@next/swc-linux-x64-gnu": "15.5.7", "@next/swc-linux-x64-musl": "15.5.7", "@next/swc-win32-arm64-msvc": "15.5.7", "@next/swc-win32-x64-msvc": "15.5.7", "sharp": "^0.34.3" }, "peerDependencies": { "@opentelemetry/api": "^1.1.0", "@playwright/test": "^1.51.1", "babel-plugin-react-compiler": "*", "react": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0", "react-dom": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0", "sass": "^1.3.0" }, "optionalPeers": ["@opentelemetry/api", "@playwright/test", "babel-plugin-react-compiler", "sass"], "bin": { "next": "dist/bin/next" } }, "sha512-+t2/0jIJ48kUpGKkdlhgkv+zPTEOoXyr60qXe68eB/pl3CMJaLeIGjzp5D6Oqt25hCBiBTt8wEeeAzfJvUKnPQ=="],
"node-fetch-native": ["node-fetch-native@1.6.7", "", {}, "sha512-g9yhqoedzIUm0nTnTqAQvueMPVOuIY16bqgAJJC8XOOubYFNwz6IER9qs0Gq2Xd0+CecCKFjtdDTMA4u4xG06Q=="], "node-fetch-native": ["node-fetch-native@1.6.7", "", {}, "sha512-g9yhqoedzIUm0nTnTqAQvueMPVOuIY16bqgAJJC8XOOubYFNwz6IER9qs0Gq2Xd0+CecCKFjtdDTMA4u4xG06Q=="],
@@ -346,6 +457,10 @@
"ohash": ["ohash@2.0.11", "", {}, "sha512-RdR9FQrFwNBNXAr4GixM8YaRZRJ5PUWbKYbE5eOsrwAjJW0q2REGcf79oYPsLyskQCZG1PLN+S/K1V00joZAoQ=="], "ohash": ["ohash@2.0.11", "", {}, "sha512-RdR9FQrFwNBNXAr4GixM8YaRZRJ5PUWbKYbE5eOsrwAjJW0q2REGcf79oYPsLyskQCZG1PLN+S/K1V00joZAoQ=="],
"once": ["once@1.4.0", "", { "dependencies": { "wrappy": "1" } }, "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w=="],
"path-is-absolute": ["path-is-absolute@1.0.1", "", {}, "sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg=="],
"pathe": ["pathe@2.0.3", "", {}, "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w=="], "pathe": ["pathe@2.0.3", "", {}, "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w=="],
"perfect-debounce": ["perfect-debounce@1.0.0", "", {}, "sha512-xCy9V055GLEqoFaHoC1SoLIaLmWctgCUaBaWxDZ7/Zx4CTyX7cJQLJOok/orfjZAh9kEYpjJa4d0KcJmCbctZA=="], "perfect-debounce": ["perfect-debounce@1.0.0", "", {}, "sha512-xCy9V055GLEqoFaHoC1SoLIaLmWctgCUaBaWxDZ7/Zx4CTyX7cJQLJOok/orfjZAh9kEYpjJa4d0KcJmCbctZA=="],
@@ -358,12 +473,22 @@
"playwright-core": ["playwright-core@1.56.1", "", { "bin": { "playwright-core": "cli.js" } }, "sha512-hutraynyn31F+Bifme+Ps9Vq59hKuUCz7H1kDOcBs+2oGguKkWTU50bBWrtz34OUWmIwpBTWDxaRPXrIXkgvmQ=="], "playwright-core": ["playwright-core@1.56.1", "", { "bin": { "playwright-core": "cli.js" } }, "sha512-hutraynyn31F+Bifme+Ps9Vq59hKuUCz7H1kDOcBs+2oGguKkWTU50bBWrtz34OUWmIwpBTWDxaRPXrIXkgvmQ=="],
"playwright-extra": ["playwright-extra@4.3.6", "", { "dependencies": { "debug": "^4.3.4" }, "peerDependencies": { "playwright": "*", "playwright-core": "*" }, "optionalPeers": ["playwright", "playwright-core"] }, "sha512-q2rVtcE8V8K3vPVF1zny4pvwZveHLH8KBuVU2MoE3Jw4OKVoBWsHI9CH9zPydovHHOCDxjGN2Vg+2m644q3ijA=="],
"possible-typed-array-names": ["possible-typed-array-names@1.1.0", "", {}, "sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg=="], "possible-typed-array-names": ["possible-typed-array-names@1.1.0", "", {}, "sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg=="],
"postcss": ["postcss@8.5.6", "", { "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg=="], "postcss": ["postcss@8.5.6", "", { "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg=="],
"prisma": ["prisma@6.17.1", "", { "dependencies": { "@prisma/config": "6.17.1", "@prisma/engines": "6.17.1" }, "peerDependencies": { "typescript": ">=5.1.0" }, "optionalPeers": ["typescript"], "bin": { "prisma": "build/index.js" } }, "sha512-ac6h0sM1Tg3zu8NInY+qhP/S9KhENVaw9n1BrGKQVFu05JT5yT5Qqqmb8tMRIE3ZXvVj4xcRA5yfrsy4X7Yy5g=="], "prisma": ["prisma@6.17.1", "", { "dependencies": { "@prisma/config": "6.17.1", "@prisma/engines": "6.17.1" }, "peerDependencies": { "typescript": ">=5.1.0" }, "optionalPeers": ["typescript"], "bin": { "prisma": "build/index.js" } }, "sha512-ac6h0sM1Tg3zu8NInY+qhP/S9KhENVaw9n1BrGKQVFu05JT5yT5Qqqmb8tMRIE3ZXvVj4xcRA5yfrsy4X7Yy5g=="],
"puppeteer-extra-plugin": ["puppeteer-extra-plugin@3.2.3", "", { "dependencies": { "@types/debug": "^4.1.0", "debug": "^4.1.1", "merge-deep": "^3.0.1" }, "peerDependencies": { "playwright-extra": "*", "puppeteer-extra": "*" }, "optionalPeers": ["playwright-extra", "puppeteer-extra"] }, "sha512-6RNy0e6pH8vaS3akPIKGg28xcryKscczt4wIl0ePciZENGE2yoaQJNd17UiEbdmh5/6WW6dPcfRWT9lxBwCi2Q=="],
"puppeteer-extra-plugin-stealth": ["puppeteer-extra-plugin-stealth@2.11.2", "", { "dependencies": { "debug": "^4.1.1", "puppeteer-extra-plugin": "^3.2.3", "puppeteer-extra-plugin-user-preferences": "^2.4.1" }, "peerDependencies": { "playwright-extra": "*", "puppeteer-extra": "*" }, "optionalPeers": ["playwright-extra", "puppeteer-extra"] }, "sha512-bUemM5XmTj9i2ZerBzsk2AN5is0wHMNE6K0hXBzBXOzP5m5G3Wl0RHhiqKeHToe/uIH8AoZiGhc1tCkLZQPKTQ=="],
"puppeteer-extra-plugin-user-data-dir": ["puppeteer-extra-plugin-user-data-dir@2.4.1", "", { "dependencies": { "debug": "^4.1.1", "fs-extra": "^10.0.0", "puppeteer-extra-plugin": "^3.2.3", "rimraf": "^3.0.2" }, "peerDependencies": { "playwright-extra": "*", "puppeteer-extra": "*" }, "optionalPeers": ["playwright-extra", "puppeteer-extra"] }, "sha512-kH1GnCcqEDoBXO7epAse4TBPJh9tEpVEK/vkedKfjOVOhZAvLkHGc9swMs5ChrJbRnf8Hdpug6TJlEuimXNQ+g=="],
"puppeteer-extra-plugin-user-preferences": ["puppeteer-extra-plugin-user-preferences@2.4.1", "", { "dependencies": { "debug": "^4.1.1", "deepmerge": "^4.2.2", "puppeteer-extra-plugin": "^3.2.3", "puppeteer-extra-plugin-user-data-dir": "^2.4.1" }, "peerDependencies": { "playwright-extra": "*", "puppeteer-extra": "*" }, "optionalPeers": ["playwright-extra", "puppeteer-extra"] }, "sha512-i1oAZxRbc1bk8MZufKCruCEC3CCafO9RKMkkodZltI4OqibLFXF3tj6HZ4LZ9C5vCXZjYcDWazgtY69mnmrQ9A=="],
"pure-rand": ["pure-rand@6.1.0", "", {}, "sha512-bVWawvoZoBYpp6yIoQtQXHZjmz35RSVHnUOTefl8Vcjr8snTPY1wnpSPMWekcFwbxI6gtmT7rSYPFvz71ldiOA=="], "pure-rand": ["pure-rand@6.1.0", "", {}, "sha512-bVWawvoZoBYpp6yIoQtQXHZjmz35RSVHnUOTefl8Vcjr8snTPY1wnpSPMWekcFwbxI6gtmT7rSYPFvz71ldiOA=="],
"query-string": ["query-string@7.1.3", "", { "dependencies": { "decode-uri-component": "^0.2.2", "filter-obj": "^1.1.0", "split-on-first": "^1.0.0", "strict-uri-encode": "^2.0.0" } }, "sha512-hh2WYhq4fi8+b+/2Kg9CEge4fDPvHS534aOOvOZeQ3+Vf2mCFsaFBYj0i+iXcAq6I9Vzp5fjMFBlONvayDC1qg=="], "query-string": ["query-string@7.1.3", "", { "dependencies": { "decode-uri-component": "^0.2.2", "filter-obj": "^1.1.0", "split-on-first": "^1.0.0", "strict-uri-encode": "^2.0.0" } }, "sha512-hh2WYhq4fi8+b+/2Kg9CEge4fDPvHS534aOOvOZeQ3+Vf2mCFsaFBYj0i+iXcAq6I9Vzp5fjMFBlONvayDC1qg=="],
@@ -378,6 +503,8 @@
"readdirp": ["readdirp@4.1.2", "", {}, "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg=="], "readdirp": ["readdirp@4.1.2", "", {}, "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg=="],
"rimraf": ["rimraf@3.0.2", "", { "dependencies": { "glob": "^7.1.3" }, "bin": { "rimraf": "bin.js" } }, "sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA=="],
"safe-buffer": ["safe-buffer@5.2.1", "", {}, "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ=="], "safe-buffer": ["safe-buffer@5.2.1", "", {}, "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ=="],
"safe-regex-test": ["safe-regex-test@1.1.0", "", { "dependencies": { "call-bound": "^1.0.2", "es-errors": "^1.3.0", "is-regex": "^1.2.1" } }, "sha512-x/+Cz4YrimQxQccJf5mKEbIa1NzeCRNI5Ecl/ekmlYaampdNLPalVyIcCZNNH3MvmqBugV5TMYZXv0ljslUlaw=="], "safe-regex-test": ["safe-regex-test@1.1.0", "", { "dependencies": { "call-bound": "^1.0.2", "es-errors": "^1.3.0", "is-regex": "^1.2.1" } }, "sha512-x/+Cz4YrimQxQccJf5mKEbIa1NzeCRNI5Ecl/ekmlYaampdNLPalVyIcCZNNH3MvmqBugV5TMYZXv0ljslUlaw=="],
@@ -390,6 +517,8 @@
"set-function-length": ["set-function-length@1.2.2", "", { "dependencies": { "define-data-property": "^1.1.4", "es-errors": "^1.3.0", "function-bind": "^1.1.2", "get-intrinsic": "^1.2.4", "gopd": "^1.0.1", "has-property-descriptors": "^1.0.2" } }, "sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg=="], "set-function-length": ["set-function-length@1.2.2", "", { "dependencies": { "define-data-property": "^1.1.4", "es-errors": "^1.3.0", "function-bind": "^1.1.2", "get-intrinsic": "^1.2.4", "gopd": "^1.0.1", "has-property-descriptors": "^1.0.2" } }, "sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg=="],
"shallow-clone": ["shallow-clone@0.1.2", "", { "dependencies": { "is-extendable": "^0.1.1", "kind-of": "^2.0.1", "lazy-cache": "^0.2.3", "mixin-object": "^2.0.1" } }, "sha512-J1zdXCky5GmNnuauESROVu31MQSnLoYvlyEn6j2Ztk6Q5EHFIhxkMhYcv6vuDzl2XEzoRr856QwzMgWM/TmZgw=="],
"sharp": ["sharp@0.34.4", "", { "dependencies": { "@img/colour": "^1.0.0", "detect-libc": "^2.1.0", "semver": "^7.7.2" }, "optionalDependencies": { "@img/sharp-darwin-arm64": "0.34.4", "@img/sharp-darwin-x64": "0.34.4", "@img/sharp-libvips-darwin-arm64": "1.2.3", "@img/sharp-libvips-darwin-x64": "1.2.3", "@img/sharp-libvips-linux-arm": "1.2.3", "@img/sharp-libvips-linux-arm64": "1.2.3", "@img/sharp-libvips-linux-ppc64": "1.2.3", "@img/sharp-libvips-linux-s390x": "1.2.3", "@img/sharp-libvips-linux-x64": "1.2.3", "@img/sharp-libvips-linuxmusl-arm64": "1.2.3", "@img/sharp-libvips-linuxmusl-x64": "1.2.3", "@img/sharp-linux-arm": "0.34.4", "@img/sharp-linux-arm64": "0.34.4", "@img/sharp-linux-ppc64": "0.34.4", "@img/sharp-linux-s390x": "0.34.4", "@img/sharp-linux-x64": "0.34.4", "@img/sharp-linuxmusl-arm64": "0.34.4", "@img/sharp-linuxmusl-x64": "0.34.4", "@img/sharp-wasm32": "0.34.4", "@img/sharp-win32-arm64": "0.34.4", "@img/sharp-win32-ia32": "0.34.4", "@img/sharp-win32-x64": "0.34.4" } }, "sha512-FUH39xp3SBPnxWvd5iib1X8XY7J0K0X7d93sie9CJg2PO8/7gmg89Nve6OjItK53/MlAushNNxteBYfM6DEuoA=="], "sharp": ["sharp@0.34.4", "", { "dependencies": { "@img/colour": "^1.0.0", "detect-libc": "^2.1.0", "semver": "^7.7.2" }, "optionalDependencies": { "@img/sharp-darwin-arm64": "0.34.4", "@img/sharp-darwin-x64": "0.34.4", "@img/sharp-libvips-darwin-arm64": "1.2.3", "@img/sharp-libvips-darwin-x64": "1.2.3", "@img/sharp-libvips-linux-arm": "1.2.3", "@img/sharp-libvips-linux-arm64": "1.2.3", "@img/sharp-libvips-linux-ppc64": "1.2.3", "@img/sharp-libvips-linux-s390x": "1.2.3", "@img/sharp-libvips-linux-x64": "1.2.3", "@img/sharp-libvips-linuxmusl-arm64": "1.2.3", "@img/sharp-libvips-linuxmusl-x64": "1.2.3", "@img/sharp-linux-arm": "0.34.4", "@img/sharp-linux-arm64": "0.34.4", "@img/sharp-linux-ppc64": "0.34.4", "@img/sharp-linux-s390x": "0.34.4", "@img/sharp-linux-x64": "0.34.4", "@img/sharp-linuxmusl-arm64": "0.34.4", "@img/sharp-linuxmusl-x64": "0.34.4", "@img/sharp-wasm32": "0.34.4", "@img/sharp-win32-arm64": "0.34.4", "@img/sharp-win32-ia32": "0.34.4", "@img/sharp-win32-x64": "0.34.4" } }, "sha512-FUH39xp3SBPnxWvd5iib1X8XY7J0K0X7d93sie9CJg2PO8/7gmg89Nve6OjItK53/MlAushNNxteBYfM6DEuoA=="],
"source-map-js": ["source-map-js@1.2.1", "", {}, "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA=="], "source-map-js": ["source-map-js@1.2.1", "", {}, "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA=="],
@@ -420,10 +549,14 @@
"tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], "tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="],
"tsx": ["tsx@4.23.15", "", { "dependencies": { "esbuild": "~0.28.0" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "bin": { "tsx": "dist/cli.mjs" } }, "sha512-Yiex1Ovn8z2xPpOWckIiysV1SSyRMY9BkLF++q0yKiDxCqRhosKfMg3janKkiLBwZ5c/YryloKwGZcrEmtwxKw=="],
"typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="], "typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="],
"undici-types": ["undici-types@6.21.0", "", {}, "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ=="], "undici-types": ["undici-types@6.21.0", "", {}, "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ=="],
"universalify": ["universalify@2.0.1", "", {}, "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw=="],
"util": ["util@0.12.5", "", { "dependencies": { "inherits": "^2.0.3", "is-arguments": "^1.0.4", "is-generator-function": "^1.0.7", "is-typed-array": "^1.1.3", "which-typed-array": "^1.1.2" } }, "sha512-kZf/K6hEIrWHI6XqOFUiiMa+79wE/D8Q+NCNAWclkyg3b4d2k7s0QGepNjiABc+aR3N1PAyHL7p6UcLY6LmrnA=="], "util": ["util@0.12.5", "", { "dependencies": { "inherits": "^2.0.3", "is-arguments": "^1.0.4", "is-generator-function": "^1.0.7", "is-typed-array": "^1.1.3", "which-typed-array": "^1.1.2" } }, "sha512-kZf/K6hEIrWHI6XqOFUiiMa+79wE/D8Q+NCNAWclkyg3b4d2k7s0QGepNjiABc+aR3N1PAyHL7p6UcLY6LmrnA=="],
"util-deprecate": ["util-deprecate@1.0.2", "", {}, "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw=="], "util-deprecate": ["util-deprecate@1.0.2", "", {}, "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw=="],
@@ -432,6 +565,8 @@
"which-typed-array": ["which-typed-array@1.1.19", "", { "dependencies": { "available-typed-arrays": "^1.0.7", "call-bind": "^1.0.8", "call-bound": "^1.0.4", "for-each": "^0.3.5", "get-proto": "^1.0.1", "gopd": "^1.2.0", "has-tostringtag": "^1.0.2" } }, "sha512-rEvr90Bck4WZt9HHFC4DJMsjvu7x+r6bImz0/BrbWb7A2djJ8hnZMrWnHo9F8ssv0OMErasDhftrfROTyqSDrw=="], "which-typed-array": ["which-typed-array@1.1.19", "", { "dependencies": { "available-typed-arrays": "^1.0.7", "call-bind": "^1.0.8", "call-bound": "^1.0.4", "for-each": "^0.3.5", "get-proto": "^1.0.1", "gopd": "^1.2.0", "has-tostringtag": "^1.0.2" } }, "sha512-rEvr90Bck4WZt9HHFC4DJMsjvu7x+r6bImz0/BrbWb7A2djJ8hnZMrWnHo9F8ssv0OMErasDhftrfROTyqSDrw=="],
"wrappy": ["wrappy@1.0.2", "", {}, "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ=="],
"xml2js": ["xml2js@0.6.2", "", { "dependencies": { "sax": ">=0.6.0", "xmlbuilder": "~11.0.0" } }, "sha512-T4rieHaC1EXcES0Kxxj4JWgaUQHDk+qwHcYOCFHfiwKz7tOVPLq7Hjq9dM1WCMhylqMEfP7hMcOIChvotiZegA=="], "xml2js": ["xml2js@0.6.2", "", { "dependencies": { "sax": ">=0.6.0", "xmlbuilder": "~11.0.0" } }, "sha512-T4rieHaC1EXcES0Kxxj4JWgaUQHDk+qwHcYOCFHfiwKz7tOVPLq7Hjq9dM1WCMhylqMEfP7hMcOIChvotiZegA=="],
"xmlbuilder": ["xmlbuilder@11.0.1", "", {}, "sha512-fDlsI/kFEx7gLvbecc0/ohLG50fugQp8ryHzMTuW9vSa1GJ0XYWKnhsUx7oie3G98+r56aTQIUB4kht42R3JvA=="], "xmlbuilder": ["xmlbuilder@11.0.1", "", {}, "sha512-fDlsI/kFEx7gLvbecc0/ohLG50fugQp8ryHzMTuW9vSa1GJ0XYWKnhsUx7oie3G98+r56aTQIUB4kht42R3JvA=="],
@@ -450,6 +585,14 @@
"@tailwindcss/oxide-wasm32-wasi/tslib": ["tslib@2.8.1", "", { "bundled": true }, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], "@tailwindcss/oxide-wasm32-wasi/tslib": ["tslib@2.8.1", "", { "bundled": true }, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="],
"mixin-object/for-in": ["for-in@0.1.8", "", {}, "sha512-F0to7vbBSHP8E3l6dCjxNOLuSFAACIxFy3UehTUlG7svlXi37HHsDkyVcHo0Pq8QwrE+pXvWSVX3ZT1T9wAZ9g=="],
"next/postcss": ["postcss@8.4.31", "", { "dependencies": { "nanoid": "^3.3.6", "picocolors": "^1.0.0", "source-map-js": "^1.0.2" } }, "sha512-PS08Iboia9mts/2ygV3eLpY5ghnUcfLV/EXTOW1E2qYxJKGGBUtNjN76FYHnMs36RmARn41bC0AZmn+rR0OVpQ=="], "next/postcss": ["postcss@8.4.31", "", { "dependencies": { "nanoid": "^3.3.6", "picocolors": "^1.0.0", "source-map-js": "^1.0.2" } }, "sha512-PS08Iboia9mts/2ygV3eLpY5ghnUcfLV/EXTOW1E2qYxJKGGBUtNjN76FYHnMs36RmARn41bC0AZmn+rR0OVpQ=="],
"shallow-clone/kind-of": ["kind-of@2.0.1", "", { "dependencies": { "is-buffer": "^1.0.2" } }, "sha512-0u8i1NZ/mg0b+W3MGGw5I7+6Eib2nx72S/QvXa0hYjEkjTknYmEYQJwGu3mLC0BrhtJjtQafTkyRUQ75Kx0LVg=="],
"shallow-clone/lazy-cache": ["lazy-cache@0.2.7", "", {}, "sha512-gkX52wvU/R8DVMMt78ATVPFMJqfW8FPz1GZ1sVHBVQHmu/WvhIWE4cE1GBzhJNFicDeYhnwp6Rl35BcAIM3YOQ=="],
"tsx/fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="],
} }
} }
+134
View File
@@ -0,0 +1,134 @@
// scripts/fix-asset-urls.ts
import { PrismaClient } from "@prisma/client";
const prisma = new PrismaClient();
const FROM = "douyin-archive/";
const TO = "";
function escapeForPgRegex(s: string) {
return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
}
const FROM_RE = `^${escapeForPgRegex(FROM)}`; // 只替换“以旧前缀开头”的字符串
const dryRun = false; // true: 只统计,不修改
async function main() {
if (dryRun) {
const rows = await prisma.$queryRawUnsafe<any[]>(`
WITH c AS (
SELECT 'Author.avatar_url' AS col, COUNT(*) AS n FROM "Author" WHERE avatar_url LIKE '${FROM}%'
UNION ALL
SELECT 'CommentUser.avatar_url' , COUNT(*) FROM "CommentUser" WHERE avatar_url LIKE '${FROM}%'
UNION ALL
SELECT 'CommentImage.url' , COUNT(*) FROM "CommentImage" WHERE url LIKE '${FROM}%'
UNION ALL
SELECT 'Video.cover_url' , COUNT(*) FROM "Video" WHERE cover_url LIKE '${FROM}%'
UNION ALL
SELECT 'Video.video_url' , COUNT(*) FROM "Video" WHERE video_url LIKE '${FROM}%'
UNION ALL
SELECT 'ImageFile.url' , COUNT(*) FROM "ImageFile" WHERE url LIKE '${FROM}%'
UNION ALL
SELECT 'ImageFile.animated' , COUNT(*) FROM "ImageFile" WHERE animated LIKE '${FROM}%'
UNION ALL
SELECT 'ImagePost.music_url' , COUNT(*) FROM "ImagePost" WHERE music_url LIKE '${FROM}%'
UNION ALL
SELECT 'Video.raw_json' , COUNT(*) FROM "Video" WHERE raw_json::text LIKE '%${FROM}%'
UNION ALL
SELECT 'ImagePost.raw_json' , COUNT(*) FROM "ImagePost" WHERE raw_json::text LIKE '%${FROM}%'
)
SELECT * FROM c ORDER BY col;
`);
console.table(rows);
return;
}
await prisma.$transaction(async (tx) => {
// CommentUser.avatar_url
await tx.$executeRawUnsafe(`
UPDATE "CommentUser"
SET avatar_url = regexp_replace(avatar_url, '${FROM_RE}', '${TO}')
WHERE avatar_url LIKE '${FROM}%'
`);
// CommentImage.url
await tx.$executeRawUnsafe(`
UPDATE "CommentImage"
SET url = regexp_replace(url, '${FROM_RE}', '${TO}')
WHERE url LIKE '${FROM}%'
`);
// Author.avatar_url
await tx.$executeRawUnsafe(`
UPDATE "Author"
SET avatar_url = regexp_replace(avatar_url, '${FROM_RE}', '${TO}')
WHERE avatar_url LIKE '${FROM}%'
`);
// Video.cover_url
await tx.$executeRawUnsafe(`
UPDATE "Video"
SET cover_url = regexp_replace(cover_url, '${FROM_RE}', '${TO}')
WHERE cover_url LIKE '${FROM}%'
`);
// Video.video_url
await tx.$executeRawUnsafe(`
UPDATE "Video"
SET video_url = regexp_replace(video_url, '${FROM_RE}', '${TO}')
WHERE video_url LIKE '${FROM}%'
`);
// ImageFile.url
await tx.$executeRawUnsafe(`
UPDATE "ImageFile"
SET url = regexp_replace(url, '${FROM_RE}', '${TO}')
WHERE url LIKE '${FROM}%'
`);
// ImageFile.animated
await tx.$executeRawUnsafe(`
UPDATE "ImageFile"
SET animated = regexp_replace(animated, '${FROM_RE}', '${TO}')
WHERE animated LIKE '${FROM}%'
`);
// ImagePost.music_url
await tx.$executeRawUnsafe(`
UPDATE "ImagePost"
SET music_url = regexp_replace(music_url, '${FROM_RE}', '${TO}')
WHERE music_url LIKE '${FROM}%'
`);
// (可选)raw_json:简单文本整体替换;若想更精细可用递归 JSONB 方法
await tx.$executeRawUnsafe(`
UPDATE "Video"
SET raw_json = to_jsonb(replace(raw_json::text, '${FROM}', '${TO}'))
WHERE raw_json::text LIKE '%${FROM}%'
`);
await tx.$executeRawUnsafe(`
UPDATE "ImagePost"
SET raw_json = to_jsonb(replace(raw_json::text, '${FROM}', '${TO}'))
WHERE raw_json::text LIKE '%${FROM}%'
`);
});
// 快速抽样
const sample = await prisma.$queryRawUnsafe<any[]>(`
(
SELECT 'CommentImage.url' AS col, url AS v FROM "CommentImage" WHERE url LIKE '${TO}%' LIMIT 2
) UNION ALL (
SELECT 'CommentUser.avatar_url', avatar_url FROM "CommentUser" WHERE avatar_url LIKE '${TO}%' LIMIT 2
) UNION ALL (
SELECT 'Video.video_url', video_url FROM "Video" WHERE video_url LIKE '${TO}%' LIMIT 2
)
`);
console.log("Sample after update:", sample);
}
main()
.catch((e) => {
console.error(e);
process.exit(1);
})
.finally(async () => {
await prisma.$disconnect();
});
+114
View File
@@ -0,0 +1,114 @@
import {
HASHTAG_EXTRACT_PG,
META_HASHTAG_PG,
META_HASHTAGS,
} from "@/lib/hashtag";
import { prisma } from "@/lib/prisma";
export type RelatedHashtag = {
name: string;
/** 和目标 tag 一起出现的作品数 */
together: number;
/** 这个 tag 自己的作品数 */
count: number;
};
export type RelatedHashtagsResult = {
name: string;
count: number;
related: RelatedHashtag[];
};
const CACHE_MS = 60_000;
const cache = new Map<string, { at: number; value: RelatedHashtagsResult }>();
/**
* 按「同一个作品描述里一起出现」计算相关 hashtag(共现图),每次实时从描述里解析,不需要额外的表。
*
* 打分取两个方向里较大的那个:
* - 子话题:候选 tag 大多和目标一起出现(together / 候选数),乘 ln(1+together) 防止只出现一次的巧合排到前面;
* 例:#明日方舟 → #明日方舟七周年、#特蕾西娅
* - 上位话题:目标大多和候选一起出现(together / 目标数),乘 ln(1+候选数),让热门的上位 tag 排前面;
* 例:#圣聆初雪(只有 2 个作品)→ #明日方舟
* cos、二次元这类「万金油」tag 与平台活动 tag 不参与推荐。
*/
export async function relatedHashtags(
rawName: string,
limit = 12,
): Promise<RelatedHashtagsResult> {
const name = rawName.toLowerCase();
const key = `${name}:${limit}`;
const hit = cache.get(key);
if (hit && Date.now() - hit.at < CACHE_MS) return hit.value;
const rows = await prisma.$queryRaw<
{ name: string; together: number; count: number; target: number }[]
>`
WITH ht AS (
SELECT DISTINCT w.aweme_id, lower(m[1]) AS tag
FROM (
SELECT aweme_id, "desc" FROM "Video"
UNION ALL
SELECT aweme_id, "desc" FROM "ImagePost"
) w,
regexp_matches(w."desc", ${HASHTAG_EXTRACT_PG}, 'g') m
),
target AS (SELECT aweme_id FROM ht WHERE tag = ${name}),
target_count AS (SELECT count(*)::int AS c FROM target),
co AS (
SELECT ht.tag, count(*)::int AS together
FROM ht JOIN target USING (aweme_id)
WHERE ht.tag <> ${name}
GROUP BY ht.tag
),
counts AS (
SELECT tag, count(*)::int AS c FROM ht
WHERE tag IN (SELECT tag FROM co)
GROUP BY tag
)
SELECT
co.tag AS name,
co.together,
counts.c AS count,
tc.c AS target
FROM co
JOIN counts USING (tag)
CROSS JOIN target_count tc
WHERE co.together >= CASE WHEN tc.c >= 4 THEN 2 ELSE 1 END
AND co.tag <> ALL (${META_HASHTAGS}::text[])
AND co.tag !~ ${META_HASHTAG_PG}
ORDER BY GREATEST(
co.together::float8 / counts.c * ln(1 + co.together),
co.together::float8 / tc.c * ln(1 + counts.c)
) DESC, co.together DESC, co.tag
LIMIT ${limit}
`;
let count = rows[0]?.target;
if (count === undefined) {
// 没有相关 tag 时单独数一下目标 tag 的作品数
const [{ c }] = await prisma.$queryRaw<{ c: number }[]>`
SELECT count(DISTINCT w.aweme_id)::int AS c
FROM (
SELECT aweme_id, "desc" FROM "Video"
UNION ALL
SELECT aweme_id, "desc" FROM "ImagePost"
) w,
regexp_matches(w."desc", ${HASHTAG_EXTRACT_PG}, 'g') m
WHERE lower(m[1]) = ${name}
`;
count = c;
}
const value: RelatedHashtagsResult = {
name,
count,
related: rows.map(({ name, together, count }) => ({
name,
together,
count,
})),
};
cache.set(key, { at: Date.now(), value });
return value;
}
+67
View File
@@ -0,0 +1,67 @@
// 从作品描述里解析抖音 hashtag(#xxx)。
// 前端渲染可点击的 hashtag 与后端按 hashtag 精确筛选作品共用这里的边界规则,
// 保证「点了 #丰川祥子 看到的列表」不会混进 #丰川祥子cos。
// hashtag 在遇到空白、下一个 #、或这些标点时结束
const STOP_CHARS =
"#,。!?、;:,.!?;:()()【】\\[\\]《》<>\"'“”‘’…~~|/\\\\@";
const MAX_HASHTAG_LENGTH = 50;
const HASHTAG_RE = new RegExp(`#([^\\s${STOP_CHARS}]+)`, "gu");
export type DescPart =
| { type: "text"; text: string }
| { type: "hashtag"; text: string; name: string };
/** 把描述切成普通文字与 hashtag 片段。 */
export function parseDescHashtags(desc: string): DescPart[] {
const parts: DescPart[] = [];
let cursor = 0;
for (const m of desc.matchAll(HASHTAG_RE)) {
const start = m.index ?? 0;
if (start > cursor) {
parts.push({ type: "text", text: desc.slice(cursor, start) });
}
parts.push({ type: "hashtag", text: m[0], name: m[1] });
cursor = start + m[0].length;
}
if (cursor < desc.length) {
parts.push({ type: "text", text: desc.slice(cursor) });
}
return parts;
}
/** 规范化 hashtag 名(去掉开头的 #、首尾空白,限制长度);不合法时返回 null。 */
export function normalizeHashtag(raw: string): string | null {
const name = raw.trim().replace(/^#+/, "").slice(0, MAX_HASHTAG_LENGTH);
if (!name) return null;
if (new RegExp(`[\\s${STOP_CHARS}]`, "u").test(name)) return null;
return name;
}
/**
* 匹配 lower() 后描述文本的 Postgres 正则(ARE):#名字 之后必须是结束符或文本末尾。
* 名字里的正则特殊字符会被转义。
*/
export function hashtagToPgPattern(name: string): string {
const escaped = name.toLowerCase().replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
return `#${escaped}([\\s${STOP_CHARS}]|$)`;
}
/** 从描述里提取全部 hashtag 名的 Postgres 正则(ARE),配合 regexp_matches(..., 'g') 使用 */
export const HASHTAG_EXTRACT_PG = `#([^\\s${STOP_CHARS}]+)`;
// 「万金油」tag:几乎和什么都一起出现,不提供话题信息,不作为相关 tag 推荐
export const META_HASHTAGS = [
"cos",
"cosplay",
"coser",
"二次元",
"ooc致歉",
"搞笑",
"抽象",
"vlog日常",
"内容过于真实",
];
/** 平台活动类 tag(xx计划、xx挑战赛…)和纯数字 tag 的 Postgres 正则 */
export const META_HASHTAG_PG = "(计划|挑战赛|大赛|扶持|热门|浪潮)$|^[0-9]+$";
+13
View File
@@ -0,0 +1,13 @@
// lib/json.ts
export function json(data: unknown, init?: ResponseInit) {
return new Response(
JSON.stringify(data, (_k, v) => (typeof v === "bigint" ? v.toString() : v)),
{
...init,
headers: {
"content-type": "application/json; charset=utf-8",
...(init?.headers || {}),
},
},
);
}
+32 -32
View File
@@ -14,7 +14,7 @@ import {
downloadFile, downloadFile,
fileExists, fileExists,
getFileInfo, getFileInfo,
} from './minio'; } from "./minio";
// ======================================== // ========================================
// 1. 上传文件示例 // 1. 上传文件示例
@@ -25,15 +25,15 @@ import {
*/ */
async function uploadAvatar(file: File, userId: string) { async function uploadAvatar(file: File, userId: string) {
// 验证文件类型 // 验证文件类型
const allowedTypes = ['jpg', 'jpeg', 'png', 'gif', 'webp']; const allowedTypes = ["jpg", "jpeg", "png", "gif", "webp"];
if (!validateFileType(file.name, allowedTypes)) { if (!validateFileType(file.name, allowedTypes)) {
throw new Error('不支持的图片格式'); throw new Error("不支持的图片格式");
} }
// 验证文件大小(5MB) // 验证文件大小(5MB)
const maxSize = 5 * 1024 * 1024; const maxSize = 5 * 1024 * 1024;
if (!validateFileSize(file.size, maxSize)) { if (!validateFileSize(file.size, maxSize)) {
throw new Error('文件大小超过限制(最大5MB)'); throw new Error("文件大小超过限制(最大5MB)");
} }
// 生成唯一文件名,存储在 avatars 目录下 // 生成唯一文件名,存储在 avatars 目录下
@@ -41,8 +41,8 @@ async function uploadAvatar(file: File, userId: string) {
// 上传文件 // 上传文件
const url = await uploadFile(file, path, { const url = await uploadFile(file, path, {
'Content-Type': file.type, "Content-Type": file.type,
'User-Id': userId, "User-Id": userId,
}); });
return { url, path }; return { url, path };
@@ -52,24 +52,24 @@ async function uploadAvatar(file: File, userId: string) {
* 上传文章封面图 * 上传文章封面图
*/ */
async function uploadPostCover(file: File, postId: string) { async function uploadPostCover(file: File, postId: string) {
const allowedTypes = ['jpg', 'jpeg', 'png', 'webp']; const allowedTypes = ["jpg", "jpeg", "png", "webp"];
if (!validateFileType(file.name, allowedTypes)) { if (!validateFileType(file.name, allowedTypes)) {
throw new Error('不支持的图片格式'); throw new Error("不支持的图片格式");
} }
const maxSize = 10 * 1024 * 1024; // 10MB const maxSize = 10 * 1024 * 1024; // 10MB
if (!validateFileSize(file.size, maxSize)) { if (!validateFileSize(file.size, maxSize)) {
throw new Error('文件大小超过限制(最大10MB)'); throw new Error("文件大小超过限制(最大10MB)");
} }
// 按日期组织文件 // 按日期组织文件
const date = new Date(); const date = new Date();
const year = date.getFullYear(); const year = date.getFullYear();
const month = String(date.getMonth() + 1).padStart(2, '0'); const month = String(date.getMonth() + 1).padStart(2, "0");
const path = generateUniqueFileName( const path = generateUniqueFileName(
file.name, file.name,
`posts/${year}/${month}/covers` `posts/${year}/${month}/covers`,
); );
const url = await uploadFile(file, path); const url = await uploadFile(file, path);
@@ -81,23 +81,23 @@ async function uploadPostCover(file: File, postId: string) {
* 上传文章内容中的图片 * 上传文章内容中的图片
*/ */
async function uploadPostImage(file: File, postId: string) { async function uploadPostImage(file: File, postId: string) {
const allowedTypes = ['jpg', 'jpeg', 'png', 'gif', 'webp']; const allowedTypes = ["jpg", "jpeg", "png", "gif", "webp"];
if (!validateFileType(file.name, allowedTypes)) { if (!validateFileType(file.name, allowedTypes)) {
throw new Error('不支持的图片格式'); throw new Error("不支持的图片格式");
} }
const maxSize = 5 * 1024 * 1024; // 5MB const maxSize = 5 * 1024 * 1024; // 5MB
if (!validateFileSize(file.size, maxSize)) { if (!validateFileSize(file.size, maxSize)) {
throw new Error('文件大小超过限制(最大5MB)'); throw new Error("文件大小超过限制(最大5MB)");
} }
const date = new Date(); const date = new Date();
const year = date.getFullYear(); const year = date.getFullYear();
const month = String(date.getMonth() + 1).padStart(2, '0'); const month = String(date.getMonth() + 1).padStart(2, "0");
const path = generateUniqueFileName( const path = generateUniqueFileName(
file.name, file.name,
`posts/${year}/${month}/images` `posts/${year}/${month}/images`,
); );
const url = await uploadFile(file, path); const url = await uploadFile(file, path);
@@ -126,9 +126,9 @@ function getPublicFileUrl(path: string) {
async function deleteAvatar(avatarPath: string) { async function deleteAvatar(avatarPath: string) {
try { try {
await deleteFile(avatarPath); await deleteFile(avatarPath);
console.log('头像删除成功'); console.log("头像删除成功");
} catch (error) { } catch (error) {
console.error('删除头像失败:', error); console.error("删除头像失败:", error);
throw error; throw error;
} }
} }
@@ -142,20 +142,20 @@ async function deletePostImages(postId: string) {
const files = await listFiles(`posts/`, true); const files = await listFiles(`posts/`, true);
// 过滤出该文章的图片(根据实际情况调整逻辑) // 过滤出该文章的图片(根据实际情况调整逻辑)
const postFiles = files.filter(file => const postFiles = files.filter((file) => file.name?.includes(postId));
file.name?.includes(postId)
);
// 批量删除 // 批量删除
const paths = postFiles.map(f => f.name).filter((name): name is string => !!name); const paths = postFiles
.map((f) => f.name)
.filter((name): name is string => !!name);
if (paths.length > 0) { if (paths.length > 0) {
const { deleteFiles } = await import('./minio'); const { deleteFiles } = await import("./minio");
await deleteFiles(paths); await deleteFiles(paths);
console.log(`删除了 ${paths.length} 个文件`); console.log(`删除了 ${paths.length} 个文件`);
} }
} catch (error) { } catch (error) {
console.error('删除文章图片失败:', error); console.error("删除文章图片失败:", error);
throw error; throw error;
} }
} }
@@ -171,14 +171,14 @@ async function getUserAvatars(userId: string) {
try { try {
const files = await listFiles(`avatars/${userId}/`, false); const files = await listFiles(`avatars/${userId}/`, false);
return files.map(file => ({ return files.map((file) => ({
name: file.name, name: file.name,
size: file.size, size: file.size,
lastModified: file.lastModified, lastModified: file.lastModified,
url: file.name ? getFileUrl(file.name) : null, url: file.name ? getFileUrl(file.name) : null,
})); }));
} catch (error) { } catch (error) {
console.error('获取用户头像列表失败:', error); console.error("获取用户头像列表失败:", error);
throw error; throw error;
} }
} }
@@ -188,16 +188,16 @@ async function getUserAvatars(userId: string) {
*/ */
async function getPostCoversByMonth(year: number, month: number) { async function getPostCoversByMonth(year: number, month: number) {
try { try {
const monthStr = String(month).padStart(2, '0'); const monthStr = String(month).padStart(2, "0");
const files = await listFiles(`posts/${year}/${monthStr}/covers/`, false); const files = await listFiles(`posts/${year}/${monthStr}/covers/`, false);
return files.map(file => ({ return files.map((file) => ({
name: file.name, name: file.name,
size: file.size, size: file.size,
url: file.name ? getFileUrl(file.name) : null, url: file.name ? getFileUrl(file.name) : null,
})); }));
} catch (error) { } catch (error) {
console.error('获取封面列表失败:', error); console.error("获取封面列表失败:", error);
throw error; throw error;
} }
} }
@@ -228,10 +228,10 @@ async function getFileDetails(path: string) {
size: info.size, size: info.size,
lastModified: info.lastModified, lastModified: info.lastModified,
etag: info.etag, etag: info.etag,
contentType: info.metaData?.['content-type'], contentType: info.metaData?.["content-type"],
}; };
} catch (error) { } catch (error) {
console.error('获取文件信息失败:', error); console.error("获取文件信息失败:", error);
throw error; throw error;
} }
} }
@@ -247,7 +247,7 @@ async function downloadFileToBuffer(path: string): Promise<Buffer> {
try { try {
return await downloadFile(path); return await downloadFile(path);
} catch (error) { } catch (error) {
console.error('下载文件失败:', error); console.error("下载文件失败:", error);
throw error; throw error;
} }
} }
+76 -71
View File
@@ -1,22 +1,22 @@
import * as Minio from 'minio'; import * as Minio from "minio";
// MinIO 客户端配置 // MinIO 客户端配置
const useSSL = process.env.MINIO_USE_SSL === 'true'; const useSSL = process.env.MINIO_USE_SSL === "true";
const port = Number(process.env.MINIO_PORT) || 9000; const port = Number(process.env.MINIO_PORT) || 9000;
// 当使用标准HTTPS端口(443)或HTTP端口(80)时,MinIO客户端不需要指定端口 // 当使用标准HTTPS端口(443)或HTTP端口(80)时,MinIO客户端不需要指定端口
const shouldOmitPort = (useSSL && port === 443) || (!useSSL && port === 80); const shouldOmitPort = (useSSL && port === 443) || (!useSSL && port === 80);
const minioClient = new Minio.Client({ const minioClient = new Minio.Client({
endPoint: process.env.MINIO_ENDPOINT || 'localhost', endPoint: process.env.MINIO_ENDPOINT || "localhost",
...(shouldOmitPort ? {} : { port }), ...(shouldOmitPort ? {} : { port }),
useSSL, useSSL,
accessKey: process.env.MINIO_ACCESS_KEY || '', accessKey: process.env.MINIO_ACCESS_KEY || "",
secretKey: process.env.MINIO_SECRET_KEY || '', secretKey: process.env.MINIO_SECRET_KEY || "",
pathStyle: true, // 使用路径风格,对反向代理更友好 pathStyle: true, // 使用路径风格,对反向代理更友好
}); });
const BUCKET_NAME = process.env.MINIO_BUCKET_NAME || 'home-page'; const BUCKET_NAME = process.env.MINIO_BUCKET_NAME || "home-page";
/** /**
* 初始化 MinIO Bucket(确保 bucket 存在) * 初始化 MinIO Bucket(确保 bucket 存在)
@@ -25,41 +25,39 @@ export async function initBucket(): Promise<void> {
try { try {
const exists = await minioClient.bucketExists(BUCKET_NAME); const exists = await minioClient.bucketExists(BUCKET_NAME);
if (!exists) { if (!exists) {
await minioClient.makeBucket(BUCKET_NAME, 'us-east-1'); await minioClient.makeBucket(BUCKET_NAME, "us-east-1");
console.log(`Bucket ${BUCKET_NAME} created successfully`); console.log(`Bucket ${BUCKET_NAME} created successfully`);
} }
// 设置公共读取策略(可选) // 设置公共读取策略(可选)
const policy = { const policy = {
Version: '2012-10-17', Version: "2012-10-17",
Statement: [ Statement: [
{ {
Effect: 'Allow', Effect: "Allow",
Principal: { AWS: ['*'] }, Principal: { AWS: ["*"] },
Action: ['s3:GetObject'], Action: ["s3:GetObject"],
Resource: [`arn:aws:s3:::${BUCKET_NAME}/*`], Resource: [`arn:aws:s3:::${BUCKET_NAME}/*`],
}, },
], ],
}; };
await minioClient.setBucketPolicy(BUCKET_NAME, JSON.stringify(policy)); await minioClient.setBucketPolicy(BUCKET_NAME, JSON.stringify(policy));
} catch (error) { } catch (error) {
console.error('Error initializing bucket:', error); console.error("Error initializing bucket:", error);
throw error; throw error;
} }
} }
/** /**
* 上传文件到 MinIO * 上传文件到 MinIO
* @param file - File 对象或 Buffer * @param file - File 对象或 Buffer
* @param path - 文件存储路径(如: 'avatars/user123.jpg' 或 'posts/2024/image.png') * @param path - 文件存储路径(如: 'avatars/user123.jpg' 或 'posts/2024/image.png')
* @param metadata - 可选的元数据 * @param metadata - 可选的元数据
* @returns 文件的访问URL * @returns 文件 path = args.path
*/ */
export async function uploadFile( export async function uploadFile(
file: File | Buffer, file: File | Buffer,
path: string, path: string,
metadata?: Record<string, string> metadata?: Record<string, string>,
): Promise<string> { ): Promise<string> {
try { try {
await initBucket(); await initBucket();
@@ -69,56 +67,43 @@ export async function uploadFile(
if (file instanceof File) { if (file instanceof File) {
buffer = Buffer.from(await file.arrayBuffer()); buffer = Buffer.from(await file.arrayBuffer());
contentType = file.type || 'application/octet-stream'; contentType = file.type || "application/octet-stream";
} else { } else {
buffer = file; buffer = file;
contentType = metadata?.['Content-Type'] || 'application/octet-stream'; contentType = metadata?.["Content-Type"] || "application/octet-stream";
} }
const metaData = { const metaData = {
'Content-Type': contentType, "Content-Type": contentType,
...metadata, ...metadata,
}; };
await minioClient.putObject(BUCKET_NAME, path, buffer, buffer.length, metaData); await minioClient.putObject(
BUCKET_NAME,
path,
buffer,
buffer.length,
metaData,
);
return getFileUrl(path); return path;
} catch (error) { } catch (error) {
console.error('Error uploading file:', error); console.error("Error uploading file:", error);
throw error; throw error;
} }
} }
/** /**
* 获取文件的公共访问URL * 获取文件的公共访问URL
* @param path - 文件路径 * @param path - 文件路径
* @returns 文件的访问URL * @returns 文件的访问URL
*/ */
export function getFileUrl(path: string): string { export function getFileUrl(path: string): string {
const endpoint = process.env.MINIO_REAL_DOMAIN; const endpoint = process.env.MINIO_PUBLIC_DOMAIN;
return `${endpoint}/${BUCKET_NAME}/${path}`; return `${endpoint}/${BUCKET_NAME}/${path}`;
} }
/**
* 获取文件的临时访问URL(带签名,用于私有文件)
* @param path - 文件路径
* @param expirySeconds - 过期时间(秒),默认7天
* @returns 临时访问URL
*/
export async function getPresignedUrl(
path: string,
expirySeconds: number = 7 * 24 * 60 * 60
): Promise<string> {
try {
return await minioClient.presignedGetObject(BUCKET_NAME, path, expirySeconds);
} catch (error) {
console.error('Error generating presigned URL:', error);
throw error;
}
}
/** /**
* 下载文件 * 下载文件
* @param path - 文件路径 * @param path - 文件路径
@@ -130,12 +115,12 @@ export async function downloadFile(path: string): Promise<Buffer> {
const stream = await minioClient.getObject(BUCKET_NAME, path); const stream = await minioClient.getObject(BUCKET_NAME, path);
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
stream.on('data', (chunk) => chunks.push(chunk)); stream.on("data", (chunk) => chunks.push(chunk));
stream.on('end', () => resolve(Buffer.concat(chunks))); stream.on("end", () => resolve(Buffer.concat(chunks)));
stream.on('error', reject); stream.on("error", reject);
}); });
} catch (error) { } catch (error) {
console.error('Error downloading file:', error); console.error("Error downloading file:", error);
throw error; throw error;
} }
} }
@@ -145,11 +130,13 @@ export async function downloadFile(path: string): Promise<Buffer> {
* @param path - 文件路径 * @param path - 文件路径
* @returns 文件流 * @returns 文件流
*/ */
export async function getFileStream(path: string): Promise<NodeJS.ReadableStream> { export async function getFileStream(
path: string,
): Promise<NodeJS.ReadableStream> {
try { try {
return await minioClient.getObject(BUCKET_NAME, path); return await minioClient.getObject(BUCKET_NAME, path);
} catch (error) { } catch (error) {
console.error('Error getting file stream:', error); console.error("Error getting file stream:", error);
throw error; throw error;
} }
} }
@@ -162,7 +149,7 @@ export async function deleteFile(path: string): Promise<void> {
try { try {
await minioClient.removeObject(BUCKET_NAME, path); await minioClient.removeObject(BUCKET_NAME, path);
} catch (error) { } catch (error) {
console.error('Error deleting file:', error); console.error("Error deleting file:", error);
throw error; throw error;
} }
} }
@@ -175,7 +162,7 @@ export async function deleteFiles(paths: string[]): Promise<void> {
try { try {
await minioClient.removeObjects(BUCKET_NAME, paths); await minioClient.removeObjects(BUCKET_NAME, paths);
} catch (error) { } catch (error) {
console.error('Error deleting files:', error); console.error("Error deleting files:", error);
throw error; throw error;
} }
} }
@@ -203,7 +190,7 @@ export async function getFileInfo(path: string): Promise<Minio.BucketItemStat> {
try { try {
return await minioClient.statObject(BUCKET_NAME, path); return await minioClient.statObject(BUCKET_NAME, path);
} catch (error) { } catch (error) {
console.error('Error getting file info:', error); console.error("Error getting file info:", error);
throw error; throw error;
} }
} }
@@ -215,24 +202,27 @@ export async function getFileInfo(path: string): Promise<Minio.BucketItemStat> {
* @returns 文件列表 * @returns 文件列表
*/ */
export async function listFiles( export async function listFiles(
prefix: string = '', prefix: string = "",
recursive: boolean = false recursive: boolean = false,
): Promise<(Minio.BucketItem & { endpoint: string })[]> { ): Promise<(Minio.BucketItem & { endpoint: string })[]> {
try { try {
const files: (Minio.BucketItem & { endpoint: string })[] = []; const files: (Minio.BucketItem & { endpoint: string })[] = [];
const stream = minioClient.listObjects(BUCKET_NAME, prefix, recursive); const stream = minioClient.listObjects(BUCKET_NAME, prefix, recursive);
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
stream.on('data', (obj) => { stream.on("data", (obj) => {
if (obj.name) { if (obj.name) {
files.push({ endpoint: `${process.env.MINIO_REAL_DOMAIN}/${BUCKET_NAME}`, ...obj, } as Minio.BucketItem & { endpoint: string }); files.push({
endpoint: `${process.env.MINIO_PUBLIC_DOMAIN}/${BUCKET_NAME}`,
...obj,
} as Minio.BucketItem & { endpoint: string });
} }
}); });
stream.on('end', () => resolve(files)); stream.on("end", () => resolve(files));
stream.on('error', reject); stream.on("error", reject);
}); });
} catch (error) { } catch (error) {
console.error('Error listing files:', error); console.error("Error listing files:", error);
throw error; throw error;
} }
} }
@@ -242,17 +232,20 @@ export async function listFiles(
* @param sourcePath - 源文件路径 * @param sourcePath - 源文件路径
* @param destPath - 目标文件路径 * @param destPath - 目标文件路径
*/ */
export async function copyFile(sourcePath: string, destPath: string): Promise<void> { export async function copyFile(
sourcePath: string,
destPath: string,
): Promise<void> {
try { try {
const conds = new Minio.CopyConditions(); const conds = new Minio.CopyConditions();
await minioClient.copyObject( await minioClient.copyObject(
BUCKET_NAME, BUCKET_NAME,
destPath, destPath,
`/${BUCKET_NAME}/${sourcePath}`, `/${BUCKET_NAME}/${sourcePath}`,
conds conds,
); );
} catch (error) { } catch (error) {
console.error('Error copying file:', error); console.error("Error copying file:", error);
throw error; throw error;
} }
} }
@@ -263,15 +256,20 @@ export async function copyFile(sourcePath: string, destPath: string): Promise<vo
* @param prefix - 路径前缀(如: 'avatars/' 或 'posts/2024/') * @param prefix - 路径前缀(如: 'avatars/' 或 'posts/2024/')
* @returns 带路径的唯一文件名 * @returns 带路径的唯一文件名
*/ */
export function generateUniqueFileName(originalName: string, prefix: string = ''): string { export function generateUniqueFileName(
originalName: string,
prefix: string = "",
): string {
const timestamp = Date.now(); const timestamp = Date.now();
const random = Math.random().toString(36).substring(2, 8); const random = Math.random().toString(36).substring(2, 8);
const ext = originalName.split('.').pop(); const ext = originalName.split(".").pop();
const nameWithoutExt = originalName.replace(`.${ext}`, '').replace(/[^a-zA-Z0-9]/g, '_'); const nameWithoutExt = originalName
.replace(`.${ext}`, "")
.replace(/[^a-zA-Z0-9]/g, "_");
const fileName = `${nameWithoutExt}_${timestamp}_${random}.${ext}`; const fileName = `${nameWithoutExt}_${timestamp}_${random}.${ext}`;
return prefix ? `${prefix.replace(/\/$/, '')}/${fileName}` : fileName; return prefix ? `${prefix.replace(/\/$/, "")}/${fileName}` : fileName;
} }
/** /**
@@ -280,7 +278,7 @@ export function generateUniqueFileName(originalName: string, prefix: string = ''
* @returns 扩展名(不含点) * @returns 扩展名(不含点)
*/ */
export function getFileExtension(filename: string): string { export function getFileExtension(filename: string): string {
return filename.split('.').pop() || ''; return filename.split(".").pop() || "";
} }
/** /**
@@ -289,9 +287,12 @@ export function getFileExtension(filename: string): string {
* @param allowedTypes - 允许的扩展名数组(如: ['jpg', 'png', 'gif']) * @param allowedTypes - 允许的扩展名数组(如: ['jpg', 'png', 'gif'])
* @returns 是否为允许的类型 * @returns 是否为允许的类型
*/ */
export function validateFileType(filename: string, allowedTypes: string[]): boolean { export function validateFileType(
filename: string,
allowedTypes: string[],
): boolean {
const ext = getFileExtension(filename).toLowerCase(); const ext = getFileExtension(filename).toLowerCase();
return allowedTypes.map(t => t.toLowerCase()).includes(ext); return allowedTypes.map((t) => t.toLowerCase()).includes(ext);
} }
/** /**
@@ -310,10 +311,10 @@ export function validateFileSize(size: number, maxSize: number): boolean {
* @returns 格式化后的文件大小 * @returns 格式化后的文件大小
*/ */
export function formatFileSize(bytes: number): string { export function formatFileSize(bytes: number): string {
if (bytes === 0) return '0 B'; if (bytes === 0) return "0 B";
const k = 1024; const k = 1024;
const sizes = ['B', 'KB', 'MB', 'GB', 'TB']; const sizes = ["B", "KB", "MB", "GB", "TB"];
const i = Math.floor(Math.log(bytes) / Math.log(k)); const i = Math.floor(Math.log(bytes) / Math.log(k));
return `${parseFloat((bytes / Math.pow(k, i)).toFixed(2))} ${sizes[i]}`; return `${parseFloat((bytes / Math.pow(k, i)).toFixed(2))} ${sizes[i]}`;
@@ -323,7 +324,6 @@ export default {
initBucket, initBucket,
uploadFile, uploadFile,
getFileUrl, getFileUrl,
getPresignedUrl,
downloadFile, downloadFile,
getFileStream, getFileStream,
deleteFile, deleteFile,
@@ -338,3 +338,8 @@ export default {
validateFileSize, validateFileSize,
formatFileSize, formatFileSize,
}; };
/** Upload derived ASR audio without changing bucket policy. */
export async function uploadTranscriptionAudio(path: string, buffer: Buffer) {
await minioClient.putObject(BUCKET_NAME, path, buffer, buffer.length, { "Content-Type": "audio/mpeg" });
}
+199
View File
@@ -0,0 +1,199 @@
import { Prisma } from "@prisma/client";
import { hashtagToPgPattern } from "@/lib/hashtag";
import { getFileUrl } from "@/lib/minio";
import {
awemeHref,
type PlaybackContext,
type PlaylistItem,
playbackContextQuery,
} from "@/lib/playback-context";
import { prisma } from "@/lib/prisma";
import { parseSearchQuery } from "@/lib/search";
import { searchIds } from "@/lib/search-query";
export type NeighborLink = { aweme_id: string; href: string };
export type PlaybackNeighbors = {
prev: NeighborLink | null;
next: NeighborLink | null;
};
export type PlaybackContextInfo = {
kind: PlaybackContext["kind"];
/** 顶部提示里的说明,如「搜索「百姓」」 */
label: string;
/** 侧栏列表标签页的标题,如「搜索结果」「#丰川祥子」 */
listTitle: string;
/** 从 1 开始 */
position: number;
total: number;
};
export type ResolvedPlayback = {
neighbors: PlaybackNeighbors;
/** 当前生效的上下文(URL 里的上下文不包含当前作品时为 null,退回全部作品) */
context: PlaybackContextInfo | null;
/** URL 里原本的上下文查询串,侧栏里跳转时原样带上 */
contextQuery: string;
};
/** 侧栏一次最多列出的作品数(搜索词很宽泛时避免一次塞几千张封面) */
export const PLAYLIST_LIMIT = 500;
// 全部作品 / 某作者的作品 / 带某个 hashtag 的作品:视频与图文合并,按发布时间倒序(与首页、作者页一致)
async function timelineIds(filter: Prisma.Sql = Prisma.empty) {
const rows = await prisma.$queryRaw<{ id: string }[]>`
SELECT aweme_id AS id FROM (
SELECT aweme_id, created_at, "authorId", "desc" FROM "Video"
UNION ALL
SELECT aweme_id, created_at, "authorId", "desc" FROM "ImagePost"
) works
${filter}
ORDER BY created_at DESC, aweme_id DESC
`;
return rows.map((r) => r.id);
}
// 数据量小(全库约两千个作品),直接取完整的有序 ID 列表再定位当前作品,
// 比为每种上下文分别写游标查询简单得多。数据量大了再换成游标查询,前端不用动。
export async function contextIds(
ctx: PlaybackContext | null,
): Promise<string[]> {
if (!ctx) return timelineIds();
switch (ctx.kind) {
case "author":
return timelineIds(Prisma.sql`WHERE "authorId" = ${ctx.secUid}`);
case "hashtag":
return timelineIds(
Prisma.sql`WHERE lower("desc") ~ ${hashtagToPgPattern(ctx.name)}`,
);
case "tag":
return searchIds({ terms: [], excludes: [], contentTag: ctx.tag });
case "search": {
const { terms, excludes } = parseSearchQuery(ctx.q);
if (terms.length === 0 && !ctx.tag) return [];
return searchIds({ terms, excludes, contentTag: ctx.tag });
}
}
}
async function contextLabels(
ctx: PlaybackContext,
): Promise<{ label: string; listTitle: string }> {
switch (ctx.kind) {
case "search":
if (!ctx.q) return { label: `标签「${ctx.tag}」`, listTitle: "搜索结果" };
return {
label: ctx.tag ? `搜索「${ctx.q}」· ${ctx.tag}` : `搜索「${ctx.q}」`,
listTitle: "搜索结果",
};
case "tag":
return { label: `标签「${ctx.tag}」`, listTitle: ctx.tag };
case "hashtag":
return { label: `#${ctx.name}`, listTitle: `#${ctx.name}` };
case "author": {
const author = await prisma.author.findUnique({
where: { sec_uid: ctx.secUid },
select: { nickname: true },
});
const label = `${author?.nickname ?? "作者"} 的作品`;
return { label, listTitle: label };
}
}
}
function neighborsAt(
ids: string[],
index: number,
query: string,
): PlaybackNeighbors {
const link = (id: string | undefined) =>
id ? { aweme_id: id, href: awemeHref(id, query) } : null;
return { prev: link(ids[index - 1]), next: link(ids[index + 1]) };
}
export async function resolvePlayback(
awemeId: string,
ctx: PlaybackContext | null,
): Promise<ResolvedPlayback> {
const contextQuery = playbackContextQuery(ctx);
if (ctx) {
const [ids, labels] = await Promise.all([
contextIds(ctx),
contextLabels(ctx),
]);
const index = ids.indexOf(awemeId);
if (index !== -1) {
return {
neighbors: neighborsAt(ids, index, contextQuery),
context: {
kind: ctx.kind,
...labels,
position: index + 1,
total: ids.length,
},
contextQuery,
};
}
// 当前作品不在这个列表里(例如在作者模式里切到了不在搜索结果中的作品),
// 退回全部作品的顺序;继续往下切时不再带上原来的上下文。
}
const ids = await timelineIds();
const index = ids.indexOf(awemeId);
return {
neighbors:
index === -1 ? { prev: null, next: null } : neighborsAt(ids, index, ""),
context: null,
contextQuery,
};
}
/** 按上下文顺序列出作品(供侧栏网格使用),最多 PLAYLIST_LIMIT 个。 */
export async function contextItems(
ctx: PlaybackContext,
): Promise<{ items: PlaylistItem[]; total: number }> {
const allIds = await contextIds(ctx);
const ids = allIds.slice(0, PLAYLIST_LIMIT);
const [videos, posts] = await Promise.all([
prisma.video.findMany({
where: { aweme_id: { in: ids } },
select: { aweme_id: true, desc: true, cover_url: true, digg_count: true },
}),
prisma.imagePost.findMany({
where: { aweme_id: { in: ids } },
select: {
aweme_id: true,
desc: true,
digg_count: true,
images: { orderBy: { order: "asc" }, take: 1, select: { url: true } },
},
}),
]);
const byId = new Map<string, PlaylistItem>();
for (const v of videos) {
byId.set(v.aweme_id, {
aweme_id: v.aweme_id,
type: "video",
desc: v.desc,
cover_url: v.cover_url ? getFileUrl(v.cover_url) : null,
likes: Number(v.digg_count),
});
}
for (const p of posts) {
byId.set(p.aweme_id, {
aweme_id: p.aweme_id,
type: "image",
desc: p.desc,
cover_url: p.images[0] ? getFileUrl(p.images[0].url) : null,
likes: Number(p.digg_count),
});
}
const items = ids
.map((id) => byId.get(id))
.filter((item): item is PlaylistItem => Boolean(item));
return { items, total: allIds.length };
}
+82
View File
@@ -0,0 +1,82 @@
// 播放上下文:播放页「上一条 / 下一条」在哪个列表里移动。
// 它放在 /aweme/<id> 的查询参数里,刷新、新标签页打开、后退都不会丢:
// (无) 全部作品,按发布时间倒序(和首页一致)
// ?ctx=tag&tag=美女 某个标签
// ?ctx=author&author=<secUid> 某个作者的作品
// ?ctx=search&q=百姓&tag=… 某次搜索的结果
// ?ctx=hashtag&hashtag=丰川祥子 描述里带某个 hashtag 的作品
// 这个文件只放纯函数,客户端组件也可以引用;查数据库的部分在 playback-context.server.ts。
import { normalizeHashtag } from "@/lib/hashtag";
import { isVideoContentTag } from "@/lib/video-tags";
export type PlaybackContext =
| { kind: "search"; q: string; tag: string | null }
| { kind: "tag"; tag: string }
| { kind: "hashtag"; name: string }
| { kind: "author"; secUid: string };
/** 播放页侧栏网格里的一项(/api/playlist 的返回) */
export type PlaylistItem = {
aweme_id: string;
type: "video" | "image";
desc: string;
cover_url: string | null;
likes: number;
};
export type PlaylistResponse = { items: PlaylistItem[]; total: number };
type ParamSource =
| URLSearchParams
| Record<string, string | string[] | undefined>;
function readParam(source: ParamSource, key: string): string {
const value =
source instanceof URLSearchParams ? source.get(key) : source[key];
return (Array.isArray(value) ? value[0] : value)?.trim() ?? "";
}
export function parsePlaybackContext(
source: ParamSource,
): PlaybackContext | null {
const kind = readParam(source, "ctx");
const tag = readParam(source, "tag");
const validTag = tag && isVideoContentTag(tag) ? tag : null;
if (kind === "search") {
const q = readParam(source, "q");
if (!q && !validTag) return null;
return { kind: "search", q, tag: validTag };
}
if (kind === "tag" && validTag) return { kind: "tag", tag: validTag };
if (kind === "hashtag") {
const name = normalizeHashtag(readParam(source, "hashtag"));
if (name) return { kind: "hashtag", name };
}
if (kind === "author") {
const secUid = readParam(source, "author");
if (secUid) return { kind: "author", secUid };
}
return null;
}
/** 生成带上下文的查询串(含开头的 ?);没有上下文时返回空串。 */
export function playbackContextQuery(ctx: PlaybackContext | null): string {
if (!ctx) return "";
const params = new URLSearchParams({ ctx: ctx.kind });
if (ctx.kind === "search") {
if (ctx.q) params.set("q", ctx.q);
if (ctx.tag) params.set("tag", ctx.tag);
} else if (ctx.kind === "tag") {
params.set("tag", ctx.tag);
} else if (ctx.kind === "hashtag") {
params.set("hashtag", ctx.name);
} else {
params.set("author", ctx.secUid);
}
return `?${params.toString()}`;
}
export function awemeHref(awemeId: string, query = ""): string {
return `/aweme/${awemeId}${query}`;
}
+5 -5
View File
@@ -1,9 +1,9 @@
import { PrismaClient } from '@prisma/client' import { PrismaClient } from "@prisma/client";
const globalForPrisma = globalThis as unknown as { const globalForPrisma = globalThis as unknown as {
prisma: PrismaClient | undefined prisma: PrismaClient | undefined;
} };
export const prisma = globalForPrisma.prisma ?? new PrismaClient() export const prisma = globalForPrisma.prisma ?? new PrismaClient();
if (process.env.NODE_ENV !== 'production') globalForPrisma.prisma = prisma if (process.env.NODE_ENV !== "production") globalForPrisma.prisma = prisma;
+115
View File
@@ -0,0 +1,115 @@
// 搜索的 SQL 部分:搜索接口(分页取结果)和播放页(取完整的有序 ID 列表算上一条/下一条)
// 共用同一段匹配与排序逻辑,保证两边顺序完全一致。
import { Prisma } from "@prisma/client";
import { prisma } from "@/lib/prisma";
import { termToPgPattern } from "@/lib/search";
export type SearchInput = {
/** 已转小写的关键词(parseSearchQuery 的结果) */
terms: string[];
excludes: string[];
contentTag: string | null;
};
export type SearchRow = {
awemeId: string;
type: "video" | "image";
score: number;
inDesc: boolean;
inTranscript: boolean;
rawDesc: string;
rawTranscript: string;
contentTags: string[];
total: number;
};
// 子串匹配:每个关键词都必须出现在「描述 + 语音转写」里(不区分大小写),
// 不依赖分词,所以输入任意片段都能搜到。数据量小,全表扫描足够快。
// 打分:描述命中权重更高,按出现次数累加(设上限防止长转写刷分);同分按发布时间倒序。
// 没有关键词、只按标签筛选时,score 都是 0,等价于按时间排序。
function matchedCte({ terms, excludes, contentTag }: SearchInput) {
const patterns = terms.map(termToPgPattern);
const excludePatterns = excludes.map(termToPgPattern);
return Prisma.sql`
WITH params AS (
SELECT ${patterns}::text[] AS terms, ${excludePatterns}::text[] AS excludes
),
docs AS (
SELECT
v.aweme_id AS "awemeId",
'video'::text AS type,
v."desc" AS "rawDesc",
COALESCE(array_to_string(vt.transcript, E'\n'), '') AS "rawTranscript",
v.created_at AS created,
v.manual_content_tags AS "contentTags"
FROM "Video" v
LEFT JOIN "VideoTranscript" vt ON vt."videoId" = v.aweme_id
WHERE ${contentTag}::text IS NULL
OR v.manual_content_tags @> ARRAY[${contentTag}]::text[]
UNION ALL
SELECT
ip.aweme_id,
'image'::text,
ip."desc",
'',
ip.created_at,
ARRAY[]::text[]
FROM "ImagePost" ip
WHERE ${contentTag}::text IS NULL
),
lowered AS (
SELECT docs.*, lower(docs."rawDesc") AS d, lower(docs."rawTranscript") AS t
FROM docs
),
matched AS (
SELECT
l.*,
COALESCE((
SELECT SUM(
4 * LEAST(regexp_count(l.d, term), 3)
+ LEAST(regexp_count(l.t, term), 10)
)
FROM unnest(p.terms) term
), 0)::float8 AS score,
EXISTS (SELECT 1 FROM unnest(p.terms) term WHERE l.d ~ term) AS "inDesc",
EXISTS (SELECT 1 FROM unnest(p.terms) term WHERE l.t ~ term) AS "inTranscript"
FROM lowered l, params p
WHERE NOT EXISTS (
SELECT 1 FROM unnest(p.terms) term
WHERE l.d !~ term AND l.t !~ term
)
AND NOT EXISTS (
SELECT 1 FROM unnest(p.excludes) x
WHERE l.d ~ x OR l.t ~ x
)
)
`;
}
const ORDER_BY = Prisma.sql`ORDER BY score DESC, created DESC, "awemeId"`;
export function searchRows(input: SearchInput, offset: number, limit: number) {
return prisma.$queryRaw<SearchRow[]>`
${matchedCte(input)}
SELECT
"awemeId", type, score, "inDesc", "inTranscript",
"rawDesc", "rawTranscript", "contentTags",
(COUNT(*) OVER ())::int AS total
FROM matched
${ORDER_BY}
OFFSET ${offset}
LIMIT ${limit};
`;
}
/** 按搜索结果顺序返回全部命中作品的 ID。 */
export async function searchIds(input: SearchInput): Promise<string[]> {
const rows = await prisma.$queryRaw<{ awemeId: string }[]>`
${matchedCte(input)}
SELECT "awemeId" FROM matched
${ORDER_BY};
`;
return rows.map((r) => r.awemeId);
}
+172
View File
@@ -0,0 +1,172 @@
// 子串搜索的查询解析与摘要高亮工具。
// 匹配本身在 Postgres 里用正则匹配 lower() 后的文本完成,这里负责:
// - 把用户输入拆成关键词(空格分隔,全部需要命中;以 - 开头的词表示排除)
// - 在原文里找命中位置,截取摘要片段并标出高亮
export type SnippetPart = { text: string; hit: boolean };
export type ParsedQuery = { terms: string[]; excludes: string[] };
const MAX_TERMS = 10;
const MAX_TERM_LENGTH = 50;
export function parseSearchQuery(raw: string): ParsedQuery {
const terms: string[] = [];
const excludes: string[] = [];
const seen = new Set<string>();
for (const token of raw.trim().split(/\s+/)) {
if (!token) continue;
const isExclude = token.length > 1 && token.startsWith("-");
const word = (isExclude ? token.slice(1) : token)
.slice(0, MAX_TERM_LENGTH)
.toLowerCase();
const key = `${isExclude ? "-" : "+"}${word}`;
if (!word || seen.has(key)) continue;
seen.add(key);
(isExclude ? excludes : terms).push(word);
if (terms.length + excludes.length >= MAX_TERMS) break;
}
return { terms, excludes };
}
type Range = { start: number; end: number; term: string };
function escapeRegExp(s: string) {
return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
}
// 纯英文/数字关键词要求「词首」对齐:前面不能紧挨字母数字。
// 这样 "ai" 不会命中 "said",但 "用AI做"、"AIGC" 仍能命中;中文关键词是纯子串匹配。
const ASCII_WORD = /^[a-z0-9]/;
/** 关键词对应的 Postgres 正则(ARE),用于匹配已 lower() 的文本。 */
export function termToPgPattern(term: string) {
const escaped = escapeRegExp(term);
return ASCII_WORD.test(term) ? `(^|[^a-z0-9])${escaped}` : escaped;
}
function termToJsPattern(term: string) {
const escaped = escapeRegExp(term);
return ASCII_WORD.test(term) ? `(?<![a-z0-9])${escaped}` : escaped;
}
function findMatches(text: string, terms: string[]): Range[] {
if (!text || terms.length === 0) return [];
// 长词优先,避免 "老百姓" 被 "百姓" 抢先匹配成更短的高亮
const sorted = [...terms].sort((a, b) => b.length - a.length);
const re = new RegExp(sorted.map(termToJsPattern).join("|"), "giu");
const ranges: Range[] = [];
for (const m of text.matchAll(re)) {
const start = m.index ?? 0;
ranges.push({
start,
end: start + m[0].length,
term: m[0].toLowerCase(),
});
}
return ranges;
}
// 不要把 UTF-16 代理对(emoji 等)切成两半
function safeStart(text: string, i: number) {
const c = text.charCodeAt(i);
return c >= 0xdc00 && c <= 0xdfff ? i - 1 : i;
}
function safeEnd(text: string, i: number) {
const c = text.charCodeAt(i);
return c >= 0xdc00 && c <= 0xdfff ? i + 1 : i;
}
function highlightRange(
text: string,
from: number,
to: number,
matches: Range[],
): SnippetPart[] {
const parts: SnippetPart[] = [];
let cursor = from;
for (const m of matches) {
if (m.end <= from || m.start >= to) continue;
const s = Math.max(m.start, from);
const e = Math.min(m.end, to);
if (s < cursor) continue;
if (s > cursor) parts.push({ text: text.slice(cursor, s), hit: false });
parts.push({ text: text.slice(s, e), hit: true });
cursor = e;
}
if (cursor < to) parts.push({ text: text.slice(cursor, to), hit: false });
return parts;
}
function normalizeWhitespace(parts: SnippetPart[]): SnippetPart[] {
return parts
.map((p) => ({ ...p, text: p.text.replace(/\s*\n\s*/g, " ") }))
.filter((p) => p.text.length > 0);
}
/** 整段文字全部返回,只标出命中位置(用于描述)。 */
export function highlightAll(text: string, terms: string[]): SnippetPart[] {
if (!text) return [];
return normalizeWhitespace(
highlightRange(text, 0, text.length, findMatches(text, terms)),
);
}
/**
* 截取命中位置附近的片段(用于语音转写这类长文本)。
* 尽量让不同片段覆盖不同的关键词;片段会对齐到换行(转写的句子边界)。
*/
export function buildSnippet(
text: string,
terms: string[],
{ before = 24, after = 60, maxFragments = 2 } = {},
): SnippetPart[] {
if (!text) return [];
const matches = findMatches(text, terms);
if (matches.length === 0) {
const end = safeEnd(text, Math.min(text.length, before + after));
const parts = normalizeWhitespace([
{ text: text.slice(0, end), hit: false },
]);
if (end < text.length) parts.push({ text: "…", hit: false });
return parts;
}
const windows: { start: number; end: number }[] = [];
const covered = new Set<string>();
const pickNext = () =>
matches.find(
(m) =>
!covered.has(m.term) &&
windows.every((w) => m.start >= w.end || m.end <= w.start),
) ??
matches.find((m) =>
windows.every((w) => m.start >= w.end || m.end <= w.start),
);
while (windows.length < maxFragments) {
const m = pickNext();
if (!m) break;
let start = Math.max(0, m.start - before);
let end = Math.min(text.length, m.end + after);
const nlBefore = text.lastIndexOf("\n", m.start - 1);
if (nlBefore >= start) start = nlBefore + 1;
const nlAfter = text.indexOf("\n", m.end);
if (nlAfter !== -1 && nlAfter < end) end = nlAfter;
windows.push({ start: safeStart(text, start), end: safeEnd(text, end) });
for (const other of matches) {
if (other.start >= start && other.end <= end) covered.add(other.term);
}
}
windows.sort((a, b) => a.start - b.start);
const parts: SnippetPart[] = [];
windows.forEach((w, i) => {
if (i === 0 ? w.start > 0 : true) parts.push({ text: "…", hit: false });
parts.push(...highlightRange(text, w.start, w.end, matches));
});
if (windows[windows.length - 1].end < text.length) {
parts.push({ text: "…", hit: false });
}
return normalizeWhitespace(parts);
}
+85
View File
@@ -0,0 +1,85 @@
import { execFile } from "node:child_process";
import { createWriteStream } from "node:fs";
import { mkdtemp, rm } from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { pipeline } from "node:stream/promises";
import { promisify } from "node:util";
import { extractAudio } from "../../app/api/media";
import { getFileStream, getFileUrl, uploadTranscriptionAudio } from "../minio";
import { prisma } from "../prisma";
import { AsrError, MAX_DURATION_MS, validateMedia } from "./dashscope";
const execFileAsync = promisify(execFile);
// Whole-file transcription: download to disk, extract once, upload only audio.
export async function prepareAudio(videoId: string): Promise<string | null> {
const video = await prisma.video.findUniqueOrThrow({
where: { aweme_id: videoId },
});
if (video.duration_ms > MAX_DURATION_MS)
throw new AsrError("视频超过 ASR 的 12 小时时长限制");
const dir = await mkdtemp(path.join(os.tmpdir(), "douyin-asr-"));
try {
const input = path.join(dir, "video.mp4");
await pipeline(
await getFileStream(video.video_url),
createWriteStream(input),
{ signal: AbortSignal.timeout(60 * 60 * 1000) },
);
const { stdout } = await execFileAsync(
process.env.FFPROBE_PATH || "ffprobe",
[
"-v",
"error",
"-select_streams",
"a:0",
"-show_entries",
"stream=index:format=duration",
"-of",
"json",
input,
],
{ timeout: 60_000 },
);
const probe = JSON.parse(stdout) as {
streams?: { index: number }[];
format?: { duration?: string };
};
if (!Array.isArray(probe.streams)) throw new AsrError("无法读取视频音轨");
if (!probe.streams.length) return null;
const duration = Number(probe.format?.duration) * 1000;
if (!Number.isFinite(duration) || duration <= 0)
throw new AsrError("无法读取音频时长");
if (duration > MAX_DURATION_MS)
throw new AsrError("音频超过 ASR 的 12 小时时长限制");
const audio = await extractAudio(input, { format: "mp3", bitrateKbps: 64 });
if (!audio) throw new AsrError("未找到 ffmpeg,请安装或配置 FFMPEG_PATH");
validateMedia(duration, audio.buffer.length);
const objectPath = `transcription-audio/${encodeURIComponent(videoId)}/${Date.now()}.mp3`;
await uploadTranscriptionAudio(objectPath, audio.buffer);
const url = getFileUrl(objectPath);
let parsed: URL;
try {
parsed = new URL(url);
} catch {
throw new AsrError("请配置公网可访问的 MINIO_PUBLIC_DOMAIN");
}
if (!["http:", "https:"].includes(parsed.protocol))
throw new AsrError("音频 URL 协议无效");
const response = await fetch(url, {
method: "HEAD",
signal: AbortSignal.timeout(30_000),
});
if (!response.ok)
throw new AsrError(`音频公共地址不可访问 (HTTP ${response.status})`);
return url;
} catch (error) {
if (error instanceof AsrError) throw error;
throw new AsrError(
"音频准备失败,请检查 MinIO、ffmpeg、ffprobe 和文件可访问性后重试",
);
} finally {
await rm(dir, { recursive: true, force: true });
}
}
+159
View File
@@ -0,0 +1,159 @@
export class AsrError extends Error {
constructor(
message: string,
public retryable = false,
public definitiveRejection = false,
) {
super(message);
}
}
export const MAX_DURATION_MS = 12 * 60 * 60 * 1000;
export const MAX_BYTES = 2 * 1024 * 1024 * 1024;
export function validateMedia(durationMs: number, size: number) {
if (durationMs > MAX_DURATION_MS)
throw new AsrError("音频超过 ASR 的 12 小时时长限制");
if (!Number.isFinite(size) || size <= 0)
throw new AsrError("音频文件为空或大小未知");
if (size > MAX_BYTES) throw new AsrError("音频超过 ASR 的 2 GB 大小限制");
}
export function getAsrConfig() {
const apiKey = process.env.DASHSCOPE_API_KEY?.trim();
if (!apiKey) throw new AsrError("未配置 DASHSCOPE_API_KEY,请配置后重试");
const baseURL = (
process.env.DASHSCOPE_BASE_URL || "https://dashscope.aliyuncs.com/api/v1"
).replace(/\/+$/, "");
let url: URL;
try {
url = new URL(baseURL);
} catch {
throw new AsrError("DASHSCOPE_BASE_URL 不是有效地址");
}
if (
!["https:", "http:"].includes(url.protocol) ||
!url.pathname.endsWith("/api/v1")
) {
throw new AsrError("DASHSCOPE_BASE_URL 应为原生 API 地址,以 /api/v1 结尾");
}
const model = process.env.DASHSCOPE_ASR_MODEL || "qwen3-asr-flash-filetrans";
if (!/^qwen3-asr-flash-filetrans(?:-\d{4}-\d{2}-\d{2})?$/.test(model)) {
throw new AsrError(
"DASHSCOPE_ASR_MODEL 必须是 qwen3-asr-flash-filetrans 或其日期版本",
);
}
return { apiKey, baseURL, model };
}
type ObjectValue = Record<string, unknown>;
function object(value: unknown): ObjectValue {
if (!value || typeof value !== "object" || Array.isArray(value))
throw new AsrError("ASR 返回格式异常");
return value as ObjectValue;
}
function safeCode(value: unknown) {
return typeof value === "string" && /^[A-Za-z0-9_.-]{1,100}$/.test(value)
? value
: "UNKNOWN";
}
async function request(url: string, init: RequestInit = {}): Promise<unknown> {
let response: Response;
try {
response = await fetch(url, {
...init,
signal: AbortSignal.timeout(30_000),
});
} catch {
throw new AsrError("ASR 网络请求失败或超时", true);
}
if (!response.ok) {
const raw = await response.json().catch(() => null);
const body = raw && typeof raw === "object" ? (raw as ObjectValue) : {};
throw new AsrError(
`ASR HTTP ${response.status} (${safeCode(body.code)})`,
response.status === 429 || response.status >= 500,
response.status >= 400 &&
response.status < 500 &&
response.status !== 429,
);
}
try {
return await response.json();
} catch {
throw new AsrError("ASR 返回非 JSON 数据", true);
}
}
export async function submitTranscription(fileURL: string): Promise<string> {
const { apiKey, baseURL, model } = getAsrConfig();
const data = object(
await request(`${baseURL}/services/audio/asr/transcription`, {
method: "POST",
headers: {
Authorization: `Bearer ${apiKey}`,
"Content-Type": "application/json",
"X-DashScope-Async": "enable",
},
body: JSON.stringify({
model,
input: { file_url: fileURL },
parameters: { channel_id: [0], enable_itn: false, enable_words: false },
}),
}),
);
const output = object(data.output);
if (typeof output.task_id !== "string" || !output.task_id)
throw new AsrError("ASR 未返回任务 ID,提交结果不确定");
return output.task_id;
}
export type AsrTask =
| { status: "PENDING" | "RUNNING" }
| { status: "SUCCEEDED"; resultURL: string };
export async function queryTranscription(taskId: string): Promise<AsrTask> {
const { apiKey, baseURL } = getAsrConfig();
const data = object(
await request(`${baseURL}/tasks/${encodeURIComponent(taskId)}`, {
headers: { Authorization: `Bearer ${apiKey}` },
}),
);
const output = object(data.output);
if (output.task_status === "PENDING" || output.task_status === "RUNNING")
return { status: output.task_status };
if (output.task_status === "SUCCEEDED") {
const result = object(output.result);
if (typeof result.transcription_url !== "string")
throw new AsrError("ASR 缺少结果下载地址");
return { status: "SUCCEEDED", resultURL: result.transcription_url };
}
throw new AsrError(
`ASR 任务失败 (${safeCode(output.code || output.task_status)})`,
);
}
export function extractTranscript(value: unknown): string[] {
const data = object(value);
if (!Array.isArray(data.transcripts))
throw new AsrError("ASR 缺少 transcripts,不能作为空文字保存");
return data.transcripts.flatMap((entry: unknown) => {
const track = object(entry);
if (track.sentences !== undefined && !Array.isArray(track.sentences))
throw new AsrError("ASR 句子格式异常");
const sentences = ((track.sentences ?? []) as unknown[])
.map((sentence) => {
const text = object(sentence).text;
if (typeof text !== "string") throw new AsrError("ASR 句子缺少文字");
return text.trim();
})
.filter(Boolean);
if (sentences.length) return sentences;
if (typeof track.text !== "string") throw new AsrError("ASR 音轨缺少文字");
return track.text.trim() ? [track.text.trim()] : [];
});
}
export async function downloadTranscript(url: string): Promise<string[]> {
let parsed: URL;
try {
parsed = new URL(url);
} catch {
throw new AsrError("ASR 结果地址无效");
}
if (!["http:", "https:"].includes(parsed.protocol))
throw new AsrError("ASR 结果地址协议无效");
// Result storage is not the API host: never forward the API key.
return extractTranscript(await request(url));
}
+141
View File
@@ -0,0 +1,141 @@
import { randomUUID } from "node:crypto";
import { Prisma, type TranscriptionTask } from "@prisma/client";
import { prisma } from "../prisma";
import { getAsrConfig } from "./dashscope";
import { isActive, transcriptionState } from "./types";
function configurationError(): string | null {
try {
getAsrConfig();
return null;
} catch (error) {
return (error as Error).message;
}
}
export async function enqueueTranscription(videoId: string, force = false) {
return prisma.$transaction(async (tx) => {
const videos = await tx.$queryRaw<
{ aweme_id: string }[]
>`SELECT aweme_id FROM "Video" WHERE aweme_id = ${videoId} FOR UPDATE`;
if (!videos.length) return null;
const [transcript, task] = await Promise.all([
tx.videoTranscript.findUnique({ where: { videoId } }),
tx.transcriptionTask.findUnique({ where: { videoId } }),
]);
if ((task && isActive(task.status)) || (!force && transcript))
return transcriptionState(transcript, task);
const lastError = configurationError();
const data = {
status: lastError ? "FAILED" : "QUEUED",
lastError,
providerTaskId: null,
submittingAt: null,
submittedAt: null,
leaseUntil: null,
leaseToken: null,
attempts: 0,
nextPollAt: new Date(),
};
const saved = await tx.transcriptionTask.upsert({
where: { videoId },
create: { videoId, ...data },
update: data,
});
return transcriptionState(transcript, saved);
});
}
export async function enqueueMissingTranscriptions() {
const error = configurationError();
if (error) throw new Error(error);
// One durable, atomic batch. Completed historical transcripts are never requeued.
return prisma.$executeRaw`
INSERT INTO "TranscriptionTask" ("videoId", status, "updatedAt")
SELECT v.aweme_id, 'QUEUED', (CURRENT_TIMESTAMP AT TIME ZONE 'UTC') FROM "Video" v
WHERE NOT EXISTS (SELECT 1 FROM "VideoTranscript" t WHERE t."videoId" = v.aweme_id)
ON CONFLICT ("videoId") DO UPDATE SET status = 'QUEUED', "providerTaskId" = NULL,
"submittingAt" = NULL, "submittedAt" = NULL, "leaseUntil" = NULL, "leaseToken" = NULL,
attempts = 0, "lastError" = NULL, "nextPollAt" = (CURRENT_TIMESTAMP AT TIME ZONE 'UTC'), "updatedAt" = (CURRENT_TIMESTAMP AT TIME ZONE 'UTC')
WHERE "TranscriptionTask".status = 'FAILED'
`;
}
export async function readTranscription(videoId: string) {
const video = await prisma.video.findUnique({
where: { aweme_id: videoId },
include: { transcript: true, transcriptionTask: true },
});
return video
? transcriptionState(video.transcript, video.transcriptionTask)
: null;
}
export async function claimTask(): Promise<TranscriptionTask | null> {
return prisma.$transaction(async (tx) => {
// Serialize admission across all worker processes, including PM2 restarts.
await tx.$queryRaw`SELECT pg_advisory_xact_lock(74196321)::text`;
const running = await tx.transcriptionTask.count({
where: { status: "PROCESSING" },
});
const eligible =
running < 2
? Prisma.sql`status IN ('QUEUED', 'PROCESSING')`
: Prisma.sql`status = 'PROCESSING'`;
const rows = await tx.$queryRaw<TranscriptionTask[]>`
SELECT * FROM "TranscriptionTask" WHERE ${eligible}
AND "nextPollAt" <= (CURRENT_TIMESTAMP AT TIME ZONE 'UTC') AND ("leaseUntil" IS NULL OR "leaseUntil" < (CURRENT_TIMESTAMP AT TIME ZONE 'UTC'))
ORDER BY CASE WHEN status = 'PROCESSING' THEN 0 ELSE 1 END, "nextPollAt", "createdAt"
LIMIT 1 FOR UPDATE SKIP LOCKED
`;
if (!rows.length) return null;
return tx.transcriptionTask.update({
where: { videoId: rows[0].videoId },
data: {
status: "PROCESSING",
leaseToken: randomUUID(),
leaseUntil: new Date(Date.now() + 180_000),
},
});
});
}
export async function updateOwnedTask(
task: TranscriptionTask,
data: Prisma.TranscriptionTaskUpdateManyMutationInput,
) {
const result = await prisma.transcriptionTask.updateMany({
where: {
videoId: task.videoId,
leaseToken: task.leaseToken,
status: "PROCESSING",
leaseUntil: { gt: new Date() },
},
data,
});
if (!result.count) throw new Error("转写任务租约已失效");
}
export async function completeTask(
task: TranscriptionTask,
transcript: string[],
) {
await prisma.$transaction(async (tx) => {
const result = await tx.transcriptionTask.updateMany({
where: {
videoId: task.videoId,
leaseToken: task.leaseToken,
status: "PROCESSING",
leaseUntil: { gt: new Date() },
},
data: {
status: "SUCCEEDED",
lastError: null,
leaseToken: null,
leaseUntil: null,
attempts: 0,
},
});
if (!result.count) throw new Error("转写任务租约已失效");
await tx.videoTranscript.upsert({
where: { videoId: task.videoId },
create: { videoId: task.videoId, transcript },
update: { transcript },
});
});
}
+33
View File
@@ -0,0 +1,33 @@
export type TranscriptionStatus =
| "PENDING"
| "QUEUED"
| "PROCESSING"
| "SUCCEEDED"
| "FAILED";
export type Transcript = { transcript: string[] };
export type TranscriptionState = {
status: TranscriptionStatus;
error: string | null;
transcript: string[] | null;
};
export const statusLabels: Record<TranscriptionStatus, string> = {
PENDING: "待转写",
QUEUED: "排队中",
PROCESSING: "处理中",
SUCCEEDED: "已完成",
FAILED: "失败",
};
export function isActive(status: string) {
return status === "QUEUED" || status === "PROCESSING";
}
export function transcriptionState(
transcript: Transcript | null,
task: { status: string; lastError: string | null } | null,
): TranscriptionState {
return {
status: (task?.status ??
(transcript ? "SUCCEEDED" : "PENDING")) as TranscriptionStatus,
error: task?.lastError ?? null,
transcript: transcript?.transcript ?? null,
};
}
+112
View File
@@ -0,0 +1,112 @@
import type { TranscriptionTask } from "@prisma/client";
import { prepareAudio } from "./audio";
import {
AsrError,
downloadTranscript,
getAsrConfig,
queryTranscription,
submitTranscription,
} from "./dashscope";
import { claimTask, completeTask, updateOwnedTask } from "./tasks";
export const defaultServices = {
prepareAudio,
submitTranscription,
queryTranscription,
downloadTranscript,
};
export async function processTask(
task: TranscriptionTask,
services = defaultServices,
) {
let submitting = false;
const heartbeat = setInterval(() => {
void updateOwnedTask(task, {
leaseUntil: new Date(Date.now() + 180_000),
}).catch(() => {});
}, 30_000);
try {
getAsrConfig();
if (!task.providerTaskId) {
if (task.submittingAt)
throw new AsrError(
"上次提交结果不确定,请核对百炼任务后手动重试,避免重复计费",
);
const url = await services.prepareAudio(task.videoId);
if (url === null) {
await completeTask(task, []);
return;
}
await updateOwnedTask(task, { submittingAt: new Date() });
submitting = true;
const providerTaskId = await services.submitTranscription(url);
await updateOwnedTask(task, {
providerTaskId,
submittedAt: new Date(),
nextPollAt: new Date(Date.now() + 10_000),
leaseToken: null,
leaseUntil: null,
lastError: null,
});
return;
}
// Provider result URLs expire after 24 hours; do not poll forever.
if (
task.submittedAt &&
Date.now() - task.submittedAt.getTime() > 24 * 60 * 60 * 1000
) {
throw new AsrError("ASR 任务超过 24 小时,请手动重试");
}
const result = await services.queryTranscription(task.providerTaskId);
if (result.status === "SUCCEEDED") {
const transcript = await services.downloadTranscript(result.resultURL);
await completeTask(task, transcript);
} else {
await updateOwnedTask(task, {
nextPollAt: new Date(Date.now() + 10_000),
leaseToken: null,
leaseUntil: null,
lastError: null,
attempts: 0,
});
}
} catch (error) {
// Unknown exceptions (e.g. database outage) leave the lease and submitting marker
// intact. Recovery can query a saved ID, but must never blindly submit again.
if (!(error instanceof AsrError)) throw error;
const retry = !submitting && !!task.providerTaskId && error.retryable;
await updateOwnedTask(task, {
status: retry ? "PROCESSING" : "FAILED",
lastError:
submitting && !error.definitiveRejection
? `${error.message};提交结果不确定,请核对百炼任务后手动重试`
: error.message,
attempts: { increment: 1 },
leaseToken: null,
leaseUntil: null,
nextPollAt: new Date(
Date.now() +
Math.min(300_000, 10_000 * 2 ** Math.min(task.attempts, 5)),
),
});
console.warn(`[ASR] ${task.videoId}: ${retry ? "等待重试" : "失败"}`);
} finally {
clearInterval(heartbeat);
}
}
export async function workerTick(services = defaultServices) {
const tasks: TranscriptionTask[] = [];
for (let i = 0; i < 2; i++) {
const task = await claimTask();
if (task) tasks.push(task);
}
await Promise.all(
tasks.map(async (task) => {
try {
await processTask(task, services);
} catch {
console.error(`[ASR] ${task.videoId}: 执行中断,租约到期后恢复`);
}
}),
);
}
+24
View File
@@ -0,0 +1,24 @@
export const VIDEO_CONTENT_TAGS = [
"美女",
"游戏",
"时政",
"美食",
"旅行",
"体育",
"科技",
"教育",
"宠物",
"音乐",
"影视",
"财经",
"生活",
"其他",
] as const;
export type VideoContentTag = (typeof VIDEO_CONTENT_TAGS)[number];
const videoContentTagSet = new Set<string>(VIDEO_CONTENT_TAGS);
export function isVideoContentTag(value: string): value is VideoContentTag {
return videoContentTagSet.has(value);
}
+13
View File
@@ -1,7 +1,20 @@
import type { NextConfig } from "next"; import type { NextConfig } from "next";
const nextConfig: NextConfig = { const nextConfig: NextConfig = {
serverExternalPackages: [
"playwright-extra",
"puppeteer-extra-plugin-stealth",
"puppeteer-extra-plugin",
],
/* config options here */ /* config options here */
images: {
remotePatterns: [
{
protocol: "https",
hostname: "s3l.xn--876a.net",
},
],
},
}; };
export default nextConfig; export default nextConfig;
+2065 -104
View File
File diff suppressed because it is too large Load Diff
+11 -4
View File
@@ -7,17 +7,24 @@
"build": "next build --turbopack", "build": "next build --turbopack",
"start": "next start", "start": "next start",
"lint": "biome check", "lint": "biome check",
"format": "biome format --write" "format": "biome format --write",
"stt:worker": "tsx scripts/stt-worker.ts",
"test:stt": "tsx --test tests/stt.test.ts tests/stt-media.test.ts",
"test:stt:integration": "tsx --test tests/stt.integration.test.ts"
}, },
"dependencies": { "dependencies": {
"@prisma/client": "^6.16.3", "@prisma/client": "^6.16.3",
"chalk": "^5.6.2", "chalk": "^5.6.2",
"lucide-react": "^0.546.0", "lucide-react": "^0.546.0",
"minio": "^8.0.6", "minio": "^8.0.6",
"next": "15.5.6", "next": "15.5.7",
"playwright": "^1.56.1", "playwright": "1.56.1",
"playwright-extra": "^4.3.6",
"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",
"dotenv": "^16.6.1",
"tsx": "^4.20.6"
}, },
"devDependencies": { "devDependencies": {
"@biomejs/biome": "2.2.0", "@biomejs/biome": "2.2.0",

Some files were not shown because too many files have changed in this diff Show More