├── .gitignore ├── .travis.yml ├── LICENSE ├── README.md ├── build_mac.sh ├── build_win.bat ├── dmg_settings.py ├── doc ├── images │ ├── auto_detection │ │ ├── preview.png │ │ └── subtitle_auto_detection.png │ ├── snapshot_ui.png │ ├── snapshot_ui_stitching.png │ ├── snapshot_ui_stitching_preview.png │ └── usage │ │ ├── screenshot_app.png │ │ ├── screenshot_exe.png │ │ ├── screenshot_jump.png │ │ ├── screenshot_open copy.png │ │ ├── screenshot_open.png │ │ ├── screenshot_output copy.png │ │ ├── screenshot_output.png │ │ ├── screenshot_play.png │ │ ├── screenshot_preview.png │ │ ├── screenshot_preview_selected.png │ │ ├── screenshot_save.png │ │ ├── screenshot_single.png │ │ ├── screenshot_srt.png │ │ ├── screenshot_task.png │ │ └── screenshot_updown.png ├── package_doc │ ├── README.md │ └── 说明.txt ├── user_manual.md └── user_manual_cn.md ├── file_version_info.txt ├── mac_build.spec ├── requirements.txt ├── res ├── README.md ├── pluto.icns └── pluto.ico ├── src ├── app.py ├── pluto │ ├── __init__.py │ ├── common │ │ ├── __init__.py │ │ ├── media │ │ │ ├── TextDetector.py │ │ │ ├── __init__.py │ │ │ └── snapshot.py │ │ └── utils.py │ └── ui │ │ ├── __init__.py │ │ ├── player │ │ ├── __init__.py │ │ ├── controllers.py │ │ ├── models.py │ │ └── views.py │ │ ├── qt │ │ ├── ListWidgetItem.py │ │ ├── __init__.py │ │ ├── mvc │ │ │ ├── __init__.py │ │ │ ├── controllers.py │ │ │ ├── routers.py │ │ │ └── views.py │ │ └── qtutils.py │ │ └── stitcher │ │ ├── __init__.py │ │ ├── controllers.py │ │ ├── models.py │ │ └── views.py └── windows │ ├── image_stitching_window.ui │ ├── player │ ├── auto.svg │ ├── end.svg │ ├── open.svg │ ├── pause.svg │ ├── play.svg │ ├── snapshot.svg │ ├── snapshot.wav │ ├── start.svg │ └── stitching.svg │ ├── pluto.png │ ├── resources.readme.txt │ ├── stitching │ ├── add.svg │ ├── magic.svg │ ├── magic.wav │ ├── player.svg │ ├── remove.svg │ └── save.svg │ └── video_player_window.ui └── test ├── __init__.py ├── files ├── empty │ ├── nosrt.avi │ ├── video.avi │ └── video.srt ├── images │ ├── 1.jpg │ ├── 2.jpg │ └── expected.jpg ├── test.srt └── video │ ├── A Message from President-Elect Donald J- Trump.mp4 │ └── A Message from President-Elect Donald J- Trump.mp4.srt └── plutotest ├── TempFileTestCase.py ├── __init__.py ├── common ├── __init__.py ├── media │ ├── __init__.py │ ├── test_TextDetector.py │ └── test_snapshot.py └── test_utils.py └── ui └── __init__.py /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # C extensions 7 | *.so 8 | 9 | # Distribution / packaging 10 | .Python 11 | env/ 12 | build/ 13 | develop-eggs/ 14 | dist/ 15 | downloads/ 16 | eggs/ 17 | .eggs/ 18 | lib/ 19 | lib64/ 20 | parts/ 21 | sdist/ 22 | var/ 23 | wheels/ 24 | *.egg-info/ 25 | .installed.cfg 26 | *.egg 27 | 28 | # PyInstaller 29 | # Usually these files are written by a python script from a template 30 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 31 | *.manifest 32 | *.spec 33 | !mac_build.spec 34 | 35 | # Installer logs 36 | pip-log.txt 37 | pip-delete-this-directory.txt 38 | 39 | # Unit test / coverage reports 40 | htmlcov/ 41 | .tox/ 42 | .coverage 43 | .coverage.* 44 | .cache 45 | nosetests.xml 46 | coverage.xml 47 | *.cover 48 | .hypothesis/ 49 | 50 | # Translations 51 | *.mo 52 | *.pot 53 | 54 | # Django stuff: 55 | *.log 56 | local_settings.py 57 | 58 | # Flask stuff: 59 | instance/ 60 | .webassets-cache 61 | 62 | # Scrapy stuff: 63 | .scrapy 64 | 65 | # Sphinx documentation 66 | docs/_build/ 67 | 68 | # PyBuilder 69 | target/ 70 | 71 | # Jupyter Notebook 72 | .ipynb_checkpoints 73 | 74 | # pyenv 75 | .python-version 76 | 77 | # celery beat schedule file 78 | celerybeat-schedule 79 | 80 | # SageMath parsed files 81 | *.sage.py 82 | 83 | # dotenv 84 | .env 85 | 86 | # virtualenv 87 | .venv 88 | venv/ 89 | ENV/ 90 | 91 | # Spyder project settings 92 | .spyderproject 93 | .spyproject 94 | 95 | # Rope project settings 96 | .ropeproject 97 | 98 | # mkdocs documentation 99 | /site 100 | 101 | # mypy 102 | .mypy_cache/ 103 | 104 | .idea/ 105 | /*.dmg 106 | /*.pfx -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: python 2 | python: 3 | - "3.6" 4 | # command to install dependencies 5 | install: 6 | - sudo apt-get update 7 | - sudo apt-get install -y build-essential cmake git 8 | - sudo apt-get install -y libjpeg8-dev libtiff4-dev libjasper-dev libpng12-dev libjpeg-dev libpng-dev libtiff-dev 9 | - sudo apt-get install -y libgtk2.0-dev pkg-config libavcodec-dev libavformat-dev libswscale-dev libv4l-dev 10 | - sudo apt-get install -y python-dev python3-dev python-numpy libtbb2 libtbb-dev libdc1394-22-dev 11 | - sudo apt-get install -y mplayer2 ubuntu-restricted-extras python-opencv libav-tools 12 | - pip install -r requirements.txt 13 | # Soooooo ugly 14 | - pip uninstall -y opencv-python 15 | - pip install opencv-python 16 | # command to run tests 17 | script: 18 | - nosetests --with-coverage --cover-package=pluto 19 | after_success: 20 | - coveralls 21 | 22 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Apache License 2 | Version 2.0, January 2004 3 | http://www.apache.org/licenses/ 4 | 5 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 6 | 7 | 1. Definitions. 8 | 9 | "License" shall mean the terms and conditions for use, reproduction, 10 | and distribution as defined by Sections 1 through 9 of this document. 11 | 12 | "Licensor" shall mean the copyright owner or entity authorized by 13 | the copyright owner that is granting the License. 14 | 15 | "Legal Entity" shall mean the union of the acting entity and all 16 | other entities that control, are controlled by, or are under common 17 | control with that entity. For the purposes of this definition, 18 | "control" means (i) the power, direct or indirect, to cause the 19 | direction or management of such entity, whether by contract or 20 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 21 | outstanding shares, or (iii) beneficial ownership of such entity. 22 | 23 | "You" (or "Your") shall mean an individual or Legal Entity 24 | exercising permissions granted by this License. 25 | 26 | "Source" form shall mean the preferred form for making modifications, 27 | including but not limited to software source code, documentation 28 | source, and configuration files. 29 | 30 | "Object" form shall mean any form resulting from mechanical 31 | transformation or translation of a Source form, including but 32 | not limited to compiled object code, generated documentation, 33 | and conversions to other media types. 34 | 35 | "Work" shall mean the work of authorship, whether in Source or 36 | Object form, made available under the License, as indicated by a 37 | copyright notice that is included in or attached to the work 38 | (an example is provided in the Appendix below). 39 | 40 | "Derivative Works" shall mean any work, whether in Source or Object 41 | form, that is based on (or derived from) the Work and for which the 42 | editorial revisions, annotations, elaborations, or other modifications 43 | represent, as a whole, an original work of authorship. For the purposes 44 | of this License, Derivative Works shall not include works that remain 45 | separable from, or merely link (or bind by name) to the interfaces of, 46 | the Work and Derivative Works thereof. 47 | 48 | "Contribution" shall mean any work of authorship, including 49 | the original version of the Work and any modifications or additions 50 | to that Work or Derivative Works thereof, that is intentionally 51 | submitted to Licensor for inclusion in the Work by the copyright owner 52 | or by an individual or Legal Entity authorized to submit on behalf of 53 | the copyright owner. For the purposes of this definition, "submitted" 54 | means any form of electronic, verbal, or written communication sent 55 | to the Licensor or its representatives, including but not limited to 56 | communication on electronic mailing lists, source code control systems, 57 | and issue tracking systems that are managed by, or on behalf of, the 58 | Licensor for the purpose of discussing and improving the Work, but 59 | excluding communication that is conspicuously marked or otherwise 60 | designated in writing by the copyright owner as "Not a Contribution." 61 | 62 | "Contributor" shall mean Licensor and any individual or Legal Entity 63 | on behalf of whom a Contribution has been received by Licensor and 64 | subsequently incorporated within the Work. 65 | 66 | 2. Grant of Copyright License. Subject to the terms and conditions of 67 | this License, each Contributor hereby grants to You a perpetual, 68 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 69 | copyright license to reproduce, prepare Derivative Works of, 70 | publicly display, publicly perform, sublicense, and distribute the 71 | Work and such Derivative Works in Source or Object form. 72 | 73 | 3. Grant of Patent License. Subject to the terms and conditions of 74 | this License, each Contributor hereby grants to You a perpetual, 75 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 76 | (except as stated in this section) patent license to make, have made, 77 | use, offer to sell, sell, import, and otherwise transfer the Work, 78 | where such license applies only to those patent claims licensable 79 | by such Contributor that are necessarily infringed by their 80 | Contribution(s) alone or by combination of their Contribution(s) 81 | with the Work to which such Contribution(s) was submitted. If You 82 | institute patent litigation against any entity (including a 83 | cross-claim or counterclaim in a lawsuit) alleging that the Work 84 | or a Contribution incorporated within the Work constitutes direct 85 | or contributory patent infringement, then any patent licenses 86 | granted to You under this License for that Work shall terminate 87 | as of the date such litigation is filed. 88 | 89 | 4. Redistribution. You may reproduce and distribute copies of the 90 | Work or Derivative Works thereof in any medium, with or without 91 | modifications, and in Source or Object form, provided that You 92 | meet the following conditions: 93 | 94 | (a) You must give any other recipients of the Work or 95 | Derivative Works a copy of this License; and 96 | 97 | (b) You must cause any modified files to carry prominent notices 98 | stating that You changed the files; and 99 | 100 | (c) You must retain, in the Source form of any Derivative Works 101 | that You distribute, all copyright, patent, trademark, and 102 | attribution notices from the Source form of the Work, 103 | excluding those notices that do not pertain to any part of 104 | the Derivative Works; and 105 | 106 | (d) If the Work includes a "NOTICE" text file as part of its 107 | distribution, then any Derivative Works that You distribute must 108 | include a readable copy of the attribution notices contained 109 | within such NOTICE file, excluding those notices that do not 110 | pertain to any part of the Derivative Works, in at least one 111 | of the following places: within a NOTICE text file distributed 112 | as part of the Derivative Works; within the Source form or 113 | documentation, if provided along with the Derivative Works; or, 114 | within a display generated by the Derivative Works, if and 115 | wherever such third-party notices normally appear. The contents 116 | of the NOTICE file are for informational purposes only and 117 | do not modify the License. You may add Your own attribution 118 | notices within Derivative Works that You distribute, alongside 119 | or as an addendum to the NOTICE text from the Work, provided 120 | that such additional attribution notices cannot be construed 121 | as modifying the License. 122 | 123 | You may add Your own copyright statement to Your modifications and 124 | may provide additional or different license terms and conditions 125 | for use, reproduction, or distribution of Your modifications, or 126 | for any such Derivative Works as a whole, provided Your use, 127 | reproduction, and distribution of the Work otherwise complies with 128 | the conditions stated in this License. 129 | 130 | 5. Submission of Contributions. Unless You explicitly state otherwise, 131 | any Contribution intentionally submitted for inclusion in the Work 132 | by You to the Licensor shall be under the terms and conditions of 133 | this License, without any additional terms or conditions. 134 | Notwithstanding the above, nothing herein shall supersede or modify 135 | the terms of any separate license agreement you may have executed 136 | with Licensor regarding such Contributions. 137 | 138 | 6. Trademarks. This License does not grant permission to use the trade 139 | names, trademarks, service marks, or product names of the Licensor, 140 | except as required for reasonable and customary use in describing the 141 | origin of the Work and reproducing the content of the NOTICE file. 142 | 143 | 7. Disclaimer of Warranty. Unless required by applicable law or 144 | agreed to in writing, Licensor provides the Work (and each 145 | Contributor provides its Contributions) on an "AS IS" BASIS, 146 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 147 | implied, including, without limitation, any warranties or conditions 148 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 149 | PARTICULAR PURPOSE. You are solely responsible for determining the 150 | appropriateness of using or redistributing the Work and assume any 151 | risks associated with Your exercise of permissions under this License. 152 | 153 | 8. Limitation of Liability. In no event and under no legal theory, 154 | whether in tort (including negligence), contract, or otherwise, 155 | unless required by applicable law (such as deliberate and grossly 156 | negligent acts) or agreed to in writing, shall any Contributor be 157 | liable to You for damages, including any direct, indirect, special, 158 | incidental, or consequential damages of any character arising as a 159 | result of this License or out of the use or inability to use the 160 | Work (including but not limited to damages for loss of goodwill, 161 | work stoppage, computer failure or malfunction, or any and all 162 | other commercial damages or losses), even if such Contributor 163 | has been advised of the possibility of such damages. 164 | 165 | 9. Accepting Warranty or Additional Liability. While redistributing 166 | the Work or Derivative Works thereof, You may choose to offer, 167 | and charge a fee for, acceptance of support, warranty, indemnity, 168 | or other liability obligations and/or rights consistent with this 169 | License. However, in accepting such obligations, You may act only 170 | on Your own behalf and on Your sole responsibility, not on behalf 171 | of any other Contributor, and only if You agree to indemnify, 172 | defend, and hold each Contributor harmless for any liability 173 | incurred by, or claims asserted against, such Contributor by reason 174 | of your accepting any such warranty or additional liability. 175 | 176 | END OF TERMS AND CONDITIONS 177 | 178 | APPENDIX: How to apply the Apache License to your work. 179 | 180 | To apply the Apache License to your work, attach the following 181 | boilerplate notice, with the fields enclosed by brackets "[]" 182 | replaced with your own identifying information. (Don't include 183 | the brackets!) The text should be enclosed in the appropriate 184 | comment syntax for the file format. We also recommend that a 185 | file or class name and description of purpose be included on the 186 | same "printed page" as the copyright notice for easier 187 | identification within third-party archives. 188 | 189 | Copyright [yyyy] [name of copyright owner] 190 | 191 | Licensed under the Apache License, Version 2.0 (the "License"); 192 | you may not use this file except in compliance with the License. 193 | You may obtain a copy of the License at 194 | 195 | http://www.apache.org/licenses/LICENSE-2.0 196 | 197 | Unless required by applicable law or agreed to in writing, software 198 | distributed under the License is distributed on an "AS IS" BASIS, 199 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 200 | See the License for the specific language governing permissions and 201 | limitations under the License. 202 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | Pluto Video Snapshoter 2 | ====================== 3 | 4 | [![Build Status](https://travis-ci.org/shootsoft/PlutoVideoSnapshoter.svg?branch=master)](https://travis-ci.org/shootsoft/PlutoVideoSnapshoter) [![Coverage Status](https://coveralls.io/repos/github/shootsoft/PlutoVideoSnapshoter/badge.svg)](https://coveralls.io/github/shootsoft/PlutoVideoSnapshoter) 5 | 6 | # Features 7 | 8 | - Automatically take snapshots for each slice of subtitles for a video with a given time range. 9 | - Stitch snapshots into one image 10 | - Automatically detect subtitle positions 11 | 12 | # Usage 13 | 14 | - Open the video 15 | - Open srt file (optional, if the srt file doesn't have the same name of the video) 16 | - Select output path (optional, if the output is different from video file's folder) 17 | - Select time range (optional, default time range is 0:0:0 ~ video's duration time) 18 | - Run task 19 | - Stitch snapshots 20 | 21 | ![Snapshot UI](doc/images/snapshot_ui.png) 22 | 23 | ![Snapshot UI](doc/images/auto_detection/subtitle_auto_detection.png) 24 | 25 | ![Snapshot UI](doc/images/snapshot_ui_stitching_preview.png) 26 | 27 | See [User Maual](doc/user_manual.md) [中文用户手册](https://gitee.com/shootsoft/PlutoVideoSnapshoter/blob/master/doc/user_manual_cn.md) 28 | 29 | # Development 30 | 31 | ## Mac 32 | 33 | Recommendation Python 3.6 34 | 35 | ``` 36 | sudo pip install -r requirements.txt 37 | ``` 38 | 39 | If met qt install failed, you may try 40 | 41 | ``` 42 | brew install qt5 43 | brew link qt5 --force 44 | ``` 45 | 46 | [Qt Designer](https://www.qt.io/download) is required for window UI design. 47 | 48 | Running 49 | 50 | ``` 51 | python src/app.py 52 | ``` 53 | 54 | Building (not fully working yet) 55 | 56 | ``` 57 | # Windows 58 | build_win.bat 59 | 60 | # macOS 61 | ./build_mac.sh 62 | ``` 63 | 64 | ## Windows 65 | 66 | Extra media codec required, recommend [K-Lite Codec Pack](http://www.codecguide.com/download_kl.htm) 67 | 68 | 69 | ## Tests 70 | 71 | ```bash 72 | nosetests --with-coverage --cover-package=pluto 73 | ``` -------------------------------------------------------------------------------- /build_mac.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | echo "Creating app file..." 4 | rm -rf dist 5 | pyinstaller -D -w mac_build.spec 6 | echo "app created." 7 | 8 | if [ "$1" == "dmg" ]; then 9 | 10 | if [ -n "$2" ]; then 11 | echo "Signing..." 12 | codesign -s "$2" dist/PlutoVideoSnapshoter.app --deep 13 | echo "Verify signing..." 14 | codesign -dv --verbose=4 dist/PlutoVideoSnapshoter.app 15 | fi 16 | 17 | echo "Creating dmg file..." 18 | dmgbuild -s dmg_settings.py "PlutoVideoSnapshoter Volume" PlutoVideoSnapshoter.dmg 19 | 20 | if [ -n "$2" ]; then 21 | echo "Signing..." 22 | codesign -s "$2" PlutoVideoSnapshoter.dmg 23 | echo "Verify signing..." 24 | codesign -dv --verbose=4 PlutoVideoSnapshoter.dmg 25 | echo "DMG created." 26 | fi 27 | fi 28 | 29 | echo "All done." 30 | # Multiple files, for debugging 31 | 32 | #pyinstaller --clean -F -w \ 33 | # --icon=res/pluto.icns \ 34 | # --add-data "src/windows:windows" \ 35 | # "src/app.py" \ 36 | # -n "PlutoVideoSnapshoter" 37 | 38 | -------------------------------------------------------------------------------- /build_win.bat: -------------------------------------------------------------------------------- 1 | pyinstaller -F -w --icon=res/pluto.ico ^ 2 | --add-data "src\windows;windows" ^ 3 | --add-data "venv/v36/Lib/site-packages/cv2/opencv_ffmpeg330.dll;./" ^ 4 | --path "C:\Program Files (x86)\Windows Kits\10\Redist\ucrt\DLLs\x86" ^ 5 | --version-file=file_version_info.txt "src\app.py" ^ 6 | -n "PlutoVideoSnapshoter" 7 | 8 | IF [%1] == [] GOTO end 9 | "C:\Program Files (x86)\Windows Kits\10\bin\x86\signtool.exe" sign ^ 10 | /f Certificates.pfx /p %1 /t "http://timestamp.comodoca.com" ^ 11 | "dist\PlutoVideoSnapshoter.exe" 12 | GOTO end 13 | ...skip this... 14 | REM Multiple files, for debugging 15 | pyinstaller -D -w --icon=res/pluto.ico ^ 16 | --add-data "src\windows;windows" ^ 17 | --add-data "venv/v36/Lib/site-packages/cv2/opencv_ffmpeg330.dll;./" ^ 18 | --path "C:\Program Files (x86)\Windows Kits\10\Redist\ucrt\DLLs\x86" ^ 19 | --version-file=file_version_info.txt "src\app.py" ^ 20 | -n "PlutoVideoSnapshoter" 21 | 22 | :end 23 | -------------------------------------------------------------------------------- /dmg_settings.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | from __future__ import unicode_literals 3 | 4 | import biplist 5 | import os.path 6 | 7 | # Use like this: dmgbuild -s settings.py "Test Volume" test.dmg 8 | 9 | # You can actually use this file for your own application (not just TextEdit) 10 | # by doing e.g. 11 | # 12 | # dmgbuild -s settings.py -D app=/path/to/My.app "My Application" MyApp.dmg 13 | 14 | # .. Useful stuff .............................................................. 15 | 16 | application = defines.get('app', 'dist/PlutoVideoSnapshoter.app') 17 | appname = os.path.basename(application) 18 | 19 | 20 | def icon_from_app(app_path): 21 | plist_path = os.path.join(app_path, 'Contents', 'Info.plist') 22 | plist = biplist.readPlist(plist_path) 23 | icon_name = plist['CFBundleIconFile'] 24 | icon_root, icon_ext = os.path.splitext(icon_name) 25 | if not icon_ext: 26 | icon_ext = '.icns' 27 | icon_name = icon_root + icon_ext 28 | return os.path.join(app_path, 'Contents', 'Resources', icon_name) 29 | 30 | 31 | # .. Basics .................................................................... 32 | 33 | # Uncomment to override the output filename 34 | # filename = 'test.dmg' 35 | 36 | # Uncomment to override the output volume name 37 | # volume_name = 'Test' 38 | 39 | # Volume format (see hdiutil create -help) 40 | format = defines.get('format', 'UDBZ') 41 | 42 | # Volume size 43 | size = defines.get('size', None) 44 | 45 | # Files to include 46 | files = [application] 47 | 48 | # Symlinks to create 49 | symlinks = {'Applications': '/Applications'} 50 | 51 | # Volume icon 52 | # 53 | # You can either define icon, in which case that icon file will be copied to the 54 | # image, *or* you can define badge_icon, in which case the icon file you specify 55 | # will be used to badge the system's Removable Disk icon 56 | # 57 | # icon = '/path/to/icon.icns' 58 | badge_icon = icon_from_app(application) 59 | 60 | # Where to put the icons 61 | icon_locations = { 62 | appname: (140, 120), 63 | 'Applications': (500, 120) 64 | } 65 | 66 | # .. Window configuration ...................................................... 67 | 68 | # Background 69 | # 70 | # This is a STRING containing any of the following: 71 | # 72 | # #3344ff - web-style RGB color 73 | # #34f - web-style RGB color, short form (#34f == #3344ff) 74 | # rgb(1,0,0) - RGB color, each value is between 0 and 1 75 | # hsl(120,1,.5) - HSL (hue saturation lightness) color 76 | # hwb(300,0,0) - HWB (hue whiteness blackness) color 77 | # cmyk(0,1,0,0) - CMYK color 78 | # goldenrod - X11/SVG named color 79 | # builtin-arrow - A simple built-in background with a blue arrow 80 | # /foo/bar/baz.png - The path to an image file 81 | # 82 | # The hue component in hsl() and hwb() may include a unit; it defaults to 83 | # degrees ('deg'), but also supports radians ('rad') and gradians ('grad' 84 | # or 'gon'). 85 | # 86 | # Other color components may be expressed either in the range 0 to 1, or 87 | # as percentages (e.g. 60% is equivalent to 0.6). 88 | background = 'builtin-arrow' 89 | 90 | show_status_bar = False 91 | show_tab_view = False 92 | show_toolbar = False 93 | show_pathbar = False 94 | show_sidebar = False 95 | sidebar_width = 180 96 | 97 | # Window position in ((x, y), (w, h)) format 98 | window_rect = ((100, 100), (640, 280)) 99 | 100 | # Select the default view; must be one of 101 | # 102 | # 'icon-view' 103 | # 'list-view' 104 | # 'column-view' 105 | # 'coverflow' 106 | # 107 | default_view = 'icon-view' 108 | 109 | # General view configuration 110 | show_icon_preview = False 111 | 112 | # Set these to True to force inclusion of icon/list view settings (otherwise 113 | # we only include settings for the default view) 114 | include_icon_view_settings = 'auto' 115 | include_list_view_settings = 'auto' 116 | 117 | # .. Icon view configuration ................................................... 118 | 119 | arrange_by = None 120 | grid_offset = (0, 0) 121 | grid_spacing = 100 122 | scroll_position = (0, 0) 123 | label_pos = 'bottom' # or 'right' 124 | text_size = 16 125 | icon_size = 128 126 | 127 | # .. List view configuration ................................................... 128 | 129 | # Column names are as follows: 130 | # 131 | # name 132 | # date-modified 133 | # date-created 134 | # date-added 135 | # date-last-opened 136 | # size 137 | # kind 138 | # label 139 | # version 140 | # comments 141 | # 142 | list_icon_size = 16 143 | list_text_size = 12 144 | list_scroll_position = (0, 0) 145 | list_sort_by = 'name' 146 | list_use_relative_dates = True 147 | list_calculate_all_sizes = False, 148 | list_columns = ('name', 'date-modified', 'size', 'kind', 'date-added') 149 | list_column_widths = { 150 | 'name': 300, 151 | 'date-modified': 181, 152 | 'date-created': 181, 153 | 'date-added': 181, 154 | 'date-last-opened': 181, 155 | 'size': 97, 156 | 'kind': 115, 157 | 'label': 100, 158 | 'version': 75, 159 | 'comments': 300, 160 | } 161 | list_column_sort_directions = { 162 | 'name': 'ascending', 163 | 'date-modified': 'descending', 164 | 'date-created': 'descending', 165 | 'date-added': 'descending', 166 | 'date-last-opened': 'descending', 167 | 'size': 'descending', 168 | 'kind': 'ascending', 169 | 'label': 'ascending', 170 | 'version': 'ascending', 171 | 'comments': 'ascending', 172 | } 173 | -------------------------------------------------------------------------------- /doc/images/auto_detection/preview.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/shootsoft/PlutoVideoSnapshoter/20eb4bc9d4eb6f090c713dce23077173d7ed8e5e/doc/images/auto_detection/preview.png -------------------------------------------------------------------------------- /doc/images/auto_detection/subtitle_auto_detection.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/shootsoft/PlutoVideoSnapshoter/20eb4bc9d4eb6f090c713dce23077173d7ed8e5e/doc/images/auto_detection/subtitle_auto_detection.png -------------------------------------------------------------------------------- /doc/images/snapshot_ui.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/shootsoft/PlutoVideoSnapshoter/20eb4bc9d4eb6f090c713dce23077173d7ed8e5e/doc/images/snapshot_ui.png -------------------------------------------------------------------------------- /doc/images/snapshot_ui_stitching.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/shootsoft/PlutoVideoSnapshoter/20eb4bc9d4eb6f090c713dce23077173d7ed8e5e/doc/images/snapshot_ui_stitching.png -------------------------------------------------------------------------------- /doc/images/snapshot_ui_stitching_preview.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/shootsoft/PlutoVideoSnapshoter/20eb4bc9d4eb6f090c713dce23077173d7ed8e5e/doc/images/snapshot_ui_stitching_preview.png -------------------------------------------------------------------------------- /doc/images/usage/screenshot_app.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/shootsoft/PlutoVideoSnapshoter/20eb4bc9d4eb6f090c713dce23077173d7ed8e5e/doc/images/usage/screenshot_app.png -------------------------------------------------------------------------------- /doc/images/usage/screenshot_exe.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/shootsoft/PlutoVideoSnapshoter/20eb4bc9d4eb6f090c713dce23077173d7ed8e5e/doc/images/usage/screenshot_exe.png -------------------------------------------------------------------------------- /doc/images/usage/screenshot_jump.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/shootsoft/PlutoVideoSnapshoter/20eb4bc9d4eb6f090c713dce23077173d7ed8e5e/doc/images/usage/screenshot_jump.png -------------------------------------------------------------------------------- /doc/images/usage/screenshot_open copy.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/shootsoft/PlutoVideoSnapshoter/20eb4bc9d4eb6f090c713dce23077173d7ed8e5e/doc/images/usage/screenshot_open copy.png -------------------------------------------------------------------------------- /doc/images/usage/screenshot_open.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/shootsoft/PlutoVideoSnapshoter/20eb4bc9d4eb6f090c713dce23077173d7ed8e5e/doc/images/usage/screenshot_open.png -------------------------------------------------------------------------------- /doc/images/usage/screenshot_output copy.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/shootsoft/PlutoVideoSnapshoter/20eb4bc9d4eb6f090c713dce23077173d7ed8e5e/doc/images/usage/screenshot_output copy.png -------------------------------------------------------------------------------- /doc/images/usage/screenshot_output.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/shootsoft/PlutoVideoSnapshoter/20eb4bc9d4eb6f090c713dce23077173d7ed8e5e/doc/images/usage/screenshot_output.png -------------------------------------------------------------------------------- /doc/images/usage/screenshot_play.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/shootsoft/PlutoVideoSnapshoter/20eb4bc9d4eb6f090c713dce23077173d7ed8e5e/doc/images/usage/screenshot_play.png -------------------------------------------------------------------------------- /doc/images/usage/screenshot_preview.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/shootsoft/PlutoVideoSnapshoter/20eb4bc9d4eb6f090c713dce23077173d7ed8e5e/doc/images/usage/screenshot_preview.png -------------------------------------------------------------------------------- /doc/images/usage/screenshot_preview_selected.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/shootsoft/PlutoVideoSnapshoter/20eb4bc9d4eb6f090c713dce23077173d7ed8e5e/doc/images/usage/screenshot_preview_selected.png -------------------------------------------------------------------------------- /doc/images/usage/screenshot_save.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/shootsoft/PlutoVideoSnapshoter/20eb4bc9d4eb6f090c713dce23077173d7ed8e5e/doc/images/usage/screenshot_save.png -------------------------------------------------------------------------------- /doc/images/usage/screenshot_single.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/shootsoft/PlutoVideoSnapshoter/20eb4bc9d4eb6f090c713dce23077173d7ed8e5e/doc/images/usage/screenshot_single.png -------------------------------------------------------------------------------- /doc/images/usage/screenshot_srt.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/shootsoft/PlutoVideoSnapshoter/20eb4bc9d4eb6f090c713dce23077173d7ed8e5e/doc/images/usage/screenshot_srt.png -------------------------------------------------------------------------------- /doc/images/usage/screenshot_task.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/shootsoft/PlutoVideoSnapshoter/20eb4bc9d4eb6f090c713dce23077173d7ed8e5e/doc/images/usage/screenshot_task.png -------------------------------------------------------------------------------- /doc/images/usage/screenshot_updown.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/shootsoft/PlutoVideoSnapshoter/20eb4bc9d4eb6f090c713dce23077173d7ed8e5e/doc/images/usage/screenshot_updown.png -------------------------------------------------------------------------------- /doc/package_doc/README.md: -------------------------------------------------------------------------------- 1 | Pluto Video Snapshoter 2 | 3 | - Author: [Shoot Sun Studio](https://shootsoft.net) 4 | - Homepage: https://github.com/shootsoft/PlutoVideoSnapshoter 5 | - Download: https://github.com/shootsoft/PlutoVideoSnapshoter/releases 6 | 7 | Extra media codec required, recommend [K-Lite Codec Pack](http://www.codecguide.com/download_kl.htm) 8 | 9 | Features: 10 | 11 | - Cross platform (macOS/Windows/Linux) 12 | - Manual video snapshot 13 | - Automatically video snapshot per subtitle line 14 | - Automatically detecting subtitle positions 15 | - Single/batch image subtitle position update 16 | - Snapshot and image stitching at one go 17 | 18 | -------------------------------------------------------------------------------- /doc/package_doc/说明.txt: -------------------------------------------------------------------------------- 1 | 视频截图字幕拼接精灵 2 | 3 | - 开发商:射日工作室 https://shootsoft.net 4 | - 主页:https://shootsoft.gitee.io/pluto/ 5 | - 下载:https://gitee.com/shootsoft/PlutoVideoSnapshoter/releases 6 | 7 | 本软件运行需要系统安装相应的视频解码类库,推荐 K-Lite Codec Pack http://www.codecguide.com/download_kl.htm 8 | 9 | 软件特点: 10 | 11 | - 多平台 (macOS/Windows/Linux) 12 | - 视频手动截图 13 | - 视频依据字幕自动截图 14 | - 自动检测字幕区域 15 | - 允许单张批量图片手动调整字幕区域 16 | - 截图拼接一气呵成 17 | 18 | 视频截图字幕拼接精灵是一款能够在多个平台下使用的视频截图软件,本软件同时集成了依据字幕自动截图,图片字幕区域自动识别功能。能够将多张带有字幕的截图拼接成一张完整图片。 -------------------------------------------------------------------------------- /doc/user_manual.md: -------------------------------------------------------------------------------- 1 | PlutoVideoSanpshoter Usage 2 | ==== 3 | 4 | ## Launch application 5 | 1. Launch application on Windows 6 | ![Launch](images/usage/screenshot_exe.png) 7 | 1. Launch application on macOS 8 | ![Launch](images/usage/screenshot_app.png) 9 | 10 | ## Usage 11 | 12 | 1. Open a video 13 | ![Open a video](images/usage/screenshot_open.png) 14 | 1. (Optional) Select snapshot output folder 15 | ![Output folder](images/usage/screenshot_output.png) 16 | 1. (Optional, this is required for `Auto Snapshots`) Open a subtitle file (*.srt). The file could be automatically detected if the file has the same name as the video file. Note: current version doesn't support rendering subtitles into video screen or snapshots. 17 | ![Open srt file](images/usage/screenshot_srt.png) 18 | 1. Play video and take single snapshot (video will automatically start playing once you opened it. You can also stop the video by click the same button or click video area) 19 | ![Play](images/usage/screenshot_play.png) 20 | ![Single snapshot](images/usage/screenshot_single.png) 21 | 1. Select `Start` and `End`, run `Auto Anapshot` to take snapshots with each line of subtitles. 22 | ![Task](images/usage/screenshot_task.png) 23 | 1. Jump to snapshot stitching 24 | ![Jump](images/usage/screenshot_jump.png) 25 | 1. Select images and up limit and down limit 26 | ![Up and Down limit](images/usage/screenshot_updown.png) 27 | 1. Preview stitched image 28 | ![Jump](images/usage/screenshot_preview.png) 29 | 1. Save stitched image 30 | ![Save](images/usage/screenshot_save.png) 31 | 32 | ## Advanced 33 | 34 | 1. Preview/save selected images (right click selected images) 35 | ![Save](images/usage/screenshot_preview_selected.png) 36 | 1. Automatically detect subtitle positions 37 | ![Save](images/auto_detection/subtitle_auto_detection.png) -------------------------------------------------------------------------------- /doc/user_manual_cn.md: -------------------------------------------------------------------------------- 1 | 视频截图字幕拼接精灵使用说明 2 | ============== 3 | 本软件运行需要系统安装相应的视频解码类库,推荐 K-Lite Codec Pack http://www.codecguide.com/download_kl.htm 4 | 5 | 软件特点: 6 | 7 | - 多平台 (macOS/Windows/Linux) 8 | - 视频手动截图 9 | - 视频依据字幕自动截图 10 | - 自动检测字幕区域 11 | - 允许单张批量图片手动调整字幕区域 12 | - 截图拼接一气呵成 13 | 14 | 视频截图字幕拼接精灵是一款能够在多个平台下使用的视频截图软件,本软件同时集成了依据字幕截图,图片字幕区域自动识别功能。能够将多张带有字幕的截图拼接成一张完整图片。效果如图: 15 | ![预览](images/snapshot_ui_stitching_preview.png) 16 | 17 | ## 启动应用 18 | 19 | 1. Windows下启动。 20 | ![Launch](images/usage/screenshot_exe.png) 21 | 1. macOS下启动。 22 | ![Launch](images/usage/screenshot_app.png) 23 | 24 | ## 使用说明 25 | 26 | 1. 打开视频。 27 | ![Open a video](images/usage/screenshot_open.png) 28 | 1. (可选) 选择截图存放路径。 29 | ![Output folder](images/usage/screenshot_output.png) 30 | 1. (可选, 针对`Auto Snapshots`,这个是必选) 打开字幕文件 (*.srt). 如果字幕文件与视频文件是相同的文件名,这个字幕文件会被自动加载。注意:本软件现阶段并不支持渲染字幕到视频或者截屏上。 31 | ![Open srt file](images/usage/screenshot_srt.png) 32 | 1. 播放视频或者手动截图(当视频被打开之后会立刻开始自动播放,此时,播放按钮会变成暂停,单机视频区域也可以播放或者暂停视频)。 33 | ![Play](images/usage/screenshot_play.png) 34 | ![Single snapshot](images/usage/screenshot_single.png) 35 | 1. 选择 `Start` 和 `End`, 之后就可以通过点击`Auto Anapshot`针对有字幕的每一帧自动进行截屏。 36 | ![Task](images/usage/screenshot_task.png) 37 | 1. 跳转到`Image Stitching`。 38 | ![Jump](images/usage/screenshot_jump.png) 39 | 1. 选择视频字幕上下区域。 40 | ![Up and Down limit](images/usage/screenshot_updown.png) 41 | 1. 预览拼接效果。 42 | ![Jump](images/usage/screenshot_preview.png) 43 | 1. 保存拼接的图片。 44 | ![Save](images/usage/screenshot_save.png) 45 | 46 | ## 高级 47 | 48 | 1. 字幕区域自动识别。 49 | ![Save](images/auto_detection/subtitle_auto_detection.png) 50 | 1. 预览/保存选择性拼接。 51 | ![Save](images/usage/screenshot_preview_selected.png) 52 | 53 | -------------------------------------------------------------------------------- /file_version_info.txt: -------------------------------------------------------------------------------- 1 | # UTF-8 2 | # 3 | # For more details about fixed file info 'ffi' see: 4 | # http://msdn.microsoft.com/en-us/library/ms646997.aspx 5 | VSVersionInfo( 6 | ffi=FixedFileInfo( 7 | # filevers and prodvers should be always a tuple with four items: (1, 2, 3, 4) 8 | # Set not needed items to zero 0. 9 | filevers=(1, 2, 0, 0), 10 | prodvers=(1, 2, 0, 0), 11 | # Contains a bitmask that specifies the valid bits 'flags'r 12 | mask=0x3f, 13 | # Contains a bitmask that specifies the Boolean attributes of the file. 14 | flags=0x0, 15 | # The operating system for which this file was designed. 16 | # 0x4 - NT and there is no need to change it. 17 | OS=0x4, 18 | # The general type of file. 19 | # 0x1 - the file is an application. 20 | fileType=0x1, 21 | # The function of the file. 22 | # 0x0 - the function is not defined for this fileType 23 | subtype=0x0, 24 | # Creation date and time stamp. 25 | date=(0, 0) 26 | ), 27 | kids=[ 28 | StringFileInfo( 29 | [ 30 | StringTable( 31 | u'040904e4', 32 | [StringStruct(u'CompanyName', u'Shoot Sun Studio'), 33 | StringStruct(u'FileDescription', u'Pluto Video Snapshoter'), 34 | StringStruct(u'FileVersion', u'1.4.4'), 35 | StringStruct(u'InternalName', u'https://github.com/shootsoft/PlutoVidoeSnapshoter'), 36 | StringStruct(u'LegalCopyright', u'Copyright (C) 2000-2018 Shoot Sun Studio All Rights Reserved'), 37 | StringStruct(u'OriginalFilename', u'PlutoVideoSnapshoter.exe'), 38 | StringStruct(u'ProductName', u'Pluto Video Snapshoter'), 39 | StringStruct(u'ProductVersion', u'1.4.4')]) 40 | ]), 41 | VarFileInfo([VarStruct(u'Translation', [1033, 1252])]) 42 | ] 43 | ) -------------------------------------------------------------------------------- /mac_build.spec: -------------------------------------------------------------------------------- 1 | # -*- mode: python -*- 2 | 3 | block_cipher = None 4 | 5 | 6 | a = Analysis(['src/app.py'], 7 | pathex=['/Users/yinjun/work/github/PlutoVidoeSnapshoter'], 8 | binaries=[], 9 | datas=[('src/windows', 'windows')], 10 | hiddenimports=[], 11 | hookspath=[], 12 | runtime_hooks=[], 13 | excludes=[], 14 | win_no_prefer_redirects=False, 15 | win_private_assemblies=False, 16 | cipher=block_cipher) 17 | pyz = PYZ(a.pure, a.zipped_data, 18 | cipher=block_cipher) 19 | exe = EXE(pyz, 20 | a.scripts, 21 | exclude_binaries=True, 22 | name='PlutoVideoSnapshoter', 23 | debug=False, 24 | strip=False, 25 | upx=True, 26 | console=False , icon='res/pluto.icns') 27 | coll = COLLECT(exe, 28 | a.binaries, 29 | a.zipfiles, 30 | a.datas, 31 | strip=False, 32 | upx=True, 33 | name='PlutoVideoSnapshoter') 34 | app = BUNDLE(coll, 35 | name='PlutoVideoSnapshoter.app', 36 | icon='res/pluto.icns', 37 | bundle_identifier= 'net.shootsoft.pluto', 38 | info_plist={ 39 | 'CFBundleName': 'PlutoVideoSnapshoter', 40 | 'CFBundleShortVersionString': '1.4.4', 41 | 'NSPrincipalClass': 'NSApplication', 42 | 'NSHighResolutionCapable': 'True' 43 | }, 44 | ) 45 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | sip 2 | pyqt5 3 | opencv-python==3.3.0.9 # Latest version may not work well with Mac 4 | numpy 5 | Pillow 6 | chardet 7 | qdarkstyle 8 | 9 | # Development 10 | pyinstaller 11 | nose 12 | coverage 13 | python-coveralls 14 | biplist 15 | dmgbuild -------------------------------------------------------------------------------- /res/README.md: -------------------------------------------------------------------------------- 1 | Icon https://www.iconfinder.com/icons/89144/pluto_icon -------------------------------------------------------------------------------- /res/pluto.icns: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/shootsoft/PlutoVideoSnapshoter/20eb4bc9d4eb6f090c713dce23077173d7ed8e5e/res/pluto.icns -------------------------------------------------------------------------------- /res/pluto.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/shootsoft/PlutoVideoSnapshoter/20eb4bc9d4eb6f090c713dce23077173d7ed8e5e/res/pluto.ico -------------------------------------------------------------------------------- /src/app.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | import sys 4 | 5 | import os 6 | from PyQt5 import QtGui 7 | 8 | from PyQt5.QtWidgets import QApplication 9 | 10 | from pluto.ui.player.controllers import PlayerController 11 | from pluto.ui.qt.mvc.routers import Router 12 | from pluto.ui.stitcher.controllers import ImageStitchingController 13 | from pluto.ui.qt.qtutils import QtUtil 14 | import qdarkstyle 15 | 16 | if __name__ == '__main__': 17 | app = QApplication(sys.argv) 18 | # setup stylesheet 19 | app.setStyleSheet(qdarkstyle.load_stylesheet_pyqt5()) 20 | app.setWindowIcon(QtGui.QIcon(QtUtil.resource_path(os.path.join('windows', 'pluto.png')))) 21 | router = Router(app) 22 | router.add_ctrl('player', PlayerController(router)) 23 | router.add_ctrl('image', ImageStitchingController(router)) 24 | router.go('player') 25 | sys.exit(app.exec_()) 26 | -------------------------------------------------------------------------------- /src/pluto/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/shootsoft/PlutoVideoSnapshoter/20eb4bc9d4eb6f090c713dce23077173d7ed8e5e/src/pluto/__init__.py -------------------------------------------------------------------------------- /src/pluto/common/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/shootsoft/PlutoVideoSnapshoter/20eb4bc9d4eb6f090c713dce23077173d7ed8e5e/src/pluto/common/__init__.py -------------------------------------------------------------------------------- /src/pluto/common/media/TextDetector.py: -------------------------------------------------------------------------------- 1 | import cv2 2 | 3 | 4 | class TextDetector(object): 5 | 6 | def __init__(self): 7 | pass 8 | 9 | def detect_text_rect(self, image, threshold=250): 10 | """ 11 | See https://stackoverflow.com/questions/24385714/detect-text-region-in-image-using-opencv 12 | :return: 13 | """ 14 | img2gray = cv2.cvtColor(image, cv2.COLOR_BGR2GRAY) 15 | ret, mask = cv2.threshold(img2gray, threshold, 255, cv2.THRESH_BINARY) 16 | image_final = cv2.bitwise_and(img2gray, img2gray, mask=mask) 17 | ret, new_img = cv2.threshold(image_final, threshold, 255, 18 | cv2.THRESH_BINARY) # for black text , cv.THRESH_BINARY_INV 19 | ''' 20 | line 8 to 12 : Remove noisy portion 21 | ''' 22 | kernel = cv2.getStructuringElement(cv2.MORPH_CROSS, (3, 23 | 3)) # to manipulate the orientation of dilution , large x means horizonatally dilating more, large y means vertically dilating more 24 | dilated = cv2.dilate(new_img, kernel, iterations=9) # dilate , more the iteration more the dilation 25 | im2, contours, hierarchy = cv2.findContours(dilated, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_NONE) # get contours 26 | 27 | rectangles = list() 28 | for contour in contours: 29 | # get rectangle bounding contour 30 | [x, y, w, h] = cv2.boundingRect(contour) 31 | 32 | # Don't plot small false positives that aren't text 33 | if w < 35 and h < 35: 34 | continue 35 | rectangles.append(Rect([x, y, w, h])) 36 | # draw rectangle around contour on original image 37 | # cv2.rectangle(img, (x, y), (x + w, y + h), (255, 0, 255), 2) 38 | rectangles.sort(key=lambda r: r.y, reverse=False) 39 | return rectangles 40 | 41 | def detect_subtitle_range(self, filename): 42 | image = cv2.imread(filename) 43 | height, width, channels = image.shape 44 | y_up = 0 45 | y_down = height 46 | rectangles = self.detect_text_rect(image) 47 | if len(rectangles) > 0: 48 | y_up = height * 0.6 49 | for rect in rectangles: 50 | if rect.y < y_up: 51 | continue 52 | if rect.y > y_up: 53 | y_up = rect.y 54 | break 55 | last_rect = rectangles[len(rectangles) - 1] 56 | max_down = last_rect.y + last_rect.height + max(last_rect.height * 0.3, 30) 57 | y_down = max_down if max_down < height else last_rect.y + last_rect.height 58 | 59 | return int(y_up), int(y_down) 60 | 61 | 62 | class Rect(object): 63 | def __init__(self, array): 64 | self.x = 0 if len(array) == 0 else array[0] 65 | self.y = 0 if len(array) < 1 else array[1] 66 | self.width = 0 if len(array) < 2 else array[2] 67 | self.height = 0 if len(array) < 3 else array[3] 68 | 69 | def __eq__(self, other): 70 | return self.__dict__ == other.__dict__ 71 | 72 | def __repr__(self): 73 | return "x=%s, y=%s, width=%s, height=%s" % (self.x, self.y, self.width, self.height) 74 | -------------------------------------------------------------------------------- /src/pluto/common/media/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/shootsoft/PlutoVideoSnapshoter/20eb4bc9d4eb6f090c713dce23077173d7ed8e5e/src/pluto/common/media/__init__.py -------------------------------------------------------------------------------- /src/pluto/common/media/snapshot.py: -------------------------------------------------------------------------------- 1 | import traceback 2 | 3 | import cv2 4 | import os 5 | 6 | from pluto.common.utils import SrtUtil, TimeUtil 7 | 8 | 9 | class Snapshot(object): 10 | def __init__(self): 11 | self.video_file = "" 12 | self.video_capture = None 13 | self.srt_file = "" 14 | self.srt_subs = [] 15 | self.width = 0 16 | self.height = 0 17 | self.duration = 0 18 | self.fps = 0 19 | 20 | def __exit__(self, exc_type, exc_val, exc_tb): 21 | self.__initialise() 22 | 23 | def __initialise(self): 24 | if self.video_capture: 25 | self.video_capture.release() 26 | self.video_capture = None 27 | self.srt_file = "" 28 | self.srt_subs = [] 29 | self.video_file = "" 30 | 31 | def load_video(self, video_file): 32 | self.__initialise() 33 | self.video_capture = cv2.VideoCapture(filename=video_file) 34 | self.video_file = video_file 35 | srt_file = self.detect_srt(video_file) 36 | if srt_file: 37 | self.load_srt(srt_file) 38 | 39 | if self.video_capture.isOpened(): 40 | self.width = self.video_capture.get(cv2.CAP_PROP_FRAME_WIDTH) 41 | self.height = self.video_capture.get(cv2.CAP_PROP_FRAME_HEIGHT) 42 | self.fps = self.video_capture.get(cv2.CAP_PROP_FPS) 43 | self.duration = self.video_capture.get(cv2.CAP_PROP_FRAME_COUNT) * 1000.0 / self.fps 44 | 45 | return self.video_capture.isOpened() 46 | 47 | @staticmethod 48 | def detect_srt(video_file): 49 | srt_files = [video_file + '.srt', os.path.splitext(video_file)[0] + '.srt'] 50 | for str_file in srt_files: 51 | if os.path.isfile(str_file): 52 | return str_file 53 | 54 | def load_srt(self, srt_file): 55 | self.srt_file = srt_file 56 | self.srt_subs = SrtUtil.parse_srt(srt_file) 57 | 58 | def estimate(self, start=0, end=None): 59 | count = 0 60 | if len(self.srt_subs) == 0: 61 | return count 62 | if end is None: 63 | end = self.__default_end() 64 | for sub in self.srt_subs: 65 | if sub.start >= start and sub.end <= end: 66 | count += 1 67 | return count 68 | 69 | def __default_end(self): 70 | return self.srt_subs[len(self.srt_subs) - 1].end 71 | 72 | def snapshot(self, position, output_file): 73 | """ 74 | Take a snapshot for certain position of the video 75 | :param position: integer 76 | :param output_file: image name 77 | :return: True for success 78 | :raise Exception for cv snapshot failure. 79 | """ 80 | if position < 0 or position > self.duration: 81 | return False 82 | self.video_capture.set(cv2.CAP_PROP_POS_MSEC, position) 83 | success, image = self.video_capture.read() 84 | if success: 85 | try: 86 | dir_path = os.path.dirname(os.path.realpath(output_file)) 87 | if not os.path.exists(dir_path): 88 | os.mkdir(dir_path) 89 | result = cv2.imwrite(output_file, image) 90 | return result 91 | except: 92 | traceback.print_exc() 93 | raise Exception('Snapshot position %s failed %s' % (position, output_file)) 94 | 95 | else: 96 | raise Exception('Snapshot position %s failed %s' % (position, output_file)) 97 | 98 | def snapshot_range(self, output_folder, start=0, end=None, callback_progress=None, callback_complete=None): 99 | """ 100 | Take snapshots for a given range 101 | :param output_folder: str output folder 102 | :param start: 103 | :param end: 104 | :param callback_progress: call_back_progress(total, current, position, output_file, output_result) 105 | :param callback_complete: callback_complete(total, success) 106 | :return: 107 | """ 108 | total = self.estimate(start, end) 109 | if total == 0: 110 | if callback_complete: 111 | callback_complete(0, 0) 112 | return 113 | 114 | if end is None: 115 | end = self.__default_end() 116 | current = 0 117 | success = 0 118 | for sub in self.srt_subs: 119 | if sub.start >= start and sub.end <= end: 120 | current += 1 121 | position = int((sub.start + sub.end) / 2) 122 | print("current %s start %s end %s mid %s" % (current, sub.start, sub.end, position)) 123 | output_file = os.path.join(output_folder, 124 | "%s_auto_%s.jpg" % (os.path.basename(self.video_file), 125 | TimeUtil.format_ms(position).replace(":", "_"))) 126 | output_result = self.snapshot(position, output_file) 127 | success += 1 if output_result else 0 128 | if callback_progress: 129 | try: 130 | callback_progress(total, current, position, output_file, output_result) 131 | except: 132 | traceback.print_exc() 133 | if callback_complete: 134 | callback_complete(total, success) 135 | -------------------------------------------------------------------------------- /src/pluto/common/utils.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | import tempfile 4 | import time 5 | import traceback 6 | 7 | from itertools import groupby 8 | from collections import namedtuple 9 | 10 | import re 11 | 12 | import chardet as chardet 13 | from PIL import Image 14 | 15 | 16 | class SizeUtil(object): 17 | @staticmethod 18 | def fit(width, height, max_width, max_height): 19 | """ 20 | Fit size (width height) into new size 21 | """ 22 | Size = namedtuple('Size', 'width height') 23 | if width == 0 or height == 0: 24 | return Size(0, 0) 25 | x = max_width * 1.0 / width 26 | y = max_height * 1.0 / height 27 | target = x if x < y else y 28 | return Size(int(width * target), int(height * target)) 29 | 30 | 31 | class TimeUtil(object): 32 | @staticmethod 33 | def format_ms(ms_time): 34 | """ 35 | Format milliseconds into string foramt '00:00:00.000' 36 | :return: string 37 | """ 38 | return time.strftime('%H:%M:%S.{}'.format(ms_time % 1000), time.gmtime(ms_time / 1000.0)) 39 | 40 | @staticmethod 41 | def parse_ms(str_time): 42 | """ 43 | Convert time '00:00:00[(,|.)000]' to integer milliseconds 44 | :param str_time: 45 | :return: milliseconds 46 | """ 47 | "convert from srt time format (0...999) to stl one (0...25)" 48 | st = re.split('[.,]', str_time) 49 | tm = st[0].split(':') 50 | ms = int(st[1]) if len(st) > 1 else 0 51 | if len(tm) != 3: 52 | raise ValueError("Expected string format 00:00:00[.000] or 00:00:00[,000]") 53 | return (int(tm[0]) * 3600 + int(tm[1]) * 60 + int(tm[2])) * 1000 + ms 54 | 55 | 56 | class SrtUtil(object): 57 | @staticmethod 58 | def parse_srt(filename): 59 | """ 60 | Parse a srt file into a list of objects (number, start, end, content) 61 | From https://stackoverflow.com/questions/23620423/parsing-a-srt-file-with-regex/23620620 62 | :param filename: 63 | :return: 64 | """ 65 | encoding = 'ascii' 66 | with open(filename, 'rb') as f: 67 | try: 68 | encoding = chardet.detect(f.read(10))['encoding'] 69 | except: 70 | traceback.print_exc() 71 | res = [] 72 | with open(filename, 'r', encoding=encoding) as f: 73 | try: 74 | res = [list(g) for b, g in groupby(f, lambda x: bool(x.strip())) if b] 75 | except: 76 | traceback.print_exc() 77 | 78 | # parse 79 | Subtitle = namedtuple('Subtitle', 'number start end content') 80 | subs = [] 81 | for sub in res: 82 | if len(sub) >= 3: # not strictly necessary, but better safe than sorry 83 | sub = [x.strip() for x in sub] 84 | number, start_end, content = sub[0], sub[1], sub[2:] # py 2 syntax 85 | start, end = start_end.split(' --> ') 86 | subs.append(Subtitle(number, TimeUtil.parse_ms(start), TimeUtil.parse_ms(end), content)) 87 | return subs 88 | 89 | 90 | class ImageUtil(object): 91 | @staticmethod 92 | def vertical_stitch(images, output): 93 | if images is None or len(images) < 1: 94 | return False 95 | ImageFile = namedtuple('ImageFile', 'filename width height') 96 | width = max(images, key=lambda x: x.width).width 97 | height = sum(map(lambda x: x.down - x.up, images)) 98 | result = Image.new('RGB', (width, height)) 99 | y_offset = 0 100 | for image in images: 101 | img = Image.open(image.image_file) 102 | cropped = img.crop((0, image.up, image.width, image.down)) 103 | result.paste(cropped, (0, y_offset)) 104 | y_offset += image.down - image.up 105 | 106 | result.save(output) 107 | return ImageFile(output, width, height) 108 | 109 | @staticmethod 110 | def get_thumbnail(image): 111 | result = Image.new('RGB', (image.width, image.down - image.up)) 112 | temp_image = Image.open(image.image_file) 113 | cropped = temp_image.crop((0, image.up, image.width, image.down)) 114 | result.paste(cropped, (0, 0)) 115 | temp_file = TempFileUtil.get_temp_file(prefix="thumbnail_", suffix=".jpg") 116 | result.save(temp_file) 117 | return temp_file 118 | 119 | 120 | class TempFileUtil(object): 121 | @staticmethod 122 | def get_temp_file(prefix='', suffix=''): 123 | temp_file = tempfile.NamedTemporaryFile(prefix=prefix, suffix=suffix) 124 | temp_file.close() 125 | return temp_file.name 126 | -------------------------------------------------------------------------------- /src/pluto/ui/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/shootsoft/PlutoVideoSnapshoter/20eb4bc9d4eb6f090c713dce23077173d7ed8e5e/src/pluto/ui/__init__.py -------------------------------------------------------------------------------- /src/pluto/ui/player/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/shootsoft/PlutoVideoSnapshoter/20eb4bc9d4eb6f090c713dce23077173d7ed8e5e/src/pluto/ui/player/__init__.py -------------------------------------------------------------------------------- /src/pluto/ui/player/controllers.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | import os 4 | import traceback 5 | 6 | from PyQt5.QtCore import QUrl, QDir 7 | from PyQt5.QtMultimedia import QMediaContent, QMediaPlayer, QSound 8 | from PyQt5.QtWidgets import QFileDialog, QMessageBox, QApplication 9 | 10 | from pluto.common.utils import TimeUtil 11 | from pluto.common.media.snapshot import Snapshot 12 | from pluto.ui.player.models import SnapshotModel 13 | from pluto.ui.player.views import PlayerWindow 14 | 15 | from pluto.ui.qt.mvc.controllers import Controller 16 | from pluto.ui.qt.qtutils import QtUtil 17 | 18 | 19 | class PlayerController(Controller): 20 | def __init__(self, router): 21 | super(PlayerController, self).__init__(router, PlayerWindow()) 22 | self.model = SnapshotModel() 23 | self.snapshot = Snapshot() 24 | self.snapshotSound = QSound(QtUtil.resource_path(os.path.join("windows", "player", "snapshot.wav"))) 25 | self.__bind(self.view) 26 | 27 | def __bind(self, view): 28 | view.openButton.clicked.connect(self.on_open) 29 | view.playButton.clicked.connect(self.on_play) 30 | view.snapshotButton.clicked.connect(self.on_snapshot) 31 | view.videoBackgroundWidget.mouseReleaseEvent = self.on_video_clicked 32 | view.videoWidget.mouseReleaseEvent = self.on_video_clicked 33 | 34 | view.outputButton.clicked.connect(self.on_set_output) 35 | view.subtitleSelectButton.clicked.connect(self.on_set_subtitle) 36 | 37 | view.startButton.clicked.connect(self.on_set_start) 38 | view.endButton.clicked.connect(self.on_set_end) 39 | view.autoSnapshotButton.clicked.connect(self.on_run_snapshots) 40 | view.imageStitchingButton.clicked.connect(self.on_stitch_snapshots) 41 | 42 | self.view.mediaPlayer.stateChanged.connect(self.on_media_state_changed) 43 | self.view.mediaPlayer.positionChanged.connect(self.on_position_changed) 44 | self.view.mediaPlayer.durationChanged.connect(self.on_duration_changed) 45 | self.view.mediaPositionSlider.sliderMoved.connect(self.on_set_position) 46 | 47 | self.view.videoBackgroundWidget.resizeEvent = self.on_video_resize 48 | 49 | def on_open(self): 50 | file_name, _ = QFileDialog.getOpenFileName(self.view, "Open Video", QDir.homePath(), 51 | filter="*.mp4;*.avi;*.mpeg;*.mpg;*.mov;*.m4v;*.mtk") 52 | if file_name != '': 53 | if not self.snapshot.load_video(file_name): 54 | QMessageBox.warning(self.view, 'Error', "Can't open this file.") 55 | return 56 | self.model = SnapshotModel() 57 | self.model.filename = file_name 58 | self.view.outputLineEdit.setText(self.model.output) 59 | self.view.playButton.setEnabled(True) 60 | self.view.snapshotButton.setEnabled(True) 61 | self.view.subtitleSelectButton.setEnabled(True) 62 | self.view.startButton.setEnabled(True) 63 | self.view.endButton.setEnabled(True) 64 | self.view.outputButton.setEnabled(True) 65 | if self.snapshot.srt_file: 66 | self.model.subtitle = self.snapshot.srt_file 67 | self.view.subtitleLineEdit.setText(self.snapshot.srt_file) 68 | self.view.autoSnapshotButton.setEnabled(True) 69 | self.set_video(file_name, self.snapshot.width, self.snapshot.height) 70 | 71 | def on_video_clicked(self, *event): 72 | if self.model.filename: 73 | self.on_play() 74 | else: 75 | self.on_open() 76 | 77 | def on_play(self, ): 78 | self.view.mediaPositionSlider.setEnabled(True) 79 | if self.model.isPlaying: 80 | self.view.mediaPlayer.pause() 81 | self.view.update_icon(self.view.playButton, "play") 82 | else: 83 | self.view.mediaPlayer.play() 84 | self.view.update_icon(self.view.playButton, "pause") 85 | self.model.isPlaying = not self.model.isPlaying 86 | self.view.videoWidget.show() 87 | 88 | def on_set_output(self): 89 | folder = QFileDialog.getExistingDirectory(self.view, "Select image output path", self.model.output) 90 | if folder: 91 | self.model.output = folder 92 | self.view.outputLineEdit.setText(self.model.output) 93 | 94 | def on_set_subtitle(self): 95 | file_name, _ = QFileDialog.getOpenFileName(self.view, "Open Subtitle", QDir.homePath(), 96 | filter="*.srt") 97 | if file_name != '': 98 | self.snapshot.load_srt(file_name) 99 | self.view.subtitleLineEdit.setText(file_name) 100 | self.model.subtitle = file_name 101 | self.view.autoSnapshotButton.setEnabled(True) 102 | 103 | def on_stitch_snapshots(self): 104 | if self.model.isPlaying: 105 | self.on_play() 106 | self.go('image') 107 | 108 | def set_video(self, file_name, width, height): 109 | self.view.mediaPlayer.setMedia(QMediaContent(QUrl.fromLocalFile(file_name))) 110 | QtUtil.central(self.view.videoWidget, self.view.videoBackgroundWidget, width, height, 2, 2) 111 | self.on_play() 112 | 113 | def on_media_state_changed(self, state): 114 | # TODO: find a method to update view instead of these tricks 115 | # After pause, the video will re-scale to original size, this is used to fix this. 116 | QtUtil.central(self.view.videoWidget, self.view.videoBackgroundWidget, 117 | self.snapshot.width, self.snapshot.height, 2, 2) 118 | if state == 0: 119 | # TODO: find a method to update view instead of these tricks 120 | self.view.videoWidget.hide() 121 | self.model.isPlaying = False 122 | self.view.update_icon(self.view.playButton, "play") 123 | self.view.mediaPositionSlider.hide() 124 | self.view.mediaPositionSlider.setValue(0) 125 | self.view.mediaPositionSlider.show() 126 | elif state == QMediaPlayer.PausedState: 127 | self.view.update_icon(self.view.playButton, "play") 128 | else: 129 | self.view.update_icon(self.view.playButton, "pause") 130 | 131 | def on_video_resize(self, event): 132 | QtUtil.central(self.view.videoWidget, self.view.videoBackgroundWidget, 133 | self.snapshot.width, self.snapshot.height) 134 | 135 | def on_position_changed(self, position): 136 | # TODO: find a method to update view instead of these tricks 137 | QtUtil.central(self.view.videoWidget, self.view.videoBackgroundWidget, 138 | self.snapshot.width, self.snapshot.height) 139 | self.view.mediaPositionSlider.setValue(position) 140 | self.view.progressLabel.setText(TimeUtil.format_ms(position)) 141 | 142 | def on_duration_changed(self, duration): 143 | print(duration) 144 | self.view.mediaPositionSlider.setRange(0, duration) 145 | self.model.end = duration 146 | self.view.endLabel.setText(TimeUtil.format_ms(duration)) 147 | 148 | def on_set_position(self, position): 149 | self.view.mediaPlayer.setPosition(position) 150 | 151 | def on_handle_error(self, error): 152 | self.view.playButton.setEnabled(False) 153 | self.view.snapshotButton.setEnabled(False) 154 | self.view.startButton.setEnabled(False) 155 | self.view.endButton.setEnabled(False) 156 | self.view.outputButton.setEnabled(False) 157 | self.view.subtitleButton.setEnabled(False) 158 | self.view.runTaskButton(False) 159 | self.view.messageLabel.setText("Error: " + self.view.mediaPlayer.errorString()) 160 | print(error) 161 | 162 | def on_snapshot(self): 163 | position = self.view.mediaPlayer.position() 164 | output_file = os.path.join(self.model.output, self.model.file + "_manual_%s.jpg" % 165 | TimeUtil.format_ms(position).replace(":", "_")) 166 | # print(output_file) 167 | try: 168 | if self.snapshot.snapshot(position, output_file): 169 | self.snapshotSound.play() 170 | self.router.notify('snapshot', output_file) 171 | self.view.messageLabel.setText("Saved " + output_file) 172 | except: 173 | traceback.print_exc() 174 | QMessageBox.warning(self.view, 'Error', 'Snapshot failed.') 175 | 176 | def on_set_start(self): 177 | self.model.start = self.view.mediaPlayer.position() 178 | self.view.startLabel.setText(TimeUtil.format_ms(self.model.start)) 179 | 180 | def on_set_end(self): 181 | self.model.end = self.view.mediaPlayer.position() 182 | self.view.endLabel.setText(TimeUtil.format_ms(self.model.end)) 183 | 184 | def on_run_snapshots(self): 185 | if self.view.mediaPlayer.state() == QMediaPlayer.PlayingState: 186 | self.on_play() 187 | 188 | count = self.snapshot.estimate(self.model.start, self.model.end) 189 | quit_msg = "Are you sure you want to run auto snapshot (start %s ~ end %s )?\n" \ 190 | "Image count estimation %s" % (TimeUtil.format_ms(self.model.start), 191 | TimeUtil.format_ms(self.model.end), count) 192 | reply = QMessageBox.question(self.view, 'Run Auto Snapshot', quit_msg, QMessageBox.Yes, 193 | QMessageBox.No) 194 | if reply == QMessageBox.Yes: 195 | self.lock_ui(False) 196 | self.model.task_progress = 0 197 | try: 198 | self.snapshot.snapshot_range(self.model.output, self.model.start, self.model.end, 199 | self.show_progress, self.show_complete) 200 | except: 201 | traceback.print_exc() 202 | QMessageBox.warning(self.view, 'Error', 'Snapshot failed.') 203 | self.lock_ui(True) 204 | 205 | def show_progress(self, total, current, position, output_file, output_result): 206 | QApplication.processEvents() 207 | self.router.notify('snapshot', output_file) 208 | percentage = int(current * 100 / total) 209 | if percentage != self.model.task_progress: 210 | self.model.task_progress = percentage 211 | self.view.messageLabel.setText("Progress %s%%" % percentage) 212 | print("total=%s, current=%s, position=%s, output_file=%s, output_result=%s" % ( 213 | total, current, position, output_file, output_result)) 214 | 215 | def show_complete(self, total, success): 216 | self.view.messageLabel.setText("Progress 100%%, all done, %s files expected, %s files saved" % (total, success)) 217 | 218 | def lock_ui(self, locked_status): 219 | self.view.playButton.setEnabled(locked_status) 220 | self.view.openButton.setEnabled(locked_status) 221 | self.view.outputButton.setEnabled(locked_status) 222 | self.view.subtitleSelectButton.setEnabled(locked_status) 223 | self.view.snapshotButton.setEnabled(locked_status) 224 | self.view.startButton.setEnabled(locked_status) 225 | self.view.endButton.setEnabled(locked_status) 226 | self.view.autoSnapshotButton.setEnabled(locked_status) 227 | self.view.mediaPositionSlider.setEnabled(locked_status) 228 | self.view.imageStitchingButton.setEnabled(locked_status) 229 | -------------------------------------------------------------------------------- /src/pluto/ui/player/models.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | import os 4 | 5 | 6 | class SnapshotModel(object): 7 | def __init__(self): 8 | self.filename = "" 9 | self.file = "" 10 | self.folder = "" 11 | self.subtitle = "" 12 | self.start = 0 13 | self.end = 0 14 | self.output = "" 15 | self.isPlaying = False 16 | self.task_progress = 0 17 | 18 | def __setattr__(self, key, value): 19 | self.__dict__[key] = value 20 | if key == 'filename' and value != "": 21 | self.folder, self.file = os.path.split(value) 22 | if self.__dict__['output'] == "": 23 | self.__dict__['output'] = os.path.join(self.folder, "clips") 24 | -------------------------------------------------------------------------------- /src/pluto/ui/player/views.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | import os 3 | 4 | from PyQt5.QtMultimedia import QMediaPlayer 5 | from PyQt5.QtMultimediaWidgets import QVideoWidget 6 | from PyQt5.QtWidgets import QLabel 7 | 8 | from pluto.ui.qt.mvc.views import View 9 | 10 | 11 | class PlayerWindow(View): 12 | def __init__(self): 13 | super(PlayerWindow, self).__init__(ui_file="video_player_window.ui") 14 | self.videoWidget = QVideoWidget(self.videoBackgroundWidget) 15 | self.mediaPlayer = QMediaPlayer(None, QMediaPlayer.VideoSurface) 16 | self.mediaPlayer.setVideoOutput(self.videoWidget) 17 | self.videoBackgroundWidget.setStyleSheet("QWidget { background-color : black;}") 18 | self.progressLabel = QLabel("00:00:00") 19 | self.statusbar.addWidget(self.progressLabel) 20 | self.messageLabel = QLabel("") 21 | self.statusbar.addWidget(self.messageLabel) 22 | self.mediaPositionSlider.setEnabled(False) 23 | self.mediaPositionSlider.setStyleSheet(""" 24 | QSlider::handle:horizontal { 25 | background: qlineargradient(x1:0, y1:0, x2:1, y2:1, stop:0 #b4b4b4, stop:1 #8f8f8f); 26 | border: 0px solid #5c5c5c; 27 | width: 16px; 28 | margin: -2px -2px -2px -1px; 29 | border-radius: 1px; 30 | } 31 | 32 | QSlider::sub-page:horizontal{ 33 | background:#3396DA; 34 | } 35 | """) 36 | self.__init_icons() 37 | self.update_icon(self.openButton, "open") 38 | self.update_icon(self.playButton, "play") 39 | self.update_icon(self.snapshotButton, "snapshot") 40 | self.update_icon(self.startButton, "start") 41 | self.update_icon(self.endButton, "end") 42 | self.update_icon(self.autoSnapshotButton, "auto") 43 | self.update_icon(self.imageStitchingButton, "stitching") 44 | 45 | def __init_icons(self): 46 | player = os.path.join("windows", "player") 47 | self.add_icon("open.svg", player) 48 | self.add_icon("play.svg", player) 49 | self.add_icon("pause.svg", player) 50 | self.add_icon("snapshot.svg", player) 51 | self.add_icon("start.svg", player) 52 | self.add_icon("end.svg", player) 53 | self.add_icon("auto.svg", player) 54 | self.add_icon("stitching.svg", player) 55 | 56 | -------------------------------------------------------------------------------- /src/pluto/ui/qt/ListWidgetItem.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | from PyQt5.QtWidgets import QListWidgetItem 4 | 5 | 6 | class ListWidgetItem(QListWidgetItem): 7 | def __init__(self, *__args): 8 | super(ListWidgetItem, self).__init__(*__args) 9 | self.storage = None 10 | 11 | def set_storage(self, val): 12 | self.storage = val 13 | 14 | def get_storage(self): 15 | return self.storage 16 | 17 | def refresh_ui(self): 18 | self.setData(0, str(self.storage)) 19 | -------------------------------------------------------------------------------- /src/pluto/ui/qt/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/shootsoft/PlutoVideoSnapshoter/20eb4bc9d4eb6f090c713dce23077173d7ed8e5e/src/pluto/ui/qt/__init__.py -------------------------------------------------------------------------------- /src/pluto/ui/qt/mvc/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/shootsoft/PlutoVideoSnapshoter/20eb4bc9d4eb6f090c713dce23077173d7ed8e5e/src/pluto/ui/qt/mvc/__init__.py -------------------------------------------------------------------------------- /src/pluto/ui/qt/mvc/controllers.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | 4 | class Controller(object): 5 | def __init__(self, router, view): 6 | self.router = router 7 | self.view = view 8 | 9 | def show(self): 10 | if self.view: 11 | self.view.show() 12 | else: 13 | raise Exception("None view") 14 | 15 | def hide(self): 16 | if self.view: 17 | self.view.hide() 18 | else: 19 | raise Exception("None view") 20 | 21 | def go(self, ctrl_name, hide_self=True): 22 | self.router.go(ctrl_name) 23 | if hide_self: 24 | self.hide() 25 | -------------------------------------------------------------------------------- /src/pluto/ui/qt/mvc/routers.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | 4 | class Router(object): 5 | def __init__(self, app): 6 | self.app = app 7 | self.controllers = dict() 8 | self.topic_subscribers = dict() 9 | 10 | def add_ctrl(self, name, ctrl): 11 | self.controllers[name] = ctrl 12 | 13 | def go(self, ctrl_name): 14 | if ctrl_name in self.controllers: 15 | self.controllers[ctrl_name].show() 16 | 17 | def notify(self, topic, message): 18 | if topic in self.topic_subscribers: 19 | for ctrl in self.topic_subscribers[topic]: 20 | ctrl.notify(topic, message) 21 | 22 | def subscribe(self, topic, ctrl): 23 | if topic not in self.topic_subscribers: 24 | self.topic_subscribers[topic] = [] 25 | self.topic_subscribers[topic].append(ctrl) 26 | -------------------------------------------------------------------------------- /src/pluto/ui/qt/mvc/views.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | import os 3 | from PyQt5 import uic 4 | from PyQt5.QtWidgets import QMainWindow 5 | 6 | from pluto.ui.qt.qtutils import QtUtil 7 | 8 | 9 | class View(QMainWindow): 10 | def __init__(self, parent=None, ui_path="windows", ui_file=""): 11 | super(View, self).__init__(parent) 12 | self._icons = dict() 13 | if ui_file: 14 | uic.loadUi(QtUtil.resource_path(os.path.join(ui_path, ui_file)), self) 15 | 16 | def update_control_text(self, control, text): 17 | if control and control.setText: 18 | control.hide() 19 | control.setText(text) 20 | control.show() 21 | 22 | def add_icon(self, icon, path): 23 | name = icon.split(".")[0] 24 | self._icons[name] = QtUtil.icon(icon, path) 25 | 26 | def update_icon(self, control, icon): 27 | control.setIcon(self._icons[icon]) 28 | -------------------------------------------------------------------------------- /src/pluto/ui/qt/qtutils.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | import os 4 | 5 | import sys 6 | from PyQt5 import QtGui 7 | 8 | from PyQt5.QtGui import QPixmap 9 | 10 | from pluto.common.utils import SizeUtil 11 | 12 | 13 | class QtUtil(object): 14 | 15 | @staticmethod 16 | def preview_image(image_file, label, parent): 17 | if not os.path.isfile(image_file): 18 | return False 19 | image_preview = QPixmap(image_file) 20 | label.setPixmap(image_preview) 21 | QtUtil.central(label, parent, image_preview.width(), image_preview.height()) 22 | 23 | @staticmethod 24 | def central(child, parent, width, height, offset_x=0, offset_y=0): 25 | size = SizeUtil.fit(width - offset_x * 2, height - offset_y * 2, parent.width(), parent.height()) 26 | child.setGeometry((parent.width() - size.width) / 2 + offset_x, 27 | (parent.height() - size.height) / 2 + offset_y, 28 | size.width, size.height) 29 | 30 | @staticmethod 31 | def resource_path(relative_path): 32 | """ Get absolute path to resource, works for dev and for PyInstaller """ 33 | try: 34 | # PyInstaller creates a temp folder and stores path in _MEIPASS 35 | base_path = sys._MEIPASS 36 | except Exception: 37 | base_path = os.path.abspath(".") 38 | 39 | return os.path.join(base_path, relative_path) 40 | 41 | @staticmethod 42 | def icon(icon, folder="windows"): 43 | return QtGui.QIcon(QtUtil.resource_path(os.path.join(folder, icon))) 44 | -------------------------------------------------------------------------------- /src/pluto/ui/stitcher/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/shootsoft/PlutoVideoSnapshoter/20eb4bc9d4eb6f090c713dce23077173d7ed8e5e/src/pluto/ui/stitcher/__init__.py -------------------------------------------------------------------------------- /src/pluto/ui/stitcher/controllers.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | import os 4 | import traceback 5 | 6 | from PyQt5.QtCore import QDir, Qt 7 | from PyQt5.QtGui import QIcon 8 | from PyQt5.QtMultimedia import QSound 9 | from PyQt5.QtWidgets import (QFileDialog, QMessageBox, QApplication) 10 | 11 | from pluto.common.media.TextDetector import TextDetector 12 | from pluto.common.utils import ImageUtil, TempFileUtil 13 | from pluto.ui.qt.ListWidgetItem import ListWidgetItem 14 | from pluto.ui.qt.mvc.controllers import Controller 15 | from pluto.ui.qt.qtutils import QtUtil 16 | from pluto.ui.stitcher.models import ImageModel 17 | from pluto.ui.stitcher.views import ImageStitchingWindow 18 | 19 | 20 | class ImageStitchingController(Controller): 21 | def __init__(self, router): 22 | super(ImageStitchingController, self).__init__(router, ImageStitchingWindow()) 23 | self.current_image = None 24 | self.preview_image_file = None 25 | self.preview_mode = 0 26 | self.router.subscribe('snapshot', self) 27 | self.files = [] 28 | self.text_detector = TextDetector() 29 | self.magicSound = QSound(QtUtil.resource_path(os.path.join("windows", "stitching", "magic.wav"))) 30 | self.__bind(self.view) 31 | 32 | def __bind(self, view): 33 | view.goBackButton.clicked.connect(self.on_go_back) 34 | view.addButton.clicked.connect(self.on_add_item) 35 | view.autoDetectButton.clicked.connect(self.on_auto_detect) 36 | view.removeButton.clicked.connect(self.on_remove_item) 37 | view.saveButton.clicked.connect(self.on_save) 38 | view.imageListWidget.itemSelectionChanged.connect(self.on_item_selected) 39 | view.upVerticalSlider.sliderMoved.connect(self.on_up_moved) 40 | view.downVerticalSlider.sliderMoved.connect(self.on_down_moved) 41 | view.tabWidget.tabBarClicked.connect(self.on_tab_clicked) 42 | view.imageWidget.resizeEvent = self.on_item_preview_resize 43 | view.previewWidget.resizeEvent = self.on_output_preview_resize 44 | view.addAction.triggered.connect(self.on_add_item) 45 | view.autoDetectSelectedAction.triggered.connect(self.on_auto_detect) 46 | view.removeSelectedAction.triggered.connect(self.on_remove_item) 47 | view.previewSelectedAction.triggered.connect(self.on_preview_selected) 48 | view.previewAllAction.triggered.connect(self.on_preview_all) 49 | view.saveSelectedAction.triggered.connect(self.on_save_selected) 50 | view.saveAllAction.triggered.connect(self.on_save_all) 51 | 52 | def show(self): 53 | super(ImageStitchingController, self).show() 54 | if len(self.files) > 0: 55 | for image in self.files: 56 | self.add_image(image) 57 | self.files = [] 58 | 59 | def notify(self, topic, message): 60 | if topic == 'snapshot': 61 | self.files.append(message) 62 | 63 | def on_go_back(self): 64 | self.go('player') 65 | 66 | def on_add_item(self): 67 | file_names, _ = QFileDialog.getOpenFileNames(self.view, "Open Images", QDir.homePath(), 68 | filter="*.jpg;*.jpeg;*.png") 69 | if file_names: 70 | for image in file_names: 71 | self.add_image(image) 72 | self.view.statusBar().showMessage("%s images added." % len(file_names)) 73 | 74 | def on_remove_item(self): 75 | items = self.view.imageListWidget.selectedItems() 76 | if len(items) == 0: 77 | return 78 | result = QMessageBox.question(self.view, 'Run Auto Snapshort', 'Remove selected [%s] items?' % len(items), 79 | QMessageBox.Yes, QMessageBox.No) 80 | if result == QMessageBox.Yes: 81 | for item in items: 82 | index = self.view.imageListWidget.indexFromItem(item) 83 | self.view.imageListWidget.takeItem(index.row()) 84 | self.view.imageListWidget.repaint() 85 | self.view.statusBar().showMessage('Removed %s images.' % len(items)) 86 | self.view.update_view() 87 | 88 | def add_image(self, image_file): 89 | try: 90 | image = ImageModel(image_file) 91 | item = ListWidgetItem() 92 | icon = QIcon() 93 | icon.addFile(image_file) 94 | item.setTextAlignment(1) 95 | item.setIcon(icon) 96 | item.setData(Qt.DisplayRole, str(image)) 97 | item.setData(Qt.StatusTipRole, image_file) 98 | item.set_storage(image) 99 | self.view.imageListWidget.addItem(item) 100 | self.view.update_view() 101 | except: 102 | traceback.print_exc() 103 | 104 | def on_item_selected(self): 105 | items = self.view.imageListWidget.selectedItems() 106 | if len(items) == 0: 107 | self.view.upVerticalSlider.setValue(0) 108 | self.view.upVerticalSlider.setEnabled(False) 109 | self.view.downVerticalSlider.setEnabled(False) 110 | self.view.downVerticalSlider.setValue(0) 111 | self.view.imageLabel.hide() 112 | self.view.upImageLabel.hide() 113 | self.view.downImageLabel.hide() 114 | self.current_image = None 115 | self.view.statusBar().showMessage('') 116 | else: 117 | item = items[0] 118 | image = item.get_storage() 119 | self.current_image = image 120 | 121 | self.render_preview() 122 | self.view.imageLabel.show() 123 | self.view.upImageLabel.show() 124 | self.view.downImageLabel.show() 125 | self.view.upVerticalSlider.setEnabled(True) 126 | self.view.downVerticalSlider.setEnabled(True) 127 | self.view.statusBar().showMessage("%s images selected." % len(items)) 128 | self.view.update_view() 129 | 130 | def render_preview(self): 131 | image = self.current_image 132 | if image is None: 133 | return 134 | QtUtil.preview_image(image.image_file, self.view.imageLabel, self.view.imageWidget) 135 | self.set_image_up_shade(image) 136 | self.set_image_down_shade(image) 137 | self.view.upVerticalSlider.setRange(0, image.height) 138 | self.view.downVerticalSlider.setRange(0, image.height) 139 | self.view.upVerticalSlider.setValue(image.up) 140 | self.view.downVerticalSlider.setValue(image.height - image.down) 141 | 142 | def set_image_up_shade(self, image): 143 | height = int(self.view.imageLabel.height() * (image.up * 1.0 / image.height)) 144 | self.view.upImageLabel.setGeometry(self.view.imageLabel.x(), self.view.imageLabel.y(), 145 | self.view.imageLabel.width(), 146 | height) 147 | # print 'up x, y, width, height', self.view.upImageLabel.geometry() 148 | 149 | def set_image_down_shade(self, image): 150 | height = int(self.view.imageWidget.height() * ((image.height - image.down) * 1.0 / image.height)) 151 | self.view.downImageLabel.setGeometry(self.view.imageLabel.x(), 152 | self.view.imageLabel.y() + self.view.imageLabel.height() - height, 153 | self.view.imageLabel.width(), height) 154 | # print 'down x, y, width, height', self.view.downImageLabel.geometry() 155 | 156 | def on_up_moved(self, position): 157 | updated = False 158 | for item in self.view.imageListWidget.selectedItems(): 159 | image = item.get_storage() 160 | if position < image.down: 161 | image.up = position 162 | item.setData(0, str(image)) 163 | if not updated: 164 | updated = True 165 | self.set_image_up_shade(image) 166 | else: 167 | self.view.upVerticalSlider.setValue(image.up) 168 | break 169 | 170 | def on_down_moved(self, position): 171 | updated = False 172 | for item in self.view.imageListWidget.selectedItems(): 173 | image = item.get_storage() 174 | val = image.height - position 175 | if val > image.up: 176 | image.down = val 177 | item.setData(0, str(image)) 178 | if not updated: 179 | updated = True 180 | self.set_image_down_shade(image) 181 | else: 182 | self.view.downVerticalSlider.setValue(image.height - image.down) 183 | break 184 | 185 | def on_save(self): 186 | filename, _ = QFileDialog.getSaveFileName(self.view, "Save Image", QDir.homePath(), 187 | filter="*.jpg") 188 | if filename: 189 | images = self.get_images(self.preview_mode) 190 | ImageUtil.vertical_stitch(images, filename) 191 | self.view.statusBar().showMessage('Image %s saved.' % filename) 192 | else: 193 | self.view.statusBar().showMessage('Save cancelled.') 194 | 195 | def get_images(self, mode=0): 196 | images = [] 197 | if mode == 0: 198 | for i in range(self.view.imageListWidget.count()): 199 | item = self.view.imageListWidget.item(i) 200 | images.append(item.get_storage()) 201 | else: 202 | for item in self.view.imageListWidget.selectedItems(): 203 | images.append(item.get_storage()) 204 | 205 | return images 206 | 207 | def on_item_preview_resize(self, event): 208 | if self.current_image is not None: 209 | QtUtil.central(self.view.imageLabel, self.view.imageWidget, 210 | self.current_image.width, self.current_image.height) 211 | self.set_image_up_shade(self.current_image) 212 | self.set_image_down_shade(self.current_image) 213 | 214 | def on_tab_clicked(self, index): 215 | self.view.model.preview = index == 1 216 | self.view.statusBar().showMessage("Preview image." if self.view.model.preview else '') 217 | if self.view.model.preview: 218 | self.render_stitching_preview() 219 | self.view.update_view() 220 | 221 | def render_stitching_preview(self): 222 | images = self.get_images(self.preview_mode) 223 | if len(images) > 0: 224 | temp_file = TempFileUtil.get_temp_file(prefix="snapshot_preview_", suffix=".jpg") 225 | self.preview_image_file = ImageUtil.vertical_stitch(images, temp_file) 226 | QtUtil.preview_image(temp_file, self.view.previewLabel, self.view.previewWidget) 227 | os.remove(temp_file) 228 | else: 229 | self.preview_image_file = None 230 | self.view.previewLabel.clear() 231 | 232 | def on_output_preview_resize(self, event): 233 | if self.preview_image_file is not None: 234 | QtUtil.central(self.view.previewLabel, self.view.previewWidget, 235 | self.preview_image_file.width, self.preview_image_file.height) 236 | 237 | def on_preview_selected(self): 238 | self.view.tabWidget.setCurrentIndex(1) 239 | self.preview_mode = 1 240 | self.on_tab_clicked(1) 241 | 242 | def on_preview_all(self): 243 | self.view.tabWidget.setCurrentIndex(1) 244 | self.preview_mode = 0 245 | self.on_tab_clicked(1) 246 | 247 | def on_save_selected(self): 248 | self.preview_mode = 1 249 | self.on_save() 250 | 251 | def on_save_all(self): 252 | self.preview_mode = 0 253 | self.on_save() 254 | 255 | def on_auto_detect(self): 256 | message = "Auto detect subtitle positions for " 257 | items = [] 258 | skip_first = False 259 | if len(self.view.imageListWidget.selectedItems()) > 0: 260 | message += "selected images?" 261 | items = self.view.imageListWidget.selectedItems() 262 | else: 263 | message += "all images (except the head image)?" 264 | for i in range(self.view.imageListWidget.count()): 265 | items.append(self.view.imageListWidget.item(i)) 266 | skip_first = True 267 | reply = QMessageBox.question(self.view, 'Auto detect', message, QMessageBox.Yes, QMessageBox.No) 268 | if reply == QMessageBox.Yes: 269 | self.view.setEnabled(False) 270 | self.do_auto_detect(items, skip_first) 271 | QApplication.processEvents() 272 | self.magicSound.play() 273 | self.view.setEnabled(True) 274 | 275 | def do_auto_detect(self, items, skip_first=False): 276 | start = 1 if skip_first else 0 277 | for i in range(start, len(items)): 278 | img = items[i].get_storage() 279 | img.up, img.down = self.text_detector.detect_subtitle_range(img.image_file) 280 | # items[i].setData(0, str(img)) 281 | items[i].refresh_ui() 282 | self.render_preview() 283 | self.view.statusBar().showMessage("Please preview final image.") 284 | 285 | -------------------------------------------------------------------------------- /src/pluto/ui/stitcher/models.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | import os 4 | 5 | import cv2 6 | import numpy as np 7 | 8 | 9 | class ImageModel(object): 10 | def __init__(self, image_file=None): 11 | self.image_file = "" 12 | # self.image = None 13 | self.up = 0 14 | self.down = -1 15 | self.width = 0 16 | self.height = 0 17 | self.name = "" 18 | 19 | if image_file: 20 | self.set_file(image_file) 21 | 22 | def set_file(self, image_file): 23 | self.image_file = image_file 24 | image = cv2.imread(image_file) 25 | self.height = np.size(image, 0) 26 | self.width = np.size(image, 1) 27 | self.up = 0 28 | self.down = self.height 29 | self.name = os.path.basename(image_file) 30 | 31 | def __str__(self): 32 | return "up:%s down:%s %s" % (self.up, self.down, self.name) 33 | 34 | 35 | class ViewModel(object): 36 | 37 | def __init__(self): 38 | self.preview = False 39 | self.items_count = 0 40 | self.select_items_count = 0 41 | -------------------------------------------------------------------------------- /src/pluto/ui/stitcher/views.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | import os 3 | from PyQt5.QtCore import QSize 4 | from PyQt5.QtWidgets import (QAction, QMenu) 5 | 6 | from pluto.ui.qt.mvc.views import View 7 | from pluto.ui.stitcher.models import ViewModel 8 | 9 | 10 | class ImageStitchingWindow(View): 11 | def __init__(self): 12 | super(ImageStitchingWindow, self).__init__(ui_file="image_stitching_window.ui") 13 | self.imageListWidget.setIconSize(QSize(96, 96)) 14 | self.imageListWidget.resize(self.width() * 0.36, self.imageListWidget.height()) 15 | self.upImageLabel.setGeometry(0, 0, 0, 0) 16 | self.upImageLabel.setStyleSheet("QLabel { background-color : rgba(0,0,0,.8); opacity:0.3;}") 17 | self.upImageLabel.setText("") 18 | self.downImageLabel.setGeometry(0, 0, 0, 0) 19 | self.downImageLabel.setStyleSheet("QLabel { background-color : rgba(0,0,0,.8); opacity:0.3;}") 20 | self.downImageLabel.setText("") 21 | self.zoomInButton.hide() 22 | self.zoomOutButton.hide() 23 | self.downVerticalSlider.setValue(0) 24 | self.__init_icons() 25 | self.update_icon(self.goBackButton, "player") 26 | self.update_icon(self.autoDetectButton, "magic") 27 | self.update_icon(self.addButton, "add") 28 | self.update_icon(self.removeButton, "remove") 29 | self.update_icon(self.saveButton, "save") 30 | self.__init_context_menu() 31 | self.model = ViewModel() 32 | self.update_view() 33 | 34 | self.upVerticalSlider.setStyleSheet(""" 35 | QSlider::handle:vertical { 36 | background: qlineargradient(x1:0, y1:0, x2:1, y2:1, stop:0 #b4b4b4, stop:1 #8f8f8f); 37 | border: 0px solid #777; 38 | height: 16px; 39 | margin: -2px -2px -2px -2px; 40 | border-radius: 1px; 41 | } 42 | 43 | QSlider::sub-page:vertical{ 44 | background:#3396DA; 45 | } 46 | """) 47 | self.downVerticalSlider.setStyleSheet(""" 48 | QSlider::handle:vertical { 49 | background: qlineargradient(x1:0, y1:0, x2:1, y2:1, stop:0 #b4b4b4, stop:1 #8f8f8f); 50 | border: 0px solid #5c5c5c; 51 | height: 16px; 52 | margin: -2px -2px -2px -2px; 53 | border-radius: 1px; 54 | } 55 | 56 | QSlider::add-page:vertical{ 57 | background:#3396DA; 58 | } 59 | """) 60 | 61 | def __init_context_menu(self): 62 | self.contextMenu = QMenu() 63 | self.addAction = QAction("Add images", self) 64 | self.removeSelectedAction = QAction("Remove selected", self) 65 | self.autoDetectSelectedAction = QAction("Magic", self) 66 | self.update_icon(self.autoDetectSelectedAction, "magic") 67 | self.previewSelectedAction = QAction("Preview selected", self) 68 | self.previewAllAction = QAction("Preview all", self) 69 | self.saveSelectedAction = QAction("Save selected", self) 70 | self.saveAllAction = QAction("Save all", self) 71 | 72 | self.contextMenu.addAction(self.addAction) 73 | self.contextMenu.addAction(self.removeSelectedAction) 74 | self.contextMenu.addSeparator() 75 | self.contextMenu.addAction(self.autoDetectSelectedAction) 76 | self.contextMenu.addSeparator() 77 | self.contextMenu.addAction(self.previewSelectedAction) 78 | self.contextMenu.addAction(self.previewAllAction) 79 | self.contextMenu.addAction(self.saveSelectedAction) 80 | self.contextMenu.addAction(self.saveAllAction) 81 | self.imageListWidget.customContextMenuRequested.connect(self.on_open_menu) 82 | 83 | def on_open_menu(self, position): 84 | self.contextMenu.exec_(self.imageListWidget.mapToGlobal(position)) 85 | 86 | def __init_icons(self): 87 | folder = os.path.join("windows", "stitching") 88 | self.add_icon("player.svg", folder) 89 | self.add_icon("add.svg", folder) 90 | self.add_icon("remove.svg", folder) 91 | self.add_icon("magic.svg", folder) 92 | self.add_icon("save.svg", folder) 93 | 94 | def update_view(self): 95 | self.model.items_count = self.imageListWidget.count() 96 | self.model.select_items_count = len(self.imageListWidget.selectedItems()) 97 | 98 | status = self.model.items_count > 0 99 | self.autoDetectSelectedAction.setEnabled(status) 100 | self.autoDetectButton.setEnabled(status) 101 | self.saveButton.setEnabled(status) 102 | self.saveAllAction.setEnabled(status) 103 | self.previewAllAction.setEnabled(status) 104 | 105 | select_status = self.model.select_items_count > 0 106 | self.removeButton.setEnabled(select_status) 107 | self.removeSelectedAction.setEnabled(select_status) 108 | self.saveSelectedAction.setEnabled(select_status) 109 | self.previewSelectedAction.setEnabled(select_status) 110 | 111 | if self.model.preview: 112 | self.addButton.hide() 113 | self.removeButton.hide() 114 | self.autoDetectButton.hide() 115 | else: 116 | self.addButton.show() 117 | self.removeButton.show() 118 | self.autoDetectButton.show() 119 | -------------------------------------------------------------------------------- /src/windows/image_stitching_window.ui: -------------------------------------------------------------------------------- 1 | 2 | 3 | ImageStitchingWindow 4 | 5 | 6 | 7 | 0 8 | 0 9 | 850 10 | 500 11 | 12 | 13 | 14 | Image Stitching 15 | 16 | 17 | 18 | 19 | 0 20 | 0 21 | 22 | 23 | 24 | 25 | 26 | 27 | QLayout::SetDefaultConstraint 28 | 29 | 30 | 31 | 32 | 33 | 0 34 | 0 35 | 36 | 37 | 38 | 0 39 | 40 | 41 | 42 | Image source 43 | 44 | 45 | 46 | 47 | 48 | Qt::Horizontal 49 | 50 | 51 | 52 | 53 | 200 54 | 300 55 | 56 | 57 | 58 | Qt::CustomContextMenu 59 | 60 | 61 | QAbstractItemView::InternalMove 62 | 63 | 64 | QAbstractItemView::ExtendedSelection 65 | 66 | 67 | QAbstractItemView::SelectRows 68 | 69 | 70 | 71 | 72 | 73 | 74 | 75 | Configure subtitle area: 76 | 77 | 78 | 79 | 80 | 81 | 82 | 83 | 84 | false 85 | 86 | 87 | 100 88 | 89 | 90 | 0 91 | 92 | 93 | true 94 | 95 | 96 | Qt::Vertical 97 | 98 | 99 | true 100 | 101 | 102 | false 103 | 104 | 105 | 106 | 107 | 108 | 109 | 110 | 250 111 | 250 112 | 113 | 114 | 115 | 116 | 117 | 0 118 | 89 119 | 250 120 | 191 121 | 122 | 123 | 124 | 125 | 0 126 | 0 127 | 128 | 129 | 130 | 131 | 132 | 133 | true 134 | 135 | 136 | Qt::AlignCenter 137 | 138 | 139 | 140 | 141 | 142 | 10 143 | 10 144 | 221 145 | 21 146 | 147 | 148 | 149 | Up 150 | 151 | 152 | 153 | 154 | 155 | 10 156 | 30 157 | 231 158 | 21 159 | 160 | 161 | 162 | Down 163 | 164 | 165 | 166 | 167 | 168 | 169 | 170 | false 171 | 172 | 173 | 100 174 | 175 | 176 | 100 177 | 178 | 179 | true 180 | 181 | 182 | Qt::Vertical 183 | 184 | 185 | false 186 | 187 | 188 | false 189 | 190 | 191 | 192 | 193 | 194 | 195 | 196 | 197 | 198 | 199 | 200 | 201 | 202 | 203 | 204 | 205 | 206 | 207 | 208 | 209 | Preview 210 | 211 | 212 | 213 | 214 | 215 | 216 | 0 217 | 30 218 | 219 | 220 | 221 | 222 | 16777215 223 | 35 224 | 225 | 226 | 227 | 228 | 229 | 230 | false 231 | 232 | 233 | 234 | 0 235 | 32 236 | 237 | 238 | 239 | 240 | 16777215 241 | 32 242 | 243 | 244 | 245 | Zoom In 246 | 247 | 248 | 249 | 250 | 251 | 252 | false 253 | 254 | 255 | 256 | 0 257 | 32 258 | 259 | 260 | 261 | 262 | 16777215 263 | 32 264 | 265 | 266 | 267 | Zoom Out 268 | 269 | 270 | 271 | 272 | 273 | 274 | Qt::Horizontal 275 | 276 | 277 | 278 | 100 279 | 20 280 | 281 | 282 | 283 | 284 | 285 | 286 | 287 | 288 | 289 | 290 | 291 | 292 | 380 293 | 40 294 | 221 295 | 181 296 | 297 | 298 | 299 | 300 | 301 | 302 | true 303 | 304 | 305 | Qt::AlignCenter 306 | 307 | 308 | 309 | 310 | 311 | 312 | 313 | 314 | 315 | 316 | 317 | 318 | 319 | Qt::Horizontal 320 | 321 | 322 | QSizePolicy::Fixed 323 | 324 | 325 | 326 | 10 327 | 20 328 | 329 | 330 | 331 | 332 | 333 | 334 | 335 | Back 336 | 337 | 338 | 339 | 340 | 341 | 342 | Qt::Horizontal 343 | 344 | 345 | 346 | 40 347 | 20 348 | 349 | 350 | 351 | 352 | 353 | 354 | 355 | false 356 | 357 | 358 | Magic 359 | 360 | 361 | 362 | 363 | 364 | 365 | 366 | 32 367 | 0 368 | 369 | 370 | 371 | 372 | 32 373 | 16777215 374 | 375 | 376 | 377 | 378 | 379 | 380 | 381 | 382 | 383 | 384 | false 385 | 386 | 387 | 388 | 32 389 | 0 390 | 391 | 392 | 393 | 394 | 32 395 | 16777215 396 | 397 | 398 | 399 | 400 | 401 | 402 | 403 | 404 | 405 | 406 | false 407 | 408 | 409 | Save 410 | 411 | 412 | 413 | 414 | 415 | 416 | Qt::Horizontal 417 | 418 | 419 | QSizePolicy::Fixed 420 | 421 | 422 | 423 | 10 424 | 20 425 | 426 | 427 | 428 | 429 | 430 | 431 | 432 | 433 | 434 | 435 | 436 | 437 | 438 | 0 439 | 0 440 | 850 441 | 22 442 | 443 | 444 | 445 | 446 | 447 | 448 | goBackButton 449 | tabWidget 450 | imageListWidget 451 | saveButton 452 | removeButton 453 | addButton 454 | autoDetectButton 455 | downVerticalSlider 456 | upVerticalSlider 457 | 458 | 459 | 460 | 461 | -------------------------------------------------------------------------------- /src/windows/player/auto.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/windows/player/end.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/windows/player/open.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/windows/player/pause.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/windows/player/play.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/windows/player/snapshot.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/windows/player/snapshot.wav: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/shootsoft/PlutoVideoSnapshoter/20eb4bc9d4eb6f090c713dce23077173d7ed8e5e/src/windows/player/snapshot.wav -------------------------------------------------------------------------------- /src/windows/player/start.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/windows/player/stitching.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/windows/pluto.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/shootsoft/PlutoVideoSnapshoter/20eb4bc9d4eb6f090c713dce23077173d7ed8e5e/src/windows/pluto.png -------------------------------------------------------------------------------- /src/windows/resources.readme.txt: -------------------------------------------------------------------------------- 1 | Icons 2 | https://www.iconfinder.com/iconsets/snipicons 3 | https://www.iconfinder.com/iconsets/new-year-christmas-nativity-xmas-noel-yule 4 | 5 | Sounds 6 | https://freesound.org/ -------------------------------------------------------------------------------- /src/windows/stitching/add.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/windows/stitching/magic.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/windows/stitching/magic.wav: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/shootsoft/PlutoVideoSnapshoter/20eb4bc9d4eb6f090c713dce23077173d7ed8e5e/src/windows/stitching/magic.wav -------------------------------------------------------------------------------- /src/windows/stitching/player.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/windows/stitching/remove.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/windows/stitching/save.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/windows/video_player_window.ui: -------------------------------------------------------------------------------- 1 | 2 | 3 | MainWindow 4 | 5 | 6 | 7 | 0 8 | 0 9 | 611 10 | 562 11 | 12 | 13 | 14 | Pluto Video Snapshoter 15 | 16 | 17 | 18 | 19 | 0 20 | 21 | 22 | 0 23 | 24 | 25 | 0 26 | 27 | 28 | 29 | 30 | 31 | 400 32 | 250 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 0 42 | 200 43 | 44 | 45 | 46 | 47 | 16777215 48 | 200 49 | 50 | 51 | 52 | 53 | 0 54 | 55 | 56 | 12 57 | 58 | 59 | 0 60 | 61 | 62 | 0 63 | 64 | 65 | 66 | 67 | 0 68 | 69 | 70 | 71 | 72 | 73 | 32 74 | 0 75 | 76 | 77 | 78 | 79 | 32 80 | 16777215 81 | 82 | 83 | 84 | 85 | 86 | 87 | 88 | 89 | 90 | 91 | false 92 | 93 | 94 | 95 | 32 96 | 0 97 | 98 | 99 | 100 | 101 | 32 102 | 16777215 103 | 104 | 105 | 106 | 107 | 108 | 109 | 110 | 111 | 112 | 113 | false 114 | 115 | 116 | 117 | 32 118 | 0 119 | 120 | 121 | 122 | 123 | 32 124 | 16777215 125 | 126 | 127 | 128 | 129 | 130 | 131 | 132 | 133 | 134 | 135 | Qt::Horizontal 136 | 137 | 138 | QSizePolicy::Fixed 139 | 140 | 141 | 142 | 10 143 | 20 144 | 145 | 146 | 147 | 148 | 149 | 150 | 151 | Qt::Horizontal 152 | 153 | 154 | 155 | 156 | 157 | 158 | 159 | 160 | 0 161 | 162 | 163 | 164 | 165 | 166 | 110 167 | 0 168 | 169 | 170 | 171 | Ouput path 172 | 173 | 174 | Qt::AlignRight|Qt::AlignTrailing|Qt::AlignVCenter 175 | 176 | 177 | 178 | 179 | 180 | 181 | Qt::Horizontal 182 | 183 | 184 | QSizePolicy::Fixed 185 | 186 | 187 | 188 | 10 189 | 20 190 | 191 | 192 | 193 | 194 | 195 | 196 | 197 | true 198 | 199 | 200 | true 201 | 202 | 203 | 204 | 205 | 206 | 207 | false 208 | 209 | 210 | ... 211 | 212 | 213 | 214 | 215 | 216 | 217 | 218 | 219 | 0 220 | 221 | 222 | 223 | 224 | 225 | 110 226 | 0 227 | 228 | 229 | 230 | Subtitle file (*.srt) 231 | 232 | 233 | Qt::AlignRight|Qt::AlignTrailing|Qt::AlignVCenter 234 | 235 | 236 | 237 | 238 | 239 | 240 | Qt::Horizontal 241 | 242 | 243 | QSizePolicy::Fixed 244 | 245 | 246 | 247 | 10 248 | 20 249 | 250 | 251 | 252 | 253 | 254 | 255 | 256 | true 257 | 258 | 259 | true 260 | 261 | 262 | 263 | 264 | 265 | 266 | false 267 | 268 | 269 | ... 270 | 271 | 272 | 273 | 274 | 275 | 276 | 277 | 278 | 0 279 | 280 | 281 | 0 282 | 283 | 284 | 0 285 | 286 | 287 | 0 288 | 289 | 290 | 291 | 292 | false 293 | 294 | 295 | 296 | 32 297 | 0 298 | 299 | 300 | 301 | 302 | 32 303 | 16777215 304 | 305 | 306 | 307 | 308 | 309 | 310 | 311 | 312 | 313 | 314 | Qt::Horizontal 315 | 316 | 317 | QSizePolicy::Fixed 318 | 319 | 320 | 321 | 10 322 | 20 323 | 324 | 325 | 326 | 327 | 328 | 329 | 330 | 331 | 0 332 | 0 333 | 334 | 335 | 336 | 337 | 0 338 | 0 339 | 340 | 341 | 342 | 343 | 100 344 | 16777215 345 | 346 | 347 | 348 | 00:00:00 349 | 350 | 351 | 352 | 353 | 354 | 355 | Qt::Horizontal 356 | 357 | 358 | QSizePolicy::Fixed 359 | 360 | 361 | 362 | 10 363 | 20 364 | 365 | 366 | 367 | 368 | 369 | 370 | 371 | false 372 | 373 | 374 | 375 | 32 376 | 0 377 | 378 | 379 | 380 | 381 | 32 382 | 16777215 383 | 384 | 385 | 386 | 387 | 388 | 389 | 390 | 391 | 392 | 393 | Qt::Horizontal 394 | 395 | 396 | QSizePolicy::Fixed 397 | 398 | 399 | 400 | 10 401 | 20 402 | 403 | 404 | 405 | 406 | 407 | 408 | 409 | 410 | 0 411 | 0 412 | 413 | 414 | 415 | 416 | 0 417 | 0 418 | 419 | 420 | 421 | 422 | 100 423 | 16777215 424 | 425 | 426 | 427 | 00:00:00 428 | 429 | 430 | 431 | 432 | 433 | 434 | Qt::Horizontal 435 | 436 | 437 | QSizePolicy::Fixed 438 | 439 | 440 | 441 | 10 442 | 20 443 | 444 | 445 | 446 | 447 | 448 | 449 | 450 | false 451 | 452 | 453 | 454 | 140 455 | 0 456 | 457 | 458 | 459 | Auto Sanpshots 460 | 461 | 462 | 463 | 464 | 465 | 466 | Qt::Horizontal 467 | 468 | 469 | 470 | 10 471 | 20 472 | 473 | 474 | 475 | 476 | 477 | 478 | 479 | 480 | 146 481 | 0 482 | 483 | 484 | 485 | Image Stitching 486 | 487 | 488 | false 489 | 490 | 491 | 492 | 493 | 494 | 495 | 496 | 497 | 498 | 499 | 500 | 501 | 502 | 0 503 | 0 504 | 611 505 | 22 506 | 507 | 508 | 509 | 510 | 511 | 512 | 4096 513 | 16777215 514 | 515 | 516 | 517 | 518 | 519 | 520 | 521 | -------------------------------------------------------------------------------- /test/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/shootsoft/PlutoVideoSnapshoter/20eb4bc9d4eb6f090c713dce23077173d7ed8e5e/test/__init__.py -------------------------------------------------------------------------------- /test/files/empty/nosrt.avi: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/shootsoft/PlutoVideoSnapshoter/20eb4bc9d4eb6f090c713dce23077173d7ed8e5e/test/files/empty/nosrt.avi -------------------------------------------------------------------------------- /test/files/empty/video.avi: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/shootsoft/PlutoVideoSnapshoter/20eb4bc9d4eb6f090c713dce23077173d7ed8e5e/test/files/empty/video.avi -------------------------------------------------------------------------------- /test/files/empty/video.srt: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/shootsoft/PlutoVideoSnapshoter/20eb4bc9d4eb6f090c713dce23077173d7ed8e5e/test/files/empty/video.srt -------------------------------------------------------------------------------- /test/files/images/1.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/shootsoft/PlutoVideoSnapshoter/20eb4bc9d4eb6f090c713dce23077173d7ed8e5e/test/files/images/1.jpg -------------------------------------------------------------------------------- /test/files/images/2.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/shootsoft/PlutoVideoSnapshoter/20eb4bc9d4eb6f090c713dce23077173d7ed8e5e/test/files/images/2.jpg -------------------------------------------------------------------------------- /test/files/images/expected.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/shootsoft/PlutoVideoSnapshoter/20eb4bc9d4eb6f090c713dce23077173d7ed8e5e/test/files/images/expected.jpg -------------------------------------------------------------------------------- /test/files/test.srt: -------------------------------------------------------------------------------- 1 | 1 2 | 00:02:43,759 --> 00:02:50,919 3 | Just click on that and just Ctlr + V. Once 4 | you do that just hit Enter and automatically 5 | 6 | 1 7 | 00:02:50,919 --> 00:02:56,679 8 | the transcript for the whole video is unleashed. 9 | 10 | 1 11 | 00:02:56,680 --> 00:03:02,299 12 | The next process is either just right clicking 13 | and saying Save As. -------------------------------------------------------------------------------- /test/files/video/A Message from President-Elect Donald J- Trump.mp4: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/shootsoft/PlutoVideoSnapshoter/20eb4bc9d4eb6f090c713dce23077173d7ed8e5e/test/files/video/A Message from President-Elect Donald J- Trump.mp4 -------------------------------------------------------------------------------- /test/files/video/A Message from President-Elect Donald J- Trump.mp4.srt: -------------------------------------------------------------------------------- 1 | 1 2 | 00:00:00,030 --> 00:00:01,770 3 | today I would like to provide the 4 | 5 | 2 6 | 00:00:01,770 --> 00:00:03,899 7 | American people with an update on the 8 | 9 | 3 10 | 00:00:03,899 --> 00:00:06,240 11 | White House transition and our policy 12 | 13 | 4 14 | 00:00:06,240 --> 00:00:08,820 15 | plans for the first 100 days our 16 | 17 | 5 18 | 00:00:08,820 --> 00:00:11,190 19 | transition team is working very smoothly 20 | 21 | 6 22 | 00:00:11,190 --> 00:00:14,490 23 | efficiently and effectively truly great 24 | 25 | 7 26 | 00:00:14,490 --> 00:00:16,800 27 | and talented men and women patriots 28 | 29 | 8 30 | 00:00:16,800 --> 00:00:19,289 31 | indeed are being brought in and many 32 | 33 | 9 34 | 00:00:19,289 --> 00:00:20,910 35 | will soon be a part of our government 36 | 37 | 10 38 | 00:00:20,910 --> 00:00:23,510 39 | helping us to make America great again 40 | 41 | 11 42 | 00:00:23,510 --> 00:00:26,640 43 | my agenda will be based on a simple core 44 | 45 | 12 46 | 00:00:26,640 --> 00:00:29,789 47 | principle putting America first whether 48 | 49 | 13 50 | 00:00:29,789 --> 00:00:32,550 51 | it's producing steel building cars or 52 | 53 | 14 54 | 00:00:32,550 --> 00:00:34,710 55 | curing disease I want the next 56 | 57 | 15 58 | 00:00:34,710 --> 00:00:36,930 59 | generation of production and innovation 60 | 61 | 16 62 | 00:00:36,930 --> 00:00:39,660 63 | to happen right here on our great 64 | 65 | 17 66 | 00:00:39,660 --> 00:00:42,540 67 | homeland America creating wealth and 68 | 69 | 18 70 | 00:00:42,540 --> 00:00:45,270 71 | jobs for American workers as part of 72 | 73 | 19 74 | 00:00:45,270 --> 00:00:47,460 75 | this plan I've asked my transition team 76 | 77 | 20 78 | 00:00:47,460 --> 00:00:50,039 79 | to develop a list of executive actions 80 | 81 | 21 82 | 00:00:50,039 --> 00:00:53,039 83 | we can take on day one to restore a laws 84 | 85 | 22 86 | 00:00:53,039 --> 00:00:55,890 87 | and bring back our jobs it's about time 88 | 89 | 23 90 | 00:00:55,890 --> 00:00:59,280 91 | these include the following on trade I'm 92 | 93 | 24 94 | 00:00:59,280 --> 00:01:01,289 95 | going to issue our notification of 96 | 97 | 25 98 | 00:01:01,289 --> 00:01:03,180 99 | intent to withdraw from the 100 | 101 | 26 102 | 00:01:03,180 --> 00:01:06,030 103 | trans-pacific partnership a potential 104 | 105 | 27 106 | 00:01:06,030 --> 00:01:09,119 107 | disaster for our country instead we will 108 | 109 | 28 110 | 00:01:09,119 --> 00:01:11,310 111 | negotiate fair bilateral trade deals 112 | 113 | 29 114 | 00:01:11,310 --> 00:01:13,590 115 | that bring jobs and industry back onto 116 | 117 | 30 118 | 00:01:13,590 --> 00:01:16,830 119 | American shores on energy I will cancel 120 | 121 | 31 122 | 00:01:16,830 --> 00:01:18,780 123 | job-killing restrictions on the 124 | 125 | 32 126 | 00:01:18,780 --> 00:01:21,360 127 | production of American energy including 128 | 129 | 33 130 | 00:01:21,360 --> 00:01:24,000 131 | shale energy and clean coal creating 132 | 133 | 34 134 | 00:01:24,000 --> 00:01:26,670 135 | many millions of high-paying jobs that's 136 | 137 | 35 138 | 00:01:26,670 --> 00:01:29,640 139 | what we want that's what we've been 140 | 141 | 36 142 | 00:01:29,640 --> 00:01:32,189 143 | waiting for on regulation I will 144 | 145 | 37 146 | 00:01:32,189 --> 00:01:33,960 147 | formulate a rule which says that for 148 | 149 | 38 150 | 00:01:33,960 --> 00:01:36,750 151 | every one new regulation too old 152 | 153 | 39 154 | 00:01:36,750 --> 00:01:40,140 155 | regulations must be eliminated so 156 | 157 | 40 158 | 00:01:40,140 --> 00:01:43,290 159 | important or national security I will 160 | 161 | 41 162 | 00:01:43,290 --> 00:01:45,420 163 | ask the Department of Defense and the 164 | 165 | 42 166 | 00:01:45,420 --> 00:01:47,430 167 | chairman of the Joint Chiefs of Staff to 168 | 169 | 43 170 | 00:01:47,430 --> 00:01:49,920 171 | develop a comprehensive plan to protect 172 | 173 | 44 174 | 00:01:49,920 --> 00:01:51,960 175 | America's vital infrastructure from 176 | 177 | 45 178 | 00:01:51,960 --> 00:01:54,960 179 | cyber attacks and all other form of 180 | 181 | 46 182 | 00:01:54,960 --> 00:01:58,290 183 | attacks on immigration I will direct the 184 | 185 | 47 186 | 00:01:58,290 --> 00:02:00,479 187 | Department of Labor to investigate all 188 | 189 | 48 190 | 00:02:00,479 --> 00:02:03,479 191 | abuses of visa programs that undercut 192 | 193 | 49 194 | 00:02:03,479 --> 00:02:06,990 195 | the American worker on ethics reform as 196 | 197 | 50 198 | 00:02:06,990 --> 00:02:09,390 199 | part of our plan to drain the swamp 200 | 201 | 51 202 | 00:02:09,390 --> 00:02:11,550 203 | we will impose a five-year ban on 204 | 205 | 52 206 | 00:02:11,550 --> 00:02:13,620 207 | executive officials becoming Lobby 208 | 209 | 53 210 | 00:02:13,620 --> 00:02:15,930 211 | is after they leave the administration 212 | 213 | 54 214 | 00:02:15,930 --> 00:02:18,510 215 | and a lifetime ban on executive 216 | 217 | 55 218 | 00:02:18,510 --> 00:02:20,700 219 | officials lobbying on behalf of a 220 | 221 | 56 222 | 00:02:20,700 --> 00:02:23,220 223 | foreign government these are just a few 224 | 225 | 57 226 | 00:02:23,220 --> 00:02:25,140 227 | of the steps we will take to reform 228 | 229 | 58 230 | 00:02:25,140 --> 00:02:27,959 231 | Washington and rebuild our middle class 232 | 233 | 59 234 | 00:02:27,959 --> 00:02:30,420 235 | I will provide more updates in the 236 | 237 | 60 238 | 00:02:30,420 --> 00:02:32,879 239 | coming days as we work together to make 240 | 241 | 61 242 | 00:02:32,879 --> 00:02:35,640 243 | America great again for everyone and I 244 | 245 | 62 246 | 00:02:35,640 --> 00:02:37,970 247 | mean everyone 248 | 249 | -------------------------------------------------------------------------------- /test/plutotest/TempFileTestCase.py: -------------------------------------------------------------------------------- 1 | import inspect 2 | import shutil 3 | import unittest 4 | 5 | import os 6 | 7 | from pluto.common.utils import TempFileUtil 8 | 9 | 10 | class TempFileTestCase(unittest.TestCase): 11 | 12 | def setUp(self): 13 | self.base_dir = os.path.realpath( 14 | os.path.join(os.path.dirname(inspect.getfile(inspect.currentframe())), '../files/')) 15 | self.__temp_files = [] 16 | 17 | def tearDown(self): 18 | for filename in self.__temp_files: 19 | if os.path.isfile(filename): 20 | os.remove(filename) 21 | elif os.path.isdir(filename): 22 | shutil.rmtree(filename) 23 | 24 | def get_temp_file(self, prefix="snapshot_unit_test_", suffix=".jpg", delete=True): 25 | temp_file = TempFileUtil.get_temp_file(prefix=prefix, suffix=suffix) 26 | if delete: 27 | self.__temp_files.append(temp_file) 28 | else: 29 | print('DEBUG: Generate temp file:', temp_file) 30 | return temp_file 31 | -------------------------------------------------------------------------------- /test/plutotest/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/shootsoft/PlutoVideoSnapshoter/20eb4bc9d4eb6f090c713dce23077173d7ed8e5e/test/plutotest/__init__.py -------------------------------------------------------------------------------- /test/plutotest/common/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/shootsoft/PlutoVideoSnapshoter/20eb4bc9d4eb6f090c713dce23077173d7ed8e5e/test/plutotest/common/__init__.py -------------------------------------------------------------------------------- /test/plutotest/common/media/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/shootsoft/PlutoVideoSnapshoter/20eb4bc9d4eb6f090c713dce23077173d7ed8e5e/test/plutotest/common/media/__init__.py -------------------------------------------------------------------------------- /test/plutotest/common/media/test_TextDetector.py: -------------------------------------------------------------------------------- 1 | import os 2 | import cv2 3 | 4 | from pluto.common.media.TextDetector import TextDetector, Rect 5 | 6 | from test.plutotest.TempFileTestCase import TempFileTestCase 7 | 8 | class TextDetectorTest(TempFileTestCase): 9 | 10 | def setUp(self): 11 | super(TextDetectorTest, self).setUp() 12 | self.text_detector = TextDetector() 13 | 14 | def test_detect_text_rect(self): 15 | image = cv2.imread(os.path.join(self.base_dir, 'images', '1.jpg')) 16 | # image = cv2.imread(os.path.join(self.base_dir, 'clips', 'tang.mp4_1731.jpg')) 17 | 18 | rectangles = self.text_detector.detect_text_rect(image) 19 | self.assertEqual(1, len(rectangles)) 20 | self.assertEqual(Rect([87, 268, 465, 47]), rectangles[0]) 21 | # Manual test 22 | #for rect in rectangles: 23 | # cv2.rectangle(image, (rect.x, rect.y), (rect.x + rect.width, rect.y + rect.height), (255, 0, 255), 2) 24 | # temp_file = self.get_temp_file() 25 | #cv2.imwrite(self.get_temp_file(delete=False), image) 26 | # actual = cv2.imread(temp_file) 27 | # Manual test 28 | # cv2.imshow('captcha_result', image) 29 | # cv2.waitKey() 30 | 31 | def test_text_subtitle(self): 32 | up, down = self.text_detector.detect_subtitle_range(os.path.join(self.base_dir, 'images', '1.jpg')) 33 | self.assertEqual((268, 345), (up, down)) 34 | -------------------------------------------------------------------------------- /test/plutotest/common/media/test_snapshot.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | import inspect 4 | import shutil 5 | import tempfile 6 | import unittest 7 | 8 | import os 9 | 10 | from pluto.common.utils import TempFileUtil 11 | from pluto.common.media.snapshot import Snapshot 12 | 13 | 14 | class SnapshotTest(unittest.TestCase): 15 | def setUp(self): 16 | self.snapshot = Snapshot() 17 | self.resource = os.path.realpath( 18 | os.path.join(os.path.dirname(inspect.getfile(inspect.currentframe())), '../../../files')) 19 | self.video_file = self.resource + "/video/A Message from President-Elect Donald J- Trump.mp4" 20 | self.temp_files = [] 21 | 22 | def tearDown(self): 23 | for filename in self.temp_files: 24 | if os.path.isfile(filename): 25 | os.remove(filename) 26 | elif os.path.isdir(filename): 27 | shutil.rmtree(filename) 28 | 29 | def test_detect_srt(self): 30 | self.assertIsNone(Snapshot.detect_srt("test.avi")) 31 | self.assertEqual(self.resource + "/video/A Message from President-Elect Donald J- Trump.mp4.srt", 32 | Snapshot.detect_srt(self.video_file)) 33 | self.assertEqual(self.resource + "/empty/video.srt", 34 | Snapshot.detect_srt(self.resource + "/empty/video.mp4")) 35 | 36 | def test_load_video_nosrt(self): 37 | video_file = self.resource + "/empty/nosrt.avi" 38 | self.snapshot.load_video(video_file) 39 | self.assertEqual("", self.snapshot.srt_file) 40 | self.assertEqual([], self.snapshot.srt_subs) 41 | self.assertEqual(video_file, self.snapshot.video_file) 42 | 43 | def test_load_video_no_srt(self): 44 | video_file = self.resource + "/empty/nosrt.avi" 45 | temp_file = self.get_temp_file() 46 | self.snapshot.load_video(video_file) 47 | self.assertEqual("", self.snapshot.srt_file) 48 | self.assertEqual([], self.snapshot.srt_subs) 49 | self.assertEqual(video_file, self.snapshot.video_file) 50 | self.assertEqual(0, self.snapshot.estimate(0, 100)) 51 | self.assertFalse(self.snapshot.snapshot(10, temp_file)) 52 | 53 | def test_load_estimate(self): 54 | self.snapshot.load_video(self.video_file) 55 | self.assertIsNotNone(self.snapshot.srt_file) 56 | self.assertEqual(62, self.snapshot.estimate()) 57 | self.assertEqual(11, self.snapshot.estimate(13000, 45000)) 58 | 59 | def test_load_snapshot(self): 60 | self.snapshot.load_video(self.video_file) 61 | temp_file = self.get_temp_file() 62 | self.assertTrue(self.snapshot.snapshot(13000, temp_file)) 63 | file_info1 = os.stat(temp_file) 64 | self.assertTrue(file_info1.st_size > 50000) 65 | 66 | def test_load_snapshot_failed(self): 67 | self.snapshot.load_video(self.video_file) 68 | with self.assertRaises(Exception) as context: 69 | self.snapshot.snapshot(13000, "test") 70 | 71 | def test_load_snapshot_range(self): 72 | self.snapshot.load_video(self.video_file) 73 | dir = self.get_temp_dir() 74 | total = self.snapshot.estimate(13000, 20000) 75 | self.assertFalse(self.snapshot.snapshot_range(dir, 13000, 20000, self.show_progress, self.show_complete)) 76 | print(os.listdir(dir)) 77 | self.assertEqual(total, len(os.listdir(dir))) 78 | 79 | def test_load_snapshot_range_failed(self): 80 | self.snapshot.load_video(self.video_file) 81 | dir = self.get_temp_dir() 82 | self.assertFalse(self.snapshot.snapshot_range(dir, 2000, 1000, self.show_progress, self.show_complete)) 83 | self.assertEqual(0, len(os.listdir(dir))) 84 | 85 | def show_progress(self, total, current, position, output_file, output_result): 86 | print("total=%s, current=%s, position=%s, output_file=%s, output_result=%s" % ( 87 | total, current, position, output_file, output_result)) 88 | self.assertTrue(output_result) 89 | 90 | def show_complete(self, total, success): 91 | self.assertEqual(total, success) 92 | 93 | def get_temp_file(self): 94 | temp_file = TempFileUtil.get_temp_file(suffix=".jpg", prefix="snapshot_unit_test_") 95 | self.temp_files.append(temp_file) 96 | return temp_file 97 | 98 | def get_temp_dir(self): 99 | temp_dir = tempfile.mkdtemp(prefix="snapshot_") 100 | self.temp_files.append(temp_dir) 101 | return temp_dir 102 | 103 | 104 | if __name__ == '__main__': 105 | unittest.main() 106 | -------------------------------------------------------------------------------- /test/plutotest/common/test_utils.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | import inspect 4 | import shutil 5 | import unittest 6 | 7 | import os 8 | from collections import namedtuple 9 | 10 | from PIL import Image 11 | 12 | from pluto.common.utils import SizeUtil, TimeUtil, SrtUtil, ImageUtil, TempFileUtil 13 | 14 | 15 | class SizeUtilTest(unittest.TestCase): 16 | def test_fit(self): 17 | new_size = SizeUtil.fit(1920, 1080, 600, 400) 18 | self.assertEqual(600, new_size.width) 19 | self.assertEqual(337, new_size.height) 20 | 21 | new_size = SizeUtil.fit(1920, 2392, 500, 200) 22 | self.assertEqual(160, new_size.width) 23 | self.assertEqual(200, new_size.height) 24 | 25 | new_size = SizeUtil.fit(10, 20, 500, 200) 26 | self.assertEqual(100, new_size.width) 27 | self.assertEqual(200, new_size.height) 28 | 29 | def test_fit_zero(self): 30 | new_size = SizeUtil.fit(0, 0, 600, 400) 31 | self.assertEqual(0, new_size.width) 32 | self.assertEqual(0, new_size.height) 33 | 34 | new_size = SizeUtil.fit(10, 10, 0, 0) 35 | self.assertEqual(0, new_size.width) 36 | self.assertEqual(0, new_size.height) 37 | 38 | 39 | class TimeUtilTest(unittest.TestCase): 40 | def test_format_ms(self): 41 | time_in_ms = 10120 42 | self.assertEqual("00:00:10.120", TimeUtil.format_ms(time_in_ms)) 43 | 44 | def test_parse_ms(self): 45 | self.assertEqual(5, TimeUtil.parse_ms("00:00:00,5")) 46 | self.assertEqual(10005, TimeUtil.parse_ms("00:00:10,5")) 47 | self.assertEqual(10005, TimeUtil.parse_ms("00:00:10.5")) 48 | self.assertEqual(1005, TimeUtil.parse_ms("00:00:1,5")) 49 | self.assertEqual(1000, TimeUtil.parse_ms("00:00:1")) 50 | 51 | def test_parse_ms_failed(self): 52 | self.assertRaises(ValueError, TimeUtil.parse_ms, "00:00") 53 | 54 | 55 | class SrtUtilTest(unittest.TestCase): 56 | def test_parse_srt(self): 57 | subs = SrtUtil.parse_srt(os.path.realpath( 58 | os.path.join(os.path.dirname(inspect.getfile(inspect.currentframe())), '../../files/test.srt'))) 59 | self.assertEqual(3, len(subs)) 60 | self.assertEqual('the transcript for the whole video is unleashed.', subs[1].content[0]) 61 | 62 | 63 | class ImageUtilTest(unittest.TestCase): 64 | def setUp(self): 65 | self.temp_files = [] 66 | 67 | def tearDown(self): 68 | for filename in self.temp_files: 69 | if os.path.isfile(filename): 70 | os.remove(filename) 71 | elif os.path.isdir(filename): 72 | shutil.rmtree(filename) 73 | 74 | def test_concat(self): 75 | base_dir = os.path.realpath( 76 | os.path.join(os.path.dirname(inspect.getfile(inspect.currentframe())), '../../files')) 77 | Img = namedtuple('Img', 'image_file width height up down') 78 | images = [ 79 | Img(os.path.join(base_dir, 'images/1.jpg'), 640, 360, 268, 320), 80 | Img(os.path.join(base_dir, 'images/2.jpg'), 640, 360, 268, 320), 81 | ] 82 | temp = self.get_temp_file() 83 | self.assertTrue(ImageUtil.vertical_stitch(images, temp)) 84 | self.assertEqual(Image.open(os.path.join(base_dir, 'images/expected.jpg')), Image.open(temp)) 85 | 86 | def get_temp_file(self): 87 | temp_file = TempFileUtil.get_temp_file(suffix=".jpg", prefix="snapshot_unit_test_") 88 | self.temp_files.append(temp_file) 89 | return temp_file 90 | 91 | 92 | if __name__ == '__main__': 93 | unittest.main() 94 | -------------------------------------------------------------------------------- /test/plutotest/ui/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/shootsoft/PlutoVideoSnapshoter/20eb4bc9d4eb6f090c713dce23077173d7ed8e5e/test/plutotest/ui/__init__.py --------------------------------------------------------------------------------