Skip to main content

libobs_wrapper/
capabilities.rs

1//! Runtime capability discovery and compatibility planning for the loaded OBS installation.
2//!
3//! All enumeration and property inspection is executed on the OBS actor. Values returned from this
4//! module are owned Rust snapshots; no callback-borrowed or temporary libobs pointer escapes into
5//! safe downstream code.
6//!
7//! Use [`ObsCapabilities`] when the application cares about capabilities such as “H.264”, “AAC” or
8//! “RTMP” rather than a specific plugin ID. Selectors expose candidate lists, while
9//! [`ObsCapabilities::best_output_plan`] resolves a complete output/encoder choice and reports
10//! structured rejection reasons when no combination works.
11//!
12//! Each source/output/encoder/service descriptor also exposes runtime property schemas and
13//! form-ready settings snapshots through [`crate::settings`]. This is the plugin-generic path for
14//! third-party OBS settings UIs.
15
16use std::{
17    collections::{HashMap, HashSet},
18    ffi::CStr,
19    os::raw::{c_char, c_void},
20    ptr,
21};
22
23use crate::{
24    context::ObsContext,
25    data::{output::ObsOutputRef, ImmutableObsData, ObsData, ObsDataPointers},
26    encoders::{audio::ObsAudioEncoder, video::ObsVideoEncoder},
27    run_with_obs,
28    runtime::ObsRuntime,
29    scenes::{ObsSceneItemRef, ObsSceneRef},
30    services::ObsServiceRef,
31    settings::{SettingsSchema, SettingsSnapshot},
32    sources::{ObsFilterRef, ObsSourceRef},
33    unsafe_send::Sendable,
34    utils::{ObjectInfo, ObsError, ObsString},
35};
36
37#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
38pub enum SourceKind {
39    Input,
40    Filter,
41    Transition,
42    Other,
43}
44
45#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
46pub enum EncoderKind {
47    Audio,
48    Video,
49    Unknown(i64),
50}
51
52bitflags::bitflags! {
53    /// Capability flags reported by libobs for an encoder type.
54    #[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
55    pub struct EncoderCapabilities: u32 {
56        const DEPRECATED = libobs::OBS_ENCODER_CAP_DEPRECATED;
57        const PASS_TEXTURE = libobs::OBS_ENCODER_CAP_PASS_TEXTURE;
58        const DYNAMIC_BITRATE = libobs::OBS_ENCODER_CAP_DYN_BITRATE;
59        const INTERNAL = libobs::OBS_ENCODER_CAP_INTERNAL;
60        const ROI = libobs::OBS_ENCODER_CAP_ROI;
61        const SCALING = libobs::OBS_ENCODER_CAP_SCALING;
62    }
63}
64
65bitflags::bitflags! {
66    /// Capability flags reported by libobs for an output type. Unknown future bits are retained.
67    #[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash)]
68    pub struct OutputCapabilities: u32 {
69        const VIDEO = libobs::OBS_OUTPUT_VIDEO;
70        const AUDIO = libobs::OBS_OUTPUT_AUDIO;
71        const ENCODED = libobs::OBS_OUTPUT_ENCODED;
72        const SERVICE = libobs::OBS_OUTPUT_SERVICE;
73        const MULTI_TRACK_AUDIO = libobs::OBS_OUTPUT_MULTI_TRACK_AUDIO;
74        const CAN_PAUSE = libobs::OBS_OUTPUT_CAN_PAUSE;
75        const MULTI_TRACK_VIDEO = libobs::OBS_OUTPUT_MULTI_TRACK_VIDEO;
76    }
77}
78
79#[derive(Clone, Debug)]
80pub struct SourceTypeInfo {
81    id: String,
82    display_name: Option<String>,
83    kind: SourceKind,
84    output_flags: u32,
85    runtime: ObsRuntime,
86}
87
88impl SourceTypeInfo {
89    pub fn id(&self) -> &str {
90        &self.id
91    }
92
93    pub fn display_name(&self) -> Option<&str> {
94        self.display_name.as_deref()
95    }
96
97    pub fn kind(&self) -> SourceKind {
98        self.kind
99    }
100
101    pub fn output_flags(&self) -> u32 {
102        self.output_flags
103    }
104
105    pub fn properties(&self) -> Result<Vec<PropertyMetadata>, ObsError> {
106        source_properties(&self.runtime, &self.id)
107    }
108
109    pub fn settings_schema(&self) -> Result<SettingsSchema, ObsError> {
110        Ok(SettingsSchema::new(self.properties()?))
111    }
112
113    /// Rebuilds the property tree after asking OBS to apply property-modified callbacks for
114    /// the supplied settings. This reflects dynamic visibility, enabled state, and list items.
115    pub fn settings_schema_for(&self, settings: &ObsData) -> Result<SettingsSchema, ObsError> {
116        self.runtime.ensure_same_runtime(settings.runtime())?;
117        Ok(SettingsSchema::new(source_properties_for_settings(
118            &self.runtime,
119            &self.id,
120            settings,
121        )?))
122    }
123
124    pub fn settings_snapshot_for(&self, settings: &ObsData) -> Result<SettingsSnapshot, ObsError> {
125        let schema = self.settings_schema_for(settings)?;
126        let defaults = self.default_settings()?;
127        schema.snapshot(settings, defaults.as_ref())
128    }
129
130    pub fn default_settings(&self) -> Result<Option<ImmutableObsData>, ObsError> {
131        default_settings(&self.runtime, &self.id, libobs::obs_get_source_defaults)
132    }
133
134    /// Returns an owned mutable copy of this type's defaults. If libobs reports no
135    /// defaults, an empty settings object for the same runtime is returned.
136    pub fn default_settings_mut(&self) -> Result<ObsData, ObsError> {
137        mutable_default_settings(&self.runtime, &self.id, libobs::obs_get_source_defaults)
138    }
139}
140
141#[derive(Clone, Debug)]
142pub struct OutputTypeInfo {
143    id: String,
144    display_name: Option<String>,
145    video_codecs: Vec<String>,
146    audio_codecs: Vec<String>,
147    protocols: Vec<String>,
148    flags: u32,
149    runtime: ObsRuntime,
150}
151
152impl OutputTypeInfo {
153    pub fn id(&self) -> &str {
154        &self.id
155    }
156
157    pub fn display_name(&self) -> Option<&str> {
158        self.display_name.as_deref()
159    }
160
161    pub fn video_codecs(&self) -> &[String] {
162        &self.video_codecs
163    }
164
165    pub fn audio_codecs(&self) -> &[String] {
166        &self.audio_codecs
167    }
168
169    pub fn protocols(&self) -> &[String] {
170        &self.protocols
171    }
172
173    pub fn flags(&self) -> u32 {
174        self.flags
175    }
176
177    pub(crate) fn runtime(&self) -> &ObsRuntime {
178        &self.runtime
179    }
180
181    pub fn capability_flags(&self) -> OutputCapabilities {
182        OutputCapabilities::from_bits_retain(self.flags)
183    }
184
185    pub fn supports_video_codec(&self, codec: &str) -> bool {
186        capability_list_supports(&self.video_codecs, codec)
187    }
188
189    pub fn supports_audio_codec(&self, codec: &str) -> bool {
190        capability_list_supports(&self.audio_codecs, codec)
191    }
192
193    pub fn supports_protocol(&self, protocol: &str) -> bool {
194        capability_list_supports(&self.protocols, protocol)
195    }
196
197    pub fn properties(&self) -> Result<Vec<PropertyMetadata>, ObsError> {
198        type_properties(&self.runtime, &self.id, libobs::obs_get_output_properties)
199    }
200
201    pub fn settings_schema(&self) -> Result<SettingsSchema, ObsError> {
202        Ok(SettingsSchema::new(self.properties()?))
203    }
204
205    pub fn settings_schema_for(&self, settings: &ObsData) -> Result<SettingsSchema, ObsError> {
206        self.runtime.ensure_same_runtime(settings.runtime())?;
207        Ok(SettingsSchema::new(type_properties_for_settings(
208            &self.runtime,
209            &self.id,
210            libobs::obs_get_output_properties,
211            settings,
212        )?))
213    }
214
215    pub fn settings_snapshot_for(&self, settings: &ObsData) -> Result<SettingsSnapshot, ObsError> {
216        let schema = self.settings_schema_for(settings)?;
217        let defaults = self.default_settings()?;
218        schema.snapshot(settings, defaults.as_ref())
219    }
220
221    pub fn default_settings(&self) -> Result<Option<ImmutableObsData>, ObsError> {
222        default_settings(&self.runtime, &self.id, libobs::obs_output_defaults)
223    }
224
225    /// Returns a mutable settings object initialized from this output type's defaults.
226    pub fn default_settings_mut(&self) -> Result<ObsData, ObsError> {
227        mutable_default_settings(&self.runtime, &self.id, libobs::obs_output_defaults)
228    }
229}
230
231#[derive(Clone, Debug)]
232pub struct EncoderTypeInfo {
233    id: String,
234    display_name: Option<String>,
235    kind: EncoderKind,
236    codec: Option<String>,
237    capabilities: u32,
238    runtime: ObsRuntime,
239}
240
241impl EncoderTypeInfo {
242    pub fn id(&self) -> &str {
243        &self.id
244    }
245
246    pub fn display_name(&self) -> Option<&str> {
247        self.display_name.as_deref()
248    }
249
250    pub fn kind(&self) -> EncoderKind {
251        self.kind
252    }
253
254    pub fn codec(&self) -> Option<&str> {
255        self.codec.as_deref()
256    }
257
258    pub fn capabilities(&self) -> u32 {
259        self.capabilities
260    }
261
262    pub fn capability_flags(&self) -> EncoderCapabilities {
263        EncoderCapabilities::from_bits_retain(self.capabilities)
264    }
265
266    pub fn is_deprecated(&self) -> bool {
267        self.capability_flags()
268            .contains(EncoderCapabilities::DEPRECATED)
269    }
270
271    pub fn is_internal(&self) -> bool {
272        self.capability_flags()
273            .contains(EncoderCapabilities::INTERNAL)
274    }
275
276    /// Returns whether this encoder is likely hardware-accelerated.
277    ///
278    /// libobs does not expose one universal hardware bit. Passing textures is a strong
279    /// signal for zero-copy hardware encoders; the fallback ID check covers common
280    /// hardware plugins that do not advertise that capability. This is a preference
281    /// heuristic only and is never used to exclude software fallbacks.
282    pub fn is_likely_hardware_accelerated(&self) -> bool {
283        if self
284            .capability_flags()
285            .contains(EncoderCapabilities::PASS_TEXTURE)
286        {
287            return true;
288        }
289        let id = self.id.to_ascii_lowercase();
290        ["nvenc", "qsv", "amf", "vaapi", "videotoolbox"]
291            .iter()
292            .any(|marker| id.contains(marker))
293    }
294
295    pub fn properties(&self) -> Result<Vec<PropertyMetadata>, ObsError> {
296        type_properties(&self.runtime, &self.id, libobs::obs_get_encoder_properties)
297    }
298
299    pub fn settings_schema(&self) -> Result<SettingsSchema, ObsError> {
300        Ok(SettingsSchema::new(self.properties()?))
301    }
302
303    pub fn settings_schema_for(&self, settings: &ObsData) -> Result<SettingsSchema, ObsError> {
304        self.runtime.ensure_same_runtime(settings.runtime())?;
305        Ok(SettingsSchema::new(type_properties_for_settings(
306            &self.runtime,
307            &self.id,
308            libobs::obs_get_encoder_properties,
309            settings,
310        )?))
311    }
312
313    pub fn settings_snapshot_for(&self, settings: &ObsData) -> Result<SettingsSnapshot, ObsError> {
314        let schema = self.settings_schema_for(settings)?;
315        let defaults = self.default_settings()?;
316        schema.snapshot(settings, defaults.as_ref())
317    }
318
319    pub fn default_settings(&self) -> Result<Option<ImmutableObsData>, ObsError> {
320        default_settings(&self.runtime, &self.id, libobs::obs_encoder_defaults)
321    }
322
323    /// Returns a mutable settings object initialized from this encoder type's defaults.
324    pub fn default_settings_mut(&self) -> Result<ObsData, ObsError> {
325        mutable_default_settings(&self.runtime, &self.id, libobs::obs_encoder_defaults)
326    }
327}
328
329#[derive(Clone, Debug)]
330pub struct ServiceTypeInfo {
331    id: String,
332    display_name: Option<String>,
333    runtime: ObsRuntime,
334}
335
336impl ServiceTypeInfo {
337    pub fn id(&self) -> &str {
338        &self.id
339    }
340
341    pub fn display_name(&self) -> Option<&str> {
342        self.display_name.as_deref()
343    }
344
345    pub fn properties(&self) -> Result<Vec<PropertyMetadata>, ObsError> {
346        type_properties(&self.runtime, &self.id, libobs::obs_get_service_properties)
347    }
348
349    pub fn settings_schema(&self) -> Result<SettingsSchema, ObsError> {
350        Ok(SettingsSchema::new(self.properties()?))
351    }
352
353    pub fn settings_schema_for(&self, settings: &ObsData) -> Result<SettingsSchema, ObsError> {
354        self.runtime.ensure_same_runtime(settings.runtime())?;
355        Ok(SettingsSchema::new(type_properties_for_settings(
356            &self.runtime,
357            &self.id,
358            libobs::obs_get_service_properties,
359            settings,
360        )?))
361    }
362
363    pub fn settings_snapshot_for(&self, settings: &ObsData) -> Result<SettingsSnapshot, ObsError> {
364        let schema = self.settings_schema_for(settings)?;
365        let defaults = self.default_settings()?;
366        schema.snapshot(settings, defaults.as_ref())
367    }
368
369    pub fn default_settings(&self) -> Result<Option<ImmutableObsData>, ObsError> {
370        default_settings(&self.runtime, &self.id, libobs::obs_service_defaults)
371    }
372
373    /// Returns a mutable settings object initialized from this service type's defaults.
374    pub fn default_settings_mut(&self) -> Result<ObsData, ObsError> {
375        mutable_default_settings(&self.runtime, &self.id, libobs::obs_service_defaults)
376    }
377}
378
379#[derive(Clone, Debug, PartialEq, Eq)]
380pub struct ModuleInfo {
381    pub file_name: Option<String>,
382    pub name: Option<String>,
383    pub author: Option<String>,
384    pub description: Option<String>,
385    pub binary_path: Option<String>,
386    pub data_path: Option<String>,
387}
388
389#[derive(Clone, Debug)]
390pub struct ObsCapabilities {
391    source_types: Vec<SourceTypeInfo>,
392    input_types: Vec<SourceTypeInfo>,
393    filter_types: Vec<SourceTypeInfo>,
394    transition_types: Vec<SourceTypeInfo>,
395    outputs: Vec<OutputTypeInfo>,
396    encoders: Vec<EncoderTypeInfo>,
397    services: Vec<ServiceTypeInfo>,
398    protocols: Vec<String>,
399    modules: Vec<ModuleInfo>,
400}
401
402impl ObsCapabilities {
403    pub fn source_types(&self) -> &[SourceTypeInfo] {
404        &self.source_types
405    }
406    pub fn input_types(&self) -> &[SourceTypeInfo] {
407        &self.input_types
408    }
409    pub fn filter_types(&self) -> &[SourceTypeInfo] {
410        &self.filter_types
411    }
412    pub fn transition_types(&self) -> &[SourceTypeInfo] {
413        &self.transition_types
414    }
415    pub fn outputs(&self) -> &[OutputTypeInfo] {
416        &self.outputs
417    }
418    pub fn encoders(&self) -> &[EncoderTypeInfo] {
419        &self.encoders
420    }
421    pub fn services(&self) -> &[ServiceTypeInfo] {
422        &self.services
423    }
424    pub fn protocols(&self) -> &[String] {
425        &self.protocols
426    }
427    pub fn modules(&self) -> &[ModuleInfo] {
428        &self.modules
429    }
430
431    /// Starts a deterministic selection over discovered video encoders.
432    pub fn select_video_encoder(&self) -> EncoderSelector<'_> {
433        EncoderSelector::new(&self.encoders, EncoderKind::Video)
434    }
435
436    /// Starts a deterministic selection over discovered audio encoders.
437    pub fn select_audio_encoder(&self) -> EncoderSelector<'_> {
438        EncoderSelector::new(&self.encoders, EncoderKind::Audio)
439    }
440
441    /// Starts a deterministic selection over discovered outputs.
442    pub fn select_output(&self) -> OutputSelector<'_> {
443        OutputSelector::new(&self.outputs)
444    }
445
446    /// Resolves the best currently available output/encoder combination for a capability request.
447    /// The operation is pure over this owned capability snapshot and never creates native objects.
448    pub fn best_output_plan(
449        &self,
450        request: &OutputCompatibilityRequest,
451    ) -> Result<OutputCompatibilityPlan, OutputCompatibilityReport> {
452        let video_encoder = request.video_codec.as_deref().and_then(|codec| {
453            let selector = self.select_video_encoder().codec(codec);
454            if request.prefer_hardware_video {
455                selector.prefer_hardware().best_available().cloned()
456            } else {
457                selector.best_available().cloned()
458            }
459        });
460        let audio_encoder = request.audio_codec.as_deref().and_then(|codec| {
461            self.select_audio_encoder()
462                .codec(codec)
463                .best_available()
464                .cloned()
465        });
466
467        let mut issues = Vec::new();
468        if let Some(codec) = request.video_codec.as_deref() {
469            if video_encoder.is_none() {
470                issues.push(CompatibilityIssue::NoVideoEncoder {
471                    codec: codec.into(),
472                });
473            }
474        }
475        if let Some(codec) = request.audio_codec.as_deref() {
476            if audio_encoder.is_none() {
477                issues.push(CompatibilityIssue::NoAudioEncoder {
478                    codec: codec.into(),
479                });
480            }
481        }
482
483        let mut matching_outputs = Vec::new();
484        for output in &self.outputs {
485            let mut reasons = Vec::new();
486            if let Some(required_id) = request.output_id.as_deref() {
487                if output.id() != required_id {
488                    reasons.push(OutputRejectionReason::OutputId {
489                        required: required_id.into(),
490                    });
491                }
492            }
493            let flags = output.capability_flags();
494            if !flags.contains(request.required_output_capabilities) {
495                reasons.push(OutputRejectionReason::MissingCapabilities {
496                    required: request.required_output_capabilities,
497                    actual: flags,
498                });
499            }
500            if let Some(protocol) = request.protocol.as_deref() {
501                if !output.supports_protocol(protocol) {
502                    reasons.push(OutputRejectionReason::Protocol {
503                        required: protocol.into(),
504                    });
505                }
506            }
507            if let Some(codec) = request.video_codec.as_deref() {
508                if !output.supports_video_codec(codec) {
509                    reasons.push(OutputRejectionReason::VideoCodec {
510                        required: codec.into(),
511                    });
512                }
513            }
514            if let Some(codec) = request.audio_codec.as_deref() {
515                if !output.supports_audio_codec(codec) {
516                    reasons.push(OutputRejectionReason::AudioCodec {
517                        required: codec.into(),
518                    });
519                }
520            }
521            if reasons.is_empty() {
522                matching_outputs.push(output.clone());
523            } else {
524                issues.push(CompatibilityIssue::OutputRejected {
525                    output_id: output.id.clone(),
526                    reasons,
527                });
528            }
529        }
530
531        matching_outputs.sort_by(|a, b| a.id.cmp(&b.id));
532        let output = matching_outputs.into_iter().next();
533        if output.is_none() {
534            issues.push(CompatibilityIssue::NoCompatibleOutput);
535        }
536
537        let video_required = request.video_codec.is_some();
538        let audio_required = request.audio_codec.is_some();
539        match (
540            output,
541            video_required && video_encoder.is_none(),
542            audio_required && audio_encoder.is_none(),
543        ) {
544            (Some(output), false, false) => Ok(OutputCompatibilityPlan {
545                output,
546                video_encoder,
547                audio_encoder,
548                protocol: request.protocol.clone(),
549            }),
550            _ => Err(OutputCompatibilityReport { issues }),
551        }
552    }
553}
554
555/// Requirements used when resolving an output graph from runtime-discovered capabilities.
556#[derive(Clone, Debug, Default)]
557pub struct OutputCompatibilityRequest {
558    output_id: Option<String>,
559    protocol: Option<String>,
560    video_codec: Option<String>,
561    audio_codec: Option<String>,
562    required_output_capabilities: OutputCapabilities,
563    prefer_hardware_video: bool,
564}
565
566impl OutputCompatibilityRequest {
567    pub fn new() -> Self {
568        Self::default()
569    }
570
571    /// Restricts matching to one concrete output type while still validating codecs/flags.
572    pub fn output_id(mut self, output_id: impl Into<String>) -> Self {
573        self.output_id = Some(output_id.into());
574        self
575    }
576
577    pub fn protocol(mut self, protocol: impl Into<String>) -> Self {
578        self.protocol = Some(protocol.into());
579        self
580    }
581
582    pub fn video_codec(mut self, codec: impl Into<String>) -> Self {
583        self.video_codec = Some(codec.into());
584        self
585    }
586
587    pub fn audio_codec(mut self, codec: impl Into<String>) -> Self {
588        self.audio_codec = Some(codec.into());
589        self
590    }
591
592    pub fn require_output_capabilities(mut self, capabilities: OutputCapabilities) -> Self {
593        self.required_output_capabilities |= capabilities;
594        self
595    }
596
597    pub fn prefer_hardware_video(mut self, prefer: bool) -> Self {
598        self.prefer_hardware_video = prefer;
599        self
600    }
601}
602
603/// A concrete compatible choice of output and encoders from one capability snapshot.
604#[derive(Clone, Debug)]
605pub struct OutputCompatibilityPlan {
606    output: OutputTypeInfo,
607    video_encoder: Option<EncoderTypeInfo>,
608    audio_encoder: Option<EncoderTypeInfo>,
609    protocol: Option<String>,
610}
611
612impl OutputCompatibilityPlan {
613    pub fn output(&self) -> &OutputTypeInfo {
614        &self.output
615    }
616    pub fn video_encoder(&self) -> Option<&EncoderTypeInfo> {
617        self.video_encoder.as_ref()
618    }
619    pub fn audio_encoder(&self) -> Option<&EncoderTypeInfo> {
620        self.audio_encoder.as_ref()
621    }
622    pub fn protocol(&self) -> Option<&str> {
623        self.protocol.as_deref()
624    }
625}
626
627#[derive(Clone, Debug, PartialEq, Eq)]
628pub enum OutputRejectionReason {
629    OutputId {
630        required: String,
631    },
632    MissingCapabilities {
633        required: OutputCapabilities,
634        actual: OutputCapabilities,
635    },
636    Protocol {
637        required: String,
638    },
639    VideoCodec {
640        required: String,
641    },
642    AudioCodec {
643        required: String,
644    },
645}
646
647#[derive(Clone, Debug, PartialEq, Eq)]
648pub enum CompatibilityIssue {
649    NoVideoEncoder {
650        codec: String,
651    },
652    NoAudioEncoder {
653        codec: String,
654    },
655    OutputRejected {
656        output_id: String,
657        reasons: Vec<OutputRejectionReason>,
658    },
659    NoCompatibleOutput,
660}
661
662#[derive(Clone, Debug, PartialEq, Eq)]
663pub struct OutputCompatibilityReport {
664    issues: Vec<CompatibilityIssue>,
665}
666
667impl OutputCompatibilityReport {
668    pub fn issues(&self) -> &[CompatibilityIssue] {
669        &self.issues
670    }
671
672    pub fn summary(&self) -> String {
673        let encoder_issues = self
674            .issues
675            .iter()
676            .filter(|issue| {
677                matches!(
678                    issue,
679                    CompatibilityIssue::NoVideoEncoder { .. }
680                        | CompatibilityIssue::NoAudioEncoder { .. }
681                )
682            })
683            .count();
684        let rejected_outputs = self
685            .issues
686            .iter()
687            .filter(|issue| matches!(issue, CompatibilityIssue::OutputRejected { .. }))
688            .count();
689        format!(
690            "no compatible OBS output graph: {encoder_issues} encoder requirement(s) unavailable, {rejected_outputs} output type(s) rejected"
691        )
692    }
693}
694
695/// Filters and ranks discovered encoder descriptors without creating native objects.
696#[derive(Clone, Debug)]
697pub struct EncoderSelector<'a> {
698    encoders: &'a [EncoderTypeInfo],
699    kind: EncoderKind,
700    codec: Option<String>,
701    required_capabilities: EncoderCapabilities,
702    include_deprecated: bool,
703    include_internal: bool,
704    prefer_hardware: bool,
705}
706
707impl<'a> EncoderSelector<'a> {
708    fn new(encoders: &'a [EncoderTypeInfo], kind: EncoderKind) -> Self {
709        Self {
710            encoders,
711            kind,
712            codec: None,
713            required_capabilities: EncoderCapabilities::empty(),
714            include_deprecated: false,
715            include_internal: false,
716            prefer_hardware: false,
717        }
718    }
719
720    pub fn codec(mut self, codec: impl Into<String>) -> Self {
721        self.codec = Some(codec.into());
722        self
723    }
724
725    pub fn require_capabilities(mut self, capabilities: EncoderCapabilities) -> Self {
726        self.required_capabilities |= capabilities;
727        self
728    }
729
730    pub fn include_deprecated(mut self, include: bool) -> Self {
731        self.include_deprecated = include;
732        self
733    }
734
735    pub fn include_internal(mut self, include: bool) -> Self {
736        self.include_internal = include;
737        self
738    }
739
740    /// Prefer likely hardware-accelerated encoders while retaining software fallback.
741    pub fn prefer_hardware(mut self) -> Self {
742        self.prefer_hardware = true;
743        self
744    }
745
746    pub fn matches(&self) -> Vec<&'a EncoderTypeInfo> {
747        let mut candidates = self
748            .encoders
749            .iter()
750            .filter(|encoder| encoder.kind == self.kind)
751            .filter(|encoder| self.include_deprecated || !encoder.is_deprecated())
752            .filter(|encoder| self.include_internal || !encoder.is_internal())
753            .filter(|encoder| {
754                self.codec.as_ref().is_none_or(|codec| {
755                    encoder
756                        .codec
757                        .as_deref()
758                        .is_some_and(|actual| actual.eq_ignore_ascii_case(codec))
759                })
760            })
761            .filter(|encoder| {
762                encoder
763                    .capability_flags()
764                    .contains(self.required_capabilities)
765            })
766            .collect::<Vec<_>>();
767
768        candidates.sort_by(|a, b| {
769            let a_hardware = self.prefer_hardware && a.is_likely_hardware_accelerated();
770            let b_hardware = self.prefer_hardware && b.is_likely_hardware_accelerated();
771            b_hardware.cmp(&a_hardware).then_with(|| a.id.cmp(&b.id))
772        });
773        candidates
774    }
775
776    pub fn best_available(&self) -> Option<&'a EncoderTypeInfo> {
777        self.matches().into_iter().next()
778    }
779}
780
781/// Filters discovered outputs by their libobs-declared protocols, codecs, and flags.
782#[derive(Clone, Debug)]
783pub struct OutputSelector<'a> {
784    outputs: &'a [OutputTypeInfo],
785    protocol: Option<String>,
786    video_codec: Option<String>,
787    audio_codec: Option<String>,
788    required_capabilities: OutputCapabilities,
789}
790
791impl<'a> OutputSelector<'a> {
792    fn new(outputs: &'a [OutputTypeInfo]) -> Self {
793        Self {
794            outputs,
795            protocol: None,
796            video_codec: None,
797            audio_codec: None,
798            required_capabilities: OutputCapabilities::empty(),
799        }
800    }
801
802    pub fn protocol(mut self, protocol: impl Into<String>) -> Self {
803        self.protocol = Some(protocol.into());
804        self
805    }
806
807    pub fn video_codec(mut self, codec: impl Into<String>) -> Self {
808        self.video_codec = Some(codec.into());
809        self
810    }
811
812    pub fn audio_codec(mut self, codec: impl Into<String>) -> Self {
813        self.audio_codec = Some(codec.into());
814        self
815    }
816
817    pub fn require_capabilities(mut self, capabilities: OutputCapabilities) -> Self {
818        self.required_capabilities |= capabilities;
819        self
820    }
821
822    pub fn matches(&self) -> Vec<&'a OutputTypeInfo> {
823        let mut candidates = self
824            .outputs
825            .iter()
826            .filter(|output| {
827                self.protocol
828                    .as_deref()
829                    .is_none_or(|protocol| output.supports_protocol(protocol))
830            })
831            .filter(|output| {
832                self.video_codec
833                    .as_deref()
834                    .is_none_or(|codec| output.supports_video_codec(codec))
835            })
836            .filter(|output| {
837                self.audio_codec
838                    .as_deref()
839                    .is_none_or(|codec| output.supports_audio_codec(codec))
840            })
841            .filter(|output| {
842                output
843                    .capability_flags()
844                    .contains(self.required_capabilities)
845            })
846            .collect::<Vec<_>>();
847        candidates.sort_by(|a, b| a.id.cmp(&b.id));
848        candidates
849    }
850
851    pub fn best_available(&self) -> Option<&'a OutputTypeInfo> {
852        self.matches().into_iter().next()
853    }
854}
855
856#[derive(Clone, Debug, PartialEq)]
857pub struct PropertyMetadata {
858    pub name: String,
859    pub description: Option<String>,
860    pub long_description: Option<String>,
861    pub enabled: bool,
862    pub visible: bool,
863    pub kind: PropertyKind,
864}
865
866#[derive(Clone, Debug, PartialEq)]
867pub enum PropertyKind {
868    Invalid,
869    Bool,
870    Integer {
871        min: i32,
872        max: i32,
873        step: i32,
874        suffix: Option<String>,
875        control: NumberControl,
876    },
877    Float {
878        min: f64,
879        max: f64,
880        step: f64,
881        suffix: Option<String>,
882        control: NumberControl,
883    },
884    Text {
885        text_type: TextType,
886        monospace: bool,
887        info_type: TextInfoType,
888        word_wrap: bool,
889    },
890    Path {
891        path_type: PathType,
892        filter: Option<String>,
893        default_path: Option<String>,
894    },
895    List {
896        list_type: ListType,
897        format: ListFormat,
898        items: Vec<ListItem>,
899    },
900    Color,
901    Button {
902        button_type: ButtonType,
903        url: Option<String>,
904    },
905    Font,
906    EditableList {
907        list_type: EditableListType,
908        filter: Option<String>,
909        default_path: Option<String>,
910    },
911    FrameRate {
912        options: Vec<FrameRateOption>,
913        ranges: Vec<FrameRateRange>,
914    },
915    Group {
916        group_type: GroupType,
917        properties: Vec<PropertyMetadata>,
918    },
919    ColorAlpha,
920    /// A property type introduced by a newer libobs than this crate knows about.
921    Unknown(i64),
922}
923
924macro_rules! raw_enum {
925    ($name:ident { $($variant:ident = $constant:path),+ $(,)? }) => {
926        #[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
927        pub enum $name {
928            $($variant,)+
929            Unknown(i64),
930        }
931        impl $name {
932            fn from_raw(raw: i64) -> Self {
933                $(if raw == $constant as i64 { return Self::$variant; })+
934                Self::Unknown(raw)
935            }
936        }
937    };
938}
939
940raw_enum!(NumberControl {
941    Scroller = libobs::obs_number_type_OBS_NUMBER_SCROLLER,
942    Slider = libobs::obs_number_type_OBS_NUMBER_SLIDER,
943});
944raw_enum!(TextType {
945    Default = libobs::obs_text_type_OBS_TEXT_DEFAULT,
946    Password = libobs::obs_text_type_OBS_TEXT_PASSWORD,
947    Multiline = libobs::obs_text_type_OBS_TEXT_MULTILINE,
948    Info = libobs::obs_text_type_OBS_TEXT_INFO,
949});
950raw_enum!(TextInfoType {
951    Normal = libobs::obs_text_info_type_OBS_TEXT_INFO_NORMAL,
952    Warning = libobs::obs_text_info_type_OBS_TEXT_INFO_WARNING,
953    Error = libobs::obs_text_info_type_OBS_TEXT_INFO_ERROR,
954});
955raw_enum!(PathType {
956    File = libobs::obs_path_type_OBS_PATH_FILE,
957    FileSave = libobs::obs_path_type_OBS_PATH_FILE_SAVE,
958    Directory = libobs::obs_path_type_OBS_PATH_DIRECTORY,
959});
960raw_enum!(ListType {
961    Invalid = libobs::obs_combo_type_OBS_COMBO_TYPE_INVALID,
962    Editable = libobs::obs_combo_type_OBS_COMBO_TYPE_EDITABLE,
963    List = libobs::obs_combo_type_OBS_COMBO_TYPE_LIST,
964    Radio = libobs::obs_combo_type_OBS_COMBO_TYPE_RADIO,
965});
966raw_enum!(ListFormat {
967    Invalid = libobs::obs_combo_format_OBS_COMBO_FORMAT_INVALID,
968    Int = libobs::obs_combo_format_OBS_COMBO_FORMAT_INT,
969    Float = libobs::obs_combo_format_OBS_COMBO_FORMAT_FLOAT,
970    String = libobs::obs_combo_format_OBS_COMBO_FORMAT_STRING,
971    Bool = libobs::obs_combo_format_OBS_COMBO_FORMAT_BOOL,
972});
973raw_enum!(ButtonType {
974    Default = libobs::obs_button_type_OBS_BUTTON_DEFAULT,
975    Url = libobs::obs_button_type_OBS_BUTTON_URL,
976});
977raw_enum!(EditableListType {
978    Strings = libobs::obs_editable_list_type_OBS_EDITABLE_LIST_TYPE_STRINGS,
979    Files = libobs::obs_editable_list_type_OBS_EDITABLE_LIST_TYPE_FILES,
980    FilesAndUrls = libobs::obs_editable_list_type_OBS_EDITABLE_LIST_TYPE_FILES_AND_URLS,
981});
982raw_enum!(GroupType {
983    Invalid = libobs::obs_group_type_OBS_COMBO_INVALID,
984    Normal = libobs::obs_group_type_OBS_GROUP_NORMAL,
985    Checkable = libobs::obs_group_type_OBS_GROUP_CHECKABLE,
986});
987
988#[derive(Clone, Debug, PartialEq)]
989pub struct ListItem {
990    pub name: String,
991    pub value: ListValue,
992    pub disabled: bool,
993}
994
995#[derive(Clone, Debug, PartialEq)]
996pub enum ListValue {
997    String(String),
998    Int(i64),
999    Float(f64),
1000    Bool(bool),
1001    UnknownFormat(i64),
1002}
1003
1004#[derive(Clone, Debug, PartialEq, Eq)]
1005pub struct FrameRateOption {
1006    pub name: Option<String>,
1007    pub description: Option<String>,
1008}
1009
1010#[derive(Clone, Copy, Debug, PartialEq, Eq)]
1011pub struct FrameRate {
1012    pub numerator: u32,
1013    pub denominator: u32,
1014}
1015
1016#[derive(Clone, Copy, Debug, PartialEq, Eq)]
1017pub struct FrameRateRange {
1018    pub min: FrameRate,
1019    pub max: FrameRate,
1020}
1021
1022impl ObsSceneRef {
1023    /// Creates a source from a discovered descriptor and adds it directly to this scene.
1024    pub fn add_discovered_source(
1025        &mut self,
1026        source_type: &SourceTypeInfo,
1027        name: impl Into<ObsString>,
1028        settings: Option<ObsData>,
1029    ) -> Result<ObsSceneItemRef<ObsSourceRef>, ObsError> {
1030        self.runtime().ensure_same_runtime(&source_type.runtime)?;
1031        if source_type.kind == SourceKind::Filter {
1032            return Err(capability_kind_mismatch(
1033                &source_type.id,
1034                "source/input/transition",
1035                "filter",
1036            ));
1037        }
1038        ensure_settings_runtime(self.runtime(), settings.as_ref())?;
1039        let source = ObsSourceRef::new_from_info(
1040            ObjectInfo::new(source_type.id.as_str(), name, settings, None),
1041            self.runtime().clone(),
1042        )?;
1043        self.add(source)
1044    }
1045}
1046
1047impl ObsContext {
1048    pub fn source_types(&self) -> Result<Vec<SourceTypeInfo>, ObsError> {
1049        discover_sources(self.runtime())
1050    }
1051
1052    pub fn input_types(&self) -> Result<Vec<SourceTypeInfo>, ObsError> {
1053        discover_source_category(
1054            self.runtime(),
1055            SourceKind::Input,
1056            libobs::obs_enum_input_types,
1057        )
1058    }
1059
1060    pub fn filter_types(&self) -> Result<Vec<SourceTypeInfo>, ObsError> {
1061        discover_source_category(
1062            self.runtime(),
1063            SourceKind::Filter,
1064            libobs::obs_enum_filter_types,
1065        )
1066    }
1067
1068    pub fn transition_types(&self) -> Result<Vec<SourceTypeInfo>, ObsError> {
1069        discover_source_category(
1070            self.runtime(),
1071            SourceKind::Transition,
1072            libobs::obs_enum_transition_types,
1073        )
1074    }
1075
1076    pub fn output_types(&self) -> Result<Vec<OutputTypeInfo>, ObsError> {
1077        discover_outputs(self.runtime())
1078    }
1079
1080    pub fn encoder_types(&self) -> Result<Vec<EncoderTypeInfo>, ObsError> {
1081        discover_encoders(self.runtime())
1082    }
1083
1084    pub fn service_types(&self) -> Result<Vec<ServiceTypeInfo>, ObsError> {
1085        discover_services(self.runtime())
1086    }
1087
1088    pub fn protocols(&self) -> Result<Vec<String>, ObsError> {
1089        discover_protocols(self.runtime())
1090    }
1091
1092    pub fn loaded_modules(&self) -> Result<Vec<ModuleInfo>, ObsError> {
1093        discover_modules(self.runtime())
1094    }
1095
1096    pub fn source_type(&self, id: &str) -> Result<Option<SourceTypeInfo>, ObsError> {
1097        Ok(self.source_types()?.into_iter().find(|info| info.id == id))
1098    }
1099
1100    pub fn output_type(&self, id: &str) -> Result<Option<OutputTypeInfo>, ObsError> {
1101        Ok(self.output_types()?.into_iter().find(|info| info.id == id))
1102    }
1103
1104    pub fn encoder_type(&self, id: &str) -> Result<Option<EncoderTypeInfo>, ObsError> {
1105        Ok(self.encoder_types()?.into_iter().find(|info| info.id == id))
1106    }
1107
1108    pub fn service_type(&self, id: &str) -> Result<Option<ServiceTypeInfo>, ObsError> {
1109        Ok(self.service_types()?.into_iter().find(|info| info.id == id))
1110    }
1111
1112    /// Creates a typed source from a discovered source descriptor. Filters use
1113    /// [`ObsContext::create_filter`] so callers cannot accidentally lose filter semantics.
1114    pub fn create_source(
1115        &self,
1116        source_type: &SourceTypeInfo,
1117        name: impl Into<ObsString>,
1118        settings: Option<ObsData>,
1119    ) -> Result<ObsSourceRef, ObsError> {
1120        self.runtime().ensure_same_runtime(&source_type.runtime)?;
1121        if source_type.kind == SourceKind::Filter {
1122            return Err(capability_kind_mismatch(
1123                &source_type.id,
1124                "source/input/transition",
1125                "filter",
1126            ));
1127        }
1128        ensure_settings_runtime(self.runtime(), settings.as_ref())?;
1129        ObsSourceRef::new_from_info(
1130            ObjectInfo::new(source_type.id.as_str(), name, settings, None),
1131            self.runtime().clone(),
1132        )
1133    }
1134
1135    /// Creates and registers a typed filter from a discovered filter descriptor.
1136    pub fn create_filter(
1137        &mut self,
1138        filter_type: &SourceTypeInfo,
1139        name: impl Into<ObsString>,
1140        settings: Option<ObsData>,
1141    ) -> Result<ObsFilterRef, ObsError> {
1142        self.runtime().ensure_same_runtime(&filter_type.runtime)?;
1143        if filter_type.kind != SourceKind::Filter {
1144            return Err(capability_kind_mismatch(
1145                &filter_type.id,
1146                "filter",
1147                &format!("{:?}", filter_type.kind),
1148            ));
1149        }
1150        ensure_settings_runtime(self.runtime(), settings.as_ref())?;
1151        self.obs_filter(ObjectInfo::new(
1152            filter_type.id.as_str(),
1153            name,
1154            settings,
1155            None,
1156        ))
1157    }
1158
1159    /// Creates and registers an output from a discovered output descriptor.
1160    pub fn create_output(
1161        &mut self,
1162        output_type: &OutputTypeInfo,
1163        name: impl Into<ObsString>,
1164        settings: Option<ObsData>,
1165    ) -> Result<ObsOutputRef, ObsError> {
1166        self.runtime().ensure_same_runtime(&output_type.runtime)?;
1167        ensure_settings_runtime(self.runtime(), settings.as_ref())?;
1168        self.output(ObjectInfo::new(
1169            output_type.id.as_str(),
1170            name,
1171            settings,
1172            None,
1173        ))
1174    }
1175
1176    /// Creates a video encoder from a discovered encoder descriptor.
1177    pub fn create_video_encoder(
1178        &self,
1179        encoder_type: &EncoderTypeInfo,
1180        name: impl Into<ObsString>,
1181        settings: Option<ObsData>,
1182    ) -> Result<std::sync::Arc<ObsVideoEncoder>, ObsError> {
1183        self.runtime().ensure_same_runtime(&encoder_type.runtime)?;
1184        if encoder_type.kind != EncoderKind::Video {
1185            return Err(capability_kind_mismatch(
1186                &encoder_type.id,
1187                "video encoder",
1188                &format!("{:?}", encoder_type.kind),
1189            ));
1190        }
1191        ensure_settings_runtime(self.runtime(), settings.as_ref())?;
1192        ObsVideoEncoder::new_from_info(
1193            ObjectInfo::new(encoder_type.id.as_str(), name, settings, None),
1194            self.runtime().clone(),
1195        )
1196    }
1197
1198    /// Creates an audio encoder from a discovered encoder descriptor.
1199    pub fn create_audio_encoder(
1200        &self,
1201        encoder_type: &EncoderTypeInfo,
1202        name: impl Into<ObsString>,
1203        settings: Option<ObsData>,
1204        mixer_index: usize,
1205    ) -> Result<std::sync::Arc<ObsAudioEncoder>, ObsError> {
1206        self.runtime().ensure_same_runtime(&encoder_type.runtime)?;
1207        if encoder_type.kind != EncoderKind::Audio {
1208            return Err(capability_kind_mismatch(
1209                &encoder_type.id,
1210                "audio encoder",
1211                &format!("{:?}", encoder_type.kind),
1212            ));
1213        }
1214        ensure_settings_runtime(self.runtime(), settings.as_ref())?;
1215        ObsAudioEncoder::new_from_info(
1216            ObjectInfo::new(encoder_type.id.as_str(), name, settings, None),
1217            mixer_index,
1218            self.runtime().clone(),
1219        )
1220    }
1221
1222    /// Creates a managed streaming service from a discovered service descriptor.
1223    pub fn create_service(
1224        &self,
1225        service_type: &ServiceTypeInfo,
1226        name: impl Into<ObsString>,
1227        settings: Option<ObsData>,
1228    ) -> Result<std::sync::Arc<ObsServiceRef>, ObsError> {
1229        self.runtime().ensure_same_runtime(&service_type.runtime)?;
1230        ensure_settings_runtime(self.runtime(), settings.as_ref())?;
1231        ObsServiceRef::new_from_info(
1232            ObjectInfo::new(service_type.id.as_str(), name, settings, None),
1233            self.runtime().clone(),
1234        )
1235    }
1236
1237    pub fn capabilities(&self) -> Result<ObsCapabilities, ObsError> {
1238        Ok(ObsCapabilities {
1239            source_types: self.source_types()?,
1240            input_types: self.input_types()?,
1241            filter_types: self.filter_types()?,
1242            transition_types: self.transition_types()?,
1243            outputs: self.output_types()?,
1244            encoders: self.encoder_types()?,
1245            services: self.service_types()?,
1246            protocols: self.protocols()?,
1247            modules: self.loaded_modules()?,
1248        })
1249    }
1250}
1251
1252type EnumTypeFn = unsafe extern "C" fn(usize, *mut *const c_char) -> bool;
1253type PropertiesFn = unsafe extern "C" fn(*const c_char) -> *mut libobs::obs_properties_t;
1254type DefaultsFn = unsafe extern "C" fn(*const c_char) -> *mut libobs::obs_data_t;
1255
1256#[allow(unknown_lints)]
1257#[allow(ensure_obs_call_in_runtime)]
1258/// # Safety
1259/// This helper may only be called from an `ObsRuntime` actor command. All native pointers
1260/// supplied to it must remain valid for the duration of the call.
1261unsafe fn enum_ids_on_actor(enum_fn: EnumTypeFn) -> Vec<String> {
1262    let mut result = Vec::new();
1263    let mut index = 0;
1264    loop {
1265        let mut id = ptr::null();
1266        // SAFETY: The surrounding actor/helper contract guarantees native pointer validity for this call.
1267        if !unsafe { enum_fn(index, &mut id) } {
1268            break;
1269        }
1270        index += 1;
1271        // SAFETY: The surrounding actor/helper contract guarantees native pointer validity for this call.
1272        if let Some(id) = unsafe { cstr_owned(id) } {
1273            result.push(id);
1274        }
1275    }
1276    result.sort();
1277    result.dedup();
1278    result
1279}
1280
1281fn discover_sources(runtime: &ObsRuntime) -> Result<Vec<SourceTypeInfo>, ObsError> {
1282    let runtime_for_result = runtime.clone();
1283    // SAFETY: The surrounding actor/helper contract guarantees native pointer validity for this call.
1284    let rows = run_with_obs!(runtime, move || unsafe {
1285        let inputs: HashSet<_> = enum_ids_on_actor(libobs::obs_enum_input_types)
1286            .into_iter()
1287            .collect();
1288        let filters: HashSet<_> = enum_ids_on_actor(libobs::obs_enum_filter_types)
1289            .into_iter()
1290            .collect();
1291        let transitions: HashSet<_> = enum_ids_on_actor(libobs::obs_enum_transition_types)
1292            .into_iter()
1293            .collect();
1294        enum_ids_on_actor(libobs::obs_enum_source_types)
1295            .into_iter()
1296            .map(|id| {
1297                let c_id = ObsString::new(&id);
1298                let display_name = cstr_owned(libobs::obs_source_get_display_name(c_id.as_ptr().0));
1299                let output_flags = libobs::obs_get_source_output_flags(c_id.as_ptr().0);
1300                let kind = if inputs.contains(&id) {
1301                    SourceKind::Input
1302                } else if filters.contains(&id) {
1303                    SourceKind::Filter
1304                } else if transitions.contains(&id) {
1305                    SourceKind::Transition
1306                } else {
1307                    SourceKind::Other
1308                };
1309                (id, display_name, kind, output_flags)
1310            })
1311            .collect::<Vec<_>>()
1312    })?;
1313    Ok(rows
1314        .into_iter()
1315        .map(|(id, display_name, kind, output_flags)| SourceTypeInfo {
1316            id,
1317            display_name,
1318            kind,
1319            output_flags,
1320            runtime: runtime_for_result.clone(),
1321        })
1322        .collect())
1323}
1324
1325fn discover_source_category(
1326    runtime: &ObsRuntime,
1327    kind: SourceKind,
1328    enum_fn: EnumTypeFn,
1329) -> Result<Vec<SourceTypeInfo>, ObsError> {
1330    let runtime_for_result = runtime.clone();
1331    // SAFETY: The surrounding actor/helper contract guarantees native pointer validity for this call.
1332    let rows = run_with_obs!(runtime, move || unsafe {
1333        enum_ids_on_actor(enum_fn)
1334            .into_iter()
1335            .map(|id| {
1336                let c_id = ObsString::new(&id);
1337                (
1338                    id,
1339                    cstr_owned(libobs::obs_source_get_display_name(c_id.as_ptr().0)),
1340                    libobs::obs_get_source_output_flags(c_id.as_ptr().0),
1341                )
1342            })
1343            .collect::<Vec<_>>()
1344    })?;
1345    Ok(rows
1346        .into_iter()
1347        .map(|(id, display_name, output_flags)| SourceTypeInfo {
1348            id,
1349            display_name,
1350            kind,
1351            output_flags,
1352            runtime: runtime_for_result.clone(),
1353        })
1354        .collect())
1355}
1356
1357fn discover_outputs(runtime: &ObsRuntime) -> Result<Vec<OutputTypeInfo>, ObsError> {
1358    let runtime_for_result = runtime.clone();
1359    // SAFETY: The surrounding actor/helper contract guarantees native pointer validity for this call.
1360    let rows = run_with_obs!(runtime, move || unsafe {
1361        let ids = enum_ids_on_actor(libobs::obs_enum_output_types);
1362        let mut protocols_by_output = HashMap::<String, Vec<String>>::new();
1363        let mut protocol_index = 0;
1364        loop {
1365            let mut protocol = ptr::null_mut();
1366            if !libobs::obs_enum_output_protocols(protocol_index, &mut protocol) {
1367                break;
1368            }
1369            protocol_index += 1;
1370            let Some(protocol_name) = cstr_owned(protocol.cast_const()) else {
1371                continue;
1372            };
1373            let mut matching_ids = HashSet::<String>::new();
1374            libobs::obs_enum_output_types_with_protocol(
1375                protocol.cast_const(),
1376                (&mut matching_ids as *mut HashSet<String>).cast::<c_void>(),
1377                Some(collect_output_type_id),
1378            );
1379            for id in matching_ids {
1380                protocols_by_output
1381                    .entry(id)
1382                    .or_default()
1383                    .push(protocol_name.clone());
1384            }
1385        }
1386
1387        ids.into_iter()
1388            .map(|id| {
1389                let c_id = ObsString::new(&id);
1390                let mut protocols = protocols_by_output.remove(&id).unwrap_or_default();
1391                protocols.sort();
1392                protocols.dedup();
1393                (
1394                    id,
1395                    cstr_owned(libobs::obs_output_get_display_name(c_id.as_ptr().0)),
1396                    split_capability_string(libobs::obs_get_output_supported_video_codecs(
1397                        c_id.as_ptr().0,
1398                    )),
1399                    split_capability_string(libobs::obs_get_output_supported_audio_codecs(
1400                        c_id.as_ptr().0,
1401                    )),
1402                    protocols,
1403                    libobs::obs_get_output_flags(c_id.as_ptr().0),
1404                )
1405            })
1406            .collect::<Vec<_>>()
1407    })?;
1408    Ok(rows
1409        .into_iter()
1410        .map(
1411            |(id, display_name, video_codecs, audio_codecs, protocols, flags)| OutputTypeInfo {
1412                id,
1413                display_name,
1414                video_codecs,
1415                audio_codecs,
1416                protocols,
1417                flags,
1418                runtime: runtime_for_result.clone(),
1419            },
1420        )
1421        .collect())
1422}
1423
1424/// # Safety
1425/// `data` must point to a live `HashSet<String>` for this synchronous libobs enumeration
1426/// and `id` must be null or a valid callback-borrowed C string for the duration of the call.
1427unsafe extern "C" fn collect_output_type_id(data: *mut c_void, id: *const c_char) -> bool {
1428    let Some(ids) = data.cast::<HashSet<String>>().as_mut() else {
1429        return false;
1430    };
1431    if let Some(id) = cstr_owned(id) {
1432        ids.insert(id);
1433    }
1434    true
1435}
1436
1437fn discover_encoders(runtime: &ObsRuntime) -> Result<Vec<EncoderTypeInfo>, ObsError> {
1438    let runtime_for_result = runtime.clone();
1439    // SAFETY: The surrounding actor/helper contract guarantees native pointer validity for this call.
1440    let rows = run_with_obs!(runtime, move || unsafe {
1441        enum_ids_on_actor(libobs::obs_enum_encoder_types)
1442            .into_iter()
1443            .map(|id| {
1444                let c_id = ObsString::new(&id);
1445                let raw_kind = libobs::obs_get_encoder_type(c_id.as_ptr().0) as i64;
1446                let kind = if raw_kind == libobs::obs_encoder_type_OBS_ENCODER_AUDIO as i64 {
1447                    EncoderKind::Audio
1448                } else if raw_kind == libobs::obs_encoder_type_OBS_ENCODER_VIDEO as i64 {
1449                    EncoderKind::Video
1450                } else {
1451                    EncoderKind::Unknown(raw_kind)
1452                };
1453                (
1454                    id,
1455                    cstr_owned(libobs::obs_encoder_get_display_name(c_id.as_ptr().0)),
1456                    kind,
1457                    cstr_owned(libobs::obs_get_encoder_codec(c_id.as_ptr().0)),
1458                    libobs::obs_get_encoder_caps(c_id.as_ptr().0),
1459                )
1460            })
1461            .collect::<Vec<_>>()
1462    })?;
1463    Ok(rows
1464        .into_iter()
1465        .map(
1466            |(id, display_name, kind, codec, capabilities)| EncoderTypeInfo {
1467                id,
1468                display_name,
1469                kind,
1470                codec,
1471                capabilities,
1472                runtime: runtime_for_result.clone(),
1473            },
1474        )
1475        .collect())
1476}
1477
1478fn discover_services(runtime: &ObsRuntime) -> Result<Vec<ServiceTypeInfo>, ObsError> {
1479    let runtime_for_result = runtime.clone();
1480    // SAFETY: The surrounding actor/helper contract guarantees native pointer validity for this call.
1481    let rows = run_with_obs!(runtime, move || unsafe {
1482        enum_ids_on_actor(libobs::obs_enum_service_types)
1483            .into_iter()
1484            .map(|id| {
1485                let c_id = ObsString::new(&id);
1486                (
1487                    id,
1488                    cstr_owned(libobs::obs_service_get_display_name(c_id.as_ptr().0)),
1489                )
1490            })
1491            .collect::<Vec<_>>()
1492    })?;
1493    Ok(rows
1494        .into_iter()
1495        .map(|(id, display_name)| ServiceTypeInfo {
1496            id,
1497            display_name,
1498            runtime: runtime_for_result.clone(),
1499        })
1500        .collect())
1501}
1502
1503fn discover_protocols(runtime: &ObsRuntime) -> Result<Vec<String>, ObsError> {
1504    // SAFETY: The surrounding actor/helper contract guarantees native pointer validity for this call.
1505    run_with_obs!(runtime, move || unsafe {
1506        let mut result = Vec::new();
1507        let mut index = 0;
1508        loop {
1509            let mut protocol: *mut c_char = ptr::null_mut();
1510            if !libobs::obs_enum_output_protocols(index, &mut protocol) {
1511                break;
1512            }
1513            index += 1;
1514            if let Some(protocol) = cstr_owned(protocol.cast_const()) {
1515                result.push(protocol);
1516            }
1517        }
1518        result.sort();
1519        result.dedup();
1520        result
1521    })
1522}
1523
1524fn discover_modules(runtime: &ObsRuntime) -> Result<Vec<ModuleInfo>, ObsError> {
1525    #[allow(unknown_lints)]
1526    #[allow(ensure_obs_call_in_runtime)]
1527    /// # Safety
1528    /// Called synchronously by `obs_enum_modules` on the OBS actor. `param` points to the
1529    /// live `Vec<ModuleInfo>` owned by `discover_modules` for the complete enumeration.
1530    unsafe extern "C" fn collect(param: *mut std::ffi::c_void, module: *mut libobs::obs_module_t) {
1531        // SAFETY: The surrounding actor/helper contract guarantees native pointer validity for this call.
1532        let rows = unsafe { &mut *(param as *mut Vec<ModuleInfo>) };
1533        rows.push(ModuleInfo {
1534            // SAFETY: The surrounding actor/helper contract guarantees native pointer validity for this call.
1535            file_name: unsafe { cstr_owned(libobs::obs_get_module_file_name(module)) },
1536            // SAFETY: The surrounding actor/helper contract guarantees native pointer validity for this call.
1537            name: unsafe { cstr_owned(libobs::obs_get_module_name(module)) },
1538            // SAFETY: The surrounding actor/helper contract guarantees native pointer validity for this call.
1539            author: unsafe { cstr_owned(libobs::obs_get_module_author(module)) },
1540            // SAFETY: The surrounding actor/helper contract guarantees native pointer validity for this call.
1541            description: unsafe { cstr_owned(libobs::obs_get_module_description(module)) },
1542            // SAFETY: The surrounding actor/helper contract guarantees native pointer validity for this call.
1543            binary_path: unsafe { cstr_owned(libobs::obs_get_module_binary_path(module)) },
1544            // SAFETY: The surrounding actor/helper contract guarantees native pointer validity for this call.
1545            data_path: unsafe { cstr_owned(libobs::obs_get_module_data_path(module)) },
1546        });
1547    }
1548
1549    // SAFETY: The surrounding actor/helper contract guarantees native pointer validity for this call.
1550    run_with_obs!(runtime, move || unsafe {
1551        let mut rows: Vec<ModuleInfo> = Vec::new();
1552        // SAFETY: obs_enum_modules is synchronous; the Vec address remains valid for the
1553        // complete callback sequence and only owned strings are retained afterward.
1554        libobs::obs_enum_modules(Some(collect), (&mut rows as *mut Vec<ModuleInfo>).cast());
1555        rows.sort_by(|a, b| a.file_name.cmp(&b.file_name));
1556        rows
1557    })
1558}
1559
1560fn type_properties(
1561    runtime: &ObsRuntime,
1562    id: &str,
1563    getter: PropertiesFn,
1564) -> Result<Vec<PropertyMetadata>, ObsError> {
1565    let id = ObsString::new(id);
1566    // SAFETY: The surrounding actor/helper contract guarantees native pointer validity for this call.
1567    run_with_obs!(runtime, (id), move || unsafe {
1568        // SAFETY: The property tree is created, fully copied, and destroyed on the actor.
1569        let properties = getter(id.as_ptr().0);
1570        snapshot_owned_properties(properties)
1571    })
1572}
1573
1574fn type_properties_for_settings(
1575    runtime: &ObsRuntime,
1576    id: &str,
1577    getter: PropertiesFn,
1578    settings: &ObsData,
1579) -> Result<Vec<PropertyMetadata>, ObsError> {
1580    runtime.ensure_same_runtime(settings.runtime())?;
1581    let id = ObsString::new(id);
1582    let settings_ptr = settings.as_ptr();
1583    run_with_obs!(runtime, (id, settings_ptr), move || unsafe {
1584        // Safety: the property tree and settings handle are live on the OBS actor. Applying
1585        // settings invokes plugin property-modified callbacks synchronously before snapshotting.
1586        let properties = getter(id.as_ptr().0);
1587        if properties.is_null() {
1588            return Vec::new();
1589        }
1590        libobs::obs_properties_apply_settings(properties, settings_ptr.get_ptr());
1591        snapshot_owned_properties(properties)
1592    })
1593}
1594
1595fn source_properties(runtime: &ObsRuntime, id: &str) -> Result<Vec<PropertyMetadata>, ObsError> {
1596    type_properties(runtime, id, libobs::obs_get_source_properties)
1597}
1598
1599fn source_properties_for_settings(
1600    runtime: &ObsRuntime,
1601    id: &str,
1602    settings: &ObsData,
1603) -> Result<Vec<PropertyMetadata>, ObsError> {
1604    type_properties_for_settings(runtime, id, libobs::obs_get_source_properties, settings)
1605}
1606
1607fn default_settings(
1608    runtime: &ObsRuntime,
1609    id: &str,
1610    getter: DefaultsFn,
1611) -> Result<Option<ImmutableObsData>, ObsError> {
1612    let id = ObsString::new(id);
1613    // SAFETY: The surrounding actor/helper contract guarantees native pointer validity for this call.
1614    let ptr = run_with_obs!(runtime, (id), move || unsafe {
1615        // SAFETY: Type-default lookup runs on the OBS actor and returns an owned obs_data_t.
1616        Sendable(getter(id.as_ptr().0))
1617    })?;
1618    if ptr.0.is_null() {
1619        Ok(None)
1620    } else {
1621        Ok(Some(ImmutableObsData::from_raw_pointer(
1622            ptr,
1623            runtime.clone(),
1624        )))
1625    }
1626}
1627
1628fn mutable_default_settings(
1629    runtime: &ObsRuntime,
1630    id: &str,
1631    getter: DefaultsFn,
1632) -> Result<ObsData, ObsError> {
1633    let id = ObsString::new(id);
1634    // Safety: the defaults callback is invoked on the OBS actor and returns an owned
1635    // obs_data_t reference when non-null.
1636    let ptr = run_with_obs!(runtime, (id), move || unsafe {
1637        Sendable(getter(id.as_ptr().0))
1638    })?;
1639    if ptr.0.is_null() {
1640        ObsData::new(runtime.clone())
1641    } else {
1642        Ok(ObsData::from_raw_pointer(ptr, runtime.clone()))
1643    }
1644}
1645
1646fn ensure_settings_runtime(
1647    runtime: &ObsRuntime,
1648    settings: Option<&ObsData>,
1649) -> Result<(), ObsError> {
1650    if let Some(settings) = settings {
1651        runtime.ensure_same_runtime(settings.runtime())?;
1652    }
1653    Ok(())
1654}
1655
1656fn capability_kind_mismatch(id: &str, expected: &str, actual: &str) -> ObsError {
1657    ObsError::CapabilityKindMismatch {
1658        id: id.to_owned(),
1659        expected: expected.to_owned(),
1660        actual: actual.to_owned(),
1661    }
1662}
1663
1664#[allow(unknown_lints)]
1665#[allow(ensure_obs_call_in_runtime)]
1666/// # Safety
1667/// This helper may only be called from an `ObsRuntime` actor command. All native pointers
1668/// supplied to it must remain valid for the duration of the call.
1669unsafe fn snapshot_owned_properties(
1670    properties: *mut libobs::obs_properties_t,
1671) -> Vec<PropertyMetadata> {
1672    if properties.is_null() {
1673        return Vec::new();
1674    }
1675    // SAFETY: The surrounding actor/helper contract guarantees native pointer validity for this call.
1676    let result = unsafe { snapshot_properties(properties) };
1677    // SAFETY: The surrounding actor/helper contract guarantees native pointer validity for this call.
1678    unsafe { libobs::obs_properties_destroy(properties) };
1679    result
1680}
1681
1682#[allow(unknown_lints)]
1683#[allow(ensure_obs_call_in_runtime)]
1684/// # Safety
1685/// This helper may only be called from an `ObsRuntime` actor command. All native pointers
1686/// supplied to it must remain valid for the duration of the call.
1687unsafe fn snapshot_properties(properties: *mut libobs::obs_properties_t) -> Vec<PropertyMetadata> {
1688    let mut result = Vec::new();
1689    // SAFETY: The surrounding actor/helper contract guarantees native pointer validity for this call.
1690    let mut property = unsafe { libobs::obs_properties_first(properties) };
1691    while !property.is_null() {
1692        // SAFETY: The surrounding actor/helper contract guarantees native pointer validity for this call.
1693        let name = unsafe { cstr_owned(libobs::obs_property_name(property)) }.unwrap_or_default();
1694        // SAFETY: The surrounding actor/helper contract guarantees native pointer validity for this call.
1695        let description = unsafe { cstr_owned(libobs::obs_property_description(property)) };
1696        let long_description =
1697            // SAFETY: The surrounding actor/helper contract guarantees native pointer validity for this call.
1698            unsafe { cstr_owned(libobs::obs_property_long_description(property)) };
1699        // SAFETY: The surrounding actor/helper contract guarantees native pointer validity for this call.
1700        let enabled = unsafe { libobs::obs_property_enabled(property) };
1701        // SAFETY: The surrounding actor/helper contract guarantees native pointer validity for this call.
1702        let visible = unsafe { libobs::obs_property_visible(property) };
1703        // SAFETY: The surrounding actor/helper contract guarantees native pointer validity for this call.
1704        let raw_type = unsafe { libobs::obs_property_get_type(property) } as i64;
1705        // SAFETY: The surrounding actor/helper contract guarantees native pointer validity for this call.
1706        let kind = unsafe { snapshot_property_kind(property, raw_type) };
1707        result.push(PropertyMetadata {
1708            name,
1709            description,
1710            long_description,
1711            enabled,
1712            visible,
1713            kind,
1714        });
1715        // SAFETY: The surrounding actor/helper contract guarantees native pointer validity for this call.
1716        if !unsafe { libobs::obs_property_next(&mut property) } {
1717            break;
1718        }
1719    }
1720    result
1721}
1722
1723#[allow(unknown_lints)]
1724#[allow(ensure_obs_call_in_runtime)]
1725/// # Safety
1726/// This helper may only be called from an `ObsRuntime` actor command. All native pointers
1727/// supplied to it must remain valid for the duration of the call.
1728unsafe fn snapshot_property_kind(
1729    property: *mut libobs::obs_property_t,
1730    raw_type: i64,
1731) -> PropertyKind {
1732    if raw_type == libobs::obs_property_type_OBS_PROPERTY_INVALID as i64 {
1733        PropertyKind::Invalid
1734    } else if raw_type == libobs::obs_property_type_OBS_PROPERTY_BOOL as i64 {
1735        PropertyKind::Bool
1736    } else if raw_type == libobs::obs_property_type_OBS_PROPERTY_INT as i64 {
1737        PropertyKind::Integer {
1738            // SAFETY: The surrounding actor/helper contract guarantees native pointer validity for this call.
1739            min: unsafe { libobs::obs_property_int_min(property) },
1740            // SAFETY: The surrounding actor/helper contract guarantees native pointer validity for this call.
1741            max: unsafe { libobs::obs_property_int_max(property) },
1742            // SAFETY: The surrounding actor/helper contract guarantees native pointer validity for this call.
1743            step: unsafe { libobs::obs_property_int_step(property) },
1744            // SAFETY: The surrounding actor/helper contract guarantees native pointer validity for this call.
1745            suffix: unsafe { cstr_owned(libobs::obs_property_int_suffix(property)) },
1746            control: NumberControl::from_raw(
1747                // SAFETY: The surrounding actor/helper contract guarantees native pointer validity for this call.
1748                unsafe { libobs::obs_property_int_type(property) } as i64,
1749            ),
1750        }
1751    } else if raw_type == libobs::obs_property_type_OBS_PROPERTY_FLOAT as i64 {
1752        PropertyKind::Float {
1753            // SAFETY: The surrounding actor/helper contract guarantees native pointer validity for this call.
1754            min: unsafe { libobs::obs_property_float_min(property) },
1755            // SAFETY: The surrounding actor/helper contract guarantees native pointer validity for this call.
1756            max: unsafe { libobs::obs_property_float_max(property) },
1757            // SAFETY: The surrounding actor/helper contract guarantees native pointer validity for this call.
1758            step: unsafe { libobs::obs_property_float_step(property) },
1759            // SAFETY: The surrounding actor/helper contract guarantees native pointer validity for this call.
1760            suffix: unsafe { cstr_owned(libobs::obs_property_float_suffix(property)) },
1761            control: NumberControl::from_raw(
1762                // SAFETY: The surrounding actor/helper contract guarantees native pointer validity for this call.
1763                unsafe { libobs::obs_property_float_type(property) } as i64,
1764            ),
1765        }
1766    } else if raw_type == libobs::obs_property_type_OBS_PROPERTY_TEXT as i64 {
1767        PropertyKind::Text {
1768            text_type: TextType::from_raw(
1769                // SAFETY: The surrounding actor/helper contract guarantees native pointer validity for this call.
1770                unsafe { libobs::obs_property_text_type(property) } as i64,
1771            ),
1772            // SAFETY: The surrounding actor/helper contract guarantees native pointer validity for this call.
1773            monospace: unsafe { libobs::obs_property_text_monospace(property) },
1774            // SAFETY: The surrounding actor/helper contract guarantees native pointer validity for this call.
1775            info_type: TextInfoType::from_raw(unsafe {
1776                libobs::obs_property_text_info_type(property)
1777            } as i64),
1778            // SAFETY: The surrounding actor/helper contract guarantees native pointer validity for this call.
1779            word_wrap: unsafe { libobs::obs_property_text_info_word_wrap(property) },
1780        }
1781    } else if raw_type == libobs::obs_property_type_OBS_PROPERTY_PATH as i64 {
1782        PropertyKind::Path {
1783            path_type: PathType::from_raw(
1784                // SAFETY: The surrounding actor/helper contract guarantees native pointer validity for this call.
1785                unsafe { libobs::obs_property_path_type(property) } as i64,
1786            ),
1787            // SAFETY: The surrounding actor/helper contract guarantees native pointer validity for this call.
1788            filter: unsafe { cstr_owned(libobs::obs_property_path_filter(property)) },
1789            // SAFETY: The surrounding actor/helper contract guarantees native pointer validity for this call.
1790            default_path: unsafe { cstr_owned(libobs::obs_property_path_default_path(property)) },
1791        }
1792    } else if raw_type == libobs::obs_property_type_OBS_PROPERTY_LIST as i64 {
1793        // SAFETY: The surrounding actor/helper contract guarantees native pointer validity for this call.
1794        let format_raw = unsafe { libobs::obs_property_list_format(property) } as i64;
1795        // SAFETY: The surrounding actor/helper contract guarantees native pointer validity for this call.
1796        let count = unsafe { libobs::obs_property_list_item_count(property) };
1797        let mut items = Vec::with_capacity(count);
1798        for index in 0..count {
1799            // SAFETY: The surrounding actor/helper contract guarantees native pointer validity for this call.
1800            let name = unsafe { cstr_owned(libobs::obs_property_list_item_name(property, index)) }
1801                .unwrap_or_default();
1802            let value = if format_raw == libobs::obs_combo_format_OBS_COMBO_FORMAT_STRING as i64 {
1803                ListValue::String(
1804                    // SAFETY: The surrounding actor/helper contract guarantees native pointer validity for this call.
1805                    unsafe { cstr_owned(libobs::obs_property_list_item_string(property, index)) }
1806                        .unwrap_or_default(),
1807                )
1808            } else if format_raw == libobs::obs_combo_format_OBS_COMBO_FORMAT_INT as i64 {
1809                // SAFETY: The surrounding actor/helper contract guarantees native pointer validity for this call.
1810                ListValue::Int(unsafe { libobs::obs_property_list_item_int(property, index) })
1811            } else if format_raw == libobs::obs_combo_format_OBS_COMBO_FORMAT_FLOAT as i64 {
1812                // SAFETY: The surrounding actor/helper contract guarantees native pointer validity for this call.
1813                ListValue::Float(unsafe { libobs::obs_property_list_item_float(property, index) })
1814            } else if format_raw == libobs::obs_combo_format_OBS_COMBO_FORMAT_BOOL as i64 {
1815                // SAFETY: The surrounding actor/helper contract guarantees native pointer validity for this call.
1816                ListValue::Bool(unsafe { libobs::obs_property_list_item_bool(property, index) })
1817            } else {
1818                ListValue::UnknownFormat(format_raw)
1819            };
1820            items.push(ListItem {
1821                name,
1822                value,
1823                // SAFETY: The surrounding actor/helper contract guarantees native pointer validity for this call.
1824                disabled: unsafe { libobs::obs_property_list_item_disabled(property, index) },
1825            });
1826        }
1827        PropertyKind::List {
1828            list_type: ListType::from_raw(
1829                // SAFETY: The surrounding actor/helper contract guarantees native pointer validity for this call.
1830                unsafe { libobs::obs_property_list_type(property) } as i64,
1831            ),
1832            format: ListFormat::from_raw(format_raw),
1833            items,
1834        }
1835    } else if raw_type == libobs::obs_property_type_OBS_PROPERTY_COLOR as i64 {
1836        PropertyKind::Color
1837    } else if raw_type == libobs::obs_property_type_OBS_PROPERTY_BUTTON as i64 {
1838        PropertyKind::Button {
1839            button_type: ButtonType::from_raw(
1840                // SAFETY: The surrounding actor/helper contract guarantees native pointer validity for this call.
1841                unsafe { libobs::obs_property_button_type(property) } as i64,
1842            ),
1843            // SAFETY: The surrounding actor/helper contract guarantees native pointer validity for this call.
1844            url: unsafe { cstr_owned(libobs::obs_property_button_url(property)) },
1845        }
1846    } else if raw_type == libobs::obs_property_type_OBS_PROPERTY_FONT as i64 {
1847        PropertyKind::Font
1848    } else if raw_type == libobs::obs_property_type_OBS_PROPERTY_EDITABLE_LIST as i64 {
1849        PropertyKind::EditableList {
1850            // SAFETY: The surrounding actor/helper contract guarantees native pointer validity for this call.
1851            list_type: EditableListType::from_raw(unsafe {
1852                libobs::obs_property_editable_list_type(property)
1853            } as i64),
1854            // SAFETY: The surrounding actor/helper contract guarantees native pointer validity for this call.
1855            filter: unsafe { cstr_owned(libobs::obs_property_editable_list_filter(property)) },
1856            // SAFETY: The surrounding actor/helper contract guarantees native pointer validity for this call.
1857            default_path: unsafe {
1858                cstr_owned(libobs::obs_property_editable_list_default_path(property))
1859            },
1860        }
1861    } else if raw_type == libobs::obs_property_type_OBS_PROPERTY_FRAME_RATE as i64 {
1862        // SAFETY: The surrounding actor/helper contract guarantees native pointer validity for this call.
1863        let option_count = unsafe { libobs::obs_property_frame_rate_options_count(property) };
1864        let mut options = Vec::with_capacity(option_count);
1865        for index in 0..option_count {
1866            options.push(FrameRateOption {
1867                // SAFETY: The surrounding actor/helper contract guarantees native pointer validity for this call.
1868                name: unsafe {
1869                    cstr_owned(libobs::obs_property_frame_rate_option_name(property, index))
1870                },
1871                // SAFETY: The surrounding actor/helper contract guarantees native pointer validity for this call.
1872                description: unsafe {
1873                    cstr_owned(libobs::obs_property_frame_rate_option_description(
1874                        property, index,
1875                    ))
1876                },
1877            });
1878        }
1879        // SAFETY: The surrounding actor/helper contract guarantees native pointer validity for this call.
1880        let range_count = unsafe { libobs::obs_property_frame_rate_fps_ranges_count(property) };
1881        let mut ranges = Vec::with_capacity(range_count);
1882        for index in 0..range_count {
1883            // SAFETY: The surrounding actor/helper contract guarantees native pointer validity for this call.
1884            let min = unsafe { libobs::obs_property_frame_rate_fps_range_min(property, index) };
1885            // SAFETY: The surrounding actor/helper contract guarantees native pointer validity for this call.
1886            let max = unsafe { libobs::obs_property_frame_rate_fps_range_max(property, index) };
1887            ranges.push(FrameRateRange {
1888                min: FrameRate {
1889                    numerator: min.numerator,
1890                    denominator: min.denominator,
1891                },
1892                max: FrameRate {
1893                    numerator: max.numerator,
1894                    denominator: max.denominator,
1895                },
1896            });
1897        }
1898        PropertyKind::FrameRate { options, ranges }
1899    } else if raw_type == libobs::obs_property_type_OBS_PROPERTY_GROUP as i64 {
1900        // SAFETY: The surrounding actor/helper contract guarantees native pointer validity for this call.
1901        let content = unsafe { libobs::obs_property_group_content(property) };
1902        PropertyKind::Group {
1903            group_type: GroupType::from_raw(
1904                // SAFETY: The surrounding actor/helper contract guarantees native pointer validity for this call.
1905                unsafe { libobs::obs_property_group_type(property) } as i64,
1906            ),
1907            properties: if content.is_null() {
1908                Vec::new()
1909            } else {
1910                // SAFETY: The surrounding actor/helper contract guarantees native pointer validity for this call.
1911                unsafe { snapshot_properties(content) }
1912            },
1913        }
1914    } else if raw_type == libobs::obs_property_type_OBS_PROPERTY_COLOR_ALPHA as i64 {
1915        PropertyKind::ColorAlpha
1916    } else {
1917        PropertyKind::Unknown(raw_type)
1918    }
1919}
1920
1921/// # Safety
1922/// A non-null `ptr` must address a NUL-terminated C string that is readable for this call.
1923unsafe fn cstr_owned(ptr: *const c_char) -> Option<String> {
1924    if ptr.is_null() {
1925        None
1926    } else {
1927        Some(
1928            // SAFETY: The surrounding actor/helper contract guarantees native pointer validity for this call.
1929            unsafe { CStr::from_ptr(ptr) }
1930                .to_string_lossy()
1931                .into_owned(),
1932        )
1933    }
1934}
1935
1936/// # Safety
1937/// A non-null `ptr` must address a NUL-terminated C string that is readable for this call.
1938fn capability_list_supports(values: &[String], requested: &str) -> bool {
1939    values.is_empty()
1940        || values
1941            .iter()
1942            .any(|value| value.eq_ignore_ascii_case(requested))
1943}
1944
1945fn split_capability_string(ptr: *const c_char) -> Vec<String> {
1946    // SAFETY: The surrounding actor/helper contract guarantees native pointer validity for this call.
1947    let Some(value) = (unsafe { cstr_owned(ptr) }) else {
1948        return Vec::new();
1949    };
1950    value
1951        .split(|c: char| c == ',' || c == ';' || c.is_ascii_whitespace())
1952        .filter(|part| !part.is_empty())
1953        .map(str::to_owned)
1954        .collect()
1955}
1956
1957#[cfg(test)]
1958mod tests {
1959    use super::*;
1960
1961    #[test]
1962    fn future_enum_values_are_preserved() {
1963        assert_eq!(
1964            NumberControl::from_raw(99_999),
1965            NumberControl::Unknown(99_999)
1966        );
1967        assert_eq!(ListFormat::from_raw(-7), ListFormat::Unknown(-7));
1968    }
1969
1970    #[test]
1971    fn capability_string_parser_is_owned_and_tolerant() {
1972        let text = std::ffi::CString::new("h264, hevc;av1").unwrap();
1973        // SAFETY: The surrounding actor/helper contract guarantees native pointer validity for this call.
1974        let parsed = split_capability_string(text.as_ptr());
1975        assert_eq!(parsed, ["h264", "hevc", "av1"]);
1976    }
1977}