VidReview — Video Review & Collaboration Platform
Self-hosted platform for distributed teams to review, annotate, and approve video content. Upload videos, add timestamped comments and drawings, track review status, and collaborate through project invitations.
Architecture Overview
┌─────────────────────────────────────────────────────────────────┐
│ VidReview Stack │
├──────────────┬──────────────┬──────────────┬───────────────────┤
│ Browser │ Frontend │ API │ Worker │
│ (Next.js) │ (Next.js) │ (Express) │ (Node.js/FFmpeg) │
│ :3000 │ :3000 │ :3001 │ background │
│ │ (SSR/SSG) │ │ │
└──────┬───────┴──────┬───────┴──────┬───────┴────────┬──────────┘
│ │ │ │
│ HTTP REST │ │ DB polling │
└──────────────┴──────────────┴────────────────┘
│
┌─────────┴─────────┐
│ PostgreSQL 16 │
│ (persisted vol) │
└───────────────────┘
Components
| Service |
Image |
Port |
Role |
| Frontend |
vidreview-frontend |
3000 |
Next.js 15 — UI, auth, video player |
| API |
vidreview-api |
3001 |
Express.js — REST API, JWT auth, file uploads |
| Worker |
vidreview-worker |
— |
Background transcode jobs via FFmpeg |
| Database |
postgres:16-alpine |
5432 |
PostgreSQL — schema, sessions, jobs |
Technology Stack
| Layer |
Technology |
| Frontend |
Next.js 15, React 19, TypeScript, Tailwind CSS, HLS.js |
| API |
Express.js 4, TypeScript, Prisma ORM |
| Auth |
JWT (RS/HS), httpOnly cookies, bcrypt |
| Video |
FFmpeg, HLS (.m3u8), adaptive streaming |
| Database |
PostgreSQL 16 |
| Container |
Docker Compose |
Project Structure
vidreview/
├── docker-compose.yml # All services definition
├── Dockerfile.api # Multi-stage build for API + worker
├── Dockerfile.frontend # Multi-stage build for Next.js
├── .env # Environment variables
├── .env.example # Template for .env
├── package.json # Workspace root (api + src)
├── packages/
│ ├── api/
│ │ ├── prisma/schema.prisma # Database schema
│ │ ├── src/
│ │ │ ├── index.ts # Express app entry (port 3001)
│ │ │ ├── lib/
│ │ │ │ ├── auth.ts # JWT middleware, cookie parsing
│ │ │ │ └── prisma.ts # PrismaClient singleton
│ │ │ ├── routes/
│ │ │ │ ├── auth.ts # /api/auth/*
│ │ │ │ ├── projects.ts # /api/projects/*
│ │ │ │ ├── assets.ts # /api/assets/*
│ │ │ │ ├── comments.ts # /api/comments/*
│ │ │ │ ├── invitations.ts # /api/invitations/*
│ │ │ │ └── users.ts # /api/users/*
│ │ │ ├── services/
│ │ │ │ └── ffmpeg.ts # FFmpeg helper types
│ │ │ └── worker/
│ │ │ ├── index.js # DB-as-queue poll loop
│ │ │ └── dispatcher.ts # Job dispatcher stub
│ │ └── workers/
│ │ └── transcode.js # FFmpeg transcode child process
└── src/ # Next.js frontend workspace
├── app/
│ ├── layout.tsx # RootLayout + AuthProvider
│ ├── page.tsx # Redirect → /login
│ ├── globals.css # Design system: CSS vars, dark theme
│ ├── (auth)/
│ │ ├── login/page.tsx # Login form + demo credentials hint
│ │ └── register/page.tsx # Register + invite token acceptance
│ ├── (dashboard)/
│ │ ├── layout.tsx # Sidebar: Projects, Users, Settings, avatar
│ │ ├── projects/page.tsx # Project listing + creation
│ │ ├── projects/[projectId]/
│ │ │ └── page.tsx # Videos / Transcode Tasks / Members tabs
│ │ ├── users/page.tsx # Admin user management
│ │ └── settings/page.tsx # Profile settings
│ ├── review/[assetId]/page.tsx # Video player + annotation canvas
│ └── invite/[token]/page.tsx # Public invite acceptance
├── components/
│ ├── ui/ # Reusable: Avatar, Button, Input, Modal
│ ├── video-player/
│ │ ├── VideoPlayer.tsx # HLS.js, keyboard shortcuts, fullscreen
│ │ ├── AnnotationCanvas.tsx # Pen/arrow/rect/ellipse, 7 colors
│ │ └── Timeline.tsx # Seek bar, comment ticks, frame accuracy
│ ├── comments/
│ │ └── CommentPanel.tsx # Timestamped + general comments, replies, resolve
│ └── transcode/
│ └── TranscodeTasksPanel.tsx # Status list, cancel/requeue, progress
└── lib/
├── api.ts # Typed fetch helpers + shared interfaces
└── auth-context.tsx # AuthProvider / useAuth hook
Database Schema
User ──────────── ProjectMember ──────────── Project
│ │
│ (author of comments) │ (has many)
▼ ▼
Comment ─────────────────────────────────── Asset
│ │
│ (timestamped, JSON annotations │ (HLS output, thumbnail)
│ on video frames) ▼
│ Invitation
│ (7-day expiry, hex token)
Models
| Model |
Description |
User |
Global user account (email, password, globalRole: ADMIN/MEMBER) |
Project |
Workspace container (name, description, ownerId) |
ProjectMember |
User ↔ Project join (role: ADMIN/EDITOR/REVIEWER/VIEWER) |
Asset |
Uploaded video (filePath, hlsPath, transcodeStatus, duration, codec) |
Comment |
Review comment (timestamp, JSON annotations, threaded replies, resolved) |
Invitation |
Pending invite (email, token, expiresAt, role) |
API Reference
Base URL: http://localhost:3001/api
Authentication
| Method |
Endpoint |
Description |
POST |
/auth/register |
Create account. Body: { email, name, password, inviteToken? } |
POST |
/auth/login |
Login. Returns JWT cookie. Body: { email, password } |
POST |
/auth/logout |
Clear JWT cookie |
GET |
/auth/me |
Current user info from JWT |
Projects
| Method |
Endpoint |
Description |
GET |
/projects |
List user's projects |
POST |
/projects |
Create project. Body: { name, description? } |
GET |
/projects/:id |
Project detail with members + assets |
PUT |
/projects/:id |
Update project |
DELETE |
/projects/:id |
Delete project + cascade assets |
POST |
/projects/:id/members |
Add member. Body: { userId, role } |
DELETE |
/projects/:id/members/:userId |
Remove member |
PUT |
/projects/:id/members/:userId |
Change member role |
Assets
| Method |
Endpoint |
Description |
GET |
/assets?projectId= |
List assets by project |
GET |
/assets/:id |
Asset detail with comments |
GET |
/assets/:id/status |
SSE stream of transcode progress |
POST |
/assets/upload |
Upload video (multipart). Body: { title, projectId } |
PUT |
/assets/:id/status |
Update review status (PENDING_REVIEW/CHANGES_REQUESTED/APPROVED/REJECTED) |
POST |
/assets/:id/transcode/cancel |
Cancel active transcode → re-queue |
DELETE |
/assets/:id |
Delete asset + HLS directory |
Comments
| Method |
Endpoint |
Description |
GET |
/assets/:assetId/comments |
List comments for asset |
POST |
/assets/:assetId/comments |
Create comment. Body: { content, timestamp?, annotations? } |
PUT |
/comments/:id/resolve |
Toggle resolved |
PUT |
/comments/:id/annotations |
Update annotations |
DELETE |
/comments/:id |
Delete comment |
Invitations
| Method |
Endpoint |
Description |
GET |
/invitations/:token |
Verify invite token (public) |
POST |
/invitations/:token/accept |
Accept invite (auth required) |
GET |
/invitations/project/:projectId |
List pending invites |
POST |
/invitations/project/:projectId |
Create invite. Body: { email, role } |
DELETE |
/invitations/:id |
Revoke pending invite |
POST |
/invitations/project/:projectId/resend |
Resend existing invite |
Users (Admin)
| Method |
Endpoint |
Description |
GET |
/users |
List all users (ADMIN only) |
PUT |
/users/:id/role |
Change global role (ADMIN/MEMBER) |
PUT |
/users/:id/active |
Enable/disable user |
DELETE |
/users/:id |
Delete user |
Video Transcode Pipeline
Upload (multer)
│
▼
PENDING ──────► UPLOADING (multer writes file)
│ │
│ ▼
│ PROCESSING (worker claims job)
│ │
│ ┌───────────┼───────────┐
│ ▼ ▼ ▼
│ ffprobe thumbnail HLS encode
│ (metadata) (JPEG) (720p H.264/AAC)
│ │
│ ▼
└────────── COMPLETED ◄────�
or
UNSUPPORTED_CODEC (auto-detected → re-encode)
or
FAILED (error logged in transcodeError)
HLS output: 720p, H.264/aac, CRF 23, preset fast, 6-second segments → /hls/{assetId}/master.m3u8
Worker: DB-as-queue pattern. Polls every 2s using FOR UPDATE SKIP LOCKED for atomic job claiming. Recovers stale PROCESSING jobs on startup. Graceful shutdown on SIGTERM.
Role Permissions
Global Roles
| Action |
ADMIN |
MEMBER |
| User management |
✓ |
✗ |
| View all users |
✓ |
✗ |
Project Roles
| Action |
ADMIN |
EDITOR |
REVIEWER |
VIEWER |
| Upload videos |
✓ |
✓ |
✗ |
✗ |
| Delete videos |
✓ |
✓ |
✗ |
✗ |
| Cancel/requeue transcode |
✓ |
✓ |
✗ |
✗ |
| Post comments |
✓ |
✓ |
✓ |
✗ |
| Resolve comments |
✓ |
✓ |
✓ |
✗ |
| Change project settings |
✓ |
✗ |
✗ |
✗ |
| Manage members |
✓ |
✗ |
✗ |
✗ |
Deployment
Prerequisites
- Docker & Docker Compose
- FFmpeg (bundled in API/worker Docker image)
- PostgreSQL client (optional, for debugging)
Quick Start
# 1. Clone and enter project
cd vidreview
# 2. Configure environment
cp .env.example .env
# Edit .env with your production values (see Environment Variables below)
# 3. Start all services
sudo docker compose up -d
# 4. Verify health
sudo docker compose ps
# All containers should show "healthy" or "Up"
On a fresh deploy, the init container runs automatically and creates an admin account.
Credentials are saved to /seed-output/admin-credentials.txt inside the seed_output Docker volume.
Read them with:
sudo docker compose run --rm --entrypoint "cat /seed-output/admin-credentials.txt" init
On an update deploy, the init container skips admin creation silently.
Production Deployment Checklist
Updating
# Pull latest code and rebuild
git pull
sudo docker compose build
sudo docker compose up -d
Container Logs
# All services
sudo docker compose logs -f
sudo docker compose logs -f
# API only
sudo docker logs vidreview-api -f
# Worker only (transcode jobs)
sudo docker logs vidreview-worker -f
# Frontend
sudo docker logs vidreview-frontend -f
Database Access
# Interactive psql
sudo docker exec -it vidreview-db psql -U vidreview -d vidreview
# Run migrations
sudo docker exec vidreview-api npx prisma db push
# Reset database (⚠️ destroys all data)
sudo docker exec vidreview-db psql -U vidreview -d vidreview -c "DROP SCHEMA public CASCADE; CREATE SCHEMA public;"
sudo docker exec vidreview-api npx prisma db push
Design System
Dark theme, indigo brand (#6366F1), CSS custom properties.
CSS Variables
--bg: #0B0B14 /* Page background */
--bg-card: #13131F /* Card / modal background */
--bg-input: #1A1A2E /* Input field background */
--border: rgba(255,255,255,0.08)
--text: #E2E8F0 /* Primary text */
--text-muted: #94A3B8 /* Secondary / muted text */
--text-subtle: #64748B /* Placeholder / disabled */
--accent: #6366F1 /* Brand indigo */
--accent-hover: #4F46E5
Typography
| Element |
Size |
Weight |
Color |
| H1 (page title) |
24px |
600 |
--text |
| H2 (section) |
18px |
600 |
--text |
| Body |
14px |
400 |
--text |
| Caption / meta |
12px |
400 |
--text-muted |
| Badge |
11px |
500 |
varies |
Development
Local Development (without Docker)
# Terminal 1 — API
npm run dev:api
# Terminal 2 — Frontend
npm run dev:frontend
# Database must be accessible at DATABASE_URL
# Uploads served at localhost:3001/uploads/
Adding a New API Route
- Create the route file in
packages/api/src/routes/
- Register it in
packages/api/src/index.ts
- Add a typed helper to
src/lib/api.ts
- Update this README's API Reference table
Adding a New Frontend Page
- Create
src/app/(dashboard)/<path>/page.tsx
- Wrap with
'use client'
- Use
useAuth() from @/lib/auth-context for auth guard
- Redirect if not authenticated:
if (!user) { router.push('/login'); return; }
Environment Variables
All secrets are read from .env — never hardcoded in docker-compose.yml.
Copy .env.example → .env and fill in your values before deploying.
| Variable |
Required |
Description |
POSTGRES_USER |
Yes |
PostgreSQL username |
POSTGRES_PASSWORD |
Yes |
PostgreSQL password |
POSTGRES_DB |
Yes |
PostgreSQL database name |
DATABASE_URL |
Yes |
Full PostgreSQL connection string |
JWT_SECRET |
Yes |
JWT signing secret — generate with openssl rand -hex 64 |
JWT_EXPIRES_IN |
No |
JWT expiry (default: 7d) |
API_PORT |
No |
API server port (default: 3001) |
NODE_ENV |
No |
production or development (default: production) |
UPLOAD_DIR |
No |
Upload directory in API container (default: /app/uploads) |
MAX_FILE_SIZE_MB |
No |
Max upload file size in MB (default: 500) |
ALLOWED_ORIGINS |
No |
Comma-separated CORS origins (default: empty = block all) |
FRONTEND_URL |
No |
Public URL of the frontend |
NEXT_PUBLIC_API_URL |
No |
API base URL for frontend (default: https://vid.k9tech.space/api) |
RESEND_API_KEY |
No |
Resend API key for invite emails (optional, leave blank to disable) |
ADMIN_EMAIL |
No |
Admin account email on fresh deploy (default: admin@vidreview.local) |
ADMIN_NAME |
No |
Admin account display name (default: Admin) |
CADDY_HTTP_PORT |
No |
Caddy HTTP port on host (default: 80) |
CADDY_HTTPS_PORT |
No |
Caddy HTTPS port on host (default: 443) |
POLL_INTERVAL_MS |
No |
Worker poll interval in ms (default: 2000) |