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>
134 lines
4.6 KiB
PHP
134 lines
4.6 KiB
PHP
<?php
|
|
|
|
namespace App\Jobs;
|
|
|
|
use App\Models\Setting;
|
|
use App\Models\Video;
|
|
use FFMpeg\FFMpeg;
|
|
use FFMpeg\Format\Video\X264;
|
|
use Illuminate\Support\Facades\Config;
|
|
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 CompressVideoJob implements ShouldQueue
|
|
{
|
|
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
|
|
|
|
public $video;
|
|
|
|
public function __construct(Video $video)
|
|
{
|
|
$this->video = $video;
|
|
}
|
|
|
|
public function handle()
|
|
{
|
|
$video = $this->video;
|
|
|
|
// Get original file path
|
|
$originalPath = storage_path('app/' . $video->path);
|
|
|
|
if (!file_exists($originalPath)) {
|
|
Log::error('CompressVideoJob: Original file not found: ' . $originalPath);
|
|
return;
|
|
}
|
|
|
|
// Create compressed file alongside the original
|
|
$compressedFilename = 'compressed_' . $video->filename;
|
|
$compressedPath = dirname($originalPath) . '/' . $compressedFilename;
|
|
|
|
try {
|
|
$ffmpegConfig = Config::get('ffmpeg');
|
|
$ffmpeg = FFMpeg::create([
|
|
'ffmpeg.binaries' => $ffmpegConfig['ffmpeg'] ?? '/usr/bin/ffmpeg',
|
|
'ffprobe.binaries' => $ffmpegConfig['ffprobe'] ?? '/usr/bin/ffprobe',
|
|
'timeout' => $ffmpegConfig['timeout'] ?? 3600,
|
|
]);
|
|
$ffmpegVideo = $ffmpeg->open($originalPath);
|
|
|
|
$gpuEnabled = Setting::gpuEnabled();
|
|
$encoder = Setting::gpuEncoder();
|
|
$preset = Setting::gpuPreset();
|
|
$device = Setting::gpuDevice();
|
|
|
|
if ($gpuEnabled) {
|
|
$videoPasses = [
|
|
"-c:v {$encoder}",
|
|
"-preset {$preset}",
|
|
'-rc vbr',
|
|
'-cq 23',
|
|
'-profile:v high',
|
|
'-pix_fmt yuv420p',
|
|
"-gpu {$device}",
|
|
];
|
|
} else {
|
|
$videoPasses = [
|
|
'-c:v libx264',
|
|
'-preset fast',
|
|
'-crf 23',
|
|
'-profile:v high',
|
|
'-pix_fmt yuv420p',
|
|
];
|
|
}
|
|
$audioPasses = ['-c:a aac', '-b:a 192k'];
|
|
|
|
$format = new X264('aac', $encoder);
|
|
foreach ($videoPasses as $pass) {
|
|
$format->addLegacyOption($pass);
|
|
}
|
|
foreach ($audioPasses as $pass) {
|
|
$format->addLegacyOption($pass);
|
|
}
|
|
$ffmpegVideo->save($format, $compressedPath);
|
|
|
|
// Check if compressed file was created and is smaller
|
|
if (file_exists($compressedPath)) {
|
|
$originalSize = filesize($originalPath);
|
|
$compressedSize = filesize($compressedPath);
|
|
|
|
// Only use compressed file if it's smaller
|
|
if ($compressedSize < $originalSize) {
|
|
// Delete original and rename compressed
|
|
unlink($originalPath);
|
|
rename($compressedPath, $originalPath);
|
|
|
|
// Update video record
|
|
$video->update([
|
|
'size' => $compressedSize,
|
|
'filename' => $video->filename, // Keep same filename
|
|
'mime_type' => 'video/mp4',
|
|
]);
|
|
|
|
Log::info('CompressVideoJob: Video compressed', [
|
|
'video_id' => $video->id,
|
|
'original_size' => $originalSize,
|
|
'compressed_size' => $compressedSize,
|
|
'saved' => round(($originalSize - $compressedSize) / $originalSize * 100) . '%',
|
|
'encoder' => $encoder,
|
|
'gpu' => $gpuEnabled,
|
|
]);
|
|
} else {
|
|
// Compressed file is larger, delete it
|
|
unlink($compressedPath);
|
|
Log::info('CompressVideoJob: Compression made file larger, keeping original');
|
|
}
|
|
}
|
|
|
|
$video->update(['status' => 'ready']);
|
|
|
|
// Chain to HLS generation for GPU-accelerated adaptive playback
|
|
\App\Jobs\GenerateHlsJob::dispatch($video);
|
|
|
|
} catch (\Exception $e) {
|
|
Log::error('CompressVideoJob failed: ' . $e->getMessage());
|
|
$video->update(['status' => 'ready']); // Mark as ready anyway
|
|
}
|
|
}
|
|
}
|
|
|