ghassan a4384113c2 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) <noreply@anthropic.com>
2026-05-23 14:03:43 +03:00

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} ";
}
}