Skip to content

Commit 924f651

Browse files
committed
Add --idle-skip option: input-based idle detection for MP4 export
Detect idle periods by absence of FAST_PATH_INPUT PDUs (keyboard/mouse) instead of screen changes. Cursor blinks and screen refreshes no longer prevent idle detection. During idle, PTS is frozen with one frame encoded every 10s for state capture. True PDU gaps are also compressed.
1 parent 688e766 commit 924f651

6 files changed

Lines changed: 95 additions & 13 deletions

File tree

pyrdp/bin/convert.py

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,14 @@ def main():
5757
"otherwise the result is output next to the source file with the proper extension. "
5858
"However if the source of the conversion is a .pcap then this option will create a directory where all files will be stored.",
5959
)
60+
parser.add_argument(
61+
"--idle-skip",
62+
type=int,
63+
default=0,
64+
metavar="N",
65+
help="Skip idle periods longer than N seconds in MP4 output (0 = disabled, default: 0)",
66+
)
67+
6068

6169
args = parser.parse_args()
6270

@@ -83,15 +91,19 @@ def main():
8391
else:
8492
outputPrefix = ""
8593

94+
handler_kwargs = {}
95+
if args.idle_skip > 0:
96+
handler_kwargs['idle_skip'] = args.idle_skip
97+
8698
if inputFile.suffix in [".pcap"]:
8799
secrets = loadSecrets(args.secrets) if args.secrets else None
88-
converter = PCAPConverter(inputFile, outputPrefix, args.format, secrets=secrets, srcFilter=args.src, dstFilter=args.dst, listOnly=args.list_only)
100+
converter = PCAPConverter(inputFile, outputPrefix, args.format, secrets=secrets, srcFilter=args.src, dstFilter=args.dst, listOnly=args.list_only, handler_kwargs=handler_kwargs)
89101
elif inputFile.suffix in [".pyrdp"]:
90102
if args.format == "replay":
91103
sys.stderr.write("Refusing to convert a replay file to a replay file. Choose another format.")
92104
sys.exit(1)
93105

94-
converter = ReplayConverter(inputFile, outputPrefix, args.format)
106+
converter = ReplayConverter(inputFile, outputPrefix, args.format, handler_kwargs=handler_kwargs)
95107
else:
96108
sys.stderr.write("Unknown file extension. (Supported: .pcap, .pyrdp)")
97109
sys.exit(1)

pyrdp/convert/Converter.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,10 +7,11 @@
77

88

99
class Converter:
10-
def __init__(self, inputFile: Path, outputPrefix: str, format: str):
10+
def __init__(self, inputFile: Path, outputPrefix: str, format: str, handler_kwargs: dict = None):
1111
self.inputFile = inputFile
1212
self.outputPrefix = outputPrefix
1313
self.format = format
14+
self.handler_kwargs = handler_kwargs or {}
1415

1516
def process(self):
1617
raise NotImplementedError("Converter.process is not implemented")

pyrdp/convert/MP4EventHandler.py

Lines changed: 71 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44
# Licensed under the GPLv3 or later.
55
#
66

7-
from pyrdp.enum import CapabilityType
7+
from pyrdp.enum import CapabilityType, PlayerPDUType
88
from pyrdp.pdu import PlayerPDU
99
from pyrdp.player.ImageHandler import ImageHandler
1010
from pyrdp.player.RenderingEventHandler import RenderingEventHandler
@@ -38,13 +38,14 @@ def screen(self) -> QImage:
3838

3939
class 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

pyrdp/convert/PCAPConverter.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -24,8 +24,8 @@
2424
class PCAPConverter(Converter):
2525
SESSIONID_FORMAT = "{timestamp}_{src}-{dst}"
2626

27-
def __init__(self, inputFile: Path, outputPrefix: str, format: str, secrets: Dict = None, srcFilter = None, dstFilter = None, listOnly = False):
28-
super().__init__(inputFile, outputPrefix, format)
27+
def __init__(self, inputFile: Path, outputPrefix: str, format: str, secrets: Dict = None, srcFilter = None, dstFilter = None, listOnly = False, handler_kwargs: dict = None):
28+
super().__init__(inputFile, outputPrefix, format, handler_kwargs=handler_kwargs)
2929
self.secrets = secrets if secrets is not None else {}
3030
self.srcFilter = srcFilter if srcFilter is not None else srcFilter
3131
self.dstFilter = dstFilter if dstFilter is not None else dstFilter
@@ -106,7 +106,7 @@ def processStream(self, startTimeStamp: int, stream: PCAPStream):
106106
})
107107
sessionID = sessionID.replace(":", "_")
108108

109-
handler, _ = createHandler(self.format, self.outputPrefix + sessionID)
109+
handler, _ = createHandler(self.format, self.outputPrefix + sessionID, **self.handler_kwargs)
110110
replayer = RDPReplayer(handler, self.outputPrefix, sessionID)
111111

112112
print(f"[*] Processing {stream.client} -> {stream.server}")

pyrdp/convert/ReplayConverter.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@ def process(self):
2020
print(f"[*] Converting '{self.inputFile}' to {self.format.upper()}")
2121

2222
outputFileBase = self.outputPrefix + self.inputFile.stem
23-
handler, outputPath = createHandler(self.format, outputFileBase)
23+
handler, outputPath = createHandler(self.format, outputFileBase, **self.handler_kwargs)
2424

2525
if not handler:
2626
print("The input file is already a replay file. Nothing to do.")

pyrdp/convert/utils.py

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -74,7 +74,7 @@ def extractInetAddressesFromPDUPacket(packet) -> Tuple[InetAddress, InetAddress]
7474
return (InetAddress(x.src, x.sport), InetAddress(x.dst, x.dport))
7575

7676

77-
def createHandler(format: str, outputFileBase: str, progress=None) -> Tuple[str, str]:
77+
def createHandler(format: str, outputFileBase: str, progress=None, **kwargs) -> Tuple[str, str]:
7878
"""
7979
Gets the appropriate handler and returns the filename with extension.
8080
Returns None if the format is replay.
@@ -87,7 +87,9 @@ def createHandler(format: str, outputFileBase: str, progress=None) -> Tuple[str,
8787

8888
HandlerClass, ext = HANDLERS[format]
8989
outputFileBase += f".{ext}"
90-
return HandlerClass(outputFileBase, progress=progress) if HandlerClass else None, outputFileBase
90+
if HandlerClass:
91+
return HandlerClass(outputFileBase, progress=progress, **kwargs), outputFileBase
92+
return None, outputFileBase
9193

9294

9395
class ExportedPDU(Packet):

0 commit comments

Comments
 (0)