How to Record Project Overview Videos with OBS
Learn how to create engaging project videos using OBS Studio with a custom zoom-to-mouse script. Perfect for showcasing web applications and interactive demos.

Why Use Videos Instead of Static Images?
When showcasing projects in your portfolio, especially web applications or interactive demos, static screenshots can only tell part of the story. Videos allow you to demonstrate user interactions, animations, and the overall flow of your application in a way that static images simply cannot. For my project showcases, I've found that videos provide a much more engaging experience. They let viewers see exactly how the application works, how smooth the interactions are, and how the interface responds to user input. This is particularly valuable for complex applications where the user experience is a key selling point.
Introducing OBS Studio and the Zoom-to-Mouse Script
OBS Studio (Open Broadcaster Software) is a free, open-source software for video recording and live streaming. While it's primarily known for streaming, it's also an excellent tool for recording high-quality project demonstrations. The challenge with recording project demos is that you often want to focus on specific areas of the screen - like where the mouse cursor is - to highlight important interactions. This is where the zoom-to-mouse script comes in. The script automatically zooms into the area around your mouse cursor, creating a smooth, professional-looking video that guides the viewer's attention to exactly what you want them to see.
Initial Challenges and Reworking the Script
When I first tried using the zoom-to-mouse script for OBS, I encountered a critical issue: the original script, available on GitHub, had an error and didn't even run for me. After investigating the problem, I reworked the script to fix the error and make it work reliably for my specific use case. I've included my reworked version below, which fixes the original error and works out of the box. You can also download the original script from the GitHub releases page if you prefer to troubleshoot it yourself.
Step-by-Step Setup Instructions
Here's how to set up OBS Studio with the zoom-to-mouse script:
- Install OBS Studio: Download and install OBS Studio from obsproject.com
- Add Display Capture Source:
- In OBS, create a new scene
- Add a "Display Capture" source
- Select the monitor you want to record
- Create and Install the Script:
- First, you'll need to create a
.luascript file (see instructions below) - Go to Tools → Scripts in OBS
- Click the "+" button to add a new script
- Select your
obs-zoom-to-mouse.luafile
- First, you'll need to create a
- Configure the Script Settings:
- Select your Display Capture source in the "Zoom Source" dropdown
- Set the following values:
- Zoom Factor: 1.50
- Zoom Speed: 0.03
- Follow Speed: 0.08
- Follow Border: 0
- Lock Sensitivity: 4
- Set Up Hotkey:
- Go to Settings → Hotkeys in OBS
- Find "Toggle zoom to mouse" in the list
- Set it to F1 (or your preferred key)
- Start Recording:
- Press F1 to toggle zoom on/off
- Use OBS's standard recording controls to start/stop recording
Understanding the Configuration Settings
Each setting in the script controls a different aspect of the zoom behavior:
-
Zoom Factor (1.50): How much the view zooms in. 1.50 means 1.5x magnification, which provides a good balance between detail and context.
-
Zoom Speed (0.03): How quickly the zoom animation happens. Lower values mean slower, smoother transitions.
-
Follow Speed (0.08): How quickly the zoomed area follows your mouse cursor. This creates smooth tracking as you move around the screen.
-
Follow Border (0): The percentage distance from the edge that triggers re-tracking. Setting this to 0 means it will always follow the mouse.
-
Lock Sensitivity (4): How close the tracking needs to get before it locks into position. This prevents jittery movements when the cursor stops.
Tips and Best Practices
- Practice Your Movements: Before recording, practice the mouse movements you want to demonstrate. Smooth, deliberate movements create better videos.
- Use Keyboard Shortcuts: When possible, use keyboard shortcuts instead of clicking through menus. This creates cleaner, faster demonstrations.
- Record in High Quality: Set OBS to record at a high resolution (at least 1920x1080) and a good frame rate (30fps or 60fps).
- Edit Post-Recording: While the script creates great raw footage, consider editing the video to remove any mistakes or add annotations.
- Test Your Setup: Always do a test recording first to ensure the zoom behavior looks good and the audio (if any) is clear.
How to Create a .lua Script File (Beginner-Friendly)
If you've never created a script file before, don't worry - it's very simple! Here's how to do it:
On Windows:
- Open Notepad (or any text editor)
- Copy the script code from the code block below (or download from GitHub)
- Paste it into Notepad
- Go to File → Save As
- In the "Save as type" dropdown, select "All Files (.)"
- Name the file
obs-zoom-to-mouse.lua(make sure it ends with.lua, not.txt) - Save it somewhere easy to find, like your Desktop or Documents folder
On macOS:
- Open TextEdit
- Go to Format → Make Plain Text (this is important!)
- Copy the script code from the code block below (or download from GitHub)
- Paste it into TextEdit
- Go to File → Save
- Name the file
obs-zoom-to-mouse.lua - Make sure "If no extension is provided, use .txt" is UNCHECKED
- Save it somewhere easy to find, like your Desktop or Documents folder
On Linux:
- Open your favorite text editor (gedit, nano, vim, etc.)
- Copy the script code from the code block below (or download from GitHub)
- Paste it into the editor
- Save the file as
obs-zoom-to-mouse.lua - Make sure the file has execute permissions if needed:
chmod +x obs-zoom-to-mouse.luaImportant Tips:
- The file MUST end with
.lua(not.lua.txtor anything else)- Make sure you're saving as plain text, not a rich text document- If you see the file icon change to show a script icon, you've done it correctly!
The Scripts
You have two options for the script:
Option 1: Original Script
The original script is available on the GitHub releases page. Simply download the .lua file from there and follow the installation steps above.
Option 2: My Reworked Version
Below is my reworked version of the script that I've tested and optimized for my setup. It includes the same features as the original but with some improvements for reliability and smoothness:
lua-- -- 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 -- ============================================================ -- OBS Lua API compatibility (OBS 31/32 moved to *_info2) -- ============================================================ local function sceneitem_get_info(item, info) if obs.obs_sceneitem_get_info2 then return obs.obs_sceneitem_get_info2(item, info) end return obs.obs_sceneitem_get_info(item, info) end local function sceneitem_set_info(item, info) if obs.obs_sceneitem_set_info2 then return obs.obs_sceneitem_set_info2(item, info) end return obs.obs_sceneitem_set_info(item, info) end -- 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) mouse.x = point.x if monitor_info ~= nil then if monitor_info.display_height > 0 then mouse.y = monitor_info.display_height - point.y else mouse.y = monitor_info.height - point.y end end end end 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") -- FIX: this should SET the original info back, not GET it. sceneitem_set_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 == nil or source_name == "" or 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() 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() 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 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 } -- After: zoom_info.source_size = { width = source_width, height = source_height } -- FIX (macOS/Retina): mouse coords often come in "points" but OBS crops in "pixels". -- If display_width/height are known, derive scale automatically. if monitor_info ~= nil and monitor_info.display_width and monitor_info.display_height and monitor_info.display_width > 0 and monitor_info.display_height > 0 then local sx = zoom_info.source_size.width / monitor_info.display_width local sy = zoom_info.source_size.height / monitor_info.display_height -- Only override if it looks like a real scale (e.g. 2.0 on Retina), not noise. if sx > 1.1 or sy > 1.1 then monitor_info.scale_x = sx monitor_info.scale_y = sy log(string.format("Auto scale set: scale_x=%.3f scale_y=%.3f", sx, sy)) end end 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 } -- Set the initial the crop filter data to match the source crop_filter_info_orig = { x = 0, y = 0, w = zoom_info.source_size.width, h = zoom_info.source_size.height } crop_filter_info = { x = crop_filter_info_orig.x, y = crop_filter_info_orig.y, w = crop_filter_info_orig.w, h = crop_filter_info_orig.h } -- Get or create our crop filter that we change during zoom crop_filter = obs.obs_source_get_filter_by_name(source, CROP_FILTER_NAME) if crop_filter == nil then crop_filter_settings = obs.obs_data_create() obs.obs_data_set_bool(crop_filter_settings, "relative", false) crop_filter = obs.obs_source_create_private("crop_filter", CROP_FILTER_NAME, crop_filter_settings) obs.obs_source_filter_add(source, crop_filter) else crop_filter_settings = obs.obs_source_get_settings(crop_filter) end obs.obs_source_filter_set_order(source, crop_filter, obs.OBS_ORDER_MOVE_BOTTOM) set_crop_settings(crop_filter_info_orig) end end --- -- Get the target position that we will attempt to zoom towards ---@param zoom any ---@return table function get_target_position(zoom) local mouse = get_mouse_pos() -- If we have monitor information then we can offset the mouse by the top-left of the monitor position if monitor_info then mouse.x = mouse.x - monitor_info.x mouse.y = mouse.y - monitor_info.y end -- Now offset the mouse by the crop top-left mouse.x = mouse.x - zoom.source_crop_filter.x mouse.y = mouse.y - zoom.source_crop_filter.y -- If the source uses a different scale to the display, apply that now. if monitor_info and monitor_info.scale_x and monitor_info.scale_y then mouse.x = mouse.x * monitor_info.scale_x mouse.y = mouse.y * monitor_info.scale_y end -- Get the new size after we zoom local new_size = { width = zoom.source_size.width / zoom.zoom_to, height = zoom.source_size.height / zoom.zoom_to } -- New offset for the crop/pad filter is whereever we clicked minus half the size local pos = { x = mouse.x - new_size.width * 0.5, y = mouse.y - new_size.height * 0.5 } -- Create the full crop results local crop = { x = pos.x, y = pos.y, w = new_size.width, h = new_size.height, } -- Keep the zoom in bounds of the source crop.x = math.floor(clamp(0, (zoom.source_size.width - new_size.width), crop.x)) crop.y = math.floor(clamp(0, (zoom.source_size.height - new_size.height), crop.y)) return { crop = crop, raw_center = mouse, clamped_center = { x = math.floor(crop.x + crop.w * 0.5), y = math.floor(crop.y + crop.h * 0.5) } } end function on_toggle_follow(pressed) if pressed then is_following_mouse = not is_following_mouse log("Tracking mouse is " .. (is_following_mouse and "on" or "off")) if is_following_mouse and zoom_state == ZoomState.ZoomedIn then if is_timer_running == false then is_timer_running = true local timer_interval = math.floor(obs.obs_get_frame_interval_ns() / 1000000) if timer_interval < 1 then timer_interval = 16 end -- ~60fps fallback obs.timer_add(on_timer, timer_interval) end end end end function on_toggle_zoom(pressed) if pressed then if zoom_state == ZoomState.ZoomedIn or zoom_state == ZoomState.None then if zoom_state == ZoomState.ZoomedIn then log("Zooming out") 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") 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 if is_timer_running == false then is_timer_running = true local timer_interval = math.floor(obs.obs_get_frame_interval_ns() / 1000000) if timer_interval < 1 then timer_interval = 16 end -- ~60fps fallback obs.timer_add(on_timer, timer_interval) end end end end function on_timer() if crop_filter_info ~= nil and zoom_target ~= nil then zoom_time = zoom_time + zoom_speed if zoom_state == ZoomState.ZoomingOut or zoom_state == ZoomState.ZoomingIn then if zoom_time <= 1 then 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 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 skip_frame = true end end if not skip_frame then 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 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) 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 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 if zoom_time >= 1 then local should_stop_timer = false 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 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 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 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") release_sceneitem() end function on_frontend_event(event) if event == obs.OBS_FRONTEND_EVENT_SCENE_CHANGED then log("OBS Scene changed") if is_obs_loaded then refresh_sceneitem(true) end elseif event == obs.OBS_FRONTEND_EVENT_FINISHED_LOADING then log("OBS Loaded") 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") if is_script_loaded then script_unload() end end end function on_update_transform() 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) 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" 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() 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") 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 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 local current_scene = obs.obs_frontend_get_current_scene() is_obs_loaded = current_scene ~= nil obs.obs_source_release(current_scene) 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) 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) 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 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 -- Keep whatever source was selected in the UI/settings source_name = obs.obs_data_get_string(settings, "source") -- Don't force-disable socket; respect saved setting -- use_socket stays as loaded above is_script_loaded = true -- If OBS is already loaded (Reload Scripts), initialize immediately if is_obs_loaded then monitor_info = get_monitor_info(source) refresh_sceneitem(true) end end function script_unload() is_script_loaded = false if major > 29.1 or (major == 29.1 and minor > 2) then 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) 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) 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 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") if source_name ~= old_source_name and is_obs_loaded then refresh_sceneitem(true) end 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
Both scripts include:
- Cross-platform mouse position detection (Windows, Linux, macOS)- Smooth zoom in/out animations with configurable speed- Automatic mouse tracking with lock sensitivity- Support for display capture sources- Hotkey support for toggling zoom and follow modes- Optional UDP socket server for remote mouse tracking- Comprehensive error handling and logging The original script was created by BlankSourceCode and is available under an open-source license. My reworked version maintains the same core functionality while addressing some issues I encountered with my specific setup.
Using Your Video in Your Portfolio
Once you've recorded your video with OBS Studio, you have a couple of options for adding it to your portfolio:
Option 1: Convert to GIF
You can use online tools like EZGIF, CloudConvert, or FreeConvert to convert your video to a GIF. I've currently used this approach on one project, and my plan is to add video demonstrations to all my projects going forward. GIFs are great because they:
- Auto-play and loop seamlessly
- Don't require user interaction to start
- Work well for short demonstrations
- Are supported everywhere
Simply upload your video file to one of these tools, adjust the quality and size settings to balance file size and visual quality, then download the GIF and add it to your project.
Option 2: Use the Video Directly
Alternatively, you can use the video file directly in your portfolio. Modern web technologies support video playback with controls, and you can:
- Use HTML5
<video>tags for direct embedding - Upload to platforms like YouTube or Vimeo and embed
- Use video hosting services for better performance
Videos give you more control over playback (play/pause, volume, fullscreen) and typically have better quality than GIFs, though they may require user interaction to start playing.
Both approaches work well - choose based on your needs and the specific requirements of your portfolio platform. While I'm currently using this on just one project, I plan to expand this approach to showcase all my projects with video demonstrations.
Conclusion
Using OBS Studio with the zoom-to-mouse script has transformed how I create project demonstrations. The videos are more engaging, professional, and effectively showcase the interactive nature of web applications. The setup might seem complex at first, but once configured, it's incredibly easy to use - just press F1 to toggle the zoom and start recording. The result is a polished video that highlights exactly what makes your project special. If you're looking to improve your project portfolio presentations, I highly recommend giving this setup a try. The investment in learning OBS and configuring the script pays off with much more compelling project showcases.