diff --git a/data/locale/en-US.ini b/data/locale/en-US.ini index 641ace7..8c78241 100644 --- a/data/locale/en-US.ini +++ b/data/locale/en-US.ini @@ -1,9 +1,17 @@ Service.Name="MOQ" Service.Server="Relay URL" Service.Namespace="Namespace" +Service.EnableAudio="Enable audio" +Service.EnableAudio.Desc="Publish an audio track alongside video. When disabled, the stream is video-only." +Service.SkipTlsVerify="Skip TLS certificate verification" +Service.SkipTlsVerify.Desc="Accept the relay's TLS certificate without verifying it." +Service.DraftVersion="MoQ draft version" +Service.DraftVersion.Auto="Automatic" +Service.DraftVersion.Desc="\"Automatic\" negotiates the highest draft supported by both the plugin and the relay. Pick a draft number to force it." Output.Name="MOQ Output" Error.NoEncoder="No video encoder is assigned to the MOQ output." Error.NoAudioEncoder="No audio encoder is assigned to the MOQ output." Error.NoURL="No relay URL is configured. Set it in Settings -> Stream." Error.NoKey="No namespace is configured. Set it in Settings -> Stream." +Error.UnsupportedVersion="This MoQ draft version is not supported by this build. Use \"Automatic\" or another draft number. Set it in Settings -> Stream." Error.Connect="Could not connect to the MOQ relay. Check the relay URL and that the relay is reachable." diff --git a/src/moq-output.cpp b/src/moq-output.cpp index 1fddb5e..6f71efd 100644 --- a/src/moq-output.cpp +++ b/src/moq-output.cpp @@ -1,12 +1,19 @@ #include "moq-output.h" #include "codec-signaling.h" +#include "moq-service.h" #include #include +#include +#include #define VIDEO_TIMESCALE 1000000u #define MOQ_HANDSHAKE_TIMEOUT_US 5000000ull +#ifndef OBS_OUTPUT_NO_INTERLEAVE +#define OBS_OUTPUT_NO_INTERLEAVE (1 << 7) +#endif + MOQOutput::MOQOutput(obs_data_t *settings, obs_output_t *output) : output(output) { blog(LOG_INFO, "[obs-moq] output created"); @@ -70,6 +77,7 @@ bool MOQOutput::LoadVideoEncoderSettings() video_init_data = BuildInitData(codec, extra, extra_size); video_codec = BuildCodecString(codec, video_init_data); + video_codec_name = codec ? codec : ""; return true; } @@ -99,6 +107,24 @@ bool MOQOutput::LoadAudioEncoderSettings() return true; } +bool MOQOutput::LoadEndpointSettings(obs_service_t *service) +{ + OBSDataAutoRelease resolved = obs_service_defaults(obs_service_get_type(service)); + if (!resolved) { + obs_output_signal_stop(output, OBS_OUTPUT_ERROR); + return false; + } + + OBSDataAutoRelease settings = obs_service_get_settings(service); + obs_data_apply(resolved, settings); + + endpoint_conf.skip_tls_verify = obs_data_get_bool(resolved, kSettingSkipTlsVerify); + endpoint_conf.draft_version = (moq_version_t)obs_data_get_int(resolved, kSettingDraftVersion); + endpoint_conf.enable_audio = obs_data_get_bool(resolved, kSettingEnableAudio); + + return true; +} + bool MOQOutput::ResolveServiceConfig() { url.clear(); @@ -110,6 +136,9 @@ bool MOQOutput::ResolveServiceConfig() return false; } + if (!LoadEndpointSettings(service)) + return false; + const char *server = obs_service_get_connect_info(service, OBS_SERVICE_CONNECT_INFO_SERVER_URL); if (server && *server) { url = server; @@ -177,6 +206,7 @@ moq_media_track_t *MOQOutput::CreateVideoTrackFromPacket(moq_media_sender_t *cur video_init_data = std::move(init); video_codec = BuildCodecString(codec, video_init_data); + video_codec_name = codec ? codec : ""; moq_media_track_t *new_track = CreateVideoTrack(cur_sender); if (!new_track) { @@ -251,15 +281,17 @@ bool MOQOutput::Connect() moq_endpoint_cfg_init_sized(&ecfg, sizeof(ecfg)); ecfg.url.data = (const uint8_t *)url.c_str(); ecfg.url.len = url.size(); - //todo: dynamically resolve from config - ecfg.insecure_skip_verify = true; + ecfg.insecure_skip_verify = endpoint_conf.skip_tls_verify; ecfg.handshake_timeout_us = MOQ_HANDSHAKE_TIMEOUT_US; - //todo: revisit if we want to set the version policy to exact or leave it auto - static const moq_version_t kVersions[] = {MOQ_VERSION_DRAFT_16}; - ecfg.versions.policy = MOQ_VERSION_POLICY_EXACT; - ecfg.versions.versions = kVersions; - ecfg.versions.version_count = 1; + ecfg.versions.struct_size = sizeof(ecfg.versions); + if (endpoint_conf.draft_version) { + ecfg.versions.policy = MOQ_VERSION_POLICY_EXACT; + ecfg.versions.versions = &endpoint_conf.draft_version; + ecfg.versions.version_count = 1; + } else { + ecfg.versions.policy = MOQ_VERSION_POLICY_AUTO; + } moq_media_sender_cfg_t scfg; moq_media_sender_cfg_init_live_sized(&scfg, sizeof(scfg)); @@ -267,6 +299,10 @@ bool MOQOutput::Connect() scfg.namespace_ = namespace_val; scfg.publish_tracks = true; scfg.drop_without_demand = true; + // The catalog only needs republishing when tracks change (MSF-01 ยง5). Leaving + // this 0 resolves to libmoq's 1-second default, which republishes the catalog + // as a new group every second. Same effect as openmoq/moq5#28. + scfg.catalog_refresh_interval_us = UINT64_MAX; moq_media_sender_callbacks_init_sized(&scfg.callbacks, sizeof(scfg.callbacks)); scfg.callbacks.ctx = this; @@ -275,9 +311,13 @@ bool MOQOutput::Connect() scfg.callbacks.on_track_closed = &MOQOutput::OnTrackClosed; moq_media_sender_t *media_sender = nullptr; - if (moq_media_sender_create(&scfg, &media_sender) != MOQ_OK) { - blog(LOG_WARNING, "[obs-moq] moq_media_sender_create failed"); - obs_output_set_last_error(output, obs_module_text("Error.Connect")); + moq_result_t create_result = moq_media_sender_create(&scfg, &media_sender); + if (create_result != MOQ_OK) { + blog(LOG_WARNING, "[obs-moq] moq_media_sender_create failed: %d", (int)create_result); + const char *error = (create_result == MOQ_ERR_UNSUPPORTED && endpoint_conf.draft_version) + ? "Error.UnsupportedVersion" + : "Error.Connect"; + obs_output_set_last_error(output, obs_module_text(error)); obs_output_signal_stop(output, OBS_OUTPUT_CONNECT_FAILED); return false; } @@ -293,12 +333,15 @@ bool MOQOutput::Connect() } } - moq_media_track_t *new_audio_track = CreateAudioTrack(media_sender); - if (!new_audio_track) { - blog(LOG_WARNING, "[obs-moq] failed to create audio track"); - moq_media_sender_destroy(media_sender); - obs_output_signal_stop(output, OBS_OUTPUT_ERROR); - return false; + moq_media_track_t *new_audio_track = nullptr; + if (endpoint_conf.enable_audio) { + new_audio_track = CreateAudioTrack(media_sender); + if (!new_audio_track) { + blog(LOG_WARNING, "[obs-moq] failed to create audio track"); + moq_media_sender_destroy(media_sender); + obs_output_signal_stop(output, OBS_OUTPUT_ERROR); + return false; + } } { @@ -380,9 +423,38 @@ void MOQOutput::SendPacket(struct encoder_packet *packet, moq_media_track_t **tr bool ends_group) { + // OBS emits AVC/HEVC in Annex-B (start-code delimited), but init_data declares + // an avcC/hvcC record, whose lengthSizeMinusOne says NALs carry 4-byte length + // prefixes. Players that configure their decoder from that description (MSE, + // or WebCodecs with `description` set) fail on Annex-B bytes; players running + // WebCodecs in Annex-B mode ignore the description and accept either. Reframe + // so the payload matches what we advertise. AV1 carries OBUs, not NALs, so it + // is passed through untouched. + struct encoder_packet reframed; + bool did_reframe = false; + const uint8_t *payload_data = packet->data; + size_t payload_size = packet->size; + if (packet->type == OBS_ENCODER_VIDEO) { + if (video_codec_name == "h264") { + obs_parse_avc_packet(&reframed, packet); + did_reframe = true; + } else if (video_codec_name == "hevc") { + obs_parse_hevc_packet(&reframed, packet); + did_reframe = true; + } + if (did_reframe) { + payload_data = reframed.data; + payload_size = reframed.size; + } + } + moq_rcbuf_t *payload = nullptr; // moq_rcbuf_create will copy the data into a new rcbuf, and increment the refcount. We will need to decref it after sending, or if we don't send it. - if (moq_rcbuf_create(moq_alloc_default(), packet->data, packet->size, &payload) != MOQ_OK) { + moq_result_t alloc_result = moq_rcbuf_create(moq_alloc_default(), payload_data, payload_size, &payload); + if (did_reframe) { + obs_encoder_packet_release(&reframed); + } + if (alloc_result != MOQ_OK) { blog(LOG_WARNING, "[obs-moq] rcbuf alloc failed"); return; } @@ -443,7 +515,7 @@ void MOQOutput::Data(struct encoder_packet *packet) if (packet->type == OBS_ENCODER_VIDEO) { SendPacket(packet, &video_track, packet->keyframe, packet->keyframe, false); } - if (packet->type == OBS_ENCODER_AUDIO) { + if (packet->type == OBS_ENCODER_AUDIO && endpoint_conf.enable_audio) { SendPacket(packet, &audio_track, true, true, true); } } @@ -457,8 +529,13 @@ void MOQOutput::StartThread() return; } - if (!LoadVideoEncoderSettings() || !LoadAudioEncoderSettings()) { - blog(LOG_WARNING, "[obs-moq] failed to configure video or audio track"); + if (!LoadVideoEncoderSettings()) { + blog(LOG_WARNING, "[obs-moq] failed to configure video track"); + return; + } + + if (endpoint_conf.enable_audio && !LoadAudioEncoderSettings()) { + blog(LOG_WARNING, "[obs-moq] failed to configure audio track"); return; } @@ -472,7 +549,7 @@ void register_moq_output() { struct obs_output_info info = {}; info.id = "moq_output"; - info.flags = OBS_OUTPUT_AV | OBS_OUTPUT_ENCODED | OBS_OUTPUT_SERVICE; + info.flags = OBS_OUTPUT_AV | OBS_OUTPUT_ENCODED | OBS_OUTPUT_SERVICE | OBS_OUTPUT_NO_INTERLEAVE; info.protocols = "MOQ"; info.encoded_video_codecs = "h264;hevc;av1"; info.encoded_audio_codecs = "aac;opus"; diff --git a/src/moq-output.h b/src/moq-output.h index e95329e..a4093f9 100644 --- a/src/moq-output.h +++ b/src/moq-output.h @@ -26,6 +26,12 @@ struct audio_config { uint64_t bitrate; }; +struct endpoint_config { + bool skip_tls_verify = false; + bool enable_audio = true; + moq_version_t draft_version = (moq_version_t)0; // 0 : negotiate automatically +}; + class MOQOutput { public: MOQOutput(obs_data_t *settings, obs_output_t *output); @@ -50,6 +56,7 @@ class MOQOutput { void SendPacket(struct encoder_packet *packet, moq_media_track_t **track, bool is_sync, bool starts_group, bool ends_group); bool ResolveServiceConfig(); + bool LoadEndpointSettings(obs_service_t *service); bool Connect(); static void OnReady(void *ctx, moq_media_sender_t *sender); @@ -72,9 +79,11 @@ class MOQOutput { video_config video_conf; audio_config audio_conf; + endpoint_config endpoint_conf; std::vector video_init_data; std::string video_codec; + std::string video_codec_name; // raw OBS encoder codec ("h264"/"hevc"/"av1") std::vector audio_init_data; std::string audio_codec; diff --git a/src/moq-service.cpp b/src/moq-service.cpp index 5efbb1b..4288d15 100644 --- a/src/moq-service.cpp +++ b/src/moq-service.cpp @@ -3,6 +3,8 @@ #include #include +#include + const char *audio_codecs[] = {"aac", "opus", nullptr}; const char *video_codecs[] = {"h264", "hevc", "av1", nullptr}; @@ -19,6 +21,13 @@ void MOQService::Update(obs_data_t *settings) blog(LOG_DEBUG, "[obs-moq] service updated: server='%s' namespace='%s'", server.c_str(), moq_namespace.c_str()); } +void MOQService::Defaults(obs_data_t *settings) +{ + obs_data_set_default_bool(settings, kSettingSkipTlsVerify, true); + obs_data_set_default_int(settings, kSettingDraftVersion, 0); + obs_data_set_default_bool(settings, kSettingEnableAudio, true); +} + obs_properties_t *MOQService::Properties() { obs_properties_t *ppts = obs_properties_create(); @@ -26,6 +35,23 @@ obs_properties_t *MOQService::Properties() obs_properties_add_text(ppts, "server", obs_module_text("Service.Server"), OBS_TEXT_DEFAULT); obs_properties_add_text(ppts, "key", obs_module_text("Service.Namespace"), OBS_TEXT_DEFAULT); + obs_property_t *enable_audio = + obs_properties_add_bool(ppts, kSettingEnableAudio, obs_module_text("Service.EnableAudio")); + obs_property_set_long_description(enable_audio, obs_module_text("Service.EnableAudio.Desc")); + + obs_property_t *skip_tls = + obs_properties_add_bool(ppts, kSettingSkipTlsVerify, obs_module_text("Service.SkipTlsVerify")); + obs_property_set_long_description(skip_tls, obs_module_text("Service.SkipTlsVerify.Desc")); + + // Mirrors the drafts libmoq supports, it exposes no API to enumerate them. + obs_property_t *draft = obs_properties_add_list(ppts, kSettingDraftVersion, + obs_module_text("Service.DraftVersion"), OBS_COMBO_TYPE_LIST, + OBS_COMBO_FORMAT_INT); + obs_property_list_add_int(draft, obs_module_text("Service.DraftVersion.Auto"), 0); + obs_property_list_add_int(draft, "16", MOQ_VERSION_DRAFT_16); + obs_property_list_add_int(draft, "18", MOQ_VERSION_DRAFT_18); + obs_property_set_long_description(draft, obs_module_text("Service.DraftVersion.Desc")); + return ppts; } @@ -62,6 +88,10 @@ bool MOQService::Initialize(obs_output_t *output) if (strcmp(enc_id, "obs_x264") == 0) obs_data_set_string(overrides, "tune", "zerolatency"); + static constexpr char kVTEncoderIdPrefix[] = "com.apple.videotoolbox.videoencoder."; + if (strncmp(enc_id, kVTEncoderIdPrefix, sizeof(kVTEncoderIdPrefix) - 1) == 0) + obs_data_set_bool(overrides, "low_latency", true); + obs_encoder_update(venc, overrides); return true; @@ -101,6 +131,9 @@ void register_moq_service() info.update = [](void *priv, obs_data_t *settings) { static_cast(priv)->Update(settings); }; + info.get_defaults = [](obs_data_t *settings) { + MOQService::Defaults(settings); + }; info.get_properties = [](void *) -> obs_properties_t * { return MOQService::Properties(); }; diff --git a/src/moq-service.h b/src/moq-service.h index 5b17f51..d870114 100644 --- a/src/moq-service.h +++ b/src/moq-service.h @@ -2,16 +2,19 @@ #include #include +inline constexpr const char *kSettingSkipTlsVerify = "skip_tls_verify"; +inline constexpr const char *kSettingDraftVersion = "draft_version"; +inline constexpr const char *kSettingEnableAudio = "enable_audio"; + class MOQService { public: std::string server; std::string moq_namespace; - // todo: add a setting or find a way to set this - bool skip_verify = true; MOQService(obs_data_t *settings, obs_service_t *service); void Update(obs_data_t *settings); + static void Defaults(obs_data_t *settings); static void ApplyEncoderSettings(obs_data_t *video_settings, obs_data_t *audio_settings); static bool Initialize(obs_output_t *output); static obs_properties_t *Properties();