OverviewFilesCommitsIssuesDocsReleasesPeers

ifsrenderer-linux / WpfDisplay / ViewModels / AnimationViewModel.cs

520 lines · csharp ref:
#nullable enable
using System;
using System.Collections.Generic;
using System.Collections.ObjectModel;
using System.IO;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
using System.Timers;
using System.Windows.Media;
using System.Windows.Media.Imaging;

using Cavern.Channels;
using Cavern.QuickEQ.Utilities;

using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;

using IFSEngine.Model;
using IFSEngine.Utility;

using WpfDisplay.Helper;
using WpfDisplay.Models;

namespace WpfDisplay.ViewModels;

public partial class AnimationViewModel : ObservableObject
{
    public readonly Workspace Workspace;
    private readonly Timer _realtimePlayer;
    public HashSet<KeyframeViewModel> SelectedKeyframes { get; } = [];

    private MediaPlayer? _audioPlayer;
    public CavernAudio? Audio { get; private set; } = null;
    [ObservableProperty]
    public partial ReferenceChannel[] LoadedAudioChannels { get; set; } = [];

    [ObservableProperty]
    public partial string? AudioClipTitle { get; set; } = null;

    [ObservableProperty]
    public partial double? KeyframeInsertPosition { get; set; } = null;

    [ObservableProperty]
    public partial bool IsExportingFrames { get; set; } = false;

    private string? _saveFramesPath = null;

    public TimeOnly CurrentTime { get; private set; } = TimeOnly.MinValue;

    public ObservableCollection<ChannelViewModel> Channels { get; } = [];
    [ObservableProperty]
    public partial ChannelViewModel? EditedChannel { get; set; } = null;

    /// <summary>
    /// pixels per second
    /// </summary>
    [ObservableProperty]
    [NotifyPropertyChangedFor(nameof(SheetWidth))]
    [NotifyPropertyChangedFor(nameof(CurrentTimeScrollPosition))]
    public partial float ViewScale { get; set; } = 120.0f;

    public float SheetWidth => (float)Workspace.Ifs.Dopesheet.Length.TotalSeconds * ViewScale;

    public double KeyframeRepositionOffset { get; internal set; }

    [ObservableProperty]
    public partial double CurrentTimeIncrement { get; set; } = 1.0 / IFS.Default.Dopesheet.Fps;

    public AnimationViewModel(Workspace workspace)
    {
        Workspace = workspace;
        workspace.LoadedParamsChanged += (s, e) => ReloadDopesheet();
        workspace.Renderer.TargetIterationReached += OnFrameFinishedRendering;
        workspace.ParamSourceRemoved += OnParamSourceRemoved;
        _realtimePlayer = new Timer(TimeSpan.FromSeconds(1.0 / workspace.Ifs.Dopesheet.Fps).TotalMilliseconds);
        _realtimePlayer.Elapsed += OnPlayerTick;
        _realtimePlayer.AutoReset = true;

    }

    private void OnParamSourceRemoved(object? sender, int sourceId)
    {
        var channelsToRemove = Channels.Where(c => c.Path.Contains(sourceId.ToString())).ToList();
        foreach (var ch in channelsToRemove)
        {
            Workspace.Ifs.Dopesheet.Channels.Remove(ch.Path);
            Channels.Remove(ch);
        }
        Workspace.RaiseAnimationFrameChanged();
    }

    public float CurrentTimeScrollPosition => (float)CurrentTime.ToTimeSpan().TotalSeconds * ViewScale;

    public double ClipFps
    {
        get => Workspace.Ifs.Dopesheet.Fps;
        set
        {
            Workspace.Ifs.Dopesheet.Fps = (int)value;
            _realtimePlayer.Interval = TimeSpan.FromSeconds(1.0 / Workspace.Ifs.Dopesheet.Fps).TotalMilliseconds;
            CurrentTimeIncrement = 1 / value;
        }
    }

    private ValueSliderSettings? _fpsSlider;
    public ValueSliderSettings FpsSlider => _fpsSlider ??= new()
    {
        Label = "🎞 Framerate (fps)",
        ToolTip = "Frames per second",
        DefaultValue = 30,
        Increment = 1,
        MinValue = 1,
        ValueWillChange = Workspace.TakeSnapshot
    };

    public double CurrentTimeSeconds
    {
        get => CurrentTime.ToTimeSpan().TotalSeconds;
        set => JumpToTime(value);
    }

    private ValueSliderSettings? _currentTimeSlider;
    public ValueSliderSettings CurrentTimeSlider => _currentTimeSlider ??= new()
    {
        Label = "⏱️ Time (s)",
        ToolTip = "Current time in seconds",
        DefaultValue = 0.0,
        Increment = 1.0 / Workspace.Ifs.Dopesheet.Fps,
        MinValue = 0,
        MaxValue = Workspace.Ifs.Dopesheet.Length.TotalSeconds
    };

    public void ReloadDopesheet()
    {
        EditedChannel = null;
        SelectedKeyframes.Clear();
        Channels.Clear();
        Workspace.Ifs.Dopesheet.Channels.ToList()
            .Select(a => new ChannelViewModel(this, a.Key, a.Value)).ToList().ForEach(Channels.Add);
        OnPropertyChanged(string.Empty);
    }

    /// <summary>
    /// null: not animated. false: interpolated. true: keyframe
    /// </summary>
    public bool? GetChannelCurrentState(string path)
    {
        if (!Workspace.Ifs.Dopesheet.Channels.TryGetValue(path, out var channel))
            return null;
        var currentTimeSeconds = CurrentTime.ToTimeSpan().TotalSeconds;
        return channel.Keyframes.Any(kf => kf.t == currentTimeSeconds);
    }

    public bool IsChannelBeingModified(string path, double value)
    {
        if (!Workspace.Ifs.Dopesheet.Channels.TryGetValue(path, out var channel))
            return false;
        return Math.Abs(channel.EvaluateAt(CurrentTimeSeconds) - value) > 0.001;
    }

    public double ClipLength
    {
        get => Workspace.Ifs.Dopesheet.Length.TotalSeconds;
        set
        {
            Workspace.Ifs.Dopesheet.Length = TimeSpan.FromSeconds(value);
            OnPropertyChanged(nameof(ClipLength));
            OnPropertyChanged(nameof(SheetWidth));
        }
    }

    private ValueSliderSettings? _lengthSlider;
    public ValueSliderSettings LengthSlider => _lengthSlider ??= new()
    {
        Label = "↔️ Length (s)",
        ToolTip = "Animation length in seconds",
        DefaultValue = 10,
        Increment = 1,
        MinValue = 1,
        ValueWillChange = Workspace.TakeSnapshot
    };


    public class AnimateValueCommandParameters(string label, string path, double val)
    {
        public string Label { get; } = label;
        public string Path { get; } = path;
        public double Val { get; } = val;
    }

    [RelayCommand]
    public void AnimateValue(AnimateValueCommandParameters param)
    {
        Workspace.TakeSnapshot();
        AddOrUpdateChannel(param.Label, param.Path, param.Val);
    }

    [RelayCommand]
    public void PlayPause()
    {
        _realtimePlayer.Enabled = !_realtimePlayer.Enabled;
        if (_realtimePlayer.Enabled)
            _audioPlayer?.Play();
        else
            _audioPlayer?.Pause();
    }

    [RelayCommand]
    public void JumpToStart()
    {
        _realtimePlayer.Stop();
        _audioPlayer?.Stop();
        JumpToTime(0);
    }

    [RelayCommand]
    public void JumpToKeyframe(KeyframeViewModel kfv) => JumpToTime(kfv._k.t);

    public void JumpToTime(double t)
    {
        CurrentTime = TimeOnly.FromTimeSpan(TimeSpan.FromSeconds(t));
        Workspace.Ifs.EvaluateAt(CurrentTime);
        Workspace.Renderer.InvalidateParamsBuffer();
        if (_audioPlayer is not null && !_realtimePlayer.Enabled)//hack
            _audioPlayer?.Dispatcher.Invoke(() => _audioPlayer.Position = CurrentTime.ToTimeSpan());
        System.Windows.Application.Current.Dispatcher.Invoke(() =>
        {
            OnPropertyChanged(nameof(CurrentTimeSeconds));
            OnPropertyChanged(nameof(CurrentTimeScrollPosition));
            Workspace.RaiseAnimationFrameChanged();
        });
    }

    /// <returns>True when there is no next frame.</returns>
    private bool JumpToNextFrame()
    {
        var nextTimestep = CurrentTime.Add(TimeSpan.FromSeconds(1.0 / Workspace.Ifs.Dopesheet.Fps));
        var lastFrame = nextTimestep.ToTimeSpan() > Workspace.Ifs.Dopesheet.Length;
        if (lastFrame)
        {
            nextTimestep = TimeOnly.MinValue;
            _audioPlayer?.Dispatcher.Invoke(() => _audioPlayer.Position = TimeSpan.Zero);
        }
        JumpToTime(nextTimestep.ToTimeSpan().TotalSeconds);
        return lastFrame;
    }

    public void AddOrUpdateChannel(string name, string path, double value) => AddOrUpdateChannel(name, path, value, CurrentTime);

    public void AddOrUpdateChannel(string name, string path, double value, TimeOnly position)
    {
        EditedChannel = null;
        var vm = Channels.FirstOrDefault(c => c.Path == path);
        if (vm is null)
        {//add new channel with single keyframe
            Workspace.Ifs.Dopesheet.AddOrUpdateChannel(name, path, position, value);
            Channels.Add(new ChannelViewModel(this, path, Workspace.Ifs.Dopesheet.Channels[path]));
        }
        else
        {
            //remove existing keyframe if value equals
            var positionSeconds = position.ToTimeSpan().TotalSeconds;
            var keyOnFrame = vm.Keyframes.FirstOrDefault(kf => kf._k.Value == value && kf._k.t == positionSeconds);
            if (keyOnFrame is not null)
            {
                if (vm.Keyframes.Count == 1)
                {
                    Workspace.Ifs.Dopesheet.Channels.Remove(vm.Path);
                    Channels.Remove(vm);
                }
                else
                    vm.RemoveKeyframe(keyOnFrame);
            }
            else
                Workspace.Ifs.Dopesheet.AddOrUpdateChannel(name, path, position, value);

            vm.UpdateKeyframes();
        }
        Workspace.RaiseAnimationFrameChanged();
    }

    [RelayCommand]
    public void RemoveKeyframe(KeyframeViewModel kfv)
    {
        Workspace.TakeSnapshot();

        if (kfv._cvm.Keyframes.Count == 1)
        {//remove channel when last keyframe is removed
            Workspace.Ifs.Dopesheet.Channels.Remove(kfv._cvm.Path);
            Channels.Remove(kfv._cvm);
        }
        else
        {
            kfv._cvm.RemoveKeyframe(kfv);
        }
        Workspace.Ifs.EvaluateAt(CurrentTime);
        Workspace.Renderer.InvalidateParamsBuffer();
        Workspace.RaiseAnimationFrameChanged();
    }

    [RelayCommand]
    public void EditChannel(ChannelViewModel cvm) => EditedChannel = cvm;

    public void CloseChannelEditor() => EditedChannel = null;

    [RelayCommand]
    public void RemoveChannel(ChannelViewModel cvm)
    {
        Workspace.TakeSnapshot();
        Workspace.Ifs.Dopesheet.Channels.Remove(cvm.Path);
        Channels.Remove(cvm);
        Workspace.Renderer.InvalidateParamsBuffer();
        Workspace.RaiseAnimationFrameChanged();
    }

    public void AddToSelection(KeyframeViewModel kvm)
    {
        SelectedKeyframes.Add(kvm);
        kvm.IsSelected = true;
    }

    public void FlipSelection(KeyframeViewModel kvm)
    {
        if (SelectedKeyframes.Contains(kvm))
        {
            SelectedKeyframes.Remove(kvm);
            kvm.IsSelected = false;
        }
        else
        {
            AddToSelection(kvm);
        }
    }

    [RelayCommand]
    public void ClearSelection()
    {
        foreach (var kvm in SelectedKeyframes)
            kvm.IsSelected = false;
        SelectedKeyframes.Clear();
    }

    public void PreviewRepositionSelectedKeyframes(double offset)
    {
        var timeOffset = offset / ViewScale;
        //clamp offset so first and last keyframes dont offset beyond bounds of the animation
        timeOffset = Math.Max(timeOffset, -SelectedKeyframes.Min(k => k.KeyframeTime));
        timeOffset = Math.Min(timeOffset, (float)Workspace.Ifs.Dopesheet.Length.TotalSeconds - SelectedKeyframes.Max(k => k.KeyframeTime));
        KeyframeRepositionOffset = timeOffset * ViewScale;
        foreach (var kf in SelectedKeyframes)
            kf.NotifyPositionChanged();
    }

    public void ApplyRepositionOfSelectedKeyframes()
    {
        Workspace.TakeSnapshot();
        foreach (var kf in SelectedKeyframes)
            kf._k.t += KeyframeRepositionOffset / ViewScale;
        KeyframeRepositionOffset = 0.0;
        Workspace.Renderer.InvalidateParamsBuffer();
    }

    private void OnPlayerTick(object? sender, ElapsedEventArgs e) => JumpToNextFrame();

    [RelayCommand]
    public async Task LoadAudio()
    {
        if (!DialogHelper.ShowOpenAudioDialog(out string path))
            return;

        _audioPlayer = new MediaPlayer();
        _audioPlayer.Open(new Uri(path));
        await Task.Run(() =>
        {
            //TODO: show loading dialog with cancel
            Audio = new(path);
            LoadedAudioChannels = ChannelPrototype.GetStandardMatrix(Audio.Clip.Channels);
            AudioClipTitle = Audio.Clip.Name ?? System.IO.Path.GetFileNameWithoutExtension(path);

            OnPropertyChanged(nameof(Audio));
            Workspace.UpdateStatusText($"Audio track loaded successfully - {path}");
        });
    }

    private bool _canExecuteStart => !IsExportingFrames;
    [RelayCommand(/*CanExecute = nameof(_canExecuteStart)*/)]
    public void StartExportingFrames()
    {
        if (IsExportingFrames) throw new InvalidOperationException();

        _realtimePlayer.Stop();
        _audioPlayer?.Stop();

        if (!DialogHelper.ShowAnimationFolderBrowserDialog(out string selectedFolderPath))
            return;
        _saveFramesPath = selectedFolderPath;

        if (!Workspace.Renderer.IsRendering)
            Workspace.Renderer.StartRenderLoop();

        IsExportingFrames = true;
        Workspace.Renderer.InvalidateParamsBuffer();
        Workspace.UpdateStatusText($"Exporting animation frames...");
    }

    private void OnFrameFinishedRendering(object? sender, EventArgs e)
    {
        if (IsExportingFrames && _saveFramesPath is not null)
        {
            var frameFileExtension = Workspace.IsRawFrameExportEnabled ? "exr" : "png";
            var frameNr = (int)(CurrentTime.ToTimeSpan().TotalSeconds * Workspace.Ifs.Dopesheet.Fps);
            var framePath = Path.Combine(_saveFramesPath, $"{Workspace.Ifs.Title}-{frameNr:D6}.{frameFileExtension}"); //TODO: filter invalid chars

            if (!Workspace.IsRawFrameExportEnabled)
            {//png
                var bitmap = Workspace.Renderer.GetExportBitmapSource(Workspace.TransparentBackground).Result;
                var enc = new PngBitmapEncoder();
                enc.Frames.Add(BitmapFrame.Create(bitmap));
                using var fstream = File.Create(framePath);
                enc.Save(fstream);
            }
            else
            {//exr
                var histogramData = Workspace.Renderer.ReadHistogramData().Result;
                using var fstream = File.Create(framePath);
                OpenEXR.WriteStream(fstream, histogramData);
            }

            Workspace.Renderer.Seed = (uint)(Workspace.Ifs.Dopesheet.Fps * (frameNr + 1));//set frame number as seed, only when exporting

            if (JumpToNextFrame())
            {//was last frame
                if (Workspace.IsExportVideoFileEnabled)
                    RunFfmpegProcess().Wait();
                IsExportingFrames = false;
                _saveFramesPath = null;
            }
            else
            {
                var totalFrames = Workspace.Ifs.Dopesheet.Length.TotalSeconds * Workspace.Ifs.Dopesheet.Fps;
                Workspace.UpdateStatusText($"Exported frame '{framePath}' ({frameNr + 1}/{totalFrames})");
            }
        }
    }

    [RelayCommand(/*CanExecute = nameof(IsExportingFrames)*/)]
    public void StopExportingFrames()
    {
        IsExportingFrames = false;
        _saveFramesPath = null;
        Workspace.UpdateStatusText($"Exporting animation frames stopped.");
    }

    private async Task RunFfmpegProcess()
    {
        if (!File.Exists(Workspace.FfmpegPath))
            throw new InvalidOperationException();

        var userArgs = Workspace.FfmpegArgs ?? "";
        var frameFileExtension = Workspace.IsRawFrameExportEnabled ? "exr" : "png";
        var videoFileExtension =
            userArgs.Contains("prores") ? "mov" :
            userArgs.Contains("libvpx-vp9") ? "webm" :
            "mp4";//figure out extension based on codec
        var audioArgs = "";
        _audioPlayer?.Dispatcher.Invoke(() => audioArgs = $"-i \"{_audioPlayer.Source.OriginalString}\"");

        StringBuilder argsBuilder = new();
        argsBuilder.AppendJoin(' ',
            $"-y",//overwrite
            $"-nostdin",//disable inout
            $"-r {Workspace.Ifs.Dopesheet.Fps}",//fps
            $"-i \"{_saveFramesPath}\\{Workspace.Ifs.Title}-%06d.{frameFileExtension}\"",//input frames
            audioArgs,
            $"-t {Workspace.Ifs.Dopesheet.Length.TotalSeconds}",//restrict video length so audio is cut off if it's longer than the animation
            $"-vf \"pad=ceil(iw/2)*2:ceil(ih/2)*2\"",//divisible by 2, as required by some codecs
            userArgs,
            $"\"{Workspace.Ifs.Title}.{videoFileExtension}\"");
        var args = argsBuilder.ToString();

        var ffmpegProc = System.Diagnostics.Process.Start(new System.Diagnostics.ProcessStartInfo(Workspace.FfmpegPath, args)
        {
            WorkingDirectory = _saveFramesPath
        });

        if (ffmpegProc is null)
        {
            Workspace.UpdateStatusText("Video file creation failed - unable to run ffmpeg.");
            return;
        }
        Workspace.UpdateStatusText("Generating video...");
        await ffmpegProc.WaitForExitAsync();
        if (ffmpegProc.ExitCode != 0)
        {
            Workspace.UpdateStatusText($"Video file creation failed - ffmpeg exited with code: {ffmpegProc.ExitCode}.");
            return;
        }
        Workspace.UpdateStatusText($"Video file created successfully at '{_saveFramesPath}'.");
    }

    [RelayCommand]
    public async Task ImportChannels()
    {
        if (!DialogHelper.ShowImportChannelsDialog(out string csvFilePath))
            return;
        Workspace.TakeSnapshot();

        var successCount = await Workspace.ImportAnimationChannels(csvFilePath);

        Workspace.Ifs.EvaluateAt(CurrentTime);
        Workspace.Renderer.InvalidateParamsBuffer();
        ReloadDopesheet();
        Workspace.RaiseAnimationFrameChanged();

        Workspace.UpdateStatusText($"{successCount} channels successfully imported from file '{csvFilePath}'.");
    }

}