// Copyright (c) 2012-2023 Wojciech Figat. All rights reserved. using System; using System.Collections.Generic; using System.IO; using System.Linq; using FlaxEditor.Utilities; using FlaxEditor.GUI.Timeline.Undo; using FlaxEditor.Viewport.Previews; using FlaxEngine; using FlaxEngine.GUI; namespace FlaxEditor.GUI.Timeline.Tracks { /// /// The timeline media that represents an audio clip media event. /// /// public class AudioMedia : Media { /// /// True if loop track, otherwise audio clip will stop on the end. /// public bool Loop { get => Track.Loop; set { if (Loop == value) return; Track.Loop = value; Preview.DrawMode = value ? AudioClipPreview.DrawModes.Looped : AudioClipPreview.DrawModes.Single; } } /// /// Playback offset of the audio (in seconds). /// public float Offset { get => Preview.ViewOffset; set { if (Mathf.NearEqual(Preview.ViewOffset, value)) return; Preview.ViewOffset = value; Timeline?.MarkAsEdited(); } } private sealed class Proxy : ProxyBase { [EditorDisplay("General"), EditorOrder(10), Tooltip("The audio clip to play.")] public AudioClip Audio { get => Track.Asset; set => Track.Asset = value; } [EditorDisplay("General"), EditorOrder(20), Tooltip("If checked, the audio clip will loop when playback exceeds its duration. Otherwise it will stop play.")] public bool Loop { get => Media.Loop; set => Media.Loop = value; } [EditorDisplay("General"), EditorOrder(30), Tooltip("Playback offset of the audio (in seconds).")] [Limit(0, float.MaxValue, 0.01f)] public float Offset { get => Media.Offset; set => Media.Offset = value; } public Proxy(AudioTrack track, AudioMedia media) : base(track, media) { } } /// /// The audio clip preview. /// public AudioClipPreview Preview; /// public AudioMedia() { CanSplit = true; CanDelete = true; Preview = new AudioClipPreview { AnchorPreset = AnchorPresets.StretchAll, Offsets = Margin.Zero, DrawMode = AudioClipPreview.DrawModes.Single, Parent = this, }; } /// protected override void OnStartFrameChanged() { base.OnStartFrameChanged(); if (Track != null && Track.SubTracks.Count != 0 && Track.SubTracks[0] is AudioVolumeTrack volumeTrack) volumeTrack.UpdateCurve(); } /// protected override void OnDurationFramesChanged() { base.OnDurationFramesChanged(); if (Track != null && Track.SubTracks.Count != 0 && Track.SubTracks[0] is AudioVolumeTrack volumeTrack) volumeTrack.UpdateCurve(); } /// public override void OnTimelineChanged(Track track) { base.OnTimelineChanged(track); PropertiesEditObject = track != null ? new Proxy((AudioTrack)track, this) : null; } /// public override void OnTimelineZoomChanged() { base.OnTimelineZoomChanged(); Preview.ViewScale = Timeline.UnitsPerSecond / AudioClipPreview.UnitsPerSecond * Timeline.Zoom; } /// public override Media Split(int frame) { var offset = Offset + ((float)(frame - StartFrame) / DurationFrames) * Duration; var clone = (AudioMedia)base.Split(frame); clone.Preview.ViewOffset = offset; clone.Preview.Asset = Preview.Asset; clone.Preview.DrawMode = Preview.DrawMode; return clone; } } /// /// The timeline track that represents an audio clip playback. /// /// public class AudioTrack : SingleMediaAssetTrack { /// /// Gets the archetype. /// /// The archetype. public static TrackArchetype GetArchetype() { return new TrackArchetype { TypeId = 5, Name = "Audio", Create = options => new AudioTrack(ref options), Load = LoadTrack, Save = SaveTrack, }; } private static void LoadTrack(int version, Track track, BinaryReader stream) { var e = (AudioTrack)track; Guid id = stream.ReadGuid(); e.Asset = FlaxEngine.Content.LoadAsync(id); if (version <= 3) { // [Deprecated on 03.09.2021 expires on 03.09.2023] var m = e.TrackMedia; m.StartFrame = stream.ReadInt32(); m.DurationFrames = stream.ReadInt32(); m.Preview.ViewOffset = 0.0f; m.Preview.DrawMode = track.Loop ? AudioClipPreview.DrawModes.Looped : AudioClipPreview.DrawModes.Single; } else { var count = stream.ReadInt32(); while (e.Media.Count > count) e.RemoveMedia(e.Media.Last()); while (e.Media.Count < count) e.AddMedia(new AudioMedia()); for (int i = 0; i < count; i++) { var m = (AudioMedia)e.Media[i]; m.StartFrame = stream.ReadInt32(); m.DurationFrames = stream.ReadInt32(); m.Preview.ViewOffset = stream.ReadSingle(); m.Preview.DrawMode = track.Loop ? AudioClipPreview.DrawModes.Looped : AudioClipPreview.DrawModes.Single; m.Preview.Asset = e.Asset; } } } private static void SaveTrack(Track track, BinaryWriter stream) { var e = (AudioTrack)track; stream.WriteGuid(ref e.AssetID); var count = e.Media.Count; stream.Write(count); for (int i = 0; i < count; i++) { var m = (AudioMedia)e.Media[i]; stream.Write(m.StartFrame); stream.Write(m.DurationFrames); stream.Write(m.Offset); } } private Button _addButton; /// public AudioTrack(ref TrackCreateOptions options) : base(ref options) { MinMediaCount = 1; // Add button const float buttonSize = 14; _addButton = new Button { Text = "+", TooltipText = "Add sub-tracks", AutoFocus = true, AnchorPreset = AnchorPresets.MiddleRight, IsScrollable = false, Offsets = new Margin(-buttonSize - 2 + _muteCheckbox.Offsets.Left, buttonSize, buttonSize * -0.5f, buttonSize), Parent = this, }; _addButton.Clicked += OnAddButtonClicked; _picker.Location = new Float2(_addButton.Left - _picker.Width - 2, 2); } private void OnAddButtonClicked() { var cm = new ContextMenu.ContextMenu(); cm.AddButton("Volume", OnAddVolumeTrack); cm.Show(_addButton.Parent, _addButton.BottomLeft); } private void OnAddVolumeTrack() { var track = Timeline.NewTrack(AudioVolumeTrack.GetArchetype()); track.ParentTrack = this; track.TrackIndex = TrackIndex + 1; track.Name = Guid.NewGuid().ToString("N"); Timeline.AddTrack(track); Expand(); } /// protected override void OnSubTracksChanged() { base.OnSubTracksChanged(); _addButton.Enabled = SubTracks.Count == 0; } /// protected override void OnAssetChanged() { base.OnAssetChanged(); foreach (AudioMedia m in Media) m.Preview.Asset = Asset; } } /// /// The child volume track for audio track. Used to animate audio volume over time. /// /// class AudioVolumeTrack : Track, IKeyframesEditorContext { /// /// Gets the archetype. /// /// The archetype. public static TrackArchetype GetArchetype() { return new TrackArchetype { TypeId = 6, Name = "Audio Volume", DisableSpawnViaGUI = true, Create = options => new AudioVolumeTrack(ref options), Load = LoadTrack, Save = SaveTrack, }; } private static void LoadTrack(int version, Track track, BinaryReader stream) { var e = (AudioVolumeTrack)track; int count = stream.ReadInt32(); var keyframes = new BezierCurve.Keyframe[count]; for (int i = 0; i < count; i++) { keyframes[i] = new BezierCurve.Keyframe { Time = stream.ReadSingle(), Value = stream.ReadSingle(), TangentIn = stream.ReadSingle(), TangentOut = stream.ReadSingle(), }; } e.Curve.SetKeyframes(keyframes); } private static void SaveTrack(Track track, BinaryWriter stream) { var e = (AudioVolumeTrack)track; var keyframes = e.Curve.Keyframes; int count = keyframes.Count; stream.Write(count); for (int i = 0; i < count; i++) { var keyframe = keyframes[i]; stream.Write(keyframe.Time); stream.Write(keyframe.Value); stream.Write(keyframe.TangentIn); stream.Write(keyframe.TangentOut); } } private const float CollapsedHeight = 20.0f; private const float ExpandedHeight = 64.0f; private Label _previewValue; private byte[] _curveEditingStartData; /// /// The volume curve. Values can be in range 0-1 to animate volume intensity and the track playback starts at the parent audio track media beginning. This curve does not loop. /// public BezierCurveEditor Curve; /// public AudioVolumeTrack(ref TrackCreateOptions options) : base(ref options) { Title = "Volume"; Height = CollapsedHeight; // Curve editor Curve = new BezierCurveEditor { Visible = false, EnableZoom = CurveEditorBase.UseMode.Off, EnablePanning = CurveEditorBase.UseMode.Off, ScrollBars = ScrollBars.None, DefaultValue = 1.0f, ShowStartEndLines = true, ShowBackground = false, }; Curve.Edited += OnCurveEdited; Curve.EditingStart += OnCurveEditingStart; Curve.EditingEnd += OnCurveEditingEnd; Curve.UnlockChildrenRecursive(); // Navigation buttons const float keySize = 18; const float addSize = 20; var icons = Editor.Instance.Icons; var rightKey = new Image { TooltipText = "Sets the time to the next key", AutoFocus = true, AnchorPreset = AnchorPresets.MiddleRight, IsScrollable = false, Color = Style.Current.ForegroundGrey, Margin = new Margin(1), Brush = new SpriteBrush(icons.Right32), Offsets = new Margin(-keySize - 2 + _muteCheckbox.Offsets.Left, keySize, keySize * -0.5f, keySize), Parent = this, }; rightKey.Clicked += OnRightKeyClicked; var addKey = new Image { TooltipText = "Adds a new key at the current time", AutoFocus = true, AnchorPreset = AnchorPresets.MiddleRight, IsScrollable = false, Color = Style.Current.ForegroundGrey, Margin = new Margin(3), Brush = new SpriteBrush(icons.Add32), Offsets = new Margin(-addSize - 2 + rightKey.Offsets.Left, addSize, addSize * -0.5f, addSize), Parent = this, }; addKey.Clicked += OnAddKeyClicked; var leftKey = new Image { TooltipText = "Sets the time to the previous key", AutoFocus = true, AnchorPreset = AnchorPresets.MiddleRight, IsScrollable = false, Color = Style.Current.ForegroundGrey, Margin = new Margin(1), Brush = new SpriteBrush(icons.Left32), Offsets = new Margin(-keySize - 2 + addKey.Offsets.Left, keySize, keySize * -0.5f, keySize), Parent = this, }; leftKey.Clicked += OnLeftKeyClicked; // Value preview var previewWidth = 50.0f; _previewValue = new Label { AutoFocus = true, AnchorPreset = AnchorPresets.MiddleRight, IsScrollable = false, HorizontalAlignment = TextAlignment.Near, TextColor = Style.Current.ForegroundGrey, Margin = new Margin(1), Offsets = new Margin(-previewWidth - 2 + leftKey.Offsets.Left, previewWidth, TextBox.DefaultHeight * -0.5f, TextBox.DefaultHeight), Parent = this, }; } private void OnRightKeyClicked(Image image, MouseButton button) { if (button == MouseButton.Left && GetNextKeyframeFrame(Timeline.CurrentTime, out var frame)) { Timeline.OnSeek(frame); } } private bool GetRangeFrames(out int startFrame, out int endFrame) { if (ParentTrack != null && ParentTrack.Media.Count != 0) { startFrame = ParentTrack.Media[0].StartFrame; endFrame = ParentTrack.Media[0].EndFrame; for (int i = 1; i < ParentTrack.Media.Count; i++) { endFrame = Mathf.Max(endFrame, ParentTrack.Media[i].EndFrame); } return true; } startFrame = endFrame = 0; return false; } private bool GetRangeMedia(out Media startMedia, out Media endMedia) { if (ParentTrack != null && ParentTrack.Media.Count != 0) { startMedia = endMedia = ParentTrack.Media[0]; for (int i = 1; i < ParentTrack.Media.Count; i++) { if (ParentTrack.Media[i].EndFrame >= endMedia.EndFrame) endMedia = ParentTrack.Media[i]; } return true; } startMedia = endMedia = null; return false; } /// protected override void OnContextMenu(ContextMenu.ContextMenu menu) { base.OnContextMenu(menu); if (!GetRangeFrames(out var startFrame, out _) || Curve == null) return; menu.AddSeparator(); menu.AddButton("Copy Preview Value", () => { var time = (Timeline.CurrentFrame - startFrame) / Timeline.FramesPerSecond; Curve.Evaluate(out var value, time, false); Clipboard.Text = FlaxEngine.Utils.RoundTo2DecimalPlaces(Mathf.Saturate(value)).ToString("0.00"); }).LinkTooltip("Copies the current track value to the clipboard").Enabled = Timeline.ShowPreviewValues; } /// public override bool GetNextKeyframeFrame(float time, out int result) { if (GetRangeFrames(out var startFrame, out var endFrame)) { var mediaTime = time - startFrame / Timeline.FramesPerSecond; for (int i = 0; i < Curve.Keyframes.Count; i++) { var k = Curve.Keyframes[i]; if (k.Time > mediaTime) { result = Mathf.FloorToInt(k.Time * Timeline.FramesPerSecond) + startFrame; return true; } } } return base.GetNextKeyframeFrame(time, out result); } private void OnAddKeyClicked(Image image, MouseButton button) { var currentFrame = Timeline.CurrentFrame; if (button == MouseButton.Left && GetRangeFrames(out var startFrame, out var endFrame) && currentFrame >= startFrame && currentFrame < endFrame) { var time = (currentFrame - startFrame) / Timeline.FramesPerSecond; for (int i = Curve.Keyframes.Count - 1; i >= 0; i--) { var k = Curve.Keyframes[i]; var frame = Mathf.FloorToInt(k.Time * Timeline.FramesPerSecond) + startFrame; if (frame == Timeline.CurrentFrame) { // Already added return; } } using (new TrackUndoBlock(this)) Curve.AddKeyframe(new BezierCurve.Keyframe(time, 1.0f)); } } private void OnLeftKeyClicked(Image image, MouseButton button) { if (button == MouseButton.Left && GetPreviousKeyframeFrame(Timeline.CurrentTime, out var frame)) { Timeline.OnSeek(frame); } } /// public override bool GetPreviousKeyframeFrame(float time, out int result) { if (GetRangeFrames(out var startFrame, out _)) { var mediaTime = time - startFrame / Timeline.FramesPerSecond; for (int i = Curve.Keyframes.Count - 1; i >= 0; i--) { var k = Curve.Keyframes[i]; if (k.Time < mediaTime) { result = Mathf.FloorToInt(k.Time * Timeline.FramesPerSecond) + startFrame; return true; } } } return base.GetPreviousKeyframeFrame(time, out result); } private void UpdatePreviewValue() { if (!GetRangeFrames(out var startFrame, out _) || Curve == null || Timeline == null) return; var time = (Timeline.CurrentFrame - startFrame) / Timeline.FramesPerSecond; Curve.Evaluate(out var value, time, false); _previewValue.Text = FlaxEngine.Utils.RoundTo2DecimalPlaces(Mathf.Saturate(value)).ToString("0.00"); } internal void UpdateCurve() { if (!GetRangeMedia(out var startMedia, out var endMedia) || Curve == null || Timeline == null) return; bool wasVisible = Curve.Visible; Curve.Visible = Visible; if (!Visible) { if (wasVisible) Curve.ClearSelection(); return; } Curve.KeyframesEditorContext = Timeline; Curve.CustomViewPanning = Timeline.OnKeyframesViewPanning; Curve.Bounds = new Rectangle(startMedia.X, Y + 1.0f, endMedia.Right - startMedia.Left, Height - 2.0f); var expanded = IsExpanded; if (expanded) { Curve.ViewScale = new Float2(Timeline.Zoom, 0.7f); Curve.ViewOffset = new Float2(0.0f, 35.0f); } else { Curve.ViewScale = new Float2(Timeline.Zoom, 1.0f); Curve.ViewOffset = Float2.Zero; } Curve.ShowCollapsed = !expanded; Curve.ShowAxes = expanded ? CurveEditorBase.UseMode.Horizontal : CurveEditorBase.UseMode.Off; Curve.UpdateKeyframes(); } private void OnCurveEdited() { UpdatePreviewValue(); Timeline.MarkAsEdited(); } private void OnCurveEditingStart() { _curveEditingStartData = EditTrackAction.CaptureData(this); } private void OnCurveEditingEnd() { var after = EditTrackAction.CaptureData(this); if (!FlaxEngine.Utils.ArraysEqual(_curveEditingStartData, after)) Timeline.AddBatchedUndoAction(new EditTrackAction(Timeline, this, _curveEditingStartData, after)); _curveEditingStartData = null; } /// public override bool CanDrag => false; /// public override bool CanRename => false; /// public override bool CanCopyPaste => false; /// public override bool CanExpand => true; /// public override void OnParentTrackChanged(Track parent) { base.OnParentTrackChanged(parent); if (parent != null) { UpdateCurve(); UpdatePreviewValue(); } } /// protected override void OnExpandedChanged() { Height = IsExpanded ? ExpandedHeight : CollapsedHeight; UpdateCurve(); base.OnExpandedChanged(); } /// protected override void OnVisibleChanged() { base.OnVisibleChanged(); Curve.Visible = Visible; } /// public override void OnTimelineChanged(Timeline timeline) { base.OnTimelineChanged(timeline); Curve.Parent = timeline?.MediaPanel; Curve.FPS = timeline?.FramesPerSecond; UpdateCurve(); UpdatePreviewValue(); } /// public override void OnUndo() { base.OnUndo(); if (Curve != null) { // TODO: fix this hack: curve UI doesn't update properly after undo that modified keyframes IsExpanded = !IsExpanded; IsExpanded = !IsExpanded; } UpdatePreviewValue(); } /// public override void OnTimelineZoomChanged() { base.OnTimelineZoomChanged(); UpdateCurve(); } /// public override void OnTimelineArrange() { base.OnTimelineArrange(); UpdateCurve(); } /// public override void OnTimelineFpsChanged(float before, float after) { base.OnTimelineFpsChanged(before, after); Curve.FPS = after; UpdatePreviewValue(); } /// public override void OnTimelineCurrentFrameChanged(int frame) { base.OnTimelineCurrentFrameChanged(frame); UpdatePreviewValue(); } /// public override void OnDuplicated(Track clone) { base.OnDuplicated(clone); clone.Name = Guid.NewGuid().ToString("N"); } /// public override void OnDestroy() { if (Curve != null) { Curve.Dispose(); Curve = null; } _previewValue = null; base.OnDestroy(); } /// public void OnKeyframesDeselect(IKeyframesEditor editor) { if (Curve != null && Curve.Visible) Curve.OnKeyframesDeselect(editor); } /// public void OnKeyframesSelection(IKeyframesEditor editor, ContainerControl control, Rectangle selection) { if (Curve != null && Curve.Visible) Curve.OnKeyframesSelection(editor, control, selection); } /// public int OnKeyframesSelectionCount() { return Curve != null && Curve.Visible ? Curve.OnKeyframesSelectionCount() : 0; } /// public void OnKeyframesDelete(IKeyframesEditor editor) { if (Curve != null && Curve.Visible) Curve.OnKeyframesDelete(editor); } /// public void OnKeyframesMove(IKeyframesEditor editor, ContainerControl control, Float2 location, bool start, bool end) { if (Curve != null && Curve.Visible) Curve.OnKeyframesMove(editor, control, location, start, end); } /// public void OnKeyframesCopy(IKeyframesEditor editor, float? timeOffset, System.Text.StringBuilder data) { if (Curve != null && Curve.Visible) Curve.OnKeyframesCopy(editor, timeOffset, data); } /// public void OnKeyframesPaste(IKeyframesEditor editor, float? timeOffset, string[] datas, ref int index) { if (Curve != null && Curve.Visible) Curve.OnKeyframesPaste(editor, timeOffset, datas, ref index); } /// public void OnKeyframesGet(Action get) { Curve?.OnKeyframesGet(Name, get); } /// public void OnKeyframesSet(List> keyframes) { Curve?.OnKeyframesSet(keyframes); } } }