Skip to content

Commit 0a24875

Browse files
authored
Fix WebRTC UI latency issue (#528)
* Fix write-driven WebRTC presentation Signed-off-by: Gangzheng Tong <gtong@nvidia.com> * Cam2V UI Enhancement (#534) Signed-off-by: Gangzheng Tong <gtong@nvidia.com> * Fix transient WebRTC disconnect handling Signed-off-by: Gangzheng Tong <gtong@nvidia.com> * Decompose realtime input handling Signed-off-by: Gangzheng Tong <gtong@nvidia.com> * Restore SlangPy Cam2V UI Signed-off-by: Gangzheng Tong <gtong@nvidia.com> * Remove WebRTC A/B benchmark harness Signed-off-by: Gangzheng Tong <gtong@nvidia.com> * Use continuous presentation for Cam2V Hoist input trace correlation and recent frame-rate tracking into reusable runtime helpers, and remove the input/idle redraw hooks from the UI loop API. Signed-off-by: Gangzheng Tong <gtong@nvidia.com> * Restore lossless event buffering Signed-off-by: Gangzheng Tong <gtong@nvidia.com> * Remove input event tracing Remove event correlation metadata and browser frame-marker updates while preserving presentation pacing, CUDA stream ordering, and the bounded WebRTC sender queue. Signed-off-by: Gangzheng Tong <gtong@nvidia.com> * Remove presentation metrics instrumentation Keep the existing metrics output sink limited to unmodified model results and remove presentation queue timing and depth diagnostics from the session runner. Signed-off-by: Gangzheng Tong <gtong@nvidia.com> * Make CUDA output readiness automatic Record readiness when CUDA StepResult objects are constructed, keep the event private, and centralize asynchronous consumer stream ordering without UI-loop event boilerplate. Signed-off-by: Gangzheng Tong <gtong@nvidia.com> * Remove redundant StepResult tests Keep the end-to-end CUDA stream-ordering regression and drop constructor microtests that duplicate the exercised presentation behavior. Signed-off-by: Gangzheng Tong <gtong@nvidia.com> * Move presentation stream ownership to manager Signed-off-by: Gangzheng Tong <gtong@nvidia.com> * Centralize compositing and simplify StepResult Signed-off-by: Gangzheng Tong <gtong@nvidia.com> * Address comments * Refine presentation timing and output access Reanchor UI deadlines after blocking writes so presentation stalls do not trigger catch-up bursts. Encapsulate StepResult tensor access behind consumer-stream event synchronization. Signed-off-by: Gangzheng Tong <gtong@nvidia.com> * Remove obsolete pointer coalescing test Signed-off-by: Gangzheng Tong <gtong@nvidia.com> * Update Waypoint v2 for presentation API Signed-off-by: Gangzheng Tong <gtong@nvidia.com> --------- Signed-off-by: Gangzheng Tong <gtong@nvidia.com>
1 parent bfc857d commit 0a24875

55 files changed

Lines changed: 3734 additions & 981 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

apps/cam2v/README.md

Lines changed: 28 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -6,16 +6,37 @@ Concrete integrations supply an existing runner config plus an input resolver
66
that turns their asset format into `Cam2VConditioning`.
77

88
The application owns the loaded pipeline. Each session owns its autoregressive
9-
cache, keyboard state, camera pose, and SlangPy UI overlay. The
10-
io-thread runs the UI loop over the current video frame; the
11-
model-generation-thread runs the model loop and is the only thread that mutates
12-
rollout state. Model status crosses to the UI loop through `invoke_async`
13-
messages.
9+
cache, keyboard state, camera pose, and SlangPy UI overlay. The UI thread draws
10+
the retained controls and status widgets over the current video frame; the model
11+
thread runs the model loop and is the only thread that mutates rollout state.
12+
Model status crosses to the UI loop through `invoke_async` messages.
13+
14+
Browser keyboard events are reflected in the outgoing video through the
15+
`Active keys` status line. Arrow keys share the corresponding WASD state, and
16+
losing browser focus clears held controls.
1417

1518
The overlay is enabled by default. Pass `-- --no-ui` after the application
1619
arguments to use the default model-output blitter for headless or benchmark
1720
runs.
1821

22+
The recent model-rate status is the wall-time-weighted throughput of
23+
autoregressive steps whose completions fall in the trailing two seconds. It
24+
excludes between-step pacing, publication, UI, WebRTC, network, and browser
25+
display time. Integrations may enable one concise console record per AR step;
26+
the Lingbot specialization logs its warmup/steady phase, frame count,
27+
synchronized step wall time, and chunk FPS. Model metrics retain the
28+
warmup-excluded cumulative `steady_state_fps` metric for benchmark comparisons.
29+
30+
Cam2V runs SlangPy rendering, model-frame conversion, and composition on a
31+
high-priority CUDA presentation stream. `CONTINUOUS` runs the UI every tick so
32+
browser input and time-driven status changes are reflected without waiting for
33+
a new model frame. The UI/write path owns output cadence; WebRTC does not pace
34+
frames again. It keeps two unsent frames in FIFO order and evicts the oldest
35+
queued frame on overflow. A frame already dequeued for the sender or encoder is
36+
committed and is outside that capacity. CUDA priority can overtake queued
37+
lower-priority kernels, but cannot preempt a model kernel that is already
38+
executing.
39+
1940
For UI testing without loading a real model, run the packaged dummy pipeline:
2041

2142
```bash
@@ -24,9 +45,8 @@ uv run flashdreams-run-v2 cam2v-dummy --mode webrtc \
2445
--step-wait-seconds 0.9 --frames-per-chunk 12
2546
```
2647

27-
The model-generation-thread waits on a `threading.Event` for each synthetic
28-
step while the io-thread continues collecting browser input and presenting
29-
generated frames.
48+
The model thread waits on a `threading.Event` for each synthetic step while the
49+
UI thread continues collecting browser input and presenting generated frames.
3050

3151
See `integrations_v2/cam2v_lingbot/cam2v_lingbot/app.py` for the minimal
3252
specialization pattern.

apps/cam2v/__init__.py

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,11 @@
1717
Cam2VSessionConfig,
1818
CameraControlInput,
1919
)
20-
from .ui import Cam2VSlangPyUILoop, Cam2VUIState, Cam2VUIStatus
20+
from .ui import (
21+
Cam2VSlangPyUILoop,
22+
Cam2VUIState,
23+
Cam2VUIStatus,
24+
)
2125

2226
__all__ = [
2327
"Cam2VApplication",

apps/cam2v/application.py

Lines changed: 1 addition & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -36,7 +36,6 @@ def __init__(self, *, defaults: Cam2VApplicationDefaults) -> None:
3636
self._pipeline_config = defaults.pipeline_config
3737
self._device = defaults.device
3838
self._total_blocks = defaults.total_blocks
39-
self._log_every_blocks = defaults.log_every_blocks
4039
self._warmup_blocks = defaults.warmup_blocks
4140
self._use_ui = True
4241
self._input_values: dict[str, Any] | None = None
@@ -101,12 +100,6 @@ def init(self, commandline_args: Sequence[str]) -> None:
101100
default=self.defaults.total_blocks,
102101
help="Autoregressive chunks generated per rollout. Default: %(default)s.",
103102
)
104-
parser.add_argument(
105-
"--log-every-blocks",
106-
type=int,
107-
default=self.defaults.log_every_blocks,
108-
help="Emit live timing every N steady-state chunks.",
109-
)
110103
parser.add_argument(
111104
"--warmup-blocks",
112105
type=int,
@@ -143,7 +136,6 @@ def init(self, commandline_args: Sequence[str]) -> None:
143136
)
144137
self._device = args.device
145138
self._total_blocks = args.total_blocks
146-
self._log_every_blocks = args.log_every_blocks
147139
self._warmup_blocks = args.warmup_blocks
148140
self._use_ui = args.ui
149141
self._input_values = {
@@ -204,8 +196,8 @@ def create_session(self, session_desc: SessionDesc) -> ISession:
204196
device=torch.device(self._device),
205197
first_frame_dtype=self.defaults.first_frame_dtype,
206198
first_frame_interpolation=self.defaults.first_frame_interpolation,
207-
log_every_blocks=self._log_every_blocks,
208199
warmup_blocks=self._warmup_blocks,
200+
log_model_timing=self.defaults.log_model_timing,
209201
install_hint=self.defaults.install_hint,
210202
),
211203
use_ui=self._use_ui,
@@ -230,8 +222,6 @@ def _validate_arguments(self, args: argparse.Namespace) -> None:
230222
"""Reject invalid rollout and timing settings."""
231223
if args.total_blocks <= 0:
232224
raise ValueError("--total-blocks must be > 0.")
233-
if args.log_every_blocks <= 0:
234-
raise ValueError("--log-every-blocks must be > 0.")
235225
if args.warmup_blocks < 0:
236226
raise ValueError("--warmup-blocks must be >= 0.")
237227
if args.world_scale is not None and args.world_scale < 0:

apps/cam2v/controls.py

Lines changed: 63 additions & 90 deletions
Original file line numberDiff line numberDiff line change
@@ -5,34 +5,29 @@
55

66
from __future__ import annotations
77

8-
from collections import deque
98
from dataclasses import dataclass, field
10-
from typing import Literal
9+
from typing import Literal, TypeAlias
1110

1211
import numpy as np
1312

1413
from flashdreams.runtime.keyboard import DEFAULT_SUPPORTED_KEYS, KeyboardState
14+
from flashdreams.runtime_v2.input_timeline import RealtimeInputTimeline
15+
from flashdreams.runtime_v2.keyboard_input import (
16+
KeyboardStateSegment,
17+
KeyboardStateTrack,
18+
)
1519

16-
PoseSegment = tuple[float, float, frozenset[str]]
20+
PoseSegment: TypeAlias = KeyboardStateSegment
1721
"""One time interval and the camera-control keys held throughout it."""
1822

1923

20-
@dataclass(frozen=True, slots=True)
21-
class _KeyboardEdge:
22-
"""One timestamped keyboard state transition."""
23-
24-
arrival_t: float
25-
"""Seconds since the WebRTC session began."""
26-
27-
event: str
28-
"""KeyboardState transition or the internal ``release_all`` action."""
29-
30-
key: str | None = None
31-
"""Browser key identifier, or ``None`` for ``release_all``."""
32-
33-
3424
class KeyboardResampler:
35-
"""Resample sparse key-down/key-up edges into a camera-control timeline."""
25+
"""Preserve Cam2V's legacy combined keyboard-resampler API.
26+
27+
New model-loop code uses :attr:`input_timeline` and
28+
:attr:`keyboard_track` separately. The combined surface remains for
29+
existing Cam2V and Lingbot callers.
30+
"""
3631

3732
def __init__(
3833
self,
@@ -41,95 +36,73 @@ def __init__(
4136
start_v: float = 0.0,
4237
supported_keys: frozenset[str] = DEFAULT_SUPPORTED_KEYS,
4338
) -> None:
44-
if fps <= 0:
45-
raise ValueError("fps must be > 0")
46-
self._fps = float(fps)
47-
self._dt = 1.0 / self._fps
48-
self._supported_keys = supported_keys
49-
self.next_chunk_start_v = start_v
50-
self._event_log: deque[_KeyboardEdge] = deque()
51-
self._carried_state = KeyboardState(supported_keys=supported_keys)
39+
self._input_timeline = RealtimeInputTimeline(
40+
samples_per_second=fps,
41+
start_s=start_v,
42+
)
43+
self._keyboard_track = KeyboardStateTrack(
44+
supported_keys=supported_keys,
45+
state_projection=KeyboardState.resolved_effective_keys,
46+
)
5247

5348
@property
5449
def fps(self) -> float:
55-
"""Return the target camera sampling rate."""
56-
return self._fps
50+
"""Return the target keyboard sampling rate."""
51+
return self._input_timeline.samples_per_second
5752

5853
@property
5954
def dt(self) -> float:
60-
"""Return the interval between adjacent camera samples."""
61-
return self._dt
55+
"""Return the interval between adjacent keyboard samples."""
56+
return self._input_timeline.sample_interval_s
57+
58+
@property
59+
def input_timeline(self) -> RealtimeInputTimeline:
60+
"""Return the modality-neutral clock backing this compatibility view."""
61+
return self._input_timeline
62+
63+
@property
64+
def keyboard_track(self) -> KeyboardStateTrack:
65+
"""Return the keyboard state track backing this compatibility view."""
66+
return self._keyboard_track
67+
68+
@property
69+
def next_chunk_start_v(self) -> float:
70+
"""Return the start of the next legacy sampling chunk."""
71+
return self._input_timeline.next_window_start_s
72+
73+
@next_chunk_start_v.setter
74+
def next_chunk_start_v(self, value: float) -> None:
75+
"""Move the legacy sampling cursor without clearing keyboard state."""
76+
self._input_timeline.reset(start_s=value)
6277

6378
def on_edge(self, *, arrival_t: float, event: str, key: str) -> None:
6479
"""Record one keyboard edge in timestamp order."""
65-
self._record_edge(_KeyboardEdge(arrival_t, event, key))
80+
self._keyboard_track.on_edge(
81+
timestamp_s=arrival_t,
82+
action=event,
83+
key=key,
84+
)
6685

6786
def release_all(self, *, arrival_t: float) -> None:
68-
"""Release every held key at ``arrival_t``, such as on focus loss."""
69-
self._record_edge(_KeyboardEdge(arrival_t, "release_all"))
87+
"""Release every held key at ``arrival_t``."""
88+
self._keyboard_track.release_all(timestamp_s=arrival_t)
7089

71-
def _record_edge(self, edge: _KeyboardEdge) -> None:
72-
"""Insert ``edge`` while preserving timestamp order."""
73-
if not self._event_log or edge.arrival_t >= self._event_log[-1].arrival_t:
74-
self._event_log.append(edge)
75-
return
76-
for index, queued_edge in enumerate(self._event_log):
77-
if edge.arrival_t < queued_edge.arrival_t:
78-
self._event_log.insert(index, edge)
79-
return
80-
self._event_log.append(edge)
81-
82-
def _apply_edge(self, edge: _KeyboardEdge) -> None:
83-
"""Apply one queued edge to the carried keyboard state."""
84-
if edge.event == "release_all":
85-
self._carried_state = KeyboardState(
86-
supported_keys=self._supported_keys,
87-
)
88-
return
89-
assert edge.key is not None
90-
self._carried_state.apply_event(event=edge.event, key=edge.key)
91-
92-
def sample_chunk(self, num_frames: int) -> tuple[list[PoseSegment], list[float]]:
93-
"""Return key-state segments and sample times for one model chunk."""
94-
if num_frames < 1:
95-
raise ValueError("num_frames must be >= 1")
96-
97-
chunk_start_v = self.next_chunk_start_v
98-
chunk_end_v = chunk_start_v + num_frames * self._dt
99-
while self._event_log and self._event_log[0].arrival_t < chunk_start_v:
100-
self._apply_edge(self._event_log.popleft())
101-
102-
segments: list[PoseSegment] = []
103-
previous_t = chunk_start_v
104-
previous_state = self._carried_state.resolved_effective_keys()
105-
while self._event_log and self._event_log[0].arrival_t <= chunk_end_v:
106-
edge = self._event_log.popleft()
107-
event_t = edge.arrival_t
108-
if event_t > previous_t:
109-
segments.append((previous_t, event_t, previous_state))
110-
self._apply_edge(edge)
111-
previous_state = self._carried_state.resolved_effective_keys()
112-
previous_t = event_t
113-
if previous_t < chunk_end_v:
114-
segments.append((previous_t, chunk_end_v, previous_state))
115-
elif not segments:
116-
segments.append((chunk_start_v, chunk_end_v, previous_state))
117-
118-
frame_times = [
119-
chunk_start_v + (index + 1) * self._dt for index in range(num_frames)
120-
]
121-
self.next_chunk_start_v = chunk_end_v
122-
return segments, frame_times
90+
def sample_chunk(
91+
self,
92+
num_frames: int,
93+
) -> tuple[list[KeyboardStateSegment], list[float]]:
94+
"""Return key-state segments and sample times for one legacy chunk."""
95+
window = self._input_timeline.next_window(num_frames)
96+
return self._keyboard_track.segments(window), list(window.sample_times_s)
12397

12498
def reset(self, *, start_v: float) -> None:
125-
"""Discard queued edges and restart the virtual camera clock."""
126-
self._event_log.clear()
127-
self._carried_state = KeyboardState(supported_keys=self._supported_keys)
128-
self.next_chunk_start_v = start_v
99+
"""Discard queued edges and restart the legacy sampling clock."""
100+
self._keyboard_track.reset()
101+
self._input_timeline.reset(start_s=start_v)
129102

130103
def event_log_size(self) -> int:
131104
"""Return the number of keyboard edges awaiting consumption."""
132-
return len(self._event_log)
105+
return self._keyboard_track.pending_event_count
133106

134107

135108
def _rotation_matrix(axis: str, angle_rad: float) -> np.ndarray:

apps/cam2v/defaults.py

Lines changed: 8 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -88,18 +88,18 @@ class Cam2VApplicationDefaults:
8888
backpressure_mode: BackpressureMode = BackpressureMode.BLOCK
8989
"""Preserve every generated model frame in presentation order."""
9090

91-
presentation_mode: PresentationMode = PresentationMode.ONLY_PRESENT_NEW
92-
"""Render and transmit the overlay once for each selected model frame."""
91+
presentation_mode: PresentationMode = PresentationMode.CONTINUOUS
92+
"""Render the UI every tick so controls and status remain responsive."""
9393

9494
ui_fps: int = 60
95-
"""Rate at which the io-thread reads inputs and runs the UI loop."""
96-
97-
log_every_blocks: int = 1
98-
"""Default interval between steady-state timing log records."""
95+
"""Rate at which the UI thread reads inputs and runs the UI loop."""
9996

10097
warmup_blocks: int = 5
10198
"""Leading blocks excluded from steady-state FPS."""
10299

100+
log_model_timing: bool = False
101+
"""Write one synchronized wall-time record for each AR model step."""
102+
103103
install_hint: str = ""
104104
"""Optional dependency hint included in first-frame loading failures."""
105105

@@ -113,10 +113,10 @@ def __post_init__(self) -> None:
113113
raise ValueError("Cam2VApplicationDefaults dimensions must be > 0.")
114114
if self.fps <= 0 or self.ui_fps <= 0:
115115
raise ValueError("Cam2VApplicationDefaults frame rates must be > 0.")
116-
if self.log_every_blocks <= 0:
117-
raise ValueError("Cam2VApplicationDefaults.log_every_blocks must be > 0.")
118116
if self.warmup_blocks < 0:
119117
raise ValueError("Cam2VApplicationDefaults.warmup_blocks must be >= 0.")
118+
if not isinstance(self.log_model_timing, bool):
119+
raise TypeError("Cam2VApplicationDefaults.log_model_timing must be bool.")
120120
object.__setattr__(
121121
self,
122122
"input_defaults",

0 commit comments

Comments
 (0)