takeone-youtube-clone/app/Jobs/GenerateHlsJob.php
ghassan 0b75acec89 Make NAS the primary storage when enabled (not a mirror)
When NAS sync is enabled:
- Audio uploads: pushed to NAS via NasSyncVideoJob, local file deleted immediately after
- Video uploads: processed locally (ffprobe, compress, HLS), then at the end of
  GenerateHlsJob the final compressed file is re-synced to NAS and the local copy removed
- stream() and download(): if local file is missing, pull from NAS into a local
  stream cache (storage/app/nas_cache/videos/) and serve from there with full
  byte-range support — so seeking still works over NAS-sourced files

When NAS is disabled:
- Upload, stream, and download all use local storage exclusively (no change)

HLS segments are intentionally kept local: they are small, generated on-demand,
and serving them via per-segment SMB round-trips would hurt playback performance.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-14 01:56:55 +03:00

168 lines
5.7 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<?php
namespace App\Jobs;
use App\Models\Setting;
use App\Models\Video;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Storage;
class GenerateHlsJob implements ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
public $video;
public function __construct(Video $video)
{
$this->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);
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(); // p1p7 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-primary storage: push the final compressed file to NAS (overwriting
// the uncompressed copy synced at upload time), then free local disk.
// HLS segments are kept local — they're small and serving them locally
// avoids per-segment SMB latency during HLS playback.
$nas = app(\App\Services\NasSyncService::class);
if ($nas->isEnabled()) {
$nas->syncVideo($video);
$nas->deleteLocalVideo($video);
}
} catch (\Exception $e) {
Log::error('GenerateHlsJob failed: ' . $e->getMessage(), ['video_id' => $video->id]);
Storage::deleteDirectory($hlsDir);
}
}
}