Thomas Winged

hero image

Notch AHK Workflow Automation


Me and AHK

AutoHotkey is a compelling scripting language with some pretty twisted syntax. I'm still determining when I first used its capabilities, but I know there will come a time again when it will suddenly prove to be the last resort in saving my day. I have never learned to use it decently, and, thinking ahead, I don't intend to, precisely because of the syntax. Nevertheless, I've had the pleasure of creating all sorts of scripts, from simple macros and mouse clickers that automate daily routines to GUI programs for more complicated particular tasks. As I've had many adventures with it, I'd like to write about them on my blog, leaving helpful code snippets and describing their capabilities and uses.

Background

The workflow automation I'll describe here is a set of scripts of varying levels of complexity that I prepared during my spare free time while working on a particular TV production around mid-2020. No one required me to do this kind of work. I was ultimately there only to process the graphics I received from another graphics team, using a program called Notch so that we could display and modify them using real-time technology during stage rehearsals.

However, realizing at some point how much repetitive work our team was doing, I saw the potential to use my programming skills. Initially, I started writing down the repetitive steps and translating them into simple macros in AHK. After a while, when my companions noticed that my computer was moving the cursor, copying text between windows and doing the monotonous work for me while eating a sandwich, I heard for the first time: "Which witchcraft is this? I want it!". And so began my adventure in developing this automation kit. I created it for many weeks adding more micro-optimizations to speed up the work by seconds. At some point, using it became so apparent that no one could imagine working without this toolkit. We used this automation for a couple of editions until I decided I wanted to move away from that strange AHK syntax. I preferred to rewrite it in Python more transparently while learning a new programming language, which is now my favorite. But that's a topic for another post.

To understand where the problems in the existing workflow came from, let's first consider this sample stage design made out of LED screens that I illustrated for the sake of this entry. As you can see, the stage consists of 6 physical LED screens combined into four logical groups - Center, Wings, Stripes, and Roof.

stage_preview.webp

Sample design of a scene

Wanting to display dedicated real-time graphic content on each of these surfaces, as I've spent good years in the world of event and concert pre-production, it was natural for me to take advantage of the benefits of a media server. It allows proper real-time content management, displays it on specific screens, and manages its position, mapping, color correction, visualization parameters, etc. There are many graphics programs designed for real-time content creation. Many of them also have a set of media server tools in them, so they can also handle multiple outputs of different resolutions and compositions simultaneously on their own - an example would be Smode or even the good old Resolume Arena.

The Notch program, which the production has decided to use, is an enjoyable and powerful graphics program designed to create real-time procedural content. As I had the pleasure of learning it years ago and am still using it, I was very eager to join this project. What I didn't know, however, was that the production, to our misfortune, left out one critical piece of the puzzle: the use of a media server such as d3/disguise to manage Notch's real-time rendered content. Instead, it chose another media server incompatible with this solution - Watchout. From that point on, the image of a pleasant assignment became a technical nightmare with each passing second.

What were the consequences of this decision? We debated for a long time on how to combine Notch's capabilities with Watchout. Ultimately, we concluded that we could send output from the Notch application via the NDI protocol, as Watchout fortunately natively supported it. However, we had to work with four heavy Notch instances simultaneously to display the image on four logical LED groups. This solution caused a lot of impracticality in creating and managing virtual scenery. First, we could not easily share assets between projects and update them with newer versions. We had to wade through the thicket of visually very similar application windows. And on top of that, there was no mechanism to synchronize their playback. It was easy to conclude that this solution was far from being practical. It all sounded unpleasant but doable.

At the very end, there remains one major flaw in this workflow. During a typical production with Notch involved, the graphic designer creates so-called Notch Blocks launched directly on media servers such as d3/disguise to display content in real-time and control the provided scenery parameters. It's like launching a subprocess that renders graphics and passes a buffer directly to the media server to minimize latency with the ability to control the visualization through the provided API. The Watchout media server was unable to take advantage of such blocks. There was also no decent way to synchronize Notch instances fired up on a separate computer with other stage devices such as pyrotechnics, lights, and the media server. During stage rehearsals, we could afford to synchronize them manually by clicking "Alt + Tab + Space" as fast as we could on all Notch instances. We controlled parameters such as graphic accents using a connected MIDI controller. How to use it during a live show without a proper media server? There was nothing left to do but... render the content. On top of that, the render had to be divided into looped sections to let the Watchout operator adjust content to any changes in the art director's vision - so we had to prepare the projects in a particular way. This inconvenience meant producing hundreds of gigabytes of heavy HAP renders (a format that Watchout supported), which, as it turned out later, production expected to be ready to play on the media server each subsequent morning. In short, the day was a time of stage rehearsals, preparing and adjusting real-time content during each break, and later, the night was a laborious rendering of our work. The Notch application did not have (and still does not have, as far as I know) any mechanism for queuing the rendering of projects stored in separate project files. To squeeze out time, we delegated different workstations that mainly handled the rendering of the tasks we modified during the day. If you've ever complained about the repetitiveness and pointlessness of your work, imagine being an organic Render Queue Manager.

Module 1: Hotkeynator

Each module had a different use. Initially, Hotkeynator was a collection of "I need this feature ASAP" scripts. It came with a set of mouse-clickers and macros for setting, for example, the appropriate project settings, such as the output resolution, output signal name, or project prefix name. It also removed the nodes inside Notch responsible for displaying the pixel map and inserted a new updated one.

Hotkeynator.webp

Hotkeynator.ahk - message box popping up after firing the script - simple as that

Basics of AHK - move cursor, click it and type some text

In order not to go into the details of such a simple script, you can see a sample snippet of the mechanics below - it consisted mainly of essential functions like MouseMove, MouseClick, and Send. Notch sometimes needed more time to process the action, so I used the Sleep functions in some places.

Hotkeynator.ahk - general idea
; Click [Project]
Mousemove, % CFG.Coords.ProjectMenu.X, % CFG.Coords.ProjectMenu.Y, 1
MouseClick, left

; Click [Settings]
Mousemove, % CFG.Coords.ProjectSettingsMenu.X, % CFG.Coords.ProjectSettingsMenu.Y, 1
MouseClick, left

; Wait for window to appear
WinWaitActive, % CFG.WindowNames.ProjectSettings

; Click [Rendering]
Mousemove, % CFG.Coords.ProjSetRendering.X, % CFG.Coords.ProjSetRendering.Y, 1
MouseClick, left

; Click [Output Width] textbox
Mousemove, % CFG.Coords.ProjSetOutWidth.X, % CFG.Coords.ProjSetOutWidth.Y, 1
MouseClick, left

; Set resolution X
Send, ^a
clipboard := CFG.Surfaces[SurfaceName].Resolution.X
ClipWait
Send ^v

; Click [Output Height] textbox
Mousemove, % CFG.Coords.ProjSetOutHeight.X, % CFG.Coords.ProjSetOutHeight.Y, 1
MouseClick, left

; Set resolution Y
Send, ^a
clipboard := CFG.Surfaces[SurfaceName].Resolution.Y
ClipWait
Send ^v

Config

To explain the elements of codes like CFG.Coords.ProjSetOutHeight.X, I need to step into the topic of the configuration file for a moment. Since the Notch's UI was written in such a way that I was unable to send an event directly to the button using the ControlClick command, all cursor clicks had to be defined using screen/window coordinates. And since each team member had their own GUI layout, sometimes different screen resolutions (and, in my case, a foreign system language), I created a JSON config where we could include all these variables for a simplified setup.

config.json - list of coordinates
"Coords": {
  "BinEntry": {
    "X": 1668,
    "Y": 150
  },
  "BrowseMissingRes": {
    "X": 443,
    "Y": 249
  },
  "DevicesMenu": {
    "X": 170,
    "Y": 45
  },
  ...
}

We read the locations of specific elements using AHK's built-in Window Spy script. I prepared a set of images (example shown below) to inform which coordinates have what name assigned to them and where to get their position from.

ProjSetOutResizing.webp{:height 558, :width 977}

Relevant coordinates for mouse-clicker.

In addition to the coordinates, the config also contained the specification of LED screens and their associated prefixes, stream output names, and bin names containing the current pixelmap for reference.

config.json - specification of projects for each LED group
"Surfaces": {
  "Main": {
    "Name": "Main",
    "RenderLetter" : "M",
    "NDI": "Main",
    "PixelmapBinName": "_MainPixelMap2021_v02",
    "FilenameMark": "_Main_",
    "Resolution": {
      "X": 4800,
      "Y": 1120
    },
    "PresetPosition" : 1
  },
  "Back": {
    "Name": "Back",
    "RenderLetter" : "B",
    ...
  },
  ...
}

It also included window names, prefixes, and suffixes to allow the AHK script to find its way around the system and other settings, such as paths to log folders and flags for additional configuration of the RenderBuddy module, which I'll write about later.

221103090431-mVLsP1QLdh.webp

Example of Notch window names
config.json
"WindowNames": {
  "MainEditorWindow": "- Notch Builder",
  "DevVideoSettings": "Video In/Camera/Kinect Settings",
  "FindMissingRes": "Autosave Recovery",
    ...
},

"OmitExportNameVersioning": 1,
"DisplayOldProjectVersions": 0,
"DefaultRenderTime": "02:20:00",
"DefaultLoopTransitionFrames": 75,
"RenderPath": "D:\\RENDER",
"PrintScreenDirectory": "\\Notch\\Notch Screenshots\\",
"LogFilePath": "\\Notch\\RenderBuddy.log",
"TemporaryFolder": "\\Notch\\Temporary Folder\\",
 ...

With the off-the-shelf AHK module to load JSON files (https://github.com/cocobelgica/AutoHotkey-JSON), I was able to read and use the config easily:

Hotkeynator.ahk - loading JSON configuration file and usage example
#Include include\JSON.ahk
...

FileRead, ConfigFile, include\config.cfg
CFG := JSON.Load(ConfigFile)
...

PlacePixelmap(SurfaceName) {
    global CFG
    ...

    ; Paste Bin name
    Send, ^a
    clipboard := CFG.Surfaces[SurfaceName].PixelmapBinName
    Send, ^v
    Sleep, % CFG.Slowness * 3
    ...

Module 2: Renamer

Another module that has proven to save a lot of time is the module that renames project directories. We named each virtual scenography using the ID_SceneName syntax. Each project directory contained a Notch subdirectory in which we kept project files. It also had a Footage subdirectory where we kept the video clips, images, and other assets used in that scene. While the ID of a scene was unique, their SceneName often changed - because the name was often taken from the title of the song planned to be used, and the art director often decided to switch them while keeping the visuals. Simply changing the name of the scene directory broke links to external media used in Notch, such as video clips stored in the Footage subfolder. And since Notch always referenced them using an absolute path (fortunately, it's no longer a problem in the latest versions of Notch), most simply, it stopped displaying them.

And so, the need arose for another script that would rename the projects and then launch them one by one would click a sequence in each of them that relinks each asset to its new location.

Simple GUI with drag-and-drop functionality

For simplicity, I made a small window where you defined a new scene name and then drag-and-drop the project folder. In the code snippet below, you will see vNewProjectName - this is the variable where the text written in the UI goes into. The letter v is required by the AHK syntax.

Renamer.webp

Renamer.ahk - simple UI with drag-and-drop functionality
Renamer.ahk - constructing the UI
Gui Margin, 0,0
Gui, Add, Text, x10 y10 w300, New name:
Gui, Add, Edit, x70 yp-3 w200 vNewProjectName, GiveMeNewNamePlease
Gui, Add, Text, x10 y40 w260 r5 0x201 Border, Drag project folder here
Gui, Add, Text, x10 y120 w300, Instruction:`n ...

Gui Show, w280 h205, AMICI Renamer

The drag-and-drop mechanics itself was contained in a few simple lines. The use of the Loop function isn't pretty in this case. Still, assuming that I expect only one folder, I took the liberty of iterating through all possibly dragged files and selecting only the last one for further processing:

Renamer.ahk - drag and drop mechanism
GuiDropFiles:
    Gui, Submit, NoHide

    Loop, parse, A_GuiEvent, `n
    {
        DroppedFolder := A_LoopField
    }

    GoSub, ProcessFolder
return

Then the ProcessFolder function iterated through all the project files renaming them. At the end, it passed the array with the paths of the project files to next subroutine:

Renamer.ahk - renaming directories
ProcessFolder:
    NotchFolder := DroppedFolder . "\Notch"

    ...

    ; Rename main folder
    FileMoveDir, %DroppedFolder%, %NewProjectFolderPath%
    NotchFolder := NewProjectFolderPath . "\Notch"

    ; Get all dfx files paths
    OldPathsDFX := []
    Loop %NotchFolder%\*.dfx, 0
    {
        OldPathsDFX.Push(A_LoopFileFullPath)
    }
    ...

    NewNamesDFX := []
    ; Rename old project names to new project names
    for index, element in OldNamesDFX {
        NewNamesDFX.Push(StrReplace(element, OldProjectName, NewProjectName))
    }
    ...

    ; Rename DFX files
    for index, element in OldPathsDFX {
        FileMove, %element%, % NewPathsDFX[index]
    }

    GoSub, FindMissingResources
Return

Then each project was launched one by one, and the relinking combination of each asset was clicked in it. This workflow sounds trivial, but during the day, just renaming projects took at least half an hour. As you'll notice, I used the ControlFocus function here, even though I wrote earlier that the Notch application was not designed to operate directly on buttons and controls. In this case, however, the relinking assets window appears to be a native Windows window.

Renamer.ahk - relinking missing footage
FindMissingResources:
    ...

    ; Click on "Find Missing Resources"
    MouseMove,  % CFG.Coords.FindMissingRes.X, % CFG.Coords.FindMissingRes.Y, 1
    MouseClick, Left

    ; Wait for [Autosave Recovery] window to appear
    WinWaitActive, % CFG.WindowNames.FindMissingRes, , 1
    if ErrorLevel
    {
        ; If could not summon this window it means that there are no missing resources
        ; Just skip the rest
    }
    else
    {
        for index, directory in FootageDirectories {
            clipboard := directory

            ; Click BROWSE button
            MouseMove, % CFG.Coords.BrowseMissingRes.X, % CFG.Coords.BrowseMissingRes.Y, 1
            MouseClick, Left

            ; Wait for [Selezione cartella] window to appear
            WinWaitActive, % CFG.WindowNames.SelectFolder

            ; Focus on path edit box and paste path
            WinGetActiveTitle, Title
            ControlFocus, Edit2, %Title%
            Send, ^v

            ; Focus on OK button and click it
            ControlFocus, Button1, %Title%
            Send, {Enter}
            WinWaitClose        
        }

        ; Close [Autosave Recovery] window
        Send, {Enter}
    }
Return

Module 3: Renovator

As the resolutions of LED screens and folder paths changed in successive production editions (e.g., the main folder EventName2021 was renamed EventName2022), Renovator was our helper in reaching out to archived versions of projects from previous editions. To mass recycle old scenographies, we needed to update those projects.

And out of that need came a module that opened each project, removed the old pixel map, added a new one, changed resolution settings, relinked assets, and cleaned the project of unused assets. This came in handy, especially in the early stages of production. We needed to dig up hundreds of scenes in just a few days and adjust them to the changed scene parameters and folder names before the actual pre-production began.

Renovator.ahk - general idea
ProcessEveryFile:
    Loop, % ProjectFilesToRenovate.MaxIndex() {
        CurrentPath := ProjectFilesToRenovate[A_Index].Path
        CurrentSurfaceName := ProjectFilesToRenovate[A_Index].Name

        SplitPath, CurrentPath, name, dir, ext, name_no_ext

        Run, % CurrentPath

        ; Wait for project file to open
        WinWaitActive, % name_no_ext

        WindowName = %name% - Notch Builder

        WinMaximize, % WindowName

        GoSub, RemoveOldPixelmap
        GoSub, RecenterNodegraph
        GoSub, SetResolutionNDIAndPixelmap
        GoSub, FindMissingResources
        GoSub, RemoveUnusedResources
    }

    MsgBox, , AHK Renovator, FINISHED! `nCHECK, SAVE and CLOSE.
Return

Renovator.webp

Renovator.ahk - a minimalist window where users drag-and-drop project files to make an update

Module 4: RenderBuddy

At the very end, I left the icing on the cake - the most extensive and valuable module that saved us sleepless nights - the module for queuing renders.

RenderBuddy.webp

RenderBuddy.ahk - interface for queuing renders
RenderBuddy.ahk - in action

Manual rendering

As I mentioned in the introduction, rendering the projects took countless hours. Using a spreadsheet on Google Sheets, we noted which scenographies and surfaces had been updated during testing and needed to be re-rendered. Sometimes the output clips needed to be looped, and sometimes they needed to be set to a specific length. What did the loop length depend on? To a large extent, it relied on the video clips that were used, as they were looped by the previous graphics team (imagine a video of an infinitely swinging swing, you can't just cut it at any moment and pretend it's still fun to ride on it). Before firing up the render, the standard was to analyze these clips to assess their length. If we were lucky and all the clips used were the same length, for example, 10 seconds (by the way, length in Notch is expressed in a count of Frames), it was known that the looped render should also last 10 seconds. And what if the clips used were of different lengths, for example, 50 and 70 Frames? After a couple of hours of work in a dark and noisy production hall, not everyone felt like reaching for a calculator to count Least Common Multiple. Back then, we often poured over the idea of looping renders, setting the length of the output clips so that during the show, there was no way they would finish and cause the content to stutter on the LED screens. However, this always resulted in gigantic output file sizes and longer render times.

checking_duration.webp

In Notch, you can read the length of the clip expressed in Frames count

Setting the render parameters itself was very repetitive. We had to select the correct codec, change the destination path taking into account file versioning (v1, v2, v3, ...), set the render length, and make sure the resolution was fetched correctly from the project.

setting_up_render.webp

Notch - render settings window

Analyzing repetitive steps

The first step was to write down a list of actions that we perform every time we turn on the render:

  1. Select a project folder
  2. Select the latest project versions for each LED surface (with the highest "_v" suffix)
  3. Fire up the project in Notch and wait about 20 seconds for it to run
  4. Click File -> Export Video
  5. Set the codec and resolution of the project
  6. Select the destination folder and adjust the suffix of the clip version
  7. Click Render
  8. We wait about 2-5 minutes for the render to complete
  9. We close the application and open another project for a different LED surface.
  10. Go to step 4.

First simple prototype

I wrote a simple clicker fitting into less than 500 lines of code that performed the desired operations after the user drag-and-drop project files onto a simple script's UI. The automation was slow, sometimes it lost focus on the Notch window causing clicks on the desktop, often moving icons and firing up some random program (to be safe, at least I made sure Notch was active before clicking the Delete button ^ ^)

RenderBuddy.ahk - safety first!
; Make sure Notch window is active
if not WinActive("Notch Builder (x") { Return }

Despite its many imperfections, the script significantly accelerated the rendering process. It was not yet the moment to put it in the hands of my co-workers. The code was not yet structured, often duplicated, and global variables appeared everywhere.

render_buddy_v1.webp

RenderBuddy.ahk - first prototype of UI

Going step by step, after drag-and-dropping the project folder on the GUI, one by one, the latest versions of the projects had to be selected, which mainly consisted of dividing the files into surface types and then sorting them by name:

221031092900-RhzLBEi0Z9.webp

example of versioning of Notch project files
RenderBuddy.ahk - prototype of choosing newest version of project files
GuiDropFiles:
    Loop, parse, A_GuiEvent, `n
    {
        Folder := A_LoopField

        MainLedArray := []
        BackLedArray := []
        FloorArray := []
        AudienceLedArray := []

        Loop %Folder%\*.dfx, 0
        {
            SplitPath, A_LoopFileLongPath, name, dir, ext, name_no_ext

            If InStr(name, "_Main_") {
                MainLedArray.Push(name)
            } Else If InStr(name, "_Back_") {
                BackLedArray.Push(name)
            } Else If InStr(name, "_Floor_") {
                FloorArray.Push(name)
            } Else If InStr(name, "_Audience_") {
                AudienceLedArray.Push(name)
            }
        }

        ; Choose only the newest files
        Sort, MainLedArray
        MainLedNewest := MainLedArray[MainLedArray.MaxIndex()]
        Sort, BackLedArray
        BackLedNewest := BackLedArray[BackLedArray.MaxIndex()]
        Sort, FloorArray
        FloorNewest := FloorArray[FloorArray.MaxIndex()]
        Sort, AudienceLedArray
        AudienceLedNewest := AudienceLedArray[AudienceLedArray.MaxIndex()]

The script would then fire up each project and retrieve each LED surface's setting specifications using an ugly if-else tree. Then click through the window to select the correct values, start the render and wait for it to finish, finally killing the application and firing up the following project:

RenderBuddy.ahk - prototype and dirty way of rendering projects
; Run the project
Run %AudienceLedNewest%

; Get window title and wait for it
SplitPath, AudienceLedNewest, name, dir, ext, name_no_ext
WinWaitActive, %name%
Sleep 1000

; Prepare render settings and then click Render button
CURRENT_SCREEN = AudienceLed  ; Used in FetchScreenProperties
GoSub, FetchScreenProperties
GoSub, PrepareRender
MouseClick, Left

; Wait until exporting process ends then close Notch project
Sleep 10000
WinWaitActive, %name%

; Make sure Notch window is active
if not WinActive("Notch Builder (x") { Return }

Sleep 2000
Send, !{F4}
Sleep 2000

Writing next version - starting from config

After showing the operation of the prototype script to colleagues, I collected feature requests and started developing the next version. This time it was supposed to be more like a queue manager. I created the first seed of a configuration structure placed directly in the script (it was before I made the config in JSON). Because the AHK syntax is so messed up, it took me a while to get it right:

RenderBuddy.ahk - second version of config structure and it's strange syntax
cfg := { "":""
    ,"Surfaces" : [ {}
        ,{ "":""
            ,"FileSurfaceName" : "Main"
            ,"RenderLetter": "M"
            ,"ResolutionX" : 4816
            ,"ResolutionY" : 1252
            ,"NDIName" : "MainLed"
            ,"PresetPosition" : 6 }
        ,{ "":""
            ,"FileSurfaceName" : "Back"
            ,"RenderLetter": "B"
            ,"ResolutionX" : 1920
            ,"ResolutionY" : 1120
            ,"NDIName" : "BackLed"
            ,"PresetPosition" : 7 }
        ...
        , {}]

    ,"DisplayOldProjectVersions" :0
    ,"DefaultRenderTime" : "02:20:00"
    ,"DefaultLoopTransitionFrames" : 75
}

Implementing list view

What would a file manager be without the ability to select and edit objects using a list view? It's time for a decent UI improvement.

221028092201-LFq5XqRyj6.webp

Second version of RenderBuddy - it began to look like a render queue manager

To add a clickable list to the UI, you need to use the ListView control. Define a callback function that will handle mouse click events (using "primary g-label notifications" in AHK syntax, gHandleQueueLV means a reference to the HandleQueueLV function). And define the name of the control (vQueueLV) from which you can, for example, read the selected rows. In the end, just set the width of the columns.

RenderBuddy.ahk - constructing ListView
Gui, 1: Add, ListView, x10 y25 w700 h320 gHandleQueueLV AltSubmit Checked Grid vQueueLV, |ID|Scene ID|Surface|Scene Name|Moment| ...

LV_ModifyCol(1, 20)
LV_ModifyCol(2, 20)
LV_ModifyCol(3, 60)
...

RenderBuddy.ahk - adding entry to list view
AddFileToListView(F) {
    Gui, ListView, QueueLV ; Choose ListView for manipulation
    LV_Add(Checked, "", F.ID, F.SceneID, F.Surface, F.SceneName, F.Moment, F.VersionInt, F.Duration, F.Loop, F.RenderName)
}

RenderBuddy.ahk - fetching selected row IDs
GetSelectedRows() {
    global SelectedRows
    SelectedRows := []
    SelectedRow := 0

    Gui, ListView, QueueLV ; Choose ListView

    Loop
    {
        SelectedRow := LV_GetNext(SelectedRow)
        if (SelectedRow = 0) { break }
        SelectedRows.Push(SelectedRow)
    }
}

Drag-and-drop mechanism and item preprocessing

Drag and drop an item onto this menu requires some preprocessing. First and foremost, I need to handle files and folders differently:

RenderBuddy.ahk - preprocessing drag-and-dropped item
GuiDropFiles:
    Loop, parse, A_GuiEvent, `n
    {
        SplitPath, A_LoopField, name, dir, ext, name_no_ext

        if InStr(FileExist(A_LoopField), "D") {
            ProcessFolder(A_LoopField)
        } else if (ext == "dfx") {
            ProcessProjectFile(A_LoopField)
        }
    }

    ; Handle older project versions
    global CFG
    if !CFG.DisplayOldProjectVersions
        DiscardOlderVersions()
Return

ProcessFolder(FolderPath) {
    Loop %FolderPath%\Notch\*.dfx, 0
    {
        ProcessProjectFile(A_LoopFileFullPath)
    }
}

And now, I can preprocess every project file, split its name into ID, name, surface tag, version, and so on and use this data to fill the list view. For managing project information data, I created a simple ProjectFile class with a following logic:

RenderBuddy.ahk - class for managing project file information
Class ProjectFile {
    ID := 0
    SceneID := 0
    SceneName := "-"
    Surface := "-"
    SurfaceTag := "-"
    ...

    ; Fill the rest information based on config on the top of script file
    FetchConfigInfo() {
        ...
        ; Fill resolution and NDI name info
        if (This.SurfaceTag == "M") {
            This.ResolutionX := cfg.MainResolutionX
            This.ResolutionY := cfg.MainResolutionY
            This.NDIName := cfg.MainNDI
            This.PresetPosition := cfg.MainPresetPosition
        } else if (This.SurfaceTag == "B") {
            ...
        }
        ...
    }

    ChangeOutputFolder(OutputFolder) {
        this.RenderFolderPath := OutputFolder . "\" . This.SceneID . "_" . This.SceneName
        this.RenderFilePath := this.RenderFolderPath . "\" . this.RenderName
        ...
    }

    ; Ugly but it's working
    ChangeInformation(Property, NewValue) {
        if (Property = "SceneName") {
            this.SceneName := NewValue
        } else if (Property = "Duration") {
            this.Duration := NewValue
        } else if (Property = "Loop") {
            this.Loop := NewValue
        }
    }
}

And with this structure, I could finally process dropped files onto the GUI and display project files information using the list view:

RenderBuddy.ahk - processing project file and submitting to list view
ProcessProjectFile(FilePath) {  
    global ProjectFiles
    global cfg

    ; Create new ProjectFile object
    CurrentFile := new ProjectFile

    ; Assign file ID for this queue
    if (ProjectFiles.MaxIndex() < 1) {
        CurrentFile.ID := 1
    } else {
        CurrentFile.ID := ProjectFiles.MaxIndex() + 1
    }

    CurrentFile.FilePath := FilePath

    SplitPath, FilePath, name, dir, ext, name_no_ext
    CurrentFile.FileName := name_no_ext ; Example: 1069_GottaDance_Floor_A_v01

    SplittedString := StrSplit(name_no_ext, "_")
    CurrentFile.SceneID := SplittedString[1]
    CurrentFile.SceneName := SplittedString[2]
    CurrentFile.Surface := SplittedString[3]

    CurrentFile.Duration := cfg.DefaultRenderTime

    ...

    ; Remove leading "0" from version number
    CurrentFile.VersionInt := LTrim(CurrentFile.VersionInt, "0")

    ; Fill the rest information based on config on the top of script file
    CurrentFile.FetchConfigInfo()

    ; Put ProjectFile object to global array with other ProjectFile's
    ProjectFiles.Push(CurrentFile)

    AddFileToListView(CurrentFile)
}

Implementing right-click context menu

I created a context menu to manipulate item properties under the right mouse button. To construct such a menu, you must first build it using the syntax: Menu <menu_name>, Add, <text>, <callback>. Right-clicking on a row in ListView now allows you, for example, to modify the name of the final render.

RenderBuddy.ahk - constructing context menu
Menu RightClickMenu, Add, Remove, DeleteEntryMenu
Menu RightClickMenu, Add, Change name, ChangeNameMenu
Menu RightClickMenu, Add, Change duration, ChangeDurationMenu
Menu RightClickMenu, Add, Switch loop mode, SwitchLoopModeMenu
Menu RightClickMenu, Add, Debug Entry, DebugEntryMenu

And then trigger them in the g-label notification subroutine defined when registering the ListView:

RenderBuddy.ahk - g-label notification function handling user control events
; Define event control of queue ListView
HandleQueueLV:
    if (A_GuiEvent = "RightClick") {
        GetSelectedRows()

        MouseGetPos, MX, MY
        Menu, RightClickMenu, Show, %MX%, %MY%
    }
Return

Changing item's name

To change the item name, I created a small popup window with a place to specify the replacement name. Let's construct this window first. Notice that to create a new menu, I need to set a different GUI ID (2 in this case):

RenderBuddy.ahk - constructing item renamer window
Gui, 2: Add, Text, x5 y9, New name:
Gui, 2: Add, Edit, x65 y5 w200 vNewNameInput
Gui, 2: Add, Button, x270 y5 gChangeNameButton, OK
Gui, 2: Margin, 0,0

RenderBuddy_rename_item.webp

RenderBuddy.ahk - rename item menu

When creating the context menu, I connected the callback function ChangeNameMenu to the menu item, which simply shows the other GUI:

RenderBuddy.ahk - defining logic for "Change name" context menu button clicked
ChangeNameMenu:
    Gui, 2: Default ; Set this menu default for current thread
    Gui, 2: Show, w300 h30
Return

All that was left was to hook up the logic to the OK button's event handler (g-label notification function gChangeNameButton). I assumed that the user should be able to rename multiple items at once, hence the decision to use the loop function:

RenderBuddy.ahk - defining logic for OK button inside GUI 2 clicked
; Define what happens when clicked RENAME button inside GUI 2
ChangeNameButton:
    ; Get edit box content and hide gui
    Gui, 2: Submit, Hide

    ; Apply name to selected rows
    if (NewNameInput) {
        LoopCount := SelectedRows.MaxIndex()

        while (LoopCount)
        {
            ProjectFiles[SelectedRows[LoopCount]].ChangeInformation( "SceneName", NewNameInput)
            LoopCount--
        }
    }

    RefreshEntires()
Return

Implementing multiple checkbox selection

Another critical feature was selecting the project files to be rendered. It seemed most intuitive to me to create checkboxes on the left side of the item's entry. I kept the list of selected items in the global variable CheckedRows, and I described the mechanism itself for retrieving information about which row was established in the function:

RenderBuddy.ahk - getting checked rows IDs
GetCheckedRows() {
    global CheckedRows
    CheckedRows := []

    ; Select Queue ListView
    Gui, ListView, QueueLV

    Loop % LV_GetCount()
    {
        ; https://www.autohotkey.com/board/topic/80262-check-which-checkboxes-are-unchecked/
        Gui +LastFound ; Make the GUI window the last found window for use by the line below
        SendMessage, 4140, A_Index - 1, 0xF000, SysListView321 ; 4140 is LVM_GETITEMSTATE.  0xF000 is LVIS_STATEIMAGEMASK
        IsChecked := (ErrorLevel >> 12) - 1 ; This sets IsChecked to true if RowNumber is checked or false otherwise

        if IsChecked
            CheckedRows.Push(A_Index)
    }
}

Let's render!

Having already all the necessary information, it's high time to render! But first - safety! During rendering, I decided to use the ESC button as an emergency button - pressing it caused the script to shut down. To define when this button interrupts the automation and when it should be ignored, I created a STOPABLE flag, using it to handle the button press:

RenderBuddy.ahk - safety key
; Safety key
$Esc::
    if ( STOPABLE = 1 ) {
        MsgBox, Stopped script execution
        ExitApp
    } else {
        Send, {Esc}
    }
Return

RenderBuddy.ahk - begining of logic behind RENDER button
RenderButton:
    Gui, 1:Submit, NoHide ; Save GUI control values like "Loop transition frames" to variables

    ; Get checked checkboxes, skip if not selected any items
    GetCheckedRows()
    if CheckedRows.MaxIndex() < 1
        return

    ; Get count of projects in queue
    MaxRenderProgress := CheckedRows.MaxIndex()

    Gui, 1:Hide ; Hide Gui
    STOPABLE = 1
    GoSub, ShowRenderProgressWindow

To display progress, I constructed an additional window that overlayed on top of everything. It was not clickable or focusable. Its only purpose was to inform about the number of elements remaining to be rendered and about allowed interaction with the script while it's working.

RenderBuddy.ahk - constructing overlay render info window
Gui, 3: Color, 0xFF0000
Gui, 3: +E0x20 -Caption +LastFound +ToolWindow +AlwaysOnTop

Gui, 3: Font, s30
Gui, 3: Add, Text, x220 y70 cWhite, RENDERING IN PROGRESS
Gui, 3: Font, s14
Gui, 3: Add, Text, x80 y150 cWhite vRenderProgressCharacters, % MakeProgressBar(80, 0, 100)
Gui, 3: Add, Text, x460 y180 w100 cWhite vRenderProgressString, % 0 . " / " . 0
Gui, 3: Add, Text, x360 y220 cWhite, Press ESC to stop script execution.

; Make it unclickable, not focusable and transparent
WinSet, Transparent, 150

...

MakeProgressBar(CharactersCount, Progress, MaxProgress) {
    CharacterProgress := Progress / MaxProgress * CharactersCount
    CharacterLeft := CharactersCount - CharacterProgress

    OutputString := "["
    Loop, %CharacterProgress% {
        OutputString := OutputString . "#"
    }
    Loop, %CharacterLeft% {
        OutputString := OutputString . "="
    }
    OutputString := OutputString . "]"

    Return OutputString
}

The ShowRenderProgressWindow subroutine used in RenderButton function spawned the overlay window while setting up the render progress text.

RenderBuddy.ahk - displaying progress overlay window without focusing on it
ShowRenderProgressWindow:
    ; Reset the counter of currently rendered files
    RenderProgress := 0

    Gui, 3: Default
    GuiControl, Text, RenderProgressString, % RenderProgress . " / " . MaxRenderProgress
    Gui, 1: Default

    ; Show render window progress
    Gui, 3:Show, w1024 h256 NoActivate
Return

RenderBuddy-warning_overlay.webp{:height 359, :width 915}

Overlay warned my coworkers not to touch the mouse while the script was working. In the previous version, the overlay said, "Do not touch! Really! Otherwise, PC WILL EXPLODE!" but I changed it after I saw they were afraid to use the script : P

Continuing with the logic behind the RENDER button, after the progress window is shown, it's time to cycle through all the selected projects, firing them off one by one, clicking the button combinations, filling in the values stored in the project structures (ProjectFile) and waiting for the render to finish:

RenderBuddy.ahk - iterating through chosen project files and rendering them one by one
; Process all checked project files
for index, value in CheckedRows
{
    PROJECT := ProjectFiles[value]
    ...
    Run % PROJECT.FilePath
    WinWaitActive, % PROJECT.FileName

    FillRenderSettings(PROJECT) ; Macro for filling render properties in Notch UI
    ...
    MouseClick, Left ; Click on RENDER button

    ; Wait until exporting process ends then close Notch project
    WinWaitActive, % PROJECT.FileName

    ; Make sure Notch window is active
    if not WinActive("Notch Builder (x") {
        Return
    }

    Send, !{F4}

    ...

    ; Increment rendering progress
    RenderProgress++
    GuiControl, Text, RenderProgressCharacters, % MakeProgressBar(80, RenderProgress, MaxRenderProgress)
    GuiControl, Text, RenderProgressString, % RenderProgress . " / " . MaxRenderProgress
}

; Rendering finished, hide overlay window, show output render folder
Gui, 3:Hide
STOPABLE = 0
Gui, 1: Default
Gui, 1: Show
Run, %OutputFolderPath%

Looping video with FFMPEG

As I already mentioned, some render needed to be looped to save disk space and time. As I already knew an FFmpeg library for some time, I decided to use it for this task. I found an elegant filter on StackOverflow that created a seamless looping video using a simple crossfade transition. I run the command using the RunWait function. We still needed to decide manually on the duration of the final video. At least the crossfade was smoothing out fine temporal movement on the video, like blinking stars or overall brightness changes of the visuals at the beginning and end of the clip. Using this approach, I had to encode the video two times, losing a little bit of its quality - the first by making an intermediate crossfaded video and the second by converting it to the final HAP codec.

RenderBuddy.ahk - crossfading single video using ffmpeg library
; https://stackoverflow.com/questions/38186672/ffmpeg-make-a-seamless-loop-with-a-crossfade
if ( PROJECT.Loop == "YES" ) {
    LoopDuration := ConvertSecondsToFPS( PROJECT.Duration, cfg.FPS ) / cfg.FPS
    LoopDurationPlusOne := LoopDuration + 1
    TransitionTime := LoopTransitionFrames / cfg.FPS
    LoopDurationNTransition := TransitionTime + LoopDuration + 1
    SourceFilepath := PROJECT.RenderFilePath
    TempFilepath := PROJECT.RenderTmpFilePathLoop
    OutputFilepath := PROJECT.RenderFilePathLoop

    ; Command for blending two files together with transition at the beginning of video file
    LoopCMD = ffmpeg -y -i %SourceFilepath% -filter_complex "[0]pad=ceil(iw/4)*4:ceil(ih/4)*4[o];[o]split[tran][body];[body]trim=1:%LoopDurationPlusOne%,setpts=PTS-STARTPTS,format=yuva420p,fade=d=%TransitionTime%:alpha=1[jt];[tran]trim=%LoopDurationPlusOne%:%LoopDurationNTransition%,setpts=PTS-STARTPTS[main];[main][jt]overlay" %TempFilepath%
    RunWait, %ComSpec% /c %LoopCMD%

    ; Reconvert output file to HAP codec
    ConvertToHAPCMD = ffmpeg -i %TempFilepath% -c:v hap %OutputFilepath%
    RunWait, %ComSpec% /c %ConvertToHAPCMD%

    ; After convertion remove temporary files
    FileDelete, %TempFilepath%
}

Next, I implemented an analysis of used video clips to calculate the Least common multiple of the duration of these clips. This gave us recommended loop duration length that would produce a seamless transition between the end and beginning of a loop. I used the MediaInfo library to analyze video clips. First, I iterated over all used video clips inside the Footage folder and extracted video properties:

mediainfo output part that I was interested in:
{
    "@type": "Video",
    "StreamOrder": "0",
    "ID": "1",
    "Format": "nclc",
    "CodecID": "nclc",
    "Duration": "1.040",
    "BitRate_Mode": "VBR",
    "BitRate": "791438",
    "Width": "256",
    "Height": "256",
    "PixelAspectRatio": "1.000",
    "DisplayAspectRatio": "1.000",
    "Rotation": "0.000",
    "FrameRate_Mode": "CFR",
    "FrameRate": "25.000",
    "FrameCount": "26",
    "StreamSize": "102887",
    "Language": "en"
}

RenderBuddy.ahk - extracting video properties using mediainfo library
GetMIOutputCMD = mediainfo --output=JSON "%FootagePath%"
MIOutput := RunWaitOne(GetMIOutputCMD)

; Get frame count
MIOutput := SubStr(MIOutput, InStr(MIOutput, "FrameCount"))
this.FrameCount := StrSplit(MIOutput, ",")[1]
this.FrameCount := SubStr(this.FrameCount, 15, -1)

; Get duration
this.Duration := ConvertFPSCountToNotchTime(this.FrameCount, CFG.FPS)

As you see, in order to read output of a command and process it inside my script, I had to use RunWaitOne function that executes a single command via WshShell object and returns output from stdout:

RenderBuddy.ahk - assigning command output into a variable
; https://stackoverflow.com/questions/44791916/assigning-command-output-into-a-variable
RunWaitOne(command) {
    l("[FUNC]: RunWaitOne(command=" . command . ")")

    ; WshShell object: http://msdn.microsoft.com/en-us/library/aew9yb99
    shell := ComObjCreate("WScript.Shell")
    ; Execute a single command via cmd.exe
    cmd = %ComSpec% /C %command%
    exec := shell.Exec(cmd)
    ; Read and return the command's output
    return exec.StdOut.ReadAll()
}

Now, having all duration of video clips, I could calculate the Least Common Multiple of all video durations. In case the recommended period was too long, I just hard-limited it to 2 minutes and 20 seconds, as single shows usually did not last longer.

RenderBuddy.ahk - iterating over media properties in order to calculate LCM
GetOptimalLoopFramesDuration(MediaUsed) {
    if (MediaUsed.MaxIndex() < 1)
        return 0 ; Skip if no video clips were used

    global SceneInfo

    VideoDurations := []
    for index, video_info in MediaUsed {
        VideoDurations.Push(video_info.FrameCount)
    }

    ; https://www.autohotkey.com/boards/viewtopic.php?t=39956
    UniqueDurations := RemoveDuplicates(VideoDurations)

    LeastCommonMultiple := LCM(UniqueDurations)

    ; Loop longer than 02m:20s is no use for us
    if (LeastCommonMultiple > 3500) {
        LeastCommonMultiple := 3500
    }

    return LeastCommonMultiple
}

RenderBuddy.ahk - calculating Least Common Multiple
LCM(InputArray) {
    ; Copy input array
    element_array := []
    for i, e in InputArray
        element_array.Push(e)

    lcm = 1
    divisor = 2 

    while True
    {
        counter = 0
        divisible := False

        for i, e in element_array {
            if (element_array[i] == 0) {
                return 0
            } else if (element_array[i] < 0) {
                element_array[i] := element_array[i] * (-1)
            }

            if (element_array[i] == 1) {
                counter := counter + 1
            }

            if (mod(element_array[i], divisor) == 0) {
                divisible := True
                element_array[i] := element_array[i] / divisor
            }
        }

        if (divisible) {
            lcm := lcm * divisor
        } else { 
            divisor := divisor + 1
        }

        if (counter == element_array.Length()) { 
            return lcm
        } 
    } 
}

Used media ListView

Another feature I decided to implement was a list of used media clips that displayed information about each video clip inside the Footage directory. Sometimes not all clips inside that folder were actually used inside the project, as nobody was cleaning this directory. At that time, I was unaware of how to check which media were used, but that changed next year when writing the Python version of the same Automator (more about this in my future post). Using this list view, it was easy to just take a peak at the properties of all video files and remember which clips were actually used to verify recommended optimal loop duration. To extract all this information, I used MediaInfo in the same way I extracted the duration of the video explained above. I will not go into details this time, and I will just put an image of that panel below.

RenderBuddy_mediainfo.webp

RenderBuddy.ahk - media info list view

Conclusions

Looking at this code after years and comparing it to my current skills in Python, I find it kind of unintuitive and chaotic. The code is a mixture of functional and object-oriented programming, and many global variables are used. But that doesn't detract from the fact that this program was one of the most elaborate I wrote at the time, which makes me still proud that it worked and served its purpose so well : ) Working on it revealed to me the practical face of programming that is the workflow optimization. It was a beautiful moment to see my coworkers liberated from the complexity of processes and instead allowed to focus on the real problem. Since then, I have been eager to nurture this programming mindset and look for more opportunities to optimize other inconveniences.

Links and files

GitHub repository: >>> LINK <<<