cat / goq
clone

turns a linux box into a private steam streaming appliance: plug in, tap your key, play from another computer

feat: encode performance metrics + sparkline in info panel

dami etoile committed 2de22fd
Backend (host):
- Time screen capture and H.264 encode separately per frame
- Emit host-encode-stats event with encode_ms, capture_ms, size_bytes, fps
- Works in daemon mode — events emit regardless of UI presence

Frontend (side panel):
- New # performance section: encode ms, capture ms, frame size, fps
- 60-sample sparkline canvas showing encode time history
- Rolling window, auto-scaling y-axis
commit
2de22fd17607e3492493e69bd79a489d6c007b52
parent
2343f99080afe7fe2e3953997eb0acc393c1f1cc
author
dami etoile <dami.etoile@gmail.com>
2 files changed, +82 -4
Msrc-tauri/src/commands.rs25++++++++++--
Msrc/ui/index.html61++++++++++++++++++++++++++++++
M src-tauri/src/commands.rs +21 -4
@@ -658,7 +658,7 @@ impl ProtocolHandler for FrameStreamHandler {
658658 let count = self.connections.fetch_add(1, Ordering::SeqCst) + 1;659659 let _ = self.app.emit("host-connections", count);660660 eprintln!("[host] client connected: {} (total: {})", conn.remote_id(), count);661- if let Err(e) = stream_frames(conn).await {661+ if let Err(e) = stream_frames(conn, &self.app).await {662662 eprintln!("[host] stream error: {}", e);663663 }664664 let count = self.connections.fetch_sub(1, Ordering::SeqCst) - 1;
@@ -668,7 +668,7 @@ impl ProtocolHandler for FrameStreamHandler {
668668 }669669 }670670 671-async fn stream_frames(conn: Connection) -> anyhow::Result<()> {671+async fn stream_frames(conn: Connection, app: &AppHandle) -> anyhow::Result<()> {672672 let (mut send, mut recv) = conn.accept_bi().await?;673673 674674 let mut start_buf = [0u8; 1];
@@ -689,11 +689,15 @@ async fn stream_frames(conn: Connection) -> anyhow::Result<()> {
689689 let start = Instant::now();690690 691691 loop {692+ let capture_start = Instant::now();692693 let image = monitor.capture_image()?;694+ let capture_ms = capture_start.elapsed().as_secs_f64() * 1000.0;695+693696 let rgb_image = image::DynamicImage::ImageRgba8(image).to_rgb8();694697 let (w, h) = (rgb_image.width() as usize, rgb_image.height() as usize);695698 696699 // RGB8 → YUV → H.264700+ let encode_start = Instant::now();697701 let rgb_source = RgbSliceU8::new(rgb_image.as_raw(), (w, h));698702 let yuv = YUVBuffer::from_rgb8_source(rgb_source);699703 let (h264_data, is_keyframe) = {
@@ -701,6 +705,7 @@ async fn stream_frames(conn: Connection) -> anyhow::Result<()> {
701705 let kf = matches!(bitstream.frame_type(), openh264::encoder::FrameType::I | openh264::encoder::FrameType::IDR);702706 (bitstream.to_vec(), kf)703707 };708+ let encode_ms = encode_start.elapsed().as_secs_f64() * 1000.0;704709 let h264_size = h264_data.len();705710 706711 // Header: width(4) + height(4) + size(4) + is_keyframe(1) = 13 bytes
@@ -720,8 +725,20 @@ async fn stream_frames(conn: Connection) -> anyhow::Result<()> {
720725 let elapsed = start.elapsed();721726 let fps = frame_count as f64 / elapsed.as_secs_f64().max(0.001);722727 eprintln!(723- "[host] frame={} {}x{} h264={}B kf={} fps={:.1}",724- frame_count, w, h, h264_size, is_keyframe, fps728+ "[host] frame={} {}x{} h264={}B kf={} fps={:.1} enc={:.1}ms cap={:.1}ms",729+ frame_count, w, h, h264_size, is_keyframe, fps, encode_ms, capture_ms730+ );731+732+ let _ = app.emit(733+ "host-encode-stats",734+ serde_json::json!({735+ "frame": frame_count,736+ "encode_ms": (encode_ms * 10.0).round() / 10.0,737+ "capture_ms": (capture_ms * 10.0).round() / 10.0,738+ "size_bytes": h264_size,739+ "fps": (fps * 10.0).round() / 10.0,740+ "keyframe": is_keyframe,741+ }),725742 );726743 727744 match tokio::time::timeout(Duration::from_millis(1), recv.read(&mut [0u8; 1])).await {
M src/ui/index.html +61 -0
@@ -501,6 +501,14 @@
501501 <div class="info-row"><span class="info-label">webcodecs</span><span class="info-value" id="codec-webcodecs">checking...</span></div>502502 <div class="info-row"><span class="info-label">decode path</span><span class="info-value" id="codec-path">—</span></div>503503 </div>504+ <div class="panel-section">505+ <h3># performance</h3>506+ <div class="info-row"><span class="info-label">encode</span><span class="info-value" id="perf-encode">— ms</span></div>507+ <div class="info-row"><span class="info-label">capture</span><span class="info-value" id="perf-capture">— ms</span></div>508+ <div class="info-row"><span class="info-label">frame size</span><span class="info-value" id="perf-size">— KB</span></div>509+ <div class="info-row"><span class="info-label">fps</span><span class="info-value" id="perf-fps">—</span></div>510+ <canvas id="encode-spark" width="260" height="48" style="width:100%;height:48px;margin-top:8px;display:block;"></canvas>511+ </div>504512 <div class="panel-section">505513 <h3># registration</h3>506514 <div class="info-row"><span class="info-label">status</span><span class="info-value" id="reg-status">—</span></div>
@@ -821,6 +829,59 @@
821829 document.getElementById('host-conn-count').textContent = event.payload;822830 });823831 832+ // ─── Encode performance sparkline ───────────────────────────────────833+ const encodeHistory = [];834+ const MAX_HISTORY = 60;835+ const sparkCanvas = document.getElementById('encode-spark');836+ const sparkCtx = sparkCanvas.getContext('2d');837+838+ function drawSparkline() {839+ const w = sparkCanvas.width;840+ const h = sparkCanvas.height;841+ sparkCtx.clearRect(0, 0, w, h);842+ if (encodeHistory.length < 2) return;843+844+ const max = Math.max(...encodeHistory, 1);845+ const step = w / (MAX_HISTORY - 1);846+847+ // Fill area848+ sparkCtx.beginPath();849+ sparkCtx.moveTo(0, h);850+ encodeHistory.forEach((ms, i) => {851+ const x = i * step;852+ const y = h - (ms / max) * (h - 4) - 2;853+ sparkCtx.lineTo(x, y);854+ });855+ sparkCtx.lineTo((encodeHistory.length - 1) * step, h);856+ sparkCtx.closePath();857+ sparkCtx.fillStyle = 'rgba(74, 222, 128, 0.15)';858+ sparkCtx.fill();859+860+ // Line861+ sparkCtx.beginPath();862+ encodeHistory.forEach((ms, i) => {863+ const x = i * step;864+ const y = h - (ms / max) * (h - 4) - 2;865+ if (i === 0) sparkCtx.moveTo(x, y);866+ else sparkCtx.lineTo(x, y);867+ });868+ sparkCtx.strokeStyle = '#4ade80';869+ sparkCtx.lineWidth = 1;870+ sparkCtx.stroke();871+ }872+873+ listen('host-encode-stats', (event) => {874+ const { encode_ms, capture_ms, size_bytes, fps } = event.payload;875+ document.getElementById('perf-encode').textContent = encode_ms.toFixed(1) + ' ms';876+ document.getElementById('perf-capture').textContent = capture_ms.toFixed(1) + ' ms';877+ document.getElementById('perf-size').textContent = (size_bytes / 1024).toFixed(1) + ' KB';878+ document.getElementById('perf-fps').textContent = fps.toFixed(1);879+880+ encodeHistory.push(encode_ms);881+ if (encodeHistory.length > MAX_HISTORY) encodeHistory.shift();882+ drawSparkline();883+ });884+824885 // ─── WebCodecs detection ────────────────────────────────────────────825886 let hasWebCodecs = ('VideoDecoder' in window);826887 let videoDecoder = null;