├── .gitignore ├── LICENSE ├── README.md └── photo-enhancer ├── ffmpeg_commands.py ├── utils.py ├── video_quality_enhancer.conf └── video_quality_enhancer.py /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # C extensions 7 | *.so 8 | 9 | # Distribution / packaging 10 | .Python 11 | build/ 12 | develop-eggs/ 13 | dist/ 14 | downloads/ 15 | eggs/ 16 | .eggs/ 17 | lib/ 18 | lib64/ 19 | parts/ 20 | sdist/ 21 | var/ 22 | wheels/ 23 | pip-wheel-metadata/ 24 | share/python-wheels/ 25 | *.egg-info/ 26 | .installed.cfg 27 | *.egg 28 | MANIFEST 29 | 30 | # PyInstaller 31 | # Usually these files are written by a python script from a template 32 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 33 | *.manifest 34 | *.spec 35 | 36 | # Installer logs 37 | pip-log.txt 38 | pip-delete-this-directory.txt 39 | 40 | # Unit test / coverage reports 41 | htmlcov/ 42 | .tox/ 43 | .nox/ 44 | .coverage 45 | .coverage.* 46 | .cache 47 | nosetests.xml 48 | coverage.xml 49 | *.cover 50 | *.py,cover 51 | .hypothesis/ 52 | .pytest_cache/ 53 | 54 | # Translations 55 | *.mo 56 | *.pot 57 | 58 | # Django stuff: 59 | *.log 60 | local_settings.py 61 | db.sqlite3 62 | db.sqlite3-journal 63 | 64 | # Flask stuff: 65 | instance/ 66 | .webassets-cache 67 | 68 | # Scrapy stuff: 69 | .scrapy 70 | 71 | # Sphinx documentation 72 | docs/_build/ 73 | 74 | # PyBuilder 75 | target/ 76 | 77 | # Jupyter Notebook 78 | .ipynb_checkpoints 79 | 80 | # IPython 81 | profile_default/ 82 | ipython_config.py 83 | 84 | # pyenv 85 | .python-version 86 | 87 | # pipenv 88 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 89 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 90 | # having no cross-platform support, pipenv may install dependencies that don't work, or not 91 | # install all needed dependencies. 92 | #Pipfile.lock 93 | 94 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow 95 | __pypackages__/ 96 | 97 | # Celery stuff 98 | celerybeat-schedule 99 | celerybeat.pid 100 | 101 | # SageMath parsed files 102 | *.sage.py 103 | 104 | # Environments 105 | .env 106 | .venv 107 | env/ 108 | venv/ 109 | ENV/ 110 | env.bak/ 111 | venv.bak/ 112 | 113 | # Spyder project settings 114 | .spyderproject 115 | .spyproject 116 | 117 | # Rope project settings 118 | .ropeproject 119 | 120 | # mkdocs documentation 121 | /site 122 | 123 | # mypy 124 | .mypy_cache/ 125 | .dmypy.json 126 | dmypy.json 127 | 128 | # Pyre type checker 129 | .pyre/ 130 | 131 | #SFTP-configuration 132 | sftp-config.json 133 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2023 Cibrán Docampo Piñeiro 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Video quality enhancer Synology Photo 2 | 3 | Unfortunately, the quality of the transcoding process for medium quality videos (for mobile devices) is poor. 4 | - Videos uploaded from the smartphone: H.264 baseline profile. 5 | - Videos uploaded on the web / samba: Framerate of 15fps. 6 | 7 | This tool aims to increase the quality lanching a optimized transcoding process base in the original video. The output can be H.264 HighProfile (best compression/quality rate) or the new H.265/HEVC codec. 8 | 9 | 10 | ## Prerequisites 11 | 12 | - A Synology NAS with DSM > 7.0 installed and properly configured. 13 | - Synology Container Manager application > 20.10.23 installed and running. 14 | - Synology Photos application > 1.5.0 installed and running. 15 | 16 | ## Configuration/Customization 17 | 18 | IMPORTANT! Modify the configuration file `video_quality_enhancer.conf` adding the necessary configuration. Mainly the name of the users, the address of the common folder (if it exists), and the path where the photos and videos of each of the users are stored. You can do it in the 4th installation step. 19 | 20 | 21 | | InputFolders | Explanation | Default value | Example | 22 | |----------------|--------------------|----------------|--------------------| 23 | | USERS | Users configured in DSM |user1, user2, user3| jhon, marc, tonny| 24 | | VIDEO_PATH | Common folder for photos & videos | /volume1/photo/ || 25 | | USER_VIDEO_PATH| Generic Path where private users fotos are stored |/volume1/homes/_user_/Photos/| 26 | 27 | 28 | 29 | ## Install 30 | 31 | 1. Using the File Station APP in your Synology, create a directory to host the code (Example: /volume1/code/synology-transcoding/) 32 | 33 | 2. Download the source code from the last release version. https://github.com/cibrandocampo/synology-transcoding/releases 34 | 35 | 3. Unzip the folder 36 | 37 | 4. Configure the `video_quality_enhancer.conf` adding the necessary configuration. More info at Configuration/Customization block. 38 | 39 | 4. Copy the uncompressed `photo-enhancer` folder to the NAS folder recently created. 40 | 41 | 5. Schedule the project execution to run once or multiple times a day. For this use the DSM task manager. Go to `Control Panel` > `Task Scheduler`, click `Create`, and select `Scheduled Task` 42 | 43 | - General 44 | - Task: Something like `video quality enhancer` 45 | - User: root 46 | 47 | - Schedule (When you whant to execute the process) 48 | - Task Settings 49 | - To receive run details of the task, tick Send run details by email. 50 | - To get notified only when abnormalities occur, tick Send run details only when the script terminates abnormally. 51 | - User-defined script, add: 52 | 53 | ```sh 54 | cd /volume1/code/synology-transcoding/photo-enhancer/ && python video_quality_enhancer.py 55 | ``` 56 | NOTE: Use the path configured in the step 1. 57 | 58 | Synology manual: https://kb.synology.com/en-uk/DSM/help/DSM/AdminCenter/system_taskscheduler?version=7 59 | 60 | 61 | ## Issues, contributions and help 62 | 63 | If you encounter any problems or have any suggestions, feel free to contact me via (hello@cibran.es). You can also contribute to improving this project by submitting pull requests. 64 | 65 | ## License 66 | 67 | This project is licensed under the [MIT License](LICENSE). -------------------------------------------------------------------------------- /photo-enhancer/ffmpeg_commands.py: -------------------------------------------------------------------------------- 1 | import os 2 | import json 3 | import time 4 | import logging 5 | import configparser 6 | 7 | from utils import clean_path, create_transcode_signal 8 | 9 | conf = configparser.ConfigParser() 10 | conf.read('video_quality_enhancer.conf') 11 | 12 | logging.basicConfig( 13 | format='%(asctime)s %(levelname)-8s %(message)s', 14 | level=logging.INFO, 15 | datefmt='%Y-%m-%d %H:%M:%S') 16 | 17 | 18 | def initialize_ffprobe_command(): 19 | 20 | cmd_ini = conf.get('Docker', 'INTRO_CMD') + ' ' 21 | cmd_ini += '-v ' + conf.get('Docker', 'VOLUME_VIDEOS_PATH') + ':' + conf.get('Docker', 'VOLUME_VIDEOS_PATH') + ' ' 22 | return cmd_ini + ' --entrypoint ffprobe ' + conf.get('Docker', 'IMAGE') 23 | 24 | 25 | def initialize_ffmpeg_command(): 26 | 27 | cmd_ini = conf.get('Docker', 'INTRO_CMD') + ' ' + conf.get('Docker', 'DEVICE_CMD') + ' ' 28 | cmd_ini += '-v ' + conf.get('Docker', 'VOLUME_VIDEOS_PATH') + ':' + conf.get('Docker', 'VOLUME_VIDEOS_PATH') + ' ' 29 | cmd_ini += '-v ' + conf.get('Docker', 'VOLUME_WORKSPACE') + ':' + conf.get('Docker', 'VOLUME_WORKSPACE') + ' ' 30 | 31 | ffmpeg_cmd = cmd_ini + ' ' + conf.get('Docker', 'IMAGE') 32 | 33 | if bool(conf.get('FFmpeg', 'HW_TRANSCODING')): 34 | ffmpeg_cmd += ' ' + conf.get('FFmpeg', 'VAAPI_INTRO') 35 | 36 | return ffmpeg_cmd 37 | 38 | 39 | def get_json_video_info(video_path): 40 | logging.debug("get_json_video_info: " + str(video_path)) 41 | ffprobe_pipe = initialize_ffprobe_command() + ' -loglevel quiet "' + video_path + '"' 42 | ffprobe_pipe += ' -show_entries stream=width,height,r_frame_rate,profile,codec_name' 43 | ffprobe_pipe += ' -print_format json' 44 | raw_video_info = os.popen(ffprobe_pipe) 45 | time.sleep(1) 46 | return json.loads(raw_video_info.read()) 47 | 48 | 49 | def get_dict_video_info(json_video_info, video_path='original'): 50 | logging.debug("get_dict_video_info: " + str(json_video_info)) 51 | video_info = {} 52 | 53 | for stream in json_video_info['streams']: 54 | if 'width' and 'height' in str(stream): 55 | video_info['width'] = stream['width'] 56 | video_info['height'] = stream['height'] 57 | video_info['codec_name'] = stream['codec_name'] 58 | video_info['profile'] = stream['profile'] 59 | raw_framerate = stream['r_frame_rate'].split('/') 60 | video_info['framerate'] = int(raw_framerate[0]) / int(raw_framerate[1]) 61 | return video_info 62 | 63 | logging.warning("get_dict_video_info. Invalid video details for: " + str(video_path)) 64 | return False 65 | 66 | 67 | def get_video_info(video_path): 68 | logging.debug("get_video_info: " + str(video_path)) 69 | 70 | json_video_info = get_json_video_info(video_path) 71 | if 'streams' in str(json_video_info) and len(json_video_info['streams']): 72 | dict_video_info = get_dict_video_info(json_video_info) 73 | return dict_video_info if len(dict_video_info) else False 74 | 75 | logging.warning("get_video_info. Invalid video details: " + str(json_video_info)) 76 | video_info = {} 77 | video_info['width'] = video_info['height'] = video_info['profile'] = 0 78 | 79 | return video_info 80 | 81 | 82 | def check_if_videos_needs_transcoding(base_video_path, videos_filename): 83 | videos_that_need_transcoding = [] 84 | for video_filename in videos_filename: 85 | video_file_path = f'{base_video_path}{video_filename}' 86 | video_file_signal = f'{video_file_path}_completed_{conf.get("OutputVideo", "VIDEO_CODEC")}' 87 | 88 | logging.debug(f'check_if_videos_needs_transcoding. Analyzing: {video_file_path}') 89 | if os.path.exists(video_file_path) and not os.path.isfile(video_file_signal): 90 | logging.info(f'check_if_videos_needs_transcoding. {video_file_path} needs to be transcoded') 91 | videos_that_need_transcoding.append(video_file_path) 92 | else: 93 | logging.debug(f'check_if_videos_needs_transcoding. Video: {video_file_path} do not exists') 94 | 95 | return videos_that_need_transcoding 96 | 97 | 98 | def transcode_video(original_video_path, optimized_video_path): 99 | logging.debug(f'video_transcode. From {original_video_path} to {optimized_video_path}') 100 | 101 | pipe = initialize_ffmpeg_command() + ' -loglevel quiet -y -i "' + original_video_path + '" ' 102 | 103 | original_video_info = get_video_info(original_video_path) 104 | if original_video_info['width']: 105 | video_width = original_video_info['width'] 106 | video_height = original_video_info['height'] 107 | else: 108 | logging.warning("video_transcode. Invalid resolution") 109 | video_width = video_height = 0 110 | 111 | if video_width >= video_height: 112 | pipe += "-vf 'format=nv12,hwupload,scale_vaapi=w=-2:h=" + conf.get('OutputVideo', 'VIDEO_MAX_H_W') + "' " 113 | else: 114 | pipe += "-vf 'format=nv12,hwupload,scale_vaapi=w=" + conf.get('OutputVideo', 'VIDEO_MAX_H_W') + ":h=-2' " 115 | 116 | if bool(conf.get('FFmpeg', 'HW_TRANSCODING')): 117 | pipe += '-c:v ' + conf.get('OutputVideo', 'VIDEO_CODEC') + '_vaapi ' 118 | else: 119 | pipe += '-c:v ' + conf.get('OutputVideo', 'VIDEO_CODEC') + ' ' 120 | 121 | pipe += conf.get('OutputVideo', 'VIDEO_PROFILE') + ' ' 122 | pipe += '-b:v ' + conf.get('OutputVideo', 'VIDEO_BITRATE') + ' ' 123 | pipe += '-maxrate ' + conf.get('OutputVideo', 'VIDEO_BITRATE') + ' ' 124 | pipe += '-threads ' + conf.get('FFmpeg', 'EXECUTION_THREADS') + ' ' 125 | 126 | working_dir = conf.get('Docker', 'VOLUME_WORKSPACE') + 'ffmpeg_workspace' 127 | os.makedirs(working_dir, exist_ok=True) 128 | log_filename_path = working_dir + '/ffmpeg' 129 | 130 | pipe_2_pass_1 = pipe + ' -pass 1 -passlogfile "' + log_filename_path + '" -an -f null /dev/null' 131 | 132 | pipe += '-c:a ' + conf.get('OutputVideo', 'AUDIO_CODEC') + ' ' 133 | pipe += '-b:a ' + conf.get('OutputVideo', 'AUDIO_BITRATE') + ' ' 134 | pipe += '-ac ' + conf.get('OutputVideo', 'AUDIO_CHANNELS') + ' ' 135 | 136 | pipe_2_pass_2 = pipe + ' -pass 2 -passlogfile "' + log_filename_path + '" ' 137 | pipe_2_pass_2 += conf.get('FFmpeg', 'CONTAINER_FLAGS') + ' "' + optimized_video_path + '"' 138 | 139 | logging.info(f'video_transcode. Starting transcoding (1/2) for: {original_video_path} to {optimized_video_path}') 140 | os.system(pipe_2_pass_1) 141 | logging.info(f'video_transcode. Finished transcoding (1/2) for: {original_video_path} to {optimized_video_path}') 142 | 143 | time.sleep(5) 144 | 145 | logging.info(f'video_transcode. Starting transcoding (2/2) for: {original_video_path} to {optimized_video_path}') 146 | if int(os.system(pipe_2_pass_2)): 147 | logging.warning(f'video_transcode. Transcoding proccess for: {original_video_path} to {optimized_video_path}') 148 | 149 | logging.info(f'video_transcode. Starting Single-pass for: {original_video_path} to {optimized_video_path}') 150 | pipe_one_pass = pipe + ' -crf 16 "' + optimized_video_path + '"' 151 | 152 | if os.system(pipe_one_pass): 153 | logging.warning(f'video_transcode. Failed Single-pass for: {original_video_path} to {optimized_video_path}') 154 | clean_path(working_dir) 155 | return False 156 | 157 | logging.info(f'video_transcode. Finished Single-pass for: {original_video_path} to {optimized_video_path}') 158 | clean_path(working_dir) 159 | create_transcode_signal(optimized_video_path) 160 | return True 161 | 162 | logging.info(f'video_transcode. Finished transcoding (2/2) for: {original_video_path} to {optimized_video_path}') 163 | clean_path(working_dir) 164 | create_transcode_signal(optimized_video_path) 165 | return True 166 | -------------------------------------------------------------------------------- /photo-enhancer/utils.py: -------------------------------------------------------------------------------- 1 | import os 2 | import glob 3 | import shutil 4 | import logging 5 | import configparser 6 | 7 | conf = configparser.ConfigParser() 8 | conf.read('video_quality_enhancer.conf') 9 | 10 | logging.basicConfig( 11 | format='%(asctime)s %(levelname)-8s %(message)s', 12 | level=logging.INFO, 13 | datefmt='%Y-%m-%d %H:%M:%S') 14 | 15 | 16 | def clean_path(directory_path): 17 | logging.debug(f'clean_path: {directory_path}') 18 | if os.path.isdir(directory_path): 19 | logging.debug('clean_path - Deleting content') 20 | shutil.rmtree(directory_path) 21 | os.makedirs(directory_path) 22 | 23 | 24 | def get_video_paths(): 25 | logging.debug('get_video_paths') 26 | paths = [conf.get('InputFolders', 'VIDEO_PATH')] 27 | for user in conf.get('InputFolders', 'USERS').split(','): 28 | paths.append(conf.get('InputFolders', 'USER_VIDEO_PATH').replace('_user_', user.strip())) 29 | 30 | return paths 31 | 32 | 33 | def list_videos_in_folder(path): 34 | logging.debug(f'list_videos_in_folder. Listing videos located in: {path}') 35 | all_video_files = [] 36 | for video_extension in conf.get('InputVideo', 'VIDEO_EXTENSIONS').split(','): 37 | all_video_files += glob.iglob(path + '**/*.' + video_extension.strip(), recursive=True) 38 | 39 | original_video_files = [x for x in all_video_files if not "@eaDir" in x and not "#recycle" in x] 40 | logging.debug(f'list_videos_in_folder. Videos found: {len(original_video_files)}') 41 | return original_video_files 42 | 43 | 44 | def create_transcode_signal(original_file): 45 | logging.debug(f'create_transcode_signal. {original_file}') 46 | signal_file = f'{original_file}_completed_{conf.get("OutputVideo", "VIDEO_CODEC")}' 47 | open(signal_file, 'a').close() 48 | 49 | 50 | def delete_files_by_name(folder_path, word): 51 | logging.debug(f'clean_completed_signal_file. {folder_path}') 52 | for filename in os.listdir(folder_path): 53 | if word in filename: 54 | os.remove(f'{folder_path}{filename}') 55 | -------------------------------------------------------------------------------- /photo-enhancer/video_quality_enhancer.conf: -------------------------------------------------------------------------------- 1 | [InputFolders] 2 | 3 | USERS: user1, user2, user3 4 | VIDEO_PATH: /volume1/photo/ 5 | USER_VIDEO_PATH: /volume1/homes/_user_/Photos/ 6 | ; _user_ will be automatically modified by the USERS defined above 7 | 8 | [InputVideo] 9 | 10 | VIDEO_EXTENSIONS: mp4, MP4, mov, MOV, avi, AVI, 3gp 11 | 12 | 13 | [OutputVideo] 14 | 15 | VIDEO_CODEC: h264 16 | VIDEO_BITRATE: 2048k 17 | VIDEO_MAX_H_W: 720 18 | VIDEO_PROFILE: -profile:v high -level:v 4.1 19 | VIDEO_CONTAINER: mp4 20 | 21 | AUDIO_CODEC: aac 22 | AUDIO_BITRATE: 128k 23 | AUDIO_CHANNELS: 1 24 | 25 | [Docker] 26 | 27 | INTRO_CMD: docker run --rm 28 | DEVICE_CMD: --device /dev/dri:/dev/dri 29 | 30 | IMAGE: jrottenberg/ffmpeg:5-vaapi 31 | 32 | VOLUME_VIDEOS_PATH: /volume1/ 33 | VOLUME_WORKSPACE: /tmp/video_quality_enhancer/ 34 | 35 | 36 | [FFmpeg] 37 | 38 | HW_TRANSCODING: True 39 | VAAPI_INTRO: -hwaccel vaapi -vaapi_device /dev/dri/renderD128 40 | CONTAINER_FLAGS: -movflags +faststart 41 | EXECUTION_THREADS: 2 -------------------------------------------------------------------------------- /photo-enhancer/video_quality_enhancer.py: -------------------------------------------------------------------------------- 1 | import logging 2 | import configparser 3 | 4 | from utils import clean_path, get_video_paths, list_videos_in_folder, delete_files_by_name 5 | from ffmpeg_commands import check_if_videos_needs_transcoding, transcode_video 6 | 7 | INTERMEDIATE_VIDEOS = ['SYNOPHOTO_FILM_M.mp4', 'SYNOPHOTO_FILM_H.mp4'] 8 | QA_CONTROL_FILENAME = 'optimizated_video' 9 | 10 | conf = configparser.ConfigParser() 11 | conf.read('video_quality_enhancer.conf') 12 | 13 | logging.basicConfig( 14 | format='%(asctime)s %(levelname)-8s %(message)s', 15 | level=logging.INFO, 16 | datefmt='%Y-%m-%d %H:%M:%S') 17 | 18 | 19 | def checkVideosInFolder(path): 20 | logging.debug(f'checkVideosInFolder. Analyzing videos inside the path: {path}') 21 | for video_path in list_videos_in_folder(path): 22 | filename = video_path.split('/')[-1:][0] 23 | snapshots_video_path = video_path.replace(filename, '') + '@eaDir/' + filename + '/' 24 | 25 | for video2transcode in check_if_videos_needs_transcoding(snapshots_video_path, INTERMEDIATE_VIDEOS): 26 | delete_files_by_name(snapshots_video_path, '_completed') 27 | transcode_video(video_path, video2transcode) 28 | 29 | 30 | logging.info("------------------------------------------------------") 31 | logging.info("- Starting video quality enhancer for Synology Photo -") 32 | logging.info("------------------------------------------------------") 33 | logging.info("") 34 | 35 | clean_path(conf.get('Docker', 'VOLUME_WORKSPACE')) 36 | 37 | for video_path in get_video_paths(): 38 | logging.info(f'Analyzing folder content {video_path}') 39 | checkVideosInFolder(video_path) 40 | 41 | logging.info("") 42 | logging.info("------------------------------------------------") 43 | logging.info("- Analysis and video enhancement was COMPLETED -") 44 | logging.info("------------------------------------------------") 45 | --------------------------------------------------------------------------------