5 Commity 34a932758b ... 813023ea04

Autor SHA1 Wiadomość Data
  kingkong 813023ea04 feat: video player buffer, bitrate display, upload queue, and hook fixes 1 miesiąc temu
  kingkong 8c9eb194c2 fix: global BigInt → Number serialization for all Express JSON responses 1 miesiąc temu
  kingkong d3a714c8b4 chore: align Caddy port (8080), add DB_CONTAINER env, improve init script 1 miesiąc temu
  kingkong 644d5b229b fix: change storageQuota/storageUsed/fileSize from Int to BigInt 1 miesiąc temu
  kingkong 57fcd7c7cd fix: quota auto-unit GB/MB, improve copy invite link, UI refinements 1 miesiąc temu

+ 62 - 0
.claude/settings.json

@@ -0,0 +1,62 @@
+{
+  "hooks": {
+    "Setup": [
+      {
+        "matcher": "*",
+        "hooks": [
+          {
+            "type": "command",
+            "command": "_R=\"${CLAUDE_PLUGIN_ROOT}\"; [ -z \"$_R\" ] && _R=$(ls -dt $HOME/.claude/plugins/cache/thedotmack/claude-mem/[0-9]*/ 2>/dev/null | head -1); _R=\"${_R%/}\"; [ -z \"$_R\" ] && _R=\"$HOME/.claude/plugins/marketplaces/thedotmack/plugin\"; \"$_R/scripts/setup.sh\"",
+            "timeout": 300
+          }
+        ]
+      }
+    ],
+    "SessionStart": [
+      {
+        "matcher": "",
+        "hooks": [
+          {
+            "type": "command",
+            "command": "export PATH=\"$HOME/.nvm/versions/node/v$(ls \\\"$HOME/.nvm/versions/node\\\" 2>/dev/null | sed 's/^v//' | sort -t. -k1,1n -k2,2n -k3,3n | tail -1)/bin:$HOME/.local/bin:/usr/local/bin:/opt/homebrew/bin:$PATH\"; _R=\"${CLAUDE_PLUGIN_ROOT}\"; [ -z \"$_R\" ] && _R=$(ls -dt $HOME/.claude/plugins/cache/thedotmack/claude-mem/[0-9]*/ 2>/dev/null | head -1); _R=\"${_R%/}\"; [ -z \"$_R\" ] && _R=\"$HOME/.claude/plugins/marketplaces/thedotmack/plugin\"; node \"$_R/scripts/bun-runner.js\" \"$_R/scripts/worker-service.cjs\" start; for i in 1 2 3 4 5 6 7 8; do curl -sf http://localhost:37777/health >/dev/null 2>&1 && break; sleep 1; done; curl -sf http://localhost:37777/health >/dev/null 2>&1 || exit 1; echo '{\"continue\":true,\"suppressOutput\":true}'",
+            "timeout": 60
+          },
+          {
+            "type": "command",
+            "command": "export PATH=\"$HOME/.nvm/versions/node/v$(ls \\\"$HOME/.nvm/versions/node\\\" 2>/dev/null | sed 's/^v//' | sort -t. -k1,1n -k2,2n -k3,3n | tail -1)/bin:$HOME/.local/bin:/usr/local/bin:/opt/homebrew/bin:$PATH\"; _R=\"${CLAUDE_PLUGIN_ROOT}\"; [ -z \"$_R\" ] && _R=$(ls -dt $HOME/.claude/plugins/cache/thedotmack/claude-mem/[0-9]*/ 2>/dev/null | head -1); _R=\"${_R%/}\"; [ -z \"$_R\" ] && _R=\"$HOME/.claude/plugins/marketplaces/thedotmack/plugin\"; for i in 1 2 3 4 5 6 7 8; do curl -sf http://localhost:37777/health >/dev/null 2>&1 && break; sleep 1; done; node \"$_R/scripts/bun-runner.js\" \"$_R/scripts/worker-service.cjs\" hook claude-code context",
+            "timeout": 60
+          },
+          {
+            "type": "command",
+            "command": "code-review-graph status",
+            "timeout": 10
+          }
+        ]
+      }
+    ],
+    "PostToolUse": [
+      {
+        "matcher": "*",
+        "hooks": [
+          {
+            "type": "command",
+            "command": "code-review-graph update --skip-flows",
+            "timeout": 30
+          }
+        ]
+      }
+    ],
+    "PreToolUse": [
+      {
+        "matcher": "Read",
+        "hooks": [
+          {
+            "type": "command",
+            "command": "_R=\"${CLAUDE_PLUGIN_ROOT}\"; [ -z \"$_R\" ] && _R=\"$HOME/.claude/plugins/marketplaces/thedotmack/plugin\"; node \"$_R/scripts/bun-runner.js\" \"$_R/scripts/worker-service.cjs\" hook claude-code file-context",
+            "timeout": 2000
+          }
+        ]
+      }
+    ]
+  }
+}

+ 27 - 0
.claude/skills/debug-issue.md

@@ -0,0 +1,27 @@
+---
+name: Debug Issue
+description: Systematically debug issues using graph-powered code navigation
+---
+
+## Debug Issue
+
+Use the knowledge graph to systematically trace and debug issues.
+
+### Steps
+
+1. Use `semantic_search_nodes` to find code related to the issue.
+2. Use `query_graph` with `callers_of` and `callees_of` to trace call chains.
+3. Use `get_flow` to see full execution paths through suspected areas.
+4. Run `detect_changes` to check if recent changes caused the issue.
+5. Use `get_impact_radius` on suspected files to see what else is affected.
+
+### Tips
+
+- Check both callers and callees to understand the full context.
+- Look at affected flows to find the entry point that triggers the bug.
+- Recent changes are the most common source of new issues.
+
+## Token Efficiency Rules
+- ALWAYS start with `get_minimal_context(task="<your task>")` before any other graph tool.
+- Use `detail_level="minimal"` on all calls. Only escalate to "standard" when minimal is insufficient.
+- Target: complete any review/debug/refactor task in ≤5 tool calls and ≤800 total output tokens.

+ 28 - 0
.claude/skills/explore-codebase.md

@@ -0,0 +1,28 @@
+---
+name: Explore Codebase
+description: Navigate and understand codebase structure using the knowledge graph
+---
+
+## Explore Codebase
+
+Use the code-review-graph MCP tools to explore and understand the codebase.
+
+### Steps
+
+1. Run `list_graph_stats` to see overall codebase metrics.
+2. Run `get_architecture_overview` for high-level community structure.
+3. Use `list_communities` to find major modules, then `get_community` for details.
+4. Use `semantic_search_nodes` to find specific functions or classes.
+5. Use `query_graph` with patterns like `callers_of`, `callees_of`, `imports_of` to trace relationships.
+6. Use `list_flows` and `get_flow` to understand execution paths.
+
+### Tips
+
+- Start broad (stats, architecture) then narrow down to specific areas.
+- Use `children_of` on a file to see all its functions and classes.
+- Use `find_large_functions` to identify complex code.
+
+## Token Efficiency Rules
+- ALWAYS start with `get_minimal_context(task="<your task>")` before any other graph tool.
+- Use `detail_level="minimal"` on all calls. Only escalate to "standard" when minimal is insufficient.
+- Target: complete any review/debug/refactor task in ≤5 tool calls and ≤800 total output tokens.

+ 28 - 0
.claude/skills/refactor-safely.md

@@ -0,0 +1,28 @@
+---
+name: Refactor Safely
+description: Plan and execute safe refactoring using dependency analysis
+---
+
+## Refactor Safely
+
+Use the knowledge graph to plan and execute refactoring with confidence.
+
+### Steps
+
+1. Use `refactor_tool` with mode="suggest" for community-driven refactoring suggestions.
+2. Use `refactor_tool` with mode="dead_code" to find unreferenced code.
+3. For renames, use `refactor_tool` with mode="rename" to preview all affected locations.
+4. Use `apply_refactor_tool` with the refactor_id to apply renames.
+5. After changes, run `detect_changes` to verify the refactoring impact.
+
+### Safety Checks
+
+- Always preview before applying (rename mode gives you an edit list).
+- Check `get_impact_radius` before major refactors.
+- Use `get_affected_flows` to ensure no critical paths are broken.
+- Run `find_large_functions` to identify decomposition targets.
+
+## Token Efficiency Rules
+- ALWAYS start with `get_minimal_context(task="<your task>")` before any other graph tool.
+- Use `detail_level="minimal"` on all calls. Only escalate to "standard" when minimal is insufficient.
+- Target: complete any review/debug/refactor task in ≤5 tool calls and ≤800 total output tokens.

+ 29 - 0
.claude/skills/review-changes.md

@@ -0,0 +1,29 @@
+---
+name: Review Changes
+description: Perform a structured code review using change detection and impact
+---
+
+## Review Changes
+
+Perform a thorough, risk-aware code review using the knowledge graph.
+
+### Steps
+
+1. Run `detect_changes` to get risk-scored change analysis.
+2. Run `get_affected_flows` to find impacted execution paths.
+3. For each high-risk function, run `query_graph` with pattern="tests_for" to check test coverage.
+4. Run `get_impact_radius` to understand the blast radius.
+5. For any untested changes, suggest specific test cases.
+
+### Output Format
+
+Provide findings grouped by risk level (high/medium/low) with:
+- What changed and why it matters
+- Test coverage status
+- Suggested improvements
+- Overall merge recommendation
+
+## Token Efficiency Rules
+- ALWAYS start with `get_minimal_context(task="<your task>")` before any other graph tool.
+- Use `detail_level="minimal"` on all calls. Only escalate to "standard" when minimal is insufficient.
+- Target: complete any review/debug/refactor task in ≤5 tool calls and ≤800 total output tokens.

+ 3 - 0
.gitignore

@@ -42,3 +42,6 @@ Thumbs.db
 dist/
 build/
 *.tsbuildinfo
+# Added by code-review-graph
+.code-review-graph/
+swap-key.sh

+ 38 - 0
CLAUDE.md

@@ -0,0 +1,38 @@
+<!-- code-review-graph MCP tools -->
+## MCP Tools: code-review-graph
+
+**IMPORTANT: This project has a knowledge graph. ALWAYS use the
+code-review-graph MCP tools BEFORE using Grep/Glob/Read to explore
+the codebase.** The graph is faster, cheaper (fewer tokens), and gives
+you structural context (callers, dependents, test coverage) that file
+scanning cannot.
+
+### When to use graph tools FIRST
+
+- **Exploring code**: `semantic_search_nodes` or `query_graph` instead of Grep
+- **Understanding impact**: `get_impact_radius` instead of manually tracing imports
+- **Code review**: `detect_changes` + `get_review_context` instead of reading entire files
+- **Finding relationships**: `query_graph` with callers_of/callees_of/imports_of/tests_for
+- **Architecture questions**: `get_architecture_overview` + `list_communities`
+
+Fall back to Grep/Glob/Read **only** when the graph doesn't cover what you need.
+
+### Key Tools
+
+| Tool | Use when |
+|------|----------|
+| `detect_changes` | Reviewing code changes — gives risk-scored analysis |
+| `get_review_context` | Need source snippets for review — token-efficient |
+| `get_impact_radius` | Understanding blast radius of a change |
+| `get_affected_flows` | Finding which execution paths are impacted |
+| `query_graph` | Tracing callers, callees, imports, tests, dependencies |
+| `semantic_search_nodes` | Finding functions/classes by name or keyword |
+| `get_architecture_overview` | Understanding high-level codebase structure |
+| `refactor_tool` | Planning renames, finding dead code |
+
+### Workflow
+
+1. The graph auto-updates on file changes (via hooks).
+2. Use `detect_changes` for code review.
+3. Use `get_affected_flows` to understand impact.
+4. Use `query_graph` pattern="tests_for" to check coverage.

+ 11 - 1
Caddyfile

@@ -5,7 +5,17 @@
 
 # Catch-all: Synology forwards decrypted HTTP traffic to this port.
 # Route based on path regardless of Host header.
-:80 {
+:8080 {
+    # HLS segments, thumbnails, uploaded raw files
+    @uploads {
+        path /uploads/*
+    }
+    handle @uploads {
+        uri strip_prefix /uploads
+        root * /app/uploads
+        file_server
+    }
+
     @api path /api/*
     handle @api {
         reverse_proxy api:3001 {

+ 2 - 0
Dockerfile.frontend

@@ -11,6 +11,8 @@ COPY src/ ./
 
 # Build Next.js — relative API URLs (/api/...) via Next.js rewrites
 ENV NEXT_TELEMETRY_DISABLED=1
+ARG NEXT_PUBLIC_API_URL
+ENV NEXT_PUBLIC_API_URL=$NEXT_PUBLIC_API_URL
 RUN npm run build
 
 # Production image

+ 9 - 3
docker-compose.yml

@@ -31,6 +31,7 @@ services:
       DB_USER: ${POSTGRES_USER:-vidreview}
       DB_PASS: ${POSTGRES_PASSWORD:?Required}
       API_CONTAINER: vidreview-api
+      DB_CONTAINER: vidreview-db
       OUTPUT_DIR: /seed-output
       ADMIN_EMAIL: ${ADMIN_EMAIL:-admin@vidreview.local}
       ADMIN_NAME: ${ADMIN_NAME:-Admin}
@@ -59,7 +60,7 @@ services:
       API_PORT: 3001
       NODE_ENV: ${NODE_ENV:-production}
       UPLOAD_DIR: /app/uploads
-      MAX_FILE_SIZE_MB: ${MAX_FILE_SIZE_MB:-500}
+      MAX_FILE_SIZE_MB: ${MAX_FILE_SIZE_MB:-10000}
       ALLOWED_ORIGINS: ${ALLOWED_ORIGINS}
       FRONTEND_URL: ${FRONTEND_URL}
       RESEND_API_KEY: ${RESEND_API_KEY:-}
@@ -88,6 +89,8 @@ services:
       NODE_ENV: ${NODE_ENV:-production}
       UPLOAD_DIR: /app/uploads
       POLL_INTERVAL_MS: ${POLL_INTERVAL_MS:-2000}
+      WORKER_CONCURRENCY: ${WORKER_CONCURRENCY:-4}
+      ENCODER: ${ENCODER:-libx264}
     depends_on:
       postgres:
         condition: service_healthy
@@ -100,12 +103,13 @@ services:
     image: caddy:2-alpine
     container_name: vidreview-caddy
     ports:
-      - '${CADDY_HTTP_PORT:-80}:80'
-      - '${CADDY_HTTPS_PORT:-443}:443'
+      - '${CADDY_HTTP_PORT:-8080}:8080'
+      - '${CADDY_HTTPS_PORT:-8443}:8443'
     volumes:
       - ./Caddyfile:/etc/caddy/Caddyfile:ro
       - caddy_data:/data
       - caddy_config:/config
+      - uploads:/app/uploads
     depends_on:
       - frontend
       - api
@@ -115,6 +119,8 @@ services:
     build:
       context: .
       dockerfile: Dockerfile.frontend
+      args:
+        NEXT_PUBLIC_API_URL: ${NEXT_PUBLIC_API_URL}
     container_name: vidreview-frontend
     environment:
       NEXT_PUBLIC_API_URL: ${NEXT_PUBLIC_API_URL}

+ 4 - 3
packages/api/prisma/schema.prisma

@@ -18,8 +18,8 @@ model User {
   avatarUrl     String?
   globalRole    GlobalRole @default(MEMBER)
   active        Boolean   @default(true)
-  storageQuota  Int        @default(524288000) // 500 MB in bytes
-  storageUsed   Int        @default(0)         // bytes consumed
+  storageQuota  BigInt     @default(524288000) // 500 MB in bytes
+  storageUsed   BigInt     @default(0)         // bytes consumed
   createdAt     DateTime  @default(now())
   updatedAt     DateTime  @updatedAt
 
@@ -83,7 +83,8 @@ model Asset {
   fps             Float           @default(30)
   codec           String?
   mimeType        String
-  fileSize        Int              @default(0)   // raw video file size in bytes
+  fileSize        BigInt           @default(0)   // raw video file size in bytes
+  bitrate         BigInt           @default(0)   // video bitrate in bits/s
   status          AssetStatus     @default(PENDING_REVIEW)
   transcodeStatus TranscodeStatus @default(PENDING)
   transcodeProgress Int            @default(0)

+ 13 - 0
packages/api/src/index.ts

@@ -51,6 +51,12 @@ app.use('/api/settings', settingsRoutes);
 app.use('/api/share', shareRoutes);
 app.use('/api/folders', folderRoutes);
 
+// ── BigInt-safe res.json() — patch Response prototype ─────────────────────────
+// Adding toJSON to BigInt prototype auto-converts BigInt → Number in JSON.stringify
+// This works because Express uses JSON.stringify internally for res.json()
+// eslint-disable-next-line @typescript-eslint/no-explicit-any
+(BigInt.prototype as any).toJSON = function () { return Number(this); };
+
 // ── 404 handler ─────────────────────────────────────────────────────────────
 app.use((_req, res) => {
   res.status(404).json({ error: 'Not found' });
@@ -59,6 +65,13 @@ app.use((_req, res) => {
 // ── Error handler ─────────────────────────────────────────────────────────────
 app.use((err: Error, _req: express.Request, res: express.Response, _next: express.NextFunction) => {
   console.error('Unhandled error:', err);
+
+  // Multer file size errors
+  if ((err as any).code === 'LIMIT_FILE_SIZE') {
+    res.status(413).json({ error: 'File too large. Maximum allowed size is ' + process.env.MAX_FILE_SIZE_MB + 'MB.' });
+    return;
+  }
+
   const status = (err as any).statusCode ?? 500;
   const message = (err as any).statusCode ? err.message : 'Internal server error';
   res.status(status).json({ error: message });

+ 18 - 4
packages/api/src/lib/prisma.ts

@@ -10,9 +10,23 @@ export const prisma =
     log: process.env.NODE_ENV === 'development' ? ['query', 'error', 'warn'] : ['error'],
   });
 
-if (process.env.NODE_ENV !== 'production') {
-  globalForPrisma.prisma = prisma;
-}
-
 // Re-export for convenience
 export { TranscodeStatus, ResolveStatus };
+
+// ── BigInt serialization helper ────────────────────────────────────────────────
+// Recursively converts all BigInt values in an object/array to Number.
+// Safe to pass through Prisma results before sending JSON responses.
+// eslint-disable-next-line @typescript-eslint/no-explicit-any
+export function bigintToNumber(val: unknown): any {
+  if (val === null || val === undefined) return val;
+  if (typeof val === 'bigint') return Number(val);
+  if (Array.isArray(val)) return val.map(bigintToNumber);
+  if (typeof val === 'object') {
+    const result: Record<string, unknown> = {};
+    for (const [k, v] of Object.entries(val)) {
+      result[k] = bigintToNumber(v);
+    }
+    return result;
+  }
+  return val;
+}

+ 15 - 6
packages/api/src/routes/assets.ts

@@ -221,7 +221,7 @@ router.post('/upload', upload.single('video'), async (req: Request, res: Respons
       return;
     }
 
-    const { projectId, title } = req.body;
+    const { projectId, title, folderId } = req.body;
 
     if (!projectId) {
       res.status(400).json({ error: 'projectId is required' });
@@ -247,12 +247,12 @@ router.post('/upload', upload.single('video'), async (req: Request, res: Respons
       return;
     }
 
-    const fileSize = req.file.size;
+    const fileSize = BigInt(req.file.size);
     if (uploader.storageUsed + fileSize > uploader.storageQuota) {
       fs.unlinkSync(req.file.path);
-      const usedMB   = (uploader.storageUsed / 1024 / 1024).toFixed(1);
-      const quotaMB   = (uploader.storageQuota / 1024 / 1024).toFixed(1);
-      const fileMB    = (fileSize / 1024 / 1024).toFixed(1);
+      const usedMB   = (Number(uploader.storageUsed) / 1024 / 1024).toFixed(1);
+      const quotaMB   = (Number(uploader.storageQuota) / 1024 / 1024).toFixed(1);
+      const fileMB    = (Number(fileSize) / 1024 / 1024).toFixed(1);
       res.status(507).json({
         error: `Storage quota exceeded. Used: ${usedMB} MB / ${quotaMB} MB. File size: ${fileMB} MB.`,
       });
@@ -283,6 +283,15 @@ router.post('/upload', upload.single('video'), async (req: Request, res: Respons
       data: { storageUsed: { increment: fileSize } },
     }).catch(() => { /* user may have been deleted, ignore */ });
 
+    // Link asset to folder if provided
+    if (folderId && typeof folderId === 'string') {
+      await prisma.folderAsset.upsert({
+        where: { folderId_assetId: { folderId, assetId: asset.id } },
+        create: { folderId, assetId: asset.id },
+        update: {},
+      }).catch(() => { /* folder may not exist, ignore */ });
+    }
+
     // Fork worker (non-blocking) — no await, runs in background
     startTranscodeJob({
       assetId: asset.id,
@@ -487,7 +496,7 @@ router.delete('/:id', async (req: Request, res: Response) => {
     }
 
     // Decrement uploader's storageUsed
-    if (asset.fileSize > 0 && asset.uploaderId) {
+    if (BigInt(asset.fileSize) > BigInt(0) && asset.uploaderId) {
       await prisma.user.update({
         where: { id: asset.uploaderId },
         data: { storageUsed: { decrement: asset.fileSize } },

+ 4 - 4
packages/api/src/routes/auth.ts

@@ -2,7 +2,7 @@ import { Router, Request, Response } from 'express';
 import bcrypt from 'bcryptjs';
 import jwt from 'jsonwebtoken';
 import rateLimit from 'express-rate-limit';
-import { prisma } from '../lib/prisma';
+import { prisma, bigintToNumber } from '../lib/prisma';
 import { authMiddleware } from '../lib/auth';
 
 const router = Router();
@@ -221,7 +221,7 @@ router.post('/login', authRateLimiter, async (req: Request, res: Response) => {
     });
 
     res.json({
-      user: {
+      user: bigintToNumber({
         id: user.id,
         email: user.email,
         name: user.name,
@@ -229,7 +229,7 @@ router.post('/login', authRateLimiter, async (req: Request, res: Response) => {
         avatarUrl: user.avatarUrl,
         storageQuota: user.storageQuota,
         storageUsed: user.storageUsed ?? 0,
-      },
+      }),
       token,
       acceptedProjects,
     });
@@ -261,7 +261,7 @@ router.get('/me', authMiddleware, async (req: Request, res: Response) => {
       return;
     }
 
-    res.json({ user: { ...user, storageUsed: user.storageUsed ?? 0 } });
+    res.json({ user: bigintToNumber({ ...user, storageUsed: user.storageUsed ?? 0 }) });
   } catch (err) {
     console.error('Me error:', err);
     res.status(500).json({ error: 'Internal server error' });

+ 4 - 8
packages/api/src/routes/users.ts

@@ -3,7 +3,7 @@ import bcrypt from 'bcryptjs';
 import multer from 'multer';
 import path from 'path';
 import { v4 as uuidv4 } from 'uuid';
-import { prisma } from '../lib/prisma';
+import { prisma, bigintToNumber } from '../lib/prisma';
 import { authMiddleware } from '../lib/auth';
 
 const router = Router();
@@ -62,13 +62,13 @@ router.get('/', async (req: Request, res: Response) => {
     const usersWithStorage = users.map(u => ({
       ...u,
       storageUsed: u.projects.reduce(
-        (sum, p) => sum + p.assets.reduce((s, a) => s + a.fileSize, 0),
+        (sum, p) => sum + p.assets.reduce((s, a) => s + Number(a.fileSize), 0),
         0
       ),
       ownedProjects: u.projects.length,
     }));
 
-    res.json({ users: usersWithStorage });
+    res.json({ users: usersWithStorage.map(bigintToNumber) });
   } catch (err) {
     console.error('List users error:', err);
     res.status(500).json({ error: 'Internal server error' });
@@ -146,11 +146,7 @@ router.put('/:id/quota', async (req: Request, res: Response) => {
     });
 
     res.json({
-      user: {
-        ...user,
-        storageQuota,
-        storageUsed: user.storageUsed ?? 0,
-      },
+      user: bigintToNumber({ ...user, storageQuota, storageUsed: user.storageUsed ?? BigInt(0) }),
     });
   } catch (err) {
     console.error('Update quota error:', err);

+ 269 - 121
packages/api/src/worker/index.js

@@ -3,142 +3,292 @@
 /**
  * Transcode Worker Service
  * ─────────────────────────────────────────────────────
- * Standalone Node.js process (runs in its own Docker service).
- * Polls the database for pending transcode jobs.
+ * Runs in its own Docker service. Uses the `cluster` module to
+ * fork N concurrent worker processes (N = WORKER_CONCURRENCY, default 4).
+ *
+ * Each forked worker independently polls the DB using FOR UPDATE SKIP LOCKED,
+ * so no coordination is needed — the DB acts as the queue.
  *
  * DB-as-queue pattern:
  *   1. API creates asset → transcodeStatus = PENDING
- *   2. Worker polls → atomically claims one PENDING job (UPDATE ... WHERE status=PENDING)
+ *   2. Workers poll → atomically claim one PENDING job each
  *   3. Worker processes thumbnail + HLS → updates DB
  *   4. Repeat
  *
- * No external queue needed — uses the existing PostgreSQL database.
+ * Stream-copy optimization: if source is already H.264+AAC in MP4/MOV container,
+ * it is remuxed (not re-encoded) which is near-instant.
+ *
+ * Hardware encoding: set ENCODER env var to h264_nvenc | h264_qsv | h264_videotoolbox
  */
 const { PrismaClient } = require('@prisma/client');
 const ffmpeg = require('fluent-ffmpeg');
 const path = require('path');
 const fs = require('fs');
+const cluster = require('cluster');
 
 const UPLOAD_DIR = process.env.UPLOAD_DIR || '/app/uploads';
-const POLL_INTERVAL_MS = parseInt(process.env.POLL_INTERVAL_MS || '5000', 10);
+const POLL_INTERVAL_MS = parseInt(process.env.POLL_INTERVAL_MS || '2000', 10);
+const BACKOFF_MS = parseInt(process.env.BACKOFF_MS || '1000', 10); // idle sleep between polls
+const WORKER_CONCURRENCY = parseInt(process.env.WORKER_CONCURRENCY || '4', 10);
+const ENCODER = process.env.ENCODER || 'libx264'; // libx264 | h264_nvenc | h264_qsv | h264_videotoolbox
+
+// ─── Master: fork N workers and manage them ────────────────────────────────
+if (cluster.isMaster) {
+  console.log(`[master] Starting ${WORKER_CONCURRENCY} transcode workers...`);
+  console.log(`[master] UPLOAD_DIR: ${UPLOAD_DIR}`);
+  console.log(`[master] ENCODER: ${ENCODER}`);
+  console.log(`[master] Poll interval: ${POLL_INTERVAL_MS}ms`);
+
+  fs.mkdirSync(UPLOAD_DIR, { recursive: true });
+
+  cluster.on('exit', (worker, code, signal) => {
+    console.log(`[master] Worker ${worker.id} exited (${code || signal}), restarting...`);
+    setTimeout(() => cluster.fork(), 2000);
+  });
+
+  for (let i = 0; i < WORKER_CONCURRENCY; i++) {
+    cluster.fork({ WORKER_ID: String(i + 1) });
+  }
+
+  cluster.on('online', (worker) => {
+    console.log(`[master] Worker ${worker.id} online`);
+  });
 
+  process.on('SIGTERM', () => {
+    console.log('[master] SIGTERM — killing all workers...');
+    for (const id in cluster.workers) cluster.workers[id].kill('SIGTERM');
+    process.exit(0);
+  });
+}
+
+// ─── Worker: each process runs its own poll loop ─────────────────────────
 const prisma = new PrismaClient({
   datasources: { db: { url: process.env.DATABASE_URL } },
 });
 
-/** ── Helpers ──────────────────────────────────────────────────────────────── */
-function send(type, data) {
-  const msg = JSON.stringify({ type, ...data, ts: new Date().toISOString() });
+const WORKER_ID = process.env.WORKER_ID || '?';
+
+function log(type, data) {
+  const msg = JSON.stringify({ type, ...data, worker: WORKER_ID, ts: new Date().toISOString() });
   process.send && process.send(msg);
-  console.log(`[worker] ${type}`, JSON.stringify(data));
+  console.log(`[worker:${WORKER_ID}] ${type}`, JSON.stringify(data));
 }
 
-function sleep(ms) {
-  return new Promise(r => setTimeout(r, ms));
+function sleep(ms) { return new Promise(r => setTimeout(r, ms)); }
+
+/** Probe container + codec + bitrate (used for stream-copy decision) */
+function probeContainer(videoPath) {
+  return new Promise((resolve) => {
+    ffmpeg.ffprobe(videoPath, (err, metadata) => {
+      if (err) return resolve({ container: null, videoCodec: null, audioCodec: null, duration: null, bitrate: null });
+      const streams = metadata?.streams || [];
+      const videoStream = streams.find(s => s.codec_type === 'video');
+      const audioStream = streams.find(s => s.codec_type === 'audio');
+      // bit_rate from video stream is in bits/s; format bit_rate is overall in bits/s
+      const bitrate = videoStream?.bit_rate
+        ? parseInt(videoStream.bit_rate)
+        : (metadata?.format?.bit_rate ? parseInt(metadata.format.bit_rate) : null);
+      resolve({
+        container: metadata?.format?.format_name || null,
+        videoCodec: videoStream?.codec_name || null,
+        audioCodec: audioStream?.codec_name || null,
+        duration: metadata?.format?.duration || null,
+        bitrate,
+      });
+    });
+  });
+}
+
+/** Returns true if source can be stream-copied (remuxed, no re-encode) */
+function canStreamCopy(probe) {
+  if (!probe) return false;
+  const c = probe.container || '';
+  const mp4Container = /^(mov|mp4|m4a|quicktime)$/.test(c) || c.includes('mov') || c.includes('mp4');
+  return (
+    mp4Container &&
+    (probe.videoCodec === 'h264' || probe.videoCodec === 'hevc') &&
+    (probe.audioCodec === 'aac' || probe.audioCodec === 'mp3' || probe.audioCodec === 'ac3' || probe.audioCodec === null)
+  );
 }
 
-/** ── Thumbnail ──────────────────────────────────────────────────────────── */
+/** Get encoder-specific ffmpeg output options */
+function getEncoderOptions(encoder, duration) {
+  const opts = [];
+
+  if (encoder === 'h264_nvenc') {
+    // NVIDIA GPU encoder
+    opts.push(
+      '-c:v', 'h264_nvenc',
+      '-preset', 'p4',         // p1=fastest … p7=slowest
+      '-rc', 'vbr', '-cq', '23',
+      '-gpu', '0',
+    );
+  } else if (encoder === 'h264_qsv') {
+    // Intel Quick Sync Video
+    opts.push(
+      '-c:v', 'h264_qsv',
+      '-preset', 'fast',
+      '-global_quality', '23',
+    );
+  } else if (encoder === 'h264_videotoolbox') {
+    // Apple VideoToolbox (macOS)
+    opts.push(
+      '-c:v', 'h264_videotoolbox',
+      '-realtime',
+    );
+  } else {
+    // Default: software libx264
+    opts.push(
+      '-c:v', 'libx264',
+      '-preset', 'fast',
+      '-crf', '23',
+    );
+  }
+
+  opts.push('-pix_fmt', 'yuv420p');
+  opts.push('-c:a', 'aac', '-b:a', '128k');
+  return opts;
+}
+
+/** ── Thumbnail + metadata extraction ─────────────────────────────────── */
 function probeAndThumbnail(videoPath, outputDir) {
   return new Promise((resolve) => {
     const videoFilename = path.basename(videoPath, path.extname(videoPath));
     const thumbFilename = videoFilename + '_thumb.jpg';
-
     fs.mkdirSync(outputDir, { recursive: true });
 
     ffmpeg.ffprobe(videoPath, (err, metadata) => {
-      const duration = metadata?.format?.duration ?? null;
-      let fps = 30;
-      let codecName = 'unknown';
-
+      let duration = null, fps = 30, codecName = 'unknown', bitrate = null;
       const videoStream = metadata?.streams?.find(s => s.codec_type === 'video');
+      if (metadata?.format?.duration) duration = parseFloat(metadata.format.duration);
       if (videoStream) {
         codecName = videoStream.codec_name || 'unknown';
+        bitrate = videoStream?.bit_rate
+          ? parseInt(videoStream.bit_rate)
+          : (metadata?.format?.bit_rate ? parseInt(metadata.format.bit_rate) : null);
         if (videoStream.r_frame_rate) {
           const [num, den] = videoStream.r_frame_rate.split('/').map(Number);
           fps = den ? Math.round(num / den) : num;
         }
       }
-
-      send('metadata', { codec: codecName, duration, fps });
+      log('metadata', { codec: codecName, duration, fps, bitrate });
 
       ffmpeg(videoPath)
-        .on('error', () => resolve({ thumbnailPath: null, duration, fps, codec: codecName }))
-        .on('end', () => resolve({ thumbnailPath: thumbFilename, duration, fps, codec: codecName }))
+        .on('error', () => resolve({ thumbnailPath: null, duration, fps, codec: codecName, bitrate }))
+        .on('end', () => resolve({ thumbnailPath: thumbFilename, duration, fps, codec: codecName, bitrate }))
         .screenshots({
-          count: 1,
-          folder: outputDir,
-          filename: thumbFilename,
-          size: '320x?',
-          timemarks: ['1'],
+          count: 1, folder: outputDir, filename: thumbFilename,
+          size: '320x?', timemarks: ['1'],
         });
     });
   });
 }
 
-/** ── HLS Transcode ─────────────────────────────────────────────────────── */
-function transcodeToHLS(videoPath, outputDir, assetId, duration) {
+/** ── HLS Transcode ────────────────────────────────────────────────────── */
+function transcodeToHLS(videoPath, outputDir, assetId, duration, encoder) {
   return new Promise((resolve, reject) => {
     const hlsDir = path.join(outputDir, 'hls', assetId);
     fs.mkdirSync(hlsDir, { recursive: true });
-
-    const playlistPath = path.join(hlsDir, 'master.m3u8');
-    const segmentPattern = path.join(hlsDir, 'segment_%03d.ts');
     let lastPct = 0;
 
-    ffmpeg(videoPath)
-      // ── Re-encode to H.264 + AAC ────────────────────────────────────────
-      .outputOptions([
-        '-c:v libx264',
-        '-c:a aac',
-        '-movflags +faststart',
-        '-preset fast',
-        '-crf 23',
-        '-f hls',
-        '-hls_time 2',
-        '-hls_playlist_type vod',
-        '-hls_segment_filename', segmentPattern,
-        '-hls_list_size 0',
-      ])
-      .output(playlistPath)
+    const cmd = ffmpeg(videoPath);
+
+    const encOpts = getEncoderOptions(encoder, duration);
+    cmd.outputOptions([
+      // Explicit stream mapping: video + optional audio (safe for silent files)
+      '-map', '0:v',
+      '-map', '0:a?',
+      ...encOpts,
+      // HLS muxer options
+      '-f', 'hls',
+      '-hls_time', '6',
+      '-hls_playlist_type', 'vod',
+      '-hls_segment_filename', path.join(hlsDir, 'segments_%03d.ts'),
+      '-hls_list_size', '0',
+      // Variant playlist output (not .ts — avoid collision with segment files)
+      '-master_pl_name', 'master.m3u8',
+    ]);
+
+    cmd.output(path.join(hlsDir, 'variant.m3u8'))
       .on('progress', ({ percent }) => {
         const pct = Math.round(Math.min(99, percent ?? lastPct));
         if (pct > lastPct) {
           lastPct = pct;
-          send('progress', { progress: pct });
-          // Also update DB progress periodically
+          log('progress', { assetId, progress: pct });
           prisma.asset.update({
             where: { id: assetId },
             data: { transcodeProgress: pct, transcodeStatus: 'PROCESSING' },
-          }).catch(() => {}); // ignore errors
+          }).catch(() => {});
         }
       })
       .on('error', (err) => reject(new Error('HLS_TRANSCODE_FAILED: ' + err.message)))
-      .on('end', () => resolve('/hls/' + assetId + '/master.m3u8'))
+      .on('end', () => {
+        // fluent-ffmpeg with %03d pattern creates segments + variant playlist but no master.m3u8;
+        // write master.m3u8 pointing to variant.m3u8 using awk (no python needed)
+        const masterPath = path.join(hlsDir, 'master.m3u8');
+        const variantPath = path.join(hlsDir, 'variant.m3u8');
+        const { execSync } = require('child_process');
+        execSync(
+          `awk 'BEGIN{print "#EXTM3U\\n#EXT-X-VERSION:3\\n#EXT-X-STREAM-INF:BANDWIDTH=2000000,RESOLUTION=1280x720\\nvariant.m3u8"}' > ${masterPath}`,
+          { cwd: hlsDir }
+        );
+        resolve('/hls/' + assetId + '/master.m3u8');
+      })
+      .run();
+  });
+}
+
+/** ── Stream-copy HLS (remux, no re-encode) ────────────────────────────── */
+function streamCopyToHLS(videoPath, outputDir, assetId, duration) {
+  return new Promise((resolve, reject) => {
+    const hlsDir = path.join(outputDir, 'hls', assetId);
+    fs.mkdirSync(hlsDir, { recursive: true });
+    const segmentPattern = path.join(hlsDir, 'segments_%03d.ts');
+
+    ffmpeg(videoPath)
+      .outputOptions([
+        '-map', '0:v',
+        '-map', '0:a?',
+        '-c:v', 'copy',
+        '-c:a', 'copy',
+        '-f', 'hls',
+        '-hls_time', '6',
+        '-hls_playlist_type', 'vod',
+        '-hls_segment_filename', segmentPattern,
+        '-hls_list_size', '0',
+        '-master_pl_name', 'master.m3u8',
+      ])
+      .output(path.join(hlsDir, 'variant.m3u8'))
+      .on('error', (err) => reject(new Error('STREAM_COPY_FAILED: ' + err.message)))
+      .on('end', () => {
+        const masterPath = path.join(hlsDir, 'master.m3u8');
+        fs.writeFileSync(masterPath, [
+          '#EXTM3U',
+          '#EXT-X-VERSION:3',
+          '#EXT-X-STREAM-INF:BANDWIDTH=2000000,RESOLUTION=1280x720',
+          'variant.m3u8',
+        ].join('\n') + '\n');
+        resolve('/hls/' + assetId + '/master.m3u8');
+      })
       .run();
   });
 }
 
-/** ── Process one job ───────────────────────────────────────────────────── */
+/** ── Process one job ──────────────────────────────────────────────────── */
 async function processJob(asset) {
   const { id: assetId, filePath, transcodePaused } = asset;
   const videoPath = path.join(UPLOAD_DIR, filePath);
 
-  // Respect pause flag — skip if user paused before worker picked it up
   if (transcodePaused) {
-    send('paused', { assetId, reason: 'transcodePaused flag set by user, re-queuing' });
-    // Keep transcodePaused=true so it won't be re-claimed until user resumes
-    await prisma.asset.update({
-      where: { id: assetId },
-      data: { transcodeStatus: 'PENDING' },
-    });
+    log('paused', { assetId, reason: 'transcodePaused flag set, re-queuing' });
+    await prisma.asset.update({ where: { id: assetId }, data: { transcodeStatus: 'PENDING' } });
     return;
   }
 
-  send('started', { assetId, filePath });
+  log('started', { assetId, filePath, encoder: ENCODER });
 
-  // Check file exists
   if (!fs.existsSync(videoPath)) {
-    send('error', { assetId, error: 'Video file not found on disk: ' + videoPath });
+    log('error', { assetId, error: 'Video file not found: ' + videoPath });
     await prisma.asset.update({
       where: { id: assetId },
       data: { transcodeStatus: 'FAILED', transcodeError: 'Video file not found on server', transcodeProgress: 0 },
@@ -147,14 +297,15 @@ async function processJob(asset) {
   }
 
   try {
-    // Phase 1: thumbnail + probe
+    // Phase 1: probe + thumbnail
     await prisma.asset.update({ where: { id: assetId }, data: { transcodeStatus: 'PROCESSING', transcodeProgress: 0 } });
+    const probe = await probeContainer(videoPath);
     const thumbResult = await probeAndThumbnail(videoPath, UPLOAD_DIR);
 
-    // Check pause flag between phases
-    const check1 = await prisma.asset.findUnique({ where: { id: assetId }, select: { transcodePaused: true } });
-    if (check1?.transcodePaused) {
-      send('paused', { assetId, reason: 'paused between thumbnail and HLS phases' });
+    // Check pause between phases
+    const check = await prisma.asset.findUnique({ where: { id: assetId }, select: { transcodePaused: true } });
+    if (check?.transcodePaused) {
+      log('paused', { assetId, reason: 'paused between phases' });
       await prisma.asset.update({ where: { id: assetId }, data: { transcodeStatus: 'PENDING', transcodePaused: false } });
       return;
     }
@@ -167,31 +318,38 @@ async function processJob(asset) {
         codec: thumbResult.codec ?? null,
         duration: thumbResult.duration ?? null,
         fps: thumbResult.fps ?? 30,
+        bitrate: thumbResult.bitrate ?? null,
         transcodeProgress: 10,
       },
     });
 
-    // Phase 2: HLS
-    const hlsPath = await transcodeToHLS(videoPath, UPLOAD_DIR, assetId, thumbResult.duration);
+    // Phase 2: HLS — decide between stream-copy or re-encode
+    const isStreamCopy = canStreamCopy(probe);
+    if (isStreamCopy) {
+      log('stream_copy', { assetId, reason: `container=${probe.container} video=${probe.videoCodec} audio=${probe.audioCodec}` });
+      await streamCopyToHLS(videoPath, UPLOAD_DIR, assetId, thumbResult.duration);
+    } else {
+      log('re_encode', { assetId, reason: `video=${probe?.videoCodec} audio=${probe?.audioCodec} container=${probe?.container}`, encoder: ENCODER });
+      await transcodeToHLS(videoPath, UPLOAD_DIR, assetId, thumbResult.duration, ENCODER);
+    }
 
-    // Done!
+    // Done
     await prisma.asset.update({
       where: { id: assetId },
       data: {
         transcodeStatus: 'COMPLETED',
         transcodeProgress: 100,
         transcodeError: null,
-        hlsPath,
+        hlsPath: '/hls/' + assetId + '/master.m3u8',
         codec: thumbResult.codec ?? null,
         duration: thumbResult.duration ?? null,
         fps: thumbResult.fps ?? null,
+        bitrate: thumbResult.bitrate ?? null,
       },
     });
-
-    send('done', { assetId, hlsPath });
+    log('done', { assetId });
   } catch (err) {
-    send('error', { assetId, error: err.message });
-    // Only mark failed if not already deleted or completed by another process
+    log('error', { assetId, error: err.message });
     try {
       await prisma.asset.update({
         where: { id: assetId },
@@ -201,7 +359,7 @@ async function processJob(asset) {
   }
 }
 
-/** ── Claim one job (atomic) ─────────────────────────────────────────────── */
+/** ── Claim one job (atomic, skip locked) ─────────────────────────────── */
 async function claimOneJob() {
   const result = await prisma.$executeRaw`
     UPDATE "Asset"
@@ -216,12 +374,10 @@ async function claimOneJob() {
       LIMIT  1
       FOR    UPDATE SKIP LOCKED
     )
-    RETURNING id, "filePath", "transcodeStatus", "transcodePaused"
   `;
 
   if (!result || result === 0) return null;
 
-  // Re-fetch the claimed asset (result doesn't return full row with $executeRaw)
   return prisma.asset.findFirst({
     where: { transcodeStatus: 'PROCESSING' },
     orderBy: { updatedAt: 'asc' },
@@ -229,71 +385,63 @@ async function claimOneJob() {
   });
 }
 
-/** ── Poll loop (runs on interval AND after every job) ───────────────────── */
+/** ── Recover stale jobs on startup ────────────────────────────────────── */
+async function recoverStaleJobs() {
+  const stale = await prisma.asset.findMany({
+    where: { transcodeStatus: 'PROCESSING', transcodePaused: false },
+    select: { id: true },
+  });
+  if (stale.length > 0) {
+    console.log(`[worker:${WORKER_ID}] Recovering ${stale.length} stale job(s)...`);
+    await prisma.asset.updateMany({
+      where: { id: { in: stale.map(s => s.id) } },
+      data: { transcodeStatus: 'PENDING', transcodeProgress: 0 },
+    });
+  }
+}
+
+/** ── Poll loop ────────────────────────────────────────────────────────── */
 async function poll() {
   try {
     const claimed = await claimOneJob();
-    if (!claimed) return;
+    if (!claimed) {
+      // No job — sleep then poll again with backoff (max 5s)
+      await sleep(BACKOFF_MS);
+      return poll();
+    }
+    // Job claimed — process immediately, then poll again without delay
     await processJob(claimed);
-    // Immediately poll again — don't wait for the next interval tick
-    // This prevents the 5-second gap between back-to-back jobs
-    poll().catch(err => console.error('[worker] Recursive poll error:', err.message));
+    poll().catch(err => console.error(`[worker:${WORKER_ID}] poll error:`, err.message));
   } catch (err) {
-    console.error('[worker] Poll error:', err.message);
+    console.error(`[worker:${WORKER_ID}] poll error:`, err.message);
+    await sleep(BACKOFF_MS);
+    poll().catch(err => console.error(`[worker:${WORKER_ID}] poll error:`, err.message));
   }
 }
 
-/** ── Main ──────────────────────────────────────────────────────────────── */
+/** ─── Worker entry point ─────────────────────────────────────────────── */
 async function main() {
-  console.log('[worker] Starting transcode worker...');
-  console.log('[worker] UPLOAD_DIR:', UPLOAD_DIR);
-  console.log('[worker] DATABASE_URL:', process.env.DATABASE_URL ? '(set)' : 'MISSING!');
-  console.log('[worker] Poll interval:', POLL_INTERVAL_MS, 'ms');
-
-  // Make sure upload dir exists
-  fs.mkdirSync(UPLOAD_DIR, { recursive: true });
-
-  send('ready', { UPLOAD_DIR, POLL_INTERVAL_MS });
-
-  // Process any stale PROCESSING jobs (worker crashed mid-job) on startup
+  // Don't run the worker entry point in master — it only forks children
+  if (cluster.isMaster) return;
+  console.log(`[worker:${WORKER_ID}] Started, ENCODER=${ENCODER}`);
   await recoverStaleJobs();
-
-  // Main poll loop
-  setInterval(poll, POLL_INTERVAL_MS);
-}
-
-/** Recover stale jobs — assets stuck in PROCESSING from a crashed worker */
-async function recoverStaleJobs() {
-  try {
-    const stale = await prisma.asset.findMany({
-      where: { transcodeStatus: 'PROCESSING', transcodePaused: false },
-      select: { id: true },
-    });
-    if (stale.length > 0) {
-      console.log(`[worker] Recovering ${stale.length} stale job(s)...`);
-      await prisma.asset.updateMany({
-        where: { id: { in: stale.map(s => s.id) } },
-        data: { transcodeStatus: 'PENDING', transcodeProgress: 0 },
-      });
-    }
-  } catch (err) {
-    console.warn('[worker] recoverStaleJobs error:', err.message);
-  }
+  // Start polling immediately — tight recursive loop with backoff when idle
+  poll().catch(err => console.error(`[worker:${WORKER_ID}] Fatal poll error:`, err.message));
+  log('ready', {});
 }
 
 main().catch(err => {
-  console.error('[worker] Fatal error:', err);
+  console.error(`[worker:${WORKER_ID}] Fatal:`, err);
   process.exit(1);
 });
 
-// Graceful shutdown
 process.on('SIGTERM', async () => {
-  console.log('[worker] SIGTERM received, shutting down...');
+  console.log(`[worker:${WORKER_ID}] SIGTERM, exiting...`);
   await prisma.$disconnect();
   process.exit(0);
 });
 process.on('SIGINT', async () => {
-  console.log('[worker] SIGINT received, shutting down...');
+  console.log(`[worker:${WORKER_ID}] SIGINT, exiting...`);
   await prisma.$disconnect();
   process.exit(0);
 });

+ 3 - 3
prisma/schema.prisma

@@ -18,8 +18,8 @@ model User {
   avatarUrl     String?
   globalRole    GlobalRole @default(MEMBER)
   active        Boolean   @default(true)
-  storageQuota  Int        @default(524288000) // 500 MB in bytes
-  storageUsed   Int        @default(0)         // bytes consumed
+  storageQuota  BigInt     @default(524288000) // 500 MB in bytes
+  storageUsed   BigInt     @default(0)         // bytes consumed
   createdAt     DateTime  @default(now())
   updatedAt     DateTime  @updatedAt
 
@@ -80,7 +80,7 @@ model Asset {
   fps             Float           @default(30)
   codec           String?
   mimeType        String
-  fileSize        Int              @default(0)   // raw video file size in bytes
+  fileSize        BigInt           @default(0)   // raw video file size in bytes
   status          AssetStatus     @default(PENDING_REVIEW)
   transcodeStatus TranscodeStatus @default(PENDING)
   transcodeProgress Int            @default(0)

+ 3 - 2
scripts/init-admin.sh

@@ -4,6 +4,7 @@
 #   - UPDATE: skips, leaves DB and data intact
 
 DB_HOST="${DB_HOST:-vidreview-db}"
+DB_CONTAINER="${DB_CONTAINER:-$DB_HOST}"
 DB_NAME="${DB_NAME:-vidreview}"
 DB_USER="${DB_USER:-vidreview}"
 OUTPUT_DIR="${OUTPUT_DIR:-/seed-output}"
@@ -12,7 +13,7 @@ ADMIN_NAME="${ADMIN_NAME:-Admin}"
 API_CONTAINER="${API_CONTAINER:-vidreview-api}"
 
 run_psql() {
-  docker exec "$DB_HOST" psql -U "$DB_USER" -d "$DB_NAME" "$@" 2>&1
+  docker exec "$DB_CONTAINER" psql -U "$DB_USER" -d "$DB_NAME" "$@" 2>&1
 }
 
 run_node() {
@@ -67,7 +68,7 @@ fi
 echo ""
 echo "  FRESH DEPLOY: setting up initial account"
 
-RANDOM_PASS="vid-$(date +%s)-$(head -c 10 /dev/urandom | tr -dc 'a-z0-9')"
+RANDOM_PASS="$(head -c 32 /dev/urandom | base64 | tr -dc 'a-zA-Z0-9!@#$%' | head -c 24)"
 echo "  Password generated."
 
 PASS_HASH=$(run_node -e "require('bcryptjs').hash('$RANDOM_PASS',10).then(h=>process.stdout.write(h)).catch(e=>{console.error(e);process.exit(1)})")

+ 4 - 1
src/app/(dashboard)/layout.tsx

@@ -5,6 +5,7 @@ import { useRouter, usePathname } from 'next/navigation';
 import Link from 'next/link';
 import { useAuth } from '@/lib/auth-context';
 import { Avatar } from '@/components/ui/avatar';
+import { UploadQueueProvider } from '@/contexts/UploadQueueContext';
 
 function formatBytes(bytes: number): string {
   if (bytes === 0) return '0 B';
@@ -234,10 +235,12 @@ export default function DashboardLayout({ children }: { children: React.ReactNod
         <SidebarContent />
       </aside>
 
-      {/* ── Main content (padding-left on desktop for fixed sidebar, padding-top on mobile for hamburger) ─── */}
+      <UploadQueueProvider>
+      {/* ── Main content ─── */}
       <main className="flex-1 overflow-auto min-w-0 md:ml-56 pt-12 md:pt-0" style={{ background: 'var(--bg)' }}>
         {children}
       </main>
+      </UploadQueueProvider>
     </div>
   );
 }

+ 60 - 34
src/app/(dashboard)/projects/[projectId]/page.tsx

@@ -10,20 +10,24 @@ import { FolderTree } from '@/components/folders/FolderTree';
 import { ShareModal } from '@/components/share/ShareModal';
 import { TranscodeTasksPanel } from '@/components/transcode/TranscodeTasksPanel';
 import { useDropzone } from 'react-dropzone';
+import { useUploadQueue } from '@/contexts/UploadQueueContext';
 
 async function safeCopy(text: string): Promise<void> {
   if (typeof window === 'undefined') return;
-  if (navigator.clipboard?.writeText) {
-    try { await navigator.clipboard.writeText(text); } catch { /* ignore */ }
-  } else {
-    const el = document.createElement('textarea');
-    el.value = text;
-    el.style.cssText = 'position:fixed;top:-999px;left:-999px;opacity:0';
-    document.body.appendChild(el);
-    el.focus(); el.select();
-    try { document.execCommand('copy'); } catch { /* ignore */ }
-    document.body.removeChild(el);
-  }
+  try {
+    const cb = navigator.clipboard;
+    if (cb && typeof cb.writeText === 'function') {
+      await cb.writeText(text);
+    } else {
+      const el = document.createElement('textarea');
+      el.value = text;
+      el.style.cssText = 'position:fixed;top:-999px;left:-999px;opacity:0';
+      document.body.appendChild(el);
+      el.focus(); el.select();
+      try { document.execCommand('copy'); } catch { /* ignore */ }
+      document.body.removeChild(el);
+    }
+  } catch { /* ignore */ }
 }
 
 const ROLE_COLORS: Record<string, string> = {
@@ -154,7 +158,7 @@ export default function ProjectDetailPage() {
   const [selectedFolderId, setSelectedFolderId] = useState<string | null>(null);
   const [viewMode, setViewMode] = useState<'file' | 'timeline'>('file');
   const [loading, setLoading] = useState(true);
-  const [uploading, setUploading] = useState(false);
+
   const [sharingAssetId, setSharingAssetId] = useState<string | null>(null);
   const [activeTab, setActiveTab] = useState<'videos' | 'members' | 'transcode'>('videos');
 
@@ -165,6 +169,8 @@ export default function ProjectDetailPage() {
   const [inviteError, setInviteError] = useState('');
   const [inviteSuccess, setInviteSuccess] = useState('');
   const [createdLink, setCreatedLink] = useState('');
+  const [createdLinkEmail, setCreatedLinkEmail] = useState('');
+  const [linkCopiedAgain, setLinkCopiedAgain] = useState(false);
 
   // Edit member role
   const [editingRoleId, setEditingRoleId] = useState<string | null>(null);
@@ -313,17 +319,20 @@ export default function ProjectDetailPage() {
     setInviteError('');
     setInviteSuccess('');
     setCreatedLink('');
+    setLinkCopiedAgain(false);
+    const email = inviteEmail.trim();
     try {
-      const { inviteUrl } = await invitationsApi.create(token, projectId, inviteEmail.trim(), inviteRole);
+      const { inviteUrl } = await invitationsApi.create(token, projectId, email, inviteRole);
       const { invitations } = await invitationsApi.list(token, projectId);
       setPendingInvites(invitations.filter((i: Invitation) => i.status === 'PENDING'));
       await safeCopy(inviteUrl);
       setCreatedLink(inviteUrl);
+      setCreatedLinkEmail(email);
       setInviteEmail('');
     } catch (err: any) {
       const msg = err instanceof Error ? err.message : String(err);
       if (msg.includes('already exists') || msg.includes('already a member') || msg.includes('409')) {
-        setInviteError(`An invitation for "${inviteEmail.trim()}" is already pending or the user is already a member.`);
+        setInviteError(`An invitation for "${email}" is already pending or the user is already a member.`);
       } else {
         setInviteError(msg || 'Failed to create invitation link');
       }
@@ -386,30 +395,24 @@ export default function ProjectDetailPage() {
   };
 
   // ── Upload ─────────────────────────────────────────────────────────────────
-  const handleDrop = async (acceptedFiles: File[]) => {
+  const { enqueue, totalActive } = useUploadQueue();
+
+  const handleDrop = (acceptedFiles: File[]) => {
     if (!token || acceptedFiles.length === 0) return;
-    setUploading(true);
     for (const file of acceptedFiles) {
-      const formData = new FormData();
-      formData.append('video', file);
-      formData.append('projectId', projectId);
-      formData.append('title', file.name.replace(/\.[^.]+$/, ''));
-      try {
-        const result = await assetsApi.upload(token, formData) as { asset: Asset };
-        setAssets(prev => [result.asset, ...prev]);
-      } catch (err) {
-        console.error('Upload failed:', err);
-        alert(`Upload failed: ${file.name}`);
-      }
+      enqueue({
+        projectId,
+        folderId: selectedFolderId ?? undefined,
+        file,
+      });
     }
-    setUploading(false);
   };
 
   const { getRootProps: getUploadRootProps, getInputProps: getUploadInputProps, isDragActive: isUploadDragActive } = useDropzone({
     onDrop: handleDrop,
     accept: { 'video/*': ['.mp4', '.mov', '.webm', '.avi', '.mpeg'] },
     multiple: true,
-    disabled: uploading,
+    disabled: totalActive > 0,
   });
 
   // Poll for assets that are still processing
@@ -559,7 +562,7 @@ export default function ProjectDetailPage() {
             title="Upload video"
           >
             <input {...getUploadInputProps()} />
-            {uploading ? (
+            {totalActive > 0 ? (
               <div className="w-3.5 h-3.5 rounded-full animate-spin" style={{ borderColor: '#A5B4FC', borderTopColor: 'transparent', borderWidth: '2px' }} />
             ) : (
               <svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
@@ -704,6 +707,11 @@ export default function ProjectDetailPage() {
                   projectId={projectId}
                   onRefresh={loadFolders}
                   totalAssetCount={assets.length}
+                  onFilesDropped={(files, folderId) => {
+                    for (const file of files) {
+                      enqueue({ projectId, folderId, file });
+                    }
+                  }}
                 />
               </aside>
 
@@ -1043,17 +1051,35 @@ export default function ProjectDetailPage() {
                   </form>
 
                   {createdLink && (
-                    <div className="rounded-lg p-3 animate-scale-in"
+                    <div className="rounded-lg p-4 animate-scale-in"
                          style={{ background: 'rgba(34,197,94,0.08)', border: '1px solid rgba(34,197,94,0.20)' }}>
                       <div className="flex items-center gap-2 mb-1.5">
-                        <svg className="w-3.5 h-3.5 shrink-0" style={{ color: '#86EFAC' }} fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
+                        <svg className="w-4 h-4 shrink-0" style={{ color: '#86EFAC' }} fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
                           <path strokeLinecap="round" strokeLinejoin="round" d="M9 12.75L11.25 15 15 9.75M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
                         </svg>
-                        <span className="text-xs font-medium" style={{ color: '#86EFAC' }}>Link copied!</span>
+                        <span className="text-sm font-medium" style={{ color: '#86EFAC' }}>Invitation link created!</span>
+                        <button
+                          type="button"
+                          onClick={async () => {
+                            await safeCopy(createdLink);
+                            setLinkCopiedAgain(true);
+                            setTimeout(() => setLinkCopiedAgain(false), 2000);
+                          }}
+                          className="ml-auto text-xs px-3 py-1 rounded-lg transition-all"
+                          style={{ background: 'rgba(255,255,255,0.06)', color: linkCopiedAgain ? '#86EFAC' : 'var(--text-muted)' }}
+                        >
+                          {linkCopiedAgain ? '✓ Copied' : 'Copy link'}
+                        </button>
                       </div>
-                      <p className="text-[10px] break-all" style={{ color: 'rgba(134,239,172,0.7)' }}>
+                      <p className="text-[10px] mb-2" style={{ color: 'rgba(134,239,172,0.5)' }}>
+                        Invite sent to <strong style={{ color: '#86EFAC' }}>{createdLinkEmail}</strong> as {inviteRole} · Link expires in 7 days
+                      </p>
+                      <p className="text-xs break-all font-mono" style={{ color: 'rgba(134,239,172,0.7)' }}>
                         {createdLink}
                       </p>
+                      <p className="text-[10px] mt-2" style={{ color: 'rgba(134,239,172,0.45)' }}>
+                        Share this link with your colleague — they can use it to join the project directly.
+                      </p>
                     </div>
                   )}
 

+ 116 - 1
src/app/(dashboard)/settings/page.tsx

@@ -2,9 +2,27 @@
 
 import { useState, useEffect } from 'react';
 import { useAuth } from '@/lib/auth-context';
-import { usersApi, settingsApi } from '@/lib/api';
+import { usersApi, settingsApi, invitationsApi } from '@/lib/api';
 import { ProfilePictureUpload } from '@/components/settings/ProfilePictureUpload';
 
+async function safeCopy(text: string): Promise<void> {
+  if (typeof window === 'undefined') return;
+  try {
+    const cb = navigator.clipboard;
+    if (cb && typeof cb.writeText === 'function') {
+      await cb.writeText(text);
+    } else {
+      const el = document.createElement('textarea');
+      el.value = text;
+      el.style.cssText = 'position:fixed;top:-999px;left:-999px;opacity:0';
+      document.body.appendChild(el);
+      el.focus(); el.select();
+      try { document.execCommand('copy'); } catch { /* ignore */ }
+      document.body.removeChild(el);
+    }
+  } catch { /* ignore */ }
+}
+
 export default function SettingsPage() {
   const { user, token, updateUserData } = useAuth();
   const [name, setName] = useState(user?.name ?? '');
@@ -18,6 +36,13 @@ export default function SettingsPage() {
   const [registrationEnabled, setRegistrationEnabled] = useState(true);
   const [loadingReg, setLoadingReg] = useState(false);
 
+  // Workspace invite (admin only)
+  const [inviteEmail, setInviteEmail] = useState('');
+  const [inviting, setInviting] = useState(false);
+  const [inviteError, setInviteError] = useState('');
+  const [inviteLink, setInviteLink] = useState('');
+  const [linkCopied, setLinkCopied] = useState(false);
+
   const isAdmin = user?.globalRole === 'ADMIN';
 
   useEffect(() => {
@@ -84,6 +109,26 @@ export default function SettingsPage() {
     }
   };
 
+  const handleWorkspaceInvite = async (e: React.FormEvent) => {
+    e.preventDefault();
+    if (!token || !inviteEmail.trim()) return;
+    setInviting(true);
+    setInviteError('');
+    setInviteLink('');
+    setLinkCopied(false);
+    try {
+      const { inviteUrl } = await invitationsApi.inviteMember(token, inviteEmail.trim());
+      setInviteLink(inviteUrl);
+      await safeCopy(inviteUrl);
+      setLinkCopied(true);
+      setInviteEmail('');
+    } catch (err) {
+      setInviteError(err instanceof Error ? err.message : 'Failed to create invitation');
+    } finally {
+      setInviting(false);
+    }
+  };
+
   if (!user || !token) return null;
 
   return (
@@ -241,6 +286,76 @@ export default function SettingsPage() {
                   : 'Registration is closed — only invited users can join via invite link.'}
               </p>
             </div>
+
+            {/* Invite members */}
+            <div className="pt-4 border-t" style={{ borderColor: 'rgba(255,255,255,0.06)' }}>
+              <p className="text-sm font-medium mb-1" style={{ color: 'var(--text)' }}>
+                Invite Member
+              </p>
+              <p className="text-xs mb-4" style={{ color: 'var(--text-muted)' }}>
+                Generate an invitation link and share it directly with a colleague.
+              </p>
+
+              <form onSubmit={handleWorkspaceInvite} className="space-y-3">
+                <div className="flex items-end gap-3 flex-wrap">
+                  <div className="flex-1 min-w-[200px]">
+                    <input
+                      type="email"
+                      className="input"
+                      value={inviteEmail}
+                      onChange={e => setInviteEmail(e.target.value)}
+                      placeholder="colleague@company.com"
+                    />
+                  </div>
+                  <button
+                    type="submit"
+                    disabled={inviting || !inviteEmail.trim()}
+                    className="btn btn-primary btn-md shrink-0"
+                  >
+                    {inviting ? 'Creating…' : (
+                      <span className="flex items-center gap-1.5">
+                        <svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
+                          <path strokeLinecap="round" strokeLinejoin="round" d="M13.19 8.688a4.5 4.5 0 011.242 7.244l-4.5 4.5a4.5 4.5 0 01-6.364-6.364l1.757-1.757m13.35-.622l1.757-1.757a4.5 4.5 0 00-6.364-6.364l-4.5 4.5a4.5 4.5 0 001.242 7.244" />
+                        </svg>
+                        Create &amp; Copy Link
+                      </span>
+                    )}
+                  </button>
+                </div>
+
+                {inviteError && (
+                  <p className="text-xs" style={{ color: '#F87171' }}>{inviteError}</p>
+                )}
+
+                {inviteLink && (
+                  <div className="rounded-lg p-3 animate-scale-in"
+                       style={{ background: 'rgba(34,197,94,0.08)', border: '1px solid rgba(34,197,94,0.20)' }}>
+                    <div className="flex items-center gap-2 mb-1.5">
+                      <svg className="w-3.5 h-3.5 shrink-0" style={{ color: '#86EFAC' }} fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
+                        <path strokeLinecap="round" strokeLinejoin="round" d="M9 12.75L11.25 15 15 9.75M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
+                      </svg>
+                      <span className="text-xs font-medium" style={{ color: '#86EFAC' }}>
+                        {linkCopied ? 'Link created & copied!' : 'Link created!'}
+                      </span>
+                      <button
+                        type="button"
+                        onClick={async () => { await safeCopy(inviteLink); setLinkCopied(true); setTimeout(() => setLinkCopied(false), 2000); }}
+                        className="ml-auto text-[10px] px-2 py-0.5 rounded"
+                        style={{ background: 'rgba(255,255,255,0.06)', color: 'var(--text-muted)' }}
+                      >
+                        {linkCopied ? 'Copied!' : 'Copy again'}
+                      </button>
+                    </div>
+                    <p className="text-[10px] break-all" style={{ color: 'rgba(134,239,172,0.7)' }}>
+                      {inviteLink}
+                    </p>
+                    <p className="text-[10px] mt-1.5" style={{ color: 'rgba(134,239,172,0.5)' }}>
+                      Link expires in 7 days. Share it with {inviteEmail || 'your colleague'}.
+                    </p>
+                  </div>
+                )}
+              </form>
+            </div>
           </section>
         )}
 

+ 29 - 4
src/app/(dashboard)/users/page.tsx

@@ -20,6 +20,23 @@ import { Avatar } from '@/components/ui/avatar';
  *  (Project-level roles in ProjectMember.role:
  *   ADMIN | EDITOR | REVIEWER | VIEWER — scoped to a specific project.)
  */
+async function safeCopy(text: string): Promise<void> {
+  if (typeof window === 'undefined') return;
+  try {
+    const cb = navigator.clipboard;
+    if (cb && typeof cb.writeText === 'function') {
+      await cb.writeText(text);
+    } else {
+      const el = document.createElement('textarea');
+      el.value = text;
+      el.style.cssText = 'position:fixed;top:-999px;left:-999px;opacity:0';
+      document.body.appendChild(el);
+      el.focus(); el.select();
+      try { document.execCommand('copy'); } catch { /* ignore */ }
+      document.body.removeChild(el);
+    }
+  } catch { /* ignore */ }
+}
 const GLOBAL_ROLE_CONFIG: Record<string, { label: string; badge: string }> = {
   ADMIN:        { label: 'Admin',         badge: 'badge-danger' },
   MEMBER:       { label: 'Member',        badge: 'badge-muted' },
@@ -53,9 +70,17 @@ export default function UsersPage() {
   };
 
   const openQuotaEdit = (u: AdminUser) => {
-    const mb = (u.storageQuota / 1024 / 1024).toFixed(0);
-    setQuotaInput(mb);
-    setQuotaUnit('MB');
+    const quotaBytes = u.storageQuota;
+    // Determine best unit: show GB if >= 1024 MB, otherwise MB
+    const quotaMB = quotaBytes / 1024 / 1024;
+    if (quotaMB >= 1024) {
+      // Show in GB
+      setQuotaInput(String(Math.round(quotaMB / 1024)));
+      setQuotaUnit('GB');
+    } else {
+      setQuotaInput(String(Math.round(quotaMB)));
+      setQuotaUnit('MB');
+    }
     setQuotaError('');
     setEditingQuota(u.id);
   };
@@ -159,7 +184,7 @@ export default function UsersPage() {
       }]);
       setInviteEmail('');
       setInviteSuccess(`Invitation sent to ${inviteEmail.trim()}`);
-      await navigator.clipboard.writeText(fullUrl).catch(() => {});
+      await safeCopy(fullUrl);
       setCreatedLink(fullUrl);
       setTimeout(() => setInviteSuccess(''), 6000);
     } catch (err) {

+ 22 - 0
src/app/review/[assetId]/page.tsx

@@ -7,6 +7,7 @@ import { assetsApi, commentsApi, AssetWithComments, Asset, Comment, AnnotationDa
 import { Avatar } from '@/components/ui/avatar';
 import { ShareModal } from '@/components/share/ShareModal';
 import { VideoPlayer } from '@/components/video-player/VideoPlayer';
+import { VideoInfoPanel } from '@/components/video-info/VideoInfoPanel';
 import { Tool } from '@/components/video-player/AnnotationCanvas';
 import { formatTimecode } from '@/lib/format';
 
@@ -42,6 +43,7 @@ export default function ReviewPage() {
   const [currentTime, setCurrentTime] = useState(0);
   const [panelWidth, setPanelWidth] = useState(380);
   const [commentPanelCollapsed, setCommentPanelCollapsed] = useState(false);
+  const [showVideoInfo, setShowVideoInfo] = useState(true); // video info strip inside comment panel
   const [showApproval, setShowApproval] = useState(false);
   const [updatingStatus, setUpdatingStatus] = useState(false);
   const [newComment, setNewComment] = useState('');
@@ -1132,6 +1134,26 @@ export default function ReviewPage() {
             )}
           </div>
 
+          {/* Video info strip — bottom of comment panel */}
+          {showVideoInfo && asset && (
+            <VideoInfoPanel asset={asset} fps={fps} />
+          )}
+
+          {/* Toggle video info */}
+          <div className="px-3 pb-1 flex items-center gap-2 shrink-0" style={{ borderTop: '1px solid rgba(255,255,255,0.04)' }}>
+            <button
+              onClick={() => setShowVideoInfo(v => !v)}
+              className={`text-[11px] px-2 py-0.5 rounded transition-colors ${showVideoInfo ? 'bg-indigo-600 text-white' : ''}`}
+              style={!showVideoInfo ? { background: 'rgba(255,255,255,0.06)', color: 'var(--text-muted)' } : {}}
+              title={showVideoInfo ? 'Hide video info' : 'Show video info'}
+            >
+              <svg className="w-3 h-3 inline mr-1" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
+                <path strokeLinecap="round" strokeLinejoin="round" d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
+              </svg>
+              {showVideoInfo ? 'Hide info' : 'Video info'}
+            </button>
+          </div>
+
           {/* New comment / reply input */}
           <div className="shrink-0 p-4"
                style={{ borderTop: '1px solid rgba(255,255,255,0.06)', background: 'rgba(10,11,20,0.80)' }}>

+ 12 - 3
src/app/share/[token]/page.tsx

@@ -90,8 +90,7 @@ export default function SharePage() {
     router.push(`/login?redirect=${encodeURIComponent(returnUrl)}`);
   }
 
-  const isHls = linkInfo?.asset.mimeType === 'application/x-mpegURL' ||
-    streamUrl?.endsWith('.m3u8');
+  const isHls = !!streamUrl?.endsWith('.m3u8');
 
   // Load HLS if needed
   useEffect(() => {
@@ -101,9 +100,19 @@ export default function SharePage() {
     // eslint-disable-next-line @typescript-eslint/no-require-imports
     const Hls = require('hls.js');
     if (Hls.isSupported()) {
-      const hls = new Hls({ enableWorker: false, lowLatencyMode: true });
+      const hls = new Hls({
+        enableWorker: false,
+        backBufferLength: 30,
+        maxBufferLength: 30,
+        maxBufferSize: 50 * 1024 * 1024,
+        maxMaxBufferSize: 100 * 1024 * 1024,
+        startLevel: -1,
+      });
       hls.loadSource(streamUrl);
       hls.attachMedia(video);
+      hls.on(Hls.Events.ERROR, (_event: string, data: { fatal: boolean; details: string }) => {
+        if (data.fatal) console.error('[HLS] Fatal:', data.details);
+      });
       return () => { hls.destroy(); };
     } else if (video.canPlayType('application/vnd.apple.mpegurl')) {
       video.src = streamUrl;

+ 29 - 15
src/components/folders/FolderTree.tsx

@@ -15,6 +15,8 @@ interface Props {
   onRefresh: () => void;
   /** Total asset count in the project (for "All Videos" label) */
   totalAssetCount: number;
+  /** Called when user drops video files from OS onto a folder row */
+  onFilesDropped?: (files: File[], folderId: string) => void;
 }
 
 /** Returns true if folderId is this folder or a descendant of it */
@@ -38,6 +40,7 @@ export function FolderTree({
   projectId,
   onRefresh,
   totalAssetCount,
+  onFilesDropped,
 }: Props) {
   // Default: all folders collapsed on first load
   const [expanded, setExpanded] = useState<Set<string>>(new Set());
@@ -128,7 +131,10 @@ export function FolderTree({
   const handleDragOver = useCallback((e: React.DragEvent, folderId: string) => {
     if (!canManage) return;
     e.preventDefault();
-    e.dataTransfer.dropEffect = 'move';
+    const hasAsset = e.dataTransfer.types.includes('application/json');
+    const hasFiles = Array.from(e.dataTransfer.items).some((i: DataTransferItem) => i.kind === 'file');
+    if (!hasAsset && !hasFiles) return;
+    e.dataTransfer.dropEffect = hasAsset ? 'move' : 'copy';
     setDragOverId(folderId);
   }, [canManage]);
 
@@ -141,22 +147,30 @@ export function FolderTree({
     setDragOverId(null);
     if (!canManage) return;
 
+    // Case 1: internal asset drag → move between folders
     const assetId = e.dataTransfer.getData('assetId');
-    if (!assetId) return;
-
-    try {
-      // MOVE: first remove from ALL folders the asset currently belongs to, then add to target
-      const removals = allFolders
-        .filter(f => f.assetIds.includes(assetId))
-        .map(f => foldersApi.removeAsset(token, f.id, assetId));
-      await Promise.all(removals);
-      // Add to target folder (no-op if already there, which is fine)
-      await foldersApi.addAssets(token, folderId, [assetId]);
-      onRefresh();
-    } catch (e) {
-      console.error('Failed to move asset to folder:', e);
+    if (assetId) {
+      try {
+        const removals = allFolders
+          .filter(f => f.assetIds.includes(assetId))
+          .map(f => foldersApi.removeAsset(token, f.id, assetId));
+        await Promise.all(removals);
+        await foldersApi.addAssets(token, folderId, [assetId]);
+        onRefresh();
+      } catch (e) {
+        console.error('Failed to move asset to folder:', e);
+      }
+      return;
     }
-  }, [canManage, token, allFolders, onRefresh]);
+
+    // Case 2: file drop from OS → upload to this folder
+    const files = Array.from(e.dataTransfer.files).filter(f =>
+      f.type.startsWith('video/') ||
+      /\.(mp4|mov|webm|avi|mpeg|mkv|ogv|wmv|ts|3gp|3gpp2)$/i.test(f.name)
+    );
+    if (files.length === 0) return;
+    onFilesDropped?.(files, folderId);
+  }, [canManage, token, allFolders, onRefresh, onFilesDropped]);
 
   // ── All folder IDs (for expand/collapse all) ───────────────────────────────
   function getAllFolderIds(folders: FolderNode[]): Set<string> {

+ 206 - 0
src/components/upload/UploadQueuePanel.tsx

@@ -0,0 +1,206 @@
+'use client';
+import React from 'react';
+import { useUploadQueue } from '@/contexts/UploadQueueContext';
+
+function formatBytes(bytes: number): string {
+  if (bytes === 0) return '0 B';
+  const k = 1024;
+  const sizes = ['B', 'KB', 'MB', 'GB'];
+  const i = Math.floor(Math.log(bytes) / Math.log(k));
+  return `${parseFloat((bytes / Math.pow(k, i)).toFixed(1))} ${sizes[i]}`;
+}
+
+function formatSpeed(bps: number): string {
+  if (bps === 0) return '—';
+  return `${formatBytes(bps)}/s`;
+}
+
+function formatEta(seconds: number): string {
+  if (seconds <= 0 || !isFinite(seconds)) return '—';
+  if (seconds < 60) return `${Math.round(seconds)}s`;
+  if (seconds < 3600) return `${Math.round(seconds / 60)}m ${Math.round(seconds % 60)}s`;
+  return `${Math.floor(seconds / 3600)}h ${Math.round((seconds % 3600) / 60)}m`;
+}
+
+function StatusIcon({ status }: { status: string }) {
+  if (status === 'uploading') {
+    return (
+      <svg className="w-4 h-4 text-violet-400 animate-spin" fill="none" viewBox="0 0 24 24">
+        <circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
+        <path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8v4a4 4 0 00-4 4H4z" />
+      </svg>
+    );
+  }
+  if (status === 'queued') {
+    return (
+      <svg className="w-4 h-4 text-slate-400" fill="none" viewBox="0 0 24 24">
+        <path stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"
+          d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z" />
+      </svg>
+    );
+  }
+  if (status === 'success') {
+    return (
+      <svg className="w-4 h-4 text-emerald-400" fill="none" viewBox="0 0 24 24">
+        <path stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" d="M5 13l4 4L19 7" />
+      </svg>
+    );
+  }
+  if (status === 'error' || status === 'cancelled') {
+    return (
+      <svg className="w-4 h-4 text-red-400" fill="none" viewBox="0 0 24 24">
+        <path stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"
+          d={status === 'error'
+            ? 'M12 9v2m0 4h.01M10.29 3.86L1.82 18a2 2 0 001.71 3h16.94a2 2 0 001.71-3L13.71 3.86a2 2 0 00-3.42 0z'
+            : 'M6 18L18 6M6 6l12 12'} />
+      </svg>
+    );
+  }
+  return null;
+}
+
+function UploadRow({ item }: { item: ReturnType<typeof useUploadQueue>['uploads'][number] }) {
+  const { cancelUpload, retryUpload, removeUpload } = useUploadQueue();
+  const isActive = item.status === 'uploading' || item.status === 'queued';
+
+  return (
+    <div
+      className="flex items-center gap-3 px-3 py-2.5 rounded-xl transition-colors"
+      style={{ background: 'rgba(255,255,255,0.04)', border: '1px solid rgba(255,255,255,0.07)' }}
+    >
+      <StatusIcon status={item.status} />
+
+      <div className="flex-1 min-w-0">
+        <div className="flex items-center gap-2 mb-1">
+          <span className="text-xs font-medium truncate" style={{ color: 'var(--text)' }} title={item.file.name}>
+            {item.file.name}
+          </span>
+          {item.folderId && (
+            <span className="text-[10px] px-1.5 py-0.5 rounded-md flex-shrink-0"
+              style={{ background: 'rgba(99,102,241,0.2)', color: '#818CF8' }}>
+              folder
+            </span>
+          )}
+        </div>
+
+        {isActive && (
+          <div className="h-1 rounded-full overflow-hidden" style={{ background: 'rgba(255,255,255,0.1)' }}>
+            <div
+              className="h-full rounded-full transition-all duration-300"
+              style={{ width: `${item.progress}%`, background: 'linear-gradient(90deg, #6366F1, #A78BFA)' }}
+            />
+          </div>
+        )}
+
+        <div className="flex items-center gap-2 mt-1">
+          {item.status === 'uploading' && (
+            <span className="text-[11px]" style={{ color: '#A78BFA' }}>
+              {formatSpeed(item.speedBps)} &middot; {formatEta(item.etaSeconds)} &middot; {formatBytes(item.bytesSent)} / {formatBytes(item.bytesTotal)}
+            </span>
+          )}
+          {item.status === 'queued' && (
+            <span className="text-[11px]" style={{ color: '#64748B' }}>Waiting&hellip;</span>
+          )}
+          {item.status === 'success' && (
+            <span className="text-[11px]" style={{ color: '#34D399' }}>Done</span>
+          )}
+          {item.status === 'error' && (
+            <span className="text-[11px] truncate max-w-[180px]" style={{ color: '#F87171' }}>{item.error}</span>
+          )}
+          {item.status === 'cancelled' && (
+            <span className="text-[11px]" style={{ color: '#64748B' }}>Cancelled</span>
+          )}
+        </div>
+      </div>
+
+      <div className="flex items-center gap-1 flex-shrink-0">
+        {item.status === 'uploading' && (
+          <button onClick={() => cancelUpload(item.id)}
+            className="p-1 rounded-md transition-colors hover:bg-white/10" title="Cancel">
+            <svg className="w-3.5 h-3.5" style={{ color: '#94A3B8' }} fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
+              <path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
+            </svg>
+          </button>
+        )}
+        {item.status === 'error' && (
+          <button onClick={() => retryUpload(item.id)}
+            className="p-1 rounded-md transition-colors hover:bg-white/10" title="Retry">
+            <svg className="w-3.5 h-3.5" style={{ color: '#60A5FA' }} fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
+              <path strokeLinecap="round" strokeLinejoin="round"
+                d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
+            </svg>
+          </button>
+        )}
+        {(item.status !== 'uploading' && item.status !== 'queued') && (
+          <button onClick={() => removeUpload(item.id)}
+            className="p-1 rounded-md transition-colors hover:bg-white/10" title="Remove">
+            <svg className="w-3.5 h-3.5" style={{ color: '#475569' }} fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
+              <path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
+            </svg>
+          </button>
+        )}
+      </div>
+    </div>
+  );
+}
+
+export function UploadQueuePanel() {
+  const { uploads, isExpanded, toggleExpanded, totalActive } = useUploadQueue();
+
+  if (uploads.length === 0) return null;
+
+  return (
+    <div className="fixed bottom-4 right-4 z-50 flex flex-col items-end gap-2" style={{ maxWidth: '360px', width: '100%' }}>
+      <button
+        onClick={toggleExpanded}
+        className="flex items-center gap-2 px-3 py-2 rounded-xl transition-all"
+        style={{
+          background: isExpanded ? '#1E2030' : 'rgba(30,32,48,0.95)',
+          border: `1px solid ${totalActive > 0 ? 'rgba(99,102,241,0.5)' : 'rgba(255,255,255,0.10)'}`,
+          boxShadow: '0 8px 32px rgba(0,0,0,0.4)',
+        }}
+      >
+        <svg className="w-4 h-4 flex-shrink-0" style={{ color: totalActive > 0 ? '#818CF8' : '#64748B' }}
+          fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
+          <path strokeLinecap="round" strokeLinejoin="round"
+            d="M7 16a4 4 0 01-.88-7.903A5 5 0 1115.9 6L16 6a5 5 0 011 9.9M9 19l3-3m0 0l3 3m-3-3v12" />
+        </svg>
+        <span className="text-xs font-medium" style={{ color: totalActive > 0 ? '#818CF8' : '#64748B' }}>
+          {totalActive > 0 ? `${totalActive} uploading` : 'Uploads'}
+        </span>
+        {totalActive > 0 && (
+          <span className="w-1.5 h-1.5 rounded-full flex-shrink-0 animate-pulse" style={{ background: '#818CF8' }} />
+        )}
+        <svg className={`w-3.5 h-3.5 transition-transform ${isExpanded ? '' : 'rotate-180'}`}
+          style={{ color: '#64748B' }} fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
+          <path strokeLinecap="round" strokeLinejoin="round" d="M19 9l-7 7-7-7" />
+        </svg>
+      </button>
+
+      {isExpanded && (
+        <div
+          className="w-full rounded-2xl overflow-hidden animate-scale-in"
+          style={{
+            background: '#1E2030',
+            border: '1px solid rgba(255,255,255,0.10)',
+            boxShadow: '0 8px 32px rgba(0,0,0,0.5)',
+          }}
+        >
+          <div className="px-3 py-2.5 flex items-center justify-between"
+            style={{ borderBottom: '1px solid rgba(255,255,255,0.07)' }}>
+            <span className="text-xs font-semibold" style={{ color: '#94A3B8' }}>Uploads</span>
+            <span className="text-[11px] px-1.5 py-0.5 rounded-md"
+              style={{ background: 'rgba(99,102,241,0.15)', color: '#818CF8' }}>
+              {uploads.length} {uploads.length === 1 ? 'file' : 'files'}
+            </span>
+          </div>
+          <div className="p-2 space-y-1.5 max-h-72 overflow-y-auto">
+            {[...uploads].reverse().map(item => (
+              <UploadRow key={item.id} item={item} />
+            ))}
+          </div>
+        </div>
+      )}
+    </div>
+  );
+}

+ 119 - 0
src/components/video-info/VideoInfoPanel.tsx

@@ -0,0 +1,119 @@
+'use client';
+
+import { Asset } from '@/lib/api';
+
+interface Props {
+  asset: Asset;
+  fps: number;
+  compact?: boolean; // true = show inline row; false = collapsible section
+}
+
+function formatBytes(bytes?: number | null): string {
+  if (!bytes) return '—';
+  if (bytes < 1024) return `${bytes} B`;
+  if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
+  if (bytes < 1024 * 1024 * 1024) return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
+  return `${(bytes / (1024 * 1024 * 1024)).toFixed(2)} GB`;
+}
+
+function formatDate(iso: string): string {
+  if (!iso) return '—';
+  try {
+    return new Intl.DateTimeFormat('en-US', {
+      month: 'short',
+      day: 'numeric',
+      year: 'numeric',
+    }).format(new Date(iso));
+  } catch {
+    return '—';
+  }
+}
+
+function fmtDur(s?: number | null): string {
+  if (!s || isNaN(s)) return '—';
+  const h = Math.floor(s / 3600);
+  const m = Math.floor((s % 3600) / 60);
+  const sec = Math.floor(s % 60);
+  if (h > 0) return `${h}:${String(m).padStart(2,'0')}:${String(sec).padStart(2,'0')}`;
+  return `${m}:${String(sec).padStart(2,'0')}`;
+}
+
+function fmtRes(width?: number | null, height?: number | null): string {
+  if (!width || !height) return '—';
+  return `${width} × ${height}`;
+}
+
+// Bitrate in bits/s → human-readable string
+function fmtBitrate(bps?: number | null): string {
+  if (!bps || bps <= 0) return '—';
+  if (bps >= 1_000_000) return `${(bps / 1_000_000).toFixed(1)} Mbps`;
+  if (bps >= 1_000) return `${(bps / 1_000).toFixed(0)} Kbps`;
+  return `${bps} bps`;
+}
+
+// Codec display name
+function fmtCodec(codec?: string | null): string {
+  if (!codec) return '—';
+  const c = codec.toUpperCase();
+  if (c === 'H264' || c === 'AVC') return 'H.264 / AVC';
+  if (c === 'H265' || c === 'HEVC') return 'H.265 / HEVC';
+  if (c === 'VP8') return 'VP8';
+  if (c === 'VP9') return 'VP9';
+  if (c === 'AV1') return 'AV1';
+  if (c.startsWith('AUDIO/')) return codec.replace('AUDIO/', '').toUpperCase();
+  return codec;
+}
+
+// Mime type → human label
+function fmtMime(mime?: string): string {
+  if (!mime) return '—';
+  if (mime === 'video/mp4' || mime === 'video/mp4; codecs=avc1') return 'MP4';
+  if (mime === 'video/webm') return 'WebM';
+  if (mime === 'application/x-mpegURL') return 'HLS / M3U8';
+  if (mime === 'video/quicktime') return 'MOV / QuickTime';
+  if (mime === 'video/x-msvideo') return 'AVI';
+  if (mime === 'video/x-matroska') return 'MKV';
+  return mime;
+}
+
+// VideoInfoPanel — collapsible info strip for review page
+export function VideoInfoPanel({ asset, fps }: Props) {
+  const hasVideoRes =
+    asset.videoWidth != null || asset.videoHeight != null;
+  const hasFileSize =
+    asset.fileSize != null && asset.fileSize > 0;
+
+  const rows: { label: string; value: string }[] = [
+    { label: 'Duration',   value: fmtDur(asset.duration) },
+    { label: 'Bitrate',    value: fmtBitrate(asset.bitrate) },
+    { label: 'Resolution', value: fmtRes(asset.videoWidth, asset.videoHeight) },
+    { label: 'Frame Rate', value: fps ? `${fps} fps` : asset.fps ? `${asset.fps} fps` : '—' },
+    { label: 'Codec',      value: fmtCodec(asset.codec) },
+    { label: 'Format',     value: fmtMime(asset.mimeType) },
+    { label: 'File Size',  value: hasFileSize ? formatBytes(asset.fileSize) : '—' },
+    { label: 'Uploaded',   value: asset.createdAt ? formatDate(asset.createdAt) : '—' },
+  ];
+
+  // Only show rows with real data
+  const visible = rows.filter(r => r.value !== '—');
+
+  if (visible.length === 0) return null;
+
+  return (
+    <div className="px-3 py-2" style={{ borderTop: '1px solid rgba(255,255,255,0.06)' }}>
+      {/* Info rows — 2-column grid */}
+      <div className="grid grid-cols-2 gap-x-4 gap-y-1">
+        {visible.map(({ label, value }) => (
+          <div key={label} className="flex items-center justify-between gap-2">
+            <span className="text-[11px] truncate shrink-0" style={{ color: 'var(--text-subtle)' }}>
+              {label}
+            </span>
+            <span className="text-[11px] font-medium text-right shrink-0" style={{ color: 'var(--text)' }}>
+              {value}
+            </span>
+          </div>
+        ))}
+      </div>
+    </div>
+  );
+}

+ 29 - 0
src/components/video-player/Timeline.tsx

@@ -3,6 +3,7 @@
 import { useRef, useCallback, useEffect, useState } from 'react';
 import { Comment } from '../../lib/api';
 import { formatTimecode } from '../../lib/format';
+import { BufferRange } from './VideoPlayer';
 
 interface Props {
   duration: number;
@@ -20,6 +21,8 @@ interface Props {
   /** Called when scrubbing starts/stops so VideoPlayer can pause the main video */
   onScrubStart?: () => void;
   onScrubEnd?: () => void;
+  /** Buffered time ranges from HLS — rendered as a subtle layer on the seek bar. */
+  bufferedRanges?: BufferRange[];
 }
 
 export function Timeline({
@@ -34,6 +37,7 @@ export function Timeline({
   onCommentClick,
   onScrubStart,
   onScrubEnd,
+  bufferedRanges,
 }: Props) {
   const trackRef = useRef<HTMLDivElement>(null);
   // Hidden video used for thumbnail frame capture (no audio, no UI)
@@ -384,6 +388,31 @@ export function Timeline({
             style={{ width: `${progress}%` }}
           />
 
+          {/* Buffered ranges — rendered as subtle teal-tinted segments behind the playhead.
+              Clamped to [0, 100] to avoid overflow on tiny durations. */}
+          {bufferedRanges && bufferedRanges.length > 0 && duration > 0 && (
+            <div className="absolute inset-0 rounded-full overflow-hidden pointer-events-none">
+              {bufferedRanges.map((range, i) => {
+                const start = Math.max(0, (range.start / duration) * 100);
+                const end = Math.min(100, (range.end / duration) * 100);
+                if (end <= start) return null;
+                return (
+                  <div
+                    key={i}
+                    className="absolute top-0 h-full rounded-full"
+                    style={{
+                      left: `${start}%`,
+                      width: `${end - start}%`,
+                      background: 'rgba(167,243,208,0.18)',
+                      borderLeft: start > 0 ? '1px solid rgba(167,243,208,0.12)' : 'none',
+                      borderRight: '1px solid rgba(167,243,208,0.08)',
+                    }}
+                  />
+                );
+              })}
+            </div>
+          )}
+
           {/* Scrubber thumb */}
           <div
             className="absolute top-1/2 -translate-y-1/2 rounded-full will-change-transform"

+ 143 - 5
src/components/video-player/VideoPlayer.tsx

@@ -13,6 +13,12 @@ interface AnnotationWithTimestamp {
   timestamp: number;
 }
 
+/** A buffered time range — start and end in seconds */
+export interface BufferRange {
+  start: number;
+  end: number;
+}
+
 interface Props {
   src: string;
   mimeType: string;
@@ -30,6 +36,15 @@ interface Props {
   onTimeUpdate: (time: number) => void;
   onCommentClick: (comment: Comment) => void;
   onPlayingChange?: (playing: boolean) => void;
+  /**
+   * Live buffer ranges from HLS.js — used by Timeline to render the
+   * buffered regions on the seek bar. Pass a setter from the parent
+   * so the VideoPlayer can push updates whenever HLS fires LEVEL_SWITCHED
+   * or the buffered ranges change.
+   */
+  onBufferChange?: (ranges: BufferRange[]) => void;
+  /** Initial buffer strategy — 'eager' preloads when paused, 'lazy' only buffers during playback */
+  bufferStrategy?: 'eager' | 'lazy';
   /** When provided, the parent intercepts timeline seeks in compare mode */
   onTimelineSeek?: (time: number) => void;
   /** Pass-through ref to the underlying <video> element so parent can seek directly */
@@ -83,6 +98,8 @@ export function VideoPlayer({
   onNextComment,
   thumbnailSrc,
   thumbnailMimeType,
+  onBufferChange,
+  bufferStrategy = 'eager',
 }: Props) {
   const internalVideoRef = useRef<HTMLVideoElement>(null);
   // Use external ref if provided, otherwise internal
@@ -105,6 +122,57 @@ export function VideoPlayer({
   useEffect(() => { dismissedRef.current = dismissedSet; }, [dismissedSet]);
   const [bubbleVisible, setBubbleVisible] = useState(false);
 
+  // ── Buffer tracking ──────────────────────────────────────────────────────────
+  // Expose buffered ranges so Timeline can visualise them on the seek bar.
+  // When bufferStrategy='lazy' we only report ranges when playing, pausing, or
+  // scrubbing — not while the video sits idle on a single frame (which would
+  // otherwise cause HLS to preload the entire file and waste bandwidth).
+  const [bufferedRanges, setBufferedRanges] = useState<BufferRange[]>([]);
+  const hlsRef = useRef<Hls | null>(null);
+  const lastReportedRangesRef = useRef<string>('');
+
+  const syncBufferRanges = useCallback((video: HTMLVideoElement) => {
+    const ranges: BufferRange[] = [];
+    const tb = video.buffered;
+    for (let i = 0; i < tb.length; i++) {
+      ranges.push({ start: tb.start(i), end: tb.end(i) });
+    }
+    // Deduplicate: only call setBufferedRanges + onBufferChange if something actually changed
+    const key = ranges.map(r => `${Math.round(r.start * 10)}_${Math.round(r.end * 10)}`).join(',');
+    if (key !== lastReportedRangesRef.current) {
+      lastReportedRangesRef.current = key;
+      setBufferedRanges(ranges);
+      onBufferChange?.(ranges);
+    }
+  }, [onBufferChange]);
+
+  // Poll buffered ranges via a low-frequency RAF loop.
+  // Runs only while playing (RAF yields automatically) so it costs nothing when paused.
+  const bufferRafRef = useRef<number | null>(null);
+  const bufferLoop = useCallback((video: HTMLVideoElement) => {
+    if (video.buffered.length > 0) syncBufferRanges(video);
+    bufferRafRef.current = requestAnimationFrame(() => bufferLoop(video));
+  }, [syncBufferRanges]);
+
+  useEffect(() => {
+    const video = videoRef.current;
+    if (!video) return;
+
+    if (bufferStrategy === 'eager') {
+      // Eager: start polling immediately
+      bufferRafRef.current = requestAnimationFrame(() => bufferLoop(video));
+    }
+    // Lazy: polling is started / stopped by play/pause handlers (see below)
+
+    return () => {
+      if (bufferRafRef.current !== null) {
+        cancelAnimationFrame(bufferRafRef.current);
+        bufferRafRef.current = null;
+      }
+    };
+  // eslint-disable-next-line react-hooks/exhaustive-deps
+  }, [bufferStrategy]);
+
   // Active comment within bubble window
   const activeComment: Comment | null = (() => {
     if (!bubbleVisible) return null;
@@ -222,21 +290,89 @@ export function VideoPlayer({
     }
   }, [isComparePlayer, externalPlaying]);
 
-  // HLS
+  // HLS — detect by URL extension (hlsPath suffix), not by original file mimeType
   useEffect(() => {
     const video = videoRef.current;
     if (!video || !src) return;
-    if (mimeType === 'application/x-mpegURL' || src.endsWith('.m3u8')) {
+
+    // Clean up previous HLS instance if any
+    if (hlsRef.current) { hlsRef.current.destroy(); hlsRef.current = null; }
+
+    const isHls = src.endsWith('.m3u8');
+    if (isHls) {
       if (Hls.isSupported()) {
-        const hls = new Hls();
+        const hls = new Hls({
+          // Buffer config: load as much as possible upfront (eager).
+          // Lazy strategy kept for very long videos where bandwidth matters.
+          // Eager: buffer up to 300s ahead (≈ full video for most content),
+          //        keep 60s behind for smooth backward scrubbing.
+          // Lazy:  buffer 15s ahead, 10s behind — only while playing.
+          backBufferLength: bufferStrategy === 'lazy' ? 10 : 60,
+          maxBufferLength: bufferStrategy === 'lazy' ? 15 : 300,
+          maxBufferSize: 500 * 1024 * 1024,
+          // Auto level selection (removes the old startLevel: -1 override)
+          enableWorker: true,
+        });
+
+        hlsRef.current = hls;
+
         hls.loadSource(src);
         hls.attachMedia(video);
-        return () => hls.destroy();
+
+        hls.on(Hls.Events.MANIFEST_PARSED, () => {
+          // Pre-fetch the first few segments as soon as the manifest is parsed —
+          // this is the key optimisation for "play as fast as possible":
+          // the first keyframe/I-frame segment is already buffered before the
+          // user even clicks Play, so playback can start with near-zero latency.
+          hls.startLoad(0);
+        });
+
+        hls.on(Hls.Events.ERROR, (_event, data) => {
+          if (data.fatal) {
+            console.error('[HLS] Fatal error:', data.type, data.details, data.url);
+            hls.destroy();
+            hlsRef.current = null;
+          }
+        });
+
+        // ── Lazy strategy: start/stop buffer polling on play/pause ──────────
+        if (bufferStrategy === 'lazy') {
+          const onPlay = () => {
+            if (bufferRafRef.current === null) {
+              bufferRafRef.current = requestAnimationFrame(() => bufferLoop(video));
+            }
+          };
+          const onPause = () => {
+            // Record buffered state one final time when pausing
+            syncBufferRanges(video);
+            if (bufferRafRef.current !== null) {
+              cancelAnimationFrame(bufferRafRef.current);
+              bufferRafRef.current = null;
+            }
+          };
+          video.addEventListener('play', onPlay);
+          video.addEventListener('pause', onPause);
+          return () => {
+            video.removeEventListener('play', onPlay);
+            video.removeEventListener('pause', onPause);
+            hls.destroy();
+            hlsRef.current = null;
+            if (bufferRafRef.current !== null) {
+              cancelAnimationFrame(bufferRafRef.current);
+              bufferRafRef.current = null;
+            }
+          };
+        } else {
+          return () => { hls.destroy(); hlsRef.current = null; };
+        }
+      } else if (video.canPlayType('application/vnd.apple.mpegurl')) {
+        // Safari / native HLS fallback
+        video.src = src;
       }
     } else {
       video.src = src;
     }
-  }, [src, mimeType]);
+  }, [src, bufferStrategy]);
 
   // Measure container
   useEffect(() => {
@@ -439,6 +575,7 @@ export function VideoPlayer({
         <video
           ref={videoRef}
           className="w-full block"
+          poster={thumbnailSrc ?? undefined}
           onClick={() => { if (!drawMode) togglePlay(); }}
           onPlay={() => { setPlaying(true); onPlayingChange?.(true); }}
           onPause={() => { setPlaying(false); onPlayingChange?.(false); }}
@@ -506,6 +643,7 @@ export function VideoPlayer({
         onCommentClick={onCommentClick}
         onScrubStart={startScrubbing}
         onScrubEnd={endScrubbing}
+        bufferedRanges={bufferedRanges}
       />
 
       {/* ── Bottom controls row ───────────────────────────────────────── */}

+ 118 - 0
src/contexts/UploadQueueContext.tsx

@@ -0,0 +1,118 @@
+'use client';
+import React, { createContext, useContext, useReducer, useCallback, useRef } from 'react';
+import { UploadItem, UploadItemInput, UploadQueueContextValue } from '@/types/upload';
+import { useAuth } from '@/lib/auth-context';
+import { startXHRUpload } from '@/lib/upload-xhr';
+import { UploadQueuePanel } from '@/components/upload/UploadQueuePanel';
+
+type Action =
+  | { type: 'ENQUEUE'; payload: UploadItem }
+  | { type: 'UPDATE_PROGRESS'; id: string; progress: number; bytesSent: number; bytesTotal: number; speedBps: number; etaSeconds: number }
+  | { type: 'SET_STATUS'; id: string; status: UploadItem['status']; assetId?: string; error?: string }
+  | { type: 'REMOVE'; id: string };
+
+function reducer(state: UploadItem[], action: Action): UploadItem[] {
+  switch (action.type) {
+    case 'ENQUEUE':
+      return [...state, action.payload];
+    case 'UPDATE_PROGRESS':
+      return state.map(u =>
+        u.id === action.id
+          ? { ...u, progress: action.progress, bytesSent: action.bytesSent, bytesTotal: action.bytesTotal, speedBps: action.speedBps, etaSeconds: action.etaSeconds }
+          : u
+      );
+    case 'SET_STATUS':
+      return state.map(u =>
+        u.id === action.id
+          ? { ...u, status: action.status, assetId: action.assetId ?? u.assetId, error: action.error }
+          : u
+      );
+    case 'REMOVE':
+      return state.filter(u => u.id !== action.id);
+    default:
+      return state;
+  }
+}
+
+const UploadQueueContext = createContext<UploadQueueContextValue | null>(null);
+
+// Module-level map to abort in-flight XHRs
+const xhrMap = new Map<string, XMLHttpRequest>();
+
+export function UploadQueueProvider({ children }: { children: React.ReactNode }) {
+  const [uploads, dispatch] = useReducer(reducer, []);
+  const [isExpanded, setIsExpanded] = React.useState(true);
+  const { token } = useAuth();
+
+  const enqueue = useCallback((item: UploadItemInput) => {
+    const id = `upload_${Date.now()}_${Math.random().toString(36).slice(2)}`;
+    const uploadItem: UploadItem = {
+      ...item,
+      id,
+      status: 'queued',
+      progress: 0,
+      bytesSent: 0,
+      bytesTotal: item.file.size,
+      speedBps: 0,
+      etaSeconds: 0,
+      startedAt: Date.now(),
+    };
+    dispatch({ type: 'ENQUEUE', payload: uploadItem });
+
+    if (!token) return;
+
+    // Auto-start the upload after enqueue
+    const xhr = startXHRUpload(id, item, token, dispatch);
+    if (xhr) {
+      xhrMap.set(id, xhr);
+      dispatch({ type: 'SET_STATUS', id, status: 'uploading' });
+    }
+  }, [token]);
+
+  const cancelUpload = useCallback((id: string) => {
+    xhrMap.get(id)?.abort();
+    xhrMap.delete(id);
+    dispatch({ type: 'SET_STATUS', id, status: 'cancelled' });
+  }, []);
+
+  const retryUpload = useCallback((id: string) => {
+    const upload = uploads.find(u => u.id === id);
+    if (!upload || !token) return;
+    dispatch({ type: 'SET_STATUS', id, status: 'uploading' });
+    const xhr = startXHRUpload(id, {
+      file: upload.file,
+      projectId: upload.projectId,
+      folderId: upload.folderId,
+    }, token, dispatch);
+    if (xhr) xhrMap.set(id, xhr);
+  }, [token, uploads]);
+
+  const removeUpload = useCallback((id: string) => {
+    xhrMap.delete(id);
+    dispatch({ type: 'REMOVE', id });
+  }, []);
+
+  const totalActive = uploads.filter(u => u.status === 'uploading' || u.status === 'queued').length;
+
+  return (
+    <UploadQueueContext.Provider value={{
+      uploads,
+      enqueue,
+      cancelUpload,
+      retryUpload,
+      removeUpload,
+      isExpanded,
+      toggleExpanded: () => setIsExpanded(v => !v),
+      totalActive,
+    }}>
+      {children}
+      <UploadQueuePanel />
+    </UploadQueueContext.Provider>
+  );
+}
+
+export function useUploadQueue(): UploadQueueContextValue {
+  const ctx = useContext(UploadQueueContext);
+  if (!ctx) throw new Error('useUploadQueue must be used within <UploadQueueProvider>');
+  return ctx;
+}

+ 11 - 2
src/lib/api.ts

@@ -15,8 +15,9 @@ async function apiFetch<T = unknown>(
     ...(options.headers as Record<string, string> || {}),
   };
 
-  if (token) {
-    headers['Authorization'] = `Bearer ${token}`;
+  const authToken = token || (typeof window !== 'undefined' ? localStorage.getItem('vidreview_token') : null);
+  if (authToken) {
+    headers['Authorization'] = `Bearer ${authToken}`;
   }
 
   const res = await fetch(`${API_BASE}${endpoint}`, {
@@ -444,6 +445,14 @@ export interface Asset {
   duration?: number | null;
   fps?: number;
   codec?: string | null;
+  /** Width of the video in pixels (px) */
+  videoWidth?: number | null;
+  /** Height of the video in pixels (px) */
+  videoHeight?: number | null;
+  /** File size in bytes */
+  fileSize?: number | null;
+  /** Video bitrate in bits/s (original file) */
+  bitrate?: number | null;
   mimeType: string;
   status: string;
   transcodeStatus: TranscodeStatus;

+ 61 - 0
src/lib/upload-xhr.ts

@@ -0,0 +1,61 @@
+import { UploadItemInput } from '@/types/upload';
+
+type Action =
+  | { type: 'UPDATE_PROGRESS'; id: string; progress: number; bytesSent: number; bytesTotal: number; speedBps: number; etaSeconds: number }
+  | { type: 'SET_STATUS'; id: string; status: 'uploading' | 'success' | 'error' | 'cancelled'; assetId?: string; error?: string };
+
+export function startXHRUpload(
+  id: string,
+  item: UploadItemInput,
+  token: string,
+  dispatch: React.Dispatch<Action>,
+) {
+  const formData = new FormData();
+  formData.append('video', item.file);
+  formData.append('projectId', item.projectId);
+  formData.append('title', item.file.name.replace(/\.[^.]+$/, ''));
+  if (item.folderId) formData.append('folderId', item.folderId);
+
+  const xhr = new XMLHttpRequest();
+  const startTime = Date.now();
+
+  xhr.upload.addEventListener('progress', (e) => {
+    if (!e.lengthComputable) return;
+    const elapsed = (Date.now() - startTime) / 1000;
+    const speedBps = e.loaded / elapsed;
+    const etaSeconds = speedBps > 0 ? (e.total - e.loaded) / speedBps : 0;
+    dispatch({
+      type: 'UPDATE_PROGRESS',
+      id,
+      progress: Math.round((e.loaded / e.total) * 100),
+      bytesSent: e.loaded,
+      bytesTotal: e.total,
+      speedBps,
+      etaSeconds,
+    });
+  });
+
+  xhr.addEventListener('load', () => {
+    if (xhr.status >= 200 && xhr.status < 300) {
+      try {
+        const { asset } = JSON.parse(xhr.responseText) as { asset: { id: string } };
+        dispatch({ type: 'SET_STATUS', id, status: 'success', assetId: asset.id });
+      } catch {
+        dispatch({ type: 'SET_STATUS', id, status: 'error', error: 'Invalid server response' });
+      }
+    } else {
+      let msg = `HTTP ${xhr.status}`;
+      try { msg = (JSON.parse(xhr.responseText) as { error?: string })?.error ?? msg; } catch { /* noop */ }
+      dispatch({ type: 'SET_STATUS', id, status: 'error', error: msg });
+    }
+  });
+
+  xhr.addEventListener('error', () => dispatch({ type: 'SET_STATUS', id, status: 'error', error: 'Network error' }));
+  xhr.addEventListener('abort', () => dispatch({ type: 'SET_STATUS', id, status: 'cancelled' }));
+
+  xhr.open('POST', `${process.env.NEXT_PUBLIC_API_URL}/api/assets/upload`);
+  xhr.setRequestHeader('Authorization', `Bearer ${token}`);
+  xhr.send(formData);
+
+  return xhr;
+}

+ 33 - 0
src/types/upload.ts

@@ -0,0 +1,33 @@
+export interface UploadItem {
+  id: string;
+  projectId: string;
+  folderId?: string | null;
+  file: File;
+  status: 'queued' | 'uploading' | 'success' | 'error' | 'cancelled';
+  progress: number;     // 0-100
+  bytesSent: number;
+  bytesTotal: number;
+  speedBps: number;     // instantaneous bytes/sec
+  etaSeconds: number;   // estimated seconds remaining
+  assetId?: string;     // set after server responds 201
+  error?: string;
+  startedAt: number;    // Date.now()
+}
+
+/** Input type for enqueue() — all fields computed or set by the context */
+export type UploadItemInput = {
+  projectId: string;
+  folderId?: string | null;
+  file: File;
+};
+
+export interface UploadQueueContextValue {
+  uploads: UploadItem[];
+  enqueue: (item: UploadItemInput) => void;
+  cancelUpload: (id: string) => void;
+  retryUpload: (id: string) => void;
+  removeUpload: (id: string) => void;
+  isExpanded: boolean;
+  toggleExpanded: () => void;
+  totalActive: number;
+}