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) <noreply@anthropic.com>
185 lines
6.6 KiB
PHP
185 lines
6.6 KiB
PHP
<?php
|
|
|
|
namespace App\Models;
|
|
|
|
use Illuminate\Database\Eloquent\Model;
|
|
use Illuminate\Support\Facades\Cache;
|
|
use Illuminate\Support\Facades\Log;
|
|
|
|
class Setting extends Model
|
|
{
|
|
protected $fillable = ['key', 'value'];
|
|
|
|
private static array $cache = [];
|
|
|
|
public static function get(string $key, mixed $default = null): mixed
|
|
{
|
|
if (! array_key_exists($key, self::$cache)) {
|
|
$row = static::where('key', $key)->first();
|
|
self::$cache[$key] = $row ? $row->value : $default;
|
|
}
|
|
return self::$cache[$key];
|
|
}
|
|
|
|
public static function set(string $key, mixed $value): void
|
|
{
|
|
static::updateOrCreate(['key' => $key], ['value' => (string) $value]);
|
|
self::$cache[$key] = (string) $value;
|
|
}
|
|
|
|
/** Returns the configured FFmpeg binary path, falling back to the config file default. */
|
|
public static function ffmpegBinary(): string
|
|
{
|
|
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';
|
|
}
|
|
|
|
public static function gpuDevice(): int
|
|
{
|
|
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::gpuUsable()
|
|
? static::get('gpu_encoder', 'h264_nvenc')
|
|
: 'libx264';
|
|
}
|
|
|
|
public static function gpuPreset(): string
|
|
{
|
|
return static::gpuUsable()
|
|
? static::get('gpu_preset', 'p4')
|
|
: 'fast';
|
|
}
|
|
|
|
public static function gpuHwaccel(): string
|
|
{
|
|
return static::get('gpu_hwaccel', 'cuda');
|
|
}
|
|
|
|
/** Returns the full video codec flags for FFmpeg shell commands. */
|
|
public static function ffmpegVideoFlags(bool $stillImage = false): string
|
|
{
|
|
if (static::gpuUsable()) {
|
|
$enc = static::get('gpu_encoder', 'h264_nvenc');
|
|
$preset = static::get('gpu_preset', 'p4');
|
|
$device = static::gpuDevice();
|
|
$gpuFlag = str_contains($enc, 'nvenc') ? " -gpu {$device}" : '';
|
|
return "-c:v {$enc} -preset {$preset} -rc vbr -cq 23{$gpuFlag} -pix_fmt yuv420p";
|
|
}
|
|
$tune = $stillImage ? ' -tune stillimage' : '';
|
|
return "-c:v libx264 -preset fast -crf 23{$tune} -pix_fmt yuv420p";
|
|
}
|
|
|
|
/** CPU-only video codec flags — used as automatic fallback when GPU encoding fails. */
|
|
public static function ffmpegVideoFlagsCpu(bool $stillImage = false): string
|
|
{
|
|
$tune = $stillImage ? ' -tune stillimage' : '';
|
|
return "-c:v libx264 -preset fast -crf 23{$tune} -pix_fmt yuv420p";
|
|
}
|
|
|
|
/** Returns hwaccel decode flags when the input source is a video file. */
|
|
public static function ffmpegHwaccelFlags(bool $inputIsVideo): string
|
|
{
|
|
if (! $inputIsVideo || ! static::gpuUsable()) return '';
|
|
$hwaccel = static::get('gpu_hwaccel', 'cuda');
|
|
$device = static::gpuDevice();
|
|
return "-hwaccel {$hwaccel} -hwaccel_device {$device} ";
|
|
}
|
|
}
|