Add lyrics pipeline, playlist views, admin toggles, and player polish
Lyrics pipeline (Whisper + Demucs + description alignment):
- New GenerateLyricsJob runs WhisperX with VAD filtering and forced word
alignment, writes per-track JSON to NAS.
- New DecorateLyricsJob calls the active LLM provider to bake one to
several emojis into each line (heavy decoration prompt).
- LyricsDescriptionParser strips heading content, section markers, and
emoji-decoration from a song's description while preserving every
language verbatim.
- correct_whisper_with_description aligner: strong-match anchors only,
vocal-region-aware gap-fill so missing verses land on actual singing.
- Owner UI for generate/regenerate/edit/delete in the player gear.
Admin pages:
- /admin/lyrics toggles for VAD, vocal gap-fill, Demucs, master
- /admin/gpu extracted GPU section, encoder picker, FFmpeg path
- /admin/backup extracted users-and-settings export/import
- /admin/settings now AI/LLM only with provider list and Test button
- /admin/nas-storage hosts NAS settings, repair, disable flow, browser
- Shared partials/settings-styles for a uniform look across pages.
Playlist view tracking:
- Migration adds playlists.view_count and playlist_views dedup table.
- Playlist::bumpViewIfNew increments per device with a one-hour window.
- Tracked from /playlists/{id}, /playlists/share/{token}, /ps/{token},
and /videos/{id}?playlist={token}. Dispatched after-response so it
never blocks the page render.
- Loading a playlist on the video page now runs one query instead of
the four the old getNextVideo/getPreviousVideo path triggered.
- View counts shown on every playlist card and the playlist hero.
Player polish:
- Floating mini-player is draggable, persists its position in
localStorage, clamps to viewport on resize.
- Mini disabled entirely on mobile (less than 768px).
- New gear-menu Mini Player toggle (persists in localStorage) lets the
user disable both scroll-activation and SPA-nav-activation.
- Close button keeps media playing when used on the player's own page.
- SPA navigator now swaps a #page-scripts container so per-page JS
(channel tabs, etc.) gets re-executed after content swaps.
Storage layout:
- Runtime data moved from /storage/* to /data/* and gitignored.
- /ml/venv, /ml/cache, /ml/__pycache__ excluded.
Co-Authored-By: Claude Opus 4.7 <[email protected]>
This commit is contained in:
1 parent
73527f3781
commit
f98e5415a3
60 files changed
+7920
-2675
No files matched your search
@@ -26,7 +26,7 @@ class VideoController extends Controller
|
||||
{
|
||||
public function __construct()
|
||||
{
|
||||
$this->middleware('auth')->except(['index', 'show', 'search', 'stream', 'hls', 'trending', 'shorts', 'download', 'downloadMp3', 'recordShare', 'ogImage', 'accessShare', 'showByToken', 'recommendations', 'slideshowProgress', 'playerData', 'streamAudioTrack']);
|
||||
$this->middleware('auth')->except(['index', 'show', 'search', 'stream', 'hls', 'trending', 'shorts', 'download', 'downloadMp3', 'recordShare', 'ogImage', 'accessShare', 'showByToken', 'recommendations', 'slideshowProgress', 'playerData', 'streamAudioTrack', 'lyricsProgress']);
|
||||
}
|
||||
|
||||
public function index()
|
||||
@@ -161,6 +161,12 @@ class VideoController extends Controller
|
||||
'extra_track_titles.*' => 'nullable|string|max:255',
|
||||
'extra_track_descriptions' => 'nullable|array',
|
||||
'extra_track_descriptions.*'=> 'nullable|string',
|
||||
// Optional per-extra-track slides. Sent as extra_track_slides[i][] = file.
|
||||
// If absent for a given index, the track inherits the primary's slides
|
||||
// at render time via Video::slidesForTrack().
|
||||
'extra_track_slides' => 'nullable|array',
|
||||
'extra_track_slides.*' => 'nullable|array',
|
||||
'extra_track_slides.*.*' => 'image|mimes:jpg,jpeg,png,webp|max:20480',
|
||||
]);
|
||||
|
||||
$videoFile = $request->file('video');
|
||||
@@ -332,6 +338,7 @@ class VideoController extends Controller
|
||||
$trackLangs = $request->input('extra_track_languages', []);
|
||||
$trackTitles = $request->input('extra_track_titles', []);
|
||||
$trackDescs = $request->input('extra_track_descriptions', []);
|
||||
$trackSlides = $request->file('extra_track_slides') ?: [];
|
||||
|
||||
foreach ($trackFiles as $i => $trackFile) {
|
||||
if (! $trackFile || ! $trackFile->isValid()) continue;
|
||||
@@ -354,19 +361,20 @@ class VideoController extends Controller
|
||||
|
||||
if ($nas->isEnabled()) {
|
||||
try {
|
||||
$videoDir = $nas->resolveVideoDir($video);
|
||||
$nas->mkdirp($videoDir);
|
||||
$trackName = $this->audioTrackName(basename($videoDir), $lang, $track->id, $ext);
|
||||
// Extra music track → its own folder under tracks/{lang-id}/audio.{ext}
|
||||
$trackDir = $nas->trackDir($video, $track);
|
||||
$nas->mkdirp($trackDir);
|
||||
$canonical = "audio.{$ext}";
|
||||
$nasPath = "{$trackDir}/{$canonical}";
|
||||
|
||||
$tempPath = $trackFile->storeAs('public/tmp', "track_{$track->id}.{$ext}");
|
||||
$tempAbs = storage_path('app/' . $tempPath);
|
||||
$nasPath = "{$videoDir}/{$trackName}";
|
||||
$nas->putFile($tempAbs, $nasPath);
|
||||
@unlink($tempAbs);
|
||||
|
||||
$track->update([
|
||||
'path' => $nasPath,
|
||||
'filename' => $trackName,
|
||||
'filename' => $canonical,
|
||||
]);
|
||||
} catch (\Throwable $e) {
|
||||
\Log::error("Extra track NAS upload failed: " . $e->getMessage());
|
||||
@@ -376,6 +384,23 @@ class VideoController extends Controller
|
||||
} else {
|
||||
$this->storeTrackLocally($track, $trackFile, $ext, $video, $nas);
|
||||
}
|
||||
|
||||
// ── Optional per-track slides ──────────────────────────────
|
||||
// The track only owns slides that were uploaded for it. If none
|
||||
// were uploaded, the player falls back to the primary's at render
|
||||
// time via Video::slidesForTrack — no row needed here.
|
||||
$files = $trackSlides[$i] ?? null;
|
||||
if (is_array($files) && count($files) > 0) {
|
||||
$this->storeTrackSlides($video, $track, $files, $nas);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Synced lyrics generation (audio/music uploads only) ───────────────
|
||||
if ($isAudioUpload) {
|
||||
\App\Jobs\GenerateLyricsJob::dispatch($video->id, null)->onConnection('database');
|
||||
foreach ($video->audioTracks()->pluck('id') as $tid) {
|
||||
\App\Jobs\GenerateLyricsJob::dispatch($video->id, (int) $tid)->onConnection('database');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -544,9 +569,19 @@ class VideoController extends Controller
|
||||
if ($playlistParam) {
|
||||
$playlist = Playlist::where('share_token', $playlistParam)->first();
|
||||
if ($playlist && $playlist->canViewViaToken(Auth::user())) {
|
||||
$nextVideo = $playlist->getNextVideo($video);
|
||||
$previousVideo = $playlist->getPreviousVideo($video);
|
||||
$playlistVideos = $playlist->videos;
|
||||
// Load the videos ONCE with their owners eager-loaded, then
|
||||
// compute prev/next in PHP. The old code fired 4+ separate
|
||||
// queries for prev/next/list — the sidebar lag the user
|
||||
// reported was almost entirely those extra round-trips.
|
||||
$playlistVideos = $playlist->videos()->with('user')->orderBy('position')->get();
|
||||
[$previousVideo, $nextVideo] = $playlist->neighborsFromCollection($playlistVideos, $video);
|
||||
|
||||
// Count the playlist view (deduped per device, 1-hour window)
|
||||
// after the response is flushed so we don't pay the round-trip
|
||||
// on the hot path.
|
||||
dispatch(function () use ($playlist, $request) {
|
||||
$playlist->bumpViewIfNew($request);
|
||||
})->afterResponse();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -579,9 +614,16 @@ class VideoController extends Controller
|
||||
? route('media.thumbnail', $video->thumbnail)
|
||||
: asset('storage/images/logo.png');
|
||||
|
||||
$slides = $video->slides->count() > 1
|
||||
? $video->slides->map(fn ($s) => route('media.thumbnail', $s->filename))->values()->all()
|
||||
: [];
|
||||
// Per-track slide map (key "0" = primary). Each entry already has the
|
||||
// sharing fallback applied by Video::slidesForTrack — a track without its
|
||||
// own slides borrows the primary's (or a sibling's) automatically.
|
||||
$slideMap = ['0' => $video->slidesForTrack(null)
|
||||
->map(fn ($s) => route('media.thumbnail', $s->filename))->values()->all()];
|
||||
foreach ($video->audioTracks as $_t) {
|
||||
$slideMap[(string) $_t->id] = $video->slidesForTrack($_t->id)
|
||||
->map(fn ($s) => route('media.thumbnail', $s->filename))->values()->all();
|
||||
}
|
||||
$slides = $slideMap['0'];
|
||||
|
||||
$allLangData = \App\Data\Languages::all();
|
||||
$audioTracks = $video->audioTracks->map(fn ($t) => [
|
||||
@@ -595,6 +637,14 @@ class VideoController extends Controller
|
||||
'dl_url' => route('videos.audio-track', ['video' => $video, 'track' => $t->id]) . '?download=1&v=' . $t->updated_at->timestamp,
|
||||
])->values()->all();
|
||||
|
||||
// Synced lyrics embedded inline (no separate request), keyed by track id; "0" = primary.
|
||||
// Local mirror only — must not block this hot path on NAS I/O.
|
||||
$nasLyrics = app(\App\Services\NasSyncService::class);
|
||||
$lyricsMap = ['0' => $nasLyrics->getLocalLyrics($video, null)];
|
||||
foreach ($video->audioTracks as $t) {
|
||||
$lyricsMap[(string) $t->id] = $nasLyrics->getLocalLyrics($video, $t);
|
||||
}
|
||||
|
||||
return response()->json([
|
||||
'id' => $video->id,
|
||||
'key' => $video->getRouteKey(),
|
||||
@@ -604,6 +654,7 @@ class VideoController extends Controller
|
||||
'stream_url' => route('videos.stream', $video) . '?v=' . $video->updated_at->timestamp,
|
||||
'cover_url' => $coverUrl,
|
||||
'slides' => $slides,
|
||||
'slide_map' => $slideMap,
|
||||
'title' => $video->title,
|
||||
'author' => $video->user->name ?? '',
|
||||
'duration' => $video->duration,
|
||||
@@ -612,9 +663,212 @@ class VideoController extends Controller
|
||||
'language' => $video->language,
|
||||
'language_flag' => $video->language ? ($allLangData[$video->language]['flag'] ?? null) : null,
|
||||
'audio_tracks' => $audioTracks,
|
||||
'lyrics' => $lyricsMap,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Owner-triggered lyrics generation for the current audio track (?track={id},
|
||||
* 0/absent = primary). Dispatches the GPU pipeline to the queue; the player
|
||||
* polls player-data and shows the lyrics once the file lands.
|
||||
*/
|
||||
public function generateLyrics(Request $request, Video $video)
|
||||
{
|
||||
if (Auth::id() !== $video->user_id) {
|
||||
abort(403);
|
||||
}
|
||||
if (\App\Models\Setting::get('lyrics_enabled', 'true') !== 'true') {
|
||||
return response()->json(['error' => 'Lyrics generation is currently disabled by the administrator.'], 422);
|
||||
}
|
||||
if (! $this->isAudioOnlyFile($video)) {
|
||||
return response()->json(['error' => 'Lyrics are only for audio tracks.'], 422);
|
||||
}
|
||||
|
||||
$trackId = (int) $request->input('track', 0);
|
||||
if ($trackId) {
|
||||
$track = $video->audioTracks()->find($trackId);
|
||||
if (! $track) {
|
||||
return response()->json(['error' => 'Track not found.'], 404);
|
||||
}
|
||||
}
|
||||
|
||||
\App\Jobs\GenerateLyricsJob::dispatch($video->id, $trackId ?: null)->onConnection('database');
|
||||
|
||||
return response()->json(['status' => 'queued']);
|
||||
}
|
||||
|
||||
/**
|
||||
* Owner-triggered delete of the saved lyrics for a track. Wipes the local
|
||||
* mirror + the NAS copy and removes any in-flight progress / temp files,
|
||||
* so the next Generate produces a fresh result.
|
||||
*/
|
||||
public function deleteLyrics(Request $request, Video $video)
|
||||
{
|
||||
if (Auth::id() !== $video->user_id) {
|
||||
abort(403);
|
||||
}
|
||||
|
||||
$trackId = (int) $request->input('track', 0);
|
||||
$track = null;
|
||||
if ($trackId) {
|
||||
$track = $video->audioTracks()->find($trackId);
|
||||
if (! $track) {
|
||||
return response()->json(['error' => 'Track not found.'], 404);
|
||||
}
|
||||
}
|
||||
|
||||
app(\App\Services\NasSyncService::class)->deleteLyrics($video, $track);
|
||||
|
||||
// Clear any in-flight progress / temp artifacts so a queued job that
|
||||
// fires later can't repopulate stale output.
|
||||
@unlink(\App\Jobs\GenerateLyricsJob::progressPath($video->id, $trackId ?: null));
|
||||
@unlink(storage_path('app/tmp/lyrics_' . $video->id . '_' . ($trackId ?: 'primary') . '.json'));
|
||||
|
||||
return response()->json(['status' => 'deleted']);
|
||||
}
|
||||
|
||||
/**
|
||||
* Live progress for an in-flight lyrics generation, driving the player's
|
||||
* progress bar. Returns {status: ready|failed|processing|none, pct, stage}.
|
||||
*/
|
||||
public function lyricsProgress(Request $request, Video $video)
|
||||
{
|
||||
if (! $video->canView(Auth::user())) {
|
||||
abort(403);
|
||||
}
|
||||
|
||||
$trackId = (int) $request->input('track', 0);
|
||||
$track = null;
|
||||
if ($trackId) {
|
||||
$track = $video->audioTracks()->find($trackId);
|
||||
if (! $track) return response()->json(['status' => 'none']);
|
||||
}
|
||||
|
||||
$data = app(\App\Services\NasSyncService::class)->getLocalLyrics($video, $track);
|
||||
if (is_array($data)) {
|
||||
$st = $data['status'] ?? 'ready';
|
||||
if ($st === 'ready' && ! empty($data['lines'])) return response()->json(['status' => 'ready', 'pct' => 100]);
|
||||
if ($st === 'failed') return response()->json(['status' => 'failed']);
|
||||
}
|
||||
|
||||
// Live percentage from the pipeline's progress file.
|
||||
$progFile = \App\Jobs\GenerateLyricsJob::progressPath($video->id, $trackId ?: null);
|
||||
if (is_file($progFile)) {
|
||||
$p = json_decode((string) file_get_contents($progFile), true);
|
||||
if (is_array($p)) {
|
||||
return response()->json([
|
||||
'status' => 'processing',
|
||||
'pct' => (int) ($p['pct'] ?? 1),
|
||||
'stage' => $p['stage'] ?? 'Working',
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
// A 'processing' lyrics marker but no progress file yet → just queued.
|
||||
if (is_array($data)) return response()->json(['status' => 'processing', 'pct' => 1, 'stage' => 'Queued']);
|
||||
|
||||
return response()->json(['status' => 'none']);
|
||||
}
|
||||
|
||||
/**
|
||||
* Owner-edited lyrics save. Receives the (possibly corrected) lines for a
|
||||
* track; preserves precise word timing for lines that weren't changed and
|
||||
* redistributes timing evenly across the new words for edited lines.
|
||||
*/
|
||||
public function saveLyrics(Request $request, Video $video)
|
||||
{
|
||||
if (Auth::id() !== $video->user_id) {
|
||||
abort(403);
|
||||
}
|
||||
|
||||
$trackId = (int) $request->input('track', 0);
|
||||
$track = null;
|
||||
if ($trackId) {
|
||||
$track = $video->audioTracks()->find($trackId);
|
||||
if (! $track) return response()->json(['error' => 'Track not found.'], 404);
|
||||
}
|
||||
|
||||
$nas = app(\App\Services\NasSyncService::class);
|
||||
$existing = $nas->getLocalLyrics($video, $track) ?: [];
|
||||
$spaceless = ['th', 'zh', 'ja', 'lo', 'my', 'km', 'yue', 'wuu'];
|
||||
|
||||
$inLines = $request->input('lines', []);
|
||||
if (! is_array($inLines)) $inLines = [];
|
||||
|
||||
$out = [];
|
||||
foreach ($inLines as $ln) {
|
||||
$text = trim(strip_tags((string) ($ln['text'] ?? '')));
|
||||
if ($text === '' || mb_strlen($text) > 1000) {
|
||||
if ($text === '') continue;
|
||||
$text = mb_substr($text, 0, 1000);
|
||||
}
|
||||
$start = round((float) ($ln['start'] ?? 0), 3);
|
||||
$end = round((float) ($ln['end'] ?? $start), 3);
|
||||
$lang = (string) ($ln['lang'] ?? ($existing['language'] ?? 'en'));
|
||||
$isSpaceless = in_array($lang, $spaceless, true);
|
||||
|
||||
// Keep original word timings if the text wasn't changed; otherwise
|
||||
// redistribute the line's span evenly across the new tokens.
|
||||
$origWords = (isset($ln['words']) && is_array($ln['words'])) ? $ln['words'] : [];
|
||||
$sep = $isSpaceless ? '' : ' ';
|
||||
$joined = implode($sep, array_map(fn ($w) => (string) ($w['text'] ?? ''), $origWords));
|
||||
$unchanged = $origWords
|
||||
&& preg_replace('/\s+/u', '', $joined) === preg_replace('/\s+/u', '', $text);
|
||||
|
||||
if ($unchanged) {
|
||||
$words = array_map(fn ($w) => [
|
||||
'start' => round((float) ($w['start'] ?? $start), 3),
|
||||
'end' => round((float) ($w['end'] ?? $end), 3),
|
||||
'text' => (string) ($w['text'] ?? ''),
|
||||
], $origWords);
|
||||
} else {
|
||||
$words = $this->redistributeWords($start, $end, $text, $isSpaceless);
|
||||
}
|
||||
|
||||
$out[] = ['start' => $start, 'end' => $end, 'text' => $text, 'lang' => $lang, 'words' => $words];
|
||||
if (count($out) >= 1000) break;
|
||||
}
|
||||
|
||||
usort($out, fn ($a, $b) => $a['start'] <=> $b['start']);
|
||||
|
||||
$nas->putLyrics($video, $track, [
|
||||
'version' => 1,
|
||||
'status' => 'ready',
|
||||
'source' => 'edited',
|
||||
'language' => $existing['language'] ?? ($out[0]['lang'] ?? 'en'),
|
||||
'multilingual' => $existing['multilingual'] ?? false,
|
||||
'lines' => $out,
|
||||
'generated_at' => now()->toIso8601String(),
|
||||
]);
|
||||
|
||||
return response()->json(['status' => 'ok', 'lines' => count($out)]);
|
||||
}
|
||||
|
||||
/** Evenly distribute a line's [start,end] across its words (used for edited lines). */
|
||||
private function redistributeWords(float $start, float $end, string $text, bool $spaceless): array
|
||||
{
|
||||
$text = trim($text);
|
||||
if ($text === '' || $end <= $start) {
|
||||
return $text === '' ? [] : [['start' => $start, 'end' => max($end, $start + 0.5), 'text' => $text]];
|
||||
}
|
||||
$tokens = $spaceless
|
||||
? preg_split('//u', $text, -1, PREG_SPLIT_NO_EMPTY)
|
||||
: preg_split('/\s+/u', $text, -1, PREG_SPLIT_NO_EMPTY);
|
||||
$tokens = array_values(array_filter($tokens, fn ($t) => trim($t) !== ''));
|
||||
$n = count($tokens);
|
||||
if ($n === 0) return [];
|
||||
$slice = ($end - $start) / $n;
|
||||
$words = [];
|
||||
foreach ($tokens as $i => $tok) {
|
||||
$words[] = [
|
||||
'start' => round($start + $i * $slice, 3),
|
||||
'end' => round($start + ($i + 1) * $slice, 3),
|
||||
'text' => $tok,
|
||||
];
|
||||
}
|
||||
return $words;
|
||||
}
|
||||
|
||||
public function matchData(Video $video)
|
||||
{
|
||||
if (! $video->canView(Auth::user())) {
|
||||
@@ -639,8 +893,9 @@ class VideoController extends Controller
|
||||
}
|
||||
|
||||
$slides = $video->slides()->orderBy('position')->get()->map(fn($s) => [
|
||||
'id' => $s->id,
|
||||
'url' => $s->url,
|
||||
'id' => $s->id,
|
||||
'url' => $s->url,
|
||||
'audio_track_id' => $s->audio_track_id, // null = primary / song-wide
|
||||
])->values();
|
||||
|
||||
$audioTracks = $video->audioTracks->map(fn ($t) => [
|
||||
@@ -1485,21 +1740,93 @@ class VideoController extends Controller
|
||||
private function storeTrackLocally(VideoAudioTrack $track, $trackFile, string $ext, Video $video, $nas): void
|
||||
{
|
||||
try {
|
||||
// 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);
|
||||
$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]);
|
||||
// Music: each track gets its own folder tracks/{lang-id}/audio.{ext}.
|
||||
// (storeTrackLocally is only reached for music since extra tracks
|
||||
// only exist on music videos.)
|
||||
$songLocalDir = $nas->localVideoDir($video);
|
||||
$trackFolder = $nas->trackFolderName($video, $track);
|
||||
$trackDirAbs = $songLocalDir . '/tracks/' . $trackFolder;
|
||||
@mkdir($trackDirAbs, 0755, true);
|
||||
|
||||
$canonical = "audio.{$ext}";
|
||||
$trackFile->move($trackDirAbs, $canonical);
|
||||
|
||||
// Build the NAS-relative path from the song's relative path so it
|
||||
// works on both local-only and NAS-enabled setups.
|
||||
$songRel = $this->relFromStoragePath($songLocalDir);
|
||||
$relPath = "{$songRel}/tracks/{$trackFolder}/{$canonical}";
|
||||
|
||||
$track->update(['path' => $relPath, 'filename' => $canonical]);
|
||||
} catch (\Throwable $e) {
|
||||
\Log::error("storeTrackLocally failed: " . $e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
/** Convert an absolute storage_path() value back to a storage-relative path. */
|
||||
private function relFromStoragePath(string $abs): string
|
||||
{
|
||||
$prefix = storage_path('app/');
|
||||
if (str_starts_with($abs, $prefix)) {
|
||||
return substr($abs, strlen($prefix));
|
||||
}
|
||||
return $abs;
|
||||
}
|
||||
|
||||
/**
|
||||
* Persist per-track slides — the new track owns them via audio_track_id.
|
||||
* Filename scheme keeps tracks from colliding in the shared slides/ folder:
|
||||
* slides/track-{trackId}-{position}.{ext}
|
||||
* Primary slides keep the legacy slides/{position}.{ext} scheme.
|
||||
*/
|
||||
private function storeTrackSlides(Video $video, VideoAudioTrack $track, array $files, $nas): void
|
||||
{
|
||||
// Per-track slides live in the track's own folder:
|
||||
// tracks/{lang-id}/slides/{position}.{ext}
|
||||
// Filenames are canonical (just {position}.{ext}) because the folder
|
||||
// already disambiguates by track.
|
||||
$nasEnabled = $nas->isEnabled();
|
||||
$songLocal = $nas->localVideoDir($video);
|
||||
$songRel = $this->relFromStoragePath($songLocal);
|
||||
$trackFold = $nas->trackFolderName($video, $track);
|
||||
$nasTrackDir = "{$songRel}/tracks/{$trackFold}";
|
||||
$localTrackDir = "{$songLocal}/tracks/{$trackFold}";
|
||||
|
||||
if ($nasEnabled) {
|
||||
try { $nas->mkdirp("{$nasTrackDir}/slides"); } catch (\Throwable $e) {}
|
||||
} else {
|
||||
@mkdir("{$localTrackDir}/slides", 0755, true);
|
||||
}
|
||||
|
||||
foreach ($files as $pos => $file) {
|
||||
if (! $file || ! $file->isValid()) continue;
|
||||
$ext = strtolower($file->getClientOriginalExtension() ?: 'jpg');
|
||||
$name = "{$pos}.{$ext}";
|
||||
$slide = VideoSlide::create([
|
||||
'video_id' => $video->id,
|
||||
'audio_track_id' => $track->id,
|
||||
'filename' => '__pending__',
|
||||
'position' => $pos,
|
||||
]);
|
||||
|
||||
$tempPath = $file->storeAs('public/tmp', "trackslide_{$slide->id}.{$ext}");
|
||||
$tempAbs = storage_path('app/' . $tempPath);
|
||||
|
||||
if ($nasEnabled) {
|
||||
$nasPath = "{$nasTrackDir}/slides/{$name}";
|
||||
if ($nas->putFile($tempAbs, $nasPath)) {
|
||||
@unlink($tempAbs);
|
||||
$slide->update(['filename' => $nasPath]);
|
||||
continue;
|
||||
}
|
||||
// Fall through to local on NAS failure
|
||||
}
|
||||
|
||||
@mkdir("{$localTrackDir}/slides", 0755, true);
|
||||
@rename($tempAbs, "{$localTrackDir}/slides/{$name}");
|
||||
$slide->update(['filename' => "{$nasTrackDir}/slides/{$name}"]);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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
|
||||
@@ -1602,10 +1929,11 @@ class VideoController extends Controller
|
||||
// Audio-only file → generate/serve a video for the version being played.
|
||||
$trackId = (int) request()->input('track', 0);
|
||||
$viz = request()->boolean('visualizer');
|
||||
$lyrics = request()->boolean('lyrics');
|
||||
|
||||
// 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));
|
||||
// Any non-primary, visualizer, or lyrics variant is served straight off disk (no DB column).
|
||||
if ($viz || $trackId || $lyrics) {
|
||||
$cacheFile = storage_path('app/' . $this->slideshowRel($video, $viz, $trackId, $lyrics));
|
||||
if (file_exists($cacheFile) && filesize($cacheFile) > 0) {
|
||||
return response()->download($cacheFile, $this->safeFilename($video->title, 'video') . '.mp4', [
|
||||
'Content-Type' => 'video/mp4',
|
||||
@@ -1707,7 +2035,8 @@ class VideoController extends Controller
|
||||
// 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' : '');
|
||||
$lyrics = $request->boolean('lyrics');
|
||||
$suffix = ($trackId ? '_t' . $trackId : '') . ($viz ? '_viz' : '') . ($lyrics ? '_lyr' : '');
|
||||
|
||||
$ffmpeg = \App\Models\Setting::ffmpegBinary();
|
||||
$ffprobe = config('ffmpeg.ffprobe', '/usr/bin/ffprobe');
|
||||
@@ -1731,7 +2060,7 @@ class VideoController extends Controller
|
||||
return response()->json(['error' => 'Audio file not found'], 404);
|
||||
}
|
||||
|
||||
$outRel = $this->slideshowRel($video, $viz, $trackId); // song's cache/ folder
|
||||
$outRel = $this->slideshowRel($video, $viz, $trackId, $lyrics); // 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';
|
||||
@@ -1772,12 +2101,37 @@ class VideoController extends Controller
|
||||
return response()->json(['error' => 'Cannot probe audio duration'], 500);
|
||||
}
|
||||
|
||||
$slides = $video->slides()->orderBy('position')->get();
|
||||
// Use the slides for the track being rendered, applying the per-track
|
||||
// sharing fallback (own → primary → sibling). Ensures the download .mp4
|
||||
// matches the slideshow the listener saw in the player.
|
||||
$video->loadMissing('slides');
|
||||
$slides = $video->slidesForTrack($trackId ?: null)->sortBy('position')->values();
|
||||
$validSlides = $slides->filter(fn($s) => file_exists($s->localPath()))->values();
|
||||
|
||||
$scale = 'scale=1280:720:force_original_aspect_ratio=decrease,'
|
||||
. 'pad=1280:720:(ow-iw)/2:(oh-ih)/2:color=black,format=yuv420p';
|
||||
|
||||
// ── Optional burned-in lyrics (ASS via libass) ───────────────────────
|
||||
// When ?lyrics=1 and a ready lyrics file exists, build an .ass karaoke
|
||||
// track and weave it into the final video output. $voutLabel/$assArg/
|
||||
// $assTail let each render branch inject the burn uniformly.
|
||||
$voutLabel = '[vout]';
|
||||
$assArg = ''; // for -vf branches: ",ass=/tmp/x.ass"
|
||||
$assTail = ''; // for filter_complex branches: ";[vout]ass=/tmp/x.ass[vsub]"
|
||||
if ($lyrics) {
|
||||
$lyrTarget = $trackId ? $video->audioTracks()->find($trackId) : null;
|
||||
$lyrData = app(\App\Services\NasSyncService::class)->getLocalLyrics($video, $lyrTarget);
|
||||
if (is_array($lyrData) && ($lyrData['status'] ?? null) === 'ready') {
|
||||
$assPath = sys_get_temp_dir() . '/lyr_' . $video->id . $suffix . '.ass';
|
||||
if (\App\Support\LyricsAss::write($lyrData, $assPath)) {
|
||||
$assFilter = 'ass=' . $assPath;
|
||||
$assArg = ',' . $assFilter;
|
||||
$assTail = ';[vout]' . $assFilter . '[vsub]';
|
||||
$voutLabel = '[vsub]';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@unlink($progressFile);
|
||||
@unlink($pidFile);
|
||||
|
||||
@@ -1850,10 +2204,10 @@ class VideoController extends Controller
|
||||
$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);
|
||||
$fc = implode(';', $fcParts) . $assTail;
|
||||
$cmd = "{$ffmpeg} -y{$inputs}"
|
||||
. ' -filter_complex ' . escapeshellarg($fc)
|
||||
. ' -map [vout] -map ' . $audioIdx . ':a'
|
||||
. ' -map ' . $voutLabel . ' -map ' . $audioIdx . ':a'
|
||||
. ' ' . $vFlags
|
||||
. ' -c:a aac -b:a 192k -movflags +faststart -shortest';
|
||||
|
||||
@@ -1881,10 +2235,10 @@ class VideoController extends Controller
|
||||
$prev = $outLabel;
|
||||
}
|
||||
|
||||
$fc = implode(';', array_merge($scaleFc, $xfadeFc));
|
||||
$fc = implode(';', array_merge($scaleFc, $xfadeFc)) . $assTail;
|
||||
$cmd = "{$ffmpeg} -y{$inputs}"
|
||||
. ' -filter_complex ' . escapeshellarg($fc)
|
||||
. ' -map [vout] -map ' . $n . ':a'
|
||||
. ' -map ' . $voutLabel . ' -map ' . $n . ':a'
|
||||
. ' ' . $vFlags
|
||||
. ' -c:a aac -b:a 192k -movflags +faststart -shortest';
|
||||
|
||||
@@ -1894,7 +2248,7 @@ class VideoController extends Controller
|
||||
. ' -loop 1 -r 1 -i ' . escapeshellarg($imgPath)
|
||||
. ' -i ' . escapeshellarg($audioPath)
|
||||
. ' -map 0:v:0 -map 1:a:0 -shortest'
|
||||
. ' -vf ' . escapeshellarg($scale)
|
||||
. ' -vf ' . escapeshellarg($scale . $assArg)
|
||||
. ' ' . $vFlags
|
||||
. ' -c:a aac -b:a 192k -movflags +faststart';
|
||||
} else {
|
||||
@@ -1902,6 +2256,7 @@ class VideoController extends Controller
|
||||
. ' -f lavfi -i color=c=black:s=1280x720:r=1'
|
||||
. ' -i ' . escapeshellarg($audioPath)
|
||||
. ' -map 0:v:0 -map 1:a:0 -shortest'
|
||||
. ($assArg !== '' ? ' -vf ' . escapeshellarg('format=yuv420p' . $assArg) : '')
|
||||
. ' ' . $vFlags
|
||||
. ' -c:a aac -b:a 192k -movflags +faststart';
|
||||
}
|
||||
@@ -1934,9 +2289,10 @@ class VideoController extends Controller
|
||||
{
|
||||
$viz = request()->boolean('visualizer');
|
||||
$trackId = (int) request()->input('track', 0);
|
||||
$suffix = ($trackId ? '_t' . $trackId : '') . ($viz ? '_viz' : '');
|
||||
$lyrics = request()->boolean('lyrics');
|
||||
$suffix = ($trackId ? '_t' . $trackId : '') . ($viz ? '_viz' : '') . ($lyrics ? '_lyr' : '');
|
||||
|
||||
$outPath = storage_path('app/' . $this->slideshowRel($video, $viz, $trackId));
|
||||
$outPath = storage_path('app/' . $this->slideshowRel($video, $viz, $trackId, $lyrics));
|
||||
$progressFile = sys_get_temp_dir() . '/sl_progress_' . $video->id . $suffix . '.txt';
|
||||
$pidFile = sys_get_temp_dir() . '/sl_pid_' . $video->id . $suffix . '.txt';
|
||||
|
||||
@@ -1955,7 +2311,7 @@ class VideoController extends Controller
|
||||
if ($isDone && file_exists($outPath) && filesize($outPath) > 0) {
|
||||
// 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) {
|
||||
if (! $viz && ! $trackId && ! $lyrics && ! $video->slideshow_video_path) {
|
||||
$video->update(['slideshow_video_path' => $this->slideshowRel($video, false, 0)]);
|
||||
}
|
||||
return response()->json(['percent' => 100, 'status' => 'ready']);
|
||||
@@ -2089,12 +2445,13 @@ class VideoController extends Controller
|
||||
* 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
|
||||
private function slideshowRel(Video $video, bool $viz, int $trackId = 0, bool $lyrics = false): string
|
||||
{
|
||||
$nas = app(\App\Services\NasSyncService::class);
|
||||
return $this->nasVideoDir($video, $nas) . '/cache/video'
|
||||
. ($trackId ? '-t' . $trackId : '')
|
||||
. ($viz ? '-viz' : '') . '.mp4';
|
||||
. ($viz ? '-viz' : '')
|
||||
. ($lyrics ? '-lyr' : '') . '.mp4';
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
Reference in new issue
Block a user