Skip to main content

libobs_wrapper\scenes/
group.rs

1//! Managed OBS scene groups and native-order enumeration.
2
3use std::{
4    collections::{HashMap, HashSet},
5    ffi::c_void,
6    sync::{Arc, Mutex, RwLock, Weak},
7};
8
9use libobs::obs_scene_item;
10
11use crate::{
12    run_with_obs,
13    runtime::ObsRuntime,
14    unsafe_send::{NativeObjectId, Sendable, SmartPointerSendable},
15    utils::{ObsError, ObsString},
16};
17
18use super::{
19    scene_item::{
20        mark_scene_item_removed, scene_item_native_handle, wrap_owned_scene_item_ref,
21        ObsSceneItemHandle, SceneItemTraitSealed,
22    },
23    ObsSceneRef, SceneItemTrait, SceneItemsBySource,
24};
25
26pub(super) type SceneGroups = Arc<RwLock<Vec<Arc<ObsSceneGroupRef>>>>;
27
28/// Replacement mapping returned when native libobs ungrouping replaces a child scene item.
29#[derive(Debug, Clone)]
30pub struct ObsUngroupedItem {
31    previous_object_id: NativeObjectId,
32    item: Arc<dyn SceneItemTrait>,
33}
34
35impl ObsUngroupedItem {
36    pub fn previous_object_id(&self) -> NativeObjectId {
37        self.previous_object_id
38    }
39
40    pub fn item(&self) -> &Arc<dyn SceneItemTrait> {
41        &self.item
42    }
43
44    pub fn into_item(self) -> Arc<dyn SceneItemTrait> {
45        self.item
46    }
47}
48
49/// A managed libobs group scene-item.
50///
51/// Groups behave like ordinary scene items for transforms/order, while their child items live in
52/// libobs's dedicated group scene. Reparenting uses libobs's native group operations rather than
53/// reconstructing transforms in Rust.
54#[derive(Debug, Clone)]
55pub struct ObsSceneGroupRef {
56    name: ObsString,
57    scene_item_ptr: SmartPointerSendable<*mut obs_scene_item>,
58    removed: Arc<Mutex<bool>>,
59    operation_lock: Arc<Mutex<()>>,
60    runtime: ObsRuntime,
61    _scene_ptr: SmartPointerSendable<*mut libobs::obs_scene_t>,
62    items_by_source: SceneItemsBySource,
63    groups: Weak<RwLock<Vec<Arc<ObsSceneGroupRef>>>>,
64}
65
66impl ObsSceneGroupRef {
67    pub fn name(&self) -> &ObsString {
68        &self.name
69    }
70
71    /// Moves an already-managed scene item into this group. libobs preserves its apparent
72    /// canvas transform while changing parentage.
73    pub fn add_item<T: SceneItemTrait + ?Sized>(&self, item: &T) -> Result<(), ObsError> {
74        let _operation = self
75            .operation_lock
76            .lock()
77            .map_err(|error| ObsError::LockError(error.to_string()))?;
78        self.runtime.ensure_same_runtime(&item.runtime())?;
79        if self.is_removed() || item.is_removed() {
80            return Err(ObsError::InvalidOperation(
81                "cannot group a removed scene item".into(),
82            ));
83        }
84        let group = self.scene_item_ptr.clone();
85        let item = scene_item_native_handle(item);
86        run_with_obs!(self.runtime, (group, item), move || unsafe {
87            // Safety: both managed item references remain alive for the actor call. Validate the
88            // native parent relationship before asking libobs to reparent the item.
89            if libobs::obs_sceneitem_is_group(item.get_ptr()) {
90                return Err(ObsError::InvalidOperation(
91                    "nested OBS scene groups are not supported by this managed group API".into(),
92                ));
93            }
94            let parent = libobs::obs_sceneitem_get_scene(group.get_ptr());
95            let item_parent = libobs::obs_sceneitem_get_scene(item.get_ptr());
96            if parent.is_null() || item_parent != parent {
97                return Err(ObsError::InvalidOperation(
98                    "scene item must be a top-level item in the same scene as the group".into(),
99                ));
100            }
101            libobs::obs_sceneitem_group_add_item(group.get_ptr(), item.get_ptr());
102            Ok(())
103        })?
104    }
105
106    /// Removes a child from this group and reparents it to the containing scene.
107    pub fn remove_item<T: SceneItemTrait + ?Sized>(&self, item: &T) -> Result<(), ObsError> {
108        let _operation = self
109            .operation_lock
110            .lock()
111            .map_err(|error| ObsError::LockError(error.to_string()))?;
112        self.runtime.ensure_same_runtime(&item.runtime())?;
113        if self.is_removed() || item.is_removed() {
114            return Err(ObsError::InvalidOperation(
115                "cannot ungroup a removed scene item".into(),
116            ));
117        }
118        let group = self.scene_item_ptr.clone();
119        let item = scene_item_native_handle(item);
120        run_with_obs!(self.runtime, (group, item), move || unsafe {
121            // Safety: both handles remain alive. Validate membership because the underlying libobs
122            // function trusts the caller and otherwise reparents an item from any scene/group.
123            let group_scene = libobs::obs_sceneitem_group_get_scene(group.get_ptr());
124            let item_parent = libobs::obs_sceneitem_get_scene(item.get_ptr());
125            if group_scene.is_null() || item_parent != group_scene {
126                return Err(ObsError::InvalidOperation(
127                    "scene item is not a child of this group".into(),
128                ));
129            }
130            libobs::obs_sceneitem_group_remove_item(group.get_ptr(), item.get_ptr());
131            Ok(())
132        })?
133    }
134
135    /// Returns group children in libobs's actual bottom-to-top native order.
136    pub fn items_in_order(&self) -> Result<Vec<Arc<dyn SceneItemTrait>>, ObsError> {
137        let _operation = self
138            .operation_lock
139            .lock()
140            .map_err(|error| ObsError::LockError(error.to_string()))?;
141        self.items_in_order_unlocked()
142    }
143
144    fn items_in_order_unlocked(&self) -> Result<Vec<Arc<dyn SceneItemTrait>>, ObsError> {
145        if self.is_removed() {
146            return Ok(Vec::new());
147        }
148        let group = self.scene_item_ptr.clone();
149        let addresses = run_with_obs!(self.runtime, (group), move || unsafe {
150            let mut result = Vec::<usize>::new();
151            // Safety: enumeration is synchronous and `result` remains live for all callbacks.
152            libobs::obs_sceneitem_group_enum_items(
153                group.get_ptr(),
154                Some(collect_item_address),
155                (&mut result as *mut Vec<usize>).cast::<c_void>(),
156            );
157            result
158        })?;
159        map_native_order(
160            addresses,
161            &self.items_by_source,
162            self.groups.upgrade().as_ref(),
163        )
164    }
165
166    /// Dissolves this group and returns replacement handles for every child.
167    ///
168    /// libobs implements ungrouping by creating new parent-scene items and copying each child into
169    /// them. Consequently, existing child handles cannot remain the identity of the visible item.
170    /// This method marks those old handles removed, rebinds the scene registry, and returns a
171    /// mapping from each previous object ID to its replacement managed handle.
172    pub fn ungroup(&self) -> Result<Vec<ObsUngroupedItem>, ObsError> {
173        let _operation = self
174            .operation_lock
175            .lock()
176            .map_err(|error| ObsError::LockError(error.to_string()))?;
177        if self.is_removed() {
178            return Ok(Vec::new());
179        }
180
181        let old_children = self.items_in_order_unlocked()?;
182        let old_ids = old_children
183            .iter()
184            .map(|item| item.object_id())
185            .collect::<Vec<_>>();
186
187        // Acquire every fallible managed-state lock before mutating native state. Once libobs
188        // dissolves the group there is no rollback operation that restores the old item identities.
189        let mut registry = self
190            .items_by_source
191            .write()
192            .map_err(|error| ObsError::LockError(error.to_string()))?;
193        for old_id in &old_ids {
194            let occurrences = registry
195                .values()
196                .flat_map(|(_, items)| items.iter())
197                .filter(|item| item.object_id() == *old_id)
198                .count();
199            if occurrences != 1 {
200                return Err(ObsError::InvalidOperation(format!(
201                    "group child {old_id:?} has {occurrences} managed registry entries"
202                )));
203            }
204        }
205
206        let groups = self.groups.upgrade();
207        let mut groups_guard = match groups.as_ref() {
208            Some(groups) => Some(
209                groups
210                    .write()
211                    .map_err(|error| ObsError::LockError(error.to_string()))?,
212            ),
213            None => None,
214        };
215        if let Some(groups) = groups_guard.as_ref() {
216            if !groups
217                .iter()
218                .any(|group| group.object_id() == self.object_id())
219            {
220                return Err(ObsError::InvalidOperation(
221                    "managed group is missing from its parent scene registry".into(),
222                ));
223            }
224        }
225
226        let before = native_scene_item_addresses(&self.runtime, self._scene_ptr.clone())?
227            .into_iter()
228            .collect::<HashSet<_>>();
229        let group = self.scene_item_ptr.clone();
230        let scene = self._scene_ptr.clone();
231        let new_native = run_with_obs!(self.runtime, (group, scene, before), move || unsafe {
232            // Safety: group and parent scene are held by managed leases. libobs creates replacement
233            // parent items synchronously before returning. Take one explicit reference for each new
234            // item before it leaves the actor call.
235            libobs::obs_sceneitem_group_ungroup2(group.get_ptr(), true);
236
237            let mut after = Vec::<usize>::new();
238            libobs::obs_scene_enum_items(
239                scene.get_ptr(),
240                Some(collect_item_address),
241                (&mut after as *mut Vec<usize>).cast::<c_void>(),
242            );
243            let mut replacements = Vec::new();
244            for address in after {
245                if before.contains(&address) {
246                    continue;
247                }
248                let item = address as *mut libobs::obs_sceneitem_t;
249                libobs::obs_sceneitem_addref(item);
250                replacements.push(Sendable(item));
251            }
252            replacements
253        })?;
254
255        let replacement_count = new_native.len();
256        let mut replacements = Vec::with_capacity(old_children.len().min(replacement_count));
257        let mut new_native = new_native.into_iter();
258
259        for old in &old_children {
260            let previous_object_id = old.object_id();
261            let Some(raw) = new_native.next() else {
262                // Native ungroup already removed the old child. Make the managed graph reflect that
263                // even if libobs unexpectedly failed to create a replacement.
264                mark_scene_item_removed(old.as_ref());
265                for (_, items) in registry.values_mut() {
266                    items.retain(|item| item.object_id() != previous_object_id);
267                }
268                continue;
269            };
270
271            let replacement = Arc::new(ObsSceneItemHandle::from_owned_native_ref(
272                raw,
273                self._scene_ptr.clone(),
274                self.runtime.clone(),
275            ));
276            let replacement_dyn: Arc<dyn SceneItemTrait> = replacement;
277            for (_, items) in registry.values_mut() {
278                for slot in items.iter_mut() {
279                    if slot.object_id() == previous_object_id {
280                        *slot = replacement_dyn.clone();
281                    }
282                }
283            }
284            mark_scene_item_removed(old.as_ref());
285            replacements.push(ObsUngroupedItem {
286                previous_object_id,
287                item: replacement_dyn,
288            });
289        }
290        registry.retain(|_, (_, items)| !items.is_empty());
291
292        // More replacements than original children would indicate a broken libobs invariant. Do not
293        // leak the explicit references we took above; the parent scene still owns the native items.
294        let extras = new_native.collect::<Vec<_>>();
295        if !extras.is_empty() {
296            run_with_obs!(self.runtime, (extras), move || unsafe {
297                // Safety: each pointer carries exactly one explicit reference acquired above.
298                for item in extras {
299                    libobs::obs_sceneitem_release(item.0);
300                }
301            })?;
302        }
303
304        {
305            let mut removed = self
306                .removed
307                .lock()
308                .unwrap_or_else(|poisoned| poisoned.into_inner());
309            *removed = true;
310        }
311        if let Some(groups) = groups_guard.as_mut() {
312            groups.retain(|group| group.object_id() != self.object_id());
313        }
314
315        if replacement_count != old_children.len() {
316            return Err(ObsError::Unexpected(format!(
317                "OBS ungroup replaced {} children with {replacement_count} native items",
318                old_children.len()
319            )));
320        }
321
322        Ok(replacements)
323    }
324}
325
326impl SceneItemTraitSealed for ObsSceneGroupRef {
327    fn __native_handle(&self) -> &SmartPointerSendable<*mut obs_scene_item> {
328        &self.scene_item_ptr
329    }
330
331    fn __removed_flag(&self) -> &Arc<Mutex<bool>> {
332        &self.removed
333    }
334}
335
336impl SceneItemTrait for ObsSceneGroupRef {
337    fn runtime(&self) -> ObsRuntime {
338        self.runtime.clone()
339    }
340
341    fn remove_from_scene(&self) -> Result<(), ObsError> {
342        let _operation = self
343            .operation_lock
344            .lock()
345            .map_err(|error| ObsError::LockError(error.to_string()))?;
346        if self.is_removed() {
347            return Ok(());
348        }
349        let children = self.items_in_order_unlocked()?;
350        let removed_ids = children
351            .iter()
352            .map(|child| child.object_id())
353            .collect::<HashSet<_>>();
354
355        // Pre-acquire all fallible registry locks before native removal. Removing a group also
356        // removes its group-scene children, so the managed registry must be updated atomically with
357        // that irreversible native transition.
358        let mut registry = self
359            .items_by_source
360            .write()
361            .map_err(|error| ObsError::LockError(error.to_string()))?;
362        for child_id in &removed_ids {
363            if !registry
364                .values()
365                .flat_map(|(_, items)| items.iter())
366                .any(|item| item.object_id() == *child_id)
367            {
368                return Err(ObsError::InvalidOperation(format!(
369                    "group child {child_id:?} is missing from its parent scene registry"
370                )));
371            }
372        }
373        let groups = self.groups.upgrade();
374        let mut groups_guard = match groups.as_ref() {
375            Some(groups) => Some(
376                groups
377                    .write()
378                    .map_err(|error| ObsError::LockError(error.to_string()))?,
379            ),
380            None => None,
381        };
382
383        let group = self.scene_item_ptr.clone();
384        run_with_obs!(self.runtime, (group), move || unsafe {
385            // Safety: the managed group owns an explicit native reference. Removing the parent
386            // scene's ownership destroys its private group scene and detaches every child.
387            libobs::obs_sceneitem_remove(group.get_ptr());
388        })?;
389
390        {
391            let mut removed = self
392                .removed
393                .lock()
394                .unwrap_or_else(|poisoned| poisoned.into_inner());
395            *removed = true;
396        }
397        for child in &children {
398            mark_scene_item_removed(child.as_ref());
399        }
400        registry.retain(|_, (_, items)| {
401            items.retain(|item| !removed_ids.contains(&item.object_id()));
402            !items.is_empty()
403        });
404        if let Some(groups) = groups_guard.as_mut() {
405            groups.retain(|group| group.object_id() != self.object_id());
406        }
407        Ok(())
408    }
409}
410
411impl ObsSceneRef {
412    /// Creates an empty managed group at the top of this scene.
413    pub fn create_group(
414        &mut self,
415        name: impl Into<ObsString>,
416    ) -> Result<ObsSceneGroupRef, ObsError> {
417        let name = name.into();
418        let scene = self.as_ptr();
419        let raw = run_with_obs!(self.runtime, (scene, name), move || unsafe {
420            // Safety: scene/name remain valid for the actor call. A successful group belongs to the
421            // scene; take one additional reference for the managed Rust handle.
422            let item = libobs::obs_scene_add_group(scene.get_ptr(), name.as_ptr().0);
423            if item.is_null() {
424                return Err(ObsError::NullPointer(Some(
425                    "OBS scene group creation".into(),
426                )));
427            }
428            libobs::obs_sceneitem_addref(item);
429            Ok(Sendable(item))
430        })??;
431        let (scene_item_ptr, removed) = wrap_owned_scene_item_ref(raw, self.runtime.clone());
432        let group = ObsSceneGroupRef {
433            name: name.clone(),
434            scene_item_ptr,
435            removed,
436            operation_lock: Arc::new(Mutex::new(())),
437            runtime: self.runtime.clone(),
438            _scene_ptr: self.as_ptr(),
439            items_by_source: self.attached_scene_items.clone(),
440            groups: Arc::downgrade(&self.attached_groups),
441        };
442        self.attached_groups
443            .write()
444            .map_err(|error| ObsError::LockError(error.to_string()))?
445            .push(Arc::new(group.clone()));
446        Ok(group)
447    }
448
449    /// Returns all currently managed groups owned by this scene.
450    pub fn groups(&self) -> Result<Vec<ObsSceneGroupRef>, ObsError> {
451        Ok(self
452            .attached_groups
453            .read()
454            .map_err(|error| ObsError::LockError(error.to_string()))?
455            .iter()
456            .filter(|group| !group.is_removed())
457            .map(|group| (**group).clone())
458            .collect())
459    }
460
461    /// Returns top-level scene items in libobs's actual bottom-to-top native order. Items inside a
462    /// group are enumerated by [`ObsSceneGroupRef::items_in_order`] instead.
463    pub fn items_in_order(&self) -> Result<Vec<Arc<dyn SceneItemTrait>>, ObsError> {
464        let addresses = native_scene_item_addresses(&self.runtime, self.as_ptr())?;
465        map_native_order(
466            addresses,
467            &self.attached_scene_items,
468            Some(&self.attached_groups),
469        )
470    }
471
472    pub(super) fn clear_groups(&mut self) -> Result<(), ObsError> {
473        let groups = self
474            .attached_groups
475            .read()
476            .map_err(|error| ObsError::LockError(error.to_string()))?
477            .clone();
478        for group in groups {
479            group.remove_from_scene()?;
480        }
481        self.attached_groups
482            .write()
483            .map_err(|error| ObsError::LockError(error.to_string()))?
484            .clear();
485        Ok(())
486    }
487}
488
489fn native_scene_item_addresses(
490    runtime: &ObsRuntime,
491    scene: SmartPointerSendable<*mut libobs::obs_scene_t>,
492) -> Result<Vec<usize>, ObsError> {
493    run_with_obs!(runtime, (scene), move || unsafe {
494        let mut result = Vec::<usize>::new();
495        // Safety: enumeration is synchronous and the vector outlives every callback.
496        libobs::obs_scene_enum_items(
497            scene.get_ptr(),
498            Some(collect_item_address),
499            (&mut result as *mut Vec<usize>).cast::<c_void>(),
500        );
501        result
502    })
503}
504
505/// # Safety
506/// `param` must point to a live `Vec<usize>` for the full synchronous libobs enumeration.
507/// `item` must be the callback-borrowed scene item supplied by libobs for that invocation.
508unsafe extern "C" fn collect_item_address(
509    _scene: *mut libobs::obs_scene_t,
510    item: *mut libobs::obs_sceneitem_t,
511    param: *mut c_void,
512) -> bool {
513    // Safety: callers pass a live Vec<usize> for the complete synchronous enumeration.
514    if let Some(result) = unsafe { param.cast::<Vec<usize>>().as_mut() } {
515        result.push(item as usize);
516        true
517    } else {
518        false
519    }
520}
521
522fn map_native_order(
523    addresses: Vec<usize>,
524    items_by_source: &SceneItemsBySource,
525    groups: Option<&SceneGroups>,
526) -> Result<Vec<Arc<dyn SceneItemTrait>>, ObsError> {
527    let mut managed = HashMap::<usize, Arc<dyn SceneItemTrait>>::new();
528    for (_, items) in items_by_source
529        .read()
530        .map_err(|error| ObsError::LockError(error.to_string()))?
531        .values()
532    {
533        for item in items {
534            if item.is_removed() {
535                continue;
536            }
537            let handle = scene_item_native_handle(item.as_ref());
538            // SAFETY: the address is used only as an ephemeral identity key while the managed
539            // handle remains alive; it is never dereferenced outside the actor.
540            managed.insert(unsafe { handle.raw_ptr_unchecked() } as usize, item.clone());
541        }
542    }
543    if let Some(groups) = groups {
544        for group in groups
545            .read()
546            .map_err(|error| ObsError::LockError(error.to_string()))?
547            .iter()
548        {
549            if group.is_removed() {
550                continue;
551            }
552            let handle = scene_item_native_handle(group.as_ref());
553            // SAFETY: the address is only an ephemeral lookup key; the managed group handle keeps
554            // the native item alive for the complete mapping operation and the pointer is not dereferenced.
555            managed.insert(
556                unsafe { handle.raw_ptr_unchecked() } as usize,
557                group.clone() as Arc<dyn SceneItemTrait>,
558            );
559        }
560    }
561
562    let mut ordered = Vec::with_capacity(addresses.len());
563    for address in addresses {
564        let Some(item) = managed.get(&address) else {
565            return Err(ObsError::InvalidOperation(format!(
566                "scene contains unmanaged native item at 0x{address:x}"
567            )));
568        };
569        ordered.push(item.clone());
570    }
571    Ok(ordered)
572}