--[[
PKTOK overlay setup for OBS Studio
---------------------------------
Paste the overlay link from your PKTOK console, press one button, and the browser source is
created with the settings that people otherwise get wrong by hand: the right canvas size, a
transparent background, "shutdown when not visible" off, and browser audio routed through OBS.
Load it in OBS: Tools -> Scripts -> "+" -> pick this file. Nothing is installed, nothing is
sent anywhere: the script only talks to OBS on this machine.
Docs: https://pktok.com/obs-plugin/
Source of truth for this file: tools/obs/pktok-obs-setup.lua in the LiveTok/PKTOK repo.
⚠️ SECURITY RULE FOR ANYONE EDITING THIS FILE: the overlay link contains an access token, and
OBS log files are routinely uploaded to the OBS forum's log analyzer when people ask for help.
Never pass a raw link to script_log() — always run it through mask().
]]
obs = obslua
local VERSION = "1.0.0"
-- OBS's own default browser-source CSS. It MUST be set explicitly: a browser source created from
-- a script with an empty `css` renders on black instead of transparent.
local DEFAULT_CSS = "body { background-color: rgba(0, 0, 0, 0); margin: 0px auto; overflow: hidden; }"
-- Every source this script owns starts with this prefix, so "refresh" can find them all and a
-- streamer running two channels gets two clearly named sources instead of one fighting over both.
local SOURCE_PREFIX = "PKTOK "
-- Hosts we accept. The old app.livetok.hu is deliberately still alive for a couple of streamers
-- whose consoles hand out the legacy link, so rejecting it would break exactly those users.
local ALLOWED_HOSTS = {
["app.pktok.com"] = true,
["app.livetok.hu"] = true,
["localhost:3200"] = true, -- local development
["127.0.0.1:3200"] = true,
}
local overlay_url = ""
local status_text = "Paste your overlay link above, then press the button."
--=============================================================================
-- helpers
--=============================================================================
-- Cut the token out of anything that may reach a log file or the UI.
local function mask(url)
if not url or url == "" then return "(empty)" end
return (string.gsub(url, "token=[^&]*", "token=***"))
end
local function log(msg)
obs.script_log(obs.LOG_INFO, "[PKTOK] " .. tostring(msg))
end
local function trim(s)
return (string.gsub(tostring(s or ""), "^%s*(.-)%s*$", "%1"))
end
-- Parse and validate the overlay link.
-- Returns a table on success, or nil plus a human-readable reason.
local function parse_overlay_url(raw)
local url = trim(raw)
if url == "" then
return nil, "Paste the overlay link from your PKTOK console first."
end
local scheme, host, rest = string.match(url, "^(https?)://([^/]+)(/.*)$")
if not scheme then
return nil, "That does not look like a link. Copy the whole line from the console, starting with https://"
end
if not ALLOWED_HOSTS[host] then
return nil, "This is not a PKTOK overlay link (host: " .. host .. "). Copy it from your PKTOK console."
end
local slug, page, query = string.match(rest, "^/overlay/([^/]+)/([^/?]+)%??(.*)$")
if not slug then
return nil, "This is a PKTOK link, but not an overlay link. In the console, copy the video layer link."
end
if page == "projector" then
return nil, "That is the projector link (for watching), not the layer link. Copy the video layer link instead."
end
if page ~= "video" and page ~= "booster" then
return nil, "Unknown overlay type: " .. page .. ". Copy the video layer link from the console."
end
if not string.find(query, "token=", 1, true) then
return nil, "The link has no access token. Copy the whole link, the end of it matters too."
end
local landscape = string.find(query, "layout=landscape", 1, true) ~= nil
local green = string.find(query, "bg=green", 1, true) ~= nil
return {
url = url,
slug = slug,
page = page,
landscape = landscape,
green = green,
width = landscape and 1920 or 1080,
height = landscape and 1080 or 1920,
label = (page == "booster") and "2nd Layer" or "Overlay",
}
end
local function source_name_for(info)
return SOURCE_PREFIX .. info.label .. " (" .. info.slug .. ")"
end
-- Canvas size of the current OBS project.
local function canvas_size()
local ovi = obs.obs_video_info()
if obs.obs_get_video_info(ovi) then
return ovi.base_width, ovi.base_height
end
return nil, nil
end
-- Make the item cover the whole canvas without distorting it.
local function fit_to_canvas(item)
local cw, ch = canvas_size()
if not cw or cw == 0 or ch == 0 then return end
local pos = obs.vec2()
pos.x = 0
pos.y = 0
obs.obs_sceneitem_set_pos(item, pos)
local bounds = obs.vec2()
bounds.x = cw
bounds.y = ch
obs.obs_sceneitem_set_alignment(item, 5) -- OBS_ALIGN_LEFT | OBS_ALIGN_TOP
obs.obs_sceneitem_set_bounds_type(item, obs.OBS_BOUNDS_SCALE_INNER)
obs.obs_sceneitem_set_bounds_alignment(item, 0) -- OBS_ALIGN_CENTER
obs.obs_sceneitem_set_bounds(item, bounds)
end
-- Ask a browser source to reload the page. The browser source exposes this as a button property;
-- clicking it programmatically is the only reliable way to force a reload from a script.
-- Older/newer OBS builds do not all expose the same id getter; calling a missing one is a hard
-- Lua error, so resolve it once and fall back.
local function source_type_id(source)
local getter = obs.obs_source_get_unversioned_id or obs.obs_source_get_id
if not getter then return "" end
return getter(source) or ""
end
local function reload_browser_source(source)
local props = obs.obs_source_properties(source)
if not props then return false end
local ok = false
for _, key in ipairs({ "refreshnocache", "refresh" }) do
local p = obs.obs_properties_get(props, key)
if p then
obs.obs_property_button_clicked(p, source)
ok = true
break
end
end
obs.obs_properties_destroy(props)
return ok
end
--=============================================================================
-- the one action: create or update the layer
--=============================================================================
local function set_up_layer()
local info, why = parse_overlay_url(overlay_url)
if not info then
status_text = "⚠️ " .. why
log("setup refused: " .. why)
return
end
local scene_source = obs.obs_frontend_get_current_scene()
if not scene_source then
status_text = "⚠️ No scene is open in OBS. Create or select a scene first, then press the button again."
return
end
local scene = obs.obs_scene_from_source(scene_source)
if not scene then
obs.obs_source_release(scene_source)
status_text = "⚠️ OBS did not give back a usable scene. Select a normal scene and try again."
return
end
local name = source_name_for(info)
local notes = {}
-- Settings that make a PKTOK layer behave: transparent, always running, audio through OBS.
local settings = obs.obs_data_create()
obs.obs_data_set_string(settings, "url", info.url)
obs.obs_data_set_int(settings, "width", info.width)
obs.obs_data_set_int(settings, "height", info.height)
obs.obs_data_set_string(settings, "css", DEFAULT_CSS)
obs.obs_data_set_bool(settings, "shutdown", false)
obs.obs_data_set_bool(settings, "restart_when_active", false)
obs.obs_data_set_bool(settings, "reroute_audio", true)
-- Idempotent by design: running this twice must never leave two layers behind.
local existing = obs.obs_get_source_by_name(name)
local created = false
local source
if existing and source_type_id(existing) ~= "browser_source" then
obs.obs_source_release(existing)
obs.obs_data_release(settings)
obs.obs_source_release(scene_source)
status_text = "⚠️ A source called \"" .. name .. "\" already exists but is not a browser source. "
.. "Rename or delete it, then press the button again."
return
end
if existing then
obs.obs_source_update(existing, settings)
source = existing
table.insert(notes, "existing layer updated")
else
source = obs.obs_source_create("browser_source", name, settings, nil)
created = true
if not source then
obs.obs_data_release(settings)
obs.obs_source_release(scene_source)
status_text = "⚠️ OBS could not create a browser source. Is this a full OBS Studio install?"
log("obs_source_create returned nil")
return
end
end
obs.obs_data_release(settings)
local item = obs.obs_scene_find_source(scene, name)
if item then
-- Already in this scene: leave the streamer's own positioning alone.
table.insert(notes, "kept its place in the scene")
else
item = obs.obs_scene_add(scene, source)
if item then
-- A freshly added item does not land on top on its own, and a PKTOK layer underneath the
-- camera is invisible — which looks exactly like "the script did nothing". Only done when
-- WE add it: if the layer was already in the scene, the streamer's own order is theirs.
obs.obs_sceneitem_set_order(item, obs.OBS_ORDER_MOVE_TOP)
fit_to_canvas(item)
table.insert(notes, created and "added on top of the current scene" or "added to this scene too")
end
end
obs.obs_source_release(source)
obs.obs_source_release(scene_source)
-- Warnings that are worth a sentence, not a silent fix.
local cw, ch = canvas_size()
if cw and ch then
local canvas_landscape = cw > ch
if canvas_landscape ~= info.landscape then
table.insert(notes, string.format(
"⚠️ your canvas is %dx%d but this link is %s — the layer will not fill the screen. "
.. "Fix it in Settings → Video, or switch the mode in the PKTOK console.",
cw, ch, info.landscape and "landscape (gamer mode)" or "vertical"))
end
end
if info.green then
table.insert(notes, "⚠️ this is the GREEN link, so it needs a chroma key filter. "
.. "The transparent link from the console needs no filter — prefer that one.")
end
local joined = table.concat(notes, "; ")
if string.sub(joined, -1) ~= "." then joined = joined .. "." end
status_text = "✅ \"" .. name .. "\" is ready (" .. info.width .. "x" .. info.height .. "): " .. joined
log("set up " .. name .. " -> " .. mask(info.url))
end
local function refresh_layers()
local sources = obs.obs_enum_sources()
local n = 0
if sources then
for _, src in ipairs(sources) do
local nm = obs.obs_source_get_name(src)
if nm and string.sub(nm, 1, #SOURCE_PREFIX) == SOURCE_PREFIX
and source_type_id(src) == "browser_source" then
if reload_browser_source(src) then n = n + 1 end
end
end
obs.source_list_release(sources)
end
status_text = (n > 0)
and ("🔄 Reloaded " .. n .. " PKTOK layer" .. (n == 1 and "" or "s") .. ".")
or "Nothing to reload — no PKTOK layer exists yet."
log(status_text)
end
--=============================================================================
-- OBS script interface
--=============================================================================
function script_description()
return "PKTOK overlay setup (v" .. VERSION .. ")
"
.. "Paste the overlay link from your PKTOK console and press the button. "
.. "The layer is created at the right size, transparent, fitted to your canvas, "
.. "with its sound routed through OBS.
"
.. "Nothing is installed and nothing leaves this computer.
"
.. "Help: pktok.com/obs-plugin"
end
function script_defaults(settings)
obs.obs_data_set_default_string(settings, "overlay_url", "")
end
function script_update(settings)
overlay_url = obs.obs_data_get_string(settings, "overlay_url")
end
function script_properties()
local props = obs.obs_properties_create()
obs.obs_properties_add_text(props, "overlay_url", "PKTOK overlay link", obs.OBS_TEXT_DEFAULT)
obs.obs_properties_add_button(props, "setup_btn", "Set up the PKTOK layer", function(p, _)
set_up_layer()
local info = obs.obs_properties_get(p, "status")
if info then obs.obs_property_set_description(info, status_text) end
return true -- redraw the panel so the status line below is up to date
end)
obs.obs_properties_add_button(props, "refresh_btn", "Reload the PKTOK layer", function(p, _)
refresh_layers()
local info = obs.obs_properties_get(p, "status")
if info then obs.obs_property_set_description(info, status_text) end
return true
end)
obs.obs_properties_add_text(props, "status", status_text, obs.OBS_TEXT_INFO)
return props
end
function script_load(settings)
overlay_url = obs.obs_data_get_string(settings, "overlay_url")
obs.obs_frontend_add_tools_menu_item("Reload PKTOK overlay", refresh_layers)
log("loaded, version " .. VERSION)
end