@@ -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
100102end
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+
102144local function parse_selected_filter (value )
103145 -- Parse 'source_uuid::filter_uuid' into (source_uuid, filter_uuid).
104146 if value == nil then
187229local 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)
206251end
207252
208253local 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
262309end
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
0 commit comments