1#!/usr/bin/env python3 2 3# Copyright (C) 2015-2020 Damon Lynch <damonlynch@gmail.com> 4 5# This file is part of Rapid Photo Downloader. 6# 7# Rapid Photo Downloader is free software: you can redistribute it and/or 8# modify it under the terms of the GNU General Public License as published by 9# the Free Software Foundation, either version 3 of the License, or 10# (at your option) any later version. 11# 12# Rapid Photo Downloader is distributed in the hope that it will be useful, 13# but WITHOUT ANY WARRANTY; without even the implied warranty of 14# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 15# GNU General Public License for more details. 16# 17# You should have received a copy of the GNU General Public License 18# along with Rapid Photo Downloader. If not, 19# see <http://www.gnu.org/licenses/>. 20 21__author__ = 'Damon Lynch' 22__copyright__ = "Copyright 2015-2020, Damon Lynch" 23 24import sys 25import logging 26from urllib.request import pathname2url 27import pickle 28import os 29from collections import namedtuple 30import tempfile 31from datetime import datetime 32from typing import Optional, Set, Union, Tuple 33 34import gi 35gi.require_version('Gst', '1.0') 36from gi.repository import Gst 37 38from PyQt5.QtGui import QImage, QTransform 39from PyQt5.QtCore import QSize, Qt, QIODevice, QBuffer 40try: 41 import rawkit 42 import rawkit.options 43 import rawkit.raw 44 have_rawkit = True 45except ImportError: 46 have_rawkit = False 47 48from raphodo.interprocess import ( 49 LoadBalancerWorker, ThumbnailExtractorArgument, GenerateThumbnailsResults 50) 51 52from raphodo.constants import ( 53 ThumbnailSize, ExtractionTask, ExtractionProcessing, ThumbnailCacheStatus, 54 ThumbnailCacheDiskStatus 55) 56from raphodo.rpdfile import RPDFile, Video, Photo 57from raphodo.constants import FileType 58from raphodo.utilities import ( 59 stdchannel_redirected, show_errors, image_large_enough_fdo 60) 61from raphodo.filmstrip import add_filmstrip 62from raphodo.cache import ThumbnailCacheSql, FdoCacheLarge, FdoCacheNormal 63import raphodo.exiftool as exiftool 64from raphodo.heif import have_heif_module, load_heif 65 66 67have_gst = Gst.init_check(None) 68 69 70def gst_version() -> str: 71 """ 72 :return: version of gstreamer, if it exists and is functioning, else '' 73 """ 74 75 if have_gst: 76 try: 77 return Gst.version_string().replace('GStreamer ', '') 78 except Exception: 79 pass 80 return '' 81 82 83def libraw_version(suppress_errors: bool=True) -> str: 84 """ 85 Return version number of libraw, using rawkit 86 87 :param suppress_errors: 88 :return: version number if available, else '' 89 """ 90 91 if not have_rawkit: 92 return '' 93 94 import libraw.bindings 95 try: 96 return libraw.bindings.LibRaw().version 97 except ImportError as e: 98 if not suppress_errors: 99 raise 100 v = str(e) 101 if v.startswith('Unsupported'): 102 import re 103 v = ''.join(re.findall(r'\d+\.?', str(e))) 104 return v[:-1] if v.endswith('.') else v 105 return v 106 except Exception: 107 if not suppress_errors: 108 raise 109 return '' 110 111 112if not have_rawkit: 113 have_functioning_rawkit = False 114else: 115 try: 116 have_functioning_rawkit = bool(libraw_version(suppress_errors=False)) 117 except Exception: 118 have_functioning_rawkit = False 119 120 121def rawkit_version() -> str: 122 if have_rawkit: 123 if have_functioning_rawkit: 124 return rawkit.VERSION 125 else: 126 return '{} (not functional)'.format(rawkit.VERSION) 127 return '' 128 129 130def get_video_frame(full_file_name: str, 131 offset: Optional[float]=5.0, 132 caps=Gst.Caps.from_string('image/png')) -> Optional[bytes]: 133 """ 134 Source: https://gist.github.com/dplanella/5563018 135 136 :param full_file_name: file and path of the video 137 :param offset: how many seconds into the video to read 138 :param caps: 139 :return: gstreamer buffer 140 """ 141 142 logging.debug("Using gstreamer to generate thumbnail from %s", full_file_name) 143 pipeline = Gst.parse_launch('playbin') 144 pipeline.props.uri = 'file://{}'.format(pathname2url(os.path.abspath(full_file_name))) 145 pipeline.props.audio_sink = Gst.ElementFactory.make('fakesink', 'fakeaudio') 146 pipeline.props.video_sink = Gst.ElementFactory.make('fakesink', 'fakevideo') 147 pipeline.set_state(Gst.State.PAUSED) 148 # Wait for state change to finish. 149 pipeline.get_state(Gst.CLOCK_TIME_NONE) 150 151 # Seek offset .10 seconds into the video as a minimum 152 if not offset: 153 offset = 0.5 * Gst.SECOND 154 155 # Duration is unreliable because when we are dealing with camera videos, 156 # we're only downloading a snapshot, i.e. 2 seconds of a 1 minute video. 157 # But no matter what, don't want to exceed it. 158 duration = pipeline.query_duration(Gst.Format.TIME)[1] 159 offset = min(duration, offset) 160 161 try: 162 v = pipeline.seek_simple( 163 Gst.Format.TIME, Gst.SeekFlags.FLUSH | Gst.SeekFlags.KEY_UNIT, offset 164 ) 165 assert v 166 except AssertionError: 167 logging.warning( 168 'seek_simple() failed for %s. Is the necessary gstreamer plugin installed for this ' 169 'file format?', full_file_name 170 ) 171 return None 172 # Wait for seek to finish. 173 pipeline.get_state(Gst.CLOCK_TIME_NONE) # alternative is Gst.SECOND * 10 174 sample = pipeline.emit('convert-sample', caps) 175 if sample is not None: 176 buffer = sample.get_buffer() 177 pipeline.set_state(Gst.State.NULL) 178 return buffer.extract_dup(0, buffer.get_size()) 179 else: 180 return None 181 182 183PhotoDetails = namedtuple('PhotoDetails', 'thumbnail, orientation') 184 185 186def qimage_to_png_buffer(image: QImage) -> QBuffer: 187 """ 188 Save the image data in PNG format in a QBuffer, whose data can then 189 be extracted using the data() member function. 190 :param image: the image to be converted 191 :return: the buffer 192 """ 193 194 buffer = QBuffer() 195 buffer.open(QIODevice.WriteOnly) 196 # Quality 100 means uncompressed. 197 image.save(buffer, "PNG", quality=100) 198 return buffer 199 200 201def crop_160x120_thumbnail(thumbnail: QImage, vertical_space: int=8) -> QImage: 202 """ 203 Remove black bands from the top and bottom of thumbnail 204 :param thumbnail: thumbnail to crop 205 :param vertical_space: how much to remove from the top and bottom 206 :return: cropped thumbnail 207 """ 208 if thumbnail.width() == 160 and thumbnail.height() == 120: 209 return thumbnail.copy(0, vertical_space, 160, 120 - vertical_space * 2) 210 elif thumbnail.width() == 120 and thumbnail.height() == 160: 211 return thumbnail.copy(vertical_space, 0, 120 - vertical_space * 2, 160) 212 else: 213 return thumbnail 214 215 216class ThumbnailExtractor(LoadBalancerWorker): 217 218 # Exif rotation constants 219 rotate_0 = '1' 220 rotate_90 = '6' 221 rotate_180 = '3' 222 rotate_270 = '8' 223 224 maxStandardSize = QSize( 225 max(ThumbnailSize.width, ThumbnailSize.height), 226 max(ThumbnailSize.width, ThumbnailSize.height) 227 ) 228 229 def __init__(self) -> None: 230 self.thumbnailSizeNeeded = QSize(ThumbnailSize.width, ThumbnailSize.height) 231 self.thumbnail_cache = ThumbnailCacheSql(create_table_if_not_exists=False) 232 self.fdo_cache_large = FdoCacheLarge() 233 self.fdo_cache_normal = FdoCacheNormal() 234 235 super().__init__('Thumbnail Extractor') 236 237 def rotate_thumb(self, thumbnail: QImage, orientation: str) -> QImage: 238 """ 239 If required return a rotated copy the thumbnail 240 :param thumbnail: thumbnail to rotate 241 :param orientation: EXIF orientation tag 242 :return: possibly rotated thumbnail 243 """ 244 245 if orientation == self.rotate_90: 246 thumbnail = thumbnail.transformed(QTransform().rotate(90)) 247 elif orientation == self.rotate_270: 248 thumbnail = thumbnail.transformed(QTransform().rotate(270)) 249 elif orientation == self.rotate_180: 250 thumbnail = thumbnail.transformed(QTransform().rotate(180)) 251 return thumbnail 252 253 def image_large_enough(self, size: QSize) -> bool: 254 """Check if image is equal or bigger than thumbnail size.""" 255 256 return ( 257 size.width() >= self.thumbnailSizeNeeded.width() or 258 size.height() >= self.thumbnailSizeNeeded.height() 259 ) 260 261 def _extract_256_thumb(self, rpd_file: RPDFile, 262 processing: Set[ExtractionProcessing], 263 orientation: Optional[str]) -> PhotoDetails: 264 265 thumbnail = None 266 data = rpd_file.metadata.get_preview_256() 267 if isinstance(data, bytes): 268 thumbnail = QImage.fromData(data) 269 if thumbnail.isNull(): 270 thumbnail = None 271 else: 272 if thumbnail.width() > 160 or thumbnail.height() > 120: 273 processing.add(ExtractionProcessing.resize) 274 275 return PhotoDetails(thumbnail, orientation) 276 277 def _extract_metadata(self, rpd_file: RPDFile, 278 processing: Set[ExtractionProcessing]) -> PhotoDetails: 279 280 thumbnail = orientation = None 281 try: 282 orientation = rpd_file.metadata.orientation() 283 except Exception: 284 pass 285 286 rpd_file.mdatatime = rpd_file.metadata.timestamp(missing=0.0) 287 288 # Not all files have an exif preview, but some do 289 # (typically CR2, ARW, PEF, RW2). 290 # If they exist, they are (almost!) always 160x120 291 292 # TODO how about thumbnail_cache_status? 293 if self.write_fdo_thumbnail and rpd_file.fdo_thumbnail_256 is None: 294 photo_details = self._extract_256_thumb( 295 rpd_file=rpd_file, processing=processing, orientation=orientation 296 ) 297 if photo_details.thumbnail is not None: 298 return photo_details 299 # if no valid preview found, fall back to the code below and make do with the best 300 # we can get 301 302 preview = rpd_file.metadata.get_small_thumbnail_or_first_indexed_preview() 303 if preview: 304 thumbnail = QImage.fromData(preview) 305 if thumbnail.isNull(): 306 thumbnail = None 307 else: 308 # logging.critical("%s, %sx%s", orientation, thumbnail.width(), thumbnail.height()) 309 if thumbnail.width() < thumbnail.height() and \ 310 orientation in (self.rotate_270, self.rotate_90): 311 # The orientation has already been applied to the thumbnail 312 logging.debug("Already rotated: %s", rpd_file.get_current_full_file_name()) 313 orientation = self.rotate_0 314 315 if max(thumbnail.width(), thumbnail.height()) > 160: 316 logging.debug("Resizing: %s", rpd_file.get_current_full_file_name()) 317 processing.add(ExtractionProcessing.resize) 318 elif not rpd_file.is_jpeg(): 319 processing.add(ExtractionProcessing.strip_bars_photo) 320 321 return PhotoDetails(thumbnail, orientation) 322 323 def get_disk_photo_thumb(self, rpd_file: Photo, 324 full_file_name: str, 325 processing: Set[ExtractionProcessing], 326 force_exiftool: bool) -> PhotoDetails: 327 """ 328 Get the photo's thumbnail from a file that is on disk. 329 330 Sets rpd_file's mdatatime. 331 332 :param rpd_file: file details 333 :param full_file_name: full name of the file from which to get the metadata 334 :param processing: processing extraction tasks to complete, 335 :param force_exiftool: whether to force the use of ExifTool to load the metadata 336 :return: thumbnail and its orientation 337 """ 338 339 orientation = None 340 thumbnail = None 341 photo_details = PhotoDetails(thumbnail, orientation) 342 if rpd_file.load_metadata(full_file_name=full_file_name, et_process=self.exiftool_process, 343 force_exiftool=force_exiftool): 344 345 photo_details = self._extract_metadata(rpd_file, processing) 346 thumbnail = photo_details.thumbnail 347 348 if thumbnail is not None: 349 return photo_details 350 elif rpd_file.is_raw() and have_functioning_rawkit: 351 try: 352 with rawkit.raw.Raw(filename=full_file_name) as raw: 353 raw.options.white_balance = rawkit.options.WhiteBalance(camera=True, auto=False) 354 if rpd_file.cache_full_file_name and not rpd_file.download_full_file_name: 355 temp_file = '{}.tiff'.format(os.path.splitext(full_file_name)[0]) 356 cache_dir = os.path.dirname(rpd_file.cache_full_file_name) 357 if os.path.isdir(cache_dir): 358 temp_file = os.path.join(cache_dir, temp_file) 359 temp_dir = None 360 else: 361 temp_dir = tempfile.mkdtemp(prefix="rpd-tmp-") 362 temp_file = os.path.join(temp_dir, temp_file) 363 else: 364 temp_dir = tempfile.mkdtemp(prefix="rpd-tmp-") 365 name = os.path.basename(full_file_name) 366 temp_file = '{}.tiff'.format(os.path.splitext(name)[0]) 367 temp_file = os.path.join(temp_dir, temp_file) 368 try: 369 logging.debug("Saving temporary rawkit render to %s", temp_file) 370 raw.save(filename=temp_file) 371 except Exception: 372 logging.exception( 373 "Rendering %s failed. Exception:", rpd_file.full_file_name 374 ) 375 else: 376 thumbnail = QImage(temp_file) 377 os.remove(temp_file) 378 if thumbnail.isNull(): 379 logging.debug("Qt failed to load rendered %s", rpd_file.full_file_name) 380 thumbnail = None 381 else: 382 logging.debug("Rendered %s using libraw", rpd_file.full_file_name) 383 processing.add(ExtractionProcessing.resize) 384 385 # libraw already correctly oriented the thumbnail 386 processing.remove(ExtractionProcessing.orient) 387 orientation = '1' 388 if temp_dir: 389 os.rmdir(temp_dir) 390 except ImportError as e: 391 logging.warning( 392 'Cannot use rawkit to render thumbnail for %s', rpd_file.full_file_name 393 ) 394 except Exception as e: 395 logging.exception( 396 "Rendering thumbnail for %s not supported. Exception:", rpd_file.full_file_name 397 ) 398 399 if thumbnail is None and rpd_file.is_loadable(): 400 thumbnail = QImage(full_file_name) 401 processing.add(ExtractionProcessing.resize) 402 if not rpd_file.from_camera: 403 processing.remove(ExtractionProcessing.orient) 404 if thumbnail.isNull(): 405 thumbnail = None 406 logging.warning( 407 "Unable to create a thumbnail out of the file: {}".format(full_file_name) 408 ) 409 410 return PhotoDetails(thumbnail, orientation) 411 412 def get_from_buffer(self, rpd_file: Photo, 413 raw_bytes: bytearray, 414 processing: Set[ExtractionProcessing]) -> PhotoDetails: 415 if not rpd_file.load_metadata(raw_bytes=raw_bytes, et_process=self.exiftool_process): 416 # logging.warning("Extractor failed to load metadata from extract of %s", rpd_file.name) 417 return PhotoDetails(None, None) 418 else: 419 return self._extract_metadata(rpd_file, processing) 420 421 def get_photo_orientation(self, rpd_file: Photo, 422 force_exiftool: bool, 423 full_file_name: Optional[str]=None, 424 raw_bytes: Optional[bytearray]=None) -> Optional[str]: 425 426 if rpd_file.metadata is None: 427 self.load_photo_metadata( 428 rpd_file=rpd_file, full_file_name=full_file_name, raw_bytes=raw_bytes, 429 force_exiftool=force_exiftool 430 ) 431 432 if rpd_file.metadata is not None: 433 try: 434 return rpd_file.metadata.orientation() 435 except Exception: 436 pass 437 return None 438 439 def assign_mdatatime(self, rpd_file: Union[Photo, Video], 440 force_exiftool: bool, 441 full_file_name: Optional[str]=None, 442 raw_bytes: Optional[bytearray]=None) -> None: 443 """ 444 Load the file's metadata and assign the metadata time to the rpd file 445 """ 446 447 if rpd_file.file_type == FileType.photo: 448 self.assign_photo_mdatatime( 449 rpd_file=rpd_file, full_file_name=full_file_name, raw_bytes=raw_bytes, 450 force_exiftool=force_exiftool 451 ) 452 else: 453 self.assign_video_mdatatime(rpd_file=rpd_file, full_file_name=full_file_name) 454 455 def assign_photo_mdatatime(self, rpd_file: Photo, force_exiftool: bool, 456 full_file_name: Optional[str]=None, 457 raw_bytes: Optional[bytearray]=None) -> None: 458 """ 459 Load the photo's metadata and assign the metadata time to the rpd file 460 """ 461 462 self.load_photo_metadata( 463 rpd_file=rpd_file, full_file_name=full_file_name, raw_bytes=raw_bytes, 464 force_exiftool=force_exiftool 465 ) 466 if rpd_file.metadata is not None and rpd_file.date_time() is None: 467 rpd_file.mdatatime = 0.0 468 469 def load_photo_metadata(self, rpd_file: Photo, force_exiftool: bool, 470 full_file_name: Optional[str]=None, 471 raw_bytes: Optional[bytearray]=None) -> None: 472 """ 473 Load the photo's metadata into the rpd file 474 """ 475 476 if raw_bytes is not None: 477 if rpd_file.is_jpeg_type(): 478 rpd_file.load_metadata(app1_segment=raw_bytes, et_process=self.exiftool_process) 479 else: 480 rpd_file.load_metadata(raw_bytes=raw_bytes, et_process=self.exiftool_process) 481 else: 482 rpd_file.load_metadata( 483 full_file_name=full_file_name, et_process=self.exiftool_process, 484 force_exiftool=force_exiftool 485 ) 486 487 def assign_video_mdatatime(self, rpd_file: Video, full_file_name: str) -> None: 488 """ 489 Load the video's metadata and assign the metadata time to the rpd file 490 """ 491 492 if rpd_file.metadata is None: 493 rpd_file.load_metadata(full_file_name=full_file_name, et_process=self.exiftool_process) 494 if rpd_file.date_time() is None: 495 rpd_file.mdatatime = 0.0 496 497 def get_video_rotation(self, rpd_file: Video, full_file_name: str) -> Optional[str]: 498 """ 499 Some videos have a rotation tag. If this video does, return it. 500 """ 501 502 if rpd_file.metadata is None: 503 rpd_file.load_metadata(full_file_name=full_file_name, et_process=self.exiftool_process) 504 orientation = rpd_file.metadata.rotation(missing=None) 505 if orientation == 180: 506 return self.rotate_180 507 elif orientation == 90: 508 return self.rotate_90 509 elif orientation == 270: 510 return self.rotate_270 511 return None 512 513 def check_for_stop(self, directive: bytes, content: bytes): 514 if directive == b'cmd': 515 assert content == b'STOP' 516 return True 517 return False 518 519 def extract_thumbnail(self, task: ExtractionTask, 520 rpd_file: Union[Photo, Video], 521 processing: Set[ExtractionProcessing], 522 data: ThumbnailExtractorArgument 523 ) -> Tuple[Optional[QImage], Optional[str]]: 524 """ 525 Extract the thumbnail using one of a variety of methods, 526 depending on the file 527 528 :param task: extraction task to perform 529 :param rpd_file: rpd_file to work on 530 :param processing: processing tasks 531 :param data: some other processing arguments passed to this process 532 :return: thumbnail and its orientation, if found 533 """ 534 535 orientation = None 536 537 if task == ExtractionTask.load_from_exif: 538 thumbnail_details = self.get_disk_photo_thumb( 539 rpd_file, data.full_file_name_to_work_on, processing, data.force_exiftool 540 ) 541 thumbnail = thumbnail_details.thumbnail 542 if thumbnail is not None: 543 orientation = thumbnail_details.orientation 544 545 elif task in (ExtractionTask.load_file_directly, 546 ExtractionTask.load_file_and_exif_directly, 547 ExtractionTask.load_file_directly_metadata_from_secondary): 548 thumbnail = QImage(data.full_file_name_to_work_on) 549 550 if task == ExtractionTask.load_file_and_exif_directly: 551 self.assign_photo_mdatatime( 552 rpd_file=rpd_file, full_file_name=data.full_file_name_to_work_on, 553 force_exiftool=data.force_exiftool 554 ) 555 elif task == ExtractionTask.load_file_directly_metadata_from_secondary: 556 self.assign_mdatatime( 557 rpd_file=rpd_file, full_file_name=data.secondary_full_file_name, 558 force_exiftool=data.force_exiftool 559 ) 560 561 if ExtractionProcessing.orient in processing: 562 orientation = self.get_photo_orientation( 563 rpd_file=rpd_file, full_file_name=data.full_file_name_to_work_on, 564 force_exiftool=data.force_exiftool 565 ) 566 567 elif task in (ExtractionTask.load_from_bytes, 568 ExtractionTask.load_from_bytes_metadata_from_temp_extract): 569 try: 570 assert data.thumbnail_bytes is not None 571 except AssertionError: 572 logging.error( 573 "Thumbnail bytes not extracted for %s (value is None)", 574 rpd_file.get_current_full_file_name() 575 ) 576 thumbnail = QImage.fromData(data.thumbnail_bytes) 577 if thumbnail.width() > self.thumbnailSizeNeeded.width() or thumbnail.height()\ 578 > self.thumbnailSizeNeeded.height(): 579 processing.add(ExtractionProcessing.resize) 580 processing.remove(ExtractionProcessing.strip_bars_photo) 581 if data.exif_buffer and ExtractionProcessing.orient in processing: 582 orientation = self.get_photo_orientation( 583 rpd_file=rpd_file, raw_bytes=data.exif_buffer, 584 force_exiftool=data.force_exiftool 585 ) 586 if task == ExtractionTask.load_from_bytes_metadata_from_temp_extract: 587 self.assign_mdatatime( 588 rpd_file=rpd_file, full_file_name=data.secondary_full_file_name, 589 force_exiftool=data.force_exiftool 590 ) 591 orientation = rpd_file.metadata.orientation() 592 os.remove(data.secondary_full_file_name) 593 rpd_file.temp_cache_full_file_chunk = '' 594 595 elif task == ExtractionTask.load_from_exif_buffer: 596 thumbnail_details = self.get_from_buffer(rpd_file, data.exif_buffer, processing) 597 thumbnail = thumbnail_details.thumbnail 598 if thumbnail is not None: 599 orientation = thumbnail_details.orientation 600 601 elif task in (ExtractionTask.load_heif_directly, 602 ExtractionTask.load_heif_and_exif_directly): 603 assert have_heif_module 604 thumbnail = load_heif( 605 data.full_file_name_to_work_on, process_name=self.identity.decode() 606 ) 607 608 if task == ExtractionTask.load_heif_and_exif_directly: 609 self.assign_photo_mdatatime( 610 rpd_file=rpd_file, full_file_name=data.full_file_name_to_work_on, 611 force_exiftool=data.force_exiftool 612 ) 613 if ExtractionProcessing.orient in processing: 614 orientation = self.get_photo_orientation( 615 rpd_file=rpd_file, full_file_name=data.full_file_name_to_work_on, 616 force_exiftool=data.force_exiftool 617 ) 618 619 else: 620 assert task in ( 621 ExtractionTask.extract_from_file, ExtractionTask.extract_from_file_and_load_metadata 622 ) 623 if rpd_file.file_type == FileType.photo: 624 self.assign_photo_mdatatime( 625 rpd_file=rpd_file, full_file_name=data.full_file_name_to_work_on, 626 force_exiftool=data.force_exiftool 627 ) 628 thumbnail_bytes = rpd_file.metadata.get_small_thumbnail_or_first_indexed_preview() 629 if thumbnail_bytes: 630 thumbnail = QImage.fromData(thumbnail_bytes) 631 orientation = rpd_file.metadata.orientation() 632 else: 633 assert rpd_file.file_type == FileType.video 634 635 if ExtractionTask.extract_from_file_and_load_metadata: 636 self.assign_video_mdatatime( 637 rpd_file=rpd_file, full_file_name=data.full_file_name_to_work_on 638 ) 639 if not have_gst: 640 thumbnail = None 641 else: 642 png = get_video_frame(data.full_file_name_to_work_on, 1.0) 643 if not png: 644 thumbnail = None 645 logging.warning( 646 "Could not extract video thumbnail from %s", 647 data.rpd_file.get_display_full_name() 648 ) 649 else: 650 thumbnail = QImage.fromData(png) 651 if thumbnail.isNull(): 652 thumbnail = None 653 else: 654 processing.add(ExtractionProcessing.add_film_strip) 655 orientation = self.get_video_rotation( 656 rpd_file, data.full_file_name_to_work_on 657 ) 658 if orientation is not None: 659 processing.add(ExtractionProcessing.orient) 660 processing.add(ExtractionProcessing.resize) 661 662 return thumbnail, orientation 663 664 def process_files(self): 665 """ 666 Loop continuously processing photo and video thumbnails 667 """ 668 669 logging.debug("{} worker started".format(self.requester.identity.decode())) 670 671 while True: 672 directive, content = self.requester.recv_multipart() 673 if self.check_for_stop(directive, content): 674 break 675 676 data = pickle.loads(content) # type: ThumbnailExtractorArgument 677 678 thumbnail_256 = png_data = None 679 task = data.task 680 processing = data.processing 681 rpd_file = data.rpd_file 682 683 logging.debug( 684 "Working on task %s for %s", task.name, rpd_file.download_name or rpd_file.name 685 ) 686 687 self.write_fdo_thumbnail = data.write_fdo_thumbnail 688 689 try: 690 if rpd_file.fdo_thumbnail_256 is not None and data.write_fdo_thumbnail: 691 if rpd_file.thumbnail_status != ThumbnailCacheStatus.fdo_256_ready: 692 logging.error( 693 "Unexpected thumbnail cache status for %s: %s", 694 rpd_file.full_file_name, rpd_file.thumbnail_status.name 695 ) 696 thumbnail = thumbnail_256 = QImage.fromData(rpd_file.fdo_thumbnail_256) 697 orientation_unknown = False 698 else: 699 thumbnail, orientation = self.extract_thumbnail( 700 task, rpd_file, processing, data 701 ) 702 if data.file_to_work_on_is_temporary: 703 os.remove(data.full_file_name_to_work_on) 704 rpd_file.temp_cache_full_file_chunk = '' 705 706 if thumbnail is not None: 707 if ExtractionProcessing.strip_bars_photo in processing: 708 thumbnail = crop_160x120_thumbnail(thumbnail) 709 elif ExtractionProcessing.strip_bars_video in processing: 710 thumbnail = crop_160x120_thumbnail(thumbnail, 15) 711 if ExtractionProcessing.resize in processing: 712 # Resize the thumbnail before rotating 713 if ((orientation == '1' or orientation is None) and 714 thumbnail.height() > thumbnail.width()): 715 716 # Special case: pictures from some cellphones have already 717 # been rotated 718 thumbnail = thumbnail.scaled( 719 self.maxStandardSize, 720 Qt.KeepAspectRatio, 721 Qt.SmoothTransformation 722 ) 723 else: 724 if rpd_file.should_write_fdo() and \ 725 image_large_enough_fdo(thumbnail.size()) \ 726 and max(thumbnail.height(), thumbnail.width()) > 256: 727 thumbnail_256 = thumbnail.scaled( 728 QSize(256, 256), 729 Qt.KeepAspectRatio, 730 Qt.SmoothTransformation 731 ) 732 thumbnail = thumbnail_256 733 if data.send_thumb_to_main: 734 # thumbnail = self.rotate_thumb(thumbnail, orientation) 735 # orientation = None 736 thumbnail = thumbnail.scaled( 737 self.thumbnailSizeNeeded, 738 Qt.KeepAspectRatio, 739 Qt.SmoothTransformation 740 ) 741 else: 742 thumbnail = None 743 744 if not thumbnail is None and thumbnail.isNull(): 745 thumbnail = None 746 747 if orientation is not None: 748 if thumbnail is not None: 749 thumbnail = self.rotate_thumb(thumbnail, orientation) 750 if thumbnail_256 is not None: 751 thumbnail_256 = self.rotate_thumb(thumbnail_256, orientation) 752 753 if ExtractionProcessing.add_film_strip in processing: 754 if thumbnail is not None: 755 thumbnail = add_filmstrip(thumbnail) 756 if thumbnail_256 is not None: 757 thumbnail = add_filmstrip(thumbnail_256) 758 759 if thumbnail is not None: 760 buffer = qimage_to_png_buffer(thumbnail) 761 png_data = buffer.data() 762 763 orientation_unknown = ( 764 ExtractionProcessing.orient in processing and orientation is None 765 ) 766 767 if data.send_thumb_to_main and data.use_thumbnail_cache and \ 768 rpd_file.thumbnail_cache_status == ThumbnailCacheDiskStatus.not_found: 769 self.thumbnail_cache.save_thumbnail( 770 full_file_name=rpd_file.full_file_name, 771 size=rpd_file.size, 772 mtime=rpd_file.modification_time, 773 mdatatime=rpd_file.mdatatime, 774 generation_failed=thumbnail is None, 775 orientation_unknown=orientation_unknown, 776 thumbnail=thumbnail, 777 camera_model=rpd_file.camera_model 778 ) 779 780 if (thumbnail is not None or thumbnail_256 is not None) and \ 781 rpd_file.should_write_fdo(): 782 if self.write_fdo_thumbnail: 783 # The modification time of the file may have changed when the file was saved 784 # Ideally it shouldn't, but it does sometimes, e.g. on NTFS! 785 # So need to get the modification time from the saved file. 786 mtime = os.path.getmtime(rpd_file.download_full_file_name) 787 788 if thumbnail_256 is not None: 789 rpd_file.fdo_thumbnail_256_name = self.fdo_cache_large.save_thumbnail( 790 full_file_name=rpd_file.download_full_file_name, 791 size=rpd_file.size, 792 modification_time=mtime, 793 generation_failed=False, 794 thumbnail=thumbnail_256, 795 free_desktop_org=False 796 ) 797 thumbnail_128 = thumbnail_256.scaled( 798 QSize(128, 128), 799 Qt.KeepAspectRatio, 800 Qt.SmoothTransformation 801 ) 802 else: 803 thumbnail_128 = thumbnail.scaled( 804 QSize(128, 128), 805 Qt.KeepAspectRatio, 806 Qt.SmoothTransformation 807 ) 808 rpd_file.fdo_thumbnail_128_name = self.fdo_cache_normal.save_thumbnail( 809 full_file_name=rpd_file.download_full_file_name, 810 size=rpd_file.size, 811 modification_time=mtime, 812 generation_failed=False, 813 thumbnail=thumbnail_128, 814 free_desktop_org=False 815 ) 816 elif thumbnail_256 is not None and rpd_file.fdo_thumbnail_256 is None: 817 rpd_file.fdo_thumbnail_256 = qimage_to_png_buffer(thumbnail).data() 818 819 if thumbnail is not None: 820 if orientation_unknown: 821 rpd_file.thumbnail_status = ThumbnailCacheStatus.orientation_unknown 822 elif rpd_file.fdo_thumbnail_256 is not None: 823 rpd_file.thumbnail_status = ThumbnailCacheStatus.fdo_256_ready 824 else: 825 rpd_file.thumbnail_status = ThumbnailCacheStatus.ready 826 827 except SystemExit as e: 828 self.exiftool_process.terminate() 829 sys.exit(e) 830 except: 831 logging.error("Exception working on file %s", rpd_file.full_file_name) 832 logging.error("Task: %s", task) 833 logging.error("Processing tasks: %s", processing) 834 logging.exception("Traceback:") 835 836 # Purge metadata, as it cannot be pickled 837 if not data.send_thumb_to_main: 838 png_data = None 839 rpd_file.metadata = None 840 self.sender.send_multipart( 841 [ 842 b'0', b'data', 843 pickle.dumps( 844 GenerateThumbnailsResults(rpd_file=rpd_file, thumbnail_bytes=png_data), 845 pickle.HIGHEST_PROTOCOL 846 ) 847 ] 848 ) 849 self.requester.send_multipart([b'', b'', b'OK']) 850 851 def do_work(self): 852 if False: 853 # exiv2 pumps out a LOT to stderr - use cautiously! 854 context = show_errors() 855 self.error_stream = sys.stderr 856 else: 857 # Redirect stderr, hiding error output from exiv2 858 context = stdchannel_redirected(sys.stderr, os.devnull) 859 self.error_stream = sys.stdout 860 with context: 861 # In some situations, using a context manager for exiftool can 862 # result in exiftool processes not being terminated. So let's 863 # handle starting and terminating it manually. 864 self.exiftool_process = exiftool.ExifTool() 865 self.exiftool_process.start() 866 self.process_files() 867 self.exit() 868 869 def cleanup_pre_stop(self) -> None: 870 logging.debug( 871 "Terminating thumbnail extractor ExifTool process for %s", self.identity.decode() 872 ) 873 self.exiftool_process.terminate() 874 875 876if __name__ == "__main__": 877 thumbnail_extractor = ThumbnailExtractor()