Created
November 15, 2024 07:49
-
-
Save aaronfay/a1b4272b43d83a8088969800e952d9e0 to your computer and use it in GitHub Desktop.
Adjusts OBS zoom-to-mouse plugin script to handle Retina displays
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
-- | |
-- OBS Zoom to Mouse | |
-- An OBS lua script to zoom a display-capture source to focus on the mouse. | |
-- Copyright (c) BlankSourceCode. All rights reserved. | |
-- | |
local obs = obslua | |
local ffi = require("ffi") | |
local VERSION = "1.0.2" | |
local CROP_FILTER_NAME = "obs-zoom-to-mouse-crop" | |
local socket_available, socket = pcall(require, "ljsocket") | |
local socket_server = nil | |
local socket_mouse = nil | |
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 use_socket = false | |
local socket_port = 0 | |
local socket_poll = 1000 | |
local debug_logs = false | |
local is_obs_loaded = false | |
local is_script_loaded = false | |
local ZoomState = { | |
None = 0, | |
ZoomingIn = 1, | |
ZoomingOut = 2, | |
ZoomedIn = 3, | |
} | |
local zoom_state = ZoomState.None | |
local version = obs.obs_get_version_string() | |
local m1, m2 = version:match("(%d+%.%d+)%.(%d+)") | |
local major = tonumber(m1) or 0 | |
local minor = tonumber(m2) 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 socket_mouse ~= nil then | |
mouse.x = socket_mouse.x | |
mouse.y = socket_mouse.y | |
else | |
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) | |
if monitor_info ~= nil then | |
-- Get the actual screen dimensions without scaling first | |
local screen_width = monitor_info.display_width > 0 and monitor_info.display_width or monitor_info.width | |
local screen_height = monitor_info.display_height > 0 and monitor_info.display_height or monitor_info.height | |
-- Get raw coordinates first | |
mouse.x = point.x | |
mouse.y = point.y | |
-- Convert from bottom-left to top-left coordinate system first | |
if monitor_info.display_height and monitor_info.display_height > 0 then | |
mouse.y = monitor_info.display_height - mouse.y | |
end | |
-- For Retina displays, we need to scale the coordinates correctly | |
if monitor_info.scale_x and monitor_info.scale_x > 0 then | |
mouse.x = mouse.x * monitor_info.scale_x | |
end | |
if monitor_info.scale_y and monitor_info.scale_y > 0 then | |
mouse.y = mouse.y * monitor_info.scale_y | |
end | |
-- Adjust for monitor position last | |
if monitor_info.x then | |
mouse.x = mouse.x - monitor_info.x | |
end | |
if monitor_info.y then | |
mouse.y = mouse.y - monitor_info.y | |
end | |
-- Floor the values after all calculations | |
mouse.x = math.floor(mouse.x) | |
mouse.y = math.floor(mouse.y) | |
-- Minimal debug output | |
if debug_logs then | |
log(string.format("Mouse pos: %d,%d (screen: %dx%d)", mouse.x, mouse.y, screen_width, screen_height)) | |
end | |
else | |
mouse.x = math.floor(point.x) | |
mouse.y = math.floor(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 then | |
if obs.obs_source_is_scene(nested) then | |
local nested_scene = obs.obs_scene_from_source(nested) | |
table.insert(queue, nested_scene) | |
elseif obs.obs_source_is_group(nested) then | |
local nested_scene = obs.obs_group_from_source(nested) | |
table.insert(queue, nested_scene) | |
end | |
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 | |
if monitor_info ~= nil and monitor_info.width > 0 and monitor_info.height > 0 then | |
log("WARNING: Something went wrong determining source size.\n" .. | |
" Using source size from info: " .. monitor_info.width .. ", " .. monitor_info.height) | |
source_width = monitor_info.width | |
source_height = monitor_info.height | |
else | |
log("ERROR: Something went wrong determining source size.\n" .. | |
" Try using the 'Set manual source position' option and adding override values") | |
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 non-relative crop/pad filter (" .. | |
name .. | |
"). Applying settings " .. format_table(zoom_info.source_crop_filter)) | |
else | |
log("WARNING: Found existing 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 debug_logs then | |
log(string.format("Initial mouse pos: %d,%d", mouse.x, mouse.y)) | |
end | |
-- Calculate the new size after zooming | |
local new_size = { | |
width = math.floor(zoom.source_size.width / zoom_value), | |
height = math.floor(zoom.source_size.height / zoom_value) | |
} | |
-- Calculate the top-left position of the zoomed area | |
local pos = { | |
x = mouse.x - (new_size.width / 2), | |
y = mouse.y - (new_size.height / 2) | |
} | |
-- Ensure the calculated position is within the bounds of the source | |
local max_x = zoom.source_size.width - new_size.width | |
local max_y = zoom.source_size.height - new_size.height | |
local crop = { | |
x = math.max(0, math.min(max_x, pos.x)), | |
y = math.max(0, math.min(max_y, pos.y)), | |
w = new_size.width, | |
h = new_size.height | |
} | |
if debug_logs then | |
log(string.format("Target position - Mouse: %d,%d", mouse.x, mouse.y)) | |
log(string.format("Target position - Source size: %dx%d", zoom.source_size.width, zoom.source_size.height)) | |
log(string.format("Target position - New size: %dx%d", new_size.width, new_size.height)) | |
log(string.format("Target position - Crop: x=%d,y=%d,w=%d,h=%d", crop.x, crop.y, crop.w, crop.h)) | |
end | |
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 on_socket_timer() | |
if not socket_server then | |
return | |
end | |
repeat | |
local data, status = socket_server:receive_from() | |
if data then | |
local sx, sy = data:match("(-?%d+) (-?%d+)") | |
if sx and sy then | |
local x = tonumber(sx, 10) | |
local y = tonumber(sy, 10) | |
if not socket_mouse then | |
log("Socket server client connected") | |
socket_mouse = { x = x, y = y } | |
else | |
socket_mouse.x = x | |
socket_mouse.y = y | |
end | |
end | |
elseif status ~= "timeout" then | |
error(status) | |
end | |
until data == nil | |
end | |
function start_server() | |
if socket_available then | |
local address = socket.find_first_address("*", socket_port) | |
socket_server = socket.create("inet", "dgram", "udp") | |
if socket_server ~= nil then | |
socket_server:set_option("reuseaddr", 1) | |
socket_server:set_blocking(false) | |
socket_server:bind(address, socket_port) | |
obs.timer_add(on_socket_timer, socket_poll) | |
log("Socket server listening on port " .. socket_port .. "...") | |
end | |
end | |
end | |
function stop_server() | |
if socket_server ~= nil then | |
log("Socket server stopped") | |
obs.timer_remove(on_socket_timer) | |
socket_server:close() | |
socket_server = nil | |
socket_mouse = nil | |
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("OBS 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 | |
-- Scene change can happen before OBS has completely loaded, so we check for that here | |
if is_obs_loaded then | |
refresh_sceneitem(true) | |
end | |
elseif event == obs.OBS_FRONTEND_EVENT_FINISHED_LOADING then | |
log("OBS Loaded") | |
-- Once loaded we perform our initial lookup | |
is_obs_loaded = true | |
monitor_info = get_monitor_info(source) | |
refresh_sceneitem(true) | |
elseif event == obs.OBS_FRONTEND_EVENT_SCRIPTING_SHUTDOWN then | |
log("OBS Shutting down") | |
-- Add a fail-safe for unloading the script during shutdown | |
if is_script_loaded then | |
script_unload() | |
end | |
end | |
end | |
function on_update_transform() | |
-- Update the crop/size settings based on whatever the source in the current scene looks like | |
if is_obs_loaded then | |
refresh_sceneitem(true) | |
end | |
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_label"), not visible) | |
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 == "use_socket" then | |
local visible = obs.obs_data_get_bool(settings, "use_socket") | |
obs.obs_property_set_visible(obs.obs_properties_get(props, "socket_label"), not visible) | |
obs.obs_property_set_visible(obs.obs_properties_get(props, "socket_port"), visible) | |
obs.obs_property_set_visible(obs.obs_properties_get(props, "socket_poll"), 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, | |
use_socket = use_socket, | |
socket_port = socket_port, | |
socket_poll = socket_poll, | |
debug_logs = debug_logs, | |
version = VERSION | |
} | |
log("OBS Version: " .. string.format("%.1f", major) .. "." .. minor) | |
log("Platform: " .. ffi.os) | |
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" | |
if socket_available then | |
help = help .. | |
"Enable remote mouse listener: True to start a UDP socket server that will listen for mouse position messages from a remote client, see: https://github.com/BlankSourceCode/obs-zoom-to-mouse-remote\n" .. | |
"Port: The port number to use for the socket server\n" .. | |
"Poll Delay: The time between updating the mouse position (in milliseconds)\n" | |
end | |
help = help .. | |
"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", "Zoom 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", "Refresh zoom sources", | |
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", "Zoom Factor", 1, 5, 0.5) | |
local zoom_speed = obs.obs_properties_add_float_slider(props, "zoom_speed", "Zoom Speed", 0.01, 1, 0.01) | |
local follow = obs.obs_properties_add_bool(props, "follow", "Auto follow mouse ") | |
obs.obs_property_set_long_description(follow, | |
"When enabled mouse traking will auto-start when zoomed in without waiting for tracking toggle hotkey") | |
local follow_outside_bounds = obs.obs_properties_add_bool(props, "follow_outside_bounds", "Follow outside bounds ") | |
obs.obs_property_set_long_description(follow_outside_bounds, | |
"When enabled the mouse will be tracked even when the cursor is outside the bounds of the zoom source") | |
local follow_speed = obs.obs_properties_add_float_slider(props, "follow_speed", "Follow Speed", 0.01, 1, 0.01) | |
local follow_border = obs.obs_properties_add_int_slider(props, "follow_border", "Follow Border", 0, 50, 1) | |
local safezone_sense = obs.obs_properties_add_int_slider(props, | |
"follow_safezone_sensitivity", "Lock Sensitivity", 1, 20, 1) | |
local follow_auto_lock = obs.obs_properties_add_bool(props, "follow_auto_lock", "Auto Lock on reverse direction ") | |
obs.obs_property_set_long_description(follow_auto_lock, | |
"When enabled moving the mouse to edge of the zoom source will begin tracking,\n" .. | |
"but moving back towards the center will stop tracking simliar to panning the camera in a RTS game") | |
local allow_all = obs.obs_properties_add_bool(props, "allow_all_sources", "Allow any zoom source ") | |
obs.obs_property_set_long_description(allow_all, "Enable to allow selecting any source as the Zoom Source\n" .. | |
"You MUST set manual source position for non-display capture sources") | |
local override_props = obs.obs_properties_create(); | |
local override_label = obs.obs_properties_add_text(override_props, "monitor_override_label", "", obs.OBS_TEXT_INFO) | |
local override_x = obs.obs_properties_add_int(override_props, "monitor_override_x", "X", -10000, 10000, 1) | |
local override_y = obs.obs_properties_add_int(override_props, "monitor_override_y", "Y", -10000, 10000, 1) | |
local override_w = obs.obs_properties_add_int(override_props, "monitor_override_w", "Width", 0, 10000, 1) | |
local override_h = obs.obs_properties_add_int(override_props, "monitor_override_h", "Height", 0, 10000, 1) | |
local override_sx = obs.obs_properties_add_float(override_props, "monitor_override_sx", "Scale X ", 0, 100, 0.01) | |
local override_sy = obs.obs_properties_add_float(override_props, "monitor_override_sy", "Scale Y ", 0, 100, 0.01) | |
local override_dw = obs.obs_properties_add_int(override_props, "monitor_override_dw", "Monitor Width ", 0, 10000, 1) | |
local override_dh = obs.obs_properties_add_int(override_props, "monitor_override_dh", "Monitor Height ", 0, 10000, 1) | |
local override = obs.obs_properties_add_group(props, "use_monitor_override", "Set manual source position ", | |
obs.OBS_GROUP_CHECKABLE, override_props) | |
obs.obs_property_set_long_description(override_label, | |
"When enabled the specified size/position settings will be used for the zoom source instead of the auto-calculated ones") | |
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") | |
if socket_available then | |
local socket_props = obs.obs_properties_create(); | |
local r_label = obs.obs_properties_add_text(socket_props, "socket_label", "", obs.OBS_TEXT_INFO) | |
local r_port = obs.obs_properties_add_int(socket_props, "socket_port", "Port ", 1024, 65535, 1) | |
local r_poll = obs.obs_properties_add_int(socket_props, "socket_poll", "Poll Delay (ms) ", 0, 1000, 1) | |
local socket = obs.obs_properties_add_group(props, "use_socket", "Enable remote mouse listener ", | |
obs.OBS_GROUP_CHECKABLE, socket_props) | |
obs.obs_property_set_long_description(r_label, | |
"When enabled a UDP socket server will listen for mouse position messages from a remote client") | |
obs.obs_property_set_long_description(r_port, | |
"You must restart the server after changing the port (Uncheck then re-check 'Enable remote mouse listener')") | |
obs.obs_property_set_long_description(r_poll, | |
"You must restart the server after changing the poll delay (Uncheck then re-check 'Enable remote mouse listener')") | |
obs.obs_property_set_visible(r_label, not use_socket) | |
obs.obs_property_set_visible(r_port, use_socket) | |
obs.obs_property_set_visible(r_poll, use_socket) | |
obs.obs_property_set_modified_callback(socket, on_settings_modified) | |
end | |
-- Add a button for more information | |
local help = obs.obs_properties_add_button(props, "help_button", "More Info", 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", "Enable debug logging ") | |
obs.obs_property_set_long_description(debug, | |
"When enabled the script will output diagnostics messages to the script log (useful for debugging/github issues)") | |
obs.obs_property_set_visible(override_label, not use_monitor_override) | |
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 | |
-- Workaround for detecting if OBS is already loaded and we were reloaded using "Reload Scripts" | |
local current_scene = obs.obs_frontend_get_current_scene() | |
is_obs_loaded = current_scene ~= nil -- Current scene is nil on first OBS load | |
obs.obs_source_release(current_scene) | |
-- Add our hotkey | |
hotkey_zoom_id = obs.obs_hotkey_register_frontend("toggle_zoom_hotkey", "Toggle zoom to mouse", | |
on_toggle_zoom) | |
hotkey_follow_id = obs.obs_hotkey_register_frontend("toggle_follow_hotkey", "Toggle follow mouse during zoom", | |
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") | |
use_socket = obs.obs_data_get_bool(settings, "use_socket") | |
socket_port = obs.obs_data_get_int(settings, "socket_port") | |
socket_poll = obs.obs_data_get_int(settings, "socket_poll") | |
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 | |
source_name = "" | |
use_socket = false | |
is_script_loaded = true | |
end | |
function script_unload() | |
is_script_loaded = false | |
-- Clean up the memory usage | |
if major > 29.1 or (major == 29.1 and minor > 2) then -- 29.1.2 and below 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) | |
x11_display = nil | |
x11_lib = nil | |
end | |
if socket_server ~= nil then | |
stop_server() | |
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, "use_socket", false) | |
obs.obs_data_set_default_int(settings, "socket_port", 12345) | |
obs.obs_data_set_default_int(settings, "socket_poll", 10) | |
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 | |
local old_socket = use_socket | |
local old_port = socket_port | |
local old_poll = socket_poll | |
-- 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") | |
use_socket = obs.obs_data_get_bool(settings, "use_socket") | |
socket_port = obs.obs_data_get_int(settings, "socket_port") | |
socket_poll = obs.obs_data_get_int(settings, "socket_poll") | |
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 and is_obs_loaded 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 | |
if is_obs_loaded then | |
monitor_info = get_monitor_info(source) | |
end | |
end | |
if old_socket ~= use_socket then | |
if use_socket then | |
start_server() | |
else | |
stop_server() | |
end | |
elseif use_socket and (old_poll ~= socket_poll or old_port ~= socket_port) then | |
stop_server() | |
start_server() | |
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, "<None>", "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 |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment