1414
1515from getstream .video .rtc .pb .stream .video .sfu .models .models_pb2 import TrackType
1616from ..edge import sfu_events
17- from ..edge .events import AudioReceivedEvent , TrackAddedEvent , CallEndedEvent
17+ from ..edge .events import AudioReceivedEvent , TrackAddedEvent , TrackRemovedEvent , CallEndedEvent
1818from ..edge .types import Connection , Participant , PcmData , User
1919from ..events .manager import EventManager
2020from ..llm import events as llm_events
@@ -161,6 +161,9 @@ def __init__(
161161 self ._interval_task = None
162162 self ._callback_executed = False
163163 self ._track_tasks : Dict [str , asyncio .Task ] = {}
164+ # Track metadata: track_id -> (track_type, participant, forwarder)
165+ self ._active_video_tracks : Dict [str , tuple [int , Any , Any ]] = {}
166+ self ._current_video_track_id : Optional [str ] = None
164167 self ._connection : Optional [Connection ] = None
165168 self ._audio_track : Optional [aiortc .AudioStreamTrack ] = None
166169 self ._video_track : Optional [VideoStreamTrack ] = None
@@ -666,10 +669,48 @@ async def on_track(event: TrackAddedEvent):
666669 if not track_id or not track_type :
667670 return
668671
672+ # If track is already being processed, just switch to it
673+ if track_id in self ._active_video_tracks :
674+ track_type_name = TrackType .Name (track_type )
675+ self .logger .info (f"🎥 Track re-added: { track_type_name } ({ track_id } ), switching to it" )
676+
677+ if self .realtime_mode and isinstance (self .llm , Realtime ):
678+ # Get the existing forwarder and switch to this track
679+ _ , _ , forwarder = self ._active_video_tracks [track_id ]
680+ track = self .edge .add_track_subscriber (track_id )
681+ if track and forwarder :
682+ await self .llm ._watch_video_track (track , shared_forwarder = forwarder )
683+ self ._current_video_track_id = track_id
684+ return
685+
669686 task = asyncio .create_task (self ._process_track (track_id , track_type , user ))
670687 self ._track_tasks [track_id ] = task
671688 task .add_done_callback (_log_task_exception )
672689
690+ @self .edge .events .subscribe
691+ async def on_track_removed (event : TrackRemovedEvent ):
692+ track_id = event .track_id
693+ track_type = event .track_type
694+ if not track_id :
695+ return
696+
697+ track_type_name = TrackType .Name (track_type ) if track_type else "unknown"
698+ self .logger .info (f"🎥 Track removed: { track_type_name } ({ track_id } )" )
699+
700+ # Cancel the processing task for this track
701+ if track_id in self ._track_tasks :
702+ self ._track_tasks [track_id ].cancel ()
703+ self ._track_tasks .pop (track_id )
704+
705+ # Clean up track metadata
706+ self ._active_video_tracks .pop (track_id , None )
707+
708+ # If this was the active track, switch to any other available track
709+ if track_id == self ._current_video_track_id and self .realtime_mode and isinstance (self .llm , Realtime ):
710+ self .logger .info ("🎥 Active video track removed, switching to next available" )
711+ self ._current_video_track_id = None
712+ await self ._switch_to_next_available_track ()
713+
673714 async def _reply_to_audio (
674715 self , pcm_data : PcmData , participant : Participant
675716 ) -> None :
@@ -698,6 +739,34 @@ async def _reply_to_audio(
698739 self .logger .debug (f"🎵 Processing audio from { participant } " )
699740 await self .stt .process_audio (pcm_data , participant )
700741
742+ async def _switch_to_next_available_track (self ) -> None :
743+ """Switch to any available video track."""
744+ if not self ._active_video_tracks :
745+ self .logger .info ("🎥 No video tracks available" )
746+ self ._current_video_track_id = None
747+ return
748+
749+ # Just pick the first available video track
750+ for track_id , (track_type , participant , forwarder ) in self ._active_video_tracks .items ():
751+ # Only consider video tracks (camera or screenshare)
752+ if track_type not in (TrackType .TRACK_TYPE_VIDEO , TrackType .TRACK_TYPE_SCREEN_SHARE ):
753+ continue
754+
755+ track_type_name = TrackType .Name (track_type )
756+ self .logger .info (f"🎥 Switching to track: { track_type_name } ({ track_id } )" )
757+
758+ # Get the track and forwarder
759+ track = self .edge .add_track_subscriber (track_id )
760+ if track and forwarder and isinstance (self .llm , Realtime ):
761+ # Send to Realtime provider
762+ await self .llm ._watch_video_track (track , shared_forwarder = forwarder )
763+ self ._current_video_track_id = track_id
764+ return
765+ else :
766+ self .logger .error (f"Failed to switch to track { track_id } " )
767+
768+ self .logger .warning ("🎥 No suitable video tracks found" )
769+
701770 async def _process_track (self , track_id : str , track_type : int , participant ):
702771 # TODO: handle CancelledError
703772 # we only process video tracks (camera video or screenshare)
@@ -737,7 +806,12 @@ async def recv(self):
737806 self ._video_forwarders = []
738807 self ._video_forwarders .append (raw_forwarder )
739808
740- # If Realtime provider supports video, determine which track to send
809+ # Store track metadata
810+ self ._active_video_tracks [track_id ] = (track_type , participant , raw_forwarder )
811+
812+ # If Realtime provider supports video, switch to this new track
813+ track_type_name = TrackType .Name (track_type )
814+
741815 if self .realtime_mode :
742816 if self ._video_track :
743817 # We have a video publisher (e.g., YOLO processor)
@@ -759,13 +833,15 @@ async def recv(self):
759833 await self .llm ._watch_video_track (
760834 self ._video_track , shared_forwarder = processed_forwarder
761835 )
836+ self ._current_video_track_id = track_id
762837 else :
763- # No video publisher, send raw frames
764- self .logger .info ("🎥 Forwarding RAW video frames to Realtime provider " )
838+ # No video publisher, send raw frames - switch to this new track
839+ self .logger .info (f "🎥 Switching to { track_type_name } track: { track_id } " )
765840 if isinstance (self .llm , Realtime ):
766841 await self .llm ._watch_video_track (
767842 track , shared_forwarder = raw_forwarder
768843 )
844+ self ._current_video_track_id = track_id
769845
770846 hasImageProcessers = len (self .image_processors ) > 0
771847
0 commit comments