Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
111 changes: 111 additions & 0 deletions homeassistant/components/stream/fmp4utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -36,3 +36,114 @@ def get_m4s(segment: io.BytesIO, sequence: int) -> bytes:
mfra_location = next(find_box(segment, b"mfra"))
segment.seek(moof_location)
return segment.read(mfra_location - moof_location)


def get_codec_string(segment: io.BytesIO) -> str:
"""Get RFC 6381 codec string."""
codecs = []

# Find moov
moov_location = next(find_box(segment, b"moov"))

# Find tracks
for trak_location in find_box(segment, b"trak", moov_location):
# Drill down to media info
mdia_location = next(find_box(segment, b"mdia", trak_location))
minf_location = next(find_box(segment, b"minf", mdia_location))
stbl_location = next(find_box(segment, b"stbl", minf_location))
stsd_location = next(find_box(segment, b"stsd", stbl_location))

# Get stsd box
segment.seek(stsd_location)
stsd_length = int.from_bytes(segment.read(4), byteorder="big")
segment.seek(stsd_location)
stsd_box = segment.read(stsd_length)

# Base Codec
codec = stsd_box[20:24].decode("utf-8")

# Handle H264
if (
codec in ("avc1", "avc2", "avc3", "avc4")
and stsd_length > 110
and stsd_box[106:110] == b"avcC"
):
profile = stsd_box[111:112].hex()
compatibility = stsd_box[112:113].hex()
level = stsd_box[113:114].hex()
codec += "." + profile + compatibility + level

# Handle H265
elif (
codec in ("hev1", "hvc1")
and stsd_length > 110
and stsd_box[106:110] == b"hvcC"
):
tmp_byte = int.from_bytes(stsd_box[111:112], byteorder="big")

# Profile Space
codec += "."
profile_space_map = {0: "", 1: "A", 2: "B", 3: "C"}
profile_space = tmp_byte >> 6
codec += profile_space_map[profile_space]
general_profile_idc = tmp_byte & 31
codec += str(general_profile_idc)

# Compatibility
codec += "."
general_profile_compatibility = int.from_bytes(
stsd_box[112:116], byteorder="big"
)
reverse = 0
for i in range(0, 32):
reverse |= general_profile_compatibility & 1
if i == 31:
break
reverse <<= 1
general_profile_compatibility >>= 1
codec += hex(reverse)[2:]

# Tier Flag
if (tmp_byte & 32) >> 5 == 0:
codec += ".L"
else:
codec += ".H"
codec += str(int.from_bytes(stsd_box[122:123], byteorder="big"))

# Constraint String
has_byte = False
constraint_string = ""
for i in range(121, 115, -1):
gci = int.from_bytes(stsd_box[i : i + 1], byteorder="big")
if gci or has_byte:
constraint_string = "." + hex(gci)[2:] + constraint_string
has_byte = True
codec += constraint_string

# Handle Audio
elif codec == "mp4a":
oti = None
dsi = None

# Parse ES Descriptors
oti_loc = stsd_box.find(b"\x04\x80\x80\x80")
if oti_loc > 0:
oti = stsd_box[oti_loc + 5 : oti_loc + 6].hex()
codec += f".{oti}"

dsi_loc = stsd_box.find(b"\x05\x80\x80\x80")
if dsi_loc > 0:
dsi_length = int.from_bytes(
stsd_box[dsi_loc + 4 : dsi_loc + 5], byteorder="big"
)
dsi_data = stsd_box[dsi_loc + 5 : dsi_loc + 5 + dsi_length]
dsi0 = int.from_bytes(dsi_data[0:1], byteorder="big")
dsi = (dsi0 & 248) >> 3
if dsi == 31 and len(dsi_data) >= 2:
dsi1 = int.from_bytes(dsi_data[1:2], byteorder="big")
dsi = 32 + ((dsi0 & 7) << 3) + ((dsi1 & 224) >> 5)
codec += f".{dsi}"

codecs.append(codec)
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should we check if len(codec)>4 before appending?

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I thought about that, but the MP4box library does fall back to returning the base codec string of the additional features aren’t present. While we know that won’t work for chromecast, neither will leaving the codec out...

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

That makes sense. Since we're generating the segments ourselves hopefully there isn't much variability which would break the parsing.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can you check this against your setup? I made the master playlist the default, so it should work in Lovelace as well. I tested against a generated H265 video, but didn’t change the codec on any of my cameras.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Tested against an online H264 stream and a H265 camera stream. Both worked with no problem in Lovelace. Checked the codec profiles/constraints against what an online parser returns and they match.


return ",".join(codecs)
126 changes: 76 additions & 50 deletions homeassistant/components/stream/hls.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
"""Provide functionality to stream HLS."""
import io
from typing import Callable

from aiohttp import web
Expand All @@ -7,7 +8,7 @@

from .const import FORMAT_CONTENT_TYPE
from .core import PROVIDERS, StreamOutput, StreamView
from .fmp4utils import get_init, get_m4s
from .fmp4utils import get_codec_string, get_init, get_m4s


@callback
Expand All @@ -16,7 +17,43 @@ def async_setup_hls(hass):
hass.http.register_view(HlsPlaylistView())
hass.http.register_view(HlsSegmentView())
hass.http.register_view(HlsInitView())
return "/api/hls/{}/playlist.m3u8"
hass.http.register_view(HlsMasterPlaylistView())
return "/api/hls/{}/master_playlist.m3u8"


class HlsMasterPlaylistView(StreamView):
"""Stream view used only for Chromecast compatibility."""

url = r"/api/hls/{token:[a-f0-9]+}/master_playlist.m3u8"
name = "api:stream:hls:master_playlist"
cors_allowed = True

@staticmethod
def render(track):
"""Render M3U8 file."""
# Need to calculate max bandwidth as input_container.bit_rate doesn't seem to work
# Calculate file size / duration and use a multiplier to account for variation
segment = track.get_segment(track.segments[-1])
bandwidth = round(
segment.segment.seek(0, io.SEEK_END) * 8 / segment.duration * 3
)
codecs = get_codec_string(segment.segment)
lines = [
"#EXTM3U",
f'#EXT-X-STREAM-INF:BANDWIDTH={bandwidth},CODECS="{codecs}"',
"playlist.m3u8",
]
return "\n".join(lines) + "\n"

async def handle(self, request, stream, sequence):
"""Return m3u8 playlist."""
track = stream.add_provider("hls")
stream.start()
# Wait for a segment to be ready
if not track.segments:
await track.recv()
headers = {"Content-Type": FORMAT_CONTENT_TYPE["hls"]}
return web.Response(body=self.render(track).encode("utf-8"), headers=headers)


class HlsPlaylistView(StreamView):
Expand All @@ -26,18 +63,50 @@ class HlsPlaylistView(StreamView):
name = "api:stream:hls:playlist"
cors_allowed = True

@staticmethod
def render_preamble(track):
"""Render preamble."""
return [
"#EXT-X-VERSION:7",
f"#EXT-X-TARGETDURATION:{track.target_duration}",
'#EXT-X-MAP:URI="init.mp4"',
]

@staticmethod
def render_playlist(track):
"""Render playlist."""
segments = track.segments

if not segments:
return []

playlist = ["#EXT-X-MEDIA-SEQUENCE:{}".format(segments[0])]

for sequence in segments:
segment = track.get_segment(sequence)
playlist.extend(
[
"#EXTINF:{:.04f},".format(float(segment.duration)),
f"./segment/{segment.sequence}.m4s",
]
)

return playlist

def render(self, track):
"""Render M3U8 file."""
lines = ["#EXTM3U"] + self.render_preamble(track) + self.render_playlist(track)
return "\n".join(lines) + "\n"

async def handle(self, request, stream, sequence):
"""Return m3u8 playlist."""
renderer = M3U8Renderer(stream)
track = stream.add_provider("hls")
stream.start()
# Wait for a segment to be ready
if not track.segments:
await track.recv()
headers = {"Content-Type": FORMAT_CONTENT_TYPE["hls"]}
return web.Response(
body=renderer.render(track).encode("utf-8"), headers=headers
)
return web.Response(body=self.render(track).encode("utf-8"), headers=headers)


class HlsInitView(StreamView):
Expand Down Expand Up @@ -77,49 +146,6 @@ async def handle(self, request, stream, sequence):
)


class M3U8Renderer:
"""M3U8 Render Helper."""

def __init__(self, stream):
"""Initialize renderer."""
self.stream = stream

@staticmethod
def render_preamble(track):
"""Render preamble."""
return [
"#EXT-X-VERSION:7",
f"#EXT-X-TARGETDURATION:{track.target_duration}",
'#EXT-X-MAP:URI="init.mp4"',
]

@staticmethod
def render_playlist(track):
"""Render playlist."""
segments = track.segments

if not segments:
return []

playlist = ["#EXT-X-MEDIA-SEQUENCE:{}".format(segments[0])]

for sequence in segments:
segment = track.get_segment(sequence)
playlist.extend(
[
"#EXTINF:{:.04f},".format(float(segment.duration)),
f"./segment/{segment.sequence}.m4s",
]
)

return playlist

def render(self, track):
"""Render M3U8 file."""
lines = ["#EXTM3U"] + self.render_preamble(track) + self.render_playlist(track)
return "\n".join(lines) + "\n"


@PROVIDERS.register("hls")
class HlsStreamOutput(StreamOutput):
"""Represents HLS Output formats."""
Expand All @@ -137,7 +163,7 @@ def format(self) -> str:
@property
def audio_codecs(self) -> str:
"""Return desired audio codecs."""
return {"aac", "ac3", "mp3"}
return {"aac", "mp3"}

@property
def video_codecs(self) -> tuple:
Expand Down
2 changes: 1 addition & 1 deletion homeassistant/components/stream/recorder.py
Original file line number Diff line number Diff line change
Expand Up @@ -78,7 +78,7 @@ def format(self) -> str:
@property
def audio_codecs(self) -> str:
"""Return desired audio codec."""
return {"aac", "ac3", "mp3"}
return {"aac", "mp3"}

@property
def video_codecs(self) -> tuple:
Expand Down