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()