├── .github └── workflows │ ├── build.yml │ └── deploy.yml ├── .gitignore ├── .idea ├── .gitignore ├── VideoMosaic.iml ├── developer-tools.xml ├── dictionaries │ └── Administrator.xml ├── encodings.xml ├── inspectionProfiles │ ├── Project_Default.xml │ └── profiles_settings.xml ├── misc.xml ├── modules.xml ├── other.xml ├── ruff.xml ├── statistic.xml └── vcs.xml ├── .python-version ├── CHANGELOG.md ├── LICENSE ├── README.assets ├── 3.jpg ├── banner.png ├── demo.gif └── logo.png ├── README.md ├── VideoFusion.py ├── assets ├── about.html ├── images │ ├── add.ico │ ├── add_256.png │ ├── logo.ico │ ├── logo.png │ ├── start_merge.ico │ └── tooltip │ │ └── upscale.png ├── resource.qrc └── ui │ ├── concate_page.ui │ └── home_page.ui ├── bin ├── ESPCN_x2.pb ├── LapSRN_x2.pb └── cb.rnnn ├── cli_interface.py ├── docs ├── .vitepress │ ├── cache │ │ └── deps │ │ │ ├── _metadata.json │ │ │ ├── chunk-CAILPCNG.js │ │ │ ├── chunk-CAILPCNG.js.map │ │ │ ├── chunk-DQYAFVCV.js │ │ │ ├── chunk-DQYAFVCV.js.map │ │ │ ├── package.json │ │ │ ├── vitepress___@vue_devtools-api.js │ │ │ ├── vitepress___@vue_devtools-api.js.map │ │ │ ├── vitepress___@vueuse_core.js │ │ │ ├── vitepress___@vueuse_core.js.map │ │ │ ├── vitepress___@vueuse_integrations_useFocusTrap.js │ │ │ ├── vitepress___@vueuse_integrations_useFocusTrap.js.map │ │ │ ├── vitepress___mark__js_src_vanilla__js.js │ │ │ ├── vitepress___mark__js_src_vanilla__js.js.map │ │ │ ├── vitepress___minisearch.js │ │ │ ├── vitepress___minisearch.js.map │ │ │ ├── vue.js │ │ │ └── vue.js.map │ ├── config.mts │ └── theme │ │ ├── index.ts │ │ └── style.css ├── CHANGLOG.md ├── about.md ├── advanced_settings.md ├── contact_me.md ├── donated.md ├── index.md ├── normal_settings.md ├── opencv_only_settings.md ├── public │ ├── VF和达芬奇和PR的区别.png │ ├── archive_folder.svg │ ├── audio_wave.svg │ ├── black_remover.png │ ├── demo.gif │ ├── donate.png │ ├── live_stabilization.gif │ ├── logo.png │ ├── process.svg │ ├── star.svg │ ├── 不止合并.jpg │ ├── 亮度自动调整.png │ ├── 快速合并视频向导图片_1.png │ ├── 检查更新.png │ ├── 白平衡.png │ ├── 简单的设置页面.png │ ├── 视频去色块.png │ ├── 视频去色带.png │ ├── 视频帧率对画面的影响.gif │ ├── 设置引擎.png │ ├── 调整视频顺序.gif │ ├── 超分平滑.png │ ├── 输出页面.png │ ├── 重新开始.png │ ├── 随时暂停.gif │ └── 预览视频去黑边.png ├── quick-start.md └── thanks.md ├── libomp140.x86_64.dll ├── openh264-2.4.1-win64.dll ├── package.json ├── pnpm-lock.yaml ├── pyproject.toml ├── requirements-dev.lock ├── requirements.lock ├── requirements.txt ├── resource_rc.py ├── scripts ├── compile_view.py └── packaged.py ├── src ├── __init__.py ├── common │ ├── __init__.py │ ├── black_remove │ │ ├── __init__.py │ │ ├── img_black_remover.py │ │ └── video_remover.py │ ├── black_remove_algorithm │ │ ├── __init__.py │ │ ├── black_remove_algorithm.py │ │ ├── img_black_remover.py │ │ └── video_remover.py │ ├── ffmpeg.py │ ├── ffmpeg_command.py │ ├── ffmpeg_handler.py │ ├── processors │ │ ├── __init__.py │ │ ├── audio_processors │ │ │ ├── __init__.py │ │ │ ├── audio_ffmpeg_processor.py │ │ │ └── audio_processor_manager.py │ │ ├── base_processor.py │ │ ├── exe_processors │ │ │ ├── __init__.py │ │ │ ├── audio_separator_processor.py │ │ │ ├── auto_editor_processor.py │ │ │ └── exe_processor_manager.py │ │ ├── ffmpeg_processors │ │ │ ├── __init__.py │ │ │ └── ffmpeg_command_processor.py │ │ ├── opencv_processors │ │ │ ├── __init__.py │ │ │ ├── bilateral_denoise_processor.py │ │ │ ├── brightness_contrast_processor.py │ │ │ ├── crop_processor.py │ │ │ ├── deband_processor.py │ │ │ ├── deblock_processor.py │ │ │ ├── deshake_processor.py │ │ │ ├── means_denoise_processor.py │ │ │ ├── opencv_processor_manager.py │ │ │ ├── resize_processor.py │ │ │ ├── rotate_processor.py │ │ │ ├── super_resolution_processor.py │ │ │ └── white_balance_processor.py │ │ └── processor_global_var.py │ ├── program_coordinator.py │ ├── task_resumer │ │ ├── __init__.py │ │ ├── task_resumer.py │ │ └── task_resumer_manager.py │ ├── utils │ │ ├── __init__.py │ │ └── image_utils.py │ ├── video_engines │ │ ├── __init__.py │ │ ├── base_video_engine.py │ │ ├── ffmpeg_video_engine.py │ │ └── opencv_video_engine.py │ ├── video_handler.py │ ├── video_info.py │ └── video_info_reader.py ├── components │ ├── __init__.py │ ├── cmd_text_edit.py │ ├── draggable_list_widget.py │ ├── file_drag_and_drop_lineedit.py │ ├── file_treeview.py │ ├── message_dialog.py │ └── sort_tool_component.py ├── config.py ├── core │ ├── __init__.py │ ├── about.py │ ├── datacls.py │ ├── dicts.py │ ├── enums.py │ ├── paths.py │ └── version.py ├── interface │ ├── Ui_concate_page.py │ ├── Ui_home_page.py │ └── __init__.py ├── model │ ├── __init__.py │ ├── concate_model.py │ ├── home_model.py │ └── settings_model.py ├── presenter │ ├── __init__.py │ ├── concate_presenter.py │ ├── home_presenter.py │ ├── main_presenter.py │ └── settings_presenter.py ├── settings.py ├── signal_bus.py ├── utils.py └── view │ ├── __init__.py │ ├── concate_view.py │ ├── home_view.py │ ├── main_view.py │ ├── message_base_view.py │ └── settings_view.py └── tests ├── __init__.py ├── ffmpeg_handler_test.py ├── test_balck_remove_algorithm ├── __init__.py ├── img_black_remove_algorithm_test.py └── video_black_remove_algorithm_test.py ├── test_data └── images │ ├── has_black_1.png │ ├── has_black_2.jpg │ ├── no_black_1.jpg │ └── no_black_2.jpg ├── test_processors ├── __init__.py └── test_opencv_processors │ ├── __init__.py │ ├── bilateral_denoise_test.py │ ├── crop_processor_test.py │ ├── resize_processor_test.py │ └── rotate_processor_test.py ├── utils_test ├── __init__.py └── image_utils_test.py └── video_info_reader_test.py /.github/workflows/build.yml: -------------------------------------------------------------------------------- 1 | name: Build and Package with Nuitka 2 | 3 | on: 4 | workflow_dispatch: 5 | 6 | jobs: 7 | build-windows: 8 | runs-on: windows-latest 9 | 10 | steps: 11 | - name: Checkout code 12 | uses: actions/checkout@v2 13 | 14 | - name: Set up Python 15 | uses: actions/setup-python@v4 16 | with: 17 | python-version: "3.10" 18 | cache: "pip" 19 | 20 | - name: Verify Python Version 21 | run: python --version 22 | 23 | - name: Install dependencies 24 | run: | 25 | python -m pip install --upgrade pip 26 | pip install nuitka pyside6 27 | 28 | - name: Package with Nuitka 29 | run: | 30 | nuitka --show-progress --remove-output --lto=no --output-dir=./output/windows --main="video_mosaic.py" --standalone --assume-yes-for-downloads --enable-plugin=pyside6 --windows-disable-console 31 | 32 | - name: Upload Windows Artifact 33 | uses: actions/upload-artifact@v2 34 | with: 35 | name: windows-output 36 | path: ./output/windows 37 | 38 | build-linux: 39 | runs-on: ubuntu-latest 40 | 41 | steps: 42 | - name: Checkout code 43 | uses: actions/checkout@v2 44 | 45 | - name: Set up Python 46 | uses: actions/setup-python@v4 47 | with: 48 | python-version: "3.10" 49 | cache: "pip" 50 | 51 | - name: Verify Python Version 52 | run: python --version 53 | 54 | - name: Install dependencies 55 | run: | 56 | python -m pip install --upgrade pip 57 | pip install nuitka PySide6 58 | 59 | - name: Package with Nuitka 60 | run: | 61 | nuitka --output-dir=./output/linux --main="video_mosaic.py" --standalone --assume-yes-for-downloads --enable-plugin=pyside6 62 | 63 | - name: Upload Linux Artifact 64 | uses: actions/upload-artifact@v2 65 | with: 66 | name: linux-output 67 | path: ./output/linux 68 | 69 | build-macos: 70 | runs-on: macos-latest 71 | 72 | steps: 73 | - name: Checkout code 74 | uses: actions/checkout@v2 75 | 76 | - name: Install gettext 77 | run: | 78 | brew install gettext 79 | echo 'export DYLD_LIBRARY_PATH="/usr/local/opt/gettext/lib:$DYLD_LIBRARY_PATH"' >> $GITHUB_ENV 80 | 81 | - name: Set up Python 82 | uses: actions/setup-python@v4 83 | with: 84 | python-version: "3.10" 85 | architecture: "x64" 86 | cache: "pip" 87 | 88 | - name: Verify Python Version 89 | run: python --version 90 | 91 | - name: Install dependencies 92 | run: | 93 | python -m pip install --upgrade pip 94 | pip install nuitka pyside6 95 | 96 | - name: Package with Nuitka 97 | run: | 98 | nuitka --output-dir=./output/macos --main="video_mosaic.py" --standalone --assume-yes-for-downloads --enable-plugin=pyside6 99 | 100 | - name: Upload MacOS Artifact 101 | uses: actions/upload-artifact@v2 102 | with: 103 | name: macos-output 104 | path: ./output/macos 105 | -------------------------------------------------------------------------------- /.github/workflows/deploy.yml: -------------------------------------------------------------------------------- 1 | # 构建 VitePress 站点并将其部署到 GitHub Pages 的示例工作流程 2 | # 3 | name: Deploy VitePress site to Pages 4 | 5 | on: 6 | # 在针对 `master` 分支的推送上运行。 7 | # push: 8 | # branches: [master] 9 | 10 | # 允许你从 Actions 选项卡手动运行此工作流程 11 | workflow_dispatch: 12 | 13 | # 设置 GITHUB_TOKEN 的权限,以允许部署到 GitHub Pages 14 | permissions: 15 | contents: read 16 | pages: write 17 | id-token: write 18 | 19 | # 只允许同时进行一次部署,跳过正在运行和最新队列之间的运行队列 20 | # 但是,不要取消正在进行的运行,因为我们希望允许这些生产部署完成 21 | concurrency: 22 | group: pages 23 | cancel-in-progress: false 24 | 25 | jobs: 26 | # 构建工作 27 | build: 28 | runs-on: ubuntu-latest 29 | steps: 30 | - name: Checkout 31 | uses: actions/checkout@v4 32 | 33 | - name: Setup pnpm 34 | uses: pnpm/action-setup@v3 35 | with: 36 | version: 9 # 指定您需要的 pnpm 版本 37 | 38 | - name: Setup Node 39 | uses: actions/setup-node@v4 40 | with: 41 | node-version: 20 42 | cache: pnpm 43 | 44 | - name: Install dependencies 45 | run: pnpm install 46 | env: 47 | NODE_ENV: development 48 | 49 | - name: Build with VitePress 50 | run: pnpm docs:build 51 | 52 | - name: Upload artifact 53 | uses: actions/upload-pages-artifact@v3 54 | with: 55 | path: docs/.vitepress/dist 56 | 57 | # 部署工作 58 | deploy: 59 | environment: 60 | name: github-pages 61 | url: ${{ steps.deployment.outputs.page_url }} 62 | needs: build 63 | runs-on: ubuntu-latest 64 | name: Deploy 65 | steps: 66 | - name: Deploy to GitHub Pages 67 | id: deployment 68 | uses: actions/deploy-pages@v4 69 | -------------------------------------------------------------------------------- /.idea/.gitignore: -------------------------------------------------------------------------------- 1 | # 默认忽略的文件 2 | /shelf/ 3 | /workspace.xml 4 | # 基于编辑器的 HTTP 客户端请求 5 | /httpRequests/ 6 | # Datasource local storage ignored files 7 | /dataSources/ 8 | /dataSources.local.xml 9 | -------------------------------------------------------------------------------- /.idea/VideoMosaic.iml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 19 | 20 | 24 | -------------------------------------------------------------------------------- /.idea/developer-tools.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | -------------------------------------------------------------------------------- /.idea/dictionaries/Administrator.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | -------------------------------------------------------------------------------- /.idea/encodings.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /.idea/inspectionProfiles/Project_Default.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 83 | -------------------------------------------------------------------------------- /.idea/inspectionProfiles/profiles_settings.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 7 | -------------------------------------------------------------------------------- /.idea/misc.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 7 | 8 | -------------------------------------------------------------------------------- /.idea/modules.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /.idea/other.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 6 | -------------------------------------------------------------------------------- /.idea/ruff.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 6 | -------------------------------------------------------------------------------- /.idea/statistic.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 6 | -------------------------------------------------------------------------------- /.idea/vcs.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /.python-version: -------------------------------------------------------------------------------- 1 | 3.10.11 2 | -------------------------------------------------------------------------------- /README.assets/3.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/271374667/VideoFusion/9ba7b8b301481ed148bc4d853a5efe8bbb7188b5/README.assets/3.jpg -------------------------------------------------------------------------------- /README.assets/banner.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/271374667/VideoFusion/9ba7b8b301481ed148bc4d853a5efe8bbb7188b5/README.assets/banner.png -------------------------------------------------------------------------------- /README.assets/demo.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/271374667/VideoFusion/9ba7b8b301481ed148bc4d853a5efe8bbb7188b5/README.assets/demo.gif -------------------------------------------------------------------------------- /README.assets/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/271374667/VideoFusion/9ba7b8b301481ed148bc4d853a5efe8bbb7188b5/README.assets/logo.png -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |

2 | VideoFusion 3 |

4 |

一站式视频批量处理软件

5 |

6 | 点击即用,自动去黑边,智能拼接,补帧,自动调整分辨率,白平衡,AI 音频降噪 7 |

8 |

9 | 最新版本 10 | downloads 11 | License: LGPL-2.1 12 | 最后提交时间 13 | Github star数 14 |

15 | 16 | 17 | ![logo](./README.assets/banner.png) 18 | 19 | ## 10 秒爱上VideoFusion 20 | 21 | 下面这个 GIF 将会带您快速查看两个有不规则黑边的视频是如何完美去除黑边然后旋转成正确的朝向最后合并的 22 | 23 | ![10 秒爱上VideoFusion](README.assets/demo.gif) 24 | 25 | 26 | ## ✨软件介绍 27 | 28 | VideoFusion 旨在打造一个上手即用,随用随走的轻量化视频批量优化和处理工具,对于无经验的视频创作者您只需要几次点击就能实现视频拼接,AI音频降噪,超分平滑,白平衡,亮度自动调整,补帧等功能,使用 VideoFusion 帮助您预处理您的视频,您无需关心参数和更多细节,VideoFusion 会自动帮您处理好剩下复杂的逻辑 29 | 30 | VideoFusion 最初的目标是制作一个能够快速去除大量视频黑边的软件,其中还需要为对视频进行合成,但是视频的尺寸不一致,分辨率也不一致,视频内横屏还是竖屏的状态也不一致,于是我开发了这个软件,能够批量将视频去除黑边后旋转到统一尺寸最后合并,相比其他的合并软件,VideoFusion 合并视频会计算最佳的分辨率,进行缩放/旋转/剪裁等一系列操作后才会进行合并,确保大量视频哪怕分辨率不一致,合成完毕之后的黑边区域面积最小(其他软件分辨率不一致直接合并会出现大量黑边,严重影响观感) 31 | 32 | 同时 VideoFusion 对去黑边进行了特化,可以去除多种多样的黑边,视频外有 logo,多余的文字都可以进行去除 33 | 34 | 在不断的发展中 VideoFusion 功能越来越强大,经过 VideoFusion 处理过的视频,大小可能仅为原来的一半!但是画面却没有肉眼可见的损失,同时还增加了大量新的功能,例如白平衡,亮度自动调整,响度自动调整,补帧等等功能,功能非常多而且未来还会继续更新,欢迎大家前往文档查看 35 | 36 | ## 🚀功能介绍 37 | 38 | ![3](./README.assets/3.jpg) 39 | 40 | 41 |
42 |

软件详情请前往文档进行查看

43 | 点我前往文档 44 |
45 | 46 | 47 | 48 | ## 如何运行该项目 49 | 50 | ### 推荐运行方法(用户) 51 | 52 | 直接通过 Release 下载最新的版本直接点击其中的 exe 文件即可运行 53 | 54 | ### 编译运行(程序员) 55 | 56 | > 推荐运行环境 Python 3.10 57 | > 备注: 可以选择更高版本,但是不能低于 Python 3.10 58 | 59 | 通过在项目根目录下输入下面的命令安装第三方库 60 | 61 | ```cmd 62 | pip install -r requirements.txt 63 | ``` 64 | 65 | 然后运行项目根目录下的 `video_fusion.py` 文件 66 | 67 | ## 额外说明 68 | 69 | - 该软件支持 Window10 64位 以及 Window11 64位,其余 Window 版本未经过测试,不保证稳定运行 70 | - 该软件永久免费,如果您在其他地方付费下载到了该软件请马上退款止损 71 | - 如果您使用出现了问题或者对软件的建议请您在该页面提出 issue 72 | - 如果您有更多的问题请前往文档查看 -------------------------------------------------------------------------------- /VideoFusion.py: -------------------------------------------------------------------------------- 1 | import loguru 2 | from PySide6.QtWidgets import QApplication 3 | 4 | from src.core.paths import LOG_FILE 5 | from src.presenter.main_presenter import MainPresenter 6 | 7 | loguru.logger.add(LOG_FILE, rotation="1 week", retention="1 days", level="DEBUG") 8 | 9 | @loguru.logger.catch(reraise=True) 10 | def main(): 11 | app = QApplication([]) 12 | main_presenter = MainPresenter() 13 | main_presenter.get_view().show() 14 | app.exec() 15 | 16 | 17 | if __name__ == '__main__': 18 | main() 19 | -------------------------------------------------------------------------------- /assets/about.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 视频拼接工具 6 | 83 | 84 | 85 |
86 |
87 |

VideoFusion

88 |

一站式短视频合成工具

89 |
90 |
91 |

功能特色

92 |

支持多种视频格式,支持多个视频拼接,自研去黑边算法,处理复杂黑边。自动横竖屏切换,自动分辨率,大量音视频增强功能。 93 |

94 |

支持格式

95 |

支持视频格式:mp4, avi, flv, mov, wmv, mkv, rmvb, rm, 3gp, mpeg, asf, ts

96 |
97 |
98 |

联系我

99 |

邮箱: 271374667@qq.com

100 |

QQ群: 557434492

101 |

GitHub: https://github.com/271374667

102 |

Gitee: https://gitee.com/sun-programing

103 |
104 |
105 |

学习资源

106 |

观看本人录制的免费教程: https://space.bilibili.com/282527875

108 |

感谢fluent-widget对本项目的大力支持: https://github.com/zhiyiYo/PyQt-Fluent-Widgets 110 |

111 |
112 |
113 |

开源软件

114 |

本软件是开源软件,如果您是通过付费获取到的本产品请立即退款,您可以随时在GitHub上找到免费的最新版本: https://github.com/271374667/VideoMosaic

116 |
117 |
118 | 119 | -------------------------------------------------------------------------------- /assets/images/add.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/271374667/VideoFusion/9ba7b8b301481ed148bc4d853a5efe8bbb7188b5/assets/images/add.ico -------------------------------------------------------------------------------- /assets/images/add_256.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/271374667/VideoFusion/9ba7b8b301481ed148bc4d853a5efe8bbb7188b5/assets/images/add_256.png -------------------------------------------------------------------------------- /assets/images/logo.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/271374667/VideoFusion/9ba7b8b301481ed148bc4d853a5efe8bbb7188b5/assets/images/logo.ico -------------------------------------------------------------------------------- /assets/images/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/271374667/VideoFusion/9ba7b8b301481ed148bc4d853a5efe8bbb7188b5/assets/images/logo.png -------------------------------------------------------------------------------- /assets/images/start_merge.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/271374667/VideoFusion/9ba7b8b301481ed148bc4d853a5efe8bbb7188b5/assets/images/start_merge.ico -------------------------------------------------------------------------------- /assets/images/tooltip/upscale.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/271374667/VideoFusion/9ba7b8b301481ed148bc4d853a5efe8bbb7188b5/assets/images/tooltip/upscale.png -------------------------------------------------------------------------------- /assets/resource.qrc: -------------------------------------------------------------------------------- 1 | 2 | 3 | images/cancel.ico 4 | images/logo.ico 5 | images/add.ico 6 | images/start_merge.ico 7 | 8 | 9 | images/tooltip/upscale.png 10 | images/tooltip/black_remover.png 11 | images/tooltip/Deblocking.png 12 | images/tooltip/debanding.png 13 | 14 | 15 | -------------------------------------------------------------------------------- /bin/ESPCN_x2.pb: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/271374667/VideoFusion/9ba7b8b301481ed148bc4d853a5efe8bbb7188b5/bin/ESPCN_x2.pb -------------------------------------------------------------------------------- /bin/LapSRN_x2.pb: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/271374667/VideoFusion/9ba7b8b301481ed148bc4d853a5efe8bbb7188b5/bin/LapSRN_x2.pb -------------------------------------------------------------------------------- /cli_interface.py: -------------------------------------------------------------------------------- 1 | import argparse 2 | from pathlib import Path 3 | 4 | import loguru 5 | 6 | from src.core.enums import Orientation 7 | 8 | description = """批量视频处理工具 9 | 目前更加细致的设定请前往GUI中进行设置,命令只提供最基础的合并功能 10 | """ 11 | 12 | parser = argparse.ArgumentParser(description=description) 13 | parser.add_argument('-i', '--input', type=str, help='包含视频文件地址的txt文件的地址') 14 | parser.add_argument("--video_oritation", type=str, default='horization', choices=['vertical', 'horization'], 15 | help='视频的方向') 16 | 17 | 18 | def parse_args(): 19 | # 解析视频文件地址 20 | input_txt_path: Path = Path(parser.parse_args().input) 21 | video_path_list: list[Path] = [] 22 | if not input_txt_path.exists(): 23 | loguru.logger.error(f'txt文件{input_txt_path}不存在') 24 | raise FileNotFoundError(f'txt文件{input_txt_path}不存在') 25 | 26 | # 判断里面每一行是否都是路径 27 | for line in input_txt_path.read_text().replace('"', '').splitlines(): 28 | real_path = Path(line) 29 | if not real_path.exists(): 30 | loguru.logger.error(f'视频文件{line}不存在,请检查txt文件中的路径是否正确') 31 | raise FileNotFoundError(f'视频文件{line}不存在,请检查txt文件中的路径是否正确') 32 | video_path_list.append(real_path) 33 | 34 | loguru.logger.debug(f'一共加载了{len(video_path_list)}个视频文件,分别是{video_path_list}') 35 | 36 | # 解析视频朝向 37 | video_orientation = parser.parse_args().video_oritation 38 | if video_orientation == 'vertical': 39 | video_orientation = Orientation.VERTICAL 40 | else: 41 | video_orientation = Orientation.HORIZONTAL 42 | loguru.logger.debug(f'视频的方向为{video_orientation}') 43 | 44 | 45 | if __name__ == '__main__': 46 | parse_args() 47 | print(parser.print_help()) 48 | print(parser.parse_args()) 49 | -------------------------------------------------------------------------------- /docs/.vitepress/cache/deps/_metadata.json: -------------------------------------------------------------------------------- 1 | { 2 | "hash": "99249c4c", 3 | "configHash": "aacd56f1", 4 | "lockfileHash": "8b33d3c5", 5 | "browserHash": "e41b3b20", 6 | "optimized": { 7 | "vue": { 8 | "src": "../../../../node_modules/.pnpm/vue@3.4.33/node_modules/vue/dist/vue.runtime.esm-bundler.js", 9 | "file": "vue.js", 10 | "fileHash": "19228ae5", 11 | "needsInterop": false 12 | }, 13 | "vitepress > @vue/devtools-api": { 14 | "src": "../../../../node_modules/.pnpm/@vue+devtools-api@7.3.7/node_modules/@vue/devtools-api/dist/index.js", 15 | "file": "vitepress___@vue_devtools-api.js", 16 | "fileHash": "f6cf1ec2", 17 | "needsInterop": false 18 | }, 19 | "vitepress > @vueuse/core": { 20 | "src": "../../../../node_modules/.pnpm/@vueuse+core@10.11.0_vue@3.4.33/node_modules/@vueuse/core/index.mjs", 21 | "file": "vitepress___@vueuse_core.js", 22 | "fileHash": "0def523b", 23 | "needsInterop": false 24 | }, 25 | "vitepress > @vueuse/integrations/useFocusTrap": { 26 | "src": "../../../../node_modules/.pnpm/@vueuse+integrations@10.11.0_focus-trap@7.5.4_vue@3.4.33/node_modules/@vueuse/integrations/useFocusTrap.mjs", 27 | "file": "vitepress___@vueuse_integrations_useFocusTrap.js", 28 | "fileHash": "46d44b77", 29 | "needsInterop": false 30 | }, 31 | "vitepress > mark.js/src/vanilla.js": { 32 | "src": "../../../../node_modules/.pnpm/mark.js@8.11.1/node_modules/mark.js/src/vanilla.js", 33 | "file": "vitepress___mark__js_src_vanilla__js.js", 34 | "fileHash": "272d94a9", 35 | "needsInterop": false 36 | }, 37 | "vitepress > minisearch": { 38 | "src": "../../../../node_modules/.pnpm/minisearch@7.1.0/node_modules/minisearch/dist/es/index.js", 39 | "file": "vitepress___minisearch.js", 40 | "fileHash": "4951fcd4", 41 | "needsInterop": false 42 | } 43 | }, 44 | "chunks": { 45 | "chunk-CAILPCNG": { 46 | "file": "chunk-CAILPCNG.js" 47 | }, 48 | "chunk-DQYAFVCV": { 49 | "file": "chunk-DQYAFVCV.js" 50 | } 51 | } 52 | } -------------------------------------------------------------------------------- /docs/.vitepress/cache/deps/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "type": "module" 3 | } 4 | -------------------------------------------------------------------------------- /docs/.vitepress/cache/deps/vitepress___@vueuse_core.js.map: -------------------------------------------------------------------------------- 1 | { 2 | "version": 3, 3 | "sources": [], 4 | "sourcesContent": [], 5 | "mappings": "", 6 | "names": [] 7 | } 8 | -------------------------------------------------------------------------------- /docs/.vitepress/cache/deps/vue.js.map: -------------------------------------------------------------------------------- 1 | { 2 | "version": 3, 3 | "sources": [], 4 | "sourcesContent": [], 5 | "mappings": "", 6 | "names": [] 7 | } 8 | -------------------------------------------------------------------------------- /docs/.vitepress/config.mts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from 'vitepress' 2 | 3 | 4 | // https://vitepress.dev/reference/site-config 5 | export default defineConfig({ 6 | title: "VideoFusion", 7 | description: "A Video Tools", 8 | cleanUrls: true, 9 | base: '/VideoFusion/', 10 | themeConfig: { 11 | // https://vitepress.dev/reference/default-theme-config 12 | nav: [ 13 | { text: '联系我', link: '/contact_me' }, 14 | { text: '更新日志', link: '/CHANGLOG' } 15 | ], 16 | logo: '/logo.png', 17 | 18 | sidebar: [ 19 | { 20 | text: '导览', 21 | items: [ 22 | { text: '快速开始', link: '/quick-start.md' }, 23 | { text: '为什么选择 VideoFusion ?', link: '/about.md' }, 24 | 25 | ] 26 | }, 27 | { 28 | text: '设置', 29 | items: [ 30 | { text: '常规设置', link: '/normal_settings.md' }, 31 | { text: '高级设置', link: '/advanced_settings.md' }, 32 | { text: 'OpenCV引擎下专属设置', link: '/opencv_only_settings.md' }, 33 | ] 34 | }, 35 | { 36 | text: '更多', 37 | items: [ 38 | { text: '更新日志', link: '/CHANGLOG.md' }, 39 | { text: '联系我', link: '/contact_me.md' }, 40 | { text: '捐赠', link: '/donated.md' }, 41 | { text: '感谢', link: '/thanks.md' }, 42 | ] 43 | } 44 | ], 45 | 46 | 47 | socialLinks: [ 48 | { icon: 'github', link: 'https://github.com/271374667/VideoFusion' } 49 | ], 50 | 51 | search: { 52 | provider: 'local' 53 | }, 54 | outline: { 55 | level: 'deep', 56 | label: '目录' 57 | } 58 | 59 | } 60 | }) 61 | -------------------------------------------------------------------------------- /docs/.vitepress/theme/index.ts: -------------------------------------------------------------------------------- 1 | // https://vitepress.dev/guide/custom-theme 2 | import { h } from 'vue' 3 | import type { Theme } from 'vitepress' 4 | import DefaultTheme from 'vitepress/theme' 5 | import './style.css' 6 | 7 | export default { 8 | extends: DefaultTheme, 9 | Layout: () => { 10 | return h(DefaultTheme.Layout, null, { 11 | // https://vitepress.dev/guide/extending-default-theme#layout-slots 12 | }) 13 | }, 14 | enhanceApp({ app, router, siteData }) { 15 | // ... 16 | } 17 | } satisfies Theme 18 | 19 | -------------------------------------------------------------------------------- /docs/.vitepress/theme/style.css: -------------------------------------------------------------------------------- 1 | /** 2 | * Customize default theme styling by overriding CSS variables: 3 | * https://github.com/vuejs/vitepress/blob/main/src/client/theme-default/styles/vars.css 4 | */ 5 | 6 | /** 7 | * Colors 8 | * 9 | * Each colors have exact same color scale system with 3 levels of solid 10 | * colors with different brightness, and 1 soft color. 11 | * 12 | * - `XXX-1`: The most solid color used mainly for colored text. It must 13 | * satisfy the contrast ratio against when used on top of `XXX-soft`. 14 | * 15 | * - `XXX-2`: The color used mainly for hover state of the button. 16 | * 17 | * - `XXX-3`: The color for solid background, such as bg color of the button. 18 | * It must satisfy the contrast ratio with pure white (#ffffff) text on 19 | * top of it. 20 | * 21 | * - `XXX-soft`: The color used for subtle background such as custom container 22 | * or badges. It must satisfy the contrast ratio when putting `XXX-1` colors 23 | * on top of it. 24 | * 25 | * The soft color must be semi transparent alpha channel. This is crucial 26 | * because it allows adding multiple "soft" colors on top of each other 27 | * to create a accent, such as when having inline code block inside 28 | * custom containers. 29 | * 30 | * - `default`: The color used purely for subtle indication without any 31 | * special meanings attched to it such as bg color for menu hover state. 32 | * 33 | * - `brand`: Used for primary brand colors, such as link text, button with 34 | * brand theme, etc. 35 | * 36 | * - `tip`: Used to indicate useful information. The default theme uses the 37 | * brand color for this by default. 38 | * 39 | * - `warning`: Used to indicate warning to the users. Used in custom 40 | * container, badges, etc. 41 | * 42 | * - `danger`: Used to show error, or dangerous message to the users. Used 43 | * in custom container, badges, etc. 44 | * -------------------------------------------------------------------------- */ 45 | 46 | :root { 47 | --vp-c-default-1: var(--vp-c-gray-1); 48 | --vp-c-default-2: var(--vp-c-gray-2); 49 | --vp-c-default-3: var(--vp-c-gray-3); 50 | --vp-c-default-soft: var(--vp-c-gray-soft); 51 | 52 | --vp-c-brand-1: var(--vp-c-indigo-1); 53 | --vp-c-brand-2: var(--vp-c-indigo-2); 54 | --vp-c-brand-3: var(--vp-c-indigo-3); 55 | --vp-c-brand-soft: var(--vp-c-indigo-soft); 56 | 57 | --vp-c-tip-1: var(--vp-c-brand-1); 58 | --vp-c-tip-2: var(--vp-c-brand-2); 59 | --vp-c-tip-3: var(--vp-c-brand-3); 60 | --vp-c-tip-soft: var(--vp-c-brand-soft); 61 | 62 | --vp-c-warning-1: var(--vp-c-yellow-1); 63 | --vp-c-warning-2: var(--vp-c-yellow-2); 64 | --vp-c-warning-3: var(--vp-c-yellow-3); 65 | --vp-c-warning-soft: var(--vp-c-yellow-soft); 66 | 67 | --vp-c-danger-1: var(--vp-c-red-1); 68 | --vp-c-danger-2: var(--vp-c-red-2); 69 | --vp-c-danger-3: var(--vp-c-red-3); 70 | --vp-c-danger-soft: var(--vp-c-red-soft); 71 | } 72 | 73 | /** 74 | * Component: Button 75 | * -------------------------------------------------------------------------- */ 76 | 77 | :root { 78 | --vp-button-brand-border: transparent; 79 | --vp-button-brand-text: var(--vp-c-white); 80 | --vp-button-brand-bg: var(--vp-c-brand-3); 81 | --vp-button-brand-hover-border: transparent; 82 | --vp-button-brand-hover-text: var(--vp-c-white); 83 | --vp-button-brand-hover-bg: var(--vp-c-brand-2); 84 | --vp-button-brand-active-border: transparent; 85 | --vp-button-brand-active-text: var(--vp-c-white); 86 | --vp-button-brand-active-bg: var(--vp-c-brand-1); 87 | } 88 | 89 | /** 90 | * Component: Home 91 | * -------------------------------------------------------------------------- */ 92 | 93 | :root { 94 | --vp-home-hero-name-color: transparent; 95 | --vp-home-hero-name-background: -webkit-linear-gradient( 96 | 120deg, 97 | #bd34fe 30%, 98 | #41d1ff 99 | ); 100 | 101 | --vp-home-hero-image-background-image: linear-gradient( 102 | -45deg, 103 | #bd34fe 50%, 104 | #47caff 50% 105 | ); 106 | --vp-home-hero-image-filter: blur(44px); 107 | } 108 | 109 | @media (min-width: 640px) { 110 | :root { 111 | --vp-home-hero-image-filter: blur(56px); 112 | } 113 | } 114 | 115 | @media (min-width: 960px) { 116 | :root { 117 | --vp-home-hero-image-filter: blur(68px); 118 | } 119 | } 120 | 121 | /** 122 | * Component: Custom Block 123 | * -------------------------------------------------------------------------- */ 124 | 125 | :root { 126 | --vp-custom-block-tip-border: transparent; 127 | --vp-custom-block-tip-text: var(--vp-c-text-1); 128 | --vp-custom-block-tip-bg: var(--vp-c-brand-soft); 129 | --vp-custom-block-tip-code-bg: var(--vp-c-brand-soft); 130 | } 131 | 132 | /** 133 | * Component: Algolia 134 | * -------------------------------------------------------------------------- */ 135 | 136 | .DocSearch { 137 | --docsearch-primary-color: var(--vp-c-brand-1) !important; 138 | } 139 | 140 | -------------------------------------------------------------------------------- /docs/CHANGLOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | ## [v1.12.5] - 2024-08-01 4 | 5 | ### Added 6 | - 新增一个非常直观的gif demo让人10秒内了解该软件 (147ef46)。 7 | - 新增一个全新的banner (147ef46)。 8 | 9 | ### Changed 10 | - 完善了README的介绍,修改了徽章显示不了的问题 (7b49364)。 11 | - 修改README内结构逻辑,为README添加徽章 (567d805)。 12 | - 移动README里面的捐赠到文档当中,让README拥有更加一致的阅读体验 (4f78b76)。 13 | - 修改文档里面github跳转为当前项目(之前是跳到个人仓库) (4e8bfed)。 14 | - 优化了 README 以及文档 (ede6b91, bcbd0ce)。 15 | 16 | ### Fixed 17 | - 修复了任务恢复系统在找不到本地文件的时候会报错的bug (147ef46)。 18 | - 修复了不检测所有任务是否完成而直接检测总任务是否完成的bug (147ef46)。 19 | - 修复了徽章版本不显示的bug (3392e88)。 20 | - 通过固定窗体大小修复了在运行完毕之后窗口会“长高”的bug (14b8cc9)。 21 | - 修复了开始一个空任务导致提示输入视频不能为空的bug (091a9d6)。 22 | - 修复了文档内图片失效的问题以及README文字居中问题 (24fd63f)。 23 | 24 | ### Documentation 25 | - 完善了文档内的重新开始的提示和文档的CHANGLOG (091a9d6)。 26 | - 为README添加了徽章 (567d805)。 27 | - 修复了README徽章显示不了的问题 (7b49364)。 28 | 29 | ### UI 30 | - 现在第一次进入设置页面不会再弹出显示当前引擎的弹窗 (9398b4b)。 31 | 32 | ### Action 33 | - 现在的action将仅使用手动触发 (091a9d6)。 34 | 35 | ## [v1.12.2] - 2024-07-31 36 | 37 | 这一次相比上一个 v0.32.4 版本增加了大量的新内容,整个底层的逻辑完全重写了,于是现在大版本号进化到了 v1,相比起之前这一次多了很多新的内容 38 | 39 | 1. 增加了一个新的处理引擎,使用功能更加强大的 OpenCV 引擎能够处理更加多样化的视频需求,同时也可以切换回 FFmpeg 引擎,以此来保证速度 40 | 2. 新增了备份系统,如果在合成的过程中报错了,程序会记住报错文件,下一次能直接处理失败的文件 41 | 3. 优化了 FFmpeg 引擎模式下除了 H264 编码器外的所有模式 42 | 4. 现在使用终止程序,程序会立即停下,并且马上释放资源 43 | 5. 优化了对没有音频的视频的处理,现在会自动加一个音频(以前会直接报错) 44 | 6. 完整的说明文档,您可以在仓库页面找到他们 45 | 7. 更多全新的处理器(OpenCV 模式下) 46 | 8. 界面细节进一步优化,更加符合操作直觉 47 | 48 | ### Added 49 | - 主页删除视频时预览画面现在会实时刷新 (02f8c2b)。 50 | - 显示了备份恢复,如果上一次任务没有完成将会自动恢复上一次的任务 (05d7d3a)。 51 | - 完整实现了FFmpegEngine (640833d)。 52 | - 现在的ffmpeg报错信息染色会更加全面 (640833d)。 53 | - FFmpegEngine模式下使用了新的填充黑边的方式 (640833d)。 54 | - 使用策略模式分离VideoEngine,现在OpenCV和FFmpeg都有自己的方法 (31ab275)。 55 | - 设置页面的版本更新说明使用了新的messagebox提示框 (4513ed9)。 56 | - 新增一个MessageBox可以使用一个无边框的信息弹框 (4513ed9)。 57 | - 新增处理模式,未来可以选择使用什么引擎处理视频(ffmpeg,opencv) (7f3c11b)。 58 | - 新增了文件树控件,为未来批量视频压缩功能做准备 (7f3c11b)。 59 | - 现在会自动为没有音频轨道的视频添加音频 (82d0b20)。 60 | - 现在软件的使用时间会在日志中输出 (af020ec)。 61 | - 添加了一个真正的中断子线程的方法 (e3022a1)。 62 | - 新增白平衡 (545e05c)。 63 | - 新增亮度调整 (545e05c)。 64 | - 新增视频AI平滑 (545e05c)。 65 | - 新增了音频降噪处理器 (6c76102)。 66 | - 新增一个线程超时自动杀死的函数 (896c94f)。 67 | - 新增一个视频画面调整尺寸处理器 (896c94f)。 68 | - 新增ESPCN视频超分AI模型以及处理器 (c49b4ef)。 69 | - 新增LapSRN视频超分AI模型以及处理器 (c49b4ef)。 70 | - 新增视频亮度和对比度调节处理器 (c49b4ef)。 71 | - 新增白平衡处理器 (c34baae)。 72 | - 新增了命令行调用方式 (e86d5c4)。 73 | - 视频额外增加一层OpenCV处理层,未来能加入更多东西 (e86d5c4)。 74 | 75 | ### Changed 76 | - 修改vitepress的base为VideoFusion (7d036c7)。 77 | - 现在设置页面中仅OpenCV支持的功能被单独移动到了新的分组方便区分 (ec14943)。 78 | - 现在设置页面的提示条消失的更快 (ec14943)。 79 | - 重新修改视频降噪策略枚举类的值为ffmpeg的字符串 (640833d)。 80 | - task_resumer新增一个获取输出路径的属性 (31ab275)。 81 | - ffmpeg_handler中的一些私有方法现在转为公开方法 (31ab275)。 82 | - video_handler的入参从task_resumer转换为更加通用的Path (31ab275)。 83 | - 不再支持自动计算最大化音频采样率,以及移除相关依赖 (6c76102)。 84 | - 修改了crop处理器的处理条件 (545e05c)。 85 | - 修改了设置文件为设置文件夹,现在所有的视频都会输出到输出文件夹中 (bc92ba7)。 86 | 87 | ### Fixed 88 | - 修复了H265格式下过于先进的参数会导致有一些老旧视频无法支持的bug(已经退回到更加兼容的参数) (99ad599)。 89 | - 修复了少量静态视频在合并的时候长时间处于获取最佳分辨率的bug (02f8c2b)。 90 | - 修复因为package.json不存在导致构建失败的bug (f2d284c)。 91 | - 修复了H265格式编码导致的报错 (c5fb666)。 92 | - 修复了在小部分情况下FFmpegEngine因为scale小于pad会报错的问题 (640833d)。 93 | - 修复了设置页面修改临时目录等选项的时候ToolTip不会发生改变,而是需要重启才会发生改变的bug (7f3c11b)。 94 | - 修复了多个视频合并的时候crop_processor剪裁会出现错误 (6696f8c)。 95 | - 修复了获取视频信息时出现的一个逻辑漏洞 (6696f8c)。 96 | - 修复了提取音频的时候出现多个输出结果的bug (6696f8c)。 97 | - 修复了旋转角度为Nothing的时候会导致报错的bug (e3022a1)。 98 | - 修复了横屏报错的bug (e3022a1)。 99 | - 修复了processor_global_var可能全局不唯一的bug (e3022a1)。 100 | - 修复了参数传入的时候会被清除导致没能正确设置的bug (e3022a1)。 101 | - 修复了分析视频的时候主进度条不会发生改变的bug (e3022a1)。 102 | - 修复了结束的时候没有正确的完成信号的bug (e3022a1)。 103 | - 修复了即使不勾选删除临时文件夹也会导致文件夹被删除的bug (1e98c52)。 104 | - 修复了退出后无法删除临时文件夹的bug (fdfb5a0)。 105 | - 修复了video_info_reader模块中黑边逻辑的bug (bc92ba7)。 106 | 107 | ### Removed 108 | - 删除vitepress的base (5c84fa7)。 109 | - 删除了无用了函数 (733aeb2)。 110 | 111 | ### Documentation 112 | - 更新了文档视频顺序调整的部分 (fcd1c39)。 113 | - 调整了文档内导航栏的结构 (fcd1c39)。 114 | - 添加了完整的 VideoFusion 的介绍 (42c5367)。 115 | 116 | ### Optimization 117 | - 现在设置页面如果不是静态去黑边模式则视频采样率滑条会保存不可选状态 (42c5367)。 118 | - 优化了报错页面的报错捕获条件 (c5fb666)。 119 | - 优化了重新编译的参数,现在H265等其他编码器也有更好的效果 (d9dc12f)。 120 | - 视频不需要合并的时候不会修改分辨率 (bc92ba7)。 121 | - 将原本ffmpeg_handler中的compress_video中的职责分给reencode_video使其更加符合单一职责原则 (bc92ba7)。 122 | - 优化了rotate_processor中获取值的方式,让其更加安全 (af020ec)。 123 | - 重构了crop_processor中变量的访问权限 (af020ec)。 124 | - 重构了TempDir在其他模块中的调用 (e3022a1)。 125 | - 使用黑板模式代替DTO传递数据,更加灵活 (896c94f)。 126 | - 使用ffmpeg_handler将所有需要用到ffmpeg的地方都整合到了这里 (896c94f)。 127 | - 使用video_handler将所有对视频的操作整合到了这里 (896c94f)。 128 | - 重构了剪裁处理器 (8cbdcb6)。 129 | - 重构了旋转处理器 (8cbdcb6)。 130 | - 使用OpenCV重构了去色带模块 (c34baae)。 131 | - 使用OpenCV重构了去色块模块 (c34baae)。 132 | - 使用OpenCV重构了去抖动模块 (c34baae)。 133 | - 使用OpenCV重构了视频降噪模块 (c34baae)。 134 | - 使用OpenCV重构了视频均值降噪模块模块 (c34baae)。 135 | - 重构了获取视频信息的方式 (e86d5c4)。 136 | - 重构了图片去黑边的方式,从中提取为单独的工具类,增加复用 (e86d5c4)。 137 | - 重构两个去黑边方式,令他们面向对象 (e86d5c4)。 138 | 139 | ### Dependence 140 | - 更新了nuitka和fluent-widget依赖 (4513ed9)。 141 | - 重新依赖opencv-contrib-python (c34baae)。 142 | 143 | ### Actions 144 | - 修改pnpm大版本号为9 (981ad52)。 145 | - 试图让action能够识别pnpm-lock.yaml (0886911)。 146 | - 修改pnpm的版本为9 (7ee8a1d)。 147 | - 指定了pnpm的版本 (5666ff5)。 148 | - 修改action中npm为pnpm (599feb7)。 149 | - 新增了自动部署 vitepress 的 action (42c5367)。 150 | 151 | ### Tests 152 | - 新增resize_processor的测试 (896c94f)。 153 | - 为剪裁和旋转新增了测试 (8cbdcb6)。 154 | - 新增了更多的测试 (c34baae)。 155 | - 新增了测试4个测试 (e86d5c4)。 156 | 157 | ## [v0.32.4] - 2024-07-06 158 | 159 | ### Added 160 | - 新增失败提示条,程序出错之后会在主页面弹出提示条进行提醒 (cdd175f)。 161 | - 现在会检测ffprobe是否能够成功载入 (a6baa74)。 162 | 163 | ### Changed 164 | - 更新了nuitka和fluent-widget相关依赖 (cc5b21c)。 165 | - 更新了所有的依赖 (a6baa74)。 166 | 167 | ### Fixed 168 | - 修复了自动去黑边因为视频剪裁宽高不一致而导致的小概率出现合成失败的bug (cdd175f)。 169 | - 修复了ffprobe载入失败的bug (a6baa74)。 170 | 171 | -------------------------------------------------------------------------------- /docs/about.md: -------------------------------------------------------------------------------- 1 | # VideoFusion 快速介绍 2 | 3 | ## VideoFusion 是什么? 4 | 5 | **VideoFusion 被开发出来是为了能让更多没有基础的朋友也能快速批量优化自己的视频**,所以会尽可能的减少普通用户操作的空间,相比起其他的开源视频处理工具,VideoFusion 限制了用户能够调整的参数细节,您无需苦心积虑的考虑这个参数是 0.1 好还是 0.2 好,所有的参数都被提前调优,您能接触到的功能将会仅仅只是一个开关,开启一个开关 VideoFusion 会自动帮您处理好剩下复杂的逻辑,您无需关心参数和更多细节 6 | 7 | ![简单的设置页面](/简单的设置页面.png) 8 | 9 | VideoFusion 在精细化处理每一个视频的同时依旧不忘初心,之后的功能都会支持批量处理的功能,您可以在设置好参数之后就可以点击开始然后去喝杯咖啡,陪陪孩子,VideoFusion 会处理好您的视频 10 | 11 | ## VideoFusion 不是什么? 12 | 13 | VideoFusion 不是 Pr,达芬奇那样精密的视频剪辑工具,而是一款剑走偏锋的软件,**VideoFusion主要是用来处理大量视频**,同时你有不需要对视频有精细化的裁剪便可以尝试使用 VideoFusion 14 | 15 | 你没有办法精确到每一帧的修改视频,而是使用诸如 AI 降噪,视频补帧之类的抽象概念对视频进行修改视频,所以可能在细节上会和您想象中的有所出入,不过 VideoFusion 依然可以当做您精细化处理视频的预处理操作 16 | 17 | ![VF和达芬奇和PR的区别](/VF和达芬奇和PR的区别.png) 18 | 19 | ## VideoFusion 的适用场景有哪些? 20 | 21 | 1. 素材整理 22 | 2. 日常 vlog 压缩 23 | 3. 批量视频合并 24 | 4. 视频音频降噪 25 | 5. 简单的视频增强 26 | 27 | 同学聚会,素材整理,日常 vlog,亦或是从网络上下载的有趣的小片段,他们并不值得我们花费大量时间在 Pr 里面精心剪裁,苦心积虑的添加音乐,后期二次调色等等,我们想要的只是为了取悦自己,或者留下一个美好的回忆,相比起操作各种专业剪辑软件中大量的时间消耗,如果您更加在意只是希望快速的批量调整一下视频的声音,或者觉得几个 G 的超高清影片有点过于占用硬盘空间,那么不要犹豫,请使用 VideoFusion,VideoFusion 能帮您处理好一切 -------------------------------------------------------------------------------- /docs/advanced_settings.md: -------------------------------------------------------------------------------- 1 | # 视频设置 2 | 3 | ## 设置输出文件路径 4 | 5 | 在这里设置输出文件夹的路径,需要注意,该路径内部最好不要存放其他文件,有可能会被输出的同名文件覆盖 6 | 7 | 默认情况下会在本地创建一个 output 文件夹并输出 8 | 9 | ## 视频去色带 10 | 11 | 色带是视频中常见的一种瑕疵,尤其在8位YUV格式的视频中2。它产生的原因是编码精度不足。具体来说,由于8位编码的限制,颜色渐变区域的编码值数量不足,导致显示器上的渐变效果被表现成了明显的色阶。 12 | 13 | ![视频去色带](/视频去色带.png) 14 | 15 | 16 | ## 视频去色块 17 | 18 | 块效应通常出现在视频的低比特率压缩中,尤其是在使用块编码(如H.264、MPEG-2等)时,视频中有块状分区 19 | 20 | ![视频去色块](/视频去色块.png) 21 | 22 | ## 视频去抖动 23 | 24 | 视频去抖动,参考了这位大佬的代码: https://github.com/lengkujiaai/video_stabilization 25 | 26 | ![视频去抖动](/live_stabilization.gif) 27 | 28 | ::: warning 警告 29 | 同时该选项会出现大量黑边 30 | ::: 31 | 32 | ## 输出视频帧率 33 | 34 | 视频帧率越高,视频越流畅,但是处理时长越长 35 | 36 | ![视频帧率对画面的影响](/视频帧率对画面的影响.gif) 37 | 38 | ## 去黑边采样帧率 39 | 40 | 该选项可以控制去黑边的采样率,VideoFusion 会从视频里面获取这些数量的帧,然后对他们每一张进行去黑边处理,最后获取出现次数最高的可能性作为整个视频的去黑边基础,所以如果您发现您的视频被剪裁多了,您可以尝试加大该选项的值 41 | 42 | ::: tip 提示 43 | 仅在使用静态去黑边算法的时候该选项有效,[点我跳转到静态去黑边文档处](#静态去黑边) 44 | ::: 45 | 46 | ## 视频黑边去除算法 47 | 48 | ### 静态去黑边 49 | 50 | 通过自研的算法实现单张去黑边,然后将整个视频去黑边的结果进行统计,出现次数最多的作为视频真正的去黑边值,适合在静态图片的时候使用,该选项在视频较短的时候速度比动态去黑边快很多,但是准确率有待提高 51 | 52 | ### 动态去黑边(推荐) 53 | 54 | 使用变动画面叠加的方式来获取到持续发生改变的区域,该区域作为真正的画面(默认黑边不会动),如果黑边是会动的黑边那么则无法实现去黑边,可以尝试使用静态去黑边,或者使用专业软件进行剪裁 55 | 56 | ![去黑边算法](/black_remover.png) 57 | 58 | ## 视频音量自动调整 59 | 60 | 用于解决视频音量过大或者过小的问题,响度标准来自中国政府统一标准 61 | 62 | 《国家广播电视总局关于发布《网络视听节目音频响度技术要求和测量方法》等三项广播电视和网络视听行业标准的通知》:https://www.gov.cn/zhengce/zhengceku/202308/content_6900294.htm 63 | 64 | 目前一共有 4 种可以设置的值 65 | 66 | 1. 关闭 67 | 2. 电台(适合嘈杂的环境) 68 | 3. TV(适合普通环境) 69 | 4. 电影(适合安静环境) 70 | 71 | ## 音频降噪 72 | 73 | ### 静态降噪 74 | 75 | 通过削去高频和低频的声音达到降噪的目的,参数经过测试可以实现大部分的降噪效果,速度快,但是对音乐或者特殊场合支持不友好,可能会消去重要声音 76 | 77 | ### AI 降噪(推荐) 78 | 79 | 使用 FFmpeg 的 arnndn 模型对音频进行降噪,速度同样也很快,不过对音乐的高频部分有部分削弱 80 | 81 | ## 视频降噪 82 | 83 | ### bilateral(双边滤波) 84 | 85 | 双边滤波器在平滑图像的同时保留边缘,这对于避免图像模糊非常重要,算法相对简单,计算效率较高 86 | 87 | ### NLMeans Filter(非局部均值滤波) 88 | 89 | 非局部均值滤波可以更好地去除噪声,同时保留更多的细节和纹理。它通过搜索整个图像中的相似块进行降噪,适应性较强,能够处理不同类型的噪声。对于复杂的、空间相关的噪声也有很好的去除效果。对低光视频拍摄中的噪声去除、老旧影片的修复都很有帮助 90 | 91 | :::warning 警告 92 | nlmeans 方法降噪会大幅增加视频处理时间(成倍增长),请谨慎使用 93 | ::: 94 | 95 | ## 分辨率缩放算法 96 | 97 | ### 1. NEAREST(最近邻插值) 98 | 优势: 99 | - 计算简单:最近邻插值的计算量最小,因此速度非常快。 100 | - 无额外模糊:不会引入额外的模糊,保持原始像素的硬边缘。 101 | 102 | 适用场所: 103 | - 实时处理:需要快速计算的场合,例如实时视频流处理。 104 | - 低分辨率图像:放大像素艺术(如像素游戏图像)时效果较好。 105 | 106 | 缺点: 107 | - 图像质量低:可能产生锯齿效应和马赛克现象,缩放后图像质量较差。 108 | - 细节丢失:不适合需要高质量图像的场合。 109 | 110 | ### 2. BILINEAR(双线性插值) 111 | 优势: 112 | - 平滑过渡:相比最近邻插值,双线性插值在图像缩放时能产生平滑过渡效果。 113 | - 计算较快:计算复杂度较低,适合快速处理需求。 114 | 115 | 适用场所: 116 | - 一般视频缩放:适用于对质量要求不高的常规视频缩放。 117 | - 实时应用:如网络视频流、视频聊天等需要快速响应的场合。 118 | 119 | 缺点: 120 | - 细节损失:图像边缘会变得模糊,细节部分容易丢失。 121 | - 模糊效果:较大的缩放比例会导致图像明显模糊。 122 | 123 | ### 3. BICUBIC(双三次插值) 124 | 优势: 125 | - 高质量:相比双线性插值,双三次插值在缩放过程中能更好地保留细节。 126 | - 平滑效果好:平滑效果优于双线性插值,缩放后的图像更自然。 127 | 128 | 适用场所: 129 | - 图像处理:适用于需要较高质量图像缩放的场合,如图像编辑和照片处理。 130 | - 视频后期制作:对视频质量要求较高的后期制作过程。 131 | 132 | 缺点: 133 | - 计算复杂:计算量较大,处理时间较长,不适合实时处理。 134 | - 可能产生伪影:某些情况下,可能会产生轻微的伪影或边缘增强现象。 135 | 136 | ### 4. LANCZOS(Lanczos重采样) 137 | 优势: 138 | - 高保真度:Lanczos滤波器能最大限度地保留图像细节,缩放效果非常好。 139 | - 平滑自然:提供比双三次插值更好的平滑效果,减少伪影和模糊。 140 | 141 | 适用场所: 142 | - 高分辨率视频:适合需要保持高分辨率和细节的场合,如4K视频处理。 143 | - 视频转码和放大:对画质有较高要求的场合,如电影制作和视频修复。 144 | 145 | 缺点: 146 | - 计算复杂:计算量大,处理时间长,通常不适用于实时应用。 147 | - 边缘效应:某些情况下,可能会在高对比度边缘处产生轻微的振铃效应。 148 | 149 | ### 5. SINC(Sinc插值) 150 | 优势: 151 | - 理论最佳:在理论上,Sinc插值可以提供最理想的重建效果,最大程度保留原始信息。 152 | - 高保真度:能很好地保留图像细节,减少失真。 153 | 154 | 适用场所: 155 | - 高精度图像处理:适用于需要最高精度的图像和视频处理场合,如科学图像处理和高精度视频修复。 156 | - 专业视频制作:用于需要最高质量的专业视频制作和修复。 157 | 158 | 缺点: 159 | - 计算复杂:计算量非常大,处理时间长,不适用于实时处理。 160 | - 振铃效应:可能会产生振铃效应(ringing artifacts),尤其是在高对比度边缘。 161 | 162 | ### 总结 163 | - NEAREST:适合实时处理和像素艺术,但质量较低。 164 | - BILINEAR:适合一般视频缩放,速度快但模糊。 165 | - BICUBIC:适合需要高质量的图像和视频处理,但计算复杂。 166 | - LANCZOS:适合高分辨率和高质量视频处理,但计算复杂。 167 | - SINC:适合最高精度和专业用途,但计算最复杂。 168 | 169 | ## 视频补帧算法 170 | 171 | ### 普通补帧(Frame Interpolation) 172 | 173 | 普通视频补帧方法(如重复帧、线性插值)通常计算简单,速度较快。 174 | 175 | ### 光流法视频补帧(Optical Flow Interpolation) 176 | 177 | 光流法利用运动矢量进行插值,能够生成高质量的中间帧,减少模糊和伪影。插值帧效果更自然,尤其在处理复杂运动场景时表现优异。能够更好地保留图像细节和边缘,提升视觉体验。 178 | 179 | :::warning 警告 180 | 启用该选项会大幅增加处理时间 181 | 182 | 光流法计算量大,处理时间长,需要高性能的计算资源。 183 | ::: 184 | 185 | ## 输出视频编码 186 | 187 | **默认情况下推荐只使用 H264 模式(速度均衡,压缩率高,画面损失肉眼几乎不可见)**,如果您拥有显卡,使用硬件编码速度会非常快,不过您需要确保您的环境支持使用硬件编码 188 | 189 | 一些编码器依赖于特定的硬件加速,所以需要确保你的系统中有相应的硬件。例如: 190 | 191 | *h264_qsv 和 hevc_qsv 需要Intel CPU并启用Quick Sync Video。* 192 | 193 | *h264_amf 和 hevc_amf 需要AMD GPU并启用AMF。* 194 | 195 | *h264_nvenc 和 hevc_nvenc 需要NVIDIA GPU并启用NVENC。* 196 | 197 | 确保你安装了最新的硬件驱动,以及相应的依赖库。例如: 198 | 199 | *Intel: 需要安装Intel Media SDK。* 200 | 201 | *AMD: 需要安装AMF SDK。* 202 | 203 | *NVIDIA: 需要安装NVIDIA驱动和CUDA。* 204 | 205 | ## 音频采样率 206 | 207 | 音频采样率,决定视频的音频质量的好坏,现在有下面的这些值支持选取 208 | 209 | - "8kHz-电话音质", 210 | - "16kHz-低质量音乐录音", 211 | - "22.05kHz-AM广播质量", 212 | - "32kHz-FM广播质量", 213 | - "44.1kHz-CD音质", 214 | - "96kHz-高解析度音频" -------------------------------------------------------------------------------- /docs/contact_me.md: -------------------------------------------------------------------------------- 1 | # 联系我 2 | 3 | :email: 271374667@qq.com 4 | 5 | :radio: QQ群:557434492 6 | 7 | :computer: B站:https://space.bilibili.com/282527875 8 | 9 | 23 | 24 |
25 | 26 |
27 | 28 | -------------------------------------------------------------------------------- /docs/donated.md: -------------------------------------------------------------------------------- 1 | # 捐赠 2 | 3 | 开源的梦想是一个崇高的事业,它使得社区成长, 代码得以培育。 根据自由开源的理念,VideoFusion致力于Good Labs的原则 。 4 | 5 | 如果我的工作对您的事业产生了帮助,请考虑将一些资金返还给开发团队,您的捐赠会让 VideoFusion更好的发展和更新,让我知道还有人在使用本产品,开源的财务支持字面上意味着开发项目的人有另外的理由坚持下去 6 | 7 | 许多有前途的开源项目变成了“放弃软件”,因为最初的开发人员已经失去了动力。 维护阶段实际上是许多开源项目死亡的地方。 金钱是一种很好的社区反馈,它说“坚持下去并继续维护这个项目!我们需要你!!!” 8 | 9 | ## 你从中获得了什么[¶](https://somethingcool.top/SimpleWMS/donation.html#_2) 10 | 11 | - 如果您支持开源,您将成为社区的一员。 这是一个庞大的社区,但往往紧密结合。 您的支持表明您相信社区,这使您成为家庭的一部分。 如果足够的财务支持汇集在一起,也可能意味着核心开发人员和维护人员能够全职工作而不需要在其他地方工作。 这反过来又促进了一个环境,在这个环境中,他们能够比在“业余爱好”的基础上更快地处理修复和请求。 12 | - 因此,也许您是一家使用开源软件为自己提供竞争优势、提高利润或简单解决其他软件无法解决的问题的企业。 也许你是一个使用开源解决方案的个人,你无法承受昂贵的企业软件。就像这个例子。 13 | - 或者您认识到数万行代码是您不必编写的行,仅仅只是感谢这背后的努力。 14 | - 无论您使用什么样的开源软件,都要想到那些花了很长时间开发它并让它为您运行的人。 15 | - 即使是一笔小额捐款也可能对他们产生巨大的影响,尤其是因为这意味着有人会非常感激他们并让他们知道。 16 | 17 | ## 如何捐赠?[¶](https://somethingcool.top/SimpleWMS/donation.html#_3) 18 | 19 | 您可以通过邮箱联系我进行捐赠,也可以通过下方的二维码进行捐赠 20 | 21 | ![donate](/donate.png) -------------------------------------------------------------------------------- /docs/index.md: -------------------------------------------------------------------------------- 1 | --- 2 | # https://vitepress.dev/reference/default-theme-home-page 3 | layout: home 4 | 5 | hero: 6 | name: "VideoFusion" 7 | text: "批量化视频处理工具" 8 | tagline: | 9 | 自动去黑边,视频合并,AI降噪,视频压缩,无需繁杂操作 10 | 轻轻点击,应有尽有 11 | image: 12 | src: /logo.png 13 | alt: VideoFusion图标 14 | actions: 15 | - theme: brand 16 | text: 快速开始 17 | link: /quick-start 18 | - theme: alt 19 | text: 什么是VideoFusion? 20 | link: /about 21 | 22 | features: 23 | - title: 自动去黑边 24 | details: 自动识别视频黑边,可去除复杂彩色黑边,去除不规则黑边,去除黑边水印 25 | icon: 26 | src: /star.svg 27 | - title: 批量压缩 28 | details: 保持画质的情况下,大幅压缩视频大小,节省内存空间 29 | icon: 30 | src: /archive_folder.svg 31 | - title: 音频优化 32 | details: 自动调整音频响度,AI自动对音频降噪等 33 | icon: 34 | src: /audio_wave.svg 35 | - title: 视频快速二次处理 36 | details: 大量视频处理选项,白平衡,亮度自动调整,去色带,去抖动,应有尽有! 37 | icon: 38 | src: /process.svg 39 | --- 40 | 41 | --- 42 | 43 |
44 |

10 秒爱上VideoFusion

45 | 简单 3 步,去除视频黑边,同时合并视频,一切都是如此简单 46 | 47 | ![10 秒爱上VideoFusion](/demo.gif) 48 | 49 |
50 |
-------------------------------------------------------------------------------- /docs/normal_settings.md: -------------------------------------------------------------------------------- 1 | # 设置 2 | 3 | 点击左侧侧边栏最下面的一个按钮进入设置页面,接下来所有的设置都会在这个页面进行 4 | 5 | ## 引擎选择 6 | 7 | VideoFusion 一共提供了 2 种底层引擎供您处理视频 8 | 9 | 1. FFmpeg (更快) 10 | 2. OpenCV (功能更多) 11 | 12 | 您可以在设置页面里面选择不同的引擎,相比起 FFmpeg,OpenCV 能实现更加强大的功能(比如自动白平衡,视频亮度自动调整等),有一些功能仅仅在引擎为 OpenCV 模式下才能正常使用 13 | 14 | ![设置引擎](/设置引擎.png) 15 | 16 | ## FFmpeg 路径设置 17 | 18 | 软件的一大半功能都需要 FFmpeg 的支持,**VideoFusion 内置一个完整编译版本的 FFmpeg,所以您无需在电脑上额外安装 FFmpeg**,不过您也可以自行前往官网下载最新的 ffmpeg.exe 将它放入并替换根目录下的 bin 目录里面的 ffmpeg.exe 19 | 20 | ffmpeg.exe 下载地址: 21 | https://ffmpeg.org/download.html 22 | 23 | ## 合并视频(重要) 24 | 25 | 如果您不需要视频合并在一起,只是需要视频经过简单的处理,您可以取消勾选该选项,最终将会将视频分别输出,同时不会再二次缩放视频的分辨率 26 | 27 | 默认情况下为勾选状态 28 | 29 | ## 临时目录 30 | 31 | 视频处理的过程中会生成中间过程文件,这些文件通常和视频同等大小,不过不用担心,他们通常会在软件关闭的时候自动被清理,所以您不用担心硬盘会被过度占用,不过在运行的过程中依然建议使用一个较大的空间的盘符来存储视频临时文件 32 | 33 | 默认情况下会在项目根目录下创建一个 Temp 文件夹,在运行完毕之后会被释放 34 | 35 | ## 是否删除临时文件 36 | 37 | 您可以选择不删除临时文件,从里面找到一些有用的东西,或者分析视频为什么没有被成功的输出,不过需要注意,如果您勾选了删除临时文件,那么他们无法从回收站被找回 38 | 39 | ## 预览视频去黑边 40 | 41 | 将主页中的预览图片去除黑边 42 | 43 | ![预览视频去黑边](/预览视频去黑边.png) 44 | 45 | ::: info 提示 46 | 视频预览不代表最终处理结果,仅仅是一个预览,视频真正处理时会采用更加复杂的判断方法,单张图片的去黑边并不准确 47 | ::: 48 | 49 | 默认情况下,该选项是关闭的 50 | 51 | ## 预览视频帧 52 | 53 | 通过这个选项可以改变在主页预览视频的时候是哪一帧的画面,因为有一些视频的开头或者结尾可能是黑屏,所以支持随机帧预览 54 | 55 | 默认情况下为视频的第一帧 56 | 57 | ## 检查更新 58 | 59 | 点击检查更新,软件会自动检测当然是否有更新的版本,不过软件不会下载,您需要自行前往项目地址进行下载,项目在国内和国外都有分流地址,检测地址使用的是 GitHub,所以有可能会检测失败 60 | 61 | ![检查更新](/检查更新.png) 62 | 63 |
64 |

GitHub下载地址: https://github.com/271374667/VideoFusion

65 | 66 | Gitee下载地址(国内): https://gitee.com/sun-programing/VideoFusion 67 | 68 | Gitcode(国内): https://gitcode.com/PythonImporter/VideoFusion 69 |
70 | 71 | -------------------------------------------------------------------------------- /docs/opencv_only_settings.md: -------------------------------------------------------------------------------- 1 | # OpenCV 专属设置 2 | 3 | 使用这些命令之前您需要先将底层引擎调整至 OpenCV 模式 4 | 5 | [如何设置?](/normal_settings.md#引擎选择) 6 | 7 | ## 视频白平衡 8 | 9 | 自动白平衡用于调整视频帧中的颜色,使其看起来更加自然和真实。白平衡的主要作用是消除由于不同光源(如日光、荧光灯、白炽灯等)引起的色偏,使得图像中的白色物体在不同光照条件下都能呈现出真实的白色 10 | 11 | ![白平衡](/白平衡.png) 12 | 13 | ## 自动调整视频亮度对比度 14 | 15 | 自动将视频亮度和对比度调整到一个合适的值确保画面不会过暗或者过亮,VideoFusion 会自动提高或者降低每一帧的亮度 16 | 17 | ![亮度自动调整](/亮度自动调整.png) 18 | 19 | ## 超分辨率算法平滑画面 20 | 21 | 该方法不是传统意义上的超分辨率,他更像是一个超级降噪,使用了这个功能之后视频的噪点几乎会全部消失 22 | 23 | ![超分平滑](/超分平滑.png) 24 | 25 | :::warning 警告 26 | 该选项会大幅增加运算时间 27 | ::: -------------------------------------------------------------------------------- /docs/public/VF和达芬奇和PR的区别.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/271374667/VideoFusion/9ba7b8b301481ed148bc4d853a5efe8bbb7188b5/docs/public/VF和达芬奇和PR的区别.png -------------------------------------------------------------------------------- /docs/public/archive_folder.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /docs/public/audio_wave.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /docs/public/black_remover.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/271374667/VideoFusion/9ba7b8b301481ed148bc4d853a5efe8bbb7188b5/docs/public/black_remover.png -------------------------------------------------------------------------------- /docs/public/demo.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/271374667/VideoFusion/9ba7b8b301481ed148bc4d853a5efe8bbb7188b5/docs/public/demo.gif -------------------------------------------------------------------------------- /docs/public/donate.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/271374667/VideoFusion/9ba7b8b301481ed148bc4d853a5efe8bbb7188b5/docs/public/donate.png -------------------------------------------------------------------------------- /docs/public/live_stabilization.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/271374667/VideoFusion/9ba7b8b301481ed148bc4d853a5efe8bbb7188b5/docs/public/live_stabilization.gif -------------------------------------------------------------------------------- /docs/public/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/271374667/VideoFusion/9ba7b8b301481ed148bc4d853a5efe8bbb7188b5/docs/public/logo.png -------------------------------------------------------------------------------- /docs/public/process.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /docs/public/star.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /docs/public/不止合并.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/271374667/VideoFusion/9ba7b8b301481ed148bc4d853a5efe8bbb7188b5/docs/public/不止合并.jpg -------------------------------------------------------------------------------- /docs/public/亮度自动调整.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/271374667/VideoFusion/9ba7b8b301481ed148bc4d853a5efe8bbb7188b5/docs/public/亮度自动调整.png -------------------------------------------------------------------------------- /docs/public/快速合并视频向导图片_1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/271374667/VideoFusion/9ba7b8b301481ed148bc4d853a5efe8bbb7188b5/docs/public/快速合并视频向导图片_1.png -------------------------------------------------------------------------------- /docs/public/检查更新.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/271374667/VideoFusion/9ba7b8b301481ed148bc4d853a5efe8bbb7188b5/docs/public/检查更新.png -------------------------------------------------------------------------------- /docs/public/白平衡.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/271374667/VideoFusion/9ba7b8b301481ed148bc4d853a5efe8bbb7188b5/docs/public/白平衡.png -------------------------------------------------------------------------------- /docs/public/简单的设置页面.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/271374667/VideoFusion/9ba7b8b301481ed148bc4d853a5efe8bbb7188b5/docs/public/简单的设置页面.png -------------------------------------------------------------------------------- /docs/public/视频去色块.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/271374667/VideoFusion/9ba7b8b301481ed148bc4d853a5efe8bbb7188b5/docs/public/视频去色块.png -------------------------------------------------------------------------------- /docs/public/视频去色带.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/271374667/VideoFusion/9ba7b8b301481ed148bc4d853a5efe8bbb7188b5/docs/public/视频去色带.png -------------------------------------------------------------------------------- /docs/public/视频帧率对画面的影响.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/271374667/VideoFusion/9ba7b8b301481ed148bc4d853a5efe8bbb7188b5/docs/public/视频帧率对画面的影响.gif -------------------------------------------------------------------------------- /docs/public/设置引擎.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/271374667/VideoFusion/9ba7b8b301481ed148bc4d853a5efe8bbb7188b5/docs/public/设置引擎.png -------------------------------------------------------------------------------- /docs/public/调整视频顺序.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/271374667/VideoFusion/9ba7b8b301481ed148bc4d853a5efe8bbb7188b5/docs/public/调整视频顺序.gif -------------------------------------------------------------------------------- /docs/public/超分平滑.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/271374667/VideoFusion/9ba7b8b301481ed148bc4d853a5efe8bbb7188b5/docs/public/超分平滑.png -------------------------------------------------------------------------------- /docs/public/输出页面.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/271374667/VideoFusion/9ba7b8b301481ed148bc4d853a5efe8bbb7188b5/docs/public/输出页面.png -------------------------------------------------------------------------------- /docs/public/重新开始.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/271374667/VideoFusion/9ba7b8b301481ed148bc4d853a5efe8bbb7188b5/docs/public/重新开始.png -------------------------------------------------------------------------------- /docs/public/随时暂停.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/271374667/VideoFusion/9ba7b8b301481ed148bc4d853a5efe8bbb7188b5/docs/public/随时暂停.gif -------------------------------------------------------------------------------- /docs/public/预览视频去黑边.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/271374667/VideoFusion/9ba7b8b301481ed148bc4d853a5efe8bbb7188b5/docs/public/预览视频去黑边.png -------------------------------------------------------------------------------- /docs/quick-start.md: -------------------------------------------------------------------------------- 1 | # 快速开始 2 | 3 | ## 10 秒爱上VideoFusion 4 | 5 | 下面这个 GIF 将会带您快速查看两个有不规则黑边的视频是如何完美去除黑边然后旋转成正确的朝向最后合并的 6 | 7 | ![10 秒爱上VideoFusion](/demo.gif) 8 | 9 | ## 如何快速合并一堆视频? 10 | 11 | VideoFuison 已经帮您简化了大量的操作,**您只需要 2 次点击就能实现将视频批量合并/处理** 12 | 13 | ![如何合并?](/快速合并视频向导图片_1.png) 14 | 15 | ![不止合并](/不止合并.jpg) 16 | 17 | **在处理完成之后,VideoFusion 会自动弹出合并完成之后的文件夹**,您可以在这里找到您的输出文件,当然您也可以自定义输出文件夹的位置,VideoFusion 拥有大量可自定义内容和功能 18 | 19 | ### 横屏还是竖屏,由您做决定 20 | 21 | 视频的输出需要您人工进行选择,您可以自由的选择输出的视频是一个横屏的视频还是竖屏的视频,VideoFusion 会智能地帮您旋转不符合规则的视频,让所有的视频都如您所愿 22 | 23 | ::: info 提示 24 | 自动旋转是根据视频的长宽进行判断是否旋转,无法识别画面内容,请自行确保旋转之后的画面内容一致,否则有可能出现上下颠倒或者左右颠倒的情况(不过在代码眼里这依旧是正确的结果) 25 | ::: 26 | 27 | ## 智能调整视频顺序 28 | 29 | 如果您需要将多个视频合并成一个视频,那么您可以简单的在主页的视屏文件列表里通过拖拽的方式调整他们的顺序,同时 VideoFusion 还内置了一些排序策略,可以智能对视频进行自动排序 30 | 31 | ![调整视频顺序](/调整视频顺序.gif) 32 | 33 | 目前支持自动排序的文件名如下: 34 | 35 | ::: details 点我查看所有支持智能排序的文件名类型 36 | 37 | - **系统重命名文件** 38 | - 例如 `重命名(1), 重命名(2)`, 39 | - 自动根据括号内的数字进行排序 40 | - **包含时间的文件** 41 | - 例如 `2023-6-25毕业旅游.mp4` 或者 `工作需要2018/7/11.mkv` 42 | - 通过识别时间进行排序,注意时间必须得是(年份/月份/日期)这样的组合,中间的连接符随意 43 | - **纯数字** 44 | - 例如 `1.mp4` 或者是 `0006.mp4` 45 | - 通过获取其中的数字进行排序 46 | - **字符串排序** 47 | - 如果上面的需求均不符合则使用字符串排序 48 | 49 | ::: 50 | 51 | ::: tip 注意 52 | 必须所有的文件都满足需求才能使用智能排序,例如所有的文件都需要是纯数字才能自动使用纯数字排序,如果有一个文件不是纯数字则使用默认的字符串排序,其他排序同理,所以请确保所有文件名字类型相同 53 | ::: 54 | 55 | ## 随时终止您的任务 56 | 57 | 任务太长?配置出现错误?没有关系,随时终止您的任务,释放您的 CPU 和内存,告别 FFmpeg 的后台残留 58 | 59 | ![随时暂停](/随时暂停.gif) 60 | 61 | :::info 提示 62 | 当所有的视频被分析完成之后进入处理阶段才能保存到本地,如果在分析视频阶段就终止任务则下一次需要重新开始 63 | ::: 64 | 65 | ## 重新开始 66 | 67 | VideoFusion 闪退了?任务中断之后后悔了想要重新开始?没有关系,VideoFusion 支持重新开始,轻点 OK 按键让您回到上一次的进度,您甚至可以在修改设置之后再重新开始,VideoFusion 会帮你处理好一切 68 | 69 | ![重新开始](/重新开始.png) 70 | 71 | ## 观察输出 72 | 73 | 如果您的程序卡主了或者很长时间都没有反应,您可以前往输出页面查看报错,将报错提交为 issue 整理好您的报错信息,附上报错原因和环境,作者将很快与您取得联系 74 | 75 | ![输出页面](/输出页面.png) 76 | 77 | ## 更多 78 | 79 | 恭喜您,您已经能够成功使用 VideoFusion 了,不过 VideoFusion 依旧有非常多的特性,您可以在设置页面找到他们,同时您也可以在这个文档里面找到相关的说明和帮助 80 | 81 | [前往查看更多设置](/normal_settings.md) -------------------------------------------------------------------------------- /docs/thanks.md: -------------------------------------------------------------------------------- 1 | # 感谢 2 | 3 | 感谢为本项目提供技术支持的所有项目。没有你们的帮助,本项目不可能得以实现。 4 | 以下排名不分先后: 5 | 6 | - [PyQt-Fluent-Widgets](https://github.com/zhiyiYo/PyQt-Fluent-Widgets):提供了 Qt 的美化 7 | - [PySide6](https://wiki.qt.io/Qt_for_Python):提供了 Python 与 Qt 的接口 8 | - [ffmpeg](https://ffmpeg.org/):提供了视频处理的功能 9 | - [opencv](https://opencv.org/):提供了图像处理的功能 10 | - [auto-editor](https://github.com/WyattBlue/auto-editor):提供了视频自动剪辑的功能 11 | - [python-audio-separator](https://github.com/nomadkaraoke/python-audio-separator):提供了音频分离的功能 -------------------------------------------------------------------------------- /libomp140.x86_64.dll: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/271374667/VideoFusion/9ba7b8b301481ed148bc4d853a5efe8bbb7188b5/libomp140.x86_64.dll -------------------------------------------------------------------------------- /openh264-2.4.1-win64.dll: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/271374667/VideoFusion/9ba7b8b301481ed148bc4d853a5efe8bbb7188b5/openh264-2.4.1-win64.dll -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "devDependencies": { 3 | "vitepress": "^1.3.1" 4 | }, 5 | "scripts": { 6 | "docs:dev": "vitepress dev docs", 7 | "docs:build": "vitepress build docs", 8 | "docs:preview": "vitepress preview docs" 9 | } 10 | } -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [project] 2 | name = "videomosaic" 3 | version = "0.1.0" 4 | description = "一站式短视频拼接软件 无依赖,点击即用,自动去黑边,自动帧同步,自动调整分辨率,批量变更视频为横屏/竖屏" 5 | authors = [ 6 | { name = "PythonImporter", email = "271374667@qq.com" } 7 | ] 8 | dependencies = [ 9 | "pyside6-fluent-widgets>=1.5.6", 10 | "ansi2html>=1.9.1", 11 | "loguru>=0.7.2", 12 | "opencv-contrib-python>=4.10.0.84", 13 | "typing-extensions>=4.12.2", 14 | "auto-editor>=24.31.1", 15 | "audio-separator[cpu]>=0.18.3", 16 | "noisereduce>=3.0.2", 17 | "onnx==1.16.1", 18 | ] 19 | readme = "README.md" 20 | requires-python = ">= 3.10" 21 | 22 | [build-system] 23 | requires = ["hatchling"] 24 | build-backend = "hatchling.build" 25 | 26 | [tool.rye] 27 | managed = true 28 | dev-dependencies = [ 29 | "nuitka>=2.2.3", 30 | "pyinstaller>=6.10.0", 31 | ] 32 | 33 | [[tool.rye.sources]] 34 | name = "default" 35 | url = "https://pypi.tuna.tsinghua.edu.cn/simple" 36 | 37 | [[tool.rye.sources]] 38 | name = "pypi" 39 | url = "https://pypi.org/simple" 40 | 41 | [[tool.rye.sources]] 42 | name = "pytorch" 43 | url = "https://download.pytorch.org/whl/cu121" 44 | 45 | [tool.hatch.metadata] 46 | allow-direct-references = true 47 | 48 | [tool.hatch.build.targets.wheel] 49 | packages = ["src/videomosaic"] 50 | -------------------------------------------------------------------------------- /requirements-dev.lock: -------------------------------------------------------------------------------- 1 | # generated by rye 2 | # use `rye lock` or `rye sync` to update this lockfile 3 | # 4 | # last locked with the following flags: 5 | # pre: false 6 | # features: [] 7 | # all-features: false 8 | # with-sources: false 9 | # generate-hashes: false 10 | # universal: false 11 | 12 | -e file:. 13 | absl-py==2.1.0 14 | # via ml-collections 15 | ae-ffmpeg==1.2.0 16 | # via auto-editor 17 | altgraph==0.17.4 18 | # via pyinstaller 19 | ansi2html==1.9.2 20 | # via videomosaic 21 | audio-separator==0.18.3 22 | # via videomosaic 23 | audioread==3.0.1 24 | # via librosa 25 | auto-editor==24.31.1 26 | # via videomosaic 27 | beartype==0.18.5 28 | # via audio-separator 29 | certifi==2024.7.4 30 | # via requests 31 | cffi==1.17.0 32 | # via samplerate 33 | # via soundfile 34 | charset-normalizer==3.3.2 35 | # via requests 36 | colorama==0.4.6 37 | # via loguru 38 | # via tqdm 39 | coloredlogs==15.0.1 40 | # via onnxruntime 41 | contextlib2==21.6.0 42 | # via ml-collections 43 | contourpy==1.2.1 44 | # via matplotlib 45 | cycler==0.12.1 46 | # via matplotlib 47 | cython==3.0.11 48 | # via diffq 49 | darkdetect==0.8.0 50 | # via pyside6-fluent-widgets 51 | decorator==5.1.1 52 | # via librosa 53 | diffq==0.2.4 54 | # via audio-separator 55 | einops==0.8.0 56 | # via audio-separator 57 | # via rotary-embedding-torch 58 | filelock==3.15.4 59 | # via torch 60 | flatbuffers==24.3.25 61 | # via onnxruntime 62 | fonttools==4.53.1 63 | # via matplotlib 64 | fsspec==2024.6.1 65 | # via torch 66 | humanfriendly==10.0 67 | # via coloredlogs 68 | idna==3.7 69 | # via requests 70 | jinja2==3.1.4 71 | # via torch 72 | joblib==1.4.2 73 | # via librosa 74 | # via scikit-learn 75 | julius==0.2.7 76 | # via audio-separator 77 | kiwisolver==1.4.5 78 | # via matplotlib 79 | lazy-loader==0.4 80 | # via librosa 81 | librosa==0.10.2.post1 82 | # via audio-separator 83 | # via noisereduce 84 | llvmlite==0.43.0 85 | # via numba 86 | loguru==0.7.2 87 | # via videomosaic 88 | markupsafe==2.1.5 89 | # via jinja2 90 | matplotlib==3.9.2 91 | # via noisereduce 92 | ml-collections==0.1.1 93 | # via audio-separator 94 | mpmath==1.3.0 95 | # via sympy 96 | msgpack==1.0.8 97 | # via librosa 98 | networkx==3.3 99 | # via torch 100 | noisereduce==3.0.2 101 | # via videomosaic 102 | nuitka==2.4.2 103 | numba==0.60.0 104 | # via librosa 105 | # via resampy 106 | numpy==1.26.4 107 | # via audio-separator 108 | # via auto-editor 109 | # via contourpy 110 | # via diffq 111 | # via librosa 112 | # via matplotlib 113 | # via noisereduce 114 | # via numba 115 | # via onnx 116 | # via onnx2torch 117 | # via onnxruntime 118 | # via opencv-contrib-python 119 | # via resampy 120 | # via samplerate 121 | # via scikit-learn 122 | # via scipy 123 | # via soxr 124 | # via torchvision 125 | onnx==1.16.1 126 | # via audio-separator 127 | # via onnx2torch 128 | # via videomosaic 129 | onnx2torch==1.5.15 130 | # via audio-separator 131 | onnxruntime==1.18.1 132 | # via audio-separator 133 | opencv-contrib-python==4.10.0.84 134 | # via videomosaic 135 | ordered-set==4.1.0 136 | # via nuitka 137 | packaging==23.2 138 | # via lazy-loader 139 | # via matplotlib 140 | # via onnxruntime 141 | # via pooch 142 | # via pyinstaller 143 | # via pyinstaller-hooks-contrib 144 | pefile==2023.2.7 145 | # via pyinstaller 146 | pillow==10.4.0 147 | # via matplotlib 148 | # via torchvision 149 | platformdirs==4.2.2 150 | # via pooch 151 | pooch==1.8.2 152 | # via librosa 153 | protobuf==5.27.3 154 | # via onnx 155 | # via onnxruntime 156 | pyav==12.3.0 157 | # via auto-editor 158 | pycparser==2.22 159 | # via cffi 160 | pydub==0.25.1 161 | # via audio-separator 162 | pyinstaller==6.10.0 163 | pyinstaller-hooks-contrib==2024.8 164 | # via pyinstaller 165 | pyparsing==3.1.2 166 | # via matplotlib 167 | pyreadline3==3.4.1 168 | # via humanfriendly 169 | pyside6==6.7.2 170 | # via pyside6-fluent-widgets 171 | pyside6-addons==6.7.2 172 | # via pyside6 173 | pyside6-essentials==6.7.2 174 | # via pyside6 175 | # via pyside6-addons 176 | pyside6-fluent-widgets==1.6.0 177 | # via videomosaic 178 | pysidesix-frameless-window==0.3.12 179 | # via pyside6-fluent-widgets 180 | python-dateutil==2.9.0.post0 181 | # via matplotlib 182 | pywin32==306 183 | # via pysidesix-frameless-window 184 | pywin32-ctypes==0.2.3 185 | # via pyinstaller 186 | pyyaml==6.0.2 187 | # via audio-separator 188 | # via ml-collections 189 | requests==2.32.3 190 | # via audio-separator 191 | # via pooch 192 | resampy==0.4.3 193 | # via audio-separator 194 | rotary-embedding-torch==0.6.4 195 | # via audio-separator 196 | samplerate==0.1.0 197 | # via audio-separator 198 | scikit-learn==1.5.1 199 | # via librosa 200 | scipy==1.14.0 201 | # via audio-separator 202 | # via librosa 203 | # via noisereduce 204 | # via scikit-learn 205 | setuptools==72.2.0 206 | # via pyinstaller 207 | # via pyinstaller-hooks-contrib 208 | shiboken6==6.7.2 209 | # via pyside6 210 | # via pyside6-addons 211 | # via pyside6-essentials 212 | six==1.16.0 213 | # via audio-separator 214 | # via ml-collections 215 | # via python-dateutil 216 | soundfile==0.12.1 217 | # via librosa 218 | soxr==0.4.0 219 | # via librosa 220 | sympy==1.13.1 221 | # via onnxruntime 222 | # via torch 223 | threadpoolctl==3.5.0 224 | # via scikit-learn 225 | torch==2.4.0 226 | # via audio-separator 227 | # via diffq 228 | # via julius 229 | # via onnx2torch 230 | # via rotary-embedding-torch 231 | # via torchvision 232 | torchvision==0.19.0 233 | # via onnx2torch 234 | tqdm==4.66.5 235 | # via audio-separator 236 | # via noisereduce 237 | typing-extensions==4.12.2 238 | # via librosa 239 | # via torch 240 | # via videomosaic 241 | urllib3==2.2.2 242 | # via requests 243 | win32-setctime==1.1.0 244 | # via loguru 245 | zstandard==0.23.0 246 | # via nuitka 247 | -------------------------------------------------------------------------------- /requirements.lock: -------------------------------------------------------------------------------- 1 | # generated by rye 2 | # use `rye lock` or `rye sync` to update this lockfile 3 | # 4 | # last locked with the following flags: 5 | # pre: false 6 | # features: [] 7 | # all-features: false 8 | # with-sources: false 9 | # generate-hashes: false 10 | # universal: false 11 | 12 | -e file:. 13 | absl-py==2.1.0 14 | # via ml-collections 15 | ae-ffmpeg==1.2.0 16 | # via auto-editor 17 | ansi2html==1.9.2 18 | # via videomosaic 19 | audio-separator==0.18.3 20 | # via videomosaic 21 | audioread==3.0.1 22 | # via librosa 23 | auto-editor==24.31.1 24 | # via videomosaic 25 | beartype==0.18.5 26 | # via audio-separator 27 | certifi==2024.7.4 28 | # via requests 29 | cffi==1.17.0 30 | # via samplerate 31 | # via soundfile 32 | charset-normalizer==3.3.2 33 | # via requests 34 | colorama==0.4.6 35 | # via loguru 36 | # via tqdm 37 | coloredlogs==15.0.1 38 | # via onnxruntime 39 | contextlib2==21.6.0 40 | # via ml-collections 41 | contourpy==1.2.1 42 | # via matplotlib 43 | cycler==0.12.1 44 | # via matplotlib 45 | cython==3.0.11 46 | # via diffq 47 | darkdetect==0.8.0 48 | # via pyside6-fluent-widgets 49 | decorator==5.1.1 50 | # via librosa 51 | diffq==0.2.4 52 | # via audio-separator 53 | einops==0.8.0 54 | # via audio-separator 55 | # via rotary-embedding-torch 56 | filelock==3.15.4 57 | # via torch 58 | flatbuffers==24.3.25 59 | # via onnxruntime 60 | fonttools==4.53.1 61 | # via matplotlib 62 | fsspec==2024.6.1 63 | # via torch 64 | humanfriendly==10.0 65 | # via coloredlogs 66 | idna==3.7 67 | # via requests 68 | jinja2==3.1.4 69 | # via torch 70 | joblib==1.4.2 71 | # via librosa 72 | # via scikit-learn 73 | julius==0.2.7 74 | # via audio-separator 75 | kiwisolver==1.4.5 76 | # via matplotlib 77 | lazy-loader==0.4 78 | # via librosa 79 | librosa==0.10.2.post1 80 | # via audio-separator 81 | # via noisereduce 82 | llvmlite==0.43.0 83 | # via numba 84 | loguru==0.7.2 85 | # via videomosaic 86 | markupsafe==2.1.5 87 | # via jinja2 88 | matplotlib==3.9.2 89 | # via noisereduce 90 | ml-collections==0.1.1 91 | # via audio-separator 92 | mpmath==1.3.0 93 | # via sympy 94 | msgpack==1.0.8 95 | # via librosa 96 | networkx==3.3 97 | # via torch 98 | noisereduce==3.0.2 99 | # via videomosaic 100 | numba==0.60.0 101 | # via librosa 102 | # via resampy 103 | numpy==1.26.4 104 | # via audio-separator 105 | # via auto-editor 106 | # via contourpy 107 | # via diffq 108 | # via librosa 109 | # via matplotlib 110 | # via noisereduce 111 | # via numba 112 | # via onnx 113 | # via onnx2torch 114 | # via onnxruntime 115 | # via opencv-contrib-python 116 | # via resampy 117 | # via samplerate 118 | # via scikit-learn 119 | # via scipy 120 | # via soxr 121 | # via torchvision 122 | onnx==1.16.1 123 | # via audio-separator 124 | # via onnx2torch 125 | # via videomosaic 126 | onnx2torch==1.5.15 127 | # via audio-separator 128 | onnxruntime==1.18.1 129 | # via audio-separator 130 | opencv-contrib-python==4.10.0.84 131 | # via videomosaic 132 | packaging==23.2 133 | # via lazy-loader 134 | # via matplotlib 135 | # via onnxruntime 136 | # via pooch 137 | pillow==10.4.0 138 | # via matplotlib 139 | # via torchvision 140 | platformdirs==4.2.2 141 | # via pooch 142 | pooch==1.8.2 143 | # via librosa 144 | protobuf==5.27.3 145 | # via onnx 146 | # via onnxruntime 147 | pyav==12.3.0 148 | # via auto-editor 149 | pycparser==2.22 150 | # via cffi 151 | pydub==0.25.1 152 | # via audio-separator 153 | pyparsing==3.1.2 154 | # via matplotlib 155 | pyreadline3==3.4.1 156 | # via humanfriendly 157 | pyside6==6.7.2 158 | # via pyside6-fluent-widgets 159 | pyside6-addons==6.7.2 160 | # via pyside6 161 | pyside6-essentials==6.7.2 162 | # via pyside6 163 | # via pyside6-addons 164 | pyside6-fluent-widgets==1.6.0 165 | # via videomosaic 166 | pysidesix-frameless-window==0.3.12 167 | # via pyside6-fluent-widgets 168 | python-dateutil==2.9.0.post0 169 | # via matplotlib 170 | pywin32==306 171 | # via pysidesix-frameless-window 172 | pyyaml==6.0.2 173 | # via audio-separator 174 | # via ml-collections 175 | requests==2.32.3 176 | # via audio-separator 177 | # via pooch 178 | resampy==0.4.3 179 | # via audio-separator 180 | rotary-embedding-torch==0.6.4 181 | # via audio-separator 182 | samplerate==0.1.0 183 | # via audio-separator 184 | scikit-learn==1.5.1 185 | # via librosa 186 | scipy==1.14.0 187 | # via audio-separator 188 | # via librosa 189 | # via noisereduce 190 | # via scikit-learn 191 | shiboken6==6.7.2 192 | # via pyside6 193 | # via pyside6-addons 194 | # via pyside6-essentials 195 | six==1.16.0 196 | # via audio-separator 197 | # via ml-collections 198 | # via python-dateutil 199 | soundfile==0.12.1 200 | # via librosa 201 | soxr==0.4.0 202 | # via librosa 203 | sympy==1.13.1 204 | # via onnxruntime 205 | # via torch 206 | threadpoolctl==3.5.0 207 | # via scikit-learn 208 | torch==2.4.0 209 | # via audio-separator 210 | # via diffq 211 | # via julius 212 | # via onnx2torch 213 | # via rotary-embedding-torch 214 | # via torchvision 215 | torchvision==0.19.0 216 | # via onnx2torch 217 | tqdm==4.66.5 218 | # via audio-separator 219 | # via noisereduce 220 | typing-extensions==4.12.2 221 | # via librosa 222 | # via torch 223 | # via videomosaic 224 | urllib3==2.2.2 225 | # via requests 226 | win32-setctime==1.1.0 227 | # via loguru 228 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | pyside6-fluent-widgets>=1.5.6 2 | ansi2html>=1.9.1 3 | loguru>=0.7.2 4 | opencv-contrib-python>=4.10.0.84 5 | typing-extensions>=4.12.2 6 | auto-editor>=24.31.1 7 | audio-separator[cpu]>=0.18.3 8 | noisereduce>=3.0.2 -------------------------------------------------------------------------------- /scripts/compile_view.py: -------------------------------------------------------------------------------- 1 | import subprocess 2 | from concurrent.futures import ThreadPoolExecutor 3 | 4 | from src.core.paths import ASSETS_DIR, SRC_DIR, QRC_FILE, QRC_PY_FILE 5 | 6 | UI_DIR = ASSETS_DIR / "ui" 7 | PY_DIR = SRC_DIR / "interface" 8 | 9 | count = 0 10 | thread_pool = ThreadPoolExecutor(10) 11 | 12 | print("正在清空Ui_*.py文件夹下的所有内容") 13 | # 清空Ui_*.py文件夹下的所有内容 14 | for each in PY_DIR.glob("**/*.*"): 15 | if each.stem.startswith("Ui_"): 16 | print(f"正在删除{each}……") 17 | each.unlink() 18 | 19 | 20 | def trans_ui_to_py(ui_file): 21 | print(f"当前正在转换{ui_file}……") 22 | py_file = PY_DIR / f"Ui_{ui_file.stem}.py" 23 | subprocess.run(["pyside6-uic", ui_file, "-o", py_file]) 24 | 25 | 26 | for ui_file in UI_DIR.glob("**/*.ui"): 27 | count += 1 28 | thread_pool.submit(trans_ui_to_py, ui_file) 29 | 30 | thread_pool.shutdown(wait=True) 31 | 32 | print(f"Done! {count} files have been converted.") 33 | 34 | print("正在替换资源文件") 35 | for each in PY_DIR.glob("**/*.py"): 36 | if each.stem == "__init__": 37 | continue 38 | 39 | with open(each, "r", encoding="utf-8") as f: 40 | content = f.read() 41 | content = content.replace("import res_rc", "from src.resource import rc_res") 42 | with open(each, "w", encoding="utf-8") as f: 43 | f.write(content) 44 | print(f"替换{each}的 res 路径成功!") 45 | 46 | print(f"Done! {count} files have been converted.") 47 | 48 | # 替换qrc文件 49 | print("正在编译资源文件") 50 | # 使用 pyside6-rcc 命令将 qrc 文件编译成 py 文件 51 | subprocess.run(["pyside6-rcc", str(QRC_FILE), "-o", str(QRC_PY_FILE)]) 52 | print("编译完成") 53 | -------------------------------------------------------------------------------- /scripts/packaged.py: -------------------------------------------------------------------------------- 1 | from abc import ABC, abstractmethod 2 | from enum import Enum 3 | 4 | 5 | class PackageType(Enum): 6 | Pyinstaller = 0 7 | Nuitka = 1 8 | 9 | 10 | class Packaged(ABC): 11 | @abstractmethod 12 | def package(self): 13 | pass 14 | 15 | 16 | class PyinstallerPackaged(Packaged): 17 | def package(self): 18 | print("PyinstallerPackaged") 19 | 20 | 21 | class NuitkaPackaged(Packaged): 22 | def package(self): 23 | print("NuitkaPackaged") 24 | 25 | 26 | class PackagedFactory: 27 | @staticmethod 28 | def create_packaged(packaged_type: PackageType) -> Packaged: 29 | if packaged_type == PackageType.Pyinstaller: 30 | return PyinstallerPackaged() 31 | elif packaged_type == PackageType.Nuitka: 32 | return NuitkaPackaged() 33 | else: 34 | raise ValueError(f"Unknown packaged type {packaged_type}") 35 | 36 | 37 | if __name__ == '__main__': 38 | pf = PackagedFactory() 39 | packaged = pf.create_packaged(PackageType.Pyinstaller) 40 | packaged.package() 41 | -------------------------------------------------------------------------------- /src/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/271374667/VideoFusion/9ba7b8b301481ed148bc4d853a5efe8bbb7188b5/src/__init__.py -------------------------------------------------------------------------------- /src/common/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/271374667/VideoFusion/9ba7b8b301481ed148bc4d853a5efe8bbb7188b5/src/common/__init__.py -------------------------------------------------------------------------------- /src/common/black_remove/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/271374667/VideoFusion/9ba7b8b301481ed148bc4d853a5efe8bbb7188b5/src/common/black_remove/__init__.py -------------------------------------------------------------------------------- /src/common/black_remove/img_black_remover.py: -------------------------------------------------------------------------------- 1 | from pathlib import Path 2 | from typing import Optional, Union 3 | 4 | import cv2 5 | import numpy as np 6 | 7 | 8 | class BlackRemover: 9 | def __init__(self, threshold: int = 30, border_width: int = 5): 10 | self.threshold: int = threshold 11 | self.border_width: int = border_width 12 | 13 | def start(self, img_path: Optional[Union[str, Path]] = None, 14 | img_array: Optional[np.ndarray] = None) -> tuple[int, int, int, int]: 15 | """ 16 | 去除图像黑边,可以传入图像路径或者图像数组 17 | 18 | Args: 19 | img_path: 图像路径 20 | img_array: 图像数组 21 | 22 | Returns: 23 | tuple[int, int, int, int]: 左上角和右下角坐标 24 | """ 25 | if img_path is None and img_array is None: 26 | raise ValueError('img_path and img_array cannot be None at the same time') 27 | elif img_path is not None and img_array is not None: 28 | raise ValueError('img_path and img_array cannot be set at the same time') 29 | elif img_path is not None: 30 | if isinstance(img_path, Path): 31 | img_path = str(img_path) 32 | # 读取图像 33 | img = cv2.imread(img_path) 34 | else: 35 | img = img_array 36 | # 获取图片的长和宽 37 | img_height: int = img.shape[0] 38 | img_width: int = img.shape[1] 39 | # 图片裁剪的左上角和右下角坐标 40 | left_top_x: int = 0 41 | left_top_y: int = 0 42 | right_bottom_x: int = img_width 43 | right_bottom_y: int = img_height 44 | 45 | # 如果图像有黑边,则直接返回 46 | if not self.has_black_border(img): 47 | # loguru.logger.debug(f'{img_path} dont have black border, skip it') 48 | return left_top_x, left_top_y, right_bottom_x, right_bottom_y 49 | 50 | # 转换为灰度图像 51 | gray = cv2.cvtColor(img, cv2.COLOR_BGR2GRAY) 52 | 53 | # 计算平均亮度阈值 54 | # mean_threshold = np.mean(gray) 55 | 56 | _, binary = cv2.threshold(gray, self.threshold, 255, cv2.THRESH_BINARY + cv2.THRESH_OTSU) 57 | # binary = cv2.adaptiveThreshold(gray, 255, cv2.ADAPTIVE_THRESH_MEAN_C, cv2.THRESH_BINARY, 11, 2) 58 | kernel = np.ones((5, 5), np.uint8) 59 | 60 | binary = cv2.morphologyEx(binary, cv2.MORPH_OPEN, kernel) 61 | 62 | binary = cv2.morphologyEx(binary, cv2.MORPH_CLOSE, kernel) 63 | 64 | # 找出图像中的连通区域 65 | num_labels, labels, stats, _ = cv2.connectedComponentsWithStats(binary, connectivity=8) 66 | 67 | # 创建一个新的二值图像,用于保存去除孤立区域后的结果 68 | new_binary = np.zeros_like(binary) 69 | 70 | # 遍历所有连通区域 71 | for i in range(1, num_labels): 72 | # 如果该连通区域的大小大于阈值,则保留该区域 73 | if stats[i, cv2.CC_STAT_AREA] > 1500: # 500是阈值,可以根据实际情况调整 74 | new_binary[labels == i] = 255 75 | 76 | # 找出图像中的轮廓 77 | contours, _ = cv2.findContours(binary, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE) 78 | 79 | # 找出最大的矩形轮廓 80 | max_area = 0 81 | max_rect = None 82 | for contour in contours: 83 | rect = cv2.boundingRect(contour) 84 | x, y, w, h = rect 85 | area = w * h 86 | if area > max_area: 87 | max_area = area 88 | max_rect = rect 89 | 90 | # 在原图像上画出该矩形轮廓 91 | if max_rect is not None: 92 | x, y, w, h = max_rect 93 | left_top_x = x 94 | left_top_y = y 95 | right_bottom_x = x + w 96 | right_bottom_y = y + h 97 | 98 | # # 绘图显示 99 | # cv2.rectangle(img, (left_top_x, left_top_y), (right_bottom_x, right_bottom_y), (0, 0, 255), 15) 100 | # # 保持横纵比的情况下将图片缩放到720p 101 | # img = cv2.resize(img, (480, 720)) 102 | # cv2.imshow('new_binary', new_binary) 103 | # cv2.imshow('img', img) 104 | # # cv2.imwrite(r"E:\load\python\Project\VideoMosaic\temp\dy\temp.png", img) 105 | # cv2.waitKey(0) 106 | return left_top_x, left_top_y, right_bottom_x, right_bottom_y 107 | 108 | def has_black_border(self, img: np.ndarray) -> bool: 109 | """ 110 | 判断图像是否有黑边 111 | 112 | Args: 113 | img: 图像 114 | 115 | Returns: 116 | bool: 是否有黑边 117 | """ 118 | # 读取图像 119 | img = cv2.cvtColor(img, cv2.COLOR_BGR2GRAY) 120 | threshold = self.threshold 121 | border_width = self.border_width 122 | 123 | # 检查上下左右四个边缘的像素值 124 | top_edge = img[:border_width, :] 125 | bottom_edge = img[-border_width:, :] 126 | left_edge = img[:, :border_width] 127 | right_edge = img[:, -border_width:] 128 | 129 | # 如果这些像素值的平均值都小于给定的阈值(默认为50),那么函数返回True,表示图像有黑边 130 | if np.mean(top_edge) < threshold or np.mean(bottom_edge) < threshold or np.mean( 131 | left_edge) < threshold or np.mean(right_edge) < threshold: 132 | return True 133 | else: 134 | return False 135 | 136 | def is_black(self, img: np.ndarray) -> bool: 137 | """ 138 | 判断图像是否为黑色 139 | 140 | Args: 141 | img: 图像 142 | 143 | Returns: 144 | bool: 是否为黑色 145 | """ 146 | img = cv2.cvtColor(img, cv2.COLOR_BGR2GRAY) 147 | threshold = self.threshold 148 | return np.mean(img) < threshold 149 | 150 | 151 | if __name__ == '__main__': 152 | b = BlackRemover() 153 | img = r"E:\load\python\Project\VideoFusion\TempAndTest\dy\Clip_2024-06-03_15-23-02.png" 154 | b.start(img_path=img) 155 | -------------------------------------------------------------------------------- /src/common/black_remove/video_remover.py: -------------------------------------------------------------------------------- 1 | from pathlib import Path 2 | 3 | import cv2 4 | import loguru 5 | import numpy as np 6 | 7 | from src.signal_bus import SignalBus 8 | 9 | signal_bus = SignalBus() 10 | 11 | 12 | class VideoRemover: 13 | def start(self, video_path: Path | str) -> tuple[int, int, int, int]: 14 | video_path: Path = Path(video_path) 15 | loguru.logger.info(f'正在使用差值法检测视频变化区域: {video_path.name}') 16 | 17 | # 打开视频文件 18 | cap = cv2.VideoCapture(str(video_path)) 19 | total_frames = int(cap.get(cv2.CAP_PROP_FRAME_COUNT)) 20 | signal_bus.set_detail_progress_max.emit(total_frames) 21 | ret, frame1 = cap.read() 22 | ret, frame2 = cap.read() 23 | 24 | # 初始化累计变化图像 25 | height, width = frame1.shape[:2] 26 | accumulated_changes = np.zeros((height, width), dtype=np.uint8) 27 | 28 | for frame_index in range(total_frames - 2): 29 | # 计算帧差异 30 | diff = cv2.absdiff(frame1, frame2) 31 | gray = cv2.cvtColor(diff, cv2.COLOR_BGR2GRAY) 32 | blur = cv2.GaussianBlur(gray, (5, 5), 0) 33 | _, thresh = cv2.threshold(blur, 20, 255, cv2.THRESH_BINARY) 34 | 35 | kernel = np.ones((5, 5), np.uint8) 36 | 37 | binary = cv2.morphologyEx(thresh, cv2.MORPH_OPEN, kernel) 38 | 39 | binary = cv2.morphologyEx(binary, cv2.MORPH_CLOSE, kernel) 40 | 41 | # 找出图像中的连通区域 42 | num_labels, labels, stats, _ = cv2.connectedComponentsWithStats(binary, connectivity=8) 43 | 44 | # 创建一个新的二值图像,用于保存去除孤立区域后的结果 45 | new_binary = np.zeros_like(binary) 46 | 47 | # 遍历所有连通区域 48 | for i in range(1, num_labels): 49 | # 如果该连通区域的大小大于阈值,则保留该区域 50 | if stats[i, cv2.CC_STAT_AREA] > 1500: # 500是阈值,可以根据实际情况调整 51 | new_binary[labels == i] = 255 52 | 53 | # 找到轮廓 54 | contours, _ = cv2.findContours(new_binary, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE) 55 | 56 | for contour in contours: 57 | if cv2.contourArea(contour) < 500: 58 | continue 59 | x, y, w, h = cv2.boundingRect(contour) 60 | cv2.rectangle(frame1, (x, y), (x + w, y + h), (255, 255, 255), -1) 61 | cv2.rectangle(accumulated_changes, (x, y), (x + w, y + h), 255, -1) 62 | 63 | frame1 = frame2 64 | ret, frame2 = cap.read() 65 | signal_bus.set_detail_progress_current.emit(frame_index) 66 | 67 | cap.release() 68 | loguru.logger.debug(f'检测视频变化区域完成: {video_path.name}') 69 | 70 | # 找到累计变化图像中的轮廓以确定最大变化区域 71 | contours, _ = cv2.findContours(accumulated_changes, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE) 72 | 73 | max_area = 0 74 | max_rect = (0, 0, 0, 0) 75 | for contour in contours: 76 | x, y, w, h = cv2.boundingRect(contour) 77 | area = w * h 78 | if area > max_area: 79 | max_area = area 80 | max_rect = (x, y, w, h) 81 | 82 | signal_bus.set_detail_progress_finish.emit() 83 | 84 | # 返回变化区域图像和最大矩形区域 85 | loguru.logger.debug(f'最大变化区域: x={max_rect[0]}, y={max_rect[1]}, w={max_rect[2]}, h={max_rect[3]}') 86 | 87 | return max_rect 88 | 89 | 90 | if __name__ == '__main__': 91 | # 使用示例 92 | video_path = r"E:\load\python\Project\VideoFusion\TempAndTest\dy\b7bb97e21600b07f66c21e7932cb7550.mp4" 93 | video_remover = VideoRemover() 94 | max_rectangle = video_remover.start(video_path) 95 | 96 | print(f"Largest rectangle: x={max_rectangle[0]}, y={max_rectangle[1]}, w={max_rectangle[2]}, h={max_rectangle[3]}") 97 | -------------------------------------------------------------------------------- /src/common/black_remove_algorithm/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/271374667/VideoFusion/9ba7b8b301481ed148bc4d853a5efe8bbb7188b5/src/common/black_remove_algorithm/__init__.py -------------------------------------------------------------------------------- /src/common/black_remove_algorithm/black_remove_algorithm.py: -------------------------------------------------------------------------------- 1 | from abc import ABC, abstractmethod 2 | from pathlib import Path 3 | 4 | 5 | class BlackRemoveAlgorithm(ABC): 6 | @abstractmethod 7 | def remove_black(self, input_file_path: str | Path) -> tuple[int, int, int, int]: 8 | pass 9 | -------------------------------------------------------------------------------- /src/common/black_remove_algorithm/video_remover.py: -------------------------------------------------------------------------------- 1 | from pathlib import Path 2 | 3 | import cv2 4 | import loguru 5 | import numpy as np 6 | 7 | from src.common.black_remove_algorithm.black_remove_algorithm import BlackRemoveAlgorithm 8 | from src.signal_bus import SignalBus 9 | 10 | signal_bus = SignalBus() 11 | 12 | 13 | class VideoRemover(BlackRemoveAlgorithm): 14 | def remove_black(self, input_file_path: str | Path) -> tuple[int, int, int, int]: 15 | video_path: Path = Path(input_file_path) 16 | loguru.logger.info(f'正在使用差值法检测视频变化区域: {video_path.name}') 17 | 18 | # 如果不是视频则报错 19 | if video_path.suffix not in ['.mp4', '.avi', '.flv', '.mov', '.mkv']: 20 | raise ValueError(f"文件不是视频: {video_path}") 21 | 22 | # 如果不存在则报错 23 | if not video_path.exists(): 24 | raise FileNotFoundError(f"文件不存在: {video_path}") 25 | 26 | # 打开视频文件 27 | cap = cv2.VideoCapture(str(video_path)) 28 | total_frames = int(cap.get(cv2.CAP_PROP_FRAME_COUNT)) 29 | signal_bus.set_detail_progress_max.emit(total_frames) 30 | ret, frame1 = cap.read() 31 | ret, frame2 = cap.read() 32 | 33 | # 初始化累计变化图像 34 | height, width = frame1.shape[:2] 35 | accumulated_changes = np.zeros((height, width), dtype=np.uint8) 36 | 37 | for frame_index in range(total_frames - 2): 38 | # 计算帧差异 39 | diff = cv2.absdiff(frame1, frame2) 40 | gray = cv2.cvtColor(diff, cv2.COLOR_BGR2GRAY) 41 | blur = cv2.GaussianBlur(gray, (5, 5), 0) 42 | _, thresh = cv2.threshold(blur, 20, 255, cv2.THRESH_BINARY) 43 | 44 | kernel = np.ones((5, 5), np.uint8) 45 | 46 | binary = cv2.morphologyEx(thresh, cv2.MORPH_OPEN, kernel) 47 | 48 | binary = cv2.morphologyEx(binary, cv2.MORPH_CLOSE, kernel) 49 | 50 | # 找出图像中的连通区域 51 | num_labels, labels, stats, _ = cv2.connectedComponentsWithStats(binary, connectivity=8) 52 | 53 | # 创建一个新的二值图像,用于保存去除孤立区域后的结果 54 | new_binary = np.zeros_like(binary) 55 | 56 | # 遍历所有连通区域 57 | for i in range(1, num_labels): 58 | # 如果该连通区域的大小大于阈值,则保留该区域 59 | if stats[i, cv2.CC_STAT_AREA] > 1500: # 500是阈值,可以根据实际情况调整 60 | new_binary[labels == i] = 255 61 | 62 | # 找到轮廓 63 | contours, _ = cv2.findContours(new_binary, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE) 64 | 65 | for contour in contours: 66 | if cv2.contourArea(contour) < 500: 67 | continue 68 | x, y, w, h = cv2.boundingRect(contour) 69 | cv2.rectangle(frame1, (x, y), (x + w, y + h), (255, 255, 255), -1) 70 | cv2.rectangle(accumulated_changes, (x, y), (x + w, y + h), 255, -1) 71 | 72 | frame1 = frame2 73 | ret, frame2 = cap.read() 74 | signal_bus.set_detail_progress_current.emit(frame_index) 75 | 76 | cap.release() 77 | loguru.logger.debug(f'检测视频变化区域完成: {video_path.name}') 78 | 79 | # 找到累计变化图像中的轮廓以确定最大变化区域 80 | contours, _ = cv2.findContours(accumulated_changes, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE) 81 | 82 | max_area = 0 83 | max_rect = (0, 0, 0, 0) 84 | for contour in contours: 85 | x, y, w, h = cv2.boundingRect(contour) 86 | area = w * h 87 | if area > max_area: 88 | max_area = area 89 | max_rect = (x, y, w, h) 90 | 91 | signal_bus.set_detail_progress_finish.emit() 92 | 93 | # 返回变化区域图像和最大矩形区域 94 | loguru.logger.debug(f'最大变化区域: x={max_rect[0]}, y={max_rect[1]}, w={max_rect[2]}, h={max_rect[3]}') 95 | 96 | return max_rect 97 | 98 | 99 | if __name__ == '__main__': 100 | # 使用示例 101 | video_path = r"E:\load\python\Project\VideoFusion\tests\test_data\videos\001.mp4" 102 | video_remover = VideoRemover() 103 | max_rectangle = video_remover.remove_black(video_path) 104 | 105 | print(f"Largest rectangle: x={max_rectangle[0]}, y={max_rectangle[1]}, w={max_rectangle[2]}, h={max_rectangle[3]}") 106 | -------------------------------------------------------------------------------- /src/common/processors/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/271374667/VideoFusion/9ba7b8b301481ed148bc4d853a5efe8bbb7188b5/src/common/processors/__init__.py -------------------------------------------------------------------------------- /src/common/processors/audio_processors/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/271374667/VideoFusion/9ba7b8b301481ed148bc4d853a5efe8bbb7188b5/src/common/processors/audio_processors/__init__.py -------------------------------------------------------------------------------- /src/common/processors/audio_processors/audio_ffmpeg_processor.py: -------------------------------------------------------------------------------- 1 | from pathlib import Path 2 | 3 | from src.common.ffmpeg_handler import FFmpegHandler 4 | from src.common.processors.base_processor import AudioProcessor 5 | from src.config import AudioNoiseReduction, AudioNormalization, AudioSampleRate, cfg 6 | 7 | 8 | class AudioFFmpegProcessor(AudioProcessor): 9 | def __init__(self, **kwargs): 10 | super().__init__(**kwargs) 11 | self._ffmpeg_handler = FFmpegHandler() 12 | 13 | def process(self, input_wav_path: Path) -> Path: 14 | audio_filters: list[str] = [] 15 | 16 | # 音频降噪 17 | audio_noise_reduction_mode: AudioNoiseReduction = cfg.get(cfg.audio_noise_reduction) 18 | if audio_noise_reduction_mode == AudioNoiseReduction.AI: 19 | audio_filters.append(f"{audio_noise_reduction_mode.value}".replace('\\', '/')) 20 | elif audio_noise_reduction_mode == AudioNoiseReduction.Static: 21 | audio_filters.append(audio_noise_reduction_mode.value) 22 | 23 | # 音频标准化 24 | audio_normalization: AudioNormalization = cfg.get(cfg.audio_normalization) 25 | if audio_normalization != AudioNormalization.Disable: 26 | audio_filters.append(audio_normalization.value) 27 | 28 | # 音频重新采样 29 | audio_sample_rate: AudioSampleRate = cfg.get(cfg.audio_sample_rate) 30 | audio_filters.append( 31 | f"aresample={audio_sample_rate.value}:resampler=soxr:precision=28:osf=s16:dither_method=triangular") 32 | 33 | return self._ffmpeg_handler.audio_process(input_wav_path, audio_filter=audio_filters) 34 | 35 | 36 | if __name__ == '__main__': 37 | processor = AudioFFmpegProcessor() 38 | print(processor.process(Path(r"E:\load\python\Project\VideoFusion\TempAndTest\dy\v\1\视频(1)_out.wav"))) 39 | print("降噪完成") 40 | -------------------------------------------------------------------------------- /src/common/processors/audio_processors/audio_processor_manager.py: -------------------------------------------------------------------------------- 1 | from pathlib import Path 2 | 3 | from src.common.ffmpeg_handler import FFmpegHandler 4 | from src.common.processors.audio_processors.audio_ffmpeg_processor import AudioFFmpegProcessor 5 | from src.common.processors.base_processor import AudioProcessor, AudioProcessorManager as APM 6 | 7 | 8 | class AudioProcessorManager(APM): 9 | def __init__(self): 10 | super().__init__() 11 | self._ffmpeg_handler = FFmpegHandler() 12 | self._audio_ffmpeg_processor = AudioFFmpegProcessor() 13 | 14 | self._processors: list[AudioProcessor] = [ 15 | self._audio_ffmpeg_processor 16 | ] 17 | 18 | def get_processors(self) -> list[AudioProcessor]: 19 | return self._processors 20 | 21 | def add_processor(self, processor: AudioProcessor): 22 | self._processors.append(processor) 23 | 24 | def process(self, x: Path) -> Path: 25 | for processor in self._processors: 26 | x = processor.process(x) 27 | return x 28 | -------------------------------------------------------------------------------- /src/common/processors/base_processor.py: -------------------------------------------------------------------------------- 1 | from abc import ABC, abstractmethod 2 | from pathlib import Path 3 | from typing import Generic, TypeVar 4 | 5 | import numpy as np 6 | 7 | from src.core.datacls import FFmpegDTO 8 | 9 | T = TypeVar("T") 10 | 11 | 12 | class BaseProcessor(ABC, Generic[T]): 13 | @abstractmethod 14 | def process(self, x: T) -> T: 15 | pass 16 | 17 | 18 | class OpenCVProcessor(BaseProcessor): 19 | is_enable: bool = True 20 | 21 | @abstractmethod 22 | def process(self, frame: np.ndarray) -> np.ndarray: 23 | """ 24 | 将输入的帧进行处理并返回处理后的帧 25 | 26 | Args: 27 | frame: 输入的帧 28 | 29 | Returns: 30 | 处理后的帧 31 | """ 32 | raise NotImplementedError("Not implemented yet") 33 | 34 | 35 | class FFmpegProcessor(BaseProcessor): 36 | is_enable: bool = True 37 | 38 | @abstractmethod 39 | def process(self, ffmpeg_dto: list[FFmpegDTO]) -> list[FFmpegDTO]: 40 | """ 41 | 将输入的文件进行处理并返回处理后的文件路径 42 | 43 | Args: 44 | ffmpeg_dto: 输入文件路径 45 | 46 | Returns: 47 | 处理后的文件路径 48 | """ 49 | raise NotImplementedError("Not implemented yet") 50 | 51 | 52 | class EXEProcessor(BaseProcessor): 53 | is_enable: bool = True 54 | 55 | @abstractmethod 56 | def process(self, input_file_path: Path) -> Path: 57 | """ 58 | 将输入的文件进行处理并返回处理后的文件路径 59 | 60 | Args: 61 | input_file_path: 输入文件路径 62 | 63 | Returns: 64 | 处理后的文件路径 65 | """ 66 | raise NotImplementedError("Not implemented yet") 67 | 68 | 69 | class AudioProcessor(BaseProcessor): 70 | @abstractmethod 71 | def process(self, input_wav_path: Path) -> Path: 72 | """ 73 | 将输入的音频文件进行处理并返回处理后的音频文件路径 74 | 75 | Args: 76 | input_wav_path: 输入音频文件路径 77 | 78 | Returns: 79 | 处理后的音频文件路径 80 | """ 81 | raise NotImplementedError("Not implemented yet") 82 | 83 | 84 | class BaseProcessorManager(ABC, Generic[T]): 85 | def __init__(self): 86 | self._processors: list[BaseProcessor] = [] 87 | 88 | def get_processors(self) -> list[BaseProcessor]: 89 | return self._processors 90 | 91 | def add_processor(self, processor: BaseProcessor): 92 | self._processors.append(processor) 93 | 94 | def process(self, x: T) -> T: 95 | for processor in self._processors: 96 | x = processor.process(x) 97 | return x 98 | 99 | 100 | class OpenCVProcessorManager(BaseProcessorManager[np.ndarray]): 101 | def __init__(self): 102 | super().__init__() 103 | self._processors: list[OpenCVProcessor] = [] 104 | 105 | def get_processors(self) -> list[OpenCVProcessor]: 106 | return self._processors 107 | 108 | def add_processor(self, processor: OpenCVProcessor): 109 | self._processors.append(processor) 110 | 111 | def process(self, frame: np.ndarray) -> np.ndarray: 112 | for processor in self._processors: 113 | frame = processor.process(frame) 114 | return frame 115 | 116 | 117 | class FFmpegProcessorManager(BaseProcessorManager[FFmpegDTO]): 118 | def __init__(self): 119 | super().__init__() 120 | self._processors: list[FFmpegProcessor] = [] 121 | 122 | def get_processors(self) -> list[FFmpegProcessor]: 123 | return self._processors 124 | 125 | def add_processor(self, processor: FFmpegProcessor): 126 | self._processors.append(processor) 127 | 128 | def process(self, x: T) -> T: 129 | for processor in self._processors: 130 | x = processor.process(x) 131 | return x 132 | 133 | 134 | class EXEProcessorManager(BaseProcessorManager[Path]): 135 | def __init__(self): 136 | super().__init__() 137 | self._processors: list[EXEProcessor] = [] 138 | 139 | def get_processors(self) -> list[EXEProcessor]: 140 | return self._processors 141 | 142 | def add_processor(self, processor: EXEProcessor): 143 | self._processors.append(processor) 144 | 145 | def process(self, x: T) -> T: 146 | for processor in self._processors: 147 | x = processor.process(x) 148 | return x 149 | 150 | 151 | class AudioProcessorManager(BaseProcessorManager[Path]): 152 | def __init__(self): 153 | super().__init__() 154 | self._processors: list[AudioProcessor] = [] 155 | 156 | def get_processors(self) -> list[AudioProcessor]: 157 | return self._processors 158 | 159 | def add_processor(self, processor: AudioProcessor): 160 | self._processors.append(processor) 161 | 162 | def process(self, x: T) -> T: 163 | for processor in self._processors: 164 | x = processor.process(x) 165 | return x 166 | -------------------------------------------------------------------------------- /src/common/processors/exe_processors/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/271374667/VideoFusion/9ba7b8b301481ed148bc4d853a5efe8bbb7188b5/src/common/processors/exe_processors/__init__.py -------------------------------------------------------------------------------- /src/common/processors/exe_processors/audio_separator_processor.py: -------------------------------------------------------------------------------- 1 | import re 2 | from enum import Enum 3 | from pathlib import Path 4 | 5 | import loguru 6 | from PySide6.QtCore import QObject, Signal 7 | from audio_separator.separator import Separator 8 | 9 | from src.common.ffmpeg_handler import FFmpegHandler 10 | from src.common.processors.base_processor import EXEProcessor 11 | from src.config import cfg, AudioSeparationAlgorithm 12 | from src.core.paths import MODELS_DIR, OUTPUT_DIR, AUDIO_SEPARATOR_EXE_FILE 13 | from src.signal_bus import SignalBus 14 | from src.utils import TempDir 15 | import subprocess 16 | 17 | 18 | class AudioSeparationType(Enum): 19 | Instructment = 0 20 | Vocal = 1 21 | 22 | 23 | class AudioSeparator: 24 | def __init__(self, model_name: str, model_file_dir: Path = MODELS_DIR, output_dir: Path = OUTPUT_DIR): 25 | self._output_dir: Path = output_dir 26 | self._model_name: str = model_name 27 | self.separator = Separator( 28 | model_file_dir=model_file_dir, 29 | output_dir=output_dir 30 | ) 31 | self.separator.load_model(model_name) 32 | 33 | @property 34 | def model_name(self) -> str: 35 | return self._model_name 36 | 37 | def separate_audio(self, input_file: str) -> tuple[Path, Path]: 38 | """分离音频 39 | Args: 40 | input_file: 输入音频文件路径(wav格式) 41 | 42 | Returns: 43 | tuple[Path, Path]: 分离后的音频文件路径,同样为wav格式(乐器,人声) 44 | """ 45 | output_files = self.separator.separate(input_file) 46 | instrument_file_path: Path = self._output_dir / output_files[0] 47 | vocal_file_path: Path = self._output_dir / output_files[1] 48 | loguru.logger.debug(f"音频分离完成,分离路径如下: {instrument_file_path} {vocal_file_path}") 49 | return instrument_file_path, vocal_file_path 50 | 51 | 52 | class AudioSeparatorRedirect(QObject): 53 | progress_signal = Signal(float) 54 | 55 | def __init__(self): 56 | super().__init__() 57 | self._pre_progress: int = 0 58 | self._process_pattern = re.compile(r"^\s*(\d+)%\|.*?") 59 | 60 | self._signal_bus = SignalBus() 61 | 62 | def write(self, message): 63 | if match := self._process_pattern.match(message): 64 | progress = float(match[1]) 65 | else: 66 | progress = 0.0 67 | 68 | progress = int(progress) 69 | 70 | if progress != self._pre_progress: 71 | self._pre_progress = min(progress, 100) 72 | self._signal_bus.set_detail_progress_current.emit(int(self._pre_progress)) 73 | 74 | def flush(self): 75 | pass 76 | 77 | 78 | class AudioSeparatorProcessor(EXEProcessor): 79 | def __init__(self): 80 | super().__init__() 81 | 82 | self._signal_bus: SignalBus = SignalBus() 83 | self._temp_dir: TempDir = TempDir() 84 | self._audio_separator_redirect: AudioSeparatorRedirect = AudioSeparatorRedirect() 85 | self._ffmpeg_handler: FFmpegHandler = FFmpegHandler() 86 | 87 | self._signal_bus.system_message.connect(self._audio_separator_redirect.write) 88 | 89 | def process(self, input_file_path: Path) -> Path: 90 | audio_separator_algorithm: AudioSeparationAlgorithm = cfg.get(cfg.audio_separation_algorithm) 91 | match audio_separator_algorithm: 92 | case AudioSeparationAlgorithm.UVRMDXNETVocFTVocal: 93 | model_name = "UVR-MDX-NET-Voc_FT.onnx" 94 | audio_separator_type = AudioSeparationType.Vocal 95 | case AudioSeparationAlgorithm.UVRMDXNETVocFTInstructment: 96 | model_name = "UVR-MDX-NET-Voc_FT.onnx" 97 | audio_separator_type = AudioSeparationType.Instructment 98 | case AudioSeparationAlgorithm.MDX23CVocal: 99 | model_name = "MDX23C_D1581.ckpt" 100 | audio_separator_type = AudioSeparationType.Vocal 101 | case AudioSeparationAlgorithm.MDX23CInstructment: 102 | model_name = "MDX23C_D1581.ckpt" 103 | audio_separator_type = AudioSeparationType.Instructment 104 | case AudioSeparationAlgorithm.BsRoformerVocal: 105 | model_name = "model_bs_roformer_ep_317_sdr_12.9755.ckpt" 106 | audio_separator_type = AudioSeparationType.Vocal 107 | case AudioSeparationAlgorithm.BsRoformerInstructment: 108 | model_name = "model_bs_roformer_ep_317_sdr_12.9755.ckpt" 109 | audio_separator_type = AudioSeparationType.Instructment 110 | case _: 111 | loguru.logger.error(f"未知的音频分离算法: {audio_separator_algorithm}") 112 | raise ValueError(f"未知的音频分离算法: {audio_separator_algorithm}") 113 | 114 | input_audio_path: Path = self._ffmpeg_handler.extract_audio_from_video(input_file_path) 115 | 116 | self._signal_bus.set_detail_progress_max.emit(100) 117 | audio_separator = AudioSeparator(model_name, output_dir=self._temp_dir.get_temp_dir()) 118 | instrument_file_path, vocal_file_path = audio_separator.separate_audio(str(input_audio_path)) 119 | 120 | result_audio_path: Path = instrument_file_path if audio_separator_type == AudioSeparationType.Instructment else vocal_file_path 121 | final_file_path: Path = self._ffmpeg_handler.replace_video_audio(input_file_path, result_audio_path) 122 | self._signal_bus.set_detail_progress_finish.emit() 123 | return final_file_path 124 | 125 | 126 | if __name__ == '__main__': 127 | from PySide6.QtWidgets import QApplication 128 | from src.components.cmd_text_edit import CMDTextEdit 129 | import threading 130 | 131 | 132 | def main(): 133 | audio_separator_processor = AudioSeparatorProcessor() 134 | print(audio_separator_processor.process( 135 | Path( 136 | r"E:\load\python\Project\VideoFusion\TempAndTest\dy\v\测试\去黑边\d41a71f1c171b148cb41006193d3bc70.mp4"))) 137 | 138 | 139 | app = QApplication([]) 140 | cmd_text_edit = CMDTextEdit() 141 | cmd_text_edit.show() 142 | threading.Thread(target=main).start() 143 | app.exec() 144 | -------------------------------------------------------------------------------- /src/common/processors/exe_processors/auto_editor_processor.py: -------------------------------------------------------------------------------- 1 | import re 2 | import sys 3 | from os import environ 4 | from pathlib import Path 5 | 6 | import loguru 7 | from PySide6.QtCore import QObject, Signal 8 | from auto_editor.edit import edit_media 9 | from auto_editor.ffwrapper import FFmpeg 10 | from auto_editor.utils.log import Log 11 | from auto_editor.utils.types import Args 12 | 13 | from src.components.cmd_text_edit import CMDTextEdit 14 | from src.core.paths import FFMPEG_FILE 15 | from src.signal_bus import SignalBus 16 | from src.utils import TempDir, get_output_file_path 17 | from src.common.processors.base_processor import EXEProcessor 18 | 19 | 20 | class AutoEditRedirect(QObject): 21 | title_signal = Signal(str) 22 | progress_signal = Signal(float) 23 | 24 | def __init__(self): 25 | super().__init__() 26 | self._signal_bus = SignalBus() 27 | self._pre_title: str = '' 28 | self._pre_progress: int = 0 29 | self._process_pattern = re.compile(r".*?&\s*(?P[\w\s]+)\s*\[.*]\s*(?P<progress>\d+\.\d+)%") 30 | 31 | def write(self, message): 32 | if match := re.search(self._process_pattern, message): 33 | title = match["title"].strip() 34 | progress = float(match["progress"]) if (match := re.search(self._process_pattern, message)) else 0.0 35 | progress = int(progress) 36 | 37 | if title != self._pre_title: 38 | self._pre_title = title 39 | self.title_signal.emit(title) 40 | loguru.logger.debug(f'自动剪辑进度: {title}') 41 | 42 | if progress != self._pre_progress: 43 | self._pre_progress = min(progress, 100) 44 | self._signal_bus.set_detail_progress_current.emit(int(self._pre_progress)) 45 | 46 | def flush(self): 47 | pass 48 | 49 | 50 | class AutoEditProcessor(EXEProcessor): 51 | def __init__(self): 52 | self._signal_bus = SignalBus() 53 | self._auto_edit_redirect = AutoEditRedirect() 54 | self._signal_bus.system_message.connect(self._auto_edit_redirect.write) 55 | 56 | def process(self, input_file: Path) -> Path: 57 | output_file = get_output_file_path(Path(input_file), process_info="_auto_edit") 58 | ffmpeg = FFmpeg(ff_location=str(FFMPEG_FILE), my_ffmpeg=True) # 初始化FFmpeg 59 | temp = TempDir().get_temp_dir() # 临时目录 60 | 61 | # 确保临时目录存在 62 | temp.mkdir(parents=True, exist_ok=True) 63 | 64 | # 创建 Args 对象并设置参数 65 | args = Args(str(input_file), str(output_file)) 66 | args.silent_speed = 8 67 | args.video_speed = 1 68 | args.frame_margin = 3 69 | args.edit = '(audio > 0.025)' 70 | args.progress = 'ascii' 71 | args.output_file = output_file 72 | args.ffmpeg_location = str(FFMPEG_FILE) 73 | args.show_ffmpeg_output = True 74 | args.no_open = True 75 | 76 | ff_color = "AV_LOG_FORCE_NOCOLOR" 77 | no_color = bool(environ.get("NO_COLOR")) or bool(environ.get(ff_color)) 78 | log = Log(no_color=no_color) # 创建日志对象 79 | paths = [str(input_file)] # 输入文件路径列表 80 | 81 | # 调用 edit_media 函数进行视频编辑 82 | self._signal_bus.set_detail_progress_max.emit(100) 83 | edit_media(paths, ffmpeg, args, str(temp), log) 84 | loguru.logger.success(f"视频剪辑完成,输出文件为: {output_file}") 85 | self._signal_bus.set_detail_progress_finish.emit() 86 | return output_file 87 | 88 | 89 | if __name__ == '__main__': 90 | from PySide6.QtWidgets import QApplication 91 | import threading 92 | 93 | app = QApplication([]) 94 | cmd_text_edit = CMDTextEdit() 95 | cmd_text_edit.show() 96 | 97 | auto_edit_redirect = AutoEditRedirect() 98 | # sys.stdout = auto_edit_redirect 99 | auto_edit_redirect.title_signal.connect(lambda title: print(title)) 100 | auto_edit_redirect.progress_signal.connect(lambda progress: print(progress)) 101 | 102 | 103 | def run(): 104 | input_video_path = r"D:\Games\OBS录像\mkdocs\002降噪\001 mkdocs 欢迎您.mp4" 105 | auto_edit_processor = AutoEditProcessor() 106 | print(auto_edit_processor.process(Path(input_video_path))) 107 | 108 | 109 | threading.Thread(target=run).start() 110 | 111 | app.exec() 112 | -------------------------------------------------------------------------------- /src/common/processors/exe_processors/exe_processor_manager.py: -------------------------------------------------------------------------------- 1 | from pathlib import Path 2 | 3 | from src.common.processors.base_processor import BaseProcessorManager, EXEProcessor 4 | from src.common.processors.exe_processors.auto_editor_processor import AutoEditProcessor 5 | from src.common.processors.exe_processors.audio_separator_processor import AudioSeparatorProcessor 6 | from src.config import cfg, AudioSeparationAlgorithm 7 | 8 | 9 | class EXEProcessorManager(BaseProcessorManager[Path]): 10 | def __init__(self): 11 | super().__init__() 12 | self._audio_separator_processor = AudioSeparatorProcessor() 13 | self._auto_edit_processor = AutoEditProcessor() 14 | 15 | self._processors: list[EXEProcessor] = [ 16 | self._audio_separator_processor, 17 | self._auto_edit_processor 18 | ] 19 | 20 | def get_processors(self) -> list[EXEProcessor]: 21 | return self._processors 22 | 23 | def add_processor(self, processor: EXEProcessor): 24 | self._processors.append(processor) 25 | 26 | def process(self, x: Path) -> Path: 27 | self._check_enabled_processors() 28 | 29 | for processor in self._processors: 30 | if not processor.is_enable: 31 | continue 32 | x = processor.process(x) 33 | return x 34 | 35 | def _check_enabled_processors(self): 36 | """读取配置文件,判断哪些处理器是启用的 37 | """ 38 | video_auto_cut_enabled: bool = cfg.get(cfg.video_auto_cut) 39 | self._auto_edit_processor.is_enable = video_auto_cut_enabled 40 | 41 | audio_separation_algorithm: AudioSeparationAlgorithm = cfg.get(cfg.audio_separation_algorithm) 42 | if audio_separation_algorithm == AudioSeparationAlgorithm.Disable: 43 | self._audio_separator_processor.is_enable = False 44 | -------------------------------------------------------------------------------- /src/common/processors/ffmpeg_processors/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/271374667/VideoFusion/9ba7b8b301481ed148bc4d853a5efe8bbb7188b5/src/common/processors/ffmpeg_processors/__init__.py -------------------------------------------------------------------------------- /src/common/processors/ffmpeg_processors/ffmpeg_command_processor.py: -------------------------------------------------------------------------------- 1 | from pathlib import Path 2 | 3 | from src.common.processors.base_processor import FFmpegProcessor 4 | from src.common.processors.processor_global_var import ProcessorGlobalVar 5 | from src.core.datacls import VideoInfo 6 | 7 | 8 | class FFmpegCommandProcessor(FFmpegProcessor): 9 | def __init__(self): 10 | super().__init__() 11 | self._processor_global_var: ProcessorGlobalVar = ProcessorGlobalVar() 12 | 13 | def process(self, data: VideoInfo) -> Path: 14 | return data 15 | -------------------------------------------------------------------------------- /src/common/processors/opencv_processors/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/271374667/VideoFusion/9ba7b8b301481ed148bc4d853a5efe8bbb7188b5/src/common/processors/opencv_processors/__init__.py -------------------------------------------------------------------------------- /src/common/processors/opencv_processors/bilateral_denoise_processor.py: -------------------------------------------------------------------------------- 1 | import cv2 2 | import numpy as np 3 | 4 | from src.common.processors.base_processor import OpenCVProcessor 5 | 6 | 7 | class BilateralDenoiseProcessor(OpenCVProcessor): 8 | def process(self, frame: np.ndarray) -> np.ndarray: 9 | """ 10 | 使用cv2.bilateralFilter对输入的帧进行降噪处理 11 | 12 | Args: 13 | frame: 输入的帧,一个numpy数组 14 | 15 | Returns: 16 | 处理后的帧,一个numpy数组 17 | """ 18 | return cv2.bilateralFilter(frame, d=9, sigmaColor=75, sigmaSpace=75) 19 | -------------------------------------------------------------------------------- /src/common/processors/opencv_processors/brightness_contrast_processor.py: -------------------------------------------------------------------------------- 1 | import cv2 2 | import numpy as np 3 | 4 | from src.common.processors.base_processor import OpenCVProcessor 5 | 6 | 7 | class BrightnessContrastProcessor(OpenCVProcessor): 8 | def __init__(self, clip_limit=2.0, tile_grid_size=(8, 8)): 9 | """ 10 | 初始化自适应直方图均衡化处理器 11 | 12 | Args: 13 | clip_limit: 对比度限制 14 | tile_grid_size: 每个网格的大小 15 | """ 16 | self.clip_limit = clip_limit 17 | self.tile_grid_size = tile_grid_size 18 | 19 | def process(self, frame: np.ndarray) -> np.ndarray: 20 | """ 21 | 对输入的帧进行亮度和对比度调整并返回处理后的帧 22 | 23 | Args: 24 | frame: 输入的帧 25 | 26 | Returns: 27 | 处理后的帧 28 | """ 29 | # Convert frame to LAB color space 30 | lab = cv2.cvtColor(frame, cv2.COLOR_BGR2LAB) 31 | 32 | # Split the LAB image to different channels 33 | l, a, b = cv2.split(lab) 34 | 35 | # Apply CLAHE to the L channel 36 | clahe = cv2.createCLAHE(clipLimit=self.clip_limit, tileGridSize=self.tile_grid_size) 37 | cl = clahe.apply(l) 38 | 39 | # Merge the CLAHE enhanced L channel with the a and b channel 40 | limg = cv2.merge((cl, a, b)) 41 | 42 | return cv2.cvtColor(limg, cv2.COLOR_LAB2BGR) 43 | 44 | 45 | # Example usage 46 | if __name__ == "__main__": 47 | bc_processor = BrightnessContrastProcessor() 48 | origin_img = cv2.imread(r"E:\load\python\Project\VideoFusion\TempAndTest\dy\0002.jpg") 49 | enhanced_img = bc_processor.process(origin_img) 50 | origin_img = cv2.resize(origin_img, (480, 720)) 51 | enhanced_img = cv2.resize(enhanced_img, (480, 720)) 52 | cv2.imshow("origin_img", origin_img) 53 | cv2.imshow("enhanced_img", enhanced_img) 54 | # cap = cv2.VideoCapture(r"E:\load\python\Project\VideoFusion\测试\video\black.mp4") 55 | # out = cv2.VideoWriter(r"E:\load\python\Project\VideoFusion\测试\video\black_out.mp4", cv2.VideoWriter.fourcc(*'mp4v'), 30.0, 56 | # (int(cap.get(cv2.CAP_PROP_FRAME_WIDTH)), int(cap.get(cv2.CAP_PROP_FRAME_HEIGHT)))) 57 | # 58 | # while cap.isOpened(): 59 | # ret, frame = cap.read() 60 | # if not ret: 61 | # break 62 | # enhanced_frame = bc_processor.process(frame) 63 | # out.write(enhanced_frame) 64 | # 65 | # cap.release() 66 | # out.release() 67 | cv2.waitKey(0) 68 | cv2.destroyAllWindows() 69 | -------------------------------------------------------------------------------- /src/common/processors/opencv_processors/crop_processor.py: -------------------------------------------------------------------------------- 1 | import numpy as np 2 | 3 | from src.common.processors.base_processor import OpenCVProcessor 4 | from src.common.processors.processor_global_var import ProcessorGlobalVar 5 | 6 | 7 | class CropProcessor(OpenCVProcessor): 8 | def __init__(self): 9 | self._processor_global_var = ProcessorGlobalVar() 10 | 11 | def process(self, frame: np.ndarray) -> np.ndarray: 12 | crop_x: int = self._processor_global_var.get("crop_x") 13 | crop_y: int = self._processor_global_var.get("crop_y") 14 | crop_width: int = self._processor_global_var.get("crop_width") 15 | crop_height: int = self._processor_global_var.get("crop_height") 16 | 17 | # 如果全部都为None,则不进行裁剪 18 | if crop_x is None and crop_y is None and crop_width is None and crop_height is None: 19 | return frame 20 | 21 | # 如果只有一个参数为None对那个参数进行报错 22 | if crop_x is None: 23 | raise ValueError("crop_x 为None") 24 | elif crop_y is None: 25 | raise ValueError("crop_y 为None") 26 | elif crop_width is None: 27 | raise ValueError("crop_width 为None") 28 | elif crop_height is None: 29 | raise ValueError("crop_height 为None") 30 | 31 | return frame[crop_y:crop_y + crop_height, crop_x:crop_x + crop_width] 32 | -------------------------------------------------------------------------------- /src/common/processors/opencv_processors/deband_processor.py: -------------------------------------------------------------------------------- 1 | import cv2 2 | import numpy as np 3 | 4 | from src.common.processors.base_processor import OpenCVProcessor 5 | 6 | 7 | class DebandProcessor(OpenCVProcessor): 8 | def process(self, frame: np.ndarray) -> np.ndarray: 9 | """ 10 | 使用opencv对输入的帧进行去色带处理 11 | 12 | Args: 13 | frame: 输入的帧 14 | 15 | Returns: 16 | 去色带处理后的帧 17 | """ 18 | 19 | # 给图像添加高斯噪声 20 | def add_gaussian_noise(frame, mean=0, sigma=10): 21 | noise = np.random.normal(mean, sigma, frame.shape).astype(np.float32) 22 | noisy_frame = cv2.add(frame.astype(np.float32), noise) 23 | noisy_frame = np.clip(noisy_frame, 0, 255).astype(np.uint8) 24 | return noisy_frame 25 | 26 | # 减少图像中的色带效应 27 | def reduce_color_banding(frame): 28 | noisy_frame = add_gaussian_noise(frame) 29 | # 使用高斯模糊平滑图像 30 | denoised_frame = cv2.GaussianBlur(noisy_frame, (5, 5), 0) 31 | return denoised_frame 32 | 33 | return reduce_color_banding(frame) 34 | 35 | 36 | if __name__ == '__main__': 37 | img_path = r"E:\load\python\Project\VideoFusion\TempAndTest\images\deband1 (2).jpg" 38 | img = cv2.imread(img_path) 39 | cv2.imshow("img", img) 40 | if img is None: 41 | print(f"Failed to load image from {img_path}") 42 | else: 43 | deband_processor = DebandProcessor() 44 | result = deband_processor.process(img) 45 | cv2.imshow("result", result) 46 | cv2.waitKey(0) 47 | cv2.destroyAllWindows() 48 | -------------------------------------------------------------------------------- /src/common/processors/opencv_processors/deblock_processor.py: -------------------------------------------------------------------------------- 1 | import cv2 2 | import numpy as np 3 | 4 | from src.common.processors.base_processor import OpenCVProcessor 5 | 6 | 7 | class DeblockProcessor(OpenCVProcessor): 8 | def process(self, frame: np.ndarray) -> np.ndarray: 9 | """ 10 | 使用cv2.medianBlur对输入的帧进行去色块处理 11 | 12 | Args: 13 | frame: 输入的帧 14 | 15 | Returns: 16 | 去色块处理后的帧 17 | """ 18 | # 使用中值滤波去色块,这里的5是滤波器的大小 19 | return cv2.medianBlur(frame, 5) 20 | 21 | 22 | if __name__ == '__main__': 23 | img_path = r"E:\load\python\Project\VideoFusion\TempAndTest\images\deband1 (2).jpg" 24 | img = cv2.imread(img_path) 25 | cv2.imshow("img", img) 26 | if img is None: 27 | print(f"Failed to load image from {img_path}") 28 | else: 29 | deband_processor = DeblockProcessor() 30 | result = deband_processor.process(img) 31 | cv2.imshow("result", result) 32 | cv2.waitKey(0) 33 | cv2.destroyAllWindows() 34 | -------------------------------------------------------------------------------- /src/common/processors/opencv_processors/deshake_processor.py: -------------------------------------------------------------------------------- 1 | import cv2 2 | import numpy as np 3 | 4 | from src.common.processors.base_processor import OpenCVProcessor 5 | 6 | 7 | class DeshakeProcessor(OpenCVProcessor): 8 | """ 9 | https://github.com/lengkujiaai/video_stabilization 10 | """ 11 | 12 | def __init__(self, max_corners=200, quality_level=0.01, min_distance=30, block_size=3, smoothing_radius=50): 13 | self.prev_pts = None 14 | self.prev_gray = None 15 | self.transforms = [] 16 | self.max_corners = max_corners 17 | self.quality_level = quality_level 18 | self.min_distance = min_distance 19 | self.block_size = block_size 20 | self.smoothing_radius = smoothing_radius 21 | 22 | def process(self, frame: np.ndarray) -> np.ndarray: 23 | # Convert frame to grayscale 24 | gray = cv2.cvtColor(frame, cv2.COLOR_BGR2GRAY) 25 | 26 | if self.prev_gray is None: 27 | return self._initialize_prev_frame_and_points(gray, frame) 28 | # Detect feature points in current frame 29 | curr_pts, status, err = cv2.calcOpticalFlowPyrLK(self.prev_gray, gray, self.prev_pts, None) 30 | 31 | # Filter only valid points 32 | idx = np.where(status == 1)[0] 33 | prev_pts = self.prev_pts[idx] 34 | curr_pts = curr_pts[idx] 35 | 36 | # Estimate the transformation matrix 37 | m, _ = cv2.estimateAffinePartial2D(prev_pts, curr_pts) 38 | 39 | return frame if m is None else self._apply_transformation_and_fix_border(m, frame, gray) 40 | 41 | def _smooth_transforms(self): 42 | trajectory = np.cumsum(self.transforms, axis=0) 43 | smoothed_trajectory = self._smooth(trajectory) 44 | difference = smoothed_trajectory - trajectory 45 | self.transforms = self.transforms + difference 46 | 47 | def _smooth(self, trajectory): 48 | smoothed_trajectory = np.copy(trajectory) 49 | for i in range(3): 50 | smoothed_trajectory[:, i] = self._moving_average(trajectory[:, i], radius=self.smoothing_radius) 51 | return smoothed_trajectory 52 | 53 | def _moving_average(self, curve, radius): 54 | window_size = 2 * radius + 1 55 | f = np.ones(window_size) / window_size 56 | curve_pad = np.lib.pad(curve, (radius, radius), 'edge') 57 | curve_smoothed = np.convolve(curve_pad, f, mode='same') 58 | return curve_smoothed[radius:-radius] 59 | 60 | def fix_border(self, frame): 61 | s = frame.shape 62 | T = cv2.getRotationMatrix2D((s[1] / 2, s[0] / 2), 0, 1.04) 63 | frame = cv2.warpAffine(frame, T, (s[1], s[0])) 64 | return frame 65 | 66 | def _initialize_prev_frame_and_points(self, gray, arg1): 67 | # Initialize the previous frame and transforms array 68 | self.prev_gray = gray 69 | self.prev_pts = cv2.goodFeaturesToTrack(gray, 70 | maxCorners=self.max_corners, 71 | qualityLevel=self.quality_level, 72 | minDistance=self.min_distance, 73 | blockSize=self.block_size) 74 | return arg1 75 | 76 | def _apply_transformation_and_fix_border(self, m, frame, gray): 77 | # Extract translation and rotation 78 | dx = m[0, 2] 79 | dy = m[1, 2] 80 | da = np.arctan2(m[1, 0], m[0, 0]) 81 | self.transforms.append([dx, dy, da]) 82 | 83 | # Apply the transformation matrix to the current frame 84 | stabilized_frame = cv2.warpAffine(frame, m, (frame.shape[1], frame.shape[0])) 85 | stabilized_frame = self.fix_border(stabilized_frame) 86 | 87 | return self._initialize_prev_frame_and_points(gray, stabilized_frame) 88 | 89 | 90 | if __name__ == '__main__': 91 | 92 | # Example usage 93 | deshake_processor = DeshakeProcessor() 94 | cap = cv2.VideoCapture(r"E:\load\python\Project\VideoFusion\测试\video\001.mp4") 95 | out = cv2.VideoWriter(r"E:\load\python\Project\VideoFusion\测试\video\001_out.mp4", cv2.VideoWriter.fourcc(*'mp4v'), 96 | 30.0, (int(cap.get(3)), int(cap.get(4)))) 97 | 98 | while cap.isOpened(): 99 | ret, frame = cap.read() 100 | if not ret: 101 | break 102 | stabilized_frame = deshake_processor.process(frame) 103 | out.write(stabilized_frame) 104 | 105 | cap.release() 106 | out.release() 107 | -------------------------------------------------------------------------------- /src/common/processors/opencv_processors/means_denoise_processor.py: -------------------------------------------------------------------------------- 1 | import cv2 2 | import numpy as np 3 | 4 | from src.common.processors.base_processor import OpenCVProcessor 5 | 6 | 7 | class MeansDenoiseProcessor(OpenCVProcessor): 8 | def process(self, frame: np.ndarray) -> np.ndarray: 9 | """ 10 | 使用cv2.fastNlMeansDenoisingColored对输入的帧进行降噪处理 11 | 12 | Args: 13 | frame: 输入的帧,一个numpy数组 14 | 15 | Returns: 16 | 处理后的帧,一个numpy数组 17 | """ 18 | return cv2.fastNlMeansDenoisingColored(frame, None, 10, 10, 7, 21) 19 | -------------------------------------------------------------------------------- /src/common/processors/opencv_processors/resize_processor.py: -------------------------------------------------------------------------------- 1 | import cv2 2 | import loguru 3 | import numpy as np 4 | 5 | from src.common.processors.base_processor import OpenCVProcessor 6 | from src.common.processors.processor_global_var import ProcessorGlobalVar 7 | from src.config import ScalingQuality, cfg 8 | 9 | 10 | class ResizeCache: 11 | def __init__(self): 12 | self.new_width = None 13 | self.new_height = None 14 | self.pad_top = None 15 | self.pad_bottom = None 16 | self.pad_left = None 17 | self.pad_right = None 18 | 19 | def is_set(self): 20 | return all([self.new_width, self.new_height, self.pad_top, self.pad_bottom, self.pad_left, self.pad_right]) 21 | 22 | def set_values(self, new_width, new_height, pad_top, pad_bottom, pad_left, pad_right): 23 | self.new_width = new_width 24 | self.new_height = new_height 25 | self.pad_top = pad_top 26 | self.pad_bottom = pad_bottom 27 | self.pad_left = pad_left 28 | self.pad_right = pad_right 29 | 30 | def reset(self): 31 | self.__init__() 32 | 33 | 34 | class ResizeProcessor(OpenCVProcessor): 35 | def __init__(self): 36 | self._processor_global_var = ProcessorGlobalVar() 37 | self._cache = ResizeCache() 38 | 39 | def process(self, frame: np.ndarray) -> np.ndarray: 40 | """ 41 | 调整输入帧的大小,使其尽可能接近指定分辨率,剩余部分使用黑边填充。 42 | 43 | Args: 44 | frame: 输入的帧,一个numpy数组 45 | 46 | Returns: 47 | 处理后的帧,一个numpy数组 48 | """ 49 | # 不需要合并就不需要调整分辨率 50 | is_merge: bool = cfg.get(cfg.merge_video) 51 | if not is_merge: 52 | return frame 53 | 54 | target_width: int = self._processor_global_var.get("target_width") 55 | target_height: int = self._processor_global_var.get("target_height") 56 | # 获取输入帧的宽度和高度 57 | height, width = frame.shape[:2] 58 | 59 | # 检查是否有缓存 60 | if not self._cache.is_set(): 61 | new_width, new_height, pad_top, pad_bottom, pad_left, pad_right = self._calculate_dimensions(width, height, 62 | target_width, 63 | target_height) 64 | self._cache.set_values(new_width, new_height, pad_top, pad_bottom, pad_left, pad_right) 65 | else: 66 | new_width = self._cache.new_width 67 | new_height = self._cache.new_height 68 | pad_top = self._cache.pad_top 69 | pad_bottom = self._cache.pad_bottom 70 | pad_left = self._cache.pad_left 71 | pad_right = self._cache.pad_right 72 | 73 | # 缩放视频帧到新的尺寸 74 | resize_algorithm: ScalingQuality = cfg.get(cfg.scaling_quality) 75 | match resize_algorithm: 76 | case ScalingQuality.Nearest: 77 | resized_frame = cv2.resize(frame, (new_width, new_height), interpolation=cv2.INTER_NEAREST) 78 | case ScalingQuality.Bilinear: 79 | resized_frame = cv2.resize(frame, (new_width, new_height), interpolation=cv2.INTER_LINEAR) 80 | case ScalingQuality.Bicubic: 81 | resized_frame = cv2.resize(frame, (new_width, new_height), interpolation=cv2.INTER_CUBIC) 82 | case ScalingQuality.Lanczos: 83 | resized_frame = cv2.resize(frame, (new_width, new_height), interpolation=cv2.INTER_LANCZOS4) 84 | case ScalingQuality.Sinc: 85 | resized_frame = cv2.resize(frame, (new_width, new_height), interpolation=cv2.INTER_CUBIC) 86 | case _: 87 | loguru.logger.error(f"Invalid scaling quality: {resize_algorithm}") 88 | resized_frame = cv2.resize(frame, (new_width, new_height), interpolation=cv2.INTER_LINEAR) 89 | 90 | return cv2.copyMakeBorder( 91 | resized_frame, 92 | pad_top, 93 | pad_bottom, 94 | pad_left, 95 | pad_right, 96 | cv2.BORDER_CONSTANT, 97 | value=[0, 0, 0], 98 | ) 99 | 100 | def _calculate_dimensions(self, width: int, height: int, target_width: int, target_height: int): 101 | if width == 0 or height == 0: 102 | loguru.logger.critical("视频的宽度或高度为0, 请检查视频") 103 | raise ValueError("Width or height is 0") 104 | scale = min(target_width / width, target_height / height) 105 | new_width = int(width * scale) 106 | new_height = int(height * scale) 107 | pad_top = (target_height - new_height) // 2 108 | pad_bottom = target_height - new_height - pad_top 109 | pad_left = (target_width - new_width) // 2 110 | pad_right = target_width - new_width - pad_left 111 | return new_width, new_height, pad_top, pad_bottom, pad_left, pad_right 112 | -------------------------------------------------------------------------------- /src/common/processors/opencv_processors/rotate_processor.py: -------------------------------------------------------------------------------- 1 | import cv2 2 | import numpy as np 3 | 4 | from src.common.processors.base_processor import OpenCVProcessor 5 | from src.common.processors.processor_global_var import ProcessorGlobalVar 6 | from src.core.enums import Orientation 7 | 8 | 9 | class RotateProcessor(OpenCVProcessor): 10 | def __init__(self): 11 | self._processor_global_var = ProcessorGlobalVar() 12 | # 获取旋转的角度{0, 90, 180, 270} 13 | 14 | def process(self, frame: np.ndarray) -> np.ndarray: 15 | angle: int = self._processor_global_var.get('rotation_angle') 16 | orientation: Orientation = self._processor_global_var.get("orientation") 17 | 18 | if orientation is None: 19 | raise ValueError("orientation is required") 20 | 21 | if angle is None: 22 | raise ValueError("angle is required") 23 | 24 | # 将角度转换为OpenCV的旋转角度 25 | match angle: 26 | case 0: 27 | cv2_angle = cv2.ROTATE_90_CLOCKWISE 28 | case 90: 29 | cv2_angle = cv2.ROTATE_90_COUNTERCLOCKWISE 30 | case 180: 31 | cv2_angle = cv2.ROTATE_180 32 | case 270: 33 | cv2_angle = cv2.ROTATE_90_CLOCKWISE 34 | case _: 35 | raise ValueError(f"Invalid angle: {angle}") 36 | 37 | frame_width: int = frame.shape[1] 38 | frame_height: int = frame.shape[0] 39 | 40 | # 如果视频的宽高和视频的朝向不一致,则旋转视频 41 | # 例如视频的宽300,高100,此时视频是一个横屏视频,但是视频的朝向是竖屏,此时就要旋转 42 | # 视频的宽高和视频的朝向一致,则不需要旋转 43 | if (((orientation == Orientation.HORIZONTAL) and (frame_height > frame_width)) 44 | or (orientation == Orientation.VERTICAL and frame_width > frame_height)): 45 | frame = cv2.rotate(frame, cv2_angle) 46 | return frame 47 | -------------------------------------------------------------------------------- /src/common/processors/opencv_processors/super_resolution_processor.py: -------------------------------------------------------------------------------- 1 | import cv2 2 | import numpy as np 3 | 4 | from src.common.processors.base_processor import OpenCVProcessor 5 | from src.core.paths import ESPCN_x2_FILE, LapSRN_x2_FILE 6 | 7 | 8 | class SuperResolutionESPCNProcessor(OpenCVProcessor): 9 | def __init__(self, scale_factor=2): 10 | """ 11 | 图像超分辨率处理器,使用ESPCN模型 12 | """ 13 | self.model = cv2.dnn_superres.DnnSuperResImpl_create() 14 | self.model.readModel(str(ESPCN_x2_FILE)) 15 | self.scale_factor = scale_factor 16 | self.model.setModel("espcn", self.scale_factor) 17 | 18 | def process(self, frame: np.ndarray) -> np.ndarray: 19 | """ 20 | 对输入的帧进行超分辨率处理并返回处理后的帧,但保持原尺寸 21 | 22 | Args: 23 | frame: 输入的帧 24 | 25 | Returns: 26 | 处理后的帧 27 | """ 28 | # 获取原始尺寸 29 | original_size = (frame.shape[1], frame.shape[0]) 30 | 31 | # 缩放输入帧 32 | scaled_size = (frame.shape[1] // self.scale_factor, frame.shape[0] // self.scale_factor) 33 | scaled_frame = cv2.resize(frame, scaled_size, interpolation=cv2.INTER_AREA) 34 | 35 | # 对缩放后的帧进行超分辨率处理 36 | super_res_frame = self.model.upsample(scaled_frame) 37 | 38 | return cv2.resize( 39 | super_res_frame, original_size, interpolation=cv2.INTER_LINEAR 40 | ) 41 | 42 | 43 | class SuperResolutionLapSRNProcessor(SuperResolutionESPCNProcessor): 44 | def __init__(self, scale_factor=2): 45 | super().__init__() 46 | self.model.readModel(str(LapSRN_x2_FILE)) 47 | self.scale_factor = scale_factor 48 | self.model.setModel("lapsrn", self.scale_factor) 49 | 50 | 51 | # Example usage 52 | if __name__ == "__main__": 53 | model_path = r"E:\load\python\Project\VideoFusion\bin\LapSRN_x2.pb" # 需要提供你的ESPCN模型文件路径 54 | scale_factor = 2 # 根据你的模型设置的放大因子 55 | sr_processor = SuperResolutionESPCNProcessor() 56 | origin_img = cv2.imread(r"E:\load\python\Project\VideoFusion\TempAndTest\images\deband1 (2).jpg") 57 | print(origin_img) 58 | super_res_frame = sr_processor.process(origin_img) 59 | cv2.imshow("origin", origin_img) 60 | cv2.imshow("super_res_frame", super_res_frame) 61 | 62 | # cap = cv2.VideoCapture(r"E:\load\python\Project\VideoFusion\测试\video\black.mp4") 63 | # out = cv2.VideoWriter(r"E:\load\python\Project\VideoFusion\测试\video\black_out.mp4.mp4", 64 | # cv2.VideoWriter.fourcc(*'mp4v'), 30.0, 65 | # (int(cap.get(cv2.CAP_PROP_FRAME_WIDTH)), int(cap.get(cv2.CAP_PROP_FRAME_HEIGHT)))) 66 | # 67 | # # Get the total number of frames in the video 68 | # total_frames = int(cap.get(cv2.CAP_PROP_FRAME_COUNT)) 69 | # 70 | # # Wrap the loop with tqdm for a progress bar 71 | # for _ in tqdm(range(total_frames), desc="Processing frames"): 72 | # ret, frame = cap.read() 73 | # if not ret: 74 | # break 75 | # super_res_frame = sr_processor.process(frame) 76 | # out.write(super_res_frame) 77 | # 78 | # cap.release() 79 | # out.release() 80 | cv2.waitKey(0) 81 | cv2.destroyAllWindows() 82 | -------------------------------------------------------------------------------- /src/common/processors/opencv_processors/white_balance_processor.py: -------------------------------------------------------------------------------- 1 | import cv2 2 | import numpy as np 3 | 4 | from src.common.processors.base_processor import OpenCVProcessor 5 | 6 | 7 | class WhiteBalanceProcessor(OpenCVProcessor): 8 | def __init__(self): 9 | # Using the SimpleWB white balance algorithm 10 | self.white_balance = cv2.xphoto.createSimpleWB() 11 | 12 | def process(self, frame: np.ndarray) -> np.ndarray: 13 | """ 14 | 对输入的帧进行白平衡处理并返回处理后的帧 15 | 16 | Args: 17 | frame: 输入的帧 18 | 19 | Returns: 20 | 处理后的帧 21 | """ 22 | return self.white_balance.balanceWhite(frame) 23 | 24 | 25 | # Example usage 26 | if __name__ == "__main__": 27 | wb_processor = WhiteBalanceProcessor() 28 | cap = cv2.VideoCapture(r"E:\load\python\Project\VideoFusion\测试\video\001.mp4") 29 | out = cv2.VideoWriter(r"E:\load\python\Project\VideoFusion\测试\video\001_out.mp4", cv2.VideoWriter.fourcc(*'mp4v'), 30.0, 30 | (int(cap.get(cv2.CAP_PROP_FRAME_WIDTH)), int(cap.get(cv2.CAP_PROP_FRAME_HEIGHT)))) 31 | 32 | while cap.isOpened(): 33 | ret, frame = cap.read() 34 | if not ret: 35 | break 36 | balanced_frame = wb_processor.process(frame) 37 | out.write(balanced_frame) 38 | 39 | cap.release() 40 | out.release() 41 | cv2.destroyAllWindows() 42 | -------------------------------------------------------------------------------- /src/common/processors/processor_global_var.py: -------------------------------------------------------------------------------- 1 | from src.core.dicts import VideoInfoDict 2 | from src.utils import singleton 3 | 4 | 5 | @singleton 6 | class ProcessorGlobalVar: 7 | _instance = None 8 | 9 | def __new__(cls, *args, **kwargs): 10 | if cls._instance is None: 11 | cls._instance = super().__new__(cls) 12 | return cls._instance 13 | 14 | def __init__(self): 15 | self._data: VideoInfoDict = {} 16 | 17 | def get_data(self) -> VideoInfoDict: 18 | return self._data 19 | 20 | def get(self, key: str): 21 | if key not in VideoInfoDict.__annotations__: 22 | raise KeyError(f"{key} is not a valid key.") 23 | return self._data.get(key) 24 | 25 | def update(self, key: str, value): 26 | if key not in VideoInfoDict.__annotations__: 27 | raise KeyError(f"{key} is not a valid key.") 28 | 29 | self._data[key] = value 30 | 31 | def clear(self): 32 | self._data.clear() 33 | 34 | def __repr__(self): 35 | return f"ProcessorGlobalVar({self._data})" 36 | -------------------------------------------------------------------------------- /src/common/task_resumer/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/271374667/VideoFusion/9ba7b8b301481ed148bc4d853a5efe8bbb7188b5/src/common/task_resumer/__init__.py -------------------------------------------------------------------------------- /src/common/task_resumer/task_resumer.py: -------------------------------------------------------------------------------- 1 | from pathlib import Path 2 | 3 | from src.core.dicts import TaskDict 4 | from src.core.enums import FileProcessType 5 | 6 | 7 | class TaskResumer: 8 | def __init__(self, input_video_path: Path, 9 | task_status: FileProcessType = FileProcessType.UNCOMPLETED, 10 | ): 11 | self._current_status: FileProcessType = task_status 12 | self._data_dict: TaskDict = { 13 | "input_video_path": str(input_video_path), 14 | "task_status": task_status.value, 15 | } 16 | 17 | @property 18 | def input_video_path(self) -> Path: 19 | return Path(self._data_dict["input_video_path"]) 20 | 21 | @input_video_path.setter 22 | def input_video_path(self, value: Path): 23 | self._data_dict["input_video_path"] = str(value) 24 | 25 | @property 26 | def current_status(self) -> FileProcessType: 27 | return self._current_status 28 | 29 | @current_status.setter 30 | def current_status(self, value: FileProcessType): 31 | self._current_status = value 32 | self._data_dict["task_status"] = value.value 33 | 34 | @property 35 | def data_dict(self) -> TaskDict: 36 | return self._data_dict 37 | 38 | @data_dict.setter 39 | def data_dict(self, value: TaskDict): 40 | self._data_dict = value 41 | self._current_status = FileProcessType(value["task_status"]) 42 | 43 | @property 44 | def output_video_path(self) -> Path | None: 45 | output_video_path = self._data_dict.get("output_video_path") 46 | return None if output_video_path is None else Path(output_video_path) 47 | 48 | @output_video_path.setter 49 | def output_video_path(self, value: Path): 50 | if not value.exists(): 51 | raise FileNotFoundError(f"Output video path {value} does not exist") 52 | self._data_dict["output_video_path"] = str(value) 53 | self.current_status = FileProcessType.COMPLETED 54 | 55 | def __repr__(self): 56 | return f"TaskResumer({self.input_video_path}, {self.current_status})" 57 | -------------------------------------------------------------------------------- /src/common/task_resumer/task_resumer_manager.py: -------------------------------------------------------------------------------- 1 | import json 2 | from pathlib import Path 3 | 4 | import loguru 5 | 6 | from src.common.task_resumer.task_resumer import TaskResumer 7 | from src.core.dicts import TaskDict 8 | from src.core.enums import FileProcessType 9 | from src.core.paths import RESUME_FILE 10 | from src.utils import singleton 11 | 12 | 13 | @singleton 14 | class TaskResumerManager: 15 | def __init__(self): 16 | self._resume_file_path: Path = RESUME_FILE 17 | self._task_list: list[TaskResumer] = [] 18 | 19 | if self.finished: 20 | self.clear() 21 | self.save() 22 | else: 23 | self.load() 24 | 25 | @property 26 | def resume_file_path(self) -> Path: 27 | return self._resume_file_path 28 | 29 | @resume_file_path.setter 30 | def resume_file_path(self, value: Path): 31 | self._resume_file_path = value 32 | 33 | @property 34 | def task_list(self) -> list[TaskResumer]: 35 | return self._task_list 36 | 37 | @property 38 | def total_task_status(self) -> FileProcessType: 39 | if all(task.current_status == FileProcessType.COMPLETED for task in self._task_list): 40 | return FileProcessType.COMPLETED 41 | return FileProcessType.UNCOMPLETED 42 | 43 | @total_task_status.setter 44 | def total_task_status(self, value: FileProcessType): 45 | for task in self._task_list: 46 | task.current_status = value 47 | 48 | @property 49 | def uncompleted_task_list(self) -> list[TaskResumer]: 50 | return [task for task in self._task_list if task.current_status != FileProcessType.COMPLETED] 51 | 52 | @property 53 | def finished(self) -> bool: 54 | return all(task.current_status == FileProcessType.COMPLETED for task in self._task_list) 55 | 56 | def append_task(self, task: TaskResumer): 57 | self._task_list.append(task) 58 | 59 | def save(self): 60 | """保存为json""" 61 | with open(self._resume_file_path, "w") as f: 62 | json.dump([x.data_dict for x in self._task_list], f) 63 | 64 | def load(self) -> list[TaskResumer]: 65 | """从json加载""" 66 | if not self._resume_file_path.exists(): 67 | self.save() 68 | 69 | with open(self._resume_file_path, "r") as f: 70 | resume_data: list[TaskDict] = json.load(f) 71 | for each_task in resume_data: 72 | task_resumer = TaskResumer(Path(each_task['input_video_path']), 73 | FileProcessType(each_task['task_status'])) 74 | self._task_list.append(task_resumer) 75 | loguru.logger.debug(f"加载任务恢复文件:{self._resume_file_path},任务数:{len(self._task_list)}") 76 | return self._task_list 77 | 78 | def remove(self, task: TaskResumer): 79 | """删除任务""" 80 | self._task_list.remove(task) 81 | loguru.logger.debug(f"删除任务{task}") 82 | 83 | def clear(self): 84 | """清空任务""" 85 | self._task_list.clear() 86 | loguru.logger.debug("清空任务恢复文件") 87 | -------------------------------------------------------------------------------- /src/common/utils/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/271374667/VideoFusion/9ba7b8b301481ed148bc4d853a5efe8bbb7188b5/src/common/utils/__init__.py -------------------------------------------------------------------------------- /src/common/utils/image_utils.py: -------------------------------------------------------------------------------- 1 | from pathlib import Path 2 | 3 | import cv2 4 | import numpy as np 5 | 6 | img_suffix: list[str] = ['.png', '.jpg', '.jpeg', '.bmp', '.webp', '.svg'] 7 | 8 | 9 | class ImageUtils: 10 | def __init__(self, threshold: int = 30, border_width: int = 5): 11 | self.threshold: int = threshold 12 | self.border_width: int = border_width 13 | 14 | def read_image(self, img_path: str | Path) -> np.ndarray: 15 | """ 16 | 读取图像 17 | 18 | Args: 19 | img_path: 图像路径 20 | 21 | Returns: 22 | np.ndarray: 图像数组 23 | """ 24 | if not Path(img_path).exists(): 25 | raise FileNotFoundError(f"文件不存在: {img_path}") 26 | 27 | if Path(img_path).suffix not in img_suffix: 28 | raise ValueError(f"不支持的文件格式: {img_path}") 29 | return cv2.imread(str(img_path)) 30 | 31 | def is_black(self, img: np.ndarray) -> bool: 32 | """ 33 | 判断图像是否为黑色 34 | 35 | Args: 36 | img: 图像 37 | 38 | Returns: 39 | bool: 是否为黑色 40 | """ 41 | img = cv2.cvtColor(img, cv2.COLOR_BGR2GRAY) 42 | return np.mean(img) < self.threshold 43 | 44 | def has_black_border(self, img: np.ndarray) -> bool: 45 | """ 46 | 判断图像是否有黑边 47 | 48 | Args: 49 | img: 图像 50 | 51 | Returns: 52 | bool: 是否有黑边 53 | """ 54 | # 读取图像 55 | img = cv2.cvtColor(img, cv2.COLOR_BGR2GRAY) 56 | threshold = self.threshold 57 | border_width = self.border_width 58 | 59 | # 检查上下左右四个边缘的像素值 60 | top_edge = img[:border_width, :] 61 | bottom_edge = img[-border_width:, :] 62 | left_edge = img[:, :border_width] 63 | right_edge = img[:, -border_width:] 64 | 65 | # 如果这些像素值的平均值都小于给定的阈值(默认为50),那么函数返回True,表示图像有黑边 66 | return ( 67 | np.mean(top_edge) < threshold 68 | or np.mean(bottom_edge) < threshold 69 | or np.mean(left_edge) < threshold 70 | or np.mean(right_edge) < threshold 71 | ) 72 | -------------------------------------------------------------------------------- /src/common/video_engines/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/271374667/VideoFusion/9ba7b8b301481ed148bc4d853a5efe8bbb7188b5/src/common/video_engines/__init__.py -------------------------------------------------------------------------------- /src/common/video_engines/base_video_engine.py: -------------------------------------------------------------------------------- 1 | from abc import ABC, abstractmethod 2 | from pathlib import Path 3 | 4 | 5 | class BaseVideoEngine(ABC): 6 | @abstractmethod 7 | def process_video(self, input_video_path: Path) -> Path: 8 | raise NotImplementedError("This method must be implemented by subclass") 9 | -------------------------------------------------------------------------------- /src/common/video_engines/opencv_video_engine.py: -------------------------------------------------------------------------------- 1 | from pathlib import Path 2 | 3 | import cv2 4 | import loguru 5 | 6 | from src.common.ffmpeg_handler import FFmpegHandler 7 | from src.common.processors.audio_processors.audio_processor_manager import AudioProcessorManager 8 | from src.common.processors.opencv_processors.opencv_processor_manager import OpenCVProcessorManager 9 | from src.common.processors.processor_global_var import ProcessorGlobalVar 10 | from src.common.video_engines.base_video_engine import BaseVideoEngine 11 | from src.signal_bus import SignalBus 12 | from src.utils import TempDir, get_output_file_path 13 | 14 | 15 | class OpenCVVideoEngine(BaseVideoEngine): 16 | def __init__(self): 17 | self._signal_bus: SignalBus = SignalBus() 18 | self._temp_dir: TempDir = TempDir() 19 | self._ffmpeg_handler: FFmpegHandler = FFmpegHandler() 20 | self._audio_processor_manager: AudioProcessorManager = AudioProcessorManager() 21 | self._video_processor_manager: OpenCVProcessorManager = OpenCVProcessorManager() 22 | self._processor_global_var: ProcessorGlobalVar = ProcessorGlobalVar() 23 | 24 | self.is_running: bool = True 25 | self._signal_bus.set_running.connect(self._set_running) 26 | 27 | def process_video(self, input_video_path: Path) -> Path: 28 | if (self._processor_global_var.get_data()['crop_x'] is None 29 | and self._processor_global_var.get_data()['crop_y'] is None): 30 | self._video_processor_manager.get_crop_processor().is_enable = False 31 | 32 | video_after_processed = self._video_process(input_video_path) 33 | 34 | try: 35 | audio_extractor = self._ffmpeg_handler.extract_audio_from_video(input_video_path) 36 | except Exception as e: 37 | loguru.logger.error(f'提取音频失败,原因:{e}') 38 | audio_extractor = None 39 | 40 | if not audio_extractor: 41 | loguru.logger.debug(f'视频{input_video_path}没有音频') 42 | return video_after_processed 43 | audio_after_processed = self._audio_process(audio_extractor) 44 | 45 | if not self.is_running: 46 | raise ValueError("您暂停了程序") 47 | 48 | # 合并视频和音频 49 | video_with_audio: Path = self._ffmpeg_handler.replace_video_audio(video_after_processed, audio_after_processed) 50 | return self._ffmpeg_handler.reencode_video(video_with_audio) 51 | 52 | def _video_process(self, input_video_path: Path) -> Path: 53 | """ 54 | 读取输入视频,逐帧处理,然后写入输出视频,以及音频处理 55 | 56 | Args: 57 | input_video_path: 输入视频的路径 58 | """ 59 | self._signal_bus.set_detail_progress_reset.emit() 60 | 61 | cap = cv2.VideoCapture(str(input_video_path)) 62 | if not cap.isOpened(): 63 | raise ValueError("无法打开输入视频") 64 | 65 | fps = cap.get(cv2.CAP_PROP_FPS) 66 | total_frames = int(cap.get(cv2.CAP_PROP_FRAME_COUNT)) 67 | # 因为处理后的视频的宽高可能会发生变化,所以需要重新获取宽高 68 | width, height = self._get_after_process_width_and_height(input_video_path) 69 | 70 | # MP4V比较通用,但是不支持透明度 71 | fourcc = cv2.VideoWriter.fourcc(*'mp4v') 72 | output_file_path = get_output_file_path(input_video_path, "video_processed") 73 | out = cv2.VideoWriter(str(output_file_path), fourcc, fps, (width, height)) 74 | 75 | self._signal_bus.set_detail_progress_max.emit(total_frames) 76 | for _ in range(total_frames): 77 | ret, frame = cap.read() 78 | if not ret or not self.is_running: 79 | break 80 | 81 | # 对帧进行处理 82 | processed_frame = self._video_processor_manager.process(frame) 83 | 84 | # 写入处理后的帧 85 | out.write(processed_frame) 86 | self._signal_bus.advance_detail_progress.emit(1) 87 | 88 | # 释放资源 89 | cap.release() 90 | out.release() 91 | 92 | self._signal_bus.set_detail_progress_finish.emit() 93 | return output_file_path 94 | 95 | def _audio_process(self, input_video_path: Path) -> Path: 96 | """ 97 | 读取输入视频,逐帧处理,然后写入输出视频,以及音频处理 98 | 99 | Args: 100 | input_video_path: 输入视频的路径 101 | """ 102 | output_file_path = get_output_file_path(input_video_path, "audio_processed") 103 | self._audio_processor_manager.process(input_video_path) 104 | return output_file_path 105 | 106 | def _get_after_process_width_and_height(self, input_video_path: Path, ) -> tuple[int, int]: 107 | cap = cv2.VideoCapture(str(input_video_path)) 108 | if not cap.isOpened(): 109 | raise ValueError("无法打开输入视频") 110 | 111 | ret, frame = cap.read() 112 | if not ret: 113 | raise ValueError("无法读取视频的第一帧") 114 | processed_frame = self._video_processor_manager.process(frame) 115 | 116 | height, width = processed_frame.shape[:2] 117 | cap.release() 118 | return width, height 119 | 120 | def _set_running(self, is_running: bool): 121 | self.is_running = is_running 122 | -------------------------------------------------------------------------------- /src/common/video_handler.py: -------------------------------------------------------------------------------- 1 | from pathlib import Path 2 | 3 | import cv2 4 | 5 | from src.common.ffmpeg_handler import FFmpegHandler 6 | from src.common.processors.audio_processors.audio_processor_manager import AudioProcessorManager 7 | from src.common.processors.exe_processors.exe_processor_manager import EXEProcessorManager 8 | from src.common.processors.opencv_processors.opencv_processor_manager import OpenCVProcessorManager 9 | from src.common.processors.processor_global_var import ProcessorGlobalVar 10 | from src.common.video_engines.ffmpeg_video_engine import FFmpegVideoEngine 11 | from src.common.video_engines.opencv_video_engine import OpenCVVideoEngine 12 | from src.config import VideoProcessEngine 13 | from src.core.enums import Orientation 14 | from src.signal_bus import SignalBus 15 | from src.utils import TempDir, get_output_file_path 16 | 17 | 18 | class VideoHandler: 19 | def __init__(self): 20 | self._signal_bus: SignalBus = SignalBus() 21 | self._temp_dir: TempDir = TempDir() 22 | self._ffmpeg_handler: FFmpegHandler = FFmpegHandler() 23 | self._open_cv_video_engine: OpenCVVideoEngine = OpenCVVideoEngine() 24 | self._ffmpeg_video_engine: FFmpegVideoEngine = FFmpegVideoEngine() 25 | self._audio_processor_manager: AudioProcessorManager = AudioProcessorManager() 26 | self._video_processor_manager: OpenCVProcessorManager = OpenCVProcessorManager() 27 | self._exe_processor_manager: EXEProcessorManager = EXEProcessorManager() 28 | self._processor_global_var: ProcessorGlobalVar = ProcessorGlobalVar() 29 | self.is_running: bool = True 30 | 31 | self._signal_bus.set_running.connect(self._set_running) 32 | 33 | def process_video(self, input_video_path: Path, 34 | engine_type: VideoProcessEngine = VideoProcessEngine.FFmpeg) -> Path: 35 | if engine_type == VideoProcessEngine.OpenCV: 36 | video_after_processed = self._open_cv_video_engine.process_video(input_video_path) 37 | elif engine_type == VideoProcessEngine.FFmpeg: 38 | video_after_processed = self._ffmpeg_video_engine.process_video(input_video_path) 39 | else: 40 | raise ValueError(f"不支持的视频处理引擎{engine_type}") 41 | 42 | video_after_processed = self._exe_processor_manager.process(video_after_processed) 43 | return video_after_processed 44 | 45 | def merge_videos(self, video_list: list[Path]) -> Path: 46 | return self._ffmpeg_handler.merge_videos(video_list) 47 | 48 | def compress_video(self, input_video_path: Path) -> Path: 49 | return self._ffmpeg_handler.reencode_video(input_video_path) 50 | 51 | def _video_process(self, input_video_path: Path) -> Path: 52 | """ 53 | 读取输入视频,逐帧处理,然后写入输出视频,以及音频处理 54 | 55 | Args: 56 | input_video_path: 输入视频的路径 57 | """ 58 | self._signal_bus.set_detail_progress_reset.emit() 59 | 60 | cap = cv2.VideoCapture(str(input_video_path)) 61 | if not cap.isOpened(): 62 | raise ValueError("无法打开输入视频") 63 | 64 | fps = cap.get(cv2.CAP_PROP_FPS) 65 | total_frames = int(cap.get(cv2.CAP_PROP_FRAME_COUNT)) 66 | # 因为处理后的视频的宽高可能会发生变化,所以需要重新获取宽高 67 | width, height = self._get_after_process_width_and_height(input_video_path) 68 | 69 | # MP4V比较通用,但是不支持透明度 70 | fourcc = cv2.VideoWriter.fourcc(*'mp4v') 71 | output_file_path = get_output_file_path(input_video_path, "video_processed") 72 | out = cv2.VideoWriter(str(output_file_path), fourcc, fps, (width, height)) 73 | 74 | self._signal_bus.set_detail_progress_max.emit(total_frames) 75 | for _ in range(total_frames): 76 | ret, frame = cap.read() 77 | if not ret or not self.is_running: 78 | break 79 | 80 | # 对帧进行处理 81 | processed_frame = self._video_processor_manager.process(frame) 82 | 83 | # 写入处理后的帧 84 | out.write(processed_frame) 85 | self._signal_bus.advance_detail_progress.emit(1) 86 | 87 | # 释放资源 88 | cap.release() 89 | out.release() 90 | 91 | self._signal_bus.set_detail_progress_finish.emit() 92 | return output_file_path 93 | 94 | def _audio_process(self, input_video_path: Path) -> Path: 95 | """ 96 | 读取输入视频,逐帧处理,然后写入输出视频,以及音频处理 97 | 98 | Args: 99 | input_video_path: 输入视频的路径 100 | """ 101 | output_file_path = get_output_file_path(input_video_path, "audio_processed") 102 | self._audio_processor_manager.process(input_video_path) 103 | return output_file_path 104 | 105 | def _get_after_process_width_and_height(self, input_video_path: Path, ) -> tuple[int, int]: 106 | cap = cv2.VideoCapture(str(input_video_path)) 107 | if not cap.isOpened(): 108 | raise ValueError("无法打开输入视频") 109 | 110 | ret, frame = cap.read() 111 | if not ret: 112 | raise ValueError("无法读取视频的第一帧") 113 | processed_frame = self._video_processor_manager.process(frame) 114 | 115 | height, width = processed_frame.shape[:2] 116 | cap.release() 117 | return width, height 118 | 119 | def _set_running(self, is_running: bool): 120 | self.is_running = is_running 121 | 122 | 123 | if __name__ == '__main__': 124 | v = VideoHandler() 125 | global_var = ProcessorGlobalVar() 126 | global_var.update("crop_x", 0) 127 | global_var.update("crop_y", 515) 128 | global_var.update("crop_width", 716) 129 | global_var.update("crop_height", 482) 130 | global_var.update("rotation_angle", 90) 131 | global_var.update("orientation", Orientation.HORIZONTAL) 132 | global_var.update("target_width", 500) 133 | global_var.update("target_height", 300) 134 | 135 | print(v.process_video( 136 | Path(r"E:\load\python\Project\VideoFusion\TempAndTest\dy\b7bb97e21600b07f66c21e7932cb7550.mp4"))) 137 | -------------------------------------------------------------------------------- /src/common/video_info_reader.py: -------------------------------------------------------------------------------- 1 | from pathlib import Path 2 | 3 | import cv2 4 | 5 | from src.common.black_remove_algorithm.black_remove_algorithm import BlackRemoveAlgorithm 6 | from src.common.black_remove_algorithm.img_black_remover import IMGBlackRemover 7 | from src.core.datacls import CropInfo, VideoInfo 8 | 9 | 10 | class VideoInfoReader: 11 | def __init__(self, video_path: str | Path): 12 | self.video_path = Path(video_path) 13 | 14 | def get_video_info(self, 15 | black_remove_algorithm: BlackRemoveAlgorithm | None, 16 | crop_enabled: bool = True) -> VideoInfo: 17 | video = cv2.VideoCapture(str(self.video_path)) 18 | frame_count = int(video.get(cv2.CAP_PROP_FRAME_COUNT)) 19 | fps = int(video.get(cv2.CAP_PROP_FPS)) 20 | width = int(video.get(cv2.CAP_PROP_FRAME_WIDTH)) 21 | height = int(video.get(cv2.CAP_PROP_FRAME_HEIGHT)) 22 | video.release() 23 | 24 | if not crop_enabled: 25 | return VideoInfo(video_path=self.video_path, 26 | fps=fps, 27 | frame_count=frame_count, 28 | width=width, 29 | height=height) 30 | 31 | if black_remove_algorithm is None: 32 | return VideoInfo(video_path=self.video_path, 33 | fps=fps, 34 | frame_count=frame_count, 35 | width=width, 36 | height=height) 37 | 38 | # 获取剪裁信息 39 | x, y, w, h = black_remove_algorithm.remove_black(self.video_path) 40 | if w == width and h == height: 41 | return VideoInfo(video_path=self.video_path, 42 | fps=fps, 43 | frame_count=frame_count, 44 | width=width, 45 | height=height) 46 | elif x == 0 and y == 0 and w == 0 and h == 0: 47 | return VideoInfo(video_path=self.video_path, 48 | fps=fps, 49 | frame_count=frame_count, 50 | width=width, 51 | height=height) 52 | return VideoInfo(video_path=self.video_path, 53 | fps=fps, 54 | frame_count=frame_count, 55 | width=width, 56 | height=height, 57 | crop=CropInfo(x, y, w, h)) 58 | 59 | def get_crop_info(self, black_remove_algorithm: BlackRemoveAlgorithm) -> CropInfo: 60 | x, y, w, h = black_remove_algorithm.remove_black(self.video_path) 61 | return CropInfo(x, y, w, h) 62 | 63 | 64 | if __name__ == '__main__': 65 | v = VideoInfoReader(r"E:\load\python\Project\VideoFusion\tests\test_data\videos\001.mp4") 66 | print(v.get_video_info(IMGBlackRemover())) 67 | -------------------------------------------------------------------------------- /src/components/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/271374667/VideoFusion/9ba7b8b301481ed148bc4d853a5efe8bbb7188b5/src/components/__init__.py -------------------------------------------------------------------------------- /src/components/cmd_text_edit.py: -------------------------------------------------------------------------------- 1 | import json 2 | import subprocess 3 | import sys 4 | import traceback 5 | from typing import Union 6 | 7 | import loguru 8 | from PySide6.QtCore import QThread, Signal 9 | from PySide6.QtGui import QTextCursor 10 | from PySide6.QtWidgets import QApplication, QVBoxLayout, QWidget 11 | from ansi2html import Ansi2HTMLConverter 12 | from qfluentwidgets.components import TextEdit 13 | 14 | from src.utils import singleton 15 | 16 | 17 | class CmdRunnerThread(QThread): 18 | append_signal = Signal(str) 19 | 20 | def __init__(self, command, parent=None): 21 | super().__init__(parent) 22 | self.command = command 23 | 24 | def run(self): 25 | if isinstance(self.command, list): 26 | command_processed = self.command 27 | else: 28 | command_processed = self.command.split() 29 | 30 | proc = subprocess.Popen( 31 | command_processed, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True 32 | ) 33 | while True: 34 | output = proc.stdout.readline() 35 | if proc.poll() is not None and output == '': 36 | break 37 | if output: 38 | self.append_signal.emit(output.strip()) 39 | 40 | 41 | class LoguruStream: 42 | def write(self, message): 43 | if no_empty_message := message.strip(): 44 | loguru.logger.debug(no_empty_message) 45 | 46 | def flush(self): 47 | pass 48 | 49 | 50 | @singleton 51 | class CMDTextEdit(QWidget): 52 | """ 53 | 一个支持ANSI颜色代码的文本框 54 | 55 | Methods: 56 | - append_log: 添加一行消息 57 | - run_cmd: 运行一个cmd命令 58 | 59 | Signals: 60 | - append_signal: 添加消息的信号 61 | 62 | Note: 63 | - 颜色代码的转换使用了`loguru`和`ansi2html`库你需要安装这两个库 64 | - 你可以直接使用`loguru`库的`logger`对象来输出日志, 会自动上色并显示在文本框中 65 | - 你可以使用`run_cmd`方法来运行一个cmd命令 66 | """ 67 | append_signal = Signal(str) 68 | 69 | def __init__(self, parent=None): 70 | super().__init__() 71 | self._redirect_print: bool = False 72 | self.setObjectName("cmd_text_edit") 73 | self._ansi2html_converter = Ansi2HTMLConverter() 74 | 75 | self.text_edit = TextEdit() 76 | self.text_edit.setStyleSheet("background-color: white;") 77 | self.text_edit.setReadOnly(True) 78 | # 设置文本框的颜色css样式 79 | 80 | layout = QVBoxLayout() 81 | layout.addWidget(self.text_edit) 82 | self.setLayout(layout) 83 | 84 | self.append_signal.connect(self._append_log_slot) 85 | 86 | # 为了方便直接绑定loguru 87 | self._hook_loguru() 88 | 89 | @property 90 | def redirect_print(self) -> bool: 91 | return self._redirect_print 92 | 93 | @redirect_print.setter 94 | def redirect_print(self, value: bool) -> None: 95 | """没事不要调用这个方法,会导致其他的sys.stdout和sys.stderr失效 96 | """ 97 | self._redirect_print = value 98 | if value: 99 | sys.stdout = LoguruStream() 100 | sys.stderr = LoguruStream() 101 | else: 102 | sys.stdout = sys.__stdout__ 103 | sys.stderr = sys.__stderr__ 104 | 105 | def append_log(self, context: str) -> None: 106 | """手动添加一行消息""" 107 | self.append_signal.emit(self._ansi2html(context)) 108 | # 将窗口滚动到底部 109 | self.text_edit.moveCursor(QTextCursor.MoveOperation.End) 110 | 111 | def run_cmd(self, command: Union[str, list[str]]) -> None: 112 | """多线程运行一个cmd命令""" 113 | self.cmd_thread = CmdRunnerThread(command) 114 | self.cmd_thread.append_signal.connect(self._append_log_slot) 115 | self.cmd_thread.start() 116 | 117 | def _hook_loguru(self): 118 | """绑定loguru""" 119 | # 为了防止忘记导入loguru库 120 | import loguru # noqa: F811 121 | 122 | def sink(message): 123 | ansi_color_text = json.loads(str(message)) 124 | self.append_log(ansi_color_text['text']) 125 | 126 | loguru.logger.add(sink, colorize=True, serialize=True) 127 | sys.excepthook = self._handle_exception 128 | 129 | def _handle_exception(self, exc_type, exc_value, exc_traceback): 130 | exc_info = "".join(traceback.format_exception(exc_type, exc_value, exc_traceback)) 131 | loguru.logger.critical(f"Uncaught exception: {exc_info}") 132 | 133 | def _append_log_slot(self, context: str) -> None: 134 | self.text_edit.moveCursor(QTextCursor.MoveOperation.End) 135 | self.text_edit.insertHtml(f"{context}<br>") 136 | # 删除多余的空格 137 | self.text_edit.moveCursor(QTextCursor.MoveOperation.End) 138 | 139 | def _ansi2html(self, ansi_content: str) -> str: 140 | convert = self._ansi2html_converter.convert(ansi_content, full=True, ensure_trailing_newline=True) 141 | 142 | return self._remove_html_space(convert) 143 | 144 | def _remove_html_space(self, html: str) -> str: 145 | return (html.replace('white-space: pre-wrap', 146 | 'white-space: nowrap') 147 | .replace('word-wrap: break-word', 148 | 'word-wrap: normal') 149 | .replace( 150 | 'font-size: normal;', 151 | 'font-size: medium; font-family: sans-serif;' 152 | ) 153 | ) 154 | 155 | 156 | if __name__ == "__main__": 157 | app = QApplication(sys.argv) 158 | cte = CMDTextEdit() 159 | cte.show() 160 | # Example of running an external command: 161 | cte.run_cmd('ping 127.0.0.1') 162 | loguru.logger.info("Hello World!") 163 | loguru.logger.error("This is an error message") 164 | loguru.logger.warning("This is a warning message") 165 | loguru.logger.debug("This is a debug message") 166 | loguru.logger.critical("我是一段中文日志信息") 167 | print('PythonImporter') 168 | 169 | # 一个绿色的ANSI颜色代码 170 | # text = "\x1b[94m\x1b[92m我是一个绿色的字\x1b[0m" 171 | # cte.append_log(text) 172 | 173 | sys.exit(app.exec()) 174 | -------------------------------------------------------------------------------- /src/components/file_drag_and_drop_lineedit.py: -------------------------------------------------------------------------------- 1 | from PySide6.QtGui import QDragEnterEvent, QDropEvent 2 | from PySide6.QtWidgets import QLineEdit 3 | 4 | from src.signal_bus import SignalBus 5 | 6 | signal_bus = SignalBus() 7 | 8 | 9 | class FileDragAndDropLineEdit(QLineEdit): 10 | def __init__(self, parent=None): 11 | super().__init__(parent) 12 | self.setAcceptDrops(True) 13 | 14 | def dragEnterEvent(self, event: QDragEnterEvent): 15 | if event.mimeData().hasUrls(): 16 | event.acceptProposedAction() 17 | 18 | def dropEvent(self, event: QDropEvent): 19 | if event.mimeData().hasUrls(): 20 | url = event.mimeData().urls()[0] 21 | # 如果不是文件夹路径,或者不是txt文件,就不接受 22 | if not url.isLocalFile() or not url.fileName().endswith('.txt'): 23 | return 24 | self.setText(url.toLocalFile()) 25 | signal_bus.file_droped.emit(url.toLocalFile()) 26 | event.acceptProposedAction() 27 | 28 | 29 | if __name__ == '__main__': 30 | from PySide6.QtWidgets import QApplication 31 | 32 | app = QApplication([]) 33 | window = FileDragAndDropLineEdit() 34 | window.show() 35 | app.exec() 36 | -------------------------------------------------------------------------------- /src/components/message_dialog.py: -------------------------------------------------------------------------------- 1 | from PySide6.QtCore import Qt, Signal 2 | from PySide6.QtWidgets import QApplication, QHBoxLayout, QSizePolicy, QSpacerItem, QVBoxLayout 3 | from qfluentwidgets.components import PrimaryPushButton, PushButton, StrongBodyLabel, TextEdit, TitleLabel 4 | from qframelesswindow import FramelessDialog 5 | 6 | 7 | class MessageDialog(FramelessDialog): 8 | ok_signal = Signal() 9 | cancel_signal = Signal() 10 | 11 | def __init__(self): 12 | super().__init__() 13 | self.ok_btn = PrimaryPushButton() 14 | self.ok_btn.setText("确认") 15 | self.cancel_btn = PushButton() 16 | self.cancel_btn.setText("取消") 17 | 18 | self.title_label = TitleLabel() 19 | self.title_label.setAlignment(Qt.AlignmentFlag.AlignCenter) 20 | self.title_label.setText("标题") 21 | 22 | self.explain_label = StrongBodyLabel() 23 | self.explain_label.setAlignment(Qt.AlignmentFlag.AlignCenter) 24 | self.explain_label.setText("说明") 25 | 26 | self.body_text_edit = TextEdit() 27 | self.body_text_edit.setReadOnly(True) 28 | 29 | self.main_layout = QVBoxLayout() 30 | self.button_layout = QHBoxLayout() 31 | 32 | self.v_spacer = QSpacerItem(0, 21, QSizePolicy.Policy.Minimum, QSizePolicy.Policy.Minimum) 33 | 34 | self.button_layout.addWidget(self.ok_btn) 35 | self.button_layout.addWidget(self.cancel_btn) 36 | self.main_layout.addItem(self.v_spacer) 37 | self.main_layout.addWidget(self.title_label) 38 | self.main_layout.addWidget(self.explain_label) 39 | self.main_layout.addWidget(self.body_text_edit) 40 | self.main_layout.addLayout(self.button_layout) 41 | 42 | self.setLayout(self.main_layout) 43 | 44 | self.ok_btn.clicked.connect(self.ok_signal.emit) 45 | self.cancel_btn.clicked.connect(self.cancel_signal.emit) 46 | 47 | self.setStyleSheet("background-color: #f9f9f9") 48 | 49 | def set_title(self, title: str): 50 | self.title_label.setText(title) 51 | 52 | def set_explain(self, explain: str): 53 | self.explain_label.setText(explain) 54 | 55 | def set_body(self, body: str): 56 | self.body_text_edit.setMarkdown(body) 57 | 58 | 59 | if __name__ == '__main__': 60 | app = QApplication([]) 61 | dialog = MessageDialog() 62 | dialog.show() 63 | app.exec() 64 | -------------------------------------------------------------------------------- /src/core/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/271374667/VideoFusion/9ba7b8b301481ed148bc4d853a5efe8bbb7188b5/src/core/__init__.py -------------------------------------------------------------------------------- /src/core/about.py: -------------------------------------------------------------------------------- 1 | # from src.core.paths import ABOUT_HTML_FILE 2 | # 3 | # about_txt: str = ABOUT_HTML_FILE.read_text(encoding='utf-8') 4 | 5 | # 直接将about_txt的内容写在这里,这样就不需要读取文件了,打包后也不会出现找不到文件的问题 6 | about_txt: str = """ 7 | <!DOCTYPE html> 8 | <html lang="en"> 9 | <head> 10 | <meta charset="UTF-8"> 11 | <title>视频拼接工具 12 | 89 | 90 | 91 |
92 |
93 |

VideoFusion

94 |

一站式短视频合成工具

95 |
96 |
97 |

功能特色

98 |

支持多种视频格式,支持多个视频拼接,自研去黑边算法,处理复杂黑边。自动横竖屏切换,自动分辨率,大量音视频增强功能。 99 |

100 |

支持格式

101 |

支持视频格式:mp4, avi, flv, mov, wmv, mkv, rmvb, rm, 3gp, mpeg, asf, ts

102 |
103 |
104 |

联系我

105 |

邮箱: 271374667@qq.com

106 |

QQ群: 557434492

107 |

GitHub: https://github.com/271374667

108 |

Gitee: https://gitee.com/sun-programing

109 |
110 |
111 |

学习资源

112 |

观看本人录制的免费教程: https://space.bilibili.com/282527875

114 |

感谢fluent-widget对本项目的大力支持: https://github.com/zhiyiYo/PyQt-Fluent-Widgets 116 |

117 |
118 |
119 |

开源软件

120 |

本软件是开源软件,如果您是通过付费获取到的本产品请立即退款,您可以随时在GitHub上找到免费的最新版本: https://github.com/271374667/VideoMosaic

122 |
123 |
124 | 125 | 126 | """ 127 | -------------------------------------------------------------------------------- /src/core/datacls.py: -------------------------------------------------------------------------------- 1 | from dataclasses import dataclass 2 | from pathlib import Path 3 | from typing import Optional 4 | 5 | 6 | @dataclass(frozen=True, slots=True) 7 | class VideoInfo: 8 | video_path: Path 9 | fps: int 10 | frame_count: int 11 | width: int 12 | height: int 13 | crop: Optional["CropInfo"] = None 14 | 15 | 16 | @dataclass(frozen=True, slots=True) 17 | class CropInfo: 18 | x: int 19 | y: int 20 | w: int 21 | h: int 22 | 23 | 24 | @dataclass(frozen=True, slots=True) 25 | class VideoScaling: 26 | video_path: Path 27 | scale_rate: float # 音频长度缩放比例 28 | 29 | 30 | @dataclass(frozen=True, slots=True) 31 | class FFmpegDTO: 32 | video_info: VideoInfo 33 | ffmpeg_command: str | None = None 34 | -------------------------------------------------------------------------------- /src/core/dicts.py: -------------------------------------------------------------------------------- 1 | from typing_extensions import NotRequired, TypedDict 2 | 3 | from src.core.enums import Orientation 4 | 5 | 6 | class VideoInfoDict(TypedDict): 7 | rotation_angle: NotRequired[int] 8 | orientation: NotRequired[Orientation] 9 | target_width: NotRequired[int] 10 | target_height: NotRequired[int] 11 | crop_x: NotRequired[int] 12 | crop_y: NotRequired[int] 13 | crop_width: NotRequired[int] 14 | crop_height: NotRequired[int] 15 | 16 | # 视频原本的信息 17 | fps: NotRequired[int] 18 | width: NotRequired[int] 19 | height: NotRequired[int] 20 | total_frames: NotRequired[int] 21 | 22 | 23 | class TaskDict(TypedDict): 24 | input_video_path: str 25 | task_status: int 26 | output_video_path: NotRequired[str] 27 | -------------------------------------------------------------------------------- /src/core/enums.py: -------------------------------------------------------------------------------- 1 | from enum import Enum 2 | 3 | 4 | class Orientation(Enum): 5 | HORIZONTAL = 0 6 | VERTICAL = 1 7 | 8 | 9 | class Rotation(Enum): 10 | CLOCKWISE = 90 11 | COUNTERCLOCKWISE = 270 12 | UPSIDE_DOWN = 180 13 | NOTHING = 0 14 | 15 | 16 | class FileProcessType(Enum): 17 | UNCOMPLETED = 0 18 | COMPLETED = 1 19 | -------------------------------------------------------------------------------- /src/core/paths.py: -------------------------------------------------------------------------------- 1 | from pathlib import Path 2 | 3 | # Path to the root of the project 4 | ROOT = Path(__file__).parent.parent.parent 5 | SRC_DIR = ROOT / 'src' 6 | 7 | # Path to the data directory 8 | ASSETS_DIR = ROOT / "assets" 9 | IMAGES_DIR = ASSETS_DIR / "images" 10 | BIN_DIR = ROOT / "bin" 11 | TEMP_DIR = ROOT / "Temp" 12 | OUTPUT_DIR = ROOT / "output" 13 | MODELS_DIR = ROOT / "models" 14 | 15 | # FILE 16 | OUTPUT_FILE = ROOT / "output.mp4" 17 | FFMPEG_FILE = BIN_DIR / "ffmpeg.exe" 18 | FFPROBE_FILE = BIN_DIR / "ffprobe.exe" 19 | NOISE_REDUCE_MODEL_FILE = BIN_DIR / "cb.rnnn" # ffmpeg的降噪模型 20 | CONFIG_FILE = ROOT / "config.json" 21 | LOGO_IMAGE_FILE = IMAGES_DIR / 'logo.ico' 22 | QRC_FILE = ASSETS_DIR / "resource.qrc" 23 | QRC_PY_FILE = ROOT / "resource_rc.py" 24 | LOG_FILE = ROOT / "log.log" 25 | ABOUT_HTML_FILE = ASSETS_DIR / "about.html" 26 | RESUME_FILE = ROOT / "task_resumer.json" 27 | AUDIO_SEPARATOR_EXE_FILE = BIN_DIR / 'audio_sep' / 'audio_sep.exe' 28 | 29 | # MODEL 30 | ESPCN_x2_FILE = BIN_DIR / "ESPCN_x2.pb" 31 | LapSRN_x2_FILE = BIN_DIR / "LapSRN_x2.pb" 32 | -------------------------------------------------------------------------------- /src/core/version.py: -------------------------------------------------------------------------------- 1 | __version__ = "1.12.5" 2 | -------------------------------------------------------------------------------- /src/interface/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/271374667/VideoFusion/9ba7b8b301481ed148bc4d853a5efe8bbb7188b5/src/interface/__init__.py -------------------------------------------------------------------------------- /src/model/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/271374667/VideoFusion/9ba7b8b301481ed148bc4d853a5efe8bbb7188b5/src/model/__init__.py -------------------------------------------------------------------------------- /src/model/concate_model.py: -------------------------------------------------------------------------------- 1 | from pathlib import Path 2 | 3 | import loguru 4 | from PySide6.QtCore import QObject 5 | 6 | from src.common.program_coordinator import ProgramCoordinator 7 | from src.config import cfg 8 | from src.core.enums import Orientation, Rotation 9 | from src.signal_bus import SignalBus 10 | from src.utils import ForceStopThread 11 | 12 | 13 | class Worker(QObject): 14 | def __init__(self): 15 | super().__init__() 16 | self._signal_bus = SignalBus() 17 | self._program_coordinator = ProgramCoordinator() 18 | 19 | def start(self, video_list: list[Path], video_orientation: Orientation, video_rotation: Rotation): 20 | self._program_coordinator.process(video_list, video_orientation, video_rotation) 21 | 22 | 23 | class ConcateModel: 24 | def __init__(self): 25 | self._signal_bus = SignalBus() 26 | self._worker = Worker() 27 | self._force_stop_thread = ForceStopThread() 28 | 29 | @property 30 | def merge_video_enabled(self) -> bool: 31 | return cfg.get(cfg.merge_video) 32 | 33 | def kill_thread(self): 34 | self._signal_bus.set_running.emit(False) 35 | self._force_stop_thread.stop_task() 36 | loguru.logger.info('程序被强制停止') 37 | 38 | def start(self, video_list: list[str | Path], video_orientation: Orientation, video_rotation: Rotation): 39 | video_list: list[Path] = [Path(video) for video in video_list if Path(video).exists()] 40 | self._force_stop_thread.start_task(self._worker.start, video_list, video_orientation, video_rotation) 41 | loguru.logger.debug(f'程序开始执行,参数如下: {video_orientation}, {video_rotation}, 视频列表为:{video_list}') 42 | 43 | 44 | if __name__ == '__main__': 45 | # 绑定信号 46 | signal_bus = SignalBus() 47 | signal_bus.set_detail_progress_current.connect(lambda x: print(f'当前进度为{x}')) 48 | signal_bus.set_detail_progress_max.connect(lambda x: print(f'最大进度为{x}')) 49 | signal_bus.set_detail_progress_description.connect(lambda x: print(f'描述为{x}')) 50 | signal_bus.set_total_progress_current.connect(lambda x: print(f'总进度为{x}')) 51 | signal_bus.set_total_progress_max.connect(lambda x: print(f'总最大进度为{x}')) 52 | signal_bus.set_total_progress_description.connect(lambda x: print(f'总描述为{x}')) 53 | signal_bus.set_total_progress_reset.connect(lambda: print('总进度重置')) 54 | signal_bus.set_detail_progress_reset.connect(lambda: print('详细进度重置')) 55 | signal_bus.set_total_progress_finish.connect(lambda: print('总进度完成')) 56 | signal_bus.set_detail_progress_finish.connect(lambda: print('详细进度完成')) 57 | signal_bus.advance_total_progress.connect(lambda x: print(f'总进度增加{x}')) 58 | signal_bus.advance_detail_progress.connect(lambda x: print(f'详细进度增加{x}')) 59 | signal_bus.finished.connect(lambda: print('完成')) 60 | 61 | video_list = Path(r"E:\load\python\Project\VideoFusion\测试\video\1.txt").read_text( 62 | ).replace('"', '').splitlines() 63 | model = ConcateModel() 64 | # model.start(video_list, Orientation.HORIZONTAL, Rotation.CLOCKWISE) 65 | -------------------------------------------------------------------------------- /src/model/home_model.py: -------------------------------------------------------------------------------- 1 | from concurrent.futures import ThreadPoolExecutor 2 | 3 | import loguru 4 | from PySide6.QtCore import QObject 5 | 6 | from src.core.enums import Orientation, Rotation 7 | from src.main import VideoMosaic 8 | 9 | 10 | class Worker(QObject): 11 | def start_job(self, 12 | is_dir: bool, 13 | dir_or_file: str, 14 | output_file: str, 15 | dir_mode: str, 16 | fps: int, 17 | sample_rate: float, 18 | video_orientation: Orientation, 19 | horizontal_rotation: Rotation, 20 | vertical_rotation: Rotation): 21 | self._vm = VideoMosaic() 22 | if is_dir: 23 | self._vm.add_video_dir(dir_or_file, dir_mode) 24 | else: 25 | self._vm.read_from_txt_file(dir_or_file) 26 | 27 | self._vm.output_dir = output_file 28 | self._vm.fps = fps 29 | self._vm.sample_rate = sample_rate 30 | self._vm.video_orientation = video_orientation 31 | self._vm.horizontal_rotation = horizontal_rotation 32 | self._vm.vertical_rotation = vertical_rotation 33 | self._vm.start() 34 | 35 | 36 | class HomeModel: 37 | def __init__(self): 38 | self._pool = ThreadPoolExecutor(3) 39 | self._worker = Worker() 40 | 41 | def start(self, 42 | is_dir: bool, 43 | dir_or_file: str, 44 | output_file: str, 45 | dir_mode: str, 46 | fps: int, 47 | sample_rate: float, 48 | video_orientation: Orientation, 49 | horizontal_rotation: Rotation, 50 | vertical_rotation: Rotation 51 | ): 52 | loguru.logger.info("程序开始执行……") 53 | self._pool.submit( 54 | lambda: self._worker.start_job(is_dir, dir_or_file, output_file, dir_mode, fps, round(sample_rate, 2), 55 | video_orientation, horizontal_rotation, vertical_rotation)) 56 | loguru.logger.debug(f'参数: {is_dir=}, {dir_or_file=}, {output_file=}, {dir_mode=}, {fps=}, {sample_rate=}, ') 57 | -------------------------------------------------------------------------------- /src/model/settings_model.py: -------------------------------------------------------------------------------- 1 | class SettingsModel: 2 | pass -------------------------------------------------------------------------------- /src/presenter/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/271374667/VideoFusion/9ba7b8b301481ed148bc4d853a5efe8bbb7188b5/src/presenter/__init__.py -------------------------------------------------------------------------------- /src/presenter/main_presenter.py: -------------------------------------------------------------------------------- 1 | from src.presenter.concate_presenter import ConcatePresenter 2 | from src.presenter.settings_presenter import SettingsPresenter 3 | from src.signal_bus import SignalBus 4 | from src.view.main_view import MainView 5 | 6 | 7 | class MainPresenter: 8 | def __init__(self): 9 | self._signal_bus = SignalBus() 10 | self._concate_presenter = ConcatePresenter() 11 | self._settings_presenter = SettingsPresenter() 12 | 13 | self._view = MainView( 14 | self._concate_presenter.get_view(), 15 | self._settings_presenter.get_view() 16 | ) 17 | 18 | # 开始运行之后禁用设置界面 19 | self._signal_bus.started.connect(self.disable_settings_view) 20 | self._signal_bus.finished.connect(self.enable_settings_view) 21 | 22 | def get_view(self) -> MainView: 23 | return self._view 24 | 25 | def enable_settings_view(self): 26 | self._settings_presenter.get_view().setEnabled(True) 27 | 28 | def disable_settings_view(self): 29 | self._settings_presenter.get_view().setEnabled(False) 30 | 31 | 32 | if __name__ == '__main__': 33 | from PySide6.QtWidgets import QApplication 34 | 35 | app = QApplication([]) 36 | m = MainPresenter() 37 | m.get_view().show() 38 | app.exec() 39 | -------------------------------------------------------------------------------- /src/settings.py: -------------------------------------------------------------------------------- 1 | from typing_extensions import Final 2 | 3 | # 所有支持的视频文件后缀名 4 | AVAILABLE_VIDEO_SUFFIX: Final[list[str]] = [ 5 | '.avi', # Audio Video Interleave 6 | '.mp4', # MPEG-4 Part 14 7 | '.mov', # QuickTime File Format 8 | '.mkv', # Matroska 9 | '.flv', # Flash Video 10 | '.wmv', # Windows Media Video 11 | '.mpeg', # MPEG Video 12 | '.mpg', # MPEG Video 13 | '.m4v', # MPEG-4 Video 14 | '.3gp', # 3GPP Multimedia File 15 | '.webm' # WebM Video 16 | ] 17 | 18 | # 包含这些关键词的FFmpeg输出信息会被认为是错误信息 19 | FFMPEG_ERROR_WORDS: Final[list[str]] = [ 20 | 'error', 21 | 'fail', 22 | 'not', 23 | 'invalid', 24 | 'unknown' 25 | ] 26 | 27 | # 最新的项目下载地址 28 | LATEST_RELEASE_URL: Final[str] = "https://github.com/271374667/VideoFusion/releases/latest" 29 | 30 | # 小于这个值的视频不能被识别 31 | READABLEVIDEOSIZE: float = 100.0 32 | -------------------------------------------------------------------------------- /src/signal_bus.py: -------------------------------------------------------------------------------- 1 | import sys 2 | 3 | import loguru 4 | from PySide6.QtCore import QObject, Signal 5 | 6 | from src.utils import singleton 7 | 8 | 9 | @singleton 10 | class SignalBus(QObject): 11 | started = Signal() 12 | finished = Signal() 13 | failed = Signal() 14 | set_running = Signal(bool) 15 | 16 | file_droped = Signal(str) 17 | system_message = Signal(str) 18 | 19 | set_total_progress_current = Signal(int) 20 | set_total_progress_max = Signal(int) 21 | advance_total_progress = Signal(int) 22 | set_total_progress_description = Signal(str) 23 | set_total_progress_finish = Signal() 24 | set_total_progress_reset = Signal() 25 | 26 | set_detail_progress_current = Signal(int) 27 | set_detail_progress_max = Signal(int) 28 | advance_detail_progress = Signal(int) 29 | set_detail_progress_description = Signal(str) 30 | set_detail_progress_finish = Signal() 31 | set_detail_progress_reset = Signal() 32 | 33 | 34 | class SystemMessageRedirect: 35 | def __init__(self): 36 | self._signal_bus = SignalBus() 37 | 38 | def write(self, message: str): 39 | if message.strip(): 40 | self._signal_bus.system_message.emit(message) 41 | loguru.logger.debug(message) 42 | 43 | def flush(self): 44 | pass 45 | 46 | 47 | sys.stdout = SystemMessageRedirect() 48 | sys.stderr = SystemMessageRedirect() 49 | -------------------------------------------------------------------------------- /src/view/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/271374667/VideoFusion/9ba7b8b301481ed148bc4d853a5efe8bbb7188b5/src/view/__init__.py -------------------------------------------------------------------------------- /src/view/main_view.py: -------------------------------------------------------------------------------- 1 | from PySide6.QtGui import QIcon, QKeyEvent 2 | from PySide6.QtWidgets import QApplication 3 | from qfluentwidgets import FluentIcon as FIF 4 | from qfluentwidgets import FluentWindow, NavigationItemPosition, TextEdit 5 | 6 | from src.components.cmd_text_edit import CMDTextEdit 7 | from src.core.about import about_txt 8 | from src.view.concate_view import ConcateView 9 | from src.view.settings_view import SettingView 10 | 11 | 12 | class MainView(FluentWindow): 13 | def __init__(self, concate_view: ConcateView, setting_view: SettingView): 14 | super().__init__() 15 | 16 | # create sub interface 17 | self.concate_interface = concate_view 18 | self.cmd_interface = CMDTextEdit() 19 | self.about_interface = TextEdit() 20 | self.about_interface.setHtml(about_txt) 21 | self.about_interface.setObjectName("about") 22 | self.setting_interface = setting_view 23 | 24 | self.initNavigation() 25 | self.initWindow() 26 | 27 | def initNavigation(self): 28 | self.addSubInterface(self.concate_interface, FIF.HOME, '主页') 29 | self.addSubInterface(self.cmd_interface, FIF.COMMAND_PROMPT, '日志') 30 | 31 | self.addSubInterface(self.about_interface, FIF.CHAT, '关于', NavigationItemPosition.BOTTOM) 32 | self.addSubInterface(self.setting_interface, FIF.SETTING, '设置', NavigationItemPosition.BOTTOM) 33 | 34 | def initWindow(self): 35 | self.resize(1100, 750) 36 | # 设置窗口的最大尺寸 37 | self.setMaximumSize(1100, 750) 38 | # 设置窗口的最小尺寸 39 | self.setMinimumSize(1100, 750) 40 | self.setWindowIcon(QIcon(':/images/images/logo.ico')) 41 | self.setWindowTitle('VideoFusion') 42 | 43 | desktop = QApplication.screens()[0].availableGeometry() 44 | w, h = desktop.width(), desktop.height() 45 | self.move(w // 2 - self.width() // 2, h // 2 - self.height() // 2) 46 | 47 | def keyPressEvent(self, event: QKeyEvent) -> None: 48 | self.concate_interface.keyPressEvent(event) 49 | 50 | 51 | if __name__ == '__main__': 52 | app = QApplication([]) 53 | w = MainView(ConcateView(), SettingView()) 54 | w.show() 55 | app.exec() 56 | -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/271374667/VideoFusion/9ba7b8b301481ed148bc4d853a5efe8bbb7188b5/tests/__init__.py -------------------------------------------------------------------------------- /tests/ffmpeg_handler_test.py: -------------------------------------------------------------------------------- 1 | import subprocess 2 | import unittest 3 | from unittest.mock import MagicMock, patch 4 | 5 | from src.common.ffmpeg_handler import FFmpegHandler 6 | 7 | 8 | class TestFFmpegHandler(unittest.TestCase): 9 | 10 | @patch('subprocess.Popen') 11 | def test_successful_command_execution_emits_finish_signal(self, mock_popen): 12 | mock_process = MagicMock() 13 | mock_process.communicate.return_value = ('output', '') 14 | mock_process.returncode = 0 15 | mock_popen.return_value = mock_process 16 | handler = FFmpegHandler() 17 | handler._signal_bus.set_detail_progress_finish.emit = MagicMock() 18 | 19 | handler.run_command('ffmpeg -i input.mp4 output.mp3') 20 | 21 | handler._signal_bus.set_detail_progress_finish.emit.assert_called_once() 22 | 23 | @patch('subprocess.Popen') 24 | def test_command_execution_with_non_zero_exit_code_raises_exception(self, mock_popen): 25 | mock_process = MagicMock() 26 | mock_process.communicate.return_value = ('', 'error') 27 | mock_process.returncode = 1 28 | mock_popen.return_value = mock_process 29 | handler = FFmpegHandler() 30 | 31 | with self.assertRaises(subprocess.CalledProcessError): 32 | handler.run_command('ffmpeg -i input.mp4 output.mp3') 33 | 34 | @patch('subprocess.Popen') 35 | def test_empty_command_raises_value_error(self, mock_popen): 36 | handler = FFmpegHandler() 37 | 38 | with self.assertRaises(ValueError): 39 | handler.run_command('') 40 | 41 | @patch('subprocess.Popen') 42 | def test_progress_tracking_updates_progress_correctly(self, mock_popen): 43 | mock_process = MagicMock() 44 | mock_process.stdout.readline.side_effect = ['frame= 10\n', 'frame= 20\n', ''] 45 | mock_process.communicate.return_value = ('', '') 46 | mock_process.returncode = 0 47 | mock_popen.return_value = mock_process 48 | handler = FFmpegHandler() 49 | handler._signal_bus.set_detail_progress_current.emit = MagicMock() 50 | 51 | handler.run_command('ffmpeg -i input.mp4 output.mp3', progress_total=20) 52 | 53 | handler._signal_bus.set_detail_progress_current.emit.assert_any_call(10) 54 | handler._signal_bus.set_detail_progress_current.emit.assert_any_call(20) 55 | 56 | @patch('subprocess.Popen') 57 | def test_command_execution_with_stderr_logs_critical_and_emits_failed_signal(self, mock_popen): 58 | mock_process = MagicMock() 59 | mock_process.communicate.return_value = ('', 'error') 60 | mock_process.returncode = 1 61 | mock_popen.return_value = mock_process 62 | handler = FFmpegHandler() 63 | handler._signal_bus.failed.emit = MagicMock() 64 | 65 | try: 66 | handler.run_command('ffmpeg -i input.mp4 output.mp3') 67 | except subprocess.CalledProcessError: 68 | pass 69 | 70 | handler._signal_bus.failed.emit.assert_called_once() 71 | -------------------------------------------------------------------------------- /tests/test_balck_remove_algorithm/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/271374667/VideoFusion/9ba7b8b301481ed148bc4d853a5efe8bbb7188b5/tests/test_balck_remove_algorithm/__init__.py -------------------------------------------------------------------------------- /tests/test_balck_remove_algorithm/img_black_remove_algorithm_test.py: -------------------------------------------------------------------------------- 1 | import unittest 2 | from pathlib import Path 3 | from unittest.mock import MagicMock, patch 4 | 5 | from src.common.black_remove_algorithm.img_black_remover import IMGBlackRemover 6 | 7 | VIDEO_PATH: Path = Path(r"E:\load\python\Project\VideoFusion\tests\test_data\videos\001.mp4") 8 | 9 | 10 | class IMGBlackRemoverTests(unittest.TestCase): 11 | 12 | def setUp(self): 13 | self.img_black_remover = IMGBlackRemover() 14 | 15 | @patch('cv2.VideoCapture') 16 | @patch('pathlib.Path.exists', return_value=True) 17 | def video_file_is_supported_and_exists_returns_coordinates(self, mock_exists, mock_VideoCapture): 18 | mock_video = MagicMock() 19 | mock_video.get.side_effect = [100, 1920, 1080] # Mock total_frames, width, height 20 | mock_VideoCapture.return_value = mock_video 21 | expected = (0, 0, 1920, 1080) # Assuming the video has no black borders 22 | 23 | result = self.img_black_remover.remove_black(VIDEO_PATH) 24 | 25 | self.assertEqual(result, expected) 26 | 27 | @patch('pathlib.Path.exists', return_value=False) 28 | def video_file_does_not_exist_raises_FileNotFoundError(self, mock_exists): 29 | with self.assertRaises(FileNotFoundError): 30 | self.img_black_remover.remove_black(VIDEO_PATH) 31 | 32 | def video_file_has_unsupported_extension_raises_ValueError(self): 33 | with self.assertRaises(ValueError): 34 | self.img_black_remover.remove_black("unsupported_file.txt") 35 | 36 | @patch('cv2.VideoCapture') 37 | @patch('pathlib.Path.exists', return_value=True) 38 | def video_file_with_black_borders_returns_correct_coordinates(self, mock_exists, mock_VideoCapture): 39 | mock_video = MagicMock() 40 | mock_video.get.side_effect = [100, 1920, 1080] # Mock total_frames, width, height 41 | mock_VideoCapture.return_value = mock_video 42 | # Assuming the video has black borders and the main content is centered with 100px padding 43 | expected = (100, 100, 1720, 880) 44 | 45 | result = self.img_black_remover.remove_black(VIDEO_PATH) 46 | 47 | self.assertEqual(result, expected) 48 | 49 | @patch('cv2.VideoCapture') 50 | @patch('pathlib.Path.exists', return_value=True) 51 | def video_file_with_no_frames_raises_RuntimeError(self, mock_exists, mock_VideoCapture): 52 | mock_video = MagicMock() 53 | mock_video.get.side_effect = [0, 1920, 1080] # Mock total_frames as 0 54 | mock_VideoCapture.return_value = mock_video 55 | 56 | with self.assertRaises(RuntimeError): 57 | self.img_black_remover.remove_black(VIDEO_PATH) 58 | 59 | 60 | if __name__ == '__main__': 61 | unittest.main() 62 | -------------------------------------------------------------------------------- /tests/test_balck_remove_algorithm/video_black_remove_algorithm_test.py: -------------------------------------------------------------------------------- 1 | import unittest 2 | from pathlib import Path 3 | from unittest.mock import patch 4 | 5 | from src.common.black_remove_algorithm.img_black_remover import IMGBlackRemover 6 | 7 | 8 | class TestIMGBlackRemover(unittest.TestCase): 9 | 10 | def setUp(self): 11 | self.img_black_remover = IMGBlackRemover() 12 | 13 | @patch('src.common.utils.image_utils.ImageUtils.has_black_border', return_value=True) 14 | def test_video_with_black_borders_returns_correct_coordinates(self, mock_has_black_border): 15 | video_path = Path("E:\\load\\python\\Project\\VideoFusion\\tests\\test_data\\videos\\001.mp4") 16 | x, y, w, h = self.img_black_remover.remove_black(video_path) 17 | self.assertNotEqual((x, y, w, h), (0, 0, 0, 0), 18 | "Should not return zero coordinates for a video with black borders") 19 | 20 | self.assertEqual((x, y, w, h), (0, 0, 720, 1610)) 21 | 22 | @patch('src.common.utils.image_utils.ImageUtils.has_black_border', return_value=False) 23 | def test_video_without_black_borders_returns_full_frame(self, mock_has_black_border): 24 | video_path = Path("E:\\load\\python\\Project\\VideoFusion\\tests\\test_data\\videos\\002.mp4") 25 | x, y, w, h = self.img_black_remover.remove_black(video_path) 26 | self.assertEqual((x, y, w, h), (0, 0, 640, 480), "Should return full frame for a video without black borders") 27 | 28 | def test_invalid_video_format_raises_value_error(self): 29 | with self.assertRaises(ValueError): 30 | self.img_black_remover.remove_black( 31 | "E:\\load\\python\\Project\\VideoFusion\\tests\\test_data\\videos\\invalid_format.txt") 32 | 33 | def test_non_existent_video_raises_file_not_found_error(self): 34 | with self.assertRaises(FileNotFoundError): 35 | self.img_black_remover.remove_black( 36 | "E:\\load\\python\\Project\\VideoFusion\\tests\\test_data\\videos\\non_existent.mp4") 37 | 38 | 39 | if __name__ == '__main__': 40 | unittest.main() 41 | -------------------------------------------------------------------------------- /tests/test_data/images/has_black_1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/271374667/VideoFusion/9ba7b8b301481ed148bc4d853a5efe8bbb7188b5/tests/test_data/images/has_black_1.png -------------------------------------------------------------------------------- /tests/test_data/images/has_black_2.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/271374667/VideoFusion/9ba7b8b301481ed148bc4d853a5efe8bbb7188b5/tests/test_data/images/has_black_2.jpg -------------------------------------------------------------------------------- /tests/test_data/images/no_black_1.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/271374667/VideoFusion/9ba7b8b301481ed148bc4d853a5efe8bbb7188b5/tests/test_data/images/no_black_1.jpg -------------------------------------------------------------------------------- /tests/test_data/images/no_black_2.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/271374667/VideoFusion/9ba7b8b301481ed148bc4d853a5efe8bbb7188b5/tests/test_data/images/no_black_2.jpg -------------------------------------------------------------------------------- /tests/test_processors/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/271374667/VideoFusion/9ba7b8b301481ed148bc4d853a5efe8bbb7188b5/tests/test_processors/__init__.py -------------------------------------------------------------------------------- /tests/test_processors/test_opencv_processors/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/271374667/VideoFusion/9ba7b8b301481ed148bc4d853a5efe8bbb7188b5/tests/test_processors/test_opencv_processors/__init__.py -------------------------------------------------------------------------------- /tests/test_processors/test_opencv_processors/bilateral_denoise_test.py: -------------------------------------------------------------------------------- 1 | import unittest 2 | 3 | import numpy as np 4 | 5 | from src.common.processors.opencv_processors.means_doising_processor import BilateralDenoiseProcessor 6 | 7 | 8 | class TestBilateralDenoiseProcessor(unittest.TestCase): 9 | def setUp(self): 10 | self.processor = BilateralDenoiseProcessor() 11 | 12 | def test_frame_with_uniform_noise_is_denoised(self): 13 | # Create a frame with uniform noise 14 | noisy_frame = np.random.uniform(0, 255, (100, 100, 3)).astype(np.uint8) 15 | # Process the frame 16 | denoised_frame = self.processor._video_process(noisy_frame) 17 | # Check if the denoised frame has less noise than the original 18 | self.assertTrue(np.var(denoised_frame) < np.var(noisy_frame)) 19 | 20 | def test_frame_with_salt_and_pepper_noise_is_denoised(self): 21 | # Create a frame with salt and pepper noise 22 | noisy_frame = np.zeros((100, 100, 3), dtype=np.uint8) 23 | noisy_frame[np.random.choice([True, False], size=noisy_frame.shape)] = 255 24 | # Process the frame 25 | denoised_frame = self.processor._video_process(noisy_frame) 26 | # Check if the denoised frame has less noise than the original 27 | self.assertTrue(np.mean(denoised_frame) > np.mean(noisy_frame[np.where(noisy_frame == 0)])) 28 | self.assertTrue(np.mean(denoised_frame) < np.mean(noisy_frame[np.where(noisy_frame == 255)])) 29 | 30 | def test_empty_frame_returns_same_frame(self): 31 | # Create an empty frame 32 | empty_frame = np.zeros((100, 100, 3), dtype=np.uint8) 33 | # Process the frame 34 | processed_frame = self.processor._video_process(empty_frame) 35 | # Check if the processed frame is the same as the input 36 | self.assertTrue(np.array_equal(processed_frame, empty_frame)) 37 | 38 | 39 | if __name__ == '__main__': 40 | unittest.main() 41 | -------------------------------------------------------------------------------- /tests/test_processors/test_opencv_processors/crop_processor_test.py: -------------------------------------------------------------------------------- 1 | import unittest 2 | from unittest.mock import MagicMock 3 | 4 | import numpy as np 5 | 6 | from src.common.processors.opencv_processors.crop_processor import CropProcessor 7 | 8 | 9 | class CropProcessorTest(unittest.TestCase): 10 | def setUp(self): 11 | self.processor = CropProcessor() 12 | self.processor._processor_global_var.get = MagicMock(side_effect=lambda key: { 13 | "crop_x": 10, 14 | "crop_y": 20, 15 | "crop_width": 100, 16 | "crop_height": 200 17 | }.get(key, None)) 18 | self.frame = np.zeros((300, 300), dtype=np.uint8) 19 | 20 | def test_cropping_with_valid_parameters_returns_correctly_cropped_frame(self): 21 | cropped_frame = self.processor.process(self.frame) 22 | self.assertEqual(cropped_frame.shape, (200, 100)) 23 | 24 | def test_cropping_with_one_parameter_missing_returns_original_frame(self): 25 | self.processor.crop_x = None # Simulate missing parameter 26 | original_frame = self.frame.copy() 27 | cropped_frame = self.processor.process(self.frame) 28 | np.testing.assert_array_equal(cropped_frame, original_frame) 29 | 30 | def test_cropping_with_all_parameters_missing_returns_original_frame(self): 31 | self.processor.crop_x = None 32 | self.processor.crop_y = None 33 | self.processor.crop_width = None 34 | self.processor.crop_height = None 35 | original_frame = self.frame.copy() 36 | cropped_frame = self.processor.process(self.frame) 37 | np.testing.assert_array_equal(cropped_frame, original_frame) 38 | 39 | def test_cropping_outside_of_frame_bounds_returns_correctly_cropped_frame(self): 40 | # Adjust parameters to exceed frame bounds 41 | self.processor.crop_x = 250 42 | self.processor.crop_y = 250 43 | self.processor.crop_width = 100 44 | self.processor.crop_height = 100 45 | cropped_frame = self.processor.process(self.frame) 46 | self.assertTrue(cropped_frame.size > 0) # Check if any cropping happened 47 | 48 | 49 | if __name__ == '__main__': 50 | unittest.main() 51 | -------------------------------------------------------------------------------- /tests/test_processors/test_opencv_processors/resize_processor_test.py: -------------------------------------------------------------------------------- 1 | import unittest 2 | 3 | import numpy as np 4 | 5 | from src.common.processors.opencv_processors.resize_processor import ResizeProcessor 6 | from src.common.processors.processor_global_var import ProcessorGlobalVar 7 | 8 | 9 | class ResizeProcessorTest(unittest.TestCase): 10 | 11 | def setUp(self): 12 | # Set up ProcessorGlobalVar with default values 13 | ProcessorGlobalVar().update("target_width", 800) 14 | ProcessorGlobalVar().update("target_height", 600) 15 | self.processor = ResizeProcessor() 16 | 17 | def create_frame(self, width, height): 18 | # Create a dummy frame for testing 19 | return np.zeros((height, width, 3), dtype=np.uint8) 20 | 21 | def test_resize_to_target_dimensions(self): 22 | frame = self.create_frame(400, 300) 23 | processed_frame = self.processor.process(frame) 24 | self.assertEqual(processed_frame.shape[1], 800) # width 25 | self.assertEqual(processed_frame.shape[0], 600) # height 26 | 27 | def test_resize_with_aspect_ratio_preserved(self): 28 | frame = self.create_frame(1024, 768) 29 | processed_frame = self.processor.process(frame) 30 | self.assertTrue(processed_frame.shape[1] <= 800) 31 | self.assertTrue(processed_frame.shape[0] <= 600) 32 | self.assertEqual(processed_frame.shape[1], processed_frame.shape[0] * (4 / 3)) 33 | 34 | def test_resize_with_padding(self): 35 | frame = self.create_frame(1600, 900) 36 | processed_frame = self.processor.process(frame) 37 | # Check if padding was added correctly 38 | expected_width = 800 39 | expected_height = 600 40 | self.assertEqual(processed_frame.shape[1], expected_width) 41 | self.assertEqual(processed_frame.shape[0], expected_height) 42 | # Check for black padding 43 | top_row = processed_frame[0, :] 44 | bottom_row = processed_frame[-1, :] 45 | left_column = processed_frame[:, 0] 46 | right_column = processed_frame[:, -1] 47 | self.assertTrue(np.all(top_row == [0, 0, 0])) 48 | self.assertTrue(np.all(bottom_row == [0, 0, 0])) 49 | self.assertTrue(np.all(left_column == [0, 0, 0])) 50 | self.assertTrue(np.all(right_column == [0, 0, 0])) 51 | 52 | def test_resize_with_zero_dimensions_raises_value_error(self): 53 | frame = self.create_frame(0, 0) 54 | with self.assertRaises(ValueError): 55 | self.processor.process(frame) 56 | 57 | def test_cache_usage_reduces_computation(self): 58 | frame = self.create_frame(400, 300) 59 | # Process the frame twice and check if cache is used 60 | processed_frame_first = self.processor.process(frame) 61 | self.processor._cache.set_values(None, None, None, None, None, None) # Invalidate cache 62 | processed_frame_second = self.processor.process(frame) 63 | self.assertNotEqual(id(processed_frame_first), id(processed_frame_second)) 64 | 65 | def test_reset_cache(self): 66 | self.processor._cache.set_values(800, 600, 0, 0, 0, 0) 67 | self.processor._cache.reset() 68 | self.assertFalse(self.processor._cache.is_set()) 69 | 70 | 71 | if __name__ == '__main__': 72 | unittest.main() 73 | -------------------------------------------------------------------------------- /tests/test_processors/test_opencv_processors/rotate_processor_test.py: -------------------------------------------------------------------------------- 1 | import unittest 2 | 3 | import numpy as np 4 | 5 | from src.common.processors.opencv_processors.rotate_processor import RotateProcessor 6 | from src.common.processors.processor_global_var import ProcessorGlobalVar 7 | from src.core.enums import Orientation 8 | 9 | 10 | class RotateProcessorTest(unittest.TestCase): 11 | def setUp(self): 12 | self.processor = RotateProcessor() 13 | self.processor_global_var = ProcessorGlobalVar() 14 | self.processor_global_var.update("rotation_angle", 90) # Assuming there's a setter method 15 | self.processor_global_var.update("orientation", 16 | Orientation.HORIZONTAL) # Assuming there's a setter method 17 | 18 | def test_rotate_horizontal_video_no_needs_rotation(self): 19 | self.processor_global_var.update("orientation", Orientation.HORIZONTAL) # Change orientation to horizontal 20 | frame = np.zeros((100, 300, 3), np.uint8) # HxW, indicating a vertical frame needing rotation 21 | rotated_frame = self.processor.process(frame) 22 | self.assertEqual(rotated_frame.shape, (100, 300, 3)) 23 | 24 | def test_rotate_horizontal_video_needs_rotation(self): 25 | self.processor_global_var.update("orientation", Orientation.HORIZONTAL) # Change orientation to horizontal 26 | self.processor_global_var.update("rotation_angle", 90) 27 | frame = np.zeros((300, 100, 3), np.uint8) # HxW, indicating a vertical frame needing rotation 28 | rotated_frame = self.processor.process(frame) 29 | self.assertEqual(rotated_frame.shape, (100, 300, 3)) 30 | 31 | def test_rotate_vertical_video_needs_no_rotation(self): 32 | self.processor_global_var.update("orientation", Orientation.VERTICAL) 33 | frame = np.zeros((300, 100, 3), np.uint8) # HxW, indicating a horizontal frame with no need for rotation 34 | rotated_frame = self.processor.process(frame) 35 | self.assertEqual(rotated_frame.shape, (300, 100, 3)) # Shape remains the same 36 | 37 | def test_rotate_vertical_video_needs_rotation(self): 38 | self.processor_global_var.update("orientation", 39 | Orientation.VERTICAL) # Change orientation to vertical 40 | frame = np.zeros((100, 300, 3), np.uint8) # HxW, indicating a horizontal frame needing rotation 41 | rotated_frame = self.processor.process(frame) 42 | self.assertEqual(rotated_frame.shape, (300, 100, 3)) # WxH after rotation 43 | 44 | def test_rotate_horizontal_video_needs_no_rotation(self): 45 | self.processor._processor_global_var.update("orientation", 46 | Orientation.HORIZONTAL) # Ensure orientation is horizontal 47 | frame = np.zeros((100, 300, 3), np.uint8) # HxW, indicating a vertical frame with no need for rotation 48 | rotated_frame = self.processor.process(frame) 49 | self.assertEqual(rotated_frame.shape, (100, 300, 3)) # Shape remains the same 50 | 51 | def test_raise_error_on_missing_orientation(self): 52 | self.processor._processor_global_var.update("orientation", None) # Remove orientation 53 | frame = np.zeros((100, 300, 3), np.uint8) 54 | with self.assertRaises(ValueError): 55 | self.processor.process(frame) 56 | 57 | def test_raise_error_on_missing_angle(self): 58 | self.processor._processor_global_var.update("rotation_angle", None) # Remove angle 59 | frame = np.zeros((100, 300, 3), np.uint8) 60 | with self.assertRaises(ValueError): 61 | self.processor.process(frame) 62 | -------------------------------------------------------------------------------- /tests/utils_test/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/271374667/VideoFusion/9ba7b8b301481ed148bc4d853a5efe8bbb7188b5/tests/utils_test/__init__.py -------------------------------------------------------------------------------- /tests/utils_test/image_utils_test.py: -------------------------------------------------------------------------------- 1 | import unittest 2 | from pathlib import Path 3 | 4 | import numpy as np 5 | 6 | from src.common.utils.image_utils import ImageUtils 7 | 8 | has_black_img1: Path = Path(r"E:\load\python\Project\VideoFusion\tests\test_data\images\has_black_1.png") 9 | has_black_img2: Path = Path(r"E:\load\python\Project\VideoFusion\tests\test_data\images\has_black_2.jpg") 10 | no_black_img1: Path = Path(r"E:\load\python\Project\VideoFusion\tests\test_data\images\no_black_1.jpg") 11 | no_black_img2: Path = Path(r"E:\load\python\Project\VideoFusion\tests\test_data\images\no_black_2.jpg") 12 | 13 | 14 | class TestImageUtils(unittest.TestCase): 15 | def setUp(self): 16 | self.image_utils = ImageUtils() 17 | self.black_image = np.zeros((100, 100, 3), dtype=np.uint8) 18 | self.white_image = np.ones((100, 100, 3), dtype=np.uint8) * 255 19 | self.image_with_black_border = np.ones((100, 100, 3), dtype=np.uint8) * 255 20 | self.image_with_black_border[:5, :, :] = 0 21 | self.image_with_black_border[-5:, :, :] = 0 22 | self.image_with_black_border[:, :5, :] = 0 23 | self.image_with_black_border[:, -5:, :] = 0 24 | 25 | def test_is_black_true(self): 26 | self.assertTrue(self.image_utils.is_black(self.black_image)) 27 | 28 | img1 = self.image_utils.read_image(has_black_img1) 29 | self.assertTrue(bool(self.image_utils.has_black_border(img1))) 30 | 31 | def test_has_black_border(self): 32 | self.assertTrue(self.image_utils.has_black_border(self.image_with_black_border)) 33 | 34 | img1 = self.image_utils.read_image(has_black_img1) 35 | self.assertTrue(self.image_utils.has_black_border(img1)) 36 | 37 | img2 = self.image_utils.read_image(has_black_img2) 38 | self.assertTrue(self.image_utils.has_black_border(img2)) 39 | 40 | img3 = self.image_utils.read_image(no_black_img1) 41 | self.assertFalse(self.image_utils.has_black_border(img3)) 42 | 43 | img4 = self.image_utils.read_image(no_black_img2) 44 | self.assertFalse(self.image_utils.has_black_border(img4)) 45 | 46 | 47 | if __name__ == '__main__': 48 | unittest.main() 49 | -------------------------------------------------------------------------------- /tests/video_info_reader_test.py: -------------------------------------------------------------------------------- 1 | import unittest 2 | from pathlib import Path 3 | 4 | from src.common.black_remove_algorithm.video_remover import VideoRemover 5 | from src.common.video_info_reader import VideoInfoReader 6 | 7 | TEST_VIDEO_PATH: Path = Path(r"E:\load\python\Project\VideoFusion\tests\test_data\videos\001.mp4") 8 | 9 | 10 | class TestVideoInfoReader(unittest.TestCase): 11 | def test_get_video_info(self): 12 | # Assuming there's a test video at the specified path with known properties 13 | test_video_path = TEST_VIDEO_PATH 14 | expected_fps = 59 15 | expected_frame_count = 1057 16 | expected_width = 720 17 | expected_height = 1610 18 | expected_audio_sample_rate = 44100 19 | crop = (0, 513, 720, 490) 20 | 21 | reader = VideoInfoReader(str(test_video_path)) 22 | video_info = reader.get_video_info(VideoRemover()) 23 | 24 | self.assertEqual(video_info.fps, expected_fps) 25 | self.assertEqual(video_info.frame_count, expected_frame_count) 26 | self.assertEqual(video_info.width, expected_width) 27 | self.assertEqual(video_info.height, expected_height) 28 | self.assertEqual(video_info.audio_sample_rate, expected_audio_sample_rate) 29 | self.assertEqual((video_info.crop.x, video_info.crop.y, video_info.crop.w, video_info.crop.h), crop) 30 | 31 | 32 | if __name__ == '__main__': 33 | unittest.main() 34 | --------------------------------------------------------------------------------