Audio songs: one-folder storage, version-aware download/share, GPU-checked renders

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) <[email protected]>
This commit is contained in:
ghassanandClaude Opus 4.7 committed 2026-05-23 14:03:43 +03:00
1 parent 66fd78c10f
commit a4384113c2
24 files changed
+1738 -258

No files matched your search

+6 -15
View File
@@ -884,6 +884,9 @@ class SuperAdminController extends Controller
Setting::set('gpu_preset', $request->gpu_preset);
Setting::set('ffmpeg_binary', $binary);
// GPU config changed — drop the cached health-check so the next encode re-probes.
Setting::flushGpuProbe();
return back()->with('success', 'Settings saved.');
}
@@ -924,21 +927,9 @@ class SuperAdminController extends Controller
*/
private function probeNvenc(): bool
{
$ffmpeg = Setting::ffmpegBinary();
$tmp = sys_get_temp_dir() . '/nvenc_probe_' . getmypid() . '.mp4';
$device = Setting::gpuDevice();
exec(
escapeshellcmd($ffmpeg)
. ' -f lavfi -i nullsrc=s=128x72:r=1 -frames:v 1'
. " -c:v h264_nvenc -gpu {$device}"
. ' -y ' . escapeshellarg($tmp) . ' 2>/dev/null',
$out, $exit
);
$ok = ($exit === 0 && file_exists($tmp) && filesize($tmp) > 0);
@unlink($tmp);
return $ok;
// Single source of truth lives on the Setting model; force the NVENC encoder so the
// admin indicator always reflects GPU capability regardless of the configured encoder.
return Setting::probeGpu('h264_nvenc');
}
public function nasStorage()
+287 -57
View File
@@ -241,7 +241,7 @@ class VideoController extends Controller
$video = Video::create([
'user_id' => Auth::id(),
'title' => $request->title,
'description' => $request->description,
'description' => \App\Support\HtmlSanitizer::clean($request->description),
'filename' => $filename,
'path' => $path,
'thumbnail' => $thumbnailPath ? basename($thumbnailPath) : null,
@@ -339,7 +339,7 @@ class VideoController extends Controller
$lang = $trackLangs[$i] ?? 'en';
$ext = strtolower($trackFile->getClientOriginalExtension() ?: 'mp3');
$title = !empty($trackTitles[$i]) ? $trackTitles[$i] : null;
$desc = !empty($trackDescs[$i]) ? $trackDescs[$i] : null;
$desc = !empty($trackDescs[$i]) ? \App\Support\HtmlSanitizer::clean($trackDescs[$i]) ?: null : null;
// Create a placeholder record to get the DB ID for the filename
$track = VideoAudioTrack::create([
@@ -354,20 +354,19 @@ class VideoController extends Controller
if ($nas->isEnabled()) {
try {
$userSlug = $nas->userSlug($video->user);
$videoDir = $nas->resolveVideoDir($video);
$nasDir = "{$videoDir}/tracks";
$nas->mkdirp($nasDir);
$videoDir = $nas->resolveVideoDir($video);
$nas->mkdirp($videoDir);
$trackName = $this->audioTrackName(basename($videoDir), $lang, $track->id, $ext);
$tempPath = $trackFile->storeAs('public/tmp', "track_{$track->id}.{$ext}");
$tempAbs = storage_path('app/' . $tempPath);
$nasPath = "{$nasDir}/{$track->id}.{$ext}";
$nasPath = "{$videoDir}/{$trackName}";
$nas->putFile($tempAbs, $nasPath);
@unlink($tempAbs);
$track->update([
'path' => $nasPath,
'filename' => "{$track->id}.{$ext}",
'filename' => $trackName,
]);
} catch (\Throwable $e) {
\Log::error("Extra track NAS upload failed: " . $e->getMessage());
@@ -501,7 +500,20 @@ class VideoController extends Controller
}
}
$video->load(['comments.user', 'comments.replies.user', 'matchRounds.points', 'coachReviews', 'slides']);
$video->load(['comments.user', 'comments.replies.user', 'matchRounds.points', 'coachReviews', 'slides', 'audioTracks']);
// Version-aware share metadata: when the link carries ?track={id}, the OG/Twitter
// tags and <title> reflect that language track (so a shared English version shows
// the English title, not the primary). Falls back to the primary when unset.
$shareTitle = $video->title;
$shareDescription = $video->description;
if ($shareTrackId = (int) $request->input('track', 0)) {
$shareTrack = $video->audioTracks->firstWhere('id', $shareTrackId);
if ($shareTrack) {
if (! empty($shareTrack->title)) $shareTitle = $shareTrack->title;
if (! empty($shareTrack->description)) $shareDescription = $shareTrack->description;
}
}
$playlist = null;
$nextVideo = null;
@@ -534,7 +546,7 @@ class VideoController extends Controller
$did = $request->cookie('_did') ?: (string) Str::uuid();
return response()
->view($view, compact('video', 'playlist', 'nextVideo', 'previousVideo', 'recommendedVideos', 'playlistVideos'))
->view($view, compact('video', 'playlist', 'nextVideo', 'previousVideo', 'recommendedVideos', 'playlistVideos', 'shareTitle', 'shareDescription', 'shareTrackId'))
->header('Cache-Control', 'no-store, no-cache, must-revalidate')
->withCookie(cookie('_did', $did, 60 * 24 * 365 * 5));
}
@@ -679,6 +691,9 @@ class VideoController extends Controller
$oldTitle = $video->title;
$data = $request->only(['title', 'description', 'visibility', 'type']);
if (array_key_exists('description', $data)) {
$data['description'] = \App\Support\HtmlSanitizer::clean($data['description']);
}
$data['download_access'] = $request->input('download_access', 'disabled');
if ($request->has('primary_language')) {
$data['language'] = $request->input('primary_language') ?: null;
@@ -802,9 +817,11 @@ class VideoController extends Controller
$data['thumbnail'] = $firstSlide->filename;
}
// Invalidate cached slideshow video whenever slides change
// Invalidate cached slideshow videos (plain + visualizer) whenever slides change
if ($slidesChanged) {
@unlink(storage_path('app/public/slideshow/' . $video->id . '_slideshow.mp4'));
// Slides feed every rendered variant (all tracks + visualizer) — wipe them all.
$cacheDir = dirname(storage_path('app/' . $this->slideshowRel($video, false, 0)));
foreach (glob($cacheDir . '/video*.mp4') ?: [] as $f) @unlink($f);
$data['slideshow_video_path'] = null;
}
}
@@ -838,7 +855,7 @@ class VideoController extends Controller
foreach ($allTrackIds as $trackId) {
$fields = [];
if (array_key_exists($trackId, $titleUpdates)) $fields['title'] = $titleUpdates[$trackId] ?: null;
if (array_key_exists($trackId, $descUpdates)) $fields['description'] = $descUpdates[$trackId] ?: null;
if (array_key_exists($trackId, $descUpdates)) $fields['description'] = \App\Support\HtmlSanitizer::clean($descUpdates[$trackId]) ?: null;
if (array_key_exists($trackId, $languageUpdates) && $languageUpdates[$trackId]) {
$fields['language'] = $languageUpdates[$trackId];
$fields['label'] = strtoupper($languageUpdates[$trackId]);
@@ -855,18 +872,18 @@ class VideoController extends Controller
$ext = strtolower($trackFile->getClientOriginalExtension() ?: 'mp3');
if ($nas->isEnabled()) {
try {
$videoDir = $this->nasVideoDir($video, $nas);
$nasDir = "{$videoDir}/tracks";
$nas->mkdirp($nasDir);
$videoDir = $this->nasVideoDir($video, $nas);
$nas->mkdirp($videoDir);
$trackName = $this->audioTrackName(basename($videoDir), $track->language, $track->id, $ext);
if ($track->path && $track->path !== '__pending__' && str_starts_with($track->path, 'users/')) {
try { $nas->deleteFile($track->path); } catch (\Throwable $e) {}
}
$tempPath = $trackFile->storeAs('public/tmp', "track_{$track->id}.{$ext}");
$tempAbs = storage_path('app/' . $tempPath);
$nasPath = "{$nasDir}/{$track->id}.{$ext}";
$nasPath = "{$videoDir}/{$trackName}";
$nas->putFile($tempAbs, $nasPath);
@unlink($tempAbs);
$track->update(['path' => $nasPath, 'filename' => "{$track->id}.{$ext}"]);
$track->update(['path' => $nasPath, 'filename' => $trackName]);
} catch (\Throwable $e) {
\Log::error("Track file replace NAS failed (track {$trackId}): " . $e->getMessage());
}
@@ -881,33 +898,36 @@ class VideoController extends Controller
$trackFiles = $request->file('extra_track_files');
$trackLangs = $request->input('extra_track_languages', []);
$trackTitles = $request->input('extra_track_titles', []);
$trackDescs = $request->input('extra_track_descriptions', []);
foreach ($trackFiles as $i => $trackFile) {
if (! $trackFile || ! $trackFile->isValid()) continue;
$lang = $trackLangs[$i] ?? 'en';
$ext = strtolower($trackFile->getClientOriginalExtension() ?: 'mp3');
$title = !empty($trackTitles[$i]) ? $trackTitles[$i] : null;
$desc = !empty($trackDescs[$i]) ? \App\Support\HtmlSanitizer::clean($trackDescs[$i]) ?: null : null;
$track = VideoAudioTrack::create([
'video_id' => $video->id,
'language' => $lang,
'label' => strtoupper($lang),
'title' => $title,
'description' => $desc,
'path' => '__pending__',
'filename' => '__pending__',
]);
if ($nas->isEnabled()) {
try {
$videoDir = $this->nasVideoDir($video, $nas);
$nasDir = "{$videoDir}/tracks";
$nas->mkdirp($nasDir);
$videoDir = $this->nasVideoDir($video, $nas);
$nas->mkdirp($videoDir);
$trackName = $this->audioTrackName(basename($videoDir), $lang, $track->id, $ext);
$tempPath = $trackFile->storeAs('public/tmp', "track_{$track->id}.{$ext}");
$tempAbs = storage_path('app/' . $tempPath);
$nasPath = "{$nasDir}/{$track->id}.{$ext}";
$nasPath = "{$videoDir}/{$trackName}";
$nas->putFile($tempAbs, $nasPath);
@unlink($tempAbs);
$track->update(['path' => $nasPath, 'filename' => "{$track->id}.{$ext}"]);
$track->update(['path' => $nasPath, 'filename' => $trackName]);
} catch (\Throwable $e) {
\Log::error("Extra track NAS upload failed (update): " . $e->getMessage());
$this->storeTrackLocally($track, $trackFile, $ext, $video, $nas);
@@ -1447,18 +1467,32 @@ class VideoController extends Controller
private function storeTrackLocally(VideoAudioTrack $track, $trackFile, string $ext, Video $video, $nas): void
{
try {
$localDir = $nas->localVideoDir($video) . '/tracks';
// All tracks (primary + secondary) live directly in the song's own folder —
// never a separate tracks/ subfolder — with a unique, lowercase name.
$localDir = $nas->localVideoDir($video);
@mkdir($localDir, 0755, true);
$trackFile->move($localDir, "{$track->id}.{$ext}");
$userSlug = $nas->userSlug($video->user);
$videoBase = basename($nas->localVideoDir($video));
$relPath = "users/{$userSlug}/videos/{$videoBase}/tracks/{$track->id}.{$ext}";
$track->update(['path' => $relPath, 'filename' => "{$track->id}.{$ext}"]);
$base = basename($localDir);
$trackName = $this->audioTrackName($base, $track->language, $track->id, $ext);
$trackFile->move($localDir, $trackName);
$userSlug = $nas->userSlug($video->user);
$relPath = "users/{$userSlug}/videos/{$base}/{$trackName}";
$track->update(['path' => $relPath, 'filename' => $trackName]);
} catch (\Throwable $e) {
\Log::error("storeTrackLocally failed: " . $e->getMessage());
}
}
/**
* Unique, lowercase filename for an audio track kept in the song's own folder:
* {song-slug}-{lang}-{db-id}.{ext}. The db id guarantees no two tracks can ever
* overwrite each other even when they share a language.
*/
private function audioTrackName(string $base, ?string $lang, int $id, string $ext): string
{
$lang = mb_strtolower(trim((string) $lang)) ?: 'xx';
return mb_strtolower($base . '-' . $lang . '-' . $id . '.' . strtolower($ext));
}
public function hls(Video $video, $file = 'playlist.m3u8')
{
if (! $video->canView(Auth::user())) {
@@ -1547,11 +1581,26 @@ class VideoController extends Controller
]);
}
// Audio-only file → generate video
// Audio-only file → generate/serve a video for the version being played.
$trackId = (int) request()->input('track', 0);
$viz = request()->boolean('visualizer');
// Serve pre-generated slideshow — DB column must confirm it's the current version
// Any non-primary or visualizer variant is served straight off disk (no DB column).
if ($viz || $trackId) {
$cacheFile = storage_path('app/' . $this->slideshowRel($video, $viz, $trackId));
if (file_exists($cacheFile) && filesize($cacheFile) > 0) {
return response()->download($cacheFile, $this->safeFilename($video->title, 'video') . '.mp4', [
'Content-Type' => 'video/mp4',
'Content-Disposition' => $this->contentDisposition($video->title, 'mp4'),
]);
}
// Not generated yet — let the player trigger background generation.
return redirect()->route('videos.show', $video)->with('_auto_dl_video', true);
}
// Primary plain — DB column must confirm it's the current version.
if ($video->slideshow_video_path) {
$slideshowCache = storage_path('app/public/slideshow/' . $video->id . '_slideshow.mp4');
$slideshowCache = storage_path('app/' . $video->slideshow_video_path);
if (file_exists($slideshowCache) && filesize($slideshowCache) > 0) {
return response()->download($slideshowCache, $this->safeFilename($video->title, 'video') . '.mp4', [
'Content-Type' => 'video/mp4',
@@ -1568,9 +1617,65 @@ class VideoController extends Controller
return redirect()->route('videos.show', $video)->with('_auto_dl_video', true);
}
/**
* Extract up to three dominant, saturated colours from a cover image — the same
* algorithm the in-player visualizer uses (extractColors() in audio-player.blade.php)
* so the baked-in download bars match what viewers see on the page. Returns three
* lowercase hex strings ordered left → middle → right of the bar gradient; falls
* back to light greys when nothing usable is found.
*/
private function slideVisualizerColors(?string $imagePath): array
{
$default = ['ffffff', 'c8c8c8', 'aaaaaa'];
if (! $imagePath || ! is_file($imagePath) || ! function_exists('imagecreatefromstring')) {
return $default;
}
$raw = @file_get_contents($imagePath);
$img = $raw ? @imagecreatefromstring($raw) : false;
if (! $img) return $default;
$t = imagecreatetruecolor(24, 24);
imagecopyresampled($t, $img, 0, 0, 0, 0, 24, 24, imagesx($img), imagesy($img));
imagedestroy($img);
$buckets = [];
for ($y = 0; $y < 24; $y++) {
for ($x = 0; $x < 24; $x++) {
$p = imagecolorat($t, $x, $y);
$r = ($p >> 16) & 255; $g = ($p >> 8) & 255; $b = $p & 255;
$bright = ($r + $g + $b) / 3;
if ($bright < 25 || $bright > 230) continue; // skip near-black / near-white
$mx = max($r, $g, $b); $mn = min($r, $g, $b);
if ($mx == 0 || ($mx - $mn) / $mx < 0.25) continue; // skip low-saturation
$k = ($r >> 2) . ',' . ($g >> 2) . ',' . ($b >> 2);
if (! isset($buckets[$k])) $buckets[$k] = ['r' => 0, 'g' => 0, 'b' => 0, 'n' => 0];
$buckets[$k]['r'] += $r; $buckets[$k]['g'] += $g; $buckets[$k]['b'] += $b; $buckets[$k]['n']++;
}
}
imagedestroy($t);
if (! $buckets) return $default;
usort($buckets, fn ($a, $b) => $b['n'] - $a['n']);
$avg = fn ($c) => [$c['r'] / $c['n'], $c['g'] / $c['n'], $c['b'] / $c['n']];
// Most-common colour first, then the next colours that are visually distinct from it.
$chosen = [$avg($buckets[0])];
for ($i = 1; $i < count($buckets) && count($chosen) < 3; $i++) {
$c = $avg($buckets[$i]); $far = true;
foreach ($chosen as $e) {
if (sqrt(($e[0]-$c[0])**2 + ($e[1]-$c[1])**2 + ($e[2]-$c[2])**2) <= 60) { $far = false; break; }
}
if ($far) $chosen[] = $c;
}
while (count($chosen) < 3) $chosen[] = $chosen[0];
return array_map(fn ($c) => sprintf('%02x%02x%02x', round($c[0]), round($c[1]), round($c[2])), $chosen);
}
// ── Background slideshow generation + progress polling ────────
public function slideshowGenerate(Video $video)
public function slideshowGenerate(Request $request, Video $video)
{
$this->checkDownloadAccess($video);
@@ -1578,28 +1683,55 @@ class VideoController extends Controller
return response()->json(['error' => 'Not an audio file'], 422);
}
// Version-aware: render the video from the audio the viewer is playing — the
// primary track, or a specific language track (?track={id}). The optional
// visualizer (?visualizer=1) bakes in the frequency bars. Each (track, viz)
// combination is cached under its own filename so they never clobber each other.
$viz = $request->boolean('visualizer');
$trackId = (int) $request->input('track', 0);
$suffix = ($trackId ? '_t' . $trackId : '') . ($viz ? '_viz' : '');
$ffmpeg = \App\Models\Setting::ffmpegBinary();
$ffprobe = config('ffmpeg.ffprobe', '/usr/bin/ffprobe');
$audioPath = $video->localVideoPath();
// Audio source = the version being played.
if ($trackId) {
$track = $video->audioTracks()->find($trackId);
if (! $track) {
return response()->json(['error' => 'Track not found'], 404);
}
$nas = app(\App\Services\NasSyncService::class);
$audioPath = $track->localPath();
if (! file_exists($audioPath) && $nas->isEnabled()) {
$audioPath = $nas->ensureLocalTrackCopy($track) ?: $audioPath;
}
} else {
$audioPath = $video->localVideoPath();
}
if (! file_exists($audioPath)) {
return response()->json(['error' => 'Audio file not found'], 404);
}
$cacheDir = storage_path('app/public/slideshow');
if (! is_dir($cacheDir)) mkdir($cacheDir, 0755, true);
$outRel = $this->slideshowRel($video, $viz, $trackId); // song's cache/ folder
$outPath = storage_path('app/' . $outRel);
if (! is_dir(dirname($outPath))) mkdir(dirname($outPath), 0755, true);
$progressFile = sys_get_temp_dir() . '/sl_progress_' . $video->id . $suffix . '.txt';
$pidFile = sys_get_temp_dir() . '/sl_pid_' . $video->id . $suffix . '.txt';
$logFile = sys_get_temp_dir() . '/sl_log_' . $video->id . $suffix . '.txt';
$outPath = $cacheDir . '/' . $video->id . '_slideshow.mp4';
$progressFile = sys_get_temp_dir() . '/sl_progress_' . $video->id . '.txt';
$pidFile = sys_get_temp_dir() . '/sl_pid_' . $video->id . '.txt';
$logFile = sys_get_temp_dir() . '/sl_log_' . $video->id . '.txt';
// Already cached — only trust it when the DB column confirms it's the right version
if ($video->slideshow_video_path && file_exists($outPath) && filesize($outPath) > 0) {
// Already cached. Only the primary plain video is tracked by the DB column; every
// other variant (a language track and/or visualizer) is guarded by file existence.
// All are invalidated together when the slides are edited (see update()).
$usesDbColumn = (! $viz && ! $trackId);
$cached = $usesDbColumn
? ($video->slideshow_video_path && file_exists($outPath) && filesize($outPath) > 0)
: (file_exists($outPath) && filesize($outPath) > 0);
if ($cached) {
return response()->json(['status' => 'ready']);
}
// File on disk but DB says it's stale (e.g. slides were edited) — delete it
// Stale file on disk (e.g. slides were edited) — delete it before regenerating
if (file_exists($outPath)) {
@unlink($outPath);
}
@@ -1631,12 +1763,83 @@ class VideoController extends Controller
@unlink($progressFile);
@unlink($pidFile);
// Pre-compute codec flags; CPU variant used as automatic fallback if GPU fails
$isStillImage = ($validSlides->count() === 1);
// Pre-compute codec flags; CPU variant used as automatic fallback if GPU fails.
// A single slide is normally a still image, but with the visualizer overlay the
// bars animate — so it must not be encoded with -tune stillimage.
$isStillImage = ($validSlides->count() === 1) && ! $viz;
$vFlags = $this->ffmpegVideoFlags($isStillImage);
$cpuFlags = $this->ffmpegVideoFlagsCpu($isStillImage);
if ($validSlides->count() >= 2) {
if ($viz) {
// ── Visualizer build ─────────────────────────────────────────────────
// Decode each slide ONCE and repeat it with the loop filter. Re-decoding a
// multi-megapixel PNG on every output frame (the naive `-loop 1` input) made
// this run below real-time; the loop filter makes it ~25x real-time instead.
// overlay shortest=1 bounds the otherwise-endless looped background to the audio.
$rate = 20;
$inputs = '';
$fcParts = [];
if ($validSlides->count() >= 2) {
$n = $validSlides->count();
$fade = max(0.5, min(1.0, ($dur / $n) * 0.15));
$T = ($dur + ($n - 1) * $fade) / $n;
foreach ($validSlides as $i => $slide) {
$inputs .= ' -i ' . escapeshellarg($slide->localPath());
// fps MUST come last so each branch is constant-frame-rate for xfade.
$fcParts[] = "[{$i}:v]{$scale},loop=loop=-1:size=1,trim=duration="
. number_format($T + 1, 3) . ",setpts=PTS-STARTPTS,fps={$rate}[s{$i}]";
}
$inputs .= ' -i ' . escapeshellarg($audioPath);
$audioIdx = $n;
$prev = '[s0]';
for ($i = 1; $i < $n; $i++) {
$offset = number_format($i * ($T - $fade), 3);
$outLabel = $i === $n - 1 ? '[vbase]' : "[v{$i}]";
$fcParts[] = "{$prev}[s{$i}]xfade=transition=fade:duration="
. number_format($fade, 3) . ":offset={$offset}{$outLabel}";
$prev = $outLabel;
}
} elseif ($validSlides->count() === 1) {
$inputs .= ' -i ' . escapeshellarg($validSlides->first()->localPath());
$inputs .= ' -i ' . escapeshellarg($audioPath);
$audioIdx = 1;
$fcParts[] = "[0:v]{$scale},loop=loop=-1:size=1,fps={$rate}[vbase]";
} else {
$inputs .= ' -f lavfi -i color=c=black:s=1280x720:r=' . $rate;
$inputs .= ' -i ' . escapeshellarg($audioPath);
$audioIdx = 1;
$fcParts[] = "[0:v]format=yuv420p[vbase]";
}
// Frequency bars matched to the in-player visualizer:
// • equal-width bars → fscale=lin (log made the left bars wide, right thin)
// • cover colours → render white bars (a shape+alpha mask), then tint
// them with a horizontal gradient of the cover's 3 dominant colours
// (left → middle → right), exactly like the page's canvas bars
// • translucent overlay → showfreqs already outputs a transparent backdrop,
// so we just dial the bar alpha down and composite over the artwork.
[$c0, $c1, $c2] = $this->slideVisualizerColors(
$validSlides->isNotEmpty() ? $validSlides->first()->localPath() : null
);
$fcParts[] = "[{$audioIdx}:a]showfreqs=mode=bar:ascale=log:fscale=lin:win_size=128"
. ":rate={$rate}:s=1280x180:colors=white,format=rgba[bars]";
$fcParts[] = "gradients=s=1280x180:nb_colors=3:c0=0x{$c0}:c1=0x{$c1}:c2=0x{$c2}"
. ":x0=0:y0=90:x1=1280:y1=90,format=rgba[grad]";
$fcParts[] = "[bars]alphaextract[vmask]";
$fcParts[] = "[grad][vmask]alphamerge,colorchannelmixer=aa=0.85[viz]";
$fcParts[] = "[vbase][viz]overlay=x=0:y=H-h:format=yuv420:shortest=1[vout]";
$fc = implode(';', $fcParts);
$cmd = "{$ffmpeg} -y{$inputs}"
. ' -filter_complex ' . escapeshellarg($fc)
. ' -map [vout] -map ' . $audioIdx . ':a'
. ' ' . $vFlags
. ' -c:a aac -b:a 192k -movflags +faststart -shortest';
} elseif ($validSlides->count() >= 2) {
$n = $validSlides->count();
$fade = max(0.5, min(1.0, ($dur / $n) * 0.15));
$T = ($dur + ($n - 1) * $fade) / $n;
@@ -1659,7 +1862,6 @@ class VideoController extends Controller
. number_format($fade, 3) . ":offset={$offset}{$outLabel}";
$prev = $outLabel;
}
if ($n === 1) $xfadeFc[] = '[s0]copy[vout]';
$fc = implode(';', array_merge($scaleFc, $xfadeFc));
$cmd = "{$ffmpeg} -y{$inputs}"
@@ -1689,9 +1891,10 @@ class VideoController extends Controller
$cmd .= ' -progress ' . escapeshellarg($progressFile)
. ' ' . escapeshellarg($outPath);
// When GPU is active, wrap in a bash fallback: if GPU command fails, clear the
// progress file and retry immediately with CPU (libx264) so the download still works.
if (Setting::gpuEnabled() && $vFlags !== $cpuFlags) {
// $vFlags already reflects a live GPU health check (Setting::gpuUsable); when the
// GPU is in use, wrap in a bash fallback so a mid-encode GPU failure still retries
// on CPU (libx264) and the download keeps working.
if (Setting::gpuUsable() && $vFlags !== $cpuFlags) {
$cpuCmd = str_replace($vFlags, $cpuFlags, $cmd);
$inner = $cmd
. ' || { truncate -s 0 ' . escapeshellarg($progressFile) . '; ' . $cpuCmd . '; }';
@@ -1711,9 +1914,13 @@ class VideoController extends Controller
public function slideshowProgress(Video $video)
{
$outPath = storage_path('app/public/slideshow/' . $video->id . '_slideshow.mp4');
$progressFile = sys_get_temp_dir() . '/sl_progress_' . $video->id . '.txt';
$pidFile = sys_get_temp_dir() . '/sl_pid_' . $video->id . '.txt';
$viz = request()->boolean('visualizer');
$trackId = (int) request()->input('track', 0);
$suffix = ($trackId ? '_t' . $trackId : '') . ($viz ? '_viz' : '');
$outPath = storage_path('app/' . $this->slideshowRel($video, $viz, $trackId));
$progressFile = sys_get_temp_dir() . '/sl_progress_' . $video->id . $suffix . '.txt';
$pidFile = sys_get_temp_dir() . '/sl_pid_' . $video->id . $suffix . '.txt';
// Read progress content once
$content = file_exists($progressFile) ? file_get_contents($progressFile) : '';
@@ -1728,8 +1935,10 @@ class VideoController extends Controller
}
if ($isDone && file_exists($outPath) && filesize($outPath) > 0) {
if (! $video->slideshow_video_path) {
$video->update(['slideshow_video_path' => 'public/slideshow/' . $video->id . '_slideshow.mp4']);
// Only the plain variant is tracked by the DB column; the visualizer variant
// is served straight off disk (see download()).
if (! $viz && ! $trackId && ! $video->slideshow_video_path) {
$video->update(['slideshow_video_path' => $this->slideshowRel($video, false, 0)]);
}
return response()->json(['percent' => 100, 'status' => 'ready']);
}
@@ -1856,6 +2065,20 @@ class VideoController extends Controller
return Setting::ffmpegHwaccelFlags($inputIsVideo);
}
/**
* Song-folder-relative path (under storage/app) for the generated "Download Video".
* Regenerable renders live in the song's own `cache/` subfolder — separated from the
* source files and kept LOCAL-only (never pushed to NAS):
* {song-folder}/cache/video.mp4 / cache/video-viz.mp4
*/
private function slideshowRel(Video $video, bool $viz, int $trackId = 0): string
{
$nas = app(\App\Services\NasSyncService::class);
return $this->nasVideoDir($video, $nas) . '/cache/video'
. ($trackId ? '-t' . $trackId : '')
. ($viz ? '-viz' : '') . '.mp4';
}
/**
* Return the NAS video root directory (where thumb.jpg and slides/ live).
* When the primary file was promoted from a secondary track, video->path points
@@ -1990,7 +2213,14 @@ class VideoController extends Controller
]);
}
return redirect(route('videos.showByToken', $video->share_token))
// Carry the version selector (?track=) through the redirect so the recipient opens
// the exact language the sharer was listening to.
$dest = route('videos.showByToken', $video->share_token);
if ($request->filled('track')) {
$dest .= '?track=' . (int) $request->input('track');
}
return redirect($dest)
->withCookie(cookie('_did', $did, 60 * 24 * 365 * 5));
}