diff --git a/internal/mjpeg/README.md b/internal/mjpeg/README.md new file mode 100644 index 00000000..eabe62e0 --- /dev/null +++ b/internal/mjpeg/README.md @@ -0,0 +1,33 @@ +## Stream as ASCII to Terminal + +**Tips** + +- this feature works only with MJPEG codec (use transcoding) +- choose a low frame rate (FPS) +- choose the width and height to fit in your terminal +- different terminals support different numbers of colours (8, 256, rgb) +- escape text param with urlencode +- you can stream any camera or file from a disc + +**go2rtc.yaml** - transcoding to MJPEG, terminal size - 210x60, fps - 4 + +```yaml +streams: + macarena: ffmpeg:macarena.mp4#video=mjpeg#hardware#width=210#height=60#raw=-r 4 +``` + +**API params** + +- `color` - foreground color, values: empty, `8`, `256`, `rgb` +- `back` - background color, values: empty, `8`, `256`, `rgb` +- `text` - character set, values: empty, one space, two spaces, anything you like (in order of brightness) + +**Examples** + +```bash +% curl "http://192.168.1.123:1984/api/stream.ascii?src=macarena" +% curl "http://192.168.1.123:1984/api/stream.ascii?src=macarena&color=256" +% curl "http://192.168.1.123:1984/api/stream.ascii?src=macarena&back=256&text=%20" +% curl "http://192.168.1.123:1984/api/stream.ascii?src=macarena&back=8&text=%20%20" +% curl "http://192.168.1.123:1984/api/stream.ascii?src=macarena&text=helloworld" +``` diff --git a/internal/mjpeg/init.go b/internal/mjpeg/init.go index f2519a61..7a4403f9 100644 --- a/internal/mjpeg/init.go +++ b/internal/mjpeg/init.go @@ -5,12 +5,14 @@ import ( "io" "net/http" "strconv" + "strings" "time" "github.com/AlexxIT/go2rtc/internal/api" "github.com/AlexxIT/go2rtc/internal/api/ws" "github.com/AlexxIT/go2rtc/internal/ffmpeg" "github.com/AlexxIT/go2rtc/internal/streams" + "github.com/AlexxIT/go2rtc/pkg/ascii" "github.com/AlexxIT/go2rtc/pkg/core" "github.com/AlexxIT/go2rtc/pkg/magic" "github.com/AlexxIT/go2rtc/pkg/mjpeg" @@ -21,6 +23,7 @@ import ( func Init() { api.HandleFunc("api/frame.jpeg", handlerKeyframe) api.HandleFunc("api/stream.mjpeg", handlerStream) + api.HandleFunc("api/stream.ascii", handlerStream) ws.HandleFunc("mjpeg", handlerWS) } @@ -99,38 +102,22 @@ func outputMjpeg(w http.ResponseWriter, r *http.Request) { } h := w.Header() - h.Set("Content-Type", "multipart/x-mixed-replace; boundary=frame") h.Set("Cache-Control", "no-cache") h.Set("Connection", "close") h.Set("Pragma", "no-cache") - wr := &writer{wr: w, buf: []byte(header)} - _, _ = cons.WriteTo(wr) - - stream.RemoveConsumer(cons) -} - -const header = "--frame\r\nContent-Type: image/jpeg\r\nContent-Length: " - -type writer struct { - wr io.Writer - buf []byte -} + if strings.HasSuffix(r.URL.Path, "mjpeg") { + wr := mjpeg.NewWriter(w) + _, _ = cons.WriteTo(wr) + } else { + cons.Type = "ASCII passive consumer " -func (w *writer) Write(p []byte) (n int, err error) { - w.buf = w.buf[:len(header)] - w.buf = append(w.buf, strconv.Itoa(len(p))...) - w.buf = append(w.buf, "\r\n\r\n"...) - w.buf = append(w.buf, p...) - w.buf = append(w.buf, "\r\n"...) - - // Chrome bug: mjpeg image always shows the second to last image - // https://bugs.chromium.org/p/chromium/issues/detail?id=527446 - if n, err = w.wr.Write(w.buf); err == nil { - w.wr.(http.Flusher).Flush() + query := r.URL.Query() + wr := ascii.NewWriter(w, query.Get("color"), query.Get("back"), query.Get("text")) + _, _ = cons.WriteTo(wr) } - return + stream.RemoveConsumer(cons) } func inputMjpeg(w http.ResponseWriter, r *http.Request) { diff --git a/pkg/ascii/README.md b/pkg/ascii/README.md new file mode 100644 index 00000000..bc9ef9dd --- /dev/null +++ b/pkg/ascii/README.md @@ -0,0 +1,6 @@ +## Useful links + +- https://en.wikipedia.org/wiki/ANSI_escape_code +- https://paulbourke.net/dataformats/asciiart/ +- https://github.com/kutuluk/xterm-color-chart +- https://github.com/hugomd/parrot.live diff --git a/pkg/ascii/ascii.go b/pkg/ascii/ascii.go new file mode 100644 index 00000000..67c7c9a3 --- /dev/null +++ b/pkg/ascii/ascii.go @@ -0,0 +1,140 @@ +package ascii + +import ( + "bytes" + "fmt" + "image/jpeg" + "io" + "net/http" +) + +func NewWriter(w io.Writer, foreground, background, text string) io.Writer { + a := &writer{wr: w, buf: []byte(clearScreen)} + + var idx0 uint8 + + // https://en.wikipedia.org/wiki/ANSI_escape_code + switch foreground { + case "8": + a.color = func(r, g, b uint8) { + if idx := xterm256color(r, g, b, 8); idx != idx0 { + idx0 = idx + a.buf = append(a.buf, fmt.Sprintf("\033[%dm", 30+idx)...) + } + } + case "256": + a.color = func(r, g, b uint8) { + if idx := xterm256color(r, g, b, 255); idx != idx0 { + idx0 = idx + a.buf = append(a.buf, fmt.Sprintf("\033[38;5;%dm", idx)...) + } + } + case "rgb": + a.color = func(r, g, b uint8) { + a.buf = append(a.buf, fmt.Sprintf("\033[38;2;%d;%d;%dm", r, g, b)...) + } + } + + switch background { + case "8": + a.color = func(r, g, b uint8) { + if idx := xterm256color(r, g, b, 8); idx != idx0 { + idx0 = idx + a.buf = append(a.buf, fmt.Sprintf("\033[%dm", 40+idx)...) + } + } + case "256": + a.color = func(r, g, b uint8) { + if idx := xterm256color(r, g, b, 255); idx != idx0 { + a.buf = append(a.buf, fmt.Sprintf("\033[48;5;%dm", idx)...) + } + } + case "rgb": + a.color = func(r, g, b uint8) { + a.buf = append(a.buf, fmt.Sprintf("\033[48;2;%d;%d;%dm", r, g, b)...) + } + } + + var ascii string + switch text { + case "": + ascii = ` .::--~~==++**##%%$@` + case " ": + a.text = func(r, g, b uint32) { + a.buf = append(a.buf, ' ') + } + case " ": + a.text = func(r, g, b uint32) { + a.buf = append(a.buf, ' ', ' ') + } + default: + ascii = text + } + if ascii != "" { + k := float64(len(ascii)-1) / 255 + a.text = func(r, g, b uint32) { + gray := (19595*r + 38470*g + 7471*b + 1<<15) >> 24 // uint8 + i := uint8(float64(gray) * k) + a.buf = append(a.buf, ascii[i]) + } + } + + return a +} + +type writer struct { + wr io.Writer + buf []byte + color func(r, g, b uint8) + text func(r, g, b uint32) +} + +// https://stackoverflow.com/questions/37774983/clearing-the-screen-by-printing-a-character +const clearScreen = "\033[2J" + "\033[H" + +func (a *writer) Write(p []byte) (n int, err error) { + img, err := jpeg.Decode(bytes.NewReader(p)) + if err != nil { + return 0, err + } + + a.buf = a.buf[:len(clearScreen)] + + w := img.Bounds().Dy() + h := img.Bounds().Dx() + + for y := 0; y < w; y++ { + for x := 0; x < h; x++ { + r, g, b, _ := img.At(x, y).RGBA() + if a.color != nil { + a.color(uint8(r>>8), uint8(g>>8), uint8(b>>8)) + } + a.text(r, g, b) + } + a.buf = append(a.buf, '\n') + } + + a.buf = append(a.buf, "\033[0m\n"...) + + if n, err = a.wr.Write(a.buf); err == nil { + a.wr.(http.Flusher).Flush() + } + + return +} + +const x256r = "\x00\x80\x00\x80\x00\x80\x00\xc0\x80\xff\x00\xff\x00\xff\x00\xff\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x5f\x5f\x5f\x5f\x5f\x5f\x5f\x5f\x5f\x5f\x5f\x5f\x5f\x5f\x5f\x5f\x5f\x5f\x5f\x5f\x5f\x5f\x5f\x5f\x5f\x5f\x5f\x5f\x5f\x5f\x5f\x5f\x5f\x5f\x5f\x5f\x87\x87\x87\x87\x87\x87\x87\x87\x87\x87\x87\x87\x87\x87\x87\x87\x87\x87\x87\x87\x87\x87\x87\x87\x87\x87\x87\x87\x87\x87\x87\x87\x87\x87\x87\x87\xaf\xaf\xaf\xaf\xaf\xaf\xaf\xaf\xaf\xaf\xaf\xaf\xaf\xaf\xaf\xaf\xaf\xaf\xaf\xaf\xaf\xaf\xaf\xaf\xaf\xaf\xaf\xaf\xaf\xaf\xaf\xaf\xaf\xaf\xaf\xaf\xd7\xd7\xd7\xd7\xd7\xd7\xd7\xd7\xd7\xd7\xd7\xd7\xd7\xd7\xd7\xd7\xd7\xd7\xd7\xd7\xd7\xd7\xd7\xd7\xd7\xd7\xd7\xd7\xd7\xd7\xd7\xd7\xd7\xd7\xd7\xd7\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\x08\x12\x1c\x26\x30\x3a\x44\x4e\x58\x60\x66\x76\x80\x8a\x94\x9e\xa8\xb2\xbc\xc6\xd0\xda\xe4\xee" +const x256g = "\x00\x00\x80\x80\x00\x00\x80\xc0\x80\x00\xff\xff\x00\x00\xff\xff\x00\x00\x00\x00\x00\x00\x5f\x5f\x5f\x5f\x5f\x5f\x87\x87\x87\x87\x87\x87\xaf\xaf\xaf\xaf\xaf\xaf\xd7\xd7\xd7\xd7\xd7\xd7\xff\xff\xff\xff\xff\xff\x00\x00\x00\x00\x00\x00\x5f\x5f\x5f\x5f\x5f\x5f\x87\x87\x87\x87\x87\x87\xaf\xaf\xaf\xaf\xaf\xaf\xd7\xd7\xd7\xd7\xd7\xd7\xff\xff\xff\xff\xff\xff\x00\x00\x00\x00\x00\x00\x5f\x5f\x5f\x5f\x5f\x5f\x87\x87\x87\x87\x87\x87\xaf\xaf\xaf\xaf\xaf\xaf\xd7\xd7\xd7\xd7\xd7\xd7\xff\xff\xff\xff\xff\xff\x00\x00\x00\x00\x00\x00\x5f\x5f\x5f\x5f\x5f\x5f\x87\x87\x87\x87\x87\x87\xaf\xaf\xaf\xaf\xaf\xaf\xd7\xd7\xd7\xd7\xd7\xd7\xff\xff\xff\xff\xff\xff\x00\x00\x00\x00\x00\x00\x5f\x5f\x5f\x5f\x5f\x5f\x87\x87\x87\x87\x87\x87\xaf\xaf\xaf\xaf\xaf\xaf\xd7\xd7\xd7\xd7\xd7\xd7\xff\xff\xff\xff\xff\xff\x00\x00\x00\x00\x00\x00\x5f\x5f\x5f\x5f\x5f\x5f\x87\x87\x87\x87\x87\x87\xaf\xaf\xaf\xaf\xaf\xaf\xd7\xd7\xd7\xd7\xd7\xd7\xff\xff\xff\xff\xff\xff\x08\x12\x1c\x26\x30\x3a\x44\x4e\x58\x60\x66\x76\x80\x8a\x94\x9e\xa8\xb2\xbc\xc6\xd0\xda\xe4\xee" +const x256b = "\x00\x00\x00\x00\x80\x80\x80\xc0\x80\x00\x00\x00\xff\xff\xff\xff\x00\x5f\x87\xaf\xd7\xff\x00\x5f\x87\xaf\xd7\xff\x00\x5f\x87\xaf\xd7\xff\x00\x5f\x87\xaf\xd7\xff\x00\x5f\x87\xaf\xd7\xff\x00\x5f\x87\xaf\xd7\xff\x00\x5f\x87\xaf\xd7\xff\x00\x5f\x87\xaf\xd7\xff\x00\x5f\x87\xaf\xd7\xff\x00\x5f\x87\xaf\xd7\xff\x00\x5f\x87\xaf\xd7\xff\x00\x5f\x87\xaf\xd7\xff\x00\x5f\x87\xaf\xd7\xff\x00\x5f\x87\xaf\xd7\xff\x00\x5f\x87\xaf\xd7\xff\x00\x5f\x87\xaf\xd7\xff\x00\x5f\x87\xaf\xd7\xff\x00\x5f\x87\xaf\xd7\xff\x00\x5f\x87\xaf\xd7\xff\x00\x5f\x87\xaf\xd7\xff\x00\x5f\x87\xaf\xd7\xff\x00\x5f\x87\xaf\xd7\xff\x00\x5f\x87\xaf\xd7\xff\x00\x5f\x87\xaf\xd7\xff\x00\x5f\x87\xaf\xd7\xff\x00\x5f\x87\xaf\xd7\xff\x00\x5f\x87\xaf\xd7\xff\x00\x5f\x87\xaf\xd7\xff\x00\x5f\x87\xaf\xd7\xff\x00\x5f\x87\xaf\xd7\xff\x00\x5f\x87\xaf\xd7\xff\x00\x5f\x87\xaf\xd7\xff\x00\x5f\x87\xaf\xd7\xff\x00\x5f\x87\xaf\xd7\xff\x00\x5f\x87\xaf\xd7\xff\x00\x5f\x87\xaf\xd7\xff\x08\x12\x1c\x26\x30\x3a\x44\x4e\x58\x60\x66\x76\x80\x8a\x94\x9e\xa8\xb2\xbc\xc6\xd0\xda\xe4\xee" + +func xterm256color(r, g, b uint8, n int) (index uint8) { + best := uint16(0xFFFF) + for i := 0; i < n; i++ { + diff := uint16(r-x256r[i]) + uint16(g-x256g[i]) + uint16(b-x256b[i]) + if diff < best { + best = diff + index = uint8(i) + } + } + return +} diff --git a/pkg/mjpeg/writer.go b/pkg/mjpeg/writer.go new file mode 100644 index 00000000..c5393015 --- /dev/null +++ b/pkg/mjpeg/writer.go @@ -0,0 +1,36 @@ +package mjpeg + +import ( + "io" + "net/http" + "strconv" +) + +func NewWriter(w io.Writer) io.Writer { + h := w.(http.ResponseWriter).Header() + h.Set("Content-Type", "multipart/x-mixed-replace; boundary=frame") + return &writer{wr: w, buf: []byte(header)} +} + +const header = "--frame\r\nContent-Type: image/jpeg\r\nContent-Length: " + +type writer struct { + wr io.Writer + buf []byte +} + +func (w *writer) Write(p []byte) (n int, err error) { + w.buf = w.buf[:len(header)] + w.buf = append(w.buf, strconv.Itoa(len(p))...) + w.buf = append(w.buf, "\r\n\r\n"...) + w.buf = append(w.buf, p...) + w.buf = append(w.buf, "\r\n"...) + + // Chrome bug: mjpeg image always shows the second to last image + // https://bugs.chromium.org/p/chromium/issues/detail?id=527446 + if n, err = w.wr.Write(w.buf); err == nil { + w.wr.(http.Flusher).Flush() + } + + return +}