├── Makefile ├── .luacheckrc ├── refresh-browser-sources.lua ├── README.md ├── auto-execute-commands.lua ├── source-one-of-many.lua ├── production-information.lua ├── clone-template-scene.lua ├── enforce-current-scenes.lua └── keyboard-event-filter.lua /Makefile: -------------------------------------------------------------------------------- 1 | 2 | all: 3 | @luacheck *.lua 4 | 5 | -------------------------------------------------------------------------------- /.luacheckrc: -------------------------------------------------------------------------------- 1 | 2 | std = { 3 | read_globals = { 4 | "os", 5 | "require", 6 | "string", 7 | "table", 8 | "obslua", 9 | "ipairs", 10 | "tonumber", 11 | "pairs", 12 | "math" 13 | }, 14 | globals = { 15 | "script_description", 16 | "script_properties", 17 | "script_defaults", 18 | "script_update", 19 | "script_load", 20 | "script_unload", 21 | "script_save", 22 | "script_tick" 23 | } 24 | } 25 | 26 | unused_args = false 27 | 28 | -------------------------------------------------------------------------------- /refresh-browser-sources.lua: -------------------------------------------------------------------------------- 1 | --[[ 2 | ** 3 | ** refresh-browser-sources.lua -- OBS Studio Lua Script for Refreshing Browser Sources 4 | ** Copyright (c) 2021-2022 Dr. Ralf S. Engelschall 5 | ** Distributed under MIT license 6 | ** 7 | --]] 8 | 9 | -- global OBS API 10 | local obs = obslua 11 | 12 | -- global context information 13 | local ctx = { 14 | hotkey = obs.OBS_INVALID_HOTKEY_ID 15 | } 16 | 17 | -- helper function: refresh all browser sources 18 | local function refreshBrowsers () 19 | local sources = obs.obs_enum_sources() 20 | if sources ~= nil then 21 | for _, source in ipairs(sources) do 22 | local source_id = obs.obs_source_get_unversioned_id(source) 23 | if source_id == "browser_source" then 24 | -- trigger the refresh functionality through its "RefreshNoCache" button property 25 | local properties = obs.obs_source_properties(source) 26 | local property = obs.obs_properties_get(properties, "refreshnocache") 27 | obs.obs_property_button_clicked(property, source) 28 | obs.obs_properties_destroy(properties) 29 | end 30 | end 31 | end 32 | obs.source_list_release(sources) 33 | end 34 | 35 | -- script hook: description displayed on script window 36 | function script_description () 37 | return [[ 38 |

Refresh Browser Sources

39 | 40 | Copyright © 2021-2022 Dr. Ralf S. Engelschall
42 | Distributed under MIT license 44 | 45 |

46 | Refresh all Browser Source sources. Either press the 47 | button below or assign a hotkey under Settings / Hotkeys 48 | to the global action Refresh all browsers. An alternative 49 | would be to assign the hotkey to all scene actions named 50 | "Refresh page of current page". 51 | ]] 52 | end 53 | 54 | -- script hook: define UI properties 55 | function script_properties () 56 | -- create new properties 57 | local props = obs.obs_properties_create() 58 | obs.obs_properties_add_button(props, "refresh_browsers", 59 | "Refresh All Browser Sources", refreshBrowsers) 60 | return props 61 | end 62 | 63 | -- script hook: on script load 64 | function script_load (settings) 65 | ctx.hotkey = obs.obs_hotkey_register_frontend( 66 | "refresh_browsers.trigger", "Refresh all browsers", 67 | function (pressed) 68 | if not pressed then 69 | return 70 | end 71 | refreshBrowsers() 72 | end 73 | ) 74 | local hotkey_save_array = obs.obs_data_get_array(settings, 75 | "refresh_browsers.trigger") 76 | obs.obs_hotkey_load(ctx.hotkey, hotkey_save_array) 77 | obs.obs_data_array_release(hotkey_save_array) 78 | end 79 | 80 | -- script hook: on script save 81 | function script_save (settings) 82 | local hotkey_save_array = obs.obs_hotkey_save(ctx.hotkey) 83 | obs.obs_data_set_array(settings, 84 | "refresh_browsers.trigger", hotkey_save_array) 85 | obs.obs_data_array_release(hotkey_save_array) 86 | end 87 | 88 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | 2 | OBS Studio Lua Scripts 3 | ====================== 4 | 5 | About 6 | ----- 7 | 8 | This is a small collection of [Lua](http://www.lua.org/) 9 | scripts for automating certain tasks and extending the 10 | functionality in the video/audio stream mixing software [OBS 11 | Studio](https://obsproject.com/). 12 | 13 | The individual scripts are: 14 | 15 | - [auto-execute-commands.lua](auto-execute-commands.lua):
16 | Automatically execute commands when OBS Studio starts up and/or 17 | shuts down. This is usually used for initializing cameras or 18 | performing similar external start/stop tasks. 19 | 20 | - [clone-template-scene.lua](clone-template-scene.lua):
21 | Clone an entire source scene (template), by creating a target scene 22 | (clone) and copying all corresponding sources, including their 23 | filters, transforms, etc. This is usually used for creating scenes, 24 | based on the same set of template scenes, during event preparation. 25 | 26 | - [keyboard-event-filter.lua](keyboard-event-filter.lua):
27 | Define a Keyboard Event filter for sources. This is intended to 28 | map OBS Studio global hotkeys onto keyboard events for Browser 29 | Source sources. This is usually used to map global hotkeys (or even 30 | attached StreamDeck keys) onto keystrokes for Browser Source based 31 | Head-Up-Display (HUD), Lower Thirds or Banner widgets during event 32 | production. 33 | 34 | - [production-information.lua](production-information.lua):
35 | Updates Text/GDI+ sources with the current Preview and Program scene 36 | information, the current wallclock time and the current on-air 37 | duration time in order to broadcast this information during production 38 | to the involved people. This is usually used for broadcasting central 39 | information during event production. 40 | 41 | - [refresh-browser-sources.lua](refresh-browser-sources.lua):
42 | Refresh all Browser Source sources. This is usally used to 43 | refresh Browser Source based Head-Up-Display (HUD), Lower Thirds or 44 | Banner widgets during event preparation. 45 | 46 | - [source-one-of-many.lua](source-one-of-many.lua):
47 | Toggle between one of many sources visible in a scene/group. If 48 | a source is made visible in a scene/group, all other sources are 49 | automatically made non-visible. The currently already visible source 50 | is made visible immediately again, if it is accidentally requested 51 | to be made non-visible. So, at each time, only one source is visible 52 | within the scene/group. This is usually used for switching between 53 | multiple cameras within a dedicated scene/group of cameras. 54 | 55 | - [enforce-current-scenes.lua](enforce-current-scenes.lua):
56 | Enforce certain scenes to be always in preview/program. This is 57 | usually used when multiple NDI-exported dashboard scenes exists, which 58 | never should be manually selected, and particular scenes should be in 59 | preview/program to ensure they are output with the Virtual Camera or 60 | with a Decklink card. 61 | 62 | Installation 63 | ------------ 64 | 65 | 1. Clone this repository:
66 | `git clone https://github.com/rse/obs-scripts` 67 | 68 | 2. Add the individual scripts to OBS Studio with
69 | **Tools** → **Scripts** → **+** (Add Script) 70 | 71 | License 72 | ------- 73 | 74 | Copyright © 2021-2024 Dr. Ralf S. Engelschall (http://engelschall.com/) 75 | 76 | Permission is hereby granted, free of charge, to any person obtaining 77 | a copy of this software and associated documentation files (the 78 | "Software"), to deal in the Software without restriction, including 79 | without limitation the rights to use, copy, modify, merge, publish, 80 | distribute, sublicense, and/or sell copies of the Software, and to 81 | permit persons to whom the Software is furnished to do so, subject to 82 | the following conditions: 83 | 84 | The above copyright notice and this permission notice shall be included 85 | in all copies or substantial portions of the Software. 86 | 87 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 88 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 89 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. 90 | IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY 91 | CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, 92 | TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE 93 | SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 94 | 95 | -------------------------------------------------------------------------------- /auto-execute-commands.lua: -------------------------------------------------------------------------------- 1 | --[[ 2 | ** 3 | ** auto-execute-commands.lua -- OBS Studio Lua Script for Automatically Executing Commands 4 | ** Copyright (c) 2021-2022 Dr. Ralf S. Engelschall 5 | ** Distributed under MIT license 6 | ** 7 | --]] 8 | 9 | -- global OBS API 10 | local obs = obslua 11 | 12 | -- global context information 13 | local ctx = { 14 | settings = nil, 15 | startExecPath = "", 16 | startExecClose = false, 17 | stopExecPath = "" 18 | } 19 | 20 | -- helper function: run start executable once 21 | local function startExecOnce() 22 | if ctx.startExecPath == "" then 23 | return nil 24 | end 25 | local index = ctx.startExecPath:match("^.*()\\") 26 | local execDir = ctx.startExecPath:sub(1, index) 27 | local execName = ctx.startExecPath:sub(index + 1, ctx.startExecPath:len()) 28 | local cmd = string.format("start \"\" /D \"%s\" \"%s\"", execDir, execName) 29 | obs.script_log(obs.LOG_INFO, string.format("executing command \"%s\"", cmd)) 30 | os.execute(cmd) 31 | end 32 | 33 | -- helper function: run stop executable once 34 | local function stopExecOnce() 35 | if ctx.stopExecPath == "" then 36 | return nil 37 | end 38 | local index = ctx.stopExecPath:match("^.*()\\") 39 | local execDir = ctx.stopExecPath:sub(1, index) 40 | local execName = ctx.stopExecPath:sub(index + 1, ctx.stopExecPath:len()) 41 | local cmd = string.format("start \"\" /D \"%s\" \"%s\"", execDir, execName) 42 | obs.script_log(obs.LOG_INFO, string.format("executing command \"%s\"", cmd)) 43 | os.execute(cmd) 44 | end 45 | 46 | -- script hook: description displayed on script window 47 | function script_description () 48 | return [[ 49 |

Automatically Execute Commands

50 | 51 | Copyright © 2021-2022 Dr. Ralf S. Engelschall
53 | Distributed under MIT license 55 | 56 |

57 | Automatically execute commands when OBS Studio starts up and/or shuts down. 58 | ]] 59 | end 60 | 61 | -- script hook: define UI properties 62 | function script_properties () 63 | -- create new properties 64 | local props = obs.obs_properties_create() 65 | obs.obs_properties_add_path(props, "startExecPath", "File", 66 | obs.OBS_PATH_FILE, "*.exe, *.bat, *.vbs, *.lnk, *.*", nil) 67 | obs.obs_properties_add_bool(props, "startExecClose", "Close again when OBS Studio shuts down?") 68 | obs.obs_properties_add_button(props, "startExecOnce", "Start Once", startExecOnce) 69 | obs.obs_properties_add_path(props, "stopExecPath", "File", 70 | obs.OBS_PATH_FILE, "*.exe, *.bat, *.vbs, *.lnk, *.*", nil) 71 | obs.obs_properties_add_button(props, "stopExecOnce", "Stop Once", stopExecOnce) 72 | return props 73 | end 74 | 75 | -- script hook: define property defaults 76 | function script_defaults (settings) 77 | -- initialize property values 78 | obs.obs_data_set_default_string(settings, "startExecPath", "") 79 | obs.obs_data_set_default_bool(settings, "startExecClose", false) 80 | obs.obs_data_set_default_string(settings, "stopExecPath", "") 81 | end 82 | 83 | -- script hook: property values were updated 84 | function script_update (settings) 85 | -- remember settings globally 86 | ctx.settings = settings 87 | 88 | -- fetch property values 89 | ctx.startExecPath = obs.obs_data_get_string(settings, "startExecPath") 90 | ctx.startExecPath = ctx.startExecPath:gsub("/", "\\") 91 | ctx.startExecClose = obs.obs_data_get_bool(settings, "startExecClose") 92 | ctx.stopExecPath = obs.obs_data_get_string(settings, "stopExecPath") 93 | ctx.stopExecPath = ctx.stopExecPath:gsub("/", "\\") 94 | end 95 | 96 | -- script hook: on script load 97 | function script_load (settings) 98 | -- remember settings globally 99 | ctx.settings = settings 100 | 101 | -- run the start executable once 102 | startExecOnce() 103 | end 104 | 105 | -- script hook: on script unload 106 | function script_unload () 107 | -- close the start executable again 108 | if ctx.startExecClose then 109 | local cmd = string.format("taskkill /t /f /im \"%s\"", ctx.startExecName) 110 | obs.script_log(obs.LOG_INFO, string.format("executing command \"%s\"", cmd)) 111 | os.execute(cmd) 112 | end 113 | 114 | -- run the stop executable once 115 | stopExecOnce() 116 | end 117 | 118 | -------------------------------------------------------------------------------- /source-one-of-many.lua: -------------------------------------------------------------------------------- 1 | --[[ 2 | ** 3 | ** source-one-of-many.lua -- OBS Studio Lua Script for Toggling One of Many Sources 4 | ** Copyright (c) 2021-2022 Dr. Ralf S. Engelschall 5 | ** Distributed under MIT license 6 | ** 7 | --]] 8 | 9 | -- global OBS API 10 | local obs = obslua 11 | 12 | -- global context information 13 | local ctx = { 14 | settings = nil, 15 | set_visible = {} 16 | } 17 | 18 | -- send a trace message to the script log 19 | local function script_log (level, msg) 20 | local time = os.date("%Y-%m-%d %X") 21 | obs.script_log(obs.LOG_INFO, string.format("%s [%s] %s\n", time, level, msg)) 22 | end 23 | 24 | -- callback of "item_visible" handler (scene/source visibility changed) 25 | local function cb_item_visible (calldata) 26 | -- determine current callback information 27 | local item = obs.calldata_sceneitem(calldata, "item") 28 | local visible = obs.calldata_bool(calldata, "visible") 29 | 30 | -- determine changed scene/source name 31 | local source = obs.obs_sceneitem_get_source(item) 32 | local sourceName = obs.obs_source_get_name(source) 33 | if visible then 34 | script_log("INFO", string.format("notice: source \"%s\" will be made visible (by OBS)", sourceName)) 35 | else 36 | script_log("INFO", string.format("notice: source \"%s\" will be made non-visible (by OBS)", sourceName)) 37 | end 38 | 39 | -- iterate over all scenes of scene/source 40 | local scene = obs.obs_sceneitem_get_scene(item) 41 | local sceneitems = obs.obs_scene_enum_items(scene) 42 | local found_other_visible = false 43 | for _, sceneitem in ipairs(sceneitems) do 44 | local itemsource = obs.obs_sceneitem_get_source(sceneitem) 45 | local name = obs.obs_source_get_name(itemsource) 46 | 47 | -- for another source which is visible... 48 | if sourceName ~= name and obs.obs_sceneitem_visible(sceneitem) then 49 | if visible then 50 | -- ...make it non-visible 51 | script_log("INFO", string.format("forcing source \"%s\" to be non-visible (by us)", name)) 52 | obs.obs_sceneitem_set_visible(sceneitem, false) 53 | else 54 | -- ...or remember it is visible 55 | found_other_visible = true 56 | end 57 | end 58 | end 59 | obs.sceneitem_list_release(sceneitems) 60 | 61 | -- if a source is requested to be made non-visible and we have not 62 | -- found any other still visible source in the scene/group, make 63 | -- it visible again (but delay the toggling as the source is still 64 | -- just flagged to be made non-visible and is actually just to be 65 | -- made non-visible after this callback!) 66 | if not visible and not found_other_visible then 67 | script_log("INFO", string.format("forcing source \"%s\" to be visible again afterwards (by us)", sourceName)) 68 | table.insert(ctx.set_visible, { item = item, delay = 10, visible = true }) 69 | end 70 | end 71 | 72 | -- callback of "source_load" handler (called when a source is being loaded) 73 | local function cb_source_load (calldata) 74 | -- skip operation of no global settings are available 75 | if ctx.settings == nil then 76 | return 77 | end 78 | 79 | -- determine current loaded source 80 | local source = obs.calldata_source(calldata, "source") 81 | local sn = obs.obs_source_get_name(source) 82 | 83 | -- (re)connect "item_visible" handler if one of our configured sources is loaded 84 | local sourceNames = obs.obs_data_get_array(ctx.settings, "sources") 85 | local sourceNamesCount = obs.obs_data_array_count(sourceNames) 86 | for i = 0, sourceNamesCount do 87 | local item = obs.obs_data_array_item(sourceNames, i) 88 | local sourceName = obs.obs_data_get_string(item, "value") 89 | if sn == sourceName then 90 | local sh = obs.obs_source_get_signal_handler(source) 91 | obs.signal_handler_disconnect(sh, "item_visible", cb_item_visible) 92 | obs.signal_handler_connect(sh, "item_visible", cb_item_visible) 93 | end 94 | obs.obs_data_release(item) 95 | end 96 | obs.obs_data_array_release(sourceNames) 97 | end 98 | 99 | -- script hook: description displayed on script window 100 | function script_description () 101 | return [[ 102 |

Source One-of-Many

103 | 104 | Copyright © 2021-2022 Dr. Ralf S. Engelschall
106 | Distributed under MIT license 108 | 109 |

110 | Toggle between one of many sources visible in a scene/group. 111 | If a source is made visible in a scene/group, all other sources 112 | are automatically made non-visible. The currently already 113 | visible source is made visible immediately again, if it is 114 | accidentally requested to be made non-visible. So, at each time, 115 | only one source is visible within the scene/group. 116 | ]] 117 | end 118 | 119 | -- script hook: define UI properties 120 | function script_properties () 121 | -- create new properties 122 | local props = obs.obs_properties_create() 123 | obs.obs_properties_add_editable_list(props, "sources", "Scenes/Groups", 124 | obs.OBS_EDITABLE_LIST_TYPE_STRINGS, nil, nil) 125 | return props 126 | end 127 | 128 | -- script hook: property values were updated 129 | function script_update (settings) 130 | -- (re)connect "item_visible" handler on all configured sources 131 | local sourceNames = obs.obs_data_get_array(settings, "sources") 132 | local sourceNamesCount = obs.obs_data_array_count(sourceNames) 133 | for i = 0, sourceNamesCount do 134 | local item = obs.obs_data_array_item(sourceNames, i) 135 | local sourceName = obs.obs_data_get_string(item, "value") 136 | local source = obs.obs_get_source_by_name(sourceName) 137 | if source ~= nil then 138 | local sh = obs.obs_source_get_signal_handler(source) 139 | obs.signal_handler_disconnect(sh, "item_visible", cb_item_visible) 140 | obs.signal_handler_connect(sh, "item_visible", cb_item_visible) 141 | obs.obs_source_release(source) 142 | end 143 | obs.obs_data_release(item) 144 | end 145 | obs.obs_data_array_release(sourceNames) 146 | end 147 | 148 | -- script hook: on script load 149 | function script_load (settings) 150 | -- remember settings globally 151 | ctx.settings = settings 152 | 153 | -- hook into "source_load" handler 154 | local sh = obs.obs_get_signal_handler() 155 | obs.signal_handler_connect(sh, "source_load", cb_source_load) 156 | end 157 | 158 | -- script hook: on script tick 159 | function script_tick (seconds) 160 | local i = 1 161 | while i <= #ctx.set_visible do 162 | ctx.set_visible[i].delay = ctx.set_visible[i].delay - (seconds * 1000) 163 | if ctx.set_visible[i].delay <= 0 then 164 | obs.obs_sceneitem_set_visible(ctx.set_visible[i].item, ctx.set_visible[i].visible) 165 | table.remove(ctx.set_visible, i) 166 | else 167 | i = i + 1 168 | end 169 | end 170 | end 171 | 172 | -------------------------------------------------------------------------------- /production-information.lua: -------------------------------------------------------------------------------- 1 | --[[ 2 | ** 3 | ** production-information.lua -- OBS Studio Lua Script for Production Information 4 | ** Copyright (c) 2021-2022 Dr. Ralf S. Engelschall 5 | ** Distributed under MIT license 6 | ** 7 | --]] 8 | 9 | -- global OBS API 10 | local obs = obslua 11 | 12 | -- global context information 13 | local ctx = { 14 | -- properties 15 | propsDef = nil, -- property definition 16 | propsDefSrcPreview = nil, -- property definition (source scene of preview) 17 | propsDefSrcProgram = nil, -- property definition (source scene of program) 18 | propsDefSrcTime = nil, -- property definition (source scene of time) 19 | propsDefSrcDuration = nil, -- property definition (source scene of duration) 20 | propsSet = nil, -- property settings (model) 21 | propsVal = {}, -- property values 22 | propsValSrcPreview = nil, -- property values (source scene of preview) 23 | propsValSrcProgram = nil, -- property values (source scene of program) 24 | propsValSrcTime = nil, -- property values (source scene of time) 25 | propsValSrcDuration = nil, -- property values (source scene of duration) 26 | 27 | -- duration timer 28 | timerStart = 0, -- timer start (in nanoseconds) 29 | timerPaused = false, -- timer paused flag 30 | timerPausedSecs = 0, -- timer paused time (in seconds) 31 | 32 | -- hotkey registration 33 | hotkeyIdPause = obs.OBS_INVALID_HOTKEY_ID, 34 | hotkeyIdReset = obs.OBS_INVALID_HOTKEY_ID 35 | } 36 | 37 | -- helper function: update text source properties 38 | local function updateTextSources () 39 | -- clear already initialized property lists 40 | if ctx.propsDefSrcPreview ~= nil then 41 | obs.obs_property_list_clear(ctx.propsDefSrcPreview) 42 | end 43 | if ctx.propsDefSrcProgram ~= nil then 44 | obs.obs_property_list_clear(ctx.propsDefSrcProgram) 45 | end 46 | if ctx.propsDefSrcTime ~= nil then 47 | obs.obs_property_list_clear(ctx.propsDefSrcTime) 48 | end 49 | if ctx.propsDefSrcDuration ~= nil then 50 | obs.obs_property_list_clear(ctx.propsDefSrcDuration) 51 | end 52 | 53 | -- clear all selected property values 54 | ctx.propsValSrcPreview = nil 55 | ctx.propsValSrcProgram = nil 56 | ctx.propsValSrcTime = nil 57 | ctx.propsValSrcDuration = nil 58 | 59 | -- iterate over all text sources... 60 | local sources = obs.obs_enum_sources() 61 | if sources ~= nil then 62 | for _, source in ipairs(sources) do 63 | local source_id = obs.obs_source_get_unversioned_id(source) 64 | if source_id == "text_gdiplus" or source_id == "text_ft2_source" then 65 | -- ...and fetch their source names 66 | local name = obs.obs_source_get_name(source) 67 | 68 | -- add source to preview text source selection list 69 | -- and initialize selected value 70 | if ctx.propsDefSrcPreview ~= nil then 71 | obs.obs_property_list_add_string(ctx.propsDefSrcPreview, name, name) 72 | end 73 | if ctx.propsValSrcPreview == nil then 74 | ctx.propsValSrcPreview = name 75 | end 76 | 77 | -- add source to program text source selection list 78 | -- and initialize selected value 79 | if ctx.propsDefSrcProgram ~= nil then 80 | obs.obs_property_list_add_string(ctx.propsDefSrcProgram, name, name) 81 | end 82 | if ctx.propsValSrcProgram == nil then 83 | ctx.propsValSrcProgram = name 84 | end 85 | 86 | -- add source to time text source selection list 87 | -- and initialize selected value 88 | if ctx.propsDefSrcTime ~= nil then 89 | obs.obs_property_list_add_string(ctx.propsDefSrcTime, name, name) 90 | end 91 | if ctx.propsValSrcTime == nil then 92 | ctx.propsValSrcTime = name 93 | end 94 | 95 | -- add source to duration text source selection list 96 | -- and initialize selected value 97 | if ctx.propsDefSrcDuration ~= nil then 98 | obs.obs_property_list_add_string(ctx.propsDefSrcDuration, name, name) 99 | end 100 | if ctx.propsValSrcDuration == nil then 101 | ctx.propsValSrcDuration = name 102 | end 103 | end 104 | end 105 | end 106 | obs.source_list_release(sources) 107 | end 108 | 109 | -- helper function for duration pause 110 | local function durationPause () 111 | ctx.timerPaused = not ctx.timerPaused 112 | end 113 | 114 | -- helper function for duration reset 115 | local function durationReset () 116 | ctx.timerStart = obs.os_gettime_ns() 117 | ctx.timerPausedSecs = 0 118 | end 119 | 120 | -- update a single target text source 121 | local function updateTextSource (name, text) 122 | local source = obs.obs_get_source_by_name(name) 123 | if source ~= nil then 124 | local settings = obs.obs_source_get_settings(source) 125 | obs.obs_data_set_string(settings, "text", text) 126 | obs.obs_source_update(source, settings) 127 | obs.obs_data_release(settings) 128 | obs.obs_source_release(source) 129 | end 130 | end 131 | 132 | -- update targets for scenes 133 | local function updateTextSourcesScene () 134 | -- determine current scene in preview and update text source 135 | local previewSceneSource = obs.obs_frontend_get_current_preview_scene() 136 | local previewSceneName = obs.obs_source_get_name(previewSceneSource) 137 | updateTextSource(ctx.propsVal.textSourceNamePreview, previewSceneName) 138 | obs.obs_source_release(previewSceneSource) 139 | 140 | -- determine current scene in program and update text source 141 | local programSceneSource = obs.obs_frontend_get_current_scene() 142 | local programSceneName = obs.obs_source_get_name(programSceneSource) 143 | updateTextSource(ctx.propsVal.textSourceNameProgram, programSceneName) 144 | obs.obs_source_release(programSceneSource) 145 | end 146 | 147 | -- update targets for time 148 | local function updateTextSourcesTime () 149 | -- determine current wallclock-time and update text source 150 | local time = os.date("%H:%M:%S") 151 | updateTextSource(ctx.propsVal.textSourceNameTime, time) 152 | 153 | -- determine current duration-time and update text source 154 | if ctx.timerPaused then 155 | ctx.timerPausedSecs = ctx.timerPausedSecs + 1 156 | end 157 | local timerEnd = obs.os_gettime_ns() 158 | local duration = math.floor((timerEnd - ctx.timerStart) / (1000 * 1000 * 1000)) - ctx.timerPausedSecs 159 | local hour = math.floor(duration / (60 * 60)) 160 | duration = math.fmod(duration, 60 * 60) 161 | local min = math.floor(duration / 60) 162 | duration = math.fmod(duration, 60) 163 | local sec = duration 164 | local text = string.format("%02d:%02d:%02d", hour, min, sec) 165 | if ctx.timerPaused then 166 | text = text .. " *" 167 | end 168 | updateTextSource(ctx.propsVal.textSourceNameDuration, text) 169 | end 170 | 171 | -- script hook: description displayed on script window 172 | function script_description () 173 | return [[ 174 |

Production Information

175 | 176 | Copyright © 2021-2022 Dr. Ralf S. Engelschall
178 | Distributed under MIT license 180 | 181 |

182 | Render production information into corresponding text sources. 183 | 184 |

185 | This is a small OBS Studio script for rendering the current 186 | scene name visible in the Preview and Program channels, the 187 | current wallclock time and the current on-air duration time into 188 | pre-defined corresponding Text/GDI+ text sources. These text 189 | sources are usually part of a (hidden) scene which is either 190 | just part of a locally shown OBS Studio Multiview or Projector 191 | or is broadcasted via an attached "Dedicated NDI Output" filter 192 | to foreign monitors. In all cases, the intention is to globally 193 | show current production information to the involved people 194 | during a production session. 195 | ]] 196 | end 197 | 198 | -- script hook: define UI properties 199 | function script_properties () 200 | -- create new properties 201 | local props = obs.obs_properties_create() 202 | 203 | -- create selection fields 204 | ctx.propsDefSrcPreview = obs.obs_properties_add_list(props, 205 | "textSourceNamePreview", "Preview-Name Text-Source", 206 | obs.OBS_COMBO_TYPE_EDITABLE, obs.OBS_COMBO_FORMAT_STRING) 207 | ctx.propsDefSrcProgram = obs.obs_properties_add_list(props, 208 | "textSourceNameProgram", "Program-Name Text-Source", 209 | obs.OBS_COMBO_TYPE_EDITABLE, obs.OBS_COMBO_FORMAT_STRING) 210 | ctx.propsDefSrcTime = obs.obs_properties_add_list(props, 211 | "textSourceNameTime", "Wallclock-Time Text-Source", 212 | obs.OBS_COMBO_TYPE_EDITABLE, obs.OBS_COMBO_FORMAT_STRING) 213 | ctx.propsDefSrcDuration = obs.obs_properties_add_list(props, 214 | "textSourceNameDuration", "Duration-Time Text-Source", 215 | obs.OBS_COMBO_TYPE_EDITABLE, obs.OBS_COMBO_FORMAT_STRING) 216 | updateTextSources() 217 | 218 | -- create buttons 219 | obs.obs_properties_add_button(props, "buttonStartStop", "Duration: Start/Stop", function () 220 | durationPause() 221 | return true 222 | end) 223 | obs.obs_properties_add_button(props, "buttonReset", "Duration: Reset", function () 224 | durationReset() 225 | return true 226 | end) 227 | 228 | return props 229 | end 230 | 231 | -- script hook: define property defaults 232 | function script_defaults (settings) 233 | -- update our text source list (for propsValSrcXXX below) 234 | updateTextSources() 235 | 236 | -- provide default values 237 | obs.obs_data_set_default_string(settings, "textSourceNamePreview", ctx.propsValSrcPreview) 238 | obs.obs_data_set_default_string(settings, "textSourceNameProgram", ctx.propsValSrcProgram) 239 | obs.obs_data_set_default_string(settings, "textSourceNameTime", ctx.propsValSrcTime) 240 | obs.obs_data_set_default_string(settings, "textSourceNameDuration", ctx.propsValSrcDuration) 241 | end 242 | 243 | -- script hook: update state from UI properties 244 | function script_update (settings) 245 | -- remember settings 246 | ctx.propsSet = settings 247 | 248 | -- fetch property values 249 | ctx.propsVal.textSourceNamePreview = obs.obs_data_get_string(settings, "textSourceNamePreview") 250 | ctx.propsVal.textSourceNameProgram = obs.obs_data_get_string(settings, "textSourceNameProgram") 251 | ctx.propsVal.textSourceNameTime = obs.obs_data_get_string(settings, "textSourceNameTime") 252 | ctx.propsVal.textSourceNameDuration = obs.obs_data_get_string(settings, "textSourceNameDuration") 253 | end 254 | 255 | -- script hook: on script load 256 | function script_load (settings) 257 | -- define hotkeys 258 | ctx.hotkeyIdPause = obs.obs_hotkey_register_frontend("duration_pause", 259 | "Duration: Start/Stop", function (pressed) 260 | if pressed then 261 | durationPause() 262 | end 263 | end) 264 | ctx.hotkeyIdReset = obs.obs_hotkey_register_frontend("duration_reset", 265 | "Duration: Reset", function (pressed) 266 | if pressed then 267 | durationReset() 268 | end 269 | end) 270 | local hotkeyArrayPause = obs.obs_data_get_array(settings, "hotkey_pause") 271 | local hotkeyArrayReset = obs.obs_data_get_array(settings, "hotkey_reset") 272 | obs.obs_hotkey_load(ctx.hotkeyIdPause, hotkeyArrayPause) 273 | obs.obs_hotkey_load(ctx.hotkeyIdReset, hotkeyArrayReset) 274 | obs.obs_data_array_release(hotkeyArrayPause) 275 | obs.obs_data_array_release(hotkeyArrayReset) 276 | 277 | -- hook into the UI events 278 | obs.obs_frontend_add_event_callback(function (event) 279 | if event == obs.OBS_FRONTEND_EVENT_SCENE_LIST_CHANGED then 280 | updateTextSources() 281 | elseif event == obs.OBS_FRONTEND_EVENT_SCENE_CHANGED then 282 | updateTextSourcesScene() 283 | elseif event == obs.OBS_FRONTEND_EVENT_PREVIEW_SCENE_CHANGED then 284 | updateTextSourcesScene() 285 | end 286 | return true 287 | end) 288 | 289 | -- start timer 290 | durationReset() 291 | obs.timer_add(updateTextSourcesTime, 1000) 292 | end 293 | 294 | -- script hook: on script save state 295 | function script_save(settings) 296 | -- save hotkeys 297 | local hotkeyArrayPause = obs.obs_hotkey_save(ctx.hotkeyIdPause) 298 | local hotkeyArrayReset = obs.obs_hotkey_save(ctx.hotkeyIdReset) 299 | obs.obs_data_set_array(settings, "hotkey_pause", hotkeyArrayPause) 300 | obs.obs_data_set_array(settings, "hotkey_reset", hotkeyArrayReset) 301 | obs.obs_data_array_release(hotkeyArrayPause) 302 | obs.obs_data_array_release(hotkeyArrayReset) 303 | end 304 | 305 | -------------------------------------------------------------------------------- /clone-template-scene.lua: -------------------------------------------------------------------------------- 1 | --[[ 2 | ** 3 | ** clone-template-scene.lua -- OBS Studio Lua Script for Cloning Template Scene 4 | ** Copyright (c) 2021-2022 Dr. Ralf S. Engelschall 5 | ** Distributed under MIT license 6 | ** 7 | --]] 8 | 9 | -- global OBS API 10 | local obs = obslua 11 | 12 | -- global context information 13 | local ctx = { 14 | propsDef = nil, -- property definition 15 | propsDefSrc = nil, -- property definition (source scene) 16 | propsSet = nil, -- property settings (model) 17 | propsVal = {}, -- property values 18 | propsValSrc = nil, -- property values (first source scene) 19 | } 20 | 21 | -- helper function: set status message 22 | local function statusMessage (type, message) 23 | if type == "error" then 24 | obs.script_log(obs.LOG_INFO, message) 25 | obs.obs_data_set_string(ctx.propsSet, "statusMessage", string.format("ERROR: %s", message)) 26 | else 27 | obs.script_log(obs.LOG_INFO, message) 28 | obs.obs_data_set_string(ctx.propsSet, "statusMessage", string.format("INFO: %s", message)) 29 | end 30 | obs.obs_properties_apply_settings(ctx.propsDef, ctx.propsSet) 31 | return true 32 | end 33 | 34 | -- helper function: find scene by name 35 | local function findSceneByName (name) 36 | local scenes = obs.obs_frontend_get_scenes() 37 | if scenes == nil then 38 | return nil 39 | end 40 | for _, scene in ipairs(scenes) do 41 | local n = obs.obs_source_get_name(scene) 42 | if n == name then 43 | obs.source_list_release(scenes) 44 | return scene 45 | end 46 | end 47 | obs.source_list_release(scenes) 48 | return nil 49 | end 50 | 51 | -- helper function: replace a string 52 | local function stringReplace (str, from, to) 53 | local function regexEscape (s) 54 | return string.gsub(s, "[%(%)%.%%%+%-%*%?%[%^%$%]]", "%%%1") 55 | end 56 | return string.gsub(str, regexEscape(from), to) 57 | end 58 | 59 | -- called for the actual cloning action 60 | local function doClone () 61 | -- find source scene (template) 62 | local sourceScene = findSceneByName(ctx.propsVal.sourceScene) 63 | if sourceScene == nil then 64 | statusMessage("error", string.format("source scene \"%s\" not found!", 65 | ctx.propsVal.sourceScene)) 66 | return true 67 | end 68 | 69 | -- find target scene (clone) 70 | local targetScene = findSceneByName(ctx.propsVal.targetScene) 71 | if targetScene ~= nil then 72 | statusMessage("error", string.format("target scene \"%s\" already exists!", 73 | ctx.propsVal.targetScene)) 74 | return true 75 | end 76 | 77 | -- create target scene 78 | obs.script_log(obs.LOG_INFO, string.format("create: SCENE \"%s\"", 79 | ctx.propsVal.targetScene)) 80 | targetScene = obs.obs_scene_create(ctx.propsVal.targetScene) 81 | 82 | -- iterate over all source scene (template) sources 83 | local sourceSceneBase = obs.obs_scene_from_source(sourceScene) 84 | local sourceItems = obs.obs_scene_enum_items(sourceSceneBase) 85 | for _, sourceItem in ipairs(sourceItems) do 86 | local sourceSrc = obs.obs_sceneitem_get_source(sourceItem) 87 | 88 | -- determine source and destination name 89 | local sourceNameSrc = obs.obs_source_get_name(sourceSrc) 90 | local sourceNameDst = stringReplace(sourceNameSrc, 91 | ctx.propsVal.sourceScene, ctx.propsVal.targetScene) 92 | obs.script_log(obs.LOG_INFO, string.format("create: SOURCE \"%s/%s\"", 93 | ctx.propsVal.targetScene, sourceNameDst)) 94 | 95 | -- create source 96 | local type = obs.obs_source_get_id(sourceSrc) 97 | local settings = obs.obs_source_get_settings(sourceSrc) 98 | local targetSource = obs.obs_source_create(type, sourceNameDst, settings, nil) 99 | 100 | -- add source to scene 101 | local targetItem = obs.obs_scene_add(targetScene, targetSource) 102 | 103 | -- copy source private settings 104 | local privSettings = obs.obs_source_get_private_settings(sourceSrc) 105 | local hidden = obs.obs_data_get_bool(privSettings, "mixer_hidden") 106 | local volumeLocked = obs.obs_data_get_bool(privSettings, "volume_locked") 107 | local showInMultiview = obs.obs_data_get_bool(privSettings, "show_in_multiview") 108 | obs.obs_data_release(privSettings) 109 | privSettings = obs.obs_source_get_private_settings(targetSource) 110 | obs.obs_data_set_bool(privSettings, "mixer_hidden", hidden) 111 | obs.obs_data_set_bool(privSettings, "volume_locked", volumeLocked) 112 | obs.obs_data_set_bool(privSettings, "show_in_multiview", showInMultiview) 113 | obs.obs_data_release(privSettings) 114 | 115 | -- copy source transforms 116 | local transform = obs.obs_transform_info() 117 | obs.obs_sceneitem_get_info(sourceItem, transform) 118 | obs.obs_sceneitem_set_info(targetItem, transform) 119 | 120 | -- copy source crop 121 | local crop = obs.obs_sceneitem_crop() 122 | obs.obs_sceneitem_get_crop(sourceItem, crop) 123 | obs.obs_sceneitem_set_crop(targetItem, crop) 124 | 125 | -- copy source filters 126 | obs.obs_source_copy_filters(targetSource, sourceSrc) 127 | 128 | -- copy source volume 129 | local volume = obs.obs_source_get_volume(sourceSrc) 130 | obs.obs_source_set_volume(targetSource, volume) 131 | 132 | -- copy source muted state 133 | local muted = obs.obs_source_muted(sourceSrc) 134 | obs.obs_source_set_muted(targetSource, muted) 135 | 136 | -- copy source push-to-mute state 137 | local pushToMute = obs.obs_source_push_to_mute_enabled(sourceSrc) 138 | obs.obs_source_enable_push_to_mute(targetSource, pushToMute) 139 | 140 | -- copy source push-to-mute delay 141 | local pushToMuteDelay = obs.obs_source_get_push_to_mute_delay(sourceSrc) 142 | obs.obs_source_set_push_to_mute_delay(targetSource, pushToMuteDelay) 143 | 144 | -- copy source push-to-talk state 145 | local pushToTalk = obs.obs_source_push_to_talk_enabled(sourceSrc) 146 | obs.obs_source_enable_push_to_talk(targetSource, pushToTalk) 147 | 148 | -- copy source push-to-talk delay 149 | local pushToTalkDelay = obs.obs_source_get_push_to_talk_delay(sourceSrc) 150 | obs.obs_source_set_push_to_talk_delay(targetSource, pushToTalkDelay) 151 | 152 | -- copy source sync offset 153 | local offset = obs.obs_source_get_sync_offset(sourceSrc) 154 | obs.obs_source_set_sync_offset(targetSource, offset) 155 | 156 | -- copy source mixer state 157 | local mixers = obs.obs_source_get_audio_mixers(sourceSrc) 158 | obs.obs_source_set_audio_mixers(targetSource, mixers) 159 | 160 | -- copy source deinterlace mode 161 | local mode = obs.obs_source_get_deinterlace_mode(sourceSrc) 162 | obs.obs_source_set_deinterlace_mode(targetSource, mode) 163 | 164 | -- copy source deinterlace field order 165 | local fieldOrder = obs.obs_source_get_deinterlace_field_order(sourceSrc) 166 | obs.obs_source_set_deinterlace_field_order(targetSource, fieldOrder) 167 | 168 | -- copy source flags 169 | local flags = obs.obs_source_get_flags(sourceSrc) 170 | obs.obs_source_set_flags(targetSource, flags) 171 | 172 | -- copy source enabled state 173 | local enabled = obs.obs_source_enabled(sourceSrc) 174 | obs.obs_source_set_enabled(targetSource, enabled) 175 | 176 | -- copy source visible state 177 | local visible = obs.obs_sceneitem_visible(sourceItem) 178 | obs.obs_sceneitem_set_visible(targetItem, visible) 179 | 180 | -- copy source locked state 181 | local locked = obs.obs_sceneitem_locked(sourceItem) 182 | obs.obs_sceneitem_set_locked(targetItem, locked) 183 | 184 | -- release resources 185 | obs.obs_source_release(targetSource) 186 | obs.obs_data_release(settings) 187 | end 188 | 189 | -- release resources 190 | obs.sceneitem_list_release(sourceItems) 191 | obs.obs_scene_release(targetScene) 192 | 193 | -- final hint 194 | statusMessage("info", string.format("scene \"%s\" successfully cloned to \"%s\".", 195 | ctx.propsVal.sourceScene, ctx.propsVal.targetScene)) 196 | return true 197 | end 198 | 199 | -- helper function: update source scenes property 200 | local function updateSourceScenes () 201 | if ctx.propsDefSrc == nil then 202 | return 203 | end 204 | obs.obs_property_list_clear(ctx.propsDefSrc) 205 | local scenes = obs.obs_frontend_get_scenes() 206 | if scenes == nil then 207 | return 208 | end 209 | ctx.propsValSrc = nil 210 | for _, scene in ipairs(scenes) do 211 | local n = obs.obs_source_get_name(scene) 212 | obs.obs_property_list_add_string(ctx.propsDefSrc, n, n) 213 | ctx.propsValSrc = n 214 | end 215 | obs.source_list_release(scenes) 216 | end 217 | 218 | -- script hook: description displayed on script window 219 | function script_description () 220 | return [[ 221 |

Clone Template Scene

222 | 223 | Copyright © 2021-2022 Dr. Ralf S. Engelschall
225 | Distributed under MIT license 227 | 228 |

229 | Clone an entire source scene (template), by creating a target 230 | scene (clone) and copying all corresponding sources, including 231 | their filters, transforms, etc. 232 | 233 |

234 | Notice: The same kind of cloning cannot to be achieved 235 | manually, as the scene Duplicate and the source 236 | Copy functions create references for many source types 237 | only and especially do not clone applied transforms. The only 238 | alternative is the tedious process of creating a new scene, 239 | step-by-step copying and pasting all sources and then also 240 | step-by-step copying and pasting all source transforms. 241 | 242 |

243 | Prerequisite: This script assumes that the source 244 | scene is named XXX (e.g. Template-01), 245 | all of its sources are named XXX-ZZZ (e.g. 246 | Template-01-Placeholder-02), the target scene is 247 | named YYY (e.g. Scene-03) and all of 248 | its sources are consequently named YYY-ZZZ (e.g. 249 | Scene-03-Placeholder-02). 250 | ]] 251 | end 252 | 253 | -- script hook: define UI properties 254 | function script_properties () 255 | -- create new properties 256 | ctx.propsDef = obs.obs_properties_create() 257 | 258 | -- create source scene list 259 | ctx.propsDefSrc = obs.obs_properties_add_list(ctx.propsDef, 260 | "sourceScene", "Source Scene (Template):", 261 | obs.OBS_COMBO_TYPE_LIST, obs.OBS_COMBO_FORMAT_STRING) 262 | updateSourceScenes() 263 | 264 | -- create target scene field 265 | obs.obs_properties_add_text(ctx.propsDef, "targetScene", 266 | "Target Scene (Clone):", obs.OBS_TEXT_DEFAULT) 267 | 268 | -- create clone button 269 | obs.obs_properties_add_button(ctx.propsDef, "clone", 270 | "Clone Template Scene", doClone) 271 | 272 | -- create status field (read-only) 273 | local status = obs.obs_properties_add_text(ctx.propsDef, "statusMessage", 274 | "Status Message:", obs.OBS_TEXT_MULTILINE) 275 | obs.obs_property_set_enabled(status, false) 276 | 277 | -- apply values to definitions 278 | obs.obs_properties_apply_settings(ctx.propsDef, ctx.propsSet) 279 | return ctx.propsDef 280 | end 281 | 282 | -- script hook: define property defaults 283 | function script_defaults (settings) 284 | -- update our source scene list (for propsValSrc below) 285 | updateSourceScenes() 286 | 287 | -- provide default values 288 | obs.obs_data_set_default_string(settings, "sourceScene", ctx.propsValSrc) 289 | obs.obs_data_set_default_string(settings, "targetScene", "Scene-01") 290 | obs.obs_data_set_default_string(settings, "statusMessage", "") 291 | end 292 | 293 | -- script hook: property values were updated 294 | function script_update (settings) 295 | -- remember settings 296 | ctx.propsSet = settings 297 | 298 | -- fetch property values 299 | ctx.propsVal.sourceScene = obs.obs_data_get_string(settings, "sourceScene") 300 | ctx.propsVal.targetScene = obs.obs_data_get_string(settings, "targetScene") 301 | ctx.propsVal.statusMessage = obs.obs_data_get_string(settings, "statusMessage") 302 | end 303 | 304 | -- react on script load 305 | function script_load (settings) 306 | -- clear status message 307 | obs.obs_data_set_string(settings, "statusMessage", "") 308 | 309 | -- react on scene list changes 310 | obs.obs_frontend_add_event_callback(function (event) 311 | if event == obs.OBS_FRONTEND_EVENT_SCENE_LIST_CHANGED then 312 | -- update our source scene list 313 | updateSourceScenes() 314 | end 315 | return true 316 | end) 317 | end 318 | 319 | -------------------------------------------------------------------------------- /enforce-current-scenes.lua: -------------------------------------------------------------------------------- 1 | --[[ 2 | ** 3 | ** enforce-current-scenes.lua -- OBS Studio Lua Script for Enforcing Preview/Program Scenes 4 | ** Copyright (c) 2024 Dr. Ralf S. Engelschall 5 | ** Distributed under MIT license 6 | ** 7 | --]] 8 | 9 | -- global OBS API 10 | local obs = obslua 11 | 12 | -- global context information 13 | local ctx = { 14 | -- properties 15 | propsDef = nil, -- property definition 16 | propsDefSrcPreview = nil, -- property definition (source scene of preview) 17 | propsDefSrcProgram = nil, -- property definition (source scene of program) 18 | propsSet = nil, -- property settings (model) 19 | propsVal = {}, -- property values 20 | 21 | -- hotkey registration 22 | hotkeyIdEnforce = obs.OBS_INVALID_HOTKEY_ID, 23 | 24 | -- flag for recursion prevention 25 | changingScenes = false, 26 | changingTimer = 0, 27 | 28 | -- timer for automatic enforcement 29 | enforceTimer = 0 30 | } 31 | 32 | -- helper function: update text source properties 33 | local function updateTextSources () 34 | -- clear already initialized property lists 35 | if ctx.propsDefSrcPreview ~= nil then 36 | obs.obs_property_list_clear(ctx.propsDefSrcPreview) 37 | obs.obs_property_list_add_string(ctx.propsDefSrcPreview, "none", "none") 38 | end 39 | if ctx.propsDefSrcProgram ~= nil then 40 | obs.obs_property_list_clear(ctx.propsDefSrcProgram) 41 | obs.obs_property_list_add_string(ctx.propsDefSrcProgram, "none", "none") 42 | end 43 | 44 | -- iterate over all sources... 45 | local scenes = obs.obs_frontend_get_scenes() 46 | if scenes ~= nil then 47 | for _, source in ipairs(scenes) do 48 | if obs.obs_source_get_type(source) == obs.OBS_SOURCE_TYPE_SCENE then 49 | -- ...and fetch their source names 50 | local name = obs.obs_source_get_name(source) 51 | 52 | -- add source to preview text source selection list 53 | if ctx.propsDefSrcPreview ~= nil then 54 | obs.obs_property_list_add_string(ctx.propsDefSrcPreview, name, name) 55 | end 56 | 57 | -- add source to program text source selection list 58 | if ctx.propsDefSrcProgram ~= nil then 59 | obs.obs_property_list_add_string(ctx.propsDefSrcProgram, name, name) 60 | end 61 | end 62 | end 63 | end 64 | obs.source_list_release(scenes) 65 | end 66 | 67 | -- tick every 10ms for changing timer 68 | local function changingTimerTick () 69 | if ctx.changingTimer > 0 then 70 | ctx.changingTimer = ctx.changingTimer - 10 71 | if ctx.changingTimer <= 0 then 72 | ctx.changingTimer = 0 73 | obs.timer_remove(changingTimerTick) 74 | ctx.changingScenes = false 75 | obs.obs_frontend_add_event_callback(ctx.onFrontendEvent) 76 | end 77 | end 78 | end 79 | 80 | -- enforce certain scenes 81 | local function enforceScenes (mode) 82 | obs.script_log(obs.LOG_INFO, 83 | string.format("[%s] enforce scenes: mode=%s", os.date("%Y-%m-%d %H:%M:%S"), mode)) 84 | 85 | -- short-circuit processing in case of mutex 86 | if ctx.changingScenes then 87 | obs.script_log(obs.LOG_INFO, 88 | string.format("[%s] enforce scenes: mutex prevents operation", os.date("%Y-%m-%d %H:%M:%S"))) 89 | return 90 | end 91 | 92 | -- optional delay on automatic enforcement 93 | if mode == "automatic" then 94 | local react = ctx.propsVal.flagReactAutomatic 95 | local delay = ctx.propsVal.numberDelayAutomatic 96 | if react then 97 | if delay == 0 then 98 | enforceScenes("timeout") 99 | elseif ctx.enforceTimer == 0 then 100 | ctx.enforceTimer = delay 101 | end 102 | end 103 | return 104 | end 105 | 106 | -- mutex initialization 107 | local mutex = false 108 | local function acquire () 109 | if not mutex then 110 | mutex = true 111 | ctx.changingScenes = true 112 | obs.obs_frontend_remove_event_callback(ctx.onFrontendEvent) 113 | end 114 | end 115 | 116 | -- enforce program 117 | if ctx.propsVal.textSourceNameProgram ~= "none" then 118 | local programSceneSourceCurrent = obs.obs_frontend_get_current_scene() 119 | local programSceneNameCurrent = obs.obs_source_get_name(programSceneSourceCurrent) 120 | local programSceneNameTarget = ctx.propsVal.textSourceNameProgram 121 | local programSceneSourceTarget = obs.obs_get_source_by_name(programSceneNameTarget) 122 | if programSceneNameCurrent ~= programSceneNameTarget then 123 | acquire() 124 | obs.script_log(obs.LOG_INFO, 125 | string.format("[%s] switching PROGRAM from \"%s\" to scene \"%s\"", 126 | os.date("%Y-%m-%d %H:%M:%S"), programSceneNameCurrent, programSceneNameTarget)) 127 | obs.obs_frontend_set_current_scene(programSceneSourceTarget) 128 | end 129 | obs.obs_source_release(programSceneSourceCurrent) 130 | obs.obs_source_release(programSceneSourceTarget) 131 | end 132 | 133 | -- enforce preview (studio mode only) 134 | if obs.obs_frontend_preview_program_mode_active() and ctx.propsVal.textSourceNamePreview ~= "none" then 135 | local previewSceneSourceCurrent = obs.obs_frontend_get_current_preview_scene() 136 | local previewSceneNameCurrent = obs.obs_source_get_name(previewSceneSourceCurrent) 137 | local previewSceneNameTarget = ctx.propsVal.textSourceNamePreview 138 | local previewSceneSourceTarget = obs.obs_get_source_by_name(previewSceneNameTarget) 139 | if previewSceneNameCurrent ~= previewSceneNameTarget then 140 | acquire() 141 | obs.script_log(obs.LOG_INFO, 142 | string.format("[%s] switching PREVIEW from \"%s\" to scene \"%s\"", 143 | os.date("%Y-%m-%d %H:%M:%S"), previewSceneNameCurrent, previewSceneNameTarget)) 144 | obs.obs_frontend_set_current_preview_scene(previewSceneSourceTarget) 145 | end 146 | obs.obs_source_release(previewSceneSourceCurrent) 147 | obs.obs_source_release(previewSceneSourceTarget) 148 | end 149 | 150 | -- handle mutex 151 | if mutex then 152 | ctx.changingTimer = 250 153 | obs.timer_add(changingTimerTick, 10) 154 | end 155 | end 156 | 157 | -- tick every 100ms for enforce timer 158 | local function enforceTimerTick () 159 | if ctx.enforceTimer > 0 then 160 | ctx.enforceTimer = ctx.enforceTimer - 100 161 | if ctx.enforceTimer <= 0 then 162 | ctx.enforceTimer = 0 163 | enforceScenes("timeout") 164 | end 165 | end 166 | end 167 | 168 | -- react on OBS Studio frontend events 169 | ctx.onFrontendEvent = function (event) 170 | if event == obs.OBS_FRONTEND_EVENT_FINISHED_LOADING then 171 | enforceScenes("automatic") 172 | elseif event == obs.OBS_FRONTEND_EVENT_SCENE_CHANGED then 173 | enforceScenes("automatic") 174 | elseif event == obs.OBS_FRONTEND_EVENT_PREVIEW_SCENE_CHANGED then 175 | enforceScenes("automatic") 176 | elseif event == obs.OBS_FRONTEND_EVENT_STUDIO_MODE_ENABLED then 177 | if ctx.propsDefSrcPreview ~= nil then 178 | obs.obs_property_set_enabled(ctx.propsDefSrcPreview, true) 179 | obs.obs_properties_apply_settings(ctx.propsDef, ctx.propsSet) 180 | end 181 | enforceScenes("automatic") 182 | elseif event == obs.OBS_FRONTEND_EVENT_STUDIO_MODE_DISABLED then 183 | if ctx.propsDefSrcPreview ~= nil then 184 | obs.obs_property_set_enabled(ctx.propsDefSrcPreview, false) 185 | obs.obs_properties_apply_settings(ctx.propsDef, ctx.propsSet) 186 | end 187 | enforceScenes("automatic") 188 | elseif event == obs.OBS_FRONTEND_EVENT_SCENE_LIST_CHANGED then 189 | updateTextSources() 190 | end 191 | return true 192 | end 193 | 194 | -- script hook: description displayed on script window 195 | function script_description () 196 | return [[ 197 |

Enforce Current Scenes

198 | 199 | Copyright © 2024 Dr. Ralf S. Engelschall
201 | Distributed under MIT license 203 | 204 |

205 | This is a small OBS Studio Lua script for enforcing certain scenes 206 | to be always in preview and program. 207 | ]] 208 | end 209 | 210 | -- script hook: define UI properties 211 | function script_properties () 212 | -- create new properties 213 | local props = obs.obs_properties_create() 214 | ctx.propsDef = props 215 | 216 | -- create scene selection fields 217 | ctx.propsDefSrcPreview = obs.obs_properties_add_list(props, 218 | "textSourceNamePreview", "Preview Scene (Studio Mode only)", 219 | obs.OBS_COMBO_TYPE_EDITABLE, obs.OBS_COMBO_FORMAT_STRING) 220 | if obs.obs_frontend_preview_program_mode_active() then 221 | obs.obs_property_set_enabled(ctx.propsDefSrcPreview, true) 222 | else 223 | obs.obs_property_set_enabled(ctx.propsDefSrcPreview, false) 224 | end 225 | ctx.propsDefSrcProgram = obs.obs_properties_add_list(props, 226 | "textSourceNameProgram", "Program Scene", 227 | obs.OBS_COMBO_TYPE_EDITABLE, obs.OBS_COMBO_FORMAT_STRING) 228 | updateTextSources() 229 | 230 | -- create boolean flag 231 | ctx.propsDefFlagReactAutomatic = obs.obs_properties_add_bool(props, 232 | "flagReactAutomatic", "Automatically enforce on any scene changes") 233 | 234 | -- create text field 235 | ctx.propsDefDelayAutomatic = obs.obs_properties_add_int(props, 236 | "numberDelayAutomatic", "Automatic enforcement delay (ms)", 0, 60 * 1000, 100) 237 | 238 | -- create button 239 | obs.obs_properties_add_button(props, "buttonEnforce", "Enforce Scenes Once", function () 240 | enforceScenes("manual") 241 | return true 242 | end) 243 | 244 | obs.obs_properties_apply_settings(ctx.propsDef, ctx.propsSet) 245 | return props 246 | end 247 | 248 | -- script hook: define property defaults 249 | function script_defaults (settings) 250 | obs.script_log(obs.LOG_INFO, string.format("[%s] initialize configuration", os.date("%Y-%m-%d %H:%M:%S"))) 251 | 252 | -- provide default values 253 | obs.obs_data_set_default_string(settings, "textSourceNamePreview", "none") 254 | obs.obs_data_set_default_string(settings, "textSourceNameProgram", "none") 255 | obs.obs_data_set_default_bool(settings, "flagReactAutomatic", false) 256 | obs.obs_data_set_default_int(settings, "numberDelayAutomatic", 10 * 1000) 257 | end 258 | 259 | -- script hook: update state from UI properties 260 | function script_update (settings) 261 | -- remember settings 262 | ctx.propsSet = settings 263 | 264 | -- fetch property values 265 | ctx.propsVal.textSourceNamePreview = obs.obs_data_get_string(settings, "textSourceNamePreview") 266 | ctx.propsVal.textSourceNameProgram = obs.obs_data_get_string(settings, "textSourceNameProgram") 267 | ctx.propsVal.flagReactAutomatic = obs.obs_data_get_bool(settings, "flagReactAutomatic") 268 | ctx.propsVal.numberDelayAutomatic = obs.obs_data_get_int(settings, "numberDelayAutomatic") 269 | 270 | -- log the current configuration 271 | local automatic = "no" 272 | if ctx.propsVal.flagReactAutomatic then 273 | automatic = "yes" 274 | enforceScenes("automatic") 275 | end 276 | obs.script_log(obs.LOG_INFO, 277 | string.format("[%s] update configuration: preview=%s program=%s automatic=%s delay=%d", 278 | os.date("%Y-%m-%d %H:%M:%S"), 279 | ctx.propsVal.textSourceNamePreview, ctx.propsVal.textSourceNameProgram, 280 | automatic, ctx.propsVal.numberDelayAutomatic)) 281 | end 282 | 283 | -- script hook: on script load 284 | function script_load (settings) 285 | -- define hotkeys 286 | ctx.hotkeyIdEnforce = obs.obs_hotkey_register_frontend("enforce_scenes", 287 | "Enforce Scenes Once", function (pressed) 288 | if pressed then 289 | enforceScenes("manual") 290 | end 291 | end) 292 | local hotkeyArrayEnforce = obs.obs_data_get_array(settings, "enforce_scenes_array") 293 | obs.obs_hotkey_load(ctx.hotkeyIdEnforce, hotkeyArrayEnforce) 294 | obs.obs_data_array_release(hotkeyArrayEnforce) 295 | 296 | -- hook into the UI events 297 | obs.obs_frontend_add_event_callback(ctx.onFrontendEvent) 298 | 299 | -- start enforce timer ticker 300 | obs.timer_add(enforceTimerTick, 100) 301 | end 302 | 303 | -- script hook: on script unlod 304 | function script_unload (settings) 305 | -- stop enforce timer ticker 306 | obs.timer_remove(enforceTimerTick) 307 | end 308 | 309 | -- script hook: on script save state 310 | function script_save(settings) 311 | -- save hotkeys 312 | local hotkeyArrayEnforce = obs.obs_hotkey_save(ctx.hotkeyIdEnforce) 313 | obs.obs_data_set_array(settings, "enforce_scenes_array", hotkeyArrayEnforce) 314 | obs.obs_data_array_release(hotkeyArrayEnforce) 315 | end 316 | 317 | -------------------------------------------------------------------------------- /keyboard-event-filter.lua: -------------------------------------------------------------------------------- 1 | --[[ 2 | ** 3 | ** keyboard-event-filter.lua -- OBS Studio Lua Script for Keyboard Event Filter 4 | ** Copyright (c) 2021-2022 Dr. Ralf S. Engelschall 5 | ** Distributed under MIT license 6 | ** 7 | --]] 8 | 9 | -- global OBS API 10 | local obs = obslua 11 | local bit = require("bit") 12 | 13 | -- create obs_source_info structure 14 | local info = {} 15 | info.id = "keyboard_event_filter" 16 | info.type = obs.OBS_SOURCE_TYPE_FILTER 17 | info.output_flags = bit.bor(obs.OBS_SOURCE_VIDEO) 18 | 19 | -- hook: provide name of filter 20 | info.get_name = function () 21 | return "Keyboard Event" 22 | end 23 | 24 | -- hook: provide default settings (initialization before create) 25 | info.get_defaults = function (settings) 26 | -- provide an empty list of keyboard events 27 | local events = obs.obs_data_get_array(settings, "keyboard_events") 28 | if not events then 29 | events = obs.obs_data_array_create() 30 | obs.obs_data_set_array(settings, "keyboard_events", events) 31 | end 32 | end 33 | 34 | -- hook: create filter context 35 | info.create = function (_settings, source) 36 | -- create new filter context object 37 | local filter = {} 38 | filter.source = source 39 | filter.parent = nil 40 | filter.width = 0 41 | filter.height = 0 42 | filter.name = obs.obs_source_get_name(source) 43 | filter.cfg = { 44 | nn2kb = {}, -- property index to keyboard event (2 -> "CTRL+a") 45 | kb2id = {}, -- keyboard event to unique id ("CTRL+a" -> "Keyboard Event: CTRL+a") 46 | id2hk = {}, -- unique id to hotkey number ("Keyboard Event: CTRL+a" -> 47) 47 | id2cb = {} -- unique id to callback function ("Keyboard Event: CTRL+a" -> function...) 48 | } 49 | obs.script_log(obs.LOG_INFO, string.format("create: filter name: \"%s\"", filter.name)) 50 | return filter 51 | end 52 | 53 | -- hook: destroy filter context 54 | info.destroy = function (filter) 55 | -- free resources only (notice: no more logging possible) 56 | filter.source = nil 57 | filter.name = nil 58 | filter.cfg = nil 59 | end 60 | 61 | -- standard keys 62 | local table_keys = { 63 | { key = "ESCAPE", id = "OBS_KEY_ESCAPE", mod = "" }, 64 | { key = "INSERT", id = "OBS_KEY_INSERT", mod = "" }, 65 | { key = "BACKSPACE", id = "OBS_KEY_BACKSPACE", mod = "" }, 66 | { key = "DELETE", id = "OBS_KEY_DELETE", mod = "" }, 67 | { key = "RETURN", id = "OBS_KEY_RETURN", mod = "" }, 68 | { key = "TAB", id = "OBS_KEY_TAB", mod = "" }, 69 | { key = "END", id = "OBS_KEY_END", mod = "" }, 70 | { key = "UP", id = "OBS_KEY_UP", mod = "" }, 71 | { key = "DOWN", id = "OBS_KEY_DOWN", mod = "" }, 72 | { key = "LEFT", id = "OBS_KEY_LEFT", mod = "" }, 73 | { key = "RIGHT", id = "OBS_KEY_RIGHT", mod = "" }, 74 | { key = "HOME", id = "OBS_KEY_HOME", mod = "" }, 75 | { key = "PAGEUP", id = "OBS_KEY_PAGEUP", mod = "" }, 76 | { key = "PAGEDOWN", id = "OBS_KEY_PAGEDOWN", mod = "" }, 77 | { key = "^", id = "OBS_KEY_ASCIICIRCUM", mod = "" }, 78 | { key = "~", id = "OBS_KEY_ASCIITILDE", mod = "" }, 79 | { key = "`", id = "OBS_KEY_FIXME", mod = "" }, 80 | { key = "´", id = "OBS_KEY_FIXME", mod = "" }, 81 | { key = ",", id = "OBS_KEY_COMMA", mod = "" }, 82 | { key = "=", id = "OBS_KEY_EQUAL", mod = "" }, 83 | { key = "+", id = "OBS_KEY_PLUS", mod = "" }, 84 | { key = "-", id = "OBS_KEY_MINUS", mod = "" }, 85 | { key = "#", id = "OBS_KEY_NUMBERSIGN", mod = "" }, 86 | { key = "*", id = "OBS_KEY_ASTERISK", mod = "" }, 87 | { key = "%", id = "OBS_KEY_PERCENT", mod = "" }, 88 | { key = "$", id = "OBS_KEY_DOLLAR", mod = "" }, 89 | { key = "!", id = "OBS_KEY_EXCLAM", mod = "" }, 90 | { key = "_", id = "OBS_KEY_UNDERSCORE", mod = "" }, 91 | { key = "<", id = "OBS_KEY_LESS", mod = "" }, 92 | { key = ">", id = "OBS_KEY_GRATER", mod = "" }, 93 | { key = "[", id = "OBS_KEY_BRACKETLEFT", mod = "" }, 94 | { key = "]", id = "OBS_KEY_BRACKETRIGHT", mod = "" }, 95 | { key = "{", id = "OBS_KEY_BRACELEFT", mod = "" }, 96 | { key = "}", id = "OBS_KEY_BRACERIGHT", mod = "" }, 97 | { key = "(", id = "OBS_KEY_PARENLEFT", mod = "" }, 98 | { key = ")", id = "OBS_KEY_PARENRIGHT", mod = "" }, 99 | { key = ".", id = "OBS_KEY_PERIOD", mod = "" }, 100 | { key = "'", id = "OBS_KEY_APOSTROPHE", mod = "" }, 101 | { key = "\"", id = "OBS_KEY_QUOTEDBL", mod = "" }, 102 | { key = ";", id = "OBS_KEY_SEMICOLON", mod = "" }, 103 | { key = "/", id = "OBS_KEY_SLASH", mod = "" }, 104 | { key = "\\", id = "OBS_KEY_BACKSLASH", mod = "" }, 105 | { key = "?", id = "OBS_KEY_QUESTION", mod = "" }, 106 | { key = "&", id = "OBS_KEY_AMPERSAND", mod = "" }, 107 | { key = ":", id = "OBS_KEY_COLON", mod = "" }, 108 | { key = " ", id = "OBS_KEY_SPACE", mod = "" }, 109 | { key = "@", id = "OBS_KEY_AT", mod = "" }, 110 | { key = "0", id = "OBS_KEY_0", mod = "" }, 111 | { key = "1", id = "OBS_KEY_1", mod = "" }, 112 | { key = "2", id = "OBS_KEY_2", mod = "" }, 113 | { key = "3", id = "OBS_KEY_3", mod = "" }, 114 | { key = "4", id = "OBS_KEY_4", mod = "" }, 115 | { key = "5", id = "OBS_KEY_5", mod = "" }, 116 | { key = "6", id = "OBS_KEY_6", mod = "" }, 117 | { key = "7", id = "OBS_KEY_7", mod = "" }, 118 | { key = "8", id = "OBS_KEY_8", mod = "" }, 119 | { key = "9", id = "OBS_KEY_9", mod = "" }, 120 | { key = "a", id = "OBS_KEY_A", mod = "" }, 121 | { key = "b", id = "OBS_KEY_B", mod = "" }, 122 | { key = "c", id = "OBS_KEY_C", mod = "" }, 123 | { key = "d", id = "OBS_KEY_D", mod = "" }, 124 | { key = "e", id = "OBS_KEY_E", mod = "" }, 125 | { key = "f", id = "OBS_KEY_F", mod = "" }, 126 | { key = "g", id = "OBS_KEY_G", mod = "" }, 127 | { key = "h", id = "OBS_KEY_H", mod = "" }, 128 | { key = "i", id = "OBS_KEY_I", mod = "" }, 129 | { key = "j", id = "OBS_KEY_J", mod = "" }, 130 | { key = "k", id = "OBS_KEY_K", mod = "" }, 131 | { key = "l", id = "OBS_KEY_L", mod = "" }, 132 | { key = "m", id = "OBS_KEY_M", mod = "" }, 133 | { key = "n", id = "OBS_KEY_N", mod = "" }, 134 | { key = "o", id = "OBS_KEY_O", mod = "" }, 135 | { key = "p", id = "OBS_KEY_P", mod = "" }, 136 | { key = "q", id = "OBS_KEY_Q", mod = "" }, 137 | { key = "r", id = "OBS_KEY_R", mod = "" }, 138 | { key = "s", id = "OBS_KEY_S", mod = "" }, 139 | { key = "t", id = "OBS_KEY_T", mod = "" }, 140 | { key = "u", id = "OBS_KEY_U", mod = "" }, 141 | { key = "v", id = "OBS_KEY_V", mod = "" }, 142 | { key = "w", id = "OBS_KEY_W", mod = "" }, 143 | { key = "x", id = "OBS_KEY_X", mod = "" }, 144 | { key = "y", id = "OBS_KEY_Y", mod = "" }, 145 | { key = "z", id = "OBS_KEY_Z", mod = "" }, 146 | { key = "A", id = "OBS_KEY_A", mod = "SHIFT" }, 147 | { key = "B", id = "OBS_KEY_B", mod = "SHIFT" }, 148 | { key = "C", id = "OBS_KEY_C", mod = "SHIFT" }, 149 | { key = "D", id = "OBS_KEY_D", mod = "SHIFT" }, 150 | { key = "E", id = "OBS_KEY_E", mod = "SHIFT" }, 151 | { key = "F", id = "OBS_KEY_F", mod = "SHIFT" }, 152 | { key = "G", id = "OBS_KEY_G", mod = "SHIFT" }, 153 | { key = "H", id = "OBS_KEY_H", mod = "SHIFT" }, 154 | { key = "I", id = "OBS_KEY_I", mod = "SHIFT" }, 155 | { key = "J", id = "OBS_KEY_J", mod = "SHIFT" }, 156 | { key = "K", id = "OBS_KEY_K", mod = "SHIFT" }, 157 | { key = "L", id = "OBS_KEY_L", mod = "SHIFT" }, 158 | { key = "M", id = "OBS_KEY_M", mod = "SHIFT" }, 159 | { key = "N", id = "OBS_KEY_N", mod = "SHIFT" }, 160 | { key = "O", id = "OBS_KEY_O", mod = "SHIFT" }, 161 | { key = "P", id = "OBS_KEY_P", mod = "SHIFT" }, 162 | { key = "Q", id = "OBS_KEY_Q", mod = "SHIFT" }, 163 | { key = "R", id = "OBS_KEY_R", mod = "SHIFT" }, 164 | { key = "S", id = "OBS_KEY_S", mod = "SHIFT" }, 165 | { key = "T", id = "OBS_KEY_T", mod = "SHIFT" }, 166 | { key = "U", id = "OBS_KEY_U", mod = "SHIFT" }, 167 | { key = "V", id = "OBS_KEY_V", mod = "SHIFT" }, 168 | { key = "W", id = "OBS_KEY_W", mod = "SHIFT" }, 169 | { key = "X", id = "OBS_KEY_X", mod = "SHIFT" }, 170 | { key = "Y", id = "OBS_KEY_Y", mod = "SHIFT" }, 171 | { key = "Z", id = "OBS_KEY_Z", mod = "SHIFT" }, 172 | } 173 | 174 | -- standard modifiers 175 | local table_mods = { 176 | { mod = "SHIFT", val = obs.INTERACT_SHIFT_KEY }, 177 | { mod = "CTRL", val = obs.INTERACT_CONTROL_KEY }, 178 | { mod = "ALT", val = obs.INTERACT_ALT_KEY }, 179 | { mod = "CMD", val = obs.INTERACT_COMMAND_KEY }, 180 | } 181 | 182 | -- inject keyboard event 183 | local keyboard_event_inject = function (filter, kb, pressed) 184 | local isPressed = "no" 185 | if pressed then 186 | isPressed = "yes" 187 | end 188 | obs.script_log(obs.LOG_INFO, 189 | string.format("keyboard event: \"%s\" (pressed: %s)", kb, isPressed)) 190 | 191 | -- helper function for splitting a string by separator character 192 | local split = function (str, sep) 193 | local fields = {} 194 | local pattern = string.format("([^%s]+)", sep) 195 | string.gsub(str, pattern, function(c) fields[#fields+1] = c end) 196 | return fields 197 | end 198 | 199 | -- helper function for mapping a modifier name to its value 200 | local lookupModifier = function (name) 201 | for _, row in pairs(table_mods) do 202 | if row.mod == name then 203 | return row.val 204 | end 205 | end 206 | return nil 207 | end 208 | 209 | -- parse keyboard event 210 | local keys = split(kb, "+ ") 211 | local n = table.getn(keys) 212 | if n == 0 then 213 | return 214 | end 215 | local M = 0 216 | local K = nil 217 | local key = keys[n] 218 | for _, row in pairs(table_keys) do 219 | if row.key == key then 220 | local k = obs.obs_key_from_name(row.id) 221 | K = obs.obs_key_to_virtual_key(k) 222 | if row.mod ~= "" then 223 | M = lookupModifier(row.mod) 224 | end 225 | end 226 | end 227 | if K == nil then 228 | obs.script_log(obs.LOG_ERROR, string.format("invalid key: \"%s\"", key)) 229 | return 230 | end 231 | if n > 1 then 232 | for i = 1, n - 1 do 233 | M = bit.bor(M, lookupModifier(keys[i])) 234 | end 235 | end 236 | 237 | -- sanity check context 238 | if filter.parent == nil then 239 | obs.script_log(obs.LOG_WARNING, 240 | "still cannot send keyboard event, because parent source information is still not available") 241 | return 242 | end 243 | 244 | -- send keyboard event to source 245 | local event = obs.obs_key_event() 246 | event.native_vkey = K 247 | event.modifiers = M 248 | event.native_scancode = K 249 | event.native_modifiers = M 250 | event.text = "" 251 | obs.obs_source_send_key_click(filter.parent, event, pressed) 252 | obs.script_log(obs.LOG_INFO, 253 | string.format("keyboard event: sent: vkey %d, modifier %d", K, M)) 254 | end 255 | 256 | -- update hotkeys 257 | local keyboard_event_reconfigure = function (filter, settings) 258 | -- start new a fresh configuration 259 | local cfg = { nn2kb = {}, kb2id = {}, id2hk = {}, id2cb = {} } 260 | 261 | -- determine current keyboard events 262 | local events = obs.obs_data_get_array(settings, "keyboard_events") 263 | local n = obs.obs_data_array_count(events) 264 | for i = 0, n - 1 do 265 | local eventObj = obs.obs_data_array_item(events, i) 266 | local kb = obs.obs_data_get_string(eventObj, "value") 267 | obs.obs_data_release(eventObj) 268 | table.insert(cfg.nn2kb, kb) 269 | local id = string.format("%s: %s", filter.name, kb) 270 | cfg.kb2id[kb] = id 271 | end 272 | obs.obs_data_array_release(events) 273 | 274 | -- remove obsolete keyboard events 275 | for kb, id in pairs(filter.cfg.kb2id) do 276 | if not cfg.kb2id[kb] then 277 | obs.script_log(obs.LOG_INFO, string.format("keyboard event: \"%s\" [removed]", kb)) 278 | local cb = filter.cfg.id2cb[id] 279 | obs.obs_hotkey_unregister(cb) 280 | filter.cfg.kb2id[kb] = nil 281 | filter.cfg.id2hk[id] = nil 282 | filter.cfg.id2cb[id] = nil 283 | end 284 | end 285 | 286 | -- create new keyboard events (or take over existing ones) 287 | for kb, id in pairs(cfg.kb2id) do 288 | if filter.cfg.kb2id[kb] then 289 | -- take over existing one 290 | cfg.id2cb[id] = filter.cfg.id2cb[id] 291 | cfg.id2hk[id] = filter.cfg.id2hk[id] 292 | obs.script_log(obs.LOG_INFO, string.format("keyboard event: \"%s\" [kept]", kb)) 293 | else 294 | -- create new one 295 | cfg.id2cb[id] = function (pressed) 296 | keyboard_event_inject(filter, kb, pressed) 297 | end 298 | cfg.id2hk[id] = obs.obs_hotkey_register_frontend(id, id, cfg.id2cb[id]) 299 | obs.script_log(obs.LOG_INFO, string.format("keyboard event: \"%s\" [created]", kb)) 300 | end 301 | end 302 | 303 | -- replace configuration 304 | filter.cfg = cfg 305 | end 306 | 307 | -- hook: after loading settings 308 | info.load = function (filter, settings) 309 | -- reconfigure (to make configuration available) 310 | keyboard_event_reconfigure(filter, settings) 311 | 312 | -- load hotkeys from settings 313 | for _, id in pairs(filter.cfg.kb2id) do 314 | local a = obs.obs_data_get_array(settings, id) 315 | obs.obs_hotkey_load(filter.cfg.id2hk[id], a) 316 | obs.obs_data_array_release(a) 317 | end 318 | end 319 | 320 | -- hook: before saving settings 321 | info.save = function (filter, settings) 322 | -- reconfigure (to make configuration available) 323 | keyboard_event_reconfigure(filter, settings) 324 | 325 | -- save hotkeys to settings 326 | for _, id in pairs(filter.cfg.kb2id) do 327 | local a = obs.obs_hotkey_save(filter.cfg.id2hk[id]) 328 | obs.obs_data_set_array(settings, id, a) 329 | obs.obs_data_array_release(a) 330 | end 331 | end 332 | 333 | -- hook: provide filter properties 334 | info.get_properties = function (_filter) 335 | -- create properties 336 | local props = obs.obs_properties_create() 337 | obs.obs_properties_add_editable_list(props, 338 | "keyboard_events", "Keyboard Events:", obs.OBS_EDITABLE_LIST_TYPE_STRINGS, "", "") 339 | return props 340 | end 341 | 342 | -- hook: react on filter property update 343 | info.update = function (filter, settings) 344 | -- reconfigure (because keyboard events might have changed) 345 | keyboard_event_reconfigure(filter, settings) 346 | end 347 | 348 | -- hook: render video 349 | info.video_render = function (filter, _effect) 350 | if filter.parent == nil then 351 | filter.parent = obs.obs_filter_get_parent(filter.source) 352 | end 353 | if filter.parent ~= nil then 354 | filter.width = obs.obs_source_get_base_width(filter.parent) 355 | filter.height = obs.obs_source_get_base_height(filter.parent) 356 | end 357 | obs.obs_source_skip_video_filter(filter.source) 358 | end 359 | 360 | -- hook: provide size 361 | info.get_width = function (filter) 362 | return filter.width 363 | end 364 | info.get_height = function (filter) 365 | return filter.height 366 | end 367 | 368 | -- register the filter 369 | obs.obs_register_source(info) 370 | 371 | -- script hook: description displayed on script window 372 | function script_description () 373 | return [[ 374 |

Keyboard Event Filter

375 | 376 | Copyright © 2021-2022 Dr. Ralf S. Engelschall
378 | Distributed under MIT license 380 | 381 |

382 | Define a Keyboard Event filter for sources. This is intended 383 | to map OBS Studio global hotkeys onto keyboard events for 384 | Browser Source sources. 385 |

386 | 387 |

388 | Use it by performing two steps: 389 |

    390 |
  1. 391 | DEFINE: Add the "Keyboard Event" pseudo-effect 392 | filter to your Browser Source based source. Give it 393 | a globally unique name in case you are using this 394 | filter more than once inside your particular OBS 395 | Studio scene/source configuration (because the name 396 | is used as the prefix for the hotkey). Then define 397 | the available keyboard events in its properies. The 398 | syntax for defining keyboard events is "a", 399 | "SHIFT+a", "CTRL+a", "ALT+a" 400 | and "CMD+a". 401 |
  2. 402 |
  3. 403 | MAP: Map global OBS hotkeys onto 404 | the source keyboard events under File 405 | → Settings → Hotkeys. You can 406 | find the keyboard events under the name 407 | "FilterName KeyboardEvent". 408 | For example, Keyboard Event CTRL+a. 409 |
  4. 410 |
411 |

412 | ]] 413 | end 414 | 415 | --------------------------------------------------------------------------------