onQueue('video-processing'); } public function handle(LlmLyricsService $llm, NasSyncService $nas): void { // Two-layer toggle: the admin's per-pipeline switch (Lyrics Pipeline // page) gates this job, and the LLM-service-level switch (AI/LLM page) // gates the LLM call inside it. Either being OFF skips decoration. if (\App\Models\Setting::get('lyrics_llm_decorate', 'true') !== 'true') return; if (! $llm->decorateEnabled()) return; $video = Video::find($this->videoId); if (! $video) return; $track = $this->trackId ? VideoAudioTrack::find($this->trackId) : null; if ($this->trackId && ! $track) return; $data = $nas->getLyrics($video, $track); if (! is_array($data) || empty($data['lines'])) return; if (($data['status'] ?? null) !== 'ready') return; // Decorate only lines that haven't been decorated yet — a re-run fills // gaps cheaply instead of re-stamping the whole song. $texts = []; $indices = []; foreach ($data['lines'] as $i => $ln) { if (! empty($ln['decorated'])) continue; $t = (string) ($ln['text'] ?? ''); if (trim($t) === '') continue; $texts[] = $t; $indices[] = $i; } if (! $texts) return; try { $decorated = $llm->decorateLines($texts); } catch (\Throwable $e) { Log::warning('DecorateLyricsJob: LLM call failed: ' . $e->getMessage(), [ 'video_id' => $this->videoId, 'track_id' => $this->trackId, ]); return; } if (! $decorated) return; $applied = 0; foreach ($decorated as $localIdx => $newText) { if (! isset($indices[$localIdx])) continue; $globalIdx = $indices[$localIdx]; $line = &$data['lines'][$globalIdx]; $line['text'] = $newText; $line['decorated'] = true; // The words array no longer matches the new (emoji-laced) text. We // redistribute the existing [start,end] window evenly across the // new tokens so the karaoke word-highlight still tracks the audio. // Tokens that are pure emoji get the same per-slot timing as words. $lang = (string) ($line['lang'] ?? ($data['language'] ?? 'en')); $line['words'] = $this->redistributeWords( (float) ($line['start'] ?? 0), (float) ($line['end'] ?? 0), $newText, $lang ); unset($line); $applied++; } if (! $applied) return; $data['decorated_at'] = now()->toIso8601String(); $nas->putLyrics($video, $track, $data); Log::info('DecorateLyricsJob: done', [ 'video_id' => $this->videoId, 'track_id' => $this->trackId, 'decorated' => $applied, ]); } /** * Evenly distribute [start,end] across the line's tokens. Words for spaced * languages, characters for spaceless scripts (Thai/CJK/…). Used after * decoration so the karaoke word-highlight still tracks the audio. */ private function redistributeWords(float $start, float $end, string $text, string $lang): array { if ($text === '' || $end <= $start) return []; $spaceless = in_array($lang, self::SPACELESS_LANGS, true); $tokens = $spaceless ? preg_split('//u', $text, -1, PREG_SPLIT_NO_EMPTY) : preg_split('/\s+/u', trim($text), -1, PREG_SPLIT_NO_EMPTY); $n = count($tokens ?: []); if ($n === 0) return []; $slot = ($end - $start) / $n; $out = []; foreach ($tokens as $i => $t) { $out[] = [ 'start' => round($start + $i * $slot, 3), 'end' => round($start + ($i + 1) * $slot, 3), 'text' => $t, ]; } return $out; } }