From 2bceadde13bcfd58a51774ccccaf3ae6d11b6611 Mon Sep 17 00:00:00 2001 From: sbkelley Date: Thu, 27 Nov 2025 23:44:41 -0500 Subject: [PATCH 1/1] Initial --- .gitignore | 3 + mockup.txt | 38 +++++ src/obs_ctl/__init__.py | 264 +++++++++++++++++++++++++++++++++ src/obs_ctl/__main__.py | 4 + src/obs_ctl/const.py | 2 + src/obs_ctl/data/layout.tcss | 27 ++++ src/obs_ctl/log.py | 32 ++++ src/obs_ctl/widget/__init__.py | 200 +++++++++++++++++++++++++ src/test/__init__.py | 0 src/test/main_test.py | 0 src/vscode_run_entrypoint.py | 3 + 11 files changed, 573 insertions(+) create mode 100644 .gitignore create mode 100644 mockup.txt create mode 100644 src/obs_ctl/__init__.py create mode 100644 src/obs_ctl/__main__.py create mode 100644 src/obs_ctl/const.py create mode 100644 src/obs_ctl/data/layout.tcss create mode 100644 src/obs_ctl/log.py create mode 100644 src/obs_ctl/widget/__init__.py create mode 100644 src/test/__init__.py create mode 100644 src/test/main_test.py create mode 100644 src/vscode_run_entrypoint.py diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..9eaf7a4 --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +.venv +__pycache__ +.vscode \ No newline at end of file diff --git a/mockup.txt b/mockup.txt new file mode 100644 index 0000000..2587a81 --- /dev/null +++ b/mockup.txt @@ -0,0 +1,38 @@ +messages: +action_stop_stream +action_start_stream +action_stop_recording +action_start_recording +action_pause_recording +action_unpause_recording + +labeled_number + h_labeled_number + v_labeled_number + +status_light +----------------------------------------- +| Status | Controls | +|Stream: [ ] | Stop Start | req.get_record_status().output_active +----------------------------------------- .output_paused +|Recording: [*] | Stop Start | req.get_stream_status().output_active +| | Pause Unpause | +----------------------------------------- +| More Statistics | +----------------------------------------- +|CPU Usage | Memory Usage | FPS | req.get_stats().cpu_usage in % +|6.0% | 298MB | 30.00 | .memory_usage in MB +----------------------------------------- .active_fps +|Disk full in (approx): 500h .available_disk_space (/1024=GB) -- (req.get_stats().available_disk_space*1024*1024) / (a.output_bytes / (a.output_duration)) / 1000 / 60 / 60 +|Dropped Frames (Network): 0/24840 (0.0%) req.get_stream_status().output_skipped_frames/*_total_frames +----------------------------------------- +| Frames missed | +----------------------------------------- +| Rendering Lag | Encoding Lag | +| 11/45607(0.0%) | 0/44582 (0.0%) | +----------------------------------------- + req.get_stats().render_skipped_frames + .render_total_Frames + .output_* for encoding YES! + + diff --git a/src/obs_ctl/__init__.py b/src/obs_ctl/__init__.py new file mode 100644 index 0000000..c370fa3 --- /dev/null +++ b/src/obs_ctl/__init__.py @@ -0,0 +1,264 @@ +from dataclasses import dataclass +import importlib.resources + +import obsws_python as obs +import obsws_python.error as obs_error +from textual.app import App +from textual.containers import HorizontalGroup, VerticalGroup, Vertical, Horizontal, Container +from textual.widgets import Footer, Header, Label, Placeholder + +from . import log, data +from .widget import StreamControls, RecordControls, HorizontalLabels, VerticalLabels + +logger = log.getLogger(__name__) +obs_req_client = obs.ReqClient() +obs_evnt_client = obs.EventClient() + +@dataclass(frozen=True) +class OBSStats: + record_output_active: bool + record_output_paused: bool + record_output_bytes: float + record_output_duration: float + stream_output_active: bool + stream_output_skipped_frames: float + stream_output_total_frames: float + stats_render_skipped_frames: float + stats_render_total_frames: float + stats_output_skipped_frames: float + stats_output_total_frames: float + cpu_usage_percent: float + memory_usage_mb: float + active_fps: float + available_disk_space: float + + @property + def estimated_hours_remaining(self): + try: + output = ( + ( + self.available_disk_space*1024*1024) / \ + ( + self.record_output_bytes / \ + self.record_output_duration + ) + ) / 1000 / 60 / 60 + except: + output = "N/A" + + return output + +class OBSCtlApp(App): + logger.debug(importlib.resources.files(data)) + CSS_PATH = importlib.resources.files(data).joinpath("layout.tcss") + + def on_mount(self): + self.set_interval(1., self.process_obs_stats) + + + def compose(self): + yield Header() + yield Footer() + logger.debug("Composing OBSCtlApp") + yield Container( + Label("Controls", id="controls_label", classes="labels"), + StreamControls(id="stream_ctl", classes="controls"), + RecordControls(id="record_ctl", classes="controls"), + id="controls", + classes="blocks" + ) + yield Container( + Label("Statistics", id="stats1_label", classes="labels"), + Horizontal( + VerticalLabels("CPU Usage", "-.-%", id="cpu_usage", classes="stats"), + VerticalLabels("Memory Usage", "-b", id="memory_usage", classes="stats"), + VerticalLabels("FPS", "--.--", id="fps", classes="stats") + ), + classes="blocks", + ) + yield Container( + Label("More Statistics", id="stats2_label", classes="labels"), + HorizontalLabels("Disk full in (approx):", "--:--:--", id="disk_full", classes="stats"), + HorizontalLabels("Dropped frames (network):", "-/- (-.-%)", id="stream_dropped_frames", classes="stats"), + classes="blocks", + ) + yield Container( + Label("Frames Missed", id="missed_label", classes="labels"), + Horizontal( + VerticalLabels("Rendering Lag", "-/- (-.-%)", id="rendering_lag", classes="stats"), + VerticalLabels("Encoding Lag", "-/- (-.-%)", id="encoding_lag", classes="stats"), + ), + classes="blocks" + ) + + + def on_stream_controls_stopped(self, message): + logger.debug("Stream Stopped message rcv'd") + try: + obs_req_client.stop_stream() + except obs_error.OBSSDKRequestError as err: + if err.code == 501: + logger.debug("Stream already stopped.") + else: + raise + + + def on_stream_controls_started(self, message): + logger.debug("Stream Started message rcv'd") + try: + obs_req_client.start_stream() + except obs_error.OBSSDKRequestError as err: + if err.code == 500: + logger.debug("Stream already started.") + else: + raise + + + def on_record_controls_stopped(self, message): + logger.debug("Record Stopped message rcv'd") + try: + obs_req_client.stop_record() + except obs_error.OBSSDKRequestError as err: + if err.code == 501: + logger.debug("Record already stopped.") + else: + raise + + + def on_record_controls_started(self, message): + logger.debug("Record Started message rcv'd") + try: + obs_req_client.start_record() + except obs_error.OBSSDKRequestError as err: + if err.code == 500: + logger.debug("Record already started.") + else: + raise + + + def on_record_controls_paused(self, message): + logger.debug("Record Paused message rcv'd") + try: + obs_req_client.pause_record() + except obs_error.OBSSDKRequestError as err: + if err.code == 502: + logger.debug("Record already paused.") + else: + raise + + + def on_record_controls_unpaused(self, message): + logger.debug("Record Unpaused message rcv'd") + try: + obs_req_client.resume_record() + except obs_error.OBSSDKRequestError as err: + if err.code == 503: + logger.debug("Record already unpaused.") + else: + raise + + + @staticmethod + def get_obs_stats(): + record_status = obs_req_client.get_record_status() + stream_status = obs_req_client.get_stream_status() + obs_stats = obs_req_client.get_stats() + # logger.debug(record_status) + # logger.debug(stream_status) + # logger.debug(obs_stats.attrs()) + output = OBSStats( + record_output_active=record_status.output_active, + record_output_paused=record_status.output_paused, + record_output_bytes=record_status.output_bytes, + record_output_duration=record_status.output_duration, + stream_output_active=stream_status.output_active, + stream_output_skipped_frames=stream_status.output_skipped_frames, + stream_output_total_frames=stream_status.output_total_frames, + stats_render_skipped_frames=obs_stats.render_skipped_frames, + stats_render_total_frames=obs_stats.render_total_frames, + stats_output_skipped_frames=obs_stats.output_skipped_frames, + stats_output_total_frames=obs_stats.output_total_frames, + cpu_usage_percent=obs_stats.cpu_usage, + memory_usage_mb=obs_stats.memory_usage, + active_fps=obs_stats.active_fps, + available_disk_space=obs_stats.available_disk_space, + ) + + return output + + + def update_monitors(self, obs_stats: OBSStats): + stream_ctl: StreamControls = self.query_exactly_one("#stream_ctl") + record_ctl: RecordControls = self.query_exactly_one("#record_ctl") + cpu_usage: VerticalLabels = self.query_exactly_one("#cpu_usage") + memory_usage: VerticalLabels = self.query_exactly_one("#memory_usage") + active_fp: VerticalLabels = self.query_exactly_one("#fps") + disk_full: HorizontalLabels = self.query_exactly_one("#disk_full") + stream_dropped_frames: HorizontalLabels = self.query_exactly_one("#stream_dropped_frames") + rendering_lag: VerticalLabels = self.query_exactly_one("#rendering_lag") + encoding_lag: VerticalLabels = self.query_exactly_one("#encoding_lag") + + stream_ctl.set_light(obs_stats.stream_output_active) + record_ctl.set_light(obs_stats.record_output_active) + record_ctl.set_pause_light(obs_stats.record_output_paused) + cpu_usage.text = f"{obs_stats.cpu_usage_percent:0.5}%" + memory_usage.text = f"{obs_stats.memory_usage_mb:0.5}MB" + active_fp.text = f"{obs_stats.active_fps}" + disk_full.text = f"{obs_stats.estimated_hours_remaining:0.5} hours" + stream_dropped_frames.text = f"{obs_stats.stream_output_skipped_frames}/{obs_stats.stream_output_total_frames}" + rendering_lag.text = f"{obs_stats.stats_render_skipped_frames}/{obs_stats.stats_render_total_frames}" + encoding_lag.text = f"{obs_stats.stats_output_skipped_frames}/{obs_stats.stats_output_total_frames}" + + + def process_obs_stats(self): + logger.debug("Processing OBS stats") + stats = self.get_obs_stats() + self.update_monitors(stats) + + +class OBSLayoutTest(App): + CSS_PATH = importlib.resources.files(data).joinpath("layout.tcss") + def compose(self): + yield Header() + yield Footer() + logger.debug("Composing OBSCtlApp") + yield Vertical( + Placeholder("Controls Label", id="controls_label", classes="labels"), + Placeholder("SteamControls", id="stream_ctl", classes="controls"), + Placeholder("RecordControls", id="record_ctl", classes="controls"), + id="controls", + classes="block" + ) + yield Vertical( + Placeholder("Statistics Label", id="stats1_label", classes="labels"), + Horizontal( + Placeholder("CPU Usage", id="cpu_usage", classes="stats"), + Placeholder("Memory Usage", id="memory_usage", classes="stats"), + Placeholder("FPS", id="fps", classes="stats"), + classes="block", + ) + ) + yield Vertical( + Placeholder("More Statistics Label", id="stats2_label", classes="labels"), + Placeholder("Disk full in (approx):", id="disk_full", classes="stats"), + Placeholder("/Dropped frames (network):", id="stream_dropped_frames", classes="stats"), + classes="blocks", + ) + yield Vertical( + Placeholder("Frames Missed Label", id="missed_label", classes="labels"), + Horizontal( + Placeholder("Rendering Lag", id="rendering_lag", classes="stats"), + Placeholder("Encoding Lag", id="encoding_lag", classes="stats"), + classes="stats" + ), + classes="blocks" + ) + + +def main(): + app = OBSCtlApp() + # app = OBSLayoutTest() + logger.debug("App entrypoint reached") + logger.debug(f"Request Client: {obs_req_client}") + logger.debug(f"Event Client: {obs_evnt_client}") + app.run() \ No newline at end of file diff --git a/src/obs_ctl/__main__.py b/src/obs_ctl/__main__.py new file mode 100644 index 0000000..878197e --- /dev/null +++ b/src/obs_ctl/__main__.py @@ -0,0 +1,4 @@ +from . import main + +if __name__ == "__main__": + main() \ No newline at end of file diff --git a/src/obs_ctl/const.py b/src/obs_ctl/const.py new file mode 100644 index 0000000..a91231b --- /dev/null +++ b/src/obs_ctl/const.py @@ -0,0 +1,2 @@ +APPNAME = "obs-ctl" +APPAUTHOR = "skyebee" \ No newline at end of file diff --git a/src/obs_ctl/data/layout.tcss b/src/obs_ctl/data/layout.tcss new file mode 100644 index 0000000..0d872bb --- /dev/null +++ b/src/obs_ctl/data/layout.tcss @@ -0,0 +1,27 @@ +OBSCtlApp { + layout: grid; + grid-size: 2 2; +} + +StreamControls { + layout:horizontal; + max-height:1; + & Button { + width:1fr; + } +} +RecordControls { + layout:vertical; + max-height:2; + & Button { + width:1fr; + } + # & RadioButton { + # max-width:20; + # } +} + +VerticalLabels { + background:#ffffff; + max-width:5; +} \ No newline at end of file diff --git a/src/obs_ctl/log.py b/src/obs_ctl/log.py new file mode 100644 index 0000000..31d9d66 --- /dev/null +++ b/src/obs_ctl/log.py @@ -0,0 +1,32 @@ +import logging +from pathlib import Path + +import platformdirs + +from .const import APPNAME, APPAUTHOR + +VERBOSE_LEVELS = ["CRITICAL", "ERROR", "WARNING", "INFO", "DEBUG"] + +dirs = platformdirs.PlatformDirs(APPNAME, APPAUTHOR, ensure_exists=True) +logfile_fmt = logging.Formatter('%(asctime)s : %(name)s : %(levelname)s : %(message)s') +logfile_hdlr = logging.FileHandler(Path(dirs.user_log_dir).joinpath("projectstruct.log")) +logfile_hdlr.setFormatter(logfile_fmt) +logfile_hdlr.setLevel("DEBUG") + +console_fmt = logging.Formatter('[%(levelname)s] %(message)s') +console_hdlr = logging.StreamHandler() +console_hdlr.setFormatter(console_fmt) +console_hdlr.setLevel("ERROR") +console_hdlr.set_name("console") + +logging.basicConfig( + handlers=[logfile_hdlr, console_hdlr], + level="DEBUG", +) + +only_me_filter = logging.Filter("obs_ctl") +console_hdlr.addFilter(only_me_filter) +logfile_hdlr.addFilter(only_me_filter) + +def getLogger(name): + return logging.getLogger(name) \ No newline at end of file diff --git a/src/obs_ctl/widget/__init__.py b/src/obs_ctl/widget/__init__.py new file mode 100644 index 0000000..d5750e4 --- /dev/null +++ b/src/obs_ctl/widget/__init__.py @@ -0,0 +1,200 @@ +from textual.app import App, ComposeResult +from textual.containers import Horizontal, Vertical, Grid, Container +from textual.message import Message +from textual.reactive import reactive +from textual.widgets import Label, RadioButton, Button +from textual.widget import Widget + +from .. import log + +logger = log.getLogger(__name__) + + +class SRControls(Widget): + light_active = reactive(False) + class MsgBase(Message): + def __init__(self, srcontrols: "SRControls"): + logger.debug(f"{self} sent from {srcontrols}") + self.srcontrol = srcontrols + super().__init__() + + def on_button_pressed(self, event: Button.Pressed): + # light: RadioButton = self.query_exactly_one("#light") + if event.button.id == "start": + logger.debug(f"Start pressed on {self}") + self.post_message(self.Started(self)) + # light.value = True + elif event.button.id == "stop": + logger.debug(f"Stop pressed on {self}") + self.post_message(self.Stopped(self)) + # light.value = False + + + def set_light(self, value: bool) -> None: + light: RadioButton = self.query_exactly_one("#light") + light.value = value + + +class StreamControls(SRControls): + # DEFAULT_CSS = """ + # StreamControls { + # layout: grid; + # grid-size:3 1; + # height:1fr; + # width:1fr; + # # grid-columns:2fr 1fr 1fr; + # # grid-rows:1fr; + # } + # """ + class Started(SRControls.MsgBase): ... + class Stopped(SRControls.MsgBase): ... + def compose(self) -> ComposeResult: + logger.debug("Composing StreamControls") + yield RadioButton( + label="Streaming", + value=False, + button_first=False, + id="light", + compact=True, + disabled=True + ) + yield Button( + label="Start", + id="start", + variant="success", + compact=True, + ) + yield Button( + label="Stop", + id="stop", + variant="error", + compact=True, + ) + + +class RecordControls(SRControls): + # DEFAULT_CSS = """ + # RecordControls { + # layout: grid; + # grid-size:3 2; + # height:1fr; + # width:1fr; + # grid-columns: 2fr 1fr 1fr; + # grid-rows: 1fr 1fr; + # } + # """ + + class Started(SRControls.MsgBase): ... + class Stopped(SRControls.MsgBase): ... + class Paused(SRControls.MsgBase): ... + class Unpaused(SRControls.MsgBase): ... + + + def compose(self): + logger.debug("Composing RecordControls") + with Horizontal(): + yield RadioButton( + label="Recording", + value=False, + button_first=False, + compact=True, + disabled=True, + id="light", + ) + yield Button( + "Start", + id="start", + variant="success", + compact = True, + ) + yield Button( + "Stop", + id="stop", + variant="error", + compact=True, + ) + with Container(): + yield RadioButton( + label="Paused", + value=False, + button_first=False, + id="pause_light", + compact=True, + disabled=True + ) + yield Button( + "Pause", + id="pause_record", + variant="error", + compact=True, + ) + yield Button( + "Unpause", + id="unpause_record", + variant="success", + compact=True, + ) + + + def on_button_pressed(self, event: Button.Pressed): + if event.button.id == "pause_record": + logger.debug(f"Pause pressed on {self}") + self.post_message(self.Paused(self)) + elif event.button.id == "unpause_record": + logger.debug(f"Unpause pressed on {self}") + self.post_message(self.Unpaused(self)) + + + def set_pause_light(self, value): + light: RadioButton = self.query_exactly_one("#pause_light") + light.value = value + + +class HorizontalLabels(Widget): + # DEFAULT_CSS = """ + # HorizontalLabels { + # layout: horizontal; + # height:1fr; + # width:1fr; + # } + # """ + text = reactive("") + def __init__(self, label_one, label_two, *children, name = None, id = None, classes = None, disabled = False, markup = True): + self._label_one_param = label_one + self._label_two_param = label_two + super().__init__(*children, name=name, id=id, classes=classes, disabled=disabled, markup=markup) + + + def compose(self): + yield Label(self._label_one_param, id="constant") + yield Label(self._label_two_param, id="variable") + + + def watch_text(self, text: str): + logger.debug(f"Setting label {self} text to {text}") + label: Label = self.query_exactly_one("#variable") + label.update(text) + +class VerticalLabels(Widget): + # DEFAULT_CSS = """ + # VerticalLabels { + # layout: vertical; + # height: 1fr; + # width:1fr; + # } + # """ + text = reactive("") + def __init__(self, label_one, label_two, *children, name = None, id = None, classes = None, disabled = False, markup = True): + self._label_one_param = label_one + self._label_two_param = label_two + super().__init__(*children, name=name, id=id, classes=classes, disabled=disabled, markup=markup) + + def compose(self): + yield Label(self._label_one_param, id="constant") + yield Label(self._label_two_param, id="variable") + + + def watch_text(self, text: str): + logger.debug(f"Setting label {self} text to {text}") + label: Label = self.query_exactly_one("#variable") + label.update(text) \ No newline at end of file diff --git a/src/test/__init__.py b/src/test/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/test/main_test.py b/src/test/main_test.py new file mode 100644 index 0000000..e69de29 diff --git a/src/vscode_run_entrypoint.py b/src/vscode_run_entrypoint.py new file mode 100644 index 0000000..e87038c --- /dev/null +++ b/src/vscode_run_entrypoint.py @@ -0,0 +1,3 @@ +import obs_ctl + +obs_ctl.main() \ No newline at end of file -- 2.47.3