-- obs-csv-lower-third.lua
-- CSV-driven lower third for OBS Studio
--
-- 场景:活动直播有几百个 lower third(姓名/职位/单位),
-- 以前用 Livestream Studio 可以导入 CSV 选行,OBS 没有这个能力。
--
-- 做法:读一个 CSV 文件 → 在脚本属性里生成下拉框 → 选中某行 → 把该行字段写进文本源。
--
-- 安装:工具 → 脚本 → + → 选本文件 → 选 CSV 文件路径 → 选文本源 → 刷新 → 从下拉框选一行
--
-- API 键名均经 obslua 模块枚举 + 官方脚本交叉确认,无猜测。
obs = obslua
csv_path = ""
source_name = ""
has_header = true
separator = ","
template = "{name} — {role}"
idle_text = ""
row_index = 1
reload_id = obs.OBS_INVALID_HOTKEY_ID
local last_text = ""
-- ---------------------------------------------------------------------------
-- CSV 解析(支持引号包裹的字段,含逗号/换行)
-- ---------------------------------------------------------------------------
local function parse_csv(text, sep)
local rows, row, field = {}, {}, {}
local in_quotes = false
local i = 1
local n = #text
while i <= n do
local c = text:sub(i, i)
if in_quotes then
if c == '"' then
if text:sub(i+1, i+1) == '"' then
field[#field+1] = '"'
i = i + 2
else
in_quotes = false
i = i + 1
end
else
field[#field+1] = c
i = i + 1
end
else
if c == '"' then
in_quotes = true
i = i + 1
elseif c == sep then
row[#row+1] = table.concat(field)
field = {}
i = i + 1
elseif c == "\r" then
i = i + 1
elseif c == "\n" then
row[#row+1] = table.concat(field)
field = {}
rows[#rows+1] = row
row = {}
i = i + 1
else
field[#field+1] = c
i = i + 1
end
end
end
if #field > 0 or #row > 0 then
row[#row+1] = table.concat(field)
rows[#rows+1] = row
end
return rows
end
local function read_file(path)
local f = io.open(path, "rb")
if not f then return nil end
local data = f:read("*a")
f:close()
-- 去掉 UTF-8 BOM
if data:sub(1,3) == "\239\187\191" then data = data:sub(4) end
return data
end
local cached_rows = nil
local cached_headers = nil
local function load_csv()
cached_rows, cached_headers = nil, nil
if csv_path == "" then return end
local data = read_file(csv_path)
if not data then return end
local rows = parse_csv(data, separator)
if #rows == 0 then return end
if has_header then
cached_headers = rows[1]
local body = {}
for i = 2, #rows do body[#body+1] = rows[i] end
cached_rows = body
else
cached_rows = rows
cached_headers = nil
end
end
-- ---------------------------------------------------------------------------
local function set_text(text)
if text == last_text then return end
local source = obs.obs_get_source_by_name(source_name)
if source ~= nil then
local s = obs.obs_data_create()
obs.obs_data_set_string(s, "text", text)
obs.obs_source_update(source, s)
obs.obs_data_release(s)
obs.obs_source_release(source)
end
last_text = text
end
-- 用 {列名} 或 {1} {2} 渲染模板
local function render(row)
if not row then return idle_text end
local out = template
-- 按表头名替换
if cached_headers then
for i, name in ipairs(cached_headers) do
local v = row[i] or ""
out = out:gsub("{" .. name .. "}", v)
out = out:gsub("{" .. i .. "}", v)
end
end
-- 没有表头时按序号
for i = 1, #row do
out = out:gsub("{" .. i .. "}", row[i] or "")
end
return out
end
local function apply()
if not cached_rows then load_csv() end
if not cached_rows or #cached_rows == 0 then
if idle_text ~= "" then set_text(idle_text) end
return
end
local idx = math.max(1, math.min(row_index, #cached_rows))
set_text(render(cached_rows[idx]))
end
-- ---------------------------------------------------------------------------
function script_description()
return "从 CSV 文件驱动的 lower third。\n\n" ..
"几百条姓名/职位不用手打:读 CSV,下拉选行,文本源自动更新。\n\n" ..
"模板占位符:{列名}(有表头时)或 {1} {2} {3}(按列序)"
end
function script_properties()
local props = obs.obs_properties_create()
obs.obs_properties_add_path(props, "csv_path", "CSV 文件",
obs.OBS_PATH_FILE, "CSV (*.csv);;所有文件 (*.*)", nil)
obs.obs_properties_add_bool(props, "has_header", "第一行是表头")
obs.obs_properties_add_text(props, "separator", "分隔符", obs.OBS_TEXT_DEFAULT)
local p = obs.obs_properties_add_list(props, "source", "文本源",
obs.OBS_COMBO_TYPE_EDITABLE, obs.OBS_COMBO_FORMAT_STRING)
local sources = obs.obs_enum_sources()
if sources ~= nil then
for _, source in ipairs(sources) do
local sid = obs.obs_source_get_unversioned_id(source)
if sid == "text_gdiplus" or sid == "text_ft2_source" then
obs.obs_property_list_add_string(p, obs.obs_source_get_name(source), obs.obs_source_get_name(source))
end
end
end
obs.source_list_release(sources)
obs.obs_properties_add_text(props, "template", "模板", obs.OBS_TEXT_DEFAULT)
obs.obs_properties_add_text(props, "idle_text", "无数据时显示(留空=不动)", obs.OBS_TEXT_DEFAULT)
-- 行下拉框
local rp = obs.obs_properties_add_list(props, "row_index", "选择行",
obs.OBS_COMBO_TYPE_LIST, obs.OBS_COMBO_FORMAT_INT)
if cached_rows then
for i, row in ipairs(cached_rows) do
local label = table.concat(row, " | ")
if #label > 70 then label = label:sub(1, 70) .. "…" end
obs.obs_property_list_add_int(rp, string.format("%d. %s", i, label), i)
end
end
obs.obs_properties_add_button(props, "reload", "重新读取 CSV", function()
load_csv()
return true
end)
return props
end
function script_defaults(settings)
obs.obs_data_set_default_bool(settings, "has_header", true)
obs.obs_data_set_default_string(settings, "separator", ",")
obs.obs_data_set_default_string(settings, "template", "{1} — {2}")
obs.obs_data_set_default_string(settings, "idle_text", "")
obs.obs_data_set_default_int(settings, "row_index", 1)
end
function script_update(settings)
csv_path = obs.obs_data_get_string(settings, "csv_path")
source_name = obs.obs_data_get_string(settings, "source")
has_header = obs.obs_data_get_bool(settings, "has_header")
separator = obs.obs_data_get_string(settings, "separator")
template = obs.obs_data_get_string(settings, "template")
idle_text = obs.obs_data_get_string(settings, "idle_text")
row_index = obs.obs_data_get_int(settings, "row_index")
if separator == "" then separator = "," end
load_csv()
last_text = "\0"
apply()
end
function script_save(settings)
local a = obs.obs_hotkey_save(reload_id)
obs.obs_data_set_array(settings, "reload_hotkey", a)
obs.obs_data_array_release(a)
end
function script_load(settings)
reload_id = obs.obs_hotkey_register_frontend("csv_lt_reload", "CSV lower third: 重新读取", function(pressed)
if not pressed then return end
load_csv()
last_text = "\0"
apply()
end)
local a = obs.obs_data_get_array(settings, "reload_hotkey")
obs.obs_hotkey_load(reload_id, a)
obs.obs_data_array_release(a)
print("[obs-csv-lower-third] loaded ok")
end
function script_unload() end