-- FilyX OBS Companion -- -- A local-only companion script for OBS Studio. It deliberately opens no -- network port: Stream Deck exchanges short command files in the current -- Windows user's AppData folder. This keeps OBS control off the network and -- avoids an OBS WebSocket password for normal FilyX actions. obs = obslua local BRIDGE_DIR = (os.getenv("APPDATA") or os.getenv("LOCALAPPDATA") or ".") .. "\\FilyX\\obs-companion" local REQUEST_FILE = BRIDGE_DIR .. "\\request.txt" local RESPONSE_FILE = BRIDGE_DIR .. "\\response.txt" local RESPONSE_TMP_FILE = BRIDGE_DIR .. "\\response.tmp" local BASE64 = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/" local function base64_encode(value) value = tostring(value or "") return ((value:gsub('.', function(byte) local bits, number = "", byte:byte() for index = 8, 1, -1 do bits = bits .. (number % 2 ^ index - number % 2 ^ (index - 1) > 0 and "1" or "0") end return bits end) .. "0000"):gsub("%d%d%d?%d?%d?%d?", function(chunk) if #chunk < 6 then return "" end local number = 0 for index = 1, 6 do if chunk:sub(index, index) == "1" then number = number + 2 ^ (6 - index) end end return BASE64:sub(number + 1, number + 1) end) .. ({ "", "==", "=" })[#value % 3 + 1]):gsub("%+", "-"):gsub("/", "_"):gsub("=", "") end local function base64_decode(value) value = tostring(value or ""):gsub("-", "+"):gsub("_", "/") value = value .. string.rep("=", (4 - #value % 4) % 4) return (value:gsub('.', function(character) if character == "=" then return "" end local position = BASE64:find(character, 1, true) if not position then return "" end local bits, number = "", position - 1 for index = 6, 1, -1 do bits = bits .. (number % 2 ^ index - number % 2 ^ (index - 1) > 0 and "1" or "0") end return bits end):gsub("%d%d%d?%d?%d?%d?%d?%d?", function(chunk) if #chunk ~= 8 then return "" end local number = 0 for index = 1, 8 do if chunk:sub(index, index) == "1" then number = number + 2 ^ (8 - index) end end return string.char(number) end)) end local function read_file(path) local handle = io.open(path, "rb") if not handle then return nil end local content = handle:read("*a") handle:close() return content end local function write_response(request_id, ok, payload) local handle = io.open(RESPONSE_TMP_FILE, "wb") if not handle then return end handle:write(request_id .. "|" .. (ok and "OK" or "ERR") .. "|" .. base64_encode(payload)) handle:close() os.remove(RESPONSE_FILE) os.rename(RESPONSE_TMP_FILE, RESPONSE_FILE) end local function split_command(line) local values = {} for value in (line .. "|"):gmatch("(.-)|") do values[#values + 1] = value end return values end local function list_sources_for_scene(scene_source) local source_names, seen_sources = {}, {} local scene = obs.obs_scene_from_source(scene_source) if not scene then return source_names end local function add_source(source) if not source then return end local name = obs.obs_source_get_name(source) if name and name ~= "" and not seen_sources[name] then seen_sources[name] = true source_names[#source_names + 1] = name end end local function visit(current_scene) local items = obs.obs_scene_enum_items(current_scene) if not items then return end for _, item in ipairs(items) do local source = obs.obs_sceneitem_get_source(item) add_source(source) -- OBS groups are sub-scenes. Older Lua bindings can omit these two -- helpers, hence the guards; normal sources stay fully supported. if source and type(obs.obs_sceneitem_is_group) == "function" and type(obs.obs_sceneitem_group_get_scene) == "function" then local ok, is_group = pcall(obs.obs_sceneitem_is_group, item) if ok and is_group then local group_scene = obs.obs_sceneitem_group_get_scene(item) if group_scene then visit(group_scene) end end end end obs.sceneitem_list_release(items) end visit(scene) return source_names end local function discover() local rows, seen_inputs = { "version\t" .. base64_encode("FilyX OBS Companion") }, {} local scenes = obs.obs_frontend_get_scenes() if scenes then for _, scene_source in ipairs(scenes) do local row = { "scene", base64_encode(obs.obs_source_get_name(scene_source)) } for _, source_name in ipairs(list_sources_for_scene(scene_source)) do row[#row + 1] = base64_encode(source_name) end rows[#rows + 1] = table.concat(row, "\t") end obs.source_list_release(scenes) end local inputs = obs.obs_enum_sources() if inputs then for _, input in ipairs(inputs) do -- Some OBS Lua builds do not expose obs_source_is_scene(). Listing -- every named source is safe here; scene controls still use the scene -- list above, while this list only feeds the source selector. local name = obs.obs_source_get_name(input) if name and not seen_inputs[name] then seen_inputs[name] = true rows[#rows + 1] = "input\t" .. base64_encode(name) end end obs.source_list_release(inputs) end return table.concat(rows, "\n") end local function set_scene(name) local source = obs.obs_get_source_by_name(name) if not source then error("Scène introuvable : " .. name) end obs.obs_frontend_set_current_scene(source) obs.obs_source_release(source) return "Scène activée" end -- Finds a source anywhere inside a scene, including groups nested in groups, -- and changes it immediately while the OBS scene item is still valid. local function set_source_visible_in_scene(scene, source_name, mode) local items = obs.obs_scene_enum_items(scene) if not items then return nil end for _, item in ipairs(items) do local source = obs.obs_sceneitem_get_source(item) if source and obs.obs_source_get_name(source) == source_name then local visible = mode == "on" or (mode == "toggle" and not obs.obs_sceneitem_visible(item)) obs.obs_sceneitem_set_visible(item, visible) obs.sceneitem_list_release(items) return visible end if source and type(obs.obs_sceneitem_is_group) == "function" and type(obs.obs_sceneitem_group_get_scene) == "function" then local ok, is_group = pcall(obs.obs_sceneitem_is_group, item) if ok and is_group then local group_scene = obs.obs_sceneitem_group_get_scene(item) if group_scene then local visible = set_source_visible_in_scene(group_scene, source_name, mode) if visible ~= nil then obs.sceneitem_list_release(items) return visible end end end end end obs.sceneitem_list_release(items) return nil end local function set_source(scene_name, source_name, mode) local scene_source = obs.obs_get_source_by_name(scene_name) if not scene_source then error("Scène introuvable : " .. scene_name) end local scene = obs.obs_scene_from_source(scene_source) local visible = scene and set_source_visible_in_scene(scene, source_name, mode) or nil obs.obs_source_release(scene_source) -- A source can be stored in another scene or a reused nested group. Its -- name is globally unique in OBS, so search every active-collection scene -- before reporting it missing. if visible == nil then local scenes = obs.obs_frontend_get_scenes() if scenes then for _, candidate in ipairs(scenes) do local candidate_scene = obs.obs_scene_from_source(candidate) if candidate_scene then visible = set_source_visible_in_scene(candidate_scene, source_name, mode) if visible ~= nil then break end end end obs.source_list_release(scenes) end end if visible == nil then error("Source introuvable dans la collection active : " .. source_name) end return visible and "Source affichée" or "Source masquée" end local function refresh_browser(source_name) local source = obs.obs_get_source_by_name(source_name) if not source then error("Source navigateur introuvable : " .. source_name) end local handler = obs.obs_source_get_proc_handler(source) local calldata = obs.calldata_create() local refreshed = handler and obs.proc_handler_call(handler, "refresh_nocache", calldata) obs.calldata_destroy(calldata) obs.obs_source_release(source) if not refreshed then error("Cette source ne permet pas le rafraîchissement navigateur.") end return "Source navigateur rafraîchie" end local function toggle_record() if obs.obs_frontend_recording_active() then obs.obs_frontend_recording_stop() else obs.obs_frontend_recording_start() end return "Enregistrement basculé" end local function toggle_stream() if obs.obs_frontend_streaming_active() then obs.obs_frontend_streaming_stop() else obs.obs_frontend_streaming_start() end return "Direct basculé" end local function execute(command, first, second, third) if command == "ping" then return "FilyX OBS Companion prêt" end if command == "discover" then return discover() end if command == "set_scene" then return set_scene(first) end if command == "set_source" then return set_source(first, second, third) end if command == "refresh_browser" then return refresh_browser(first) end if command == "toggle_record" then return toggle_record() end if command == "toggle_stream" then return toggle_stream() end error("Commande FilyX inconnue") end local function process_request() local raw = read_file(REQUEST_FILE) if not raw or raw == "" then return end os.remove(REQUEST_FILE) local values = split_command(raw:gsub("[\r\n]+$", "")) local request_id, command = values[1], values[2] if not request_id or not command then return end local ok, response = pcall(execute, command, base64_decode(values[3]), base64_decode(values[4]), base64_decode(values[5])) write_response(request_id, ok, response) end function script_description() return [[
Connexion locale FilyX pour Stream Deck : aucune adresse, aucun port et aucun mot de passe OBS. Le script ne communique jamais sur Internet.
]] end function script_load(settings) os.execute('mkdir "' .. BRIDGE_DIR .. '" >nul 2>nul') obs.timer_add(process_request, 60) end function script_unload() obs.timer_remove(process_request) end