Predicting Starlight Completion Time / Real Time Backup

zmon - bash script to predict schedule of Video AI processing

How do you know when your Starlight processing will be complete? Are the processes still running OK? Will you have enough storage space? Is there a backup in case output files get lost?

This is what my little script attempts to answer. It’s written for macOS using bash. Hand-written (no AI) by a programmer who had to unlearn programming to understand shell scripting (!).

This is going to be a long post, there is a lot I have to explain!

Why did I write this?

  • Video AI does not display the correct time to completion for Starlight models. I was looking at the logs to try to figure out when the video would be complete
  • During beta testing (including macOS 27 beta), I have experienced crashes and (un)recoverable errors driving to losing days of processing
  • I was wasting too much time keeping an eye on multiple machines

What does the script do?

  • Predicts when the final video will be available by calculating the time between batches of frames
  • Predicts disk space required and monitor space available
  • Automatic real-time backup of frames generated in separate folder
  • Monitors critical processes (Topaz Video AI and neuroserver)
  • Color coded status, progress, # frames completed, seconds/frame…
  • Monitors local machine and/or multiple remote machines
  • Consolidates all outputs in one location
  • Automatically start monitoring next video in queue after completion
  • Automatically detects Release or Beta versions of Video AI
  • No setup required, just save the script and run. Optional parameters=names of remote machines

Example output:

Machine Video Status Estimated Sec/fr Done (%) / Total Last update Required/avail
dom14 fnm SYNC Thu 03 04:20:11 10.8spf 11890 (36%) / 32627 18m 58s ago 49 / 158 GB

Requirements

  • macOS only, I guess it could be adapted to Windows, but I am now less familiar with the syntax and capabilities
  • Works with Video AI 1.6, 1.7 release and beta versions
  • Video AI must be setup to export frames as PNG images
  • Video AI should create a subfolder with the name of the video file, and name the frames “video_frame#.PNG” under the sub-folder (which should be the default behavior)
  • There has to be an output folder chosen ideally in a different location (so it doesn’t get erased by bugs), and a backup folder. My typical setup has 3 folders under my main Video AI folder. Of course, you can change this!
    • src will contain video files (source)
    • work will be setup as the output folder
    • backup folder will receive frames from the work folder
  • If you want to monitor multiple machines from one screen, you have to create ssh key pairs to automatically SSH into the other machines. But you don’t have to!

How to get the video built from the PNG files? Well, I have written another script that I will post in a separate message.

How to use?

  1. copy/paste and save the script in a location on your mac, make it executable (chmod +x)
  2. start the script
  3. optional: if you want to monitor multiple systems, enter the name of these systems as parameters

The script will attempt to locate Video AI log files, see what video is in progress, find the working folder, create a backup folder and start monitoring and doing its backup. After a few rounds of frame generation, you will start seeing the estimated time when your video will be ready

My backup strategy is to check every minute if there are new frames. Then I use rsync to copy the frames in the backup folder. I decided to create subfolders in the backup folder. Each folder will contain up to 1000 frames. When a subfolder is complete (exactly 1000 frames), I remove the frames from the output folder to avoid duplication.

Why 1000 frames per folder? I don’t have enough space to store 100,000+ frames on my machines, so I have set up the backup folder to point to my NAS. Browsing gets very slow on network folders with a huge amount of files in them!

I will be happy to explain the design of the script in future posts if you have questions, there is a lot of stuff in there. I also imagine your needs could be different and you might want to adapt or remove some portions that you find unnecessary for your workflow.

Thank you for reading until this point. I hope someone will find it useful!

Best regards,

Dom

This is the script itself, please copy/paste:

#!/bin/bash

zcheck() { 		# script will execute locally or remotely via ssh

	modiftime() { echo $(date -j -f "%Y-%m-%d %H:%M:%S" "$(stat -f "%Sm" -t "%Y-%m-%d %H:%M:%S" "$1")" +%s); }	# modification time of a folder
	unixtime()  { echo $(date -j -f "%Y-%m-%d %H-%M-%S" "${1%\.*}" +%s); }										# converts time to EPOCH time
	regex()     { if [[ $line =~ $1 ]]; then echo ${BASH_REMATCH[1]}; fi; }										# returns match of regular expression

# readlog:	reads the log file to determine output location, video being processed, frames already generated, # of frames expected (goal)
#			if no video is being processed, initializes VIDEO OUTPUT_ROOT STATUS CURRENT_OUTPUT CURRENT_BACKUP GOAL SPF FINISH AGE EARLIER NEED AVAIL TOPAZ NEURO
#			OUTPUT_ROOT		location of output folder
#			STATUS			STARTED when video processing has started
#			CURRENT_OUTPUT	last frame number found in log file
#			SPF				seconds per frame calculated based on content of log file

	readlog()	{
		local SOURCE STARTED LATEST LOG_RELEASE LOG_BETA LOGFILE
		read -r VIDEO OUTPUT_ROOT STATUS CURRENT_OUTPUT CURRENT_BACKUP GOAL SPF FINISH AGE EARLIER NEED AVAIL <<< "- - - - - - - - - - - - - -"
		
		LOG_RELEASE=~/Library/Application\ Support/Topaz\ Labs\ LLC/Topaz\ Video/logs
		LOG_BETA=~/Library/Application\ Support/Topaz\ Labs\ LLC/Topaz\ Video\ BETA/logs
		if [[ -d $LOG_RELEASE && -d $LOG_BETA ]]; then	# find latest log folder
			if [[ $(modiftime "$LOG_BETA") -gt $(modiftime "$LOG_RELEASE") ]]; then
				TOPAZLOG=$LOG_BETA
			else
				TOPAZLOG=$LOG_RELEASE
			fi
		elif [[ -d $LOG_BETA ]]; then
			TOPAZLOG=$LOG_BETA
		elif [[ -d $LOG_RELEASE ]]; then
			TOPAZLOG=$LOG_RELEASE
		else
			echo "$MAC cannot find Video AI log folder" >&2
		fi

		if [[ -d "$TOPAZLOG" ]]; then
			LOGFILE="$TOPAZLOG"/$(ls "$TOPAZLOG" | tail -n -1)								# find last log file from Topaz folder
			if [[ -f $LOGFILE ]]; then
				STARTED=0
				LATEST=0
				while IFS= read -r line; do													# loops through content of log file
					if [[ $line == *program* ]]; then
						SOURCE=$(regex "--input-path\", \"([^\"]+)")						# gets name of source video
						VIDEO="$(basename "${SOURCE%.*}")"									# get name without extension
						OUTPUT_ROOT=$(regex "--output-path\", \"([^\"]+)")					# gets output folder
						OUTPUT_ROOT=$(dirname "$OUTPUT_ROOT")
						OUTPUT_ROOT=${OUTPUT_ROOT%/*}
						GOAL=$(($(regex "--end-frame-idx\", \"([0-9]+)\"")+1))				# gets last frame number + 1
						STARTED=$(unixtime "${line% Thread:*}")								# starting time, from first frame
						STATUS="STARTED"
						CURRENT_OUTPUT="-"
						LATEST=0
						SPF="-"
					elif [[ $line == *"\"Processing\"}}"* ]]; then
						CURRENT_OUTPUT=$(regex "\"frame\":\ ([0-9]+),")						# gets number of last frame processed
						LATEST=$(unixtime "${line% Thread:*}")								# gets time of last frame processed
					fi
				done < <(grep -E 'Thread:.*(program|Processing)' "$LOGFILE")				# filter log file for faster processing
				if [[ $CURRENT_OUTPUT != "-" && $CURRENT_OUTPUT -gt 0 && $STARTED -gt 1 ]]; then
					SPF=`bc -S 1 -e "($LATEST-$STARTED)/$CURRENT_OUTPUT"`					# calculate sec per frame based on log file for now
				fi
			fi
		fi
	}

# checkprocesses:	check if Topaz and neuroserver processes
#					TOPAZ	%mem utilization of Topaz Video process (beta or release)
#					NEURO	%mem utilization of neuroserver
#					STATUS	PAUSED when low activity, DEAD if process not active

	checkprocesses()	{
		local PS TOPAZ NEURO
		read -r TOPAZ NEURO <<< "- -"														# will store results from ps command
		PS=$(ps -ax -o %mem,command | awk -F- '/Topaz/ && !/tail|grep/ {print $1}')	
		while read -r line; do
			if [[ "$line" == *"Topaz Video BETA" ]]; then TOPAZ="${line%% *}"; fi			# returns %mem utilization for Topaz Video BETA process
			if [[ "$line" == *"Topaz Video" ]]; then TOPAZ="${line%% *}"; fi				# returns %mem utilization for Topaz Video process
			if [[ "$line" == *"neuroserver" ]]; then NEURO="${line%% *}"; fi				# returns %mem utilization for neuroserver process
		done <<< "$PS"
		if [[ $STATUS != "DONE" ]]; then
			if [[ $TOPAZ == "-" || $NEURO == "-" ]]; then
				STATUS="DEAD"																# Video AI or neuroserver not running
			elif [[ $(echo "$NEURO < 0.3" | bc ) -eq 1 ]]; then
				STATUS="PAUSED"																# neuroserver does not consume memory, paused?
			fi
		fi
	}

# checkspace:	makes sure there will be enough space available to store all frames in backup folder
#				if backup folder is a link to a shared folder, it's considered "infinite"
#				STATUS	LOWSPACE is less than 10GB available for output, NOSPACE if not enough space for backup
#				NEED	space required in GB for backup
#				AVAIL	space currently available for backup

	checkspace() {
		local USED
		if [[ -d "$OUTPUT_FOLDER" ]]; then
			read -r USED _ < <(du -k "$OUTPUT_FOLDER")											# gets spaced utilized by folder
			if [[ $OUTPUT_COUNT -gt 0 ]]; then
				NEED=$(bc -S 0 -e "($USED/$OUTPUT_COUNT)*($GOAL-$OUTPUT_COUNT)") 				# calculate space required based on # frames and space already used
				NEED=$(bc -S 0 -e "($NEED+524288)/1048576")										# round up and converts to GB
				AVAIL=$(df -g "$BACKUP_FOLDER" | awk 'NR==2 {print $4;exit}')					# exit is required in case df returns multiple entries !!!
				if [[ $NEED -ge $AVAIL ]]; then
					STATUS="NOSPACE"															# no more space available to store future frames
				elif [[ $(df -g "$OUTPUT_FOLDER" | awk 'NR==2 {print $4;exit}') -lt 10 ]]; then
					STATUS="LOWSPACE"															# less than 10GB of space available for future frames
				fi
			fi
		fi
	}

# backup frames:	uses rsync to backup frames by 1000s
#					video_000 will contain frames 000000-000999
#					video_001 will contain frames 001000-001999
#					video_XXX will contain frames XXX000-XXX999
#					BACKUP_COUNT	new count of frames in backup folder
#					STATUS			DONE, SYNC, > or < if discrepancy between output and backup

	backupframes() {
		local FIRST_FRAME FOLDER
		if [[ $CURRENT_BACKUP == "-" ]]; then												# just started,  count all frames in backup folders
			BACKUP_COUNT=0
			FOLDER_COUNT=$(($(ls -f "$BACKUP_FOLDER/$FOLDER" | wc -l)-2))					# get an count of backup folders
			echo -n "$FOLDER_COUNT folders to scan ..." >&2
			for i in $(seq 0 $FOLDER_COUNT); do												# backup folders contain up to 1000 frames for efficiency
				echo -n " $i" >&2
				FOLDER="${VIDEO}_$(printf "%03s" $i)"										# name of folder = video _ first 3 digits of frame numbers (thousands)
				if [[ -d $BACKUP_FOLDER/$FOLDER ]]; then
					((BACKUP_COUNT += $(ls -f "$BACKUP_FOLDER/$FOLDER" | wc -l)-2))	# get a count of frames in backup folders
				fi
			done
		else
			BACKUP_COUNT=$CURRENT_BACKUP													# already looping, we know how many backup frames we already have
		fi
		FIRST_FRAME=$(ls "$OUTPUT_FOLDER" | head -n 1)										# get first frame from output folder
		if [[ -f $OUTPUT_FOLDER/$FIRST_FRAME ]]; then										# if we have a first frame (folder is not empty)
			if [[ $FIRST_FRAME =~ _0*([1-9][0-9]*)\. ]]; then								# if Video AI was restarted or if frames were erased in output folder, first frame might be >0
				FIRST_FRAME=${BASH_REMATCH[1]}
			else
				FIRST_FRAME=0
			fi
		else
			FIRST_FRAME=0
		fi
		OUTPUT_COUNT=$(($(ls -f "$OUTPUT_FOLDER" | wc -l)-2))								# counts files in folder, need to remove . and .. from count
		for i in $(seq $((FIRST_FRAME/1000)) $(((OUTPUT_COUNT+FIRST_FRAME)/1000))); do		# sync up to last frame generated
			FOLDER="${VIDEO}_$(printf "%03s" $i)"
			if [[ ! -d $BACKUP_FOLDER/$FOLDER ]]; then
				mkdir "$BACKUP_FOLDER/$FOLDER" >&2											# creates backup folder for new frames
			fi
			if [[ -d $BACKUP_FOLDER/$FOLDER ]]; then
				if find "$OUTPUT_FOLDER" -maxdepth 1 -type f -name "${FOLDER}*" -print -quit 2>/dev/null | grep -q .; then
					FOLDER_COUNT1=$(($(ls -f "$BACKUP_FOLDER/$FOLDER" | wc -l)-2))			# how many frames in current folder?
					rsync -rtuvq --stats "$OUTPUT_FOLDER/$FOLDER"* "$BACKUP_FOLDER/$FOLDER" >&2
					FOLDER_COUNT2=$(($(ls -f "$BACKUP_FOLDER/$FOLDER" | wc -l)-2))			# count again, sync might have added new files
					DIFF=$((FOLDER_COUNT2-FOLDER_COUNT1))									# calculates # of frames added in backup folder
					(( BACKUP_COUNT+=DIFF ))												# adds frame difference
					if [[ $DIFF -gt 0 ]]; then
						MSG="$MSG +$DIFF"													# will display how many frames are added to backup
					fi
					if [[ $FOLDER_COUNT2 -eq 1000 ]]; then									# when 1000 frames are generated...
						rm "$OUTPUT_FOLDER/$FOLDER"* >&2									# output folder can be removed since it's in backup
						MSG="$MSG -#$i"														# will display -# with the folder number
					fi
				fi
			fi
		done
		if [[ $BACKUP_COUNT -gt 0 ]]; then
			if [[ $BACKUP_COUNT -ge $GOAL ]]; then
				STATUS="DONE"																# we're DONE, next loop will check for new queued file by reading logs
			elif [[ $OUTPUT_COUNT -eq $FOLDER_COUNT2 ]]; then
				STATUS="SYNC"
			elif [[ $BACKUP_COUNT -gt $FOLDER_COUNT2 ]]; then								# if process was restarted, backup contains more frames
				STATUS=">$((FOLDER_COUNT2 - OUTPUT_COUNT))"									# status will indicate how many more frames are in backup
			else
				STATUS="<$((OUTPUT_COUNT - FOLDER_COUNT2))"									# backup not working properly?
			fi
		fi
		CURRENT_BACKUP=$BACKUP_COUNT														# saves current frame in backup folder
	}

# estimatefinish:	calculates date/time of completion
#					STATUS	INACTIVE if no new frame has been generated after expected time, calculated based on time to generate 150 frames
#					SPF		updated calculation of second per frame

	estimatefinish() {
		if [[ $SPF != "-" ]]; then
			REMAIN=`bc -S 0 -e "($SPF * ($GOAL-$BACKUP_COUNT)) / 1"`						# calculates # of seconds needed to get remaining frames
			FINISH=$(date -r $(( OUTPUT_TIME + REMAIN )) '+%a %d %H:%M:%S')					# convert into estimated finish date/time
			if (( $(echo "$AGE > $SPF * 150" | bc -l) )); then 								# expecting ~100 frames per batch + 50% margin
				STATUS="INACTIVE"															# if no new frames available, process must be stalled
			fi
		fi
		if [[ $EARLIER == "-" ]]; then														# remembers previous time for next batch
			EARLIER=$OUTPUT_TIME
		elif [[ $OUTPUT_COUNT -gt $CURRENT_OUTPUT ]]; then
			NEWSPF=`bc -S 1 -e "($OUTPUT_TIME-$EARLIER)/($OUTPUT_COUNT-$CURRENT_OUTPUT)"`	# calculate new SPF based on previous and current batch
			if (( $(echo "$NEWSPF > 0.5" | bc -l) )); then SPF=$NEWSPF; fi					# only saves new SPF if meaningful
			EARLIER=$OUTPUT_TIME															# saves time of update for next loop
		fi
		CURRENT_OUTPUT=$OUTPUT_COUNT														# saves current frame in output folder
	}
	
# checknewframes:	if process monitoring has started, look at # of files in OUTPUT and BACKUP folders
#					calls backupframes and estimatefinish if new frames have been generated more than 20s ago
#					AGE		time since last frame generation
#					SPF		updated calculation of second per frame
#					STATUS	DONE, SYNC, INACTIVE, >, <, NOFOLDER if folder has been destroyed by process

	checknewframes() {
		BACKUP_ROOT="$(dirname "$OUTPUT_ROOT")/backup"										# calculates name of root backup folder
		OUTPUT_FOLDER="$OUTPUT_ROOT/$VIDEO"
		BACKUP_FOLDER="$BACKUP_ROOT/$VIDEO"
		if [[ -d $OUTPUT_FOLDER ]]; then
			if [[ ! -d $BACKUP_ROOT ]]; 	then mkdir "$BACKUP_ROOT"; fi					# creates root backup folder
			if [[ ! -d $BACKUP_FOLDER ]];	then mkdir "$BACKUP_FOLDER"; fi					# creates video backup folder
			if [[ -d $BACKUP_FOLDER ]]; then
				echo -n "$MAC ">&2
				OUTPUT_TIME=$(modiftime "$OUTPUT_FOLDER" %Sm)								# gets modified time of OUTPUT folder
				AGE=$(($(date +%s) - OUTPUT_TIME))											# how old is this update?
				if [[ $AGE -gt 20 && $STATUS != "DONE" ]]; then								# if batch just got saved, SPF can be skewed
					backupframes
					estimatefinish
				fi
			else
				STATUS="NOFOLDER"															# backup folder destroyed ?
			fi
		else
			STATUS="NOFOLDER"																# output folder destroyed ?
		fi
	}
	
	IFS='|' read -r MAC VIDEO OUTPUT_ROOT STATUS CURRENT_OUTPUT CURRENT_BACKUP GOAL SPF FINISH AGE EARLIER NEED AVAIL <<< "$1"	# reads parameters from main machine
	MSG=""
	if [[ $STATUS == "" || $STATUS == "-" || $STATUS == "DONE" || $STATUS == "DEAD"  ]]; then
		readlog
	fi
	if [[ $STATUS != "-"  && $STATUS != "DONE" && $GOAL != "-" ]]; then
		checknewframes
		checkspace
		checkprocesses
	fi
	echo "$MAC|$VIDEO|$OUTPUT_ROOT|$STATUS|$CURRENT_OUTPUT|$CURRENT_BACKUP|$GOAL|$SPF|$FINISH|$AGE|$EARLIER|$NEED|$AVAIL|$MSG"	# returns variables to main machine
}

displayStatus() {
	IFS='|' read -r MAC VIDEO OUTPUT_ROOT STATUS CURRENT_OUTPUT CURRENT_BACKUP GOAL SPF FINISH AGE EARLIER NEED AVAIL MSG <<< "$1"
	LINE=$(printf "%-12s%-10s%-10s" "$MAC" "$VIDEO" "$STATUS")
	if [[ $STATUS != "-"  && $AGE != "-" ]]; then
		if [[ $SPF != "-" ]]; then SPF="${SPF}spf" ; fi
		if [[ $CURRENT_BACKUP != "-" && $GOAL != "-" ]]; then PROGRESS=" ($(bc -S 0 -e "$CURRENT_BACKUP*100/$GOAL")%)"; fi
		LINE=$(printf "%-32s%-18s%-10s%-24s%-12s%6s / %-6s%-6s%-12s%s" \
			"$LINE" "$FINISH" "$SPF" "$CURRENT_BACKUP$PROGRESS / $GOAL" \
			"$([[ $AGE -ge 60 ]] && echo "$(($AGE / 60))m $((AGE % 60))s ago" || echo "$((AGE % 60))s ago")" \
			"$NEED" "$AVAIL GB " "$MSG")
	fi
	COLOR="[0m"									# no color
	case "$STATUS" in
		DONE) COLOR="[32m";;					# green
		INACTIVE|DEAD|NOFOLDER) COLOR="[31m";;	# red
		SYNC|STARTED|\<*|\>*) if [[ $AGE != "-" && $AGE -lt 60 ]]; then COLOR="[32m"; fi ;;
		LOWSPACE|NOSPACE|PAUSED) COLOR="[33m";;	# orange
	esac
	echo -e "\r\033[0K\033$COLOR${LINE}\033[0m"
}

echo ============================================================================
echo "ZMON 1.0 - Topaz Video AI Monitor"
echo ============================================================================

echo "Machine     Video     Status    Estimated         Sec/fr    Done (%) / Total        Last update     Required/avail"
HOST="$(hostname -s)"
if [[ $# -gt 0 ]]; then
	read -ra files <<< "$@"																	# if no parameter, uses local machine only
else
	read -ra files <<< "$HOST"																# each parameter represents the name of a remote machine (can also include local)
fi
while true; do
	if [[ $# -gt 0 ]]; then
		echo -e "\r\033[0K"																# \r moves cursor to start, \033[0K clears line from cursor to end
	else
		echo -n -e "\r\033[0K"																# \r moves cursor to start, \033[0K clears line from cursor to end
	fi
	for (( i=0; i<${#files[@]}; i++ )); do
		IFS='|' read -r MAC _ <<< "${files[$i]}"
		if [[ "$MAC" == "$HOST" ]]; then
			files[$i]="$(zcheck "${files[$i]}")"											# processing local machine
			echo "$(displayStatus "${files[$i]}")"
		else
			SSH=$(printf '%s\n' "$(typeset -f zcheck)" "zcheck \"\$1\"" | ssh "$USER"@$MAC.local "bash -s -- \""${files[$i]}"\"")	# ssh into remote machine
			if [ $? -eq 0 ]; then															# local machine needs to be able to ssh into remote machine automatically
				files[$i]="$SSH"															# use ssh-keygen to generate a key pair
				echo "$(displayStatus "${files[$i]}")"										# copy public key in ~/.ssh/authorized_keys
			else																			# keep private key in ~/.ssh on the local machine
				echo "$MAC - cannot connect"
			fi
		fi
	done
	for i in {1..12}; do																	# one . every 5 s to show indicate progress
		echo -n "."
		sleep 5
	done
done

# ===============================
# state variable utilization
# ===============================
# MAC				machine name
# VIDEO				video name
# OUTPUT_ROOT		root of output folder
# STATUS			status
# CURRENT_OUTPUT	current frame #
# GOAL				last frame #
# SPF				Seconds per frame
# FINISH			estimated finish time
# AGE				how long ago was the last change
# EARLIER			time of previous frame
# NEED				space required to complete processing
# AVAIL				space available in GB

This is the script I have written in bash to build videos from PNG frames:

#!/bin/bash

buildvideo() {
	VIDEO="$(basename "${1%.*}")"															# get name without extension
	SOURCE_ROOT=$(dirname "$1")
	if [[ -z "$SOURCE_ROOT" ]] || [[ "$SOURCE_ROOT" == "." ]]; then							# if run from the command line, might not contain the full path
		SOURCE_ROOT=$(pwd)
	fi
	if [[ -z $OUTPUT_ROOT ]]; then															# calculate path of output folder if not extracted form the logs
		OUTPUT_ROOT="$(dirname "$(pwd)")/work"												# output folder is assumed to be ../work, upate if required
	fi
	if [[ -z $BACKUP_ROOT ]]; then															# calculate path of backup folder if not extracted form the logs
		BACKUP_ROOT="$(dirname "$(pwd)")/backup"											# backup folder is assumed to be ../backup, upate if required
	fi
	echo "Preparing to build $VIDEO"
	echo "Source folder $SOURCE_ROOT"
	echo "Output folder $OUTPUT_ROOT"
	echo "Backup folder $BACKUP_ROOT"
	SOURCE_AUDIO=""
	for f in "$SOURCE_ROOT/$VIDEO"*; do														# find a file wich audio stream
		AUDIO=$(ffprobe "$f" 2>&1 | grep Audio:)											# scans for audio stream
		if [[ ! -z $AUDIO ]]; then
			SOURCE_AUDIO="$f"																# location of video file that contains audio
			echo "Found audio stream in $SOURCE_AUDIO"
		fi
	done

# get frame rate from original video file

	echo "Getting framerate from $SOURCE_AUDIO"
	FRAMERATE=$(ffprobe "$SOURCE_AUDIO" 2>&1 | grep fps | grep -oE '[0-9.]+ fps' | grep -oE '[0-9.]+')
	if [[ -z $FRAMERATE ]]; then echo "zbuild: cannot find framerate"; exit 1; fi

# builds new video from PNG files and merge with audio from original video

	if [[ -d $BACKUP_ROOT/$VIDEO ]]; then
		echo "Building $VIDEO from BACKUP with framerate $FRAMERATE"
		if [[ -f $OUTPUT_ROOT/$VIDEO.txt ]]; then rm "$OUTPUT_ROOT/$VIDEO.txt"; fi
		for FOLDER in "$BACKUP_ROOT/$VIDEO/"*; do
			echo Parsing $FOLDER
			if [[ -d $FOLDER ]]; then
				for PNG in "$FOLDER/"*; do													# builds frames/durations file required to avoid warnings with -concat
					echo "file '$PNG'" >> "$OUTPUT_ROOT/$VIDEO.txt"
					echo "duration 0$(bc -S 8 -e "1/$FRAMERATE")" >> "$OUTPUT_ROOT/$VIDEO.txt"
				done
			fi
		done
		if [[ -f $OUTPUT_ROOT/$VIDEO.txt ]]; then
			if [[ ! -z $SOURCE_AUDIO ]]; then
				ffmpeg -r $FRAMERATE -f concat -safe 0 -i "$OUTPUT_ROOT/$VIDEO.txt" -i "$SOURCE_AUDIO" -map 0:v -map 1:a -vcodec libx265 -pix_fmt yuv420p -tag:v hvc1 "$OUTPUT_ROOT/${VIDEO}_slp.mp4"
			else																			# if no audio stream is available, still generate a video with no audio
				ffmpeg -r $FRAMERATE -f concat -safe 0 -i "$OUTPUT_ROOT/$VIDEO.txt" -map 0:v -vcodec libx265 -pix_fmt yuv420p -tag:v hvc1 "$OUTPUT_ROOT/${VIDEO}_slp.mp4"
			fi
			if [[ $? -eq 0 ]]; then
				echo "$OUTPUT_ROOT/${VIDEO}_slp.mp4 created"
				exit 0
			else
				echo "zbuild: error creating $OUTPUT_ROOT/${VIDEO}_slp.mp4"
				exit 1
			fi
		fi
	else
		echo "szuild: cannot find folder $BACKUP_ROOT/$VIDEO"
	fi
}

# finds location of Topaz Video AI logs

checklogs() {
	regex()     { if [[ $line =~ $1 ]]; then echo ${BASH_REMATCH[1]}; fi; }					# returns match of regular expression
	modiftime() { echo $(date -j -f "%Y-%m-%d %H:%M:%S" "$(stat -f "%Sm" -t "%Y-%m-%d %H:%M:%S" "$1")" +%s); }	# modification time of a folder
	
	LOG_RELEASE=~/Library/Application\ Support/Topaz\ Labs\ LLC/Topaz\ Video/logs
	LOG_BETA=~/Library/Application\ Support/Topaz\ Labs\ LLC/Topaz\ Video\ BETA/logs
	if [[ -d $LOG_RELEASE && -d $LOG_BETA ]]; then	# find latest log folder
		if [[ $(modiftime "$LOG_BETA") -gt $(modiftime "$LOG_RELEASE") ]]; then
			TOPAZLOG=$LOG_BETA
		else
			TOPAZLOG=$LOG_RELEASE
		fi
	elif [[ -d $LOG_BETA ]]; then
		TOPAZLOG=$LOG_BETA
	elif [[ -d $LOG_RELEASE ]]; then
		TOPAZLOG=$LOG_RELEASE
	else
		echo "zbuild: cannot find Video AI log folder" >&2
	fi
	
	# scan log files to determine available videos and folders
	
	if [[ -d "$TOPAZLOG" ]]; then
		for LOGFILE in "$TOPAZLOG/"*; do
			while IFS= read -r line; do														# loops through content of log file
				if [[ $line == *program* ]]; then
					SOURCE_VIDEO=$(regex "--input-path\", \"([^\"]+)")						# gets name of source video
					if [[ -f $SOURCE_VIDEO ]]; then
						VIDEO="$(basename "${SOURCE_VIDEO%.*}")"							# get name without extension
						SOURCE_ROOT=$(dirname "$SOURCE_VIDEO")
						OUTPUT_ROOT=$(regex "--output-path\", \"([^\"]+)")					# gets output folder
						OUTPUT_ROOT=$(dirname "$OUTPUT_ROOT")
						OUTPUT_ROOT=${OUTPUT_ROOT%/*}
						BACKUP_ROOT="$(dirname "$OUTPUT_ROOT")/backup"						# calculates name of root backup folder
						GOAL=$(($(regex "--end-frame-idx\", \"([0-9]+)\"")+1))				# gets last frame number + 1
						if [[ -d $BACKUP_ROOT/$VIDEO ]]; then
							FOLDER_COUNT=$(($(ls -f "$BACKUP_ROOT/$VIDEO" | wc -l)-3))		# will assume 1000 frames per folder
							LAST_FOLDER_COUNT=$(($(ls -f "$BACKUP_ROOT/$VIDEO/${VIDEO}_$(printf "%03d" $FOLDER_COUNT)" | wc -l)-2))
							CURRENT_BACKUP=$((FOLDER_COUNT*1000+LAST_FOLDER_COUNT))
						if [[ $CURRENT_BACKUP != "-" && $GOAL != "-" ]]; then PROGRESS=" ($(bc -S 0 -e "$CURRENT_BACKUP*100/$GOAL")%)"; fi
							printf "%-10s %s\n" "$VIDEO" "$CURRENT_BACKUP$PROGRESS / $GOAL"
							if [[ $CURRENT_BACKUP -eq $GOAL ]]; then						# video complete
								buildvideo "$SOURCE_VIDEO"
							fi
						fi
					fi
				fi
			done < <(grep -E 'program' "$LOGFILE")											# filter log file for faster processing
		done
	fi
}

if [[ "$#" -ne 1 ]]; then
	checklogs
else
	for SOURCE_VIDEO in "$@"; do
		buildvideo "$SOURCE_VIDEO"
	done
fi

Edit 1: added definition of modiftime() that was missing in checklogs

This explains how zmon works…

zmon design

The script is written with bash included with macOS. I know there is a new version of bash on brew, but I didn’t lookinto it.

in this post, I will explain critical elements of the design so you can adapt to your needs. Sometimes you will see snippets of code that are non-trivial (at least to me). I am not a scripting expert (I am a more 'traditional" programmer), and I will be happy if you can show me how I could improve!

Working folders

I have created a folder called “Video AI” on my home location : ~/Video AI. Below I have 3 folders I call src, work and backup

  • src will contain the video source files
  • work will be the output folder. You have to select it everytime you start Video AI, even if you set it up as default, it will not automatically be selected when you export.
  • backup is where the frames will be backed up.

You could have backup on the same physical drive if you have enough space or use a shared folder if you have another machine or a NAS.

ln -s /Volumes/NAS/Video\ AI ~/Video\ AI/backup

This will create a soft link in your ~/Video AI folder called backup and will point to your NAS (you need to have it mounted of course)

Parameters

If no parameter is provided, zmon will monitor Video AI on the current machine. If parameters are provided, zmon will monitor local or remote machines.

  • local machine: parameter needs to be the name of the host (obtained via hostname -s)
  • remove machines: zmon will use ssh to run a portion of the script on the remote machine. ssh key pairs must be setup to enable pre-anthentication from the monitoring machine to the remote machines

list of parameters will be placed in an array called files

read -ra files <<< "$@"	

Note:

  • <<< passes a single string directly to the standard input (stdin) of a command (Here string)
  • $@ contains the list of parameters

The script will loop indefinitely and check every machine each minute until stopped (Ctrl-C).

Definition and usage of variables passed in the loop

zmon will communicate between local and remote machines by passing one string containing multiple elements separated by pipes “|”. The structure of the string that is passed as arguments back and forth is as follows:

Variable Utilization
MAC machine name
VIDEO video name
OUTPUT_ROOT root of output folder
STATUS status
CURRENT_OUTPUT current frame #
GOAL last frame #
SPF Seconds per frame
FINISH estimated finish daye/time
AGE how long ago was the last change
EARLIER time of previous frame
NEED space required to complete processing
AVAIL space available in GB
MSG optional message, e.g. used when new frames are added

This will retrieve all “variables” passed as one string argument

IFS='|' read -r MAC VIDEO OUTPUT_ROOT STATUS CURRENT_OUTPUT CURRENT_BACKUP GOAL SPF FINISH AGE EARLIER NEED AVAIL <<< "$1"

this will “return” all “variables” for the next loop

echo "$MAC|$VIDEO|$OUTPUT_ROOT|$STATUS|$CURRENT_OUTPUT|$CURRENT_BACKUP|$GOAL|$SPF|$FINISH|$AGE|$EARLIER|$NEED|$AVAIL|$MSG"

I use pipes “|” as separators so I can have spaces in variables. MSG is temporary and never passed through the loops.

I’ve read there might be better options with newer versions of bash, but I’ve decided to stick to whatever is provided in macOS for now

zcheck

This is the main function that will be executed locally or remotely via ssh. zcheck will return state variables that will be displayed and used for the next loop

Local processing

files[$i]="$(zcheck "${files[$i]}")"	

Remote processing

SSH=$(printf '%s\n' "$(typeset -f zcheck)" "zcheck \"\$1\"" | ssh "$USER"@$MAC.local "bash -s -- \""${files[$i]}"\"")

When successful:

files[$i]="$SSH"

Calling ssh with passing parameters and returning something is a little tricky, I did consult AI to figure this one out! Note $USER is used to autherticate (name of the current user). .local is added to the name of each machine (should be on the same LAN)

subfunctions of zcheck will be explained later

displayStatus

After completion of zcheck, displayStatus will be run (locally) to display the information returned by zcheck. it will take care of formatting and some color coding

The following information will be displayed

Header Content
Machine Name of machine
Video Name of video being processed
Status State of machine (see below)
Estimated Estimated day/time of completion
Sec/fr Seconds per frame
Done (%) / Total # frames in backup, % and # expected frames
Last update How long ago a batch of frames was generated
Required/avail Space required / available in GB

State machine (STATUS variable)

The STATUS variables will contain different states that will change as more information become available from the log files.

STATUS Meaning
- monitor just started, no information available from log files
STARTED video processing started, no frame generated yet
SYNC frames available, in sync with backup folder
>xxx xxx more frames are available in the backup folder. This can happen on rare occasions during transition to the next folder. This is normally resolved at the next loop.
<xxx xxx more frames available in output folder. This can happen when rsync is “behind schedule”. This is normally resolved at the next loop.
INACTIVE no new frames have been seen in a while. As the scripts calculates the amount of time it usually takes to generate one frame, knowing they come in batches of ~100, if we haven’t seen anything in that time + 50%, there is a problem
DEAD script cannot find the Topaz Video AI or Neuroserver running
PAUSED One process is still alive but not using a lot of memory. This could be an indication of a “Recoverable error” (which is usually not recoverable!)
NOFOLDER output folder or backup folder removed!!! I have seen this happening after resuming from crashes!!!
LOWSPACE there will be <10G of disk space on the main machine after estimated completion of processing. This could be an issue where Video AI decides to stop.
NOSPACE there is not enough space available in backup to store all frames. Script will calculate the average size of one frame and multiply by the remaining number of frames to be processed
DONE processing is complete, script will reinitialize itself and wait for the next video in queue

zcheck subfunctions

  1. readlog

This portion will look for location of log files (release or beta version) and take the most recent one. the log file will be parsed to collect information to monitor the video processing

  • location and name of video source file
  • location of output folder
  • of frames to be processed

  • time when processing started
  • of frames already generated and timestamps

zmon can be stopped and restarted at anytime, and will attempt to resume based on what’s in the log file

Folders scanned are:

~/Library/Application Support/Topaz Labs LLC/Topaz Video/logs
~/Library/Application Support/Topaz Labs LLC/Topaz Video BETA/logs

zmon will only look at lines that contain **Thread: **and program or Processing. if Topaz changes the content of their log files (which they can without notice!), zmon will have to be updated

while IFS= read -r line; do

... analyze log file

done < <(grep -E 'Thread:.*(program|Processing)' "$LOGFILE")

Note (with a little help from AI on this one):

  • The syntax < <(process) combines Input Redirection (<) with Process Substitution (<(…)).
  • It tells Bash to run the process in the background, treat its output as a temporary file, and feed that file into the command’s standard input (stdin).
  1. checkprocesses

This calls ps to check for Video AI and Neuroserver processes

PS=$(ps -ax -o %mem,command | awk -F- '/Topaz/ && !/tail|grep/ {print $1}')	

if %memory utilization is <0.3, zmon considers this is a warning and one process may be stalled. It might happen from time to time and will most likely come back to normal.

  1. checkspace

Checks available space for output and backup. Folders can be on the same machine or backup can be a link to a file share (NAS).

For working folder, zmon assumes there could be ~100-200 frames saved and would require an extra 5GB for Video AI to not stop processing. Thus the check for 10GB

For backup folder, zmon will do the math based on the average size required for each frame, space available and # of frames that still need.

  1. checknewframes

Will check for folders and see if the output folder has been updated since last time (EARLIER). If the update happened less than 20s ago, it’s possible Video AI is still generating some frames, so we will wait for next loop. Otherwise, will call backupframes and estimate finish

** 4.1 backupframes**

Uses rsync to automatilly copy any new frame into the backup folder. Backup is organized in subfolders of thousands of frames

video_000 will contain frames 000000-000999
video_001 will contain frames 001000-001999

video_XXX will contain frames XXX000-XXX999

I implemented this as it was getting painful to browse a folder of 100,000 frames on my NAS (I use Synology, which in theory doesn’t have a limit with ZFS, but they still recommend to stay below 100,000 files per folder).

When a folder contains 1000 frames, it’s considered “complete” and matching frames will be removed from the output folder. This avoids duplicating every frame!

note: I updated rsync with brew to increase performance (macos rsync is quite old). This should work OK without the faster version though.

** 4.2 estimatefinish**

This calculate estimated time of completion with the knowledge of time it took to generated previous frames. Calculation varies based on information available, first I use the time the video file was queued, then I refine the time calculation with the history of frames generated. So, you will see this time fluctuate.

SPF will be recalculated when we get a batch of new frames.

Time will also change if you run other processes on your machine, especially when using the GPU. Switching to battery on a laptop will also increase your “SPF” (Second Per Frame) and estimated time will change at the next loop.

CURRENT_OUTPUT, CURRENT_BACKUP are critical variables that track the # of frames produced in the output and backup folders

Finally, when you queue a 2nd video, it will only start when the first video is complete. SPF and time to completion will be bad as it relies on the time the file was originally queued. When new frames are generated, you will get a closer approximation of time to completion after the 2nd batch of frames.

Final words

There are many more comments in the script itself if you interested in understanding how things work or want to make it better.

The script is provided “as is” and I will not be responsible for anything bad that happens! Hope you find it useful. If you have suggestions or made some modifications and are willing to share, please feel free to do so.

Thank you for reading and best regards,

Dom

Final post for the first series, this is how zbuild works:

zbuild design

This script will build the video from the PNG frames present in the backup folder

Parameters

If run without parameters, zbuild will attempt to find the latest video file present in the logs, determine if it has a complete set of frames in the backup folders and build it

Otherwise, each parameter will be the name of the video file, and the VIdeo AI log files will not be scanned. In this mode, zbuild will look for the current folder, and try to get to the work and backup folders assuming the following structure (same as zmon)

  • src will contain the video source files
  • work will be the output folder
  • backup is where the frames will be retrieved

buildvideo

This function will build the video passed as a parameter. I will first search for any file that begins with the video name and locate a file with an audio stream. The reason for this separation is related to the crashes I have experienced with macOS 27 beta (there is a racing condition on the mpv library that causes Video AI to crash when the audio changes on macOS, e.g. when a monitor is turned on)

When I process a video, I always do it without the audio to avoid the crashes (!). So I have a 2nd video file that contains the audio stream. This is the stream.

If you’re on macOS 26, you should not have this issue and probably have one video file with the audio stream

ffprobe is used to detect which file has the audio stream

AUDIO=$(ffprobe "$f" 2>&1 | grep Audio:)

Then zbuildvideo will determine the framerate from the source file using ffprobe again

FRAMERATE=$(ffprobe "$SOURCE_AUDIO" 2>&1 | grep fps | grep -oE '[0-9.]+ fps' | grep -oE '[0-9.]+')

I could also get the framerate from the Video AI logs, but this function attempts to not need them, thus the use of ffprobe

Because of my complex backup folder structure (1000 frames per subfolder), I have to create a file with the list of each individual frame and its duration. The file name is the name of the video with the .txt extensio and is placed in the work folder

echo "file '$PNG'" >> "$OUTPUT_ROOT/$VIDEO.txt"
echo "duration 0$(bc -S 8 -e "1/$FRAMERATE")" >> "$OUTPUT_ROOT/$VIDEO.txt"

Then ffmpeg will build the video from the frame file and the audio stream

ffmpeg -r $FRAMERATE -f concat -safe 0 -i "$OUTPUT_ROOT/$VIDEO.txt" -i "$SOURCE_AUDIO" -map 0:v -map 1:a -vcodec libx265 -pix_fmt yuv420p -tag:v hvc1 "$OUTPUT_ROOT/${VIDEO}_slp.mp4"

I did not copy the subtitles, this could be added (-map 1:s?).

Because I do not have sequential file names, I cannot use the Image Sequence Demuxer (I do not want 100,000+ files in one folder), so I have to use the concat demuxer, thus the need for the text file with the durations. Without this, I was getting a lot of “invalid dropping” errors.

If no audio is present, I have a simplified command that does the same thing (I guess I could use -map 1:a? and only one command)

libx265 seems to be the best option for quality, but it takes a little while and uses the CPU vs. the GPU. I tag the video with hvc1 so a thumbnail can be generated by macOS

checklogs

this function is used when zbuild is used without parameters. Similar to zmon, it will locate the Video AI log folders, scan each file and see if one is complete. If the source video file and backup folders can be found, checklogs will call buildvideo.

in this mode, the output folder will be read from the log files, and the backup folder will be assumed to be called backup, at the same level as the output folder

Final words

I could merge zbuild into zmon into one script, since zmon knows when a video is complete, it could call buildvideo automatically. I might do this in the future, but I want to remain in control of when the video is created for now.

As more automation is included in the script, more testing is required. And testing takes a lot of time…

Looking forward to reading your thoughts. I would also ask Topaz to improve Video AI so less of this is necessary. These scripts should not exist in the first place, they are workarounds. I created them to make Video AI work for me. Without them, I would lose days of processing.

Thank you and Best regards,

Dom

Bug fix in zbuild (see above), I was missing the definition of the following function

modiftime() { echo $(date -j -f "%Y-%m-%d %H:%M:%S" "$(stat -f "%Sm" -t "%Y-%m-%d %H:%M:%S" "$1")" +%s); }	# modification time of a folder

Sorry about that :slight_smile:

thank you so much :blush: