├── .github └── workflows │ └── testing.yml ├── .gitignore ├── LICENSE ├── README.md ├── make_pypi_release.sh ├── pyproject.toml ├── tests ├── __init__.py ├── data │ └── bbb_ffprobe.json ├── test_input.py └── test_mediainfo.py └── vcsi ├── VERSION ├── __init__.py └── vcsi.py /.github/workflows/testing.yml: -------------------------------------------------------------------------------- 1 | name: testing 2 | run-name: Testing workflow 3 | on: [ push ] 4 | jobs: 5 | build: 6 | runs-on: ubuntu-latest 7 | strategy: 8 | matrix: 9 | python-version: [ "3.10", "3.11", "3.12", "3.13" ] 10 | steps: 11 | - uses: actions/checkout@v3 12 | - name: Set up Python ${{ matrix.python-version }} 13 | uses: actions/setup-python@v4 14 | with: 15 | python-version: ${{ matrix.python-version }} 16 | - name: Install uv 17 | run: | 18 | pip install uv 19 | - name: Install dependencies 20 | run: | 21 | uv sync 22 | - name: Run pytest 23 | run: | 24 | uv run pytest --cov=vcsi.vcsi -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /packaging 2 | !packaging/arch-vcsi-git/PKGBUILD 3 | !packaging/arch-vcsi/PKGBUILD 4 | /.coverage 5 | /.coveralls.yml 6 | /coverage.xml 7 | __pycache__ 8 | /cover 9 | *.pyc 10 | /.idea 11 | /build 12 | /dist 13 | /vcsi.egg-info 14 | uv.lock 15 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2015-2019 amietn 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | 23 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # vcsi 2 | 3 | ![Build Status](https://github.com/amietn/vcsi/actions/workflows/testing.yml/badge.svg) 4 | [![License](https://img.shields.io/badge/license-MIT-blue.svg)](http://opensource.org/licenses/MIT) 5 | [![PyPI version](https://badge.fury.io/py/vcsi.svg)](https://badge.fury.io/py/vcsi) 6 | 7 | Create video contact sheets. A video contact sheet is an image composed of video capture thumbnails arranged on a grid. 8 | 9 | ## Examples 10 | 11 | ``` 12 | $ vcsi bbb_sunflower_2160p_60fps_normal.mp4 \ 13 | -t \ 14 | -w 830 \ 15 | -g 4x4 \ 16 | --background-color 000000 \ 17 | --metadata-font-color ffffff \ 18 | --end-delay-percent 20 \ 19 | --metadata-font /usr/share/fonts/TTF/DejaVuSans-Bold.ttf 20 | ``` 21 | 22 | ![Example image 1](https://github.com/amietn/vcsi/assets/5566087/4ef4e631-eca6-43d0-8400-89f1bbbda73d) 23 | 24 | ``` 25 | $ vcsi bbb_sunflower_2160p_60fps_normal.mp4 \ 26 | -t \ 27 | -w 830 \ 28 | -g 3x5 \ 29 | --end-delay-percent 20 \ 30 | --timestamp-font /usr/share/fonts/TTF/DejaVuSans.ttf \ 31 | -o output.png 32 | ``` 33 | 34 | ![Example image 2](https://github.com/amietn/vcsi/assets/5566087/5c6e88f3-29af-44dc-b36c-5e493e6d8dee) 35 | 36 | 37 | The above contact sheets were generated from a movie called "Big Buck Bunny". 38 | 39 | ## Installation 40 | 41 | First, install [uv](https://docs.astral.sh/uv/getting-started/installation/). 42 | 43 | 44 | ### uv 45 | 46 | `vcsi` can be installed from PyPi: 47 | 48 | ``` 49 | $ uv tool install vcsi 50 | ``` 51 | 52 | or from local sources: 53 | 54 | ``` 55 | $ uv tool install . 56 | ``` 57 | 58 | ### pip 59 | 60 | ``` 61 | pip install vcsi 62 | ``` 63 | 64 | ### Distribution packages 65 | 66 | vcsi is currently packaged for the following systems: 67 | 68 | | Linux packages | | 69 | | -------------- | --- | 70 | | Arch (AUR) | https://aur.archlinux.org/packages/vcsi/ | 71 | | Arch (AUR, git master) | https://aur.archlinux.org/packages/vcsi-git/ | 72 | | Gentoo | https://packages.gentoo.org/packages/media-video/vcsi | 73 | 74 | Your system is not listed? 75 | 76 | ``` 77 | $ apt-get install ffmpeg 78 | ``` 79 | 80 | Then use the uv installation method above. 81 | 82 | Running Windows? See the note below. 83 | 84 | 85 | ## Note for Windows users 86 | 87 | Download a binary build of ffmpeg from Zeranoe here (e.g. 64bit static): http://ffmpeg.zeranoe.com/builds/ 88 | 89 | Extract the archive and add the `bin` directory to your PATH so that `ffmpeg` and `ffprobe` can be invoked from the command line. 90 | 91 | If you have issues installing numpy with pip, download an already built version of numpy here: http://sourceforge.net/projects/numpy/files/NumPy/ 92 | 93 | 94 | ## Requirements 95 | 96 | Python modules: 97 | 98 | * numpy 99 | * pillow 100 | * jinja2 101 | * texttable 102 | * parsedatetime 103 | 104 | 105 | Must be in your PATH: 106 | 107 | * ffmpeg 108 | * ffprobe 109 | 110 | 111 | ## Usage 112 | 113 | ``` 114 | $ vcsi -h 115 | usage: vcsi [-h] [-o OUTPUT_PATH] [-c CONFIG] 116 | [--start-delay-percent START_DELAY_PERCENT] 117 | [--end-delay-percent END_DELAY_PERCENT] 118 | [--delay-percent DELAY_PERCENT] [--grid-spacing GRID_SPACING] 119 | [--grid-horizontal-spacing GRID_HORIZONTAL_SPACING] 120 | [--grid-vertical-spacing GRID_VERTICAL_SPACING] [-w VCS_WIDTH] 121 | [-g GRID] [-s NUM_SAMPLES] [-t] 122 | [--metadata-font-size METADATA_FONT_SIZE] 123 | [--metadata-font METADATA_FONT] 124 | [--timestamp-font-size TIMESTAMP_FONT_SIZE] 125 | [--timestamp-font TIMESTAMP_FONT] 126 | [--metadata-position METADATA_POSITION] 127 | [--background-color BACKGROUND_COLOR] 128 | [--metadata-font-color METADATA_FONT_COLOR] 129 | [--timestamp-font-color TIMESTAMP_FONT_COLOR] 130 | [--timestamp-background-color TIMESTAMP_BACKGROUND_COLOR] 131 | [--timestamp-border-color TIMESTAMP_BORDER_COLOR] 132 | [--template METADATA_TEMPLATE_PATH] [-m MANUAL_TIMESTAMPS] [-v] 133 | [-a] [-A ACCURATE_DELAY_SECONDS] 134 | [--metadata-margin METADATA_MARGIN] 135 | [--metadata-horizontal-margin METADATA_HORIZONTAL_MARGIN] 136 | [--metadata-vertical-margin METADATA_VERTICAL_MARGIN] 137 | [--timestamp-horizontal-padding TIMESTAMP_HORIZONTAL_PADDING] 138 | [--timestamp-vertical-padding TIMESTAMP_VERTICAL_PADDING] 139 | [--timestamp-horizontal-margin TIMESTAMP_HORIZONTAL_MARGIN] 140 | [--timestamp-vertical-margin TIMESTAMP_VERTICAL_MARGIN] 141 | [--quality IMAGE_QUALITY] [-f IMAGE_FORMAT] 142 | [-T TIMESTAMP_POSITION] [-r] [--timestamp-border-mode] 143 | [--timestamp-border-size TIMESTAMP_BORDER_SIZE] 144 | [--capture-alpha CAPTURE_ALPHA] [--version] 145 | [--list-template-attributes] [--frame-type FRAME_TYPE] 146 | [--interval INTERVAL] [--ignore-errors] [--no-overwrite] 147 | [--exclude-extensions EXCLUDE_EXTENSIONS] [--fast] 148 | [-O THUMBNAIL_OUTPUT_PATH] [-S] 149 | [--timestamp-format TIMESTAMP_FORMAT] 150 | filenames [filenames ...] 151 | 152 | Create a video contact sheet 153 | 154 | positional arguments: 155 | filenames 156 | 157 | optional arguments: 158 | -h, --help show this help message and exit 159 | -o OUTPUT_PATH, --output OUTPUT_PATH 160 | save to output file (default: None) 161 | -c CONFIG, --config CONFIG 162 | Config file to load defaults from (default: 163 | C:\Users\zacke\.config/vcsi.conf) 164 | --start-delay-percent START_DELAY_PERCENT 165 | do not capture frames in the first n percent of total 166 | time (default: 7) 167 | --end-delay-percent END_DELAY_PERCENT 168 | do not capture frames in the last n percent of total 169 | time (default: 7) 170 | --delay-percent DELAY_PERCENT 171 | do not capture frames in the first and last n percent 172 | of total time (default: None) 173 | --grid-spacing GRID_SPACING 174 | number of pixels spacing captures both vertically and 175 | horizontally (default: None) 176 | --grid-horizontal-spacing GRID_HORIZONTAL_SPACING 177 | number of pixels spacing captures horizontally 178 | (default: 5) 179 | --grid-vertical-spacing GRID_VERTICAL_SPACING 180 | number of pixels spacing captures vertically (default: 181 | 5) 182 | -w VCS_WIDTH, --width VCS_WIDTH 183 | width of the generated contact sheet (default: 1500) 184 | -g GRID, --grid GRID display frames on a mxn grid (for example 4x5). The 185 | special value zero (as in 2x0 or 0x5 or 0x0) is only 186 | allowed when combined with --interval or with 187 | --manual. Zero means that the component should be 188 | automatically deduced based on other arguments passed. 189 | (default: 4x4) 190 | -s NUM_SAMPLES, --num-samples NUM_SAMPLES 191 | number of samples (default: None) 192 | -t, --show-timestamp display timestamp for each frame (default: False) 193 | --metadata-font-size METADATA_FONT_SIZE 194 | size of the font used for metadata (default: 16) 195 | --metadata-font METADATA_FONT 196 | TTF font used for metadata (default: 197 | C:/Windows/Fonts/msgothic.ttc) 198 | --timestamp-font-size TIMESTAMP_FONT_SIZE 199 | size of the font used for timestamps (default: 12) 200 | --timestamp-font TIMESTAMP_FONT 201 | TTF font used for timestamps (default: 202 | C:/Windows/Fonts/msgothic.ttc) 203 | --metadata-position METADATA_POSITION 204 | Position of the metadata header. Must be one of 205 | ['top', 'bottom', 'hidden'] (default: top) 206 | --background-color BACKGROUND_COLOR 207 | Color of the background in hexadecimal, for example 208 | AABBCC (default: 000000FF) 209 | --metadata-font-color METADATA_FONT_COLOR 210 | Color of the metadata font in hexadecimal, for example 211 | AABBCC (default: FFFFFFFF) 212 | --timestamp-font-color TIMESTAMP_FONT_COLOR 213 | Color of the timestamp font in hexadecimal, for 214 | example AABBCC (default: FFFFFFFF) 215 | --timestamp-background-color TIMESTAMP_BACKGROUND_COLOR 216 | Color of the timestamp background rectangle in 217 | hexadecimal, for example AABBCC (default: 000000AA) 218 | --timestamp-border-color TIMESTAMP_BORDER_COLOR 219 | Color of the timestamp border in hexadecimal, for 220 | example AABBCC (default: 000000FF) 221 | --template METADATA_TEMPLATE_PATH 222 | Path to metadata template file (default: None) 223 | -m MANUAL_TIMESTAMPS, --manual MANUAL_TIMESTAMPS 224 | Comma-separated list of frame timestamps to use, for 225 | example 1:11:11.111,2:22:22.222 (default: None) 226 | -v, --verbose display verbose messages (default: False) 227 | -a, --accurate Make accurate captures. This capture mode is way 228 | slower than the default one but it helps when 229 | capturing frames from HEVC videos. (default: False) 230 | -A ACCURATE_DELAY_SECONDS, --accurate-delay-seconds ACCURATE_DELAY_SECONDS 231 | Fast skip to N seconds before capture time, then do 232 | accurate capture (decodes N seconds of video before 233 | each capture). This is used with accurate capture mode 234 | only. (default: 1) 235 | --metadata-margin METADATA_MARGIN 236 | Margin (in pixels) in the metadata header. (default: 237 | 10) 238 | --metadata-horizontal-margin METADATA_HORIZONTAL_MARGIN 239 | Horizontal margin (in pixels) in the metadata header. 240 | (default: 10) 241 | --metadata-vertical-margin METADATA_VERTICAL_MARGIN 242 | Vertical margin (in pixels) in the metadata header. 243 | (default: 10) 244 | --timestamp-horizontal-padding TIMESTAMP_HORIZONTAL_PADDING 245 | Horizontal padding (in pixels) for timestamps. 246 | (default: 3) 247 | --timestamp-vertical-padding TIMESTAMP_VERTICAL_PADDING 248 | Vertical padding (in pixels) for timestamps. (default: 249 | 1) 250 | --timestamp-horizontal-margin TIMESTAMP_HORIZONTAL_MARGIN 251 | Horizontal margin (in pixels) for timestamps. 252 | (default: 5) 253 | --timestamp-vertical-margin TIMESTAMP_VERTICAL_MARGIN 254 | Vertical margin (in pixels) for timestamps. (default: 255 | 5) 256 | --quality IMAGE_QUALITY 257 | Output image quality. Must be an integer in the range 258 | 0-100. 100 = best quality. (default: 100) 259 | -f IMAGE_FORMAT, --format IMAGE_FORMAT 260 | Output image format. Can be any format supported by 261 | pillow. For example 'png' or 'jpg'. (default: jpg) 262 | -T TIMESTAMP_POSITION, --timestamp-position TIMESTAMP_POSITION 263 | Timestamp position. Must be one of ['north', 'south', 264 | 'east', 'west', 'ne', 'nw', 'se', 'sw', 'center']. 265 | (default: TimestampPosition.se) 266 | -r, --recursive Process every file in the specified directory 267 | recursively. (default: False) 268 | --timestamp-border-mode 269 | Draw timestamp text with a border instead of the 270 | default rectangle. (default: False) 271 | --timestamp-border-size TIMESTAMP_BORDER_SIZE 272 | Size of the timestamp border in pixels (used only with 273 | --timestamp-border-mode). (default: 1) 274 | --capture-alpha CAPTURE_ALPHA 275 | Alpha channel value for the captures (transparency in 276 | range [0, 255]). Defaults to 255 (opaque) (default: 277 | 255) 278 | --version show program's version number and exit 279 | --list-template-attributes 280 | --frame-type FRAME_TYPE 281 | Frame type passed to ffmpeg 282 | 'select=eq(pict_type,FRAME_TYPE)' filter. Should be 283 | one of ('I', 'B', 'P') or the special type 'key' which 284 | will use the 'select=key' filter instead. (default: 285 | None) 286 | --interval INTERVAL Capture frames at specified interval. Interval format 287 | is any string supported by `parsedatetime`. For 288 | example '5m', '3 minutes 5 seconds', '1 hour 15 min 289 | and 20 sec' etc. (default: None) 290 | --ignore-errors Ignore any error encountered while processing files 291 | recursively and continue to the next file. (default: 292 | False) 293 | --no-overwrite Do not overwrite output file if it already exists, 294 | simply ignore this file and continue processing other 295 | unprocessed files. (default: False) 296 | --exclude-extensions EXCLUDE_EXTENSIONS 297 | Do not process files that end with the given 298 | extensions. (default: []) 299 | --fast Fast mode. Just make a contact sheet as fast as 300 | possible, regardless of output image quality. May mess 301 | up the terminal. (default: False) 302 | -O THUMBNAIL_OUTPUT_PATH, --thumbnail-output THUMBNAIL_OUTPUT_PATH 303 | Save thumbnail files to the specified output 304 | directory. If set, the thumbnail files will not be 305 | deleted after successful creation of the contact 306 | sheet. (default: None) 307 | -S, --actual-size Make thumbnails of actual size. In other words, 308 | thumbnails will have the actual 1:1 size of the video 309 | resolution. (default: False) 310 | --timestamp-format TIMESTAMP_FORMAT 311 | Use specified timestamp format. Replaced values 312 | include: {TIME}, {DURATION}, {THUMBNAIL_NUMBER}, {H} 313 | (hours), {M} (minutes), {S} (seconds), {c} 314 | (centiseconds), {m} (milliseconds), {dH}, {dM}, {dS}, 315 | {dc} and {dm} (same as previous values but for the 316 | total duration). Example format: '{TIME} / 317 | {DURATION}'. Another example: '{THUMBNAIL_NUMBER}'. 318 | Yet another example: '{H}:{M}:{S}.{m} / 319 | {dH}:{dM}:{dS}.{dm}'. (default: {TIME}) 320 | 321 | 322 | ``` 323 | 324 | ## Metadata templates 325 | 326 | `vcsi` now supports metadata templates thanks to jinja2. In order to use custom templates one should use the `--template` argument to specifiy the path to a template file. 327 | 328 | Here is a sample template file: 329 | 330 | ``` 331 | {{filename}} 332 | File size: {{size}} 333 | {% if audio_sample_rate %} 334 | Audio sample rate: {{audio_sample_rate/1000}} KHz 335 | {% endif %} 336 | 337 | {% if audio_bit_rate %} 338 | Audio bitrate: {{audio_bit_rate/1000}} Kbps 339 | {% endif %} 340 | 341 | {{frame_rate}} fps 342 | 343 | Resolution: {{sample_width}}x{{sample_height}} 344 | ``` 345 | 346 | ## Exposed metadata template attributes 347 | 348 | | Attribute name | Description | Example | 349 | | --- | --- | --- | 350 | | size | File size (pretty format) | 128.3 MiB | 351 | | size_bytes | File size (bytes) | 4662788373 | 352 | | filename | File name | video.mkv | 353 | | duration | Duration (pretty format) | 03:07 | 354 | | sample_width | Width of samples (pixels) | 1920 | 355 | | sample_height | Height of samples (pixels) | 1080 | 356 | | display_width | Display width (pixels) | 1920 | 357 | | display_height | Display height (pixels) | 1080 | 358 | | video_codec | Video codec | h264 | 359 | | video_codec_long | Video codec (long name) | H.264 / AVC / MPEG-4 AVC / MPEG-4 part 10 | 360 | | video_bit_rate | Video bitrate | 371006 | 361 | | display_aspect_ratio | Display aspect ratio | 16:9 | 362 | | sample_aspect_ratio | Sample aspect ratio | 1:1 | 363 | | audio_codec | Audio codec | aac | 364 | | audio_codec_long | Audio codec (long name) | AAC (Advanced Audio Coding) | 365 | | audio_sample_rate | Audio sample rate (Hz) | 44100 | 366 | | audio_bit_rate | Audio bit rate | 192000 | 367 | | frame_rate | Frame rate (fps) | 23.974 | 368 | 369 | 370 | ## Testing 371 | 372 | To run the test suite, run: 373 | 374 | ``` 375 | uv run pytest 376 | ``` 377 | 378 | To measure code coverage: 379 | 380 | ``` 381 | uv run pytest --cov=vcsi.vcsi 382 | ``` 383 | 384 | To test Github Actions locally using [act](https://github.com/nektos/act): 385 | 386 | ``` 387 | act push 388 | ``` 389 | -------------------------------------------------------------------------------- /make_pypi_release.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | rm -rf dist 4 | uv build 5 | uv publish 6 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [project] 2 | name = "vcsi" 3 | version = "7.0.16" 4 | description = "Create video contact sheets, thumbnails, screenshots" 5 | authors = [{ name = "Nils Amiet", email = "amietn@foobar.tld" }] 6 | requires-python = ">=3.10" 7 | readme = "README.md" 8 | license = "MIT" 9 | dependencies = [ 10 | "pillow==11.2.1", 11 | "numpy==2.2.6", 12 | "jinja2>=3.1.6,<4", 13 | "texttable>=1.6.7,<2", 14 | "parsedatetime~=2.6", 15 | ] 16 | 17 | [project.scripts] 18 | vcsi = "vcsi.vcsi:main" 19 | 20 | [dependency-groups] 21 | dev = [ 22 | "pytest>=7.3.1,<8", 23 | "pytest-cov>=4.0.0,<5", 24 | ] 25 | 26 | [build-system] 27 | requires = ["hatchling"] 28 | build-backend = "hatchling.build" 29 | -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/amietn/vcsi/d36b1b636073e24983f91f79e3d6303b30619d7f/tests/__init__.py -------------------------------------------------------------------------------- /tests/data/bbb_ffprobe.json: -------------------------------------------------------------------------------- 1 | { 2 | "streams": [ 3 | { 4 | "index": 0, 5 | "codec_name": "h264", 6 | "codec_long_name": "H.264 / AVC / MPEG-4 AVC / MPEG-4 part 10", 7 | "profile": "High", 8 | "codec_type": "video", 9 | "codec_time_base": "1/120", 10 | "codec_tag_string": "avc1", 11 | "codec_tag": "0x31637661", 12 | "width": 1920, 13 | "height": 1080, 14 | "has_b_frames": 2, 15 | "sample_aspect_ratio": "1:1", 16 | "display_aspect_ratio": "16:9", 17 | "pix_fmt": "yuv420p", 18 | "level": 42, 19 | "chroma_location": "left", 20 | "refs": 4, 21 | "is_avc": "1", 22 | "nal_length_size": "4", 23 | "r_frame_rate": "60/1", 24 | "avg_frame_rate": "60/1", 25 | "time_base": "1/60000", 26 | "start_pts": 2000, 27 | "start_time": "0.033333", 28 | "duration_ts": 38072000, 29 | "duration": "634.533333", 30 | "bit_rate": "4001453", 31 | "bits_per_raw_sample": "8", 32 | "nb_frames": "38072", 33 | "disposition": { 34 | "default": 1, 35 | "dub": 0, 36 | "original": 0, 37 | "comment": 0, 38 | "lyrics": 0, 39 | "karaoke": 0, 40 | "forced": 0, 41 | "hearing_impaired": 0, 42 | "visual_impaired": 0, 43 | "clean_effects": 0, 44 | "attached_pic": 0 45 | }, 46 | "tags": { 47 | "creation_time": "2013-12-16 17:59:32", 48 | "language": "und", 49 | "handler_name": "GPAC ISO Video Handler" 50 | } 51 | }, 52 | { 53 | "index": 1, 54 | "codec_name": "mp3", 55 | "codec_long_name": "MP3 (MPEG audio layer 3)", 56 | "codec_type": "audio", 57 | "codec_time_base": "1/48000", 58 | "codec_tag_string": "mp4a", 59 | "codec_tag": "0x6134706d", 60 | "sample_fmt": "s16p", 61 | "sample_rate": "48000", 62 | "channels": 2, 63 | "channel_layout": "stereo", 64 | "bits_per_sample": 0, 65 | "r_frame_rate": "0/0", 66 | "avg_frame_rate": "0/0", 67 | "time_base": "1/48000", 68 | "start_pts": 0, 69 | "start_time": "0.000000", 70 | "duration_ts": 30441600, 71 | "duration": "634.200000", 72 | "bit_rate": "160000", 73 | "nb_frames": "26425", 74 | "disposition": { 75 | "default": 1, 76 | "dub": 0, 77 | "original": 0, 78 | "comment": 0, 79 | "lyrics": 0, 80 | "karaoke": 0, 81 | "forced": 0, 82 | "hearing_impaired": 0, 83 | "visual_impaired": 0, 84 | "clean_effects": 0, 85 | "attached_pic": 0 86 | }, 87 | "tags": { 88 | "creation_time": "2013-12-16 17:59:37", 89 | "language": "und", 90 | "handler_name": "GPAC ISO Audio Handler" 91 | } 92 | }, 93 | { 94 | "index": 2, 95 | "codec_name": "ac3", 96 | "codec_long_name": "ATSC A/52A (AC-3)", 97 | "codec_type": "audio", 98 | "codec_time_base": "1/48000", 99 | "codec_tag_string": "ac-3", 100 | "codec_tag": "0x332d6361", 101 | "sample_fmt": "fltp", 102 | "sample_rate": "48000", 103 | "channels": 6, 104 | "channel_layout": "5.1(side)", 105 | "bits_per_sample": 0, 106 | "dmix_mode": "-1", 107 | "ltrt_cmixlev": "-1.000000", 108 | "ltrt_surmixlev": "-1.000000", 109 | "loro_cmixlev": "-1.000000", 110 | "loro_surmixlev": "-1.000000", 111 | "r_frame_rate": "0/0", 112 | "avg_frame_rate": "0/0", 113 | "time_base": "1/48000", 114 | "start_pts": 0, 115 | "start_time": "0.000000", 116 | "duration_ts": 30438912, 117 | "duration": "634.144000", 118 | "bit_rate": "320000", 119 | "nb_frames": "19817", 120 | "disposition": { 121 | "default": 1, 122 | "dub": 0, 123 | "original": 0, 124 | "comment": 0, 125 | "lyrics": 0, 126 | "karaoke": 0, 127 | "forced": 0, 128 | "hearing_impaired": 0, 129 | "visual_impaired": 0, 130 | "clean_effects": 0, 131 | "attached_pic": 0 132 | }, 133 | "tags": { 134 | "creation_time": "2013-12-16 17:59:37", 135 | "language": "und", 136 | "handler_name": "GPAC ISO Audio Handler" 137 | } 138 | } 139 | ], 140 | "format": { 141 | "filename": "bbb_sunflower_1080p_60fps_normal.mp4", 142 | "nb_streams": 3, 143 | "nb_programs": 0, 144 | "format_name": "mov,mp4,m4a,3gp,3g2,mj2", 145 | "format_long_name": "QuickTime / MOV", 146 | "start_time": "0.000000", 147 | "duration": "634.533333", 148 | "size": "355856562", 149 | "bit_rate": "4486529", 150 | "probe_score": 100, 151 | "tags": { 152 | "major_brand": "isom", 153 | "minor_version": "1", 154 | "compatible_brands": "isomavc1", 155 | "creation_time": "2013-12-16 17:59:32", 156 | "title": "Big Buck Bunny, Sunflower version", 157 | "artist": "Blender Foundation 2008, Janus Bager Kristensen 2013", 158 | "comment": "Creative Commons Attribution 3.0 - http://bbb3d.renderfarming.net", 159 | "genre": "Animation", 160 | "composer": "Sacha Goedegebure" 161 | } 162 | } 163 | } 164 | -------------------------------------------------------------------------------- /tests/test_input.py: -------------------------------------------------------------------------------- 1 | from argparse import ArgumentTypeError 2 | from unittest.mock import patch, Mock, PropertyMock, MagicMock 3 | 4 | import pytest 5 | 6 | from vcsi.vcsi import Grid, mxn_type, Color, hex_color_type, manual_timestamps, timestamp_position_type, \ 7 | TimestampPosition, comma_separated_string_type, metadata_position_type, cleanup, save_image,\ 8 | compute_timestamp_position, max_line_length, draw_metadata 9 | from vcsi import vcsi 10 | 11 | 12 | def test_grid_default(): 13 | test_grid = mxn_type('4x4') 14 | 15 | assert test_grid.x == 4 16 | assert test_grid.y == 4 17 | 18 | 19 | def test_grid_equality(): 20 | g1 = Grid(4, 4) 21 | g2 = Grid(4, 4) 22 | assert g1 == g2 23 | 24 | 25 | def test_grid_inequality(): 26 | g1 = Grid(4, 4) 27 | g2 = Grid(3, 4) 28 | assert g1 != g2 29 | 30 | 31 | def test_grid_columns_integer(): 32 | pytest.raises(ArgumentTypeError, mxn_type, 'ax4') 33 | 34 | pytest.raises(ArgumentTypeError, mxn_type, '4.1x4') 35 | 36 | 37 | def test_grid_columns_positive(): 38 | pytest.raises(ArgumentTypeError, mxn_type, '-1x4') 39 | 40 | 41 | def test_grid_rows_integer(): 42 | pytest.raises(ArgumentTypeError, mxn_type, '4xa') 43 | 44 | pytest.raises(ArgumentTypeError, mxn_type, '4x4.1') 45 | 46 | 47 | def test_grid_rows_positive(): 48 | pytest.raises(ArgumentTypeError, mxn_type, '4x-1') 49 | 50 | 51 | def test_grid_format(): 52 | pytest.raises(ArgumentTypeError, mxn_type, '') 53 | 54 | pytest.raises(ArgumentTypeError, mxn_type, '4xx4') 55 | 56 | pytest.raises(ArgumentTypeError, mxn_type, '4x1x4') 57 | 58 | pytest.raises(ArgumentTypeError, mxn_type, '4') 59 | 60 | 61 | def test_hex_color_type(): 62 | assert Color(*(0x10, 0x10, 0x10, 0xff)) == hex_color_type("101010") 63 | 64 | assert Color(*(0x10, 0x10, 0x10, 0x00)) == hex_color_type("10101000") 65 | 66 | assert Color(*(0xff, 0xff, 0xff, 0xff)) == hex_color_type("ffffff") 67 | 68 | assert Color(*(0xff, 0xff, 0xff, 0x00)) == hex_color_type("ffffff00") 69 | 70 | pytest.raises(ArgumentTypeError, hex_color_type, "abcdeff") 71 | 72 | pytest.raises(ArgumentTypeError, hex_color_type, "abcdfg") 73 | 74 | 75 | def test_manual_timestamps(): 76 | assert manual_timestamps("1:11:11.111,2:22:22.222") == ["1:11:11.111", "2:22:22.222"] 77 | 78 | pytest.raises(ArgumentTypeError, manual_timestamps, "1:11:a1.111,2:22:b2.222") 79 | 80 | pytest.raises(ArgumentTypeError, manual_timestamps, "1:1:1:1.111,2:2.222") 81 | 82 | assert manual_timestamps("") == [] 83 | 84 | 85 | def test_timestamp_position_type(): 86 | assert timestamp_position_type("north") == TimestampPosition.north 87 | 88 | assert timestamp_position_type("south") != TimestampPosition.north 89 | 90 | pytest.raises(ArgumentTypeError, timestamp_position_type, "whatever") 91 | 92 | 93 | @patch("vcsi.vcsi.parsedatetime") 94 | def test_interval_type(mocked_parsedatatime): 95 | mocked_parsedatatime.return_value = 30 96 | assert mocked_parsedatatime("30 seconds") == 30 97 | 98 | mocked_parsedatatime.assert_called_once_with("30 seconds") 99 | 100 | 101 | def test_comma_separated_string_type(): 102 | assert comma_separated_string_type("a, b, c") == ["a", "b", "c"] 103 | 104 | assert comma_separated_string_type("a b, c") == ["a b", "c"] 105 | 106 | 107 | def test_metadata_position_type(): 108 | assert metadata_position_type("top") == "top" 109 | 110 | assert metadata_position_type("TOP") == "top" 111 | 112 | pytest.raises(ArgumentTypeError, metadata_position_type, "whatever") 113 | 114 | 115 | @patch("vcsi.vcsi.os") 116 | def test_cleanup(mocked_os): 117 | mocked_os.unlink.side_effect = lambda x: True 118 | args = Mock() 119 | args.is_verbose = False 120 | frames = [Mock()] 121 | frames[0].filename = "frame1" 122 | cleanup(frames, args) 123 | 124 | mocked_os.unlink.assert_called_once_with("frame1") 125 | 126 | 127 | @patch("vcsi.vcsi.Image") 128 | def test_save_image(mocked_Image): 129 | args = PropertyMock() 130 | output_path = "whatever" 131 | assert True == save_image(args, mocked_Image, None, output_path) 132 | 133 | mocked_Image.convert().save.assert_called_once() 134 | 135 | 136 | def test_compute_timestamp_position(): 137 | args = PropertyMock() 138 | args.timestamp_horizontal_margin = 10 139 | args.timestamp_vertical_margin = 10 140 | w, h = 1000, 500 141 | text_size = (10, 10) 142 | desired_size = (20, 20) 143 | rectangle_hpadding, rectangle_vpadding = 5, 5 144 | 145 | args.timestamp_position = TimestampPosition.west 146 | assert (((1010, 500.0), (1030, 520.0)) == 147 | compute_timestamp_position(args, w, h, text_size, desired_size, rectangle_hpadding, 148 | rectangle_vpadding)) 149 | 150 | args.timestamp_position = TimestampPosition.north 151 | assert (((1000, 510.0), (1020, 530.0)) == 152 | compute_timestamp_position(args, w, h, text_size, desired_size, rectangle_hpadding, 153 | rectangle_vpadding)) 154 | 155 | args.timestamp_position = None 156 | assert (((990, 490), (1010, 510)) == 157 | compute_timestamp_position(args, w, h, text_size, desired_size, rectangle_hpadding, 158 | rectangle_vpadding)) 159 | 160 | 161 | def test_max_line_length(): 162 | media_info = PropertyMock() 163 | metadata_font = PropertyMock() 164 | metadata_font.getlength.return_value = 40 165 | header_margin = 100 166 | width = 1000 167 | 168 | text = "A long line of text" 169 | assert 19 == max_line_length(media_info, metadata_font, header_margin, width, text) 170 | 171 | text = "A long line of text with a few more words" 172 | assert 41 == max_line_length(media_info, metadata_font, header_margin, width, text) 173 | 174 | text = "A really long line of text with a lots more words" * 100 175 | assert 4900 == max_line_length(media_info, metadata_font, header_margin, width, text) 176 | 177 | text = None 178 | filename = PropertyMock() 179 | type(media_info).filename = filename 180 | # media_info.filename = filename 181 | assert 0 == max_line_length(media_info, metadata_font, header_margin, width, text) 182 | filename.assert_called_once() 183 | 184 | 185 | def test_draw_metadata(): 186 | args = Mock() 187 | draw = Mock() 188 | header_lines = MagicMock() 189 | draw.text.return_value = 0 190 | args.metadata_vertical_margin = 0 191 | header_lines.__iter__ = Mock(return_value=iter(['text1', 'text2'])) 192 | 193 | assert 0 == draw_metadata(draw, args, 194 | header_lines=header_lines, 195 | header_line_height=0, 196 | start_height=0) 197 | draw.text.assert_called() 198 | 199 | 200 | def test_grid(): 201 | assert "2x2" == Grid(2, 2).__str__() 202 | 203 | assert "10x0" == Grid(10, 0).__str__() 204 | 205 | def test_color(): 206 | assert "FFFFFFFF" == Color(255, 255, 255, 255).__str__() 207 | 208 | -------------------------------------------------------------------------------- /tests/test_mediainfo.py: -------------------------------------------------------------------------------- 1 | import json 2 | import argparse 3 | from argparse import ArgumentTypeError 4 | 5 | import pytest 6 | 7 | from vcsi.vcsi import MediaInfo 8 | from vcsi.vcsi import Grid, grid_desired_size 9 | from vcsi.vcsi import timestamp_generator 10 | 11 | 12 | FFPROBE_EXAMPLE_JSON_PATH = "tests/data/bbb_ffprobe.json" 13 | 14 | 15 | class MediaInfoForTest(MediaInfo): 16 | 17 | def __init__(self, json_path): 18 | super(MediaInfoForTest, self).__init__(json_path) 19 | 20 | def probe_media(self, path): 21 | with open(path) as f: 22 | self.ffprobe_dict = json.loads(f.read()) 23 | 24 | 25 | def test_compute_display_resolution(): 26 | mi = MediaInfoForTest(FFPROBE_EXAMPLE_JSON_PATH) 27 | assert mi.display_width == 1920 28 | assert mi.display_height == 1080 29 | 30 | 31 | def test_filename(): 32 | mi = MediaInfoForTest(FFPROBE_EXAMPLE_JSON_PATH) 33 | assert mi.filename == "bbb_sunflower_1080p_60fps_normal.mp4" 34 | 35 | 36 | def test_duration(): 37 | mi = MediaInfoForTest(FFPROBE_EXAMPLE_JSON_PATH) 38 | assert mi.duration_seconds == 634.533333 39 | 40 | 41 | def test_pretty_duration(): 42 | mi = MediaInfoForTest(FFPROBE_EXAMPLE_JSON_PATH) 43 | assert mi.duration == "10:34" 44 | 45 | 46 | def test_size_bytes(): 47 | mi = MediaInfoForTest(FFPROBE_EXAMPLE_JSON_PATH) 48 | assert mi.size_bytes == 355856562 49 | 50 | 51 | def test_size(): 52 | mi = MediaInfoForTest(FFPROBE_EXAMPLE_JSON_PATH) 53 | assert mi.size == "339.4 MiB" 54 | 55 | 56 | def test_template_attributes(): 57 | mi = MediaInfoForTest(FFPROBE_EXAMPLE_JSON_PATH) 58 | attributes = mi.template_attributes() 59 | assert attributes["audio_codec"] == "mp3" 60 | assert attributes["video_codec"] == "h264" 61 | 62 | 63 | def test_grid_desired_size(): 64 | mi = MediaInfoForTest(FFPROBE_EXAMPLE_JSON_PATH) 65 | x = 2 66 | y = 3 67 | grid = Grid(x, y) 68 | width = 800 69 | hmargin = 20 70 | s = grid_desired_size(grid, mi, width=width, horizontal_margin=hmargin) 71 | expected_width = (width - (x-1) * hmargin) / x 72 | 73 | assert s[0] == expected_width 74 | 75 | 76 | def test_desired_size(): 77 | mi = MediaInfoForTest(FFPROBE_EXAMPLE_JSON_PATH) 78 | s = mi.desired_size(width=1280) 79 | assert s[1] == 720 80 | 81 | 82 | def test_timestamps(): 83 | mi = MediaInfoForTest(FFPROBE_EXAMPLE_JSON_PATH) 84 | mi.duration_seconds = 100 85 | start_delay_percent = 7 86 | end_delay_percent = 7 87 | interval = mi.duration_seconds - (start_delay_percent + end_delay_percent) 88 | num_samples = interval - 1 89 | 90 | args = argparse.Namespace() 91 | args.interval = None 92 | args.num_samples = num_samples 93 | args.start_delay_percent = start_delay_percent 94 | args.end_delay_percent = end_delay_percent 95 | 96 | expected_timestamp = start_delay_percent + 1 97 | for t in timestamp_generator(mi, args): 98 | assert int(t[0]) == expected_timestamp 99 | expected_timestamp += 1 100 | 101 | 102 | def test_pretty_duration_centis_limit(): 103 | mi = MediaInfoForTest(FFPROBE_EXAMPLE_JSON_PATH) 104 | mi.duration_seconds = 1.9999 105 | pretty_duration = MediaInfo.pretty_duration(mi.duration_seconds, show_centis=True) 106 | assert pretty_duration == "00:01.99" 107 | 108 | 109 | def test_pretty_duration_millis_limit(): 110 | mi = MediaInfoForTest(FFPROBE_EXAMPLE_JSON_PATH) 111 | mi.duration_seconds = 1.9999 112 | pretty_duration = MediaInfo.pretty_duration(mi.duration_seconds, show_millis=True) 113 | assert pretty_duration == "00:01.999" 114 | 115 | 116 | def test_pretty_to_seconds(): 117 | assert MediaInfo.pretty_to_seconds("1:11:11.111") == 4271.111 118 | 119 | assert MediaInfo.pretty_to_seconds("1:11:11") == 4271 120 | 121 | assert MediaInfo.pretty_to_seconds("1:01:00") == 3660 122 | 123 | pytest.raises(ArgumentTypeError, MediaInfo.pretty_to_seconds, "1:01:01:01:00") 124 | -------------------------------------------------------------------------------- /vcsi/VERSION: -------------------------------------------------------------------------------- 1 | 7.0.16 2 | -------------------------------------------------------------------------------- /vcsi/__init__.py: -------------------------------------------------------------------------------- 1 | def main(): 2 | import vcsi.vcsi 3 | vcsi.vcsi.main() 4 | -------------------------------------------------------------------------------- /vcsi/vcsi.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | 3 | """Create a video contact sheet. 4 | """ 5 | 6 | from __future__ import print_function 7 | 8 | import datetime 9 | import os 10 | import shutil 11 | import subprocess 12 | import sys 13 | from argparse import ArgumentTypeError 14 | from concurrent.futures import ThreadPoolExecutor 15 | from copy import deepcopy 16 | from typing import List, Iterable 17 | from urllib.parse import urlparse 18 | 19 | try: 20 | from subprocess import DEVNULL 21 | except ImportError: 22 | DEVNULL = open(os.devnull, 'wb') 23 | import argparse 24 | import configparser 25 | import json 26 | import math 27 | import tempfile 28 | import textwrap 29 | from collections import namedtuple 30 | from enum import Enum 31 | from glob import glob 32 | from glob import escape 33 | 34 | from PIL import Image, ImageDraw, ImageFont 35 | import numpy 36 | from jinja2 import Template 37 | import texttable 38 | import parsedatetime 39 | 40 | here = os.path.abspath(os.path.dirname(__file__)) 41 | 42 | with open(os.path.join(here, "VERSION")) as f: 43 | VERSION = f.readline().strip() 44 | __version__ = VERSION 45 | __author__ = "Nils Amiet" 46 | 47 | 48 | class Grid(namedtuple('Grid', ['x', 'y'])): 49 | def __str__(self): 50 | return "%sx%s" % (self.x, self.y) 51 | 52 | 53 | class Frame(namedtuple('Frame', ['filename', 'blurriness', 'timestamp', 'avg_color'])): 54 | pass 55 | 56 | 57 | class Color(namedtuple('Color', ['r', 'g', 'b', 'a'])): 58 | def to_hex(self, component): 59 | h = hex(component).replace("0x", "").upper() 60 | return h if len(h) == 2 else "0" + h 61 | 62 | def __str__(self): 63 | return "".join([self.to_hex(x) for x in [self.r, self.g, self.b, self.a]]) 64 | 65 | 66 | TimestampPosition = Enum('TimestampPosition', "north south east west ne nw se sw center") 67 | VALID_TIMESTAMP_POSITIONS = [x.name for x in TimestampPosition] 68 | 69 | DEFAULT_CONFIG_FILE = os.path.join(os.path.expanduser("~"), ".config/vcsi.conf") 70 | DEFAULT_CONFIG_SECTION = "vcsi" 71 | 72 | DEFAULT_METADATA_FONT_SIZE = 16 73 | DEFAULT_TIMESTAMP_FONT_SIZE = 12 74 | 75 | # Defaults 76 | DEFAULT_METADATA_FONT = "/usr/share/fonts/TTF/DejaVuSans-Bold.ttf" 77 | DEFAULT_TIMESTAMP_FONT = "/usr/share/fonts/TTF/DejaVuSans.ttf" 78 | FALLBACK_FONTS = ["/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf", "/Library/Fonts/Arial Unicode.ttf"] 79 | 80 | # Replace defaults on Windows to support unicode/CJK and multiple fallbacks 81 | if os.name == 'nt': 82 | DEFAULT_METADATA_FONT = "C:/Windows/Fonts/msgothic.ttc" 83 | DEFAULT_TIMESTAMP_FONT = "C:/Windows/Fonts/msgothic.ttc" 84 | FALLBACK_FONTS = [ 85 | "C:/Windows/Fonts/simsun.ttc", 86 | "C:/Windows/Fonts/Everson Mono.ttf", 87 | "C:/Windows/Fonts/calibri.ttf", 88 | "C:/Windows/Fonts/arial.ttf" 89 | ] 90 | 91 | DEFAULT_CONTACT_SHEET_WIDTH = 1500 92 | DEFAULT_DELAY_PERCENT = None 93 | DEFAULT_START_DELAY_PERCENT = 7 94 | DEFAULT_END_DELAY_PERCENT = DEFAULT_START_DELAY_PERCENT 95 | DEFAULT_GRID_SPACING = None 96 | DEFAULT_GRID_HORIZONTAL_SPACING = 5 97 | DEFAULT_GRID_VERTICAL_SPACING = DEFAULT_GRID_HORIZONTAL_SPACING 98 | DEFAULT_METADATA_POSITION = "top" 99 | DEFAULT_METADATA_FONT_COLOR = "ffffff" 100 | DEFAULT_BACKGROUND_COLOR = "000000" 101 | DEFAULT_TIMESTAMP_FONT_COLOR = "ffffff" 102 | DEFAULT_TIMESTAMP_BACKGROUND_COLOR = "000000aa" 103 | DEFAULT_TIMESTAMP_BORDER_COLOR = "000000" 104 | DEFAULT_TIMESTAMP_BORDER_SIZE = 1 105 | DEFAULT_ACCURATE_DELAY_SECONDS = 1 106 | DEFAULT_METADATA_MARGIN = 10 107 | DEFAULT_METADATA_HORIZONTAL_MARGIN = DEFAULT_METADATA_MARGIN 108 | DEFAULT_METADATA_VERTICAL_MARGIN = DEFAULT_METADATA_MARGIN 109 | DEFAULT_CAPTURE_ALPHA = 255 110 | DEFAULT_GRID_SIZE = Grid(4, 4) 111 | DEFAULT_TIMESTAMP_HORIZONTAL_PADDING = 3 112 | DEFAULT_TIMESTAMP_VERTICAL_PADDING = 3 113 | DEFAULT_TIMESTAMP_HORIZONTAL_MARGIN = 5 114 | DEFAULT_TIMESTAMP_VERTICAL_MARGIN = 5 115 | DEFAULT_IMAGE_QUALITY = 100 116 | DEFAULT_IMAGE_FORMAT = "jpg" 117 | DEFAULT_TIMESTAMP_POSITION = TimestampPosition.se 118 | DEFAULT_FRAME_TYPE = None 119 | DEFAULT_INTERVAL = None 120 | 121 | 122 | class Config: 123 | metadata_font_size = DEFAULT_METADATA_FONT_SIZE 124 | metadata_font = DEFAULT_METADATA_FONT 125 | timestamp_font_size = DEFAULT_TIMESTAMP_FONT_SIZE 126 | timestamp_font = DEFAULT_TIMESTAMP_FONT 127 | fallback_fonts = FALLBACK_FONTS 128 | contact_sheet_width = DEFAULT_CONTACT_SHEET_WIDTH 129 | delay_percent = DEFAULT_DELAY_PERCENT 130 | start_delay_percent = DEFAULT_START_DELAY_PERCENT 131 | end_delay_percent = DEFAULT_END_DELAY_PERCENT 132 | grid_spacing = DEFAULT_GRID_SPACING 133 | grid_horizontal_spacing = DEFAULT_GRID_HORIZONTAL_SPACING 134 | grid_vertical_spacing = DEFAULT_GRID_VERTICAL_SPACING 135 | metadata_position = DEFAULT_METADATA_POSITION 136 | metadata_font_color = DEFAULT_METADATA_FONT_COLOR 137 | background_color = DEFAULT_BACKGROUND_COLOR 138 | timestamp_font_color = DEFAULT_TIMESTAMP_FONT_COLOR 139 | timestamp_background_color = DEFAULT_TIMESTAMP_BACKGROUND_COLOR 140 | timestamp_border_color = DEFAULT_TIMESTAMP_BORDER_COLOR 141 | timestamp_border_size = DEFAULT_TIMESTAMP_BORDER_SIZE 142 | accurate_delay_seconds = DEFAULT_ACCURATE_DELAY_SECONDS 143 | metadata_margin = DEFAULT_METADATA_MARGIN 144 | metadata_horizontal_margin = DEFAULT_METADATA_HORIZONTAL_MARGIN 145 | metadata_vertical_margin = DEFAULT_METADATA_VERTICAL_MARGIN 146 | capture_alpha = DEFAULT_CAPTURE_ALPHA 147 | grid_size = DEFAULT_GRID_SIZE 148 | timestamp_horizontal_padding = DEFAULT_TIMESTAMP_HORIZONTAL_PADDING 149 | timestamp_vertical_padding = DEFAULT_TIMESTAMP_VERTICAL_PADDING 150 | timestamp_horizontal_margin = DEFAULT_TIMESTAMP_HORIZONTAL_MARGIN 151 | timestamp_vertical_margin = DEFAULT_TIMESTAMP_VERTICAL_MARGIN 152 | quality = DEFAULT_IMAGE_QUALITY 153 | format = DEFAULT_IMAGE_FORMAT 154 | timestamp_position = DEFAULT_TIMESTAMP_POSITION 155 | frame_type = DEFAULT_FRAME_TYPE 156 | interval = DEFAULT_INTERVAL 157 | 158 | @classmethod 159 | def load_configuration(cls, filename=DEFAULT_CONFIG_FILE): 160 | config = configparser.ConfigParser(default_section=DEFAULT_CONFIG_SECTION) 161 | config.read(filename) 162 | 163 | for config_entry in cls.__dict__.keys(): 164 | # skip magic attributes 165 | if config_entry.startswith('__'): 166 | continue 167 | setattr(cls, config_entry, config.get( 168 | DEFAULT_CONFIG_SECTION, 169 | config_entry, 170 | fallback=getattr(cls, config_entry) 171 | )) 172 | # special cases 173 | # fallback_fonts is an array, it's reflected as comma separated list in config file 174 | fallback_fonts = config.get(DEFAULT_CONFIG_SECTION, 'fallback_fonts', fallback=None) 175 | if fallback_fonts: 176 | cls.fallback_fonts = comma_separated_string_type(fallback_fonts) 177 | 178 | 179 | class MediaInfo(object): 180 | """Collect information about a video file 181 | """ 182 | 183 | def __init__(self, path, verbose=False): 184 | self.probe_media(path) 185 | self.find_video_stream() 186 | self.find_audio_stream() 187 | self.compute_display_resolution() 188 | self.compute_format() 189 | self.parse_attributes() 190 | 191 | if verbose: 192 | print(self.filename) 193 | print("%sx%s" % (self.sample_width, self.sample_height)) 194 | print("%sx%s" % (self.display_width, self.display_height)) 195 | print(self.duration) 196 | print(self.size) 197 | 198 | def probe_media(self, path): 199 | """Probe video file using ffprobe 200 | """ 201 | ffprobe_command = [ 202 | "ffprobe", 203 | "-v", "quiet", 204 | "-print_format", "json", 205 | "-show_format", 206 | "-show_streams", 207 | "--", 208 | path 209 | ] 210 | 211 | try: 212 | output = subprocess.check_output(ffprobe_command) 213 | self.ffprobe_dict = json.loads(output.decode("utf-8")) 214 | except FileNotFoundError: 215 | error = "Could not find 'ffprobe' executable. Please make sure ffmpeg/ffprobe is installed and is in your PATH." 216 | error_exit(error) 217 | 218 | def human_readable_size(self, num, suffix='B'): 219 | """Converts a number of bytes to a human readable format 220 | """ 221 | for unit in ['', 'Ki', 'Mi', 'Gi', 'Ti', 'Pi', 'Ei', 'Zi']: 222 | if abs(num) < 1024.0: 223 | return "%3.1f %s%s" % (num, unit, suffix) 224 | num /= 1024.0 225 | return "%.1f %s%s" % (num, 'Yi', suffix) 226 | 227 | def find_video_stream(self): 228 | """Find the first stream which is a video stream 229 | """ 230 | for stream in self.ffprobe_dict["streams"]: 231 | try: 232 | if stream["codec_type"] == "video": 233 | self.video_stream = stream 234 | break 235 | except: 236 | pass 237 | 238 | def find_audio_stream(self): 239 | """Find the first stream which is an audio stream 240 | """ 241 | for stream in self.ffprobe_dict["streams"]: 242 | try: 243 | if stream["codec_type"] == "audio": 244 | self.audio_stream = stream 245 | break 246 | except: 247 | pass 248 | 249 | def compute_display_resolution(self): 250 | """Computes the display resolution. 251 | Some videos have a sample resolution that differs from the display resolution 252 | (non-square pixels), thus the proper display resolution has to be computed. 253 | """ 254 | self.sample_width = int(self.video_stream["width"]) 255 | self.sample_height = int(self.video_stream["height"]) 256 | 257 | # videos recorded with a smartphone may have a "rotate" flag 258 | try: 259 | rotation = int(self.video_stream["tags"]["rotate"]) 260 | except KeyError: 261 | rotation = None 262 | 263 | if rotation in [90, 270]: 264 | # swap width and height 265 | self.sample_width, self.sample_height = self.sample_height, self.sample_width 266 | 267 | sample_aspect_ratio = "1:1" 268 | try: 269 | sample_aspect_ratio = self.video_stream["sample_aspect_ratio"] 270 | except KeyError: 271 | pass 272 | 273 | if sample_aspect_ratio == "1:1": 274 | self.display_width = self.sample_width 275 | self.display_height = self.sample_height 276 | else: 277 | sample_split = sample_aspect_ratio.split(":") 278 | sw = int(sample_split[0]) 279 | sh = int(sample_split[1]) 280 | 281 | self.display_width = int(self.sample_width * sw / sh) 282 | self.display_height = int(self.sample_height) 283 | 284 | if self.display_width == 0: 285 | self.display_width = self.sample_width 286 | 287 | if self.display_height == 0: 288 | self.display_height = self.sample_height 289 | 290 | def compute_format(self): 291 | """Compute duration, size and retrieve filename 292 | """ 293 | format_dict = self.ffprobe_dict["format"] 294 | 295 | try: 296 | # try getting video stream duration first 297 | self.duration_seconds = float(self.video_stream["duration"]) 298 | except (KeyError, AttributeError): 299 | # otherwise fallback to format duration 300 | self.duration_seconds = float(format_dict["duration"]) 301 | 302 | self.duration = MediaInfo.pretty_duration(self.duration_seconds) 303 | 304 | self.filename = os.path.basename(format_dict["filename"]) 305 | 306 | self.size_bytes = int(format_dict["size"]) 307 | self.size = self.human_readable_size(self.size_bytes) 308 | 309 | @staticmethod 310 | def pretty_to_seconds( 311 | pretty_duration): 312 | """Converts pretty printed timestamp to seconds 313 | """ 314 | millis_split = pretty_duration.split(".") 315 | millis = 0 316 | if len(millis_split) == 2: 317 | millis = int(millis_split[1]) 318 | left = millis_split[0] 319 | else: 320 | left = pretty_duration 321 | 322 | left_split = left.split(":") 323 | 324 | if len(left_split) > 3: 325 | e = f"Timestamp {pretty_duration} ill formatted" 326 | raise ArgumentTypeError(e) 327 | 328 | if len(left_split) < 3: 329 | hours = 0 330 | minutes = int(left_split[0]) 331 | seconds = int(left_split[1]) 332 | else: 333 | hours = int(left_split[0]) 334 | minutes = int(left_split[1]) 335 | seconds = int(left_split[2]) 336 | 337 | result = (millis / 1000.0) + seconds + minutes * 60 + hours * 3600 338 | return result 339 | 340 | @staticmethod 341 | def pretty_duration( 342 | seconds, 343 | show_centis=False, 344 | show_millis=False): 345 | """Converts seconds to a human readable time format 346 | """ 347 | hours = int(math.floor(seconds / 3600)) 348 | remaining_seconds = seconds - 3600 * hours 349 | 350 | minutes = math.floor(remaining_seconds / 60) 351 | remaining_seconds = remaining_seconds - 60 * minutes 352 | 353 | duration = "" 354 | 355 | if hours > 0: 356 | duration += "%s:" % (int(hours),) 357 | 358 | duration += "%s:%s" % (str(int(minutes)).zfill(2), str(int(math.floor(remaining_seconds))).zfill(2)) 359 | 360 | if show_centis or show_millis: 361 | coeff = 1000 if show_millis else 100 362 | digits = 3 if show_millis else 2 363 | centis = math.floor((remaining_seconds - math.floor(remaining_seconds)) * coeff) 364 | duration += ".%s" % (str(int(centis)).zfill(digits)) 365 | 366 | return duration 367 | 368 | @staticmethod 369 | def parse_duration(seconds): 370 | hours = int(math.floor(seconds / 3600)) 371 | remaining_seconds = seconds - 3600 * hours 372 | 373 | minutes = math.floor(remaining_seconds / 60) 374 | remaining_seconds = remaining_seconds - 60 * minutes 375 | seconds = math.floor(remaining_seconds) 376 | 377 | millis = math.floor((remaining_seconds - math.floor(remaining_seconds)) * 1000) 378 | centis = math.floor((remaining_seconds - math.floor(remaining_seconds)) * 100) 379 | 380 | return { 381 | "hours": hours, 382 | "minutes": minutes, 383 | "seconds": seconds, 384 | "centis": centis, 385 | "millis": millis 386 | } 387 | 388 | def desired_size(self, width=Config.contact_sheet_width): 389 | """Computes the height based on a given width and fixed aspect ratio. 390 | Returns (width, height) 391 | """ 392 | ratio = width / float(self.display_width) 393 | desired_height = int(math.floor(self.display_height * ratio)) 394 | return (width, desired_height) 395 | 396 | def parse_attributes(self): 397 | """Parse multiple media attributes 398 | """ 399 | # video 400 | try: 401 | self.video_codec = self.video_stream["codec_name"] 402 | except KeyError: 403 | self.video_codec = None 404 | 405 | try: 406 | self.video_codec_long = self.video_stream["codec_long_name"] 407 | except KeyError: 408 | self.video_codec_long = None 409 | 410 | try: 411 | self.video_bit_rate = int(self.video_stream["bit_rate"]) 412 | except KeyError: 413 | self.video_bit_rate = None 414 | 415 | try: 416 | self.sample_aspect_ratio = self.video_stream["sample_aspect_ratio"] 417 | except KeyError: 418 | self.sample_aspect_ratio = None 419 | 420 | try: 421 | self.display_aspect_ratio = self.video_stream["display_aspect_ratio"] 422 | except KeyError: 423 | self.display_aspect_ratio = None 424 | 425 | try: 426 | self.frame_rate = self.video_stream["avg_frame_rate"] 427 | splits = self.frame_rate.split("/") 428 | 429 | if len(splits) == 2: 430 | self.frame_rate = int(splits[0]) / int(splits[1]) 431 | else: 432 | self.frame_rate = int(self.frame_rate) 433 | 434 | self.frame_rate = round(self.frame_rate, 3) 435 | except KeyError: 436 | self.frame_rate = None 437 | except ZeroDivisionError: 438 | self.frame_rate = None 439 | 440 | # audio 441 | try: 442 | self.audio_codec = self.audio_stream["codec_name"] 443 | except (KeyError, AttributeError): 444 | self.audio_codec = None 445 | 446 | try: 447 | self.audio_codec_long = self.audio_stream["codec_long_name"] 448 | except (KeyError, AttributeError): 449 | self.audio_codec_long = None 450 | 451 | try: 452 | self.audio_sample_rate = int(self.audio_stream["sample_rate"]) 453 | except (KeyError, AttributeError): 454 | self.audio_sample_rate = None 455 | 456 | try: 457 | self.audio_bit_rate = int(self.audio_stream["bit_rate"]) 458 | except (KeyError, AttributeError): 459 | self.audio_bit_rate = None 460 | 461 | def template_attributes(self): 462 | """Returns the template attributes and values ready for use in the metadata header 463 | """ 464 | return dict((x["name"], getattr(self, x["name"])) for x in MediaInfo.list_template_attributes()) 465 | 466 | @staticmethod 467 | def list_template_attributes(): 468 | """Returns a list a of all supported template attributes with their description and example 469 | """ 470 | table = [] 471 | table.append({"name": "size", "description": "File size (pretty format)", "example": "128.3 MiB"}) 472 | table.append({"name": "size_bytes", "description": "File size (bytes)", "example": "4662788373"}) 473 | table.append({"name": "filename", "description": "File name", "example": "video.mkv"}) 474 | table.append({"name": "duration", "description": "Duration (pretty format)", "example": "03:07"}) 475 | table.append({"name": "sample_width", "description": "Sample width (pixels)", "example": "1920"}) 476 | table.append({"name": "sample_height", "description": "Sample height (pixels)", "example": "1080"}) 477 | table.append({"name": "display_width", "description": "Display width (pixels)", "example": "1920"}) 478 | table.append({"name": "display_height", "description": "Display height (pixels)", "example": "1080"}) 479 | table.append({"name": "video_codec", "description": "Video codec", "example": "h264"}) 480 | table.append({"name": "video_codec_long", "description": "Video codec (long name)", 481 | "example": "H.264 / AVC / MPEG-4 AVC / MPEG-4 part 10"}) 482 | table.append({"name": "video_bit_rate", "description": "Video bitrate", "example": "4000"}) 483 | table.append({"name": "display_aspect_ratio", "description": "Display aspect ratio", "example": "16:9"}) 484 | table.append({"name": "sample_aspect_ratio", "description": "Sample aspect ratio", "example": "1:1"}) 485 | table.append({"name": "audio_codec", "description": "Audio codec", "example": "aac"}) 486 | table.append({"name": "audio_codec_long", "description": "Audio codec (long name)", 487 | "example": "AAC (Advanced Audio Coding)"}) 488 | table.append({"name": "audio_sample_rate", "description": "Audio sample rate (Hz)", "example": "44100"}) 489 | table.append({"name": "audio_bit_rate", "description": "Audio bit rate (bits/s)", "example": "192000"}) 490 | table.append({"name": "frame_rate", "description": "Frame rate (frames/s)", "example": "23.974"}) 491 | return table 492 | 493 | 494 | class MediaCapture(object): 495 | """Capture frames of a video 496 | """ 497 | 498 | def __init__(self, path, accurate=False, skip_delay_seconds=Config.accurate_delay_seconds, 499 | frame_type=Config.frame_type): 500 | self.path = path 501 | self.accurate = accurate 502 | self.skip_delay_seconds = skip_delay_seconds 503 | self.frame_type = frame_type 504 | 505 | def make_capture(self, time, width, height, out_path="out.png"): 506 | """Capture a frame at given time with given width and height using ffmpeg 507 | """ 508 | skip_delay = MediaInfo.pretty_duration(self.skip_delay_seconds, show_millis=True) 509 | 510 | ffmpeg_command = [ 511 | "ffmpeg", 512 | "-ss", time, 513 | "-i", self.path, 514 | "-vframes", "1", 515 | "-s", "%sx%s" % (width, height), 516 | ] 517 | 518 | if self.frame_type is not None: 519 | select_args = [ 520 | "-vf", "select='eq(frame_type\\," + self.frame_type + ")'" 521 | ] 522 | 523 | if self.frame_type == "key": 524 | select_args = [ 525 | "-vf", "select=key" 526 | ] 527 | 528 | if self.frame_type is not None: 529 | ffmpeg_command += select_args 530 | 531 | ffmpeg_command += [ 532 | "-y", 533 | out_path 534 | ] 535 | 536 | if self.accurate: 537 | time_seconds = MediaInfo.pretty_to_seconds(time) 538 | skip_time_seconds = time_seconds - self.skip_delay_seconds 539 | 540 | if skip_time_seconds < 0: 541 | ffmpeg_command = [ 542 | "ffmpeg", 543 | "-i", self.path, 544 | "-ss", time, 545 | "-vframes", "1", 546 | "-s", "%sx%s" % (width, height), 547 | ] 548 | 549 | if self.frame_type is not None: 550 | ffmpeg_command += select_args 551 | 552 | ffmpeg_command += [ 553 | "-y", 554 | out_path 555 | ] 556 | else: 557 | skip_time = MediaInfo.pretty_duration(skip_time_seconds, show_millis=True) 558 | ffmpeg_command = [ 559 | "ffmpeg", 560 | "-ss", skip_time, 561 | "-i", self.path, 562 | "-ss", skip_delay, 563 | "-vframes", "1", 564 | "-s", "%sx%s" % (width, height), 565 | ] 566 | 567 | if self.frame_type is not None: 568 | ffmpeg_command += select_args 569 | 570 | ffmpeg_command += [ 571 | "-y", 572 | out_path 573 | ] 574 | 575 | try: 576 | subprocess.call(ffmpeg_command, stdin=DEVNULL, stderr=DEVNULL, stdout=DEVNULL) 577 | except FileNotFoundError: 578 | error = "Could not find 'ffmpeg' executable. Please make sure ffmpeg/ffprobe is installed and is in your PATH." 579 | error_exit(error) 580 | 581 | def compute_avg_color(self, image_path): 582 | """Computes the average color of an image 583 | """ 584 | i = Image.open(image_path) 585 | i = i.convert('P') 586 | p = i.getcolors() 587 | 588 | # compute avg color 589 | total_count = 0 590 | avg_color = 0 591 | for count, color in p: 592 | total_count += count 593 | avg_color += count * color 594 | 595 | avg_color /= total_count 596 | 597 | return avg_color 598 | 599 | def compute_blurriness(self, image_path): 600 | """Computes the blurriness of an image. Small value means less blurry. 601 | """ 602 | i = Image.open(image_path) 603 | i = i.convert('L') # convert to grayscale 604 | 605 | a = numpy.asarray(i) 606 | b = abs(numpy.fft.rfft2(a)) 607 | max_freq = self.avg9x(b) 608 | 609 | if max_freq != 0: 610 | return 1 / max_freq 611 | else: 612 | return 1 613 | 614 | def avg9x(self, matrix, percentage=0.05): 615 | """Computes the median of the top n% highest values. 616 | By default, takes the top 5% 617 | """ 618 | xs = matrix.flatten() 619 | srt = sorted(xs, reverse=True) 620 | length = int(math.floor(percentage * len(srt))) 621 | 622 | matrix_subset = srt[:length] 623 | return numpy.median(matrix_subset) 624 | 625 | def max_freq(self, matrix): 626 | """Returns the maximum value in the matrix 627 | """ 628 | m = 0 629 | for row in matrix: 630 | mx = max(row) 631 | if mx > m: 632 | m = mx 633 | 634 | return m 635 | 636 | 637 | def grid_desired_size( 638 | grid, 639 | media_info, 640 | width=Config.contact_sheet_width, 641 | horizontal_margin=Config.grid_horizontal_spacing): 642 | """Computes the size of the images placed on a mxn grid with given fixed width. 643 | Returns (width, height) 644 | """ 645 | desired_width = (width - (grid.x - 1) * horizontal_margin) / grid.x 646 | desired_width = int(math.floor(desired_width)) 647 | 648 | return media_info.desired_size(width=desired_width) 649 | 650 | 651 | def total_delay_seconds(media_info, args): 652 | """Computes the total seconds to skip (beginning + ending). 653 | """ 654 | start_delay_seconds = math.floor(media_info.duration_seconds * args.start_delay_percent / 100) 655 | end_delay_seconds = math.floor(media_info.duration_seconds * args.end_delay_percent / 100) 656 | delay = start_delay_seconds + end_delay_seconds 657 | return delay 658 | 659 | 660 | def timestamp_generator(media_info, args): 661 | """Generates `num_samples` uniformly distributed timestamps over time. 662 | Timestamps will be selected in the range specified by start_delay_percent and end_delay percent. 663 | For example, `end_delay_percent` can be used to avoid making captures during the ending credits. 664 | """ 665 | delay = total_delay_seconds(media_info, args) 666 | capture_interval = (media_info.duration_seconds - delay) / (args.num_samples + 1) 667 | 668 | if args.interval is not None: 669 | capture_interval = int(args.interval.total_seconds()) 670 | start_delay_seconds = math.floor(media_info.duration_seconds * args.start_delay_percent / 100) 671 | time = start_delay_seconds + capture_interval 672 | 673 | for i in range(args.num_samples): 674 | yield (time, MediaInfo.pretty_duration(time, show_millis=True)) 675 | time += capture_interval 676 | 677 | 678 | def select_sharpest_images( 679 | media_info, 680 | media_capture, 681 | args): 682 | """Make `num_samples` captures and select `num_selected` captures out of these 683 | based on blurriness and color variety. 684 | """ 685 | 686 | desired_size = grid_desired_size( 687 | args.grid, 688 | media_info, 689 | width=args.vcs_width, 690 | horizontal_margin=args.grid_horizontal_spacing) 691 | 692 | if args.manual_timestamps is None: 693 | timestamps = timestamp_generator(media_info, args) 694 | else: 695 | timestamps = [(MediaInfo.pretty_to_seconds(x), x) for x in args.manual_timestamps] 696 | 697 | def do_capture(ts_tuple, width, height, suffix, args): 698 | fd, filename = tempfile.mkstemp(suffix=suffix) 699 | 700 | media_capture.make_capture(ts_tuple[1], width, height, filename) 701 | 702 | blurriness = 1 703 | avg_color = 0 704 | 705 | if not args.fast: 706 | blurriness = media_capture.compute_blurriness(filename) 707 | avg_color = media_capture.compute_avg_color(filename) 708 | 709 | os.close(fd) 710 | frm = Frame( 711 | filename=filename, 712 | blurriness=blurriness, 713 | timestamp=ts_tuple[0], 714 | avg_color=avg_color 715 | ) 716 | return frm 717 | 718 | blurs: List[Frame] = [] 719 | futures = [] 720 | 721 | if args.fast: 722 | # use multiple threads 723 | with ThreadPoolExecutor() as executor: 724 | for i, timestamp_tuple in enumerate(timestamps): 725 | status = "Starting task... {}/{}".format(i + 1, args.num_samples) 726 | print(status, end="\r") 727 | suffix = ".jpg" # faster processing time 728 | future = executor.submit(do_capture, timestamp_tuple, desired_size[0], desired_size[1], suffix, args) 729 | futures.append(future) 730 | print() 731 | 732 | for i, future in enumerate(futures): 733 | status = "Sampling... {}/{}".format(i + 1, args.num_samples) 734 | print(status, end="\r") 735 | frame = future.result() 736 | blurs += [ 737 | frame 738 | ] 739 | print() 740 | else: 741 | # grab captures sequentially 742 | for i, timestamp_tuple in enumerate(timestamps): 743 | status = "Sampling... {}/{}".format(i + 1, args.num_samples) 744 | print(status, end="\r") 745 | suffix = ".bmp" # lossless 746 | frame = do_capture(timestamp_tuple, desired_size[0], desired_size[1], suffix, args) 747 | 748 | blurs += [ 749 | frame 750 | ] 751 | print() 752 | 753 | time_sorted = sorted(blurs, key=lambda x: x.timestamp) 754 | 755 | # group into num_selected groups 756 | if args.num_groups > 1: 757 | group_size = max(1, int(math.floor(len(time_sorted) / args.num_groups))) 758 | groups = chunks(time_sorted, group_size) 759 | 760 | # find top sharpest for each group 761 | selected_items: List[Frame] = [best(x) for x in groups] 762 | else: 763 | selected_items = time_sorted 764 | 765 | selected_items = select_color_variety(selected_items, args.num_selected) 766 | 767 | return selected_items, time_sorted 768 | 769 | 770 | def select_color_variety(frames: Iterable[Frame], num_selected): 771 | """Select captures so that they are not too similar to each other. 772 | """ 773 | avg_color_sorted = sorted(frames, key=lambda x: x.avg_color) 774 | min_color = avg_color_sorted[0].avg_color 775 | max_color = avg_color_sorted[-1].avg_color 776 | color_span = max_color - min_color 777 | min_color_distance = int(color_span * 0.05) 778 | 779 | blurriness_sorted = sorted(frames, key=lambda x: x.blurriness, reverse=True) 780 | 781 | selected_items = [] 782 | unselected_items = [] 783 | while blurriness_sorted: 784 | frame = blurriness_sorted.pop() 785 | 786 | if not selected_items: 787 | selected_items += [frame] 788 | else: 789 | color_distance = min([abs(frame.avg_color - x.avg_color) for x in selected_items]) 790 | if color_distance < min_color_distance: 791 | # too close to existing selected frame 792 | # don't select unless we run out of frames 793 | unselected_items += [(frame, color_distance)] 794 | else: 795 | selected_items += [frame] 796 | 797 | missing_items_count = num_selected - len(selected_items) 798 | if missing_items_count > 0: 799 | remaining_items = sorted(unselected_items, key=lambda x: x[0].blurriness) 800 | selected_items += [x[0] for x in remaining_items[:missing_items_count]] 801 | 802 | return selected_items 803 | 804 | 805 | def best(captures: Frame): 806 | """Returns the least blurry capture 807 | """ 808 | return sorted(captures, key=lambda x: x.blurriness)[0] 809 | 810 | 811 | def chunks(l, n): 812 | """ Yield successive n-sized chunks from l. 813 | """ 814 | for i in range(0, len(l), n): 815 | yield l[i:i + n] 816 | 817 | 818 | def draw_metadata( 819 | draw, 820 | args, 821 | header_line_height=None, 822 | header_lines=None, 823 | header_font=None, 824 | header_font_color=None, 825 | start_height=None): 826 | """Draw metadata header 827 | """ 828 | h = start_height 829 | h += args.metadata_vertical_margin 830 | 831 | for line in header_lines: 832 | draw.text((args.metadata_horizontal_margin, h), line, font=header_font, fill=header_font_color) 833 | h += header_line_height 834 | 835 | h += args.metadata_vertical_margin 836 | 837 | return h 838 | 839 | 840 | def max_line_length( 841 | media_info, 842 | metadata_font, 843 | header_margin, 844 | width=Config.contact_sheet_width, 845 | text=None): 846 | """Find the number of characters that fit in width with given font. 847 | """ 848 | if text is None: 849 | text = media_info.filename 850 | 851 | max_width = width - 2 * header_margin 852 | 853 | max_length = 0 854 | for i in range(len(text) + 1): 855 | text_chunk = text[:i] 856 | text_width = 0 if len(text_chunk) == 0 else metadata_font.getlength(text_chunk) 857 | 858 | max_length = i 859 | if text_width > max_width: 860 | break 861 | 862 | return max_length 863 | 864 | 865 | def prepare_metadata_text_lines(media_info, header_font, header_margin, width, template_path=None): 866 | """Prepare the metadata header text and return a list containing each line. 867 | """ 868 | template = "" 869 | if template_path is None: 870 | template = """{{filename}} 871 | File size: {{size}} 872 | Duration: {{duration}} 873 | Dimensions: {{sample_width}}x{{sample_height}}""" 874 | else: 875 | with open(template_path) as f: 876 | template = f.read() 877 | 878 | params = media_info.template_attributes() 879 | template = Template(template).render(params) 880 | template_lines = template.split("\n") 881 | template_lines = [x.strip() for x in template_lines if len(x) > 0] 882 | 883 | header_lines = [] 884 | for line in template_lines: 885 | remaining_chars = line 886 | while len(remaining_chars) > 0: 887 | max_metadata_line_length = max_line_length( 888 | media_info, 889 | header_font, 890 | header_margin, 891 | width=width, 892 | text=remaining_chars) 893 | wraps = textwrap.wrap(remaining_chars, max_metadata_line_length) 894 | header_lines.append(wraps[0]) 895 | remaining_chars = remaining_chars[len(wraps[0]):].strip() 896 | 897 | return header_lines 898 | 899 | 900 | def compute_timestamp_position(args, w, h, text_size, desired_size, rectangle_hpadding, rectangle_vpadding): 901 | """Compute the (x,y) position of the upper left and bottom right points of the rectangle surrounding timestamp text. 902 | """ 903 | position = args.timestamp_position 904 | 905 | x_offset = 0 906 | if position in [TimestampPosition.west, TimestampPosition.nw, TimestampPosition.sw]: 907 | x_offset = args.timestamp_horizontal_margin 908 | elif position in [TimestampPosition.north, TimestampPosition.center, TimestampPosition.south]: 909 | x_offset = (desired_size[0] / 2) - (text_size[0] / 2) - rectangle_hpadding 910 | else: 911 | x_offset = desired_size[0] - text_size[0] - args.timestamp_horizontal_margin - 2 * rectangle_hpadding 912 | 913 | y_offset = 0 914 | if position in [TimestampPosition.nw, TimestampPosition.north, TimestampPosition.ne]: 915 | y_offset = args.timestamp_vertical_margin 916 | elif position in [TimestampPosition.west, TimestampPosition.center, TimestampPosition.east]: 917 | y_offset = (desired_size[1] / 2) - (text_size[1] / 2) - rectangle_vpadding 918 | else: 919 | y_offset = desired_size[1] - text_size[1] - args.timestamp_vertical_margin - 2 * rectangle_vpadding 920 | 921 | upper_left = ( 922 | w + x_offset, 923 | h + y_offset 924 | ) 925 | 926 | bottom_right = ( 927 | upper_left[0] + text_size[0] + 2 * rectangle_hpadding, 928 | upper_left[1] + text_size[1] + 2 * rectangle_vpadding 929 | ) 930 | 931 | return upper_left, bottom_right 932 | 933 | 934 | def load_font(args, font_path, font_size, default_font_path): 935 | """Loads given font and defaults to fallback fonts if that fails.""" 936 | if args.is_verbose: 937 | print("Loading font...") 938 | 939 | fonts = [font_path] + FALLBACK_FONTS 940 | if font_path == default_font_path: 941 | for font in fonts: 942 | if args.is_verbose: 943 | print("Trying to load font:", font) 944 | if os.path.exists(font): 945 | try: 946 | return ImageFont.truetype(font, font_size) 947 | except OSError: 948 | pass 949 | print("Falling back to default font.") 950 | return ImageFont.load_default() 951 | else: 952 | try: 953 | return ImageFont.truetype(font_path, font_size) 954 | except OSError: 955 | error_exit("Cannot load font: {}".format(font_path)) 956 | 957 | 958 | def compose_contact_sheet( 959 | media_info, 960 | frames, 961 | args): 962 | """Creates a video contact sheet with the media information in a header 963 | and the selected frames arranged on a mxn grid with optional timestamps 964 | """ 965 | desired_size = grid_desired_size( 966 | args.grid, 967 | media_info, 968 | width=args.vcs_width, 969 | horizontal_margin=args.grid_horizontal_spacing) 970 | width = args.grid.x * (desired_size[0] + args.grid_horizontal_spacing) - args.grid_horizontal_spacing 971 | height = args.grid.y * (desired_size[1] + args.grid_vertical_spacing) - args.grid_vertical_spacing 972 | 973 | header_font = load_font(args, args.metadata_font, args.metadata_font_size, Config.metadata_font) 974 | timestamp_font = load_font(args, args.timestamp_font, args.timestamp_font_size, Config.timestamp_font) 975 | 976 | header_lines = prepare_metadata_text_lines( 977 | media_info, 978 | header_font, 979 | args.metadata_horizontal_margin, 980 | width, 981 | template_path=args.metadata_template_path) 982 | 983 | line_spacing_coefficient = 1.2 984 | header_line_height = int(args.metadata_font_size * line_spacing_coefficient) 985 | header_height = 2 * args.metadata_margin + len(header_lines) * header_line_height 986 | 987 | if args.metadata_position == "hidden": 988 | header_height = 0 989 | 990 | final_image_width = width 991 | final_image_height = height + header_height 992 | transparent = (255, 255, 255, 0) 993 | 994 | image = Image.new("RGBA", (final_image_width, final_image_height), args.background_color) 995 | image_capture_layer = Image.new("RGBA", (final_image_width, final_image_height), transparent) 996 | image_header_text_layer = Image.new("RGBA", (final_image_width, final_image_height), transparent) 997 | image_timestamp_layer = Image.new("RGBA", (final_image_width, final_image_height), transparent) 998 | image_timestamp_text_layer = Image.new("RGBA", (final_image_width, final_image_height), transparent) 999 | 1000 | draw_header_text_layer = ImageDraw.Draw(image_header_text_layer) 1001 | draw_timestamp_layer = ImageDraw.Draw(image_timestamp_layer) 1002 | draw_timestamp_text_layer = ImageDraw.Draw(image_timestamp_text_layer) 1003 | h = 0 1004 | 1005 | def draw_metadata_helper(): 1006 | """Draw metadata with fixed arguments 1007 | """ 1008 | return draw_metadata( 1009 | draw_header_text_layer, 1010 | args, 1011 | header_line_height=header_line_height, 1012 | header_lines=header_lines, 1013 | header_font=header_font, 1014 | header_font_color=args.metadata_font_color, 1015 | start_height=h) 1016 | 1017 | # draw metadata 1018 | if args.metadata_position == "top": 1019 | h = draw_metadata_helper() 1020 | 1021 | # draw capture grid 1022 | w = 0 1023 | frames = sorted(frames, key=lambda x: x.timestamp) 1024 | for i, frame in enumerate(frames): 1025 | f = Image.open(frame.filename) 1026 | f.putalpha(args.capture_alpha) 1027 | image_capture_layer.paste(f, (w, h)) 1028 | 1029 | # show timestamp 1030 | if args.show_timestamp: 1031 | timestamp_time = MediaInfo.pretty_duration(frame.timestamp, show_centis=True) 1032 | timestamp_duration = MediaInfo.pretty_duration(media_info.duration_seconds, show_centis=True) 1033 | parsed_time = MediaInfo.parse_duration(frame.timestamp) 1034 | parsed_duration = MediaInfo.parse_duration(media_info.duration_seconds) 1035 | timestamp_args = { 1036 | "TIME": timestamp_time, 1037 | "DURATION": timestamp_duration, 1038 | "THUMBNAIL_NUMBER": i + 1, 1039 | "H": str(parsed_time["hours"]).zfill(2), 1040 | "M": str(parsed_time["minutes"]).zfill(2), 1041 | "S": str(parsed_time["seconds"]).zfill(2), 1042 | "c": str(parsed_time["centis"]).zfill(2), 1043 | "m": str(parsed_time["millis"]).zfill(3), 1044 | "dH": str(parsed_duration["hours"]).zfill(2), 1045 | "dM": str(parsed_duration["minutes"]).zfill(2), 1046 | "dS": str(parsed_duration["seconds"]).zfill(2), 1047 | "dc": str(parsed_duration["centis"]).zfill(2), 1048 | "dm": str(parsed_duration["millis"]).zfill(3) 1049 | } 1050 | timestamp_text = args.timestamp_format.format(**timestamp_args) 1051 | text_bbox = timestamp_font.getbbox(timestamp_text) 1052 | (left, top, right, bottom) = text_bbox 1053 | text_width = abs(right - left) 1054 | text_height = abs(top - bottom) 1055 | text_size = (text_width, text_height) 1056 | 1057 | # draw rectangle 1058 | rectangle_hpadding = args.timestamp_horizontal_padding 1059 | rectangle_vpadding = args.timestamp_vertical_padding 1060 | 1061 | upper_left, bottom_right = compute_timestamp_position(args, w, h, text_size, desired_size, 1062 | rectangle_hpadding, rectangle_vpadding) 1063 | 1064 | if not args.timestamp_border_mode: 1065 | draw_timestamp_layer.rectangle( 1066 | [upper_left, bottom_right], 1067 | fill=args.timestamp_background_color 1068 | ) 1069 | else: 1070 | offset_factor = args.timestamp_border_size 1071 | offsets = [ 1072 | (1, 0), 1073 | (-1, 0), 1074 | (0, 1), 1075 | (0, -1), 1076 | (1, 1), 1077 | (1, -1), 1078 | (-1, 1), 1079 | (-1, -1) 1080 | ] 1081 | 1082 | final_offsets = [] 1083 | for offset_counter in range(1, offset_factor + 1): 1084 | final_offsets += [(x[0] * offset_counter, x[1] * offset_counter) for x in offsets] 1085 | 1086 | for offset in final_offsets: 1087 | # draw border first 1088 | draw_timestamp_text_layer.text( 1089 | ( 1090 | upper_left[0] + rectangle_hpadding + offset[0], 1091 | upper_left[1] + rectangle_vpadding + offset[1] 1092 | ), 1093 | timestamp_text, 1094 | font=timestamp_font, 1095 | fill=args.timestamp_border_color, 1096 | anchor="lt" 1097 | ) 1098 | 1099 | # draw timestamp 1100 | draw_timestamp_text_layer.text( 1101 | ( 1102 | upper_left[0] + rectangle_hpadding, 1103 | upper_left[1] + rectangle_vpadding 1104 | ), 1105 | timestamp_text, 1106 | font=timestamp_font, 1107 | fill=args.timestamp_font_color, 1108 | anchor="lt" 1109 | ) 1110 | 1111 | # update x position for next frame 1112 | w += desired_size[0] + args.grid_horizontal_spacing 1113 | 1114 | # update y position 1115 | if (i + 1) % args.grid.x == 0: 1116 | h += desired_size[1] + args.grid_vertical_spacing 1117 | 1118 | # update x position 1119 | if (i + 1) % args.grid.x == 0: 1120 | w = 0 1121 | 1122 | # draw metadata 1123 | if args.metadata_position == "bottom": 1124 | h -= args.grid_vertical_spacing 1125 | h = draw_metadata_helper() 1126 | 1127 | # alpha blend 1128 | out_image = Image.alpha_composite(image, image_capture_layer) 1129 | out_image = Image.alpha_composite(out_image, image_header_text_layer) 1130 | out_image = Image.alpha_composite(out_image, image_timestamp_layer) 1131 | out_image = Image.alpha_composite(out_image, image_timestamp_text_layer) 1132 | 1133 | return out_image 1134 | 1135 | 1136 | def save_image(args, image, media_info, output_path): 1137 | """Save the image to `output_path` 1138 | """ 1139 | image = image.convert("RGB") 1140 | try: 1141 | image.save(output_path, optimize=True, quality=args.image_quality) 1142 | return True 1143 | except KeyError: 1144 | return False 1145 | 1146 | 1147 | def cleanup(frames, args): 1148 | """Delete temporary captures 1149 | """ 1150 | if args.is_verbose: 1151 | print("Deleting {} temporary frames...".format(len(frames))) 1152 | for frame in frames: 1153 | try: 1154 | if args.is_verbose: 1155 | print("Deleting {} ...".format(frame.filename)) 1156 | os.unlink(frame.filename) 1157 | except Exception as e: 1158 | if args.is_verbose: 1159 | print("[Error] Failed to delete {}".format(frame.filename)) 1160 | print(e) 1161 | 1162 | 1163 | def print_template_attributes(): 1164 | """Display all the available template attributes in a tabular format 1165 | """ 1166 | table = MediaInfo.list_template_attributes() 1167 | 1168 | tab = texttable.Texttable() 1169 | tab.set_cols_dtype(["t", "t", "t"]) 1170 | rows = [[x["name"], x["description"], x["example"]] for x in table] 1171 | tab.add_rows(rows, header=False) 1172 | tab.header(["Attribute name", "Description", "Example"]) 1173 | print(tab.draw()) 1174 | 1175 | 1176 | def mxn_type(string): 1177 | """Type parser for argparse. Argument of type "mxn" will be converted to Grid(m, n). 1178 | An exception will be thrown if the argument is not of the required form 1179 | """ 1180 | try: 1181 | split = string.split("x") 1182 | assert (len(split) == 2) 1183 | m = int(split[0]) 1184 | assert (m >= 0) 1185 | n = int(split[1]) 1186 | assert (n >= 0) 1187 | return Grid(m, n) 1188 | except (IndexError, ValueError, AssertionError): 1189 | error = "Grid must be of the form mxn, where m is the number of columns and n is the number of rows." 1190 | raise argparse.ArgumentTypeError(error) 1191 | 1192 | 1193 | def metadata_position_type(string): 1194 | """Type parser for argparse. Argument of type string must be one of ["top", "bottom", "hidden"]. 1195 | An exception will be thrown if the argument is not one of these. 1196 | """ 1197 | valid_metadata_positions = ["top", "bottom", "hidden"] 1198 | 1199 | lowercase_position = string.lower() 1200 | if lowercase_position in valid_metadata_positions: 1201 | return lowercase_position 1202 | else: 1203 | error = 'Metadata header position must be one of %s' % (str(valid_metadata_positions, )) 1204 | raise argparse.ArgumentTypeError(error) 1205 | 1206 | 1207 | def hex_color_type(string): 1208 | """Type parser for argparse. Argument must be an hexadecimal number representing a color. 1209 | For example 'AABBCC' (RGB) or 'AABBCCFF' (RGBA). An exception will be raised if the argument 1210 | is not of that form. 1211 | """ 1212 | try: 1213 | components = tuple(bytearray.fromhex(string)) 1214 | if len(components) == 3: 1215 | components += (255,) 1216 | c = Color(*components) 1217 | return c 1218 | except: 1219 | error = "Color must be an hexadecimal number, for example 'AABBCC'" 1220 | raise argparse.ArgumentTypeError(error) 1221 | 1222 | 1223 | def manual_timestamps(string): 1224 | """Type parser for argparse. Argument must be a comma-separated list of frame timestamps. 1225 | For example 1:11:11.111,2:22:22.222 1226 | """ 1227 | try: 1228 | timestamps = string.split(",") 1229 | timestamps = [x.strip() for x in timestamps if x] 1230 | 1231 | # check whether timestamps are valid 1232 | for t in timestamps: 1233 | MediaInfo.pretty_to_seconds(t) 1234 | 1235 | return timestamps 1236 | except Exception as e: 1237 | print(e) 1238 | error = "Manual frame timestamps must be comma-separated and of the form h:mm:ss.mmmm" 1239 | raise argparse.ArgumentTypeError(error) 1240 | 1241 | 1242 | def timestamp_position_type(string): 1243 | """Type parser for argparse. Argument must be a valid timestamp position""" 1244 | try: 1245 | return getattr(TimestampPosition, string) 1246 | except AttributeError: 1247 | error = "Invalid timestamp position: %s. Valid positions are: %s" % (string, VALID_TIMESTAMP_POSITIONS) 1248 | raise argparse.ArgumentTypeError(error) 1249 | 1250 | 1251 | def interval_type(string): 1252 | """Type parser for argparse. Argument must be a valid interval format. 1253 | Supports any format supported by `parsedatetime`, including: 1254 | * "30sec" (every 30 seconds) 1255 | * "5 minutes" (every 5 minutes) 1256 | * "1h" (every hour) 1257 | * "2 hours 1 min and 30 seconds" 1258 | """ 1259 | m = datetime.datetime.min 1260 | cal = parsedatetime.Calendar() 1261 | interval = cal.parseDT(string, sourceTime=m)[0] - m 1262 | if interval == m: 1263 | error = "Invalid interval format: {}".format(string) 1264 | raise argparse.ArgumentTypeError(error) 1265 | 1266 | return interval 1267 | 1268 | 1269 | def comma_separated_string_type(string): 1270 | """Type parser for argparse. Argument must be a comma-separated list of strings.""" 1271 | splits = string.split(",") 1272 | splits = [x.strip() for x in splits] 1273 | splits = [x for x in splits if len(x) > 0] 1274 | return splits 1275 | 1276 | 1277 | def error(message): 1278 | """Print an error message.""" 1279 | print("[ERROR] %s" % (message,)) 1280 | 1281 | 1282 | def error_exit(message): 1283 | """Print an error message and exit""" 1284 | error(message) 1285 | sys.exit(-1) 1286 | 1287 | 1288 | def main(): 1289 | """Program entry point 1290 | """ 1291 | # Argument parser before actual argument parser to let the user overwrite the config path 1292 | preargparser = argparse.ArgumentParser(add_help=False) 1293 | preargparser.add_argument("-c", "--config", dest="configfile", default=None) 1294 | preargs, _ = preargparser.parse_known_args() 1295 | try: 1296 | if preargs.configfile: 1297 | # check if the given config file exists 1298 | # abort if not, because the user wants to use a specific file and not the default config 1299 | if os.path.exists(preargs.configfile): 1300 | Config.load_configuration(preargs.configfile) 1301 | else: 1302 | error_exit("Could find config file") 1303 | else: 1304 | # check if the config file exists and load it 1305 | if os.path.exists(DEFAULT_CONFIG_FILE): 1306 | Config.load_configuration(DEFAULT_CONFIG_FILE) 1307 | except configparser.MissingSectionHeaderError as e: 1308 | error_exit(e.message) 1309 | 1310 | parser = argparse.ArgumentParser(description="Create a video contact sheet", 1311 | formatter_class=argparse.ArgumentDefaultsHelpFormatter) 1312 | parser.add_argument("filenames", nargs="+") 1313 | parser.add_argument( 1314 | "-o", "--output", 1315 | help="save to output file", 1316 | dest="output_path") 1317 | # adding --config to the main parser to display it when the user asks for help 1318 | # the value is not important anymore 1319 | parser.add_argument( 1320 | "-c", "--config", 1321 | help="Config file to load defaults from", 1322 | default=DEFAULT_CONFIG_FILE 1323 | ) 1324 | parser.add_argument( 1325 | "--start-delay-percent", 1326 | help="do not capture frames in the first n percent of total time", 1327 | dest="start_delay_percent", 1328 | type=int, 1329 | default=Config.start_delay_percent) 1330 | parser.add_argument( 1331 | "--end-delay-percent", 1332 | help="do not capture frames in the last n percent of total time", 1333 | dest="end_delay_percent", 1334 | type=int, 1335 | default=Config.end_delay_percent) 1336 | parser.add_argument( 1337 | "--delay-percent", 1338 | help="do not capture frames in the first and last n percent of total time", 1339 | dest="delay_percent", 1340 | type=int, 1341 | default=Config.delay_percent) 1342 | parser.add_argument( 1343 | "--grid-spacing", 1344 | help="number of pixels spacing captures both vertically and horizontally", 1345 | dest="grid_spacing", 1346 | type=int, 1347 | default=Config.grid_spacing) 1348 | parser.add_argument( 1349 | "--grid-horizontal-spacing", 1350 | help="number of pixels spacing captures horizontally", 1351 | dest="grid_horizontal_spacing", 1352 | type=int, 1353 | default=Config.grid_horizontal_spacing) 1354 | parser.add_argument( 1355 | "--grid-vertical-spacing", 1356 | help="number of pixels spacing captures vertically", 1357 | dest="grid_vertical_spacing", 1358 | type=int, 1359 | default=Config.grid_vertical_spacing) 1360 | parser.add_argument( 1361 | "-w", "--width", 1362 | help="width of the generated contact sheet", 1363 | dest="vcs_width", 1364 | type=int, 1365 | default=Config.contact_sheet_width) 1366 | parser.add_argument( 1367 | "-g", "--grid", 1368 | help="display frames on a mxn grid (for example 4x5). The special value zero (as in 2x0 or 0x5 or 0x0) is only allowed when combined with --interval or with --manual. Zero means that the component should be automatically deduced based on other arguments passed.", 1369 | dest="grid", 1370 | type=mxn_type, 1371 | default=Config.grid_size) 1372 | parser.add_argument( 1373 | "-s", "--num-samples", 1374 | help="number of samples", 1375 | dest="num_samples", 1376 | type=int, 1377 | default=None) 1378 | parser.add_argument( 1379 | "-t", "--show-timestamp", 1380 | action="store_true", 1381 | help="display timestamp for each frame", 1382 | dest="show_timestamp") 1383 | parser.add_argument( 1384 | "--metadata-font-size", 1385 | help="size of the font used for metadata", 1386 | dest="metadata_font_size", 1387 | type=int, 1388 | default=Config.metadata_font_size) 1389 | parser.add_argument( 1390 | "--metadata-font", 1391 | help="TTF font used for metadata", 1392 | dest="metadata_font", 1393 | default=Config.metadata_font) 1394 | parser.add_argument( 1395 | "--timestamp-font-size", 1396 | help="size of the font used for timestamps", 1397 | dest="timestamp_font_size", 1398 | type=int, 1399 | default=Config.timestamp_font_size) 1400 | parser.add_argument( 1401 | "--timestamp-font", 1402 | help="TTF font used for timestamps", 1403 | dest="timestamp_font", 1404 | default=Config.timestamp_font) 1405 | parser.add_argument( 1406 | "--metadata-position", 1407 | help="Position of the metadata header. Must be one of ['top', 'bottom', 'hidden']", 1408 | dest="metadata_position", 1409 | type=metadata_position_type, 1410 | default=Config.metadata_position) 1411 | parser.add_argument( 1412 | "--background-color", 1413 | help="Color of the background in hexadecimal, for example AABBCC", 1414 | dest="background_color", 1415 | type=hex_color_type, 1416 | default=hex_color_type(Config.background_color)) 1417 | parser.add_argument( 1418 | "--metadata-font-color", 1419 | help="Color of the metadata font in hexadecimal, for example AABBCC", 1420 | dest="metadata_font_color", 1421 | type=hex_color_type, 1422 | default=hex_color_type(Config.metadata_font_color)) 1423 | parser.add_argument( 1424 | "--timestamp-font-color", 1425 | help="Color of the timestamp font in hexadecimal, for example AABBCC", 1426 | dest="timestamp_font_color", 1427 | type=hex_color_type, 1428 | default=hex_color_type(Config.timestamp_font_color)) 1429 | parser.add_argument( 1430 | "--timestamp-background-color", 1431 | help="Color of the timestamp background rectangle in hexadecimal, for example AABBCC", 1432 | dest="timestamp_background_color", 1433 | type=hex_color_type, 1434 | default=hex_color_type(Config.timestamp_background_color)) 1435 | parser.add_argument( 1436 | "--timestamp-border-color", 1437 | help="Color of the timestamp border in hexadecimal, for example AABBCC", 1438 | dest="timestamp_border_color", 1439 | type=hex_color_type, 1440 | default=hex_color_type(Config.timestamp_border_color)) 1441 | parser.add_argument( 1442 | "--template", 1443 | help="Path to metadata template file", 1444 | dest="metadata_template_path", 1445 | default=None) 1446 | parser.add_argument( 1447 | "-m", "--manual", 1448 | help="Comma-separated list of frame timestamps to use, for example 1:11:11.111,2:22:22.222", 1449 | dest="manual_timestamps", 1450 | type=manual_timestamps, 1451 | default=None) 1452 | parser.add_argument( 1453 | "-v", "--verbose", 1454 | action="store_true", 1455 | help="display verbose messages", 1456 | dest="is_verbose") 1457 | parser.add_argument( 1458 | "-a", "--accurate", 1459 | action="store_true", 1460 | help="""Make accurate captures. This capture mode is way slower than the default one 1461 | but it helps when capturing frames from HEVC videos.""", 1462 | dest="is_accurate") 1463 | parser.add_argument( 1464 | "-A", "--accurate-delay-seconds", 1465 | type=int, 1466 | default=Config.accurate_delay_seconds, 1467 | help="""Fast skip to N seconds before capture time, then do accurate capture 1468 | (decodes N seconds of video before each capture). This is used with accurate capture mode only.""", 1469 | dest="accurate_delay_seconds") 1470 | parser.add_argument( 1471 | "--metadata-margin", 1472 | type=int, 1473 | default=Config.metadata_margin, 1474 | help="Margin (in pixels) in the metadata header.", 1475 | dest="metadata_margin") 1476 | parser.add_argument( 1477 | "--metadata-horizontal-margin", 1478 | type=int, 1479 | default=Config.metadata_horizontal_margin, 1480 | help="Horizontal margin (in pixels) in the metadata header.", 1481 | dest="metadata_horizontal_margin") 1482 | parser.add_argument( 1483 | "--metadata-vertical-margin", 1484 | type=int, 1485 | default=Config.metadata_vertical_margin, 1486 | help="Vertical margin (in pixels) in the metadata header.", 1487 | dest="metadata_vertical_margin") 1488 | parser.add_argument( 1489 | "--timestamp-horizontal-padding", 1490 | type=int, 1491 | default=Config.timestamp_horizontal_padding, 1492 | help="Horizontal padding (in pixels) for timestamps.", 1493 | dest="timestamp_horizontal_padding") 1494 | parser.add_argument( 1495 | "--timestamp-vertical-padding", 1496 | type=int, 1497 | default=Config.timestamp_vertical_padding, 1498 | help="Vertical padding (in pixels) for timestamps.", 1499 | dest="timestamp_vertical_padding") 1500 | parser.add_argument( 1501 | "--timestamp-horizontal-margin", 1502 | type=int, 1503 | default=Config.timestamp_horizontal_margin, 1504 | help="Horizontal margin (in pixels) for timestamps.", 1505 | dest="timestamp_horizontal_margin") 1506 | parser.add_argument( 1507 | "--timestamp-vertical-margin", 1508 | type=int, 1509 | default=Config.timestamp_vertical_margin, 1510 | help="Vertical margin (in pixels) for timestamps.", 1511 | dest="timestamp_vertical_margin") 1512 | parser.add_argument( 1513 | "--quality", 1514 | type=int, 1515 | default=Config.quality, 1516 | help="Output image quality. Must be an integer in the range 0-100. 100 = best quality.", 1517 | dest="image_quality") 1518 | parser.add_argument( 1519 | "-f", "--format", 1520 | type=str, 1521 | default=Config.format, 1522 | help="Output image format. Can be any format supported by pillow. For example 'png' or 'jpg'.", 1523 | dest="image_format") 1524 | parser.add_argument( 1525 | "-T", "--timestamp-position", 1526 | type=timestamp_position_type, 1527 | default=Config.timestamp_position, 1528 | help="Timestamp position. Must be one of %s." % (VALID_TIMESTAMP_POSITIONS,), 1529 | dest="timestamp_position") 1530 | parser.add_argument( 1531 | "-r", "--recursive", 1532 | action="store_true", 1533 | help="Process every file in the specified directory recursively.", 1534 | dest="recursive") 1535 | parser.add_argument( 1536 | "--timestamp-border-mode", 1537 | action="store_true", 1538 | help="Draw timestamp text with a border instead of the default rectangle.", 1539 | dest="timestamp_border_mode") 1540 | parser.add_argument( 1541 | "--timestamp-border-size", 1542 | type=int, 1543 | default=Config.timestamp_border_size, 1544 | help="Size of the timestamp border in pixels (used only with --timestamp-border-mode).", 1545 | dest="timestamp_border_size") 1546 | parser.add_argument( 1547 | "--capture-alpha", 1548 | type=int, 1549 | default=Config.capture_alpha, 1550 | help="Alpha channel value for the captures (transparency in range [0, 255]). Defaults to 255 (opaque)", 1551 | dest="capture_alpha") 1552 | parser.add_argument( 1553 | "--version", 1554 | action="version", 1555 | version="%(prog)s version {version}".format(version=__version__)) 1556 | parser.add_argument( 1557 | "--list-template-attributes", 1558 | action="store_true", 1559 | dest="list_template_attributes") 1560 | parser.add_argument( 1561 | "--frame-type", 1562 | type=str, 1563 | default=DEFAULT_FRAME_TYPE, 1564 | help="Frame type passed to ffmpeg 'select=eq(pict_type,FRAME_TYPE)' filter. Should be one of ('I', 'B', 'P') or the special type 'key' which will use the 'select=key' filter instead.", 1565 | dest="frame_type") 1566 | parser.add_argument( 1567 | "--interval", 1568 | type=interval_type, 1569 | default=Config.interval, 1570 | help="Capture frames at specified interval. Interval format is any string supported by `parsedatetime`. For example '5m', '3 minutes 5 seconds', '1 hour 15 min and 20 sec' etc.", 1571 | dest="interval") 1572 | parser.add_argument( 1573 | "--ignore-errors", 1574 | action="store_true", 1575 | help="Ignore any error encountered while processing files recursively and continue to the next file.", 1576 | dest="ignore_errors") 1577 | parser.add_argument( 1578 | "--no-overwrite", 1579 | action="store_true", 1580 | help="Do not overwrite output file if it already exists, simply ignore this file and continue processing other unprocessed files.", 1581 | dest="no_overwrite" 1582 | ) 1583 | parser.add_argument( 1584 | "--exclude-extensions", 1585 | type=comma_separated_string_type, 1586 | default=[], 1587 | help="Do not process files that end with the given extensions.", 1588 | dest="exclude_extensions" 1589 | ) 1590 | parser.add_argument( 1591 | "--fast", 1592 | action="store_true", 1593 | help="Fast mode. Just make a contact sheet as fast as possible, regardless of output image quality. May mess up the terminal.", 1594 | dest="fast") 1595 | parser.add_argument( 1596 | "-O", "--thumbnail-output", 1597 | help="Save thumbnail files to the specified output directory. If set, the thumbnail files will not be deleted after successful creation of the contact sheet.", 1598 | default=None, 1599 | dest="thumbnail_output_path" 1600 | ) 1601 | parser.add_argument( 1602 | "-S", "--actual-size", 1603 | help="Make thumbnails of actual size. In other words, thumbnails will have the actual 1:1 size of the video resolution.", 1604 | action="store_true", 1605 | dest="actual_size" 1606 | ) 1607 | parser.add_argument( 1608 | "--timestamp-format", 1609 | help="Use specified timestamp format. Replaced values include: {TIME}, {DURATION}, {THUMBNAIL_NUMBER}, {H} (hours), {M} (minutes), {S} (seconds), {c} (centiseconds), {m} (milliseconds), {dH}, {dM}, {dS}, {dc} and {dm} (same as previous values but for the total duration). Example format: '{TIME} / {DURATION}'. Another example: '{THUMBNAIL_NUMBER}'. Yet another example: '{H}:{M}:{S}.{m} / {dH}:{dM}:{dS}.{dm}'.", 1610 | default="{TIME}", 1611 | dest="timestamp_format" 1612 | ) 1613 | 1614 | args = parser.parse_args() 1615 | 1616 | if args.list_template_attributes: 1617 | print_template_attributes() 1618 | sys.exit(0) 1619 | 1620 | def process_file_or_ignore(filepath, args): 1621 | try: 1622 | process_file(filepath, args) 1623 | except Exception: 1624 | if not args.ignore_errors: 1625 | raise 1626 | else: 1627 | print("[WARN]: failed to process {} ... skipping.".format(filepath), file=sys.stderr) 1628 | 1629 | if args.recursive: 1630 | for path in args.filenames: 1631 | for root, subdirs, files in os.walk(path): 1632 | for f in files: 1633 | filepath = os.path.join(root, f) 1634 | process_file_or_ignore(filepath, args) 1635 | else: 1636 | for path in args.filenames: 1637 | if os.path.isdir(path): 1638 | for filepath in os.listdir(path): 1639 | abs_filepath = os.path.join(path, filepath) 1640 | if not os.path.isdir(abs_filepath): 1641 | process_file_or_ignore(abs_filepath, args) 1642 | 1643 | else: 1644 | files_to_process = glob(escape(path)) 1645 | if len(files_to_process) == 0: 1646 | files_to_process = [path] 1647 | for filename in files_to_process: 1648 | process_file_or_ignore(filename, args) 1649 | 1650 | 1651 | def process_file(path, args): 1652 | """Generate a video contact sheet for the file at given path 1653 | """ 1654 | if args.is_verbose: 1655 | print("Considering {}...".format(path)) 1656 | 1657 | args = deepcopy(args) 1658 | 1659 | is_url = False 1660 | url_path = "" 1661 | try: 1662 | _, _, url_path, _, _, _ = urlparse(path) 1663 | is_url = True 1664 | except ValueError(e): 1665 | pass 1666 | 1667 | if not is_url and not os.path.exists(path): 1668 | if args.ignore_errors: 1669 | print("File does not exist, skipping: {}".format(path)) 1670 | return 1671 | else: 1672 | error_message = "File does not exist: {}".format(path) 1673 | error_exit(error_message) 1674 | 1675 | if not is_url: 1676 | file_extension = path.lower().split(".")[-1] 1677 | if file_extension in args.exclude_extensions: 1678 | print("[WARN] Excluded extension {}. Skipping.".format(file_extension)) 1679 | return 1680 | 1681 | output_path = args.output_path 1682 | if not output_path: 1683 | output_path = path + "." + args.image_format 1684 | if is_url: 1685 | url_path = url_path.replace("/", "_") 1686 | if len(url_path) == 0: 1687 | url_path = "out" 1688 | output_path = f"{url_path}.{args.image_format}" 1689 | elif os.path.isdir(output_path): 1690 | output_path = os.path.join(output_path, os.path.basename(path) + "." + args.image_format) 1691 | 1692 | if args.no_overwrite: 1693 | if os.path.exists(output_path): 1694 | print("[INFO] contact-sheet already exists, skipping: {}".format(output_path)) 1695 | return 1696 | 1697 | print("Processing {}...".format(path)) 1698 | 1699 | if args.interval is not None and args.manual_timestamps is not None: 1700 | error_exit("Cannot use --interval and --manual at the same time.") 1701 | 1702 | if args.vcs_width != DEFAULT_CONTACT_SHEET_WIDTH and args.actual_size: 1703 | error_exit("Cannot use --width and --actual-size at the same time.") 1704 | 1705 | if args.delay_percent is not None: 1706 | args.start_delay_percent = args.delay_percent 1707 | args.end_delay_percent = args.delay_percent 1708 | 1709 | args.num_groups = 5 1710 | 1711 | media_info = MediaInfo( 1712 | path, 1713 | verbose=args.is_verbose) 1714 | media_capture = MediaCapture( 1715 | path, 1716 | accurate=args.is_accurate, 1717 | skip_delay_seconds=args.accurate_delay_seconds, 1718 | frame_type=args.frame_type 1719 | ) 1720 | 1721 | # metadata margins 1722 | if not args.metadata_margin == DEFAULT_METADATA_MARGIN: 1723 | args.metadata_horizontal_margin = args.metadata_margin 1724 | args.metadata_vertical_margin = args.metadata_margin 1725 | 1726 | if args.interval is None and args.manual_timestamps is None and (args.grid.x == 0 or args.grid.y == 0): 1727 | error = "Row or column of size zero is only supported with --interval or --manual." 1728 | error_exit(error) 1729 | 1730 | if args.interval is not None: 1731 | total_delay = total_delay_seconds(media_info, args) 1732 | selected_duration = media_info.duration_seconds - total_delay 1733 | args.num_samples = math.floor(selected_duration / args.interval.total_seconds()) 1734 | args.num_selected = args.num_samples 1735 | args.num_groups = args.num_samples 1736 | 1737 | # manual frame selection 1738 | if args.manual_timestamps is not None: 1739 | mframes_size = len(args.manual_timestamps) 1740 | 1741 | args.num_selected = mframes_size 1742 | args.num_samples = mframes_size 1743 | args.num_groups = mframes_size 1744 | 1745 | if args.interval is not None or args.manual_timestamps is not None: 1746 | square_side = math.ceil(math.sqrt(args.num_samples)) 1747 | 1748 | if args.grid == DEFAULT_GRID_SIZE: 1749 | args.grid = Grid(square_side, square_side) 1750 | elif args.grid.x == 0 and args.grid.y == 0: 1751 | args.grid = Grid(square_side, square_side) 1752 | elif args.grid.x == 0: 1753 | # y is fixed 1754 | x = math.ceil(args.num_samples / args.grid.y) 1755 | args.grid = Grid(x, args.grid.y) 1756 | elif args.grid.y == 0: 1757 | # x is fixed 1758 | y = math.ceil(args.num_samples / args.grid.x) 1759 | args.grid = Grid(args.grid.x, y) 1760 | 1761 | args.num_selected = args.grid.x * args.grid.y 1762 | if args.num_samples is None: 1763 | args.num_samples = args.num_selected 1764 | 1765 | if args.num_groups is None: 1766 | args.num_groups = args.num_selected 1767 | 1768 | # make sure num_selected is not too large 1769 | if args.interval is None and args.manual_timestamps is None: 1770 | if args.num_selected > args.num_groups: 1771 | args.num_groups = args.num_selected 1772 | 1773 | if args.num_selected > args.num_samples: 1774 | args.num_samples = args.num_selected 1775 | 1776 | # make sure num_samples is large enough 1777 | if args.num_samples < args.num_selected or args.num_samples < args.num_groups: 1778 | args.num_samples = args.num_selected 1779 | args.num_groups = args.num_selected 1780 | 1781 | if args.grid_spacing is not None: 1782 | args.grid_horizontal_spacing = args.grid_spacing 1783 | args.grid_vertical_spacing = args.grid_spacing 1784 | 1785 | if args.actual_size: 1786 | x = args.grid.x 1787 | width = media_info.display_width 1788 | args.vcs_width = x * width + (x - 1) * args.grid_horizontal_spacing 1789 | 1790 | selected_frames, temp_frames = select_sharpest_images(media_info, media_capture, args) 1791 | 1792 | print("Composing contact sheet...") 1793 | image = compose_contact_sheet(media_info, selected_frames, args) 1794 | 1795 | is_save_successful = save_image(args, image, media_info, output_path) 1796 | 1797 | # save selected frames of the contact sheet to the predefined location in thumbnail_output_path 1798 | thumbnail_output_path = args.thumbnail_output_path 1799 | if thumbnail_output_path is not None: 1800 | os.makedirs(thumbnail_output_path, exist_ok=True) 1801 | print("Copying thumbnails to {} ...".format(thumbnail_output_path)) 1802 | for i, frame in enumerate(sorted(selected_frames, key=lambda x_frame: x_frame.timestamp)): 1803 | print(frame.filename) 1804 | thumbnail_file_extension = frame.filename.lower().split(".")[-1] 1805 | thumbnail_filename = "{filename}.{number}.{extension}".format(filename=os.path.basename(path), 1806 | number=str(i).zfill(4), 1807 | extension=thumbnail_file_extension) 1808 | thumbnail_destination = os.path.join(thumbnail_output_path, thumbnail_filename) 1809 | shutil.copyfile(frame.filename, thumbnail_destination) 1810 | 1811 | print("Cleaning up temporary files...") 1812 | cleanup(temp_frames, args) 1813 | 1814 | if not is_save_successful: 1815 | error_exit("Unsupported image format: %s." % (args.image_format,)) 1816 | 1817 | 1818 | if __name__ == "__main__": 1819 | main() 1820 | --------------------------------------------------------------------------------