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
+96
-4
@@ -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} ";
|
||||
|
||||
Reference in new issue
Block a user