📝

UnityのTimelineを便利に使うために、めちゃくちゃ地味だけど独自に実装して良かったエディタ拡張機能

2024/12/17に公開

はじめに

こんにちは、Porcoと申します。Unityで3DCGを用いた同人映像制作をしています。現在はUE5に移行中。
好きなSCPの登場人物はタローラン研究員です。

UnityのTimelineエディタは初期状態だといまいち使い勝手が悪いです。快適な開発のためには、ワークフローに合わせたエディタ拡張の実装が効果的です。
本記事では「Timelineのエディタ拡張でこんなことができるようになるぞ」「こんな感じの機能を実装したらそこそこ役に立ったぞ」あたりをサンプルコード付きでご紹介します。
実装方法の詳細は、以下の参考記事が役に立つかと思います。

これから紹介する拡張機能は「Timelineに配置するアセットはほとんど外部DCCツールで作成し、UnityのTimelineではクリップを並べるだけ」みたいなシンプルワークフローを徹底できてるならあんま役に立たないかもです。クリップの位置や期間を細かく調整したい人向け。

この記事は性DEC Advent Calendar 2024に参加しています。記事の内容は特に成人向けとかではなかったかもしれない。それでは本題に入ります。

前提知識


本記事で紹介する拡張機能はUnityEditor.Timeline.Actions.TimelineActionクラスを用いて、Timelineエディタのコンテキストメニューに独自の処理を追加しています。各処理にはショートカットキーを割り当てることも可能です。以降のサンプルGIFでもショートカットキーを使用して処理を実行しています。
なおTimelineAction以外にも、TrackEditorやClipEditorを使用することで、トラックやクリップの描画をカスタマイズすることもできます。詳細については前述の記事が詳しいです。

1.再生ヘッドを選択したクリップの開始/終了位置に合わせる

標準機能では再生ヘッドをクリップにスナップさせることができません。この拡張機能は一見地味ですが、以下のような場合に役立ちました。

  • 特定のクリップの開始/終了位置からタイムラインを再生したい場合 (例: クリップの挙動を確認するために、クリップの始点付近から再生したい場合)
  • 異なるトラック間でのクリップのスナップが困難な場合 (例: 離れた位置にある別トラックのクリップ同士をスナップさせたい場合) に、スナップ用のガイドとして再生ヘッドを移動させたい場合
[MenuEntry("←再生ヘッドをクリップの開始位置に合わせる", 9000)]
public class AlignPlayheadClipStart : ClipAction
{
    public override ActionValidity Validate(IEnumerable<TimelineClip> clip)
    {

        //選択クリップ数が1より大きければ表示しない
        if (clip.Count() > 1) return ActionValidity.NotApplicable;
        else return ActionValidity.Valid;
    }

    public override bool Execute(IEnumerable<TimelineClip> items)
    {
        var window = TimelineEditor.GetWindow();
        window.playbackControls.SetCurrentTime(TimelineEditor.selectedClip.start, TimelinePlaybackControls.Context.Local);

        //タイムラインウインドウ更新
        TimelineEditor.Refresh(RefreshReason.ContentsModified | RefreshReason.SceneNeedsUpdate);

        return true;
    }

    [TimelineShortcut("再生ヘッドをクリップの開始位置に合わせる", KeyCode.LeftBracket)]
    public static void HandleShortCut(ShortcutArguments args)
    {
        Invoker.InvokeWithSelectedClips<AlignPlayheadClipStart>();
    }
}
[MenuEntry(" 再生ヘッドをクリップの終了位置に合わせる→", 9001)]
public class AlignPlayheadClipEnd : ClipAction
{
    public override ActionValidity Validate(IEnumerable<TimelineClip> clip)
    {
        //選択クリップ数が1より大きければ表示しない
        if (clip.Count() > 1) return ActionValidity.NotApplicable;
        else return ActionValidity.Valid;
    }

    public override bool Execute(IEnumerable<TimelineClip> items)
    {
        var window = TimelineEditor.GetWindow();
        window.playbackControls.SetCurrentTime(TimelineEditor.selectedClip.end, TimelinePlaybackControls.Context.Local);

        //タイムラインウインドウ更新
        TimelineEditor.Refresh(RefreshReason.ContentsModified | RefreshReason.SceneNeedsUpdate);

        return true;
    }

    [TimelineShortcut("再生ヘッドを選択したクリップの終了位置に合わせる", KeyCode.RightBracket)]
    public static void HandleShortCut(ShortcutArguments args)
    {
        Invoker.InvokeWithSelectedClips<AlignPlayheadClipEnd>();
    }
}

2.選択したクリップと一つ前のクリップを(疑似的に)結合する


GIFの動作を見るとクリップ同士を結合してるかのような挙動のように見えますが、実際には、選択したクリップを終了位置を記憶した上で消去し、前のクリップの終了タイミングに前述の終了位置を挿入するといった処理を行っています。分割されたクリップをまとめる際に使用しています。

[MenuEntry("前のクリップと結合する", 9010)]
public class BeforeAnimationClipCombine : ClipAction
{
    public override ActionValidity Validate(IEnumerable<TimelineClip> clip)
    {
        int clipCount = 0;
        foreach (var c in clip) { clipCount++; }

        //選択クリップ数が1より大きければ表示しない
        if (clipCount > 1) return ActionValidity.NotApplicable;

        //選択したクリップを取得
        TimelineClip targetClip = TimelineEditor.selectedClip;

        //クリップの所属トラックを取得
        TrackAsset currentTrack = targetClip.GetParentTrack();

        //最初のクリップでは表示するが実行できないようにする
        TimelineClip prevClip = null;
        foreach (var c in currentTrack.GetClips())
        {
            //ターゲットクリップと精査中のクリップが一致するか調べる
            if (targetClip.start == c.start)
            {
                //前回精査クリップが存在しない場合、トラック上の一番最初のクリップと見なす
                if (prevClip == null)
                {
                    return ActionValidity.Invalid;
                }
            }
            prevClip = c;
        }
        return ActionValidity.Valid;
    }

    public override bool Execute(IEnumerable<TimelineClip> items)
    {
        //選択したクリップを取得
        TimelineClip targetClip = TimelineEditor.selectedClip;

        //クリップの所属トラックを取得
        TrackAsset currentTrack = targetClip.GetParentTrack();

        //前のクリップを調べる Tips:GetClip()はトラックの並び順で取得できるみたい
        TimelineClip prevClip = null;
        foreach (var c in currentTrack.GetClips())
        {
            //ターゲットクリップと精査中のクリップが一致するか調べる
            if (targetClip.start == c.start)
            {
                //前回精査クリップが存在しない場合、トラック上の一番最初のクリップと見なして処理を中止する
                if (prevClip == null)
                {
                    Debug.LogError("前のクリップが存在しません");
                    return false;
                }
                //前回精査クリップとターゲットクリップのアニメーションクリップが異なる場合、処理を中止する
                if (targetClip.animationClip != null && prevClip.animationClip.name != targetClip.animationClip.name)
                {
                    Debug.LogError("前のクリップとアニメーションクリップが一致しません");
                    return false;
                }
                //前回精査クリップとターゲットクリップのタイムスケールが異なる場合、警告する
                if (prevClip.timeScale != targetClip.timeScale)
                {
                    Debug.LogWarning("前のクリップとタイムスケールが一致しませんが、処理は継続されます");
                }
                //ターゲットクリップの終了時間を取得
                var endTime = targetClip.end;

                //ターゲットクリップの削除
                currentTrack.timelineAsset.DeleteClip(targetClip);

                //前回精査クリップの終了時間を調整
                prevClip.duration = endTime - prevClip.start;

                //前回精査クリップのイーズアウトを削除
                prevClip.easeOutDuration = 0.0;
            }
            prevClip = c;
        }
        //タイムラインウインドウ更新
        TimelineEditor.Refresh(RefreshReason.ContentsModified | RefreshReason.SceneNeedsUpdate);
        return true;
    }
    [TimelineShortcut("前のアニメーションクリップと結合する", KeyCode.M)]
    public static void HandleShortCut(ShortcutArguments args)
    {
        Invoker.InvokeWithSelectedClips<BeforeAnimationClipCombine>();
    }
}

3.選択したクリップの開始/終了位置を再生ヘッドに揃える


複数のクリップの開始/終了タイミングを一発で調整できて便利です。
タイムラインの終わり際でクリップ群の終了タイミングを揃えたい時とかに使ってます。
処理後にクリップの長さを元に戻したい場合は、コンテキストメニューの「コンテントを合わせる」(ショートカットキー:C)を使用する必要があります。

[MenuEntry("選択したクリップの開始時間を再生ヘッド位置に揃える", 9005)]
public class AlignmentClipStartTime : ClipAction
{
    //コンテキストメニューの表示判定
    public override ActionValidity Validate(IEnumerable<TimelineClip> clip)
    {
        //選択クリップ数の取得
        int clipCount = 0;
        foreach (var c in clip) { clipCount++; }

        //選択クリップ数が0であれば表示しない
        if (clipCount <= 0) return ActionValidity.NotApplicable;

        // 選択されたクリップをトラックごとにグループ化
        var groupedClips = clip.GroupBy(c => c.parentTrack);

        // グループ数と選択されたクリップ数が一致するかどうかを確認
        if (groupedClips.Count() != clip.Count())
        {
            // 同じトラックに属するクリップがある場合は表示しない
            return ActionValidity.NotApplicable;
        }
        else
        {
            // すべてのクリップが異なるトラックに属している場合は表示
            return ActionValidity.Valid;
        }

    }

    public override bool Execute(IEnumerable<TimelineClip> items)
    {
        foreach (var c in items)
        {
            //再生ヘッドの位置の時間を取得
            var window = TimelineEditor.GetWindow();
            double currentTime = window.playbackControls.GetCurrentTime();

            //再生ヘッドがクリップの終了位置より奥にいる場合は処理をスキップ
            if (currentTime >= c.end)
            {
                Debug.LogWarning("再生ヘッドが選択したクリップの終了位置より奥または同じ位置にいるため、処理がスキップされました");
                continue;
            }
            //再生ヘッドとクリップの時間差分を取得、セット
            double timeDiff = c.start - currentTime;
            c.start = currentTime;
            c.duration += timeDiff;
        }
        //タイムラインウインドウ更新
        TimelineEditor.Refresh(RefreshReason.ContentsModified | RefreshReason.SceneNeedsUpdate);
        return true;
    }
    [TimelineShortcut("選択したクリップの開始時間を再生ヘッド位置に揃える", KeyCode.LeftBracket, ShortcutModifiers.Control)]
    public static void HandleShortCut(ShortcutArguments args)
    {
        Invoker.InvokeWithSelectedClips<AlignmentClipStartTime>();
    }
}
[MenuEntry("選択したクリップの終了時間を再生ヘッド位置に揃える", 9006)]
public class AlignmentClipEndTime : ClipAction
{
    //コンテキストメニューの表示判定
    public override ActionValidity Validate(IEnumerable<TimelineClip> clip)
    {
        //選択クリップ数の取得
        int clipCount = 0;
        foreach (var c in clip) { clipCount++; }

        //選択クリップ数が0であれば表示しない
        if (clipCount <= 0) return ActionValidity.NotApplicable;

        // 選択されたクリップをトラックごとにグループ化
        var groupedClips = clip.GroupBy(c => c.parentTrack);

        // グループ数と選択されたクリップ数が一致するかどうかを確認
        if (groupedClips.Count() != clip.Count())
        {
            // 同じトラックに属するクリップがある場合は表示しない
            return ActionValidity.NotApplicable;
        }
        else
        {
            // すべてのクリップが異なるトラックに属している場合は表示
            return ActionValidity.Valid;
        }

    }

    public override bool Execute(IEnumerable<TimelineClip> items)
    {

        foreach (var c in items)
        {

            //再生ヘッドの位置の時間を取得
            var window = TimelineEditor.GetWindow();
            double currentTime = window.playbackControls.GetCurrentTime();

            //再生ヘッドがクリップの開始位置より前にいる場合は処理をスキップ
            if (currentTime <= c.start)
            {
                Debug.LogWarning("再生ヘッドが選択したクリップの開始位置より前または同じ位置にいるため、処理がスキップされました");
                continue;
            }

            //再生ヘッドとクリップの時間差分を取得、セット
            double timeDiff = currentTime - c.end;
            double newDuration = c.duration + timeDiff;
            c.duration = newDuration;
        }

        //タイムラインウインドウ更新
        TimelineEditor.Refresh(RefreshReason.ContentsModified | RefreshReason.SceneNeedsUpdate);
        return true;
    }

    [TimelineShortcut("選択したクリップの終了時間を再生ヘッド位置に揃える", KeyCode.RightBracket, ShortcutModifiers.Control)]
    public static void HandleShortCut(ShortcutArguments args)
    {
        Invoker.InvokeWithSelectedClips<AlignmentClipEndTime>();
    }

}

4.クリップを前後に0.1秒ずらす


演出のタイミングの微調整に使用します。ここでは0.1秒単位で移動させていますが、必要に応じてこの値は調整可能です。複数のクリップを選択してまとめてずらすこともできます。

[MenuEntry(" クリップを後ろに0.1secずらす→", 8990)]
public class ShiftClipBack01 : ClipAction
{
    private const float ShiftAmount = 0.1f;
    public override ActionValidity Validate(IEnumerable<TimelineClip> clip)
    {
        //選択クリップ数が1以下なら表示しない
        if (clip.Count() < 1) return ActionValidity.NotApplicable;
        else return ActionValidity.Valid;
    }

    public override bool Execute(IEnumerable<TimelineClip> items)
    {
        if (items == null) return false;
        foreach (var item in items)
        {
            if (item != null) item.start += ShiftAmount;
        }
        return true;
    }

    [TimelineShortcut("クリップを後ろに0.1secずらす", KeyCode.RightArrow, ShortcutModifiers.Control)]
    public static void HandleShortCut(ShortcutArguments args)
    {
        Invoker.InvokeWithSelectedClips<ShiftClipBack01>();
    }
}
[MenuEntry("←クリップを前に0.1secずらす", 8991)]
public class ShiftClipFront01 : ClipAction
{
    private const float ShiftAmount = 0.1f;
    public override ActionValidity Validate(IEnumerable<TimelineClip> clip)
    {
        //選択クリップ数が1以下なら表示しない
        if (clip.Count() < 1) return ActionValidity.NotApplicable;
        else return ActionValidity.Valid;
    }

    public override bool Execute(IEnumerable<TimelineClip> items)
    {
        if (items == null) return false;
        foreach (var item in items)
        {
            if (item != null) item.start -= ShiftAmount;
        }
        return true;
    }

    [TimelineShortcut("クリップを前に0.1secずらす", KeyCode.LeftArrow, ShortcutModifiers.Control)]
    public static void HandleShortCut(ShortcutArguments args)
    {
        Invoker.InvokeWithSelectedClips<ShiftClipFront01>();
    }

}

5.クリップ期間を0.1秒増やす/減らす


これも微妙な演出タイミングの調整に。複数のクリップに対して同時に適用することも可能です。

[MenuEntry(" クリップ期間を0.1sec増やす→", 8992)]
public class ShiftClipExtend01 : ClipAction
{
    private const float ShiftAmount = 0.1f;
    public override ActionValidity Validate(IEnumerable<TimelineClip> clip)
    {
        //選択クリップ数が1以下なら表示しない
        if (clip.Count() < 1) return ActionValidity.NotApplicable;
        else return ActionValidity.Valid;
    }

    public override bool Execute(IEnumerable<TimelineClip> items)
    {
        if (items == null) return false;
        foreach (var item in items)
        {
            if (item != null) item.duration += ShiftAmount;
        }
        return true;
    }

    [TimelineShortcut("クリップ期間を0.1sec増やす", KeyCode.RightArrow, ShortcutModifiers.Alt)]
    public static void HandleShortCut(ShortcutArguments args)
    {
        Invoker.InvokeWithSelectedClips<ShiftClipExtend01>();
    }
}
[MenuEntry("←クリップ期間を0.1sec減らす", 8993)]
public class ShiftClipShort01 : ClipAction
{
    private const float ShiftAmount = 0.1f;

    public override ActionValidity Validate(IEnumerable<TimelineClip> clip)
    {
        //選択クリップ数が1以下なら表示しない
        if (clip.Count() < 1) return ActionValidity.NotApplicable;
        else return ActionValidity.Valid;
    }

    public override bool Execute(IEnumerable<TimelineClip> items)
    {
        if (items == null) return false;
        foreach (var item in items)
        {
            if (item != null) item.duration -= ShiftAmount;
        }
        return true;
    }

    [TimelineShortcut("クリップ期間を0.1sec減らす", KeyCode.LeftArrow, ShortcutModifiers.Alt)]
    public static void HandleShortCut(ShortcutArguments args)
    {
        Invoker.InvokeWithSelectedClips<ShiftClipShort01>();
    }
}

6.クリップのイーズイン/アウト期間を0.1sec増やす/減らす


これまた微妙な演出タイミングの調整に。曲のフェードのタイミング調整とか。複数のクリップに対して同時に適用することも可能です。

[MenuEntry(" クリップのイーズイン期間を0.1sec増やす→", 8994)]
public class AddClipEaseIn01 : ClipAction
{
    private const float ShiftAmount = 0.1f;

    //アクションが表示される条件を記述
    public override ActionValidity Validate(IEnumerable<TimelineClip> clip)
    {
        //選択クリップ数が1以下なら表示しない
        if (clip.Count() < 1) return ActionValidity.NotApplicable;
        else return ActionValidity.Valid;
    }

    //アクションの処理を記述
    public override bool Execute(IEnumerable<TimelineClip> items)
    {
        if (items == null) return false;
        foreach (var item in items)
        {
            if (item != null) item.easeInDuration += ShiftAmount;
        }
        return true;
    }

    //アクションのショートカット登録
    [TimelineShortcut("クリップのイーズイン期間を0.1sec増やす", KeyCode.Quote)]
    public static void HandleShortCut(ShortcutArguments args)
    {
        Invoker.InvokeWithSelectedClips<AddClipEaseIn01>();
    }
}
[MenuEntry("←クリップのイーズイン期間を0.1sec減らす", 8995)]
public class ShortClipEaseIn01 : ClipAction
{
    private const float ShiftAmount = 0.1f;

    //アクションが表示される条件を記述
    public override ActionValidity Validate(IEnumerable<TimelineClip> clip)
    {
        //選択クリップ数が1以下なら表示しない
        if (clip.Count() < 1) return ActionValidity.NotApplicable;
        else return ActionValidity.Valid;
    }

    //アクションの処理を記述
    public override bool Execute(IEnumerable<TimelineClip> items)
    {
        if (items == null) return false;

        foreach (var item in items)
        {
            if (item != null)
            {
                item.easeInDuration -= ShiftAmount;
            }
        }
        return true;
    }

    //アクションのショートカット登録
    [TimelineShortcut("クリップのイーズイン期間を0.1sec減らす", KeyCode.Semicolon)]
    public static void HandleShortCut(ShortcutArguments args)
    {
        Invoker.InvokeWithSelectedClips<ShortClipEaseIn01>();
    }
}
[MenuEntry(" クリップのイーズアウト期間を0.1sec増やす→", 8996)]
public class AddClipEaseOut01 : ClipAction
{
    private const float ShiftAmount = 0.1f;

    //アクションが表示される条件を記述
    public override ActionValidity Validate(IEnumerable<TimelineClip> clip)
    {
        //選択クリップ数が1以下なら表示しない
        if (clip.Count() < 1) return ActionValidity.NotApplicable;
        else return ActionValidity.Valid;
    }

    //アクションの処理を記述
    public override bool Execute(IEnumerable<TimelineClip> items)
    {
        if (items == null) return false;

        foreach (var item in items)
        {
            if (item != null)
            {
                item.easeOutDuration += ShiftAmount;
            }
        }
        return true;
    }

    //アクションのショートカット登録
    [TimelineShortcut("クリップのイーズアウト期間を0.1sec増やす", KeyCode.Semicolon, ShortcutModifiers.Control)]
    public static void HandleShortCut(ShortcutArguments args)
    {
        Invoker.InvokeWithSelectedClips<AddClipEaseOut01>();
    }
}
[MenuEntry("←クリップのイーズアウト期間を0.1sec減らす", 8997)]
public class ShortClipEaseOut01 : ClipAction
{
    private const float ShiftAmount = 0.1f;

    //アクションが表示される条件を記述
    public override ActionValidity Validate(IEnumerable<TimelineClip> clip)
    {
        //選択クリップ数が1以下なら表示しない
        if (clip.Count() < 1) return ActionValidity.NotApplicable;
        else return ActionValidity.Valid;
    }

    //アクションの処理を記述
    public override bool Execute(IEnumerable<TimelineClip> items)
    {
        if (items == null) return false;

        foreach (var item in items)
        {
            if (item != null)
                item.easeOutDuration -= ShiftAmount;
        }
        return true;
    }

    //ショートカット登録
    [TimelineShortcut("クリップのイーズアウト期間を0.1sec減らす", KeyCode.Quote, ShortcutModifiers.Control)]
    public static void HandleShortCut(ShortcutArguments args)
    {
        Invoker.InvokeWithSelectedClips<ShortClipEaseOut01>();
    }
}

おわり

Timelineエディタ拡張は実装も簡単なので、皆さんも是非色々試してみてください。
私のプロジェクトでは他にもPlayable APIに手を加えて加算アニメーショントラックを実装したりしたんですが、共有するにしても需要が無さそうなのと挙動が若干怪しいのでお蔵入りにします。

Discussion