feat: use MJPEG for live capture

This commit is contained in:
Simon Vieille 2025-12-11 20:55:59 +01:00
commit 408c9c6d89
Signed by: deblan
GPG key ID: 579388D585F70417
9 changed files with 112 additions and 151 deletions

View file

@ -102,7 +102,5 @@ remote:
label: Volume
- label: Desktop
items:
- type: screenshot
label: Screenshot
- type: live_video
label: Live video
- type: capture
label: Capture

67
live_controller.go Normal file
View file

@ -0,0 +1,67 @@
package main
import (
"bytes"
"image/color"
"image/jpeg"
"math"
"net/http"
"time"
"github.com/kbinani/screenshot"
"github.com/labstack/echo/v4"
)
func captureController(c echo.Context) error {
bounds := screenshot.GetDisplayBounds(0)
switch c.QueryParam("type") {
case "screenshot":
if img, err := screenshot.CaptureRect(bounds); err == nil {
var buf bytes.Buffer
jpeg.Encode(&buf, img, nil)
c.Response().Header().Set("Content-Type", "image/jpeg")
return c.Blob(http.StatusOK, "image/jpeg", buf.Bytes())
}
default:
c.Response().Header().Set("Content-Type", "multipart/x-mixed-replace; boundary=frame")
for {
if img, err := screenshot.CaptureRect(bounds); err == nil {
var buf bytes.Buffer
if c.QueryParam("pointer") == "1" {
currentX, currentY := GetPointerPosition()
pointerSize := 2 * 16.0
pixelColor := color.RGBA{
R: 255,
G: 0,
B: 0,
A: 255,
}
for x := math.Max(0.0, currentX-pointerSize/2); x <= currentX+3; x++ {
for y := math.Max(0.0, currentY-pointerSize/2); y < currentY+3; y++ {
img.SetRGBA(int(x), int(y), pixelColor)
}
}
}
jpeg.Encode(&buf, img, nil)
_, _ = c.Response().Write([]byte("--frame\r\n"))
c.Response().Write([]byte("Content-Type: image/jpeg\r\n\r\n"))
c.Response().Write(buf.Bytes())
c.Response().Write([]byte("\r\n"))
time.Sleep(33 * time.Millisecond)
}
}
}
return nil
}

View file

@ -63,6 +63,7 @@ func main() {
e.GET("/manifest.webmanifest", manifestController)
e.GET("/", homeController)
e.GET("/ws", wsController)
e.GET("/capture", captureController)
if config.Server.Tls.Enable == false {
e.Logger.Fatal(e.Start(config.Server.Listen))

BIN
remote-i3wm-go Executable file

Binary file not shown.

File diff suppressed because one or more lines are too long

View file

@ -157,7 +157,7 @@ a {
display: none;
}
#screenshot img {
.capture-img img {
max-width: 100%;
margin-top: 10px;
cursor: pointer;

View file

@ -7,6 +7,7 @@ let wsLock = false
let isPointerLive = false
let isScreenshotWaiting = null
let isPointerScreenshotWaiting = false
let emptyImg = "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAIAAACQd1PeAAAACXBIWXMAAAsTAAALEwEAmpwYAAAAB3RJTUUH4gIJDjc3srQk8gAAAB1pVFh0Q29tbWVudAAAAAAAQ3JlYXRlZCB3aXRoIEdJTVBkLmUHAAAADElEQVQI12P48+cPAAXsAvVTWDc6AAAAAElFTkSuQmCC"
function createWebSocketConnection() {
const protocol = location.protocol === 'https:' ? 'wss' : 'ws'
@ -24,7 +25,7 @@ function createWebSocketConnection() {
window.setTimeout(createWebSocketConnection, 5000)
})
ws.addEventListener('message', function(event) {
unLock()
let data = JSON.parse(event.data)
@ -47,27 +48,6 @@ function createWebSocketConnection() {
return
}
if (data.type === 'screenshot') {
if (isScreenshotWaiting || isScreenshotWaiting === null) {
if (isScreenshotWaiting) {
isScreenshotWaiting = false
}
screenshotImg.setAttribute('src', 'data:image/png;base64, ' + data.value)
}
let pointer = document.querySelector('#pointer')
if (isPointerScreenshotWaiting) {
pointer.style.backgroundImage = `url('data:image/png;base64, ${data.value}')`
isPointerScreenshotWaiting = false
} else {
pointer.style.backgroundImage = 'none'
}
}
return
})
}
@ -223,26 +203,10 @@ function pointerTouchStartHandler(e) {
function pointerLiveHandler(e) {
if (!e.target.checked) {
isPointerLive = false
isPointerScreenshotWaiting = null
return
pointer.style.backgroundImage = ""
} else {
pointer.style.backgroundImage = `url("/capture?type=live&pointer=1&${Math.random()}")`
}
isPointerLive = true
let doScreenshot = function() {
if (isPointerLive) {
if (!isPointerScreenshotWaiting) {
isPointerScreenshotWaiting = true
ws.send(`{"type":"screenshot","quality":"lq","pointer":true}`)
}
window.setTimeout(doScreenshot, 300)
}
}
doScreenshot()
}
function pointerTouchMoveHandler(e) {
@ -265,46 +229,30 @@ function pointerTouchMoveHandler(e) {
ws.send(msg)
}
function liveHqClickHandler(e) {
return liveClickHandler(e, 'hq')
function capture(mode) {
}
function liveLqClickHandler(e) {
return liveClickHandler(e, 'lq')
function captureScreenshotClickHandler(e) {
const img = e.target.parentNode.querySelector('.capture-img img')
img.src = "/capture?type=screenshot&" + Math.random()
}
function liveClickHandler(e, quality) {
if (isLive) {
isLive = false
isScreenshotWaiting = null
function captureLiveClickHandler(e) {
const img = e.target.parentNode.querySelector('.capture-img img')
document.querySelector('#live-hq').innerText = 'Live HQ'
document.querySelector('#live-lq').innerText = 'Live LQ'
return
if (img.src.indexOf("live") > -1) {
img.src = emptyImg
} else {
img.src = "/capture?type=live&" + Math.random()
}
isLive = true
e.target.innerText = 'Stop live'
let doScreenshot = function() {
if (isLive) {
if (!isScreenshotWaiting) {
isScreenshotWaiting = true
ws.send(`{"type":"screenshot","quality":"${quality}"}`)
}
window.setTimeout(doScreenshot, 100)
}
}
doScreenshot()
}
function fullscreenHandler(e) {
let element = document.querySelector(e.target.getAttribute('data-target'))
let isFullscreen = parseInt(e.target.getAttribute('data-fullscreen'))
const targetConf = e.target.getAttribute('data-target')
const isFullscreen = parseInt(e.target.getAttribute('data-fullscreen'))
const element = (targetConf === 'this')
? e.target
: document.querySelector(targetConf)
document.querySelector('body').classList.toggle('fullscreen', isFullscreen)
@ -372,8 +320,9 @@ function addListeners() {
addEventListenerOn(pointer, 'touchmove', pointerTouchMoveHandler)
addEventListenerOn('#mouse-screenshot-live input', 'change', pointerLiveHandler)
addEventListenerOn('#live-hq', 'click', liveHqClickHandler)
addEventListenerOn('#live-lq', 'click', liveLqClickHandler)
addEventListenerOn('.capture-live', 'click', captureLiveClickHandler)
addEventListenerOn('.capture-screenshot', 'click', captureScreenshotClickHandler)
addEventListenerOn('.btn-fullscreen', 'click', fullscreenHandler)
}

View file

@ -124,18 +124,17 @@
</div>
{{end}}
{{if eq $value.Type "screenshot"}}
{{if eq $value.Type "capture"}}
<div class="col-12">
<button type="button" data-msg='{"type":"screenshot","quality":"hq"}' class="btn btn-secondary">Screenshot HQ</button>
<button type="button" data-msg='{"type":"screenshot","quality":"lq"}' class="btn btn-secondary">Screenshot LQ</button>
</div>
{{end}}
{{if eq $value.Type "live_video"}}
<div class="col-12">
<button type="button" id="live-hq" class="btn btn-secondary">Live HQ</button>
<button type="button" id="live-lq" class="btn btn-secondary">Live LQ</button>
<div id="screenshot"><img class="btn-fullscreen" data-target="#screenshot img" src="data:image/png; base64, iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAIAAACQd1PeAAAACXBIWXMAAAsTAAALEwEAmpwYAAAAB3RJTUUH4gIJDjc3srQk8gAAAB1pVFh0Q29tbWVudAAAAAAAQ3JlYXRlZCB3aXRoIEdJTVBkLmUHAAAADElEQVQI12P48+cPAAXsAvVTWDc6AAAAAElFTkSuQmCC"></div>
<button type="button" class="btn btn-secondary capture-screenshot">
Screenshot
</button>
<button type="button" class="btn btn-secondary capture-live">
Live
</button>
<div class="capture-img">
<img src="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAIAAACQd1PeAAAACXBIWXMAAAsTAAALEwEAmpwYAAAAB3RJTUUH4gIJDjc3srQk8gAAAB1pVFh0Q29tbWVudAAAAAAAQ3JlYXRlZCB3aXRoIEdJTVBkLmUHAAAADElEQVQI12P48+cPAAXsAvVTWDc6AAAAAElFTkSuQmCC" class="btn-fullscreen" data-target="this">
</div>
</div>
{{end}}

View file

@ -1,13 +1,9 @@
package main
import (
"bytes"
"encoding/json"
"errors"
"fmt"
"image/color"
"image/jpeg"
"math"
"os/exec"
"regexp"
"strconv"
@ -15,7 +11,6 @@ import (
"time"
"github.com/gorilla/websocket"
"github.com/kbinani/screenshot"
"github.com/labstack/echo/v4"
)
@ -61,7 +56,7 @@ func sendMessageResponse(ws *websocket.Conn, r MessageResponse) {
ws.WriteMessage(websocket.TextMessage, value)
}
func getPointerPosition() (float64, float64) {
func GetPointerPosition() (float64, float64) {
location := exec.Command("xdotool", "getmouselocation")
output, _ := location.Output()
position := string(output)
@ -106,7 +101,7 @@ func createActions() Actions {
return cmd.Run()
}
currentX, currentY := getPointerPosition()
currentX, currentY := GetPointerPosition()
newX, _ := strconv.ParseFloat(data.X, 32)
newY, _ := strconv.ParseFloat(data.Y, 32)
@ -335,54 +330,6 @@ func createActions() Actions {
return cmd.Run()
})
actions.add("screenshot", func(ws *websocket.Conn, msg []byte) error {
data := ScreenshotMessageData{}
json.Unmarshal([]byte(msg), &data)
bounds := screenshot.GetDisplayBounds(0)
img, err := screenshot.CaptureRect(bounds)
if err != nil {
return errors.New("Capture error")
}
var quality int
if data.Quality == "lq" {
quality = 10
} else {
quality = 90
}
if data.Pointer {
currentX, currentY := getPointerPosition()
pointerSize := 2 * 16.0
pixelColor := color.RGBA{
R: 255,
G: 0,
B: 0,
A: 255,
}
for x := math.Max(0.0, currentX-pointerSize/2); x <= currentX+3; x++ {
for y := math.Max(0.0, currentY-pointerSize/2); y < currentY+3; y++ {
img.SetRGBA(int(x), int(y), pixelColor)
}
}
}
buff := new(bytes.Buffer)
jpeg.Encode(buff, img, &jpeg.Options{Quality: quality})
sendMessageResponse(ws, MessageResponse{
Type: "screenshot",
Value: toBase64(buff.Bytes()),
})
return nil
})
actions.add("messages", func(ws *websocket.Conn, msg []byte) error {
data := MessagesData{}
json.Unmarshal([]byte(msg), &data)