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
@@ -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)");
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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
@@ -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} ";
|
||||
|
||||
@@ -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| |<br\s*/?>)*</\1>#i', '', $html);
|
||||
return trim((string) $html);
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user