44# Licensed under the GPLv3 or later.
55#
66
7- from pyrdp .enum import CapabilityType
7+ from pyrdp .enum import CapabilityType , PlayerPDUType
88from pyrdp .pdu import PlayerPDU
99from pyrdp .player .ImageHandler import ImageHandler
1010from pyrdp .player .RenderingEventHandler import RenderingEventHandler
@@ -38,13 +38,14 @@ def screen(self) -> QImage:
3838
3939class MP4EventHandler (RenderingEventHandler ):
4040
41- def __init__ (self , filename : str , fps = 10 , progress = None ):
41+ def __init__ (self , filename : str , fps = 10 , progress = None , idle_skip = 0 ):
4242 """
4343 Construct an event handler that outputs to an Mp4 file.
4444
4545 :param filename: The output file to write to.
4646 :param fps: The frame rate (10 recommended for forensic captures).
4747 :param progress: An optional callback (sig: `() -> ()`) whenever a frame is muxed.
48+ :param idle_skip: Seconds of inactivity before compressing idle gaps (0 = disabled).
4849 """
4950 self .filename = filename
5051 # faststart moves the moov atom to the front for seekable playback.
@@ -66,6 +67,13 @@ def __init__(self, filename: str, fps=10, progress=None):
6667 self .pts = 0
6768 # Track whether the surface has changed since the last encoded frame
6869 self .dirty = False
70+ # Idle skip: based on user input (keyboard/mouse), not screen changes
71+ self .idle_skip_ms = idle_skip * 1000 if idle_skip > 0 else 0
72+ self .total_skipped_ms = 0
73+ self .lastInputTimestamp = None
74+ self ._in_idle = False
75+ self ._idle_enter_ts = None
76+ self ._last_idle_frame_ts = None
6977
7078 super ().__init__ (MP4Image ())
7179
@@ -78,16 +86,56 @@ def onPDUReceived(self, pdu: PlayerPDU):
7886
7987 ts = pdu .timestamp
8088 self .timestamp = ts
89+ is_input = pdu .header == PlayerPDUType .FAST_PATH_INPUT
90+
91+ # Track user input for idle detection
92+ if is_input :
93+ self .lastInputTimestamp = ts
8194
8295 if self .prevTimestamp is None :
83- # First PDU: encode if surface was rendered
96+ # First PDU: assume active at start
97+ if self .lastInputTimestamp is None :
98+ self .lastInputTimestamp = ts
99+ if self .dirty :
100+ self .writeFrame ()
101+ self .dirty = False
102+ self .prevTimestamp = ts
103+ return
104+
105+ # Check input-idle state
106+ input_idle_ms = ts - self .lastInputTimestamp
107+ now_idle = self .idle_skip_ms > 0 and input_idle_ms > self .idle_skip_ms
108+
109+ if now_idle and not self ._in_idle :
110+ # Entering idle: encode last dirty frame, then freeze
111+ self ._in_idle = True
112+ self ._idle_enter_ts = ts
113+ self ._last_idle_frame_ts = ts
84114 if self .dirty :
85115 self .writeFrame ()
86116 self .dirty = False
117+
118+ if self ._in_idle and not now_idle :
119+ # Exiting idle (input resumed)
120+ self ._in_idle = False
121+ self .total_skipped_ms += ts - self ._idle_enter_ts
122+ self .pts += self .fps # 1s pause in output
123+ self .prevTimestamp = ts
124+ return
125+
126+ if self ._in_idle :
127+ # During idle: encode 1 frame every 10s to capture screen state
128+ if self .dirty and (ts - self ._last_idle_frame_ts ) >= 10000 :
129+ self .writeFrame ()
130+ self .dirty = False
131+ self ._last_idle_frame_ts = ts
132+ else :
133+ self .dirty = False
87134 self .prevTimestamp = ts
88135 return
89136
90- dt = self .timestamp - self .prevTimestamp # ms
137+ # Normal (non-idle) processing
138+ dt = ts - self .prevTimestamp # ms
91139 nframes = (dt // self .delta )
92140
93141 if nframes > 0 :
@@ -97,11 +145,23 @@ def onPDUReceived(self, pdu: PlayerPDU):
97145 self .writeFrame ()
98146 self .dirty = False
99147 nframes -= 1 # One frame was just encoded
148+
149+ # True gap (no PDUs at all): compress
150+ gap_threshold = int (self .idle_skip_ms / self .delta ) if self .idle_skip_ms > 0 else 0
151+ if gap_threshold > 0 and nframes > gap_threshold :
152+ self .total_skipped_ms += nframes * self .delta
153+ nframes = self .fps # Replace gap with 1s pause
154+
100155 # Skip remaining frames (player holds last frame)
101156 self .pts += nframes
102157 self .prevTimestamp = ts
103158
104159 def cleanup (self ):
160+ # Close out idle state if capture ends while idle
161+ if self ._in_idle and self ._idle_enter_ts and self .timestamp :
162+ self .total_skipped_ms += self .timestamp - self ._idle_enter_ts
163+ self ._in_idle = False
164+
105165 # Flush any pending dirty frame
106166 if self .dirty :
107167 self .writeFrame ()
@@ -116,6 +176,13 @@ def cleanup(self):
116176 if self .progress :
117177 self .progress ()
118178 self .mp4 .mux (pkt )
179+
180+ if self .total_skipped_ms > 0 :
181+ skipped_s = self .total_skipped_ms / 1000
182+ m , s = divmod (int (skipped_s ), 60 )
183+ h , m = divmod (m , 60 )
184+ self .log .info ('Total idle time skipped: %dh %dm %ds (%.1fs)' , h , m , s , skipped_s )
185+
119186 self .log .info ('Export completed.' )
120187 self .mp4 .close ()
121188
0 commit comments