├── .cruft.json ├── .github ├── FUNDING.yml ├── PULL_REQUEST_TEMPLATE.md └── workflows │ ├── release.yml │ ├── test.yml │ └── update.yml ├── .gitignore ├── .pre-commit-config.yaml ├── CHANGELOG.md ├── CONTRIBUTORS.md ├── LICENSE ├── README.md ├── poetry.lock ├── poetry.toml ├── pyproject.toml ├── setup.cfg ├── test_proj ├── __init__.py ├── conftest.py ├── manage.py ├── media_library │ ├── __init__.py │ ├── admin.py │ ├── migrations │ │ ├── 0001_initial.py │ │ ├── 0002_auto_20160704_1656.py │ │ └── __init__.py │ ├── models.py │ ├── templates │ │ └── video_form.html │ ├── tests │ │ ├── __init__.py │ │ ├── test_admin.py │ │ ├── test_encoding.py │ │ ├── test_ffmpeg_backend.py │ │ ├── test_fields.py │ │ ├── test_files.py │ │ ├── test_managers.py │ │ └── test_signals.py │ └── views.py ├── requirements.txt ├── settings.py ├── urls.py ├── waterfall.mp4 └── wsgi.py ├── tox.ini └── video_encoding ├── __init__.py ├── admin.py ├── backends ├── __init__.py ├── base.py └── ffmpeg.py ├── config.py ├── exceptions.py ├── fields.py ├── files.py ├── manager.py ├── migrations ├── 0001_initial.py ├── 0002_update_field_definitions.py └── __init__.py ├── models.py ├── signals.py ├── tasks.py └── utils.py /.cruft.json: -------------------------------------------------------------------------------- 1 | { 2 | "template": "https://github.com/escaped/cookiecutter-pypackage.git", 3 | "commit": "201e2aa005db39b8b3ab854658dc5d8da0822e3a", 4 | "context": { 5 | "cookiecutter": { 6 | "author": "Alexander Frenzel", 7 | "author_email": "alex@relatedworks.com", 8 | "github_username": "escaped", 9 | "project_name": "django-video-encoding", 10 | "project_slug": "video_encoding", 11 | "short_description": "django-video-encoding helps to convert your videos into different formats and resolutions.", 12 | "version": "0.4.0", 13 | "line_length": "88", 14 | "uses_django": "y", 15 | "_template": "https://github.com/escaped/cookiecutter-pypackage.git" 16 | } 17 | }, 18 | "directory": null 19 | } -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | --- 2 | github: [escaped] 3 | -------------------------------------------------------------------------------- /.github/PULL_REQUEST_TEMPLATE.md: -------------------------------------------------------------------------------- 1 | ## Description 2 | 3 | Please include a summary of the proposed changes. 4 | 5 | Fixes #(issue) 6 | 7 | ## Checklist 8 | 9 | - [ ] Tests covering the new functionality have been added 10 | - [ ] Code builds clean without any errors or warnings 11 | - [ ] Documentation has been updated 12 | - [ ] Changes have been added to the `CHANGELOG.md` 13 | - [ ] You added yourself to the `CONTRIBUTORS.md` 14 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: Release 2 | 3 | on: 4 | push: 5 | tags: 6 | - '*' 7 | 8 | jobs: 9 | release: 10 | name: Create release 11 | runs-on: ubuntu-latest 12 | steps: 13 | - name: Checkout code 14 | uses: actions/checkout@v2 15 | - name: Get version from tag 16 | id: tag_name 17 | run: | 18 | echo ::set-output name=current_version::${GITHUB_REF#refs/tags/} 19 | shell: bash 20 | - name: Get Changelog Entry 21 | id: changelog_reader 22 | uses: mindsers/changelog-reader-action@v2 23 | with: 24 | version: ${{ steps.tag_name.outputs.current_version }} 25 | path: ./CHANGELOG.md 26 | - name: Create Release 27 | id: create_release 28 | uses: actions/create-release@v1 29 | env: 30 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 31 | with: 32 | tag_name: ${{ steps.changelog_reader.outputs.version }} 33 | release_name: Release ${{ steps.changelog_reader.outputs.version }} 34 | body: ${{ steps.changelog_reader.outputs.changes }} 35 | prerelease: ${{ steps.changelog_reader.outputs.status == 'prereleased' }} 36 | draft: ${{ steps.changelog_reader.outputs.status == 'unreleased' }} 37 | 38 | publish: 39 | needs: [release] 40 | name: Build and publish Python distributions to PyPI 41 | runs-on: ubuntu-latest 42 | steps: 43 | - uses: actions/checkout@master 44 | - name: Set up Python 3.7 45 | uses: actions/setup-python@v1 46 | with: 47 | python-version: 3.7 48 | - name: Install pep517 49 | run: | 50 | python -m pip install pep517 51 | - name: Build a binary wheel and a source tarball 52 | run: | 53 | python -m pep517.build . --source --binary --out-dir dist/ 54 | - name: Publish distribution to PyPI 55 | uses: pypa/gh-action-pypi-publish@master 56 | with: 57 | password: ${{ secrets.pypi_token }} 58 | 59 | -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | name: Test & Lint 2 | 3 | on: 4 | pull_request: 5 | push: 6 | branches: 7 | - master 8 | - main 9 | 10 | jobs: 11 | lint_cruft: 12 | name: Check if automatic project update was successful 13 | runs-on: ubuntu-latest 14 | steps: 15 | - name: Checkout code 16 | uses: actions/checkout@v2 17 | with: 18 | ref: ${{ github.event.pull_request.head.sha }} 19 | - name: Fail if .rej files exist as structure update was not successful 20 | run: test -z "$(find . -iname '*.rej')" 21 | 22 | lint: 23 | name: Lint 24 | needs: [lint_cruft] 25 | runs-on: ubuntu-latest 26 | steps: 27 | - name: Checkout code 28 | uses: actions/checkout@v2 29 | with: 30 | ref: ${{ github.event.pull_request.head.sha }} 31 | - name: Set up Python 32 | uses: actions/setup-python@v2 33 | with: 34 | python-version: 3.8.5 35 | - name: Install dependencies 36 | run: | 37 | python -m pip install --upgrade pip 38 | pip install poetry 39 | poetry install 40 | - name: Lint 41 | run: poetry run pre-commit run -a 42 | 43 | test: 44 | name: Test 45 | needs: [lint_cruft] 46 | runs-on: ${{ matrix.platform }} 47 | strategy: 48 | max-parallel: 4 49 | matrix: 50 | platform: [ubuntu-latest] 51 | python-version: [3.6, 3.7, 3.8, 3.9] 52 | steps: 53 | - uses: actions/checkout@v2 54 | with: 55 | ref: ${{ github.event.pull_request.head.sha }} 56 | - name: Set up Python ${{ matrix.python-version }} 57 | uses: actions/setup-python@v2 58 | with: 59 | python-version: ${{ matrix.python-version }} 60 | - name: Install dependencies 61 | run: | 62 | python -m pip install --upgrade pip 63 | pip install tox tox-gh-actions coveralls 64 | sudo apt-get update 65 | sudo apt-get install -y ffmpeg 66 | - name: Test with tox 67 | run: tox 68 | env: 69 | PLATFORM: ${{ matrix.platform }} 70 | - name: Coveralls 71 | uses: AndreMiras/coveralls-python-action@develop 72 | with: 73 | github-token: ${{ secrets.GITHUB_TOKEN }} 74 | parallel: true 75 | 76 | coveralls_finish: 77 | needs: [test] 78 | runs-on: ubuntu-latest 79 | steps: 80 | - name: Coveralls Finished 81 | uses: AndreMiras/coveralls-python-action@develop 82 | with: 83 | github-token: ${{ secrets.GITHUB_TOKEN }} 84 | parallel-finished: true 85 | 86 | -------------------------------------------------------------------------------- /.github/workflows/update.yml: -------------------------------------------------------------------------------- 1 | name: Update project structure 2 | on: 3 | schedule: 4 | - cron: "0 0 * * *" # at the end of every day 5 | 6 | jobs: 7 | autoUpdateProject: 8 | runs-on: ubuntu-latest 9 | steps: 10 | - uses: actions/checkout@v2 11 | - name: Set up Python 12 | uses: actions/setup-python@v2 13 | with: 14 | python-version: 3.8.5 15 | 16 | - name: Install dependencies 17 | run: pip install cruft poetry jello 18 | 19 | - name: Update project structure 20 | run: | 21 | cruft update -y 22 | 23 | - name: Check if there are changes 24 | id: changes 25 | uses: UnicornGlobal/has-changes-action@v1.0.11 26 | 27 | - name: apply additional changes and fixes 28 | if: steps.changes.outputs.changed == 1 29 | run: | 30 | poetry lock --no-update # add new dependencies 31 | poetry install 32 | poetry run pre-commit run -a || true # we have to fix other issue manually 33 | 34 | - name: Get new template version 35 | if: steps.changes.outputs.changed == 1 36 | # extract new cooiecutter template version 37 | run: | 38 | echo "TEMPLATE_COMMIT=$(cat .cruft.json | jello -r "_['commit'][:8]")" >> $GITHUB_ENV 39 | 40 | # behaviour if PR already exists: https://github.com/marketplace/actions/create-pull-request#action-behaviour 41 | - name: Create Pull Request 42 | if: steps.changes.outputs.changed == 1 43 | uses: peter-evans/create-pull-request@v3 44 | with: 45 | token: ${{ secrets.AUTO_UPDATE_GITHUB_TOKEN }} 46 | commit-message: >- 47 | chore: update project structure to ${{ env.TEMPLATE_COMMIT }} 48 | title: "[Actions] Auto-Sync cookiecutter template" 49 | body: "" 50 | branch: chore/cookiecutter-pypackage 51 | delete-branch: true 52 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | 2 | # Created by https://www.toptal.com/developers/gitignore/api/vim,osx,node,linux,python,windows,visualstudiocode,git 3 | # Edit at https://www.toptal.com/developers/gitignore?templates=vim,osx,node,linux,python,windows,visualstudiocode,git 4 | 5 | ### Git ### 6 | # Created by git for backups. To disable backups in Git: 7 | # $ git config --global mergetool.keepBackup false 8 | *.orig 9 | 10 | # Created by git when using merge tools for conflicts 11 | *.BACKUP.* 12 | *.BASE.* 13 | *.LOCAL.* 14 | *.REMOTE.* 15 | *_BACKUP_*.txt 16 | *_BASE_*.txt 17 | *_LOCAL_*.txt 18 | *_REMOTE_*.txt 19 | 20 | ### Linux ### 21 | *~ 22 | 23 | # temporary files which can be created if a process still has a handle open of a deleted file 24 | .fuse_hidden* 25 | 26 | # KDE directory preferences 27 | .directory 28 | 29 | # Linux trash folder which might appear on any partition or disk 30 | .Trash-* 31 | 32 | # .nfs files are created when an open file is removed but is still being accessed 33 | .nfs* 34 | 35 | ### Node ### 36 | # Logs 37 | logs 38 | *.log 39 | npm-debug.log* 40 | yarn-debug.log* 41 | yarn-error.log* 42 | lerna-debug.log* 43 | 44 | # Diagnostic reports (https://nodejs.org/api/report.html) 45 | report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json 46 | 47 | # Runtime data 48 | pids 49 | *.pid 50 | *.seed 51 | *.pid.lock 52 | 53 | # Directory for instrumented libs generated by jscoverage/JSCover 54 | lib-cov 55 | 56 | # Coverage directory used by tools like istanbul 57 | coverage 58 | *.lcov 59 | 60 | # nyc test coverage 61 | .nyc_output 62 | 63 | # Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) 64 | .grunt 65 | 66 | # Bower dependency directory (https://bower.io/) 67 | bower_components 68 | 69 | # node-waf configuration 70 | .lock-wscript 71 | 72 | # Compiled binary addons (https://nodejs.org/api/addons.html) 73 | build/Release 74 | 75 | # Dependency directories 76 | node_modules/ 77 | jspm_packages/ 78 | 79 | # TypeScript v1 declaration files 80 | typings/ 81 | 82 | # TypeScript cache 83 | *.tsbuildinfo 84 | 85 | # Optional npm cache directory 86 | .npm 87 | 88 | # Optional eslint cache 89 | .eslintcache 90 | 91 | # Microbundle cache 92 | .rpt2_cache/ 93 | .rts2_cache_cjs/ 94 | .rts2_cache_es/ 95 | .rts2_cache_umd/ 96 | 97 | # Optional REPL history 98 | .node_repl_history 99 | 100 | # Output of 'npm pack' 101 | *.tgz 102 | 103 | # Yarn Integrity file 104 | .yarn-integrity 105 | 106 | # dotenv environment variables file 107 | .env 108 | .env.test 109 | .env*.local 110 | 111 | # parcel-bundler cache (https://parceljs.org/) 112 | .cache 113 | .parcel-cache 114 | 115 | # Next.js build output 116 | .next 117 | 118 | # Nuxt.js build / generate output 119 | .nuxt 120 | dist 121 | 122 | # Gatsby files 123 | .cache/ 124 | # Comment in the public line in if your project uses Gatsby and not Next.js 125 | # https://nextjs.org/blog/next-9-1#public-directory-support 126 | # public 127 | 128 | # vuepress build output 129 | .vuepress/dist 130 | 131 | # Serverless directories 132 | .serverless/ 133 | 134 | # FuseBox cache 135 | .fusebox/ 136 | 137 | # DynamoDB Local files 138 | .dynamodb/ 139 | 140 | # TernJS port file 141 | .tern-port 142 | 143 | # Stores VSCode versions used for testing VSCode extensions 144 | .vscode-test 145 | 146 | ### OSX ### 147 | # General 148 | .DS_Store 149 | .AppleDouble 150 | .LSOverride 151 | 152 | # Icon must end with two \r 153 | Icon 154 | 155 | # Thumbnails 156 | ._* 157 | 158 | # Files that might appear in the root of a volume 159 | .DocumentRevisions-V100 160 | .fseventsd 161 | .Spotlight-V100 162 | .TemporaryItems 163 | .Trashes 164 | .VolumeIcon.icns 165 | .com.apple.timemachine.donotpresent 166 | 167 | # Directories potentially created on remote AFP share 168 | .AppleDB 169 | .AppleDesktop 170 | Network Trash Folder 171 | Temporary Items 172 | .apdisk 173 | 174 | ### Python ### 175 | # Byte-compiled / optimized / DLL files 176 | __pycache__/ 177 | *.py[cod] 178 | *$py.class 179 | 180 | # C extensions 181 | *.so 182 | 183 | # Distribution / packaging 184 | .Python 185 | build/ 186 | develop-eggs/ 187 | dist/ 188 | downloads/ 189 | eggs/ 190 | .eggs/ 191 | lib/ 192 | lib64/ 193 | parts/ 194 | sdist/ 195 | var/ 196 | wheels/ 197 | pip-wheel-metadata/ 198 | share/python-wheels/ 199 | *.egg-info/ 200 | .installed.cfg 201 | *.egg 202 | MANIFEST 203 | 204 | # PyInstaller 205 | # Usually these files are written by a python script from a template 206 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 207 | *.manifest 208 | *.spec 209 | 210 | # Installer logs 211 | pip-log.txt 212 | pip-delete-this-directory.txt 213 | 214 | # Unit test / coverage reports 215 | htmlcov/ 216 | .tox/ 217 | .nox/ 218 | .coverage 219 | .coverage.* 220 | nosetests.xml 221 | coverage.xml 222 | *.cover 223 | *.py,cover 224 | .hypothesis/ 225 | .pytest_cache/ 226 | pytestdebug.log 227 | 228 | # Translations 229 | *.mo 230 | *.pot 231 | 232 | # Django stuff: 233 | local_settings.py 234 | db.sqlite3 235 | db.sqlite3-journal 236 | 237 | # Flask stuff: 238 | instance/ 239 | .webassets-cache 240 | 241 | # Scrapy stuff: 242 | .scrapy 243 | 244 | # Sphinx documentation 245 | docs/_build/ 246 | doc/_build/ 247 | 248 | # PyBuilder 249 | target/ 250 | 251 | # Jupyter Notebook 252 | .ipynb_checkpoints 253 | 254 | # IPython 255 | profile_default/ 256 | ipython_config.py 257 | 258 | # pyenv 259 | .python-version 260 | 261 | # pipenv 262 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 263 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 264 | # having no cross-platform support, pipenv may install dependencies that don't work, or not 265 | # install all needed dependencies. 266 | #Pipfile.lock 267 | 268 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow 269 | __pypackages__/ 270 | 271 | # Celery stuff 272 | celerybeat-schedule 273 | celerybeat.pid 274 | 275 | # SageMath parsed files 276 | *.sage.py 277 | 278 | # Environments 279 | .venv 280 | env/ 281 | venv/ 282 | ENV/ 283 | env.bak/ 284 | venv.bak/ 285 | pythonenv* 286 | 287 | # Spyder project settings 288 | .spyderproject 289 | .spyproject 290 | 291 | # Rope project settings 292 | .ropeproject 293 | 294 | # mkdocs documentation 295 | /site 296 | 297 | # mypy 298 | .mypy_cache/ 299 | .dmypy.json 300 | dmypy.json 301 | 302 | # Pyre type checker 303 | .pyre/ 304 | 305 | # pytype static type analyzer 306 | .pytype/ 307 | 308 | # profiling data 309 | .prof 310 | 311 | ### Vim ### 312 | # Swap 313 | [._]*.s[a-v][a-z] 314 | !*.svg # comment out if you don't need vector files 315 | [._]*.sw[a-p] 316 | [._]s[a-rt-v][a-z] 317 | [._]ss[a-gi-z] 318 | [._]sw[a-p] 319 | 320 | # Session 321 | Session.vim 322 | Sessionx.vim 323 | 324 | # Temporary 325 | .netrwhist 326 | # Auto-generated tag files 327 | tags 328 | # Persistent undo 329 | [._]*.un~ 330 | 331 | ### VisualStudioCode ### 332 | .vscode/* 333 | !.vscode/settings.json 334 | !.vscode/tasks.json 335 | !.vscode/launch.json 336 | !.vscode/extensions.json 337 | *.code-workspace 338 | 339 | ### VisualStudioCode Patch ### 340 | # Ignore all local history of files 341 | .history 342 | .ionide 343 | 344 | ### Windows ### 345 | # Windows thumbnail cache files 346 | Thumbs.db 347 | Thumbs.db:encryptable 348 | ehthumbs.db 349 | ehthumbs_vista.db 350 | 351 | # Dump file 352 | *.stackdump 353 | 354 | # Folder config file 355 | [Dd]esktop.ini 356 | 357 | # Recycle Bin used on file shares 358 | $RECYCLE.BIN/ 359 | 360 | # Windows Installer files 361 | *.cab 362 | *.msi 363 | *.msix 364 | *.msm 365 | *.msp 366 | 367 | # Windows shortcuts 368 | *.lnk 369 | 370 | # End of https://www.toptal.com/developers/gitignore/api/vim,osx,node,linux,python,windows,visualstudiocode,git 371 | 372 | media 373 | -------------------------------------------------------------------------------- /.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | --- 2 | repos: 3 | - repo: local 4 | hooks: 5 | - id: black 6 | name: black 7 | language: system 8 | entry: poetry run black 9 | types: [python] 10 | 11 | - repo: local 12 | hooks: 13 | - id: autoflake 14 | name: autoflake 15 | language: system 16 | entry: poetry run autoflake --expand-star-imports --remove-all-unused-imports --remove-unused-variables --in-place --recursive test_proj/ video_encoding/ 17 | types: [python] 18 | 19 | - repo: local 20 | hooks: 21 | - id: isort 22 | name: isort 23 | language: system 24 | entry: poetry run isort 25 | types: [python] 26 | 27 | - repo: local 28 | hooks: 29 | - id: mypy 30 | name: mypy 31 | language: system 32 | entry: poetry run mypy 33 | types: [python] 34 | 35 | - repo: local 36 | hooks: 37 | - id: flake8 38 | name: flake8 39 | language: system 40 | entry: poetry run flake8 41 | types: [python] 42 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | All notable changes to this project will be documented in this file. 4 | 5 | The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), 6 | and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). 7 | 8 | ## [Unreleased] 9 | 10 | ## [1.0.0] - 2021-01-03 11 | 12 | ### Added 13 | 14 | * support for django-storages, @lifenautjoe & @bashu 15 | * add signals for encoding 16 | 17 | ### Changed 18 | 19 | * remove deprecation warnings 20 | 21 | ### Removed 22 | 23 | * dropped support for python `2.7` and `3.5` 24 | * dropped support for Django `<2.2` 25 | 26 | ## [0.4.0] - 2018-12-04 27 | 28 | ### Changed 29 | 30 | * An `InvalidTimeError` is raise, when a thumbnail could not be generated 31 | * This can happens if the chosen time is too close to the end of the video or if the video is shorter. 32 | 33 | ## [0.3.1] - 2018-11-16 34 | 35 | ### Fixed 36 | 37 | * add missing migration 38 | 39 | ## [0.3.0] 2018-11-16 40 | 41 | ### Added 42 | 43 | * Example for Form usage 44 | 45 | ### Changed 46 | 47 | * Switched to poetry for dependency management and packaging 48 | * Support for Python 3.7 49 | * Support for Django 2.1 50 | * Dropped Support for Django <1.11 51 | 52 | ## [0.2.0] - 2018-01-21 53 | 54 | ### Added 55 | 56 | * Support for django 1.11 and 2.0 (Thanks @goranpavlovic) 57 | 58 | ## [0.1.0] -2017-04-24 59 | 60 | * Initial release 61 | 62 | [Unreleased]: https://github.com/escaped/django-video-encoding/compare/0.4.0...HEAD 63 | [1.0.0]: https://github.com/escaped/django-video-encoding/compare/0.4.0...1.0.0 64 | [0.4.0]: https://github.com/escaped/django-video-encoding/compare/0.3.1...0.4.0 65 | [0.3.1]: https://github.com/escaped/django-video-encoding/compare/0.3.0...0.3.1 66 | [0.3.0]: https://github.com/escaped/django-video-encoding/compare/0.3.0...0.2.0 67 | [0.2.0]: https://github.com/escaped/django-video-encoding/compare/0.10...0.2.0 68 | [0.1.0]: https://github.com/escaped/django-video-encoding/tree/0.1.0 69 | -------------------------------------------------------------------------------- /CONTRIBUTORS.md: -------------------------------------------------------------------------------- 1 | # Contributors 2 | 3 | * [@bashu](https://github.com/bashu) 4 | * [@escaped](https://github.com/escaped/) 5 | * [@goranpavlovic](https://github.com/goranpavlovic) 6 | * [@lifenautjoe](https://github.com/lifenautjoe) 7 | * [@mabuelhagag](https://github.com/mabuelhagag) 8 | 9 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) Alexander Frenzel . 2 | All rights reserved. 3 | 4 | Redistribution and use in source and binary forms, with or without modification, 5 | are permitted provided that the following conditions are met: 6 | 7 | 1. Redistributions of source code must retain the above copyright notice, 8 | this list of conditions and the following disclaimer. 9 | 10 | 2. Redistributions in binary form must reproduce the above copyright 11 | notice, this list of conditions and the following disclaimer in the 12 | documentation and/or other materials provided with the distribution. 13 | 14 | 3. Neither the name of Alexander Frenzel nor the names of its 15 | contributors may be used to endorse or promote products derived from 16 | this software without specific prior written permission. 17 | 18 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND 19 | ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED 20 | WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE 21 | DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR 22 | ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES 23 | (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; 24 | LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON 25 | ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT 26 | (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS 27 | SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 28 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # django-video-encoding 2 | 3 | ![PyPI](https://img.shields.io/pypi/v/django-video-encoding?style=flat-square) 4 | ![GitHub Workflow Status (master)](https://img.shields.io/github/workflow/status/escaped/django-video-encoding/Test%20&%20Lint/master?style=flat-square) 5 | ![Coveralls github branch](https://img.shields.io/coveralls/github/escaped/django-video-encoding/master?style=flat-square) 6 | ![PyPI - Python Version](https://img.shields.io/pypi/pyversions/django-video-encoding?style=flat-square) 7 | ![PyPI - License](https://img.shields.io/pypi/l/django-video-encoding?style=flat-square) 8 | 9 | django-video-encoding helps to convert your videos into different formats and resolutions. 10 | 11 | ## Requirements 12 | 13 | * Python 3.6.1 or newer 14 | * ffmpeg and ffprobe 15 | 16 | ## Installation 17 | 18 | 1. Install django-video-encoding 19 | 20 | ```sh 21 | pip install django-video-encoding 22 | ``` 23 | 24 | 2. Add `video_encoding` to your `INSTALLED_APPS`. 25 | 26 | ## Integration 27 | 28 | Add a `VideoField` and a `GenericRelation(Format)` to your model. 29 | You can optionally store the `width`, `height` and `duration` of the video 30 | by supplying the corresponding field names to the `VideoField`. 31 | 32 | ```python 33 | from django.contrib.contenttypes.fields import GenericRelation 34 | from django.db import models 35 | from video_encoding.fields import VideoField 36 | from video_encoding.models import Format 37 | 38 | 39 | class Video(models.Model): 40 | width = models.PositiveIntegerField(editable=False, null=True) 41 | height = models.PositiveIntegerField(editable=False, null=True) 42 | duration = models.FloatField(editable=False, null=True) 43 | 44 | file = VideoField(width_field='width', height_field='height', 45 | duration_field='duration') 46 | 47 | format_set = GenericRelation(Format) 48 | ``` 49 | 50 | To show all converted videos in the admin, you should add the `FormatInline` 51 | to your `ModelAdmin` 52 | 53 | ```python 54 | from django.contrib import admin 55 | from video_encoding.admin import FormatInline 56 | 57 | from .models import Video 58 | 59 | 60 | @admin.register(Video) 61 | class VideoAdmin(admin.ModelAdmin): 62 | inlines = (FormatInline,) 63 | 64 | list_dispaly = ('get_filename', 'width', 'height', 'duration') 65 | fields = ('file', 'width', 'height', 'duration') 66 | readonly_fields = fields 67 | ``` 68 | 69 | 70 | The conversion of the video should be done in a separate process. Typical 71 | options are [django-rq] or [celery]. We will use `django-rq` in the 72 | following example. The configuration for `celery` is similar. 73 | `django-video-encoding` already provides a task (`convert_all_videos`) 74 | for converting all videos on a model. 75 | This task should be triggered when a video was uploaded. Hence we listen to 76 | the `post-save` signal and enqueue the saved instance for processing. 77 | 78 | ```python 79 | # signals.py 80 | from django.db.models.signals import post_save 81 | from django.dispatch import receiver 82 | from django_rq import enqueue 83 | 84 | from video_encoding import tasks 85 | 86 | from .models import Video 87 | 88 | 89 | @receiver(post_save, sender=Video) 90 | def convert_video(sender, instance, **kwargs): 91 | enqueue(tasks.convert_all_videos, 92 | instance._meta.app_label, 93 | instance._meta.model_name, 94 | instance.pk) 95 | ``` 96 | 97 | After a while You can access the converted videos using 98 | 99 | ```python 100 | video = Video.objects.get(...) 101 | for format in video.format_set.complete().all(): 102 | # do something 103 | ``` 104 | 105 | [django-rq]: https://github.com/ui/django-rq 106 | [celery]: http://www.celeryproject.org/ 107 | 108 | ### Generate a video thumbnail 109 | 110 | The backend provides a `get_thumbnail()` method to extract a thumbnail from a video. 111 | Here is a basic example on how to generate the thumbnail and store it in the model. 112 | 113 | ```python 114 | # models.py 115 | from django.db import models 116 | 117 | class Video(models.Model): 118 | width = models.PositiveIntegerField(editable=False, null=True) 119 | height = models.PositiveIntegerField(editable=False, null=True) 120 | duration = models.FloatField(editable=False, null=True) 121 | 122 | thumbnail = ImageField(blank=True) 123 | file = VideoField(width_field='width', height_field='height', 124 | duration_field='duration') 125 | 126 | format_set = GenericRelation(Format) 127 | 128 | 129 | # tasks.py 130 | from django.core.files import File 131 | from video_encoding.backends import get_backend 132 | 133 | from .models import Video 134 | 135 | 136 | def create_thumbnail(video_pk): 137 | video = Video.objects.get(pk=video_pk) 138 | if not video.file: 139 | # no video file attached 140 | return 141 | 142 | if video.thumbnail: 143 | # thumbnail has already been generated 144 | return 145 | 146 | encoding_backend = get_backend() 147 | thumbnail_path = encoding_backend.get_thumbnail(video.file.path) 148 | filename = os.path.basename(self.url), 149 | 150 | try: 151 | with open(thumbnail_path, 'rb') as file_handler: 152 | django_file = File(file_handler) 153 | video.thumbnail.save(filename, django_file) 154 | video.save() 155 | finally: 156 | os.unlink(thumbnail_path) 157 | ``` 158 | 159 | You should run this method in a separate process by using `django-rq`, `celery` 160 | or similar) and enqueue execution from within a `post_save` signal. 161 | 162 | ```python 163 | # signals.py 164 | from django.db.models.signals import post_save 165 | from django.dispatch import receiver 166 | from django_rq import enqueue 167 | 168 | from . import tasks 169 | from .models import Video 170 | 171 | 172 | @receiver(post_save, sender=Video) 173 | def create_thumbnail(sender, instance, **kwargs): 174 | enqueue(tasks.create_thumbnail, instance.pk) 175 | ``` 176 | 177 | ### Signals 178 | 179 | During the encoding multiple signals are emitted to report the progress. 180 | You can register to the signals as described in the [Django documentation](https://docs.djangoproject.com/en/3.1/topics/signals/#connecting-to-signals-sent-by-specific-senders). 181 | 182 | This simple example demonstrates, on how to update the "video model" once the convertion is finished. 183 | 184 | ```python 185 | # apps.py 186 | from django.apps import AppConfig 187 | 188 | 189 | class MyAppConfig(AppConfig): 190 | # ... 191 | 192 | def ready(self) -> None: 193 | from . import signals # register signals 194 | 195 | 196 | # signals.py 197 | from typing import Type 198 | 199 | from django.dispatch import receiver 200 | from video_encoding import signals 201 | 202 | from myapp.models import Video 203 | 204 | 205 | @receiver(signals.encoding_finished, sender=Video) 206 | def mark_as_finished(sender: Type[Video], instance: Video) -> None: 207 | """ 208 | Mark video as "convertion has been finished". 209 | """ 210 | video.processed = True 211 | video.save(update_fields=['processed']) 212 | ``` 213 | 214 | #### `signals.encoding_started` 215 | 216 | This is sent before the encoding starts. 217 | 218 | _Arguments_ 219 | `sender: Type[models.Model]`: Model which contains the `VideoField`. 220 | `instance: models.Model)`: Instance of the model containing the `VideoField`. 221 | 222 | #### `signals.encoding_finished` 223 | 224 | Like `encoding_started()`, but sent after the file had been converted into all formats. 225 | 226 | _Arguments_ 227 | `sender: Type[models.Model]`: Model which contains the `VideoField`. 228 | `instance: models.Model)`: Instance of the model containing the `VideoField`. 229 | 230 | #### `signals.format_started` 231 | 232 | This is sent before the video is converted to one of the configured formats. 233 | 234 | _Arguments_ 235 | `sender: Type[models.Model]`: Model which contains the `VideoField`. 236 | `instance: models.Model)`: Instance of the model containing the `VideoField`. 237 | `format: Format`: The format instance, which will reference the encoded video file. 238 | 239 | #### `signals.format_finished` 240 | 241 | Like `format_finished`, but sent after the video encoding process and includes whether the encoding was succesful or not. 242 | 243 | _Arguments_ 244 | `sender: Type[models.Model]`: Model which contains the `VideoField`. 245 | `instance: models.Model)`: Instance of the model containing the `VideoField`. 246 | `format: Format`: The format instance, which will reference the encoded video file. 247 | `result: ConversionResult`: Instance of `video_encoding.signals.ConversionResult` and indicates whether the convertion `FAILED`, `SUCCEEDED` or was `SKIPPED`. 248 | 249 | 250 | ## Configuration 251 | 252 | **VIDEO_ENCODING_THREADS** (default: `1`) 253 | Defines how many threads should be used for encoding. This may not be supported 254 | by every backend. 255 | 256 | **VIDEO_ENCODING_BACKEND** (default: `'video_encoding.backends.ffmpeg.FFmpegBackend'`) 257 | Choose the backend for encoding. `django-video-encoding` only supports `ffmpeg`, 258 | but you can implement your own backend. Feel free to pulish your plugin and 259 | submit a pull request. 260 | 261 | **VIDEO_ENCODING_BACKEND_PARAMS** (default: `{}`) 262 | If your backend requires some special configuration, you can specify them here 263 | as `dict`. 264 | 265 | **VIDEO_ENCODING_FORMATS** (for defaults see `video_encoding/config.py`) 266 | This dictionary defines all required encodings and has some resonable defaults. 267 | If you want to customize the formats, you have to specify `name`, 268 | `extension` and `params` for each format. For example 269 | 270 | ```python 271 | VIDEO_ENCODING_FORMATS = { 272 | 'FFmpeg': [ 273 | { 274 | 'name': 'webm_sd', 275 | 'extension': 'webm', 276 | 'params': [ 277 | '-b:v', '1000k', '-maxrate', '1000k', '-bufsize', '2000k', 278 | '-codec:v', 'libvpx', '-r', '30', 279 | '-vf', 'scale=-1:480', '-qmin', '10', '-qmax', '42', 280 | '-codec:a', 'libvorbis', '-b:a', '128k', '-f', 'webm', 281 | ], 282 | }, 283 | ] 284 | ``` 285 | 286 | ## Encoding Backends 287 | 288 | ### video_encoding.backends.ffmpeg.FFmpegBackend (default) 289 | 290 | Backend for using `ffmpeg` and `ffprobe` to convert your videos. 291 | 292 | #### Options 293 | 294 | **VIDEO_ENCODING_FFMPEG_PATH** 295 | Path to `ffmpeg`. If no path is provided, the backend uses `which` to 296 | locate it. 297 | **VIDEO_ENCODING_FFPROBE_PATH** 298 | Path to `ffprobe`. If no path is provided, the backend uses `which` to 299 | locate it. 300 | 301 | ### Custom Backend 302 | 303 | You can implement a custom encoding backend. Create a new class which inherits from 304 | [`video_encoding.backends.base.BaseEncodingBackend`](video_encoding/backends/base.py). 305 | You must set the property `name` and implement the methods `encode`, `get_media_info` 306 | and `get_thumbnail`. For further details see the reference implementation: 307 | [`video_encoding.backends.ffmpeg.FFmpegBackend`](video_encoding/backends/ffmpeg.py). 308 | 309 | If you want to open source your backend, follow these steps. 310 | 311 | 1. create a packages named django-video-encoding-BACKENDNAME 312 | 2. publish your package to [pypi] 313 | 3. Submit a pull requests with the following changes: 314 | 315 | * add the package to `extra_requires` 316 | * provide reasonable defaults for `VIDEO_ENCODING_FORMATS` 317 | 318 | [pypi]: https://pypi.python.org/pypi 319 | 320 | ## Development 321 | 322 | This project uses [poetry](https://poetry.eustace.io/) for packaging and 323 | managing all dependencies and [pre-commit](https://pre-commit.com/) to run 324 | [flake8](http://flake8.pycqa.org/), [isort](https://pycqa.github.io/isort/), 325 | [mypy](http://mypy-lang.org/) and [black](https://github.com/python/black). 326 | 327 | Additionally, [pdbpp](https://github.com/pdbpp/pdbpp) and [better-exceptions](https://github.com/qix-/better-exceptions) are installed to provide a better debugging experience. 328 | To enable `better-exceptions` you have to run `export BETTER_EXCEPTIONS=1` in your current session/terminal. 329 | 330 | Clone this repository and run 331 | 332 | ```bash 333 | poetry install 334 | poetry run pre-commit install 335 | ``` 336 | 337 | to create a virtual enviroment containing all dependencies. 338 | Afterwards, You can run the test suite using 339 | 340 | ```bash 341 | poetry run pytest 342 | ``` 343 | 344 | This repository follows the [Conventional Commits](https://www.conventionalcommits.org/) 345 | style. 346 | 347 | ### Cookiecutter template 348 | 349 | This project was created using [cruft](https://github.com/cruft/cruft) and the 350 | [cookiecutter-pyproject](https://github.com/escaped/cookiecutter-pypackage) template. 351 | In order to update this repository to the latest template version run 352 | 353 | ```sh 354 | cruft update 355 | ``` 356 | 357 | in the root of this repository. 358 | -------------------------------------------------------------------------------- /poetry.lock: -------------------------------------------------------------------------------- 1 | [[package]] 2 | name = "appdirs" 3 | version = "1.4.4" 4 | description = "A small Python module for determining appropriate platform-specific dirs, e.g. a \"user data dir\"." 5 | category = "dev" 6 | optional = false 7 | python-versions = "*" 8 | 9 | [[package]] 10 | name = "asgiref" 11 | version = "3.2.10" 12 | description = "ASGI specs, helper code, and adapters" 13 | category = "main" 14 | optional = false 15 | python-versions = ">=3.5" 16 | 17 | [package.extras] 18 | tests = ["pytest", "pytest-asyncio"] 19 | 20 | [[package]] 21 | name = "atomicwrites" 22 | version = "1.4.0" 23 | description = "Atomic file writes." 24 | category = "dev" 25 | optional = false 26 | python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" 27 | 28 | [[package]] 29 | name = "attrs" 30 | version = "20.2.0" 31 | description = "Classes Without Boilerplate" 32 | category = "dev" 33 | optional = false 34 | python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" 35 | 36 | [package.extras] 37 | dev = ["coverage[toml] (>=5.0.2)", "hypothesis", "pympler", "pytest (>=4.3.0)", "six", "zope.interface", "sphinx", "sphinx-rtd-theme", "pre-commit"] 38 | docs = ["sphinx", "sphinx-rtd-theme", "zope.interface"] 39 | tests = ["coverage[toml] (>=5.0.2)", "hypothesis", "pympler", "pytest (>=4.3.0)", "six", "zope.interface"] 40 | tests_no_zope = ["coverage[toml] (>=5.0.2)", "hypothesis", "pympler", "pytest (>=4.3.0)", "six"] 41 | 42 | [[package]] 43 | name = "autoflake" 44 | version = "1.4" 45 | description = "Removes unused imports and unused variables" 46 | category = "dev" 47 | optional = false 48 | python-versions = "*" 49 | 50 | [package.dependencies] 51 | pyflakes = ">=1.1.0" 52 | 53 | [[package]] 54 | name = "better-exceptions" 55 | version = "0.3.3" 56 | description = "Pretty and helpful exceptions, automatically" 57 | category = "dev" 58 | optional = false 59 | python-versions = "*" 60 | 61 | [package.dependencies] 62 | colorama = {version = "*", markers = "sys_platform == \"win32\""} 63 | 64 | [[package]] 65 | name = "black" 66 | version = "20.8b1" 67 | description = "The uncompromising code formatter." 68 | category = "dev" 69 | optional = false 70 | python-versions = ">=3.6" 71 | 72 | [package.dependencies] 73 | appdirs = "*" 74 | click = ">=7.1.2" 75 | dataclasses = {version = ">=0.6", markers = "python_version < \"3.7\""} 76 | mypy-extensions = ">=0.4.3" 77 | pathspec = ">=0.6,<1" 78 | regex = ">=2020.1.8" 79 | toml = ">=0.10.1" 80 | typed-ast = ">=1.4.0" 81 | typing-extensions = ">=3.7.4" 82 | 83 | [package.extras] 84 | colorama = ["colorama (>=0.4.3)"] 85 | d = ["aiohttp (>=3.3.2)", "aiohttp-cors"] 86 | 87 | [[package]] 88 | name = "cfgv" 89 | version = "3.2.0" 90 | description = "Validate configuration and produce human readable error messages." 91 | category = "dev" 92 | optional = false 93 | python-versions = ">=3.6.1" 94 | 95 | [[package]] 96 | name = "click" 97 | version = "7.1.2" 98 | description = "Composable command line interface toolkit" 99 | category = "dev" 100 | optional = false 101 | python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*" 102 | 103 | [[package]] 104 | name = "colorama" 105 | version = "0.4.4" 106 | description = "Cross-platform colored terminal text." 107 | category = "dev" 108 | optional = false 109 | python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*" 110 | 111 | [[package]] 112 | name = "coverage" 113 | version = "5.3" 114 | description = "Code coverage measurement for Python" 115 | category = "dev" 116 | optional = false 117 | python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*, <4" 118 | 119 | [package.extras] 120 | toml = ["toml"] 121 | 122 | [[package]] 123 | name = "dataclasses" 124 | version = "0.7" 125 | description = "A backport of the dataclasses module for Python 3.6" 126 | category = "dev" 127 | optional = false 128 | python-versions = ">=3.6, <3.7" 129 | 130 | [[package]] 131 | name = "distlib" 132 | version = "0.3.1" 133 | description = "Distribution utilities" 134 | category = "dev" 135 | optional = false 136 | python-versions = "*" 137 | 138 | [[package]] 139 | name = "django" 140 | version = "3.1.2" 141 | description = "A high-level Python Web framework that encourages rapid development and clean, pragmatic design." 142 | category = "main" 143 | optional = false 144 | python-versions = ">=3.6" 145 | 146 | [package.dependencies] 147 | asgiref = ">=3.2.10,<3.3.0" 148 | pytz = "*" 149 | sqlparse = ">=0.2.2" 150 | 151 | [package.extras] 152 | argon2 = ["argon2-cffi (>=16.1.0)"] 153 | bcrypt = ["bcrypt"] 154 | 155 | [[package]] 156 | name = "django-appconf" 157 | version = "1.0.4" 158 | description = "A helper class for handling configuration defaults of packaged apps gracefully." 159 | category = "main" 160 | optional = false 161 | python-versions = "*" 162 | 163 | [package.dependencies] 164 | django = "*" 165 | 166 | [[package]] 167 | name = "fancycompleter" 168 | version = "0.9.1" 169 | description = "colorful TAB completion for Python prompt" 170 | category = "dev" 171 | optional = false 172 | python-versions = "*" 173 | 174 | [package.dependencies] 175 | pyreadline = {version = "*", markers = "platform_system == \"Windows\""} 176 | pyrepl = ">=0.8.2" 177 | 178 | [[package]] 179 | name = "filelock" 180 | version = "3.0.12" 181 | description = "A platform independent file lock." 182 | category = "dev" 183 | optional = false 184 | python-versions = "*" 185 | 186 | [[package]] 187 | name = "flake8" 188 | version = "3.8.4" 189 | description = "the modular source code checker: pep8 pyflakes and co" 190 | category = "dev" 191 | optional = false 192 | python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,>=2.7" 193 | 194 | [package.dependencies] 195 | importlib-metadata = {version = "*", markers = "python_version < \"3.8\""} 196 | mccabe = ">=0.6.0,<0.7.0" 197 | pycodestyle = ">=2.6.0a1,<2.7.0" 198 | pyflakes = ">=2.2.0,<2.3.0" 199 | 200 | [[package]] 201 | name = "flake8-bugbear" 202 | version = "20.11.1" 203 | description = "A plugin for flake8 finding likely bugs and design problems in your program. Contains warnings that don't belong in pyflakes and pycodestyle." 204 | category = "dev" 205 | optional = false 206 | python-versions = ">=3.6" 207 | 208 | [package.dependencies] 209 | attrs = ">=19.2.0" 210 | flake8 = ">=3.0.0" 211 | 212 | [package.extras] 213 | dev = ["coverage", "black", "hypothesis", "hypothesmith"] 214 | 215 | [[package]] 216 | name = "flake8-builtins" 217 | version = "1.5.3" 218 | description = "Check for python builtins being used as variables or parameters." 219 | category = "dev" 220 | optional = false 221 | python-versions = "*" 222 | 223 | [package.dependencies] 224 | flake8 = "*" 225 | 226 | [package.extras] 227 | test = ["coverage", "coveralls", "mock", "pytest", "pytest-cov"] 228 | 229 | [[package]] 230 | name = "flake8-comprehensions" 231 | version = "3.3.1" 232 | description = "A flake8 plugin to help you write better list/set/dict comprehensions." 233 | category = "dev" 234 | optional = false 235 | python-versions = ">=3.6" 236 | 237 | [package.dependencies] 238 | flake8 = ">=3.0,<3.2.0 || >3.2.0,<4" 239 | importlib-metadata = {version = "*", markers = "python_version < \"3.8\""} 240 | 241 | [[package]] 242 | name = "flake8-debugger" 243 | version = "4.0.0" 244 | description = "ipdb/pdb statement checker plugin for flake8" 245 | category = "dev" 246 | optional = false 247 | python-versions = ">=3.6" 248 | 249 | [package.dependencies] 250 | flake8 = ">=3.0" 251 | pycodestyle = "*" 252 | six = "*" 253 | 254 | [[package]] 255 | name = "flake8-polyfill" 256 | version = "1.0.2" 257 | description = "Polyfill package for Flake8 plugins" 258 | category = "dev" 259 | optional = false 260 | python-versions = "*" 261 | 262 | [package.dependencies] 263 | flake8 = "*" 264 | 265 | [[package]] 266 | name = "identify" 267 | version = "1.5.6" 268 | description = "File identification library for Python" 269 | category = "dev" 270 | optional = false 271 | python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,>=2.7" 272 | 273 | [package.extras] 274 | license = ["editdistance"] 275 | 276 | [[package]] 277 | name = "importlib-metadata" 278 | version = "2.0.0" 279 | description = "Read metadata from Python packages" 280 | category = "dev" 281 | optional = false 282 | python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,>=2.7" 283 | 284 | [package.dependencies] 285 | zipp = ">=0.5" 286 | 287 | [package.extras] 288 | docs = ["sphinx", "rst.linker"] 289 | testing = ["packaging", "pep517", "importlib-resources (>=1.3)"] 290 | 291 | [[package]] 292 | name = "importlib-resources" 293 | version = "3.0.0" 294 | description = "Read resources from Python packages" 295 | category = "dev" 296 | optional = false 297 | python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,>=2.7" 298 | 299 | [package.dependencies] 300 | zipp = {version = ">=0.4", markers = "python_version < \"3.8\""} 301 | 302 | [package.extras] 303 | docs = ["sphinx", "rst.linker", "jaraco.packaging"] 304 | 305 | [[package]] 306 | name = "iniconfig" 307 | version = "1.1.1" 308 | description = "iniconfig: brain-dead simple config-ini parsing" 309 | category = "dev" 310 | optional = false 311 | python-versions = "*" 312 | 313 | [[package]] 314 | name = "isort" 315 | version = "5.6.4" 316 | description = "A Python utility / library to sort Python imports." 317 | category = "dev" 318 | optional = false 319 | python-versions = ">=3.6,<4.0" 320 | 321 | [package.extras] 322 | pipfile_deprecated_finder = ["pipreqs", "requirementslib"] 323 | requirements_deprecated_finder = ["pipreqs", "pip-api"] 324 | colors = ["colorama (>=0.4.3,<0.5.0)"] 325 | 326 | [[package]] 327 | name = "matchlib" 328 | version = "0.2.1" 329 | description = "A tool for partial comparison of (nested) data structures" 330 | category = "dev" 331 | optional = false 332 | python-versions = "*" 333 | 334 | [[package]] 335 | name = "mccabe" 336 | version = "0.6.1" 337 | description = "McCabe checker, plugin for flake8" 338 | category = "dev" 339 | optional = false 340 | python-versions = "*" 341 | 342 | [[package]] 343 | name = "mypy" 344 | version = "0.800" 345 | description = "Optional static typing for Python" 346 | category = "dev" 347 | optional = false 348 | python-versions = ">=3.5" 349 | 350 | [package.dependencies] 351 | mypy-extensions = ">=0.4.3,<0.5.0" 352 | typed-ast = ">=1.4.0,<1.5.0" 353 | typing-extensions = ">=3.7.4" 354 | 355 | [package.extras] 356 | dmypy = ["psutil (>=4.0)"] 357 | 358 | [[package]] 359 | name = "mypy-extensions" 360 | version = "0.4.3" 361 | description = "Experimental type system extensions for programs checked with the mypy typechecker." 362 | category = "dev" 363 | optional = false 364 | python-versions = "*" 365 | 366 | [[package]] 367 | name = "nodeenv" 368 | version = "1.5.0" 369 | description = "Node.js virtual environment builder" 370 | category = "dev" 371 | optional = false 372 | python-versions = "*" 373 | 374 | [[package]] 375 | name = "packaging" 376 | version = "20.4" 377 | description = "Core utilities for Python packages" 378 | category = "dev" 379 | optional = false 380 | python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" 381 | 382 | [package.dependencies] 383 | pyparsing = ">=2.0.2" 384 | six = "*" 385 | 386 | [[package]] 387 | name = "pathspec" 388 | version = "0.8.0" 389 | description = "Utility library for gitignore style pattern matching of file paths." 390 | category = "dev" 391 | optional = false 392 | python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*" 393 | 394 | [[package]] 395 | name = "pdbpp" 396 | version = "0.10.2" 397 | description = "pdb++, a drop-in replacement for pdb" 398 | category = "dev" 399 | optional = false 400 | python-versions = "*" 401 | 402 | [package.dependencies] 403 | fancycompleter = ">=0.8" 404 | pygments = "*" 405 | wmctrl = "*" 406 | 407 | [package.extras] 408 | funcsigs = ["funcsigs"] 409 | testing = ["funcsigs", "pytest"] 410 | 411 | [[package]] 412 | name = "pep8-naming" 413 | version = "0.11.1" 414 | description = "Check PEP-8 naming conventions, plugin for flake8" 415 | category = "dev" 416 | optional = false 417 | python-versions = "*" 418 | 419 | [package.dependencies] 420 | flake8-polyfill = ">=1.0.2,<2" 421 | 422 | [[package]] 423 | name = "pillow" 424 | version = "8.0.0" 425 | description = "Python Imaging Library (Fork)" 426 | category = "main" 427 | optional = false 428 | python-versions = ">=3.6" 429 | 430 | [[package]] 431 | name = "pluggy" 432 | version = "0.13.1" 433 | description = "plugin and hook calling mechanisms for python" 434 | category = "dev" 435 | optional = false 436 | python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" 437 | 438 | [package.dependencies] 439 | importlib-metadata = {version = ">=0.12", markers = "python_version < \"3.8\""} 440 | 441 | [package.extras] 442 | dev = ["pre-commit", "tox"] 443 | 444 | [[package]] 445 | name = "pre-commit" 446 | version = "2.7.1" 447 | description = "A framework for managing and maintaining multi-language pre-commit hooks." 448 | category = "dev" 449 | optional = false 450 | python-versions = ">=3.6.1" 451 | 452 | [package.dependencies] 453 | cfgv = ">=2.0.0" 454 | identify = ">=1.0.0" 455 | importlib-metadata = {version = "*", markers = "python_version < \"3.8\""} 456 | importlib-resources = {version = "*", markers = "python_version < \"3.7\""} 457 | nodeenv = ">=0.11.1" 458 | pyyaml = ">=5.1" 459 | toml = "*" 460 | virtualenv = ">=20.0.8" 461 | 462 | [[package]] 463 | name = "py" 464 | version = "1.9.0" 465 | description = "library with cross-python path, ini-parsing, io, code, log facilities" 466 | category = "dev" 467 | optional = false 468 | python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" 469 | 470 | [[package]] 471 | name = "pycodestyle" 472 | version = "2.6.0" 473 | description = "Python style guide checker" 474 | category = "dev" 475 | optional = false 476 | python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" 477 | 478 | [[package]] 479 | name = "pyflakes" 480 | version = "2.2.0" 481 | description = "passive checker of Python programs" 482 | category = "dev" 483 | optional = false 484 | python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" 485 | 486 | [[package]] 487 | name = "pygments" 488 | version = "2.8.0" 489 | description = "Pygments is a syntax highlighting package written in Python." 490 | category = "dev" 491 | optional = false 492 | python-versions = ">=3.5" 493 | 494 | [[package]] 495 | name = "pyparsing" 496 | version = "2.4.7" 497 | description = "Python parsing module" 498 | category = "dev" 499 | optional = false 500 | python-versions = ">=2.6, !=3.0.*, !=3.1.*, !=3.2.*" 501 | 502 | [[package]] 503 | name = "pyreadline" 504 | version = "2.1" 505 | description = "A python implmementation of GNU readline." 506 | category = "dev" 507 | optional = false 508 | python-versions = "*" 509 | 510 | [[package]] 511 | name = "pyrepl" 512 | version = "0.9.0" 513 | description = "A library for building flexible command line interfaces" 514 | category = "dev" 515 | optional = false 516 | python-versions = "*" 517 | 518 | [[package]] 519 | name = "pytest" 520 | version = "6.1.1" 521 | description = "pytest: simple powerful testing with Python" 522 | category = "dev" 523 | optional = false 524 | python-versions = ">=3.5" 525 | 526 | [package.dependencies] 527 | atomicwrites = {version = ">=1.0", markers = "sys_platform == \"win32\""} 528 | attrs = ">=17.4.0" 529 | colorama = {version = "*", markers = "sys_platform == \"win32\""} 530 | importlib-metadata = {version = ">=0.12", markers = "python_version < \"3.8\""} 531 | iniconfig = "*" 532 | packaging = "*" 533 | pluggy = ">=0.12,<1.0" 534 | py = ">=1.8.2" 535 | toml = "*" 536 | 537 | [package.extras] 538 | checkqa_mypy = ["mypy (==0.780)"] 539 | testing = ["argcomplete", "hypothesis (>=3.56)", "mock", "nose", "requests", "xmlschema"] 540 | 541 | [[package]] 542 | name = "pytest-cov" 543 | version = "2.10.1" 544 | description = "Pytest plugin for measuring coverage." 545 | category = "dev" 546 | optional = false 547 | python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*" 548 | 549 | [package.dependencies] 550 | coverage = ">=4.4" 551 | pytest = ">=4.6" 552 | 553 | [package.extras] 554 | testing = ["fields", "hunter", "process-tests (==2.0.2)", "six", "pytest-xdist", "virtualenv"] 555 | 556 | [[package]] 557 | name = "pytest-django" 558 | version = "3.10.0" 559 | description = "A Django plugin for pytest." 560 | category = "dev" 561 | optional = false 562 | python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" 563 | 564 | [package.dependencies] 565 | pytest = ">=3.6" 566 | 567 | [package.extras] 568 | docs = ["sphinx", "sphinx-rtd-theme"] 569 | testing = ["django", "django-configurations (>=2.0)", "six"] 570 | 571 | [[package]] 572 | name = "pytest-mock" 573 | version = "3.3.1" 574 | description = "Thin-wrapper around the mock package for easier use with pytest" 575 | category = "dev" 576 | optional = false 577 | python-versions = ">=3.5" 578 | 579 | [package.dependencies] 580 | pytest = ">=5.0" 581 | 582 | [package.extras] 583 | dev = ["pre-commit", "tox", "pytest-asyncio"] 584 | 585 | [[package]] 586 | name = "pytz" 587 | version = "2020.1" 588 | description = "World timezone definitions, modern and historical" 589 | category = "main" 590 | optional = false 591 | python-versions = "*" 592 | 593 | [[package]] 594 | name = "pyyaml" 595 | version = "5.3.1" 596 | description = "YAML parser and emitter for Python" 597 | category = "dev" 598 | optional = false 599 | python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*" 600 | 601 | [[package]] 602 | name = "regex" 603 | version = "2020.10.15" 604 | description = "Alternative regular expression module, to replace re." 605 | category = "dev" 606 | optional = false 607 | python-versions = "*" 608 | 609 | [[package]] 610 | name = "six" 611 | version = "1.15.0" 612 | description = "Python 2 and 3 compatibility utilities" 613 | category = "dev" 614 | optional = false 615 | python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*" 616 | 617 | [[package]] 618 | name = "sqlparse" 619 | version = "0.4.1" 620 | description = "A non-validating SQL parser." 621 | category = "main" 622 | optional = false 623 | python-versions = ">=3.5" 624 | 625 | [[package]] 626 | name = "toml" 627 | version = "0.10.1" 628 | description = "Python Library for Tom's Obvious, Minimal Language" 629 | category = "dev" 630 | optional = false 631 | python-versions = "*" 632 | 633 | [[package]] 634 | name = "tox" 635 | version = "3.20.1" 636 | description = "tox is a generic virtualenv management and test command line tool" 637 | category = "dev" 638 | optional = false 639 | python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,>=2.7" 640 | 641 | [package.dependencies] 642 | colorama = {version = ">=0.4.1", markers = "platform_system == \"Windows\""} 643 | filelock = ">=3.0.0" 644 | importlib-metadata = {version = ">=0.12,<3", markers = "python_version < \"3.8\""} 645 | packaging = ">=14" 646 | pluggy = ">=0.12.0" 647 | py = ">=1.4.17" 648 | six = ">=1.14.0" 649 | toml = ">=0.9.4" 650 | virtualenv = ">=16.0.0,<20.0.0 || >20.0.0,<20.0.1 || >20.0.1,<20.0.2 || >20.0.2,<20.0.3 || >20.0.3,<20.0.4 || >20.0.4,<20.0.5 || >20.0.5,<20.0.6 || >20.0.6,<20.0.7 || >20.0.7" 651 | 652 | [package.extras] 653 | docs = ["pygments-github-lexers (>=0.0.5)", "sphinx (>=2.0.0)", "sphinxcontrib-autoprogram (>=0.1.5)", "towncrier (>=18.5.0)"] 654 | testing = ["flaky (>=3.4.0)", "freezegun (>=0.3.11)", "pathlib2 (>=2.3.3)", "psutil (>=5.6.1)", "pytest (>=4.0.0)", "pytest-cov (>=2.5.1)", "pytest-mock (>=1.10.0)", "pytest-randomly (>=1.0.0)", "pytest-xdist (>=1.22.2)"] 655 | 656 | [[package]] 657 | name = "tox-gh-actions" 658 | version = "1.3.0" 659 | description = "Seamless integration of tox into GitHub Actions" 660 | category = "dev" 661 | optional = false 662 | python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,>=2.7" 663 | 664 | [package.dependencies] 665 | tox = ">=3.12" 666 | 667 | [package.extras] 668 | testing = ["flake8 (>=3,<4)", "pytest (>=4.0.0,<6)", "pytest-mock (>=2,<3)", "pytest-randomly (>=3)"] 669 | 670 | [[package]] 671 | name = "typed-ast" 672 | version = "1.4.1" 673 | description = "a fork of Python 2 and 3 ast modules with type comment support" 674 | category = "dev" 675 | optional = false 676 | python-versions = "*" 677 | 678 | [[package]] 679 | name = "typing-extensions" 680 | version = "3.7.4.3" 681 | description = "Backported and Experimental Type Hints for Python 3.5+" 682 | category = "dev" 683 | optional = false 684 | python-versions = "*" 685 | 686 | [[package]] 687 | name = "virtualenv" 688 | version = "20.0.35" 689 | description = "Virtual Python Environment builder" 690 | category = "dev" 691 | optional = false 692 | python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,>=2.7" 693 | 694 | [package.dependencies] 695 | appdirs = ">=1.4.3,<2" 696 | distlib = ">=0.3.1,<1" 697 | filelock = ">=3.0.0,<4" 698 | importlib-metadata = {version = ">=0.12,<3", markers = "python_version < \"3.8\""} 699 | importlib-resources = {version = ">=1.0", markers = "python_version < \"3.7\""} 700 | six = ">=1.9.0,<2" 701 | 702 | [package.extras] 703 | docs = ["proselint (>=0.10.2)", "sphinx (>=3)", "sphinx-argparse (>=0.2.5)", "sphinx-rtd-theme (>=0.4.3)", "towncrier (>=19.9.0rc1)"] 704 | testing = ["coverage (>=4)", "coverage-enable-subprocess (>=1)", "flaky (>=3)", "pytest (>=4)", "pytest-env (>=0.6.2)", "pytest-freezegun (>=0.4.1)", "pytest-mock (>=2)", "pytest-randomly (>=1)", "pytest-timeout (>=1)", "pytest-xdist (>=1.31.0)", "packaging (>=20.0)", "xonsh (>=0.9.16)"] 705 | 706 | [[package]] 707 | name = "wmctrl" 708 | version = "0.3" 709 | description = "A tool to programmatically control windows inside X" 710 | category = "dev" 711 | optional = false 712 | python-versions = "*" 713 | 714 | [[package]] 715 | name = "zipp" 716 | version = "3.3.1" 717 | description = "Backport of pathlib-compatible object wrapper for zip files" 718 | category = "dev" 719 | optional = false 720 | python-versions = ">=3.6" 721 | 722 | [package.extras] 723 | docs = ["sphinx", "jaraco.packaging (>=3.2)", "rst.linker (>=1.9)"] 724 | testing = ["pytest (>=3.5,!=3.7.3)", "pytest-checkdocs (>=1.2.3)", "pytest-flake8", "pytest-cov", "jaraco.test (>=3.2.0)", "jaraco.itertools", "func-timeout", "pytest-black (>=0.3.7)", "pytest-mypy"] 725 | 726 | [metadata] 727 | lock-version = "1.1" 728 | python-versions = ">=3.6.1, <4.0" 729 | content-hash = "356f25925e8fd580f95059d8f6e5a514c72add80e278b557ea5333a7dd45b13a" 730 | 731 | [metadata.files] 732 | appdirs = [ 733 | {file = "appdirs-1.4.4-py2.py3-none-any.whl", hash = "sha256:a841dacd6b99318a741b166adb07e19ee71a274450e68237b4650ca1055ab128"}, 734 | {file = "appdirs-1.4.4.tar.gz", hash = "sha256:7d5d0167b2b1ba821647616af46a749d1c653740dd0d2415100fe26e27afdf41"}, 735 | ] 736 | asgiref = [ 737 | {file = "asgiref-3.2.10-py3-none-any.whl", hash = "sha256:9fc6fb5d39b8af147ba40765234fa822b39818b12cc80b35ad9b0cef3a476aed"}, 738 | {file = "asgiref-3.2.10.tar.gz", hash = "sha256:7e51911ee147dd685c3c8b805c0ad0cb58d360987b56953878f8c06d2d1c6f1a"}, 739 | ] 740 | atomicwrites = [ 741 | {file = "atomicwrites-1.4.0-py2.py3-none-any.whl", hash = "sha256:6d1784dea7c0c8d4a5172b6c620f40b6e4cbfdf96d783691f2e1302a7b88e197"}, 742 | {file = "atomicwrites-1.4.0.tar.gz", hash = "sha256:ae70396ad1a434f9c7046fd2dd196fc04b12f9e91ffb859164193be8b6168a7a"}, 743 | ] 744 | attrs = [ 745 | {file = "attrs-20.2.0-py2.py3-none-any.whl", hash = "sha256:fce7fc47dfc976152e82d53ff92fa0407700c21acd20886a13777a0d20e655dc"}, 746 | {file = "attrs-20.2.0.tar.gz", hash = "sha256:26b54ddbbb9ee1d34d5d3668dd37d6cf74990ab23c828c2888dccdceee395594"}, 747 | ] 748 | autoflake = [ 749 | {file = "autoflake-1.4.tar.gz", hash = "sha256:61a353012cff6ab94ca062823d1fb2f692c4acda51c76ff83a8d77915fba51ea"}, 750 | ] 751 | better-exceptions = [ 752 | {file = "better_exceptions-0.3.3-py3-none-any.whl", hash = "sha256:9c70b1c61d5a179b84cd2c9d62c3324b667d74286207343645ed4306fdaad976"}, 753 | {file = "better_exceptions-0.3.3-py3.8.egg", hash = "sha256:bf111d0c9994ac1123f29c24907362bed2320a86809c85f0d858396000667ce2"}, 754 | {file = "better_exceptions-0.3.3.tar.gz", hash = "sha256:e4e6bc18444d5f04e6e894b10381e5e921d3d544240418162c7db57e9eb3453b"}, 755 | ] 756 | black = [ 757 | {file = "black-20.8b1-py3-none-any.whl", hash = "sha256:70b62ef1527c950db59062cda342ea224d772abdf6adc58b86a45421bab20a6b"}, 758 | {file = "black-20.8b1.tar.gz", hash = "sha256:1c02557aa099101b9d21496f8a914e9ed2222ef70336404eeeac8edba836fbea"}, 759 | ] 760 | cfgv = [ 761 | {file = "cfgv-3.2.0-py2.py3-none-any.whl", hash = "sha256:32e43d604bbe7896fe7c248a9c2276447dbef840feb28fe20494f62af110211d"}, 762 | {file = "cfgv-3.2.0.tar.gz", hash = "sha256:cf22deb93d4bcf92f345a5c3cd39d3d41d6340adc60c78bbbd6588c384fda6a1"}, 763 | ] 764 | click = [ 765 | {file = "click-7.1.2-py2.py3-none-any.whl", hash = "sha256:dacca89f4bfadd5de3d7489b7c8a566eee0d3676333fbb50030263894c38c0dc"}, 766 | {file = "click-7.1.2.tar.gz", hash = "sha256:d2b5255c7c6349bc1bd1e59e08cd12acbbd63ce649f2588755783aa94dfb6b1a"}, 767 | ] 768 | colorama = [ 769 | {file = "colorama-0.4.4-py2.py3-none-any.whl", hash = "sha256:9f47eda37229f68eee03b24b9748937c7dc3868f906e8ba69fbcbdd3bc5dc3e2"}, 770 | {file = "colorama-0.4.4.tar.gz", hash = "sha256:5941b2b48a20143d2267e95b1c2a7603ce057ee39fd88e7329b0c292aa16869b"}, 771 | ] 772 | coverage = [ 773 | {file = "coverage-5.3-cp27-cp27m-macosx_10_13_intel.whl", hash = "sha256:bd3166bb3b111e76a4f8e2980fa1addf2920a4ca9b2b8ca36a3bc3dedc618270"}, 774 | {file = "coverage-5.3-cp27-cp27m-macosx_10_9_x86_64.whl", hash = "sha256:9342dd70a1e151684727c9c91ea003b2fb33523bf19385d4554f7897ca0141d4"}, 775 | {file = "coverage-5.3-cp27-cp27m-manylinux1_i686.whl", hash = "sha256:63808c30b41f3bbf65e29f7280bf793c79f54fb807057de7e5238ffc7cc4d7b9"}, 776 | {file = "coverage-5.3-cp27-cp27m-manylinux1_x86_64.whl", hash = "sha256:4d6a42744139a7fa5b46a264874a781e8694bb32f1d76d8137b68138686f1729"}, 777 | {file = "coverage-5.3-cp27-cp27m-win32.whl", hash = "sha256:86e9f8cd4b0cdd57b4ae71a9c186717daa4c5a99f3238a8723f416256e0b064d"}, 778 | {file = "coverage-5.3-cp27-cp27m-win_amd64.whl", hash = "sha256:7858847f2d84bf6e64c7f66498e851c54de8ea06a6f96a32a1d192d846734418"}, 779 | {file = "coverage-5.3-cp27-cp27mu-manylinux1_i686.whl", hash = "sha256:530cc8aaf11cc2ac7430f3614b04645662ef20c348dce4167c22d99bec3480e9"}, 780 | {file = "coverage-5.3-cp27-cp27mu-manylinux1_x86_64.whl", hash = "sha256:381ead10b9b9af5f64646cd27107fb27b614ee7040bb1226f9c07ba96625cbb5"}, 781 | {file = "coverage-5.3-cp35-cp35m-macosx_10_13_x86_64.whl", hash = "sha256:71b69bd716698fa62cd97137d6f2fdf49f534decb23a2c6fc80813e8b7be6822"}, 782 | {file = "coverage-5.3-cp35-cp35m-manylinux1_i686.whl", hash = "sha256:1d44bb3a652fed01f1f2c10d5477956116e9b391320c94d36c6bf13b088a1097"}, 783 | {file = "coverage-5.3-cp35-cp35m-manylinux1_x86_64.whl", hash = "sha256:1c6703094c81fa55b816f5ae542c6ffc625fec769f22b053adb42ad712d086c9"}, 784 | {file = "coverage-5.3-cp35-cp35m-win32.whl", hash = "sha256:cedb2f9e1f990918ea061f28a0f0077a07702e3819602d3507e2ff98c8d20636"}, 785 | {file = "coverage-5.3-cp35-cp35m-win_amd64.whl", hash = "sha256:7f43286f13d91a34fadf61ae252a51a130223c52bfefb50310d5b2deb062cf0f"}, 786 | {file = "coverage-5.3-cp36-cp36m-macosx_10_13_x86_64.whl", hash = "sha256:c851b35fc078389bc16b915a0a7c1d5923e12e2c5aeec58c52f4aa8085ac8237"}, 787 | {file = "coverage-5.3-cp36-cp36m-manylinux1_i686.whl", hash = "sha256:aac1ba0a253e17889550ddb1b60a2063f7474155465577caa2a3b131224cfd54"}, 788 | {file = "coverage-5.3-cp36-cp36m-manylinux1_x86_64.whl", hash = "sha256:2b31f46bf7b31e6aa690d4c7a3d51bb262438c6dcb0d528adde446531d0d3bb7"}, 789 | {file = "coverage-5.3-cp36-cp36m-win32.whl", hash = "sha256:c5f17ad25d2c1286436761b462e22b5020d83316f8e8fcb5deb2b3151f8f1d3a"}, 790 | {file = "coverage-5.3-cp36-cp36m-win_amd64.whl", hash = "sha256:aef72eae10b5e3116bac6957de1df4d75909fc76d1499a53fb6387434b6bcd8d"}, 791 | {file = "coverage-5.3-cp37-cp37m-macosx_10_13_x86_64.whl", hash = "sha256:e8caf961e1b1a945db76f1b5fa9c91498d15f545ac0ababbe575cfab185d3bd8"}, 792 | {file = "coverage-5.3-cp37-cp37m-manylinux1_i686.whl", hash = "sha256:29a6272fec10623fcbe158fdf9abc7a5fa032048ac1d8631f14b50fbfc10d17f"}, 793 | {file = "coverage-5.3-cp37-cp37m-manylinux1_x86_64.whl", hash = "sha256:2d43af2be93ffbad25dd959899b5b809618a496926146ce98ee0b23683f8c51c"}, 794 | {file = "coverage-5.3-cp37-cp37m-win32.whl", hash = "sha256:c3888a051226e676e383de03bf49eb633cd39fc829516e5334e69b8d81aae751"}, 795 | {file = "coverage-5.3-cp37-cp37m-win_amd64.whl", hash = "sha256:9669179786254a2e7e57f0ecf224e978471491d660aaca833f845b72a2df3709"}, 796 | {file = "coverage-5.3-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:0203acd33d2298e19b57451ebb0bed0ab0c602e5cf5a818591b4918b1f97d516"}, 797 | {file = "coverage-5.3-cp38-cp38-manylinux1_i686.whl", hash = "sha256:582ddfbe712025448206a5bc45855d16c2e491c2dd102ee9a2841418ac1c629f"}, 798 | {file = "coverage-5.3-cp38-cp38-manylinux1_x86_64.whl", hash = "sha256:0f313707cdecd5cd3e217fc68c78a960b616604b559e9ea60cc16795c4304259"}, 799 | {file = "coverage-5.3-cp38-cp38-win32.whl", hash = "sha256:78e93cc3571fd928a39c0b26767c986188a4118edc67bc0695bc7a284da22e82"}, 800 | {file = "coverage-5.3-cp38-cp38-win_amd64.whl", hash = "sha256:8f264ba2701b8c9f815b272ad568d555ef98dfe1576802ab3149c3629a9f2221"}, 801 | {file = "coverage-5.3-cp39-cp39-macosx_10_13_x86_64.whl", hash = "sha256:50691e744714856f03a86df3e2bff847c2acede4c191f9a1da38f088df342978"}, 802 | {file = "coverage-5.3-cp39-cp39-manylinux1_i686.whl", hash = "sha256:9361de40701666b034c59ad9e317bae95c973b9ff92513dd0eced11c6adf2e21"}, 803 | {file = "coverage-5.3-cp39-cp39-manylinux1_x86_64.whl", hash = "sha256:c1b78fb9700fc961f53386ad2fd86d87091e06ede5d118b8a50dea285a071c24"}, 804 | {file = "coverage-5.3-cp39-cp39-win32.whl", hash = "sha256:cb7df71de0af56000115eafd000b867d1261f786b5eebd88a0ca6360cccfaca7"}, 805 | {file = "coverage-5.3-cp39-cp39-win_amd64.whl", hash = "sha256:47a11bdbd8ada9b7ee628596f9d97fbd3851bd9999d398e9436bd67376dbece7"}, 806 | {file = "coverage-5.3.tar.gz", hash = "sha256:280baa8ec489c4f542f8940f9c4c2181f0306a8ee1a54eceba071a449fb870a0"}, 807 | ] 808 | dataclasses = [ 809 | {file = "dataclasses-0.7-py3-none-any.whl", hash = "sha256:3459118f7ede7c8bea0fe795bff7c6c2ce287d01dd226202f7c9ebc0610a7836"}, 810 | {file = "dataclasses-0.7.tar.gz", hash = "sha256:494a6dcae3b8bcf80848eea2ef64c0cc5cd307ffc263e17cdf42f3e5420808e6"}, 811 | ] 812 | distlib = [ 813 | {file = "distlib-0.3.1-py2.py3-none-any.whl", hash = "sha256:8c09de2c67b3e7deef7184574fc060ab8a793e7adbb183d942c389c8b13c52fb"}, 814 | {file = "distlib-0.3.1.zip", hash = "sha256:edf6116872c863e1aa9d5bb7cb5e05a022c519a4594dc703843343a9ddd9bff1"}, 815 | ] 816 | django = [ 817 | {file = "Django-3.1.2-py3-none-any.whl", hash = "sha256:c93c28ccf1d094cbd00d860e83128a39e45d2c571d3b54361713aaaf9a94cac4"}, 818 | {file = "Django-3.1.2.tar.gz", hash = "sha256:a2127ad0150ec6966655bedf15dbbff9697cc86d61653db2da1afa506c0b04cc"}, 819 | ] 820 | django-appconf = [ 821 | {file = "django-appconf-1.0.4.tar.gz", hash = "sha256:be58deb54a43d77d2e1621fe59f787681376d3cd0b8bd8e4758ef6c3a6453380"}, 822 | {file = "django_appconf-1.0.4-py2.py3-none-any.whl", hash = "sha256:1b1d0e1069c843ebe8ae5aa48ec52403b1440402b320c3e3a206a0907e97bb06"}, 823 | ] 824 | fancycompleter = [ 825 | {file = "fancycompleter-0.9.1-py3-none-any.whl", hash = "sha256:dd076bca7d9d524cc7f25ec8f35ef95388ffef9ef46def4d3d25e9b044ad7080"}, 826 | {file = "fancycompleter-0.9.1.tar.gz", hash = "sha256:09e0feb8ae242abdfd7ef2ba55069a46f011814a80fe5476be48f51b00247272"}, 827 | ] 828 | filelock = [ 829 | {file = "filelock-3.0.12-py3-none-any.whl", hash = "sha256:929b7d63ec5b7d6b71b0fa5ac14e030b3f70b75747cef1b10da9b879fef15836"}, 830 | {file = "filelock-3.0.12.tar.gz", hash = "sha256:18d82244ee114f543149c66a6e0c14e9c4f8a1044b5cdaadd0f82159d6a6ff59"}, 831 | ] 832 | flake8 = [ 833 | {file = "flake8-3.8.4-py2.py3-none-any.whl", hash = "sha256:749dbbd6bfd0cf1318af27bf97a14e28e5ff548ef8e5b1566ccfb25a11e7c839"}, 834 | {file = "flake8-3.8.4.tar.gz", hash = "sha256:aadae8761ec651813c24be05c6f7b4680857ef6afaae4651a4eccaef97ce6c3b"}, 835 | ] 836 | flake8-bugbear = [ 837 | {file = "flake8-bugbear-20.11.1.tar.gz", hash = "sha256:528020129fea2dea33a466b9d64ab650aa3e5f9ffc788b70ea4bc6cf18283538"}, 838 | {file = "flake8_bugbear-20.11.1-py36.py37.py38-none-any.whl", hash = "sha256:f35b8135ece7a014bc0aee5b5d485334ac30a6da48494998cc1fabf7ec70d703"}, 839 | ] 840 | flake8-builtins = [ 841 | {file = "flake8-builtins-1.5.3.tar.gz", hash = "sha256:09998853b2405e98e61d2ff3027c47033adbdc17f9fe44ca58443d876eb00f3b"}, 842 | {file = "flake8_builtins-1.5.3-py2.py3-none-any.whl", hash = "sha256:7706babee43879320376861897e5d1468e396a40b8918ed7bccf70e5f90b8687"}, 843 | ] 844 | flake8-comprehensions = [ 845 | {file = "flake8-comprehensions-3.3.1.tar.gz", hash = "sha256:e734bf03806bb562886d9bf635d23a65a1a995c251b67d7e007a7b608af9bd22"}, 846 | {file = "flake8_comprehensions-3.3.1-py3-none-any.whl", hash = "sha256:6d80dfafda0d85633f88ea5bc7de949485f71f1e28db7af7719563fe5f62dcb1"}, 847 | ] 848 | flake8-debugger = [ 849 | {file = "flake8-debugger-4.0.0.tar.gz", hash = "sha256:e43dc777f7db1481db473210101ec2df2bd39a45b149d7218a618e954177eda6"}, 850 | {file = "flake8_debugger-4.0.0-py3-none-any.whl", hash = "sha256:82e64faa72e18d1bdd0000407502ebb8ecffa7bc027c62b9d4110ce27c091032"}, 851 | ] 852 | flake8-polyfill = [ 853 | {file = "flake8-polyfill-1.0.2.tar.gz", hash = "sha256:e44b087597f6da52ec6393a709e7108b2905317d0c0b744cdca6208e670d8eda"}, 854 | {file = "flake8_polyfill-1.0.2-py2.py3-none-any.whl", hash = "sha256:12be6a34ee3ab795b19ca73505e7b55826d5f6ad7230d31b18e106400169b9e9"}, 855 | ] 856 | identify = [ 857 | {file = "identify-1.5.6-py2.py3-none-any.whl", hash = "sha256:3139bf72d81dfd785b0a464e2776bd59bdc725b4cc10e6cf46b56a0db931c82e"}, 858 | {file = "identify-1.5.6.tar.gz", hash = "sha256:969d844b7a85d32a5f9ac4e163df6e846d73c87c8b75847494ee8f4bd2186421"}, 859 | ] 860 | importlib-metadata = [ 861 | {file = "importlib_metadata-2.0.0-py2.py3-none-any.whl", hash = "sha256:cefa1a2f919b866c5beb7c9f7b0ebb4061f30a8a9bf16d609b000e2dfaceb9c3"}, 862 | {file = "importlib_metadata-2.0.0.tar.gz", hash = "sha256:77a540690e24b0305878c37ffd421785a6f7e53c8b5720d211b211de8d0e95da"}, 863 | ] 864 | importlib-resources = [ 865 | {file = "importlib_resources-3.0.0-py2.py3-none-any.whl", hash = "sha256:d028f66b66c0d5732dae86ba4276999855e162a749c92620a38c1d779ed138a7"}, 866 | {file = "importlib_resources-3.0.0.tar.gz", hash = "sha256:19f745a6eca188b490b1428c8d1d4a0d2368759f32370ea8fb89cad2ab1106c3"}, 867 | ] 868 | iniconfig = [ 869 | {file = "iniconfig-1.1.1-py2.py3-none-any.whl", hash = "sha256:011e24c64b7f47f6ebd835bb12a743f2fbe9a26d4cecaa7f53bc4f35ee9da8b3"}, 870 | {file = "iniconfig-1.1.1.tar.gz", hash = "sha256:bc3af051d7d14b2ee5ef9969666def0cd1a000e121eaea580d4a313df4b37f32"}, 871 | ] 872 | isort = [ 873 | {file = "isort-5.6.4-py3-none-any.whl", hash = "sha256:dcab1d98b469a12a1a624ead220584391648790275560e1a43e54c5dceae65e7"}, 874 | {file = "isort-5.6.4.tar.gz", hash = "sha256:dcaeec1b5f0eca77faea2a35ab790b4f3680ff75590bfcb7145986905aab2f58"}, 875 | ] 876 | matchlib = [ 877 | {file = "matchlib-0.2.1-py3-none-any.whl", hash = "sha256:c6f1995f2778f08524ca54828a9de9ee5550cf85411c9db8913086ee496b1c90"}, 878 | {file = "matchlib-0.2.1.tar.gz", hash = "sha256:43a9a1eb09cc048a8cd7f8ef44520f10ab46b676e3952d28ef8c32fba3a6a640"}, 879 | ] 880 | mccabe = [ 881 | {file = "mccabe-0.6.1-py2.py3-none-any.whl", hash = "sha256:ab8a6258860da4b6677da4bd2fe5dc2c659cff31b3ee4f7f5d64e79735b80d42"}, 882 | {file = "mccabe-0.6.1.tar.gz", hash = "sha256:dd8d182285a0fe56bace7f45b5e7d1a6ebcbf524e8f3bd87eb0f125271b8831f"}, 883 | ] 884 | mypy = [ 885 | {file = "mypy-0.800-cp35-cp35m-macosx_10_9_x86_64.whl", hash = "sha256:e1c84c65ff6d69fb42958ece5b1255394714e0aac4df5ffe151bc4fe19c7600a"}, 886 | {file = "mypy-0.800-cp35-cp35m-manylinux1_x86_64.whl", hash = "sha256:947126195bfe4709c360e89b40114c6746ae248f04d379dca6f6ab677aa07641"}, 887 | {file = "mypy-0.800-cp35-cp35m-manylinux2010_x86_64.whl", hash = "sha256:b95068a3ce3b50332c40e31a955653be245666a4bc7819d3c8898aa9fb9ea496"}, 888 | {file = "mypy-0.800-cp35-cp35m-win_amd64.whl", hash = "sha256:ca7ad5aed210841f1e77f5f2f7d725b62c78fa77519312042c719ed2ab937876"}, 889 | {file = "mypy-0.800-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:e32b7b282c4ed4e378bba8b8dfa08e1cfa6f6574067ef22f86bee5b1039de0c9"}, 890 | {file = "mypy-0.800-cp36-cp36m-manylinux1_x86_64.whl", hash = "sha256:e497a544391f733eca922fdcb326d19e894789cd4ff61d48b4b195776476c5cf"}, 891 | {file = "mypy-0.800-cp36-cp36m-manylinux2010_x86_64.whl", hash = "sha256:5615785d3e2f4f03ab7697983d82c4b98af5c321614f51b8f1034eb9ebe48363"}, 892 | {file = "mypy-0.800-cp36-cp36m-win_amd64.whl", hash = "sha256:2b216eacca0ec0ee124af9429bfd858d5619a0725ee5f88057e6e076f9eb1a7b"}, 893 | {file = "mypy-0.800-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:e3b8432f8df19e3c11235c4563a7250666dc9aa7cdda58d21b4177b20256ca9f"}, 894 | {file = "mypy-0.800-cp37-cp37m-manylinux1_x86_64.whl", hash = "sha256:d16c54b0dffb861dc6318a8730952265876d90c5101085a4bc56913e8521ba19"}, 895 | {file = "mypy-0.800-cp37-cp37m-manylinux2010_x86_64.whl", hash = "sha256:0d2fc8beb99cd88f2d7e20d69131353053fbecea17904ee6f0348759302c52fa"}, 896 | {file = "mypy-0.800-cp37-cp37m-win_amd64.whl", hash = "sha256:aa9d4901f3ee1a986a3a79fe079ffbf7f999478c281376f48faa31daaa814e86"}, 897 | {file = "mypy-0.800-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:319ee5c248a7c3f94477f92a729b7ab06bf8a6d04447ef3aa8c9ba2aa47c6dcf"}, 898 | {file = "mypy-0.800-cp38-cp38-manylinux1_x86_64.whl", hash = "sha256:74f5aa50d0866bc6fb8e213441c41e466c86678c800700b87b012ed11c0a13e0"}, 899 | {file = "mypy-0.800-cp38-cp38-manylinux2010_x86_64.whl", hash = "sha256:a301da58d566aca05f8f449403c710c50a9860782148332322decf73a603280b"}, 900 | {file = "mypy-0.800-cp38-cp38-win_amd64.whl", hash = "sha256:b9150db14a48a8fa114189bfe49baccdff89da8c6639c2717750c7ae62316738"}, 901 | {file = "mypy-0.800-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:f5fdf935a46aa20aa937f2478480ebf4be9186e98e49cc3843af9a5795a49a25"}, 902 | {file = "mypy-0.800-cp39-cp39-manylinux1_x86_64.whl", hash = "sha256:6f8425fecd2ba6007e526209bb985ce7f49ed0d2ac1cc1a44f243380a06a84fb"}, 903 | {file = "mypy-0.800-cp39-cp39-manylinux2010_x86_64.whl", hash = "sha256:5ff616787122774f510caeb7b980542a7cc2222be3f00837a304ea85cd56e488"}, 904 | {file = "mypy-0.800-cp39-cp39-win_amd64.whl", hash = "sha256:90b6f46dc2181d74f80617deca611925d7e63007cf416397358aa42efb593e07"}, 905 | {file = "mypy-0.800-py3-none-any.whl", hash = "sha256:3e0c159a7853e3521e3f582adb1f3eac66d0b0639d434278e2867af3a8c62653"}, 906 | {file = "mypy-0.800.tar.gz", hash = "sha256:e0202e37756ed09daf4b0ba64ad2c245d357659e014c3f51d8cd0681ba66940a"}, 907 | ] 908 | mypy-extensions = [ 909 | {file = "mypy_extensions-0.4.3-py2.py3-none-any.whl", hash = "sha256:090fedd75945a69ae91ce1303b5824f428daf5a028d2f6ab8a299250a846f15d"}, 910 | {file = "mypy_extensions-0.4.3.tar.gz", hash = "sha256:2d82818f5bb3e369420cb3c4060a7970edba416647068eb4c5343488a6c604a8"}, 911 | ] 912 | nodeenv = [ 913 | {file = "nodeenv-1.5.0-py2.py3-none-any.whl", hash = "sha256:5304d424c529c997bc888453aeaa6362d242b6b4631e90f3d4bf1b290f1c84a9"}, 914 | {file = "nodeenv-1.5.0.tar.gz", hash = "sha256:ab45090ae383b716c4ef89e690c41ff8c2b257b85b309f01f3654df3d084bd7c"}, 915 | ] 916 | packaging = [ 917 | {file = "packaging-20.4-py2.py3-none-any.whl", hash = "sha256:998416ba6962ae7fbd6596850b80e17859a5753ba17c32284f67bfff33784181"}, 918 | {file = "packaging-20.4.tar.gz", hash = "sha256:4357f74f47b9c12db93624a82154e9b120fa8293699949152b22065d556079f8"}, 919 | ] 920 | pathspec = [ 921 | {file = "pathspec-0.8.0-py2.py3-none-any.whl", hash = "sha256:7d91249d21749788d07a2d0f94147accd8f845507400749ea19c1ec9054a12b0"}, 922 | {file = "pathspec-0.8.0.tar.gz", hash = "sha256:da45173eb3a6f2a5a487efba21f050af2b41948be6ab52b6a1e3ff22bb8b7061"}, 923 | ] 924 | pdbpp = [ 925 | {file = "pdbpp-0.10.2.tar.gz", hash = "sha256:73ff220d5006e0ecdc3e2705d8328d8aa5ac27fef95cc06f6e42cd7d22d55eb8"}, 926 | ] 927 | pep8-naming = [ 928 | {file = "pep8-naming-0.11.1.tar.gz", hash = "sha256:a1dd47dd243adfe8a83616e27cf03164960b507530f155db94e10b36a6cd6724"}, 929 | {file = "pep8_naming-0.11.1-py2.py3-none-any.whl", hash = "sha256:f43bfe3eea7e0d73e8b5d07d6407ab47f2476ccaeff6937c84275cd30b016738"}, 930 | ] 931 | pillow = [ 932 | {file = "Pillow-8.0.0-cp36-cp36m-macosx_10_10_x86_64.whl", hash = "sha256:b04569ff215b85ce3e2954979d2d5e0bf84007e43ddcf84b632fc6bc18e07909"}, 933 | {file = "Pillow-8.0.0-cp36-cp36m-manylinux1_i686.whl", hash = "sha256:594f2f25b7bcfd9542c41b9df156fb5104f19f5fcefa51b1447f1d9f64c9cc14"}, 934 | {file = "Pillow-8.0.0-cp36-cp36m-manylinux1_x86_64.whl", hash = "sha256:87a855b64a9b692604f6339baa4f9913d06838df1b4ccf0cb899dd18f56ec03c"}, 935 | {file = "Pillow-8.0.0-cp36-cp36m-manylinux2014_aarch64.whl", hash = "sha256:b731d45764349313bd956c07bdc1d43803bb0ad2b11354328a074e416c7d84bc"}, 936 | {file = "Pillow-8.0.0-cp36-cp36m-win32.whl", hash = "sha256:30615e9115f976e00a938a28c7152562e8cf8e221ddacf4446dd8b20c0d97333"}, 937 | {file = "Pillow-8.0.0-cp36-cp36m-win_amd64.whl", hash = "sha256:e6ac40f1a62a227eb00226eb64c9c82bc878a3ed700b5414d34c9be57be87e87"}, 938 | {file = "Pillow-8.0.0-cp37-cp37m-macosx_10_10_x86_64.whl", hash = "sha256:2696f1a6402c1a42ed12c5cd8adfb4b381c32d41e35a34b8ee544309ef854172"}, 939 | {file = "Pillow-8.0.0-cp37-cp37m-manylinux1_i686.whl", hash = "sha256:5b5dde5dcedc4e6f5a71d7654a3c6e189ced82e97d7896b1ca5a5c5e4e0e916f"}, 940 | {file = "Pillow-8.0.0-cp37-cp37m-manylinux1_x86_64.whl", hash = "sha256:04d984e45a0b9815f4b407e8aadb50f25fbb82a605d89db927376e94c3adf371"}, 941 | {file = "Pillow-8.0.0-cp37-cp37m-manylinux2014_aarch64.whl", hash = "sha256:6bcea85f93fb2c94a1bcd35704c348a929a7fb24a0ec0cc2b9fcbb0046b87176"}, 942 | {file = "Pillow-8.0.0-cp37-cp37m-win32.whl", hash = "sha256:233513465a2f25fce537b965621866da3d1f02e15708f371dd4e19f0fb7b7711"}, 943 | {file = "Pillow-8.0.0-cp37-cp37m-win_amd64.whl", hash = "sha256:d904570afcdbec40eb6bdbe24cba8d95c0215a2c0cbbc9c16301045bc8504c1f"}, 944 | {file = "Pillow-8.0.0-cp38-cp38-macosx_10_10_x86_64.whl", hash = "sha256:8c006d52365c0a6bb41a07f9c8f9f458ae8170e0af3b8c49bf7089347066b97b"}, 945 | {file = "Pillow-8.0.0-cp38-cp38-manylinux1_i686.whl", hash = "sha256:9b5b41737853bc49943864d5980dfb401a09e78ddb471e71291810ccdeadd712"}, 946 | {file = "Pillow-8.0.0-cp38-cp38-manylinux1_x86_64.whl", hash = "sha256:3a77e7b9f8991b81d7be8e0b2deab05013cf3ebb24ac2b863d2979acb68c73dd"}, 947 | {file = "Pillow-8.0.0-cp38-cp38-manylinux2014_aarch64.whl", hash = "sha256:c41442c3814afeba1f6f16fd70cdf312a2c73c6dee8dc3ac8926bb115713ad1d"}, 948 | {file = "Pillow-8.0.0-cp38-cp38-win32.whl", hash = "sha256:718d7f0eb3351052023b33fe0f83fc9e3beeb7cbacbd0ff2b52524e2153e4598"}, 949 | {file = "Pillow-8.0.0-cp38-cp38-win_amd64.whl", hash = "sha256:7c4a7ee37027ca716f42726b6f9fc491c13c843c7af559e0767dfab1ae9682d4"}, 950 | {file = "Pillow-8.0.0-cp39-cp39-macosx_10_10_x86_64.whl", hash = "sha256:54667c8ab16658cc0b7d824d8706b440d4db8382a3561042758bdfd48ca99298"}, 951 | {file = "Pillow-8.0.0-cp39-cp39-manylinux1_i686.whl", hash = "sha256:1f59596af2b3d64a9e43f9d6509b7a51db744d0eecc23297617c604e6823c6ae"}, 952 | {file = "Pillow-8.0.0-cp39-cp39-manylinux1_x86_64.whl", hash = "sha256:f5270369c799b4405ed47d45c88c09fbd7942fc9fb9891c0dabf0b8c751b625d"}, 953 | {file = "Pillow-8.0.0-cp39-cp39-manylinux2014_aarch64.whl", hash = "sha256:8e29701229705615d3dcfc439c7c46f40f913e57c7fe322b1efc30d3f37d1287"}, 954 | {file = "Pillow-8.0.0-cp39-cp39-win32.whl", hash = "sha256:c12e33cb17e2e12049a49b77696ee479791a4e44e541fdc393ae043e1246389f"}, 955 | {file = "Pillow-8.0.0-cp39-cp39-win_amd64.whl", hash = "sha256:06e730451b70471c08b8a0ee7f18e7e1df310dba9c780bbfb730a13102b143db"}, 956 | {file = "Pillow-8.0.0-pp36-pypy36_pp73-macosx_10_10_x86_64.whl", hash = "sha256:c4d743c5c91424965707c9c8edc58b7cb43c127dcaf191fbcd304e2082eef56a"}, 957 | {file = "Pillow-8.0.0-pp36-pypy36_pp73-manylinux2010_x86_64.whl", hash = "sha256:2ca55a4443b463eec90528ac27be14d226b1c2b972178bc7d4d282ce89e47b6a"}, 958 | {file = "Pillow-8.0.0-pp36-pypy36_pp73-win32.whl", hash = "sha256:e674be2f349ea810e221b0113bd4491f53584ac848d5bcc3b62443cfa11d9c40"}, 959 | {file = "Pillow-8.0.0-pp37-pypy37_pp73-win32.whl", hash = "sha256:d6766fd28f4f47cf93280a57e3dc6a9d11bdada1a6e9f019b8c62b12bbc86f6a"}, 960 | {file = "Pillow-8.0.0.tar.gz", hash = "sha256:59304c67d12394815331eda95ec892bf54ad95e0aa7bc1ccd8e0a4a5a25d4bf3"}, 961 | ] 962 | pluggy = [ 963 | {file = "pluggy-0.13.1-py2.py3-none-any.whl", hash = "sha256:966c145cd83c96502c3c3868f50408687b38434af77734af1e9ca461a4081d2d"}, 964 | {file = "pluggy-0.13.1.tar.gz", hash = "sha256:15b2acde666561e1298d71b523007ed7364de07029219b604cf808bfa1c765b0"}, 965 | ] 966 | pre-commit = [ 967 | {file = "pre_commit-2.7.1-py2.py3-none-any.whl", hash = "sha256:810aef2a2ba4f31eed1941fc270e72696a1ad5590b9751839c90807d0fff6b9a"}, 968 | {file = "pre_commit-2.7.1.tar.gz", hash = "sha256:c54fd3e574565fe128ecc5e7d2f91279772ddb03f8729645fa812fe809084a70"}, 969 | ] 970 | py = [ 971 | {file = "py-1.9.0-py2.py3-none-any.whl", hash = "sha256:366389d1db726cd2fcfc79732e75410e5fe4d31db13692115529d34069a043c2"}, 972 | {file = "py-1.9.0.tar.gz", hash = "sha256:9ca6883ce56b4e8da7e79ac18787889fa5206c79dcc67fb065376cd2fe03f342"}, 973 | ] 974 | pycodestyle = [ 975 | {file = "pycodestyle-2.6.0-py2.py3-none-any.whl", hash = "sha256:2295e7b2f6b5bd100585ebcb1f616591b652db8a741695b3d8f5d28bdc934367"}, 976 | {file = "pycodestyle-2.6.0.tar.gz", hash = "sha256:c58a7d2815e0e8d7972bf1803331fb0152f867bd89adf8a01dfd55085434192e"}, 977 | ] 978 | pyflakes = [ 979 | {file = "pyflakes-2.2.0-py2.py3-none-any.whl", hash = "sha256:0d94e0e05a19e57a99444b6ddcf9a6eb2e5c68d3ca1e98e90707af8152c90a92"}, 980 | {file = "pyflakes-2.2.0.tar.gz", hash = "sha256:35b2d75ee967ea93b55750aa9edbbf72813e06a66ba54438df2cfac9e3c27fc8"}, 981 | ] 982 | pygments = [ 983 | {file = "Pygments-2.8.0-py3-none-any.whl", hash = "sha256:b21b072d0ccdf29297a82a2363359d99623597b8a265b8081760e4d0f7153c88"}, 984 | {file = "Pygments-2.8.0.tar.gz", hash = "sha256:37a13ba168a02ac54cc5891a42b1caec333e59b66addb7fa633ea8a6d73445c0"}, 985 | ] 986 | pyparsing = [ 987 | {file = "pyparsing-2.4.7-py2.py3-none-any.whl", hash = "sha256:ef9d7589ef3c200abe66653d3f1ab1033c3c419ae9b9bdb1240a85b024efc88b"}, 988 | {file = "pyparsing-2.4.7.tar.gz", hash = "sha256:c203ec8783bf771a155b207279b9bccb8dea02d8f0c9e5f8ead507bc3246ecc1"}, 989 | ] 990 | pyreadline = [ 991 | {file = "pyreadline-2.1.win-amd64.exe", hash = "sha256:9ce5fa65b8992dfa373bddc5b6e0864ead8f291c94fbfec05fbd5c836162e67b"}, 992 | {file = "pyreadline-2.1.win32.exe", hash = "sha256:65540c21bfe14405a3a77e4c085ecfce88724743a4ead47c66b84defcf82c32e"}, 993 | {file = "pyreadline-2.1.zip", hash = "sha256:4530592fc2e85b25b1a9f79664433da09237c1a270e4d78ea5aa3a2c7229e2d1"}, 994 | ] 995 | pyrepl = [ 996 | {file = "pyrepl-0.9.0.tar.gz", hash = "sha256:292570f34b5502e871bbb966d639474f2b57fbfcd3373c2d6a2f3d56e681a775"}, 997 | ] 998 | pytest = [ 999 | {file = "pytest-6.1.1-py3-none-any.whl", hash = "sha256:7a8190790c17d79a11f847fba0b004ee9a8122582ebff4729a082c109e81a4c9"}, 1000 | {file = "pytest-6.1.1.tar.gz", hash = "sha256:8f593023c1a0f916110285b6efd7f99db07d59546e3d8c36fc60e2ab05d3be92"}, 1001 | ] 1002 | pytest-cov = [ 1003 | {file = "pytest-cov-2.10.1.tar.gz", hash = "sha256:47bd0ce14056fdd79f93e1713f88fad7bdcc583dcd7783da86ef2f085a0bb88e"}, 1004 | {file = "pytest_cov-2.10.1-py2.py3-none-any.whl", hash = "sha256:45ec2d5182f89a81fc3eb29e3d1ed3113b9e9a873bcddb2a71faaab066110191"}, 1005 | ] 1006 | pytest-django = [ 1007 | {file = "pytest-django-3.10.0.tar.gz", hash = "sha256:4de6dbd077ed8606616958f77655fed0d5e3ee45159475671c7fa67596c6dba6"}, 1008 | {file = "pytest_django-3.10.0-py2.py3-none-any.whl", hash = "sha256:c33e3d3da14d8409b125d825d4e74da17bb252191bf6fc3da6856e27a8b73ea4"}, 1009 | ] 1010 | pytest-mock = [ 1011 | {file = "pytest-mock-3.3.1.tar.gz", hash = "sha256:a4d6d37329e4a893e77d9ffa89e838dd2b45d5dc099984cf03c703ac8411bb82"}, 1012 | {file = "pytest_mock-3.3.1-py3-none-any.whl", hash = "sha256:024e405ad382646318c4281948aadf6fe1135632bea9cc67366ea0c4098ef5f2"}, 1013 | ] 1014 | pytz = [ 1015 | {file = "pytz-2020.1-py2.py3-none-any.whl", hash = "sha256:a494d53b6d39c3c6e44c3bec237336e14305e4f29bbf800b599253057fbb79ed"}, 1016 | {file = "pytz-2020.1.tar.gz", hash = "sha256:c35965d010ce31b23eeb663ed3cc8c906275d6be1a34393a1d73a41febf4a048"}, 1017 | ] 1018 | pyyaml = [ 1019 | {file = "PyYAML-5.3.1-cp27-cp27m-win32.whl", hash = "sha256:74809a57b329d6cc0fdccee6318f44b9b8649961fa73144a98735b0aaf029f1f"}, 1020 | {file = "PyYAML-5.3.1-cp27-cp27m-win_amd64.whl", hash = "sha256:240097ff019d7c70a4922b6869d8a86407758333f02203e0fc6ff79c5dcede76"}, 1021 | {file = "PyYAML-5.3.1-cp35-cp35m-win32.whl", hash = "sha256:4f4b913ca1a7319b33cfb1369e91e50354d6f07a135f3b901aca02aa95940bd2"}, 1022 | {file = "PyYAML-5.3.1-cp35-cp35m-win_amd64.whl", hash = "sha256:cc8955cfbfc7a115fa81d85284ee61147059a753344bc51098f3ccd69b0d7e0c"}, 1023 | {file = "PyYAML-5.3.1-cp36-cp36m-win32.whl", hash = "sha256:7739fc0fa8205b3ee8808aea45e968bc90082c10aef6ea95e855e10abf4a37b2"}, 1024 | {file = "PyYAML-5.3.1-cp36-cp36m-win_amd64.whl", hash = "sha256:69f00dca373f240f842b2931fb2c7e14ddbacd1397d57157a9b005a6a9942648"}, 1025 | {file = "PyYAML-5.3.1-cp37-cp37m-win32.whl", hash = "sha256:d13155f591e6fcc1ec3b30685d50bf0711574e2c0dfffd7644babf8b5102ca1a"}, 1026 | {file = "PyYAML-5.3.1-cp37-cp37m-win_amd64.whl", hash = "sha256:73f099454b799e05e5ab51423c7bcf361c58d3206fa7b0d555426b1f4d9a3eaf"}, 1027 | {file = "PyYAML-5.3.1-cp38-cp38-win32.whl", hash = "sha256:06a0d7ba600ce0b2d2fe2e78453a470b5a6e000a985dd4a4e54e436cc36b0e97"}, 1028 | {file = "PyYAML-5.3.1-cp38-cp38-win_amd64.whl", hash = "sha256:95f71d2af0ff4227885f7a6605c37fd53d3a106fcab511b8860ecca9fcf400ee"}, 1029 | {file = "PyYAML-5.3.1.tar.gz", hash = "sha256:b8eac752c5e14d3eca0e6dd9199cd627518cb5ec06add0de9d32baeee6fe645d"}, 1030 | ] 1031 | regex = [ 1032 | {file = "regex-2020.10.15-cp27-cp27m-win32.whl", hash = "sha256:e935a166a5f4c02afe3f7e4ce92ce5a786f75c6caa0c4ce09c922541d74b77e8"}, 1033 | {file = "regex-2020.10.15-cp27-cp27m-win_amd64.whl", hash = "sha256:d81be22d5d462b96a2aa5c512f741255ba182995efb0114e5a946fe254148df1"}, 1034 | {file = "regex-2020.10.15-cp36-cp36m-manylinux1_i686.whl", hash = "sha256:6d4cdb6c20e752426b2e569128488c5046fb1b16b1beadaceea9815c36da0847"}, 1035 | {file = "regex-2020.10.15-cp36-cp36m-manylinux1_x86_64.whl", hash = "sha256:25991861c6fef1e5fd0a01283cf5658c5e7f7aa644128e85243bc75304e91530"}, 1036 | {file = "regex-2020.10.15-cp36-cp36m-manylinux2010_i686.whl", hash = "sha256:6e9f72e0ee49f7d7be395bfa29e9533f0507a882e1e6bf302c0a204c65b742bf"}, 1037 | {file = "regex-2020.10.15-cp36-cp36m-manylinux2010_x86_64.whl", hash = "sha256:578ac6379e65eb8e6a85299b306c966c852712c834dc7eef0ba78d07a828f67b"}, 1038 | {file = "regex-2020.10.15-cp36-cp36m-win32.whl", hash = "sha256:65b6b018b07e9b3b6a05c2c3bb7710ed66132b4df41926c243887c4f1ff303d5"}, 1039 | {file = "regex-2020.10.15-cp36-cp36m-win_amd64.whl", hash = "sha256:2f60ba5c33f00ce9be29a140e6f812e39880df8ba9cb92ad333f0016dbc30306"}, 1040 | {file = "regex-2020.10.15-cp37-cp37m-manylinux1_i686.whl", hash = "sha256:5d4a3221f37520bb337b64a0632716e61b26c8ae6aaffceeeb7ad69c009c404b"}, 1041 | {file = "regex-2020.10.15-cp37-cp37m-manylinux1_x86_64.whl", hash = "sha256:26b85672275d8c7a9d4ff93dbc4954f5146efdb2ecec89ad1de49439984dea14"}, 1042 | {file = "regex-2020.10.15-cp37-cp37m-manylinux2010_i686.whl", hash = "sha256:828618f3c3439c5e6ef8621e7c885ca561bbaaba90ddbb6a7dfd9e1ec8341103"}, 1043 | {file = "regex-2020.10.15-cp37-cp37m-manylinux2010_x86_64.whl", hash = "sha256:aef23aed9d4017cc74d37f703d57ce254efb4c8a6a01905f40f539220348abf9"}, 1044 | {file = "regex-2020.10.15-cp37-cp37m-win32.whl", hash = "sha256:6c72adb85adecd4522a488a751e465842cdd2a5606b65464b9168bf029a54272"}, 1045 | {file = "regex-2020.10.15-cp37-cp37m-win_amd64.whl", hash = "sha256:ef3a55b16c6450574734db92e0a3aca283290889934a23f7498eaf417e3af9f0"}, 1046 | {file = "regex-2020.10.15-cp38-cp38-manylinux1_i686.whl", hash = "sha256:8958befc139ac4e3f16d44ec386c490ea2121ed8322f4956f83dd9cad8e9b922"}, 1047 | {file = "regex-2020.10.15-cp38-cp38-manylinux1_x86_64.whl", hash = "sha256:3dd952f3f8dc01b72c0cf05b3631e05c50ac65ddd2afdf26551638e97502107b"}, 1048 | {file = "regex-2020.10.15-cp38-cp38-manylinux2010_i686.whl", hash = "sha256:608d6c05452c0e6cc49d4d7407b4767963f19c4d2230fa70b7201732eedc84f2"}, 1049 | {file = "regex-2020.10.15-cp38-cp38-manylinux2010_x86_64.whl", hash = "sha256:02686a2f0b1a4be0facdd0d3ad4dc6c23acaa0f38fb5470d892ae88584ba705c"}, 1050 | {file = "regex-2020.10.15-cp38-cp38-win32.whl", hash = "sha256:137da580d1e6302484be3ef41d72cf5c3ad22a076070051b7449c0e13ab2c482"}, 1051 | {file = "regex-2020.10.15-cp38-cp38-win_amd64.whl", hash = "sha256:20cdd7e1736f4f61a5161aa30d05ac108ab8efc3133df5eb70fe1e6a23ea1ca6"}, 1052 | {file = "regex-2020.10.15-cp39-cp39-manylinux1_i686.whl", hash = "sha256:85b733a1ef2b2e7001aff0e204a842f50ad699c061856a214e48cfb16ace7d0c"}, 1053 | {file = "regex-2020.10.15-cp39-cp39-manylinux1_x86_64.whl", hash = "sha256:af1f5e997dd1ee71fb6eb4a0fb6921bf7a778f4b62f1f7ef0d7445ecce9155d6"}, 1054 | {file = "regex-2020.10.15-cp39-cp39-manylinux2010_i686.whl", hash = "sha256:b5eeaf4b5ef38fab225429478caf71f44d4a0b44d39a1aa4d4422cda23a9821b"}, 1055 | {file = "regex-2020.10.15-cp39-cp39-manylinux2010_x86_64.whl", hash = "sha256:aeac7c9397480450016bc4a840eefbfa8ca68afc1e90648aa6efbfe699e5d3bb"}, 1056 | {file = "regex-2020.10.15-cp39-cp39-win32.whl", hash = "sha256:698f8a5a2815e1663d9895830a063098ae2f8f2655ae4fdc5dfa2b1f52b90087"}, 1057 | {file = "regex-2020.10.15-cp39-cp39-win_amd64.whl", hash = "sha256:a51e51eecdac39a50ede4aeed86dbef4776e3b73347d31d6ad0bc9648ba36049"}, 1058 | {file = "regex-2020.10.15.tar.gz", hash = "sha256:d25f5cca0f3af6d425c9496953445bf5b288bb5b71afc2b8308ad194b714c159"}, 1059 | ] 1060 | six = [ 1061 | {file = "six-1.15.0-py2.py3-none-any.whl", hash = "sha256:8b74bedcbbbaca38ff6d7491d76f2b06b3592611af620f8426e82dddb04a5ced"}, 1062 | {file = "six-1.15.0.tar.gz", hash = "sha256:30639c035cdb23534cd4aa2dd52c3bf48f06e5f4a941509c8bafd8ce11080259"}, 1063 | ] 1064 | sqlparse = [ 1065 | {file = "sqlparse-0.4.1-py3-none-any.whl", hash = "sha256:017cde379adbd6a1f15a61873f43e8274179378e95ef3fede90b5aa64d304ed0"}, 1066 | {file = "sqlparse-0.4.1.tar.gz", hash = "sha256:0f91fd2e829c44362cbcfab3e9ae12e22badaa8a29ad5ff599f9ec109f0454e8"}, 1067 | ] 1068 | toml = [ 1069 | {file = "toml-0.10.1-py2.py3-none-any.whl", hash = "sha256:bda89d5935c2eac546d648028b9901107a595863cb36bae0c73ac804a9b4ce88"}, 1070 | {file = "toml-0.10.1.tar.gz", hash = "sha256:926b612be1e5ce0634a2ca03470f95169cf16f939018233a670519cb4ac58b0f"}, 1071 | ] 1072 | tox = [ 1073 | {file = "tox-3.20.1-py2.py3-none-any.whl", hash = "sha256:42ce19ce5dc2f6d6b1fdc5666c476e1f1e2897359b47e0aa3a5b774f335d57c2"}, 1074 | {file = "tox-3.20.1.tar.gz", hash = "sha256:4321052bfe28f9d85082341ca8e233e3ea901fdd14dab8a5d3fbd810269fbaf6"}, 1075 | ] 1076 | tox-gh-actions = [ 1077 | {file = "tox-gh-actions-1.3.0.tar.gz", hash = "sha256:85d61e5f6176746497692f1ae17854656dbc1d4badfd97c6e5218f91804de176"}, 1078 | {file = "tox_gh_actions-1.3.0-py2.py3-none-any.whl", hash = "sha256:4ffcdaffd271b678ff77f90eee8b59247197f8faab2f5d19b6375f62a7545318"}, 1079 | ] 1080 | typed-ast = [ 1081 | {file = "typed_ast-1.4.1-cp35-cp35m-manylinux1_i686.whl", hash = "sha256:73d785a950fc82dd2a25897d525d003f6378d1cb23ab305578394694202a58c3"}, 1082 | {file = "typed_ast-1.4.1-cp35-cp35m-manylinux1_x86_64.whl", hash = "sha256:aaee9905aee35ba5905cfb3c62f3e83b3bec7b39413f0a7f19be4e547ea01ebb"}, 1083 | {file = "typed_ast-1.4.1-cp35-cp35m-win32.whl", hash = "sha256:0c2c07682d61a629b68433afb159376e24e5b2fd4641d35424e462169c0a7919"}, 1084 | {file = "typed_ast-1.4.1-cp35-cp35m-win_amd64.whl", hash = "sha256:4083861b0aa07990b619bd7ddc365eb7fa4b817e99cf5f8d9cf21a42780f6e01"}, 1085 | {file = "typed_ast-1.4.1-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:269151951236b0f9a6f04015a9004084a5ab0d5f19b57de779f908621e7d8b75"}, 1086 | {file = "typed_ast-1.4.1-cp36-cp36m-manylinux1_i686.whl", hash = "sha256:24995c843eb0ad11a4527b026b4dde3da70e1f2d8806c99b7b4a7cf491612652"}, 1087 | {file = "typed_ast-1.4.1-cp36-cp36m-manylinux1_x86_64.whl", hash = "sha256:fe460b922ec15dd205595c9b5b99e2f056fd98ae8f9f56b888e7a17dc2b757e7"}, 1088 | {file = "typed_ast-1.4.1-cp36-cp36m-win32.whl", hash = "sha256:4e3e5da80ccbebfff202a67bf900d081906c358ccc3d5e3c8aea42fdfdfd51c1"}, 1089 | {file = "typed_ast-1.4.1-cp36-cp36m-win_amd64.whl", hash = "sha256:249862707802d40f7f29f6e1aad8d84b5aa9e44552d2cc17384b209f091276aa"}, 1090 | {file = "typed_ast-1.4.1-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:8ce678dbaf790dbdb3eba24056d5364fb45944f33553dd5869b7580cdbb83614"}, 1091 | {file = "typed_ast-1.4.1-cp37-cp37m-manylinux1_i686.whl", hash = "sha256:c9e348e02e4d2b4a8b2eedb48210430658df6951fa484e59de33ff773fbd4b41"}, 1092 | {file = "typed_ast-1.4.1-cp37-cp37m-manylinux1_x86_64.whl", hash = "sha256:bcd3b13b56ea479b3650b82cabd6b5343a625b0ced5429e4ccad28a8973f301b"}, 1093 | {file = "typed_ast-1.4.1-cp37-cp37m-win32.whl", hash = "sha256:d5d33e9e7af3b34a40dc05f498939f0ebf187f07c385fd58d591c533ad8562fe"}, 1094 | {file = "typed_ast-1.4.1-cp37-cp37m-win_amd64.whl", hash = "sha256:0666aa36131496aed8f7be0410ff974562ab7eeac11ef351def9ea6fa28f6355"}, 1095 | {file = "typed_ast-1.4.1-cp38-cp38-macosx_10_15_x86_64.whl", hash = "sha256:d205b1b46085271b4e15f670058ce182bd1199e56b317bf2ec004b6a44f911f6"}, 1096 | {file = "typed_ast-1.4.1-cp38-cp38-manylinux1_i686.whl", hash = "sha256:6daac9731f172c2a22ade6ed0c00197ee7cc1221aa84cfdf9c31defeb059a907"}, 1097 | {file = "typed_ast-1.4.1-cp38-cp38-manylinux1_x86_64.whl", hash = "sha256:498b0f36cc7054c1fead3d7fc59d2150f4d5c6c56ba7fb150c013fbc683a8d2d"}, 1098 | {file = "typed_ast-1.4.1-cp38-cp38-win32.whl", hash = "sha256:715ff2f2df46121071622063fc7543d9b1fd19ebfc4f5c8895af64a77a8c852c"}, 1099 | {file = "typed_ast-1.4.1-cp38-cp38-win_amd64.whl", hash = "sha256:fc0fea399acb12edbf8a628ba8d2312f583bdbdb3335635db062fa98cf71fca4"}, 1100 | {file = "typed_ast-1.4.1-cp39-cp39-macosx_10_15_x86_64.whl", hash = "sha256:d43943ef777f9a1c42bf4e552ba23ac77a6351de620aa9acf64ad54933ad4d34"}, 1101 | {file = "typed_ast-1.4.1.tar.gz", hash = "sha256:8c8aaad94455178e3187ab22c8b01a3837f8ee50e09cf31f1ba129eb293ec30b"}, 1102 | ] 1103 | typing-extensions = [ 1104 | {file = "typing_extensions-3.7.4.3-py2-none-any.whl", hash = "sha256:dafc7639cde7f1b6e1acc0f457842a83e722ccca8eef5270af2d74792619a89f"}, 1105 | {file = "typing_extensions-3.7.4.3-py3-none-any.whl", hash = "sha256:7cb407020f00f7bfc3cb3e7881628838e69d8f3fcab2f64742a5e76b2f841918"}, 1106 | {file = "typing_extensions-3.7.4.3.tar.gz", hash = "sha256:99d4073b617d30288f569d3f13d2bd7548c3a7e4c8de87db09a9d29bb3a4a60c"}, 1107 | ] 1108 | virtualenv = [ 1109 | {file = "virtualenv-20.0.35-py2.py3-none-any.whl", hash = "sha256:0ebc633426d7468664067309842c81edab11ae97fcaf27e8ad7f5748c89b431b"}, 1110 | {file = "virtualenv-20.0.35.tar.gz", hash = "sha256:2a72c80fa2ad8f4e2985c06e6fc12c3d60d060e410572f553c90619b0f6efaf3"}, 1111 | ] 1112 | wmctrl = [ 1113 | {file = "wmctrl-0.3.tar.gz", hash = "sha256:d806f65ac1554366b6e31d29d7be2e8893996c0acbb2824bbf2b1f49cf628a13"}, 1114 | ] 1115 | zipp = [ 1116 | {file = "zipp-3.3.1-py3-none-any.whl", hash = "sha256:16522f69653f0d67be90e8baa4a46d66389145b734345d68a257da53df670903"}, 1117 | {file = "zipp-3.3.1.tar.gz", hash = "sha256:c1532a8030c32fd52ff6a288d855fe7adef5823ba1d26a29a68fd6314aa72baa"}, 1118 | ] 1119 | -------------------------------------------------------------------------------- /poetry.toml: -------------------------------------------------------------------------------- 1 | [virtualenvs] 2 | in-project = true 3 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [tool.poetry] 2 | name = "django-video-encoding" 3 | version = "1.0.0" 4 | description = "django-video-encoding helps to convert your videos into different formats and resolutions." 5 | authors = [ 6 | "Alexander Frenzel ", 7 | ] 8 | 9 | license = "BSD-3-Clause" 10 | readme = "README.md" 11 | 12 | documentation = "https://github.com/escaped/django-video-encoding/blob/master/README.md" 13 | homepage = "https://github.com/escaped/django-video-encoding" 14 | repository = "https://github.com/escaped/django-video-encoding" 15 | 16 | classifiers = [ 17 | "Development Status :: 4 - Beta", 18 | "Environment :: Web Environment", 19 | "Framework :: Django", 20 | "Intended Audience :: Developers", 21 | "Operating System :: OS Independent", 22 | "Programming Language :: Python", 23 | "Programming Language :: Python :: 3", 24 | "Programming Language :: Python :: 3.6", 25 | "Programming Language :: Python :: 3.7", 26 | "Programming Language :: Python :: 3.8", 27 | "Programming Language :: Python :: 3.9", 28 | "Topic :: Software Development :: Libraries :: Python Modules", 29 | ] 30 | 31 | packages = [ 32 | { include = "video_encoding" }, 33 | ] 34 | 35 | [tool.poetry.dependencies] 36 | python = ">=3.6.1, <4.0" 37 | 38 | django = ">=2.2" 39 | 40 | django-appconf = "^1.0" 41 | pillow = ">=5.0" 42 | 43 | [tool.poetry.dev-dependencies] 44 | autoflake = "^1.4" 45 | better-exceptions = "^0.3.2" 46 | black = "^20.8b1" 47 | flake8 = "^3.8.3" 48 | flake8-bugbear = "^20.11.1" 49 | flake8-builtins = "^1.5.3" 50 | flake8-comprehensions = "^3.3.1" 51 | flake8-debugger = "^4.0.0" 52 | isort = "^5.5.2" 53 | mypy = "^0.800" 54 | pdbpp = "^0.10.2" 55 | pep8-naming = "^0.11.1" 56 | pre-commit = "^2.7.1" 57 | pytest = "^6.0.1" 58 | pytest-cov = "^2.10.1" 59 | pytest-django = "^3.9.0" 60 | pytest-mock = "^3.3.1" 61 | tox = "^3.20.0" 62 | tox-gh-actions = "^1.3.0" 63 | matchlib = "^0.2.1" 64 | 65 | [tool.black] 66 | line-length = 88 67 | skip-string-normalization = true 68 | target_version = ['py36', 'py37', 'py38'] 69 | include = '\.pyi?$' 70 | exclude = ''' 71 | ( 72 | /( 73 | \.eggs # exclude a few common directories in the 74 | | \.git # root of the project 75 | | \.hg 76 | | \.mypy_cache 77 | | \.tox 78 | | \.venv 79 | | _build 80 | | buck-out 81 | | build 82 | | dist 83 | )/ 84 | ) 85 | ''' 86 | 87 | [build-system] 88 | requires = ["poetry-core>=1.0.0"] 89 | build-backend = "poetry.core.masonry.api" 90 | 91 | -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [isort] 2 | line_length = 88 3 | profile = black 4 | 5 | 6 | [flake8] 7 | exclude = 8 | .git 9 | __pycache__ 10 | dist 11 | build 12 | 13 | ignore = 14 | E501 # code is reformatted using black 15 | max-line-length = 88 16 | max-complexity = 9 17 | 18 | 19 | [coverage:run] 20 | relative_files = True 21 | branch = True 22 | include = video_encoding/* 23 | omit = 24 | */tests/* 25 | 26 | [coverage:report] 27 | show_missing = True 28 | exclude_lines = 29 | pragma: no cover 30 | 31 | # Don't complain about missing debug-only code: 32 | def __unicode__ 33 | def __repr__ 34 | def __str__ 35 | 36 | # Don't complain if tests don't hit defensive assertion code: 37 | raise AssertionError 38 | raise NotImplementedError 39 | 40 | # Don't complain if non-runnable code isn't run: 41 | if __name__ == __main__: 42 | 43 | # No need to check type checking imports 44 | if TYPE_CHECKING: 45 | 46 | 47 | [tool:pytest] 48 | addopts = 49 | --durations=10 50 | --cov=video_encoding 51 | --cov-report term 52 | norecursedirs = build dist 53 | testpaths = 54 | video_encoding 55 | test_proj 56 | 57 | 58 | [mypy] 59 | # Specify the target platform details in config, so your developers are 60 | # free to run mypy on Windows, Linux, or macOS and get consistent 61 | # results. 62 | python_version = 3.6 63 | platform = Linux 64 | 65 | ignore_missing_imports = True 66 | -------------------------------------------------------------------------------- /test_proj/__init__.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | import django 4 | 5 | os.environ['DJANGO_SETTINGS_MODULE'] = 'test_proj.settings' 6 | django.setup() 7 | -------------------------------------------------------------------------------- /test_proj/conftest.py: -------------------------------------------------------------------------------- 1 | import enum 2 | import os 3 | import shutil 4 | from pathlib import Path 5 | from typing import IO, Any, Generator 6 | 7 | import pytest 8 | from django.contrib.contenttypes.models import ContentType 9 | from django.core.files import File 10 | from django.core.files.storage import FileSystemStorage 11 | 12 | from test_proj.media_library.models import Format, Video 13 | from video_encoding.backends.ffmpeg import FFmpegBackend 14 | 15 | 16 | class StorageType(enum.Enum): 17 | LOCAL = enum.auto() 18 | REMOTE = enum.auto() 19 | 20 | 21 | @pytest.fixture 22 | def video_path(): 23 | path = os.path.dirname(os.path.abspath(__file__)) 24 | return os.path.join(path, 'waterfall.mp4') 25 | 26 | 27 | @pytest.fixture 28 | def ffmpeg(): 29 | return FFmpegBackend() 30 | 31 | 32 | @pytest.fixture 33 | def local_video(video_path) -> Generator[Video, None, None]: 34 | """ 35 | Return a video object which is stored locally. 36 | """ 37 | video = Video.objects.create() 38 | video.file.save('test.MTS', File(open(video_path, 'rb')), save=True) 39 | try: 40 | yield video 41 | finally: 42 | try: 43 | video.file.delete() 44 | except ValueError: 45 | # file has already been deleted 46 | pass 47 | 48 | for format_ in video.format_set.all(): 49 | format_.file.delete() 50 | 51 | video.delete() 52 | 53 | 54 | @pytest.fixture 55 | def video_format(video_path, local_video) -> Generator[Format, None, None]: 56 | format_ = Format.objects.create( 57 | object_id=local_video.pk, 58 | content_type=ContentType.objects.get_for_model(local_video), 59 | field_name='file', 60 | format='mp4_hd', 61 | progress=100, 62 | ) 63 | format_.file.save('test.MTS', File(open(video_path, 'rb')), save=True) 64 | yield format_ 65 | 66 | 67 | @pytest.fixture 68 | def remote_video(local_video) -> Generator[Video, None, None]: 69 | """ 70 | Return a video which is stored "remotely". 71 | """ 72 | storage_path = Path(local_video.file.path).parent 73 | 74 | remote_video = local_video 75 | remote_video.file.storage = FakeRemoteStorage(storage_path) 76 | yield remote_video 77 | 78 | 79 | @pytest.fixture(params=StorageType) 80 | def video( 81 | request, local_video: Video, remote_video: Video 82 | ) -> Generator[Video, None, None]: 83 | """ 84 | Return a locally and a remotely stored video. 85 | """ 86 | storage_type = request.param 87 | 88 | if storage_type == StorageType.LOCAL: 89 | yield local_video 90 | elif storage_type == StorageType.REMOTE: 91 | yield remote_video 92 | else: 93 | raise ValueError(f"Invalid storage type {storage_type}") 94 | 95 | 96 | class FakeRemoteStorage(FileSystemStorage): 97 | """ 98 | Fake remote storage which does not support accessing a file by path. 99 | """ 100 | 101 | def __init__(self, root_path: Path) -> None: 102 | super().__init__() 103 | self.root_path = root_path 104 | 105 | def delete(self, name: str) -> None: 106 | file_path = self.__path(name) 107 | file_path.unlink() 108 | 109 | def exists(self, name: str) -> bool: 110 | return self.__path(name).exists() 111 | 112 | def open(self, name: str, mode: str) -> IO[Any]: # noqa: A003 113 | return open(self.__path(name), mode) 114 | 115 | def path(self, *args, **kwargs): 116 | raise NotImplementedError("Remote storage does not implement path()") 117 | 118 | def _save(self, name: str, content: File) -> str: 119 | file_path = self.__path(name) 120 | folder_path = file_path.parent 121 | 122 | if not folder_path.is_dir(): 123 | file_path.parent.mkdir(parents=True) 124 | 125 | if hasattr(content, 'temporary_file_path'): 126 | shutil.move(content.temporary_file_path(), file_path) 127 | else: 128 | with open(file_path, 'wb') as fp: 129 | fp.write(content.read()) 130 | 131 | return str(file_path) 132 | 133 | def __path(self, name: str) -> Path: 134 | """ 135 | Return path to local file. 136 | """ 137 | return self.root_path / name 138 | -------------------------------------------------------------------------------- /test_proj/manage.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | import os 3 | import sys 4 | 5 | if __name__ == "__main__": 6 | os.environ.setdefault("DJANGO_SETTINGS_MODULE", "test_proj.settings") 7 | sys.path.append(os.pardir) 8 | 9 | from django.core.management import execute_from_command_line 10 | 11 | execute_from_command_line(sys.argv) 12 | -------------------------------------------------------------------------------- /test_proj/media_library/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/escaped/django-video-encoding/e74f2f6871087bd43cdabd370e9197d6a8805df0/test_proj/media_library/__init__.py -------------------------------------------------------------------------------- /test_proj/media_library/admin.py: -------------------------------------------------------------------------------- 1 | from django.contrib import admin 2 | 3 | from video_encoding.admin import FormatInline 4 | 5 | from .models import Video 6 | 7 | 8 | @admin.register(Video) 9 | class VideoAdmin(admin.ModelAdmin): 10 | inlines = (FormatInline,) 11 | 12 | list_dispaly = ('get_filename', 'width', 'height', 'duration') 13 | fields = ('file', 'width', 'height', 'duration') 14 | readonly_fields = fields 15 | -------------------------------------------------------------------------------- /test_proj/media_library/migrations/0001_initial.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | from __future__ import unicode_literals 3 | 4 | from django.db import migrations, models 5 | 6 | import video_encoding.fields 7 | 8 | 9 | class Migration(migrations.Migration): 10 | 11 | dependencies = [] # type: ignore 12 | 13 | operations = [ 14 | migrations.CreateModel( 15 | name='Video', 16 | fields=[ 17 | ( 18 | 'id', 19 | models.AutoField( 20 | primary_key=True, 21 | serialize=False, 22 | auto_created=True, 23 | verbose_name='ID', 24 | ), 25 | ), 26 | ('width', models.PositiveIntegerField(editable=False, default=0)), 27 | ('height', models.PositiveIntegerField(editable=False, default=0)), 28 | ('duration', models.FloatField(editable=False, default=0)), 29 | ( 30 | 'file', 31 | video_encoding.fields.VideoField( 32 | width_field='width', height_field='height', upload_to='' 33 | ), 34 | ), 35 | ], 36 | ), 37 | ] 38 | -------------------------------------------------------------------------------- /test_proj/media_library/migrations/0002_auto_20160704_1656.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | from __future__ import unicode_literals 3 | 4 | from django.db import migrations, models 5 | 6 | 7 | class Migration(migrations.Migration): 8 | 9 | dependencies = [ 10 | ('media_library', '0001_initial'), 11 | ] 12 | 13 | operations = [ 14 | migrations.AlterField( 15 | model_name='video', 16 | name='duration', 17 | field=models.FloatField(editable=False, null=True), 18 | ), 19 | migrations.AlterField( 20 | model_name='video', 21 | name='height', 22 | field=models.PositiveIntegerField(editable=False, null=True), 23 | ), 24 | migrations.AlterField( 25 | model_name='video', 26 | name='width', 27 | field=models.PositiveIntegerField(editable=False, null=True), 28 | ), 29 | ] 30 | -------------------------------------------------------------------------------- /test_proj/media_library/migrations/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/escaped/django-video-encoding/e74f2f6871087bd43cdabd370e9197d6a8805df0/test_proj/media_library/migrations/__init__.py -------------------------------------------------------------------------------- /test_proj/media_library/models.py: -------------------------------------------------------------------------------- 1 | from django.contrib.contenttypes.fields import GenericRelation 2 | from django.db import models 3 | 4 | from video_encoding.fields import VideoField 5 | from video_encoding.models import Format 6 | 7 | 8 | class Video(models.Model): 9 | width = models.PositiveIntegerField( 10 | editable=False, 11 | null=True, 12 | ) 13 | height = models.PositiveIntegerField( 14 | editable=False, 15 | null=True, 16 | ) 17 | duration = models.FloatField( 18 | editable=False, 19 | null=True, 20 | ) 21 | 22 | file = VideoField( 23 | width_field='width', 24 | height_field='height', 25 | duration_field='duration', 26 | ) 27 | 28 | format_set = GenericRelation(Format) 29 | -------------------------------------------------------------------------------- /test_proj/media_library/templates/video_form.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 |

Video Upload

6 |
7 | {% csrf_token %} 8 | {{ form }} 9 | 10 |
11 | 12 |

Uploaded Videos

13 | {% for video in videos %} 14 | {{ video.file.name }} 15 | (Duration: {{ video.duration }}s, {{ video.width }}x{{ video.height }}) 16 |
17 | {% endfor %} 18 | 19 | 20 | -------------------------------------------------------------------------------- /test_proj/media_library/tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/escaped/django-video-encoding/e74f2f6871087bd43cdabd370e9197d6a8805df0/test_proj/media_library/tests/__init__.py -------------------------------------------------------------------------------- /test_proj/media_library/tests/test_admin.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | from django.urls import reverse 3 | 4 | 5 | @pytest.fixture() 6 | def admin_client(client, admin_user): 7 | client.force_login(admin_user) 8 | return client 9 | 10 | 11 | def test_format_inline(admin_client, video): 12 | url = reverse('admin:media_library_video_change', args=(video.pk,)) 13 | admin_client.get(url) 14 | -------------------------------------------------------------------------------- /test_proj/media_library/tests/test_encoding.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | from django.conf import settings 3 | 4 | from video_encoding.tasks import convert_all_videos, convert_video 5 | 6 | 7 | @pytest.mark.django_db 8 | def test_encoding(video): 9 | assert video.format_set.count() == 0 10 | 11 | convert_video(video.file) 12 | 13 | assert video.format_set.count() == 4 14 | 15 | formats = {o['name']: o for o in settings.VIDEO_ENCODING_FORMATS['FFmpeg']} 16 | assert set(video.format_set.values_list('format', flat=True)) == set( 17 | formats.keys() 18 | ) # NOQA 19 | 20 | for f in video.format_set.all(): 21 | assert formats[f.format]['extension'] == f.file.name.split('.')[-1] 22 | assert f.progress == 100 23 | 24 | 25 | @pytest.mark.django_db 26 | def test_encoding_auto_fields(video): 27 | assert video.format_set.count() == 0 28 | 29 | convert_all_videos( 30 | video._meta.app_label, 31 | video._meta.model_name, 32 | video.id, 33 | ) 34 | 35 | assert video.format_set.count() == 4 36 | -------------------------------------------------------------------------------- /test_proj/media_library/tests/test_ffmpeg_backend.py: -------------------------------------------------------------------------------- 1 | import os 2 | import tempfile 3 | 4 | import pytest 5 | from PIL import Image 6 | 7 | from video_encoding import exceptions 8 | from video_encoding.backends.ffmpeg import FFmpegBackend 9 | 10 | 11 | def test_get_media_info(ffmpeg, video_path): 12 | media_info = ffmpeg.get_media_info(video_path) 13 | 14 | assert media_info == {'width': 1280, 'height': 720, 'duration': 2.022} 15 | 16 | 17 | def test_encode(ffmpeg, video_path): 18 | __, target_path = tempfile.mkstemp(suffix='.mp4') 19 | encoding = ffmpeg.encode( 20 | video_path, 21 | target_path, 22 | ['-vf', 'scale=-2:320', '-r', '90', '-codec:v', 'libx264'], 23 | ) 24 | percent = next(encoding) 25 | assert 0 <= percent <= 100 26 | while percent: 27 | assert 0 <= percent <= 100 28 | try: 29 | percent = next(encoding) 30 | except StopIteration: 31 | break 32 | 33 | assert percent == 100 34 | assert os.path.isfile(target_path) 35 | media_info = ffmpeg.get_media_info(target_path) 36 | assert media_info == {'width': 568, 'height': 320, 'duration': 2.027} 37 | 38 | 39 | def test_get_thumbnail(ffmpeg, video_path): 40 | thumbnail_path = ffmpeg.get_thumbnail(video_path) 41 | 42 | assert os.path.isfile(thumbnail_path) 43 | with Image.open(thumbnail_path) as im: 44 | width, height = im.size 45 | assert width == 1280 46 | assert height == 720 47 | 48 | 49 | def test_get_thumbnail__invalid_time(ffmpeg, video_path): 50 | with pytest.raises(exceptions.InvalidTimeError): 51 | ffmpeg.get_thumbnail(video_path, at_time=1000000) 52 | 53 | 54 | @pytest.mark.parametrize( 55 | 'offset', 56 | (0, 0.02), 57 | ) 58 | def test_get_thumbnail__too_close_to_the_end(ffmpeg, video_path, offset): 59 | """ 60 | If the selected time point is close to the end of the video, 61 | a video frame cannot be extracted. 62 | """ 63 | duration = ffmpeg.get_media_info(video_path)['duration'] 64 | 65 | with pytest.raises(exceptions.InvalidTimeError): 66 | ffmpeg.get_thumbnail( 67 | video_path, 68 | at_time=duration - offset, 69 | ) 70 | 71 | 72 | def test_check(): 73 | assert FFmpegBackend.check() == [] 74 | 75 | path = os.environ['PATH'] 76 | os.environ['PATH'] = '' 77 | assert len(FFmpegBackend.check()) == 1 78 | os.environ['PATH'] = path 79 | -------------------------------------------------------------------------------- /test_proj/media_library/tests/test_fields.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | import pytest 4 | 5 | from video_encoding.backends.ffmpeg import FFmpegBackend 6 | 7 | from ..models import Video 8 | 9 | 10 | @pytest.mark.django_db 11 | def test_info_forward(ffmpeg, video, video_path): 12 | media_info = ffmpeg.get_media_info(video_path) 13 | 14 | assert video.duration == media_info['duration'] 15 | assert video.width == media_info['width'] 16 | assert video.height == media_info['height'] 17 | 18 | 19 | @pytest.mark.django_db 20 | def test_delete(ffmpeg, video): 21 | video.file.delete() 22 | video = Video.objects.get(pk=video.pk) 23 | assert not video.file 24 | 25 | 26 | def test_check(): 27 | field = Video._meta.get_field('file') 28 | assert field.check() == FFmpegBackend.check() 29 | 30 | path = os.environ['PATH'] 31 | os.environ['PATH'] = '' 32 | assert field.check() == FFmpegBackend.check() 33 | os.environ['PATH'] = path 34 | -------------------------------------------------------------------------------- /test_proj/media_library/tests/test_files.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | from video_encoding.files import VideoFile 4 | 5 | 6 | def test_videofile(ffmpeg, video_path): 7 | media_info = ffmpeg.get_media_info(video_path) 8 | 9 | video_file = VideoFile(open(video_path, mode='rb')) 10 | assert video_file.duration == media_info['duration'] 11 | assert video_file.width == media_info['width'] 12 | assert video_file.height == media_info['height'] 13 | 14 | 15 | @pytest.mark.django_db 16 | def test_videofile__with_storages(ffmpeg, video, video_path): 17 | media_info = ffmpeg.get_media_info(video_path) 18 | 19 | video_file = video.file 20 | assert video_file.duration == media_info['duration'] 21 | assert video_file.width == media_info['width'] 22 | assert video_file.height == media_info['height'] 23 | -------------------------------------------------------------------------------- /test_proj/media_library/tests/test_managers.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | from django.contrib.contenttypes.models import ContentType 3 | 4 | from ..models import Format 5 | 6 | 7 | @pytest.fixture 8 | def video_format(local_video): 9 | return Format.objects.create( 10 | object_id=local_video.pk, 11 | content_type=ContentType.objects.get_for_model(local_video), 12 | field_name='file', 13 | format='mp4_hd', 14 | progress=100, 15 | ) 16 | 17 | 18 | @pytest.mark.django_db 19 | def test_related_manager(local_video): 20 | assert hasattr(local_video.format_set, 'complete') 21 | assert hasattr(local_video.format_set, 'in_progress') 22 | 23 | 24 | @pytest.mark.django_db 25 | def test_in_progress(video_format): 26 | video_format.progress = 30 27 | video_format.save() 28 | 29 | assert Format.objects.complete().count() == 0 30 | assert Format.objects.in_progress().count() == 1 31 | assert Format.objects.in_progress()[0].progress < 100 32 | 33 | 34 | @pytest.mark.django_db 35 | def test_complete(video_format): 36 | assert Format.objects.in_progress().count() == 0 37 | assert Format.objects.complete().count() == 1 38 | assert Format.objects.complete()[0].progress == 100 39 | -------------------------------------------------------------------------------- /test_proj/media_library/tests/test_signals.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | from matchlib import matches 3 | 4 | from video_encoding import signals, tasks 5 | from video_encoding.exceptions import VideoEncodingError 6 | 7 | from .. import models 8 | 9 | 10 | @pytest.mark.django_db 11 | def test_signals(monkeypatch, mocker, local_video: models.Video) -> None: 12 | """ 13 | Make sure encoding signals are send. 14 | 15 | There are currently 4 signals: 16 | - encoding_started 17 | - format_started 18 | - format_finished 19 | - encoding_finished 20 | """ 21 | # encode only to one format 22 | encoding_format = tasks.settings.VIDEO_ENCODING_FORMATS['FFmpeg'][0] 23 | monkeypatch.setattr( 24 | tasks.settings, 'VIDEO_ENCODING_FORMATS', {'FFmpeg': [encoding_format]} 25 | ) 26 | 27 | mocker.patch.object(tasks, '_encode') # don't encode anything 28 | 29 | listener = mocker.MagicMock() 30 | signals.encoding_started.connect(listener) 31 | signals.format_started.connect(listener) 32 | signals.format_finished.connect(listener) 33 | signals.encoding_finished.connect(listener) 34 | 35 | tasks.convert_video(local_video.file) 36 | 37 | assert listener.call_count == 4 38 | # check arguments and make sure they are called in the right order 39 | # encoding_started 40 | _, kwargs = listener.call_args_list[0] 41 | assert kwargs == { 42 | 'signal': signals.encoding_started, 43 | 'sender': models.Video, 44 | 'instance': local_video, 45 | } 46 | 47 | # format started 48 | _, kwargs = listener.call_args_list[1] 49 | assert matches( 50 | kwargs, 51 | { 52 | 'signal': signals.format_started, 53 | 'sender': models.Format, 54 | 'instance': local_video, 55 | 'format': ..., 56 | }, 57 | ) 58 | assert isinstance(kwargs['format'], models.Format) 59 | assert kwargs['format'].format == encoding_format['name'] 60 | assert kwargs['format'].progress == 0 61 | 62 | # format finished 63 | _, kwargs = listener.call_args_list[2] 64 | assert matches( 65 | kwargs, 66 | { 67 | 'signal': signals.format_finished, 68 | 'sender': models.Format, 69 | 'instance': local_video, 70 | 'format': ..., 71 | 'result': signals.ConversionResult.SUCCEEDED, 72 | }, 73 | ) 74 | assert isinstance(kwargs['format'], models.Format) 75 | assert kwargs['format'].format == encoding_format['name'] 76 | 77 | # encoding finished 78 | _, kwargs = listener.call_args_list[3] 79 | assert kwargs == { 80 | 'signal': signals.encoding_finished, 81 | 'sender': models.Video, 82 | 'instance': local_video, 83 | } 84 | 85 | 86 | @pytest.mark.django_db 87 | def test_signals__encoding_failed( 88 | monkeypatch, mocker, local_video: models.Video 89 | ) -> None: 90 | """ 91 | Make sure encoding signal reports failed, if the encoding was not succesful. 92 | """ 93 | # encode only to one format 94 | encoding_format = tasks.settings.VIDEO_ENCODING_FORMATS['FFmpeg'][0] 95 | monkeypatch.setattr( 96 | tasks.settings, 'VIDEO_ENCODING_FORMATS', {'FFmpeg': [encoding_format]} 97 | ) 98 | 99 | mocker.patch.object( 100 | tasks, '_encode', side_effect=VideoEncodingError() 101 | ) # encoding should fail 102 | 103 | listener = mocker.MagicMock() 104 | signals.format_started.connect(listener) 105 | signals.format_finished.connect(listener) 106 | 107 | tasks.convert_video(local_video.file) 108 | 109 | assert listener.call_count == 2 110 | # check arguments and make sure they are called in the right order 111 | # format started 112 | _, kwargs = listener.call_args_list[0] 113 | assert matches(kwargs, {'signal': signals.format_started, ...: ...}) 114 | 115 | # format finished, but failed 116 | _, kwargs = listener.call_args_list[1] 117 | assert matches( 118 | kwargs, 119 | { 120 | 'signal': signals.format_finished, 121 | 'sender': models.Format, 122 | 'instance': local_video, 123 | 'format': ..., 124 | 'result': signals.ConversionResult.FAILED, 125 | }, 126 | ) 127 | assert isinstance(kwargs['format'], models.Format) 128 | assert kwargs['format'].format == encoding_format['name'] 129 | 130 | 131 | @pytest.mark.django_db 132 | def test_signals__encoding_skipped( 133 | monkeypatch, mocker, local_video: models.Video, video_format: models.Format 134 | ) -> None: 135 | """ 136 | Make sure encoding signal reports skipped, if file had been encoded before. 137 | """ 138 | # encode only to one format 139 | encoding_format = tasks.settings.VIDEO_ENCODING_FORMATS['FFmpeg'][0] 140 | monkeypatch.setattr( 141 | tasks.settings, 'VIDEO_ENCODING_FORMATS', {'FFmpeg': [encoding_format]} 142 | ) 143 | 144 | mocker.patch.object(tasks, '_encode') # don't encode anything 145 | # encoding has already been done for the given format 146 | video_format.format = encoding_format["name"] 147 | video_format.save() 148 | 149 | listener = mocker.MagicMock() 150 | signals.format_started.connect(listener) 151 | signals.format_finished.connect(listener) 152 | 153 | tasks.convert_video(local_video.file) 154 | 155 | assert listener.call_count == 2 156 | # check arguments and make sure they are called in the right order 157 | # format started 158 | _, kwargs = listener.call_args_list[0] 159 | assert matches(kwargs, {'signal': signals.format_started, ...: ...}) 160 | 161 | # format finished, but skipped 162 | _, kwargs = listener.call_args_list[1] 163 | assert matches( 164 | kwargs, 165 | { 166 | 'signal': signals.format_finished, 167 | 'sender': models.Format, 168 | 'instance': local_video, 169 | 'format': ..., 170 | 'result': signals.ConversionResult.SKIPPED, 171 | }, 172 | ) 173 | assert isinstance(kwargs['format'], models.Format) 174 | assert kwargs['format'].format == encoding_format['name'] 175 | -------------------------------------------------------------------------------- /test_proj/media_library/views.py: -------------------------------------------------------------------------------- 1 | from django.views.generic import CreateView 2 | 3 | from .models import Video 4 | 5 | 6 | class VideoFormView(CreateView): 7 | model = Video 8 | fields = ('file',) 9 | 10 | success_url = '/' 11 | template_name = 'video_form.html' 12 | 13 | def get_context_data(self, *args, **kwargs): 14 | context = super(VideoFormView, self).get_context_data(*args, **kwargs) 15 | context['videos'] = Video.objects.all() 16 | return context 17 | -------------------------------------------------------------------------------- /test_proj/requirements.txt: -------------------------------------------------------------------------------- 1 | Django 2 | -------------------------------------------------------------------------------- /test_proj/settings.py: -------------------------------------------------------------------------------- 1 | import os 2 | from typing import List 3 | 4 | BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) 5 | 6 | 7 | # Quick-start development settings - unsuitable for production 8 | # See https://docs.djangoproject.com/en/1.8/howto/deployment/checklist/ 9 | 10 | # SECURITY WARNING: keep the secret key used in production secret! 11 | SECRET_KEY = '=ycop_5evy)i4s@_5cuw(5q*$e(03kj!ajeg92$g8$42a_nw_w' 12 | 13 | # SECURITY WARNING: don't run with debug turned on in production! 14 | DEBUG = True 15 | 16 | ALLOWED_HOSTS: List[str] = [] 17 | 18 | MEDIA_ROOT = os.path.join(BASE_DIR, 'media') 19 | 20 | # Application definition 21 | 22 | INSTALLED_APPS = ( 23 | 'django.contrib.admin', 24 | 'django.contrib.auth', 25 | 'django.contrib.contenttypes', 26 | 'django.contrib.sessions', 27 | 'django.contrib.messages', 28 | 'django.contrib.staticfiles', 29 | 'video_encoding', 30 | 'test_proj.media_library', 31 | ) 32 | 33 | MIDDLEWARE = ( 34 | 'django.middleware.security.SecurityMiddleware', 35 | 'django.contrib.sessions.middleware.SessionMiddleware', 36 | 'django.middleware.common.CommonMiddleware', 37 | 'django.middleware.csrf.CsrfViewMiddleware', 38 | 'django.contrib.auth.middleware.AuthenticationMiddleware', 39 | 'django.contrib.messages.middleware.MessageMiddleware', 40 | 'django.middleware.clickjacking.XFrameOptionsMiddleware', 41 | ) 42 | 43 | ROOT_URLCONF = 'test_proj.urls' 44 | 45 | TEMPLATES = [ 46 | { 47 | 'BACKEND': 'django.template.backends.django.DjangoTemplates', 48 | 'DIRS': [], 49 | 'APP_DIRS': True, 50 | 'OPTIONS': { 51 | 'context_processors': [ 52 | 'django.template.context_processors.debug', 53 | 'django.template.context_processors.request', 54 | 'django.contrib.auth.context_processors.auth', 55 | 'django.contrib.messages.context_processors.messages', 56 | ], 57 | }, 58 | }, 59 | ] 60 | 61 | WSGI_APPLICATION = 'test_proj.wsgi.application' 62 | 63 | 64 | # Database 65 | # https://docs.djangoproject.com/en/1.8/ref/settings/#databases 66 | 67 | DATABASES = { 68 | 'default': { 69 | 'ENGINE': 'django.db.backends.sqlite3', 70 | 'NAME': os.path.join(BASE_DIR, 'db.sqlite3'), 71 | } 72 | } 73 | 74 | 75 | # Internationalization 76 | # https://docs.djangoproject.com/en/1.8/topics/i18n/ 77 | 78 | LANGUAGE_CODE = 'en-us' 79 | 80 | TIME_ZONE = 'UTC' 81 | 82 | USE_I18N = True 83 | 84 | USE_L10N = True 85 | 86 | USE_TZ = True 87 | 88 | 89 | # Static files (CSS, JavaScript, Images) 90 | # https://docs.djangoproject.com/en/1.8/howto/static-files/ 91 | 92 | STATIC_URL = '/static/' 93 | -------------------------------------------------------------------------------- /test_proj/urls.py: -------------------------------------------------------------------------------- 1 | from django.conf.urls import re_path 2 | from django.contrib import admin 3 | 4 | from .media_library.views import VideoFormView 5 | 6 | urlpatterns = [ 7 | re_path(r'^admin/', admin.site.urls), 8 | re_path(r'^$', VideoFormView.as_view()), 9 | ] 10 | -------------------------------------------------------------------------------- /test_proj/waterfall.mp4: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/escaped/django-video-encoding/e74f2f6871087bd43cdabd370e9197d6a8805df0/test_proj/waterfall.mp4 -------------------------------------------------------------------------------- /test_proj/wsgi.py: -------------------------------------------------------------------------------- 1 | """ 2 | WSGI config for bla project. 3 | 4 | It exposes the WSGI callable as a module-level variable named ``application``. 5 | 6 | For more information on this file, see 7 | https://docs.djangoproject.com/en/1.8/howto/deployment/wsgi/ 8 | """ 9 | 10 | import os 11 | 12 | from django.core.wsgi import get_wsgi_application 13 | 14 | os.environ.setdefault("DJANGO_SETTINGS_MODULE", "settings") 15 | 16 | application = get_wsgi_application() 17 | -------------------------------------------------------------------------------- /tox.ini: -------------------------------------------------------------------------------- 1 | [gh-actions] 2 | python = 3 | 3.6: py36 4 | 3.7: py37 5 | 3.8: py38 6 | 3.9: py39 7 | 8 | [tox] 9 | skipsdist = True 10 | isolated_build = True 11 | envlist = 12 | py36-{2.2,3.0,3.1} 13 | py37-{2.2,3.0,3.1} 14 | py38-{2.2,3.0,3.1} 15 | py39-{2.2,3.0,3.1} 16 | 17 | [testenv] 18 | skip_install = True 19 | whitelist_externals = 20 | bash 21 | env 22 | grep 23 | deps = 24 | poetry 25 | 2.2: Django>=2.2,<2.3 26 | 3.0: Django>=3.0,<3.1 27 | 3.1: Django>=3.1,<3.2 28 | commands = 29 | # Poetry install automatically install the specific versions from the `poetry.lock` 30 | # file regardless whether a different version is already present or not. 31 | # Since we want to test specific versions of Django, which is installed by tox, 32 | # we need to manually install all other dependencies. 33 | # see here for more information: https://github.com/python-poetry/poetry/issues/1745 34 | bash -c 'poetry export --dev --without-hashes -f requirements.txt | grep -v "^[dD]jango==" > .requirements.txt' 35 | poetry run pip install --no-deps -r .requirements.txt 36 | poetry run pytest --cov-append 37 | coverage report 38 | 39 | -------------------------------------------------------------------------------- /video_encoding/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/escaped/django-video-encoding/e74f2f6871087bd43cdabd370e9197d6a8805df0/video_encoding/__init__.py -------------------------------------------------------------------------------- /video_encoding/admin.py: -------------------------------------------------------------------------------- 1 | from django.contrib.contenttypes import admin 2 | 3 | from .models import Format 4 | 5 | 6 | class FormatInline(admin.GenericTabularInline): 7 | model = Format 8 | fields = ('format', 'progress', 'file', 'width', 'height', 'duration') 9 | readonly_fields = fields 10 | extra = 0 11 | max_num = 0 12 | 13 | def has_add_permission(self, *args, **kwargs): 14 | return False 15 | 16 | def has_delete_permission(self, *args, **kwargs): 17 | return False 18 | -------------------------------------------------------------------------------- /video_encoding/backends/__init__.py: -------------------------------------------------------------------------------- 1 | from django.core.exceptions import ImproperlyConfigured 2 | from django.utils.module_loading import import_string 3 | from django.utils.translation import ugettext_lazy as _ 4 | 5 | 6 | def get_backend_class(): 7 | from ..config import settings 8 | 9 | try: 10 | cls = import_string(settings.VIDEO_ENCODING_BACKEND) 11 | except ImportError as e: 12 | raise ImproperlyConfigured( 13 | _("Cannot retrieve backend '{}'. Error: '{}'.").format( 14 | settings.VIDEO_ENCODING_BACKEND, e 15 | ) 16 | ) 17 | return cls 18 | 19 | 20 | def get_backend(): 21 | from ..config import settings 22 | 23 | cls = get_backend_class() 24 | return cls(**settings.VIDEO_ENCODING_BACKEND_PARAMS) 25 | -------------------------------------------------------------------------------- /video_encoding/backends/base.py: -------------------------------------------------------------------------------- 1 | import abc 2 | from typing import Dict, Generator, List, Union 3 | 4 | from django.core import checks 5 | 6 | 7 | class BaseEncodingBackend(metaclass=abc.ABCMeta): 8 | # used as key to get all defined formats from `VIDEO_ENCODING_FORMATS` 9 | name = 'undefined' 10 | 11 | @classmethod 12 | def check(cls) -> List[checks.Error]: 13 | return [] 14 | 15 | @abc.abstractmethod 16 | def encode( 17 | self, source_path: str, target_path: str, params: List[str] 18 | ) -> Generator[float, None, None]: # pragma: no cover 19 | """ 20 | Encode a video. 21 | 22 | All encoder specific options are passed in using `params`. 23 | """ 24 | 25 | @abc.abstractmethod 26 | def get_media_info( 27 | self, video_path: str 28 | ) -> Dict[str, Union[int, float]]: # pragma: no cover 29 | """ 30 | Return duration, width and height of the video. 31 | """ 32 | 33 | @abc.abstractmethod 34 | def get_thumbnail( 35 | self, video_path: str, at_time: float = 0.5 36 | ) -> str: # pragma: no cover 37 | """ 38 | Extract an image from a video and return its path. 39 | 40 | If the requested thumbnail is not within the duration of the video 41 | an `InvalidTimeError` is thrown. 42 | """ 43 | -------------------------------------------------------------------------------- /video_encoding/backends/ffmpeg.py: -------------------------------------------------------------------------------- 1 | import io 2 | import json 3 | import logging 4 | import os 5 | import re 6 | import subprocess 7 | import tempfile 8 | from shutil import which 9 | from typing import Dict, Generator, List, Union 10 | 11 | from django.core import checks 12 | 13 | from .. import exceptions 14 | from ..config import settings 15 | from .base import BaseEncodingBackend 16 | 17 | logger = logging.getLogger(__name__) 18 | 19 | # regex to extract the progress (time) from ffmpeg 20 | RE_TIMECODE = re.compile(r'time=(\d+:\d+:\d+.\d+) ') 21 | 22 | 23 | class FFmpegBackend(BaseEncodingBackend): 24 | name = 'FFmpeg' 25 | 26 | def __init__(self) -> None: 27 | self.params: List[str] = [ 28 | '-threads', 29 | str(settings.VIDEO_ENCODING_THREADS), 30 | '-y', # overwrite temporary created file 31 | '-strict', 32 | '-2', # support aac codec (which is experimental) 33 | ] 34 | 35 | self.ffmpeg_path: str = getattr( 36 | settings, 'VIDEO_ENCODING_FFMPEG_PATH', which('ffmpeg') 37 | ) 38 | self.ffprobe_path: str = getattr( 39 | settings, 'VIDEO_ENCODING_FFPROBE_PATH', which('ffprobe') 40 | ) 41 | 42 | if not self.ffmpeg_path: 43 | raise exceptions.FFmpegError( 44 | "ffmpeg binary not found: {}".format(self.ffmpeg_path or '') 45 | ) 46 | 47 | if not self.ffprobe_path: 48 | raise exceptions.FFmpegError( 49 | "ffprobe binary not found: {}".format(self.ffmpeg_path or '') 50 | ) 51 | 52 | @classmethod 53 | def check(cls) -> List[checks.Error]: 54 | errors = super(FFmpegBackend, cls).check() 55 | try: 56 | FFmpegBackend() 57 | except exceptions.FFmpegError as e: 58 | errors.append( 59 | checks.Error( 60 | e.msg, 61 | hint="Please install ffmpeg.", 62 | obj=cls, 63 | id='video_conversion.E001', 64 | ) 65 | ) 66 | return errors 67 | 68 | def _spawn(self, cmd: List[str]) -> subprocess.Popen: 69 | try: 70 | return subprocess.Popen( 71 | cmd, 72 | shell=False, 73 | stderr=subprocess.PIPE, # ffmpeg reports live stats to stderr 74 | universal_newlines=False, # stderr will return bytes 75 | ) 76 | except OSError as e: 77 | raise exceptions.FFmpegError('Error while running ffmpeg binary') from e 78 | 79 | def encode( 80 | self, source_path: str, target_path: str, params: List[str] 81 | ) -> Generator[float, None, None]: 82 | """ 83 | Encode a video. 84 | 85 | All encoder specific options are passed in using `params`. 86 | """ 87 | total_time = self.get_media_info(source_path)['duration'] 88 | 89 | cmd = [self.ffmpeg_path, '-i', source_path, *self.params, *params, target_path] 90 | process = self._spawn(cmd) 91 | # ffmpeg write the progress to stderr 92 | # each line is either terminated by \n or \r 93 | reader = io.TextIOWrapper(process.stderr, newline=None) # type: ignore 94 | 95 | # update progress 96 | while process.poll() is None: # is process terminated yet? 97 | line = reader.readline() 98 | 99 | try: 100 | # format 00:00:00.00 101 | time_str = RE_TIMECODE.findall(line)[0] 102 | except IndexError: 103 | continue 104 | 105 | # convert time to seconds 106 | time: float = 0 107 | for part in time_str.split(':'): 108 | time = 60 * time + float(part) 109 | 110 | percent = round(time / total_time, 2) 111 | logger.debug('yield {}%'.format(percent)) 112 | yield percent 113 | 114 | if os.path.getsize(target_path) == 0: 115 | raise exceptions.FFmpegError("File size of generated file is 0") 116 | 117 | if process.returncode != 0: 118 | raise exceptions.FFmpegError( 119 | "`{}` exited with code {:d}".format( 120 | ' '.join(map(str, process.args)), process.returncode 121 | ) 122 | ) 123 | 124 | yield 100 125 | 126 | def _parse_media_info(self, data: bytes) -> Dict: 127 | media_info = json.loads(data) 128 | media_info['video'] = [ 129 | stream 130 | for stream in media_info['streams'] 131 | if stream['codec_type'] == 'video' 132 | ] 133 | media_info['audio'] = [ 134 | stream 135 | for stream in media_info['streams'] 136 | if stream['codec_type'] == 'audio' 137 | ] 138 | media_info['subtitle'] = [ 139 | stream 140 | for stream in media_info['streams'] 141 | if stream['codec_type'] == 'subtitle' 142 | ] 143 | del media_info['streams'] 144 | return media_info 145 | 146 | def get_media_info(self, video_path: str) -> Dict[str, Union[int, float]]: 147 | """ 148 | Return information about the given video. 149 | """ 150 | cmd = [self.ffprobe_path, '-i', video_path] 151 | cmd.extend(['-hide_banner', '-loglevel', 'warning']) 152 | cmd.extend(['-print_format', 'json']) 153 | cmd.extend(['-show_format', '-show_streams']) 154 | 155 | stdout = subprocess.check_output(cmd) 156 | media_info = self._parse_media_info(stdout) 157 | 158 | return { 159 | 'duration': float(media_info['format']['duration']), 160 | 'width': int(media_info['video'][0]['width']), 161 | 'height': int(media_info['video'][0]['height']), 162 | } 163 | 164 | def get_thumbnail(self, video_path: str, at_time: float = 0.5) -> str: 165 | """ 166 | Extract an image from a video and return its path. 167 | 168 | If the requested thumbnail is not within the duration of the video 169 | an `InvalidTimeError` is thrown. 170 | """ 171 | filename = os.path.basename(video_path) 172 | filename, __ = os.path.splitext(filename) 173 | _, image_path = tempfile.mkstemp(suffix='_{}.jpg'.format(filename)) 174 | 175 | video_duration = self.get_media_info(video_path)['duration'] 176 | if at_time > video_duration: 177 | raise exceptions.InvalidTimeError() 178 | thumbnail_time = at_time 179 | 180 | cmd = [self.ffmpeg_path, '-i', video_path, '-vframes', '1'] 181 | cmd.extend(['-ss', str(thumbnail_time), '-y', image_path]) 182 | 183 | subprocess.check_call(cmd) 184 | 185 | if not os.path.getsize(image_path): 186 | # we somehow failed to generate thumbnail 187 | os.unlink(image_path) 188 | raise exceptions.InvalidTimeError() 189 | 190 | return image_path 191 | -------------------------------------------------------------------------------- /video_encoding/config.py: -------------------------------------------------------------------------------- 1 | from appconf import AppConf 2 | from django.conf import settings # NOQA 3 | 4 | 5 | class VideoEncodingAppConf(AppConf): 6 | THREADS = 1 7 | PROGRESS_UPDATE = 30 8 | BACKEND = 'video_encoding.backends.ffmpeg.FFmpegBackend' 9 | BACKEND_PARAMS = {} # type: ignore 10 | FORMATS = { 11 | 'FFmpeg': [ 12 | { 13 | 'name': 'webm_sd', 14 | 'extension': 'webm', 15 | 'params': [ 16 | '-b:v', 17 | '1000k', 18 | '-maxrate', 19 | '1000k', 20 | '-bufsize', 21 | '2000k', 22 | '-codec:v', 23 | 'libvpx', 24 | '-r', 25 | '30', 26 | '-vf', 27 | 'scale=-1:480', 28 | '-qmin', 29 | '10', 30 | '-qmax', 31 | '42', 32 | '-codec:a', 33 | 'libvorbis', 34 | '-b:a', 35 | '128k', 36 | '-f', 37 | 'webm', 38 | ], 39 | }, 40 | { 41 | 'name': 'webm_hd', 42 | 'extension': 'webm', 43 | 'params': [ 44 | '-codec:v', 45 | 'libvpx', 46 | '-b:v', 47 | '3000k', 48 | '-maxrate', 49 | '3000k', 50 | '-bufsize', 51 | '6000k', 52 | '-vf', 53 | 'scale=-1:720', 54 | '-qmin', 55 | '11', 56 | '-qmax', 57 | '51', 58 | '-acodec', 59 | 'libvorbis', 60 | '-b:a', 61 | '128k', 62 | '-f', 63 | 'webm', 64 | ], 65 | }, 66 | { 67 | 'name': 'mp4_sd', 68 | 'extension': 'mp4', 69 | 'params': [ 70 | '-codec:v', 71 | 'libx264', 72 | '-crf', 73 | '20', 74 | '-preset', 75 | 'medium', 76 | '-b:v', 77 | '1000k', 78 | '-maxrate', 79 | '1000k', 80 | '-bufsize', 81 | '2000k', 82 | '-vf', 83 | 'scale=-2:480', # http://superuser.com/a/776254 84 | '-codec:a', 85 | 'aac', 86 | '-b:a', 87 | '128k', 88 | '-strict', 89 | '-2', 90 | ], 91 | }, 92 | { 93 | 'name': 'mp4_hd', 94 | 'extension': 'mp4', 95 | 'params': [ 96 | '-codec:v', 97 | 'libx264', 98 | '-crf', 99 | '20', 100 | '-preset', 101 | 'medium', 102 | '-b:v', 103 | '3000k', 104 | '-maxrate', 105 | '3000k', 106 | '-bufsize', 107 | '6000k', 108 | '-vf', 109 | 'scale=-2:720', 110 | '-codec:a', 111 | 'aac', 112 | '-b:a', 113 | '128k', 114 | '-strict', 115 | '-2', 116 | ], 117 | }, 118 | ] 119 | } 120 | -------------------------------------------------------------------------------- /video_encoding/exceptions.py: -------------------------------------------------------------------------------- 1 | class VideoEncodingError(Exception): 2 | pass 3 | 4 | 5 | class FFmpegError(VideoEncodingError): 6 | def __init__(self, *args, **kwargs): 7 | self.msg = args[0] 8 | super(VideoEncodingError, self).__init__(*args, **kwargs) 9 | 10 | 11 | class InvalidTimeError(VideoEncodingError): 12 | pass 13 | -------------------------------------------------------------------------------- /video_encoding/fields.py: -------------------------------------------------------------------------------- 1 | from django.db.models.fields.files import FieldFile, ImageField, ImageFileDescriptor 2 | from django.utils.translation import gettext as _ 3 | 4 | from .backends import get_backend_class 5 | from .files import VideoFile 6 | 7 | 8 | class VideoFileDescriptor(ImageFileDescriptor): 9 | pass 10 | 11 | 12 | class VideoFieldFile(VideoFile, FieldFile): 13 | def delete(self, save=True): 14 | # Clear the video info cache 15 | if hasattr(self, '_info_cache'): 16 | del self._info_cache 17 | super(VideoFieldFile, self).delete(save=save) 18 | 19 | 20 | class VideoField(ImageField): 21 | attr_class = VideoFieldFile 22 | descriptor_class = VideoFileDescriptor 23 | description = _("Video") 24 | 25 | def __init__(self, verbose_name=None, name=None, duration_field=None, **kwargs): 26 | self.duration_field = duration_field 27 | super(VideoField, self).__init__(verbose_name, name, **kwargs) 28 | 29 | def check(self, **kwargs): 30 | errors = super(ImageField, self).check(**kwargs) 31 | errors.extend(self._check_backend()) 32 | return errors 33 | 34 | def _check_backend(self): 35 | backend = get_backend_class() 36 | return backend.check() 37 | 38 | def to_python(self, data): 39 | # use FileField method 40 | return super(ImageField, self).to_python(data) 41 | 42 | def update_dimension_fields(self, instance, force=False, *args, **kwargs): 43 | _file = getattr(instance, self.attname) 44 | 45 | # we need a real file 46 | if not _file._committed: 47 | return 48 | 49 | # write `width` and `height` 50 | super(VideoField, self).update_dimension_fields( 51 | instance, force, *args, **kwargs 52 | ) 53 | if not self.duration_field: 54 | return 55 | 56 | # Nothing to update if we have no file and not being forced to update. 57 | if not _file and not force: 58 | return 59 | if getattr(instance, self.duration_field) and not force: 60 | return 61 | 62 | # get duration if file is defined 63 | duration = _file.duration if _file else None 64 | 65 | # update duration 66 | setattr(instance, self.duration_field, duration) 67 | 68 | def formfield(self, **kwargs): 69 | # use normal FileFieldWidget for now 70 | return super(ImageField, self).formfield(**kwargs) 71 | -------------------------------------------------------------------------------- /video_encoding/files.py: -------------------------------------------------------------------------------- 1 | from django.core.files import File 2 | 3 | from .backends import get_backend 4 | from .utils import get_local_path 5 | 6 | 7 | class VideoFile(File): 8 | """ 9 | A mixin for use alongside django.core.files.base.File, which provides 10 | additional features for dealing with videos. 11 | """ 12 | 13 | def _get_width(self): 14 | """ 15 | Returns video width in pixels. 16 | """ 17 | return self._get_video_info().get('width', 0) 18 | 19 | width = property(_get_width) 20 | 21 | def _get_height(self): 22 | """ 23 | Returns video height in pixels. 24 | """ 25 | return self._get_video_info().get('height', 0) 26 | 27 | height = property(_get_height) 28 | 29 | def _get_duration(self): 30 | """ 31 | Returns duration in seconds. 32 | """ 33 | return self._get_video_info().get('duration', 0) 34 | 35 | duration = property(_get_duration) 36 | 37 | def _get_video_info(self): 38 | """ 39 | Returns basic information about the video as dictionary. 40 | """ 41 | if not hasattr(self, '_info_cache'): 42 | encoding_backend = get_backend() 43 | 44 | with get_local_path(self) as local_path: 45 | info_cache = encoding_backend.get_media_info(local_path) 46 | 47 | self._info_cache = info_cache 48 | 49 | return self._info_cache 50 | -------------------------------------------------------------------------------- /video_encoding/manager.py: -------------------------------------------------------------------------------- 1 | from django.db.models import Manager 2 | from django.db.models.query import QuerySet 3 | 4 | 5 | class FormatQuerySet(QuerySet): 6 | def in_progress(self): 7 | return self.filter(progress__lt=100) 8 | 9 | def complete(self): 10 | return self.filter(progress=100) 11 | 12 | 13 | class FormatManager(Manager.from_queryset(FormatQuerySet)): # type: ignore 14 | use_for_related_fields = True 15 | -------------------------------------------------------------------------------- /video_encoding/migrations/0001_initial.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | from __future__ import unicode_literals 3 | 4 | from django.db import migrations, models 5 | 6 | import video_encoding.fields 7 | import video_encoding.models 8 | 9 | 10 | class Migration(migrations.Migration): 11 | 12 | dependencies = [ 13 | ('contenttypes', '0002_remove_content_type_name'), 14 | ] 15 | 16 | operations = [ 17 | migrations.CreateModel( 18 | name='Format', 19 | fields=[ 20 | ( 21 | 'id', 22 | models.AutoField( 23 | serialize=False, 24 | verbose_name='ID', 25 | primary_key=True, 26 | auto_created=True, 27 | ), 28 | ), 29 | ('object_id', models.PositiveIntegerField()), 30 | ('field_name', models.CharField(max_length=255)), 31 | ( 32 | 'progress', 33 | models.PositiveSmallIntegerField( 34 | verbose_name='Progress', default=0 35 | ), 36 | ), 37 | ('format', models.CharField(verbose_name='Format', max_length=255)), 38 | ( 39 | 'file', 40 | video_encoding.fields.VideoField( 41 | height_field='height', 42 | verbose_name='File', 43 | width_field='width', 44 | max_length=2048, 45 | upload_to=video_encoding.models.upload_format_to, 46 | ), 47 | ), 48 | ('width', models.PositiveIntegerField(verbose_name='Width', null=True)), 49 | ( 50 | 'height', 51 | models.PositiveIntegerField(verbose_name='Height', null=True), 52 | ), 53 | ( 54 | 'duration', 55 | models.PositiveIntegerField(verbose_name='Duration (s)', null=True), 56 | ), 57 | ( 58 | 'content_type', 59 | models.ForeignKey( 60 | to='contenttypes.ContentType', on_delete=models.CASCADE 61 | ), 62 | ), 63 | ], 64 | options={ 65 | 'verbose_name': 'Format', 66 | 'verbose_name_plural': 'Formats', 67 | }, 68 | ), 69 | ] 70 | -------------------------------------------------------------------------------- /video_encoding/migrations/0002_update_field_definitions.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 2.1.3 on 2018-11-16 00:48 2 | 3 | import django.db.models.deletion 4 | from django.db import migrations, models 5 | 6 | import video_encoding.fields 7 | import video_encoding.models 8 | 9 | 10 | class Migration(migrations.Migration): 11 | 12 | dependencies = [ 13 | ('video_encoding', '0001_initial'), 14 | ] 15 | 16 | operations = [ 17 | migrations.AlterField( 18 | model_name='format', 19 | name='content_type', 20 | field=models.ForeignKey( 21 | editable=False, 22 | on_delete=django.db.models.deletion.CASCADE, 23 | to='contenttypes.ContentType', 24 | ), 25 | ), 26 | migrations.AlterField( 27 | model_name='format', 28 | name='duration', 29 | field=models.PositiveIntegerField( 30 | editable=False, null=True, verbose_name='Duration (s)' 31 | ), 32 | ), 33 | migrations.AlterField( 34 | model_name='format', 35 | name='file', 36 | field=video_encoding.fields.VideoField( 37 | editable=False, 38 | height_field='height', 39 | max_length=2048, 40 | upload_to=video_encoding.models.upload_format_to, 41 | verbose_name='File', 42 | width_field='width', 43 | ), 44 | ), 45 | migrations.AlterField( 46 | model_name='format', 47 | name='format', 48 | field=models.CharField( 49 | editable=False, max_length=255, verbose_name='Format' 50 | ), 51 | ), 52 | migrations.AlterField( 53 | model_name='format', 54 | name='height', 55 | field=models.PositiveIntegerField( 56 | editable=False, null=True, verbose_name='Height' 57 | ), 58 | ), 59 | migrations.AlterField( 60 | model_name='format', 61 | name='object_id', 62 | field=models.PositiveIntegerField(editable=False), 63 | ), 64 | migrations.AlterField( 65 | model_name='format', 66 | name='progress', 67 | field=models.PositiveSmallIntegerField( 68 | default=0, editable=False, verbose_name='Progress' 69 | ), 70 | ), 71 | migrations.AlterField( 72 | model_name='format', 73 | name='width', 74 | field=models.PositiveIntegerField( 75 | editable=False, null=True, verbose_name='Width' 76 | ), 77 | ), 78 | ] 79 | -------------------------------------------------------------------------------- /video_encoding/migrations/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/escaped/django-video-encoding/e74f2f6871087bd43cdabd370e9197d6a8805df0/video_encoding/migrations/__init__.py -------------------------------------------------------------------------------- /video_encoding/models.py: -------------------------------------------------------------------------------- 1 | from os.path import splitext 2 | 3 | from django.contrib.contenttypes.fields import GenericForeignKey 4 | from django.contrib.contenttypes.models import ContentType 5 | from django.db import models 6 | from django.utils.translation import gettext_lazy as _ 7 | 8 | from .fields import VideoField 9 | from .manager import FormatManager 10 | 11 | 12 | def upload_format_to(i, f): 13 | return 'formats/%s/%s%s' % ( 14 | i.format, 15 | splitext(getattr(i.video, i.field_name).name)[0], # keep path 16 | splitext(f)[1].lower(), 17 | ) 18 | 19 | 20 | class Format(models.Model): 21 | object_id = models.PositiveIntegerField( 22 | editable=False, 23 | ) 24 | content_type = models.ForeignKey( 25 | ContentType, editable=False, on_delete=models.CASCADE 26 | ) 27 | video = GenericForeignKey() 28 | field_name = models.CharField( 29 | max_length=255, 30 | ) 31 | 32 | progress = models.PositiveSmallIntegerField( 33 | default=0, 34 | editable=False, 35 | verbose_name=_("Progress"), 36 | ) 37 | format = models.CharField( # noqa: A003 38 | max_length=255, 39 | editable=False, 40 | verbose_name=_("Format"), 41 | ) 42 | file = VideoField( 43 | duration_field='duration', 44 | editable=False, 45 | max_length=2048, 46 | upload_to=upload_format_to, 47 | verbose_name=_("File"), 48 | width_field='width', 49 | height_field='height', 50 | ) 51 | width = models.PositiveIntegerField( 52 | editable=False, 53 | null=True, 54 | verbose_name=_("Width"), 55 | ) 56 | height = models.PositiveIntegerField( 57 | editable=False, 58 | null=True, 59 | verbose_name=_("Height"), 60 | ) 61 | duration = models.PositiveIntegerField( 62 | editable=False, 63 | null=True, 64 | verbose_name=_("Duration (s)"), 65 | ) 66 | 67 | objects = FormatManager() 68 | 69 | class Meta: 70 | verbose_name = _("Format") 71 | verbose_name_plural = _("Formats") 72 | 73 | def __str__(self): 74 | return '{} ({:d}%)'.format(self.file.name, self.progress) 75 | 76 | def unicode(self): 77 | return self.__str__() 78 | 79 | def update_progress(self, percent, commit=True): 80 | if 0 > percent > 100: 81 | raise ValueError("Invalid percent value.") 82 | 83 | self.progress = percent 84 | if commit: 85 | self.save() 86 | 87 | def reset_progress(self, commit=True): 88 | self.percent = 0 89 | if commit: 90 | self.save() 91 | -------------------------------------------------------------------------------- /video_encoding/signals.py: -------------------------------------------------------------------------------- 1 | import enum 2 | 3 | from django.dispatch import Signal 4 | 5 | 6 | class ConversionResult(enum.Enum): 7 | SUCCEEDED = "success" 8 | FAILED = "failed" 9 | SKIPPED = "skipped" 10 | 11 | 12 | encoding_started = Signal() 13 | encoding_finished = Signal() 14 | 15 | format_started = Signal() 16 | format_finished = Signal() 17 | -------------------------------------------------------------------------------- /video_encoding/tasks.py: -------------------------------------------------------------------------------- 1 | import os 2 | import tempfile 3 | 4 | from django.apps import apps 5 | from django.contrib.contenttypes.models import ContentType 6 | from django.core.files import File 7 | 8 | from . import signals 9 | from .backends import get_backend 10 | from .backends.base import BaseEncodingBackend 11 | from .config import settings 12 | from .exceptions import VideoEncodingError 13 | from .fields import VideoField 14 | from .models import Format 15 | from .utils import get_local_path 16 | 17 | 18 | def convert_all_videos(app_label, model_name, object_pk): 19 | """ 20 | Automatically converts all videos of a given instance. 21 | """ 22 | # get instance 23 | model_class = apps.get_model(app_label=app_label, model_name=model_name) 24 | instance = model_class.objects.get(pk=object_pk) 25 | 26 | # search for `VideoFields` 27 | fields = instance._meta.fields 28 | for field in fields: 29 | if isinstance(field, VideoField): 30 | if not getattr(instance, field.name): 31 | # ignore empty fields 32 | continue 33 | 34 | # trigger conversion 35 | fieldfile = getattr(instance, field.name) 36 | convert_video(fieldfile) 37 | 38 | 39 | def convert_video(fieldfile, force=False): 40 | """ 41 | Converts a given video file into all defined formats. 42 | """ 43 | instance = fieldfile.instance 44 | field = fieldfile.field 45 | 46 | with get_local_path(fieldfile) as source_path: 47 | encoding_backend = get_backend() 48 | 49 | signals.encoding_started.send(instance.__class__, instance=instance) 50 | for options in settings.VIDEO_ENCODING_FORMATS[encoding_backend.name]: 51 | video_format, created = Format.objects.get_or_create( 52 | object_id=instance.pk, 53 | content_type=ContentType.objects.get_for_model(instance), 54 | field_name=field.name, 55 | format=options['name'], 56 | ) 57 | signals.format_started.send(Format, instance=instance, format=video_format) 58 | 59 | # do not reencode if not requested 60 | if video_format.file and not force: 61 | signals.format_finished.send( 62 | Format, 63 | instance=instance, 64 | format=video_format, 65 | result=signals.ConversionResult.SKIPPED, 66 | ) 67 | continue 68 | 69 | try: 70 | _encode(source_path, video_format, encoding_backend, options) 71 | except VideoEncodingError: 72 | signals.format_finished.send( 73 | Format, 74 | instance=instance, 75 | format=video_format, 76 | result=signals.ConversionResult.FAILED, 77 | ) 78 | # TODO handle with more care 79 | video_format.delete() 80 | continue 81 | signals.format_finished.send( 82 | Format, 83 | instance=instance, 84 | format=video_format, 85 | result=signals.ConversionResult.SUCCEEDED, 86 | ) 87 | signals.encoding_finished.send(instance.__class__, instance=instance) 88 | 89 | 90 | def _encode( 91 | source_path: str, 92 | video_format: Format, 93 | encoding_backend: BaseEncodingBackend, 94 | options: dict, 95 | ) -> None: 96 | """ 97 | Encode video and continously report encoding progress. 98 | """ 99 | # TODO do not upscale videos 100 | # TODO move logic to Format model 101 | 102 | with tempfile.NamedTemporaryFile( 103 | suffix='_{name}.{extension}'.format(**options) 104 | ) as file_handler: 105 | target_path = file_handler.name 106 | 107 | # set progress to 0 108 | video_format.reset_progress() 109 | 110 | encoding = encoding_backend.encode(source_path, target_path, options['params']) 111 | while encoding: 112 | try: 113 | progress = next(encoding) 114 | except StopIteration: 115 | break 116 | video_format.update_progress(progress) 117 | 118 | # save encoded file 119 | filename = os.path.basename(source_path) 120 | # TODO remove existing file? 121 | video_format.file.save( 122 | '{filename}_{name}.{extension}'.format(filename=filename, **options), 123 | File(open(target_path, mode='rb')), 124 | ) 125 | 126 | video_format.update_progress(100) # now we are ready 127 | -------------------------------------------------------------------------------- /video_encoding/utils.py: -------------------------------------------------------------------------------- 1 | import contextlib 2 | import os 3 | import tempfile 4 | from typing import Generator 5 | 6 | from django.core.files import File 7 | 8 | 9 | @contextlib.contextmanager 10 | def get_local_path(fieldfile: File) -> Generator[str, None, None]: 11 | """ 12 | Get a local file to work with from a file retrieved from a FileField. 13 | """ 14 | if not hasattr(fieldfile, 'storage'): 15 | # Its a local file with no storage abstraction 16 | try: 17 | yield os.path.abspath(fieldfile.path) 18 | except AttributeError: 19 | yield os.path.abspath(fieldfile.name) 20 | else: 21 | storage = fieldfile.storage 22 | try: 23 | # Try to access with path 24 | yield storage.path(fieldfile.path) 25 | except (NotImplementedError, AttributeError): 26 | # Storage doesnt support absolute paths, 27 | # download file to a temp local dir 28 | with tempfile.NamedTemporaryFile(mode="wb", delete=False) as temp_file: 29 | storage_file = storage.open(fieldfile.name, 'rb') 30 | 31 | temp_file.write(storage_file.read()) 32 | temp_file.flush() 33 | yield temp_file.name 34 | --------------------------------------------------------------------------------