diff --git a/obs-zoom-to-mouse[zh].lua b/obs-zoom-to-mouse[zh].lua new file mode 100644 index 0000000..2673d97 --- /dev/null +++ b/obs-zoom-to-mouse[zh].lua @@ -0,0 +1,1387 @@ +-- +-- OBS Zoom to Mouse +-- 一个 OBS lua 脚本,用于缩放显示捕获源以聚焦于鼠标。 +-- Copyright (c) BlankSourceCode. All rights reserved. +-- + +local obs = obslua +local ffi = require("ffi") +local VERSION = "1.0" +local CROP_FILTER_NAME = "obs-zoom-to-mouse-crop" + +local source_name = "" +local source = nil +local sceneitem = nil +local sceneitem_info_orig = nil +local sceneitem_crop_orig = nil +local sceneitem_info = nil +local sceneitem_crop = nil +local crop_filter = nil +local crop_filter_temp = nil +local crop_filter_settings = nil +local crop_filter_info_orig = { x = 0, y = 0, w = 0, h = 0 } +local crop_filter_info = { x = 0, y = 0, w = 0, h = 0 } +local monitor_info = nil +local zoom_info = { + source_size = { width = 0, height = 0 }, + source_crop = { x = 0, y = 0, w = 0, h = 0 }, + source_crop_filter = { x = 0, y = 0, w = 0, h = 0 }, + zoom_to = 2 +} +local zoom_time = 0 +local zoom_target = nil +local locked_center = nil +local locked_last_pos = nil +local hotkey_zoom_id = nil +local hotkey_follow_id = nil +local is_timer_running = false + +local win_point = nil +local x11_display = nil +local x11_root = nil +local x11_mouse = nil +local osx_lib = nil +local osx_nsevent = nil +local osx_mouse_location = nil + +local use_auto_follow_mouse = true +local use_follow_outside_bounds = false +local is_following_mouse = false +local follow_speed = 0.1 +local follow_border = 0 +local follow_safezone_sensitivity = 10 +local use_follow_auto_lock = false +local zoom_value = 2 +local zoom_speed = 0.1 +local allow_all_sources = false +local use_monitor_override = false +local monitor_override_x = 0 +local monitor_override_y = 0 +local monitor_override_w = 0 +local monitor_override_h = 0 +local monitor_override_sx = 0 +local monitor_override_sy = 0 +local monitor_override_dw = 0 +local monitor_override_dh = 0 +local debug_logs = false + +local ZoomState = { + None = 0, + ZoomingIn = 1, + ZoomingOut = 2, + ZoomedIn = 3, +} +local zoom_state = ZoomState.None + +local version = obs.obs_get_version_string() +local major = tonumber(version:match("(%d+%.%d+)")) or 0 + +-- Define the mouse cursor functions for each platform +if ffi.os == "Windows" then + ffi.cdef([[ + typedef int BOOL; + typedef struct{ + long x; + long y; + } POINT, *LPPOINT; + BOOL GetCursorPos(LPPOINT); + ]]) + win_point = ffi.new("POINT[1]") +elseif ffi.os == "Linux" then + ffi.cdef([[ + typedef unsigned long XID; + typedef XID Window; + typedef void Display; + Display* XOpenDisplay(char*); + XID XDefaultRootWindow(Display *display); + int XQueryPointer(Display*, Window, Window*, Window*, int*, int*, int*, int*, unsigned int*); + int XCloseDisplay(Display*); + ]]) + + x11_lib = ffi.load("X11.so.6") + x11_display = x11_lib.XOpenDisplay(nil) + if x11_display ~= nil then + x11_root = x11_lib.XDefaultRootWindow(x11_display) + x11_mouse = { + root_win = ffi.new("Window[1]"), + child_win = ffi.new("Window[1]"), + root_x = ffi.new("int[1]"), + root_y = ffi.new("int[1]"), + win_x = ffi.new("int[1]"), + win_y = ffi.new("int[1]"), + mask = ffi.new("unsigned int[1]") + } + end +elseif ffi.os == "OSX" then + ffi.cdef([[ + typedef struct { + double x; + double y; + } CGPoint; + typedef void* SEL; + typedef void* id; + typedef void* Method; + + SEL sel_registerName(const char *str); + id objc_getClass(const char*); + Method class_getClassMethod(id cls, SEL name); + void* method_getImplementation(Method); + int access(const char *path, int amode); + ]]) + + osx_lib = ffi.load("libobjc") + if osx_lib ~= nil then + osx_nsevent = { + class = osx_lib.objc_getClass("NSEvent"), + sel = osx_lib.sel_registerName("mouseLocation") + } + local method = osx_lib.class_getClassMethod(osx_nsevent.class, osx_nsevent.sel) + if method ~= nil then + local imp = osx_lib.method_getImplementation(method) + osx_mouse_location = ffi.cast("CGPoint(*)(void*, void*)", imp) + end + end +end + +--- +-- Get the current mouse position +---@return table Mouse position +function get_mouse_pos() + local mouse = { x = 0, y = 0 } + + if ffi.os == "Windows" then + if win_point and ffi.C.GetCursorPos(win_point) ~= 0 then + mouse.x = win_point[0].x + mouse.y = win_point[0].y + end + elseif ffi.os == "Linux" then + if x11_lib ~= nil and x11_display ~= nil and x11_root ~= nil and x11_mouse ~= nil then + if x11_lib.XQueryPointer(x11_display, x11_root, x11_mouse.root_win, x11_mouse.child_win, x11_mouse.root_x, x11_mouse.root_y, x11_mouse.win_x, x11_mouse.win_y, x11_mouse.mask) ~= 0 then + mouse.x = tonumber(x11_mouse.win_x[0]) + mouse.y = tonumber(x11_mouse.win_y[0]) + end + end + elseif ffi.os == "OSX" then + if osx_lib ~= nil and osx_nsevent ~= nil and osx_mouse_location ~= nil then + local point = osx_mouse_location(osx_nsevent.class, osx_nsevent.sel) + mouse.x = point.x + if monitor_info ~= nil then + if monitor_info.display_height > 0 then + mouse.y = monitor_info.display_height - point.y + else + mouse.y = monitor_info.height - point.y + end + end + end + end + + return mouse +end + +--- +-- Get the information about display capture sources for the current platform +---@return any +function get_dc_info() + if ffi.os == "Windows" then + return { + source_id = "monitor_capture", + prop_id = "monitor_id", + prop_type = "string" + } + elseif ffi.os == "Linux" then + return { + source_id = "xshm_input", + prop_id = "screen", + prop_type = "int" + } + elseif ffi.os == "OSX" then + if major > 29.0 then + return { + source_id = "screen_capture", + prop_id = "display_uuid", + prop_type = "string" + } + else + return { + source_id = "display_capture", + prop_id = "display", + prop_type = "int" + } + end + end + + return nil +end + +--- +-- Logs a message to the OBS script console +---@param msg string The message to log +function log(msg) + if debug_logs then + obs.script_log(obs.OBS_LOG_INFO, msg) + end +end + +--- +-- Format the given lua table into a string +---@param tbl any +---@param indent any +---@return string result The formatted string +function format_table(tbl, indent) + if not indent then + indent = 0 + end + + local str = "{\n" + for key, value in pairs(tbl) do + local tabs = string.rep(" ", indent + 1) + if type(value) == "table" then + str = str .. tabs .. key .. " = " .. format_table(value, indent + 1) .. ",\n" + else + str = str .. tabs .. key .. " = " .. tostring(value) .. ",\n" + end + end + str = str .. string.rep(" ", indent) .. "}" + + return str +end + +--- +-- Linear interpolate between v0 and v1 +---@param v0 number The start position +---@param v1 number The end position +---@param t number Time +---@return number value The interpolated value +function lerp(v0, v1, t) + return v0 * (1 - t) + v1 * t; +end + +--- +-- Ease a time value in and out +---@param t number Time between 0 and 1 +---@return number +function ease_in_out(t) + t = t * 2 + if t < 1 then + return 0.5 * t * t * t + else + t = t - 2 + return 0.5 * (t * t * t + 2) + end +end + +--- +-- Clamps a given value between min and max +---@param min number The min value +---@param max number The max value +---@param value number The number to clamp +---@return number result the clamped number +function clamp(min, max, value) + return math.max(min, math.min(max, value)) +end + +--- +-- Get the size and position of the monitor so that we know the top-left mouse point +---@param source any The OBS source +---@return table|nil monitor_info The monitor size/top-left point +function get_monitor_info(source) + local info = nil + + -- Only do the expensive look up if we are using automatic calculations on a display source + if is_display_capture(source) and not use_monitor_override then + local dc_info = get_dc_info() + if dc_info ~= nil then + local props = obs.obs_source_properties(source) + if props ~= nil then + local monitor_id_prop = obs.obs_properties_get(props, dc_info.prop_id) + if monitor_id_prop then + local found = nil + local settings = obs.obs_source_get_settings(source) + if settings ~= nil then + local to_match + if dc_info.prop_type == "string" then + to_match = obs.obs_data_get_string(settings, dc_info.prop_id) + elseif dc_info.prop_type == "int" then + to_match = obs.obs_data_get_int(settings, dc_info.prop_id) + end + + local item_count = obs.obs_property_list_item_count(monitor_id_prop); + for i = 0, item_count do + local name = obs.obs_property_list_item_name(monitor_id_prop, i) + local value + if dc_info.prop_type == "string" then + value = obs.obs_property_list_item_string(monitor_id_prop, i) + elseif dc_info.prop_type == "int" then + value = obs.obs_property_list_item_int(monitor_id_prop, i) + end + + if value == to_match then + found = name + break + end + end + obs.obs_data_release(settings) + end + + -- This works for my machine as the monitor names are given as "U2790B: 3840x2160 @ -1920,0 (Primary Monitor)" + -- I don't know if this holds true for other machines and/or OBS versions + -- TODO: Update this with some custom FFI calls to find the monitor top-left x and y coordinates if it doesn't work for anyone else + -- TODO: Refactor this into something that would work with Windows/Linux/Mac assuming we can't do it like this + if found then + log("Parsing display name: " .. found) + local x, y = found:match("(-?%d+),(-?%d+)") + local width, height = found:match("(%d+)x(%d+)") + + info = { x = 0, y = 0, width = 0, height = 0 } + info.x = tonumber(x, 10) + info.y = tonumber(y, 10) + info.width = tonumber(width, 10) + info.height = tonumber(height, 10) + info.scale_x = 1 + info.scale_y = 1 + info.display_width = info.width + info.display_height = info.height + + log("Parsed the following display information\n" .. format_table(info)) + + if info.width == 0 and info.height == 0 then + info = nil + end + end + end + + obs.obs_properties_destroy(props) + end + end + end + + if use_monitor_override then + info = { + x = monitor_override_x, + y = monitor_override_y, + width = monitor_override_w, + height = monitor_override_h, + scale_x = monitor_override_sx, + scale_y = monitor_override_sy, + display_width = monitor_override_dw, + display_height = monitor_override_dh + } + end + + if not info then + log("WARNING: Could not auto calculate zoom source position and size.\n" .. + " Try using the 'Set manual source position' option and adding override values") + end + + return info +end + +--- +-- Check to see if the specified source is a display capture source +-- If the source_to_check is nil then the answer will be false +---@param source_to_check any The source to check +---@return boolean result True if source is a display capture, false if it nil or some other source type +function is_display_capture(source_to_check) + if source_to_check ~= nil then + local dc_info = get_dc_info() + if dc_info ~= nil then + -- Do a quick check to ensure this is a display capture + if allow_all_sources then + local source_type = obs.obs_source_get_id(source_to_check) + if source_type == dc_info.source_id then + return true + end + else + return true + end + end + end + + return false +end + +--- +-- Releases the current sceneitem and resets data back to default +function release_sceneitem() + if is_timer_running then + obs.timer_remove(on_timer) + is_timer_running = false + end + + zoom_state = ZoomState.None + + if sceneitem ~= nil then + if crop_filter ~= nil and source ~= nil then + log("Zoom crop filter removed") + obs.obs_source_filter_remove(source, crop_filter) + obs.obs_source_release(crop_filter) + crop_filter = nil + end + + if crop_filter_temp ~= nil and source ~= nil then + log("Conversion crop filter removed") + obs.obs_source_filter_remove(source, crop_filter_temp) + obs.obs_source_release(crop_filter_temp) + crop_filter_temp = nil + end + + if crop_filter_settings ~= nil then + obs.obs_data_release(crop_filter_settings) + crop_filter_settings = nil + end + + if sceneitem_info_orig ~= nil then + log("Transform info reset back to original") + obs.obs_sceneitem_get_info(sceneitem, sceneitem_info_orig) + sceneitem_info_orig = nil + end + + if sceneitem_crop_orig ~= nil then + log("Transform crop reset back to original") + obs.obs_sceneitem_set_crop(sceneitem, sceneitem_crop_orig) + sceneitem_crop_orig = nil + end + + obs.obs_sceneitem_release(sceneitem) + sceneitem = nil + end + + if source ~= nil then + obs.obs_source_release(source) + source = nil + end +end + +--- +-- Updates the current sceneitem with a refreshed set of data from the source +-- Optionally will release the existing sceneitem and get a new one from the current scene +---@param find_newest boolean True to release the current sceneitem and get a new one +function refresh_sceneitem(find_newest) + -- TODO: Figure out why we need to get the size from the named source during update instead of via the sceneitem source + local source_raw = { width = 0, height = 0 } + + if find_newest then + -- Release the current sceneitem now that we are replacing it + release_sceneitem() + + -- Quit early if we are using no zoom source + -- This allows users to reset the crop data back to the original, + -- update it, and then force the conversion to happen by re-selecting it. + if source_name == "obs-zoom-to-mouse-none" then + return + end + + -- Get a matching source we can use for zooming in the current scene + log("Finding sceneitem for Zoom Source '" .. source_name .. "'") + if source_name ~= nil then + source = obs.obs_get_source_by_name(source_name) + if source ~= nil then + -- Get the source size, for some reason this works during load but the sceneitem source doesn't + source_raw.width = obs.obs_source_get_width(source) + source_raw.height = obs.obs_source_get_height(source) + + -- Get the current scene + local scene_source = obs.obs_frontend_get_current_scene() + if scene_source ~= nil then + local function find_scene_item_by_name(root_scene) + local queue = {} + table.insert(queue, root_scene) + + while #queue > 0 do + local s = table.remove(queue, 1) + log("Looking in scene '" .. obs.obs_source_get_name(obs.obs_scene_get_source(s)) .. "'") + + -- Check if the current scene has the target scene item + local found = obs.obs_scene_find_source(s, source_name) + if found ~= nil then + log("Found sceneitem '" .. source_name .. "'") + obs.obs_sceneitem_addref(found) + return found + end + + -- If the current scene has nested scenes, enqueue them for later examination + local all_items = obs.obs_scene_enum_items(s) + if all_items then + for _, item in pairs(all_items) do + local nested = obs.obs_sceneitem_get_source(item) + if nested ~= nil and obs.obs_source_is_scene(nested) then + local nested_scene = obs.obs_scene_from_source(nested) + table.insert(queue, nested_scene) + end + end + obs.sceneitem_list_release(all_items) + end + end + + return nil + end + + -- Find the sceneitem for the source_name by looking through all the items + -- We start at the current scene and use a BFS to look into any nested scenes + local current = obs.obs_scene_from_source(scene_source) + sceneitem = find_scene_item_by_name(current) + + obs.obs_source_release(scene_source) + end + + if not sceneitem then + log("WARNING: Source not part of the current scene hierarchy.\n" .. + " Try selecting a different zoom source or switching scenes.") + obs.obs_sceneitem_release(sceneitem) + obs.obs_source_release(source) + + sceneitem = nil + source = nil + return + end + end + end + end + + if not monitor_info then + monitor_info = get_monitor_info(source) + end + + local is_non_display_capture = not is_display_capture(source) + if is_non_display_capture then + if not use_monitor_override then + log("ERROR: Selected Zoom Source is not a display capture source.\n" .. + " You MUST enable 'Set manual source position' and set the correct override values for size and position.") + end + end + + if sceneitem ~= nil then + -- Capture the original settings so we can restore them later + sceneitem_info_orig = obs.obs_transform_info() + obs.obs_sceneitem_get_info(sceneitem, sceneitem_info_orig) + + sceneitem_crop_orig = obs.obs_sceneitem_crop() + obs.obs_sceneitem_get_crop(sceneitem, sceneitem_crop_orig) + + sceneitem_info = obs.obs_transform_info() + obs.obs_sceneitem_get_info(sceneitem, sceneitem_info) + + sceneitem_crop = obs.obs_sceneitem_crop() + obs.obs_sceneitem_get_crop(sceneitem, sceneitem_crop) + + if is_non_display_capture then + -- Non-Display Capture sources don't correctly report crop values + sceneitem_crop_orig.left = 0 + sceneitem_crop_orig.top = 0 + sceneitem_crop_orig.right = 0 + sceneitem_crop_orig.bottom = 0 + end + + -- Get the current source size (this will be the value after any applied crop filters) + if not source then + log("ERROR: Could not get source for sceneitem (" .. source_name .. ")") + end + + -- TODO: Figure out why we need this fallback code + local source_width = obs.obs_source_get_base_width(source) + local source_height = obs.obs_source_get_base_height(source) + + if source_width == 0 then + source_width = source_raw.width + end + if source_height == 0 then + source_height = source_raw.height + end + + if source_width == 0 or source_height == 0 then + log("ERROR: Something went wrong determining source size." .. + " Try using the 'Set manual source position' option and adding override values") + + if monitor_info ~= nil then + source_width = monitor_info.width + source_height = monitor_info.height + end + else + log("Using source size: " .. source_width .. ", " .. source_height) + end + + -- Convert the current transform into one we can correctly modify for zooming + -- Ideally the user just has a valid one set and we don't have to change anything because this might not work 100% of the time + if sceneitem_info.bounds_type == obs.OBS_BOUNDS_NONE then + sceneitem_info.bounds_type = obs.OBS_BOUNDS_SCALE_INNER + sceneitem_info.bounds_alignment = 5 -- (5 == OBS_ALIGN_TOP | OBS_ALIGN_LEFT) (0 == OBS_ALIGN_CENTER) + sceneitem_info.bounds.x = source_width * sceneitem_info.scale.x + sceneitem_info.bounds.y = source_height * sceneitem_info.scale.y + + obs.obs_sceneitem_set_info(sceneitem, sceneitem_info) + + log("WARNING: Found existing non-boundingbox transform. This may cause issues with zooming.\n" .. + " Settings have been auto converted to a bounding box scaling transfrom instead.\n" .. + " If you have issues with your layout consider making the transform use a bounding box manually.") + end + + -- Get information about any existing crop filters (that aren't ours) + zoom_info.source_crop_filter = { x = 0, y = 0, w = 0, h = 0 } + local found_crop_filter = false + local filters = obs.obs_source_enum_filters(source) + if filters ~= nil then + for k, v in pairs(filters) do + local id = obs.obs_source_get_id(v) + if id == "crop_filter" then + local name = obs.obs_source_get_name(v) + if name ~= CROP_FILTER_NAME and name ~= "temp_" .. CROP_FILTER_NAME then + found_crop_filter = true + local settings = obs.obs_source_get_settings(v) + if settings ~= nil then + if not obs.obs_data_get_bool(settings, "relative") then + zoom_info.source_crop_filter.x = + zoom_info.source_crop_filter.x + obs.obs_data_get_int(settings, "left") + zoom_info.source_crop_filter.y = + zoom_info.source_crop_filter.y + obs.obs_data_get_int(settings, "top") + zoom_info.source_crop_filter.w = + zoom_info.source_crop_filter.w + obs.obs_data_get_int(settings, "cx") + zoom_info.source_crop_filter.h = + zoom_info.source_crop_filter.h + obs.obs_data_get_int(settings, "cy") + log("Found existing relative crop/pad filter (" .. + name .. + "). Applying settings " .. format_table(zoom_info.source_crop_filter)) + else + log("WARNING: Found existing non-relative crop/pad filter (" .. name .. ").\n" .. + " This will cause issues with zooming. Convert to relative settings instead.") + end + obs.obs_data_release(settings) + end + end + end + end + + obs.source_list_release(filters) + end + + -- If the user has a transform crop set, we need to convert it into a crop filter so that it works correctly with zooming + -- Ideally the user does this manually and uses a crop filter instead of the transfrom crop because this might not work 100% of the time + if not found_crop_filter and (sceneitem_crop_orig.left ~= 0 or sceneitem_crop_orig.top ~= 0 or sceneitem_crop_orig.right ~= 0 or sceneitem_crop_orig.bottom ~= 0) then + log("Creating new crop filter") + + -- Update the source size + source_width = source_width - (sceneitem_crop_orig.left + sceneitem_crop_orig.right) + source_height = source_height - (sceneitem_crop_orig.top + sceneitem_crop_orig.bottom) + + -- Update the source crop filter now that we will be using one + zoom_info.source_crop_filter.x = sceneitem_crop_orig.left + zoom_info.source_crop_filter.y = sceneitem_crop_orig.top + zoom_info.source_crop_filter.w = source_width + zoom_info.source_crop_filter.h = source_height + + -- Add a new crop filter that emulates the existing transform crop + local settings = obs.obs_data_create() + obs.obs_data_set_bool(settings, "relative", false) + obs.obs_data_set_int(settings, "left", zoom_info.source_crop_filter.x) + obs.obs_data_set_int(settings, "top", zoom_info.source_crop_filter.y) + obs.obs_data_set_int(settings, "cx", zoom_info.source_crop_filter.w) + obs.obs_data_set_int(settings, "cy", zoom_info.source_crop_filter.h) + crop_filter_temp = obs.obs_source_create_private("crop_filter", "temp_" .. CROP_FILTER_NAME, settings) + obs.obs_source_filter_add(source, crop_filter_temp) + obs.obs_data_release(settings) + + -- Clear out the transform crop + sceneitem_crop.left = 0 + sceneitem_crop.top = 0 + sceneitem_crop.right = 0 + sceneitem_crop.bottom = 0 + obs.obs_sceneitem_set_crop(sceneitem, sceneitem_crop) + + log("WARNING: Found existing transform crop. This may cause issues with zooming.\n" .. + " Settings have been auto converted to a relative crop/pad filter instead.\n" .. + " If you have issues with your layout consider making the filter manually.") + elseif found_crop_filter then + source_width = zoom_info.source_crop_filter.w + source_height = zoom_info.source_crop_filter.h + end + + -- Get the rest of the information needed to correctly zoom + zoom_info.source_size = { width = source_width, height = source_height } + zoom_info.source_crop = { + l = sceneitem_crop_orig.left, + t = sceneitem_crop_orig.top, + r = sceneitem_crop_orig.right, + b = sceneitem_crop_orig.bottom + } + --log("Transform updated. Using following values -\n" .. format_table(zoom_info)) + + -- Set the initial the crop filter data to match the source + crop_filter_info_orig = { x = 0, y = 0, w = zoom_info.source_size.width, h = zoom_info.source_size.height } + crop_filter_info = { + x = crop_filter_info_orig.x, + y = crop_filter_info_orig.y, + w = crop_filter_info_orig.w, + h = crop_filter_info_orig.h + } + + -- Get or create our crop filter that we change during zoom + crop_filter = obs.obs_source_get_filter_by_name(source, CROP_FILTER_NAME) + if crop_filter == nil then + crop_filter_settings = obs.obs_data_create() + obs.obs_data_set_bool(crop_filter_settings, "relative", false) + crop_filter = obs.obs_source_create_private("crop_filter", CROP_FILTER_NAME, crop_filter_settings) + obs.obs_source_filter_add(source, crop_filter) + else + crop_filter_settings = obs.obs_source_get_settings(crop_filter) + end + + obs.obs_source_filter_set_order(source, crop_filter, obs.OBS_ORDER_MOVE_BOTTOM) + set_crop_settings(crop_filter_info_orig) + end +end + +--- +-- Get the target position that we will attempt to zoom towards +---@param zoom any +---@return table +function get_target_position(zoom) + local mouse = get_mouse_pos() + + -- If we have monitor information then we can offset the mouse by the top-left of the monitor position + -- This is because the display-capture source assumes top-left is 0,0 but the mouse uses the total desktop area, + -- so a second monitor might start at x:1920, y:0 for example, so when we click at 1920,0 we want it to look like we clicked 0,0 on the source. + if monitor_info then + mouse.x = mouse.x - monitor_info.x + mouse.y = mouse.y - monitor_info.y + end + + -- Now offset the mouse by the crop top-left because if we cropped 100px off of the display clicking at 100,0 should really be the top-left 0,0 + mouse.x = mouse.x - zoom.source_crop_filter.x + mouse.y = mouse.y - zoom.source_crop_filter.y + + -- If the source uses a different scale to the display, apply that now. + -- This can happen with cloned sources, where it is cloning a scene that has a full screen display. + -- The display will be the full desktop pixel size, but the cloned scene will be scaled down to the canvas, + -- so we need to scale down the mouse movement to match + if monitor_info and monitor_info.scale_x and monitor_info.scale_y then + mouse.x = mouse.x * monitor_info.scale_x + mouse.y = mouse.y * monitor_info.scale_y + end + + -- Get the new size after we zoom + -- Remember that because we are using a crop/pad filter making the size smaller (dividing by zoom) means that we see less of the image + -- in the same amount of space making it look bigger (aka zoomed in) + local new_size = { + width = zoom.source_size.width / zoom.zoom_to, + height = zoom.source_size.height / zoom.zoom_to + } + + -- New offset for the crop/pad filter is whereever we clicked minus half the size, so that the clicked point because the new center + local pos = { + x = mouse.x - new_size.width * 0.5, + y = mouse.y - new_size.height * 0.5 + } + + -- Create the full crop results + local crop = { + x = pos.x, + y = pos.y, + w = new_size.width, + h = new_size.height, + } + + -- Keep the zoom in bounds of the source so that we never show something outside that user is trying to hide with existing crop settings + crop.x = math.floor(clamp(0, (zoom.source_size.width - new_size.width), crop.x)) + crop.y = math.floor(clamp(0, (zoom.source_size.height - new_size.height), crop.y)) + + return { crop = crop, raw_center = mouse, clamped_center = { x = math.floor(crop.x + crop.w * 0.5), y = math.floor(crop.y + crop.h * 0.5) } } +end + +function on_toggle_follow(pressed) + if pressed then + is_following_mouse = not is_following_mouse + log("Tracking mouse is " .. (is_following_mouse and "on" or "off")) + + if is_following_mouse and zoom_state == ZoomState.ZoomedIn then + -- Since we are zooming we need to start the timer for the animation and tracking + if is_timer_running == false then + is_timer_running = true + local timer_interval = math.floor(obs.obs_get_frame_interval_ns() / 1000000) + obs.timer_add(on_timer, timer_interval) + end + end + end +end + +function on_toggle_zoom(pressed) + if pressed then + -- Check if we are in a safe state to zoom + if zoom_state == ZoomState.ZoomedIn or zoom_state == ZoomState.None then + if zoom_state == ZoomState.ZoomedIn then + log("Zooming out") + -- To zoom out, we set the target back to whatever it was originally + zoom_state = ZoomState.ZoomingOut + zoom_time = 0 + locked_center = nil + locked_last_pos = nil + zoom_target = { crop = crop_filter_info_orig, c = sceneitem_crop_orig } + if is_following_mouse then + is_following_mouse = false + log("Tracking mouse is off (due to zoom out)") + end + else + log("Zooming in") + -- To zoom in, we get a new target based on where the mouse was when zoom was clicked + zoom_state = ZoomState.ZoomingIn + zoom_info.zoom_to = zoom_value + zoom_time = 0 + locked_center = nil + locked_last_pos = nil + zoom_target = get_target_position(zoom_info) + end + + -- Since we are zooming we need to start the timer for the animation and tracking + if is_timer_running == false then + is_timer_running = true + local timer_interval = math.floor(obs.obs_get_frame_interval_ns() / 1000000) + obs.timer_add(on_timer, timer_interval) + end + end + end +end + +function on_timer() + if crop_filter_info ~= nil and zoom_target ~= nil then + -- Update our zoom time that we use for the animation + zoom_time = zoom_time + zoom_speed + + if zoom_state == ZoomState.ZoomingOut or zoom_state == ZoomState.ZoomingIn then + -- When we are doing a zoom animation (in or out) we linear interpolate the crop to the target + if zoom_time <= 1 then + -- If we have auto-follow turned on, make sure to keep the mouse in the view while we zoom + -- This is incase the user is moving the mouse a lot while the animation (which may be slow) is playing + if zoom_state == ZoomState.ZoomingIn and use_auto_follow_mouse then + zoom_target = get_target_position(zoom_info) + end + crop_filter_info.x = lerp(crop_filter_info.x, zoom_target.crop.x, ease_in_out(zoom_time)) + crop_filter_info.y = lerp(crop_filter_info.y, zoom_target.crop.y, ease_in_out(zoom_time)) + crop_filter_info.w = lerp(crop_filter_info.w, zoom_target.crop.w, ease_in_out(zoom_time)) + crop_filter_info.h = lerp(crop_filter_info.h, zoom_target.crop.h, ease_in_out(zoom_time)) + set_crop_settings(crop_filter_info) + end + else + -- If we are not zooming we only move the x/y to follow the mouse (width/height stay constant) + if is_following_mouse then + zoom_target = get_target_position(zoom_info) + + local skip_frame = false + if not use_follow_outside_bounds then + if zoom_target.raw_center.x < zoom_target.crop.x or + zoom_target.raw_center.x > zoom_target.crop.x + zoom_target.crop.w or + zoom_target.raw_center.y < zoom_target.crop.y or + zoom_target.raw_center.y > zoom_target.crop.y + zoom_target.crop.h then + -- Don't follow the mouse if we are outside the bounds of the source + skip_frame = true + end + end + + if not skip_frame then + -- If we have a locked_center it means we are currently in a locked zone and + -- shouldn't track the mouse until it moves out of the area + if locked_center ~= nil then + local diff = { + x = zoom_target.raw_center.x - locked_center.x, + y = zoom_target.raw_center.y - locked_center.y + } + + local track = { + x = zoom_target.crop.w * (0.5 - (follow_border * 0.01)), + y = zoom_target.crop.h * (0.5 - (follow_border * 0.01)) + } + + if math.abs(diff.x) > track.x or math.abs(diff.y) > track.y then + -- Cursor moved into the active border area, so resume tracking by clearing out the locked_center + locked_center = nil + locked_last_pos = { + x = zoom_target.raw_center.x, + y = zoom_target.raw_center.y, + diff_x = diff.x, + diff_y = diff.y + } + log("Locked area exited - resume tracking") + end + end + + if locked_center == nil and (zoom_target.crop.x ~= crop_filter_info.x or zoom_target.crop.y ~= crop_filter_info.y) then + crop_filter_info.x = lerp(crop_filter_info.x, zoom_target.crop.x, follow_speed) + crop_filter_info.y = lerp(crop_filter_info.y, zoom_target.crop.y, follow_speed) + set_crop_settings(crop_filter_info) + + -- Check to see if the mouse has stopped moving long enough to create a new safe zone + if is_following_mouse and locked_center == nil and locked_last_pos ~= nil then + local diff = { + x = math.abs(crop_filter_info.x - zoom_target.crop.x), + y = math.abs(crop_filter_info.y - zoom_target.crop.y), + auto_x = zoom_target.raw_center.x - locked_last_pos.x, + auto_y = zoom_target.raw_center.y - locked_last_pos.y + } + + locked_last_pos.x = zoom_target.raw_center.x + locked_last_pos.y = zoom_target.raw_center.y + + local lock = false + if math.abs(locked_last_pos.diff_x) > math.abs(locked_last_pos.diff_y) then + if (diff.auto_x < 0 and locked_last_pos.diff_x > 0) or (diff.auto_x > 0 and locked_last_pos.diff_x < 0) then + lock = true + end + else + if (diff.auto_y < 0 and locked_last_pos.diff_y > 0) or (diff.auto_y > 0 and locked_last_pos.diff_y < 0) then + lock = true + end + end + + if (lock and use_follow_auto_lock) or (diff.x <= follow_safezone_sensitivity and diff.y <= follow_safezone_sensitivity) then + -- Make the new center the position of the current camera (which might not be the same as the mouse since we lerp towards it) + locked_center = { + x = math.floor(crop_filter_info.x + zoom_target.crop.w * 0.5), + y = math.floor(crop_filter_info.y + zoom_target.crop.h * 0.5) + } + log("Cursor stopped. Tracking locked to " .. locked_center.x .. ", " .. locked_center.y) + end + end + end + end + end + end + + -- Check to see if the animation is over + if zoom_time >= 1 then + local should_stop_timer = false + -- When we finished zooming out we remove the timer + if zoom_state == ZoomState.ZoomingOut then + log("Zoomed out") + zoom_state = ZoomState.None + should_stop_timer = true + elseif zoom_state == ZoomState.ZoomingIn then + log("Zoomed in") + zoom_state = ZoomState.ZoomedIn + -- If we finished zooming in and we arent tracking the mouse we can also remove the timer + should_stop_timer = (not use_auto_follow_mouse) and (not is_following_mouse) + + if use_auto_follow_mouse then + is_following_mouse = true + log("Tracking mouse is " .. (is_following_mouse and "on" or "off") .. " (due to auto follow)") + end + + -- We set the current position as the center for the follow safezone + if is_following_mouse and follow_border < 50 then + zoom_target = get_target_position(zoom_info) + locked_center = { x = zoom_target.clamped_center.x, y = zoom_target.clamped_center.y } + log("Cursor stopped. Tracking locked to " .. locked_center.x .. ", " .. locked_center.y) + end + end + + if should_stop_timer then + is_timer_running = false + obs.timer_remove(on_timer) + end + end + end +end + +function set_crop_settings(crop) + if crop_filter ~= nil and crop_filter_settings ~= nil then + -- Call into OBS to update our crop filter with the new settings + -- I have no idea how slow/expensive this is, so we could potentially only do it if something changes + obs.obs_data_set_int(crop_filter_settings, "left", math.floor(crop.x)) + obs.obs_data_set_int(crop_filter_settings, "top", math.floor(crop.y)) + obs.obs_data_set_int(crop_filter_settings, "cx", math.floor(crop.w)) + obs.obs_data_set_int(crop_filter_settings, "cy", math.floor(crop.h)) + obs.obs_source_update(crop_filter, crop_filter_settings) + end +end + +function on_transition_start(t) + log("Transition started") + -- We need to remove the crop from the sceneitem as the transition starts to avoid + -- a delay with the rendering where you see the old crop and jump to the new one + release_sceneitem() +end + +function on_frontend_event(event) + if event == obs.OBS_FRONTEND_EVENT_SCENE_CHANGED then + log("Scene changed") + -- If the scene changes we attempt to find a new source with the same name in this new scene + -- TODO: There probably needs to be a way for users to specify what source they want to use in each scene + refresh_sceneitem(true) + end +end + +function on_update_transform() + -- Update the crop/size settings based on whatever the source in the current scene looks like + refresh_sceneitem(true) + return true +end + +function on_settings_modified(props, prop, settings) + local name = obs.obs_property_name(prop) + + -- Show/Hide the settings based on if the checkbox is checked or not + if name == "use_monitor_override" then + local visible = obs.obs_data_get_bool(settings, "use_monitor_override") + obs.obs_property_set_visible(obs.obs_properties_get(props, "monitor_override_x"), visible) + obs.obs_property_set_visible(obs.obs_properties_get(props, "monitor_override_y"), visible) + obs.obs_property_set_visible(obs.obs_properties_get(props, "monitor_override_w"), visible) + obs.obs_property_set_visible(obs.obs_properties_get(props, "monitor_override_h"), visible) + obs.obs_property_set_visible(obs.obs_properties_get(props, "monitor_override_sx"), visible) + obs.obs_property_set_visible(obs.obs_properties_get(props, "monitor_override_sy"), visible) + obs.obs_property_set_visible(obs.obs_properties_get(props, "monitor_override_dw"), visible) + obs.obs_property_set_visible(obs.obs_properties_get(props, "monitor_override_dh"), visible) + return true + elseif name == "allow_all_sources" then + local sources_list = obs.obs_properties_get(props, "source") + populate_zoom_sources(sources_list) + return true + elseif name == "debug_logs" then + if obs.obs_data_get_bool(settings, "debug_logs") then + log_current_settings() + end + end + + return false +end + +--- +-- Write the current settings into the log for debugging and user issue reports +function log_current_settings() + local settings = { + zoom_value = zoom_value, + zoom_speed = zoom_speed, + use_auto_follow_mouse = use_auto_follow_mouse, + use_follow_outside_bounds = use_follow_outside_bounds, + follow_speed = follow_speed, + follow_border = follow_border, + follow_safezone_sensitivity = follow_safezone_sensitivity, + use_follow_auto_lock = use_follow_auto_lock, + use_monitor_override = use_monitor_override, + monitor_override_x = monitor_override_x, + monitor_override_y = monitor_override_y, + monitor_override_w = monitor_override_w, + monitor_override_h = monitor_override_h, + monitor_override_sx = monitor_override_sx, + monitor_override_sy = monitor_override_sy, + monitor_override_dw = monitor_override_dw, + monitor_override_dh = monitor_override_dh, + debug_logs = debug_logs + } + + log("OBS Version: " .. string.format("%.1f", major)) + log("Current settings:") + log(format_table(settings)) +end + +function on_print_help() + local help = "\n----------------------------------------------------\n" .. + "Help Information for OBS-Zoom-To-Mouse v" .. VERSION .. "\n" .. + "https://github.com/BlankSourceCode/obs-zoom-to-mouse\n" .. + "----------------------------------------------------\n" .. + "This script will zoom the selected display-capture source to focus on the mouse\n\n" .. + "Zoom Source: The display capture in the current scene to use for zooming\n" .. + "Zoom Factor: How much to zoom in by\n" .. + "Zoom Speed: The speed of the zoom in/out animation\n" .. + "Auto follow mouse: True to track the cursor while you are zoomed in\n" .. + "Follow outside bounds: True to track the cursor even when it is outside the bounds of the source\n" .. + "Follow Speed: The speed at which the zoomed area will follow the mouse when tracking\n" .. + "Follow Border: The %distance from the edge of the source that will re-enable mouse tracking\n" .. + "Lock Sensitivity: How close the tracking needs to get before it locks into position and stops tracking until you enter the follow border\n" .. + "Auto Lock on reverse direction: Automatically stop tracking if you reverse the direction of the mouse\n" .. + "Show all sources: True to allow selecting any source as the Zoom Source - You MUST set manual source position for non-display capture sources\n" .. + "Set manual source position: True to override the calculated x/y (topleft position), width/height (size), and scaleX/scaleY (canvas scale factor) for the selected source\n" .. + "X: The coordinate of the left most pixel of the source\n" .. + "Y: The coordinate of the top most pixel of the source\n" .. + "Width: The width of the source (in pixels)\n" .. + "Height: The height of the source (in pixels)\n" .. + "Scale X: The x scale factor to apply to the mouse position if the source size is not 1:1 (useful for cloned sources)\n" .. + "Scale Y: The y scale factor to apply to the mouse position if the source size is not 1:1 (useful for cloned sources)\n" .. + "Monitor Width: The width of the monitor that is showing the source (in pixels)\n" .. + "Monitor Height: The height of the monitor that is showing the source (in pixels)\n" .. + "More Info: Show this text in the script log\n" .. + "Enable debug logging: Show additional debug information in the script log\n\n" + + obs.script_log(obs.OBS_LOG_INFO, help) +end + +function script_description() + return "Zoom the selected display-capture source to focus on the mouse" +end + +function script_properties() + local props = obs.obs_properties_create() + + -- Populate the sources list with the known display-capture sources (OBS calls them 'monitor_capture' internally even though the UI says 'Display Capture') + local sources_list = obs.obs_properties_add_list(props, "source", "选择缩放源", obs.OBS_COMBO_TYPE_LIST, + obs.OBS_COMBO_FORMAT_STRING) + + populate_zoom_sources(sources_list) + + local refresh_sources = obs.obs_properties_add_button(props, "refresh", "刷新缩放源", + function() + populate_zoom_sources(sources_list) + monitor_info = get_monitor_info(source) + return true + end) + obs.obs_property_set_long_description(refresh_sources, + "Click to re-populate Zoom Sources dropdown with available sources") + + -- Add the rest of the settings UI + local zoom = obs.obs_properties_add_float(props, "zoom_value", "缩放系数", 1, 5, 0.5) + local zoom_speed = obs.obs_properties_add_float_slider(props, "zoom_speed", "缩放速度", 0.01, 1, 0.01) + local follow = obs.obs_properties_add_bool(props, "follow", "自动跟随鼠标指针 ") + obs.obs_property_set_long_description(follow, + "启用后,鼠标跟踪将在放大时自动启动,无需等待跟踪切换热键") + + local follow_outside_bounds = obs.obs_properties_add_bool(props, "follow_outside_bounds", "始终跟随指针") + obs.obs_property_set_long_description(follow_outside_bounds, + "启用后,即使光标位于缩放源的边界之外,也会跟踪鼠标") + + local follow_speed = obs.obs_properties_add_float_slider(props, "follow_speed", "跟随速度", 0.01, 1, 0.01) + local follow_border = obs.obs_properties_add_int_slider(props, "follow_border", "跟随边界", 0, 50, 1) + local safezone_sense = obs.obs_properties_add_int_slider(props, + "follow_safezone_sensitivity", "锁定灵敏度", 1, 20, 1) + local follow_auto_lock = obs.obs_properties_add_bool(props, "follow_auto_lock", "反向自动锁定 ") + obs.obs_property_set_long_description(follow_auto_lock, + "启用后,将鼠标移动到缩放源的边缘将开始跟踪,\n" .. + "但是向后移动到中心将停止跟踪 RTS 游戏中的平移镜头") + + local allow_all = obs.obs_properties_add_bool(props, "allow_all_sources", "允许任何缩放源 ") + obs.obs_property_set_long_description(allow_all, "启用此选项可允许选择任何源作为缩放源\n" .. + "您必须为非显示捕获源设置手动源位置") + + local override = obs.obs_properties_add_bool(props, "use_monitor_override", "设置手动源位置 ") + obs.obs_property_set_long_description(override, + "启用后,指定的大小/位置的设置将用于缩放源,而不是自动计算的设置") + + local override_x = obs.obs_properties_add_int(props, "monitor_override_x", "X", -10000, 10000, 1) + local override_y = obs.obs_properties_add_int(props, "monitor_override_y", "Y", -10000, 10000, 1) + local override_w = obs.obs_properties_add_int(props, "monitor_override_w", "Width", 0, 10000, 1) + local override_h = obs.obs_properties_add_int(props, "monitor_override_h", "Height", 0, 10000, 1) + local override_sx = obs.obs_properties_add_float(props, "monitor_override_sx", "Scale X ", 0, 100, 0.01) + local override_sy = obs.obs_properties_add_float(props, "monitor_override_sy", "Scale Y ", 0, 100, 0.01) + local override_dw = obs.obs_properties_add_int(props, "monitor_override_dw", "Monitor Width ", 0, 10000, 1) + local override_dh = obs.obs_properties_add_int(props, "monitor_override_dh", "Monitor Height ", 0, 10000, 1) + + obs.obs_property_set_long_description(override_sx, "Usually 1 - unless you are using a scaled source") + obs.obs_property_set_long_description(override_sy, "Usually 1 - unless you are using a scaled source") + obs.obs_property_set_long_description(override_dw, "X resolution of your montior") + obs.obs_property_set_long_description(override_dh, "Y resolution of your monitor") + + -- Add a button for more information + local help = obs.obs_properties_add_button(props, "help_button", "更多信息", on_print_help) + obs.obs_property_set_long_description(help, + "Click to show help information (via the script log)") + + local debug = obs.obs_properties_add_bool(props, "debug_logs", "启用调试日志记录 ") + obs.obs_property_set_long_description(debug, + "启用后,脚本会将诊断消息输出到脚本日志(对于调试/github 问题很有用)") + + obs.obs_property_set_visible(override_x, use_monitor_override) + obs.obs_property_set_visible(override_y, use_monitor_override) + obs.obs_property_set_visible(override_w, use_monitor_override) + obs.obs_property_set_visible(override_h, use_monitor_override) + obs.obs_property_set_visible(override_sx, use_monitor_override) + obs.obs_property_set_visible(override_sy, use_monitor_override) + obs.obs_property_set_visible(override_dw, use_monitor_override) + obs.obs_property_set_visible(override_dh, use_monitor_override) + obs.obs_property_set_modified_callback(override, on_settings_modified) + obs.obs_property_set_modified_callback(allow_all, on_settings_modified) + obs.obs_property_set_modified_callback(debug, on_settings_modified) + + return props +end + +function script_load(settings) + sceneitem_info_orig = nil + + -- Add our hotkey + hotkey_zoom_id = obs.obs_hotkey_register_frontend("toggle_zoom_hotkey", "在鼠标指针处缩放", + on_toggle_zoom) + + hotkey_follow_id = obs.obs_hotkey_register_frontend("toggle_follow_hotkey", "跟随鼠标指针缩放", + on_toggle_follow) + + -- Attempt to reload existing hotkey bindings if we can find any + local hotkey_save_array = obs.obs_data_get_array(settings, "obs_zoom_to_mouse.hotkey.zoom") + obs.obs_hotkey_load(hotkey_zoom_id, hotkey_save_array) + obs.obs_data_array_release(hotkey_save_array) + + hotkey_save_array = obs.obs_data_get_array(settings, "obs_zoom_to_mouse.hotkey.follow") + obs.obs_hotkey_load(hotkey_follow_id, hotkey_save_array) + obs.obs_data_array_release(hotkey_save_array) + + -- Load any other settings + zoom_value = obs.obs_data_get_double(settings, "zoom_value") + zoom_speed = obs.obs_data_get_double(settings, "zoom_speed") + use_auto_follow_mouse = obs.obs_data_get_bool(settings, "follow") + use_follow_outside_bounds = obs.obs_data_get_bool(settings, "follow_outside_bounds") + follow_speed = obs.obs_data_get_double(settings, "follow_speed") + follow_border = obs.obs_data_get_int(settings, "follow_border") + follow_safezone_sensitivity = obs.obs_data_get_int(settings, "follow_safezone_sensitivity") + use_follow_auto_lock = obs.obs_data_get_bool(settings, "follow_auto_lock") + allow_all_sources = obs.obs_data_get_bool(settings, "allow_all_sources") + use_monitor_override = obs.obs_data_get_bool(settings, "use_monitor_override") + monitor_override_x = obs.obs_data_get_int(settings, "monitor_override_x") + monitor_override_y = obs.obs_data_get_int(settings, "monitor_override_y") + monitor_override_w = obs.obs_data_get_int(settings, "monitor_override_w") + monitor_override_h = obs.obs_data_get_int(settings, "monitor_override_h") + monitor_override_sx = obs.obs_data_get_double(settings, "monitor_override_sx") + monitor_override_sy = obs.obs_data_get_double(settings, "monitor_override_sy") + monitor_override_dw = obs.obs_data_get_int(settings, "monitor_override_dw") + monitor_override_dh = obs.obs_data_get_int(settings, "monitor_override_dh") + debug_logs = obs.obs_data_get_bool(settings, "debug_logs") + + obs.obs_frontend_add_event_callback(on_frontend_event) + + if debug_logs then + log_current_settings() + end + + -- Add the transition_start event handlers to each transition (the global source_transition_start event never fires) + local transitions = obs.obs_frontend_get_transitions() + if transitions ~= nil then + for i, s in pairs(transitions) do + local name = obs.obs_source_get_name(s) + log("Adding transition_start listener to " .. name) + local handler = obs.obs_source_get_signal_handler(s) + obs.signal_handler_connect(handler, "transition_start", on_transition_start) + end + obs.source_list_release(transitions) + end + + if ffi.os == "Linux" and not x11_display then + log("ERROR: Could not get X11 Display for Linux\n" .. + "Mouse position will be incorrect.") + end +end + +function script_unload() + -- Clean up the memory usage + if major > 29.0 then -- 29.0 seems to crash if you do this, so we ignore it as the script is closing anyway + local transitions = obs.obs_frontend_get_transitions() + if transitions ~= nil then + for i, s in pairs(transitions) do + local handler = obs.obs_source_get_signal_handler(s) + obs.signal_handler_disconnect(handler, "transition_start", on_transition_start) + end + obs.source_list_release(transitions) + end + + obs.obs_hotkey_unregister(on_toggle_zoom) + obs.obs_hotkey_unregister(on_toggle_follow) + obs.obs_frontend_remove_event_callback(on_frontend_event) + release_sceneitem() + end + + if x11_lib ~= nil and x11_display ~= nil then + x11_lib.XCloseDisplay(x11_display) + end +end + +function script_defaults(settings) + -- Default values for the script + obs.obs_data_set_default_double(settings, "zoom_value", 2) + obs.obs_data_set_default_double(settings, "zoom_speed", 0.06) + obs.obs_data_set_default_bool(settings, "follow", true) + obs.obs_data_set_default_bool(settings, "follow_outside_bounds", false) + obs.obs_data_set_default_double(settings, "follow_speed", 0.25) + obs.obs_data_set_default_int(settings, "follow_border", 8) + obs.obs_data_set_default_int(settings, "follow_safezone_sensitivity", 4) + obs.obs_data_set_default_bool(settings, "follow_auto_lock", false) + obs.obs_data_set_default_bool(settings, "allow_all_sources", false) + obs.obs_data_set_default_bool(settings, "use_monitor_override", false) + obs.obs_data_set_default_int(settings, "monitor_override_x", 0) + obs.obs_data_set_default_int(settings, "monitor_override_y", 0) + obs.obs_data_set_default_int(settings, "monitor_override_w", 1920) + obs.obs_data_set_default_int(settings, "monitor_override_h", 1080) + obs.obs_data_set_default_double(settings, "monitor_override_sx", 1) + obs.obs_data_set_default_double(settings, "monitor_override_sy", 1) + obs.obs_data_set_default_int(settings, "monitor_override_dw", 1920) + obs.obs_data_set_default_int(settings, "monitor_override_dh", 1080) + obs.obs_data_set_default_bool(settings, "debug_logs", false) +end + +function script_save(settings) + -- Save the custom hotkey information + if hotkey_zoom_id ~= nil then + local hotkey_save_array = obs.obs_hotkey_save(hotkey_zoom_id) + obs.obs_data_set_array(settings, "obs_zoom_to_mouse.hotkey.zoom", hotkey_save_array) + obs.obs_data_array_release(hotkey_save_array) + end + + if hotkey_follow_id ~= nil then + local hotkey_save_array = obs.obs_hotkey_save(hotkey_follow_id) + obs.obs_data_set_array(settings, "obs_zoom_to_mouse.hotkey.follow", hotkey_save_array) + obs.obs_data_array_release(hotkey_save_array) + end +end + +function script_update(settings) + local old_source_name = source_name + local old_override = use_monitor_override + local old_x = monitor_override_x + local old_y = monitor_override_y + local old_w = monitor_override_w + local old_h = monitor_override_h + local old_sx = monitor_override_sx + local old_sy = monitor_override_sy + local old_dw = monitor_override_dw + local old_dh = monitor_override_dh + + -- Update the settings + source_name = obs.obs_data_get_string(settings, "source") + zoom_value = obs.obs_data_get_double(settings, "zoom_value") + zoom_speed = obs.obs_data_get_double(settings, "zoom_speed") + use_auto_follow_mouse = obs.obs_data_get_bool(settings, "follow") + use_follow_outside_bounds = obs.obs_data_get_bool(settings, "follow_outside_bounds") + follow_speed = obs.obs_data_get_double(settings, "follow_speed") + follow_border = obs.obs_data_get_int(settings, "follow_border") + follow_safezone_sensitivity = obs.obs_data_get_int(settings, "follow_safezone_sensitivity") + use_follow_auto_lock = obs.obs_data_get_bool(settings, "follow_auto_lock") + allow_all_sources = obs.obs_data_get_bool(settings, "allow_all_sources") + use_monitor_override = obs.obs_data_get_bool(settings, "use_monitor_override") + monitor_override_x = obs.obs_data_get_int(settings, "monitor_override_x") + monitor_override_y = obs.obs_data_get_int(settings, "monitor_override_y") + monitor_override_w = obs.obs_data_get_int(settings, "monitor_override_w") + monitor_override_h = obs.obs_data_get_int(settings, "monitor_override_h") + monitor_override_sx = obs.obs_data_get_double(settings, "monitor_override_sx") + monitor_override_sy = obs.obs_data_get_double(settings, "monitor_override_sy") + monitor_override_dw = obs.obs_data_get_int(settings, "monitor_override_dw") + monitor_override_dh = obs.obs_data_get_int(settings, "monitor_override_dh") + debug_logs = obs.obs_data_get_bool(settings, "debug_logs") + + -- Only do the expensive refresh if the user selected a new source + if source_name ~= old_source_name then + refresh_sceneitem(true) + end + + -- Update the monitor_info if the settings changed + if source_name ~= old_source_name or + use_monitor_override ~= old_override or + monitor_override_x ~= old_x or + monitor_override_y ~= old_y or + monitor_override_w ~= old_w or + monitor_override_h ~= old_h or + monitor_override_sx ~= old_sx or + monitor_override_sy ~= old_sy or + monitor_override_w ~= old_dw or + monitor_override_h ~= old_dh then + monitor_info = get_monitor_info(source) + end +end + +function populate_zoom_sources(list) + obs.obs_property_list_clear(list) + + local sources = obs.obs_enum_sources() + if sources ~= nil then + local dc_info = get_dc_info() + obs.obs_property_list_add_string(list, "", "obs-zoom-to-mouse-none") + for _, source in ipairs(sources) do + local source_type = obs.obs_source_get_id(source) + if source_type == dc_info.source_id or allow_all_sources then + local name = obs.obs_source_get_name(source) + obs.obs_property_list_add_string(list, name, name) + end + end + + obs.source_list_release(sources) + end +end