--[[-------------------------------------------------------------------------------- TipBit.lua — OBS-скрипт (Lua) для виджета алертов TipBit Версия: 1.0.0 Автор: TipBit Файл: obs/TipBit.lua --------------------------------------------------------------------------- НАЗНАЧЕНИЕ --------------------------------------------------------------------------- Скрипт в один клик добавляет в выбранную (или текущую) сцену OBS источник типа «Браузер» (browser_source) с адресом виджета алертов TipBit: ${TIPBIT_PUBLIC_URL}/w/ Виджет — обычная веб-страница с прозрачным фоном: размер по умолчанию 1920x1080, контент центрирован, поэтому корректно работает и на 800x450. Звук алертов воспроизводит сам виджет через WebAudio, поэтому громкость источника в микшере по умолчанию НЕ меняется — звук донатов идёт в эфир. Галочка «Отключить звук источника в микшере» нужна только тем, у кого звук алертов идёт другим путём (при ней звук донатов в трансляцию не попадёт). --------------------------------------------------------------------------- УСТАНОВКА --------------------------------------------------------------------------- 1. Сохраните файл TipBit.lua в любую папку (например C:\TipBit\TipBit.lua). 2. В OBS: «Инструменты» → «Скрипты» → кнопка «+» → выберите TipBit.lua. 3. В кабинете TipBit откройте раздел «Алерты и виджет» и скопируйте ссылку виджета (вида https://ваш-домен/w/<токен>) — целиком или только токен. 4. Вставьте её в поле «Ссылка виджета (или токен)», выберите сцену и нажмите кнопку «Проверить / обновить источник». 5. Проверка: кнопка «Тестовый донат» в кабинете — алерт должен появиться в OBS и в эфире. --------------------------------------------------------------------------- ОСОБЕННОСТИ --------------------------------------------------------------------------- * Повторное нажатие кнопки НЕ создаёт дубликат: источник с таким же именем находится (в любой сцене) и обновляется, а не создаётся заново. * Настройки скрипта сохраняются самим OBS (script_defaults + свойства). * Скрипт работает без интернета: страница просто покажет «нет соединения», это нормально и на работу OBS не влияет. * Требуется OBS Studio 28+ (проверено на Lua 5.1-совместимом интерпретаторе, который поставляется с OBS). Используются только документированные функции Lua-API OBS. --------------------------------------------------------------------------------]] local obs = obslua -------------------------------------------------------------------------------- -- Константы и состояние -------------------------------------------------------------------------------- local SCRIPT_VERSION = "1.0.0" local DEFAULT_BASE_URL = "https://tipbit.local" local DEFAULT_NAME = "TipBit Alert" -- Значение пункта «Текущая сцена» в списке сцен local SCENE_CURRENT = "__current__" -- Стандартный CSS прозрачного источника «Браузер» (совпадает с дефолтом OBS: -- фон страницы прозрачный, без полей и полос прокрутки). Именно это убирает -- чёрный прямоугольник вокруг алерта. local TRANSPARENT_CSS = "body { background-color: rgba(0, 0, 0, 0); margin: 0px auto; overflow: hidden; }" -- Уровни логирования. В Lua-API это obs.LOG_*, числовые фолбэки — значения -- libobs (100/200/300/400) на случай нестандартной сборки. local LOG_INFO = obs.LOG_INFO or 200 local LOG_WARNING = obs.LOG_WARNING or 300 local LOG_ERROR = obs.LOG_ERROR or 400 -- Копия настроек скрипта в виде обычной Lua-таблицы: кнопкам и обработчикам -- нельзя держать obs_data_t «между вызовами» — OBS владеет этой памятью. local st = { widget = "", base_url = DEFAULT_BASE_URL, source_name = DEFAULT_NAME, width = 1920, height = 1080, scene = SCENE_CURRENT, append_to_end = false, restart_when_active = true, -- Звук алертов играет сам виджет через WebAudio, поэтому по умолчанию -- громкость источника НЕ трогаем: звук донатов должен идти в эфир. -- Галочка нужна только тем, у кого звук алертов идёт другим путём. mute_audio = false, } -------------------------------------------------------------------------------- -- Мелкие помощники -------------------------------------------------------------------------------- local function log_info(msg) obs.script_log(LOG_INFO, "[TipBit] " .. tostring(msg)) end local function log_warn(msg) obs.script_log(LOG_WARNING, "[TipBit] " .. tostring(msg)) end local function log_err(msg) obs.script_log(LOG_ERROR, "[TipBit] " .. tostring(msg)) end -- Обрезка пробелов по краям (без string.trim, т.к. Lua 5.1) local function trim(s) if s == nil then return "" end return (tostring(s):gsub("^%s*(.-)%s*$", "%1")) end -- Источник «Браузер»? id у него ровно "browser_source" local function is_browser_source(source) local id = obs.obs_source_get_id(source) if id == nil then return false end return id == "browser_source" or id:sub(1, 14) == "browser_source" end -- Источник по имени (в любом месте, во всех сценах и группах). -- Возвращает obs_source_t* с +1 ссылкой — вызывающий обязан сделать -- obs.obs_source_release(). local function find_source_by_name(name) local found = nil local sources = obs.obs_enum_sources() if sources ~= nil then for _, source in ipairs(sources) do if obs.obs_source_get_name(source) == name then found = obs.obs_source_get_ref(source) break end end end obs.source_list_release(sources) return found end -- Исходник сцены по имени. SCENE_CURRENT — программная (текущая) сцена OBS. -- Возвращает obs_source_t* с +1 ссылкой либо nil. local function find_scene_source(scene_name) if scene_name == nil or scene_name == "" or scene_name == SCENE_CURRENT then local current = obs.obs_frontend_get_current_scene() if current == nil then log_warn("OBS не вернул текущую сцену (obs_frontend_get_current_scene = nil).") end return current end local found = nil local scenes = obs.obs_frontend_get_scenes() if scenes ~= nil then for _, scene in ipairs(scenes) do if obs.obs_source_get_name(scene) == scene_name then found = obs.obs_source_get_ref(scene) break end end end obs.source_list_release(scenes) return found end -------------------------------------------------------------------------------- -- Нормализация и проверка ссылки виджета -------------------------------------------------------------------------------- -- Приводит введённое значение к полному URL виджета. -- Возвращает url, nil при успехе либо nil, текст_ошибки. local function resolve_widget_url() local raw = trim(st.widget) if raw == "" then return nil, "не указана ссылка виджета: вставьте ссылку из кабинета TipBit " .. "(раздел «Алерты и виджет») или только токен." end local lower = raw:lower() if lower:match("^https?://") then -- Вставлена полная ссылка. Внутренние пробелы/переводы строк убираем. local url = raw:gsub("%s+", "") -- Человек мог скопировать ссылку вида https://host/widget.html?token=abc -- (или /widget?token=abc). Приводим её к каноническому /w/<токен>. local token = url:match("[?&]token=([%w_%-%.]+)") if token ~= nil and url:find("/w/", 1, true) == nil then local origin = url:match("^(https?://[^/%?#]+)") if origin ~= nil then log_info("ссылка нормализована в " .. origin .. "/w/" .. token) return origin .. "/w/" .. token, nil end end if url:find("/w/", 1, true) == nil then log_warn("в ссылке нет «/w/». Виджет алертов живёт по адресу " .. "<сервер>/w/<токен>; если фон источника чёрный — возьмите в кабинете " .. "ссылку вида https://домен/w/<токен>.") end return url, nil end -- Вставлен только токен (возможно с лишними «/», «w/» или пробелами) local token = raw:gsub("^/*", "") token = token:gsub("^w/", "") token = trim(token) if token == "" then return nil, "после нормализации ссылка/токен пусты." end if token:find("[^%w_%-%.]") then return nil, "токен содержит недопустимые символы: «" .. token .. "». Ожидается токен из кабинета TipBit (латиница, цифры, «-», «_»)." end local base = trim(st.base_url):gsub("%s+", "") if base == "" then base = DEFAULT_BASE_URL end if base:lower():match("^https?://") == nil then base = "https://" .. base log_info("базовый URL дополнен схемой: " .. base) end base = base:gsub("/+$", "") return base .. "/w/" .. token, nil end -------------------------------------------------------------------------------- -- Настройки источника «Браузер» -------------------------------------------------------------------------------- -- css обязателен: прозрачный фон. fps_custom намеренно НЕ задаём — -- остаётся стандартное значение OBS (30 fps). local function build_source_settings(url, css) local settings = obs.obs_data_create() obs.obs_data_set_string(settings, "url", url) obs.obs_data_set_int(settings, "width", st.width) obs.obs_data_set_int(settings, "height", st.height) obs.obs_data_set_bool(settings, "shutdown", false) obs.obs_data_set_bool(settings, "restart_when_active", st.restart_when_active) obs.obs_data_set_string(settings, "css", css or TRANSPARENT_CSS) return settings end -------------------------------------------------------------------------------- -- Добавление источника в сцену -------------------------------------------------------------------------------- -- Возвращает true, если источник оказался в сцене (или уже был там). local function ensure_in_scene(source, name) local scene_source = find_scene_source(st.scene) if scene_source == nil then if st.scene == SCENE_CURRENT then log_warn("не удалось определить текущую сцену OBS. Источник создан, " .. "но в сцену не добавлен — добавьте его вручную или выберите сцену в списке.") else log_warn("сцена «" .. tostring(st.scene) .. "» не найдена. Нажмите " .. "«Обновить список сцен» и выберите сцену заново.") end return false end local scene = obs.obs_scene_from_source(scene_source) if scene == nil then log_warn("obs_scene_from_source вернул nil — источник «" .. name .. "» не добавлен в сцену.") obs.obs_source_release(scene_source) return false end local scene_name = obs.obs_source_get_name(scene_source) -- Уже лежит в этой сцене? Тогда просто ничего не добавляем — дубликатов не будет. local item = obs.obs_scene_find_source(scene, name) if item ~= nil then obs.obs_sceneitem_set_pos(item, {x = 0, y = 0}) log_info("источник «" .. name .. "» уже есть в сцене «" .. tostring(scene_name) .. "» — обновлены только настройки.") obs.obs_source_release(scene_source) return true end item = obs.obs_scene_add(scene, source) if item == nil then log_warn("obs_scene_add вернул nil — не удалось добавить источник «" .. name .. "» в сцену «" .. tostring(scene_name) .. "».") obs.obs_source_release(scene_source) return false end -- «В конец сцены» = ниже остальных источников списка. if st.append_to_end then if obs.obs_sceneitem_set_order ~= nil and obs.OBS_ORDER_MOVE_BOTTOM ~= nil then obs.obs_sceneitem_set_order(item, obs.OBS_ORDER_MOVE_BOTTOM) else log_warn("OBS API не предоставил obs_sceneitem_set_order — источник " .. "добавлен как обычно. Порядок можно поправить перетаскиванием в OBS.") end end -- Позиционируем в (0,0) относительно канвы сцены. obs.obs_sceneitem_set_pos(item, {x = 0, y = 0}) log_info("источник «" .. name .. "» добавлен в сцену «" .. tostring(scene_name) .. "» в позицию (0,0).") obs.obs_source_release(scene_source) return true end -------------------------------------------------------------------------------- -- Перезагрузка страницы виджета -------------------------------------------------------------------------------- local function restart_source(source) if obs.obs_source_media_restart ~= nil then obs.obs_source_media_restart(source) return true end log_warn("OBS API не предоставил obs_source_media_restart: страница перезагрузится " .. "сама при следующей активации сцены.") return false end -------------------------------------------------------------------------------- -- Основная работа: создать/обновить источник -------------------------------------------------------------------------------- -- restart == true — дополнительно перезагрузить страницу (obs_source_media_restart). -- Возвращает true, если источник существует и настроен. local function sync_source(restart) local url, err = resolve_widget_url() if url == nil then log_warn(err) return false end local name = trim(st.source_name) if name == "" then name = DEFAULT_NAME end -- 1) Ищем существующий источник с таким именем во всех сценах, -- чтобы обновить его, а не плодить дубликаты. local source = find_source_by_name(name) local created = false if source ~= nil then if not is_browser_source(source) then log_err("источник «" .. name .. "» уже существует, но это не «Браузер» (id = «" .. tostring(obs.obs_source_get_id(source)) .. "»). Переименуйте его или задайте " .. "другое «Имя источника» — существующий источник скрипт не трогает.") obs.obs_source_release(source) return false end else local settings = build_source_settings(url, nil) source = obs.obs_source_create("browser_source", name, settings, nil) obs.obs_data_release(settings) if source == nil then log_err("OBS не смог создать источник browser_source. Проверьте, что " .. "плагин «Browser Source» есть в сборке OBS (он входит в стандартную).") return false end created = true end -- 2) Обновляем настройки. Пользовательский CSS, если он был задан, -- сохраняем; иначе ставим стандартный прозрачный. local css = TRANSPARENT_CSS local existing = obs.obs_source_get_settings(source) if existing ~= nil then local current_css = obs.obs_data_get_string(existing, "css") if current_css ~= nil and current_css ~= "" then css = current_css end obs.obs_data_release(existing) end local settings = build_source_settings(url, css) obs.obs_source_update(source, settings) obs.obs_data_release(settings) -- 3) Звук. Звук алертов играет сам виджет через WebAudio, поэтому по умолчанию -- громкость источника НЕ трогаем: звук донатов должен идти в эфир. -- Галочка «Отключить звук источника в микшере» нужна только тем, у кого звук -- алертов идёт другим путём — при ней ставим volume = 0.0. if st.mute_audio then obs.obs_source_set_volume(source, 0.0) end -- 4) Кладём в сцену (если его там ещё нет) local in_scene = ensure_in_scene(source, name) -- 5) При необходимости перезагружаем страницу if restart and in_scene then restart_source(source) end log_info((created and "создан" or "обновлён") .. " источник «" .. name .. "»: url = " .. url .. ", " .. tostring(st.width) .. "x" .. tostring(st.height) .. ", restart_when_active = " .. tostring(st.restart_when_active) .. ", звук в микшере = " .. (st.mute_audio and "отключён галочкой" or "не менялся")) if st.mute_audio then log_warn("включена галочка «Отключить звук источника в микшере»: громкость источника " .. "выставлена в 0.0, поэтому звук донатов НЕ попадёт в трансляцию. Выключите " .. "галочку, если алерты должны звучать в эфире.") end obs.obs_source_release(source) return true end -- Отдельная функция «Проверить подключение»: обновляет URL у источника и -- перезагружает страницу (obs_source_media_restart). Если источника нет — -- создаёт его (см. sync_source). local function check_connection() local ok = sync_source(false) if not ok then log_warn("проверка подключения не выполнена: не удалось создать/обновить источник.") return false end local name = trim(st.source_name) if name == "" then name = DEFAULT_NAME end local source = find_source_by_name(name) if source == nil then log_warn("источник «" .. name .. "» не найден — нечего перезагружать.") return false end restart_source(source) obs.obs_source_release(source) log_info("проверка подключения: источник «" .. name .. "» перезагружен. " .. "Отправьте «Тестовый донат» в кабинете TipBit — алерт должен появиться в OBS.") return true end -------------------------------------------------------------------------------- -- Список сцен в свойствах скрипта -------------------------------------------------------------------------------- local function fill_scene_list(props) local p = obs.obs_properties_get(props, "scene") if p == nil then return end obs.obs_property_list_clear(p) obs.obs_property_list_add_string(p, "Текущая сцена (программная)", SCENE_CURRENT) local names = {} local scenes = obs.obs_frontend_get_scenes() if scenes ~= nil then for _, scene in ipairs(scenes) do local scene_name = obs.obs_source_get_name(scene) if scene_name ~= nil and scene_name ~= "" then table.insert(names, scene_name) end end end obs.source_list_release(scenes) local found_current = false for _, scene_name in ipairs(names) do if scene_name == st.scene then found_current = true end obs.obs_property_list_add_string(p, scene_name, scene_name) end -- Сохранённая сцена пропала (переименовали/удалили) — оставляем её отдельным -- пунктом, чтобы не потерять выбор пользователя молча. if not found_current and st.scene ~= SCENE_CURRENT and trim(st.scene) ~= "" then obs.obs_property_list_add_string(p, st.scene .. " (не найдена)", st.scene) end end -------------------------------------------------------------------------------- -- Обработчики кнопок -------------------------------------------------------------------------------- local function on_apply_clicked(props, p) fill_scene_list(props) if sync_source(true) then log_info("готово. Проверьте алерт кнопкой «Тестовый донат» в кабинете TipBit.") else log_warn("источник не создан/не обновлён — смотрите сообщения выше.") end return false end local function on_check_clicked(props, p) check_connection() return false end local function on_refresh_scenes_clicked(props, p) fill_scene_list(props) log_info("список сцен обновлён.") return false end -------------------------------------------------------------------------------- -- Экспортируемые функции OBS -------------------------------------------------------------------------------- function script_description() return "TipBit " .. SCRIPT_VERSION .. " — добавляет в сцену источник «Браузер» " .. "(Browser Source) с виджетом алертов TipBit.\n\n" .. "1) В кабинете TipBit откройте «Алерты и виджет» и скопируйте ссылку виджета " .. "(https://домен/w/<токен>) — целиком или только токен.\n" .. "2) Вставьте её в поле «Ссылка виджета (или токен)» ниже.\n" .. "3) Проверьте размеры (1920x1080, либо 800x450), выберите сцену и нажмите " .. "«Проверить / обновить источник».\n" .. "4) Проверка алерта — кнопка «Тестовый донат» в кабинете.\n\n" .. "Звук алертов играет сам виджет (WebAudio): по умолчанию громкость источника " .. "в микшере не меняется, поэтому донаты слышно в эфире. Галочку «Отключить звук " .. "источника в микшере» включайте только если звук алертов у вас идёт другим путём.\n\n" .. "Повторный запуск кнопки обновляет существующий источник, дубликатов не создаёт." end function script_defaults(settings) obs.obs_data_set_default_string(settings, "widget", "") obs.obs_data_set_default_string(settings, "base_url", DEFAULT_BASE_URL) obs.obs_data_set_default_string(settings, "source_name", DEFAULT_NAME) obs.obs_data_set_default_int(settings, "width", 1920) obs.obs_data_set_default_int(settings, "height", 1080) obs.obs_data_set_default_string(settings, "scene", SCENE_CURRENT) obs.obs_data_set_default_bool(settings, "append_to_end", false) obs.obs_data_set_default_bool(settings, "restart_when_active", true) obs.obs_data_set_default_bool(settings, "mute_audio", false) obs.obs_data_set_default_int(settings, "auto_refresh_minutes", 0) end function script_properties() local props = obs.obs_properties_create() obs.obs_properties_add_text(props, "widget", "Ссылка виджета (или токен)", obs.OBS_TEXT_DEFAULT) obs.obs_properties_add_text(props, "base_url", "Базовый URL сервера (нужен, если вставлен только токен)", obs.OBS_TEXT_DEFAULT) obs.obs_properties_add_text(props, "source_name", "Имя источника", obs.OBS_TEXT_DEFAULT) obs.obs_properties_add_int(props, "width", "Ширина (1920 или 800)", 16, 7680, 1) obs.obs_properties_add_int(props, "height", "Высота (1080 или 450)", 16, 4320, 1) obs.obs_properties_add_list(props, "scene", "Сцена", obs.OBS_COMBO_TYPE_LIST, obs.OBS_COMBO_FORMAT_STRING) obs.obs_properties_add_bool(props, "append_to_end", "Добавить в конец сцены (ниже остальных источников)") obs.obs_properties_add_bool(props, "restart_when_active", "Перезапускать при активации сцены") obs.obs_properties_add_bool(props, "mute_audio", "Отключить звук источника в микшере (звук донатов не попадёт в эфир)") obs.obs_properties_add_int(props, "auto_refresh_minutes", "Профилактическая перезагрузка виджета, минут (0 — выключено)", 0, 720, 5) obs.obs_properties_add_button(props, "apply", "Проверить / обновить источник", on_apply_clicked) obs.obs_properties_add_button(props, "check", "Проверить подключение (перезагрузить виджет)", on_check_clicked) obs.obs_properties_add_button(props, "refresh_scenes", "Обновить список сцен", on_refresh_scenes_clicked) fill_scene_list(props) return props end function script_update(settings) st.widget = obs.obs_data_get_string(settings, "widget") st.base_url = obs.obs_data_get_string(settings, "base_url") st.source_name = obs.obs_data_get_string(settings, "source_name") st.scene = obs.obs_data_get_string(settings, "scene") st.width = obs.obs_data_get_int(settings, "width") st.height = obs.obs_data_get_int(settings, "height") -- защита от мусора в настройках if st.width == nil or st.width < 16 then st.width = 1920 end if st.height == nil or st.height < 16 then st.height = 1080 end if st.width > 7680 then st.width = 7680 end if st.height > 4320 then st.height = 4320 end if st.base_url == nil or trim(st.base_url) == "" then st.base_url = DEFAULT_BASE_URL end if st.source_name == nil or trim(st.source_name) == "" then st.source_name = DEFAULT_NAME end if st.scene == nil or trim(st.scene) == "" then st.scene = SCENE_CURRENT end st.append_to_end = obs.obs_data_get_bool(settings, "append_to_end") st.restart_when_active = obs.obs_data_get_bool(settings, "restart_when_active") st.mute_audio = obs.obs_data_get_bool(settings, "mute_audio") st.auto_refresh_minutes = obs.obs_data_get_int(settings, "auto_refresh_minutes") or 0 if st.auto_refresh_minutes < 0 then st.auto_refresh_minutes = 0 end if st.auto_refresh_minutes > 720 then st.auto_refresh_minutes = 720 end end -------------------------------------------------------------------------------- -- Надёжность: автопроверка при запуске OBS и профилактическая перезагрузка -------------------------------------------------------------------------------- -- Если OBS перезапустили (или стример сменил токен виджета в кабинете), источник -- нужно привести в актуальное состояние без ручных действий. local AUTO_SYNC_DELAY_MS = 3000 local auto_sync_done = false local function auto_sync_on_load() auto_sync_done = true obs.timer_remove(auto_sync_on_load) if trim(st.widget or "") == "" then log_info("ссылка виджета ещё не указана — автопроверка источника пропущена") return end if sync_source(false) then log_info("автопроверка при загрузке: источник актуален") end end -- Профилактическая перезагрузка страницы: если за долгий стрим браузерный -- движок OBS «подвис», страница перезапускается сама (0 = выключено). local watchdog_timer = nil local function watchdog_tick() local name = trim(st.source_name) if name == "" then name = DEFAULT_NAME end if not sync_source(false) then return end local source = find_source_by_name(name) if source == nil then return end restart_source(source) obs.obs_source_release(source) log_info("профилактическая перезагрузка виджета выполнена") end local function apply_watchdog() if watchdog_timer ~= nil then obs.timer_remove(watchdog_timer) watchdog_timer = nil end local minutes = tonumber(st.auto_refresh_minutes) or 0 if minutes > 0 then watchdog_timer = watchdog_tick obs.timer_add(watchdog_timer, minutes * 60000) log_info("профилактическая перезагрузка включена: каждые " .. tostring(minutes) .. " мин") end end function script_load(settings) script_update(settings) log_info("загружен (v" .. SCRIPT_VERSION .. "). Вставьте ссылку виджета из кабинета " .. "TipBit и нажмите «Проверить / обновить источник».") -- OBS должен догрузить сцены и источники, поэтому проверяем с небольшой задержкой obs.timer_add(auto_sync_on_load, AUTO_SYNC_DELAY_MS) apply_watchdog() end function script_unload() if watchdog_timer ~= nil then obs.timer_remove(watchdog_timer) watchdog_timer = nil end log_info("выгружен.") end