Implement Cast Streaming mirroring, DLNA casting, daemon+GUI, and breadd integration
Some checks failed
dev release / build (push) Failing after 12s
Some checks failed
dev release / build (push) Failing after 12s
Builds out the full v1 scope: a vendored+patched openscreen subset for low-latency Cast Streaming (Mirroring receiver 0F5096E8) alongside the existing Cast V2/HLS and new DLNA/AVTransport casting paths, breadcastd's Idle/Casting state machine with a private IPC socket, the breadcast GTK4 popup as a thin IPC client, and bread.cast.*/bread.command.cast.* breadd integration (device discovery, start/stop, mirroring lifecycle events). Also adds bakery/systemd/Forgejo CI packaging. Validated end-to-end against a real Chromecast/Google TV: negotiated Cast Streaming session, live pipeline playback, and daemon+GUI click-to-cast/ stop through the actual popup.
This commit is contained in:
parent
887c29002f
commit
8c745d18e0
283 changed files with 36788 additions and 0 deletions
487
breadcast-caststream-sys/vendor/openscreen/cast/streaming/public/offer_messages.cc
vendored
Normal file
487
breadcast-caststream-sys/vendor/openscreen/cast/streaming/public/offer_messages.cc
vendored
Normal file
|
|
@ -0,0 +1,487 @@
|
|||
// Copyright 2019 The Chromium Authors
|
||||
// Use of this source code is governed by a BSD-style license that can be
|
||||
// found in the LICENSE file.
|
||||
|
||||
#include "cast/streaming/public/offer_messages.h"
|
||||
|
||||
#include <inttypes.h>
|
||||
|
||||
#include <algorithm>
|
||||
#include <limits>
|
||||
#include <ranges>
|
||||
#include <string>
|
||||
#include <string_view>
|
||||
#include <utility>
|
||||
|
||||
#include "cast/streaming/public/constants.h"
|
||||
#include "platform/base/error.h"
|
||||
#include "util/big_endian.h"
|
||||
#include "util/enum_name_table.h"
|
||||
#include "util/json/json_helpers.h"
|
||||
#include "util/json/json_serialization.h"
|
||||
#include "util/osp_logging.h"
|
||||
#include "util/string_util.h"
|
||||
#include "util/stringprintf.h"
|
||||
|
||||
namespace openscreen::cast {
|
||||
|
||||
namespace {
|
||||
|
||||
constexpr char kSupportedStreams[] = "supportedStreams";
|
||||
constexpr char kAudioSourceType[] = "audio_source";
|
||||
constexpr char kVideoSourceType[] = "video_source";
|
||||
constexpr char kStreamType[] = "type";
|
||||
|
||||
[[nodiscard]] constexpr bool CodecParameterIsValid(VideoCodec codec,
|
||||
std::string_view parameter) {
|
||||
if (parameter.empty()) {
|
||||
return true;
|
||||
}
|
||||
switch (codec) {
|
||||
using enum VideoCodec;
|
||||
case kVp8:
|
||||
return parameter.starts_with("vp08");
|
||||
case kVp9:
|
||||
return parameter.starts_with("vp09");
|
||||
case kAv1:
|
||||
return parameter.starts_with("av01");
|
||||
case kHevc:
|
||||
return parameter.starts_with("hev1");
|
||||
case kH264:
|
||||
return parameter.starts_with("avc1");
|
||||
case kNotSpecified:
|
||||
return false;
|
||||
}
|
||||
OSP_NOTREACHED();
|
||||
}
|
||||
|
||||
bool CodecParameterIsValid(AudioCodec codec,
|
||||
const std::string& codec_parameter) {
|
||||
if (codec_parameter.empty()) {
|
||||
return true;
|
||||
}
|
||||
switch (codec) {
|
||||
case AudioCodec::kAac:
|
||||
return codec_parameter.starts_with("mp4a.");
|
||||
|
||||
// Opus doesn't use codec parameters.
|
||||
case AudioCodec::kOpus: // fallthrough
|
||||
case AudioCodec::kNotSpecified:
|
||||
return false;
|
||||
}
|
||||
OSP_NOTREACHED();
|
||||
}
|
||||
|
||||
EnumNameTable<CastMode, 2> kCastModeNames{
|
||||
{{"mirroring", CastMode::kMirroring}, {"remoting", CastMode::kRemoting}}};
|
||||
|
||||
bool TryParseRtpPayloadType(const Json::Value& value, RtpPayloadType* out) {
|
||||
int t;
|
||||
if (!json::TryParseInt(value, &t)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
uint8_t t_small = t;
|
||||
if (t_small != t || !IsRtpPayloadType(t_small)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
*out = static_cast<RtpPayloadType>(t_small);
|
||||
return true;
|
||||
}
|
||||
|
||||
bool TryParseRtpTimebase(const Json::Value& value, int* out) {
|
||||
std::string raw_timebase;
|
||||
if (!json::TryParseString(value, &raw_timebase)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// The spec demands a leading 1, so this isn't really a fraction.
|
||||
const auto fraction = SimpleFraction::FromString(raw_timebase);
|
||||
if (fraction.is_error() || !fraction.value().is_positive() ||
|
||||
fraction.value().numerator() != 1) {
|
||||
return false;
|
||||
}
|
||||
|
||||
*out = fraction.value().denominator();
|
||||
return true;
|
||||
}
|
||||
|
||||
// For a hex byte, the conversion is 4 bits to 1 character, e.g.
|
||||
// 0b11110001 becomes F1, so 1 byte is two characters.
|
||||
constexpr int kHexDigitsPerByte = 2;
|
||||
constexpr int kAesBytesSize = 16;
|
||||
constexpr int kAesStringLength = kAesBytesSize * kHexDigitsPerByte;
|
||||
bool TryParseAesHexBytes(const Json::Value& value,
|
||||
std::array<uint8_t, kAesBytesSize>* out) {
|
||||
std::string hex_string;
|
||||
if (!json::TryParseString(value, &hex_string)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
constexpr int kHexDigitsPerScanField = 16;
|
||||
constexpr int kNumScanFields = kAesStringLength / kHexDigitsPerScanField;
|
||||
uint64_t quads[kNumScanFields];
|
||||
int chars_scanned;
|
||||
if (hex_string.size() == kAesStringLength &&
|
||||
sscanf(hex_string.c_str(), "%16" SCNx64 "%16" SCNx64 "%n", &quads[0],
|
||||
&quads[1], &chars_scanned) == kNumScanFields &&
|
||||
chars_scanned == kAesStringLength &&
|
||||
std::none_of(hex_string.begin(), hex_string.end(),
|
||||
[](char c) { return std::isspace(c); })) {
|
||||
WriteBigEndian(quads[0], out->data());
|
||||
WriteBigEndian(quads[1], out->data() + 8);
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
std::string_view ToString(Stream::Type type) {
|
||||
switch (type) {
|
||||
case Stream::Type::kAudioSource:
|
||||
return kAudioSourceType;
|
||||
case Stream::Type::kVideoSource:
|
||||
return kVideoSourceType;
|
||||
default: {
|
||||
OSP_NOTREACHED();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
bool TryParseResolutions(const Json::Value& value,
|
||||
std::vector<Resolution>* out) {
|
||||
out->clear();
|
||||
|
||||
// Some legacy senders don't provide resolutions, so just return empty.
|
||||
if (!value.isArray() || value.empty()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
for (Json::ArrayIndex i = 0; i < value.size(); ++i) {
|
||||
auto resolution = Resolution::TryParse(value[i]);
|
||||
if (resolution.is_error()) {
|
||||
out->clear();
|
||||
return false;
|
||||
}
|
||||
out->push_back(std::move(resolution.value()));
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
ErrorOr<Stream> Stream::TryParse(const Json::Value& value, Stream::Type type) {
|
||||
if (!value.isObject()) {
|
||||
return Error(Error::Code::kJsonParseError, "Stream is not a JSON object");
|
||||
}
|
||||
|
||||
Stream out;
|
||||
out.type = type;
|
||||
|
||||
if (!json::TryParseInt(value["index"], &out.index) ||
|
||||
!json::TryParseUint(value["ssrc"], &out.ssrc) ||
|
||||
!TryParseRtpPayloadType(value["rtpPayloadType"], &out.rtp_payload_type) ||
|
||||
!TryParseRtpTimebase(value["timeBase"], &out.rtp_timebase)) {
|
||||
return Error(Error::Code::kJsonParseError,
|
||||
"Offer stream has missing or invalid mandatory field");
|
||||
}
|
||||
|
||||
if (!json::TryParseInt(value["channels"], &out.channels)) {
|
||||
out.channels = out.type == Stream::Type::kAudioSource
|
||||
? kDefaultNumAudioChannels
|
||||
: kDefaultNumVideoChannels;
|
||||
} else if (out.channels <= 0) {
|
||||
return Error(Error::Code::kJsonParseError, "Invalid channel count");
|
||||
}
|
||||
|
||||
if (!TryParseAesHexBytes(value["aesKey"], &out.aes_key) ||
|
||||
!TryParseAesHexBytes(value["aesIvMask"], &out.aes_iv_mask)) {
|
||||
return Error(Error::Code::kUnencryptedOffer,
|
||||
"Offer stream must have both a valid aesKey and aesIvMask");
|
||||
}
|
||||
if (out.rtp_timebase <
|
||||
std::min(kDefaultAudioMinSampleRate, kRtpVideoTimebase) ||
|
||||
out.rtp_timebase > kRtpVideoTimebase) {
|
||||
return Error(Error::Code::kJsonParseError, "rtp_timebase (sample rate)");
|
||||
}
|
||||
|
||||
out.target_delay = kDefaultTargetPlayoutDelay;
|
||||
int target_delay;
|
||||
if (json::TryParseInt(value["targetDelay"], &target_delay)) {
|
||||
auto d = std::chrono::milliseconds(target_delay);
|
||||
if (kMinTargetPlayoutDelay <= d && d <= kMaxTargetPlayoutDelay) {
|
||||
out.target_delay = d;
|
||||
}
|
||||
}
|
||||
|
||||
json::TryParseBool(value["receiverRtcpEventLog"],
|
||||
&out.receiver_rtcp_event_log);
|
||||
int dscp_value;
|
||||
if (json::TryParseInt(value["receiverRtcpDscp"], &dscp_value)) {
|
||||
// DSCP values are clamped to [0, 63].
|
||||
if (dscp_value < 0 || dscp_value > 63) {
|
||||
return Error(Error::Code::kJsonParseError,
|
||||
"receiverRtcpDscp (invalid DSCP value)");
|
||||
}
|
||||
out.receiver_rtcp_dscp = dscp_value;
|
||||
}
|
||||
|
||||
json::TryParseStringArray(value["rtpExtensions"], &out.rtp_extensions);
|
||||
json::TryParseString(value["codecParameter"], &out.codec_parameter);
|
||||
|
||||
return out;
|
||||
}
|
||||
|
||||
Json::Value Stream::ToJson() const {
|
||||
OSP_CHECK(IsValid());
|
||||
|
||||
Json::Value root;
|
||||
root["index"] = index;
|
||||
root["type"] = std::string(ToString(type));
|
||||
root["channels"] = channels;
|
||||
root["rtpPayloadType"] = static_cast<int>(rtp_payload_type);
|
||||
// rtpProfile is technically required by the spec, although it is always set
|
||||
// to cast. We set it here to be compliant with all spec implementers.
|
||||
root["rtpProfile"] = "cast";
|
||||
static_assert(sizeof(ssrc) <= sizeof(Json::UInt),
|
||||
"this code assumes Ssrc fits in a Json::UInt");
|
||||
root["ssrc"] = static_cast<Json::UInt>(ssrc);
|
||||
root["targetDelay"] = static_cast<int>(target_delay.count());
|
||||
root["aesKey"] = HexEncode(aes_key.data(), aes_key.size());
|
||||
root["aesIvMask"] = HexEncode(aes_iv_mask.data(), aes_iv_mask.size());
|
||||
root["receiverRtcpEventLog"] = receiver_rtcp_event_log;
|
||||
if (receiver_rtcp_dscp.has_value()) {
|
||||
root["receiverRtcpDscp"] = receiver_rtcp_dscp.value();
|
||||
}
|
||||
root["timeBase"] = "1/" + std::to_string(rtp_timebase);
|
||||
root["codecParameter"] = codec_parameter;
|
||||
if (!rtp_extensions.empty()) {
|
||||
root["rtpExtensions"] = json::PrimitiveVectorToJson(rtp_extensions);
|
||||
}
|
||||
return root;
|
||||
}
|
||||
|
||||
bool Stream::IsValid() const {
|
||||
return channels >= 1 && index >= 0 && target_delay.count() > 0 &&
|
||||
target_delay.count() <= std::numeric_limits<int>::max() &&
|
||||
rtp_timebase >= 1;
|
||||
}
|
||||
|
||||
ErrorOr<AudioStream> AudioStream::TryParse(const Json::Value& value) {
|
||||
if (!value.isObject()) {
|
||||
return Error(Error::Code::kJsonParseError,
|
||||
"Audio stream is not a JSON object");
|
||||
}
|
||||
|
||||
auto stream_or_error = Stream::TryParse(value, Stream::Type::kAudioSource);
|
||||
if (stream_or_error.is_error()) {
|
||||
return stream_or_error.error();
|
||||
}
|
||||
|
||||
AudioStream out;
|
||||
out.stream = std::move(stream_or_error.value());
|
||||
|
||||
std::string codec_name;
|
||||
if (!json::TryParseInt(value["bitRate"], &out.bit_rate) || out.bit_rate < 0 ||
|
||||
!json::TryParseString(value[kCodecName], &codec_name)) {
|
||||
return Error(Error::Code::kJsonParseError, "Invalid audio stream field");
|
||||
}
|
||||
ErrorOr<AudioCodec> codec = StringToAudioCodec(codec_name);
|
||||
if (!codec) {
|
||||
return Error(Error::Code::kUnknownCodec,
|
||||
"Codec is not known, can't use stream");
|
||||
}
|
||||
out.codec = codec.value();
|
||||
if (!CodecParameterIsValid(codec.value(), out.stream.codec_parameter)) {
|
||||
return Error(Error::Code::kInvalidCodecParameter,
|
||||
StringFormat("Invalid audio codec parameter ({} for codec {})",
|
||||
out.stream.codec_parameter.c_str(),
|
||||
CodecToString(codec.value())));
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
Json::Value AudioStream::ToJson() const {
|
||||
OSP_CHECK(IsValid());
|
||||
|
||||
Json::Value out = stream.ToJson();
|
||||
out[kCodecName] = CodecToString(codec);
|
||||
out["bitRate"] = bit_rate;
|
||||
return out;
|
||||
}
|
||||
|
||||
bool AudioStream::IsValid() const {
|
||||
return bit_rate >= 0 && stream.IsValid();
|
||||
}
|
||||
|
||||
ErrorOr<VideoStream> VideoStream::TryParse(const Json::Value& value) {
|
||||
if (!value.isObject()) {
|
||||
return Error(Error::Code::kJsonParseError,
|
||||
"Video stream is not a JSON object");
|
||||
}
|
||||
|
||||
auto stream_or_error = Stream::TryParse(value, Stream::Type::kVideoSource);
|
||||
if (stream_or_error.is_error()) {
|
||||
return stream_or_error.error();
|
||||
}
|
||||
|
||||
VideoStream out;
|
||||
out.stream = std::move(stream_or_error.value());
|
||||
|
||||
std::string codec_name;
|
||||
if (!json::TryParseString(value[kCodecName], &codec_name)) {
|
||||
return Error(Error::Code::kJsonParseError, "Video stream missing codec");
|
||||
}
|
||||
ErrorOr<VideoCodec> codec = StringToVideoCodec(codec_name);
|
||||
if (!codec) {
|
||||
return Error(Error::Code::kUnknownCodec,
|
||||
"Codec is not known, can't use stream");
|
||||
}
|
||||
out.codec = codec.value();
|
||||
if (!CodecParameterIsValid(codec.value(), out.stream.codec_parameter)) {
|
||||
return Error(Error::Code::kInvalidCodecParameter,
|
||||
StringFormat("Invalid video codec parameter ({} for codec {})",
|
||||
out.stream.codec_parameter.c_str(),
|
||||
CodecToString(codec.value())));
|
||||
}
|
||||
|
||||
out.max_frame_rate = SimpleFraction{kDefaultMaxFrameRate, 1};
|
||||
std::string raw_max_frame_rate;
|
||||
if (json::TryParseString(value["maxFrameRate"], &raw_max_frame_rate)) {
|
||||
auto parsed = SimpleFraction::FromString(raw_max_frame_rate);
|
||||
if (parsed.is_value() && parsed.value().is_positive()) {
|
||||
out.max_frame_rate = parsed.value();
|
||||
}
|
||||
}
|
||||
|
||||
TryParseResolutions(value["resolutions"], &out.resolutions);
|
||||
json::TryParseString(value["profile"], &out.profile);
|
||||
json::TryParseString(value["protection"], &out.protection);
|
||||
json::TryParseString(value["level"], &out.level);
|
||||
json::TryParseString(value["errorRecoveryMode"], &out.error_recovery_mode);
|
||||
if (!json::TryParseInt(value["maxBitRate"], &out.max_bit_rate)) {
|
||||
out.max_bit_rate = 4 << 20;
|
||||
}
|
||||
|
||||
return out;
|
||||
}
|
||||
|
||||
Json::Value VideoStream::ToJson() const {
|
||||
OSP_CHECK(IsValid());
|
||||
|
||||
Json::Value out = stream.ToJson();
|
||||
out["codecName"] = CodecToString(codec);
|
||||
out["maxFrameRate"] = max_frame_rate.ToString();
|
||||
out["maxBitRate"] = max_bit_rate;
|
||||
out["protection"] = protection;
|
||||
out["profile"] = profile;
|
||||
out["level"] = level;
|
||||
out["errorRecoveryMode"] = error_recovery_mode;
|
||||
|
||||
Json::Value rs;
|
||||
for (auto resolution : resolutions) {
|
||||
rs.append(resolution.ToJson());
|
||||
}
|
||||
out["resolutions"] = std::move(rs);
|
||||
return out;
|
||||
}
|
||||
|
||||
bool VideoStream::IsValid() const {
|
||||
return max_bit_rate > 0 && max_frame_rate.is_positive();
|
||||
}
|
||||
|
||||
// static
|
||||
ErrorOr<Offer> Offer::TryParse(const Json::Value& root) {
|
||||
if (!root.isObject()) {
|
||||
return Error(Error::Code::kJsonParseError, "null offer");
|
||||
}
|
||||
const ErrorOr<CastMode> cast_mode =
|
||||
GetEnum(kCastModeNames, root["castMode"].asString());
|
||||
Json::Value supported_streams = root[kSupportedStreams];
|
||||
if (!supported_streams.isArray()) {
|
||||
return Error(Error::Code::kJsonParseError, "supported streams in offer");
|
||||
}
|
||||
|
||||
std::vector<AudioStream> audio_streams;
|
||||
std::vector<VideoStream> video_streams;
|
||||
|
||||
using Dscp = std::optional<int>;
|
||||
std::optional<Dscp> receiver_rtcp_dscp;
|
||||
for (Json::ArrayIndex i = 0; i < supported_streams.size(); ++i) {
|
||||
const Json::Value& fields = supported_streams[i];
|
||||
std::string type;
|
||||
if (!json::TryParseString(fields[kStreamType], &type)) {
|
||||
return Error(Error::Code::kJsonParseError, "Missing stream type");
|
||||
}
|
||||
|
||||
Error error = Error::None();
|
||||
if (type == kAudioSourceType) {
|
||||
auto stream_or_error = AudioStream::TryParse(fields);
|
||||
if (stream_or_error.is_value()) {
|
||||
auto stream = std::move(stream_or_error.value());
|
||||
if (!receiver_rtcp_dscp) {
|
||||
receiver_rtcp_dscp.emplace(stream.stream.receiver_rtcp_dscp);
|
||||
} else if (stream.stream.receiver_rtcp_dscp != *receiver_rtcp_dscp) {
|
||||
return Error(Error::Code::kJsonParseError,
|
||||
"Mixed DSCP values in offer");
|
||||
}
|
||||
audio_streams.push_back(std::move(stream));
|
||||
} else {
|
||||
error = stream_or_error.error();
|
||||
}
|
||||
} else if (type == kVideoSourceType) {
|
||||
auto stream_or_error = VideoStream::TryParse(fields);
|
||||
if (stream_or_error.is_value()) {
|
||||
auto stream = std::move(stream_or_error.value());
|
||||
if (!receiver_rtcp_dscp) {
|
||||
receiver_rtcp_dscp.emplace(stream.stream.receiver_rtcp_dscp);
|
||||
} else if (stream.stream.receiver_rtcp_dscp != *receiver_rtcp_dscp) {
|
||||
return Error(Error::Code::kJsonParseError,
|
||||
"Mixed DSCP values in offer");
|
||||
}
|
||||
video_streams.push_back(std::move(stream));
|
||||
} else {
|
||||
error = stream_or_error.error();
|
||||
}
|
||||
}
|
||||
|
||||
if (!error.ok()) {
|
||||
if (error.code() == Error::Code::kUnknownCodec) {
|
||||
OSP_VLOG << "Dropping audio stream due to unknown codec: " << error;
|
||||
continue;
|
||||
} else {
|
||||
return error;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return Offer{cast_mode.value(CastMode::kMirroring), std::move(audio_streams),
|
||||
std::move(video_streams)};
|
||||
}
|
||||
|
||||
Json::Value Offer::ToJson() const {
|
||||
OSP_CHECK(IsValid());
|
||||
Json::Value root;
|
||||
root["castMode"] = GetEnumName(kCastModeNames, cast_mode).value();
|
||||
Json::Value streams;
|
||||
for (auto& stream : audio_streams) {
|
||||
streams.append(stream.ToJson());
|
||||
}
|
||||
|
||||
for (auto& stream : video_streams) {
|
||||
streams.append(stream.ToJson());
|
||||
}
|
||||
|
||||
root[kSupportedStreams] = std::move(streams);
|
||||
return root;
|
||||
}
|
||||
|
||||
bool Offer::IsValid() const {
|
||||
return std::ranges::all_of(
|
||||
audio_streams, [](const AudioStream& a) { return a.IsValid(); }) &&
|
||||
std::ranges::all_of(video_streams,
|
||||
[](const VideoStream& v) { return v.IsValid(); });
|
||||
}
|
||||
} // namespace openscreen::cast
|
||||
Loading…
Add table
Add a link
Reference in a new issue