Skip to content

Instantly share code, notes, and snippets.

@aaronfay
Created November 15, 2024 07:49
Show Gist options
  • Save aaronfay/a1b4272b43d83a8088969800e952d9e0 to your computer and use it in GitHub Desktop.
Save aaronfay/a1b4272b43d83a8088969800e952d9e0 to your computer and use it in GitHub Desktop.
Adjusts OBS zoom-to-mouse plugin script to handle Retina displays
--
-- 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