--[[ 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