Browse Source

Layer based key frames

Krzysztof Krysiński 1 year ago
parent
commit
bbfc507a5e
23 changed files with 301 additions and 81 deletions
  1. 2 2
      src/PixiEditor.AvaloniaUI/Models/DocumentModels/DocumentUpdater.cs
  2. 5 2
      src/PixiEditor.AvaloniaUI/Models/Handlers/IAnimationHandler.cs
  3. 2 0
      src/PixiEditor.AvaloniaUI/Models/Handlers/IKeyFrameHandler.cs
  4. 1 1
      src/PixiEditor.AvaloniaUI/Models/Handlers/IRasterKeyFrameHandler.cs
  5. 20 0
      src/PixiEditor.AvaloniaUI/Styles/Templates/KeyFrame.axaml
  6. 10 23
      src/PixiEditor.AvaloniaUI/Styles/Templates/Timeline.axaml
  7. 67 4
      src/PixiEditor.AvaloniaUI/ViewModels/Document/AnimationDataViewModel.cs
  8. 23 0
      src/PixiEditor.AvaloniaUI/ViewModels/Document/KeyFrameCollection.cs
  9. 16 0
      src/PixiEditor.AvaloniaUI/ViewModels/Document/KeyFrameGroupViewModel.cs
  10. 7 4
      src/PixiEditor.AvaloniaUI/ViewModels/Document/KeyFrameViewModel.cs
  11. 3 4
      src/PixiEditor.AvaloniaUI/ViewModels/Document/RasterKeyFrameViewModel.cs
  12. 10 3
      src/PixiEditor.AvaloniaUI/ViewModels/SubViewModels/AnimationsViewModel.cs
  13. 8 0
      src/PixiEditor.AvaloniaUI/Views/Animations/KeyFrame.cs
  14. 3 5
      src/PixiEditor.AvaloniaUI/Views/Animations/Timeline.cs
  15. 1 1
      src/PixiEditor.AvaloniaUI/Views/Windows/HelloTherePopup.axaml.cs
  16. 1 1
      src/PixiEditor.ChangeableDocument/ChangeInfos/Animation/CreateRasterKeyFrame_ChangeInfo.cs
  17. 1 1
      src/PixiEditor.ChangeableDocument/ChangeInfos/Animation/DeleteKeyFrame_ChangeInfo.cs
  18. 82 12
      src/PixiEditor.ChangeableDocument/Changeables/Animations/AnimationData.cs
  19. 10 0
      src/PixiEditor.ChangeableDocument/Changeables/Animations/GroupKeyFrame.cs
  20. 14 4
      src/PixiEditor.ChangeableDocument/Changeables/Animations/KeyFrame.cs
  21. 4 7
      src/PixiEditor.ChangeableDocument/Changeables/Animations/RasterKeyFrame.cs
  22. 2 0
      src/PixiEditor.ChangeableDocument/Changeables/Interfaces/IReadOnlyKeyFrame.cs
  23. 9 7
      src/PixiEditor.ChangeableDocument/Changes/Animation/CreateRasterClip_Change.cs

+ 2 - 2
src/PixiEditor.AvaloniaUI/Models/DocumentModels/DocumentUpdater.cs

@@ -405,12 +405,12 @@ internal class DocumentUpdater
     
     private void ProcessCreateRasterKeyFrame(CreateRasterKeyFrame_ChangeInfo info)
     {
-        doc.AnimationHandler.KeyFrames.Add(new RasterKeyFrameViewModel(info.TargetLayerGuid, info.Frame, 1));
+        doc.AnimationHandler.AddKeyFrame(new RasterKeyFrameViewModel(info.TargetLayerGuid, info.Frame, 1, info.KeyFrameId));
     }
     
     private void ProcessDeleteKeyFrame(DeleteKeyFrame_ChangeInfo info)
     {
-        doc.AnimationHandler.KeyFrames.RemoveAt(info.IndexOfDeletedClip);
+        doc.AnimationHandler.RemoveKeyFrame(info.DeletedKeyFrameId);
     }
     
     private void ProcessActiveFrame(ActiveFrame_ChangeInfo info)

+ 5 - 2
src/PixiEditor.AvaloniaUI/Models/Handlers/IAnimationHandler.cs

@@ -1,12 +1,15 @@
 using System.Collections.ObjectModel;
+using PixiEditor.AvaloniaUI.ViewModels.Document;
 using PixiEditor.ChangeableDocument.Changeables.Interfaces;
 
 namespace PixiEditor.AvaloniaUI.Models.Handlers;
 
 public interface IAnimationHandler
 {
-    public ObservableCollection<IKeyFrameHandler> KeyFrames { get; }
+    public IReadOnlyCollection<IKeyFrameHandler> KeyFrames { get; }
     public int ActiveFrameBindable { get; set; }
-    public void AddRasterClip(Guid targetLayerGuid, int frame, bool cloneFromExisting);
+    public void CreateRasterKeyFrame(Guid targetLayerGuid, int frame, bool cloneFromExisting);
     public void SetActiveFrame(int newFrame);
+    internal void AddKeyFrame(IKeyFrameHandler keyFrame);
+    internal void RemoveKeyFrame(Guid keyFrameId);
 }

+ 2 - 0
src/PixiEditor.AvaloniaUI/Models/Handlers/IKeyFrameHandler.cs

@@ -4,4 +4,6 @@ public interface IKeyFrameHandler
 {
     public int StartFrame { get; }
     public int Duration { get; }
+    public Guid LayerGuid { get; }
+    public Guid Id { get; }
 }

+ 1 - 1
src/PixiEditor.AvaloniaUI/Models/Handlers/IRasterKeyFrameHandler.cs

@@ -2,5 +2,5 @@
 
 public interface IRasterKeyFrameHandler : IKeyFrameHandler
 {
-    public Guid TargetLayerGuid { get; }
+
 }

+ 20 - 0
src/PixiEditor.AvaloniaUI/Styles/Templates/KeyFrame.axaml

@@ -0,0 +1,20 @@
+<ResourceDictionary xmlns="https://github.com/avaloniaui"
+                    xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
+                    xmlns:animations="clr-namespace:PixiEditor.AvaloniaUI.Views.Animations"
+                    xmlns:document="clr-namespace:PixiEditor.AvaloniaUI.ViewModels.Document"
+                    xmlns:handlers="clr-namespace:PixiEditor.AvaloniaUI.Models.Handlers"
+                    xmlns:behaviours="clr-namespace:PixiEditor.AvaloniaUI.Helpers.Behaviours"
+                    xmlns:commands="clr-namespace:PixiEditor.AvaloniaUI.Models.Commands.Attributes.Commands"
+                    xmlns:xaml="clr-namespace:PixiEditor.AvaloniaUI.Models.Commands.XAML">
+    <Design.PreviewWith>
+    </Design.PreviewWith>
+
+    <ControlTheme TargetType="animations:KeyFrame" x:Key="{x:Type animations:KeyFrame}">
+        <Setter Property="Template">
+            <ControlTemplate>
+
+            </ControlTemplate>
+        </Setter>
+    </ControlTheme>
+
+</ResourceDictionary>

+ 10 - 23
src/PixiEditor.AvaloniaUI/Styles/Templates/Timeline.axaml

@@ -5,7 +5,8 @@
                     xmlns:handlers="clr-namespace:PixiEditor.AvaloniaUI.Models.Handlers"
                     xmlns:behaviours="clr-namespace:PixiEditor.AvaloniaUI.Helpers.Behaviours"
                     xmlns:commands="clr-namespace:PixiEditor.AvaloniaUI.Models.Commands.Attributes.Commands"
-                    xmlns:xaml="clr-namespace:PixiEditor.AvaloniaUI.Models.Commands.XAML">
+                    xmlns:xaml="clr-namespace:PixiEditor.AvaloniaUI.Models.Commands.XAML"
+                    xmlns:converters="clr-namespace:PixiEditor.AvaloniaUI.Helpers.Converters">
     <Design.PreviewWith>
         <animations:Timeline>
             <animations:Timeline.KeyFrames>
@@ -40,7 +41,7 @@
                                 IsSnapToTickEnabled="True"
                                 Name="ActiveFrameSlider"
                                 Minimum="0"
-                                Maximum="{Binding KeyFrames.Count, RelativeSource={RelativeSource TemplatedParent}}">
+                                Maximum="{Binding KeyFrames.FrameCount, RelativeSource={RelativeSource TemplatedParent}}">
                             <Interaction.Behaviors>
                                 <behaviours:SliderUpdateBehavior
                                     Binding="{Binding RelativeSource={RelativeSource TemplatedParent}, Path=ActiveFrame, Mode=OneWay}"
@@ -54,27 +55,13 @@
                     </DockPanel>
                     
                     <ScrollViewer Grid.Row="2" HorizontalScrollBarVisibility="Auto">
-                        <ItemsControl ItemsSource="{TemplateBinding KeyFrames}">
-                            <ItemsControl.ItemsPanel>
-                                <ItemsPanelTemplate>
-                                    <StackPanel Orientation="Horizontal"/>
-                                </ItemsPanelTemplate>
-                            </ItemsControl.ItemsPanel>
-                            <ItemsControl.ItemTemplate>
-                                <DataTemplate DataType="handlers:IKeyFrameHandler">
-                                    <Border BorderBrush="Black" BorderThickness="1" Margin="2">
-                                        <Grid>
-                                            <Grid.ColumnDefinitions>
-                                                <ColumnDefinition Width="Auto"/>
-                                                <ColumnDefinition Width="*"/>
-                                            </Grid.ColumnDefinitions>
-                                            <TextBlock Text="{Binding StartFrame}"/>
-                                            <TextBlock Grid.Column="1" Text="{Binding Duration}"/>
-                                        </Grid>
-                                    </Border>
-                                </DataTemplate>
-                            </ItemsControl.ItemTemplate>
-                        </ItemsControl>
+                        <TreeView ItemsSource="{TemplateBinding KeyFrames}">
+                            <TreeView.ItemTemplate>
+                                <TreeDataTemplate ItemsSource="{Binding Children}">
+                                    <TextBlock Text="{Binding StartFrame}"/>
+                                </TreeDataTemplate>
+                            </TreeView.ItemTemplate>
+                        </TreeView>
                     </ScrollViewer>
                 </Grid>
             </ControlTemplate>

+ 67 - 4
src/PixiEditor.AvaloniaUI/ViewModels/Document/AnimationDataViewModel.cs

@@ -12,7 +12,9 @@ internal class AnimationDataViewModel : ObservableObject, IAnimationHandler
     private int _activeFrameBindable;
     public DocumentViewModel Document { get; }
     protected DocumentInternalParts Internals { get; }
-    public ObservableCollection<IKeyFrameHandler> KeyFrames { get; } = new();
+    public IReadOnlyCollection<IKeyFrameHandler> KeyFrames => keyFrames;
+
+    private KeyFrameCollection keyFrames = new KeyFrameCollection();
 
     public int ActiveFrameBindable
     {
@@ -21,7 +23,7 @@ internal class AnimationDataViewModel : ObservableObject, IAnimationHandler
         {
             if (Document.UpdateableChangeActive)
                 return;
-            
+
             Internals.ActionAccumulator.AddFinishedActions(
                 new ActiveFrame_Action(value),
                 new EndActiveFrame_Action());
@@ -34,10 +36,11 @@ internal class AnimationDataViewModel : ObservableObject, IAnimationHandler
         Internals = internals;
     }
 
-    public void AddRasterClip(Guid targetLayerGuid, int frame, bool cloneFromExisting)
+    public void CreateRasterKeyFrame(Guid targetLayerGuid, int frame, bool cloneFromExisting)
     {
         if (!Document.UpdateableChangeActive)
-            Internals.ActionAccumulator.AddFinishedActions(new CreateRasterClip_Action(targetLayerGuid, frame, cloneFromExisting));
+            Internals.ActionAccumulator.AddFinishedActions(new CreateRasterClip_Action(targetLayerGuid, frame,
+                cloneFromExisting));
     }
 
     public void SetActiveFrame(int newFrame)
@@ -45,4 +48,64 @@ internal class AnimationDataViewModel : ObservableObject, IAnimationHandler
         _activeFrameBindable = newFrame;
         OnPropertyChanged(nameof(ActiveFrameBindable));
     }
+
+    public void AddKeyFrame(IKeyFrameHandler keyFrame)
+    {
+        Guid id = keyFrame.LayerGuid;
+        if (TryFindKeyFrame(id, out KeyFrameGroupViewModel group))
+        {
+            group.Children.Add((KeyFrameViewModel)keyFrame);
+        }
+        else
+        {
+            KeyFrameGroupViewModel createdGroup =
+                new KeyFrameGroupViewModel(keyFrame.StartFrame, keyFrame.Duration, id, id);
+            createdGroup.Children.Add((KeyFrameViewModel)keyFrame);
+            keyFrames.Add(createdGroup);
+        }
+
+        keyFrames.NotifyCollectionChanged();
+    }
+
+    public void RemoveKeyFrame(Guid keyFrameId)
+    {
+        TryFindKeyFrame<KeyFrameViewModel>(keyFrameId, out _, (frame, parent) =>
+        {
+            parent.Children.Remove(frame);
+        });
+    }
+
+    // TODO: Use the same structure functions as layers
+    public bool TryFindKeyFrame<T>(Guid id, out T foundKeyFrame,
+        Action<KeyFrameViewModel, KeyFrameGroupViewModel?> onFound = null) where T : KeyFrameViewModel
+    {
+        return TryFindKeyFrame(keyFrames, null, id, out foundKeyFrame, onFound);
+    }
+
+    private bool TryFindKeyFrame<T>(IList<KeyFrameViewModel> root, KeyFrameGroupViewModel parent, Guid id, out T result,
+        Action<KeyFrameViewModel, KeyFrameGroupViewModel?> onFound) where T : KeyFrameViewModel
+    {
+        for (var i = 0; i < root.Count; i++)
+        {
+            var frame = root[i];
+            if (frame is T targetFrame && targetFrame.Id.Equals(id))
+            {
+                result = targetFrame;
+                onFound?.Invoke(frame, parent);
+                return true;
+            }
+
+            if (frame is KeyFrameGroupViewModel { Children.Count: > 0 } group)
+            {
+                bool found = TryFindKeyFrame(group.Children, group, id, out result, onFound);
+                if (found)
+                {
+                    return true;
+                }
+            }
+        }
+
+        result = null;
+        return false;
+    }
 }

+ 23 - 0
src/PixiEditor.AvaloniaUI/ViewModels/Document/KeyFrameCollection.cs

@@ -0,0 +1,23 @@
+using System.Collections.ObjectModel;
+using System.Collections.Specialized;
+using System.ComponentModel;
+
+namespace PixiEditor.AvaloniaUI.ViewModels.Document;
+
+public class KeyFrameCollection : ObservableCollection<KeyFrameViewModel>
+{
+    public KeyFrameCollection()
+    {
+
+    }
+
+    public void NotifyCollectionChanged()
+    {
+        OnPropertyChanged(new PropertyChangedEventArgs(nameof(FrameCount)));
+    }
+
+    public int FrameCount
+    {
+        get => Items.Count == 0 ? 0 : Items.Max(x => x.StartFrame + x.Duration);
+    }
+}

+ 16 - 0
src/PixiEditor.AvaloniaUI/ViewModels/Document/KeyFrameGroupViewModel.cs

@@ -0,0 +1,16 @@
+using System.Collections.ObjectModel;
+using PixiEditor.AvaloniaUI.Models.Handlers;
+
+namespace PixiEditor.AvaloniaUI.ViewModels.Document;
+
+public class KeyFrameGroupViewModel : KeyFrameViewModel
+{
+    public ObservableCollection<KeyFrameViewModel> Children { get; } = new ObservableCollection<KeyFrameViewModel>();
+
+    public override int StartFrame => Children.Min(x => x.StartFrame);
+    public override int Duration => Children.Max(x => x.StartFrame + x.Duration);
+
+    public KeyFrameGroupViewModel(int startFrame, int duration, Guid layerGuid, Guid id) : base(startFrame, duration, layerGuid, id)
+    {
+    }
+}

+ 7 - 4
src/PixiEditor.AvaloniaUI/ViewModels/Document/KeyFrameViewModel.cs

@@ -1,9 +1,12 @@
-using PixiEditor.AvaloniaUI.Models.Handlers;
+using CommunityToolkit.Mvvm.ComponentModel;
+using PixiEditor.AvaloniaUI.Models.Handlers;
 
 namespace PixiEditor.AvaloniaUI.ViewModels.Document;
 
-public class KeyFrameViewModel(int startFrame, int duration) : IKeyFrameHandler
+public abstract class KeyFrameViewModel(int startFrame, int duration, Guid layerGuid, Guid id) : ObservableObject, IKeyFrameHandler
 {
-    public int StartFrame { get; } = startFrame;
-    public int Duration { get; } = duration;
+    public virtual int StartFrame { get; } = startFrame;
+    public virtual int Duration { get; } = duration;
+    public Guid LayerGuid { get; } = layerGuid;
+    public Guid Id { get; } = id;
 }

+ 3 - 4
src/PixiEditor.AvaloniaUI/ViewModels/Document/RasterKeyFrameViewModel.cs

@@ -4,10 +4,9 @@ namespace PixiEditor.AvaloniaUI.ViewModels.Document;
 
 public class RasterKeyFrameViewModel : KeyFrameViewModel, IRasterKeyFrameHandler
 {
-    public Guid TargetLayerGuid { get; }
-    
-    public RasterKeyFrameViewModel(Guid targetLayerGuid, int startFrame, int duration) : base(startFrame, duration)
+    public RasterKeyFrameViewModel(Guid targetLayerGuid, int startFrame, int duration, Guid id) : base(startFrame, duration, targetLayerGuid, id)
     {
-        TargetLayerGuid = targetLayerGuid;
+
     }
+
 }

+ 10 - 3
src/PixiEditor.AvaloniaUI/ViewModels/SubViewModels/AnimationsViewModel.cs

@@ -1,4 +1,5 @@
 using PixiEditor.AvaloniaUI.Models.Commands.Attributes.Commands;
+using PixiEditor.AvaloniaUI.ViewModels.Document;
 
 namespace PixiEditor.AvaloniaUI.ViewModels.SubViewModels;
 
@@ -18,11 +19,17 @@ internal class AnimationsViewModel : SubViewModel<ViewModelMain>
         {
             return;
         }
+
+        int newFrame = 0;
+
+        if (activeDocument.AnimationDataViewModel.TryFindKeyFrame(activeDocument.SelectedStructureMember.GuidValue, out KeyFrameGroupViewModel group))
+        {
+            newFrame = group.StartFrame + group.Duration;
+        }
         
-        
-        activeDocument.AnimationDataViewModel.AddRasterClip(
+        activeDocument.AnimationDataViewModel.CreateRasterKeyFrame(
             activeDocument.SelectedStructureMember.GuidValue, 
-            activeDocument.AnimationDataViewModel.KeyFrames.Count,
+            newFrame,
             false);
     }
     

+ 8 - 0
src/PixiEditor.AvaloniaUI/Views/Animations/KeyFrame.cs

@@ -0,0 +1,8 @@
+using Avalonia.Controls.Primitives;
+
+namespace PixiEditor.AvaloniaUI.Views.Animations;
+
+public class KeyFrame : TemplatedControl
+{
+
+}

+ 3 - 5
src/PixiEditor.AvaloniaUI/Views/Animations/Timeline.cs

@@ -4,10 +4,8 @@ using Avalonia;
 using Avalonia.Controls;
 using Avalonia.Controls.Metadata;
 using Avalonia.Controls.Primitives;
-using Avalonia.Input;
 using Avalonia.Interactivity;
 using Avalonia.Threading;
-using PixiEditor.AvaloniaUI.Models.Handlers;
 using PixiEditor.AvaloniaUI.ViewModels.Document;
 
 namespace PixiEditor.AvaloniaUI.Views.Animations;
@@ -15,8 +13,8 @@ namespace PixiEditor.AvaloniaUI.Views.Animations;
 [TemplatePart("PART_PlayToggle", typeof(ToggleButton))]
 public class Timeline : TemplatedControl
 {
-    public static readonly StyledProperty<ObservableCollection<IKeyFrameHandler>> KeyFramesProperty =
-        AvaloniaProperty.Register<Timeline, ObservableCollection<IKeyFrameHandler>>(
+    public static readonly StyledProperty<KeyFrameCollection> KeyFramesProperty =
+        AvaloniaProperty.Register<Timeline, KeyFrameCollection>(
             nameof(KeyFrames));
 
     public static readonly StyledProperty<int> ActiveFrameProperty =
@@ -41,7 +39,7 @@ public class Timeline : TemplatedControl
         set => SetValue(IsPlayingProperty, value);
     }
 
-    public ObservableCollection<IKeyFrameHandler> KeyFrames
+    public KeyFrameCollection KeyFrames
     {
         get => GetValue(KeyFramesProperty);
         set => SetValue(KeyFramesProperty, value);

+ 1 - 1
src/PixiEditor.AvaloniaUI/Views/Windows/HelloTherePopup.axaml.cs

@@ -197,7 +197,7 @@ internal partial class HelloTherePopup : PixiEditorPopup
 
     private async void HelloTherePopup_OnLoaded(object sender, RoutedEventArgs e)
     {
-        //return; // TODO
+        // TODO: Fetching news freezes input when no internet is present
         if(_newsDisabled) return;
 
         try

+ 1 - 1
src/PixiEditor.ChangeableDocument/ChangeInfos/Animation/CreateRasterKeyFrame_ChangeInfo.cs

@@ -3,5 +3,5 @@
 public record CreateRasterKeyFrame_ChangeInfo(
     Guid TargetLayerGuid,
     int Frame,
-    int IndexOfCreatedClip,
+    Guid KeyFrameId,
     bool CloneFromExisting) : IChangeInfo;

+ 1 - 1
src/PixiEditor.ChangeableDocument/ChangeInfos/Animation/DeleteKeyFrame_ChangeInfo.cs

@@ -2,4 +2,4 @@
 
 public record DeleteKeyFrame_ChangeInfo(
     int Frame,
-    int IndexOfDeletedClip) : IChangeInfo;
+    Guid DeletedKeyFrameId) : IChangeInfo;

+ 82 - 12
src/PixiEditor.ChangeableDocument/Changeables/Animations/AnimationData.cs

@@ -24,14 +24,72 @@ public class AnimationData : IReadOnlyAnimationData
             OnPreviewFrameChanged(lastFrame);
         }
     }
-    
-    public List<KeyFrame> KeyFrames { get; set; } = new List<KeyFrame>();
-    IReadOnlyList<IReadOnlyKeyFrame> IReadOnlyAnimationData.KeyFrames => KeyFrames;
+
+    public IReadOnlyList<IReadOnlyKeyFrame> KeyFrames => keyFrames;
+
+    private List<KeyFrame> keyFrames = new List<KeyFrame>();
     
     public void ChangePreviewFrame(int frame)
     {
         ActiveFrame = frame;
     }
+
+    public void AddKeyFrame(KeyFrame keyFrame)
+    {
+        Guid id = keyFrame.LayerGuid;
+        if (TryFindKeyFrame(id, out GroupKeyFrame group))
+        {
+            group.Children.Add(keyFrame);
+        }
+        else
+        {
+            GroupKeyFrame createdGroup = new GroupKeyFrame(id, keyFrame.StartFrame);
+            createdGroup.Children.Add(keyFrame);
+            keyFrames.Add(createdGroup);
+        }
+    }
+
+    public void RemoveKeyFrame(Guid createdKeyFrameId)
+    {
+        TryFindKeyFrame<KeyFrame>(createdKeyFrameId, out _, (frame, parent) =>
+        {
+            if (parent != null)
+            {
+                parent.Children.Remove(frame);
+            }
+        });
+    }
+
+    private bool TryFindKeyFrame<T>(Guid id, out T foundKeyFrame, Action<KeyFrame, GroupKeyFrame?> onFound = null) where T : KeyFrame
+    {
+        return TryFindKeyFrame(keyFrames, null, id, out foundKeyFrame, onFound);
+    }
+
+    private bool TryFindKeyFrame<T>(List<KeyFrame> root, GroupKeyFrame parent, Guid id, out T result, Action<KeyFrame, GroupKeyFrame?> onFound) where T : KeyFrame
+    {
+        for (var i = 0; i < root.Count; i++)
+        {
+            var frame = root[i];
+            if (frame is T targetFrame && targetFrame.Id.Equals(id))
+            {
+                result = targetFrame;
+                onFound?.Invoke(frame, parent);
+                return true;
+            }
+
+            if (frame is GroupKeyFrame { Children.Count: > 0 } group)
+            {
+                bool found = TryFindKeyFrame(group.Children, group, id, out result, onFound);
+                if (found)
+                {
+                    return true;
+                }
+            }
+        }
+
+        result = null;
+        return false;
+    }
     
     private void OnPreviewFrameChanged(int lastFrame)
     {
@@ -39,18 +97,30 @@ public class AnimationData : IReadOnlyAnimationData
         {
             return;
         }
-        
-        foreach (var keyFrame in KeyFrames)
+
+        NotifyKeyFrames(lastFrame, keyFrames);
+    }
+
+    private void NotifyKeyFrames(int lastFrame, List<KeyFrame> root)
+    {
+        foreach (var keyFrame in root)
         {
-            if (IsWithinRange(keyFrame, ActiveFrame))
+            if (keyFrame is GroupKeyFrame group)
             {
-                if (!IsWithinRange(keyFrame, lastFrame))
-                {
-                    keyFrame.Deactivated(ActiveFrame);
-                }
-                else
+                NotifyKeyFrames(lastFrame, group.Children);
+            }
+            else
+            {
+                if (IsWithinRange(keyFrame, ActiveFrame))
                 {
-                    keyFrame.ActiveFrameChanged(ActiveFrame);   
+                    if (!IsWithinRange(keyFrame, lastFrame))
+                    {
+                        keyFrame.Deactivated(ActiveFrame);
+                    }
+                    else
+                    {
+                        keyFrame.ActiveFrameChanged(ActiveFrame);
+                    }
                 }
             }
         }

+ 10 - 0
src/PixiEditor.ChangeableDocument/Changeables/Animations/GroupKeyFrame.cs

@@ -0,0 +1,10 @@
+namespace PixiEditor.ChangeableDocument.Changeables.Animations;
+
+public class GroupKeyFrame : KeyFrame
+{
+    public List<KeyFrame> Children { get; } = new List<KeyFrame>();
+    public GroupKeyFrame(Guid layerGuid, int startFrame) : base(layerGuid, startFrame)
+    {
+        Id = layerGuid;
+    }
+}

+ 14 - 4
src/PixiEditor.ChangeableDocument/Changeables/Animations/KeyFrame.cs

@@ -5,8 +5,18 @@ namespace PixiEditor.ChangeableDocument.Changeables.Animations;
 public abstract class KeyFrame : IReadOnlyKeyFrame
 {
     public int StartFrame { get; set; }
-    public int Duration { get; } = 1;
-    
-    public abstract void ActiveFrameChanged(int atFrame);
-    public abstract void Deactivated(int atFrame);
+    public int Duration { get; set; }
+    public Guid LayerGuid { get; }
+    public Guid Id { get; protected init; }
+
+    protected KeyFrame(Guid layerGuid, int startFrame)
+    {
+        LayerGuid = layerGuid;
+        StartFrame = startFrame;
+        Duration = 1;
+        Id = Guid.NewGuid();
+    }
+
+    public virtual void ActiveFrameChanged(int atFrame) { }
+    public virtual void Deactivated(int atFrame) { }
 }

+ 4 - 7
src/PixiEditor.ChangeableDocument/Changeables/Animations/RasterKeyFrame.cs

@@ -2,17 +2,14 @@
 
 internal class RasterKeyFrame : KeyFrame
 {
-    public Guid TargetLayerGuid { get; set; }
     public ChunkyImage Image { get; set; }
     public Document Document { get; set; }
-    
+
     private ChunkyImage originalLayerImage;
 
     public RasterKeyFrame(Guid targetLayerGuid, int startFrame, Document document, ChunkyImage? cloneFrom = null)
+        : base(targetLayerGuid, startFrame)
     {
-        TargetLayerGuid = targetLayerGuid;
-        StartFrame = startFrame;
-        
         Image = cloneFrom?.CloneFromCommitted() ?? new ChunkyImage(document.Size);
 
         Document = document;
@@ -20,7 +17,7 @@ internal class RasterKeyFrame : KeyFrame
     
     public override void ActiveFrameChanged(int atFrame)
     {
-        if (Document.TryFindMember<RasterLayer>(TargetLayerGuid, out var layer))
+        if (Document.TryFindMember<RasterLayer>(LayerGuid, out var layer))
         {
             originalLayerImage = layer.LayerImage;
             layer.LayerImage = Image;
@@ -29,7 +26,7 @@ internal class RasterKeyFrame : KeyFrame
 
     public override void Deactivated(int atFrame)
     {
-        if (Document.TryFindMember<RasterLayer>(TargetLayerGuid, out var layer))
+        if (Document.TryFindMember<RasterLayer>(LayerGuid, out var layer))
         {
             layer.LayerImage = originalLayerImage;
         }

+ 2 - 0
src/PixiEditor.ChangeableDocument/Changeables/Interfaces/IReadOnlyKeyFrame.cs

@@ -4,6 +4,8 @@ public interface IReadOnlyKeyFrame
 {
     public int StartFrame { get; }
     public int Duration { get; }
+    public Guid LayerGuid { get; }
+    public Guid Id { get; }
 
     public void ActiveFrameChanged(int atFrame);
     public void Deactivated(int atFrame);

+ 9 - 7
src/PixiEditor.ChangeableDocument/Changes/Animation/CreateRasterClip_Change.cs

@@ -9,8 +9,8 @@ internal class CreateRasterClip_Change : Change
     private readonly int _frame;
     private readonly bool _cloneFromExisting;
     private RasterLayer? _layer;
-    private int indexOfCreatedClip;
-    
+    private Guid createdKeyFrameId;
+
     [GenerateMakeChangeAction]
     public CreateRasterClip_Change(Guid targetLayerGuid, int frame, bool cloneFromExisting = false)
     {
@@ -26,16 +26,18 @@ internal class CreateRasterClip_Change : Change
 
     public override OneOf<None, IChangeInfo, List<IChangeInfo>> Apply(Document target, bool firstApply, out bool ignoreInUndo)
     {
-        indexOfCreatedClip = target.AnimationData.KeyFrames.Count;
-        target.AnimationData.KeyFrames.Add(new RasterKeyFrame(_targetLayerGuid, _frame, target, _cloneFromExisting ? _layer.LayerImage : null));
+        var keyFrame =
+            new RasterKeyFrame(_targetLayerGuid, _frame, target, _cloneFromExisting ? _layer.LayerImage : null);
+        createdKeyFrameId = keyFrame.Id;
+        target.AnimationData.AddKeyFrame(keyFrame);
         target.AnimationData.ChangePreviewFrame(_frame);
         ignoreInUndo = false;
-        return new CreateRasterKeyFrame_ChangeInfo(_targetLayerGuid, _frame, indexOfCreatedClip, _cloneFromExisting);
+        return new CreateRasterKeyFrame_ChangeInfo(_targetLayerGuid, _frame, createdKeyFrameId, _cloneFromExisting);
     }
 
     public override OneOf<None, IChangeInfo, List<IChangeInfo>> Revert(Document target)
     {
-        target.AnimationData.KeyFrames.RemoveAt(indexOfCreatedClip);
-        return new DeleteKeyFrame_ChangeInfo(_frame, indexOfCreatedClip);
+        target.AnimationData.RemoveKeyFrame(createdKeyFrameId);
+        return new DeleteKeyFrame_ChangeInfo(_frame, createdKeyFrameId);
     }
 }