From 30ec25b5eb08d2f7f6b4c7cf59105ccfc5c115d5 Mon Sep 17 00:00:00 2001 From: ehendrix23 Date: Tue, 20 Aug 2019 08:18:11 -0600 Subject: [PATCH] Release 0.1.12 (#57) * Temp exe * Updated temporary exe file * Delete tesla_dashcam_190b3.exe * Fix check release * Updated README with initial 0.1.10 and additional TODO Updated README with items already in progress for 0.1.10 Added items to TODO list * Fix running tesla_dashcam when installed with pip * Updated README with additional items done for next release * Initial set of updates for 0.1.10. * Timestamp sorting fix * Added Chapter Markers in To-Do list * Made behaviour for single folder same as multiple Made behaviour for single folder discovered same as multiple. * --delete_souce now also for execution without --monitor * Updated README and reorged 0.1.10 items a bit * Fix deleting foldr file if name same as clip name * Exclude ffmpeg.exe * Default output folder, change in folder filename Added default output folder Changed folder filename to _ Source is optional now * Set Python version to 3.7 only * Updated readme for 0.1.10 Finished updating readme * Set version to 0.1.10 * Fixed metadata issue Fixed if all 3 files of a timestamp are corrupt Ensured that video_timestamp is dateformat if timestamp was retrieved from filename instead of clip. * Fixed issue if camera file was missing Fixed an issue if a camera file was missing. Removed now unused MOVIE_LAYOUT dictionary * Release 0.1.10 * Fixed traceback issue for ffmpeg (#39) * Added option to test distribution with TestPyPi * Version change to 0.1.11 * Updated README Added 2 fixes for 0.1.11 Added option to crop videos in TODO section * Fixed output folder issues * Missing piece for PyPi Figured out missing piece allowing easy execution from PyPi. * Updated links to executables * Removed DIAGONAL as it is not implemented * Formatting update with Black Formatting update with Black * Fix for folder deletion with empty files Fix for issue #40 where folders would not be deleted if there are 0-byte or corrupt files within the folder * Fixed --output with filename issue Fixed issue #52 with --output when providing a filename * Changed concat for movie creation Changed how to concatenate the clips in create_movie resulting in massive performance improvement. * Updated README for create_movie Updated readme for create_movie performance improvement * Add chapter markers Concatenated video files now will have chapter markers. Folder level will have chapters for each clip, merged will have chapters for each folder * removed single clip exception Removed single clip exception for creating movie as we now add chapters in it as well hence need to process. * Add flags -movstart and +faststart Added flags -movstart and +faststart to movies (not clips) created * Updated README * Version change to 0.1.12 beta 0 * Further fix for output argument Further fix for output argument to determine what was provided. * Fix chapter settings when video speed is adjusted Chapter settings were not taking into consideration that clips were sped-up or slowed-down. * Fix issue 54 (win10toast notifier) Potential fix for issue 54 * Missed something * Fix traceback with invalid output path * Trigger file or folder and few fixes Option to provide a trigger file or folder for monitoring. Fix for sub-dir scanning Fix for nothing being processed if . was provided as source. * Cosmetic fixes * Added chapter offset & movie filename fix Added optional chapter offset for merged video file. Fix for moviefile when output filename is provided on monitor * Beta version 0.1.12.2 * Fix typo in new version check output (#56) * Update README * Final prep of README for 0.1.12 * Cleanup * Fixes for chapter and deletion with corruptio files Fix for chapter of 1st clip Fix for deletion of files & folders with corrupt files. * Fix oopsie * Fix if file does not have timestamp Fix if fiel does not have timestamp for issue introduced with fixing deletion of corrupt/empty files * Added durations of resulting movie to print out * Version to 0.1.12 for release --- README.rst | 94 +- setup.py | 127 +- tesla_dashcam/__version__.py | 2 +- tesla_dashcam/tesla_dashcam.py | 2556 ++++++++++++++++++-------------- 4 files changed, 1573 insertions(+), 1206 deletions(-) diff --git a/README.rst b/README.rst index 5eb553d..4417003 100755 --- a/README.rst +++ b/README.rst @@ -13,7 +13,7 @@ Using this program, one can combine all of these into 1 video file. The video of into one picture, with the video for all the minutes further put together into one. By default sub-folders are included when retrieving the video clips. One can, for example, just provide the path to the -respective SavedClips folder (i.e. e:\TeslaCam\SavedClips for Windows if drive has letter E, +respective SavedClips folder (i.e. e:\\TeslaCam\\SavedClips for Windows if drive has letter E, /Volumes/Tesla/TeslaCam/SavedClips on MacOS if drive is mounted on /Volumes/Tesla) and then all folders that were created within the SavedClips folder will be processed. There will be a movie file for each folder. When using the option --merge there will also be a movie file created combining the movies from all the folders into 1. @@ -30,8 +30,24 @@ file for that folder already exist. It is still possible for --monitor_once to provide a output filename instead of just a folder. For --monitor the filename will be ignored and the files will be created within the path specified using a unique name instead. +Using the option --monitor_trigger_file one can have it check for existence of a certain file or folder for starting +processing instead of waiting for the disk with the TeslaCam folder to become available. Once available processing will +start, if a trigger file was provided then upon completion of processing the file will then be deleted. If it was a folder +then it will wait for the folder to be removed by something else (or for example link removed) and then wait again for it +to appear again. +If no source folder is provided then folder SavedClips will be processed with assumption it is in the same location as +the trigger file. If source folder is an absolute path (i.e. /Videos/Tesla) then that will be used as source location. +If it is a relative path (i.e. Tesla/MyVideos) then the path will be considered to be relative based on the location +provided for the trigger file. + When using --merge, the name of the resulting video file will be appended with the current timestamp of processing when --monitor parameter is used, this to ensure that the resulting video file is always unique. +Option --chapter_offset can be provided to offset the chapter markers within the merged video. A negative number would +result in the chapter marker being set not at the start for the folder video but instead be set provided number of +seconds before the end of that video. For example, with 10 minute video for a folder a value of -120 would result +in the chapter markers being set 2 minutes before the end of that video. A positive number will result in chapter marker +being set to provided number of seconds after the start of the video. Value of 600 would result in chapter markers being +set 5 minutes into that folder's video. If --merge is not provided as an option and there are multiple sub-folders then the filename (if provided in output) will be ignored. Instead the files will all be placed in the folder identified by the output parameter, one movie file @@ -51,8 +67,8 @@ Binaries Stand-alone binaries can be retrieved: -- Windows: https://github.com/ehendrix23/tesla_dashcam/releases/download/v0.1.11/tesla_dashcam.zip -- MacOS (OSX): https://github.com/ehendrix23/tesla_dashcam/releases/download/v0.1.11/tesla_dashcam.dmg +- Windows: https://github.com/ehendrix23/tesla_dashcam/releases/download/v0.1.12/tesla_dashcam.zip +- MacOS (OSX): https://github.com/ehendrix23/tesla_dashcam/releases/download/v0.1.12/tesla_dashcam.dmg `ffmpeg `_ is included within the respective package. ffmpeg is a separately licensed product under the `GNU Lesser General Public License (LGPL) version 2.1 or later `_. @@ -113,9 +129,10 @@ Usage .. code:: bash usage: tesla_dashcam.py [-h] [--version] [--exclude_subdirs | --merge] - [--output OUTPUT] [--keep-intermediate] - [--delete_source] [--no-notification] - [--layout {WIDESCREEN,FULLSCREEN,DIAGONAL,PERSPECTIVE}] + [--chapter_offset CHAPTER_OFFSET] [--output OUTPUT] + [--keep-intermediate] [--delete_source] + [--no-notification] + [--layout {WIDESCREEN,FULLSCREEN,PERSPECTIVE}] [--scale CLIP_SCALE] [--mirror | --rear] [--swap] [--no-swap] [--slowdown SLOW_DOWN] [--speedup SPEED_UP] @@ -126,6 +143,7 @@ Usage [--quality {LOWEST,LOWER,LOW,MEDIUM,HIGH}] [--compression {ultrafast,superfast,veryfast,faster,fast,medium,slow,slower,veryslow}] [--ffmpeg FFMPEG] [--monitor] [--monitor_once] + [--monitor_trigger MONITOR_TRIGGER] [--check_for_update] [--no-check_for_update] [--include_test] [source [source ...]] @@ -139,11 +157,16 @@ Usage optional arguments: -h, --help show this help message and exit - --version show program's version number and exit - --exclude_subdirs Do not search all sub folders for video files to. + --version show program''s version number and exit + --exclude_subdirs Do not search sub folders for video files to process. (default: False) --merge Merge the video files from different folders into 1 big video file. (default: False) + --chapter_offset CHAPTER_OFFSET + Offset in seconds for chapters in merged video. + Negative offset is \# of seconds before the end of the + subdir video, positive offset if \# of seconds after + the start of the subdir video. (default: 0) --output OUTPUT Path/Filename for the new movie file. Intermediate files will be stored in same folder. (default: /Users/ehendrix/Movies/Tesla_Dashcam/) --keep-intermediate Do not remove the intermediate video files that are @@ -161,7 +184,6 @@ Usage --scale CLIP_SCALE Set camera clip scale, scale of 1 is 1280x960 camera clip. Defaults: WIDESCREEN: 1/2 (640x480, video is 1920x480) FULLSCREEN: 1/2 (640x480, video is 1280x960) - DIAGONAL: 1/4 (320x240, video is 980x380) PERSPECTIVE: 1/4 (320x240, video is 980x380) (default: None) --mirror Video from side cameras as if being viewed through the @@ -196,8 +218,7 @@ Usage For more information on supported cards see: https://developer.nvidia.com/video-encode-decode-gpu-support-matrix (default: False) --ffmpeg FFMPEG Path and filename for ffmpeg. Specify if ffmpeg is not - within path. (default: /Users/ehendrix/Documents/GitHu - b/tesla_dashcam/tesla_dashcam/ffmpeg) + within path. (default: tesla_dashcam/ffmpeg) Timestamp: Options for timestamp: @@ -244,6 +265,13 @@ Usage --monitor_once Enable monitoring and exit once drive with TeslaCam folder has been attached and files processed. (default: False) + --monitor_trigger MONITOR_TRIGGER + Trigger file to look for instead of waiting for drive + to be attached. Once file is discovered then + processing will start, file will be deleted when + processing has been completed. If source is not + provided then folder where file is located will be + used as source. (default: None) Update Check: Check for updates @@ -258,7 +286,6 @@ Usage - Layout: ------- @@ -527,6 +554,30 @@ Also create a movie file that has them all merged together. python3 tesla_dashcam.py --slowdown 2 --rear --merge --output /home/me/Tesla --monitor_once SavedClips +Enable monitoring using a trigger file (or folder) to start processing all the files from SavedClips. +Note that for source we provide the folder name (SavedClips), the complete path will be created by the program using the +path of the trigger file (if it is a file) or folder. Videos are stored in folder specified by --output. Videos from all +the folders are then merged into 1 folder with name TeslaDashcam followed by timestamp of processing (timestamp is +added automatically). Chapter offset is set to be 2 minutes (120 seconds) before the end of the respective folder clips. + +* Windows: + +.. code:: bash + + tesla_dashcam.exe --merge --chapter_offset -120 --output c:\Tesla\TeslaDashcam.mp4 --monitor --monitor_trigger x:\TeslaCam\start_processing.txt SavedClips + +* Mac: + +.. code:: bash + + tesla_dashcam --merge --chapter_offset -120 --output /Users/me/Desktop/Tesla --monitor --monitor_trigger /Users/me/TeslaCam/start_processing.txt SavedClips + +* Linux: + +.. code:: bash + + python3 tesla_dashcam.py --merge --chapter_offset -120 --output /home/me/Tesla --monitor --monitor_trigger /home/me/TeslaCam/start_processing.txt SavedClips + Argument (Parameter) file ------------------------- @@ -659,10 +710,23 @@ Release Notes - Fixed: Python version has to be 3.7 or higher due to use of capture_output `Issue #19 `_ 0.1.11: - Fixed: Traceback when getting ffmpeg path in Linux `Issue #39 `_ - - Fixed: running tesla_dashcam when installed using pip. `Issue #38 `_ + - Fixed: Running tesla_dashcam when installed using pip. `Issue #38 `_ - Fixed: Just providing a filename for output would result in traceback. - Fixed: When providing a folder as output it would be possible that the last folder name was stripped potentially resulting in error. - +0.1.12: + - New: Added chapter markers in the concatenated movies. Folder ones will have a chapter marker for each intermediate clip, merged one has a chapter marker for each folder. + - New: Option --chapter_offset for use with --merge to offset the chapter marker in relation to the folder clip. + - New: Added flags -movstart and +faststart for video files better suited with browsers etc. (i.e. YouTube). Thanks to sf302 for suggestion. + - New: Option to add trigger (--monitor_trigger_file) to use existence of a file/folder/link for starting processing instead of USB/SD being inserted. + - Changed: Method for concatenating the clips together has been changed resulting in massive performance improvement (less then 1 second to do concatenation). Big thanks to sf302! + - Fixed: Folders will now be deleted if there are 0-byte or corrupt video files within the folder `Issue #40 `_ + - Fixed: Providing a filename for --output would create a folder instead and not setting resulting file to filename provided `Issue #52 `_ + - Fixed: Thread exception in Windows that ToastNotifier does not have an attribute classAtom (potential fix). `Issue #54 `_ + - Fixed: Traceback when invalid output path (none-existing) is provided or when unable to create target folder in given path. + - Fixed: Including sub dirs did not work correctly, it would only grab the 1st folder. + - Fixed: When using monitor, if . was provided as source then nothing would be processed. Now it will process everything as intended. + - Fixed: File created when providing a filename with --output and --monitor option did not put timestamp in filename to ensure unique filenames + - Fixed: Argument to get release notes was provided incorrectly when checking for updates. Thank you to demonbane for fixing. TODO ---- @@ -670,8 +734,6 @@ TODO * Allow exclusion of camera(s) in output (i.e. don't include right, or don't include front, ...). * Implement option to crop individual camera output * Provide option to copy or move from source to output folder before starting to process -* Add chapter markers -* Allow for scanning if there are new folders and process if there are * Develop method to run as a service with --monitor option * GUI Front-end * Support drag&drop of video folder (supported in Windows now, MacOS not yet) diff --git a/setup.py b/setup.py index 6b64399..78a2aa0 100755 --- a/setup.py +++ b/setup.py @@ -14,20 +14,19 @@ from setuptools import find_packages, setup, Command # type: ignore # Package meta-data. -NAME = 'tesla_dashcam' -DESCRIPTION = 'Python program to merge video files created by Tesla ' \ - 'dashcam' -URL = 'https://github.com/ehendrix23/tesla_dashcam' -EMAIL = 'hendrix_erik@hotmail.com' -AUTHOR = 'Erik Hendrix' -REQUIRES_PYTHON = '>=3.7.0' +NAME = "tesla_dashcam" +DESCRIPTION = "Python program to merge video files created by Tesla " "dashcam" +URL = "https://github.com/ehendrix23/tesla_dashcam" +EMAIL = "hendrix_erik@hotmail.com" +AUTHOR = "Erik Hendrix" +REQUIRES_PYTHON = ">=3.7.0" VERSION = None # What packages are required for this module to be executed? REQUIRED = [ # type: ignore - 'tzlocal', - 'requests', - 'psutil', + "tzlocal", + "requests", + "psutil", ] # The rest you shouldn't have to touch too much :) @@ -40,27 +39,28 @@ # Import the README and use it as the long-description. # Note: this will only work if 'README.rst' is present in your MANIFEST.in file! -with io.open(os.path.join(HERE, 'README.rst'), encoding='utf-8') as f: - LONG_DESC = '\n' + f.read() +with io.open(os.path.join(HERE, "README.rst"), encoding="utf-8") as f: + LONG_DESC = "\n" + f.read() # Load the package's __version__.py module as a dictionary. ABOUT = {} # type: ignore if not VERSION: - with open(os.path.join(HERE, NAME, '__version__.py')) as f: + with open(os.path.join(HERE, NAME, "__version__.py")) as f: exec(f.read(), ABOUT) # pylint: disable=exec-used else: - ABOUT['__version__'] = VERSION + ABOUT["__version__"] = VERSION + class UploadCommand(Command): """Support setup.py upload.""" - description = 'Build and publish the package.' + description = "Build and publish the package." user_options = [] # type: ignore @staticmethod def status(string): """Prints things in bold.""" - print('\033[1m{0}\033[0m'.format(string)) + print("\033[1m{0}\033[0m".format(string)) def initialize_options(self): """Add options for initialization.""" @@ -73,21 +73,62 @@ def finalize_options(self): def run(self): """Run.""" try: - self.status('Removing previous builds…') - rmtree(os.path.join(HERE, 'dist')) + self.status("Removing previous builds…") + rmtree(os.path.join(HERE, "dist")) except OSError: pass - self.status('Building Source and Wheel (universal) distribution…') - os.system('{0} setup.py sdist bdist_wheel --universal'.format( - sys.executable)) + self.status("Building Source and Wheel (universal) distribution…") + os.system("{0} setup.py sdist bdist_wheel --universal".format(sys.executable)) - self.status('Uploading the package to PyPi via Twine…') - os.system('twine upload dist/*') + self.status("Uploading the package to PyPi via Twine…") + os.system("twine upload dist/*") - self.status('Pushing git tags…') - os.system('git tag v{0}'.format(ABOUT['__version__'])) - os.system('git push --tags') + self.status("Pushing git tags…") + os.system("git tag v{0}".format(ABOUT["__version__"])) + os.system("git push --tags") + + sys.exit() + + +class TestUploadCommand(Command): + """Support setup.py upload.""" + + description = "Build and publish the package to TestPyPi." + user_options = [] # type: ignore + + @staticmethod + def status(string): + """Prints things in bold.""" + print("\033[1m{0}\033[0m".format(string)) + + def initialize_options(self): + """Add options for initialization.""" + pass + + def finalize_options(self): + """Add options for finalization.""" + pass + + def run(self): + """Run.""" + try: + self.status("Removing previous builds…") + rmtree(os.path.join(HERE, "dist")) + except OSError: + pass + + self.status("Building Source and Wheel (universal) distribution…") + os.system("{0} setup.py sdist bdist_wheel --universal".format(sys.executable)) + + self.status("Uploading the package to TestPyPi via Twine…") + os.system( + "twine upload --repository-url " "https://test.pypi.org/legacy/ dist/*" + ) + + self.status("Pushing git tags…") + os.system("git tag v{0}".format(ABOUT["__version__"])) + os.system("git push --tags") sys.exit() @@ -135,39 +176,33 @@ def run(self): # Where the magic happens: setup( name=NAME, - version=ABOUT['__version__'], + version=ABOUT["__version__"], description=DESCRIPTION, long_description=LONG_DESC, - long_description_content_type='text/x-rst', + long_description_content_type="text/x-rst", author=AUTHOR, # author_email=EMAIL, python_requires=REQUIRES_PYTHON, url=URL, - packages=find_packages(exclude=('tests',)), + packages=find_packages(exclude=("tests",)), # If your package is a single module, use this instead of 'packages': # py_modules=['tesla_dashcam'], - - entry_points={ - 'console_scripts': ['tesla_dashcam=tesla_dashcam:main'], - }, + entry_points={"console_scripts": ["tesla_dashcam=tesla_dashcam:main"]}, install_requires=REQUIRED, include_package_data=True, - license='Apache License 2.0', + license="Apache License 2.0", classifiers=[ # Trove classifiers # Full list: https://pypi.python.org/pypi?%3Aaction=list_classifiers - 'Intended Audience :: End Users/Desktop', - 'License :: OSI Approved :: Apache Software License', - 'Programming Language :: Python', - 'Programming Language :: Python :: 3', - 'Programming Language :: Python :: 3.7', - 'Programming Language :: Python :: Implementation :: CPython', - 'Programming Language :: Python :: Implementation :: PyPy', - 'Topic :: Multimedia :: Video :: Conversion', + "Intended Audience :: End Users/Desktop", + "License :: OSI Approved :: Apache Software License", + "Programming Language :: Python", + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3.7", + "Programming Language :: Python :: Implementation :: CPython", + "Programming Language :: Python :: Implementation :: PyPy", + "Topic :: Multimedia :: Video :: Conversion", ], # $ setup.py publish support. - cmdclass={ - 'upload': UploadCommand, - 'testupload': TestUploadCommand, - }, + cmdclass={"upload": UploadCommand, "testupload": TestUploadCommand}, ) diff --git a/tesla_dashcam/__version__.py b/tesla_dashcam/__version__.py index 13b7089..74acd0e 100644 --- a/tesla_dashcam/__version__.py +++ b/tesla_dashcam/__version__.py @@ -1 +1 @@ -__version__ = '0.1.11' +__version__ = "0.1.12" diff --git a/tesla_dashcam/tesla_dashcam.py b/tesla_dashcam/tesla_dashcam.py index 86b86a3..343497f 100644 --- a/tesla_dashcam/tesla_dashcam.py +++ b/tesla_dashcam/tesla_dashcam.py @@ -4,13 +4,14 @@ """ import argparse import os -import shutil import sys -from datetime import datetime, timedelta +from datetime import datetime, timedelta, timezone +from fnmatch import fnmatch from glob import glob from pathlib import Path from re import search from subprocess import CalledProcessError, run +from tempfile import mkstemp from time import sleep, time as timestamp import requests @@ -26,85 +27,71 @@ # different ones to be created based on where it should go to (stdout, # log file, ...). -VERSION = { - 'major': 0, - 'minor': 1, - 'patch': 11, - 'beta': -1, -} -VERSION_STR = 'v{major}.{minor}.{patch}'.format( - major=VERSION['major'], - minor=VERSION['minor'], - patch=VERSION['patch'], +VERSION = {"major": 0, "minor": 1, "patch": 12, "beta": -1} +VERSION_STR = "v{major}.{minor}.{patch}".format( + major=VERSION["major"], minor=VERSION["minor"], patch=VERSION["patch"] ) -if VERSION['beta'] > -1: - VERSION_STR = VERSION_STR + 'b{beta}'.format( - beta=VERSION['beta'] - ) +if VERSION["beta"] > -1: + VERSION_STR = VERSION_STR + "b{beta}".format(beta=VERSION["beta"]) MONITOR_SLEEP_TIME = 5 GITHUB = { - 'URL': 'https://api.github.com', - 'owner': 'ehendrix23', - 'repo': 'tesla_dashcam', + "URL": "https://api.github.com", + "owner": "ehendrix23", + "repo": "tesla_dashcam", } FFMPEG = { - 'darwin': 'ffmpeg', - 'win32': 'ffmpeg.exe', - 'cygwin': 'ffmpeg', - 'linux': 'ffmpeg', + "darwin": "ffmpeg", + "win32": "ffmpeg.exe", + "cygwin": "ffmpeg", + "linux": "ffmpeg", } MOVIE_HOMEDIR = { - 'darwin': 'Movies/Tesla_Dashcam', - 'win32': 'Videos\Tesla_Dashcam', - 'cygwin': 'Videos/Tesla_Dashcam', - 'linux': 'Videos/Tesla_Dashcam', + "darwin": "Movies/Tesla_Dashcam", + "win32": "Videos\Tesla_Dashcam", + "cygwin": "Videos/Tesla_Dashcam", + "linux": "Videos/Tesla_Dashcam", } DEFAULT_CLIP_HEIGHT = 960 DEFAULT_CLIP_WIDTH = 1280 MOVIE_QUALITY = { - 'HIGH': '18', - 'MEDIUM': '20', - 'LOW': '23', - 'LOWER': '28', - 'LOWEST': '33', + "HIGH": "18", + "MEDIUM": "20", + "LOW": "23", + "LOWER": "28", + "LOWEST": "33", } MOVIE_ENCODING = { - 'x264': 'libx264', - 'x264_nvidia': 'h264_nvenc', - 'x264_mac': 'h264_videotoolbox', - 'x264_intel': 'h264_qsv', - 'x265': 'libx265', - 'x265_nvidia': 'hevc_nvenc', - 'x265_mac': 'hevc_videotoolbox', - 'x265_intel': 'h265_qsv', + "x264": "libx264", + "x264_nvidia": "h264_nvenc", + "x264_mac": "h264_videotoolbox", + "x264_intel": "h264_qsv", + "x265": "libx265", + "x265_nvidia": "hevc_nvenc", + "x265_mac": "hevc_videotoolbox", + "x265_intel": "h265_qsv", } DEFAULT_FONT = { - 'darwin': '/Library/Fonts/Arial.ttf', - 'win32': '/Windows/Fonts/arial.ttf', - 'cygwin': '/cygdrive/c/Windows/Fonts/arial.ttf', - 'linux': '/usr/share/fonts/truetype/freefont/FreeSans.ttf', + "darwin": "/Library/Fonts/Arial.ttf", + "win32": "/Windows/Fonts/arial.ttf", + "cygwin": "/cygdrive/c/Windows/Fonts/arial.ttf", + "linux": "/usr/share/fonts/truetype/freefont/FreeSans.ttf", } -HALIGN = { - 'LEFT': '10', - 'CENTER': '(w/2-text_w/2)', - 'RIGHT': '(w-text_w)', -} +HALIGN = {"LEFT": "10", "CENTER": "(w/2-text_w/2)", "RIGHT": "(w-text_w)"} + +VALIGN = {"TOP": "10", "MIDDLE": "(h/2-(text_h/2))", "BOTTOM": "(h-(text_h*2))"} + +TOASTER_INSTANCE = None -VALIGN = { - 'TOP': '10', - 'MIDDLE': '(h/2-(text_h/2))', - 'BOTTOM': '(h-(text_h*2))', -} class MovieLayout(object): """ WideScreen Format @@ -123,9 +110,9 @@ def __init__(self): self._right_width = 0 self._right_height = 0 - self._left_options = '' - self._front_options = '' - self._right_options = '' + self._left_options = "" + self._front_options = "" + self._right_options = "" self._swap_left_right = False @@ -251,15 +238,19 @@ def right_height(self, size): @property def video_width(self): - return max(self.left_x + self.left_width, - self.front_x + self.front_width, - self.right_x + self.right_width) + return max( + self.left_x + self.left_width, + self.front_x + self.front_width, + self.right_x + self.right_width, + ) @property def video_height(self): - return max(self.left_y + self.left_height, - self.front_y + self.front_height, - self.right_y + self.right_height) + return max( + self.left_y + self.left_height, + self.front_y + self.front_height, + self.right_y + self.right_height, + ) @property def front_x(self): @@ -285,6 +276,7 @@ def right_x(self): def right_y(self): return 0 + class WideScreen(MovieLayout): """ WideScreen Movie Layout """ @@ -303,9 +295,9 @@ def __init__(self): self.right_width = 1280 self.right_height = 960 - self.left_options = '' - self.front_options = '' - self.right_options = '' + self.left_options = "" + self.front_options = "" + self.right_options = "" self.swap_left_right = False @@ -352,17 +344,15 @@ def __init__(self): self.right_width = 1280 self.right_height = 960 - self.left_options = '' - self.front_options = '' - self.right_options = '' + self.left_options = "" + self.front_options = "" + self.right_options = "" self.swap_left_right = False @property def front_x(self): - return max(0, - int((self.right_x + self.right_width) / 2 - - self.front_width / 2)) + return max(0, int((self.right_x + self.right_width) / 2 - self.front_width / 2)) @property def front_y(self): @@ -380,7 +370,6 @@ def left_y(self): def right_x(self): return self.left_x + self.left_width - @property def right_y(self): return self.front_height @@ -404,29 +393,37 @@ def __init__(self): self.right_width = 1280 self.right_height = 960 - self.left_options = ', pad=iw+4:3/2*ih:-1:ih/8:0x00000000, ' \ - 'perspective=x0=0:y0=1*H/5:x1=W:y1=-3/44*H:' \ - 'x2=0:y2=6*H/5:x3=7/8*W:y3=5*H/6:sense=destination' - self.front_options = '' - self.right_options = ', pad=iw+4:3/2*ih:-1:ih/8:0x00000000,' \ - 'perspective=x0=0:y1=1*H/5:x1=W:y0=-3/44*H:' \ - 'x2=1/8*W:y3=6*H/5:x3=W:y2=5*H/6:sense=destination' + self.left_options = ( + ", pad=iw+4:3/2*ih:-1:ih/8:0x00000000, " + "perspective=x0=0:y0=1*H/5:x1=W:y1=-3/44*H:" + "x2=0:y2=6*H/5:x3=7/8*W:y3=5*H/6:sense=destination" + ) + self.front_options = "" + self.right_options = ( + ", pad=iw+4:3/2*ih:-1:ih/8:0x00000000," + "perspective=x0=0:y1=1*H/5:x1=W:y0=-3/44*H:" + "x2=1/8*W:y3=6*H/5:x3=W:y2=5*H/6:sense=destination" + ) self.swap_left_right = False @property def video_width(self): - width = self.front_width + 5 * self.front + \ - self.left_width + 5 * self.left + \ - self.right_width + 5 * self.right + width = ( + self.front_width + + 5 * self.front + + self.left_width + + 5 * self.left + + self.right_width + + 5 * self.right + ) return width + 5 if width > 0 else 0 @property def video_height(self): - height = int(max( - 3/2*self.left_height, - self.front_height, - 3/2*self.right_height)) + height = int( + max(3 / 2 * self.left_height, self.front_height, 3 / 2 * self.right_height) + ) height = height + 5 if height > 0 else 0 return height @@ -473,13 +470,17 @@ def __init__(self): self.right_width = 1280 self.right_height = 960 - self.left_options = ', pad=iw+4:11/6*ih:-1:30:0x00000000,' \ - 'perspective=x0=0:y0=1*H/5:x1=W:y1=-3/44*H:' \ - 'x2=0:y2=6*H/5:x3=W:y3=410:sense=destination' - self.front_options = '' - self.right_options = ', pad=iw+4:11/6*ih:-1:30:0x00000000,' \ - 'perspective=x0=0:y0=-3/44*H:x1=W:y1=1*H/5:' \ - 'x2=0:y2=410:x3=W:y3=6*H/5:sense=destination' + self.left_options = ( + ", pad=iw+4:11/6*ih:-1:30:0x00000000," + "perspective=x0=0:y0=1*H/5:x1=W:y1=-3/44*H:" + "x2=0:y2=6*H/5:x3=W:y3=410:sense=destination" + ) + self.front_options = "" + self.right_options = ( + ", pad=iw+4:11/6*ih:-1:30:0x00000000," + "perspective=x0=0:y0=-3/44*H:x1=W:y1=1*H/5:" + "x2=0:y2=410:x3=W:y3=6*H/5:sense=destination" + ) self.swap_left_right = False @@ -502,10 +503,13 @@ def video_width(self): @property def video_height(self): - height = int(max( - (6*self.left_height/5 + 1*self.left_height/5), - self.front_height, - (self.right_height/5+6*self.right_height/5))) + height = int( + max( + (6 * self.left_height / 5 + 1 * self.left_height / 5), + self.front_height, + (self.right_height / 5 + 6 * self.right_height / 5), + ) + ) height = height + 5 if height > 0 else 0 return height @@ -533,44 +537,41 @@ def right_x(self): def right_y(self): return 5 + class MyArgumentParser(argparse.ArgumentParser): def convert_arg_line_to_args(self, arg_line): return arg_line.split() + # noinspection PyCallByClass,PyProtectedMember,PyProtectedMember class SmartFormatter(argparse.HelpFormatter): """ Formatter for argument help. """ def _split_lines(self, text, width): """ Provide raw output allowing for prettier help output """ - if text.startswith('R|'): + if text.startswith("R|"): return text[2:].splitlines() # this is the RawTextHelpFormatter._split_lines return argparse.HelpFormatter._split_lines(self, text, width) def _get_help_string(self, action): """ Call default help string """ - return argparse.ArgumentDefaultsHelpFormatter._get_help_string(self, - action) + return argparse.ArgumentDefaultsHelpFormatter._get_help_string(self, action) def check_latest_release(include_beta): """ Checks GitHub for latest release """ - url = '{url}/repos/{owner}/{repo}/releases'.format( - url=GITHUB['URL'], - owner=GITHUB['owner'], - repo=GITHUB['repo'], + url = "{url}/repos/{owner}/{repo}/releases".format( + url=GITHUB["URL"], owner=GITHUB["owner"], repo=GITHUB["repo"] ) if not include_beta: - url = url + '/latest' + url = url + "/latest" try: releases = requests.get(url) except requests.exceptions.RequestException as exc: - print("Unable to check for latest release: {exc}".format( - exc=exc - )) + print("Unable to check for latest release: {exc}".format(exc=exc)) return None release_data = releases.json() @@ -585,10 +586,10 @@ def check_latest_release(include_beta): def get_tesladashcam_folder(): """ Check if there is a drive mounted with the Tesla DashCam folder.""" for partition in disk_partitions(all=False): - if 'cdrom' in partition.opts or partition.fstype == '': + if "cdrom" in partition.opts or partition.fstype == "": continue - teslacamfolder = os.path.join(partition.mountpoint, 'TeslaCam') + teslacamfolder = os.path.join(partition.mountpoint, "TeslaCam") if os.path.isdir(teslacamfolder): return teslacamfolder, partition.mountpoint @@ -599,17 +600,29 @@ def get_movie_files(source_folder, exclude_subdirs, video_settings): """ Find all the clip files within folder (and subfolder if requested) """ folder_list = {} + total_folders = 0 for pathname in source_folder: if os.path.isdir(pathname): - # Retrieve all the video files in current path: - search_path = os.path.join(pathname, '*.mp4') - files = (glob(search_path)) - - if not exclude_subdirs: - # Search through all sub folders as well. - search_path = os.path.join(pathname, '*', '*.mp4') - files = files + (glob(search_path)) isfile = False + if exclude_subdirs: + # Retrieve all the video files in current path: + search_path = os.path.join(pathname, "*.mp4") + files = glob(search_path) + print("Discovered {} files, retrieving clip data.".format(len(files))) + else: + # Search all sub folder. + files = [] + for folder, _, filenames in os.walk(pathname, followlinks=True): + total_folders = total_folders + 1 + for filename in filenames: + if fnmatch(filename, "*.mp4"): + files.append(os.path.join(folder, filename)) + + print( + "Discovered {} folders containing total of {} files, retrieving clip data.".format( + total_folders, len(files) + ) + ) else: files = [pathname] isfile = True @@ -620,7 +633,7 @@ def get_movie_files(source_folder, exclude_subdirs, video_settings): movie_folder, movie_filename = os.path.split(file) # And now get the timestamp of the filename. - filename_timestamp = movie_filename.rsplit('-', 1)[0] + filename_timestamp = movie_filename.rsplit("-", 1)[0] movie_file_list = folder_list.get(movie_folder, {}) @@ -630,56 +643,59 @@ def get_movie_files(source_folder, exclude_subdirs, video_settings): continue video_info = { - 'front_camera': { - 'filename': None, - 'duration': None, - 'timestamp': None, + "front_camera": { + "filename": None, + "duration": None, + "timestamp": None, + "include": False, }, - 'left_camera': { - 'filename': None, - 'duration': None, - 'timestamp': None, + "left_camera": { + "filename": None, + "duration": None, + "timestamp": None, + "include": False, }, - 'right_camera': { - 'filename': None, - 'duration': None, - 'timestamp': None, + "right_camera": { + "filename": None, + "duration": None, + "timestamp": None, + "include": False, }, } - if video_settings['video_layout'].front: - front_filename = str(filename_timestamp) + '-front.mp4' + if video_settings["video_layout"].front: + front_filename = str(filename_timestamp) + "-front.mp4" front_path = os.path.join(movie_folder, front_filename) else: front_filename = None front_path = "" - if video_settings['video_layout'].left: - left_filename = str(filename_timestamp) + '-left_repeater.mp4' + if video_settings["video_layout"].left: + left_filename = str(filename_timestamp) + "-left_repeater.mp4" left_path = os.path.join(movie_folder, left_filename) else: left_filename = None left_path = "" - if video_settings['video_layout'].right: - right_filename = str(filename_timestamp) + '-right_repeater.mp4' + if video_settings["video_layout"].right: + right_filename = str(filename_timestamp) + "-right_repeater.mp4" right_path = os.path.join(movie_folder, right_filename) else: right_filename = None right_path = "" # Confirm we have at least one movie file: - if not os.path.isfile(front_path) and \ - not os.path.isfile(left_path) and \ - not os.path.isfile(right_path): + if ( + not os.path.isfile(front_path) + and not os.path.isfile(left_path) + and not os.path.isfile(right_path) + ): continue # Get meta data for each video to determine creation time and duration. - metadata = get_metadata(video_settings['ffmpeg_exec'], [ - front_path, - left_path, - right_path, - ]) + metadata = get_metadata( + video_settings["ffmpeg_exec"], [front_path, left_path, right_path] + ) # Move on to next one if nothing received. if not metadata: @@ -689,56 +705,65 @@ def get_movie_files(source_folder, exclude_subdirs, video_settings): duration = 0 video_timestamp = None for item in metadata: - _, filename = os.path.split(item['filename']) + _, filename = os.path.split(item["filename"]) if filename == front_filename: - camera = 'front_camera' + camera = "front_camera" video_filename = front_filename elif filename == left_filename: - camera = 'left_camera' + camera = "left_camera" video_filename = left_filename elif filename == right_filename: - camera = 'right_camera' + camera = "right_camera" video_filename = right_filename else: continue # Store duration and timestamp - video_info[camera].update(filename=video_filename, - duration=item['duration'], - timestamp=item['timestamp'], - ) - - # Figure out which one has the longest duration - duration = item['duration'] if item['duration'] > duration else \ - duration - - # Figure out starting timestamp - if video_timestamp is None: - video_timestamp = item['timestamp'] - else: - video_timestamp = item['timestamp'] \ - if item['timestamp'] < video_timestamp else \ - video_timestamp + video_info[camera].update( + filename=video_filename, + duration=item["duration"], + timestamp=item["timestamp"], + include=item["include"], + ) + + # Only check duration and timestamp if this file is not corrupt. + if item["include"]: + # Figure out which one has the longest duration + duration = ( + item["duration"] if item["duration"] > duration else duration + ) + + # Figure out starting timestamp + if video_timestamp is None: + video_timestamp = item["timestamp"] + else: + video_timestamp = ( + item["timestamp"] + if item["timestamp"] < video_timestamp + else video_timestamp + ) if video_timestamp is None: # Firmware version 2019.16 changed filename timestamp format. if len(filename_timestamp) == 16: # This is for before version 2019.16 video_timestamp = datetime.strptime( - filename_timestamp, - "%Y-%m-%d_%H-%M") + filename_timestamp, "%Y-%m-%d_%H-%M" + ) + video_timestamp = video_timestamp.astimezone(get_localzone()) else: # This is for version 2019.16 and later video_timestamp = datetime.strptime( - filename_timestamp, - "%Y-%m-%d_%H-%M-%S") + filename_timestamp, "%Y-%m-%d_%H-%M-%S" + ) + video_timestamp = video_timestamp.astimezone(timezone.utc) movie_info = { - 'movie_folder': movie_folder, - 'timestamp': video_timestamp, - 'duration': duration, - 'video_info': video_info, - 'file_only': isfile, + "movie_folder": movie_folder, + "timestamp": video_timestamp, + "duration": duration, + "video_info": video_info, + "file_only": isfile, } movie_file_list.update({filename_timestamp: movie_info}) @@ -750,25 +775,30 @@ def get_movie_files(source_folder, exclude_subdirs, video_settings): def get_metadata(ffmpeg, filenames): """ Retrieve the meta data for the clip (i.e. timestamp, duration) """ # Get meta data for each video to determine creation time and duration. - ffmpeg_command = [ - ffmpeg, - ] + ffmpeg_command = [ffmpeg] + metadata = [] for file in filenames: if os.path.isfile(file): - ffmpeg_command.append('-i') + ffmpeg_command.append("-i") ffmpeg_command.append(file) - ffmpeg_command.append('-hide_banner') + metadata.append( + {"filename": file, "timestamp": None, "duration": 0, "include": False} + ) + + ffmpeg_command.append("-hide_banner") command_result = run(ffmpeg_command, capture_output=True, text=True) input_counter = 0 - file = '' - metadata = [] + file = "" + video_timestamp = None wait_for_input_line = True for line in command_result.stderr.splitlines(): if search("^Input #", line) is not None: + # If filename was not yet appended then it means it is a corrupt file, in that case just add to list for + # but identify not to include for processing file = filenames[input_counter] input_counter += 1 video_timestamp = None @@ -779,321 +809,371 @@ def get_metadata(ffmpeg, filenames): continue if search("^ *creation_time ", line) is not None: - line_split = line.split(':', 1) - video_timestamp = datetime.strptime(line_split[1].strip(), - "%Y-%m-%dT%H:%M:%S.%f%z") + line_split = line.split(":", 1) + video_timestamp = datetime.strptime( + line_split[1].strip(), "%Y-%m-%dT%H:%M:%S.%f%z" + ) continue if search("^ *Duration: ", line) is not None: - line_split = line.split(',') - line_split = line_split[0].split(':', 1) - duration_list = line_split[1].split(':') - duration = int(duration_list[0]) * 60 * 60 + \ - int(duration_list[1]) * 60 + \ - int(duration_list[2].split('.')[0]) + \ - (float(duration_list[2].split('.')[1]) / 100) - - # Only add if duration is greater then 0; otherwise ignore. - if duration <= 0: - continue - - metadata.append( - { - 'filename': file, - 'timestamp': video_timestamp, - 'duration': duration, - } + line_split = line.split(",") + line_split = line_split[0].split(":", 1) + duration_list = line_split[1].split(":") + duration = ( + int(duration_list[0]) * 60 * 60 + + int(duration_list[1]) * 60 + + int(duration_list[2].split(".")[0]) + + (float(duration_list[2].split(".")[1]) / 100) ) - continue + # File will only be processed if duration is greater then 0 + include = duration > 0 + + # Update our metadata list. + element = next( + (item for item in metadata if item["filename"] == file), None + ) + # Should never be None but just in case. :-) + if element != None: + element.update( + { + "timestamp": video_timestamp, + "duration": duration, + "include": include, + } + ) return metadata -def create_intermediate_movie(filename_timestamp, - video, - video_settings, - clip_number, - total_clips): +def create_intermediate_movie( + filename_timestamp, video, video_settings, clip_number, total_clips +): """ Create intermediate movie files. This is the merging of the 3 camera video files into 1 video file. """ # We first stack (combine the 3 different camera video files into 1 # and then we concatenate. camera_1 = None - if video['video_info']['front_camera']['filename'] is not None: + if ( + video["video_info"]["front_camera"]["filename"] is not None + and video["video_info"]["front_camera"]["include"] + ): camera_1 = os.path.join( - video['movie_folder'], - video['video_info']['front_camera']['filename']) + video["movie_folder"], video["video_info"]["front_camera"]["filename"] + ) left_camera = None - if video['video_info']['left_camera']['filename'] is not None: + if ( + video["video_info"]["left_camera"]["filename"] is not None + and video["video_info"]["left_camera"]["include"] + ): left_camera = os.path.join( - video['movie_folder'], - video['video_info']['left_camera']['filename']) + video["movie_folder"], video["video_info"]["left_camera"]["filename"] + ) right_camera = None - if video['video_info']['right_camera']['filename'] is not None: + if ( + video["video_info"]["right_camera"]["filename"] is not None + and video["video_info"]["right_camera"]["include"] + ): right_camera = os.path.join( - video['movie_folder'], - video['video_info']['right_camera']['filename']) + video["movie_folder"], video["video_info"]["right_camera"]["filename"] + ) if camera_1 is None and left_camera is None and right_camera is None: - print("\t\tNo valid video files for {timestamp}".format( - timestamp=filename_timestamp, - )) - return None + return None, 0, True - if video_settings['video_layout'].swap_left_right: + if video_settings["video_layout"].swap_left_right: camera_2 = left_camera - clip_2 = (video_settings['video_layout'].left_width, video_settings[ - 'video_layout'].left_height) + clip_2 = ( + video_settings["video_layout"].left_width, + video_settings["video_layout"].left_height, + ) camera_0 = right_camera - clip_0 = (video_settings['video_layout'].right_width, video_settings[ - 'video_layout'].right_height) + clip_0 = ( + video_settings["video_layout"].right_width, + video_settings["video_layout"].right_height, + ) else: camera_0 = left_camera - clip_0 = (video_settings['video_layout'].left_width, video_settings[ - 'video_layout'].left_height) + clip_0 = ( + video_settings["video_layout"].left_width, + video_settings["video_layout"].left_height, + ) camera_2 = right_camera - clip_2 = (video_settings['video_layout'].right_width, video_settings[ - 'video_layout'].right_height) + clip_2 = ( + video_settings["video_layout"].right_width, + video_settings["video_layout"].right_height, + ) - temp_movie_name = os.path.join(video_settings['target_folder'], - filename_timestamp) + '.mp4' + temp_movie_name = ( + os.path.join(video_settings["target_folder"], filename_timestamp) + ".mp4" + ) - movie_layout = video_settings['movie_layout'] - speed = video_settings['movie_speed'] + speed = video_settings["movie_speed"] # Confirm if files exist, if not replace with nullsrc input_count = 0 if camera_0 is not None and os.path.isfile(camera_0): - ffmpeg_command_0 = [ - '-i', - camera_0 - ] - ffmpeg_camera_0 = '[0:v] ' + video_settings['input_0'] + ffmpeg_command_0 = ["-i", camera_0] + ffmpeg_camera_0 = "[0:v] " + video_settings["input_0"] input_count += 1 else: ffmpeg_command_0 = [] - ffmpeg_camera_0 = video_settings['background'].format( - duration=video['duration'], - speed=speed, - width=clip_0[0], - height=clip_0[1], - ) + '[left];' + ffmpeg_camera_0 = ( + video_settings["background"].format( + duration=video["duration"], + speed=speed, + width=clip_0[0], + height=clip_0[1], + ) + + "[left];" + ) if camera_1 is not None and os.path.isfile(camera_1): - ffmpeg_command_1 = [ - '-i', - camera_1 - ] - ffmpeg_camera_1 = '[' + str(input_count) + ':v] ' + \ - video_settings['input_1'] + ffmpeg_command_1 = ["-i", camera_1] + ffmpeg_camera_1 = "[" + str(input_count) + ":v] " + video_settings["input_1"] input_count += 1 else: ffmpeg_command_1 = [] - ffmpeg_camera_1 = video_settings['background'].format( - duration=video['duration'], - speed=speed, - width=video_settings['video_layout'].front_width, - height=video_settings['video_layout'].front_height, - ) + '[front];' + ffmpeg_camera_1 = ( + video_settings["background"].format( + duration=video["duration"], + speed=speed, + width=video_settings["video_layout"].front_width, + height=video_settings["video_layout"].front_height, + ) + + "[front];" + ) if camera_2 is not None and os.path.isfile(camera_2): - ffmpeg_command_2 = [ - '-i', - camera_2 - ] - ffmpeg_camera_2 = '[' + str(input_count) + ':v] ' + \ - video_settings['input_2'] + ffmpeg_command_2 = ["-i", camera_2] + ffmpeg_camera_2 = "[" + str(input_count) + ":v] " + video_settings["input_2"] input_count += 1 else: ffmpeg_command_2 = [] - ffmpeg_camera_2 = video_settings['background'].format( - duration=video['duration'], - speed=speed, - width=clip_2[0], - height=clip_2[1], - ) + '[right];' + ffmpeg_camera_2 = ( + video_settings["background"].format( + duration=video["duration"], + speed=speed, + width=clip_2[0], + height=clip_2[1], + ) + + "[right];" + ) # If we could not get a timestamp then retrieve it from the filename # instead - if video['timestamp'] is None: + if video["timestamp"] is None: # Get the pure filename which would be timestamp in format: # YYYY-MM-DD_HH-MM # Split in date and time parts - timestamps = filename_timestamp.split('_') + timestamps = filename_timestamp.split("_") # Split date - date = timestamps[0].split('-') + date = timestamps[0].split("-") # Split time - time = timestamps[1].split('-') - video_timestamp = datetime(int(date[0]), - int(date[1]), - int(date[2]), - int(time[0]), - int(time[1])) + time = timestamps[1].split("-") + video_timestamp = datetime( + int(date[0]), int(date[1]), int(date[2]), int(time[0]), int(time[1]) + ) else: - video_timestamp = video['timestamp'] + video_timestamp = video["timestamp"] local_timestamp = video_timestamp.astimezone(get_localzone()) - print("\t\tProcessing clip {clip_number}/{total_clips} from {timestamp} " - "and {duration} seconds long.".format( - clip_number=clip_number + 1, - total_clips=total_clips, - timestamp=local_timestamp.strftime("%x %X"), - duration=int(video['duration']), - )) + print( + "\t\tProcessing clip {clip_number}/{total_clips} from {timestamp} " + "and {duration} seconds long.".format( + clip_number=clip_number + 1, + total_clips=total_clips, + timestamp=local_timestamp.strftime("%x %X"), + duration=int(video["duration"]), + ) + ) epoch_timestamp = int(video_timestamp.timestamp()) - ffmpeg_filter = \ - video_settings['base'].format( - duration=video['duration'], - speed=speed, ) + \ - ffmpeg_camera_0 + \ - ffmpeg_camera_1 + \ - ffmpeg_camera_2 + \ - video_settings['clip_positions'] + \ - video_settings['timestamp_text'].format( - epoch_time=epoch_timestamp) + \ - video_settings['ffmpeg_speed'] - - ffmpeg_command = [video_settings['ffmpeg_exec']] + \ - ffmpeg_command_0 + \ - ffmpeg_command_1 + \ - ffmpeg_command_2 + \ - ['-filter_complex', ffmpeg_filter] + \ - video_settings['other_params'] - - ffmpeg_command = ffmpeg_command + ['-y', temp_movie_name] + ffmpeg_filter = ( + video_settings["base"].format(duration=video["duration"], speed=speed) + + ffmpeg_camera_0 + + ffmpeg_camera_1 + + ffmpeg_camera_2 + + video_settings["clip_positions"] + + video_settings["timestamp_text"].format(epoch_time=epoch_timestamp) + + video_settings["ffmpeg_speed"] + ) + + ffmpeg_command = ( + [video_settings["ffmpeg_exec"]] + + ffmpeg_command_0 + + ffmpeg_command_1 + + ffmpeg_command_2 + + ["-filter_complex", ffmpeg_filter] + + video_settings["other_params"] + ) + + ffmpeg_command = ffmpeg_command + ["-y", temp_movie_name] # print(ffmpeg_command) # Run the command. try: run(ffmpeg_command, capture_output=True, check=True) except CalledProcessError as exc: - print("\t\t\tError trying to create clip for {base_name}. RC: {rc}\n" - "\t\t\tCommand: {command}\n" - "\t\t\tError: {stderr}\n\n".format( - base_name=os.path.join(video['movie_folder'], - filename_timestamp), - rc=exc.returncode, - command=exc.cmd, - stderr=exc.stderr, - )) - return None + print( + "\t\t\tError trying to create clip for {base_name}. RC: {rc}\n" + "\t\t\tCommand: {command}\n" + "\t\t\tError: {stderr}\n\n".format( + base_name=os.path.join(video["movie_folder"], filename_timestamp), + rc=exc.returncode, + command=exc.cmd, + stderr=exc.stderr, + ) + ) + return None, 0, False + + # Get actual duration of our new video, required for chapters when concatenating. + metadata = get_metadata(video_settings["ffmpeg_exec"], [temp_movie_name]) + duration = metadata[0]["duration"] if metadata else video["duration"] - return temp_movie_name + return temp_movie_name, duration, True -def create_movie(clips_list, movie_filename, video_settings): +def create_movie(clips_list, movie_filename, video_settings, chapter_offset): """ Concatenate provided movie files into 1.""" # Just return if there are no clips. if not clips_list: return None - # If there is only 1 clip then we can just put it in place as there is - # nothing to concatenate. - if len(clips_list) == 1: - # If not output folder provided then these 2 are the same and thus - # nothing to be done. - if movie_filename == clips_list[0]['video_filename']: - return movie_filename - - # There really was only one, no need to create, just move - # intermediate file. - # Remove file 1st if it exist otherwise on Windows we can't rename. - if os.path.isfile(movie_filename): - try: - os.remove(movie_filename) - except OSError as exc: - # Putting out error but going to try to copy/move anyway. - print("\t\tError trying to remove file {}: {}".format( - movie_filename, - exc)) - - if not video_settings['keep_intermediate']: - try: - shutil.move(clips_list[0]['video_filename'], - movie_filename) - except OSError as exc: - print("\t\tError trying to move file {} to {}: {}".format( - clips_list[0]['video_filename'], - movie_filename, - exc)) - return None - else: - try: - shutil.copyfile(clips_list[0]['video_filename'], - movie_filename) - except OSError as exc: - print("\t\tError trying to copy file {} to {}: {}".format( - clips_list[0]['video_filename'], - movie_filename, - exc)) - return None - - return movie_filename - - # Go through the list of clips to create the command. - ffmpeg_concat_input = [] - concat_filter_complex = '' + # Go through the list of clips to create the command and content for chapter meta file. + ffmpeg_join_filehandle, ffmpeg_join_filename = mkstemp(suffix=".txt", text=True) total_clips = 0 - # Loop through the list sorted by video timestamp. - for video_clip in sorted(clips_list, key=lambda video: video[ - 'video_timestamp']): - if not os.path.isfile(video_clip['video_filename']): - print("\t\tFile {} does not exist anymore, skipping.".format( - video_clip['video_filename'] - )) - continue + meta_content = "" + meta_start = 0 + chapter_offset = chapter_offset * 1000000000 + with os.fdopen(ffmpeg_join_filehandle, "w") as fp: + # Loop through the list sorted by video timestamp. + for video_clip in sorted( + clips_list, key=lambda video: video["video_timestamp"] + ): + if not os.path.isfile(video_clip["video_filename"]): + print( + "\t\tFile {} does not exist anymore, skipping.".format( + video_clip["video_filename"] + ) + ) + continue - ffmpeg_concat_input = ffmpeg_concat_input + ['-i', - video_clip['video_filename']] - concat_filter_complex = concat_filter_complex + \ - '[{clip}:v:0] '.format( - clip=total_clips + # Add this file in our join list. + fp.write( + "file '" + + video_clip["video_filename"] + + "'{linesep}".format(linesep=os.linesep) ) - total_clips = total_clips + 1 + total_clips = total_clips + 1 + title = video_clip["video_timestamp"].astimezone(get_localzone()) + # For duration need to also calculate if video was sped-up or slowed down. + video_duration = int(video_clip["video_duration"] * 1000000000) + chapter_start = meta_start + if video_duration > abs(chapter_offset): + if chapter_offset < 0: + chapter_start = meta_start + video_duration + chapter_offset + elif chapter_offset > 0: + chapter_start = chapter_start + chapter_offset + + # We need to add an initial chapter if our "1st" chapter is not at the beginning of the movie. + if total_clips == 1 and chapter_start > 0: + meta_content = ( + "[CHAPTER]{linesep}" + "TIMEBASE=1/1000000000{linesep}" + "START={start}{linesep}" + "END={end}{linesep}" + "title={title}{linesep}".format( + linesep=os.linesep, + start=0, + end=chapter_start - 1, + title="Start", + ) + ) + + meta_content = ( + meta_content + "[CHAPTER]{linesep}" + "TIMEBASE=1/1000000000{linesep}" + "START={start}{linesep}" + "END={end}{linesep}" + "title={title}{linesep}".format( + linesep=os.linesep, + start=chapter_start, + end=meta_start + video_duration, + title=title.strftime("%x %X"), + ) + ) + meta_start = meta_start + 1 + video_duration if total_clips == 0: print("\t\tError: No valid clips to merge found.") return None - concat_filter_complex = concat_filter_complex + \ - "concat=n={total_clips}:v=1:a=0 [v]".format( - total_clips=total_clips, - ) + # Write out the meta data file. + meta_content = ";FFMETADATA1" + os.linesep + meta_content + + ffmpeg_meta_filehandle, ffmpeg_meta_filename = mkstemp(suffix=".txt", text=True) + with os.fdopen(ffmpeg_meta_filehandle, "w") as fp: + fp.write(meta_content) - ffmpeg_params = ['-filter_complex', - concat_filter_complex, - '-map', - '[v]', - '-preset', - video_settings['movie_compression'], - '-crf', - MOVIE_QUALITY[video_settings['movie_quality']] - ] + \ - video_settings['video_encoding'] - - ffmpeg_command = [video_settings['ffmpeg_exec']] + \ - ffmpeg_concat_input + \ - ffmpeg_params + \ - ['-y', movie_filename] + ffmpeg_params = [ + "-f", + "concat", + "-safe", + "0", + "-i", + ffmpeg_join_filename, + "-i", + ffmpeg_meta_filename, + "-map_metadata", + "1", + "-map_chapters", + "1", + "-movflags", + "+faststart", + "-c", + "copy", + ] + + ffmpeg_command = ( + [video_settings["ffmpeg_exec"]] + ffmpeg_params + ["-y", movie_filename] + ) try: run(ffmpeg_command, capture_output=True, check=True) except CalledProcessError as exc: - print("\t\tError trying to create movie {base_name}. RC: {rc}\n" - "\t\tCommand: {command}\n" - "\t\tError: {stderr}\n\n".format( - base_name=movie_filename, - rc=exc.returncode, - command=exc.cmd, - stderr=exc.stderr, - )) - return None + print( + "\t\tError trying to create movie {base_name}. RC: {rc}\n" + "\t\tCommand: {command}\n" + "\t\tError: {stderr}\n\n".format( + base_name=movie_filename, + rc=exc.returncode, + command=exc.cmd, + stderr=exc.stderr, + ) + ) + movie_filename = None + duration = 0 + else: + # Get actual duration of our new video, required for chapters when concatenating. + metadata = get_metadata(video_settings["ffmpeg_exec"], [movie_filename]) + duration = metadata[0]["duration"] if metadata else video["duration"] + + # Remove temp join file. + try: + os.remove(ffmpeg_join_filename) + except: + pass + + # Remove temp join file. + try: + os.remove(ffmpeg_meta_filename) + except: + pass - return movie_filename + return movie_filename, duration def delete_intermediate(movie_files): @@ -1102,20 +1182,22 @@ def delete_intermediate(movie_files): if file is not None: if os.path.isfile(file): try: - os.remove(file) except OSError as exc: - print("\t\tError trying to remove file {}: {}".format( - file, - exc)) + print("\t\tError trying to remove file {}: {}".format(file, exc)) elif os.path.isdir(file): + # This is more specific for Mac but won't hurt on other platforms. + if os.path.exists(os.path.join(file, ".DS_Store")): + try: + os.remove(os.path.join(file, ".DS_Store")) + except: + pass + try: os.rmdir(file) except OSError as exc: - print("\t\tError trying to remove folder {}: {}".format( - file, - exc)) + print("\t\tError trying to remove folder {}: {}".format(file, exc)) def process_folders(folders, video_settings, skip_existing, delete_source): @@ -1125,11 +1207,10 @@ def process_folders(folders, video_settings, skip_existing, delete_source): total_clips = 0 for folder_number, folder_name in enumerate(sorted(folders)): total_clips = total_clips + len(folders[folder_name]) - print("Discovered {total_folders} folders with {total_clips} clips to " - "process.".format( - total_folders=len(folders), - total_clips=total_clips - )) + print( + "There are {total_folders} folders with {total_clips} clips to " + "process.".format(total_folders=len(folders), total_clips=total_clips) + ) # Loop through all the folders. dashcam_clips = [] @@ -1137,50 +1218,55 @@ def process_folders(folders, video_settings, skip_existing, delete_source): files = folders[folder_name] # Ensure the clips are sorted based on video timestamp. - sorted_video_clips = sorted( - files, - key=lambda video: files[video]['timestamp']) + sorted_video_clips = sorted(files, key=lambda video: files[video]["timestamp"]) # Get the start and ending timestamps, we add duration to # last timestamp to get true ending. - first_clip_tmstp = files[sorted_video_clips[0]]['timestamp'] + first_clip_tmstp = files[sorted_video_clips[0]]["timestamp"] - last_clip_tmstp = files[sorted_video_clips[-1]]['timestamp'] + \ - timedelta( - seconds= - files[sorted_video_clips[-1]]['duration']) + last_clip_tmstp = files[sorted_video_clips[-1]]["timestamp"] + timedelta( + seconds=files[sorted_video_clips[-1]]["duration"] + ) # Convert timestamp to local timezone. first_clip_tmstp = first_clip_tmstp.astimezone(get_localzone()) last_clip_tmstp = last_clip_tmstp.astimezone(get_localzone()) # Put them together to create the filename for the folder. - movie_filename = first_clip_tmstp.strftime("%Y-%m-%dT%H-%M-%S") + \ - "_" + last_clip_tmstp.strftime("%Y-%m-%dT%H-%M-%S") + movie_filename = ( + first_clip_tmstp.strftime("%Y-%m-%dT%H-%M-%S") + + "_" + + last_clip_tmstp.strftime("%Y-%m-%dT%H-%M-%S") + ) # Now add full path to it. - movie_filename = os.path.join(video_settings['target_folder'], - movie_filename) + '.mp4' + movie_filename = ( + os.path.join(video_settings["target_folder"], movie_filename) + ".mp4" + ) # Do not process the files from this folder if we're to skip it if # the target movie file already exist. if skip_existing and os.path.isfile(movie_filename): - print("\tSkipping folder {folder} as {filename} is already " - "created ({folder_number}/{total_folders})".format( - folder=folder_name, - filename=movie_filename, - folder_number=folder_number + 1, - total_folders=len(folders), - )) + print( + "\tSkipping folder {folder} as {filename} is already " + "created ({folder_number}/{total_folders})".format( + folder=folder_name, + filename=movie_filename, + folder_number=folder_number + 1, + total_folders=len(folders), + ) + ) continue - print("\tProcessing {total_clips} clips in folder {folder} " - "({folder_number}/{total_folders})".format( - total_clips=len(files), - folder=folder_name, - folder_number=folder_number + 1, - total_folders=len(folders), - )) + print( + "\tProcessing {total_clips} clips in folder {folder} " + "({folder_number}/{total_folders})".format( + total_clips=len(files), + folder=folder_name, + folder_number=folder_number + 1, + total_folders=len(folders), + ) + ) # Loop through all the files within the folder. folder_clips = [] @@ -1191,175 +1277,206 @@ def process_folders(folders, video_settings, skip_existing, delete_source): for clip_number, filename_timestamp in enumerate(sorted_video_clips): video_timestamp_info = files[filename_timestamp] - folder_timestamp = video_timestamp_info['timestamp'] \ - if folder_timestamp is None else folder_timestamp - clip_name = create_intermediate_movie( + folder_timestamp = ( + video_timestamp_info["timestamp"] + if folder_timestamp is None + else folder_timestamp + ) + clip_name, clip_duration, files_processed = create_intermediate_movie( filename_timestamp, video_timestamp_info, video_settings, clip_number, - len(files) + len(files), ) - if clip_name is not None: - if video_timestamp_info['file_only']: + if video_timestamp_info["file_only"]: # When file only there is no concatenation at the folder # level, will only happen at the higher level if requested. - dashcam_clips.append({ - 'video_timestamp': video_timestamp_info['timestamp'], - 'video_filename': clip_name - }) + dashcam_clips.append( + { + "video_timestamp": video_timestamp_info["timestamp"], + "video_filename": clip_name, + "video_duration": clip_duration, + } + ) else: # Movie was created, store name for concatenation. - folder_clips.append({ - 'video_timestamp': video_timestamp_info['timestamp'], - 'video_filename': clip_name - }) + folder_clips.append( + { + "video_timestamp": video_timestamp_info["timestamp"], + "video_filename": clip_name, + "video_duration": clip_duration, + } + ) # Add clip for deletion only if it's name is not the # same as the resulting movie filename if clip_name != movie_filename: delete_folder_clips.append(clip_name) - - # Add the files to our list for removal. - video_info = video_timestamp_info['video_info'] - if video_info['front_camera']['filename'] is not None: - delete_file_list.append( - os.path.join( - video_timestamp_info['movie_folder'], - video_info['front_camera']['filename'])) - - if video_info['left_camera']['filename'] is not None: - delete_file_list.append( - os.path.join( - video_timestamp_info['movie_folder'], - video_info['left_camera']['filename'])) - - if video_info['right_camera']['filename'] is not None: - delete_file_list.append( - os.path.join( - video_timestamp_info['movie_folder'], - video_info['right_camera']['filename'])) - else: + elif not files_processed: delete_folder_files = False + if files_processed: + # Add the files to our list for removal. + video_info = video_timestamp_info["video_info"] + if video_info["front_camera"]["filename"] is not None: + delete_file_list.append( + os.path.join( + video_timestamp_info["movie_folder"], + video_info["front_camera"]["filename"], + ) + ) + + if video_info["left_camera"]["filename"] is not None: + delete_file_list.append( + os.path.join( + video_timestamp_info["movie_folder"], + video_info["left_camera"]["filename"], + ) + ) + + if video_info["right_camera"]["filename"] is not None: + delete_file_list.append( + os.path.join( + video_timestamp_info["movie_folder"], + video_info["right_camera"]["filename"], + ) + ) + # All clips in folder have been processed, merge those clips # together now. movie_name = None if folder_clips: - print("\t\tCreating movie {}, please be patient.".format( - movie_filename)) + print("\t\tCreating movie {}, please be patient.".format(movie_filename)) - movie_name = create_movie( - folder_clips, - movie_filename, - video_settings, + movie_name, movie_duration = create_movie( + folder_clips, movie_filename, video_settings, 0 ) + # Delete the source files if stated to delete. + # We only do so if there were no issues in processing the clips + if delete_folder_files and ( + (folder_clips and movie_name is not None) or not folder_clips + ): + print( + "\t\tDeleting files and folder {folder_name}".format( + folder_name=folder_name + ) + ) + delete_intermediate(delete_file_list) + # And delete the folder + delete_intermediate([folder_name]) + # Add this one to our list for final concatenation if movie_name is not None: - dashcam_clips.append({ - 'video_timestamp': folder_timestamp, - 'video_filename': movie_name - }) + dashcam_clips.append( + { + "video_timestamp": folder_timestamp, + "video_filename": movie_name, + "video_duration": movie_duration, + } + ) # Delete the intermediate files we created. - if not video_settings['keep_intermediate']: + if not video_settings["keep_intermediate"]: delete_intermediate(delete_folder_clips) - # Delete the source files if stated to delete. - if delete_folder_files: - print("\t\tDeleting files and folder {folder_name}".format( - folder_name=folder_name - )) - delete_intermediate(delete_file_list) - # And delete the folder - delete_intermediate([folder_name]) - - print("\tMovie {base_name} for folder {folder_name} is " - "ready.".format( - base_name=movie_name, - folder_name=folder_name, - )) + print( + "\tMovie {base_name} for folder {folder_name} with duration {duration} is " + "ready.".format( + base_name=movie_name, + folder_name=folder_name, + duration=str(timedelta(seconds=int(movie_duration))), + ) + ) # Now that we have gone through all the folders merge. # We only do this if merge is enabled OR if we only have 1 clip and for # output a specific filename was provided. movie_name = None if dashcam_clips: - if video_settings['merge_subdirs'] or \ - (len(folders) == 1 and - video_settings['target_filename'] is not None): - - if video_settings['movie_filename'] is not None: - movie_filename = video_settings['movie_filename'] - elif video_settings['target_filename'] is not None: - movie_filename = video_settings['target_filename'] + if video_settings["merge_subdirs"] or ( + len(folders) == 1 and video_settings["target_filename"] is not None + ): + + if video_settings["movie_filename"] is not None: + movie_filename = video_settings["movie_filename"] + elif video_settings["target_filename"] is not None: + movie_filename = video_settings["target_filename"] else: - folder, movie_filename = os.path.split( - video_settings['target_folder']) + folder, movie_filename = os.path.split(video_settings["target_folder"]) # If there was a trailing separator provided then it will be # empty, redo split then. - if movie_filename == '': + if movie_filename == "": movie_filename = os.path.split(folder)[1] movie_filename = os.path.join( - video_settings['target_folder'], - movie_filename + video_settings["target_folder"], movie_filename ) # Make sure it ends in .mp4 - if os.path.splitext(movie_filename)[1] != '.mp4': - movie_filename = movie_filename + '.mp4' + if os.path.splitext(movie_filename)[1] != ".mp4": + movie_filename = movie_filename + ".mp4" - print("\tCreating movie {}, please be patient.".format( - movie_filename)) + print("\tCreating movie {}, please be patient.".format(movie_filename)) - movie_name = create_movie( + movie_name, movie_duration = create_movie( dashcam_clips, movie_filename, video_settings, + video_settings["chapter_offset"], ) if movie_name is not None: - print("Movie {base_name} has been created, enjoy.".format( - base_name=movie_name)) + print( + "Movie {base_name} with duration {duration} has been created, enjoy.".format( + base_name=movie_name, + duration=str(timedelta(seconds=int(movie_duration))), + ) + ) else: - print("All folders have been processed, resulting movie files are " - "located in {target_folder}".format( - target_folder=video_settings['target_folder'] - )) + print( + "All folders have been processed, resulting movie files are " + "located in {target_folder}".format( + target_folder=video_settings["target_folder"] + ) + ) else: print("No clips found.") end_time = timestamp() real = int((end_time - start_time)) - print("Total processing time: {real}".format( - real=str(timedelta(seconds=real)), - )) - if video_settings['notification']: + print("Total processing time: {real}".format(real=str(timedelta(seconds=real)))) + if video_settings["notification"]: if movie_name is not None: - notify("TeslaCam", "Completed", - "{total_folders} folder{folders} with {total_clips} " - "clip{clips} have been processed, movie {movie_name} has " - "been created.".format( - folders='' if len(folders) < 2 else 's', - total_folders=len(folders), - clips='' if total_clips < 2 else 's', - total_clips=total_clips, - movie_name=video_settings['target_folder'] - )) + notify( + "TeslaCam", + "Completed", + "{total_folders} folder{folders} with {total_clips} " + "clip{clips} have been processed, movie {movie_name} has " + "been created.".format( + folders="" if len(folders) < 2 else "s", + total_folders=len(folders), + clips="" if total_clips < 2 else "s", + total_clips=total_clips, + movie_name=video_settings["target_folder"], + ), + ) else: - notify("TeslaCam", "Completed", - "{total_folders} folder{folders} with {total_clips} " - "clip{clips} have been processed, {target_folder} contains " - "resulting files.".format( - folders='' if len(folders) < 2 else 's', - total_folders=len(folders), - clips='' if total_clips < 2 else 's', - total_clips=total_clips, - target_folder=video_settings['target_folder'] - )) + notify( + "TeslaCam", + "Completed", + "{total_folders} folder{folders} with {total_clips} " + "clip{clips} have been processed, {target_folder} contains " + "resulting files.".format( + folders="" if len(folders) < 2 else "s", + total_folders=len(folders), + clips="" if total_clips < 2 else "s", + total_clips=total_clips, + target_folder=video_settings["target_folder"], + ), + ) print() @@ -1370,43 +1487,55 @@ def resource_path(relative_path): """ # If compiled with pyinstaller then sys._MEIPASS points to the location # of the bundle. Otherwise path of python script is used. - base_path = getattr(sys, '_MEIPASS', str(Path(__file__).parent)) + base_path = getattr(sys, "_MEIPASS", str(Path(__file__).parent)) return os.path.join(base_path, relative_path) def notify_macos(title, subtitle, message): """ Notification on MacOS """ try: - run(['osascript', - '-e display notification "{message}" with title "{title}" ' - 'subtitle "{subtitle}"' - ''.format( - message=message, - title=title, - subtitle=subtitle, - )]) + run( + [ + "osascript", + '-e display notification "{message}" with title "{title}" ' + 'subtitle "{subtitle}"' + "".format(message=message, title=title, subtitle=subtitle), + ] + ) except Exception as exc: print("Failed in notifification: ", exc) def notify_windows(title, subtitle, message): """ Notification on Windows """ + + # Section commented out, waiting to see if it really does not work on Windows 7 + # This works only on Windows 10 9r Windows Server 2016/2019. Skipping for everything else + # from platform import win32_ver + # if win32_ver()[0] != 10: + # return + try: from win10toast import ToastNotifier - ToastNotifier().show_toast( + + if TOASTER_INSTANCE is None: + TOASTER_INSTANCE = ToastNotifier() + + TOASTER_INSTANCE.show_toast( threaded=True, title="{} {}".format(title, subtitle), msg=message, duration=5, - icon_path=resource_path("tesla_dashcam.ico") + icon_path=resource_path("tesla_dashcam.ico"), ) - run(['notify-send', - '"{title} {subtitle}"'.format( - title=title, - subtitle=subtitle), - '"{}"'.format(message), - ]) + run( + [ + "notify-send", + '"{title} {subtitle}"'.format(title=title, subtitle=subtitle), + '"{}"'.format(message), + ] + ) except Exception: pass @@ -1414,173 +1543,190 @@ def notify_windows(title, subtitle, message): def notify_linux(title, subtitle, message): """ Notification on Linux """ try: - run(['notify-send', - '"{title} {subtitle}"'.format( - title=title, - subtitle=subtitle), - '"{}"'.format(message), - ]) + run( + [ + "notify-send", + '"{title} {subtitle}"'.format(title=title, subtitle=subtitle), + '"{}"'.format(message), + ] + ) except Exception as exc: print("Failed in notifification: ", exc) def notify(title, subtitle, message): """ Call function to send notification based on OS """ - if sys.platform == 'darwin': + if sys.platform == "darwin": notify_macos(title, subtitle, message) - elif sys.platform == 'win32': + elif sys.platform == "win32": notify_windows(title, subtitle, message) - elif sys.platform == 'linux': + elif sys.platform == "linux": notify_linux(title, subtitle, message) def main() -> None: """ Main function """ - internal_ffmpeg = getattr(sys, 'frozen', None) is not None - ffmpeg_default = resource_path(FFMPEG.get(sys.platform, 'ffmpeg')) - - movie_folder = os.path.join(str(Path.home()), - MOVIE_HOMEDIR.get(sys.platform),'') + internal_ffmpeg = getattr(sys, "frozen", None) is not None + ffmpeg_default = resource_path(FFMPEG.get(sys.platform, "ffmpeg")) + movie_folder = os.path.join(str(Path.home()), MOVIE_HOMEDIR.get(sys.platform), "") # Check if ffmpeg exist, if not then hope it is in default path or # provided. if not os.path.isfile(ffmpeg_default): internal_ffmpeg = False - ffmpeg_default = FFMPEG.get(sys.platform, 'ffmpeg') - - epilog = "This program leverages ffmpeg which is included. See " \ - "https://ffmpeg.org/ for more information on ffmpeg" if \ - internal_ffmpeg else 'This program requires ffmpeg which can be ' \ - 'downloaded from: ' \ - 'https://ffmpeg.org/download.html' + ffmpeg_default = FFMPEG.get(sys.platform, "ffmpeg") + + epilog = ( + "This program leverages ffmpeg which is included. See " + "https://ffmpeg.org/ for more information on ffmpeg" + if internal_ffmpeg + else "This program requires ffmpeg which can be " + "downloaded from: " + "https://ffmpeg.org/download.html" + ) parser = MyArgumentParser( - description='tesla_dashcam - Tesla DashCam & Sentry Video Creator', + description="tesla_dashcam - Tesla DashCam & Sentry Video Creator", epilog=epilog, formatter_class=SmartFormatter, - fromfile_prefix_chars='@', + fromfile_prefix_chars="@", ) - parser.add_argument('--version', - action='version', - version=' %(prog)s ' + VERSION_STR - ) - parser.add_argument('source', - type=str, - nargs='*', - help="Folder(s) containing the saved camera " - "files. Filenames can be provided as well to " - "manage individual clips." - ) + parser.add_argument( + "--version", action="version", version=" %(prog)s " + VERSION_STR + ) + parser.add_argument( + "source", + type=str, + nargs="*", + help="Folder(s) containing the saved camera " + "files. Filenames can be provided as well to " + "manage individual clips.", + ) sub_dirs = parser.add_mutually_exclusive_group() - sub_dirs.add_argument('--exclude_subdirs', - dest='exclude_subdirs', - action='store_true', - help="Do not search all sub folders for video files " - "to." - ) - - sub_dirs.add_argument('--merge', - dest='merge_subdirs', - action='store_true', - help="Merge the video files from different " - "folders into 1 big video file." - ) - - parser.add_argument('--output', - required=False, - default = movie_folder, - type=str, - help="R|Path/Filename for the new movie file. " - "Intermediate files will be stored in same " - "folder.\n" - ) + sub_dirs.add_argument( + "--exclude_subdirs", + dest="exclude_subdirs", + action="store_true", + help="Do not search sub folders for video files to process.", + ) - parser.add_argument('--keep-intermediate', - dest='keep_intermediate', - action='store_true', - help='Do not remove the intermediate video files that ' - 'are created') - - parser.add_argument('--delete_source', - dest='delete_source', - action='store_true', - help='Delete the processed files on the ' - 'TeslaCam drive.' - ) + sub_dirs.add_argument( + "--merge", + dest="merge_subdirs", + action="store_true", + help="Merge the video files from different " "folders into 1 big video file.", + ) - parser.add_argument('--no-notification', - dest='system_notification', - action='store_false', - help='Do not create a notification upon ' - 'completion.') - - parser.add_argument('--layout', - required=False, - choices=['WIDESCREEN', - 'FULLSCREEN', - 'PERSPECTIVE', ], - default='FULLSCREEN', - help="R|Layout of the created video.\n" - " FULLSCREEN: Front camera center top, " - "side cameras underneath it.\n" - " WIDESCREEN: Output from all 3 cameras are " - "next to each other.\n" - " PERSPECTIVE: Front camera center top, " - "side cameras next to it in perspective.\n" - ) - parser.add_argument('--scale', - dest='clip_scale', - type=float, - help="R|Set camera clip scale, scale of 1 " - "is 1280x960 camera clip. " - "Defaults:\n" - " WIDESCREEN: 1/2 (640x480, video is " - "1920x480)\n" - " FULLSCREEN: 1/2 (640x480, video is " - "1280x960)\n" - " PERSPECTIVE: 1/4 (320x240, video is " - "980x380)\n" - ) + parser.add_argument( + "--chapter_offset", + dest="chapter_offset", + type=int, + default=0, + help="Offset in seconds for chapters in merged video. Negative offset is # of seconds before the end of the " + "subdir video, positive offset if # of seconds after the start of the subdir video.", + ) + + parser.add_argument( + "--output", + required=False, + default=movie_folder, + type=str, + help="R|Path/Filename for the new movie file. " + "Intermediate files will be stored in same " + "folder." + os.linesep, + ) + + parser.add_argument( + "--keep-intermediate", + dest="keep_intermediate", + action="store_true", + help="Do not remove the intermediate video files that " "are created", + ) + + parser.add_argument( + "--delete_source", + dest="delete_source", + action="store_true", + help="Delete the processed files on the " "TeslaCam drive.", + ) + + parser.add_argument( + "--no-notification", + dest="system_notification", + action="store_false", + help="Do not create a notification upon " "completion.", + ) + + parser.add_argument( + "--layout", + required=False, + choices=["WIDESCREEN", "FULLSCREEN", "PERSPECTIVE"], + default="FULLSCREEN", + help="R|Layout of the created video.\n" + " FULLSCREEN: Front camera center top, " + "side cameras underneath it.\n" + " WIDESCREEN: Output from all 3 cameras are " + "next to each other.\n" + " PERSPECTIVE: Front camera center top, " + "side cameras next to it in perspective.\n", + ) + parser.add_argument( + "--scale", + dest="clip_scale", + type=float, + help="R|Set camera clip scale, scale of 1 " + "is 1280x960 camera clip. " + "Defaults:\n" + " WIDESCREEN: 1/2 (640x480, video is " + "1920x480)\n" + " FULLSCREEN: 1/2 (640x480, video is " + "1280x960)\n" + " PERSPECTIVE: 1/4 (320x240, video is " + "980x380)\n", + ) mirror_or_rear = parser.add_mutually_exclusive_group() - mirror_or_rear.add_argument('--mirror', - dest='mirror', - action='store_true', - help="Video from side cameras as if being " - "viewed through the sidemirrors. Cannot " - "be used in combination with --rear." - ) - mirror_or_rear.add_argument('--rear', - dest='rear', - action='store_true', - help="Video from side cameras as if looking " - "backwards. Cannot be used in " - "combination with --mirror." - ) + mirror_or_rear.add_argument( + "--mirror", + dest="mirror", + action="store_true", + help="Video from side cameras as if being " + "viewed through the sidemirrors. Cannot " + "be used in combination with --rear.", + ) + mirror_or_rear.add_argument( + "--rear", + dest="rear", + action="store_true", + help="Video from side cameras as if looking " + "backwards. Cannot be used in " + "combination with --mirror.", + ) parser.set_defaults(mirror=True) parser.set_defaults(rear=False) swap_cameras = parser.add_mutually_exclusive_group() - swap_cameras.add_argument('--swap', - dest='swap', - action='store_const', - const=1, - help="Swap left and right cameras, default when " - "layout FULLSCREEN with --rear option is " - "chosen." - ) - swap_cameras.add_argument('--no-swap', - dest='swap', - action='store_const', - const=0, - help="Do not swap left and right cameras, " - "default with all other options." - ) + swap_cameras.add_argument( + "--swap", + dest="swap", + action="store_const", + const=1, + help="Swap left and right cameras, default when " + "layout FULLSCREEN with --rear option is " + "chosen.", + ) + swap_cameras.add_argument( + "--no-swap", + dest="swap", + action="store_const", + const=0, + help="Do not swap left and right cameras, " "default with all other options.", + ) # camera_group = parser.add_argument_group(title='Camera Exclusion', # description="Exclude " @@ -1601,230 +1747,244 @@ def main() -> None: # help="Exclude right camera from video.") speed_group = parser.add_mutually_exclusive_group() - speed_group.add_argument('--slowdown', - dest='slow_down', - type=int, - help="Slow down video output. Accepts a number " - "that is then used as multiplier, " - "providing 2 means half the speed." - ) - swap_cameras.add_argument('--speedup', - dest='speed_up', - type=int, - help="Speed up the video. Accepts a number " - "that is then used as a multiplier, " - "providing 2 means twice the speed." - ) + speed_group.add_argument( + "--slowdown", + dest="slow_down", + type=int, + help="Slow down video output. Accepts a number " + "that is then used as multiplier, " + "providing 2 means half the speed.", + ) + swap_cameras.add_argument( + "--speedup", + dest="speed_up", + type=int, + help="Speed up the video. Accepts a number " + "that is then used as a multiplier, " + "providing 2 means twice the speed.", + ) encoding_group = parser.add_mutually_exclusive_group() - encoding_group.add_argument('--encoding', - required=False, - choices=['x264', - 'x265', ], - default='x264', - help="R|Encoding to use for video creation.\n" - " x264: standard encoding, can be " - "viewed on most devices but results in " - "bigger file.\n" - " x265: newer encoding standard but " - "not all devices support this yet.\n" - ) - encoding_group.add_argument('--enc', - required=False, - type=str, - help="R|Provide a custom encoding for video " - "creation.\n" - "Note: when using this option the --gpu " - "option is ignored. To use GPU hardware " - "acceleration specify a encoding that " - "provides this." - ) + encoding_group.add_argument( + "--encoding", + required=False, + choices=["x264", "x265"], + default="x264", + help="R|Encoding to use for video creation.\n" + " x264: standard encoding, can be " + "viewed on most devices but results in " + "bigger file.\n" + " x265: newer encoding standard but " + "not all devices support this yet.\n", + ) + encoding_group.add_argument( + "--enc", + required=False, + type=str, + help="R|Provide a custom encoding for video " + "creation.\n" + "Note: when using this option the --gpu " + "option is ignored. To use GPU hardware " + "acceleration specify a encoding that " + "provides this.", + ) - gpu_help = "R|Use GPU acceleration, only enable if " \ - "supported by hardware.\n" \ - " MAC: All MACs with Haswell CPU or later " \ - "support this (Macs after 2013).\n" \ - " See following link as well: \n" \ - " https://en.wikipedia.org/wiki/List_of_" \ - "Macintosh_models_grouped_by_CPU_type#Haswell\n" \ - " Windows and Linux: PCs with NVIDIA graphic " \ - "cards support this as well.\n" \ - " For more information on " \ - "supported cards see:\n" \ - " https://developer.nvidia.com/" \ - "video-encode-decode-gpu-support-matrix" - - if sys.platform == 'darwin': - parser.add_argument('--no-gpu', - dest='gpu', - action='store_true', - help=gpu_help - ) + gpu_help = ( + "R|Use GPU acceleration, only enable if " + "supported by hardware.\n" + " MAC: All MACs with Haswell CPU or later " + "support this (Macs after 2013).\n" + " See following link as well: \n" + " https://en.wikipedia.org/wiki/List_of_" + "Macintosh_models_grouped_by_CPU_type#Haswell\n" + " Windows and Linux: PCs with NVIDIA graphic " + "cards support this as well.\n" + " For more information on " + "supported cards see:\n" + " https://developer.nvidia.com/" + "video-encode-decode-gpu-support-matrix" + ) + + if sys.platform == "darwin": + parser.add_argument("--no-gpu", dest="gpu", action="store_true", help=gpu_help) else: - parser.add_argument('--gpu', - dest='gpu', - action='store_true', - help=gpu_help - ) + parser.add_argument("--gpu", dest="gpu", action="store_true", help=gpu_help) + + timestamp_group = parser.add_argument_group( + title="Timestamp", description="Options for " "timestamp:" + ) + timestamp_group.add_argument( + "--no-timestamp", + dest="no_timestamp", + action="store_true", + help="Include timestamp in video", + ) + + timestamp_group.add_argument( + "--halign", + required=False, + choices=["LEFT", "CENTER", "RIGHT"], + default="CENTER", + help="Horizontal alignment for timestamp", + ) + + timestamp_group.add_argument( + "--valign", + required=False, + choices=["TOP", "MIDDLE", "BOTTOM"], + default="BOTTOM", + help="Vertical Alignment for timestamp", + ) + + timestamp_group.add_argument( + "--font", + required=False, + type=str, + default=DEFAULT_FONT.get(sys.platform, None), + help="Fully qualified filename (.ttf) to the " + "font to be chosen for timestamp.", + ) + + timestamp_group.add_argument( + "--fontsize", + required=False, + type=int, + help="Font size for timestamp. Default is " "scaled based on video scaling.", + ) + + timestamp_group.add_argument( + "--fontcolor", + required=False, + type=str, + default="white", + help="R|Font color for timestamp. Any color " + "is " + "accepted as a color string or RGB " + "value.\n" + "Some potential values are:\n" + " white\n" + " yellowgreen\n" + " yellowgreen@0.9\n" + " Red\n:" + " 0x2E8B57\n" + "For more information on this see " + "ffmpeg " + "documentation for color: " + "https://ffmpeg.org/ffmpeg-utils.html#" + "Color", + ) + + quality_group = parser.add_argument_group( + title="Video Quality", + description="Options for " "resulting video " "quality and size:", + ) + + quality_group.add_argument( + "--quality", + required=False, + choices=["LOWEST", "LOWER", "LOW", "MEDIUM", "HIGH"], + default="LOWER", + help="Define the quality setting for the " + "video, higher quality means bigger file " + "size but might not be noticeable.", + ) - timestamp_group = parser.add_argument_group(title='Timestamp', - description="Options for " - "timestamp:") - timestamp_group.add_argument('--no-timestamp', - dest='no_timestamp', - action='store_true', - help="Include timestamp in video") - - timestamp_group.add_argument('--halign', - required=False, - choices=['LEFT', - 'CENTER', - 'RIGHT', ], - default='CENTER', - help='Horizontal alignment for timestamp') - - timestamp_group.add_argument('--valign', - required=False, - choices=['TOP', - 'MIDDLE', - 'BOTTOM', ], - default='BOTTOM', - help='Vertical Alignment for timestamp') - - timestamp_group.add_argument('--font', - required=False, - type=str, - default=DEFAULT_FONT.get(sys.platform, None), - help="Fully qualified filename (.ttf) to the " - "font to be chosen for timestamp." - ) - - timestamp_group.add_argument('--fontsize', - required=False, - type=int, - help="Font size for timestamp. Default is " - "scaled based on video scaling.") - - timestamp_group.add_argument('--fontcolor', - required=False, - type=str, - default='white', - help="R|Font color for timestamp. Any color " - "is " - "accepted as a color string or RGB " - "value.\n" - "Some potential values are:\n" - " white\n" - " yellowgreen\n" - " yellowgreen@0.9\n" - " Red\n:" - " 0x2E8B57\n" - "For more information on this see " - "ffmpeg " - "documentation for color: " - "https://ffmpeg.org/ffmpeg-utils.html#" - "Color" - ) - - quality_group = parser.add_argument_group(title='Video Quality', - description="Options for " - "resulting video " - "quality and size:" - ) - - quality_group.add_argument('--quality', - required=False, - choices=['LOWEST', - 'LOWER', - 'LOW', - 'MEDIUM', - 'HIGH'], - default='LOWER', - help="Define the quality setting for the " - "video, higher quality means bigger file " - "size but might not be noticeable." - ) - - quality_group.add_argument('--compression', - required=False, - choices=['ultrafast', - 'superfast', - 'veryfast', - 'faster', - 'fast', - 'medium', - 'slow', - 'slower', - 'veryslow'], - default='medium', - help="Speed to optimize video. Faster speed " - "results in a bigger file. This does not " - "impact the quality of the video, " - "just how " - "much time is used to compress it." - ) + quality_group.add_argument( + "--compression", + required=False, + choices=[ + "ultrafast", + "superfast", + "veryfast", + "faster", + "fast", + "medium", + "slow", + "slower", + "veryslow", + ], + default="medium", + help="Speed to optimize video. Faster speed " + "results in a bigger file. This does not " + "impact the quality of the video, " + "just how " + "much time is used to compress it.", + ) if internal_ffmpeg: - parser.add_argument('--ffmpeg', - required=False, - type=str, - help='Full path and filename for alternative ' - 'ffmpeg.') + parser.add_argument( + "--ffmpeg", + required=False, + type=str, + help="Full path and filename for alternative " "ffmpeg.", + ) else: - parser.add_argument('--ffmpeg', - required=False, - type=str, - default=ffmpeg_default, - help='Path and filename for ffmpeg. Specify if ' - 'ffmpeg is not within path.') + parser.add_argument( + "--ffmpeg", + required=False, + type=str, + default=ffmpeg_default, + help="Path and filename for ffmpeg. Specify if " + "ffmpeg is not within path.", + ) monitor_group = parser.add_argument_group( title="Monitor for TeslaDash Cam drive", description="Parameters to monitor for a drive to be attached with " - "folder TeslaCam in the root." + "folder TeslaCam in the root.", ) - monitor_group.add_argument('--monitor', - dest='monitor', - action='store_true', - help='Enable monitoring for drive to be ' - 'attached with TeslaCam folder.' - ) - - monitor_group.add_argument('--monitor_once', - dest='monitor_once', - action='store_true', - help='Enable monitoring and exit once drive ' - 'with TeslaCam folder has been attached ' - 'and files processed.' - ) + monitor_group.add_argument( + "--monitor", + dest="monitor", + action="store_true", + help="Enable monitoring for drive to be attached with TeslaCam folder.", + ) + + monitor_group.add_argument( + "--monitor_once", + dest="monitor_once", + action="store_true", + help="Enable monitoring and exit once drive " + "with TeslaCam folder has been attached " + "and files processed.", + ) + + monitor_group.add_argument( + "--monitor_trigger", + required=False, + type=str, + help="Trigger file to look for instead of waiting for drive to be attached. Once file is discovered then " + "processing will start, file will be deleted when processing has been completed. If source is not " + "provided then folder where file is located will be used as source.", + ) update_check_group = parser.add_argument_group( - title="Update Check", - description="Check for updates" + title="Update Check", description="Check for updates" ) - update_check_group.add_argument('--check_for_update', - dest='check_for_updates', - action='store_true', - help='Check for updates, do not do ' - 'anything else.' - ) + update_check_group.add_argument( + "--check_for_update", + dest="check_for_updates", + action="store_true", + help="Check for updates, do not do " "anything else.", + ) - update_check_group.add_argument('--no-check_for_update', - dest='no_check_for_updates', - action='store_true', - help='A check for new updates is ' - 'performed every time. With this ' - 'parameter that can be disabled' - ) + update_check_group.add_argument( + "--no-check_for_update", + dest="no_check_for_updates", + action="store_true", + help="A check for new updates is " + "performed every time. With this " + "parameter that can be disabled", + ) - update_check_group.add_argument('--include_test', - dest='include_beta', - action='store_true', - help='Include test (beta) releases ' - 'when checking for updates.' - ) + update_check_group.add_argument( + "--include_test", + dest="include_beta", + action="store_true", + help="Include test (beta) releases " "when checking for updates.", + ) args = parser.parse_args() @@ -1832,8 +1992,8 @@ def main() -> None: release_info = check_latest_release(args.include_beta) if release_info is not None: new_version = False - if release_info.get('tag_name') is not None: - github_version = release_info.get('tag_name').split('.') + if release_info.get("tag_name") is not None: + github_version = release_info.get("tag_name").split(".") if len(github_version) == 3: # Release tags normally start with v. If that is the case # then strip the v. @@ -1843,92 +2003,103 @@ def main() -> None: major_version = int(github_version[0][1:]) minor_version = int(github_version[1]) - if release_info.get('prerelease'): + if release_info.get("prerelease"): # Drafts will have b and then beta number. - patch_version = int(github_version[2].split('b')[0]) - beta_version = int(github_version[2].split('b')[1]) + patch_version = int(github_version[2].split("b")[0]) + beta_version = int(github_version[2].split("b")[1]) else: patch_version = int(github_version[2]) beta_version = -1 - if major_version == VERSION['major']: - if minor_version == VERSION['minor']: - if patch_version == VERSION['patch']: - if beta_version > VERSION['beta'] or \ - (beta_version == -1 and - VERSION['beta'] != -1): + if major_version == VERSION["major"]: + if minor_version == VERSION["minor"]: + if patch_version == VERSION["patch"]: + if beta_version > VERSION["beta"] or ( + beta_version == -1 and VERSION["beta"] != -1 + ): new_version = True - elif patch_version > VERSION['patch']: + elif patch_version > VERSION["patch"]: new_version = True - elif minor_version > VERSION['minor']: + elif minor_version > VERSION["minor"]: new_version = True - elif major_version > VERSION['major']: + elif major_version > VERSION["major"]: new_version = True if new_version: beta = "" - if release_info.get('prerelease'): + if release_info.get("prerelease"): beta = "beta " release_notes = "" if not args.check_for_updates: if args.system_notification: - notify("TeslaCam", "Update available", - "New {beta}release {release} is available. You are " - "on version {version}".format( - beta=beta, - release=release_info.get('tag_name'), - version=VERSION_STR, - )) - release_notes = "Use --check-for-update to get latest " \ - "release notes." - - print("New {beta}release {release} is available for download " - "({url}). You are currently on {version}. {rel_note}".format( - beta=beta, - release=release_info.get('tag_name'), - url=release_info.get('html_url'), - version=VERSION_STR, - rel_note=release_notes, - )) + notify( + "TeslaCam", + "Update available", + "New {beta}release {release} is available. You are " + "on version {version}".format( + beta=beta, + release=release_info.get("tag_name"), + version=VERSION_STR, + ), + ) + release_notes = ( + "Use --check_for_update to get latest " "release notes." + ) + + print( + "New {beta}release {release} is available for download " + "({url}). You are currently on {version}. {rel_note}".format( + beta=beta, + release=release_info.get("tag_name"), + url=release_info.get("html_url"), + version=VERSION_STR, + rel_note=release_notes, + ) + ) if args.check_for_updates: - print("You can download the new release from: {url}".format( - url=release_info.get('html_url') - )) - print("Release Notes:\n {release_notes}".format( - release_notes=release_info.get('body') - )) + print( + "You can download the new release from: {url}".format( + url=release_info.get("html_url") + ) + ) + print( + "Release Notes:\n {release_notes}".format( + release_notes=release_info.get("body") + ) + ) return else: if args.check_for_updates: - print("{version} is the latest release available.".format( - version=VERSION_STR, - )) + print( + "{version} is the latest release available.".format( + version=VERSION_STR + ) + ) return else: print("Did not retrieve latest version info.") - ffmpeg = ffmpeg_default if getattr(args, 'ffmpeg', None) is None else \ - args.ffmpeg + ffmpeg = ffmpeg_default if getattr(args, "ffmpeg", None) is None else args.ffmpeg - mirror_sides = '' + mirror_sides = "" if args.rear: side_camera_as_mirror = False else: side_camera_as_mirror = True if side_camera_as_mirror: - mirror_sides = ', hflip' + mirror_sides = ", hflip" - black_base = 'color=duration={duration}:' - black_size = 's={width}x{height}:c=black ' + black_base = "color=duration={duration}:" + black_size = "s={width}x{height}:c=black " - if args.layout == 'WIDESCREEN': + if args.layout == "WIDESCREEN": layout_settings = WideScreen() - elif args.layout == 'FULLSCREEN': + elif args.layout == "FULLSCREEN": layout_settings = FullScreen() - elif args.layout == 'PERSPECTIVE': + elif args.layout == "PERSPECTIVE": layout_settings = Perspective() else: layout_settings = Diagonal() @@ -1944,46 +2115,55 @@ def main() -> None: layout_settings.left = True layout_settings.right = True - ffmpeg_base = black_base + black_size.format( - width=layout_settings.video_width, - height=layout_settings.video_height, - ) + '[base];' + ffmpeg_base = ( + black_base + + black_size.format( + width=layout_settings.video_width, height=layout_settings.video_height + ) + + "[base];" + ) ffmpeg_black_video = black_base + black_size - ffmpeg_input_0 = 'setpts=PTS-STARTPTS, ' \ - 'scale={clip_width}x{clip_height} {mirror}{options}' \ - ' [left];'.format( - clip_width=layout_settings.left_width, - clip_height=layout_settings.left_height, - mirror=mirror_sides, - options=layout_settings.left_options, - ) - - ffmpeg_input_1 = 'setpts=PTS-STARTPTS, ' \ - 'scale={clip_width}x{clip_height} {options}' \ - ' [front];'.format( - clip_width=layout_settings.front_width, - clip_height=layout_settings.front_height, - options=layout_settings.front_options, - ) - - ffmpeg_input_2 = 'setpts=PTS-STARTPTS, ' \ - 'scale={clip_width}x{clip_height} {mirror}{options}' \ - ' [right];'.format( - clip_width=layout_settings.right_width, - clip_height=layout_settings.right_height, - mirror=mirror_sides, - options=layout_settings.right_options, - ) - - ffmpeg_video_position = \ - '[base][left] overlay=eof_action=pass:repeatlast=0:' \ - 'x={left_x}:y={left_y} [left1];' \ - '[left1][front] overlay=eof_action=pass:repeatlast=0:' \ - 'x={front_x}:y={front_y} [front1];' \ - '[front1][right] overlay=eof_action=pass:repeatlast=0:' \ - 'x={right_x}:y={right_y}'.format( + ffmpeg_input_0 = ( + "setpts=PTS-STARTPTS, " + "scale={clip_width}x{clip_height} {mirror}{options}" + " [left];".format( + clip_width=layout_settings.left_width, + clip_height=layout_settings.left_height, + mirror=mirror_sides, + options=layout_settings.left_options, + ) + ) + + ffmpeg_input_1 = ( + "setpts=PTS-STARTPTS, " + "scale={clip_width}x{clip_height} {options}" + " [front];".format( + clip_width=layout_settings.front_width, + clip_height=layout_settings.front_height, + options=layout_settings.front_options, + ) + ) + + ffmpeg_input_2 = ( + "setpts=PTS-STARTPTS, " + "scale={clip_width}x{clip_height} {mirror}{options}" + " [right];".format( + clip_width=layout_settings.right_width, + clip_height=layout_settings.right_height, + mirror=mirror_sides, + options=layout_settings.right_options, + ) + ) + + ffmpeg_video_position = ( + "[base][left] overlay=eof_action=pass:repeatlast=0:" + "x={left_x}:y={left_y} [left1];" + "[left1][front] overlay=eof_action=pass:repeatlast=0:" + "x={front_x}:y={front_y} [front1];" + "[front1][right] overlay=eof_action=pass:repeatlast=0:" + "x={right_x}:y={right_y}".format( left_x=layout_settings.left_x, left_y=layout_settings.left_y, front_x=layout_settings.front_x, @@ -1991,12 +2171,13 @@ def main() -> None: right_x=layout_settings.right_x, right_y=layout_settings.right_y, ) + ) filter_counter = 0 - filter_label = '[tmp{filter_counter}];[tmp{filter_counter}] ' - ffmpeg_timestamp = '' + filter_label = "[tmp{filter_counter}];[tmp{filter_counter}] " + ffmpeg_timestamp = "" if not args.no_timestamp: - if args.font is not None and args.font != '': + if args.font is not None and args.font != "": font_file = args.font else: font_file = DEFAULT_FONT.get(sys.platform, None) @@ -2006,51 +2187,45 @@ def main() -> None: return ffmpeg_timestamp = filter_label.format( - filter_counter=filter_counter) + \ - 'drawtext=fontfile={fontfile}:'.format( - fontfile=font_file, - ) + filter_counter=filter_counter + ) + "drawtext=fontfile={fontfile}:".format(fontfile=font_file) filter_counter += 1 # If fontsize is not provided then scale font size based on scaling # of video clips, otherwise use fixed font size. if args.fontsize is None or args.fontsize == 0: - fontsize = 16 * layout_settings.font_scale * \ - layout_settings.scale + fontsize = 16 * layout_settings.font_scale * layout_settings.scale else: fontsize = args.fontsize - ffmpeg_timestamp = ffmpeg_timestamp + \ - 'fontcolor={fontcolor}:fontsize={fontsize}:' \ - 'borderw=2:bordercolor=black@1.0:' \ - 'x={halign}:y={valign}:'.format( + ffmpeg_timestamp = ( + ffmpeg_timestamp + "fontcolor={fontcolor}:fontsize={fontsize}:" + "borderw=2:bordercolor=black@1.0:" + "x={halign}:y={valign}:".format( fontcolor=args.fontcolor, fontsize=fontsize, valign=VALIGN[args.valign], halign=HALIGN[args.halign], ) + ) - ffmpeg_timestamp = ffmpeg_timestamp + \ - "text='%{{pts\:localtime\:{epoch_time}\:%x %X}}'" + ffmpeg_timestamp = ( + ffmpeg_timestamp + "text='%{{pts\:localtime\:{epoch_time}\:%x %X}}'" + ) - speed = args.slow_down if args.slow_down is not None else '' + speed = args.slow_down if args.slow_down is not None else "" speed = 1 / args.speed_up if args.speed_up is not None else speed - ffmpeg_speed = '' - if speed != '': + ffmpeg_speed = "" + if speed != "": ffmpeg_speed = filter_label.format( - filter_counter=filter_counter) + \ - " setpts={speed}*PTS".format(speed=speed) + filter_counter=filter_counter + ) + " setpts={speed}*PTS".format(speed=speed) filter_counter += 1 - ffmpeg_params = [ - '-preset', - args.compression, - '-crf', - MOVIE_QUALITY[args.quality] - ] + ffmpeg_params = ["-preset", args.compression, "-crf", MOVIE_QUALITY[args.quality]] use_gpu = args.gpu - if sys.platform == 'darwin': + if sys.platform == "darwin": use_gpu = not args.gpu video_encoding = [] @@ -2059,39 +2234,30 @@ def main() -> None: # GPU acceleration enabled if use_gpu: print("GPU acceleration is enabled") - if sys.platform == 'darwin': - video_encoding = video_encoding + \ - ['-allow_sw', - '1' - ] - encoding = encoding + '_mac' + if sys.platform == "darwin": + video_encoding = video_encoding + ["-allow_sw", "1"] + encoding = encoding + "_mac" else: - encoding = encoding + '_nvidia' + encoding = encoding + "_nvidia" - bit_rate = str(int(10000 * layout_settings.scale)) + 'K' - video_encoding = video_encoding + \ - ['-b:v', - bit_rate, - ] + bit_rate = str(int(10000 * layout_settings.scale)) + "K" + video_encoding = video_encoding + ["-b:v", bit_rate] - video_encoding = video_encoding + \ - ['-c:v', - MOVIE_ENCODING[encoding] - ] + video_encoding = video_encoding + ["-c:v", MOVIE_ENCODING[encoding]] else: - video_encoding = video_encoding + \ - ['-c:v', - args.enc - ] + video_encoding = video_encoding + ["-c:v", args.enc] ffmpeg_params = ffmpeg_params + video_encoding # Determine the target folder and filename. # If no extension then assume it is a folder. - if os.path.splitext(args.output)[1] is None: + if ( + os.path.splitext(args.output)[1] is not None + and os.path.splitext(args.output)[1] != "" + ): target_folder, target_filename = os.path.split(args.output) - if target_filename is None: + if target_folder is None or target_folder == "": # If nothing in target_filename then no folder was given, # setting default movie folder target_folder = movie_folder @@ -2103,12 +2269,33 @@ def main() -> None: # Create folder if not already existing. if not os.path.isdir(target_folder): - os.mkdir(target_folder) + current_path, add_folder = os.path.split(target_folder) + if add_folder == "": + current_path, add_folder = os.path.split(current_path) + + # If path does not exist in which to create folder then exit. + if not os.path.isdir(current_path): + print( + "Path {} does not exist, please provide a valid path.".format( + current_path + ) + ) + return + + try: + os.mkdir(target_folder) + except OSError as exc: + print( + "Error creating folder {} at location {}".format( + add_folder, current_path + ) + ) + return # Determine if left and right cameras should be swapped or not. if args.swap is None: # Default is set based on layout chosen. - if args.layout == 'FULLSCREEN': + if args.layout == "FULLSCREEN": # FULLSCREEN is different, if doing mirror then default should # not be swapping. If not doing mirror then default should be # to swap making it seem more like a "rear" camera. @@ -2117,131 +2304,214 @@ def main() -> None: layout_settings.swap_left_right = args.swap # Set the run type based on arguments. - runtype = 'RUN' + runtype = "RUN" if args.monitor: - runtype = 'MONITOR' + runtype = "MONITOR" elif args.monitor_once: - runtype = 'MONITOR_ONCE' + runtype = "MONITOR_ONCE" + monitor_file = args.monitor_trigger # If no source provided then set to MONITOR_ONCE and we're only going to # take SavedClips source_list = args.source if not source_list: - source_list = ['SavedClips'] - if runtype == 'RUN': - runtype = 'MONITOR_ONCE' + source_list = ["SavedClips"] + if runtype == "RUN": + runtype = "MONITOR_ONCE" video_settings = { - 'source_folder': source_list, - 'output': args.output, - 'target_folder': target_folder, - 'target_filename': target_filename, - 'run_type': runtype, - 'merge_subdirs': args.merge_subdirs, - 'movie_filename': None, - 'keep_intermediate': args.keep_intermediate, - 'notification': args.system_notification, - 'movie_layout': args.layout, - 'movie_speed': speed, - 'video_encoding': video_encoding, - 'movie_encoding': args.encoding, - 'movie_compression': args.compression, - 'movie_quality': args.quality, - 'background': ffmpeg_black_video, - 'ffmpeg_exec': ffmpeg, - 'base': ffmpeg_base, - 'video_layout': layout_settings, - 'clip_positions': ffmpeg_video_position, - 'timestamp_text': ffmpeg_timestamp, - 'ffmpeg_speed': ffmpeg_speed, - 'other_params': ffmpeg_params, - 'input_0': ffmpeg_input_0, - 'input_1': ffmpeg_input_1, - 'input_2': ffmpeg_input_2, + "source_folder": source_list, + "output": args.output, + "target_folder": target_folder, + "target_filename": target_filename, + "run_type": runtype, + "merge_subdirs": args.merge_subdirs, + "chapter_offset": args.chapter_offset, + "movie_filename": None, + "keep_intermediate": args.keep_intermediate, + "notification": args.system_notification, + "movie_layout": args.layout, + "movie_speed": speed, + "video_encoding": video_encoding, + "movie_encoding": args.encoding, + "movie_compression": args.compression, + "movie_quality": args.quality, + "background": ffmpeg_black_video, + "ffmpeg_exec": ffmpeg, + "base": ffmpeg_base, + "video_layout": layout_settings, + "clip_positions": ffmpeg_video_position, + "timestamp_text": ffmpeg_timestamp, + "ffmpeg_speed": ffmpeg_speed, + "other_params": ffmpeg_params, + "input_0": ffmpeg_input_0, + "input_1": ffmpeg_input_1, + "input_2": ffmpeg_input_2, } # If we constantly run and monitor for drive added or not. - if video_settings['run_type'] in ['MONITOR', 'MONITOR_ONCE']: - got_drive = False - print("Monitoring for TeslaCam Drive to be inserted. Press CTRL-C to" - " stop") + if video_settings["run_type"] in ["MONITOR", "MONITOR_ONCE"]: + trigger_exist = False + if monitor_file is None: + print("Monitoring for TeslaCam Drive to be inserted. Press CTRL-C to stop") + else: + print( + "Monitoring for trigger {} to exist. Press CTRL-C to stop".format( + monitor_file + ) + ) while True: try: - source_folder, source_partition = get_tesladashcam_folder() - if source_folder is None: - # Nothing found, sleep for 1 minute and check again. - if got_drive: - print("TeslaCam drive has been ejected.") - print("Monitoring for TeslaCam Drive to be inserted. " - "Press CTRL-C to stop") - - sleep(MONITOR_SLEEP_TIME) - got_drive = False - continue + # Monitoring for disk to be inserted and not for a file. + if monitor_file is None: + source_folder, source_partition = get_tesladashcam_folder() + if source_folder is None: + # Nothing found, sleep for 1 minute and check again. + if trigger_exist: + print("TeslaCam drive has been ejected.") + print( + "Monitoring for TeslaCam Drive to be inserted. " + "Press CTRL-C to stop" + ) - # As long as TeslaCam drive is still attached we're going to - # keep on waiting. - if got_drive: - sleep(MONITOR_SLEEP_TIME) - continue + sleep(MONITOR_SLEEP_TIME) + trigger_exist = False + continue + + # As long as TeslaCam drive is still attached we're going to + # keep on waiting. + if trigger_exist: + sleep(MONITOR_SLEEP_TIME) + continue + + # Got a folder, append what was provided as source unless + # . was provided in which case everything is done. + if video_settings["source_folder"][0] != ".": + source_folder = os.path.join( + source_folder, video_settings["source_folder"][0] + ) - # TeslaCam Folder found, returning it. - print("TeslaCam folder found on {partition}.".format( - partition=source_partition - )) - if args.system_notification: - notify("TeslaCam", "Started", - "TeslaCam folder found on {partition}.".format( - partition=source_partition - )) - # Got a folder, append what was provided as source unless - # . was provided in which case everything is done. - if video_settings['source_folder'][0] != '.': - - source_folder = (os.path.join( - source_folder, - video_settings['source_folder'][0])) - - folders = get_movie_files([source_folder], - args.exclude_subdirs, - video_settings) - - if video_settings['run_type'] == 'MONITOR': - # We will continue to monitor hence we need to - # ensure we - # always have a unique final movie name. - movie_filename = video_settings['target_filename'] - movie_filename = movie_filename + '_' if \ - movie_filename is not None else '' - movie_filename = movie_filename + \ - datetime.today().strftime( - '%Y-%m-%d_%H_%M') - - video_settings.update({movie_filename: movie_filename}) - - process_folders(folders, video_settings, True, - args.delete_source) + message = "TeslaCam folder found on {partition}.".format( + partition=source_partition + ) + else: + # Wait till trigger file exist (can also be folder). + if not os.path.exists(monitor_file): + sleep(MONITOR_SLEEP_TIME) + trigger_exist = False + continue + + if trigger_exist: + sleep(MONITOR_SLEEP_TIME) + continue + + message = "Trigger {} exist.".format(monitor_file) + trigger_exist = True + + # Set monitor path, make sure what was provided is a file first otherwise get path. + monitor_path = monitor_file + if os.path.isfile(monitor_file): + monitor_path, _ = os.path.split(monitor_file) + + # If . is provided then source folder is path where monitor file exist. + if video_settings["source_folder"][0] == ".": + source_folder = monitor_path + else: + # If source path provided is absolute then use that for source path + if os.path.isabs(video_settings["source_folder"][0]): + source_folder = video_settings["source_folder"][0] + else: + # Path provided is relative, hence based on path of trigger file. + source_folder = os.path.join( + monitor_path, video_settings["source_folder"][0] + ) + print(message) if args.system_notification: - notify("TeslaCam", "Completed", - "Processing of movies has completed.".format( - partition=source_partition - )) + notify("TeslaCam", "Started", message) + + print("Retrieving all files from {}".format(source_folder)) + folders = get_movie_files( + [source_folder], args.exclude_subdirs, video_settings + ) + + if video_settings["run_type"] == "MONITOR": + # We will continue to monitor hence we need to + # ensure we always have a unique final movie name. + movie_filename = ( + datetime.today().strftime("%Y-%m-%d_%H_%M") + if video_settings["target_filename"] is None + else os.path.splitext(video_settings["target_filename"])[0] + + "_" + + datetime.today().strftime("%Y-%m-%d_%H_%M") + + os.path.splitext(video_settings["target_filename"])[1] + ) + + video_settings.update({"movie_filename": movie_filename}) + + process_folders(folders, video_settings, True, args.delete_source) + + print("Processing of movies has completed.") + if args.system_notification: + notify( + "TeslaCam", "Completed", "Processing of movies has completed." + ) + # Stop if we're only to monitor once and then exit. - if video_settings['run_type'] == 'MONITOR_ONCE': + if video_settings["run_type"] == "MONITOR_ONCE": + if monitor_file is not None: + if os.path.isfile(monitor_file): + try: + os.remove(monitor_file) + except OSError as exc: + print( + "Error trying to remove trigger file {}: {}".format( + monitor_file, exc + ) + ) + trigger_exist = False + print("Exiting monitoring as asked process once.") break - got_drive = True - print("Waiting for TeslaCam Drive to be ejected. Press " - "CTRL-C to stop") + if monitor_file is None: + trigger_exist = True + print( + "Waiting for TeslaCam Drive to be ejected. Press " + "CTRL-C to stop" + ) + else: + if os.path.isfile(monitor_file): + try: + os.remove(monitor_file) + except OSError as exc: + print( + "Error trying to remove trigger file {}: {}".format( + monitor_file, exc + ) + ) + break + trigger_exist = False + + print( + "Monitoring for trigger {}. Press CTRL-C to stop".format( + monitor_file + ) + ) + else: + print( + "Waiting for trigger {} to be removed. Press CTRL-C to stop".format( + monitor_file + ) + ) + except KeyboardInterrupt: print("Monitoring stopped due to CTRL-C.") break else: - folders = get_movie_files(video_settings['source_folder'], - args.exclude_subdirs, - video_settings) + folders = get_movie_files( + video_settings["source_folder"], args.exclude_subdirs, video_settings + ) process_folders(folders, video_settings, False, args.delete_source)