GameSentenceMiner 2.9.5__py3-none-any.whl → 2.9.7__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,449 @@
1
+ import subprocess
2
+ import tempfile
3
+ import time
4
+ from pathlib import Path
5
+
6
+ from GameSentenceMiner import obs
7
+ from GameSentenceMiner.util.gsm_utils import make_unique_file_name, get_file_modification_time
8
+ from GameSentenceMiner.util import configuration
9
+ from GameSentenceMiner.util.configuration import *
10
+ from GameSentenceMiner.util.text_log import initial_time
11
+
12
+
13
+ def get_ffmpeg_path():
14
+ return os.path.join(get_app_directory(), "ffmpeg", "ffmpeg.exe") if is_windows() else "ffmpeg"
15
+
16
+ def get_ffprobe_path():
17
+ return os.path.join(get_app_directory(), "ffmpeg", "ffprobe.exe") if is_windows() else "ffprobe"
18
+
19
+ ffmpeg_base_command_list = [get_ffmpeg_path(), "-hide_banner", "-loglevel", "error", '-nostdin']
20
+
21
+
22
+ def call_frame_extractor(video_path, timestamp):
23
+ """
24
+ Calls the video frame extractor script and captures the output.
25
+
26
+ Args:
27
+ video_path (str): Path to the video file.
28
+ timestamp (str): Timestamp string (HH:MM:SS).
29
+
30
+ Returns:
31
+ str: The path of the selected image, or None on error.
32
+ """
33
+ try:
34
+ # Get the directory of the current script
35
+ current_dir = os.path.dirname(os.path.abspath(__file__))
36
+ # Construct the path to the frame extractor script
37
+ script_path = os.path.join(current_dir, "ss_selector.py") # Replace with the actual script name if different
38
+
39
+ logger.info(' '.join([sys.executable, "-m", "GameSentenceMiner.util.ss_selector", video_path, str(timestamp)]))
40
+
41
+ # Run the script using subprocess.run()
42
+ result = subprocess.run(
43
+ [sys.executable, "-m", "GameSentenceMiner.util.ss_selector", video_path, str(timestamp), get_config().screenshot.screenshot_timing_setting], # Use sys.executable
44
+ capture_output=True,
45
+ text=True, # Get output as text
46
+ check=False # Raise an exception for non-zero exit codes
47
+ )
48
+ if result.returncode != 0:
49
+ logger.error(f"Script failed with return code: {result.returncode}")
50
+ return None
51
+ logger.info(result)
52
+ # Print the standard output
53
+ logger.info(f"Frame extractor script output: {result.stdout.strip()}")
54
+ return result.stdout.strip() # Return the output
55
+
56
+ except subprocess.CalledProcessError as e:
57
+ logger.error(f"Error calling script: {e}")
58
+ logger.error(f"Script output (stderr): {e.stderr.strip()}")
59
+ return None
60
+ except FileNotFoundError:
61
+ logger.error(f"Error: Script not found at {script_path}. Make sure the script name is correct.")
62
+ return None
63
+ except Exception as e:
64
+ logger.error(f"An unexpected error occurred: {e}")
65
+ return None
66
+
67
+ def get_screenshot(video_file, screenshot_timing, try_selector=False):
68
+ screenshot_timing = screenshot_timing if screenshot_timing else 1
69
+ if try_selector:
70
+ filepath = call_frame_extractor(video_path=video_file, timestamp=screenshot_timing)
71
+ output = process_image(filepath)
72
+ if output:
73
+ return output
74
+ else:
75
+ logger.error("Frame extractor script failed to run or returned no output, defaulting")
76
+ output_image = make_unique_file_name(os.path.join(
77
+ get_config().paths.screenshot_destination, f"{obs.get_current_game(sanitize=True)}.{get_config().screenshot.extension}"))
78
+ # FFmpeg command to extract the last frame of the video
79
+ ffmpeg_command = ffmpeg_base_command_list + [
80
+ "-ss", f"{screenshot_timing}",
81
+ "-i", f"{video_file}",
82
+ "-vframes", "1" # Extract only one frame
83
+ ]
84
+
85
+ if get_config().screenshot.custom_ffmpeg_settings:
86
+ ffmpeg_command.extend(get_config().screenshot.custom_ffmpeg_settings.replace("\"", "").split(" "))
87
+ else:
88
+ ffmpeg_command.extend(["-compression_level", "6", "-q:v", get_config().screenshot.quality])
89
+
90
+ if get_config().screenshot.width or get_config().screenshot.height:
91
+ ffmpeg_command.extend(
92
+ ["-vf", f"scale={get_config().screenshot.width or -1}:{get_config().screenshot.height or -1}"])
93
+
94
+ ffmpeg_command.append(f"{output_image}")
95
+
96
+ logger.debug(f"FFMPEG SS Command: {ffmpeg_command}")
97
+
98
+ try:
99
+ for i in range(3):
100
+ logger.debug(" ".join(ffmpeg_command))
101
+ result = subprocess.run(ffmpeg_command)
102
+ if result.returncode != 0 and i < 2:
103
+ raise RuntimeError(f"FFmpeg command failed with return code {result.returncode}")
104
+ else:
105
+ break
106
+ except Exception as e:
107
+ logger.error(f"Error running FFmpeg command: {e}. Defaulting to standard PNG.")
108
+ output_image = make_unique_file_name(os.path.join(
109
+ get_config().paths.screenshot_destination,
110
+ f"{obs.get_current_game(sanitize=True)}.png"))
111
+ ffmpeg_command = ffmpeg_base_command_list + [
112
+ "-ss", f"{screenshot_timing}", # Default to 1 second
113
+ "-i", video_file,
114
+ "-vframes", "1",
115
+ output_image
116
+ ]
117
+ subprocess.run(ffmpeg_command)
118
+
119
+ logger.debug(f"Screenshot saved to: {output_image}")
120
+
121
+ return output_image
122
+
123
+ def get_screenshot_for_line(video_file, game_line, try_selector=False):
124
+ return get_screenshot(video_file, get_screenshot_time(video_file, game_line), try_selector)
125
+
126
+
127
+ def get_screenshot_time(video_path, game_line, default_beginning=False, vad_result=None, doing_multi_line=False, previous_line=False):
128
+ if game_line:
129
+ line_time = game_line.time
130
+ else:
131
+ # Assuming initial_time is defined elsewhere if game_line is None
132
+ line_time = initial_time
133
+ if previous_line:
134
+ logger.debug(f"Calculating screenshot time for previous line: {str(game_line.text)}")
135
+ else:
136
+ logger.debug("Calculating screenshot time for line: " + str(game_line.text))
137
+
138
+ file_length = get_video_duration(video_path)
139
+ file_mod_time = get_file_modification_time(video_path)
140
+
141
+ # Calculate when the line occurred within the video file (seconds from start)
142
+ time_delta = file_mod_time - line_time
143
+ line_timestamp_in_video = file_length - time_delta.total_seconds()
144
+ screenshot_offset = get_config().screenshot.seconds_after_line
145
+
146
+ # Calculate screenshot time from the beginning by adding the offset
147
+ # if vad_result and vad_result.success and not doing_multi_line:
148
+ # screenshot_time_from_beginning = line_timestamp_in_video + vad_result.end - 1
149
+ # logger.info(f"Using VAD result {vad_result} for screenshot time: {screenshot_time_from_beginning} seconds from beginning of replay")
150
+ if get_config().screenshot.screenshot_timing_setting == "beginning":
151
+ screenshot_time_from_beginning = line_timestamp_in_video + screenshot_offset
152
+ logger.debug(f"Using 'beginning' setting for screenshot time: {screenshot_time_from_beginning} seconds from beginning of replay")
153
+ elif get_config().screenshot.screenshot_timing_setting == "middle":
154
+ if game_line.next:
155
+ screenshot_time_from_beginning = line_timestamp_in_video + ((game_line.next.time - game_line.time).total_seconds() / 2) + screenshot_offset
156
+ else:
157
+ screenshot_time_from_beginning = (file_length - ((file_length - line_timestamp_in_video) / 2)) + screenshot_offset
158
+ logger.debug(f"Using 'middle' setting for screenshot time: {screenshot_time_from_beginning} seconds from beginning of replay")
159
+ elif get_config().screenshot.screenshot_timing_setting == "end":
160
+ if game_line.next:
161
+ screenshot_time_from_beginning = line_timestamp_in_video + (game_line.next.time - game_line.time).total_seconds() - screenshot_offset
162
+ else:
163
+ screenshot_time_from_beginning = file_length - screenshot_offset
164
+ logger.debug(f"Using 'end' setting for screenshot time: {screenshot_time_from_beginning} seconds from beginning of replay")
165
+ else:
166
+ logger.error(f"Invalid screenshot timing setting: {get_config().screenshot.screenshot_timing_setting}")
167
+ screenshot_time_from_beginning = line_timestamp_in_video + screenshot_offset
168
+
169
+ # Check if the calculated time is out of bounds
170
+ if screenshot_time_from_beginning < 0 or screenshot_time_from_beginning > file_length:
171
+ logger.error(
172
+ f"Calculated screenshot time ({screenshot_time_from_beginning:.2f}s) is out of bounds for video (length {file_length:.2f}s)."
173
+ )
174
+ if default_beginning:
175
+ return 1.0
176
+ return file_length - screenshot_offset
177
+
178
+ # Return the calculated time from the beginning
179
+ return screenshot_time_from_beginning
180
+
181
+
182
+ def process_image(image_file):
183
+ output_image = make_unique_file_name(
184
+ os.path.join(get_config().paths.screenshot_destination, f"{obs.get_current_game(sanitize=True)}.{get_config().screenshot.extension}"))
185
+
186
+ # FFmpeg command to process the input image
187
+ ffmpeg_command = ffmpeg_base_command_list + [
188
+ "-i", image_file
189
+ ]
190
+
191
+ if get_config().screenshot.custom_ffmpeg_settings:
192
+ ffmpeg_command.extend(get_config().screenshot.custom_ffmpeg_settings.split(" "))
193
+ else:
194
+ ffmpeg_command.extend(["-compression_level", "6", "-q:v", get_config().screenshot.quality])
195
+
196
+ if get_config().screenshot.width or get_config().screenshot.height:
197
+ ffmpeg_command.extend(
198
+ ["-vf", f"scale={get_config().screenshot.width or -1}:{get_config().screenshot.height or -1}"])
199
+
200
+ ffmpeg_command.append(output_image)
201
+ logger.debug(ffmpeg_command)
202
+ logger.debug(" ".join(ffmpeg_command))
203
+ # Run the command
204
+ try:
205
+ for i in range(3):
206
+ logger.debug(" ".join(ffmpeg_command))
207
+ result = subprocess.run(ffmpeg_command)
208
+ if result.returncode != 0 and i < 2:
209
+ raise RuntimeError(f"FFmpeg command failed with return code {result.returncode}")
210
+ else:
211
+ break
212
+ except Exception as e:
213
+ logger.error(f"Error re-encoding screenshot: {e}. Defaulting to standard PNG.")
214
+ output_image = make_unique_file_name(os.path.join(get_config().paths.screenshot_destination, f"{obs.get_current_game(sanitize=True)}.png"))
215
+ shutil.move(image_file, output_image)
216
+
217
+ logger.info(f"Processed image saved to: {output_image}")
218
+
219
+ return output_image
220
+
221
+
222
+ def get_audio_codec(video_path):
223
+ command = [
224
+ f"{get_ffprobe_path()}",
225
+ "-v", "error",
226
+ "-select_streams", "a:0",
227
+ "-show_entries", "stream=codec_name",
228
+ "-of", "json",
229
+ video_path
230
+ ]
231
+
232
+ logger.debug(" ".join(command))
233
+ # Run the command and capture the output
234
+ result = subprocess.run(command, capture_output=True, text=True)
235
+
236
+ # Parse the JSON output
237
+ try:
238
+ output = json.loads(result.stdout)
239
+ codec_name = output['streams'][0]['codec_name']
240
+ return codec_name
241
+ except (json.JSONDecodeError, KeyError, IndexError):
242
+ logger.error("Failed to get codec information. Re-encoding Anyways")
243
+ return None
244
+
245
+
246
+ def get_audio_and_trim(video_path, game_line, next_line_time, anki_card_creation_time):
247
+ supported_formats = {
248
+ 'opus': 'libopus',
249
+ 'mp3': 'libmp3lame',
250
+ 'ogg': 'libvorbis',
251
+ 'aac': 'aac',
252
+ 'm4a': 'aac',
253
+ }
254
+
255
+ codec = get_audio_codec(video_path)
256
+
257
+ if codec == get_config().audio.extension:
258
+ codec_command = ['-c:a', 'copy']
259
+ logger.debug(f"Extracting {get_config().audio.extension} from video")
260
+ else:
261
+ codec_command = ["-c:a", f"{supported_formats[get_config().audio.extension]}"]
262
+ logger.debug(f"Re-encoding {codec} to {get_config().audio.extension}")
263
+
264
+ untrimmed_audio = tempfile.NamedTemporaryFile(dir=configuration.get_temporary_directory(),
265
+ suffix=f"_untrimmed.{get_config().audio.extension}").name
266
+
267
+ command = ffmpeg_base_command_list + [
268
+ "-i", video_path,
269
+ "-map", "0:a"] + codec_command + [
270
+ untrimmed_audio
271
+ ]
272
+
273
+ # FFmpeg command to extract OR re-encode the audio
274
+ # command = f"{ffmpeg_base_command} -i \"{video_path}\" -map 0:a {codec_command} \"{untrimmed_audio}\""
275
+ logger.debug("Doing initial audio extraction")
276
+ logger.debug(" ".join(command))
277
+
278
+ subprocess.run(command)
279
+
280
+ return trim_audio_based_on_last_line(untrimmed_audio, video_path, game_line, next_line_time, anki_card_creation_time)
281
+
282
+
283
+ def get_video_duration(file_path):
284
+ ffprobe_command = [
285
+ f"{get_ffprobe_path()}",
286
+ "-v", "error",
287
+ "-show_entries", "format=duration",
288
+ "-of", "json",
289
+ file_path
290
+ ]
291
+ logger.debug(" ".join(ffprobe_command))
292
+ result = subprocess.run(ffprobe_command, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
293
+ duration_info = json.loads(result.stdout)
294
+ logger.debug(f"Video duration: {duration_info}")
295
+ return float(duration_info["format"]["duration"]) # Return the duration in seconds
296
+
297
+
298
+ def trim_audio_based_on_last_line(untrimmed_audio, video_path, game_line, next_line, anki_card_creation_time):
299
+ trimmed_audio = tempfile.NamedTemporaryFile(dir=configuration.get_temporary_directory(),
300
+ suffix=f".{get_config().audio.extension}").name
301
+ start_trim_time, total_seconds, total_seconds_after_offset = get_video_timings(video_path, game_line, anki_card_creation_time)
302
+ end_trim_time = ""
303
+
304
+ ffmpeg_command = ffmpeg_base_command_list + [
305
+ "-i", untrimmed_audio,
306
+ "-ss", str(start_trim_time)]
307
+ if next_line and next_line > game_line.time:
308
+ end_total_seconds = total_seconds + (next_line - game_line.time).total_seconds() + 1
309
+ hours, remainder = divmod(end_total_seconds, 3600)
310
+ minutes, seconds = divmod(remainder, 60)
311
+ end_trim_time = "{:02}:{:02}:{:06.3f}".format(int(hours), int(minutes), seconds)
312
+ ffmpeg_command.extend(['-to', end_trim_time])
313
+ logger.debug(
314
+ f"Looks Like this is mining from History, or Multiple Lines were selected Trimming end of audio to {end_trim_time}")
315
+
316
+ ffmpeg_command.extend([
317
+ "-c", "copy", # Using copy to avoid re-encoding, adjust if needed
318
+ trimmed_audio
319
+ ])
320
+
321
+ logger.debug(" ".join(ffmpeg_command))
322
+ subprocess.run(ffmpeg_command)
323
+
324
+ logger.debug(f"{total_seconds_after_offset} trimmed off of beginning")
325
+
326
+ if end_trim_time:
327
+ logger.info(f"Audio Extracted and trimmed to {start_trim_time} seconds with end time {end_trim_time}")
328
+ else:
329
+ logger.info(f"Audio Extracted and trimmed to {start_trim_time} seconds")
330
+
331
+ logger.debug(f"Audio trimmed and saved to {trimmed_audio}")
332
+ return trimmed_audio
333
+
334
+ def get_video_timings(video_path, game_line, anki_card_creation_time=None):
335
+ if anki_card_creation_time and get_config().advanced.use_anki_note_creation_time:
336
+ file_mod_time = anki_card_creation_time
337
+ else:
338
+ file_mod_time = get_file_modification_time(video_path)
339
+ file_length = get_video_duration(video_path)
340
+ time_delta = file_mod_time - game_line.time
341
+ # Convert time_delta to FFmpeg-friendly format (HH:MM:SS.milliseconds)
342
+ total_seconds = file_length - time_delta.total_seconds()
343
+ total_seconds_after_offset = total_seconds + get_config().audio.beginning_offset
344
+ if total_seconds < 0 or total_seconds >= file_length:
345
+ logger.error("Line mined is outside of the replay buffer! Defaulting to the beginning of the replay buffer. ")
346
+ logger.info("Recommend either increasing replay buffer length in OBS Settings or mining faster.")
347
+ return 0, 0, 0
348
+
349
+ hours, remainder = divmod(total_seconds_after_offset, 3600)
350
+ minutes, seconds = divmod(remainder, 60)
351
+ start_trim_time = "{:02}:{:02}:{:06.3f}".format(int(hours), int(minutes), seconds)
352
+ return start_trim_time, total_seconds, total_seconds_after_offset
353
+
354
+
355
+ def reencode_file_with_user_config(input_file, final_output_audio, user_ffmpeg_options):
356
+ logger.debug(f"Re-encode running with settings: {user_ffmpeg_options}")
357
+ temp_file = create_temp_file_with_same_name(input_file)
358
+ command = ffmpeg_base_command_list + [
359
+ "-i", input_file,
360
+ "-map", "0:a"
361
+ ] + user_ffmpeg_options.replace("\"", "").split(" ") + [
362
+ temp_file
363
+ ]
364
+
365
+ logger.debug(" ".join(command))
366
+ process = subprocess.run(command)
367
+
368
+ if process.returncode != 0:
369
+ logger.error("Re-encode failed, using original audio")
370
+ return
371
+
372
+ replace_file_with_retry(temp_file, final_output_audio)
373
+
374
+
375
+ def create_temp_file_with_same_name(input_file: str):
376
+ path = Path(input_file)
377
+ return str(path.with_name(f"{path.stem}_temp{path.suffix}"))
378
+
379
+
380
+ def replace_file_with_retry(temp_file, input_file, retries=5, delay=1):
381
+ for attempt in range(retries):
382
+ try:
383
+ shutil.move(temp_file, input_file)
384
+ return
385
+ except OSError as e:
386
+ if attempt < retries - 1:
387
+ logger.warning(f"Attempt {attempt + 1}: File still in use. Retrying in {delay} seconds...")
388
+ time.sleep(delay)
389
+ else:
390
+ logger.error(f"Failed to replace the file after {retries} attempts. Error: {e}")
391
+ raise
392
+
393
+
394
+ def trim_audio_by_end_time(input_audio, end_time, output_audio):
395
+ command = ffmpeg_base_command_list + [
396
+ "-i", input_audio,
397
+ "-to", str(end_time),
398
+ "-c", "copy",
399
+ output_audio
400
+ ]
401
+ logger.debug(" ".join(command))
402
+ subprocess.run(command)
403
+
404
+
405
+ def convert_audio_to_wav(input_audio, output_wav):
406
+ command = ffmpeg_base_command_list + [
407
+ "-i", input_audio,
408
+ "-ar", "16000", # Resample to 16kHz
409
+ "-ac", "1", # Convert to mono
410
+ "-af", "afftdn,dialoguenhance" if not is_linux() else "afftdn",
411
+ output_wav
412
+ ]
413
+ logger.debug(" ".join(command))
414
+ subprocess.run(command)
415
+
416
+
417
+ # Trim the audio using FFmpeg based on detected speech timestamps
418
+ def trim_audio(input_audio, start_time, end_time, output_audio):
419
+ command = ffmpeg_base_command_list.copy()
420
+
421
+ command.extend(['-i', input_audio])
422
+
423
+ if get_config().vad.trim_beginning and start_time > 0:
424
+ logger.debug(f"trimming beginning to {start_time}")
425
+ command.extend(['-ss', f"{start_time:.2f}"])
426
+
427
+ command.extend([
428
+ '-to', f"{end_time:.2f}",
429
+ '-c', 'copy',
430
+ output_audio
431
+ ])
432
+
433
+ logger.debug(" ".join(command))
434
+
435
+ subprocess.run(command)
436
+
437
+
438
+ def is_video_big_enough(file_path, min_size_kb=250):
439
+ try:
440
+ file_size = os.path.getsize(file_path) # Size in bytes
441
+ file_size_kb = file_size / 1024 # Convert to KB
442
+ return file_size_kb >= min_size_kb
443
+ except FileNotFoundError:
444
+ logger.error("File not found!")
445
+ return False
446
+ except Exception as e:
447
+ logger.error(f"Error: {e}")
448
+ return False
449
+
@@ -0,0 +1,235 @@
1
+ import json
2
+ import os
3
+ import random
4
+ import re
5
+ import string
6
+ import subprocess
7
+ import threading
8
+ import time
9
+ from datetime import datetime
10
+ from pathlib import Path
11
+
12
+ from rapidfuzz import process
13
+
14
+ from GameSentenceMiner.util.configuration import logger, get_config, get_app_directory
15
+
16
+ SCRIPTS_DIR = r"E:\Japanese Stuff\agent-v0.1.4-win32-x64\data\scripts"
17
+
18
+ def run_new_thread(func):
19
+ thread = threading.Thread(target=func, daemon=True)
20
+ thread.start()
21
+ return thread
22
+
23
+
24
+ def make_unique_file_name(path):
25
+ path = Path(path)
26
+ current_time = datetime.now().strftime('%Y-%m-%d-%H-%M-%S-%f')[:-3]
27
+ return f"{path.stem}_{current_time}{path.suffix}"
28
+
29
+ def sanitize_filename(filename):
30
+ return re.sub(r'[ <>:"/\\|?*\x00-\x1F]', '', filename)
31
+
32
+
33
+ def get_random_digit_string():
34
+ return ''.join(random.choice(string.digits) for i in range(9))
35
+
36
+
37
+ def timedelta_to_ffmpeg_friendly_format(td_obj):
38
+ total_seconds = td_obj.total_seconds()
39
+ hours, remainder = divmod(total_seconds, 3600)
40
+ minutes, seconds = divmod(remainder, 60)
41
+ return "{:02}:{:02}:{:06.3f}".format(int(hours), int(minutes), seconds)
42
+
43
+
44
+ def get_file_modification_time(file_path):
45
+ mod_time_epoch = os.path.getmtime(file_path)
46
+ mod_time = datetime.fromtimestamp(mod_time_epoch)
47
+ return mod_time
48
+
49
+
50
+ def get_process_id_by_title(game_title):
51
+ powershell_command = f"Get-Process | Where-Object {{$_.MainWindowTitle -like '*{game_title}*'}} | Select-Object -First 1 -ExpandProperty Id"
52
+ process_id = subprocess.check_output(["powershell", "-Command", powershell_command], text=True).strip()
53
+ logger.info(f"Process ID for {game_title}: {process_id}")
54
+ return process_id
55
+
56
+
57
+ def get_script_files(directory):
58
+ script_files = []
59
+ for root, dirs, files in os.walk(directory):
60
+ for file in files:
61
+ if file.endswith(".js"): # Assuming the scripts are .js files
62
+ script_files.append(os.path.join(root, file))
63
+ return script_files
64
+
65
+
66
+ def filter_steam_scripts(scripts):
67
+ return [script for script in scripts if "PC_Steam" in os.path.basename(script)]
68
+
69
+
70
+ def extract_game_name(script_path):
71
+ # Remove directory and file extension to get the name part
72
+ script_name = os.path.basename(script_path)
73
+ game_name = script_name.replace("PC_Steam_", "").replace(".js", "")
74
+ return game_name.replace("_", " ").replace(".", " ")
75
+
76
+
77
+ def find_most_similar_script(game_title, steam_scripts):
78
+ # Create a list of game names from the script paths
79
+ game_names = [extract_game_name(script) for script in steam_scripts]
80
+
81
+ # Use rapidfuzz to find the closest match
82
+ best_match = process.extractOne(game_title, game_names)
83
+
84
+ if best_match:
85
+ matched_game_name, confidence_score, index = best_match
86
+ return steam_scripts[index], matched_game_name, confidence_score
87
+ return None, None, None
88
+
89
+
90
+ def find_script_for_game(game_title):
91
+ script_files = get_script_files(SCRIPTS_DIR)
92
+
93
+ steam_scripts = filter_steam_scripts(script_files)
94
+
95
+ best_script, matched_game_name, confidence = find_most_similar_script(game_title, steam_scripts)
96
+
97
+
98
+ if best_script:
99
+ logger.info(f"Found Script: {best_script}")
100
+ return best_script
101
+ else:
102
+ logger.warning("No similar script found.")
103
+
104
+
105
+ def run_agent_and_hook(pname, agent_script):
106
+ command = f'agent --script=\"{agent_script}\" --pname={pname}'
107
+ logger.info("Running and Hooking Agent!")
108
+ try:
109
+ dos_process = subprocess.Popen(command, shell=True)
110
+ dos_process.wait() # Wait for the process to complete
111
+ logger.info("Agent script finished or closed.")
112
+ except Exception as e:
113
+ logger.error(f"Error occurred while running agent script: {e}")
114
+
115
+ keep_running = False
116
+
117
+
118
+ # def run_command(command, shell=False, input=None, capture_output=False, timeout=None, check=False, **kwargs):
119
+ # # Use shell=True if the OS is Linux, otherwise shell=False
120
+ # if is_linux():
121
+ # return subprocess.run(command, shell=True, input=input, capture_output=capture_output, timeout=timeout,
122
+ # check=check, **kwargs)
123
+ # else:
124
+ # return subprocess.run(command, shell=shell, input=input, capture_output=capture_output, timeout=timeout,
125
+ # check=check, **kwargs)
126
+ def remove_html_and_cloze_tags(text):
127
+ text = re.sub(r'<.*?>', '', re.sub(r'{{c\d+::(.*?)(::.*?)?}}', r'\1', text))
128
+ return text
129
+
130
+
131
+ def combine_dialogue(dialogue_lines, new_lines=None):
132
+ if not dialogue_lines: # Handle empty input
133
+ return []
134
+
135
+ if new_lines is None:
136
+ new_lines = []
137
+
138
+ if len(dialogue_lines) == 1 and '「' not in dialogue_lines[0]:
139
+ new_lines.append(dialogue_lines[0])
140
+ return new_lines
141
+
142
+ character_name = dialogue_lines[0].split("「")[0]
143
+ text = character_name + "「"
144
+
145
+ for i, line in enumerate(dialogue_lines):
146
+ if not line.startswith(character_name + "「"):
147
+ text = text + "」" + get_config().advanced.multi_line_line_break
148
+ new_lines.append(text)
149
+ new_lines.extend(combine_dialogue(dialogue_lines[i:]))
150
+ break
151
+ else:
152
+ text += (get_config().advanced.multi_line_line_break if i > 0 else "") + line.split("「")[1].rstrip("」") + ""
153
+ else:
154
+ text = text + "」"
155
+ new_lines.append(text)
156
+
157
+ return new_lines
158
+
159
+ def wait_for_stable_file(file_path, timeout=10, check_interval=0.1):
160
+ elapsed_time = 0
161
+ last_size = -1
162
+
163
+ while elapsed_time < timeout:
164
+ try:
165
+ current_size = os.path.getsize(file_path)
166
+ if current_size == last_size:
167
+ try:
168
+ with open(file_path, 'rb') as f:
169
+ return True
170
+ except Exception as e:
171
+ time.sleep(check_interval)
172
+ elapsed_time += check_interval
173
+ last_size = current_size
174
+ time.sleep(check_interval)
175
+ elapsed_time += check_interval
176
+ except Exception as e:
177
+ logger.warning(f"Error checking file size, will still try updating Anki Card!: {e}")
178
+ return False
179
+ logger.warning("File size did not stabilize within the timeout period. Continuing...")
180
+ return False
181
+
182
+ def isascii(s: str):
183
+ try:
184
+ return s.isascii()
185
+ except:
186
+ try:
187
+ s.encode("ascii")
188
+ return True
189
+ except:
190
+ return False
191
+
192
+ def do_text_replacements(text, replacements_json):
193
+ if not text:
194
+ return text
195
+
196
+ replacements = {}
197
+ if os.path.exists(replacements_json):
198
+ with open(replacements_json, 'r', encoding='utf-8') as f:
199
+ replacements.update(json.load(f))
200
+
201
+ if replacements.get("enabled", False):
202
+ orig_text = text
203
+ filters = replacements.get("args", {}).get("replacements", {})
204
+ for fil, replacement in filters.items():
205
+ if not fil:
206
+ continue
207
+ if fil.startswith("re:"):
208
+ pattern = fil[3:]
209
+ try:
210
+ text = re.sub(pattern, replacement, text)
211
+ except Exception:
212
+ logger.error(f"Invalid regex pattern: {pattern}")
213
+ continue
214
+ if isascii(fil):
215
+ text = re.sub(r"\b{}\b".format(re.escape(fil)), replacement, text)
216
+ else:
217
+ text = text.replace(fil, replacement)
218
+ if text != orig_text:
219
+ logger.info(f"Text replaced: '{orig_text}' -> '{text}' using replacements.")
220
+ return text
221
+
222
+
223
+ TEXT_REPLACEMENTS_FILE = os.path.join(get_app_directory(), 'config', 'text_replacements.json')
224
+ OCR_REPLACEMENTS_FILE = os.path.join(get_app_directory(), 'config', 'ocr_replacements.json')
225
+ os.makedirs(os.path.dirname(TEXT_REPLACEMENTS_FILE), exist_ok=True)
226
+
227
+ # if not os.path.exists(OCR_REPLACEMENTS_FILE):
228
+ # url = "https://raw.githubusercontent.com/bpwhelan/GameSentenceMiner/refs/heads/main/electron-src/assets/ocr_replacements.json"
229
+ # try:
230
+ # with urllib.request.urlopen(url) as response:
231
+ # data = response.read().decode('utf-8')
232
+ # with open(OCR_REPLACEMENTS_FILE, 'w', encoding='utf-8') as f:
233
+ # f.write(data)
234
+ # except Exception as e:
235
+ # logger.error(f"Failed to fetch JSON from {url}: {e}")