Skip to content

Commit 288b272

Browse files
committed
fix: Correct filename script reliability in Lua and Python variants
- Defer last_text update to proc call success to prevent failed calls from being silently swallowed on subsequent ticks - Fix 4-branch new_format logic in Python to match Lua (trailing space, unintended clear when base_format is empty) - Add UTF-8 validation for non-UTF-8 file content (CP932 etc.) - Add cp_to_utf8 surrogate-half guard - Detect and warn on file truncation at MAX_READ_SIZE - Unify sanitize_filename step order with Python variant - Move MAX_FILENAME_BYTES to module-level constants - Log warning on JSON parse failure - Remove redundant state resets after clear_override()
1 parent e4d9986 commit 288b272

4 files changed

Lines changed: 203 additions & 53 deletions

File tree

data/scripts/recording-filename-from-text.lua

Lines changed: 81 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -68,7 +68,9 @@ local function get_branch_output_filters()
6868
local json_str = obs.calldata_string(cd, "json")
6969
if json_str and json_str ~= "" then
7070
local data = obs.obs_data_create_from_json(json_str)
71-
if data ~= nil then
71+
if data == nil then
72+
obs.script_log(obs.LOG_WARNING, "Failed to parse filter list JSON")
73+
else
7274
local array = obs.obs_data_get_array(data, "filters")
7375
if array ~= nil then
7476
local count = obs.obs_data_array_count(array)
@@ -99,6 +101,46 @@ local function get_branch_output_filters()
99101
return filters
100102
end
101103

104+
local function utf8_validate(s)
105+
-- Returns true when every byte in s is part of a valid UTF-8 sequence.
106+
-- Returns false when an unexpected non-ASCII byte appears (e.g. CP932).
107+
local i = 1
108+
local len = #s
109+
while i <= len do
110+
local b = string.byte(s, i)
111+
local size
112+
if b < 0x80 then
113+
size = 1
114+
elseif b >= 0xC2 and b <= 0xDF and i + 1 <= len then
115+
local b2 = string.byte(s, i + 1)
116+
if b2 >= 0x80 and b2 <= 0xBF then
117+
size = 2
118+
end
119+
elseif b >= 0xE0 and b <= 0xEF and i + 2 <= len then
120+
local b2 = string.byte(s, i + 1)
121+
local b3 = string.byte(s, i + 2)
122+
if b2 >= 0x80 and b2 <= 0xBF and b3 >= 0x80 and b3 <= 0xBF
123+
and (b ~= 0xE0 or b2 >= 0xA0) and (b ~= 0xED or b2 <= 0x9F) then
124+
size = 3
125+
end
126+
elseif b >= 0xF0 and b <= 0xF4 and i + 3 <= len then
127+
local b2 = string.byte(s, i + 1)
128+
local b3 = string.byte(s, i + 2)
129+
local b4 = string.byte(s, i + 3)
130+
if b2 >= 0x80 and b2 <= 0xBF and b3 >= 0x80 and b3 <= 0xBF
131+
and b4 >= 0x80 and b4 <= 0xBF
132+
and (b ~= 0xF0 or b2 >= 0x90) and (b ~= 0xF4 or b2 <= 0x8F) then
133+
size = 4
134+
end
135+
end
136+
if not size then
137+
return false
138+
end
139+
i = i + size
140+
end
141+
return true
142+
end
143+
102144
local function parse_selected_filter(value)
103145
-- Parse 'source_uuid::filter_uuid' into (source_uuid, filter_uuid).
104146
if value == nil then
@@ -187,6 +229,9 @@ end
187229
local function cp_to_utf8(cp)
188230
-- Encode a Unicode code point to a UTF-8 byte string.
189231
-- Compatible with LuaJIT / Lua 5.1 (no utf8 library required).
232+
if cp >= 0xD800 and cp <= 0xDFFF then
233+
return "" -- surrogate halves are not valid UTF-8 scalars
234+
end
190235
if cp <= 0x7F then
191236
return string.char(cp)
192237
elseif cp <= 0x7FF then
@@ -206,13 +251,15 @@ local function cp_to_utf8(cp)
206251
end
207252

208253
local function sanitize_filename(text)
209-
-- Produce a filesystem-safe prefix:
210-
-- 1. Walk the string as UTF-8 code points, dropping strip-list points
211-
-- and folding whitespace-like points to a single ASCII space.
254+
-- Produce a filesystem-safe prefix. Order matches the Python variant:
255+
-- 1. Walk as UTF-8 code points, drop strip-list points, fold whitespace-
256+
-- like points to a single ASCII space.
212257
-- 2. Collapse runs of whitespace and trim.
213258
-- 3. Replace filesystem-unsafe characters with "-".
214-
-- 4. Strip trailing dots/spaces (Windows disallows these at end of filename).
215-
-- 5. Prefix an underscore if the result collides with a Windows reserved name.
259+
-- 4. Strip trailing dots/spaces (Windows disallows these at end).
260+
-- 5. Truncate to 200 bytes on UTF-8 codepoint boundaries.
261+
-- 6. Re-strip trailing dots/spaces that may appear at the new end.
262+
-- 7. Prefix an underscore if the result collides with a Windows reserved name.
216263
if text == nil then
217264
return ""
218265
end
@@ -231,14 +278,6 @@ local function sanitize_filename(text)
231278
local trimmed = cleaned:match("^%s*(.-)%s*$") or ""
232279
local sanitized = trimmed:gsub('[<>:"|?*/\\]', "-")
233280
sanitized = sanitized:gsub("[%.%s]+$", "")
234-
-- Windows treats reserved device names as reserved even when followed by
235-
-- an extension (e.g. "CON.txt"). Since this prefix will have the base
236-
-- format appended after a dot+space, check the portion before the first
237-
-- dot as well.
238-
local base_before_dot = sanitized:match("^([^%.]*)") or sanitized
239-
if WINDOWS_RESERVED[sanitized:upper()] or WINDOWS_RESERVED[base_before_dot:upper()] then
240-
sanitized = "_" .. sanitized
241-
end
242281
-- Truncate to 200 bytes to stay within NTFS filename component (255) /
243282
-- MAX_PATH (260) limits, leaving room for the base format and extension.
244283
-- Respect UTF-8 codepoint boundaries.
@@ -258,6 +297,14 @@ local function sanitize_filename(text)
258297
-- Re-strip trailing dots/spaces that may appear at the new end.
259298
sanitized = sanitized:gsub("[%.%s]+$", "")
260299
end
300+
-- Windows treats reserved device names as reserved even when followed by
301+
-- an extension (e.g. "CON.txt"). Since this prefix will have the base
302+
-- format appended after a dot+space, check the portion before the first
303+
-- dot as well.
304+
local base_before_dot = sanitized:match("^([^%.]*)") or sanitized
305+
if WINDOWS_RESERVED[sanitized:upper()] or WINDOWS_RESERVED[base_before_dot:upper()] then
306+
sanitized = "_" .. sanitized
307+
end
261308
return sanitized
262309
end
263310

@@ -280,7 +327,15 @@ local function read_text_from_source(text_source)
280327
if f then
281328
-- Limit read size to prevent performance issues on accidental large-file selection.
282329
local data = f:read(MAX_READ_SIZE) or ""
330+
-- Detect silent truncation: if more bytes remain past MAX_READ_SIZE, warn.
331+
local extra = f:read(1)
332+
local truncated = extra ~= nil
283333
f:close()
334+
if truncated then
335+
obs.script_log(obs.LOG_WARNING,
336+
"Text file exceeds " .. MAX_READ_SIZE ..
337+
" bytes; only the first " .. MAX_READ_SIZE .. " bytes are used")
338+
end
284339
-- UTF-8 BOM: strip it.
285340
if data:sub(1, 3) == "\239\187\191" then
286341
data = data:sub(4)
@@ -290,6 +345,13 @@ local function read_text_from_source(text_source)
290345
"UTF-16 text files are not supported; please save the text file as UTF-8")
291346
ok = false
292347
end
348+
-- Skip strict UTF-8 validation when truncated, since the cut may
349+
-- have landed inside a multibyte sequence.
350+
if ok and not truncated and not utf8_validate(data) then
351+
obs.script_log(obs.LOG_WARNING,
352+
"Text file is not valid UTF-8 (e.g. CP932); please save as UTF-8")
353+
ok = false
354+
end
293355
if ok then
294356
result_text = data
295357
end
@@ -376,13 +438,14 @@ local function update_recording_format()
376438
return
377439
end
378440

379-
-- Skip if text hasn't changed
441+
-- Skip if text hasn't changed since last observation
380442
if current_text == last_text then
381443
return
382444
end
383-
last_text = current_text
384445

385-
-- Throttle: skip if the same text was already applied within THROTTLE_SECONDS
446+
-- Throttle: skip if the same text was already applied within THROTTLE_SECONDS.
447+
-- last_text is updated only after a successful apply so a throttled tick
448+
-- is not swallowed by the equality early-return on the next tick.
386449
local now = os.time()
387450
if current_text == last_applied_text and (now - last_applied_time) < THROTTLE_SECONDS then
388451
return
@@ -407,6 +470,7 @@ local function update_recording_format()
407470
end
408471

409472
if call_override_proc(filter_uuid, new_format) then
473+
last_text = current_text
410474
last_applied_text = current_text
411475
last_applied_time = now
412476
override_cleared = false

data/scripts/recording-filename-from-text.py

Lines changed: 20 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,10 @@
3535
BRANCH_OUTPUT_FILTER_ID = "osi_branch_output"
3636
LOG_LABEL = "Recording filename format"
3737
MAX_READ_SIZE = 4096 # 4 KB read limit to prevent performance issues on large files
38+
# Truncate to 200 bytes to stay within NTFS filename component (255) /
39+
# MAX_PATH (260) limits, leaving room for the base format and extension.
40+
MAX_FILENAME_BYTES = 200
41+
THROTTLE_SECONDS = 30
3842

3943
# Windows reserved device names (case-insensitive).
4044
WINDOWS_RESERVED = {
@@ -51,7 +55,6 @@
5155
last_applied_text = None
5256
last_applied_time = 0.0
5357
override_cleared = False
54-
THROTTLE_SECONDS = 30
5558

5659

5760
def get_branch_output_filters():
@@ -116,10 +119,7 @@ def sanitize_filename(text):
116119
sanitized = sanitized.replace(ch, '-')
117120
# Strip trailing dots/spaces
118121
sanitized = sanitized.rstrip(". ")
119-
# Truncate to 200 bytes to stay within NTFS filename component (255) /
120-
# MAX_PATH (260) limits, leaving room for the base format and extension.
121-
# Respect UTF-8 codepoint boundaries.
122-
MAX_FILENAME_BYTES = 200
122+
# Truncate to MAX_FILENAME_BYTES, respecting UTF-8 codepoint boundaries.
123123
if len(sanitized.encode('utf-8')) > MAX_FILENAME_BYTES:
124124
truncated = sanitized.encode('utf-8')[:MAX_FILENAME_BYTES]
125125
sanitized = truncated.decode('utf-8', errors='ignore')
@@ -157,6 +157,10 @@ def read_text_from_source(text_source):
157157
# Limit read size to prevent performance issues on accidental large-file selection.
158158
with open(file_path, "rb") as f:
159159
data = f.read(MAX_READ_SIZE)
160+
if f.read(1):
161+
obs.script_log(obs.LOG_WARNING,
162+
f"Text file exceeds {MAX_READ_SIZE} bytes; "
163+
"only the first chunk is used")
160164
# UTF-8 BOM: strip it.
161165
if data[:3] == b"\xef\xbb\xbf":
162166
data = data[3:]
@@ -252,7 +256,6 @@ def update_recording_format():
252256
# Skip if text hasn't changed
253257
if current_text == last_text:
254258
return
255-
last_text = current_text
256259

257260
# Throttle: skip if the same text was already applied within THROTTLE_SECONDS
258261
now = time.time()
@@ -261,12 +264,21 @@ def update_recording_format():
261264

262265
# Build the new format string
263266
sanitized = sanitize_filename(current_text)
264-
if sanitized:
267+
if sanitized and base_format:
265268
new_format = f"{sanitized} {base_format}"
266-
else:
269+
elif sanitized:
270+
new_format = sanitized
271+
elif base_format:
267272
new_format = base_format
273+
else:
274+
# Both text and base format are empty; clear the override
275+
# rather than sending an ambiguous empty string.
276+
if not override_cleared:
277+
clear_override()
278+
return
268279

269280
if call_override_proc(filter_uuid, new_format):
281+
last_text = current_text
270282
last_applied_text = current_text
271283
last_applied_time = now
272284
override_cleared = False
@@ -370,9 +382,6 @@ def script_update(settings):
370382
# filter so it reverts to its own setting.
371383
if not text_source_uuid:
372384
clear_override()
373-
last_text = None
374-
last_applied_text = None
375-
last_applied_time = 0.0
376385
return
377386

378387
# Reset state to force update on next tick

0 commit comments

Comments
 (0)