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

+12 -9
View File
@@ -224,24 +224,27 @@ class NasFreeLocalStorage extends Command
$this->line(' Done scanning banners.');
// ── Slideshow cache directories ───────────────────────────────────────
// The slideshow/ directory is a render cache that is always regenerated on
// demand, so its contents are safe to delete unconditionally.
// ── Generated/derived renders inside song folders ─────────────────────
// Everything under a song's cache/ subfolder (download videos + HLS) is a
// render that regenerates on demand, so it is always safe to delete to free
// space. Sources (audio, tracks, slides) live outside cache/ and are untouched.
$this->newLine();
$this->info('Scanning slideshow cache…');
$this->info('Scanning generated renders (song cache/ folders)…');
$slideshowDir = storage_path('app/public/slideshow');
if (is_dir($slideshowDir)) {
$usersRoot = storage_path('app/users');
if (is_dir($usersRoot)) {
$iterator = new \RecursiveIteratorIterator(
new \RecursiveDirectoryIterator($slideshowDir, \FilesystemIterator::SKIP_DOTS)
new \RecursiveDirectoryIterator($usersRoot, \FilesystemIterator::SKIP_DOTS)
);
foreach ($iterator as $file) {
if (! $file->isFile()) continue;
$path = $file->getPathname();
if (! str_contains($path, '/cache/')) continue;
$bytes = $file->getSize();
$totalBytes += $bytes;
$toDelete[] = ['label' => 'slideshow', 'path' => $file->getPathname(), 'bytes' => $bytes];
$toDelete[] = ['label' => str_contains($path, '/cache/hls/') ? 'hls' : 'download-video', 'path' => $path, 'bytes' => $bytes];
}
$this->line(' Done scanning slideshow cache.');
$this->line(' Done scanning generated renders.');
}
// ── NAS stream cache (nas_cache/videos/) ──────────────────────────────
@@ -0,0 +1,325 @@
<?php
namespace App\Console\Commands;
use App\Models\Playlist;
use App\Models\User;
use App\Models\Video;
use App\Models\VideoAudioTrack;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\DB;
/**
* Major file-structure cleanup for audio "songs":
*
* 1. CONSOLIDATE every song's files into the song's own folder. Each file is
* gathered from wherever its local copy currently is (the canonical path OR
* the nas_cache/) and moved to:
* - primary : kept at its canonical name; promoted/legacy primaries are
* relocated to {song-folder}/{title-slug}.{ext}
* - secondary: {song-folder}/{folder-slug}-{lang}-{track-id}.{ext}
* (lowercase, unique — the db id means nothing can ever overwrite anything,
* and there is no more tracks/ subfolder).
* 2. UPDATE the DB records (path + filename) to match.
* 3. DELETE orphan files (referenced by NO database column) and empty folders
* across the media roots.
*
* Moves are two-phase (src -> temp -> final) so the historical primary<->secondary
* "swaps" cannot clobber each other. Serving works straight afterwards because
* NasSyncService::ensureLocalCopy() prefers the local canonical path.
*
* Defaults to a DRY RUN. Pass --force to apply.
*/
class ReorganizeAudioTracks extends Command
{
protected $signature = 'tracks:reorganize {--force : Apply changes (default is a dry run)}';
protected $description = 'Consolidate audio tracks into one folder per song with unique names, update records, delete orphans + empty folders';
private const AUDIO_EXT = ['mp3', 'm4a', 'aac', 'wav', 'flac', 'ogg', 'opus', 'wma'];
/** Media roots (relative to storage/app) that hold ONLY user media — safe to clean. */
private const SCAN_ROOTS = ['users', 'nas_cache/videos', 'public/videos', 'public/thumbnails', 'public/avatars'];
private bool $dry = true;
private string $appRoot;
public function handle(): int
{
$this->dry = ! $this->option('force');
$this->appRoot = storage_path('app');
$this->info($this->dry ? '=== DRY RUN (no changes) — pass --force to apply ===' : '=== APPLYING CHANGES ===');
$this->newLine();
$plan = $this->buildPlan();
$this->printPlan($plan);
if (! $this->dry) {
$this->applyPlan($plan);
}
$finalPaths = $this->finalReferencedPaths($plan);
$this->handleOrphans($plan, $finalPaths);
$this->handleEmptyDirs();
$this->newLine();
$this->info($this->dry ? 'Dry run complete. Re-run with --force to apply.' : 'Done.');
return self::SUCCESS;
}
// ── helpers ────────────────────────────────────────────────────────────────
private function isAudio(Video $v): bool
{
if ($v->mime_type && str_starts_with($v->mime_type, 'audio/')) return true;
return in_array(strtolower(pathinfo($v->filename ?? '', PATHINFO_EXTENSION)), self::AUDIO_EXT, true);
}
private function titleSlug(string $title): string
{
$title = \Normalizer::normalize($title, \Normalizer::FORM_C) ?: $title;
$slug = preg_replace('/[^\p{L}\p{N}]+/u', '-', $title);
$slug = trim(mb_strtolower($slug), '-');
if (mb_strlen($slug) > 100) $slug = rtrim(mb_substr($slug, 0, 100), '-');
return $slug ?: 'video';
}
private function songDir(Video $v): string
{
$path = (string) $v->path;
if (str_starts_with($path, 'users/')) {
$dir = dirname($path);
if (basename($dir) === 'tracks') $dir = dirname($dir); // promoted-primary case
return $dir;
}
$userSlug = $v->user?->username ?: (string) $v->user_id;
return 'users/' . $userSlug . '/videos/' . $this->titleSlug($v->title);
}
private function trackName(string $base, ?string $lang, int $id, string $ext): string
{
$lang = mb_strtolower(trim((string) $lang)) ?: 'xx';
return mb_strtolower($base . '-' . $lang . '-' . $id . '.' . strtolower($ext));
}
/** Locate the actual local copy of a file given its DB path + filename. */
private function locate(string $dbPath, string $filename): ?string
{
foreach ([$dbPath, 'nas_cache/videos/' . $filename] as $cand) {
if ($cand && is_file($this->appRoot . '/' . $cand)) return $cand;
}
return null;
}
// ── plan ─────────────────────────────────────────────────────────────────
private function buildPlan(): array
{
$plan = [];
foreach (Video::with(['user', 'audioTracks'])->get() as $video) {
if (! $this->isAudio($video)) continue;
$dir = $this->songDir($video);
$base = basename($dir);
// ── primary ──
$pExt = strtolower(pathinfo($video->filename ?: $video->path, PATHINFO_EXTENSION)) ?: 'mp3';
$inDir = str_starts_with((string) $video->path, 'users/') && basename(dirname((string) $video->path)) !== 'tracks';
$pDst = $inDir
? (string) $video->path // already in song folder — keep its name
: $dir . '/' . $this->titleSlug($video->title) . '.' . $pExt; // promoted/legacy — canonical name
$plan[] = $this->item('primary', $video, (string) $video->path, $video->filename, $pDst);
// ── secondaries ──
foreach ($video->audioTracks as $t) {
$tExt = strtolower(pathinfo($t->filename ?: $t->path, PATHINFO_EXTENSION)) ?: 'mp3';
$tDst = $dir . '/' . $this->trackName($base, $t->language, $t->id, $tExt);
$plan[] = $this->item('track', $t, (string) $t->path, $t->filename, $tDst);
}
}
return $plan;
}
private function item(string $type, $model, string $dbPath, ?string $filename, string $dst): array
{
$src = $this->locate($dbPath, (string) $filename);
return [
'type' => $type,
'id' => $model->id,
'model' => $model,
'db_path' => $dbPath,
'src' => $src, // actual local file (or null if only on NAS)
'dst' => $dst,
'needsMove' => $src !== null && $src !== $dst,
'atDest' => $src !== null && $src === $dst,
'dbStale' => $dbPath !== $dst, // DB needs updating even if file already at dest
];
}
private function printPlan(array $plan): void
{
$moves = array_filter($plan, fn ($p) => $p['needsMove']);
$miss = array_filter($plan, fn ($p) => $p['src'] === null);
$this->line('<comment>Consolidation / rename plan:</comment>');
foreach ($plan as $p) {
if ($p['src'] === null) {
$this->line(sprintf(' <fg=red>[%s #%d] NO LOCAL COPY</> (db: %s) — skipped', $p['type'], $p['id'], $p['db_path']));
continue;
}
if ($p['needsMove']) {
$this->line(sprintf(' [%s #%d] %s', $p['type'], $p['id'], $p['src']));
$this->line(sprintf(' -> %s', $p['dst']));
}
}
$this->newLine();
$this->line(' moves: ' . count($moves) . ' | already correct: ' . (count($plan) - count($moves) - count($miss)) . ' | no local copy: ' . count($miss));
$this->newLine();
}
private function applyPlan(array $plan): void
{
// Phase 1: move every file that needs moving to a unique temp name (collision-safe).
$temps = [];
foreach ($plan as $i => $p) {
if (! $p['needsMove']) continue;
$srcAbs = $this->appRoot . '/' . $p['src'];
$tmpRel = dirname($p['dst']) . '/.reorg_' . $p['type'] . '_' . $p['id'] . '.tmp';
$tmpAbs = $this->appRoot . '/' . $tmpRel;
@mkdir(dirname($tmpAbs), 0755, true);
if (@rename($srcAbs, $tmpAbs)) {
$temps[$i] = $tmpRel;
} else {
$this->error(" FAILED phase1 move: {$p['src']}");
}
}
// Phase 2: temp -> final, then update DB.
foreach ($plan as $i => $p) {
if (isset($temps[$i])) {
$tmpAbs = $this->appRoot . '/' . $temps[$i];
$dstAbs = $this->appRoot . '/' . $p['dst'];
if (! @rename($tmpAbs, $dstAbs)) {
$this->error(" FAILED phase2 move -> {$p['dst']}");
continue;
}
$this->line(" moved #{$p['id']} -> {$p['dst']}");
}
// Update DB record whenever the stored path/name is out of date.
if ($p['src'] !== null && $p['dbStale']) {
$p['model']->update(['path' => $p['dst'], 'filename' => basename($p['dst'])]);
}
}
}
// ── orphans + empties ──────────────────────────────────────────────────────
/** All file paths (relative to storage/app) referenced by the DB after migration. */
private function finalReferencedPaths(array $plan): array
{
$paths = [];
$planned = []; // db_path keyed -> handled by plan
foreach ($plan as $p) {
if ($p['src'] !== null) { $paths[$p['dst']] = true; $planned[$p['db_path']] = true; }
else { $paths[$p['db_path']] = true; } // keep NAS-only refs
}
foreach (Video::all() as $v) {
if ($v->path && ! isset($planned[$v->path]) && ! $this->plannedPrimary($plan, $v->id)) $paths[$v->path] = true;
if ($v->thumbnail) $paths[$v->thumbnail] = true;
if ($v->slideshow_video_path) $paths[$v->slideshow_video_path] = true; // generated download video
}
foreach (VideoAudioTrack::all() as $t) {
if ($t->path && ! isset($planned[$t->path]) && ! $this->plannedTrack($plan, $t->id)) $paths[$t->path] = true;
}
foreach (DB::table('video_slides')->pluck('filename') as $f) if ($f) $paths[$f] = true;
foreach (User::all() as $u) { if ($u->avatar) $paths[$u->avatar] = true; if ($u->banner) $paths[$u->banner] = true; }
foreach (Playlist::all() as $pl) if ($pl->thumbnail) $paths[$pl->thumbnail] = true;
foreach (DB::table('post_images')->pluck('filename') as $f) if ($f) $paths[$f] = true;
foreach (DB::table('posts')->pluck('image') as $f) if ($f) $paths[$f] = true;
return $paths;
}
private function plannedPrimary(array $plan, int $id): bool
{
foreach ($plan as $p) if ($p['type'] === 'primary' && $p['id'] === $id && $p['src'] !== null) return true;
return false;
}
private function plannedTrack(array $plan, int $id): bool
{
foreach ($plan as $p) if ($p['type'] === 'track' && $p['id'] === $id && $p['src'] !== null) return true;
return false;
}
private function handleOrphans(array $plan, array $finalPaths): void
{
// Conservative: also protect any file whose basename matches a referenced basename
// or a planned move-source (sources are being moved, not orphaned).
$keepBasenames = ['meta.json' => true];
foreach (array_keys($finalPaths) as $rel) $keepBasenames[basename($rel)] = true;
foreach ($plan as $p) if ($p['src']) $keepBasenames[basename($p['src'])] = true;
// Files actively moved this run (their old location is vacated, not an orphan to re-check).
$movedFrom = [];
foreach ($plan as $p) if ($p['needsMove']) $movedFrom[$p['src']] = true;
$orphans = [];
foreach (self::SCAN_ROOTS as $root) {
$abs = $this->appRoot . '/' . $root;
if (! is_dir($abs)) continue;
$it = new \RecursiveIteratorIterator(new \RecursiveDirectoryIterator($abs, \FilesystemIterator::SKIP_DOTS));
foreach ($it as $file) {
if (! $file->isFile()) continue;
$rel = substr($file->getPathname(), strlen($this->appRoot) + 1);
if (isset($finalPaths[$rel])) continue;
if (isset($keepBasenames[$file->getFilename()])) continue;
if (isset($movedFrom[$rel])) continue;
// Everything under a song's cache/ is a regenerable render — never an orphan.
if (str_contains($rel, '/cache/')) continue;
$orphans[] = $rel;
}
}
$this->newLine();
if (! $orphans) { $this->line('No orphan files found.'); return; }
$this->line('<comment>Orphan files (no DB reference) — ' . count($orphans) . ':</comment>');
$bytes = 0;
foreach ($orphans as $rel) {
$abs = $this->appRoot . '/' . $rel;
$sz = is_file($abs) ? filesize($abs) : 0; $bytes += $sz;
$this->line(sprintf(' %s (%s KB)', $rel, number_format($sz / 1024, 1)));
if (! $this->dry) @unlink($abs);
}
$this->line(' total: ' . number_format($bytes / 1048576, 1) . ' MB' . ($this->dry ? '' : ' (deleted)'));
}
private function handleEmptyDirs(): void
{
$this->newLine();
$removed = 0; $listed = [];
// Loop because removing leaf dirs can empty their parents.
do {
$found = 0;
foreach (self::SCAN_ROOTS as $root) {
$abs = $this->appRoot . '/' . $root;
if (! is_dir($abs)) continue;
$it = new \RecursiveIteratorIterator(
new \RecursiveDirectoryIterator($abs, \FilesystemIterator::SKIP_DOTS),
\RecursiveIteratorIterator::CHILD_FIRST
);
foreach ($it as $f) {
if (! $f->isDir()) continue;
if ((new \FilesystemIterator($f->getPathname()))->valid()) continue; // not empty
$rel = substr($f->getPathname(), strlen($this->appRoot) + 1);
if (isset($listed[$rel])) continue;
$listed[$rel] = true; $found++;
if (! $this->dry) { @rmdir($f->getPathname()); $removed++; }
}
}
} while (! $this->dry && $found > 0);
if (! $listed) { $this->line('No empty folders found.'); return; }
$this->line('<comment>Empty folders — ' . count($listed) . ':</comment>');
foreach (array_keys($listed) as $rel) $this->line(' ' . $rel);
if (! $this->dry) $this->line(" ({$removed} removed)");
}
}
+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));
}
+3 -1
View File
@@ -51,7 +51,9 @@ class CompressVideoJob implements ShouldQueue
]);
$ffmpegVideo = $ffmpeg->open($originalPath);
$gpuEnabled = Setting::gpuEnabled();
// 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();
$encoder = Setting::gpuEncoder();
$preset = Setting::gpuPreset();
$device = Setting::gpuDevice();
+9 -2
View File
@@ -52,7 +52,12 @@ class GenerateHlsJob implements ShouldQueue
}
}
$hlsDir = 'public/hls/' . $video->id;
// HLS rendition lives in the song's own cache/ subfolder (regenerable, local-only —
// never pushed to NAS). Fall back to the shared public/hls only for legacy rows
// whose path is not in the users/ layout.
$hlsDir = str_starts_with((string) $video->path, 'users/')
? dirname($video->path) . '/cache/hls'
: 'public/hls/' . $video->id;
$hlsPath = storage_path('app/' . $hlsDir);
if (is_dir($hlsPath)) {
@@ -72,7 +77,9 @@ class GenerateHlsJob implements ShouldQueue
try {
$ffmpegBin = \App\Models\Setting::ffmpegBinary();
$gpuEnabled = Setting::gpuEnabled();
// 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();
$encoder = Setting::gpuEncoder(); // h264_nvenc / hevc_nvenc / libx264
$preset = Setting::gpuPreset(); // p1–p7 for NVENC, fast/medium/slow for x264
$device = Setting::gpuDevice(); // GPU index (0, 1, …)
+96 -4
View File
@@ -3,6 +3,8 @@
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Log;
class Setting extends Model
{
@@ -31,6 +33,7 @@ class Setting extends Model
return static::get('ffmpeg_binary', config('ffmpeg.ffmpeg', '/usr/bin/ffmpeg'));
}
/** Raw setting: is GPU encoding switched on in the admin panel? */
public static function gpuEnabled(): bool
{
return static::get('gpu_enabled', 'true') === 'true';
@@ -41,16 +44,105 @@ class Setting extends Model
return (int) static::get('gpu_device', '0');
}
/**
* Runtime gate used by every encode path: the GPU is switched on AND it is
* actually reachable and able to encode right now. Unlike gpuEnabled() this
* runs a live smoke-test (cached briefly) so a misconfigured / unplugged /
* driver-mismatched GPU automatically routes work to the CPU instead of
* producing a hung or failed encode.
*/
public static function gpuUsable(): bool
{
if (! static::gpuEnabled()) {
return false;
}
// Cache the smoke-test result briefly, but never let a cache backend problem
// (e.g. an unwritable cache dir) break an encode — fall back to a direct probe.
try {
return (bool) Cache::remember('gpu_usable_probe', 60, fn () => static::probeGpu());
} catch (\Throwable $e) {
return static::probeGpu();
}
}
/** Forget the cached probe result — call this whenever GPU settings change. */
public static function flushGpuProbe(): void
{
try {
Cache::forget('gpu_usable_probe');
} catch (\Throwable $e) {
// ignore — a missing/unwritable cache just means the next call re-probes
}
}
/**
* Smoke-test the GPU encode path. Two checks:
* 1. the device is visible to the NVIDIA driver (nvidia-smi), and
* 2. the encoder actually produces a frame.
* Step 2 is the decisive one — it catches CUDA / driver-version mismatches
* that nvidia-smi cannot see. Returns true only if the GPU can really encode.
*
* @param string|null $encoder Encoder to test; defaults to the configured one.
*/
public static function probeGpu(?string $encoder = null): bool
{
$encoder = $encoder ?: static::get('gpu_encoder', 'h264_nvenc');
$device = static::gpuDevice();
$isNvenc = str_contains($encoder, 'nvenc');
// 1) Device visible to the driver? (catches unplugged card / unloaded module)
if ($isNvenc) {
@exec('nvidia-smi -i ' . (int) $device
. ' --query-gpu=name --format=csv,noheader,nounits 2>/dev/null', $smi, $smiExit);
if ($smiExit !== 0 || trim(implode('', $smi)) === '') {
Log::warning('GPU probe: device not visible via nvidia-smi — using CPU', [
'device' => $device,
]);
return false;
}
}
// 2) Encoder actually works? (encode a single throwaway frame)
$ffmpeg = static::ffmpegBinary();
$tmp = sys_get_temp_dir() . '/gpu_probe_' . getmypid() . '_' . uniqid() . '.mp4';
$gpuArg = $isNvenc ? ' -gpu ' . (int) $device : '';
// 256x144 is the smallest 16:9 size NVENC will accept (it rejects tiny frames
// such as 128x72 with "Frame Dimension less than the minimum supported value").
@exec(
escapeshellcmd($ffmpeg)
. ' -hide_banner -loglevel error'
. ' -f lavfi -i color=c=black:s=256x144:r=1 -frames:v 1'
. ' -c:v ' . escapeshellarg($encoder) . $gpuArg
. ' -y ' . escapeshellarg($tmp) . ' 2>/dev/null',
$o, $exit
);
$ok = ($exit === 0 && is_file($tmp) && filesize($tmp) > 0);
@unlink($tmp);
if (! $ok) {
Log::warning('GPU probe: encode smoke-test failed — using CPU', [
'encoder' => $encoder,
'device' => $device,
'binary' => $ffmpeg,
]);
}
return $ok;
}
public static function gpuEncoder(): string
{
return static::gpuEnabled()
return static::gpuUsable()
? static::get('gpu_encoder', 'h264_nvenc')
: 'libx264';
}
public static function gpuPreset(): string
{
return static::gpuEnabled()
return static::gpuUsable()
? static::get('gpu_preset', 'p4')
: 'fast';
}
@@ -63,7 +155,7 @@ class Setting extends Model
/** Returns the full video codec flags for FFmpeg shell commands. */
public static function ffmpegVideoFlags(bool $stillImage = false): string
{
if (static::gpuEnabled()) {
if (static::gpuUsable()) {
$enc = static::get('gpu_encoder', 'h264_nvenc');
$preset = static::get('gpu_preset', 'p4');
$device = static::gpuDevice();
@@ -84,7 +176,7 @@ class Setting extends Model
/** Returns hwaccel decode flags when the input source is a video file. */
public static function ffmpegHwaccelFlags(bool $inputIsVideo): string
{
if (! $inputIsVideo || ! static::gpuEnabled()) return '';
if (! $inputIsVideo || ! static::gpuUsable()) return '';
$hwaccel = static::get('gpu_hwaccel', 'cuda');
$device = static::gpuDevice();
return "-hwaccel {$hwaccel} -hwaccel_device {$device} ";
+223
View File
@@ -0,0 +1,223 @@
<?php
namespace App\Support;
use DOMDocument;
use DOMElement;
use DOMNode;
/**
* Allowlist-based HTML sanitizer for rich-text descriptions.
*
* Descriptions are authored in a self-hosted contenteditable editor and stored
* as HTML. clean() strips everything not on the allowlist before storage;
* render() produces safe display HTML (and upgrades legacy plain-text values).
*/
class HtmlSanitizer
{
/** Tags allowed in stored description HTML. */
private const ALLOWED_TAGS = [
'p', 'br', 'div', 'span',
'b', 'strong', 'i', 'em', 'u', 's', 'strike',
'h2', 'h3',
'ul', 'ol', 'li',
'blockquote', 'a',
];
/** CSS class values permitted on <a> (button-link styling). */
private const ALLOWED_CLASSES = [
'action-btn', 'action-btn-primary', 'action-btn-danger',
'action-btn-link', 'primary', 'danger', 'icon-only',
];
/** URL schemes permitted in href. */
private const ALLOWED_SCHEMES = ['http', 'https', 'mailto'];
/** Tags removed wholesale, including their text content. */
private const DROP_TAGS = [
'script', 'style', 'iframe', 'object', 'embed', 'form',
'input', 'button', 'textarea', 'select', 'option', 'link', 'meta',
'svg', 'math', 'noscript', 'template',
];
/**
* Sanitize untrusted HTML down to the allowlist, for storage.
*/
public static function clean(?string $html): string
{
$html = trim((string) $html);
if ($html === '') {
return '';
}
// Wrap so DOMDocument has a single root and a known encoding.
$wrapped = '<?xml encoding="UTF-8"><div id="__rte_root__">' . $html . '</div>';
$doc = new DOMDocument();
$libxmlPrev = libxml_use_internal_errors(true);
$doc->loadHTML($wrapped, LIBXML_NOERROR | LIBXML_NOWARNING | LIBXML_HTML_NOIMPLIED | LIBXML_HTML_NODEFDTD);
libxml_clear_errors();
libxml_use_internal_errors($libxmlPrev);
$root = $doc->getElementById('__rte_root__');
if (!$root) {
return '';
}
self::sanitizeChildren($root);
$out = '';
foreach (iterator_to_array($root->childNodes) as $child) {
$out .= $doc->saveHTML($child);
}
return self::tidy($out);
}
/**
* Produce safe display HTML. Legacy plain-text values (no tags) are
* escaped and converted to <br>; rich values are run through clean().
*/
public static function render(?string $value): string
{
$value = (string) $value;
if (trim($value) === '') {
return '';
}
if (strip_tags($value) === $value) {
return nl2br(e($value), false);
}
return self::clean($value);
}
private static function sanitizeChildren(DOMNode $node): void
{
foreach (iterator_to_array($node->childNodes) as $child) {
if ($child instanceof DOMElement) {
self::sanitizeElement($child);
} elseif ($child->nodeType === XML_COMMENT_NODE) {
$child->parentNode->removeChild($child);
}
// Text nodes are kept as-is (saveHTML re-encodes them safely).
}
}
private static function sanitizeElement(DOMElement $el): void
{
$tag = strtolower($el->nodeName);
if (in_array($tag, self::DROP_TAGS, true)) {
$el->parentNode->removeChild($el);
return;
}
if (!in_array($tag, self::ALLOWED_TAGS, true)) {
// Unwrap: keep sanitized children, drop the disallowed wrapper.
self::sanitizeChildren($el);
$parent = $el->parentNode;
while ($el->firstChild) {
$parent->insertBefore($el->firstChild, $el);
}
$parent->removeChild($el);
return;
}
// Strip every attribute, then re-add only the allowed ones.
$keep = [];
$alignAttr = null;
foreach (iterator_to_array($el->attributes) as $attr) {
$name = strtolower($attr->nodeName);
$val = $attr->nodeValue;
if ($name === 'align') {
$a = strtolower(trim($val));
if (in_array($a, ['left', 'right', 'center', 'justify'], true)) {
$alignAttr = $a;
}
} elseif ($name === 'href' && $tag === 'a') {
$href = self::safeUrl($val);
if ($href !== null) {
$keep['href'] = $href;
}
} elseif ($name === 'class') {
$classes = array_values(array_intersect(
preg_split('/\s+/', trim($val)) ?: [],
self::ALLOWED_CLASSES
));
if ($classes) {
$keep['class'] = implode(' ', $classes);
}
} elseif ($name === 'style') {
$style = self::safeStyle($val);
if ($style !== '') {
$keep['style'] = $style;
}
} elseif ($name === 'target' && $tag === 'a') {
if ($val === '_blank') {
$keep['target'] = '_blank';
}
}
}
// Fold a legacy align="" attribute into the text-align style.
if ($alignAttr !== null && !str_contains($keep['style'] ?? '', 'text-align')) {
$keep['style'] = trim(($keep['style'] ?? '') . ';text-align:' . $alignAttr, ';');
}
while ($el->attributes->length) {
$el->removeAttribute($el->attributes->item(0)->nodeName);
}
foreach ($keep as $name => $val) {
$el->setAttribute($name, $val);
}
// Any link opening a new tab gets rel protection.
if ($tag === 'a' && ($keep['target'] ?? '') === '_blank') {
$el->setAttribute('rel', 'noopener noreferrer');
}
self::sanitizeChildren($el);
}
private static function safeUrl(?string $url): ?string
{
$url = trim((string) $url);
if ($url === '') {
return null;
}
// Reject control chars that could smuggle a scheme.
$stripped = preg_replace('/[\x00-\x20]+/', '', $url);
if (preg_match('/^([a-z][a-z0-9+.\-]*):/i', $stripped, $m)) {
if (!in_array(strtolower($m[1]), self::ALLOWED_SCHEMES, true)) {
return null;
}
}
// Allow relative URLs and fragments/anchors as-is.
return $url;
}
private static function safeStyle(?string $style): string
{
$out = [];
foreach (explode(';', (string) $style) as $decl) {
if (!str_contains($decl, ':')) {
continue;
}
[$prop, $val] = array_map('trim', explode(':', $decl, 2));
$prop = strtolower($prop);
$val = strtolower($val);
if ($prop === 'text-align' && in_array($val, ['left', 'right', 'center', 'justify'], true)) {
$out[] = "text-align:{$val}";
}
}
return implode(';', $out);
}
private static function tidy(string $html): string
{
// DOMDocument can emit empty paragraphs/divs from editor churn.
$html = preg_replace('#<(p|div)>(\s|&nbsp;|<br\s*/?>)*</\1>#i', '', $html);
return trim((string) $html);
}
}