diff --git a/BopSubtitleReader/Core/SubtitleRuntimeController.cs b/BopSubtitleReader/Core/SubtitleRuntimeController.cs index 3e25d1c..c1c8688 100644 --- a/BopSubtitleReader/Core/SubtitleRuntimeController.cs +++ b/BopSubtitleReader/Core/SubtitleRuntimeController.cs @@ -17,6 +17,12 @@ public sealed class SubtitleRuntimeController : MonoBehaviour AccessTools.FieldRefAccess("sceneKeys"); private static readonly AccessTools.FieldRef> ScriptsRef = AccessTools.FieldRefAccess>("scripts"); + private static readonly AccessTools.FieldRef RiqLoaderRef = + AccessTools.FieldRefAccess("riqLoader"); + private static readonly AccessTools.FieldRef RiqLoaderMixtapeRef = + AccessTools.FieldRefAccess("mixtape"); + private static readonly AccessTools.FieldRef MixtapePropertiesEventRef = + AccessTools.FieldRefAccess("mixtapePropertiesEvent"); // Sentinel stored in _displayText while the karaoke overlay is actively rendering. private const string KaraokeActiveSentinel = "__KARAOKE_ACTIVE__"; @@ -123,7 +129,7 @@ private void Update() if (!_timingResolved) { - ResolveCueTiming(_track, jukebox); + ResolveCueTiming(_track, _loader, jukebox); _timingResolved = true; } @@ -253,21 +259,25 @@ private static string BuildStyleKey(SubtitleCueStyle? style) $"{style.FontName}|{fontSize}|{style.ColorHexRgba}|{style.SecondaryColorHexRgba}|{style.OutlineColorHexRgba}|{style.OutlineWidth}|{style.Bold}|{style.Italic}|{style.Alignment}"; } - private static void ResolveCueTiming(SubtitleTrack track, JukeboxScript jukebox) + private static void ResolveCueTiming(SubtitleTrack track, MixtapeLoaderCustom loader, JukeboxScript jukebox) { + var playbackRate = ResolvePlaybackRate(loader, jukebox); + var secondsBeatScale = Mathf.Approximately(playbackRate, 0f) ? 1f : 1f / playbackRate; + Log.Info( + $"Resolving cue timing for mixtape using {jukebox.Bpm} BPM (playbackSpeed={playbackRate.ToString("F3", CultureInfo.InvariantCulture)})"); foreach (var cue in track.Cues) { if (cue.UsesSecondsTiming && cue is { StartSeconds: not null, EndSeconds: not null }) { - cue.StartBeat = jukebox.SecondsToBeats(cue.StartSeconds.Value); - cue.EndBeat = jukebox.SecondsToBeats(cue.EndSeconds.Value); + cue.StartBeat = ResolveSecondsToBeat(jukebox, cue.StartSeconds.Value, secondsBeatScale); + cue.EndBeat = ResolveSecondsToBeat(jukebox, cue.EndSeconds.Value, secondsBeatScale); } foreach (var segment in cue.KaraokeSegments) { if (!segment.Beat.HasValue && segment.Seconds.HasValue) { - segment.Beat = jukebox.SecondsToBeats(segment.Seconds.Value); + segment.Beat = ResolveSecondsToBeat(jukebox, segment.Seconds.Value, secondsBeatScale); } } } @@ -275,6 +285,79 @@ private static void ResolveCueTiming(SubtitleTrack track, JukeboxScript jukebox) track.Cues.Sort((a, b) => a.StartBeat.CompareTo(b.StartBeat)); } + private static float ResolveSecondsToBeat(JukeboxScript jukebox, double seconds, float secondsBeatScale) + { + return jukebox.SecondsToBeats(seconds) * secondsBeatScale; + } + + private static float ResolvePlaybackRate(MixtapeLoaderCustom? loader, JukeboxScript jukebox) + { + if (!TryGetBaseMixtapeBpm(loader, out var baseBpm, out var source)) + { + Log.Info("ResolvePlaybackRate: base mixtape BPM unavailable; defaulting to 1.000"); + return 1f; + } + + var playbackRate = jukebox.bpm / baseBpm; + Log.Info( + $"ResolvePlaybackRate: {jukebox.bpm.ToString("F3", CultureInfo.InvariantCulture)} / {baseBpm.ToString("F3", CultureInfo.InvariantCulture)} ({source})"); + return playbackRate > 0f ? playbackRate : 1f; + } + + private static bool TryGetBaseMixtapeBpm(MixtapeLoaderCustom? loader, out float baseBpm, out string source) + { + baseBpm = 0f; + source = "none"; + + if (loader is not null && loader) + { + var riqLoader = RiqLoaderRef(loader); + if (riqLoader is not null && riqLoader) + { + var loadedMixtape = RiqLoaderMixtapeRef(riqLoader); + if (loadedMixtape is not null && loadedMixtape.bpm > 0f) + { + baseBpm = loadedMixtape.bpm; + source = "riqLoader.mixtape"; + return true; + } + } + } + + if (!MixtapeEditorScript.IsInEditor) + { + return false; + } + + var editor = FindObjectOfType(); + if (editor is null || !editor) + { + return false; + } + + var mixtapePropertiesEvent = MixtapePropertiesEventRef(editor); + if (mixtapePropertiesEvent is null || !mixtapePropertiesEvent) + { + return false; + } + + var entity = mixtapePropertiesEvent.Entity; + if (entity is null) + { + return false; + } + + var editorBpm = entity.GetFloat("bpm"); + if (editorBpm <= 0f) + { + return false; + } + + baseBpm = editorBpm; + source = "MixtapeEditorScript.mixtapePropertiesEvent"; + return true; + } + private void RefreshSubtitleCamera(bool force = false) { if (_loader is null || !_loader)