Wraps linkctl so the Insta360 Link 2's AI tracking and gimbal presets are reachable from a keypress and from OBS, instead of a terminal. bin/linkctl-macro resolves the linkctl binary itself, because a desktop shortcut is launched with a far sparser PATH than an interactive shell and would otherwise just fail silently. It defaults to --force on the grounds that a deliberate keypress means "act now" even when nothing holds the camera open, which is precisely the state the Link parks itself in. Results go to a desktop notification, since tracking state and gimbal position are invisible otherwise, and linkctl's documented exit codes are mapped to actionable text. kde/install-shortcuts.sh registers Plasma global shortcuts via .desktop entries plus kglobalshortcutsrc. These work under Wayland, where OBS's own hotkeys only fire while OBS has focus. It writes with the shortcut daemon stopped, because the daemon rewrites that file on exit and would discard the entries. Supports --dry-run, --list and --uninstall. obs/obs-linkctl.lua adds OBS hotkeys, test buttons, and scene-to-action mapping, so switching scenes can set the camera state unattended. Its startup action defaults to loading the home preset, which undoes the gimbal's idle parking when OBS launches. Commands are backgrounded so the OBS UI thread never waits on the camera, and identical commands within 250ms are collapsed to absorb the repeated events studio mode emits. Tested: 22 offline Lua checks over shell quoting, action tokenizing, scene map parsing and command construction; the wrapper exercised against a stub linkctl for every documented exit code; the KDE installer dry-run, install, list and uninstall paths run against stubbed Plasma tools in a throwaway HOME. shellcheck clean. Not yet run against a live camera or a real Plasma session.
347 lines
12 KiB
Lua
347 lines
12 KiB
Lua
--[[
|
|
obs-linkctl.lua - Insta360 Link control inside OBS.
|
|
|
|
Adds to OBS:
|
|
* Hotkeys for tracking on/off/toggle, a named preset, and recenter.
|
|
Bind them under Settings -> Hotkeys (search for "Insta360").
|
|
* Scene automation: switching to a scene can set the camera's state, so a
|
|
"Talking Head" scene enables tracking and an "Overhead" scene loads a
|
|
preset without anyone pressing anything.
|
|
* A startup action, which matters on Linux because the Link parks its
|
|
gimbal facing down when idle. Pointing it home when OBS finishes
|
|
loading undoes that automatically.
|
|
|
|
Install: Tools -> Scripts -> + -> pick this file.
|
|
|
|
OBS's own hotkeys only fire while OBS has focus under Wayland. For hotkeys
|
|
that work from anywhere, use the KDE shortcuts in ../kde/ instead; the two
|
|
are complementary and can both be installed.
|
|
--]]
|
|
|
|
local obs = obslua
|
|
|
|
-- Actions are plain linkctl argument lists, resolved when the action runs so
|
|
-- that edits to the preset name in the properties UI take effect immediately.
|
|
local cfg = {
|
|
linkctl = "linkctl",
|
|
device = "",
|
|
force = true,
|
|
preset = "home",
|
|
scene_map_text = "",
|
|
startup_action = "preset load home",
|
|
log_output = false,
|
|
enable_scenes = true,
|
|
}
|
|
|
|
local scene_map = {}
|
|
local hotkey_ids = {}
|
|
local last_run = { cmd = nil, at = 0 }
|
|
-- OBS owns this handle and keeps it alive for the lifetime of the script. We
|
|
-- hold it because a button callback is handed only (props, property) and so
|
|
-- has no other route to write a setting back.
|
|
local script_settings = nil
|
|
|
|
local HOTKEYS = {
|
|
{ name = "linkctl_tracking_toggle", label = "Insta360: toggle AI tracking",
|
|
args = function() return { "tracking", "toggle" } end },
|
|
{ name = "linkctl_tracking_on", label = "Insta360: AI tracking on",
|
|
args = function() return { "tracking", "on" } end },
|
|
{ name = "linkctl_tracking_off", label = "Insta360: AI tracking off",
|
|
args = function() return { "tracking", "off" } end },
|
|
{ name = "linkctl_preset_load", label = "Insta360: load named preset",
|
|
args = function() return { "preset", "load", cfg.preset } end },
|
|
{ name = "linkctl_center", label = "Insta360: recenter gimbal",
|
|
args = function() return { "center" } end },
|
|
}
|
|
|
|
-- Tolerate obs being absent so the pure helpers below stay testable offline.
|
|
local function log(fmt, ...)
|
|
local msg = string.format(fmt, ...)
|
|
if obs then obs.script_log(obs.LOG_INFO, msg) end
|
|
end
|
|
|
|
local function warn(fmt, ...)
|
|
local msg = string.format(fmt, ...)
|
|
if obs then obs.script_log(obs.LOG_WARNING, msg) end
|
|
end
|
|
|
|
-- Single-quote for /bin/sh. Preset names come from user input and reach a
|
|
-- shell, so this is the boundary that has to hold.
|
|
local function shquote(s)
|
|
return "'" .. (tostring(s):gsub("'", "'\\''")) .. "'"
|
|
end
|
|
|
|
-- Split an action string into arguments, honouring double quotes so that a
|
|
-- preset name containing a space still works.
|
|
local function tokenize(s)
|
|
local out, i, n = {}, 1, #s
|
|
while i <= n do
|
|
local c = s:sub(i, i)
|
|
if c:match("%s") then
|
|
i = i + 1
|
|
elseif c == '"' then
|
|
local j = s:find('"', i + 1, true)
|
|
if not j then
|
|
table.insert(out, s:sub(i + 1))
|
|
break
|
|
end
|
|
table.insert(out, s:sub(i + 1, j - 1))
|
|
i = j + 1
|
|
else
|
|
local j = s:find("%s", i) or (n + 1)
|
|
table.insert(out, s:sub(i, j - 1))
|
|
i = j
|
|
end
|
|
end
|
|
return out
|
|
end
|
|
|
|
local function build_cmd(args)
|
|
local parts = { shquote(cfg.linkctl) }
|
|
if cfg.force then
|
|
table.insert(parts, "--force")
|
|
end
|
|
if cfg.device ~= "" then
|
|
table.insert(parts, "--device")
|
|
table.insert(parts, shquote(cfg.device))
|
|
end
|
|
for _, a in ipairs(args) do
|
|
table.insert(parts, shquote(a))
|
|
end
|
|
return table.concat(parts, " ")
|
|
end
|
|
|
|
local function run(args)
|
|
if not args or #args == 0 then
|
|
return
|
|
end
|
|
local cmd = build_cmd(args)
|
|
|
|
-- Studio mode and transitions can emit several scene events in a row.
|
|
-- Collapse identical commands fired within 250ms of each other.
|
|
local now = obs.os_gettime_ns()
|
|
if last_run.cmd == cmd and (now - last_run.at) < 250000000 then
|
|
return
|
|
end
|
|
last_run.cmd, last_run.at = cmd, now
|
|
|
|
if cfg.log_output then
|
|
-- Synchronous, so the camera's reply reaches the script log. Slightly
|
|
-- blocking; intended for troubleshooting rather than normal use.
|
|
local f = io.popen(cmd .. " 2>&1")
|
|
if f then
|
|
local out = f:read("*a")
|
|
f:close()
|
|
log("%s -> %s", cmd, (out:gsub("%s+$", "")))
|
|
else
|
|
warn("could not run: %s", cmd)
|
|
end
|
|
else
|
|
-- Fire and forget: the shell backgrounds it, so the OBS UI thread
|
|
-- never waits on the camera.
|
|
os.execute(cmd .. " >/dev/null 2>&1 &")
|
|
end
|
|
end
|
|
|
|
local function parse_scene_map(text)
|
|
local map = {}
|
|
for line in tostring(text):gmatch("[^\r\n]+") do
|
|
local trimmed = line:match("^%s*(.-)%s*$")
|
|
if trimmed ~= "" and not trimmed:match("^#") then
|
|
local scene, action = trimmed:match("^(.-)%s*=%s*(.*)$")
|
|
if scene and scene ~= "" and action and action ~= "" then
|
|
map[scene] = action
|
|
else
|
|
warn("ignoring unparsable scene mapping: %s", trimmed)
|
|
end
|
|
end
|
|
end
|
|
return map
|
|
end
|
|
|
|
local function current_scene_name()
|
|
local src = obs.obs_frontend_get_current_scene()
|
|
if not src then
|
|
return nil
|
|
end
|
|
local name = obs.obs_source_get_name(src)
|
|
obs.obs_source_release(src)
|
|
return name
|
|
end
|
|
|
|
local function on_scene_changed()
|
|
if not cfg.enable_scenes then
|
|
return
|
|
end
|
|
local name = current_scene_name()
|
|
if not name then
|
|
return
|
|
end
|
|
local action = scene_map[name]
|
|
if action then
|
|
run(tokenize(action))
|
|
end
|
|
end
|
|
|
|
local function on_frontend_event(event)
|
|
if event == obs.OBS_FRONTEND_EVENT_SCENE_CHANGED then
|
|
on_scene_changed()
|
|
elseif event == obs.OBS_FRONTEND_EVENT_FINISHED_LOADING then
|
|
if cfg.startup_action ~= "" then
|
|
run(tokenize(cfg.startup_action))
|
|
end
|
|
end
|
|
end
|
|
|
|
----------------------------------------------------------------------------
|
|
-- Properties UI
|
|
----------------------------------------------------------------------------
|
|
|
|
function script_description()
|
|
return [[<b>Insta360 Link control</b><br/>
|
|
Drives <code>linkctl</code> for AI tracking, presets and the gimbal.<br/><br/>
|
|
Bind the hotkeys under <b>Settings → Hotkeys</b> ("Insta360").<br/>
|
|
For hotkeys that work outside OBS, install the KDE shortcuts shipped
|
|
alongside this script.]]
|
|
end
|
|
|
|
local function populate_scenes()
|
|
-- Prefill the mapping box with every scene currently in the collection,
|
|
-- so the user edits a list rather than guessing exact scene names.
|
|
if not script_settings then
|
|
return false
|
|
end
|
|
local scenes = obs.obs_frontend_get_scenes()
|
|
if not scenes then
|
|
return false
|
|
end
|
|
local lines = {}
|
|
for _, src in ipairs(scenes) do
|
|
local name = obs.obs_source_get_name(src)
|
|
-- Keep any action already mapped to this scene.
|
|
table.insert(lines, string.format("%s = %s", name, scene_map[name] or ""))
|
|
end
|
|
obs.source_list_release(scenes)
|
|
|
|
local text = table.concat(lines, "\n")
|
|
obs.obs_data_set_string(script_settings, "scene_map_text", text)
|
|
cfg.scene_map_text = text
|
|
scene_map = parse_scene_map(text)
|
|
-- true asks OBS to redraw the properties pane so the new text appears.
|
|
return true
|
|
end
|
|
|
|
function script_properties()
|
|
local props = obs.obs_properties_create()
|
|
|
|
obs.obs_properties_add_path(props, "linkctl", "linkctl binary",
|
|
obs.OBS_PATH_FILE, "", "/usr/local/bin/linkctl")
|
|
obs.obs_properties_add_text(props, "device",
|
|
"Video device (blank = auto-detect)", obs.OBS_TEXT_DEFAULT)
|
|
obs.obs_properties_add_bool(props, "force",
|
|
"Act even when no application is using the camera (--force)")
|
|
obs.obs_properties_add_text(props, "preset",
|
|
"Preset name for the 'load named preset' hotkey", obs.OBS_TEXT_DEFAULT)
|
|
|
|
obs.obs_properties_add_text(props, "startup_action",
|
|
"Run when OBS finishes loading (blank = nothing)", obs.OBS_TEXT_DEFAULT)
|
|
|
|
obs.obs_properties_add_bool(props, "enable_scenes",
|
|
"React to scene changes")
|
|
local map = obs.obs_properties_add_text(props, "scene_map_text",
|
|
"Scene actions, one per line: Scene Name = linkctl args",
|
|
obs.OBS_TEXT_MULTILINE)
|
|
obs.obs_property_set_long_description(map,
|
|
"Example:\n" ..
|
|
"Talking Head = tracking on\n" ..
|
|
"Overhead = preset load overhead\n" ..
|
|
"Slides = tracking off\n\n" ..
|
|
"Blank actions and lines starting with # are ignored.")
|
|
obs.obs_properties_add_button(props, "fill_scenes",
|
|
"Insert current scene names", populate_scenes)
|
|
|
|
obs.obs_properties_add_bool(props, "log_output",
|
|
"Log linkctl output to the script log (for troubleshooting)")
|
|
|
|
obs.obs_properties_add_button(props, "test_tracking_on", "Test: tracking on",
|
|
function() run({ "tracking", "on" }) return false end)
|
|
obs.obs_properties_add_button(props, "test_tracking_off", "Test: tracking off",
|
|
function() run({ "tracking", "off" }) return false end)
|
|
obs.obs_properties_add_button(props, "test_preset", "Test: load preset",
|
|
function() run({ "preset", "load", cfg.preset }) return false end)
|
|
obs.obs_properties_add_button(props, "test_center", "Test: recenter",
|
|
function() run({ "center" }) return false end)
|
|
|
|
return props
|
|
end
|
|
|
|
function script_defaults(settings)
|
|
obs.obs_data_set_default_string(settings, "linkctl", "linkctl")
|
|
obs.obs_data_set_default_string(settings, "device", "")
|
|
obs.obs_data_set_default_bool(settings, "force", true)
|
|
obs.obs_data_set_default_string(settings, "preset", "home")
|
|
obs.obs_data_set_default_string(settings, "startup_action", "preset load home")
|
|
obs.obs_data_set_default_bool(settings, "enable_scenes", true)
|
|
obs.obs_data_set_default_string(settings, "scene_map_text", "")
|
|
obs.obs_data_set_default_bool(settings, "log_output", false)
|
|
end
|
|
|
|
function script_update(settings)
|
|
script_settings = settings
|
|
cfg.linkctl = obs.obs_data_get_string(settings, "linkctl")
|
|
cfg.device = obs.obs_data_get_string(settings, "device")
|
|
cfg.force = obs.obs_data_get_bool(settings, "force")
|
|
cfg.preset = obs.obs_data_get_string(settings, "preset")
|
|
cfg.startup_action = obs.obs_data_get_string(settings, "startup_action")
|
|
cfg.enable_scenes = obs.obs_data_get_bool(settings, "enable_scenes")
|
|
cfg.scene_map_text = obs.obs_data_get_string(settings, "scene_map_text")
|
|
cfg.log_output = obs.obs_data_get_bool(settings, "log_output")
|
|
|
|
if cfg.linkctl == "" then
|
|
cfg.linkctl = "linkctl"
|
|
end
|
|
scene_map = parse_scene_map(cfg.scene_map_text)
|
|
end
|
|
|
|
function script_load(settings)
|
|
script_settings = settings
|
|
for _, def in ipairs(HOTKEYS) do
|
|
local id = obs.obs_hotkey_register_frontend(def.name, def.label,
|
|
function(pressed)
|
|
if pressed then
|
|
run(def.args())
|
|
end
|
|
end)
|
|
hotkey_ids[def.name] = id
|
|
local saved = obs.obs_data_get_array(settings, def.name)
|
|
obs.obs_hotkey_load(id, saved)
|
|
obs.obs_data_array_release(saved)
|
|
end
|
|
obs.obs_frontend_add_event_callback(on_frontend_event)
|
|
end
|
|
|
|
function script_save(settings)
|
|
for name, id in pairs(hotkey_ids) do
|
|
local a = obs.obs_hotkey_save(id)
|
|
obs.obs_data_set_array(settings, name, a)
|
|
obs.obs_data_array_release(a)
|
|
end
|
|
end
|
|
|
|
function script_unload()
|
|
obs.obs_frontend_remove_event_callback(on_frontend_event)
|
|
end
|
|
|
|
-- Exposed only when loaded outside OBS, by the test harness in ../tests.
|
|
-- OBS discards a chunk's return value, but staying silent there keeps the
|
|
-- script's contract with OBS unambiguous.
|
|
if not rawget(_G, "obslua") then
|
|
return {
|
|
shquote = shquote,
|
|
tokenize = tokenize,
|
|
parse_scene_map = parse_scene_map,
|
|
build_cmd = build_cmd,
|
|
cfg = cfg,
|
|
}
|
|
end
|