
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.
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.
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.
; 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.
"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.
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.
"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.
"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:
#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.
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:
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:
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.
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.
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
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.
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.
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.
Analyzing repetitive steps
The first step was to write down a list of actions that we perform every time we turn on the render:
- Select a project folder
- Select the latest project versions for each LED surface (with the highest "_v" suffix)
- Fire up the project in Notch and wait about 20 seconds for it to run
- Click File -> Export Video
- Set the codec and resolution of the project
- Select the destination folder and adjust the suffix of the clip version
- Click Render
- We wait about 2-5 minutes for the render to complete
- We close the application and open another project for a different LED surface.
- 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 ^ ^)
; 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.
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:
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:
; 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:
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.
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.
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)
...
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)
}
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:
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:
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:
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.
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
:
; 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):
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
When creating the context menu, I connected the callback function ChangeNameMenu
to the menu item, which simply shows the other GUI:
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:
; 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:
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:
; Safety key
$Esc::
if ( STOPABLE = 1 ) {
MsgBox, Stopped script execution
ExitApp
} else {
Send, {Esc}
}
Return
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.
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.
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
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:
; 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.
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"
}
mediainfo
libraryGetMIOutputCMD = 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
:
; 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.
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
}
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.
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 <<<