diff options
author | David Timber <dxdt@dev.snart.me> | 2025-03-14 10:08:53 +0100 |
---|---|---|
committer | David Timber <dxdt@dev.snart.me> | 2025-03-14 10:08:53 +0100 |
commit | 4f8b1876ba1f635fff55a21d2b7d01acfd286141 (patch) | |
tree | 81650df25703eeddbe769c66feaa2c7fc2efa664 | |
parent | bfce6bbfc41037526c22bec06be0970816975262 (diff) |
-rw-r--r-- | stab-aftg/README.md | 33 | ||||
-rwxr-xr-x | stab-aftg/stab-aftg | 156 |
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())) |