910 lines
37 KiB
C#
910 lines
37 KiB
C#
#nullable enable
|
|
using System.IO;
|
|
using System.Text;
|
|
using System.Text.RegularExpressions;
|
|
using ImGuiNET;
|
|
using ManagedBass;
|
|
using ManagedBass.Wasapi;
|
|
using T3.Core.Animation;
|
|
using T3.Core.Audio;
|
|
using T3.Core.IO;
|
|
using T3.Core.Logging;
|
|
using T3.Core.Model;
|
|
using T3.Core.Operator;
|
|
using T3.Core.Resource;
|
|
using T3.Core.Settings;
|
|
using T3.Core.Video;
|
|
using T3.Editor.Gui.Audio;
|
|
using T3.Editor.Gui.Input;
|
|
using T3.Editor.Gui.Interaction.Timing;
|
|
using T3.Editor.Gui.Styling;
|
|
using T3.Editor.Gui.UiHelpers;
|
|
using T3.Editor.Gui.Windows.RenderExport;
|
|
using T3.Editor.UiModel;
|
|
using T3.Editor.UiModel.InputsAndTypes;
|
|
using T3.Editor.UiModel.ProjectHandling;
|
|
|
|
namespace T3.Editor.Gui.Windows.TimeLine;
|
|
|
|
/// <summary>
|
|
/// Per-project settings window with categories for Playback, Audio, Rendering, IO, and Performance.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// Controlling the primary soundtrack is finicky:
|
|
/// - "Add soundtrack" adds a <see cref="AudioClipResourceHandle"/> with empty filepath.
|
|
/// - When modifying the path we use to resolve the path (i.e. verify if file exists) before setting the filepath.
|
|
/// - If valid and set, <see cref="AudioEngine"/> will then load them in CompleteFrame.
|
|
/// </remarks>
|
|
[HelpUiID("ProjectSettings")]
|
|
internal sealed class ProjectSettingsWindow : Window
|
|
{
|
|
internal ProjectSettingsWindow()
|
|
{
|
|
Config.Title = "Composition Settings";
|
|
}
|
|
|
|
internal override List<Window> GetInstances() => [];
|
|
|
|
protected override void DrawContent()
|
|
{
|
|
var composition = ProjectView.Focused?.CompositionInstance;
|
|
|
|
if (composition == null)
|
|
{
|
|
CustomComponents.EmptyWindowMessage("no composition active");
|
|
return;
|
|
}
|
|
|
|
NavigationSidebar.BeginLayout();
|
|
|
|
PlaybackUtils.FindCompositionSettingsForInstance(composition, out var compositionWithSettings,
|
|
out var settings);
|
|
|
|
var isEnabledForCurrent = compositionWithSettings == composition && settings is {Enabled: true};
|
|
|
|
// Main toggle with composition name
|
|
{
|
|
FormInputs.SetIndent(0);
|
|
FormInputs.AddVerticalSpace();
|
|
|
|
if (FormInputs.AddCheckBox("Specify settings for ", ref isEnabledForCurrent))
|
|
{
|
|
if (isEnabledForCurrent)
|
|
{
|
|
settings = composition.Symbol.CompositionSettings;
|
|
if (settings == null)
|
|
{
|
|
settings = new CompositionSettings();
|
|
composition.Symbol.CompositionSettings = settings;
|
|
}
|
|
|
|
compositionWithSettings = composition;
|
|
settings.Enabled = true;
|
|
Playback.Current.Settings = settings;
|
|
}
|
|
else
|
|
{
|
|
// ReSharper disable once PossibleNullReferenceException
|
|
settings.Enabled = false;
|
|
}
|
|
|
|
composition.Symbol.GetSymbolUi().FlagAsModified();
|
|
}
|
|
|
|
ImGui.SameLine();
|
|
ImGui.PushFont(Fonts.FontBold);
|
|
ImGui.TextUnformatted(composition.Symbol.Name);
|
|
ImGui.PopFont();
|
|
|
|
// Explanation hint
|
|
var hint = "";
|
|
if (isEnabledForCurrent)
|
|
{
|
|
hint = "These symbol settings are also used for its child operators.";
|
|
}
|
|
else if (compositionWithSettings != null && compositionWithSettings != composition)
|
|
{
|
|
hint = $"Currently inheriting settings from {compositionWithSettings.Symbol.Name}";
|
|
}
|
|
|
|
FormInputs.AddVerticalSpace(4);
|
|
ImGui.SetCursorPosX( ImGui.GetCursorPosX() + 20 * T3Ui.UiScaleFactor);
|
|
CustomComponents
|
|
.HelpText(hint);
|
|
|
|
FormInputs.AddVerticalSpace();
|
|
}
|
|
|
|
ImGui.Separator();
|
|
|
|
if (isEnabledForCurrent)
|
|
{
|
|
DrawSettingsPanels(composition, settings, compositionWithSettings);
|
|
}
|
|
else
|
|
{
|
|
CustomComponents.EmptyWindowMessage("No settings");
|
|
FormInputs.SetIndentToParameters();
|
|
}
|
|
}
|
|
|
|
private enum Categories
|
|
{
|
|
Playback,
|
|
Audio,
|
|
Proxies,
|
|
Recording,
|
|
Executable,
|
|
}
|
|
|
|
private static Categories _activeCategory;
|
|
|
|
private void DrawSettingsPanels(Instance composition, CompositionSettings settings,
|
|
Instance? compositionWithSettings)
|
|
{
|
|
NavigationSidebar.BeginColumn("categories", 120);
|
|
{
|
|
foreach (var category in Enum.GetValues<Categories>())
|
|
{
|
|
var name = CustomComponents.HumanReadablePascalCase(Enum.GetName(category));
|
|
if (NavigationSidebar.Item(name, _activeCategory == category))
|
|
_activeCategory = category;
|
|
}
|
|
}
|
|
NavigationSidebar.EndColumn();
|
|
|
|
NavigationSidebar.BeginContentPanel(PanelTitle(_activeCategory), this);
|
|
{
|
|
FormInputs.SetIndentToParameters();
|
|
var modified = false;
|
|
switch (_activeCategory)
|
|
{
|
|
case Categories.Playback:
|
|
modified |= DrawPlaybackSettings(composition, settings, compositionWithSettings);
|
|
break;
|
|
case Categories.Audio:
|
|
modified |= DrawAudioSettings(settings);
|
|
break;
|
|
case Categories.Proxies:
|
|
modified |= DrawProxySettings(composition, settings);
|
|
break;
|
|
case Categories.Recording:
|
|
modified |= DrawRecordingSettings(composition);
|
|
break;
|
|
case Categories.Executable:
|
|
modified |= DrawRenderingSettings(settings);
|
|
break;
|
|
}
|
|
|
|
if (modified)
|
|
composition.Symbol.GetSymbolUi().FlagAsModified();
|
|
}
|
|
NavigationSidebar.EndContentPanel();
|
|
}
|
|
|
|
private static string PanelTitle(Categories category) => category switch
|
|
{
|
|
Categories.Playback => "Playback",
|
|
Categories.Audio => "Audio Mix",
|
|
Categories.Proxies => "Video Proxies",
|
|
Categories.Recording => "Recording",
|
|
Categories.Executable => "Export",
|
|
_ => string.Empty,
|
|
};
|
|
|
|
#region Category panels
|
|
|
|
private static bool DrawAudioSettings(CompositionSettings settings)
|
|
{
|
|
var modified = false;
|
|
var audio = settings.Audio;
|
|
var defaults = CompositionSettings.Defaults.Audio;
|
|
|
|
modified |= FormInputs.AddFloat("Soundtrack Volume",
|
|
ref audio.SoundtrackVolume, 0f, 10f, 0.01f, true, true,
|
|
"Volume level for the project soundtrack.",
|
|
defaults.SoundtrackVolume);
|
|
|
|
modified |= FormInputs.AddCheckBox("Mute Soundtrack",
|
|
ref audio.SoundtrackMute,
|
|
"Mute the soundtrack audio.",
|
|
defaults.SoundtrackMute);
|
|
|
|
FormInputs.AddVerticalSpace();
|
|
|
|
modified |= FormInputs.AddFloat("Operator Volume",
|
|
ref audio.OperatorVolume, 0f, 1f, 0.01f, true, true,
|
|
"Volume level for operator-generated audio.",
|
|
defaults.OperatorVolume);
|
|
|
|
modified |= FormInputs.AddCheckBox("Mute Operators",
|
|
ref audio.OperatorMute,
|
|
"Mute all operator audio output.",
|
|
defaults.OperatorMute);
|
|
|
|
FormInputs.AddVerticalSpace();
|
|
|
|
modified |= FormInputs.AddFloat("Resync Threshold",
|
|
ref audio.AudioResyncThreshold, 0.001f, 0.1f, 0.001f, true, true,
|
|
"If audio playback drifts too far from the animation it will be resynced. A normal range is between 0.02s and 0.05s.",
|
|
defaults.AudioResyncThreshold);
|
|
|
|
return modified;
|
|
}
|
|
|
|
private static bool DrawProxySettings(Instance composition, CompositionSettings settings)
|
|
{
|
|
var modified = false;
|
|
var proxy = settings.Proxy;
|
|
var defaults = CompositionSettings.Defaults.Proxy;
|
|
|
|
CustomComponents.HelpText("Proxies are downscaled, fast-seeking copies of video clips used for preview. "
|
|
+ "Rendering to file always uses the full-resolution source. Generate them from a "
|
|
+ "video operator's context menu.");
|
|
FormInputs.AddVerticalSpace();
|
|
|
|
modified |= FormInputs.AddCheckBox("Use proxies for preview",
|
|
ref proxy.UseForPreview,
|
|
"When a clip has a generated proxy beside it, play the proxy while previewing/scrubbing for faster seeking.",
|
|
defaults.UseForPreview);
|
|
|
|
FormInputs.AddVerticalSpace();
|
|
|
|
// Restricted to all-intra / LGPL codecs — a proxy never uses H.264/HEVC (libx264/libx265 = GPL).
|
|
modified |= FormInputs.AddDropdown(ref proxy.Format, ProxyFormats, "Proxy Format", ProxyFormatName,
|
|
"All-intra codec used when generating proxies. ProRes is a balanced default; Hap variants scrub fastest at a larger file size.");
|
|
|
|
modified |= FormInputs.AddFloat("Proxy Resolution",
|
|
ref proxy.Resolution, 0.1f, 1f, 0.05f, true, true,
|
|
"Proxy size as a fraction of the source resolution (0.5 = half-size). Smaller seeks faster and uses less disk.",
|
|
defaults.Resolution);
|
|
|
|
// Per-machine guard (not project data) — saved to UserSettings directly so it doesn't dirty the symbol.
|
|
if (FormInputs.AddFloat("Min. free disk (GB)",
|
|
ref UserSettings.Config.ProxyMinFreeDiskGb, 0f, 500f, 1f, true, true,
|
|
"Generation is blocked when the source's drive has less than this much free space. Per-machine, not part of the project.",
|
|
UserSettings.Defaults.ProxyMinFreeDiskGb))
|
|
{
|
|
UserSettings.Save();
|
|
}
|
|
|
|
DrawActiveProxyJobs();
|
|
DrawProxyStorage(composition);
|
|
|
|
return modified;
|
|
}
|
|
|
|
// Generated proxies pile up next to their sources; surface how much disk they use and offer a one-click cleanup.
|
|
// The directory scan is cached and only re-runs when the file watcher reports a change (a proxy was written or
|
|
// deleted) or the active project changed — so the panel stays allocation-free per frame.
|
|
private static void DrawProxyStorage(Instance composition)
|
|
{
|
|
var projectFolder = composition.Symbol.SymbolPackage.AssetsFolder;
|
|
EnsureProxyScan(projectFolder);
|
|
|
|
FormInputs.AddVerticalSpace();
|
|
FormInputs.AddSectionSubHeader("Storage");
|
|
|
|
DrawStorageRow(_thisProjectProxyLabel, _thisProjectProxyTooltip, _thisProjectProxyFiles, "##deleteThisProjectProxies");
|
|
FormInputs.AddVerticalSpace(5);
|
|
DrawStorageRow(_allProjectsProxyLabel, _allProjectsProxyTooltip, _allProjectsProxyFiles, "##deleteAllProxies");
|
|
}
|
|
|
|
private static void DrawStorageRow(string label, string fileBreakdown, List<string> files, string popupId)
|
|
{
|
|
ImGui.PushFont(Fonts.FontSmall);
|
|
ImGui.PushStyleColor(ImGuiCol.Text, UiColors.TextMuted.Rgba);
|
|
ImGui.TextUnformatted(label);
|
|
ImGui.PopStyleColor();
|
|
ImGui.PopFont();
|
|
|
|
if (files.Count == 0)
|
|
return;
|
|
|
|
FormInputs.AppendTooltip(fileBreakdown); // (i) marker on the same line, hover lists the files
|
|
FormInputs.AddVerticalSpace(2);
|
|
|
|
if (ImGui.SmallButton($"Delete{popupId}"))
|
|
ImGui.OpenPopup(popupId);
|
|
|
|
if (ImGui.BeginPopup(popupId))
|
|
{
|
|
ImGui.TextUnformatted($"Delete {files.Count} proxy file(s)? Sources are untouched and proxies can be regenerated.");
|
|
ImGui.PushStyleColor(ImGuiCol.Text, UiColors.StatusAttention.Rgba);
|
|
if (ImGui.Button($"Delete{popupId}confirm"))
|
|
{
|
|
DeleteProxyFiles(files);
|
|
ImGui.CloseCurrentPopup();
|
|
}
|
|
|
|
ImGui.PopStyleColor();
|
|
ImGui.SameLine();
|
|
if (ImGui.Button($"Cancel{popupId}"))
|
|
ImGui.CloseCurrentPopup();
|
|
|
|
ImGui.EndPopup();
|
|
}
|
|
}
|
|
|
|
private static void DeleteProxyFiles(List<string> files)
|
|
{
|
|
int deleted = 0, failed = 0;
|
|
foreach (var file in files)
|
|
{
|
|
try
|
|
{
|
|
File.Delete(file);
|
|
deleted++;
|
|
}
|
|
catch (Exception e)
|
|
{
|
|
failed++;
|
|
Log.Warning($"Couldn't delete proxy '{file}': {e.Message}");
|
|
}
|
|
}
|
|
|
|
var inUseHint = failed > 0 ? $" {failed} could not be removed (in use? stop playback and retry)." : string.Empty;
|
|
Log.Info($"Deleted {deleted} proxy file(s).{inUseHint}");
|
|
_proxyScanWatcherState = -1; // force a rescan on the next draw
|
|
}
|
|
|
|
// Walks every writable project's asset folder for proxy files, caching totals + paths until the next change.
|
|
private static void EnsureProxyScan(string currentProjectFolder)
|
|
{
|
|
var watcherState = ResourceFileWatcher.FileStateChangeCounter;
|
|
if (watcherState == _proxyScanWatcherState && currentProjectFolder == _scannedProjectFolder)
|
|
return;
|
|
|
|
_proxyScanWatcherState = watcherState;
|
|
_scannedProjectFolder = currentProjectFolder;
|
|
_thisProjectProxyFiles.Clear();
|
|
_allProjectsProxyFiles.Clear();
|
|
long thisBytes = 0, allBytes = 0;
|
|
var thisBreakdown = new StringBuilder();
|
|
var allBreakdown = new StringBuilder();
|
|
|
|
foreach (var package in SymbolPackage.AllPackages)
|
|
{
|
|
if (package.IsReadOnly) // shipped/read-only packages never hold user-generated proxies
|
|
continue;
|
|
|
|
var folder = package.AssetsFolder;
|
|
if (string.IsNullOrEmpty(folder) || !Directory.Exists(folder))
|
|
continue;
|
|
|
|
var isCurrent = string.Equals(folder, currentProjectFolder, StringComparison.OrdinalIgnoreCase);
|
|
try
|
|
{
|
|
foreach (var file in Directory.EnumerateFiles(folder, "*.mov", SearchOption.AllDirectories))
|
|
{
|
|
if (!file.EndsWith(VideoPlayback.ProxySuffix, StringComparison.OrdinalIgnoreCase))
|
|
continue;
|
|
|
|
long size;
|
|
try
|
|
{
|
|
size = new FileInfo(file).Length;
|
|
}
|
|
catch
|
|
{
|
|
continue;
|
|
}
|
|
|
|
var fileName = Path.GetFileName(file);
|
|
_allProjectsProxyFiles.Add(file);
|
|
allBytes += size;
|
|
AppendBreakdownLine(allBreakdown, $"{package.Name}: {fileName} — {FormatBytes(size)}", _allProjectsProxyFiles.Count);
|
|
if (isCurrent)
|
|
{
|
|
_thisProjectProxyFiles.Add(file);
|
|
thisBytes += size;
|
|
AppendBreakdownLine(thisBreakdown, $"{fileName} — {FormatBytes(size)}", _thisProjectProxyFiles.Count);
|
|
}
|
|
}
|
|
}
|
|
catch (Exception e)
|
|
{
|
|
Log.Warning($"Couldn't scan proxies in '{folder}': {e.Message}");
|
|
}
|
|
}
|
|
|
|
_thisProjectProxyLabel = $"This project: {FormatBytes(thisBytes)} in {_thisProjectProxyFiles.Count} proxy file(s).";
|
|
_allProjectsProxyLabel = $"All projects: {FormatBytes(allBytes)} in {_allProjectsProxyFiles.Count} proxy file(s).";
|
|
_thisProjectProxyTooltip = thisBreakdown.ToString();
|
|
_allProjectsProxyTooltip = allBreakdown.ToString();
|
|
}
|
|
|
|
// Keeps the hover breakdown bounded so a project with hundreds of proxies doesn't produce a screen-tall tooltip.
|
|
private static void AppendBreakdownLine(StringBuilder breakdown, string line, int indexSoFar)
|
|
{
|
|
const int maxLines = 40;
|
|
if (indexSoFar < maxLines)
|
|
{
|
|
if (breakdown.Length > 0)
|
|
breakdown.Append('\n');
|
|
|
|
breakdown.Append(line);
|
|
}
|
|
else if (indexSoFar == maxLines)
|
|
{
|
|
breakdown.Append("\n…");
|
|
}
|
|
}
|
|
|
|
private static string FormatBytes(long bytes)
|
|
{
|
|
if (bytes < 1024)
|
|
return $"{bytes} B";
|
|
|
|
// _byteUnits[0] is KB, so the index trails the division count by one: after the first ÷1024 the value is
|
|
// in KB (unit 0), after the second it's MB (unit 1), etc.
|
|
double value = bytes;
|
|
var unit = -1;
|
|
while (value >= 1024 && unit < _byteUnits.Length - 1)
|
|
{
|
|
value /= 1024;
|
|
unit++;
|
|
}
|
|
|
|
return $"{value:0.#} {_byteUnits[unit]}";
|
|
}
|
|
|
|
// Live feedback for the background generation queue: anything currently queued / encoding, plus failures
|
|
// (e.g. low disk). Done jobs are omitted — they're silent successes.
|
|
private static void DrawActiveProxyJobs()
|
|
{
|
|
var shownHeader = false;
|
|
foreach (var (path, status) in ProxyGenerationService.Jobs)
|
|
{
|
|
var line = status.State switch
|
|
{
|
|
ProxyGenerationService.JobState.Queued => $"Queued — {Path.GetFileName(path)}",
|
|
ProxyGenerationService.JobState.Generating => $"Generating {Path.GetFileName(path)} — {status.Progress * 100:0}%",
|
|
ProxyGenerationService.JobState.Failed => $"Failed {Path.GetFileName(path)} — {status.Error}",
|
|
_ => null,
|
|
};
|
|
if (line == null)
|
|
continue;
|
|
|
|
if (!shownHeader)
|
|
{
|
|
FormInputs.AddVerticalSpace();
|
|
FormInputs.AddSectionSubHeader("Generation");
|
|
shownHeader = true;
|
|
}
|
|
|
|
var color = status.State == ProxyGenerationService.JobState.Failed ? UiColors.StatusError : UiColors.TextMuted;
|
|
ImGui.PushStyleColor(ImGuiCol.Text, color.Rgba);
|
|
ImGui.TextWrapped(line);
|
|
ImGui.PopStyleColor();
|
|
}
|
|
}
|
|
|
|
private static readonly VideoExportCodec[] ProxyFormats =
|
|
[VideoExportCodec.ProRes, VideoExportCodec.Hap, VideoExportCodec.HapAlpha, VideoExportCodec.HapQ];
|
|
|
|
private static string ProxyFormatName(VideoExportCodec codec) => codec switch
|
|
{
|
|
VideoExportCodec.ProRes => "ProRes",
|
|
VideoExportCodec.Hap => "Hap",
|
|
VideoExportCodec.HapAlpha => "Hap Alpha",
|
|
VideoExportCodec.HapQ => "Hap Q",
|
|
_ => codec.ToString(),
|
|
};
|
|
|
|
/// <summary>
|
|
/// Per-composition toggles for the live recording feature: what the Record button on
|
|
/// the timeline toolbar should actually capture. Lives on
|
|
/// <see cref="T3.Editor.UiModel.SymbolUi.RecordingSettings"/> (Editor-only — Core has
|
|
/// no consumer for these flags). Defaults to capturing everything so compositions
|
|
/// that never visited this panel keep working unchanged. The settings instance is
|
|
/// lazily created on first edit so .t3ui files for never-touched compositions stay
|
|
/// clean.
|
|
/// </summary>
|
|
private static bool DrawRecordingSettings(Instance composition)
|
|
{
|
|
var modified = false;
|
|
var symbolUi = composition.GetSymbolUi();
|
|
var recording = symbolUi.RecordingSettings ??= new RecordingSettings();
|
|
var defaults = RecordingSettings.Defaults;
|
|
CustomComponents.HelpText("Which sources the timeline Record button captures.");
|
|
FormInputs.AddVerticalSpace();
|
|
|
|
modified |= FormInputs.AddCheckBox("Capture Audio",
|
|
ref recording.CaptureAudio,
|
|
"Record the active WASAPI input device while the session is running. The device is configured under Playback → Audio Source.",
|
|
defaults.CaptureAudio);
|
|
|
|
FormInputs.AddVerticalSpace();
|
|
|
|
modified |= FormInputs.AddCheckBox("Capture IO",
|
|
ref recording.CaptureIo,
|
|
"Record MIDI and / or OSC events into a .data file alongside the audio clip.",
|
|
defaults.CaptureIo);
|
|
|
|
// Indent the IO sub-flags so the visual hierarchy matches "MIDI / OSC are children
|
|
// of IO". Skip the sub-rows entirely when the parent is off — they're inert and
|
|
// would just clutter the panel.
|
|
if (recording.CaptureIo)
|
|
{
|
|
FormInputs.AddVerticalSpace();
|
|
FormInputs.Indent(20 * T3Ui.UiScaleFactor);
|
|
modified |= FormInputs.AddCheckBox("MIDI",
|
|
ref recording.CaptureMidi,
|
|
"Subscribe to MIDI events for this recording.",
|
|
defaults.CaptureMidi);
|
|
modified |= FormInputs.AddCheckBox("OSC",
|
|
ref recording.CaptureOsc,
|
|
"Subscribe to OSC events on the configured DefaultOscPort for this recording.",
|
|
defaults.CaptureOsc);
|
|
FormInputs.Unindent(20 * T3Ui.UiScaleFactor);
|
|
|
|
if (!recording.CaptureMidi && !recording.CaptureOsc)
|
|
{
|
|
CustomComponents.HelpText("Both MIDI and OSC are off — Capture IO has nothing to record.");
|
|
}
|
|
}
|
|
|
|
return modified;
|
|
}
|
|
|
|
private static bool DrawRenderingSettings(CompositionSettings settings)
|
|
{
|
|
var modified = false;
|
|
var export = settings.Export;
|
|
var defaults = CompositionSettings.Defaults.Export;
|
|
|
|
CustomComponents.HelpText("These settings apply when exporting as executable.");
|
|
FormInputs.AddVerticalSpace();
|
|
|
|
modified |= FormInputs.AddEnumDropdown(ref export.DefaultWindowMode,
|
|
"Window Mode",
|
|
"The default window mode when running the exported executable.",
|
|
defaults.DefaultWindowMode);
|
|
|
|
modified |= FormInputs.AddCheckBox("Enable Playback Control",
|
|
ref export.EnablePlaybackControlWithKeyboard,
|
|
"Users can use cursor left/right to skip through time\nand space key to pause playback\nof exported executable.",
|
|
defaults.EnablePlaybackControlWithKeyboard);
|
|
|
|
return modified;
|
|
}
|
|
|
|
|
|
#endregion
|
|
|
|
#region Playback settings
|
|
|
|
private static bool DrawPlaybackSettings(Instance composition, CompositionSettings settings,
|
|
Instance? compositionWithSettings)
|
|
{
|
|
var modified = false;
|
|
|
|
var playback = settings.Playback;
|
|
|
|
if (FormInputs.AddSegmentedButtonWithLabel(ref playback.AudioSource, "Audio Source"))
|
|
{
|
|
modified = true;
|
|
UpdatePlaybackAndTimeline(settings);
|
|
}
|
|
|
|
FormInputs.AddVerticalSpace();
|
|
ImGui.Separator();
|
|
FormInputs.AddVerticalSpace();
|
|
|
|
switch (playback.AudioSource)
|
|
{
|
|
case CompositionSettings.AudioSources.ProjectSoundTrack:
|
|
{
|
|
if (!settings.TryGetMainSoundtrack(compositionWithSettings, out var soundtrackHandle))
|
|
{
|
|
if (ImGui.Button("Add soundtrack to composition"))
|
|
{
|
|
modified = true;
|
|
playback.AudioClips.Add(new TimelineAudioClip()
|
|
{
|
|
IsMainSoundtrack = true,
|
|
});
|
|
_tempSoundtrackFilepathForEdit = string.Empty;
|
|
}
|
|
}
|
|
else
|
|
{
|
|
if (string.IsNullOrEmpty(soundtrackHandle.Clip.AssetPath))
|
|
{
|
|
_tempSoundtrackFilepathForEdit = string.Empty;
|
|
}
|
|
else
|
|
{
|
|
var isSoundtrackFileValid = soundtrackHandle.TryGetFileResource(out _);
|
|
if (isSoundtrackFileValid)
|
|
{
|
|
if (ImGui.IsWindowAppearing())
|
|
{
|
|
_tempSoundtrackFilepathForEdit = soundtrackHandle.Clip.AssetPath;
|
|
}
|
|
}
|
|
else
|
|
{
|
|
Log.Warning($"Removing invalid soundtrack file: {soundtrackHandle.Clip.AssetPath}");
|
|
soundtrackHandle.Clip.AssetPath = string.Empty;
|
|
modified = true;
|
|
}
|
|
}
|
|
|
|
var editResult = FilePickingUi.DrawTypeAheadSearch(FileOperations.FilePickerTypes.File,
|
|
AudioFileFilter,
|
|
ref _tempSoundtrackFilepathForEdit,
|
|
showAssetFolderToggle: false);
|
|
|
|
var filepathModified = (editResult & InputEditStateFlags.Modified) != 0;
|
|
if (filepathModified)
|
|
{
|
|
// Push the picker's value back onto the clip. TryToApplyFilePath validates
|
|
// via AssetRegistry and clears AssetPath if the path doesn't resolve.
|
|
if (!string.IsNullOrEmpty(_tempSoundtrackFilepathForEdit))
|
|
{
|
|
if (compositionWithSettings != null)
|
|
soundtrackHandle.TryToApplyFilePath(_tempSoundtrackFilepathForEdit, compositionWithSettings);
|
|
}
|
|
else
|
|
{
|
|
soundtrackHandle.Clip.AssetPath = string.Empty;
|
|
}
|
|
modified = true;
|
|
}
|
|
|
|
FormInputs.ApplyIndent();
|
|
if (ImGui.Button("Reload"))
|
|
{
|
|
AudioEngine.ReloadSoundtrackClip(soundtrackHandle);
|
|
AudioImageFactory.ResetImageCache();
|
|
modified = true;
|
|
filepathModified = true;
|
|
}
|
|
|
|
ImGui.SameLine();
|
|
if (ImGui.Button("Remove"))
|
|
{
|
|
playback.AudioClips.Remove(soundtrackHandle.Clip);
|
|
modified = true;
|
|
}
|
|
|
|
FormInputs.AddVerticalSpace();
|
|
|
|
var bpm = (float)playback.Bpm;
|
|
if (FormInputs.AddFloat("BPM",
|
|
ref bpm,
|
|
0,
|
|
1000,
|
|
0.02f,
|
|
true, true,
|
|
"In T3 animation units are in bars.\nThe BPM rate controls the animation speed of your project.",
|
|
120))
|
|
{
|
|
Playback.Current.Bpm = bpm;
|
|
playback.Bpm = bpm;
|
|
modified = true;
|
|
}
|
|
|
|
// Offset is the clip's TimeRange.Start (bars on the timeline).
|
|
var soundtrackStart = soundtrackHandle.Clip.TimeRange.Start;
|
|
if (FormInputs.AddFloat("Offset",
|
|
ref soundtrackStart,
|
|
-100,
|
|
100,
|
|
0.02f,
|
|
false, true,
|
|
"Offsets the start of the soundtrack on the timeline, in bars.",
|
|
0))
|
|
{
|
|
soundtrackHandle.Clip.TimeRange.Start = soundtrackStart;
|
|
modified = true;
|
|
}
|
|
|
|
FormInputs.AddEnumDropdown(ref UserSettings.Config.TimeDisplayMode, "Display Timeline in");
|
|
|
|
modified |= FormInputs.AddFloat("Audio Decay", ref playback.AudioDecayFactor,
|
|
0.001f,
|
|
1f,
|
|
0.01f,
|
|
true, true,
|
|
"The decay factors controls the impact of [AudioReaction] when AttackMode. Good values strongly depend on style, loudness and variation of input signal.",
|
|
0.9f);
|
|
|
|
if (filepathModified)
|
|
{
|
|
composition.Symbol.GetSymbolUi().FlagAsModified();
|
|
AudioEngine.ReloadSoundtrackClip(soundtrackHandle);
|
|
UpdateBpmFromSoundtrackConfig(soundtrackHandle.Clip);
|
|
UpdatePlaybackAndTimeline(settings);
|
|
}
|
|
}
|
|
|
|
break;
|
|
}
|
|
case CompositionSettings.AudioSources.ExternalDevice:
|
|
{
|
|
FormInputs.AddVerticalSpace();
|
|
|
|
if (FormInputs.AddSegmentedButtonWithLabel(ref playback.Syncing, "Sync Mode"))
|
|
{
|
|
UpdatePlaybackAndTimeline(settings);
|
|
modified = true;
|
|
}
|
|
|
|
if (playback.Syncing == CompositionSettings.SyncModes.Tapping)
|
|
{
|
|
FormInputs.SetIndentToParameters();
|
|
FormInputs.AddHint("""
|
|
Tap the [Sync] button on every beat.
|
|
The right click on measure to resync and refine.
|
|
""");
|
|
|
|
modified |= FormInputs.AddCheckBox("Enable audio beat lock",
|
|
ref playback.EnableAudioBeatLocking,
|
|
"""
|
|
If enabled, the editor will look for transient bass, hi-hats and snares and attempt to lock the playback speed onto the incoming audio signal.
|
|
To use this, start by tapping the base beat (e.g. with Z) then tap the beginning of the bar with (e.g. with X).
|
|
From now on, you will see the BPM be constantly sliding to lock onto the beat).
|
|
""",
|
|
true
|
|
);
|
|
FormInputs.AddVerticalSpace();
|
|
}
|
|
|
|
if (!playback.EnableAudioBeatLocking || playback.Syncing == CompositionSettings.SyncModes.Timeline)
|
|
{
|
|
modified |= FormInputs.AddFloat("BPM",
|
|
ref playback.Bpm,
|
|
0,
|
|
1000,
|
|
0.02f,
|
|
true, true,
|
|
"""
|
|
In T3 animation units are in bars.
|
|
The BPM rate controls the animation speed of your project.
|
|
""",
|
|
120);
|
|
}
|
|
|
|
FormInputs.SetIndentToParameters();
|
|
modified |= FormInputs.AddFloat("Beat Sync Offset (sec)",
|
|
ref playback.BeatLockAudioOffsetSec,
|
|
-1f, 1f, 0.001f,
|
|
true, true,
|
|
"""
|
|
When using beat lock through audio analysis, you can slightly offset the phase.
|
|
|
|
This might be useful to tighten the sync between audio and video, e.g. if the visual output is delayed by video-processing devices.
|
|
""",
|
|
0);
|
|
|
|
FormInputs.AddVerticalSpace();
|
|
|
|
modified |= FormInputs.AddFloat("Audio Gain", ref playback.AudioGainFactor, 0.01f, 100, 0.01f, true,
|
|
true,
|
|
"Can be used to adjust the input signal (e.g. in live situation where the input level might vary.",
|
|
1);
|
|
|
|
modified |= FormInputs.AddFloat("Audio Decay", ref playback.AudioDecayFactor,
|
|
0.001f,
|
|
1f,
|
|
0.01f,
|
|
true, true,
|
|
"The decay factors controls the impact of [AudioReaction] when AttackMode. Good values strongly depend on style, loudness and variation of input signal.",
|
|
0.9f);
|
|
|
|
// Input meter
|
|
var level = playback.AudioGainFactor * WasapiAudioInput.DecayingAudioLevel * 0.03f;
|
|
var normalizedLevel = level / 644f;
|
|
FormInputs.DrawInputLabel("Input Level");
|
|
var inputSize = FormInputs.GetAvailableInputSize(" ", true, true);
|
|
var cursorScreenPos = ImGui.GetCursorScreenPos();
|
|
AudioLevelMeter.DrawAbsoluteWithinBounds("", normalizedLevel, ref _smoothedLevel, 2f, cursorScreenPos.X,
|
|
cursorScreenPos.X + inputSize.X);
|
|
|
|
FormInputs.DrawInputLabel("Input Device");
|
|
ImGui.BeginGroup();
|
|
|
|
modified |= AudioDeviceSelector.DrawProjectDeviceCombo("##SelectDevice", ref playback.AudioInputDeviceName);
|
|
|
|
if (string.IsNullOrEmpty(playback.AudioInputDeviceName))
|
|
{
|
|
// Project defers to the machine default; expose the local device so a portable
|
|
// project still resolves to a real input on this machine.
|
|
FormInputs.DrawInputLabel("Default Device");
|
|
AudioDeviceSelector.DrawLocalDefaultDeviceCombo("##SelectLocalDevice");
|
|
CustomComponents.HelpText("Stored per machine, not in the project. Set this once and shared projects work everywhere.");
|
|
}
|
|
else if (playback.AudioInputDeviceName != WasapiAudioInput.ActiveInputDeviceName)
|
|
{
|
|
ImGui.PushStyleColor(ImGuiCol.Text, UiColors.StatusWarning.Rgba);
|
|
ImGui.TextUnformatted(playback.AudioInputDeviceName + " (NOT FOUND)");
|
|
ImGui.PopStyleColor();
|
|
}
|
|
|
|
ImGui.EndGroup();
|
|
break;
|
|
}
|
|
}
|
|
|
|
return modified;
|
|
}
|
|
|
|
#endregion
|
|
|
|
#region Helpers
|
|
|
|
private static void UpdatePlaybackAndTimeline(CompositionSettings settings)
|
|
{
|
|
var playback = settings.Playback;
|
|
if (playback.AudioSource == CompositionSettings.AudioSources.ProjectSoundTrack)
|
|
{
|
|
Playback.Current = T3Ui.DefaultTimelinePlayback;
|
|
|
|
if (playback.AudioClips.Count > 0)
|
|
{
|
|
// The settings loader already migrated any legacy per-clip BPM into playback.Bpm.
|
|
Playback.Current.Bpm = playback.Bpm;
|
|
if (Playback.Current.Settings != null)
|
|
Playback.Current.Settings.Playback.Syncing = CompositionSettings.SyncModes.Timeline;
|
|
}
|
|
|
|
UserSettings.Config.ShowTimeline = true;
|
|
}
|
|
else
|
|
{
|
|
if (playback.Syncing == CompositionSettings.SyncModes.Tapping)
|
|
{
|
|
Playback.Current = T3Ui.DefaultBeatTimingPlayback;
|
|
UserSettings.Config.ShowTimeline = false;
|
|
UserSettings.Config.EnableIdleMotion = true;
|
|
Playback.Current.PlaybackSpeed = 1;
|
|
}
|
|
else
|
|
{
|
|
Playback.Current = T3Ui.DefaultTimelinePlayback;
|
|
UserSettings.Config.ShowTimeline = true;
|
|
Playback.Current.PlaybackSpeed = 0;
|
|
}
|
|
}
|
|
}
|
|
|
|
private static void UpdateBpmFromSoundtrackConfig(TimelineAudioClip? audioClip)
|
|
{
|
|
if (audioClip == null || string.IsNullOrEmpty(audioClip.AssetPath))
|
|
{
|
|
Log.Error("Can't detected BPM-rate from empty undefined audio-clip filename");
|
|
return;
|
|
}
|
|
|
|
var matchBpmPattern = new Regex(@"(\d+\.?\d*)bpm");
|
|
var result = matchBpmPattern.Match(audioClip.AssetPath);
|
|
if (!result.Success)
|
|
return;
|
|
|
|
if (float.TryParse(result.Groups[1].Value, out var bpm))
|
|
{
|
|
Log.Debug($"Using bpm-rate {bpm} from filename.");
|
|
Playback.Current.Bpm = bpm;
|
|
}
|
|
}
|
|
|
|
#endregion
|
|
|
|
private static string? _tempSoundtrackFilepathForEdit = string.Empty;
|
|
private static float _smoothedLevel;
|
|
private const string AudioFileFilter = "mp3,wav,ogg";
|
|
|
|
// Cached proxy-storage scan (see DrawProxyStorage / EnsureProxyScan).
|
|
private static readonly string[] _byteUnits = ["KB", "MB", "GB", "TB"];
|
|
private static int _proxyScanWatcherState = -1;
|
|
private static string? _scannedProjectFolder;
|
|
private static readonly List<string> _thisProjectProxyFiles = [];
|
|
private static readonly List<string> _allProjectsProxyFiles = [];
|
|
private static string _thisProjectProxyLabel = string.Empty;
|
|
private static string _allProjectsProxyLabel = string.Empty;
|
|
private static string _thisProjectProxyTooltip = string.Empty;
|
|
private static string _allProjectsProxyTooltip = string.Empty;
|
|
} |