ghassan f98e5415a3 Add lyrics pipeline, playlist views, admin toggles, and player polish
Lyrics pipeline (Whisper + Demucs + description alignment):
- New GenerateLyricsJob runs WhisperX with VAD filtering and forced word
  alignment, writes per-track JSON to NAS.
- New DecorateLyricsJob calls the active LLM provider to bake one to
  several emojis into each line (heavy decoration prompt).
- LyricsDescriptionParser strips heading content, section markers, and
  emoji-decoration from a song's description while preserving every
  language verbatim.
- correct_whisper_with_description aligner: strong-match anchors only,
  vocal-region-aware gap-fill so missing verses land on actual singing.
- Owner UI for generate/regenerate/edit/delete in the player gear.

Admin pages:
- /admin/lyrics    toggles for VAD, vocal gap-fill, Demucs, master
- /admin/gpu       extracted GPU section, encoder picker, FFmpeg path
- /admin/backup    extracted users-and-settings export/import
- /admin/settings  now AI/LLM only with provider list and Test button
- /admin/nas-storage hosts NAS settings, repair, disable flow, browser
- Shared partials/settings-styles for a uniform look across pages.

Playlist view tracking:
- Migration adds playlists.view_count and playlist_views dedup table.
- Playlist::bumpViewIfNew increments per device with a one-hour window.
- Tracked from /playlists/{id}, /playlists/share/{token}, /ps/{token},
  and /videos/{id}?playlist={token}.  Dispatched after-response so it
  never blocks the page render.
- Loading a playlist on the video page now runs one query instead of
  the four the old getNextVideo/getPreviousVideo path triggered.
- View counts shown on every playlist card and the playlist hero.

Player polish:
- Floating mini-player is draggable, persists its position in
  localStorage, clamps to viewport on resize.
- Mini disabled entirely on mobile (less than 768px).
- New gear-menu Mini Player toggle (persists in localStorage) lets the
  user disable both scroll-activation and SPA-nav-activation.
- Close button keeps media playing when used on the player's own page.
- SPA navigator now swaps a #page-scripts container so per-page JS
  (channel tabs, etc.) gets re-executed after content swaps.

Storage layout:
- Runtime data moved from /storage/* to /data/* and gitignored.
- /ml/venv, /ml/cache, /ml/__pycache__ excluded.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-31 22:01:47 +03:00

110 lines
4.3 KiB
PHP

<?php
namespace App\Support;
/**
* Build an ASS (Advanced SubStation Alpha) subtitle file with word-level
* karaoke timing from a lyrics JSON payload (the shape produced by
* ml/transcribe.py). Burned into the downloadable mp4 via libass.
*
* Karaoke fill uses ASS \k tags: each word's \k duration (centiseconds) is the
* time it waits before the highlight reaches it, so the cumulative \k before a
* word equals its onset relative to the line start. PrimaryColour is the sung
* (filled) colour, SecondaryColour the not-yet-sung colour.
*/
class LyricsAss
{
/** Render at the same canvas the slideshow uses. */
private const W = 1280;
private const H = 720;
/**
* Write an .ass file for the given lyrics data. Returns true on success,
* false when there are no usable timed lines (caller should skip burning).
*/
public static function write(array $lyrics, string $outPath): bool
{
$lines = $lyrics['lines'] ?? [];
if (! is_array($lines) || ! $lines) return false;
$events = [];
foreach ($lines as $ln) {
$start = $ln['start'] ?? null;
$end = $ln['end'] ?? null;
if ($start === null || $end === null) continue;
$words = (isset($ln['words']) && is_array($ln['words']) && $ln['words']) ? $ln['words'] : null;
$text = $words
? self::karaokeText($words, (float) $start)
: self::escape((string) ($ln['text'] ?? ''));
if ($text === '') continue;
// Hold the line a touch past its last word for readability.
// Fields per the Format line: Layer,Start,End,Style,Name,MarginL,MarginR,Effect,Text
$events[] = 'Dialogue: 0,' . self::ts((float) $start) . ',' . self::ts((float) $end + 0.4)
. ',Lyrics,,0,0,,' . $text;
}
if (! $events) return false;
$ass = self::header() . implode("\n", $events) . "\n";
return file_put_contents($outPath, $ass) !== false;
}
private static function karaokeText(array $words, float $lineStart): string
{
$out = '';
$lead = (int) round((((float) $words[0]['start']) - $lineStart) * 100);
if ($lead > 0) $out .= '{\k' . $lead . '}';
$n = count($words);
foreach ($words as $i => $w) {
$wStart = (float) $w['start'];
$wEnd = (float) $w['end'];
$dur = max(1, (int) round(($wEnd - $wStart) * 100));
$out .= '{\k' . $dur . '}' . self::escape((string) $w['text']);
if ($i < $n - 1) {
$gap = (int) round((((float) $words[$i + 1]['start']) - $wEnd) * 100);
$out .= ($gap > 0 ? '{\k' . $gap . '}' : '') . ' ';
}
}
return $out;
}
/** ASS timestamp: H:MM:SS.cc (centiseconds). */
private static function ts(float $t): string
{
if ($t < 0) $t = 0;
$cs = (int) round($t * 100);
$h = intdiv($cs, 360000);
$m = intdiv($cs % 360000, 6000);
$s = intdiv($cs % 6000, 100);
$c = $cs % 100;
return sprintf('%d:%02d:%02d.%02d', $h, $m, $s, $c);
}
private static function escape(string $s): string
{
// Strip ASS override delimiters and collapse newlines.
$s = str_replace(['{', '}', '\\'], ['(', ')', '/'], $s);
return str_replace(["\r\n", "\n", "\r"], ' ', $s);
}
private static function header(): string
{
// Colours are &HAABBGGRR. Primary = sung (white), Secondary = unsung
// (translucent grey), heavy outline + shadow for legibility over artwork.
return "[Script Info]\n"
. "ScriptType: v4.00+\n"
. 'PlayResX: ' . self::W . "\n"
. 'PlayResY: ' . self::H . "\n"
. "WrapStyle: 0\n"
. "ScaledBorderAndShadow: yes\n\n"
. "[V4+ Styles]\n"
. "Format: Name, Fontname, Fontsize, PrimaryColour, SecondaryColour, OutlineColour, BackColour, Bold, Italic, Underline, StrikeOut, ScaleX, ScaleY, Spacing, Angle, BorderStyle, Outline, Shadow, Alignment, MarginL, MarginR, MarginV, Encoding\n"
. "Style: Lyrics,Sans,54,&H00FFFFFF,&H64C8C8C8,&H00101010,&H80000000,-1,0,0,0,100,100,0,0,1,3,2,2,80,80,70,1\n\n"
. "[Events]\n"
. "Format: Layer, Start, End, Style, Name, MarginL, MarginR, Effect, Text\n";
}
}