ts 视频网站

ts 视频网站

更新
简介我来帮你设计一个TypeScript实现的视频网站。这是一个完整的全栈项目架构:项目架构技术栈前端: Next.js 14 (App Router) + TypeScript + Tailwind C
立即观看 收藏

影片介绍

我来帮你设计一个TypeScript实现的视频视频网站。这是网站一个完整的全栈项目架构:

项目架构

ts 视频网站

技术栈

ts 视频网站

    ts 视频网站

  • 前端: 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.prisma

model 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.ts

import { 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.ts

import 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.tsx

import 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.tsx

import 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 格式,最大 1GB

</p>

</div>

)}

</div>

</div>

);

};

6. 环境变量配置

// src/config/env.ts

export 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配置

# Dockerfile

FROM 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.yml

version: '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 "部署完成!"

快速开始

  1. 安装依赖

npm install

npx prisma generate

  1. 配置环境变量

cp .env.example .env

# 编辑.env文件

  1. 启动开发服务器

npm run dev

  1. 数据库迁移

npx prisma migrate dev

这个架构提供了完整的网站视频网站功能,包括:

  • ✅ 视频上传和转码
  • ✅ HLS流媒体播放
  • ✅ 用户认证系统
  • ✅ 评论和点赞系统
  • ✅ 响应式设计
  • ✅ 生产环境部署配置

需要我详细解释某个模块或添加特定功能吗?视频