├── .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 | [](https://travis-ci.org/shootsoft/PlutoVideoSnapshoter) [](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 | 
22 |
23 | 
24 |
25 | 
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 | 
7 | 1. Launch application on macOS
8 | 
9 |
10 | ## Usage
11 |
12 | 1. Open a video
13 | 
14 | 1. (Optional) Select snapshot output folder
15 | 
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 | 
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 | 
20 | 
21 | 1. Select `Start` and `End`, run `Auto Anapshot` to take snapshots with each line of subtitles.
22 | 
23 | 1. Jump to snapshot stitching
24 | 
25 | 1. Select images and up limit and down limit
26 | 
27 | 1. Preview stitched image
28 | 
29 | 1. Save stitched image
30 | 
31 |
32 | ## Advanced
33 |
34 | 1. Preview/save selected images (right click selected images)
35 | 
36 | 1. Automatically detect subtitle positions
37 | 
--------------------------------------------------------------------------------
/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 | 
16 |
17 | ## 启动应用
18 |
19 | 1. Windows下启动。
20 | 
21 | 1. macOS下启动。
22 | 
23 |
24 | ## 使用说明
25 |
26 | 1. 打开视频。
27 | 
28 | 1. (可选) 选择截图存放路径。
29 | 
30 | 1. (可选, 针对`Auto Snapshots`,这个是必选) 打开字幕文件 (*.srt). 如果字幕文件与视频文件是相同的文件名,这个字幕文件会被自动加载。注意:本软件现阶段并不支持渲染字幕到视频或者截屏上。
31 | 
32 | 1. 播放视频或者手动截图(当视频被打开之后会立刻开始自动播放,此时,播放按钮会变成暂停,单机视频区域也可以播放或者暂停视频)。
33 | 
34 | 
35 | 1. 选择 `Start` 和 `End`, 之后就可以通过点击`Auto Anapshot`针对有字幕的每一帧自动进行截屏。
36 | 
37 | 1. 跳转到`Image Stitching`。
38 | 
39 | 1. 选择视频字幕上下区域。
40 | 
41 | 1. 预览拼接效果。
42 | 
43 | 1. 保存拼接的图片。
44 | 
45 |
46 | ## 高级
47 |
48 | 1. 字幕区域自动识别。
49 | 
50 | 1. 预览/保存选择性拼接。
51 | 
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 |
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 |
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
--------------------------------------------------------------------------------