mirror of
https://github.com/gen2brain/cam2ip.git
synced 2026-07-24 22:05:07 +00:00
feat(camview): Add native live camera viewer
This commit is contained in:
@@ -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
27
cmd/camview/go.mod
Normal 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
42
cmd/camview/go.sum
Normal 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
BIN
cmd/camview/icon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 11 KiB |
397
cmd/camview/main.go
Normal file
397
cmd/camview/main.go
Normal 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(×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)
|
||||
}
|
||||
Reference in New Issue
Block a user