#nullable enable using System.Collections.Generic; using System.Diagnostics.CodeAnalysis; using System.IO; using System.Linq; using Newtonsoft.Json; using Newtonsoft.Json.Linq; using T3.Core.Audio; using T3.Core.IO; using T3.Core.Logging; using T3.Serialization; using T3.Core.Resource; using T3.Core.Video; namespace T3.Core.Settings; /// /// Per-symbol project settings including playback configuration (soundtrack, BPM, audio input). /// Stored in .t3 files. Found via breadcrumb traversal up the graph hierarchy. /// public sealed class CompositionSettings { /// /// Provides default values when no project settings are active. /// public static CompositionSettings Defaults { get; } = new(); /// /// Returns the active project settings from the current playback, falling back to defaults. /// This is the primary access path for project-level configuration. /// public static CompositionSettings Current => Animation.Playback.Current?.Settings ?? Defaults; public bool Enabled { get; set; } public PlaybackConfig Playback { get; init; } = new(); public AudioMixConfig Audio { get; init; } = new(); public ExportConfig Export { get; init; } = new(); public ProxyConfig Proxy { get; init; } = new(); public bool TryGetMainSoundtrack(IResourceConsumer? instance, [NotNullWhen(true)] out AudioClipResourceHandle? soundtrack) { if (!Enabled) { soundtrack = null; return false; } foreach (var clip in Playback.AudioClips) { if (!clip.IsMainSoundtrack) continue; soundtrack = clip.GetResourceHandle(instance); return true; } soundtrack = null; return false; } #region Enums public enum AudioSources { ProjectSoundTrack, ExternalDevice, } public enum SyncModes { Timeline, Tapping, } #endregion #region Nested config classes public sealed class AudioMixConfig { public bool SoundtrackMute = false; public float SoundtrackVolume = 0.5f; public bool OperatorMute = false; public float OperatorVolume = 1; public float AudioResyncThreshold = 0.04f; } public sealed class ExportConfig { public WindowMode DefaultWindowMode = WindowMode.Fullscreen; public bool EnablePlaybackControlWithKeyboard = true; } /// Preview-proxy preferences for this project: how seek-proxies are transcoded and whether the engine /// substitutes them for source clips during preview. Per-project so the choice travels with the project (the /// proxy *files* are still local). Proxy codecs are all-intra/LGPL — never H.264/HEVC (libx264 = GPL). public sealed class ProxyConfig { public VideoExportCodec Format = VideoExportCodec.ProRes; // all-intra, no GPL public float Resolution = 0.5f; // fraction of the source resolution public bool UseForPreview = true; // open a clip's proxy when one exists (never while rendering) } public sealed class PlaybackConfig { public float Bpm = 120; public List AudioClips { get; internal init; } = []; public AudioSources AudioSource; public SyncModes Syncing; public string AudioInputDeviceName = string.Empty; public float AudioGainFactor = 1; public float AudioDecayFactor = 0.9f; public bool EnableAudioBeatLocking = true; public float BeatLockAudioOffsetSec; } #endregion #region Serialization internal void WriteToJson(JsonTextWriter writer) { var hasSettings = Enabled || Playback.AudioClips.Count > 0 || ProxyIsNonDefault(); if (!hasSettings) return; writer.WritePropertyName("ProjectSettings"); writer.WriteStartObject(); { writer.WriteValue(nameof(Enabled), Enabled); // Playback section writer.WritePropertyName("Playback"); writer.WriteStartObject(); { writer.WriteValue(nameof(PlaybackConfig.Bpm), Playback.Bpm); writer.WriteValue(nameof(PlaybackConfig.AudioSource), Playback.AudioSource); writer.WriteValue(nameof(PlaybackConfig.Syncing), Playback.Syncing); writer.WriteValue(nameof(PlaybackConfig.AudioDecayFactor), Playback.AudioDecayFactor); writer.WriteValue(nameof(PlaybackConfig.AudioGainFactor), Playback.AudioGainFactor); writer.WriteObject(nameof(PlaybackConfig.AudioInputDeviceName), Playback.AudioInputDeviceName); writer.WriteObject(nameof(PlaybackConfig.EnableAudioBeatLocking), Playback.EnableAudioBeatLocking); writer.WriteObject(nameof(PlaybackConfig.BeatLockAudioOffsetSec), Playback.BeatLockAudioOffsetSec); if (Playback.AudioClips.Count != 0) { writer.WritePropertyName("AudioClips"); writer.WriteStartArray(); foreach (var audioClip in Playback.AudioClips) { audioClip.ToJson(writer); } writer.WriteEndArray(); } } writer.WriteEndObject(); // Audio mix section writer.WritePropertyName("Audio"); writer.WriteStartObject(); { writer.WriteValue(nameof(AudioMixConfig.SoundtrackMute), Audio.SoundtrackMute); writer.WriteValue(nameof(AudioMixConfig.SoundtrackVolume), Audio.SoundtrackVolume); writer.WriteValue(nameof(AudioMixConfig.OperatorMute), Audio.OperatorMute); writer.WriteValue(nameof(AudioMixConfig.OperatorVolume), Audio.OperatorVolume); writer.WriteValue(nameof(AudioMixConfig.AudioResyncThreshold), Audio.AudioResyncThreshold); } writer.WriteEndObject(); // Export section writer.WritePropertyName("Export"); writer.WriteStartObject(); { writer.WriteValue(nameof(ExportConfig.DefaultWindowMode), Export.DefaultWindowMode); writer.WriteValue(nameof(ExportConfig.EnablePlaybackControlWithKeyboard), Export.EnablePlaybackControlWithKeyboard); } writer.WriteEndObject(); // Proxy section writer.WritePropertyName("Proxy"); writer.WriteStartObject(); { writer.WriteValue(nameof(ProxyConfig.Format), Proxy.Format); writer.WriteValue(nameof(ProxyConfig.Resolution), Proxy.Resolution); writer.WriteValue(nameof(ProxyConfig.UseForPreview), Proxy.UseForPreview); } writer.WriteEndObject(); } writer.WriteEndObject(); } private bool ProxyIsNonDefault() => Proxy.Format != Defaults.Proxy.Format || Proxy.Resolution != Defaults.Proxy.Resolution || Proxy.UseForPreview != Defaults.Proxy.UseForPreview; internal static CompositionSettings? ReadFromJson(JToken symbolToken) { // Try new format first, then fall back to legacy "PlaybackSettings" key var jSettingsToken = symbolToken["ProjectSettings"] ?? symbolToken["PlaybackSettings"]; if (jSettingsToken == null) return null; var settingsToken = (JObject)jSettingsToken; // Check if this is the new nested format (has "Playback" sub-object) var playbackToken = settingsToken["Playback"] as JObject; if (playbackToken != null) { return ReadNewFormat(settingsToken, playbackToken); } // Legacy flat format — all fields at root level return ReadLegacyFormat(symbolToken, settingsToken); } private static CompositionSettings ReadNewFormat(JObject settingsToken, JObject playbackToken) { var clips = GetClips(playbackToken).ToList(); var audioToken = settingsToken["Audio"] as JObject; var exportToken = settingsToken["Export"] as JObject; var proxyToken = settingsToken["Proxy"] as JObject; var debugToken = settingsToken["Debug"] as JObject; // legacy compat var settings = new CompositionSettings { Enabled = JsonUtils.ReadValueSafe(settingsToken, nameof(Enabled), false), Playback = new PlaybackConfig { AudioClips = clips, Bpm = JsonUtils.ReadValueSafe(playbackToken, nameof(PlaybackConfig.Bpm), 120f), AudioSource = JsonUtils.ReadEnum(playbackToken, nameof(PlaybackConfig.AudioSource)), Syncing = JsonUtils.ReadEnum(playbackToken, nameof(PlaybackConfig.Syncing)), AudioDecayFactor = JsonUtils.ReadValueSafe(playbackToken, nameof(PlaybackConfig.AudioDecayFactor), 0.5f), AudioGainFactor = JsonUtils.ReadValueSafe(playbackToken, nameof(PlaybackConfig.AudioGainFactor), 1f), AudioInputDeviceName = JsonUtils.ReadValueSafe(playbackToken, nameof(PlaybackConfig.AudioInputDeviceName)) ?? string.Empty, EnableAudioBeatLocking = JsonUtils.ReadValueSafe(playbackToken, nameof(PlaybackConfig.EnableAudioBeatLocking), false), BeatLockAudioOffsetSec = JsonUtils.ReadValueSafe(playbackToken, nameof(PlaybackConfig.BeatLockAudioOffsetSec), 0f), }, Audio = audioToken != null ? new AudioMixConfig { SoundtrackMute = JsonUtils.ReadValueSafe(audioToken, nameof(AudioMixConfig.SoundtrackMute), Defaults.Audio.SoundtrackMute), SoundtrackVolume = JsonUtils.ReadValueSafe(audioToken, nameof(AudioMixConfig.SoundtrackVolume), Defaults.Audio.SoundtrackVolume), OperatorMute = JsonUtils.ReadValueSafe(audioToken, nameof(AudioMixConfig.OperatorMute), Defaults.Audio.OperatorMute), OperatorVolume = JsonUtils.ReadValueSafe(audioToken, nameof(AudioMixConfig.OperatorVolume), Defaults.Audio.OperatorVolume), AudioResyncThreshold = JsonUtils.ReadValueSafe(audioToken, nameof(AudioMixConfig.AudioResyncThreshold), Defaults.Audio.AudioResyncThreshold), } : new AudioMixConfig(), Export = exportToken != null ? new ExportConfig { DefaultWindowMode = JsonUtils.ReadEnum(exportToken, nameof(ExportConfig.DefaultWindowMode)), EnablePlaybackControlWithKeyboard = JsonUtils.ReadValueSafe(exportToken, nameof(ExportConfig.EnablePlaybackControlWithKeyboard), Defaults.Export.EnablePlaybackControlWithKeyboard), } : new ExportConfig(), Proxy = proxyToken != null ? new ProxyConfig { // ReadEnum returns the enum's zero value when the key is missing, which isn't the // proxy default (ProRes) — so fall back explicitly when "Format" is absent. Format = proxyToken[nameof(ProxyConfig.Format)] != null ? JsonUtils.ReadEnum(proxyToken, nameof(ProxyConfig.Format)) : Defaults.Proxy.Format, Resolution = JsonUtils.ReadValueSafe(proxyToken, nameof(ProxyConfig.Resolution), Defaults.Proxy.Resolution), UseForPreview = JsonUtils.ReadValueSafe(proxyToken, nameof(ProxyConfig.UseForPreview), Defaults.Proxy.UseForPreview), } : new ProxyConfig(), }; return settings; } private static CompositionSettings ReadLegacyFormat(JToken symbolToken, JObject settingsToken) { var clips = GetClips(symbolToken).ToList(); // Support legacy json format with clips at symbol root var settings = new CompositionSettings { Enabled = JsonUtils.ReadValueSafe(settingsToken, nameof(Enabled), false), Playback = new PlaybackConfig { AudioClips = clips, Bpm = JsonUtils.ReadValueSafe(settingsToken, "Bpm", 120f), AudioSource = JsonUtils.ReadEnum(settingsToken, "AudioSource"), Syncing = JsonUtils.ReadEnum(settingsToken, "Syncing"), AudioDecayFactor = JsonUtils.ReadValueSafe(settingsToken, "AudioDecayFactor", 0.5f), AudioGainFactor = JsonUtils.ReadValueSafe(settingsToken, "AudioGainFactor", 1f), AudioInputDeviceName = JsonUtils.ReadValueSafe(settingsToken, "AudioInputDeviceName") ?? string.Empty, EnableAudioBeatLocking = JsonUtils.ReadValueSafe(settingsToken, "EnableAudioBeatLocking", false), BeatLockAudioOffsetSec = JsonUtils.ReadValueSafe(settingsToken, "BeatLockAudioOffsetSec", 0f), } }; settings.Playback.AudioClips.AddRange(GetClips(settingsToken)); // Support clips inside settings token // BPM migration: pre-rewrite projects stored BPM on the clip itself. The new model // keeps BPM only on Playback. If the legacy field was present on the loaded clip, // copy it once into Playback.Bpm. if (settings.Playback.Bpm == 0) { var soundtrack = settings.Playback.AudioClips.FirstOrDefault(c => c.IsMainSoundtrack); if (soundtrack != null && soundtrack.LegacyBpmForMigration > 0) { settings.Playback.Bpm = soundtrack.LegacyBpmForMigration; settings.Enabled = true; } } return settings; } private static IEnumerable GetClips(JToken token) { var jClipsToken = token["AudioClips"]; if (jClipsToken == null) yield break; var jAudioClipArray = (JArray)jClipsToken; foreach (var c in jAudioClipArray) { if (!TimelineAudioClip.TryFromJson(c, out var clip)) continue; if (IsUnmanageableMissingClip(clip)) { // Non-main clips have no UI to remove them; drop dead references on load so // they stop being re-registered (and re-logged) every frame. Persisted out on next save. Log.Warning($"Removing audio clip with missing file '{clip.AssetPath}' from composition settings."); continue; } yield return clip; } } /// /// True for a non-main audio clip whose absolute file path no longer exists. Restricted to /// absolute paths so package-relative clips (resolved later against a resource consumer) and /// the user-manageable main soundtrack are never dropped. /// private static bool IsUnmanageableMissingClip(TimelineAudioClip clip) { if (clip.IsMainSoundtrack || string.IsNullOrEmpty(clip.AssetPath)) return false; return Path.IsPathRooted(clip.AssetPath) && !File.Exists(clip.AssetPath); } #endregion }