Exemplo n.º 1
0
class Upscaler:
    """ An instance of this class is a upscaler that will
    upscale all images in the given directory.

    Raises:
        Exception -- all exceptions
        ArgumentError -- if argument is not valid
    """
    def __init__(self,
                 input_path: pathlib.Path or list,
                 output_path: pathlib.Path,
                 driver_settings: dict,
                 ffmpeg_settings: dict,
                 gifski_settings: dict,
                 driver: str = 'waifu2x_caffe',
                 scale_ratio: float = None,
                 processes: int = 1,
                 video2x_cache_directory: pathlib.Path = pathlib.Path(
                     tempfile.gettempdir()) / 'video2x',
                 extracted_frame_format: str = 'png',
                 output_file_name_format_string:
                 str = '{original_file_name}_output{extension}',
                 image_output_extension: str = '.png',
                 video_output_extension: str = '.mp4',
                 preserve_frames: bool = False):

        # required parameters
        self.input = input_path
        self.output = output_path
        self.driver_settings = driver_settings
        self.ffmpeg_settings = ffmpeg_settings
        self.gifski_settings = gifski_settings

        # optional parameters
        self.driver = driver
        self.scale_ratio = scale_ratio
        self.processes = processes
        self.video2x_cache_directory = video2x_cache_directory
        self.extracted_frame_format = extracted_frame_format
        self.output_file_name_format_string = output_file_name_format_string
        self.image_output_extension = image_output_extension
        self.video_output_extension = video_output_extension
        self.preserve_frames = preserve_frames

        # other internal members and signals
        self.running = False
        self.total_frames_upscaled = 0
        self.total_frames = 0
        self.total_files = 0
        self.total_processed = 0
        self.current_input_file = pathlib.Path()
        self.last_frame_upscaled = pathlib.Path()

    def create_temp_directories(self):
        """create temporary directories
        """

        # if cache directory unspecified, use %TEMP%\video2x
        if self.video2x_cache_directory is None:
            self.video2x_cache_directory = pathlib.Path(
                tempfile.gettempdir()) / 'video2x'

        # if specified cache path exists and isn't a directory
        if self.video2x_cache_directory.exists(
        ) and not self.video2x_cache_directory.is_dir():
            Avalon.error(
                _('Specified or default cache directory is a file/link'))
            raise FileExistsError(
                'Specified or default cache directory is a file/link')

        # if cache directory doesn't exist, try creating it
        if not self.video2x_cache_directory.exists():
            try:
                Avalon.debug_info(
                    _('Creating cache directory {}').format(
                        self.video2x_cache_directory))
                self.video2x_cache_directory.mkdir(parents=True, exist_ok=True)
            except Exception as exception:
                Avalon.error(
                    _('Unable to create {}').format(
                        self.video2x_cache_directory))
                raise exception

        # create temp directories for extracted frames and upscaled frames
        self.extracted_frames = pathlib.Path(
            tempfile.mkdtemp(dir=self.video2x_cache_directory))
        Avalon.debug_info(
            _('Extracted frames are being saved to: {}').format(
                self.extracted_frames))
        self.upscaled_frames = pathlib.Path(
            tempfile.mkdtemp(dir=self.video2x_cache_directory))
        Avalon.debug_info(
            _('Upscaled frames are being saved to: {}').format(
                self.upscaled_frames))

    def cleanup_temp_directories(self):
        """delete temp directories when done
        """
        if not self.preserve_frames:
            for directory in [
                    self.extracted_frames, self.upscaled_frames,
                    self.video2x_cache_directory
            ]:
                try:
                    # avalon framework cannot be used if python is shutting down
                    # therefore, plain print is used
                    print(
                        _('Cleaning up cache directory: {}').format(directory))
                    shutil.rmtree(directory)
                except FileNotFoundError:
                    pass
                except OSError:
                    print(_('Unable to delete: {}').format(directory))
                    traceback.print_exc()

    def _check_arguments(self):
        if isinstance(self.input, list):
            if self.output.exists() and not self.output.is_dir():
                Avalon.error(_('Input and output path type mismatch'))
                Avalon.error(
                    _('Input is multiple files but output is not directory'))
                raise ArgumentError('input output path type mismatch')
            for input_path in self.input:
                if not input_path.is_file() and not input_path.is_dir():
                    Avalon.error(
                        _('Input path {} is neither a file nor a directory').
                        format(input_path))
                    raise FileNotFoundError(
                        f'{input_path} is neither file nor directory')
                with contextlib.suppress(FileNotFoundError):
                    if input_path.samefile(self.output):
                        Avalon.error(
                            _('Input directory and output directory cannot be the same'
                              ))
                        raise FileExistsError(
                            'input directory and output directory are the same'
                        )

        # if input is a file
        elif self.input.is_file():
            if self.output.is_dir():
                Avalon.error(_('Input and output path type mismatch'))
                Avalon.error(_('Input is single file but output is directory'))
                raise ArgumentError('input output path type mismatch')
            if self.output.suffix == '':
                Avalon.error(_('No suffix found in output file path'))
                Avalon.error(_('Suffix must be specified'))
                raise ArgumentError('no output file suffix specified')

        # if input is a directory
        elif self.input.is_dir():
            if self.output.is_file():
                Avalon.error(_('Input and output path type mismatch'))
                Avalon.error(
                    _('Input is directory but output is existing single file'))
                raise ArgumentError('input output path type mismatch')
            with contextlib.suppress(FileNotFoundError):
                if self.input.samefile(self.output):
                    Avalon.error(
                        _('Input directory and output directory cannot be the same'
                          ))
                    raise FileExistsError(
                        'input directory and output directory are the same')

        # if input is neither
        else:
            Avalon.error(_('Input path is neither a file nor a directory'))
            raise FileNotFoundError(
                f'{self.input} is neither file nor directory')

        # check FFmpeg settings
        ffmpeg_path = pathlib.Path(self.ffmpeg_settings['ffmpeg_path'])
        if not ((pathlib.Path(ffmpeg_path / 'ffmpeg.exe').is_file()
                 and pathlib.Path(ffmpeg_path / 'ffprobe.exe').is_file()) or
                (pathlib.Path(ffmpeg_path / 'ffmpeg').is_file()
                 and pathlib.Path(ffmpeg_path / 'ffprobe').is_file())):
            Avalon.error(
                _('FFmpeg or FFprobe cannot be found under the specified path')
            )
            Avalon.error(_('Please check the configuration file settings'))
            raise FileNotFoundError(self.ffmpeg_settings['ffmpeg_path'])

        # check if driver settings
        driver_settings = copy.deepcopy(self.driver_settings)
        driver_path = driver_settings.pop('path')

        # check if driver path exists
        if not (pathlib.Path(driver_path).is_file()
                or pathlib.Path(f'{driver_path}.exe').is_file()):
            Avalon.error(
                _('Specified driver executable directory doesn\'t exist'))
            Avalon.error(_('Please check the configuration file settings'))
            raise FileNotFoundError(driver_path)

        # parse driver arguments using driver's parser
        # the parser will throw AttributeError if argument doesn't satisfy constraints
        try:
            driver_arguments = []
            for key in driver_settings.keys():

                value = driver_settings[key]

                if value is None or value is False:
                    continue

                else:
                    if len(key) == 1:
                        driver_arguments.append(f'-{key}')
                    else:
                        driver_arguments.append(f'--{key}')
                    # true means key is an option
                    if value is not True:
                        driver_arguments.append(str(value))

            DriverWrapperMain = getattr(
                importlib.import_module(f'wrappers.{self.driver}'),
                'WrapperMain')
            DriverWrapperMain.parse_arguments(driver_arguments)
        except AttributeError as e:
            Avalon.error(
                _('Failed to parse driver argument: {}').format(e.args[0]))
            raise e

        # waifu2x-caffe scale_ratio, scale_width and scale_height check
        if self.driver == 'waifu2x_caffe':
            if (driver_settings['scale_width'] != 0
                    and driver_settings['scale_height'] == 0
                    or driver_settings['scale_width'] == 0
                    and driver_settings['scale_height'] != 0):
                Avalon.error(
                    _('Only one of scale_width and scale_height is specified for waifu2x-caffe'
                      ))
                raise AttributeError(
                    'only one of scale_width and scale_height is specified for waifu2x-caffe'
                )

            # if scale_width and scale_height are specified, ensure scale_ratio is None
            elif self.driver_settings[
                    'scale_width'] != 0 and self.driver_settings[
                        'scale_height'] != 0:
                self.driver_settings['scale_ratio'] = None

            # if scale_width and scale_height not specified
            # ensure they are None, not 0
            else:
                self.driver_settings['scale_width'] = None
                self.driver_settings['scale_height'] = None

    def _upscale_frames(self):
        """ Upscale video frames with waifu2x-caffe

        This function upscales all the frames extracted
        by ffmpeg using the waifu2x-caffe binary.

        Arguments:
            w2 {Waifu2x Object} -- initialized waifu2x object
        """

        # initialize waifu2x driver
        if self.driver not in AVAILABLE_DRIVERS:
            raise UnrecognizedDriverError(
                _('Unrecognized driver: {}').format(self.driver))

        # list all images in the extracted frames
        frames = [(self.extracted_frames / f)
                  for f in self.extracted_frames.iterdir() if f.is_file]

        # if we have less images than processes,
        # create only the processes necessary
        if len(frames) < self.processes:
            self.processes = len(frames)

        # create a directory for each process and append directory
        # name into a list
        process_directories = []
        for process_id in range(self.processes):
            process_directory = self.extracted_frames / str(process_id)
            process_directories.append(process_directory)

            # delete old directories and create new directories
            if process_directory.is_dir():
                shutil.rmtree(process_directory)
            process_directory.mkdir(parents=True, exist_ok=True)

        # waifu2x-converter-cpp will perform multi-threading within its own process
        if self.driver in [
                'waifu2x_converter_cpp', 'waifu2x_ncnn_vulkan',
                'srmd_ncnn_vulkan', 'realsr_ncnn_vulkan', 'anime4kcpp'
        ]:
            process_directories = [self.extracted_frames]

        else:
            # evenly distribute images into each directory
            # until there is none left in the directory
            for image in frames:
                # move image
                image.rename(process_directories[0] / image.name)
                # rotate list
                process_directories = process_directories[
                    -1:] + process_directories[:-1]

        # create driver processes and start them
        for process_directory in process_directories:
            self.process_pool.append(
                self.driver_object.upscale(process_directory,
                                           self.upscaled_frames))

        # start progress bar in a different thread
        Avalon.debug_info(_('Starting progress monitor'))
        self.progress_monitor = ProgressMonitor(self, process_directories)
        self.progress_monitor.start()

        # create the clearer and start it
        Avalon.debug_info(_('Starting upscaled image cleaner'))
        self.image_cleaner = ImageCleaner(self.extracted_frames,
                                          self.upscaled_frames,
                                          len(self.process_pool))
        self.image_cleaner.start()

        # wait for all process to exit
        try:
            self._wait()
        except (Exception, KeyboardInterrupt, SystemExit) as e:
            # cleanup
            Avalon.debug_info(_('Killing progress monitor'))
            self.progress_monitor.stop()

            Avalon.debug_info(_('Killing upscaled image cleaner'))
            self.image_cleaner.stop()
            raise e

        # if the driver is waifu2x-converter-cpp
        # images need to be renamed to be recognizable for FFmpeg
        if self.driver == 'waifu2x_converter_cpp':
            for image in [
                    f for f in self.upscaled_frames.iterdir() if f.is_file()
            ]:
                renamed = re.sub(
                    f'_\\[.*\\]\\[x(\\d+(\\.\\d+)?)\\]\\.{self.extracted_frame_format}',
                    f'.{self.extracted_frame_format}', str(image.name))
                (self.upscaled_frames / image).rename(self.upscaled_frames /
                                                      renamed)

        # upscaling done, kill helper threads
        Avalon.debug_info(_('Killing progress monitor'))
        self.progress_monitor.stop()

        Avalon.debug_info(_('Killing upscaled image cleaner'))
        self.image_cleaner.stop()

    def _terminate_subprocesses(self):
        Avalon.warning(_('Terminating all processes'))
        for process in self.process_pool:
            process.terminate()

    def _wait(self):
        """ wait for subprocesses in process pool to complete
        """
        Avalon.debug_info(_('Main process waiting for subprocesses to exit'))

        try:
            # while process pool not empty
            while self.process_pool:

                # if stop signal received, terminate all processes
                if self.running is False:
                    raise SystemExit

                for process in self.process_pool:
                    process_status = process.poll()

                    # if process finished
                    if process_status is None:
                        continue

                    # if return code is not 0
                    elif process_status != 0:
                        Avalon.error(
                            _('Subprocess {} exited with code {}').format(
                                process.pid, process_status))
                        raise subprocess.CalledProcessError(
                            process_status, process.args)

                    else:
                        Avalon.debug_info(
                            _('Subprocess {} exited with code {}').format(
                                process.pid, process_status))
                        self.process_pool.remove(process)

                time.sleep(0.1)

        except (KeyboardInterrupt, SystemExit) as e:
            Avalon.warning(_('Stop signal received'))
            self._terminate_subprocesses()
            raise e

        except (Exception, subprocess.CalledProcessError) as e:
            Avalon.error(_('Subprocess execution ran into an error'))
            self._terminate_subprocesses()
            raise e

    def run(self):
        """ Main controller for Video2X

        This function controls the flow of video conversion
        and handles all necessary functions.
        """

        # external stop signal when called in a thread
        self.running = True

        # define process pool to contain processes
        self.process_pool = []

        # load driver modules
        DriverWrapperMain = getattr(
            importlib.import_module(f'wrappers.{self.driver}'), 'WrapperMain')
        self.driver_object = DriverWrapperMain(self.driver_settings)

        # load options from upscaler class into driver settings
        self.driver_object.load_configurations(self)

        # initialize FFmpeg object
        self.ffmpeg_object = Ffmpeg(
            self.ffmpeg_settings,
            extracted_frame_format=self.extracted_frame_format)

        # define processing queue
        self.processing_queue = queue.Queue()

        Avalon.info(_('Loading files into processing queue'))
        Avalon.debug_info(_('Input path(s): {}').format(self.input))

        # make output directory if the input is a list or a directory
        if isinstance(self.input, list) or self.input.is_dir():
            self.output.mkdir(parents=True, exist_ok=True)

        input_files = []

        # if input is single directory
        # put it in a list for compability with the following code
        if not isinstance(self.input, list):
            input_paths = [self.input]
        else:
            input_paths = self.input

        # flatten directories into file paths
        for input_path in input_paths:

            # if the input path is a single file
            # add the file's path object to input_files
            if input_path.is_file():
                input_files.append(input_path)

            # if the input path is a directory
            # add all files under the directory into the input_files (non-recursive)
            elif input_path.is_dir():
                input_files.extend(
                    [f for f in input_path.iterdir() if f.is_file()])

        output_paths = []

        for input_path in input_files:

            # get file type
            # try python-magic if it's available
            try:
                input_file_mime_type = magic.from_file(str(
                    input_path.absolute()),
                                                       mime=True)
                input_file_type = input_file_mime_type.split('/')[0]
                input_file_subtype = input_file_mime_type.split('/')[1]
            except Exception:
                input_file_type = input_file_subtype = None

            # in case python-magic fails to detect file type
            # try guessing file mime type with mimetypes
            if input_file_type not in ['image', 'video']:
                input_file_mime_type = mimetypes.guess_type(input_path.name)[0]
                input_file_type = input_file_mime_type.split('/')[0]
                input_file_subtype = input_file_mime_type.split('/')[1]

            # set default output file suffixes
            # if image type is GIF, default output suffix is also .gif
            if input_file_mime_type == 'image/gif':
                output_path = self.output / self.output_file_name_format_string.format(
                    original_file_name=input_path.stem, extension='.gif')

            elif input_file_type == 'image':
                output_path = self.output / self.output_file_name_format_string.format(
                    original_file_name=input_path.stem,
                    extension=self.image_output_extension)

            elif input_file_type == 'video':
                output_path = self.output / self.output_file_name_format_string.format(
                    original_file_name=input_path.stem,
                    extension=self.video_output_extension)

            # if file is none of: image, image/gif, video
            # skip to the next task
            else:
                Avalon.error(
                    _('File {} ({}) neither an image nor a video').format(
                        input_path, input_file_mime_type))
                Avalon.warning(_('Skipping this file'))
                continue

            # if there is only one input file
            # do not modify output file suffix
            if isinstance(self.input, pathlib.Path) and self.input.is_file():
                output_path = self.output

            output_path_id = 0
            while str(output_path) in output_paths:
                output_path = output_path.parent / pathlib.Path(
                    f'{output_path.stem}_{output_path_id}{output_path.suffix}')
                output_path_id += 1

            # record output path
            output_paths.append(str(output_path))

            # push file information into processing queue
            self.processing_queue.put(
                (input_path.absolute(), output_path.absolute(),
                 input_file_mime_type, input_file_type, input_file_subtype))

        # check argument sanity before running
        self._check_arguments()

        # record file count for external calls
        self.total_files = self.processing_queue.qsize()

        Avalon.info(_('Loaded files into processing queue'))
        # print all files in queue for debugging
        for job in self.processing_queue.queue:
            Avalon.debug_info(_('Input file: {}').format(job[0].absolute()))

        try:
            while not self.processing_queue.empty():

                # get new job from queue
                self.current_input_file, output_path, input_file_mime_type, input_file_type, input_file_subtype = self.processing_queue.get(
                )

                # start handling input
                # if input file is a static image
                if input_file_type == 'image' and input_file_subtype != 'gif':
                    Avalon.info(_('Starting to upscale image'))
                    self.process_pool.append(
                        self.driver_object.upscale(self.current_input_file,
                                                   output_path))
                    self._wait()
                    Avalon.info(_('Upscaling completed'))

                    # static images don't require GIF or video encoding
                    # go to the next task
                    self.processing_queue.task_done()
                    self.total_processed += 1
                    continue

                # if input file is a image/gif file or a video
                elif input_file_mime_type == 'image/gif' or input_file_type == 'video':

                    self.create_temp_directories()

                    # get video information JSON using FFprobe
                    Avalon.info(_('Reading video information'))
                    video_info = self.ffmpeg_object.probe_file_info(
                        self.current_input_file)
                    # analyze original video with FFprobe and retrieve framerate
                    # width, height = info['streams'][0]['width'], info['streams'][0]['height']

                    # find index of video stream
                    video_stream_index = None
                    for stream in video_info['streams']:
                        if stream['codec_type'] == 'video':
                            video_stream_index = stream['index']
                            break

                    # exit if no video stream found
                    if video_stream_index is None:
                        Avalon.error(_('Aborting: No video stream found'))
                        raise StreamNotFoundError('no video stream found')

                    # get average frame rate of video stream
                    framerate = float(
                        Fraction(video_info['streams'][video_stream_index]
                                 ['r_frame_rate']))
                    Avalon.info(_('Framerate: {}').format(framerate))
                    # self.ffmpeg_object.pixel_format = video_info['streams'][video_stream_index]['pix_fmt']

                    # extract frames from video
                    self.process_pool.append(
                        (self.ffmpeg_object.extract_frames(
                            self.current_input_file, self.extracted_frames)))
                    self._wait()

                    # if driver is waifu2x-caffe
                    # pass pixel format output depth information
                    if self.driver == 'waifu2x_caffe':
                        # get a dict of all pixel formats and corresponding bit depth
                        pixel_formats = self.ffmpeg_object.get_pixel_formats()

                        # try getting pixel format's corresponding bti depth
                        try:
                            self.driver_settings[
                                'output_depth'] = pixel_formats[
                                    self.ffmpeg_object.pixel_format]
                        except KeyError:
                            Avalon.error(
                                _('Unsupported pixel format: {}').format(
                                    self.ffmpeg_object.pixel_format))
                            raise UnsupportedPixelError(
                                f'unsupported pixel format {self.ffmpeg_object.pixel_format}'
                            )

                    # width/height will be coded width/height x upscale factor
                    # original_width = video_info['streams'][video_stream_index]['width']
                    # original_height = video_info['streams'][video_stream_index]['height']
                    # scale_width = int(self.scale_ratio * original_width)
                    # scale_height = int(self.scale_ratio * original_height)

                    # upscale images one by one using waifu2x
                    Avalon.info(_('Starting to upscale extracted frames'))
                    self._upscale_frames()
                    Avalon.info(_('Upscaling completed'))

                # start handling output
                # output can be either GIF or video

                # if the desired output is gif file
                if output_path.suffix.lower() == '.gif':
                    Avalon.info(
                        _('Converting extracted frames into GIF image'))
                    gifski_object = Gifski(self.gifski_settings)
                    self.process_pool.append(
                        gifski_object.make_gif(self.upscaled_frames,
                                               output_path, framerate,
                                               self.extracted_frame_format))
                    self._wait()
                    Avalon.info(_('Conversion completed'))

                # if the desired output is video
                else:
                    # frames to video
                    Avalon.info(_('Converting extracted frames into video'))
                    self.process_pool.append(
                        self.ffmpeg_object.assemble_video(
                            framerate, self.upscaled_frames))
                    # f'{scale_width}x{scale_height}'
                    self._wait()
                    Avalon.info(_('Conversion completed'))

                    try:
                        # migrate audio tracks and subtitles
                        Avalon.info(
                            _('Migrating audio, subtitles and other streams to upscaled video'
                              ))
                        self.process_pool.append(
                            self.ffmpeg_object.migrate_streams(
                                self.current_input_file, output_path,
                                self.upscaled_frames))
                        self._wait()

                    # if failed to copy streams
                    # use file with only video stream
                    except subprocess.CalledProcessError:
                        traceback.print_exc()
                        Avalon.error(_('Failed to migrate streams'))
                        Avalon.warning(
                            _('Trying to output video without additional streams'
                              ))

                        if input_file_mime_type == 'image/gif':
                            # copy will overwrite destination content if exists
                            shutil.copy(
                                self.upscaled_frames /
                                self.ffmpeg_object.intermediate_file_name,
                                output_path)

                        else:
                            # construct output file path
                            output_file_name = f'{output_path.stem}{self.ffmpeg_object.intermediate_file_name.suffix}'
                            output_video_path = output_path.parent / output_file_name

                            # if output file already exists
                            # create temporary directory in output folder
                            # temporary directories generated by tempfile are guaranteed to be unique
                            # and won't conflict with other files
                            if output_video_path.exists():
                                Avalon.error(_('Output video file exists'))

                                temporary_directory = pathlib.Path(
                                    tempfile.mkdtemp(dir=output_path.parent))
                                output_video_path = temporary_directory / output_file_name
                                Avalon.info(
                                    _('Created temporary directory to contain file'
                                      ))

                            # move file to new destination
                            Avalon.info(
                                _('Writing intermediate file to: {}').format(
                                    output_video_path.absolute()))
                            shutil.move(
                                self.upscaled_frames /
                                self.ffmpeg_object.intermediate_file_name,
                                output_video_path)

                # increment total number of files processed
                self.cleanup_temp_directories()
                self.processing_queue.task_done()
                self.total_processed += 1

        except (Exception, KeyboardInterrupt, SystemExit) as e:
            with contextlib.suppress(ValueError, AttributeError):
                self.cleanup_temp_directories()
                self.running = False
            raise e

        # signal upscaling completion
        self.running = False
Exemplo n.º 2
0
class Upscaler:
    """An instance of this class is a upscaler that will
    upscale all images in the given directory.

    Raises:
        Exception -- all exceptions
        ArgumentError -- if argument is not valid
    """
    def __init__(
        self,
        input_path: pathlib.Path or list,
        output_path: pathlib.Path,
        driver_settings: dict,
        ffmpeg_settings: dict,
        gifski_settings: dict,
        driver: str = "waifu2x_caffe",
        scale_ratio: float = None,
        scale_width: int = None,
        scale_height: int = None,
        processes: int = 1,
        video2x_cache_directory: pathlib.Path = pathlib.Path(
            tempfile.gettempdir()) / "video2x",
        extracted_frame_format: str = "png",
        output_file_name_format_string:
        str = "{original_file_name}_output{extension}",
        image_output_extension: str = ".png",
        video_output_extension: str = ".mp4",
        preserve_frames: bool = False,
    ):

        # required parameters
        self.input = input_path
        self.output = output_path
        self.driver_settings = driver_settings
        self.ffmpeg_settings = ffmpeg_settings
        self.gifski_settings = gifski_settings

        # optional parameters
        self.driver = driver
        self.scale_ratio = scale_ratio
        self.scale_width = scale_width
        self.scale_height = scale_height
        self.processes = processes
        self.video2x_cache_directory = video2x_cache_directory
        self.extracted_frame_format = extracted_frame_format
        self.output_file_name_format_string = output_file_name_format_string
        self.image_output_extension = image_output_extension
        self.video_output_extension = video_output_extension
        self.preserve_frames = preserve_frames

        # other internal members and signals
        self.running = False
        self.current_processing_starting_time = time.time()
        self.total_frames_upscaled = 0
        self.total_frames = 0
        self.total_files = 0
        self.total_processed = 0
        self.scaling_jobs = []
        self.current_pass = 0
        self.current_input_file = pathlib.Path()
        self.last_frame_upscaled = pathlib.Path()

    def create_temp_directories(self):
        """create temporary directories"""

        # if cache directory unspecified, use %TEMP%\video2x
        if self.video2x_cache_directory is None:
            self.video2x_cache_directory = (
                pathlib.Path(tempfile.gettempdir()) / "video2x")

        # if specified cache path exists and isn't a directory
        if (self.video2x_cache_directory.exists()
                and not self.video2x_cache_directory.is_dir()):
            Avalon.error(
                _("Specified or default cache directory is a file/link"))
            raise FileExistsError(
                "Specified or default cache directory is a file/link")

        # if cache directory doesn't exist, try creating it
        if not self.video2x_cache_directory.exists():
            try:
                Avalon.debug_info(
                    _("Creating cache directory {}").format(
                        self.video2x_cache_directory))
                self.video2x_cache_directory.mkdir(parents=True, exist_ok=True)
            except Exception as exception:
                Avalon.error(
                    _("Unable to create {}").format(
                        self.video2x_cache_directory))
                raise exception

        # create temp directories for extracted frames and upscaled frames
        self.extracted_frames = pathlib.Path(
            tempfile.mkdtemp(dir=self.video2x_cache_directory))
        Avalon.debug_info(
            _("Extracted frames are being saved to: {}").format(
                self.extracted_frames))
        self.upscaled_frames = pathlib.Path(
            tempfile.mkdtemp(dir=self.video2x_cache_directory))
        Avalon.debug_info(
            _("Upscaled frames are being saved to: {}").format(
                self.upscaled_frames))

    def cleanup_temp_directories(self):
        """delete temp directories when done"""
        if not self.preserve_frames:
            for directory in [
                    self.extracted_frames,
                    self.upscaled_frames,
                    self.video2x_cache_directory,
            ]:
                try:
                    # avalon framework cannot be used if python is shutting down
                    # therefore, plain print is used
                    print(
                        _("Cleaning up cache directory: {}").format(directory))
                    shutil.rmtree(directory)
                except FileNotFoundError:
                    pass
                except OSError:
                    print(_("Unable to delete: {}").format(directory))
                    traceback.print_exc()

    def _check_arguments(self):
        if isinstance(self.input, list):
            if self.output.exists() and not self.output.is_dir():
                Avalon.error(_("Input and output path type mismatch"))
                Avalon.error(
                    _("Input is multiple files but output is not directory"))
                raise ArgumentError("input output path type mismatch")
            for input_path in self.input:
                if not input_path.is_file() and not input_path.is_dir():
                    Avalon.error(
                        _("Input path {} is neither a file nor a directory").
                        format(input_path))
                    raise FileNotFoundError(
                        f"{input_path} is neither file nor directory")
                with contextlib.suppress(FileNotFoundError):
                    if input_path.samefile(self.output):
                        Avalon.error(
                            _("Input directory and output directory cannot be the same"
                              ))
                        raise FileExistsError(
                            "input directory and output directory are the same"
                        )

        # if input is a file
        elif self.input.is_file():
            if self.output.is_dir():
                Avalon.error(_("Input and output path type mismatch"))
                Avalon.error(_("Input is single file but output is directory"))
                raise ArgumentError("input output path type mismatch")
            if self.output.suffix == "":
                Avalon.error(_("No suffix found in output file path"))
                Avalon.error(_("Suffix must be specified"))
                raise ArgumentError("no output file suffix specified")

        # if input is a directory
        elif self.input.is_dir():
            if self.output.is_file():
                Avalon.error(_("Input and output path type mismatch"))
                Avalon.error(
                    _("Input is directory but output is existing single file"))
                raise ArgumentError("input output path type mismatch")
            with contextlib.suppress(FileNotFoundError):
                if self.input.samefile(self.output):
                    Avalon.error(
                        _("Input directory and output directory cannot be the same"
                          ))
                    raise FileExistsError(
                        "input directory and output directory are the same")

        # if input is neither
        else:
            Avalon.error(_("Input path is neither a file nor a directory"))
            raise FileNotFoundError(
                f"{self.input} is neither file nor directory")

        # check FFmpeg settings
        ffmpeg_path = pathlib.Path(self.ffmpeg_settings["ffmpeg_path"])
        if not ((pathlib.Path(ffmpeg_path / "ffmpeg.exe").is_file()
                 and pathlib.Path(ffmpeg_path / "ffprobe.exe").is_file()) or
                (pathlib.Path(ffmpeg_path / "ffmpeg").is_file()
                 and pathlib.Path(ffmpeg_path / "ffprobe").is_file())):
            Avalon.error(
                _("FFmpeg or FFprobe cannot be found under the specified path")
            )
            Avalon.error(_("Please check the configuration file settings"))
            raise FileNotFoundError(self.ffmpeg_settings["ffmpeg_path"])

        # check if driver settings
        driver_settings = copy.deepcopy(self.driver_settings)
        driver_path = driver_settings.pop("path")

        # check if driver path exists
        if not (pathlib.Path(driver_path).is_file()
                or pathlib.Path(f"{driver_path}.exe").is_file()):
            Avalon.error(
                _("Specified driver executable directory doesn't exist"))
            Avalon.error(_("Please check the configuration file settings"))
            raise FileNotFoundError(driver_path)

        # parse driver arguments using driver's parser
        # the parser will throw AttributeError if argument doesn't satisfy constraints
        try:
            driver_arguments = []
            for key in driver_settings.keys():

                value = driver_settings[key]

                if value is None or value is False:
                    continue

                else:
                    if len(key) == 1:
                        driver_arguments.append(f"-{key}")
                    else:
                        driver_arguments.append(f"--{key}")
                    # true means key is an option
                    if value is not True:
                        driver_arguments.append(str(value))

            DriverWrapperMain = getattr(
                importlib.import_module(f"wrappers.{self.driver}"),
                "WrapperMain")
            DriverWrapperMain.parse_arguments(driver_arguments)
        except AttributeError as e:
            Avalon.error(
                _("Failed to parse driver argument: {}").format(e.args[0]))
            raise e

    def _upscale_frames(self, input_directory: pathlib.Path,
                        output_directory: pathlib.Path):
        """Upscale video frames with waifu2x-caffe

        This function upscales all the frames extracted
        by ffmpeg using the waifu2x-caffe binary.

        Args:
            input_directory (pathlib.Path): directory containing frames to upscale
            output_directory (pathlib.Path): directory which upscaled frames should be exported to

        Raises:
            UnrecognizedDriverError: raised when the given driver is not recognized
            e: re-raised exception after an exception has been captured and finished processing in this scope
        """

        # initialize waifu2x driver
        if self.driver not in AVAILABLE_DRIVERS:
            raise UnrecognizedDriverError(
                _("Unrecognized driver: {}").format(self.driver))

        # list all images in the extracted frames
        frames = [(input_directory / f) for f in input_directory.iterdir()
                  if f.is_file]

        # if we have less images than processes,
        # create only the processes necessary
        if len(frames) < self.processes:
            self.processes = len(frames)

        # create a directory for each process and append directory
        # name into a list
        process_directories = []
        for process_id in range(self.processes):
            process_directory = input_directory / str(process_id)
            process_directories.append(process_directory)

            # delete old directories and create new directories
            if process_directory.is_dir():
                shutil.rmtree(process_directory)
            process_directory.mkdir(parents=True, exist_ok=True)

        # waifu2x-converter-cpp will perform multi-threading within its own process
        if self.driver in [
                "waifu2x_converter_cpp",
                "waifu2x_ncnn_vulkan",
                "srmd_ncnn_vulkan",
                "realsr_ncnn_vulkan",
                "anime4kcpp",
        ]:
            process_directories = [input_directory]

        else:
            # evenly distribute images into each directory
            # until there is none left in the directory
            for image in frames:
                # move image
                image.rename(process_directories[0] / image.name)
                # rotate list
                process_directories = (process_directories[-1:] +
                                       process_directories[:-1])

        # create driver processes and start them
        for process_directory in process_directories:
            self.process_pool.append(
                self.driver_object.upscale(process_directory,
                                           output_directory))

        # start progress bar in a different thread
        Avalon.debug_info(_("Starting progress monitor"))
        self.progress_monitor = ProgressMonitor(self, process_directories)
        self.progress_monitor.start()

        # create the clearer and start it
        Avalon.debug_info(_("Starting upscaled image cleaner"))
        self.image_cleaner = ImageCleaner(input_directory, output_directory,
                                          len(self.process_pool))
        self.image_cleaner.start()

        # wait for all process to exit
        try:
            self._wait()
        except (Exception, KeyboardInterrupt, SystemExit) as e:
            # cleanup
            Avalon.debug_info(_("Killing progress monitor"))
            self.progress_monitor.stop()

            Avalon.debug_info(_("Killing upscaled image cleaner"))
            self.image_cleaner.stop()
            raise e

        # if the driver is waifu2x-converter-cpp
        # images need to be renamed to be recognizable for FFmpeg
        if self.driver == "waifu2x_converter_cpp":
            for image in [
                    f for f in output_directory.iterdir() if f.is_file()
            ]:
                renamed = re.sub(
                    f"_\\[.*\\]\\[x(\\d+(\\.\\d+)?)\\]\\.{self.extracted_frame_format}",
                    f".{self.extracted_frame_format}",
                    str(image.name),
                )
                (output_directory / image).rename(output_directory / renamed)

        # upscaling done, kill helper threads
        Avalon.debug_info(_("Killing progress monitor"))
        self.progress_monitor.stop()

        Avalon.debug_info(_("Killing upscaled image cleaner"))
        self.image_cleaner.stop()

    def _terminate_subprocesses(self):
        Avalon.warning(_("Terminating all processes"))
        for process in self.process_pool:
            process.terminate()

    def _wait(self):
        """wait for subprocesses in process pool to complete"""
        Avalon.debug_info(_("Main process waiting for subprocesses to exit"))

        try:
            # while process pool not empty
            while self.process_pool:

                # if stop signal received, terminate all processes
                if self.running is False:
                    raise SystemExit

                for process in self.process_pool:
                    process_status = process.poll()

                    # if process finished
                    if process_status is None:
                        continue

                    # if return code is not 0
                    elif process_status != 0:
                        Avalon.error(
                            _("Subprocess {} exited with code {}").format(
                                process.pid, process_status))
                        raise subprocess.CalledProcessError(
                            process_status, process.args)

                    else:
                        Avalon.debug_info(
                            _("Subprocess {} exited with code {}").format(
                                process.pid, process_status))
                        self.process_pool.remove(process)

                time.sleep(0.1)

        except (KeyboardInterrupt, SystemExit) as e:
            Avalon.warning(_("Stop signal received"))
            self._terminate_subprocesses()
            raise e

        except (Exception, subprocess.CalledProcessError) as e:
            Avalon.error(_("Subprocess execution ran into an error"))
            self._terminate_subprocesses()
            raise e

    def run(self):
        """Main controller for Video2X

        This function controls the flow of video conversion
        and handles all necessary functions.
        """

        # external stop signal when called in a thread
        self.running = True

        # define process pool to contain processes
        self.process_pool = []

        # load driver modules
        DriverWrapperMain = getattr(
            importlib.import_module(f"wrappers.{self.driver}"), "WrapperMain")
        self.driver_object = DriverWrapperMain(self.driver_settings)

        # load options from upscaler class into driver settings
        self.driver_object.load_configurations(self)

        # initialize FFmpeg object
        self.ffmpeg_object = Ffmpeg(
            self.ffmpeg_settings,
            extracted_frame_format=self.extracted_frame_format)

        # define processing queue
        self.processing_queue = queue.Queue()

        Avalon.info(_("Loading files into processing queue"))
        Avalon.debug_info(_("Input path(s): {}").format(self.input))

        # make output directory if the input is a list or a directory
        if isinstance(self.input, list) or self.input.is_dir():
            self.output.mkdir(parents=True, exist_ok=True)

        input_files = []

        # if input is single directory
        # put it in a list for compability with the following code
        if not isinstance(self.input, list):
            input_paths = [self.input]
        else:
            input_paths = self.input

        # flatten directories into file paths
        for input_path in input_paths:

            # if the input path is a single file
            # add the file's path object to input_files
            if input_path.is_file():
                input_files.append(input_path)

            # if the input path is a directory
            # add all files under the directory into the input_files (non-recursive)
            elif input_path.is_dir():
                input_files.extend(
                    [f for f in input_path.iterdir() if f.is_file()])

        output_paths = []

        for input_path in input_files:

            # get file type
            # try python-magic if it's available
            try:
                input_file_mime_type = magic.from_file(str(
                    input_path.absolute()),
                                                       mime=True)
                input_file_type = input_file_mime_type.split("/")[0]
                input_file_subtype = input_file_mime_type.split("/")[1]
            except Exception:
                input_file_mime_type = input_file_type = input_file_subtype = ""

            # if python-magic doesn't determine the file to be an image/video file
            # fall back to mimetypes to guess the file type based on the extension
            if input_file_type not in ["image", "video"]:
                # in case python-magic fails to detect file type
                # try guessing file mime type with mimetypes
                input_file_mime_type = mimetypes.guess_type(input_path.name)[0]
                input_file_type = input_file_mime_type.split("/")[0]
                input_file_subtype = input_file_mime_type.split("/")[1]

            Avalon.debug_info(
                _("File MIME type: {}").format(input_file_mime_type))

            # set default output file suffixes
            # if image type is GIF, default output suffix is also .gif
            if input_file_mime_type == "image/gif":
                output_path = self.output / self.output_file_name_format_string.format(
                    original_file_name=input_path.stem, extension=".gif")

            elif input_file_type == "image":
                output_path = self.output / self.output_file_name_format_string.format(
                    original_file_name=input_path.stem,
                    extension=self.image_output_extension,
                )

            elif input_file_type == "video":
                output_path = self.output / self.output_file_name_format_string.format(
                    original_file_name=input_path.stem,
                    extension=self.video_output_extension,
                )

            # if file is none of: image, image/gif, video
            # skip to the next task
            else:
                Avalon.error(
                    _("File {} ({}) neither an image nor a video").format(
                        input_path, input_file_mime_type))
                Avalon.warning(_("Skipping this file"))
                continue

            # if there is only one input file
            # do not modify output file suffix
            if isinstance(self.input, pathlib.Path) and self.input.is_file():
                output_path = self.output

            output_path_id = 0
            while str(output_path) in output_paths:
                output_path = output_path.parent / pathlib.Path(
                    f"{output_path.stem}_{output_path_id}{output_path.suffix}")
                output_path_id += 1

            # record output path
            output_paths.append(str(output_path))

            # push file information into processing queue
            self.processing_queue.put((
                input_path.absolute(),
                output_path.absolute(),
                input_file_mime_type,
                input_file_type,
                input_file_subtype,
            ))

        # check argument sanity before running
        self._check_arguments()

        # record file count for external calls
        self.total_files = self.processing_queue.qsize()

        Avalon.info(_("Loaded files into processing queue"))
        # print all files in queue for debugging
        for job in self.processing_queue.queue:
            Avalon.debug_info(_("Input file: {}").format(job[0].absolute()))

        try:
            while not self.processing_queue.empty():

                # get new job from queue
                (
                    self.current_input_file,
                    output_path,
                    input_file_mime_type,
                    input_file_type,
                    input_file_subtype,
                ) = self.processing_queue.get()

                # get current job starting time for GUI calculations
                self.current_processing_starting_time = time.time()

                # get video information JSON using FFprobe
                Avalon.info(_("Reading file information"))
                file_info = self.ffmpeg_object.probe_file_info(
                    self.current_input_file)

                # create temporary directories for storing frames
                self.create_temp_directories()

                # start handling input
                # if input file is a static image
                if input_file_type == "image" and input_file_subtype != "gif":
                    Avalon.info(_("Starting upscaling image"))

                    # copy original file into the pre-processing directory
                    shutil.copy(
                        self.current_input_file,
                        self.extracted_frames / self.current_input_file.name,
                    )

                    width = int(file_info["streams"][0]["width"])
                    height = int(file_info["streams"][0]["height"])
                    framerate = self.total_frames = 1

                # elif input_file_mime_type == 'image/gif' or input_file_type == 'video':
                else:
                    Avalon.info(_("Starting upscaling video/GIF"))

                    # find index of video stream
                    video_stream_index = None
                    for stream in file_info["streams"]:
                        if stream["codec_type"] == "video":
                            video_stream_index = stream["index"]
                            break

                    # exit if no video stream found
                    if video_stream_index is None:
                        Avalon.error(_("Aborting: No video stream found"))
                        raise StreamNotFoundError("no video stream found")

                    # get average frame rate of video stream
                    framerate = float(
                        Fraction(file_info["streams"][video_stream_index]
                                 ["r_frame_rate"]))
                    width = int(
                        file_info["streams"][video_stream_index]["width"])
                    height = int(
                        file_info["streams"][video_stream_index]["height"])

                    # get total number of frames
                    Avalon.info(
                        _("Getting total number of frames in the file"))

                    # if container stores total number of frames in nb_frames, fetch it directly
                    if "nb_frames" in file_info["streams"][video_stream_index]:
                        self.total_frames = int(
                            file_info["streams"][video_stream_index]
                            ["nb_frames"])

                    # otherwise call FFprobe to count the total number of frames
                    else:
                        self.total_frames = self.ffmpeg_object.get_number_of_frames(
                            self.current_input_file, video_stream_index)

                # calculate scale width/height/ratio and scaling jobs if required
                Avalon.info(_("Calculating scaling parameters"))

                # create a local copy of the global output settings
                output_scale = self.scale_ratio
                output_width = self.scale_width
                output_height = self.scale_height

                # calculate output width and height if scale ratio is specified
                if output_scale is not None:
                    output_width = int(
                        math.ceil(width * output_scale / 2.0) * 2)
                    output_height = int(
                        math.ceil(height * output_scale / 2.0) * 2)

                else:
                    # scale keeping aspect ratio is only one of width/height is given
                    if output_width == 0 or output_width is None:
                        output_width = output_height / height * width

                    elif output_height == 0 or output_height is None:
                        output_height = output_width / width * height

                    output_width = int(math.ceil(output_width / 2.0) * 2)
                    output_height = int(math.ceil(output_height / 2.0) * 2)

                    # calculate required minimum scale ratio
                    output_scale = max(output_width / width,
                                       output_height / height)

                # if driver is one of the drivers that doesn't support arbitrary scaling ratio
                # TODO: more documentations on this block
                if self.driver in DRIVER_FIXED_SCALING_RATIOS:

                    # select the optimal driver scaling ratio to use
                    supported_scaling_ratios = sorted(
                        DRIVER_FIXED_SCALING_RATIOS[self.driver])

                    remaining_scaling_ratio = math.ceil(output_scale)
                    self.scaling_jobs = []

                    # if the scaling ratio is 1.0
                    # apply the smallest scaling ratio available
                    if remaining_scaling_ratio == 1:
                        self.scaling_jobs.append(supported_scaling_ratios[0])
                    else:
                        while remaining_scaling_ratio > 1:
                            for ratio in supported_scaling_ratios:
                                if ratio >= remaining_scaling_ratio:
                                    self.scaling_jobs.append(ratio)
                                    remaining_scaling_ratio /= ratio
                                    break

                            else:
                                found = False
                                for i in supported_scaling_ratios:
                                    for j in supported_scaling_ratios:
                                        if i * j >= remaining_scaling_ratio:
                                            self.scaling_jobs.extend([i, j])
                                            remaining_scaling_ratio /= i * j
                                            found = True
                                            break
                                    if found is True:
                                        break

                                if found is False:
                                    self.scaling_jobs.append(
                                        supported_scaling_ratios[-1])
                                    remaining_scaling_ratio /= supported_scaling_ratios[
                                        -1]

                else:
                    self.scaling_jobs = [output_scale]

                # print file information
                Avalon.debug_info(_("Framerate: {}").format(framerate))
                Avalon.debug_info(_("Width: {}").format(width))
                Avalon.debug_info(_("Height: {}").format(height))
                Avalon.debug_info(
                    _("Total number of frames: {}").format(self.total_frames))
                Avalon.debug_info(_("Output width: {}").format(output_width))
                Avalon.debug_info(_("Output height: {}").format(output_height))
                Avalon.debug_info(
                    _("Required scale ratio: {}").format(output_scale))
                Avalon.debug_info(
                    _("Upscaling jobs queue: {}").format(self.scaling_jobs))

                # extract frames from video
                if input_file_mime_type == "image/gif" or input_file_type == "video":
                    self.process_pool.append(
                        (self.ffmpeg_object.extract_frames(
                            self.current_input_file, self.extracted_frames)))
                    self._wait()

                # if driver is waifu2x-caffe
                # pass pixel format output depth information
                if self.driver == "waifu2x_caffe":
                    # get a dict of all pixel formats and corresponding bit depth
                    pixel_formats = self.ffmpeg_object.get_pixel_formats()

                    # try getting pixel format's corresponding bti depth
                    try:
                        self.driver_settings["output_depth"] = pixel_formats[
                            self.ffmpeg_object.pixel_format]
                    except KeyError:
                        Avalon.error(
                            _("Unsupported pixel format: {}").format(
                                self.ffmpeg_object.pixel_format))
                        raise UnsupportedPixelError(
                            f"unsupported pixel format {self.ffmpeg_object.pixel_format}"
                        )

                # upscale images one by one using waifu2x
                Avalon.info(_("Starting to upscale extracted frames"))
                upscale_begin_time = time.time()

                self.current_pass = 1
                if self.driver == "waifu2x_caffe":
                    self.driver_object.set_scale_resolution(
                        output_width, output_height)
                else:
                    self.driver_object.set_scale_ratio(self.scaling_jobs[0])
                self._upscale_frames(self.extracted_frames,
                                     self.upscaled_frames)
                for job in self.scaling_jobs[1:]:
                    self.current_pass += 1
                    self.driver_object.set_scale_ratio(job)
                    shutil.rmtree(self.extracted_frames)
                    shutil.move(self.upscaled_frames, self.extracted_frames)
                    self.upscaled_frames.mkdir(parents=True, exist_ok=True)
                    self._upscale_frames(self.extracted_frames,
                                         self.upscaled_frames)

                Avalon.info(_("Upscaling completed"))
                Avalon.info(
                    _("Average processing speed: {} seconds per frame").format(
                        self.total_frames /
                        (time.time() - upscale_begin_time)))

                # downscale frames with Lanczos
                Avalon.info(_("Lanczos downscaling frames"))
                shutil.rmtree(self.extracted_frames)
                shutil.move(self.upscaled_frames, self.extracted_frames)
                self.upscaled_frames.mkdir(parents=True, exist_ok=True)

                for image in tqdm(
                    [
                        i for i in self.extracted_frames.iterdir()
                        if i.is_file()
                        and i.name.endswith(self.extracted_frame_format)
                    ],
                        ascii=True,
                        desc=_("Downscaling"),
                ):
                    image_object = Image.open(image)

                    # if the image dimensions are not equal to the output size
                    # resize the image using Lanczos
                    if (image_object.width, image_object.height) != (
                            output_width,
                            output_height,
                    ):
                        image_object.resize(
                            (output_width, output_height), Image.LANCZOS).save(
                                self.upscaled_frames / image.name)
                        image_object.close()

                    # if the image's dimensions are already equal to the output size
                    # move image to the finished directory
                    else:
                        image_object.close()
                        shutil.move(image, self.upscaled_frames / image.name)

                # start handling output
                # output can be either GIF or video
                if input_file_type == "image" and input_file_subtype != "gif":

                    Avalon.info(_("Exporting image"))

                    # there should be only one image in the directory
                    shutil.move(
                        [
                            f for f in self.upscaled_frames.iterdir()
                            if f.is_file()
                        ][0],
                        output_path,
                    )

                # elif input_file_mime_type == 'image/gif' or input_file_type == 'video':
                else:

                    # if the desired output is gif file
                    if output_path.suffix.lower() == ".gif":
                        Avalon.info(
                            _("Converting extracted frames into GIF image"))
                        gifski_object = Gifski(self.gifski_settings)
                        self.process_pool.append(
                            gifski_object.make_gif(
                                self.upscaled_frames,
                                output_path,
                                framerate,
                                self.extracted_frame_format,
                                output_width,
                                output_height,
                            ))
                        self._wait()
                        Avalon.info(_("Conversion completed"))

                    # if the desired output is video
                    else:
                        # frames to video
                        Avalon.info(
                            _("Converting extracted frames into video"))
                        self.process_pool.append(
                            self.ffmpeg_object.assemble_video(
                                framerate, self.upscaled_frames))
                        # f'{scale_width}x{scale_height}'
                        self._wait()
                        Avalon.info(_("Conversion completed"))

                        try:
                            # migrate audio tracks and subtitles
                            Avalon.info(
                                _("Migrating audio, subtitles and other streams to upscaled video"
                                  ))
                            self.process_pool.append(
                                self.ffmpeg_object.migrate_streams(
                                    self.current_input_file,
                                    output_path,
                                    self.upscaled_frames,
                                ))
                            self._wait()

                        # if failed to copy streams
                        # use file with only video stream
                        except subprocess.CalledProcessError:
                            traceback.print_exc()
                            Avalon.error(_("Failed to migrate streams"))
                            Avalon.warning(
                                _("Trying to output video without additional streams"
                                  ))

                            if input_file_mime_type == "image/gif":
                                # copy will overwrite destination content if exists
                                shutil.copy(
                                    self.upscaled_frames /
                                    self.ffmpeg_object.intermediate_file_name,
                                    output_path,
                                )

                            else:
                                # construct output file path
                                output_file_name = f"{output_path.stem}{self.ffmpeg_object.intermediate_file_name.suffix}"
                                output_video_path = (output_path.parent /
                                                     output_file_name)

                                # if output file already exists
                                # create temporary directory in output folder
                                # temporary directories generated by tempfile are guaranteed to be unique
                                # and won't conflict with other files
                                if output_video_path.exists():
                                    Avalon.error(_("Output video file exists"))

                                    temporary_directory = pathlib.Path(
                                        tempfile.mkdtemp(
                                            dir=output_path.parent))
                                    output_video_path = (temporary_directory /
                                                         output_file_name)
                                    Avalon.info(
                                        _("Created temporary directory to contain file"
                                          ))

                                # move file to new destination
                                Avalon.info(
                                    _("Writing intermediate file to: {}").
                                    format(output_video_path.absolute()))
                                shutil.move(
                                    self.upscaled_frames /
                                    self.ffmpeg_object.intermediate_file_name,
                                    output_video_path,
                                )

                # increment total number of files processed
                self.cleanup_temp_directories()
                self.processing_queue.task_done()
                self.total_processed += 1

        except (Exception, KeyboardInterrupt, SystemExit) as e:
            with contextlib.suppress(ValueError, AttributeError):
                self.cleanup_temp_directories()
                self.running = False
            raise e

        # signal upscaling completion
        self.running = False
Exemplo n.º 3
0
class Upscaler:
    """ An instance of this class is a upscaler that will
    upscale all images in the given directory.

    Raises:
        Exception -- all exceptions
        ArgumentError -- if argument is not valid
    """

    def __init__(self, input_path, output_path, driver_settings, ffmpeg_settings):
        # mandatory arguments
        self.input_path = input_path
        self.output_path = output_path
        self.driver_settings = driver_settings
        self.ffmpeg_settings = ffmpeg_settings

        # optional arguments
        self.driver = 'waifu2x_caffe'
        self.scale_width = None
        self.scale_height = None
        self.scale_ratio = None
        self.processes = 1
        self.video2x_cache_directory = pathlib.Path(tempfile.gettempdir()) / 'video2x'
        self.image_format = 'png'
        self.preserve_frames = False

        # other internal members and signals
        self.stop_signal = False
        self.total_frames_upscaled = 0
        self.total_frames = 0

    def create_temp_directories(self):
        """create temporary directories
        """

        # if cache directory unspecified, use %TEMP%\video2x
        if self.video2x_cache_directory is None:
            self.video2x_cache_directory = pathlib.Path(tempfile.gettempdir()) / 'video2x'

        # if specified cache path exists and isn't a directory
        if self.video2x_cache_directory.exists() and not self.video2x_cache_directory.is_dir():
            Avalon.error(_('Specified or default cache directory is a file/link'))
            raise FileExistsError('Specified or default cache directory is a file/link')

        # if cache directory doesn't exist, try creating it
        if not self.video2x_cache_directory.exists():
            try:
                Avalon.debug_info(_('Creating cache directory {}').format(self.video2x_cache_directory))
                self.video2x_cache_directory.mkdir(parents=True, exist_ok=True)
            except Exception as exception:
                Avalon.error(_('Unable to create {}').format(self.video2x_cache_directory))
                raise exception

        # create temp directories for extracted frames and upscaled frames
        self.extracted_frames = pathlib.Path(tempfile.mkdtemp(dir=self.video2x_cache_directory))
        Avalon.debug_info(_('Extracted frames are being saved to: {}').format(self.extracted_frames))
        self.upscaled_frames = pathlib.Path(tempfile.mkdtemp(dir=self.video2x_cache_directory))
        Avalon.debug_info(_('Upscaled frames are being saved to: {}').format(self.upscaled_frames))

    def cleanup_temp_directories(self):
        """delete temp directories when done
        """
        if not self.preserve_frames:
            for directory in [self.extracted_frames, self.upscaled_frames, self.video2x_cache_directory]:
                try:
                    # avalon framework cannot be used if python is shutting down
                    # therefore, plain print is used
                    print(_('Cleaning up cache directory: {}').format(directory))
                    shutil.rmtree(directory)
                except (OSError, FileNotFoundError):
                    print(_('Unable to delete: {}').format(directory))
                    traceback.print_exc()

    def _check_arguments(self):
        # if input is a file
        if self.input_path.is_file():
            if self.output_path.is_dir():
                Avalon.error(_('Input and output path type mismatch'))
                Avalon.error(_('Input is single file but output is directory'))
                raise ArgumentError('input output path type mismatch')
            if not re.search(r'.*\..*$', str(self.output_path)):
                Avalon.error(_('No suffix found in output file path'))
                Avalon.error(_('Suffix must be specified for FFmpeg'))
                raise ArgumentError('no output video suffix specified')

        # if input is a directory
        elif self.input_path.is_dir():
            if self.output_path.is_file():
                Avalon.error(_('Input and output path type mismatch'))
                Avalon.error(_('Input is directory but output is existing single file'))
                raise ArgumentError('input output path type mismatch')

        # if input is neither
        else:
            Avalon.error(_('Input path is neither a file nor a directory'))
            raise FileNotFoundError(f'{self.input_path} is neither file nor directory')

        # check Fmpeg settings
        ffmpeg_path = pathlib.Path(self.ffmpeg_settings['ffmpeg_path'])
        if not ((pathlib.Path(ffmpeg_path / 'ffmpeg.exe').is_file() and
                pathlib.Path(ffmpeg_path / 'ffprobe.exe').is_file()) or
                (pathlib.Path(ffmpeg_path / 'ffmpeg').is_file() and
                pathlib.Path(ffmpeg_path / 'ffprobe').is_file())):
            Avalon.error(_('FFmpeg or FFprobe cannot be found under the specified path'))
            Avalon.error(_('Please check the configuration file settings'))
            raise FileNotFoundError(self.ffmpeg_settings['ffmpeg_path'])

        # check if driver settings
        driver_settings = copy.deepcopy(self.driver_settings)
        driver_path = driver_settings.pop('path')

        # check if driver path exists
        if not (pathlib.Path(driver_path).is_file() or pathlib.Path(f'{driver_path}.exe').is_file()):
            Avalon.error(_('Specified driver executable directory doesn\'t exist'))
            Avalon.error(_('Please check the configuration file settings'))
            raise FileNotFoundError(driver_path)

        # parse driver arguments using driver's parser
        # the parser will throw AttributeError if argument doesn't satisfy constraints
        try:
            driver_arguments = []
            for key in driver_settings.keys():

                value = driver_settings[key]

                if value is None or value is False:
                    continue

                else:
                    if len(key) == 1:
                        driver_arguments.append(f'-{key}')
                    else:
                        driver_arguments.append(f'--{key}')
                    # true means key is an option
                    if value is not True:
                        driver_arguments.append(str(value))

            DriverWrapperMain = getattr(importlib.import_module(f'wrappers.{self.driver}'), 'WrapperMain')
            DriverWrapperMain.parse_arguments(driver_arguments)
        except AttributeError as e:
            Avalon.error(_('Failed to parse driver argument: {}').format(e.args[0]))
            raise e

    def _upscale_frames(self):
        """ Upscale video frames with waifu2x-caffe

        This function upscales all the frames extracted
        by ffmpeg using the waifu2x-caffe binary.

        Arguments:
            w2 {Waifu2x Object} -- initialized waifu2x object
        """

        # initialize waifu2x driver
        if self.driver not in AVAILABLE_DRIVERS:
            raise UnrecognizedDriverError(_('Unrecognized driver: {}').format(self.driver))

        # list all images in the extracted frames
        frames = [(self.extracted_frames / f) for f in self.extracted_frames.iterdir() if f.is_file]

        # if we have less images than processes,
        # create only the processes necessary
        if len(frames) < self.processes:
            self.processes = len(frames)

        # create a directory for each process and append directory
        # name into a list
        process_directories = []
        for process_id in range(self.processes):
            process_directory = self.extracted_frames / str(process_id)
            process_directories.append(process_directory)

            # delete old directories and create new directories
            if process_directory.is_dir():
                shutil.rmtree(process_directory)
            process_directory.mkdir(parents=True, exist_ok=True)

        # waifu2x-converter-cpp will perform multi-threading within its own process
        if self.driver == 'waifu2x_converter_cpp':
            process_directories = [self.extracted_frames]

        else:
            # evenly distribute images into each directory
            # until there is none left in the directory
            for image in frames:
                # move image
                image.rename(process_directories[0] / image.name)
                # rotate list
                process_directories = process_directories[-1:] + process_directories[:-1]

        # create threads and start them
        for process_directory in process_directories:

            DriverWrapperMain = getattr(importlib.import_module(f'wrappers.{self.driver}'), 'WrapperMain')
            driver = DriverWrapperMain(copy.deepcopy(self.driver_settings))

            # if the driver being used is waifu2x-caffe
            if self.driver == 'waifu2x_caffe':
                self.process_pool.append(driver.upscale(process_directory,
                                                         self.upscaled_frames,
                                                         self.scale_ratio,
                                                         self.scale_width,
                                                         self.scale_height,
                                                         self.image_format,
                                                         self.bit_depth))

            # if the driver being used is waifu2x-converter-cpp
            elif self.driver == 'waifu2x_converter_cpp':
                self.process_pool.append(driver.upscale(process_directory,
                                                         self.upscaled_frames,
                                                         self.scale_ratio,
                                                         self.processes,
                                                         self.image_format))

            # if the driver being used is waifu2x-ncnn-vulkan
            elif self.driver == 'waifu2x_ncnn_vulkan':
                self.process_pool.append(driver.upscale(process_directory,
                                                         self.upscaled_frames,
                                                         self.scale_ratio))

            # if the driver being used is srmd_ncnn_vulkan
            elif self.driver == 'srmd_ncnn_vulkan':
                self.process_pool.append(driver.upscale(process_directory,
                                                         self.upscaled_frames,
                                                         self.scale_ratio))

        # start progress bar in a different thread
        Avalon.debug_info(_('Starting progress monitor'))
        self.progress_monitor = ProgressMonitor(self, process_directories)
        self.progress_monitor.start()

        # create the clearer and start it
        Avalon.debug_info(_('Starting upscaled image cleaner'))
        self.image_cleaner = ImageCleaner(self.extracted_frames, self.upscaled_frames, len(self.process_pool))
        self.image_cleaner.start()

        # wait for all process to exit
        try:
            self._wait()
        except (Exception, KeyboardInterrupt, SystemExit) as e:
            # cleanup
            Avalon.debug_info(_('Killing progress monitor'))
            self.progress_monitor.stop()

            Avalon.debug_info(_('Killing upscaled image cleaner'))
            self.image_cleaner.stop()
            raise e

        # if the driver is waifu2x-converter-cpp
        # images need to be renamed to be recognizable for FFmpeg
        if self.driver == 'waifu2x_converter_cpp':
            for image in [f for f in self.upscaled_frames.iterdir() if f.is_file()]:
                renamed = re.sub(f'_\\[.*\\]\\[x(\\d+(\\.\\d+)?)\\]\\.{self.image_format}',
                                 f'.{self.image_format}',
                                 str(image.name))
                (self.upscaled_frames / image).rename(self.upscaled_frames / renamed)

        # upscaling done, kill helper threads
        Avalon.debug_info(_('Killing progress monitor'))
        self.progress_monitor.stop()

        Avalon.debug_info(_('Killing upscaled image cleaner'))
        self.image_cleaner.stop()

    def _terminate_subprocesses(self):
        Avalon.warning(_('Terminating all processes'))
        for process in self.process_pool:
            process.terminate()

    def _wait(self):
        """ wait for subprocesses in process pool to complete
        """
        Avalon.debug_info(_('Main process waiting for subprocesses to exit'))

        try:
            # while process pool not empty
            while self.process_pool:

                # if stop signal received, terminate all processes
                if self.stop_signal is True:
                    raise SystemExit

                for process in self.process_pool:
                    process_status = process.poll()

                    # if process finished
                    if process_status is None:
                        continue

                    # if return code is not 0
                    elif process_status != 0:
                        Avalon.error(_('Subprocess {} exited with code {}').format(process.pid, process_status))
                        raise subprocess.CalledProcessError(process_status, process.args)

                    else:
                        Avalon.debug_info(_('Subprocess {} exited with code {}').format(process.pid, process_status))
                        self.process_pool.remove(process)

                time.sleep(0.1)

        except (KeyboardInterrupt, SystemExit) as e:
            Avalon.warning(_('Stop signal received'))
            self._terminate_subprocesses()
            raise e

        except (Exception, subprocess.CalledProcessError) as e:
            Avalon.error(_('Subprocess execution ran into an error'))
            self._terminate_subprocesses()
            raise e

    def run(self):
        """ Main controller for Video2X

        This function controls the flow of video conversion
        and handles all necessary functions.
        """

        # external stop signal when called in a thread
        self.stop_signal = False

        # define process pool to contain processes
        self.process_pool = []

        # parse arguments for waifu2x
        # check argument sanity
        self._check_arguments()

        # define processing queue
        processing_queue = queue.Queue()

        # if input specified is single file
        if self.input_path.is_file():
            Avalon.info(_('Upscaling single video file: {}').format(self.input_path))
            processing_queue.put((self.input_path.absolute(), self.output_path.absolute()))

        # if input specified is a directory
        elif self.input_path.is_dir():

            # make output directory if it doesn't exist
            self.output_path.mkdir(parents=True, exist_ok=True)
            for input_video in [f for f in self.input_path.iterdir() if f.is_file()]:
                output_video = self.output_path / input_video.name
                processing_queue.put((input_video.absolute(), output_video.absolute()))

        while not processing_queue.empty():
            input_video, output_video = processing_queue.get()
            # drivers that have native support for video processing
            if self.driver == 'anime4kcpp':
                # append FFmpeg path to the end of PATH
                # Anime4KCPP will then use FFmpeg to migrate audio tracks
                os.environ['PATH'] += f';{self.ffmpeg_settings["ffmpeg_path"]}'
                Avalon.info(_('Starting to upscale extracted images'))

                # import and initialize Anime4KCPP wrapper
                DriverWrapperMain = getattr(importlib.import_module('wrappers.anime4kcpp'), 'WrapperMain')
                driver = DriverWrapperMain(copy.deepcopy(self.driver_settings))

                # run Anime4KCPP
                self.process_pool.append(driver.upscale(input_video, output_video, self.scale_ratio, self.processes))
                self._wait()
                Avalon.info(_('Upscaling completed'))

            else:
                try:
                    self.create_temp_directories()

                    # initialize objects for ffmpeg and waifu2x-caffe
                    fm = Ffmpeg(self.ffmpeg_settings, self.image_format)

                    Avalon.info(_('Reading video information'))
                    video_info = fm.get_video_info(input_video)
                    # analyze original video with ffprobe and retrieve framerate
                    # width, height = info['streams'][0]['width'], info['streams'][0]['height']

                    # find index of video stream
                    video_stream_index = None
                    for stream in video_info['streams']:
                        if stream['codec_type'] == 'video':
                            video_stream_index = stream['index']
                            break

                    # exit if no video stream found
                    if video_stream_index is None:
                        Avalon.error(_('Aborting: No video stream found'))
                        raise StreamNotFoundError('no video stream found')

                    # extract frames from video
                    self.process_pool.append((fm.extract_frames(input_video, self.extracted_frames)))
                    self._wait()

                    # get average frame rate of video stream
                    framerate = float(Fraction(video_info['streams'][video_stream_index]['avg_frame_rate']))
                    fm.pixel_format = video_info['streams'][video_stream_index]['pix_fmt']

                    # get a dict of all pixel formats and corresponding bit depth
                    pixel_formats = fm.get_pixel_formats()

                    # try getting pixel format's corresponding bti depth
                    try:
                        self.bit_depth = pixel_formats[fm.pixel_format]
                    except KeyError:
                        Avalon.error(_('Unsupported pixel format: {}').format(fm.pixel_format))
                        raise UnsupportedPixelError(f'unsupported pixel format {fm.pixel_format}')

                    Avalon.info(_('Framerate: {}').format(framerate))

                    # width/height will be coded width/height x upscale factor
                    if self.scale_ratio:
                        original_width = video_info['streams'][video_stream_index]['width']
                        original_height = video_info['streams'][video_stream_index]['height']
                        self.scale_width = int(self.scale_ratio * original_width)
                        self.scale_height = int(self.scale_ratio * original_height)

                    # upscale images one by one using waifu2x
                    Avalon.info(_('Starting to upscale extracted images'))
                    self._upscale_frames()
                    Avalon.info(_('Upscaling completed'))

                    # frames to Video
                    Avalon.info(_('Converting extracted frames into video'))

                    # use user defined output size
                    self.process_pool.append(fm.convert_video(framerate, f'{self.scale_width}x{self.scale_height}', self.upscaled_frames))
                    self._wait()
                    Avalon.info(_('Conversion completed'))

                    # migrate audio tracks and subtitles
                    Avalon.info(_('Migrating audio tracks and subtitles to upscaled video'))
                    self.process_pool.append(fm.migrate_audio_tracks_subtitles(input_video, output_video, self.upscaled_frames))
                    self._wait()

                    # destroy temp directories
                    self.cleanup_temp_directories()

                except (Exception, KeyboardInterrupt, SystemExit) as e:
                    with contextlib.suppress(ValueError):
                        self.cleanup_temp_directories()
                    raise e