initialize

This commit is contained in:
2026-04-09 08:47:37 +00:00
commit b80e97799f
22 changed files with 3015 additions and 0 deletions

13
.gitignore vendored Normal file
View File

@@ -0,0 +1,13 @@
__pycache__/
.pytest_cache/
backend/.venv/
frontend/node_modules/
frontend/dist/
storage/uploads/*
storage/processed/*
storage/covers/*
storage/hls/*
!storage/uploads/.gitkeep
!storage/processed/.gitkeep
!storage/covers/.gitkeep
!storage/hls/.gitkeep

259
README.md Normal file
View File

@@ -0,0 +1,259 @@
# VPlatform
一个面向“通信服务后端 / 音视频处理 / 视频编辑服务 / AIGC 流程”求职方向设计的最小可用项目。它实现了一个完整的音视频处理服务闭环:上传视频、创建异步处理任务、由内存队列中的 worker 调用 FFmpeg 执行处理、返回任务状态与结果,并在前端页面中展示处理产物。
## Step 1 - 功能解读
### 这个项目解决什么问题
很多视频处理场景都不是同步接口能优雅承载的上传文件后转码、裁剪、封面抽帧、HLS 切片通常都要几秒到几十秒。如果全部放在请求线程内,会导致接口超时、资源竞争和糟糕的用户体验。
这个项目把问题缩成一个 1 天内能交付的最小闭环:
- 用户上传视频
- 用户提交处理任务
- 后端把任务放入内存队列
- worker 异步执行 FFmpeg 任务
- 前端轮询查看状态和结果
### 为什么贴合 JD
- 体现了通信服务后端常见的“任务异步化”和“状态可观测”
- 体现了音视频工程能力,真实调用 FFmpeg 而不是伪代码
- 体现了视频编辑服务常见能力裁剪、转码、封面、HLS
- 体现了 AIGC/媒体流水线常见的 pipeline 思维
- 体现了调度系统最小形态队列、worker、状态、日志
### 为什么一天内能完成
- 不引入 Redis、MySQL、对象存储等重基础设施
- 任务队列使用内存 `Queue`
- 文件存储使用本地文件系统
- 后端选 FastAPI前端选 React + Vite开发速度快
- 核心亮点集中在“处理链路真实可跑通”
## Step 2 - 技术选型
### Frontend
- `React + Vite`
- 原因:启动快、代码量小、适合快速搭建可展示页面
### Backend
- `Python FastAPI`
- 原因:接口编写快,文件上传和 JSON API 处理顺手,和 FFmpeg 命令行集成成本低
### Task Model
- `内存队列 + 后台 worker 线程`
- 原因:足够展示异步处理模型,不需要为了 demo 引入 Redis / Celery
### Media Processing
- `FFmpeg`
- 原因:真实工业标准工具,能直接体现音视频处理能力
### Storage
- `本地文件系统`
- 原因:最容易跑通,便于 GitHub demo 和本地展示
## Step 3 - 架构设计
### 模块划分
- 前端页面:上传文件、创建任务、轮询状态、展示结果
- FastAPI 接口层:上传接口、创建任务接口、任务查询接口
- TaskManager任务元数据管理、状态维护、队列分发
- Worker从队列消费任务调用 FFmpeg 执行媒体处理
- 本地存储保存原始文件、转码结果、封面图、HLS 切片
### 请求流转
1. 前端上传视频到 `/api/upload`
2. 后端保存文件并返回 `file_id`
3. 前端使用 `file_id` 调用 `/api/tasks`
4. 后端将任务入队并返回 `task_id`
5. 前端轮询 `/api/tasks``/api/tasks/{task_id}`
### 异步任务流转
1. API 创建 `queued` 状态任务
2. worker 从队列取出任务,更新为 `processing`
3. FFmpeg 依次执行转码、封面、HLS
4. 成功则更新为 `completed`
5. 失败则更新为 `failed` 并记录错误
### 存储结构
- `storage/uploads/` 原始上传文件
- `storage/processed/` 处理后的 MP4
- `storage/covers/` 封面图
- `storage/hls/<task_id>/` HLS m3u8 与 ts 分片
### Mermaid 架构图
```mermaid
flowchart LR
A[React Frontend] -->|上传视频| B[FastAPI Upload API]
A -->|创建任务| C[FastAPI Task API]
C --> D[In-Memory Queue]
D --> E[Worker Thread]
E --> F[FFmpeg Pipeline]
F --> G[Local Storage]
A -->|轮询状态| C
C -->|返回状态/结果| A
G -->|静态文件访问| A
```
## Step 4 - 分阶段计划
- 第 1 小时:设计接口、目录结构、任务模型
- 第 2 小时:完成 FastAPI 文件上传和任务查询接口
- 第 3 小时:实现内存队列和 worker
- 第 4 小时:接入 FFmpeg 转码、封面、HLS
- 第 5 小时:搭 React 页面,打通上传和轮询
- 第 6 小时联调、异常处理、README 收尾
## Step 5 - 项目目录结构
```text
vplatform/
├── backend/
│ ├── app/
│ │ ├── services/
│ │ │ ├── media.py
│ │ │ ├── storage.py
│ │ │ └── tasks.py
│ │ ├── __init__.py
│ │ ├── config.py
│ │ ├── main.py
│ │ └── schemas.py
│ └── requirements.txt
├── frontend/
│ ├── src/
│ │ ├── App.jsx
│ │ ├── main.jsx
│ │ └── styles.css
│ ├── index.html
│ ├── package.json
│ └── vite.config.js
├── storage/
│ ├── covers/
│ ├── hls/
│ ├── processed/
│ └── uploads/
├── .gitignore
├── README.md
└── prompt.md
```
## 功能列表
- 上传视频文件
- 创建异步音视频处理任务
- 任务状态查询:`queued / processing / completed / failed`
- 视频裁剪前 N 秒
- MP4 转码并叠加简单文字水印
- 封面图抽帧
- HLS 切片输出
- 前端任务看板和结果展示
## 快速启动
### 1. 启动后端
```bash
cd backend
python3 -m venv .venv
source .venv/bin/activate
pip install -r requirements.txt
uvicorn app.main:app --reload --port 8000
```
如果本机没有 `python3-venv`,也可以直接:
```bash
cd backend
python3 -m pip install --user -r requirements.txt
python3 -m uvicorn app.main:app --reload --port 8000
```
### 2. 启动前端
```bash
cd frontend
npm install
npm run dev
```
### 3. 打开页面
- 前端http://localhost:5173
- 后端http://localhost:8000
- 健康检查http://localhost:8000/api/health
## API 说明
### `POST /api/upload`
上传视频文件,返回上传后的 `file_id`
响应示例:
```json
{
"file_id": "f8d25e4c0e8b4b2b9b60f1a4d0e6a1df.mp4",
"original_name": "demo.mp4",
"file_url": "/media/uploads/f8d25e4c0e8b4b2b9b60f1a4d0e6a1df.mp4"
}
```
### `POST /api/tasks`
创建异步处理任务。
请求示例:
```json
{
"file_id": "f8d25e4c0e8b4b2b9b60f1a4d0e6a1df.mp4",
"clip_seconds": 8,
"transcode_mp4": true,
"generate_cover": true,
"generate_hls": true,
"watermark_text": "VPlatform Demo"
}
```
### `GET /api/tasks`
获取全部任务列表,用于前端轮询。
### `GET /api/tasks/{task_id}`
获取单个任务详情。
## 页面预览说明
- 顶部 Hero 区展示项目定位和技术栈
- 左侧面板上传视频
- 右侧面板配置处理参数并提交异步任务
- 底部任务看板实时展示状态、日志、转码视频、封面图和 HLS 链接
## 项目亮点
- 用最小成本演示了“上传 -> 入队 -> worker -> FFmpeg -> 结果回查”的完整链路
- 没有停留在概念层,能真实跑出 MP4、封面图和 HLS 文件
- 结构足够清晰,后续可自然扩展到 Redis、Celery、对象存储和告警系统
- 非常适合写进简历,能覆盖后端、音视频、异步任务和前后端联调能力
## 后续优化方向
- 使用 Redis + Celery / RQ 替换内存队列
- 接入 MySQL / PostgreSQL 持久化任务状态
- 上传到对象存储而不是本地磁盘
- 增加任务超时、重试和告警
- 接入 WebSocket 推送任务状态,替代前端轮询
- 支持更多处理能力例如字幕叠加、GIF 生成、拼接和音轨替换

1
backend/app/__init__.py Normal file
View File

@@ -0,0 +1 @@

12
backend/app/config.py Normal file
View File

@@ -0,0 +1,12 @@
from pathlib import Path
ROOT_DIR = Path(__file__).resolve().parents[2]
STORAGE_DIR = ROOT_DIR / "storage"
UPLOAD_DIR = STORAGE_DIR / "uploads"
PROCESSED_DIR = STORAGE_DIR / "processed"
HLS_DIR = STORAGE_DIR / "hls"
COVER_DIR = STORAGE_DIR / "covers"
for directory in (STORAGE_DIR, UPLOAD_DIR, PROCESSED_DIR, HLS_DIR, COVER_DIR):
directory.mkdir(parents=True, exist_ok=True)

71
backend/app/main.py Normal file
View File

@@ -0,0 +1,71 @@
from typing import Dict, List
from fastapi import FastAPI, File, HTTPException, UploadFile
from fastapi.middleware.cors import CORSMiddleware
from fastapi.staticfiles import StaticFiles
from app.config import COVER_DIR, HLS_DIR, PROCESSED_DIR, STORAGE_DIR, UPLOAD_DIR
from app.schemas import TaskCreateRequest, TaskResponse, UploadResponse
from app.services.storage import get_upload_path, save_upload
from app.services.tasks import task_manager
app = FastAPI(title="VPlatform API", version="0.1.0")
app.add_middleware(
CORSMiddleware,
allow_origins=["http://localhost:5173"],
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
app.mount("/media/uploads", StaticFiles(directory=UPLOAD_DIR), name="uploads")
app.mount("/media/processed", StaticFiles(directory=PROCESSED_DIR), name="processed")
app.mount("/media/hls", StaticFiles(directory=HLS_DIR), name="hls")
app.mount("/media/covers", StaticFiles(directory=COVER_DIR), name="covers")
app.mount("/media", StaticFiles(directory=STORAGE_DIR), name="media")
@app.on_event("startup")
def start_worker() -> None:
task_manager.start()
@app.get("/api/health")
def health() -> Dict[str, str]:
return {"status": "ok"}
@app.post("/api/upload", response_model=UploadResponse)
async def upload_video(file: UploadFile = File(...)) -> UploadResponse:
if not file.filename:
raise HTTPException(status_code=400, detail="missing filename")
file_id, original_name = save_upload(file)
return UploadResponse(
file_id=file_id,
original_name=original_name,
file_url=f"/media/uploads/{file_id}",
)
@app.post("/api/tasks", response_model=TaskResponse)
def create_task(payload: TaskCreateRequest) -> TaskResponse:
if not get_upload_path(payload.file_id).exists():
raise HTTPException(status_code=404, detail="uploaded file not found")
record = task_manager.create_task(payload)
return TaskResponse(**record.__dict__)
@app.get("/api/tasks", response_model=List[TaskResponse])
def list_tasks() -> List[TaskResponse]:
return [TaskResponse(**task.__dict__) for task in task_manager.list_tasks()]
@app.get("/api/tasks/{task_id}", response_model=TaskResponse)
def get_task(task_id: str) -> TaskResponse:
task = task_manager.get_task(task_id)
if task is None:
raise HTTPException(status_code=404, detail="task not found")
return TaskResponse(**task.__dict__)

36
backend/app/schemas.py Normal file
View File

@@ -0,0 +1,36 @@
from typing import List, Optional
from pydantic import BaseModel, Field
class UploadResponse(BaseModel):
file_id: str
original_name: str
file_url: str
class TaskCreateRequest(BaseModel):
file_id: str = Field(..., description="Uploaded file identifier")
clip_seconds: Optional[int] = Field(default=8, ge=1, le=60)
transcode_mp4: bool = True
generate_cover: bool = True
generate_hls: bool = True
watermark_text: Optional[str] = Field(default="VPlatform Demo", max_length=32)
class TaskResult(BaseModel):
mp4_url: Optional[str] = None
cover_url: Optional[str] = None
hls_url: Optional[str] = None
log: List[str] = Field(default_factory=list)
class TaskResponse(BaseModel):
task_id: str
status: str
file_id: str
created_at: str
updated_at: str
options: TaskCreateRequest
result: TaskResult
error: Optional[str] = None

View File

@@ -0,0 +1,104 @@
import subprocess
from pathlib import Path
from typing import List, Optional
from app.config import COVER_DIR, HLS_DIR, PROCESSED_DIR
class FFmpegError(RuntimeError):
pass
def run_ffmpeg(command: List[str]) -> None:
completed = subprocess.run(
command,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
text=True,
check=False,
)
if completed.returncode != 0:
raise FFmpegError(completed.stderr.strip() or "ffmpeg command failed")
def build_filter(watermark_text: Optional[str]) -> Optional[str]:
if not watermark_text:
return None
escaped = watermark_text.replace(":", r"\:").replace("'", r"\\'")
return (
"drawtext=text='"
f"{escaped}"
"':fontcolor=white:fontsize=28:box=1:boxcolor=black@0.45:boxborderw=6:x=w-tw-24:y=h-th-24"
)
def transcode_to_mp4(
source: Path, task_id: str, clip_seconds: Optional[int], watermark_text: Optional[str]
) -> Path:
output = PROCESSED_DIR / f"{task_id}.mp4"
command = ["ffmpeg", "-y", "-i", str(source)]
if clip_seconds:
command.extend(["-t", str(clip_seconds)])
video_filter = build_filter(watermark_text)
if video_filter:
command.extend(["-vf", video_filter])
command.extend(
[
"-c:v",
"libx264",
"-preset",
"veryfast",
"-c:a",
"aac",
"-movflags",
"+faststart",
str(output),
]
)
run_ffmpeg(command)
return output
def generate_cover(source: Path, task_id: str) -> Path:
output = COVER_DIR / f"{task_id}.jpg"
command = [
"ffmpeg",
"-y",
"-ss",
"00:00:01",
"-i",
str(source),
"-frames:v",
"1",
str(output),
]
run_ffmpeg(command)
return output
def generate_hls(source_mp4: Path, task_id: str) -> Path:
task_dir = HLS_DIR / task_id
task_dir.mkdir(parents=True, exist_ok=True)
playlist = task_dir / "index.m3u8"
segment_pattern = task_dir / "segment_%03d.ts"
command = [
"ffmpeg",
"-y",
"-i",
str(source_mp4),
"-codec:v",
"libx264",
"-codec:a",
"aac",
"-hls_time",
"4",
"-hls_playlist_type",
"vod",
"-hls_segment_filename",
str(segment_pattern),
str(playlist),
]
run_ffmpeg(command)
return playlist

View File

@@ -0,0 +1,23 @@
import shutil
import uuid
from pathlib import Path
from typing import Tuple
from fastapi import UploadFile
from app.config import UPLOAD_DIR
def save_upload(file: UploadFile) -> Tuple[str, str]:
suffix = Path(file.filename or "").suffix.lower() or ".mp4"
file_id = f"{uuid.uuid4().hex}{suffix}"
destination = UPLOAD_DIR / file_id
with destination.open("wb") as buffer:
shutil.copyfileobj(file.file, buffer)
return file_id, file.filename or file_id
def get_upload_path(file_id: str) -> Path:
return UPLOAD_DIR / file_id

View File

@@ -0,0 +1,124 @@
import threading
import uuid
from dataclasses import dataclass, field
from datetime import datetime
from pathlib import Path
from queue import Queue
from typing import Dict, List, Optional
from app.schemas import TaskCreateRequest, TaskResult
from app.services.media import generate_cover, generate_hls, transcode_to_mp4
from app.services.storage import get_upload_path
def now_iso() -> str:
return datetime.utcnow().replace(microsecond=0).isoformat() + "Z"
@dataclass
class TaskRecord:
task_id: str
file_id: str
options: TaskCreateRequest
status: str = "queued"
result: TaskResult = field(default_factory=TaskResult)
error: Optional[str] = None
created_at: str = field(default_factory=now_iso)
updated_at: str = field(default_factory=now_iso)
def touch(self) -> None:
self.updated_at = now_iso()
class TaskManager:
def __init__(self) -> None:
self.tasks: Dict[str, TaskRecord] = {}
self.queue: Queue = Queue()
self.lock = threading.Lock()
self.worker = threading.Thread(target=self._worker_loop, daemon=True)
self.worker_started = False
def start(self) -> None:
if not self.worker_started:
self.worker.start()
self.worker_started = True
def create_task(self, payload: TaskCreateRequest) -> TaskRecord:
task_id = uuid.uuid4().hex[:12]
record = TaskRecord(task_id=task_id, file_id=payload.file_id, options=payload)
with self.lock:
self.tasks[task_id] = record
self.queue.put(task_id)
return record
def get_task(self, task_id: str) -> Optional[TaskRecord]:
return self.tasks.get(task_id)
def list_tasks(self) -> List[TaskRecord]:
return sorted(self.tasks.values(), key=lambda task: task.created_at, reverse=True)
def _update_status(self, task: TaskRecord, status: str) -> None:
with self.lock:
task.status = status
task.touch()
def _append_log(self, task: TaskRecord, message: str) -> None:
with self.lock:
task.result.log.append(message)
task.touch()
def _fail_task(self, task: TaskRecord, error: str) -> None:
with self.lock:
task.status = "failed"
task.error = error
task.touch()
def _worker_loop(self) -> None:
while True:
task_id = self.queue.get()
task = self.tasks.get(task_id)
if task is None:
self.queue.task_done()
continue
try:
self._process_task(task)
except Exception as exc: # noqa: BLE001
self._fail_task(task, str(exc))
finally:
self.queue.task_done()
def _process_task(self, task: TaskRecord) -> None:
source_path = get_upload_path(task.file_id)
if not source_path.exists():
raise FileNotFoundError(f"uploaded file not found: {task.file_id}")
self._update_status(task, "processing")
working_source: Path = source_path
if task.options.transcode_mp4:
self._append_log(task, "Transcoding to MP4 with optional clipping and watermark")
mp4_path = transcode_to_mp4(
source=source_path,
task_id=task.task_id,
clip_seconds=task.options.clip_seconds,
watermark_text=task.options.watermark_text,
)
task.result.mp4_url = f"/media/processed/{mp4_path.name}"
working_source = mp4_path
if task.options.generate_cover:
self._append_log(task, "Generating cover image")
cover_path = generate_cover(working_source, task.task_id)
task.result.cover_url = f"/media/covers/{cover_path.name}"
if task.options.generate_hls:
self._append_log(task, "Generating HLS playlist")
playlist = generate_hls(working_source, task.task_id)
task.result.hls_url = f"/media/hls/{task.task_id}/{playlist.name}"
self._append_log(task, "Task completed")
self._update_status(task, "completed")
task_manager = TaskManager()

3
backend/requirements.txt Normal file
View File

@@ -0,0 +1,3 @@
fastapi==0.115.0
uvicorn[standard]==0.30.6
python-multipart==0.0.9

12
frontend/index.html Normal file
View File

@@ -0,0 +1,12 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>VPlatform</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.jsx"></script>
</body>
</html>

1677
frontend/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

19
frontend/package.json Normal file
View File

@@ -0,0 +1,19 @@
{
"name": "vplatform-frontend",
"version": "0.1.0",
"private": true,
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview"
},
"dependencies": {
"react": "^18.3.1",
"react-dom": "^18.3.1"
},
"devDependencies": {
"@vitejs/plugin-react": "^4.3.1",
"vite": "^5.4.8"
}
}

258
frontend/src/App.jsx Normal file
View File

@@ -0,0 +1,258 @@
import { useEffect, useState } from 'react'
const API_BASE = 'http://localhost:8000'
const defaultForm = {
clip_seconds: 8,
transcode_mp4: true,
generate_cover: true,
generate_hls: true,
watermark_text: 'VPlatform Demo',
}
function formatTime(iso) {
return new Date(iso).toLocaleString('zh-CN', { hour12: false })
}
function App() {
const [fileInfo, setFileInfo] = useState(null)
const [form, setForm] = useState(defaultForm)
const [tasks, setTasks] = useState([])
const [uploading, setUploading] = useState(false)
const [creating, setCreating] = useState(false)
const [error, setError] = useState('')
async function fetchTasks() {
try {
const response = await fetch(`${API_BASE}/api/tasks`)
if (!response.ok) {
throw new Error('获取任务列表失败')
}
const data = await response.json()
setTasks(data)
} catch (err) {
setError(err.message)
}
}
useEffect(() => {
fetchTasks()
const timer = setInterval(fetchTasks, 2500)
return () => clearInterval(timer)
}, [])
async function handleUpload(event) {
const selected = event.target.files?.[0]
if (!selected) {
return
}
setUploading(true)
setError('')
const formData = new FormData()
formData.append('file', selected)
try {
const response = await fetch(`${API_BASE}/api/upload`, {
method: 'POST',
body: formData,
})
if (!response.ok) {
throw new Error('上传失败')
}
const data = await response.json()
setFileInfo(data)
} catch (err) {
setError(err.message)
} finally {
setUploading(false)
}
}
async function handleCreateTask() {
if (!fileInfo) {
setError('请先上传视频')
return
}
setCreating(true)
setError('')
try {
const response = await fetch(`${API_BASE}/api/tasks`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
file_id: fileInfo.file_id,
...form,
}),
})
if (!response.ok) {
throw new Error('创建任务失败')
}
const task = await response.json()
setTasks((current) => [task, ...current])
} catch (err) {
setError(err.message)
} finally {
setCreating(false)
}
}
function updateField(key, value) {
setForm((current) => ({ ...current, [key]: value }))
}
return (
<div className="page-shell">
<header className="hero">
<div>
<p className="eyebrow">Audio / Video Processing Demo</p>
<h1>VPlatform</h1>
<p className="hero-copy">
一个 1 天内可交付的音视频处理服务最小闭环上传排队异步转码生成封面与 HLS并在前端实时查看结果
</p>
</div>
<div className="hero-card">
<span>后端</span>
<strong>FastAPI + In-Memory Worker</strong>
<span>处理链路</span>
<strong>FFmpeg</strong>
</div>
</header>
<main className="grid">
<section className="panel">
<h2>1. 上传视频</h2>
<label className="upload-box">
<input type="file" accept="video/*" onChange={handleUpload} />
<span>{uploading ? '上传中...' : '点击选择视频文件'}</span>
</label>
{fileInfo && (
<div className="info-card">
<p>已上传{fileInfo.original_name}</p>
<a href={`${API_BASE}${fileInfo.file_url}`} target="_blank" rel="noreferrer">
查看原文件
</a>
</div>
)}
</section>
<section className="panel">
<h2>2. 创建任务</h2>
<div className="form-grid">
<label>
截取前 N
<input
type="number"
min="1"
max="60"
value={form.clip_seconds}
onChange={(event) => updateField('clip_seconds', Number(event.target.value))}
/>
</label>
<label>
水印文本
<input
type="text"
value={form.watermark_text}
onChange={(event) => updateField('watermark_text', event.target.value)}
/>
</label>
</div>
<div className="checkboxes">
<label>
<input
type="checkbox"
checked={form.transcode_mp4}
onChange={(event) => updateField('transcode_mp4', event.target.checked)}
/>
转码 MP4
</label>
<label>
<input
type="checkbox"
checked={form.generate_cover}
onChange={(event) => updateField('generate_cover', event.target.checked)}
/>
生成封面图
</label>
<label>
<input
type="checkbox"
checked={form.generate_hls}
onChange={(event) => updateField('generate_hls', event.target.checked)}
/>
生成 HLS
</label>
</div>
<button className="primary-btn" onClick={handleCreateTask} disabled={creating}>
{creating ? '创建中...' : '提交异步任务'}
</button>
{error && <p className="error-text">{error}</p>}
</section>
<section className="panel full-width">
<div className="panel-title">
<h2>3. 任务看板</h2>
<button className="ghost-btn" onClick={fetchTasks}>
刷新
</button>
</div>
<div className="task-list">
{tasks.length === 0 && <p className="muted-text">暂无任务先上传一个视频并提交处理</p>}
{tasks.map((task) => (
<article className="task-card" key={task.task_id}>
<div className="task-meta">
<div>
<p className="task-id">Task #{task.task_id}</p>
<p className={`status-pill ${task.status}`}>{task.status}</p>
</div>
<p className="muted-text">{formatTime(task.created_at)}</p>
</div>
<p className="muted-text">
clip {task.options.clip_seconds}s / mp4 {String(task.options.transcode_mp4)} / cover{' '}
{String(task.options.generate_cover)} / hls {String(task.options.generate_hls)}
</p>
{task.error && <p className="error-text">{task.error}</p>}
<div className="result-grid">
{task.result.mp4_url && (
<div>
<p>处理后视频</p>
<video controls src={`${API_BASE}${task.result.mp4_url}`} />
</div>
)}
{task.result.cover_url && (
<div>
<p>封面图</p>
<img src={`${API_BASE}${task.result.cover_url}`} alt="cover" />
</div>
)}
{task.result.hls_url && (
<div className="hls-box">
<p>HLS 播放清单</p>
<a href={`${API_BASE}${task.result.hls_url}`} target="_blank" rel="noreferrer">
打开 m3u8
</a>
</div>
)}
</div>
<div className="log-box">
{task.result.log.map((item, index) => (
<p key={`${task.task_id}-${index}`}>{item}</p>
))}
</div>
</article>
))}
</div>
</section>
</main>
</div>
)
}
export default App

10
frontend/src/main.jsx Normal file
View File

@@ -0,0 +1,10 @@
import React from 'react'
import ReactDOM from 'react-dom/client'
import App from './App'
import './styles.css'
ReactDOM.createRoot(document.getElementById('root')).render(
<React.StrictMode>
<App />
</React.StrictMode>,
)

264
frontend/src/styles.css Normal file
View File

@@ -0,0 +1,264 @@
:root {
color: #f5f1e8;
background:
radial-gradient(circle at top left, rgba(237, 123, 72, 0.22), transparent 28%),
radial-gradient(circle at top right, rgba(90, 153, 212, 0.22), transparent 32%),
linear-gradient(135deg, #111827, #1f2937 40%, #0f172a 100%);
font-family: "IBM Plex Sans", "Segoe UI", sans-serif;
}
* {
box-sizing: border-box;
}
body {
margin: 0;
min-height: 100vh;
}
a {
color: #ffd79f;
}
button,
input {
font: inherit;
}
.page-shell {
width: min(1180px, calc(100% - 32px));
margin: 0 auto;
padding: 32px 0 48px;
}
.hero {
display: grid;
grid-template-columns: minmax(0, 1.3fr) minmax(260px, 0.7fr);
gap: 20px;
align-items: stretch;
margin-bottom: 24px;
}
.eyebrow {
margin: 0 0 12px;
font-size: 12px;
letter-spacing: 0.24em;
text-transform: uppercase;
color: #fca46b;
}
.hero h1 {
margin: 0;
font-size: clamp(40px, 8vw, 72px);
line-height: 0.95;
}
.hero-copy {
max-width: 680px;
color: #d8dee9;
}
.hero-card,
.panel,
.task-card {
border: 1px solid rgba(255, 255, 255, 0.1);
background: rgba(17, 24, 39, 0.72);
backdrop-filter: blur(14px);
border-radius: 20px;
box-shadow: 0 18px 60px rgba(0, 0, 0, 0.25);
}
.hero-card {
display: grid;
gap: 8px;
padding: 24px;
align-content: center;
}
.hero-card span {
color: #9fb3c8;
font-size: 14px;
}
.hero-card strong {
font-size: 22px;
}
.grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 20px;
}
.panel {
padding: 24px;
}
.full-width {
grid-column: 1 / -1;
}
.upload-box {
display: grid;
place-items: center;
min-height: 180px;
border: 1px dashed rgba(255, 255, 255, 0.3);
border-radius: 18px;
cursor: pointer;
background: rgba(255, 255, 255, 0.03);
}
.upload-box input {
display: none;
}
.info-card,
.log-box,
.hls-box {
padding: 14px 16px;
border-radius: 14px;
background: rgba(255, 255, 255, 0.04);
}
.form-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 16px;
}
.form-grid label,
.checkboxes label {
display: grid;
gap: 8px;
font-size: 14px;
color: #d8dee9;
}
.form-grid input[type='number'],
.form-grid input[type='text'] {
border: 0;
border-radius: 12px;
padding: 12px 14px;
color: #f5f1e8;
background: rgba(255, 255, 255, 0.08);
}
.checkboxes {
display: flex;
gap: 18px;
flex-wrap: wrap;
margin: 18px 0;
}
.primary-btn,
.ghost-btn {
border: 0;
border-radius: 999px;
cursor: pointer;
transition: transform 160ms ease, opacity 160ms ease;
}
.primary-btn {
padding: 12px 20px;
color: #111827;
background: linear-gradient(90deg, #ffd79f, #fca46b);
font-weight: 700;
}
.ghost-btn {
padding: 10px 16px;
color: #f5f1e8;
background: rgba(255, 255, 255, 0.08);
}
.primary-btn:hover,
.ghost-btn:hover {
transform: translateY(-1px);
}
.panel-title,
.task-meta {
display: flex;
justify-content: space-between;
align-items: center;
gap: 12px;
}
.task-list {
display: grid;
gap: 16px;
}
.task-card {
padding: 20px;
}
.task-id {
margin: 0;
font-size: 18px;
}
.status-pill {
display: inline-flex;
margin-top: 8px;
padding: 6px 10px;
border-radius: 999px;
font-size: 12px;
text-transform: uppercase;
}
.status-pill.queued {
background: rgba(250, 204, 21, 0.16);
color: #fde68a;
}
.status-pill.processing {
background: rgba(59, 130, 246, 0.18);
color: #bfdbfe;
}
.status-pill.completed {
background: rgba(74, 222, 128, 0.18);
color: #bbf7d0;
}
.status-pill.failed {
background: rgba(248, 113, 113, 0.18);
color: #fecaca;
}
.result-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
gap: 16px;
margin: 16px 0;
}
.result-grid video,
.result-grid img {
width: 100%;
border-radius: 14px;
background: #000;
}
.log-box p,
.muted-text,
.error-text {
margin: 6px 0;
}
.muted-text {
color: #9fb3c8;
}
.error-text {
color: #fca5a5;
}
@media (max-width: 900px) {
.hero,
.grid,
.form-grid {
grid-template-columns: 1fr;
}
}

9
frontend/vite.config.js Normal file
View File

@@ -0,0 +1,9 @@
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
export default defineConfig({
plugins: [react()],
server: {
port: 5173,
},
})

116
prompt.md Normal file
View File

@@ -0,0 +1,116 @@
# Role
你是一名资深全栈工程师 + 后端多媒体工程师 + 技术项目负责人。
你的目标不是泛泛而谈而是直接交付一个“1天内可完成、可运行、可展示、可写进简历”的最小可用项目。
# Project Goal
请围绕“音视频处理服务最小闭环”设计并实现一个前后端项目,项目需要贴合以下求职方向:
- 通信服务后端开发实习生
- 音视频技术
- 视频编辑服务
- AIGC处理流程
- 调度系统 / 任务队列 / 监控告警
项目必须满足:
1. 一天内可快速开发完成
2. 必须有前端页面展示
3. 必须有后端 API
4. 必须体现异步任务处理思想
5. 必须能运行一个真实的音视频处理流程
6. 必须适合发布到 GitHub
7. 最终输出必须包含 README.md 和完整文件内容
# Core Requirement
请基于“最小可行但足够有技术亮点”的原则,完成以下工作:
1. 先做需求功能解读
2. 再做技术选型
3. 再做系统架构设计
4. 再做分阶段开发计划
5. 再输出完整项目目录结构
6. 再逐文件给出可运行代码
7. 最后生成符合 GitHub 风格的 README.md
# Business Scope
项目建议方向:
一个极简音视频处理平台,至少包含以下能力:
- 上传视频
- 创建处理任务
- 异步执行任务
- 查询任务状态
- 返回处理结果
- 前端可查看结果
处理能力至少实现 2~3 个:
- 视频转码 MP4
- 截取前 N 秒
- 生成封面图
- 生成 HLS
- 添加简单水印
# Preferred Tech Stack
默认优先选择“开发速度快、展示效果好、一天能做完”的技术栈。
如果没有特别冲突,优先:
- Frontend: React + Vite
- Backend: Python FastAPI 或 Go Gin二选一并说明原因
- Task model: 内存队列 + worker
- Media processing: FFmpeg
- Storage: 本地文件系统
- API style: RESTful JSON
如果你认为其他技术栈更合适,也可以替换,但必须说明为什么更适合“一天内交付”。
# Hard Constraints
你必须严格遵守以下要求:
1. 不要做成“大而全”的复杂平台
2. 只做最小可用版本,但必须完整闭环
3. 不要只给概念,必须给真实代码
4. 不要只给代码片段,必须给完整文件内容
5. 不要只写伪代码
6. 不要省略关键文件
7. 代码要能直接复制到本地创建项目
8. 优先保证“能跑通”而不是“功能很多”
9. 前端页面可以朴素,但流程必须完整
10. 后端必须体现任务队列 / 异步处理思想
11. README.md 必须像真实 GitHub 开源项目
12. README 中必须包含:
- 项目介绍
- 技术栈
- 功能列表
- 架构图Mermaid
- 快速启动
- API 说明
- 页面预览说明
- 项目亮点
- 后续优化方向
# Execution Order
请严格按以下顺序输出,不要跳步:
## Step 1 - 功能解读
把这个项目要解决的问题、为什么贴合 JD、为什么一天内能完成讲清楚。
## Step 2 - 技术选型
给出前端、后端、任务队列、音视频处理方案、存储方案,并说明取舍。
## Step 3 - 架构设计
输出:
- 模块划分
- 请求流转
- 异步任务流转
- 存储结构
- Mermaid 架构图
## Step 4 - 分阶段计划
给出按小时拆分的一天开发计划,例如:
- 第 1 阶段:搭后端
- 第 2 阶段:接 FFmpeg
- 第 3 阶段:写前端
- 第 4 阶段:联调
- 第 5 阶段README
## Step 5 - 项目目录结构
给出完整目录树。
## step 6 - 编写代码
按照分阶段计划编写代码,每个阶段末尾停滞一次申请用户来检查代码。

1
storage/covers/.gitkeep Normal file
View File

@@ -0,0 +1 @@

1
storage/hls/.gitkeep Normal file
View File

@@ -0,0 +1 @@

View File

@@ -0,0 +1 @@

1
storage/uploads/.gitkeep Normal file
View File

@@ -0,0 +1 @@