From 45a531066cd0d13d49c5d8a9c446c3640a2b3ae0 Mon Sep 17 00:00:00 2001 From: flamingcow Date: Sun, 30 Aug 2026 16:45:19 -0700 Subject: [PATCH] Stream the panel as masked png deltas and seqlock the scanout reads --- harness/grab.go | 67 +++++++++------ harness/main.go | 10 ++- harness/stream.go | 211 ++++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 260 insertions(+), 28 deletions(-) create mode 100644 harness/stream.go diff --git a/harness/grab.go b/harness/grab.go index 9d93d1f..6b78ba2 100644 --- a/harness/grab.go +++ b/harness/grab.go @@ -1,7 +1,6 @@ package main import ( - "bytes" "fmt" "image" "image/color" @@ -73,20 +72,18 @@ func activeCrtc(fd int) (uint32, int, error) { func (g *grabber) close() { unix.Close(g.fd) } -// Reading a buffer takes about as long as a frame, so where the read starts in -// the flip cycle decides whether it stays ahead of the writer. Woken at a -// blank, the buffer GETCRTC then names has just gone on screen. -func (g *grabber) waitVblank() error { +// A delta of 0 returns the current sequence without blocking. +func (g *grabber) vblank(delta uint32) (uint32, error) { v := drm.WaitVblank{ Type: drm.VblankRelative | uint32(g.index< 0 { err = fuzz(*fuzzN, *runFor, flag.Args()) diff --git a/harness/stream.go b/harness/stream.go new file mode 100644 index 0000000..349087d --- /dev/null +++ b/harness/stream.go @@ -0,0 +1,211 @@ +package main + +import ( + "bytes" + "encoding/binary" + "fmt" + "image" + "image/color" + "image/png" + "net" + "net/http" + "os" + + "g.fc.run/theater/cabletest/internal/drm" +) + +const streamAddr = ":8080" + +const page = ` + +cabletest + + + +` + +func serveStream() error { + ln, err := net.Listen("tcp", streamAddr) + if err != nil { + return err + } + host, err := os.Hostname() + if err != nil { + host = "localhost" + } + fmt.Printf("panel stream at http://%s%s/\n", host, streamAddr) + mux := http.NewServeMux() + mux.HandleFunc("/{$}", func(w http.ResponseWriter, r *http.Request) { + fmt.Fprint(w, page) + }) + mux.HandleFunc("/frames", frames) + go http.Serve(ln, mux) + return nil +} + +func frames(w http.ResponseWriter, r *http.Request) { + g, err := openGrabber() + if err != nil { + http.Error(w, err.Error(), http.StatusServiceUnavailable) + return + } + defer g.close() + + cur, pitch, pw, ph, err := g.rawFrame(nil) + if err != nil { + http.Error(w, err.Error(), http.StatusServiceUnavailable) + return + } + + w.Header().Set("Content-Type", "application/octet-stream") + if err := send(w, image.Pt(0, 0), decode(cur, pitch, pw, ph)); err != nil { + return + } + + ctx := r.Context() + prev := make([]byte, len(cur)) + for { + prev, cur = cur, prev + for { + if ctx.Err() != nil { + return + } + if cur, pitch, pw, ph, err = g.rawFrame(cur); err != nil { + fmt.Fprintf(os.Stderr, "stream %s: %v\n", r.RemoteAddr, err) + return + } + rects := diffRects(prev, cur, pitch, pw, ph) + if len(rects) == 0 { + continue + } + for _, rect := range rects { + if err := send(w, rect.Min, maskRect(prev, cur, pitch, ph, rect)); err != nil { + return + } + } + break + } + } +} + +// Encoding happens inside the vblank budget, and on rect-sized parts the +// dense level trades 3-6x the cpu for a few percent of size — enough to spill +// past a vblank on the once-per-second bursts and stutter the stream. +var encoder = png.Encoder{CompressionLevel: png.DefaultCompression} + +func send(w http.ResponseWriter, at image.Point, img *image.NRGBA) error { + var buf bytes.Buffer + if err := encoder.Encode(&buf, img); err != nil { + return err + } + var hdr [12]byte + binary.LittleEndian.PutUint32(hdr[0:], uint32(at.X)) + binary.LittleEndian.PutUint32(hdr[4:], uint32(at.Y)) + binary.LittleEndian.PutUint32(hdr[8:], uint32(buf.Len())) + if _, err := w.Write(hdr[:]); err != nil { + return err + } + if _, err := w.Write(buf.Bytes()); err != nil { + return err + } + w.(http.Flusher).Flush() + return nil +} + +// Distant regions changing together (a spinner up top, counters at the foot) +// would union into one near-full-height box, so changed portrait rows are +// merged into bands only across small gaps and each band gets its own box. +const bandGap = 32 + +// Compares in panel space, where a portrait column is one contiguous row, and +// reports the changed bounding boxes in portrait coordinates. +func diffRects(prev, cur []byte, pitch, pw, ph int) []image.Rectangle { + var rows []int + for row := 0; row < ph; row++ { + if !bytes.Equal(prev[row*pitch:row*pitch+pw*4], cur[row*pitch:row*pitch+pw*4]) { + rows = append(rows, row) + } + } + if len(rows) == 0 { + return nil + } + + changed := make([]bool, pw) + for _, row := range rows { + a := prev[row*pitch : row*pitch+pw*4] + b := cur[row*pitch : row*pitch+pw*4] + for y := 0; y < pw; y++ { + if !changed[y] && !bytes.Equal(a[y*4:y*4+4], b[y*4:y*4+4]) { + changed[y] = true + } + } + } + + var rects []image.Rectangle + for y := 0; y < pw; { + if !changed[y] { + y++ + continue + } + y0, y1 := y, y+1 + for next := y1; next < pw && next < y1+bandGap; next++ { + if changed[next] { + y1 = next + 1 + } + } + rMin, rMax := -1, -1 + for _, row := range rows { + if !bytes.Equal(prev[row*pitch+y0*4:row*pitch+y1*4], cur[row*pitch+y0*4:row*pitch+y1*4]) { + if rMin < 0 { + rMin = row + } + rMax = row + } + } + rects = append(rects, image.Rect(ph-1-rMax, y0, ph-rMin, y1)) + y = y1 + } + return rects +} + +// The box holds only the pixels that changed, transparent everywhere else, so +// the client composites it straight over the last frame. +func maskRect(prev, cur []byte, pitch, ph int, rect image.Rectangle) *image.NRGBA { + img := image.NewNRGBA(image.Rect(0, 0, rect.Dx(), rect.Dy())) + for x := rect.Min.X; x < rect.Max.X; x++ { + row := (ph - 1 - x) * pitch + for y := rect.Min.Y; y < rect.Max.Y; y++ { + o := row + y*4 + if bytes.Equal(prev[o:o+4], cur[o:o+4]) { + continue + } + v := uint32(cur[o]) | uint32(cur[o+1])<<8 | + uint32(cur[o+2])<<16 | uint32(cur[o+3])<<24 + cr, cg, cb := drm.Unpack(v) + img.SetNRGBA(x-rect.Min.X, y-rect.Min.Y, color.NRGBA{R: cr, G: cg, B: cb, A: 255}) + } + } + return img +}