diff --git a/CHANGELOG.md b/CHANGELOG.md index 4d8a603f..7b2ea97c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,19 @@ # Changelog 更新日志 +## 0.0.11 +> 2024-04-19 + +- 支持多显示器与单个显示器录制; +- 添加了录制时的编码选项(cpu_h264, cpu_h265, NVIDIA_h265, AMD_h265, SVT-AV1); +- 优化了索引时比较图像的性能; + +- Supports multi-monitor and single-monitor recording; +- Added encoding options when recording (cpu_h264, cpu_h265, NVIDIA_h265, AMD_h265, SVT-AV1); +- Optimized the performance of comparing images during indexing; + +### Fixed +- bug: 当锁屏时程序有几率不会进入空闲暂停状态;There is a chance that the program will not enter the idle pause state when the screen is locked; + ## 0.0.10 > 2024-03-03 diff --git a/__assets__/how-it-work-en.jpg b/__assets__/how-it-work-en.jpg index e1fbeca4..a0e5b682 100644 Binary files a/__assets__/how-it-work-en.jpg and b/__assets__/how-it-work-en.jpg differ diff --git a/__assets__/how-it-work-sc.jpg b/__assets__/how-it-work-sc.jpg index b685db2a..c45702dc 100644 Binary files a/__assets__/how-it-work-sc.jpg and b/__assets__/how-it-work-sc.jpg differ diff --git a/__assets__/workflow-en.png b/__assets__/workflow-en.png index 54b8077f..4703ec1e 100644 Binary files a/__assets__/workflow-en.png and b/__assets__/workflow-en.png differ diff --git a/__assets__/workflow-ja.png b/__assets__/workflow-ja.png index 6ef01579..4703ec1e 100644 Binary files a/__assets__/workflow-ja.png and b/__assets__/workflow-ja.png differ diff --git a/__assets__/workflow-sc.png b/__assets__/workflow-sc.png index 2d3aecdf..cbabb187 100644 Binary files a/__assets__/workflow-sc.png and b/__assets__/workflow-sc.png differ diff --git a/extension/i18n_create_synonyms_embedding_asset/meta.json b/extension/i18n_create_synonyms_embedding_asset/meta.json index 8999e4d5..6df6b5ad 100644 --- a/extension/i18n_create_synonyms_embedding_asset/meta.json +++ b/extension/i18n_create_synonyms_embedding_asset/meta.json @@ -1,5 +1,5 @@ { - "extension_name": "Create synonyms for i18n", + "extension_name": "[for i18n contributor] Create synonyms for i18n", "developer_name": "antonoko", "developer_url": "https://github.com/Antonoko", "version": "0.0.1", diff --git a/onboard_setting.py b/onboard_setting.py index fe4e3396..d0399786 100644 --- a/onboard_setting.py +++ b/onboard_setting.py @@ -7,26 +7,34 @@ from windrecorder import utils from windrecorder.config import config -if os.path.exists(config.tray_lock_path): - with open(config.tray_lock_path, encoding="utf-8") as f: - check_pid = int(f.read()) - - tray_is_running = utils.is_process_running(check_pid, compare_process_name="python.exe") - if tray_is_running: - subprocess.run("cls", shell=True) - print("Windrecorder seems to be running, please try to close it and retry.") - print("捕风记录仪似乎正在运行,请尝试关闭后重试。") - print() - print(f"PID: {check_pid}") - sys.exit() - else: - try: - os.remove(config.tray_lock_path) - except FileNotFoundError: - pass +# ------------------------------------------------------------------ + + +def check_is_running(): + if os.path.exists(config.tray_lock_path): + with open(config.tray_lock_path, encoding="utf-8") as f: + check_pid = int(f.read()) + tray_is_running = utils.is_process_running(check_pid, compare_process_name="python.exe") + if tray_is_running: + subprocess.run("cls", shell=True) + print("Windrecorder seems to be running, please try to close it and retry.") + print("捕风记录仪似乎正在运行,请尝试关闭后重试。") + print() + print(f"PID: {check_pid}") + sys.exit() + else: + try: + os.remove(config.tray_lock_path) + except FileNotFoundError: + pass + + +check_is_running() + +# ------------------------------------------------------------------ -import windrecorder.upgrade_migration_routine as upgrade_migration_routine +import windrecorder.upgrade_migration_routine as upgrade_migration_routine # NOQA: E402 # 清理早期版本的旧设定。需要在多余包被 import 进来被占用文件前处理。 try: @@ -37,11 +45,14 @@ # ------------------------------------------------------------------ -from windrecorder import file_utils, ocr_manager, record -from windrecorder.utils import get_text as _t +print("Loading onboard setting, please stand by...") + +from windrecorder import file_utils, ocr_manager # NOQA: E402 +from windrecorder.utils import get_text as _t # NOQA: E402 # 全部向导的步骤数 ALLSTEPS = 6 +subprocess.run("color 06", shell=True) # 清理缓存 if os.path.exists("cache"): @@ -76,224 +87,243 @@ def config_indicator(config_element, expect_result): # ======================================================== -subprocess.run("color 06", shell=True) # 设置语言 -while True: - print_header(step=1) - print("First, please choose your interface language. (Enter the number option and press Enter to confirm.)") - print("首先,请设置你的界面语言。(输入数字项后回车确认)") - divider() - print( - f""" - 1. English {config_indicator(config.lang,"en")} - 2. 简体中文 {config_indicator(config.lang,"sc")} - 3. 日本語 {config_indicator(config.lang,"ja")} - """ - ) - input_lang_num = input("> ") - - if input_lang_num == "1": - config.set_and_save_config("lang", "en") - lang = "en" - print("The interface language is set to English") - break - if input_lang_num == "2": - config.set_and_save_config("lang", "sc") - lang = "sc" - print("界面语言已设定为:简体中文") - break - if input_lang_num == "3": - config.set_and_save_config("lang", "ja") - lang = "ja" - print("インターフェース言語は日本語に設定されています。") - break - else: - print(_t("qs_la_text_same_as_previous")) - break - -divider() -subprocess.run("pause", shell=True) +def set_lang(): + while True: + print_header(step=1) + print("First, please choose your interface language. (Enter the number option and press Enter to confirm.)") + print("首先,请设置你的界面语言。(输入数字项后回车确认)") + divider() + print( + f""" + 1. English {config_indicator(config.lang,"en")} + 2. 简体中文 {config_indicator(config.lang,"sc")} + 3. 日本語 {config_indicator(config.lang,"ja")} + """ + ) + input_lang_num = input("> ") + + if input_lang_num == "1": + config.set_and_save_config("lang", "en") + print("The interface language is set to English") + break + if input_lang_num == "2": + config.set_and_save_config("lang", "sc") + print("界面语言已设定为:简体中文") + break + if input_lang_num == "3": + config.set_and_save_config("lang", "ja") + print("インターフェース言語は日本語に設定されています。") + break + else: + print(_t("qs_la_text_same_as_previous")) + break # 设置用户名 -while True: - print_header(step=2) - if config.user_name == "default": - sys_username = getpass.getuser() # 如果为默认用户名,获取当前系统的用户名 - else: - sys_username = config.user_name # 如果配置文件已有自定义用户名,读取之前的用户名 - print(_t("qs_un_set_your_username")) - print(_t("qs_un_describe").format(sys_username=sys_username)) - divider() - - your_username = input("> ") - - if len(your_username) > 20: - print(_t("qs_un_longer_than_expect")) +def set_username(): + while True: + print_header(step=2) + if config.user_name == "default": + sys_username = getpass.getuser() # 如果为默认用户名,获取当前系统的用户名 + else: + sys_username = config.user_name # 如果配置文件已有自定义用户名,读取之前的用户名 + print(_t("qs_un_use_current_name").format(sys_username=sys_username)) + break # 如果已设定用户名,此项设置跳过以避免误操作 + + print(_t("qs_un_set_your_username")) + print(_t("qs_un_describe").format(sys_username=sys_username)) divider() - subprocess.run("pause", shell=True) - elif len(your_username) == 0: - print(_t("qs_un_use_current_name").format(sys_username=sys_username)) - config.set_and_save_config("user_name", sys_username) - break - else: - print(_t("qs_un_use_custom_name").format(your_username=your_username)) - config.set_and_save_config("user_name", your_username) - break + your_username = input("> ") -divider() -subprocess.run("pause", shell=True) + if len(your_username) > 20: + print(_t("qs_un_longer_than_expect")) + divider() + subprocess.run("pause", shell=True) + elif len(your_username) == 0: + print(_t("qs_un_use_current_name").format(sys_username=sys_username)) + config.set_and_save_config("user_name", sys_username) + break + else: + print(_t("qs_un_use_custom_name").format(your_username=your_username)) + config.set_and_save_config("user_name", your_username) + break # 选择可ocr的语言 -os_support_lang = utils.get_os_support_lang() +def set_ocr_lang(): + os_support_lang = utils.get_os_support_lang() + + if len(os_support_lang) > 1: # 如果系统安装了超过一种语言 + while True: + print_header(step=3) + print(_t("qs_olang_intro")) + utils.print_numbered_list(os_support_lang) + divider() + + try: + input_ocr_lang_num = int(input("> ")) + + if 0 < input_ocr_lang_num <= len(os_support_lang): + config.set_and_save_config("ocr_lang", os_support_lang[input_ocr_lang_num - 1]) + print( + _t("qs_olang_ocrlang_set_to"), + os_support_lang[input_ocr_lang_num - 1], + ) + break + + except ValueError: + print(_t("qs_olang_error")) + subprocess.run("pause", shell=True) -if len(os_support_lang) > 1: # 如果系统安装了超过一种语言 - while True: + else: # 如果系统只安装了一种语言,自动选择 print_header(step=3) - print(_t("qs_olang_intro")) - utils.print_numbered_list(os_support_lang) - divider() + print(_t("qs_olang_one_choice_default_set").format(os_support_lang=os_support_lang[0])) + config.set_and_save_config("ocr_lang", os_support_lang[0]) - try: - input_ocr_lang_num = int(input("> ")) - if input_ocr_lang_num <= len(os_support_lang): - config.set_and_save_config("ocr_lang", os_support_lang[input_ocr_lang_num - 1]) - print( - _t("qs_olang_ocrlang_set_to"), - os_support_lang[input_ocr_lang_num - 1], +# 测试与设置 ocr 引擎 +def set_ocr_engine(): + test_img_filepath = "__assets__\\OCR_test_1080_" + config.ocr_lang + ".png" # 读取测试图像 + if not os.path.exists(test_img_filepath): + test_img_filepath = "OCR_test_1080_en-US.png" # fallback 读取为英文图像 + + with open("__assets__\\OCR_test_1080_words_" + config.ocr_lang + ".txt", encoding="utf-8") as f: # 读取比对参考文本 + ocr_text_refer = f.read() + ocr_text_refer = utils.wrap_text_by_remove_break(ocr_text_refer) + + # 测试COL - 已废弃 + # try: + # time_cost_col = time.time() + # ocr_result_col = ocr_manager.ocr_image_col(test_img_filepath) + # time_cost_col = time.time() - time_cost_col + # ocr_result_col = utils.wrap_text_by_remove_break(ocr_result_col) + # _, ocr_correct_col = ocr_manager.compare_strings(ocr_result_col, ocr_text_refer) + + # except Exception as e: + # ocr_result_col = "" + # print(e) + + # 测试ms ocr + time_cost_ms = time.time() + ocr_result_ms = ocr_manager.ocr_image_ms(test_img_filepath) + time_cost_ms = time.time() - time_cost_ms + ocr_result_ms = utils.wrap_text_by_remove_break(ocr_result_ms) + _, ocr_correct_ms = ocr_manager.compare_strings(ocr_result_ms, ocr_text_refer) + + while True: + print_header(step=3) + print(_t("qs_ocr_title")) + + if ocr_result_ms: + print("- Windows.Media.Ocr.Cli OCR languages: ", config.ocr_lang) + print( + _t("qs_ocr_result_describe").format( + accuracy=ocr_correct_ms, + timecost=time_cost_ms, + timecost_15=utils.convert_seconds_to_hhmmss(int(time_cost_ms * 350)), ) - subprocess.run("pause", shell=True) + ) + if ocr_correct_ms < 50: + print(_t("qs_ocr_tips_low_accuracy")) + + break + + +def set_display(): + # 设置显示器录制选项 + display_count = utils.get_display_count() + display_info = utils.get_display_info() + display_info_formatted = utils.get_display_info_formatted() + + if display_count > 1: + while True: + print_header(step=4) + print(_t("qs_mo_describe_all")) + print( + f""" + 1. {_t('qs_mo_option_all')} {config_indicator(config.multi_display_record_strategy,"all")} + 2. {_t('qs_mo_option_single')} {config_indicator(config.multi_display_record_strategy,"single")} + """ + ) + divider() + + record_strategy_num = input("> ") + if record_strategy_num == "1": + config.set_and_save_config("multi_display_record_strategy", "all") + print(f"{_t('qs_mo_set_to')} {_t('qs_mo_option_all')}") + break + elif record_strategy_num == "2": + config.set_and_save_config("multi_display_record_strategy", "single") + break + elif len(record_strategy_num) == 0: # set same as before + if config.multi_display_record_strategy == "single": + record_strategy_num = "2" + elif config.multi_display_record_strategy == "all": + print(f"{_t('qs_mo_set_to')} {_t('qs_mo_option_all')}") break - except ValueError: - print(_t("qs_olang_error")) - subprocess.run("pause", shell=True) + while True: # config record which single display + if record_strategy_num == "2": + print_header(step=4) + print(f"{_t('qs_mo_set_to')} {_t('qs_mo_option_single')}") + print(_t("qs_mo_choose_one_display")) + utils.print_numbered_list(display_info_formatted) + divider() + + try: + display_index = int(input("> ")) + if 0 < display_index <= display_count: + config.set_and_save_config("record_single_display_index", display_index) + print(f"{_t('qs_mo_record_single')} {display_info_formatted[display_index-1]}") + break + else: + print(_t("qs_olang_error")) + subprocess.run("pause", shell=True) + + except ValueError: + print(_t("qs_olang_error")) + subprocess.run("pause", shell=True) + else: + break -else: # 如果系统只安装了一种语言,自动选择 - print_header(step=3) - print(_t("qs_olang_one_choice_default_set").format(os_support_lang=os_support_lang[0])) - config.set_and_save_config("ocr_lang", os_support_lang[0]) - subprocess.run("pause", shell=True) + else: + print_header(step=4) + print(_t("qs_mo_describe_single").format(width=display_info[0]["width"], height=display_info[0]["height"])) + print(_t("qs_mo_cta")) -# 测试与设置 ocr 引擎 -test_img_filepath = "__assets__\\OCR_test_1080_" + config.ocr_lang + ".png" # 读取测试图像 -if not os.path.exists(test_img_filepath): - test_img_filepath = "OCR_test_1080_en-US.png" # fallback 读取为英文图像 - -with open("__assets__\\OCR_test_1080_words_" + config.ocr_lang + ".txt", encoding="utf-8") as f: # 读取比对参考文本 - ocr_text_refer = f.read() - ocr_text_refer = utils.wrap_text_by_remove_break(ocr_text_refer) - - -# 测试COL - 已废弃 -# if config.enable_ocr_chineseocr_lite_onnx: -# try: -# time_cost_col = time.time() -# ocr_result_col = ocr_manager.ocr_image_col(test_img_filepath) -# time_cost_col = time.time() - time_cost_col -# ocr_result_col = utils.wrap_text_by_remove_break(ocr_result_col) -# _, ocr_correct_col = ocr_manager.compare_strings(ocr_result_col, ocr_text_refer) - -# except Exception as e: -# ocr_result_col = "" -# print(e) -# else: -# print("enable_ocr_chineseocr_lite_onnx disabled.") -# ocr_result_col = "" - - -# 测试ms ocr -time_cost_ms = time.time() -ocr_result_ms = ocr_manager.ocr_image_ms(test_img_filepath) -time_cost_ms = time.time() - time_cost_ms -ocr_result_ms = utils.wrap_text_by_remove_break(ocr_result_ms) -_, ocr_correct_ms = ocr_manager.compare_strings(ocr_result_ms, ocr_text_refer) - - -while True: - print_header(step=3) - print(_t("qs_ocr_title")) - - if ocr_result_ms: - print("- Windows.Media.Ocr.Cli OCR languages: ", config.ocr_lang) - print( - _t("qs_ocr_result_describe").format( - accuracy=ocr_correct_ms, - timecost=time_cost_ms, - timecost_15=utils.convert_seconds_to_hhmmss(int(time_cost_ms * 350)), - ) - ) - if ocr_correct_ms < 50: - print(_t("qs_ocr_tips_low_accuracy")) - - break - - # if ocr_result_col: - # print("- chineseocr_lite_onnx") - # # print("准确率:", ocr_correct_col, ",识别时间:", time_cost_col, ",索引15分钟视频约用时:", utils.convert_seconds_to_hhmmss(int(time_cost_col*350))) - # print(_t("qs_ocr_result_describe").format(accuracy=ocr_correct_col, timecost=time_cost_col , timecost_15=utils.convert_seconds_to_hhmmss(int(time_cost_col*350)))) - # else: - # print(_t("qs_ocr_describe_disable_clo")) - - # divider() - # print(_t("qs_ocr_describe")) - # print(_t("qs_ocr_cta")) - # print("1. Windows.Media.Ocr.Cli", _t("qs_ocr_option_recommand"), "\n2. chineseocr_lite_onnx") - # input_lang_num = input('> ') - - # if input_lang_num == '2': - # if config.enable_ocr_chineseocr_lite_onnx: - # config.set_and_save_config("ocr_engine", "chineseocr_lite_onnx") - # print(_t("qs_ocr_option_recommand"), "chineseocr_lite_onnx") - # else: - # config.set_and_save_config("ocr_engine", "Windows.Media.Ocr.Cli") - # print(_t("qs_ocr_engine_chosen"), "Windows.Media.Ocr.Cli") - # break - # else: - # config.set_and_save_config("ocr_engine", "Windows.Media.Ocr.Cli") - # print(_t("qs_ocr_engine_chosen"), "Windows.Media.Ocr.Cli") - # break - -divider() -subprocess.run("pause", shell=True) - - -# 设置显示器录制选项 -while True: - print_header(step=4) - print(_t("qs_mo_describe")) - - monitor_width = utils.get_screen_resolution().width - monitor_height = utils.get_screen_resolution().height - scale_width, scale_height = record.get_scale_screen_res_strategy(origin_width=monitor_width, origin_height=monitor_height) - - print(_t("qs_mo_detect").format(monitor_width=monitor_width, monitor_height=monitor_height)) - print(_t("qs_mo_cta")) - break - -divider() -subprocess.run("pause", shell=True) - -# 扩展介绍 -while True: - print_header(step=5) - print(_t("qs_et_describe")) - print() - for key, value in file_utils.get_extension().items(): - print(f" - {key}") - break - -divider() -subprocess.run("pause", shell=True) - -# 完成初始化设定 -print_header(step=6) -print(_t("qs_end_describe")) -print(_t("qs_end_slogan")) -print(_t("qs_end_feedback")) -divider() +def set_extension(): + # 扩展介绍 + while True: + print_header(step=5) + print(_t("qs_et_describe")) + print() + for key, value in file_utils.get_extension().items(): + print(f" - {key}") + break + + +def finish_setting(): + # 完成初始化设定 + print_header(step=6) + print(_t("qs_end_describe")) + print(_t("qs_end_slogan")) + print(_t("qs_end_feedback")) + + +# ======================================================== + + +def set_main(): + setting_step_functions = [set_lang, set_username, set_ocr_lang, set_ocr_engine, set_display, set_extension, finish_setting] + for f in setting_step_functions: + f() + divider() + subprocess.run("pause", shell=True) + + +if __name__ == "__main__": + set_main() diff --git a/poetry.lock b/poetry.lock index b240d900..59666400 100644 --- a/poetry.lock +++ b/poetry.lock @@ -1509,26 +1509,6 @@ type = "legacy" url = "https://pypi.tuna.tsinghua.edu.cn/simple" reference = "tsinghua" -[[package]] -name = "mouseinfo" -version = "0.1.3" -description = "An application to display XY position and RGB color information for the pixel currently under the mouse. Works on Python 2 and 3." -optional = false -python-versions = "*" -files = [ - {file = "MouseInfo-0.1.3.tar.gz", hash = "sha256:2c62fb8885062b8e520a3cce0a297c657adcc08c60952eb05bc8256ef6f7f6e7"}, -] - -[package.dependencies] -pyperclip = "*" -python3-Xlib = {version = "*", markers = "platform_system == \"Linux\" and python_version >= \"3.0\""} -rubicon-objc = {version = "*", markers = "platform_system == \"Darwin\""} - -[package.source] -type = "legacy" -url = "https://pypi.tuna.tsinghua.edu.cn/simple" -reference = "tsinghua" - [[package]] name = "mpmath" version = "1.3.0" @@ -1551,6 +1531,22 @@ type = "legacy" url = "https://pypi.tuna.tsinghua.edu.cn/simple" reference = "tsinghua" +[[package]] +name = "mss" +version = "9.0.1" +description = "An ultra fast cross-platform multiple screenshots module in pure python using ctypes." +optional = false +python-versions = ">=3.8" +files = [ + {file = "mss-9.0.1-py3-none-any.whl", hash = "sha256:7ee44db7ab14cbea6a3eb63813c57d677a109ca5979d3b76046e4bddd3ca1a0b"}, + {file = "mss-9.0.1.tar.gz", hash = "sha256:6eb7b9008cf27428811fa33aeb35f3334db81e3f7cc2dd49ec7c6e5a94b39f12"}, +] + +[package.source] +type = "legacy" +url = "https://pypi.tuna.tsinghua.edu.cn/simple" +reference = "tsinghua" + [[package]] name = "nest-asyncio" version = "1.6.0" @@ -2159,31 +2155,6 @@ type = "legacy" url = "https://pypi.tuna.tsinghua.edu.cn/simple" reference = "tsinghua" -[[package]] -name = "pyautogui" -version = "0.9.54" -description = "PyAutoGUI lets Python control the mouse and keyboard, and other GUI automation tasks. For Windows, macOS, and Linux, on Python 3 and 2." -optional = false -python-versions = "*" -files = [ - {file = "PyAutoGUI-0.9.54.tar.gz", hash = "sha256:dd1d29e8fd118941cb193f74df57e5c6ff8e9253b99c7b04f39cfc69f3ae04b2"}, -] - -[package.dependencies] -mouseinfo = "*" -pygetwindow = ">=0.0.5" -pymsgbox = "*" -pyobjc-core = {version = "*", markers = "platform_system == \"Darwin\""} -pyobjc-framework-quartz = {version = "*", markers = "platform_system == \"Darwin\""} -pyscreeze = ">=0.1.21" -python3-Xlib = {version = "*", markers = "platform_system == \"Linux\" and python_version >= \"3.0\""} -pytweening = ">=1.0.4" - -[package.source] -type = "legacy" -url = "https://pypi.tuna.tsinghua.edu.cn/simple" -reference = "tsinghua" - [[package]] name = "pyclipper" version = "1.3.0.post5" @@ -2320,21 +2291,6 @@ type = "legacy" url = "https://pypi.tuna.tsinghua.edu.cn/simple" reference = "tsinghua" -[[package]] -name = "pymsgbox" -version = "1.0.9" -description = "A simple, cross-platform, pure Python module for JavaScript-like message boxes." -optional = false -python-versions = "*" -files = [ - {file = "PyMsgBox-1.0.9.tar.gz", hash = "sha256:2194227de8bff7a3d6da541848705a155dcbb2a06ee120d9f280a1d7f51263ff"}, -] - -[package.source] -type = "legacy" -url = "https://pypi.tuna.tsinghua.edu.cn/simple" -reference = "tsinghua" - [[package]] name = "pyobjc-core" version = "10.1" @@ -2424,21 +2380,6 @@ type = "legacy" url = "https://pypi.tuna.tsinghua.edu.cn/simple" reference = "tsinghua" -[[package]] -name = "pyperclip" -version = "1.8.2" -description = "A cross-platform clipboard module for Python. (Only handles plain text for now.)" -optional = false -python-versions = "*" -files = [ - {file = "pyperclip-1.8.2.tar.gz", hash = "sha256:105254a8b04934f0bc84e9c24eb360a591aaf6535c9def5f29d92af107a9bf57"}, -] - -[package.source] -type = "legacy" -url = "https://pypi.tuna.tsinghua.edu.cn/simple" -reference = "tsinghua" - [[package]] name = "pyreadline3" version = "3.4.1" @@ -2470,27 +2411,6 @@ type = "legacy" url = "https://pypi.tuna.tsinghua.edu.cn/simple" reference = "tsinghua" -[[package]] -name = "pyscreeze" -version = "0.1.30" -description = "A simple, cross-platform screenshot module for Python 2 and 3." -optional = false -python-versions = "*" -files = [ - {file = "PyScreeze-0.1.30.tar.gz", hash = "sha256:74098ad048e76a6231dcfa6243343af94459b8c829f9ccb7a44a5d3b147a67d1"}, -] - -[package.dependencies] -Pillow = [ - {version = ">=9.2.0", markers = "python_version == \"3.10\""}, - {version = ">=9.3.0", markers = "python_version == \"3.11\""}, -] - -[package.source] -type = "legacy" -url = "https://pypi.tuna.tsinghua.edu.cn/simple" -reference = "tsinghua" - [[package]] name = "pyshortcuts" version = "1.9.0" @@ -2569,36 +2489,6 @@ type = "legacy" url = "https://pypi.tuna.tsinghua.edu.cn/simple" reference = "tsinghua" -[[package]] -name = "python3-xlib" -version = "0.15" -description = "Python3 X Library" -optional = false -python-versions = "*" -files = [ - {file = "python3-xlib-0.15.tar.gz", hash = "sha256:dc4245f3ae4aa5949c1d112ee4723901ade37a96721ba9645f2bfa56e5b383f8"}, -] - -[package.source] -type = "legacy" -url = "https://pypi.tuna.tsinghua.edu.cn/simple" -reference = "tsinghua" - -[[package]] -name = "pytweening" -version = "1.2.0" -description = "A collection of tweening (aka easing) functions." -optional = false -python-versions = "*" -files = [ - {file = "pytweening-1.2.0.tar.gz", hash = "sha256:243318b7736698066c5f362ec5c2b6434ecf4297c3c8e7caa8abfe6af4cac71b"}, -] - -[package.source] -type = "legacy" -url = "https://pypi.tuna.tsinghua.edu.cn/simple" -reference = "tsinghua" - [[package]] name = "pytz" version = "2024.1" @@ -2999,26 +2889,6 @@ type = "legacy" url = "https://pypi.tuna.tsinghua.edu.cn/simple" reference = "tsinghua" -[[package]] -name = "rubicon-objc" -version = "0.4.7" -description = "A bridge between an Objective C runtime environment and Python." -optional = false -python-versions = ">=3.8" -files = [ - {file = "rubicon-objc-0.4.7.tar.gz", hash = "sha256:be937d864bd1229f860defabb89b40c53634eedc36448d89ad3c14eb3286e509"}, - {file = "rubicon_objc-0.4.7-py3-none-any.whl", hash = "sha256:f37108e35d5da1a78ab3eed2d03b095934f5f618329a939e4bd2ada9894eff6e"}, -] - -[package.extras] -dev = ["pre-commit (==3.5.0)", "pytest (==7.4.2)", "pytest-tldr (==0.2.5)", "setuptools-scm[toml] (==8.0.4)", "tox (==4.11.3)"] -docs = ["furo (==2023.9.10)", "pyenchant (==3.2.2)", "sphinx (==7.1.2)", "sphinx (==7.2.6)", "sphinx-autobuild (==2021.3.14)", "sphinx-copybutton (==0.5.2)", "sphinx-tabs (==3.4.1)", "sphinxcontrib-spelling (==8.0.0)"] - -[package.source] -type = "legacy" -url = "https://pypi.tuna.tsinghua.edu.cn/simple" -reference = "tsinghua" - [[package]] name = "scikit-image" version = "0.22.0" @@ -3800,4 +3670,4 @@ reference = "tsinghua" [metadata] lock-version = "2.0" python-versions = ">=3.10,<3.12" -content-hash = "8a784cff5b10b85f7cd2bb20d2aa1c9b8c320cada687546e30eb8d247315905b" +content-hash = "4be4bde883c3ea44b9e5f56050798ad0f276165c8e1a7b4bf315c05149043fa7" diff --git a/pyproject.toml b/pyproject.toml index 18184aec..32bf3d31 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -14,7 +14,6 @@ numpy = "^1.24.2" onnxruntime = "~1.15.1" opencv_python = "~4.5.5.64" pandas = "^2.0.3" -PyAutoGUI = "^0.9.54" pyclipper = "~1.3.0.post4" pyshortcuts = "^1.9.0" pywin32 = "~306" @@ -30,6 +29,8 @@ scikit-image = "^0.22.0" faiss-cpu = "^1.7.4" tqdm = "^4.65.0" streamlit-tags = "^1.2.8" +mss = "^9.0.1" +pygetwindow = "^0.0.9" [tool.poetry.group.dev.dependencies] pre-commit = "^3.5.0" diff --git a/record_screen.py b/record_screen.py index 5fa212fd..18c8cd6d 100644 --- a/record_screen.py +++ b/record_screen.py @@ -8,16 +8,10 @@ import time from os import getpid +import mss import numpy as np -import pyautogui - -from windrecorder import ( # wordcloud, - file_utils, - ocr_manager, - record, - record_wintitle, - utils, -) + +from windrecorder import file_utils, ocr_manager, record, record_wintitle, utils from windrecorder.config import config from windrecorder.exceptions import LockExistsException from windrecorder.lock import FileLock @@ -110,69 +104,6 @@ def index_video_data(video_saved_dir, vid_file_name): logger.warning(f"--{full_path} ocr is already in process.") -# 录制屏幕 -def record_screen( - output_dir=config.record_videos_dir_ud, - record_time=config.record_seconds, -): - """ - 用ffmpeg持续录制屏幕,每15分钟保存一个视频文件 - """ - # 构建输出文件名 - now = datetime.datetime.now() - video_out_name = now.strftime("%Y-%m-%d_%H-%M-%S") + ".mp4" - output_dir_with_date = now.strftime("%Y-%m") # 将视频存储在日期月份子目录下 - video_saved_dir = os.path.join(output_dir, output_dir_with_date) - file_utils.ensure_dir(video_saved_dir) - out_path = os.path.join(video_saved_dir, video_out_name) - - # 获取屏幕分辨率并根据策略决定缩放 - screen_width, screen_height = utils.get_screen_resolution() - target_scale_width, target_scale_height = record.get_scale_screen_res_strategy( - origin_width=screen_width, origin_height=screen_height - ) - logger.info( - f"Origin screen resolution: {screen_width}x{screen_height}, Resized to {target_scale_width}x{target_scale_height}." - ) - - pix_fmt_args = ["-pix_fmt", "yuv420p"] - - ffmpeg_cmd = [ - config.ffmpeg_path, - "-f", - "gdigrab", - "-video_size", - f"{screen_width}x{screen_height}", - "-framerate", - f"{config.record_framerate}", - "-i", - "desktop", - "-vf", - f"scale={target_scale_width}:{target_scale_height}", - # 默认使用编码成 h264 格式 - "-c:v", - "libx264", - # 默认码率为 200kbps - "-b:v", - f"{config.record_bitrate}k", - *pix_fmt_args, - "-t", - str(record_time), - out_path, - ] - - # 执行命令 - try: - logger.info(f"record_screen: ffmpeg cmd: {ffmpeg_cmd}") - # 运行ffmpeg - subprocess.run(ffmpeg_cmd, check=True) - logger.info("Windrecorder: Start Recording via FFmpeg") - return video_saved_dir, video_out_name - except subprocess.CalledProcessError as ex: - logger.error(f"Windrecorder: {ex.cmd} failed with return code {ex.returncode}") - return video_saved_dir, video_out_name - - # 持续录制屏幕的主要线程 def continuously_record_screen(): global last_idle_maintain_time @@ -202,7 +133,7 @@ def continuously_record_screen(): time.sleep(10) else: subprocess.run("color 2f", shell=True) # 设定背景色为活动 - video_saved_dir, video_out_name = record_screen() # 录制屏幕 + video_saved_dir, video_out_name = record.record_screen() # 录制屏幕 # 自动索引策略 if config.OCR_index_strategy == 1: @@ -222,38 +153,52 @@ def continuously_record_screen(): # 每隔一段截图对比是否屏幕内容缺少变化 def monitor_compare_screenshot(): - while True: - if utils.is_screen_locked() or not utils.is_system_awake(): - logger.info("Windrecorder: Screen locked / System not awaked") - else: - try: - global monitor_idle_minutes - global last_screenshot_array - - while True: - similarity = None - screenshot = pyautogui.screenshot() - screenshot_array = np.array(screenshot) - - if last_screenshot_array is not None: - similarity = ocr_manager.compare_image_similarity_np(last_screenshot_array, screenshot_array) - - if similarity > 0.9: # 对比检测阈值 - monitor_idle_minutes += 0.5 + with mss.mss() as sct: + while True: + global monitor_idle_minutes + global last_screenshot_array + if utils.is_screen_locked() or not utils.is_system_awake(): + logger.info("Windrecorder: Screen locked / System not awaked") + monitor_idle_minutes += 0.5 + else: + try: + while True: + similarity = None + screenshot_array = [] + + if config.multi_display_record_strategy == "single" and config.record_single_display_index < len( + sct.monitors + ): + screenshot = sct.grab(sct.monitors[config.record_single_display_index]) + logger.debug(f"{sct.monitors[config.record_single_display_index]=}") + screenshot_array.append(np.array(screenshot)) else: - monitor_idle_minutes = 0 - - last_screenshot_array = screenshot_array.copy() - logger.info(f"monitor_idle_minutes:{monitor_idle_minutes}, similarity:{similarity}") - time.sleep(30) - except Exception as e: - logger.warning(f"{str(e)}") - if "batchDistance" in str(e): # 如果是抓不到画面导致出错,可以认为是进入了休眠等情况 - monitor_idle_minutes += 0.5 - else: - monitor_idle_minutes = 0 - - time.sleep(5) + for monitor in sct.monitors[1:]: + screenshot = sct.grab(monitor) + logger.debug(f"{monitor=}") + screenshot_array.append(np.array(screenshot)) + + if last_screenshot_array is not None: + similarity = [] + for last_screen, now_screen in zip(last_screenshot_array, screenshot_array): + similarity.append(ocr_manager.compare_image_similarity_np(last_screen, now_screen)) + + if all(sim > 0.90 for sim in similarity): # 对比检测阈值 + monitor_idle_minutes += 0.5 + else: + monitor_idle_minutes = 0 + + last_screenshot_array = screenshot_array.copy() + logger.info(f"monitor_idle_minutes:{monitor_idle_minutes}, similarity:{similarity}") + time.sleep(30) + except Exception as e: + logger.warning(f"Error occurred:{str(e)}") + if "batchDistance" in str(e): # 如果是抓不到画面导致出错,可以认为是进入了休眠等情况 + monitor_idle_minutes += 0.5 + else: + monitor_idle_minutes = 0 + + time.sleep(5) # 定时记录前台窗口标题页名 diff --git a/windrecorder/config.py b/windrecorder/config.py index 1a9de4d4..70fbff65 100644 --- a/windrecorder/config.py +++ b/windrecorder/config.py @@ -11,11 +11,14 @@ CONFIG_NAME_USER = "config_user.json" CONFIG_NAME_DEFAULT = "config_default.json" CONFIG_NAME_VIDEO_COMPRESS_PRESET = "video_compress_preset.json" +CONFIG_NAME_RECORD_PRESET = "record_preset.json" + DIR_CONFIG_SRC = "windrecorder\\config_src" DIR_USERDATA = "userdata" FILEPATH_CONFIG_DEFAULT = os.path.join(DIR_CONFIG_SRC, CONFIG_NAME_DEFAULT) FILEPATH_CONFIG_USER = os.path.join(DIR_USERDATA, CONFIG_NAME_USER) FILEPATH_CONFIG_VIDEO_COMPRESS_PRESET = os.path.join(DIR_CONFIG_SRC, CONFIG_NAME_VIDEO_COMPRESS_PRESET) +FILEPATH_CONFIG_RECORD_PRESET = os.path.join(DIR_CONFIG_SRC, CONFIG_NAME_RECORD_PRESET) class Config: @@ -28,7 +31,6 @@ def __init__( record_seconds, record_framerate, record_bitrate, - record_screen_enable_half_res_while_hidpi, lang, ocr_lang, ocr_engine, @@ -80,6 +82,10 @@ def __init__( batch_size_compress_video_in_idle, enable_3_columns_in_oneday, enable_synonyms_recommend, + multi_display_record_strategy, + record_single_display_index, + record_encoder, + record_crf, **other_field, ) -> None: # If need to process input parameters, they should assign another variable name to prevent recursive writing into the config. @@ -91,7 +97,6 @@ def __init__( self.record_seconds = record_seconds self.record_framerate = record_framerate self.record_bitrate = record_bitrate - self.record_screen_enable_half_res_while_hidpi = record_screen_enable_half_res_while_hidpi self.ffmpeg_path = ".venv\\ffmpeg.exe" if release_ver else "ffmpeg" self.ffprobe_path = ".venv\\ffprobe.exe" if release_ver else "ffprobe" self.lang = lang @@ -125,7 +130,6 @@ def __init__( self.compress_encoder = compress_encoder self.compress_accelerator = compress_accelerator self.compress_quality = compress_quality - self.compress_preset = get_video_compress_preset_json() self.log_dir = log_dir self.win_title_dir = win_title_dir self.start_recording_on_startup = start_recording_on_startup @@ -147,6 +151,10 @@ def __init__( self.batch_size_compress_video_in_idle = batch_size_compress_video_in_idle self.enable_3_columns_in_oneday = enable_3_columns_in_oneday self.enable_synonyms_recommend = enable_synonyms_recommend + self.multi_display_record_strategy = multi_display_record_strategy # all:record all single:record single display + self.record_single_display_index = record_single_display_index # start from 1, map to mms display list + self.record_encoder = record_encoder + self.record_crf = record_crf def set_and_save_config(self, attr: str, value): if not hasattr(self, attr): @@ -222,7 +230,15 @@ def get_video_compress_preset_json(): return config_json +def get_record_preset_json(): + with open(FILEPATH_CONFIG_RECORD_PRESET, "r", encoding="utf-8") as f: + config_json = json.load(f) + return config_json + + config = Config(**get_config_json()) +CONFIG_VIDEO_COMPRESS_PRESET = get_video_compress_preset_json() +CONFIG_RECORD_PRESET = get_record_preset_json() # main 函数,输出 config 内容 if __name__ == "__main__": diff --git a/windrecorder/config_src/config_default.json b/windrecorder/config_src/config_default.json index 5976219b..512edc25 100644 --- a/windrecorder/config_src/config_default.json +++ b/windrecorder/config_src/config_default.json @@ -47,7 +47,6 @@ "video_compress_rate":"0.5", "oneday_timeline_pic_num":50, "enable_ocr_chineseocr_lite_onnx":false, - "record_screen_enable_half_res_while_hidpi": false, "compress_encoder": "x264", "compress_accelerator": "cpu", "compress_quality": 39, @@ -71,5 +70,9 @@ "batch_size_remove_video_in_idle": 80, "batch_size_compress_video_in_idle": 50, "enable_3_columns_in_oneday": false, - "enable_synonyms_recommend": false + "enable_synonyms_recommend": false, + "multi_display_record_strategy": "all", + "record_single_display_index": 1, + "record_encoder": "cpu_h264", + "record_crf": 39 } diff --git a/windrecorder/config_src/languages.json b/windrecorder/config_src/languages.json index f4f1ae94..a267969b 100644 --- a/windrecorder/config_src/languages.json +++ b/windrecorder/config_src/languages.json @@ -3,7 +3,7 @@ "main_title": "#### 🦝 Windrecorder", "tab_name_oneday": "Daily", - "tab_name_search": "Global Search", + "tab_name_search": "Search", "tab_name_stat": "Summary", "tab_name_recording": "Recording & Video Storage", "tab_name_setting": "Settings", @@ -82,12 +82,11 @@ "stat_text_no_month_lightbox": "No lightbox image for this month.", "stat_text_counting": "Counting...", - "rs_text_need_to_restart_after_save_setting": "After saving the settings on this page, you need to restart the 'start_app.bat' to take effect.", + "rs_text_need_to_restart_after_save_setting": "After saving the settings on this page, you need to restart 'Windrecorder' to take effect.", "rs_md_title": "### 📼 Recording & Video Storage", "rs_md_record_setting_title": "#### Recording options", "rs_checkbox_start_record_when_startup": "Run on system startup", "rs_checkbox_start_record_when_startup_help": "After applied, a shortcut will be created for 'start_app.bat' and placed in the system's startup directory. This behavior may be misjudged as a virus and cause 'start_webui.bat' to be removed by some security software, if any interception, please remove it from the isolation area and mark it as trusted software. Or manually create a shortcut for 'start_app.bat' and place it in the system's startup folder.", - "rs_md_only_support_main_monitor": "

⚠ Currently only supports recording main monitor.

", "rs_checkbox_is_start_recording_on_start_app": "Start recording after app started", "rs_input_stop_recording_when_screen_freeze": "Skip recording the next video clip when screen has not changed for minutes (0 for never pause)", "rs_text_skip_recording_by_wintitle": "Skip recording the next video clip and index to database when the title of the current application or OCR text contain one of the following words:", @@ -101,17 +100,26 @@ "rs_selectbox_compress_ratio": "Picture compression ratio", "rs_selectbox_compress_ratio_help": "0.75 times scaling can compress the original video size to 1/4, 0.5 times to 1/8, 0.25 times to 1/16. Compression ratio may vary depending on the video's specific content.", "rs_checkbox_enable_half_res_while_hidpi": "For 'high DPI/high resolution screens', scale to quarter resolution when recording", - "rs_text_enable_half_res_while_hidpi": "Screen resolutions with a height greater than 1500 will be considered 'high DPI/high resolution screens' and their recorded video resolution will be reduced to a quarter of the original. For example, on a 4k monitor of 3840x2160, the resolution of the recorded video will downscale to 1920x1080. After turn on this option, you can slightly improve video quality and reduce size, but it will also lead to a decrease in OCR recognition accuracy. You can turn this option off if you use smaller fonts/zooming on a high-resolution screen, or find that OCR recognition is less accurate.", - "rs_text_compress_encoder": "Compression encoding method", - "rs_text_compress_accelerator": "Compression encoding accelerator", - "rs_text_compress_CRF": "Compression quality CRF", + "rs_text_compress_encoder": "Encoding method", + "rs_text_compress_accelerator": "Encoding accelerator", + "rs_text_compress_CRF": "Quality CRF", "rs_text_compress_CRF_help": "CRF is the abbreviation of Constant Rate Factor, which is used to set the quality and bit rate control of video encoding. Windrecorder is set to 39 by default for a higher compression rate. In ffmpeg, the value range of CRF depends on The encoder used. For x264 encoders, the CRF value range is 0 to 51, where 0 means lossless, 23 is the default value, and 51 means the worst quality. Lower values mean higher quality, but Will result in a larger file size. Typically, the reasonable value range for x264 encoders is 18 to 28. For x265 encoders, the default CRF value is 28. For libvpx encoders, the CRF value range is 0 to 63. In general, the lower the CRF value, the higher the video quality, but the file size will increase accordingly.", + "rs_text_estimate_hint": "Estimated video size recorded every 15 minutes: {min}Mb ~ {max}Mb, depending on the specific recording content and the number of displays.", "rs_btn_encode_benchmark": "Test supported encoding methods ♘", - "rs_text_encode_benchmark_loading": "During testing, it will take about 1 minute...", + "rs_text_encode_benchmark_loading": "Testing, it will take about 1 minute...", "rs_text_support": "Support", "rs_text_compress_ratio": "Compression ratio ❓", - "rs_text_compress_ratio_help": "Compression ratio = compressed video file volume/original video file volume. The provided test video file is short, and there may be a large deviation in the presentation of this indicator. As the video time increases, the compression rate will be relatively higher .You can replace the __assets__\\test_video_compress.mp4 file for testing.", + "rs_text_compress_ratio_help": "Compression ratio = compressed video file size/original video file size. The provided test video file is short, and there may be a large deviation in the presentation of this indicator. As the video time increases, the compression rate will be relatively higher .You can replace the __assets__\\test_video_compress.mp4 file for testing.", "rs_text_compress_time": "Compression time (s)", + "rs_text_record_strategy_option_all": "Record all displays ({num} total)", + "rs_text_record_strategy_option_single": "Record one display only", + "rs_text_record_range": "Screen recording range", + "rs_text_record_single_display_select": "Record display only:", + "rs_text_show_encode_option": "Show advanced encoding options", + "rs_text_record_encoder": "Recording Encoder", + "rs_text_record_help": "Choose h265/AV1 to get better image quality in the same file size, but the performance may be affected during recording and playback. (If supported, hardware acceleration will be automatically enabled)", + "rs_text_record_bitrate": "Recording Bitrate (kbps)", + "rs_text_bitrate_help": "Video bitrate refers to the transmission speed or processing speed of video data, which will affect the video quality and file size. Specifically, the higher the bitrate, the better the video quality, but the corresponding file The size will also be larger. On the contrary, if the bit rate is lower, the video quality may be blurred or distorted even at the same resolution. In order to keep the picture size smaller, the default is 200kbps.", "set_md_title": "### ⚙️ Settings", "set_md_index_db": "#### Database Index\n", @@ -119,9 +127,10 @@ "set_selectbox_local_ocr_engine": "Local OCR Engine", "set_selectbox_ocr_lang": "Main OCR language", "set_selectbox_local_ocr_engine_help": "It is recommended to use Windows built-in Windows.Media.Ocr.Cli for OCR, which is faster and consumes less resources.", - "set_md_ocr_ignore_area": "**OCR area to ignore on the screen edges**", + "set_md_ocr_ignore_area": "**OCR area to ignore on the display edges**", "set_md_ocr_ignore_area_help": "Enter number as percentage, i.e., '6' == 6%. This option allows to ignore elements at the edges of the screen during OCR, such as browser tabs, Windows Start Menu, in-page advertisements, etc.", - "set_toggle_use_screenshot_as_refer": "Use current screen as reference", + "set_toggle_use_screenshot_as_refer": "Use current display as reference", + "set_text_choose_displays": "Choose displays", "set_text_top_padding":"Top Padding", "set_text_bottom_padding":"Bottom Padding", "set_text_left_padding":"Left Padding", @@ -172,7 +181,7 @@ "qs_un_use_custom_name": "Use a custom username: {your_username}", "qs_olang_intro": "The system has the following languages installed. Select one as the main language for OCR detection:", "qs_olang_ocrlang_set_to": "The main language for OCR detection has been set to:", - "qs_olang_error": "The input should be a number.", + "qs_olang_error": "The input should be numeric and within the range of options.", "qs_olang_one_choice_default_set": "The system language is {os_support_lang}, which has been applied as the main language for OCR detection.\n(Not the language you want? Please check whether the input method/language pack of the selected language is installed on the system.\n(https: //learn.microsoft.com/en-us/uwp/api/windows.media.ocr)", "qs_ocr_title": "OCR Engine Test Results:\n", "qs_ocr_result_describe": "Accuracy: {accuracy}, Recognition Time: {timecost}, Time Required to Index a 15-minute Video: {timecost_15}", @@ -182,9 +191,14 @@ "qs_ocr_cta": "Please select the OCR engine for extracting screen content:", "qs_ocr_option_recommand": "(Recommended & Default)", "qs_ocr_engine_chosen": "OCR Engine Used:", - "qs_mo_describe": "Note: Due to the lack of official support for multiple monitors in pyautogui, Windrecorder will only record the screen set as the 'primary display' in Windows.\n", - "qs_mo_detect": "The detected resolution of the primary display is: {monitor_width}x{monitor_height}", - "qs_mo_cta": "This setting will be automatically detected each time you start recording, so you don't need to choose or set it separately.", + "qs_mo_describe_all": "Multiple displays have been detected. Choose recording mode: (Press enter to apply the current option directly)\n", + "qs_mo_describe_single": "Single display ({width}x{height}) detected.\nIf you use multiple displays in the future, you can always adjust the multi-screen recording mode in the recording settings.\n", + "qs_mo_option_all": "Record all displays", + "qs_mo_option_single": "Only record one of the displays", + "qs_mo_set_to": "The recording mode is set to:", + "qs_mo_choose_one_display": "Please select a single display you want to record:", + "qs_mo_record_single": "Record display only:", + "qs_mo_cta": "The display's resolution will be automatically recognized each time the screen is recorded, no additional settings are required.", "qs_et_describe": "Windrecorder also provides some extension functions, which you can later install/use in the extension directory.", "qs_end_describe": "Congratulations! You have completed all initial settings. Don't worry, you can adjust the settings anytime in the app! \n\nNow, you can open [start_app.bat] in the directory to start using it. \n", "qs_end_slogan": "> Capture and preserve the fleeting moments of the wind, as seen through your eyes.", @@ -298,12 +312,11 @@ "stat_text_no_month_lightbox": "当月未有光箱图片。", "stat_text_counting": "统计中……", - "rs_text_need_to_restart_after_save_setting": "本页的设置在保存后,需重启 「start_app.bat」 才能生效。", + "rs_text_need_to_restart_after_save_setting": "本页的设置在保存后,需重启 「捕风记录仪」 才能生效。", "rs_md_title": "### 📼 录制与视频存储", "rs_md_record_setting_title": "#### 录制选项", "rs_checkbox_start_record_when_startup": "开机后自动启动应用", "rs_checkbox_start_record_when_startup_help": "此项勾选后会为「start_app.bat」创建快捷方式,并放到系统开机自启动的目录下。此行为可能会被部分安全软件误判为病毒行为,导致「start_webui.bat」被移除,如有拦截,请将其移出隔离区并标记为可信任软件。或手动为「start_app.bat」创建快捷方式、并放到系统的开机启动目录下。", - "rs_md_only_support_main_monitor": "

⚠ 当前仅支持录制主显示器画面。

", "rs_checkbox_is_start_recording_on_start_app": "启动应用后自动开始录制", "rs_input_stop_recording_when_screen_freeze": "当画面几分钟没有变化时,暂停录制下个视频切片(0为永不暂停)", "rs_text_skip_recording_by_wintitle": "当前台应用标题/ OCR 包含以下词语之一时,跳过录制片段,且不索引、展示:", @@ -317,18 +330,27 @@ "rs_selectbox_compress_ratio": "压缩到原先画面尺寸的", "rs_selectbox_compress_ratio_help": "0.75 倍的边缩放约可压缩到原视频体积的 1/4,0.5 倍为 1/8,0.25 倍为 1/16。根据视频具体内容不同,压缩比可能有所差异。", "rs_checkbox_enable_half_res_while_hidpi": "对于「高 DPI /高分辨率屏幕」,在录制时缩放至四分之一的分辨率", - "rs_text_enable_half_res_while_hidpi": "对于高度大于 1500 的屏幕分辨率,将被视为「高 DPI /高分辨率屏幕」,勾选后录制分辨率将缩小至原来的四分之一。比如在 3840x2160 的 4k 显示器上,录制视频的分辨率将为 1920x1080。开启该选项后,可以稍微提高视频画质、降低存储大小,但也可能导致 OCR 识别准确率的下降。如果你在高分辨率屏幕上使用较小的字体/缩放、或发现 OCR 识别的准确率较低,尝试关闭此选项。", - "rs_text_compress_encoder": "压缩编码方式", - "rs_text_compress_accelerator": "压缩编码加速器", - "rs_text_compress_CRF": "压缩质量 CRF", + "rs_text_compress_encoder": "编码方式", + "rs_text_compress_accelerator": "编码加速器", + "rs_text_compress_CRF": "编码质量 CRF", "rs_text_compress_CRF_help": "CRF 是 Constant Rate Factor 的缩写,用于设置视频编码的质量和比特率控制。Windrecorder 为了较高的压缩率,默认设定在 39。在 ffmpeg 中,CRF 的取值范围取决于所使用的编码器。对于 x264 编码器,CRF 的取值范围是 0 到 51,其中 0 表示无损,23 是默认值,51 表示最差的质量。较低的值意味着更高的质量,但会导致更大的文件大小。通常情况下,x264 编码器的合理取值范围是 18 到 28。对于 x265 编码器,默认的 CRF 值是 28。而对于 libvpx 编码器,CRF 的取值范围是 0 到 63。总的来说,CRF 值越低,视频质量越高,但文件大小也会相应增加。", + "rs_text_estimate_hint": "每15分钟录制视频大小估计:{min}Mb ~ {max}Mb,视具体录制内容与显示器数量决定。", "rs_btn_encode_benchmark": "测试支持的编码方式 ♘", "rs_text_encode_benchmark_loading": "测试中,大概需要 1 分钟……", "rs_text_support": "支持", "rs_text_compress_ratio": "压缩率❓", "rs_text_compress_ratio_help": "压缩率 = 压缩后的视频文件体积 / 原视频文件体积。此处测试文件时长较短,该项指标呈现可能存在较大偏差。随着视频时间增长,压缩率相对会更高。你可以替换 __assets__\\test_video_compress.mp4 文件来进行测试。", "rs_text_compress_time": "压缩耗时(s)", - + "rs_text_record_strategy_option_all": "录制所有显示器(共 {num} 个)", + "rs_text_record_strategy_option_single": "仅录制一个显示器", + "rs_text_record_range": "画面录制范围", + "rs_text_record_single_display_select": "仅录制显示器:", + "rs_text_show_encode_option": "显示高级编码选项", + "rs_text_record_encoder": "录制编码器", + "rs_text_record_help": "选择 h265/AV1 可以在同等体积下获得更好的画质,但在录制与回放时可能性能较差。(若支持,将会自动启用硬件加速)", + "rs_text_record_bitrate": "录制比特率(kbps)", + "rs_text_bitrate_help": "视频比特率是指视频数据的传输速度或处理速度,会影响视频的画质和文件的大小。具体来说,比特率越高,视频的画质越好,但相应的文件大小也会越大。相反,如果比特率较低,则即使在同样的分辨率下,视频的画质可能就会出现模糊或者失真。为了将画面保持在较小体积,默认为 200kbps。", + "set_md_title": "### ⚙️ 设置", "set_md_index_db": "#### 数据库索引\n", "set_checkbox_shutdown_after_updated": "更新完毕后关闭计算机", @@ -338,6 +360,7 @@ "set_md_ocr_ignore_area": "**OCR 时忽略屏幕四边的区域范围**", "set_md_ocr_ignore_area_help": "填入数字为百分比,比如'6' == 6%。此选项可以在 OCR 时忽略屏幕四边的元素,如浏览器的标签栏、Windows 的开始菜单、网页内的花边广告信息等。", "set_toggle_use_screenshot_as_refer": "用当前屏幕截图参照", + "set_text_choose_displays": "选择显示器", "set_text_top_padding":"上边框", "set_text_bottom_padding":"下边框", "set_text_left_padding":"左边框", @@ -388,7 +411,7 @@ "qs_un_use_custom_name": "使用自定义用户名:{your_username}", "qs_olang_intro": "系统已安装以下语言,选择一项作为 OCR 检测的主要语言:", "qs_olang_ocrlang_set_to": "OCR 检测的主要语言已设定为:", - "qs_olang_error": "输入项应当为数字。", + "qs_olang_error": "输入项应当为数字且在选项范围内。", "qs_olang_one_choice_default_set": "系统语言为 {os_support_lang},已应用为 OCR 检测的主要语言。\n(不是所需语言?请检查系统是否安装了选定语言的输入法/语言包。\n(https://learn.microsoft.com/en-us/uwp/api/windows.media.ocr)", "qs_ocr_title": "OCR 引擎测试情况:\n", "qs_ocr_result_describe": "准确率:{accuracy},识别时间:{timecost} ,索引15分钟视频约用时:{timecost_15}", @@ -398,9 +421,14 @@ "qs_ocr_cta": "请选择提取屏幕内容时的 OCR 引擎:", "qs_ocr_option_recommand": "(推荐 & 默认)", "qs_ocr_engine_chosen": "OCR 引擎使用:", - "qs_mo_describe": "注意:由于 pyautogui 暂未官方支持多显示器,捕风记录仪将只记录 Windows 下设置的【主显示器】\n", - "qs_mo_detect": "当前检测到的主显示器分辨率为:{monitor_width}x{monitor_height}", - "qs_mo_cta": "此项设定将在每次录屏时自动识别,无需额外选择与设定。", + "qs_mo_describe_all": "检测到了多个显示器,你希望的录制模式为:(回车直接应用当前选项)\n", + "qs_mo_describe_single": "检测到了单个显示器({width}x{height})。\n如果未来使用多个显示器,可以随时在录制设置中调整多屏幕录制模式。\n", + "qs_mo_option_all": "录制所有显示器", + "qs_mo_option_single": "仅录制其中一个显示器", + "qs_mo_set_to": "录制模式设置为:", + "qs_mo_choose_one_display": "请选择想录制的单个显示器:", + "qs_mo_record_single": "仅录制显示器:", + "qs_mo_cta": "显示器的分辨率将在每次录屏时自动识别,无需额外设定。", "qs_et_describe": "捕风记录仪 还提供了一些扩展功能,你可以稍后在 extension 目录下安装/使用。", "qs_end_describe": "恭喜!你已完成所有初始设定。别担心,你可以随时在应用内调整设置!\n\n现在,你可以打开目录下的 【start_app.bat】 来开始使用啦。\n", "qs_end_slogan": "> 一起捕捉贮藏风一般掠过的、你的目之所见。", @@ -514,12 +542,11 @@ "stat_text_no_month_lightbox": "今月はライトボックス画像がありません。", "stat_text_counting": "統計...", - "rs_text_need_to_restart_after_save_setting": "このページの設定を保存した後、start_app.bat を再起動する必要があります。", + "rs_text_need_to_restart_after_save_setting": "このページの設定を保存した後、Windrecorder を再起動する必要があります。", "rs_md_title": "### 📼 録画とビデオストレージ", "rs_md_record_setting_title": "#### 録画オプション", "rs_checkbox_start_record_when_startup": "起動時に自動的にAppを開始する", "rs_checkbox_start_record_when_startup_help": "このオプションにチェックを入れると、start_app.bat のショートカットが作成されて、システムのスタートアップディレクトリに配置されます。このオプションにより、一部のセキュリティソフトウェアが start_webui.bat をウイルスのように誤検出する可能性がありますが、隔離エリアから取り除いて信頼できるソフトウェアとしてマークすることができます。また、手動で start_app.bat のショートカットを作成し、システムのスタートアップディレクトリに配置することもできます。", - "rs_md_only_support_main_monitor": "

⚠ 現在、メインディスプレイの画面のみが録画できます。

", "rs_checkbox_is_start_recording_on_start_app": "アプリケーションの起動後に自動的に録画を開始します", "rs_input_stop_recording_when_screen_freeze": "画面が数分間変化しないときは、次のビデオスライスの録画を一時停止する(0 は永続停止しない)", "rs_text_skip_recording_by_wintitle": "フロントエンド アプリケーションのタイトル/OCR に次の単語のいずれかが含まれている場合、記録クリップはスキップされ、インデックス付けも表示もされません。", @@ -533,17 +560,26 @@ "rs_selectbox_compress_ratio": "元の画面サイズの", "rs_selectbox_compress_ratio_help": "0.75 倍の縮尺で元のビデオ容量の 1/4 まで圧縮できます。0.5 倍は 1/8、0.25 倍は 1/16 まで。ビデオの内容によっては、圧縮比率が異なります。", "rs_checkbox_enable_half_res_while_hidpi": "「高 DPI/高解像度画面」の場合、録画時に 4 分の 1 の解像度にスケールします。", - "rs_text_enable_half_res_while_hidpi": "高さが 1500 を超える画面解像度は「高 DPI/高解像度画面」とみなされ、記録されるビデオ解像度は元の 4 分の 1 に低下します。 たとえば、3840x2160 の 4k モニターでは、録画されるビデオの解像度は 1920x1080 になります。 このオプションをオンにすると、最初に記録されたビデオのサイズを縮小できますが、OCR 認識精度も低下します。 高解像度の画面で小さいフォントやズームを使用する場合、または OCR 認識の精度が低い場合は、このオプションをオフにすることができます。", - "rs_text_compress_encoder": "圧縮エンコード方式", - "rs_text_compress_accelerator": "圧縮エンコード アクセラレータ", - "rs_text_compress_CRF": "圧縮品質 CRF", + "rs_text_compress_encoder": "エンコード方式", + "rs_text_compress_accelerator": "エンコード アクセラレータ", + "rs_text_compress_CRF": "画面品質 CRF", "rs_text_compress_CRF_help": "CRF は Constant Rate Factor の略で、ビデオ エンコーディングの品質とビット レート制御を設定するために使用されます。Windrecorder は圧縮率を高めるためにデフォルトで 39 に設定されています。ffmpeg では、CRF の値の範囲は使用するエンコーダによって異なります。x264 エンコーダの場合、CRF 値の範囲は 0 ~ 51 です。0 はロスレスを意味し、23 はデフォルト値、51 は最悪の品質を意味します。値が低いほど高品質を意味しますが、結果はファイル サイズが大きくなります。通常、x264 エンコーダの適切な値の範囲は 18 ~ 28 です。x265 エンコーダの場合、デフォルトの CRF 値は 28 です。libvpx エンコーダの場合、CRF 値の範囲は 0 ~ 63 です。一般に、CRF 値が小さいほど、 、ビデオ品質は高くなりますが、それに応じてファイル サイズも大きくなります。", + "rs_text_estimate_hint": "15 分ごとに録画される推定ビデオ サイズ: {min}Mb ~ {max}Mb (特定の録画コンテンツとモニターの数によって異なります)。", "rs_btn_encode_benchmark": "サポートされているエンコード方式をテストする ♘", "rs_text_encode_benchmark_loading": "テスト中は約 1 分かかります...", "rs_text_support": "サポート", "rs_text_compress_ratio": "圧縮率 ❓", "rs_text_compress_ratio_help": "圧縮率 = 圧縮ビデオ ファイルの容量 / 元のビデオ ファイルの容量。ここでのテスト ファイルは短いため、このインジケーターの表示には大きな誤差が生じる可能性があります。ビデオ時間が増加するにつれて、圧縮率は比較的高い。テスト用に __assets__\\test_video_compress.mp4 ファイルを置き換えることができます。", "rs_text_compress_time": "圧縮時間 (秒)", + "rs_text_record_strategy_option_all": "すべての表示を記録します (合計 {num})", + "rs_text_record_strategy_option_single": "1 つのモニターのみを録画します", + "rs_text_record_range": "画面録画範囲", + "rs_text_record_single_display_select": "レコード表示のみ:", + "rs_text_show_encode_option": "高度なエンコード オプションを表示", + "rs_text_record_encoder": "録音エンコーダー", + "rs_text_record_help": "同じボリュームでより良い画質を得るには h265/AV1 を選択してください。ただし、録音および再生時のパフォーマンスが低下する可能性があります。(サポートされている場合は、ハードウェア アクセラレーションが自動的に有効になります)", + "rs_text_record_bitrate": "録音ビットレート(kbps)", + "rs_text_bitrate_help": "ビデオのビットレートとは、ビデオ データの送信速度または処理速度を指し、ビデオの品質とファイル サイズに影響します。具体的には、ビットレートが高いほどビデオの品質は向上しますが、対応するファイルのサイズも異なります。逆に、ビットレートが低い場合は、同じ解像度でもビデオ品質がぼやけたり歪んだりする可能性があります。画像サイズを小さくするために、デフォルトは 200kbps です。", "set_md_title": "### ⚙️ 設定", "set_md_index_db": "#### データベースインデックス\n", @@ -554,6 +590,7 @@ "set_md_ocr_ignore_area": "**OCR時に画面の四辺の領域を無視する**", "set_md_ocr_ignore_area_help": "数字はパーセンテージです。例えば、「6」は 6% です。このオプションにより、OCR 時に画面の四辺の要素が無視されます。これには、ブラウザのタブバー、Windows のスタートメニュー、Web ページ内のボーダー広告情報などがあります。", "set_toggle_use_screenshot_as_refer": "現在のスクリーンショットを参照として使用する", + "set_text_choose_displays": "ディスプレイを選択", "set_text_top_padding":"上マージン", "set_text_bottom_padding":"下マージン", "set_text_left_padding":"左マージン", @@ -604,7 +641,7 @@ "qs_un_use_custom_name": "カスタムのユーザー名を使用する:{your_username}", "qs_olang_intro": "システムには次の言語がインストールされています。OCR 検出のメイン言語として 1 つを選択してください:", "qs_olang_ocrlang_set_to": "OCR 検出のメイン言語は次のように設定されました:", - "qs_olang_error": "入力は数値である必要があります。", + "qs_olang_error": "入力は数値であり、オプションの範囲内である必要があります。", "qs_olang_one_choice_default_set": "システム言語は {os_support_lang} で、OCR 検出のメイン言語として適用されています。\n(必要な言語ではありませんか? 選択した言語の入力方式/言語パックがコンピュータにインストールされているかどうかを確認してください)システム。\n(https://learn.microsoft.com/en-us/uwp/api/windows.media.ocr)", "qs_ocr_title": "OCRエンジンのテスト結果:\n", "qs_ocr_result_describe": "精度:{accuracy}、認識時間:{timecost}、15分のビデオの索引作成に約かかる時間:{timecost_15}", @@ -614,9 +651,14 @@ "qs_ocr_cta": "画面のコンテンツを抽出する際のOCRエンジンを選択してください:", "qs_ocr_option_recommand": "(おすすめ&デフォルト)", "qs_ocr_engine_chosen": "使用されるOCRエンジン:", - "qs_mo_describe": "注意:pyautoguiは公式に複数のディスプレイをサポートしていないため、WindrecorderはWindowsで設定された【メインディスプレイ】のみを記録します。\n", - "qs_mo_detect": "現在検出されたメインディスプレイの解像度は{monitor_width}x{monitor_height}", - "qs_mo_cta": "この設定は画面録画時に自動的に識別され、追加の選択や設定は必要ありません。", + "qs_mo_describe_all": "複数のモニターが検出されました。必要な記録モードは次のとおりです: (現在のオプションを直接適用するには Enter キーを押します)\n", + "qs_mo_describe_single": "単一モニター ({width}x{height}) が検出されました。\n将来複数のモニターを使用する場合は、録画設定でいつでもマルチ画面録画モードを調整できます。\n", + "qs_mo_option_all": "すべてのモニターを記録します", + "qs_mo_option_single": "モニターの 1 つだけを記録します", + "qs_mo_set_to": "記録モードは次のように設定されています:", + "qs_mo_choose_one_display": "記録したいディスプレイを 1 つ選択してください:", + "qs_mo_record_single": "記録モニターのみ:", + "qs_mo_cta": "モニターの解像度は画面が録画されるたびに自動的に認識されるため、追加の設定は必要ありません。", "qs_et_describe": "Windrecorder にはいくつかの拡張機能も用意されており、後で拡張機能ディレクトリにインストールして使用できます。", "qs_end_describe": "おめでとう! すべての初期設定が完了しました。 心配しないでください。設定はアプリ内でいつでも調整できます。 \n\nこれで、ディレクトリ内の [start_app.bat] を開いて使用を開始できます。 \n", "qs_end_slogan": "> 一緒に、あなたの目が見た、風のように過ぎ去るものをキャプチャしましょう。", diff --git a/windrecorder/config_src/record_preset.json b/windrecorder/config_src/record_preset.json new file mode 100644 index 00000000..082f3cf3 --- /dev/null +++ b/windrecorder/config_src/record_preset.json @@ -0,0 +1,17 @@ +{ + "cpu_h264":{ + "ffmpeg_cmd":["-c:v", "libx264", "-b:v", "BITRATE"] + }, + "cpu_h265":{ + "ffmpeg_cmd":["-c:v", "libx265", "-b:v", "BITRATE"] + }, + "NVIDIA_h265":{ + "ffmpeg_cmd":["-c:v", "hevc_nvenc", "-b:v", "BITRATE"] + }, + "AMD_h265":{ + "ffmpeg_cmd":["-c:v", "hevc_amf", "-b:v", "BITRATE"] + }, + "SVT-AV1":{ + "ffmpeg_cmd":["-c:v", "libsvtav1", "-b:v", "BITRATE", "-svtav1-params", "fast-decode=1:tbr=400k:rc=1:preset=13:scm=1:lp=4:lookahead=0"] + } +} \ No newline at end of file diff --git a/windrecorder/const.py b/windrecorder/const.py new file mode 100644 index 00000000..3e51e737 --- /dev/null +++ b/windrecorder/const.py @@ -0,0 +1,5 @@ +import os + +# all const var should be stored here +CACHE_DIR = "cache" +CACHE_DIR_OCR_IMG_PREPROCESSOR = os.path.join(CACHE_DIR, "temp_ocr_img_preprocess") diff --git a/windrecorder/file_utils.py b/windrecorder/file_utils.py index 15854557..63dc3809 100644 --- a/windrecorder/file_utils.py +++ b/windrecorder/file_utils.py @@ -14,12 +14,17 @@ # 清空指定目录下的所有文件和子目录 def empty_directory(path): + if len(path) == 0: + return with os.scandir(path) as it: for entry in it: - if entry.is_dir(): - shutil.rmtree(entry.path) - else: - os.remove(entry.path) + try: + if entry.is_dir(): + shutil.rmtree(entry.path) + else: + os.remove(entry.path) + except Exception as e: + logger.error(e) # 检查目录是否存在,若无则创建 diff --git a/windrecorder/flag_mark_note.py b/windrecorder/flag_mark_note.py index b5523c82..d32c229b 100644 --- a/windrecorder/flag_mark_note.py +++ b/windrecorder/flag_mark_note.py @@ -3,7 +3,6 @@ import customtkinter import pandas as pd -import pyautogui import streamlit as st from PIL import Image, ImageDraw from send2trash import send2trash @@ -37,7 +36,11 @@ def add_new_flag_record_from_tray(datetime_created=None): datetime_created = datetime.datetime.now() ensure_flag_mark_note_csv_exist() df = file_utils.read_dataframe_from_path(config.flag_mark_note_filepath) - current_screenshot = pyautogui.screenshot() + + screenshot_display_index = 0 + if config.multi_display_record_strategy == "single" and config.record_single_display_index <= utils.get_display_count(): + screenshot_display_index = config.record_single_display_index + current_screenshot = utils.get_screenshot_of_display(screenshot_display_index) img_b64 = utils.resize_image_as_base64(current_screenshot) new_data = { diff --git a/windrecorder/logger.py b/windrecorder/logger.py index 14c04a54..fd8965e7 100644 --- a/windrecorder/logger.py +++ b/windrecorder/logger.py @@ -20,7 +20,12 @@ def get_logger(name, log_name="wr.log"): formatter = logging.Formatter("%(asctime)s - [%(filename)s:%(lineno)d] - %(funcName)s - %(levelname)s - %(message)s") # 创建一个滚动文件处理器,每个日志文件最大大小为5M,保存5个旧日志文件 - rf_handler = RotatingFileHandler(os.path.join(log_dir, log_name), maxBytes=5 * 1024 * 1024, backupCount=5) + rf_handler = RotatingFileHandler( + os.path.join(log_dir, log_name), + maxBytes=5 * 1024 * 1024, + backupCount=5, + encoding="utf-8", + ) # rf_handler = TimedRotatingFileHandler(os.path.join(log_dir, log_name), when="d", interval=1, backupCount=7) # rf_handler.suffix = "%Y-%m-%d_%H-%M-%S.log" # 设置历史文件 后缀 rf_handler.setFormatter(formatter) diff --git a/windrecorder/ocr_manager.py b/windrecorder/ocr_manager.py index 87711284..fc6da5cb 100644 --- a/windrecorder/ocr_manager.py +++ b/windrecorder/ocr_manager.py @@ -2,11 +2,12 @@ import os import shutil import subprocess +import time import cv2 import pandas as pd import win32file -from PIL import Image +from PIL import Image, ImageDraw from send2trash import send2trash from skimage.metrics import structural_similarity as ssim @@ -14,6 +15,7 @@ import windrecorder.utils as utils from windrecorder import file_utils, record_wintitle from windrecorder.config import config +from windrecorder.const import CACHE_DIR_OCR_IMG_PREPROCESSOR from windrecorder.db_manager import db_manager from windrecorder.exceptions import LockExistsException from windrecorder.lock import FileLock @@ -54,31 +56,65 @@ def is_file_in_use(file_path): # todo - 加入检测视频是否为合法视频? def extract_iframe(video_file, iframe_path, iframe_interval=4000): logger.info(f"extracting video i-frame: {video_file}") - cap = cv2.VideoCapture(video_file) - fps = cap.get(cv2.CAP_PROP_FPS) - - frame_step = int(fps * iframe_interval / 1000) - frame_cnt = 0 - while cap.isOpened(): - ret, frame = cap.read() - if not ret: - break + if "av1" not in config.record_encoder.lower(): + cap = cv2.VideoCapture(video_file) + fps = cap.get(cv2.CAP_PROP_FPS) + + frame_step = int(fps * iframe_interval / 1000) + frame_cnt = 0 + while cap.isOpened(): + ret, frame = cap.read() + if not ret: + break - if frame_cnt % frame_step == 0: - logger.debug(f"extract frame cut:{str(frame_cnt)}") - cv2.imwrite(os.path.join(iframe_path, f"{frame_cnt}.jpg"), frame) + if frame_cnt % frame_step == 0: + logger.debug(f"extract frame cut:{str(frame_cnt)}") + cv2.imwrite(os.path.join(iframe_path, f"{frame_cnt}.jpg"), frame) - frame_cnt += 1 + frame_cnt += 1 - cap.release() + cap.release() + else: + extract_iframe_by_ffmpeg(video_file, iframe_path) + + +def extract_iframe_by_ffmpeg(video_file, iframe_path): + ffmpeg_cmd = [ + config.ffmpeg_path, + "-i", + video_file, + "-vf", + "select='eq(pict_type\\,I)'", + "-r", + "1", + "-f", + "image2", + os.path.join(iframe_path, "%d.jpg"), + ] + subprocess.run(" ".join(ffmpeg_cmd), shell=True, check=True) + logger.debug("extract frame cut:" + " ".join(ffmpeg_cmd)) # 根据config配置裁剪图片 def crop_iframe(directory): - top_percent = config.ocr_image_crop_URBL[0] * 0.01 - bottom_percent = config.ocr_image_crop_URBL[1] * 0.01 - left_percent = config.ocr_image_crop_URBL[2] * 0.01 - right_percent = config.ocr_image_crop_URBL[3] * 0.01 + display_cnt = utils.get_display_count() + display_info = utils.get_display_info() + display_all_full_size = display_info[0] + display_info = display_info[1:] + ocr_image_crop_URBL = config.ocr_image_crop_URBL + top_percent = [] + bottom_percent = [] + left_percent = [] + right_percent = [] + if len(ocr_image_crop_URBL) < display_cnt * 4: # 不足时补齐参数 slot + for i in range(display_cnt - (len(ocr_image_crop_URBL) // 4)): + ocr_image_crop_URBL.extend([6, 6, 6, 3]) + config.set_and_save_config("ocr_image_crop_URBL", ocr_image_crop_URBL) + for i in range(display_cnt): + top_percent.append(config.ocr_image_crop_URBL[i * 4 + 0] * 0.01) + bottom_percent.append(config.ocr_image_crop_URBL[i * 4 + 1] * 0.01) + left_percent.append(config.ocr_image_crop_URBL[i * 4 + 2] * 0.01) + right_percent.append(config.ocr_image_crop_URBL[i * 4 + 3] * 0.01) # 获取目录下所有图片文件 image_files = [f for f in os.listdir(directory) if f.endswith((".jpg", ".jpeg", ".png"))] @@ -87,31 +123,200 @@ def crop_iframe(directory): for file_name in image_files: # 构建图片文件的完整路径 file_path = os.path.join(directory, file_name) + if "_cropped" in file_name: + continue - # 打开图片文件 image = Image.open(file_path) + draw = ImageDraw.Draw(image) + + # 校验图片 + img_width, img_height = image.size + fallback_condition = False + display_index = -1 + if not config.record_single_display_index <= len(display_info): # 当记录的显示器索引不存在于所有显示器中时,当作一个完整显示器使用默认参数处理 + fallback_condition = True + elif config.multi_display_record_strategy == "single": + # 当图片分辨率符合其中某个显示器的完整尺寸时,对其单独处理 + for i in display_info: # 逐个检查显示器,是否与config index吻合 + if ( + abs(display_info[config.record_single_display_index - 1]["width"] - img_width) < 2 + and abs(display_info[config.record_single_display_index - 1]["height"] - img_height) < 2 + ): + monitors_info_process = [display_info[config.record_single_display_index - 1]] + display_index = config.record_single_display_index - 1 + fallback_condition = False + break + else: + fallback_condition = True + # 当显示器配置为录制所有显示器、但与图片不符时,执行fallback策略:当作一个显示器、使用默认涂黑范围处理 + elif config.multi_display_record_strategy == "all" and ( + abs(display_all_full_size["width"] - img_width) > 10 or abs(display_all_full_size["height"] - img_height) > 10 + ): + fallback_condition = True + + if fallback_condition: + logger.info( + f"video iframe {file_name} not matched with current display configuration({display_info}), fallback to default mask config." + ) + monitors_info_process = [display_all_full_size] + top_percent = [0.06] + bottom_percent = [0.06] + left_percent = [0.06] + right_percent = [0.03] + else: + monitors_info_process = display_info - # 获取图片的原始尺寸 - width, height = image.size - - # 计算裁剪区域的像素值 - top = int(height * top_percent) - bottom = int(height * (1 - bottom_percent)) - left = int(width * left_percent) - right = int(width * (1 - right_percent)) - - # 裁剪图片 - cropped_image = image.crop((left, top, right, bottom)) - - # 保存裁剪后的图片 - # cropped_file_path = os.path.splitext(file_path)[0] + '_cropped' + os.path.splitext(file_path)[1] - cropped_file_path = file_path - cropped_image.save(cropped_file_path) + for i, monitor in enumerate(monitors_info_process): + # 计算裁剪区域的像素值 + try: + top = top_percent[i] + bottom = bottom_percent[i] + left = left_percent[i] + right = right_percent[i] + except IndexError: + top = 0.06 + bottom = 0.06 + left = 0.06 + right = 0.03 + + # 如果仅录制单显示器,且通过了校验 + if display_index > 0: + i = display_index + left_boundary = 0 + top_boundary = 0 + right_boundary = img_width + bottom_boundary = img_height + else: # 录制所有显示器情况下 + left_boundary = monitor["left"] - display_all_full_size["left"] + top_boundary = monitor["top"] - display_all_full_size["top"] + right_boundary = left_boundary + monitor["width"] + bottom_boundary = top_boundary + monitor["height"] + + # 计算涂黑的区域 + top_black = ( + left_boundary, + top_boundary, + right_boundary, + top_boundary + int(monitor["height"] * top), + ) + bottom_black = ( + left_boundary, + bottom_boundary - int(monitor["height"] * bottom), + right_boundary, + bottom_boundary, + ) + left_black = ( + left_boundary, + top_boundary, + left_boundary + int(monitor["width"] * left), + bottom_boundary, + ) + right_black = ( + right_boundary - int(monitor["width"] * right), + top_boundary, + right_boundary, + bottom_boundary, + ) + + # 在对应区域涂黑 + draw.rectangle(top_black, fill="black") + draw.rectangle(bottom_black, fill="black") + draw.rectangle(left_black, fill="black") + draw.rectangle(right_black, fill="black") + + cropped_file_path = os.path.splitext(file_path)[0] + "_cropped" + os.path.splitext(file_path)[1] + image.save(cropped_file_path) - # 关闭图片文件 image.close() + logger.debug(f"saved croped img in {image_files}") - logger.debug(f"saved croped img {cropped_file_path}") + +# OCR 输入图片预处理器 +def ocr_img_preprocessor(img_input): + def _save_cache_img(img: Image): + img_save_name = f"preprocess_{int(time.time()*100000000000)}.jpg" + try: + file_utils.ensure_dir(CACHE_DIR_OCR_IMG_PREPROCESSOR) + save_filepath = os.path.join(CACHE_DIR_OCR_IMG_PREPROCESSOR, img_save_name) + img.save(save_filepath) + return save_filepath + except Exception as e: + logger.error(f"img({img_input}) pre process fail: {e}, {display_info=}") + return None + + def _fallback_cropper(img: Image): + """策略:平均地切开图像,同时确保每部分小于最大输入尺寸""" + res = [] + img_to_process_width, img_to_process_height = img.size + divide_factor_width = 1 + divide_factor_height = 1 + for i in range(10): # 最多切10次,不用while来执行 + if (img_to_process_width / divide_factor_width) < SINGLE_IMG_MAX_LONG_SIDE: + break + divide_factor_width += 1 + for i in range(10): + if (img_to_process_height / divide_factor_height) < SINGLE_IMG_MAX_LONG_SIDE: + break + divide_factor_height += 1 + + crop_block_width = int(img_to_process_width / divide_factor_width) + crop_block_height = int(img_to_process_height / divide_factor_height) + for height_i in range(divide_factor_height): + for width_i in range(divide_factor_width): + cropped_img = img.crop( + ( + width_i * crop_block_width, + height_i * crop_block_height, + width_i * crop_block_width + crop_block_width, + height_i * crop_block_height + crop_block_height, + ) + ) + saved_res = _save_cache_img(cropped_img) + if saved_res is None: + return False + else: + res.append(saved_res) + return res + + res = [] + display_info = utils.get_display_info() + display_all_full_size = display_info[0] + display_info = display_info[1:] + SINGLE_IMG_MAX_LONG_SIDE = 4000 + + img = Image.open(img_input) + img_width, img_height = img.size + + if img_width < SINGLE_IMG_MAX_LONG_SIDE and img_height < SINGLE_IMG_MAX_LONG_SIDE: # 未超出ocr识别范围 + logger.debug("img under single 4k display") + return [img_input] + elif len(display_info) > 1 and ( + abs(display_all_full_size["width"] - img_width) < 10 or abs(display_all_full_size["height"] - img_height) < 10 + ): # 逐个裁剪显示器,若单个显示器有超过最大范围,则对其进行分块裁剪 + logger.debug("img is consistent with the current display info") + for display in display_info: + cropped_img = img.crop( + (display["left"], display["top"], display["left"] + display["width"], display["top"] + display["height"]) + ) + if (cropped_img.size[0] > SINGLE_IMG_MAX_LONG_SIDE) or (cropped_img.size[1] > SINGLE_IMG_MAX_LONG_SIDE): + fallback_crop_res = _fallback_cropper(cropped_img) + if fallback_crop_res: + res.extend(fallback_crop_res) + else: + return [img_input] + else: + saved_res = _save_cache_img(cropped_img) + if saved_res is not None: + res.append(saved_res) + else: + return [img_input] + else: + fallback_crop_res = _fallback_cropper(img) + if fallback_crop_res: + res.extend(fallback_crop_res) + else: + return [img_input] + return res # OCR 分流器 @@ -202,18 +407,22 @@ def compare_strings(a, b, threshold=70): # 计算两张图片的重合率 - 通过本地文件的方式 +# FIXME 这个函数太慢了,得优化 def compare_image_similarity(img_path1, img_path2, threshold=0.85): - # todo: 将删除操作改为整理为文件列表,降低io开销 logger.debug("Calculate the coincidence rate of two pictures.") - imageA = cv2.imread(img_path1) - imageB = cv2.imread(img_path2) + # 读取所有需要比较的图片 + image_paths = [img_path1, img_path2] + images = [cv2.imread(path) for path in image_paths] + + # 缩小图像大小 + scale_factor = 0.3 + images = [cv2.resize(img, None, fx=scale_factor, fy=scale_factor) for img in images] # 将图片转换为灰度 - imageA = cv2.cvtColor(imageA, cv2.COLOR_BGR2GRAY) - imageB = cv2.cvtColor(imageB, cv2.COLOR_BGR2GRAY) + images_gray = [cv2.cvtColor(img, cv2.COLOR_BGR2GRAY) for img in images] # 计算两张图片的SSIM - score = ssim(imageA, imageB) + score = ssim(images_gray[0], images_gray[1]) if score >= threshold: logger.debug(f"Images are similar with score {score}, deleting {img_path2}") @@ -276,31 +485,40 @@ def ocr_core_logic(file_path, vid_file_name, iframe_path): # 裁剪图片 crop_iframe(iframe_path) + display_count = utils.get_display_count() + # 假设屏幕大小一致,每块屏幕需要 15% 的不同画面,与 30% 的不同文字 + threshold_img_similarity = 1 - 0.15 / display_count + threshold_str_similarity = 100 - 30 / display_count + img1_path_temp = "" img2_path_temp = "" is_first_process_image_similarity = 1 - # 先清理一波看起来重复的图像 - for img_file_name in os.listdir(iframe_path): + # 根据时间先后顺序,清理一波看起来重复的图像 + # FIXME 寻找更好的方法可以清理所有重复图像,而不是按时间先后依次比对? + sotred_file = sorted(os.listdir(iframe_path), key=lambda x: int("".join(filter(str.isdigit, x)))) + for img_file_name in sotred_file: + if "_cropped" not in img_file_name: + continue logger.debug(f"processing IMG - compare:{img_file_name}") - img = os.path.join(iframe_path, img_file_name) - logger.debug(f"img={img}") + img_filepath = os.path.join(iframe_path, img_file_name) + logger.debug(f"img={img_filepath}") # 填充用于对比的slot队列 if is_first_process_image_similarity == 1: - img1_path_temp = img + img1_path_temp = img_filepath is_first_process_image_similarity = 2 elif is_first_process_image_similarity == 2: - img2_path_temp = img + img2_path_temp = img_filepath is_first_process_image_similarity = 3 else: - is_img_same = compare_image_similarity(img1_path_temp, img2_path_temp) + is_img_same = compare_image_similarity(img1_path_temp, img2_path_temp, threshold_img_similarity) if is_img_same: os.remove(img2_path_temp) img1_path_temp = img1_path_temp - img2_path_temp = img + img2_path_temp = img_filepath else: img1_path_temp = img2_path_temp - img2_path_temp = img + img2_path_temp = img_filepath # - OCR所有i帧图像 ocr_result_stringA = "" @@ -317,16 +535,23 @@ def ocr_core_logic(file_path, vid_file_name, iframe_path): ] dataframe_all = pd.DataFrame(columns=dataframe_column_names) - # TODO: os.listdir 应该进行正确的数字排序、以确保是按视频顺序索引的 - for img_file_name in os.listdir(iframe_path): + sotred_file = sorted(os.listdir(iframe_path), key=lambda x: int("".join(filter(str.isdigit, x)))) + for img_file_name in sotred_file: + if "_cropped" not in img_file_name: + continue + logger.debug("_____________________") logger.debug(f"processing IMG - OCR:{img_file_name}") - img = os.path.join(iframe_path, img_file_name) - ocr_result_stringB = ocr_image(img) - # logger.debug(f"ocr_result_stringB:{ocr_result_stringB}") + img_orgin_not_crop_filepath = os.path.join(iframe_path, img_file_name.replace("_cropped", "")) + img_crop_filepath = os.path.join(iframe_path, img_file_name) + img_crop_preprocess_list = ocr_img_preprocessor(img_crop_filepath) + ocr_result_stringB = "" + for img_filepath in img_crop_preprocess_list: + ocr_result_stringB += ocr_image(img_filepath) + logger.debug(f"OCR res:{ocr_result_stringB}") - is_str_same, _ = compare_strings(ocr_result_stringA, ocr_result_stringB) + is_str_same, _ = compare_strings(ocr_result_stringA, ocr_result_stringB, threshold_str_similarity) if is_str_same: logger.debug("[Skip] The content is consistent, not written to the database, skipped.") elif len(ocr_result_stringB) < 3: @@ -340,7 +565,9 @@ def ocr_core_logic(file_path, vid_file_name, iframe_path): # 使用os.path.splitext()可以把文件名和文件扩展名分割开来,os.path.splitext(file_name)会返回一个元组,元组的第一个元素是文件名,第二个元素是扩展名 calc_to_sec_vidname = os.path.splitext(vid_file_name)[0] calc_to_sec_vidname = calc_to_sec_vidname.replace("-INDEX", "") - calc_to_sec_picname = round(int(os.path.splitext(img_file_name)[0]) / 2) + calc_to_sec_picname = round( + int(os.path.splitext(img_file_name.replace("_cropped", ""))[0]) / int(config.record_framerate) + ) # 用fps折算秒数 calc_to_sec_data = date_to_seconds(calc_to_sec_vidname) + calc_to_sec_picname win_title = record_wintitle.get_wintitle_by_timestamp(calc_to_sec_data) win_title = record_wintitle.optimize_wintitle_name(win_title) @@ -351,7 +578,7 @@ def ocr_core_logic(file_path, vid_file_name, iframe_path): ) continue # 计算图片预览图 - img_thumbnail = resize_image_as_base64(img) + img_thumbnail = resize_image_as_base64(img_orgin_not_crop_filepath) # 清理ocr数据 ocr_result_write = utils.clean_dirty_text(ocr_result_stringB) + " -||- " + str(win_title) # 为准备写入数据库dataframe添加记录 @@ -369,66 +596,68 @@ def ocr_core_logic(file_path, vid_file_name, iframe_path): # 将完成的dataframe写入数据库 db_manager.db_add_dataframe_to_db_process(dataframe_all) + # 清理缓存 + file_utils.empty_directory(CACHE_DIR_OCR_IMG_PREPROCESSOR) # 对某个视频进行处理的过程 def ocr_process_single_video(video_path, vid_file_name, iframe_path, optimize_for_high_framerate_vid=False): - with acquire_ocr_lock(vid_file_name): - iframe_sub_path = os.path.join(iframe_path, os.path.splitext(vid_file_name)[0]) - # 整合完整路径 - file_path = os.path.join(video_path, vid_file_name) - - # 判断文件是否为上次索引未完成的文件 - if "-INDEX" in vid_file_name: - # 是-执行回滚操作 - logger.info("INDEX flag exists, perform rollback operation.") - # 这里我们保证 vid_file_name 不包含 -INDEX - vid_file_name = vid_file_name.replace("-INDEX", "") - rollback_data(video_path, vid_file_name) - # 保证进入 ocr_core_logic 前 iframe_sub_path 是空的 + # with acquire_ocr_lock(vid_file_name): + iframe_sub_path = os.path.join(iframe_path, os.path.splitext(vid_file_name)[0]) + # 整合完整路径 + file_path = os.path.join(video_path, vid_file_name) + + # 判断文件是否为上次索引未完成的文件 + if "-INDEX" in vid_file_name: + # 是-执行回滚操作 + logger.info("INDEX flag exists, perform rollback operation.") + # 这里我们保证 vid_file_name 不包含 -INDEX + vid_file_name = vid_file_name.replace("-INDEX", "") + rollback_data(video_path, vid_file_name) + # 保证进入 ocr_core_logic 前 iframe_sub_path 是空的 + try: + shutil.rmtree(iframe_sub_path) + except FileNotFoundError: + pass + else: + # 为正在索引的视频文件改名添加"-INDEX" + new_filename = vid_file_name.replace(".", "-INDEX.") + new_file_path = os.path.join(video_path, new_filename) + os.rename(file_path, new_file_path) + file_path = new_file_path + + file_utils.ensure_dir(iframe_sub_path) + try: + if optimize_for_high_framerate_vid: + vid_filepath_optimize = convert_temp_optimize_vidfile_for_ocr(file_path) + if vid_filepath_optimize is None: + raise FileNotFoundError + ocr_core_logic(vid_filepath_optimize, vid_file_name, iframe_sub_path) try: - shutil.rmtree(iframe_sub_path) + os.remove(vid_filepath_optimize) except FileNotFoundError: pass else: - # 为正在索引的视频文件改名添加"-INDEX" - new_filename = vid_file_name.replace(".", "-INDEX.") - new_file_path = os.path.join(video_path, new_filename) - os.rename(file_path, new_file_path) - file_path = new_file_path - - file_utils.ensure_dir(iframe_sub_path) - try: - if optimize_for_high_framerate_vid: - vid_filepath_optimize = convert_temp_optimize_vidfile_for_ocr(file_path) - if vid_filepath_optimize is None: - raise FileNotFoundError - ocr_core_logic(vid_filepath_optimize, vid_file_name, iframe_sub_path) - try: - os.remove(vid_filepath_optimize) - except FileNotFoundError: - pass - else: - ocr_core_logic(file_path, vid_file_name, iframe_sub_path) - except Exception as e: - # 记录错误日志 - logger.error(f"Error occurred while processing :{file_path=}, {e=}") - new_name = vid_file_name.split(".")[0] + "-ERROR." + vid_file_name.split(".")[1] - new_name_dir = os.path.dirname(file_path) - os.rename(file_path, os.path.join(new_name_dir, new_name)) - - with open(f"cache\\LOG_ERROR_{new_name}.MD", "w", encoding="utf-8") as f: - f.write(f'{datetime.datetime.now().strftime("%Y-%m-%d_%H-%M-%S")}\n{e}') - else: - logger.info("Add tags to video file") - new_file_path = file_path.replace("-INDEX", "-OCRED") - os.rename(file_path, new_file_path) - logger.info(f"--------- {file_path} Finished! ---------") - finally: - # 清理文件 - if not config.enable_img_embed_search: - shutil.rmtree(iframe_sub_path) # 先不清理文件,留给 img embed 流程继续使用,由它清理 - pass + ocr_core_logic(file_path, vid_file_name, iframe_sub_path) + except Exception as e: + # 记录错误日志 + logger.error(f"Error occurred while processing :{file_path=}, {e=}") + new_name = vid_file_name.split(".")[0] + "-ERROR." + vid_file_name.split(".")[1] + new_name_dir = os.path.dirname(file_path) + os.rename(file_path, os.path.join(new_name_dir, new_name)) + + with open(f"cache\\LOG_ERROR_{new_name}.MD", "w", encoding="utf-8") as f: + f.write(f'{datetime.datetime.now().strftime("%Y-%m-%d_%H-%M-%S")}\n{e}') + else: + logger.info("Add tags to video file") + new_file_path = file_path.replace("-INDEX", "-OCRED") + os.rename(file_path, new_file_path) + logger.info(f"--------- {file_path} Finished! ---------") + finally: + # 清理文件 + if not config.enable_img_embed_search: + shutil.rmtree(iframe_sub_path) # 先不清理文件,留给 img embed 流程继续使用,由它清理 + pass def convert_temp_optimize_vidfile_for_ocr(vid_filepath): diff --git a/windrecorder/oneday.py b/windrecorder/oneday.py index 2d5eef36..79df2c49 100644 --- a/windrecorder/oneday.py +++ b/windrecorder/oneday.py @@ -125,6 +125,8 @@ def find_closest_video_by_filesys(self, target_datetime): if file_dt < target_datetime: file_times.append((file, file_dt)) # 寻找时间距离target_datetime最近的先前时间的视频文件 + if len(file_times) == 0: + return False, None closest_file = max(file_times, key=lambda x: x[1]) # 判断时间差是否在阈值内 diff --git a/windrecorder/record.py b/windrecorder/record.py index f726747a..abd2695a 100644 --- a/windrecorder/record.py +++ b/windrecorder/record.py @@ -1,3 +1,4 @@ +import datetime import os import shutil import subprocess @@ -6,13 +7,98 @@ import pandas as pd from send2trash import send2trash -from windrecorder.config import config +from windrecorder import file_utils, utils +from windrecorder.config import ( + CONFIG_RECORD_PRESET, + CONFIG_VIDEO_COMPRESS_PRESET, + config, +) from windrecorder.logger import get_logger -from windrecorder.utils import is_process_running logger = get_logger(__name__) +# 录制屏幕 +def record_screen( + output_dir=config.record_videos_dir_ud, + record_time=config.record_seconds, + framerate=config.record_framerate, + encoder_preset_name=config.record_encoder, +): + """ + 用ffmpeg持续录制屏幕,默认每15分钟保存一个视频文件 + """ + # 构建输出文件名 + now = datetime.datetime.now() + video_out_name = now.strftime("%Y-%m-%d_%H-%M-%S") + ".mp4" + output_dir_with_date = now.strftime("%Y-%m") # 将视频存储在日期月份子目录下 + video_saved_dir = os.path.join(output_dir, output_dir_with_date) + file_utils.ensure_dir(video_saved_dir) + out_path = os.path.join(video_saved_dir, video_out_name) + + def _replace_value_in_args(lst, bitrate_displays_factor): + for i in range(len(lst)): + if lst[i] == "CRF_NUM": + lst[i] = f"{config.record_crf}" + elif lst[i] == "BITRATE": + lst[i] = f"{bitrate_displays_factor}k" + return lst + + display_info = utils.get_display_info() + pix_fmt_args = ["-pix_fmt", "yuv420p"] + + record_range_args = [] + if config.multi_display_record_strategy == "single" and len(display_info) > 1: # 当有多台显示器、且选择仅录制其中一台时 + record_encoder_args = _replace_value_in_args( + CONFIG_RECORD_PRESET[encoder_preset_name]["ffmpeg_cmd"], config.record_bitrate + ) + if config.record_single_display_index > len(display_info): + logger.warning("display index not detected, reset record_single_display_index to default index 1") + config.set_and_save_config("record_single_display_index", 1) + else: + record_range_args = [ + "-video_size", + f"{display_info[config.record_single_display_index]['width']}x{display_info[config.record_single_display_index]['height']}", + "-offset_x", + f"{display_info[config.record_single_display_index]['left']}", + "-offset_y", + f"{display_info[config.record_single_display_index]['top']}", + ] + else: + record_encoder_args = _replace_value_in_args( + CONFIG_RECORD_PRESET[encoder_preset_name]["ffmpeg_cmd"], int(config.record_bitrate) * (len(display_info) - 1) + ) + + ffmpeg_cmd = [ + config.ffmpeg_path, + "-hwaccel", + "auto", + "-f", + "gdigrab", + "-framerate", + f"{framerate}", + *record_range_args, + "-i", + "desktop", + *record_encoder_args, + *pix_fmt_args, + "-t", + str(record_time), + out_path, + ] + + # 执行命令 + try: + logger.info(f"record_screen: ffmpeg cmd: {ffmpeg_cmd}") + # 运行ffmpeg + subprocess.run(ffmpeg_cmd, check=True) + return video_saved_dir, video_out_name + except subprocess.CalledProcessError as ex: + logger.error(f"Windrecorder: {ex.cmd} failed with return code {ex.returncode}") + return video_saved_dir, video_out_name + # FIXME 报错录制失败时给用户反馈 + + # 检测是否正在录屏 def is_recording(): try: @@ -22,19 +108,7 @@ def is_recording(): logger.error("record: Screen recording service file lock does not exist.") return False - return is_process_running(check_pid, "python.exe") - - -# 获取录屏时目标缩放分辨率策略 -def get_scale_screen_res_strategy(origin_width=1920, origin_height=1080): - target_scale_width = origin_width - target_scale_height = origin_height - - if origin_height > 1500 and config.record_screen_enable_half_res_while_hidpi: # 高分屏缩放至四分之一策略 - target_scale_width = int(origin_width / 2) - target_scale_height = int(origin_height / 2) - - return target_scale_width, target_scale_height + return utils.is_process_running(check_pid, "python.exe") # 获取视频的原始分辨率 @@ -65,12 +139,12 @@ def compress_video_resolution(video_path, scale_factor): target_height = int(height * scale_factor) # 获取编码器和加速器 - encoder_default = config.compress_preset["x264"]["cpu"]["encoder"] - crf_flag_default = config.compress_preset["x264"]["cpu"]["crf_flag"] + encoder_default = CONFIG_VIDEO_COMPRESS_PRESET["x264"]["cpu"]["encoder"] + crf_flag_default = CONFIG_VIDEO_COMPRESS_PRESET["x264"]["cpu"]["crf_flag"] crf_default = 39 try: - encoder = config.compress_preset[config.compress_encoder][config.compress_accelerator]["encoder"] - crf_flag = config.compress_preset[config.compress_encoder][config.compress_accelerator]["crf_flag"] + encoder = CONFIG_VIDEO_COMPRESS_PRESET[config.compress_encoder][config.compress_accelerator]["encoder"] + crf_flag = CONFIG_VIDEO_COMPRESS_PRESET[config.compress_encoder][config.compress_accelerator]["crf_flag"] crf = int(config.compress_quality) except KeyError: logger.error("Fail to get video compress config correctly. Fallback to default preset.") @@ -124,7 +198,7 @@ def encode_preset_benchmark_test(scale_factor, crf): test_video_filepath = "__assets__\\test_video_compress.mp4" if not os.path.exists(test_video_filepath): logger.error("test_video_filepath not found.") - return + return None # 准备测试环境 test_env_folder = "cache\\encode_preset_benchmark_test" @@ -169,7 +243,7 @@ def check_encode_result(filepath): df_result = pd.DataFrame(columns=["encoder", "accelerator", "support", "compress_ratio", "compress_time"]) # 测试所有参数预设 - for encoder_name, encoder in config.compress_preset.items(): + for encoder_name, encoder in CONFIG_VIDEO_COMPRESS_PRESET.items(): logger.info(f"Testing {encoder}") for encode_accelerator_name, encode_accelerator in encoder.items(): logger.info(f"Testing {encode_accelerator}") @@ -195,3 +269,38 @@ def check_encode_result(filepath): df_result.loc[len(df_result)] = [encoder_name, encode_accelerator_name, False, 0, 0] return df_result + + +# 测试所有的录制参数,由 webui 指定缩放系数与 crf 压缩质量 +def record_encode_preset_benchmark_test(): + test_env_folder = "cache\\record_preset_benchmark_test" + if os.path.exists(test_env_folder): + shutil.rmtree(test_env_folder) + os.makedirs(test_env_folder) + + df_result = pd.DataFrame(columns=["encoder preset", "support"]) + + for encoder_preset_name in CONFIG_RECORD_PRESET.keys(): + logger.info(f"Testing {encoder_preset_name}") + support_res = False + try: + video_saved_dir, video_out_name = record_screen( + output_dir=test_env_folder, record_time=2, framerate=30, encoder_preset_name=encoder_preset_name + ) + output_path = os.path.join(video_saved_dir, video_out_name) + if os.path.exists(output_path): + if os.stat(output_path).st_size < 1024: + support_res = False + else: + support_res = True + else: + support_res = False + except Exception: + support_res = False + + df_result.loc[len(df_result)] = [ + encoder_preset_name, + support_res, + ] + + return df_result diff --git a/windrecorder/record_wintitle.py b/windrecorder/record_wintitle.py index 842ab2d8..a601c2aa 100644 --- a/windrecorder/record_wintitle.py +++ b/windrecorder/record_wintitle.py @@ -141,6 +141,11 @@ def optimize_wintitle_name(text): text = re.sub("\\(\\d+\\)", "", text) text = text.strip() + # remove asterisk for saved state + # eg. Blender* a.blend + text = re.sub(" \\* ", " ", text) + text = re.sub(" \\*", " ", text) + text = re.sub("\\* ", " ", text) return text diff --git a/windrecorder/ui/recording.py b/windrecorder/ui/recording.py index 85575f78..2821c4fb 100644 --- a/windrecorder/ui/recording.py +++ b/windrecorder/ui/recording.py @@ -5,12 +5,24 @@ from streamlit_tags import st_tags from windrecorder import record, utils -from windrecorder.config import config +from windrecorder.config import ( + CONFIG_RECORD_PRESET, + CONFIG_VIDEO_COMPRESS_PRESET, + config, +) from windrecorder.utils import find_key_position_in_dict from windrecorder.utils import get_text as _t def render(): + # 初始化懒状态 + if "display_count" not in st.session_state: + st.session_state["display_count"] = utils.get_display_count() + if "display_info" not in st.session_state: + st.session_state["display_info"] = utils.get_display_info() + if "display_info_formatted" not in st.session_state: + st.session_state["display_info_formatted"] = utils.get_display_info_formatted() + st.markdown(_t("rs_md_title")) settings_col, spacing_col, pic_col = st.columns([1, 0.5, 1.5]) @@ -20,29 +32,45 @@ def render(): st.markdown(_t("rs_md_record_setting_title")) # 录制选项 - col1_record, col2_record = st.columns([1, 1]) - with col1_record: - if "is_create_startup_shortcut" not in st.session_state: - st.session_state.is_create_startup_shortcut = utils.is_file_already_in_startup("start_app.bat.lnk") - st.session_state.is_create_startup_shortcut = st.checkbox( - _t("rs_checkbox_start_record_when_startup"), - value=st.session_state.is_create_startup_shortcut, - on_change=utils.change_startup_shortcut(is_create=st.session_state.is_create_startup_shortcut), - help=_t("rs_checkbox_start_record_when_startup_help"), - ) - is_start_recording_on_start_app = st.checkbox( - _t("rs_checkbox_is_start_recording_on_start_app"), value=config.start_recording_on_startup - ) - - with col2_record: - st.markdown(_t("rs_md_only_support_main_monitor"), unsafe_allow_html=True) - - record_screen_enable_half_res_while_hidpi = st.checkbox( - _t("rs_checkbox_enable_half_res_while_hidpi"), - help=_t("rs_text_enable_half_res_while_hidpi"), - value=config.record_screen_enable_half_res_while_hidpi, + if "is_create_startup_shortcut" not in st.session_state: + st.session_state.is_create_startup_shortcut = utils.is_file_already_in_startup("start_app.bat.lnk") + st.session_state.is_create_startup_shortcut = st.checkbox( + _t("rs_checkbox_start_record_when_startup"), + value=st.session_state.is_create_startup_shortcut, + on_change=utils.change_startup_shortcut(is_create=st.session_state.is_create_startup_shortcut), + help=_t("rs_checkbox_start_record_when_startup_help"), + ) + is_start_recording_on_start_app = st.checkbox( + _t("rs_checkbox_is_start_recording_on_start_app"), value=config.start_recording_on_startup ) + # 检测到多显示器时,提供设置选项 + record_strategy_config = { + _t("rs_text_record_strategy_option_all").format(num=len(st.session_state.display_info_formatted)): "all", + _t("rs_text_record_strategy_option_single"): "single", + } + if st.session_state.display_count > 1: + col1_ms, col2_ms = st.columns([1, 1]) + with col1_ms: + display_record_strategy = st.selectbox( + _t("rs_text_record_range"), + index=1 if config.multi_display_record_strategy == "single" else 0, + options=[i for i in record_strategy_config.keys()], + ) + with col2_ms: + if display_record_strategy == _t("rs_text_record_strategy_option_single"): + display_record_selection = st.selectbox( + _t("rs_text_record_single_display_select"), + index=config.record_single_display_index - 1, + options=st.session_state.display_info_formatted, + ) + else: + display_record_selection = None + st.empty() + else: + display_record_strategy = None + display_record_selection = None + screentime_not_change_to_pause_record = st.number_input( _t("rs_input_stop_recording_when_screen_freeze"), value=config.screentime_not_change_to_pause_record, @@ -53,6 +81,52 @@ def render(): label=_t("rs_text_skip_recording_by_wintitle"), text=_t("rs_tag_input_tip"), value=config.exclude_words ) + if st.toggle(_t("rs_text_show_encode_option"), key="expand_encode_option_recording"): + col_record_encoder, col_record_quality = st.columns([1, 1]) + with col_record_encoder: + RECORD_ENCODER_LST = list(CONFIG_RECORD_PRESET.keys()) + record_encoder = st.selectbox( + _t("rs_text_record_encoder"), + index=RECORD_ENCODER_LST.index(config.record_encoder), + options=RECORD_ENCODER_LST, + help=_t("rs_text_record_help"), + ) + with col_record_quality: + record_bitrate = st.number_input( + _t("rs_text_record_bitrate"), + value=config.record_bitrate, + min_value=50, + max_value=10000, + help=_t("rs_text_bitrate_help"), + ) + + estimate_display_cnt = ( + 1 + if (display_record_strategy is None) + or (display_record_strategy == _t("rs_text_record_strategy_option_single")) + else len(st.session_state.display_info_formatted) + ) + st.text( + _t("rs_text_estimate_hint").format( + min=round(0.025 * record_bitrate * estimate_display_cnt, 2), + max=round(0.125 * record_bitrate * estimate_display_cnt, 2), + ) + ) + + if st.button(_t("rs_btn_encode_benchmark"), key="rs_btn_encode_benchmark_recording"): + with st.spinner(_t("rs_text_encode_benchmark_loading")): + result_df = record.record_encode_preset_benchmark_test() + st.dataframe( + result_df, + column_config={ + "encoder preset": st.column_config.TextColumn(_t("rs_text_compress_encoder")), + "support": st.column_config.CheckboxColumn(_t("rs_text_support"), default=False), + }, + ) + else: + record_encoder = config.record_encoder + record_bitrate = config.record_bitrate + st.divider() # 自动化维护选项 @@ -91,55 +165,79 @@ def render(): help=_t("rs_selectbox_compress_ratio_help"), ) - col1_encode, col2_encode, col3_encode = st.columns([1, 1, 1]) - with col1_encode: - video_compress_encoder = st.selectbox( - _t("rs_text_compress_encoder"), - list(config.compress_preset.keys()), - index=find_key_position_in_dict(config.compress_preset, config.compress_encoder), - ) - with col2_encode: - video_compress_accelerator = st.selectbox( - _t("rs_text_compress_accelerator"), - list(config.compress_preset[video_compress_encoder].keys()), - index=find_key_position_in_dict(config.compress_preset[video_compress_encoder], config.compress_accelerator), - ) - with col3_encode: - video_compress_crf = st.number_input( - _t("rs_text_compress_CRF"), - value=config.compress_quality, - min_value=0, - max_value=50, - help=_t("rs_text_compress_CRF_help"), - ) - - if st.button(_t("rs_btn_encode_benchmark")): - with st.spinner(_t("rs_text_encode_benchmark_loading")): - result_df = record.encode_preset_benchmark_test( - scale_factor=video_compress_rate_selectbox, crf=video_compress_crf + if st.toggle(_t("rs_text_show_encode_option"), key="expand_encode_option_compress"): + col1_encode, col2_encode, col3_encode = st.columns([1, 1, 1]) + with col1_encode: + video_compress_encoder = st.selectbox( + _t("rs_text_compress_encoder"), + list(CONFIG_VIDEO_COMPRESS_PRESET.keys()), + index=find_key_position_in_dict(CONFIG_VIDEO_COMPRESS_PRESET, config.compress_encoder), + ) + with col2_encode: + video_compress_accelerator = st.selectbox( + _t("rs_text_compress_accelerator"), + list(CONFIG_VIDEO_COMPRESS_PRESET[video_compress_encoder].keys()), + index=find_key_position_in_dict( + CONFIG_VIDEO_COMPRESS_PRESET[video_compress_encoder], config.compress_accelerator + ), ) - st.dataframe( - result_df, - column_config={ - "encoder": st.column_config.TextColumn(_t("rs_text_compress_encoder")), - "accelerator": st.column_config.TextColumn(_t("rs_text_compress_accelerator")), - "support": st.column_config.CheckboxColumn(_t("rs_text_support"), default=False), - "compress_ratio": st.column_config.TextColumn( - _t("rs_text_compress_ratio"), help=_t("rs_text_compress_ratio_help") - ), - "compress_time": st.column_config.TextColumn(_t("rs_text_compress_time")), - }, + with col3_encode: + video_compress_crf = st.number_input( + _t("rs_text_compress_CRF"), + value=config.compress_quality, + min_value=0, + max_value=50, + help=_t("rs_text_compress_CRF_help"), ) + if st.button(_t("rs_btn_encode_benchmark")): + with st.spinner(_t("rs_text_encode_benchmark_loading")): + result_df = record.encode_preset_benchmark_test( + scale_factor=video_compress_rate_selectbox, crf=video_compress_crf + ) + if result_df is not None: + st.text( + f'{_t("rs_selectbox_compress_ratio")}: {video_compress_rate_selectbox}, {_t("rs_text_compress_CRF")}: {video_compress_crf}' + ) + st.dataframe( + result_df, + column_config={ + "encoder": st.column_config.TextColumn(_t("rs_text_compress_encoder")), + "accelerator": st.column_config.TextColumn(_t("rs_text_compress_accelerator")), + "support": st.column_config.CheckboxColumn(_t("rs_text_support"), default=False), + "compress_ratio": st.column_config.TextColumn( + _t("rs_text_compress_ratio"), help=_t("rs_text_compress_ratio_help") + ), + "compress_time": st.column_config.TextColumn(_t("rs_text_compress_time")), + }, + ) + else: + st.error("test_video_filepath not found.") + else: + video_compress_encoder = config.compress_accelerator + video_compress_accelerator = config.compress_accelerator + video_compress_crf = config.compress_quality + st.divider() if st.button("Save and Apple All Change / 保存并应用所有更改", type="primary", key="SaveBtnRecord"): + if display_record_strategy is not None: + config.set_and_save_config("multi_display_record_strategy", record_strategy_config[display_record_strategy]) + if display_record_selection is None: + config.set_and_save_config("record_single_display_index", 1) + else: + config.set_and_save_config( + "record_single_display_index", st.session_state.display_info_formatted.index(display_record_selection) + 1 + ) + config.set_and_save_config("screentime_not_change_to_pause_record", screentime_not_change_to_pause_record) config.set_and_save_config("start_recording_on_startup", is_start_recording_on_start_app) - config.set_and_save_config("record_screen_enable_half_res_while_hidpi", record_screen_enable_half_res_while_hidpi) config.set_and_save_config("OCR_index_strategy", ocr_strategy_option_dict[ocr_strategy_option]) config.set_and_save_config("exclude_words", [item for item in exclude_words if len(item) >= 2]) + config.set_and_save_config("record_encoder", record_encoder) + config.set_and_save_config("record_bitrate", record_bitrate) + config.set_and_save_config("vid_store_day", vid_store_day) config.set_and_save_config("vid_compress_day", vid_compress_day) config.set_and_save_config("video_compress_rate", video_compress_rate_selectbox) diff --git a/windrecorder/ui/setting.py b/windrecorder/ui/setting.py index 335bb5a2..1e202cc4 100644 --- a/windrecorder/ui/setting.py +++ b/windrecorder/ui/setting.py @@ -3,7 +3,6 @@ import time from pathlib import Path -import pyautogui import streamlit as st from PIL import Image @@ -59,20 +58,6 @@ def update_database_clicked(): col1, col2 = st.columns([1, 1]) with col1: - update_db_btn = st.button( - _t("set_btn_update_db_manual"), - type="secondary", - key="update_button_key", - disabled=st.session_state.get("update_button_disabled", False), - on_click=update_database_clicked, - ) - is_shutdown_pasocon_after_updatedDB = st.checkbox( - _t("set_checkbox_shutdown_after_updated"), - value=False, - disabled=st.session_state.get("update_button_disabled", False), - ) - - with col2: # 设置ocr引擎 # if config.enable_ocr_chineseocr_lite_onnx: # check_ocr_engine() @@ -103,6 +88,25 @@ def update_database_clicked(): else: option_enable_img_embed_search = False + with col2: + if config.OCR_index_strategy == 0: + update_db_btn = st.button( + _t("set_btn_update_db_manual"), + type="secondary", + key="update_button_key", + disabled=st.session_state.get("update_button_disabled", False), + on_click=update_database_clicked, + ) + is_shutdown_pasocon_after_updatedDB = st.checkbox( + _t("set_checkbox_shutdown_after_updated"), + value=False, + disabled=st.session_state.get("update_button_disabled", False), + ) + else: + update_db_btn = False + is_shutdown_pasocon_after_updatedDB = False + st.empty() + if not st.session_state.is_cuda_available and option_enable_img_embed_search: st.warning(_t("set_text_img_emb_not_suppport_cuda")) @@ -140,50 +144,57 @@ def update_database_clicked(): with col2pb: st.session_state.ocr_screenshot_refer_used = st.toggle(_t("set_toggle_use_screenshot_as_refer"), False) - if "ocr_padding_top" not in st.session_state: - st.session_state.ocr_padding_top = config.ocr_image_crop_URBL[0] - if "ocr_padding_right" not in st.session_state: - st.session_state.ocr_padding_right = config.ocr_image_crop_URBL[1] - if "ocr_padding_bottom" not in st.session_state: - st.session_state.ocr_padding_bottom = config.ocr_image_crop_URBL[2] - if "ocr_padding_left" not in st.session_state: - st.session_state.ocr_padding_left = config.ocr_image_crop_URBL[3] + # 当检测到多显示器时提供设置选项 + if ( + st.session_state.display_count > 1 and config.multi_display_record_strategy == "all" + ): # 当使用多显示器录制时。此处所用变量会在 recording.py 先进行初始化 + crop_display_selector = st.selectbox(_t("set_text_choose_displays"), st.session_state.display_info_formatted) + crop_display_index = st.session_state.display_info_formatted.index(crop_display_selector) + else: + crop_display_index = 0 + + if "ocr_padding_URBL" not in st.session_state: + st.session_state.ocr_padding_URBL = utils.ensure_list_divisible_by_num(config.ocr_image_crop_URBL, 4) + if len(st.session_state.ocr_padding_URBL) < st.session_state.display_count * 4: # 不足时补齐参数 slot + for i in range(st.session_state.display_count - (len(st.session_state.ocr_padding_URBL) // 4)): + st.session_state.ocr_padding_URBL.extend([6, 6, 6, 3]) col1pa, col2pa, col3pa = st.columns([0.5, 0.5, 1]) with col1pa: - st.session_state.ocr_padding_top = st.number_input( + st.session_state.ocr_padding_URBL[0 + crop_display_index * 4] = st.number_input( _t("set_text_top_padding"), - value=st.session_state.ocr_padding_top, + value=st.session_state.ocr_padding_URBL[0 + crop_display_index * 4], min_value=0, max_value=40, ) - st.session_state.ocr_padding_bottom = st.number_input( + st.session_state.ocr_padding_URBL[2 + crop_display_index * 4] = st.number_input( _t("set_text_bottom_padding"), - value=st.session_state.ocr_padding_bottom, + value=st.session_state.ocr_padding_URBL[2 + crop_display_index * 4], min_value=0, max_value=40, ) with col2pa: - st.session_state.ocr_padding_left = st.number_input( + st.session_state.ocr_padding_URBL[3 + crop_display_index * 4] = st.number_input( _t("set_text_left_padding"), - value=st.session_state.ocr_padding_left, + value=st.session_state.ocr_padding_URBL[3 + crop_display_index * 4], min_value=0, max_value=40, ) - st.session_state.ocr_padding_right = st.number_input( + st.session_state.ocr_padding_URBL[1 + crop_display_index * 4] = st.number_input( _t("set_text_right_padding"), - value=st.session_state.ocr_padding_right, + value=st.session_state.ocr_padding_URBL[1 + crop_display_index * 4], min_value=0, max_value=40, ) with col3pa: image_setting_crop_refer = screen_ignore_padding( - st.session_state.ocr_padding_top, - st.session_state.ocr_padding_right, - st.session_state.ocr_padding_bottom, - st.session_state.ocr_padding_left, + st.session_state.ocr_padding_URBL[0 + crop_display_index * 4], + st.session_state.ocr_padding_URBL[1 + crop_display_index * 4], + st.session_state.ocr_padding_URBL[2 + crop_display_index * 4], + st.session_state.ocr_padding_URBL[3 + crop_display_index * 4], use_screenshot=st.session_state.ocr_screenshot_refer_used, + screenshot_display_index=crop_display_index + 1, ) st.image(image_setting_crop_refer) @@ -200,11 +211,14 @@ def update_database_clicked(): help=_t("set_help_enable_3_columns_in_oneday"), ) # 使用中文形近字进行搜索 - config_use_similar_ch_char_to_search = st.checkbox( - _t("set_checkbox_use_similar_zh_char_to_search"), - value=config.use_similar_ch_char_to_search, - help=_t("set_checkbox_use_similar_zh_char_to_search_help"), - ) + if str(config.ocr_lang).startswith("zh"): + config_use_similar_ch_char_to_search = st.checkbox( + _t("set_checkbox_use_similar_zh_char_to_search"), + value=config.use_similar_ch_char_to_search, + help=_t("set_checkbox_use_similar_zh_char_to_search_help"), + ) + else: + config_use_similar_ch_char_to_search = config.use_similar_ch_char_to_search # 搜索中推荐近似词 if config.img_embed_module_install: config_enable_synonyms_recommend = st.checkbox( @@ -313,12 +327,7 @@ def update_database_clicked(): config.set_and_save_config( "ocr_image_crop_URBL", - [ - st.session_state.ocr_padding_top, - st.session_state.ocr_padding_right, - st.session_state.ocr_padding_bottom, - st.session_state.ocr_padding_left, - ], + st.session_state.ocr_padding_URBL, ) config.set_and_save_config( "wordcloud_user_stop_words", @@ -436,16 +445,29 @@ def legal_ocr_lang_index(): # 调整屏幕忽略范围的设置可视化 -def screen_ignore_padding(topP, rightP, bottomP, leftP, use_screenshot=False): +def screen_ignore_padding(topP, rightP, bottomP, leftP, use_screenshot=False, screenshot_display_index=1): image_padding_refer = Image.open("__assets__\\setting-crop-refer-pure.png") + indicator_overdraw_color = (100, 0, 255, 80) if use_screenshot: - image_padding_refer = pyautogui.screenshot() + image_padding_refer = utils.get_screenshot_of_display(screenshot_display_index) image_padding_refer_width, image_padding_refer_height = image_padding_refer.size + else: + image_padding_refer_width = st.session_state.display_info[screenshot_display_index]["width"] + image_padding_refer_height = st.session_state.display_info[screenshot_display_index]["height"] + + if image_padding_refer_width > image_padding_refer_height: image_padding_refer_height = int(350 * image_padding_refer_height / image_padding_refer_width) image_padding_refer = image_padding_refer.resize((350, image_padding_refer_height)) - image_padding_refer_fade = Image.new("RGBA", (350, 200), (255, 233, 216, 100)) # 添加背景色蒙层 - image_padding_refer.paste(image_padding_refer_fade, (0, 0), image_padding_refer_fade) + if use_screenshot: + image_padding_refer_fade = Image.new("RGBA", (350, image_padding_refer_height), (255, 233, 216, 100)) # 添加背景色蒙层 + image_padding_refer.paste(image_padding_refer_fade, (0, 0), image_padding_refer_fade) + else: + image_padding_refer_width = int(350 * image_padding_refer_width / image_padding_refer_height) + image_padding_refer = image_padding_refer.resize((image_padding_refer_width, 350)) + if use_screenshot: + image_padding_refer_fade = Image.new("RGBA", (image_padding_refer_width, 350), (255, 233, 216, 100)) # 添加背景色蒙层 + image_padding_refer.paste(image_padding_refer_fade, (0, 0), image_padding_refer_fade) image_padding_refer_width, image_padding_refer_height = image_padding_refer.size topP_height = round(image_padding_refer_height * topP * 0.01) @@ -453,17 +475,17 @@ def screen_ignore_padding(topP, rightP, bottomP, leftP, use_screenshot=False): leftP_width = round(image_padding_refer_width * leftP * 0.01) rightP_width = round(image_padding_refer_width * rightP * 0.01) - image_color_area = Image.new("RGBA", (image_padding_refer_width, topP_height), (100, 0, 255, 80)) + image_color_area = Image.new("RGBA", (image_padding_refer_width, topP_height), indicator_overdraw_color) image_padding_refer.paste(image_color_area, (0, 0), image_color_area) - image_color_area = Image.new("RGBA", (image_padding_refer_width, bottomP_height), (100, 0, 255, 80)) + image_color_area = Image.new("RGBA", (image_padding_refer_width, bottomP_height), indicator_overdraw_color) image_padding_refer.paste( image_color_area, (0, image_padding_refer_height - bottomP_height), image_color_area, ) - image_color_area = Image.new("RGBA", (leftP_width, image_padding_refer_height), (100, 0, 255, 80)) + image_color_area = Image.new("RGBA", (leftP_width, image_padding_refer_height), indicator_overdraw_color) image_padding_refer.paste(image_color_area, (0, 0), image_color_area) - image_color_area = Image.new("RGBA", (rightP_width, image_padding_refer_height), (100, 0, 255, 80)) + image_color_area = Image.new("RGBA", (rightP_width, image_padding_refer_height), indicator_overdraw_color) image_padding_refer.paste( image_color_area, (image_padding_refer_width - rightP_width, 0), diff --git a/windrecorder/utils.py b/windrecorder/utils.py index 747586cd..dead73fb 100644 --- a/windrecorder/utils.py +++ b/windrecorder/utils.py @@ -11,12 +11,11 @@ import threading import time from contextlib import closing -from datetime import timedelta from io import BytesIO import cv2 +import mss import psutil -import pyautogui import requests from PIL import Image from pyshortcuts import make_shortcut @@ -47,8 +46,32 @@ def stop(self): # 获得屏幕分辨率 -def get_screen_resolution(): - return pyautogui.size() +def get_display_resolution(): # FIXME: remove all methods + with mss.mss() as mss_instance: + return mss_instance.monitors[0]["width"], mss_instance.monitors[0]["height"] + + +# 获得屏幕数量 +def get_display_count(): + with mss.mss() as mss_instance: + return len(mss_instance.monitors) - 1 + + +# 获得屏幕具体数值 +def get_display_info(): + with mss.mss() as mss_instance: + return mss_instance.monitors + + +# 根据mss返回的屏幕具体数值格式化显示器信息 +def get_display_info_formatted(): + info = get_display_info() + info_formatted = [] + index = 1 + for i in info[1:]: + info_formatted.append(f"Display {index}: {i['width']}x{i['height']}") + index += 1 + return info_formatted # 获取视频文件信息 @@ -697,14 +720,6 @@ def change_startup_shortcut(is_create=True): logger.info("record: Delete shortcut") -def is_win11(): - return sys.getwindowsversion().build >= 22000 - - -def get_windows_edition(): - return platform.win32_edition() - - def is_process_running(pid, compare_process_name): """根据进程 PID 与名字比对检测进程是否存在""" pid = int(pid) @@ -772,3 +787,18 @@ def check_ffmpeg_and_ffprobe(): elif not available_ffprobe: return False, "FFprobe is not available.\nPlease check the installation." return False, "Unexpected Error on checking ffmpeg and ffprobe available." + + +def ensure_list_divisible_by_num(lst, num: int): + while len(lst) % num != 0: + lst.append(0) + return lst + + +def get_screenshot_of_display(display_index): + """display_index start from 1""" + with mss.mss() as sct: + monitor = sct.monitors[display_index] + sct_img = sct.grab(monitor) + img = Image.frombytes("RGB", sct_img.size, sct_img.bgra, "raw", "BGRX") + return img