obs = obslua local CONNECTOR_PROTOCOL = { status = "drawtool-status-v1", local_rpc = "drawtool-local-v1", version = "39" } local CONFIG = { first_local_port = 16982, last_local_port = 16991, host_heartbeat_timeout_seconds = 90, max_stream_renditions = 10, telestrator_scene_name = "[Drawing]", caller_audio_scene_name = "[CALLER AUDIO]", caller_dsk_first_channel = 60, telestrator_dsk_channel = 63 } local output = nil local service = nil local video_encoder = nil local relay_outputs = {} local video_encoders = {} local rendition_ids = {} local audio_encoder = nil local p2p_sessions = {} local encoder_probe_releases = {} local p2p_state = "idle" local p2p_detail = "Direct P2P is idle" local p2p_session_id = "" local p2p_session_ids = "" local p2p_active_count = 0 local p2p_total_bytes = 0 local p2p_last_error = "" local p2p_idle_detail = "Direct P2P is idle" local last_nonce = "" local state = "idle" local detail = "Ready" local encoder_id = "" local started_at = 0 local max_duration = 0 local last_total_bytes = 0 local last_sample_time = 0 local bitrate_kbps = 0 local managed_dsks_dirty = false local last_status_text = "" local stream_view_state = { view = nil, added = false, scene_name = "" } local diagnostics_summary = "" local global_audio_enabled = false local global_audio_scene = "" local telestrator_enabled = false local telestrator_excluded_scenes = {} local last_host_heartbeat_at = 0 local script_settings = nil local shutting_down = false local connector_instance_id = "" local connector_port = 0 local connector_detail = "Starting local connector" local enqueue_event = nil local native_ui = { initialized = false, ready = false, error = "" } local frontend_state = { collection = "", collections = {}, scenes = {}, program = "", preview = "", studio = false } local private_dsks = { scenes = {}, public_output_sources = {}, channels = {}, view_channels = {}, status_assignments = { { name = CONFIG.caller_audio_scene_name, channel_key = "callerAudioDskChannel", bound_key = "callerAudioDskBound" }, { name = CONFIG.telestrator_scene_name, channel_key = "drawingDskChannel", bound_key = "drawingDskBound" } }, state = "idle", detail = "Managed downstream keys are idle", frontend_callback_registered = false } local invite_alpha_dsk = { source_id = "drawtool_invite_alpha_composite", entries = {}, instances = {}, source_def = { id = "drawtool_invite_alpha_composite", type = obs.OBS_SOURCE_TYPE_INPUT, create_error = "" }, effect_source = [[ uniform float4x4 ViewProj; uniform texture2d image; sampler_state textureSampler { Filter = Point; AddressU = Clamp; AddressV = Clamp; }; struct VertData { float4 pos : POSITION; float2 uv : TEXCOORD0; }; VertData VSDefault(VertData v_in) { VertData vert_out; vert_out.pos = mul(float4(v_in.pos.xyz, 1.0), ViewProj); vert_out.uv = v_in.uv; return vert_out; } float4 PSComposite(VertData v_in) : TARGET { float2 fill_uv = float2(v_in.uv.x * 0.5, v_in.uv.y); float2 key_uv = float2(0.5 + v_in.uv.x * 0.5, v_in.uv.y); float4 fill_color = image.Sample(textureSampler, fill_uv); float4 key_color = image.Sample(textureSampler, key_uv); float alpha_value = clamp( dot(key_color.rgb, float3(0.2126, 0.7152, 0.0722)), 0.0, 1.0 ); return float4(fill_color.rgb, alpha_value); } technique Draw { pass { vertex_shader = VSDefault(v_in); pixel_shader = PSComposite(v_in); } } ]] } local function fail(message) assert(false, tostring(message)) end local function collect_diagnostics() local parts = {} table.insert(parts, "OS=" .. tostring(jit and jit.os or package.config:sub(1, 1) == "\\" and "Windows" or "Unix")) table.insert(parts, "Arch=" .. tostring(jit and jit.arch or "unknown")) if obs.obs_get_version_string then table.insert(parts, "OBS=" .. tostring(obs.obs_get_version_string())) end local cpu = os.getenv("PROCESSOR_IDENTIFIER") or "" if cpu ~= "" then table.insert(parts, "CPU=" .. cpu) end local logical_cpus = os.getenv("NUMBER_OF_PROCESSORS") or "" if logical_cpus ~= "" then table.insert(parts, "LogicalCPUs=" .. logical_cpus) end local video_info = obs.obs_video_info() if obs.obs_get_video_info and obs.obs_get_video_info(video_info) then table.insert(parts, string.format( "Video=%dx%d %d/%d fps", video_info.output_width or 0, video_info.output_height or 0, video_info.fps_num or 0, video_info.fps_den or 1 )) end diagnostics_summary = table.concat(parts, " | ") end local function percent_encode(value) return string.gsub(tostring(value or ""), "([^A-Za-z0-9_.~-])", function(char) return string.format("%%%02X", string.byte(char)) end) end local function add_field(lines, key, value) table.insert(lines, key .. "=" .. percent_encode(value)) end local function current_collection() return frontend_state.collection end frontend_state.refresh_program = function() local source = obs.obs_frontend_get_current_scene and obs.obs_frontend_get_current_scene() or nil frontend_state.program = source and (obs.obs_source_get_name(source) or "") or "" if source then obs.obs_source_release(source) end end frontend_state.refresh_preview = function() if not frontend_state.studio then frontend_state.preview = frontend_state.program return end local source = obs.obs_frontend_get_current_preview_scene and obs.obs_frontend_get_current_preview_scene() or nil frontend_state.preview = source and (obs.obs_source_get_name(source) or "") or "" if source then obs.obs_source_release(source) end end frontend_state.refresh_scenes = function() local names = {} local scenes = obs.obs_frontend_get_scenes and (obs.obs_frontend_get_scenes() or {}) or {} for _, source in ipairs(scenes) do table.insert(names, obs.obs_source_get_name(source) or "") end if obs.source_list_release then obs.source_list_release(scenes) end frontend_state.scenes = names end frontend_state.refresh_collections = function() frontend_state.collections = obs.obs_frontend_get_scene_collections and (obs.obs_frontend_get_scene_collections() or {}) or {} end frontend_state.refresh_all = function() frontend_state.collection = obs.obs_frontend_get_current_scene_collection and (obs.obs_frontend_get_current_scene_collection() or "") or "" frontend_state.studio = obs.obs_frontend_preview_program_mode_active and obs.obs_frontend_preview_program_mode_active() or false frontend_state.refresh_collections() frontend_state.refresh_scenes() frontend_state.refresh_program() frontend_state.refresh_preview() end frontend_state.add_scene = function(scene_name) if not scene_name or scene_name == "" then return end for _, name in ipairs(frontend_state.scenes) do if name == scene_name then return end end local anchor = frontend_state.studio and frontend_state.preview or frontend_state.program local insertion_index = #frontend_state.scenes + 1 for index, name in ipairs(frontend_state.scenes) do if name == anchor then insertion_index = index + 1 break end end table.insert(frontend_state.scenes, insertion_index, scene_name) end frontend_state.remove_scene = function(scene_name) for index = #frontend_state.scenes, 1, -1 do if frontend_state.scenes[index] == scene_name then table.remove(frontend_state.scenes, index) end end end frontend_state.rename_scene = function(previous_name, new_name) for index, name in ipairs(frontend_state.scenes) do if name == previous_name then frontend_state.scenes[index] = new_name end end if frontend_state.program == previous_name then frontend_state.program = new_name end if frontend_state.preview == previous_name then frontend_state.preview = new_name end end private_dsks.kind = function(scene_name) if scene_name == CONFIG.telestrator_scene_name then return "drawing" end if scene_name == CONFIG.caller_audio_scene_name then return "caller_audio" end return nil end private_dsks.preferred_channel = function(scene_name) if scene_name == CONFIG.telestrator_scene_name then return CONFIG.telestrator_dsk_channel end return CONFIG.caller_dsk_first_channel end private_dsks.available = function() return obs.obs_scene_create_private ~= nil and obs.obs_scene_get_source ~= nil and obs.obs_source_get_ref ~= nil and obs.obs_source_get_uuid ~= nil and obs.obs_get_output_source ~= nil and obs.obs_set_output_source ~= nil end private_dsks.get_source_ref = function(scene_name) if not scene_name then return nil end local scene = private_dsks.scenes[scene_name] if scene then local source = obs.obs_scene_get_source(scene) return source and obs.obs_source_get_ref(source) or nil end if private_dsks.public_output_sources[scene_name] then return obs.obs_get_source_by_name(scene_name) end return nil end private_dsks.register_public_output_source = function(scene_name) if scene_name and scene_name ~= "" then private_dsks.public_output_sources[scene_name] = true end end private_dsks.unregister_public_output_source = function(scene_name) if not scene_name or scene_name == "" then return end private_dsks.clear_output_assignment(scene_name) private_dsks.public_output_sources[scene_name] = nil end private_dsks.same_source = function(left, right) if not left or not right then return false end if left == right then return true end if not obs.obs_source_get_uuid then return false end local left_uuid = obs.obs_source_get_uuid(left) or "" local right_uuid = obs.obs_source_get_uuid(right) or "" return left_uuid ~= "" and left_uuid == right_uuid end private_dsks.detach_source = function(source) if not source then return end if obs.obs_get_output_source and obs.obs_set_output_source then for channel = 1, 63 do local current = obs.obs_get_output_source(channel) if current and private_dsks.same_source(current, source) then obs.obs_set_output_source(channel, nil) end if current then obs.obs_source_release(current) end end end if stream_view_state.view and obs.obs_view_get_source then for channel = 1, 63 do local current = obs.obs_view_get_source( stream_view_state.view, channel ) if current and private_dsks.same_source(current, source) then obs.obs_view_set_source(stream_view_state.view, channel, nil) end if current then obs.obs_source_release(current) end end end end private_dsks.clear_output_assignment = function(scene_name) if not scene_name then return end local source = private_dsks.get_source_ref(scene_name) local channel = private_dsks.channels[scene_name] if source and channel and obs.obs_get_output_source then local current = obs.obs_get_output_source(channel) if current and private_dsks.same_source(current, source) then obs.obs_set_output_source(channel, nil) end if current then obs.obs_source_release(current) end end private_dsks.channels[scene_name] = nil if source then obs.obs_source_release(source) end end private_dsks.output_assignment_matches = function(scene_name) if not scene_name or not obs.obs_get_output_source then return false end local channel = private_dsks.channels[scene_name] if not channel then return false end local expected = private_dsks.get_source_ref(scene_name) if not expected then return false end local current = obs.obs_get_output_source(channel) local matches = current ~= nil and private_dsks.same_source(current, expected) if current then obs.obs_source_release(current) end obs.obs_source_release(expected) return matches end private_dsks.clear_view_assignment = function(scene_name) if not scene_name then return end local source = private_dsks.get_source_ref(scene_name) local view_channel = private_dsks.view_channels[scene_name] if source and view_channel and stream_view_state.view and obs.obs_view_get_source then local current = obs.obs_view_get_source( stream_view_state.view, view_channel ) if current and private_dsks.same_source(current, source) then obs.obs_view_set_source(stream_view_state.view, view_channel, nil) end if current then obs.obs_source_release(current) end end private_dsks.view_channels[scene_name] = nil if source then obs.obs_source_release(source) end end private_dsks.clear_assignment = function(scene_name) private_dsks.clear_output_assignment(scene_name) private_dsks.clear_view_assignment(scene_name) end private_dsks.channel_reserved = function(channel, scene_name) for assigned_name, assigned_channel in pairs(private_dsks.channels) do if assigned_name ~= scene_name and assigned_channel == channel then return true end end return false end private_dsks.assign_output = function(scene_name) local source = private_dsks.get_source_ref(scene_name) if not source then return false, "Internal DSK scene is not ready: " .. scene_name end local channel = private_dsks.channels[scene_name] if channel then local current = obs.obs_get_output_source(channel) local usable = not current or private_dsks.same_source(current, source) if current then obs.obs_source_release(current) end if not usable then channel = nil end end if not channel then local candidates = { private_dsks.preferred_channel(scene_name) } for candidate = 63, 32, -1 do if candidate ~= candidates[1] then table.insert(candidates, candidate) end end for _, candidate in ipairs(candidates) do if not private_dsks.channel_reserved(candidate, scene_name) then local current = obs.obs_get_output_source(candidate) local usable = not current or private_dsks.same_source(current, source) if current then obs.obs_source_release(current) end if usable then channel = candidate break end end end end if not channel then obs.obs_source_release(source) return false, "No free OBS downstream output channel is available" end obs.obs_set_output_source(channel, source) private_dsks.channels[scene_name] = channel obs.obs_source_release(source) return true, channel end private_dsks.assign_view = function(scene_name) if not stream_view_state.view then private_dsks.view_channels[scene_name] = nil return true, -1 end if not obs.obs_view_get_source then return false, "This OBS build cannot inspect Feed-view DSK channels" end local source = private_dsks.get_source_ref(scene_name) if not source then return false, "Internal DSK scene is not ready: " .. scene_name end local previous_channel = private_dsks.view_channels[scene_name] local channel = previous_channel if channel then local current = obs.obs_view_get_source(stream_view_state.view, channel) local usable = not current or private_dsks.same_source(current, source) if current then obs.obs_source_release(current) end if not usable then channel = nil end end if not channel then local candidates = {} local preferred = private_dsks.channels[scene_name] or private_dsks.preferred_channel(scene_name) table.insert(candidates, preferred) for candidate = 63, 32, -1 do if candidate ~= preferred then table.insert(candidates, candidate) end end for _, candidate in ipairs(candidates) do local reserved = false for assigned_name, assigned_channel in pairs( private_dsks.view_channels ) do if assigned_name ~= scene_name and assigned_channel == candidate then reserved = true break end end if not reserved then local current = obs.obs_view_get_source( stream_view_state.view, candidate ) local usable = not current or private_dsks.same_source(current, source) if current then obs.obs_source_release(current) end if usable then channel = candidate break end end end end if not channel then obs.obs_source_release(source) return false, "No free OBS Feed-view DSK channel is available" end if previous_channel and previous_channel ~= channel then local previous = obs.obs_view_get_source( stream_view_state.view, previous_channel ) if previous and private_dsks.same_source(previous, source) then obs.obs_view_set_source( stream_view_state.view, previous_channel, nil ) end if previous then obs.obs_source_release(previous) end end obs.obs_view_set_source(stream_view_state.view, channel, source) private_dsks.view_channels[scene_name] = channel obs.obs_source_release(source) return true, channel end private_dsks.ensure = function(scene_name) if not private_dsks.kind(scene_name) then return false, false, "Scene is not a managed internal DSK" end if not private_dsks.available() then return false, false, "This OBS build does not expose private scenes to Lua" end local public_source = obs.obs_get_source_by_name(scene_name) if private_dsks.scenes[scene_name] then local private_source = private_dsks.get_source_ref(scene_name) local has_collision = public_source and not private_dsks.same_source(public_source, private_source) if public_source then obs.obs_source_release(public_source) end if private_source then obs.obs_source_release(private_source) end if has_collision then return false, false, "A visible OBS scene now uses the reserved name " .. scene_name end return true, false, "" end local public_scene = public_source and obs.obs_scene_from_source(public_source) or nil if public_source and not public_scene then obs.obs_source_release(public_source) return false, false, "An OBS source already uses the reserved name " .. scene_name end if public_scene and ( frontend_state.program == scene_name or (frontend_state.studio and frontend_state.preview == scene_name) ) then obs.obs_source_release(public_source) return false, false, "Switch Program/Preview away from " .. scene_name .. " so it can be hidden safely" end local private_scene = nil if public_scene then if not obs.obs_scene_duplicate or obs.OBS_SCENE_DUP_PRIVATE_REFS == nil then obs.obs_source_release(public_source) return false, false, "This OBS build cannot migrate the managed DSK scene safely" end private_scene = obs.obs_scene_duplicate( public_scene, scene_name, obs.OBS_SCENE_DUP_PRIVATE_REFS ) else private_scene = obs.obs_scene_create_private(scene_name) end if not private_scene then if public_source then obs.obs_source_release(public_source) end return false, false, "OBS could not create the internal DSK scene" end private_dsks.scenes[scene_name] = private_scene if public_source then private_dsks.detach_source(public_source) if private_dsks.frontend_callback_registered then obs.obs_frontend_remove_event_callback(frontend_event) end local removed, remove_error = pcall(obs.obs_source_remove, public_source) if private_dsks.frontend_callback_registered then obs.obs_frontend_add_event_callback(frontend_event) end obs.obs_source_release(public_source) if not removed then private_dsks.scenes[scene_name] = nil obs.obs_scene_release(private_scene) return false, false, tostring(remove_error) end frontend_state.remove_scene(scene_name) end return true, true, "" end private_dsks.release = function(scene_name) local scene = private_dsks.scenes[scene_name] if not scene then return false end private_dsks.clear_assignment(scene_name) private_dsks.scenes[scene_name] = nil obs.obs_scene_release(scene) return true end private_dsks.release_all = function() local names = {} for scene_name in pairs(private_dsks.scenes) do table.insert(names, scene_name) end for _, scene_name in ipairs(names) do private_dsks.release(scene_name) end local public_names = {} for scene_name in pairs(private_dsks.public_output_sources) do table.insert(public_names, scene_name) end for _, scene_name in ipairs(public_names) do private_dsks.unregister_public_output_source(scene_name) end end private_dsks.write_status = function(result) for _, assignment in ipairs(private_dsks.status_assignments) do local scene_name = assignment.name local channel = private_dsks.channels[scene_name] local source = private_dsks.get_source_ref(scene_name) local current = channel and obs.obs_get_output_source(channel) or nil local bound = source and current and private_dsks.same_source(source, current) or false obs.obs_data_set_int( result, assignment.channel_key, bound and channel or -1 ) obs.obs_data_set_bool(result, assignment.bound_key, bound) if current then obs.obs_source_release(current) end if source then obs.obs_source_release(source) end end local scene_name = CONFIG.telestrator_scene_name local channel = private_dsks.view_channels[scene_name] local source = private_dsks.get_source_ref(scene_name) local current = channel and stream_view_state.view and obs.obs_view_get_source and obs.obs_view_get_source(stream_view_state.view, channel) or nil local bound = source and current and private_dsks.same_source(source, current) or false obs.obs_data_set_int( result, "drawingViewDskChannel", bound and channel or -1 ) obs.obs_data_set_bool(result, "drawingViewDskBound", bound) obs.obs_data_set_string(result, "dskState", private_dsks.state) obs.obs_data_set_string(result, "dskDetail", private_dsks.detail) if current then obs.obs_source_release(current) end if source then obs.obs_source_release(source) end end local function update_p2p_summary() local session_ids = {} local ready_count = 0 local starting_count = 0 local error_count = 0 p2p_total_bytes = 0 for session_id, session in pairs(p2p_sessions) do table.insert(session_ids, session_id) if session.output then p2p_total_bytes = p2p_total_bytes + (obs.obs_output_get_total_bytes(session.output) or 0) end if session.state == "ready" then ready_count = ready_count + 1 elseif session.state == "error" then error_count = error_count + 1 else starting_count = starting_count + 1 end end table.sort(session_ids) p2p_active_count = #session_ids p2p_session_id = session_ids[1] or "" p2p_session_ids = table.concat(session_ids, ",") if p2p_active_count == 0 then if p2p_last_error ~= "" then p2p_state = "error" p2p_detail = p2p_last_error else p2p_state = "idle" p2p_detail = p2p_idle_detail end elseif error_count > 0 and ready_count == 0 and starting_count == 0 then p2p_state = "error" p2p_detail = p2p_last_error ~= "" and p2p_last_error or "Direct P2P outputs stopped" elseif ready_count > 0 then p2p_state = "ready" p2p_detail = string.format( "%d direct P2P viewer%s receiving the feed%s", ready_count, ready_count == 1 and " is" or "s are", starting_count > 0 and string.format("; %d connecting", starting_count) or "" ) else p2p_state = "starting" p2p_detail = string.format( "%d direct P2P viewer%s connecting", starting_count, starting_count == 1 and " is" or "s are" ) end end local function write_status() update_p2p_summary() local lines = { CONNECTOR_PROTOCOL.status } add_field(lines, "version", CONNECTOR_PROTOCOL.version) add_field(lines, "sceneCollection", current_collection()) add_field(lines, "state", state) add_field(lines, "detail", detail) add_field(lines, "encoder", encoder_id) add_field(lines, "bitrateKbps", bitrate_kbps) add_field(lines, "totalBytes", output and obs.obs_output_get_total_bytes(output) or 0) add_field(lines, "droppedFrames", output and obs.obs_output_get_frames_dropped(output) or 0) add_field(lines, "congestion", output and obs.obs_output_get_congestion(output) or 0) add_field(lines, "connectTimeMs", output and obs.obs_output_get_connect_time_ms(output) or 0) add_field(lines, "p2pState", p2p_state) add_field(lines, "p2pDetail", p2p_detail) add_field(lines, "p2pSessionId", p2p_session_id) add_field(lines, "p2pSessionIds", p2p_session_ids) add_field(lines, "p2pActiveCount", p2p_active_count) add_field(lines, "p2pTotalBytes", p2p_total_bytes) add_field(lines, "p2pLastError", p2p_last_error) add_field(lines, "diagnostics", diagnostics_summary) add_field(lines, "nonce", last_nonce) local status_text = table.concat(lines, "\n") if status_text == last_status_text then return end last_status_text = status_text if enqueue_event then local event_data = obs.obs_data_create() obs.obs_data_set_string(event_data, "protocol", CONNECTOR_PROTOCOL.status) obs.obs_data_set_string(event_data, "version", CONNECTOR_PROTOCOL.version) obs.obs_data_set_string(event_data, "sceneCollection", current_collection()) obs.obs_data_set_string(event_data, "state", state) obs.obs_data_set_string(event_data, "detail", detail) obs.obs_data_set_string(event_data, "encoder", encoder_id) obs.obs_data_set_int(event_data, "bitrateKbps", bitrate_kbps) obs.obs_data_set_int( event_data, "totalBytes", output and obs.obs_output_get_total_bytes(output) or 0 ) obs.obs_data_set_int( event_data, "droppedFrames", output and obs.obs_output_get_frames_dropped(output) or 0 ) obs.obs_data_set_double( event_data, "congestion", output and obs.obs_output_get_congestion(output) or 0 ) obs.obs_data_set_int( event_data, "connectTimeMs", output and obs.obs_output_get_connect_time_ms(output) or 0 ) obs.obs_data_set_string(event_data, "p2pState", p2p_state) obs.obs_data_set_string(event_data, "p2pDetail", p2p_detail) obs.obs_data_set_string(event_data, "p2pSessionId", p2p_session_id) obs.obs_data_set_string(event_data, "p2pSessionIds", p2p_session_ids) obs.obs_data_set_int(event_data, "p2pActiveCount", p2p_active_count) obs.obs_data_set_int( event_data, "p2pTotalBytes", p2p_total_bytes ) obs.obs_data_set_string(event_data, "p2pLastError", p2p_last_error) obs.obs_data_set_string(event_data, "diagnostics", diagnostics_summary) obs.obs_data_set_string(event_data, "nonce", last_nonce) enqueue_event("CompanionStatusChanged", event_data) obs.obs_data_release(event_data) end end local function release_p2p_session(session_id, reason) local session = p2p_sessions[session_id or ""] if not session then return end if session.output then if obs.obs_output_active(session.output) then obs.obs_output_force_stop(session.output) end obs.obs_output_release(session.output) end if session.service then obs.obs_service_release(session.service) end p2p_sessions[session_id] = nil p2p_idle_detail = reason or "Direct P2P is idle" update_p2p_summary() end local function release_all_p2p_outputs(reason) local session_ids = {} for session_id in pairs(p2p_sessions) do table.insert(session_ids, session_id) end for _, session_id in ipairs(session_ids) do release_p2p_session(session_id, reason) end p2p_last_error = "" p2p_idle_detail = reason or "Direct P2P is idle" update_p2p_summary() end local function release_output() release_all_p2p_outputs("Caller feed stopped") for _, relay in ipairs(relay_outputs) do if relay.output then if obs.obs_output_active(relay.output) then obs.obs_output_force_stop(relay.output) end obs.obs_output_release(relay.output) end if relay.service then obs.obs_service_release(relay.service) end end relay_outputs = {} output = nil service = nil for _, encoder in ipairs(video_encoders) do if encoder then obs.obs_encoder_release(encoder) end end video_encoders = {} rendition_ids = {} video_encoder = nil if audio_encoder then obs.obs_encoder_release(audio_encoder); audio_encoder = nil end if stream_view_state.view then obs.obs_view_set_source(stream_view_state.view, 0, nil) if stream_view_state.added and obs.obs_view_remove then obs.obs_view_remove(stream_view_state.view) end obs.obs_view_destroy(stream_view_state.view) stream_view_state.view = nil stream_view_state.added = false private_dsks.view_channels = {} end stream_view_state.scene_name = "" started_at = 0 max_duration = 0 last_total_bytes = 0 last_sample_time = 0 bitrate_kbps = 0 end local function stop_stream(reason) release_output() state = "idle" detail = reason or "Stopped" encoder_id = "" end local function settings_from_json(value) if obs.obs_data_create_from_json and value and value ~= "" then local settings = obs.obs_data_create_from_json(value) if settings then return settings end end return obs.obs_data_create() end local function rendition_value(command, index, name, fallback) local value = command["rendition" .. index .. name] if value == nil or value == "" then return command[fallback] end return value end local function create_video_encoder(command, index) local settings = settings_from_json( rendition_value(command, index, "VideoSettings", "videoSettings") ) local requested_encoder_id = command.videoEncoderId or "" local encoder = obs.obs_video_encoder_create( requested_encoder_id, "Caller Companion Video " .. index, settings, nil ) encoder_id = encoder and requested_encoder_id or "" obs.obs_data_release(settings) return encoder end local function create_audio_encoder(command) local settings = settings_from_json(command.audioSettings) local track = math.max(1, math.min(6, tonumber(command.audioTrack))) local encoder = obs.obs_audio_encoder_create( command.audioEncoderId or "", "Caller Companion Audio", settings, track - 1, nil ) obs.obs_data_release(settings) return encoder end local function start_stream(command) release_output() state = "starting" detail = "Creating WHIP output" local rendition_count = math.max( 1, math.min(CONFIG.max_stream_renditions, tonumber(command.renditionCount) or 1) ) local audio_track = tonumber(command.audioTrack) if not audio_track or audio_track < 1 or audio_track > 6 or not command.videoEncoderId or command.videoEncoderId == "" or not command.audioEncoderId or command.audioEncoderId == "" then stop_stream("The server supplied an incomplete stream configuration") state = "error" return end audio_encoder = create_audio_encoder(command) if not audio_encoder then stop_stream("The selected audio encoder is unavailable") state = "error" return end local stream_video = obs.obs_get_video() if command.videoSource == "scene" and command.sceneName and command.sceneName ~= "" then local scene_source = obs.obs_get_source_by_name(command.sceneName) if not scene_source then detail = "Selected OBS scene was not found; using Program" else stream_view_state.view = obs.obs_view_create() if not stream_view_state.view or not obs.obs_view_add then obs.obs_source_release(scene_source) stop_stream("This OBS build cannot create a scene video view") state = "error" return end stream_video = obs.obs_view_add(stream_view_state.view) if not stream_video then obs.obs_source_release(scene_source) stop_stream("OBS could not activate the selected scene video view") state = "error" return end stream_view_state.added = true obs.obs_view_set_source(stream_view_state.view, 0, scene_source) stream_view_state.scene_name = command.sceneName obs.obs_source_release(scene_source) end end local gpu_scale_type = obs.OBS_SCALE_BILINEAR if gpu_scale_type == nil then gpu_scale_type = obs.OBS_SCALE_BICUBIC end for index = 1, rendition_count do local width = tonumber(rendition_value(command, index, "Width", "width")) local height = tonumber(rendition_value(command, index, "Height", "height")) local fps_divisor = tonumber( rendition_value(command, index, "FpsDivisor", "fpsDivisor") ) local publish_url = rendition_value( command, index, "PublishUrl", "publishUrl" ) if not width or width < 1 or not height or height < 1 or not fps_divisor or fps_divisor < 1 or not publish_url or publish_url == "" then stop_stream("The server supplied an incomplete rendition configuration") state = "error" return end local encoder = create_video_encoder(command, index) if not encoder then stop_stream("The selected video encoder is unavailable for rendition " .. index) state = "error" return end if obs.obs_encoder_set_gpu_scale_type and gpu_scale_type ~= nil then obs.obs_encoder_set_gpu_scale_type(encoder, gpu_scale_type) end obs.obs_encoder_set_scaled_size(encoder, width, height) if obs.obs_encoder_set_frame_rate_divisor then obs.obs_encoder_set_frame_rate_divisor( encoder, math.max(1, fps_divisor) ) end obs.obs_encoder_set_video(encoder, stream_video) table.insert(video_encoders, encoder) table.insert( rendition_ids, rendition_value(command, index, "Id", "qualityId") or tostring(index) ) end video_encoder = video_encoders[1] managed_dsks_dirty = true obs.obs_encoder_set_audio(audio_encoder, obs.obs_get_audio()) for index, encoder in ipairs(video_encoders) do local service_settings = obs.obs_data_create() obs.obs_data_set_string( service_settings, "server", rendition_value(command, index, "PublishUrl", "publishUrl") or "" ) obs.obs_data_set_string( service_settings, "bearer_token", rendition_value(command, index, "BearerToken", "bearerToken") or "" ) local relay_service = obs.obs_service_create( "whip_custom", "Caller Companion WHIP service " .. index, service_settings, nil ) obs.obs_data_release(service_settings) local relay_output = obs.obs_output_create( "whip_output", "Caller Companion WHIP output " .. index, nil, nil ) if not relay_service or not relay_output then if relay_output then obs.obs_output_release(relay_output) end if relay_service then obs.obs_service_release(relay_service) end stop_stream("OBS WHIP output is unavailable; update OBS") state = "error" return end table.insert(relay_outputs, { output = relay_output, service = relay_service, quality_id = rendition_ids[index] }) obs.obs_output_set_service(relay_output, relay_service) obs.obs_output_set_video_encoder(relay_output, encoder) obs.obs_output_set_audio_encoder(relay_output, audio_encoder, 0) if not obs.obs_output_start(relay_output) then local message = obs.obs_output_get_last_error(relay_output) or "" stop_stream( message ~= "" and message or "WHIP rendition " .. index .. " did not start" ) state = "error" return end end output = relay_outputs[1].output service = relay_outputs[1].service started_at = os.time() max_duration = math.max(0, tonumber(command.maxDurationMinutes) or 0) * 60 state = "starting" detail = "Connecting to CallerView Server" end local function fail_p2p(session_id, message) release_p2p_session(session_id, message) p2p_last_error = message or "Direct P2P output failed" update_p2p_summary() end local function start_p2p(command) local session_id = command.sessionId or "" p2p_last_error = "" release_p2p_session(session_id, "Restarting a direct P2P session") update_p2p_summary() if not output or not video_encoder or not audio_encoder or not obs.obs_output_active(output) then fail_p2p(session_id, "Start the Caller Feed before accepting a direct P2P viewer") return end if not command.publishUrl or command.publishUrl == "" or not command.sessionId or command.sessionId == "" then fail_p2p(session_id, "The server supplied an incomplete direct P2P configuration") return end local service_settings = obs.obs_data_create() obs.obs_data_set_string(service_settings, "server", command.publishUrl) obs.obs_data_set_string( service_settings, "bearer_token", command.bearerToken or "" ) local p2p_service = obs.obs_service_create( "whip_custom", "Caller Companion direct P2P WHIP service " .. session_id, service_settings, nil ) obs.obs_data_release(service_settings) local p2p_output = obs.obs_output_create( "whip_output", "Caller Companion direct P2P output " .. session_id, nil, nil ) if not p2p_service or not p2p_output then if p2p_output then obs.obs_output_release(p2p_output) end if p2p_service then obs.obs_service_release(p2p_service) end fail_p2p(session_id, "OBS could not create the direct P2P WHIP output") return end local selected_video_encoder = video_encoder local requested_quality_id = command.qualityId or "" if requested_quality_id ~= "" then for index, quality_id in ipairs(rendition_ids) do if quality_id == requested_quality_id then selected_video_encoder = video_encoders[index] break end end end obs.obs_output_set_service(p2p_output, p2p_service) obs.obs_output_set_video_encoder(p2p_output, selected_video_encoder) obs.obs_output_set_audio_encoder(p2p_output, audio_encoder, 0) p2p_sessions[session_id] = { output = p2p_output, service = p2p_service, state = "starting", quality_id = requested_quality_id, detail = "Negotiating directly with the viewer", started_at = os.time() } if not obs.obs_output_start(p2p_output) then local message = obs.obs_output_get_last_error(p2p_output) or "" fail_p2p( session_id, message ~= "" and message or "Direct P2P WHIP output did not start" ) return end update_p2p_summary() end local function clear_managed_dsks() private_dsks.clear_assignment(CONFIG.caller_audio_scene_name) private_dsks.clear_assignment(CONFIG.telestrator_scene_name) for scene_name in pairs(private_dsks.public_output_sources) do private_dsks.clear_output_assignment(scene_name) end end local function current_program_scene_name() local output_source = obs.obs_get_output_source and obs.obs_get_output_source(0) or nil local scene_source = output_source if output_source and obs.obs_transition_get_active_source and obs.obs_source_get_type(output_source) == obs.OBS_SOURCE_TYPE_TRANSITION then scene_source = obs.obs_transition_get_active_source(output_source) end local scene_name = scene_source and (obs.obs_source_get_name(scene_source) or "") or "" if scene_source and scene_source ~= output_source then obs.obs_source_release(scene_source) end if output_source then obs.obs_source_release(output_source) end if scene_name ~= "" then frontend_state.program = scene_name end return scene_name ~= "" and scene_name or frontend_state.program end local function scene_is_telestrator_excluded(scene_name) for _, excluded_name in ipairs(telestrator_excluded_scenes) do if excluded_name == scene_name then return true end end return false end invite_alpha_dsk.scene_is_excluded = function(entry, scene_name) if not entry or not scene_name or scene_name == "" then return true end if scene_name == entry.public_scene_name then return true end for _, excluded_name in ipairs(entry.excluded_scenes or {}) do if excluded_name == scene_name then return true end end return false end local function set_dsk_scene(scene_name, enabled) if not enabled or not scene_name or scene_name == "" then private_dsks.clear_output_assignment(scene_name) return true, "" end return private_dsks.assign_output(scene_name) end local function enforce_managed_dsks() local failures = {} if not obs.obs_set_output_source then private_dsks.state = "error" private_dsks.detail = "This OBS build does not expose downstream output channels to Lua" return end local audio_ok, audio_detail = set_dsk_scene( CONFIG.caller_audio_scene_name, global_audio_enabled and global_audio_scene ~= "" ) if not audio_ok then table.insert(failures, audio_detail) end local program_scene_name = current_program_scene_name() local show_program_drawing = telestrator_enabled and not scene_is_telestrator_excluded(program_scene_name) local drawing_ok, drawing_detail = set_dsk_scene( CONFIG.telestrator_scene_name, show_program_drawing ) if not drawing_ok then table.insert(failures, drawing_detail) end for _, entry in pairs(invite_alpha_dsk.entries) do local alpha_ok, alpha_detail = set_dsk_scene( entry.public_scene_name, entry.visible == true and not invite_alpha_dsk.scene_is_excluded(entry, program_scene_name) ) if not alpha_ok then table.insert(failures, alpha_detail) end end local feed_scene_name = stream_view_state.scene_name local show_feed_drawing = stream_view_state.view ~= nil and telestrator_enabled and feed_scene_name ~= "" and not scene_is_telestrator_excluded(feed_scene_name) local view_ok, view_detail = true, "" if show_feed_drawing then view_ok, view_detail = private_dsks.assign_view( CONFIG.telestrator_scene_name ) else private_dsks.clear_view_assignment(CONFIG.telestrator_scene_name) end if not view_ok then table.insert(failures, view_detail) end if #failures > 0 then private_dsks.state = "error" private_dsks.detail = table.concat(failures, "; ") elseif global_audio_enabled or telestrator_enabled or next(invite_alpha_dsk.entries) ~= nil then private_dsks.state = "ready" private_dsks.detail = "Managed downstream keys are ready" else private_dsks.state = "idle" private_dsks.detail = "Managed downstream keys are idle" end end local function update_managed_dsks(command) global_audio_enabled = command.enabled == "1" global_audio_scene = command.callerAudioScene == CONFIG.caller_audio_scene_name and CONFIG.caller_audio_scene_name or "" local requested_telestrator_scene = command.telestratorScene or "" telestrator_enabled = command.telestratorEnabled == "1" and requested_telestrator_scene == CONFIG.telestrator_scene_name telestrator_excluded_scenes = {} local excluded_count = math.max( 0, tonumber(command.telestratorExcludedCount) or 0 ) for index = 1, excluded_count do local scene_name = command["telestratorExcluded" .. index] or "" if scene_name ~= "" then table.insert(telestrator_excluded_scenes, scene_name) end end managed_dsks_dirty = true enforce_managed_dsks() managed_dsks_dirty = false end local function apply_command(command) if command.nonce == nil or command.nonce == "" or command.nonce == last_nonce then return false end last_nonce = command.nonce last_host_heartbeat_at = os.time() if command.action == "start" then start_stream(command) enforce_managed_dsks() managed_dsks_dirty = false elseif command.action == "stop" then stop_stream("Stopped by Caller Companion") elseif command.action == "start-p2p" then start_p2p(command) elseif command.action == "stop-p2p" then p2p_last_error = "" if command.sessionId and command.sessionId ~= "" then release_p2p_session(command.sessionId, "Direct viewer disconnected") else release_all_p2p_outputs("Direct viewers disconnected") end elseif command.action == "sync-managed-dsks" then update_managed_dsks(command) elseif command.action == "clear-managed-dsks" then global_audio_enabled = false global_audio_scene = "" telestrator_enabled = false telestrator_excluded_scenes = {} clear_managed_dsks() private_dsks.state = "idle" private_dsks.detail = "Managed downstream keys are idle" elseif command.action == "ping" then return false end return true end local bitlib = bit or require("bit") local event_cursor = 0 local event_queue = {} invite_alpha_dsk.source_def.output_flags = bitlib.bor( obs.OBS_SOURCE_VIDEO, obs.OBS_SOURCE_CUSTOM_DRAW ) if obs.OBS_SOURCE_SRGB ~= nil then invite_alpha_dsk.source_def.output_flags = bitlib.bor( invite_alpha_dsk.source_def.output_flags, obs.OBS_SOURCE_SRGB ) end invite_alpha_dsk.set_effect_texture = function(parameter, texture, force_srgb) if obs.gs_effect_set_texture_srgb ~= nil and force_srgb then obs.gs_effect_set_texture_srgb(parameter, texture) else obs.gs_effect_set_texture(parameter, texture) end end invite_alpha_dsk.output_assignments_need_repair = function() local program_scene_name = current_program_scene_name() for _, entry in pairs(invite_alpha_dsk.entries) do local expected = entry.visible == true and not invite_alpha_dsk.scene_is_excluded(entry, program_scene_name) local assigned = private_dsks.output_assignment_matches( entry.public_scene_name ) if expected ~= assigned then return true end end return false end invite_alpha_dsk.begin_srgb_draw = function(force_srgb) if obs.gs_framebuffer_srgb_enabled == nil or obs.gs_enable_framebuffer_srgb == nil then return nil end local previous = obs.gs_framebuffer_srgb_enabled() obs.gs_enable_framebuffer_srgb(force_srgb == true) return previous end invite_alpha_dsk.end_srgb_draw = function(previous) if previous ~= nil and obs.gs_enable_framebuffer_srgb ~= nil then obs.gs_enable_framebuffer_srgb(previous) end end invite_alpha_dsk.clear_color = function(data) if not data.clear_color then data.clear_color = obs.vec4() data.clear_color.x = 0.0 data.clear_color.y = 0.0 data.clear_color.z = 0.0 data.clear_color.w = 0.0 end return data.clear_color end invite_alpha_dsk.destroy_source_graphics = function(data) if not data then return end obs.obs_enter_graphics() if data.effect then obs.gs_effect_destroy(data.effect) data.effect = nil end if data.texrender then obs.gs_texrender_destroy(data.texrender) data.texrender = nil end obs.obs_leave_graphics() data.params = nil end invite_alpha_dsk.clear_active_target = function(data) if not data then return end if data.active_target and data.target_linked and obs.obs_source_remove_active_child and data.source then obs.obs_source_remove_active_child(data.source, data.active_target) end if data.active_target then obs.obs_source_release(data.active_target) end data.active_target = nil data.target_linked = false data.target_rejected = false end invite_alpha_dsk.refresh_active_target = function(data) invite_alpha_dsk.clear_active_target(data) if not data or not data.source or not data.target or data.target == "" then return end local target = obs.obs_get_source_by_name(data.target) if not target then return end if target == data.source or obs.obs_source_get_name(target) == obs.obs_source_get_name(data.source) then obs.obs_source_release(target) return end if obs.obs_source_add_active_child then data.target_linked = obs.obs_source_add_active_child(data.source, target) == true if not data.target_linked then data.target_rejected = true obs.obs_source_release(target) return end end data.active_target = target end invite_alpha_dsk.ensure_source_graphics = function(data) if data.effect and data.texrender and data.params and data.params.image then return true end obs.obs_enter_graphics() if not data.effect then data.effect = obs.gs_effect_create( invite_alpha_dsk.effect_source, "drawtool-invite-alpha-composite.effect", nil ) if data.effect then data.params = { image = obs.gs_effect_get_param_by_name(data.effect, "image") } end end if not data.texrender then data.texrender = obs.gs_texrender_create(obs.GS_RGBA, obs.GS_ZS_NONE) end obs.obs_leave_graphics() if not data.effect or not data.params or not data.params.image then if not data.logged_effect_error then obs.script_log( obs.LOG_ERROR, "Invite Alpha Composite: failed to create its GPU effect" ) data.logged_effect_error = true end return false end if not data.texrender then if not data.logged_texrender_error then obs.script_log( obs.LOG_ERROR, "Invite Alpha Composite: failed to create its texrender" ) data.logged_texrender_error = true end return false end data.logged_effect_error = false data.logged_texrender_error = false return true end invite_alpha_dsk.apply_source_settings = function(data, settings) data.target = obs.obs_data_get_string(settings, "target") or "" data.straight_alpha = obs.obs_data_get_bool(settings, "straight_alpha") data.force_srgb = obs.obs_data_get_bool(settings, "force_srgb") data.output_width = math.max( 1, math.floor(tonumber(obs.obs_data_get_int(settings, "output_width")) or 1) ) data.output_height = math.max( 1, math.floor(tonumber(obs.obs_data_get_int(settings, "output_height")) or 1) ) invite_alpha_dsk.refresh_active_target(data) data.graphics_ready = invite_alpha_dsk.ensure_source_graphics(data) return data.graphics_ready end invite_alpha_dsk.get_target = function(data) if data and data.target_rejected then return nil end if data and data.active_target and obs.obs_source_get_ref then return obs.obs_source_get_ref(data.active_target) end if not data or not data.target or data.target == "" then return nil end local target = obs.obs_get_source_by_name(data.target) if not target then return nil end if data.source and ( target == data.source or obs.obs_source_get_name(target) == obs.obs_source_get_name(data.source) ) then obs.obs_source_release(target) return nil end return target end invite_alpha_dsk.render_target = function(data, target) if not invite_alpha_dsk.ensure_source_graphics(data) then return nil end local width = math.max(1, obs.obs_source_get_width(target) or 0) local height = math.max(1, obs.obs_source_get_height(target) or 0) obs.gs_texrender_reset(data.texrender) if not obs.gs_texrender_begin(data.texrender, width, height) then return nil end obs.gs_clear( obs.GS_CLEAR_COLOR, invite_alpha_dsk.clear_color(data), 0.0, 0 ) obs.gs_ortho(0.0, width, 0.0, height, -100.0, 100.0) obs.gs_blend_state_push() obs.gs_blend_function(obs.GS_BLEND_ONE, obs.GS_BLEND_ZERO) obs.obs_source_video_render(target) obs.gs_blend_state_pop() obs.gs_texrender_end(data.texrender) return obs.gs_texrender_get_texture(data.texrender) end invite_alpha_dsk.draw_source = function(data, texture) if not texture or not data.params or not data.params.image then return end invite_alpha_dsk.set_effect_texture( data.params.image, texture, data.force_srgb ) if not data.straight_alpha then obs.gs_blend_state_push() obs.gs_blend_function( obs.GS_BLEND_ONE, obs.GS_BLEND_INVSRCALPHA ) end local previous_srgb = invite_alpha_dsk.begin_srgb_draw(data.force_srgb) while obs.gs_effect_loop(data.effect, "Draw") do obs.gs_draw_sprite( nil, 0, data.output_width, data.output_height ) end invite_alpha_dsk.end_srgb_draw(previous_srgb) if not data.straight_alpha then obs.gs_blend_state_pop() end end invite_alpha_dsk.source_def.get_name = function() return "Alpha Composite" end invite_alpha_dsk.source_def.get_defaults = function(settings) obs.obs_data_set_default_string(settings, "target", "") obs.obs_data_set_default_bool(settings, "straight_alpha", true) obs.obs_data_set_default_bool(settings, "force_srgb", false) obs.obs_data_set_default_int(settings, "output_width", 1920) obs.obs_data_set_default_int(settings, "output_height", 1080) end invite_alpha_dsk.source_def.create = function(settings, source) invite_alpha_dsk.source_def.create_error = "" local data = { source = source, target = "", straight_alpha = true, force_srgb = false, output_width = 1920, output_height = 1080, effect = nil, params = nil, texrender = nil, clear_color = nil, active_target = nil, target_linked = false, target_rejected = false, graphics_ready = false, logged_effect_error = false, logged_texrender_error = false } if not invite_alpha_dsk.apply_source_settings(data, settings) then invite_alpha_dsk.source_def.create_error = "OBS could not initialize the invite alpha compositor" invite_alpha_dsk.clear_active_target(data) invite_alpha_dsk.destroy_source_graphics(data) return nil end invite_alpha_dsk.instances[obs.obs_source_get_name(source) or ""] = data invite_alpha_dsk.source_def.last_created_data = data return data end invite_alpha_dsk.source_def.destroy = function(data) if data and data.source then local source_name = obs.obs_source_get_name(data.source) or "" if invite_alpha_dsk.instances[source_name] == data then invite_alpha_dsk.instances[source_name] = nil end end if invite_alpha_dsk.source_def.last_created_data == data then invite_alpha_dsk.source_def.last_created_data = nil end invite_alpha_dsk.clear_active_target(data) invite_alpha_dsk.destroy_source_graphics(data) end invite_alpha_dsk.source_def.update = function(data, settings) invite_alpha_dsk.apply_source_settings(data, settings) if data and data.source then invite_alpha_dsk.instances[ obs.obs_source_get_name(data.source) or "" ] = data end end invite_alpha_dsk.source_def.load = function(data, settings) invite_alpha_dsk.apply_source_settings(data, settings) if data and data.source then invite_alpha_dsk.instances[ obs.obs_source_get_name(data.source) or "" ] = data end end invite_alpha_dsk.source_def.get_properties = function(data) local properties = obs.obs_properties_create() obs.obs_properties_add_text( properties, "target", "Packed Browser Source", obs.OBS_TEXT_DEFAULT ) obs.obs_properties_add_bool( properties, "straight_alpha", "Straight Alpha" ) obs.obs_properties_add_bool( properties, "force_srgb", "Use sRGB Processing" ) obs.obs_properties_add_int( properties, "output_width", "Output Width", 1, 16384, 1 ) obs.obs_properties_add_int( properties, "output_height", "Output Height", 1, 16384, 1 ) return properties end invite_alpha_dsk.source_def.get_width = function(data) return data and data.output_width or 1 end invite_alpha_dsk.source_def.get_height = function(data) return data and data.output_height or 1 end invite_alpha_dsk.source_def.enum_active_sources = function( data, callback, param ) if data and data.source and data.active_target and callback then callback(data.source, data.active_target, param) end end invite_alpha_dsk.source_def.video_render = function(data, effect) local target = invite_alpha_dsk.get_target(data) if not target then return end local texture = invite_alpha_dsk.render_target(data, target) obs.obs_source_release(target) invite_alpha_dsk.draw_source(data, texture) end local function new_data() return obs.obs_data_create() end local function set_data_object(parent, key, child) obs.obs_data_set_obj(parent, key, child) end local function set_data_array(parent, key, array) obs.obs_data_set_array(parent, key, array) end local function data_has(data, key) return data and ( not obs.obs_data_has_user_value or obs.obs_data_has_user_value(data, key) ) end local function required_string(data, key, label) local value = data and obs.obs_data_get_string(data, key) or "" if not value or value == "" then fail((label or key) .. " is required") end return value end local function source_kind(source) if obs.obs_source_get_unversioned_id then return obs.obs_source_get_unversioned_id(source) or "" end return obs.obs_source_get_id(source) or "" end local function get_source(name, label) local source = private_dsks.get_source_ref(name) or obs.obs_get_source_by_name(name or "") if not source then fail((label or "OBS source") .. " was not found") end return source end local function get_scene(name) local source = get_source(name, "OBS scene") local scene = obs.obs_scene_from_source(source) if not scene then obs.obs_source_release(source) fail("OBS scene was not found") end return source, scene end local function get_scene_item(request) local scene_source, scene = get_scene( required_string(request, "sceneName", "sceneName") ) local item = obs.obs_scene_find_sceneitem_by_id( scene, obs.obs_data_get_int(request, "sceneItemId") ) if not item then obs.obs_source_release(scene_source) fail("OBS scene item was not found") end return scene_source, scene, item end local function status_data() update_p2p_summary() local result = new_data() obs.obs_data_set_string(result, "protocol", CONNECTOR_PROTOCOL.status) obs.obs_data_set_string(result, "version", CONNECTOR_PROTOCOL.version) obs.obs_data_set_string(result, "sceneCollection", current_collection()) obs.obs_data_set_string(result, "state", state) obs.obs_data_set_string(result, "detail", detail) obs.obs_data_set_string(result, "encoder", encoder_id) obs.obs_data_set_int(result, "bitrateKbps", bitrate_kbps) obs.obs_data_set_int( result, "totalBytes", output and obs.obs_output_get_total_bytes(output) or 0 ) obs.obs_data_set_int( result, "droppedFrames", output and obs.obs_output_get_frames_dropped(output) or 0 ) obs.obs_data_set_double( result, "congestion", output and obs.obs_output_get_congestion(output) or 0 ) obs.obs_data_set_int( result, "connectTimeMs", output and obs.obs_output_get_connect_time_ms(output) or 0 ) obs.obs_data_set_string(result, "p2pState", p2p_state) obs.obs_data_set_string(result, "p2pDetail", p2p_detail) obs.obs_data_set_string(result, "p2pSessionId", p2p_session_id) obs.obs_data_set_string(result, "p2pSessionIds", p2p_session_ids) obs.obs_data_set_int(result, "p2pActiveCount", p2p_active_count) obs.obs_data_set_int( result, "p2pTotalBytes", p2p_total_bytes ) obs.obs_data_set_string(result, "p2pLastError", p2p_last_error) obs.obs_data_set_string(result, "diagnostics", diagnostics_summary) obs.obs_data_set_string(result, "nonce", last_nonce) private_dsks.write_status(result) return result end enqueue_event = function(event_type, event_data) event_cursor = event_cursor + 1 local event_json = "{}" if event_data then event_json = obs.obs_data_get_json(event_data) or "{}" end table.insert(event_queue, { id = event_cursor, event_type = event_type, event_json = event_json }) while #event_queue > 128 do table.remove(event_queue, 1) end end local function emit_simple_event(event_type, key, value) local data = new_data() if key then obs.obs_data_set_string(data, key, value or "") end enqueue_event(event_type, data) obs.obs_data_release(data) end local function rpc_poll_events(request) local after = obs.obs_data_get_int(request, "after") local result = new_data() local events = obs.obs_data_array_create() for _, queued in ipairs(event_queue) do if queued.id > after then local item = new_data() obs.obs_data_set_int(item, "id", queued.id) obs.obs_data_set_string(item, "eventType", queued.event_type) local event_data = obs.obs_data_create_from_json(queued.event_json) if not event_data then event_data = new_data() end set_data_object(item, "eventData", event_data) obs.obs_data_release(event_data) obs.obs_data_array_push_back(events, item) obs.obs_data_release(item) end end obs.obs_data_set_int(result, "cursor", event_cursor) set_data_array(result, "events", events) obs.obs_data_array_release(events) return result end local function rpc_get_version() local result = new_data() obs.obs_data_set_string( result, "obsVersion", obs.obs_get_version_string and obs.obs_get_version_string() or "" ) obs.obs_data_set_string(result, "obsWebSocketVersion", "not-required") obs.obs_data_set_string( result, "platformDescription", tostring(jit and (jit.os .. " " .. jit.arch) or "unknown") ) return result end local function rpc_test_video_encoder(request) local requested_encoder_id = required_string( request, "videoEncoderId", "videoEncoderId" ) if jit and jit.os == "OSX" then local result = new_data() obs.obs_data_set_string(result, "videoEncoderId", requested_encoder_id) obs.obs_data_set_bool(result, "available", false) return result end local video_settings = settings_from_json( obs.obs_data_get_string(request, "videoSettings") ) local video_encoder = obs.obs_video_encoder_create( requested_encoder_id, "Caller Companion Encoder Test", video_settings, nil ) obs.obs_data_release(video_settings) local requested_audio_encoder_id = required_string( request, "audioEncoderId", "audioEncoderId" ) local audio_settings = settings_from_json( obs.obs_data_get_string(request, "audioSettings") ) local audio_encoder = obs.obs_audio_encoder_create( requested_audio_encoder_id, "Caller Companion Encoder Test Audio", audio_settings, 0, nil ) obs.obs_data_release(audio_settings) local test_output = obs.obs_output_create( "null_output", "Caller Companion Encoder Test Output", nil, nil ) local available = false if test_output and video_encoder and audio_encoder then obs.obs_encoder_set_video(video_encoder, obs.obs_get_video()) obs.obs_encoder_set_audio(audio_encoder, obs.obs_get_audio()) obs.obs_output_set_video_encoder(test_output, video_encoder) obs.obs_output_set_audio_encoder(test_output, audio_encoder, 0) available = obs.obs_output_start(test_output) == true if available then obs.obs_output_stop(test_output) table.insert(encoder_probe_releases, { output = test_output, video_encoder = video_encoder, audio_encoder = audio_encoder, release_after = os.time() + 3 }) test_output = nil video_encoder = nil audio_encoder = nil end end local result = new_data() obs.obs_data_set_string(result, "videoEncoderId", requested_encoder_id) obs.obs_data_set_bool(result, "available", available) if test_output then obs.obs_output_release(test_output) end if video_encoder then obs.obs_encoder_release(video_encoder) end if audio_encoder then obs.obs_encoder_release(audio_encoder) end return result end local function rpc_get_scene_collection_list() local result = new_data() local collections = obs.obs_data_array_create() for _, name in ipairs(frontend_state.collections) do local item = new_data() obs.obs_data_set_string(item, "sceneCollectionName", name) obs.obs_data_array_push_back(collections, item) obs.obs_data_release(item) end obs.obs_data_set_string( result, "currentSceneCollectionName", current_collection() ) set_data_array(result, "sceneCollections", collections) obs.obs_data_array_release(collections) return result end local function rpc_set_current_scene_collection(request) local collection_name = required_string( request, "sceneCollectionName", "sceneCollectionName" ) if collection_name == current_collection() then return new_data() end if not obs.obs_frontend_set_current_scene_collection then fail("This OBS build cannot switch scene collections from Caller Companion") end frontend_state.refresh_collections() local available = false for _, name in ipairs(frontend_state.collections or {}) do if name == collection_name then available = true break end end if not available then fail("OBS scene collection '" .. collection_name .. "' was not found") end local switched, switch_error = pcall( obs.obs_frontend_set_current_scene_collection, collection_name ) if not switched then fail(tostring(switch_error)) end return new_data() end local function rpc_get_persistent_data(request) local result = new_data() local slot_name = required_string(request, "slotName", "slotName") local value = script_settings and obs.obs_data_get_string(script_settings, "persistent." .. slot_name) or "" obs.obs_data_set_string(result, "slotValue", value or "") return result end local function rpc_set_persistent_data(request) local slot_name = required_string(request, "slotName", "slotName") local value = obs.obs_data_get_string(request, "slotValue") or "" if script_settings then obs.obs_data_set_string(script_settings, "persistent." .. slot_name, value) end return new_data() end local function rpc_get_video_settings() local result = new_data() local video_info = obs.obs_video_info() if not obs.obs_get_video_info(video_info) then fail("OBS video settings are unavailable") end obs.obs_data_set_int(result, "baseWidth", video_info.base_width or 0) obs.obs_data_set_int(result, "baseHeight", video_info.base_height or 0) obs.obs_data_set_int(result, "outputWidth", video_info.output_width or 0) obs.obs_data_set_int(result, "outputHeight", video_info.output_height or 0) obs.obs_data_set_int(result, "fpsNumerator", video_info.fps_num or 0) obs.obs_data_set_int(result, "fpsDenominator", video_info.fps_den or 1) return result end local function rpc_get_input_list() local result = new_data() local inputs = obs.obs_data_array_create() local sources = obs.obs_enum_sources() or {} for _, source in ipairs(sources) do if obs.obs_source_get_type(source) == obs.OBS_SOURCE_TYPE_INPUT then local item = new_data() obs.obs_data_set_string( item, "inputName", obs.obs_source_get_name(source) or "" ) obs.obs_data_set_string(item, "inputKind", source_kind(source)) obs.obs_data_set_string( item, "unversionedInputKind", source_kind(source) ) obs.obs_data_array_push_back(inputs, item) obs.obs_data_release(item) end end obs.source_list_release(sources) set_data_array(result, "inputs", inputs) obs.obs_data_array_release(inputs) return result end local function rpc_get_scene_list() local result = new_data() local scenes_data = obs.obs_data_array_create() local current_name = current_program_scene_name() for _, scene_name in ipairs(frontend_state.scenes) do local item = new_data() obs.obs_data_set_string(item, "sceneName", scene_name) obs.obs_data_set_bool(item, "isInternal", false) obs.obs_data_array_push_back(scenes_data, item) obs.obs_data_release(item) end for _, scene_name in ipairs({ CONFIG.caller_audio_scene_name, CONFIG.telestrator_scene_name }) do if private_dsks.scenes[scene_name] then local item = new_data() obs.obs_data_set_string(item, "sceneName", scene_name) obs.obs_data_set_bool(item, "isInternal", true) obs.obs_data_array_push_back(scenes_data, item) obs.obs_data_release(item) end end obs.obs_data_set_string(result, "currentProgramSceneName", current_name) set_data_array(result, "scenes", scenes_data) obs.obs_data_array_release(scenes_data) return result end local function rpc_set_input_name(request) local new_input_name = required_string(request, "newInputName", "newInputName") local previous_name = required_string(request, "inputName", "inputName") if private_dsks.kind(previous_name) or private_dsks.kind(new_input_name) then fail("Managed DSK names are reserved for internal scenes") end local source = get_source( previous_name, "OBS input" ) obs.obs_frontend_remove_event_callback(frontend_event) local renamed, rename_error = pcall( obs.obs_source_set_name, source, new_input_name ) obs.obs_frontend_add_event_callback(frontend_event) obs.obs_source_release(source) if not renamed then fail(rename_error) end return new_data() end local function rpc_set_scene_name(request) local new_scene_name = required_string(request, "newSceneName", "newSceneName") local previous_name = required_string(request, "sceneName", "sceneName") if private_dsks.scenes[previous_name] then fail("Internal DSK scenes cannot be renamed") end if private_dsks.kind(new_scene_name) then fail("Managed DSK names are reserved for internal scenes") end local source = get_source( previous_name, "OBS scene" ) obs.obs_frontend_remove_event_callback(frontend_event) local renamed, rename_error = pcall( obs.obs_source_set_name, source, new_scene_name ) obs.obs_frontend_add_event_callback(frontend_event) obs.obs_source_release(source) if not renamed then fail(rename_error) end frontend_state.rename_scene(previous_name, new_scene_name) return new_data() end local function rpc_get_input_settings(request) local source = get_source( required_string(request, "inputName", "inputName"), "OBS input" ) local settings = obs.obs_source_get_settings(source) local result = new_data() obs.obs_data_set_string(result, "inputKind", source_kind(source)) set_data_object(result, "inputSettings", settings) obs.obs_data_release(settings) obs.obs_source_release(source) return result end local function rpc_set_input_settings(request) local input_name = required_string(request, "inputName", "inputName") local source = get_source(input_name, "OBS input") local settings = obs.obs_data_get_obj(request, "inputSettings") if not settings then obs.obs_source_release(source) fail("inputSettings is required") end obs.obs_source_update(source, settings) obs.obs_data_release(settings) obs.obs_source_release(source) return new_data() end local function rpc_press_input_properties_button(request) local property_name = required_string(request, "propertyName", "propertyName") if property_name ~= "refreshnocache" then fail("Unsupported OBS input button property: " .. property_name) end local source = get_source( required_string(request, "inputName", "inputName"), "OBS input" ) if source_kind(source) ~= "browser_source" then obs.obs_source_release(source) fail("OBS input is not a browser source") end local properties = obs.obs_source_properties(source) if not properties then obs.obs_source_release(source) fail("OBS input properties were not available") end local property = obs.obs_properties_get(properties, property_name) if not property or obs.obs_property_get_type(property) ~= obs.OBS_PROPERTY_BUTTON then obs.obs_properties_destroy(properties) obs.obs_source_release(source) fail("OBS input button property was not found: " .. property_name) end obs.obs_property_button_clicked(property, source) obs.obs_properties_destroy(properties) obs.obs_source_release(source) return new_data() end local function rpc_get_input_mute(request) local source = get_source( required_string(request, "inputName", "inputName"), "OBS input" ) local result = new_data() obs.obs_data_set_bool(result, "inputMuted", obs.obs_source_muted(source)) obs.obs_source_release(source) return result end local function rpc_set_input_mute(request) local source = get_source( required_string(request, "inputName", "inputName"), "OBS input" ) obs.obs_source_set_muted( source, obs.obs_data_get_bool(request, "inputMuted") ) obs.obs_source_release(source) return new_data() end local function rpc_get_input_audio_tracks(request) local source = get_source( required_string(request, "inputName", "inputName"), "OBS input" ) local mixers = obs.obs_source_get_audio_mixers(source) local result = new_data() local tracks = new_data() for index = 0, 5 do obs.obs_data_set_bool( tracks, tostring(index + 1), bitlib.band(mixers, bitlib.lshift(1, index)) ~= 0 ) end set_data_object(result, "inputAudioTracks", tracks) obs.obs_data_release(tracks) obs.obs_source_release(source) return result end local function rpc_set_input_audio_tracks(request) local source = get_source( required_string(request, "inputName", "inputName"), "OBS input" ) local tracks = obs.obs_data_get_obj(request, "inputAudioTracks") if not tracks then obs.obs_source_release(source) fail("inputAudioTracks is required") end local mixers = 0 for index = 0, 5 do if obs.obs_data_get_bool(tracks, tostring(index + 1)) then mixers = bitlib.bor(mixers, bitlib.lshift(1, index)) end end obs.obs_source_set_audio_mixers(source, mixers) obs.obs_data_release(tracks) obs.obs_source_release(source) return new_data() end local monitor_types = { OBS_MONITORING_TYPE_NONE = obs.OBS_MONITORING_TYPE_NONE, OBS_MONITORING_TYPE_MONITOR_ONLY = obs.OBS_MONITORING_TYPE_MONITOR_ONLY, OBS_MONITORING_TYPE_MONITOR_AND_OUTPUT = obs.OBS_MONITORING_TYPE_MONITOR_AND_OUTPUT } local function rpc_set_input_audio_monitor_type(request) local monitor_type = required_string(request, "monitorType", "monitorType") local source = get_source( required_string(request, "inputName", "inputName"), "OBS input" ) if monitor_types[monitor_type] == nil then obs.obs_source_release(source) fail("Unsupported OBS monitoring type") end obs.obs_source_set_monitoring_type(source, monitor_types[monitor_type]) obs.obs_source_release(source) return new_data() end local bounds_types = { OBS_BOUNDS_NONE = obs.OBS_BOUNDS_NONE, OBS_BOUNDS_STRETCH = obs.OBS_BOUNDS_STRETCH, OBS_BOUNDS_SCALE_INNER = obs.OBS_BOUNDS_SCALE_INNER, OBS_BOUNDS_SCALE_OUTER = obs.OBS_BOUNDS_SCALE_OUTER, OBS_BOUNDS_SCALE_TO_WIDTH = obs.OBS_BOUNDS_SCALE_TO_WIDTH, OBS_BOUNDS_SCALE_TO_HEIGHT = obs.OBS_BOUNDS_SCALE_TO_HEIGHT, OBS_BOUNDS_MAX_ONLY = obs.OBS_BOUNDS_MAX_ONLY } local bounds_type_names = {} for name, value in pairs(bounds_types) do bounds_type_names[value] = name end local function scene_item_transform(item) local result = new_data() local source = obs.obs_sceneitem_get_source(item) local pos = obs.vec2() local scale = obs.vec2() local bounds = obs.vec2() local crop = obs.obs_sceneitem_crop() obs.obs_sceneitem_get_pos(item, pos) obs.obs_sceneitem_get_scale(item, scale) obs.obs_sceneitem_get_bounds(item, bounds) obs.obs_sceneitem_get_crop(item, crop) obs.obs_data_set_double(result, "positionX", pos.x) obs.obs_data_set_double(result, "positionY", pos.y) obs.obs_data_set_double(result, "rotation", obs.obs_sceneitem_get_rot(item)) obs.obs_data_set_double(result, "scaleX", scale.x) obs.obs_data_set_double(result, "scaleY", scale.y) obs.obs_data_set_int(result, "alignment", obs.obs_sceneitem_get_alignment(item)) obs.obs_data_set_int(result, "cropLeft", crop.left) obs.obs_data_set_int(result, "cropRight", crop.right) obs.obs_data_set_int(result, "cropTop", crop.top) obs.obs_data_set_int(result, "cropBottom", crop.bottom) obs.obs_data_set_double(result, "boundsWidth", bounds.x) obs.obs_data_set_double(result, "boundsHeight", bounds.y) obs.obs_data_set_string( result, "boundsType", bounds_type_names[obs.obs_sceneitem_get_bounds_type(item)] or "OBS_BOUNDS_NONE" ) obs.obs_data_set_int(result, "sourceWidth", obs.obs_source_get_width(source)) obs.obs_data_set_int(result, "sourceHeight", obs.obs_source_get_height(source)) return result end local function rpc_get_scene_item_list(request) local scene_source, scene = get_scene( required_string(request, "sceneName", "sceneName") ) local result = new_data() local result_items = obs.obs_data_array_create() local items = obs.obs_scene_enum_items(scene) or {} for _, scene_item in ipairs(items) do local source = obs.obs_sceneitem_get_source(scene_item) local item = new_data() obs.obs_data_set_int(item, "sceneItemId", obs.obs_sceneitem_get_id(scene_item)) obs.obs_data_set_int( item, "sceneItemIndex", obs.obs_sceneitem_get_order_position(scene_item) ) obs.obs_data_set_string( item, "sourceName", obs.obs_source_get_name(source) or "" ) obs.obs_data_set_string(item, "inputKind", source_kind(source)) obs.obs_data_set_bool( item, "sceneItemEnabled", obs.obs_sceneitem_visible(scene_item) ) local transform = scene_item_transform(scene_item) set_data_object(item, "sceneItemTransform", transform) obs.obs_data_release(transform) obs.obs_data_array_push_back(result_items, item) obs.obs_data_release(item) end obs.sceneitem_list_release(items) set_data_array(result, "sceneItems", result_items) obs.obs_data_array_release(result_items) obs.obs_source_release(scene_source) return result end local function find_scene_item_by_name(scene, source_name, search_offset) local matches = 0 local items = obs.obs_scene_enum_items(scene) or {} local found = nil for _, item in ipairs(items) do local source = obs.obs_sceneitem_get_source(item) if not found and obs.obs_source_get_name(source) == source_name then if matches >= search_offset then obs.obs_sceneitem_addref(item) found = item end matches = matches + 1 end end obs.sceneitem_list_release(items) return found end invite_alpha_dsk.private_scene_name = function(composite_source_name) return "[DrawTool Invite Alpha Private] " .. composite_source_name end invite_alpha_dsk.find_item = function(scene, source) if not scene or not source then return nil end local items = obs.obs_scene_enum_items(scene) or {} local found = nil for _, item in ipairs(items) do if not found and private_dsks.same_source( obs.obs_sceneitem_get_source(item), source ) then obs.obs_sceneitem_addref(item) found = item end end obs.sceneitem_list_release(items) return found end invite_alpha_dsk.add_item_ref = function(scene, source) local item = obs.obs_scene_add(scene, source) if item then obs.obs_sceneitem_addref(item) end return item end invite_alpha_dsk.remove_items = function(scene, source) local item = invite_alpha_dsk.find_item(scene, source) while item do obs.obs_sceneitem_remove(item) obs.obs_sceneitem_release(item) item = invite_alpha_dsk.find_item(scene, source) end end invite_alpha_dsk.capture_item_state = function(item) if not item then return nil end local state = { transform = scene_item_transform(item), index = obs.obs_sceneitem_get_order_position(item), visible = obs.obs_sceneitem_visible(item) } if obs.obs_sceneitem_locked then state.locked = obs.obs_sceneitem_locked(item) end if obs.obs_sceneitem_get_blending_mode then state.blending_mode = obs.obs_sceneitem_get_blending_mode(item) end return state end invite_alpha_dsk.release_item_state = function(state) if state and state.transform then obs.obs_data_release(state.transform) state.transform = nil end end invite_alpha_dsk.apply_item_state = function(item, state) if not item or not state or not state.transform then return end local transform = state.transform local position = obs.vec2() position.x = obs.obs_data_get_double(transform, "positionX") position.y = obs.obs_data_get_double(transform, "positionY") local scale = obs.vec2() scale.x = obs.obs_data_get_double(transform, "scaleX") scale.y = obs.obs_data_get_double(transform, "scaleY") local bounds = obs.vec2() bounds.x = obs.obs_data_get_double(transform, "boundsWidth") bounds.y = obs.obs_data_get_double(transform, "boundsHeight") local crop = obs.obs_sceneitem_crop() crop.left = obs.obs_data_get_int(transform, "cropLeft") crop.right = obs.obs_data_get_int(transform, "cropRight") crop.top = obs.obs_data_get_int(transform, "cropTop") crop.bottom = obs.obs_data_get_int(transform, "cropBottom") obs.obs_sceneitem_defer_update_begin(item) obs.obs_sceneitem_set_pos(item, position) obs.obs_sceneitem_set_scale(item, scale) obs.obs_sceneitem_set_rot( item, obs.obs_data_get_double(transform, "rotation") ) obs.obs_sceneitem_set_alignment( item, obs.obs_data_get_int(transform, "alignment") ) obs.obs_sceneitem_set_bounds_type( item, bounds_types[ obs.obs_data_get_string(transform, "boundsType") ] or obs.OBS_BOUNDS_NONE ) obs.obs_sceneitem_set_bounds(item, bounds) obs.obs_sceneitem_set_crop(item, crop) obs.obs_sceneitem_defer_update_end(item) obs.obs_sceneitem_set_visible(item, state.visible) if state.locked ~= nil and obs.obs_sceneitem_set_locked then obs.obs_sceneitem_set_locked(item, state.locked) end if state.blending_mode ~= nil and obs.obs_sceneitem_set_blending_mode then obs.obs_sceneitem_set_blending_mode(item, state.blending_mode) end end invite_alpha_dsk.fit_item_to_canvas = function( item, source_width, source_height, canvas_width, canvas_height ) if not item or source_width < 1 or source_height < 1 or canvas_width < 1 or canvas_height < 1 then return end local position = obs.vec2() position.x = 0 position.y = 0 local scale = obs.vec2() scale.x = canvas_width / source_width scale.y = canvas_height / source_height local bounds = obs.vec2() bounds.x = 0 bounds.y = 0 local crop = obs.obs_sceneitem_crop() crop.left = 0 crop.right = 0 crop.top = 0 crop.bottom = 0 obs.obs_sceneitem_defer_update_begin(item) obs.obs_sceneitem_set_pos(item, position) obs.obs_sceneitem_set_scale(item, scale) obs.obs_sceneitem_set_rot(item, 0) obs.obs_sceneitem_set_alignment(item, 5) obs.obs_sceneitem_set_bounds_type(item, obs.OBS_BOUNDS_NONE) obs.obs_sceneitem_set_bounds(item, bounds) obs.obs_sceneitem_set_crop(item, crop) obs.obs_sceneitem_defer_update_end(item) end invite_alpha_dsk.make_item_audio_only = function(item, enabled) if not item then return end local zero = obs.vec2() zero.x = 0 zero.y = 0 local crop = obs.obs_sceneitem_crop() crop.left = 0 crop.right = 0 crop.top = 0 crop.bottom = 0 obs.obs_sceneitem_defer_update_begin(item) obs.obs_sceneitem_set_pos(item, zero) obs.obs_sceneitem_set_scale(item, zero) obs.obs_sceneitem_set_rot(item, 0) obs.obs_sceneitem_set_alignment(item, 5) obs.obs_sceneitem_set_bounds_type(item, obs.OBS_BOUNDS_NONE) obs.obs_sceneitem_set_bounds(item, zero) obs.obs_sceneitem_set_crop(item, crop) obs.obs_sceneitem_defer_update_end(item) obs.obs_sceneitem_set_visible(item, enabled == true) end invite_alpha_dsk.update_source = function( source, target_name, straight_alpha, force_srgb, output_width, output_height ) local settings = obs.obs_data_create() obs.obs_data_set_string(settings, "target", target_name) obs.obs_data_set_bool(settings, "straight_alpha", straight_alpha) obs.obs_data_set_bool(settings, "force_srgb", force_srgb) obs.obs_data_set_int(settings, "output_width", output_width) obs.obs_data_set_int(settings, "output_height", output_height) obs.obs_source_update(source, settings) obs.obs_data_release(settings) end invite_alpha_dsk.new_source = function( source_name, target_name, straight_alpha, force_srgb, output_width, output_height ) invite_alpha_dsk.source_def.create_error = "" invite_alpha_dsk.source_def.last_created_data = nil local settings = obs.obs_data_create() obs.obs_data_set_string(settings, "target", target_name) obs.obs_data_set_bool(settings, "straight_alpha", straight_alpha) obs.obs_data_set_bool(settings, "force_srgb", force_srgb) obs.obs_data_set_int(settings, "output_width", output_width) obs.obs_data_set_int(settings, "output_height", output_height) local source = obs.obs_source_create( invite_alpha_dsk.source_id, source_name, settings, nil ) obs.obs_data_release(settings) return source end invite_alpha_dsk.release_entry = function(composite_source_name) local entry = invite_alpha_dsk.entries[composite_source_name] if not entry then return false end invite_alpha_dsk.entries[composite_source_name] = nil private_dsks.unregister_public_output_source(entry.public_scene_name) if entry.scene then obs.obs_scene_release(entry.scene) entry.scene = nil end return true end invite_alpha_dsk.release_all = function() local names = {} for composite_source_name in pairs(invite_alpha_dsk.entries) do table.insert(names, composite_source_name) end for _, composite_source_name in ipairs(names) do invite_alpha_dsk.release_entry(composite_source_name) end end invite_alpha_dsk.restore_entry = function(composite_source_name) local entry = invite_alpha_dsk.entries[composite_source_name] if not entry then return true end local public_source = obs.obs_get_source_by_name( entry.public_scene_name or "" ) local public_scene = public_source and obs.obs_scene_from_source(public_source) or nil local raw_source = obs.obs_get_source_by_name(entry.raw_source_name or "") local restored = false local restore_detail = "" if public_scene and raw_source then local ok, result = pcall( invite_alpha_dsk.disable, public_scene, entry.public_scene_name, raw_source, entry.raw_source_name, composite_source_name ) restored = ok if ok and result then obs.obs_data_release(result) elseif not ok then restore_detail = tostring(result) end else restore_detail = "the public stream scene or browser source no longer exists" end if raw_source then obs.obs_source_release(raw_source) end if public_source then obs.obs_source_release(public_source) end if not restored then local composite_source = obs.obs_get_source_by_name( composite_source_name ) if composite_source and source_kind(composite_source) == invite_alpha_dsk.source_id then pcall(obs.obs_source_remove, composite_source) end if composite_source then obs.obs_source_release(composite_source) end end if invite_alpha_dsk.entries[composite_source_name] then invite_alpha_dsk.release_entry(composite_source_name) end if not restored and restore_detail ~= "" then obs.script_log( obs.LOG_WARNING, "Invite Alpha Composite cleanup could not restore " .. composite_source_name .. ": " .. restore_detail ) end return restored end invite_alpha_dsk.restore_all = function() local names = {} for composite_source_name in pairs(invite_alpha_dsk.entries) do table.insert(names, composite_source_name) end for _, composite_source_name in ipairs(names) do invite_alpha_dsk.restore_entry(composite_source_name) end end invite_alpha_dsk.mark_source_stale = function(source) if not source then return end local source_name = obs.obs_source_get_name(source) or "" for composite_source_name, entry in pairs(invite_alpha_dsk.entries) do if source_name == composite_source_name or source_name == entry.raw_source_name or source_name == entry.public_scene_name then entry.stale = true end end end invite_alpha_dsk.cleanup_stale = function() local names = {} for composite_source_name, entry in pairs(invite_alpha_dsk.entries) do if entry.stale then table.insert(names, composite_source_name) end end for _, composite_source_name in ipairs(names) do invite_alpha_dsk.restore_entry(composite_source_name) end end invite_alpha_dsk.result = function(item, active, composite_source_name) local result = new_data() obs.obs_data_set_int( result, "sceneItemId", item and obs.obs_sceneitem_get_id(item) or 0 ) obs.obs_data_set_bool(result, "active", active) obs.obs_data_set_string( result, "compositeSourceName", composite_source_name ) return result end invite_alpha_dsk.enable = function( public_scene, public_scene_name, raw_source, raw_source_name, composite_source_name, straight_alpha, force_srgb, output_width, output_height, excluded_scenes, visible, fit_to_canvas, canvas_width, canvas_height ) local entry = invite_alpha_dsk.entries[composite_source_name] if entry and ( entry.public_scene_name ~= public_scene_name or entry.raw_source_name ~= raw_source_name ) then fail("Composite source is already assigned to another invite stream") end for managed_name, managed_entry in pairs(invite_alpha_dsk.entries) do if managed_name ~= composite_source_name and managed_entry.raw_source_name == raw_source_name then fail("Browser source is already assigned to another invite DSK") end end local new_entry = false if not entry then local private_scene = obs.obs_scene_create_private( invite_alpha_dsk.private_scene_name(composite_source_name) ) if not private_scene then fail("OBS could not create the invite DSK private scene") end entry = { scene = private_scene, public_scene_name = public_scene_name, raw_source_name = raw_source_name, excluded_scenes = {} } new_entry = true end entry.excluded_scenes = excluded_scenes or {} entry.visible = visible == true local private_item = invite_alpha_dsk.find_item(entry.scene, raw_source) if not private_item then private_item = invite_alpha_dsk.add_item_ref(entry.scene, raw_source) end if not private_item then if new_entry then obs.obs_scene_release(entry.scene) end fail("OBS could not place the browser source in its private scene") end obs.obs_sceneitem_release(private_item) local composite_source = obs.obs_get_source_by_name(composite_source_name) local created_source = false if composite_source and source_kind(composite_source) ~= invite_alpha_dsk.source_id then obs.obs_source_release(composite_source) if new_entry then obs.obs_scene_release(entry.scene) end fail("An incompatible OBS source already uses compositeSourceName") end if not composite_source then composite_source = invite_alpha_dsk.new_source( composite_source_name, raw_source_name, straight_alpha, force_srgb, output_width, output_height ) created_source = composite_source ~= nil end if not composite_source then if new_entry then obs.obs_scene_release(entry.scene) end fail( invite_alpha_dsk.source_def.create_error ~= "" and invite_alpha_dsk.source_def.create_error or "OBS could not create the invite alpha composite source" ) end invite_alpha_dsk.update_source( composite_source, raw_source_name, straight_alpha, force_srgb, output_width, output_height ) local source_data = invite_alpha_dsk.instances[composite_source_name] if not source_data and created_source then source_data = invite_alpha_dsk.source_def.last_created_data end if not source_data or not source_data.graphics_ready or not source_data.active_target or source_data.target_rejected or (obs.obs_source_add_active_child and not source_data.target_linked) then if created_source then pcall(obs.obs_source_remove, composite_source) end obs.obs_source_release(composite_source) if new_entry then obs.obs_scene_release(entry.scene) end fail("OBS could not activate the invite alpha compositor") end local composite_item = invite_alpha_dsk.find_item( public_scene, composite_source ) local created_composite_item = false if not composite_item then composite_item = invite_alpha_dsk.add_item_ref( public_scene, composite_source ) created_composite_item = composite_item ~= nil end if not composite_item then if created_source then pcall(obs.obs_source_remove, composite_source) end obs.obs_source_release(composite_source) if new_entry then obs.obs_scene_release(entry.scene) end fail("OBS could not add the invite alpha composite to the stream scene") end local raw_item = invite_alpha_dsk.find_item(public_scene, raw_source) local raw_state = invite_alpha_dsk.capture_item_state(raw_item) if not raw_item then raw_item = invite_alpha_dsk.add_item_ref(public_scene, raw_source) end if not raw_item then if created_source then pcall(obs.obs_source_remove, composite_source) elseif created_composite_item then obs.obs_sceneitem_remove(composite_item) end obs.obs_sceneitem_release(composite_item) obs.obs_source_release(composite_source) if new_entry then obs.obs_scene_release(entry.scene) end fail("OBS could not keep the invite stream audio in its DSK scene") end if created_composite_item and raw_state then invite_alpha_dsk.apply_item_state(composite_item, raw_state) end invite_alpha_dsk.make_item_audio_only(raw_item, entry.visible) if created_composite_item and raw_state then obs.obs_sceneitem_set_order_position( composite_item, raw_state.index ) end if fit_to_canvas then invite_alpha_dsk.fit_item_to_canvas( composite_item, output_width, output_height, canvas_width, canvas_height ) end obs.obs_sceneitem_set_visible(composite_item, entry.visible) if new_entry then invite_alpha_dsk.entries[composite_source_name] = entry end private_dsks.register_public_output_source(public_scene_name) local result = invite_alpha_dsk.result( composite_item, true, composite_source_name ) invite_alpha_dsk.release_item_state(raw_state) if raw_item then obs.obs_sceneitem_release(raw_item) end obs.obs_sceneitem_release(composite_item) obs.obs_source_release(composite_source) return result end invite_alpha_dsk.disable = function( public_scene, public_scene_name, raw_source, raw_source_name, composite_source_name, fit_to_canvas, output_width, output_height, canvas_width, canvas_height ) local entry = invite_alpha_dsk.entries[composite_source_name] if entry and ( entry.public_scene_name ~= public_scene_name or entry.raw_source_name ~= raw_source_name ) then fail("Composite source is already assigned to another invite stream") end local composite_source = obs.obs_get_source_by_name(composite_source_name) if composite_source and source_kind(composite_source) ~= invite_alpha_dsk.source_id then obs.obs_source_release(composite_source) fail("An incompatible OBS source already uses compositeSourceName") end local composite_item = composite_source and invite_alpha_dsk.find_item(public_scene, composite_source) or nil local composite_state = invite_alpha_dsk.capture_item_state(composite_item) local raw_item = invite_alpha_dsk.find_item(public_scene, raw_source) local created_raw_item = false if not raw_item then raw_item = invite_alpha_dsk.add_item_ref(public_scene, raw_source) created_raw_item = raw_item ~= nil end if not raw_item then if composite_item then obs.obs_sceneitem_release(composite_item) end if composite_source then obs.obs_source_release(composite_source) end invite_alpha_dsk.release_item_state(composite_state) fail("OBS could not restore the browser source to the stream scene") end if composite_state then invite_alpha_dsk.apply_item_state(raw_item, composite_state) end if composite_source then local removed, remove_error = pcall( obs.obs_source_remove, composite_source ) if not removed then if created_raw_item then obs.obs_sceneitem_remove(raw_item) end obs.obs_sceneitem_release(raw_item) if composite_item then obs.obs_sceneitem_release(composite_item) end obs.obs_source_release(composite_source) invite_alpha_dsk.release_item_state(composite_state) fail(remove_error) end end if composite_state then obs.obs_sceneitem_set_order_position(raw_item, composite_state.index) end if fit_to_canvas then invite_alpha_dsk.fit_item_to_canvas( raw_item, output_width, output_height, canvas_width, canvas_height ) end invite_alpha_dsk.release_entry(composite_source_name) local result = invite_alpha_dsk.result( raw_item, false, composite_source_name ) obs.obs_sceneitem_release(raw_item) if composite_item then obs.obs_sceneitem_release(composite_item) end if composite_source then obs.obs_source_release(composite_source) end invite_alpha_dsk.release_item_state(composite_state) return result end invite_alpha_dsk.rpc_configure = function(request) if not obs.obs_scene_create_private or not obs.obs_source_remove or not obs.obs_source_add_active_child or not obs.obs_source_remove_active_child or not obs.obs_source_get_ref then fail("This OBS build cannot manage invite alpha DSK scenes") end local public_scene_name = required_string( request, "sceneName", "sceneName" ) local raw_source_name = required_string( request, "sourceName", "sourceName" ) local composite_source_name = required_string( request, "compositeSourceName", "compositeSourceName" ) if raw_source_name == composite_source_name then fail("sourceName and compositeSourceName must be different") end if raw_source_name == public_scene_name or composite_source_name == public_scene_name then fail("Invite DSK source names must differ from sceneName") end local enabled = obs.obs_data_get_bool(request, "enabled") local visible = enabled and obs.obs_data_get_bool(request, "visible") local straight_alpha = not data_has(request, "straightAlpha") or obs.obs_data_get_bool(request, "straightAlpha") local force_srgb = obs.obs_data_get_bool(request, "srgb") local output_width = obs.obs_data_get_int(request, "width") local output_height = obs.obs_data_get_int(request, "height") local fit_to_canvas = obs.obs_data_get_bool(request, "fitToCanvas") local canvas_width = obs.obs_data_get_int(request, "canvasWidth") local canvas_height = obs.obs_data_get_int(request, "canvasHeight") local excluded_scenes = {} local excluded_count = math.max( 0, math.min(512, obs.obs_data_get_int(request, "excludedSceneCount")) ) for index = 1, excluded_count do local scene_name = obs.obs_data_get_string( request, "excludedScene" .. index ) if scene_name ~= "" and scene_name ~= public_scene_name then table.insert(excluded_scenes, scene_name) end end if enabled and ( output_width < 1 or output_height < 1 or output_width > 16384 or output_height > 16384 ) then fail("width and height must be between 1 and 16384") end if fit_to_canvas and ( output_width < 1 or output_height < 1 or canvas_width < 1 or canvas_height < 1 or canvas_width > 16384 or canvas_height > 16384 ) then fail("Alpha DSK canvas dimensions must be between 1 and 16384") end local public_source, public_scene = get_scene(public_scene_name) local raw_source = obs.obs_get_source_by_name(raw_source_name) if not raw_source then obs.obs_source_release(public_source) fail("Invite browser source was not found") end if obs.obs_source_get_type(raw_source) ~= obs.OBS_SOURCE_TYPE_INPUT then obs.obs_source_release(raw_source) obs.obs_source_release(public_source) fail("sourceName must identify an OBS input") end local ok, result = pcall(function() if enabled then return invite_alpha_dsk.enable( public_scene, public_scene_name, raw_source, raw_source_name, composite_source_name, straight_alpha, force_srgb, output_width, output_height, excluded_scenes, visible, fit_to_canvas, canvas_width, canvas_height ) end return invite_alpha_dsk.disable( public_scene, public_scene_name, raw_source, raw_source_name, composite_source_name, fit_to_canvas, output_width, output_height, canvas_width, canvas_height ) end) obs.obs_source_release(raw_source) obs.obs_source_release(public_source) if not ok then fail(result) end managed_dsks_dirty = true enforce_managed_dsks() managed_dsks_dirty = false return result end local function rpc_get_scene_item_id(request) local scene_source, scene = get_scene( required_string(request, "sceneName", "sceneName") ) local item = find_scene_item_by_name( scene, required_string(request, "sourceName", "sourceName"), math.max(0, obs.obs_data_get_int(request, "searchOffset")) ) if not item then obs.obs_source_release(scene_source) fail("OBS scene item was not found") end local result = new_data() obs.obs_data_set_int(result, "sceneItemId", obs.obs_sceneitem_get_id(item)) obs.obs_sceneitem_release(item) obs.obs_source_release(scene_source) return result end local function rpc_create_scene_item(request) local scene_name = required_string(request, "sceneName", "sceneName") local source_name = required_string(request, "sourceName", "sourceName") local scene_source, scene = get_scene( scene_name ) local source = get_source( source_name, "OBS source" ) local item = obs.obs_scene_add(scene, source) obs.obs_source_release(source) if not item then obs.obs_source_release(scene_source) fail("OBS could not create the scene item") end if data_has(request, "sceneItemEnabled") then obs.obs_sceneitem_set_visible( item, obs.obs_data_get_bool(request, "sceneItemEnabled") ) end local result = new_data() obs.obs_data_set_int(result, "sceneItemId", obs.obs_sceneitem_get_id(item)) obs.obs_source_release(scene_source) emit_simple_event("SceneItemCreated") return result end local function rpc_create_input(request) local scene_name = required_string(request, "sceneName", "sceneName") local input_kind = required_string(request, "inputKind", "inputKind") local input_name = required_string(request, "inputName", "inputName") if private_dsks.kind(input_name) then fail("Managed DSK names are reserved for internal scenes") end local scene_source, scene = get_scene( scene_name ) local input_settings = obs.obs_data_get_obj(request, "inputSettings") if not input_settings then input_settings = new_data() end local source = obs.obs_source_create( input_kind, input_name, input_settings, nil ) obs.obs_data_release(input_settings) if not source then obs.obs_source_release(scene_source) fail("OBS could not create the input") end local item = obs.obs_scene_add(scene, source) obs.obs_source_release(source) if not item then obs.obs_source_release(scene_source) fail("OBS could not add the input to the scene") end if data_has(request, "sceneItemEnabled") then obs.obs_sceneitem_set_visible( item, obs.obs_data_get_bool(request, "sceneItemEnabled") ) end local result = new_data() obs.obs_data_set_int(result, "sceneItemId", obs.obs_sceneitem_get_id(item)) obs.obs_source_release(scene_source) emit_simple_event("InputCreated") emit_simple_event("SceneItemCreated") return result end local function rpc_remove_scene_item(request) local scene_source, _, item = get_scene_item(request) obs.obs_sceneitem_remove(item) obs.obs_source_release(scene_source) emit_simple_event("SceneItemRemoved") return new_data() end local function rpc_set_scene_item_index(request) local scene_source, _, item = get_scene_item(request) obs.obs_sceneitem_set_order_position( item, obs.obs_data_get_int(request, "sceneItemIndex") ) obs.obs_source_release(scene_source) emit_simple_event("SceneItemListReindexed") return new_data() end local function rpc_set_scene_item_transform(request) local scene_source, _, item = get_scene_item(request) local transform = obs.obs_data_get_obj(request, "sceneItemTransform") if not transform then obs.obs_source_release(scene_source) fail("sceneItemTransform is required") end obs.obs_sceneitem_defer_update_begin(item) local position = obs.vec2() obs.obs_sceneitem_get_pos(item, position) if data_has(transform, "positionX") then position.x = obs.obs_data_get_double(transform, "positionX") end if data_has(transform, "positionY") then position.y = obs.obs_data_get_double(transform, "positionY") end obs.obs_sceneitem_set_pos(item, position) local scale = obs.vec2() obs.obs_sceneitem_get_scale(item, scale) if data_has(transform, "scaleX") then scale.x = obs.obs_data_get_double(transform, "scaleX") end if data_has(transform, "scaleY") then scale.y = obs.obs_data_get_double(transform, "scaleY") end obs.obs_sceneitem_set_scale(item, scale) if data_has(transform, "rotation") then obs.obs_sceneitem_set_rot( item, obs.obs_data_get_double(transform, "rotation") ) end if data_has(transform, "alignment") then obs.obs_sceneitem_set_alignment( item, obs.obs_data_get_int(transform, "alignment") ) end if data_has(transform, "boundsType") then local name = obs.obs_data_get_string(transform, "boundsType") obs.obs_sceneitem_set_bounds_type( item, bounds_types[name] or obs.OBS_BOUNDS_NONE ) end local bounds = obs.vec2() obs.obs_sceneitem_get_bounds(item, bounds) if data_has(transform, "boundsWidth") then bounds.x = obs.obs_data_get_double(transform, "boundsWidth") end if data_has(transform, "boundsHeight") then bounds.y = obs.obs_data_get_double(transform, "boundsHeight") end obs.obs_sceneitem_set_bounds(item, bounds) local crop = obs.obs_sceneitem_crop() obs.obs_sceneitem_get_crop(item, crop) if data_has(transform, "cropLeft") then crop.left = obs.obs_data_get_int(transform, "cropLeft") end if data_has(transform, "cropRight") then crop.right = obs.obs_data_get_int(transform, "cropRight") end if data_has(transform, "cropTop") then crop.top = obs.obs_data_get_int(transform, "cropTop") end if data_has(transform, "cropBottom") then crop.bottom = obs.obs_data_get_int(transform, "cropBottom") end obs.obs_sceneitem_set_crop(item, crop) obs.obs_sceneitem_defer_update_end(item) obs.obs_data_release(transform) obs.obs_source_release(scene_source) return new_data() end local function rpc_get_scene_item_enabled(request) local scene_source, _, item = get_scene_item(request) local result = new_data() obs.obs_data_set_bool( result, "sceneItemEnabled", obs.obs_sceneitem_visible(item) ) obs.obs_source_release(scene_source) return result end local function rpc_set_scene_item_enabled(request) local scene_source, _, item = get_scene_item(request) obs.obs_sceneitem_set_visible( item, obs.obs_data_get_bool(request, "sceneItemEnabled") ) obs.obs_source_release(scene_source) return new_data() end local function rpc_create_scene(request) local scene_name = required_string(request, "sceneName", "sceneName") if private_dsks.kind(scene_name) then local ensured, changed, ensure_detail = private_dsks.ensure(scene_name) if not ensured then fail(ensure_detail) end enforce_managed_dsks() managed_dsks_dirty = false if changed then emit_simple_event("SceneCreated") emit_simple_event("SceneListChanged") end local result = new_data() obs.obs_data_set_bool(result, "isInternal", true) return result end local existing_source = obs.obs_get_source_by_name(scene_name) if existing_source then local existing_scene = obs.obs_scene_from_source(existing_source) obs.obs_source_release(existing_source) if not existing_scene then fail("An OBS source already uses the scene name " .. scene_name) end local result = new_data() obs.obs_data_set_bool(result, "alreadyExists", true) return result end obs.obs_frontend_remove_event_callback(frontend_event) local created, scene = pcall(function() local result = nil if obs.obs_get_main_canvas and obs.obs_canvas_scene_create then local canvas = obs.obs_get_main_canvas() if canvas then result = obs.obs_canvas_scene_create(canvas, scene_name) obs.obs_canvas_release(canvas) end elseif obs.obs_scene_create then result = obs.obs_scene_create(scene_name) end return result end) obs.obs_frontend_add_event_callback(frontend_event) if not created then fail(scene) end if not scene then fail("OBS could not create the scene") end obs.obs_scene_release(scene) frontend_state.add_scene(scene_name) return new_data() end local function rpc_remove_scene(request) local scene_name = required_string(request, "sceneName", "sceneName") if private_dsks.release(scene_name) then enforce_managed_dsks() managed_dsks_dirty = false emit_simple_event("SceneRemoved") emit_simple_event("SceneListChanged") return new_data() end local source = get_source(scene_name, "OBS scene") if not obs.obs_scene_from_source(source) then obs.obs_source_release(source) fail("OBS scene was not found") end obs.obs_frontend_remove_event_callback(frontend_event) local removed, remove_error = pcall(obs.obs_source_remove, source) obs.obs_frontend_add_event_callback(frontend_event) obs.obs_source_release(source) if not removed then fail(remove_error) end frontend_state.remove_scene(scene_name) return new_data() end local function rpc_get_studio_mode_enabled() local result = new_data() obs.obs_data_set_bool(result, "studioModeEnabled", frontend_state.studio) return result end local function rpc_set_studio_mode_enabled(request) local requested = obs.obs_data_get_bool(request, "studioModeEnabled") if requested ~= frontend_state.studio then fail("Caller Connector does not change OBS Studio Mode") end return new_data() end local function rpc_get_current_preview_scene() local result = new_data() obs.obs_data_set_string( result, "currentPreviewSceneName", frontend_state.preview ) return result end local function rpc_set_current_preview_scene(request) local scene_name = required_string(request, "sceneName", "sceneName") if private_dsks.scenes[scene_name] then fail("Internal DSK scenes cannot become Preview") end if not native_ui.queue_source or not native_ui.queue_source( native_ui.set_preview_task, scene_name ) then fail("OBS scene was not found or Preview cannot be changed safely") end frontend_state.preview = scene_name return new_data() end local function rpc_get_current_program_scene() local result = new_data() obs.obs_data_set_string( result, "currentProgramSceneName", current_program_scene_name() ) return result end local function rpc_set_current_program_scene(request) local scene_name = required_string(request, "sceneName", "sceneName") if private_dsks.scenes[scene_name] then fail("Internal DSK scenes cannot become Program") end if not native_ui.queue_source or not native_ui.queue_source( native_ui.set_program_task, scene_name ) then fail("OBS scene was not found or Program cannot be changed safely") end frontend_state.program = scene_name return new_data() end local function get_filter(source, filter_name) local filter = obs.obs_source_get_filter_by_name(source, filter_name) if not filter then obs.obs_source_release(source) fail("OBS source filter was not found") end return filter end local function filter_data(filter, parent_source) local item = new_data() obs.obs_data_set_string( item, "filterName", obs.obs_source_get_name(filter) or "" ) obs.obs_data_set_string(item, "filterKind", source_kind(filter)) obs.obs_data_set_bool(item, "filterEnabled", obs.obs_source_enabled(filter)) obs.obs_data_set_int( item, "filterIndex", obs.obs_source_filter_get_index(parent_source, filter) ) local settings = obs.obs_source_get_settings(filter) set_data_object(item, "filterSettings", settings) obs.obs_data_release(settings) return item end local function rpc_get_source_filter_list(request) local source = get_source( required_string(request, "sourceName", "sourceName"), "OBS source" ) local result = new_data() local filters_data = obs.obs_data_array_create() local filters = obs.obs_source_enum_filters(source) or {} for _, filter in ipairs(filters) do local item = filter_data(filter, source) obs.obs_data_array_push_back(filters_data, item) obs.obs_data_release(item) end obs.source_list_release(filters) set_data_array(result, "filters", filters_data) obs.obs_data_array_release(filters_data) obs.obs_source_release(source) return result end local function rpc_get_source_filter(request) local source_name = required_string(request, "sourceName", "sourceName") local filter_name = required_string(request, "filterName", "filterName") local source = get_source( source_name, "OBS source" ) local filter = get_filter( source, filter_name ) local result = filter_data(filter, source) obs.obs_source_release(filter) obs.obs_source_release(source) return result end local function rpc_create_source_filter(request) local source_name = required_string(request, "sourceName", "sourceName") local filter_kind = required_string(request, "filterKind", "filterKind") local filter_name = required_string(request, "filterName", "filterName") local source = get_source( source_name, "OBS source" ) local settings = obs.obs_data_get_obj(request, "filterSettings") if not settings then settings = new_data() end local filter = obs.obs_source_create( filter_kind, filter_name, settings, nil ) obs.obs_data_release(settings) if not filter then obs.obs_source_release(source) fail("OBS could not create the source filter") end obs.obs_source_filter_add(source, filter) obs.obs_source_release(filter) obs.obs_source_release(source) emit_simple_event("SourceFilterCreated") return new_data() end local function rpc_set_source_filter_settings(request) local source_name = required_string(request, "sourceName", "sourceName") local filter_name = required_string(request, "filterName", "filterName") local source = get_source( source_name, "OBS source" ) local filter = get_filter( source, filter_name ) local settings = obs.obs_data_get_obj(request, "filterSettings") if not settings then obs.obs_source_release(filter) obs.obs_source_release(source) fail("filterSettings is required") end obs.obs_source_update(filter, settings) obs.obs_data_release(settings) obs.obs_source_release(filter) obs.obs_source_release(source) return new_data() end local function rpc_set_source_filter_enabled(request) local source_name = required_string(request, "sourceName", "sourceName") local filter_name = required_string(request, "filterName", "filterName") local source = get_source( source_name, "OBS source" ) local filter = get_filter( source, filter_name ) obs.obs_source_set_enabled( filter, obs.obs_data_get_bool(request, "filterEnabled") ) obs.obs_source_release(filter) obs.obs_source_release(source) return new_data() end local media_state_names = { [obs.OBS_MEDIA_STATE_NONE] = "OBS_MEDIA_STATE_NONE", [obs.OBS_MEDIA_STATE_PLAYING] = "OBS_MEDIA_STATE_PLAYING", [obs.OBS_MEDIA_STATE_OPENING] = "OBS_MEDIA_STATE_OPENING", [obs.OBS_MEDIA_STATE_BUFFERING] = "OBS_MEDIA_STATE_BUFFERING", [obs.OBS_MEDIA_STATE_PAUSED] = "OBS_MEDIA_STATE_PAUSED", [obs.OBS_MEDIA_STATE_STOPPED] = "OBS_MEDIA_STATE_STOPPED", [obs.OBS_MEDIA_STATE_ENDED] = "OBS_MEDIA_STATE_ENDED", [obs.OBS_MEDIA_STATE_ERROR] = "OBS_MEDIA_STATE_ERROR" } local function rpc_get_media_input_status(request) local source = get_source( required_string(request, "inputName", "inputName"), "OBS media input" ) local result = new_data() obs.obs_data_set_string( result, "mediaState", media_state_names[obs.obs_source_media_get_state(source)] or "OBS_MEDIA_STATE_NONE" ) obs.obs_data_set_int( result, "mediaDuration", obs.obs_source_media_get_duration(source) ) obs.obs_data_set_int( result, "mediaCursor", obs.obs_source_media_get_time(source) ) obs.obs_source_release(source) return result end local function rpc_set_media_input_cursor(request) local source = get_source( required_string(request, "inputName", "inputName"), "OBS media input" ) obs.obs_source_media_set_time( source, math.max(0, obs.obs_data_get_int(request, "mediaCursor")) ) obs.obs_source_release(source) return new_data() end local function rpc_trigger_media_input_action(request) local action = required_string(request, "mediaAction", "mediaAction") local source = get_source( required_string(request, "inputName", "inputName"), "OBS media input" ) if action == "OBS_WEBSOCKET_MEDIA_INPUT_ACTION_PLAY" then obs.obs_source_media_play_pause(source, false) elseif action == "OBS_WEBSOCKET_MEDIA_INPUT_ACTION_PAUSE" then obs.obs_source_media_play_pause(source, true) elseif action == "OBS_WEBSOCKET_MEDIA_INPUT_ACTION_STOP" then obs.obs_source_media_stop(source) elseif action == "OBS_WEBSOCKET_MEDIA_INPUT_ACTION_RESTART" then obs.obs_source_media_restart(source) elseif action == "OBS_WEBSOCKET_MEDIA_INPUT_ACTION_NEXT" then obs.obs_source_media_next(source) elseif action == "OBS_WEBSOCKET_MEDIA_INPUT_ACTION_PREVIOUS" then obs.obs_source_media_previous(source) else obs.obs_source_release(source) fail("Unsupported OBS media action") end obs.obs_source_release(source) return new_data() end local function command_from_data(request) local command = {} local keys = { "nonce", "action", "publishUrl", "bearerToken", "sessionId", "qualityId", "renditionCount", "videoEncoderId", "audioEncoderId", "videoSettings", "audioSettings", "width", "height", "fpsDivisor", "audioTrack", "videoSource", "sceneName", "maxDurationMinutes", "enabled", "callerAudioScene", "telestratorEnabled", "telestratorScene", "telestratorExcludedCount" } for _, key in ipairs(keys) do command[key] = obs.obs_data_get_string(request, key) end local rendition_count = math.max( 0, math.min(CONFIG.max_stream_renditions, tonumber(command.renditionCount) or 0) ) local rendition_fields = { "Id", "PublishUrl", "BearerToken", "Width", "Height", "FpsDivisor", "VideoSettings" } for index = 1, rendition_count do for _, field in ipairs(rendition_fields) do local key = "rendition" .. index .. field command[key] = obs.obs_data_get_string(request, key) end end local excluded_count = math.max( 0, tonumber(command.telestratorExcludedCount) or 0 ) for index = 1, excluded_count do local key = "telestratorExcluded" .. index command[key] = obs.obs_data_get_string(request, key) end return command end local function rpc_get_companion_status() return status_data() end local function rpc_send_companion_command(request) local command = command_from_data(request) if not apply_command(command) and command.action ~= "ping" then fail("Caller Companion ignored a duplicate or incomplete command") end write_status() return status_data() end local function rpc_configure_invite_stream_dsk(request) return invite_alpha_dsk.rpc_configure(request) end local rpc_handlers = { PollEvents = rpc_poll_events, GetVersion = rpc_get_version, TestVideoEncoder = rpc_test_video_encoder, GetSceneCollectionList = rpc_get_scene_collection_list, SetCurrentSceneCollection = rpc_set_current_scene_collection, GetPersistentData = rpc_get_persistent_data, SetPersistentData = rpc_set_persistent_data, GetVideoSettings = rpc_get_video_settings, GetInputList = rpc_get_input_list, GetSceneList = rpc_get_scene_list, SetInputName = rpc_set_input_name, SetSceneName = rpc_set_scene_name, GetInputSettings = rpc_get_input_settings, SetInputSettings = rpc_set_input_settings, PressInputPropertiesButton = rpc_press_input_properties_button, GetInputMute = rpc_get_input_mute, SetInputMute = rpc_set_input_mute, GetInputAudioTracks = rpc_get_input_audio_tracks, SetInputAudioTracks = rpc_set_input_audio_tracks, SetInputAudioMonitorType = rpc_set_input_audio_monitor_type, GetSceneItemList = rpc_get_scene_item_list, GetSceneItemId = rpc_get_scene_item_id, CreateSceneItem = rpc_create_scene_item, CreateInput = rpc_create_input, RemoveSceneItem = rpc_remove_scene_item, SetSceneItemIndex = rpc_set_scene_item_index, SetSceneItemTransform = rpc_set_scene_item_transform, GetSceneItemEnabled = rpc_get_scene_item_enabled, SetSceneItemEnabled = rpc_set_scene_item_enabled, CreateScene = rpc_create_scene, RemoveScene = rpc_remove_scene, GetStudioModeEnabled = rpc_get_studio_mode_enabled, SetStudioModeEnabled = rpc_set_studio_mode_enabled, GetCurrentPreviewScene = rpc_get_current_preview_scene, SetCurrentPreviewScene = rpc_set_current_preview_scene, GetCurrentProgramScene = rpc_get_current_program_scene, SetCurrentProgramScene = rpc_set_current_program_scene, GetSourceFilterList = rpc_get_source_filter_list, GetSourceFilter = rpc_get_source_filter, CreateSourceFilter = rpc_create_source_filter, SetSourceFilterSettings = rpc_set_source_filter_settings, SetSourceFilterEnabled = rpc_set_source_filter_enabled, GetMediaInputStatus = rpc_get_media_input_status, SetMediaInputCursor = rpc_set_media_input_cursor, TriggerMediaInputAction = rpc_trigger_media_input_action, ConfigureInviteStreamDsk = rpc_configure_invite_stream_dsk, GetCompanionStatus = rpc_get_companion_status, SendCompanionCommand = rpc_send_companion_command } local function execute_rpc(request_type, request_data) local handler = rpc_handlers[request_type] if not handler or request_type == "RequestBatch" then return false, nil, "UNSUPPORTED_REQUEST", "Unsupported request: " .. request_type end local ok, result = pcall(handler, request_data) if not ok then local message = tostring(result) message = string.match(message, "^.-:%d+:%s*(.*)$") or message local expected_missing_scene_item = request_type == "GetSceneItemId" and message == "OBS scene item was not found" if not expected_missing_scene_item then obs.script_log( obs.LOG_WARNING, "Caller Connector RPC " .. request_type .. " failed: " .. message ) end return false, nil, "OBS_REQUEST_FAILED", message end return true, result end local function rpc_request_batch(request) local requests = obs.obs_data_get_array(request, "requests") if not requests then fail("requests must be an array") end local count = obs.obs_data_array_count(requests) if count > 128 then obs.obs_data_array_release(requests) fail("A batch cannot contain more than 128 requests") end local halt_on_failure = obs.obs_data_get_bool(request, "haltOnFailure") local results = obs.obs_data_array_create() for index = 0, count - 1 do local batch_request = obs.obs_data_array_item(requests, index) local request_type = obs.obs_data_get_string(batch_request, "requestType") local request_data = obs.obs_data_get_obj(batch_request, "requestData") if not request_data then request_data = new_data() end local ok, response_data, error_code, message = execute_rpc( request_type, request_data ) local batch_result = new_data() local request_status = new_data() obs.obs_data_set_string(batch_result, "requestType", request_type) obs.obs_data_set_bool(request_status, "result", ok) obs.obs_data_set_int(request_status, "code", ok and 100 or 500) if ok then set_data_object(batch_result, "responseData", response_data) obs.obs_data_release(response_data) else obs.obs_data_set_string(request_status, "errorCode", error_code or "") obs.obs_data_set_string(request_status, "comment", message or "Request failed") end set_data_object(batch_result, "requestStatus", request_status) obs.obs_data_array_push_back(results, batch_result) obs.obs_data_release(request_status) obs.obs_data_release(batch_result) obs.obs_data_release(request_data) obs.obs_data_release(batch_request) if halt_on_failure and not ok then break end end obs.obs_data_array_release(requests) local response = new_data() set_data_array(response, "results", results) obs.obs_data_array_release(results) return response end rpc_handlers.RequestBatch = rpc_request_batch local function rpc_response(body) local request_root = obs.obs_data_create_from_json(body or "") if not request_root then return false, "INVALID_JSON", "The RPC request body is not valid JSON." end local request_type = obs.obs_data_get_string(request_root, "requestType") local request_data = obs.obs_data_get_obj(request_root, "requestData") if not request_data then request_data = new_data() end local ok, result, error_code, message if request_type == "RequestBatch" then ok, result = pcall(rpc_request_batch, request_data) if not ok then message = tostring(result) message = string.match(message, "^.-:%d+:%s*(.*)$") or message error_code = "OBS_REQUEST_FAILED" end else ok, result, error_code, message = execute_rpc(request_type, request_data) end obs.obs_data_release(request_data) obs.obs_data_release(request_root) if not ok then return false, error_code or "OBS_REQUEST_FAILED", message or tostring(result) end local envelope = new_data() obs.obs_data_set_bool(envelope, "ok", true) set_data_object(envelope, "data", result) obs.obs_data_release(result) local json = obs.obs_data_get_json(envelope) or "{\"ok\":false}" obs.obs_data_release(envelope) return true, json end local ffi_ok, ffi = pcall(require, "ffi") local socket_state = { api = nil, server = nil, clients = {}, is_windows = false, send_flags = 0, initialized = false, last_failure = "" } local SOCKET_LIMITS = { read_size = 8192, max_request = 1024 * 1024, max_clients = 32 } local function json_error(code, message) local envelope = new_data() obs.obs_data_set_bool(envelope, "ok", false) obs.obs_data_set_string(envelope, "code", code or "CALLER_CONNECTOR_ERROR") obs.obs_data_set_string(envelope, "error", message or "Caller Connector error") local json = obs.obs_data_get_json(envelope) or "{\"ok\":false,\"error\":\"Caller Connector error\"}" obs.obs_data_release(envelope) return json end local function health_json() local health = new_data() obs.obs_data_set_string(health, "protocol", CONNECTOR_PROTOCOL.local_rpc) obs.obs_data_set_string(health, "version", CONNECTOR_PROTOCOL.version) obs.obs_data_set_string(health, "connectorType", "guest") obs.obs_data_set_string(health, "instanceId", connector_instance_id) obs.obs_data_set_int(health, "port", connector_port) obs.obs_data_set_string(health, "sceneCollection", current_collection()) local json = obs.obs_data_get_json(health) or "{}" obs.obs_data_release(health) return json end local function allowed_origin(origin) if origin == "https://call.joeprod.com" or origin == "https://call2.joeprod.com" then return true end if origin == "http://localhost" or origin == "https://localhost" or origin == "http://127.0.0.1" or origin == "https://127.0.0.1" then return true end if string.match(origin or "", "^http://localhost:%d+$") or string.match(origin or "", "^https://localhost:%d+$") or string.match(origin or "", "^http://127%.0%.0%.1:%d+$") or string.match(origin or "", "^https://127%.0%.0%.1:%d+$") then return true end return false end local function response_headers(status, body, origin, extra) local lines = { "HTTP/1.1 " .. status, "Content-Type: application/json; charset=utf-8", "Content-Length: " .. tostring(#body), "Cache-Control: no-store", "Connection: close" } if origin and origin ~= "" then table.insert(lines, "Access-Control-Allow-Origin: " .. origin) table.insert(lines, "Vary: Origin") table.insert(lines, "Access-Control-Allow-Private-Network: true") end for _, header in ipairs(extra or {}) do table.insert(lines, header) end table.insert(lines, "") table.insert(lines, body) return table.concat(lines, "\r\n") end local function parse_http_request(raw) local header_end = string.find(raw, "\r\n\r\n", 1, true) if not header_end then return nil end local header_text = string.sub(raw, 1, header_end - 1) local request_line_end = string.find(header_text, "\r\n", 1, true) local request_line = request_line_end and string.sub(header_text, 1, request_line_end - 1) or header_text local method, path = string.match(request_line, "^(%u+)%s+([^%s]+)%s+HTTP/") if not method or not path then return false, "Malformed HTTP request" end local headers = {} for line in string.gmatch(header_text .. "\r\n", "([^\r\n]+)\r\n") do local key, value = string.match(line, "^([^:]+):%s*(.*)$") if key then headers[string.lower(key)] = value end end local content_length = tonumber(headers["content-length"] or "0") or 0 if content_length < 0 or content_length > SOCKET_LIMITS.max_request then return false, "Request body is too large" end local body_start = header_end + 4 if #raw - body_start + 1 < content_length then return nil end return { method = method, path = path, headers = headers, body = string.sub(raw, body_start, body_start + content_length - 1) } end local function pending_event_poll(request) if request.method ~= "POST" or request.path ~= "/rpc" then return nil end local origin = request.headers.origin or "" if not allowed_origin(origin) then return nil end local client_header = request.headers["x-caller-connector"] if client_header ~= nil and client_header ~= "1" then return nil end local root = obs.obs_data_create_from_json(request.body) if not root then return nil end local request_type = obs.obs_data_get_string(root, "requestType") local request_data = obs.obs_data_get_obj(root, "requestData") local after = 0 local wait_ms = 0 if request_data then after = obs.obs_data_get_int(request_data, "after") wait_ms = obs.obs_data_get_int(request_data, "waitMs") obs.obs_data_release(request_data) end obs.obs_data_release(root) if request_type ~= "PollEvents" or wait_ms <= 0 or event_cursor > after then return nil end local wait_seconds = math.max(1, math.min(5, math.ceil(wait_ms / 1000))) return { request = request, after = after, expires_at = os.time() + wait_seconds } end local function handle_http_request(request) local origin = request.headers.origin or "" if not allowed_origin(origin) then local body = json_error("ORIGIN_NOT_ALLOWED", "This page cannot use Caller Connector.") return response_headers("403 Forbidden", body, nil) end if request.method == "OPTIONS" then return response_headers("204 No Content", "", origin, { "Access-Control-Allow-Methods: GET, POST, OPTIONS", "Access-Control-Allow-Headers: Content-Type, X-Caller-Connector", "Access-Control-Max-Age: 7200" }) end if request.method == "GET" and request.path == "/health" then return response_headers("200 OK", health_json(), origin) end if request.method ~= "POST" or request.path ~= "/rpc" then local body = json_error("NOT_FOUND", "Local connector endpoint not found.") return response_headers("404 Not Found", body, origin) end local client_header = request.headers["x-caller-connector"] if client_header ~= nil and client_header ~= "1" then local body = json_error("INVALID_CLIENT", "Caller Connector header is invalid.") return response_headers("403 Forbidden", body, origin) end local ok, value, message = rpc_response(request.body) local body = ok and value or json_error(value, message) return response_headers(ok and "200 OK" or "400 Bad Request", body, origin) end local function setup_socket_api() if not ffi_ok then connector_detail = "LuaJIT FFI is unavailable in this OBS build" return false end if not native_ui.initialized then native_ui.initialized = true local loaded, load_error = pcall(function() ffi.cdef[[ typedef void (*caller_obs_task_t)(void *param); void obs_queue_task( int task_type, caller_obs_task_t task, void *param, bool wait ); void *obs_get_source_by_name(const char *name); void obs_source_release(void *source); void obs_frontend_set_current_scene(void *source); void obs_frontend_set_current_preview_scene(void *source); ]] local obs_library_names = { "obs" } local frontend_library_names = { "obs-frontend-api" } if ffi.os == "OSX" then ffi.cdef[[ int _NSGetExecutablePath(char *buffer, uint32_t *buffer_size); ]] local executable_buffer = ffi.new("char[4096]") local executable_size = ffi.new("uint32_t[1]", 4096) local contents_directory = nil if ffi.C._NSGetExecutablePath( executable_buffer, executable_size ) == 0 then local executable_path = ffi.string(executable_buffer) contents_directory = string.match( executable_path, "^(.*)/MacOS/[^/]+$" ) end contents_directory = contents_directory or "/Applications/OBS.app/Contents" local frameworks_directory = contents_directory .. "/Frameworks" obs_library_names = { frameworks_directory .. "/libobs.framework/libobs", frameworks_directory .. "/libobs.framework/Versions/A/libobs" } frontend_library_names = { frameworks_directory .. "/obs-frontend-api.dylib", frameworks_directory .. "/libobs-frontend-api.1.dylib" } end local function load_native_library(names) local errors = {} for _, name in ipairs(names) do local ok, library = pcall(ffi.load, name) if ok then return library end table.insert(errors, tostring(library)) end fail(table.concat(errors, " | ")) end native_ui.obs = load_native_library(obs_library_names) native_ui.frontend = load_native_library(frontend_library_names) native_ui.release_source_task = ffi.cast( "caller_obs_task_t", native_ui.obs.obs_source_release ) native_ui.set_program_task = ffi.cast( "caller_obs_task_t", native_ui.frontend.obs_frontend_set_current_scene ) native_ui.set_preview_task = ffi.cast( "caller_obs_task_t", native_ui.frontend.obs_frontend_set_current_preview_scene ) native_ui.ready = true end) if not loaded then native_ui.error = tostring(load_error) end native_ui.queue_source = function(task, source_name) if not native_ui.ready then return false end local source = native_ui.obs.obs_get_source_by_name(source_name) if source == nil then return false end native_ui.obs.obs_queue_task(0, task, source, false) native_ui.obs.obs_queue_task( 0, native_ui.release_source_task, source, false ) return true end if not native_ui.ready then obs.script_log( obs.LOG_WARNING, "Caller Connector: native UI bridge unavailable: " .. native_ui.error ) end end socket_state.is_windows = ffi.os == "Windows" if socket_state.is_windows then ffi.cdef[[ typedef uintptr_t SOCKET; struct in_addr { uint32_t s_addr; }; struct sockaddr { uint16_t sa_family; char sa_data[14]; }; struct sockaddr_in { int16_t sin_family; uint16_t sin_port; struct in_addr sin_addr; char sin_zero[8]; }; int WSAStartup(uint16_t version, void *data); int WSACleanup(void); int WSAGetLastError(void); SOCKET socket(int af, int type, int protocol); int bind(SOCKET socket, const struct sockaddr *name, int name_length); int listen(SOCKET socket, int backlog); SOCKET accept(SOCKET socket, struct sockaddr *address, int *address_length); int recv(SOCKET socket, char *buffer, int length, int flags); int send(SOCKET socket, const char *buffer, int length, int flags); int closesocket(SOCKET socket); int ioctlsocket(SOCKET socket, uint32_t command, unsigned long *argument); uint16_t htons(uint16_t value); uint32_t htonl(uint32_t value); uint32_t inet_addr(const char *address); ]] local loaded, library = pcall(ffi.load, "Ws2_32") if not loaded then connector_detail = "Windows socket library could not be loaded" return false end socket_state.api = library local startup_data = ffi.new("uint64_t[64]") local startup_error = socket_state.api.WSAStartup(0x0202, startup_data) if startup_error ~= 0 then connector_detail = "Windows sockets could not be initialized (error " .. tostring(startup_error) .. ")" return false end socket_state.initialized = true return true end if ffi.os == "OSX" then ffi.cdef[[ struct in_addr { uint32_t s_addr; }; struct sockaddr { uint8_t sa_len; uint8_t sa_family; char sa_data[14]; }; struct sockaddr_in { uint8_t sin_len; uint8_t sin_family; uint16_t sin_port; struct in_addr sin_addr; char sin_zero[8]; }; int socket(int domain, int type, int protocol); int bind(int socket, const struct sockaddr *address, uint32_t address_len); int listen(int socket, int backlog); int accept(int socket, struct sockaddr *address, uint32_t *address_len); long recv(int socket, void *buffer, unsigned long length, int flags); long send(int socket, const void *buffer, unsigned long length, int flags); int close(int file_descriptor); int fcntl(int file_descriptor, int command, ...); int setsockopt(int socket, int level, int option, const void *value, uint32_t length); uint16_t htons(uint16_t value); uint32_t htonl(uint32_t value); uint32_t inet_addr(const char *address); ]] else ffi.cdef[[ struct in_addr { uint32_t s_addr; }; struct sockaddr { uint16_t sa_family; char sa_data[14]; }; struct sockaddr_in { uint16_t sin_family; uint16_t sin_port; struct in_addr sin_addr; char sin_zero[8]; }; int socket(int domain, int type, int protocol); int bind(int socket, const struct sockaddr *address, uint32_t address_len); int listen(int socket, int backlog); int accept(int socket, struct sockaddr *address, uint32_t *address_len); long recv(int socket, void *buffer, unsigned long length, int flags); long send(int socket, const void *buffer, unsigned long length, int flags); int close(int file_descriptor); int fcntl(int file_descriptor, int command, ...); int setsockopt(int socket, int level, int option, const void *value, uint32_t length); uint16_t htons(uint16_t value); uint32_t htonl(uint32_t value); uint32_t inet_addr(const char *address); ]] socket_state.send_flags = 0x4000 end socket_state.api = ffi.C socket_state.initialized = true return true end local function invalid_socket(socket) if socket_state.is_windows then return socket == ffi.cast("SOCKET", -1) end return socket == nil or socket < 0 end local function socket_error() if socket_state.is_windows then return socket_state.api.WSAGetLastError() end return ffi.errno() end local function socket_would_block(error_code) if socket_state.is_windows then return error_code == 10035 end return error_code == 11 or error_code == 35 end local function close_socket(socket) if socket == nil or invalid_socket(socket) then return end if socket_state.is_windows then socket_state.api.closesocket(socket) else socket_state.api.close(socket) end end local function set_socket_nonblocking(socket) if socket_state.is_windows then local enabled = ffi.new("unsigned long[1]", 1) local command = ffi.cast("uint32_t", 0x8004667E) local result = socket_state.api.ioctlsocket(socket, command, enabled) return result == 0, result == 0 and 0 or socket_error() end local get_flags = 3 local set_flags = 4 local nonblocking = ffi.os == "OSX" and 4 or 2048 local flags = socket_state.api.fcntl( socket, get_flags, ffi.cast("int", 0) ) local result = flags >= 0 and socket_state.api.fcntl( socket, set_flags, ffi.cast("int", bitlib.bor(flags, nonblocking)) ) or -1 return result == 0, result == 0 and 0 or socket_error() end local function create_listening_socket(port) local server = socket_state.api.socket(2, 1, 0) if invalid_socket(server) then socket_state.last_failure = "socket() failed (error " .. tostring(socket_error()) .. ")" return nil end if not socket_state.is_windows then local enabled = ffi.new("int[1]", 1) socket_state.api.setsockopt(server, 1, 2, enabled, ffi.sizeof(enabled)) if ffi.os == "OSX" then socket_state.api.setsockopt( server, 0xffff, 0x1022, enabled, ffi.sizeof(enabled) ) end end local address = ffi.new("struct sockaddr_in[1]") if ffi.os == "OSX" then address[0].sin_len = ffi.sizeof(address[0]) end address[0].sin_family = 2 address[0].sin_port = socket_state.api.htons(port) address[0].sin_addr.s_addr = socket_state.api.inet_addr("127.0.0.1") local bound = socket_state.api.bind( server, ffi.cast("const struct sockaddr *", address), ffi.sizeof(address[0]) ) if bound ~= 0 then socket_state.last_failure = "bind(127.0.0.1:" .. tostring(port) .. ") failed (error " .. tostring(socket_error()) .. ")" close_socket(server) return nil end if socket_state.api.listen(server, 16) ~= 0 then socket_state.last_failure = "listen(127.0.0.1:" .. tostring(port) .. ") failed (error " .. tostring(socket_error()) .. ")" close_socket(server) return nil end local nonblocking, nonblocking_error = set_socket_nonblocking(server) if not nonblocking then socket_state.last_failure = "nonblocking setup failed (error " .. tostring(nonblocking_error) .. ")" close_socket(server) return nil end return server end local function stop_local_connector() for _, client in ipairs(socket_state.clients) do close_socket(client.socket) end socket_state.clients = {} close_socket(socket_state.server) socket_state.server = nil connector_port = 0 if socket_state.is_windows and socket_state.initialized and socket_state.api then socket_state.api.WSACleanup() end socket_state.initialized = false end local function start_local_connector() socket_state.last_failure = "" if not setup_socket_api() then obs.script_log(obs.LOG_ERROR, "Caller Connector: " .. connector_detail) return false end for port = CONFIG.first_local_port, CONFIG.last_local_port do local server = create_listening_socket(port) if server then socket_state.server = server connector_port = port connector_detail = "Listening on 127.0.0.1:" .. port obs.script_log(obs.LOG_INFO, "Caller Connector: " .. connector_detail) return true end end connector_detail = "No local connector port is available" if socket_state.last_failure ~= "" then connector_detail = connector_detail .. ": " .. socket_state.last_failure end obs.script_log(obs.LOG_ERROR, "Caller Connector: " .. connector_detail) stop_local_connector() return false end local function accept_clients() if not socket_state.server then return end for _ = 1, 8 do if #socket_state.clients >= SOCKET_LIMITS.max_clients then return end local socket = socket_state.api.accept(socket_state.server, nil, nil) if invalid_socket(socket) then if not socket_would_block(socket_error()) then connector_detail = "Local connector accept failed" end return end set_socket_nonblocking(socket) table.insert(socket_state.clients, { socket = socket, input = "", output = nil, output_offset = 1, opened_at = os.time() }) end end local function set_client_response(client, request) local ok, response = pcall(handle_http_request, request) client.output = ok and response or response_headers( "500 Internal Server Error", json_error("LOCAL_CONNECTOR_ERROR", tostring(response)), request.headers.origin ) end local function service_client(client) if client.pending_poll then if event_cursor <= client.pending_poll.after and os.time() < client.pending_poll.expires_at then return true end local request = client.pending_poll.request client.pending_poll = nil set_client_response(client, request) end if not client.output then local buffer = ffi.new("char[?]", SOCKET_LIMITS.read_size) for _ = 1, 8 do local received = socket_state.api.recv( client.socket, buffer, SOCKET_LIMITS.read_size, 0 ) if received > 0 then client.input = client.input .. ffi.string(buffer, received) if #client.input > SOCKET_LIMITS.max_request + 16384 then client.output = response_headers( "413 Payload Too Large", json_error("REQUEST_TOO_LARGE", "Local request is too large."), nil ) break end local request, parse_error = parse_http_request(client.input) if request == false then client.output = response_headers( "400 Bad Request", json_error("INVALID_HTTP", parse_error), nil ) break elseif request then client.pending_poll = pending_event_poll(request) if not client.pending_poll then set_client_response(client, request) end break end elseif received == 0 then return false else if not socket_would_block(socket_error()) then return false end break end end end if client.output then local remaining = #client.output - client.output_offset + 1 if remaining <= 0 then return false end local sent = socket_state.api.send( client.socket, string.sub(client.output, client.output_offset), remaining, socket_state.send_flags ) if sent > 0 then client.output_offset = client.output_offset + sent if client.output_offset > #client.output then return false end elseif sent < 0 and not socket_would_block(socket_error()) then return false end end return os.time() - client.opened_at <= 8 end local function connector_tick() if not socket_state.server then return end accept_clients() for index = #socket_state.clients, 1, -1 do local client = socket_state.clients[index] if not service_client(client) then close_socket(client.socket) table.remove(socket_state.clients, index) end end end local global_signal_handler = nil local observed_scene_handlers = {} local function scene_item_added() if shutting_down then return end emit_simple_event("SceneItemCreated") end local function scene_item_removed() if shutting_down then return end emit_simple_event("SceneItemRemoved") end local function scene_items_reordered() if shutting_down then return end emit_simple_event("SceneItemListReindexed") end local function scene_item_visibility_changed() if shutting_down then return end emit_simple_event("SceneItemEnableStateChanged") end local function scene_handler_key(source) if not source then return nil end local uuid = obs.obs_source_get_uuid and (obs.obs_source_get_uuid(source) or "") or "" if uuid ~= "" then return "uuid:" .. uuid end local name = obs.obs_source_get_name(source) or "" return name ~= "" and "name:" .. name or nil end local function detach_scene_signals(source) if not source then return end local key = scene_handler_key(source) if not key then return end local handler = observed_scene_handlers[key] if not handler then return end obs.signal_handler_disconnect(handler, "item_add", scene_item_added) obs.signal_handler_disconnect(handler, "item_remove", scene_item_removed) obs.signal_handler_disconnect(handler, "reorder", scene_items_reordered) obs.signal_handler_disconnect( handler, "item_visible", scene_item_visibility_changed ) observed_scene_handlers[key] = nil end local function attach_scene_signals(source) if not source or obs.obs_source_get_type(source) ~= obs.OBS_SOURCE_TYPE_SCENE or source_kind(source) == "group" then return end local key = scene_handler_key(source) if not key then return end if observed_scene_handlers[key] then return end local handler = obs.obs_source_get_signal_handler(source) if not handler then return end obs.signal_handler_connect(handler, "item_add", scene_item_added) obs.signal_handler_connect(handler, "item_remove", scene_item_removed) obs.signal_handler_connect(handler, "reorder", scene_items_reordered) obs.signal_handler_connect( handler, "item_visible", scene_item_visibility_changed ) observed_scene_handlers[key] = handler end local function attach_all_scene_signals() for _, scene_name in ipairs(frontend_state.scenes) do local source = obs.obs_get_source_by_name(scene_name) if source then attach_scene_signals(source) obs.obs_source_release(source) end end end local function detach_all_scene_signals() for _, handler in pairs(observed_scene_handlers) do obs.signal_handler_disconnect(handler, "item_add", scene_item_added) obs.signal_handler_disconnect(handler, "item_remove", scene_item_removed) obs.signal_handler_disconnect(handler, "reorder", scene_items_reordered) obs.signal_handler_disconnect( handler, "item_visible", scene_item_visibility_changed ) end observed_scene_handlers = {} end local function global_source_created(calldata) if shutting_down then return end local source = obs.calldata_source(calldata, "source") if not source then return end local source_type = obs.obs_source_get_type(source) if source_type == obs.OBS_SOURCE_TYPE_SCENE and source_kind(source) ~= "group" then attach_scene_signals(source) frontend_state.add_scene(obs.obs_source_get_name(source) or "") emit_simple_event("SceneCreated") emit_simple_event("SceneListChanged") elseif source_type == obs.OBS_SOURCE_TYPE_INPUT then emit_simple_event("InputCreated") end end local function global_source_removed(calldata) local source = obs.calldata_source(calldata, "source") if not source then return end invite_alpha_dsk.mark_source_stale(source) if obs.obs_source_get_type(source) == obs.OBS_SOURCE_TYPE_SCENE and source_kind(source) ~= "group" then -- source_remove is the last guaranteed point at which the scene's -- signal handler is still live. Do not leave it for script_unload. detach_scene_signals(source) end end local function global_source_destroyed(calldata) local source = obs.calldata_source(calldata, "source") if not source then return end invite_alpha_dsk.mark_source_stale(source) local source_type = obs.obs_source_get_type(source) if source_type == obs.OBS_SOURCE_TYPE_SCENE and source_kind(source) ~= "group" then detach_scene_signals(source) frontend_state.remove_scene(obs.obs_source_get_name(source) or "") emit_simple_event("SceneRemoved") emit_simple_event("SceneListChanged") elseif source_type == obs.OBS_SOURCE_TYPE_INPUT then emit_simple_event("InputRemoved") end end local function global_source_renamed(calldata) if shutting_down then return end local source = obs.calldata_source(calldata, "source") if not source then return end if obs.obs_source_get_type(source) == obs.OBS_SOURCE_TYPE_SCENE and source_kind(source) ~= "group" then frontend_state.rename_scene( obs.calldata_string(calldata, "prev_name") or "", obs.obs_source_get_name(source) or "" ) emit_simple_event("SceneNameChanged") emit_simple_event("SceneListChanged") elseif obs.obs_source_get_type(source) == obs.OBS_SOURCE_TYPE_INPUT then emit_simple_event("InputNameChanged") end end local function connect_obs_signals() global_signal_handler = obs.obs_get_signal_handler() if global_signal_handler then obs.signal_handler_connect( global_signal_handler, "source_create", global_source_created ) obs.signal_handler_connect( global_signal_handler, "source_remove", global_source_removed ) obs.signal_handler_connect( global_signal_handler, "source_destroy", global_source_destroyed ) obs.signal_handler_connect( global_signal_handler, "source_rename", global_source_renamed ) end attach_all_scene_signals() end local function disconnect_obs_signals() if global_signal_handler then obs.signal_handler_disconnect( global_signal_handler, "source_create", global_source_created ) obs.signal_handler_disconnect( global_signal_handler, "source_remove", global_source_removed ) obs.signal_handler_disconnect( global_signal_handler, "source_destroy", global_source_destroyed ) obs.signal_handler_disconnect( global_signal_handler, "source_rename", global_source_renamed ) global_signal_handler = nil end detach_all_scene_signals() end function frontend_event(event) if shutting_down then return end if event == obs.OBS_FRONTEND_EVENT_SCENE_COLLECTION_CHANGING then detach_all_scene_signals() global_audio_enabled = false global_audio_scene = "" telestrator_enabled = false telestrator_excluded_scenes = {} clear_managed_dsks() private_dsks.release_all() invite_alpha_dsk.release_all() private_dsks.state = "idle" private_dsks.detail = "Managed downstream keys are idle" elseif event == obs.OBS_FRONTEND_EVENT_SCENE_CHANGED or event == obs.OBS_FRONTEND_EVENT_SCENE_COLLECTION_CHANGED or event == obs.OBS_FRONTEND_EVENT_FINISHED_LOADING then if event == obs.OBS_FRONTEND_EVENT_SCENE_COLLECTION_CHANGED then frontend_state.refresh_all() attach_all_scene_signals() emit_simple_event( "CurrentSceneCollectionChanged", "sceneCollectionName", current_collection() ) elseif event == obs.OBS_FRONTEND_EVENT_SCENE_CHANGED then frontend_state.refresh_program() emit_simple_event( "CurrentProgramSceneChanged", "sceneName", current_program_scene_name() ) elseif event == obs.OBS_FRONTEND_EVENT_FINISHED_LOADING then frontend_state.refresh_all() emit_simple_event("SceneListChanged") end enforce_managed_dsks() managed_dsks_dirty = false elseif obs.OBS_FRONTEND_EVENT_PREVIEW_SCENE_CHANGED and event == obs.OBS_FRONTEND_EVENT_PREVIEW_SCENE_CHANGED then frontend_state.refresh_preview() emit_simple_event( "CurrentPreviewSceneChanged", "sceneName", frontend_state.preview ) elseif obs.OBS_FRONTEND_EVENT_STUDIO_MODE_ENABLED and event == obs.OBS_FRONTEND_EVENT_STUDIO_MODE_ENABLED then frontend_state.studio = true frontend_state.refresh_preview() emit_simple_event("StudioModeStateChanged") elseif obs.OBS_FRONTEND_EVENT_STUDIO_MODE_DISABLED and event == obs.OBS_FRONTEND_EVENT_STUDIO_MODE_DISABLED then frontend_state.studio = false frontend_state.preview = frontend_state.program emit_simple_event("StudioModeStateChanged") elseif obs.OBS_FRONTEND_EVENT_SCENE_LIST_CHANGED and event == obs.OBS_FRONTEND_EVENT_SCENE_LIST_CHANGED then frontend_state.refresh_scenes() emit_simple_event("SceneListChanged") elseif obs.OBS_FRONTEND_EVENT_SCENE_COLLECTION_LIST_CHANGED and event == obs.OBS_FRONTEND_EVENT_SCENE_COLLECTION_LIST_CHANGED then frontend_state.refresh_collections() emit_simple_event("SceneCollectionListChanged") end end local function tick() if shutting_down then return end local previous_state = state local previous_detail = detail local previous_p2p_state = p2p_state local previous_p2p_detail = p2p_detail invite_alpha_dsk.cleanup_stale() local now = os.time() for index = #encoder_probe_releases, 1, -1 do local probe = encoder_probe_releases[index] if now >= probe.release_after and not obs.obs_output_active(probe.output) then obs.obs_output_release(probe.output) obs.obs_encoder_release(probe.video_encoder) obs.obs_encoder_release(probe.audio_encoder) table.remove(encoder_probe_releases, index) end end if stream_view_state.scene_name ~= "" then local scene_source = obs.obs_get_source_by_name(stream_view_state.scene_name) if scene_source then obs.obs_source_release(scene_source) else local missing_scene_name = stream_view_state.scene_name stop_stream("OBS scene was deleted: " .. missing_scene_name) end end if output then if last_host_heartbeat_at > 0 and now - last_host_heartbeat_at > CONFIG.host_heartbeat_timeout_seconds then stop_stream("Host browser disconnected from OBS") else local all_active = #relay_outputs > 0 local total = 0 local stopped_message = "" for _, relay in ipairs(relay_outputs) do if relay.output and obs.obs_output_active(relay.output) then total = total + (obs.obs_output_get_total_bytes(relay.output) or 0) else all_active = false if relay.output then stopped_message = obs.obs_output_get_last_error(relay.output) or "" end break end end if all_active then if last_sample_time > 0 and now > last_sample_time and total >= last_total_bytes then bitrate_kbps = math.floor(((total - last_total_bytes) * 8) / ((now - last_sample_time) * 1000) + 0.5) end last_total_bytes = total last_sample_time = now state = total > 0 and "ready" or "starting" detail = total > 0 and "Stream is ready" or "Waiting for media" if max_duration > 0 and started_at > 0 and now - started_at >= max_duration then stop_stream("Maximum duration reached") end elseif state ~= "error" then if state == "starting" and started_at > 0 and now - started_at < 15 then detail = "Connecting to CallerView Server" else local failure_detail = stopped_message ~= "" and stopped_message or "A WHIP rendition output stopped" stop_stream(failure_detail) state = "error" detail = failure_detail end end end end for _, session in pairs(p2p_sessions) do if session.output and obs.obs_output_active(session.output) then local total = obs.obs_output_get_total_bytes(session.output) session.state = total > 0 and "ready" or "starting" session.detail = total > 0 and "Direct P2P viewer is receiving the feed" or "Waiting for direct P2P media" elseif session.state ~= "error" then if session.started_at > 0 and now - session.started_at < 15 then session.state = "starting" session.detail = "Negotiating directly with the viewer" else local message_text = session.output and (obs.obs_output_get_last_error(session.output) or "") or "" session.state = "error" session.detail = message_text ~= "" and message_text or "Direct P2P WHIP output stopped" p2p_last_error = session.detail end end end update_p2p_summary() if not managed_dsks_dirty and invite_alpha_dsk.output_assignments_need_repair() then managed_dsks_dirty = true end if managed_dsks_dirty then enforce_managed_dsks() managed_dsks_dirty = false end if state ~= previous_state or detail ~= previous_detail or p2p_state ~= previous_p2p_state or p2p_detail ~= previous_p2p_detail or output or next(p2p_sessions) then write_status() end end function script_description() return "Caller Companion for OBS: provides a loopback-only site connection, automates OBS scenes and sources, creates hosted and optional direct P2P WHIP delivery, and runs managed downstream keys. OBS WebSocket is not required." end function script_load(settings) shutting_down = false obs.obs_data_addref(settings) script_settings = settings connector_instance_id = obs.obs_data_get_string( settings, "connector.instanceId" ) or "" if connector_instance_id == "" then connector_instance_id = string.format( "%x-%s", os.time(), string.gsub(tostring({}), "[^A-Fa-f0-9]", "") ) obs.obs_data_set_string( settings, "connector.instanceId", connector_instance_id ) end collect_diagnostics() frontend_state.refresh_all() obs.obs_frontend_add_event_callback(frontend_event) private_dsks.frontend_callback_registered = true connect_obs_signals() start_local_connector() write_status() obs.timer_add(tick, 1000) obs.timer_add(connector_tick, 20) end function script_unload() shutting_down = true obs.timer_remove(tick) obs.timer_remove(connector_tick) obs.obs_frontend_remove_event_callback(frontend_event) private_dsks.frontend_callback_registered = false disconnect_obs_signals() stop_local_connector() clear_managed_dsks() private_dsks.release_all() invite_alpha_dsk.restore_all() release_output() if script_settings then obs.obs_data_release(script_settings) script_settings = nil end end obs.obs_register_source(invite_alpha_dsk.source_def)