From 21208b58c7aca72557c54aeb8bf79a55f69eacf2 Mon Sep 17 00:00:00 2001 From: bunker-admin Date: Thu, 30 Apr 2026 19:03:29 -0600 Subject: [PATCH] feat(media): HLS adaptive bitrate streaming with MP4 fallback MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replaces single-MP4 + range-request streaming with HLS multi-bitrate segments to fix video stutter through the Newt tunnel. Range-request bursts were the root cause; HLS chunks are small and tunnel-friendly, plus the player adapts bitrate to bandwidth. Backend - New BullMQ `hls-transcode` queue (in-process worker, concurrency 1) - FFmpeg single-pass transcode → 360p/720p/1080p variants with aligned keyframes; output at /media/local/hls/{id}/master.m3u8 - New /api/{videos|public}/{id}/hls/* routes serving signed manifests and segments (URLs emitted as /media/* so nginx rewrites to media-api) - Prisma: HlsStatus enum + 6 fields on Video + index, migration - Upload + yt-dlp fetch paths enqueue transcode jobs - ENABLE_HLS_TRANSCODE flag (default off; gates enqueue only) - Backfill script: `npm run backfill:hls` - media-api bumped to 4 CPU / 2G for FFmpeg headroom Frontend - New useHls hook: lazy-imports hls.js (kept out of main bundle), native HLS on Safari/iOS, gives up after 2 NETWORK_ERRORs so MP4 fallback engages cleanly - VideoPlayer, VideoViewerModal, ShortsPage, ProductDetailPage now prefer HLS when ready; MP4 fallback is automatic - ShortsPage prefetches next-3 master manifests via - PublicVideoCard hover preview stays MP4 (avoids hls.js init latency) Bunker Admin --- .env.example | 7 + CLAUDE.md | 40 +- admin/package-lock.json | 7 + admin/package.json | 1 + admin/src/components/media/VideoPlayer.tsx | 20 +- .../src/components/media/VideoViewerModal.tsx | 13 +- admin/src/lib/use-hls.ts | 150 +++++++ admin/src/pages/public/ProductDetailPage.tsx | 56 ++- admin/src/pages/public/ShortsPage.tsx | 107 ++++- admin/src/types/media.ts | 5 + admin/src/utils/video.ts | 17 + api/package.json | 3 +- .../migration.sql | 13 + api/prisma/schema.prisma | 21 + api/scripts/backfill-hls.ts | 67 +++ api/src/config/env.ts | 6 + api/src/media-server.ts | 10 + api/src/modules/media/routes/hls.routes.ts | 383 ++++++++++++++++++ api/src/modules/media/routes/upload.routes.ts | 15 + .../media/routes/video-streaming.routes.ts | 41 +- .../media/services/hls-transcode.service.ts | 211 ++++++++++ .../services/hls-transcode-queue.service.ts | 238 +++++++++++ api/src/services/video-fetch-queue.service.ts | 13 + docker-compose.prod.yml | 11 +- docker-compose.yml | 13 +- 25 files changed, 1421 insertions(+), 47 deletions(-) create mode 100644 admin/src/lib/use-hls.ts create mode 100644 api/prisma/migrations/20260430225432_add_hls_fields/migration.sql create mode 100644 api/scripts/backfill-hls.ts create mode 100644 api/src/modules/media/routes/hls.routes.ts create mode 100644 api/src/modules/media/services/hls-transcode.service.ts create mode 100644 api/src/services/hls-transcode-queue.service.ts diff --git a/.env.example b/.env.example index d7420320..3840e4b3 100644 --- a/.env.example +++ b/.env.example @@ -188,6 +188,13 @@ MEDIA_API_PORT=4100 MEDIA_API_PUBLIC_URL=http://media-api:4100 # Used during admin Docker build to set the media API endpoint for Vite VITE_MEDIA_API_URL=http://changemaker-media-api:4100 +# HLS adaptive bitrate transcoding. When 'true', uploaded videos are queued +# for FFmpeg transcoding into 360p/720p/1080p HLS variants and the player +# prefers HLS over the MP4 range-request stream. When 'false' (default), +# uploads are tagged SKIPPED and the player falls back to MP4 — no DB or +# disk impact, fully reversible. The worker is always registered so existing +# PENDING jobs from a prior run still process if you flip the flag back on. +ENABLE_HLS_TRANSCODE=false MEDIA_ROOT=/media/library MEDIA_UPLOADS=/media/uploads MAX_UPLOAD_SIZE_GB=10 diff --git a/CLAUDE.md b/CLAUDE.md index 9aa7391d..d7b71bbc 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -298,6 +298,7 @@ Most features are toggled via **SiteSettings** in the database (admin Settings p ```bash # .env feature flags (env-level) ENABLE_MEDIA_FEATURES=true # Media manager +ENABLE_HLS_TRANSCODE=true # HLS adaptive bitrate transcoding (off by default) ENABLE_PAYMENTS=true # Stripe integration ENABLE_SMS=true # SMS campaigns ENABLE_CHAT=true # Rocket.Chat @@ -489,9 +490,13 @@ cd api && npx tsc --noEmit && cd ../admin && npx tsc --noEmit **Files:** - `api/src/modules/media/` — Fastify media API (videos, reactions, jobs, analytics) -- `api/src/modules/media/services/` — FFprobe, video analytics service -- `api/src/modules/media/routes/` — Video CRUD, actions, schedule, analytics, tracking, upload +- `api/src/modules/media/services/` — FFprobe, thumbnail, **HLS transcode** services +- `api/src/modules/media/routes/` — Video CRUD, actions, schedule, analytics, tracking, upload, **HLS streaming** - `api/src/services/video-schedule-queue.service.ts` — BullMQ queue for scheduled publishing +- `api/src/services/hls-transcode-queue.service.ts` — BullMQ queue for HLS adaptive bitrate transcoding (concurrency 1, in-process worker) +- `api/src/modules/media/routes/hls.routes.ts` — Master/variant playlist + segment serving with signed URLs +- `api/scripts/backfill-hls.ts` — Backfill HLS for pre-existing videos (`npm run backfill:hls`) +- `admin/src/lib/use-hls.ts` — React hook attaching hls.js (Chrome/FF/Edge) or native (Safari/iOS) - `admin/src/lib/media-api.ts` — Dedicated axios instance for Media API - `admin/src/pages/media/LibraryPage.tsx` — Video library with quick actions + calendar - `admin/src/pages/media/AnalyticsDashboardPage.tsx` — Global analytics dashboard @@ -499,7 +504,15 @@ cd api && npx tsc --noEmit && cd ../admin && npx tsc --noEmit - `admin/src/pages/public/MediaGalleryPage.tsx` — Public video gallery - `admin/src/components/media/` — VideoCard, VideoActions, modals, charts -**Features:** Video CRUD with FFprobe metadata, quick actions, scheduled publishing (BullMQ + timezones), analytics (GDPR-compliant), public tracking endpoints, keyboard shortcuts +**Features:** Video CRUD with FFprobe metadata, quick actions, scheduled publishing (BullMQ + timezones), analytics (GDPR-compliant), public tracking endpoints, keyboard shortcuts, **HLS adaptive bitrate streaming (360p/720p/1080p, MP4 fallback)**. + +**HLS adaptive bitrate streaming:** +- On upload, a BullMQ `hls-transcode` job runs FFmpeg to produce a master playlist + 3 keyframe-aligned variants under `/media/local/hls/{videoId}/`. Concurrency is 1; the worker runs in-process with the media-api Fastify server. +- Player prefers HLS over MP4 when `Video.hlsStatus === 'READY'`. MP4 streaming routes stay as the always-on fallback for un-transcoded videos and for hover-preview cards (where 200ms hls.js init defeats the UX — `PublicVideoCard` stays MP4). +- `useHls()` hook lazy-imports hls.js (~75 KB gzipped, never enters main bundle), uses native HLS on Safari/iOS, gives up after 2 NETWORK_ERROR retries so the MP4 fallback can kick in. +- Manifest URLs are HMAC-signed (`?sig=&exp=&uid=`) per existing `signMediaPath()` pattern. Variant playlists rewrite their segment URIs server-side at fetch time so each segment carries a fresh signature. +- Feature flag: `ENABLE_HLS_TRANSCODE` (default `false`). When off, uploads are tagged `SKIPPED` and the player falls back to MP4 — fully reversible. The worker stays registered so existing `PENDING` jobs still process if the flag flips back on. +- Backfill: `docker compose exec api npm run backfill:hls` enqueues all `hlsStatus IS NULL` videos. Bypasses the flag (operator opt-in). At ~2 min per 1080p video, throughput is ~30/hour. **Routes:** - Admin: `/app/media/library`, `/app/media/analytics`, `/app/media/shared`, `/app/media/jobs` @@ -770,6 +783,27 @@ Check in order: ### Database/Redis Connection Failures Check container status (`docker compose ps`), verify credentials in `.env`, check logs (`docker compose logs --tail 50`). Test DB: `docker compose exec api npx prisma db execute --stdin <<< "SELECT 1"`. Test Redis: `docker compose exec redis-changemaker redis-cli -a $REDIS_PASSWORD ping`. +### Video Stuck in HLS PROCESSING / FAILED with EACCES +**Symptom:** A video shows `hlsStatus = 'PROCESSING'` for many minutes; or `'FAILED'` with `hls_transcode_error LIKE '%EACCES%'`. Player keeps falling back to MP4. + +Check in order: +1. **First-run perms.** If `hls_transcode_error` contains `EACCES: permission denied, mkdir '/media/local/hls/'`, the bind-mount got created as `root:root` but the Node process runs as `node` (UID 1000). One-time fix: + ``` + docker compose exec -u 0 media-api chown -R 1000:1000 /media/local/hls + ``` + Then reset and re-enqueue: + ``` + docker compose exec -T v2-postgres psql -U changemaker -d changemaker_v2 -c "UPDATE videos SET hls_status = NULL, hls_transcode_error = NULL WHERE hls_status = 'FAILED';" + docker compose exec api npm run backfill:hls + ``` +2. **Worker running:** `docker compose logs media-api --tail 100 | grep -i hls` — expect `[hls]` lines for the queue worker startup and per-job progress. +3. **FFmpeg in container:** `docker compose exec media-api ffmpeg -version` — should print FFmpeg version. (Already in `Dockerfile.media`.) +4. **Queue depth:** `docker compose exec redis-changemaker redis-cli -a $REDIS_PASSWORD LLEN bull:hls-transcode:wait` — non-zero means jobs are queued behind a slow one. +5. **Disk space at output:** `docker compose exec media-api df -h /media/local/hls` — transcoding can consume several GB per video. +6. **Failure record:** `docker compose exec api npx prisma studio` → Video table → check `hlsTranscodeError`. + +To force a re-transcode of a failed video, set `hlsStatus = NULL` in the DB and run `npm run backfill:hls`. + --- ## V1 Reference (Legacy) diff --git a/admin/package-lock.json b/admin/package-lock.json index 7bba49a4..275d64d1 100644 --- a/admin/package-lock.json +++ b/admin/package-lock.json @@ -33,6 +33,7 @@ "grapesjs-tabs": "^1.0.6", "grapesjs-touch": "^0.1.1", "grapesjs-typed": "^2.0.1", + "hls.js": "^1.6.16", "html5-qrcode": "^2.3.8", "jwt-decode": "^4.0.0", "leaflet": "^1.9.4", @@ -2634,6 +2635,12 @@ "node": ">= 0.4" } }, + "node_modules/hls.js": { + "version": "1.6.16", + "resolved": "https://registry.npmjs.org/hls.js/-/hls.js-1.6.16.tgz", + "integrity": "sha512-VSIRpLfRwlAAdGL4wiTucx2ScRipo0ed1FBatWkyt832jC4CReKstga6yIhYVwGu9LOBjuX9wzmRMeQdBJtzEA==", + "license": "Apache-2.0" + }, "node_modules/html-entities": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/html-entities/-/html-entities-1.4.0.tgz", diff --git a/admin/package.json b/admin/package.json index 84199571..3911bc75 100644 --- a/admin/package.json +++ b/admin/package.json @@ -34,6 +34,7 @@ "grapesjs-tabs": "^1.0.6", "grapesjs-touch": "^0.1.1", "grapesjs-typed": "^2.0.1", + "hls.js": "^1.6.16", "html5-qrcode": "^2.3.8", "jwt-decode": "^4.0.0", "leaflet": "^1.9.4", diff --git a/admin/src/components/media/VideoPlayer.tsx b/admin/src/components/media/VideoPlayer.tsx index 43f81456..97350633 100644 --- a/admin/src/components/media/VideoPlayer.tsx +++ b/admin/src/components/media/VideoPlayer.tsx @@ -3,6 +3,7 @@ import { Alert, Spin } from 'antd'; import { PlayCircleOutlined } from '@ant-design/icons'; import { getAuthCallbacks } from '@/lib/api'; import { signedMediaUrl } from '@/lib/media-url'; +import { useHls } from '@/lib/use-hls'; export interface VideoMetadata { id: number; @@ -15,6 +16,8 @@ export interface VideoMetadata { quality: string | null; streamUrl: string; thumbnailUrl: string | null; + hlsStatus?: 'PENDING' | 'PROCESSING' | 'READY' | 'FAILED' | 'SKIPPED' | null; + hlsManifestUrl?: string | null; createdAt: string; } @@ -68,6 +71,13 @@ export const VideoPlayer = forwardRef(({ const [loading, setLoading] = useState(true); const [error, setError] = useState(null); + // Attach HLS when manifest is ready. Must be called unconditionally on + // every render (rules of hooks) — even before the loading/error early + // returns. The hook is a no-op when manifestUrl is null. + const hlsManifestUrl = metadata?.hlsStatus === 'READY' ? metadata.hlsManifestUrl ?? null : null; + const { error: hlsError } = useHls(videoRef, hlsManifestUrl); + const useMp4Src = !hlsManifestUrl || !!hlsError; + // Expose control methods via ref useImperativeHandle(ref, () => ({ play: () => { @@ -150,7 +160,9 @@ export const VideoPlayer = forwardRef(({ const data = await response.json(); // For admin previews of unpublished media, sign stream/thumbnail URLs - // (the legacy ?token= path was removed 2026-04-12). + // (the legacy ?token= path was removed 2026-04-12). The HLS + // manifest URL is already signed server-side by the metadata route, so + // we leave it untouched. if (isAdmin) { if (data.streamUrl) data.streamUrl = await signedMediaUrl(data.streamUrl); if (data.thumbnailUrl) data.thumbnailUrl = await signedMediaUrl(data.thumbnailUrl); @@ -212,6 +224,10 @@ export const VideoPlayer = forwardRef(({ ? (metadata.height / metadata.width) * 100 : 56.25; // Default to 16:9 + // (HLS attachment + MP4 fallback flag are computed at the top of the + // component, before the loading/error early returns, to satisfy the rules + // of hooks. See useMp4Src above.) + return (
(({ >
@@ -291,3 +284,38 @@ export default function ProductDetailPage() { ); } + +// Product video player. Optimistically attempts the public HLS manifest; if +// the video isn't transcoded yet, hls.js fails fast (after 2 retries) and we +// fall back to the MP4 stream. This avoids needing the API to expose +// hlsStatus on the Product serializer for v1. +function ProductVideoPlayer({ + videoId, + streamUrl, + poster, +}: { + videoId: number; + streamUrl: string; + poster?: string; +}) { + const videoRef = useRef(null); + const hlsManifestUrl = `/media/public/${videoId}/hls/master.m3u8`; + const { error: hlsError } = useHls(videoRef, hlsManifestUrl); + const useMp4Src = !!hlsError; + + return ( +