video = $video; } public function handle() { $video = $this->video; $originalPath = storage_path('app/' . $video->path); if (!file_exists($originalPath)) { Log::error('CompressVideoJob: Original file not found: ' . $originalPath); return; } $compressedFilename = 'compressed_' . $video->filename; $compressedPath = dirname($originalPath) . '/' . $compressedFilename; try { $ffmpegConfig = Config::get('ffmpeg'); $ffmpegBin = $ffmpegConfig['ffmpeg'] ?? '/usr/bin/ffmpeg'; $ffprobeBin = $ffmpegConfig['ffprobe'] ?? '/usr/bin/ffprobe'; // Detect the source video codec. Browsers on Chrome/Firefox/Android can't // decode HEVC (H.265/hev1/hvc1) — those sources must be replaced with H.264 // even if the transcode is larger, so downloads and MP4 fallback stay playable. $srcCodec = ''; try { $probeOut = []; exec(escapeshellcmd($ffprobeBin) . ' -v error -select_streams v:0 -show_entries stream=codec_name -of csv=p=0 ' . escapeshellarg($originalPath), $probeOut); $srcCodec = strtolower(trim($probeOut[0] ?? '')); } catch (\Throwable $e) {} $forceReplace = in_array($srcCodec, ['hevc', 'h265', 'hev1', 'hvc1'], true); $gpuEnabled = Setting::gpuUsable(); $encoder = Setting::gpuEncoder(); // h264_nvenc / libx264 $preset = Setting::gpuPreset(); // p1–p7 for NVENC, fast/medium/slow for x264 $device = Setting::gpuDevice(); $hwaccel = Setting::gpuHwaccel(); // Build ffmpeg command directly — php-ffmpeg's X264 format validator // only accepts 'libx264' and throws on 'h264_nvenc'. $cmd = [escapeshellcmd($ffmpegBin), '-y']; if ($gpuEnabled && $hwaccel !== 'none') { $cmd[] = "-hwaccel {$hwaccel}"; $cmd[] = "-hwaccel_device {$device}"; } $cmd[] = '-i ' . escapeshellarg($originalPath); $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[] = '-profile:v high'; $cmd[] = '-pix_fmt yuv420p'; $cmd[] = '-c:a aac'; $cmd[] = '-b:a 192k'; $cmd[] = '-movflags +faststart'; $cmd[] = escapeshellarg($compressedPath); $fullCmd = implode(' ', $cmd) . ' 2>&1'; Log::info('CompressVideoJob: Starting', [ 'video_id' => $video->id, 'src_codec' => $srcCodec, 'encoder' => $encoder, 'gpu' => $gpuEnabled, ]); $output = []; exec($fullCmd, $output, $exitCode); if ($exitCode !== 0) { $tail = implode("\n", array_slice($output, -30)); throw new \RuntimeException("FFmpeg exited {$exitCode}:\n{$tail}"); } if (file_exists($compressedPath)) { $originalSize = filesize($originalPath); $compressedSize = filesize($compressedPath); if ($compressedSize < $originalSize || $forceReplace) { unlink($originalPath); rename($compressedPath, $originalPath); $video->update([ 'size' => $compressedSize, 'mime_type' => 'video/mp4', ]); Log::info('CompressVideoJob: Video compressed', [ 'video_id' => $video->id, 'original_size' => $originalSize, 'compressed_size' => $compressedSize, 'saved' => round(($originalSize - $compressedSize) / max(1, $originalSize) * 100) . '%', 'encoder' => $encoder, 'gpu' => $gpuEnabled, 'src_codec' => $srcCodec, 'forced' => $forceReplace && $compressedSize >= $originalSize, ]); } else { unlink($compressedPath); Log::info('CompressVideoJob: Compression made file larger, keeping original', ['src_codec' => $srcCodec]); } } $video->update(['status' => 'ready']); \App\Jobs\GenerateHlsJob::dispatch($video); } catch (\Exception $e) { Log::error('CompressVideoJob failed: ' . $e->getMessage()); if (file_exists($compressedPath)) @unlink($compressedPath); $video->update(['status' => 'ready']); } } }