def test_assets_are_equal_when_essence_and_properties_are_identical(self, asset): asset.some_attr = 42 another_asset = Asset(asset.essence) another_asset.some_attr = 42 assert asset is not another_asset assert asset == another_asset
def test_empty_pipeline_does_not_change_assets(self, pipeline): some_asset = Asset(io.BytesIO(b'some')) another_asset = Asset(io.BytesIO(b'other')) processed_assets = pipeline.process(some_asset, another_asset) assert some_asset in processed_assets assert another_asset in processed_assets
def test_iterator_contains_all_stored_assets(self, storage): assets = ( Asset(io.BytesIO(b'0')), Asset(io.BytesIO(b'1')), Asset(io.BytesIO(b'2')), ) for asset in assets: asset_key = str(hash(asset)) storage[asset_key] = asset, set() iterator = iter(storage) assert len(list(iterator)) == 3
def test_asset_string_representation_does_not_contain_complex_metadata(self): asset = Asset(essence=io.BytesIO(), mime_type='application/x-empty', complex=dict(k1='v1', k2=42)) asset_repr = repr(asset) assert '{}={!r}'.format('mime_type', asset.mime_type) in asset_repr assert '{}={!r}'.format('complex', asset.complex) not in asset_repr
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)
def create_svg_asset(fragment=''): essence = io.BytesIO() essence.write(SVG_START.encode('utf-8')) essence.write(fragment.encode('utf-8')) essence.write(SVG_END.encode('utf-8')) essence.seek(0) return Asset(essence, mime_type='image/svg+xml')
def test_filter_by_tags_returns_assets_with_specified_tags(self, storage): assets = ( Asset(io.BytesIO(b'0')), Asset(io.BytesIO(b'1')), Asset(io.BytesIO(b'2')), ) asset_keys = tuple(str(hash(asset)) for asset in assets) storage[asset_keys[0]] = assets[0], {'foo'} storage[asset_keys[1]] = assets[1], {'foo', 'bar'} storage[asset_keys[2]] = assets[2], {'foo', 'bar'} tagged_asset_keys = storage.filter_by_tags('bar', 'foo') assert asset_keys[0] not in tagged_asset_keys and \ asset_keys[1] in tagged_asset_keys and \ asset_keys[2] in tagged_asset_keys
def test_asset_string_representation_contains_metadata(self): asset = Asset(essence=io.BytesIO(), mime_type='application/x-empty', magic=42) asset_repr = repr(asset) for key, val in asset.metadata.items(): assert f'{key}={val!r}' in asset_repr
def test_writes_correct_essence_without_metadata(madam, asset): asset = Asset(essence=asset.essence) file = io.BytesIO() madam.write(asset, file) file.seek(0) assert file.read() == asset.essence.read()
def test_asset_getattr_is_identical_to_access_through_metadata(self): asset_with_metadata = Asset(io.BytesIO(b'TestEssence'), SomeKey='SomeValue', AnotherKey=None, _42=43.0) for key, value in asset_with_metadata.metadata.items(): assert getattr(asset_with_metadata, key) == value
def test_iterator_is_a_readable_storage_snapshot(self, storage): assets = ( Asset(io.BytesIO(b'0')), Asset(io.BytesIO(b'1')), Asset(io.BytesIO(b'2')), Asset(io.BytesIO(b'3')), ) asset_keys = tuple(str(hash(asset)) for asset in assets) storage[asset_keys[0]] = assets[0], set() storage[asset_keys[1]] = assets[1], set() iterator = iter(storage) del storage[asset_keys[0]] storage[asset_keys[2]] = assets[2], set() storage[asset_keys[3]] = assets[3], set() assert set(iterator) == {asset_keys[0], asset_keys[1]}
def test_filter_returns_assets_with_specified_madam_metadata(self, storage): asset = Asset(io.BytesIO(b'TestEssence'), duration=1) asset_key = str(hash(asset)) storage[asset_key] = asset, set() asset_keys_with_1s_duration = storage.filter(duration=1) assert len(asset_keys_with_1s_duration) == 1 assert list(asset_keys_with_1s_duration)[0] == asset_key
def read(self, file): image = PIL.Image.open(file) metadata = dict( mime_type=self.__mime_type_to_pillow_type.inv[image.format], width=image.width, height=image.height) file.seek(0) asset = Asset(file, **metadata) return 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 read(self, file: IO) -> Asset: _, root = _parse_svg(file) metadata: Dict[str, Any] = dict(mime_type='image/svg+xml') if 'width' in root.keys(): metadata['width'] = svg_length_to_px(root.get('width')) if 'height' in root.keys(): metadata['height'] = svg_length_to_px(root.get('height')) file.seek(0) return Asset(essence=file, **metadata)
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 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 read(self, file): try: tree = ET.parse(file) except ET.ParseError as e: raise UnsupportedFormatError( 'Error while parsing XML in line %d, column %d' % e.position) root = tree.getroot() metadata = dict(mime_type='image/svg+xml') if 'width' in root.keys(): metadata['width'] = svg_length_to_px(root.get('width')) if 'height' in root.keys(): metadata['height'] = svg_length_to_px(root.get('height')) file.seek(0) return Asset(essence=file, **metadata)
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 read(self, file: IO) -> Asset: try: probe_data = _probe(file) except subprocess.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[str, Any] = dict(mime_type=str(mime_type), ) if 'duration' in probe_data['format']: metadata['duration'] = float(probe_data['format']['duration']) for stream in probe_data['streams']: stream_type = stream.get('codec_type') if stream_type in {'video', 'audio', 'subtitle'}: # 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 if 'pix_fmt' in stream: color_space, depth, data_type = FFmpegProcessor.__ffmpeg_pix_fmt_to_color_mode[ stream['pix_fmt']] metadata[stream_type]['color_space'] = color_space metadata[stream_type]['depth'] = depth metadata[stream_type]['data_type'] = data_type return Asset(essence=file, **metadata)
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 :param mime_type: Target MIME type :return: New asset with converted essence """ pil_format = self.__mime_type_to_pillow_type[mime_type] try: image = PIL.Image.open(asset.essence) converted_essence_data = io.BytesIO() image.save(converted_essence_data, pil_format) except (IOError, KeyError) as pil_error: raise OperatorError('Could not convert image to %r: %s' % (pil_format, pil_error)) converted_essence_data.seek(0) converted_asset = Asset(converted_essence_data, mime_type=mime_type) return converted_asset
def auto_orient(self, asset): """ Creates a new asset whose essence is rotated according to the Exif orientation. If no orientation metadata exists, an identical asset is returned. :param asset: Asset with orientation metadata :return: Asset with rotated essence """ orientation = asset.metadata.get('exif', {}).get('orientation') if orientation is None: return asset if orientation == 1: oriented_asset = Asset(asset.essence, metadata={}) elif orientation == 2: oriented_asset = self.flip( orientation=FlipOrientation.HORIZONTAL)(asset) elif orientation == 3: oriented_asset = self._rotate(asset, PIL.Image.ROTATE_180) elif orientation == 4: oriented_asset = self.flip( orientation=FlipOrientation.VERTICAL)(asset) elif orientation == 5: oriented_asset = self.flip(orientation=FlipOrientation.VERTICAL)( self._rotate(asset, PIL.Image.ROTATE_90)) elif orientation == 6: oriented_asset = self._rotate(asset, PIL.Image.ROTATE_270) elif orientation == 7: oriented_asset = self.flip(orientation=FlipOrientation.HORIZONTAL)( 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
def test_hash_is_different_when_assets_have_different_metadata(self): asset0 = Asset(io.BytesIO(b'same'), SomeMetadata=42) asset1 = Asset(io.BytesIO(b'same'), DifferentMetadata=43) assert hash(asset0) != hash(asset1)
def test_hash_is_equal_for_equal_assets(self): metadata = dict(SomeMetadata=42) asset0 = Asset(io.BytesIO(b'same'), **metadata) asset1 = Asset(io.BytesIO(b'same'), **metadata) assert hash(asset0) == hash(asset1)
def test_setattr_raises_when_attribute_is_a_metadata_attribute(self): asset_with_metadata = Asset(io.BytesIO(b''), SomeMetadata=42) with pytest.raises(NotImplementedError): asset_with_metadata.SomeMetadata = 43
def test_asset_string_representation_contains_class_name(self): asset = Asset(essence=io.BytesIO(), mime_type='application/x-empty') asset_repr = repr(asset) assert asset.__class__.__qualname__ in asset_repr
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 asset(): return Asset(io.BytesIO(b'TestEssence'))