diff --git a/README.md b/README.md index 0b93c07..0ae00ab 100644 --- a/README.md +++ b/README.md @@ -80,3 +80,9 @@ Usage: cam2ip [] * `/html`: HTML handler, frames are pushed to canvas over websocket * `/jpeg`: Static JPEG handler * `/mjpeg`: Motion JPEG, supported natively in major web browsers + +### camview + +`cmd/camview` is a small native window that shows the local camera live, using [iv](https://github.com/gen2brain/iv) for display. + + go install github.com/gen2brain/cam2ip/cmd/camview@latest diff --git a/cmd/camview/go.mod b/cmd/camview/go.mod new file mode 100644 index 0000000..13a6ab7 --- /dev/null +++ b/cmd/camview/go.mod @@ -0,0 +1,27 @@ +module github.com/gen2brain/cam2ip/cmd/camview + +go 1.26 + +require ( + github.com/gen2brain/cam2ip v1.7.1-0.20260704073444-57c82eb3a02e + github.com/gen2brain/iv v0.3.0 +) + +require ( + codeberg.org/tesselslate/wl v0.3.24 // indirect + codeberg.org/tesselslate/wl-protocols v0.3.47 // indirect + github.com/NeowayLabs/drm v0.0.0-20190824133025-4939fc0ad345 // indirect + github.com/dblohm7/wingoes v0.0.0-20260526185140-fb298caac7ca // indirect + github.com/ebitengine/purego v0.10.1 // indirect + github.com/gen2brain/base64 v0.2.0 // indirect + github.com/gen2brain/jpegn v0.4.2 // indirect + github.com/gen2brain/shm v0.2.2 // indirect + github.com/holoplot/go-evdev v0.0.0-20260504100651-66d1748fe847 // indirect + github.com/jezek/xgb v1.3.1 // indirect + github.com/korandiz/v4l v1.1.0 // indirect + github.com/pbnjay/pixfont v0.0.0-20200714042608-33b744692567 // indirect + github.com/pixiv/go-libjpeg v0.0.0-20190822045933-3da21a74767d // indirect + github.com/tailscale/win v0.0.0-20260619195133-2d76c33a64c1 // indirect + golang.org/x/exp v0.0.0-20260611194520-c48552f49976 // indirect + golang.org/x/sys v0.46.0 // indirect +) diff --git a/cmd/camview/go.sum b/cmd/camview/go.sum new file mode 100644 index 0000000..202e85c --- /dev/null +++ b/cmd/camview/go.sum @@ -0,0 +1,42 @@ +codeberg.org/tesselslate/wl v0.3.24 h1:49Gb1vt2Qp5Wl3KudHmPe2Cq1zg2WR8YPtTnHhdodBE= +codeberg.org/tesselslate/wl v0.3.24/go.mod h1:9eLDoCveYvMpKcUIqig1ctVKrQm5f8aoCHlgDWvyf/U= +codeberg.org/tesselslate/wl-protocols v0.3.47 h1:TTIPtEsCoWDh2qD5Z4n9VCBc0unNDGVClmk9+VLoBAw= +codeberg.org/tesselslate/wl-protocols v0.3.47/go.mod h1:Dtk2EZR1jSFZIWZOSVuaZeoQJpo9qJon1D59lt7DYPE= +github.com/NeowayLabs/drm v0.0.0-20190824133025-4939fc0ad345 h1:gsz1u5J8hnoPtBaA5dUdluC4lZNYgYRZmEchdxTuNxc= +github.com/NeowayLabs/drm v0.0.0-20190824133025-4939fc0ad345/go.mod h1:B8Pf8lJWIsLTs7oyWkXOWOGAZiQQVomlZurKAgx8HBA= +github.com/dblohm7/wingoes v0.0.0-20260526185140-fb298caac7ca h1:h1Awca4lQOspNR/2eeo04Ricn5NixDX9mb17WSAgLhQ= +github.com/dblohm7/wingoes v0.0.0-20260526185140-fb298caac7ca/go.mod h1:2TGl1jRJrRpbzykmg7asHm3h08TqutUgQqY5v9k/g3c= +github.com/ebitengine/purego v0.10.1 h1:dewVBCBT2GaMu1SrNTYxQhgQBethzfhiwvZiLGP/qyY= +github.com/ebitengine/purego v0.10.1/go.mod h1:iIjxzd6CiRiOG0UyXP+V1+jWqUXVjPKLAI0mRfJZTmQ= +github.com/gen2brain/base64 v0.2.0 h1:tIkh2/g6oiDKGfLfsbFEXmSeb5lxyTGbaFV2rQwpcaw= +github.com/gen2brain/base64 v0.2.0/go.mod h1:tiBvPzg3qFDIggMPy0TEedCJVCeNOhDYeJMErNqvZNk= +github.com/gen2brain/cam2ip v1.7.1-0.20260704073444-57c82eb3a02e h1:MSd4ptIXGkD6JfFRC4WZ0ULo4CIxMnuH6uv1TZP+RnM= +github.com/gen2brain/cam2ip v1.7.1-0.20260704073444-57c82eb3a02e/go.mod h1:cjpZVlBsNVzYEGkgtrEoJQFT3mi9ND1ZQ9h3VfGp56Y= +github.com/gen2brain/iv v0.3.0 h1:c8N+mWOqdf354PbEKS/EFm8SRkUw3y1Wacin1x5t+OE= +github.com/gen2brain/iv v0.3.0/go.mod h1:LVyZXPg3Jpu/sAAeLHZjOOuo9lzOjo2+gDPz0pueFDk= +github.com/gen2brain/jpegn v0.4.2 h1:sxy2yolV1eNA02uYtnqBFm4EIC3ETnars98aG7Dc4LM= +github.com/gen2brain/jpegn v0.4.2/go.mod h1:YvcVOmVPSAsefH6yn9HBW3uY0EHlZwCMoiJXoAWfgL0= +github.com/gen2brain/shm v0.2.2 h1:CmyCEXB392Mva9kdpec09CAhVZJIx9YhA/CFoWke0iE= +github.com/gen2brain/shm v0.2.2/go.mod h1:UgIcVtvmOu+aCJpqJX7GOtiN7X2ct+TKLg4RTxwPIUA= +github.com/holoplot/go-evdev v0.0.0-20260504100651-66d1748fe847 h1:1rQ5UQXFm02DXEtsIpotfA32WJ9KceS6t8w5K8QtFqc= +github.com/holoplot/go-evdev v0.0.0-20260504100651-66d1748fe847/go.mod h1:iHAf8OIncO2gcQ8XOjS7CMJ2aPbX2Bs0wl5pZyanEqk= +github.com/jezek/xgb v1.3.1 h1:NQCAEfQyzN+3RjWUSHBuVIxQcy2YfG3/mNvKfs/0rEg= +github.com/jezek/xgb v1.3.1/go.mod h1:nrhwO0FX/enq75I7Y7G8iN1ubpSGZEiA3v9e9GyRFlk= +github.com/korandiz/v4l v1.1.0 h1:VbzaWlhqNzVPfHEYEM+V8T7184ndiEzljJgDHSHc7pc= +github.com/korandiz/v4l v1.1.0/go.mod h1:pftxPG7hkuUgepioAY6PAE81mShaVjzd95X/WF4Izus= +github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646 h1:zYyBkD/k9seD2A7fsi6Oo2LfFZAehjjQMERAvZLEDnQ= +github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646/go.mod h1:jpp1/29i3P1S/RLdc7JQKbRpFeM1dOBd8T9ki5s+AY8= +github.com/pbnjay/pixfont v0.0.0-20200714042608-33b744692567 h1:pKjmNHL7BCXhgsnSlN6Ov3WAN2jbJMCx6IvrMN9GNfc= +github.com/pbnjay/pixfont v0.0.0-20200714042608-33b744692567/go.mod h1:ytYavTmrpWG4s7UOfDhP6m4ASL5XA66nrOcUn1e2M78= +github.com/pixiv/go-libjpeg v0.0.0-20190822045933-3da21a74767d h1:ls+7AYarUlUSetfnN/DKVNcK6W8mQWc6VblmOm4XwX0= +github.com/pixiv/go-libjpeg v0.0.0-20190822045933-3da21a74767d/go.mod h1:DO7ixpslN6XfbWzeNH9vkS5CF2FQUX81B85rYe9zDxU= +github.com/tailscale/win v0.0.0-20260619195133-2d76c33a64c1 h1:QzOW7bZOlg5/hi1W6dStSN9qazTHkHj5kO7sTA5aymY= +github.com/tailscale/win v0.0.0-20260619195133-2d76c33a64c1/go.mod h1:vwG7VvIPzIYNJd0Nio6PZW7yr8KNNFEc8EVQ+J/B4bk= +github.com/tc-hib/winres v0.2.1 h1:YDE0FiP0VmtRaDn7+aaChp1KiF4owBiJa5l964l5ujA= +github.com/tc-hib/winres v0.2.1/go.mod h1:C/JaNhH3KBvhNKVbvdlDWkbMDO9H4fKKDaN7/07SSuk= +golang.org/x/exp v0.0.0-20260611194520-c48552f49976 h1:X8Hz2ImujgbmetVuW+w2YkyZChE3cBpZi2P158rTG9M= +golang.org/x/exp v0.0.0-20260611194520-c48552f49976/go.mod h1:vnf4pv9iKZXY58sQE1L86zmNWJ4159e1RkcWiLCkeEY= +golang.org/x/image v0.12.0 h1:w13vZbU4o5rKOFFR8y7M+c4A5jXDC0uXTdHYRP8X2DQ= +golang.org/x/image v0.12.0/go.mod h1:Lu90jvHG7GfemOIcldsh9A2hS01ocl6oNO7ype5mEnk= +golang.org/x/sys v0.46.0 h1:noSf2Fq6F8DBgS+LysIkx7rIExoNHJsxOAtPp4rthXw= +golang.org/x/sys v0.46.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= diff --git a/cmd/camview/icon.png b/cmd/camview/icon.png new file mode 100644 index 0000000..c7006f3 Binary files /dev/null and b/cmd/camview/icon.png differ diff --git a/cmd/camview/main.go b/cmd/camview/main.go new file mode 100644 index 0000000..c1e2bf5 --- /dev/null +++ b/cmd/camview/main.go @@ -0,0 +1,397 @@ +package main + +import ( + "bytes" + "context" + _ "embed" + "flag" + "fmt" + "image" + "image/png" + "os" + "runtime" + "runtime/debug" + "strings" + "sync" + "time" + + "github.com/gen2brain/iv" + + "github.com/gen2brain/cam2ip/camera" +) + +const name = "camview" + +//go:embed icon.png +var iconPNG []byte + +var version string + +func init() { + if version != "" { + return + } + + buildInfo, ok := debug.ReadBuildInfo() + if !ok { + return + } + + if buildInfo.Main.Version != "" { + version = buildInfo.Main.Version + } + + for _, kv := range buildInfo.Settings { + if kv.Key == "vcs.revision" && kv.Value != "" { + version = kv.Value + if len(version) > 7 { + version = version[:7] + } + } + } +} + +func main() { + runtime.LockOSThread() + + var ( + index int + device string + listDevices bool + width float64 + height float64 + rotate int + flip string + timestamp bool + timeFormat string + fps int + fullscreen bool + showVersion bool + ) + + flag.IntVar(&index, "index", 0, "Camera index") + flag.StringVar(&device, "device", "", "Camera name to use, matched as substring, overrides index") + flag.BoolVar(&listDevices, "list-devices", false, "List available cameras and exit") + flag.Float64Var(&width, "width", 640, "Frame width") + flag.Float64Var(&height, "height", 480, "Frame height") + flag.IntVar(&rotate, "rotate", 0, "Rotate image, valid values are 90, 180, 270") + flag.StringVar(&flip, "flip", "", "Flip image, valid values are horizontal and vertical") + flag.BoolVar(×tamp, "timestamp", false, "Draws timestamp on image") + flag.StringVar(&timeFormat, "time-format", "2006-01-02 15:04:05", "Time format") + flag.IntVar(&fps, "fps", 30, "Maximum frames per second to display") + flag.BoolVar(&fullscreen, "fullscreen", false, "Start in fullscreen") + flag.BoolVar(&showVersion, "version", false, "Print version and exit") + flag.Parse() + + if showVersion { + fmt.Printf("%s %s\n", name, version) + os.Exit(0) + } + + if listDevices { + devices, err := camera.Devices() + if err != nil { + stderr("%s\n", err.Error()) + os.Exit(1) + } + + for _, d := range devices { + if d.Path != "" { + fmt.Printf("%d: %s (%s)\n", d.Index, d.Name, d.Path) + } else { + fmt.Printf("%d: %s\n", d.Index, d.Name) + } + } + + os.Exit(0) + } + + if fps < 1 { + fps = 1 + } + + if device != "" { + idx, err := deviceIndex(device) + if err != nil { + stderr("%s\n", err.Error()) + os.Exit(1) + } + + index = idx + } + + cam, err := camera.New(camera.Options{ + Index: index, + Rotate: rotate, + Flip: flip, + Width: width, + Height: height, + Timestamp: timestamp, + TimeFormat: timeFormat, + }) + if err != nil { + stderr("%s\n", err.Error()) + os.Exit(1) + } + + banner(index, cam.Info()) + + view, err := iv.New(iv.Options{AppID: name, Width: int(width), Height: int(height)}) + if err != nil { + cam.Close() + stderr("%s\n", err.Error()) + os.Exit(1) + } + + _ = view.SetTitle(title(index)) + + if icon := appIcon(); icon != nil { + _ = view.SetIcon(icon) + } + + if fullscreen { + _ = view.ToggleFullscreen() + } + + s := &stream{} + done := make(chan struct{}) + + var closeOnce sync.Once + quit := func() { + closeOnce.Do(func() { close(done) }) + s.wake() + } + + view.SetClosedHandler(quit) + view.SetKeyPressHandler(func(key int) { + switch key { + case iv.KeyEscape, iv.KeyQ: + quit() + case iv.KeyF, iv.KeyF11: + _ = view.ToggleFullscreen() + } + }) + + captureDone := make(chan struct{}) + go func() { + defer close(captureDone) + defer cam.Close() + + capture(cam, s, fps, isTerminal(os.Stderr), done) + }() + + blank := image.NewRGBA(image.Rect(0, 0, 1, 1)) + + for { + select { + case <-done: + default: + img, ctx := s.frame() + if img == nil { + img = blank + } + + if err := view.Display(ctx, img, "", true); err != nil { + stderr("%s\n", err.Error()) + quit() + } + + continue + } + + break + } + + // Capture owns and closes the camera; bound the wait so a stall cannot hang exit. + select { + case <-captureDone: + case <-time.After(time.Second): + } + + view.Close() +} + +// stream holds the latest frame and wakes the display loop when a new one arrives. +type stream struct { + mu sync.Mutex + img image.Image + cancel context.CancelFunc +} + +// set stores a new frame and wakes the in-flight Display. +func (s *stream) set(img image.Image) { + s.mu.Lock() + s.img = img + s.mu.Unlock() + + s.wake() +} + +// wake cancels the in-flight Display so the display loop advances. +func (s *stream) wake() { + s.mu.Lock() + cancel := s.cancel + s.mu.Unlock() + + if cancel != nil { + cancel() + } +} + +// frame returns the latest image and a context that stays live until the next +// frame arrives or the viewer is asked to quit. +func (s *stream) frame() (image.Image, context.Context) { + s.mu.Lock() + defer s.mu.Unlock() + + ctx, cancel := context.WithCancel(context.Background()) + s.cancel = cancel + + return s.img, ctx +} + +// capture reads the newest frame until quit; Read() paces the loop, fps caps it. +// When showFPS is set it reports the measured rate on a single overwritten line. +func capture(cam *camera.Camera, s *stream, fps int, showFPS bool, done <-chan struct{}) { + interval := time.Second / time.Duration(fps) + next := time.Now() + + frames := 0 + mark := time.Now() + reported := false + + if showFPS { + defer func() { + if reported { + fmt.Fprintln(os.Stderr) + } + }() + } + + for { + select { + case <-done: + return + default: + } + + img, err := cam.Read() + if err != nil { + select { + case <-done: + return + default: + } + + stderr("%s\n", err.Error()) + + select { + case <-done: + return + case <-time.After(100 * time.Millisecond): + } + + next = time.Now() + + continue + } + + s.set(img) + + if frames++; showFPS { + if d := time.Since(mark); d >= time.Second { + fmt.Fprintf(os.Stderr, "\rFPS: %5.1f", float64(frames)/d.Seconds()) + reported = true + frames = 0 + mark = time.Now() + } + } + + if next = next.Add(interval); time.Until(next) <= 0 { + next = time.Now() + + continue + } + + select { + case <-done: + return + case <-time.After(time.Until(next)): + } + } +} + +func appIcon() image.Image { + img, err := png.Decode(bytes.NewReader(iconPNG)) + if err != nil { + return nil + } + + return img +} + +// banner prints the device and negotiated format on startup. +func banner(index int, info camera.Info) { + fmt.Fprintf(os.Stderr, "%s %s\n", name, version) + + if n := deviceName(index); n != "" { + fmt.Fprintf(os.Stderr, " device %s\n", n) + } + + fmt.Fprintf(os.Stderr, " format %s %dx%d\n", info.Format, info.Width, info.Height) +} + +func title(index int) string { + if n := deviceName(index); n != "" { + return fmt.Sprintf("%s - %s", name, n) + } + + return name +} + +func stderr(format string, a ...any) { + _, _ = fmt.Fprintf(os.Stderr, format, a...) +} + +// isTerminal reports whether f is a character device (a terminal). +func isTerminal(f *os.File) bool { + stat, err := f.Stat() + if err != nil { + return false + } + + return stat.Mode()&os.ModeCharDevice != 0 +} + +// deviceName returns the name of the camera at index, or an empty string. +func deviceName(index int) string { + devices, err := camera.Devices() + if err != nil { + return "" + } + + for _, d := range devices { + if d.Index == index { + return d.Name + } + } + + return "" +} + +// deviceIndex returns the index of the first camera whose name contains the query. +func deviceIndex(name string) (int, error) { + devices, err := camera.Devices() + if err != nil { + return 0, err + } + + want := strings.ToLower(name) + for _, d := range devices { + if strings.Contains(strings.ToLower(d.Name), want) { + return d.Index, nil + } + } + + return 0, fmt.Errorf("camera: no device matching %q", name) +}