Skip to content

Commit

Permalink
[youtube] add storyboards meta field with list and write options
Browse files Browse the repository at this point in the history
Storyboards are grids of small images that appear when
the user hovers their cursor over a video's timeline.
See related issue ytdl-org#9868.

Options added:
  * --list-storyboards
  * --write-storyboards

Co-authored by @benob (See: #1)
  • Loading branch information
MarcAbonce committed Mar 1, 2021
1 parent 0a04e03 commit ec8cc20
Show file tree
Hide file tree
Showing 5 changed files with 112 additions and 16 deletions.
58 changes: 42 additions & 16 deletions youtube_dl/YoutubeDL.py
Original file line number Diff line number Diff line change
Expand Up @@ -182,6 +182,7 @@ class YoutubeDL(object):
writeannotations: Write the video annotations to a .annotations.xml file
writethumbnail: Write the thumbnail image to a file
write_all_thumbnails: Write all thumbnail formats to files
writestoryboards: Write all storyboards (grid of video frames) to a file
writesubtitles: Write the video subtitles to a file
writeautomaticsub: Write the automatically generated subtitles to a file
allsubtitles: Downloads all the subtitles of the video
Expand Down Expand Up @@ -278,6 +279,7 @@ class YoutubeDL(object):
[sleep_interval; max_sleep_interval].
listformats: Print an overview of available video formats and exit.
list_thumbnails: Print a table of all thumbnails and exit.
list_storyboards: Print a table of all storyboards and exit.
match_filter: A function that gets called with the info_dict of
every video.
If it returns a message, the video is ignored.
Expand Down Expand Up @@ -1502,6 +1504,10 @@ def sanitize_numeric_fields(info):
self.list_thumbnails(info_dict)
return

if self.params.get('list_storyboards'):
self.list_thumbnails(info_dict, item_name='storyboards')
return

thumbnail = info_dict.get('thumbnail')
if thumbnail:
info_dict['thumbnail'] = sanitize_url(thumbnail)
Expand Down Expand Up @@ -2245,17 +2251,27 @@ def list_formats(self, info_dict):
'[info] Available formats for %s:\n%s' %
(info_dict['id'], render_table(header_line, table)))

def list_thumbnails(self, info_dict):
thumbnails = info_dict.get('thumbnails')
def list_thumbnails(self, info_dict, item_name='thumbnails'):
thumbnails = info_dict.get(item_name)
if not thumbnails:
self.to_screen('[info] No thumbnails present for %s' % info_dict['id'])
self.to_screen('[info] No %s present for %s' % (item_name, info_dict['id']))
return

self.to_screen(
'[info] Thumbnails for %s:' % info_dict['id'])
self.to_screen(render_table(
['ID', 'width', 'height', 'URL'],
[[t['id'], t.get('width', 'unknown'), t.get('height', 'unknown'), t['url']] for t in thumbnails]))
'[info] %s for %s:' % (item_name.title(), info_dict['id']))

columns = ['ID', 'width', 'height']
if item_name == 'storyboards':
columns += ['cols', 'rows', 'frames']
columns += ['URL']

table = []
for t in thumbnails:
table.append([])
for column in columns:
table[-1].append(t.get(column.lower(), 'unknown'))

self.to_screen(render_table(columns, table))

def list_subtitles(self, video_id, subtitles, name='subtitles'):
if not subtitles:
Expand Down Expand Up @@ -2420,12 +2436,16 @@ def get_encoding(self):
return encoding

def _write_thumbnails(self, info_dict, filename):
item_name = 'thumbnail'
if self.params.get('writethumbnail', False):
thumbnails = info_dict.get('thumbnails')
if thumbnails:
thumbnails = [thumbnails[-1]]
elif self.params.get('write_all_thumbnails', False):
thumbnails = info_dict.get('thumbnails')
elif self.params.get('writestoryboards', False):
thumbnails = info_dict.get('storyboards')
item_name = 'storyboard'
else:
return

Expand All @@ -2435,22 +2455,28 @@ def _write_thumbnails(self, info_dict, filename):

for t in thumbnails:
thumb_ext = determine_ext(t['url'], 'jpg')
suffix = '_%s' % t['id'] if len(thumbnails) > 1 else ''
if item_name == 'thumbnails':
suffix = '_%s' % t['id'] if len(thumbnails) > 1 else ''
else:
suffix = '_%s_%s' % (item_name, t['id'])
thumb_display_id = '%s ' % t['id'] if len(thumbnails) > 1 else ''
t['filename'] = thumb_filename = replace_extension(filename + suffix, thumb_ext, info_dict.get('ext'))

if self.params.get('nooverwrites', False) and os.path.exists(encodeFilename(thumb_filename)):
self.to_screen('[%s] %s: Thumbnail %sis already present' %
(info_dict['extractor'], info_dict['id'], thumb_display_id))
self.to_screen('[%s] %s: %s %sis already present' %
(info_dict['extractor'], info_dict['id'],
item_name.title(), thumb_display_id))
else:
self.to_screen('[%s] %s: Downloading thumbnail %s...' %
(info_dict['extractor'], info_dict['id'], thumb_display_id))
self.to_screen('[%s] %s: Downloading %s %s...' %
(info_dict['extractor'], info_dict['id'],
item_name, thumb_display_id))
try:
uf = self.urlopen(t['url'])
with open(encodeFilename(thumb_filename), 'wb') as thumbf:
shutil.copyfileobj(uf, thumbf)
self.to_screen('[%s] %s: Writing thumbnail %sto: %s' %
(info_dict['extractor'], info_dict['id'], thumb_display_id, thumb_filename))
self.to_screen('[%s] %s: Writing %s %sto: %s' %
(info_dict['extractor'], info_dict['id'],
item_name, thumb_display_id, thumb_filename))
except (compat_urllib_error.URLError, compat_http_client.HTTPException, socket.error) as err:
self.report_warning('Unable to download thumbnail "%s": %s' %
(t['url'], error_to_compat_str(err)))
self.report_warning('Unable to download %s "%s": %s' %
(t['url'], item_name, error_to_compat_str(err)))
2 changes: 2 additions & 0 deletions youtube_dl/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -372,6 +372,7 @@ def parse_retries(retries):
'writeinfojson': opts.writeinfojson,
'writethumbnail': opts.writethumbnail,
'write_all_thumbnails': opts.write_all_thumbnails,
'writestoryboards': opts.writestoryboards,
'writesubtitles': opts.writesubtitles,
'writeautomaticsub': opts.writeautomaticsub,
'allsubtitles': opts.allsubtitles,
Expand Down Expand Up @@ -419,6 +420,7 @@ def parse_retries(retries):
'max_sleep_interval': opts.max_sleep_interval,
'external_downloader': opts.external_downloader,
'list_thumbnails': opts.list_thumbnails,
'list_storyboards': opts.list_storyboards,
'playlist_items': opts.playlist_items,
'xattr_set_filesize': opts.xattr_set_filesize,
'match_filter': match_filter,
Expand Down
6 changes: 6 additions & 0 deletions youtube_dl/extractor/common.py
Original file line number Diff line number Diff line change
Expand Up @@ -226,6 +226,12 @@ class InfoExtractor(object):
deprecated)
* "filesize" (optional, int)
thumbnail: Full URL to a video thumbnail image.
storyboards: A list of dictionaries representing storyboards.
A storyboard is an image grid made of frames from the video.
This has the same structure as the thumbnails list, plus:
* "cols" (optional, int)
* "rows" (optional, int)
* "frames" (optional, int)
description: Full video description.
uploader: Full name of the video uploader.
license: License name the video is licensed under.
Expand Down
54 changes: 54 additions & 0 deletions youtube_dl/extractor/youtube.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
import random
import re
import traceback
import math

from .common import InfoExtractor, SearchInfoExtractor
from ..compat import (
Expand Down Expand Up @@ -1694,6 +1695,58 @@ def feed_entry(name):
if thumbnail:
thumbnails = [{'url': thumbnail}]

storyboards = []
sb_spec = try_get(player_response,
lambda x: x['storyboards']['playerStoryboardSpecRenderer']['spec'],
compat_str)
if sb_spec:
s_parts = sb_spec.split('|')
base_url = s_parts[0]
for i, params in enumerate(s_parts[1:]):
storyboard_attrib = params.split('#')
if len(storyboard_attrib) != 8:
self._downloader.report_warning('Unable to extract storyboard')
continue

frame_width = int_or_none(storyboard_attrib[0])
frame_height = int_or_none(storyboard_attrib[1])
total_frames = int_or_none(storyboard_attrib[2])
cols = int_or_none(storyboard_attrib[3])
rows = int_or_none(storyboard_attrib[4])
filename = storyboard_attrib[6]
sigh = storyboard_attrib[7]

if frame_width and frame_height and cols and rows and total_frames:
frames = cols * rows
width, height = frame_width * cols, frame_height * rows
n_images = int(math.ceil(total_frames / float(cols * rows)))
else:
self._downloader.report_warning('Unable to extract storyboard')
continue

storyboards_url = base_url.replace('$L', compat_str(i)) + '&'
for j in range(n_images):
url = storyboards_url.replace('$N', filename).replace('$M', compat_str(j)) + 'sigh=' + sigh
if j == n_images - 1:
remaining_frames = total_frames % (cols * rows)
if remaining_frames != 0:
frames = remaining_frames
rows = int(math.ceil(float(remaining_frames) / rows))
height = rows * frame_height
if rows == 1:
cols = remaining_frames
width = cols * frame_width

storyboards.append({
'id': 'L{0}-M{1}'.format(i, j),
'width': width,
'height': height,
'cols': cols,
'rows': rows,
'frames': frames,
'url': url
})

category = microformat.get('category') or search_meta('genre')
channel_id = video_details.get('channelId') \
or microformat.get('externalChannelId') \
Expand Down Expand Up @@ -1733,6 +1786,7 @@ def feed_entry(name):
'categories': [category] if category else None,
'tags': keywords,
'is_live': is_live,
'storyboards': storyboards,
}

pctr = try_get(
Expand Down
8 changes: 8 additions & 0 deletions youtube_dl/options.py
Original file line number Diff line number Diff line change
Expand Up @@ -781,6 +781,14 @@ def _comma_separated_values_options_callback(option, opt_str, value, parser):
'--list-thumbnails',
action='store_true', dest='list_thumbnails', default=False,
help='Simulate and list all available thumbnail formats')
thumbnail.add_option(
'--write-storyboards',
action='store_true', dest='writestoryboards', default=False,
help='Write all storyboards (grid of video frames) to disk')
thumbnail.add_option(
'--list-storyboards',
action='store_true', dest='list_storyboards', default=False,
help='Simulate and list all available storyboards')

postproc = optparse.OptionGroup(parser, 'Post-processing Options')
postproc.add_option(
Expand Down

0 comments on commit ec8cc20

Please sign in to comment.