影片介绍
我来帮你设计一个TypeScript实现的视频视频网站。包括:
- ✅ 视频上传和转码
- ✅ HLS流媒体播放
- ✅ 用户认证系统
- ✅ 评论和点赞系统
- ✅ 响应式设计
- ✅ 生产环境部署配置



需要我详细解释某个模块或添加特定功能吗?网站
这是视频一个完整的全栈项目架构:项目架构
技术栈
- 前端: Next.js 14 (App Router) + TypeScript + Tailwind CSS
- 后端: Node.js + Express + TypeScript
- 数据库: PostgreSQL + Prisma ORM
- 视频处理: FFmpeg + Node.js流处理
- 存储: AWS S3 / Cloudinary / 本地存储
- 实时: WebSocket (Socket.io)
- 部署: Docker + AWS/ Vercel
核心功能模块
1. 数据库模型 (Prisma Schema)
// prisma/schema.prismamodel User {
id String @id @default(cuid())
email String @unique
username String @unique
avatar String?
bio String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
videos Video[]
comments Comment[]
likes Like[]
playlists Playlist[]
}
model Video {
id String @id @default(cuid())
title String
description String?
url String
thumbnail String
duration Int
views Int @default(0)
isPublic Boolean @default(true)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
userId String
user User @relation(fields: [userId], references: [id])
comments Comment[]
likes Like[]
tags VideoTag[]
playlists PlaylistVideo[]
@@index([userId])
@@index([createdAt])
}
model VideoTag {
id String @id @default(cuid())
name String
videoId String
video Video @relation(fields: [videoId], references: [id])
@@unique([videoId, name])
}
model Comment {
id String @id @default(cuid())
content String
createdAt DateTime @default(now())
videoId String
video Video @relation(fields: [videoId], references: [id])
userId String
user User @relation(fields: [userId], references: [id])
parentId String?
replies Comment @relation("Replies", fields: [parentId], references: [id])
@@index([videoId])
@@index([userId])
}
2. 后端API结构
// src/server/routes/video.routes.tsimport { Router } from 'express';
import {
uploadVideo,
getVideo,
streamVideo,
updateVideo,
deleteVideo,
getVideoComments,
likeVideo
} from '../controllers/video.controller';
import { upload } from '../middleware/upload.middleware';
import { auth } from '../middleware/auth.middleware';
const router = Router();
// 视频上传(支持分片上传)
router.post('/upload', auth, upload.single('video'), uploadVideo);
router.post('/upload/chunk', auth, uploadVideoChunk);
router.post('/upload/complete', auth, completeUpload);
// 视频流
router.get('/stream/:id', streamVideo);
router.get('/:id', getVideo);
router.put('/:id', auth, updateVideo);
router.delete('/:id', auth, deleteVideo);
// 互动
router.post('/:id/like', auth, likeVideo);
router.get('/:id/comments', getVideoComments);
export default router;
3. 视频处理服务
// src/services/video-processing.service.tsimport ffmpeg from 'fluent-ffmpeg';
import { createReadStream, createWriteStream } from 'fs';
import { pipeline } from 'stream/promises';
import { join } from 'path';
export class VideoProcessingService {
async generateThumbnail(videoPath: string, outputPath: string): Promise<string> {
return new Promise((resolve, reject) => {
ffmpeg(videoPath)
.screenshots({
count: 1,
folder: outputPath,
filename: 'thumbnail.jpg',
size: '640x360'
})
.on('end', () => resolve(join(outputPath, 'thumbnail.jpg')))
.on('error', reject);
});
}
async getVideoDuration(videoPath: string): Promise<number> {
return new Promise((resolve, reject) => {
ffmpeg.ffprobe(videoPath, (err, metadata) => {
if (err) reject(err);
resolve(Math.floor(metadata.format.duration || 0));
});
});
}
async transcodeVideo(
inputPath: string,
outputPath: string,
quality: '360p' | '480p' | '720p' | '1080p'
): Promise<void> {
const resolutions = {
'360p': '640x360',
'480p': '854x480',
'720p': '1280x720',
'1080p': '1920x1080'
};
return new Promise((resolve, reject) => {
ffmpeg(inputPath)
.videoCodec('libx264')
.audioCodec('aac')
.size(resolutions[quality])
.output(outputPath)
.on('end', resolve)
.on('error', reject)
.run();
});
}
}
4. 前端视频播放器组件
// src/components/VideoPlayer.tsximport React, { useEffect, useRef, useState } from 'react';
import Hls from 'hls.js';
interface VideoPlayerProps {
videoId: string;
poster?: string;
autoPlay?: boolean;
controls?: boolean;
}
export const VideoPlayer: React.FC<VideoPlayerProps> = ({
videoId,
poster,
autoPlay = false,
controls = true
}) => {
const videoRef = useRef<HTMLVideoElement>(null);
const [quality, setQuality] = useState<'360p' | '720p' | '1080p'>('720p');
const [isPlaying, setIsPlaying] = useState(false);
useEffect(() => {
const video = videoRef.current;
if (!video) return;
if (Hls.isSupported()) {
const hls = new Hls({
enableWorker: true,
lowLatencyMode: true,
backBufferLength: 90
});
hls.loadSource(`/api/videos/stream/${videoId}/master.m3u8`);
hls.attachMedia(video);
hls.on(Hls.Events.MANIFEST_PARSED, () => {
if (autoPlay) {
video.play().catch(console.error);
}
});
return () => {
hls.destroy();
};
} else if (video.canPlayType('application/vnd.apple.mpegurl')) {
video.src = `/api/videos/stream/${videoId}/master.m3u8`;
}
}, [videoId, autoPlay]);
const togglePlay = async () => {
if (!videoRef.current) return;
if (isPlaying) {
videoRef.current.pause();
} else {
try {
await videoRef.current.play();
} catch (error) {
console.error('播放失败:', error);
}
}
setIsPlaying(!isPlaying);
};
return (
<div className="relative w-full max-w-6xl mx-auto">
<div className="relative aspect-video bg-black rounded-lg overflow-hidden">
<video
ref={videoRef}
className="w-full h-full"
poster={poster}
controls={controls}
onPlay={() => setIsPlaying(true)}
onPause={() => setIsPlaying(false)}
/>
{!controls && (
<button
onClick={togglePlay}
className="absolute inset-0 flex items-center justify-center"
>
{!isPlaying && (
<div className="w-16 h-16 bg-red-600 rounded-full flex items-center justify-center">
<svg className="w-8 h-8 text-white" fill="currentColor" viewBox="0 0 24 24">
<path d="M8 5v14l11-7z" />
</svg>
</div>
)}
</button>
)}
</div>
{/* 清晰度选择器 */}
<div className="mt-2 flex gap-2">
{['360p', '720p', '1080p'].map((q) => (
<button
key={q}
onClick={() => setQuality(q as any)}
className={`px-3 py-1 rounded ${
quality === q
? 'bg-red-600 text-white'
: 'bg-gray-200 hover:bg-gray-300'
}`}
>
{q}
</button>
))}
</div>
</div>
);
};
5. 上传组件
// src/components/VideoUpload.tsximport React, { useState, useCallback } from 'react';
import { useDropzone } from 'react-dropzone';
import axios from 'axios';
interface UploadProgress {
loaded: number;
total: number;
percentage: number;
}
export const VideoUpload: React.FC = () => {
const [uploading, setUploading] = useState(false);
const [progress, setProgress] = useState<UploadProgress | null>(null);
const [videoInfo, setVideoInfo] = useState({
title: '',
description: '',
isPublic: true
});
const onDrop = useCallback(async (acceptedFiles: File[]) => {
const file = acceptedFiles[0];
if (!file) return;
const formData = new FormData();
formData.append('video', file);
formData.append('title', videoInfo.title);
formData.append('description', videoInfo.description);
formData.append('isPublic', videoInfo.isPublic.toString());
try {
setUploading(true);
const response = await axios.post('/api/videos/upload', formData, {
onUploadProgress: (progressEvent) => {
const { loaded, total } = progressEvent;
const percentage = Math.round((loaded * 100) / (total || 1));
setProgress({ loaded, total: total || 0, percentage });
},
headers: {
'Content-Type': 'multipart/form-data',
},
});
// 上传成功
console.log('上传成功:', response.data);
} catch (error) {
console.error('上传失败:', error);
} finally {
setUploading(false);
setProgress(null);
}
}, [videoInfo]);
const { getRootProps, getInputProps, isDragActive } = useDropzone({
onDrop,
accept: {
'video/*': ['.mp4', '.mov', '.avi', '.mkv']
},
maxSize: 1024 * 1024 * 1024, // 1GB
});
return (
<div className="max-w-2xl mx-auto p-6">
<div className="mb-6">
<input
type="text"
placeholder="视频标题"
className="w-full p-3 border rounded-lg mb-3"
value={videoInfo.title}
onChange={(e) => setVideoInfo({...videoInfo, title: e.target.value})}
/>
<textarea
placeholder="视频描述"
className="w-full p-3 border rounded-lg mb-3"
rows={3}
value={videoInfo.description}
onChange={(e) => setVideoInfo({...videoInfo, description: e.target.value})}
/>
</div>
<div
{...getRootProps()}
className={`
border-2 border-dashed rounded-lg p-12 text-center cursor-pointer
transition-colors
${isDragActive ? 'border-red-500 bg-red-50' : 'border-gray-300 hover:border-red-400'}
${uploading ? 'opacity-50 cursor-not-allowed' : ''}
`}
>
<input {...getInputProps()} disabled={uploading} />
{uploading ? (
<div>
<div className="mb-4">
<div className="w-full bg-gray-200 rounded-full h-2.5">
<div
className="bg-red-600 h-2.5 rounded-full transition-all duration-300"
style={{ width: `${progress?.percentage || 0}%` }}
/>
</div>
<p className="mt-2 text-sm text-gray-600">
{progress?.percentage}% 上传中...
</p>
</div>
</div>
) : (
<div>
<svg className="w-12 h-12 mx-auto text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M7 16a4 4 0 01-.88-7.903A5 5 0 1115.9 6L16 6a5 5 0 011 9.9M15 13l-3-3m0 0l-3 3m3-3v12" />
</svg>
<p className="mt-4 text-lg">
{isDragActive ? '松开以上传文件' : '拖放视频文件或点击选择'}
</p>
<p className="text-sm text-gray-500 mt-2">
支持 MP4, MOV, AVI, MKV 格式,"
快速开始
- 安装依赖
npm installnpx prisma generate
- 配置环境变量
cp .env.example .env# 编辑.env文件
- 启动开发服务器
npm run dev- 数据库迁移
npx prisma migrate dev这个架构提供了完整的网站视频网站功能,最大 1GB
</p>
</div>
)}
</div>
</div>
);
};
6. 环境变量配置
// src/config/env.tsexport const env = {
// 数据库
DATABASE_URL: process.env.DATABASE_URL!,视频
// 身份验证
JWT_SECRET: process.env.JWT_SECRET!,
NEXTAUTH_URL: process.env.NEXTAUTH_URL!,
NEXTAUTH_SECRET: process.env.NEXTAUTH_SECRET!,
// 存储
AWS_ACCESS_KEY_ID: process.env.AWS_ACCESS_KEY_ID,
AWS_SECRET_ACCESS_KEY: process.env.AWS_SECRET_ACCESS_KEY,
AWS_REGION: process.env.AWS_REGION,
AWS_S3_BUCKET: process.env.AWS_S3_BUCKET,
// 视频处理
FFMPEG_PATH: process.env.FFMPEG_PATH || 'ffmpeg',
UPLOAD_DIR: process.env.UPLOAD_DIR || './uploads',
// Redis(缓存和会话)
REDIS_URL: process.env.REDIS_URL,
// 邮件服务
SMTP_HOST: process.env.SMTP_HOST,
SMTP_PORT: parseInt(process.env.SMTP_PORT || '587'),
SMTP_USER: process.env.SMTP_USER,
SMTP_PASS: process.env.SMTP_PASS,
// 生产环境
NODE_ENV: process.env.NODE_ENV || 'development',
PORT: parseInt(process.env.PORT || '3000'),
} as const;
7. Docker配置
# DockerfileFROM node:18-alpine AS base
# 安装 FFmpeg
RUN apk add --no-cache ffmpeg
# 依赖安装阶段
FROM base AS deps
WORKDIR /app
COPY package*.json ./
RUN npm ci
# 构建阶段
FROM base AS builder
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY . .
RUN npm run build
# 生产阶段
FROM base AS runner
WORKDIR /app
ENV NODE_ENV=production
ENV PORT=3000
RUN addgroup --system --gid 1001 nodejs
RUN adduser --system --uid 1001 nextjs
COPY --from=builder /app/public ./public
COPY --from=builder /app/.next/standalone ./
COPY --from=builder /app/.next/static ./.next/static
USER nextjs
EXPOSE 3000
ENV HOSTNAME="0.0.0.0"
CMD ["node", "server.js"]
# docker-compose.ymlversion: '3.8'
services:
postgres:
image: postgres:15-alpine
environment:
POSTGRES_DB: video_db
POSTGRES_USER: video_user
POSTGRES_PASSWORD: video_pass
volumes:
- postgres_data:/var/lib/postgresql/data
ports:
- "5432:5432"
redis:
image: redis:7-alpine
ports:
- "6379:6379"
app:
build: .
depends_on:
- postgres
- redis
environment:
DATABASE_URL: postgresql://video_user:video_pass@postgres:5432/video_db
REDIS_URL: redis://redis:6379
ports:
- "3000:3000"
volumes:
- uploads:/app/uploads
volumes:
postgres_data:
uploads:
8. 部署脚本
#!/bin/bash# deploy.sh
echo "开始部署视频网站..."
# 构建Docker镜像
docker-compose build
# 运行数据库迁移
docker-compose run --rm app npx prisma migrate deploy
# 启动服务
docker-compose up -d
echo "部署完成!