metadata.json claimed MIT while no LICENSE file existed, so the repository had no effective licence at all. It is now GPL-3.0-or-later: the full FSF text in LICENSE, the identifier in metadata.json, and REUSE-style SPDX-FileCopyrightText and SPDX-License-Identifier headers on all 33 source files, which is the convention KDE itself uses. Worth recording that GPL-3.0 is a preference here rather than an obligation. Nothing in the dependency graph forces it: linkctl is MIT and is invoked as a subprocess rather than linked, and the KDE QML modules the applet imports are LGPL. Removed two things that should not ship in a public repository. tests's QML linter had an absolute sandbox path, /tmp/pyside/PySide6/qmllint, baked into its search list; it now looks for Qt's qmllint and PySide6's pyside6-qmllint on PATH and honours a QMLLINT override, and says how to install one when it finds nothing. The systemd unit carried a Documentation= URL pointing at a host that does not exist, and its ExecStart silently assumed a particular checkout directory; the URL is gone and the path is now an obvious PATH/TO/CHECKOUT placeholder next to the comment telling you to edit it. Added a Credits section naming the work this is built on, since the project is mostly glue: linkctl does the camera control, schlarpc's firmware teardown documents the extension-unit protocol, and vrwallace's controller established that the Link 2 needs both pan and tilt axes in a single VIDIOC_S_EXT_CTRLS call, which is the detail the whole thing depends on. Also made the two Python tests executable, matching the other scripts. Audited for operator-specific detail: no credentials, tokens, private hostnames, IP addresses or absolute home paths in any tracked file or anywhere in the history's diffs. Three merge commits do carry Reviewed-on and Reviewed-by trailers naming a private forge, added by the forge at merge time; those are commit metadata rather than file content and are handled separately. Tested: 169 checks across seven suites plus the QML linter, all passing after the header insertion, which confirms it did not disturb the order-sensitive `.pragma library` line in logic.js or the qmldir module definitions. shellcheck clean.
350 lines
12 KiB
Lua
350 lines
12 KiB
Lua
-- SPDX-FileCopyrightText: 2026 branderson
|
|
-- SPDX-License-Identifier: GPL-3.0-or-later
|
|
|
|
--[[
|
|
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
|