From 799ff27b74c8952cbe81e6d186db20ceb9b18133 Mon Sep 17 00:00:00 2001 From: Milan Nikolic Date: Sat, 4 Jul 2026 09:40:46 +0200 Subject: [PATCH] feat(camview): Add native live camera viewer --- README.md | 6 + cmd/camview/go.mod | 27 +++ cmd/camview/go.sum | 42 +++++ cmd/camview/icon.png | Bin 0 -> 11338 bytes cmd/camview/main.go | 397 +++++++++++++++++++++++++++++++++++++++++++ 5 files changed, 472 insertions(+) create mode 100644 cmd/camview/go.mod create mode 100644 cmd/camview/go.sum create mode 100644 cmd/camview/icon.png create mode 100644 cmd/camview/main.go 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 0000000000000000000000000000000000000000..c7006f3579dcfcb4b1ba9d982a46527d616edb68 GIT binary patch literal 11338 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 literal 0 HcmV?d00001 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) +}