feat(camview): Add native live camera viewer

This commit is contained in:
Milan Nikolic
2026-07-04 09:40:46 +02:00
parent 57c82eb3a0
commit 799ff27b74
5 changed files with 472 additions and 0 deletions

View File

@@ -80,3 +80,9 @@ Usage: cam2ip [<flags>]
* `/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

27
cmd/camview/go.mod Normal file
View File

@@ -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
)

42
cmd/camview/go.sum Normal file
View File

@@ -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=

BIN
cmd/camview/icon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

397
cmd/camview/main.go Normal file
View File

@@ -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(&timestamp, "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)
}