feat(media): HLS adaptive bitrate streaming with MP4 fallback

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 <link rel="prefetch">
- PublicVideoCard hover preview stays MP4 (avoids hls.js init latency)

Bunker Admin
This commit is contained in:
2026-04-30 19:03:29 -06:00
parent 2ae7d8b968
commit 21208b58c7
25 changed files with 1421 additions and 47 deletions

View File

@@ -0,0 +1,13 @@
-- CreateEnum
CREATE TYPE "HlsStatus" AS ENUM ('PENDING', 'PROCESSING', 'READY', 'FAILED', 'SKIPPED');
-- AlterTable
ALTER TABLE "videos" ADD COLUMN "hls_job_id" TEXT,
ADD COLUMN "hls_manifest_path" TEXT,
ADD COLUMN "hls_status" "HlsStatus",
ADD COLUMN "hls_transcode_error" TEXT,
ADD COLUMN "hls_transcoded_at" TIMESTAMP(3),
ADD COLUMN "hls_variants" JSONB;
-- CreateIndex
CREATE INDEX "idx_videos_hls_status" ON "videos"("hls_status");

View File

@@ -1485,6 +1485,15 @@ enum DirectoryType {
highlights
}
// HLS adaptive bitrate transcoding state for the Video model.
enum HlsStatus {
PENDING
PROCESSING
READY
FAILED
SKIPPED
}
enum ResourceCategory {
gpu_ai
gpu_encode
@@ -1800,6 +1809,17 @@ model Video {
// Uploader tracking
uploaderId String? @map("uploader_id")
// HLS adaptive bitrate transcoding state.
// null = never queued; PENDING after upload; PROCESSING when worker picks up;
// READY when master.m3u8 + variants exist on disk; FAILED on transcode error;
// SKIPPED when ENABLE_HLS_TRANSCODE was off at enqueue time.
hlsStatus HlsStatus? @map("hls_status")
hlsManifestPath String? @map("hls_manifest_path") // /media/local/hls/{id}/master.m3u8
hlsTranscodedAt DateTime? @map("hls_transcoded_at")
hlsTranscodeError String? @map("hls_transcode_error")
hlsVariants Json? @map("hls_variants") // [{height, bitrate, path}, ...]
hlsJobId String? @map("hls_job_id")
// Relations
uploader User? @relation("VideoUploader", fields: [uploaderId], references: [id])
locker User? @relation("VideoLocker", fields: [lockedById], references: [id])
@@ -1840,6 +1860,7 @@ model Video {
@@index([category, isPublished], map: "idx_videos_category_published")
@@index([isShort, isPublished, isLocked], map: "idx_videos_short_published")
@@index([uploaderId], map: "idx_videos_uploader")
@@index([hlsStatus], map: "idx_videos_hls_status")
@@map("videos")
}