Class: Rubycam::GTK::CameraApp

Inherits:
Object
  • Object
show all
Defined in:
lib/rubycam/gtk/camera_app.rb

Constant Summary collapse

FRAME_INTERVAL_MS =
16
SLIDER_KEYS =
%i[pan_absolute tilt_absolute zoom_absolute
brightness contrast saturation sharpness].freeze
FULL_SIZE =
[1380, 640].freeze
COMPACT_SIZE =
[340, 640].freeze
STATUS_FAILURES_BEFORE_RECONNECT =

After this many consecutive failed polls the camera is treated as gone (a transient EIO during privacy-sleep transitions must not trigger a disruptive reconnect).

3
WATCHDOG_SECONDS =

Longer than the camera's first-frame latency, so warmup after a stream (re)start is never mistaken for a dead stream.

5

Instance Method Summary collapse

Constructor Details

#initialize(device_path) ⇒ CameraApp

Returns a new instance of CameraApp.



19
20
21
22
# File 'lib/rubycam/gtk/camera_app.rb', line 19

def initialize(device_path)
  @device_path = device_path
  @awake = true
end

Instance Method Details

#appObject



78
# File 'lib/rubycam/gtk/camera_app.rb', line 78

def app = @app ||= Gtk::Application.new('org.rubycam.viewer', :default_flags)

#buildObject



24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
# File 'lib/rubycam/gtk/camera_app.rb', line 24

def build
  app.tap do
    app.signal_connect('activate') do
      app.add_window(window)

      window.tap do |win|
        win.title = begin
          device.card
        rescue SystemCallError
          'Rubycam (no camera)'
        end
        win.set_default_size(*FULL_SIZE)
        win.child = layout

        win.signal_connect('close-request') do
          GLib::Source.remove(@pump) if @pump
          close_device
          false
        end
      end

      layout.tap do |l|
        l.append(picture)
        l.append(sidebar)
        l.append(panel.build)

        sidebar.tap do |side|
          sliders.each_value { |s| side.append(s) }
          side.append(reset_button)
          @sliders_in_sidebar = !sliders.empty?

          reset_button.tap do |btn|
            btn.signal_connect('clicked') { reset_controls }
          end
        end
      end

      start_frame_pump
      start_status_sync
      window.present
    end
  end
end

#close_deviceObject



207
208
209
210
211
212
213
214
# File 'lib/rubycam/gtk/camera_app.rb', line 207

def close_device
  @device&.close
rescue SystemCallError
  nil
ensure
  @device = nil
  @obsbot = nil
end

#control_value(key) ⇒ Object



237
238
239
240
241
# File 'lib/rubycam/gtk/camera_app.rb', line 237

def control_value(key)
  device.controls[key].value
rescue SystemCallError
  nil
end

#deviceObject

Accepts a device path, a /dev name, or a card/bus substring (e.g. 'OBSBOT Tiny 2').



70
71
72
73
74
75
76
# File 'lib/rubycam/gtk/camera_app.rb', line 70

def device
  @device ||= (Rubycam::Device.find(@device_path) or
               raise Errno::ENOENT, @device_path).tap do |cam|
    cam.set_format(width: 1280, height: 720, pixel_format: 'MJPG')
    cam.set_fps(30)
  end
end

#layoutObject



80
# File 'lib/rubycam/gtk/camera_app.rb', line 80

def layout = @layout ||= Gtk::Box.new(:horizontal, 12)

#monotonic_nowObject



280
# File 'lib/rubycam/gtk/camera_app.rb', line 280

def monotonic_now = Process.clock_gettime(Process::CLOCK_MONOTONIC)

#obsbotObject



137
# File 'lib/rubycam/gtk/camera_app.rb', line 137

def obsbot = @obsbot ||= Rubycam::Obsbot.new(device)

#panelObject



82
83
84
85
86
87
# File 'lib/rubycam/gtk/camera_app.rb', line 82

def panel
  @panel ||= Rubycam::GTK::ObsbotPanel.new(bot: -> { obsbot },
                             on_wake: -> { wake_camera },
                             on_sleep: -> { sleep_camera },
                             on_compact_toggle: -> { toggle_compact })
end

#pictureObject



89
90
91
92
93
94
95
96
97
# File 'lib/rubycam/gtk/camera_app.rb', line 89

def picture
  @picture ||= Gtk::Picture.new.tap do |pic|
    pic.hexpand = true
    pic.vexpand = true
    pic.margin_start = 12
    pic.margin_top = 12
    pic.margin_bottom = 12
  end
end

#pump_one_frameObject



263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
# File 'lib/rubycam/gtk/camera_app.rb', line 263

def pump_one_frame
  device.poll_frame.then do |frame|
    if frame
      @last_frame_at = monotonic_now
      show_frame(frame)
    elsif @awake && monotonic_now - (@last_frame_at || 0) > WATCHDOG_SECONDS
      # Video should be flowing but is not (e.g. the camera slept and woke
      # outside our control): rebuild the stream until frames return.
      @last_frame_at = monotonic_now
      warn 'video stalled: rebuilding stream'
      device.restart_streaming
    end
  end
rescue SystemCallError, RuntimeError
  nil # stream hiccup (e.g. privacy sleep transition): keep the pump alive
end

#reconnect_cameraObject

The camera is gone (unplugged or re-enumerated): drop the dead handle and try to find it again on every poll until it returns.



195
196
197
198
199
200
201
202
203
204
205
# File 'lib/rubycam/gtk/camera_app.rb', line 195

def reconnect_camera
  panel.update(nil)
  close_device
  device.then do
    @failures = 0
    @last_frame_at = monotonic_now
    restore_sliders
  end
rescue SystemCallError
  nil
end

#reset_buttonObject



115
116
117
118
119
# File 'lib/rubycam/gtk/camera_app.rb', line 115

def reset_button
  @reset_button ||= Gtk::Button.new(label: 'Reset to defaults').tap do |btn|
    btn.margin_top = 12
  end
end

#reset_controlsObject



226
227
228
229
230
231
232
233
# File 'lib/rubycam/gtk/camera_app.rb', line 226

def reset_controls
  sliders.each_key do |key|
    device.controls[key].then do |ctrl|
      set_control_safely(key, ctrl.default)
      slider_scale(key).value = ctrl.default
    end
  end
end

#restore_slidersObject

When the app started without a camera the sidebar has no sliders yet; build and attach them on first successful (re)connect.



218
219
220
221
222
223
224
# File 'lib/rubycam/gtk/camera_app.rb', line 218

def restore_sliders
  unless @sliders_in_sidebar || sliders.empty?
    sliders.each_value { |s| sidebar.append(s) }
    sidebar.reorder_child_after(reset_button, sidebar.last_child)
    @sliders_in_sidebar = true
  end
end

#set_control_safely(key, value) ⇒ Object

Control writes can fail transiently (EIO/EBUSY) while the camera enters or leaves privacy sleep; an exception here must never kill the main loop.



245
246
247
248
249
# File 'lib/rubycam/gtk/camera_app.rb', line 245

def set_control_safely(key, value)
  device.controls[key].value = value
rescue SystemCallError
  nil
end

#show_frame(jpeg) ⇒ Object



282
283
284
285
286
287
288
289
290
# File 'lib/rubycam/gtk/camera_app.rb', line 282

def show_frame(jpeg)
  GdkPixbuf::PixbufLoader.new.tap do |loader|
    loader.write(jpeg)
    loader.close
    picture.pixbuf = loader.pixbuf
  end
rescue GLib::Error
  nil # drop corrupt frames rather than crashing the pump
end


99
100
101
102
103
104
105
# File 'lib/rubycam/gtk/camera_app.rb', line 99

def sidebar
  @sidebar ||= Gtk::Box.new(:vertical, 6).tap do |box|
    box.margin_top = 12
    box.margin_bottom = 12
    box.width_request = 260
  end
end

#sleep_cameraObject

Sleep/wake go through OBSBOT's vendor extension unit — the same command its official software sends. This also wakes the camera after it was put to sleep by physically folding it down.



151
152
153
154
155
156
# File 'lib/rubycam/gtk/camera_app.rb', line 151

def sleep_camera
  @awake = false
  obsbot.sleep!
rescue SystemCallError
  nil
end

#slider_for(ctrl) ⇒ Object



121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
# File 'lib/rubycam/gtk/camera_app.rb', line 121

def slider_for(ctrl)
  Gtk::Box.new(:vertical, 2).tap do |box|
    box.append(Gtk::Label.new(ctrl.key.to_s).tap { |l| l.halign = :start })
    box.append(
      Gtk::Scale.new(:horizontal,
                     Gtk::Adjustment.new(ctrl.value, ctrl.min, ctrl.max,
                                         ctrl.step, ctrl.step * 10, 0)).tap do |scale|
        scale.draw_value = false
        scale.signal_connect('value-changed') do
          set_control_safely(ctrl.key, scale.value.round)
        end
      end
    )
  end
end

#slider_scale(key) ⇒ Object



235
# File 'lib/rubycam/gtk/camera_app.rb', line 235

def slider_scale(key) = sliders[key].last_child

#slidersObject



107
108
109
110
111
112
113
# File 'lib/rubycam/gtk/camera_app.rb', line 107

def sliders
  @sliders ||= SLIDER_KEYS.filter_map do |key|
    device.controls[key]&.then { |ctrl| [key, slider_for(ctrl)] }
  end.to_h
rescue SystemCallError
  {}
end

#start_frame_pumpObject



251
252
253
254
255
256
257
# File 'lib/rubycam/gtk/camera_app.rb', line 251

def start_frame_pump
  @last_frame_at = monotonic_now
  @pump = GLib::Timeout.add(FRAME_INTERVAL_MS) do
    pump_one_frame if @device
    GLib::Source::CONTINUE
  end
end

#start_status_syncObject



173
174
175
176
177
178
# File 'lib/rubycam/gtk/camera_app.rb', line 173

def start_status_sync
  GLib::Timeout.add_seconds(2) do
    sync_status
    GLib::Source::CONTINUE
  end
end

#sync_statusObject

Poll the camera and mirror its state into the panel, so the UI follows changes made outside the app (folding the camera down, other software).



182
183
184
185
186
187
188
189
190
191
# File 'lib/rubycam/gtk/camera_app.rb', line 182

def sync_status
  obsbot.status.then do |status|
    @awake = !status[:asleep]
    @failures = 0
    panel.update(status)
  end
rescue SystemCallError
  @failures = (@failures || 0) + 1
  reconnect_camera if @failures >= STATUS_FAILURES_BEFORE_RECONNECT
end

#toggle_compactObject

Dashboard ⇄ compact widget mode: compact hides the preview and the V4L2 sliders, leaving just the OBSBOT panel (like Tiny4Linux's widget mode).



141
142
143
144
145
146
# File 'lib/rubycam/gtk/camera_app.rb', line 141

def toggle_compact
  @compact = !@compact
  picture.visible = !@compact
  sidebar.visible = !@compact
  window.set_default_size(*(@compact ? COMPACT_SIZE : FULL_SIZE))
end

#wake_cameraObject



158
159
160
161
162
163
164
165
166
# File 'lib/rubycam/gtk/camera_app.rb', line 158

def wake_camera
  @awake = true
  obsbot.wake!
  # Grace period for the stream to resume on its own before the
  # watchdog rebuilds it.
  @last_frame_at = monotonic_now
rescue SystemCallError
  nil
end

#windowObject



79
# File 'lib/rubycam/gtk/camera_app.rb', line 79

def window = @window ||= Gtk::ApplicationWindow.new(app)