def test_mime_type_is_case_insensitive(): mime1 = MimeType(mediatype='foo', subtype='bar') mime2 = MimeType(mediatype='fOo', subtype='bAr') assert mime1 == mime2 mime3 = MimeType(mediatype='Foo', subtype='baR') assert mime1 == mime3
def test_mime_type_accepts_strings_in_constructor(): mime = MimeType(mediatype='foo', subtype='bar') assert mime.type == 'foo' assert mime.subtype == 'bar' mime = MimeType(mediatype='', subtype='') assert mime.type is None assert mime.subtype is None
def test_mime_type_can_parse_mime_type_strings(): mime = MimeType('foo/bar') assert mime.type == 'foo' and mime.subtype == 'bar' mime = MimeType('foo/*') assert mime.type == 'foo' and mime.subtype is None mime = MimeType('*/bar') assert mime.type is None and mime.subtype == 'bar' mime = MimeType('*/*') assert mime.type is None and mime.subtype is None
def test_mime_type_can_be_equal_to_a_mime_type_string(): mime = MimeType(mediatype='foo', subtype='bar') assert mime == 'foo/bar' mime = MimeType(mediatype='foo', subtype=None) assert mime == 'foo/*' mime = MimeType(mediatype=None, subtype='bar') assert mime == '*/bar' mime = MimeType(mediatype=None, subtype=None) assert mime == '*/*'
def test_mime_type_handles_wildcards(): mime = MimeType(mediatype='foo', subtype='*') assert mime.type == 'foo' assert mime.subtype is None mime = MimeType(mediatype='*', subtype='bar') assert mime.type is None assert mime.subtype == 'bar' mime = MimeType(mediatype='*', subtype='*') assert mime.type is None assert mime.subtype is None
def test_mime_type_returns_correct_mime_type_string(): mime = MimeType(mediatype='foo', subtype='bar') assert str(mime) == 'foo/bar' mime = MimeType(mediatype='foo', subtype=None) assert str(mime) == 'foo/*' mime = MimeType(mediatype=None, subtype='bar') assert str(mime) == '*/bar' mime = MimeType(mediatype=None, subtype=None) assert str(mime) == '*/*'
def extract_frame(self, asset: Asset, mime_type: Union[MimeType, str], seconds: float = 0) -> Asset: """ Creates a new image asset of the specified MIME type from the essence of the specified video asset. :param asset: Video asset which will serve as the source for the frame :type asset: Asset :param mime_type: MIME type of the destination image :type mime_type: MimeType or str :param seconds: Offset of the frame in seconds :type seconds: float :return: New image asset with converted essence :rtype: Asset """ source_mime_type = MimeType(asset.mime_type) if source_mime_type.type != 'video': raise UnsupportedFormatError( f'Unsupported source asset type: {source_mime_type}') mime_type = MimeType(mime_type) encoder_name = self.__mime_type_to_encoder.get(mime_type) codec_name = self.__mime_type_to_codec.get(mime_type) if not (encoder_name and codec_name): raise UnsupportedFormatError( f'Unsupported target asset type: {mime_type}') result = io.BytesIO() with _FFmpegContext(asset.essence, result) as ctx: command = [ 'ffmpeg', '-v', 'error', '-i', ctx.input_path, '-ss', str(float(seconds)), '-codec:v', codec_name, '-vframes', '1', '-f', encoder_name, '-y', ctx.output_path ] try: subprocess.run(command, stderr=subprocess.PIPE, check=True) except subprocess.CalledProcessError as ffmpeg_error: error_message = ffmpeg_error.stderr.decode('utf-8') raise OperatorError( f'Could not extract frame from asset: {error_message}') metadata = _combine_metadata(asset, 'width', 'height', mime_type=mime_type) if 'video' in asset.metadata: metadata['depth'] = asset.metadata['video']['depth'] return Asset(essence=result, **metadata)
def test_mime_type_can_be_checked_for_equality_with_another_mime_type(): mime1 = MimeType(mediatype='foo', subtype='bar') mime2 = MimeType(mediatype=None, subtype=None) assert mime1 == mime1 assert mime2 == mime2 mime3 = MimeType(mediatype='foo', subtype='baz') assert not (mime1 == mime3) assert not (mime2 == mime3) mime4 = MimeType(mediatype='foo', subtype=None) assert not (mime1 == mime4) assert not (mime2 == mime4)
def test_mime_type_has_a_total_ordering(): mime1 = MimeType(mediatype='foo', subtype='bar') mime2 = MimeType(mediatype='foo', subtype='baz') assert mime1 < mime2 assert mime2 > mime1 assert not (mime1 < mime1) mime3 = MimeType(mediatype='foo', subtype=None) assert mime3 < mime1 mime4 = MimeType(mediatype=None, subtype='bar') assert mime4 < mime2 mime5 = MimeType(mediatype='goo', subtype='bar') assert mime1 < mime5
def resize(self, asset, width, height, mode=ResizeMode.EXACT): """ Creates a new Asset whose essence is resized according to the specified parameters. :param asset: Asset to be resized :type asset: Asset :param width: target width :type width: int :param height: target height :type height: int :param mode: resize behavior :type mode: ResizeMode :return: Asset with resized essence :rtype: Asset """ image = PIL.Image.open(asset.essence) mime_type = MimeType(asset.mime_type) width_delta = width - image.width height_delta = height - image.height resized_width = width resized_height = height if mode in (ResizeMode.FIT, ResizeMode.FILL): if mode == ResizeMode.FIT and width_delta < height_delta or \ mode == ResizeMode.FILL and width_delta > height_delta: resize_factor = width / image.width else: resize_factor = height / image.height resized_width = round(resize_factor * image.width) resized_height = round(resize_factor * image.height) resized_image = image.resize((resized_width, resized_height), resample=PIL.Image.LANCZOS) resized_asset = self._image_to_asset(resized_image, mime_type=mime_type) return resized_asset
def rotate(self, asset, angle, expand=False): """ Creates an asset whose essence is rotated by the specified angle in degrees. :param asset: Asset whose contents will be rotated :type asset: Asset :param angle: Angle in degrees, counter clockwise :type angle: float :param expand: If true, changes the dimensions of the new asset so it can hold the entire rotated essence, otherwise the dimensions of the original asset will be used. :type expand: bool :return: New asset with rotated essence :rtype: Asset """ mime_type = MimeType(asset.mime_type) encoder_name = self.__mime_type_to_encoder.get(mime_type) if not encoder_name or mime_type.type != 'video': raise UnsupportedFormatError('Unsupported source asset type: %s' % mime_type) if angle % 360.0 == 0.0: return asset angle_rad = radians(angle) width = asset.width height = asset.height if expand: if angle % 180 < 90: width_ = asset.width height_ = asset.height angle_rad_ = angle_rad % pi else: width_ = asset.height height_ = asset.width angle_rad_ = angle_rad % pi - pi/2 cos_a = cos(angle_rad_) sin_a = sin(angle_rad_) width = ceil(round(width_ * cos_a + height_ * sin_a, 7)) height = ceil(round(width_ * sin_a + height_ * cos_a, 7)) result = io.BytesIO() with _FFmpegContext(asset.essence, result) as ctx: command = ['ffmpeg', '-v', 'error', '-i', ctx.input_path, '-codec', 'copy', '-f:v', 'rotate=a=%(a)f:ow=%(w)d:oh=%(h)d)' % dict(a=angle_rad, w=width, h=height), '-f', encoder_name, '-y', ctx.output_path] try: subprocess_run(command, stderr=subprocess.PIPE, check=True) except CalledProcessError as ffmpeg_error: error_message = ffmpeg_error.stderr.decode('utf-8') raise OperatorError('Could not convert video asset: %s' % error_message) return Asset(essence=result, mime_type=mime_type, width=width, height=height)
def resize(self, asset: Asset, width: int, height: int) -> Asset: """ Creates a new image or video asset of the specified width and height from the essence of the specified image or video asset. Width and height must be positive numbers. :param asset: Video asset that will serve as the source for the frame :type asset: Asset :param width: Width of the resized asset :type width: int :param height: Height of the resized asset :type height: int :return: New asset with specified width and height :rtype: Asset """ if width < 1 or height < 1: raise ValueError(f'Invalid dimensions: {width:d}x{height:d}') mime_type = MimeType(asset.mime_type) encoder_name = self.__mime_type_to_encoder.get(mime_type) if not encoder_name: raise UnsupportedFormatError( f'Unsupported asset type: {mime_type}') if mime_type.type not in ('image', 'video'): raise OperatorError(f'Cannot resize asset of type {mime_type}') result = io.BytesIO() with _FFmpegContext(asset.essence, result) as ctx: with open(ctx.input_path, 'wb') as temp_in: shutil.copyfileobj(asset.essence, temp_in) temp_in.flush() command = [ 'ffmpeg', '-loglevel', 'error', '-f', encoder_name, '-i', ctx.input_path, '-filter:v', f'scale={width:d}:{height:d}', '-qscale', '0', '-threads', str(self.__threads), '-f', encoder_name, '-y', ctx.output_path ] try: subprocess.run(command, stderr=subprocess.PIPE, check=True) except subprocess.CalledProcessError as ffmpeg_error: error_message = ffmpeg_error.stderr.decode('utf-8') raise OperatorError(f'Could not resize asset: {error_message}') metadata = _combine_metadata(asset, 'mime_type', 'duration', 'video', 'audio', 'subtitle', width=width, height=height) return Asset(essence=result, **metadata)
def trim(self, asset: Asset, from_seconds: float = 0, to_seconds: float = 0) -> Asset: """ Creates a trimmed audio or video asset that only contains the data between from_seconds and to_seconds. :param asset: Audio or video asset, which will serve as the source :type asset: Asset :param from_seconds: Start time of the clip in seconds :type from_seconds: float :param to_seconds: End time of the clip in seconds :type to_seconds: float :return: New asset with trimmed essence :rtype: Asset """ mime_type = MimeType(asset.mime_type) encoder_name = self.__mime_type_to_encoder.get(mime_type) if not encoder_name or mime_type.type not in ('audio', 'video'): raise UnsupportedFormatError( f'Unsupported source asset type: {mime_type}') if to_seconds <= 0: to_seconds = asset.duration + to_seconds duration = float(to_seconds) - float(from_seconds) if duration <= 0: raise ValueError('Start time must be before end time') result = io.BytesIO() with _FFmpegContext(asset.essence, result) as ctx: command = [ 'ffmpeg', '-v', 'error', '-ss', str(float(from_seconds)), '-t', str(duration), '-i', ctx.input_path, '-codec', 'copy', '-f', encoder_name, '-y', ctx.output_path ] try: subprocess.run(command, stderr=subprocess.PIPE, check=True) except subprocess.CalledProcessError as ffmpeg_error: error_message = ffmpeg_error.stderr.decode('utf-8') raise OperatorError(f'Could not trim asset: {error_message}') metadata = _combine_metadata(asset, 'mime_type', 'width', 'height', 'video', 'audio', 'subtitle', duration=duration) return Asset(essence=result, **metadata)
def crop(self, asset, x, y, width, height): """ Creates a cropped video asset whose essence is cropped to the specified rectangular area. :param asset: Video asset whose contents will be cropped :type asset: Asset :param x: Horizontal offset of the cropping area from left :type x: int :param y: Vertical offset of the cropping area from top :type y: int :param width: Width of the cropping area :type width: int :param height: Height of the cropping area :type height: int :return: New asset with cropped essence :rtype: Asset """ mime_type = MimeType(asset.mime_type) encoder_name = self.__mime_type_to_encoder.get(mime_type) if not encoder_name or mime_type.type != 'video': raise UnsupportedFormatError('Unsupported source asset type: %s' % mime_type) if x == 0 and y == 0 and width == asset.width and height == asset.height: return asset max_x = max(0, min(asset.width, width + x)) max_y = max(0, min(asset.height, height + y)) min_x = max(0, min(asset.width, x)) min_y = max(0, min(asset.height, y)) if min_x == asset.width or min_y == asset.height or max_x <= min_x or max_y <= min_y: raise OperatorError('Invalid cropping area: <x=%r, y=%r, width=%r, height=%r>' % (x, y, width, height)) width = max_x - min_x height = max_y - min_y result = io.BytesIO() with _FFmpegContext(asset.essence, result) as ctx: command = ['ffmpeg', '-v', 'error', '-i', ctx.input_path, '-codec', 'copy', '-f:v', 'crop=w=%d:h=%d:x=%d:y=%d' % (width, height, x, y), '-f', encoder_name, '-y', ctx.output_path] try: subprocess_run(command, stderr=subprocess.PIPE, check=True) except CalledProcessError as ffmpeg_error: error_message = ffmpeg_error.stderr.decode('utf-8') raise OperatorError('Could not convert video asset: %s' % error_message) return Asset(essence=result, mime_type=mime_type, width=width, height=height)
def resize(self, asset: Asset, width: int, height: int, mode: ResizeMode = ResizeMode.EXACT) -> Asset: """ Creates a new Asset whose essence is resized according to the specified parameters. :param asset: Asset to be resized :type asset: Asset :param width: target width :type width: int :param height: target height :type height: int :param mode: resize behavior :type mode: ResizeMode :return: Asset with resized essence :rtype: Asset """ mime_type = MimeType(asset.mime_type) with PIL.Image.open(asset.essence) as image: if mode == ResizeMode.EXACT: resized_width = width resized_height = height else: aspect = asset.width / asset.height aspect_target = width / height if mode == ResizeMode.FIT and aspect >= aspect_target or \ mode == ResizeMode.FILL and aspect <= aspect_target: resize_factor = width / image.width else: resize_factor = height / image.height resized_width = max(1, round(resize_factor * image.width)) resized_height = max(1, round(resize_factor * image.height)) # Pillow supports resampling only for 8-bit images resampling_method = PIL.Image.LANCZOS if asset.depth == 8 else PIL.Image.NEAREST resized_image = image.resize((resized_width, resized_height), resample=resampling_method) with resized_image: resized_asset = self._image_to_asset(resized_image, mime_type=mime_type) return resized_asset
def _rotate(self, asset, rotation): """ Creates a new image asset from specified asset whose essence is rotated by the specified rotation. :param asset: Image asset to be rotated :type asset: Asset :param rotation: One of `PIL.Image.FLIP_LEFT_RIGHT`, `PIL.Image.FLIP_TOP_BOTTOM`, `PIL.Image.ROTATE_90`, `PIL.Image.ROTATE_180`, `PIL.Image.ROTATE_270`, or `PIL.Image.TRANSPOSE` :return: New image asset with rotated essence :rtype: Asset """ image = PIL.Image.open(asset.essence) mime_type = MimeType(asset.mime_type) transposed_image = image.transpose(rotation) transposed_asset = self._image_to_asset(transposed_image, mime_type=mime_type) return transposed_asset
def _image_to_asset(self, image, mime_type): """ Converts an PIL image to a MADAM asset. THe conversion can also include a conversion in file type. :param image: PIL image :type image: PIL.Image :param mime_type: MIME type of the target asset :type mime_type: MimeType :return: MADAM asset with hte specified MIME type :rtype: Asset """ mime_type = MimeType(mime_type) pil_format = PillowProcessor.__mime_type_to_pillow_type[mime_type] pil_options = PillowProcessor.__format_options.get(mime_type, {}) image_buffer = io.BytesIO() image.save(image_buffer, pil_format, **pil_options) image_buffer.seek(0) asset = self.read(image_buffer) return asset
def convert(self, asset, mime_type): """ Creates a new asset of the specified MIME type from the essence of the specified asset. :param asset: Asset whose contents will be converted :type asset: Asset :param mime_type: Target MIME type :type mime_type: MimeType or str :return: New asset with converted essence :rtype: Asset """ mime_type = MimeType(mime_type) try: image = PIL.Image.open(asset.essence) converted_asset = self._image_to_asset(image, mime_type) except (IOError, KeyError) as pil_error: raise OperatorError('Could not convert image to %s: %s' % (mime_type, pil_error)) return converted_asset
def convert(self, asset, mime_type, color_space=None, depth=None, data_type=None): """ Creates a new asset of the specified MIME type from the essence of the specified asset. :param asset: Asset whose contents will be converted :type asset: Asset :param mime_type: Target MIME type :type mime_type: MimeType or str :param color_space: Name of color space :type color_space: str or None :param depth: Bit depth per channel :type depth: int or None :param data_type: Data type of pixels ('int' or 'float') :type data_type: str or None :return: New asset with converted essence :rtype: Asset """ mime_type = MimeType(mime_type) try: image = PIL.Image.open(asset.essence) color_mode = color_space or asset.color_space, depth or asset.depth, data_type or asset.data_type pil_mode = PillowProcessor.__pillow_mode_to_color_mode.inv.get( color_mode) if pil_mode is not None: image = image.convert(pil_mode) converted_asset = self._image_to_asset(image, mime_type) except (IOError, KeyError) as pil_error: raise OperatorError('Could not convert image to %s: %s' % (mime_type, pil_error)) return converted_asset
def convert(self, asset: Asset, mime_type: Union[MimeType, str], color_space: Optional[str] = None, depth: Optional[int] = None, data_type: Optional[str] = None) -> Asset: """ Creates a new asset of the specified MIME type from the essence of the specified asset. :param asset: Asset whose contents will be converted :type asset: Asset :param mime_type: Target MIME type :type mime_type: MimeType or str :param color_space: Name of color space :type color_space: str or None :param depth: Bit depth per channel :type depth: int or None :param data_type: Data type of the pixels, e.g. 'uint' or 'float' :type data_type: str or None :return: New asset with converted essence :rtype: Asset """ mime_type = MimeType(mime_type) try: with PIL.Image.open(asset.essence) as image: color_mode = color_space or asset.color_space, depth or asset.depth, data_type or asset.data_type pil_mode = PillowProcessor.__pillow_mode_to_color_mode.inv.get( color_mode) if pil_mode is not None and pil_mode != image.mode: image = image.convert(pil_mode) converted_asset = self._image_to_asset(image, mime_type) except (IOError, KeyError) as pil_error: raise OperatorError( f'Could not convert image to {mime_type}: {pil_error}') return converted_asset
def read(self, file): with tempfile.NamedTemporaryFile() as tmp: tmp.write(file.read()) tmp.flush() metadata = pyexiv2.ImageMetadata(tmp.name) try: metadata.read() except OSError: raise UnsupportedFormatError('Unknown file format.') if MimeType(metadata.mime_type) not in Exiv2MetadataProcessor.supported_mime_types: raise UnsupportedFormatError('Unsupported format: %s' % metadata.mime_type) metadata_by_format = {} for metadata_format in self.formats: format_metadata = {} for exiv2_key in getattr(metadata, metadata_format + '_keys'): madam_key = Exiv2MetadataProcessor.metadata_to_exiv2.inv.get(exiv2_key) if madam_key is None: continue exiv2_value = metadata[exiv2_key].value convert_to_madam, _ = Exiv2MetadataProcessor.converters[madam_key] format_metadata[madam_key] = convert_to_madam(exiv2_value) if format_metadata: metadata_by_format[metadata_format] = format_metadata return metadata_by_format
def _image_to_asset(self, image: PIL.Image.Image, mime_type: Union[MimeType, str]) -> Asset: """ Converts an PIL image to a MADAM asset. The conversion can also include a change in file type. :param image: PIL image :type image: PIL.Image.Image :param mime_type: MIME type of the target asset :type mime_type: MimeType or str :return: MADAM asset with the specified MIME type :rtype: Asset """ mime_type = MimeType(mime_type) pil_format = PillowProcessor.__mime_type_to_pillow_type[mime_type] pil_options = dict(PillowProcessor.__format_defaults.get( mime_type, {})) format_config = dict(self.config.get(mime_type.type, {})) format_config.update(self.config.get(str(mime_type), {})) image_buffer = io.BytesIO() if mime_type == MimeType('image/png') and image.mode != 'P': use_zopfli = format_config.get('zopfli', False) if use_zopfli: import zopfli zopfli_png = zopfli.ZopfliPNG() # Convert 16-bit per channel images to 8-bit per channel zopfli_png.lossy_8bit = False # Allow altering hidden colors of fully transparent pixels zopfli_png.lossy_transparent = True # Use all available optimization strategies zopfli_png.filter_strategies = format_config.get( 'zopfli_strategies', '0me') pil_options.pop('optimize', False) essence = io.BytesIO() image.save(essence, 'PNG', optimize=False, **pil_options) essence.seek(0) optimized_data = zopfli_png.optimize(essence.read()) image_buffer.write(optimized_data) else: image.save(image_buffer, pil_format, **pil_options) elif mime_type == MimeType('image/jpeg'): pil_options['progressive'] = int( format_config.get('progressive', pil_options['progressive'])) pil_options['quality'] = int( format_config.get('quality', pil_options['quality'])) image.save(image_buffer, pil_format, **pil_options) elif mime_type == MimeType('image/tiff') and image.mode == 'P': pil_options.pop('compression', '') image.save(image_buffer, pil_format, **pil_options) elif mime_type == MimeType('image/webp'): pil_options['method'] = int( format_config.get('method', pil_options['method'])) pil_options['quality'] = int( format_config.get('quality', pil_options['quality'])) image.save(image_buffer, pil_format, **pil_options) else: image.save(image_buffer, pil_format, **pil_options) image_buffer.seek(0) asset = self.read(image_buffer) return asset
class PillowProcessor(Processor): """ Represents a processor that uses Pillow as a backend. """ __mime_type_to_pillow_type = bidict({ MimeType('image/gif'): 'GIF', MimeType('image/jpeg'): 'JPEG', MimeType('image/png'): 'PNG', MimeType('image/webp'): 'WEBP', }) __format_options = { MimeType('image/jpeg'): dict( optimize=True, progressive=True, ), MimeType('image/png'): dict(optimize=True, ), MimeType('image/webp'): dict(method=6, ), } def __init__(self): """ Initializes a new `PillowProcessor`. """ super().__init__() def read(self, file): image = PIL.Image.open(file) mime_type = PillowProcessor.__mime_type_to_pillow_type.inv[ image.format] metadata = dict(mime_type=str(mime_type), width=image.width, height=image.height) file.seek(0) asset = Asset(file, **metadata) return asset def can_read(self, file): try: PIL.Image.open(file) file.seek(0) return True except IOError: return False @operator def resize(self, asset, width, height, mode=ResizeMode.EXACT): """ Creates a new Asset whose essence is resized according to the specified parameters. :param asset: Asset to be resized :type asset: Asset :param width: target width :type width: int :param height: target height :type height: int :param mode: resize behavior :type mode: ResizeMode :return: Asset with resized essence :rtype: Asset """ image = PIL.Image.open(asset.essence) mime_type = MimeType(asset.mime_type) width_delta = width - image.width height_delta = height - image.height resized_width = width resized_height = height if mode in (ResizeMode.FIT, ResizeMode.FILL): if mode == ResizeMode.FIT and width_delta < height_delta or \ mode == ResizeMode.FILL and width_delta > height_delta: resize_factor = width / image.width else: resize_factor = height / image.height resized_width = round(resize_factor * image.width) resized_height = round(resize_factor * image.height) resized_image = image.resize((resized_width, resized_height), resample=PIL.Image.LANCZOS) resized_asset = self._image_to_asset(resized_image, mime_type=mime_type) return resized_asset def _image_to_asset(self, image, mime_type): """ Converts an PIL image to a MADAM asset. THe conversion can also include a conversion in file type. :param image: PIL image :type image: PIL.Image :param mime_type: MIME type of the target asset :type mime_type: MimeType :return: MADAM asset with hte specified MIME type :rtype: Asset """ mime_type = MimeType(mime_type) pil_format = PillowProcessor.__mime_type_to_pillow_type[mime_type] pil_options = PillowProcessor.__format_options.get(mime_type, {}) image_buffer = io.BytesIO() image.save(image_buffer, pil_format, **pil_options) image_buffer.seek(0) asset = self.read(image_buffer) return asset def _rotate(self, asset, rotation): """ Creates a new image asset from specified asset whose essence is rotated by the specified rotation. :param asset: Image asset to be rotated :type asset: Asset :param rotation: One of `PIL.Image.FLIP_LEFT_RIGHT`, `PIL.Image.FLIP_TOP_BOTTOM`, `PIL.Image.ROTATE_90`, `PIL.Image.ROTATE_180`, `PIL.Image.ROTATE_270`, or `PIL.Image.TRANSPOSE` :return: New image asset with rotated essence :rtype: Asset """ image = PIL.Image.open(asset.essence) mime_type = MimeType(asset.mime_type) transposed_image = image.transpose(rotation) transposed_asset = self._image_to_asset(transposed_image, mime_type=mime_type) return transposed_asset @operator def transpose(self, asset): """ Creates a new image asset whose essence is the transpose of the specified asset's essence. :param asset: Image asset whose essence is to be transposed :type asset: Asset :return: New image asset with transposed essence :rtype: Asset """ return self._rotate(asset, PIL.Image.TRANSPOSE) @operator def flip(self, asset, orientation): """ Creates a new asset whose essence is flipped according the specified orientation. :param asset: Asset whose essence is to be flipped :type asset: Asset :param orientation: axis of the flip operation :type orientation: FlipOrientation :return: Asset with flipped essence :rtype: Asset """ if orientation == FlipOrientation.HORIZONTAL: flip_orientation = PIL.Image.FLIP_LEFT_RIGHT else: flip_orientation = PIL.Image.FLIP_TOP_BOTTOM return self._rotate(asset, flip_orientation) @operator def auto_orient(self, asset): """ Creates a new asset whose essence is rotated according to the Exif orientation. If no orientation metadata exists or asset is not rotated, an identical asset object is returned. :param asset: Asset with orientation metadata :type asset: Asset :return: Asset with rotated essence :rtype: Asset """ orientation = asset.metadata.get('exif', {}).get('orientation') if orientation is None or orientation == 1: return asset flip_horizontally = self.flip(orientation=FlipOrientation.HORIZONTAL) flip_vertically = self.flip(orientation=FlipOrientation.VERTICAL) if orientation == 2: oriented_asset = flip_horizontally(asset) elif orientation == 3: oriented_asset = self._rotate(asset, PIL.Image.ROTATE_180) elif orientation == 4: oriented_asset = flip_vertically(asset) elif orientation == 5: oriented_asset = flip_vertically( self._rotate(asset, PIL.Image.ROTATE_90)) elif orientation == 6: oriented_asset = self._rotate(asset, PIL.Image.ROTATE_270) elif orientation == 7: oriented_asset = flip_horizontally( self._rotate(asset, PIL.Image.ROTATE_90)) elif orientation == 8: oriented_asset = self._rotate(asset, PIL.Image.ROTATE_90) else: raise OperatorError( 'Unable to correct image orientation with value %s' % orientation) return oriented_asset @operator def convert(self, asset, mime_type): """ Creates a new asset of the specified MIME type from the essence of the specified asset. :param asset: Asset whose contents will be converted :type asset: Asset :param mime_type: Target MIME type :type mime_type: MimeType or str :return: New asset with converted essence :rtype: Asset """ mime_type = MimeType(mime_type) try: image = PIL.Image.open(asset.essence) converted_asset = self._image_to_asset(image, mime_type) except (IOError, KeyError) as pil_error: raise OperatorError('Could not convert image to %s: %s' % (mime_type, pil_error)) return converted_asset @operator def crop(self, asset, x, y, width, height): """ Creates a new asset whose essence is cropped to the specified rectangular area. :param asset: Asset whose contents will be cropped :type asset: Asset :param x: horizontal offset of the cropping area from left :type x: int :param y: vertical offset of the cropping area from top :type y: int :param width: width of the cropping area :type width: int :param height: height of the cropping area :type height: int :return: New asset with cropped essence :rtype: Asset """ if x == 0 and y == 0 and width == asset.width and height == asset.height: return asset max_x = max(0, min(asset.width, width + x)) max_y = max(0, min(asset.height, height + y)) min_x = max(0, min(asset.width, x)) min_y = max(0, min(asset.height, y)) if min_x == asset.width or min_y == asset.height or max_x <= min_x or max_y <= min_y: raise OperatorError( 'Invalid cropping area: <x=%r, y=%r, width=%r, height=%r>' % (x, y, width, height)) image = PIL.Image.open(asset.essence) cropped_image = image.crop(box=(min_x, min_y, max_x, max_y)) cropped_asset = self._image_to_asset(cropped_image, mime_type=asset.mime_type) return cropped_asset @operator def rotate(self, asset, angle, expand=False): """ Creates an asset whose essence is rotated by the specified angle in degrees. .. warning:: The color model will be changed to RGB when applying this operation :param asset: Asset whose contents will be rotated :type asset: Asset :param angle: Angle in degrees, counter clockwise :type angle: float :param expand: If true, changes the dimensions of the new asset so it can hold the entire rotated essence, otherwise the dimensions of the original asset will be used. :type expand: bool :return: New asset with rotated essence :rtype: Asset """ if angle % 360.0 == 0.0: return asset image = PIL.Image.open(asset.essence).convert('RGB') rotated_image = image.rotate(angle=angle, resample=PIL.Image.BICUBIC, expand=expand) rotated_asset = self._image_to_asset(rotated_image, mime_type=asset.mime_type) return rotated_asset
def test_mime_type_type_cannot_contain_more_than_one_delimiter(): with pytest.raises(ValueError): MimeType('foo/bar/baz')
class FFmpegProcessor(Processor): """ Represents a processor that uses FFmpeg to read audio and video data. The minimum version of FFmpeg required is v0.9. """ __decoder_and_stream_type_to_mime_type = { ('matroska,webm', 'video'): MimeType('video/x-matroska'), ('mov,mp4,m4a,3gp,3g2,mj2', 'video'): MimeType('video/quicktime'), ('avi', 'video'): MimeType('video/x-msvideo'), ('mpegts', 'video'): MimeType('video/mp2t'), ('ogg', 'video'): MimeType('video/ogg'), ('mp3', 'audio'): MimeType('audio/mpeg'), ('ogg', 'audio'): MimeType('audio/ogg'), ('wav', 'audio'): MimeType('audio/wav'), } __mime_type_to_encoder = { MimeType('video/x-matroska'): 'matroska', MimeType('video/quicktime'): 'mov', MimeType('video/x-msvideo'): 'avi', MimeType('video/mp2t'): 'mpegts', MimeType('video/ogg'): 'ogg', MimeType('audio/mpeg'): 'mp3', MimeType('audio/ogg'): 'ogg', MimeType('audio/wav'): 'wav', MimeType('image/gif'): 'gif', MimeType('image/jpeg'): 'image2', MimeType('image/png'): 'image2', MimeType('image/webp'): 'image2', } __mime_type_to_codec = { MimeType('image/gif'): 'gif', MimeType('image/jpeg'): 'mjpeg', MimeType('image/png'): 'png', MimeType('image/webp'): 'libwebp', } __codec_options = { 'video': { 'libx264': [ '-preset', 'slow', '-crf', '23', '-pix_fmt', 'yuv420p', ], 'libx265': [ '-preset', 'slow', '-crf', '28', '-pix_fmt', 'yuv420p', ], 'libvpx': [ '-speed', '1', '-crf', '10', '-pix_fmt', 'yuv420p', ], 'libvpx-vp9': [ '-speed', '1', '-tile-columns', '6', '-crf', '32', '-pix_fmt', 'yuv420p', ], 'vp9': [ '-speed', '1', '-tile-columns', '6', '-crf', '32', '-pix_fmt', 'yuv420p', ], } } __container_options = { MimeType('video/quicktime'): [ '-movflags', '+faststart', ], MimeType('video/x-matroska'): [ '-avoid_negative_ts', 'make_zero', ], } def __init__(self): """ Initializes a new `FFmpegProcessor`. :raises EnvironmentError: if the installed version of ffprobe does not match the minimum version requirement """ super().__init__() self._min_version = '0.9' command = 'ffprobe -version'.split() result = subprocess_run(command, stdout=subprocess.PIPE) string_result = result.stdout.decode('utf-8') version_string = string_result.split()[2] if version_string < self._min_version: raise EnvironmentError('Found ffprobe version %s. Requiring at least version %s.' % (version_string, self._min_version)) self.__threads = multiprocessing.cpu_count() def can_read(self, file): try: probe_data = _probe(file) return bool(probe_data) except CalledProcessError: return False def read(self, file): try: probe_data = _probe(file) except CalledProcessError: raise UnsupportedFormatError('Unsupported file format.') decoder_and_stream_type = _get_decoder_and_stream_type(probe_data) mime_type = self.__decoder_and_stream_type_to_mime_type.get(decoder_and_stream_type) if not mime_type: raise UnsupportedFormatError('Unsupported metadata source.') metadata = dict( mime_type=str(mime_type), duration=float(probe_data['format']['duration']) ) for stream in probe_data['streams']: stream_type = stream.get('codec_type') if stream_type in ('audio', 'video'): # Only use first stream if stream_type in metadata: break metadata[stream_type] = {} if 'width' in stream: metadata['width'] = max(stream['width'], metadata.get('width', 0)) if 'height' in stream: metadata['height'] = max(stream['height'], metadata.get('height', 0)) if stream_type not in metadata: continue if 'codec_name' in stream: metadata[stream_type]['codec'] = stream['codec_name'] if 'bit_rate' in stream: metadata[stream_type]['bitrate'] = float(stream['bit_rate'])/1000.0 return Asset(essence=file, **metadata) @operator def resize(self, asset, width, height): """ Creates a new image or video asset of the specified width and height from the essence of the specified image or video asset. Width and height must be positive numbers. :param asset: Video asset that will serve as the source for the frame :type asset: Asset :param width: Width of the resized asset :type width: int :param height: Height of the resized asset :type height: int :return: New asset with specified width and height :rtype: Asset """ if width < 1 or height < 1: raise ValueError('Invalid dimensions: %dx%d' % (width, height)) mime_type = MimeType(asset.mime_type) encoder_name = self.__mime_type_to_encoder.get(mime_type) if not encoder_name: raise UnsupportedFormatError('Unsupported asset type: %s' % mime_type) if mime_type.type not in ('image', 'video'): raise OperatorError('Cannot resize asset of type %s') result = io.BytesIO() with _FFmpegContext(asset.essence, result) as ctx: with open(ctx.input_path, 'wb') as temp_in: shutil.copyfileobj(asset.essence, temp_in) temp_in.flush() command = ['ffmpeg', '-loglevel', 'error', '-f', encoder_name, '-i', ctx.input_path, '-filter:v', 'scale=%d:%d' % (width, height), '-threads', str(self.__threads), '-f', encoder_name, '-y', ctx.output_path] try: subprocess_run(command, stderr=subprocess.PIPE, check=True) except CalledProcessError as ffmpeg_error: error_message = ffmpeg_error.stderr.decode('utf-8') raise OperatorError('Could not resize video asset: %s' % error_message) return Asset(essence=result, mime_type=mime_type, width=width, height=height, duration=asset.duration) @operator def convert(self, asset, mime_type, video=None, audio=None, subtitle=None): """ Creates a new asset of the specified MIME type from the essence of the specified asset. Additional options can be specified for video, audio, and subtitle streams. Options are passed as dictionary instances and can contain various keys for each stream type. **Options for video streams:** - **codec** – Processor-specific name of the video codec as string - **bitrate** – Target bitrate in kBit/s as float number **Options for audio streams:** - **codec** – Processor-specific name of the audio codec as string - **bitrate** – Target bitrate in kBit/s as float number **Options for subtitle streams:** - **codec** – Processor-specific name of the subtitle format as string :param asset: Asset whose contents will be converted :type asset: Asset :param mime_type: MIME type of the video container :type mime_type: MimeType or str :param video: Dictionary with options for video streams. :type video: dict or None :param audio: Dictionary with options for audio streams. :type audio: dict or None :param subtitle: Dictionary with the options for subtitle streams. :type subtitle: dict or None :return: New asset with converted essence :rtype: Asset """ mime_type = MimeType(mime_type) encoder_name = self.__mime_type_to_encoder.get(mime_type) if not encoder_name: raise UnsupportedFormatError('Unsupported asset type: %s' % mime_type) result = io.BytesIO() with _FFmpegContext(asset.essence, result) as ctx: command = ['ffmpeg', '-loglevel', 'error', '-i', ctx.input_path] if video: if 'codec' in video: if video['codec']: command.extend(['-c:v', video['codec']]) codec_options = FFmpegProcessor.__codec_options.get('video', {}) command.extend(codec_options.get(video['codec'], [])) else: command.extend(['-vn']) if video.get('bitrate'): # Set minimum at 50% of bitrate and maximum at 145% of bitrate # (see https://developers.google.com/media/vp9/settings/vod/) command.extend(['-minrate', '%dk' % round(0.5*video['bitrate']), '-b:v', '%dk' % video['bitrate'], '-maxrate', '%dk' % round(1.45*video['bitrate'])]) if audio: if 'codec' in audio: if audio['codec']: command.extend(['-c:a', audio['codec']]) codec_options = FFmpegProcessor.__codec_options.get('audio', {}) command.extend(codec_options.get(audio['codec'], [])) else: command.extend(['-an']) if audio.get('bitrate'): command.extend(['-b:a', '%dk' % audio['bitrate']]) if subtitle: if 'codec' in subtitle: if subtitle['codec']: command.extend(['-c:s', subtitle['codec']]) codec_options = FFmpegProcessor.__codec_options.get('subtitles', {}) command.extend(codec_options.get(subtitle['codec'], [])) else: command.extend(['-sn']) container_options = FFmpegProcessor.__container_options.get(mime_type, []) command.extend(container_options) command.extend(['-threads', str(self.__threads), '-f', encoder_name, '-y', ctx.output_path]) try: subprocess_run(command, stderr=subprocess.PIPE, check=True) except CalledProcessError as ffmpeg_error: error_message = ffmpeg_error.stderr.decode('utf-8') raise OperatorError('Could not convert video asset: %s' % error_message) metadata = { 'mime_type': str(mime_type) } if mime_type.type in ('image', 'video'): metadata['width'] = asset.width metadata['height'] = asset.height if mime_type.type in ('audio', 'video'): metadata['duration'] = asset.duration return Asset(essence=result, **metadata) @operator def trim(self, asset, from_seconds=0, to_seconds=0): """ Creates a trimmed audio or video asset that only contains the data between from_seconds and to_seconds. :param asset: Audio or video asset, which will serve as the source :type asset: Asset :param from_seconds: Start time of the clip in seconds :type from_seconds: float :param to_seconds: End time of the clip in seconds :type to_seconds: float :return: New asset with trimmed essence :rtype: Asset """ mime_type = MimeType(asset.mime_type) encoder_name = self.__mime_type_to_encoder.get(mime_type) if not encoder_name or mime_type.type not in ('audio', 'video'): raise UnsupportedFormatError('Unsupported source asset type: %s' % mime_type) if to_seconds <= 0: to_seconds = asset.duration + to_seconds duration = float(to_seconds) - float(from_seconds) if duration <= 0: raise ValueError('Start time must be before end time') result = io.BytesIO() with _FFmpegContext(asset.essence, result) as ctx: command = ['ffmpeg', '-v', 'error', '-ss', str(float(from_seconds)), '-t', str(duration), '-i', ctx.input_path, '-codec', 'copy', '-f', encoder_name, '-y', ctx.output_path] try: subprocess_run(command, stderr=subprocess.PIPE, check=True) except CalledProcessError as ffmpeg_error: error_message = ffmpeg_error.stderr.decode('utf-8') raise OperatorError('Could not convert video asset: %s' % error_message) return Asset(essence=result, mime_type=asset.mime_type, width=asset.width, height=asset.height, duration=duration) @operator def extract_frame(self, asset, mime_type, seconds=0): """ Creates a new image asset of the specified MIME type from the essence of the specified video asset. :param asset: Video asset which will serve as the source for the frame :type asset: Asset :param mime_type: MIME type of the destination image :type mime_type: MimeType or str :param seconds: Offset of the frame in seconds :type seconds: float :return: New image asset with converted essence :rtype: Asset """ source_mime_type = MimeType(asset.mime_type) if source_mime_type.type != 'video': raise UnsupportedFormatError('Unsupported source asset type: %s' % source_mime_type) mime_type = MimeType(mime_type) encoder_name = self.__mime_type_to_encoder.get(mime_type) codec_name = self.__mime_type_to_codec.get(mime_type) if not (encoder_name and codec_name): raise UnsupportedFormatError('Unsupported target asset type: %s' % mime_type) result = io.BytesIO() with _FFmpegContext(asset.essence, result) as ctx: command = ['ffmpeg', '-v', 'error', '-i', ctx.input_path, '-ss', str(float(seconds)), '-codec:v', codec_name, '-vframes', '1', '-f', encoder_name, '-y', ctx.output_path] try: subprocess_run(command, stderr=subprocess.PIPE, check=True) except CalledProcessError as ffmpeg_error: error_message = ffmpeg_error.stderr.decode('utf-8') raise OperatorError('Could not convert video asset: %s' % error_message) return Asset(essence=result, mime_type=mime_type, width=asset.width, height=asset.height) @operator def crop(self, asset, x, y, width, height): """ Creates a cropped video asset whose essence is cropped to the specified rectangular area. :param asset: Video asset whose contents will be cropped :type asset: Asset :param x: Horizontal offset of the cropping area from left :type x: int :param y: Vertical offset of the cropping area from top :type y: int :param width: Width of the cropping area :type width: int :param height: Height of the cropping area :type height: int :return: New asset with cropped essence :rtype: Asset """ mime_type = MimeType(asset.mime_type) encoder_name = self.__mime_type_to_encoder.get(mime_type) if not encoder_name or mime_type.type != 'video': raise UnsupportedFormatError('Unsupported source asset type: %s' % mime_type) if x == 0 and y == 0 and width == asset.width and height == asset.height: return asset max_x = max(0, min(asset.width, width + x)) max_y = max(0, min(asset.height, height + y)) min_x = max(0, min(asset.width, x)) min_y = max(0, min(asset.height, y)) if min_x == asset.width or min_y == asset.height or max_x <= min_x or max_y <= min_y: raise OperatorError('Invalid cropping area: <x=%r, y=%r, width=%r, height=%r>' % (x, y, width, height)) width = max_x - min_x height = max_y - min_y result = io.BytesIO() with _FFmpegContext(asset.essence, result) as ctx: command = ['ffmpeg', '-v', 'error', '-i', ctx.input_path, '-codec', 'copy', '-f:v', 'crop=w=%d:h=%d:x=%d:y=%d' % (width, height, x, y), '-f', encoder_name, '-y', ctx.output_path] try: subprocess_run(command, stderr=subprocess.PIPE, check=True) except CalledProcessError as ffmpeg_error: error_message = ffmpeg_error.stderr.decode('utf-8') raise OperatorError('Could not convert video asset: %s' % error_message) return Asset(essence=result, mime_type=mime_type, width=width, height=height) @operator def rotate(self, asset, angle, expand=False): """ Creates an asset whose essence is rotated by the specified angle in degrees. :param asset: Asset whose contents will be rotated :type asset: Asset :param angle: Angle in degrees, counter clockwise :type angle: float :param expand: If true, changes the dimensions of the new asset so it can hold the entire rotated essence, otherwise the dimensions of the original asset will be used. :type expand: bool :return: New asset with rotated essence :rtype: Asset """ mime_type = MimeType(asset.mime_type) encoder_name = self.__mime_type_to_encoder.get(mime_type) if not encoder_name or mime_type.type != 'video': raise UnsupportedFormatError('Unsupported source asset type: %s' % mime_type) if angle % 360.0 == 0.0: return asset angle_rad = radians(angle) width = asset.width height = asset.height if expand: if angle % 180 < 90: width_ = asset.width height_ = asset.height angle_rad_ = angle_rad % pi else: width_ = asset.height height_ = asset.width angle_rad_ = angle_rad % pi - pi/2 cos_a = cos(angle_rad_) sin_a = sin(angle_rad_) width = ceil(round(width_ * cos_a + height_ * sin_a, 7)) height = ceil(round(width_ * sin_a + height_ * cos_a, 7)) result = io.BytesIO() with _FFmpegContext(asset.essence, result) as ctx: command = ['ffmpeg', '-v', 'error', '-i', ctx.input_path, '-codec', 'copy', '-f:v', 'rotate=a=%(a)f:ow=%(w)d:oh=%(h)d)' % dict(a=angle_rad, w=width, h=height), '-f', encoder_name, '-y', ctx.output_path] try: subprocess_run(command, stderr=subprocess.PIPE, check=True) except CalledProcessError as ffmpeg_error: error_message = ffmpeg_error.stderr.decode('utf-8') raise OperatorError('Could not convert video asset: %s' % error_message) return Asset(essence=result, mime_type=mime_type, width=width, height=height)
class FFmpegMetadataProcessor(MetadataProcessor): """ Represents a metadata processor that uses FFmpeg. """ __decoder_and_stream_type_to_mime_type = { ('matroska,webm', 'video'): MimeType('video/x-matroska'), ('mov,mp4,m4a,3gp,3g2,mj2', 'video'): MimeType('video/quicktime'), ('avi', 'video'): MimeType('video/x-msvideo'), ('mpegts', 'video'): MimeType('video/mp2t'), ('ogg', 'video'): MimeType('video/ogg'), ('mp3', 'audio'): MimeType('audio/mpeg'), ('ogg', 'audio'): MimeType('audio/ogg'), ('wav', 'audio'): MimeType('audio/wav'), } __mime_type_to_encoder = { MimeType('video/x-matroska'): 'matroska', MimeType('video/quicktime'): 'mov', MimeType('video/x-msvideo'): 'avi', MimeType('video/mp2t'): 'mpegts', MimeType('video/ogg'): 'ogg', MimeType('audio/mpeg'): 'mp3', MimeType('audio/ogg'): 'ogg', MimeType('audio/wav'): 'wav', } # See https://wiki.multimedia.cx/index.php?title=FFmpeg_Metadata metadata_keys_by_mime_type = { MimeType('video/x-matroska'): bidict({}), MimeType('video/x-msvideo'): bidict({}), MimeType('video/mp2t'): bidict({}), MimeType('video/quicktime'): bidict({}), MimeType('video/ogg'): bidict({}), MimeType('audio/mpeg'): bidict({ 'album': 'album', # TALB Album 'album_artist': 'album_artist', # TPE2 Band/orchestra/accompaniment 'album_sort': 'album-sort', # TSOA Album sort order 'artist': 'artist', # TPE1 Lead performer(s)/Soloist(s) 'artist_sort': 'artist-sort', # TSOP Performer sort order 'bpm': 'TBPM', # TBPM BPM (beats per minute) 'composer': 'composer', # TCOM Composer 'performer': 'performer', # TPE3 Conductor/performer refinement 'content_group': 'TIT1', # TIT1 Content group description 'copyright': 'copyright', # TCOP (Copyright message) 'date': 'date', # TDRC Recording time 'disc': 'disc', # TPOS Part of a set 'disc_subtitle': 'TSST', # TSST Set subtitle 'encoded_by': 'encoded_by', # TENC Encoded by 'encoder': 'encoder', # TSSE Software/Hardware and settings used for encoding 'encoding_time': 'TDEN', # TDEN Encoding time 'file_type': 'TFLT', # TFLT File type 'genre': 'genre', # TCON (Content type) 'isrc': 'TSRC', # TSRC ISRC (international standard recording code) 'initial_key': 'TKEY', # TKEY Musical key in which the sound starts 'involved_people': 'TIPL', # TIPL Involved people list 'language': 'language', # TLAN Language(s) 'length': 'TLEN', # TLEN Length of the audio file in milliseconds 'lyricist': 'TEXT', # TEXT Lyricist/Text writer 'lyrics': 'lyrics', # USLT Unsychronized lyric/text transcription 'media_type': 'TMED', # TMED Media type 'mood': 'TMOO', # TMOO Mood 'original_album': 'TOAL', # TOAL Original album/movie/show title 'original_artist': 'TOPE', # TOPE Original artist(s)/performer(s) 'original_date': 'TDOR', # TDOR Original release time 'original_filename': 'TOFN', # TOFN Original filename 'original_lyricist': 'TOLY', # TOLY Original lyricist(s)/text writer(s) 'owner': 'TOWN', # TOWN File owner/licensee 'credits': 'TMCL', # TMCL Musician credits list 'playlist_delay': 'TDLY', # TDLY Playlist delay 'produced_by': 'TPRO', # TPRO Produced notice 'publisher': 'publisher', # TPUB Publisher 'radio_station_name': 'TRSN', # TRSN Internet radio station name 'radio_station_owner': 'TRSO', # TRSO Internet radio station owner 'remixed_by': 'TP4', # TPE4 Interpreted, remixed, or otherwise modified by 'tagging_date': 'TDTG', # TDTG Tagging time 'title': 'title', # TIT2 Title/songname/content description 'title_sort': 'title-sort', # TSOT Title sort order 'track': 'track', # TRCK Track number/Position in set 'version': 'TIT3', # TIT3 Subtitle/Description refinement # Release time (TDRL) can be written, but it collides with # recording time (TDRC) when reading; # AENC, APIC, ASPI, COMM, COMR, ENCR, EQU2, ETCO, GEOB, GRID, LINK, # MCDI, MLLT, OWNE, PRIV, PCNT, POPM, POSS, RBUF, RVA2, RVRB, SEEK, # SIGN, SYLT, SYTC, UFID, USER, WCOM, WCOP, WOAF, WOAR, WOAS, WORS, # WPAY, WPUB, and WXXX will be written as TXXX tag }), MimeType('audio/ogg'): bidict({ 'album': 'ALBUM', # Collection name 'album_artist': 'album_artist', # Band/orchestra/accompaniment 'artist': 'ARTIST', # Band or singer, composer, author, etc. 'comment': 'comment', # Short text description of the contents 'composer': 'COMPOSER', # Composer 'contact': 'CONTACT', # Contact information for the creators or distributors 'copyright': 'COPYRIGHT', # Copyright attribution 'date': 'DATE', # Date the track was recorded 'disc': 'disc', # Collection number 'encoded_by': 'ENCODED-BY', # Encoded by 'encoder': 'ENCODER', # Software/Hardware and settings used for encoding 'genre': 'GENRE', # Short text indication of music genre 'isrc': 'ISRC', # ISRC number 'license': 'LICENSE', # License information 'location': 'LOCATION', # Location where track was recorded 'performer': 'PERFORMER', # Artist(s) who performed the work (conductor, orchestra, etc.) 'produced_by': 'ORGANIZATION', # Organization producing the track (i.e. the 'record label') 'title': 'TITLE', # Track/Work name 'track': 'track', # Track number if part of a collection or album 'tracks': 'TRACKTOTAL', # Total number of track number in a collection or album 'version': 'VERSION', # Version of the track (e.g. remix info) }), MimeType('audio/wav'): bidict({}), } def __init__(self): """ Initializes a new `FFmpegMetadataProcessor`. """ super().__init__() @property def formats(self): return 'ffmetadata', def read(self, file): try: probe_data = _probe(file) except CalledProcessError: raise UnsupportedFormatError('Unsupported file format.') decoder_and_stream_type = _get_decoder_and_stream_type(probe_data) mime_type = self.__decoder_and_stream_type_to_mime_type.get(decoder_and_stream_type) if not mime_type: raise UnsupportedFormatError('Unsupported metadata source.') # Extract metadata (tags) from ffprobe information ffmetadata = probe_data['format'].get('tags', {}) for stream in probe_data['streams']: ffmetadata.update(stream.get('tags', {})) # Convert FFMetadata items to metadata items metadata = {} metadata_keys = self.metadata_keys_by_mime_type[mime_type] for ffmetadata_key, value in ffmetadata.items(): metadata_key = metadata_keys.inv.get(ffmetadata_key) if metadata_key is not None: metadata[metadata_key] = value return {'ffmetadata': metadata} def strip(self, file): try: probe_data = _probe(file) except CalledProcessError: raise UnsupportedFormatError('Unsupported file format.') decoder_and_stream_type = _get_decoder_and_stream_type(probe_data) mime_type = self.__decoder_and_stream_type_to_mime_type.get(decoder_and_stream_type) if not mime_type: raise UnsupportedFormatError('Unsupported metadata source.') # Strip metadata result = io.BytesIO() with _FFmpegContext(file, result) as ctx: encoder_name = self.__mime_type_to_encoder[mime_type] command = ['ffmpeg', '-loglevel', 'error', '-i', ctx.input_path, '-map_metadata', '-1', '-codec', 'copy', '-y', '-f', encoder_name, ctx.output_path] try: subprocess_run(command, stderr=subprocess.PIPE, check=True) except CalledProcessError as ffmpeg_error: error_message = ffmpeg_error.stderr.decode('utf-8') raise OperatorError('Could not strip metadata: %s' % error_message) return result def combine(self, file, metadata_by_type): try: probe_data = _probe(file) except CalledProcessError: raise UnsupportedFormatError('Unsupported file format.') decoder_and_stream_type = _get_decoder_and_stream_type(probe_data) mime_type = self.__decoder_and_stream_type_to_mime_type.get(decoder_and_stream_type) if not mime_type: raise UnsupportedFormatError('Unsupported metadata source.') # Validate provided metadata if not metadata_by_type: raise ValueError('No metadata provided') if 'ffmetadata' not in metadata_by_type: raise UnsupportedFormatError('Invalid metadata to be combined with essence: %r' % (metadata_by_type.keys(),)) if not metadata_by_type['ffmetadata']: raise ValueError('No metadata provided') # Add metadata to file result = io.BytesIO() with _FFmpegContext(file, result) as ctx: encoder_name = self.__mime_type_to_encoder[mime_type] command = ['ffmpeg', '-loglevel', 'error', '-f', encoder_name, '-i', ctx.input_path] ffmetadata = metadata_by_type['ffmetadata'] metadata_keys = self.metadata_keys_by_mime_type[mime_type] for metadata_key, value in ffmetadata.items(): ffmetadata_key = metadata_keys.get(metadata_key) if ffmetadata_key is None: raise ValueError('Unsupported metadata key: %r' % metadata_key) command.append('-metadata') command.append('%s=%s' % (ffmetadata_key, value)) command.extend(['-codec', 'copy', '-y', '-f', encoder_name, ctx.output_path]) try: subprocess_run(command, stderr=subprocess.PIPE, check=True) except CalledProcessError as ffmpeg_error: error_message = ffmpeg_error.stderr.decode('utf-8') raise OperatorError('Could not add metadata: %s' % error_message) return result
def convert(self, asset, mime_type, video=None, audio=None, subtitle=None): """ Creates a new asset of the specified MIME type from the essence of the specified asset. Additional options can be specified for video, audio, and subtitle streams. Options are passed as dictionary instances and can contain various keys for each stream type. **Options for video streams:** - **codec** – Processor-specific name of the video codec as string - **bitrate** – Target bitrate in kBit/s as float number **Options for audio streams:** - **codec** – Processor-specific name of the audio codec as string - **bitrate** – Target bitrate in kBit/s as float number **Options for subtitle streams:** - **codec** – Processor-specific name of the subtitle format as string :param asset: Asset whose contents will be converted :type asset: Asset :param mime_type: MIME type of the video container :type mime_type: MimeType or str :param video: Dictionary with options for video streams. :type video: dict or None :param audio: Dictionary with options for audio streams. :type audio: dict or None :param subtitle: Dictionary with the options for subtitle streams. :type subtitle: dict or None :return: New asset with converted essence :rtype: Asset """ mime_type = MimeType(mime_type) encoder_name = self.__mime_type_to_encoder.get(mime_type) if not encoder_name: raise UnsupportedFormatError('Unsupported asset type: %s' % mime_type) result = io.BytesIO() with _FFmpegContext(asset.essence, result) as ctx: command = ['ffmpeg', '-loglevel', 'error', '-i', ctx.input_path] if video: if 'codec' in video: if video['codec']: command.extend(['-c:v', video['codec']]) codec_options = FFmpegProcessor.__codec_options.get('video', {}) command.extend(codec_options.get(video['codec'], [])) else: command.extend(['-vn']) if video.get('bitrate'): # Set minimum at 50% of bitrate and maximum at 145% of bitrate # (see https://developers.google.com/media/vp9/settings/vod/) command.extend(['-minrate', '%dk' % round(0.5*video['bitrate']), '-b:v', '%dk' % video['bitrate'], '-maxrate', '%dk' % round(1.45*video['bitrate'])]) if audio: if 'codec' in audio: if audio['codec']: command.extend(['-c:a', audio['codec']]) codec_options = FFmpegProcessor.__codec_options.get('audio', {}) command.extend(codec_options.get(audio['codec'], [])) else: command.extend(['-an']) if audio.get('bitrate'): command.extend(['-b:a', '%dk' % audio['bitrate']]) if subtitle: if 'codec' in subtitle: if subtitle['codec']: command.extend(['-c:s', subtitle['codec']]) codec_options = FFmpegProcessor.__codec_options.get('subtitles', {}) command.extend(codec_options.get(subtitle['codec'], [])) else: command.extend(['-sn']) container_options = FFmpegProcessor.__container_options.get(mime_type, []) command.extend(container_options) command.extend(['-threads', str(self.__threads), '-f', encoder_name, '-y', ctx.output_path]) try: subprocess_run(command, stderr=subprocess.PIPE, check=True) except CalledProcessError as ffmpeg_error: error_message = ffmpeg_error.stderr.decode('utf-8') raise OperatorError('Could not convert video asset: %s' % error_message) metadata = { 'mime_type': str(mime_type) } if mime_type.type in ('image', 'video'): metadata['width'] = asset.width metadata['height'] = asset.height if mime_type.type in ('audio', 'video'): metadata['duration'] = asset.duration return Asset(essence=result, **metadata)
def test_mime_type_has_string_representation(): mime = MimeType(mediatype='foo', subtype=None) assert repr(mime) == "MimeType(mediatype='foo', subtype=None)"
class PillowProcessor(Processor): """ Represents a processor that uses Pillow as a backend. """ __mime_type_to_pillow_type = bidict({ MimeType('image/bmp'): 'BMP', MimeType('image/gif'): 'GIF', MimeType('image/jpeg'): 'JPEG', MimeType('image/png'): 'PNG', MimeType('image/tiff'): 'TIFF', MimeType('image/webp'): 'WEBP', }) __format_defaults = { MimeType('image/gif'): dict(optimize=True, ), MimeType('image/jpeg'): dict( optimize=True, progressive=True, quality=80, ), MimeType('image/png'): dict(optimize=True, ), MimeType('image/tiff'): dict(compression='tiff_deflate', ), MimeType('image/webp'): dict( method=6, quality=80, ), } __pillow_mode_to_color_mode = bidict({ '1': ('LUMA', 1, 'uint'), 'L': ('LUMA', 8, 'uint'), 'LA': ('LUMAA', 8, 'uint'), 'P': ('PALETTE', 8, 'uint'), 'RGB': ('RGB', 8, 'uint'), 'RGBA': ('RGBA', 8, 'uint'), 'RGBX': ('RGBX', 8, 'uint'), 'CMYK': ('CMYK', 8, 'uint'), 'YCbCr': ('YCbCr', 8, 'uint'), 'LAB': ('LAB', 8, 'uint'), 'HSV': ('HSV', 8, 'uint'), 'I;16': ('LUMA', 16, 'uint'), 'I': ('LUMA', 32, 'uint'), 'F': ('LUMA', 32, 'float'), }) def __init__(self, config: Optional[Mapping[str, Any]] = None) -> None: """ Initializes a new `PillowProcessor`. :param config: Mapping with settings. """ super().__init__(config) def read(self, file: IO) -> Asset: with PIL.Image.open(file) as image: mime_type = PillowProcessor.__mime_type_to_pillow_type.inv[ image.format] color_space, bit_depth, data_type = PillowProcessor.__pillow_mode_to_color_mode[ image.mode] metadata = dict( mime_type=str(mime_type), width=image.width, height=image.height, color_space=color_space, depth=bit_depth, data_type=data_type, ) file.seek(0) asset = Asset(file, **metadata) return asset def can_read(self, file: IO) -> bool: try: PIL.Image.open(file) return True except IOError: return False finally: file.seek(0) @operator def resize(self, asset: Asset, width: int, height: int, mode: ResizeMode = ResizeMode.EXACT) -> Asset: """ Creates a new Asset whose essence is resized according to the specified parameters. :param asset: Asset to be resized :type asset: Asset :param width: target width :type width: int :param height: target height :type height: int :param mode: resize behavior :type mode: ResizeMode :return: Asset with resized essence :rtype: Asset """ mime_type = MimeType(asset.mime_type) with PIL.Image.open(asset.essence) as image: if mode == ResizeMode.EXACT: resized_width = width resized_height = height else: aspect = asset.width / asset.height aspect_target = width / height if mode == ResizeMode.FIT and aspect >= aspect_target or \ mode == ResizeMode.FILL and aspect <= aspect_target: resize_factor = width / image.width else: resize_factor = height / image.height resized_width = max(1, round(resize_factor * image.width)) resized_height = max(1, round(resize_factor * image.height)) # Pillow supports resampling only for 8-bit images resampling_method = PIL.Image.LANCZOS if asset.depth == 8 else PIL.Image.NEAREST resized_image = image.resize((resized_width, resized_height), resample=resampling_method) with resized_image: resized_asset = self._image_to_asset(resized_image, mime_type=mime_type) return resized_asset def _image_to_asset(self, image: PIL.Image.Image, mime_type: Union[MimeType, str]) -> Asset: """ Converts an PIL image to a MADAM asset. The conversion can also include a change in file type. :param image: PIL image :type image: PIL.Image.Image :param mime_type: MIME type of the target asset :type mime_type: MimeType or str :return: MADAM asset with the specified MIME type :rtype: Asset """ mime_type = MimeType(mime_type) pil_format = PillowProcessor.__mime_type_to_pillow_type[mime_type] pil_options = dict(PillowProcessor.__format_defaults.get( mime_type, {})) format_config = dict(self.config.get(mime_type.type, {})) format_config.update(self.config.get(str(mime_type), {})) image_buffer = io.BytesIO() if mime_type == MimeType('image/png') and image.mode != 'P': use_zopfli = format_config.get('zopfli', False) if use_zopfli: import zopfli zopfli_png = zopfli.ZopfliPNG() # Convert 16-bit per channel images to 8-bit per channel zopfli_png.lossy_8bit = False # Allow altering hidden colors of fully transparent pixels zopfli_png.lossy_transparent = True # Use all available optimization strategies zopfli_png.filter_strategies = format_config.get( 'zopfli_strategies', '0me') pil_options.pop('optimize', False) essence = io.BytesIO() image.save(essence, 'PNG', optimize=False, **pil_options) essence.seek(0) optimized_data = zopfli_png.optimize(essence.read()) image_buffer.write(optimized_data) else: image.save(image_buffer, pil_format, **pil_options) elif mime_type == MimeType('image/jpeg'): pil_options['progressive'] = int( format_config.get('progressive', pil_options['progressive'])) pil_options['quality'] = int( format_config.get('quality', pil_options['quality'])) image.save(image_buffer, pil_format, **pil_options) elif mime_type == MimeType('image/tiff') and image.mode == 'P': pil_options.pop('compression', '') image.save(image_buffer, pil_format, **pil_options) elif mime_type == MimeType('image/webp'): pil_options['method'] = int( format_config.get('method', pil_options['method'])) pil_options['quality'] = int( format_config.get('quality', pil_options['quality'])) image.save(image_buffer, pil_format, **pil_options) else: image.save(image_buffer, pil_format, **pil_options) image_buffer.seek(0) asset = self.read(image_buffer) return asset def _rotate(self, asset: Asset, rotation: int) -> Asset: """ Creates a new image asset from specified asset whose essence is rotated by the specified rotation. :param asset: Image asset to be rotated :type asset: Asset :param rotation: One of `PIL.Image.FLIP_LEFT_RIGHT`, `PIL.Image.FLIP_TOP_BOTTOM`, `PIL.Image.ROTATE_90`, `PIL.Image.ROTATE_180`, `PIL.Image.ROTATE_270`, or `PIL.Image.TRANSPOSE` :return: New image asset with rotated essence :rtype: Asset """ mime_type = MimeType(asset.mime_type) with PIL.Image.open(asset.essence) as image: transposed_image = image.transpose(rotation) with transposed_image: transposed_asset = self._image_to_asset(transposed_image, mime_type=mime_type) return transposed_asset @operator def transpose(self, asset: Asset) -> Asset: """ Creates a new image asset whose essence is the transpose of the specified asset's essence. :param asset: Image asset whose essence is to be transposed :type asset: Asset :return: New image asset with transposed essence :rtype: Asset """ return self._rotate(asset, PIL.Image.TRANSPOSE) @operator def flip(self, asset: Asset, orientation: FlipOrientation) -> Asset: """ Creates a new asset whose essence is flipped according the specified orientation. :param asset: Asset whose essence is to be flipped :type asset: Asset :param orientation: axis of the flip operation :type orientation: FlipOrientation :return: Asset with flipped essence :rtype: Asset """ if orientation == FlipOrientation.HORIZONTAL: flip_orientation = PIL.Image.FLIP_LEFT_RIGHT else: flip_orientation = PIL.Image.FLIP_TOP_BOTTOM return self._rotate(asset, flip_orientation) @operator def auto_orient(self, asset: Asset) -> Asset: """ Creates a new asset whose essence is rotated according to the Exif orientation. If no orientation metadata exists or asset is not rotated, an identical asset object is returned. :param asset: Asset with orientation metadata :type asset: Asset :return: Asset with rotated essence :rtype: Asset """ orientation = asset.metadata.get('exif', {}).get('orientation') if orientation is None or orientation == 1: return asset flip_horizontally: Callable[[Asset], Asset] = self.flip( orientation=FlipOrientation.HORIZONTAL) flip_vertically: Callable[[Asset], Asset] = self.flip( orientation=FlipOrientation.VERTICAL) if orientation == 2: oriented_asset = flip_horizontally(asset) elif orientation == 3: oriented_asset = self._rotate(asset, PIL.Image.ROTATE_180) elif orientation == 4: oriented_asset = flip_vertically(asset) elif orientation == 5: oriented_asset = flip_vertically( self._rotate(asset, PIL.Image.ROTATE_90)) elif orientation == 6: oriented_asset = self._rotate(asset, PIL.Image.ROTATE_270) elif orientation == 7: oriented_asset = flip_horizontally( self._rotate(asset, PIL.Image.ROTATE_90)) elif orientation == 8: oriented_asset = self._rotate(asset, PIL.Image.ROTATE_90) else: raise OperatorError( f'Unable to correct image orientation with value {orientation}' ) return oriented_asset @operator def convert(self, asset: Asset, mime_type: Union[MimeType, str], color_space: Optional[str] = None, depth: Optional[int] = None, data_type: Optional[str] = None) -> Asset: """ Creates a new asset of the specified MIME type from the essence of the specified asset. :param asset: Asset whose contents will be converted :type asset: Asset :param mime_type: Target MIME type :type mime_type: MimeType or str :param color_space: Name of color space :type color_space: str or None :param depth: Bit depth per channel :type depth: int or None :param data_type: Data type of the pixels, e.g. 'uint' or 'float' :type data_type: str or None :return: New asset with converted essence :rtype: Asset """ mime_type = MimeType(mime_type) try: with PIL.Image.open(asset.essence) as image: color_mode = color_space or asset.color_space, depth or asset.depth, data_type or asset.data_type pil_mode = PillowProcessor.__pillow_mode_to_color_mode.inv.get( color_mode) if pil_mode is not None and pil_mode != image.mode: image = image.convert(pil_mode) converted_asset = self._image_to_asset(image, mime_type) except (IOError, KeyError) as pil_error: raise OperatorError( f'Could not convert image to {mime_type}: {pil_error}') return converted_asset @operator def crop(self, asset: Asset, x: int, y: int, width: int, height: int) -> Asset: """ Creates a new asset whose essence is cropped to the specified rectangular area. :param asset: Asset whose contents will be cropped :type asset: Asset :param x: horizontal offset of the cropping area from left :type x: int :param y: vertical offset of the cropping area from top :type y: int :param width: width of the cropping area :type width: int :param height: height of the cropping area :type height: int :return: New asset with cropped essence :rtype: Asset """ if x == 0 and y == 0 and width == asset.width and height == asset.height: return asset max_x = max(0, min(asset.width, width + x)) max_y = max(0, min(asset.height, height + y)) min_x = max(0, min(asset.width, x)) min_y = max(0, min(asset.height, y)) if min_x == asset.width or min_y == asset.height or max_x <= min_x or max_y <= min_y: raise OperatorError( f'Invalid cropping area: <x={x!r}, y={y!r}, width={width!r}, height={height!r}>' ) with PIL.Image.open(asset.essence) as image: cropped_image = image.crop(box=(min_x, min_y, max_x, max_y)) with cropped_image: cropped_asset = self._image_to_asset(cropped_image, mime_type=asset.mime_type) return cropped_asset @operator def rotate(self, asset: Asset, angle: float, expand: bool = False) -> Asset: """ Creates an asset whose essence is rotated by the specified angle in degrees. :param asset: Asset whose contents will be rotated :type asset: Asset :param angle: Angle in degrees, counter clockwise :type angle: float :param expand: If true, changes the dimensions of the new asset so it can hold the entire rotated essence, otherwise the dimensions of the original asset will be used. :type expand: bool :return: New asset with rotated essence :rtype: Asset """ if angle % 360.0 == 0.0: return asset with PIL.Image.open(asset.essence) as image: rotated_image = image.rotate(angle=angle, resample=PIL.Image.BICUBIC, expand=expand) with rotated_image: rotated_asset = self._image_to_asset(rotated_image, mime_type=asset.mime_type) return rotated_asset
def test_mime_type_subtype_cannot_contain_a_delimiter(): with pytest.raises(ValueError): MimeType(mediatype='foo', subtype='bar/baz')