修改资源URL模式,不再存储https://domain 前缀

This commit is contained in:
2025-10-25 12:19:53 +08:00
parent f94ef73518
commit e4339a5b91
27 changed files with 347 additions and 137 deletions
+49
View File
@@ -0,0 +1,49 @@
import fs from "fs";
import OpenAI from "openai";
import prompt from "./prompt.md";
import { prisma } from "@/lib/prisma";
const client = new OpenAI({
apiKey: process.env.OPENAI_API_KEY,
baseURL: process.env.OPENAI_API_BASE_URL,
});
async function transcriptAudio(audio: Buffer | string) {
if (typeof audio === "string") {
audio = fs.readFileSync(audio);
}
const base64Audio = Buffer.from(audio).toString("base64");
const response = await client.chat.completions.create({
model: "gemini-2.5-flash-lite",
messages: [
{
role: "user",
content: [
{
type: "text",
text: prompt,
},
{
type: "input_audio",
input_audio: {
data: base64Audio,
format: "wav",
},
},
],
},
],
});
console.log(response.choices[0].message.content);
}
async function transcriptAweme(awemeId: string) {
const aweme = await prisma.video.findUnique({
where: { aweme_id: awemeId },
});
if (!aweme) {
throw new Error("Aweme not found or aweme is not a video post");
}
}
+51
View File
@@ -0,0 +1,51 @@
你将接收一段音频。请完成:
A.语音活动检测(VAD)与声源分类;
B.条件式处理:
- 若包含可辨识的人类发言:** 进行转录 **(保留原语言,不翻译),并尽可能给出说话人分离与时间戳;
- 若不包含人类发言:** 不转录 **,仅返回音频类型与简要描述。
C.严格输出为下方 JSON,字段不得缺失或额外编造。听不清处用“[听不清]”。
** 输出 JSON Schema(示例)**
```json
{
"speech_detected": true,
"language": "zh-CN",
"audio_type": null,
"background": "music | ambience | none | unknown",
"transcript": [
{
"start": 0.00,
"end": 3.42,
"text": "大家好,我是……"
},
{
"start": 3.50,
"end": 6.10,
"text": "欢迎来到今天的节目。"
}
],
"non_speech_summary": null,
}
```
>
** 当无发言时返回:**
```json
{
"speech_detected": false,
"language": null,
"audio_type": "music | ambience | animal | mechanical | other",
"background": "none",
"transcript": [],
"non_speech_summary": "示例:纯音乐-钢琴独奏,节奏舒缓;或 环境声-雨声伴随雷鸣。",
}
```
** 规则补充 **
* 只要存在可理解的人类发言(即便有音乐 / 噪声),就执行转录,并在 `background` 标注“music / ambience”。
* 不要将唱词 / 哼唱视为“发言”;若仅有人声演唱且无口语发言,视为 ** 音乐 **。
* 不要臆测未听清内容;不要添加与音频无关的信息。
* 时间单位统一为秒,保留两位小数。
* 允许`language` 为多标签(如 "zh-CN,en")或为 `null`(无发言时)。
+13 -13
View File
@@ -3,7 +3,7 @@
import { useRouter } from "next/navigation";
import { useMemo, useRef } from "react";
import { Pause, Play } from "lucide-react";
import type { AwemeData, ImageData, Neighbors, VideoData } from "./types";
import type { AwemeData, ImageData, Neighbors, VideoData } from "./types.ts";
import { BackgroundCanvas } from "./components/BackgroundCanvas";
import { CommentPanel } from "./components/CommentPanel";
import { ImageCarousel } from "./components/ImageCarousel";
@@ -41,7 +41,7 @@ export default function AwemeDetailClient({ data, neighbors }: AwemeDetailClient
const backgroundCanvasRef = useRef<HTMLCanvasElement | null>(null);
// 图文轮播状态
const images = isVideo ? [] : (data as ImageData).images;
const images = isVideo ? [] : (data as ImageData).images;
const imageCarouselState = useImageCarousel({
images,
isPlaying: playerState.isPlaying,
@@ -80,17 +80,17 @@ export default function AwemeDetailClient({ data, neighbors }: AwemeDetailClient
return;
}
if (!images?.length) return;
// 计算每张图片的时长
const durations = images.map(img => img.duration ?? SEGMENT_MS);
const totalDuration = durations.reduce((sum, d) => sum + d, 0);
const targetTime = ratio * totalDuration;
// 找到目标时间对应的图片索引和进度
let accumulatedTime = 0;
let targetIdx = 0;
let remainder = 0;
for (let i = 0; i < images.length; i++) {
if (accumulatedTime + durations[i] > targetTime) {
targetIdx = i;
@@ -107,7 +107,7 @@ export default function AwemeDetailClient({ data, neighbors }: AwemeDetailClient
imageCarouselState.idxRef.current = targetIdx;
imageCarouselState.setIdx(targetIdx);
imageCarouselState.segStartRef.current = performance.now() - remainder * durations[targetIdx];
// 重新计算总进度
let totalProgress = 0;
for (let i = 0; i < targetIdx; i++) {
@@ -115,7 +115,7 @@ export default function AwemeDetailClient({ data, neighbors }: AwemeDetailClient
}
totalProgress += remainder;
playerState.setProgress(totalProgress / images.length);
// 虚拟滚动不需要实际滚动 DOM
};
@@ -123,7 +123,7 @@ export default function AwemeDetailClient({ data, neighbors }: AwemeDetailClient
if (isVideo) {
const v = videoRef.current;
if (!v) return;
if (v.paused) await v.play().catch(() => {});
if (v.paused) await v.play().catch(() => { });
else v.pause();
return;
}
@@ -131,8 +131,8 @@ export default function AwemeDetailClient({ data, neighbors }: AwemeDetailClient
if (!playerState.isPlaying) {
playerState.setIsPlaying(true);
try {
await el?.play().catch(() => {});
} catch {}
await el?.play().catch(() => { });
} catch { }
} else {
playerState.setIsPlaying(false);
el?.pause();
@@ -142,12 +142,12 @@ export default function AwemeDetailClient({ data, neighbors }: AwemeDetailClient
const toggleFullscreen = () => {
if (!document.fullscreenElement) {
if (document.body.requestFullscreen) {
document.body.requestFullscreen().catch(() => {});
document.body.requestFullscreen().catch(() => { });
return;
}
const vRef = videoRef.current;
if (vRef && vRef.requestFullscreen) {
vRef.requestFullscreen().catch(() => {});
vRef.requestFullscreen().catch(() => { });
return;
}
// @ts-ignore
@@ -156,7 +156,7 @@ export default function AwemeDetailClient({ data, neighbors }: AwemeDetailClient
vRef.webkitEnterFullscreen();
}
} else {
document.exitFullscreen().catch(() => {});
document.exitFullscreen().catch(() => { });
}
};
@@ -1,6 +1,6 @@
import { ThumbsUp, X } from "lucide-react";
import { useState } from "react";
import type { Comment, User } from "../types";
import type { Comment, User } from "../types.ts";
import { formatRelativeTime, formatAbsoluteUTC } from "../utils";
import { CommentText } from "./CommentText";
@@ -1,6 +1,6 @@
import { X } from "lucide-react";
import { useState, useEffect, useRef, useCallback } from "react";
import type { Comment, User } from "../types";
import type { Comment, User } from "../types.ts";
import { CommentList } from "./CommentList";
interface CommentPanelProps {
@@ -1,5 +1,5 @@
import { forwardRef, useEffect, useRef, useState } from "react";
import type { ImageData } from "../types";
import type { ImageData } from "../types.ts";
interface ImageCarouselProps {
images: ImageData["images"];
@@ -14,7 +14,7 @@ import {
VolumeX,
} from "lucide-react";
import type { RefObject } from "react";
import type { LoopMode, ObjectFit, User } from "../types";
import type { LoopMode, ObjectFit, User } from "../types.ts";
import { formatRelativeTime, formatTime, formatAbsoluteUTC } from "../utils";
import { ProgressBar } from "./ProgressBar";
import { SegmentedProgressBar } from "./SegmentedProgressBar";
@@ -1,5 +1,5 @@
import { ChevronDown, ChevronUp, MessageSquareText, ThumbsUp } from "lucide-react";
import type { Neighbors } from "../types";
import type { Neighbors } from "../types.ts";
interface NavigationButtonsProps {
neighbors: Neighbors;
@@ -1,5 +1,5 @@
import { forwardRef } from "react";
import type { ObjectFit } from "../types";
import type { ObjectFit } from "../types.ts";
interface VideoPlayerProps {
videoUrl: string;
@@ -89,7 +89,7 @@ export function useBackgroundCanvas({
ctx.drawImage(sourceElement, offsetX, offsetY, drawWidth, drawHeight);
};
const intervalId = setInterval(drawMediaToCanvas, 20);
const intervalId = setInterval(drawMediaToCanvas, 34);
return () => {
clearInterval(intervalId);
@@ -1,7 +1,7 @@
import { useEffect, useRef, useState } from "react";
import type { RefObject } from "react";
import { useRouter } from "next/navigation";
import type { ImageData, LoopMode, Neighbors } from "../types";
import type { ImageData, LoopMode, Neighbors } from "../types.ts";
interface UseImageCarouselProps {
images: ImageData["images"];
+1 -1
View File
@@ -1,7 +1,7 @@
import { useEffect, useRef } from "react";
import type { RefObject } from "react";
import { useRouter } from "next/navigation";
import type { Neighbors } from "../types";
import type { Neighbors } from "../types.ts";
interface UseNavigationProps {
neighbors: Neighbors;
+1 -1
View File
@@ -1,5 +1,5 @@
import { useEffect, useState } from "react";
import type { LoopMode, ObjectFit } from "../types";
import type { LoopMode, ObjectFit } from "../types.ts";
import { getNumberFromStorage, getStringFromStorage, saveToStorage } from "../utils";
export function usePlayerState() {
+1 -1
View File
@@ -1,7 +1,7 @@
import { useEffect, useRef } from "react";
import type { RefObject } from "react";
import { useRouter } from "next/navigation";
import type { LoopMode, Neighbors } from "../types";
import type { LoopMode, Neighbors } from "../types.ts";
interface UseVideoPlayerProps {
awemeId: string;
+38 -39
View File
@@ -2,14 +2,8 @@ import { prisma } from "@/lib/prisma";
import BackButton from "@/app/components/BackButton";
import AwemeDetailClient from "./Client";
import type { Metadata } from "next";
function ms(v?: number | null) {
if (!v) return "";
const s = Math.round(v / 1000);
const m = Math.floor(s / 60);
const r = s % 60;
return `${m}:${r.toString().padStart(2, "0")}`;
}
import { getFileUrl } from "@/lib/minio";
import { AwemeData } from "./types";
export async function generateMetadata({ params }: { params: Promise<{ awemeId: string }> }): Promise<Metadata> {
const id = (await params).awemeId;
@@ -59,40 +53,45 @@ export default async function AwemeDetail({ params }: { params: Promise<{ awemeI
if (!video && !post) return <main className="p-8">找不到该作品</main>;
const isVideo = !!video;
// 获取评论总数
const commentsCount = await prisma.comment.count({
where: isVideo ? { videoId: id } : { imagePostId: id },
});
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 },
commentsCount,
likesCount: Number(video!.digg_count),
const aweme = isVideo ? video : post;
const data: AwemeData = {
aweme_id: aweme!.aweme_id,
desc: aweme!.desc,
created_at: aweme!.created_at,
likesCount: Number(aweme!.digg_count),
commentsCount,
author: { nickname: aweme!.author.nickname, avatar_url: getFileUrl(aweme!.author.avatar_url || 'default-avatar.png') },
...(() => {
if (isVideo) {
const aweme = video!
return {
type: "video" as const,
duration_ms: aweme!.duration_ms,
video_url: aweme!.video_url,
width: aweme!.width ?? null,
height: aweme!.height ?? null,
}
} else {
const aweme = post!
return {
type: "image" as const,
images: aweme!.images.map(img => ({ ...img, url: getFileUrl(img.url) })),
music_url: aweme!.music_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 },
commentsCount,
likesCount: Number(post!.digg_count),
};
})()
}
// 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 currentCreatedAt = (isVideo ? video!.created_at : post!.created_at);
const [newerVideo, newerPost, olderVideo, olderPost] = await Promise.all([
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 } }),
@@ -101,16 +100,16 @@ export default async function AwemeDetail({ params }: { params: Promise<{ awemeI
]);
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 (newerVideo) cands.push({ aweme_id: newerVideo.aweme_id, created_at: newerVideo.created_at });
if (newerPost) cands.push({ aweme_id: newerPost.aweme_id, created_at: newerPost.created_at });
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 (olderVideo) cands.push({ aweme_id: olderVideo.aweme_id, created_at: olderVideo.created_at });
if (olderPost) cands.push({ aweme_id: olderPost.aweme_id, created_at: olderPost.created_at });
if (cands.length === 0) return null;
cands.sort((a, b) => +b.created_at - +a.created_at);
return { aweme_id: cands[0].aweme_id };
@@ -126,7 +125,7 @@ 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"
/>
</div>
<AwemeDetailClient data={data as any} neighbors={neighbors as any} />
<AwemeDetailClient data={data} neighbors={neighbors} />
</main>
);
}
+5 -5
View File
@@ -14,10 +14,10 @@ export type VideoData = {
aweme_id: string;
desc: string;
created_at: string | Date;
duration_ms?: number | null;
duration_ms: number | null;
video_url: string;
width?: number | null;
height?: number | null;
width: number | null;
height: number | null;
author: User;
commentsCount: number;
likesCount: number;
@@ -28,8 +28,8 @@ export type ImageData = {
aweme_id: string;
desc: string;
created_at: string | Date;
images: { id: string; url: string; width?: number; height?: number; animated?: string; duration?: number }[];
music_url?: string | null;
images: { id: string; url: string; width: number | null; height: number | null; animated: string | null; duration: number | null }[];
music_url: string | null;
author: User;
commentsCount: number;
likesCount: number;
+5 -1
View File
@@ -28,9 +28,13 @@ export default function FeedMasonry({ initialItems, initialCursor }: Props) {
if (w >= 640) return 2; // sm
return 1;
}, []);
const [columnCount, setColumnCount] = useState<number>(getColumnCount());
// 为避免 SSR 与客户端初次渲染不一致(window 未定义导致服务端为 1 列,客户端首次渲染为多列),
// 这里将初始列数固定为 1,待挂载后再根据窗口宽度更新。
const [columnCount, setColumnCount] = useState<number>(1);
useEffect(() => {
// 挂载后立即根据当前窗口宽度更新一次列数
setColumnCount(getColumnCount());
const onResize = () => setColumnCount(getColumnCount());
window.addEventListener('resize', onResize);
return () => window.removeEventListener('resize', onResize);
+6 -5
View File
@@ -2,6 +2,7 @@ import { prisma } from "@/lib/prisma";
import FeedMasonry from "./components/FeedMasonry";
import type { FeedItem } from "./types/feed";
import type { Metadata } from "next";
import { getFileUrl } from "@/lib/minio";
export const metadata: Metadata = {
title: "作品集 - 抖歪",
@@ -28,11 +29,11 @@ export default async function Home() {
aweme_id: v.aweme_id,
created_at: v.created_at,
desc: v.desc,
video_url: v.video_url,
cover_url: v.cover_url ?? null,
video_url: getFileUrl(v.video_url),
cover_url: getFileUrl(v.cover_url ?? ''),
width: v.width ?? 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 ?? '') },
likes: Number(v.digg_count)
})),
...posts.map((p) => ({
@@ -40,10 +41,10 @@ export default async function Home() {
aweme_id: p.aweme_id,
created_at: p.created_at,
desc: p.desc,
cover_url: p.images?.[0]?.url ?? null,
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: p.author.avatar_url ?? null },
author: { nickname: p.author.nickname, avatar_url: getFileUrl(p.author.avatar_url ?? '') },
likes: Number(p.digg_count)
})),
]