diff --git a/bin/bin/obs-zoom-and-follow.py b/bin/bin/obs-zoom-and-follow.py new file mode 100644 index 0000000..33c6bf7 --- /dev/null +++ b/bin/bin/obs-zoom-and-follow.py @@ -0,0 +1,836 @@ +""" +This script is intended to be called from OBS Studio. Provides +mouse-based zoom and tracking for desktop/monitor/window/game sources. +For more information please visit: +https://github.com/tryptech/obs-zoom-and-follow +""" + +description = ( + "Crops and resizes a source to simulate a zoomed in tracked to"\ + " the mouse.\n\n" + + "Set activation hotkey in Settings.\n\n" + + "Active Border enables lazy/smooth tracking; border size"\ + "calculated as percent of smallest dimension. " + + "Border of 50% keeps mouse locked in the center of the zoom"\ + " frame\n\n" + + "By tryptech (@yo_tryptech / tryptech#1112)\n\n" + + "v2022.09.26" +) + +import obspython as obs +import pywinctl as pwc # version >=0.0.38 +from platform import system +from math import sqrt +from json import load, loads + +c = pwc.getMousePos +get_position = lambda: [c().x, c().y] +zoom_id_tog = None +follow_id_tog = None +new_source = True +ZOOM_NAME_TOG = "zoom.toggle" +FOLLOW_NAME_TOG = "follow.toggle" +ZOOM_DESC_TOG = "Enable/Disable Mouse Zoom" +FOLLOW_DESC_TOG = "Enable/Disable Mouse Follow" +USE_MANUAL_MONITOR_SIZE = "Manual Monitor Size" + + +# ------------------------------------------------------------------- + +class CursorWindow: + flag = lock = track = update = True + zi_timer = zo_timer = 0 + windows = window_titles = monitor = window = window_handle \ + = window_name = '' + monitors = pwc.getAllScreens() + monitors_key = list(dict.keys(monitors)) + monitor_override = manual_offset = monitor_size_override = False + monitor_override_id = '' + source_w = source_h = source_x = source_y = zoom_x = zoom_y \ + = source_x_override = source_y_override = 0 + refresh_rate = int(obs.obs_get_frame_interval_ns()/1000000) + source_name = source_type = '' + zoom_w = 1280 + zoom_h = 720 + active_border = 0.15 + max_speed = 160 + smooth = 1.0 + zoom_time = 300 + + def update_sources(self): + """ + Update the list of Windows and Monitors from PyWinCtl + """ + self.windows = pwc.getAllWindows() + self.monitors = pwc.getAllScreens() + self.monitors_key = list(dict.keys(self.monitors)) + + def update_window_dim(self, window): + """ + Update the stored window dimensions to those of the selected + window + + :param window: Window with new dimensions + """ + print("Updating stored dimensions to match current dimensions") + if window != None: + # FIXME: on macos get window bounds results in an error and + # does not work + # NSInternalInconsistencyException - NSWindow drag regions + # should only be invalidated on the Main Thread! + window_dim = window.getClientFrame() + if (self.source_w != window_dim.right - window_dim.left + or self.source_h != window_dim.bottom - window_dim.top + or self.source_x != window_dim.left + or self.source_y != window_dim.top): + print("OLD") + print("Width, Height, X, Y") + print(f"{self.source_w}, {self.source_h}, {self.source_x},"\ + f" {self.source_y}") + self.source_w = window_dim.right - window_dim.left + self.source_h = window_dim.bottom - window_dim.top + self.source_x = window_dim.left + self.source_y = window_dim.top + print("NEW") + print("Width, Height, X, Y") + print(f"{self.source_w}, {self.source_h}, {self.source_x},"\ + f" {self.source_y}") + else: + print("Dimensions did not change") + + def update_monitor_dim(self, monitor): + """ + Update the stored dimensions based on the selected monitor + + :param monitor: Single monitor as returned from the PyWinCtl + Monitor function getAllScreens() + """ + if self.monitor_size_override: + print("Manual monitor size enabled") + print("Dimensions set to:") + print("Width, Height, X, Y") + self.source_x = monitor['pos'].x + self.source_y = monitor['pos'].y + print(f"{self.source_w}, {self.source_h}, {self.source_x}, \ + {self.source_y}") + else: + print(f"Updating stored dimensions to match monitor's dimensions | {monitor}") + if (self.source_w != monitor['size'].width + or self.source_h != monitor['size'].height + or self.source_x != monitor['pos'].x + or self.source_y != monitor['pos'].y): + print("OLD") + print("Width, Height, X, Y") + print(f"{self.source_w}, {self.source_h}, {self.source_x}, \ + {self.source_y}") + self.source_w = monitor['size'].width + self.source_h = monitor['size'].height + self.source_x = monitor['pos'].x + self.source_y = monitor['pos'].y + print("NEW") + print("Width, Height, X, Y") + print(f"{self.source_w}, {self.source_h}, {self.source_x}, \ + {self.source_y}") + else: + print("Dimensions did not change") + + def window_capture_mac(self, data): + """ + Window capture for macOS + macos uses an exclusive property 'window_name' pywinctl does not + report application windows correctly for macos yet, so we must + capture based on the actual window name and not based on the + application like we do for windows. + """ + + self.window_name = data.get('window_name') + + def monitor_capture_mac(self, data): + """ + The 'display' property is an index value and not the true + monitor id. It is only returned when there is more than one + monitor on your system. We will assume that the order of the + monitors returned from pywinctl are in the same order that OBS + is assigning the display index value. + """ + monitor_index = data.get('display', 0) + print(f"Retrieving monitor {monitor_index}") + for monitor in self.monitors.items(): + if (monitor['id'] == monitor_index): + print(f"Found monitor {monitor['id']} | {monitor}") + self.update_monitor_dim(monitor) + + def window_capture_gen(self, data): + """ + TODO: More Linux testing, specifically with handles Windows + capture for Windows and Linux. In Windows, application data is + stored as "Title:WindowClass:Executable" + """ + try: + # Assuming the OBS data is formatted correctly, we should + # be able to identify the window + # If New Source/Init + # If Handle Exists + # Else + if new_source: + # If new source selected / OBS initialize + # Build window, window_handle, and + # window_name + print("New Source") + print("Retrieving target window info from OBS") + self.window_name = data['window'].split(":")[0] + print(f"Searching for: {self.window_name}") + for w in self.windows: + if w.title == self.window_name: + window_match = w + self.window_handle = w.getHandle() + new_source = False + print(f"Window Match: {window_match.title}") + print("Window Match Handle:"\ + f" {str(self.window_handle)}") + if self.window_handle != '': + # If window handle is already stored + # Get window based on handle + # Check if name needs changing + print(f"Handle exists: {str(self.window_handle)}") + handle_match = False + for w in self.windows: + if w.getHandle() == self.window_handle: + handle_match = True + print(f"Found Handle: {str(w.getHandle())} | {self.window}") + window_match = w + if window_match.title != self.window: + print("Changing target title") + print(f"Old Title: {self.window_name}") + self.window_name = w.title + print(f"New Title: {self.window_name}") + if handle_match == False: + # TODO: If the handle no longer exists, + # eg. Window or App closed + raise + else: + print("I don't know how it gets here.") + window_match = None + # TODO: + except: + print(f"Source {self.source_name} has changed." \ + " Select new source window") + window_match = None + return window_match + + def monitor_capture_gen(self, data): + """ + If monitor override, update with monitor override + Else if no monitor ID, monitor does not exist + Else search for the monitor and update + """ + monitor_id = data.get('monitor', None) + if len(self.monitors.items()) == 1: + print("Only one monitor detected. Forcing override.") + for monitor in self.monitors.items(): + self.update_monitor_dim(monitor[1]) + elif self.monitor_override is True: + print(f"Monitor Override: {self.monitor_override}") + for monitor in self.monitors.items(): + if monitor[0] == self.monitors_key[ + self.monitor_override_id]: + self.update_monitor_dim(monitor[1]) + elif monitor_id == None: + print(f"Key 'monitor' does not exist in {data}") + else: + print(f"Searching for monitor {monitor_id}") + for monitor in self.monitors.items(): + if (monitor[1]['id'] == monitor_id): + print(f"Found monitor {monitor[1]['id']} | {monitor}") + self.update_monitor_dim(monitor[1]) + + def update_source_size(self): + """ + Adjusts the source size variables based on the source given + """ + global new_source + + try: + # Try to pull the data for the source object + # OBS stores the monitor index/window target in the + # window/game/display sources settings + # Info is stored in a JSON format + source = obs.obs_get_source_by_name(self.source_name) + source_settings = obs.obs_source_get_settings(source) + data = loads(obs.obs_data_get_json(source_settings)) + except: + # If it cannot be pulled, it is likely one of the following: + # The source no longer exists + # The source's name has changed + # OBS does not have the sources loaded yet when launching + # the script on start + + print("Source '" + self.source_name + "' not found.") + if len(self.window_name) == 0: + # OBS does not have the sources loaded yet when + # launching the script on start + self.source_name = '' + print(obs.obs_get_source_by_name(self.source_name)) + else: + # If the source data is pulled, it exists. Therefore other + # information must also exists. Source Type is pulled to + # determine if the source is a display, game, or window + + print(f"Source loaded successfully: {self.source_type}") + self.source_type = obs.obs_source_get_id(source) + print(f"Source Type: f{self.source_type}") + if (self.source_type in { 'window_capture','game_capture' }): + window_match = '' + if 'window_name' in data: + self.window_capture_mac(data) + elif 'window' in data: + window_match = self.window_capture_gen(data) + if window_match is not None: + print("Proceeding to resize") + self.window = pwc.getWindowsWithTitle(self.window_name)[0] + self.update_window_dim(self.window) + elif (self.source_type == 'monitor_capture'): + self.monitor_capture_gen(data) + elif (self.source_type == 'display_capture'): + self.monitor_capture_mac(data) + if (self.manual_offset + or self.monitor_size_override): + self.source_x += self.source_x_override + self.source_y += self.source_y_override + + def resetZI(self): + """ + Reset the zoom-in timer + """ + self.zi_timer = 0 + + def resetZO(self): + """ + Reset the zoom-out timer + """ + self.zo_timer = 0 + + def cubic_in_out(self, p): + """ + Cubic in/out easing function. Accelerates until halfway, then + decelerates. + + :param p: Linear temporal percent progress through easing from + 0 to 1 + :return: Adjusted percent progress + """ + if p < 0.5: + return 4 * p * p * p + else: + f = (2 * p) - 2 + return 0.5 * f * f * f + 1 + + def check_offset(self, arg1, arg2, smooth): + """ + Checks if a given value is offset from pivot value and provides + an adjustment towards the pivot based on a smoothing factor + + :param arg1: Pivot value + :param arg2: Checked value + :param smooth: Smoothing factor; larger values adjusts more + smoothly + :return: Adjustment value + """ + result = round((arg1 - arg2) / smooth + 1) + return int(result) + + def follow(self, mousePos): + """ + Updates the position of the zoom window. + + :param mousePos: [x,y] position of the mouse on the canvas of + all connected displays + :return: If the zoom window was moved + """ + track = False + + if ((mousePos[0] - (self.source_x + self.source_w) < 1) + and (mousePos[0] - self.source_x > -1)): + if ((mousePos[1] - (self.source_y + self.source_h) < 1) + and (mousePos[1] - self.source_y > -1)): + track = True + + if not track: + return track + + move = False + + # Find shortest dimension (usually height) + borderScale = min(self.zoom_w, self.zoom_h) + # Get active zone edges + zoom_edge_left = ( self.zoom_x + + int(self.active_border * borderScale)) + zoom_edge_right = ( self.zoom_x + + self.zoom_w + - int(self.active_border * borderScale)) + zoom_edge_top = ( self.zoom_y + + int(self.active_border * borderScale)) + zoom_edge_bottom = (self.zoom_y + + self.zoom_h + - int(self.active_border * borderScale)) + + # Clamp zone edges at center + if zoom_edge_right < zoom_edge_left: + zoom_edge_left = self.zoom_x + int(self.zoom_w/2.0) + zoom_edge_right = zoom_edge_left + + if zoom_edge_bottom < zoom_edge_top: + zoom_edge_top = self.zoom_y + int(self.zoom_h/2.0) + zoom_edge_bottom = zoom_edge_top + + # Set smoothing values + smoothFactor = 1 if self.update else int((self.smooth * 9) / 10 + 1) + + # Set x and y zoom offset + x_o = mousePos[0] - self.source_x + y_o = mousePos[1] - self.source_y + + # Set x and y zoom offset + offset_x = offset_y = 0 + + if x_o < zoom_edge_left: + offset_x = self.check_offset(x_o, zoom_edge_left, smoothFactor) + move = True + elif x_o > zoom_edge_right: + offset_x = self.check_offset(x_o, zoom_edge_right, smoothFactor) + move = True + + if y_o < zoom_edge_top: + offset_y = self.check_offset(y_o, zoom_edge_top, smoothFactor) + move = True + elif y_o > zoom_edge_bottom: + offset_y = self.check_offset(y_o, zoom_edge_bottom, smoothFactor) + move = True + + # Max speed clamp + #if not self.update: + speed_h = sqrt((offset_x**2)+(offset_y**2)) + speed_factor = max(self.max_speed, speed_h)/float(self.max_speed) + if not self.update: + offset_x /= speed_factor + offset_y /= speed_factor + + self.zoom_x += offset_x + self.zoom_y += offset_y + if (self.active_border < 0.5): + self.check_pos() + + return move + + def check_pos(self): + """ + Checks if zoom window exceeds window dimensions and clamps it if + true + """ + if not self.monitor_size_override: + x_min = 0 + x_max = self.source_w - self.zoom_w + y_min = 0 + y_max = self.source_h - self.zoom_h + else: + x_min = self.source_x_override + x_max = self.source_w - self.zoom_w + self.source_x_override + y_min = self.source_y_override + y_max = self.source_h - self.zoom_h + self.source_y_override + + if self.zoom_x < x_min: + self.zoom_x = x_min + elif self.zoom_x > x_max: + self.zoom_x = x_max + if self.zoom_y < y_min: + self.zoom_y = y_min + elif self.zoom_y > y_max: + self.zoom_y = y_max + + def set_crop(self, inOut): + """ + Set dimensions of the crop filter used for zooming + + :param inOut: direction of the filter zoom, in or out + """ + totalFrames = int(self.zoom_time / self.refresh_rate) + + source = obs.obs_get_source_by_name(self.source_name) + crop = obs.obs_source_get_filter_by_name(source, "ZoomCrop") + + if crop is None: # create filter + _s = obs.obs_data_create() + obs.obs_data_set_bool(_s, "relative", False) + f = obs.obs_source_create_private("crop_filter", + "ZoomCrop", _s) + obs.obs_source_filter_add(source, f) + obs.obs_source_release(f) + obs.obs_data_release(_s) + + s = obs.obs_source_get_settings(crop) + i = obs.obs_data_set_int + + if inOut == 0: + self.resetZI() + if self.zo_timer < totalFrames: + self.zo_timer += 1 + time = self.cubic_in_out(self.zo_timer / totalFrames) + i(s, "left", int(((1 - time) * self.zoom_x))) + i(s, "top", int(((1 - time) * self.zoom_y))) + i( + s, + "cx", + self.zoom_w + int(time * (self.source_w - self.zoom_w)), + ) + i( + s, + "cy", + self.zoom_h + int(time * (self.source_h - self.zoom_h)), + ) + self.update = True + else: + i(s, "left", 0) + i(s, "top", 0) + i(s, "cx", self.source_w) + i(s, "cy", self.source_h) + self.update = False + else: + self.resetZO() + if self.zi_timer < totalFrames: + self.zi_timer += 1 + time = self.cubic_in_out(self.zi_timer / totalFrames) + i(s, "left", int(time * self.zoom_x)) + i(s, "top", int(time * self.zoom_y)) + i( + s, + "cx", + self.source_w - int(time * (self.source_w - self.zoom_w)), + ) + i( + s, + "cy", + self.source_h - int(time * (self.source_h - self.zoom_h)), + ) + self.update = True if time < 0.8 else False + else: + i(s, "left", int(self.zoom_x)) + i(s, "top", int(self.zoom_y)) + i(s, "cx", int(self.zoom_w)) + i(s, "cy", int(self.zoom_h)) + self.update = False + + obs.obs_source_update(crop, s) + + obs.obs_data_release(s) + obs.obs_source_release(source) + obs.obs_source_release(crop) + if (inOut == 0) and (self.zo_timer >= totalFrames): + obs.remove_current_callback() + + def tracking(self): + """ + Tracking state function + """ + if self.lock: + if self.track or self.update: + self.follow(get_position()) + self.set_crop(int(self.lock)) + + def tick(self): + """ + Containing function that is run every frame + """ + self.tracking() + + +zoom = CursorWindow() + + +# ------------------------------------------------------------------- +def script_description(): + return description + + +def script_defaults(settings): + obs.obs_data_set_default_string(settings, "source", "") + obs.obs_data_set_default_bool(settings, + "Manual Monitor Override", False) + obs.obs_data_set_default_bool(settings, "Manual Offset", False) + obs.obs_data_set_default_int(settings, "Width", 1280) + obs.obs_data_set_default_int(settings, "Height", 720) + obs.obs_data_set_default_double(settings, "Border", 0.15) + obs.obs_data_set_default_int(settings, "Speed", 160) + obs.obs_data_set_default_double(settings, "Smooth", 1.0) + obs.obs_data_set_default_int(settings, "Zoom", 300) + obs.obs_data_set_default_int(settings, "Manual X Offset", 0) + obs.obs_data_set_default_int(settings, "Manual Y Offset", 0) + + +def script_update(settings): + global new_source + + source_string = obs.obs_data_get_string(settings, "source") + if source_string == "": + zoom.source_name = zoom.source_type = "" + return + + [source, source_type] = source_string.split("||") + if zoom.source_name != source: + zoom.source_name = source + zoom.source_type = source_type + new_source = True + if new_source: + print("Source update") + zoom.update_sources() + sources = obs.obs_enum_sources() + if len(sources) == 0: + print("No sources, likely OBS startup.") + else: + print("Non-initial update") + zoom.update_source_size() + print("Source Name: " + zoom.source_name) + zoom.monitor_override = obs.obs_data_get_bool(settings, + "Manual Monitor Override") + zoom.monitor_override_id = obs.obs_data_get_int(settings, "monitor") + zoom.monitor_size_override = obs.obs_data_get_bool(settings, + "Manual Monitor Dim") + if zoom.monitor_size_override: + zoom.source_w = obs.obs_data_get_int(settings, "Monitor Width") + zoom.source_h = obs.obs_data_get_int(settings, "Monitor Height") + zoom.manual_offset = obs.obs_data_get_bool(settings, "Manual Offset") + zoom.zoom_w = obs.obs_data_get_int(settings, "Width") + zoom.zoom_h = obs.obs_data_get_int(settings, "Height") + zoom.active_border = obs.obs_data_get_double(settings, "Border") + zoom.max_speed = obs.obs_data_get_int(settings, "Speed") + zoom.smooth = obs.obs_data_get_double(settings, "Smooth") + zoom.zoom_time = obs.obs_data_get_double(settings, "Zoom") + if zoom.monitor_size_override or zoom.manual_offset: + zoom.source_x_override = obs.obs_data_get_int(settings, + "Manual X Offset") + zoom.source_y_override = obs.obs_data_get_int(settings, + "Manual Y Offset") + else: + zoom.source_x_override = 0 + zoom.source_y_override = 0 + + +def populate_list_property_with_source_names(list_property): + global new_source + + print("Updating Source List") + zoom.update_sources() + sources = obs.obs_enum_sources() + print(f"System: {system()}") + if sources is not None: + obs.obs_property_list_clear(list_property) + obs.obs_property_list_add_string(list_property, "", "") + for source in sources: + if system() == "Darwin": + print(f"{obs.obs_source_get_name(source)} | {source}") + source_type = obs.obs_source_get_id(source) + print("DEBUG:", source_type) + if source_type in { "monitor_capture", "window_capture", + "game_capture","xshm_input", "display_capture" }: + name_val = name = obs.obs_source_get_name(source) + name = name + "||" + source_type + obs.obs_property_list_add_string(list_property, name_val, name) + obs.source_list_release(sources) + new_source = True + print(f"New source: {str(new_source)}") + + +def populate_list_property_with_monitors(list_property): + print("Updating Monitor List") + if zoom.monitors is not None: + obs.obs_property_list_clear(list_property) + obs.obs_property_list_add_int(list_property, "", -1) + monitor_index = 0 + for monitor in zoom.monitors: + screen_size = pwc.getScreenSize(monitor) + obs.obs_property_list_add_int(list_property, + f"{monitor}: {screen_size.width} x {screen_size.height}", + monitor_index) + monitor_index += 1 + print("Monitor override list updated") + + +def callback(props, prop, *args): + prop_name = obs.obs_property_name(prop) + monitor_override = obs.obs_properties_get(props, "Manual Monitor Override") + monitor_size_override = obs.obs_properties_get(props, "Manual Monitor Dim") + refresh_monitor = obs.obs_properties_get(props, "Refresh monitors") + source_type = zoom.source_type + if prop_name == "source": + if source_type in {'monitor_capture', 'display_capture'}: + obs.obs_property_set_visible(monitor_override,True) + obs.obs_property_set_visible(refresh_monitor,True) + obs.obs_property_set_visible(monitor_size_override,True) + else: + obs.obs_property_set_visible(monitor_override,False) + obs.obs_property_set_visible(refresh_monitor,False) + obs.obs_property_set_visible(monitor_size_override,False) + obs.obs_property_set_visible( + obs.obs_properties_get(props, "Monitor Width"), + zoom.monitor_size_override) + obs.obs_property_set_visible( + obs.obs_properties_get(props, "Monitor Height"), + zoom.monitor_size_override) + obs.obs_property_set_visible( + obs.obs_properties_get(props, "Manual X Offset"), + zoom.manual_offset) + obs.obs_property_set_visible( + obs.obs_properties_get(props, "Manual Y Offset"), + zoom.manual_offset) + monitor = obs.obs_properties_get(props, "monitor") + obs.obs_property_set_visible(monitor,zoom.monitor_override + and obs.obs_property_visible(monitor_override)) + return True + + +def script_properties(): + props = obs.obs_properties_create() + + zs = obs.obs_properties_add_list( + props, + "source", + "Zoom Source", + obs.OBS_COMBO_TYPE_LIST, + obs.OBS_COMBO_FORMAT_STRING, + ) + populate_list_property_with_source_names(zs) + + obs.obs_properties_add_button(props, "Refresh sources", + "Refresh list of sources", + lambda props,prop: True if callback(props, zs) else True) + + monitor_override = obs.obs_properties_add_bool(props, + "Manual Monitor Override", "Enable Monitor Override") + + m = obs.obs_properties_add_list( + props, + "monitor", + "Monitor Override", + obs.OBS_COMBO_TYPE_LIST, + obs.OBS_COMBO_FORMAT_INT, + ) + populate_list_property_with_monitors(m) + + rm = obs.obs_properties_add_button(props, + "Refresh monitors", "Refresh list of monitors", lambda props, + prop: True if callback(props, zs) else True) + + mon_size = obs.obs_properties_add_bool(props, + "Manual Monitor Dim", "Enable Manual Monitor Dimensions") + + mon_w = obs.obs_properties_add_int(props, + "Monitor Width", "Manual Monitor Width", -8000, 8000, 1) + mon_h = obs.obs_properties_add_int(props, + "Monitor Height", "Manual Monitor Height", -8000, 8000, 1) + + offset = obs.obs_properties_add_bool(props, + "Manual Offset", "Enable Manual Offset") + + mx = obs.obs_properties_add_int(props, + "Manual X Offset", "Manual X Offset", -8000, 8000, 1) + my = obs.obs_properties_add_int(props, + "Manual Y Offset", "Manual Y Offset", -8000, 8000, 1) + + obs.obs_properties_add_int(props, + "Width", "Zoom Window Width", 320, 3840, 1) + obs.obs_properties_add_int(props, + "Height", "Zoom Window Height", 240, 3840, 1) + obs.obs_properties_add_float_slider(props, + "Border", "Active Border", 0, 0.5, 0.01) + obs.obs_properties_add_int(props, + "Speed", "Max Scroll Speed", 0, 540, 10) + obs.obs_properties_add_float_slider(props, + "Smooth", "Smooth", 0, 10, 1.00) + obs.obs_properties_add_int_slider(props, + "Zoom", "Zoom Duration (ms)", 0, 1000, 1) + + mon_show = (True if + zoom.source_type in { 'monitor_capture', 'display_capture' } + else False) + + obs.obs_property_set_visible(monitor_override, mon_show) + obs.obs_property_set_visible(m, zoom.monitor_override) + obs.obs_property_set_visible(rm, zoom.monitor_override) + obs.obs_property_set_visible(mon_h, zoom.monitor_override) + obs.obs_property_set_visible(mon_w, zoom.monitor_override) + obs.obs_property_set_visible(mx, zoom.manual_offset) + obs.obs_property_set_visible(my, zoom.manual_offset) + + obs.obs_property_set_modified_callback(zs, callback) + obs.obs_property_set_modified_callback(monitor_override, callback) + obs.obs_property_set_modified_callback(mon_size, callback) + obs.obs_property_set_modified_callback(offset, callback) + return props + + +def script_load(settings): + global zoom_id_tog + + load_settings = loads(obs.obs_data_get_json(settings)) + try: + [source, source_type] = load_settings['source'].split("||") + [zoom.source_name, zoom.source_type] = [source, source_type] + except: + print(f"Key 'source' does not exist | {load_settings}") + + zoom_id_tog = obs.obs_hotkey_register_frontend( + ZOOM_NAME_TOG, ZOOM_DESC_TOG, toggle_zoom + ) + hotkey_save_array = obs.obs_data_get_array(settings, ZOOM_NAME_TOG) + obs.obs_hotkey_load(zoom_id_tog, hotkey_save_array) + obs.obs_data_array_release(hotkey_save_array) + + global follow_id_tog + follow_id_tog = obs.obs_hotkey_register_frontend( + FOLLOW_NAME_TOG, FOLLOW_DESC_TOG, toggle_follow + ) + hotkey_save_array = obs.obs_data_get_array(settings, FOLLOW_NAME_TOG) + obs.obs_hotkey_load(follow_id_tog, hotkey_save_array) + obs.obs_data_array_release(hotkey_save_array) + zoom.update_sources() + zoom.new_source = True + + +def script_unload(): + obs.obs_hotkey_unregister(toggle_zoom) + obs.obs_hotkey_unregister(toggle_follow) + + +def script_save(settings): + hotkey_save_array = obs.obs_hotkey_save(zoom_id_tog) + obs.obs_data_set_array(settings, ZOOM_NAME_TOG, hotkey_save_array) + obs.obs_data_array_release(hotkey_save_array) + + hotkey_save_array = obs.obs_hotkey_save(follow_id_tog) + obs.obs_data_set_array(settings, FOLLOW_NAME_TOG, hotkey_save_array) + obs.obs_data_array_release(hotkey_save_array) + + +def toggle_zoom(pressed): + if pressed: + if new_source: + zoom.update_sources() + if zoom.source_name != "" and zoom.flag: + zoom.update_source_size() + obs.timer_add(zoom.tick, zoom.refresh_rate) + zoom.lock = True + zoom.flag = False + elif not zoom.flag: + zoom.flag = True + zoom.lock = False + print(f"Zoom: {zoom.lock}") + if zoom.lock: + print(f"Mouse position: {get_position()}") + + +def toggle_follow(pressed): + if pressed: + if zoom.track: + zoom.track = False + elif not zoom.track: + zoom.track = True + print(f"Tracking: {zoom.track}")