Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Screencast utility functions and examples #1152

Open
wants to merge 11 commits into
base: main
Choose a base branch
from
119 changes: 119 additions & 0 deletions dev_helpers.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,18 +5,21 @@
package rod

import (
"context"
"encoding/json"
"fmt"
"html"
"net"
"net/http"
"strings"
"sync"
"time"

"github.com/go-rod/rod/lib/assets"
"github.com/go-rod/rod/lib/js"
"github.com/go-rod/rod/lib/proto"
"github.com/go-rod/rod/lib/utils"
"github.com/ysmood/gson"
)

// TraceType for logger.
Expand Down Expand Up @@ -44,6 +47,14 @@ const (
TraceTypeInput TraceType = "input"
)

/* cspell:ignore screencast, screencasts, screencasting, mjpeg */

type screencastPage struct {
frames chan []byte
stop func()
once sync.Once
}

// ServeMonitor starts the monitor server.
// The reason why not to use "chrome://inspect/#devices" is one target cannot be driven by multiple controllers.
func (b *Browser) ServeMonitor(host string) string {
Expand All @@ -53,6 +64,9 @@ func (b *Browser) ServeMonitor(host string) string {
utils.E(closeSvr())
}()

activeScreencasts := make(map[proto.TargetTargetID]*screencastPage)
screencastMux := sync.RWMutex{}

mux.HandleFunc("/", func(w http.ResponseWriter, _ *http.Request) {
httHTML(w, assets.Monitor)
})
Expand Down Expand Up @@ -88,10 +102,111 @@ func (b *Browser) ServeMonitor(host string) string {
w.Header().Add("Content-Type", "image/png;")
utils.E(w.Write(p.MustScreenshot())) //nolint: contextcheck
})
mux.HandleFunc("/screencast/", func(w http.ResponseWriter, r *http.Request) {
b.handleScreencast(w, r, activeScreencasts, &screencastMux)
})

return u
}

// handleScreencast handles the ServeMonitor screencasting functionality for a target.
func (b *Browser) handleScreencast(
w http.ResponseWriter,
r *http.Request,
activeScreencasts map[proto.TargetTargetID]*screencastPage,
screencastMux *sync.RWMutex,
) {
id := r.URL.Path[strings.LastIndex(r.URL.Path, "/")+1:]
target := proto.TargetTargetID(id)

// Set headers for MJPEG stream
w.Header().Set("Content-Type", "multipart/x-mixed-replace; boundary=frame")
w.Header().Set("Cache-Control", "no-cache")
w.Header().Set("Connection", "keep-alive")
w.Header().Set("Pragma", "no-cache")

screencastMux.RLock()
sc, exists := activeScreencasts[target]
screencastMux.RUnlock()

if !exists {
p := b.MustPageFromTargetID(target)
frames := make(chan []byte, 100)

stopCtx, cancelFunc := context.WithCancel(r.Context())

sc = &screencastPage{
frames: frames,
once: sync.Once{},
stop: func() {
cancelFunc()
},
}

screencastMux.Lock()
activeScreencasts[target] = sc
screencastMux.Unlock()

go func() {
defer sc.once.Do(func() {
p.MustStopScreencast()
close(sc.frames)
screencastMux.Lock()
delete(activeScreencasts, target)
screencastMux.Unlock()
})

opts := &ScreencastOptions{
Format: "jpeg",
Quality: gson.Int(75),
EveryNthFrame: gson.Int(1),
BufferSize: 100,
}

frames, err := p.StartScreencast(opts)
if err != nil {
return
}

for {
select {
case <-stopCtx.Done():
return
case frame, ok := <-frames:
if !ok {
return
}
select {
case sc.frames <- frame:
default:
// Skip frame if buffer is full
}
}
}
}()
}

flusher, ok := w.(http.Flusher)
if !ok {
http.Error(w, "Streaming unsupported!", http.StatusInternalServerError)
return
}

for {
select {
case <-r.Context().Done():
sc.stop()
return
case frame, ok := <-sc.frames:
if !ok {
return
}

utils.MustWriteMJPEGFrame(w, frame, flusher)
}
}
}

// check method and sleep if needed.
func (b *Browser) trySlowMotion() {
if b.slowMotion == 0 {
Expand Down Expand Up @@ -245,6 +360,10 @@ func serve(host string) (string, *http.ServeMux, func() error) {
srv := &http.Server{Handler: http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
defer func() {
if err := recover(); err != nil {
/* cspell: disable-next-line */
if nerr, ok := err.(*net.OpError); ok && strings.Contains(nerr.Err.Error(), "broken pipe") {
return
}
w.WriteHeader(http.StatusBadRequest)
utils.E(json.NewEncoder(w).Encode(err))
}
Expand Down
5 changes: 5 additions & 0 deletions dev_helpers_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,8 @@ import (
"github.com/ysmood/gson"
)

/* cspell:ignore screencast */

func TestMonitor(t *testing.T) {
g := setup(t)

Expand All @@ -33,6 +35,9 @@ func TestMonitor(t *testing.T) {
img := g.Req("", host+"/screenshot").Bytes()
g.Gt(img.Len(), 10)

screencast := g.Req("", host+"/screencast").Bytes()
g.Gt(screencast.Len(), 10)

res := g.Req("", host+"/api/page/test")
g.Eq(400, res.StatusCode)
g.Eq(-32602, gson.New(res.Body).Get("code").Int())
Expand Down
2 changes: 2 additions & 0 deletions go.work
Original file line number Diff line number Diff line change
Expand Up @@ -4,5 +4,7 @@ use (
.
./lib/examples/custom-websocket
./lib/examples/e2e-testing
./lib/examples/screencast
./lib/examples/recording
./lib/utils/check-issue
)
72 changes: 65 additions & 7 deletions lib/assets/assets.go
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,8 @@ const Monitor = `<html>
</html>
`

/* cspell:ignore screencast */

// MonitorPage for rod.
const MonitorPage = `<html>
<head>
Expand Down Expand Up @@ -123,6 +125,25 @@ const MonitorPage = `<html>
.rate {
flex: 1;
}
.controls {
display: flex;
gap: 10px;
padding: 5px;
}
.mode-switch {
background: #4f475a;
color: white;
border: none;
border-radius: 3px;
padding: 5px 10px;
cursor: pointer;
}
.mode-switch:hover {
background: #635b6f;
}
.mode-switch.active {
background: #8d8396;
}
</style>
</head>
<body>
Expand All @@ -143,6 +164,10 @@ const MonitorPage = `<html>
title="refresh rate (second)"
/>
</div>
<div class="controls">
<button class="mode-switch" data-mode="screenshot">Screenshot Mode</button>
<button class="mode-switch" data-mode="screencast">Screencast Mode</button>
</div>
<pre class="error"></pre>
<img class="screen" />
</body>
Expand All @@ -153,15 +178,19 @@ const MonitorPage = `<html>
const elUrl = document.querySelector('.url')
const elRate = document.querySelector('.rate')
const elErr = document.querySelector('.error')

const modeButtons = document.querySelectorAll('.mode-switch')

let currentMode = 'screenshot'
document.title = ` + "`" + `Rod Monitor - ${id}` + "`" + `

async function update() {
async function updateInfo() {
const res = await fetch(` + "`" + `/api/page/${id}` + "`" + `)
const info = await res.json()
elTitle.value = info.title
elUrl.value = info.url
}

async function updateScreenshot() {
await new Promise((resolve, reject) => {
const now = new Date()
elImg.src = ` + "`" + `/screenshot/${id}?t=${now.getTime()}` + "`" + `
Expand All @@ -171,19 +200,48 @@ const MonitorPage = `<html>
})
}

function startScreencast() {
elImg.src = ` + "`" + `/screencast/${id}` + "`" + `
elImg.style.maxWidth = innerWidth + 'px'
}

async function mainLoop() {
try {
await update()
elErr.attributeStyleMap.delete('display')
await updateInfo()

if (currentMode === 'screenshot') {
await updateScreenshot()
}

elErr.style.display = 'none'
} catch (err) {
elErr.style.display = 'block'
elErr.textContent = err + ''
}

setTimeout(mainLoop, parseFloat(elRate.value) * 1000)
if (currentMode === 'screenshot') {
setTimeout(mainLoop, parseFloat(elRate.value) * 1000)
}
}

modeButtons.forEach(btn => {
btn.addEventListener('click', () => {
const mode = btn.dataset.mode
if (mode === currentMode) return

currentMode = mode
modeButtons.forEach(b => b.classList.toggle('active', b.dataset.mode === mode))

if (mode === 'screenshot') {
mainLoop()
} else {
startScreencast()
}
})
})

// Start with screenshot mode
modeButtons[0].classList.add('active')
mainLoop()
</script>
</html>
`
</html>`
5 changes: 4 additions & 1 deletion lib/assets/generate/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ package main

import (
"path/filepath"
"strings"

"github.com/go-rod/rod/lib/utils"
)
Expand All @@ -19,12 +20,14 @@ const MousePointer = {{.mousePointer}}
// Monitor for rod
const Monitor = {{.monitor}}

/* cspell:ignore screencast */

// MonitorPage for rod
const MonitorPage = {{.monitorPage}}
`,
"mousePointer", get("../../fixtures/mouse-pointer.svg"),
"monitor", get("monitor.html"),
"monitorPage", get("monitor-page.html"),
"monitorPage", "`"+strings.SplitN(get("monitor-page.html"), "\n", 2)[1], // Stripping the cspell comment
)

utils.E(utils.OutputFile(slash("lib/assets/assets.go"), build))
Expand Down
Loading