-- Instant Replay VLC 2.0.0-rc1 -- Save -> optional copy -> one-entry VLC playlist; session-only replay history. -- Existing settings and five hotkey IDs are deliberately preserved. local obs = obslua local cfg = {source = "", folder = "", copied = true, timeout = 10} local history, selected = {}, 0 local job, timer_running, loaded = nil, false, false local status = "Ready. Select a dedicated VLC source and check setup." local sequence = 0 local awaiting_completion = false local tick, on_event local hotkeys = {} local CHUNK, TICK_MS = 256 * 1024, 20 local function report(message, level) status = message obs.script_log(level or obs.LOG_INFO, "[Instant Replay VLC] " .. message) end local function now() return tonumber(obs.os_gettime_ns()) / 1000000000 end local function basename(path) return path:match("([^\\/]+)$") or path end local function join(folder, name) return folder .. (folder:match("[\\/]$") and "" or package.config:sub(1, 1)) .. name end local function source_for(name) if name == "" then return nil, "Select a VLC source in Tools > Scripts." end local s = obs.obs_get_source_by_name(name) if not s then return nil, "VLC source not found: " .. name end if obs.obs_source_get_id(s) ~= "vlc_source" then obs.obs_source_release(s) return nil, "Selected source is not a VLC Video Source: " .. name end return s end local function readable(path) local f, err = io.open(path, "rb") if not f then return false, err end local size, size_err = f:seek("end") f:close() return size ~= nil and size > 0, size_err or "File is empty or unavailable." end local function set_playlist(name, path) local s, err = source_for(name) if not s then return false, err end local settings, array = obs.obs_data_create(), obs.obs_data_array_create() if path then local item = obs.obs_data_create() obs.obs_data_set_string(item, "value", path) obs.obs_data_array_push_back(array, item) obs.obs_data_release(item) end obs.obs_data_set_array(settings, "playlist", array) obs.obs_source_update(s, settings) obs.obs_data_array_release(array) obs.obs_data_release(settings) obs.obs_source_release(s) return true -- Settings submitted; decoding/playback is owned by VLC. end local function stop_timer() if timer_running then obs.timer_remove(tick); timer_running = false end end local function cleanup_copy(j) if j.input then pcall(function() j.input:close() end); j.input = nil end if j.output then pcall(function() j.output:close() end); j.output = nil end if j.temp then if obs.os_unlink(j.temp) ~= 0 then report("Could not remove partial copy: " .. j.temp, obs.LOG_WARNING) end j.temp = nil end if j.lock then if obs.os_rmdir(j.lock) ~= 0 then report("Temporary directory remains: " .. j.lock, obs.LOG_WARNING) end j.lock = nil end end local function cancel(message) stop_timer() if job then cleanup_copy(job); job = nil end if message then report(message, obs.LOG_WARNING) end end local function select_entry(index) local entry = history[index] if not entry then return false end local ok = readable(entry.path) if not ok then return false end local updated, err = set_playlist(cfg.source, entry.path) if not updated then report(err, obs.LOG_WARNING); return false, "source" end selected = index report(string.format("Selected %d/%d: %s", selected, #history, basename(entry.path))) return true end local function finish(path, warning) local j = job if not j then return end cleanup_copy(j) stop_timer() job = nil local ok, err = readable(path) if not ok then report("Replay unavailable: " .. path .. " (" .. tostring(err) .. ")", obs.LOG_ERROR); return end if #history == 0 or history[#history].path ~= path then history[#history + 1] = {path = path} end local updated, update_err = set_playlist(j.source, path) if updated then selected = #history report(string.format("%sSelected %d/%d: %s", warning and (warning .. " ") or "", selected, #history, basename(path)), warning and obs.LOG_WARNING or obs.LOG_INFO) else report("Replay saved in history, but not selected: " .. update_err, obs.LOG_WARNING) end end local function copy_failed(reason) finish(job.path, "Copy failed; using original. " .. tostring(reason)) end -- Reserve a name using an exclusive directory. Other instances of this script -- respect the same reservation. Never use this folder with external writers -- that deliberately replace these names; Lua has no portable no-replace rename. local function reserve_copy(j) local filename = basename(j.path) local base, ext = filename:match("^(.*)(%.[^%.]+)$") base, ext = base or filename, ext or "" local stem = base .. "_" .. os.date("%Y%m%d_%H%M%S") for n = 1, 1000 do local destination = join(j.folder, stem .. (n == 1 and "" or ("_" .. n)) .. ext) if not obs.os_file_exists(destination) then local lock = destination .. ".irvlc-lock" local result = obs.os_mkdir(lock) if result == 0 then if obs.os_file_exists(destination) then obs.os_rmdir(lock) else j.destination, j.lock = destination, lock return true end elseif result == -1 then return false, "Copy folder is unavailable or not writable." end end end return false, "Could not reserve a unique copy filename." end local function start_copy(j) if not obs.os_mkdir or not obs.os_rmdir or not obs.os_unlink or not obs.os_file_exists then copy_failed("Required filesystem bindings unavailable in this OBS build."); return end local ok, err = reserve_copy(j) if not ok then copy_failed(err); return end j.input, err = io.open(j.path, "rb") if not j.input then copy_failed(err); return end j.size, err = j.input:seek("end") if not j.size or j.size <= 0 then copy_failed(err or "Empty replay."); return end if not j.input:seek("set", 0) then copy_failed("Cannot seek original."); return end local temp = join(j.lock, "replay.part") j.output, err = io.open(temp, "wb") if not j.output then copy_failed(err); return end j.temp, j.bytes, j.state = temp, 0, "copying" j.deadline = now() + 300 report("Copying replay: " .. basename(j.path)) end local function get_last_path() local output = obs.obs_frontend_get_replay_buffer_output() if not output then return nil end local cd = obs.calldata_create() local ok = obs.proc_handler_call(obs.obs_output_get_proc_handler(output), "get_last_replay", cd) local path = ok and obs.calldata_string(cd, "path") or nil obs.calldata_destroy(cd) obs.obs_output_release(output) return path end local function process_tick() local j = job if not j then stop_timer(); return end if now() >= j.deadline then if j.state == "copying" then copy_failed("Copy exceeded five minutes.") else cancel("Timed out waiting for OBS. The recording may still finish; no old replay was selected.") end return end if j.state ~= "copying" then return end -- At most 1 MiB per tick; yield sooner if the 4 ms work budget is consumed. local started = now() for _ = 1, 4 do local chunk, err = j.input:read(CHUNK) if not chunk then if err or j.bytes ~= j.size then copy_failed(err or "Source size changed or read was incomplete."); return end local closed, close_err = j.output:close(); j.output = nil if not closed then copy_failed(close_err or "Could not finalize copy."); return end j.input:close(); j.input = nil local check = io.open(j.temp, "rb") local actual = check and check:seek("end") if check then check:close() end if actual ~= j.size then copy_failed("Copied size does not match original."); return end if obs.os_file_exists(j.destination) then copy_failed("Destination appeared during copying; not overwritten."); return end local renamed, rename_err = os.rename(j.temp, j.destination) if not renamed then copy_failed(rename_err or "Cannot publish copy."); return end j.temp = nil finish(j.copied and j.destination or j.path) return end local written, write_err = j.output:write(chunk) if not written then copy_failed(write_err or "Write failed."); return end j.bytes = j.bytes + #chunk if j.bytes > j.size then copy_failed("Original replay changed during copying."); return end if now() - started >= 0.004 then return end end end tick = function() local ok, err = pcall(process_tick) if not ok then cancel("Processing stopped safely: " .. tostring(err)) end end function instant_replay(pressed) if not pressed then return end if job then report("Replay processing is already in progress; additional save ignored.", obs.LOG_WARNING); return end if awaiting_completion then report("Waiting for the canceled/timed-out OBS save to finish. If it is stuck, stop and restart Replay Buffer.", obs.LOG_WARNING); return end if not loaded then report("Script is not ready.", obs.LOG_WARNING); return end if not obs.obs_frontend_replay_buffer_active() then report("Start Replay Buffer in OBS before saving a replay.", obs.LOG_WARNING); return end sequence = sequence + 1 job = {id = sequence, state = "saving", source = cfg.source, folder = cfg.folder, copied = cfg.copied, deadline = now() + cfg.timeout} awaiting_completion = true obs.timer_add(tick, TICK_MS); timer_running = true report("Save requested; waiting for OBS.") obs.obs_frontend_replay_buffer_save() end on_event = function(event) if event == obs.OBS_FRONTEND_EVENT_REPLAY_BUFFER_SAVED then awaiting_completion = false -- Only process a pending script request. Native saves otherwise retain -- their original OBS-only behavior. OBS events do not contain request IDs. if not job or job.state ~= "saving" then return end local path = get_last_path() if not path or path == "" then cancel("OBS reported a save, but returned no replay path."); return end job.path = path if job.folder ~= "" then local ok, err = pcall(start_copy, job) if not ok then cancel("Cannot prepare copy: " .. tostring(err)) end else finish(path) end elseif event == obs.OBS_FRONTEND_EVENT_REPLAY_BUFFER_STOPPED then awaiting_completion = false if job and job.state == "saving" then cancel("Replay buffer stopped before completion was received.") end elseif event == obs.OBS_FRONTEND_EVENT_SCENE_COLLECTION_CHANGING or event == obs.OBS_FRONTEND_EVENT_PROFILE_CHANGING then cancel("OBS context changed; pending replay processing canceled.") history, selected = {}, 0 end end local function navigate(direction) if #history == 0 then report("No replays in this session.", obs.LOG_WARNING); return end local index = selected == 0 and (direction == 1 and 1 or #history) or selected + direction while index >= 1 and index <= #history do local ok, reason = select_entry(index) if ok or reason == "source" then return end index = index + direction end report("No further available replay in that direction. Current selection unchanged.") end function replay_prev(pressed) if pressed then navigate(-1) end end function replay_next(pressed) if pressed then navigate(1) end end function replay_latest(pressed) if not pressed then return end for index = #history, 1, -1 do if index == selected and readable(history[index].path) then report("Latest available replay is already selected."); return end local ok, reason = select_entry(index) if ok or reason == "source" then return end end report("No available replay in this session.", obs.LOG_WARNING) end function clear_vlc_playlist(pressed) if not pressed then return end cancel() history, selected = {}, 0 local ok, err = set_playlist(cfg.source, nil) report(ok and "Playlist and history cleared. Recordings were not deleted." or ("History cleared; playlist unchanged: " .. err), ok and obs.LOG_INFO or obs.LOG_WARNING) end local function check_setup() local s, err = source_for(cfg.source) if not s then report(err, obs.LOG_WARNING); return end obs.obs_source_release(s) if cfg.folder ~= "" then if not obs.os_opendir or not obs.os_mkdir or not obs.os_rmdir or not obs.os_unlink or not obs.os_file_exists then report("Cannot validate folder with this OBS build.", obs.LOG_WARNING); return end local dir = obs.os_opendir(cfg.folder) if not dir then report("Copy folder is not an accessible directory.", obs.LOG_WARNING); return end obs.os_closedir(dir) -- A write probe is deliberately performed only when the user clicks Check. local probe = {path = "setup-check.tmp", folder = cfg.folder} local ok, why = reserve_copy(probe) if not ok then report(why, obs.LOG_WARNING); return end local temp = join(probe.lock, "probe.tmp") probe.output, why = io.open(temp, "wb") if probe.output then probe.temp = temp ok, why = probe.output:write("Instant Replay VLC setup check") local closed, close_err = probe.output:close(); probe.output = nil if not closed then ok, why = false, close_err end else ok = false end cleanup_copy(probe) if not ok then report("Copy folder write check failed: " .. tostring(why), obs.LOG_WARNING); return end end report(obs.obs_frontend_replay_buffer_active() and "Setup check passed. Use a dedicated VLC source; scene switching remains manual." or "Source/folder checks passed. Start Replay Buffer before saving.") end function script_description() return [[Instant Replay VLC 1.4 Save replays from OBS, optionally copy them to another folder, and load them into a dedicated VLC Video Source. Browse your saved replays using the Previous, Next, and Latest hotkeys. Getting started 1. Create a dedicated VLC Video Source and select it in the script settings. 2. Choose a copy folder if you want an additional copy of each replay. 3. Configure Save, Previous, Next, Latest, and Clear under Settings > Hotkeys. 4. Click Check setup, then start OBS’s Replay Buffer. 5. Use the script’s Save Replay hotkey to save and select a replay. Existing hotkey bindings are retained when updating the script in place. Picture-in-picture Load a replay first, then pause playback while positioning and resizing the VLC source in your scene. New replays use that same layout. Scene switching, looping, and audio settings remain under your control. Things to know * The script replaces the selected VLC source’s playlist. Use a separate source for replays. * Replay history resets when OBS closes or you change profiles or scene collections. Your saved video files remain on disk and can still be opened manually. * Clear empties the playlist and history and cancels pending script processing. It does not delete your recordings. * Wait for processing to finish before saving again. Avoid using other replay-save buttons or hotkeys at the same time. * Use a local drive where possible. Slow or disconnected network storage can affect OBS responsiveness. * Click Refresh sources / status to update the source list and status display. ]] end local function options_changed(props, _, settings) obs.obs_property_set_enabled(obs.obs_properties_get(props, "use_copied_file"), obs.obs_data_get_string(settings, "copy_folder") ~= "") return true end function script_properties() local props = obs.obs_properties_create() local list = obs.obs_properties_add_list(props, "source", "Dedicated VLC Video Source", obs.OBS_COMBO_TYPE_LIST, obs.OBS_COMBO_FORMAT_STRING) obs.obs_property_list_add_string(list, "Select a VLC source", "") local sources, found = obs.obs_enum_sources(), false if sources then for _, s in ipairs(sources) do if obs.obs_source_get_id(s) == "vlc_source" then local name = obs.obs_source_get_name(s) obs.obs_property_list_add_string(list, name, name) if name == cfg.source then found = true end end end obs.source_list_release(sources) end if cfg.source ~= "" and not found then obs.obs_property_list_add_string(list, "Missing: " .. cfg.source, cfg.source) end local folder = obs.obs_properties_add_path(props, "copy_folder", "Copy replays to folder (optional)", obs.OBS_PATH_DIRECTORY, "", "") obs.obs_property_set_modified_callback(folder, options_changed) local copied = obs.obs_properties_add_bool(props, "use_copied_file", "Play copied replay") obs.obs_property_set_enabled(copied, cfg.folder ~= "") obs.obs_properties_add_int(props, "timeout_seconds", "Save timeout (seconds)", 2, 120, 1) obs.obs_properties_add_button(props, "check", "Check setup", function() check_setup(); return true end) obs.obs_properties_add_button(props, "refresh", "Refresh sources / status", function() return true end) obs.obs_properties_add_text(props, "status", "Status: " .. status, obs.OBS_TEXT_INFO) return props end function script_defaults(settings) obs.obs_data_set_default_bool(settings, "use_copied_file", true) obs.obs_data_set_default_int(settings, "interval", 500) obs.obs_data_set_default_int(settings, "max_attempts", 20) end function script_update(settings) local source = obs.obs_data_get_string(settings, "source") if source ~= cfg.source then cancel() selected = 0 end cfg.source = source cfg.folder = obs.obs_data_get_string(settings, "copy_folder") cfg.copied = obs.obs_data_get_bool(settings, "use_copied_file") local timeout = obs.obs_data_get_int(settings, "timeout_seconds") if timeout <= 0 then timeout = obs.obs_data_get_int(settings, "interval") * obs.obs_data_get_int(settings, "max_attempts") / 1000 if timeout <= 0 then timeout = 10 end end cfg.timeout = math.max(2, math.min(120, math.ceil(timeout))) obs.obs_data_set_int(settings, "timeout_seconds", cfg.timeout) end function script_load(settings) script_update(settings) if not obs.OBS_FRONTEND_EVENT_REPLAY_BUFFER_SAVED or not obs.os_gettime_ns then report("Unsupported OBS build: replay saved events and monotonic timer are required.", obs.LOG_ERROR); return end local definitions = { {"trigger", "Save Replay", instant_replay}, {"prev", "Previous Replay", replay_prev}, {"next", "Next Replay", replay_next}, {"latest", "Latest Replay", replay_latest}, {"clear_playlist", "Clear playlist + history", clear_vlc_playlist} } for _, definition in ipairs(definitions) do local key = "instant_replay_vlc." .. definition[1] local id = obs.obs_hotkey_register_frontend(key, "Instant Replay VLC: " .. definition[2], definition[3]) hotkeys[key] = id local data = obs.obs_data_get_array(settings, key) obs.obs_hotkey_load(id, data) obs.obs_data_array_release(data) end obs.obs_frontend_add_event_callback(on_event) loaded = true report("Loaded V1.4. Existing hotkeys retained; check setup before going live.") end function script_save(settings) for key, id in pairs(hotkeys) do local data = obs.obs_hotkey_save(id) obs.obs_data_set_array(settings, key, data) obs.obs_data_array_release(data) end end function script_unload() cancel() if loaded then obs.obs_frontend_remove_event_callback(on_event) end loaded = false history, selected = {}, 0 -- OBS owns and unregisters script hotkeys on unload. end