@@ 0,0 1,296 @@
+import argparse
+import re
+import subprocess
+from pathlib import Path
+
+
+FORMATS = {
+ "hero7": {
+ "timelapse": re.compile(r"(G(\d{3})(\d{4}))\.(\w{3})"),
+ "video": re.compile(r"(GH(\d{2})(\d{4}))\.(\w{3})"),
+ "lowres": re.compile(r"(GL(\d{2})(\d{4}))\.(\w{3})"),
+ },
+}
+
+def parse_video_name(model, format, path):
+ regex = FORMATS[model][format]
+ match = regex.match(path.name)
+ if not match:
+ return None
+
+ return {
+ "filename": match.group(1),
+ "chapter": match.group(2),
+ "number": match.group(3),
+ "ext": match.group(4),
+ }
+
+
+def parse_timelapse_name(model, path):
+ regex = FORMATS[model]["timelapse"]
+ match = regex.match(path.name)
+ if not match:
+ return None
+
+ return {
+ "group_number": match.group(2),
+ "file_number": match.group(3),
+ "ext": match.group(4),
+ }
+
+
+def str_to_bool(v):
+ if isinstance(v, bool):
+ return v
+ if v.lower() in ("yes", "true", "t", "y", "1"):
+ return True
+ elif v.lower() in ("no", "false", "f", "n", "0"):
+ return False
+ else:
+ raise argparse.ArgumentTypeError("Boolean value expected.")
+
+
+def organize(args):
+ video_dir = Path(args.video_dir).resolve()
+ hi_res_dir = Path(video_dir, "hi-res").resolve()
+ low_res_dir = Path(video_dir, "low-res").resolve()
+ thumb_dir = Path(video_dir, "thumbnails").resolve()
+ timelapse_dir = Path(args.timelapse_dir).resolve()
+
+ print(f"model: {args.model}")
+ print(f"video dir: {video_dir}")
+ print(f"timelapse dir: {timelapse_dir}")
+
+ if not FORMATS.get(args.model):
+ sys.exit(f"Unsupported go pro model \"{model}\"")
+
+ video_dir.mkdir(exist_ok=True)
+ hi_res_dir.mkdir(exist_ok=True)
+ low_res_dir.mkdir(exist_ok=True)
+ thumb_dir.mkdir(exist_ok=True)
+ timelapse_dir.mkdir(exist_ok=True)
+
+ directories = [Path(d).resolve() for d in args.directory]
+ print("Organizing the following directories")
+ for directory in directories:
+ print(directory)
+
+ for directory in directories:
+ for path in directory.glob("*.MP4"):
+ match = parse_video_name(args.model, "video", path)
+ if match:
+ new_path = Path(
+ hi_res_dir, f"{match['number']} {match['chapter']}.{match['ext']}"
+ )
+ print(f"RENAME {path} => {new_path}")
+ new_path.parent.mkdir(parents=True, exist_ok=True)
+ path.rename(new_path)
+
+ for path in directory.glob("*.LRV"):
+ match = parse_video_name(args.model, "lowres", path)
+ if match:
+ new_path = Path(
+ low_res_dir,
+ f"{match['number']} {match['chapter']}.{match['ext']}"
+ )
+ print(f"RENAME {path} => {new_path}")
+ new_path.parent.mkdir(parents=True, exist_ok=True)
+ path.rename(new_path)
+
+ for path in directory.glob("*.THM"):
+ match = parse_video_name(args.model, "video", path)
+ if match:
+ new_path = Path(
+ thumb_dir,
+ f"{match['number']} {match['chapter']}.{match['ext']}"
+ )
+ print(f"RENAME {path} => {new_path}")
+ new_path.parent.mkdir(parents=True, exist_ok=True)
+ path.rename(new_path)
+
+ for path in directory.glob("*.JPG"):
+ match = parse_timelapse_name(args.model, path)
+ if not match:
+ continue
+
+ group_dir = Path(timelapse_dir, match['group_number'])
+ new_path = Path(group_dir, f"{match['file_number']}.{match['ext']}")
+ print(f"RENAME {path} => {new_path}")
+ group_dir.mkdir(exist_ok=True)
+ path.rename(Path(group_dir, path.name))
+
+ # Delete all empty directories
+ if len([f for f in directory.iterdir()]) == 0:
+ print(f"DELETE {directory}")
+ directory.rmdir()
+
+ if args.archive:
+ for path in list(timelapse_dir.glob("*")):
+ if path.is_dir():
+ parent_path = path.parent
+ outfile = Path(timelapse_dir, f"{path.name}.tar.zst")
+ print(f"Archiving {path} => {outfile}")
+ subprocess.run(
+ [
+ "tar",
+ "--directory",
+ parent_path,
+ "--zstd",
+ "-cf",
+ outfile,
+ path.name
+ ],
+ check=True,
+ )
+
+
+
+def timelapse(args):
+ working_dir = Path(args.dir).resolve()
+ file_glob = Path(working_dir, "*.JPG")
+ output_dir = Path(args.output_dir).resolve()
+
+ total = int(args.rotate / 90)
+
+ width = args.width
+ height = args.height
+ scale = f"{width}x{height}"
+
+ outfilename = working_dir.name
+ if args.name:
+ outfilename = args.name
+
+ rotation = ""
+
+ if total:
+ rotation = ",".join(["transpose=2" for i in range(0, total)])
+ rotation = f"-vf \"{rotation}\""
+
+ command = [
+ "ffmpeg",
+ "-f",
+ "image2",
+ "-pattern_type",
+ "glob",
+ "-r",
+ f"{args.fps}",
+ "-i",
+ f"{file_glob}",
+ "-s",
+ f"{scale}",
+ "-c:v",
+ "libx264",
+ "-q:v",
+ "10",
+ "-an",
+ ]
+
+ if rotation:
+ command.append("rotation")
+
+ command.append(str(Path(output_dir, f"{outfilename}.mp4")))
+
+ subprocess.run(command, check=True)
+
+
+parser = argparse.ArgumentParser(
+ description="A utility to help facilitate GoPro file management.",
+)
+
+subparsers = parser.add_subparsers(
+ help="sub-command help",
+ dest="command",
+)
+
+organize_parser = subparsers.add_parser(
+ "organize",
+ aliases=["o"],
+ help="Organize the GoPro files",
+)
+organize_parser.add_argument(
+ "directory",
+ type=str,
+ nargs="+",
+ help="the directory that needs files to be organized",
+ default=".",
+)
+organize_parser.add_argument(
+ "--timelapse-dir",
+ type=str,
+ help="the directory to store timelapse files",
+ default="./timelapse",
+)
+organize_parser.add_argument(
+ "--video-dir",
+ type=str,
+ help="the directory to store video files",
+ default="./videos",
+)
+organize_parser.add_argument(
+ "--model",
+ type=str,
+ help="the model of go pro used",
+ default="hero7",
+)
+organize_parser.add_argument(
+ "--archive",
+ type=str_to_bool,
+ help="Enable or disable archiving the timelapse files",
+ default=True,
+)
+
+timelapse_parser = subparsers.add_parser(
+ "timelapse",
+ help="Compile timelapse",
+)
+timelapse_parser.add_argument(
+ "dir",
+ type=str,
+ help="the directory where the timelapse frames are",
+ default=".",
+)
+timelapse_parser.add_argument(
+ "--output-dir",
+ type=str,
+ help="the directory to store the file",
+ default=".",
+)
+timelapse_parser.add_argument(
+ "--rotate",
+ type=int,
+ help="the amount to rotate the video by",
+ default=0,
+)
+timelapse_parser.add_argument(
+ "--width",
+ type=int,
+ help="the final width of the video",
+ default=4000,
+)
+timelapse_parser.add_argument(
+ "--height",
+ type=int,
+ help="the final height of the video",
+ default=3000,
+)
+timelapse_parser.add_argument(
+ "--fps",
+ type=int,
+ help="the frame rate of the video",
+ default=6,
+)
+timelapse_parser.add_argument(
+ "--name",
+ type=str,
+ help="the output file without the extension",
+ default=None,
+)
+
+args = parser.parse_args()
+
+COMMANDS = {
+ "organize": organize,
+ "timelapse": timelapse,
+}
+
+COMMANDS[args.command](args)