New user looking for text help

Seehorse

New Member
I webcast events and I have a lower third graphic for each individual class at the event, this list is several hundred entries long. I've been using Livestream Studio which allows me to import a .csv file of all the graphics into a DSK but due to out of control pricing with Vimeo I've changed platforms and have to look at another program.

As I mentioned there will be hundreds of individual text lower third graphics that will be used during the course of a webcast. I can't figure out a way to do anything but individually type in each title which is not an option. I need to be able to import the spreadsheet and quickly select a given row for a class, then when that class is finished I need to change to the next row. Is there an easy way to accomplish this?

Seehorse
 
I webcast events and I have a lower third graphic for each individual class at the event, this list is several hundred entries long. I've been using Livestream Studio which allows me to import a .csv file of all the graphics into a DSK but due to out of control pricing with Vimeo I've changed platforms and have to look at another program.

As I mentioned there will be hundreds of individual text lower third graphics that will be used during the course of a webcast. I can't figure out a way to do anything but individually type in each title which is not an option. I need to be able to import the spreadsheet and quickly select a given row for a class, then when that class is finished I need to change to the next row. Is there an easy way to accomplish this?

Seehorse
You might have to explain this a little more.
 
Text source in OBS has a "Read from file" option that refreshes live whenever the file changes. So what I'd do: write a small Python script that reads your CSV, lets you hit a key to advance rows, and writes the current entry to a .txt file -- OBS picks it up automatically. About 20 lines of code but once it's running it's actually faster than clicking through a DSK.
 
Text source in OBS has a "Read from file" option that refreshes live whenever the file changes. So what I'd do: write a small Python script that reads your CSV, lets you hit a key to advance rows, and writes the current entry to a .txt file -- OBS picks it up automatically. About 20 lines of code but once it's running it's actually faster than clicking through a DSK.
It sounds like you have solved the problem. Maybe you can share with Seehorse
 
sure, here's a minimal version. save as advance_lower.py, run from Terminal:

import csv, sys

rows = list(csv.reader(open('your_file.csv')))
i = 0
while i < len(rows):
open('lower_third.txt', 'w').write(', '.join(rows))
print(f'[{i+1}/{len(rows)}] {rows}')
input('Enter for next...')
i += 1

In OBS, add a Text source, check "Read from file", point it at lower_third.txt. Every time you hit Enter the text updates live. Works fine on Mac, python3 is pre-installed so no extra setup.
 
sure, here's a minimal version. save as advance_lower.py, run from Terminal:

import csv, sys

rows = list(csv.reader(open('your_file.csv')))
i = 0
while i < len(rows):
open('lower_third.txt', 'w').write(', '.join(rows))
print(f'[{i+1}/{len(rows)}] {rows}')
input('Enter for next...')
i += 1

In OBS, add a Text source, check "Read from file", point it at lower_third.txt. Every time you hit Enter the text updates live. Works fine on Mac, python3 is pre-installed so no extra setup.
I'm afraid I know nothing about programming, I copy this into notepad, save it as the advance.py and then open terminal and type that file name? Is there a way to search and go backwards in the list?

Frequently we have classes cancelled at the last minute and need to skip one and it's not unheard of to have them out of order, not to mention user errors in skipping the wrong one.
 
You might have to explain this a little more.
Ok, there will be anywhere from 150 to 400 individual lower thirds graphics of class titles during the course of the show. I have a spreadsheet of all of the titles with each class having one row of the spreadsheet. When a class enters the ring we put up that class title and change it when the next class enters.

The complication comes in when the show adds classes to the schedule or occasionally shifts the order of the class. Up to now I've used Livestream Studio 6 from Vimeo and I simply import the .csv into one of the GFX downstream keys and it monitors the source file for any changes. The GFX keyer gives me a scrolling list of the entire spreadsheet and allows me to select which row to display, I simply click the correct class title and it puts up that graphic. When the next class comes in I simply click on the next row and it changes to the next class.

That's the kind of thing I really need; unfortunately Vimeo was charging me over $13k per year for streaming so I had to look for another provider and Livestream Studio is locked to Vimeo; it phones home for authorization.
 
Ok, there will be anywhere from 150 to 400 individual lower thirds graphics of class titles during the course of the show. I have a spreadsheet of all of the titles with each class having one row of the spreadsheet. When a class enters the ring we put up that class title and change it when the next class enters.

The complication comes in when the show adds classes to the schedule or occasionally shifts the order of the class. Up to now I've used Livestream Studio 6 from Vimeo and I simply import the .csv into one of the GFX downstream keys and it monitors the source file for any changes. The GFX keyer gives me a scrolling list of the entire spreadsheet and allows me to select which row to display, I simply click the correct class title and it puts up that graphic. When the next class comes in I simply click on the next row and it changes to the next class.

That's the kind of thing I really need; unfortunately Vimeo was charging me over $13k per year for streaming so I had to look for another provider and Livestream Studio is locked to Vimeo; it phones home for authorization.
if you have an excel spreadsheet, excel has vba to automate things. There is a vba/visual basic forum with people that can help write a macro to do this. A simple button on the row could just write the title to the common title text file. There is a"looking for dev " , column, ie , dev for hire. here is an invite. Hope this helps.

 
Since you mentioned you're not into programming, skip the Python route. Look into H2R Graphics, it's a free OBS plugin that does exactly what you're describing. You point it at a CSV, it gives you a clickable list of lower thirds, and you just pick whichever row you need live. Handles out-of-order selection too so cancelled classes aren't a problem. Way closer to the Livestream Studio GFX keyer workflow you're used to.
 
Since you mentioned you're not into programming, skip the Python route. Look into H2R Graphics, it's a free OBS plugin that does exactly what you're describing. You point it at a CSV, it gives you a clickable list of lower thirds, and you just pick whichever row you need live. Handles out-of-order selection too so cancelled classes aren't a problem. Way closer to the Livestream Studio GFX keyer workflow you're used to.
I'll give that a try, from looking at the webpage it looks like it might just answer my problems. Thanks for your help.

Scott Cooper
Seehorse Video
 
This is very doable in OBS, and it's a good fit for a Lua script — no plugin build needed.

The approach: a script that reads a CSV file, populates a dropdown in the script properties with the row names, and writes the selected row's fields into a text source. You pick a class from the dropdown, the lower third updates. When that class finishes, you pick the next one. The CSV stays on disk, so you regenerate it in Excel for each event.

Two things I'd want to pin down before building it:

  • How many fields per row? Typical lower third is 2-3 (name, role/affiliation, maybe a second line). If yours is more complex, I'd rather know now than guess.
  • Do you need the graphic itself to change, or just the text? If the layout is fixed and only the words change, this is straightforward. If each row also needs a different logo or background image, that's a different (still doable) shape.

Also worth asking: when you say "quickly select a given row" — is a dropdown in the script properties fast enough, or do you need it bound to a hotkey / an external control surface so you can switch without alt-tabbing to the OBS window during a live event? That distinction matters a lot for something you're driving live in front of an audience.

If you can paste 2-3 anonymised rows of your CSV (just the structure, no real names needed), I can tell you concretely whether this is a half-day job or a bigger one — and I'll write the script for you either way.

For what it's worth, this is exactly the kind of gap that makes people stay on expensive platforms: OBS can do the video, but the bulk-data workflow is missing. A small script closes it.
 
I went ahead and built it. Full script below — save as obs-csv-lower-third.lua.

How it works: point it at your CSV, tell it whether the first row is a header, pick your text source, hit "reload CSV", and the row dropdown fills with every entry. Selecting a row writes that row into the text source. You drive the whole show from one dropdown, and you regenerate the CSV in Excel per event — no retyping titles ever.

Template placeholders: if you have a header row you can write {name} — {role} using your actual column names, or {1} — {2} by column order if you don't.

It handles quoted CSV fields (commas and newlines inside quotes), and strips the UTF-8 BOM that Excel likes to add — that BOM is a classic cause of "why is my first column name broken".

Setup: Tools -> Scripts -> + -> pick the .lua file -> set CSV path -> choose Text (GDI+) source -> click "reload CSV" -> pick a row.

On the two questions I asked earlier, I made reasonable defaults rather than blocking on them:
- Field count: any number of columns works; the template decides what shows.
- Text-only vs image changes: this version is text-only. If each class also needs a different logo or background, tell me and I'll extend it — that's a separate, doable step.

Still worth knowing: is a dropdown fast enough for you during a live event, or do you need hotkeys to step through rows without alt-tabbing to the OBS window? If it's the latter I'll add next/previous hotkeys in about ten minutes.

What I verified: syntax against OBS's own lua51.dll, and every API key confirmed by enumerating the obslua module rather than guessing. Not yet run in a live OBS session on my machine, so if anything misbehaves post the log and I'll fix it.

Try it and tell me what's missing.

Code:
-- 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
 
Back
Top