-- Smart Chapters / Markers Writer -- Writes YouTube-style chapters in real time based on stream/record time. -- -- Features: -- - Auto chapter on scene change (optional) -- - Hotkey to add chapter for current scene -- - Scene name as default label -- - Optional per-scene custom labels (mapping) -- - Uses active streaming/recording elapsed time local obs = obslua ---------------------------------------------------------- -- Config / state ---------------------------------------------------------- local chapter_file_path = "" local auto_on_scene_change = true local write_session_header = true local overrides_raw = "" local scene_overrides = {} local fps_num = 0 local fps_den = 0 local hotkey_add_chapter_id = obs.OBS_INVALID_HOTKEY_ID ---------------------------------------------------------- -- Helpers ---------------------------------------------------------- local function log(msg) print("[SmartChapters] " .. msg) end local function update_video_info() local vi = obs.obs_video_info() if obs.obs_get_video_info(vi) then fps_num = vi.fps_num fps_den = vi.fps_den log(string.format("Video FPS: %d/%d", fps_num, fps_den)) else fps_num = 30 fps_den = 1 log("Failed to get video info, defaulting to 30 FPS") end end local function frames_to_seconds(frames) if fps_num == 0 or fps_den == 0 then return 0 end -- seconds = frames * (den / num) return math.floor(frames * fps_den / fps_num) end local function format_timestamp(seconds) seconds = math.floor(seconds or 0) local h = math.floor(seconds / 3600) local m = math.floor((seconds % 3600) / 60) local s = seconds % 60 if h > 0 then return string.format("%02d:%02d:%02d", h, m, s) else return string.format("%02d:%02d", m, s) end end local function get_active_output_time_seconds() local seconds = nil -- Prefer streaming time if active local out = obs.obs_frontend_get_streaming_output() if out ~= nil then if obs.obs_output_active(out) then local frames = obs.obs_output_get_total_frames(out) seconds = frames_to_seconds(frames) end obs.obs_output_release(out) end -- Fallback to recording time if streaming not active if seconds == nil then out = obs.obs_frontend_get_recording_output() if out ~= nil then if obs.obs_output_active(out) then local frames = obs.obs_output_get_total_frames(out) seconds = frames_to_seconds(frames) end obs.obs_output_release(out) end end -- If neither streaming nor recording is active yet, 0 return seconds or 0 end local function parse_scene_overrides() scene_overrides = {} for line in string.gmatch(overrides_raw or "", "[^\r\n]+") do -- Syntax: Scene Name = Custom Label or Scene Name: Custom Label local name, label = line:match("^%s*(.-)%s*[:=]%s*(.+)$") if name and label and name ~= "" and label ~= "" then scene_overrides[name] = label end end end local function get_current_scene_label() local source = obs.obs_frontend_get_current_scene() if source == nil then return "Unknown" end local scene_name = obs.obs_source_get_name(source) obs.obs_source_release(source) if scene_overrides[scene_name] ~= nil then return scene_overrides[scene_name] end return scene_name end local function append_line_to_chapters_file(line) if chapter_file_path == nil or chapter_file_path == "" then return end local file, err = io.open(chapter_file_path, "a") if not file then log("Failed to open file: " .. tostring(err)) return end file:write(line .. "\n") file:flush() file:close() end local function write_session_header_line() if not write_session_header then return end if chapter_file_path == nil or chapter_file_path == "" then return end local date_str = os.date("%Y-%m-%d %H:%M:%S") append_line_to_chapters_file("") append_line_to_chapters_file(string.format("# --- New session: %s ---", date_str)) end ---------------------------------------------------------- -- Core: create chapter ---------------------------------------------------------- local function create_chapter(custom_label) if chapter_file_path == nil or chapter_file_path == "" then log("No chapters file configured.") return end local seconds = get_active_output_time_seconds() local timestamp = format_timestamp(seconds) local label = custom_label if label == nil or label == "" then label = get_current_scene_label() end local line = string.format("%s – %s", timestamp, label) append_line_to_chapters_file(line) log("Added chapter: " .. line) end ---------------------------------------------------------- -- Frontend events ---------------------------------------------------------- local function on_frontend_event(event) if event == obs.OBS_FRONTEND_EVENT_STREAMING_STARTED or event == obs.OBS_FRONTEND_EVENT_RECORDING_STARTED then update_video_info() write_session_header_line() end if event == obs.OBS_FRONTEND_EVENT_SCENE_CHANGED then if auto_on_scene_change then create_chapter(nil) end end end ---------------------------------------------------------- -- Hotkeys ---------------------------------------------------------- local function on_add_chapter_hotkey(pressed) if not pressed then return end create_chapter(nil) -- uses current scene label end ---------------------------------------------------------- -- Script interface ---------------------------------------------------------- function script_description() return [[Smart Chapters / Markers Writer Automatically writes a chapters.txt file in real time using the current streaming/recording time and scene names. Examples: 00:00 – Intro 03:25 – Gameplay Start 14:10 – Boss Fight Features: • Auto chapter on scene change • Hotkey to add manual chapter for current scene • Scene-based custom labels (per-scene overrides) • Uses stream/record elapsed time Perfect for YouTube chapters, VOD indexing, and long tutorials.]] end function script_properties() local props = obs.obs_properties_create() obs.obs_properties_add_path( props, "chapter_file_path", "Chapters file", obs.OBS_PATH_FILE, "Text files (*.txt);;All files (*.*)", nil ) obs.obs_properties_add_bool( props, "auto_on_scene_change", "Auto add chapter on scene change" ) obs.obs_properties_add_bool( props, "write_session_header", "Write session header on start" ) local overrides_prop = obs.obs_properties_add_text( props, "overrides_raw", "Scene label overrides (one per line: Scene = Label)", obs.OBS_TEXT_MULTILINE ) -- Button to clear file quickly obs.obs_properties_add_button( props, "clear_file_btn", "Clear chapters file now", function(props, property) if chapter_file_path ~= nil and chapter_file_path ~= "" then local f = io.open(chapter_file_path, "w") if f then f:close() log("Cleared chapters file.") else log("Failed to clear chapters file.") end end return true end ) return props end function script_update(settings) chapter_file_path = obs.obs_data_get_string(settings, "chapter_file_path") auto_on_scene_change = obs.obs_data_get_bool(settings, "auto_on_scene_change") write_session_header = obs.obs_data_get_bool(settings, "write_session_header") overrides_raw = obs.obs_data_get_string(settings, "overrides_raw") parse_scene_overrides() end function script_load(settings) update_video_info() -- Hotkey registration hotkey_add_chapter_id = obs.obs_hotkey_register_frontend( "smart_chapters_add_chapter", "Smart Chapters: Add chapter (current scene)", on_add_chapter_hotkey ) local hotkey_save_array = obs.obs_data_get_array(settings, "smart_chapters_add_chapter") obs.obs_hotkey_load(hotkey_add_chapter_id, hotkey_save_array) obs.obs_data_array_release(hotkey_save_array) obs.obs_frontend_add_event_callback(on_frontend_event) end function script_save(settings) -- Save hotkey local hotkey_save_array = obs.obs_hotkey_save(hotkey_add_chapter_id) obs.obs_data_set_array(settings, "smart_chapters_add_chapter", hotkey_save_array) obs.obs_data_array_release(hotkey_save_array) end function script_unload() -- Nothing special needed; OBS will clean up end