Strix/pkg/tester/worker.go
eduard256 0fb7356a5e Add ONVIF stream handler for tester
- Add testOnvif(): resolves all profiles via ONVIF client, tests
  each RTSP stream, returns two Results per profile (onvif + rtsp)
  with shared screenshot
- Route onvif:// URLs in worker.go alongside homekit://
- Classify onvif:// streams as recommended in test.html
- Harden create.html against undefined/null URL values
2026-04-08 11:00:32 +00:00

204 lines
3.5 KiB
Go

package tester
import (
"bytes"
"fmt"
"os/exec"
"strings"
"time"
"github.com/AlexxIT/go2rtc/pkg/core"
"github.com/AlexxIT/go2rtc/pkg/magic"
)
const workers = 20
func RunWorkers(s *Session, urls []string) {
ch := make(chan string, len(urls))
for _, u := range urls {
ch <- u
}
close(ch)
done := make(chan struct{})
n := workers
if len(urls) < n {
n = len(urls)
}
for i := 0; i < n; i++ {
go func() {
for rawURL := range ch {
select {
case <-s.Cancelled():
return
default:
}
testURL(s, rawURL)
}
done <- struct{}{}
}()
}
for i := 0; i < n; i++ {
<-done
}
s.Done()
}
func testURL(s *Session, rawURL string) {
defer s.AddTested()
if strings.HasPrefix(rawURL, "homekit://") {
testHomeKit(s, rawURL)
return
}
if strings.HasPrefix(rawURL, "onvif://") {
testOnvif(s, rawURL)
return
}
handler := GetHandler(rawURL)
if handler == nil {
return
}
start := time.Now()
prod, err := handler(rawURL)
if err != nil {
return
}
defer func() { _ = prod.Stop() }()
latency := time.Since(start).Milliseconds()
var codecs []string
for _, media := range prod.GetMedias() {
if media.Direction != core.DirectionRecvonly {
continue
}
for _, codec := range media.Codecs {
codecs = append(codecs, codec.Name)
}
}
r := &Result{
Source: rawURL,
Codecs: codecs,
LatencyMs: latency,
}
if raw, codecName := getScreenshot(prod); raw != nil {
var jpeg []byte
switch codecName {
case core.CodecH264, core.CodecH265:
jpeg = toJPEG(raw)
case core.CodecJPEG:
jpeg = raw
default:
jpeg = raw
}
if jpeg != nil {
idx := s.AddScreenshot(jpeg)
r.Screenshot = fmt.Sprintf("api/test/screenshot?id=%s&i=%d", s.ID, idx)
r.Width, r.Height = jpegSize(jpeg)
}
}
s.AddResult(r)
}
// getScreenshot connects Keyframe consumer to producer, waits for first keyframe with 10s timeout
func getScreenshot(prod core.Producer) ([]byte, string) {
cons := magic.NewKeyframe()
for _, prodMedia := range prod.GetMedias() {
if prodMedia.Kind != core.KindVideo || prodMedia.Direction != core.DirectionRecvonly {
continue
}
for _, consMedia := range cons.GetMedias() {
prodCodec, consCodec := prodMedia.MatchMedia(consMedia)
if prodCodec == nil {
continue
}
track, err := prod.GetTrack(prodMedia, prodCodec)
if err != nil {
continue
}
if err = cons.AddTrack(consMedia, consCodec, track); err != nil {
continue
}
goto matched
}
}
return nil, ""
matched:
go func() {
_ = prod.Start()
}()
once := &core.OnceBuffer{}
done := make(chan struct{})
go func() {
_, _ = cons.WriteTo(once)
close(done)
}()
select {
case <-done:
case <-time.After(10 * time.Second):
_ = prod.Stop()
return nil, ""
}
return once.Buffer(), cons.CodecName()
}
// jpegSize extracts width and height from JPEG SOF0/SOF2 marker
func jpegSize(data []byte) (int, int) {
for i := 2; i < len(data)-9; {
if data[i] != 0xFF {
return 0, 0
}
marker := data[i+1]
size := int(data[i+2])<<8 | int(data[i+3])
// SOF0 (0xC0) or SOF2 (0xC2) -- baseline or progressive
if marker == 0xC0 || marker == 0xC2 {
h := int(data[i+5])<<8 | int(data[i+6])
w := int(data[i+7])<<8 | int(data[i+8])
return w, h
}
i += 2 + size
}
return 0, 0
}
func toJPEG(raw []byte) []byte {
cmd := exec.Command("ffmpeg",
"-hide_banner", "-loglevel", "error",
"-i", "-",
"-frames:v", "1",
"-f", "image2", "-c:v", "mjpeg",
"-",
)
cmd.Stdin = bytes.NewReader(raw)
out, err := cmd.Output()
if err != nil {
return nil
}
return out
}