Fix match-video playback: HEVC → H.264 transcode + correct HLS URL

- 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>
This commit is contained in:
ghassan 2026-08-08 02:08:25 +03:00
parent e0aa6e4817
commit 76f5ef0c84
5 changed files with 170 additions and 68 deletions

View File

@ -479,8 +479,29 @@ class VideoController extends Controller
} }
if ($nasUploadSucceeded) { if ($nasUploadSucceeded) {
// For non-audio: HLS generation still runs (downloads from NAS, keeps HLS local) // For non-audio: HLS generation still runs (downloads from NAS, keeps HLS local).
// GenerateHlsJob no-ops unless status === 'ready', and on the NAS branch the
// upload IS the "done" state (there's no local CompressVideoJob to flip it).
if (! $isAudioUpload) { if (! $isAudioUpload) {
$duration = 0;
try {
$ffprobeBin = config('ffmpeg.ffprobe.binaries', '/usr/bin/ffprobe');
$localCopy = app(\App\Services\NasSyncService::class)->ensureLocalCopy($video->fresh());
if ($localCopy && file_exists($localCopy)) {
$out = [];
exec("{$ffprobeBin} -v error -show_entries format=duration -of csv=p=0 " . escapeshellarg($localCopy), $out);
$duration = (int) round((float) ($out[0] ?? 0));
}
} catch (\Throwable $e) {
\Log::warning('store: ffprobe duration failed: ' . $e->getMessage());
}
$video->update([
'status' => 'ready',
'duration' => $duration ?: $video->duration,
'is_shorts' => ($duration ?: $video->duration) <= 60 && $orientation === 'portrait',
]);
\App\Jobs\GenerateHlsJob::dispatch($video->fresh()) \App\Jobs\GenerateHlsJob::dispatch($video->fresh())
->onQueue('video-processing') ->onQueue('video-processing')
->onConnection('database'); ->onConnection('database');
@ -831,7 +852,7 @@ class VideoController extends Controller
'key' => $video->getRouteKey(), 'key' => $video->getRouteKey(),
'type' => $video->type, 'type' => $video->type,
'has_hls' => (bool) $video->has_hls, 'has_hls' => (bool) $video->has_hls,
'hls_url' => $video->has_hls ? route('videos.hls', ['video' => $video, 'file' => 'master.m3u8']) : null, 'hls_url' => $video->has_hls ? route('videos.hls', ['video' => $video, 'file' => 'playlist.m3u8']) : null,
'stream_url' => route('videos.stream', $video) . '?v=' . $video->updated_at->timestamp, 'stream_url' => route('videos.stream', $video) . '?v=' . $video->updated_at->timestamp,
// When a specific language track is requested (playlist row pin, // When a specific language track is requested (playlist row pin,
// shared link with ?track=), expose it separately. The player uses // shared link with ?track=), expose it separately. The player uses

View File

@ -4,8 +4,6 @@ namespace App\Jobs;
use App\Models\Setting; use App\Models\Setting;
use App\Models\Video; use App\Models\Video;
use FFMpeg\FFMpeg;
use FFMpeg\Format\Video\X264;
use Illuminate\Support\Facades\Config; use Illuminate\Support\Facades\Config;
use Illuminate\Bus\Queueable; use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue; use Illuminate\Contracts\Queue\ShouldQueue;
@ -13,7 +11,6 @@ use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue; use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels; use Illuminate\Queue\SerializesModels;
use Illuminate\Support\Facades\Log; use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Storage;
class CompressVideoJob implements ShouldQueue class CompressVideoJob implements ShouldQueue
{ {
@ -30,7 +27,6 @@ class CompressVideoJob implements ShouldQueue
{ {
$video = $this->video; $video = $this->video;
// Get original file path
$originalPath = storage_path('app/' . $video->path); $originalPath = storage_path('app/' . $video->path);
if (!file_exists($originalPath)) { if (!file_exists($originalPath)) {
@ -38,71 +34,87 @@ class CompressVideoJob implements ShouldQueue
return; return;
} }
// Create compressed file alongside the original
$compressedFilename = 'compressed_' . $video->filename; $compressedFilename = 'compressed_' . $video->filename;
$compressedPath = dirname($originalPath) . '/' . $compressedFilename; $compressedPath = dirname($originalPath) . '/' . $compressedFilename;
try { try {
$ffmpegConfig = Config::get('ffmpeg'); $ffmpegConfig = Config::get('ffmpeg');
$ffmpeg = FFMpeg::create([ $ffmpegBin = $ffmpegConfig['ffmpeg'] ?? '/usr/bin/ffmpeg';
'ffmpeg.binaries' => $ffmpegConfig['ffmpeg'] ?? '/usr/bin/ffmpeg', $ffprobeBin = $ffmpegConfig['ffprobe'] ?? '/usr/bin/ffprobe';
'ffprobe.binaries' => $ffmpegConfig['ffprobe'] ?? '/usr/bin/ffprobe',
'timeout' => $ffmpegConfig['timeout'] ?? 3600, // 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
$ffmpegVideo = $ffmpeg->open($originalPath); // 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);
// 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(); $gpuEnabled = Setting::gpuUsable();
$encoder = Setting::gpuEncoder(); $encoder = Setting::gpuEncoder(); // h264_nvenc / libx264
$preset = Setting::gpuPreset(); $preset = Setting::gpuPreset(); // p1p7 for NVENC, fast/medium/slow for x264
$device = Setting::gpuDevice(); $device = Setting::gpuDevice();
$hwaccel = Setting::gpuHwaccel();
if ($gpuEnabled) { // Build ffmpeg command directly — php-ffmpeg's X264 format validator
$videoPasses = [ // only accepts 'libx264' and throws on 'h264_nvenc'.
"-c:v {$encoder}", $cmd = [escapeshellcmd($ffmpegBin), '-y'];
"-preset {$preset}",
'-rc vbr', if ($gpuEnabled && $hwaccel !== 'none') {
'-cq 23', $cmd[] = "-hwaccel {$hwaccel}";
'-profile:v high', $cmd[] = "-hwaccel_device {$device}";
'-pix_fmt yuv420p', }
"-gpu {$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 { } else {
$videoPasses = [ $cmd[] = "-preset {$preset}";
'-c:v libx264', $cmd[] = '-crf 23';
'-preset fast',
'-crf 23',
'-profile:v high',
'-pix_fmt yuv420p',
];
} }
$audioPasses = ['-c:a aac', '-b:a 192k'];
$format = new X264('aac', $encoder); $cmd[] = '-profile:v high';
foreach ($videoPasses as $pass) { $cmd[] = '-pix_fmt yuv420p';
$format->addLegacyOption($pass); $cmd[] = '-c:a aac';
} $cmd[] = '-b:a 192k';
foreach ($audioPasses as $pass) { $cmd[] = '-movflags +faststart';
$format->addLegacyOption($pass); $cmd[] = escapeshellarg($compressedPath);
}
$ffmpegVideo->save($format, $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}");
}
// Check if compressed file was created and is smaller
if (file_exists($compressedPath)) { if (file_exists($compressedPath)) {
$originalSize = filesize($originalPath); $originalSize = filesize($originalPath);
$compressedSize = filesize($compressedPath); $compressedSize = filesize($compressedPath);
// Only use compressed file if it's smaller if ($compressedSize < $originalSize || $forceReplace) {
if ($compressedSize < $originalSize) {
// Delete original and rename compressed
unlink($originalPath); unlink($originalPath);
rename($compressedPath, $originalPath); rename($compressedPath, $originalPath);
// Update video record
$video->update([ $video->update([
'size' => $compressedSize, 'size' => $compressedSize,
'filename' => $video->filename, // Keep same filename
'mime_type' => 'video/mp4', 'mime_type' => 'video/mp4',
]); ]);
@ -110,26 +122,26 @@ class CompressVideoJob implements ShouldQueue
'video_id' => $video->id, 'video_id' => $video->id,
'original_size' => $originalSize, 'original_size' => $originalSize,
'compressed_size' => $compressedSize, 'compressed_size' => $compressedSize,
'saved' => round(($originalSize - $compressedSize) / $originalSize * 100) . '%', 'saved' => round(($originalSize - $compressedSize) / max(1, $originalSize) * 100) . '%',
'encoder' => $encoder, 'encoder' => $encoder,
'gpu' => $gpuEnabled, 'gpu' => $gpuEnabled,
'src_codec' => $srcCodec,
'forced' => $forceReplace && $compressedSize >= $originalSize,
]); ]);
} else { } else {
// Compressed file is larger, delete it
unlink($compressedPath); unlink($compressedPath);
Log::info('CompressVideoJob: Compression made file larger, keeping original'); Log::info('CompressVideoJob: Compression made file larger, keeping original', ['src_codec' => $srcCodec]);
} }
} }
$video->update(['status' => 'ready']); $video->update(['status' => 'ready']);
// Chain to HLS generation for GPU-accelerated adaptive playback
\App\Jobs\GenerateHlsJob::dispatch($video); \App\Jobs\GenerateHlsJob::dispatch($video);
} catch (\Exception $e) { } catch (\Exception $e) {
Log::error('CompressVideoJob failed: ' . $e->getMessage()); Log::error('CompressVideoJob failed: ' . $e->getMessage());
$video->update(['status' => 'ready']); // Mark as ready anyway if (file_exists($compressedPath)) @unlink($compressedPath);
$video->update(['status' => 'ready']);
} }
} }
} }

View File

@ -9,7 +9,7 @@
@php @php
// Force 16:9 for every video — orientation classes intentionally disabled // Force 16:9 for every video — orientation classes intentionally disabled
$orientationClass = ''; $orientationClass = '';
$hlsUrl = $video->has_hls ? route('videos.hls', ['video' => $video, 'file' => 'master.m3u8']) : null; $hlsUrl = $video->has_hls ? route('videos.hls', ['video' => $video, 'file' => 'playlist.m3u8']) : null;
$mp4Url = route('videos.stream', $video) . '?v=' . $video->updated_at->timestamp; $mp4Url = route('videos.stream', $video) . '?v=' . $video->updated_at->timestamp;
$nextUrl = $nextVideo && $playlist ? route('videos.show', $nextVideo) .'?playlist='.$playlist->share_token : null; $nextUrl = $nextVideo && $playlist ? route('videos.show', $nextVideo) .'?playlist='.$playlist->share_token : null;
$prevUrl = $previousVideo && $playlist ? route('videos.show', $previousVideo).'?playlist='.$playlist->share_token : null; $prevUrl = $previousVideo && $playlist ? route('videos.show', $previousVideo).'?playlist='.$playlist->share_token : null;

View File

@ -711,6 +711,55 @@
} }
.utc-card:hover .utc-card-arr { opacity: 1; transform: translateX(0); } .utc-card:hover .utc-card-arr { opacity: 1; transform: translateX(0); }
/* ── Mobile: render the chooser as a bottom sheet ───────────── */
@media (max-width: 768px) {
.utc-overlay {
align-items: flex-end; justify-content: stretch;
padding: 0; background: rgba(0,0,0,.55);
backdrop-filter: blur(4px);
}
.utc-box {
max-width: none; width: 100%;
border-radius: 20px 20px 0 0;
border-left: none; border-right: none; border-bottom: none;
padding: 10px 18px calc(22px + env(safe-area-inset-bottom));
transform: translateY(100%); transition: transform .28s cubic-bezier(.22,1,.36,1);
box-shadow: 0 -12px 40px rgba(0,0,0,.6);
max-height: 88vh; overflow-y: auto;
}
.utc-overlay.show .utc-box { transform: translateY(0); }
/* Grabber handle at the top */
.utc-box::before {
content: ''; display: block; width: 40px; height: 4px;
background: #333; border-radius: 2px; margin: 4px auto 14px;
}
.utc-close { top: 12px; right: 12px; }
.utc-head { margin-bottom: 16px; }
.utc-head-icon { width: 44px; height: 44px; font-size: 20px; margin-bottom: 10px; }
.utc-title { font-size: 17px; }
.utc-sub { font-size: 12px; }
/* Row of compact tiles instead of full stacked cards */
.utc-grid { grid-template-columns: repeat(3, 1fr); gap: 10px; }
.utc-card {
padding: 16px 8px 14px; border-radius: 14px; border-width: 1px;
}
.utc-card-ico {
width: 44px; height: 44px; margin-bottom: 8px;
font-size: 20px; border-radius: 12px;
}
.utc-card-title { font-size: 13px; }
.utc-card-desc { display: none; }
.utc-card-arr { display: none; }
.utc-card:active {
transform: scale(.96);
background: color-mix(in srgb, var(--accent) 10%, #111);
border-color: var(--accent);
}
}
/* ════════════════════════════════════════════════════════════════ /* ════════════════════════════════════════════════════════════════
Unified control system one consistent look for every field, Unified control system one consistent look for every field,
dropdown and picker in the upload modal. Scoped to #uploadModal dropdown and picker in the upload modal. Scoped to #uploadModal
@ -829,19 +878,28 @@ const _UTC_META = {
}; };
function openUploadChooser() { function openUploadChooser() {
// Mobile keeps the existing full-page create flow (with its own type picker) // Chooser is shown on every viewport — on mobile chooseUploadType() routes
if (window.innerWidth < 992) { // straight to the standalone create page with the picked ?type= preserved.
window.location.href = '{{ route("videos.create") }}';
return;
}
document.getElementById('upload-type-chooser').classList.add('show'); document.getElementById('upload-type-chooser').classList.add('show');
} }
// Called from the desktop chooser cards on mobile — carries the picked type
// through to the standalone create page via ?type=.
function chooseUploadTypeMobile(type) {
const t = ['generic', 'music', 'match'].includes(type) ? type : 'generic';
window.location.href = '{{ route("videos.create") }}?type=' + encodeURIComponent(t);
}
function closeUploadChooser() { function closeUploadChooser() {
document.getElementById('upload-type-chooser').classList.remove('show'); document.getElementById('upload-type-chooser').classList.remove('show');
} }
function chooseUploadType(type) { function chooseUploadType(type) {
// Mobile has no modal — route straight to the create page with the type preserved.
if (window.innerWidth < 992) {
chooseUploadTypeMobile(type);
return;
}
const meta = _UTC_META[type] || _UTC_META.generic; const meta = _UTC_META[type] || _UTC_META.generic;
closeUploadChooser(); closeUploadChooser();
openUploadModal(); openUploadModal();

View File

@ -784,6 +784,17 @@
setAudioMode(this.dataset.type === 'music'); setAudioMode(this.dataset.type === 'music');
}); });
}); });
// Honor ?type=generic|music|match forwarded from the desktop chooser
// when routed here on mobile — pre-select the corresponding option.
(function preselectTypeFromQuery() {
try {
const t = new URLSearchParams(window.location.search).get('type');
if (!t) return;
const opt = document.querySelector('#type-options .option-item[data-type="' + t + '"]');
if (opt) opt.click();
} catch (_) {}
}());
document.querySelectorAll('#visibility-options .option-item').forEach(item => { document.querySelectorAll('#visibility-options .option-item').forEach(item => {
item.querySelector('input').addEventListener('change', () => { item.querySelector('input').addEventListener('change', () => {
document.querySelectorAll('#visibility-options .option-item').forEach(o => o.classList.remove('active')); document.querySelectorAll('#visibility-options .option-item').forEach(o => o.classList.remove('active'));