video = $video; $this->onQueue('video-processing'); } public function handle() { $video = $this->video->fresh(); if ($video->status !== 'ready') { Log::warning('GenerateHlsJob: Video not ready', ['id' => $video->id]); return; } $sourcePath = storage_path('app/' . $video->path); $nasDownloaded = null; // track a NAS-fetched local copy so we can clean it up if (! file_exists($sourcePath)) { // NAS-primary mode: file lives on NAS, download a temporary local copy $nas = app(\App\Services\NasSyncService::class); if ($nas->isEnabled()) { $localCopy = $nas->ensureLocalCopy($video); if ($localCopy) { $sourcePath = $localCopy; $nasDownloaded = $localCopy; } } if (! file_exists($sourcePath)) { Log::error('GenerateHlsJob: Source file missing', ['path' => $sourcePath]); return; } } $hlsDir = 'public/hls/' . $video->id; $hlsPath = storage_path('app/' . $hlsDir); if (is_dir($hlsPath)) { Storage::deleteDirectory($hlsDir); } Storage::makeDirectory($hlsDir); $variants = [ ['height' => 480, 'name' => '480p', 'bitrate' => '1000k'], ['height' => 720, 'name' => '720p', 'bitrate' => '2500k'], ['height' => 1080, 'name' => '1080p', 'bitrate' => '5000k'], ]; foreach ($variants as $v) { @mkdir($hlsPath . '/' . $v['name'], 0755, true); } try { $ffmpegBin = \App\Models\Setting::ffmpegBinary(); $gpuEnabled = Setting::gpuEnabled(); $encoder = Setting::gpuEncoder(); // h264_nvenc / hevc_nvenc / libx264 $preset = Setting::gpuPreset(); // p1–p7 for NVENC, fast/medium/slow for x264 $device = Setting::gpuDevice(); // GPU index (0, 1, …) $hwaccel = Setting::gpuHwaccel(); // cuda / none $cmd = [escapeshellcmd($ffmpegBin)]; // Hardware-accelerated decode when GPU is in use if ($gpuEnabled && $hwaccel !== 'none') { $cmd[] = "-hwaccel {$hwaccel}"; $cmd[] = "-hwaccel_device {$device}"; } $cmd[] = '-i ' . escapeshellarg($sourcePath); // One video+audio stream per variant $n = count($variants); for ($i = 0; $i < $n; $i++) { $cmd[] = '-map 0:v:0'; $cmd[] = '-map 0:a:0?'; } // Video codec $cmd[] = "-c:v {$encoder}"; if ($gpuEnabled && str_contains($encoder, 'nvenc')) { $cmd[] = "-preset {$preset}"; $cmd[] = '-rc vbr'; $cmd[] = '-cq 23'; $cmd[] = "-gpu {$device}"; } else { $cmd[] = "-preset {$preset}"; $cmd[] = '-crf 23'; } $cmd[] = '-pix_fmt yuv420p'; // Audio codec $cmd[] = '-c:a aac'; $cmd[] = '-b:a 128k'; $cmd[] = '-ar 48000'; // Per-variant scale + bitrate for ($i = 0; $i < $n; $i++) { $cmd[] = "-filter:v:{$i} scale=-2:{$variants[$i]['height']}"; $cmd[] = "-b:v:{$i} {$variants[$i]['bitrate']}"; } // HLS muxer options $cmd[] = '-g 48'; $cmd[] = '-sc_threshold 0'; $cmd[] = '-f hls'; $cmd[] = '-hls_time 6'; $cmd[] = '-hls_list_size 0'; $cmd[] = '-hls_flags independent_segments'; $cmd[] = '-hls_segment_filename ' . escapeshellarg($hlsPath . '/%v/%03d.ts'); $cmd[] = '-master_pl_name playlist.m3u8'; $vsm = implode(' ', array_map( fn ($i, $v) => "v:{$i},a:{$i},name:{$v['name']}", array_keys($variants), $variants )); $cmd[] = '-var_stream_map ' . escapeshellarg($vsm); $cmd[] = escapeshellarg($hlsPath . '/%v/index.m3u8'); $fullCmd = implode(' ', $cmd) . ' 2>&1'; Log::info('GenerateHlsJob: Starting', [ 'video_id' => $video->id, 'gpu' => $gpuEnabled, 'encoder' => $encoder, 'device' => $gpuEnabled ? $device : 'cpu', ]); exec($fullCmd, $output, $exitCode); if ($exitCode !== 0) { $tail = implode("\n", array_slice($output, -30)); throw new \RuntimeException("FFmpeg exited {$exitCode}:\n{$tail}"); } $video->update(['has_hls' => true, 'hls_path' => $hlsDir]); Log::info('GenerateHlsJob: HLS generated', [ 'video_id' => $video->id, 'variants' => array_column($variants, 'name'), 'encoder' => $encoder, ]); $nas = app(\App\Services\NasSyncService::class); if ($nas->isEnabled()) { if ($nasDownloaded) { // NAS-primary mode: video was fetched from NAS for HLS generation. // The original is already on NAS — just delete the local temp copy. @unlink($nasDownloaded); } else { // Local-storage mode: push the (compressed) file to NAS and free local disk. // HLS segments stay local — per-segment SMB latency would hurt playback. $nas->syncVideo($video); $nas->deleteLocalVideo($video); $nas->deleteLocalAssets($video); } } } catch (\Exception $e) { Log::error('GenerateHlsJob failed: ' . $e->getMessage(), ['video_id' => $video->id]); Storage::deleteDirectory($hlsDir); } } }