aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorDavid Timber <dxdt@dev.snart.me>2025-03-14 10:08:53 +0100
committerDavid Timber <dxdt@dev.snart.me>2025-03-14 10:08:53 +0100
commit4f8b1876ba1f635fff55a21d2b7d01acfd286141 (patch)
tree81650df25703eeddbe769c66feaa2c7fc2efa664
parentbfce6bbfc41037526c22bec06be0970816975262 (diff)
Add stab-aftgHEADmaster
-rw-r--r--stab-aftg/README.md33
-rwxr-xr-xstab-aftg/stab-aftg156
2 files changed, 189 insertions, 0 deletions
diff --git a/stab-aftg/README.md b/stab-aftg/README.md
new file mode 100644
index 0000000..9c62292
--- /dev/null
+++ b/stab-aftg/README.md
@@ -0,0 +1,33 @@
+# Action cam footage batch stabilization script
+Out in the wild, you can only carry too many Gopro batteries and you can never
+be sure when you can charge them. You can extend battery life by turning off
+stabilization, which is quite battery intensive. But the problem is that you
+have to post process the footage when you get back to the civilization. Footage
+taken without stabilization on is generally unusable because it's too "shaky".
+
+The post process can take a very long time depending on the amount of footage
+you took. Gyroflow is a great tool, but it was not made by Unix programmers so
+it has poor CUI and scripting support.
+
+Actions cams like Gopro produce what's called "low resolution video"(LRV) files
+in addition to the actual footage on the fly by default. These LRV files are for
+on-device playback and "proxy clips" in video editor software. LRV files need to
+be regenerated as well for good editing experience.
+
+It seemed obvious that I messed up and had to come up with a solution. At first,
+Makefile seemed like a good straightforward answer, but the way Gyroflow was
+designed made it impossible to incorporate it. So naturally, I ended up writing
+the Python script.
+
+## The script
+The script had to be reentrant because the VAAPI support on Linux isn't quite
+there yet. The driver can crash the window manager at any given moment, the
+external USB hard drive can pop out any time due to insufficient power and so
+on. The script had to be resilient to hardware failures.
+
+Gyroflow and FFmpeg outputs files suffixed with ".tmp.mp4". After a successful
+run, the ".tmp" suffix is removed. Upon reentry, they're simply overwritten.
+
+On startup, the script looks for all .mp4 files that are **NOT** suffixed with
+".tmp" or "_stabilized" to skip the files that are already processed from the
+previous run.
diff --git a/stab-aftg/stab-aftg b/stab-aftg/stab-aftg
new file mode 100755
index 0000000..b669f4c
--- /dev/null
+++ b/stab-aftg/stab-aftg
@@ -0,0 +1,156 @@
+#!/bin/python3
+'''
+Stabilize all action cam(Gopro) footage in the current working directory using
+Gyroflow, produce low resolution video(LRV) files using FFmpeg.
+
+NOTE: Gyroflow requires hardware acceleration, which may require a non-free
+version of mesa driver depending on the VGA on your system.
+
+By David Timber 2025
+'''
+
+import datetime
+import glob
+import os
+import shutil
+import stat
+import sys
+
+ARGV0 = 'stab-aftg'
+
+def myprintf (msg: str):
+ sys.stderr.write(ARGV0 + ': ' + msg + os.linesep)
+
+class FootageInfo:
+ def __init__(self, fullpath: str, s_stabilized: str, e_lrv: str, e_stabilized: str):
+ s = os.lstat(fullpath)
+ if stat.S_IFREG & s.st_mode == 0:
+ raise FileNotFoundError(fullpath)
+
+ self.path = fullpath
+
+ seppos = fullpath.rfind(os.path.sep)
+ if seppos < 0:
+ self.dirname = ''
+ else:
+ self.dirname = fullpath[:seppos]
+
+ seppos = fullpath.rfind('.')
+ if seppos > len(self.dirname):
+ self.basename = fullpath[:seppos]
+ self.extension = fullpath[seppos:]
+ else:
+ self.basename = fullpath
+ self.extension = ''
+
+ self.filesize = s.st_size
+
+ self.path_stabilized = self.basename + s_stabilized + e_stabilized
+ self.path_lrv = self.basename + s_stabilized + e_lrv
+
+def exists_and_isfile (path):
+ if os.path.exists(path):
+ if os.path.isfile(path):
+ return True
+ raise IsADirectoryError(path)
+ return False
+
+params = {
+ 'dryrun': False
+}
+
+def invoke_cmd (cmd: str):
+ global params
+
+ sys.stderr.write(cmd + os.linesep)
+ if not params.get('dryrun'):
+ waitstat = os.system(cmd)
+ if sys.platform.startswith('win32'):
+ ec = waitstat
+ else:
+ ec = os.waitstatus_to_exitcode(waitstat)
+
+ if ec != 0:
+ raise ChildProcessError(ec)
+
+exec_gyroflow = os.environ.get('EXEC_GYROFLOW') or 'gyroflow'
+exec_ffmpeg = os.environ.get('EXEC_FFMPEG') or 'ffmpeg'
+glob_pat = os.environ.get('GLOB_PAT') or '*.[mM][pP]4'
+# opts_gyroflow = os.environ.get('OPTS_GYROFLOW') or '-f'
+# TODO
+# opts_ffmpeg = os.environ.get('OPTS_FFMPEG') or '-c:v libx264 -vf scale=768:-2 -c:a aac -b:a 128k -f'
+opts_ffmpeg = os.environ.get('OPTS_FFMPEG') or '-vf scale=768:-2 -vcodec libx264 -g 1 -bf 0 -vb 0 -crf 20 -preset medium -acodec aac -ab 128k -f mp4 -y'
+suffix_stabilized = '_stabilized'
+ext_stabilized = '.mp4'
+ext_lrv = '.LRV'
+ext_tmp = '.tmp'
+
+cnt_found = 0
+size_total = 0
+list_footage = list[FootageInfo]()
+for f in glob.glob(glob_pat):
+ cnt_found += 1
+
+ if f.lower().endswith((suffix_stabilized + ext_tmp + ext_stabilized).lower()):
+ continue
+ if f.lower().endswith((suffix_stabilized + ext_stabilized).lower()):
+ continue
+
+ fi = FootageInfo(f, suffix_stabilized, ext_lrv, ext_stabilized)
+ if exists_and_isfile(fi.path_stabilized) and exists_and_isfile(fi.path_lrv):
+ continue
+
+ size_total += fi.filesize
+ list_footage.append(fi)
+
+myprintf('''found %d unprocessed files out of %d files. Total size: %d''' %
+ (len(list_footage), cnt_found, size_total))
+
+batch_start = datetime.datetime.now()
+myprintf('''batch start: ''' + batch_start.isoformat())
+
+size_proc = 0
+i = 0
+for f in list_footage:
+ f_start = datetime.datetime.now()
+
+ # Gyroflow
+ if not exists_and_isfile(f.path_stabilized):
+ invoke_cmd('''%s "%s" -f -t "%s%s"''' %
+ (exec_gyroflow, os.path.realpath(f.path), suffix_stabilized, ext_tmp))
+
+ src = f.basename + suffix_stabilized + ext_tmp + ext_stabilized
+ dst = f.path_stabilized
+ sys.stderr.write('''mv %s %s''' % (src, dst) + os.linesep)
+ if not params.get('dryrun'):
+ shutil.move(src, dst)
+
+ # LRV
+ src = f.basename + suffix_stabilized + ext_tmp + ext_lrv
+ dst = f.path_lrv
+ invoke_cmd('''%s -i %s %s %s''' %
+ (exec_ffmpeg, f.path_stabilized, opts_ffmpeg, src))
+ sys.stderr.write('''mv %s %s''' % (src, dst) + os.linesep)
+ if not params.get('dryrun'):
+ shutil.move(src, dst)
+
+ f_end = datetime.datetime.now()
+ f_elapsed = f_end - f_start
+ i += 1
+
+ # progress and estimation report
+ size_proc += f.filesize
+ total_elapsed = f_end - batch_start
+ bps = size_proc / total_elapsed.total_seconds()
+ etf = size_total / bps
+ myprintf('''%s: processed %d bytes in %.3fs (%d/%d %.1f%%)''' %
+ (f.path, f.filesize, total_elapsed.total_seconds(), i, len(list_footage), size_proc / size_total * 100))
+ # FIXME: Generation of LRV is much faster. This estimation is way off when
+ # Gyroflow process is skipped
+ myprintf('''bytes per second = %.3f, estimated time to finish = %.0fs''' %
+ (bps, etf))
+
+batch_end = datetime.datetime.now()
+batch_elapsed = batch_end - batch_start
+myprintf('''batch end: ''' + batch_end.isoformat())
+myprintf('''processed %d in %.3fs''' % (size_total, batch_elapsed.total_seconds()))