首页
/ OBS Studio 脚本系统实战指南:Python 与 Lua 脚本 API、生命周期函数与底层实现解析

OBS Studio 脚本系统实战指南:Python 与 Lua 脚本 API、生命周期函数与底层实现解析

2026-09-04 10:20:14作者:丁柯新Fawn

OBS Studio 从 21.0 版本开始内置了完整的脚本子系统,支持 Python 3 和 Luajit 2(约等同于 Lua 5.2),允许用户在不编译任何原生插件模块的情况下扩展功能、添加特性或自动化操作。本文以仓库文档 scripting.rst 为骨架,结合 shared/obs-scripting 中的 C 层实现与 plugins/frontend-tools/data/scripts 中的官方示例脚本,完整讲解脚本的生命周期函数、定时器机制、Lua 专属的源码注册能力,以及与 C API 的回调差异——读完后你可以独立编写可热加载/热重载的 OBS 脚本,并理解其背后逐帧调度、回调管理与引用释放的实现细节。

脚本入口与运行环境

脚本功能在 OBS Studio 的 Tools(工具)菜单 → Scripts(脚本) 中访问,会弹出脚本对话框。该对话框允许在程序运行期间实时添加、删除和重载脚本,对应前端工具插件中的 Add Scripts / Remove Scripts / Reload Scripts 按钮(本地化条目见 az-AZ.ini)。

脚本能力由 frontend-tools 插件集成,其构建入口直接挂载了脚本子系统:CMakeLists.txt 中有一行 add_subdirectory("${CMAKE_SOURCE_DIR}/shared/obs-scripting" ...),即 shared/obs-scripting/CMakeLists.txt 定义的脚本模块。

注意事项(原文档 NOTE 的完整保留):

  • 所有 API 绑定通过 Python 的 obspython 模块和 Lua 的 obslua 模块提供;
  • 在 Windows 或 macOS 上使用 Python 时,必须下载并安装与 OBS 架构匹配的 Python 版本,然后在脚本对话框的 "Python Settings" 标签页中设置 Python 安装路径。源码中同样体现了这一约束:obs-scripting.cobs_scripting_load() 中,只有非 Windows 且非 macOS 平台才会自动调用 obs_scripting_load_python(NULL),Win32 和 macOS "need user-provided Python library paths";
  • 部分 C API 函数因回调机制被重新实现,即原文档 "Other Differences From the C API" 一节,详见本文 与 C API 的差异 一节。

警告(原文档 WARNING 完整保留): 由于绑定覆盖了整个 API,编写不当的脚本可能泄漏内存甚至使程序崩溃。编写脚本时应保持谨慎,并检查日志文件中的内存泄漏计数器,确认你的脚本没有泄漏内存。请像编写 C 程序一样对待 API 绑定:阅读你所使用函数的文档,并释放/销毁你通过 API 引用或创建的每一个对象。

支持的语言与脚本类型分派

obs-scripting.c 可以看到支持格式列表由编译期宏决定:

static const char *supported_formats[] = {
#if defined(LUAJIT_FOUND)
	"lua",
#endif
#if defined(Python_FOUND)
	"py",
#endif
	NULL};

obs_script_create()obs-scripting.c#L245-L274)按文件扩展名分派:.lua 交给 obs_lua_script_create().py 交给 obs_python_script_create(),未知扩展名会记录 Unsupported/unknown script type 警告。这也解释了为什么 "Script Sources" 一节标注为 Lua Only——注册自定义 source 的能力只在 Luajit 分支中实现(obs-scripting-lua-source.c)。

脚本生命周期函数(Script Function Exports)

脚本可以可选地提供以下全局函数,它们构成脚本的完整生命周期。下表逐一说明(继承原文档全部条目):

函数 调用时机 说明
script_description() 加载时 返回显示在脚本窗口中给用户的描述字符串
script_load(settings) 脚本启动时 携带与脚本关联的 settings 调用。该参数通常不用于用户设置的值,而是用于脚本内部的额外设置数据
script_unload() 脚本卸载时 用于清理资源
script_save(settings) 脚本保存时 同样不用于用户设置,而是保存脚本内部数据
script_defaults(settings) 加载早期 设置脚本的默认设置(如有),典型做法是对 settings 调用 obs_data_set_default_* 系列函数
script_update(settings) 用户修改脚本设置后 响应设置变更
script_properties() 需要展示设置界面时 定义脚本的用户属性,返回通过 obs_properties_create() 创建的 obs_properties_t 对象
script_tick(seconds) 每帧 用于逐帧处理;seconds 为距上一帧的秒数。若只需要基础计时功能,文档建议使用更高效的定时器(见下文);在 Python 中不建议使用该函数,因为存在全局解释器锁(GIL)问题

C 层的调用顺序可以直接在 Lua 加载流程中得到印证。obs-scripting-lua.cload_lua_script() 中:

  1. 查找 script_propertiesscript_updatescript_save 全局函数并保存引用(找不到则记为 LUA_REFNIL);
  2. 若存在 script_defaults(settings),先调用它(传入脚本的 settings 对象);
  3. 若存在 script_description(),调用并缓存返回值到 data->base.desc
  4. 最后调用 script_load(settings)

卸载路径同样严谨:obs_lua_script_unload() 会先标记所有回调为 removed(防止卸载期间回调继续触发),再撤销已注册的 source 类型、摘除 tick 钩子、调用 script_unload、移除全部回调,最后 lua_close() 解释器状态。

获取当前脚本路径:script_path()

存在一个内置函数可获取当前脚本的绝对路径。该函数在脚本加载之前自动注入到每个脚本中,属于脚本自身命名空间的一部分,不属于 obslua/obspython 模块。其 Lua 注入模板见 obs-scripting-lua.c#L49-L54

static const char *get_script_path_func = "\
function script_path()\n\
	 return \"%s\"\n\
end\n\
package.cpath = package.cpath .. \";\" .. script_path() .. \"/?." SO_EXT "\"\n\
package.path = package.path .. \";\" .. script_path() .. \"/?.lua\"\n";

script_path() 之外,OBS 还会把脚本所在目录加入 package.path / package.cpath,因此脚本可以 require 同目录下的其他 Lua 模块或加载同目录的动态库。脚本文件、目录与描述等信息保存在 obs_script_t 中,可通过 obs_script_get_path() 等函数读取;obs_lua_script_create()obs-scripting-lua.c#L1111-L1142)会从路径中拆出文件名(file)和目录(dir)。

脚本定时器(Script Timers)

脚本定时器提供了一种高效的计时回调方式,无需每帧都锁定脚本解释器。这两个函数属于 obspython/obslua 模块/命名空间:

  • timer_add(callback, milliseconds):添加一个每 milliseconds 毫秒触发一次的计时回调。注意:不支持实例方法作为回调,必须使用模块级方法(module methods)
  • timer_remove(callback):移除一个计时回调。也可以在计时回调内部使用 remove_current_callback() 终止该定时器。

底层实现是理解其"高效"的关键。每个脚本加载时,全局注册一个逐帧钩子:obs_lua_load() 末尾执行 obs_add_tick_callback(lua_tick, NULL)lua_tick()obs-scripting-lua.c#L1039-L1091)每帧做两件事:

  1. 处理 script_tick:遍历所有定义了 script_tick 的脚本链表(first_tick_script),逐个加锁调用;
  2. 处理定时器:遍历所有脚本注册的计时器链表(first_timer),基于 obs_get_video_frame_time() 的时间戳做纯 C 层比较 elapsed >= interval只有到期才进入 Lua 解释器调用回调。

计时器的注册(timer_addobs-scripting-lua.c#L308-L324)将毫秒间隔换算为纳秒存于 timer->interval,初始基准时间取当前视频帧时间,注册操作本身通过 defer_call_post() 投递到脚本子系统自己的延迟线程执行,避免在回调上下文中直接修改链表。

这就是文档建议优先使用定时器而非 script_tick 的源码级原因:定时器让解释器锁只在回调真正触发时才被获取,而 script_tick 则是每帧都进入解释器。

Script Sources:Lua 中注册自定义 Source

Lua 脚本可以注册自定义 source。做法是创建一个 table,并按 C API 中 obs_source_info 结构体的方式定义其字段:

local info = {}
info.id = "my_source_id"
info.type = obslua.OBS_SOURCE_TYPE_INPUT
info.output_flags = obslua.OBS_SOURCE_VIDEO

info.get_name = function()
        return "My Source"
end

info.create = function(settings, source)
        -- 通常把 source 数据保存为一个 table
        local my_source_data = {}

        [...]

        return my_source_data
end

info.video_render = function(my_source_data, effect)
        [...]
end

info.get_width = function(my_source_data)
        [...]

        -- 假设 source data 中包含 'width' 键
        return my_source_data.width
end

info.get_height = function(my_source_data)
        [...]

        -- 假设 source data 中包含 'height' 键
        return my_source_data.height
end

-- 注册该 source
obs_register_source(info)

C 层的 obs_lua_register_source()obs-scripting-lua-source.c#L555-L678)印证了这段示例的工作方式:

  • 从 table 中读取必填项 id(字符串)、typeobs_source_type 枚举)、output_flags(整数),并调用 get_name() 得到显示名称;
  • 通过 get_callback 宏批量读取可选回调:createdestroyget_widthget_heightget_propertiesupdateactivatedeactivateshowhidevideo_tickvideo_rendersaveload,另加 get_defaults;每个回调被 luaL_ref 固定在 LUA_REGISTRYINDEX,保证在解释器存活期间函数引用不丢失;
  • 最终填充 C 的 obs_source_info 并调用 obs_register_source(&info),同时 obs_module_add_source() 把该 id 挂到当前模块名下,使脚本卸载时能被正确清理;
  • 支持重定义(redefinition):若同一 id 已注册且来自已卸载的脚本(existing->script == NULL),会复用旧定义、重新调用各实例的 create 回调,即脚本热重载后已存在的 source 实例可以"续接"。

卸载时的清理由 undef_lua_script_sources()obs-scripting-lua-source.c#L713-L725)完成:对属于该脚本的每个 source 定义,obs_enable_source_type(id, false) 禁用类型,逐一调用各实例的 destroy 回调并释放注册引用。这也意味着脚本卸载后,已创建的该类型 source 实例会被安全销毁,而不会留下悬挂引用。

注意 create 回调返回的 Lua table 会被保存在注册表(lua_data_ref),作为该 source 实例的用户数据,后续 get_width/video_render 等回调都以它作为第一参数——这与示例中 my_source_data 的用法完全一致。

与 C API 的差异:回调机制带来的差异函数

由于回调的工作方式不同,以下函数与 C API 的实现存在差异。它们都属于 obspython/obslua 模块/命名空间(原文档 all 条目完整继承):

  • obs_enum_sources():枚举所有 source。返回引用计数已增加的 source 数组,须用 source_list_release() 释放;
  • obs_scene_enum_items(scene):枚举场景中所有 item。参数为 obs_scene_t 对象,返回 scene item 列表,用 sceneitem_list_release() 释放;
  • obs_sceneitem_group_enum_items(group):枚举组内所有 item。参数为 obs_sceneitem_t 对象,返回列表同样用 sceneitem_list_release() 释放;
  • obs_add_main_render_callback(callback)仅 Lua):添加主输出渲染回调,回调无参数。用 obs_remove_main_render_callback()remove_current_callback() 移除;
  • obs_remove_main_render_callback(callback)仅 Lua):移除主输出渲染回调;
  • signal_handler_connect(handler, signal, callback):向信号处理器的特定信号添加回调。回调只有一个参数:calldata_t 对象。用 signal_handler_disconnect()remove_current_callback() 移除;
  • signal_handler_disconnect(handler, signal, callback):从信号处理器的特定信号移除回调;
  • signal_handler_connect_global(handler, callback):向信号处理器添加全局回调。回调有两个参数:信号字符串和 calldata_t 对象。用 signal_handler_disconnect_global()remove_current_callback() 移除;
  • signal_handler_disconnect_global(handler, callback):从信号处理器移除全局回调;
  • obs_hotkey_register_frontend(name, description, callback):注册前端热键。回调有一个布尔参数 pressedname 是热键的唯一名称标识,description 是展示给用户的描述。用 obs_hotkey_unregister()remove_current_callback() 移除回调;
  • obs_hotkey_unregister(callback):注销与指定回调关联的热键;
  • obs_properties_add_button(properties, setting_name, text, callback):向 obs_properties_t 对象添加按钮属性。回调有两个参数:obs_properties_t 对象和按钮的 obs_property_t。该回调会被自动清理;
  • remove_current_callback():移除当前正在执行的回调;若不在回调上下文中调用则什么都不做;
  • source_list_release(source_list):释放 source 列表的引用。参数为 source 数组;
  • sceneitem_list_release(item_list):释放 scene item 列表的引用。参数为 scene item 数组;
  • calldata_source(calldata, name):把 calldata_t 对象的指针参数转换为 obs_source_t 对象。返回借用引用(borrowed reference);
  • calldata_sceneitem(calldata, name):把 calldata_t 对象的指针参数转换为 obs_sceneitem_t 对象。返回借用引用。

这些函数在 Lua 侧的注册点集中在 add_hook_functions()obs-scripting-lua.c#L988-L1035),全部挂到 obslua 全局表下。几个值得注意的实现细节:

  • 引用语义enum_sourcesobs-scripting-lua.c#L567-L584)在枚举回调里对每个 source 执行 obs_source_get_ref() 再压栈,与文档"reference-incremented sources"完全对应;source_list_release()#L896-L909)则遍历 Lua 表逐项 obs_source_release()calldata_source() / calldata_sceneitem() 则以 ownership = false 压栈,实现"借用引用"语义——脚本侧不能释放它们;
  • remove_current_callback:基于线程局部变量 current_lua_cb#L235-L236)实现,回调执行前由运行时设置该指针,因此可以在回调内部安全地移除自身(典型用途即终止定时器或信号回调);
  • 热键的延迟注销obs_hotkey_register_frontend() 返回热键 id 存入回调元数据;当回调被移除(如脚本卸载)时,on_remove_hotkey()defer_call_post(defer_hotkey_unregister, id),把真正的 obs_hotkey_unregister() 放到延迟线程执行,避免在回调上下文中同步注销导致的重入问题(#L650-L704);
  • 信号连接也是延迟的signal_handler_connect() 先在回调元数据 calldata 中记录 handler 与 signal 字符串,再 defer_call_post(defer_connect, cb),由延迟线程完成实际的 signal_handler_connect() 注册(#L468-L495)。

延迟调用线程:脚本子系统的核心基础设施

上述"延迟注册"能力来自 obs-scripting.c 中的一套基础设施:一个名为 scripting: defer 的专用线程(os_set_thread_name),配一个无锁队列(deque + 互斥锁)与信号量。所有脚本触发的、不能安全地即时执行的 OBS 调用(注册信号、注册热键、添加 tick/render 回调、注销热键等)都被打包成 struct defer_call 投递到该线程串行执行。obs_scripting_unload()#L171-L226)在退出前会统计并日志输出 [Scripting] Total detached callbacks: %d——这为排查脚本回调残留提供了日志依据。

另外,脚本的日志也被接管:Lua 的全局 print 被替换为 hook_print(写入脚本信息日志)、error 被替换为 hook_error#L988-L1004),obslua.script_log(level, message) 则支持按级别输出(lua_script_log#L952-L984),多行消息会逐行拆分写入日志。

实战示例:官方倒计时脚本

仓库自带了完整的参考实现 countdown.lua(同目录还有 clock-source.luainstant-replay.luapause-scene.lua 以及 Python 示例 url-text.py)。它把前述所有机制组合在一起,是一个极好的学习模板:

  • script_properties():创建属性集,添加 duration(整数 1–100000 分钟)、通过 obs_enum_sources() 枚举文本 source 填充可编辑下拉列表(注意循环结束后调用 obs.source_list_release(sources) 释放引用),添加 stop_text 文本框,以及带回调 reset_button_clicked 的"Reset Timer"按钮(按钮回调接收 props, p 两个参数,返回布尔值决定是否继续传播);
  • script_defaults(settings)obs_data_set_default_int(settings, "duration", 5) 等,即文档中推荐的默认值设置方式;
  • script_update(settings):设置变更时重新读取 duration/source/stop_text 并重置计时器;
  • script_save / script_load 配对保存热键:save 时 obs_hotkey_save(hotkey_id) 得到数组并存入 settings,load 时 obs_hotkey_load() 恢复——这正是文档所说 script_save 用于"用户设置之外的内部数据"的典型场景(用户通过属性界面设置的值会被自动保存);
  • 信号回调script_load 中通过 obs_get_signal_handler() 获取全局信号处理器,signal_handler_connect(sh, "source_activate", ...) / "source_deactivate" 监听 source 激活/停用,并用 calldata_source(cd, "source") 从 calldata 中取出被操作的 source(借用引用,用后 obs_source_release 其查找引用即可,calldata 中的引用本身不需释放);
  • 定时器 + 自移除timer_callback 中秒数归零时调用 obs.remove_current_callback() 终止定时器——remove_current_callback 在回调内使用正是文档推荐的方式;
  • 热键obs_hotkey_register_frontend("reset_timer_thingy", "Reset Timer", reset),回调参数即文档所述布尔 pressed

该脚本注释还特别指出:这些随脚本生命周期的回调"不一定要手动断开,脚本卸载时回调会自动销毁"——与 obs_lua_script_unload() 中卸载前统一移除所有回调的实现一致。

内存与稳定性:像写 C 一样写脚本

文档的 WARNING 在源码层面有明确的技术背景:SWIG 生成的绑定(obslua.iobspython.i)把 libobs 的整个 C API 暴露给了脚本,每个 API 返回的引用都需要按 C 的引用计数规则手动释放。对照 obs-scripting.c#L245-L359obs_script_get_settings()obs_script_save() 返回的都是 obs_data_addref 后的引用,调用方必须 obs_data_release。官方示例中每一处 obs_get_source_by_name 都严格配对 obs_source_release(如 countdown.lua 的 set_time_text()countdown.lua#L25-L32)。

编写脚本时的实践要点(均来自文档与源码印证的事实):

  1. 每次 obs_get_source_by_name / obs_enum_sources / obs_scene_enum_items 获得的所有 source、scene item 引用,用完必须 release;枚举返回列表用对应的 *_list_release() 整体释放;
  2. calldata_source / calldata_sceneitem 获得的是借用引用,不要释放;
  3. 定时器/信号/热键回调若需长期存活,交给脚本卸载时的自动清理即可;若需提前终止,用 remove_current_callback() 或对应的 *_remove/*_disconnect/*_unregister
  4. 需要周期性任务优先 timer_add,而非 script_tick;Python 脚本尤其避免 script_tick(GIL 开销);
  5. 观察日志中的内存泄漏计数器,验证脚本没有引用泄漏。

小结

OBS Studio 的脚本子系统(21.0+)以 frontend-tools 插件为入口,以 shared/obs-scripting 为运行时核心:按扩展名分派 Python/Lua 解释器,为每个脚本提供独立的解释状态与互斥锁,用专门的 defer 线程串行化回调注册等敏感操作,并在全局 tick 中调度 script_tick 与定时器。脚本作者通过 script_description/script_load/script_defaults/script_properties/script_update/script_save/script_unload/script_tick 八个可选函数控制生命周期,通过 timer_add 实现低开销计时,Lua 脚本还可注册完整的自定义 source 类型。理解 scripting.rst 所列的差异函数及其引用语义,再配合 countdown.lua 这类官方示例,即可开始编写安全、可维护、可热重载的 OBS 脚本。

登录后查看全文
热门项目推荐
相关项目推荐