rubycam-gtk 0.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +7 -0
- data/LICENSE +21 -0
- data/README.md +148 -0
- data/exe/rubycam-gtk +41 -0
- data/lib/rubycam/gtk/camera_app.rb +291 -0
- data/lib/rubycam/gtk/obsbot_panel.rb +426 -0
- data/lib/rubycam/gtk/video4linux.rb +236 -0
- data/lib/rubycam/gtk.rb +13 -0
- data/rubycam-gtk.gemspec +48 -0
- metadata +154 -0
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: 0d4db566e44eb16348e92c1dadace1af5af4b180fc4de621d6e98becf58f1fee
|
|
4
|
+
data.tar.gz: 9e4971aaed8f96f29f0c012fcd42ded2385a7b0e6e72281b33d3feb8a73da113
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: e5f71af7207517401c84216fbad6b1c144bd75ec384dc534a7a37df83bb98bc20a255a852f60d1b0b40255f75913f18670f4a9e94c71f644ec7de6815293f1e3
|
|
7
|
+
data.tar.gz: 0fe2370563fa78126edae0f5bda0845f3c9e1437c9647be5c23eb2cf71292bcd856d2c57decdb33c1f344c55ec163cccc60479621b60cd6098cb00c4a3001d14
|
data/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Nathan Kidd
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
data/README.md
ADDED
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
# rubycam
|
|
2
|
+
|
|
3
|
+
Pure-Ruby V4L2 webcam library plus a GTK4 viewer, built against an OBSBOT
|
|
4
|
+
Tiny 2. No C extension and no GStreamer: controls go through `ioctl` and
|
|
5
|
+
frames come from memory-mapped kernel buffers via Fiddle.
|
|
6
|
+
|
|
7
|
+
The OBSBOT is a standard UVC device, so the kernel's `uvcvideo` driver
|
|
8
|
+
already handles it — this library talks V4L2 and works with any UVC webcam.
|
|
9
|
+
|
|
10
|
+
## Install
|
|
11
|
+
|
|
12
|
+
Two gems ship from this repo:
|
|
13
|
+
|
|
14
|
+
| Gem | Provides | Depends on |
|
|
15
|
+
| ------------- | ------------------------------------------ | ------------------- |
|
|
16
|
+
| `rubycam` | the V4L2/OBSBOT library and the `rubycam` CLI | `dry-cli` |
|
|
17
|
+
| `rubycam-gtk` | the GTK4 viewer and the `rubycam-gtk` app | `rubycam`, `gtk4` |
|
|
18
|
+
|
|
19
|
+
```sh
|
|
20
|
+
gem install rubycam # library + CLI (pure Ruby, light install)
|
|
21
|
+
gem install rubycam-gtk # adds the GTK4 viewer (pulls in the GTK stack)
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
The `rubycam` gem has no dependencies beyond `dry-cli`; the GTK stack only
|
|
25
|
+
comes in with `rubycam-gtk`.
|
|
26
|
+
|
|
27
|
+
## Library
|
|
28
|
+
|
|
29
|
+
```ruby
|
|
30
|
+
require 'rubycam'
|
|
31
|
+
|
|
32
|
+
Rubycam::Device.open('/dev/video0') do |cam|
|
|
33
|
+
cam.controls.each_value { |c| puts c } # discover controls
|
|
34
|
+
cam[:zoom_absolute] = 50 # get/set by symbol
|
|
35
|
+
cam[:pan_absolute] = 20 * 3600 # gimbal units: 1/3600 degree
|
|
36
|
+
|
|
37
|
+
cam.set_format(width: 1920, height: 1080, pixel_format: 'MJPG')
|
|
38
|
+
cam.set_fps(30)
|
|
39
|
+
File.binwrite('frame.jpg', cam.capture_frame) # blocking
|
|
40
|
+
cam.poll_frame # non-blocking, nil if not ready
|
|
41
|
+
end
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
OBSBOT vendor commands go through the camera's UVC extension unit — the
|
|
45
|
+
same channel the official OBSBOT software uses (protocol from the
|
|
46
|
+
[Tiny4Linux](https://github.com/OpenFoxes/Tiny4Linux) project; see
|
|
47
|
+
`TINY4LINUX_FEATURES.md` for the full feature map):
|
|
48
|
+
|
|
49
|
+
```ruby
|
|
50
|
+
cam = Rubycam::Device.find('OBSBOT Tiny 2') # path, /dev name or card/bus hint
|
|
51
|
+
bot = Rubycam::Obsbot.new(cam)
|
|
52
|
+
bot.status # => { asleep: false, hdr: true, ai_mode: :no_tracking,
|
|
53
|
+
# tracking_speed: :standard }
|
|
54
|
+
bot.sleep! # privacy sleep (gimbal folds down)
|
|
55
|
+
bot.wake! # wakes even after the camera was folded down by hand
|
|
56
|
+
|
|
57
|
+
bot.ai_mode = :normal_tracking # :no_tracking :upper_body :close_up :headless
|
|
58
|
+
# :lower_body :desk_mode :whiteboard :hand :group
|
|
59
|
+
bot.tracking_speed = :sport # or :standard
|
|
60
|
+
bot.goto_preset(0) # stored gimbal positions 0..2
|
|
61
|
+
bot.hdr = true
|
|
62
|
+
bot.exposure_mode = :face # :manual, :global or :face
|
|
63
|
+
|
|
64
|
+
bot.debug = true # log raw traffic to stderr
|
|
65
|
+
bot.send_hex('16 02 00 00') # raw command to the extension unit
|
|
66
|
+
bot.dump # current status block as hex
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
Notes:
|
|
70
|
+
|
|
71
|
+
- The camera delivers 4K@30 / 1080p@60 in MJPG; frames are JPEG strings.
|
|
72
|
+
- Folding the camera down by hand is privacy sleep; V4L2 keeps working but
|
|
73
|
+
video stops. `Obsbot#wake!` is the only software way back.
|
|
74
|
+
- Writing `tilt_absolute` to its minimum does NOT trigger privacy sleep —
|
|
75
|
+
that gesture is hardware-only.
|
|
76
|
+
- The first frame after STREAMON takes a few seconds (ISP startup).
|
|
77
|
+
- On newer Tiny 2 firmware the status block lags mode changes by seconds
|
|
78
|
+
and reports tracking speed at byte 0x24 instead of 0x21 (both handled).
|
|
79
|
+
|
|
80
|
+
## Viewer app
|
|
81
|
+
|
|
82
|
+
With `rubycam-gtk` installed the viewer is on your PATH:
|
|
83
|
+
|
|
84
|
+
```sh
|
|
85
|
+
rubycam-gtk # first camera at /dev/video0
|
|
86
|
+
rubycam-gtk /dev/video2 # explicit device
|
|
87
|
+
rubycam-gtk 'OBSBOT Tiny 2' # find by name
|
|
88
|
+
|
|
89
|
+
rubycam-gtk --obsbot # full OBSBOT viewer (default)
|
|
90
|
+
rubycam-gtk --v4l2 # generic V4L2 viewer, no OBSBOT features
|
|
91
|
+
```
|
|
92
|
+
|
|
93
|
+
From a checkout, use the dev shell (it provides the native GTK libs):
|
|
94
|
+
|
|
95
|
+
```sh
|
|
96
|
+
nix develop # or let direnv do it (.envrc: use flake)
|
|
97
|
+
bundle install # or bin/setup
|
|
98
|
+
rubycam-gtk # exe/ is on PATH via direnv
|
|
99
|
+
```
|
|
100
|
+
|
|
101
|
+
Live preview, sliders for gimbal/zoom/image controls, and an OBSBOT panel
|
|
102
|
+
with power switch, live status, AI tracking modes, tracking speed, preset
|
|
103
|
+
positions, HDR, exposure modes and a raw-hex debug console. The ⤢ button
|
|
104
|
+
toggles a compact widget mode (panel only); if the camera disappears the
|
|
105
|
+
app keeps polling and reconnects when it returns.
|
|
106
|
+
|
|
107
|
+
`--v4l2` launches the same live preview and control sliders without any of
|
|
108
|
+
the OBSBOT panel or vendor commands, so it works with any UVC webcam.
|
|
109
|
+
|
|
110
|
+
## CLI
|
|
111
|
+
|
|
112
|
+
Everything the OBSBOT panel does, scriptable — shipped by the `rubycam`
|
|
113
|
+
gem (needs only Ruby + the `dry-cli` gem, no GTK):
|
|
114
|
+
|
|
115
|
+
```sh
|
|
116
|
+
rubycam # list commands
|
|
117
|
+
rubycam status # sleep state, AI mode, speed, HDR
|
|
118
|
+
rubycam wake # wake from privacy sleep
|
|
119
|
+
rubycam track upper_body # AI tracking mode
|
|
120
|
+
rubycam speed sport
|
|
121
|
+
rubycam preset 2 # gimbal preset 1-3
|
|
122
|
+
rubycam hdr off
|
|
123
|
+
rubycam exposure face
|
|
124
|
+
|
|
125
|
+
rubycam devices # every /dev/video* node
|
|
126
|
+
rubycam controls # V4L2 controls with ranges
|
|
127
|
+
rubycam set zoom_absolute 50
|
|
128
|
+
rubycam reset # all controls back to defaults
|
|
129
|
+
rubycam snapshot shot.jpg --width=3840 --height=2160
|
|
130
|
+
|
|
131
|
+
rubycam xu dump # debug: status block as hex
|
|
132
|
+
rubycam xu send '16 02 02 00' --selector=0x06
|
|
133
|
+
```
|
|
134
|
+
|
|
135
|
+
Generic V4L2 commands default to `/dev/video0`; OBSBOT commands find the
|
|
136
|
+
camera by name. Both take `-d` to target a path, `/dev` name or card/bus
|
|
137
|
+
substring.
|
|
138
|
+
|
|
139
|
+
## Examples
|
|
140
|
+
|
|
141
|
+
```sh
|
|
142
|
+
ruby examples/snapshot.rb # capture snapshot.jpg
|
|
143
|
+
ruby examples/controls.rb # list all controls
|
|
144
|
+
ruby examples/controls.rb zoom_absolute 50
|
|
145
|
+
```
|
|
146
|
+
|
|
147
|
+
The library itself has no dependencies beyond the Ruby stdlib; only the
|
|
148
|
+
GTK viewer needs the dev shell and `bundle install`.
|
data/exe/rubycam-gtk
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
#!/usr/bin/env ruby
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
#
|
|
4
|
+
# Launch the Rubycam GTK viewer.
|
|
5
|
+
#
|
|
6
|
+
# rubycam-gtk # OBSBOT viewer, first camera at /dev/video0
|
|
7
|
+
# rubycam-gtk /dev/video2 # explicit device node
|
|
8
|
+
# rubycam-gtk 'OBSBOT Tiny 2' # find by card/bus name
|
|
9
|
+
#
|
|
10
|
+
# rubycam-gtk --obsbot # full OBSBOT panel (default)
|
|
11
|
+
# rubycam-gtk --v4l2 # generic V4L2 viewer, no OBSBOT features
|
|
12
|
+
USAGE = <<~TEXT
|
|
13
|
+
Usage: rubycam-gtk [options] [device]
|
|
14
|
+
|
|
15
|
+
device Device path, /dev name, or card/bus-name substring
|
|
16
|
+
(default: /dev/video0)
|
|
17
|
+
|
|
18
|
+
Options:
|
|
19
|
+
--obsbot Full OBSBOT panel (default)
|
|
20
|
+
--v4l2 Generic V4L2 viewer, no OBSBOT features
|
|
21
|
+
-h, --help Show this message
|
|
22
|
+
|
|
23
|
+
Examples:
|
|
24
|
+
rubycam-gtk # OBSBOT viewer, first camera at /dev/video0
|
|
25
|
+
rubycam-gtk /dev/video2 # explicit device node
|
|
26
|
+
rubycam-gtk 'OBSBOT Tiny 2' # find by card/bus name
|
|
27
|
+
TEXT
|
|
28
|
+
|
|
29
|
+
if ARGV.include?("--help") || ARGV.include?("-h")
|
|
30
|
+
puts USAGE
|
|
31
|
+
exit
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
$LOAD_PATH.unshift File.expand_path("../lib", __dir__)
|
|
35
|
+
require "rubycam/gtk"
|
|
36
|
+
|
|
37
|
+
args = ARGV.dup
|
|
38
|
+
mode = args.delete("--v4l2") ? Rubycam::GTK::Video4Linux : nil
|
|
39
|
+
mode = Rubycam::GTK::CameraApp if args.delete("--obsbot") || mode.nil?
|
|
40
|
+
|
|
41
|
+
mode.new(args.fetch(0, "/dev/video0")).build.run
|
|
@@ -0,0 +1,291 @@
|
|
|
1
|
+
# Live OBSBOT viewer: video preview, gimbal/zoom/image sliders, plus the
|
|
2
|
+
# full Tiny4Linux control set (tracking, presets, speed, HDR, exposure,
|
|
3
|
+
# debug console) in the OBSBOT panel. Launched by exe/rubycam-gtk.
|
|
4
|
+
require 'gtk4'
|
|
5
|
+
require 'rubycam'
|
|
6
|
+
require_relative 'obsbot_panel'
|
|
7
|
+
|
|
8
|
+
module Rubycam
|
|
9
|
+
module GTK; end
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
class Rubycam::GTK::CameraApp
|
|
13
|
+
FRAME_INTERVAL_MS = 16
|
|
14
|
+
SLIDER_KEYS = %i[pan_absolute tilt_absolute zoom_absolute
|
|
15
|
+
brightness contrast saturation sharpness].freeze
|
|
16
|
+
FULL_SIZE = [1380, 640].freeze
|
|
17
|
+
COMPACT_SIZE = [340, 640].freeze
|
|
18
|
+
|
|
19
|
+
def initialize(device_path)
|
|
20
|
+
@device_path = device_path
|
|
21
|
+
@awake = true
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def build
|
|
25
|
+
app.tap do
|
|
26
|
+
app.signal_connect('activate') do
|
|
27
|
+
app.add_window(window)
|
|
28
|
+
|
|
29
|
+
window.tap do |win|
|
|
30
|
+
win.title = begin
|
|
31
|
+
device.card
|
|
32
|
+
rescue SystemCallError
|
|
33
|
+
'Rubycam (no camera)'
|
|
34
|
+
end
|
|
35
|
+
win.set_default_size(*FULL_SIZE)
|
|
36
|
+
win.child = layout
|
|
37
|
+
|
|
38
|
+
win.signal_connect('close-request') do
|
|
39
|
+
GLib::Source.remove(@pump) if @pump
|
|
40
|
+
close_device
|
|
41
|
+
false
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
layout.tap do |l|
|
|
46
|
+
l.append(picture)
|
|
47
|
+
l.append(sidebar)
|
|
48
|
+
l.append(panel.build)
|
|
49
|
+
|
|
50
|
+
sidebar.tap do |side|
|
|
51
|
+
sliders.each_value { |s| side.append(s) }
|
|
52
|
+
side.append(reset_button)
|
|
53
|
+
@sliders_in_sidebar = !sliders.empty?
|
|
54
|
+
|
|
55
|
+
reset_button.tap do |btn|
|
|
56
|
+
btn.signal_connect('clicked') { reset_controls }
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
start_frame_pump
|
|
62
|
+
start_status_sync
|
|
63
|
+
window.present
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
# Accepts a device path, a /dev name, or a card/bus substring
|
|
69
|
+
# (e.g. 'OBSBOT Tiny 2').
|
|
70
|
+
def device
|
|
71
|
+
@device ||= (Rubycam::Device.find(@device_path) or
|
|
72
|
+
raise Errno::ENOENT, @device_path).tap do |cam|
|
|
73
|
+
cam.set_format(width: 1280, height: 720, pixel_format: 'MJPG')
|
|
74
|
+
cam.set_fps(30)
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def app = @app ||= Gtk::Application.new('org.rubycam.viewer', :default_flags)
|
|
79
|
+
def window = @window ||= Gtk::ApplicationWindow.new(app)
|
|
80
|
+
def layout = @layout ||= Gtk::Box.new(:horizontal, 12)
|
|
81
|
+
|
|
82
|
+
def panel
|
|
83
|
+
@panel ||= Rubycam::GTK::ObsbotPanel.new(bot: -> { obsbot },
|
|
84
|
+
on_wake: -> { wake_camera },
|
|
85
|
+
on_sleep: -> { sleep_camera },
|
|
86
|
+
on_compact_toggle: -> { toggle_compact })
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
def picture
|
|
90
|
+
@picture ||= Gtk::Picture.new.tap do |pic|
|
|
91
|
+
pic.hexpand = true
|
|
92
|
+
pic.vexpand = true
|
|
93
|
+
pic.margin_start = 12
|
|
94
|
+
pic.margin_top = 12
|
|
95
|
+
pic.margin_bottom = 12
|
|
96
|
+
end
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
def sidebar
|
|
100
|
+
@sidebar ||= Gtk::Box.new(:vertical, 6).tap do |box|
|
|
101
|
+
box.margin_top = 12
|
|
102
|
+
box.margin_bottom = 12
|
|
103
|
+
box.width_request = 260
|
|
104
|
+
end
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
def sliders
|
|
108
|
+
@sliders ||= SLIDER_KEYS.filter_map do |key|
|
|
109
|
+
device.controls[key]&.then { |ctrl| [key, slider_for(ctrl)] }
|
|
110
|
+
end.to_h
|
|
111
|
+
rescue SystemCallError
|
|
112
|
+
{}
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
def reset_button
|
|
116
|
+
@reset_button ||= Gtk::Button.new(label: 'Reset to defaults').tap do |btn|
|
|
117
|
+
btn.margin_top = 12
|
|
118
|
+
end
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
def slider_for(ctrl)
|
|
122
|
+
Gtk::Box.new(:vertical, 2).tap do |box|
|
|
123
|
+
box.append(Gtk::Label.new(ctrl.key.to_s).tap { |l| l.halign = :start })
|
|
124
|
+
box.append(
|
|
125
|
+
Gtk::Scale.new(:horizontal,
|
|
126
|
+
Gtk::Adjustment.new(ctrl.value, ctrl.min, ctrl.max,
|
|
127
|
+
ctrl.step, ctrl.step * 10, 0)).tap do |scale|
|
|
128
|
+
scale.draw_value = false
|
|
129
|
+
scale.signal_connect('value-changed') do
|
|
130
|
+
set_control_safely(ctrl.key, scale.value.round)
|
|
131
|
+
end
|
|
132
|
+
end
|
|
133
|
+
)
|
|
134
|
+
end
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
def obsbot = @obsbot ||= Rubycam::Obsbot.new(device)
|
|
138
|
+
|
|
139
|
+
# Dashboard ⇄ compact widget mode: compact hides the preview and the V4L2
|
|
140
|
+
# sliders, leaving just the OBSBOT panel (like Tiny4Linux's widget mode).
|
|
141
|
+
def toggle_compact
|
|
142
|
+
@compact = !@compact
|
|
143
|
+
picture.visible = !@compact
|
|
144
|
+
sidebar.visible = !@compact
|
|
145
|
+
window.set_default_size(*(@compact ? COMPACT_SIZE : FULL_SIZE))
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
# Sleep/wake go through OBSBOT's vendor extension unit — the same command
|
|
149
|
+
# its official software sends. This also wakes the camera after it was put
|
|
150
|
+
# to sleep by physically folding it down.
|
|
151
|
+
def sleep_camera
|
|
152
|
+
@awake = false
|
|
153
|
+
obsbot.sleep!
|
|
154
|
+
rescue SystemCallError
|
|
155
|
+
nil
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
def wake_camera
|
|
159
|
+
@awake = true
|
|
160
|
+
obsbot.wake!
|
|
161
|
+
# Grace period for the stream to resume on its own before the
|
|
162
|
+
# watchdog rebuilds it.
|
|
163
|
+
@last_frame_at = monotonic_now
|
|
164
|
+
rescue SystemCallError
|
|
165
|
+
nil
|
|
166
|
+
end
|
|
167
|
+
|
|
168
|
+
# After this many consecutive failed polls the camera is treated as gone
|
|
169
|
+
# (a transient EIO during privacy-sleep transitions must not trigger a
|
|
170
|
+
# disruptive reconnect).
|
|
171
|
+
STATUS_FAILURES_BEFORE_RECONNECT = 3
|
|
172
|
+
|
|
173
|
+
def start_status_sync
|
|
174
|
+
GLib::Timeout.add_seconds(2) do
|
|
175
|
+
sync_status
|
|
176
|
+
GLib::Source::CONTINUE
|
|
177
|
+
end
|
|
178
|
+
end
|
|
179
|
+
|
|
180
|
+
# Poll the camera and mirror its state into the panel, so the UI follows
|
|
181
|
+
# changes made outside the app (folding the camera down, other software).
|
|
182
|
+
def sync_status
|
|
183
|
+
obsbot.status.then do |status|
|
|
184
|
+
@awake = !status[:asleep]
|
|
185
|
+
@failures = 0
|
|
186
|
+
panel.update(status)
|
|
187
|
+
end
|
|
188
|
+
rescue SystemCallError
|
|
189
|
+
@failures = (@failures || 0) + 1
|
|
190
|
+
reconnect_camera if @failures >= STATUS_FAILURES_BEFORE_RECONNECT
|
|
191
|
+
end
|
|
192
|
+
|
|
193
|
+
# The camera is gone (unplugged or re-enumerated): drop the dead handle
|
|
194
|
+
# and try to find it again on every poll until it returns.
|
|
195
|
+
def reconnect_camera
|
|
196
|
+
panel.update(nil)
|
|
197
|
+
close_device
|
|
198
|
+
device.then do
|
|
199
|
+
@failures = 0
|
|
200
|
+
@last_frame_at = monotonic_now
|
|
201
|
+
restore_sliders
|
|
202
|
+
end
|
|
203
|
+
rescue SystemCallError
|
|
204
|
+
nil
|
|
205
|
+
end
|
|
206
|
+
|
|
207
|
+
def close_device
|
|
208
|
+
@device&.close
|
|
209
|
+
rescue SystemCallError
|
|
210
|
+
nil
|
|
211
|
+
ensure
|
|
212
|
+
@device = nil
|
|
213
|
+
@obsbot = nil
|
|
214
|
+
end
|
|
215
|
+
|
|
216
|
+
# When the app started without a camera the sidebar has no sliders yet;
|
|
217
|
+
# build and attach them on first successful (re)connect.
|
|
218
|
+
def restore_sliders
|
|
219
|
+
unless @sliders_in_sidebar || sliders.empty?
|
|
220
|
+
sliders.each_value { |s| sidebar.append(s) }
|
|
221
|
+
sidebar.reorder_child_after(reset_button, sidebar.last_child)
|
|
222
|
+
@sliders_in_sidebar = true
|
|
223
|
+
end
|
|
224
|
+
end
|
|
225
|
+
|
|
226
|
+
def reset_controls
|
|
227
|
+
sliders.each_key do |key|
|
|
228
|
+
device.controls[key].then do |ctrl|
|
|
229
|
+
set_control_safely(key, ctrl.default)
|
|
230
|
+
slider_scale(key).value = ctrl.default
|
|
231
|
+
end
|
|
232
|
+
end
|
|
233
|
+
end
|
|
234
|
+
|
|
235
|
+
def slider_scale(key) = sliders[key].last_child
|
|
236
|
+
|
|
237
|
+
def control_value(key)
|
|
238
|
+
device.controls[key].value
|
|
239
|
+
rescue SystemCallError
|
|
240
|
+
nil
|
|
241
|
+
end
|
|
242
|
+
|
|
243
|
+
# Control writes can fail transiently (EIO/EBUSY) while the camera enters
|
|
244
|
+
# or leaves privacy sleep; an exception here must never kill the main loop.
|
|
245
|
+
def set_control_safely(key, value)
|
|
246
|
+
device.controls[key].value = value
|
|
247
|
+
rescue SystemCallError
|
|
248
|
+
nil
|
|
249
|
+
end
|
|
250
|
+
|
|
251
|
+
def start_frame_pump
|
|
252
|
+
@last_frame_at = monotonic_now
|
|
253
|
+
@pump = GLib::Timeout.add(FRAME_INTERVAL_MS) do
|
|
254
|
+
pump_one_frame if @device
|
|
255
|
+
GLib::Source::CONTINUE
|
|
256
|
+
end
|
|
257
|
+
end
|
|
258
|
+
|
|
259
|
+
# Longer than the camera's first-frame latency, so warmup after a stream
|
|
260
|
+
# (re)start is never mistaken for a dead stream.
|
|
261
|
+
WATCHDOG_SECONDS = 5
|
|
262
|
+
|
|
263
|
+
def pump_one_frame
|
|
264
|
+
device.poll_frame.then do |frame|
|
|
265
|
+
if frame
|
|
266
|
+
@last_frame_at = monotonic_now
|
|
267
|
+
show_frame(frame)
|
|
268
|
+
elsif @awake && monotonic_now - (@last_frame_at || 0) > WATCHDOG_SECONDS
|
|
269
|
+
# Video should be flowing but is not (e.g. the camera slept and woke
|
|
270
|
+
# outside our control): rebuild the stream until frames return.
|
|
271
|
+
@last_frame_at = monotonic_now
|
|
272
|
+
warn 'video stalled: rebuilding stream'
|
|
273
|
+
device.restart_streaming
|
|
274
|
+
end
|
|
275
|
+
end
|
|
276
|
+
rescue SystemCallError, RuntimeError
|
|
277
|
+
nil # stream hiccup (e.g. privacy sleep transition): keep the pump alive
|
|
278
|
+
end
|
|
279
|
+
|
|
280
|
+
def monotonic_now = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
281
|
+
|
|
282
|
+
def show_frame(jpeg)
|
|
283
|
+
GdkPixbuf::PixbufLoader.new.tap do |loader|
|
|
284
|
+
loader.write(jpeg)
|
|
285
|
+
loader.close
|
|
286
|
+
picture.pixbuf = loader.pixbuf
|
|
287
|
+
end
|
|
288
|
+
rescue GLib::Error
|
|
289
|
+
nil # drop corrupt frames rather than crashing the pump
|
|
290
|
+
end
|
|
291
|
+
end
|
|
@@ -0,0 +1,426 @@
|
|
|
1
|
+
# OBSBOT control panel: the Tiny4Linux GUI feature set as one widget —
|
|
2
|
+
# sleep/wake, live status, presets, tracking modes, tracking speed, HDR,
|
|
3
|
+
# exposure modes and a raw-hex debug console. Talks to the camera through
|
|
4
|
+
# the injected bot getter so a reconnect (new Obsbot) is picked up
|
|
5
|
+
# transparently.
|
|
6
|
+
module Rubycam
|
|
7
|
+
module GTK; end
|
|
8
|
+
end
|
|
9
|
+
|
|
10
|
+
class Rubycam::GTK::ObsbotPanel
|
|
11
|
+
AI_MODE_LABELS = { no_tracking: 'Static', normal_tracking: 'Normal',
|
|
12
|
+
close_up: 'Close-up', upper_body: 'Upper Body',
|
|
13
|
+
headless: 'Headless', lower_body: 'Lower Body',
|
|
14
|
+
desk_mode: 'Desk', whiteboard: 'Whiteboard',
|
|
15
|
+
hand: 'Hand', group: 'Group' }.freeze
|
|
16
|
+
SPEED_LABELS = { standard: 'Standard', sport: 'Sport' }.freeze
|
|
17
|
+
EXPOSURE_LABELS = { manual: 'Manual', global: 'Global', face: 'Face' }.freeze
|
|
18
|
+
STATS = { sleep: 'State', tracking: 'Tracking', speed: 'Speed',
|
|
19
|
+
hdr: 'HDR', version: 'Version' }.freeze
|
|
20
|
+
|
|
21
|
+
def initialize(bot:, on_wake:, on_sleep:, on_compact_toggle:)
|
|
22
|
+
@bot = bot
|
|
23
|
+
@on_wake = on_wake
|
|
24
|
+
@on_sleep = on_sleep
|
|
25
|
+
@on_compact_toggle = on_compact_toggle
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def build
|
|
29
|
+
@build ||= scroller.tap do |sc|
|
|
30
|
+
sc.child = root
|
|
31
|
+
|
|
32
|
+
root.tap do |r|
|
|
33
|
+
r.append(header_row)
|
|
34
|
+
r.append(connection_label)
|
|
35
|
+
r.append(power_row)
|
|
36
|
+
r.append(stats_grid)
|
|
37
|
+
r.append(presets_row)
|
|
38
|
+
r.append(tracking_heading)
|
|
39
|
+
r.append(tracking_grid)
|
|
40
|
+
r.append(speed_row)
|
|
41
|
+
r.append(hdr_row)
|
|
42
|
+
r.append(exposure_row)
|
|
43
|
+
r.append(debug_toggle)
|
|
44
|
+
r.append(debug_revealer)
|
|
45
|
+
|
|
46
|
+
header_row.tap do |row|
|
|
47
|
+
row.append(title_label)
|
|
48
|
+
row.append(compact_button)
|
|
49
|
+
|
|
50
|
+
compact_button.signal_connect('clicked') { @on_compact_toggle.call }
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
power_row.tap do |row|
|
|
54
|
+
row.append(power_label)
|
|
55
|
+
row.append(power_switch)
|
|
56
|
+
|
|
57
|
+
power_switch.signal_connect('state-set') do |_, on|
|
|
58
|
+
(on ? @on_wake : @on_sleep).call unless @syncing
|
|
59
|
+
false
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
stats_grid.tap do |g|
|
|
64
|
+
STATS.each_key.with_index do |key, i|
|
|
65
|
+
g.attach(stat_name_labels[key], 0, i, 1, 1)
|
|
66
|
+
g.attach(stat_labels[key], 1, i, 1, 1)
|
|
67
|
+
end
|
|
68
|
+
stat_labels[:version].label = Rubycam::VERSION
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
presets_row.tap do |row|
|
|
72
|
+
row.append(presets_label)
|
|
73
|
+
preset_buttons.each_with_index do |btn, i|
|
|
74
|
+
row.append(btn)
|
|
75
|
+
btn.signal_connect('clicked') { goto_preset(i) }
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
tracking_grid.tap do |g|
|
|
80
|
+
tracking_buttons.each_with_index do |(mode, btn), i|
|
|
81
|
+
g.attach(btn, i % 2, i / 2, 1, 1)
|
|
82
|
+
btn.signal_connect('clicked') { set_tracking(mode) }
|
|
83
|
+
end
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
speed_row.tap do |row|
|
|
87
|
+
row.append(speed_label)
|
|
88
|
+
speed_buttons.each do |speed, btn|
|
|
89
|
+
row.append(btn)
|
|
90
|
+
btn.signal_connect('clicked') { set_speed(speed) }
|
|
91
|
+
end
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
hdr_row.tap do |row|
|
|
95
|
+
row.append(hdr_label)
|
|
96
|
+
row.append(hdr_toggle)
|
|
97
|
+
|
|
98
|
+
hdr_toggle.signal_connect('toggled') do
|
|
99
|
+
command { |bot| bot.hdr = hdr_toggle.active? } unless @syncing
|
|
100
|
+
end
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
exposure_row.tap do |row|
|
|
104
|
+
row.append(exposure_label)
|
|
105
|
+
exposure_buttons.each do |mode, btn|
|
|
106
|
+
row.append(btn)
|
|
107
|
+
btn.signal_connect('clicked') { command { |bot| bot.exposure_mode = mode } }
|
|
108
|
+
end
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
debug_toggle.signal_connect('toggled') { toggle_debug }
|
|
112
|
+
|
|
113
|
+
debug_revealer.tap do |rev|
|
|
114
|
+
rev.child = debug_box
|
|
115
|
+
|
|
116
|
+
debug_box.tap do |dbg|
|
|
117
|
+
dbg.append(hex06_row)
|
|
118
|
+
dbg.append(hex02_row)
|
|
119
|
+
dbg.append(dump_row)
|
|
120
|
+
dbg.append(debug_output)
|
|
121
|
+
|
|
122
|
+
hex06_row.tap do |row|
|
|
123
|
+
row.append(hex06_entry)
|
|
124
|
+
row.append(hex06_send)
|
|
125
|
+
hex06_entry.signal_connect('activate') { send_hex(hex06_entry, Rubycam::Obsbot::SELECTOR_STATUS) }
|
|
126
|
+
hex06_send.signal_connect('clicked') { send_hex(hex06_entry, Rubycam::Obsbot::SELECTOR_STATUS) }
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
hex02_row.tap do |row|
|
|
130
|
+
row.append(hex02_entry)
|
|
131
|
+
row.append(hex02_send)
|
|
132
|
+
hex02_entry.signal_connect('activate') { send_hex(hex02_entry, Rubycam::Obsbot::SELECTOR_COMMAND) }
|
|
133
|
+
hex02_send.signal_connect('clicked') { send_hex(hex02_entry, Rubycam::Obsbot::SELECTOR_COMMAND) }
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
dump_row.tap do |row|
|
|
137
|
+
row.append(dump06_button)
|
|
138
|
+
row.append(dump02_button)
|
|
139
|
+
dump06_button.signal_connect('clicked') { show_dump(Rubycam::Obsbot::SELECTOR_STATUS) }
|
|
140
|
+
dump02_button.signal_connect('clicked') { show_dump(Rubycam::Obsbot::SELECTOR_COMMAND) }
|
|
141
|
+
end
|
|
142
|
+
end
|
|
143
|
+
end
|
|
144
|
+
end
|
|
145
|
+
end
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
# Mirror camera state into the panel. A nil status means no camera.
|
|
149
|
+
def update(status)
|
|
150
|
+
@syncing = true
|
|
151
|
+
connection_label.label = status ? 'Connected' : 'No camera detected — searching…'
|
|
152
|
+
stat_labels[:sleep].label = status ? (status[:asleep] ? 'Sleeping' : 'Awake') : '—'
|
|
153
|
+
stat_labels[:tracking].label = status ? AI_MODE_LABELS.fetch(status[:ai_mode], 'Unknown') : '—'
|
|
154
|
+
stat_labels[:speed].label = status ? SPEED_LABELS.fetch(status[:tracking_speed]) : '—'
|
|
155
|
+
stat_labels[:hdr].label = status ? (status[:hdr] ? 'On' : 'Off') : '—'
|
|
156
|
+
status&.then do |s|
|
|
157
|
+
power_switch.active = !s[:asleep]
|
|
158
|
+
hdr_toggle.active = s[:hdr]
|
|
159
|
+
highlight(tracking_buttons, s[:ai_mode])
|
|
160
|
+
highlight(speed_buttons, s[:tracking_speed])
|
|
161
|
+
end
|
|
162
|
+
@syncing = false
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
private
|
|
166
|
+
|
|
167
|
+
# Commands can fail transiently (camera asleep or unplugged) or on bad
|
|
168
|
+
# hex input; report failure instead of crashing the main loop.
|
|
169
|
+
def command
|
|
170
|
+
yield @bot.call
|
|
171
|
+
true
|
|
172
|
+
rescue SystemCallError, ArgumentError
|
|
173
|
+
false
|
|
174
|
+
end
|
|
175
|
+
|
|
176
|
+
def highlight(buttons, active_key)
|
|
177
|
+
buttons.each do |key, btn|
|
|
178
|
+
key == active_key ? btn.add_css_class('suggested-action') : btn.remove_css_class('suggested-action')
|
|
179
|
+
end
|
|
180
|
+
end
|
|
181
|
+
|
|
182
|
+
def set_tracking(mode)
|
|
183
|
+
command { |bot| bot.ai_mode = mode }
|
|
184
|
+
highlight(tracking_buttons, mode)
|
|
185
|
+
end
|
|
186
|
+
|
|
187
|
+
def set_speed(speed)
|
|
188
|
+
command { |bot| bot.tracking_speed = speed }
|
|
189
|
+
highlight(speed_buttons, speed)
|
|
190
|
+
end
|
|
191
|
+
|
|
192
|
+
# The camera ignores preset moves while tracking, so tracking is switched
|
|
193
|
+
# off first — same order as the official software.
|
|
194
|
+
def goto_preset(number)
|
|
195
|
+
command do |bot|
|
|
196
|
+
bot.ai_mode = :no_tracking
|
|
197
|
+
bot.goto_preset(number)
|
|
198
|
+
end
|
|
199
|
+
highlight(tracking_buttons, :no_tracking)
|
|
200
|
+
end
|
|
201
|
+
|
|
202
|
+
def toggle_debug
|
|
203
|
+
debug_revealer.reveal_child = debug_toggle.active?
|
|
204
|
+
command { |bot| bot.debug = debug_toggle.active? }
|
|
205
|
+
end
|
|
206
|
+
|
|
207
|
+
def send_hex(entry, selector)
|
|
208
|
+
debug_output.label =
|
|
209
|
+
command { |bot| bot.send_hex(entry.text, selector: selector) } ? 'Sent.' : 'Failed (bad hex or camera error).'
|
|
210
|
+
end
|
|
211
|
+
|
|
212
|
+
def show_dump(selector)
|
|
213
|
+
command { |bot| debug_output.label = bot.dump(selector).scan(/.{1,8}/).join(' ') }
|
|
214
|
+
.then { |ok| debug_output.label = 'Failed (camera error).' unless ok }
|
|
215
|
+
end
|
|
216
|
+
|
|
217
|
+
def scroller
|
|
218
|
+
@scroller ||= Gtk::ScrolledWindow.new.tap do |sc|
|
|
219
|
+
sc.set_policy(:never, :automatic)
|
|
220
|
+
sc.width_request = 330
|
|
221
|
+
end
|
|
222
|
+
end
|
|
223
|
+
|
|
224
|
+
def root
|
|
225
|
+
@root ||= Gtk::Box.new(:vertical, 10).tap do |b|
|
|
226
|
+
b.margin_top = 12
|
|
227
|
+
b.margin_bottom = 12
|
|
228
|
+
b.margin_start = 6
|
|
229
|
+
b.margin_end = 12
|
|
230
|
+
end
|
|
231
|
+
end
|
|
232
|
+
|
|
233
|
+
def header_row = @header_row ||= Gtk::Box.new(:horizontal, 8)
|
|
234
|
+
|
|
235
|
+
def title_label
|
|
236
|
+
@title_label ||= Gtk::Label.new('OBSBOT').tap do |l|
|
|
237
|
+
l.halign = :start
|
|
238
|
+
l.hexpand = true
|
|
239
|
+
l.add_css_class('heading')
|
|
240
|
+
end
|
|
241
|
+
end
|
|
242
|
+
|
|
243
|
+
def compact_button
|
|
244
|
+
@compact_button ||= Gtk::Button.new.tap do |b|
|
|
245
|
+
b.icon_name = 'view-restore-symbolic'
|
|
246
|
+
b.tooltip_text = 'Toggle compact widget mode'
|
|
247
|
+
b.halign = :end
|
|
248
|
+
end
|
|
249
|
+
end
|
|
250
|
+
|
|
251
|
+
def connection_label
|
|
252
|
+
@connection_label ||= Gtk::Label.new('Connecting…').tap do |l|
|
|
253
|
+
l.xalign = 0
|
|
254
|
+
l.add_css_class('dim-label')
|
|
255
|
+
end
|
|
256
|
+
end
|
|
257
|
+
|
|
258
|
+
def power_row = @power_row ||= Gtk::Box.new(:horizontal, 8)
|
|
259
|
+
|
|
260
|
+
def power_label
|
|
261
|
+
@power_label ||= Gtk::Label.new('Camera').tap do |l|
|
|
262
|
+
l.halign = :start
|
|
263
|
+
l.hexpand = true
|
|
264
|
+
end
|
|
265
|
+
end
|
|
266
|
+
|
|
267
|
+
def power_switch
|
|
268
|
+
@power_switch ||= Gtk::Switch.new.tap do |sw|
|
|
269
|
+
sw.active = true
|
|
270
|
+
sw.halign = :end
|
|
271
|
+
end
|
|
272
|
+
end
|
|
273
|
+
|
|
274
|
+
def stats_grid
|
|
275
|
+
@stats_grid ||= Gtk::Grid.new.tap do |g|
|
|
276
|
+
g.row_spacing = 4
|
|
277
|
+
g.column_spacing = 12
|
|
278
|
+
end
|
|
279
|
+
end
|
|
280
|
+
|
|
281
|
+
def stat_name_labels
|
|
282
|
+
@stat_name_labels ||= STATS.to_h do |key, name|
|
|
283
|
+
[key, Gtk::Label.new("#{name}:").tap do |l|
|
|
284
|
+
l.xalign = 0
|
|
285
|
+
l.hexpand = true
|
|
286
|
+
l.add_css_class('dim-label')
|
|
287
|
+
end]
|
|
288
|
+
end
|
|
289
|
+
end
|
|
290
|
+
|
|
291
|
+
def stat_labels
|
|
292
|
+
@stat_labels ||= STATS.keys.to_h do |key|
|
|
293
|
+
[key, Gtk::Label.new('—').tap { |l| l.xalign = 1 }]
|
|
294
|
+
end
|
|
295
|
+
end
|
|
296
|
+
|
|
297
|
+
def presets_row = @presets_row ||= Gtk::Box.new(:horizontal, 6)
|
|
298
|
+
|
|
299
|
+
def presets_label
|
|
300
|
+
@presets_label ||= Gtk::Label.new('Presets:').tap do |l|
|
|
301
|
+
l.xalign = 0
|
|
302
|
+
l.hexpand = true
|
|
303
|
+
end
|
|
304
|
+
end
|
|
305
|
+
|
|
306
|
+
def preset_buttons
|
|
307
|
+
@preset_buttons ||= (1..3).map do |n|
|
|
308
|
+
Gtk::Button.new(label: n.to_s).tap { |b| b.tooltip_text = "Go to preset position #{n}" }
|
|
309
|
+
end
|
|
310
|
+
end
|
|
311
|
+
|
|
312
|
+
def tracking_heading
|
|
313
|
+
@tracking_heading ||= Gtk::Label.new('Tracking mode').tap do |l|
|
|
314
|
+
l.xalign = 0
|
|
315
|
+
l.add_css_class('dim-label')
|
|
316
|
+
end
|
|
317
|
+
end
|
|
318
|
+
|
|
319
|
+
def tracking_grid
|
|
320
|
+
@tracking_grid ||= Gtk::Grid.new.tap do |g|
|
|
321
|
+
g.row_spacing = 6
|
|
322
|
+
g.column_spacing = 6
|
|
323
|
+
g.column_homogeneous = true
|
|
324
|
+
end
|
|
325
|
+
end
|
|
326
|
+
|
|
327
|
+
def tracking_buttons
|
|
328
|
+
@tracking_buttons ||= AI_MODE_LABELS.transform_values do |label|
|
|
329
|
+
Gtk::Button.new(label: label).tap { |b| b.tooltip_text = "Set tracking mode: #{label}" }
|
|
330
|
+
end
|
|
331
|
+
end
|
|
332
|
+
|
|
333
|
+
def speed_row = @speed_row ||= Gtk::Box.new(:horizontal, 6)
|
|
334
|
+
|
|
335
|
+
def speed_label
|
|
336
|
+
@speed_label ||= Gtk::Label.new('Speed:').tap do |l|
|
|
337
|
+
l.xalign = 0
|
|
338
|
+
l.hexpand = true
|
|
339
|
+
end
|
|
340
|
+
end
|
|
341
|
+
|
|
342
|
+
def speed_buttons
|
|
343
|
+
@speed_buttons ||= SPEED_LABELS.transform_values do |label|
|
|
344
|
+
Gtk::Button.new(label: label).tap { |b| b.tooltip_text = "Set tracking speed: #{label}" }
|
|
345
|
+
end
|
|
346
|
+
end
|
|
347
|
+
|
|
348
|
+
def hdr_row = @hdr_row ||= Gtk::Box.new(:horizontal, 6)
|
|
349
|
+
|
|
350
|
+
def hdr_label
|
|
351
|
+
@hdr_label ||= Gtk::Label.new('HDR:').tap do |l|
|
|
352
|
+
l.xalign = 0
|
|
353
|
+
l.hexpand = true
|
|
354
|
+
end
|
|
355
|
+
end
|
|
356
|
+
|
|
357
|
+
def hdr_toggle
|
|
358
|
+
@hdr_toggle ||= Gtk::ToggleButton.new.tap do |b|
|
|
359
|
+
b.label = 'HDR'
|
|
360
|
+
b.tooltip_text = 'Toggle HDR'
|
|
361
|
+
end
|
|
362
|
+
end
|
|
363
|
+
|
|
364
|
+
def exposure_row = @exposure_row ||= Gtk::Box.new(:horizontal, 6)
|
|
365
|
+
|
|
366
|
+
def exposure_label
|
|
367
|
+
@exposure_label ||= Gtk::Label.new('Exposure:').tap do |l|
|
|
368
|
+
l.xalign = 0
|
|
369
|
+
l.hexpand = true
|
|
370
|
+
end
|
|
371
|
+
end
|
|
372
|
+
|
|
373
|
+
def exposure_buttons
|
|
374
|
+
@exposure_buttons ||= EXPOSURE_LABELS.transform_values do |label|
|
|
375
|
+
Gtk::Button.new(label: label).tap { |b| b.tooltip_text = "Set exposure mode: #{label}" }
|
|
376
|
+
end
|
|
377
|
+
end
|
|
378
|
+
|
|
379
|
+
def debug_toggle
|
|
380
|
+
@debug_toggle ||= Gtk::ToggleButton.new.tap do |b|
|
|
381
|
+
b.label = 'Debug console'
|
|
382
|
+
b.tooltip_text = 'Log commands to stderr and send raw hex'
|
|
383
|
+
end
|
|
384
|
+
end
|
|
385
|
+
|
|
386
|
+
def debug_revealer
|
|
387
|
+
@debug_revealer ||= Gtk::Revealer.new.tap do |r|
|
|
388
|
+
r.transition_type = :slide_down
|
|
389
|
+
r.reveal_child = false
|
|
390
|
+
end
|
|
391
|
+
end
|
|
392
|
+
|
|
393
|
+
def debug_box = @debug_box ||= Gtk::Box.new(:vertical, 6)
|
|
394
|
+
def hex06_row = @hex06_row ||= Gtk::Box.new(:horizontal, 6)
|
|
395
|
+
def hex02_row = @hex02_row ||= Gtk::Box.new(:horizontal, 6)
|
|
396
|
+
def dump_row = @dump_row ||= Gtk::Box.new(:horizontal, 6)
|
|
397
|
+
|
|
398
|
+
def hex06_entry
|
|
399
|
+
@hex06_entry ||= Gtk::Entry.new.tap do |e|
|
|
400
|
+
e.placeholder_text = '0x06 hex bytes'
|
|
401
|
+
e.hexpand = true
|
|
402
|
+
end
|
|
403
|
+
end
|
|
404
|
+
|
|
405
|
+
def hex02_entry
|
|
406
|
+
@hex02_entry ||= Gtk::Entry.new.tap do |e|
|
|
407
|
+
e.placeholder_text = '0x02 hex bytes'
|
|
408
|
+
e.hexpand = true
|
|
409
|
+
end
|
|
410
|
+
end
|
|
411
|
+
|
|
412
|
+
def hex06_send = @hex06_send ||= Gtk::Button.new(label: 'Send')
|
|
413
|
+
def hex02_send = @hex02_send ||= Gtk::Button.new(label: 'Send')
|
|
414
|
+
def dump06_button = @dump06_button ||= Gtk::Button.new(label: 'Dump 0x06')
|
|
415
|
+
def dump02_button = @dump02_button ||= Gtk::Button.new(label: 'Dump 0x02')
|
|
416
|
+
|
|
417
|
+
def debug_output
|
|
418
|
+
@debug_output ||= Gtk::Label.new('').tap do |l|
|
|
419
|
+
l.xalign = 0
|
|
420
|
+
l.wrap = true
|
|
421
|
+
l.wrap_mode = :char
|
|
422
|
+
l.selectable = true
|
|
423
|
+
l.add_css_class('monospace')
|
|
424
|
+
end
|
|
425
|
+
end
|
|
426
|
+
end
|
|
@@ -0,0 +1,236 @@
|
|
|
1
|
+
# Standalone generic V4L2 viewer: live preview plus sliders for whatever
|
|
2
|
+
# controls the camera exposes. A trimmed copy of CameraApp with no OBSBOT
|
|
3
|
+
# panel or vendor commands, so it works with any UVC webcam. Launched by
|
|
4
|
+
# exe/rubycam-v4l2.
|
|
5
|
+
require 'gtk4'
|
|
6
|
+
require 'rubycam'
|
|
7
|
+
|
|
8
|
+
module Rubycam
|
|
9
|
+
module GTK; end
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
class Rubycam::GTK::Video4Linux
|
|
13
|
+
FRAME_INTERVAL_MS = 16
|
|
14
|
+
SLIDER_KEYS = %i[pan_absolute tilt_absolute zoom_absolute
|
|
15
|
+
brightness contrast saturation sharpness].freeze
|
|
16
|
+
WINDOW_SIZE = [1120, 640].freeze
|
|
17
|
+
|
|
18
|
+
def initialize(device_path)
|
|
19
|
+
@device_path = device_path
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def build
|
|
23
|
+
app.tap do
|
|
24
|
+
app.signal_connect('activate') do
|
|
25
|
+
app.add_window(window)
|
|
26
|
+
|
|
27
|
+
window.tap do |win|
|
|
28
|
+
win.title = window_title
|
|
29
|
+
win.set_default_size(*WINDOW_SIZE)
|
|
30
|
+
win.child = layout
|
|
31
|
+
|
|
32
|
+
win.signal_connect('close-request') do
|
|
33
|
+
GLib::Source.remove(@pump) if @pump
|
|
34
|
+
close_device
|
|
35
|
+
false
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
layout.tap do |l|
|
|
40
|
+
l.append(picture)
|
|
41
|
+
l.append(sidebar)
|
|
42
|
+
|
|
43
|
+
sidebar.tap do |side|
|
|
44
|
+
sliders.each_value { |s| side.append(s) }
|
|
45
|
+
side.append(reset_button)
|
|
46
|
+
@sliders_in_sidebar = !sliders.empty?
|
|
47
|
+
|
|
48
|
+
reset_button.tap do |btn|
|
|
49
|
+
btn.signal_connect('clicked') { reset_controls }
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
start_frame_pump
|
|
55
|
+
start_reconnect_watch
|
|
56
|
+
window.present
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
# Accepts a device path, a /dev name, or a card/bus substring
|
|
62
|
+
# (e.g. 'Integrated Camera').
|
|
63
|
+
def device
|
|
64
|
+
@device ||= (Rubycam::Device.find(@device_path) or
|
|
65
|
+
raise Errno::ENOENT, @device_path).tap do |cam|
|
|
66
|
+
cam.set_format(width: 1280, height: 720, pixel_format: 'MJPG')
|
|
67
|
+
cam.set_fps(30)
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def window_title
|
|
72
|
+
device.card
|
|
73
|
+
rescue SystemCallError
|
|
74
|
+
'Rubycam V4L2 (no camera)'
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def app = @app ||= Gtk::Application.new('org.rubycam.v4l2', :default_flags)
|
|
78
|
+
def window = @window ||= Gtk::ApplicationWindow.new(app)
|
|
79
|
+
def layout = @layout ||= Gtk::Box.new(:horizontal, 12)
|
|
80
|
+
|
|
81
|
+
def picture
|
|
82
|
+
@picture ||= Gtk::Picture.new.tap do |pic|
|
|
83
|
+
pic.hexpand = true
|
|
84
|
+
pic.vexpand = true
|
|
85
|
+
pic.margin_start = 12
|
|
86
|
+
pic.margin_top = 12
|
|
87
|
+
pic.margin_bottom = 12
|
|
88
|
+
end
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
def sidebar
|
|
92
|
+
@sidebar ||= Gtk::Box.new(:vertical, 6).tap do |box|
|
|
93
|
+
box.margin_top = 12
|
|
94
|
+
box.margin_bottom = 12
|
|
95
|
+
box.margin_end = 12
|
|
96
|
+
box.width_request = 260
|
|
97
|
+
end
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
def sliders
|
|
101
|
+
@sliders ||= SLIDER_KEYS.filter_map do |key|
|
|
102
|
+
device.controls[key]&.then { |ctrl| [key, slider_for(ctrl)] }
|
|
103
|
+
end.to_h
|
|
104
|
+
rescue SystemCallError
|
|
105
|
+
{}
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
def reset_button
|
|
109
|
+
@reset_button ||= Gtk::Button.new(label: 'Reset to defaults').tap do |btn|
|
|
110
|
+
btn.margin_top = 12
|
|
111
|
+
end
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
def slider_for(ctrl)
|
|
115
|
+
Gtk::Box.new(:vertical, 2).tap do |box|
|
|
116
|
+
box.append(Gtk::Label.new(ctrl.key.to_s).tap { |l| l.halign = :start })
|
|
117
|
+
box.append(
|
|
118
|
+
Gtk::Scale.new(:horizontal,
|
|
119
|
+
Gtk::Adjustment.new(ctrl.value, ctrl.min, ctrl.max,
|
|
120
|
+
ctrl.step, ctrl.step * 10, 0)).tap do |scale|
|
|
121
|
+
scale.draw_value = false
|
|
122
|
+
scale.signal_connect('value-changed') do
|
|
123
|
+
set_control_safely(ctrl.key, scale.value.round)
|
|
124
|
+
end
|
|
125
|
+
end
|
|
126
|
+
)
|
|
127
|
+
end
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
# After this many consecutive frame-pump errors the camera is treated as
|
|
131
|
+
# gone (a single hiccup while a stream rebuilds must not drop the handle).
|
|
132
|
+
STREAM_ERRORS_BEFORE_DROP = 60
|
|
133
|
+
|
|
134
|
+
# Drop the dead handle and try to find the camera again on every tick until
|
|
135
|
+
# it returns (unplugged or re-enumerated).
|
|
136
|
+
def start_reconnect_watch
|
|
137
|
+
GLib::Timeout.add_seconds(2) do
|
|
138
|
+
reconnect_camera unless @device
|
|
139
|
+
GLib::Source::CONTINUE
|
|
140
|
+
end
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
def reconnect_camera
|
|
144
|
+
device.then do
|
|
145
|
+
@stream_errors = 0
|
|
146
|
+
@last_frame_at = monotonic_now
|
|
147
|
+
restore_sliders
|
|
148
|
+
window.title = window_title
|
|
149
|
+
end
|
|
150
|
+
rescue SystemCallError
|
|
151
|
+
nil
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
def close_device
|
|
155
|
+
@device&.close
|
|
156
|
+
rescue SystemCallError
|
|
157
|
+
nil
|
|
158
|
+
ensure
|
|
159
|
+
@device = nil
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
# When the app started without a camera the sidebar has no sliders yet;
|
|
163
|
+
# build and attach them on first successful (re)connect.
|
|
164
|
+
def restore_sliders
|
|
165
|
+
unless @sliders_in_sidebar || sliders.empty?
|
|
166
|
+
sliders.each_value { |s| sidebar.append(s) }
|
|
167
|
+
sidebar.reorder_child_after(reset_button, sidebar.last_child)
|
|
168
|
+
@sliders_in_sidebar = true
|
|
169
|
+
end
|
|
170
|
+
end
|
|
171
|
+
|
|
172
|
+
def reset_controls
|
|
173
|
+
sliders.each_key do |key|
|
|
174
|
+
device.controls[key].then do |ctrl|
|
|
175
|
+
set_control_safely(key, ctrl.default)
|
|
176
|
+
slider_scale(key).value = ctrl.default
|
|
177
|
+
end
|
|
178
|
+
end
|
|
179
|
+
end
|
|
180
|
+
|
|
181
|
+
def slider_scale(key) = sliders[key].last_child
|
|
182
|
+
|
|
183
|
+
# Control writes can fail transiently (EIO/EBUSY); an exception here must
|
|
184
|
+
# never kill the main loop.
|
|
185
|
+
def set_control_safely(key, value)
|
|
186
|
+
device.controls[key].value = value
|
|
187
|
+
rescue SystemCallError
|
|
188
|
+
nil
|
|
189
|
+
end
|
|
190
|
+
|
|
191
|
+
def start_frame_pump
|
|
192
|
+
@last_frame_at = monotonic_now
|
|
193
|
+
@stream_errors = 0
|
|
194
|
+
@pump = GLib::Timeout.add(FRAME_INTERVAL_MS) do
|
|
195
|
+
pump_one_frame if @device
|
|
196
|
+
GLib::Source::CONTINUE
|
|
197
|
+
end
|
|
198
|
+
end
|
|
199
|
+
|
|
200
|
+
# Longer than the camera's first-frame latency, so warmup after a stream
|
|
201
|
+
# (re)start is never mistaken for a dead stream.
|
|
202
|
+
WATCHDOG_SECONDS = 5
|
|
203
|
+
|
|
204
|
+
def pump_one_frame
|
|
205
|
+
device.poll_frame.then do |frame|
|
|
206
|
+
if frame
|
|
207
|
+
@last_frame_at = monotonic_now
|
|
208
|
+
@stream_errors = 0
|
|
209
|
+
show_frame(frame)
|
|
210
|
+
elsif monotonic_now - (@last_frame_at || 0) > WATCHDOG_SECONDS
|
|
211
|
+
# Video should be flowing but is not: rebuild the stream until frames
|
|
212
|
+
# return.
|
|
213
|
+
@last_frame_at = monotonic_now
|
|
214
|
+
warn 'video stalled: rebuilding stream'
|
|
215
|
+
device.restart_streaming
|
|
216
|
+
end
|
|
217
|
+
end
|
|
218
|
+
rescue SystemCallError, RuntimeError
|
|
219
|
+
# Sustained errors mean the camera is gone: drop the handle so the
|
|
220
|
+
# reconnect watch re-finds it. A one-off hiccup is ignored.
|
|
221
|
+
@stream_errors = (@stream_errors || 0) + 1
|
|
222
|
+
close_device if @stream_errors >= STREAM_ERRORS_BEFORE_DROP
|
|
223
|
+
end
|
|
224
|
+
|
|
225
|
+
def monotonic_now = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
226
|
+
|
|
227
|
+
def show_frame(jpeg)
|
|
228
|
+
GdkPixbuf::PixbufLoader.new.tap do |loader|
|
|
229
|
+
loader.write(jpeg)
|
|
230
|
+
loader.close
|
|
231
|
+
picture.pixbuf = loader.pixbuf
|
|
232
|
+
end
|
|
233
|
+
rescue GLib::Error
|
|
234
|
+
nil # drop corrupt frames rather than crashing the pump
|
|
235
|
+
end
|
|
236
|
+
end
|
data/lib/rubycam/gtk.rb
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# Rubycam GTK4 viewer: live preview plus the full OBSBOT control panel.
|
|
4
|
+
# Depends on the rubycam gem for the underlying V4L2 / OBSBOT driver.
|
|
5
|
+
#
|
|
6
|
+
# require "rubycam/gtk"
|
|
7
|
+
# Rubycam::GTK::CameraApp.new("/dev/video0").build.run
|
|
8
|
+
require "gtk4"
|
|
9
|
+
require "rubycam"
|
|
10
|
+
|
|
11
|
+
require_relative "gtk/obsbot_panel"
|
|
12
|
+
require_relative "gtk/camera_app"
|
|
13
|
+
require_relative "gtk/video4linux"
|
data/rubycam-gtk.gemspec
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "lib/rubycam/version"
|
|
4
|
+
|
|
5
|
+
Gem::Specification.new do |spec|
|
|
6
|
+
spec.name = "rubycam-gtk"
|
|
7
|
+
spec.version = Rubycam::VERSION
|
|
8
|
+
spec.authors = ["Nathan Kidd"]
|
|
9
|
+
spec.email = ["nathankidd@hey.com"]
|
|
10
|
+
|
|
11
|
+
spec.summary = "GTK4 viewer for Rubycam webcams (OBSBOT Tiny live preview and controls)"
|
|
12
|
+
|
|
13
|
+
spec.description = <<~DESC
|
|
14
|
+
A GTK4 desktop viewer built on the rubycam library: live video preview,
|
|
15
|
+
sliders for gimbal/zoom/image controls, and a full OBSBOT Tiny control
|
|
16
|
+
panel — privacy sleep/wake, AI tracking modes, gimbal presets, tracking
|
|
17
|
+
speed, HDR, exposure modes and a raw-hex debug console.
|
|
18
|
+
DESC
|
|
19
|
+
|
|
20
|
+
spec.homepage = "https://github.com/ruby-gtk-project/rubycam"
|
|
21
|
+
spec.license = "MIT"
|
|
22
|
+
spec.required_ruby_version = ">= 3.1.0"
|
|
23
|
+
|
|
24
|
+
spec.metadata["source_code_uri"] = spec.homepage
|
|
25
|
+
spec.metadata["changelog_uri"] = "#{spec.homepage}/releases"
|
|
26
|
+
spec.metadata["rubygems_mfa_required"] = "true"
|
|
27
|
+
|
|
28
|
+
# The rubycam-gtk gem ships only the viewer; the library itself comes from
|
|
29
|
+
# the rubycam runtime dependency below.
|
|
30
|
+
spec.files = `git ls-files -z`.split("\x0").select do |f|
|
|
31
|
+
f.start_with?("lib/rubycam/gtk") ||
|
|
32
|
+
f == "exe/rubycam-gtk" ||
|
|
33
|
+
f == "rubycam-gtk.gemspec" ||
|
|
34
|
+
%w[README.md LICENSE].include?(f)
|
|
35
|
+
end
|
|
36
|
+
spec.bindir = "exe"
|
|
37
|
+
spec.executables = ["rubycam-gtk"]
|
|
38
|
+
spec.require_paths = ["lib"]
|
|
39
|
+
|
|
40
|
+
spec.add_dependency "rubycam", Rubycam::VERSION
|
|
41
|
+
spec.add_dependency "gtk4", "~> 4.2"
|
|
42
|
+
spec.add_dependency "gdk_pixbuf2", "~> 4.2"
|
|
43
|
+
|
|
44
|
+
spec.add_development_dependency "minitest", "~> 5.0"
|
|
45
|
+
spec.add_development_dependency "rake", "~> 13.0"
|
|
46
|
+
spec.add_development_dependency "rubocop", "~> 1.21"
|
|
47
|
+
spec.add_development_dependency "lefthook", "~> 2.1"
|
|
48
|
+
end
|
metadata
ADDED
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
|
2
|
+
name: rubycam-gtk
|
|
3
|
+
version: !ruby/object:Gem::Version
|
|
4
|
+
version: 0.1.1
|
|
5
|
+
platform: ruby
|
|
6
|
+
authors:
|
|
7
|
+
- Nathan Kidd
|
|
8
|
+
bindir: exe
|
|
9
|
+
cert_chain: []
|
|
10
|
+
date: 1980-01-01 00:00:00.000000000 Z
|
|
11
|
+
dependencies:
|
|
12
|
+
- !ruby/object:Gem::Dependency
|
|
13
|
+
name: rubycam
|
|
14
|
+
requirement: !ruby/object:Gem::Requirement
|
|
15
|
+
requirements:
|
|
16
|
+
- - '='
|
|
17
|
+
- !ruby/object:Gem::Version
|
|
18
|
+
version: 0.1.1
|
|
19
|
+
type: :runtime
|
|
20
|
+
prerelease: false
|
|
21
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
22
|
+
requirements:
|
|
23
|
+
- - '='
|
|
24
|
+
- !ruby/object:Gem::Version
|
|
25
|
+
version: 0.1.1
|
|
26
|
+
- !ruby/object:Gem::Dependency
|
|
27
|
+
name: gtk4
|
|
28
|
+
requirement: !ruby/object:Gem::Requirement
|
|
29
|
+
requirements:
|
|
30
|
+
- - "~>"
|
|
31
|
+
- !ruby/object:Gem::Version
|
|
32
|
+
version: '4.2'
|
|
33
|
+
type: :runtime
|
|
34
|
+
prerelease: false
|
|
35
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
36
|
+
requirements:
|
|
37
|
+
- - "~>"
|
|
38
|
+
- !ruby/object:Gem::Version
|
|
39
|
+
version: '4.2'
|
|
40
|
+
- !ruby/object:Gem::Dependency
|
|
41
|
+
name: gdk_pixbuf2
|
|
42
|
+
requirement: !ruby/object:Gem::Requirement
|
|
43
|
+
requirements:
|
|
44
|
+
- - "~>"
|
|
45
|
+
- !ruby/object:Gem::Version
|
|
46
|
+
version: '4.2'
|
|
47
|
+
type: :runtime
|
|
48
|
+
prerelease: false
|
|
49
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
50
|
+
requirements:
|
|
51
|
+
- - "~>"
|
|
52
|
+
- !ruby/object:Gem::Version
|
|
53
|
+
version: '4.2'
|
|
54
|
+
- !ruby/object:Gem::Dependency
|
|
55
|
+
name: minitest
|
|
56
|
+
requirement: !ruby/object:Gem::Requirement
|
|
57
|
+
requirements:
|
|
58
|
+
- - "~>"
|
|
59
|
+
- !ruby/object:Gem::Version
|
|
60
|
+
version: '5.0'
|
|
61
|
+
type: :development
|
|
62
|
+
prerelease: false
|
|
63
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
64
|
+
requirements:
|
|
65
|
+
- - "~>"
|
|
66
|
+
- !ruby/object:Gem::Version
|
|
67
|
+
version: '5.0'
|
|
68
|
+
- !ruby/object:Gem::Dependency
|
|
69
|
+
name: rake
|
|
70
|
+
requirement: !ruby/object:Gem::Requirement
|
|
71
|
+
requirements:
|
|
72
|
+
- - "~>"
|
|
73
|
+
- !ruby/object:Gem::Version
|
|
74
|
+
version: '13.0'
|
|
75
|
+
type: :development
|
|
76
|
+
prerelease: false
|
|
77
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
78
|
+
requirements:
|
|
79
|
+
- - "~>"
|
|
80
|
+
- !ruby/object:Gem::Version
|
|
81
|
+
version: '13.0'
|
|
82
|
+
- !ruby/object:Gem::Dependency
|
|
83
|
+
name: rubocop
|
|
84
|
+
requirement: !ruby/object:Gem::Requirement
|
|
85
|
+
requirements:
|
|
86
|
+
- - "~>"
|
|
87
|
+
- !ruby/object:Gem::Version
|
|
88
|
+
version: '1.21'
|
|
89
|
+
type: :development
|
|
90
|
+
prerelease: false
|
|
91
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
92
|
+
requirements:
|
|
93
|
+
- - "~>"
|
|
94
|
+
- !ruby/object:Gem::Version
|
|
95
|
+
version: '1.21'
|
|
96
|
+
- !ruby/object:Gem::Dependency
|
|
97
|
+
name: lefthook
|
|
98
|
+
requirement: !ruby/object:Gem::Requirement
|
|
99
|
+
requirements:
|
|
100
|
+
- - "~>"
|
|
101
|
+
- !ruby/object:Gem::Version
|
|
102
|
+
version: '2.1'
|
|
103
|
+
type: :development
|
|
104
|
+
prerelease: false
|
|
105
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
106
|
+
requirements:
|
|
107
|
+
- - "~>"
|
|
108
|
+
- !ruby/object:Gem::Version
|
|
109
|
+
version: '2.1'
|
|
110
|
+
description: |
|
|
111
|
+
A GTK4 desktop viewer built on the rubycam library: live video preview,
|
|
112
|
+
sliders for gimbal/zoom/image controls, and a full OBSBOT Tiny control
|
|
113
|
+
panel — privacy sleep/wake, AI tracking modes, gimbal presets, tracking
|
|
114
|
+
speed, HDR, exposure modes and a raw-hex debug console.
|
|
115
|
+
email:
|
|
116
|
+
- nathankidd@hey.com
|
|
117
|
+
executables:
|
|
118
|
+
- rubycam-gtk
|
|
119
|
+
extensions: []
|
|
120
|
+
extra_rdoc_files: []
|
|
121
|
+
files:
|
|
122
|
+
- LICENSE
|
|
123
|
+
- README.md
|
|
124
|
+
- exe/rubycam-gtk
|
|
125
|
+
- lib/rubycam/gtk.rb
|
|
126
|
+
- lib/rubycam/gtk/camera_app.rb
|
|
127
|
+
- lib/rubycam/gtk/obsbot_panel.rb
|
|
128
|
+
- lib/rubycam/gtk/video4linux.rb
|
|
129
|
+
- rubycam-gtk.gemspec
|
|
130
|
+
homepage: https://github.com/ruby-gtk-project/rubycam
|
|
131
|
+
licenses:
|
|
132
|
+
- MIT
|
|
133
|
+
metadata:
|
|
134
|
+
source_code_uri: https://github.com/ruby-gtk-project/rubycam
|
|
135
|
+
changelog_uri: https://github.com/ruby-gtk-project/rubycam/releases
|
|
136
|
+
rubygems_mfa_required: 'true'
|
|
137
|
+
rdoc_options: []
|
|
138
|
+
require_paths:
|
|
139
|
+
- lib
|
|
140
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
|
141
|
+
requirements:
|
|
142
|
+
- - ">="
|
|
143
|
+
- !ruby/object:Gem::Version
|
|
144
|
+
version: 3.1.0
|
|
145
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
|
146
|
+
requirements:
|
|
147
|
+
- - ">="
|
|
148
|
+
- !ruby/object:Gem::Version
|
|
149
|
+
version: '0'
|
|
150
|
+
requirements: []
|
|
151
|
+
rubygems_version: 3.7.2
|
|
152
|
+
specification_version: 4
|
|
153
|
+
summary: GTK4 viewer for Rubycam webcams (OBSBOT Tiny live preview and controls)
|
|
154
|
+
test_files: []
|