Skip to content

Commit

Permalink
Add support streaming as ascii to terminal
Browse files Browse the repository at this point in the history
  • Loading branch information
AlexxIT committed May 16, 2024
1 parent 75020d4 commit 41badbf
Show file tree
Hide file tree
Showing 5 changed files with 227 additions and 25 deletions.
33 changes: 33 additions & 0 deletions internal/mjpeg/README.md
Original file line number Diff line number Diff line change
@@ -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"
```
37 changes: 12 additions & 25 deletions internal/mjpeg/init.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -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)
}
Expand Down Expand Up @@ -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) {
Expand Down
6 changes: 6 additions & 0 deletions pkg/ascii/README.md
Original file line number Diff line number Diff line change
@@ -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
140 changes: 140 additions & 0 deletions pkg/ascii/ascii.go
Original file line number Diff line number Diff line change
@@ -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
}
36 changes: 36 additions & 0 deletions pkg/mjpeg/writer.go
Original file line number Diff line number Diff line change
@@ -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
}

0 comments on commit 41badbf

Please sign in to comment.