├── LICENSE ├── README.md ├── compress.py ├── data ├── config │ ├── config.json │ ├── config_.json │ ├── global_config.json │ └── param_template.json ├── log │ └── .gitkeep ├── log_config │ └── log_config.conf └── script_template │ └── vs_script_template.py ├── doc └── change_log.zh-CN.md ├── media_master ├── __init__.py ├── analysis │ ├── __init__.py │ └── gop_analysis.py ├── audio │ ├── __init__.py │ └── transcode.py ├── error │ ├── __init__.py │ └── error.py ├── log │ ├── __init__.py │ └── log.py ├── track │ ├── __init__.py │ └── track.py ├── transcode.py ├── util │ ├── __init__.py │ ├── chapter.py │ ├── chapter_converter.py │ ├── charset.py │ ├── check.py │ ├── config.py │ ├── constant.py │ ├── extraction.py │ ├── ffprobe.py │ ├── file_hash.py │ ├── fraction.py │ ├── language.py │ ├── log_compress.py │ ├── meta_data.py │ ├── multiplex.py │ ├── name_hash.py │ ├── number.py │ ├── package_subtitle.py │ ├── rar_compress.py │ ├── sort.py │ ├── string_util.py │ ├── subtitle.py │ ├── subtitle_check.py │ ├── template.py │ └── timecode.py └── video │ ├── __init__.py │ ├── fps_conversion.py │ └── transcode.py └── requirements.txt /README.md: -------------------------------------------------------------------------------- 1 | # Media Master 2 | 3 | You can transcode videos with your vapoursynth script template via Media Master. 4 | 5 | If you want to transcode many videos with flexibility automatically, you should use it. 6 | 7 | It supports series mission mode, you can transcode a series of videos, add audios, subtitles, chapters, attachments and so on. 8 | 9 | It supports changing fps, changing sar, hdr, vfr and so on. 10 | 11 | Document: [Media Master](https://aceclee.art/archives/category/media-master). 12 | 13 | Supported frameserver: VapourSynth. 14 | 15 | Supported output video format: hevc([x265-Yuuki-Asuna](https://github.com/msg7086/x265-Yuuki-Asuna), [NVEnc](https://github.com/rigaya/NVEnc)), avc([x264](https://github.com/jpsdr/x264), [NVEnc](https://github.com/rigaya/NVEnc)). 16 | 17 | Supported output audio format: flac, opus, qaac. 18 | 19 | Supported container: mkv, mp4. 20 | 21 | ## Table of Contents 22 | 23 | - [Media Master](#media-master) 24 | - [Table of Contents](#table-of-contents) 25 | - [Background](#background) 26 | - [Install](#install) 27 | - [Usage](#usage) 28 | - [Badge](#badge) 29 | - [Related Efforts](#related-efforts) 30 | - [Maintainers](#maintainers) 31 | - [License](#license) 32 | 33 | ## Background 34 | 35 | When I started to learn video transcoding, I just wanted to downscale some videos for my friend. I have found a software named MediaCoder, it is a great software, but it is too expensive to use it for me. So I started to develop a software to transcode many videos automatically. 36 | 37 | Now, I can use Media Master to transcode videos expediently. 38 | 39 | ## Install 40 | 41 | download Media Master. 42 | 43 | ```shell 44 | git clone https://github.com/AceCLee/Media-Master.git 45 | ``` 46 | 47 | install python libraries. 48 | 49 | ```shell 50 | pip install -r requirements.txt 51 | ``` 52 | 53 | install [MKVToolNix](https://mkvtoolnix.download/downloads.html), [GPAC 0.8.0](https://www.videohelp.com/download/gpac-0.8.0-rev95-g00dfc933-master-x64.exe), [NVEnc](https://github.com/rigaya/NVEnc/releases), [x265-Yuuki-Asuna](https://down.7086.in/x265-Yuuki-Asuna/), [x264-tmod](https://github.com/jpsdr/x264/releases), [gop_muxer](https://github.com/msg7086/gop_muxer/releases), [FFmpeg](http://ffmpeg.org/download.html), [FLAC](https://xiph.org/flac/download.html), [qaac](https://github.com/nu774/qaac/releases), [AppleApplicationSupport used by qaac](https://github.com/kiki-kiko/iTunes-12.3.1.23) and [Opus](https://opus-codec.org/downloads/), add their paths to environment variables. 54 | 55 | install [vapoursynth](https://github.com/vapoursynth/vapoursynth/releases) or [vapoursynth-portable](https://github.com/theChaosCoder/vapoursynth-portable-FATPACK/releases) and add path of vspipe.exe to environment variables. 56 | 57 | ## Usage 58 | 59 | edit default config files (data/config/config.json and data/config/param_template.json). 60 | 61 | supported config format: json(.json), yaml(.yaml .yml) and hocon(.conf .hocon). 62 | 63 | if you like other config format, you can change filepath of the config file in `compress.py`. 64 | 65 | ```shell 66 | python compress.py 67 | ``` 68 | 69 | ## Badge 70 | 71 | [![GitHub](https://img.shields.io/github/license/AceCLee/Media-Master?style=flat-square)](https://github.com/AceCLee/Media-Master) 72 | 73 | ## Related Efforts 74 | 75 | - [NVEnc](https://github.com/rigaya/NVEnc) - a cmdline tool to transcode videos with nvidia gpu and process videos. 76 | - [x265-Yuuki-Asuna](https://github.com/msg7086/x265-Yuuki-Asuna) - a fork of x265 with more practical functions. 77 | 78 | ## Maintainers 79 | 80 | [@AceCLee](https://github.com/AceCLee). 81 | 82 | ## License 83 | 84 | [GPL-3.0](https://github.com/AceCLee/Media-Master/blob/master/LICENSE) 85 | -------------------------------------------------------------------------------- /compress.py: -------------------------------------------------------------------------------- 1 | """ 2 | compress.py compress videos with media_master 3 | Copyright (C) 2019 Ace C Lee 4 | 5 | This program is free software: you can redistribute it and/or modify 6 | it under the terms of the GNU General Public License as published by 7 | the Free Software Foundation, either version 3 of the License, or 8 | (at your option) any later version. 9 | 10 | This program is distributed in the hope that it will be useful, 11 | but WITHOUT ANY WARRANTY; without even the implied warranty of 12 | MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 13 | GNU General Public License for more details. 14 | 15 | You should have received a copy of the GNU General Public License 16 | along with this program. If not, see . 17 | """ 18 | 19 | import os 20 | from media_master import transcode_all_missions 21 | 22 | # type cmdline to run this script 23 | 24 | if __name__ == "__main__": 25 | config_filepath: str = os.path.join(os.getcwd(), "data/config/config.json") 26 | param_template_filepath: str = os.path.join( 27 | os.getcwd(), "data/config/param_template.json" 28 | ) 29 | global_config_filepath: str = os.path.join( 30 | os.getcwd(), "data/config/global_config.json" 31 | ) 32 | 33 | transcode_all_missions( 34 | config_filepath, param_template_filepath, global_config_filepath 35 | ) 36 | -------------------------------------------------------------------------------- /data/config/config.json: -------------------------------------------------------------------------------- 1 | { 2 | "basic_config": { 3 | "delay_start_sec": 0, 4 | "log_config_filepath": "data/log_config/log_config.conf", 5 | "delete_cache_file_bool": true 6 | }, 7 | "all_mission_config": [{ 8 | "type": "single", 9 | "type_related_config": { 10 | "input_video_filepath": "A:/xxxx/xxxx.mkv", 11 | "external_subtitle_info_list": [], 12 | "external_audio_info_list": [], 13 | "external_chapter_info": { 14 | "filepath": "" 15 | }, 16 | "hardcoded_subtitle_info": { 17 | "filepath": "" 18 | }, 19 | "segmented_transcode_config_list": [], 20 | "output_video_dir": "A:/xxxx/", 21 | "output_video_name": "xxxx" 22 | }, 23 | "general_config": { 24 | "cache_dir": "A:/xxxx/", 25 | "package_format": "mp4", 26 | "thread_bool": false, 27 | "video_related_config": { 28 | "video_process_option": "transcode", 29 | "output_full_range_bool": false, 30 | "video_title": "", 31 | "video_language": "", 32 | "frame_server": "vspipe", 33 | "frame_server_template_filepath": "A:/xxxx/remove_grain_template.py", 34 | "frame_server_template_config": { 35 | "input_filepath": "{{input_filepath}}", 36 | "threads_num": 0, 37 | "max_memory_size_mb": 0, 38 | "output_bit_depth": 0, 39 | "output_width": "{{input_video_width}}", 40 | "output_height": "{{input_video_height}}", 41 | "input_full_range_bool": "{{input_full_range_bool}}", 42 | "output_full_range_bool": "{{output_full_range_bool}}", 43 | "fps_num": "{{fps_num}}", 44 | "output_fps_num": "{{output_fps_num}}", 45 | "fps_den": "{{fps_den}}", 46 | "output_fps_den": "{{output_fps_den}}", 47 | "input_color_matrix": "{{input_color_matrix}}", 48 | "input_color_primaries": "{{input_color_primaries}}", 49 | "input_transfer": "{{input_transfer}}", 50 | "vfr_bool": "{{vfr_bool}}", 51 | "output_chroma_subsampling": "420", 52 | "subtitle_bool": true, 53 | "subtitle_filepath": "{{hardcoded_subtitle_filepath}}", 54 | "timecode_filepath": "{{timecode_filepath}}", 55 | "first_frame_index": "{{first_frame_index}}", 56 | "last_frame_index": "{{last_frame_index}}" 57 | }, 58 | "video_transcoding_method": "x264", 59 | "video_transcoding_cmd_param_template": [ 60 | "{{vspipe_exe_filepath}}", 61 | "--y4m", 62 | "{{input_vpy_filepath}}", 63 | "-", 64 | "|", 65 | "{{x264_exe_filepath}}", 66 | "--demuxer", 67 | "y4m", 68 | "-", 69 | "--output", 70 | "{{output_video_filepath}}", 71 | 72 | "--output-depth", 73 | "8", 74 | "--preset", 75 | "veryslow", 76 | "--crf", 77 | "{{crf}}" 78 | ], 79 | "video_transcoding_cmd_param_template_config": { 80 | "crf": 0, 81 | "merange": 0, 82 | "fgo": 0 83 | }, 84 | "gop_segmented_transcode_config": { 85 | "gop_frame_cnt": 6000 86 | }, 87 | "output_fps": "", 88 | "output_frame_rate_mode": "", 89 | "output_sar": "", 90 | "output_dynamic_range_mode": "" 91 | }, 92 | "audio_related_config": { 93 | "audio_prior_option": "external", 94 | "external_audio_process_option": "transcode", 95 | "internal_audio_track_to_process": "default", 96 | "internal_audio_process_option": "copy", 97 | "internal_audio_info_list": [{ 98 | "title": "Main Audio", 99 | "language": "jpn", 100 | "delay_ms_delta": 0 101 | }], 102 | "internal_audio_track_order_list": [], 103 | "audio_transcoding_method": "qaac", 104 | "audio_transcoding_cmd_param_template": [ 105 | "{{ffmpeg_exe_filepath}}", 106 | "-i", 107 | "{{input_audio_filepath}}", 108 | "-vn", 109 | "-sn", 110 | "-dn", 111 | "-n", 112 | "-f", 113 | "wav", 114 | "-codec:a", 115 | "{{ffmpeg_wav_audio_codec}}", 116 | "-", 117 | "|", 118 | "{{qaac_exe_filepath}}", 119 | "--tvbr", 120 | "32", 121 | "--quality", 122 | "2", 123 | "--threading", 124 | "--ignorelength", 125 | "-o", 126 | "{{output_filepath}}", 127 | "-" 128 | ] 129 | }, 130 | "subtitle_related_config": { 131 | "subtitle_prior_option": "external", 132 | "copy_internal_subtitle_bool": false, 133 | "internal_subtitle_info_list": [], 134 | "internal_subtitle_track_order_list": [] 135 | }, 136 | "chapter_related_config": { 137 | "copy_internal_chapter_bool": true 138 | }, 139 | "attachment_related_config": { 140 | "copy_internal_attachment_bool": false, 141 | "external_attachment_filepath_list": [] 142 | } 143 | } 144 | }] 145 | } -------------------------------------------------------------------------------- /data/config/config_.json: -------------------------------------------------------------------------------- 1 | { 2 | "basic_config": { 3 | "delay_start_sec": 0, 4 | "log_config_filepath": "data/log_config/log_config.conf", 5 | "delete_cache_file_bool": true 6 | }, 7 | "all_mission_config": [{ 8 | "type": "single", 9 | "type_related_config": { 10 | "input_video_filepath": "", 11 | "external_subtitle_info_list": [], 12 | "external_audio_info_list": [], 13 | "external_chapter_info": { 14 | "filepath": "" 15 | }, 16 | "hardcoded_subtitle_info": { 17 | "filepath": "" 18 | }, 19 | "segmented_transcode_config_list": [], 20 | "output_video_dir": "", 21 | "output_video_name": "" 22 | }, 23 | "general_config": { 24 | "cache_dir": "", 25 | "package_format": "mp4", 26 | "thread_bool": true, 27 | "video_related_config": { 28 | "video_process_option": "transcode", 29 | "output_full_range_bool": false, 30 | "video_title": "AceCLee", 31 | "video_language": "", 32 | "frame_server": "vspipe", 33 | "frame_server_template_filepath": "D:/xxx/test.vpy", 34 | "frame_server_template_config": {}, 35 | "video_transcoding_method": "x265", 36 | "video_transcoding_cmd_param_template": "x265_slower", 37 | "video_transcoding_cmd_param_template_config": {}, 38 | "gop_segmented_transcode_config": { 39 | "gop_frame_cnt": 9000 40 | }, 41 | "output_fps": "", 42 | "output_frame_rate_mode": "", 43 | "output_sar": "", 44 | "output_dynamic_range_mode": "" 45 | }, 46 | "audio_related_config": { 47 | "audio_prior_option": "external", 48 | "external_audio_process_option": "transcode", 49 | "internal_audio_track_to_process": "all", 50 | "internal_audio_process_option": "transcode", 51 | "internal_audio_info_list": [{ 52 | "title": "Main Audio", 53 | "language": "", 54 | "delay_ms_delta": 0 55 | }], 56 | "internal_audio_track_order_list": [], 57 | "audio_transcoding_method": "qaac", 58 | "audio_transcoding_cmd_param_template": "qaac" 59 | }, 60 | "subtitle_related_config": { 61 | "subtitle_prior_option": "external", 62 | "copy_internal_subtitle_bool": true, 63 | "internal_subtitle_info_list": [], 64 | "internal_subtitle_track_order_list": [] 65 | }, 66 | "chapter_related_config": { 67 | "copy_internal_chapter_bool": true 68 | }, 69 | "attachment_related_config": { 70 | "copy_internal_attachment_bool": false, 71 | "external_attachment_filepath_list": [] 72 | } 73 | } 74 | }, { 75 | "type": "series", 76 | "type_related_config": { 77 | "input_video_dir": "", 78 | "input_video_filename_reexp": "", 79 | "external_subtitle_info_list": [], 80 | "external_audio_info_list": [], 81 | "external_chapter_info": { 82 | "chapter_dir": "", 83 | "chapter_filename_reexp": "" 84 | }, 85 | "hardcoded_subtitle_info": { 86 | "hardcoded_subtitle_dir": "", 87 | "hardcoded_subtitle_filename_reexp": "" 88 | }, 89 | "segmented_transcode_config": {}, 90 | "output_video_dir": "", 91 | "output_video_name_template_str": "[Ace] XXX [{episode:0>2}]", 92 | "episode_list": "1~26" 93 | }, 94 | "universal_config": { 95 | "cache_dir": "", 96 | "package_format": "mkv", 97 | "thread_bool": true, 98 | "video_related_config": { 99 | "video_process_option": "transcode", 100 | "output_full_range_bool": false, 101 | "video_title": "AceCLee", 102 | "video_language": "jpn", 103 | "frame_server": "", 104 | "frame_server_template_filepath": "", 105 | "frame_server_template_config": {}, 106 | "video_transcoding_method": "nvenc", 107 | "video_transcoding_cmd_param_template": "nvenc", 108 | "video_transcoding_cmd_param_template_config": { 109 | "codec": "hevc", 110 | "preset": "quality" 111 | }, 112 | "gop_segmented_transcode_config": { 113 | "gop_frame_cnt": 9000 114 | }, 115 | "output_fps": "", 116 | "output_frame_rate_mode": "", 117 | "output_sar": "", 118 | "output_dynamic_range_mode": "" 119 | }, 120 | "audio_related_config": { 121 | "audio_prior_option": "external", 122 | "external_audio_process_option": "transcode", 123 | "internal_audio_track_to_process": "all", 124 | "internal_audio_process_option": "transcode", 125 | "internal_audio_info_list": [], 126 | "internal_audio_track_order_list": [], 127 | "audio_transcoding_method": "qaac", 128 | "audio_transcoding_cmd_param_template": "qaac_test" 129 | }, 130 | "subtitle_related_config": { 131 | "subtitle_prior_option": "external", 132 | "copy_internal_subtitle_bool": true, 133 | "internal_subtitle_info_list": [], 134 | "internal_subtitle_track_order_list": [] 135 | }, 136 | "chapter_related_config": { 137 | "copy_internal_chapter_bool": true 138 | }, 139 | "attachment_related_config": { 140 | "copy_internal_attachment_bool": false, 141 | "external_attachment_filepath_list": [] 142 | } 143 | } 144 | }] 145 | } -------------------------------------------------------------------------------- /data/config/global_config.json: -------------------------------------------------------------------------------- 1 | { 2 | "subtitle_allowable_missing_glyph_char_list": [" ", "♡", "❤", "♥", " ", "❈"] 3 | } -------------------------------------------------------------------------------- /data/config/param_template.json: -------------------------------------------------------------------------------- 1 | { 2 | "video_transcoding_cmd_param_template": { 3 | "x264_veryslow": [ 4 | "{{vspipe_exe_filepath}}", 5 | "--y4m", 6 | "{{input_vpy_filepath}}", 7 | "-", 8 | "|", 9 | "{{x264_exe_filepath}}", 10 | "--demuxer", 11 | "y4m", 12 | "-", 13 | "--output", 14 | "{{output_video_filepath}}", 15 | 16 | "--preset", 17 | "veryslow" 18 | ], 19 | "x265_slower": [ 20 | "{{vspipe_exe_filepath}}", 21 | "--y4m", 22 | "{{input_vpy_filepath}}", 23 | "-", 24 | "|", 25 | "{{x265_exe_filepath}}", 26 | "--y4m", 27 | "--output", 28 | "{{output_video_filepath}}", 29 | "-", 30 | "--preset", 31 | "slower" 32 | ], 33 | "nvenc_default": [ 34 | "{{nvenc_exe_filepath}}", 35 | "--input", 36 | "{{input_video_filepath}}", 37 | "--output", 38 | "{{output_video_filepath}}", 39 | "--codec", 40 | "{{codec}}", 41 | "--output-depth", 42 | "{{output_depth}}", 43 | "--cqp", 44 | "20:23:25" 45 | ], 46 | "vspipe_nvenc_default": [ 47 | "{{vspipe_exe_filepath}}", 48 | "--y4m", 49 | "{{input_vpy_filepath}}", 50 | "-", 51 | "|", 52 | "{{nvenc_exe_filepath}}", 53 | "--y4m", 54 | "--input", 55 | "-", 56 | "--output", 57 | "{{output_video_filepath}}", 58 | "--codec", 59 | "{{codec}}", 60 | "--output-depth", 61 | "{{output_depth}}", 62 | "--cqp", 63 | "20:23:25" 64 | ] 65 | }, 66 | "audio_transcoding_cmd_param_template": { 67 | "flac_test": [ 68 | "{{ffmpeg_exe_filepath}}", 69 | "-i", 70 | "{{input_audio_filepath}}", 71 | "-vn", 72 | "-sn", 73 | "-dn", 74 | "-n", 75 | "-f", 76 | "wav", 77 | "-codec:a", 78 | "{{ffmpeg_wav_audio_codec}}", 79 | "-", 80 | "|", 81 | "{{flac_exe_filepath}}", 82 | "-", 83 | "--best", 84 | "--ignore-chunk-sizes", 85 | "--force", 86 | "-o", 87 | "{{output_filepath}}" 88 | ], 89 | "qaac_test": [ 90 | "{{ffmpeg_exe_filepath}}", 91 | "-i", 92 | "{{input_audio_filepath}}", 93 | "-vn", 94 | "-sn", 95 | "-dn", 96 | "-n", 97 | "-f", 98 | "wav", 99 | "-codec:a", 100 | "{{ffmpeg_wav_audio_codec}}", 101 | "-", 102 | "|", 103 | "{{qaac_exe_filepath}}", 104 | "--ignorelength", 105 | "-o", 106 | "{{output_filepath}}", 107 | "-" 108 | ], 109 | "opus_128": [ 110 | "{{opusenc_exe_filepath}}", 111 | "--bitrate", 112 | "128.0", 113 | "--vbr", 114 | "{{input_audio_filepath}}", 115 | "{{output_filepath}}" 116 | ] 117 | } 118 | } -------------------------------------------------------------------------------- /data/log/.gitkeep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/AceCLee/Media-Master/38ef19de1217692f109ecbbcfaf84bf29c762f2b/data/log/.gitkeep -------------------------------------------------------------------------------- /data/log_config/log_config.conf: -------------------------------------------------------------------------------- 1 | [loggers] 2 | keys=root,media_master 3 | 4 | 5 | [handlers] 6 | keys=all_log_handler,warning_log_handler,error_log_handler,all_log_root_handler,error_log_root_handler,warning_log_root_handler 7 | 8 | [formatters] 9 | keys=simple_formatter,error_formatter,warning_formatter 10 | 11 | 12 | [formatter_simple_formatter] 13 | format=%(name)s - %(levelname)s - %(asctime)s - %(message)s 14 | 15 | [formatter_error_formatter] 16 | format=%(name)s - %(levelname)s - %(asctime)s -%(filename)s[:%(lineno)d] - %(message)s 17 | 18 | [formatter_warning_formatter] 19 | format=%(name)s - %(levelname)s - %(asctime)s -%(filename)s[:%(lineno)d] - %(message)s 20 | 21 | [handler_all_log_handler] 22 | class=handlers.TimedRotatingFileHandler 23 | level=DEBUG 24 | formatter=simple_formatter 25 | args=("data/log/all.log","midnight",1,0,"utf-8") 26 | 27 | [handler_warning_log_handler] 28 | class=FileHandler 29 | level=WARNING 30 | formatter=warning_formatter 31 | args=("data/log/warning.log","a","utf-8") 32 | 33 | [handler_error_log_handler] 34 | class=FileHandler 35 | level=ERROR 36 | formatter=error_formatter 37 | args=("data/log/error.log","a","utf-8") 38 | 39 | [handler_all_log_root_handler] 40 | class=handlers.TimedRotatingFileHandler 41 | level=DEBUG 42 | formatter=simple_formatter 43 | args=("data/log/all_root.log","midnight",1,0,"utf-8") 44 | 45 | [handler_warning_log_root_handler] 46 | class=FileHandler 47 | level=WARNING 48 | formatter=warning_formatter 49 | args=("data/log/warning_root.log","a","utf-8") 50 | 51 | [handler_error_log_root_handler] 52 | class=FileHandler 53 | level=ERROR 54 | formatter=error_formatter 55 | args=("data/log/error_root.log","a","utf-8") 56 | 57 | [logger_media_master] 58 | level=DEBUG 59 | handlers=all_log_handler,warning_log_handler,error_log_handler 60 | # must be top level module name 61 | qualname=media_master 62 | # propagate: events logged to this logger will be passed to the handlers of higher level (ancestor) loggers 63 | propagate=1 64 | 65 | [logger_root] 66 | level=DEBUG 67 | handlers=all_log_root_handler,warning_log_root_handler,error_log_root_handler 68 | -------------------------------------------------------------------------------- /data/script_template/vs_script_template.py: -------------------------------------------------------------------------------- 1 | import vapoursynth as vs 2 | from vapoursynth import core 3 | 4 | file_path = "{{input_filepath}}" 5 | 6 | threads_num = {{threads_num}} 7 | max_memory_size_mb = {{max_memory_size_mb}} 8 | 9 | output_width = {{output_width}} 10 | output_height = {{output_height}} 11 | 12 | output_bit_depth = {{output_bit_depth}} 13 | input_full_range_bool = {{input_full_range_bool}} 14 | 15 | output_full_range_bool = {{output_full_range_bool}} 16 | 17 | input_color_matrix = "{{input_color_matrix}}" 18 | 19 | input_color_primaries = "{{input_color_primaries}}" 20 | 21 | input_transfer = "{{input_transfer}}" 22 | 23 | fps_num = {{fps_num}} 24 | fps_den = {{fps_den}} 25 | 26 | output_fps_num = {{output_fps_num}} 27 | output_fps_den = {{output_fps_den}} 28 | 29 | vfr_bool = {{vfr_bool}} 30 | 31 | timecode_filepath = "{{timecode_filepath}}" 32 | 33 | hardcoded_subtitle_filepath = "{{hardcoded_subtitle_filepath}}" 34 | 35 | 36 | first_frame_index = {{first_frame_index}} 37 | last_frame_index = {{last_frame_index}} 38 | 39 | 40 | core.num_threads = threads_num 41 | core.max_cache_size = max_memory_size_mb 42 | 43 | if not vfr_bool: 44 | original = core.lsmas.LWLibavSource( 45 | file_path, fpsnum=fps_num, fpsden=fps_den 46 | ) 47 | else: 48 | original = core.lsmas.LWLibavSource(file_path) 49 | 50 | output = original 51 | 52 | if first_frame_index != -1 and last_frame_index != -1: 53 | output = output[first_frame_index : last_frame_index + 1] 54 | 55 | output.set_output() 56 | -------------------------------------------------------------------------------- /doc/change_log.zh-CN.md: -------------------------------------------------------------------------------- 1 | # Media Master 2 | 3 | ## Change Log 4 | 5 | ### Version 0.0.10.0 6 | 7 | * 将video.transcode.transcode_video_x265中存储stderror的信息的实现更改: 8 | 原实现:将每行的信息都存在一个字符串中,每读取指定行数清空一次字符串。 9 | 注解:虽然保证了字符串不会有超过指定行数的数据,但是并不能保证有尽可能多的信息,甚至可能需要的信息刚好在输出之前被清空了。 10 | 新实现:使用队列存储指定行数的stderr信息,元素为每行stderr的字符串。使用collections.deque。 11 | 注解:保证不论在何处结束都能输出指定函数的stderr信息。 12 | 13 | ### Version 0.0.10.1 14 | 15 | * 修复Version 0.0.10.0的队列存储指定行数的stderr信息的BUG: 16 | 原实现:使用队列存储指定行数的stderr信息,元素为每行stderr的字符串。但是即使读取到的每行信息为空,也会加入队列。 17 | 注解:很有可能出现队列里面全是空字符串。 18 | 新实现:先判断该行字符串是否为空,再加入队列中。 19 | 注解:保证不会存储无效的stderr信息。 20 | 21 | ### Version 0.0.10.2 22 | 23 | * 将media_master.transcode全部变为面向对象实现: 24 | 原实现:面向过程实现。 25 | 注解:不容易模块化。 26 | 新实现:面向对象实现。 27 | 注解:容易模块化,但是还未实现参数检查部分。 28 | 29 | * 将media_master.video.transcode的x265和nvenc变为面向对象实现,并且加入了x264的压制: 30 | 原实现:面向过程实现。 31 | 注解:大量代码冗余重复。 32 | 新实现:面向对象实现。 33 | 注解:使用继承重复利用代码,后面考虑使用多重继承复用代码。 34 | 35 | * 更改了VS脚本,加入了新的模板参数"video_style" 36 | * 更改了多处配置文件的,使其更加规范统一化 37 | 38 | ### Version 0.0.10.3 39 | 40 | * 加入添加附件功能 41 | * single支持添加多个字幕 42 | 43 | ### Version 0.0.10.4 44 | 45 | * 为字幕轨加上标题和语言 46 | 47 | ### Version 0.0.10.5 48 | 49 | * 将fps的赋值由纯数字改为"分子/分母"的形式 50 | 51 | ### Version 0.0.11.0 52 | 53 | * 使用统一标准的配置文件格式 54 | * 支持指定任何轨道的title和language 55 | * 支持系列多字幕 56 | * 支持多音轨转码 57 | 58 | ### Version 0.0.11.1 59 | 60 | * 加入qaac音频编码 61 | 62 | ### Version 0.0.11.2 63 | 64 | * 加入视频和音频压制复用的功能 65 | 66 | ### Version 0.0.11.3 67 | 68 | * 自动生成顺序和逆序的集数的功能 69 | * 修复外置音频的BUG,之前版本的外置音频是全局配置,也就是说即使对于series,不能按照集数的正则表达式配置 70 | * 复制视频轨道时,不再将视频提取出来(--repeat-header参数压制的流,被提取出来时一开始会出现绿屏) 71 | 72 | ### Version 0.0.11.4 73 | 74 | * 章节信息的分集指定 75 | 76 | ### Version 0.0.12.0 77 | 78 | * 使用GOP分段压制,支持断点续压 79 | * 由于使用Asuna版本的x265,自带较为全面的命令行输出,未修改之前x265正则表达式判定和输出。 80 | 81 | ### Version 0.0.12.1 82 | 83 | * 对分GOP压制的脚本名称进行了改进 84 | * 针对Asuna版本的x265修改之前x265正则表达式判定和输出 85 | * 为了保证正常的命令行输出,即使用户输入了--stylish参数,也会被去除 86 | 87 | ### Version 0.0.13.0 88 | 89 | * 支持按章节分段制定压制脚本和压制参数 90 | 91 | ### Version 0.0.13.1 92 | 93 | * 增加x265中压制详细压制信息(总帧数、时间、压制FPS、比特率、平均QP)打印和输出日志的功能 94 | 95 | ### Version 0.0.13.2 96 | 97 | * 增加提取视频和音频时若输出文件已经存在,就跳过的功能,减少大体积视频和音频对固态硬盘的损耗 98 | 99 | ### Version 0.0.14.0 100 | 101 | * 加入逐帧分析输入视频,并根据gop码率改变压制参数的功能。 102 | * 修复分段配置GOP压制时,预先配置两个的GOP叠在一起,无法生成正确最终配置的BUG。 103 | 104 | ### Version 0.0.14.1 105 | 106 | * 当压制视频和源视频标注的总帧数不同时,差别小于3时,会留下警告,差别不小于3时,会抛出错误 107 | * 增加若干gop分析的功能 108 | 109 | ### Version 0.0.14.2 110 | 111 | * 修复了GOP信息空输入的情况下的BUG 112 | 113 | ### Version 0.0.14.3 114 | 115 | * 为了兼容从mkv提取流会出错的视频,将提取流改变为复制原视频整体 116 | 117 | ### Version 0.0.14.4 118 | 119 | * 修复分段参数压制失效的BUG 120 | * 修复正则表达式找不到任何文件但是不报错的BUG 121 | 122 | ### Version 0.0.14.5 123 | 124 | * 发现在某些特殊的源,例如reinforce的未确认进行式的OAD,会出现mediainfo读不出总帧数的问题,读不出总帧数就相当于分段压制不可能实现,因此遇到不能读取总帧数的源,先用最新版的mkvmerge封装一次,就可以读取总帧数。 125 | 126 | ### Version 0.0.14.6 127 | 128 | * 加入当Gop区间过多抛出错误的功能,加入预测gop_muxer命令行参数长度的功能并输出至stderr 129 | 注解:由于Gop区间过于分散,在剧场版的压制时,在gop_muxer的封装部分,会出现Windows命令行参数无法正确接受过长的命令行参数的无解错误。后续考虑更换gop_muxer的调用目录,使用相对路径进行尝试。 130 | 131 | ### Version 0.0.14.7 132 | 133 | * 加入识别音频中mp2音频,正确输出音频后缀的功能 134 | * 修复GopX265VspipeVideoTranscoding中封装错误的Bug 135 | 136 | ### Version 0.0.14.8 137 | 138 | * 在frame_server_template_config中,增加了"{{input_video_width}}"、"{{input_video_height}}"、"{{2x_input_video_width}}"、"{{2x_input_video_height}}"、"{{4x_input_video_width}}"、"{{4x_input_video_height}}"模板 139 | 140 | ### Version 0.0.14.9 141 | 142 | * 加入识别音频中pcm音频,正确输出音频后缀的功能 143 | 144 | ### Version 0.0.14.10 145 | 146 | * 加入对非264、265视频流使用mkv重新封装,得到正确的帧率和帧数的功能 147 | 148 | ### Version 0.0.15.0 149 | 150 | * 加入自动化转码BD中音频和图片的功能 151 | * 加入由同一个single配置生成多个不同路径但是参数相同的配置模板功能 152 | 153 | ### Version 0.0.15.1 154 | 155 | * 小错误的修复 156 | 157 | ### Version 0.0.15.2 158 | 159 | * 在x265分GOP压制时,将缓存文件名映射为短长度hash,压缩缓存文件长度,完善超出长度提醒功能 160 | 161 | ### Version 0.0.15.3 162 | 163 | * 封装和完善了对不可信任的视频元数据的判断 164 | 165 | ### Version 0.0.15.4 166 | 167 | * 修复小错误,发布前最后一版本 168 | 169 | ### Version 0.0.15.5 170 | 171 | * 删除和修改一些注释 172 | * 将encapsulation和encapsulate改名为multiplex 173 | 174 | ### Version 0.0.16.0 175 | 176 | * 增加音频flac的压制功能 177 | * 增加对输入格式为m2ts的支持 178 | * 支持从能从MediaInfo获取章节信息的地方取得章节数据 179 | * 修复指定最小GOP区间时的错误 180 | * 修复若干错误 181 | 182 | ### Version 0.0.17.0 183 | 184 | * 增加了日志系统中warning级别的日志输出 185 | * 细分mkvmerge的返回值,根据返回值不同改变当下的行为 186 | * 之前的实现为只要不返回0,就会抛出错误,现在的实现为若返回1(mkvmerge输出警告),程序继续运行,将在日志中记录警告,若返回2,抛出错误 187 | * 配置文件中增加了audio_prior_option、external_audio_process_option、internal_audio_track_order_list、subtitle_prior_option、internal_subtitle_track_order_list四个选项,用于对于内外置音轨和内外置字幕更加细致的控制 188 | * 修复若干错误 189 | 190 | ### Version 0.0.17.1 191 | 192 | * 修复无法正确读取音频的错误 193 | 194 | ### Version 0.0.17.2 195 | 196 | * 修复当输入文件不带有音频,但是指定了音频"transcode"或"copy"时会报的错误 197 | * 在压制完成后的帧数校验时,比较帧数的时,优先获取MediaInfo中的"source_frame_count"字段中的帧率 198 | 199 | ### Version 0.0.17.3 200 | 201 | * 修复内置音频只能压制一个的bug 202 | * 修复外置音频和内置音频转码会冲突出现的bug 203 | 204 | ### Version 0.0.17.4 205 | 206 | * x265的输出目录不接受非ascii字符,因此在每个压制任务之前预先将其中非ascii字符删除,输出文件名中的非ascii字符将会被删除。 207 | 208 | ### Version 0.0.17.5 209 | 210 | * 增加压缩日志之功能 211 | 212 | ### Version 0.0.17.6 213 | 214 | * 对输入mkv有章节但是没有章节信息时出现的bug进行修复 215 | * 加入分析无效ass字幕的功能 216 | 217 | ### Version 0.0.17.7 218 | 219 | * 加入根据MediaInfo信息更改文件名的功能 220 | 221 | ### Version 0.0.17.8 222 | 223 | * 加入自动生成x265转码参数模板的功能 224 | 225 | ### Version 0.0.17.9 226 | 227 | * 修正hash相关模块的接口的参数名称 228 | * 考虑到输出帧率模式一般为cfr,增加对输入为vfr模式的视频的支持,自动将帧率和帧数改为cfr模式下的帧率 229 | * 增加对vspipe+nvenc模式的支持 230 | * 增加在最后混流时指定视频的帧率的功能,视频信息中增加fps的键值对即可实现 231 | * 增加对Voukoder封装的mkv的meta_data的信任 232 | * 修复了multiplex模块在校验是否所有键都存在的错误 233 | 234 | ### Version 0.0.18.0 235 | 236 | * 增加封装为mp4格式的功能 237 | * 在get_proper_frame_rate中增加讲23976/1000和29970/1000标准化的功能 238 | * 加入配置中单独指定转码参数的模板键值对功能 239 | 240 | ### Version 0.0.18.1 241 | 242 | * 外置音频和外置字幕可以直接从容器文件的轨道中提取 243 | * mkvextract增加对mka文件的支持 244 | * nvenc在读取新版本的mkv容器会无故闪退,因此在使用nvenc选择将视频流提取出来作为nvenc的输入 245 | 246 | ### Version 0.0.18.2 247 | 248 | * 修复整数倍帧数在LWLibavSource出现的错误 249 | * 修复track_index_list的相关bug 250 | 251 | ### Version 0.0.18.3 252 | 253 | * 若mkvextract提取主要流出现了警告,但是extract仍然进行了,此时不会报错,但是会在日志中留下warning 254 | * 修复无法提取vobsub为idx格式的字幕的错误 255 | * 若mkvextract提取附件和章节的该错误没有修复 256 | * 增加指定输出帧率的功能 257 | * 将在封装mkv时指定帧率的功能消除 258 | * 将在封装mp4时指定帧率的功能消除 259 | * nvenc的输入文件变为整个原视频容器 260 | * 去除帧数检测功能 261 | 262 | ### Version 0.0.18.4 263 | 264 | * 加入多线程并行转码视频和音轨的功能,但是输出会变得很乱 265 | * 去除字幕、音频和视频的个数必须匹配的限制 266 | * aac文件作为中间缓存文件时,直接保存aac文件无法保存时长信息,将其改为m4a封装的aac格式,防止出现时长错误 267 | * 修复封装mp4无法复制视频流的bug 268 | * 当输入文件路径长度大于255时,自动复制至缓存文件夹处理 269 | * 暂时将视频源元数据无法信任时封装的mkv格式的视频流的延迟全归零(nvenc字幕受到延迟影响bug) 270 | 271 | ### Version 0.0.18.5 272 | 273 | * 加入对mp4封装章节的支持 274 | * 针对mp4box不能正确视频路径的bug[MP4Box: Import options which have colons in them not properly parsed · Issue #873 · gpac/gpac](https://github.com/gpac/gpac/issues/873#issuecomment-521693709),将路径变为标准化路径避免bug 275 | * 修复封装mp4并且复制视频流时不会自动删除视频中间缓存文件的bug 276 | 277 | ### Version 0.0.18.6 278 | 279 | * 将视频源元数据无法信任时封装的mkv格式的视频流的延迟全归零去除 280 | * 增加脚本模板"{{output_fps_num}}","{{output_fps_den}}",该值为配置文件中"output_fps"指定的输出 281 | * 暂时去除将输出文件不可打印字符去除的功能 282 | * 输入文件为vob封装时,预先封装为mkv 283 | * 将x264的转码匹配最新版的[x264 tmod](https://github.com/jpsdr/x264/releases) 284 | * 加入更改内置音轨时延的功能 285 | * 修复了mp4提取流时出现的空的流序号的错误 286 | * 修复不指定视频转码参数模板时出现的bug 287 | * 由于较新版的MP4BOX封装的视频,不会直接在容器的音频轨道的元信息中留下delay_ms的信息,所以将所有mp4进行预封装 288 | * 修复复制视频时,无法指定输出视频元信息的bug 289 | * 修复了当通过"output_fps"更改输出帧率时,x265在分段压制时, 290 | 291 | ### Version 0.0.18.7 292 | 293 | * 增加对输出文件名的非ASCII字符的支持,某些编码器不支持非ASCII字符,之前中间缓存文件名均使用输出文件名,可能会报错。修改:中间缓存文件名为原文件删去非可见字符+哈希。 294 | * 输入文件扩展名为mpls,m2ts,vob,mp4会进行预封装为mkv 295 | * 将多线程模式改为:字幕操作、章节操作和附件操作串行,后为视频操作和音频操作有条件地并行(错开io),错开所有操作的io,防止因io操作过于密集造成错误出现。 296 | * 增加对wmv和wma格式的支持 297 | 298 | ### Version 0.0.18.8 299 | 300 | * 所有视频都预封装为mkv格式 301 | * 增加对vfr的支持 302 | * 增加对sar压制的支持 303 | 304 | ### Version 0.0.18.9 305 | 306 | * 增加对hdr压制的支持 307 | * 修复若干小错误 308 | 309 | ### Version 0.0.19.0 310 | 311 | * 压制前对配置文件绝大多数值进行正确性检查 312 | 313 | ### Version 0.0.19.1 314 | 315 | * 将修改元信息的实现从mkvmerge改为mkvpropedit 316 | 317 | ### Version 0.0.19.2 318 | 319 | * 修复正确性检查在copy视频时的一个逻辑错误 320 | * 修复视频提取和音频提取IO操作未错开的错误 321 | * 针对x264在中断压制后又重新压制导致和一次成型时体积的巨大差异,尝试通过在压制前删除之前的.264文件来避免此问题 322 | * 在正确性检查中加入将字幕非uft-8-bom编码更改为uft-8-bom编码,以应对vsmod无法识别uft-8编码的问题 323 | * mkvmerge在封装mkv文件时,媒体轨道原来的延迟会被考虑在内,不需要单独指定,不然会重复指定,修复之前版本会重复指定的错误 324 | * 在预检查中,增加对frame_server_template_config内的字幕文件的字体检查功能 325 | 326 | ### Version 0.0.19.3 327 | 328 | * 检查硬字幕字体即"frame_server_template_config"-"subtitle_filepath"中字幕的字体是否存在(Windows Only) 329 | * 修复转码后未将视频流索引置为0(仍然为输入视频容器的视频轨的索引)的错误 330 | * 修复压制视频之前未检查编码器环境变量的问题 331 | * 在调用子进程时,为了避免出现UnicodeDecodeError,调用子进程加入errors参数以规避该错误 332 | * 将universal_config改为general_config 333 | 334 | ### Version 0.0.19.4 335 | 336 | * 在配置文件中加入硬字幕读取功能,并作为模板{{hardcoded_subtitle_filepath}}给出 337 | * 增加了硬字幕的字型检查功能,可允许的缺失字型由"global_config.json"中的"subtitle_allowable_missing_glyph_char_list"给出 338 | * 完善了series转码的预检查逻辑 339 | * 优化了缓存文件的文件名 340 | 341 | ### Version 0.0.19.5 342 | 343 | * 修复了系统字体文件夹遇到错误字体报错的问题 344 | * 修复了无法转换章节的问题 345 | * 修复了requirements.txt中依赖库缺失的问题 346 | 347 | ### Version 0.0.19.6 348 | 349 | * 增加yaml(".yaml", ".yml")和hocon(".conf", ".hocon")配置文件的支持 350 | * 加入是否删除缓存文件的全局变量"delete_cache_file_bool" 351 | * 完善了封装容器为mp4时,对内封字幕的处理逻辑 352 | * 加入提取文件、复制文件时的验证机制,若成功提取,在文件名中加入标识 353 | * 将提取流的缓存文件名改为只与源视频相关,方便不删除缓存文件时复用 -------------------------------------------------------------------------------- /media_master/__init__.py: -------------------------------------------------------------------------------- 1 | from .transcode import transcode_all_missions 2 | -------------------------------------------------------------------------------- /media_master/analysis/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/AceCLee/Media-Master/38ef19de1217692f109ecbbcfaf84bf29c762f2b/media_master/analysis/__init__.py -------------------------------------------------------------------------------- /media_master/analysis/gop_analysis.py: -------------------------------------------------------------------------------- 1 | """ 2 | gop_analysis.py analyse gop information of video 3 | Copyright (C) 2019 Ace C Lee 4 | 5 | This program is free software: you can redistribute it and/or modify 6 | it under the terms of the GNU General Public License as published by 7 | the Free Software Foundation, either version 3 of the License, or 8 | (at your option) any later version. 9 | 10 | This program is distributed in the hope that it will be useful, 11 | but WITHOUT ANY WARRANTY; without even the implied warranty of 12 | MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 13 | GNU General Public License for more details. 14 | 15 | You should have received a copy of the GNU General Public License 16 | along with this program. If not, see . 17 | """ 18 | 19 | import os 20 | import sys 21 | import subprocess 22 | import logging 23 | import re 24 | import pandas as pd 25 | import numpy as np 26 | from ..util import check_file_environ_path 27 | from ..error import DirNotFoundError 28 | from ..util import save_config 29 | 30 | g_logger = logging.getLogger(__name__) 31 | g_logger.propagate = True 32 | g_logger.setLevel(logging.DEBUG) 33 | 34 | 35 | def video_frame_info( 36 | input_filepath: str, thread_num=os.cpu_count(), ffprobe_exe_file_dir="" 37 | ) -> str: 38 | if not isinstance(input_filepath, str): 39 | raise TypeError( 40 | f"type of input_filepath must be str " f"instead of {type(input_filepath)}" 41 | ) 42 | 43 | if not isinstance(thread_num, int): 44 | raise TypeError(f"type of thread_num must be int instead of {type(thread_num)}") 45 | 46 | if not isinstance(ffprobe_exe_file_dir, str): 47 | raise TypeError( 48 | f"type of ffprobe_exe_file_dir must be str " 49 | f"instead of {type(ffprobe_exe_file_dir)}" 50 | ) 51 | if not os.path.isfile(input_filepath): 52 | raise FileNotFoundError( 53 | f"input Matroska file cannot be found with {input_filepath}" 54 | ) 55 | 56 | ffprobe_exe_filename: str = "ffprobe.exe" 57 | if ffprobe_exe_file_dir: 58 | if not os.path.isdir(ffprobe_exe_file_dir): 59 | raise DirNotFoundError( 60 | f"ffprobe dir cannot be found with {ffprobe_exe_file_dir}" 61 | ) 62 | all_filename_list: list = os.listdir(ffprobe_exe_file_dir) 63 | if ffprobe_exe_filename not in all_filename_list: 64 | raise FileNotFoundError( 65 | f"{ffprobe_exe_filename} cannot be found in " f"{ffprobe_exe_file_dir}" 66 | ) 67 | else: 68 | if not check_file_environ_path({ffprobe_exe_filename}): 69 | raise FileNotFoundError( 70 | f"{ffprobe_exe_filename} cannot be found in " f"environment path" 71 | ) 72 | 73 | ffprobe_exe_filepath: str = os.path.join(ffprobe_exe_file_dir, ffprobe_exe_filename) 74 | input_file_dir: str = os.path.dirname(input_filepath) 75 | input_file_basename: str = os.path.basename(input_filepath) 76 | input_file_suffix: str = f".{input_file_basename.split('.')[-1]}" 77 | input_file_name: str = input_file_basename.replace(input_file_suffix, "") 78 | csv_suffix: str = ".csv" 79 | output_csv_filename: str = f"{input_file_name}{csv_suffix}" 80 | output_csv_filepath: str = os.path.join(input_file_dir, output_csv_filename) 81 | output_filepath: str = output_csv_filepath 82 | if os.path.isfile(output_filepath): 83 | skip_info_str: str = ( 84 | f"video_frame_info: {output_filepath} " "already existed, skip analysis." 85 | ) 86 | 87 | print(skip_info_str, file=sys.stderr) 88 | g_logger.log(logging.INFO, skip_info_str) 89 | return output_filepath 90 | 91 | thread_key: str = "-threads" 92 | thread_value: str = str(thread_num) 93 | select_stream_key: str = "-select_streams" 94 | map_video_symbol: str = "v" 95 | map_audio_symbol: str = "a" 96 | select_stream_value: str = map_video_symbol 97 | print_format_key: str = "-print_format" 98 | print_format_value: str = "csv" 99 | show_entries_key: str = "-show_entries" 100 | show_entries_value: str = "frame" 101 | output_symbol: str = ">>" 102 | 103 | args_list: list = [ 104 | ffprobe_exe_filepath, 105 | thread_key, 106 | thread_value, 107 | select_stream_key, 108 | select_stream_value, 109 | print_format_key, 110 | print_format_value, 111 | show_entries_key, 112 | show_entries_value, 113 | input_filepath, 114 | output_symbol, 115 | output_filepath, 116 | ] 117 | 118 | ffmpeg_param_debug_str: str = ( 119 | f"video_frame_info: param: {subprocess.list2cmdline(args_list)}" 120 | ) 121 | print(ffmpeg_param_debug_str, file=sys.stderr) 122 | g_logger.log(logging.DEBUG, ffmpeg_param_debug_str) 123 | 124 | start_info_str: str = ( 125 | f"video_frame_info: start to analyse " f"{input_filepath} to {output_filepath}" 126 | ) 127 | 128 | print(start_info_str, file=sys.stderr) 129 | g_logger.log(logging.INFO, start_info_str) 130 | process = subprocess.Popen(args_list, shell=True) 131 | 132 | return_code = process.communicate() 133 | 134 | if return_code == 0: 135 | end_info_str: str = ( 136 | f"video_frame_info: analyse {output_filepath} successfully." 137 | ) 138 | print(end_info_str, file=sys.stderr) 139 | g_logger.log(logging.INFO, end_info_str) 140 | else: 141 | raise ChildProcessError( 142 | f"video_frame_info: analyse {output_filepath} unsuccessfully." 143 | ) 144 | 145 | return output_filepath 146 | 147 | 148 | def save_high_bitrate_gop_info( 149 | input_filepath: str, 150 | config_json_filepath: str, 151 | thread_num: int, 152 | config: dict, 153 | minimum_gop_length=300, 154 | ): 155 | json_dir: str = os.path.dirname(config_json_filepath) 156 | if not os.path.isdir(json_dir): 157 | os.makedirs(json_dir) 158 | csv_filepath: str = video_frame_info(input_filepath, thread_num=thread_num) 159 | csv_text: str = "" 160 | with open(csv_filepath, mode="r") as csv_file: 161 | for line in csv_file.readlines(): 162 | if line.startswith("side_data"): 163 | continue 164 | if "N/A" in line: 165 | continue 166 | csv_text += line 167 | csv_text = csv_text.replace("\n\n\n", "\n") 168 | with open(csv_filepath, mode="w") as csv_file: 169 | csv_file.write(csv_text) 170 | 171 | unknown_name_list: list = [f"unknown{index}" for index in range(10)] 172 | original_frame_df: pd.DataFrame = pd.read_csv( 173 | csv_filepath, 174 | names=[ 175 | "entry", 176 | "media_type", 177 | "data_type", 178 | "key_frame", 179 | "pkt_pts", 180 | "pkt_pts_time", 181 | "pkt_dts", 182 | "pkt_dts_time", 183 | "best_effort_timestamp", 184 | "best_effort_timestamp_time", 185 | "pkt_duration", 186 | "pkt_duration_time", 187 | "pkt_pos", 188 | "pkt_size", 189 | "width", 190 | "height", 191 | "pix_fmt", 192 | "sample_aspect_ratio", 193 | "pict_type", 194 | "coded_picture_number", 195 | "display_picture_number", 196 | "interlaced_frame", 197 | "top_field_first", 198 | "repeat_pict", 199 | "color_range", 200 | "color_space1", 201 | "color_space2", 202 | "color_space3", 203 | "unspecified", 204 | ] 205 | + unknown_name_list, 206 | low_memory=False, 207 | ) 208 | frame_df = original_frame_df[["key_frame", "pkt_size"]] 209 | ave_size: float = frame_df["pkt_size"].mean() 210 | frame_df["gop_ave_size"] = np.zeros((frame_df.shape[0], 1)) 211 | frame_df["next_i_frame_index"] = np.zeros((frame_df.shape[0], 1), dtype=int) 212 | key_frame_index_array: np.ndarray = np.array( 213 | frame_df[frame_df["key_frame"] == 1].index 214 | ) 215 | key_frame_index_list: list = [key_frame_index_array[0]] 216 | 217 | key_frame_index_array_index: int = 0 218 | while key_frame_index_array_index < len(key_frame_index_array): 219 | least_next_key_frame_index: int = key_frame_index_array[ 220 | key_frame_index_array_index 221 | ] + minimum_gop_length 222 | next_key_frame_index_index: int = np.searchsorted( 223 | key_frame_index_array, least_next_key_frame_index 224 | ) 225 | if next_key_frame_index_index >= len(key_frame_index_array): 226 | break 227 | key_frame_index_list.append(key_frame_index_array[next_key_frame_index_index]) 228 | key_frame_index_array_index = next_key_frame_index_index 229 | key_frame_index_array = np.array(key_frame_index_list, dtype=int) 230 | 231 | for gop_index in range(key_frame_index_array.shape[0] - 1): 232 | index: int = key_frame_index_array[gop_index] 233 | next_index: int = key_frame_index_array[gop_index + 1] 234 | mean: float = frame_df["pkt_size"][index:next_index].values.mean() 235 | frame_df.iloc[index, 2] = mean 236 | frame_df.iloc[index, 3] = int(next_index) 237 | 238 | output_list: list = [] 239 | for key in config["multiple_config"].keys(): 240 | multiple_min: float = -1 241 | multiple_max: float = -1 242 | 243 | if key.endswith("~"): 244 | re_exp: str = "([\\d.]+)~" 245 | re_result = re.search(re_exp, key) 246 | multiple_min = float(re_result.group(1)) 247 | else: 248 | re_exp: str = "([\\d.]+)~([\\d.]+)" 249 | re_result = re.search(re_exp, key) 250 | multiple_min = float(re_result.group(1)) 251 | multiple_max = float(re_result.group(2)) 252 | if multiple_max != -1: 253 | if multiple_min == 0: 254 | multiple_min = 0.01 255 | target_df = frame_df[ 256 | (frame_df["gop_ave_size"] >= multiple_min * ave_size) 257 | & (frame_df["gop_ave_size"] < multiple_max * ave_size) 258 | ] 259 | else: 260 | target_df = frame_df[frame_df["gop_ave_size"] >= multiple_min * ave_size] 261 | 262 | target_list: list = [] 263 | for index, row in target_df.iterrows(): 264 | gop_dict: dict = {} 265 | gop_dict[config["first_frame_index_key"]] = index 266 | gop_dict[config["last_frame_index_key"]] = ( 267 | int(row["next_i_frame_index"]) - 1 268 | ) 269 | target_list.append(gop_dict) 270 | 271 | merge_target_list: list = [] 272 | for index in range(len(target_list)): 273 | if index == 0: 274 | merge_target_list.append(target_list[0]) 275 | continue 276 | if ( 277 | merge_target_list[-1][config["last_frame_index_key"]] + 1 278 | == target_list[index][config["first_frame_index_key"]] 279 | ): 280 | gop_dict: dict = {} 281 | gop_dict[config["first_frame_index_key"]] = merge_target_list[-1][ 282 | config["first_frame_index_key"] 283 | ] 284 | gop_dict[config["last_frame_index_key"]] = target_list[index][ 285 | config["last_frame_index_key"] 286 | ] 287 | merge_target_list[-1] = gop_dict 288 | else: 289 | merge_target_list.append(target_list[index]) 290 | 291 | target_dict: dict = {} 292 | target_dict[config["video_transcoding_cmd_param_template_key"]] = config[ 293 | "multiple_config" 294 | ][key]["video_transcoding_cmd_param_template_value"] 295 | target_dict[config["frame_server_template_filepath_key"]] = config[ 296 | "multiple_config" 297 | ][key]["frame_server_template_filepath_value"] 298 | target_dict[config["frame_interval_list_key"]] = merge_target_list 299 | output_list.append(target_dict) 300 | 301 | save_config(config_json_filepath, dict(gop_info=output_list)) 302 | print(f"save {config_json_filepath}", file=sys.stderr) 303 | return output_list 304 | 305 | 306 | def save_series_high_bitrate_gop_info( 307 | input_video_dir: str, 308 | input_video_filename_reexp: str, 309 | output_json_dir: str, 310 | thread_num: int, 311 | config: dict, 312 | minimum_gop_length: int, 313 | ): 314 | 315 | if not os.path.isdir(output_json_dir): 316 | os.makedirs(output_json_dir) 317 | 318 | json_suffix: str = ".json" 319 | all_gop_info_dict: dict = {} 320 | for filename in os.listdir(input_video_dir): 321 | re_result = re.search(pattern=input_video_filename_reexp, string=filename) 322 | if re_result: 323 | input_filepath: str = os.path.join(input_video_dir, filename) 324 | input_file_basename: str = os.path.basename(input_filepath) 325 | input_file_suffix: str = f".{input_file_basename.split('.')[-1]}" 326 | input_file_name: str = input_file_basename.replace(input_file_suffix, "") 327 | json_filename = f"{input_file_name}_gop_info{json_suffix}" 328 | json_filepath: str = os.path.join(output_json_dir, json_filename) 329 | print(input_filepath, json_filepath) 330 | gop_info_list: list = save_high_bitrate_gop_info( 331 | input_filepath, 332 | json_filepath, 333 | thread_num, 334 | config, 335 | minimum_gop_length=minimum_gop_length, 336 | ) 337 | episode_num: int = int(re_result.group(1)) 338 | all_gop_info_dict[str(episode_num)] = gop_info_list 339 | all_gop_info_json_filename: str = "all_gop_info" + json_suffix 340 | all_gop_info_json_filepath: str = os.path.join( 341 | output_json_dir, all_gop_info_json_filename 342 | ) 343 | save_config(all_gop_info_json_filepath, all_gop_info_dict) 344 | 345 | 346 | -------------------------------------------------------------------------------- /media_master/audio/__init__.py: -------------------------------------------------------------------------------- 1 | from .transcode import ( 2 | transcode_audio_opus, 3 | transcode_audio_qaac, 4 | transcode_audio_flac, 5 | transcode_audio_ffmpeg, 6 | ) 7 | -------------------------------------------------------------------------------- /media_master/error/__init__.py: -------------------------------------------------------------------------------- 1 | from .error import DirNotFoundError, MissTemplateError, RangeError 2 | -------------------------------------------------------------------------------- /media_master/error/error.py: -------------------------------------------------------------------------------- 1 | """ 2 | error.py error class of media_master 3 | Copyright (C) 2019 Ace C Lee 4 | 5 | This program is free software: you can redistribute it and/or modify 6 | it under the terms of the GNU General Public License as published by 7 | the Free Software Foundation, either version 3 of the License, or 8 | (at your option) any later version. 9 | 10 | This program is distributed in the hope that it will be useful, 11 | but WITHOUT ANY WARRANTY; without even the implied warranty of 12 | MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 13 | GNU General Public License for more details. 14 | 15 | You should have received a copy of the GNU General Public License 16 | along with this program. If not, see . 17 | """ 18 | 19 | class RangeError(ValueError): 20 | 21 | def __init__(self, message: str, valid_range: str): 22 | self.message = message 23 | self.valid_range = valid_range 24 | 25 | def __str__(self): 26 | return f"\nmessage:{self.message}\nvalid_range:{self.valid_range}" 27 | 28 | 29 | class MissTemplateError(ValueError): 30 | 31 | def __init__(self, message: str, missing_template: str): 32 | self.message = message 33 | self.missing_template = missing_template 34 | 35 | def __str__(self): 36 | return f"\nmessage:{self.message}\n\ 37 | missing_template:{self.missing_template}" 38 | 39 | 40 | class DirNotFoundError(OSError): 41 | 42 | def __init__(self, message: str): 43 | self.message = message 44 | 45 | def __str__(self): 46 | return f"message:{self.message}" 47 | 48 | 49 | -------------------------------------------------------------------------------- /media_master/log/__init__.py: -------------------------------------------------------------------------------- 1 | from .log import get_logger 2 | -------------------------------------------------------------------------------- /media_master/log/log.py: -------------------------------------------------------------------------------- 1 | """ 2 | log.py logging module of media_master 3 | Copyright (C) 2019 Ace C Lee 4 | 5 | This program is free software: you can redistribute it and/or modify 6 | it under the terms of the GNU General Public License as published by 7 | the Free Software Foundation, either version 3 of the License, or 8 | (at your option) any later version. 9 | 10 | This program is distributed in the hope that it will be useful, 11 | but WITHOUT ANY WARRANTY; without even the implied warranty of 12 | MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 13 | GNU General Public License for more details. 14 | 15 | You should have received a copy of the GNU General Public License 16 | along with this program. If not, see . 17 | """ 18 | 19 | import logging 20 | import logging.config 21 | 22 | 23 | def get_logger( 24 | log_config_filepath: str, disable_existing_loggers=False 25 | ) -> logging.Logger: 26 | if not log_config_filepath: 27 | raise ValueError("log_config_filepath is empty") 28 | logging.config.fileConfig( 29 | log_config_filepath, disable_existing_loggers=disable_existing_loggers 30 | ) 31 | logger = logging.getLogger("media_master") 32 | 33 | return logger 34 | -------------------------------------------------------------------------------- /media_master/track/__init__.py: -------------------------------------------------------------------------------- 1 | from .track import VideoTrackFile, AudioTrackFile, TextTrackFile, MenuTrackFile 2 | -------------------------------------------------------------------------------- /media_master/track/track.py: -------------------------------------------------------------------------------- 1 | """ 2 | track.py media track module of media_master 3 | Copyright (C) 2019 Ace C Lee 4 | 5 | This program is free software: you can redistribute it and/or modify 6 | it under the terms of the GNU General Public License as published by 7 | the Free Software Foundation, either version 3 of the License, or 8 | (at your option) any later version. 9 | 10 | This program is distributed in the hope that it will be useful, 11 | but WITHOUT ANY WARRANTY; without even the implied warranty of 12 | MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 13 | GNU General Public License for more details. 14 | 15 | You should have received a copy of the GNU General Public License 16 | along with this program. If not, see . 17 | """ 18 | 19 | import os 20 | from typing import Union 21 | 22 | from ..error import RangeError 23 | 24 | 25 | class Track(object): 26 | 27 | video_track_type: str = "video" 28 | audio_track_type: str = "audio" 29 | text_track_type: str = "text" 30 | menu_track_type: str = "menu" 31 | 32 | def __init__(self, track_type: str): 33 | if not isinstance(track_type, str): 34 | raise TypeError( 35 | f"type of track_type must be str instead of " 36 | f"{type(track_type)}" 37 | ) 38 | 39 | self.track_type = track_type 40 | 41 | 42 | class RepeatableTrack(Track): 43 | 44 | def __init__( 45 | self, 46 | track_index: int, 47 | track_type: str, 48 | track_format: str, 49 | duration_ms: int, 50 | bit_rate_bps: int, 51 | delay_ms: int, 52 | stream_size_byte: int, 53 | title: str, 54 | language: str, 55 | default_bool: bool, 56 | forced_bool: bool, 57 | ): 58 | if not isinstance(track_index, int): 59 | raise TypeError( 60 | f"type of track_index must be int instead of " 61 | f"{type(track_index)}" 62 | ) 63 | 64 | if track_index < 0: 65 | raise RangeError( 66 | message=f"value of track_index must in [0,inf)", 67 | valid_range=f"[0,inf)", 68 | ) 69 | self.track_index = track_index 70 | 71 | if not isinstance(track_format, str): 72 | raise TypeError( 73 | f"type of track_format must be str instead of " 74 | f"{type(track_format)}" 75 | ) 76 | self.track_format = track_format 77 | 78 | super(RepeatableTrack, self).__init__(track_type) 79 | 80 | if not isinstance(duration_ms, int): 81 | raise TypeError( 82 | f"type of duration_ms must be int instead of " 83 | f"{type(duration_ms)}" 84 | ) 85 | if duration_ms <= 0 and duration_ms != -1: 86 | raise RangeError( 87 | message=( 88 | f"value of duration_ms must in (0,inf) and -1 " 89 | f"instead of {duration_ms}" 90 | ), 91 | valid_range=f"(0,inf) and -1", 92 | ) 93 | self.duration_ms = duration_ms 94 | 95 | if not isinstance(bit_rate_bps, int): 96 | raise TypeError( 97 | f"type of bit_rate_bps must be int instead of " 98 | f"{type(bit_rate_bps)}" 99 | ) 100 | if bit_rate_bps <= 0 and bit_rate_bps != -1: 101 | raise RangeError( 102 | message=f"value of bit_rate_bps must in (0,inf) and -1", 103 | valid_range=f"(0,inf) and -1", 104 | ) 105 | self.bit_rate_bps = bit_rate_bps 106 | 107 | if not isinstance(delay_ms, int): 108 | raise TypeError( 109 | f"type of delay_ms must be int instead of {type(delay_ms)}" 110 | ) 111 | self.delay_ms = delay_ms 112 | 113 | if not isinstance(stream_size_byte, int): 114 | raise TypeError( 115 | f"type of stream_size_byte must be int instead of " 116 | f"{type(stream_size_byte)}" 117 | ) 118 | if stream_size_byte <= 0 and stream_size_byte != -1: 119 | raise RangeError( 120 | message=f"value of stream_size_byte must in (0,inf) and -1", 121 | valid_range=f"(0,inf) and -1", 122 | ) 123 | self.stream_size_byte = stream_size_byte 124 | 125 | if not isinstance(title, str): 126 | raise TypeError( 127 | f"type of title must be str instead of {type(title)}" 128 | ) 129 | self.title = title 130 | 131 | if not isinstance(language, str): 132 | raise TypeError( 133 | f"type of language must be str instead of {type(language)}" 134 | ) 135 | self.language = language 136 | 137 | if not isinstance(default_bool, bool): 138 | raise TypeError( 139 | f"type of default_bool must be bool instead of " 140 | f"{type(default_bool)}" 141 | ) 142 | self.default_bool = default_bool 143 | 144 | if not isinstance(forced_bool, bool): 145 | raise TypeError( 146 | f"type of forced_bool must be bool instead of " 147 | f"{type(forced_bool)}" 148 | ) 149 | self.forced_bool = forced_bool 150 | 151 | 152 | class MenuTrack(Track): 153 | 154 | needed_menu_info_key_set: set = { 155 | "title", 156 | "start_time", 157 | "end_time", 158 | "language", 159 | } 160 | 161 | non_empty_menu_info_key_set: set = {"title", "start_time"} 162 | 163 | def __init__(self, menu_info_list: list): 164 | if not isinstance(menu_info_list, list): 165 | raise TypeError( 166 | f"type of menu_info_list must be list instead of " 167 | f"{type(menu_info_list)}" 168 | ) 169 | 170 | for menu_info in menu_info_list: 171 | if not isinstance(menu_info, dict): 172 | raise TypeError( 173 | f"type of menu_info must be dict instead of " 174 | f"{type(menu_info)}" 175 | ) 176 | for field in menu_info.keys(): 177 | if field not in self.needed_menu_info_key_set: 178 | raise RangeError( 179 | message=( 180 | f"value of field of menu_info must in " 181 | f"{self.needed_menu_info_key_set} instead of {field}" 182 | ), 183 | valid_range=f"{self.needed_menu_info_key_set}", 184 | ) 185 | if ( 186 | not menu_info[field] 187 | and field in self.non_empty_menu_info_key_set 188 | ): 189 | raise ValueError(f"value of {field} can't be empty") 190 | 191 | self.menu_info_list = menu_info_list 192 | 193 | super(MenuTrack, self).__init__(track_type=self.menu_track_type) 194 | 195 | 196 | class VideoTrack(RepeatableTrack): 197 | 198 | def __init__( 199 | self, 200 | track_index: int, 201 | track_format: str, 202 | duration_ms: int, 203 | bit_rate_bps: int, 204 | width: int, 205 | height: int, 206 | frame_rate_mode: str, 207 | frame_rate: str, 208 | original_frame_rate: str, 209 | frame_count: int, 210 | color_range: str, 211 | color_space: str, 212 | color_matrix: str, 213 | color_primaries: str, 214 | transfer: str, 215 | chroma_subsampling: str, 216 | bit_depth: int, 217 | sample_aspect_ratio: Union[float, str], 218 | delay_ms: int, 219 | stream_size_byte: int, 220 | title: str, 221 | language: str, 222 | default_bool: bool, 223 | forced_bool: bool, 224 | hdr_bool=False, 225 | mastering_display_color_primaries="", 226 | min_mastering_display_luminance=-1, 227 | max_mastering_display_luminance=-1, 228 | max_content_light_level=-1, 229 | max_frameaverage_light_level=-1, 230 | ): 231 | 232 | if not isinstance(width, int): 233 | raise TypeError( 234 | f"type of width must be int instead of {type(width)}" 235 | ) 236 | if width <= 0: 237 | raise RangeError( 238 | message=f"value of width must in (0,inf)", 239 | valid_range=f"(0,inf)", 240 | ) 241 | self.width = width 242 | 243 | if not isinstance(height, int): 244 | raise TypeError( 245 | f"type of height must be int instead of {type(height)}" 246 | ) 247 | if height <= 0: 248 | raise RangeError( 249 | message=f"value of height must in (0,inf)", 250 | valid_range=f"(0,inf)", 251 | ) 252 | self.height = height 253 | 254 | available_frame_rate_mode_set: set = {"cfr", "vfr"} 255 | if not isinstance(frame_rate_mode, str): 256 | raise TypeError( 257 | f"type of frame_rate_mode must be str instead of " 258 | f"{type(frame_rate_mode)}" 259 | ) 260 | if frame_rate_mode not in available_frame_rate_mode_set: 261 | raise RangeError( 262 | message=( 263 | f"value of frame_rate_mode must in " 264 | f"{available_frame_rate_mode_set}" 265 | ), 266 | valid_range=f"{available_frame_rate_mode_set}", 267 | ) 268 | self.frame_rate_mode = frame_rate_mode 269 | 270 | if not isinstance(frame_rate, str): 271 | raise TypeError( 272 | f"type of frame_rate must be str instead of " 273 | f"{type(frame_rate)}" 274 | ) 275 | self.frame_rate = frame_rate 276 | 277 | if not isinstance(original_frame_rate, str): 278 | raise TypeError( 279 | f"type of original_frame_rate must be str instead of " 280 | f"{type(original_frame_rate)}" 281 | ) 282 | self.original_frame_rate = original_frame_rate 283 | 284 | if not isinstance(frame_count, int): 285 | raise TypeError( 286 | f"type of frame_count must be int instead of " 287 | f"{type(frame_count)}" 288 | ) 289 | if frame_count <= 0 and frame_count != -1: 290 | raise RangeError( 291 | message=f"value of frame_count must in (0,inf) and -1", 292 | valid_range=f"(0,inf) and -1", 293 | ) 294 | self.frame_count = frame_count 295 | 296 | available_color_range_set: set = {"full", "limited"} 297 | if not isinstance(color_range, str): 298 | raise TypeError( 299 | f"type of color_range must be str instead of " 300 | f"{type(color_range)}" 301 | ) 302 | if color_range not in available_color_range_set: 303 | raise RangeError( 304 | message=( 305 | f"value of color_range must in " 306 | f"{available_color_range_set}" 307 | ), 308 | valid_range=f"{available_color_range_set}", 309 | ) 310 | self.color_range = color_range 311 | 312 | if not isinstance(color_space, str): 313 | raise TypeError( 314 | f"type of color_space must be str instead of " 315 | f"{type(color_space)}" 316 | ) 317 | self.color_space = color_space 318 | 319 | if not isinstance(color_matrix, str): 320 | raise TypeError( 321 | f"type of color_matrix must be str instead of " 322 | f"{type(color_matrix)}" 323 | ) 324 | self.color_matrix = color_matrix 325 | 326 | if not isinstance(color_primaries, str): 327 | raise TypeError( 328 | f"type of color_primaries must be str instead of " 329 | f"{type(color_primaries)}" 330 | ) 331 | self.color_primaries = color_primaries 332 | 333 | if not isinstance(transfer, str): 334 | raise TypeError( 335 | f"type of transfer must be str instead of {type(transfer)}" 336 | ) 337 | self.transfer = transfer 338 | 339 | if not isinstance(chroma_subsampling, str): 340 | raise TypeError( 341 | f"type of chroma_subsampling must be str instead of " 342 | f"{type(chroma_subsampling)}" 343 | ) 344 | self.chroma_subsampling = chroma_subsampling 345 | 346 | if not isinstance(bit_depth, int): 347 | raise TypeError( 348 | f"type of bit_depth must be int instead of " 349 | f"{type(bit_depth)}" 350 | ) 351 | if bit_depth <= 0 and bit_depth != -1: 352 | raise RangeError( 353 | message=f"value of bit_depth must in (0,inf) or -1", 354 | valid_range=f"(0,inf) or -1", 355 | ) 356 | self.bit_depth = bit_depth 357 | 358 | if not isinstance(sample_aspect_ratio, float) and not isinstance( 359 | sample_aspect_ratio, str 360 | ): 361 | raise TypeError( 362 | f"type of sample_aspect_ratio must be float or str " 363 | f"instead of {type(sample_aspect_ratio)}" 364 | ) 365 | self.sample_aspect_ratio = sample_aspect_ratio 366 | 367 | if not isinstance(hdr_bool, bool): 368 | raise TypeError( 369 | f"type of hdr_bool must be bool instead of " 370 | f"{type(hdr_bool)}" 371 | ) 372 | self.hdr_bool = hdr_bool 373 | 374 | if not isinstance(mastering_display_color_primaries, str): 375 | raise TypeError( 376 | f"type of mastering_display_color_primaries must be str instead of " 377 | f"{type(mastering_display_color_primaries)}" 378 | ) 379 | self.mastering_display_color_primaries = ( 380 | mastering_display_color_primaries 381 | ) 382 | 383 | if not isinstance( 384 | min_mastering_display_luminance, float 385 | ) and not isinstance(min_mastering_display_luminance, int): 386 | raise TypeError( 387 | f"type of min_mastering_display_luminance must be float or int " 388 | f"instead of {type(min_mastering_display_luminance)}" 389 | ) 390 | self.min_mastering_display_luminance = min_mastering_display_luminance 391 | 392 | if not isinstance( 393 | max_mastering_display_luminance, float 394 | ) and not isinstance(max_mastering_display_luminance, int): 395 | raise TypeError( 396 | f"type of max_mastering_display_luminance must be float or int " 397 | f"instead of {type(max_mastering_display_luminance)}" 398 | ) 399 | self.max_mastering_display_luminance = max_mastering_display_luminance 400 | 401 | if not isinstance(max_content_light_level, float) and not isinstance( 402 | max_content_light_level, int 403 | ): 404 | raise TypeError( 405 | f"type of max_content_light_level must be float or int " 406 | f"instead of {type(max_content_light_level)}" 407 | ) 408 | self.max_content_light_level = max_content_light_level 409 | 410 | if not isinstance( 411 | max_frameaverage_light_level, float 412 | ) and not isinstance(max_frameaverage_light_level, int): 413 | raise TypeError( 414 | f"type of max_frameaverage_light_level must be float or int " 415 | f"instead of {type(max_frameaverage_light_level)}" 416 | ) 417 | self.max_frameaverage_light_level = max_frameaverage_light_level 418 | 419 | super(VideoTrack, self).__init__( 420 | track_index=track_index, 421 | track_type=self.video_track_type, 422 | track_format=track_format, 423 | duration_ms=duration_ms, 424 | bit_rate_bps=bit_rate_bps, 425 | delay_ms=delay_ms, 426 | stream_size_byte=stream_size_byte, 427 | title=title, 428 | language=language, 429 | default_bool=default_bool, 430 | forced_bool=forced_bool, 431 | ) 432 | 433 | 434 | class AudioTrack(RepeatableTrack): 435 | 436 | def __init__( 437 | self, 438 | track_index: int, 439 | track_format: str, 440 | duration_ms: int, 441 | bit_rate_bps: int, 442 | bit_depth: int, 443 | delay_ms: int, 444 | stream_size_byte: int, 445 | title: str, 446 | language: str, 447 | default_bool: bool, 448 | forced_bool: bool, 449 | ): 450 | if not isinstance(bit_depth, int): 451 | raise TypeError( 452 | f"type of bit_depth must be int instead of " 453 | f"{type(bit_depth)}" 454 | ) 455 | if bit_depth <= 0 and bit_depth != -1: 456 | raise RangeError( 457 | message=f"value of bit_depth must in (0,inf) or -1", 458 | valid_range=f"(0,inf) or -1", 459 | ) 460 | self.bit_depth = bit_depth 461 | 462 | super(AudioTrack, self).__init__( 463 | track_index=track_index, 464 | track_type=self.audio_track_type, 465 | track_format=track_format, 466 | duration_ms=duration_ms, 467 | bit_rate_bps=bit_rate_bps, 468 | delay_ms=delay_ms, 469 | stream_size_byte=stream_size_byte, 470 | title=title, 471 | language=language, 472 | default_bool=default_bool, 473 | forced_bool=forced_bool, 474 | ) 475 | 476 | 477 | class TextTrack(RepeatableTrack): 478 | 479 | def __init__( 480 | self, 481 | track_index: int, 482 | track_format: str, 483 | duration_ms: int, 484 | bit_rate_bps: int, 485 | delay_ms: int, 486 | stream_size_byte: int, 487 | title: str, 488 | language: str, 489 | default_bool: bool, 490 | forced_bool: bool, 491 | ): 492 | super(TextTrack, self).__init__( 493 | track_index=track_index, 494 | track_type=self.text_track_type, 495 | track_format=track_format, 496 | duration_ms=duration_ms, 497 | bit_rate_bps=bit_rate_bps, 498 | delay_ms=delay_ms, 499 | stream_size_byte=stream_size_byte, 500 | title=title, 501 | language=language, 502 | default_bool=default_bool, 503 | forced_bool=forced_bool, 504 | ) 505 | 506 | 507 | class IntermediateFile(object): 508 | 509 | def __init__(self, filepath: str): 510 | if not isinstance(filepath, str): 511 | raise TypeError( 512 | f"type of filepath must be str " f"instead of {type(filepath)}" 513 | ) 514 | if filepath and not os.path.isfile(filepath): 515 | raise FileNotFoundError( 516 | f"input file cannot be found with {filepath}" 517 | ) 518 | self.filepath: str = filepath 519 | 520 | 521 | class VideoTrackFile(VideoTrack, IntermediateFile): 522 | def __init__( 523 | self, 524 | filepath: str, 525 | track_index: int, 526 | track_format: str, 527 | duration_ms: int, 528 | bit_rate_bps: int, 529 | width: int, 530 | height: int, 531 | frame_rate_mode: str, 532 | frame_rate: str, 533 | original_frame_rate: str, 534 | frame_count: int, 535 | color_range: str, 536 | color_space: str, 537 | color_matrix: str, 538 | color_primaries: str, 539 | transfer: str, 540 | chroma_subsampling: str, 541 | bit_depth: int, 542 | sample_aspect_ratio: Union[float, str], 543 | delay_ms: int, 544 | stream_size_byte: int, 545 | title: str, 546 | language: str, 547 | default_bool: bool, 548 | forced_bool: bool, 549 | hdr_bool=False, 550 | mastering_display_color_primaries="", 551 | min_mastering_display_luminance=-1, 552 | max_mastering_display_luminance=-1, 553 | max_content_light_level=-1, 554 | max_frameaverage_light_level=-1, 555 | ): 556 | VideoTrack.__init__( 557 | self, 558 | track_index=track_index, 559 | track_format=track_format, 560 | duration_ms=duration_ms, 561 | bit_rate_bps=bit_rate_bps, 562 | width=width, 563 | height=height, 564 | frame_rate_mode=frame_rate_mode, 565 | frame_rate=frame_rate, 566 | original_frame_rate=original_frame_rate, 567 | frame_count=frame_count, 568 | color_range=color_range, 569 | color_space=color_space, 570 | color_matrix=color_matrix, 571 | color_primaries=color_primaries, 572 | transfer=transfer, 573 | chroma_subsampling=chroma_subsampling, 574 | bit_depth=bit_depth, 575 | sample_aspect_ratio=sample_aspect_ratio, 576 | delay_ms=delay_ms, 577 | stream_size_byte=stream_size_byte, 578 | title=title, 579 | language=language, 580 | default_bool=default_bool, 581 | forced_bool=forced_bool, 582 | hdr_bool=hdr_bool, 583 | mastering_display_color_primaries=mastering_display_color_primaries, 584 | min_mastering_display_luminance=min_mastering_display_luminance, 585 | max_mastering_display_luminance=max_mastering_display_luminance, 586 | max_content_light_level=max_content_light_level, 587 | max_frameaverage_light_level=max_frameaverage_light_level, 588 | ) 589 | IntermediateFile.__init__(self, filepath=filepath) 590 | 591 | 592 | class AudioTrackFile(AudioTrack, IntermediateFile): 593 | def __init__( 594 | self, 595 | filepath: str, 596 | track_index: int, 597 | track_format: str, 598 | duration_ms: int, 599 | bit_rate_bps: int, 600 | bit_depth: int, 601 | delay_ms: int, 602 | stream_size_byte: int, 603 | title: str, 604 | language: str, 605 | default_bool: bool, 606 | forced_bool: bool, 607 | ): 608 | AudioTrack.__init__( 609 | self, 610 | track_index=track_index, 611 | track_format=track_format, 612 | duration_ms=duration_ms, 613 | bit_rate_bps=bit_rate_bps, 614 | bit_depth=bit_depth, 615 | delay_ms=delay_ms, 616 | stream_size_byte=stream_size_byte, 617 | title=title, 618 | language=language, 619 | default_bool=default_bool, 620 | forced_bool=forced_bool, 621 | ) 622 | IntermediateFile.__init__(self, filepath=filepath) 623 | 624 | 625 | class TextTrackFile(TextTrack, IntermediateFile): 626 | def __init__( 627 | self, 628 | filepath: str, 629 | track_index: int, 630 | track_format: str, 631 | duration_ms: int, 632 | bit_rate_bps: int, 633 | delay_ms: int, 634 | stream_size_byte: int, 635 | title: str, 636 | language: str, 637 | default_bool: bool, 638 | forced_bool: bool, 639 | ): 640 | TextTrack.__init__( 641 | self, 642 | track_index=track_index, 643 | track_format=track_format, 644 | duration_ms=duration_ms, 645 | bit_rate_bps=bit_rate_bps, 646 | delay_ms=delay_ms, 647 | stream_size_byte=stream_size_byte, 648 | title=title, 649 | language=language, 650 | default_bool=default_bool, 651 | forced_bool=forced_bool, 652 | ) 653 | IntermediateFile.__init__(self, filepath=filepath) 654 | 655 | 656 | class MenuTrackFile(IntermediateFile): 657 | def __init__(self, filepath: str): 658 | super(MenuTrackFile, self).__init__(filepath=filepath) 659 | -------------------------------------------------------------------------------- /media_master/util/__init__.py: -------------------------------------------------------------------------------- 1 | from .chapter import convert_chapter_format, get_chapter_format_info_dict 2 | from .check import check_file_environ_path, is_iso_language 3 | from .config import load_config, save_config 4 | from .constant import global_constant 5 | from .extraction import ( 6 | copy_video, 7 | extract_all_attachments, 8 | extract_all_subtitles, 9 | extract_audio_track, 10 | extract_chapter, 11 | extract_mkv_video_timecode, 12 | extract_video_track, 13 | get_fr_and_original_fr, 14 | get_stream_order, 15 | ) 16 | from .fraction import get_reduced_fraction 17 | from .meta_data import ( 18 | get_colorspace_specification, 19 | get_float_frame_rate, 20 | get_proper_color_specification, 21 | get_proper_frame_rate, 22 | get_proper_sar, 23 | reliable_meta_data, 24 | ) 25 | from .multiplex import multiplex_mkv, multiplex_mp4, remultiplex_ffmpeg 26 | from .name_hash import hash_name 27 | from .sort import resort 28 | from .string_util import ( 29 | get_printable, 30 | is_ascii, 31 | is_printable, 32 | get_unique_printable_filename, 33 | is_filename_with_valid_mark, 34 | get_filename_with_valid_mark, 35 | ) 36 | from .template import ( 37 | generate_vpy_file, 38 | is_template, 39 | replace_config_template_dict, 40 | replace_param_template_list, 41 | ) 42 | from .timecode import mkv_timecode_2_standard_timecode 43 | from .charset import convert_codec_2_uft8bom 44 | from .subtitle import ( 45 | get_subtitle_missing_glyph_char_info, 46 | get_vsmod_improper_style, 47 | ) 48 | from .number import is_number 49 | -------------------------------------------------------------------------------- /media_master/util/chapter.py: -------------------------------------------------------------------------------- 1 | """ 2 | chapter.py chapter module of media_master 3 | Copyright (C) 2020 Ace C Lee 4 | 5 | This program is free software: you can redistribute it and/or modify 6 | it under the terms of the GNU General Public License as published by 7 | the Free Software Foundation, either version 3 of the License, or 8 | (at your option) any later version. 9 | 10 | This program is distributed in the hope that it will be useful, 11 | but WITHOUT ANY WARRANTY; without even the implied warranty of 12 | MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 13 | GNU General Public License for more details. 14 | 15 | You should have received a copy of the GNU General Public License 16 | along with this program. If not, see . 17 | """ 18 | 19 | import logging 20 | import os 21 | import subprocess 22 | import sys 23 | 24 | g_logger = logging.getLogger(__name__) 25 | g_logger.propagate = True 26 | g_logger.setLevel(logging.DEBUG) 27 | 28 | 29 | def get_chapter_format_info_dict(): 30 | return dict( 31 | ogm=dict(ext=".txt", cmd_format="ogm"), 32 | pot=dict(ext=".pbf", cmd_format="pot"), 33 | simple=dict(ext=".txt", cmd_format="simple"), 34 | tab=dict(ext=".txt", cmd_format="tab"), 35 | matroska=dict(ext=".xml", cmd_format="xml"), 36 | ) 37 | 38 | 39 | def convert_chapter_format( 40 | src_chapter_filepath: str, 41 | output_dir: str, 42 | output_filename: str, 43 | dst_chapter_format: str, 44 | ) -> str: 45 | if not os.path.isdir(output_dir): 46 | os.makedirs(output_dir) 47 | all_format_info_dict: dict = dict( 48 | ogm=dict(ext=".txt", cmd_format="ogm"), 49 | pot=dict(ext=".pbf", cmd_format="pot"), 50 | simple=dict(ext=".txt", cmd_format="simple"), 51 | tab=dict(ext=".txt", cmd_format="tab"), 52 | matroska=dict(ext=".xml", cmd_format="xml"), 53 | ) 54 | 55 | chapter_converter_py_filepath: str = "media_master/util/chapter_converter.py" 56 | 57 | format_info: dict = all_format_info_dict[dst_chapter_format] 58 | 59 | src_full_filename: str = os.path.basename(src_chapter_filepath) 60 | 61 | src_filename, src_extension = os.path.splitext(src_full_filename) 62 | 63 | dst_full_filename: str = output_filename + format_info["ext"] 64 | 65 | dst_filepath: str = os.path.join(output_dir, dst_full_filename) 66 | 67 | if os.path.isfile(dst_filepath): 68 | os.remove(dst_filepath) 69 | 70 | python_exe = "python.exe" 71 | 72 | cmd_param_list: list = [ 73 | python_exe, 74 | chapter_converter_py_filepath, 75 | "--format", 76 | format_info["cmd_format"], 77 | "--output", 78 | dst_filepath, 79 | src_chapter_filepath, 80 | ] 81 | 82 | param_debug_str: str = ( 83 | f"convert_chapter_format: param: {subprocess.list2cmdline(cmd_param_list)}" 84 | ) 85 | g_logger.log(logging.DEBUG, param_debug_str) 86 | print(param_debug_str, file=sys.stderr) 87 | 88 | start_info_str: str = ( 89 | f"convert_chapter_format: " 90 | f"start convert {src_chapter_filepath} " 91 | f"to {dst_filepath}" 92 | ) 93 | 94 | g_logger.log(logging.INFO, start_info_str) 95 | print(start_info_str, file=sys.stderr) 96 | 97 | process: subprocess.Popen = subprocess.Popen(cmd_param_list) 98 | 99 | process.communicate() 100 | 101 | return_code = process.returncode 102 | 103 | if return_code == 0: 104 | end_info_str: str = ( 105 | f"convert_chapter_format: " 106 | f"start convert {src_chapter_filepath} " 107 | f"to {dst_filepath} successfully." 108 | ) 109 | print(end_info_str, file=sys.stderr) 110 | g_logger.log(logging.INFO, end_info_str) 111 | else: 112 | raise ChildProcessError( 113 | f"convert_chapter_format: " 114 | f"start convert {src_chapter_filepath} " 115 | f"to {dst_filepath} unsuccessfully." 116 | ) 117 | 118 | return dst_filepath 119 | 120 | 121 | -------------------------------------------------------------------------------- /media_master/util/chapter_converter.py: -------------------------------------------------------------------------------- 1 | """ 2 | 3 | https://github.com/fireattack/chapter_converter 4 | 5 | """ 6 | 7 | from subprocess import run 8 | import argparse 9 | import datetime 10 | import re 11 | from os.path import exists, splitext 12 | from os import remove 13 | import win32clipboard 14 | 15 | import chardet 16 | 17 | 18 | def get_clipboard_data(): 19 | win32clipboard.OpenClipboard() 20 | data = win32clipboard.GetClipboardData(win32clipboard.CF_UNICODETEXT) 21 | win32clipboard.CloseClipboard() 22 | return data 23 | 24 | 25 | def set_clipboard_data(data): 26 | win32clipboard.OpenClipboard() 27 | win32clipboard.EmptyClipboard() 28 | win32clipboard.SetClipboardText(data, win32clipboard.CF_UNICODETEXT) 29 | win32clipboard.CloseClipboard() 30 | 31 | 32 | def ms_to_timestamp(ms): 33 | ms = int(ms) 34 | return str(datetime.timedelta(seconds=ms // 1000)) + "." + str(ms % 1000) 35 | 36 | 37 | def timestamp_to_ms(timestamp): 38 | h, m, s, ms = re.split("[:.]", timestamp) 39 | return str(1000 * (int(h) * 3600 + int(m) * 60 + int(s)) + int(ms)) 40 | 41 | 42 | def load_file_content(filename): 43 | with open(filename, "rb") as file: 44 | raw = file.read() 45 | encoding = chardet.detect(raw)["encoding"] 46 | with open(filename, encoding=encoding) as f: 47 | return f.readlines() 48 | 49 | 50 | def main(): 51 | 52 | parser = argparse.ArgumentParser() 53 | parser.add_argument("filename", nargs="?", help="input filename") 54 | parser.add_argument( 55 | "-f", 56 | "--format", 57 | choices=["simple", "pot", "ogm", "tab", "xml"], 58 | help="output format (default: pot)", 59 | ) 60 | parser.add_argument( 61 | "--mp4-charset", 62 | help="input chapter charset for mp4 file, since it can't be auto detected (default: utf-8)", 63 | default="utf-8", 64 | ) 65 | parser.add_argument( 66 | "--charset", 67 | help="output file charset (default: utf-8-sig)", 68 | default="utf-8-sig", 69 | ) 70 | parser.add_argument( 71 | "-o", 72 | "--output", 73 | help="output filename (default: original_filename.format[.txt])", 74 | ) 75 | parser.add_argument( 76 | "-c", 77 | "--clipboard", 78 | action="store_true", 79 | help="automatically process text in clipboard and save it back.", 80 | ) 81 | args = parser.parse_args() 82 | if args.filename and exists(args.filename): 83 | if args.filename.lower().endswith(".xml"): 84 | run(["mkvmerge", "-o", "temp.mks", "--chapters", args.filename]) 85 | run(["mkvextract", "temp.mks", "chapters", "-s", "temp.ogm.txt"]) 86 | lines = load_file_content("temp.ogm.txt") 87 | remove("temp.mks") 88 | remove("temp.ogm.txt") 89 | elif args.filename.lower().split(".")[-1] in ["mp4", "mkv"]: 90 | run( 91 | [ 92 | "mkvmerge", 93 | "-o", 94 | "temp.mks", 95 | "-A", 96 | "-D", 97 | "--chapter-charset", 98 | args.mp4_charset, 99 | args.filename, 100 | ] 101 | ) 102 | run(["mkvextract", "temp.mks", "chapters", "-s", "temp.ogm.txt"]) 103 | lines = load_file_content("temp.ogm.txt") 104 | remove("temp.mks") 105 | remove("temp.ogm.txt") 106 | else: 107 | lines = load_file_content(args.filename) 108 | elif args.clipboard: 109 | f = get_clipboard_data() 110 | if f: 111 | print("Get data from clipboard:") 112 | print(f) 113 | lines = f.splitlines() 114 | else: 115 | print("No valid input data in clipboard!") 116 | return 0 117 | else: 118 | print("Input file missing or invalid!") 119 | return 0 120 | lines = list(filter(lambda x: not re.match(r"^\s*$", x), lines)) 121 | input_format = "" 122 | SIMPLE_RE = r"([0-9:.]+?), *(.+)" 123 | TAB_RE = r"([0-9:.].+?)\t(.+)" 124 | if re.match(SIMPLE_RE, lines[0]): 125 | input_format = "simple" 126 | elif re.match(TAB_RE, lines[0]): 127 | input_format = "tab" 128 | elif re.match(r"CHAPTER\d", lines[0]): 129 | input_format = "ogm" 130 | elif lines[0].startswith("[Bookmark]"): 131 | input_format = "pot" 132 | if not input_format: 133 | print("Can't guess file format!") 134 | return 0 135 | chapters = [] 136 | if input_format == "simple": 137 | for line in lines: 138 | m = re.match(SIMPLE_RE, line) 139 | if m: 140 | chapters.append((m.group(1), m.group(2))) 141 | elif input_format == "tab": 142 | for line in lines: 143 | m = re.match(TAB_RE, line) 144 | if m: 145 | chapters.append((m.group(1), m.group(2))) 146 | elif input_format == "ogm": 147 | for i in range(0, len(lines), 2): 148 | line1 = lines[i].strip() 149 | line2 = lines[i + 1].strip() 150 | chapters.append( 151 | (line1[line1.index("=") + 1 :], line2[line2.index("=") + 1 :]) 152 | ) 153 | elif input_format == "pot": 154 | for line in lines[1:]: 155 | m = re.match(r"\d+=(\d+)\*([^*]+)", line.strip()) 156 | if m: 157 | timestamp = ms_to_timestamp(m.group(1)) 158 | chapters.append((timestamp, m.group(2))) 159 | if not args.format: 160 | args.format = "pot" 161 | if args.clipboard and input_format != "tab": 162 | args.format = ( 163 | "tab" 164 | ) 165 | if ( 166 | args.output 167 | ): 168 | ext = splitext(args.output)[-1] 169 | if ext.lower() == ".pbf": 170 | args.format = "pot" 171 | elif ext.lower() == ".xml": 172 | args.format = "xml" 173 | elif ext.lower() == ".txt": 174 | args.format = "ogm" 175 | if args.clipboard and not args.output: 176 | pass 177 | else: 178 | if args.output: 179 | new_filename = args.output 180 | args.clipboard = False 181 | else: 182 | if args.format == "pot": 183 | new_filename = f"{splitext(args.filename)[0]}.pbf" 184 | elif args.format == "xml": 185 | new_filename = f"{splitext(args.filename)[0]}.xml" 186 | else: 187 | new_filename = ( 188 | f"{splitext(args.filename)[0]}.{args.format}.txt" 189 | ) 190 | i = 2 191 | stem = splitext(new_filename)[0] 192 | ext = splitext(new_filename)[1] 193 | while exists(new_filename): 194 | new_filename = f"{stem} ({i}){ext}" 195 | i += 1 196 | output = "" 197 | if args.format == "tab": 198 | for time, title in chapters: 199 | output = output + f"{time}\t{title}\n" 200 | elif args.format == "simple": 201 | for time, title in chapters: 202 | output = output + f"{time},{title}\n" 203 | elif args.format in ["ogm", "xml"]: 204 | i = 1 205 | for time, title in chapters: 206 | output = output + f"CHAPTER{i:02}={time}\n" 207 | output = output + f"CHAPTER{i:02}NAME={title}\n" 208 | i += 1 209 | elif args.format == "pot": 210 | i = 0 211 | output = output + "[Bookmark]\n" 212 | for time, title in chapters: 213 | output = output + f"{i}={timestamp_to_ms(time)}*{title}*\n" 214 | i += 1 215 | if args.clipboard: 216 | print("Set data to clipboard:") 217 | print(output) 218 | set_clipboard_data(output.replace("\n", "\r\n")) 219 | elif args.format == "xml": 220 | with open("temp.ogm.txt", "w", encoding=args.charset) as f: 221 | f.write(output) 222 | run(["mkvmerge", "-o", "temp.mks", "--chapters", "temp.ogm.txt"]) 223 | run(["mkvextract", "temp.mks", "chapters", new_filename]) 224 | remove("temp.mks") 225 | remove("temp.ogm.txt") 226 | else: 227 | with open(new_filename, "w", encoding=args.charset) as f: 228 | f.write(output) 229 | 230 | 231 | if __name__ == "__main__": 232 | main() 233 | -------------------------------------------------------------------------------- /media_master/util/charset.py: -------------------------------------------------------------------------------- 1 | """ 2 | charset.py charset module of media_master 3 | Copyright (C) 2019 Ace C Lee 4 | 5 | This program is free software: you can redistribute it and/or modify 6 | it under the terms of the GNU General Public License as published by 7 | the Free Software Foundation, either version 3 of the License, or 8 | (at your option) any later version. 9 | 10 | This program is distributed in the hope that it will be useful, 11 | but WITHOUT ANY WARRANTY; without even the implied warranty of 12 | MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 13 | GNU General Public License for more details. 14 | 15 | You should have received a copy of the GNU General Public License 16 | along with this program. If not, see . 17 | """ 18 | 19 | import chardet 20 | from .constant import global_constant 21 | import logging 22 | 23 | g_logger = logging.getLogger(__name__) 24 | g_logger.propagate = True 25 | g_logger.setLevel(logging.DEBUG) 26 | 27 | 28 | def is_utf8bom(filepath: str) -> bool: 29 | charset: str = "" 30 | constant = global_constant() 31 | python_text_codec_dict: dict = constant.python_text_codec_dict 32 | with open(filepath, "rb") as file: 33 | result_dict: dict = chardet.detect(file.read()) 34 | charset = result_dict["encoding"].lower() 35 | 36 | return_bool: bool = False 37 | if charset == python_text_codec_dict["utf_8_bom"]: 38 | return_bool = True 39 | 40 | return return_bool 41 | 42 | 43 | def convert_codec_2_uft8bom(filepath: str): 44 | charset: str = "" 45 | constant = global_constant() 46 | python_text_codec_dict: dict = constant.python_text_codec_dict 47 | with open(filepath, "rb") as file: 48 | result_dict: dict = chardet.detect(file.read()) 49 | charset = result_dict["encoding"].lower() 50 | 51 | if charset != python_text_codec_dict["utf_8_bom"]: 52 | start_info_str: str = ( 53 | f"start to convert codec of {filepath} to utf-8-bom, " 54 | f"original codec is {charset}" 55 | ) 56 | g_logger.log(logging.INFO, start_info_str) 57 | 58 | text_str: str = "" 59 | with open(filepath, "r", encoding=charset) as file: 60 | text_str = file.read() 61 | 62 | with open( 63 | filepath, "w", encoding=python_text_codec_dict["utf_8_bom"] 64 | ) as file: 65 | file.write(text_str) 66 | 67 | end_info_str: str = ( 68 | f"convert codec of {filepath} to utf-8-bom successfully" 69 | ) 70 | g_logger.log(logging.INFO, end_info_str) 71 | 72 | 73 | -------------------------------------------------------------------------------- /media_master/util/check.py: -------------------------------------------------------------------------------- 1 | """ 2 | check.py checking module of media_master 3 | Copyright (C) 2019 Ace C Lee 4 | 5 | This program is free software: you can redistribute it and/or modify 6 | it under the terms of the GNU General Public License as published by 7 | the Free Software Foundation, either version 3 of the License, or 8 | (at your option) any later version. 9 | 10 | This program is distributed in the hope that it will be useful, 11 | but WITHOUT ANY WARRANTY; without even the implied warranty of 12 | MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 13 | GNU General Public License for more details. 14 | 15 | You should have received a copy of the GNU General Public License 16 | along with this program. If not, see . 17 | """ 18 | 19 | import os 20 | from .constant import global_constant 21 | 22 | 23 | def check_file_environ_path(filename_set: set) -> bool: 24 | if not isinstance(filename_set, set): 25 | raise TypeError( 26 | f"type of filename_set must be set instead of {type(filename_set)}" 27 | ) 28 | if not all(isinstance(filename, str) for filename in filename_set): 29 | raise TypeError( 30 | f"type of all the elements in filename_set must be str" 31 | ) 32 | 33 | path_str: str = os.environ.get("PATH") 34 | path_dir_set: set = set(path_str.split(";")) 35 | all_filename_set: set = set() 36 | for path_dir in path_dir_set: 37 | all_filename_set |= ( 38 | set(os.listdir(path_dir)) if os.path.isdir(path_dir) else set() 39 | ) 40 | all_filename_set = set(filename.lower() for filename in all_filename_set) 41 | return all( 42 | filename.lower() in all_filename_set for filename in filename_set 43 | ) 44 | 45 | 46 | def is_iso_language(iso_language: str): 47 | if not iso_language: 48 | return False 49 | 50 | constant = global_constant() 51 | all_iso639_code_set: set = constant.all_iso639_code_set 52 | if iso_language in all_iso639_code_set: 53 | return True 54 | else: 55 | return False 56 | 57 | 58 | -------------------------------------------------------------------------------- /media_master/util/config.py: -------------------------------------------------------------------------------- 1 | """ 2 | config.py config module of media_master 3 | Copyright (C) 2019 Ace C Lee 4 | 5 | This program is free software: you can redistribute it and/or modify 6 | it under the terms of the GNU General Public License as published by 7 | the Free Software Foundation, either version 3 of the License, or 8 | (at your option) any later version. 9 | 10 | This program is distributed in the hope that it will be useful, 11 | but WITHOUT ANY WARRANTY; without even the implied warranty of 12 | MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 13 | GNU General Public License for more details. 14 | 15 | You should have received a copy of the GNU General Public License 16 | along with this program. If not, see . 17 | """ 18 | 19 | import json 20 | import os 21 | from .constant import global_constant 22 | from ..error import RangeError 23 | import yaml 24 | import pyhocon 25 | 26 | 27 | def load_config(config_filepath): 28 | if not isinstance(config_filepath, str): 29 | raise TypeError( 30 | f"type of config_filepath must be str " 31 | f"instead of {type(config_filepath)}" 32 | ) 33 | if not os.path.isfile(config_filepath): 34 | raise FileNotFoundError( 35 | f"input config file cannot be found with {config_filepath}" 36 | ) 37 | 38 | constant = global_constant() 39 | 40 | available_config_format_set: set = constant.available_config_format_set 41 | available_config_format_extension_dict: dict = constant.available_config_format_extension_dict 42 | available_config_format_extension_set: set = set() 43 | for extension_set in available_config_format_extension_dict.values(): 44 | available_config_format_extension_set |= extension_set 45 | 46 | config_full_filename: str = os.path.basename(config_filepath) 47 | config_filename, config_extension = os.path.splitext(config_full_filename) 48 | 49 | if config_extension not in available_config_format_extension_set: 50 | raise RangeError( 51 | message=(f"Unknown config_extension: {config_extension}"), 52 | valid_range=str(available_config_format_extension_set), 53 | ) 54 | 55 | config_format: str = "" 56 | for current_config_format in available_config_format_set: 57 | if ( 58 | config_extension 59 | in available_config_format_extension_dict[current_config_format] 60 | ): 61 | config_format = current_config_format 62 | break 63 | 64 | if not config_format: 65 | raise ValueError(f"it is not possible to run this code.") 66 | 67 | config_data_dict: dict = {} 68 | with open(config_filepath, "r", encoding="utf-8") as file: 69 | if config_format == "json": 70 | config_data_dict = json.loads(file.read()) 71 | elif config_format == "yaml": 72 | config_data_dict = yaml.load(file, Loader=yaml.SafeLoader) 73 | elif config_format == "hocon": 74 | config_data_dict = pyhocon.ConfigFactory.parse_string( 75 | file.read() 76 | ).as_plain_ordered_dict() 77 | else: 78 | raise ValueError(f"it is not possible to run this code.") 79 | 80 | return config_data_dict 81 | 82 | 83 | def save_config(config_json_filepath: str, config_dict: dict): 84 | if not isinstance(config_json_filepath, str): 85 | raise TypeError( 86 | f"type of config_json_filepath must be str " 87 | f"instead of {type(config_json_filepath)}" 88 | ) 89 | if not isinstance(config_dict, dict): 90 | raise TypeError( 91 | f"type of config_dict must be dict " 92 | f"instead of {type(config_dict)}" 93 | ) 94 | config_json_dir = os.path.abspath(os.path.dirname(config_json_filepath)) 95 | if not os.path.isdir(config_json_dir): 96 | os.makedirs(config_json_dir) 97 | with open(config_json_filepath, "w", encoding="utf-8") as file: 98 | file.write(json.dumps(config_dict, indent=4, ensure_ascii=False)) 99 | -------------------------------------------------------------------------------- /media_master/util/constant.py: -------------------------------------------------------------------------------- 1 | """ 2 | constant.py constant of media info 3 | Copyright (C) 2020 Ace C Lee 4 | 5 | This program is free software: you can redistribute it and/or modify 6 | it under the terms of the GNU General Public License as published by 7 | the Free Software Foundation, either version 3 of the License, or 8 | (at your option) any later version. 9 | 10 | This program is distributed in the hope that it will be useful, 11 | but WITHOUT ANY WARRANTY; without even the implied warranty of 12 | MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 13 | GNU General Public License for more details. 14 | 15 | You should have received a copy of the GNU General Public License 16 | along with this program. If not, see . 17 | """ 18 | 19 | from collections import namedtuple 20 | from .language import all_iso639_code_set 21 | 22 | 23 | def global_constant(): 24 | constant_dict: dict = dict( 25 | valid_file_suffix="_valid", 26 | delete_cache_file_bool_config_key="delete_cache_file_bool", 27 | python_text_codec_dict={"utf_8_bom": "utf-8-sig"}, 28 | vapoursynth_lwlibavsource_cache_file_extension=".lwi", 29 | all_iso639_code_set=all_iso639_code_set(), 30 | available_config_format_set={"json", "yaml", "hocon"}, 31 | available_config_format_extension_dict=dict( 32 | json={".json"}, yaml={".yaml", ".yml"}, hocon={".conf", ".hocon"} 33 | ), 34 | available_package_format_set={"mkv", "mp4"}, 35 | available_video_process_option_set={"copy", "transcode"}, 36 | available_frame_server_set={"vspipe", ""}, 37 | available_video_transcoding_method_set={"x264", "x265", "nvenc"}, 38 | available_output_frame_rate_mode_set={ 39 | "", 40 | "auto", 41 | "unchange", 42 | "vfr", 43 | "cfr", 44 | }, 45 | available_output_dynamic_range_mode_set={"", "unchange", "hdr", "sdr"}, 46 | available_audio_prior_option_set={"internal", "external"}, 47 | available_external_audio_process_option_set={"copy", "transcode"}, 48 | available_internal_audio_track_to_process_set={"all", "default"}, 49 | available_internal_audio_process_option_set={ 50 | "copy", 51 | "transcode", 52 | "skip", 53 | }, 54 | available_subtitle_prior_option_set={"internal", "external"}, 55 | video_type="video", 56 | audio_type="audio", 57 | subtitle_type="subtitle", 58 | track_id_key="track_id", 59 | mediainfo_width_key="width", 60 | mediainfo_height_key="height", 61 | mediainfo_bit_depth_key="bit_depth", 62 | matroska_extensions=(".mkv", ".mka", "mks"), 63 | matroska_video_extension=".mkv", 64 | matroska_audio_extension=".mka", 65 | matroska_subtitle_extension=".mks", 66 | mediainfo_video_type="Video", 67 | mediainfo_audio_type="Audio", 68 | mediainfo_subtitle_type="Text", 69 | mediainfo_track_id_key="streamorder", 70 | hevc_track_extension=".265", 71 | avc_track_extension=".264", 72 | mediainfo_colormatrix_key="matrix_coefficients", 73 | mediainfo_colorprim_key="color_primaries", 74 | mediainfo_transfer_key="transfer_characteristics", 75 | mediainfo_colormatrix_bt709="BT.709", 76 | mediainfo_colorprim_bt709="BT.709", 77 | mediainfo_transfer_bt709="BT.709", 78 | encoder_colormatrix_bt709="bt709", 79 | encoder_colorprim_bt709="bt709", 80 | encoder_transfer_bt709="bt709", 81 | vapoursynth_colormatrix_bt709="709", 82 | vapoursynth_colorprim_bt709="709", 83 | vapoursynth_transfer_bt709="709", 84 | fmtconv_colormatrix_bt709="709", 85 | fmtconv_colorprim_bt709="709", 86 | fmtconv_transfer_bt709="709", 87 | mediainfo_colormatrix_smpte170="BT.601", 88 | mediainfo_colorprim_smpte170="BT.601 NTSC", 89 | mediainfo_transfer_smpte170="BT.601", 90 | encoder_colormatrix_smpte170="smpte170m", 91 | encoder_colorprim_smpte170="smpte170m", 92 | encoder_transfer_smpte170="smpte170m", 93 | vapoursynth_colormatrix_smpte170="170m", 94 | vapoursynth_colorprim_smpte170="170m", 95 | vapoursynth_transfer_smpte170="601", 96 | fmtconv_colormatrix_smpte170="601", 97 | fmtconv_colorprim_smpte170="170m", 98 | fmtconv_transfer_smpte170="601", 99 | mediainfo_colormatrix_bt2020nc="BT.2020 non-constant", 100 | mediainfo_colormatrix_bt2020c="BT.2020 constant", 101 | mediainfo_colorprim_bt2020="BT.2020", 102 | mediainfo_colorprim_p3="Display P3", 103 | mediainfo_transfer_bt2020_10="BT.2020 (10-bit)", 104 | mediainfo_transfer_bt2020_12="BT.2020 (12-bit)", 105 | mediainfo_transfer_smpte2084="PQ", 106 | encoder_colormatrix_bt2020nc="bt2020nc", 107 | encoder_colormatrix_bt2020c="bt2020c", 108 | encoder_colorprim_bt2020="bt2020", 109 | encoder_colorprim_p3="p3", 110 | encoder_transfer_bt2020_10="bt2020-10", 111 | encoder_transfer_bt2020_12="bt2020-12", 112 | encoder_transfer_smpte2084="smpte2084", 113 | vapoursynth_colormatrix_bt2020nc="2020ncl", 114 | vapoursynth_colormatrix_bt2020c="2020cl", 115 | vapoursynth_colorprim_bt2020="2020", 116 | vapoursynth_transfer_bt2020_10="2020_10", 117 | vapoursynth_transfer_bt2020_12="2020_12", 118 | vapoursynth_transfer_smpte2084="st2084", 119 | fmtconv_colormatrix_bt2020nc="2020", 120 | fmtconv_colorprim_bt2020="2020", 121 | fmtconv_transfer_bt2020_10="2020_10", 122 | fmtconv_transfer_bt2020_12="2020_12", 123 | fmtconv_transfer_smpte2084="2084", 124 | fmtconv_colormatrix_rgb="rgb", 125 | fmtconv_colorprim_srgb="srgb", 126 | fmtconv_transfer_linear="linear", 127 | bt2020_available_bit_depth_tuple=(10, 12), 128 | encoder_max_cll_format_str="{max_content_light_level:.0f},{max_frameaverage_light_level:.0f}", 129 | encoder_master_display_prim_bt2020_format_str="G(8500,39850)B(6550,2300)R(35400,14600)WP(15635,16450)L({max_master_display_luminance:.0f},{min_master_display_luminance:.0f})", 130 | encoder_master_display_prim_p3_format_str="G(13250,34500)B(7500,3000)R(34000,16000)WP(15635,16450)L({max_master_display_luminance:.0f},{min_master_display_luminance:.0f})", 131 | mediainfo_mastering_display_luminance_re_exp="min: (?P[\\d.]+?) cd/m2, max: (?P[\\d.]+?) cd/m2", 132 | mediainfo_light_level_re_exp="(?P[\\d.]+?) cd/m2", 133 | ) 134 | 135 | constant_dict["mediainfo_encoder_colormatrix_dict"]: dict = { 136 | constant_dict["mediainfo_colormatrix_bt709"]: constant_dict[ 137 | "encoder_colormatrix_bt709" 138 | ], 139 | constant_dict["mediainfo_colormatrix_smpte170"]: constant_dict[ 140 | "encoder_colormatrix_smpte170" 141 | ], 142 | constant_dict["mediainfo_colormatrix_bt2020nc"]: constant_dict[ 143 | "encoder_colormatrix_bt2020nc" 144 | ], 145 | constant_dict["mediainfo_colormatrix_bt2020c"]: constant_dict[ 146 | "encoder_colormatrix_bt2020c" 147 | ], 148 | } 149 | constant_dict["mediainfo_encoder_colorprim_dict"]: dict = { 150 | constant_dict["mediainfo_colorprim_bt709"]: constant_dict[ 151 | "encoder_colorprim_bt709" 152 | ], 153 | constant_dict["mediainfo_colorprim_smpte170"]: constant_dict[ 154 | "encoder_colorprim_smpte170" 155 | ], 156 | constant_dict["mediainfo_colorprim_bt2020"]: constant_dict[ 157 | "encoder_colorprim_bt2020" 158 | ], 159 | constant_dict["mediainfo_colorprim_p3"]: constant_dict[ 160 | "encoder_colorprim_p3" 161 | ], 162 | } 163 | constant_dict["mediainfo_encoder_transfer_dict"]: dict = { 164 | constant_dict["mediainfo_transfer_bt709"]: constant_dict[ 165 | "encoder_transfer_bt709" 166 | ], 167 | constant_dict["mediainfo_transfer_smpte170"]: constant_dict[ 168 | "encoder_transfer_smpte170" 169 | ], 170 | constant_dict["mediainfo_transfer_bt2020_10"]: constant_dict[ 171 | "encoder_transfer_bt2020_10" 172 | ], 173 | constant_dict["mediainfo_transfer_bt2020_12"]: constant_dict[ 174 | "encoder_transfer_bt2020_12" 175 | ], 176 | constant_dict["mediainfo_transfer_smpte2084"]: constant_dict[ 177 | "encoder_transfer_smpte2084" 178 | ], 179 | } 180 | 181 | constant_dict["encoder_fmtconv_colormatrix_dict"]: dict = { 182 | constant_dict["encoder_colormatrix_bt709"]: constant_dict[ 183 | "fmtconv_colormatrix_bt709" 184 | ], 185 | constant_dict["encoder_colormatrix_smpte170"]: constant_dict[ 186 | "fmtconv_colormatrix_smpte170" 187 | ], 188 | constant_dict["encoder_colormatrix_bt2020nc"]: constant_dict[ 189 | "fmtconv_colormatrix_bt2020nc" 190 | ], 191 | } 192 | constant_dict["encoder_fmtconv_colorprim_dict"]: dict = { 193 | constant_dict["encoder_colorprim_bt709"]: constant_dict[ 194 | "fmtconv_colorprim_bt709" 195 | ], 196 | constant_dict["encoder_colorprim_smpte170"]: constant_dict[ 197 | "fmtconv_colorprim_smpte170" 198 | ], 199 | constant_dict["encoder_colorprim_bt2020"]: constant_dict[ 200 | "fmtconv_colorprim_bt2020" 201 | ], 202 | } 203 | constant_dict["encoder_fmtconv_transfer_dict"]: dict = { 204 | constant_dict["encoder_transfer_bt709"]: constant_dict[ 205 | "fmtconv_transfer_bt709" 206 | ], 207 | constant_dict["encoder_transfer_smpte170"]: constant_dict[ 208 | "fmtconv_transfer_smpte170" 209 | ], 210 | constant_dict["encoder_transfer_bt2020_10"]: constant_dict[ 211 | "fmtconv_transfer_bt2020_10" 212 | ], 213 | constant_dict["encoder_transfer_bt2020_12"]: constant_dict[ 214 | "fmtconv_transfer_bt2020_12" 215 | ], 216 | constant_dict["encoder_transfer_smpte2084"]: constant_dict[ 217 | "fmtconv_transfer_smpte2084" 218 | ], 219 | } 220 | 221 | constant_dict["encoder_colormatrix_transfer_dict"]: dict = { 222 | constant_dict["encoder_colormatrix_bt709"]: constant_dict[ 223 | "encoder_transfer_bt709" 224 | ], 225 | constant_dict["encoder_colormatrix_smpte170"]: constant_dict[ 226 | "encoder_transfer_smpte170" 227 | ], 228 | constant_dict["encoder_colormatrix_bt2020nc"]: constant_dict[ 229 | "encoder_transfer_smpte2084" 230 | ], 231 | constant_dict["encoder_colormatrix_bt2020c"]: constant_dict[ 232 | "encoder_transfer_smpte2084" 233 | ], 234 | } 235 | constant_dict["encoder_colormatrix_colorprim_dict"]: dict = { 236 | constant_dict["encoder_colormatrix_bt709"]: constant_dict[ 237 | "encoder_colorprim_bt709" 238 | ], 239 | constant_dict["encoder_colormatrix_smpte170"]: constant_dict[ 240 | "encoder_colorprim_smpte170" 241 | ], 242 | constant_dict["encoder_colormatrix_bt2020nc"]: constant_dict[ 243 | "encoder_colorprim_bt2020" 244 | ], 245 | constant_dict["encoder_colormatrix_bt2020c"]: constant_dict[ 246 | "encoder_colorprim_bt2020" 247 | ], 248 | } 249 | Constant: namedtuple = namedtuple("Constant", constant_dict.keys()) 250 | constant: namedtuple = Constant(**constant_dict) 251 | return constant 252 | 253 | 254 | -------------------------------------------------------------------------------- /media_master/util/ffprobe.py: -------------------------------------------------------------------------------- 1 | """ 2 | ffprobe.py ffprobe module of media master 3 | Copyright (C) 2020 Ace C Lee 4 | 5 | This program is free software: you can redistribute it and/or modify 6 | it under the terms of the GNU General Public License as published by 7 | the Free Software Foundation, either version 3 of the License, or 8 | (at your option) any later version. 9 | 10 | This program is distributed in the hope that it will be useful, 11 | but WITHOUT ANY WARRANTY; without even the implied warranty of 12 | MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 13 | GNU General Public License for more details. 14 | 15 | You should have received a copy of the GNU General Public License 16 | along with this program. If not, see . 17 | """ 18 | 19 | import os 20 | import subprocess 21 | import json 22 | import logging 23 | import sys 24 | 25 | g_logger = logging.getLogger(__name__) 26 | g_logger.propagate = True 27 | g_logger.setLevel(logging.DEBUG) 28 | 29 | 30 | def ffmpeg_probe(filepath: str, ffprobe_exe_file_dir=""): 31 | ffprobe_exe_filename = "ffprobe.exe" 32 | ffprobe_exe_filepath = os.path.join( 33 | ffprobe_exe_file_dir, ffprobe_exe_filename 34 | ) 35 | args_list = [ 36 | ffprobe_exe_filepath, 37 | "-show_format", 38 | "-show_streams", 39 | "-of", 40 | "json", 41 | filepath, 42 | ] 43 | 44 | ffprobe_param_debug_str: str = ( 45 | f"ffprobe: param: {subprocess.list2cmdline(args_list)}" 46 | ) 47 | g_logger.log(logging.DEBUG, ffprobe_param_debug_str) 48 | 49 | process = subprocess.Popen( 50 | args_list, stdout=subprocess.PIPE, stderr=subprocess.PIPE 51 | ) 52 | stdout_data, stderr_data = process.communicate() 53 | 54 | return_code = process.returncode 55 | 56 | if return_code != 0: 57 | error_info_str: str = ( 58 | f"ffprobe error:\n" 59 | f"stdout_data:\n" 60 | f"{stdout_data}" 61 | f"stderr_data:\n" 62 | f"{stderr_data}" 63 | ) 64 | g_logger.log(logging.INFO, error_info_str) 65 | print(error_info_str, file=sys.stderr) 66 | raise ChildProcessError(error_info_str) 67 | 68 | return json.loads(stdout_data.decode("utf-8")) 69 | 70 | 71 | -------------------------------------------------------------------------------- /media_master/util/file_hash.py: -------------------------------------------------------------------------------- 1 | """ 2 | file_hash.py file hash module of media_master 3 | Copyright (C) 2019 Ace C Lee 4 | 5 | This program is free software: you can redistribute it and/or modify 6 | it under the terms of the GNU General Public License as published by 7 | the Free Software Foundation, either version 3 of the License, or 8 | (at your option) any later version. 9 | 10 | This program is distributed in the hope that it will be useful, 11 | but WITHOUT ANY WARRANTY; without even the implied warranty of 12 | MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 13 | GNU General Public License for more details. 14 | 15 | You should have received a copy of the GNU General Public License 16 | along with this program. If not, see . 17 | """ 18 | import hashlib 19 | 20 | 21 | def blake2b_hash(data, output_size_in_byte=6): 22 | blake_hash = hashlib.blake2b(data, digest_size=output_size_in_byte) 23 | return blake_hash.hexdigest() 24 | 25 | 26 | def hash_file(filepath: str, output_size_in_byte=6): 27 | with open(filepath, "rb") as file: 28 | hash_value = blake2b_hash( 29 | file.read(), output_size_in_byte=output_size_in_byte 30 | ) 31 | return hash_value 32 | -------------------------------------------------------------------------------- /media_master/util/fraction.py: -------------------------------------------------------------------------------- 1 | """ 2 | fraction.py fraction module of media info 3 | Copyright (C) 2020 Ace C Lee 4 | 5 | This program is free software: you can redistribute it and/or modify 6 | it under the terms of the GNU General Public License as published by 7 | the Free Software Foundation, either version 3 of the License, or 8 | (at your option) any later version. 9 | 10 | This program is distributed in the hope that it will be useful, 11 | but WITHOUT ANY WARRANTY; without even the implied warranty of 12 | MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 13 | GNU General Public License for more details. 14 | 15 | You should have received a copy of the GNU General Public License 16 | along with this program. If not, see . 17 | """ 18 | 19 | from fractions import Fraction 20 | 21 | 22 | def get_reduced_fraction(numerator: int, denominator: int): 23 | fraction: Fraction = Fraction(numerator=numerator, denominator=denominator) 24 | return dict(numerator=fraction.numerator, denominator=fraction.denominator) 25 | 26 | -------------------------------------------------------------------------------- /media_master/util/language.py: -------------------------------------------------------------------------------- 1 | """ 2 | language.py language module of media_master 3 | Copyright (C) 2020 Ace C Lee 4 | 5 | This program is free software: you can redistribute it and/or modify 6 | it under the terms of the GNU General Public License as published by 7 | the Free Software Foundation, either version 3 of the License, or 8 | (at your option) any later version. 9 | 10 | This program is distributed in the hope that it will be useful, 11 | but WITHOUT ANY WARRANTY; without even the implied warranty of 12 | MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 13 | GNU General Public License for more details. 14 | 15 | You should have received a copy of the GNU General Public License 16 | along with this program. If not, see . 17 | """ 18 | 19 | from iso639 import languages 20 | 21 | 22 | def all_iso639_code_set(): 23 | all_code_set: set = set() 24 | 25 | all_code_set |= set(la.part1 for la in languages) 26 | all_code_set |= set(la.part2t for la in languages) 27 | all_code_set |= set(la.part2b for la in languages) 28 | all_code_set |= set(la.part3 for la in languages) 29 | all_code_set |= set(la.part5 for la in languages) 30 | 31 | all_code_set.remove("") 32 | 33 | return all_code_set 34 | 35 | 36 | -------------------------------------------------------------------------------- /media_master/util/log_compress.py: -------------------------------------------------------------------------------- 1 | """ 2 | log_compress.py log compression module of media_master 3 | Copyright (C) 2020 Ace C Lee 4 | 5 | This program is free software: you can redistribute it and/or modify 6 | it under the terms of the GNU General Public License as published by 7 | the Free Software Foundation, either version 3 of the License, or 8 | (at your option) any later version. 9 | 10 | This program is distributed in the hope that it will be useful, 11 | but WITHOUT ANY WARRANTY; without even the implied warranty of 12 | MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 13 | GNU General Public License for more details. 14 | 15 | You should have received a copy of the GNU General Public License 16 | along with this program. If not, see . 17 | """ 18 | 19 | from datetime import date 20 | import os 21 | import re 22 | from .rar_compress import rar_compress 23 | import sys 24 | 25 | 26 | def compress_log_expect_this_month( 27 | log_dir: str, 28 | rm_original=False, 29 | log_extension_re_exp="^\\.(\\d{4})-(\\d{2})-(\\d{2})$", 30 | ): 31 | re_pattern = re.compile(log_extension_re_exp) 32 | today_date = date.today() 33 | this_month = date(year=today_date.year, month=today_date.month, day=1) 34 | dict_key_format: str = "{year:0>4}-{month:0>2}" 35 | output_filename_format: str = "log-{datetime}" 36 | 37 | candidate_filepath_dict: dict = {} 38 | for full_filename in os.listdir(log_dir): 39 | filepath: str = os.path.join(log_dir, full_filename) 40 | filename, extension = os.path.splitext(full_filename) 41 | re_result = re.fullmatch(pattern=re_pattern, string=extension) 42 | if not re_result: 43 | continue 44 | year, month, day = ( 45 | int(re_result.group(1)), 46 | int(re_result.group(2)), 47 | int(re_result.group(3)), 48 | ) 49 | log_date = date(year=year, month=month, day=day) 50 | if log_date < this_month: 51 | log_dict_key: str = dict_key_format.format(year=year, month=month) 52 | if log_dict_key not in candidate_filepath_dict.keys(): 53 | candidate_filepath_dict[log_dict_key] = {filepath} 54 | else: 55 | candidate_filepath_dict[log_dict_key].add(filepath) 56 | 57 | for date_str, filepath_set in candidate_filepath_dict.items(): 58 | output_filename: str = output_filename_format.format(datetime=date_str) 59 | filename_set: set = set( 60 | os.path.basename(filepath) for filepath in filepath_set 61 | ) 62 | rar_compress( 63 | input_filepath_set=filename_set, 64 | output_dir=log_dir, 65 | output_filename=output_filename, 66 | compress_level=5, 67 | solid_compress_bool=True, 68 | rar_version=4, 69 | text_compress_opt_bool=True, 70 | work_dir=log_dir, 71 | ) 72 | if rm_original: 73 | for filepath in filepath_set: 74 | print(f"delete: {filepath}", file=sys.stderr) 75 | os.remove(filepath) 76 | 77 | 78 | -------------------------------------------------------------------------------- /media_master/util/meta_data.py: -------------------------------------------------------------------------------- 1 | """ 2 | meta_data.py meta data module of media_master 3 | Copyright (C) 2019 Ace C Lee 4 | 5 | This program is free software: you can redistribute it and/or modify 6 | it under the terms of the GNU General Public License as published by 7 | the Free Software Foundation, either version 3 of the License, or 8 | (at your option) any later version. 9 | 10 | This program is distributed in the hope that it will be useful, 11 | but WITHOUT ANY WARRANTY; without even the implied warranty of 12 | MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 13 | GNU General Public License for more details. 14 | 15 | You should have received a copy of the GNU General Public License 16 | along with this program. If not, see . 17 | """ 18 | 19 | import logging 20 | import os 21 | import re 22 | import subprocess 23 | import sys 24 | from fractions import Fraction 25 | 26 | from pymediainfo import MediaInfo 27 | 28 | from ..error import RangeError 29 | from .constant import global_constant 30 | 31 | g_logger = logging.getLogger(__name__) 32 | g_logger.propagate = True 33 | g_logger.setLevel(logging.DEBUG) 34 | 35 | 36 | def get_proper_sar(sar, max_denominator=100) -> dict: 37 | re_exp: str = "^(\\d+):(\\d+)$" 38 | sar_num: int = 0 39 | sar_den: int = 0 40 | 41 | if isinstance(sar, str): 42 | if not sar: 43 | sar_num = 1 44 | sar_den = 1 45 | re_result = re.fullmatch(re_exp, sar) 46 | if re_result: 47 | sar_num = int(re_result.group(1)) 48 | sar_den = int(re_result.group(2)) 49 | else: 50 | sar_float: float = float(sar) 51 | elif isinstance(sar, int) or isinstance(sar, float): 52 | sar_float: float = float(sar) 53 | else: 54 | raise ValueError(f"unknown sar : {sar}") 55 | 56 | if not any([sar_num, sar_den]): 57 | sar_fraction: Fraction = Fraction(sar_float).limit_denominator( 58 | max_denominator 59 | ) 60 | sar_num = sar_fraction.numerator 61 | sar_den = sar_fraction.denominator 62 | 63 | sar_dict: dict = dict(sar_num=sar_num, sar_den=sar_den) 64 | 65 | return sar_dict 66 | 67 | 68 | def get_proper_hdr_info(video_info_dict: dict) -> dict: 69 | constant = global_constant() 70 | mediainfo_mastering_display_luminance_re_exp: str = ( 71 | constant.mediainfo_mastering_display_luminance_re_exp 72 | ) 73 | mediainfo_light_level_re_exp: str = constant.mediainfo_light_level_re_exp 74 | mediainfo_encoder_colorprim_dict: dict = ( 75 | constant.mediainfo_encoder_colorprim_dict 76 | ) 77 | hdr_info_dict: dict = {} 78 | if "hdr_format" not in video_info_dict.keys(): 79 | return hdr_info_dict 80 | hdr_info_dict["mastering_display_color_primaries"] = ( 81 | mediainfo_encoder_colorprim_dict[ 82 | video_info_dict["mastering_display_color_primaries"] 83 | ] 84 | if "mastering_display_color_primaries" in video_info_dict.keys() 85 | else "" 86 | ) 87 | 88 | if "mastering_display_luminance" in video_info_dict.keys(): 89 | re_result = re.search( 90 | mediainfo_mastering_display_luminance_re_exp, 91 | video_info_dict["mastering_display_luminance"], 92 | ) 93 | hdr_info_dict["min_mastering_display_luminance"] = float( 94 | re_result.groupdict()["min"] 95 | ) 96 | hdr_info_dict["max_mastering_display_luminance"] = float( 97 | re_result.groupdict()["max"] 98 | ) 99 | else: 100 | hdr_info_dict["min_mastering_display_luminance"] = -1 101 | hdr_info_dict["max_mastering_display_luminance"] = -1 102 | 103 | if "maximum_content_light_level" in video_info_dict.keys(): 104 | re_result = re.search( 105 | mediainfo_light_level_re_exp, 106 | video_info_dict["maximum_content_light_level"], 107 | ) 108 | hdr_info_dict["max_content_light_level"] = float( 109 | re_result.groupdict()["num"] 110 | ) 111 | else: 112 | hdr_info_dict["max_content_light_level"] = -1 113 | 114 | if "maximum_frameaverage_light_level" in video_info_dict.keys(): 115 | re_result = re.search( 116 | mediainfo_light_level_re_exp, 117 | video_info_dict["maximum_frameaverage_light_level"], 118 | ) 119 | hdr_info_dict["max_frameaverage_light_level"] = float( 120 | re_result.groupdict()["num"] 121 | ) 122 | else: 123 | hdr_info_dict["max_frameaverage_light_level"] = -1 124 | 125 | return hdr_info_dict 126 | 127 | 128 | def get_colorspace_specification(width: int, height: int, bit_depth: int): 129 | if not isinstance(width, int): 130 | raise TypeError(f"type of width must be int instead of {type(width)}") 131 | 132 | if not isinstance(height, int): 133 | raise TypeError( 134 | f"type of height must be int instead of {type(height)}" 135 | ) 136 | 137 | if not isinstance(bit_depth, int): 138 | raise TypeError( 139 | f"type of bit_depth must be int instead of {type(bit_depth)}" 140 | ) 141 | 142 | if width <= 0: 143 | raise RangeError( 144 | message=f"value of width must in [0,inf]", valid_range="[0,inf]" 145 | ) 146 | 147 | if height <= 0: 148 | raise RangeError( 149 | message=f"value of height must in [0,inf]", valid_range="[0,inf]" 150 | ) 151 | 152 | if bit_depth <= 0: 153 | raise RangeError( 154 | message=f"value of bit_depth must in [0,inf]", 155 | valid_range="[0,inf]", 156 | ) 157 | 158 | constant = global_constant() 159 | 160 | sd_bool: bool = False 161 | hd_bool: bool = False 162 | uhd_bool: bool = False 163 | 164 | if width <= 1024 and height <= 576: 165 | sd_bool = True 166 | elif width <= 2048 and height <= 1536: 167 | hd_bool = True 168 | else: 169 | uhd_bool = True 170 | 171 | color_matrix: str = ( 172 | constant.encoder_colormatrix_smpte170 173 | if sd_bool 174 | else constant.encoder_colormatrix_bt2020nc 175 | if uhd_bool 176 | else constant.encoder_colormatrix_bt709 177 | ) 178 | color_primaries: str = ( 179 | constant.encoder_colorprim_smpte170 180 | if sd_bool 181 | else constant.encoder_colorprim_bt2020 182 | if uhd_bool 183 | else constant.encoder_colorprim_bt709 184 | ) 185 | 186 | bt2020_available_bit_depth_tuple: tuple = ( 187 | constant.bt2020_available_bit_depth_tuple 188 | ) 189 | 190 | bt2020_transfer: str = constant.encoder_transfer_smpte2084 191 | 192 | transfer: str = ( 193 | constant.encoder_transfer_smpte170 194 | if sd_bool 195 | else bt2020_transfer 196 | if uhd_bool 197 | else constant.encoder_transfer_bt709 198 | ) 199 | 200 | colorspace_specification_dict: dict = dict( 201 | color_matrix=color_matrix, 202 | color_primaries=color_primaries, 203 | transfer=transfer, 204 | ) 205 | 206 | return colorspace_specification_dict 207 | 208 | 209 | def get_proper_color_specification(video_info_dict: dict) -> dict: 210 | constant = global_constant() 211 | color_specification_dict: dict = get_colorspace_specification( 212 | width=video_info_dict[constant.mediainfo_width_key], 213 | height=video_info_dict[constant.mediainfo_height_key], 214 | bit_depth=int(video_info_dict[constant.mediainfo_bit_depth_key]) 215 | if constant.mediainfo_bit_depth_key in video_info_dict.keys() 216 | else 8, 217 | ) 218 | 219 | mediainfo_encoder_colormatrix_dict: dict = ( 220 | constant.mediainfo_encoder_colormatrix_dict 221 | ) 222 | 223 | mediainfo_encoder_colorprim_dict: dict = ( 224 | constant.mediainfo_encoder_colorprim_dict 225 | ) 226 | 227 | mediainfo_encoder_transfer_dict: dict = ( 228 | constant.mediainfo_encoder_transfer_dict 229 | ) 230 | 231 | encoder_colormatrix_transfer_dict: dict = ( 232 | constant.encoder_colormatrix_transfer_dict 233 | ) 234 | 235 | encoder_colormatrix_colorprim_dict: dict = ( 236 | constant.encoder_colormatrix_colorprim_dict 237 | ) 238 | 239 | if constant.mediainfo_colormatrix_key in video_info_dict.keys(): 240 | color_specification_dict[ 241 | "color_matrix" 242 | ] = mediainfo_encoder_colormatrix_dict[ 243 | video_info_dict[constant.mediainfo_colormatrix_key] 244 | ] 245 | if constant.mediainfo_colorprim_key in video_info_dict.keys(): 246 | color_specification_dict[ 247 | "color_primaries" 248 | ] = mediainfo_encoder_colorprim_dict[ 249 | video_info_dict[constant.mediainfo_colorprim_key] 250 | ] 251 | else: 252 | color_specification_dict[ 253 | "color_primaries" 254 | ] = encoder_colormatrix_colorprim_dict[ 255 | color_specification_dict["color_matrix"] 256 | ] 257 | if constant.mediainfo_transfer_key in video_info_dict.keys(): 258 | color_specification_dict[ 259 | "transfer" 260 | ] = mediainfo_encoder_transfer_dict[ 261 | video_info_dict[constant.mediainfo_transfer_key] 262 | ] 263 | else: 264 | if ( 265 | color_specification_dict["color_primaries"] 266 | == constant.vapoursynth_colorprim_bt2020 267 | ): 268 | color_specification_dict[ 269 | "transfer" 270 | ] = encoder_colormatrix_transfer_dict[ 271 | color_specification_dict["color_matrix"] 272 | ] 273 | else: 274 | color_specification_dict[ 275 | "transfer" 276 | ] = encoder_colormatrix_transfer_dict[ 277 | color_specification_dict["color_matrix"] 278 | ] 279 | 280 | return color_specification_dict 281 | 282 | 283 | def get_float_frame_rate(fps: str) -> float: 284 | fps = str(fps) 285 | fps_num: float = 0.0 286 | if "/" in fps: 287 | fps_num_list: list = fps.split("/") 288 | if len(fps_num_list) != 2: 289 | raise ValueError(f"len(fps_num_list) != 2") 290 | fps_num = int(fps_num_list[0]) / int(fps_num_list[1]) 291 | elif fps.replace(".", "").isdigit(): 292 | fps_num = float(fps) 293 | else: 294 | raise ValueError(f"unknown fps: {fps}") 295 | 296 | return fps_num 297 | 298 | 299 | def get_proper_frame_rate(video_info_dict: dict, original_fps=False): 300 | original_frame_rate_key: str = "original_frame_rate" 301 | original_frame_rate_num_key: str = "framerate_original_num" 302 | original_frame_rate_den_key: str = "framerate_original_den" 303 | frame_rate_key: str = "frame_rate" 304 | frame_rate_num_key: str = "framerate_num" 305 | frame_rate_den_key: str = "framerate_den" 306 | 307 | original_play_frame_rate = "" 308 | play_frame_rate = "" 309 | if original_frame_rate_key in video_info_dict.keys(): 310 | if ( 311 | original_frame_rate_num_key in video_info_dict.keys() 312 | and original_frame_rate_den_key in video_info_dict.keys() 313 | ): 314 | original_play_frame_rate = ( 315 | f"{video_info_dict[original_frame_rate_num_key]}" 316 | f"/{video_info_dict[original_frame_rate_den_key]}" 317 | ) 318 | else: 319 | original_play_frame_rate = video_info_dict[original_frame_rate_key] 320 | if frame_rate_key in video_info_dict.keys(): 321 | if ( 322 | frame_rate_num_key in video_info_dict.keys() 323 | and frame_rate_den_key in video_info_dict.keys() 324 | ): 325 | play_frame_rate = ( 326 | f"{video_info_dict[frame_rate_num_key]}" 327 | f"/{video_info_dict[frame_rate_den_key]}" 328 | ) 329 | else: 330 | play_frame_rate = video_info_dict[frame_rate_key] 331 | 332 | return_fps = "" 333 | 334 | if original_fps and original_play_frame_rate: 335 | return_fps = original_play_frame_rate 336 | elif play_frame_rate: 337 | return_fps = play_frame_rate 338 | 339 | if return_fps == "23976/1000": 340 | return_fps = "24000/1001" 341 | elif return_fps == "29970/1000": 342 | return_fps = "30000/1001" 343 | elif return_fps == "59940/1000": 344 | return_fps = "60000/1001" 345 | 346 | return return_fps 347 | 348 | 349 | def reliable_meta_data( 350 | input_filename: str, media_info_data: dict, lowest_mkvmerge_version=10 351 | ): 352 | mp4_extension: str = ".mp4" 353 | mkv_extension: str = ".mkv" 354 | vob_extension: str = ".vob" 355 | m2ts_extension: str = ".m2ts" 356 | if input_filename.endswith(mkv_extension): 357 | general_info: dict = media_info_data["tracks"][0] 358 | writing_application_str: str = general_info["writing_application"] 359 | mkvmerge_str: str = "mkvmerge" 360 | voukoder_str: str = "Voukoder" 361 | mkvmerge_re_exp: str = "mkvmerge v(\\d+)\\.(\\d+)\\.(\\d+)" 362 | if mkvmerge_str in writing_application_str: 363 | re_result = re.search(mkvmerge_re_exp, writing_application_str) 364 | if re_result: 365 | version = int(re_result.group(1)) 366 | if version < lowest_mkvmerge_version: 367 | return False 368 | else: 369 | return True 370 | else: 371 | raise RuntimeError( 372 | f"Unknown writing_application_str {writing_application_str}" 373 | ) 374 | elif voukoder_str in writing_application_str: 375 | return True 376 | else: 377 | return False 378 | elif input_filename.endswith(m2ts_extension): 379 | return False 380 | elif input_filename.endswith(mp4_extension): 381 | return False 382 | elif input_filename.endswith(vob_extension): 383 | return False 384 | else: 385 | return False 386 | 387 | 388 | def edit_mkv_prop( 389 | mkv_filepath: str, info_list: list, mkvpropedit_exe_file_dir="" 390 | ): 391 | if not os.path.isfile(mkv_filepath): 392 | raise ValueError 393 | 394 | mkvpropedit_exe_filename: str = "mkvpropedit.exe" 395 | 396 | mkvpropedit_exe_filepath: str = os.path.join( 397 | mkvpropedit_exe_file_dir, mkvpropedit_exe_filename 398 | ) 399 | edit_key: str = "--edit" 400 | edit_track_value_format: str = "{selector}:{track_order}" 401 | set_key: str = "--set" 402 | set_value_format: str = "{key}={value}" 403 | 404 | cmd_param_list: list = [mkvpropedit_exe_filepath] 405 | 406 | for info_dict in info_list: 407 | print(info_dict) 408 | if info_dict["selector"] == "track": 409 | edit_value: str = edit_track_value_format.format( 410 | selector=info_dict["selector"], 411 | track_order=int(info_dict["track_index"]) + 1, 412 | ) 413 | else: 414 | raise ValueError 415 | cmd_param_list.extend( 416 | [ 417 | edit_key, 418 | edit_value, 419 | set_key, 420 | set_value_format.format( 421 | key=info_dict["prop_key"], value=info_dict["prop_value"] 422 | ), 423 | ] 424 | ) 425 | 426 | cmd_param_list.append(mkv_filepath) 427 | 428 | mkvpropedit_param_debug_str: str = ( 429 | f"mkvpropedit: param: {subprocess.list2cmdline(cmd_param_list)}" 430 | ) 431 | g_logger.log(logging.DEBUG, mkvpropedit_param_debug_str) 432 | 433 | start_info_str: str = ( 434 | f"mkvpropedit: start editing prop of {mkv_filepath}" 435 | ) 436 | 437 | print(start_info_str, file=sys.stderr) 438 | g_logger.log(logging.INFO, start_info_str) 439 | 440 | process = subprocess.Popen( 441 | cmd_param_list, 442 | stdout=subprocess.PIPE, 443 | text=True, 444 | encoding="utf-8", 445 | errors="ignore", 446 | ) 447 | 448 | stdout_lines: list = [] 449 | while process.poll() is None: 450 | stdout_line = process.stdout.readline() 451 | stdout_lines.append(stdout_line) 452 | print(stdout_line, end="", file=sys.stderr) 453 | 454 | return_code = process.returncode 455 | 456 | if return_code == 0: 457 | end_info_str: str = ( 458 | f"mkvpropedit: edit prop of {mkv_filepath} successfully." 459 | ) 460 | print(end_info_str, file=sys.stderr) 461 | g_logger.log(logging.INFO, end_info_str) 462 | elif return_code == 1: 463 | warning_prefix = "Warning:" 464 | warning_text_str = "".join( 465 | line for line in stdout_lines if line.startswith(warning_prefix) 466 | ) 467 | stdout_text_str = "".join(stdout_lines) 468 | warning_str: str = ( 469 | "mkvpropedit: " 470 | "mkvpropedit has output at least one warning, " 471 | "but modification did continue.\n" 472 | f"warning:\n{warning_text_str}" 473 | f"stdout:\n{stdout_text_str}" 474 | ) 475 | print(warning_str, file=sys.stderr) 476 | g_logger.log(logging.WARNING, warning_str) 477 | else: 478 | error_str = f"mkvpropedit: edit prop of {mkv_filepath} unsuccessfully." 479 | print(error_str, file=sys.stderr) 480 | raise subprocess.CalledProcessError( 481 | returncode=return_code, 482 | cmd=subprocess.list2cmdline(cmd_param_list), 483 | output=stdout_text_str, 484 | ) 485 | 486 | 487 | def change_mkv_meta_data(mkv_filepath: str, title_map_dict: dict): 488 | if not os.path.isfile(mkv_filepath): 489 | raise ValueError 490 | 491 | media_info_list: list = MediaInfo.parse(mkv_filepath).to_data()["tracks"] 492 | 493 | track_info_list: list = [ 494 | track 495 | for track in media_info_list 496 | if track["track_type"].lower() != "general" 497 | and track["track_type"].lower() != "menu" 498 | ] 499 | 500 | skip_bool: bool = True 501 | for track_info in track_info_list: 502 | if ( 503 | "streamorder" in track_info.keys() 504 | and "title" in track_info.keys() 505 | and any( 506 | re.search(re_exp, track_info["title"]) 507 | for re_exp in title_map_dict.keys() 508 | ) 509 | ): 510 | skip_bool = False 511 | break 512 | 513 | if skip_bool: 514 | return 515 | 516 | mkvpropedit_info_list: list = [] 517 | for track_info in track_info_list: 518 | if ( 519 | "streamorder" in track_info.keys() 520 | and "title" in track_info.keys() 521 | and any( 522 | re.search(re_exp, track_info["title"]) 523 | for re_exp in title_map_dict.keys() 524 | ) 525 | ): 526 | prop_value: str = "" 527 | for re_exp, value_format in title_map_dict.items(): 528 | re_result = re.search(re_exp, track_info["title"]) 529 | if not re_result: 530 | continue 531 | prop_value = value_format.format( 532 | creator=re_result.groupdict()["creator"] 533 | ) 534 | mkvpropedit_info_list.append( 535 | dict( 536 | selector="track", 537 | track_index=int(track_info["streamorder"]), 538 | prop_key="name", 539 | prop_value=prop_value, 540 | ) 541 | ) 542 | 543 | edit_mkv_prop(mkv_filepath, info_list=mkvpropedit_info_list) 544 | 545 | 546 | def change_dir_mkv_meta_data(file_dir: str, title_map_dict: dict): 547 | mkv_extension: str = ".mkv" 548 | mkv_filename_list: list = [ 549 | filename 550 | for filename in os.listdir(file_dir) 551 | if filename.endswith(mkv_extension) 552 | and os.path.isfile(os.path.join(file_dir, filename)) 553 | ] 554 | 555 | for filename in mkv_filename_list: 556 | filepath = os.path.join(file_dir, filename) 557 | print(filepath) 558 | change_mkv_meta_data(filepath, title_map_dict) 559 | 560 | 561 | def change_volume_mkv_meta_data(volume: str, title_map_dict: dict): 562 | for dirpath, dirnames, filenames in os.walk(volume): 563 | change_dir_mkv_meta_data(dirpath, title_map_dict=title_map_dict) 564 | 565 | 566 | -------------------------------------------------------------------------------- /media_master/util/multiplex.py: -------------------------------------------------------------------------------- 1 | """ 2 | multiplex.py multiplex media to video 3 | Copyright (C) 2019 Ace C Lee 4 | 5 | This program is free software: you can redistribute it and/or modify 6 | it under the terms of the GNU General Public License as published by 7 | the Free Software Foundation, either version 3 of the License, or 8 | (at your option) any later version. 9 | 10 | This program is distributed in the hope that it will be useful, 11 | but WITHOUT ANY WARRANTY; without even the implied warranty of 12 | MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 13 | GNU General Public License for more details. 14 | 15 | You should have received a copy of the GNU General Public License 16 | along with this program. If not, see . 17 | """ 18 | 19 | import os 20 | import subprocess 21 | import logging 22 | import sys 23 | 24 | from ..error import DirNotFoundError, RangeError 25 | from .check import check_file_environ_path 26 | from .constant import global_constant 27 | from pymediainfo import MediaInfo 28 | from .string_util import ( 29 | get_unique_printable_filename, 30 | is_filename_with_valid_mark, 31 | get_filename_with_valid_mark, 32 | ) 33 | 34 | import copy 35 | 36 | g_logger = logging.getLogger(__name__) 37 | g_logger.propagate = True 38 | g_logger.setLevel(logging.DEBUG) 39 | 40 | 41 | def remultiplex_ffmpeg( 42 | input_filepath: str, 43 | output_file_dir: str, 44 | output_file_name: str, 45 | output_file_extension: str, 46 | add_valid_mark_bool: bool = False, 47 | ffmpeg_exe_file_dir="", 48 | ) -> str: 49 | if not isinstance(output_file_dir, str): 50 | raise TypeError( 51 | f"type of output_file_dir must be str " 52 | f"instead of {type(output_file_dir)}" 53 | ) 54 | 55 | if not isinstance(output_file_name, str): 56 | raise TypeError( 57 | f"type of output_file_name must be str " 58 | f"instead of {type(output_file_name)}" 59 | ) 60 | 61 | if not isinstance(output_file_extension, str): 62 | raise TypeError( 63 | f"type of output_file_extension must be str " 64 | f"instead of {type(output_file_extension)}" 65 | ) 66 | 67 | if not isinstance(ffmpeg_exe_file_dir, str): 68 | raise TypeError( 69 | f"type of ffmpeg_exe_file_dir must be str " 70 | f"instead of {type(ffmpeg_exe_file_dir)}" 71 | ) 72 | 73 | ffmpeg_exe_filename: str = "ffmpeg.exe" 74 | if ffmpeg_exe_file_dir: 75 | if not os.path.isdir(ffmpeg_exe_file_dir): 76 | raise DirNotFoundError( 77 | f"ffmpeg dir cannot be found with {ffmpeg_exe_file_dir}" 78 | ) 79 | all_filename_list: list = os.listdir(ffmpeg_exe_file_dir) 80 | if ffmpeg_exe_filename not in all_filename_list: 81 | raise FileNotFoundError( 82 | f"{ffmpeg_exe_filename} cannot be found in " f"{ffmpeg_exe_file_dir}" 83 | ) 84 | else: 85 | if not check_file_environ_path({ffmpeg_exe_filename}): 86 | raise FileNotFoundError( 87 | f"{ffmpeg_exe_filename} cannot be found in " f"environment path" 88 | ) 89 | if not os.path.exists(output_file_dir): 90 | os.makedirs(output_file_dir) 91 | 92 | ffmpeg_exe_filepath: str = os.path.join(ffmpeg_exe_file_dir, ffmpeg_exe_filename) 93 | output_filename_fullname: str = output_file_name + output_file_extension 94 | output_filepath: str = os.path.join(output_file_dir, output_filename_fullname) 95 | 96 | if add_valid_mark_bool: 97 | valid_output_filename_fullname: str = get_filename_with_valid_mark( 98 | output_filename_fullname 99 | ) 100 | valid_output_filepath: str = os.path.join( 101 | output_file_dir, valid_output_filename_fullname 102 | ) 103 | 104 | if os.path.isfile(output_filepath): 105 | os.remove(output_filepath) 106 | 107 | if os.path.isfile(valid_output_filepath): 108 | skip_info_str: str = ( 109 | f"multiplex ffmpeg: {valid_output_filepath} " 110 | f"already existed, skip multiplexing." 111 | ) 112 | 113 | print(skip_info_str, file=sys.stderr) 114 | g_logger.log(logging.INFO, skip_info_str) 115 | return valid_output_filepath 116 | 117 | input_key: str = "-i" 118 | input_value: str = input_filepath 119 | overwrite_key: str = "-y" 120 | codec_key: str = "-codec" 121 | codec_value: str = "copy" 122 | output_value: str = output_filepath 123 | cmd_param_list: list = [ 124 | ffmpeg_exe_filepath, 125 | input_key, 126 | input_value, 127 | overwrite_key, 128 | codec_key, 129 | codec_value, 130 | output_value, 131 | ] 132 | 133 | ffmpeg_param_debug_str: str = ( 134 | f"multiplex ffmpeg: param:" f"{subprocess.list2cmdline(cmd_param_list)}" 135 | ) 136 | g_logger.log(logging.DEBUG, ffmpeg_param_debug_str) 137 | 138 | start_info_str: str = (f"multiplex ffmpeg: starting multiplexing {output_filepath}") 139 | 140 | print(start_info_str, file=sys.stderr) 141 | g_logger.log(logging.INFO, start_info_str) 142 | 143 | process = subprocess.Popen(cmd_param_list) 144 | 145 | process.communicate() 146 | 147 | return_code = process.returncode 148 | 149 | if return_code == 0: 150 | end_info_str: str = ( 151 | f"multiplex ffmpeg: " f"multiplex {output_filepath} successfully." 152 | ) 153 | print(end_info_str, file=sys.stderr) 154 | g_logger.log(logging.INFO, end_info_str) 155 | else: 156 | error_str = f"multiplex ffmpeg: " f"multiplex {output_filepath} unsuccessfully." 157 | print(error_str, file=sys.stderr) 158 | raise ChildProcessError(error_str) 159 | 160 | if add_valid_mark_bool: 161 | os.rename(output_filepath, valid_output_filepath) 162 | output_filepath = valid_output_filepath 163 | 164 | return output_filepath 165 | 166 | 167 | def multiplex_mkv( 168 | track_info_list: list, 169 | output_file_dir: str, 170 | output_file_name: str, 171 | file_title="", 172 | chapters_filepath="", 173 | attachments_filepath_set=set(), 174 | add_valid_mark_bool: bool = False, 175 | mkvmerge_exe_file_dir="", 176 | ) -> str: 177 | if not isinstance(track_info_list, list): 178 | raise TypeError( 179 | f"type of track_info_list must be list " 180 | f"instead of {type(track_info_list)}" 181 | ) 182 | 183 | if track_info_list and not isinstance(track_info_list[0], dict): 184 | raise TypeError( 185 | f"type of element of track_info_list must be dict " 186 | f"instead of {type(track_info_list[0])}" 187 | ) 188 | 189 | if not isinstance(output_file_dir, str): 190 | raise TypeError( 191 | f"type of output_file_dir must be str " 192 | f"instead of {type(output_file_dir)}" 193 | ) 194 | 195 | if not isinstance(output_file_name, str): 196 | raise TypeError( 197 | f"type of output_file_name must be str " 198 | f"instead of {type(output_file_name)}" 199 | ) 200 | 201 | if not isinstance(chapters_filepath, str): 202 | raise TypeError( 203 | f"type of chapters_filepath must be str " 204 | f"instead of {type(chapters_filepath)}" 205 | ) 206 | 207 | if not isinstance(attachments_filepath_set, set): 208 | raise TypeError( 209 | f"type of attachments_filepath_set must be set " 210 | f"instead of {type(attachments_filepath_set)}" 211 | ) 212 | 213 | if not isinstance(mkvmerge_exe_file_dir, str): 214 | raise TypeError( 215 | f"type of mkvmerge_exe_file_dir must be str " 216 | f"instead of {type(mkvmerge_exe_file_dir)}" 217 | ) 218 | 219 | necessary_key_set: set = {"filepath", "track_id"} 220 | 221 | for infor_dict in track_info_list: 222 | key_set: set = set(infor_dict.keys()) 223 | for key in necessary_key_set: 224 | if key not in key_set: 225 | raise KeyError(f"{infor_dict} misses key {key}") 226 | 227 | filepath: str = infor_dict["filepath"] 228 | if not os.path.isfile(filepath): 229 | raise FileNotFoundError( 230 | f"filepath of {infor_dict} cannot be found with {filepath}" 231 | ) 232 | if chapters_filepath and not os.path.isfile(chapters_filepath): 233 | raise FileNotFoundError(f"input chapter file cannot be found with {filepath}") 234 | if attachments_filepath_set: 235 | for filepath in attachments_filepath_set: 236 | if not os.path.isfile(filepath): 237 | raise FileNotFoundError( 238 | f"input attachment file cannot be found with {filepath}" 239 | ) 240 | 241 | mkvmerge_exe_filename: str = "mkvmerge.exe" 242 | if mkvmerge_exe_file_dir: 243 | if not os.path.isdir(mkvmerge_exe_file_dir): 244 | raise DirNotFoundError( 245 | f"mkvmerge dir cannot be found with {mkvmerge_exe_file_dir}" 246 | ) 247 | all_filename_list: list = os.listdir(mkvmerge_exe_file_dir) 248 | if mkvmerge_exe_filename not in all_filename_list: 249 | raise FileNotFoundError( 250 | f"{mkvmerge_exe_filename} cannot be found in " 251 | f"{mkvmerge_exe_file_dir}" 252 | ) 253 | else: 254 | if not check_file_environ_path({mkvmerge_exe_filename}): 255 | raise FileNotFoundError( 256 | f"{mkvmerge_exe_filename} cannot be found in " "environment path" 257 | ) 258 | 259 | for index, track_info_dict in enumerate(track_info_list): 260 | if "timecode_filepath" in track_info_dict.keys(): 261 | new_track_info_dict: dict = copy.deepcopy(track_info_dict) 262 | new_track_info_dict["timestamp_filepath"] = track_info_dict[ 263 | "timecode_filepath" 264 | ] 265 | new_track_info_dict.pop("timecode_filepath") 266 | track_info_list[index] = new_track_info_dict 267 | 268 | selective_key_set: set = { 269 | "track_type", 270 | "delay_ms", 271 | "track_name", 272 | "language", 273 | "timestamp_filepath", 274 | } 275 | 276 | selective_key_default_value_dict: dict = dict( 277 | delay_ms=0, track_name="", language="", timestamp_filepath="" 278 | ) 279 | 280 | constant = global_constant() 281 | 282 | video_track_type: str = constant.video_type 283 | audio_track_type: str = constant.audio_type 284 | subtitle_track_type: str = constant.subtitle_type 285 | 286 | mediainfo_track_type_dict: dict = { 287 | constant.mediainfo_video_type: constant.video_type, 288 | constant.mediainfo_audio_type: constant.audio_type, 289 | constant.mediainfo_subtitle_type: constant.subtitle_type, 290 | } 291 | 292 | for track_info_dict in track_info_list: 293 | if track_info_dict["track_id"] == -1: 294 | continue 295 | for selective_key in selective_key_set: 296 | if selective_key in track_info_dict.keys(): 297 | continue 298 | if selective_key == "track_type": 299 | media_info_list: list = MediaInfo.parse( 300 | track_info_dict["filepath"] 301 | ).to_data()["tracks"] 302 | track_mediainfo_dict: dict = next( 303 | ( 304 | track 305 | for track in media_info_list 306 | if constant.mediainfo_track_id_key in track.keys() 307 | and int(track[constant.mediainfo_track_id_key]) 308 | == track_info_dict["track_id"] 309 | ), 310 | None, 311 | ) 312 | track_info_dict["track_type"] = mediainfo_track_type_dict[ 313 | track_mediainfo_dict["track_type"] 314 | ] 315 | else: 316 | track_info_dict[selective_key] = selective_key_default_value_dict[ 317 | selective_key 318 | ] 319 | 320 | available_track_type_set: set = { 321 | video_track_type, 322 | audio_track_type, 323 | subtitle_track_type, 324 | } 325 | 326 | for infor_dict in track_info_list: 327 | if track_info_dict["track_id"] == -1: 328 | continue 329 | key_set: set = set(infor_dict.keys()) 330 | track_type: str = infor_dict["track_type"] 331 | if track_type not in available_track_type_set: 332 | raise RangeError( 333 | message=(f"value of track_type must in " f"{available_track_type_set}"), 334 | valid_range=str(available_track_type_set), 335 | ) 336 | if not os.path.isdir(output_file_dir): 337 | os.makedirs(output_file_dir) 338 | 339 | mkv_suffix: str = ".mkv" 340 | mkvmerge_exe_filepath: str = os.path.join( 341 | mkvmerge_exe_file_dir, mkvmerge_exe_filename 342 | ) 343 | output_filename_fullname: str = output_file_name + mkv_suffix 344 | output_filepath: str = os.path.join(output_file_dir, output_filename_fullname) 345 | 346 | if add_valid_mark_bool: 347 | valid_output_filename_fullname: str = get_filename_with_valid_mark( 348 | output_filename_fullname 349 | ) 350 | valid_output_filepath: str = os.path.join( 351 | output_file_dir, valid_output_filename_fullname 352 | ) 353 | 354 | if os.path.isfile(output_filepath): 355 | os.remove(output_filepath) 356 | 357 | if os.path.isfile(valid_output_filepath): 358 | skip_info_str: str = ( 359 | f"multiplex mkvmerge: {valid_output_filepath} " 360 | f"already existed, skip multiplexing." 361 | ) 362 | 363 | print(skip_info_str, file=sys.stderr) 364 | g_logger.log(logging.INFO, skip_info_str) 365 | return valid_output_filepath 366 | output_key: str = "--output" 367 | output_value: str = output_filepath 368 | audio_track_key: str = "--audio-tracks" 369 | video_track_key: str = "--video-tracks" 370 | subtitle_track_key: str = "--subtitle-tracks" 371 | no_audio_key: str = "--no-audio" 372 | no_video_key: str = "--no-video" 373 | no_subtitles_key: str = "--no-subtitles" 374 | no_attachments_key: str = "--no-attachments" 375 | sync_key: str = "--sync" 376 | track_name_key: str = "--track-name" 377 | language_key: str = "--language" 378 | 379 | timestamp_key: str = "--timestamps" 380 | 381 | mkvmerge_value_format: str = "{track_id}:{value}" 382 | 383 | title_key: str = "--title" 384 | chapters_key: str = "--chapters" 385 | attachment_key: str = "--attach-file" 386 | verbose_key: str = "--verbose" 387 | 388 | def mkvmerge_option_cmd_list(cmd_key: str, value_format: str, track_id: int, value): 389 | if value: 390 | cmd_value: str = value_format.format(track_id=track_id, value=value) 391 | return [cmd_key, cmd_value] 392 | else: 393 | return [] 394 | 395 | def exclusive_track_type_list(track_type: str): 396 | all_exclusive_track_type_list: list = [ 397 | no_audio_key, 398 | no_video_key, 399 | no_subtitles_key, 400 | no_attachments_key, 401 | ] 402 | return [ 403 | exclusive_track_type 404 | for exclusive_track_type in all_exclusive_track_type_list 405 | if track_type not in exclusive_track_type 406 | ] 407 | 408 | cmd_param_list: list = [ 409 | mkvmerge_exe_filepath, 410 | verbose_key, 411 | output_key, 412 | output_value, 413 | ] 414 | for track_info_dict in track_info_list: 415 | filepath: str = track_info_dict["filepath"] 416 | track_id: int = int(track_info_dict["track_id"]) 417 | track_cmd_param_list: list = [] 418 | if track_id == -1: 419 | pass 420 | elif track_id < 0: 421 | raise ValueError( 422 | RangeError( 423 | message="track_id must be int in the range of [-1,+inf]", 424 | valid_range="[-1,+inf]", 425 | ) 426 | ) 427 | else: 428 | track_type: str = track_info_dict["track_type"] 429 | delay_ms: int = int(track_info_dict["delay_ms"]) 430 | track_name: str = track_info_dict["track_name"] 431 | language: str = track_info_dict["language"] 432 | timestamp_filepath: str = track_info_dict["timestamp_filepath"] 433 | track_key: str = video_track_key if track_type == video_track_type else ( 434 | audio_track_key 435 | if track_type == audio_track_type 436 | else subtitle_track_key 437 | ) 438 | 439 | track_cmd_param_list += [track_key, str(track_id)] 440 | 441 | track_cmd_param_list += exclusive_track_type_list(track_type=track_type) 442 | 443 | track_cmd_param_list += mkvmerge_option_cmd_list( 444 | cmd_key=sync_key, 445 | value_format=mkvmerge_value_format, 446 | track_id=track_id, 447 | value=delay_ms, 448 | ) 449 | 450 | track_cmd_param_list += mkvmerge_option_cmd_list( 451 | cmd_key=track_name_key, 452 | value_format=mkvmerge_value_format, 453 | track_id=track_id, 454 | value=track_name, 455 | ) 456 | 457 | track_cmd_param_list += mkvmerge_option_cmd_list( 458 | cmd_key=language_key, 459 | value_format=mkvmerge_value_format, 460 | track_id=track_id, 461 | value=language, 462 | ) 463 | 464 | track_cmd_param_list += mkvmerge_option_cmd_list( 465 | cmd_key=timestamp_key, 466 | value_format=mkvmerge_value_format, 467 | track_id=track_id, 468 | value=timestamp_filepath, 469 | ) 470 | 471 | track_cmd_param_list.append(filepath) 472 | cmd_param_list += track_cmd_param_list 473 | 474 | if chapters_filepath: 475 | cmd_param_list += [chapters_key, chapters_filepath] 476 | 477 | if file_title: 478 | cmd_param_list += [title_key, file_title] 479 | 480 | if attachments_filepath_set: 481 | for filepath in attachments_filepath_set: 482 | cmd_param_list += [attachment_key, filepath] 483 | 484 | mkvmerge_param_debug_str: str = ( 485 | f"multiplex mkvmerge: param: " f"{subprocess.list2cmdline(cmd_param_list)}" 486 | ) 487 | g_logger.log(logging.DEBUG, mkvmerge_param_debug_str) 488 | 489 | start_info_str: str = (f"multiplex mkvmerge: start multiplexing {output_filepath}") 490 | 491 | print(start_info_str, file=sys.stderr) 492 | g_logger.log(logging.INFO, start_info_str) 493 | process = subprocess.Popen( 494 | cmd_param_list, 495 | stdout=subprocess.PIPE, 496 | text=True, 497 | encoding="utf-8", 498 | errors="ignore", 499 | ) 500 | 501 | stdout_lines: list = [] 502 | while process.poll() is None: 503 | stdout_line = process.stdout.readline() 504 | stdout_lines.append(stdout_line) 505 | print(stdout_line, end="", file=sys.stderr) 506 | 507 | return_code = process.returncode 508 | 509 | if return_code == 0: 510 | end_info_str: str = ( 511 | f"multiplex mkvmerge: " f"multiplex {output_filepath} successfully." 512 | ) 513 | print(end_info_str, file=sys.stderr) 514 | g_logger.log(logging.INFO, end_info_str) 515 | elif return_code == 1: 516 | warning_prefix = "Warning:" 517 | warning_text_str = "".join( 518 | line for line in stdout_lines if line.startswith(warning_prefix) 519 | ) 520 | stdout_text_str = "".join(stdout_lines) 521 | warning_str: str = ( 522 | "multiplex mkvmerge: " 523 | "mkvmerge has output at least one warning, " 524 | "but muxing did continue.\n" 525 | f"warning:\n{warning_text_str}" 526 | f"stdout:\n{stdout_text_str}" 527 | ) 528 | print(warning_str, file=sys.stderr) 529 | g_logger.log(logging.WARNING, warning_str) 530 | else: 531 | error_str = ( 532 | f"multiplex mkvmerge: " f"multiplex {output_filepath} unsuccessfully." 533 | ) 534 | print(error_str, file=sys.stderr) 535 | raise subprocess.CalledProcessError( 536 | returncode=return_code, 537 | cmd=subprocess.list2cmdline(cmd_param_list), 538 | output=stdout_text_str, 539 | ) 540 | 541 | if add_valid_mark_bool: 542 | os.rename(output_filepath, valid_output_filepath) 543 | output_filepath = valid_output_filepath 544 | 545 | return output_filepath 546 | 547 | 548 | def multiplex_mp4( 549 | track_info_list: list, 550 | output_file_dir: str, 551 | output_file_name: str, 552 | chapters_filepath="", 553 | add_valid_mark_bool: bool = False, 554 | mp4box_exe_file_dir="", 555 | ) -> str: 556 | if not isinstance(track_info_list, list): 557 | raise TypeError( 558 | f"type of track_info_list must be list " 559 | f"instead of {type(track_info_list)}" 560 | ) 561 | 562 | if track_info_list and not isinstance(track_info_list[0], dict): 563 | raise TypeError( 564 | f"type of element of track_info_list must be dict " 565 | f"instead of {type(track_info_list[0])}" 566 | ) 567 | 568 | if not isinstance(output_file_dir, str): 569 | raise TypeError( 570 | f"type of output_file_dir must be str " 571 | f"instead of {type(output_file_dir)}" 572 | ) 573 | 574 | if not isinstance(output_file_name, str): 575 | raise TypeError( 576 | f"type of output_file_name must be str " 577 | f"instead of {type(output_file_name)}" 578 | ) 579 | 580 | if not isinstance(chapters_filepath, str): 581 | raise TypeError( 582 | f"type of chapters_filepath must be str " 583 | f"instead of {type(chapters_filepath)}" 584 | ) 585 | 586 | if not isinstance(mp4box_exe_file_dir, str): 587 | raise TypeError( 588 | f"type of mp4box_exe_file_dir must be str " 589 | f"instead of {type(mp4box_exe_file_dir)}" 590 | ) 591 | 592 | necessary_key_set: set = {"filepath", "track_id"} 593 | 594 | for infor_dict in track_info_list: 595 | key_set: set = set(infor_dict.keys()) 596 | for key in necessary_key_set: 597 | if key not in key_set: 598 | raise KeyError(f"{infor_dict} misses key {key}") 599 | 600 | filepath: str = infor_dict["filepath"] 601 | if not os.path.isfile(filepath): 602 | raise FileNotFoundError( 603 | f"filepath of {infor_dict} cannot be found with {filepath}" 604 | ) 605 | if chapters_filepath and not os.path.isfile(chapters_filepath): 606 | raise FileNotFoundError(f"input chapter file cannot be found with {filepath}") 607 | 608 | mp4box_exe_filename: str = "mp4box.exe" 609 | if mp4box_exe_file_dir: 610 | if not os.path.isdir(mp4box_exe_file_dir): 611 | raise DirNotFoundError( 612 | f"mp4box dir cannot be found with {mp4box_exe_file_dir}" 613 | ) 614 | all_filename_list: list = os.listdir(mp4box_exe_file_dir) 615 | if mp4box_exe_filename not in all_filename_list: 616 | raise FileNotFoundError( 617 | f"{mp4box_exe_filename} cannot be found in " f"{mp4box_exe_file_dir}" 618 | ) 619 | else: 620 | if not check_file_environ_path({mp4box_exe_filename}): 621 | raise FileNotFoundError( 622 | f"{mp4box_exe_filename} cannot be found in " "environment path" 623 | ) 624 | 625 | selective_key_set: set = {"delay_ms", "track_name", "language"} 626 | 627 | selective_key_default_value_dict: dict = dict( 628 | delay_ms=0, track_name="", language="" 629 | ) 630 | 631 | constant = global_constant() 632 | 633 | video_track_type: str = constant.video_type 634 | audio_track_type: str = constant.audio_type 635 | 636 | mediainfo_track_type_dict: dict = { 637 | constant.mediainfo_video_type: constant.video_type, 638 | constant.mediainfo_audio_type: constant.audio_type, 639 | constant.mediainfo_subtitle_type: constant.subtitle_type, 640 | } 641 | 642 | for track_info_dict in track_info_list: 643 | for selective_key in selective_key_set: 644 | if selective_key in track_info_dict.keys(): 645 | continue 646 | if selective_key == "track_type": 647 | media_info_list: list = MediaInfo.parse( 648 | track_info_dict["filepath"] 649 | ).to_data()["tracks"] 650 | track_mediainfo_dict: dict = next( 651 | ( 652 | track 653 | for track in media_info_list 654 | if constant.mediainfo_track_id_key in track.keys() 655 | and int(track[constant.mediainfo_track_id_key]) 656 | == track_info_dict["track_id"] 657 | ), 658 | None, 659 | ) 660 | track_info_dict["track_type"] = mediainfo_track_type_dict[ 661 | track_mediainfo_dict["track_type"] 662 | ] 663 | else: 664 | track_info_dict[selective_key] = selective_key_default_value_dict[ 665 | selective_key 666 | ] 667 | 668 | available_track_type_set: set = {video_track_type, audio_track_type} 669 | 670 | for infor_dict in track_info_list: 671 | if track_info_dict["track_id"] == -1: 672 | continue 673 | key_set: set = set(infor_dict.keys()) 674 | track_type: str = infor_dict["track_type"] 675 | if track_type not in available_track_type_set: 676 | raise RangeError( 677 | message=(f"value of track_type must in " f"{available_track_type_set}"), 678 | valid_range=str(available_track_type_set), 679 | ) 680 | if not os.path.isdir(output_file_dir): 681 | os.makedirs(output_file_dir) 682 | 683 | mp4_extension: str = ".mp4" 684 | mp4box_exe_filepath: str = os.path.join(mp4box_exe_file_dir, mp4box_exe_filename) 685 | 686 | output_filename_fullname: str = output_file_name + mp4_extension 687 | output_filepath: str = os.path.join(output_file_dir, output_filename_fullname) 688 | 689 | if add_valid_mark_bool: 690 | valid_output_filename_fullname: str = get_filename_with_valid_mark( 691 | output_filename_fullname 692 | ) 693 | valid_output_filepath: str = os.path.join( 694 | output_file_dir, valid_output_filename_fullname 695 | ) 696 | 697 | if os.path.isfile(output_filepath): 698 | os.remove(output_filepath) 699 | 700 | if os.path.isfile(valid_output_filepath): 701 | skip_info_str: str = ( 702 | f"multiplex mp4box: {valid_output_filepath} " 703 | f"already existed, skip multiplexing." 704 | ) 705 | 706 | print(skip_info_str, file=sys.stderr) 707 | g_logger.log(logging.INFO, skip_info_str) 708 | return valid_output_filepath 709 | force_new_file_key: str = "-new" 710 | output_value: str = os.path.abspath(output_filepath) 711 | add_key: str = "-add" 712 | track_id_option_key: str = "#trackID=" 713 | language_option_key: str = ":lang=" 714 | delay_ms_option_key: str = ":delay=" 715 | track_name_option_key: str = ":name=" 716 | 717 | cmd_param_list: list = [ 718 | mp4box_exe_filepath, 719 | force_new_file_key, 720 | output_value, 721 | ] 722 | for track_info_dict in track_info_list: 723 | filepath: str = os.path.abspath(track_info_dict["filepath"]) 724 | track_id: int = track_info_dict["track_id"] 725 | delay_ms: int = track_info_dict["delay_ms"] 726 | track_name: str = track_info_dict["track_name"] 727 | language: str = track_info_dict["language"] 728 | 729 | track_info_str: str = filepath 730 | 731 | track_info_str += track_id_option_key + str(track_id) 732 | 733 | if delay_ms: 734 | track_info_str += delay_ms_option_key + str(delay_ms) 735 | track_info_str += track_name_option_key + track_name 736 | 737 | if language: 738 | track_info_str += language_option_key + language 739 | 740 | cmd_param_list += [add_key, track_info_str] 741 | 742 | if chapters_filepath: 743 | chapters_filepath = os.path.abspath(chapters_filepath) 744 | cmd_param_list += ["-chap", f"{chapters_filepath}"] 745 | 746 | mp4box_param_debug_str: str = ( 747 | f"multiplex mp4box: param: " f"{subprocess.list2cmdline(cmd_param_list)}" 748 | ) 749 | g_logger.log(logging.DEBUG, mp4box_param_debug_str) 750 | print(mp4box_param_debug_str, file=sys.stderr) 751 | 752 | start_info_str: str = (f"multiplex mp4box: start multiplexing {output_filepath}") 753 | 754 | print(start_info_str, file=sys.stderr) 755 | g_logger.log(logging.INFO, start_info_str) 756 | 757 | process = subprocess.Popen(cmd_param_list) 758 | 759 | process.communicate() 760 | 761 | return_code = process.returncode 762 | 763 | if return_code == 0: 764 | end_info_str: str = ( 765 | f"multiplex mp4box: multiplex {output_filepath} successfully." 766 | ) 767 | print(end_info_str, file=sys.stderr) 768 | g_logger.log(logging.INFO, end_info_str) 769 | else: 770 | error_str = f"multiplex mp4box: " f"multiplex {output_filepath} unsuccessfully." 771 | raise ChildProcessError(error_str) 772 | 773 | if add_valid_mark_bool: 774 | os.rename(output_filepath, valid_output_filepath) 775 | output_filepath = valid_output_filepath 776 | 777 | return output_filepath 778 | 779 | 780 | -------------------------------------------------------------------------------- /media_master/util/name_hash.py: -------------------------------------------------------------------------------- 1 | """ 2 | name_hash.py name hash module of media_master 3 | Copyright (C) 2019 Ace C Lee 4 | 5 | This program is free software: you can redistribute it and/or modify 6 | it under the terms of the GNU General Public License as published by 7 | the Free Software Foundation, either version 3 of the License, or 8 | (at your option) any later version. 9 | 10 | This program is distributed in the hope that it will be useful, 11 | but WITHOUT ANY WARRANTY; without even the implied warranty of 12 | MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 13 | GNU General Public License for more details. 14 | 15 | You should have received a copy of the GNU General Public License 16 | along with this program. If not, see . 17 | """ 18 | 19 | import hashlib 20 | 21 | 22 | def hash_name(string: str, output_size_in_byte=3) -> str: 23 | blake_hash = hashlib.blake2b( 24 | string.encode("utf-8"), digest_size=output_size_in_byte 25 | ) 26 | return blake_hash.hexdigest() 27 | -------------------------------------------------------------------------------- /media_master/util/number.py: -------------------------------------------------------------------------------- 1 | """ 2 | number.py number module of media info 3 | Copyright (C) 2020 Ace C Lee 4 | 5 | This program is free software: you can redistribute it and/or modify 6 | it under the terms of the GNU General Public License as published by 7 | the Free Software Foundation, either version 3 of the License, or 8 | (at your option) any later version. 9 | 10 | This program is distributed in the hope that it will be useful, 11 | but WITHOUT ANY WARRANTY; without even the implied warranty of 12 | MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 13 | GNU General Public License for more details. 14 | 15 | You should have received a copy of the GNU General Public License 16 | along with this program. If not, see . 17 | """ 18 | 19 | 20 | def is_number(number): 21 | try: 22 | number = float(number) 23 | except ValueError: 24 | return False 25 | else: 26 | return True 27 | -------------------------------------------------------------------------------- /media_master/util/package_subtitle.py: -------------------------------------------------------------------------------- 1 | """ 2 | package_subtitle.py package subtitle to video file 3 | Copyright (C) 2020 Ace C Lee 4 | 5 | This program is free software: you can redistribute it and/or modify 6 | it under the terms of the GNU General Public License as published by 7 | the Free Software Foundation, either version 3 of the License, or 8 | (at your option) any later version. 9 | 10 | This program is distributed in the hope that it will be useful, 11 | but WITHOUT ANY WARRANTY; without even the implied warranty of 12 | MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 13 | GNU General Public License for more details. 14 | 15 | You should have received a copy of the GNU General Public License 16 | along with this program. If not, see . 17 | """ 18 | 19 | import logging 20 | import os 21 | import re 22 | import subprocess 23 | import sys 24 | 25 | from ..error import DirNotFoundError, RangeError 26 | from ..util import check_file_environ_path 27 | 28 | g_logger = logging.getLogger(__name__) 29 | g_logger.propagate = True 30 | g_logger.setLevel(logging.DEBUG) 31 | 32 | 33 | def package_subtitle_2_mkv( 34 | video_filepath: str, 35 | subtitle_filepath: str, 36 | subtitle_title: str, 37 | subtitle_language: str, 38 | output_dir: str, 39 | output_filename: str, 40 | mkvmerge_exe_file_dir="", 41 | ): 42 | mkv_extension: str = ".mkv" 43 | if not os.path.isdir(output_dir): 44 | os.makedirs(output_dir) 45 | output_full_filename: str = output_filename + mkv_extension 46 | output_filepath: str = os.path.join(output_dir, output_full_filename) 47 | 48 | mkvmerge_exe_filename: str = "mkvmerge.exe" 49 | if mkvmerge_exe_file_dir: 50 | if not os.path.isdir(mkvmerge_exe_file_dir): 51 | raise DirNotFoundError( 52 | f"mkvmerge dir cannot be found with {mkvmerge_exe_file_dir}" 53 | ) 54 | all_filename_list: list = os.listdir(mkvmerge_exe_file_dir) 55 | if mkvmerge_exe_filename not in all_filename_list: 56 | raise FileNotFoundError( 57 | f"{mkvmerge_exe_filename} cannot be found in " 58 | f"{mkvmerge_exe_file_dir}" 59 | ) 60 | else: 61 | if not check_file_environ_path({mkvmerge_exe_filename}): 62 | raise FileNotFoundError( 63 | f"{mkvmerge_exe_filename} cannot be found in " 64 | "environment path" 65 | ) 66 | if not os.path.exists(output_dir): 67 | os.makedirs(output_dir) 68 | 69 | mkvmerge_exe_filepath: str = os.path.join( 70 | mkvmerge_exe_file_dir, mkvmerge_exe_filename 71 | ) 72 | output_key: str = "--output" 73 | output_value: str = output_filepath 74 | 75 | track_name_key: str = "--track-name" 76 | language_key: str = "--language" 77 | 78 | cmd_param_list: list = [ 79 | mkvmerge_exe_filepath, 80 | output_key, 81 | output_value, 82 | video_filepath, 83 | ] 84 | 85 | track_name_value: str = f"0:{subtitle_title}" 86 | cmd_param_list += [track_name_key, track_name_value] 87 | language_value: str = f"0:{subtitle_language}" 88 | cmd_param_list += [language_key, language_value] 89 | 90 | cmd_param_list.append(subtitle_filepath) 91 | 92 | print(cmd_param_list, file=sys.stderr) 93 | 94 | mkvmerge_param_debug_str: str = ( 95 | f"multiplex mkvmerge: param:" 96 | f"{subprocess.list2cmdline(cmd_param_list)}" 97 | ) 98 | print(mkvmerge_param_debug_str, file=sys.stderr) 99 | g_logger.log(logging.DEBUG, mkvmerge_param_debug_str) 100 | 101 | start_info_str: str = ( 102 | f"multiplex mkvmerge: starting multiplexing {output_filepath}" 103 | ) 104 | 105 | print(start_info_str, file=sys.stderr) 106 | g_logger.log(logging.INFO, start_info_str) 107 | process = subprocess.Popen( 108 | cmd_param_list, 109 | stdout=subprocess.PIPE, 110 | text=True, 111 | encoding="utf-8", 112 | errors="ignore", 113 | ) 114 | 115 | stdout_lines: list = [] 116 | while process.poll() is None: 117 | stdout_line = process.stdout.readline() 118 | stdout_lines.append(stdout_line) 119 | print(stdout_line, end="", file=sys.stderr) 120 | 121 | return_code = process.returncode 122 | 123 | if return_code == 0: 124 | end_info_str: str = ( 125 | f"multiplex mkvmerge: " 126 | f"multiplex {output_filepath} successfully." 127 | ) 128 | print(end_info_str, file=sys.stderr) 129 | g_logger.log(logging.INFO, end_info_str) 130 | elif return_code == 1: 131 | warning_prefix = "Warning:" 132 | warning_text_str = "".join( 133 | line for line in stdout_lines if line.startswith(warning_prefix) 134 | ) 135 | stdout_text_str = "".join(stdout_lines) 136 | warning_str: str = ( 137 | "multiplex mkvmerge: " 138 | "mkvmerge has output at least one warning, " 139 | "but muxing did continue.\n" 140 | f"warning:\n{warning_text_str}" 141 | f"stdout:\n{stdout_text_str}" 142 | ) 143 | print(warning_str, file=sys.stderr) 144 | g_logger.log(logging.WARNING, warning_str) 145 | else: 146 | error_str = ( 147 | f"multiplex mkvmerge: " 148 | f"multiplex {output_filepath} unsuccessfully." 149 | ) 150 | print(error_str, file=sys.stderr) 151 | raise subprocess.CalledProcessError( 152 | returncode=return_code, 153 | cmd=subprocess.list2cmdline(cmd_param_list), 154 | output=stdout_text_str, 155 | ) 156 | 157 | return output_filepath 158 | 159 | 160 | def package_subtitles( 161 | output_dir: str, 162 | video_dir: str, 163 | video_filename_re_exp: str, 164 | subtitle_dir: str, 165 | subtitle_filename_re_exp: str, 166 | subtitle_title: str, 167 | subtitle_language: str, 168 | ): 169 | if not os.path.isdir(output_dir): 170 | os.makedirs(output_dir) 171 | video_filename_re_pattern = re.compile(video_filename_re_exp) 172 | subtitle_filename_re_pattern = re.compile(subtitle_filename_re_exp) 173 | video_filename_list: list = [ 174 | filename 175 | for filename in os.listdir(video_dir) 176 | if os.path.isfile(os.path.join(video_dir, filename)) 177 | and video_filename_re_pattern.search(filename) 178 | ] 179 | subtitle_filename_list: list = [ 180 | filename 181 | for filename in os.listdir(subtitle_dir) 182 | if os.path.isfile(os.path.join(subtitle_dir, filename)) 183 | and subtitle_filename_re_pattern.search(filename) 184 | ] 185 | if len(video_filename_list) != len(subtitle_filename_list): 186 | raise ValueError( 187 | f"len(video_filename_list) != " 188 | f"len(subtitle_filename_list): " 189 | f"{len(video_filename_list)} != " 190 | f"{len(subtitle_filename_list)}" 191 | ) 192 | for video_filename in video_filename_list: 193 | video_episode_num = int( 194 | video_filename_re_pattern.search(video_filename).group(1) 195 | ) 196 | for subtitle_filename in subtitle_filename_list: 197 | subtitle_episode_num = int( 198 | subtitle_filename_re_pattern.search(subtitle_filename).group(1) 199 | ) 200 | if video_episode_num != subtitle_episode_num: 201 | continue 202 | package_subtitle_2_mkv( 203 | video_filepath=os.path.join(video_dir, video_filename), 204 | subtitle_filepath=os.path.join( 205 | subtitle_dir, subtitle_filename 206 | ), 207 | subtitle_title=subtitle_title, 208 | subtitle_language=subtitle_language, 209 | output_dir=output_dir, 210 | output_filename=os.path.splitext(video_filename)[0], 211 | ) 212 | 213 | 214 | -------------------------------------------------------------------------------- /media_master/util/rar_compress.py: -------------------------------------------------------------------------------- 1 | import subprocess 2 | import os 3 | import sys 4 | from ..error import DirNotFoundError 5 | from .check import check_file_environ_path 6 | 7 | import logging 8 | 9 | g_logger = logging.getLogger(__name__) 10 | g_logger.propagate = True 11 | g_logger.setLevel(logging.DEBUG) 12 | 13 | 14 | def rar_compress( 15 | input_path_set: set, 16 | output_dir: str, 17 | output_filename: str, 18 | compress_level=5, 19 | solid_compress_bool=False, 20 | rar_version=5, 21 | text_compress_opt_bool=False, 22 | work_dir="", 23 | rar_exe_file_dir="", 24 | ) -> str: 25 | if not isinstance(input_path_set, set): 26 | raise TypeError( 27 | f"type of input_path_set must be set " 28 | f"instead of {type(input_path_set)}" 29 | ) 30 | 31 | if not input_path_set: 32 | return "" 33 | 34 | if not isinstance(output_dir, str): 35 | raise TypeError( 36 | f"type of output_dir must be str instead of {type(output_dir)}" 37 | ) 38 | 39 | if not isinstance(output_filename, str): 40 | raise TypeError( 41 | f"type of output_filename must be str " 42 | f"instead of {type(output_filename)}" 43 | ) 44 | 45 | if not isinstance(rar_exe_file_dir, str): 46 | raise TypeError( 47 | f"type of rar_exe_file_dir must be str " 48 | f"instead of {type(rar_exe_file_dir)}" 49 | ) 50 | 51 | rar_exe_filename: str = "Rar.exe" 52 | if rar_exe_file_dir: 53 | if not os.path.isdir(rar_exe_file_dir): 54 | raise DirNotFoundError( 55 | f"rar dir cannot be found with {rar_exe_file_dir}" 56 | ) 57 | all_filename_list: list = os.listdir(rar_exe_file_dir) 58 | if rar_exe_filename not in all_filename_list: 59 | raise FileNotFoundError( 60 | f"{rar_exe_filename} cannot be found in " f"{rar_exe_file_dir}" 61 | ) 62 | else: 63 | if not check_file_environ_path({rar_exe_filename}): 64 | raise FileNotFoundError( 65 | f"{rar_exe_filename} cannot be found in environment path" 66 | ) 67 | if not os.path.exists(output_dir): 68 | os.makedirs(output_dir) 69 | 70 | rar_extension: str = ".rar" 71 | output_file_fullname: str = output_filename + rar_extension 72 | output_filepath: str = os.path.join(output_dir, output_file_fullname) 73 | 74 | if os.path.isfile(output_filepath): 75 | os.remove(output_filepath) 76 | 77 | rar_exe_filepath: str = os.path.join(rar_exe_file_dir, rar_exe_filename) 78 | add_key: str = "a" 79 | compress_level_key: str = f"-m{compress_level}" 80 | rar_version_key: str = f"-ma{rar_version}" 81 | black_hash_key: str = "-htb" 82 | 83 | text_compress_key: str = "-mct+" 84 | solid_compress_key: str = "-s" 85 | 86 | cmd_param_list: list = [rar_exe_filepath, add_key, output_filepath] 87 | cmd_param_list.extend(input_path_set) 88 | cmd_param_list += [compress_level_key, rar_version_key, black_hash_key] 89 | if solid_compress_bool: 90 | cmd_param_list.append(solid_compress_key) 91 | 92 | if text_compress_opt_bool: 93 | cmd_param_list.append(text_compress_key) 94 | 95 | rar_param_debug_str: str = ( 96 | f"compress rar: param:" f"{subprocess.list2cmdline(cmd_param_list)}" 97 | ) 98 | g_logger.log(logging.DEBUG, rar_param_debug_str) 99 | print(rar_param_debug_str) 100 | 101 | start_info_str: str = f"compress rar: starting compress {output_filepath}" 102 | 103 | print(start_info_str, file=sys.stderr) 104 | g_logger.log(logging.INFO, start_info_str) 105 | 106 | process: subprocess.Popen = subprocess.Popen(cmd_param_list, cwd=work_dir) 107 | 108 | process.communicate() 109 | 110 | if process.returncode == 0: 111 | end_info_str: str = ( 112 | f"compress rar: compress {input_path_set} to " 113 | f"{output_filepath} successfully." 114 | ) 115 | print(end_info_str, file=sys.stderr) 116 | g_logger.log(logging.INFO, end_info_str) 117 | else: 118 | raise ChildProcessError( 119 | f"compress rar: transcode {input_path_set} to " 120 | f"{output_filepath} unsuccessfully!" 121 | ) 122 | 123 | return output_filepath 124 | 125 | 126 | -------------------------------------------------------------------------------- /media_master/util/sort.py: -------------------------------------------------------------------------------- 1 | """ 2 | sort.py sort module of media_master 3 | Copyright (C) 2019 Ace C Lee 4 | 5 | This program is free software: you can redistribute it and/or modify 6 | it under the terms of the GNU General Public License as published by 7 | the Free Software Foundation, either version 3 of the License, or 8 | (at your option) any later version. 9 | 10 | This program is distributed in the hope that it will be useful, 11 | but WITHOUT ANY WARRANTY; without even the implied warranty of 12 | MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 13 | GNU General Public License for more details. 14 | 15 | You should have received a copy of the GNU General Public License 16 | along with this program. If not, see . 17 | """ 18 | 19 | from ..error import RangeError 20 | import copy 21 | 22 | 23 | def resort(src, order_list): 24 | order_list = copy.deepcopy(order_list) 25 | if len(order_list) > len(src): 26 | raise ValueError( 27 | f"len(order_list) > len(src) {len(order_list)} > {len(src)}" 28 | ) 29 | if any(index not in order_list for index in range(len(order_list))): 30 | RangeError( 31 | message=( 32 | f"index of src must be in {list(range(len(order_list)))}" 33 | ), 34 | valid_range=str(list(range(len(order_list)))), 35 | ) 36 | if len(order_list) < len(src): 37 | order_list += list(range(len(order_list), len(src))) 38 | return sorted(src, key=lambda x: order_list[src.index(x)]) 39 | 40 | 41 | -------------------------------------------------------------------------------- /media_master/util/string_util.py: -------------------------------------------------------------------------------- 1 | """ 2 | string_util.py string module of media_master 3 | Copyright (C) 2019 Ace C Lee 4 | 5 | This program is free software: you can redistribute it and/or modify 6 | it under the terms of the GNU General Public License as published by 7 | the Free Software Foundation, either version 3 of the License, or 8 | (at your option) any later version. 9 | 10 | This program is distributed in the hope that it will be useful, 11 | but WITHOUT ANY WARRANTY; without even the implied warranty of 12 | MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 13 | GNU General Public License for more details. 14 | 15 | You should have received a copy of the GNU General Public License 16 | along with this program. If not, see . 17 | """ 18 | 19 | import string 20 | import os 21 | from .name_hash import hash_name 22 | from .constant import global_constant 23 | 24 | 25 | def is_ascii(s) -> str: 26 | return all(ord(c) < 128 for c in s) 27 | 28 | 29 | def is_printable(s) -> str: 30 | printable = set(string.printable) 31 | return all(c in printable for c in s) 32 | 33 | 34 | def get_printable(s) -> str: 35 | printable = set(string.printable) 36 | return "".join(filter(lambda x: x in printable, s)) 37 | 38 | 39 | def get_unique_printable_filename(filepath: str) -> str: 40 | if not os.path.isfile(filepath): 41 | raise ValueError(f"filepath: {filepath} is not a file") 42 | full_filename: str = os.path.basename(filepath) 43 | filename, extension = os.path.splitext(full_filename) 44 | 45 | printable_filename: str = get_printable(filename) 46 | hash_str: str = hash_name(filepath) 47 | 48 | output_filename: str = f"{printable_filename}_{hash_str}" 49 | 50 | return output_filename 51 | 52 | 53 | def is_filename_with_valid_mark(full_filename: str) -> bool: 54 | filename, extension = os.path.splitext(full_filename) 55 | constant = global_constant() 56 | valid_file_suffix: str = constant.valid_file_suffix 57 | if filename.endswith(valid_file_suffix): 58 | return True 59 | else: 60 | return False 61 | 62 | 63 | def get_filename_with_valid_mark(full_filename: str) -> str: 64 | filename, extension = os.path.splitext(full_filename) 65 | constant = global_constant() 66 | output_full_filename: str = filename + constant.valid_file_suffix + extension 67 | return output_full_filename 68 | 69 | 70 | -------------------------------------------------------------------------------- /media_master/util/subtitle.py: -------------------------------------------------------------------------------- 1 | """ 2 | subtitle.py subtitle module of media_master 3 | Copyright (C) 2020 Ace C Lee 4 | 5 | This program is free software: you can redistribute it and/or modify 6 | it under the terms of the GNU General Public License as published by 7 | the Free Software Foundation, either version 3 of the License, or 8 | (at your option) any later version. 9 | 10 | This program is distributed in the hope that it will be useful, 11 | but WITHOUT ANY WARRANTY; without even the implied warranty of 12 | MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 13 | GNU General Public License for more details. 14 | 15 | You should have received a copy of the GNU General Public License 16 | along with this program. If not, see . 17 | """ 18 | 19 | import os 20 | import re 21 | 22 | import chardet 23 | import pysubs2 24 | from fontTools import ttLib 25 | from fontTools.ttLib.sfnt import ( 26 | readTTCHeader, 27 | sfntDirectorySize, 28 | woffDirectorySize, 29 | sfntDirectoryFormat, 30 | woffDirectoryFormat, 31 | ) 32 | from fontTools.misc import sstruct 33 | 34 | from fontTools.misc.py23 import Tag 35 | import copy 36 | import struct 37 | 38 | from .config import load_config, save_config 39 | import logging 40 | import warnings 41 | 42 | g_logger = logging.getLogger(__name__) 43 | g_logger.propagate = True 44 | g_logger.setLevel(logging.DEBUG) 45 | 46 | 47 | def ass_check(subtitle_dir: str, encoding="utf-8-sig"): 48 | valid_identifier: str = "[Script Info]" 49 | ass_extension: str = ".ass" 50 | subtitle_filename_list = [] 51 | for filename in os.listdir(subtitle_dir): 52 | if filename.endswith(ass_extension): 53 | subtitle_filename_list.append(filename) 54 | for filename in subtitle_filename_list: 55 | filepath = os.path.join(subtitle_dir, filename) 56 | with open(filepath, mode="r", encoding=encoding) as f: 57 | text: str = f.read() 58 | if not text.startswith(valid_identifier): 59 | print(filename) 60 | 61 | 62 | def dir_font_info( 63 | fonts_dir: str = "", 64 | info_file_dir: str = "font_info", 65 | info_filename: str = "font_info.json", 66 | ): 67 | 68 | available_font_extension_set: set = [ 69 | ".ttf", 70 | ".ttc", 71 | ".otf", 72 | ".woff", 73 | ".woff2", 74 | ] 75 | 76 | if not fonts_dir: 77 | if os.name != "nt": 78 | raise OSError("only available in Windows") 79 | windows_fonts_dir: str = os.path.join( 80 | os.environ["SystemRoot"], "Fonts" 81 | ) 82 | fonts_dir = windows_fonts_dir 83 | 84 | FONTTOOLS_NAME_ID_FAMILY = 1 85 | FONTTOOLS_NAME_ID_NAME = 4 86 | FONTTOOLS_PLATFORM_ID_WINDOWS = 3 87 | LCID_EN = 1033 88 | 89 | update_font_info_bool: bool = True 90 | 91 | info_file_dir = os.path.abspath(info_file_dir) 92 | 93 | if not os.path.isdir(info_file_dir): 94 | os.makedirs(info_file_dir) 95 | 96 | info_filepath: str = os.path.join(info_file_dir, info_filename) 97 | 98 | font_info_dict: dict = dict(font_info_list=[]) 99 | if os.path.isfile(info_filepath): 100 | font_info_dict = load_config(info_filepath) 101 | 102 | all_font_filename_list: list = [ 103 | filename 104 | for filename in os.listdir(fonts_dir) 105 | if any( 106 | filename.lower().endswith(ext) 107 | for ext in available_font_extension_set 108 | ) 109 | ] 110 | all_font_filepath_list: list = [ 111 | os.path.join(fonts_dir, filename) 112 | for filename in all_font_filename_list 113 | ] 114 | 115 | info_existed_font_filepath_set: set = set( 116 | single_font_info_dict["filepath"] 117 | for single_font_info_dict in font_info_dict["font_info_list"] 118 | ) 119 | 120 | if set(all_font_filepath_list) == info_existed_font_filepath_set: 121 | update_font_info_bool = False 122 | 123 | if update_font_info_bool: 124 | font_info_dict = dict(font_info_list=[]) 125 | for font_path in all_font_filepath_list: 126 | font_num: int = 1 127 | 128 | with open(font_path, "rb") as file: 129 | font_sfnt_version: str = "" 130 | 131 | file.seek(0) 132 | font_sfnt_version = file.read(4) 133 | file.seek(0) 134 | 135 | if font_sfnt_version == b"ttcf": 136 | header = readTTCHeader(file) 137 | font_num = header.numFonts 138 | 139 | file.seek(header.offsetTable[0]) 140 | data = file.read(sfntDirectorySize) 141 | if len(data) != sfntDirectorySize: 142 | font_error_warning_str: str = ( 143 | f"{font_path} is Not a Font Collection " 144 | "(not enough data), skip." 145 | ) 146 | g_logger.log(logging.WARNING, font_error_warning_str) 147 | warnings.warn(font_error_warning_str, RuntimeWarning) 148 | continue 149 | data_dict: dict = sstruct.unpack(sfntDirectoryFormat, data) 150 | font_sfnt_version = data_dict["sfntVersion"] 151 | elif font_sfnt_version == b"wOFF": 152 | font_num = 1 153 | 154 | data = file.read(woffDirectorySize) 155 | if len(data) != woffDirectorySize: 156 | font_error_warning_str: str = ( 157 | f"{font_path} is Not a WOFF font " 158 | "(not enough data), skip." 159 | ) 160 | g_logger.log(logging.WARNING, font_error_warning_str) 161 | warnings.warn(font_error_warning_str, RuntimeWarning) 162 | continue 163 | data_dict: dict = sstruct.unpack(woffDirectoryFormat, data) 164 | font_sfnt_version = data_dict["sfntVersion"] 165 | else: 166 | font_num = 1 167 | 168 | data = file.read(sfntDirectorySize) 169 | if len(data) != sfntDirectorySize: 170 | font_error_warning_str: str = ( 171 | f"{font_path} is Not a TrueType or OpenType font " 172 | "(not enough data), skip." 173 | ) 174 | g_logger.log(logging.WARNING, font_error_warning_str) 175 | warnings.warn(font_error_warning_str, RuntimeWarning) 176 | continue 177 | data_dict: dict = sstruct.unpack(sfntDirectoryFormat, data) 178 | font_sfnt_version = data_dict["sfntVersion"] 179 | 180 | font_sfnt_version_tag = Tag(font_sfnt_version) 181 | 182 | if font_sfnt_version_tag not in ( 183 | "\x00\x01\x00\x00", 184 | "OTTO", 185 | "true", 186 | ): 187 | print(font_sfnt_version) 188 | print(font_sfnt_version_tag) 189 | font_error_warning_str: str = ( 190 | f"{font_path} is Not a TrueType or OpenType font " 191 | "(bad sfntVersion), skip." 192 | ) 193 | g_logger.log(logging.WARNING, font_error_warning_str) 194 | warnings.warn(font_error_warning_str, RuntimeWarning) 195 | continue 196 | 197 | for font_num_index in range(font_num): 198 | tt_font = ttLib.TTFont(font_path, fontNumber=font_num_index) 199 | family_list: list = [] 200 | for name_record in tt_font["name"].names: 201 | if ( 202 | name_record.nameID == FONTTOOLS_NAME_ID_FAMILY 203 | and name_record.platformID 204 | == FONTTOOLS_PLATFORM_ID_WINDOWS 205 | ): 206 | record_str: str = "" 207 | try: 208 | record_str = name_record.toStr() 209 | except UnicodeDecodeError: 210 | encoding: str = chardet.detect(name_record.string)[ 211 | "encoding" 212 | ] 213 | if encoding: 214 | record_str = name_record.string.decode( 215 | encoding 216 | ) 217 | else: 218 | continue 219 | family_list.append(record_str) 220 | 221 | family_list = list(set(family_list)) 222 | 223 | single_font_info_dict: dict = dict( 224 | filepath=font_path, 225 | family_list=family_list, 226 | index=font_num_index, 227 | file_font_num=font_num, 228 | ) 229 | font_info_dict["font_info_list"].append(single_font_info_dict) 230 | 231 | save_config(info_filepath, font_info_dict) 232 | return font_info_dict["font_info_list"] 233 | 234 | 235 | def ass_string_style_font_text(string): 236 | override_block_re_exp: str = "(\\{[^\\{\\}]+?\\})" 237 | font_name_override_tag_re_exp: str = "\\\\fn(?P[^\\\\{\\}]*?)(\\\\|\\})" 238 | 239 | override_block_pattern = re.compile(override_block_re_exp) 240 | font_name_override_tag_pattern = re.compile(font_name_override_tag_re_exp) 241 | 242 | info_dict: dict = dict(original_style_text_set=set(), fn_tag_text_dict={}) 243 | 244 | if font_name_override_tag_pattern.search(string): 245 | split_text_list: list = override_block_pattern.split(string) 246 | 247 | last_fn: str = "" 248 | for index in range(len(split_text_list)): 249 | override_block_re_result = override_block_pattern.search( 250 | split_text_list[index] 251 | ) 252 | if override_block_re_result: 253 | font_name_override_tag_re_result = font_name_override_tag_pattern.search( 254 | split_text_list[index] 255 | ) 256 | if font_name_override_tag_re_result: 257 | last_fn = font_name_override_tag_re_result.groupdict()[ 258 | "font_name" 259 | ].strip(" ") 260 | 261 | else: 262 | if last_fn: 263 | if last_fn in info_dict["fn_tag_text_dict"].keys(): 264 | info_dict["fn_tag_text_dict"][last_fn] |= set( 265 | split_text_list[index] 266 | ) 267 | else: 268 | info_dict["fn_tag_text_dict"][last_fn] = set( 269 | split_text_list[index] 270 | ) 271 | else: 272 | info_dict["original_style_text_set"] |= set( 273 | split_text_list[index] 274 | ) 275 | 276 | else: 277 | info_dict["original_style_text_set"] = set( 278 | override_block_pattern.sub("", string) 279 | ) 280 | 281 | return info_dict 282 | 283 | 284 | def get_missing_glyph_char_set(font_filepath: str, font_index: int, text: str): 285 | font_file = ttLib.TTFont(font_filepath, fontNumber=font_index) 286 | 287 | text_set: set = set(text) 288 | 289 | used_unicode_char_dict: dict = {} 290 | for char in text_set: 291 | char_utf32 = char.encode("utf-32-be") 292 | unicode_num: int = struct.unpack(">L", char_utf32)[0] 293 | used_unicode_char_dict[unicode_num] = char 294 | 295 | cmap_dict: dict = font_file.getBestCmap() 296 | if cmap_dict: 297 | existed_unicode_set: set = set(cmap_dict.keys()) 298 | 299 | missing_glyph_char_set: set = set() 300 | for unicode, char in used_unicode_char_dict.items(): 301 | if unicode not in existed_unicode_set: 302 | missing_glyph_char_set.add(char) 303 | 304 | return missing_glyph_char_set 305 | else: 306 | raise ValueError(f"CMAP of {font_filepath} is {cmap_dict}") 307 | 308 | 309 | def get_subtitle_missing_glyph_char_info( 310 | subtitle_filepath: str, 311 | allowable_missing_char_set: set = set(), 312 | input_encoding: str = "utf-8", 313 | fonts_dir: str = "", 314 | info_file_dir: str = "font_info", 315 | info_filename: str = "font_info.json", 316 | ): 317 | 318 | subtitle_file = pysubs2.load(subtitle_filepath, encoding=input_encoding) 319 | 320 | style_text_dict: dict = {} 321 | style_font_text_dict: dict = {} 322 | fn_font_text_dict: dict = {} 323 | 324 | for event in subtitle_file: 325 | if event.is_comment: 326 | continue 327 | 328 | ass_string_style_font_text_dict: dict = ass_string_style_font_text( 329 | event.text 330 | ) 331 | 332 | if ass_string_style_font_text_dict["original_style_text_set"]: 333 | if event.style in style_text_dict.keys(): 334 | style_text_dict[ 335 | event.style 336 | ] |= ass_string_style_font_text_dict["original_style_text_set"] 337 | else: 338 | style_text_dict[event.style] = ass_string_style_font_text_dict[ 339 | "original_style_text_set" 340 | ] 341 | 342 | for font in ass_string_style_font_text_dict["fn_tag_text_dict"]: 343 | if ass_string_style_font_text_dict["fn_tag_text_dict"][font]: 344 | if font in fn_font_text_dict.keys(): 345 | fn_font_text_dict[font] |= ass_string_style_font_text_dict[ 346 | "fn_tag_text_dict" 347 | ][font] 348 | else: 349 | fn_font_text_dict[font] = ass_string_style_font_text_dict[ 350 | "fn_tag_text_dict" 351 | ][font] 352 | 353 | for style in style_text_dict.keys(): 354 | if style in subtitle_file.styles.keys(): 355 | font: str = subtitle_file.styles[style].fontname 356 | if font in style_font_text_dict.keys(): 357 | style_font_text_dict[font] |= style_text_dict[style] 358 | else: 359 | style_font_text_dict[font] = style_text_dict[style] 360 | else: 361 | raise ValueError 362 | font_text_dict: dict = copy.copy(style_font_text_dict) 363 | 364 | for fn_font, text_set in fn_font_text_dict.items(): 365 | if fn_font in font_text_dict.keys(): 366 | font_text_dict[fn_font] |= text_set 367 | else: 368 | font_text_dict[fn_font] = text_set 369 | 370 | cache_font_text_dict: dict = {} 371 | for font, text_set in font_text_dict.items(): 372 | if font.strip("@") in cache_font_text_dict.keys(): 373 | cache_font_text_dict[font.strip("@")] |= text_set 374 | else: 375 | cache_font_text_dict[font.strip("@")] = text_set 376 | font_text_dict = cache_font_text_dict 377 | 378 | for font in font_text_dict.keys(): 379 | text_list: list = list(font_text_dict[font]) 380 | text_list.sort() 381 | font_text_dict[font] = text_list 382 | 383 | font_info_list: list = dir_font_info( 384 | fonts_dir=fonts_dir, 385 | info_file_dir=info_file_dir, 386 | info_filename=info_filename, 387 | ) 388 | 389 | all_font_name_set: set = set() 390 | all_lower_font_name_set: set = set() 391 | for font_info in font_info_list: 392 | for font_name in font_info["family_list"]: 393 | lower_font_name: str = font_name.lower() 394 | all_font_name_set.add(font_name) 395 | all_lower_font_name_set.add(lower_font_name) 396 | 397 | used_font_info_dict: dict = {} 398 | for font_name in font_text_dict.keys(): 399 | if font_name.lower() not in all_lower_font_name_set: 400 | raise ValueError( 401 | f"{font_name} in {subtitle_filepath} does NOT exist!" 402 | ) 403 | 404 | for current_font_info in font_info_list: 405 | lower_family_list: list = [ 406 | family.lower() for family in current_font_info["family_list"] 407 | ] 408 | if font_name.lower() in lower_family_list: 409 | used_font_info_dict[font_name] = dict( 410 | filepath=current_font_info["filepath"], 411 | index=current_font_info["index"], 412 | file_font_num=current_font_info["file_font_num"], 413 | ) 414 | break 415 | 416 | missing_glyph_char_info: dict = {} 417 | for font, current_font_info in used_font_info_dict.items(): 418 | 419 | text: str = "".join(font_text_dict[font]) 420 | 421 | missing_glyph_char_set: set = get_missing_glyph_char_set( 422 | font_filepath=current_font_info["filepath"], 423 | font_index=current_font_info["index"], 424 | text=text, 425 | ) 426 | 427 | unallowed_missing_glyph_char_set: set = missing_glyph_char_set - allowable_missing_char_set 428 | 429 | if unallowed_missing_glyph_char_set: 430 | missing_glyph_char_info[font] = unallowed_missing_glyph_char_set 431 | 432 | return missing_glyph_char_info 433 | 434 | 435 | def get_vsmod_improper_style( 436 | subtitle_filepath: str, input_encoding="utf-8" 437 | ) -> set: 438 | subs = pysubs2.load(subtitle_filepath, encoding=input_encoding) 439 | 440 | improper_style_name: str = "default" 441 | 442 | used_style_name_set: set = set() 443 | 444 | line_font_name_override_tag_re_exp: str = "^\\{[^\\{\\}]*?\\\\fn(?P[^\\\\{\\}]+?)[^\\{\\}]*?\\}" 445 | line_font_name_override_tag_pattern = re.compile( 446 | line_font_name_override_tag_re_exp 447 | ) 448 | 449 | for event_index in range(len(subs)): 450 | if subs[event_index].is_comment: 451 | continue 452 | if not line_font_name_override_tag_pattern.search( 453 | subs[event_index].text 454 | ): 455 | used_style_name_set.add(subs[event_index].style) 456 | 457 | improper_style_set: set = set() 458 | 459 | if improper_style_name in used_style_name_set: 460 | improper_style_set.add(improper_style_name) 461 | 462 | return improper_style_set 463 | 464 | 465 | -------------------------------------------------------------------------------- /media_master/util/subtitle_check.py: -------------------------------------------------------------------------------- 1 | """ 2 | subtitle_check.py subtitle check module of media_master 3 | Copyright (C) 2019 Ace C Lee 4 | 5 | This program is free software: you can redistribute it and/or modify 6 | it under the terms of the GNU General Public License as published by 7 | the Free Software Foundation, either version 3 of the License, or 8 | (at your option) any later version. 9 | 10 | This program is distributed in the hope that it will be useful, 11 | but WITHOUT ANY WARRANTY; without even the implied warranty of 12 | MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 13 | GNU General Public License for more details. 14 | 15 | You should have received a copy of the GNU General Public License 16 | along with this program. If not, see . 17 | """ 18 | 19 | import os 20 | 21 | 22 | def ass_check(subtitle_dir: str, encoding="utf-8-sig"): 23 | valid_identifier: str = "[Script Info]" 24 | ass_extension: str = ".ass" 25 | subtitle_filename_list = [] 26 | for filename in os.listdir(subtitle_dir): 27 | if filename.endswith(ass_extension): 28 | subtitle_filename_list.append(filename) 29 | for filename in subtitle_filename_list: 30 | filepath = os.path.join(subtitle_dir, filename) 31 | with open(filepath, mode="r", encoding=encoding) as f: 32 | text: str = f.read() 33 | if not text.startswith(valid_identifier): 34 | print(filename) 35 | 36 | 37 | -------------------------------------------------------------------------------- /media_master/util/template.py: -------------------------------------------------------------------------------- 1 | """ 2 | template.py template module of media_master 3 | Copyright (C) 2019 Ace C Lee 4 | 5 | This program is free software: you can redistribute it and/or modify 6 | it under the terms of the GNU General Public License as published by 7 | the Free Software Foundation, either version 3 of the License, or 8 | (at your option) any later version. 9 | 10 | This program is distributed in the hope that it will be useful, 11 | but WITHOUT ANY WARRANTY; without even the implied warranty of 12 | MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 13 | GNU General Public License for more details. 14 | 15 | You should have received a copy of the GNU General Public License 16 | along with this program. If not, see . 17 | """ 18 | 19 | import os 20 | import re 21 | import copy 22 | from .config import load_config, save_config 23 | 24 | G_PARAM_RE_EXP: str = "{{\\s*(\\S+)\\s*}}" 25 | 26 | 27 | def is_template(string: str, param_re_exp=G_PARAM_RE_EXP): 28 | return re.fullmatch(param_re_exp, str(string)) is not None 29 | 30 | 31 | def replace_config_template_dict( 32 | config_dict, program_param_dict, param_re_exp=G_PARAM_RE_EXP 33 | ): 34 | modified_config_dict: dict = copy.deepcopy(config_dict) 35 | for key in modified_config_dict.keys(): 36 | value = str(modified_config_dict[key]) 37 | re_result = re.fullmatch(param_re_exp, str(value)) 38 | if re_result and re_result.group(1) in program_param_dict.keys(): 39 | modified_config_dict[key] = program_param_dict[re_result.group(1)] 40 | return modified_config_dict 41 | 42 | 43 | def replace_param_template_list( 44 | param_list, program_param_dict, param_re_exp=G_PARAM_RE_EXP 45 | ): 46 | modified_param_list: dict = copy.deepcopy(param_list) 47 | for index, param in enumerate(modified_param_list): 48 | re_result = re.fullmatch(param_re_exp, str(param)) 49 | if re_result and re_result.group(1) in program_param_dict.keys(): 50 | modified_param_list[index] = program_param_dict[re_result.group(1)] 51 | return modified_param_list 52 | 53 | 54 | def generate_vpy_file( 55 | vpy_template_dict: dict, 56 | vpy_content_template_filepath: str, 57 | vpy_files_dir: str, 58 | vpy_filename: str, 59 | ): 60 | if not os.path.exists(vpy_files_dir): 61 | os.makedirs(vpy_files_dir) 62 | 63 | vpy_file_suffix = ".vpy" 64 | 65 | content_template_str = "" 66 | with open(vpy_content_template_filepath, "r", encoding="utf-8") as file: 67 | content_template_str = file.read() 68 | 69 | assert content_template_str, "content of {} is empty!".format( 70 | vpy_content_template_filepath 71 | ) 72 | 73 | content_str = content_template_str 74 | for key in vpy_template_dict.keys(): 75 | content_str = content_str.replace( 76 | "{{" + key + "}}", str(vpy_template_dict[key]) 77 | ) 78 | 79 | vpy_file_fullname = vpy_filename + vpy_file_suffix 80 | vpy_filepath = os.path.join(vpy_files_dir, vpy_file_fullname) 81 | 82 | with open(vpy_filepath, "w", encoding="utf-8") as file: 83 | file.write(content_str) 84 | 85 | return vpy_filepath 86 | 87 | 88 | def generate_single_transcode_config( 89 | single_transcode_config_template_json_filepath: str, 90 | output_transcode_config_template_json_filepath: str, 91 | input_output_info_list: list, 92 | ): 93 | input_config_dict: dict = load_config( 94 | single_transcode_config_template_json_filepath 95 | ) 96 | output_config_list: list = [] 97 | for input_output_info in input_output_info_list: 98 | input_filepath: str = input_output_info["input_filepath"] 99 | output_file_dir: str = input_output_info["output_file_dir"] 100 | output_filename: str = input_output_info["output_filename"] 101 | new_config_dict: dict = copy.deepcopy(input_config_dict) 102 | new_config_dict["type_related_config"][ 103 | "input_video_filepath" 104 | ] = input_filepath 105 | new_config_dict["type_related_config"][ 106 | "output_video_dir" 107 | ] = output_file_dir 108 | new_config_dict["type_related_config"][ 109 | "output_video_name" 110 | ] = output_filename 111 | output_config_list.append(new_config_dict) 112 | output_dict: dict = {"result": output_config_list} 113 | save_config(output_transcode_config_template_json_filepath, output_dict) 114 | 115 | 116 | -------------------------------------------------------------------------------- /media_master/util/timecode.py: -------------------------------------------------------------------------------- 1 | """ 2 | timecode.py time code module of media master 3 | Copyright (C) 2019 Ace C Lee 4 | 5 | This program is free software: you can redistribute it and/or modify 6 | it under the terms of the GNU General Public License as published by 7 | the Free Software Foundation, either version 3 of the License, or 8 | (at your option) any later version. 9 | 10 | This program is distributed in the hope that it will be useful, 11 | but WITHOUT ANY WARRANTY; without even the implied warranty of 12 | MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 13 | GNU General Public License for more details. 14 | 15 | You should have received a copy of the GNU General Public License 16 | along with this program. If not, see . 17 | """ 18 | 19 | import re 20 | 21 | 22 | def mkv_timecode_2_standard_timecode(mkv_timecode_filepath: str): 23 | mkv_timecode_comment_re_exp: str = "^# timestamp format v(?P\\d+)" 24 | standard_timecode_comment_format: str = "# timecode format v{version_num}" 25 | 26 | data_str: str = "" 27 | with open(file=mkv_timecode_filepath, mode="r") as file: 28 | data_str = file.read() 29 | 30 | re_result = re.search(pattern=mkv_timecode_comment_re_exp, string=data_str) 31 | 32 | if not re_result: 33 | return mkv_timecode_filepath 34 | 35 | mkv_timecode_comment: str = re_result.group(0) 36 | version_num: str = re_result.groupdict()["version_num"] 37 | standard_timecode_comment: str = standard_timecode_comment_format.format( 38 | version_num=version_num 39 | ) 40 | data_str = data_str.replace( 41 | mkv_timecode_comment, standard_timecode_comment 42 | ) 43 | 44 | standard_timecode_filepath: str = mkv_timecode_filepath 45 | 46 | with open(file=standard_timecode_filepath, mode="w") as file: 47 | file.write(data_str) 48 | 49 | return standard_timecode_filepath 50 | 51 | 52 | -------------------------------------------------------------------------------- /media_master/video/__init__.py: -------------------------------------------------------------------------------- 1 | from .transcode import ( 2 | GopX265VspipeVideoTranscoding, 3 | NvencVideoTranscoding, 4 | NvencVspipeVideoTranscoding, 5 | SegmentedConfigX265VspipeTranscoding, 6 | X264VspipeVideoTranscoding, 7 | X265VspipeVideoTranscoding, 8 | ) 9 | -------------------------------------------------------------------------------- /media_master/video/fps_conversion.py: -------------------------------------------------------------------------------- 1 | """ 2 | fps_conversion.py convert fps of video stream 3 | Copyright (C) 2020 Ace C Lee 4 | 5 | This program is free software: you can redistribute it and/or modify 6 | it under the terms of the GNU General Public License as published by 7 | the Free Software Foundation, either version 3 of the License, or 8 | (at your option) any later version. 9 | 10 | This program is distributed in the hope that it will be useful, 11 | but WITHOUT ANY WARRANTY; without even the implied warranty of 12 | MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 13 | GNU General Public License for more details. 14 | 15 | You should have received a copy of the GNU General Public License 16 | along with this program. If not, see . 17 | """ 18 | 19 | import logging 20 | import os 21 | import subprocess 22 | import sys 23 | 24 | from ..error import DirNotFoundError, MissTemplateError, RangeError 25 | from ..util import check_file_environ_path 26 | 27 | g_logger = logging.getLogger(__name__) 28 | g_logger.propagate = True 29 | g_logger.setLevel(logging.DEBUG) 30 | 31 | 32 | def avc_fps_conversion( 33 | filepath: str, 34 | ouput_dir: str, 35 | output_filename: str, 36 | output_fps: str, 37 | mkvmerge_exe_file_dir="", 38 | ): 39 | mkv_extension: str = ".mkv" 40 | if not os.path.isdir(ouput_dir): 41 | os.makedirs(ouput_dir) 42 | output_full_filename: str = output_filename + mkv_extension 43 | output_filepath: str = os.path.join(ouput_dir, output_full_filename) 44 | 45 | mkvmerge_exe_filename: str = "mkvmerge.exe" 46 | if mkvmerge_exe_file_dir: 47 | if not os.path.isdir(mkvmerge_exe_file_dir): 48 | raise DirNotFoundError( 49 | f"mkvmerge dir cannot be found with {mkvmerge_exe_file_dir}" 50 | ) 51 | all_filename_list: list = os.listdir(mkvmerge_exe_file_dir) 52 | if mkvmerge_exe_filename not in all_filename_list: 53 | raise FileNotFoundError( 54 | f"{mkvmerge_exe_filename} cannot be found in " 55 | f"{mkvmerge_exe_file_dir}" 56 | ) 57 | else: 58 | if not check_file_environ_path({mkvmerge_exe_filename}): 59 | raise FileNotFoundError( 60 | f"{mkvmerge_exe_filename} cannot be found in " 61 | "environment path" 62 | ) 63 | if not os.path.exists(ouput_dir): 64 | os.makedirs(ouput_dir) 65 | 66 | mkvmerge_exe_filepath: str = os.path.join( 67 | mkvmerge_exe_file_dir, mkvmerge_exe_filename 68 | ) 69 | output_key: str = "--output" 70 | output_value: str = output_filepath 71 | default_index: int = 0 72 | default_deration_key: str = "--default-duration" 73 | default_deration_value: str = f"{default_index}:{output_fps}fps" 74 | fix_timing_info_key: str = "--fix-bitstream-timing-information" 75 | 76 | cmd_param_list: list = [ 77 | mkvmerge_exe_filepath, 78 | output_key, 79 | output_value, 80 | default_deration_key, 81 | default_deration_value, 82 | fix_timing_info_key, 83 | str(default_index), 84 | filepath, 85 | ] 86 | print(cmd_param_list, file=sys.stderr) 87 | 88 | mkvmerge_param_debug_str: str = ( 89 | f"multiplex mkvmerge: param:" 90 | f"{subprocess.list2cmdline(cmd_param_list)}" 91 | ) 92 | print(mkvmerge_param_debug_str, file=sys.stderr) 93 | g_logger.log(logging.DEBUG, mkvmerge_param_debug_str) 94 | 95 | start_info_str: str = ( 96 | f"multiplex mkvmerge: starting multiplexing {output_filepath}" 97 | ) 98 | 99 | print(start_info_str, file=sys.stderr) 100 | g_logger.log(logging.INFO, start_info_str) 101 | process = subprocess.Popen( 102 | cmd_param_list, 103 | stdout=subprocess.PIPE, 104 | text=True, 105 | encoding="utf-8", 106 | errors="ignore", 107 | ) 108 | 109 | stdout_lines: list = [] 110 | while process.poll() is None: 111 | stdout_line = process.stdout.readline() 112 | stdout_lines.append(stdout_line) 113 | print(stdout_line, end="", file=sys.stderr) 114 | 115 | return_code = process.returncode 116 | 117 | if return_code == 0: 118 | end_info_str: str = ( 119 | f"multiplex mkvmerge: " 120 | f"multiplex {output_filepath} successfully." 121 | ) 122 | print(end_info_str, file=sys.stderr) 123 | g_logger.log(logging.INFO, end_info_str) 124 | elif return_code == 1: 125 | warning_prefix = "Warning:" 126 | warning_text_str = "".join( 127 | line for line in stdout_lines if line.startswith(warning_prefix) 128 | ) 129 | stdout_text_str = "".join(stdout_lines) 130 | warning_str: str = ( 131 | "multiplex mkvmerge: " 132 | "mkvmerge has output at least one warning, " 133 | "but muxing did continue.\n" 134 | f"warning:\n{warning_text_str}" 135 | f"stdout:\n{stdout_text_str}" 136 | ) 137 | print(warning_str, file=sys.stderr) 138 | g_logger.log(logging.WARNING, warning_str) 139 | else: 140 | error_str = ( 141 | f"multiplex mkvmerge: " 142 | f"multiplex {output_filepath} unsuccessfully." 143 | ) 144 | print(error_str, file=sys.stderr) 145 | raise subprocess.CalledProcessError( 146 | returncode=return_code, 147 | cmd=subprocess.list2cmdline(cmd_param_list), 148 | output=stdout_text_str, 149 | ) 150 | 151 | return output_filepath 152 | 153 | 154 | def dir_avc_fps_conversion( 155 | input_dir: str, output_fps: str, output_filename_suffix="_fps_revise" 156 | ): 157 | video_extension_set: set = {".mkv", ".mp4"} 158 | video_filename_set: set = set() 159 | for full_filename in os.listdir(input_dir): 160 | filename, extension = os.path.splitext(full_filename) 161 | if extension in video_extension_set: 162 | video_filename_set.add(full_filename) 163 | 164 | for full_filename in video_filename_set: 165 | input_filepath = os.path.join(input_dir, full_filename) 166 | print(input_filepath) 167 | filename, extension = os.path.splitext(full_filename) 168 | new_filename = filename + output_filename_suffix 169 | print(new_filename) 170 | avc_fps_conversion( 171 | filepath=input_filepath, 172 | ouput_dir=input_dir, 173 | output_filename=new_filename, 174 | output_fps=output_fps, 175 | ) 176 | 177 | 178 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | pymediainfo==4.0 2 | pandas 3 | numpy 4 | iso-639 5 | chardet 6 | pysubs2 7 | fontTools 8 | pywin32 9 | pyyaml 10 | pyhocon --------------------------------------------------------------------------------