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:
@@ -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");
|
||||
@@ -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")
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user