When NAS storage is enabled, uploaded files go directly to the NAS share
(users/{username}/videos/{title-slug}/) with no permanent local copy kept.
Thumbnails and video are fetched from NAS on demand for streaming/playback.
When NAS is disabled, files are organised into the same directory schema
in local storage.
- VideoController: branch upload flow on NAS enabled/disabled
- NasSyncService: add uploadDirectToNas() for direct NAS writes,
organizeLocalFiles() for local NAS-schema, localVideoDir() resolver,
deleteLocalAssets() for post-sync cleanup
- GenerateHlsJob: download from NAS via ensureLocalCopy() when local
file is absent (NAS-primary mode); clean up temp after HLS generation
- CompressVideoJob: place compressed file alongside original (any dir)
- Video/VideoSlide models: localVideoPath(), localThumbnailPath(),
thumbnailStorageKey(), localPath(), storageKey() helpers for
format-agnostic path resolution (old flat paths + new NAS-schema paths)
- MediaController: serve thumbnails from NAS-mirrored paths with NAS fallback
- SuperAdminController: use model path helpers for file deletion
- NasFreeLocalStorage: scan new users/ tree in addition to legacy flat dirs
- Settings: rename "NAS Storage Sync" tab to "NAS Storage", update description
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
186 lines
6.5 KiB
PHP
186 lines
6.5 KiB
PHP
<?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);
|
||
$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);
|
||
}
|
||
}
|
||
}
|