Video Duration: The duration of a video is now shown on thumbnails and bellow the video player
Asset nodes now have a new field called "properties.duration_seconds". This holds a copy of the duration stored on the referenced video file and stays in sync using eve hooks. To migrate existing duration times from files to nodes you need to run the following: ./manage.py maintenance reconcile_node_video_duration -ag There are 2 more maintenance commands to be used to determine if there are any missing durations in either files or nodes: find_video_files_without_duration find_video_nodes_without_duration FFProbe is now used to detect what duration a video file has. Reviewed by Sybren.
This commit is contained in:
@@ -130,6 +130,67 @@ def _process_image(bucket: Bucket,
|
||||
src_file['status'] = 'complete'
|
||||
|
||||
|
||||
def _video_duration_seconds(filename: pathlib.Path) -> typing.Optional[int]:
|
||||
"""Get the duration of a video file using ffprobe
|
||||
https://superuser.com/questions/650291/how-to-get-video-duration-in-seconds
|
||||
|
||||
:param filename: file path to video
|
||||
:return: video duration in seconds
|
||||
"""
|
||||
import subprocess
|
||||
|
||||
def run(cli_args):
|
||||
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 None
|
||||
|
||||
try:
|
||||
return int(float(ffprobe.stdout))
|
||||
except ValueError as e:
|
||||
log.exception('ffprobe produced invalid number: %s', ffprobe.stdout)
|
||||
return None
|
||||
|
||||
ffprobe_from_container_args = [
|
||||
current_app.config['BIN_FFPROBE'],
|
||||
'-v', 'error',
|
||||
'-show_entries', 'format=duration',
|
||||
'-of', 'default=noprint_wrappers=1:nokey=1',
|
||||
str(filename),
|
||||
]
|
||||
|
||||
ffprobe_from_stream_args = [
|
||||
current_app.config['BIN_FFPROBE'],
|
||||
'-v', 'error',
|
||||
'-hide_banner',
|
||||
'-select_streams', 'v:0', # we only care about the first video stream
|
||||
'-show_entries', 'stream=duration',
|
||||
'-of', 'default=noprint_wrappers=1:nokey=1',
|
||||
str(filename),
|
||||
]
|
||||
|
||||
duration = run(ffprobe_from_stream_args) or\
|
||||
run(ffprobe_from_container_args) or\
|
||||
None
|
||||
return duration
|
||||
|
||||
|
||||
def _video_size_pixels(filename: pathlib.Path) -> typing.Tuple[int, int]:
|
||||
"""Figures out the size (in pixels) of the video file.
|
||||
|
||||
@@ -220,8 +281,10 @@ def _process_video(gcs,
|
||||
# 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))
|
||||
video_path = pathlib.Path(local_file.name)
|
||||
video_width, video_height = _video_size_pixels(video_path)
|
||||
capped_video_width, capped_video_height = _video_cap_at_1080(video_width, video_height)
|
||||
video_duration = _video_duration_seconds(video_path)
|
||||
|
||||
# Create variations
|
||||
root, _ = os.path.splitext(src_file['file_path'])
|
||||
@@ -234,12 +297,13 @@ def _process_video(gcs,
|
||||
content_type='video/{}'.format(v),
|
||||
file_path='{}-{}.{}'.format(root, v, v),
|
||||
size='',
|
||||
duration=0,
|
||||
width=capped_video_width,
|
||||
height=capped_video_height,
|
||||
length=0,
|
||||
md5='',
|
||||
)
|
||||
if video_duration:
|
||||
file_variation['duration'] = video_duration
|
||||
# Append file variation. Originally mp4 and webm were the available options,
|
||||
# that's why we build a list.
|
||||
src_file['variations'].append(file_variation)
|
||||
|
@@ -70,6 +70,7 @@ def latest_assets():
|
||||
{'name': 1, 'node_type': 1,
|
||||
'parent': 1, 'picture': 1, 'properties.status': 1,
|
||||
'properties.content_type': 1,
|
||||
'properties.duration_seconds': 1,
|
||||
'permissions.world': 1},
|
||||
12)
|
||||
|
||||
|
@@ -24,6 +24,10 @@ node_type_asset = {
|
||||
'content_type': {
|
||||
'type': 'string'
|
||||
},
|
||||
# The duration of a video asset in seconds.
|
||||
'duration_seconds': {
|
||||
'type': 'integer'
|
||||
},
|
||||
# We point to the original file (and use it to extract any relevant
|
||||
# variation useful for our scope).
|
||||
'file': _file_embedded_schema,
|
||||
@@ -58,6 +62,7 @@ node_type_asset = {
|
||||
},
|
||||
'form_schema': {
|
||||
'content_type': {'visible': False},
|
||||
'duration_seconds': {'visible': False},
|
||||
'order': {'visible': False},
|
||||
'tags': {'visible': False},
|
||||
'categories': {'visible': False},
|
||||
|
@@ -66,8 +66,8 @@ def tagged(tag=''):
|
||||
agg_list = _tagged(tag)
|
||||
|
||||
for node in agg_list:
|
||||
if node.get('video_duration_seconds'):
|
||||
node['video_duration'] = datetime.timedelta(seconds=node['video_duration_seconds'])
|
||||
if node['properties'].get('duration_seconds'):
|
||||
node['properties']['duration'] = datetime.timedelta(seconds=node['properties']['duration_seconds'])
|
||||
|
||||
if node.get('_created') is not None:
|
||||
node['pretty_created'] = pretty_date(node['_created'])
|
||||
@@ -108,26 +108,16 @@ def _tagged(tag: str):
|
||||
'foreignField': '_id',
|
||||
'as': '_project',
|
||||
}},
|
||||
{'$lookup': {
|
||||
'from': 'files',
|
||||
'localField': 'properties.file',
|
||||
'foreignField': '_id',
|
||||
'as': '_file',
|
||||
}},
|
||||
{'$unwind': '$_file'},
|
||||
{'$unwind': '$_project'},
|
||||
{'$match': {'_project.is_private': False}},
|
||||
{'$addFields': {
|
||||
'project._id': '$_project._id',
|
||||
'project.name': '$_project.name',
|
||||
'project.url': '$_project.url',
|
||||
'video_duration_seconds': {'$arrayElemAt': ['$_file.variations.duration', 0]},
|
||||
}},
|
||||
|
||||
# Don't return the entire project/file for each node.
|
||||
{'$project': {'_project': False,
|
||||
'_file': False}
|
||||
},
|
||||
{'$project': {'_project': False}},
|
||||
{'$sort': {'_created': -1}}
|
||||
])
|
||||
|
||||
@@ -224,13 +214,13 @@ def setup_app(app, url_prefix):
|
||||
app.on_replace_nodes += eve_hooks.before_replacing_node
|
||||
app.on_replace_nodes += eve_hooks.parse_markdown
|
||||
app.on_replace_nodes += eve_hooks.texture_sort_files
|
||||
app.on_replace_nodes += eve_hooks.deduct_content_type
|
||||
app.on_replace_nodes += eve_hooks.deduct_content_type_and_duration
|
||||
app.on_replace_nodes += eve_hooks.node_set_default_picture
|
||||
app.on_replaced_nodes += eve_hooks.after_replacing_node
|
||||
|
||||
app.on_insert_nodes += eve_hooks.before_inserting_nodes
|
||||
app.on_insert_nodes += eve_hooks.parse_markdowns
|
||||
app.on_insert_nodes += eve_hooks.nodes_deduct_content_type
|
||||
app.on_insert_nodes += eve_hooks.nodes_deduct_content_type_and_duration
|
||||
app.on_insert_nodes += eve_hooks.nodes_set_default_picture
|
||||
app.on_insert_nodes += eve_hooks.textures_sort_files
|
||||
app.on_inserted_nodes += eve_hooks.after_inserting_nodes
|
||||
|
@@ -165,7 +165,7 @@ def after_inserting_nodes(items):
|
||||
)
|
||||
|
||||
|
||||
def deduct_content_type(node_doc, original=None):
|
||||
def deduct_content_type_and_duration(node_doc, original=None):
|
||||
"""Deduct the content type from the attached file, if any."""
|
||||
|
||||
if node_doc['node_type'] != 'asset':
|
||||
@@ -184,7 +184,8 @@ def deduct_content_type(node_doc, original=None):
|
||||
|
||||
files = current_app.data.driver.db['files']
|
||||
file_doc = files.find_one({'_id': file_id},
|
||||
{'content_type': 1})
|
||||
{'content_type': 1,
|
||||
'variations': 1})
|
||||
if not file_doc:
|
||||
log.warning('deduct_content_type: Node %s refers to non-existing file %s, rejecting.',
|
||||
node_id, file_id)
|
||||
@@ -201,10 +202,17 @@ def deduct_content_type(node_doc, original=None):
|
||||
|
||||
node_doc['properties']['content_type'] = content_type
|
||||
|
||||
if content_type == 'video':
|
||||
duration = file_doc['variations'][0].get('duration')
|
||||
if duration:
|
||||
node_doc['properties']['duration_seconds'] = duration
|
||||
else:
|
||||
log.warning('Video file %s has no duration', file_id)
|
||||
|
||||
def nodes_deduct_content_type(nodes):
|
||||
|
||||
def nodes_deduct_content_type_and_duration(nodes):
|
||||
for node in nodes:
|
||||
deduct_content_type(node)
|
||||
deduct_content_type_and_duration(node)
|
||||
|
||||
|
||||
def node_set_default_picture(node, original=None):
|
||||
|
@@ -57,6 +57,18 @@ def remove_private_keys(document):
|
||||
return doc_copy
|
||||
|
||||
|
||||
def pretty_duration(seconds):
|
||||
if seconds is None:
|
||||
return ''
|
||||
seconds = round(seconds)
|
||||
hours, seconds = divmod(seconds, 3600)
|
||||
minutes, seconds = divmod(seconds, 60)
|
||||
if hours > 0:
|
||||
return f'{hours:02}:{minutes:02}:{seconds:02}'
|
||||
else:
|
||||
return f'{minutes:02}:{seconds:02}'
|
||||
|
||||
|
||||
class PillarJSONEncoder(json.JSONEncoder):
|
||||
"""JSON encoder with support for Pillar resources."""
|
||||
|
||||
@@ -65,12 +77,7 @@ class PillarJSONEncoder(json.JSONEncoder):
|
||||
return obj.strftime(RFC1123_DATE_FORMAT)
|
||||
|
||||
if isinstance(obj, datetime.timedelta):
|
||||
hours, seconds = divmod(obj.seconds, 3600)
|
||||
minutes, seconds = divmod(seconds, 60)
|
||||
if hours > 0:
|
||||
return f'{hours:02}:{minutes:02}:{seconds:02}'
|
||||
else:
|
||||
return f'{minutes:02}:{seconds:02}'
|
||||
return pretty_duration(obj.total_seconds())
|
||||
|
||||
if isinstance(obj, bson.ObjectId):
|
||||
return str(obj)
|
||||
|
Reference in New Issue
Block a user