Allow upload of videos > 1080p
Videos that are larger than 1920x1080 pixels are scaled down so that they fit that size. Care is taken to keep the width a multiple of 16 pixels and the height a multiple of 8.
This commit is contained in:
parent
c711a04e6c
commit
bd3f8d597a
@ -129,14 +129,99 @@ def _process_image(bucket: Bucket,
|
|||||||
src_file['status'] = 'complete'
|
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,
|
def _process_video(gcs,
|
||||||
file_id: ObjectId,
|
file_id: ObjectId,
|
||||||
local_file: tempfile._TemporaryFileWrapper,
|
local_file: tempfile._TemporaryFileWrapper,
|
||||||
src_file: dict):
|
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)
|
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
|
# Create variations
|
||||||
root, _ = os.path.splitext(src_file['file_path'])
|
root, _ = os.path.splitext(src_file['file_path'])
|
||||||
src_file['variations'] = []
|
src_file['variations'] = []
|
||||||
@ -149,8 +234,8 @@ def _process_video(gcs,
|
|||||||
file_path='{}-{}.{}'.format(root, v, v),
|
file_path='{}-{}.{}'.format(root, v, v),
|
||||||
size='',
|
size='',
|
||||||
duration=0,
|
duration=0,
|
||||||
width=0,
|
width=capped_video_width,
|
||||||
height=0,
|
height=capped_video_height,
|
||||||
length=0,
|
length=0,
|
||||||
md5='',
|
md5='',
|
||||||
)
|
)
|
||||||
@ -624,8 +709,9 @@ def stream_to_storage(project_id):
|
|||||||
project_oid)
|
project_oid)
|
||||||
raise wz_exceptions.BadRequest('Missing content type.')
|
raise wz_exceptions.BadRequest('Missing content type.')
|
||||||
|
|
||||||
if uploaded_file.content_type.startswith('image/'):
|
if uploaded_file.content_type.startswith('image/') or uploaded_file.content_type.startswith(
|
||||||
# We need to do local thumbnailing, so we have to write the stream
|
'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.
|
# both to Google Cloud Storage and to local storage.
|
||||||
local_file = tempfile.NamedTemporaryFile(
|
local_file = tempfile.NamedTemporaryFile(
|
||||||
dir=current_app.config['STORAGE_DIR'])
|
dir=current_app.config['STORAGE_DIR'])
|
||||||
|
@ -31,7 +31,10 @@ class Encoder:
|
|||||||
options = dict(notifications=current_app.config['ZENCODER_NOTIFICATIONS_URL'])
|
options = dict(notifications=current_app.config['ZENCODER_NOTIFICATIONS_URL'])
|
||||||
|
|
||||||
outputs = [{'format': v['format'],
|
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']]
|
for v in src_file['variations']]
|
||||||
r = current_app.encoding_service_client.job.create(file_input,
|
r = current_app.encoding_service_client.job.create(file_input,
|
||||||
outputs=outputs,
|
outputs=outputs,
|
||||||
|
@ -233,3 +233,50 @@ class FileMaxSizeTest(AbstractPillarTest):
|
|||||||
def create_test_file(self, file_size_bytes):
|
def create_test_file(self, file_size_bytes):
|
||||||
fileob = io.BytesIO(rsa.randnum.read_random_bits(file_size_bytes * 8))
|
fileob = io.BytesIO(rsa.randnum.read_random_bits(file_size_bytes * 8))
|
||||||
return fileob
|
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)
|
||||||
|
BIN
tests/test_api/video-tiny.mkv
Normal file
BIN
tests/test_api/video-tiny.mkv
Normal file
Binary file not shown.
Loading…
x
Reference in New Issue
Block a user