2026-08-30 16:45:19 -07:00
|
|
|
package main
|
|
|
|
|
|
|
|
|
|
import (
|
|
|
|
|
"bytes"
|
|
|
|
|
"encoding/binary"
|
|
|
|
|
"fmt"
|
|
|
|
|
"image"
|
|
|
|
|
"image/color"
|
|
|
|
|
"image/png"
|
2026-08-31 09:02:16 -07:00
|
|
|
"io"
|
2026-08-30 16:45:19 -07:00
|
|
|
"net"
|
|
|
|
|
"net/http"
|
|
|
|
|
"os"
|
2026-08-31 08:58:31 -07:00
|
|
|
"sync"
|
2026-08-31 15:51:52 -07:00
|
|
|
"time"
|
2026-08-30 16:45:19 -07:00
|
|
|
|
|
|
|
|
"g.fc.run/theater/cabletest/internal/drm"
|
2026-08-31 15:51:52 -07:00
|
|
|
"g.fc.run/theater/cabletest/internal/evdev"
|
2026-08-30 16:45:19 -07:00
|
|
|
)
|
|
|
|
|
|
|
|
|
|
const streamAddr = ":8080"
|
|
|
|
|
|
|
|
|
|
const page = `<!doctype html>
|
|
|
|
|
<meta name="viewport" content="width=device-width">
|
|
|
|
|
<title>cabletest</title>
|
|
|
|
|
<body style="margin:0;background:#000;min-height:100vh;display:grid;place-items:center">
|
2026-08-31 14:58:51 -07:00
|
|
|
<canvas style="max-width:100vw;max-height:100vh;touch-action:none"></canvas>
|
2026-08-30 16:45:19 -07:00
|
|
|
<script>
|
|
|
|
|
(async () => {
|
|
|
|
|
const cv = document.querySelector('canvas'), g = cv.getContext('2d');
|
|
|
|
|
for (;;) {
|
2026-08-30 19:15:25 -07:00
|
|
|
try {
|
|
|
|
|
const res = await fetch('frames');
|
|
|
|
|
if (res.ok) {
|
|
|
|
|
const rd = res.body.getReader();
|
|
|
|
|
let buf = new Uint8Array(0);
|
|
|
|
|
for (;;) {
|
|
|
|
|
const {value, done} = await rd.read();
|
|
|
|
|
if (done) break;
|
|
|
|
|
const b = new Uint8Array(buf.length + value.length);
|
|
|
|
|
b.set(buf); b.set(value, buf.length); buf = b;
|
|
|
|
|
for (;;) {
|
|
|
|
|
if (buf.length < 12) break;
|
|
|
|
|
const dv = new DataView(buf.buffer, buf.byteOffset);
|
|
|
|
|
const x = dv.getUint32(0, true), y = dv.getUint32(4, true), n = dv.getUint32(8, true);
|
|
|
|
|
if (buf.length < 12 + n) break;
|
|
|
|
|
const bmp = await createImageBitmap(new Blob([buf.slice(12, 12 + n)], {type: 'image/png'}));
|
|
|
|
|
if (x === 0 && y === 0 && cv.width !== bmp.width) { cv.width = bmp.width; cv.height = bmp.height; }
|
|
|
|
|
g.drawImage(bmp, x, y);
|
|
|
|
|
buf = buf.slice(12 + n);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
} catch (e) {}
|
|
|
|
|
await new Promise(r => setTimeout(r, 1000));
|
2026-08-30 16:45:19 -07:00
|
|
|
}
|
|
|
|
|
})();
|
2026-08-31 09:02:16 -07:00
|
|
|
addEventListener('keydown', e => {
|
|
|
|
|
if (e.key.length === 1 && !e.ctrlKey && !e.metaKey && !e.altKey) {
|
|
|
|
|
e.preventDefault();
|
|
|
|
|
fetch('keys', {method: 'POST', body: e.key});
|
|
|
|
|
}
|
|
|
|
|
});
|
2026-08-31 14:58:51 -07:00
|
|
|
(() => {
|
|
|
|
|
const cv = document.querySelector('canvas');
|
|
|
|
|
let q = Promise.resolve(), held = -1, lastMove = 0;
|
|
|
|
|
const send = b => q = q.then(() => fetch('touch', {method: 'POST', body: b}).catch(() => {}));
|
|
|
|
|
const at = e => {
|
|
|
|
|
const r = cv.getBoundingClientRect();
|
|
|
|
|
return Math.round((e.clientX - r.left) * cv.width / r.width) + ' ' +
|
|
|
|
|
Math.round((e.clientY - r.top) * cv.height / r.height);
|
|
|
|
|
};
|
|
|
|
|
cv.addEventListener('pointerdown', e => {
|
|
|
|
|
e.preventDefault();
|
|
|
|
|
held = e.pointerId;
|
|
|
|
|
cv.setPointerCapture(held);
|
|
|
|
|
send('down ' + at(e));
|
|
|
|
|
});
|
|
|
|
|
cv.addEventListener('pointermove', e => {
|
|
|
|
|
if (e.pointerId !== held || e.timeStamp - lastMove < 50) return;
|
|
|
|
|
lastMove = e.timeStamp;
|
|
|
|
|
send('move ' + at(e));
|
|
|
|
|
});
|
|
|
|
|
const lift = e => {
|
|
|
|
|
if (e.pointerId !== held) return;
|
|
|
|
|
held = -1;
|
|
|
|
|
send('up');
|
|
|
|
|
};
|
|
|
|
|
cv.addEventListener('pointerup', lift);
|
|
|
|
|
cv.addEventListener('pointercancel', lift);
|
|
|
|
|
})();
|
2026-08-30 16:45:19 -07:00
|
|
|
</script>
|
|
|
|
|
`
|
|
|
|
|
|
2026-08-31 08:58:31 -07:00
|
|
|
type raw struct {
|
|
|
|
|
buf []byte
|
|
|
|
|
pitch int
|
|
|
|
|
pw int
|
|
|
|
|
ph int
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
var (
|
|
|
|
|
captured = make(chan raw, 8)
|
|
|
|
|
|
|
|
|
|
latestMu sync.RWMutex
|
|
|
|
|
latest raw
|
|
|
|
|
|
|
|
|
|
clientsMu sync.RWMutex
|
|
|
|
|
clients = make(map[chan []byte]bool)
|
2026-08-31 09:02:16 -07:00
|
|
|
|
|
|
|
|
consoleMu sync.Mutex
|
|
|
|
|
console io.Writer
|
2026-08-31 08:58:31 -07:00
|
|
|
)
|
|
|
|
|
|
2026-08-30 16:45:19 -07:00
|
|
|
func serveStream() error {
|
2026-08-31 08:58:31 -07:00
|
|
|
g, err := openGrabber()
|
|
|
|
|
if err != nil {
|
|
|
|
|
return err
|
|
|
|
|
}
|
2026-08-31 15:51:52 -07:00
|
|
|
if touchscreen, err = evdev.FindTouchscreen(os.O_RDWR); err != nil {
|
2026-08-31 14:58:51 -07:00
|
|
|
return err
|
|
|
|
|
}
|
2026-08-30 16:45:19 -07:00
|
|
|
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)
|
2026-08-31 08:58:31 -07:00
|
|
|
go captureLoop(g)
|
|
|
|
|
go encodeLoop()
|
2026-08-30 16:45:19 -07:00
|
|
|
mux := http.NewServeMux()
|
|
|
|
|
mux.HandleFunc("/{$}", func(w http.ResponseWriter, r *http.Request) {
|
|
|
|
|
fmt.Fprint(w, page)
|
|
|
|
|
})
|
|
|
|
|
mux.HandleFunc("/frames", frames)
|
2026-08-31 09:02:16 -07:00
|
|
|
mux.HandleFunc("/keys", keys)
|
2026-08-31 14:58:51 -07:00
|
|
|
mux.HandleFunc("/touch", touch)
|
2026-08-30 16:45:19 -07:00
|
|
|
go http.Serve(ln, mux)
|
|
|
|
|
return nil
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-31 09:02:16 -07:00
|
|
|
func keys(w http.ResponseWriter, r *http.Request) {
|
|
|
|
|
b, err := io.ReadAll(r.Body)
|
|
|
|
|
if err != nil {
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
consoleMu.Lock()
|
|
|
|
|
defer consoleMu.Unlock()
|
|
|
|
|
if console == nil {
|
|
|
|
|
http.Error(w, "no run to type into", http.StatusServiceUnavailable)
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
if _, err := console.Write(b); err != nil {
|
|
|
|
|
http.Error(w, err.Error(), http.StatusServiceUnavailable)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-31 08:58:31 -07:00
|
|
|
// Between runs the crtc scans out the console framebuffer, which is not a
|
|
|
|
|
// dumb buffer and fails to map on every frame; each error reads once, not at
|
2026-08-31 15:51:52 -07:00
|
|
|
// frame rate. The pause paces the retry, since a failure ahead of the vblank
|
|
|
|
|
// wait would otherwise spin hot.
|
|
|
|
|
const captureErrPause = 100 * time.Millisecond
|
|
|
|
|
|
2026-08-31 08:58:31 -07:00
|
|
|
func captureLoop(g *grabber) {
|
|
|
|
|
lastErr := ""
|
|
|
|
|
for {
|
|
|
|
|
buf, pitch, pw, ph, err := g.rawFrame()
|
|
|
|
|
if err != nil {
|
|
|
|
|
if err.Error() != lastErr {
|
|
|
|
|
lastErr = err.Error()
|
|
|
|
|
fmt.Fprintf(os.Stderr, "capture: %v\n", err)
|
|
|
|
|
}
|
2026-08-31 15:51:52 -07:00
|
|
|
time.Sleep(captureErrPause)
|
2026-08-31 08:58:31 -07:00
|
|
|
continue
|
|
|
|
|
}
|
|
|
|
|
lastErr = ""
|
|
|
|
|
captured <- raw{buf, pitch, pw, ph}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// latest advances before the delta fans out, so a handler that registers and
|
|
|
|
|
// then snapshots latest can only ever replay a delta its keyframe already
|
|
|
|
|
// includes, never miss one.
|
|
|
|
|
func encodeLoop() {
|
|
|
|
|
for cur := range captured {
|
|
|
|
|
prev := latest
|
|
|
|
|
if prev.buf != nil && (prev.pitch != cur.pitch || prev.pw != cur.pw || prev.ph != cur.ph) {
|
|
|
|
|
panic(fmt.Sprintf("framebuffer geometry changed: %dx%d pitch %d to %dx%d pitch %d",
|
|
|
|
|
prev.pw, prev.ph, prev.pitch, cur.pw, cur.ph, cur.pitch))
|
|
|
|
|
}
|
|
|
|
|
latestMu.Lock()
|
|
|
|
|
latest = cur
|
|
|
|
|
latestMu.Unlock()
|
|
|
|
|
if prev.buf != nil {
|
|
|
|
|
broadcast(prev, cur)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// A client that stops draining its channel is closed so its page reconnects
|
|
|
|
|
// into a fresh keyframe.
|
|
|
|
|
func broadcast(prev, cur raw) {
|
|
|
|
|
var delta []byte
|
|
|
|
|
for _, rect := range diffRects(prev.buf, cur.buf, cur.pitch, cur.pw, cur.ph) {
|
|
|
|
|
p, err := pack(rect.Min, maskRect(prev.buf, cur.buf, cur.pitch, cur.ph, rect))
|
|
|
|
|
if err != nil {
|
|
|
|
|
fmt.Fprintf(os.Stderr, "encode: %v\n", err)
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
delta = append(delta, p...)
|
|
|
|
|
}
|
|
|
|
|
var dead []chan []byte
|
|
|
|
|
clientsMu.RLock()
|
|
|
|
|
for ch := range clients {
|
|
|
|
|
select {
|
|
|
|
|
case ch <- delta:
|
|
|
|
|
default:
|
|
|
|
|
dead = append(dead, ch)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
clientsMu.RUnlock()
|
|
|
|
|
if len(dead) == 0 {
|
2026-08-30 16:45:19 -07:00
|
|
|
return
|
|
|
|
|
}
|
2026-08-31 08:58:31 -07:00
|
|
|
clientsMu.Lock()
|
|
|
|
|
for _, ch := range dead {
|
|
|
|
|
if clients[ch] {
|
|
|
|
|
delete(clients, ch)
|
|
|
|
|
close(ch)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
clientsMu.Unlock()
|
|
|
|
|
}
|
2026-08-30 16:45:19 -07:00
|
|
|
|
2026-08-31 08:58:31 -07:00
|
|
|
// Frames are never written again once captured, so the lock only guards the
|
|
|
|
|
// swap of latest itself; deltas queue in the channel while the keyframe
|
|
|
|
|
// encodes, and replaying one the keyframe already includes is idempotent.
|
|
|
|
|
func frames(w http.ResponseWriter, r *http.Request) {
|
|
|
|
|
ch := make(chan []byte, 16)
|
|
|
|
|
clientsMu.Lock()
|
|
|
|
|
clients[ch] = true
|
|
|
|
|
clientsMu.Unlock()
|
|
|
|
|
defer func() {
|
|
|
|
|
clientsMu.Lock()
|
|
|
|
|
delete(clients, ch)
|
|
|
|
|
clientsMu.Unlock()
|
|
|
|
|
}()
|
|
|
|
|
latestMu.RLock()
|
|
|
|
|
k := latest
|
|
|
|
|
latestMu.RUnlock()
|
|
|
|
|
if k.buf == nil {
|
|
|
|
|
http.Error(w, "no frame captured yet", http.StatusServiceUnavailable)
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
key, err := pack(image.Pt(0, 0), decode(k.buf, k.pitch, k.pw, k.ph))
|
2026-08-30 16:45:19 -07:00
|
|
|
if err != nil {
|
2026-08-31 08:58:31 -07:00
|
|
|
fmt.Fprintf(os.Stderr, "keyframe %s: %v\n", r.RemoteAddr, err)
|
2026-08-30 16:45:19 -07:00
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
w.Header().Set("Content-Type", "application/octet-stream")
|
2026-08-31 08:58:31 -07:00
|
|
|
if _, err := w.Write(key); err != nil {
|
2026-08-30 16:45:19 -07:00
|
|
|
return
|
|
|
|
|
}
|
2026-08-31 08:58:31 -07:00
|
|
|
w.(http.Flusher).Flush()
|
2026-08-30 16:45:19 -07:00
|
|
|
ctx := r.Context()
|
|
|
|
|
for {
|
2026-08-31 08:58:31 -07:00
|
|
|
select {
|
|
|
|
|
case <-ctx.Done():
|
|
|
|
|
return
|
|
|
|
|
case payload, ok := <-ch:
|
|
|
|
|
if !ok {
|
2026-08-30 16:45:19 -07:00
|
|
|
return
|
|
|
|
|
}
|
2026-08-31 08:58:31 -07:00
|
|
|
if _, err := w.Write(payload); err != nil {
|
2026-08-30 16:45:19 -07:00
|
|
|
return
|
|
|
|
|
}
|
2026-08-31 08:58:31 -07:00
|
|
|
w.(http.Flusher).Flush()
|
2026-08-30 16:45:19 -07:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-31 08:58:31 -07:00
|
|
|
// On rect-sized parts the dense level trades 3-6x the cpu for a few percent
|
|
|
|
|
// of size — enough to fall behind the capture loop on the once-per-second
|
|
|
|
|
// bursts and stutter the stream.
|
2026-08-30 16:45:19 -07:00
|
|
|
var encoder = png.Encoder{CompressionLevel: png.DefaultCompression}
|
|
|
|
|
|
2026-08-31 08:58:31 -07:00
|
|
|
func pack(at image.Point, img *image.NRGBA) ([]byte, error) {
|
2026-08-30 16:45:19 -07:00
|
|
|
var buf bytes.Buffer
|
|
|
|
|
var hdr [12]byte
|
|
|
|
|
binary.LittleEndian.PutUint32(hdr[0:], uint32(at.X))
|
|
|
|
|
binary.LittleEndian.PutUint32(hdr[4:], uint32(at.Y))
|
2026-08-31 08:58:31 -07:00
|
|
|
buf.Write(hdr[:])
|
|
|
|
|
if err := encoder.Encode(&buf, img); err != nil {
|
|
|
|
|
return nil, err
|
2026-08-30 16:45:19 -07:00
|
|
|
}
|
2026-08-31 08:58:31 -07:00
|
|
|
b := buf.Bytes()
|
|
|
|
|
binary.LittleEndian.PutUint32(b[8:], uint32(len(b)-12))
|
|
|
|
|
return b, nil
|
2026-08-30 16:45:19 -07:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// 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
|
|
|
|
|
}
|