How to best use API to create new sequences automatically?

I was experimenting creating Sequences via a script from a script. Err, I mean via C# from the screenplay script. Is there a cleaner way to write the code using the SequencesUtilty class? Some parts feel a bit hacky.

To use it, I open the window “Windows / Alan Tools / Make Sequences from Script”, paste my script into the text box (I write it in Google docs), then click the button at the bottom. In my script, I use “[4-1-2]” at the start of the line for each shot, where the ‘4’ is the episode (ignored - I assume the master sequence for episode 4 has already been created by hand), ‘1’ is the location number, and ‘2’ is the shot number. So parse all those numbers out, then go looking for the master sequence, then locations (if not previously created, create it now).

The idea is I can copy a new version of the script in any time and it creates all the missing sequences for me. I may put some more default objects in the sequences to start them off. E.g. I might in the script come up with a standard for referring to shot types etc, so reduce the manual effort to set things up. Not sure yet.

My Example Script

[4-10-010] My first shot
        -Sam-
    (angry)
WHAT ARE YOU TALKING ABOUT?

[4-10-020] My second shot. Close up on Sam.
        -Sam-
THAT MAKES NO SENSE AT ALL!

[4-20-010] First shot in second location
Put some big flowers in the corner of the room.
Camera tracks from Sam to the flowers.

Here is the code

using System;
using System.Collections;
using System.Collections.Generic;
using System.Text.RegularExpressions;
using UnityEditor;
using UnityEditor.Sequences;
using UnityEngine;
using UnityEngine.Playables;
using UnityEngine.Sequences;

public class MakeSequencesFromScript : EditorWindow
{
    [MenuItem("Window/Alan Tools/Make Sequences From Script")]
    public static void ShowWindow()
    {
        // Find or create window so it appears on the screen.
        EditorWindow.GetWindow<MakeSequencesFromScript>("Make Sequences From Script");
    }

    // The script the user supplies.
    private string script;

    void OnGUI()
    {
        script = GUILayout.TextArea(script, GUILayout.MaxHeight(10));
        if (GUILayout.Button("Make Missing Sequences"))
        {
            Debug.Log("Button Click");
            var shotReferences = ExtractShotReferencesFromScript(script);
            GameObject masterSequenceGO = FindMasterSequenceGO();
            CreateMissingSequences(masterSequenceGO, shotReferences);
        }
    }

    private void CreateMissingSequences(GameObject masterSequenceGO, string[] shotReferences)
    {
        MasterSequence masterSequence = masterSequenceGO.GetComponent<SequenceFilter>().masterSequence;
        TimelineSequence rootSequence = masterSequence.rootSequence;

        foreach (var tup in ExtractShotNumbersFromScript(shotReferences)) {
            string loc = "Location " + tup.Item1;
            string shot = "Shot " + tup.Item2;

            var locGO = masterSequenceGO.transform.Find(loc);
            if (locGO == null)
            {
                Debug.Log("Creating " + loc);
                SequenceUtility.CreateSequence(loc, masterSequence, null);
                locGO = masterSequenceGO.transform.Find(loc);
            }

            var shotGO = locGO.Find(shot);
            if (shotGO == null)
            {
                // TODO: This seems a bit ugly, but works. Need to find the TimlineSequence for the location.
                foreach (var locSequence in rootSequence.children)
                {
                    if (locSequence.name == loc)
                    {
                        Debug.Log("- Creating " + shot);
                        SequenceUtility.CreateSequence(shot, masterSequence, locSequence as TimelineSequence);
                    }
                }
            }
        }
    }

    private GameObject FindMasterSequenceGO()
    {
        var rootObjects = UnityEngine.SceneManagement.SceneManager.GetActiveScene().GetRootGameObjects();
        foreach (var go in rootObjects)
        {
            if (go.GetComponent<SequenceFilter>() != null)
            {
                return go;
            }
        }
        return null;
    }

// From here down is specific to my script format

    private static Tuple<string,string>[] ExtractShotNumbersFromScript(string[] shotReferences)
    {
        var regex = new Regex(@"-([^-]*)-([^]]*)");
        List< Tuple<string,string> > shotNumbers = new();
        foreach (var shot in shotReferences)
        {
            var match = regex.Match(shot);
            if (match.Success)
            {             
                shotNumbers.Add(new Tuple<string,string>(match.Groups[1].Value, match.Groups[2].Value));
            }
        }

        return shotNumbers.ToArray();
    }

    private static string[] ExtractShotReferencesFromScript(string script)
    {
        List<string> arr = new();
        var regex = new Regex(@"\n\[([^]]*)\]");
        foreach (Match match in regex.Matches(script))
        {
            arr.Add(match.Groups[1].Value);
        }
        return arr.ToArray();
    }
}
1 Like

Hey Alan, I'm tryin to look at your script this week see if I can help. I'll let you know! Don't be worry if I don't answer fast!

Here I have some proposition for your CreateMissingSequences function. The logic is mostly the same, it's just that instead of going through GameObject I directly use the sequence.children information.

private void CreateMissingSequences(GameObject masterSequenceGO, string[] shotReferences)
    {
        MasterSequence masterSequence = masterSequenceGO.GetComponent<SequenceFilter>().masterSequence;
        TimelineSequence rootSequence = masterSequence.rootSequence;
        foreach (var tup in ExtractShotNumbersFromScript(shotReferences)) {
            string locName = "Location " + tup.Item1;
            string shotName = "Shot " + tup.Item2;

            // Look into the rootSequence children to find the location we want.
            var loc = rootSequence.children.FirstOrDefault(seq => seq.name == locName) as TimelineSequence;
            var locAlreadyExists = loc != null;
            if (!locAlreadyExists)
            {
                Debug.Log("Creating " + locName);
                loc = SequenceUtility.CreateSequence(locName, masterSequence, null);
            }

            bool shotAlreadyExists = false;
            if (locAlreadyExists)
            {
                // The location was already there. Check if the sub-shot is also already there or not.
                // If the location has just been created (locAlreadyExists was false), there is no need to check the
                // sub-shot because it necessarily doesn't exist.
                var shot = loc.children.FirstOrDefault(seq => seq.name == shotName) as TimelineSequence;
                shotAlreadyExists = shot != null;
            }

            if (!shotAlreadyExists)
            {
                Debug.Log("- Creating " + shotName);
                var shot = SequenceUtility.CreateSequence(shotName, masterSequence, loc);
            }
        }
    }

I also have an alternative to your FindMasterSequenceGo. I propose you something that returns you the MasterSequence itself directly. This might not change much compare to what you're doing. It could actually even be slower but maybe more robust. As I guess it is only for you, I would say that you can stay with your own function.
Here is my proposition anyway:

IEnumerable<MasterSequence> GetMasterSequences()
    {
        var GUIDs = AssetDatabase.FindAssets("t:MasterSequence");
        foreach (var GUID in GUIDs)
        {
            var path = AssetDatabase.GUIDToAssetPath(GUID);
            var masterSequence = AssetDatabase.LoadAssetAtPath<MasterSequence>(path);
            yield return masterSequence;
        }
    }

    MasterSequence FindMasterSequence()
    {
        string msName = "MasterSequence 4"; // Not sure what's your naming convention for MasterSequences.

        foreach (var ms in GetMasterSequences())
        {
            if (ms.name == msName)
            {
                var rootObjects = UnityEngine.SceneManagement.SceneManager.GetActiveScene().GetRootGameObjects();
                var msGO = rootObjects.FirstOrDefault(go => go.name == msName && go.GetComponent<SequenceFilter>() != null);
                if (msGO == null)
                {
                    // You found the MasterSequence you wanted but it's not loaded in the current open scene.
                    return null;
                }

                return ms;
            }
        }

        return null;
    }

If you use this two last function, then you'll need to change a bit the signature of the CreateMissingSequences function to take a MasterSequence in input instead of a GameObject.

1 Like

Thanks Erika! I merged in your code snippets - all working. (I ended up not using your GetMasterSequences() though as it was searching through the whole asset directory rather than the one in the current scene that was open. I do not (yet) have a consistent naming scheme for the master sequence to filter it with, so I start from the one in the currently open scene.)

But I had another question if I may. I was trying to change the length of created sequences. It presently defaults to 2 seconds, then puts them one after the other in the parent timelines. I was trying to change the default lengths to 10 seconds (a more reasonable length for my project). I assume after creating a new sequence I should go to the parent sequence timeline, find the Sequence track, find the clip in that track for the newly created child sequence, change the duration of the clip to extend it out longer, then save the change so the next creation starts from that new position. From the debug statements I think I have this all going, but the end result is everything is still 2 seconds long after the script runs. I suspect I am not “saving” the new length correctly, so the clip duration change gets lost. Or are there other steps I need to do?

(Once I have that working, I want to rejig the length of the master sequence reference to the child sequences as well - they are all starting out at 2 seconds so I have to hand adjust it. But there are only a few, so this is not such a big deal. Manually positioning 50 shot sequences because they are all too short is more annoying.

Or is there a better way to change the default length of sequences that get created?

According to the Sequences version I presume you're using, you should have access to all that through the TimelineSequence object you get when you create a new Sequence.

I'm drafting that (not tested) but:

var shot = SequenceUtility.CreateSequence(shotName, masterSequence, loc);
var clip = shot.editorialClip;
clip.duration = 10; // It is already in seconds

// What you might miss at this step is "saving" the timeline asset or at minimum flag it as dirty.
EditorUtility.SetDirty(seq.timeline); // With seq being shot's parent sequence
// Or
EditorUtility.SetDirty(clip.timeline); // This is the same timeline as the line above, it's the timeline in which the editorial clip is

// Then you can let it this way, and it will be saved when you Save your Unity project. Or you can save the asset programmatically by calling:
AssetDatabase.SaveAssetIfDirty(seq.timeline);
// Or
AssetDatabase.SaveAssets();

I hope I'm winging the function names correctly, if you still have trouble I'll put more time to actually test a piece of code to do what you need!

Also, just to precise, in this code example I'm starting from the last call to CreateSequence in your function CreateMissingSequences but of course you can change the duration (and also the start time if you want) of the editorial clip of any sequence you create through SequenceUtility.CreateSequence.

Also, I started with "according to the Sequences version you're using" because we're modifying the API. But if you want to update I'll help you update your script as well if needed!

Lol! Well, realizing editorialClip existed deleted about 20 lines of code immediately! (I was finding the parent, looking through the tracks, finding the right clip in the track, ...). And yes, using SetDirty() solved the problem. Thank you!

In case useful to anyone else who comes across this thread, here is the revised CreateMissingSequences function. It creates a shot sequence, then extends the new shot clips to 10 seconds, and extends the parent clip duration so everything is included and packed in nicely.

    private void CreateMissingSequences(MasterSequence masterSequence, string[] shotReferences)
    {
        TimelineSequence rootSequence = masterSequence.rootSequence;

        foreach (var tup in ExtractShotNumbersFromScript(shotReferences)) {
            string locName = "Location " + tup.Item1;
            string shotName = "Shot " + tup.Item2;
            Debug.Log(locName + " - " + shotName);

            var loc = rootSequence.children.FirstOrDefault(seq => seq.name == locName) as TimelineSequence;
            if (loc == null)
            {
                Debug.Log("Creating " + loc);
                loc = SequenceUtility.CreateSequence(locName, masterSequence, null);
            }

            var shot = loc.children.FirstOrDefault(seq => seq.name == shotName) as TimelineSequence;
            if (shot == null)
            {
                Debug.Log("- Creating " + shotName);
                shot = SequenceUtility.CreateSequence(shotName, masterSequence, loc);

                // Set the shot to 10 seconds long
                shot.editorialClip.duration = 10;
                EditorUtility.SetDirty(shot.timeline);
                AssetDatabase.SaveAssetIfDirty(shot.timeline);

                // Extend the parent clip length so it fits.
                if (shot.editorialClip.start + shot.editorialClip.duration > loc.editorialClip.duration)
                {
                    loc.editorialClip.duration = shot.editorialClip.start + shot.editorialClip.duration;
                    EditorUtility.SetDirty(loc.timeline);
                    AssetDatabase.SaveAssetIfDirty(loc.timeline);
                }
            }
        }
    }
1 Like