diff --git a/api/openapi.yaml b/api/openapi.yaml index 618acb48c..9d6e86e94 100644 --- a/api/openapi.yaml +++ b/api/openapi.yaml @@ -112,8 +112,8 @@ paths: schema: { type: integer } example: 100 responses: - default: - description: Default response + default: + description: Default response /api/restart: post: @@ -121,8 +121,8 @@ paths: description: Restarts the daemon. tags: [ Application ] responses: - default: - description: Default response + default: + description: Default response /api/config: get: @@ -140,8 +140,8 @@ paths: content: "*/*": { example: "streams:..." } responses: - default: - description: Default response + default: + description: Default response patch: summary: Merge changes to main config file tags: [ Config ] @@ -149,8 +149,8 @@ paths: content: "*/*": { example: "streams:..." } responses: - default: - description: Default response + default: + description: Default response @@ -180,8 +180,8 @@ paths: schema: { type: string } example: camera1 responses: - default: - description: Default response + default: + description: Default response patch: summary: Update stream source tags: [ Streams list ] @@ -199,8 +199,8 @@ paths: schema: { type: string } example: camera1 responses: - default: - description: Default response + default: + description: Default response delete: summary: Delete stream tags: [ Streams list ] @@ -212,8 +212,8 @@ paths: schema: { type: string } example: camera1 responses: - default: - description: Default response + default: + description: Default response post: summary: Send stream from source to destination description: "[Stream to camera](https://github.com/AlexxIT/go2rtc#stream-to-camera)" @@ -232,8 +232,8 @@ paths: schema: { type: string } example: camera1 responses: - default: - description: Default response + default: + description: Default response @@ -334,7 +334,72 @@ paths: description: "" content: { multipart/x-mixed-replace: { example: "" } } - + /api/stream.ascii: + get: + summary: Stream video as ASCII art + description: Streams a video source as ASCII art with various configurable parameters. + parameters: + - name: src + in: query + required: true + description: Video source identifier + schema: + type: string + example: gamazda + - name: color + in: query + required: false + description: Foreground color in xterm-256 color codes + schema: + type: string + enum: [ 256, 8 ] + example: "256" + - name: back + in: query + required: false + description: Background color in xterm-256 color codes + schema: + type: string + example: "40" # black + - name: text + in: query + required: false + description: Character set to use for ASCII art + schema: + type: string + - name: width + in: query + required: false + description: Width of the output in characters + schema: + type: integer + minimum: 1 + example: 100 + - name: height + in: query + required: false + description: Height of the output in characters + schema: + type: integer + minimum: 1 + example: 40 + responses: + '200': + description: Successfully streaming video as ASCII art + content: + image/jpeg: + schema: + type: string + format: binary + '400': + description: Bad request, possible incorrect parameters + content: + application/json: + schema: + type: object + properties: + error: + type: string /api/frame.jpeg?src={src}: get: @@ -369,8 +434,8 @@ paths: parameters: - $ref: "#/components/parameters/stream_dst_path" responses: - default: - description: Default response + default: + description: Default response /api/stream.flv?dst={dst}: post: summary: Post stream in FLV format @@ -379,8 +444,8 @@ paths: parameters: - $ref: "#/components/parameters/stream_dst_path" responses: - default: - description: Default response + default: + description: Default response /api/stream.ts?dst={dst}: post: summary: Post stream in MPEG-TS format @@ -389,8 +454,8 @@ paths: parameters: - $ref: "#/components/parameters/stream_dst_path" responses: - default: - description: Default response + default: + description: Default response /api/stream.mjpeg?dst={dst}: post: summary: Post stream in MJPEG format @@ -399,8 +464,8 @@ paths: parameters: - $ref: "#/components/parameters/stream_dst_path" responses: - default: - description: Default response + default: + description: Default response @@ -410,8 +475,8 @@ paths: description: "[Source: DVRIP](https://github.com/AlexxIT/go2rtc#source-dvrip)" tags: [ Discovery ] responses: - default: - description: Default response + default: + description: Default response /api/ffmpeg/devices: get: @@ -419,55 +484,55 @@ paths: description: "[Source: FFmpeg Device](https://github.com/AlexxIT/go2rtc#source-ffmpeg-device)" tags: [ Discovery ] responses: - default: - description: Default response + default: + description: Default response /api/ffmpeg/hardware: get: summary: FFmpeg hardware transcoding discovery description: "[Hardware acceleration](https://github.com/AlexxIT/go2rtc/wiki/Hardware-acceleration)" tags: [ Discovery ] responses: - default: - description: Default response + default: + description: Default response /api/hass: get: summary: Home Assistant cameras discovery description: "[Source: Hass](https://github.com/AlexxIT/go2rtc#source-hass)" tags: [ Discovery ] responses: - default: - description: Default response + default: + description: Default response /api/homekit: get: summary: HomeKit cameras discovery description: "[Source: HomeKit](https://github.com/AlexxIT/go2rtc#source-homekit)" tags: [ Discovery ] responses: - default: - description: Default response + default: + description: Default response /api/nest: get: summary: Nest cameras discovery tags: [ Discovery ] responses: - default: - description: Default response + default: + description: Default response /api/onvif: get: summary: ONVIF cameras discovery description: "[Source: ONVIF](https://github.com/AlexxIT/go2rtc#source-onvif)" tags: [ Discovery ] responses: - default: - description: Default response + default: + description: Default response /api/roborock: get: summary: Roborock vacuums discovery description: "[Source: Roborock](https://github.com/AlexxIT/go2rtc#source-roborock)" tags: [ Discovery ] responses: - default: - description: Default response + default: + description: Default response @@ -477,8 +542,8 @@ paths: description: Simple realisation of the ONVIF protocol. Accepts any suburl requests tags: [ ONVIF ] responses: - default: - description: Default response + default: + description: Default response @@ -488,8 +553,8 @@ paths: description: Simple API for support [RTSPtoWebRTC](https://www.home-assistant.io/integrations/rtsp_to_webrtc/) integration tags: [ RTSPtoWebRTC ] responses: - default: - description: Default response + default: + description: Default response @@ -515,8 +580,8 @@ paths: parameters: - $ref: "#/components/parameters/stream_src_path" responses: - default: - description: Default response + default: + description: Default response /api/webtorrent: get: diff --git a/internal/mjpeg/README.md b/internal/mjpeg/README.md index a09e59c4b..5892333f5 100644 --- a/internal/mjpeg/README.md +++ b/internal/mjpeg/README.md @@ -26,6 +26,10 @@ streams: - example: `40` (black), `47` (white), `48;5;226` (yellow) - `text` - character set, values: empty, one char, `block`, list of chars (in order of brightness) - example: `%20` (space), `block` (keyword for block elements), `ox` (two chars) +- `width` - the width of the output in characters, value: any positive integer + - example: `100` +- `height` - the height of the output in characters, value: any positive integer + - example: `40` **Examples** @@ -35,4 +39,6 @@ streams: % curl "http://192.168.1.123:1984/api/stream.ascii?src=gamazda&back=256&text=%20" % curl "http://192.168.1.123:1984/api/stream.ascii?src=gamazda&back=8&text=%20%20" % curl "http://192.168.1.123:1984/api/stream.ascii?src=gamazda&text=helloworld" -``` +% curl "http://192.168.1.123:1984/api/stream.ascii?src=gamazda&width=100&height=40" +% curl "http://192.168.1.123:1984/api/stream.ascii?src=gamazda&color=256&width=80&height=20" +``` \ No newline at end of file diff --git a/internal/mjpeg/init.go b/internal/mjpeg/init.go index 0bed95c60..dfeb70e25 100644 --- a/internal/mjpeg/init.go +++ b/internal/mjpeg/init.go @@ -120,7 +120,10 @@ func outputMjpeg(w http.ResponseWriter, r *http.Request) { cons.Type = "ASCII passive consumer " query := r.URL.Query() - wr := ascii.NewWriter(w, query.Get("color"), query.Get("back"), query.Get("text")) + wr := ascii.NewWriter(w, + query.Get("color"), query.Get("back"), query.Get("text"), + core.Atoi(query.Get("width")), core.Atoi(query.Get("height")), + ) _, _ = cons.WriteTo(wr) } diff --git a/pkg/ascii/ascii.go b/pkg/ascii/ascii.go index 6636e278b..45051a70f 100644 --- a/pkg/ascii/ascii.go +++ b/pkg/ascii/ascii.go @@ -3,19 +3,27 @@ package ascii import ( "bytes" "fmt" + "image" "image/jpeg" "io" + "math" "net/http" "unicode/utf8" ) -func NewWriter(w io.Writer, foreground, background, text string) io.Writer { +func NewWriter(w io.Writer, foreground, background, text string, width, height int) io.Writer { // once clear screen _, _ = w.Write([]byte(csiClear)) // every frame - move to home a := &writer{wr: w, buf: []byte(csiHome)} + if width > 0 || height > 0 { + a.trans = func(img image.Image) image.Image { + return resizeImage(img, width, height) + } + } + // https://en.wikipedia.org/wiki/ANSI_escape_code switch foreground { case "": @@ -23,7 +31,6 @@ func NewWriter(w io.Writer, foreground, background, text string) io.Writer { a.color = func(r, g, b uint8) { idx := xterm256color(r, g, b, 8) a.appendEsc(fmt.Sprintf("\033[%dm", 30+idx)) - } case "256": a.color = func(r, g, b uint8) { @@ -98,6 +105,7 @@ type writer struct { esc string color func(r, g, b uint8) text func(r, g, b uint32) + trans func(image.Image) image.Image } // https://stackoverflow.com/questions/37774983/clearing-the-screen-by-printing-a-character @@ -110,6 +118,10 @@ func (a *writer) Write(p []byte) (n int, err error) { return 0, err } + if a.trans != nil { + img = a.trans(img) + } + a.buf = a.buf[:a.pre] // restore prefix w := img.Bounds().Dx() @@ -165,6 +177,61 @@ func xterm256color(r, g, b uint8, n int) (index uint8) { return } +// resizeImage resizes the given image to the specified new width and height. +// If either newWidth or newHeight is set to 0, the function calculates the missing dimension +// to maintain the aspect ratio of the original image. +// +// Parameters: +// - img: The source image to be resized. +// - newWidth: The desired width of the resized image. If set to 0, it will be calculated based on newHeight. +// - newHeight: The desired height of the resized image. If set to 0, it will be calculated based on newWidth. +// +// Returns: +// - A new image.Image object that is the resized version of the input image. +// +// Example usage: +// +// resizedImg := resizeImage(originalImg, 200, 0) // Resizes to a width of 200 while maintaining aspect ratio. +func resizeImage(img image.Image, newWidth, newHeight int) image.Image { + if newWidth == 0 && newHeight == 0 { + return img + } + + bounds := img.Bounds() + width, height := bounds.Max.X, bounds.Max.Y + + // Calculate missing dimension if necessary + if newWidth == 0 { + newWidth = int(math.Round(float64(width) * (float64(newHeight) / float64(height)))) + } else if newHeight == 0 { + newHeight = int(math.Round(float64(height) * (float64(newWidth) / float64(width)))) + } + + newImg := image.NewRGBA(image.Rect(0, 0, newWidth, newHeight)) + + xRatio := float64(width) / float64(newWidth) + yRatio := float64(height) / float64(newHeight) + + for y := 0; y < newHeight; y++ { + for x := 0; x < newWidth; x++ { + srcX := int(xRatio * float64(x)) + srcY := int(yRatio * float64(y)) + + if srcX >= width { + srcX = width - 1 + } + if srcY >= height { + srcY = height - 1 + } + + newColor := img.At(srcX, srcY) + newImg.Set(x, y, newColor) + } + } + + return newImg +} + // sqDiff - just like from image/color/color.go func sqDiff(x, y uint8) uint16 { d := uint16(x - y) diff --git a/pkg/ascii/ascii_test.go b/pkg/ascii/ascii_test.go new file mode 100644 index 000000000..136c20eb4 --- /dev/null +++ b/pkg/ascii/ascii_test.go @@ -0,0 +1,59 @@ +package ascii + +import ( + "image" + "testing" +) + +func TestXterm256Color(t *testing.T) { + tests := []struct { + r, g, b uint8 + n int + want uint8 + }{ + {255, 0, 0, 6, 1}, + {0, 255, 0, 6, 2}, + {0, 0, 255, 6, 4}, + {255, 255, 255, 6, 3}, + {0, 0, 0, 6, 0}, + } + + for _, tt := range tests { + got := xterm256color(tt.r, tt.g, tt.b, tt.n) + if got != tt.want { + t.Errorf("xterm256color(%v, %v, %v, %v) = %v; want %v", tt.r, tt.g, tt.b, tt.n, got, tt.want) + } + } +} + +func TestResizeImage(t *testing.T) { + img := image.NewRGBA(image.Rect(0, 0, 100, 100)) + newImg := resizeImage(img, 50, 50) + + if newImg.Bounds().Dx() != 50 || newImg.Bounds().Dy() != 50 { + t.Errorf("resizeImage: expected dimensions (50, 50), got (%d, %d)", newImg.Bounds().Dx(), newImg.Bounds().Dy()) + } + + newImg = resizeImage(img, 0, 50) + if newImg.Bounds().Dx() != 50 || newImg.Bounds().Dy() != 50 { + t.Errorf("resizeImage: expected dimensions (50, 50), got (%d, %d)", newImg.Bounds().Dx(), newImg.Bounds().Dy()) + } + + newImg = resizeImage(img, 50, 0) + if newImg.Bounds().Dx() != 50 || newImg.Bounds().Dy() != 50 { + t.Errorf("resizeImage: expected dimensions (50, 50), got (%d, %d)", newImg.Bounds().Dx(), newImg.Bounds().Dy()) + } +} + +func BenchmarkXterm256Color(b *testing.B) { + for i := 0; i < b.N; i++ { + xterm256color(255, 0, 0, 6) + } +} + +func BenchmarkResizeImage(b *testing.B) { + img := image.NewRGBA(image.Rect(0, 0, 1000, 1000)) + for i := 0; i < b.N; i++ { + resizeImage(img, 500, 500) + } +}