diff --git a/pillar/api/file_storage/__init__.py b/pillar/api/file_storage/__init__.py index a311e31a..066e67ec 100644 --- a/pillar/api/file_storage/__init__.py +++ b/pillar/api/file_storage/__init__.py @@ -129,14 +129,99 @@ def _process_image(bucket: Bucket, src_file['status'] = 'complete' +def _video_size_pixels(filename: pathlib.Path) -> typing.Tuple[int, int]: + """Figures out the size (in pixels) of the video file. + + Returns (0, 0) if there was any error detecting the size. + """ + + import json + import subprocess + + cli_args = [ + current_app.config['BIN_FFPROBE'], + '-loglevel', 'error', + '-hide_banner', + '-print_format', 'json', + '-select_streams', 'v:0', # we only care about the first video stream + '-show_streams', + str(filename), + ] + + if log.isEnabledFor(logging.INFO): + import shlex + cmd = ' '.join(shlex.quote(s) for s in cli_args) + log.info('Calling %s', cmd) + + ffprobe = subprocess.run( + cli_args, + stdin=subprocess.DEVNULL, + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, + timeout=10, # seconds + ) + + if ffprobe.returncode: + import shlex + cmd = ' '.join(shlex.quote(s) for s in cli_args) + log.error('Error running %s: stopped with return code %i', + cmd, ffprobe.returncode) + log.error('Output was: %s', ffprobe.stdout) + return 0, 0 + + try: + ffprobe_info = json.loads(ffprobe.stdout) + except json.JSONDecodeError: + log.exception('ffprobe produced invalid JSON: %s', ffprobe.stdout) + return 0, 0 + + try: + stream_info = ffprobe_info['streams'][0] + return stream_info['width'], stream_info['height'] + except (KeyError, IndexError): + log.exception('ffprobe produced unexpected JSON: %s', ffprobe.stdout) + return 0, 0 + + +def _video_cap_at_1080(width: int, height: int) -> typing.Tuple[int, int]: + """Returns an appropriate width/height for a video capped at 1920x1080. + + Takes into account that h264 has limitations: + - the width must be a multiple of 16 + - the height must be a multiple of 8 + """ + + if width > 1920: + # The height must be a multiple of 8 + new_height = height / width * 1920 + height = new_height - (new_height % 8) + width = 1920 + + if height > 1080: + # The width must be a multiple of 16 + new_width = width / height * 1080 + width = new_width - (new_width % 16) + height = 1080 + + return int(width), int(height) + + def _process_video(gcs, file_id: ObjectId, local_file: tempfile._TemporaryFileWrapper, src_file: dict): - """Video is processed by Zencoder; the file isn't even stored locally.""" + """Video is processed by Zencoder.""" log.info('Processing video for file %s', file_id) + # Use ffprobe to find the size (in pixels) of the video. + # Even though Zencoder can do resizing to a maximum resolution without upscaling, + # by determining the video size here we already have this information in the file + # document before Zencoder calls our notification URL. It also opens up possibilities + # for other encoding backends that don't support this functionality. + video_width, video_height = _video_size_pixels(pathlib.Path(local_file.name)) + capped_video_width, capped_video_height = _video_cap_at_1080(video_width, video_height) + # Create variations root, _ = os.path.splitext(src_file['file_path']) src_file['variations'] = [] @@ -149,8 +234,8 @@ def _process_video(gcs, file_path='{}-{}.{}'.format(root, v, v), size='', duration=0, - width=0, - height=0, + width=capped_video_width, + height=capped_video_height, length=0, md5='', ) @@ -624,8 +709,9 @@ def stream_to_storage(project_id): project_oid) raise wz_exceptions.BadRequest('Missing content type.') - if uploaded_file.content_type.startswith('image/'): - # We need to do local thumbnailing, so we have to write the stream + if uploaded_file.content_type.startswith('image/') or uploaded_file.content_type.startswith( + 'video/'): + # We need to do local thumbnailing and ffprobe, so we have to write the stream # both to Google Cloud Storage and to local storage. local_file = tempfile.NamedTemporaryFile( dir=current_app.config['STORAGE_DIR']) diff --git a/pillar/api/utils/encoding.py b/pillar/api/utils/encoding.py index d36b54db..31817812 100644 --- a/pillar/api/utils/encoding.py +++ b/pillar/api/utils/encoding.py @@ -31,7 +31,10 @@ class Encoder: options = dict(notifications=current_app.config['ZENCODER_NOTIFICATIONS_URL']) outputs = [{'format': v['format'], - 'url': os.path.join(storage_base, v['file_path'])} + 'url': os.path.join(storage_base, v['file_path']), + 'upscale': False, + 'size': '{width}x{height}'.format(**v), + } for v in src_file['variations']] r = current_app.encoding_service_client.job.create(file_input, outputs=outputs, diff --git a/tests/test_api/test_file_storage.py b/tests/test_api/test_file_storage.py index 74a48fc3..821dc014 100644 --- a/tests/test_api/test_file_storage.py +++ b/tests/test_api/test_file_storage.py @@ -233,3 +233,50 @@ class FileMaxSizeTest(AbstractPillarTest): def create_test_file(self, file_size_bytes): fileob = io.BytesIO(rsa.randnum.read_random_bits(file_size_bytes * 8)) return fileob + + +class VideoSizeTest(AbstractPillarTest): + def test_video_size(self): + from pillar.api import file_storage + from pathlib import Path + + fname = Path(__file__).with_name('video-tiny.mkv') + + with self.app.test_request_context(): + size = file_storage._video_size_pixels(fname) + + self.assertEqual((960, 540), size) + + def test_video_size_nonexistant(self): + from pillar.api import file_storage + from pathlib import Path + + fname = Path(__file__).with_name('video-nonexistant.mkv') + + with self.app.test_request_context(): + size = file_storage._video_size_pixels(fname) + + self.assertEqual((0, 0), size) + + def test_video_cap_at_1080(self): + from pillar.api import file_storage + + # Up to 1920x1080, the input should be returned as-is. + self.assertEqual((0, 0), file_storage._video_cap_at_1080(0, 0)) + self.assertEqual((1, 1), file_storage._video_cap_at_1080(1, 1)) + self.assertEqual((960, 540), file_storage._video_cap_at_1080(960, 540)) + self.assertEqual((1920, 540), file_storage._video_cap_at_1080(1920, 540)) + + # The height must be multiple of 8 + self.assertEqual((1920, 784), file_storage._video_cap_at_1080(2048, 840)) + + # The width must be multiple of 16 + self.assertEqual((1024, 1080), file_storage._video_cap_at_1080(1920, 2000)) + + # Resizing the height based on the width will still produce a too high video, + # so this one hits both resize branches in one call: + self.assertEqual((1104, 1080), file_storage._video_cap_at_1080(2048, 2000)) + + size = file_storage._video_cap_at_1080(2048, 2000) + self.assertIsInstance(size[0], int) + self.assertIsInstance(size[1], int) diff --git a/tests/test_api/video-tiny.mkv b/tests/test_api/video-tiny.mkv new file mode 100644 index 00000000..93061272 Binary files /dev/null and b/tests/test_api/video-tiny.mkv differ