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 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
@@ -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"
@@ -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: []