From bb958a2498e8b78cbf95fc300cf5d8d2c6288cfa Mon Sep 17 00:00:00 2001 From: raw-brt Date: Sun, 12 Jul 2026 12:05:08 +0200 Subject: [PATCH] Apply recording path/format changes to a running output (#164-adjacent) A live Source Record recording ignored changes to its recording path, format or filename pattern until the filter was disabled/re-enabled: source_record_filter_update only (re)started the file output when the record state changed, never when the settings changed while already recording. Users saw new recordings keep going to the old path (reported on the OBS forum: "file format and path revert unless the filter is disabled/re-enabled between changes"). Cache the path/rec_format/filename_formatting the output was started with, and on an update while recording, if any changed, restart the output the same way a replay-duration change already does (disconnecting the remove-after-record "stop" handler first). A live ffmpeg muxer can't change its file mid-write, so this splits the recording: the current file finalizes (moov written, playable) and a new one starts with the new settings. Only fires while recording; an idle filter already picks up new settings on its next start. Validated over obs-websocket (tests/repro_settings_restart.py): record into dir A, change the filter path to dir B while recording -> a new growing file appears in B and the file in A is finalized with its moov atom. Co-Authored-By: Claude --- source-record.c | 44 +++++++++++ tests/repro_settings_restart.py | 135 ++++++++++++++++++++++++++++++++ tests/requirements.txt | 1 + 3 files changed, 180 insertions(+) create mode 100644 tests/repro_settings_restart.py create mode 100644 tests/requirements.txt diff --git a/source-record.c b/source-record.c index f1f60ca..6d43c45 100644 --- a/source-record.c +++ b/source-record.c @@ -56,6 +56,11 @@ struct source_record_filter_context { bool remove_after_record; long long record_max_seconds; int last_frontend_event; + /* Last recording path/format/filename applied to the live fileOutput, so a + * change while recording can be detected and the output restarted. */ + struct dstr rec_path; + struct dstr rec_format; + struct dstr rec_filename; }; DARRAY(obs_source_t *) source_record_filters; @@ -517,11 +522,28 @@ static void update_video_encoder(struct source_record_filter_context *filter, ob obs_output_set_video_encoder(filter->replayOutput, filter->encoder); } +/* True if the recording path, format or filename pattern differs from what the + * live fileOutput was started with (the cache is refreshed by start_file_output + * when the output is restarted). */ +static bool record_settings_changed(struct source_record_filter_context *filter, obs_data_t *settings) +{ + const char *path = obs_data_get_string(settings, "path"); + const char *format = obs_data_get_string(settings, "rec_format"); + const char *filename = obs_data_get_string(settings, "filename_formatting"); + return dstr_cmp(&filter->rec_path, path) != 0 || dstr_cmp(&filter->rec_format, format) != 0 || + dstr_cmp(&filter->rec_filename, filename) != 0; +} + static void start_file_output(struct source_record_filter_context *filter, obs_data_t *settings) { obs_data_t *s = obs_data_create(); char path[512]; const char *format = obs_data_get_string(settings, "rec_format"); + /* Remember what this output is being started with, so a later path/format/ + * filename change while recording can be detected (see update). */ + dstr_copy(&filter->rec_path, obs_data_get_string(settings, "path")); + dstr_copy(&filter->rec_format, format); + dstr_copy(&filter->rec_filename, obs_data_get_string(settings, "filename_formatting")); char *filename = os_generate_formatted_filename(GetFormatExt(format), true, obs_data_get_string(settings, "filename_formatting")); snprintf(path, 512, "%s/%s", obs_data_get_string(settings, "path"), filename); @@ -963,6 +985,25 @@ static void source_record_filter_update(void *data, obs_data_t *settings) } } filter->record = record; + } else if (record && filter->fileOutput && obs_source_enabled(filter->source) && !filter->closing && + record_settings_changed(filter, settings)) { + /* Apply a path/format/filename change to an already-running recording. + * A live ffmpeg muxer can't change its file mid-write, so restart splits + * the recording: the current file finalizes and a new one starts with the + * new settings. Only fires while recording; an idle filter picks up new + * settings on its next start. Mirrors the replay-duration restart below. */ + blog(LOG_INFO, + "[Source Record] recording path/format changed while active; restarting output (splits the file)"); + signal_handler_t *sh = obs_output_get_signal_handler(filter->fileOutput); + if (sh) + signal_handler_disconnect(sh, "stop", remove_filter, filter); + struct stop_output *so = bmalloc(sizeof(struct stop_output)); + so->output = filter->fileOutput; + so->context = filter; + run_queued(force_stop_output_task, so); + filter->fileOutput = NULL; + if (filter->video_output) + start_file_output(filter, settings); } if (record && filter->fileOutput && filter->last_frontend_event == OBS_FRONTEND_EVENT_RECORDING_PAUSED && @@ -1271,6 +1312,9 @@ static void source_record_filter_destroy(void *data) } context->source = NULL; + dstr_free(&context->rec_path); + dstr_free(&context->rec_format); + dstr_free(&context->rec_filename); bfree(context); } diff --git a/tests/repro_settings_restart.py b/tests/repro_settings_restart.py new file mode 100644 index 0000000..2c7094e --- /dev/null +++ b/tests/repro_settings_restart.py @@ -0,0 +1,135 @@ +#!/usr/bin/env python3 +"""Regression test: changing the recording path/format while a Source Record +filter is actively recording must apply immediately by restarting the output +(splitting the file), instead of being silently ignored until the filter is +disabled/re-enabled (forum report: settings "revert unless disabled/re-enabled"). + +Flow: record into dir A, confirm growth, then set the filter's "path" to dir B. +Expect: a new file appears in B and grows, and the file in A finalizes (moov). + +Exit: 0 pass, 1 fail (change not applied), 2 setup abort. Env: OBS_WS_PASSWORD. +""" +import glob +import logging +import os +import struct +import sys +import time + +import obsws_python as obs + +logging.getLogger("obsws_python").setLevel(logging.CRITICAL) +HOST = os.environ.get("OBS_WS_HOST", "localhost") +PORT = int(os.environ.get("OBS_WS_PORT", "4455")) +PW = os.environ.get("OBS_WS_PASSWORD", "") +VENDOR = "source-record" +SUF = str(os.getpid()) +SCENE, SRC, FILT = f"SR_RESTART_SC_{SUF}", f"SR_RESTART_SRC_{SUF}", "SR_RESTART_F" + + +def vendor(cl, rt, data=None): + r = cl.send("CallVendorRequest", {"vendorName": VENDOR, "requestType": rt, "requestData": data or {}}, raw=True) + return r.get("responseData", {}) + + +def newest(d): + fs = glob.glob(os.path.join(d, "*")) + return max(fs, key=os.path.getmtime) if fs else None + + +def grow(d, secs, step=1.5): + out, prev = [], os.path.getsize(newest(d)) if newest(d) else 0 + end = time.time() + secs + while time.time() < end: + time.sleep(step) + cur = os.path.getsize(newest(d)) if newest(d) else 0 + out.append(cur - prev) + prev = cur + return out + + +def has_moov(p): + try: + off, end = 0, os.path.getsize(p) + with open(p, "rb") as f: + while off + 8 <= end: + f.seek(off) + h = f.read(16) + sz = struct.unpack(">I", h[:4])[0] + nm = h[4:8].decode("latin1") + if sz == 1: + sz = struct.unpack(">Q", h[8:16])[0] + elif sz == 0: + sz = end - off + if nm == "moov": + return True + if sz < 8: + break + off += sz + except Exception: + pass + return False + + +def main(): + if not PW: + print("OBS_WS_PASSWORD not set", file=sys.stderr) + return 2 + base = os.environ.get("TMPDIR", "/tmp") + dirA = os.path.join(base, f"sr_restart_A_{SUF}") + dirB = os.path.join(base, f"sr_restart_B_{SUF}") + os.makedirs(dirA, exist_ok=True) + os.makedirs(dirB, exist_ok=True) + cl = obs.ReqClient(host=HOST, port=PORT, password=PW, timeout=5) + print("Connected:", cl.get_version().obs_version, flush=True) + for fn in (lambda: cl.remove_input(SRC), lambda: cl.remove_scene(SCENE)): + try: + fn() + except Exception: + pass + try: + cl.create_scene(SCENE) + kinds = cl.get_input_kind_list(False).input_kinds + ck = next((k for k in kinds if k.startswith("color_source")), "color_source_v3") + cl.create_input(SCENE, SRC, ck, {"width": 640, "height": 360}, True) + cl.create_source_filter(SRC, FILT, "source_record_filter", + {"path": dirA, "record_mode": 0, "stream_mode": 0}) + time.sleep(1.0) + vendor(cl, "record_start", {"source": SRC}) + time.sleep(2.0) + a1 = newest(dirA) + gA = grow(dirA, 6) + print(f"A growth KB={[d // 1024 for d in gA]} file={os.path.basename(a1) if a1 else None}", flush=True) + if not a1 or sum(gA) <= 0: + print("RESULT: not recording into A at baseline — abort", flush=True) + return 2 + + # --- change the path to B while recording --- + print("--- set path -> B while recording ---", flush=True) + cl.set_source_filter_settings(SRC, FILT, {"path": dirB}, overlay=True) + time.sleep(5.0) + + b = newest(dirB) + gB = grow(dirB, 6) + print(f"B growth KB={[d // 1024 for d in gB]} file={os.path.basename(b) if b else None}", flush=True) + a_final = has_moov(a1) if a1 else False + print(f"A finalized (moov)={a_final}", flush=True) + + ok = bool(b) and sum(gB) > 0 + print(f"VERDICT={'APPLIED' if ok else 'IGNORED'} (new file in B growing={ok})", flush=True) + vendor(cl, "record_stop", {"source": SRC}) + time.sleep(2) + return 0 if ok else 1 + finally: + for fn in (lambda: cl.remove_input(SRC), lambda: cl.remove_scene(SCENE)): + try: + fn() + except Exception: + pass + import shutil + shutil.rmtree(dirA, ignore_errors=True) + shutil.rmtree(dirB, ignore_errors=True) + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/tests/requirements.txt b/tests/requirements.txt new file mode 100644 index 0000000..9f44944 --- /dev/null +++ b/tests/requirements.txt @@ -0,0 +1 @@ +obsws-python>=1.8