From 3e0bfc3a282b5357933b9e3db8b31b49d9b6d66a Mon Sep 17 00:00:00 2001 From: rosborne132 Date: Wed, 25 Jan 2023 06:52:47 -0800 Subject: [PATCH] remove hls adapter related files Signed-off-by: rosborne132 --- .../contrib_adapters.plugin_manifest.json | 6 - .../adapters/hls_playlist.py | 1751 ----------------- .../tests/sample_data/v1_prog_index.m3u8 | 158 -- .../tests/test_hls_playlist_adapter.py | 701 ------- docs/tutorials/adapters.md | 4 - docs/tutorials/otio-plugins.md | 119 -- 6 files changed, 2739 deletions(-) delete mode 100644 contrib/opentimelineio_contrib/adapters/hls_playlist.py delete mode 100644 contrib/opentimelineio_contrib/adapters/tests/sample_data/v1_prog_index.m3u8 delete mode 100644 contrib/opentimelineio_contrib/adapters/tests/test_hls_playlist_adapter.py diff --git a/contrib/opentimelineio_contrib/adapters/contrib_adapters.plugin_manifest.json b/contrib/opentimelineio_contrib/adapters/contrib_adapters.plugin_manifest.json index 58bb97719..21a9a6ae5 100644 --- a/contrib/opentimelineio_contrib/adapters/contrib_adapters.plugin_manifest.json +++ b/contrib/opentimelineio_contrib/adapters/contrib_adapters.plugin_manifest.json @@ -1,12 +1,6 @@ { "OTIO_SCHEMA" : "PluginManifest.1", "adapters": [ - { - "OTIO_SCHEMA": "Adapter.1", - "name": "hls_playlist", - "filepath": "hls_playlist.py", - "suffixes": ["m3u8"] - }, { "OTIO_SCHEMA" : "Adapter.1", "name" : "ale", diff --git a/contrib/opentimelineio_contrib/adapters/hls_playlist.py b/contrib/opentimelineio_contrib/adapters/hls_playlist.py deleted file mode 100644 index 6f55b1a87..000000000 --- a/contrib/opentimelineio_contrib/adapters/hls_playlist.py +++ /dev/null @@ -1,1751 +0,0 @@ -# SPDX-License-Identifier: Apache-2.0 -# Copyright Contributors to the OpenTimelineIO project - -"""HLS Playlist OpenTimelineIO adapter - -This adapter supports authoring of HLS playlists within OpenTimelineIO by using -clips to represent media fragments. - -Status: - - Export of Media Playlists well supported - - Export of Master Playlists supported - - Import of Media Playlists well supported - - Import of Master Playlists unsupported - - Explicit Variant Stream controls in Master Playlists unsupported - -In general, you can author otio as follows: - t = otio.schema.Timeline() - track = otio.schema.Track("v1") - track.metadata['HLS'] = { - "EXT-X-INDEPENDENT-SEGMENTS": None, - "EXT-X-PLAYLIST-TYPE": "VOD" - } - t.tracks.append(track) - - # Make a prototype media ref with the fragment's initialization metadata - fragmented_media_ref = otio.schema.ExternalReference( - target_url='video1.mp4', - metadata={ - "streaming": { - "init_byterange": { - "byte_count": 729, - "byte_offset": 0 - }, - "init_uri": "media-video-1.mp4" - } - } - ) - - # Make a copy of the media ref specifying the byte range for the fragment - media_ref1 = fragmented_media_ref.deepcopy() - media_ref1.available_range=otio.opentime.TimeRange( - otio.opentime.RationalTime(0, 1), - otio.opentime.RationalTime(2.002, 1) - ) - media_ref1.metadata['streaming'].update( - { - "byte_count": 534220, - "byte_offset": 1361 - } - ) - - # make the fragment and append it - fragment1 = otio.schema.Clip(media_reference=media_ref1) - track.append(fragment1) - - # (repeat to define each fragment) - -The code above would yield an HLS playlist like: - #EXTM3U - #EXT-X-VERSION:7 - #EXT-X-TARGETDURATION:2 - #EXT-X-PLAYLIST-TYPE:VOD - #EXT-X-INDEPENDENT-SEGMENTS - #EXT-X-MEDIA-SEQUENCE:1 - #EXT-X-MAP:BYTERANGE="729@0",URI="media-video-1.mp4" - #EXTINF:2.00200, - #EXT-X-BYTERANGE:534220@1361 - video1.mp4 - #EXT-X-ENDLIST - -If you add min_segment_duration and max_segment_duration to the timeline's -metadata dictionary as RationalTime objects, you can control the rule set -deciding how many fragments to accumulate into a single segment. When nothing -is specified for these metadata keys, the adapter will create one segment per -fragment. - -In general, any metadata added to the track metadata dict under the HLS -namespace will be included at the top level of the exported playlist (see -``EXT-X-INDEPENDENT-SEGMENTS`` and ``EXT-X-PLAYLIST-TYPE`` in the example -above). Each segment will pass through any metadata in the HLS namespace from -the media_reference. - -If you write a Timeline with more than one track specified, then the adapter -will create an HLS master playlist. - -The following track metadata keys will be used to inform exported master -playlist metadata per variant stream: - bandwidth - codec - language - mimeType - group_id (audio) - autoselect (audio) - default (audio) -These values are translated to EXT-X-STREAM-INF and EXT-X-MEDIA -attributes as defined in sections 4.3.4.2 and 4.3.4.1 of -draft-pantos-http-live-streaming, respectively. -""" - -import re -import copy - -import opentimelineio as otio - -# TODO: determine output version based on features used -OUTPUT_PLAYLIST_VERSION = "7" - -# TODO: make sure all strings get sanitized through encoding and decoding -PLAYLIST_STRING_ENCODING = "utf-8" - -""" -Matches a single key/value pair from an HLS Attribute List. -See section 4.2 of draft-pantos-http-live-streaming for more detail. -""" -ATTRIBUTE_RE = re.compile( - r'(?P[A-Z0-9-]+)' + r'\=' + - r'(?P(?:\"[^\r\n"]*\")|[^,]+)' + r',?' -) - -""" -Matches AttributeValue of the above regex into appropriate data types. -Note that these are meant to be joined using regex "or" in this order. -""" -_ATTRIBUTE_RE_VALUE_STR_LIST = [ - r'(?P(?P[0-9]+)x(?P[0-9]+))\Z', - r'(?P0[xX](?P[0-9A-F]+))\Z', - r'(?P-?[0-9]+\.[0-9]+)\Z', - r'(?P[0-9]+)\Z', - r'(?P\"(?P[^\r\n"]*)\")\Z', - r'(?P[^",\s]+)\Z' -] -ATTRIBUTE_VALUE_RE = re.compile("|".join(_ATTRIBUTE_RE_VALUE_STR_LIST)) - -""" -Matches a byterange as used in various contexts. -See section 4.3.2.2 of draft-pantos-http-live-streaming for an example use of -this byterange form. -""" -BYTERANGE_RE = re.compile(r'(?P\d+)(?:@(?P\d+))?') - -""" -Matches HLS Playlist tags or comments, respective. -See section 4.1 of draft-pantos-http-live-streaming for more detail. -""" -TAG_RE = re.compile( - r'#(?PEXT[^:\s]+)(?P:?)(?P.*)' -) -COMMENT_RE = re.compile(r'#(?!EXT)(?P.*)') - - -class AttributeListEnum(str): - """ A subclass allowing us to differentiate enums in HLS attribute lists - """ - - -def _value_from_raw_attribute_value(raw_attribute_value): - """ - Takes in a raw AttributeValue and returns an appopritate Python type. - If there is a problem decoding the value, None is returned. - """ - value_match = ATTRIBUTE_VALUE_RE.match(raw_attribute_value) - if not value_match: - return None - - group_dict = value_match.groupdict() - # suss out the match - for k, v in group_dict.items(): - # not a successful group match - if v is None: - continue - - # decode the string - if k == 'resolution': - return v - elif k == 'enumerated': - return AttributeListEnum(v) - elif k == 'hexcidecimal': - return int(group_dict['hex_value'], base=16) - elif k == 'floating_point': - return float(v) - elif k == 'decimal': - return int(v) - elif k == 'string': - # grab only the data within the quotes, excluding the quotes - string_value = group_dict['string_value'] - return string_value - - return None - - -class AttributeList(dict): - """ - Dictionary-like object representing an HLS AttributeList. - See section 4.2 of draft-pantos-http-live-streaming for more detail. - """ - - def __init__(self, other=None): - """ - contstructs an :class:`AttributeList`. - - ``Other`` can be either another dictionary-like object or a list of - key/value pairs - """ - if not other: - return - - try: - items = other.items() - except AttributeError: - items = other - - for k, v in items: - self[k] = v - - def __str__(self): - """ - Construct attribute list string as it would exist in an HLS playlist. - """ - attr_list_entries = [] - # Use a sorted version of the dictionary to ensure consistency - for k, v in sorted(self.items(), key=lambda i: i[0]): - out_value = '' - if isinstance(v, AttributeListEnum): - out_value = v - elif isinstance(v, str): - out_value = f'"{v}"' - else: - out_value = str(v) - - attr_list_entries.append(f'{k}={out_value}') - - return ','.join(attr_list_entries) - - @classmethod - def from_string(cls, attrlist_string): - """ - Accepts an attribute list string and returns an :class:`AttributeList`. - - The values will be transformed to Python types. - """ - attr_list = cls() - match = ATTRIBUTE_RE.search(attrlist_string) - while match: - # unpack the values from the match - group_dict = match.groupdict() - name = group_dict['AttributeName'] - raw_value = group_dict['AttributeValue'] - - # parse the raw value - value = _value_from_raw_attribute_value(raw_value) - attr_list[name] = value - - # search for the next attribute in the string - match_end = match.span()[1] - match = ATTRIBUTE_RE.search(attrlist_string, match_end) - - return attr_list - - -# some special top-levle keys that HLS metadata will be decoded into -FORMAT_METADATA_KEY = 'HLS' -""" -Some concepts are translatable between HLS and other streaming formats (DASH). -These metadata keys are used on OTIO objects outside the HLS namespace because -they are higher level concepts. -""" -STREAMING_METADATA_KEY = 'streaming' -INIT_BYTERANGE_KEY = 'init_byterange' -INIT_URI_KEY = 'init_uri' -SEQUENCE_NUM_KEY = 'sequence_num' -BYTE_OFFSET_KEY = 'byte_offset' -BYTE_COUNT_KEY = 'byte_count' - - -class Byterange: - """Offers interpretation of HLS byte ranges in various forms.""" - - count = None - """(:class:`int`) Number of bytes included in the range.""" - - offset = None - """(:class:`int`) Byte offset at which the range starts.""" - - def __init__(self, count=None, offset=None): - """Constructs a :class:`Byterange` object. - - :param count: (:class:`int`) Number of bytes included in the range. - :param offset: (:class:`int`) Byte offset at which the range starts. - """ - self.count = (count if count is not None else 0) - self.offset = offset - - def __eq__(self, other): - if not isinstance(other, Byterange): - # fall back on identity, this should always be False - return (self is other) - return (self.count == other.count and self.offset == other.offset) - - def __ne__(self, other): - return not self.__eq__(other) - - def __repr__(self): - return '{}(offset = {}, count = {})'.format( - type(self), - str(self.offset), - str(self.count) - ) - - def __str__(self): - """returns a string in HLS format""" - - out_str = str(self.count) - if self.offset is not None: - out_str += f'@{str(self.offset)}' - - return out_str - - def to_dict(self): - """Returns a dict suitable for storing in otio metadata. - - :return: (:class:`dict`) serializable version of byterange. - """ - range_dict = {BYTE_COUNT_KEY: self.count} - if self.offset is not None: - range_dict[BYTE_OFFSET_KEY] = self.offset - - return range_dict - - @classmethod - def from_string(cls, byterange_string): - """Construct a :class:`Byterange` given a string in HLS format. - - :param byterange_string: (:class:`str`) a byterange string. - :return: (:class:`Byterange`) The instance for the provided string. - """ - m = BYTERANGE_RE.match(byterange_string) - - return cls.from_match_dict(m.groupdict()) - - @classmethod - def from_match_dict(cls, match_dict): - """ - Construct a :class:`Byterange` given a groupdict from ``BYTERANGE_RE`` - - :param match_dict: (:class:`dict`) the ``match_dict``. - :return: (:class:`Byterange`) The instance for the provided string. - """ - byterange = cls(count=int(match_dict['n'])) - - try: - byterange.offset = int(match_dict['o']) - except KeyError: - pass - - return byterange - - @classmethod - def from_dict(cls, info_dict): - """ Creates a :class:`Byterange` given a dictionary containing keys - like generated from the :meth:`to_dict method`. - - :param info_dict: (:class:`dict`) Dictionary byterange. - :return: (:class:`Byterange`) an equivalent instance. - """ - byterange = cls( - count=info_dict.get(BYTE_COUNT_KEY), - offset=info_dict.get(BYTE_OFFSET_KEY) - ) - - return byterange - - -""" -For a given collection of media, HLS has two playlist types: - - Media Playlist - - Master Playlist - -The media playlist refers directly to the individual segments that make up an -audio or video track of a given program. The master playlist refers to a -collection of media playlists and provides ways to use them together -(rendition groups). - -See section 2 of draft-pantos-http-live-streaming for more detail. - -The constants below define which tags belong to which schema. -""" - -""" -Basic tags appear in both media and master playlists. -See section 4.3.1 of draft-pantos-http-live-streaming for more detail. -""" -BASIC_TAGS = { - "EXTM3U", - "EXT-X-VERSION" -} - -""" -Media segment tags apply to either the following media or all subsequent -segments. They MUST NOT appear in master playlists. -See section 4.3.2 of draft-pantos-http-live-streaming for more detail. -""" -MEDIA_SEGMENT_TAGS = { - 'EXTINF', - 'EXT-X-BYTERANGE', - 'EXT-X-DISCONTINUITY', - 'EXT-X-KEY', - 'EXT-X-MAP', - 'EXT-X-PROGRAM-DATE-TIME', - 'EXT-X-DATERANGE' -} - -""" The subset of above tags that apply to every segment following them """ -MEDIA_SEGMENT_SUBSEQUENT_TAGS = { - 'EXT-X-KEY', - 'EXT-X-MAP', -} - -""" -Media Playlist tags must only occur once per playlist, and must not appear in -Master Playlists. -See section 4.3.3 of draft-pantos-http-live-streaming for more detail. -""" -MEDIA_PLAYLIST_TAGS = { - 'EXT-X-TARGETDURATION', - 'EXT-X-MEDIA-SEQUENCE', - 'EXT-X-DISCONTINUITY-SEQUENCE', - 'EXT-X-ENDLIST', - 'EXT-X-PLAYLIST-TYPE', - 'EXT-X-I-FRAMES-ONLY' -} - -""" -Master playlist tags declare global parameters for the presentation. -They must not appear in media playlists. -See section 4.3.4 of draft-pantos-http-live-streaming for more detail. -""" -MASTER_PLAYLIST_TAGS = { - 'EXT-X-MEDIA', - 'EXT-X-STREAM-INF', - 'EXT-X-I-FRAME-STREAM-INF', - 'EXT-X-SESSION-DATA', - 'EXT-X-SESSION-KEY', -} - -""" -Media or Master Playlist tags can appear in either media or master playlists. -See section 4.3.5 of draft-pantos-http-live-streaming for more detail. -These tags SHOULD appear in either the media or master playlist. If they occur -in both, their values MUST agree. -These values MUST NOT appear more than once in a playlist. -""" -MEDIA_OR_MASTER_TAGS = { - "EXT-X-INDEPENDENT-SEGMENTS", - "EXT-X-START" -} - -""" -Some special tags used by the parser. -""" -PLAYLIST_START_TAG = "EXTM3U" -PLAYLIST_END_TAG = "EXT-X-ENDLIST" -PLAYLIST_VERSION_TAG = "EXT-X-VERSION" -PLAYLIST_SEGMENT_INF_TAG = "EXTINF" - -""" -attribute list entries to omit from EXT-I-FRAME-STREAM-INF tags -See section 4.3.4.3 of draft-pantos-http-live-streaming for more detail. -""" -I_FRAME_OMIT_ATTRS = { - 'FRAME-RATE', - 'AUDIO', - 'SUBTITLES', - 'CLOSED-CAPTIONS' -} - -""" enum for kinds of playlist entries """ -EntryType = type('EntryType', (), { - 'tag': 'tag', - 'comment': 'comment', - 'URI': 'URI' -}) - -""" enum for types of playlists """ -PlaylistType = type('PlaylistType', (), { - 'media': 'media', - 'master': 'master' -}) - -""" mapping from HLS track type to otio ``TrackKind`` """ -HLS_TRACK_TYPE_TO_OTIO_KIND = { - AttributeListEnum('AUDIO'): otio.schema.TrackKind.Audio, - AttributeListEnum('VIDEO'): otio.schema.TrackKind.Video, - # TODO: determine how to handle SUBTITLES and CLOSED-CAPTIONS -} - -""" mapping from otio ``TrackKind`` to HLS track type """ -OTIO_TRACK_KIND_TO_HLS_TYPE = { - v: k for k, v in HLS_TRACK_TYPE_TO_OTIO_KIND.items() -} - - -class HLSPlaylistEntry: - """An entry in an HLS playlist. - - Entries can be a tag, a comment, or a URI. All HLS playlists are parsed - into lists of :class:`HLSPlaylistEntry` instances that can then be - interpreted against the HLS schema. - """ - - # TODO: rename this to entry_type to fix builtin masking - # type = None - """ (``EntryType``) the type of entry """ - - comment_string = None - """ - (:class:`str`) value of comment (if the ``entry_type`` is - ``EntryType.comment``). - """ - - tag_name = None - """ - (:class:`str`) Name of tag (if the ``entry_type`` is ``EntryType.tag``). - """ - - tag_value = None - """ - (:class:`str`) Value of tag (if the ``entry_type`` is ``EntryType.tag``). - """ - - uri = None - """ - (:class:`str`) Value of the URI (if the ``entry_type is ``EntryType.uri``). - """ - - def __init__(self, type): - """ - Constructs an :class:`HLSPlaylistEntry`. - - :param type: (``EntryType``) Type of entry. - """ - self.type = type - - def __repr__(self): - base_str = 'otio.adapter.HLSPlaylistEntry(type={}'.format( - self.type) - if self.type == EntryType.tag: - base_str += ', tag_name={}, tag_value={}'.format( - repr(self.tag_name), - repr(self.tag_value) - ) - elif self.type == EntryType.comment: - base_str += f', comment={repr(self.comment_string)}' - elif self.type == EntryType.URI: - base_str += f', URI={repr(self.uri)}' - - return base_str + ')' - - def __str__(self): - """ - Returns a string as it would appear in an HLS playlist. - - :return: (:class:`str`) HLS playlist entry string. - """ - if self.type == EntryType.comment and self.comment_string: - return f"# {self.comment_string}" - elif self.type == EntryType.comment: - # empty comments are blank lines - return "" - elif self.type == EntryType.URI: - return self.uri - elif self.type == EntryType.tag: - out_tag_name = self.tag_name - if self.tag_value is not None: - return f'#{out_tag_name}:{self.tag_value}' - else: - return f'#{out_tag_name}' - - @classmethod - def tag_entry(cls, name, value=None): - """ - Creates an ``EntryType.tag`` :class:`HLSPlaylistEntry`. - - :param name: (:class:`str`) tag name. - :param value: (:class:`str`) tag value. - :return: (:class:`HLSPlaylistEntry`) Entry instance. - """ - entry = cls(EntryType.tag) - entry.tag_name = name - entry.tag_value = value - - return entry - - @classmethod - def comment_entry(cls, comment): - """Creates an ``EntryType.comment`` :class:`HLSPlaylistEntry`. - - :param comment: (:class:`str`) the comment. - :return: (:class:`HLSPlaylistEntry`) Entry instance. - """ - entry = cls(EntryType.comment) - entry.comment_string = comment - - return entry - - @classmethod - def uri_entry(cls, uri): - """Creates an ``EntryType.uri`` :class:`HLSPlaylistEntry`. - - :param uri: (:class:`str`) A URI string. - :return: (:class:`HLSPlaylistEntry`) Entry instance. - """ - entry = cls(EntryType.URI) - entry.uri = uri - - return entry - - @classmethod - def from_string(cls, entry_string): - """Creates an `:class:`HLSPlaylistEntry` given a string as it appears - in an HLS playlist. - - :param entry_string: (:class:`str`) String from an HLS playlist. - :return: (:class:`HLSPlaylistEntry`) Entry instance. - """ - # Empty lines are skipped - if not entry_string.strip(): - return None - - # Attempt to parse as a tag - m = TAG_RE.match(entry_string) - if m: - group_dict = m.groupdict() - tag_value = ( - group_dict['tagvalue'] - if group_dict['hasvalue'] else None - ) - entry = cls.tag_entry(group_dict['tagname'], tag_value) - return entry - - # Attempt to parse as a comment - m = COMMENT_RE.match(entry_string) - if m: - entry = cls.comment_entry(m.groupdict()['comment']) - return entry - - # If it's not the others, treat as a URI - entry = cls.uri_entry(entry_string) - - return entry - - """A dispatch dictionary for grabbing the right Regex to parse tags.""" - TAG_VALUE_RE_MAP = { - "EXTINF": re.compile(r'(?P\d+(\.\d*)?),(?P.*$)'), - "EXT-X-BYTERANGE": BYTERANGE_RE, - "EXT-X-KEY": re.compile(r'(?P<attribute_list>.*$)'), - "EXT-X-MAP": re.compile(r'(?P<attribute_list>.*$)'), - "EXT-X-MEDIA-SEQUENCE": re.compile(r'(?P<number>\d+)'), - "EXT-X-PLAYLIST-TYPE": re.compile(r'(?P<type>EVENT|VOD)'), - PLAYLIST_VERSION_TAG: re.compile(r'(?P<n>\d+)') - } - - def parsed_tag_value(self, playlist_version=None): - """Parses and returns ``self.tag_value`` based on the HLS schema. - - The value will be a dictionary where the keys are the names used in the - draft Pantos HTTP Live Streaming doc. When "attribute-list" is - specified, an entry "attribute_list" will be present containing - an :class:`AttributeList` instance. - - :param playlist_version: (:class:`int`) version number of the playlist. - If none is provided, a best guess will be made. - :return: The parsed value. - """ - if self.type != EntryType.tag: - return None - - try: - tag_re = self.TAG_VALUE_RE_MAP[self.tag_name] - except KeyError: - return None - - # parse the tag - m = tag_re.match(self.tag_value) - group_dict = m.groupdict() - - if not m: - return None - - # If the tag value has an attribute list, parse it and add it - try: - attribute_list = group_dict['attribute_list'] - attr_list = AttributeList.from_string(attribute_list) - group_dict['attributes'] = attr_list - except KeyError: - pass - - return group_dict - - -class HLSPlaylistParser: - """Bootstraps HLS parsing and hands the playlist string off to the - appropriate parser for the type - """ - - def __init__(self, edl_string): - self.timeline = otio.schema.Timeline() - self.playlist_type = None - - self._parse_playlist(edl_string) - - def _parse_playlist(self, edl_string): - """Parses the HLS Playlist string line-by-line.""" - # parse lines until we encounter one that identifies the playlist type - # then hand off - start_encountered = False - end_encountered = False - playlist_entries = [] - playlist_version = 1 - for line in edl_string.splitlines(): - # attempt to parse the entry - entry = HLSPlaylistEntry.from_string(line) - if entry is None: - continue - - entry_is_tag = (entry.type == EntryType.tag) - - # identify if the playlist start/end is encountered - if (entry_is_tag and not (start_encountered and end_encountered)): - if entry.tag_name == PLAYLIST_START_TAG: - start_encountered = True - elif entry.tag_name == PLAYLIST_END_TAG: - end_encountered = True - - # if the playlist starting tag hasn't been encountered, ignore - if not start_encountered: - continue - - # Store the parsed entry - playlist_entries.append(entry) - - # Determine if this tells us the playlist type - if not self.playlist_type and entry_is_tag: - if entry.tag_name in MASTER_PLAYLIST_TAGS: - self.playlist_type = PlaylistType.master - elif entry.tag_name in MEDIA_PLAYLIST_TAGS: - self.playlist_type = PlaylistType.media - - if end_encountered: - break - - # try to grab the version from the playlist - if entry_is_tag and entry.tag_name == PLAYLIST_VERSION_TAG: - playlist_version = int(entry.parsed_tag_value()['n']) - - # dispatch to the appropriate schema interpreter - if self.playlist_type is None: - self.timeline = None - raise otio.exceptions.ReadingNotSupportedError( - "could not determine playlist type" - ) - elif self.playlist_type == PlaylistType.master: - self.timeline = None - raise otio.exceptions.AdapterDoesntSupportFunction( - "HLS master playlists are not yet supported" - ) - elif self.playlist_type == PlaylistType.media: - parser = MediaPlaylistParser(playlist_entries, playlist_version) - if len(parser.track): - self.timeline.tracks.append(parser.track) - - -class MediaPlaylistParser: - """Parses an HLS Media playlist returning a SEQUENCE""" - - def __init__(self, playlist_entries, playlist_version=None): - self.track = otio.schema.Track( - metadata={FORMAT_METADATA_KEY: {}} - ) - - self._parse_entries(playlist_entries, playlist_version) - - def _handle_track_metadata(self, entry, playlist_version, clip): - """Stashes the tag value in the track metadata""" - value = entry.tag_value - self.track.metadata[FORMAT_METADATA_KEY][entry.tag_name] = value - - def _handle_discarded_metadata(self, entry, playlist_version, clip): - """Handler for tags that are discarded. This is done when a tag's - information is represented by the native OTIO concepts. - - For instance, the EXT-X-TARGETDURATION tag simply gives a rounded - value for the maximum segment size in the playlist. This can easily - be found in OTIO by examining the clips. - """ - # Do nothing - - def _metadata_dict_for_MAP(self, entry, playlist_version): - entry_data = entry.parsed_tag_value() - attributes = entry_data['attributes'] - map_dict = {} - for attr, value in attributes.items(): - if attr == 'BYTERANGE': - byterange = Byterange.from_string(value) - map_dict[INIT_BYTERANGE_KEY] = byterange.to_dict() - elif attr == 'URI': - map_dict[INIT_URI_KEY] = value - - return map_dict - - def _handle_INF(self, entry, playlist_version, clip): - # This specifies segment duration and optional title - info_dict = entry.parsed_tag_value(playlist_version) - segment_duration = float(info_dict['duration']) - segment_title = info_dict['title'] - available_range = otio.opentime.TimeRange( - otio.opentime.RationalTime(0, 1), - otio.opentime.RationalTime(segment_duration, 1) - ) - - # Push the info to the clip - clip.media_reference.available_range = available_range - clip.source_range = available_range - clip.name = segment_title - - def _handle_BYTERANGE(self, entry, playlist_version, clip): - reference_metadata = clip.media_reference.metadata - ref_streaming_metadata = reference_metadata.setdefault( - STREAMING_METADATA_KEY, - {} - ) - - # Pull out the byte count and offset - byterange = Byterange.from_match_dict( - entry.parsed_tag_value(playlist_version) - ) - ref_streaming_metadata.update(byterange.to_dict()) - - """ - Specifies handlers for specific HLS tags. - """ - TAG_HANDLERS = { - "EXTINF": _handle_INF, - PLAYLIST_VERSION_TAG: _handle_track_metadata, - "EXT-X-TARGETDURATION": _handle_discarded_metadata, - "EXT-X-MEDIA-SEQUENCE": _handle_discarded_metadata, - "EXT-X-PLAYLIST-TYPE": _handle_track_metadata, - "EXT-X-INDEPENDENT-SEGMENTS": _handle_track_metadata, - "EXT-X-BYTERANGE": _handle_BYTERANGE - } - - def _parse_entries(self, playlist_entries, playlist_version): - """Interpret the entries through the lens of the schema""" - current_clip = otio.schema.Clip( - media_reference=otio.schema.ExternalReference( - metadata={ - FORMAT_METADATA_KEY: {}, - STREAMING_METADATA_KEY: {} - } - ) - ) - current_media_ref = current_clip.media_reference - segment_metadata = {} - current_map_data = {} - # per section 4.3.3.2 of Pantos HLS, 0 is default start track - current_track = 0 - for entry in playlist_entries: - if entry.type == EntryType.URI: - # the URI ends the segment definition - current_media_ref.target_url = entry.uri - current_media_ref.metadata[FORMAT_METADATA_KEY].update( - segment_metadata - ) - current_media_ref.metadata[STREAMING_METADATA_KEY].update( - current_map_data - ) - current_clip.metadata.setdefault( - STREAMING_METADATA_KEY, - {} - )[SEQUENCE_NUM_KEY] = current_track - self.track.append(current_clip) - current_track += 1 - - # Set up the next segment definition - current_clip = otio.schema.Clip( - media_reference=otio.schema.ExternalReference( - metadata={ - FORMAT_METADATA_KEY: {}, - STREAMING_METADATA_KEY: {} - } - ) - ) - current_media_ref = current_clip.media_reference - continue - elif entry.type != EntryType.tag: - # the rest of the code deals only with tags - continue - - # Explode the EXT-X-MAP info out - if entry.tag_name == "EXT-X-MAP": - map_data = self._metadata_dict_for_MAP(entry, playlist_version) - current_map_data.update(map_data) - continue - - # Grab the track when it comes around - if entry.tag_name == "EXT-X-MEDIA-SEQUENCE": - entry_data = entry.parsed_tag_value() - current_track = int(entry_data['number']) - - # If the segment tag is one that applies to all that follow - # store the value to be applied to each segment - if entry.tag_name in MEDIA_SEGMENT_SUBSEQUENT_TAGS: - segment_metadata[entry.tag_name] = entry.tag_value - continue - - # use a handler if available - try: - handler = self.TAG_HANDLERS[entry.tag_name] - handler(self, entry, playlist_version, current_clip) - continue - except KeyError: - pass - - # add the tag to the reference metadata at the correct level - if entry.tag_name in [PLAYLIST_START_TAG, PLAYLIST_END_TAG]: - continue - elif entry.tag_name in MEDIA_SEGMENT_TAGS: - # Media segments translate into media refs - hls_metadata = current_media_ref.metadata[FORMAT_METADATA_KEY] - hls_metadata[entry.tag_name] = entry.tag_value - elif entry.tag_name in MEDIA_PLAYLIST_TAGS: - # Media playlists translate into tracks - hls_metadata = self.track.metadata[FORMAT_METADATA_KEY] - hls_metadata[entry.tag_name] = entry.tag_value - - -""" -Compatibility version list: - EXT-X-BYTERANGE >= 4 - EXT-X-I-FRAMES-ONLY >= 4 - EXT-X-MAP in media playlist with EXT-X-I-FRAMES-ONLY >= 5 - EXT-X-MAP in media playlist without I-FRAMES-ONLY >= 6 - EXT-X-KEY constrants are by attributes specified: - - IV >= 2 - - KEYFORMAT >= 5 - - KEYFORMATVERSIONS >= 5 - EXTINF with floating point vaules >= 3 - - master playlist: - EXT-X-MEDIA with INSTREAM-ID="SERVICE" -""" - - -def entries_for_segment( - uri, - segment_duration, - segment_name=None, - segment_byterange=None, - segment_tags=None -): - """Creates a set of :class:`HLSPlaylistEntries` with the given parameters. - - :param uri: (:class:`str`) The uri for the segment media. - :param segment_duration: (:class:`opentimelineio.opentime.RationalTime`) - playback duration of the segment. - :param segment_byterange: (:class:`ByteRange`) The data range for the - segment in the media (if required) - :param segment_tags: (:class:`dict`) key/value pairs of to become - additional tags for the segment - - :return: (:class:`list`) a group of :class:`HLSPlaylistEntry` instances for - the segment - """ - # Create the tags dict to build - if segment_tags: - tags = copy.deepcopy(segment_tags) - else: - tags = {} - - # Start building the entries list - segment_entries = [] - - # add the EXTINF - name = segment_name if segment_name is not None else '' - tag_value = '{:.5f},{}'.format( - otio.opentime.to_seconds(segment_duration), - name - ) - extinf_entry = HLSPlaylistEntry.tag_entry('EXTINF', tag_value) - segment_entries.append(extinf_entry) - - # add the additional tags - tag_entries = [ - HLSPlaylistEntry.tag_entry(k, v) for k, v in - tags.items() - ] - segment_entries.extend(tag_entries) - - # Now add the byterange for the entry - if segment_byterange: - byterange_entry = HLSPlaylistEntry.tag_entry( - 'EXT-X-BYTERANGE', - str(segment_byterange) - ) - segment_entries.append(byterange_entry) - - # Add the URI - # this method expects all fragments come from the same source file - uri_entry = HLSPlaylistEntry.uri_entry(uri) - segment_entries.append(uri_entry) - - return segment_entries - - -def stream_inf_attr_list_for_track(track): - """ Builds an :class:`AttributeList` instance for use in ``STREAM-INF`` - tags for the provided track. - - :param track: (:class:`otio.schema.Track`) A track representing a - variant stream - :return: (:class:`AttributeList`) The instance from the metadata - """ - streaming_metadata = track.metadata.get(STREAMING_METADATA_KEY, {}) - - attributes = [] - bandwidth = streaming_metadata.get('bandwidth') - if bandwidth is not None: - attributes.append(('BANDWIDTH', bandwidth)) - - codec = streaming_metadata.get('codec') - if codec is not None: - attributes.append(('CODECS', codec)) - - frame_rate = streaming_metadata.get('frame_rate') - if frame_rate is not None: - attributes.append(('FRAME-RATE', frame_rate)) - - if 'width' in streaming_metadata and 'height' in streaming_metadata: - resolution = "{}x{}".format( - streaming_metadata['width'], - streaming_metadata['height'] - ) - attributes.append(('RESOLUTION', AttributeListEnum(resolution))) - - al = AttributeList(attributes) - - return al - - -def master_playlist_to_string(master_timeline): - """Writes a master playlist describing the tracks""" - - # start with a version number of 1, as features are encountered, we will - # update the version accordingly - version_requirements = {1} - - # TODO: detect rather than forcing version 6 - version_requirements.add(6) - - header_tags = copy.copy( - master_timeline.metadata.get(FORMAT_METADATA_KEY, {}) - ) - - # Filter out any values from the HLS metadata that aren't meant to become - # tags, such as the directive to force an HLS master playlist - hls_md_rejectlist = ['master_playlist'] - for key in hls_md_rejectlist: - try: - del header_tags[key] - except KeyError: - pass - - playlist_entries = [] - - # First declare the non-visual media - hls_type_count = {} - video_tracks = [] - audio_tracks = [ - t for t in master_timeline.tracks if - t.kind == otio.schema.TrackKind.Audio - ] - for track in master_timeline.tracks: - if track.kind == otio.schema.TrackKind.Video: - # video is done later, skip - video_tracks.append(track) - continue - - # Determine the HLS type - hls_type = OTIO_TRACK_KIND_TO_HLS_TYPE[track.kind] - - streaming_metadata = track.metadata.get(STREAMING_METADATA_KEY, {}) - - # Find the group name - try: - group_id = streaming_metadata['group_id'] - except KeyError: - sub_id = hls_type_count.setdefault(hls_type, 1) - group_id = f'{hls_type}{sub_id}' - hls_type_count[hls_type] += 1 - - media_playlist_default_uri = f"{track.name}.m3u8" - try: - track_uri = track.metadata[FORMAT_METADATA_KEY].get( - 'uri', - media_playlist_default_uri - ) - except KeyError: - track_uri = media_playlist_default_uri - - # Build the attribute list - attributes = AttributeList( - [ - ('TYPE', hls_type), - ('GROUP-ID', group_id), - ('URI', track_uri), - ('NAME', track.name), - ] - ) - - if streaming_metadata.get('autoselect'): - attributes['AUTOSELECT'] = AttributeListEnum('YES') - - if streaming_metadata.get('default'): - attributes['DEFAULT'] = AttributeListEnum('YES') - - # Finally, create the tag - entry = HLSPlaylistEntry.tag_entry( - 'EXT-X-MEDIA', - str(attributes) - ) - - playlist_entries.append(entry) - - # Add a blank line in the playlist to separate sections - if playlist_entries: - playlist_entries.append(HLSPlaylistEntry.comment_entry('')) - - # First write any i-frame playlist entires - iframe_list_entries = [] - for track in video_tracks: - try: - iframe_uri = track.metadata[FORMAT_METADATA_KEY]['iframe_uri'] - except KeyError: - # don't include iframe playlist - continue - - # Create the attribute list - attribute_list = stream_inf_attr_list_for_track(track) - - # Remove entries to not be included for I-Frame streams - for attr in I_FRAME_OMIT_ATTRS: - try: - del attribute_list[attr] - except KeyError: - pass - - # Add the URI - attribute_list['URI'] = iframe_uri - - iframe_list_entries.append( - HLSPlaylistEntry.tag_entry( - 'EXT-X-I-FRAME-STREAM-INF', - str(attribute_list) - ) - ) - - if iframe_list_entries: - iframe_list_entries.append(HLSPlaylistEntry.comment_entry('')) - - playlist_entries.extend(iframe_list_entries) - - # Write an EXT-STREAM-INF for each rendition set - for track in video_tracks: - # create the base attribute list for the video track - al = stream_inf_attr_list_for_track(track) - - # Create the uri - media_playlist_default_uri = f"{track.name}.m3u8" - try: - track_uri = track.metadata[FORMAT_METADATA_KEY].get( - 'uri', media_playlist_default_uri - ) - except KeyError: - track_uri = media_playlist_default_uri - uri_entry = HLSPlaylistEntry.uri_entry(track_uri) - - # TODO: this will break when we have subtitle and CC tracks - added_entry = False - for audio_track in audio_tracks: - if track.name not in audio_track.metadata['linked_tracks']: - continue - - # Write an entry for using these together - try: - audio_track_streaming_metadata = audio_track.metadata[ - STREAMING_METADATA_KEY - ] - aud_group = audio_track_streaming_metadata['group_id'] - aud_codec = audio_track_streaming_metadata['codec'] - aud_bandwidth = audio_track_streaming_metadata['bandwidth'] - except KeyError: - raise TypeError( - "HLS audio tracks must have 'codec', 'group_id', and" - " 'bandwidth' specified in metadata" - ) - - combo_al = copy.copy(al) - combo_al['CODECS'] += f',{aud_codec}' - combo_al['AUDIO'] = aud_group - combo_al['BANDWIDTH'] += aud_bandwidth - - entry = HLSPlaylistEntry.tag_entry( - 'EXT-X-STREAM-INF', - str(combo_al) - ) - playlist_entries.append(entry) - playlist_entries.append(uri_entry) - - added_entry = True - - if not added_entry: - # write out one simple entry - entry = HLSPlaylistEntry.tag_entry( - 'EXT-X-STREAM-INF', - str(al) - ) - playlist_entries.append(entry) - playlist_entries.append(uri_entry) - - # add a break before the next grouping of entries - playlist_entries.append(HLSPlaylistEntry.comment_entry('')) - - out_entries = [HLSPlaylistEntry.tag_entry(PLAYLIST_START_TAG, None)] - - playlist_version = max(version_requirements) - playlist_version_entry = HLSPlaylistEntry.tag_entry( - PLAYLIST_VERSION_TAG, - str(playlist_version) - ) - - out_entries.append(playlist_version_entry) - - out_entries += ( - HLSPlaylistEntry.tag_entry(k, v) for k, v in header_tags.items() - ) - - # separate the header entries from the rest of the entries - out_entries.append(HLSPlaylistEntry.comment_entry('')) - - out_entries += playlist_entries - - playlist_string = '\n'.join( - str(entry) for entry in out_entries - ) - - return playlist_string - - -class MediaPlaylistWriter(): - - def __init__( - self, - media_track, - min_seg_duration=None, - max_seg_duration=None - ): - # Default to one segment per fragment - if min_seg_duration is None: - min_seg_duration = otio.opentime.RationalTime(0, 1) - if max_seg_duration is None: - max_seg_duration = otio.opentime.RationalTime(0, 1) - - self._min_seg_duration = min_seg_duration - self._max_seg_duration = max_seg_duration - - self._playlist_entries = [] - self._playlist_tags = {} - - # Whenever an entry is added that has a minimum version requirement, - # we add that version to this set. The max value from this set is the - # playlist's version requirement - self._versions_used = {1} - - # TODO: detect rather than forcing version 7 - self._versions_used.add(7) - - # Start the build - self._build_playlist_with_track(media_track) - - def _build_playlist_with_track(self, media_track): - """ - Executes methods to result in a fully populated _playlist_entries list - """ - self._copy_HLS_metadata(media_track) - self._setup_track_info(media_track) - self._add_segment_entries(media_track) - self._finalize_entries(media_track) - - def _copy_HLS_metadata(self, media_track): - """ - Copies any metadata in the "HLS" namespace from the track to the - playlist-global tags - """ - # Grab any metadata provided on the otio - try: - track_metadata = media_track.metadata[FORMAT_METADATA_KEY] - self._playlist_tags.update(track_metadata) - - # Remove the version tag from the track metadata, we'll compute - # based on what we write out - del self._playlist_tags[PLAYLIST_VERSION_TAG] - - except KeyError: - pass - - # additionally remove metadata keys added for providing master - # playlist URIs - for key in ('uri', 'iframe_uri'): - try: - del self._playlist_tags[key] - except KeyError: - pass - - def _setup_track_info(self, media_track): - """sets up playlist global metadata""" - - # Setup the track start - if 'EXT-X-I-FRAMES-ONLY' in media_track.metadata.get( - FORMAT_METADATA_KEY, - {} - ): - # I-Frame playlists start at zero no matter what - track_start = 0 - else: - # Pull the track num from the first clip, if provided - first_segment_streaming_metadata = media_track[0].metadata.get( - STREAMING_METADATA_KEY, - {} - ) - track_start = first_segment_streaming_metadata.get( - SEQUENCE_NUM_KEY - ) - - # If we found a track start or one isn't already set in the - # metadata, create the tag for it. - if ( - track_start is not None or - 'EXT-X-MEDIA-SEQUENCE' not in self._playlist_tags - ): - # Choose a reasonable track start default - if track_start is None: - track_start = 1 - self._playlist_tags['EXT-X-MEDIA-SEQUENCE'] = str(track_start) - - def _add_map_entry(self, fragment): - """adds an EXT-X-MAP entry from the given fragment - - returns the added entry - """ - - media_ref = fragment.media_reference - - # Extract useful tag data - media_ref_streaming_metadata = media_ref.metadata[ - STREAMING_METADATA_KEY - ] - uri = media_ref_streaming_metadata[INIT_URI_KEY] - seg_map_byterange_dict = media_ref_streaming_metadata.get( - INIT_BYTERANGE_KEY - ) - - # Create the attrlist - map_attr_list = AttributeList([ - ('URI', uri), - ]) - - # Add the byterange if provided - if seg_map_byterange_dict is not None: - seg_map_byterange = Byterange.from_dict(seg_map_byterange_dict) - map_attr_list['BYTERANGE'] = str(seg_map_byterange) - - # Construct the entry with the attrlist as the value - map_tag_str = str(map_attr_list) - entry = HLSPlaylistEntry.tag_entry("EXT-X-MAP", map_tag_str) - - self._playlist_entries.append(entry) - - return entry - - def _add_entries_for_segment_from_fragments( - self, - fragments, - omit_hls_keys=None, - is_iframe_playlist=False - ): - """ - For the given list of otio clips representing fragments in the mp4, - add playlist entries for single HLS segment. - - :param fragments: (:clas:`list`) :class:`opentimelineio.schema.Clip` - objects to write as a contiguous segment. - :param omit_hls_keys: (:class:`list`) metadata keys from the original - "HLS" metadata namespeaces will not be passed through. - :param is_iframe_playlist: (:class:`bool`) If true, writes one segment - per fragment, otherwise writes all fragments as a single segment - - :return: (:class:`list` the :class:`HLSPlaylistEntry` instances added - to the playlist - """ - if is_iframe_playlist: - entries = [] - for fragment in fragments: - name = '' - fragment_range = Byterange.from_dict( - fragment.media_reference.metadata[STREAMING_METADATA_KEY] - ) - - segment_tags = {} - frag_tags = fragment.media_reference.metadata.get( - FORMAT_METADATA_KEY, - {} - ) - segment_tags.update(copy.deepcopy(frag_tags)) - - # scrub any metadata marked for omission - omit_hls_keys = omit_hls_keys or [] - for key in omit_hls_keys: - try: - del segment_tags[key] - except KeyError: - pass - - segment_entries = entries_for_segment( - fragment.media_reference.target_url, - fragment.duration(), - name, - fragment_range, - segment_tags - ) - entries.extend(segment_entries) - - self._playlist_entries.extend(entries) - return entries - - segment_tags = {} - for fragment in fragments: - frag_tags = fragment.media_reference.metadata.get( - FORMAT_METADATA_KEY, - {} - ) - segment_tags.update(copy.deepcopy(frag_tags)) - - # scrub any metadata marked for omission - omit_hls_keys = omit_hls_keys or [] - for key in omit_hls_keys: - try: - del segment_tags[key] - except KeyError: - pass - - # Calculate the byterange for the segment (if byteranges are specified) - first_ref = fragments[0].media_reference - first_ref_streaming_md = first_ref.metadata[STREAMING_METADATA_KEY] - if 'byte_offset' in first_ref_streaming_md and len(fragments) == 1: - segment_range = Byterange.from_dict(first_ref_streaming_md) - elif 'byte_offset' in first_ref_streaming_md: - # Find the byterange encapsulating everything - last_ref = fragments[-1].media_reference - last_ref_streaming_md = last_ref.metadata[STREAMING_METADATA_KEY] - first_range = Byterange.from_dict(first_ref_streaming_md) - last_range = Byterange.from_dict(last_ref_streaming_md) - - segment_offset = first_range.offset - segment_end = (last_range.offset + last_range.count) - segment_count = segment_end - segment_offset - segment_range = Byterange(segment_count, segment_offset) - else: - segment_range = None - - uri = fragments[0].media_reference.target_url - - # calculate the combined duration - segment_duration = fragments[0].duration() - for frag in fragments[1:]: - segment_duration += frag.duration() - - # TODO: Determine how to pass a segment name in - segment_name = '' - segment_entries = entries_for_segment( - uri, - segment_duration, - segment_name, - segment_range, - segment_tags - ) - - self._playlist_entries.extend(segment_entries) - return segment_entries - - def _fragments_have_same_map(self, fragment, following_fragment): - """ - Given fragment and following_fragment, returns whether or not their - initialization data is the same (what becomes EXT-X-MAP) - """ - media_ref = fragment.media_reference - media_ref_streaming_md = media_ref.metadata.get( - STREAMING_METADATA_KEY, - {} - ) - following_ref = following_fragment.media_reference - following_ref_streaming_md = following_ref.metadata.get( - STREAMING_METADATA_KEY, - {} - ) - # Check the init file - init_uri = media_ref_streaming_md.get(INIT_URI_KEY) - following_init_uri = media_ref_streaming_md.get(INIT_URI_KEY) - if init_uri != following_init_uri: - return False - - # Check the init byterange - init_dict = media_ref_streaming_md.get(INIT_BYTERANGE_KEY) - following_init_dict = following_ref_streaming_md.get( - INIT_BYTERANGE_KEY - ) - - dummy_range = Byterange(0, 0) - init_range = ( - Byterange.from_dict(init_dict) if init_dict else dummy_range - ) - following_range = ( - Byterange.from_dict(following_init_dict) - if following_init_dict else dummy_range - ) - - if init_range != following_range: - return False - - return True - - def _fragments_are_contiguous(self, fragment, following_fragment): - """ Given fragment and following_fragment (otio clips) returns whether - or not they are contiguous. - - To be contiguous the fragments must: - 1. have the same file URL - 2. have the same initialization data (what becomes EXT-X-MAP) - 3. be adjacent in the file (follwoing_fragment's first byte directly - follows fragment's last byte) - - Returns True if following_fragment is contiguous from fragment - """ - # Fragments are contiguous if: - # 1. They have the file url - # 2. They have the same map info - # 3. Their byte ranges are contiguous - media_ref = fragment.media_reference - media_ref_streaming_md = media_ref.metadata.get( - STREAMING_METADATA_KEY, - {} - ) - following_ref = following_fragment.media_reference - following_ref_streaming_md = following_ref.metadata.get( - STREAMING_METADATA_KEY, - {} - ) - if media_ref.target_url != following_ref.target_url: - return False - - if ( - media_ref_streaming_md.get(INIT_URI_KEY) != - following_ref_streaming_md.get(INIT_URI_KEY) - ): - return False - - if not self._fragments_have_same_map(fragment, following_fragment): - return False - - # Check if fragments are contiguous in file - try: - frag_end = ( - media_ref_streaming_md['byte_offset'] + - media_ref_streaming_md['byte_count'] - ) - if frag_end != following_ref_streaming_md['byte_offset']: - return False - except KeyError: - return False - - # since we haven't returned yet, all checks must have passed! - return True - - def _add_segment_entries(self, media_track): - """given a media track, generates the segment entries""" - - # Determine whether or not this is an I-Frame playlist - track_hls_metadata = media_track.metadata.get('HLS') - is_iframe_playlist = 'EXT-X-I-FRAMES-ONLY' in track_hls_metadata - - # Make a list copy of the fragments - fragments = [clip for clip in media_track] - - segment_durations = [] - previous_fragment = None - map_changed = True - while fragments: - # There should be at least one fragment per segment - frag_it = iter(fragments) - first_frag = next(frag_it) - gathered_fragments = [first_frag] - gathered_duration = first_frag.duration() - - # Determine this segment will need a new EXT-X-MAP entry - map_changed = ( - True if previous_fragment is None else - not self._fragments_have_same_map( - previous_fragment, - first_frag - ) - ) - - # Iterate through the remaining fragments until a discontinuity - # is found, our time limit is met, or we add all the fragments to - # the segment - for fragment in frag_it: - # Determine whther or not the fragments are contiguous - previous_fragment = gathered_fragments[-1] - contiguous = self._fragments_are_contiguous( - previous_fragment, - fragment - ) - - # Determine if we've hit our segment time conditions - new_duration = gathered_duration + fragment.duration() - segment_full = ( - gathered_duration >= self._min_seg_duration or - new_duration > self._max_seg_duration - ) - - # End condition met, cut the segment - if not contiguous or segment_full: - break - - # Include the fragment - gathered_duration = new_duration - gathered_fragments.append(fragment) - - # Write out the segment and start the next - start_fragment = gathered_fragments[0] - - # If the map for this segment was a change, write it - if map_changed: - self._add_map_entry(start_fragment) - - # add the entries for the segment. Omit any EXT-X-MAP metadata - # that may have come in from reading a file (we're updating) - self._add_entries_for_segment_from_fragments( - gathered_fragments, - omit_hls_keys=('EXT-X-MAP'), - is_iframe_playlist=is_iframe_playlist - ) - - duration_seconds = otio.opentime.to_seconds(gathered_duration) - segment_durations.append(duration_seconds) - - # in the next iteration, start where we left off - fragments = fragments[len(gathered_fragments):] - - # Set the max segment duration - max_duration = round(max(segment_durations)) - self._playlist_tags['EXT-X-TARGETDURATION'] = str(int(max_duration)) - - def _finalize_entries(self, media_track): - """Does final wrap-up of playlist entries""" - - self._playlist_tags['EXT-X-PLAYLIST-TYPE'] = 'VOD' - - # add the end - end_entry = HLSPlaylistEntry.tag_entry(PLAYLIST_END_TAG) - self._playlist_entries.append(end_entry) - - # find the maximum HLS feature version we've used - playlist_version = max(self._versions_used) - playlist_version_entry = HLSPlaylistEntry.tag_entry( - PLAYLIST_VERSION_TAG, - str(playlist_version) - ) - - # now that we know what was used, let's prepend the header - playlist_header_entries = [ - HLSPlaylistEntry.tag_entry(PLAYLIST_START_TAG), - playlist_version_entry - ] - - # add in the rest of the header entries in a deterministic order - playlist_header_entries += ( - HLSPlaylistEntry.tag_entry(k, v) - for k, v in sorted(self._playlist_tags.items(), key=lambda i: i[0]) - ) - - # Prepend the entries with the header entries - self._playlist_entries = ( - playlist_header_entries + self._playlist_entries - ) - - def playlist_string(self): - """Returns the string representation of the playlist entries""" - - return '\n'.join( - str(entry) for entry in self._playlist_entries - ) - -# Public interface - - -def read_from_string(input_str): - """Adapter entry point for reading.""" - - parser = HLSPlaylistParser(input_str) - return parser.timeline - - -def write_to_string(input_otio): - """Adapter entry point for writing.""" - - if len(input_otio.tracks) == 0: - return None - - # Determine whether we should write a media or master playlist - try: - write_master = input_otio.metadata['HLS']['master_playlist'] - except KeyError: - # If no explicit directive, infer - write_master = (len(input_otio.tracks) > 1) - - if write_master: - return master_playlist_to_string(input_otio) - else: - media_track = input_otio.tracks[0] - track_streaming_md = input_otio.metadata.get( - STREAMING_METADATA_KEY, - {} - ) - min_seg_duration = track_streaming_md.get('min_segment_duration') - max_seg_duration = track_streaming_md.get('max_segment_duration') - - writer = MediaPlaylistWriter( - media_track, - min_seg_duration, - max_seg_duration - ) - return writer.playlist_string() diff --git a/contrib/opentimelineio_contrib/adapters/tests/sample_data/v1_prog_index.m3u8 b/contrib/opentimelineio_contrib/adapters/tests/sample_data/v1_prog_index.m3u8 deleted file mode 100644 index 40fa14a4c..000000000 --- a/contrib/opentimelineio_contrib/adapters/tests/sample_data/v1_prog_index.m3u8 +++ /dev/null @@ -1,158 +0,0 @@ -#EXTM3U -#EXT-X-VERSION:7 -#EXT-X-INDEPENDENT-SEGMENTS -#EXT-X-MEDIA-SEQUENCE:1 -#EXT-X-PLAYLIST-TYPE:VOD -#EXT-X-TARGETDURATION:1 -#EXT-X-MAP:BYTERANGE="729@0",URI="media-video-1.mp4" -#EXTINF:1.00100, -#EXT-X-BYTERANGE:534220@1361 -media-video-1.mp4 -#EXTINF:1.00100, -#EXT-X-BYTERANGE:778907@535581 -media-video-1.mp4 -#EXTINF:1.00100, -#EXT-X-BYTERANGE:537741@1314488 -media-video-1.mp4 -#EXTINF:1.00100, -#EXT-X-BYTERANGE:578439@1852229 -media-video-1.mp4 -#EXTINF:1.00100, -#EXT-X-BYTERANGE:593718@2430668 -media-video-1.mp4 -#EXTINF:1.00100, -#EXT-X-BYTERANGE:630613@3024386 -media-video-1.mp4 -#EXTINF:1.00100, -#EXT-X-BYTERANGE:634728@3654999 -media-video-1.mp4 -#EXTINF:1.00100, -#EXT-X-BYTERANGE:714824@4289727 -media-video-1.mp4 -#EXTINF:1.00100, -#EXT-X-BYTERANGE:959505@5004551 -media-video-1.mp4 -#EXTINF:1.00100, -#EXT-X-BYTERANGE:901951@5964056 -media-video-1.mp4 -#EXTINF:1.00100, -#EXT-X-BYTERANGE:618831@6866007 -media-video-1.mp4 -#EXTINF:1.00100, -#EXT-X-BYTERANGE:505733@7484838 -media-video-1.mp4 -#EXTINF:1.00100, -#EXT-X-BYTERANGE:593480@7990571 -media-video-1.mp4 -#EXTINF:1.00100, -#EXT-X-BYTERANGE:617335@8584051 -media-video-1.mp4 -#EXTINF:1.00100, -#EXT-X-BYTERANGE:578236@9201386 -media-video-1.mp4 -#EXTINF:1.00100, -#EXT-X-BYTERANGE:421794@9779622 -media-video-1.mp4 -#EXTINF:1.00100, -#EXT-X-BYTERANGE:390440@10201416 -media-video-1.mp4 -#EXTINF:1.00100, -#EXT-X-BYTERANGE:477096@10591856 -media-video-1.mp4 -#EXTINF:1.00100, -#EXT-X-BYTERANGE:562407@11068952 -media-video-1.mp4 -#EXTINF:1.00100, -#EXT-X-BYTERANGE:565052@11631359 -media-video-1.mp4 -#EXTINF:1.00100, -#EXT-X-BYTERANGE:600160@12196411 -media-video-1.mp4 -#EXTINF:1.00100, -#EXT-X-BYTERANGE:552664@12796571 -media-video-1.mp4 -#EXTINF:1.00100, -#EXT-X-BYTERANGE:550040@13349235 -media-video-1.mp4 -#EXTINF:1.00100, -#EXT-X-BYTERANGE:512679@13899275 -media-video-1.mp4 -#EXTINF:1.00100, -#EXT-X-BYTERANGE:541998@14411954 -media-video-1.mp4 -#EXTINF:1.00100, -#EXT-X-BYTERANGE:654961@14953952 -media-video-1.mp4 -#EXTINF:1.00100, -#EXT-X-BYTERANGE:652785@15608913 -media-video-1.mp4 -#EXTINF:1.00100, -#EXT-X-BYTERANGE:598097@16261698 -media-video-1.mp4 -#EXTINF:1.00100, -#EXT-X-BYTERANGE:538081@16859795 -media-video-1.mp4 -#EXTINF:1.00100, -#EXT-X-BYTERANGE:471646@17397876 -media-video-1.mp4 -#EXTINF:1.00100, -#EXT-X-BYTERANGE:503387@17869522 -media-video-1.mp4 -#EXTINF:1.00100, -#EXT-X-BYTERANGE:588415@18372909 -media-video-1.mp4 -#EXTINF:1.00100, -#EXT-X-BYTERANGE:577750@18961324 -media-video-1.mp4 -#EXTINF:1.00100, -#EXT-X-BYTERANGE:616575@19539074 -media-video-1.mp4 -#EXTINF:1.00100, -#EXT-X-BYTERANGE:587505@20155649 -media-video-1.mp4 -#EXTINF:1.00100, -#EXT-X-BYTERANGE:587032@20743154 -media-video-1.mp4 -#EXTINF:1.00100, -#EXT-X-BYTERANGE:531455@21330186 -media-video-1.mp4 -#EXTINF:1.00100, -#EXT-X-BYTERANGE:559421@21861641 -media-video-1.mp4 -#EXTINF:1.00100, -#EXT-X-BYTERANGE:565333@22421062 -media-video-1.mp4 -#EXTINF:1.00100, -#EXT-X-BYTERANGE:593998@22986395 -media-video-1.mp4 -#EXTINF:1.00100, -#EXT-X-BYTERANGE:541919@23580393 -media-video-1.mp4 -#EXTINF:1.00100, -#EXT-X-BYTERANGE:596496@24122312 -media-video-1.mp4 -#EXTINF:1.00100, -#EXT-X-BYTERANGE:658420@24718808 -media-video-1.mp4 -#EXTINF:1.00100, -#EXT-X-BYTERANGE:658304@25377228 -media-video-1.mp4 -#EXTINF:1.00100, -#EXT-X-BYTERANGE:593870@26035532 -media-video-1.mp4 -#EXTINF:1.00100, -#EXT-X-BYTERANGE:606370@26629402 -media-video-1.mp4 -#EXTINF:1.00100, -#EXT-X-BYTERANGE:720134@27235772 -media-video-1.mp4 -#EXTINF:1.00100, -#EXT-X-BYTERANGE:605085@27955906 -media-video-1.mp4 -#EXTINF:1.00100, -#EXT-X-BYTERANGE:622832@28560991 -media-video-1.mp4 -#EXTINF:0.83417, -#EXT-X-BYTERANGE:489061@29183823 -media-video-1.mp4 -#EXT-X-ENDLIST diff --git a/contrib/opentimelineio_contrib/adapters/tests/test_hls_playlist_adapter.py b/contrib/opentimelineio_contrib/adapters/tests/test_hls_playlist_adapter.py deleted file mode 100644 index cac54afb0..000000000 --- a/contrib/opentimelineio_contrib/adapters/tests/test_hls_playlist_adapter.py +++ /dev/null @@ -1,701 +0,0 @@ -# SPDX-License-Identifier: Apache-2.0 -# Copyright Contributors to the OpenTimelineIO project - -import os -import unittest - -import opentimelineio as otio - -import tempfile - -# Reference data -SAMPLE_DATA_DIR = os.path.join(os.path.dirname(__file__), "sample_data") -HLS_EXAMPLE_PATH = os.path.join(SAMPLE_DATA_DIR, "v1_prog_index.m3u8") - -# Load the adapter module using otio -hls_playlist = otio.adapters.from_name("hls_playlist").module() - -MEM_PLAYLIST_REF_VALUE = """#EXTM3U -#EXT-X-VERSION:7 -#EXT-X-INDEPENDENT-SEGMENTS -#EXT-X-MEDIA-SEQUENCE:1 -#EXT-X-PLAYLIST-TYPE:VOD -#EXT-X-TARGETDURATION:2 -#EXT-X-MAP:BYTERANGE="729@0",URI="media-video-1.mp4" -#EXTINF:2.00200, -#EXT-X-BYTERANGE:534220@1361 -video1.mp4 -#EXT-X-ENDLIST""" - -MEM_MASTER_PLAYLIST_REF_VALUE = """#EXTM3U -#EXT-X-VERSION:6 -#EXT-X-MEDIA:GROUP-ID="aud1",NAME="a1",TYPE=AUDIO,URI="a1/prog_index.m3u8" -#EXT-X-STREAM-INF:AUDIO="aud1",BANDWIDTH=135801,CODECS="avc.test,aac.test",FRAME-RATE=23.976,RESOLUTION=1920x1080 -v1/prog_index.m3u8""" - -MEM_IFRAME_MASTER_PLAYLIST_REF_VALUE = """#EXTM3U -#EXT-X-VERSION:6 -#EXT-X-MEDIA:GROUP-ID="aud1",NAME="a1",TYPE=AUDIO,URI="a1/prog_index.m3u8" -#EXT-X-I-FRAME-STREAM-INF:BANDWIDTH=123456,CODECS="avc.test",RESOLUTION=1920x1080,URI="v1/iframe_index.m3u8" -#EXT-X-STREAM-INF:AUDIO="aud1",BANDWIDTH=135801,CODECS="avc.test,aac.test",FRAME-RATE=23.976,RESOLUTION=1920x1080 -v1/prog_index.m3u8""" - -MEM_COMPLEX_MASTER_PLAYLIST_REF_VALUE = """#EXTM3U -#EXT-X-VERSION:6 -#EXT-X-MEDIA:GROUP-ID="aud1",NAME="a1",TYPE=AUDIO,URI="a1/prog_index.m3u8" -#EXT-X-I-FRAME-STREAM-INF:BANDWIDTH=123456,CODECS="avc.test",RESOLUTION=1920x1080,URI="v1/iframe_index.m3u8" -#EXT-X-I-FRAME-STREAM-INF:BANDWIDTH=12345,CODECS="avc.test",RESOLUTION=720x480,URI="v2/iframe_index.m3u8" -#EXT-X-STREAM-INF:AUDIO="aud1",BANDWIDTH=135801,CODECS="avc.test,aac.test",FRAME-RATE=23.976,RESOLUTION=1920x1080 -v1/prog_index.m3u8 -#EXT-X-STREAM-INF:AUDIO="aud1",BANDWIDTH=24690,CODECS="avc.test,aac.test",FRAME-RATE=23.976,RESOLUTION=720x480 -v2/prog_index.m3u8""" - -MEM_SINGLE_TRACK_MASTER_PLAYLIST_REF_VALUE = """#EXTM3U -#EXT-X-VERSION:6 -#EXT-X-I-FRAME-STREAM-INF:BANDWIDTH=123456,CODECS="avc.test",RESOLUTION=1920x1080,URI="v1/iframe_index.m3u8" -#EXT-X-STREAM-INF:BANDWIDTH=123456,CODECS="avc.test",FRAME-RATE=23.976,RESOLUTION=1920x1080 -v1/prog_index.m3u8""" - - -class HLSPlaylistDataStructuresTest(unittest.TestCase): - """ Test the lower-level HLS Data structures """ - - def test_hls_attribute_list(self): - """ Test the HLS adapter's attribute list parser """ - attribute_list_string = 'HEXTHING=0xFAF,FIRST-NEGFLOAT=-1.25,'\ - 'STRTHING="foo, bar",DECIMALTHING=123456,FLOATTHING=1.233,'\ - 'ENUMTHING=0xeS1,NEGFLOAT=-3.14' - attribute_list_dictionary = { - "HEXTHING": 0xFAF, - "FIRST-NEGFLOAT": -1.25, - "STRTHING": "foo, bar", - "DECIMALTHING": 123456, - "FLOATTHING": 1.233, - "ENUMTHING": "0xeS1", - "NEGFLOAT": -3.14 - } - attr_list = hls_playlist.AttributeList.from_string( - attribute_list_string - ) - - self.assertEqual(len(attr_list), len(attribute_list_dictionary)) - for attrName, attrValue in attr_list.items(): - self.assertEqual(attrValue, attribute_list_dictionary[attrName]) - - def test_playlist_tag_exclusivity(self): - """ Test that mutually-exclusive tag types don't overlap """ - # see sections 4.3.2, 4.3.3, and 4.3.4 of - # draft-pantos-http-live-streaming for more information about these - # constraints - - non_master_tags = hls_playlist.MEDIA_SEGMENT_TAGS.union( - hls_playlist.MEDIA_PLAYLIST_TAGS) - - common_tags = non_master_tags.intersection( - hls_playlist.MASTER_PLAYLIST_TAGS) - self.assertEqual(len(common_tags), 0) - - -class HLSPMedialaylistAdapterTest(unittest.TestCase): - """ Test the HLS Playlist adapter media playlist functionality """ - - def test_media_pl_from_mem(self): - t = otio.schema.Timeline() - track = otio.schema.Track("v1") - track.metadata['HLS'] = { - "EXT-X-INDEPENDENT-SEGMENTS": None, - "EXT-X-PLAYLIST-TYPE": "VOD" - } - t.tracks.append(track) - - # Make a prototype media ref with the segment's initialization metadata - segmented_media_ref = otio.schema.ExternalReference( - target_url='video1.mp4', - metadata={ - "streaming": { - "init_byterange": { - "byte_count": 729, - "byte_offset": 0 - }, - "init_uri": "media-video-1.mp4" - } - } - ) - - # Make a copy of the media ref specifying the byte range for the - # segment - media_ref1 = segmented_media_ref.deepcopy() - media_ref1.available_range = otio.opentime.TimeRange( - otio.opentime.RationalTime(0, 1), - otio.opentime.RationalTime(2.002, 1) - ) - media_ref1.metadata['streaming'].update( - { - "byte_count": 534220, - "byte_offset": 1361 - } - ) - - # make the segment and append it - segment1 = otio.schema.Clip(media_reference=media_ref1) - track.append(segment1) - - # Write out and validate the playlist - with tempfile.TemporaryDirectory() as temp_dir: - media_pl_tmp_path = os.path.join( - temp_dir, - "test_media_pl_from_mem.m3u8" - ) - otio.adapters.write_to_file(t, media_pl_tmp_path) - - with open(media_pl_tmp_path) as f: - pl_string = f.read() - - # Compare against the reference value - self.assertEqual(pl_string, MEM_PLAYLIST_REF_VALUE) - - def _validate_sample_playlist(self, timeline): - # Validate the track count - self.assertEqual(len(timeline.tracks), 1) - track = timeline.tracks[0] - - # Validate the track global metadata - self.assertNotEqual(track.metadata['HLS'], {}) - track_metadata = track.metadata['HLS'] - self.assertEqual(track_metadata, { - 'EXT-X-INDEPENDENT-SEGMENTS': None, - 'EXT-X-VERSION': '7', - 'EXT-X-PLAYLIST-TYPE': 'VOD'} - ) - - # There are 50 segments (clips) - # Validate the count, "sequence_num", and durations - self.assertEqual(len(track), 50) - start_seq_num = int(track[0].metadata['streaming']['sequence_num']) - segment_durations = otio.opentime.RationalTime(1.001, 1) - for seq_num, clip in enumerate(track, start_seq_num): - self.assertEqual( - clip.metadata['streaming']['sequence_num'], - seq_num - ) - if seq_num < 50: - self.assertEqual(clip.duration(), segment_durations) - else: - # The last segment has a shorter duration - self.assertEqual( - clip.duration(), - otio.opentime.RationalTime(0.83417, 1) - ) - - # Spot-check a segment - segment_5 = track[4] - seg_5_media_ref = segment_5.media_reference - seg_5_ref_streaming_metadata = seg_5_media_ref.metadata['streaming'] - self.assertEqual( - seg_5_ref_streaming_metadata['byte_count'], - 593718 - ) - self.assertEqual( - seg_5_ref_streaming_metadata['byte_offset'], - 2430668 - ) - self.assertEqual( - seg_5_ref_streaming_metadata['init_byterange']['byte_count'], - 729 - ) - self.assertEqual( - seg_5_ref_streaming_metadata['init_byterange']['byte_offset'], - 0 - ) - self.assertEqual( - seg_5_ref_streaming_metadata['init_uri'], - "media-video-1.mp4" - ) - self.assertEqual( - seg_5_media_ref.target_url, - "media-video-1.mp4" - ) - - def test_media_roundtrip(self): - hls_path = HLS_EXAMPLE_PATH - timeline = otio.adapters.read_from_file(hls_path) - - # validate the read-in playlist matches reference data - self._validate_sample_playlist(timeline) - - # Write out and validate both playlists have the same lines - with tempfile.TemporaryDirectory() as temp_dir: - media_pl_tmp_path = os.path.join( - temp_dir, - "test_media_roundtrip.m3u8" - ) - otio.adapters.write_to_file(timeline, media_pl_tmp_path) - - # Read in both playlists - with open(hls_path) as f: - reference_lines = f.readlines() - - with open(media_pl_tmp_path) as f: - adapter_out_lines = f.readlines() - - # Using otio as well - in_timeline = otio.adapters.read_from_file(media_pl_tmp_path) - - # Strip newline chars - reference_lines = [line.strip('\n') for line in reference_lines] - adapter_out_lines = [line.strip('\n') for line in adapter_out_lines] - - # Compare the lines - self.assertEqual(reference_lines, adapter_out_lines) - - # validate the otio of the playlist we wrote - self._validate_sample_playlist(in_timeline) - - def test_media_segment_size(self): - hls_path = HLS_EXAMPLE_PATH - timeline = otio.adapters.read_from_file(hls_path) - - # validate the read-in playlist matches reference data - self._validate_sample_playlist(timeline) - - # Set the sement size to ~six seconds - timeline_streaming_md = timeline.metadata.setdefault('streaming', {}) - seg_min_duration = otio.opentime.RationalTime(6, 1) - timeline_streaming_md['min_segment_duration'] = seg_min_duration - seg_max_duration = otio.opentime.RationalTime( - (60 * 60 * 24), - 1 - ) - timeline_streaming_md['max_segment_duration'] = seg_max_duration - - # Write out the playlist - with tempfile.TemporaryDirectory() as temp_dir: - media_pl_tmp_path = os.path.join( - temp_dir, - "test_media_segment_size.m3u8" - ) - otio.adapters.write_to_file(timeline, media_pl_tmp_path) - - # Read in the playlist - in_timeline = otio.adapters.read_from_file(media_pl_tmp_path) - - # Pick a duration that segments won't exceed but is less than max - seg_upper_duration = otio.opentime.RationalTime(7, 1) - - # When reading an HLS playlist, segments become clips. Check clip - # durations (except the last one since it's the leftover) - for clip in in_timeline.tracks[0][:-1]: - self.assertTrue(clip.duration() >= seg_min_duration) - self.assertTrue(clip.duration() < seg_upper_duration) - - # Check the last segment duration - last_clip = in_timeline.tracks[0][-1] - self.assertTrue(last_clip.duration() < seg_min_duration) - self.assertTrue( - last_clip.duration() > otio.opentime.RationalTime( - 0, 1 - ) - ) - - def test_iframe_segment_size(self): - hls_path = HLS_EXAMPLE_PATH - timeline = otio.adapters.read_from_file(hls_path) - - # the reference playlist is one segment per keyframe, pluck the first - # segment duration as reference for keyframe duration - keyframe_duration = timeline.tracks[0][0].duration() - - # validate the read-in playlist matches reference data - self._validate_sample_playlist(timeline) - - # Set the sement size to ~six seconds - timeline_streaming_md = timeline.metadata.setdefault('streaming', {}) - seg_min_duration = otio.opentime.RationalTime(6, 1) - timeline_streaming_md['min_segment_duration'] = seg_min_duration - seg_max_duration = otio.opentime.RationalTime( - (60 * 60 * 24), - 1 - ) - timeline_streaming_md['max_segment_duration'] = seg_max_duration - - # Configure the playlist to be an iframe list - track_hls_metadata = timeline.tracks[0].metadata['HLS'] - del track_hls_metadata['EXT-X-INDEPENDENT-SEGMENTS'] - track_hls_metadata['EXT-X-I-FRAMES-ONLY'] = None - - # Write out the playlist - with tempfile.TemporaryDirectory() as temp_dir: - media_pl_tmp_path = os.path.join( - temp_dir, - "test_iframe_segment_size.m3u8" - ) - otio.adapters.write_to_file(timeline, media_pl_tmp_path) - - # Read in the playlist - in_timeline = otio.adapters.read_from_file(media_pl_tmp_path) - with open(media_pl_tmp_path) as f: - pl_lines = f.readlines() - pl_lines = [line.strip('\n') for line in pl_lines] - - # validate the TARGETDURATION value is correct - self.assertTrue('#EXT-X-TARGETDURATION:6' in pl_lines) - self.assertTrue('#EXT-X-MEDIA-SEQUENCE:0' in pl_lines) - self.assertEqual(len(timeline.tracks), len(in_timeline.tracks)) - self.assertEqual(len(timeline.tracks[0]), len(in_timeline.tracks[0])) - - # The segments should all be 1.001 seconds like the original input - seg_upper_duration = otio.opentime.RationalTime(1.1, 1) - - # When reading an HLS playlist, segments become clips. Check clip - # durations (except the last one since it's the leftover) - for clip in in_timeline.tracks[0][:-1]: - self.assertTrue(clip.duration() == keyframe_duration) - self.assertTrue(clip.duration() < seg_upper_duration) - - # Check the last segment duration - last_clip = in_timeline.tracks[0][-1] - self.assertTrue(last_clip.duration() < seg_min_duration) - self.assertTrue( - last_clip.duration() > otio.opentime.RationalTime(0, 1) - ) - - -class HLSPMasterPlaylistAdapterTest(unittest.TestCase): - """ Test the HLS Playlist adapter master playlist functionality """ - - def test_simple_master_pl_from_mem(self): - t = otio.schema.Timeline() - - # add a video track - vtrack = otio.schema.Track( - "v1", - kind=otio.schema.TrackKind.Video - ) - vtrack.metadata.update( - { - 'streaming': { - 'bandwidth': 123456, - 'codec': 'avc.test', - 'width': 1920, - 'height': 1080, - 'frame_rate': 23.976, - }, - 'HLS': { - 'uri': 'v1/prog_index.m3u8' - } - } - ) - t.tracks.append(vtrack) - - # add an audio track - atrack = otio.schema.Track( - "a1", - kind=otio.schema.TrackKind.Audio - ) - atrack.metadata.update( - { - 'linked_tracks': [vtrack.name], - 'streaming': { - 'bandwidth': 12345, - 'codec': 'aac.test', - 'group_id': 'aud1', - }, - 'HLS': { - 'uri': 'a1/prog_index.m3u8' - } - } - ) - t.tracks.append(atrack) - - # Write out and validate the playlist - with tempfile.TemporaryDirectory() as temp_dir: - media_pl_tmp_path = os.path.join( - temp_dir, - "master.m3u8" - ) - otio.adapters.write_to_file(t, media_pl_tmp_path) - - with open(media_pl_tmp_path) as f: - pl_string = f.read() - - # Drop blank lines before comparing - pl_string = '\n'.join(line for line in pl_string.split('\n') if line) - - # Compare against the reference value - self.assertEqual(pl_string, MEM_MASTER_PLAYLIST_REF_VALUE) - - def test_master_pl_with_iframe_pl_from_mem(self): - t = otio.schema.Timeline() - - # add a video track - vtrack = otio.schema.Track( - "v1", - kind=otio.schema.TrackKind.Video - ) - vtrack.metadata.update( - { - 'streaming': { - 'bandwidth': 123456, - 'codec': 'avc.test', - 'width': 1920, - 'height': 1080, - 'frame_rate': 23.976, - }, - 'HLS': { - 'uri': 'v1/prog_index.m3u8', - 'iframe_uri': 'v1/iframe_index.m3u8' - } - } - ) - t.tracks.append(vtrack) - - # add an audio track - atrack = otio.schema.Track( - "a1", - kind=otio.schema.TrackKind.Audio - ) - atrack.metadata.update( - { - 'linked_tracks': [vtrack.name], - 'streaming': { - 'bandwidth': 12345, - 'codec': 'aac.test', - 'group_id': 'aud1', - }, - 'HLS': { - 'uri': 'a1/prog_index.m3u8' - } - } - ) - t.tracks.append(atrack) - - # Write out and validate the playlist - with tempfile.TemporaryDirectory() as temp_dir: - media_pl_tmp_path = os.path.join( - temp_dir, - "master.m3u8" - ) - otio.adapters.write_to_file(t, media_pl_tmp_path) - - with open(media_pl_tmp_path) as f: - pl_string = f.read() - - # Drop blank lines before comparing - pl_string = '\n'.join(line for line in pl_string.split('\n') if line) - - # Compare against the reference value - self.assertEqual(pl_string, MEM_IFRAME_MASTER_PLAYLIST_REF_VALUE) - - def test_master_pl_complex_from_mem(self): - t = otio.schema.Timeline() - - # add a video track - vtrack = otio.schema.Track( - "v1", - kind=otio.schema.TrackKind.Video - ) - vtrack.metadata.update( - { - 'streaming': { - 'bandwidth': 123456, - 'codec': 'avc.test', - 'width': 1920, - 'height': 1080, - 'frame_rate': 23.976, - }, - 'HLS': { - 'uri': 'v1/prog_index.m3u8', - 'iframe_uri': 'v1/iframe_index.m3u8' - } - } - ) - t.tracks.append(vtrack) - - # add an alternate video track rep - v2track = otio.schema.Track( - "v2", - kind=otio.schema.TrackKind.Video - ) - v2track.metadata.update( - { - 'streaming': { - 'bandwidth': 12345, - 'codec': 'avc.test', - 'width': 720, - 'height': 480, - 'frame_rate': 23.976, - }, - 'HLS': { - 'uri': 'v2/prog_index.m3u8', - 'iframe_uri': 'v2/iframe_index.m3u8' - } - } - ) - t.tracks.append(v2track) - - # add an audio track - atrack = otio.schema.Track( - "a1", - kind=otio.schema.TrackKind.Audio - ) - atrack.metadata.update( - { - 'linked_tracks': [vtrack.name, v2track.name], - 'streaming': { - 'bandwidth': 12345, - 'codec': 'aac.test', - 'group_id': 'aud1', - }, - 'HLS': { - 'uri': 'a1/prog_index.m3u8' - } - } - ) - t.tracks.append(atrack) - - # Write out and validate the playlist - with tempfile.TemporaryDirectory() as temp_dir: - media_pl_tmp_path = os.path.join( - temp_dir, - "master.m3u8" - ) - otio.adapters.write_to_file(t, media_pl_tmp_path) - - with open(media_pl_tmp_path) as f: - pl_string = f.read() - - # Drop blank lines before comparing - pl_string = '\n'.join(line for line in pl_string.split('\n') if line) - - # Compare against the reference value - self.assertEqual(pl_string, MEM_COMPLEX_MASTER_PLAYLIST_REF_VALUE) - - def test_master_playlist_hint_metadata(self): - """ - Test that URL hints for master playlists don't leak out to media - playlsits. - """ - # Start with the reference playlist - hls_path = HLS_EXAMPLE_PATH - timeline = otio.adapters.read_from_file(hls_path) - - # add master playlist metadata to the track - timeline.tracks[0].metadata.update( - { - 'bandwidth': 123456, - 'codec': 'avc.test', - 'width': 1920, - 'height': 1080, - 'frame_rate': 23.976, - 'HLS': { - 'uri': 'v1/prog_index.m3u8', - 'iframe_uri': 'v1/iframe_index.m3u8' - } - } - ) - - # Write out and validate the playlist - with tempfile.TemporaryDirectory() as temp_dir: - media_pl_tmp_path = os.path.join( - temp_dir, - "test_media_pl_from_mem.m3u8" - ) - otio.adapters.write_to_file(timeline, media_pl_tmp_path) - - with open(media_pl_tmp_path) as f: - pl_string = f.read() - - # ensure metadata that wasn't supposed to didn't leak out - for line in pl_string.split('\n'): - self.assertFalse(line.startswith('#uri:')) - self.assertFalse(line.startswith('#iframe_uri:')) - - def test_explicit_master_pl_from_mem(self): - """Test that forcing a master playlist for a single track timeline - works. - """ - t = otio.schema.Timeline() - # Set the master playlist flag - t.metadata.update( - { - 'HLS': { - 'master_playlist': True - } - } - ) - - # build a track - track = otio.schema.Track('v1') - track.metadata.update( - { - 'streaming': { - 'bandwidth': 123456, - 'codec': 'avc.test', - 'width': 1920, - 'height': 1080, - 'frame_rate': 23.976, - }, - 'HLS': { - 'EXT-X-INDEPENDENT-SEGMENTS': None, - 'EXT-X-PLAYLIST-TYPE': 'VOD', - 'uri': 'v1/prog_index.m3u8', - 'iframe_uri': 'v1/iframe_index.m3u8' - } - } - ) - t.tracks.append(track) - - # Make a prototype media ref with the segment's initialization metadata - segmented_media_ref = otio.schema.ExternalReference( - target_url='video1.mp4', - metadata={ - 'streaming': { - 'init_byterange': { - 'byte_count': 729, - 'byte_offset': 0 - }, - 'init_uri': 'media-video-1.mp4' - } - } - ) - - # Make a copy of the media ref specifying the byte range for the - # segment - media_ref1 = segmented_media_ref.deepcopy() - media_ref1.available_range = otio.opentime.TimeRange( - otio.opentime.RationalTime(0, 1), - otio.opentime.RationalTime(2.002, 1) - ) - media_ref1.metadata.update( - { - 'streaming': { - 'byte_count': 534220, - 'byte_offset': 1361 - } - } - ) - - # make the segment and append it - segment1 = otio.schema.Clip(media_reference=media_ref1) - track.append(segment1) - - # Write out and validate the playlist - with tempfile.TemporaryDirectory() as temp_dir: - master_pl_tmp_path = os.path.join( - temp_dir, - "master.m3u8" - ) - otio.adapters.write_to_file(t, master_pl_tmp_path) - - with open(master_pl_tmp_path) as f: - pl_string = f.read() - - # Drop blank lines before comparing - pl_string = '\n'.join(line for line in pl_string.split('\n') if line) - - # Compare against the reference value - self.assertEqual(pl_string, MEM_SINGLE_TRACK_MASTER_PLAYLIST_REF_VALUE) - - -if __name__ == '__main__': - unittest.main() diff --git a/docs/tutorials/adapters.md b/docs/tutorials/adapters.md index ddb4c4522..d94e3cb9d 100644 --- a/docs/tutorials/adapters.md +++ b/docs/tutorials/adapters.md @@ -50,10 +50,6 @@ The contrib area hosts adapters which come from the community (_not_ supported - set `${OTIO_RV_PYTHON_LIB}` to point at the parent directory of `rvSession.py`: `setenv OTIO_RV_PYTHON_LIB /Applications/RV64.app/Contents/src/python` -## HLS Playlist - -- Status: supported via the `hls_playlist` adapter. - ## Avid Log Exchange (ALE) - Status: supported via the `ale` adapter. diff --git a/docs/tutorials/otio-plugins.md b/docs/tutorials/otio-plugins.md index 6e6f6a02d..36142f62a 100644 --- a/docs/tutorials/otio-plugins.md +++ b/docs/tutorials/otio-plugins.md @@ -286,125 +286,6 @@ required OTIO function hook -### hls_playlist - -``` -HLS Playlist OpenTimelineIO adapter - -This adapter supports authoring of HLS playlists within OpenTimelineIO by using -clips to represent media fragments. - -Status: - - Export of Media Playlists well supported - - Export of Master Playlists supported - - Import of Media Playlists well supported - - Import of Master Playlists unsupported - - Explicit Variant Stream controls in Master Playlists unsupported - -In general, you can author otio as follows: - t = otio.schema.Timeline() - track = otio.schema.Track("v1") - track.metadata['HLS'] = { - "EXT-X-INDEPENDENT-SEGMENTS": None, - "EXT-X-PLAYLIST-TYPE": "VOD" - } - t.tracks.append(track) - - # Make a prototype media ref with the fragment's initialization metadata - fragmented_media_ref = otio.schema.ExternalReference( - target_url='video1.mp4', - metadata={ - "streaming": { - "init_byterange": { - "byte_count": 729, - "byte_offset": 0 - }, - "init_uri": "media-video-1.mp4" - } - } - ) - - # Make a copy of the media ref specifying the byte range for the fragment - media_ref1 = fragmented_media_ref.deepcopy() - media_ref1.available_range=otio.opentime.TimeRange( - otio.opentime.RationalTime(0, 1), - otio.opentime.RationalTime(2.002, 1) - ) - media_ref1.metadata['streaming'].update( - { - "byte_count": 534220, - "byte_offset": 1361 - } - ) - - # make the fragment and append it - fragment1 = otio.schema.Clip(media_reference=media_ref1) - track.append(fragment1) - - # (repeat to define each fragment) - -The code above would yield an HLS playlist like: - #EXTM3U - #EXT-X-VERSION:7 - #EXT-X-TARGETDURATION:2 - #EXT-X-PLAYLIST-TYPE:VOD - #EXT-X-INDEPENDENT-SEGMENTS - #EXT-X-MEDIA-SEQUENCE:1 - #EXT-X-MAP:BYTERANGE="729@0",URI="media-video-1.mp4" - #EXTINF:2.00200, - #EXT-X-BYTERANGE:534220@1361 - video1.mp4 - #EXT-X-ENDLIST - -If you add min_segment_duration and max_segment_duration to the timeline's -metadata dictionary as RationalTime objects, you can control the rule set -deciding how many fragments to accumulate into a single segment. When nothing -is specified for these metadata keys, the adapter will create one segment per -fragment. - -In general, any metadata added to the track metadata dict under the HLS -namespace will be included at the top level of the exported playlist (see -``EXT-X-INDEPENDENT-SEGMENTS`` and ``EXT-X-PLAYLIST-TYPE`` in the example -above). Each segment will pass through any metadata in the HLS namespace from -the media_reference. - -If you write a Timeline with more than one track specified, then the adapter -will create an HLS master playlist. - -The following track metadata keys will be used to inform exported master -playlist metadata per variant stream: - bandwidth - codec - language - mimeType - group_id (audio) - autoselect (audio) - default (audio) -These values are translated to EXT-X-STREAM-INF and EXT-X-MEDIA -attributes as defined in sections 4.3.4.2 and 4.3.4.1 of -draft-pantos-http-live-streaming, respectively. -``` - -*source*: `opentimelineio_contrib/adapters/hls_playlist.py` - - -*Supported Features (with arguments)*: - -- read_from_string: -``` -Adapter entry point for reading. -``` - - input_str -- write_to_string: -``` -Adapter entry point for writing. -``` - - input_otio - - - - - ### xges ```