From 70b60d68c3d0a1166fd239a365505938990d5b47 Mon Sep 17 00:00:00 2001 From: spacedouut Date: Mon, 14 Sep 2026 18:24:50 +0000 Subject: [PATCH 1/3] feat: per-frame framing on video uni stream Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- AGENTS.md | 14 ++- src/framer.go | 151 +++++++++++++++++++++++ src/framer_test.go | 89 ++++++++++++++ src/stream.go | 53 +++++++-- src/types.go | 2 + src/web/dist/assets/index-DaqCH3dr.js | 2 + src/web/dist/assets/index-zx29XWV1.js | 2 - src/web/dist/index.html | 2 +- src/web/src/decoder.ts | 165 ++++++-------------------- src/web/src/main.ts | 2 +- src/web/src/transport.ts | 21 +++- src/web/src/types.ts | 6 + 12 files changed, 360 insertions(+), 149 deletions(-) create mode 100644 src/framer.go create mode 100644 src/framer_test.go create mode 100644 src/web/dist/assets/index-DaqCH3dr.js delete mode 100644 src/web/dist/assets/index-zx29XWV1.js diff --git a/AGENTS.md b/AGENTS.md index 2e56145..7ac0712 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -37,6 +37,18 @@ JSON control messages (bidirectional stream): {"type":"fingerprint-refresh","algorithm":"sha-256","fingerprint":""} // sent on connect + cert rotation ``` +Video uni-stream records use one record per access unit: + +| Bytes | Description | +|-------|-------------| +| 1 | Flags; bit 0 is set when the access unit contains an IDR NAL | +| 8 | Timestamp in milliseconds since the stream started, big-endian uint64 | +| 4 | Access-unit payload length, big-endian uint32 | +| N | Annex B access unit payload, including start codes | + +The client may receive records split across reads and must parse each complete +record before decoding it. + `start` also accepts optional `codec` and `bitrate`. On connect the agent pushes `fingerprint-refresh` (only when it manages its own @@ -75,7 +87,7 @@ await transport.ready; const stream = await transport.createBidirectionalStream(); ``` -**Note**: MoQ integration has been removed. The agent now publishes video exclusively over WebTransport unidirectional streams (raw H.264 Annex B). +**Note**: MoQ integration has been removed. The agent now publishes video exclusively over WebTransport unidirectional streams using the framed H.264 format above. ### Web UI diff --git a/src/framer.go b/src/framer.go new file mode 100644 index 0000000..73d6b30 --- /dev/null +++ b/src/framer.go @@ -0,0 +1,151 @@ +package main + +import "encoding/binary" + +type framer struct { + buf []byte + pending [][]byte + ready [][]byte +} + +func (f *framer) Push(b []byte) [][]byte { + f.buf = append(f.buf, b...) + for { + nal, ok := f.nextNAL() + if !ok { + break + } + f.ingestNAL(nal) + } + out := f.ready + f.ready = nil + return out +} + +func (f *framer) Flush() [][]byte { + if start := findStartCode(f.buf, 0); start >= 0 { + dataStart := start + startCodeLen(f.buf, start) + if dataStart < len(f.buf) { + f.ingestNAL(append([]byte(nil), f.buf[start:]...)) + } + } + f.buf = nil + if len(f.pending) > 0 { + f.ready = append(f.ready, joinNALs(f.pending)) + f.pending = nil + } + out := f.ready + f.ready = nil + return out +} + +func (f *framer) nextNAL() ([]byte, bool) { + start := findStartCode(f.buf, 0) + if start < 0 { + if len(f.buf) > 3 { + f.buf = append([]byte(nil), f.buf[len(f.buf)-3:]...) + } + return nil, false + } + dataStart := start + startCodeLen(f.buf, start) + next := findStartCode(f.buf, dataStart) + if next < 0 { + f.buf = append([]byte(nil), f.buf[start:]...) + return nil, false + } + nal := append([]byte(nil), f.buf[start:next]...) + f.buf = f.buf[next:] + return nal, true +} + +func (f *framer) ingestNAL(nal []byte) { + if len(nal) == 0 { + return + } + t := nalType(nal) + if isVCL(t) && startsNewPicture(nal) && len(f.pending) > 0 { + split := len(f.pending) + for split > 0 && !isVCL(nalType(f.pending[split-1])) { + split-- + } + if split > 0 { + f.ready = append(f.ready, joinNALs(f.pending[:split])) + f.pending = append([][]byte(nil), f.pending[split:]...) + } + } + f.pending = append(f.pending, nal) +} + +func joinNALs(nals [][]byte) []byte { + size := 0 + for _, nal := range nals { + size += len(nal) + } + out := make([]byte, 0, size) + for _, nal := range nals { + out = append(out, nal...) + } + return out +} + +func findStartCode(buf []byte, from int) int { + for i := from; i+3 <= len(buf); i++ { + if buf[i] != 0 || buf[i+1] != 0 { + continue + } + if buf[i+2] == 1 { + return i + } + if i+3 < len(buf) && buf[i+2] == 0 && buf[i+3] == 1 { + return i + } + } + return -1 +} + +func startCodeLen(buf []byte, i int) int { + if i+3 < len(buf) && buf[i+2] == 0 && buf[i+3] == 1 { + return 4 + } + return 3 +} + +func nalType(nal []byte) byte { + i := startCodeLen(nal, 0) + if i >= len(nal) { + return 0 + } + return nal[i] & 0x1f +} + +func isVCL(t byte) bool { + return t >= 1 && t <= 5 +} + +func startsNewPicture(nal []byte) bool { + i := startCodeLen(nal, 0) + return i+1 < len(nal) && nal[i+1]&0x80 != 0 +} + +func keyframe(au []byte) bool { + for i := 0; ; { + start := findStartCode(au, i) + if start < 0 { + return false + } + t := nalType(au[start:]) + if t == 5 { + return true + } + i = start + startCodeLen(au, start) + } +} + +func encodeFrame(flags byte, tsMs uint64, au []byte) []byte { + out := make([]byte, 13+len(au)) + out[0] = flags + binary.BigEndian.PutUint64(out[1:9], tsMs) + binary.BigEndian.PutUint32(out[9:13], uint32(len(au))) + copy(out[13:], au) + return out +} diff --git a/src/framer_test.go b/src/framer_test.go new file mode 100644 index 0000000..53bfbc8 --- /dev/null +++ b/src/framer_test.go @@ -0,0 +1,89 @@ +package main + +import ( + "bytes" + "testing" +) + +func TestFramerChunking(t *testing.T) { + sps := []byte{0, 0, 0, 1, 0x67, 0x42, 0x00} + pps := []byte{0, 0, 1, 0x68, 0xce, 0x06} + idr := []byte{0, 0, 0, 1, 0x65, 0x88, 0x11} + slice1 := []byte{0, 0, 1, 0x41, 0x9a, 0x22} + slice2 := []byte{0, 0, 0, 1, 0x41, 0x9a, 0x33} + stream := append(append(append(append(append([]byte{}, sps...), pps...), idr...), slice1...), slice2...) + want := [][]byte{ + append(append(append([]byte{}, sps...), pps...), idr...), + slice1, + slice2, + } + + tests := []struct { + name string + chunks [][]byte + }{ + { + name: "all at once", + chunks: [][]byte{ + stream, + }, + }, + { + name: "one byte at a time", + chunks: func() [][]byte { + out := make([][]byte, len(stream)) + for i := range stream { + out[i] = stream[i : i+1] + } + return out + }(), + }, + { + name: "split start code", + chunks: [][]byte{ + stream[:len(sps)+len(pps)+len(idr)+1], + stream[len(sps)+len(pps)+len(idr)+1 : len(sps)+len(pps)+len(idr)+2], + stream[len(sps)+len(pps)+len(idr)+2:], + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + var f framer + var got [][]byte + for _, chunk := range tt.chunks { + got = append(got, f.Push(chunk)...) + } + got = append(got, f.Flush()...) + if len(got) != len(want) { + t.Fatalf("got %d AUs, want %d", len(got), len(want)) + } + for i := range want { + if !bytes.Equal(got[i], want[i]) { + t.Errorf("AU %d = %x, want %x", i, got[i], want[i]) + } + } + if !keyframe(got[0]) { + t.Error("AU 0 is not marked as a keyframe") + } + if keyframe(got[1]) || keyframe(got[2]) { + t.Error("non-IDR AUs marked as keyframes") + } + }) + } +} + +func TestEncodeFrame(t *testing.T) { + au := []byte{0, 0, 1, 0x65, 0x88} + got := encodeFrame(1, 0x0102030405060708, au) + want := []byte{ + 1, + 1, 2, 3, 4, 5, 6, 7, 8, + 0, 0, 0, 5, + 0, 0, 1, 0x65, 0x88, + } + if !bytes.Equal(got, want) { + t.Fatalf("frame = %x, want %x", got, want) + } +} diff --git a/src/stream.go b/src/stream.go index 381f70a..358dc00 100644 --- a/src/stream.go +++ b/src/stream.go @@ -3,6 +3,7 @@ package main import ( "context" "fmt" + "io" "log" "time" @@ -66,6 +67,7 @@ func startStream(displayID, fps int, codec string, bitrate int, caller *subscrib stream: stream, subscribers: make(map[*subscriber]struct{}), stopPub: pubCancel, + streamStart: time.Now(), owner: caller, } @@ -85,14 +87,12 @@ func publishStream(ctx context.Context, ss *streamState) { } stateMu.Unlock() }() - for chunk := range ss.stream.Chunks() { - if ctx.Err() != nil { - return - } - + fr := &framer{} + publish := func(au []byte) { + frame := encodeFrame(boolToByte(keyframe(au)), uint64(time.Since(ss.streamStart).Milliseconds()), au) ss.subMu.Lock() + defer ss.subMu.Unlock() if ss.subscribers == nil { - ss.subMu.Unlock() return } for sub := range ss.subscribers { @@ -100,13 +100,50 @@ func publishStream(ctx context.Context, ss *streamState) { continue } sub.video.SetWriteDeadline(time.Now().Add(100 * time.Millisecond)) - if _, err := sub.video.Write(chunk.Data); err != nil { + if err := writeFrame(sub.video, frame); err != nil { sub.video.Close() delete(ss.subscribers, sub) } } - ss.subMu.Unlock() } + for chunk := range ss.stream.Chunks() { + if ctx.Err() != nil { + return + } + for _, au := range fr.Push(chunk.Data) { + if ctx.Err() != nil { + return + } + publish(au) + } + } + for _, au := range fr.Flush() { + if ctx.Err() != nil { + return + } + publish(au) + } +} + +func writeFrame(w interface{ Write([]byte) (int, error) }, frame []byte) error { + for len(frame) > 0 { + n, err := w.Write(frame) + if err != nil { + return err + } + if n <= 0 { + return io.ErrNoProgress + } + frame = frame[n:] + } + return nil +} + +func boolToByte(v bool) byte { + if v { + return 1 + } + return 0 } func teardown() { diff --git a/src/types.go b/src/types.go index 93a438a..a125296 100644 --- a/src/types.go +++ b/src/types.go @@ -4,6 +4,7 @@ import ( "context" "strings" "sync" + "time" "github.com/okdaichi/webtransport-go" @@ -35,6 +36,7 @@ type streamState struct { subscribers map[*subscriber]struct{} subMu sync.Mutex stopPub context.CancelFunc + streamStart time.Time owner *subscriber } diff --git a/src/web/dist/assets/index-DaqCH3dr.js b/src/web/dist/assets/index-DaqCH3dr.js new file mode 100644 index 0000000..b5d79ef --- /dev/null +++ b/src/web/dist/assets/index-DaqCH3dr.js @@ -0,0 +1,2 @@ +(function(){const t=document.createElement("link").relList;if(t&&t.supports&&t.supports("modulepreload"))return;for(const i of document.querySelectorAll('link[rel="modulepreload"]'))n(i);new MutationObserver(i=>{for(const r of i)if(r.type==="childList")for(const c of r.addedNodes)c.tagName==="LINK"&&c.rel==="modulepreload"&&n(c)}).observe(document,{childList:!0,subtree:!0});function e(i){const r={};return i.integrity&&(r.integrity=i.integrity),i.referrerPolicy&&(r.referrerPolicy=i.referrerPolicy),i.crossOrigin==="use-credentials"?r.credentials="include":i.crossOrigin==="anonymous"?r.credentials="omit":r.credentials="same-origin",r}function n(i){if(i.ep)return;i.ep=!0;const r=e(i);fetch(i.href,r)}})();function o(s,t={},e=[]){const n=document.createElement(s);for(const[i,r]of Object.entries(t))r!==void 0&&(i==="class"?n.className=r:i==="text"?n.textContent=r:n.setAttribute(i,r));for(const i of e)n.append(i);return n}function W(s){const t=s.replace(/[^0-9a-fA-F]/g,"");if(t.length%2!==0)throw new Error("invalid hex length");const e=new Uint8Array(t.length/2);for(let n=0;nn.classList.add("hidden"),e))}class U{wt=null;ctrlWriter=null;ctrlReader=null;msgHandler=null;videoHandler=null;statsHandler=null;ctrlBuf=new Uint8Array(0);enc=new TextEncoder;dec=new TextDecoder;rxBytes=0;windowBytes=0;lastWindow=performance.now();bitrate=0;pendingSince=0;rtt=0;statsTimer;closed=!1;onMessage(t){this.msgHandler=t}setVideoHandler(t){this.videoHandler=t}onStats(t){this.statsHandler=t}get connected(){return this.wt!==null}async connect(t){this.closed=!1;const e=W(t.fingerprintHex);if(e.length!==32)throw new Error("fingerprint must be a 32-byte SHA-256 hex string");const n=new WebTransport(t.url,{serverCertificateHashes:[{algorithm:"sha-256",value:e}]});this.wt=n,n.closed.then(()=>{this.closed||this.msgHandler?.({type:"stream-ended"})},()=>{this.closed||this.msgHandler?.({type:"stream-ended"})}),await n.ready;const i=await n.createBidirectionalStream();this.ctrlWriter=i.writable.getWriter(),this.ctrlReader=i.readable.getReader(),this.readControlLoop(),this.readVideoLoop(),this.statsTimer=window.setInterval(()=>this.tickStats(),1e3)}send(t){if(!this.ctrlWriter)throw new Error("not connected");(t.type==="list-displays"||t.type==="start")&&(this.pendingSince=performance.now());const e=this.enc.encode(JSON.stringify(t)+` +`);this.ctrlWriter.write(e).catch(()=>{this.closed||this.msgHandler?.({type:"stream-ended"})})}listDisplays(){this.send({type:"list-displays"})}start(t,e={}){this.send({type:"start",display_id:t,...e})}stop(){this.send({type:"stop"})}getStats(){return{bitrate:this.bitrate,rtt:this.rtt,rxBytes:this.rxBytes}}close(){this.closed=!0,this.statsTimer&&window.clearInterval(this.statsTimer),this.statsTimer=void 0;try{this.ctrlWriter?.close()}catch{}try{this.wt?.close()}catch{}this.wt=null,this.ctrlWriter=null,this.ctrlReader=null}async readControlLoop(){const t=this.ctrlReader;if(t)try{for(;;){const{value:e,done:n}=await t.read();if(n)break;if(!e)continue;this.ctrlBuf=C(this.ctrlBuf,e);let i;for(;(i=K(this.ctrlBuf))!==-1;){const r=this.dec.decode(this.ctrlBuf.subarray(0,i));this.ctrlBuf=this.ctrlBuf.subarray(i+1);const c=r.trim();if(!c)continue;let h;try{h=JSON.parse(c)}catch{continue}(h.type==="displays"||h.type==="started")&&this.pendingSince&&(this.rtt=Math.round(performance.now()-this.pendingSince),this.pendingSince=0),this.msgHandler?.(h)}}}catch{}}async readVideoLoop(){const t=this.wt;if(t)try{const e=t.incomingUnidirectionalStreams.getReader();for(;;){const{value:n,done:i}=await e.read();if(i)break;n&&this.pumpVideoStream(n)}}catch{}}async pumpVideoStream(t){const e=t.getReader();let n=new Uint8Array(0);try{for(;;){const{value:i,done:r}=await e.read();if(r)break;if(!(!i||i.byteLength===0))for(this.rxBytes+=i.byteLength,this.windowBytes+=i.byteLength,n=C(n,i);n.length>=13;){const c=new DataView(n.buffer,n.byteOffset,n.byteLength),a=13+c.getUint32(9);if(n.length0&&(this.bitrate=Math.round(this.windowBytes*8/e),this.windowBytes=0,this.lastWindow=t),this.statsHandler?.(this.getStats())}}function C(s,t){const e=new Uint8Array(s.length+t.length);return e.set(s,0),e.set(t,s.length),e}function K(s){for(let t=0;t>8&255,e[n++]=s.length&255,e.set(s,n),n+=s.length,e[n++]=1,e[n++]=t.length>>8&255,e[n++]=t.length&255,e.set(t,n),n+=t.length,e.subarray(0,n)}function V(s){const t=e=>e.toString(16).padStart(2,"0");return`avc1.${t(s[1])}${t(s[2])}${t(s[3])}`}function $(s,t){for(let e=t;e+3<=s.length;e++)if(s[e]===0&&s[e+1]===0&&(s[e+2]===1||s[e+2]===0&&e+3"u")throw new Error("WebCodecs VideoDecoder is not available in this browser")}configure(t,e,n,i){this.reset(),i&&i>0&&(this.fps=i)}feedFrame(t){const e=_(t.data);e.length!==0&&this.emitAU(e,t)}emitAU(t,e){if(t.some(a=>{const f=a[0]&31;return f===E&&(this.sps=a.slice()),f===A&&(this.pps=a.slice()),f===E||f===A})&&this.sps&&this.pps&&this.configureDecoder(this.sps,this.pps),!this.seenKeyframe){if(!e.keyframe){this.droppedBeforeKeyframe++;return}this.seenKeyframe=!0,this.droppedBeforeKeyframe>0&&console.info(`[decoder] dropped ${this.droppedBeforeKeyframe} access unit(s) before the first keyframe`)}if(!this.configured){this.pendingBeforeConfig.length<8&&this.pendingBeforeConfig.push({frame:e,nals:t});return}if(!this.videoDecoder||this.videoDecoder.state!=="configured")return;let i=0;for(const a of t)i+=4+a.length;const r=new Uint8Array(i);let c=0;for(const a of t)r[c++]=a.length>>24&255,r[c++]=a.length>>16&255,r[c++]=a.length>>8&255,r[c++]=a.length&255,r.set(a,c),c+=a.length;const h=Math.round(1e6/this.fps);try{this.videoDecoder.decode(new EncodedVideoChunk({type:e.keyframe?"key":"delta",timestamp:e.timestampMs*1e3,duration:h,data:r}))}catch(a){a instanceof DOMException&&a.name==="InvalidStateError"||console.warn("[decoder] decode threw",a)}}onDecoderError(t){console.error("[decoder] error",t),this.configured=!1,this.seenKeyframe=!1,this.sps=null,this.pps=null,this.pendingBeforeConfig=[],this.videoDecoder=null}configureDecoder(t,e){const n=V(t);if(!(this.configured&&this.codec===n)){if(this.videoDecoder&&this.videoDecoder.state!=="closed")try{this.videoDecoder.reset()}catch{}this.videoDecoder=new VideoDecoder({output:i=>this.onFrame(i),error:i=>this.onDecoderError(i)});try{this.videoDecoder.configure({codec:n,description:q(t,e),optimizeForLatency:!0})}catch(i){console.error("[decoder] configure failed",n,i);return}if(this.codec=n,this.configured=!0,console.info("[decoder] configured",n),this.pendingBeforeConfig.length){const i=this.pendingBeforeConfig;this.pendingBeforeConfig=[];for(const r of i)this.emitAU(r.nals,r.frame)}}}onFrame(t){const e=t.displayWidth||t.codedWidth,n=t.displayHeight||t.codedHeight;(this.canvas.width!==e||this.canvas.height!==n)&&(this.canvas.width=e,this.canvas.height=n),this.ctx.drawImage(t,0,0,e,n),t.close(),this.frameCount++,this.tickFps(),this.firstFrameDrawn||(this.firstFrameDrawn=!0,this.onFirstFrame?.())}tickFps(){const t=performance.now(),e=(t-this.fpsWindowStart)/1e3;e>=1&&(this.measuredFps=Math.round(this.frameCount/e),this.frameCount=0,this.fpsWindowStart=t,this.onFps?.(this.measuredFps))}get currentFps(){return this.measuredFps}reset(){this.pendingBeforeConfig=[],this.sps=null,this.pps=null,this.configured=!1,this.codec="",this.frameCount=0,this.measuredFps=0,this.firstFrameDrawn=!1,this.seenKeyframe=!1,this.droppedBeforeKeyframe=0;try{this.videoDecoder?.reset()}catch{}this.videoDecoder=null}close(){try{this.videoDecoder?.close()}catch{}this.videoDecoder=null,this.configured=!1}}class Y{send;target=null;locked=!1;bound=[];constructor(t){this.send=t}attach(t){this.target=t,this.on(t,"click",()=>{!this.locked&&t.requestPointerLock&&t.requestPointerLock()}),this.on(document,"pointerlockchange",()=>{this.locked=document.pointerLockElement===t,t.classList.toggle("locked",this.locked),this.locked&&this.tryKeyboardLock()}),this.on(t,"mousemove",e=>{if(!this.locked)return;const n=e;this.send({type:"input",kind:"mouse",dx:n.movementX,dy:n.movementY,buttons:n.buttons})}),this.on(t,"mousedown",e=>{if(!this.locked)return;const n=e;this.send({type:"input",kind:"mousedown",button:n.button})}),this.on(t,"mouseup",e=>{if(!this.locked)return;const n=e;this.send({type:"input",kind:"mouseup",button:n.button})}),this.on(t,"wheel",e=>{if(!this.locked)return;e.preventDefault();const n=e;this.send({type:"input",kind:"wheel",dx:n.deltaX,dy:n.deltaY})},{passive:!1}),this.on(t,"contextmenu",e=>e.preventDefault()),this.on(window,"keydown",e=>this.onKey(e,!0)),this.on(window,"keyup",e=>this.onKey(e,!1)),this.on(t,"touchstart",e=>this.onTouch(e,"start"),{passive:!1}),this.on(t,"touchmove",e=>this.onTouch(e,"move"),{passive:!1}),this.on(t,"touchend",e=>this.onTouch(e,"end"),{passive:!1})}get isLocked(){return this.locked}release(){this.locked&&document.exitPointerLock&&document.exitPointerLock();const t=navigator.keyboard;t?.unlock&&t.unlock()}detach(){this.release();for(const[t,e,n,i]of this.bound)t.removeEventListener(e,n,i);this.bound=[],this.target=null}on(t,e,n,i){t.addEventListener(e,n,i),this.bound.push([t,e,n,i??!1])}tryKeyboardLock(){const t=navigator.keyboard;t?.lock&&t.lock(["Tab","Space","ArrowUp","ArrowDown","ArrowLeft","ArrowRight","F1","F2","F3","F4","F5","F6","F7","F8","F9","F10","F11","F12"]).catch(()=>{})}onKey(t,e){if(!this.locked)return;["Tab","Space","ArrowUp","ArrowDown","ArrowLeft","ArrowRight","'","/","F1","F2","F3","F4","F5","F6","F7","F8","F9","F10","F11","F12"].includes(t.key)&&t.preventDefault(),this.send({type:"input",kind:"key",code:t.code,down:e})}onTouch(t,e){if(!this.target)return;t.preventDefault();const n=this.target.getBoundingClientRect(),i=t.changedTouches;for(let r=0;rthis.doConnect());const f=o("button",{class:"btn ghost",text:"Scan QR"});f.addEventListener("click",()=>this.openQr()),a.append(this.connectBtn,f);const x=o("div",{class:"field"},[o("label",{text:"…or paste a connection JSON"})]),v=o("textarea",{placeholder:'{"host":"10.10.1.5","port":52020,"fingerprint":"abcd…","label":"pc"}',spellcheck:"false"});v.addEventListener("change",()=>this.applyJson(v.value)),x.append(v),t.append(n,i,h,a,x);const L=this.loadRecent();if(L.length){const B=o("div",{class:"recent"},[o("h2",{text:"Recent hosts"})]),I=o("div",{class:"recent-list"});for(const d of L){const w=o("div",{class:"recent-item"}),R=o("div",{class:"meta"},[o("div",{class:"host",text:d.label?`${d.label} · ${d.host}:${d.port}`:`${d.host}:${d.port}`}),o("div",{class:"fp",text:d.fingerprint})]);w.append(R);const D=o("button",{class:"del",title:"forget",text:"×"});D.addEventListener("click",P=>{P.stopPropagation(),this.forget(d.fingerprint+d.host),this.render()}),w.append(D),w.addEventListener("click",()=>{this.fpInput.value=d.fingerprint,this.hostInput.value=d.host,this.portInput.value=String(d.port),d.label&&(this.labelInput.value=d.label)}),I.append(w)}B.append(I),t.append(B)}this.root.append(t),this.fpInput.value=e.fingerprint??""}suggestFromPage(){const t={host:location.hostname||"",port:52020,fingerprint:void 0};return fetch("/api/info").then(e=>e.json()).then(e=>{e?.fingerprint&&!this.fpInput.value&&(this.fpInput.value=e.fingerprint),Array.isArray(e?.ips)&&e.ips.length&&!this.hostInput.value&&(this.hostInput.value=e.ips[0])}).catch(()=>{}),t}async doConnect(){const t=this.fpInput.value.trim().replace(/\s+/g,""),e=this.hostInput.value.trim(),n=parseInt(this.portInput.value.trim(),10)||52020,i=this.labelInput.value.trim()||void 0;if(!/^[0-9a-fA-F]{64}$/.test(t)){l("Fingerprint must be 64 hex characters","err");return}if(!e){l("Enter the agent host","err");return}this.connectBtn.disabled=!0,this.connectBtn.textContent="Connecting…";try{const r=await this.deps.connectAndList({host:e,port:n,fingerprint:t,label:i});this.deps.onConnected?.({host:e,port:n,fingerprint:t,label:i}),this.showDisplays(r,{host:e,port:n,fingerprint:t,label:i})}catch(r){l(`Connection failed: ${r.message}`,"err"),this.connectBtn.disabled=!1,this.connectBtn.textContent="Connect"}}showDisplays(t,e){this.root.innerHTML="";const n=o("div",{class:"connect-card"});n.append(o("div",{class:"brand"},[o("span",{class:"dot"}),o("h1",{text:"Choose a display"})]),o("div",{class:"subtitle",text:`${e.host}:${e.port}`})),t.length||n.append(o("div",{class:"muted",text:"No displays reported by the agent yet."}));const i=o("div",{class:"displays"});for(const r of t){const c=o("button",{class:"display-tile"},[o("div",{class:"name",text:`Display ${r.id}`}),o("div",{class:"res",text:`${r.width}×${r.height} @ ${r.refresh_rate||"?"}Hz`})]);c.addEventListener("click",()=>{this.deps.startStream(r.id)}),i.append(c)}n.append(i),this.root.append(n)}applyJson(t){let e;try{e=JSON.parse(t.trim())}catch{return l("Invalid connection JSON","err"),!1}return e===null||Array.isArray(e)||typeof e!="object"?(l("Invalid connection JSON: expected object","err"),!1):e.fingerprint&&typeof e.fingerprint!="string"?(l("Invalid connection JSON: fingerprint must be string","err"),!1):e.host&&typeof e.host!="string"?(l("Invalid connection JSON: host must be string","err"),!1):e.port!==void 0&&typeof e.port!="number"?(l("Invalid connection JSON: port must be number","err"),!1):e.label!==void 0&&typeof e.label!="string"?(l("Invalid connection JSON: label must be string","err"),!1):(e.fingerprint&&(this.fpInput.value=e.fingerprint),e.host&&(this.hostInput.value=e.host),e.port&&(this.portInput.value=String(e.port)),e.label&&(this.labelInput.value=e.label),l("Filled from JSON","ok"),!0)}openQr(){if(!window.BarcodeDetector){l("QR scanning requires Chrome/Edge with BarcodeDetector","err");return}const t=o("div",{class:"qr-modal"}),e=o("div",{class:"box"},[o("h3",{text:"Scan connection QR"})]),n=o("video",{class:"qr-video",playsinline:"true"});e.append(n),e.append(o("div",{class:"muted",text:"Point your camera at the agent’s QR code."}));const i=o("button",{class:"btn ghost",text:"Cancel"});i.addEventListener("click",()=>this.stopQr(t)),e.append(o("div",{class:"actions"},[i])),t.append(e),document.body.append(t),navigator.mediaDevices.getUserMedia({video:{facingMode:"environment"}}).then(async r=>{if(!t.isConnected){r.getTracks().forEach(c=>c.stop());return}this.qrStream=r,n.srcObject=r,await n.play(),this.scanLoop(n,t)}).catch(()=>{l("Camera unavailable","err"),this.stopQr(t)})}async scanLoop(t,e){if(!this.qrStream||!window.BarcodeDetector)return;const n=new BarcodeDetector({formats:["qr"]}),i=async()=>{if(this.qrStream){try{const r=await n.detect(t);for(const c of r)if(this.applyJson(c.rawValue)){this.stopQr(e);return}}catch{}requestAnimationFrame(i)}};i()}stopQr(t){this.qrStream?.getTracks().forEach(e=>e.stop()),this.qrStream=null,t.remove()}loadRecent(){try{const t=localStorage.getItem(b);return t?JSON.parse(t).sort((n,i)=>i.last-n.last).slice(0,8):[]}catch{return[]}}remember(t){try{const e=this.loadRecent().filter(n=>!(n.host===t.host&&n.port===(t.port??52020)));e.unshift({host:t.host,port:t.port??52020,fingerprint:t.fingerprint,label:t.label,last:Date.now()}),localStorage.setItem(b,JSON.stringify(e.slice(0,8)))}catch{}}forget(t){try{const e=this.loadRecent().filter(n=>n.fingerprint+n.host!==t);localStorage.setItem(b,JSON.stringify(e))}catch{}}saveRecent(t){this.remember(t)}}class X{root;visible=!1;last={fps:0,rtt:0,bitrate:0,width:0,height:0,online:!1};constructor(t){this.root=t}toggle(){this.visible=!this.visible,this.root.classList.toggle("hidden",!this.visible),this.visible&&this.render()}show(){this.visible=!0,this.root.classList.remove("hidden"),this.render()}get isVisible(){return this.visible}update(t){this.last={...this.last,...t},this.visible&&this.render()}row(t,e,n=""){return`
${t}${e}
`}render(){const t=this.last,e=t.rtt>150?"crit":t.rtt>60?"bad":"",n=t.fps===0?"bad":"",i=t.bitrate===0?"bad":"";this.root.innerHTML=this.row("fps",t.fps?`${t.fps}`:"—",n)+this.row("rtt",t.rtt?`${t.rtt} ms`:"—",e)+this.row("bitrate",t.bitrate?J(t.bitrate):"—",i)+this.row("res",t.width?`${t.width}×${t.height}`:"—")+this.row("link",t.online?"up":"down",t.online?"":"crit")}}const H=60,k=document.getElementById("connect"),S=document.getElementById("viewer"),N=document.getElementById("stage"),G=document.getElementById("stats"),T=document.getElementById("viewer-hint"),u=new U,m=new j(N),g=new X(G),O=new Y(s=>u.send(s));let p=null;u.onMessage(s=>Z(s));u.setVideoHandler(s=>m.feedFrame(s));u.onStats(s=>g.update({bitrate:s.bitrate,rtt:s.rtt,online:u.connected}));m.onFps=s=>g.update({fps:s});m.onFirstFrame=()=>g.show();function Z(s){switch(s.type){case"displays":p&&(p.resolve(s.displays),p=null);break;case"started":tt(s);break;case"error":p?(p.reject(new Error(s.message)),p=null):l(`Agent: ${s.message}`,"err");break;case"stopped":case"stream-ended":et(s.type);break;case"fingerprint-refresh":l("Agent certificate rotated — reconnect to refresh fingerprint","info");break}}function tt(s){m.configure(s.codec,s.width,s.height,H),g.update({width:s.width,height:s.height}),k.classList.add("hidden"),S.classList.remove("hidden"),g.show(),O.attach(N),nt(),l(`Streaming ${s.width}×${s.height}`,"ok")}function et(s){u.close(),l(s==="stopped"?"Stream stopped":"Stream ended","info"),O.detach(),m.reset(),S.classList.add("hidden"),k.classList.remove("hidden"),F.render()}function nt(){window.setTimeout(()=>{T&&(T.style.opacity="0")},6e3)}window.addEventListener("keydown",s=>{if(s.key==="~"||s.key==="`"){if(S.classList.contains("hidden"))return;g.toggle()}});const st={connectAndList:s=>new Promise((t,e)=>{const n=M(s.host,s.port??52020);u.connect({url:n,fingerprintHex:s.fingerprint}).then(()=>{p={resolve:t,reject:e},u.listDisplays(),window.setTimeout(()=>{p&&(p.reject(new Error("timed out waiting for displays")),p=null)},8e3)}).catch(e)}),startStream:s=>{u.start(s,{fps:H})},onConnected:s=>F.saveRecent(s)},F=new z(k,st);F.render(); diff --git a/src/web/dist/assets/index-zx29XWV1.js b/src/web/dist/assets/index-zx29XWV1.js deleted file mode 100644 index 1a35387..0000000 --- a/src/web/dist/assets/index-zx29XWV1.js +++ /dev/null @@ -1,2 +0,0 @@ -(function(){const t=document.createElement("link").relList;if(t&&t.supports&&t.supports("modulepreload"))return;for(const i of document.querySelectorAll('link[rel="modulepreload"]'))n(i);new MutationObserver(i=>{for(const r of i)if(r.type==="childList")for(const c of r.addedNodes)c.tagName==="LINK"&&c.rel==="modulepreload"&&n(c)}).observe(document,{childList:!0,subtree:!0});function e(i){const r={};return i.integrity&&(r.integrity=i.integrity),i.referrerPolicy&&(r.referrerPolicy=i.referrerPolicy),i.crossOrigin==="use-credentials"?r.credentials="include":i.crossOrigin==="anonymous"?r.credentials="omit":r.credentials="same-origin",r}function n(i){if(i.ep)return;i.ep=!0;const r=e(i);fetch(i.href,r)}})();function o(s,t={},e=[]){const n=document.createElement(s);for(const[i,r]of Object.entries(t))r!==void 0&&(i==="class"?n.className=r:i==="text"?n.textContent=r:n.setAttribute(i,r));for(const i of e)n.append(i);return n}function W(s){const t=s.replace(/[^0-9a-fA-F]/g,"");if(t.length%2!==0)throw new Error("invalid hex length");const e=new Uint8Array(t.length/2);for(let n=0;nn.classList.add("hidden"),e))}class K{wt=null;ctrlWriter=null;ctrlReader=null;msgHandler=null;videoHandler=null;statsHandler=null;ctrlBuf=new Uint8Array(0);enc=new TextEncoder;dec=new TextDecoder;rxBytes=0;windowBytes=0;lastWindow=performance.now();bitrate=0;pendingSince=0;rtt=0;statsTimer;closed=!1;onMessage(t){this.msgHandler=t}setVideoHandler(t){this.videoHandler=t}onStats(t){this.statsHandler=t}get connected(){return this.wt!==null}async connect(t){this.closed=!1;const e=W(t.fingerprintHex);if(e.length!==32)throw new Error("fingerprint must be a 32-byte SHA-256 hex string");const n=new WebTransport(t.url,{serverCertificateHashes:[{algorithm:"sha-256",value:e}]});this.wt=n,n.closed.then(()=>{this.closed||this.msgHandler?.({type:"stream-ended"})},()=>{this.closed||this.msgHandler?.({type:"stream-ended"})}),await n.ready;const i=await n.createBidirectionalStream();this.ctrlWriter=i.writable.getWriter(),this.ctrlReader=i.readable.getReader(),this.readControlLoop(),this.readVideoLoop(),this.statsTimer=window.setInterval(()=>this.tickStats(),1e3)}send(t){if(!this.ctrlWriter)throw new Error("not connected");(t.type==="list-displays"||t.type==="start")&&(this.pendingSince=performance.now());const e=this.enc.encode(JSON.stringify(t)+` -`);this.ctrlWriter.write(e).catch(()=>{this.closed||this.msgHandler?.({type:"stream-ended"})})}listDisplays(){this.send({type:"list-displays"})}start(t,e={}){this.send({type:"start",display_id:t,...e})}stop(){this.send({type:"stop"})}getStats(){return{bitrate:this.bitrate,rtt:this.rtt,rxBytes:this.rxBytes}}close(){this.closed=!0,this.statsTimer&&window.clearInterval(this.statsTimer),this.statsTimer=void 0;try{this.ctrlWriter?.close()}catch{}try{this.wt?.close()}catch{}this.wt=null,this.ctrlWriter=null,this.ctrlReader=null}async readControlLoop(){const t=this.ctrlReader;if(t)try{for(;;){const{value:e,done:n}=await t.read();if(n)break;if(!e)continue;this.ctrlBuf=M(this.ctrlBuf,e);let i;for(;(i=q(this.ctrlBuf))!==-1;){const r=this.dec.decode(this.ctrlBuf.subarray(0,i));this.ctrlBuf=this.ctrlBuf.subarray(i+1);const c=r.trim();if(!c)continue;let h;try{h=JSON.parse(c)}catch{continue}(h.type==="displays"||h.type==="started")&&this.pendingSince&&(this.rtt=Math.round(performance.now()-this.pendingSince),this.pendingSince=0),this.msgHandler?.(h)}}}catch{}}async readVideoLoop(){const t=this.wt;if(t)try{const e=t.incomingUnidirectionalStreams.getReader();for(;;){const{value:n,done:i}=await e.read();if(i)break;n&&this.pumpVideoStream(n)}}catch{}}async pumpVideoStream(t){const e=t.getReader();try{for(;;){const{value:n,done:i}=await e.read();if(i)break;!n||n.byteLength===0||(this.rxBytes+=n.byteLength,this.windowBytes+=n.byteLength,this.videoHandler?.(n))}}catch(n){this.closed||console.warn("[transport] video stream error",n)}finally{try{e.releaseLock()}catch{}}}tickStats(){const t=performance.now(),e=(t-this.lastWindow)/1e3;e>0&&(this.bitrate=Math.round(this.windowBytes*8/e),this.windowBytes=0,this.lastWindow=t),this.statsHandler?.(this.getStats())}}function M(s,t){const e=new Uint8Array(s.length+t.length);return e.set(s,0),e.set(t,s.length),e}function q(s){for(let t=0;t=1&&s<=5}function _(s){return s.length>1&&(s[1]&128)!==0}function j(s,t){const e=new Uint8Array(8+s.length+1+2+t.length);let n=0;return e[n++]=1,e[n++]=s[1],e[n++]=s[2],e[n++]=s[3],e[n++]=255,e[n++]=225,e[n++]=s.length>>8&255,e[n++]=s.length&255,e.set(s,n),n+=s.length,e[n++]=1,e[n++]=t.length>>8&255,e[n++]=t.length&255,e.set(t,n),n+=t.length,e.subarray(0,n)}function Y(s){const t=e=>e.toString(16).padStart(2,"0");return`avc1.${t(s[1])}${t(s[2])}${t(s[3])}`}function z(s,t){const e=new Uint8Array(s.length+t.length);return e.set(s,0),e.set(t,s.length),e}function T(s,t){for(let e=t;e+3<=s.length;e++)if(s[e]===0&&s[e+1]===0&&(s[e+2]===1||s[e+2]===0&&e+3"u")throw new Error("WebCodecs VideoDecoder is not available in this browser")}configure(t,e,n,i){this.reset(),i&&i>0&&(this.fps=i)}feed(t){for(this.buf=z(this.buf,t);;){const e=this.nextNal();if(!e)break;this.ingestNal(e)}}nextNal(){const t=T(this.buf,0);if(t===-1){const r=Math.max(0,this.buf.length-3);return this.buf=this.buf.subarray(r),null}const e=t+X(this.buf,t),n=T(this.buf,e);if(n===-1)return this.buf=this.buf.subarray(t),null;const i=this.buf.subarray(e,n);return this.buf=this.buf.subarray(n),i}ingestNal(t){if(t.length===0)return;const e=y(t);if(e===C?this.sps=t.slice():e===E&&(this.pps=t.slice()),$(e)&&_(t)&&this.pendingAU.length>0){let n=this.pendingAU.length;for(;n>0&&!$(y(this.pendingAU[n-1]));)n--;n>0&&(this.emitAU(this.pendingAU.slice(0,n)),this.pendingAU=this.pendingAU.slice(n))}this.pendingAU.push(t)}emitAU(t){t.some(a=>{const m=y(a);return m===C||m===E})&&this.sps&&this.pps&&this.configureDecoder(this.sps,this.pps);const n=t.some(a=>y(a)===Q);if(!this.seenKeyframe){if(!n){this.droppedBeforeKeyframe++;return}this.seenKeyframe=!0,this.droppedBeforeKeyframe>0&&console.info(`[decoder] dropped ${this.droppedBeforeKeyframe} access unit(s) before the first keyframe`)}if(!this.configured){this.pendingBeforeConfig.length<8&&this.pendingBeforeConfig.push(t);return}if(!this.videoDecoder||this.videoDecoder.state!=="configured")return;let i=0;for(const a of t)i+=4+a.length;const r=new Uint8Array(i);let c=0;for(const a of t)r[c++]=a.length>>24&255,r[c++]=a.length>>16&255,r[c++]=a.length>>8&255,r[c++]=a.length&255,r.set(a,c),c+=a.length;const h=Math.round(this.frameIndex*1e6/this.fps),f=Math.round(1e6/this.fps);try{this.videoDecoder.decode(new EncodedVideoChunk({type:n?"key":"delta",timestamp:h,duration:f,data:r})),this.frameIndex++}catch(a){a instanceof DOMException&&a.name==="InvalidStateError"||console.warn("[decoder] decode threw",a)}}onDecoderError(t){console.error("[decoder] error",t),this.configured=!1,this.seenKeyframe=!1,this.sps=null,this.pps=null,this.pendingBeforeConfig=[],this.videoDecoder=null}configureDecoder(t,e){const n=Y(t);if(!(this.configured&&this.codec===n)){if(this.videoDecoder&&this.videoDecoder.state!=="closed")try{this.videoDecoder.reset()}catch{}this.videoDecoder=new VideoDecoder({output:i=>this.onFrame(i),error:i=>this.onDecoderError(i)});try{this.videoDecoder.configure({codec:n,description:j(t,e),optimizeForLatency:!0})}catch(i){console.error("[decoder] configure failed",n,i);return}if(this.codec=n,this.configured=!0,console.info("[decoder] configured",n),this.pendingBeforeConfig.length){const i=this.pendingBeforeConfig;this.pendingBeforeConfig=[];for(const r of i)this.emitAU(r)}}}onFrame(t){const e=t.displayWidth||t.codedWidth,n=t.displayHeight||t.codedHeight;(this.canvas.width!==e||this.canvas.height!==n)&&(this.canvas.width=e,this.canvas.height=n),this.ctx.drawImage(t,0,0,e,n),t.close(),this.frameCount++,this.tickFps(),this.firstFrameDrawn||(this.firstFrameDrawn=!0,this.onFirstFrame?.())}tickFps(){const t=performance.now(),e=(t-this.fpsWindowStart)/1e3;e>=1&&(this.measuredFps=Math.round(this.frameCount/e),this.frameCount=0,this.fpsWindowStart=t,this.onFps?.(this.measuredFps))}get currentFps(){return this.measuredFps}reset(){this.buf=new Uint8Array(0),this.pendingAU=[],this.pendingBeforeConfig=[],this.sps=null,this.pps=null,this.configured=!1,this.codec="",this.frameIndex=0,this.frameCount=0,this.measuredFps=0,this.firstFrameDrawn=!1,this.seenKeyframe=!1,this.droppedBeforeKeyframe=0;try{this.videoDecoder?.reset()}catch{}this.videoDecoder=null}close(){try{this.videoDecoder?.close()}catch{}this.videoDecoder=null,this.configured=!1}}class Z{send;target=null;locked=!1;bound=[];constructor(t){this.send=t}attach(t){this.target=t,this.on(t,"click",()=>{!this.locked&&t.requestPointerLock&&t.requestPointerLock()}),this.on(document,"pointerlockchange",()=>{this.locked=document.pointerLockElement===t,t.classList.toggle("locked",this.locked),this.locked&&this.tryKeyboardLock()}),this.on(t,"mousemove",e=>{if(!this.locked)return;const n=e;this.send({type:"input",kind:"mouse",dx:n.movementX,dy:n.movementY,buttons:n.buttons})}),this.on(t,"mousedown",e=>{if(!this.locked)return;const n=e;this.send({type:"input",kind:"mousedown",button:n.button})}),this.on(t,"mouseup",e=>{if(!this.locked)return;const n=e;this.send({type:"input",kind:"mouseup",button:n.button})}),this.on(t,"wheel",e=>{if(!this.locked)return;e.preventDefault();const n=e;this.send({type:"input",kind:"wheel",dx:n.deltaX,dy:n.deltaY})},{passive:!1}),this.on(t,"contextmenu",e=>e.preventDefault()),this.on(window,"keydown",e=>this.onKey(e,!0)),this.on(window,"keyup",e=>this.onKey(e,!1)),this.on(t,"touchstart",e=>this.onTouch(e,"start"),{passive:!1}),this.on(t,"touchmove",e=>this.onTouch(e,"move"),{passive:!1}),this.on(t,"touchend",e=>this.onTouch(e,"end"),{passive:!1})}get isLocked(){return this.locked}release(){this.locked&&document.exitPointerLock&&document.exitPointerLock();const t=navigator.keyboard;t?.unlock&&t.unlock()}detach(){this.release();for(const[t,e,n,i]of this.bound)t.removeEventListener(e,n,i);this.bound=[],this.target=null}on(t,e,n,i){t.addEventListener(e,n,i),this.bound.push([t,e,n,i??!1])}tryKeyboardLock(){const t=navigator.keyboard;t?.lock&&t.lock(["Tab","Space","ArrowUp","ArrowDown","ArrowLeft","ArrowRight","F1","F2","F3","F4","F5","F6","F7","F8","F9","F10","F11","F12"]).catch(()=>{})}onKey(t,e){if(!this.locked)return;["Tab","Space","ArrowUp","ArrowDown","ArrowLeft","ArrowRight","'","/","F1","F2","F3","F4","F5","F6","F7","F8","F9","F10","F11","F12"].includes(t.key)&&t.preventDefault(),this.send({type:"input",kind:"key",code:t.code,down:e})}onTouch(t,e){if(!this.target)return;t.preventDefault();const n=this.target.getBoundingClientRect(),i=t.changedTouches;for(let r=0;rthis.doConnect());const a=o("button",{class:"btn ghost",text:"Scan QR"});a.addEventListener("click",()=>this.openQr()),f.append(this.connectBtn,a);const m=o("div",{class:"field"},[o("label",{text:"…or paste a connection JSON"})]),b=o("textarea",{placeholder:'{"host":"10.10.1.5","port":52020,"fingerprint":"abcd…","label":"pc"}',spellcheck:"false"});b.addEventListener("change",()=>this.applyJson(b.value)),m.append(b),t.append(n,i,h,f,m);const I=this.loadRecent();if(I.length){const B=o("div",{class:"recent"},[o("h2",{text:"Recent hosts"})]),A=o("div",{class:"recent-list"});for(const l of I){const v=o("div",{class:"recent-item"}),O=o("div",{class:"meta"},[o("div",{class:"host",text:l.label?`${l.label} · ${l.host}:${l.port}`:`${l.host}:${l.port}`}),o("div",{class:"fp",text:l.fingerprint})]);v.append(O);const D=o("button",{class:"del",title:"forget",text:"×"});D.addEventListener("click",P=>{P.stopPropagation(),this.forget(l.fingerprint+l.host),this.render()}),v.append(D),v.addEventListener("click",()=>{this.fpInput.value=l.fingerprint,this.hostInput.value=l.host,this.portInput.value=String(l.port),l.label&&(this.labelInput.value=l.label)}),A.append(v)}B.append(A),t.append(B)}this.root.append(t),this.fpInput.value=e.fingerprint??""}suggestFromPage(){const t={host:location.hostname||"",port:52020,fingerprint:void 0};return fetch("/api/info").then(e=>e.json()).then(e=>{e?.fingerprint&&!this.fpInput.value&&(this.fpInput.value=e.fingerprint),Array.isArray(e?.ips)&&e.ips.length&&!this.hostInput.value&&(this.hostInput.value=e.ips[0])}).catch(()=>{}),t}async doConnect(){const t=this.fpInput.value.trim().replace(/\s+/g,""),e=this.hostInput.value.trim(),n=parseInt(this.portInput.value.trim(),10)||52020,i=this.labelInput.value.trim()||void 0;if(!/^[0-9a-fA-F]{64}$/.test(t)){d("Fingerprint must be 64 hex characters","err");return}if(!e){d("Enter the agent host","err");return}this.connectBtn.disabled=!0,this.connectBtn.textContent="Connecting…";try{const r=await this.deps.connectAndList({host:e,port:n,fingerprint:t,label:i});this.deps.onConnected?.({host:e,port:n,fingerprint:t,label:i}),this.showDisplays(r,{host:e,port:n,fingerprint:t,label:i})}catch(r){d(`Connection failed: ${r.message}`,"err"),this.connectBtn.disabled=!1,this.connectBtn.textContent="Connect"}}showDisplays(t,e){this.root.innerHTML="";const n=o("div",{class:"connect-card"});n.append(o("div",{class:"brand"},[o("span",{class:"dot"}),o("h1",{text:"Choose a display"})]),o("div",{class:"subtitle",text:`${e.host}:${e.port}`})),t.length||n.append(o("div",{class:"muted",text:"No displays reported by the agent yet."}));const i=o("div",{class:"displays"});for(const r of t){const c=o("button",{class:"display-tile"},[o("div",{class:"name",text:`Display ${r.id}`}),o("div",{class:"res",text:`${r.width}×${r.height} @ ${r.refresh_rate||"?"}Hz`})]);c.addEventListener("click",()=>{this.deps.startStream(r.id)}),i.append(c)}n.append(i),this.root.append(n)}applyJson(t){let e;try{e=JSON.parse(t.trim())}catch{return d("Invalid connection JSON","err"),!1}return e===null||Array.isArray(e)||typeof e!="object"?(d("Invalid connection JSON: expected object","err"),!1):e.fingerprint&&typeof e.fingerprint!="string"?(d("Invalid connection JSON: fingerprint must be string","err"),!1):e.host&&typeof e.host!="string"?(d("Invalid connection JSON: host must be string","err"),!1):e.port!==void 0&&typeof e.port!="number"?(d("Invalid connection JSON: port must be number","err"),!1):e.label!==void 0&&typeof e.label!="string"?(d("Invalid connection JSON: label must be string","err"),!1):(e.fingerprint&&(this.fpInput.value=e.fingerprint),e.host&&(this.hostInput.value=e.host),e.port&&(this.portInput.value=String(e.port)),e.label&&(this.labelInput.value=e.label),d("Filled from JSON","ok"),!0)}openQr(){if(!window.BarcodeDetector){d("QR scanning requires Chrome/Edge with BarcodeDetector","err");return}const t=o("div",{class:"qr-modal"}),e=o("div",{class:"box"},[o("h3",{text:"Scan connection QR"})]),n=o("video",{class:"qr-video",playsinline:"true"});e.append(n),e.append(o("div",{class:"muted",text:"Point your camera at the agent’s QR code."}));const i=o("button",{class:"btn ghost",text:"Cancel"});i.addEventListener("click",()=>this.stopQr(t)),e.append(o("div",{class:"actions"},[i])),t.append(e),document.body.append(t),navigator.mediaDevices.getUserMedia({video:{facingMode:"environment"}}).then(async r=>{if(!t.isConnected){r.getTracks().forEach(c=>c.stop());return}this.qrStream=r,n.srcObject=r,await n.play(),this.scanLoop(n,t)}).catch(()=>{d("Camera unavailable","err"),this.stopQr(t)})}async scanLoop(t,e){if(!this.qrStream||!window.BarcodeDetector)return;const n=new BarcodeDetector({formats:["qr"]}),i=async()=>{if(this.qrStream){try{const r=await n.detect(t);for(const c of r)if(this.applyJson(c.rawValue)){this.stopQr(e);return}}catch{}requestAnimationFrame(i)}};i()}stopQr(t){this.qrStream?.getTracks().forEach(e=>e.stop()),this.qrStream=null,t.remove()}loadRecent(){try{const t=localStorage.getItem(S);return t?JSON.parse(t).sort((n,i)=>i.last-n.last).slice(0,8):[]}catch{return[]}}remember(t){try{const e=this.loadRecent().filter(n=>!(n.host===t.host&&n.port===(t.port??52020)));e.unshift({host:t.host,port:t.port??52020,fingerprint:t.fingerprint,label:t.label,last:Date.now()}),localStorage.setItem(S,JSON.stringify(e.slice(0,8)))}catch{}}forget(t){try{const e=this.loadRecent().filter(n=>n.fingerprint+n.host!==t);localStorage.setItem(S,JSON.stringify(e))}catch{}}saveRecent(t){this.remember(t)}}class et{root;visible=!1;last={fps:0,rtt:0,bitrate:0,width:0,height:0,online:!1};constructor(t){this.root=t}toggle(){this.visible=!this.visible,this.root.classList.toggle("hidden",!this.visible),this.visible&&this.render()}show(){this.visible=!0,this.root.classList.remove("hidden"),this.render()}get isVisible(){return this.visible}update(t){this.last={...this.last,...t},this.visible&&this.render()}row(t,e,n=""){return`
${t}${e}
`}render(){const t=this.last,e=t.rtt>150?"crit":t.rtt>60?"bad":"",n=t.fps===0?"bad":"",i=t.bitrate===0?"bad":"";this.root.innerHTML=this.row("fps",t.fps?`${t.fps}`:"—",n)+this.row("rtt",t.rtt?`${t.rtt} ms`:"—",e)+this.row("bitrate",t.bitrate?J(t.bitrate):"—",i)+this.row("res",t.width?`${t.width}×${t.height}`:"—")+this.row("link",t.online?"up":"down",t.online?"":"crit")}}const H=60,x=document.getElementById("connect"),F=document.getElementById("viewer"),U=document.getElementById("stage"),nt=document.getElementById("stats"),N=document.getElementById("viewer-hint"),u=new K,w=new G(U),g=new et(nt),R=new Z(s=>u.send(s));let p=null;u.onMessage(s=>st(s));u.setVideoHandler(s=>w.feed(s));u.onStats(s=>g.update({bitrate:s.bitrate,rtt:s.rtt,online:u.connected}));w.onFps=s=>g.update({fps:s});w.onFirstFrame=()=>g.show();function st(s){switch(s.type){case"displays":p&&(p.resolve(s.displays),p=null);break;case"started":it(s);break;case"error":p?(p.reject(new Error(s.message)),p=null):d(`Agent: ${s.message}`,"err");break;case"stopped":case"stream-ended":rt(s.type);break;case"fingerprint-refresh":d("Agent certificate rotated — reconnect to refresh fingerprint","info");break}}function it(s){w.configure(s.codec,s.width,s.height,H),g.update({width:s.width,height:s.height}),x.classList.add("hidden"),F.classList.remove("hidden"),g.show(),R.attach(U),ot(),d(`Streaming ${s.width}×${s.height}`,"ok")}function rt(s){u.close(),d(s==="stopped"?"Stream stopped":"Stream ended","info"),R.detach(),w.reset(),F.classList.add("hidden"),x.classList.remove("hidden"),L.render()}function ot(){window.setTimeout(()=>{N&&(N.style.opacity="0")},6e3)}window.addEventListener("keydown",s=>{if(s.key==="~"||s.key==="`"){if(F.classList.contains("hidden"))return;g.toggle()}});const ct={connectAndList:s=>new Promise((t,e)=>{const n=V(s.host,s.port??52020);u.connect({url:n,fingerprintHex:s.fingerprint}).then(()=>{p={resolve:t,reject:e},u.listDisplays(),window.setTimeout(()=>{p&&(p.reject(new Error("timed out waiting for displays")),p=null)},8e3)}).catch(e)}),startStream:s=>{u.start(s,{fps:H})},onConnected:s=>L.saveRecent(s)},L=new tt(x,ct);L.render(); diff --git a/src/web/dist/index.html b/src/web/dist/index.html index b95b609..a7d0d8d 100644 --- a/src/web/dist/index.html +++ b/src/web/dist/index.html @@ -5,7 +5,7 @@ Distance Desktop - + diff --git a/src/web/src/decoder.ts b/src/web/src/decoder.ts index a6d11f1..85ba839 100644 --- a/src/web/src/decoder.ts +++ b/src/web/src/decoder.ts @@ -1,57 +1,10 @@ -/** - * H264 (Annex B) decoder on the platform WebCodecs `VideoDecoder`, drawing onto - * a . - * - * The agent streams raw ffmpeg H.264 Annex B bytes over a WebTransport - * unidirectional stream — one contiguous byte stream with no framing. So: - * 1. buffer incoming bytes and split them on Annex B start codes - * (00 00 00 01 / 00 00 01); - * 2. group NALs into access units (one per frame), starting a new unit at each - * VCL NAL whose slice header reports first_mb_in_slice == 0, so the - * SPS/PPS/SEI preceding a frame stay attached to it; - * 3. build the AVCDecoderConfigurationRecord (avcC `description`) from the - * SPS/PPS and derive the RFC 6381 `avc1.PPCCLL` codec string from the SPS — - * the profile and level must come from the bitstream, not a constant, or - * `VideoDecoder` silently decodes nothing; - * 4. convert each access unit from Annex B to AVCC (4-byte length prefixes) - * and feed it as one `EncodedVideoChunk`. - * - * Streams are always joined mid-GOP, since the agent's encoder is already - * running when a viewer connects, so access units before the first keyframe are - * discarded (see `emitAU`). - */ +/** H264 decoder on the platform WebCodecs `VideoDecoder`. */ + +import type { VideoFrameRecord } from './types' -const NAL_IDR = 5 const NAL_SPS = 7 const NAL_PPS = 8 -function nalType(nal: Uint8Array): number { - return nal[0] & 0x1f -} - -function isVCL(t: number): boolean { - return t >= 1 && t <= 5 -} - -/** - * True when a VCL NAL starts a new picture, i.e. its slice header's - * `first_mb_in_slice` is 0. - * - * That field is the first ue(v) Exp-Golomb value after the 1-byte NAL header, - * and ue(v) == 0 is encoded as a single set bit, so a high bit in the first - * payload byte means "this slice covers macroblock 0" — a new picture. With - * multiple slices per picture only the first has first_mb_in_slice == 0, so this - * still identifies exactly one boundary per frame. - * - * This is the reliable boundary test. Keying off "a VCL NAL following a non-VCL - * NAL" only works when the encoder emits SEI/parameter sets between frames: - * ffmpeg's Main-profile output does, but its High-profile output does not, and - * there consecutive slices would otherwise collapse into a single access unit. - */ -function startsNewPicture(nal: Uint8Array): boolean { - return nal.length > 1 && (nal[1] & 0x80) !== 0 -} - /** * Build the AVCDecoderConfigurationRecord (avcC) from SPS/PPS NALs. * Both are passed without start codes but with their 1-byte NAL header. @@ -83,13 +36,6 @@ function codecStringFromSps(sps: Uint8Array): string { return `avc1.${h(sps[1])}${h(sps[2])}${h(sps[3])}` } -function concat(a: Uint8Array, b: Uint8Array): Uint8Array { - const out = new Uint8Array(a.length + b.length) - out.set(a, 0) - out.set(b, a.length) - return out -} - /** Index of the first Annex B start code at or after `from`, else -1. */ function findStartCode(buf: Uint8Array, from: number): number { for (let i = from; i + 3 <= buf.length; i++) { @@ -106,21 +52,32 @@ function startCodeLen(buf: Uint8Array, i: number): number { return i + 3 < buf.length && buf[i + 3] === 0x01 ? 4 : 3 } +function splitAccessUnit(buf: Uint8Array): Uint8Array[] { + const nals: Uint8Array[] = [] + let start = findStartCode(buf, 0) + while (start !== -1) { + const dataStart = start + startCodeLen(buf, start) + const next = findStartCode(buf, dataStart) + const end = next === -1 ? buf.length : next + if (dataStart < end) nals.push(buf.slice(dataStart, end)) + if (next === -1) break + start = next + } + return nals +} + export class Decoder { private videoDecoder: VideoDecoder | null = null private canvas: HTMLCanvasElement private ctx: CanvasRenderingContext2D - private buf: Uint8Array = new Uint8Array(0) - private pendingAU: Uint8Array[] = [] - private pendingBeforeConfig: Uint8Array[][] = [] + private pendingBeforeConfig: Array<{ frame: VideoFrameRecord; nals: Uint8Array[] }> = [] private sps: Uint8Array | null = null private pps: Uint8Array | null = null private configured = false private codec = '' private fps = 60 - private frameIndex = 0 private seenKeyframe = false private droppedBeforeKeyframe = 0 @@ -152,77 +109,25 @@ export class Decoder { if (fps && fps > 0) this.fps = fps } - /** Feed a raw chunk of video bytes; may contain any number of NAL units. */ - feed(chunk: Uint8Array): void { - this.buf = concat(this.buf, chunk) - while (true) { - const nal = this.nextNal() - if (!nal) break - this.ingestNal(nal) - } - } - - /** - * Extract the next complete NAL. A NAL's end is only known once the following - * start code appears, so an incomplete trailing NAL stays buffered. - */ - private nextNal(): Uint8Array | null { - const start = findStartCode(this.buf, 0) - if (start === -1) { - // Keep up to 3 trailing bytes that could be the head of a start code. - const keep = Math.max(0, this.buf.length - 3) - this.buf = this.buf.subarray(keep) - return null - } - const dataStart = start + startCodeLen(this.buf, start) - const next = findStartCode(this.buf, dataStart) - if (next === -1) { - this.buf = this.buf.subarray(start) - return null - } - const nal = this.buf.subarray(dataStart, next) - this.buf = this.buf.subarray(next) - return nal - } - - private ingestNal(nal: Uint8Array): void { - if (nal.length === 0) return - const t = nalType(nal) - if (t === NAL_SPS) this.sps = nal.slice() - else if (t === NAL_PPS) this.pps = nal.slice() - - // A VCL NAL that starts a new picture closes the previous access unit. Any - // trailing non-VCL NALs already buffered (SPS/PPS/SEI) are parameter sets - // for the *new* picture, so they stay with it rather than being emitted. - if (isVCL(t) && startsNewPicture(nal) && this.pendingAU.length > 0) { - let split = this.pendingAU.length - while (split > 0 && !isVCL(nalType(this.pendingAU[split - 1]))) split-- - if (split > 0) { - this.emitAU(this.pendingAU.slice(0, split)) - this.pendingAU = this.pendingAU.slice(split) - } - } - this.pendingAU.push(nal) + feedFrame(frame: VideoFrameRecord): void { + const au = splitAccessUnit(frame.data) + if (au.length === 0) return + this.emitAU(au, frame) } - private emitAU(au: Uint8Array[]): void { + private emitAU(au: Uint8Array[], frame: VideoFrameRecord): void { const carriesParams = au.some((n) => { - const t = nalType(n) + const t = n[0] & 0x1f + if (t === NAL_SPS) this.sps = n.slice() + if (t === NAL_PPS) this.pps = n.slice() return t === NAL_SPS || t === NAL_PPS }) if (carriesParams && this.sps && this.pps) { this.configureDecoder(this.sps, this.pps) } - const isKey = au.some((n) => nalType(n) === NAL_IDR) - - // A stream is joined mid-GOP: the agent's ffmpeg is already running, so the - // first access units received are the tail of the previous GOP and reference - // frames (and a PPS) that were never sent. Feeding those to VideoDecoder - // raises a fatal decode error, which moves it to `closed` and kills the - // IDR that follows. So everything before the first keyframe is dropped. if (!this.seenKeyframe) { - if (!isKey) { + if (!frame.keyframe) { this.droppedBeforeKeyframe++ return } @@ -235,8 +140,9 @@ export class Decoder { } if (!this.configured) { - // Keyframe arrived but SPS/PPS have not: hold it so the GOP is not lost. - if (this.pendingBeforeConfig.length < 8) this.pendingBeforeConfig.push(au) + if (this.pendingBeforeConfig.length < 8) { + this.pendingBeforeConfig.push({ frame, nals: au }) + } return } if (!this.videoDecoder || this.videoDecoder.state !== 'configured') return @@ -255,18 +161,16 @@ export class Decoder { o += n.length } - const timestamp = Math.round((this.frameIndex * 1_000_000) / this.fps) const duration = Math.round(1_000_000 / this.fps) try { this.videoDecoder.decode( new EncodedVideoChunk({ - type: isKey ? 'key' : 'delta', - timestamp, + type: frame.keyframe ? 'key' : 'delta', + timestamp: frame.timestampMs * 1000, duration, data: avcc as unknown as BufferSource }) ) - this.frameIndex++ } catch (e) { if (!(e instanceof DOMException && e.name === 'InvalidStateError')) { console.warn('[decoder] decode threw', e) @@ -321,7 +225,7 @@ export class Decoder { if (this.pendingBeforeConfig.length) { const queued = this.pendingBeforeConfig this.pendingBeforeConfig = [] - for (const au of queued) this.emitAU(au) + for (const item of queued) this.emitAU(item.nals, item.frame) } } @@ -360,14 +264,11 @@ export class Decoder { } reset(): void { - this.buf = new Uint8Array(0) - this.pendingAU = [] this.pendingBeforeConfig = [] this.sps = null this.pps = null this.configured = false this.codec = '' - this.frameIndex = 0 this.frameCount = 0 this.measuredFps = 0 this.firstFrameDrawn = false diff --git a/src/web/src/main.ts b/src/web/src/main.ts index 8664aaa..17af1ae 100644 --- a/src/web/src/main.ts +++ b/src/web/src/main.ts @@ -25,7 +25,7 @@ const input = new InputController((m: InputMessage) => transport.send(m)) let pendingList: { resolve: (d: Display[]) => void; reject: (e: Error) => void } | null = null transport.onMessage((msg: ControlMessage) => handleMessage(msg)) -transport.setVideoHandler((chunk) => decoder.feed(chunk)) +transport.setVideoHandler((frame) => decoder.feedFrame(frame)) transport.onStats((s) => stats.update({ bitrate: s.bitrate, rtt: s.rtt, online: transport.connected })) decoder.onFps = (fps) => stats.update({ fps }) diff --git a/src/web/src/transport.ts b/src/web/src/transport.ts index fa8bec9..6e30a20 100644 --- a/src/web/src/transport.ts +++ b/src/web/src/transport.ts @@ -1,4 +1,4 @@ -import type { ClientMessage, ServerMessage, ControlMessage } from './types' +import type { ClientMessage, ServerMessage, ControlMessage, VideoFrameRecord } from './types' import { hexToBytes } from './util' export interface ConnectOptions { @@ -15,14 +15,14 @@ export interface TransportStats { } type MessageHandler = (msg: ControlMessage) => void -type VideoHandler = (chunk: Uint8Array) => void +type VideoHandler = (frame: VideoFrameRecord) => void type StatsHandler = (stats: TransportStats) => void /** * WebTransport client for Distance Desktop. * * - One bidirectional stream carries newline-delimited JSON control messages. - * - One unidirectional stream (server -> client) carries raw H264 Annex B video. + * - One unidirectional stream (server -> client) carries framed H264 access units. * - The server certificate hash is pinned via `serverCertificateHashes` so a * self-signed agent cert is accepted without OS trust store involvement. */ @@ -209,6 +209,7 @@ export class Transport { // picked up promptly instead of waiting on the previous one to end. private async pumpVideoStream(stream: ReadableStream): Promise { const reader = stream.getReader() + let buf = new Uint8Array(0) try { while (true) { const { value, done } = await reader.read() @@ -216,7 +217,19 @@ export class Transport { if (!value || value.byteLength === 0) continue this.rxBytes += value.byteLength this.windowBytes += value.byteLength - this.videoHandler?.(value) + buf = concat(buf, value) + while (buf.length >= 13) { + const view = new DataView(buf.buffer, buf.byteOffset, buf.byteLength) + const payloadLen = view.getUint32(9) + const recordLen = 13 + payloadLen + if (buf.length < recordLen) break + this.videoHandler?.({ + keyframe: (buf[0] & 1) !== 0, + timestampMs: Number(view.getBigUint64(1)), + data: buf.slice(13, recordLen) + }) + buf = buf.slice(recordLen) + } } } catch (err) { if (!this.closed) console.warn('[transport] video stream error', err) diff --git a/src/web/src/types.ts b/src/web/src/types.ts index 33cfe15..a57bf81 100644 --- a/src/web/src/types.ts +++ b/src/web/src/types.ts @@ -44,6 +44,12 @@ export type ServerMessage = export type ControlMessage = ServerMessage +export interface VideoFrameRecord { + keyframe: boolean + timestampMs: number + data: Uint8Array +} + // Connect payload encoded in a QR / paste blob. export interface ConnectPayload { host: string From 97fbe41ac0fa14cdf62c16b1536373ea0590593d Mon Sep 17 00:00:00 2001 From: spacedouut Date: Mon, 14 Sep 2026 18:26:10 +0000 Subject: [PATCH 2/3] refactor: writeFrame takes io.Writer Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- src/stream.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/stream.go b/src/stream.go index 358dc00..bb1608c 100644 --- a/src/stream.go +++ b/src/stream.go @@ -125,7 +125,7 @@ func publishStream(ctx context.Context, ss *streamState) { } } -func writeFrame(w interface{ Write([]byte) (int, error) }, frame []byte) error { +func writeFrame(w io.Writer, frame []byte) error { for len(frame) > 0 { n, err := w.Write(frame) if err != nil { From 63104aeee4ac547e7e26d60730d9d14c315dbdf4 Mon Sep 17 00:00:00 2001 From: spacedouut Date: Tue, 15 Sep 2026 12:57:59 +0000 Subject: [PATCH 3/3] fix: bound framer NAL buffer; update AGENTS.md publish description Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- AGENTS.md | 4 ++-- src/framer.go | 8 ++++++++ src/framer_test.go | 27 +++++++++++++++++++++++++++ 3 files changed, 37 insertions(+), 2 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 7ac0712..0e2288a 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -124,7 +124,7 @@ backend.Backend { ListDisplays(ctx) ([]Display,error); StartStream(ctx, StartReq activeBackend (chosen via --backend at startup): └─ StartStream → Stream.Chunks() channel - └─ publishStream goroutine writes each chunk to every subscriber's WT uni stream + └─ publishStream goroutine frames complete access units and writes records to every subscriber's WT uni stream ``` ### Start sequence @@ -133,7 +133,7 @@ activeBackend (chosen via --backend at startup): 3. Captured returns media socket path → agent connects → reads first frame (header + data) for dimensions 4. Agent spawns `ffmpeg` with correct `-s WxH`, writes first frame, starts BGRA reader goroutine for subsequent frames 5. Agent opens unidirectional stream on the caller's session → adds caller as owner + subscriber -6. ffmpeg stdout read in 64KB chunks → each chunk written to all subscriber unidirectional streams +6. ffmpeg stdout read in 64KB chunks → chunks pass through the framer, and complete framed access-unit records are written to all subscriber unidirectional streams 7. Agent responds to caller with `{"type":"started","width":...,"height":...,"codec":"h264"}` ### Stop sequence diff --git a/src/framer.go b/src/framer.go index 73d6b30..00e63ee 100644 --- a/src/framer.go +++ b/src/framer.go @@ -2,10 +2,13 @@ package main import "encoding/binary" +const maxNALBytes = 8 << 20 + type framer struct { buf []byte pending [][]byte ready [][]byte + dropped int } func (f *framer) Push(b []byte) [][]byte { @@ -13,6 +16,11 @@ func (f *framer) Push(b []byte) [][]byte { for { nal, ok := f.nextNAL() if !ok { + if len(f.buf) > maxNALBytes { + f.buf = nil + f.pending = nil + f.dropped++ + } break } f.ingestNAL(nal) diff --git a/src/framer_test.go b/src/framer_test.go index 53bfbc8..1b5bf26 100644 --- a/src/framer_test.go +++ b/src/framer_test.go @@ -87,3 +87,30 @@ func TestEncodeFrame(t *testing.T) { t.Fatalf("frame = %x, want %x", got, want) } } + +func TestFramerDropsOversizedNALAndResyncs(t *testing.T) { + var f framer + oversized := make([]byte, maxNALBytes+100) + copy(oversized, []byte{0, 0, 0, 1}) + for i := 4; i < len(oversized); i++ { + oversized[i] = 0xff + } + if got := f.Push(oversized); len(got) != 0 { + t.Fatalf("oversized NAL emitted %d AUs", len(got)) + } + if f.dropped != 1 { + t.Fatalf("dropped = %d, want 1", f.dropped) + } + + sps := []byte{0, 0, 0, 1, 0x67, 0x42, 0x00} + pps := []byte{0, 0, 1, 0x68, 0xce, 0x06} + idr := []byte{0, 0, 0, 1, 0x65, 0x88, 0x11} + nextSlice := []byte{0, 0, 1, 0x41, 0x9a, 0x22} + followingSlice := []byte{0, 0, 0, 1, 0x41, 0x9a, 0x33} + stream := append(append(append(append(append([]byte{}, sps...), pps...), idr...), nextSlice...), followingSlice...) + got := f.Push(stream) + want := append(append(append([]byte{}, sps...), pps...), idr...) + if len(got) != 1 || !bytes.Equal(got[0], want) { + t.Fatalf("resynced AUs = %x, want %x", got, [][]byte{want}) + } +}