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:
1 parent
66fd78c10f
commit
a4384113c2
24 files changed
+1738
-258
No files matched your search
@@ -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()
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user