Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
44 changes: 44 additions & 0 deletions source-record.c
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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 &&
Expand Down Expand Up @@ -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);
}

Expand Down
135 changes: 135 additions & 0 deletions tests/repro_settings_restart.py
Original file line number Diff line number Diff line change
@@ -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())
1 change: 1 addition & 0 deletions tests/requirements.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
obsws-python>=1.8