@@ -681,56 +681,123 @@ export class DualOutputService extends PersistentStatefulService<IDualOutputServ
681681 */
682682 validateSceneNodes ( sceneId : string ) {
683683 this . SET_IS_LOADING ( true ) ;
684- const sceneNodes = this . scenesService . views . getSceneNodesBySceneId ( sceneId ) ;
685- if ( ! sceneNodes ) return ;
686- const corruptedNodeIds = new Set < string > ( ) ;
687-
688- const sceneNodeMap = this . views . sceneNodeMaps [ sceneId ] ;
689- const invertedSceneNodeMap = invert ( sceneNodeMap ) ;
690-
691- // The keys in the nodemap are the ids for the horizontal nodes. Initialize with all keys
692- // and delete entries as nodes are visited; whatever remains are stale entries whose
693- // horizontal node no longer exists in the scene.
694- const horizontalNodeIds = new Set < string > ( Object . keys ( sceneNodeMap ) ) ;
695-
696- // Iterate over the scene nodes in reverse order to automatically handle correctly ordering
697- // any nodes created as a part of the validation process. This optimizes validation by skipping
698- // an extra loop over the nodes to reorder them.
699- forEachRight ( sceneNodes , ( node : TSceneNode , index : number ) => {
700- // don't handle corrupted nodes
701- if ( corruptedNodeIds . has ( node . id ) ) return ;
702-
703- // confirm partner node exists
704- const nodeMap = node ?. display === 'vertical' ? invertedSceneNodeMap : sceneNodeMap ;
705- const partnerNode = this . validatePartnerNode ( node , nodeMap , sceneNodes ) ;
706-
707- // Remove from horizontal node ids because we have confirmed this entry.
708- // Any nodes added as a horizontal partner node for a vertical node do not need
709- // to be validated again in the node map
710- if ( node . display === 'horizontal' ) {
711- horizontalNodeIds . delete ( node . id ) ;
712- }
713-
714- // confirm source and output for scene items
715- if ( node . isItem ( ) && partnerNode . isItem ( ) ) {
716- this . validateOutput ( node , sceneId ) ;
717- const corruptedNode : SceneItem = this . validateSource ( node , partnerNode ) ;
718- if ( corruptedNode ) {
719- corruptedNodeIds . add ( corruptedNode . id ) ;
684+ try {
685+ const sceneNodes = this . scenesService . views . getSceneNodesBySceneId ( sceneId ) ;
686+ if ( ! sceneNodes ) return ;
687+ const corruptedNodeIds = new Set < string > ( ) ;
688+
689+ const sceneNodeMap = this . views . sceneNodeMaps [ sceneId ] ;
690+ const invertedSceneNodeMap = invert ( sceneNodeMap ) ;
691+
692+ // The keys in the nodemap are the ids for the horizontal nodes. Initialize with all keys
693+ // and delete entries as nodes are visited; whatever remains are stale entries whose
694+ // horizontal node no longer exists in the scene.
695+ const horizontalNodeIds = new Set < string > ( Object . keys ( sceneNodeMap ) ) ;
696+
697+ // Iterate over the scene nodes in reverse order to automatically handle correctly ordering
698+ // any nodes created as a part of the validation process. This optimizes validation by skipping
699+ // an extra loop over the nodes to reorder them.
700+ forEachRight ( sceneNodes , ( node : TSceneNode , index : number ) => {
701+ // don't handle corrupted nodes
702+ if ( corruptedNodeIds . has ( node . id ) ) return ;
703+
704+ // confirm partner node exists
705+ const nodeMap = node ?. display === 'vertical' ? invertedSceneNodeMap : sceneNodeMap ;
706+ const partnerNode = this . validatePartnerNode ( node , nodeMap , sceneNodes ) ;
707+
708+ // Either side confirms the horizontal entry. Source reconciliation may
709+ // replace the vertical item and skip its horizontal partner later in this
710+ // traversal, so mark the pair now to retain its valid map entry.
711+ const horizontalNode = node . display === 'horizontal' ? node : partnerNode ;
712+ if ( horizontalNode . display === 'horizontal' ) horizontalNodeIds . delete ( horizontalNode . id ) ;
713+
714+ // confirm source and output for scene items
715+ if ( node . isItem ( ) && partnerNode . isItem ( ) ) {
716+ this . validateOutput ( node , sceneId ) ;
717+ const corruptedNode : SceneItem = this . validateSource ( node , partnerNode ) ;
718+ if ( corruptedNode ) {
719+ corruptedNodeIds . add ( corruptedNode . id ) ;
720+ }
720721 }
721- }
722722
723- this . sceneNodeHandled . next ( index ) ;
723+ this . sceneNodeHandled . next ( index ) ;
724+ } ) ;
725+
726+ // After confirming all of the scene items, `horizontalNodeIds` should be empty.
727+ // If there are any remaining entries, these are stale entries in the scene node map.
728+ // To repair the scene node map, delete these incorrect entries.
729+ horizontalNodeIds . forEach ( ( horizontalId : string ) => {
730+ this . sceneCollectionsService . removeNodeMapEntry ( sceneId , horizontalId ) ;
731+ } ) ;
732+
733+ this . repairCrossDisplayItemParents ( sceneId ) ;
734+ } finally {
735+ this . SET_IS_LOADING ( false ) ;
736+ }
737+ }
738+
739+ /**
740+ * Older source repair placed recreated items next to their partner, inheriting
741+ * that partner's folder. Repair those saved cross-display parents without
742+ * changing valid per-display layouts or the items' transforms and visibility.
743+ */
744+ private repairCrossDisplayItemParents ( sceneId : string ) {
745+ const scene = this . scenesService . views . getScene ( sceneId ) ;
746+ const nodes = scene . getNodes ( ) ;
747+ const nodesById = new Map ( nodes . map ( node => [ node . id , node ] ) ) ;
748+ const nodeMap = this . views . sceneNodeMaps [ sceneId ] ;
749+ const invertedNodeMap = invert ( nodeMap ) ;
750+ const verticalReferences = new Map < string , number > ( ) ;
751+ Object . values ( nodeMap ) . forEach ( id => {
752+ verticalReferences . set ( id , ( verticalReferences . get ( id ) ?? 0 ) + 1 ) ;
753+ } ) ;
754+ const repairedParents = new Map < string , string > ( ) ;
755+
756+ nodes . forEach ( node => {
757+ if ( ! node . isItem ( ) || ! node . parentId ) return ;
758+ const parent = nodesById . get ( node . parentId ) ;
759+ if ( ! parent ?. isFolder ( ) || parent . display === node . display ) return ;
760+
761+ const parentMap = node . display === 'vertical' ? nodeMap : invertedNodeMap ;
762+ const reverseParentMap = node . display === 'vertical' ? invertedNodeMap : nodeMap ;
763+ const mappedParent = nodesById . get ( parentMap [ parent . id ] ) ;
764+ if (
765+ ! mappedParent ?. isFolder ( ) ||
766+ mappedParent . display !== node . display ||
767+ reverseParentMap [ mappedParent . id ] !== parent . id ||
768+ verticalReferences . get ( node . display === 'vertical' ? mappedParent . id : parent . id ) !== 1
769+ ) {
770+ throw new Error ( `Cannot repair folder for scene item ${ node . id } : invalid paired folder` ) ;
771+ }
772+ repairedParents . set ( node . id , mappedParent . id ) ;
724773 } ) ;
725774
726- // After confirming all of the scene items, `horizontalNodeIds` should be empty.
727- // If there are any remaining entries, these are stale entries in the scene node map.
728- // To repair the scene node map, delete these incorrect entries.
729- horizontalNodeIds . forEach ( ( horizontalId : string ) => {
730- this . sceneCollectionsService . removeNodeMapEntry ( sceneId , horizontalId ) ;
775+ if ( ! repairedParents . size ) return ;
776+
777+ // Plan the final preorder before mutating. Keep every root and sibling in
778+ // its existing relative order, including children already in the target folder.
779+ const children = new Map < string , TSceneNode [ ] > ( ) ;
780+ nodes . forEach ( node => {
781+ const parentId = repairedParents . get ( node . id ) ?? node . parentId ?? '' ;
782+ if ( ! children . has ( parentId ) ) children . set ( parentId , [ ] ) ;
783+ children . get ( parentId ) ! . push ( node ) ;
731784 } ) ;
785+ const pending = [ ...( children . get ( '' ) ?? [ ] ) ] . reverse ( ) ;
786+ const order : string [ ] = [ ] ;
787+ const visited = new Set < string > ( ) ;
788+ while ( pending . length ) {
789+ const node = pending . pop ( ) ! ;
790+ if ( visited . has ( node . id ) ) break ;
791+ visited . add ( node . id ) ;
792+ order . push ( node . id ) ;
793+ pending . push ( ...[ ...( children . get ( node . id ) ?? [ ] ) ] . reverse ( ) ) ;
794+ }
795+ if ( order . length !== nodes . length ) {
796+ throw new Error ( `Cannot repair folders in scene ${ sceneId } : invalid scene hierarchy` ) ;
797+ }
732798
733- this . SET_IS_LOADING ( false ) ;
799+ repairedParents . forEach ( ( parentId , nodeId ) => scene . getItem ( nodeId ) ! . setParent ( parentId ) ) ;
800+ scene . setNodesOrder ( order ) ;
734801 }
735802
736803 /**
@@ -776,6 +843,9 @@ export class DualOutputService extends PersistentStatefulService<IDualOutputServ
776843 const matchVisibility = node . display === 'horizontal' ;
777844 const { visible, ...settings } = Object . assign ( verticalNode . getSettings ( ) ) ;
778845 const verticalNodeId = verticalNode . id ;
846+ const scene = verticalNode . getScene ( ) ;
847+ const parentId = verticalNode . parentId ;
848+ const nodeOrder = scene . getNodesIds ( ) ;
779849
780850 // remove old node
781851 this . sceneCollectionsService . removeNodeMapEntry ( horizontalNode . sceneId , horizontalNode . id ) ;
@@ -792,6 +862,12 @@ export class DualOutputService extends PersistentStatefulService<IDualOutputServ
792862 newPartner . setSettings ( { ...settings , output : context } ) ;
793863 newPartner . setVisibility ( visible ) ;
794864
865+ // Source reconciliation replaces the OBS item, not its authored location in
866+ // the scene tree. createPartnerNode's placement inherits the other display's
867+ // parent, so restore both the original parent and the complete node order.
868+ newPartner . setParent ( parentId ) ;
869+ scene . setNodesOrder ( nodeOrder ) ;
870+
795871 return partnerNode ;
796872 }
797873
0 commit comments