├── 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 | [](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
--------------------------------------------------------------------------------