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.
- GameSentenceMiner/gsm.py +0 -1
- GameSentenceMiner/obs.py +2 -4
- GameSentenceMiner/util/__init__.py +0 -0
- GameSentenceMiner/util/configuration.py +653 -0
- GameSentenceMiner/util/electron_config.py +315 -0
- GameSentenceMiner/util/ffmpeg.py +449 -0
- GameSentenceMiner/util/gsm_utils.py +235 -0
- GameSentenceMiner/util/model.py +177 -0
- GameSentenceMiner/util/notification.py +124 -0
- GameSentenceMiner/util/package.py +37 -0
- GameSentenceMiner/util/ss_selector.py +122 -0
- GameSentenceMiner/util/text_log.py +186 -0
- {gamesentenceminer-2.9.5.dist-info → gamesentenceminer-2.9.7.dist-info}/METADATA +1 -1
- {gamesentenceminer-2.9.5.dist-info → gamesentenceminer-2.9.7.dist-info}/RECORD +18 -8
- {gamesentenceminer-2.9.5.dist-info → gamesentenceminer-2.9.7.dist-info}/WHEEL +1 -1
- {gamesentenceminer-2.9.5.dist-info → gamesentenceminer-2.9.7.dist-info}/entry_points.txt +0 -0
- {gamesentenceminer-2.9.5.dist-info → gamesentenceminer-2.9.7.dist-info}/licenses/LICENSE +0 -0
- {gamesentenceminer-2.9.5.dist-info → gamesentenceminer-2.9.7.dist-info}/top_level.txt +0 -0
@@ -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}")
|