mirror of
https://github.com/AlexxIT/go2rtc.git
synced 2025-10-13 12:03:52 +08:00
Add support streaming as ascii to terminal
This commit is contained in:
33
internal/mjpeg/README.md
Normal file
33
internal/mjpeg/README.md
Normal file
@@ -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"
|
||||||
|
```
|
@@ -5,12 +5,14 @@ import (
|
|||||||
"io"
|
"io"
|
||||||
"net/http"
|
"net/http"
|
||||||
"strconv"
|
"strconv"
|
||||||
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/AlexxIT/go2rtc/internal/api"
|
"github.com/AlexxIT/go2rtc/internal/api"
|
||||||
"github.com/AlexxIT/go2rtc/internal/api/ws"
|
"github.com/AlexxIT/go2rtc/internal/api/ws"
|
||||||
"github.com/AlexxIT/go2rtc/internal/ffmpeg"
|
"github.com/AlexxIT/go2rtc/internal/ffmpeg"
|
||||||
"github.com/AlexxIT/go2rtc/internal/streams"
|
"github.com/AlexxIT/go2rtc/internal/streams"
|
||||||
|
"github.com/AlexxIT/go2rtc/pkg/ascii"
|
||||||
"github.com/AlexxIT/go2rtc/pkg/core"
|
"github.com/AlexxIT/go2rtc/pkg/core"
|
||||||
"github.com/AlexxIT/go2rtc/pkg/magic"
|
"github.com/AlexxIT/go2rtc/pkg/magic"
|
||||||
"github.com/AlexxIT/go2rtc/pkg/mjpeg"
|
"github.com/AlexxIT/go2rtc/pkg/mjpeg"
|
||||||
@@ -21,6 +23,7 @@ import (
|
|||||||
func Init() {
|
func Init() {
|
||||||
api.HandleFunc("api/frame.jpeg", handlerKeyframe)
|
api.HandleFunc("api/frame.jpeg", handlerKeyframe)
|
||||||
api.HandleFunc("api/stream.mjpeg", handlerStream)
|
api.HandleFunc("api/stream.mjpeg", handlerStream)
|
||||||
|
api.HandleFunc("api/stream.ascii", handlerStream)
|
||||||
|
|
||||||
ws.HandleFunc("mjpeg", handlerWS)
|
ws.HandleFunc("mjpeg", handlerWS)
|
||||||
}
|
}
|
||||||
@@ -99,40 +102,24 @@ func outputMjpeg(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
h := w.Header()
|
h := w.Header()
|
||||||
h.Set("Content-Type", "multipart/x-mixed-replace; boundary=frame")
|
|
||||||
h.Set("Cache-Control", "no-cache")
|
h.Set("Cache-Control", "no-cache")
|
||||||
h.Set("Connection", "close")
|
h.Set("Connection", "close")
|
||||||
h.Set("Pragma", "no-cache")
|
h.Set("Pragma", "no-cache")
|
||||||
|
|
||||||
wr := &writer{wr: w, buf: []byte(header)}
|
if strings.HasSuffix(r.URL.Path, "mjpeg") {
|
||||||
|
wr := mjpeg.NewWriter(w)
|
||||||
_, _ = cons.WriteTo(wr)
|
_, _ = cons.WriteTo(wr)
|
||||||
|
} else {
|
||||||
|
cons.Type = "ASCII passive consumer "
|
||||||
|
|
||||||
|
query := r.URL.Query()
|
||||||
|
wr := ascii.NewWriter(w, query.Get("color"), query.Get("back"), query.Get("text"))
|
||||||
|
_, _ = cons.WriteTo(wr)
|
||||||
|
}
|
||||||
|
|
||||||
stream.RemoveConsumer(cons)
|
stream.RemoveConsumer(cons)
|
||||||
}
|
}
|
||||||
|
|
||||||
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
|
|
||||||
}
|
|
||||||
|
|
||||||
func inputMjpeg(w http.ResponseWriter, r *http.Request) {
|
func inputMjpeg(w http.ResponseWriter, r *http.Request) {
|
||||||
dst := r.URL.Query().Get("dst")
|
dst := r.URL.Query().Get("dst")
|
||||||
stream := streams.Get(dst)
|
stream := streams.Get(dst)
|
||||||
|
6
pkg/ascii/README.md
Normal file
6
pkg/ascii/README.md
Normal file
@@ -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
pkg/ascii/ascii.go
Normal file
140
pkg/ascii/ascii.go
Normal file
@@ -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
pkg/mjpeg/writer.go
Normal file
36
pkg/mjpeg/writer.go
Normal file
@@ -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
|
||||||
|
}
|
Reference in New Issue
Block a user