Storage structure
- All audio tracks (primary + per-language) now live in one folder per song with
unique lowercase names ({slug}-{lang}-{id}); no more tracks/ subfolder.
- Generated renders (download video + HLS) moved into the song's local-only cache/
subfolder, separated from source files (never synced to NAS, safe to wipe).
- tracks:reorganize artisan command (dry-run default) consolidates legacy files,
updates DB paths, and deletes orphans + empty folders.
- CLAUDE.md documents the canonical layout as a global rule (identical local + NAS).
Version-aware download & share
- Download MP3/Video and Share now act on the version being played; ?track={id}
is carried through share links and auto-selects audio + title + flag + about +
OG/meta on open.
GPU + visualizer
- Setting::gpuUsable() runs a cached health probe (nvidia-smi + nvenc smoke test,
256x144) before sending any encode to the GPU; falls back to CPU otherwise.
- Visualizer "Download Video" bakes in equal-width, cover-coloured, translucent
frequency bars; loop-filter rebuild makes generation ~25x faster.
Image cropper
- result-callback mode + per-song cover-slide cropper in upload/edit (modal + mobile).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
193 lines
7.0 KiB
PHP
193 lines
7.0 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;
|
||
}
|
||
}
|
||
|
||
// HLS rendition lives in the song's own cache/ subfolder (regenerable, local-only —
|
||
// never pushed to NAS). Fall back to the shared public/hls only for legacy rows
|
||
// whose path is not in the users/ layout.
|
||
$hlsDir = str_starts_with((string) $video->path, 'users/')
|
||
? dirname($video->path) . '/cache/hls'
|
||
: '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();
|
||
// Verify the GPU is actually reachable and able to encode before sending
|
||
// the file to it; otherwise fall back to CPU so the job never hangs.
|
||
$gpuEnabled = Setting::gpuUsable();
|
||
$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);
|
||
}
|
||
}
|
||
}
|