- CompressVideoJob: rewrite to exec ffmpeg directly. php-ffmpeg's X264 format class validates the codec name and only accepts libx264, so every job dispatched with h264_nvenc had been throwing within ~1s and leaving the source untouched. The job now honours the GPU encoder settings, and always replaces the source when the codec is HEVC/H.265 so downloads and the MP4 fallback stay playable. - VideoController + video-player component: point HLS URLs at playlist.m3u8 (the actual master file GenerateHlsJob writes) instead of the non-existent master.m3u8, which was 404-ing and forcing the player back to the raw MP4. - VideoController@store: on the NAS-success branch, flip status to 'ready' and populate duration/is_shorts before dispatching GenerateHlsJob, so the job no longer silently no-ops. - Upload chooser: render as a bottom sheet on mobile, and forward the picked type to /videos/create?type= so the create page pre-selects it. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
148 lines
5.5 KiB
PHP
148 lines
5.5 KiB
PHP
<?php
|
||
|
||
namespace App\Jobs;
|
||
|
||
use App\Models\Setting;
|
||
use App\Models\Video;
|
||
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;
|
||
|
||
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;
|
||
|
||
$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']);
|
||
}
|
||
}
|
||
}
|