├── .gitignore ├── .pycodestyle ├── .pylintrc ├── .vscode ├── launch.json ├── settings.json └── tasks.json ├── LICENSE ├── README.md ├── docs └── features.md ├── mypy.ini ├── requirements.txt ├── run.py ├── stubs ├── PyQt5 │ ├── Qt.pyi │ ├── __init__.py │ ├── _sip.pyi │ └── sip.py ├── __init__.py ├── cueparser.pyi ├── psutil.pyi ├── pysubs2.pyi ├── qdarkstyle.pyi ├── vapoursynth.pyi └── yaml.pyi ├── tests ├── EP01.qp ├── candy_boy_05_tfm.txt ├── chapters.txt ├── jintai_01.scxvid.txt ├── koizumi_tc.txt ├── monte_01_tc.txt ├── monte_01_tc_v2.txt ├── script.vpy ├── test.ass └── timestamps v2.txt └── vspreview ├── __init__.py ├── __main__.py ├── core ├── __init__.py ├── abstracts.py ├── bases.py ├── better_abc.py └── types.py ├── main.py ├── models ├── __init__.py ├── outputs.py ├── scening.py └── zoom_levels.py ├── toolbars ├── __init__.py ├── benchmark.py ├── debug.py ├── misc.py ├── pipette.py ├── playback.py └── scening.py ├── utils ├── __init__.py ├── debug.py └── utils.py └── widgets ├── __init__.py ├── colorview.py ├── custom ├── __init__.py ├── combobox.py ├── edits.py ├── graphicsview.py └── misc.py └── timeline.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 | build/ 12 | develop-eggs/ 13 | dist/ 14 | downloads/ 15 | eggs/ 16 | .eggs/ 17 | lib/ 18 | lib64/ 19 | parts/ 20 | sdist/ 21 | var/ 22 | wheels/ 23 | *.egg-info/ 24 | .installed.cfg 25 | *.egg 26 | MANIFEST 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 | 34 | # Installer logs 35 | pip-log.txt 36 | pip-delete-this-directory.txt 37 | 38 | # Unit test / coverage reports 39 | htmlcov/ 40 | .tox/ 41 | .coverage 42 | .coverage.* 43 | .cache 44 | nosetests.xml 45 | coverage.xml 46 | *.cover 47 | .hypothesis/ 48 | .pytest_cache/ 49 | 50 | # Translations 51 | *.mo 52 | *.pot 53 | 54 | # Django stuff: 55 | *.log 56 | local_settings.py 57 | db.sqlite3 58 | 59 | # Flask stuff: 60 | instance/ 61 | .webassets-cache 62 | 63 | # Scrapy stuff: 64 | .scrapy 65 | 66 | # Sphinx documentation 67 | docs/_build/ 68 | 69 | # PyBuilder 70 | target/ 71 | 72 | # Jupyter Notebook 73 | .ipynb_checkpoints 74 | 75 | # pyenv 76 | .python-version 77 | 78 | # celery beat schedule file 79 | celerybeat-schedule 80 | 81 | # SageMath parsed files 82 | *.sage.py 83 | 84 | # Environments 85 | .env 86 | .venv 87 | env/ 88 | venv/ 89 | ENV/ 90 | env.bak/ 91 | venv.bak/ 92 | 93 | # Spyder project settings 94 | .spyderproject 95 | .spyproject 96 | 97 | # Rope project settings 98 | .ropeproject 99 | 100 | # mkdocs documentation 101 | /site 102 | 103 | # mypy 104 | .mypy_cache/ 105 | 106 | misc/ 107 | 108 | tests/* 109 | !tests/script.vpy 110 | 111 | !tests/candy_boy_05_tfm.txt 112 | !tests/chapters.txt 113 | !tests/EP01.qp 114 | !tests/test.ass 115 | !tests/[Beatrice-Raws] Re:Creators 01 [BDRip 1920x1080 x264 FLAC]_chapters.cue 116 | !tests/[Beatrice-Raws] Re:Creators 01 [BDRip 1920x1080 x264 FLAC]_chapters.ogm.txt 117 | !tests/[Beatrice-Raws] Re:Creators 01 [BDRip 1920x1080 x264 FLAC]_chapters.xml 118 | !tests/jintai_01.scxvid.txt 119 | !tests/koizumi_tc.txt 120 | !tests/monte_01_tc_v2.txt 121 | !tests/monte_01_tc.txt 122 | !tests/timestamps v2.txt -------------------------------------------------------------------------------- /.pycodestyle: -------------------------------------------------------------------------------- 1 | [pycodestyle] 2 | count = True 3 | ignore = E201, E203, E221, E222, E241, E251, E271, E272, E302, E303, E501, E704, W503 4 | statistics = True -------------------------------------------------------------------------------- /.pylintrc: -------------------------------------------------------------------------------- 1 | [MASTER] 2 | 3 | # Specify a configuration file. 4 | #rcfile= 5 | 6 | # Python code to execute, usually for sys.path manipulation such as 7 | # pygtk.require(). 8 | #init-hook= 9 | 10 | # Add files or directories to the blacklist. They should be base names, not 11 | # paths. 12 | ignore=CVS 13 | 14 | # Pickle collected data for later comparisons. 15 | persistent=yes 16 | 17 | # List of plugins (as comma separated values of python modules names) to load, 18 | # usually to register additional checkers. 19 | load-plugins= 20 | 21 | # Use multiple processes to speed up Pylint. 22 | jobs=1 23 | 24 | # Allow loading of arbitrary C extensions. Extensions are imported into the 25 | # active Python interpreter and may run arbitrary code. 26 | unsafe-load-any-extension=no 27 | 28 | # A comma-separated list of package or module names from where C extensions may 29 | # be loaded. Extensions are loading into the active Python interpreter and may 30 | # run arbitrary code 31 | extension-pkg-whitelist= 32 | 33 | 34 | [MESSAGES CONTROL] 35 | 36 | # Only show warnings with the listed confidence levels. Leave empty to show 37 | # all. Valid levels: HIGH, INFERENCE, INFERENCE_FAILURE, UNDEFINED 38 | confidence= 39 | 40 | # Enable the message, report, category or checker with the given id(s). You can 41 | # either give multiple identifier separated by comma (,) or put this option 42 | # multiple time. See also the "--disable" option for examples. 43 | #enable= 44 | 45 | # Disable the message, report, category or checker with the given id(s). You 46 | # can either give multiple identifiers separated by comma (,) or put this 47 | # option multiple times (only on the command line, not in the configuration 48 | # file where it should appear only once).You can also use "--disable=all" to 49 | # disable everything first and then reenable specific checks. For example, if 50 | # you want to run only the similarities checker, you can use "--disable=all 51 | # --enable=similarities". If you want to run only the classes checker, but have 52 | # no Warning level messages displayed, use"--disable=all --enable=classes 53 | # --disable= 54 | 55 | disable= 56 | C, 57 | assigning-non-slot, 58 | c-extension-no-member, 59 | unused-argument, 60 | logging-fstring-interpolation, 61 | logging-format-interpolation, 62 | unused-import, 63 | protected-access, 64 | fixme 65 | 66 | [REPORTS] 67 | 68 | # Set the output format. Available formats are text, parseable, colorized, msvs 69 | # (visual studio) and html. You can also give a reporter class, eg 70 | # mypackage.mymodule.MyReporterClass. 71 | output-format=text 72 | 73 | # Put messages in a separate file for each module / package specified on the 74 | # command line instead of printing them on stdout. Reports (if any) will be 75 | # written in a file name "pylint_global.[txt|html]". 76 | files-output=no 77 | 78 | # Tells whether to display a full report or only the messages 79 | reports=no 80 | 81 | # Python expression which should return a note less than 10 (10 is the highest 82 | # note). You have access to the variables errors warning, statement which 83 | # respectively contain the number of errors / warnings messages and the total 84 | # number of statements analyzed. This is used by the global evaluation report 85 | # (RP0004). 86 | evaluation=10.0 - ((float(5 * error + warning + refactor + convention) / statement) * 10) 87 | 88 | # Template used to display messages. This is a python new-style format string 89 | # used to format the message information. See doc for all details 90 | #msg-template= 91 | 92 | 93 | [LOGGING] 94 | 95 | # Logging modules to check that the string format arguments are in logging 96 | # function parameter format 97 | logging-modules=logging 98 | 99 | 100 | [MISCELLANEOUS] 101 | 102 | # List of note tags to take in consideration, separated by a comma. 103 | notes=FIXME,XXX,TODO 104 | 105 | 106 | [SIMILARITIES] 107 | 108 | # Minimum lines number of a similarity. 109 | min-similarity-lines=4 110 | 111 | # Ignore comments when computing similarities. 112 | ignore-comments=yes 113 | 114 | # Ignore docstrings when computing similarities. 115 | ignore-docstrings=yes 116 | 117 | # Ignore imports when computing similarities. 118 | ignore-imports=no 119 | 120 | 121 | [VARIABLES] 122 | 123 | # Tells whether we should check for unused import in __init__ files. 124 | init-import=no 125 | 126 | # A regular expression matching the name of dummy variables (i.e. expectedly 127 | # not used). 128 | dummy-variables-rgx=_$|dummy 129 | 130 | # List of additional names supposed to be defined in builtins. Remember that 131 | # you should avoid defining new builtins when possible. 132 | additional-builtins= 133 | 134 | # List of strings which can identify a callback function by name. A callback 135 | # name must start or end with one of those strings. 136 | callbacks=cb_,_cb 137 | 138 | 139 | [FORMAT] 140 | 141 | # Maximum number of characters on a single line. 142 | max-line-length=100 143 | 144 | # Regexp for a line that is allowed to be longer than the limit. 145 | ignore-long-lines=^\s*(# )??$ 146 | 147 | # Allow the body of an if to be on the same line as the test if there is no 148 | # else. 149 | single-line-if-stmt=no 150 | 151 | # List of optional constructs for which whitespace checking is disabled 152 | no-space-check=trailing-comma,dict-separator 153 | 154 | # Maximum number of lines in a module 155 | max-module-lines=2000 156 | 157 | # String used as indentation unit. This is usually " " (4 spaces) or "\t" (1 158 | # tab). 159 | indent-string=' ' 160 | 161 | # Number of spaces of indent required inside a hanging or continued line. 162 | indent-after-paren=4 163 | 164 | # Expected format of line ending, e.g. empty (any line ending), LF or CRLF. 165 | expected-line-ending-format= 166 | 167 | 168 | [BASIC] 169 | 170 | # List of builtins function names that should not be used, separated by a comma 171 | bad-functions=map,filter,input 172 | 173 | # Good variable names which should always be accepted, separated by a comma 174 | good-names=i,j,k,ex,Run,_ 175 | 176 | # Bad variable names which should always be refused, separated by a comma 177 | bad-names=foo,bar,baz,toto,tutu,tata 178 | 179 | # Colon-delimited sets of names that determine each other's naming style when 180 | # the name regexes allow several styles. 181 | name-group= 182 | 183 | # Include a hint for the correct naming format with invalid-name 184 | include-naming-hint=no 185 | 186 | # Regular expression matching correct function names 187 | function-rgx=[a-z_][a-z0-9_]{2,30}$ 188 | 189 | # Naming hint for function names 190 | function-name-hint=[a-z_][a-z0-9_]{2,30}$ 191 | 192 | # Regular expression matching correct variable names 193 | variable-rgx=[a-z_][a-z0-9_]{2,30}$ 194 | 195 | # Naming hint for variable names 196 | variable-name-hint=[a-z_][a-z0-9_]{2,30}$ 197 | 198 | # Regular expression matching correct constant names 199 | const-rgx=(([A-Z_][A-Z0-9_]*)|(__.*__))$ 200 | 201 | # Naming hint for constant names 202 | const-name-hint=(([A-Z_][A-Z0-9_]*)|(__.*__))$ 203 | 204 | # Regular expression matching correct attribute names 205 | attr-rgx=[a-z_][a-z0-9_]{2,}$ 206 | 207 | # Naming hint for attribute names 208 | attr-name-hint=[a-z_][a-z0-9_]{2,}$ 209 | 210 | # Regular expression matching correct argument names 211 | argument-rgx=[a-z_][a-z0-9_]{2,30}$ 212 | 213 | # Naming hint for argument names 214 | argument-name-hint=[a-z_][a-z0-9_]{2,30}$ 215 | 216 | # Regular expression matching correct class attribute names 217 | class-attribute-rgx=([A-Za-z_][A-Za-z0-9_]{2,30}|(__.*__))$ 218 | 219 | # Naming hint for class attribute names 220 | class-attribute-name-hint=([A-Za-z_][A-Za-z0-9_]{2,30}|(__.*__))$ 221 | 222 | # Regular expression matching correct inline iteration names 223 | inlinevar-rgx=[A-Za-z_][A-Za-z0-9_]*$ 224 | 225 | # Naming hint for inline iteration names 226 | inlinevar-name-hint=[A-Za-z_][A-Za-z0-9_]*$ 227 | 228 | # Regular expression matching correct class names 229 | class-rgx=[A-Z_][a-zA-Z0-9]+$ 230 | 231 | # Naming hint for class names 232 | class-name-hint=[A-Z_][a-zA-Z0-9]+$ 233 | 234 | # Regular expression matching correct module names 235 | module-rgx=(([a-z_][a-z0-9_]*)|([A-Z][a-zA-Z0-9]+))$ 236 | 237 | # Naming hint for module names 238 | module-name-hint=(([a-z_][a-z0-9_]*)|([A-Z][a-zA-Z0-9]+))$ 239 | 240 | # Regular expression matching correct method names 241 | method-rgx=[a-z_][a-z0-9_]{2,}$ 242 | 243 | # Naming hint for method names 244 | method-name-hint=[a-z_][a-z0-9_]{2,}$ 245 | 246 | # Regular expression which should only match function or class names that do 247 | # not require a docstring. 248 | no-docstring-rgx=__.*__ 249 | 250 | # Minimum line length for functions/classes that require docstrings, shorter 251 | # ones are exempt. 252 | docstring-min-length=-1 253 | 254 | # List of decorators that define properties, such as abc.abstractproperty. 255 | property-classes=abc.abstractproperty 256 | 257 | 258 | [TYPECHECK] 259 | 260 | # Tells whether missing members accessed in mixin class should be ignored. A 261 | # mixin class is detected if its name ends with "mixin" (case insensitive). 262 | ignore-mixin-members=yes 263 | 264 | # List of module names for which member attributes should not be checked 265 | # (useful for modules/projects where namespaces are manipulated during runtime 266 | # and thus existing member attributes cannot be deduced by static analysis 267 | ignored-modules=vapoursynth 268 | 269 | # List of classes names for which member attributes should not be checked 270 | # (useful for classes with attributes dynamically set). 271 | ignored-classes=SQLObject, optparse.Values, thread._local, _thread._local 272 | 273 | # List of members which are set dynamically and missed by pylint inference 274 | # system, and so shouldn't trigger E1101 when accessed. Python regular 275 | # expressions are accepted. 276 | generated-members=REQUEST,acl_users,aq_parent 277 | 278 | # List of decorators that create context managers from functions, such as 279 | # contextlib.contextmanager. 280 | contextmanager-decorators=contextlib.contextmanager 281 | 282 | 283 | [SPELLING] 284 | 285 | # Spelling dictionary name. Available dictionaries: none. To make it working 286 | # install python-enchant package. 287 | spelling-dict= 288 | 289 | # List of comma separated words that should not be checked. 290 | spelling-ignore-words= 291 | 292 | # A path to a file that contains private dictionary; one word per line. 293 | spelling-private-dict-file= 294 | 295 | # Tells whether to store unknown words to indicated private dictionary in 296 | # --spelling-private-dict-file option instead of raising a message. 297 | spelling-store-unknown-words=no 298 | 299 | 300 | [DESIGN] 301 | 302 | # Maximum number of arguments for function / method 303 | max-args=10 304 | 305 | # Argument names that match this expression will be ignored. Default to name 306 | # with leading underscore 307 | ignored-argument-names=_.* 308 | 309 | # Maximum number of locals for function / method body 310 | max-locals=25 311 | 312 | # Maximum number of return / yield for function / method body 313 | max-returns=11 314 | 315 | # Maximum number of branch for function / method body 316 | max-branches=26 317 | 318 | # Maximum number of statements in function / method body 319 | max-statements=100 320 | 321 | # Maximum number of parents for a class (see R0901). 322 | max-parents=7 323 | 324 | # Maximum number of attributes for a class (see R0902). 325 | max-attributes=11 326 | 327 | # Minimum number of public methods for a class (see R0903). 328 | min-public-methods=2 329 | 330 | # Maximum number of public methods for a class (see R0904). 331 | max-public-methods=25 332 | 333 | 334 | [CLASSES] 335 | 336 | # List of method names used to declare (i.e. assign) instance attributes. 337 | defining-attr-methods=__init__,__new__,setUp,setup_ui 338 | 339 | # List of valid names for the first argument in a class method. 340 | valid-classmethod-first-arg=cls 341 | 342 | # List of valid names for the first argument in a metaclass class method. 343 | valid-metaclass-classmethod-first-arg=mcs 344 | 345 | # List of member names, which should be excluded from the protected access 346 | # warning. 347 | exclude-protected=_asdict,_fields,_replace,_source,_make 348 | 349 | 350 | [IMPORTS] 351 | 352 | # Deprecated modules which should not be used, separated by a comma 353 | deprecated-modules=regsub,TERMIOS,Bastion,rexec 354 | 355 | # Create a graph of every (i.e. internal and external) dependencies in the 356 | # given file (report RP0402 must not be disabled) 357 | import-graph= 358 | 359 | # Create a graph of external dependencies in the given file (report RP0402 must 360 | # not be disabled) 361 | ext-import-graph= 362 | 363 | # Create a graph of internal dependencies in the given file (report RP0402 must 364 | # not be disabled) 365 | int-import-graph= 366 | 367 | 368 | [EXCEPTIONS] 369 | 370 | # Exceptions that will emit a warning when being caught. Defaults to 371 | # "Exception" 372 | overgeneral-exceptions=Exception -------------------------------------------------------------------------------- /.vscode/launch.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "0.2.0", 3 | "configurations": [ 4 | { 5 | "name": "VSPreview", 6 | "type": "python", 7 | "request": "launch", 8 | "program": "${workspaceFolder}/run.py", 9 | "console": "integratedTerminal", 10 | "args": [ 11 | "${workspaceFolder}/tests/script.vpy" 12 | ], 13 | "showReturnValue": true, 14 | "subProcess": true, 15 | }, 16 | { 17 | "name": "VSP Current File", 18 | "type": "python", 19 | "request": "launch", 20 | "console": "integratedTerminal", 21 | "program": "${workspaceFolder}/run.py", 22 | "args": [ 23 | "${file}" 24 | ], 25 | "showReturnValue": true, 26 | "subProcess": true, 27 | }, 28 | { 29 | "name": "Current File", 30 | "type": "python", 31 | "request": "launch", 32 | "console": "integratedTerminal", 33 | "program": "${file}", 34 | "showReturnValue": true, 35 | "subProcess": true, 36 | } 37 | // { 38 | // "name": "Python: Attach", 39 | // "type": "python", 40 | // "request": "attach", 41 | // "localRoot": "${workspaceFolder}", 42 | // "remoteRoot": "${workspaceFolder}", 43 | // "port": 3000, 44 | // "secret": "my_secret", 45 | // "host": "localhost" 46 | // }, 47 | 48 | // { 49 | // "name": "Python: Terminal (external)", 50 | // "type": "python", 51 | // "request": "launch", 52 | // "program": "${file}", 53 | // "console": "externalTerminal" 54 | // }, 55 | // { 56 | // "name": "Python: Django", 57 | // "type": "python", 58 | // "request": "launch", 59 | // "program": "${workspaceFolder}/manage.py", 60 | // "args": [ 61 | // "runserver", 62 | // "--noreload", 63 | // "--nothreading" 64 | // ], 65 | // "debugOptions": [ 66 | // "RedirectOutput", 67 | // "Django" 68 | // ] 69 | // }, 70 | // { 71 | // "name": "Python: Flask (0.11.x or later)", 72 | // "type": "python", 73 | // "request": "launch", 74 | // "module": "flask", 75 | // "env": { 76 | // "FLASK_APP": "${workspaceFolder}/app.py" 77 | // }, 78 | // "args": [ 79 | // "run", 80 | // "--no-debugger", 81 | // "--no-reload" 82 | // ] 83 | // }, 84 | // { 85 | // "name": "Python: Module", 86 | // "type": "python", 87 | // "request": "launch", 88 | // "module": "module.name" 89 | // }, 90 | // { 91 | // "name": "Python: Pyramid", 92 | // "type": "python", 93 | // "request": "launch", 94 | // "args": [ 95 | // "${workspaceFolder}/development.ini" 96 | // ], 97 | // "debugOptions": [ 98 | // "RedirectOutput", 99 | // "Pyramid" 100 | // ] 101 | // }, 102 | // { 103 | // "name": "Python: Watson", 104 | // "type": "python", 105 | // "request": "launch", 106 | // "program": "${workspaceFolder}/console.py", 107 | // "args": [ 108 | // "dev", 109 | // "runserver", 110 | // "--noreload=True" 111 | // ] 112 | // }, 113 | // { 114 | // "name": "Python: All debug Options", 115 | // "type": "python", 116 | // "request": "launch", 117 | // "python": "${command:python.interpreterPath}", 118 | // "program": "${file}", 119 | // "module": "module.name", 120 | // "env": { 121 | // "VAR1": "1", 122 | // "VAR2": "2" 123 | // }, 124 | // "envFile": "${workspaceFolder}/.env", 125 | // "args": [ 126 | // "arg1", 127 | // "arg2" 128 | // ], 129 | // "debugOptions": [ 130 | // "RedirectOutput" 131 | // ] 132 | // } 133 | ] 134 | } -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "python.linting.enabled": true, 3 | "python.linting.pylintEnabled": true, 4 | "files.eol": "\n", 5 | "python.linting.mypyEnabled": true, 6 | "python.linting.mypyArgs": [ 7 | "--follow-imports=silent", 8 | "--show-column-numbers" 9 | ], 10 | "files.associations": { 11 | "*.vpy": "python", 12 | "*.sip": "cpp", 13 | "*.pyi": "python" 14 | }, 15 | "files.encoding": "utf8", 16 | "python.linting.pycodestylePath": "pycodestyle", 17 | "python.linting.pycodestyleEnabled": true, 18 | "python.linting.pycodestyleArgs": [ 19 | "--config=.pycodestyle" 20 | ] 21 | } -------------------------------------------------------------------------------- /.vscode/tasks.json: -------------------------------------------------------------------------------- 1 | { 2 | // See https://go.microsoft.com/fwlink/?LinkId=733558 3 | // for the documentation about the tasks.json format 4 | "version": "2.0.0", 5 | "tasks": [ 6 | { 7 | "label": "Run VSPreview", 8 | "type": "shell", 9 | "windows": { 10 | "command": "python run.py tests/script.vpy", 11 | }, 12 | "linux": { 13 | "command": "python3 run.py tests/script.vpy", 14 | }, 15 | "group": { 16 | "kind": "build", 17 | "isDefault": true 18 | } 19 | }, 20 | { 21 | "label": "Check whole project with mypy", 22 | "type": "shell", 23 | "command": "mypy run.py", 24 | "group": { 25 | "kind": "test", 26 | "isDefault": true 27 | } 28 | }, 29 | { 30 | "label": "Open Current File in VSPreview", 31 | "type": "shell", 32 | "windows": { 33 | "command": "python ${workspaceFolder}/run.py \"${file}\"" 34 | }, 35 | "linux": { 36 | "command": "python3 ${workspaceFolder}/run.py \"${file}\"" 37 | }, 38 | "problemMatcher": [] 39 | } 40 | ] 41 | } -------------------------------------------------------------------------------- /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 | Standalone preview for VapourSynth scripts. Meant to be paired with a code editor with integrated terminal like Visual Studio Code. 2 | 3 | Feel free to contact me in [Telegram chat](https://t.me/vspreview_chat). Any feedback is appreciated. 4 | 5 | # Prerequisites 6 | 7 | * Python 3.8 8 | * Vapoursynth R49 9 | * pip modules in `requirements.txt` 10 | 11 | You can use the following command to install pip modules: 12 | 13 | `pip install -r requirements.txt` 14 | 15 | # Usage 16 | 17 | Assuming `script.vpy` is your VapourSynth script, there are two ways to run vspreview: 18 | * `python run.py script.vpy` 19 | * Add this directory (repository root) to your *PYTHONPATH*, and `python -m vspreview script.vpy` 20 | 21 | # Note 22 | 23 | WIP, so there're some debug stuff among the logic, but not much. 24 | 25 | # Development 26 | 27 | pip modules: 28 | 29 | `mypy pycodestyle pylint pyqt5-stubs` 30 | 31 | PyQt5 stubs may be incomplete when it comes to signals. 32 | -------------------------------------------------------------------------------- /docs/features.md: -------------------------------------------------------------------------------- 1 | # Предисловие 2 | Этот документ был написан перед тем, как начать разработку. Сейчас он лишь частично отражает как реализованный функционал, так и планы на будущее. 3 | 4 | # Концепция 5 | Как у предпросмотра VapourSynth Editor, но без верхней строки меню. 6 | Элементы интерфейса сгруппированы по функционалу. Группы с дополнительным функционалом можно скрывать. (Точно так же это сделано в VapourSynth Editor) 7 | Все действия в превью можно будет делать мышкой. На любое действие можно повесить сочетание клавиш. 8 | 9 | # Элементы интерфейса (обязательные) 10 | + Текущий кадр (поле) 11 | + Скопировать номер текущего кадра в буфер обмена 12 | + Текущее время (поле) 13 | + Скопировать время текущего кадра в буфер обмена 14 | + Полоса прокрутки (дизайн как в vsedit): 15 | + Текущее положение 16 | + Управление воспроизведением (группа кнопок): 17 | + Запустить/поставить на паузу 18 | + Предыдущий/следующий кадр 19 | + Cтатус (скрипт обрабатывается/готово) 20 | - Настройки (кнопка, модальное окно) 21 | + Перезагрузить скрипт (кнопка) 22 | 23 | # Элементы интерфейса (полезные) 24 | Приоритетные: 25 | ? Поверх всех окон (флаг) 26 | - Текущий кадр/обрезка (группа кнопок): 27 | - Сохранить в файл (текущий вывод/все выводы) 28 | - Сохранить в файл с выбором пути 29 | - Скопировать в буфер обмена 30 | - Загрузить на slowpics (текущий вывод/все выводы) (нужно обсудить со Slow Poke) 31 | + Управление воспроизведением (группа кнопок): 32 | + Прыгнуть назад/вперед на N кадров 33 | + Длина прыжка 34 | + Скорость (поля «К/с», «Множитель», флаг «Не ограничивать») 35 | + Сценинг: 36 | - Получать и парсить строку-шаблон через параметры запуска 37 | + Задать начальный кадр 38 | + Задать конечный кадр 39 | + Скопировать готовую строку в буфер обмена 40 | - Отмечать на таймлайне выбранные кадры 41 | - Полоса прокрутки (дизайн как в vsedit): 42 | - Линейка с номерами кадров/временем (переключаемо) 43 | + Закладки (название в статусной строке) 44 | + Переключение каналов вывода: 45 | + Поле с номерами выводов и стрелками 46 | - Связывание (по времени) (флаг) 47 | 48 | Неприоритетные: 49 | - Масштаб (выпадающий список): 50 | + 1:1 (по умолчанию) 51 | - Вписать в окно 52 | - Подогнать окно 53 | + Пользовательский (поле) 54 | - Обрезка (группа полей): 55 | - Отступы слева/сверху/справа/снизу (отступы справа и снизу синхронизируются с шириной и высотой) (расположить по знаку «-») 56 | - Ширина/высота (синхронизируются с отступами справа и снизу) 57 | - Скопировать параметры обрезки в буфер обмена как строку скрипта 58 | - Затемнять обрезанные области 59 | - Задание обрезки выделением области экрана 60 | - Режим обрезки (относительный/абсолютный) 61 | - Управление закладками (группа кнопок): 62 | + Добавить/убрать закладку на текущем кадре 63 | - Добавить именованную закладку 64 | + Перейти к предыдущей/следующей закладке 65 | + Сохранить/загрузить закладки, в том числе из файла с главами 66 | - Окно со списком закладок 67 | - Строка статуса (для текущего канала вывода) (текст): 68 | - Левый край: 69 | - Пипетка координат пикселя (обновляется в реальном времени) 70 | - Пипетка YUV и RGB (обновляется в реальном времени) 71 | + Правый край: 72 | + Общее кол-во кадров 73 | + Полная длина 74 | + Разрешение кадра 75 | + Кадры в секунду 76 | + Формат пикселя 77 | 78 | # Элементы интерфейса (бесполезные) 79 | - Информация о видео (кнопка, всплывающее окно) 80 | - Калькулятор размеров 81 | - Учет требований фильтров 82 | 83 | # Настройки (в отдельном окне) 84 | * Горячие клавиши 85 | - Настройки ресайза 86 | - Выключение пипеток (группа флагов) 87 | - Настройка снимков экрана: 88 | + Формат и настройки сжатия 89 | + Путь 90 | - slowpics: открыть в браузере (флаг) 91 | - slowpics: cкопировать ссылку в буфер обмена (флаг) 92 | - Отключение кнопок, которые открывают панели (группа флагов) 93 | - Перезагружать скрипт автоматически (флаг) 94 | 95 | # Сложное 96 | + Запоминать текущий кадр между запусками превью 97 | - Внесение изменений в скрипт (программно) 98 | + Вставка номера кадра по горячей клавише 99 | + Легко сделать через буфер обмена 100 | + Для сценинга предусмотрен отдельный более удобный механизм (тоже через буфер обмена) 101 | 102 | # Расширение VS Code 103 | - Предпросмотр текущего скрипта 104 | - Ассоциация .vpy с Python 105 | - Предпросмотр любого .vpy через контекстное меню 106 | - Удобный механизм получения консольного вывода превью 107 | - Хранение настроек пользователя 108 | + Сохранение настроек рядом со скриптом 109 | - Доработка встроенного автозавершения для поддержки динамически получаемого ядра vapoursynth (если возможно) 110 | 111 | # Публикация 112 | - После реализации обязательного функционала 113 | + Придумать название 114 | - PyPI 115 | - Visual Studio Marketplace 116 | -------------------------------------------------------------------------------- /mypy.ini: -------------------------------------------------------------------------------- 1 | [mypy] 2 | 3 | # Import discovery 4 | 5 | mypy_path = ./stubs 6 | # files 7 | # namespace_packages 8 | # ignore_missing_imports 9 | # follow_imports 10 | # follow_imports_for_stubs 11 | # python_executable 12 | # no_silence_site_packages 13 | 14 | # Platform configuration 15 | 16 | python_version = 3.7 17 | # platform 18 | # always_true 19 | # always_false 20 | 21 | # Disallow dynamic typing 22 | 23 | # disallow_any_unimported = True 24 | # disallow_any_expr = True 25 | # disallow_any_decorated = True 26 | # disallow_any_explicit = True 27 | # disallow_any_generics = True 28 | disallow_subclassing_any = True 29 | 30 | # Untyped definitions and calls 31 | 32 | disallow_untyped_calls = True 33 | disallow_untyped_defs = True 34 | disallow_incomplete_defs = True 35 | check_untyped_defs = True 36 | disallow_untyped_decorators = True 37 | 38 | # None and optional handling 39 | 40 | no_implicit_optional = True 41 | strict_optional = True 42 | 43 | # Configuring warnings 44 | 45 | warn_redundant_casts = True 46 | warn_unused_ignores = True 47 | warn_no_return = True 48 | warn_return_any = True 49 | warn_unreachable = True 50 | 51 | # Suppressing errors 52 | 53 | show_none_errors = True 54 | ignore_errors = False 55 | 56 | # Miscellaneous strictness flags 57 | 58 | allow_untyped_globals = False 59 | allow_redefinition = False 60 | implicit_reexport = True 61 | strict_equality = True 62 | 63 | # Configuring error messages 64 | 65 | # show_error_context 66 | show_column_numbers = True 67 | show_error_codes = True 68 | # pretty 69 | color_output = True 70 | # error_summary 71 | # show_absolute_path 72 | 73 | # Miscellaneous 74 | 75 | warn_unused_configs = True 76 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | cueparser 2 | psutil 3 | pyqt5 4 | pysubs2 5 | pyyaml 6 | qdarkstyle 7 | vapoursynth -------------------------------------------------------------------------------- /run.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/python3 2 | from vspreview import main 3 | 4 | main() 5 | -------------------------------------------------------------------------------- /stubs/PyQt5/Qt.pyi: -------------------------------------------------------------------------------- 1 | # Providing our own module to existing stubs package feels like a hack, 2 | # so I won't be surprised if it'll break after some CPython or Mypy update. 3 | 4 | # pylint: skip-file 5 | 6 | from .QtCore import * 7 | from .QtDBus import * 8 | from .QtGui import * # type: ignore 9 | from .QtNetwork import * 10 | from .QtOpenGL import * 11 | from .QtPrintSupport import * 12 | from .QtSql import * 13 | from .QtTest import * 14 | from .QtWidgets import * 15 | from .QtXml import * 16 | -------------------------------------------------------------------------------- /stubs/PyQt5/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/AkarinVS/vapoursynth-preview/a76b57280bc2390ae9e1217459f64d33ed4d86ac/stubs/PyQt5/__init__.py -------------------------------------------------------------------------------- /stubs/PyQt5/_sip.pyi: -------------------------------------------------------------------------------- 1 | # This file is the Python type hints stub file for the sip extension module. 2 | # 3 | # Copyright (c) 2016 Riverbank Computing Limited 4 | # 5 | # This file is part of SIP. 6 | # 7 | # This copy of SIP is licensed for use under the terms of the SIP License 8 | # Agreement. See the file LICENSE for more details. 9 | # 10 | # This copy of SIP may also used under the terms of the GNU General Public 11 | # License v2 or v3 as published by the Free Software Foundation which can be 12 | # found in the files LICENSE-GPL2 and LICENSE-GPL3 included in this package. 13 | # 14 | # SIP is supplied WITHOUT ANY WARRANTY; without even the implied warranty of 15 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. 16 | 17 | 18 | from typing import overload, Sequence, Union 19 | 20 | 21 | # Constants. 22 | SIP_VERSION = ... # type: int 23 | SIP_VERSION_STR = ... # type: str 24 | 25 | 26 | # The bases for SIP generated types. 27 | class wrappertype: ... 28 | class simplewrapper: ... 29 | class wrapper(simplewrapper): ... 30 | 31 | 32 | # PEP 484 has no explicit support for the buffer protocol so we just name types 33 | # we know that implement it. 34 | Buffer = Union['array', 'voidptr', str, bytes, bytearray] 35 | 36 | 37 | # The array type. 38 | class array(Sequence): ... 39 | 40 | 41 | # The voidptr type. 42 | class voidptr: 43 | 44 | def __init__(addr: Union[int, Buffer], size: int = -1, writeable: bool = True) -> None: ... 45 | 46 | def __int__(self) -> int: ... 47 | 48 | @overload 49 | def __getitem__(self, i: int) -> bytes: ... 50 | 51 | @overload 52 | def __getitem__(self, s: slice) -> 'voidptr': ... 53 | 54 | def __hex__(self) -> str: ... 55 | 56 | def __len__(self) -> int: ... 57 | 58 | def __setitem__(self, i: Union[int, slice], v: Buffer) -> None: ... 59 | 60 | def asarray(self, size: int = -1) -> array: ... 61 | 62 | # Python doesn't expose the capsule type. 63 | #def ascapsule(self) -> capsule: ... 64 | 65 | def asstring(self, size: int = -1) -> bytes: ... 66 | 67 | def getsize(self) -> int: ... 68 | 69 | def getwriteable(self) -> bool: ... 70 | 71 | def setsize(self, size: int) -> None: ... 72 | 73 | def setwriteable(self, bool) -> None: ... 74 | 75 | 76 | # Remaining functions. 77 | def cast(obj: simplewrapper, type: wrappertype) -> simplewrapper: ... 78 | def delete(obj: simplewrapper) -> None: ... 79 | def dump(obj: simplewrapper) -> None: ... 80 | def enableautoconversion(type: wrappertype, enable: bool) -> bool: ... 81 | def getapi(name: str) -> int: ... 82 | def isdeleted(obj: simplewrapper) -> bool: ... 83 | def ispycreated(obj: simplewrapper) -> bool: ... 84 | def ispyowned(obj: simplewrapper) -> bool: ... 85 | def setapi(name: str, version: int) -> None: ... 86 | def setdeleted(obj: simplewrapper) -> None: ... 87 | def setdestroyonexit(destroy: bool) -> None: ... 88 | def settracemask(mask: int) -> None: ... 89 | def transferback(obj: wrapper) -> None: ... 90 | def transferto(obj: wrapper, owner: wrapper) -> None: ... 91 | def unwrapinstance(obj: simplewrapper) -> None: ... 92 | def wrapinstance(addr: int, type: wrappertype) -> simplewrapper: ... 93 | -------------------------------------------------------------------------------- /stubs/PyQt5/sip.py: -------------------------------------------------------------------------------- 1 | from typing import Any 2 | 3 | # pylint: skip-file 4 | 5 | wrappertype: Any 6 | -------------------------------------------------------------------------------- /stubs/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/AkarinVS/vapoursynth-preview/a76b57280bc2390ae9e1217459f64d33ed4d86ac/stubs/__init__.py -------------------------------------------------------------------------------- /stubs/cueparser.pyi: -------------------------------------------------------------------------------- 1 | from datetime import timedelta 2 | from typing import List, Optional 3 | 4 | # pylint: skip-file 5 | 6 | class CueTrack: 7 | duration: Optional[timedelta] 8 | offset: Optional[str] 9 | title: Optional[str] 10 | 11 | class CueSheet: 12 | tracks: List[CueTrack] 13 | 14 | def parse(self) -> None: ... 15 | def setData(self, data: str) -> None: ... 16 | def setOutputFormat(self, outputFormat: str, trackOutputFormat: str = '') -> None: ... 17 | -------------------------------------------------------------------------------- /stubs/psutil.pyi: -------------------------------------------------------------------------------- 1 | from typing import List, Optional 2 | 3 | # pylint: skip-file 4 | 5 | class Process: 6 | def cpu_affinity(self, cpu_list: Optional[List[int]] = None) -> List[int]: ... 7 | 8 | 9 | def cpu_count(logical: bool = True) -> int: ... 10 | -------------------------------------------------------------------------------- /stubs/pysubs2.pyi: -------------------------------------------------------------------------------- 1 | from typing import Iterator 2 | 3 | # pylint: skip-file 4 | 5 | class SSAEvent: 6 | start: int 7 | end: int 8 | 9 | 10 | class SSAFile: 11 | @classmethod 12 | def load(self, path: str) -> 'SSAFile': ... 13 | 14 | def __iter__(self) -> Iterator[SSAEvent]: ... 15 | 16 | def __len__(self) -> int: ... 17 | 18 | 19 | load = SSAFile.load 20 | -------------------------------------------------------------------------------- /stubs/qdarkstyle.pyi: -------------------------------------------------------------------------------- 1 | # pylint: skip-file 2 | 3 | def load_stylesheet_pyqt5() -> str: ... 4 | -------------------------------------------------------------------------------- /stubs/vapoursynth.pyi: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from concurrent.futures import Future 4 | from ctypes import c_void_p 5 | from enum import Enum 6 | from fractions import Fraction 7 | from inspect import Signature 8 | from typing import ( 9 | Any, BinaryIO, Callable, Dict, Optional, overload, Union, 10 | ) 11 | 12 | # pylint: skip-file 13 | 14 | # TODO : annotate return type of array methods 15 | # FIXME: Format == PresetFormat doesn't pass mypy's strict equality check 16 | # TODO : check signature of message handler callback 17 | 18 | 19 | class ColorFamily: 20 | pass 21 | 22 | 23 | RGB : ColorFamily 24 | YUV : ColorFamily 25 | GRAY : ColorFamily 26 | YCOCG : ColorFamily 27 | COMPAT: ColorFamily 28 | 29 | 30 | class SampleType: 31 | pass 32 | 33 | 34 | INTEGER: SampleType 35 | FLOAT : SampleType 36 | 37 | 38 | class Format: 39 | id: int 40 | name: str 41 | color_family: ColorFamily 42 | sample_type: SampleType 43 | bits_per_sample: int 44 | bytes_per_sample: int 45 | subsampling_w: int 46 | subsampling_h: int 47 | num_planes: int 48 | 49 | def replace(self, core: Optional[Core] = None, **kwargs: Any) -> None: ... 50 | 51 | 52 | class PresetFormat(Enum): 53 | GRAY8 : int 54 | GRAY16: int 55 | GRAYH : int 56 | GRAYS : int 57 | 58 | YUV420P8: int 59 | YUV422P8: int 60 | YUV444P8: int 61 | YUV410P8: int 62 | YUV411P8: int 63 | YUV440P8: int 64 | 65 | YUV420P9: int 66 | YUV422P9: int 67 | YUV444P9: int 68 | 69 | YUV420P10: int 70 | YUV422P10: int 71 | YUV444P10: int 72 | 73 | YUV420P12: int 74 | YUV422P12: int 75 | YUV444P12: int 76 | 77 | YUV420P14: int 78 | YUV422P14: int 79 | YUV444P14: int 80 | 81 | YUV420P16: int 82 | YUV422P16: int 83 | YUV444P16: int 84 | 85 | YUV444PH: int 86 | YUV444PS: int 87 | 88 | RGB24: int 89 | RGB27: int 90 | RGB30: int 91 | RGB48: int 92 | 93 | RGBH: int 94 | RGBS: int 95 | 96 | COMPATBGR32: int 97 | COMPATYUY2: int 98 | 99 | 100 | GRAY8 : PresetFormat 101 | GRAY16: PresetFormat 102 | GRAYH : PresetFormat 103 | GRAYS : PresetFormat 104 | 105 | YUV420P8: PresetFormat 106 | YUV422P8: PresetFormat 107 | YUV444P8: PresetFormat 108 | YUV410P8: PresetFormat 109 | YUV411P8: PresetFormat 110 | YUV440P8: PresetFormat 111 | 112 | YUV420P9: PresetFormat 113 | YUV422P9: PresetFormat 114 | YUV444P9: PresetFormat 115 | 116 | YUV420P10: PresetFormat 117 | YUV422P10: PresetFormat 118 | YUV444P10: PresetFormat 119 | 120 | YUV420P12: PresetFormat 121 | YUV422P12: PresetFormat 122 | YUV444P12: PresetFormat 123 | 124 | YUV420P14: PresetFormat 125 | YUV422P14: PresetFormat 126 | YUV444P14: PresetFormat 127 | 128 | YUV420P16: PresetFormat 129 | YUV422P16: PresetFormat 130 | YUV444P16: PresetFormat 131 | 132 | YUV444PH: PresetFormat 133 | YUV444PS: PresetFormat 134 | 135 | RGB24: PresetFormat 136 | RGB27: PresetFormat 137 | RGB30: PresetFormat 138 | RGB48: PresetFormat 139 | 140 | RGBH: PresetFormat 141 | RGBS: PresetFormat 142 | 143 | COMPATBGR32: PresetFormat 144 | COMPATYUY2 : PresetFormat 145 | 146 | 147 | class VideoFrame: 148 | format: Format 149 | width: int 150 | height: int 151 | readonly: bool 152 | props: VideoProps 153 | 154 | def copy(self) -> VideoFrame: ... 155 | def get_read_ptr(self, plane: int) -> c_void_p: ... 156 | def get_read_array(self, plane: int) -> Any: ... 157 | def get_write_ptr(self, plane: int) -> c_void_p: ... 158 | def get_write_array(self, plane: int) -> Any: ... 159 | def get_stride(self, plane: int) -> int: ... 160 | 161 | 162 | class VideoNode: 163 | format: Format 164 | width: int 165 | height: int 166 | num_frames: int 167 | fps: Fraction 168 | fps_num: int 169 | fps_den: int 170 | flags: int 171 | 172 | def get_frame(self, n: int) -> VideoFrame: ... 173 | def get_frame_async(self, n: int) -> Future: ... 174 | @overload 175 | def get_frame_async_raw(self, n: int, cb: Callable[[Union[VideoFrame, Error]], None]) -> None: ... 176 | @overload 177 | def get_frame_async_raw(self, n: int, cb: Future, wrapper: Optional[Callable] = None) -> None: ... 178 | def set_output(self, i: int, alpha: Optional[VideoNode] = None) -> None: ... 179 | def output(self, fileobj: BinaryIO, y4m: bool = False, prefetch: int = 0, progress_update: Optional[bool] = None) -> None: ... 180 | 181 | 182 | class AlphaOutputTuple: 183 | clip: VideoNode 184 | alpha: VideoNode 185 | 186 | 187 | class VideoProps(dict): 188 | pass 189 | 190 | 191 | class Function: 192 | name: str 193 | plugin: Plugin 194 | signature: str 195 | def __call__(self, *args: Any, **kwargs: Any) -> VideoNode: ... 196 | 197 | 198 | class Plugin: 199 | name: str 200 | def get_functions(self) -> Dict[str, str]: ... 201 | def list_functions(self) -> str: ... 202 | def __getattr__(self, name: str) -> Function: ... 203 | 204 | 205 | class Core: 206 | num_threads: int 207 | add_cache: bool 208 | max_cache_size: int 209 | 210 | def set_max_cache_size(self, mb: int) -> None: ... 211 | def get_plugins(self) -> Dict[str, Union[str, Dict[str, str]]]: ... 212 | def list_functions(self) -> str: ... 213 | def register_format(self, color_family: ColorFamily, sample_type: SampleType, bits_per_sample: int, subsampling_w: int, subsampling_h: int) -> Format: ... 214 | def get_format(self, id: int) -> Format: ... 215 | def version(self) -> str: ... 216 | def version_number(self) -> int: ... 217 | def __getattr__(self, name: str) -> Plugin: ... 218 | 219 | 220 | core: Core 221 | def get_core(threads: int = 0, add_cache: bool = True) -> Core: ... 222 | def set_message_handler(handler_func: Callable[[int, str], None]) -> None: ... 223 | def get_outputs() -> Dict[int, Union[VideoNode, AlphaOutputTuple]]: ... 224 | def get_output(index: int = 0) -> Union[VideoNode, AlphaOutputTuple]: ... 225 | def clear_output(index: int = 0) -> None: ... 226 | def clear_outputs() -> None: ... 227 | def construct_signature(signature: str, injected: Optional[Any] = None) -> Signature: ... 228 | 229 | 230 | class Environment: 231 | env_id: int 232 | single: bool 233 | alive: bool 234 | 235 | def is_single(self) -> bool: ... 236 | 237 | 238 | def vpy_current_environment() -> Environment: ... 239 | 240 | 241 | class Error(Exception): 242 | pass 243 | -------------------------------------------------------------------------------- /stubs/yaml.pyi: -------------------------------------------------------------------------------- 1 | from typing import Any, Callable, Dict, IO, Optional, Type, TypeVar, Tuple 2 | 3 | # pylint: skip-file 4 | 5 | T = TypeVar('T') 6 | 7 | class Node: 8 | pass 9 | 10 | class Dumper: 11 | ignore_aliases: Callable[[], bool] 12 | 13 | def represent_scalar(self, tag: str, value: Any, style: Optional[str] = None) -> Node: ... 14 | 15 | class Loader: 16 | def construct_scalar(self, node: Node) -> Any: ... 17 | 18 | class Mark: 19 | line: int 20 | column: int 21 | 22 | class YAMLError(Exception): 23 | pass 24 | 25 | class MarkedYAMLError(YAMLError): 26 | problem_mark: Mark 27 | 28 | 29 | def dump(data: Any, stream: Optional[IO[Any]] = None, Dumper: Dumper = Dumper(), **kwds: Any) -> Any: ... 30 | def load(stream: IO[Any], Loader: Optional[Type] = Loader) -> Any: ... 31 | 32 | class YAMLObjectMetaclass(type): 33 | def __init__(cls: Type[T], name: str, bases: Tuple[type, ...], dct: Dict[str, Any]) -> None: ... 34 | 35 | class YAMLObject(metaclass=YAMLObjectMetaclass): 36 | @classmethod 37 | def to_yaml(cls: Type[T], dumper: Dumper, data: T) -> Node: ... 38 | 39 | @classmethod 40 | def from_yaml(cls: Type[T], loader: Loader, node: Node) -> T: ... 41 | 42 | 43 | def add_representer(cls: Type[T], method: Callable[[Dumper, T], Node]) -> None: ... 44 | def add_constructor(yaml_tag: str, method: Callable[[Loader, Node], T]) -> None: ... -------------------------------------------------------------------------------- /tests/EP01.qp: -------------------------------------------------------------------------------- 1 | 0 I -1 2 | 1000 I -1 3 | 2000 I -1 4 | 3000 I -1 5 | -------------------------------------------------------------------------------- /tests/chapters.txt: -------------------------------------------------------------------------------- 1 | CHAPTER01=00:00:01.500 2 | CHAPTER01NAME=Chapter 01 3 | CHAPTER02=00:00:05.500 4 | CHAPTER02NAME=01 5 | CHAPTER03=00:01:05 6 | CHAPTER03NAME= -------------------------------------------------------------------------------- /tests/koizumi_tc.txt: -------------------------------------------------------------------------------- 1 | # timecode format v1 2 | Assume 29.970030 3 | # TDecimate v1.0.3 by tritical 4 | # Mode 5 - Auto-generated mkv timecodes file 5 | 0,34499,23.976024 6 | 39005,39208,23.976024 7 | # vfr stats: 90.59% film 09.41% video 8 | # vfr stats: 43381 - film 4505 - video 47886 - total 9 | # vfr stats: longest vid section - 4505 frames 10 | # vfr stats: # of detected vid sections - 1 -------------------------------------------------------------------------------- /tests/monte_01_tc.txt: -------------------------------------------------------------------------------- 1 | # timecode format v1 2 | Assume 29.970030 3 | # TDecimate v1.0.3 by tritical 4 | # Mode 5 - Auto-generated mkv timecodes file 5 | 0,411,23.976024 6 | 422,469,23.976024 7 | 475,822,23.976024 8 | 828,1475,23.976024 9 | 1536,1647,23.976024 10 | 1648,1650,17.982018 11 | 1651,2174,23.976024 12 | 2210,2213,23.976024 13 | 2229,2236,23.976024 14 | 2242,2321,23.976024 15 | 2332,2339,23.976024 16 | 2340,2342,17.982018 17 | 2343,2798,23.976024 18 | 2799,2801,17.982018 19 | 2802,3161,23.976024 20 | 3167,3170,23.976024 21 | 3181,8632,23.976024 22 | 8648,8731,23.976024 23 | 8732,8734,17.982018 24 | 8735,9114,23.976024 25 | 9115,9117,17.982018 26 | 9118,9413,23.976024 27 | 9414,9416,17.982018 28 | 9417,9504,23.976024 29 | 9510,17637,23.976024 30 | 17643,17710,23.976024 31 | 17716,17718,17.982018 32 | 17719,20994,23.976024 33 | 20995,20997,17.982018 34 | 20998,22817,23.976024 35 | 22823,24170,23.976024 36 | 24171,24173,17.982018 37 | 24174,24213,23.976024 38 | 24219,25750,23.976024 39 | 25751,25753,17.982018 40 | 25754,26261,23.976024 41 | 26262,26264,17.982018 42 | 26265,26544,23.976024 43 | 26545,26547,17.982018 44 | 26548,29607,23.976024 45 | 29613,29615,17.982018 46 | 29616,30347,23.976024 47 | 30368,30863,23.976024 48 | 30869,30912,23.976024 49 | 30918,30937,23.976024 50 | 30943,30946,23.976024 51 | 30952,31095,23.976024 52 | 31101,31108,23.976024 53 | 31114,31145,23.976024 54 | 31151,31334,23.976024 55 | 33825,33828,23.976024 56 | 33849,34000,23.976024 57 | 34006,34008,17.982018 58 | 34009,34248,23.976024 59 | # vfr stats: 93.41% film 06.59% video 60 | # vfr stats: 39361 - film 2775 - video 42136 - total 61 | # vfr stats: longest vid section - 2490 frames 62 | # vfr stats: # of detected vid sections - 28 -------------------------------------------------------------------------------- /tests/script.vpy: -------------------------------------------------------------------------------- 1 | # pylint: skip-file 2 | 3 | import vapoursynth as vs 4 | core = vs.core 5 | 6 | # clip0 = core.dgdecodenv.DGSource(r'Pop Team Epic OP.dgi') 7 | clip0 = core.ffms2.Source(r'Pop Team Epic OP.mkv') 8 | clip0 = core.text.FrameNum(clip0) 9 | clip0.set_output(0) 10 | 11 | clip0.resize.Bicubic(format=vs.YUV420P10).set_output(1) 12 | 13 | clip1 = core.ffms2.Source(r'Cocoro Palette.mp4') 14 | # clip1 = core.ffms2.Source(r'Cross Ange Tenshi to Ryuu no Rondo 01.m2ts') 15 | clip1.set_output(10) 16 | 17 | alpha = core.std.BlankClip(width=1280, height=720, length=2232, fpsnum=24000, fpsden=1001, format=vs.GRAY16, color=(32768)) 18 | # alpha.set_output(1) 19 | 20 | clip0 = core.resize.Point(clip0, format=vs.YUV420P16) 21 | # clip0.set_output(3) 22 | # clip0.set_output(2, alpha) 23 | # clip0 = core.ffms2.Source(r'Pop Team Epic OP.mkv') 24 | # clip1 = core.std.BlankClip(width=848, height=480, color=[77, 255, 90], format=vs.YUV420P8, fpsnum=1000, fpsden=3) 25 | # print('exiting script.vpy...') 26 | 27 | # clip1 = core.raws.Source(r'YUV444P8.y4m') 28 | # clip1.set_output(20) 29 | 30 | core.std.BlankClip(width=1920, height=1080, length=24, fpsnum=24000, fpsden=1001, format=vs.YUV444P10, color=(40, 0, 0)).set_output(30) 31 | 32 | # core.std.BlankClip(width=1920, height=1080, length=24, fpsnum=24000, fpsden=1001, format=vs.GRAY8, color=(0x40)).set_output(31) 33 | 34 | core.std.BlankClip( format=vs.YUV444PS, color=(1, 0, -1.5)).set_output(31) 35 | core.std.BlankClip( format=vs.YUV444PS, color=(1, 0, -1)).set_output(32) 36 | core.std.BlankClip( format=vs.YUV444PS, color=(1, 0, -0.5)).set_output(33) 37 | core.std.BlankClip( format=vs.YUV444PS, color=(1, 0, 0)).set_output(34) 38 | core.std.BlankClip( format=vs.YUV444PS, color=(1, 0, 0.5)).set_output(35) 39 | core.std.BlankClip( format=vs.YUV444PS, color=(1, 0, 1)).set_output(36) 40 | core.std.BlankClip( format=vs.YUV444PS, color=(1, 0, 1.5)).set_output(37) 41 | 42 | # core.std.BlankClip( format=vs.RGB24, color=(128, 0, 0)).resize.Bicubic(format=vs.COMPATYUY2, matrix='709', matrix_in_s='709').set_output(38) 43 | 44 | 45 | # clip1 = core.raws.Source(r'YUV444P8 black.y4m') 46 | # clip1.set_output(31) 47 | -------------------------------------------------------------------------------- /tests/test.ass: -------------------------------------------------------------------------------- 1 | [Script Info] 2 | ; Script generated by Aegisub r8942 3 | ; http://www.aegisub.org/ 4 | Title: New subtitles 5 | ScriptType: v4.00+ 6 | WrapStyle: 0 7 | PlayResX: 640 8 | PlayResY: 480 9 | ScaledBorderAndShadow: yes 10 | YCbCr Matrix: TV.709 11 | 12 | [Aegisub Project Garbage] 13 | Active Line: 1 14 | 15 | [V4+ Styles] 16 | Format: Name, Fontname, Fontsize, PrimaryColour, SecondaryColour, OutlineColour, BackColour, Bold, Italic, Underline, StrikeOut, ScaleX, ScaleY, Spacing, Angle, BorderStyle, Outline, Shadow, Alignment, MarginL, MarginR, MarginV, Encoding 17 | Style: Default,Arial,20,&H00FFFFFF,&H000000FF,&H00000000,&H00000000,0,0,0,0,100,100,0,0,1,2,2,2,10,10,10,1 18 | 19 | [Events] 20 | Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text 21 | Dialogue: 0,0:00:10.00,0:00:10.00,Default,,0,0,0,, 22 | Dialogue: 0,0:00:20.00,0:00:20.00,Default,,0,0,0,, 23 | -------------------------------------------------------------------------------- /tests/timestamps v2.txt: -------------------------------------------------------------------------------- 1 | 0 2 | 40 3 | 80 4 | 100 5 | 120 6 | 150 7 | 200 -------------------------------------------------------------------------------- /vspreview/__init__.py: -------------------------------------------------------------------------------- 1 | from .main import main 2 | -------------------------------------------------------------------------------- /vspreview/__main__.py: -------------------------------------------------------------------------------- 1 | from .main import main 2 | main() 3 | -------------------------------------------------------------------------------- /vspreview/core/__init__.py: -------------------------------------------------------------------------------- 1 | from .abstracts import ( 2 | AbstractMainWindow, AbstractToolbar, AbstractToolbars, 3 | ) 4 | from .bases import ( 5 | AbstractYAMLObject, AbstractYAMLObjectSingleton, 6 | QABC, QAbstractSingleton, QAbstractYAMLObject, 7 | QAbstractYAMLObjectSingleton, QSingleton, 8 | QYAMLObject, QYAMLObjectSingleton, 9 | ) 10 | from .types import ( 11 | Frame, FrameInterval, FrameType, 12 | Time, TimeInterval, TimeType, 13 | Scene, Output, 14 | ) 15 | -------------------------------------------------------------------------------- /vspreview/core/abstracts.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from abc import abstractmethod 4 | import logging 5 | from pathlib import Path 6 | from typing import ( 7 | Any, cast, Iterator, Mapping, Optional, 8 | TYPE_CHECKING, Union, 9 | ) 10 | 11 | from PyQt5 import Qt 12 | from .bases import ( 13 | AbstractYAMLObjectSingleton, QABC, QAbstractYAMLObjectSingleton, 14 | ) 15 | from .better_abc import abstract_attribute 16 | from .types import Frame, Output, Time 17 | 18 | 19 | class AbstractMainWindow(Qt.QMainWindow, QAbstractYAMLObjectSingleton): 20 | if TYPE_CHECKING: 21 | from vspreview.models import Outputs 22 | from vspreview.widgets import Timeline 23 | 24 | __slots__ = () 25 | 26 | @abstractmethod 27 | def load_script(self, script_path: Path, external_args: str = '', reloading = False) -> None: 28 | raise NotImplementedError 29 | 30 | @abstractmethod 31 | def reload_script(self) -> None: 32 | raise NotImplementedError 33 | 34 | @abstractmethod 35 | def init_outputs(self) -> None: 36 | raise NotImplementedError 37 | 38 | @abstractmethod 39 | def switch_output(self, value: Union[int, Output]) -> None: 40 | raise NotImplementedError() 41 | 42 | @abstractmethod 43 | def switch_frame(self, pos: Union[Frame, Time], *, render_frame: bool = True) -> None: 44 | raise NotImplementedError() 45 | 46 | @abstractmethod 47 | def show_message(self, message: str, timeout: Optional[int] = None) -> None: 48 | raise NotImplementedError 49 | 50 | central_widget: Qt.QWidget = abstract_attribute() 51 | clipboard : Qt.QClipboard = abstract_attribute() 52 | current_frame : Frame = abstract_attribute() 53 | current_time : Time = abstract_attribute() 54 | current_output: Output = abstract_attribute() 55 | display_scale : float = abstract_attribute() 56 | graphics_scene: Qt.QGraphicsScene = abstract_attribute() 57 | graphics_view : Qt.QGraphicsView = abstract_attribute() 58 | outputs : Outputs = abstract_attribute() 59 | timeline : Timeline = abstract_attribute() 60 | toolbars : AbstractToolbars = abstract_attribute() # pylint: disable=used-before-assignment 61 | save_on_exit : bool = abstract_attribute() 62 | script_path : Path = abstract_attribute() 63 | statusbar : Qt.QStatusBar = abstract_attribute() 64 | 65 | 66 | class AbstractToolbar(Qt.QWidget, QABC): 67 | if TYPE_CHECKING: 68 | from vspreview.widgets import Notches 69 | 70 | __slots__ = ( 71 | 'main', 'toggle_button', 72 | ) 73 | 74 | if TYPE_CHECKING: 75 | notches_changed = Qt.pyqtSignal(AbstractToolbar) # pylint: disable=undefined-variable 76 | else: 77 | notches_changed = Qt.pyqtSignal(object) 78 | 79 | def __init__(self, main: AbstractMainWindow, name: str) -> None: 80 | super().__init__(main.central_widget) 81 | self.main = main 82 | 83 | self.setFocusPolicy(Qt.Qt.ClickFocus) 84 | 85 | self.notches_changed.connect(self.main.timeline.update_notches) 86 | 87 | self.toggle_button = Qt.QToolButton(self) 88 | self.toggle_button.setCheckable(True) 89 | self.toggle_button.setText(name) 90 | self.toggle_button.clicked.connect(self.on_toggle) 91 | 92 | self.setVisible(False) 93 | 94 | 95 | def on_toggle(self, new_state: bool) -> None: 96 | # invoking order matters 97 | self.setVisible(new_state) 98 | self.resize_main_window(new_state) 99 | 100 | def on_current_frame_changed(self, frame: Frame, time: Time) -> None: 101 | pass 102 | 103 | def on_current_output_changed(self, index: int, prev_index: int) -> None: 104 | pass 105 | 106 | def on_script_unloaded(self) -> None: 107 | pass 108 | 109 | def on_script_loaded(self) -> None: 110 | pass 111 | 112 | def get_notches(self) -> Notches: 113 | from vspreview.widgets import Notches 114 | 115 | return Notches() 116 | 117 | def is_notches_visible(self) -> bool: 118 | return self.isVisible() 119 | 120 | def resize_main_window(self, expanding: bool) -> None: 121 | if self.main.windowState() in (Qt.Qt.WindowMaximized, 122 | Qt.Qt.WindowFullScreen): 123 | return 124 | 125 | if expanding: 126 | self.main.resize( 127 | self.main.width(), 128 | self.main.height() + self.height() + round(6 * self.main.display_scale)) 129 | if not expanding: 130 | self.main.resize( 131 | self.main.width(), 132 | self.main.height() - self.height() - round(6 * self.main.display_scale)) 133 | self.main.timeline.full_repaint() 134 | 135 | def __getstate__(self) -> Mapping[str, Any]: 136 | return { 137 | 'toggle': self.toggle_button.isChecked() 138 | } 139 | 140 | def __setstate__(self, state: Mapping[str, Any]) -> None: 141 | try: 142 | toggle = state['toggle'] 143 | if not isinstance(toggle, bool): 144 | raise TypeError 145 | except (KeyError, TypeError): 146 | logging.warning( 147 | 'Storage loading: Toolbar: failed to parse toggle') 148 | toggle = self.main.TOGGLE_TOOLBAR 149 | 150 | if self.toggle_button.isChecked() != toggle: 151 | self.toggle_button.click() 152 | 153 | 154 | class AbstractToolbars(AbstractYAMLObjectSingleton): 155 | yaml_tag: str = abstract_attribute() 156 | 157 | __slots__ = () 158 | 159 | # special toolbar ignored by len() 160 | # and not accessible via subscription and 'in' operator 161 | main : AbstractToolbar = abstract_attribute() 162 | 163 | playback : AbstractToolbar = abstract_attribute() 164 | scening : AbstractToolbar = abstract_attribute() 165 | pipette : AbstractToolbar = abstract_attribute() 166 | benchmark: AbstractToolbar = abstract_attribute() 167 | misc : AbstractToolbar = abstract_attribute() 168 | debug : AbstractToolbar = abstract_attribute() 169 | 170 | toolbars_names = ('playback', 'scening', 'pipette', 'benchmark', 'misc', 'debug') 171 | # 'main' should be the first 172 | all_toolbars_names = ['main'] + list(toolbars_names) 173 | 174 | def __getitem__(self, index: int) -> AbstractToolbar: 175 | if index >= len(self.toolbars_names): 176 | raise IndexError 177 | return cast(AbstractToolbar, getattr(self, self.toolbars_names[index])) 178 | 179 | def __len__(self) -> int: 180 | return len(self.toolbars_names) 181 | 182 | @abstractmethod 183 | def __getstate__(self) -> Mapping[str, Any]: 184 | raise NotImplementedError 185 | 186 | @abstractmethod 187 | def __setstate__(self, state: Mapping[str, Any]) -> None: 188 | raise NotImplementedError 189 | 190 | if TYPE_CHECKING: 191 | # https://github.com/python/mypy/issues/2220 192 | def __iter__(self) -> Iterator[AbstractToolbar]: ... 193 | -------------------------------------------------------------------------------- /vspreview/core/bases.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from typing import ( 4 | Any, cast, Dict, List, no_type_check, Optional, Type, TypeVar, Tuple, 5 | ) 6 | 7 | from PyQt5 import sip 8 | from yaml import YAMLObject, YAMLObjectMetaclass 9 | 10 | from .better_abc import ABCMeta 11 | 12 | # pylint: disable=too-few-public-methods,too-many-ancestors 13 | 14 | T = TypeVar('T') 15 | 16 | 17 | class SingletonMeta(type): 18 | def __init__(cls: Type[T], name: str, bases: Tuple[type, ...], dct: Dict[str, Any]) -> None: 19 | super().__init__(name, bases, dct) # type: ignore 20 | cls.instance: Optional[T] = None # type: ignore 21 | 22 | def __call__(cls, *args: Any, **kwargs: Any) -> T: 23 | if cls.instance is None: 24 | cls.instance = super().__call__(*args, **kwargs) 25 | return cls.instance 26 | 27 | def __new__(cls: Type[type], name: str, bases: Tuple[type, ...], dct: Dict[str, Any]) -> SingletonMeta: 28 | subcls = super(SingletonMeta, cls).__new__(cls, name, bases, dct) # type: ignore 29 | singleton_new = None 30 | for entry in subcls.__mro__: 31 | if entry.__class__ is SingletonMeta: 32 | singleton_new = entry.__new__ 33 | if subcls.__new__ is not singleton_new: 34 | subcls.__default_new__ = subcls.__new__ 35 | subcls.__new__ = singleton_new 36 | return cast(SingletonMeta, subcls) 37 | class Singleton(metaclass=SingletonMeta): 38 | @no_type_check 39 | def __new__(cls: Type[T], *args: Any, **kwargs: Any) -> T: 40 | if cls.instance is None: 41 | if hasattr(cls, '__default_new__'): 42 | cls.instance = cls.__default_new__(cls, *args, **kwargs) # pylint: disable=no-member 43 | else: 44 | cls.instance = super(Singleton, cls).__new__(cls) 45 | return cls.instance 46 | 47 | class AbstractYAMLObjectMeta(YAMLObjectMetaclass, ABCMeta): 48 | pass 49 | class AbstractYAMLObject(YAMLObject, metaclass=AbstractYAMLObjectMeta): 50 | pass 51 | 52 | class AbstractYAMLObjectSingletonMeta(SingletonMeta, AbstractYAMLObjectMeta): 53 | pass 54 | class AbstractYAMLObjectSingleton(AbstractYAMLObject, Singleton, metaclass=AbstractYAMLObjectSingletonMeta): 55 | pass 56 | 57 | class QABCMeta(sip.wrappertype, ABCMeta): # type: ignore 58 | pass 59 | class QABC(metaclass=QABCMeta): 60 | pass 61 | 62 | class QSingletonMeta(SingletonMeta, sip.wrappertype): # type: ignore 63 | pass 64 | class QSingleton(Singleton, metaclass=QSingletonMeta): 65 | pass 66 | 67 | class QAbstractSingletonMeta(QSingletonMeta, QABCMeta): 68 | pass 69 | class QAbstractSingleton(Singleton, metaclass=QAbstractSingletonMeta): 70 | pass 71 | 72 | class QYAMLObjectMeta(YAMLObjectMetaclass, sip.wrappertype): # type: ignore 73 | pass 74 | class QYAMLObject(YAMLObject, metaclass=QYAMLObjectMeta): 75 | pass 76 | 77 | class QAbstractYAMLObjectMeta(QYAMLObjectMeta, QABC): 78 | pass 79 | class QAbstractYAMLObject(YAMLObject, metaclass=QAbstractYAMLObjectMeta): 80 | pass 81 | 82 | class QYAMLObjectSingletonMeta(QSingletonMeta, QYAMLObjectMeta): 83 | pass 84 | class QYAMLObjectSingleton(QYAMLObject, Singleton, metaclass=QYAMLObjectSingletonMeta): 85 | pass 86 | 87 | class QAbstractYAMLObjectSingletonMeta(QYAMLObjectSingletonMeta, QABCMeta): 88 | pass 89 | class QAbstractYAMLObjectSingleton(QYAMLObjectSingleton, metaclass=QAbstractYAMLObjectSingletonMeta): 90 | pass 91 | -------------------------------------------------------------------------------- /vspreview/core/better_abc.py: -------------------------------------------------------------------------------- 1 | # original: https://stackoverflow.com/a/50381071 2 | # pylint: skip-file 3 | from __future__ import annotations 4 | 5 | from abc import ABCMeta as NativeABCMeta 6 | from typing import Any, cast, Optional, TypeVar, Union 7 | 8 | 9 | T = TypeVar('T') 10 | 11 | 12 | class DummyAttribute: 13 | pass 14 | 15 | 16 | def abstract_attribute(obj: Optional[T] = None) -> T: 17 | if obj is None: 18 | obj = DummyAttribute() # type: ignore 19 | obj._is_abstract_attribute_ = True # type: ignore 20 | return cast(T, obj) 21 | 22 | 23 | class ABCMeta(NativeABCMeta): 24 | def __call__(cls, *args: Any, **kwargs: Any) -> Any: 25 | instance = NativeABCMeta.__call__(cls, *args, **kwargs) 26 | abstract_attributes = [] 27 | for name in dir(instance): 28 | attr = getattr(instance, name, None) 29 | if attr is not None: 30 | if getattr(attr, '_is_abstract_attribute_', False): 31 | abstract_attributes.append(name) 32 | 33 | if len(abstract_attributes) > 0: 34 | raise NotImplementedError( 35 | "Class {} doesn't initialize following abstract attributes: {}" 36 | .format(cls.__name__, ', '.join(abstract_attributes))) 37 | return instance 38 | 39 | 40 | class ABC(metaclass=ABCMeta): 41 | pass 42 | -------------------------------------------------------------------------------- /vspreview/models/__init__.py: -------------------------------------------------------------------------------- 1 | from .outputs import Outputs 2 | from .scening import SceningList, SceningLists 3 | from .zoom_levels import ZoomLevels 4 | -------------------------------------------------------------------------------- /vspreview/models/outputs.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from collections import OrderedDict 4 | import logging 5 | from typing import Any, cast, Iterator, List, Mapping, Optional 6 | 7 | from PyQt5 import Qt 8 | import vapoursynth as vs 9 | 10 | from vspreview.core import Output, QYAMLObjectSingleton, QYAMLObject 11 | from vspreview.utils import debug, main_window 12 | 13 | 14 | # TODO: support non-YUV outputs 15 | 16 | 17 | class Outputs(Qt.QAbstractListModel, QYAMLObject): 18 | yaml_tag = '!Outputs' 19 | 20 | __slots__ = ( 21 | 'items', 22 | ) 23 | 24 | def __init__(self, local_storage: Optional[Mapping[str, Output]] = None) -> None: 25 | super().__init__() 26 | self.items: List[Output] = [] 27 | 28 | local_storage = local_storage if local_storage is not None else {} 29 | 30 | if main_window().ORDERED_OUTPUTS: 31 | outputs = OrderedDict(sorted(vs.get_outputs().items())) 32 | else: 33 | outputs = vs.get_outputs() 34 | 35 | for i, vs_output in outputs.items(): 36 | try: 37 | output = local_storage[str(i)] 38 | output.__init__(vs_output, i) # type: ignore 39 | except KeyError: 40 | output = Output(vs_output, i) 41 | 42 | self.items.append(output) 43 | 44 | def __getitem__(self, i: int) -> Output: 45 | return self.items[i] 46 | 47 | def __len__(self) -> int: 48 | return len(self.items) 49 | 50 | def index_of(self, item: Output) -> int: 51 | return self.items.index(item) 52 | 53 | def __getiter__(self) -> Iterator[Output]: 54 | return iter(self.items) 55 | 56 | def append(self, item: Output) -> int: 57 | index = len(self.items) 58 | self.beginInsertRows(Qt.QModelIndex(), index, index) 59 | self.items.append(item) 60 | self.endInsertRows() 61 | 62 | return len(self.items) - 1 63 | 64 | def clear(self) -> None: 65 | self.beginRemoveRows(Qt.QModelIndex(), 0, len(self.items)) 66 | self.items.clear() 67 | self.endRemoveRows() 68 | 69 | def data(self, index: Qt.QModelIndex, role: int = Qt.Qt.UserRole) -> Any: 70 | if not index.isValid(): 71 | return None 72 | if index.row() >= len(self.items): 73 | return None 74 | 75 | if role == Qt.Qt.DisplayRole: 76 | return self.items[index.row()].name 77 | if role == Qt.Qt.EditRole: 78 | return self.items[index.row()].name 79 | if role == Qt.Qt.UserRole: 80 | return self.items[index.row()] 81 | return None 82 | 83 | def rowCount(self, parent: Qt.QModelIndex = Qt.QModelIndex()) -> int: 84 | if self.items is not None: 85 | return len(self.items) 86 | 87 | def flags(self, index: Qt.QModelIndex) -> Qt.Qt.ItemFlags: 88 | if not index.isValid(): 89 | return cast(Qt.Qt.ItemFlags, Qt.Qt.ItemIsEnabled) 90 | 91 | return cast(Qt.Qt.ItemFlags, 92 | super().flags(index) | Qt.Qt.ItemIsEditable) 93 | 94 | def setData(self, index: Qt.QModelIndex, value: Any, role: int = Qt.Qt.EditRole) -> bool: 95 | if not index.isValid(): 96 | return False 97 | if not role == Qt.Qt.EditRole: 98 | return False 99 | if not isinstance(value, str): 100 | return False 101 | 102 | self.items[index.row()].name = value 103 | self.dataChanged.emit(index, index, [role]) 104 | return True 105 | 106 | def __getstate__(self) -> Mapping[str, Any]: 107 | return dict(zip([ 108 | str(output.index) for output in self.items], 109 | [ output for output in self.items] 110 | )) 111 | 112 | def __setstate__(self, state: Mapping[str, Output]) -> None: 113 | for key, value in state.items(): 114 | if not isinstance(key, str): 115 | raise TypeError( 116 | f'Storage loading: Outputs: key {key} is not a string') 117 | if not isinstance(value, Output): 118 | raise TypeError( 119 | f'Storage loading: Outputs: value of key {key} is not an Output') 120 | 121 | self.__init__(state) # type: ignore 122 | -------------------------------------------------------------------------------- /vspreview/models/scening.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from bisect import bisect_left, bisect_right 4 | import logging 5 | from typing import ( 6 | Any, Callable, cast, Dict, Iterator, List, Mapping, Optional, Set, Tuple, 7 | Union, 8 | ) 9 | 10 | from PyQt5 import Qt 11 | 12 | from vspreview.core import ( 13 | Frame, FrameInterval, QYAMLObject, 14 | Scene, Time, TimeInterval, 15 | ) 16 | from vspreview.utils import debug, main_window 17 | 18 | 19 | class SceningList(Qt.QAbstractTableModel, QYAMLObject): 20 | yaml_tag = '!SceningList' 21 | 22 | __slots__ = ( 23 | 'name', 'items', 24 | ) 25 | 26 | START_FRAME_COLUMN = 0 27 | END_FRAME_COLUMN = 1 28 | START_TIME_COLUMN = 2 29 | END_TIME_COLUMN = 3 30 | LABEL_COLUMN = 4 31 | COLUMN_COUNT = 5 32 | 33 | def __init__(self, name: str = '', items: Optional[List[Scene]] = None) -> None: 34 | super().__init__() 35 | self.name = name 36 | self.items = items if items is not None else [] 37 | 38 | self.main = main_window() 39 | 40 | def rowCount(self, parent: Qt.QModelIndex = Qt.QModelIndex()) -> int: 41 | return len(self.items) 42 | 43 | def columnCount(self, parent: Qt.QModelIndex = Qt.QModelIndex()) -> int: 44 | return self.COLUMN_COUNT 45 | 46 | def headerData(self, section: int, orientation: Qt.Qt.Orientation, role: int = Qt.Qt.DisplayRole) -> Any: 47 | if role != Qt.Qt.DisplayRole: 48 | return None 49 | 50 | if orientation == Qt.Qt.Horizontal: 51 | if section == self.START_FRAME_COLUMN: 52 | return 'Start' 53 | if section == self.END_FRAME_COLUMN: 54 | return 'End' 55 | if section == self.START_TIME_COLUMN: 56 | return 'Start' 57 | if section == self.END_TIME_COLUMN: 58 | return 'End' 59 | if section == self.LABEL_COLUMN: 60 | return 'Label' 61 | if orientation == Qt.Qt.Vertical: 62 | return section + 1 63 | return None 64 | 65 | def data(self, index: Qt.QModelIndex, role: int = Qt.Qt.UserRole) -> Any: 66 | if not index.isValid(): 67 | return None 68 | row = index.row() 69 | if row >= len(self.items): 70 | return None 71 | column = index.column() 72 | if column >= self.COLUMN_COUNT: 73 | return None 74 | 75 | if role in (Qt.Qt.DisplayRole, 76 | Qt.Qt. EditRole): 77 | if column == self.START_FRAME_COLUMN: 78 | return str(self.items[row].start) 79 | if column == self.END_FRAME_COLUMN: 80 | if self.items[row].end != self.items[row].start: 81 | return str(self.items[row].end) 82 | else: 83 | return '' 84 | if column == self.START_TIME_COLUMN: 85 | return str(Time(self.items[row].start)) 86 | if column == self.END_TIME_COLUMN: 87 | if self.items[row].end != self.items[row].start: 88 | return str(Time(self.items[row].end)) 89 | else: 90 | return '' 91 | if column == self.LABEL_COLUMN: 92 | return str(self.items[row].label) 93 | 94 | if role == Qt.Qt.UserRole: 95 | if column == self.START_FRAME_COLUMN: 96 | return self.items[row].start 97 | if column == self.END_FRAME_COLUMN: 98 | return self.items[row].end 99 | if column == self.START_TIME_COLUMN: 100 | return Time(self.items[row].start) 101 | if column == self.END_TIME_COLUMN: 102 | return Time(self.items[row].end) 103 | if column == self.LABEL_COLUMN: 104 | return self.items[row].label 105 | 106 | return None 107 | 108 | def setData(self, index: Qt.QModelIndex, value: Any, role: int = Qt.Qt.EditRole) -> bool: 109 | from copy import deepcopy 110 | 111 | if not index.isValid(): 112 | return False 113 | if role not in (Qt.Qt.EditRole, 114 | Qt.Qt.UserRole): 115 | return False 116 | 117 | row = index.row() 118 | column = index.column() 119 | scene = deepcopy(self.items[row]) 120 | 121 | if column == self.START_FRAME_COLUMN: 122 | if not isinstance(value, Frame): 123 | raise TypeError 124 | if scene.start != scene.end: 125 | if value > scene.end: 126 | return False 127 | scene.start = value 128 | else: 129 | scene.start = value 130 | scene.end = value 131 | proper_update = True 132 | elif column == self.END_FRAME_COLUMN: 133 | if not isinstance(value, Frame): 134 | raise TypeError 135 | if scene.start != scene.end: 136 | if value < scene.start: 137 | return False 138 | scene.end = value 139 | else: 140 | scene.start = value 141 | scene.end = value 142 | proper_update = True 143 | elif column == self.START_TIME_COLUMN: 144 | if not isinstance(value, Time): 145 | raise TypeError 146 | frame = Frame(value) 147 | if scene.start != scene.end: 148 | if frame > scene.end: 149 | return False 150 | scene.start = frame 151 | else: 152 | scene.start = frame 153 | scene.end = frame 154 | proper_update = True 155 | elif column == self.END_TIME_COLUMN: 156 | if not isinstance(value, Time): 157 | raise TypeError 158 | frame = Frame(value) 159 | if scene.start != scene.end: 160 | if frame < scene.start: 161 | return False 162 | scene.end = frame 163 | else: 164 | scene.start = frame 165 | scene.end = frame 166 | proper_update = True 167 | elif column == self.LABEL_COLUMN: 168 | if not isinstance(value, str): 169 | raise TypeError 170 | scene.label = value 171 | proper_update = False 172 | 173 | if proper_update is True: 174 | i = bisect_right(self.items, scene) 175 | if i > row: 176 | i -= 1 177 | if i != row: 178 | self.beginMoveRows(self.createIndex(row, 0), row, row, 179 | self.createIndex(i, 0), i) 180 | del self.items[row] 181 | self.items.insert(i, scene) 182 | self.endMoveRows() 183 | else: 184 | self.items[index.row()] = scene 185 | self.dataChanged.emit(index, index) 186 | else: 187 | self.items[index.row()] = scene 188 | self.dataChanged.emit(index, index) 189 | return True 190 | 191 | def __len__(self) -> int: 192 | return len(self.items) 193 | 194 | def __getitem__(self, i: int) -> Scene: 195 | return self.items[i] 196 | 197 | def __setitem__(self, i: int, value: Scene) -> None: 198 | if i >= len(self.items): 199 | raise IndexError 200 | 201 | self.items[i] = value 202 | self.dataChanged.emit( 203 | self.createIndex(i, 0), 204 | self.createIndex(i, self.COLUMN_COUNT - 1)) 205 | 206 | def __contains__(self, item: Union[Scene, Frame]) -> bool: 207 | if isinstance(item, Scene): 208 | return item in self.items 209 | if isinstance(item, Frame): 210 | for scene in self.items: 211 | if item in (scene.start, scene.end): 212 | return True 213 | return False 214 | raise TypeError 215 | 216 | def __getiter__(self) -> Iterator[Scene]: 217 | return iter(self.items) 218 | 219 | def add(self, start: Frame, end: Optional[Frame] = None, label: str = '') -> Scene: 220 | scene = Scene(start, end, label) 221 | 222 | if scene in self.items: 223 | return scene 224 | 225 | if scene.end > self.main.current_output.end_frame: 226 | raise ValueError('New Scene is out of bounds of output') 227 | 228 | index = bisect_right(self.items, scene) 229 | self.beginInsertRows(Qt.QModelIndex(), index, index) 230 | self.items.insert(index, scene) 231 | self.endInsertRows() 232 | 233 | return scene 234 | 235 | def remove(self, i: Union[int, Scene]) -> None: 236 | if isinstance(i, Scene): 237 | i = self.items.index(i) 238 | 239 | if i >= 0 and i < len(self.items): 240 | self.beginRemoveRows(Qt.QModelIndex(), i, i) 241 | del(self.items[i]) 242 | self.endRemoveRows() 243 | else: 244 | raise IndexError 245 | 246 | def get_next_frame(self, initial: Frame) -> Optional[Frame]: 247 | result = None 248 | result_delta = FrameInterval(int(self.main.current_output.end_frame)) 249 | for scene in self.items: 250 | if FrameInterval(0) < scene.start - initial < result_delta: 251 | result = scene.start 252 | result_delta = scene.start - initial 253 | if FrameInterval(0) < scene.end - initial < result_delta: 254 | result = scene.end 255 | result_delta = scene.end - initial 256 | 257 | return result 258 | 259 | def get_prev_frame(self, initial: Frame) -> Optional[Frame]: 260 | result = None 261 | result_delta = FrameInterval(int(self.main.current_output.end_frame)) 262 | for scene in self.items: 263 | if FrameInterval(0) < initial - scene.start < result_delta: 264 | result = scene.start 265 | result_delta = initial - scene.start 266 | if FrameInterval(0) < initial - scene.end < result_delta: 267 | result = scene.end 268 | result_delta = initial - scene.end 269 | 270 | return result 271 | 272 | def __getstate__(self) -> Mapping[str, Any]: 273 | return {name: getattr(self, name) 274 | for name in self.__slots__} 275 | 276 | def __setstate__(self, state: Mapping[str, Any]) -> None: 277 | try: 278 | name = state['name'] 279 | if not isinstance(name, str): 280 | raise TypeError( 281 | '\'name\' of a SceningList is not a string. It\'s most probably corrupted.') 282 | 283 | items = state['items'] 284 | if not isinstance(items, list): 285 | raise TypeError( 286 | '\'items\' of a SceningList is not a List. It\'s most probably corrupted.') 287 | for item in items: 288 | if not isinstance(item, Scene): 289 | raise TypeError( 290 | 'One of the items of SceningList is not a Scene. It\'s most probably corrupted.') 291 | except KeyError: 292 | raise KeyError( 293 | 'SceningList lacks one or more of its fields. It\'s most probably corrupted. Check those: {}.' 294 | .format(', '.join(self.__slots__))) 295 | 296 | self.__init__(name, items) # type: ignore 297 | 298 | 299 | class SceningLists(Qt.QAbstractListModel, QYAMLObject): 300 | yaml_tag = '!SceningLists' 301 | 302 | __slots__ = ( 303 | 'items', 304 | ) 305 | 306 | def __init__(self, items: Optional[List[SceningList]] = None) -> None: 307 | super().__init__() 308 | self.main = main_window() 309 | self.items = items if items is not None else [] 310 | 311 | def __getitem__(self, i: int) -> SceningList: 312 | return self.items[i] 313 | 314 | def __len__(self) -> int: 315 | return len(self.items) 316 | 317 | def __getiter__(self) -> Iterator[SceningList]: 318 | return iter(self.items) 319 | 320 | def index_of(self, item: SceningList, start_i: int = 0, end_i: int = 0) -> int: 321 | if end_i == 0: 322 | end_i = len(self.items) 323 | return self.items.index(item, start_i, end_i) 324 | 325 | def rowCount(self, parent: Qt.QModelIndex = Qt.QModelIndex()) -> int: 326 | return len(self.items) 327 | 328 | def data(self, index: Qt.QModelIndex, role: int = Qt.Qt.UserRole) -> Any: 329 | if not index.isValid(): 330 | return None 331 | if index.row() >= len(self.items): 332 | return None 333 | 334 | if role in (Qt.Qt.DisplayRole, 335 | Qt.Qt.EditRole): 336 | return self.items[index.row()].name 337 | if role == Qt.Qt.UserRole: 338 | return self.items[index.row()] 339 | return None 340 | 341 | def flags(self, index: Qt.QModelIndex) -> Qt.Qt.ItemFlags: 342 | if not index.isValid(): 343 | return cast(Qt.Qt.ItemFlags, Qt.Qt.ItemIsEnabled) 344 | 345 | return cast(Qt.Qt.ItemFlags, 346 | super().flags(index) | Qt.Qt.ItemIsEditable) 347 | 348 | def setData(self, index: Qt.QModelIndex, value: Any, role: int = Qt.Qt.EditRole) -> bool: 349 | if not index.isValid(): 350 | return False 351 | if role not in (Qt.Qt.EditRole, 352 | Qt.Qt.UserRole): 353 | return False 354 | if not isinstance(value, str): 355 | return False 356 | 357 | self.items[index.row()].name = value 358 | self.dataChanged.emit(index, index) 359 | return True 360 | 361 | def insertRow(self, i: int, parent: Qt.QModelIndex = Qt.QModelIndex()) -> bool: 362 | self.add(i=i) 363 | return True 364 | 365 | def removeRow(self, i: int, parent: Qt.QModelIndex = Qt.QModelIndex()) -> bool: 366 | try: 367 | self.remove(i) 368 | except IndexError: 369 | return False 370 | 371 | return True 372 | 373 | def add(self, name: Optional[str] = None, i: Optional[int] = None) -> Tuple[SceningList, int]: 374 | if i is None: 375 | i = len(self.items) 376 | 377 | self.beginInsertRows(Qt.QModelIndex(), i, i) 378 | if name is None: 379 | self.items.insert(i, SceningList('List {}' 380 | .format(len(self.items) + 1))) 381 | else: 382 | self.items.insert(i, SceningList(name)) 383 | self.endInsertRows() 384 | return self.items[i], i 385 | 386 | def add_list(self, scening_list: SceningList) -> int: 387 | i = len(self.items) 388 | self.beginInsertRows(Qt.QModelIndex(), i, i) 389 | self.items.insert(i, scening_list) 390 | self.endInsertRows() 391 | return i 392 | 393 | def remove(self, item: Union[int, SceningList]) -> None: 394 | i = item 395 | if isinstance(i, SceningList): 396 | i = self.items.index(i) 397 | 398 | if i >= 0 and i < len(self.items): 399 | self.beginRemoveRows(Qt.QModelIndex(), i, i) 400 | del(self.items[i]) 401 | self.endRemoveRows() 402 | else: 403 | raise IndexError 404 | 405 | def __getstate__(self) -> Mapping[str, Any]: 406 | return { 407 | name: getattr(self, name) 408 | for name in self.__slots__ 409 | } 410 | 411 | def __setstate__(self, state: Mapping[str, Any]) -> None: 412 | try: 413 | items = state['items'] 414 | if not isinstance(items, list): 415 | raise TypeError( 416 | '\'items\' of a SceningLists is not a List. It\'s most probably corrupted.') 417 | for item in items: 418 | if not isinstance(item, SceningList): 419 | raise TypeError( 420 | 'One of the items of a SceningLists is not a SceningList. It\'s most probably corrupted.') 421 | except KeyError: 422 | raise KeyError( 423 | 'SceningLists lacks one or more of its fields. It\'s most probably corrupted. Check those: {}.' 424 | .format(', '.join(self.__slots__))) 425 | 426 | self.__init__(items) # type: ignore 427 | -------------------------------------------------------------------------------- /vspreview/models/zoom_levels.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import logging 4 | from typing import Any, Iterator, Sequence 5 | 6 | from PyQt5 import Qt 7 | 8 | from vspreview.utils import debug 9 | 10 | 11 | class ZoomLevels(Qt.QAbstractListModel): 12 | __slots__ = ( 13 | 'levels', 14 | ) 15 | 16 | def __init__(self, init_seq: Sequence[float]) -> None: 17 | super().__init__() 18 | self.levels = list(init_seq) 19 | 20 | def __getitem__(self, i: int) -> float: 21 | return self.levels[i] 22 | 23 | def __len__(self) -> int: 24 | return len(self.levels) 25 | 26 | def __getiter__(self) -> Iterator[float]: 27 | return iter(self.levels) 28 | 29 | def index_of(self, item: float) -> int: 30 | return self.levels.index(item) 31 | 32 | def data(self, index: Qt.QModelIndex, role: int = Qt.Qt.UserRole) -> Any: 33 | if (not index.isValid() 34 | or index.row() >= len(self.levels)): 35 | return None 36 | 37 | if role == Qt.Qt.DisplayRole: 38 | return '{}%'.format(round(self.levels[index.row()] * 100)) 39 | if role == Qt.Qt.UserRole: 40 | return self.levels[index.row()] 41 | return None 42 | 43 | def rowCount(self, parent: Qt.QModelIndex = Qt.QModelIndex()) -> int: 44 | if self.levels is not None: 45 | return len(self.levels) 46 | -------------------------------------------------------------------------------- /vspreview/toolbars/__init__.py: -------------------------------------------------------------------------------- 1 | from .debug import DebugToolbar 2 | from .misc import MiscToolbar 3 | from .pipette import PipetteToolbar 4 | from .playback import PlaybackToolbar 5 | from .scening import SceningToolbar 6 | from .benchmark import BenchmarkToolbar 7 | -------------------------------------------------------------------------------- /vspreview/toolbars/benchmark.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from collections import deque 4 | from concurrent.futures import Future 5 | import logging 6 | from time import perf_counter 7 | from typing import Deque, Optional, Union 8 | 9 | from PyQt5 import Qt 10 | 11 | from vspreview.core import ( 12 | AbstractMainWindow, AbstractToolbar, Frame, FrameInterval, Time, 13 | TimeInterval, 14 | ) 15 | from vspreview.utils import ( 16 | debug, get_usable_cpus_count, qt_silent_call, set_qobject_names, 17 | vs_clear_cache, 18 | ) 19 | from vspreview.widgets import FrameEdit, TimeEdit 20 | 21 | 22 | # TODO: think of proper fix for frame data sharing issue 23 | 24 | 25 | class BenchmarkToolbar(AbstractToolbar): 26 | __slots__ = ( 27 | 'start_frame_control', 'start_time_control', 28 | 'end_frame_control', 'end_time_control', 29 | 'total_frames_control', 'total_time_control', 30 | 'prefetch_checkbox', 'unsequenced_checkbox', 31 | 'run_abort_button', 'info_label', 32 | 'running', 'unsequenced', 'run_start_time', 33 | 'start_frame', 'end_frame', 'total_frames', 34 | 'frames_left', 'buffer', 'update_info_timer', 35 | ) 36 | 37 | def __init__(self, main: AbstractMainWindow) -> None: 38 | super().__init__(main, 'Benchmark') 39 | 40 | self.setup_ui() 41 | 42 | self.running = False 43 | self.unsequenced = False 44 | self.buffer: Deque[Future] = deque() 45 | self.run_start_time = 0.0 46 | self.start_frame = Frame(0) 47 | self. end_frame = Frame(0) 48 | self.total_frames = FrameInterval(0) 49 | self.frames_left = FrameInterval(0) 50 | 51 | self.sequenced_timer = Qt.QTimer() 52 | self.sequenced_timer.setTimerType(Qt.Qt.PreciseTimer) 53 | self.sequenced_timer.setInterval(0) 54 | 55 | self.update_info_timer = Qt.QTimer() 56 | self.update_info_timer.setTimerType(Qt.Qt.PreciseTimer) 57 | self.update_info_timer.setInterval( 58 | self.main.BENCHMARK_REFRESH_INTERVAL) 59 | 60 | self. start_frame_control.valueChanged.connect(lambda value: self.update_controls(start= value)) 61 | self. start_time_control.valueChanged.connect(lambda value: self.update_controls(start=Frame(value))) 62 | self. end_frame_control.valueChanged.connect(lambda value: self.update_controls( end= value)) 63 | self. end_time_control.valueChanged.connect(lambda value: self.update_controls( end=Frame(value))) 64 | self.total_frames_control.valueChanged.connect(lambda value: self.update_controls(total= value)) 65 | self. total_time_control.valueChanged.connect(lambda value: self.update_controls(total=FrameInterval(value))) 66 | self. prefetch_checkbox.stateChanged.connect(self.on_prefetch_changed) 67 | self. run_abort_button. clicked.connect(self.on_run_abort_pressed) 68 | self. sequenced_timer. timeout.connect(self._request_next_frame_sequenced) 69 | self. update_info_timer. timeout.connect(self.update_info) 70 | 71 | set_qobject_names(self) 72 | 73 | def setup_ui(self) -> None: 74 | layout = Qt.QHBoxLayout(self) 75 | layout.setObjectName('BenchmarkToolbar.setup_ui.layout') 76 | layout.setContentsMargins(0, 0, 0, 0) 77 | 78 | start_label = Qt.QLabel(self) 79 | start_label.setObjectName('BenchmarkToolbar.setup_ui.start_label') 80 | start_label.setText('Start:') 81 | layout.addWidget(start_label) 82 | 83 | self.start_frame_control = FrameEdit[Frame](self) 84 | layout.addWidget(self.start_frame_control) 85 | 86 | self.start_time_control = TimeEdit[Time](self) 87 | layout.addWidget(self.start_time_control) 88 | 89 | end_label = Qt.QLabel(self) 90 | end_label.setObjectName('BenchmarkToolbar.setup_ui.end_label') 91 | end_label.setText('End:') 92 | layout.addWidget(end_label) 93 | 94 | self.end_frame_control = FrameEdit[Frame](self) 95 | layout.addWidget(self.end_frame_control) 96 | 97 | self.end_time_control = TimeEdit[Time](self) 98 | layout.addWidget(self.end_time_control) 99 | 100 | total_label = Qt.QLabel(self) 101 | total_label.setObjectName('BenchmarkToolbar.setup_ui.total_label') 102 | total_label.setText('Total:') 103 | layout.addWidget(total_label) 104 | 105 | self.total_frames_control = FrameEdit[FrameInterval](self) 106 | self.total_frames_control.setMinimum(FrameInterval(1)) 107 | layout.addWidget(self.total_frames_control) 108 | 109 | self.total_time_control = TimeEdit[TimeInterval](self) 110 | layout.addWidget(self.total_time_control) 111 | 112 | self.prefetch_checkbox = Qt.QCheckBox(self) 113 | self.prefetch_checkbox.setText('Prefetch') 114 | self.prefetch_checkbox.setChecked(True) 115 | self.prefetch_checkbox.setToolTip( 116 | 'Request multiple frames in advance.') 117 | layout.addWidget(self.prefetch_checkbox) 118 | 119 | self.unsequenced_checkbox = Qt.QCheckBox(self) 120 | self.unsequenced_checkbox.setText('Unsequenced') 121 | self.unsequenced_checkbox.setChecked(True) 122 | self.unsequenced_checkbox.setToolTip( 123 | "If enabled, next frame will be requested each time " 124 | "frameserver returns completed frame. " 125 | "If disabled, first frame that's currently processing " 126 | "will be waited before requesting next. Like for playback. " 127 | ) 128 | layout.addWidget(self.unsequenced_checkbox) 129 | 130 | self.run_abort_button = Qt.QPushButton(self) 131 | self.run_abort_button.setText('Run') 132 | self.run_abort_button.setCheckable(True) 133 | layout.addWidget(self.run_abort_button) 134 | 135 | self.info_label = Qt.QLabel(self) 136 | layout.addWidget(self.info_label) 137 | 138 | layout.addStretch() 139 | 140 | def on_current_output_changed(self, index: int, prev_index: int) -> None: 141 | self. start_frame_control.setMaximum(self.main.current_output.end_frame) 142 | self. start_time_control.setMaximum(self.main.current_output.end_time) 143 | self. end_frame_control.setMaximum(self.main.current_output.end_frame) 144 | self. end_time_control.setMaximum(self.main.current_output.end_time) 145 | self.total_frames_control.setMaximum(self.main.current_output.total_frames) 146 | self. total_time_control.setMaximum(self.main.current_output.total_time) 147 | self. total_time_control.setMaximum(TimeInterval(FrameInterval(1))) 148 | 149 | 150 | def run(self) -> None: 151 | from copy import deepcopy 152 | 153 | from vapoursynth import VideoFrame 154 | 155 | if self.main.BENCHMARK_CLEAR_CACHE: 156 | vs_clear_cache() 157 | if self.main.BENCHMARK_FRAME_DATA_SHARING_FIX: 158 | self.main.current_output.graphics_scene_item.setImage( 159 | self.main.current_output.graphics_scene_item.image().copy()) 160 | 161 | self.start_frame = self.start_frame_control .value() 162 | self. end_frame = self. end_frame_control .value() 163 | self.total_frames = self.total_frames_control.value() 164 | self.frames_left = deepcopy(self.total_frames) 165 | if self.prefetch_checkbox.isChecked(): 166 | concurrent_requests_count = get_usable_cpus_count() 167 | else: 168 | concurrent_requests_count = 1 169 | 170 | self.unsequenced = self.unsequenced_checkbox.isChecked() 171 | if not self.unsequenced: 172 | self.buffer = deque([], concurrent_requests_count) 173 | self.sequenced_timer.start() 174 | 175 | self.running = True 176 | self.run_start_time = perf_counter() 177 | 178 | for offset in range(min(int(self.frames_left), 179 | concurrent_requests_count)): 180 | if self.unsequenced: 181 | self._request_next_frame_unsequenced() 182 | else: 183 | frame = self.start_frame + FrameInterval(offset) 184 | future = self.main.current_output.vs_output.get_frame_async( 185 | int(frame)) 186 | self.buffer.appendleft(future) 187 | 188 | self.update_info_timer.start() 189 | 190 | def abort(self) -> None: 191 | if self.running: 192 | self.update_info() 193 | 194 | self.running = False 195 | Qt.QMetaObject.invokeMethod(self.update_info_timer, 'stop', 196 | Qt.Qt.QueuedConnection) 197 | 198 | if self.run_abort_button.isChecked(): 199 | self.run_abort_button.click() 200 | 201 | def _request_next_frame_sequenced(self) -> None: 202 | if self.frames_left <= FrameInterval(0): 203 | self.abort() 204 | return 205 | 206 | self.buffer.pop().result() 207 | 208 | next_frame = self.end_frame + FrameInterval(1) - self.frames_left 209 | if next_frame <= self.end_frame: 210 | new_future = self.main.current_output.vs_output.get_frame_async( 211 | int(next_frame)) 212 | self.buffer.appendleft(new_future) 213 | 214 | self.frames_left -= FrameInterval(1) 215 | 216 | def _request_next_frame_unsequenced(self, future: Optional[Future] = None) -> None: 217 | if self.frames_left <= FrameInterval(0): 218 | self.abort() 219 | return 220 | 221 | if self.running: 222 | next_frame = self.end_frame + FrameInterval(1) - self.frames_left 223 | new_future = self.main.current_output.vs_output.get_frame_async( 224 | int(next_frame)) 225 | new_future.add_done_callback(self._request_next_frame_unsequenced) 226 | 227 | if future is not None: 228 | future.result() 229 | self.frames_left -= FrameInterval(1) 230 | 231 | 232 | def on_run_abort_pressed(self, checked: bool) -> None: 233 | if checked: 234 | self.set_ui_editable(False) 235 | self.run() 236 | else: 237 | self.set_ui_editable(True) 238 | self.abort() 239 | 240 | def on_prefetch_changed(self, new_state: int) -> None: 241 | if new_state == Qt.Qt.Checked: 242 | self.unsequenced_checkbox.setEnabled(True) 243 | elif new_state == Qt.Qt.Unchecked: 244 | self.unsequenced_checkbox.setChecked(False) 245 | self.unsequenced_checkbox.setEnabled(False) 246 | 247 | def set_ui_editable(self, new_state: bool) -> None: 248 | self. start_frame_control.setEnabled(new_state) 249 | self. start_time_control.setEnabled(new_state) 250 | self. end_frame_control.setEnabled(new_state) 251 | self. end_time_control.setEnabled(new_state) 252 | self.total_frames_control.setEnabled(new_state) 253 | self. total_time_control.setEnabled(new_state) 254 | self. prefetch_checkbox.setEnabled(new_state) 255 | self. unsequenced_checkbox.setEnabled(new_state) 256 | 257 | def update_controls(self, start: Optional[Frame] = None, end: Optional[Frame] = None, total: Optional[FrameInterval] = None) -> None: 258 | if start is not None: 259 | end = self. end_frame_control.value() 260 | total = self.total_frames_control.value() 261 | 262 | if start > end: 263 | end = start 264 | total = end - start + FrameInterval(1) 265 | 266 | elif end is not None: 267 | start = self. start_frame_control.value() 268 | total = self.total_frames_control.value() 269 | 270 | if end < start: 271 | start = end 272 | total = end - start + FrameInterval(1) 273 | 274 | elif total is not None: 275 | start = self.start_frame_control.value() 276 | end = self. end_frame_control.value() 277 | old_total = end - start + FrameInterval(1) 278 | delta = total - old_total 279 | 280 | end += delta 281 | if end > self.main.current_output.end_frame: 282 | start -= end - self.main.current_output.end_frame 283 | end = self.main.current_output.end_frame 284 | else: 285 | return 286 | 287 | qt_silent_call(self. start_frame_control.setValue, start) 288 | qt_silent_call(self. start_time_control.setValue, Time(start)) 289 | qt_silent_call(self. end_frame_control.setValue, end) 290 | qt_silent_call(self. end_time_control.setValue, Time(end)) 291 | qt_silent_call(self.total_frames_control.setValue, total) 292 | qt_silent_call(self. total_time_control.setValue, TimeInterval(total)) 293 | 294 | def update_info(self) -> None: 295 | run_time = TimeInterval(seconds=(perf_counter() - self.run_start_time)) 296 | frames_done = self.total_frames - self.frames_left 297 | fps = int(frames_done) / float(run_time) 298 | 299 | info_str = ("{}/{} frames in {}, {:.3f} fps" 300 | .format(frames_done, self.total_frames, run_time, fps)) 301 | self.info_label.setText(info_str) 302 | -------------------------------------------------------------------------------- /vspreview/toolbars/debug.py: -------------------------------------------------------------------------------- 1 | from typing import Optional 2 | 3 | from PyQt5 import Qt 4 | 5 | from vspreview.core import AbstractMainWindow, AbstractToolbar, Frame 6 | from vspreview.utils import debug, set_qobject_names 7 | 8 | 9 | class DebugToolbar(AbstractToolbar): 10 | __slots__ = ( 11 | 'test_button', 12 | 'exec_lineedit', 'exec_button', 13 | 'test_button', 14 | 'toggle_button', 15 | ) 16 | 17 | def __init__(self, main: AbstractMainWindow) -> None: 18 | super().__init__(main, 'Debug') 19 | 20 | self.setup_ui() 21 | 22 | self. test_button.clicked.connect(self.test_button_clicked) 23 | self. break_button.clicked.connect(self.break_button_clicked) 24 | self. exec_button.clicked.connect(self.exec_button_clicked) 25 | self.exec_lineedit.editingFinished.connect(self.exec_button_clicked) 26 | 27 | if self.main.DEBUG_TOOLBAR_BUTTONS_PRINT_STATE: 28 | self.filter = debug.EventFilter(self) 29 | self.main.toolbars.main.widget.installEventFilter(self.filter) 30 | for toolbar in self.main.toolbars: 31 | toolbar.widget.installEventFilter(self.filter) 32 | 33 | set_qobject_names(self) 34 | 35 | def setup_ui(self) -> None: 36 | layout = Qt.QHBoxLayout(self) 37 | layout.setObjectName('DebugToolbar.setup_ui.layout') 38 | layout.setContentsMargins(0, 0, 0, 0) 39 | 40 | self.test_button = Qt.QPushButton(self) 41 | self.test_button.setText('Test') 42 | layout.addWidget(self.test_button) 43 | 44 | self.break_button = Qt.QPushButton(self) 45 | self.break_button.setText('Break') 46 | layout.addWidget(self.break_button) 47 | 48 | self.exec_lineedit = Qt.QLineEdit(self) 49 | self.exec_lineedit.setPlaceholderText( 50 | 'Python statement in context of DebugToolbar.exec_button_clicked()') 51 | layout.addWidget(self.exec_lineedit) 52 | 53 | self.exec_button = Qt.QPushButton(self) 54 | self.exec_button.setText('Exec') 55 | layout.addWidget(self.exec_button) 56 | 57 | layout.addStretch() 58 | 59 | self.toggle_button.setVisible(False) 60 | 61 | 62 | def test_button_clicked(self, checked: Optional[bool] = None) -> None: 63 | pass 64 | 65 | def exec_button_clicked(self, checked: Optional[bool] = None) -> None: 66 | try: 67 | exec(self.exec_lineedit.text()) # pylint: disable=exec-used 68 | except Exception as e: # pylint: disable=broad-except 69 | print(e) 70 | 71 | def break_button_clicked(self, checked: Optional[bool] = None) -> None: 72 | breakpoint() 73 | -------------------------------------------------------------------------------- /vspreview/toolbars/misc.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import logging 4 | from pathlib import Path 5 | from typing import Any, Mapping, Optional 6 | 7 | from PyQt5 import Qt 8 | 9 | from vspreview.core import ( 10 | AbstractMainWindow, AbstractToolbar, Frame, Output, 11 | ) 12 | from vspreview.utils import ( 13 | add_shortcut, debug, fire_and_forget, set_qobject_names, set_status_label, 14 | ) 15 | 16 | 17 | class MiscToolbar(AbstractToolbar): 18 | storable_attrs : Sequence[str] = [] 19 | __slots__ = storable_attrs + [ 20 | 'autosave_timer', 'reload_script_button', 21 | 'save_button', 22 | 'keep_on_top_checkbox', 'save_template_lineedit', 23 | 'show_debug_checkbox', 'save_frame_as_button', 24 | 'toggle_button', 'save_file_types', 'copy_frame_button', 25 | ] 26 | 27 | def __init__(self, main: AbstractMainWindow) -> None: 28 | super().__init__(main, 'Misc') 29 | self.setup_ui() 30 | 31 | self.save_template_lineedit.setText(self.main.SAVE_TEMPLATE) 32 | 33 | self.autosave_timer = Qt.QTimer() 34 | self.autosave_timer.timeout.connect(self.save) 35 | 36 | self.save_file_types = { 37 | 'Single Image (*.png)': self.save_as_png, 38 | } 39 | 40 | self.reload_script_button. clicked.connect(lambda: self.main.reload_script()) # pylint: disable=unnecessary-lambda 41 | self. save_button. clicked.connect(lambda: self.save(manually=True)) 42 | self.keep_on_top_checkbox.stateChanged.connect( self.on_keep_on_top_changed) 43 | self. copy_frame_button. clicked.connect( self.copy_frame_to_clipboard) 44 | self.save_frame_as_button. clicked.connect( self.on_save_frame_as_clicked) 45 | self. show_debug_checkbox.stateChanged.connect( self.on_show_debug_changed) 46 | 47 | add_shortcut(Qt.Qt.CTRL + Qt.Qt.Key_R, self.reload_script_button.click) 48 | add_shortcut(Qt.Qt.CTRL + Qt.Qt.Key_S, self. save_button.click) 49 | add_shortcut(Qt.Qt.ALT + Qt.Qt.Key_S, self. copy_frame_button.click) 50 | add_shortcut(Qt.Qt.CTRL + Qt.Qt.SHIFT + Qt.Qt.Key_S, 51 | self.save_frame_as_button.click) 52 | 53 | set_qobject_names(self) 54 | 55 | def setup_ui(self) -> None: 56 | layout = Qt.QHBoxLayout(self) 57 | layout.setObjectName('MiscToolbar.setup_ui.layout') 58 | layout.setContentsMargins(0, 0, 0, 0) 59 | 60 | self.reload_script_button = Qt.QPushButton(self) 61 | self.reload_script_button.setText('Reload Script') 62 | layout.addWidget(self.reload_script_button) 63 | 64 | self.save_button = Qt.QPushButton(self) 65 | self.save_button.setText('Save') 66 | layout.addWidget(self.save_button) 67 | 68 | self.keep_on_top_checkbox = Qt.QCheckBox(self) 69 | self.keep_on_top_checkbox.setText('Keep on Top') 70 | self.keep_on_top_checkbox.setEnabled(False) 71 | layout.addWidget(self.keep_on_top_checkbox) 72 | 73 | self.copy_frame_button = Qt.QPushButton(self) 74 | self.copy_frame_button.setText('Copy Frame') 75 | layout.addWidget(self.copy_frame_button) 76 | 77 | self.save_frame_as_button = Qt.QPushButton(self) 78 | self.save_frame_as_button.setText('Save Frame as') 79 | layout.addWidget(self.save_frame_as_button) 80 | 81 | save_template_label = Qt.QLabel(self) 82 | save_template_label.setObjectName( 83 | 'MiscToolbar.setup_ui.save_template_label') 84 | save_template_label.setText('Save file name template:') 85 | layout.addWidget(save_template_label) 86 | 87 | self.save_template_lineedit = Qt.QLineEdit(self) 88 | self.save_template_lineedit.setToolTip( 89 | r'Available placeholders: {format}, {fps_den}, {fps_num}, {frame},' 90 | r' {height}, {index}, {matrix}, {primaries}, {range},' 91 | r' {script_name}, {total_frames}, {transfer}, {width}.' 92 | r' Frame props can be accessed as well using their names.') 93 | layout.addWidget(self.save_template_lineedit) 94 | 95 | layout.addStretch() 96 | layout.addStretch() 97 | 98 | self.show_debug_checkbox = Qt.QCheckBox(self) 99 | self.show_debug_checkbox.setText('Show Debug Toolbar') 100 | layout.addWidget(self.show_debug_checkbox) 101 | 102 | def on_script_unloaded(self) -> None: 103 | self.autosave_timer.stop() 104 | 105 | def on_script_loaded(self) -> None: 106 | self.autosave_timer.start(self.main.AUTOSAVE_INTERVAL) 107 | 108 | def copy_frame_to_clipboard(self) -> None: 109 | frame_image = self.main.current_output.graphics_scene_item.image() 110 | self.main.clipboard.setImage(frame_image) 111 | self.main.show_message('Current frame successfully copied to clipboard') 112 | 113 | @fire_and_forget 114 | @set_status_label(label='Saving') 115 | def save(self, path: Optional[Path] = None) -> None: 116 | self.save_sync(path) 117 | 118 | def save_sync(self, path: Optional[Path] = None) -> None: 119 | import yaml 120 | 121 | yaml.Dumper.ignore_aliases = lambda *args: True 122 | 123 | if path is None: 124 | vsp_dir = self.main.config_dir 125 | vsp_dir.mkdir(exist_ok=True) 126 | path = vsp_dir / (self.main.script_path.stem + '.yml') 127 | 128 | backup_paths = [ 129 | path.with_suffix(f'.old{i}.yml') 130 | for i in range(self.main.STORAGE_BACKUPS_COUNT, 0, -1) 131 | ] + [path] 132 | for dest_path, src_path in zip(backup_paths[:-1], backup_paths[1:]): 133 | if src_path.exists(): 134 | src_path.replace(dest_path) 135 | 136 | with path.open(mode='w', newline='\n') as f: 137 | f.write(f'# VSPreview storage for {self.main.script_path}\n') 138 | yaml.dump(self.main, f, indent=4, default_flow_style=False) 139 | 140 | def on_keep_on_top_changed(self, state: Qt.Qt.CheckState) -> None: 141 | if state == Qt.Qt.Checked: 142 | pass 143 | # self.main.setWindowFlag(Qt.Qt.X11BypassWindowManagerHint) 144 | # self.main.setWindowFlag(Qt.Qt.WindowStaysOnTopHint, True) 145 | elif state == Qt.Qt.Unchecked: 146 | self.main.setWindowFlag(Qt.Qt.WindowStaysOnTopHint, False) 147 | 148 | def on_save_frame_as_clicked(self, checked: Optional[bool] = None) -> None: 149 | filter_str = ''.join( 150 | [file_type + ';;' for file_type in self.save_file_types.keys()]) 151 | filter_str = filter_str[0:-2] 152 | 153 | template = self.main.toolbars.misc.save_template_lineedit.text() 154 | frame_props = self.main.current_output.vs_output.get_frame( 155 | self.main.current_frame).props 156 | builtin_substitutions = { 157 | 'format' : self.main.current_output.format.name, 158 | 'fps_den' : self.main.current_output.fps_den, 159 | 'fps_num' : self.main.current_output.fps_num, 160 | 'frame' : self.main.current_frame, 161 | 'height' : self.main.current_output.height, 162 | 'index' : self.main.current_output.index, 163 | 'matrix' : Output.Matrix.values[frame_props['_Matrix']], 164 | 'primaries' : Output.Primaries.values[frame_props['_Primaries']], 165 | 'range' : Output.Range.values[frame_props['_ColorRange']], 166 | 'script_name' : self.main.script_path.stem, 167 | 'total_frames' : self.main.current_output.total_frames, 168 | 'transfer' : Output.Transfer.values[frame_props['_Transfer']], 169 | 'width' : self.main.current_output.width, 170 | } 171 | substitutions = {k: v.decode("utf-8") if isinstance(v, bytes) else v for k,v in dict(frame_props).items()} 172 | substitutions.update(builtin_substitutions) 173 | try: 174 | suggested_path_str = template.format(**substitutions) 175 | except ValueError: 176 | suggested_path_str = self.main.SAVE_TEMPLATE.format(**substitutions) 177 | self.main.show_message('Save name template is invalid') 178 | 179 | save_path_str, file_type = Qt.QFileDialog.getSaveFileName( 180 | self.main, 'Save as', suggested_path_str, filter_str) 181 | try: 182 | self.save_file_types[file_type](Path(save_path_str)) 183 | except KeyError: 184 | pass 185 | 186 | def on_show_debug_changed(self, state: Qt.Qt.CheckState) -> None: 187 | if state == Qt.Qt.Checked: 188 | self.main.toolbars.debug.toggle_button.setVisible(True) 189 | elif state == Qt.Qt.Unchecked: 190 | if self.main.toolbars.debug.toggle_button.isChecked(): 191 | self.main.toolbars.debug.toggle_button.click() 192 | self.main.toolbars.debug.toggle_button.setVisible(False) 193 | 194 | def save_as_png(self, path: Path) -> None: 195 | image = self.main.current_output.graphics_scene_item.image() 196 | image.save(str(path), 'PNG', self.main.PNG_COMPRESSION_LEVEL) 197 | 198 | def __getstate__(self) -> Mapping[str, Any]: 199 | state = { 200 | attr_name: getattr(self, attr_name) 201 | for attr_name in self.storable_attrs 202 | } 203 | state.update({ 204 | 'save_file_name_template': self.save_template_lineedit.text(), 205 | 'show_debug' : self.show_debug_checkbox.isChecked() 206 | }) 207 | state.update(super().__getstate__()) 208 | return state 209 | 210 | def __setstate__(self, state: Mapping[str, Any]) -> None: 211 | try: 212 | self.save_template_lineedit.setText( 213 | state['save_file_name_template']) 214 | except (KeyError, TypeError): 215 | logging.warning( 216 | 'Storage loading: failed to parse save file name template.') 217 | 218 | try: 219 | show_debug = state['show_debug'] 220 | if not isinstance(show_debug, bool): 221 | raise TypeError 222 | except (KeyError, TypeError): 223 | logging.warning( 224 | 'Storage loading: failed to parse show debug flag.') 225 | show_debug = self.main.DEBUG_TOOLBAR 226 | 227 | self.show_debug_checkbox.setChecked(show_debug) 228 | 229 | super().__setstate__(state) 230 | -------------------------------------------------------------------------------- /vspreview/toolbars/pipette.py: -------------------------------------------------------------------------------- 1 | import ctypes 2 | import logging 3 | from typing import cast, Dict, List, TypeVar, Union 4 | from weakref import WeakKeyDictionary 5 | 6 | from PyQt5 import Qt 7 | import vapoursynth as vs 8 | 9 | from vspreview.core import AbstractMainWindow, AbstractToolbar, Output 10 | from vspreview.utils import set_qobject_names 11 | from vspreview.widgets import ColorView 12 | 13 | 14 | Number = TypeVar('Number', int, float) 15 | 16 | 17 | class PipetteToolbar(AbstractToolbar): 18 | __slots__ = ( 19 | 'color_view', 'outputs', 'position', 'pos_fmt', 'tracking', 20 | 'rgb_dec', 'rgb_hex', 'rgb_label', 21 | 'src_dec', 'src_dec_fmt', 'src_hex', 'src_hex_fmt', 'src_label', 22 | ) 23 | 24 | data_types = { 25 | vs.INTEGER: { 26 | 1: ctypes.c_uint8, 27 | 2: ctypes.c_uint16, 28 | # 4: ctypes.c_char * 4, 29 | }, 30 | vs.FLOAT: { 31 | # 2: ctypes.c_char * 2, 32 | 4: ctypes.c_float, 33 | } 34 | } 35 | 36 | def __init__(self, main: AbstractMainWindow) -> None: 37 | super().__init__(main, 'Pipette') 38 | 39 | self.setup_ui() 40 | 41 | self.pos_fmt = '{},{}' 42 | self.src_hex_fmt = '{:2X}' 43 | self.src_max_val: Union[int, float] = 2**8 - 1 44 | self.src_dec_fmt = '{:3d}' 45 | self.src_norm_fmt = '{:0.5f}' 46 | self.outputs = WeakKeyDictionary[Output, vs.VideoNode]() 47 | self.tracking = False 48 | 49 | set_qobject_names(self) 50 | 51 | def setup_ui(self) -> None: 52 | layout = Qt.QHBoxLayout(self) 53 | layout.setObjectName('PipetteToolbar.setup_ui.layout') 54 | layout.setContentsMargins(0, 0, 0, 0) 55 | 56 | self.color_view = ColorView(self) 57 | self.color_view.setFixedSize(self.height() // 2 , self.height() // 2) 58 | layout.addWidget(self.color_view) 59 | 60 | font = Qt.QFont('Consolas', 9) 61 | font.setStyleHint(Qt.QFont.Monospace) 62 | 63 | self.position = Qt.QLabel(self) 64 | self.position.setFont(font) 65 | self.position.setTextInteractionFlags(Qt.Qt.TextSelectableByMouse) 66 | layout.addWidget(self.position) 67 | 68 | self.rgb_label = Qt.QLabel(self) 69 | self.rgb_label.setText('Rendered (RGB):') 70 | layout.addWidget(self.rgb_label) 71 | 72 | self.rgb_hex = Qt.QLabel(self) 73 | self.rgb_hex.setFont(font) 74 | self.rgb_hex.setTextInteractionFlags(Qt.Qt.TextSelectableByMouse) 75 | layout.addWidget(self.rgb_hex) 76 | 77 | self.rgb_dec = Qt.QLabel(self) 78 | self.rgb_dec.setFont(font) 79 | self.rgb_dec.setTextInteractionFlags(Qt.Qt.TextSelectableByMouse) 80 | layout.addWidget(self.rgb_dec) 81 | 82 | self.rgb_norm = Qt.QLabel(self) 83 | self.rgb_norm.setFont(font) 84 | self.rgb_norm.setTextInteractionFlags(Qt.Qt.TextSelectableByMouse) 85 | layout.addWidget(self.rgb_norm) 86 | 87 | self.src_label = Qt.QLabel(self) 88 | layout.addWidget(self.src_label) 89 | 90 | self.src_hex = Qt.QLabel(self) 91 | self.src_hex.setFont(font) 92 | self.src_hex.setTextInteractionFlags(Qt.Qt.TextSelectableByMouse) 93 | layout.addWidget(self.src_hex) 94 | 95 | self.src_dec = Qt.QLabel(self) 96 | self.src_dec.setFont(font) 97 | self.src_dec.setTextInteractionFlags(Qt.Qt.TextSelectableByMouse) 98 | layout.addWidget(self.src_dec) 99 | 100 | self.src_norm = Qt.QLabel(self) 101 | self.src_norm.setFont(font) 102 | self.src_norm.setTextInteractionFlags(Qt.Qt.TextSelectableByMouse) 103 | layout.addWidget(self.src_norm) 104 | 105 | layout.addStretch() 106 | 107 | def subscribe_on_mouse_events(self) -> None: 108 | self.main.graphics_view.mouseMoved .connect(self.mouse_moved) 109 | self.main.graphics_view.mousePressed .connect(self.mouse_pressed) 110 | self.main.graphics_view.mouseReleased.connect(self.mouse_released) 111 | 112 | def unsubscribe_from_mouse_events(self) -> None: 113 | self.main.graphics_view.mouseMoved .disconnect(self.mouse_moved) 114 | self.main.graphics_view.mousePressed .disconnect(self.mouse_pressed) 115 | self.main.graphics_view.mouseReleased.disconnect(self.mouse_released) 116 | 117 | def on_script_unloaded(self) -> None: 118 | self.outputs.clear() 119 | 120 | def mouse_moved(self, event: Qt.QMouseEvent) -> None: 121 | if self.tracking and not event.buttons(): 122 | self.update_labels(event.pos()) 123 | 124 | def mouse_pressed(self, event: Qt.QMouseEvent) -> None: 125 | if event.buttons() == Qt.Qt.RightButton: 126 | self.tracking = False 127 | 128 | def mouse_released(self, event: Qt.QMouseEvent) -> None: 129 | if event.buttons() == Qt.Qt.RightButton: 130 | self.tracking = True 131 | self.update_labels(event.pos()) 132 | 133 | def update_labels(self, local_pos: Qt.QPoint) -> None: 134 | from math import floor, trunc 135 | from struct import unpack 136 | 137 | pos_f = self.main.graphics_view.mapToScene(local_pos) 138 | pos = Qt.QPoint(floor(pos_f.x()), floor(pos_f.y())) 139 | if not self.main.current_output.graphics_scene_item.contains(pos_f): 140 | return 141 | color = self.main.current_output.graphics_scene_item.image() \ 142 | .pixelColor(pos) 143 | self.color_view.color = color 144 | 145 | self.position.setText(self.pos_fmt.format(pos.x(), pos.y())) 146 | 147 | self.rgb_hex.setText('{:2X},{:2X},{:2X}'.format( 148 | color.red(), color.green(), color.blue())) 149 | self.rgb_dec.setText('{:3d},{:3d},{:3d}'.format( 150 | color.red(), color.green(), color.blue())) 151 | self.rgb_norm.setText('{:0.5f},{:0.5f},{:0.5f}'.format( 152 | color.red() / 255, color.green() / 255, color.blue() / 255)) 153 | 154 | if not self.src_label.isVisible(): 155 | return 156 | 157 | def extract_value(vs_frame: vs.VideoFrame, plane: int, 158 | pos: Qt.QPoint) -> Union[int, float]: 159 | fmt = vs_frame.format 160 | stride = vs_frame.get_stride(plane) 161 | if fmt.sample_type == vs.FLOAT and fmt.bytes_per_sample == 2: 162 | ptr = ctypes.cast(vs_frame.get_read_ptr(plane), ctypes.POINTER( 163 | ctypes.c_char * (stride * vs_frame.height))) 164 | offset = pos.y() * stride + pos.x() * 2 165 | val = unpack('e', ptr.contents[offset:(offset + 2)])[0] # type: ignore 166 | return cast(float, val) 167 | else: 168 | ptr = ctypes.cast(vs_frame.get_read_ptr(plane), ctypes.POINTER( 169 | self.data_types[fmt.sample_type][fmt.bytes_per_sample] * ( # type:ignore 170 | stride * vs_frame.height))) 171 | logical_stride = stride // fmt.bytes_per_sample 172 | idx = pos.y() * logical_stride + pos.x() 173 | return ptr.contents[idx] # type: ignore 174 | 175 | vs_frame = self.outputs[self.main.current_output].get_frame( 176 | int(self.main.current_frame)) 177 | fmt = vs_frame.format 178 | 179 | src_vals = [extract_value(vs_frame, i, pos) 180 | for i in range(fmt.num_planes)] 181 | if self.main.current_output.has_alpha: 182 | vs_alpha = self.main.current_output.source_vs_alpha.get_frame( 183 | int(self.main.current_frame)) 184 | src_vals.append(extract_value(vs_alpha, 0, pos)) 185 | 186 | self.src_dec.setText(self.src_dec_fmt.format(*src_vals)) 187 | if fmt.sample_type == vs.INTEGER: 188 | self.src_hex.setText(self.src_hex_fmt.format(*src_vals)) 189 | self.src_norm.setText(self.src_norm_fmt.format( 190 | *[src_val / self.src_max_val for src_val in src_vals])) 191 | elif fmt.sample_type == vs.FLOAT: 192 | self.src_norm.setText(self.src_norm_fmt.format(*[ 193 | self.clip(val, 0.0, 1.0) if i in (0, 3) else 194 | self.clip(val, -0.5, 0.5) + 0.5 195 | for i, val in enumerate(src_vals) 196 | ])) 197 | 198 | def on_current_output_changed(self, index: int, prev_index: int) -> None: 199 | from math import ceil, log 200 | 201 | super().on_current_output_changed(index, prev_index) 202 | 203 | fmt = self.main.current_output.format 204 | src_label_text = '' 205 | if fmt.color_family == vs.RGB: 206 | src_label_text = 'Raw (RGB{}):' 207 | elif fmt.color_family == vs.YUV: 208 | src_label_text = 'Raw (YUV{}):' 209 | elif fmt.color_family == vs.GRAY: 210 | src_label_text = 'Raw (Gray{}):' 211 | elif fmt.color_family == vs.YCOCG: 212 | src_label_text = 'Raw (YCoCg{}):' 213 | elif fmt.id == vs.COMPATBGR32.value: 214 | src_label_text = 'Raw (RGB{}):' 215 | elif fmt.id == vs.COMPATYUY2.value: 216 | src_label_text = 'Raw (YUV{}):' 217 | 218 | has_alpha = self.main.current_output.has_alpha 219 | if not has_alpha: 220 | self.src_label.setText(src_label_text.format('')) 221 | else: 222 | self.src_label.setText(src_label_text.format(' + Alpha')) 223 | 224 | self.pos_fmt = '{:4d},{:4d}' 225 | 226 | if self.main.current_output not in self.outputs: 227 | self.outputs[self.main.current_output] = self.prepare_vs_output( 228 | self.main.current_output.source_vs_output) 229 | src_fmt = self.outputs[self.main.current_output].format 230 | 231 | if src_fmt.sample_type == vs.INTEGER: 232 | self.src_max_val = 2**src_fmt.bits_per_sample - 1 233 | elif src_fmt.sample_type == vs.FLOAT: 234 | self.src_hex.setVisible(False) 235 | self.src_max_val = 1.0 236 | 237 | src_num_planes = src_fmt.num_planes + int(has_alpha) 238 | self.src_hex_fmt = ','.join(('{{:{w}X}}',) * src_num_planes) \ 239 | .format(w=ceil(log(self.src_max_val, 16))) 240 | if src_fmt.sample_type == vs.INTEGER: 241 | self.src_dec_fmt = ','.join(('{{:{w}d}}',) * src_num_planes) \ 242 | .format(w=ceil(log(self.src_max_val, 10))) 243 | elif src_fmt.sample_type == vs.FLOAT: 244 | self.src_dec_fmt = ','.join(('{: 0.5f}',) * src_num_planes) 245 | self.src_norm_fmt = ','.join(('{:0.5f}',) * src_num_planes) 246 | 247 | self.update_labels(self.main.graphics_view.mapFromGlobal( 248 | self.main.cursor().pos())) 249 | 250 | def on_toggle(self, new_state: bool) -> None: 251 | super().on_toggle(new_state) 252 | self.main.graphics_view.setMouseTracking(new_state) 253 | if new_state is True: 254 | self.subscribe_on_mouse_events() 255 | self.main.graphics_view.setDragMode(Qt.QGraphicsView.NoDrag) 256 | else: 257 | self.unsubscribe_from_mouse_events() 258 | self.main.graphics_view.setDragMode( 259 | Qt.QGraphicsView.ScrollHandDrag) 260 | self.tracking = new_state 261 | 262 | @staticmethod 263 | def prepare_vs_output(vs_output: vs.VideoNode) -> vs.VideoNode: 264 | def non_subsampled_format(fmt): 265 | #if fmt.id == vs.COMPATBGR32.value: 266 | # return vs.RGB24 # type: ignore 267 | #elif fmt.id == vs.COMPATYUY2.value: 268 | # return vs.YUV444P8 # type: ignore 269 | #else: 270 | if True: 271 | return vs.core.register_format( 272 | color_family=fmt.color_family, 273 | sample_type=fmt.sample_type, 274 | bits_per_sample=fmt.bits_per_sample, 275 | subsampling_w=0, 276 | subsampling_h=0 277 | ) 278 | 279 | return vs.core.resize.Bicubic( 280 | vs_output, 281 | format=non_subsampled_format(vs_output.format)) 282 | 283 | @staticmethod 284 | def clip(value: Number, lower_bound: Number, upper_bound: Number) -> Number: 285 | return max(lower_bound, min(value, upper_bound)) 286 | -------------------------------------------------------------------------------- /vspreview/toolbars/playback.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from collections import deque 4 | import logging 5 | from time import perf_counter_ns 6 | from typing import Any, cast, Deque, Mapping, Optional, Union 7 | 8 | from PyQt5 import Qt 9 | 10 | from vspreview.core import ( 11 | AbstractMainWindow, AbstractToolbar, Frame, FrameInterval, Time, 12 | TimeInterval, 13 | ) 14 | from vspreview.utils import ( 15 | add_shortcut, debug, qt_silent_call, set_qobject_names, 16 | ) 17 | from vspreview.widgets import FrameEdit, TimeEdit 18 | 19 | 20 | class PlaybackToolbar(AbstractToolbar): 21 | yaml_tag = '!PlaybackToolbar' 22 | 23 | __slots__ = ( 24 | 'play_timer', 'fps_timer', 'fps_history', 'current_fps', 25 | 'seek_n_frames_b_button', 'seek_to_prev_button', 'play_pause_button', 26 | 'seek_to_next_button', 'seek_n_frames_f_button', 27 | 'seek_frame_control', 'seek_time_control', 28 | 'fps_spinbox', 'fps_unlimited_checkbox', 'fps_reset_button', 29 | 'play_start_time', 'play_start_frame', 'play_end_time', 30 | 'play_end_frame', 'play_buffer', 'toggle_button', 31 | ) 32 | 33 | def __init__(self, main: AbstractMainWindow) -> None: 34 | from concurrent.futures import Future 35 | 36 | super().__init__(main, 'Playback') 37 | self.setup_ui() 38 | 39 | self.play_buffer: Deque[Future] = deque() 40 | self.play_timer = Qt.QTimer() 41 | self.play_timer.setTimerType(Qt.Qt.PreciseTimer) 42 | self.play_timer.timeout.connect(self._show_next_frame) 43 | 44 | self.fps_history: Deque[int] = deque( 45 | [], int(self.main.FPS_AVERAGING_WINDOW_SIZE) + 1) 46 | self.current_fps = 0.0 47 | self.fps_timer = Qt.QTimer() 48 | self.fps_timer.setTimerType(Qt.Qt.PreciseTimer) 49 | self.fps_timer.timeout.connect( 50 | lambda: self.fps_spinbox.setValue(self.current_fps)) 51 | 52 | self.play_start_time: Optional[int] = None 53 | self.play_start_frame = Frame(0) 54 | self.play_end_time = 0 55 | self.play_end_frame = Frame(0) 56 | 57 | self.play_pause_button .clicked.connect(self.on_play_pause_clicked) 58 | self.seek_to_prev_button .clicked.connect(self.seek_to_prev) 59 | self.seek_to_next_button .clicked.connect(self.seek_to_next) 60 | self.seek_n_frames_b_button .clicked.connect(self.seek_n_frames_b) 61 | self.seek_n_frames_f_button .clicked.connect(self.seek_n_frames_f) 62 | self.seek_to_start_button .clicked.connect(self.seek_to_start) 63 | self.seek_to_end_button .clicked.connect(self.seek_to_end) 64 | self.seek_frame_control .valueChanged.connect(self.on_seek_frame_changed) 65 | self.seek_time_control .valueChanged.connect(self.on_seek_time_changed) 66 | self.fps_spinbox .valueChanged.connect(self.on_fps_changed) 67 | self.fps_reset_button .clicked.connect(self.reset_fps) 68 | self.fps_unlimited_checkbox.stateChanged.connect(self.on_fps_unlimited_changed) 69 | 70 | add_shortcut( Qt.Qt.Key_Space, self.play_pause_button .click) 71 | add_shortcut( Qt.Qt.Key_Left , self.seek_to_prev_button .click) 72 | add_shortcut( Qt.Qt.Key_Right, self.seek_to_next_button .click) 73 | add_shortcut(Qt.Qt.SHIFT + Qt.Qt.Key_Left , self.seek_n_frames_b_button.click) 74 | add_shortcut(Qt.Qt.SHIFT + Qt.Qt.Key_Right, self.seek_n_frames_f_button.click) 75 | add_shortcut( Qt.Qt.Key_Home, self.seek_to_start_button .click) 76 | add_shortcut( Qt.Qt.Key_End, self.seek_to_end_button .click) 77 | 78 | set_qobject_names(self) 79 | 80 | def setup_ui(self) -> None: 81 | layout = Qt.QHBoxLayout(self) 82 | layout.setObjectName('PlaybackToolbar.setup_ui.layout') 83 | layout.setContentsMargins(0, 0, 0, 0) 84 | 85 | self.seek_to_start_button = Qt.QToolButton(self) 86 | self.seek_to_start_button.setText('⏮') 87 | self.seek_to_start_button.setToolTip('Seek to First Frame') 88 | layout.addWidget(self.seek_to_start_button) 89 | 90 | self.seek_n_frames_b_button = Qt.QToolButton(self) 91 | self.seek_n_frames_b_button.setText('⏪') 92 | self.seek_n_frames_b_button.setToolTip('Seek N Frames Backwards') 93 | layout.addWidget(self.seek_n_frames_b_button) 94 | 95 | self.seek_to_prev_button = Qt.QToolButton(self) 96 | self.seek_to_prev_button.setText('◂') 97 | self.seek_to_prev_button.setToolTip('Seek 1 Frame Backwards') 98 | layout.addWidget(self.seek_to_prev_button) 99 | 100 | self.play_pause_button = Qt.QToolButton(self) 101 | self.play_pause_button.setText('⏯') 102 | self.play_pause_button.setToolTip('Play/Pause') 103 | self.play_pause_button.setCheckable(True) 104 | layout.addWidget(self.play_pause_button) 105 | 106 | self.seek_to_next_button = Qt.QToolButton(self) 107 | self.seek_to_next_button.setText('▸') 108 | self.seek_to_next_button.setToolTip('Seek 1 Frame Forward') 109 | layout.addWidget(self.seek_to_next_button) 110 | 111 | self.seek_n_frames_f_button = Qt.QToolButton(self) 112 | self.seek_n_frames_f_button.setText('⏩') 113 | self.seek_n_frames_f_button.setToolTip('Seek N Frames Forward') 114 | layout.addWidget(self.seek_n_frames_f_button) 115 | 116 | self.seek_to_end_button = Qt.QToolButton(self) 117 | self.seek_to_end_button.setText('⏭') 118 | self.seek_to_end_button.setToolTip('Seek to Last Frame') 119 | layout.addWidget(self.seek_to_end_button) 120 | 121 | self.seek_frame_control = FrameEdit[FrameInterval](self) 122 | self.seek_frame_control.setMinimum(FrameInterval(1)) 123 | self.seek_frame_control.setValue(10) 124 | self.seek_frame_control.setToolTip('Seek N Frames Step') 125 | layout.addWidget(self.seek_frame_control) 126 | 127 | self.seek_time_control = TimeEdit[TimeInterval](self) 128 | layout.addWidget(self.seek_time_control) 129 | 130 | self.fps_spinbox = Qt.QDoubleSpinBox(self) 131 | self.fps_spinbox.setRange(0.001, 9999.0) 132 | self.fps_spinbox.setDecimals(3) 133 | self.fps_spinbox.setSuffix(' fps') 134 | layout.addWidget(self.fps_spinbox) 135 | 136 | self.fps_reset_button = Qt.QPushButton(self) 137 | self.fps_reset_button.setText('Reset FPS') 138 | layout.addWidget(self.fps_reset_button) 139 | 140 | self.fps_unlimited_checkbox = Qt.QCheckBox(self) 141 | self.fps_unlimited_checkbox.setText('Unlimited FPS') 142 | layout.addWidget(self.fps_unlimited_checkbox) 143 | 144 | layout.addStretch() 145 | 146 | def on_current_output_changed(self, index: int, prev_index: int) -> None: 147 | qt_silent_call(self.seek_frame_control.setMaximum, self.main.current_output.total_frames) 148 | qt_silent_call(self. seek_time_control.setMaximum, self.main.current_output.total_time) 149 | qt_silent_call(self. seek_time_control.setMinimum, TimeInterval(FrameInterval(1))) 150 | qt_silent_call(self. fps_spinbox.setValue , self.main.current_output.play_fps) 151 | 152 | 153 | def play(self) -> None: 154 | if self.main.current_frame == self.main.current_output.end_frame: 155 | return 156 | 157 | if self.main.statusbar.label.text() == 'Ready': 158 | self.main.statusbar.label.setText('Playing') 159 | 160 | if not self.main.current_output.has_alpha: 161 | play_buffer_size = int(min( 162 | self.main.PLAY_BUFFER_SIZE, 163 | self.main.current_output.end_frame - self.main.current_frame 164 | )) 165 | self.play_buffer = deque([], play_buffer_size) 166 | for i in range(cast(int, self.play_buffer.maxlen)): 167 | future = self.main.current_output.vs_output.get_frame_async( 168 | int(self.main.current_frame + FrameInterval(i) 169 | + FrameInterval(1))) 170 | self.play_buffer.appendleft(future) 171 | else: 172 | play_buffer_size = int(min( 173 | self.main.PLAY_BUFFER_SIZE, 174 | (self.main.current_output.end_frame - self.main.current_frame) * 2 175 | )) 176 | # buffer size needs to be even in case alpha is present 177 | play_buffer_size -= play_buffer_size % 2 178 | self.play_buffer = deque([], play_buffer_size) 179 | 180 | for i in range(cast(int, self.play_buffer.maxlen) // 2): 181 | frame = (self.main.current_frame + FrameInterval(i) 182 | + FrameInterval(1)) 183 | future = self.main.current_output.vs_output.get_frame_async( 184 | int(frame)) 185 | self.play_buffer.appendleft(future) 186 | future = self.main.current_output.vs_alpha.get_frame_async( 187 | int(frame)) 188 | self.play_buffer.appendleft(future) 189 | 190 | if self.fps_unlimited_checkbox.isChecked() or self.main.DEBUG_PLAY_FPS: 191 | self.play_timer.start(0) 192 | if self.main.DEBUG_PLAY_FPS: 193 | self.play_start_time = debug.perf_counter_ns() 194 | self.play_start_frame = self.main.current_frame 195 | else: 196 | self.fps_timer.start(self.main.FPS_REFRESH_INTERVAL) 197 | else: 198 | self.play_timer.start( 199 | round(1000 / self.main.current_output.play_fps)) 200 | 201 | def _show_next_frame(self) -> None: 202 | if not self.main.current_output.has_alpha: 203 | try: 204 | frame_future = self.play_buffer.pop() 205 | except IndexError: 206 | self.play_pause_button.click() 207 | return 208 | 209 | next_frame_for_buffer = (self.main.current_frame 210 | + self.main.PLAY_BUFFER_SIZE) 211 | if next_frame_for_buffer <= self.main.current_output.end_frame: 212 | self.play_buffer.appendleft( 213 | self.main.current_output.vs_output.get_frame_async( 214 | next_frame_for_buffer)) 215 | 216 | self.main.switch_frame( 217 | self.main.current_frame + FrameInterval(1), render_frame=False) 218 | image = self.main.current_output.render_raw_videoframe( 219 | frame_future.result()) 220 | else: 221 | try: 222 | frame_future = self.play_buffer.pop() 223 | alpha_future = self.play_buffer.pop() 224 | except IndexError: 225 | self.play_pause_button.click() 226 | return 227 | 228 | next_frame_for_buffer = (self.main.current_frame 229 | + self.main.PLAY_BUFFER_SIZE // 2) 230 | if next_frame_for_buffer <= self.main.current_output.end_frame: 231 | self.play_buffer.appendleft( 232 | self.main.current_output.vs_output.get_frame_async( 233 | next_frame_for_buffer)) 234 | self.play_buffer.appendleft( 235 | self.main.current_output.vs_alpha.get_frame_async( 236 | next_frame_for_buffer)) 237 | 238 | self.main.switch_frame( 239 | self.main.current_frame + FrameInterval(1), render_frame=False) 240 | image = self.main.current_output.render_raw_videoframe( 241 | frame_future.result(), alpha_future.result()) 242 | 243 | self.main.current_output.graphics_scene_item.setImage(image) 244 | 245 | if not self.main.DEBUG_PLAY_FPS: 246 | self.update_fps_counter() 247 | 248 | def stop(self) -> None: 249 | self.play_timer.stop() 250 | if self.main.DEBUG_PLAY_FPS and self.play_start_time is not None: 251 | self.play_end_time = debug.perf_counter_ns() 252 | self.play_end_frame = self.main.current_frame 253 | if self.main.statusbar.label.text() == 'Playing': 254 | self.main.statusbar.label.setText('Ready') 255 | 256 | for future in self.play_buffer: 257 | future.add_done_callback(lambda future: future.result()) 258 | self.play_buffer.clear() 259 | 260 | self.fps_history.clear() 261 | self.fps_timer.stop() 262 | 263 | if self.main.DEBUG_PLAY_FPS and self.play_start_time is not None: 264 | time_interval = ((self.play_end_time - self.play_start_time) 265 | / 1_000_000_000) 266 | frame_interval = self.play_end_frame - self.play_start_frame 267 | logging.debug( 268 | f'{time_interval:.3f} s, {frame_interval} frames, {int(frame_interval) / time_interval:.3f} fps') 269 | self.play_start_time = None 270 | 271 | def seek_to_start(self, checked: Optional[bool] = None) -> None: 272 | self.stop() 273 | self.main.current_frame = Frame(0) 274 | 275 | def seek_to_end(self, checked: Optional[bool] = None) -> None: 276 | self.stop() 277 | self.main.current_frame = self.main.current_output.end_frame 278 | 279 | def seek_to_prev(self, checked: Optional[bool] = None) -> None: 280 | try: 281 | new_pos = self.main.current_frame - FrameInterval(1) 282 | except ValueError: 283 | return 284 | self.stop() 285 | self.main.current_frame = new_pos 286 | 287 | def seek_to_next(self, checked: Optional[bool] = None) -> None: 288 | new_pos = self.main.current_frame + FrameInterval(1) 289 | if new_pos > self.main.current_output.end_frame: 290 | return 291 | self.stop() 292 | self.main.current_frame = new_pos 293 | 294 | def seek_n_frames_b(self, checked: Optional[bool] = None) -> None: 295 | try: 296 | new_pos = (self.main.current_frame 297 | - FrameInterval(self.seek_frame_control.value())) 298 | except ValueError: 299 | return 300 | self.stop() 301 | self.main.current_frame = new_pos 302 | 303 | def seek_n_frames_f(self, checked: Optional[bool] = None) -> None: 304 | new_pos = (self.main.current_frame 305 | + FrameInterval(self.seek_frame_control.value())) 306 | if new_pos > self.main.current_output.end_frame: 307 | return 308 | self.stop() 309 | self.main.current_frame = new_pos 310 | 311 | def on_seek_frame_changed(self, frame: FrameInterval) -> None: 312 | qt_silent_call(self.seek_time_control.setValue, TimeInterval(frame)) 313 | 314 | def on_seek_time_changed(self, time: TimeInterval) -> None: 315 | qt_silent_call(self.seek_frame_control.setValue, FrameInterval(time)) 316 | 317 | def on_play_pause_clicked(self, checked: bool) -> None: 318 | if checked: 319 | self.play() 320 | else: 321 | self.stop() 322 | 323 | def on_fps_changed(self, new_fps: float) -> None: 324 | if not self.fps_spinbox.isEnabled(): 325 | return 326 | 327 | self.main.current_output.play_fps = new_fps 328 | if self.play_timer.isActive(): 329 | self.stop() 330 | self.play() 331 | 332 | def reset_fps(self, checked: Optional[bool] = None) -> None: 333 | self.fps_spinbox.setValue(self.main.current_output.fps_num 334 | / self.main.current_output.fps_den) 335 | 336 | def on_fps_unlimited_changed(self, state: int) -> None: 337 | if state == Qt.Qt.Checked: 338 | self.fps_spinbox.setEnabled(False) 339 | self.fps_reset_button.setEnabled(False) 340 | if state == Qt.Qt.Unchecked: 341 | self.fps_spinbox.setEnabled(True) 342 | self.fps_reset_button.setEnabled(True) 343 | self.fps_spinbox.setValue(self.main.current_output.play_fps) 344 | 345 | if self.play_timer.isActive(): 346 | self.stop() 347 | self.play() 348 | 349 | def update_fps_counter(self) -> None: 350 | if self.fps_spinbox.isEnabled(): 351 | return 352 | 353 | self.fps_history.append(perf_counter_ns()) 354 | if len(self.fps_history) == 1: 355 | return 356 | 357 | elapsed_total = 0 358 | for i in range(len(self.fps_history) - 1): 359 | elapsed_total += self.fps_history[i + 1] - self.fps_history[i] 360 | 361 | self.current_fps = (1_000_000_000 362 | / (elapsed_total / len(self.fps_history))) 363 | 364 | def __getstate__(self) -> Mapping[str, Any]: 365 | state = { 366 | 'seek_interval_frame': self.seek_frame_control.value() 367 | } 368 | state.update(super().__getstate__()) 369 | return state 370 | 371 | def __setstate__(self, state: Mapping[str, Any]) -> None: 372 | try: 373 | seek_interval_frame = state['seek_interval_frame'] 374 | if not isinstance(seek_interval_frame, FrameInterval): 375 | raise TypeError 376 | self.seek_frame_control.setValue(seek_interval_frame) 377 | except (KeyError, TypeError): 378 | logging.warning( 379 | 'Storage loading: PlaybackToolbar: failed to parse seek_interval_frame') 380 | 381 | super().__setstate__(state) 382 | -------------------------------------------------------------------------------- /vspreview/utils/__init__.py: -------------------------------------------------------------------------------- 1 | __all__ = [ 2 | 'debug', 3 | ] 4 | 5 | from .utils import ( 6 | from_qtime, to_qtime, 7 | strfdelta, qt_silent_call, 8 | main_window, set_status_label, add_shortcut, 9 | fire_and_forget, method_dispatch, set_qobject_names, 10 | get_usable_cpus_count, vs_clear_cache, 11 | ) 12 | -------------------------------------------------------------------------------- /vspreview/utils/debug.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from functools import wraps 4 | import inspect 5 | import logging 6 | import re 7 | from time import perf_counter_ns 8 | from typing import Any, Callable, cast, Dict, Type, TypeVar, Tuple, Union 9 | 10 | from pprint import pprint 11 | from PyQt5 import Qt, sip 12 | import vapoursynth as vs 13 | 14 | from vspreview.core import AbstractMainWindow 15 | 16 | 17 | T = TypeVar('T') 18 | 19 | 20 | def print_var(var: Any) -> None: 21 | current_frame = inspect.currentframe() 22 | if current_frame is None: 23 | logging.debug('print_var(): current_frame is None') 24 | return 25 | frame = current_frame.f_back 26 | 27 | context = inspect.getframeinfo(frame).code_context 28 | if context is None: 29 | logging.debug('print_var(): code_context is None') 30 | return 31 | s = context[0] 32 | 33 | match = re.search(r"\((.*)\)", s) 34 | if match is None: 35 | logging.debug('print_var(): match is None') 36 | return 37 | r = match.group(1) 38 | logging.debug(f'{r}: {var}') 39 | 40 | 41 | def print_func_name() -> None: 42 | logging.debug(f'{inspect.stack()[1][3]}()') 43 | 44 | 45 | class EventFilter(Qt.QObject): 46 | __slots__ = ( 47 | 'main', 48 | ) 49 | 50 | def __init__(self, main: AbstractMainWindow) -> None: 51 | super().__init__() 52 | self.main = main 53 | 54 | def eventFilter(self, obj: Qt.QObject, event: Qt.QEvent) -> bool: 55 | if (event.type() == Qt.QEvent.Show): 56 | logging.debug( '--------------------------------') 57 | logging.debug(f'{obj.objectName()}') 58 | logging.debug( 'event: Show') 59 | logging.debug(f'spontaneous: {event.spontaneous()}') 60 | logging.debug( '') 61 | self.print_toolbars_state() 62 | elif (event.type() == Qt.QEvent.Hide): 63 | logging.debug( '--------------------------------') 64 | logging.debug(f'{obj.objectName()}') 65 | logging.debug( 'event: Hide') 66 | logging.debug(f'spontaneous: {event.spontaneous()}') 67 | logging.debug( '') 68 | self.print_toolbars_state() 69 | 70 | # return Qt.QObject.eventFilter(object, event) 71 | return False 72 | 73 | def print_toolbars_state(self) -> None: 74 | logging.debug(f'main toolbar: {self.main.main_toolbar_widget.isVisible()}') 75 | logging.debug(f'playback toolbar: {self.main.toolbars.playback .isVisible()}') 76 | logging.debug(f'scening toolbar: {self.main.toolbars.scening .isVisible()}') 77 | logging.debug(f'misc toolbar: {self.main.toolbars.misc .isVisible()}') 78 | 79 | def run_get_frame_test(self) -> None: 80 | N = 10 81 | 82 | start_frame_async = 1000 83 | total_async = 0 84 | for i in range(start_frame_async, start_frame_async + N): 85 | s1 = perf_counter_ns() 86 | f1 = self.main.current_output.vs_output.get_frame_async(i) 87 | f1.result() 88 | s2 = perf_counter_ns() 89 | logging.debug(f'async test time: {s2 - s1} ns') 90 | if i != start_frame_async: 91 | total_async += s2 - s1 92 | logging.debug('') 93 | 94 | start_frame_sync = 2000 95 | total_sync = 0 96 | for i in range(start_frame_sync, start_frame_sync + N): 97 | s1 = perf_counter_ns() 98 | f2 = self.main.current_output.vs_output.get_frame(i) # pylint: disable=unused-variable 99 | s2 = perf_counter_ns() 100 | logging.debug(f'sync test time: {s2 - s1} ns') 101 | if i != start_frame_sync: 102 | total_sync += s2 - s1 103 | 104 | logging.debug('') 105 | logging.debug( 106 | f'Async average: {total_async / N - 1} ns, {1_000_000_000 / (total_async / N - 1)} fps') 107 | logging.debug( 108 | f'Sync average: {total_sync / N - 1} ns, {1_000_000_000 / (total_sync / N - 1)} fps') 109 | 110 | 111 | def measure_exec_time_ms(func: Callable[..., T], return_exec_time: bool = False, print_exec_time: bool = True) -> Callable[..., Union[T, Tuple[T, float]]]: 112 | @wraps(func) 113 | def decorator(*args: Any, **kwargs: Any) -> T: 114 | t1 = perf_counter_ns() 115 | ret = func(*args, **kwargs) 116 | t2 = perf_counter_ns() 117 | exec_time = (t2 - t1) / 1_000_000 118 | if print_exec_time: 119 | logging.debug(f'{exec_time:7.3f} ms: {func.__name__}()') 120 | if return_exec_time: 121 | return ret, exec_time # type: ignore 122 | return ret 123 | return decorator 124 | 125 | 126 | def print_perf_timepoints(*args: int) -> None: 127 | if len(args) < 2: 128 | raise ValueError('At least 2 timepoints required') 129 | for i in range(1, len(args)): 130 | logging.debug(f'{i}: {args[i] - args[i-1]} ns') 131 | 132 | 133 | def profile_cpu(func: Callable[..., T]) -> Callable[..., T]: 134 | @wraps(func) 135 | def decorator(*args: Any, **kwargs: Any) -> T: 136 | from cProfile import Profile 137 | from pstats import Stats, SortKey # type: ignore 138 | 139 | p = Profile(perf_counter_ns, 0.000_000_001, True, False) 140 | ret = p.runcall(func, *args, **kwargs) 141 | 142 | s = Stats(p) 143 | s.sort_stats(SortKey.TIME) 144 | s.print_stats(10) 145 | return ret 146 | return decorator 147 | 148 | 149 | def print_vs_output_colorspace_info(vs_output: vs.VideoNode) -> None: 150 | from vspreview.core import Output 151 | 152 | props = vs_output.get_frame(0).props 153 | logging.debug('Matrix: {}, Transfer: {}, Primaries: {}, Range: {}'.format( 154 | Output.Matrix .values[props['_Matrix']] if '_Matrix' in props else None, 155 | Output.Transfer .values[props['_Transfer']] if '_Transfer' in props else None, 156 | Output.Primaries.values[props['_Primaries']] if '_Primaries' in props else None, 157 | Output.Range .values[props['_ColorRange']] if '_ColorRange' in props else None, 158 | )) 159 | 160 | 161 | class DebugMeta(sip.wrappertype): # type: ignore 162 | def __new__(cls: Type[type], name: str, bases: Tuple[type, ...], dct: Dict[str, Any]) -> DebugMeta: 163 | from functools import partialmethod 164 | 165 | base = bases[0] 166 | for attr in dir(base): 167 | if not attr.endswith('__') and callable(getattr(base, attr)): 168 | dct[attr] = partialmethod(DebugMeta.dummy_method, attr) 169 | subcls = super(DebugMeta, cls).__new__(cls, name, bases, dct) # type: ignore 170 | return cast(DebugMeta, subcls) 171 | 172 | def dummy_method(self, name: str, *args: Any, **kwargs: Any) -> Any: 173 | method = getattr(super(GraphicsScene, GraphicsScene), name) 174 | method = measure_exec_time_ms(method) 175 | return method(self, *args, **kwargs) 176 | 177 | 178 | class GraphicsScene(Qt.QGraphicsScene, metaclass=DebugMeta): # pylint: disable=invalid-metaclass 179 | def event(self, event: Qt.QEvent) -> bool: 180 | t0 = perf_counter_ns() 181 | ret = super().event(event) 182 | t1 = perf_counter_ns() 183 | interval = t1 - t0 184 | if interval > 5_000_000: 185 | print(self.__class__.__name__ + '.event()') 186 | print(f'{interval / 1_000_000}: {event.type()}') 187 | 188 | return ret 189 | 190 | def __getattribute__(self, name: str) -> Any: 191 | attr = super().__getattribute__(name) 192 | if callable(attr): 193 | return measure_exec_time_ms(attr) 194 | 195 | 196 | qevent_info = { 197 | 0: ('None', 'invalid event'), 198 | 1: ('Timer', 'timer event'), 199 | 2: ('MouseButtonPress', 'mouse button pressed'), 200 | 3: ('MouseButtonRelease', 'mouse button released'), 201 | 4: ('MouseButtonDblClick', 'mouse button double click'), 202 | 5: ('MouseMove', 'mouse move'), 203 | 6: ('KeyPress', 'key pressed'), 204 | 7: ('KeyRelease', 'key released'), 205 | 8: ('FocusIn', 'keyboard focus received'), 206 | 9: ('FocusOut', 'keyboard focus lost'), 207 | 23: ('FocusAboutToChange', 'keyboard focus is about to be lost'), 208 | 10: ('Enter', 'mouse enters widget'), 209 | 11: ('Leave', 'mouse leaves widget'), 210 | 12: ('Paint', 'paint widget'), 211 | 13: ('Move', 'move widget'), 212 | 14: ('Resize', 'resize widget'), 213 | 15: ('Create', 'after widget creation'), 214 | 16: ('Destroy', 'during widget destruction'), 215 | 17: ('Show', 'widget is shown'), 216 | 18: ('Hide', 'widget is hidden'), 217 | 19: ('Close', 'request to close widget'), 218 | 20: ('Quit', 'request to quit application'), 219 | 21: ('ParentChange', 'widget has been reparented'), 220 | 131: ('ParentAboutToChange', 'sent just before the parent change is done'), 221 | 22: ('ThreadChange', 'object has changed threads'), 222 | 24: ('WindowActivate', 'window was activated'), 223 | 25: ('WindowDeactivate', 'window was deactivated'), 224 | 26: ('ShowToParent', 'widget is shown to parent'), 225 | 27: ('HideToParent', 'widget is hidden to parent'), 226 | 31: ('Wheel', 'wheel event'), 227 | 33: ('WindowTitleChange', 'window title changed'), 228 | 34: ('WindowIconChange', 'icon changed'), 229 | 35: ('ApplicationWindowIconChange', 'application icon changed'), 230 | 36: ('ApplicationFontChange', 'application font changed'), 231 | 37: ('ApplicationLayoutDirectionChange', 'application layout direction changed'), 232 | 38: ('ApplicationPaletteChange', 'application palette changed'), 233 | 39: ('PaletteChange', 'widget palette changed'), 234 | 40: ('Clipboard', 'internal clipboard event'), 235 | 42: ('Speech', 'reserved for speech input'), 236 | 43: ('MetaCall', 'meta call event'), 237 | 50: ('SockAct', 'socket activation'), 238 | 132: ('WinEventAct', 'win event activation'), 239 | 52: ('DeferredDelete', 'deferred delete event'), 240 | 60: ('DragEnter', 'drag moves into widget'), 241 | 61: ('DragMove', 'drag moves in widget'), 242 | 62: ('DragLeave', 'drag leaves or is cancelled'), 243 | 63: ('Drop', 'actual drop'), 244 | 64: ('DragResponse', 'drag accepted/rejected'), 245 | 68: ('ChildAdded', 'new child widget'), 246 | 69: ('ChildPolished', 'polished child widget'), 247 | 71: ('ChildRemoved', 'deleted child widget'), 248 | 73: ('ShowWindowRequest', 'widget\'s window should be mapped'), 249 | 74: ('PolishRequest', 'widget should be polished'), 250 | 75: ('Polish', 'widget is polished'), 251 | 76: ('LayoutRequest', 'widget should be relayouted'), 252 | 77: ('UpdateRequest', 'widget should be repainted'), 253 | 78: ('UpdateLater', 'request update() later'), 254 | 255 | 79: ('EmbeddingControl', 'ActiveX embedding'), 256 | 80: ('ActivateControl', 'ActiveX activation'), 257 | 81: ('DeactivateControl', 'ActiveX deactivation'), 258 | 82: ('ContextMenu', 'context popup menu'), 259 | 83: ('InputMethod', 'input method'), 260 | 87: ('TabletMove', 'Wacom tablet event'), 261 | 88: ('LocaleChange', 'the system locale changed'), 262 | 89: ('LanguageChange', 'the application language changed'), 263 | 90: ('LayoutDirectionChange', 'the layout direction changed'), 264 | 91: ('Style', 'internal style event'), 265 | 92: ('TabletPress', 'tablet press'), 266 | 93: ('TabletRelease', 'tablet release'), 267 | 94: ('OkRequest', 'CE (Ok) button pressed'), 268 | 95: ('HelpRequest', 'CE (?) button pressed'), 269 | 270 | 96: ('IconDrag', 'proxy icon dragged'), 271 | 272 | 97: ('FontChange', 'font has changed'), 273 | 98: ('EnabledChange', 'enabled state has changed'), 274 | 99: ('ActivationChange', 'window activation has changed'), 275 | 100: ('StyleChange', 'style has changed'), 276 | 101: ('IconTextChange', 'icon text has changed. Deprecated.'), 277 | 102: ('ModifiedChange', 'modified state has changed'), 278 | 109: ('MouseTrackingChange', 'mouse tracking state has changed'), 279 | 280 | 103: ('WindowBlocked', 'window is about to be blocked modally'), 281 | 104: ('WindowUnblocked', 'windows modal blocking has ended'), 282 | 105: ('WindowStateChange', ''), 283 | 284 | 106: ('ReadOnlyChange', 'readonly state has changed'), 285 | 286 | 110: ('ToolTip', ''), 287 | 111: ('WhatsThis', ''), 288 | 112: ('StatusTip', ''), 289 | 290 | 113: ('ActionChanged', ''), 291 | 114: ('ActionAdded', ''), 292 | 115: ('ActionRemoved', ''), 293 | 294 | 116: ('FileOpen', 'file open request'), 295 | 296 | 117: ('Shortcut', 'shortcut triggered'), 297 | 51: ('ShortcutOverride', 'shortcut override request'), 298 | 299 | 118: ('WhatsThisClicked', ''), 300 | 301 | 120: ('ToolBarChange', 'toolbar visibility toggled'), 302 | 303 | 121: ('ApplicationActivate', 304 | 'deprecated. Use ApplicationStateChange instead.'), 305 | 122: ('ApplicationDeactivate', 306 | 'deprecated. Use ApplicationStateChange instead.'), 307 | 308 | 123: ('QueryWhatsThis', 'query what\'s this widget help'), 309 | 124: ('EnterWhatsThisMode', ''), 310 | 125: ('LeaveWhatsThisMode', ''), 311 | 312 | 126: ('ZOrderChange', 'child widget has had its z-order changed'), 313 | 314 | 127: ('HoverEnter', 'mouse cursor enters a hover widget'), 315 | 128: ('HoverLeave', 'mouse cursor leaves a hover widget'), 316 | 129: ('HoverMove', 'mouse cursor move inside a hover widget'), 317 | 318 | 150: ('EnterEditFocus', 'enter edit mode in keypad navigation'), 319 | 151: ('LeaveEditFocus', 'enter edit mode in keypad navigation'), 320 | 152: ('AcceptDropsChange', ''), 321 | 322 | 154: ('ZeroTimerEvent', 'Used for Windows Zero timer events'), 323 | 324 | 155: ('GraphicsSceneMouseMove', 'GraphicsView'), 325 | 156: ('GraphicsSceneMousePress', ''), 326 | 157: ('GraphicsSceneMouseRelease', ''), 327 | 158: ('GraphicsSceneMouseDoubleClick', ''), 328 | 159: ('GraphicsSceneContextMenu', ''), 329 | 160: ('GraphicsSceneHoverEnter', ''), 330 | 161: ('GraphicsSceneHoverMove', ''), 331 | 162: ('GraphicsSceneHoverLeave', ''), 332 | 163: ('GraphicsSceneHelp', ''), 333 | 164: ('GraphicsSceneDragEnter', ''), 334 | 165: ('GraphicsSceneDragMove', ''), 335 | 166: ('GraphicsSceneDragLeave', ''), 336 | 167: ('GraphicsSceneDrop', ''), 337 | 168: ('GraphicsSceneWheel', ''), 338 | 339 | 169: ('KeyboardLayoutChange', 'keyboard layout changed'), 340 | 341 | 170: ('DynamicPropertyChange', 342 | 'A dynamic property was changed through setProperty/property'), 343 | 344 | 171: ('TabletEnterProximity', ''), 345 | 172: ('TabletLeaveProximity', ''), 346 | 347 | 173: ('NonClientAreaMouseMove', ''), 348 | 174: ('NonClientAreaMouseButtonPress', ''), 349 | 175: ('NonClientAreaMouseButtonRelease', ''), 350 | 176: ('NonClientAreaMouseButtonDblClick', ''), 351 | 352 | 177: ('MacSizeChange', 353 | 'when the Qt::WA_Mac{Normal,Small,Mini}Size changes'), 354 | 355 | 178: ('ContentsRectChange', 356 | 'sent by QWidget::setContentsMargins (internal)'), 357 | 358 | 179: ('MacGLWindowChange', 359 | 'Internal! the window of the GLWidget has changed'), 360 | 361 | 180: ('FutureCallOut', ''), 362 | 363 | 181: ('GraphicsSceneResize', ''), 364 | 182: ('GraphicsSceneMove', ''), 365 | 366 | 183: ('CursorChange', ''), 367 | 184: ('ToolTipChange', ''), 368 | 369 | 185: ('NetworkReplyUpdated', 'Internal for QNetworkReply'), 370 | 371 | 186: ('GrabMouse', ''), 372 | 187: ('UngrabMouse', ''), 373 | 188: ('GrabKeyboard', ''), 374 | 189: ('UngrabKeyboard', ''), 375 | 191: ('MacGLClearDrawable', 376 | 'Internal Cocoa, the window has changed, so we must clear'), 377 | 378 | 192: ('StateMachineSignal', ''), 379 | 193: ('StateMachineWrapped', ''), 380 | 381 | 194: ('TouchBegin', ''), 382 | 195: ('TouchUpdate', ''), 383 | 196: ('TouchEnd', ''), 384 | 385 | 197: ('NativeGesture', 'QtGui native gesture'), 386 | 199: ('RequestSoftwareInputPanel', ''), 387 | 200: ('CloseSoftwareInputPanel', ''), 388 | 389 | 203: ('WinIdChange', ''), 390 | 198: ('Gesture', ''), 391 | 202: ('GestureOverride', ''), 392 | 204: ('ScrollPrepare', ''), 393 | 205: ('Scroll', ''), 394 | 395 | 206: ('Expose', ''), 396 | 397 | 207: ('InputMethodQuery', ''), 398 | 208: ('OrientationChange', 'Screen orientation has changed'), 399 | 400 | 209: ('TouchCancel', ''), 401 | 402 | 210: ('ThemeChange', ''), 403 | 404 | 211: ('SockClose', 'socket closed'), 405 | 406 | 212: ('PlatformPanel', ''), 407 | 408 | 213: ('StyleAnimationUpdate', 'style animation target should be updated'), 409 | 214: ('ApplicationStateChange', ''), 410 | 411 | 215: ('WindowChangeInternal', 'internal for QQuickWidget'), 412 | 216: ('ScreenChangeInternal', ''), 413 | 414 | 217: ('PlatformSurface', 415 | 'Platform surface created or about to be destroyed'), 416 | 417 | 218: ('Pointer', 'QQuickPointerEvent; ### Qt 6: QPointerEvent'), 418 | 419 | 219: ('TabletTrackingChange', 'tablet tracking state has changed'), 420 | 421 | 512: ('reserved', 'reserved for Qt Jambi\'s MetaCall event'), 422 | 513: ('reserved', 'reserved for Qt Jambi\'s DeleteOnMainThread event'), 423 | 424 | 1000: ('User', 'first user event id'), 425 | 65535: ('MaxUser', 'last user event id'), 426 | } 427 | 428 | 429 | class Application(Qt.QApplication): 430 | enter_count = 0 431 | 432 | def notify(self, obj: Qt.QObject, event: Qt.QEvent) -> bool: 433 | import sys 434 | 435 | isex = False 436 | try: 437 | self.enter_count += 1 438 | ret, time = cast( 439 | Tuple[bool, float], 440 | measure_exec_time_ms( 441 | Qt.QApplication.notify, True, False)(self, obj, event)) 442 | 443 | if (type(event).__name__ == 'QEvent' 444 | and event.type() in qevent_info): 445 | event_name = qevent_info[event.type()][0] 446 | else: 447 | event_name = type(event).__name__ 448 | 449 | try: 450 | obj_name = obj.objectName() 451 | except RuntimeError: 452 | obj_name = '' 453 | 454 | if obj_name == '': 455 | try: 456 | if (obj.parent() is not None 457 | and obj.parent().objectName() != ''): 458 | obj_name = '(parent) ' + obj.parent().objectName() 459 | except RuntimeError: 460 | pass 461 | 462 | recursive_indent = 2 * (self.enter_count - 1) 463 | 464 | print( 465 | f'{time:7.3f} ms, receiver: {type(obj).__name__:>25}, event: {event.type():3d} {" " * recursive_indent + event_name:<30}, name: {obj_name}') 466 | 467 | self.enter_count -= 1 468 | 469 | return ret 470 | except Exception: # pylint: disable=broad-except 471 | isex = True 472 | logging.error('Application: unexpected error') 473 | print(*sys.exc_info()) 474 | return False 475 | finally: 476 | if isex: 477 | self.quit() 478 | -------------------------------------------------------------------------------- /vspreview/utils/utils.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from functools import lru_cache, partial, wraps 4 | import logging 5 | from string import Template 6 | from typing import ( 7 | Any, Callable, cast, MutableMapping, Optional, Type, 8 | TYPE_CHECKING, TypeVar, Union, 9 | ) 10 | 11 | from PyQt5 import Qt 12 | 13 | from vspreview.core import Time, TimeInterval, TimeType 14 | from vspreview.utils import debug 15 | 16 | 17 | T = TypeVar('T') 18 | 19 | 20 | def to_qtime(time: Union[TimeType]) -> Qt.QTime: 21 | td = time.value 22 | hours, secs_rem = divmod(td.seconds, 3600) 23 | return Qt.QTime(hours, 24 | secs_rem // 60, 25 | secs_rem % 60, 26 | td.microseconds // 1000) 27 | 28 | 29 | def from_qtime(qtime: Qt.QTime, t: Type[TimeType]) -> TimeType: 30 | return t(milliseconds=qtime.msecsSinceStartOfDay()) 31 | 32 | 33 | # it is a BuiltinMethodType at the same time 34 | def qt_silent_call(qt_method: Callable[..., T], *args: Any, **kwargs: Any) -> T: 35 | # https://github.com/python/typing/issues/213 36 | qobject = qt_method.__self__ # type: ignore 37 | block = Qt.QSignalBlocker(qobject) 38 | ret = qt_method(*args, **kwargs) 39 | del(block) 40 | return ret 41 | 42 | 43 | class DeltaTemplate(Template): 44 | delimiter = '%' 45 | 46 | 47 | def strfdelta(time: Union[TimeType], output_format: str) -> str: 48 | d: MutableMapping[str, str] = {} 49 | td = time.value 50 | hours, secs_rem = divmod(td.seconds, 3600) 51 | minutes = secs_rem // 60 52 | seconds = secs_rem % 60 53 | milliseconds = td.microseconds // 1000 54 | d['D'] = '{:d}'.format(td.days) 55 | d['H'] = '{:02d}'.format(hours) 56 | d['M'] = '{:02d}'.format(minutes) 57 | d['S'] = '{:02d}'.format(seconds) 58 | d['Z'] = '{:03d}'.format(milliseconds) 59 | d['h'] = '{:d}'.format(hours) 60 | d['m'] = '{:2d}'.format(minutes) 61 | d['s'] = '{:2d}'.format(seconds) 62 | 63 | template = DeltaTemplate(output_format) 64 | return template.substitute(**d) 65 | 66 | 67 | if TYPE_CHECKING: 68 | from vspreview.core import AbstractMainWindow 69 | 70 | 71 | @lru_cache() 72 | def main_window() -> AbstractMainWindow: 73 | from vspreview.core import AbstractMainWindow # pylint: disable=redefined-outer-name 74 | 75 | app = Qt.QApplication.instance() 76 | if app is not None: 77 | for widget in app.topLevelWidgets(): 78 | if isinstance(widget, AbstractMainWindow): 79 | # TODO: get rid of excessive cast 80 | return cast(AbstractMainWindow, widget) 81 | logging.critical('main_window() failed') 82 | app.exit() 83 | raise RuntimeError 84 | 85 | 86 | def add_shortcut(key: int, handler: Callable[[], None], widget: Optional[Qt.QWidget] = None) -> None: 87 | if widget is None: 88 | widget = main_window() 89 | Qt.QShortcut(Qt.QKeySequence(key), widget).activated.connect(handler) # type: ignore 90 | 91 | 92 | def fire_and_forget(f: Callable[..., T]) -> Callable[..., T]: 93 | from asyncio import get_event_loop 94 | 95 | @wraps(f) 96 | def wrapped(*args: Any, **kwargs: Any) -> Any: 97 | loop = get_event_loop() 98 | if callable(f): 99 | return loop.run_in_executor(None, partial(f, *args, **kwargs)) 100 | else: 101 | raise TypeError('fire_and_forget(): Task must be a callable') 102 | return wrapped 103 | 104 | 105 | def set_status_label(label: str) -> Callable[..., T]: 106 | def decorator(func: Callable[..., T]) -> Any: 107 | @wraps(func) 108 | def wrapped(*args: Any, **kwargs: Any) -> T: 109 | main = main_window() 110 | 111 | if main.statusbar.label.text() == 'Ready': 112 | main.statusbar.label.setText(label) 113 | 114 | ret = func(*args, **kwargs) 115 | 116 | if main.statusbar.label.text() == label: 117 | main.statusbar.label.setText('Ready') 118 | 119 | return ret 120 | return wrapped 121 | return decorator 122 | 123 | 124 | def method_dispatch(func: Callable[..., T]) -> Callable[..., T]: 125 | ''' 126 | https://stackoverflow.com/a/24602374 127 | ''' 128 | from functools import singledispatch, update_wrapper 129 | 130 | dispatcher = singledispatch(func) 131 | 132 | def wrapper(*args: Any, **kwargs: Any) -> T: 133 | return dispatcher.dispatch(args[1].__class__)(*args, **kwargs) 134 | 135 | wrapper.register = dispatcher.register # type: ignore 136 | update_wrapper(wrapper, dispatcher) 137 | return wrapper 138 | 139 | 140 | def set_qobject_names(obj: object) -> None: 141 | from vspreview.core import AbstractToolbar 142 | 143 | if not hasattr(obj, '__slots__'): 144 | return 145 | 146 | slots = list(obj.__slots__) 147 | 148 | if 'main' in slots: 149 | slots.remove('main') 150 | 151 | for attr_name in slots: 152 | attr = getattr(obj, attr_name) 153 | if not isinstance(attr, Qt.QObject): 154 | continue 155 | attr.setObjectName(type(obj).__name__ + '.' + attr_name) 156 | 157 | 158 | def get_usable_cpus_count() -> int: 159 | from psutil import cpu_count, Process 160 | 161 | try: 162 | count = len(Process().cpu_affinity()) 163 | except AttributeError: 164 | count = cpu_count() 165 | 166 | return count 167 | 168 | 169 | def vs_clear_cache() -> None: 170 | import vapoursynth as vs 171 | 172 | cache_size = vs.core.max_cache_size 173 | vs.core.max_cache_size = 1 174 | output = list(vs.get_outputs().values())[0] 175 | if isinstance(output, vs.AlphaOutputTuple): 176 | output = output.clip 177 | output.get_frame(0) 178 | vs.core.max_cache_size = cache_size 179 | -------------------------------------------------------------------------------- /vspreview/widgets/__init__.py: -------------------------------------------------------------------------------- 1 | from .colorview import ColorView 2 | from .custom import * 3 | from .timeline import Timeline 4 | 5 | from .timeline import Notch, Notches 6 | -------------------------------------------------------------------------------- /vspreview/widgets/colorview.py: -------------------------------------------------------------------------------- 1 | import logging 2 | 3 | from PyQt5 import Qt 4 | 5 | 6 | class ColorView(Qt.QWidget): 7 | __slots__ = ( 8 | '_color', 9 | ) 10 | 11 | def __init__(self, parent: Qt.QWidget) -> None: 12 | super().__init__(parent) 13 | 14 | self._color = Qt.QColor(0, 0, 0, 255) 15 | 16 | def paintEvent(self, event: Qt.QPaintEvent) -> None: 17 | super().paintEvent(event) 18 | 19 | painter = Qt.QPainter(self) 20 | painter.fillRect(event.rect(), self.color) 21 | 22 | @property 23 | def color(self) -> Qt.QColor: 24 | return self._color 25 | 26 | @color.setter 27 | def color(self, value: Qt.QColor) -> None: 28 | if self._color == value: 29 | return 30 | self._color = value 31 | self.update() 32 | -------------------------------------------------------------------------------- /vspreview/widgets/custom/__init__.py: -------------------------------------------------------------------------------- 1 | from .combobox import ComboBox 2 | from .edits import FrameEdit, TimeEdit 3 | from .graphicsview import GraphicsImageItem, GraphicsView 4 | from .misc import StatusBar 5 | -------------------------------------------------------------------------------- /vspreview/widgets/custom/combobox.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import logging 4 | from typing import cast, Dict, Generic, Optional, Type, TYPE_CHECKING, TypeVar 5 | 6 | from PyQt5 import Qt 7 | 8 | from vspreview.core import Output 9 | from vspreview.models import SceningList 10 | 11 | T = TypeVar('T', Output, SceningList, float) 12 | 13 | 14 | class ComboBox(Qt.QComboBox, Generic[T]): 15 | def __class_getitem__(cls, ty: Type[T]) -> Type: 16 | type_specializations: Dict[Type, Type] = { 17 | Output : _ComboBox_Output, 18 | SceningList: _ComboBox_SceningList, 19 | float : _ComboBox_float, 20 | } 21 | 22 | try: 23 | return type_specializations[ty] 24 | except KeyError: 25 | raise TypeError 26 | 27 | indexChanged = Qt.pyqtSignal(int, int) 28 | 29 | def __init__(self, parent: Optional[Qt.QWidget] = None) -> None: 30 | super().__init__(parent) 31 | 32 | self.ty: Type[T] 33 | 34 | self.setSizeAdjustPolicy( 35 | Qt.QComboBox.AdjustToMinimumContentsLengthWithIcon) 36 | 37 | self.oldValue = self.currentData() 38 | self.oldIndex = self.currentIndex() 39 | self.currentIndexChanged.connect(self._currentIndexChanged) 40 | 41 | def _currentIndexChanged(self, newIndex: int) -> None: 42 | newValue = self.currentData() 43 | self.valueChanged.emit(newValue, self.oldValue) 44 | self.indexChanged.emit(newIndex, self.oldIndex) 45 | self.oldValue = newValue 46 | self.oldIndex = newIndex 47 | 48 | def currentValue(self) -> Optional[T]: 49 | return cast(Optional[T], self.currentData()) 50 | 51 | def setCurrentValue(self, newValue: T) -> None: 52 | i = self.model().index_of(newValue) 53 | self.setCurrentIndex(i) 54 | 55 | 56 | class _ComboBox_Output(ComboBox): 57 | ty = Output 58 | if TYPE_CHECKING: 59 | valueChanged = Qt.pyqtSignal(Optional[ty], Optional[ty]) 60 | else: 61 | valueChanged = Qt.pyqtSignal(object, object) 62 | 63 | 64 | class _ComboBox_SceningList(ComboBox): 65 | ty = SceningList 66 | if TYPE_CHECKING: 67 | valueChanged = Qt.pyqtSignal(ty, Optional[ty]) 68 | else: 69 | valueChanged = Qt.pyqtSignal(ty, object) 70 | 71 | 72 | class _ComboBox_float(ComboBox): 73 | ty = float 74 | if TYPE_CHECKING: 75 | valueChanged = Qt.pyqtSignal(ty, Optional[ty]) 76 | else: 77 | valueChanged = Qt.pyqtSignal(ty, object) 78 | -------------------------------------------------------------------------------- /vspreview/widgets/custom/edits.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import logging 4 | from typing import cast, Dict, Generic, Optional, Type 5 | 6 | from PyQt5 import Qt 7 | 8 | from vspreview.core import ( 9 | Frame, FrameInterval, FrameType, Time, TimeInterval, TimeType, 10 | ) 11 | from vspreview.utils import debug, from_qtime, to_qtime 12 | 13 | 14 | # TODO: replace specialized Edit classes with some metaclasses magic or such 15 | 16 | 17 | class FrameEdit(Qt.QSpinBox, Generic[FrameType]): 18 | def __class_getitem__(cls, ty: Type[FrameType]) -> Type: 19 | type_specializations: Dict[Type, Type] = { 20 | Frame : _FrameEdit_Frame, 21 | FrameInterval: _FrameEdit_FrameInterval, 22 | } 23 | 24 | try: 25 | return type_specializations[ty] 26 | except KeyError: 27 | raise TypeError 28 | 29 | def __init__(self, parent: Optional[Qt.QWidget] = None) -> None: 30 | super().__init__(parent) 31 | 32 | self.ty: Type[FrameType] 33 | 34 | self.setMinimum(self.ty(0)) 35 | 36 | self.oldValue: FrameType = self.value() 37 | super().valueChanged.connect(self._valueChanged) 38 | 39 | def _valueChanged(self, newValue: int) -> None: 40 | self.valueChanged.emit(self.value(), self.oldValue) 41 | 42 | def value(self) -> FrameType: # type: ignore 43 | return self.ty(super().value()) 44 | 45 | def setValue(self, newValue: FrameType) -> None: # type: ignore 46 | super().setValue(int(newValue)) 47 | 48 | def minimum(self) -> FrameType: # type: ignore 49 | return self.ty(super().minimum()) 50 | 51 | def setMinimum(self, newValue: FrameType) -> None: # type: ignore 52 | super().setMinimum(int(newValue)) 53 | 54 | def maximum(self) -> FrameType: # type: ignore 55 | return self.ty(super().maximum()) 56 | 57 | def setMaximum(self, newValue: FrameType) -> None: # type: ignore 58 | super().setMaximum(int(newValue)) 59 | 60 | 61 | class _FrameEdit_Frame(FrameEdit): 62 | ty = Frame 63 | valueChanged = Qt.pyqtSignal(ty, ty) 64 | 65 | 66 | class _FrameEdit_FrameInterval(FrameEdit): 67 | ty = FrameInterval 68 | valueChanged = Qt.pyqtSignal(ty, ty) 69 | 70 | 71 | class TimeEdit(Qt.QTimeEdit, Generic[TimeType]): 72 | def __class_getitem__(cls, ty: Type[TimeType]) -> Type: 73 | type_specializations: Dict[Type, Type] = { 74 | Time : _TimeEdit_Time, 75 | TimeInterval: _TimeEdit_TimeInterval, 76 | } 77 | 78 | try: 79 | return type_specializations[ty] 80 | except KeyError: 81 | raise TypeError 82 | 83 | def __init__(self, parent: Optional[Qt.QWidget] = None) -> None: 84 | super().__init__(parent) 85 | 86 | self.ty: Type[TimeType] 87 | 88 | self.setDisplayFormat('H:mm:ss.zzz') 89 | self.setButtonSymbols(Qt.QTimeEdit.NoButtons) 90 | self.setMinimum(self.ty()) 91 | 92 | self.oldValue: TimeType = self.value() 93 | cast(Qt.pyqtSignal, self.timeChanged).connect(self._timeChanged) 94 | 95 | def _timeChanged(self, newValue: Qt.QTime) -> None: 96 | self.valueChanged.emit(self.value(), self.oldValue) 97 | self.oldValue = self.value() 98 | 99 | def value(self) -> TimeType: 100 | return from_qtime(super().time(), self.ty) 101 | 102 | def setValue(self, newValue: TimeType) -> None: 103 | super().setTime(to_qtime(newValue)) 104 | 105 | def minimum(self) -> TimeType: 106 | return from_qtime(super().minimumTime(), self.ty) 107 | 108 | def setMinimum(self, newValue: TimeType) -> None: 109 | super().setMinimumTime(to_qtime(newValue)) 110 | 111 | def maximum(self) -> TimeType: 112 | return from_qtime(super().maximumTime(), self.ty) 113 | 114 | def setMaximum(self, newValue: TimeType) -> None: 115 | super().setMaximumTime(to_qtime(newValue)) 116 | 117 | 118 | class _TimeEdit_Time(TimeEdit): 119 | ty = Time 120 | valueChanged = Qt.pyqtSignal(ty, ty) 121 | 122 | 123 | class _TimeEdit_TimeInterval(TimeEdit): 124 | ty = TimeInterval 125 | valueChanged = Qt.pyqtSignal(ty, ty) 126 | -------------------------------------------------------------------------------- /vspreview/widgets/custom/graphicsview.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import logging 4 | from typing import cast, Optional 5 | 6 | from PyQt5 import Qt, QtCore 7 | 8 | 9 | class GraphicsView(Qt.QGraphicsView): 10 | WHEEL_STEP = 15 * 8 # degrees 11 | 12 | __slots__ = ( 13 | 'app', 'angleRemainder', 'zoomValue', 14 | ) 15 | 16 | mouseMoved = Qt.pyqtSignal(Qt.QMouseEvent) 17 | mousePressed = Qt.pyqtSignal(Qt.QMouseEvent) 18 | mouseReleased = Qt.pyqtSignal(Qt.QMouseEvent) 19 | wheelScrolled = Qt.pyqtSignal(int) 20 | 21 | def __init__(self, parent: Optional[Qt.QWidget] = None) -> None: 22 | super().__init__(parent) 23 | 24 | self.app = Qt.QApplication.instance() 25 | self.angleRemainder = 0 26 | self.zoomValue = 0 27 | 28 | def setZoom(self, value: int) -> None: 29 | transform = Qt.QTransform() 30 | transform.scale(value, value) 31 | self.setTransform(transform) 32 | 33 | def event(self, event: Qt.QEvent) -> bool: 34 | if isinstance(event, Qt.QNativeGestureEvent): 35 | typ = event.gestureType() 36 | if typ == QtCore.Qt.BeginNativeGesture: 37 | self.zoomValue = 0 38 | elif typ == QtCore.Qt.ZoomNativeGesture: 39 | self.zoomValue += event.value() 40 | if typ == QtCore.Qt.EndNativeGesture: 41 | self.wheelScrolled.emit(-1 if self.zoomValue < 0 else 1) 42 | return super().event(event) 43 | 44 | def wheelEvent(self, event: Qt.QWheelEvent) -> None: 45 | modifiers = self.app.keyboardModifiers() 46 | if modifiers == Qt.Qt.ControlModifier: 47 | angleDelta = event.angleDelta().y() 48 | 49 | # check if wheel wasn't rotated the other way since last rotation 50 | if self.angleRemainder * angleDelta < 0: 51 | self.angleRemainder = 0 52 | 53 | self.angleRemainder += angleDelta 54 | if abs(self.angleRemainder) >= self.WHEEL_STEP: 55 | self.wheelScrolled.emit(self.angleRemainder // self.WHEEL_STEP) 56 | self.angleRemainder %= self.WHEEL_STEP 57 | return 58 | elif modifiers == Qt.Qt.NoModifier: 59 | self. verticalScrollBar().setValue( 60 | self. verticalScrollBar().value() - event.angleDelta().y()) 61 | self.horizontalScrollBar().setValue( 62 | self.horizontalScrollBar().value() - event.angleDelta().x()) 63 | return 64 | elif modifiers == Qt.Qt.ShiftModifier: 65 | self. verticalScrollBar().setValue( 66 | self. verticalScrollBar().value() - event.angleDelta().x()) 67 | self.horizontalScrollBar().setValue( 68 | self.horizontalScrollBar().value() - event.angleDelta().y()) 69 | return 70 | 71 | event.ignore() 72 | 73 | def mouseMoveEvent(self, event: Qt.QMouseEvent) -> None: 74 | super().mouseMoveEvent(event) 75 | if self.hasMouseTracking(): 76 | self.mouseMoved.emit(event) 77 | 78 | def mousePressEvent(self, event: Qt.QMouseEvent) -> None: 79 | if event.button() == Qt.Qt.LeftButton: 80 | self.drag_mode = self.dragMode() 81 | self.setDragMode(Qt.QGraphicsView.ScrollHandDrag) 82 | super().mousePressEvent(event) 83 | self.mousePressed.emit(event) 84 | 85 | def mouseReleaseEvent(self, event: Qt.QMouseEvent) -> None: 86 | super().mouseReleaseEvent(event) 87 | if event.button() == Qt.Qt.LeftButton: 88 | self.setDragMode(self.drag_mode) 89 | self.mouseReleased.emit(event) 90 | 91 | 92 | class GraphicsImageItem: 93 | __slots__ = ( 94 | '_image', '_graphics_item' 95 | ) 96 | 97 | def __init__(self, graphics_item: Qt.QGraphicsItem, image: Qt.QImage) -> None: 98 | self._graphics_item = graphics_item 99 | self._image = image 100 | 101 | def contains(self, point: Qt.QPointF) -> bool: 102 | return self._graphics_item.contains(point) 103 | 104 | def hide(self) -> None: 105 | self._graphics_item.hide() 106 | 107 | def image(self) -> Qt.QImage: 108 | return self._image 109 | 110 | def pixmap(self) -> Qt.QPixmap: 111 | return cast(Qt.QPixmap, self._graphics_item.pixmap()) 112 | 113 | def setImage(self, value: Qt.QImage) -> None: 114 | self._image = value 115 | self._graphics_item.setPixmap(Qt.QPixmap.fromImage(self._image, Qt.Qt.NoFormatConversion)) 116 | 117 | def show(self) -> None: 118 | self._graphics_item.show() 119 | -------------------------------------------------------------------------------- /vspreview/widgets/custom/misc.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | import logging 3 | 4 | from PyQt5 import Qt 5 | 6 | 7 | class StatusBar(Qt.QStatusBar): 8 | def __init__(self, parent: Qt.QWidget) -> None: 9 | super().__init__(parent) 10 | 11 | self.permament_start_index = 0 12 | 13 | def addPermanentWidget(self, widget: Qt.QWidget, stretch: int = 0) -> None: 14 | self.insertPermanentWidget(self.permament_start_index, widget, stretch) 15 | 16 | def addWidget(self, widget: Qt.QWidget, stretch: int = 0) -> None: 17 | self.permament_start_index += 1 18 | super().addWidget(widget, stretch) 19 | 20 | def insertWidget(self, index: int, widget: Qt.QWidget, stretch: int = 0) -> int: 21 | self.permament_start_index += 1 22 | return super().insertWidget(index, widget, stretch) -------------------------------------------------------------------------------- /vspreview/widgets/timeline.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from enum import auto, Enum 4 | import logging 5 | from typing import ( 6 | Any, cast, Dict, Iterator, List, Optional, Tuple, Type, Union, 7 | ) 8 | 9 | from PyQt5 import Qt 10 | from yaml import YAMLObject 11 | 12 | from vspreview.core import ( 13 | AbstractToolbar, Frame, FrameInterval, Scene, Time, TimeInterval, 14 | TimeType, FrameType, 15 | ) 16 | from vspreview.utils import debug 17 | 18 | 19 | # pylint: disable=attribute-defined-outside-init 20 | 21 | # TODO: store cursor pos as frame 22 | # TODO: consider moving from ints to floats 23 | # TODO: make Timeline.Mode a proper class instead of bunch of strings 24 | 25 | 26 | class Notch: 27 | def __init__(self, data: Union[Frame, Time], color: Qt.QColor = cast(Qt.QColor, Qt.Qt.white), 28 | label: str = '', line: Qt.QLineF = Qt.QLineF()) -> None: 29 | self.data = data 30 | self.color = color 31 | self.label = label 32 | self.line = line 33 | 34 | def __repr__(self) -> str: 35 | return '{}({}, {}, {}, {})'.format( 36 | type(self).__name__, repr(self.data), repr(self.color), 37 | repr(self.label), repr(self.line)) 38 | 39 | 40 | class Notches: 41 | def __init__(self, other: Optional[Notches] = None) -> None: 42 | self.items: List[Notch] = [] 43 | 44 | if other is None: 45 | return 46 | self.items = other.items 47 | 48 | def add(self, data: Union[Frame, Scene, Time, Notch], color: Qt.QColor = cast(Qt.QColor, Qt.Qt.white), label: str = '') -> None: 49 | if isinstance(data, Notch): 50 | self.items.append(data) 51 | elif isinstance(data, Scene): 52 | if label == '': 53 | label = data.label 54 | self.items.append(Notch(data.start, color, label)) 55 | if data.end != data.start: 56 | self.items.append(Notch(data.end, color, label)) 57 | elif isinstance(data, (Frame, Time)): 58 | self.items.append(Notch(data, color, label)) 59 | else: 60 | raise TypeError 61 | 62 | def __len__(self) -> int: 63 | return len(self.items) 64 | 65 | def __getitem__(self, index: int) -> Notch: 66 | return self.items[index] 67 | 68 | def __iter__(self) -> Iterator[Notch]: 69 | return iter(self.items) 70 | 71 | def __repr__(self) -> str: 72 | return '{}({})'.format(type(self).__name__, repr(self.items)) 73 | 74 | 75 | class Timeline(Qt.QWidget): 76 | __slots__ = ( 77 | 'app', 'main', 78 | 'rectF', 'prevRectF', 79 | 'totalT', 'totalF', 80 | 'notchIntervalTargetX', 'notchHeight', 'fontHeight', 81 | 'notchLabelInterval', 'notchScrollInterval', 'scrollHeight', 82 | 'cursorX', 'cursorFT', 'needFullRepaint', 83 | 'scrollRect', 84 | ) 85 | 86 | class Mode(YAMLObject): 87 | yaml_tag = '!Timeline.Mode' 88 | 89 | FRAME = 'frame' 90 | TIME = 'time' 91 | 92 | @classmethod 93 | def is_valid(cls, value: str) -> bool: 94 | return value in (cls.FRAME, 95 | cls.TIME) 96 | 97 | clicked = Qt.pyqtSignal(Frame, Time) 98 | 99 | def __init__(self, parent: Qt.QWidget) -> None: 100 | from vspreview.utils import main_window 101 | 102 | super().__init__(parent) 103 | self.app = Qt.QApplication.instance() 104 | self.main = main_window() 105 | 106 | self._mode = self.Mode.TIME 107 | 108 | self.rect_f = Qt.QRectF() 109 | 110 | self.end_t = Time(seconds=1) 111 | self.end_f = Frame(1) 112 | 113 | self.notch_interval_target_x = round(75 * self.main.display_scale) 114 | self.notch_height = round( 6 * self.main.display_scale) 115 | self.font_height = round(10 * self.main.display_scale) 116 | self.notch_label_interval = round(-1 * self.main.display_scale) 117 | self.notch_scroll_interval = round( 2 * self.main.display_scale) 118 | self.scroll_height = round(10 * self.main.display_scale) 119 | 120 | self.setMinimumSize(self.notch_interval_target_x, 121 | round(33 * self.main.display_scale)) 122 | 123 | font = self.font() 124 | font.setPixelSize(self.font_height) 125 | self.setFont(font) 126 | 127 | self.cursor_x = 0 128 | # used as a fallback when self.rectF.width() is 0, 129 | # so cursorX is incorrect 130 | self.cursor_ftx: Optional[Union[Frame, Time, int]] = None 131 | # False means that only cursor position'll be recalculated 132 | self.need_full_repaint = True 133 | 134 | self.toolbars_notches: Dict[AbstractToolbar, Notches] = {} 135 | 136 | self.setAttribute(Qt.Qt.WA_OpaquePaintEvent) 137 | self.setMouseTracking(True) 138 | 139 | def paintEvent(self, event: Qt.QPaintEvent) -> None: 140 | super().paintEvent(event) 141 | self.rect_f = Qt.QRectF(event.rect()) 142 | # self.rectF.adjust(0, 0, -1, -1) 143 | 144 | if self.cursor_ftx is not None: 145 | self.set_position(self.cursor_ftx) 146 | self.cursor_ftx = None 147 | 148 | painter = Qt.QPainter(self) 149 | self.drawWidget(painter) 150 | 151 | def drawWidget(self, painter: Qt.QPainter) -> None: 152 | from copy import deepcopy 153 | 154 | from vspreview.utils import strfdelta 155 | 156 | # calculations 157 | 158 | if self.need_full_repaint: 159 | labels_notches = Notches() 160 | label_notch_bottom = (self.rect_f.top() + self.font_height 161 | + self.notch_label_interval 162 | + self.notch_height + 5) 163 | label_notch_top = label_notch_bottom - self.notch_height 164 | label_notch_x = self.rect_f.left() 165 | 166 | if self.mode == self.Mode.TIME: 167 | notch_interval_t = self.calculate_notch_interval_t( 168 | self.notch_interval_target_x) 169 | label_format = self.generate_label_format(notch_interval_t, 170 | self.end_t) 171 | label_notch_t = Time() 172 | 173 | while (label_notch_x < self.rect_f.right() 174 | and label_notch_t <= self.end_t): 175 | line = Qt.QLineF(label_notch_x, label_notch_bottom, 176 | label_notch_x, label_notch_top) 177 | labels_notches.add(Notch(deepcopy(label_notch_t), 178 | line=line)) 179 | label_notch_t += notch_interval_t 180 | label_notch_x = self.t_to_x(label_notch_t) 181 | 182 | elif self.mode == self.Mode.FRAME: 183 | notch_interval_f = self.calculate_notch_interval_f( 184 | self.notch_interval_target_x) 185 | label_notch_f = Frame(0) 186 | 187 | while (label_notch_x < self.rect_f.right() 188 | and label_notch_f <= self.end_f): 189 | line = Qt.QLineF(label_notch_x, label_notch_bottom, 190 | label_notch_x, label_notch_top) 191 | labels_notches.add(Notch(deepcopy(label_notch_f), 192 | line=line)) 193 | label_notch_f += notch_interval_f 194 | label_notch_x = self.f_to_x(label_notch_f) 195 | 196 | self.scroll_rect = Qt.QRectF( 197 | self.rect_f.left(), 198 | label_notch_bottom + self.notch_scroll_interval, 199 | self.rect_f.width(), self.scroll_height) 200 | 201 | for toolbar, notches in self.toolbars_notches.items(): 202 | if not toolbar.is_notches_visible(): 203 | continue 204 | 205 | for notch in notches: 206 | if isinstance(notch.data, Frame): 207 | x = self.f_to_x(notch.data) 208 | elif isinstance(notch.data, Time): 209 | x = self.t_to_x(notch.data) 210 | y = self.scroll_rect.top() 211 | notch.line = Qt.QLineF( 212 | x, y, x, y + self.scroll_rect.height() - 1) 213 | 214 | cursor_line = Qt.QLineF( 215 | self.cursor_x, self.scroll_rect.top(), self.cursor_x, 216 | self.scroll_rect.top() + self.scroll_rect.height() - 1) 217 | 218 | # drawing 219 | 220 | if self.need_full_repaint: 221 | painter.fillRect(self.rect_f, 222 | self.palette().color(Qt.QPalette.Window)) 223 | 224 | painter.setPen( 225 | Qt.QPen(self.palette().color(Qt.QPalette.WindowText))) 226 | painter.setRenderHint(Qt.QPainter.Antialiasing, False) 227 | painter.drawLines([notch.line for notch in labels_notches]) 228 | 229 | painter.setRenderHint(Qt.QPainter.Antialiasing) 230 | for i, notch in enumerate(labels_notches): 231 | line = notch.line 232 | anchor_rect = Qt.QRectF( 233 | line.x2(), line.y2() - self.notch_label_interval, 0, 0) 234 | 235 | if self.mode == self.Mode.TIME: 236 | time = cast(Time, notch.data) 237 | label = strfdelta(time, label_format) 238 | if self.mode == self.Mode.FRAME: 239 | label = str(notch.data) 240 | 241 | if i == 0: 242 | rect = painter.boundingRect( 243 | anchor_rect, Qt.Qt.AlignBottom + Qt.Qt.AlignLeft, 244 | label) 245 | if self.mode == self.Mode.TIME: 246 | rect.moveLeft(-2.5) 247 | elif i == (len(labels_notches) - 1): 248 | rect = painter.boundingRect( 249 | anchor_rect, Qt.Qt.AlignBottom + Qt.Qt.AlignHCenter, 250 | label) 251 | if rect.right() > self.rect_f.right(): 252 | rect = painter.boundingRect( 253 | anchor_rect, Qt.Qt.AlignBottom + Qt.Qt.AlignRight, 254 | label) 255 | else: 256 | rect = painter.boundingRect( 257 | anchor_rect, Qt.Qt.AlignBottom + Qt.Qt.AlignHCenter, 258 | label) 259 | painter.drawText(rect, label) 260 | 261 | painter.setRenderHint(Qt.QPainter.Antialiasing, False) 262 | painter.fillRect(self.scroll_rect, Qt.Qt.gray) 263 | 264 | for toolbar, notches in self.toolbars_notches.items(): 265 | if not toolbar.is_notches_visible(): 266 | continue 267 | 268 | for notch in notches: 269 | painter.setPen(notch.color) 270 | painter.drawLine(notch.line) 271 | 272 | painter.setPen(Qt.Qt.black) 273 | painter.drawLine(cursor_line) 274 | 275 | self.need_full_repaint = False 276 | 277 | def full_repaint(self) -> None: 278 | self.need_full_repaint = True 279 | self.update() 280 | 281 | def moveEvent(self, event: Qt.QMoveEvent) -> None: 282 | super().moveEvent(event) 283 | self.full_repaint() 284 | 285 | def mousePressEvent(self, event: Qt.QMouseEvent) -> None: 286 | super().mousePressEvent(event) 287 | pos = Qt.QPoint(event.pos()) 288 | if self.scroll_rect.contains(pos): 289 | self.set_position(pos.x()) 290 | self.clicked.emit(self.x_to_f(self.cursor_x, Frame), 291 | self.x_to_t(self.cursor_x, Time)) 292 | 293 | def mouseMoveEvent(self, event: Qt.QMouseEvent) -> None: 294 | super().mouseMoveEvent(event) 295 | for toolbar, notches in self.toolbars_notches.items(): 296 | if not toolbar.is_notches_visible(): 297 | continue 298 | for notch in notches: 299 | line = notch.line 300 | if line.x1() - 0.5 <= event.x() <= line.x1() + 0.5: 301 | Qt.QToolTip.showText(event.globalPos(), notch.label) 302 | return 303 | 304 | def resizeEvent(self, event: Qt.QResizeEvent) -> None: 305 | super().resizeEvent(event) 306 | self.full_repaint() 307 | 308 | def event(self, event: Qt.QEvent) -> bool: 309 | if event.type() in (Qt.QEvent.Polish, 310 | Qt.QEvent.ApplicationPaletteChange): 311 | self.setPalette(self.main.palette()) 312 | self.full_repaint() 313 | return True 314 | 315 | return super().event(event) 316 | 317 | def update_notches(self, toolbar: Optional[AbstractToolbar] = None) -> None: 318 | if toolbar is not None: 319 | self.toolbars_notches[toolbar] = toolbar.get_notches() 320 | if toolbar is None: 321 | for t in self.main.toolbars: 322 | self.toolbars_notches[t] = t.get_notches() 323 | self.full_repaint() 324 | 325 | @property 326 | def mode(self) -> str: # pylint: disable=undefined-variable 327 | return self._mode 328 | 329 | @mode.setter 330 | def mode(self, value: str) -> None: 331 | if value == self._mode: 332 | return 333 | 334 | self._mode = value 335 | self.full_repaint() 336 | 337 | notch_intervals_t = ( 338 | TimeInterval(seconds= 1), 339 | TimeInterval(seconds= 2), 340 | TimeInterval(seconds= 5), 341 | TimeInterval(seconds= 10), 342 | TimeInterval(seconds= 15), 343 | TimeInterval(seconds= 30), 344 | TimeInterval(seconds= 60), 345 | TimeInterval(seconds= 90), 346 | TimeInterval(seconds= 120), 347 | TimeInterval(seconds= 300), 348 | TimeInterval(seconds= 600), 349 | TimeInterval(seconds= 900), 350 | TimeInterval(seconds=1200), 351 | TimeInterval(seconds=1800), 352 | TimeInterval(seconds=2700), 353 | TimeInterval(seconds=3600), 354 | TimeInterval(seconds=5400), 355 | TimeInterval(seconds=7200), 356 | ) 357 | 358 | def calculate_notch_interval_t(self, target_interval_x: int) -> TimeInterval: 359 | margin = 1 + self.main.TIMELINE_LABEL_NOTCHES_MARGIN / 100 360 | target_interval_t = self.x_to_t(target_interval_x, TimeInterval) 361 | if target_interval_t >= self.notch_intervals_t[-1] * margin: 362 | return self.notch_intervals_t[-1] 363 | for interval in self.notch_intervals_t: 364 | if target_interval_t < interval * margin: 365 | return interval 366 | raise RuntimeError 367 | 368 | notch_intervals_f = ( 369 | FrameInterval( 1), 370 | FrameInterval( 5), 371 | FrameInterval( 10), 372 | FrameInterval( 20), 373 | FrameInterval( 25), 374 | FrameInterval( 50), 375 | FrameInterval( 75), 376 | FrameInterval( 100), 377 | FrameInterval( 200), 378 | FrameInterval( 250), 379 | FrameInterval( 500), 380 | FrameInterval( 750), 381 | FrameInterval( 1000), 382 | FrameInterval( 2000), 383 | FrameInterval( 2500), 384 | FrameInterval( 5000), 385 | FrameInterval( 7500), 386 | FrameInterval(10000), 387 | FrameInterval(20000), 388 | FrameInterval(25000), 389 | FrameInterval(50000), 390 | FrameInterval(75000), 391 | ) 392 | 393 | def calculate_notch_interval_f(self, target_interval_x: int) -> FrameInterval: 394 | margin = 1 + self.main.TIMELINE_LABEL_NOTCHES_MARGIN / 100 395 | target_interval_f = self.x_to_f(target_interval_x, FrameInterval) 396 | if target_interval_f >= FrameInterval( 397 | round(int(self.notch_intervals_f[-1]) * margin)): 398 | return self.notch_intervals_f[-1] 399 | for interval in self.notch_intervals_f: 400 | if target_interval_f < FrameInterval( 401 | round(int(interval) * margin)): 402 | return interval 403 | raise RuntimeError 404 | 405 | def generate_label_format(self, notch_interval_t: TimeInterval, end_time: TimeInterval) -> str: 406 | if end_time >= TimeInterval(hours=1): 407 | return '%h:%M:00' 408 | elif notch_interval_t >= TimeInterval(minutes=1): 409 | return '%m:00' 410 | else: 411 | return '%m:%S' 412 | 413 | def set_end_frame(self, end_f: Frame) -> None: 414 | self.end_f = end_f 415 | self.end_t = Time(end_f) 416 | self.full_repaint() 417 | 418 | def set_position(self, pos: Union[Frame, Time, int]) -> None: 419 | if self.rect_f.width() == 0.0: 420 | self.cursor_ftx = pos 421 | 422 | if isinstance(pos, Frame): 423 | self.cursor_x = self.f_to_x(pos) 424 | elif isinstance(pos, Time): 425 | self.cursor_x = self.t_to_x(pos) 426 | elif isinstance(pos, int): 427 | self.cursor_x = pos 428 | else: 429 | raise TypeError 430 | self.update() 431 | 432 | def t_to_x(self, t: TimeType) -> int: 433 | width = self.rect_f.width() 434 | try: 435 | x = round(float(t) / float(self.end_t) * width) 436 | except ZeroDivisionError: 437 | x = 0 438 | return x 439 | 440 | def x_to_t(self, x: int, ty: Type[TimeType]) -> TimeType: 441 | width = self.rect_f.width() 442 | return ty(seconds=(x * float(self.end_t) / width)) 443 | 444 | def f_to_x(self, f: FrameType) -> int: 445 | width = self.rect_f.width() 446 | try: 447 | x = round(int(f) / int(self.end_f) * width) 448 | except ZeroDivisionError: 449 | x = 0 450 | return x 451 | 452 | def x_to_f(self, x: int, ty: Type[FrameType]) -> FrameType: 453 | width = self.rect_f.width() 454 | value = round(x / width * int(self.end_f)) 455 | return ty(value) 456 | --------------------------------------------------------------------------------