GameSentenceMiner 2.9.18__py3-none-any.whl → 2.9.20__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.
@@ -5,11 +5,9 @@ import pyperclip
5
5
  import websockets
6
6
  from websockets import InvalidStatus
7
7
 
8
- from GameSentenceMiner import util
9
8
  from GameSentenceMiner.util.gsm_utils import do_text_replacements, TEXT_REPLACEMENTS_FILE, run_new_thread
10
9
  from GameSentenceMiner.util.configuration import *
11
10
  from GameSentenceMiner.util.text_log import *
12
-
13
11
  from GameSentenceMiner.web.texthooking_page import add_event_to_texthooker
14
12
 
15
13
  current_line = ''
@@ -65,7 +63,6 @@ async def listen_websockets():
65
63
  logger.info(f"Texthooker WebSocket {uri} connected Successfully!" + " Disabling Clipboard Monitor." if (get_config().general.use_clipboard and not get_config().general.use_both_clipboard_and_websocket) else "")
66
64
  reconnecting = False
67
65
  websocket_connected[uri] = True
68
- try_other = True
69
66
  line_time = None
70
67
  while True:
71
68
  message = await websocket.recv()
GameSentenceMiner/gsm.py CHANGED
@@ -4,10 +4,10 @@ import sys
4
4
 
5
5
  import os
6
6
 
7
+
7
8
  os.environ.pop('TCL_LIBRARY', None)
8
9
 
9
- from GameSentenceMiner.util.gsm_utils import wait_for_stable_file, make_unique_file_name, run_new_thread, \
10
- open_audio_in_external
10
+ from GameSentenceMiner.util.gsm_utils import wait_for_stable_file, make_unique_file_name, run_new_thread
11
11
  from GameSentenceMiner.util.communication.send import send_restart_signal
12
12
  from GameSentenceMiner.util.downloader.download_tools import download_obs_if_needed, download_ffmpeg_if_needed
13
13
  from GameSentenceMiner.vad import vad_processor, VADResult
@@ -40,6 +40,7 @@ try:
40
40
  from GameSentenceMiner.util.text_log import GameLine, get_text_event, get_mined_line, get_all_lines, game_log
41
41
  from GameSentenceMiner.util import *
42
42
  from GameSentenceMiner.web import texthooking_page
43
+ from GameSentenceMiner.web.service import handle_texthooker_button
43
44
  from GameSentenceMiner.web.texthooking_page import run_text_hooker_page
44
45
  except Exception as e:
45
46
  from GameSentenceMiner.util.configuration import logger, is_linux, is_windows
@@ -77,13 +78,9 @@ class VideoToAudioHandler(FileSystemEventHandler):
77
78
  def process_replay(self, video_path):
78
79
  vad_trimmed_audio = ''
79
80
  skip_delete = False
80
- if "previous.mkv" in video_path:
81
- os.remove(video_path)
82
- video_path = gsm_state.previous_replay
83
- else:
84
- gsm_state.previous_replay = video_path
81
+ gsm_state.previous_replay = video_path
85
82
  if gsm_state.line_for_audio or gsm_state.line_for_screenshot:
86
- self.handle_texthooker_button(video_path)
83
+ handle_texthooker_button(video_path, get_audio_from_video=VideoToAudioHandler.get_audio)
87
84
  return
88
85
  try:
89
86
  if anki.card_queue and len(anki.card_queue) > 0:
@@ -180,66 +177,11 @@ class VideoToAudioHandler(FileSystemEventHandler):
180
177
  if vad_trimmed_audio and get_config().paths.remove_audio and os.path.exists(vad_trimmed_audio):
181
178
  os.remove(vad_trimmed_audio)
182
179
 
183
- def handle_texthooker_button(self, video_path):
184
- try:
185
- if gsm_state.line_for_audio:
186
- line: GameLine = gsm_state.line_for_audio
187
- gsm_state.line_for_audio = None
188
- if line == gsm_state.previous_line_for_audio:
189
- logger.info("Line is the same as the last one, skipping processing.")
190
- if get_config().advanced.audio_player_path:
191
- play_audio_in_external(gsm_state.previous_audio)
192
- elif get_config().advanced.video_player_path:
193
- play_video_in_external(line, gsm_state.previous_audio)
194
- return
195
- gsm_state.previous_line_for_audio = line
196
- if get_config().advanced.audio_player_path:
197
- audio = VideoToAudioHandler.get_audio(line, line.next.time if line.next else None, video_path,
198
- temporary=True)
199
- play_audio_in_external(audio)
200
- gsm_state.previous_audio = audio
201
- elif get_config().advanced.video_player_path:
202
- new_video_path = play_video_in_external(line, video_path)
203
- gsm_state.previous_audio = new_video_path
204
- gsm_state.previous_replay = new_video_path
205
- return
206
- if gsm_state.line_for_screenshot:
207
- line: GameLine = gsm_state.line_for_screenshot
208
- gsm_state.line_for_screenshot = None
209
- gsm_state.previous_line_for_screenshot = line
210
- screenshot = ffmpeg.get_screenshot_for_line(video_path, line, True)
211
- if gsm_state.anki_note_for_screenshot:
212
- gsm_state.anki_note_for_screenshot = None
213
- encoded_image = ffmpeg.process_image(screenshot)
214
- if get_config().anki.update_anki and get_config().screenshot.screenshot_hotkey_updates_anki:
215
- last_note = anki.get_last_anki_card()
216
- if get_config().features.backfill_audio:
217
- last_note = anki.get_cards_by_sentence(gametext.current_line)
218
- if last_note:
219
- anki.add_image_to_card(last_note, encoded_image)
220
- notification.send_screenshot_updated(last_note.get_field(get_config().anki.word_field))
221
- if get_config().features.open_anki_edit:
222
- notification.open_anki_card(last_note.noteId)
223
- else:
224
- notification.send_screenshot_saved(encoded_image)
225
- else:
226
- notification.send_screenshot_saved(encoded_image)
227
- else:
228
- os.startfile(screenshot)
229
- return
230
- except Exception as e:
231
- logger.error(f"Error Playing Audio/Video: {e}")
232
- logger.debug(f"Error Playing Audio/Video: {e}", exc_info=True)
233
- return
234
- finally:
235
- if video_path and get_config().paths.remove_video and os.path.exists(video_path):
236
- os.remove(video_path)
237
-
238
180
  @staticmethod
239
181
  def get_audio(game_line, next_line_time, video_path, anki_card_creation_time=None, temporary=False, timing_only=False, mined_line=None):
240
182
  trimmed_audio = get_audio_and_trim(video_path, game_line, next_line_time, anki_card_creation_time)
241
183
  if temporary:
242
- return trimmed_audio
184
+ return ffmpeg.convert_audio_to_wav_lossless(trimmed_audio)
243
185
  vad_trimmed_audio = make_unique_file_name(
244
186
  f"{os.path.abspath(configuration.get_temporary_directory())}/{obs.get_current_game(sanitize=True)}.{get_config().audio.extension}")
245
187
  final_audio_output = make_unique_file_name(os.path.join(get_config().paths.audio_destination,
@@ -256,62 +198,6 @@ class VideoToAudioHandler(FileSystemEventHandler):
256
198
  return final_audio_output, vad_result, vad_trimmed_audio
257
199
 
258
200
 
259
- def play_audio_in_external(filepath):
260
- exe = get_config().advanced.audio_player_path
261
-
262
- filepath = os.path.normpath(filepath)
263
-
264
- command = [exe, "--no-video", filepath]
265
-
266
- try:
267
- subprocess.Popen(command)
268
- print(f"Opened {filepath} in {exe}.")
269
- except Exception as e:
270
- print(f"An error occurred: {e}")
271
-
272
- def play_video_in_external(line, filepath):
273
- def move_video_when_closed(p, fp):
274
- p.wait()
275
- os.remove(fp)
276
-
277
- shutil.move(filepath, get_temporary_directory())
278
- new_filepath = os.path.join(get_temporary_directory(), os.path.basename(filepath))
279
-
280
- command = [get_config().advanced.video_player_path]
281
-
282
- start, _, _, _ = get_video_timings(new_filepath, line)
283
-
284
- if start:
285
- if "vlc" in get_config().advanced.video_player_path:
286
- command.extend(["--start-time", convert_to_vlc_seconds(start), '--one-instance'])
287
- else:
288
- command.extend(["--start", convert_to_vlc_seconds(start)])
289
- command.append(os.path.normpath(new_filepath))
290
-
291
- logger.info(" ".join(command))
292
-
293
-
294
-
295
- try:
296
- proc = subprocess.Popen(command)
297
- print(f"Opened {filepath} in {get_config().advanced.video_player_path}.")
298
- threading.Thread(target=move_video_when_closed, args=(proc, filepath)).start()
299
- except FileNotFoundError:
300
- print("VLC not found. Make sure it's installed and in your PATH.")
301
- except Exception as e:
302
- print(f"An error occurred: {e}")
303
- return new_filepath
304
-
305
- def convert_to_vlc_seconds(time_str):
306
- """Converts HH:MM:SS.milliseconds to VLC-compatible seconds."""
307
- try:
308
- hours, minutes, seconds_ms = time_str.split(":")
309
- seconds, milliseconds = seconds_ms.split(".")
310
- total_seconds = (int(hours) * 3600) + (int(minutes) * 60) + int(seconds) + (int(milliseconds) / 1000.0)
311
- return str(total_seconds)
312
- except ValueError:
313
- return "Invalid time format"
314
-
315
201
  def initial_checks():
316
202
  try:
317
203
  subprocess.run(ffmpeg.ffmpeg_base_command_list)
@@ -15,6 +15,8 @@ import websockets
15
15
  from rapidfuzz import fuzz
16
16
 
17
17
  from GameSentenceMiner import obs
18
+ from GameSentenceMiner.ocr.ss_picker import ScreenCropper
19
+ from GameSentenceMiner.owocr.owocr.run import TextFiltering
18
20
  from GameSentenceMiner.util.configuration import get_config, get_app_directory, get_temporary_directory
19
21
  from GameSentenceMiner.util.electron_config import get_ocr_scan_rate, get_requires_open_window
20
22
  from GameSentenceMiner.ocr.gsm_ocr_config import OCRConfig, set_dpi_awareness
@@ -71,7 +73,7 @@ def get_new_game_cords():
71
73
  return coords_list
72
74
 
73
75
 
74
- def get_ocr_config() -> OCRConfig:
76
+ def get_ocr_config(window=None) -> OCRConfig:
75
77
  """Loads and updates screen capture areas from the corresponding JSON file."""
76
78
  app_dir = Path.home() / "AppData" / "Roaming" / "GameSentenceMiner"
77
79
  ocr_config_dir = app_dir / "ocr_config"
@@ -80,8 +82,10 @@ def get_ocr_config() -> OCRConfig:
80
82
  scene = sanitize_filename(obs.get_current_scene())
81
83
  config_path = ocr_config_dir / f"{scene}.json"
82
84
  if not config_path.exists():
83
- config_path.touch()
84
- return
85
+ ocr_config = OCRConfig(scene=scene, window=window, rectangles=[], coordinate_system="")
86
+ with open(config_path, 'w', encoding="utf-8") as f:
87
+ json.dump(ocr_config.to_dict(), f, indent=4)
88
+ return ocr_config
85
89
  try:
86
90
  with open(config_path, 'r', encoding="utf-8") as f:
87
91
  config_data = json.load(f)
@@ -204,13 +208,11 @@ all_cords = None
204
208
  rectangles = None
205
209
  last_ocr2_result = ""
206
210
 
207
- def do_second_ocr(ocr1_text, time, img, filtering, scrolling=False):
211
+ def do_second_ocr(ocr1_text, time, img, filtering):
208
212
  global twopassocr, ocr2, last_ocr2_result
209
213
  try:
210
214
  orig_text, text = run.process_and_write_results(img, None, last_ocr2_result, filtering, None,
211
215
  engine=ocr2)
212
- if scrolling:
213
- return text
214
216
  if fuzz.ratio(last_ocr2_result, text) >= 90:
215
217
  logger.info("Seems like the same text from previous ocr2 result, not sending")
216
218
  return
@@ -239,11 +241,10 @@ async def send_result(text, time):
239
241
  if get_config().advanced.ocr_sends_to_clipboard or ssonly:
240
242
  import pyperclip
241
243
  pyperclip.copy(text)
242
- if not ssonly:
243
- try:
244
- await websocket_server_thread.send_text(text, time)
245
- except Exception as e:
246
- logger.debug(f"Error sending text to websocket: {e}")
244
+ try:
245
+ await websocket_server_thread.send_text(text, time)
246
+ except Exception as e:
247
+ logger.debug(f"Error sending text to websocket: {e}")
247
248
 
248
249
 
249
250
  previous_text_list = []
@@ -255,10 +256,9 @@ previous_img = None
255
256
  previous_orig_text = "" # Store original text result
256
257
  TEXT_APPEARENCE_DELAY = get_ocr_scan_rate() * 1000 + 500 # Adjust as needed
257
258
  force_stable = False
258
- scrolling_text_images = []
259
259
 
260
260
  def text_callback(text, orig_text, time, img=None, came_from_ss=False, filtering=None, crop_coords=None):
261
- global twopassocr, ocr2, previous_text, last_oneocr_time, text_stable_start_time, previous_orig_text, previous_img, force_stable, previous_ocr1_result, scrolling_text_images, previous_text_list
261
+ global twopassocr, ocr2, previous_text, last_oneocr_time, text_stable_start_time, previous_orig_text, previous_img, force_stable, previous_ocr1_result, previous_text_list
262
262
  orig_text_string = ''.join([item for item in orig_text if item is not None]) if orig_text else ""
263
263
  if came_from_ss:
264
264
  save_result_image(img)
@@ -341,8 +341,8 @@ def run_oneocr(ocr_config: OCRConfig, rectangles):
341
341
  exclusions = list(rect.coordinates for rect in list(filter(lambda x: x.is_excluded, rectangles)))
342
342
 
343
343
  run.init_config(False)
344
- run.run(read_from="screencapture" if not ssonly else "clipboard",
345
- read_from_secondary="clipboard" if not ssonly else None,
344
+ run.run(read_from="screencapture" if not ssonly else "clipboard" if ss_clipboard else "",
345
+ read_from_secondary="clipboard" if ss_clipboard and not ssonly else None,
346
346
  write_to="callback",
347
347
  screen_capture_area=screen_area,
348
348
  # screen_capture_monitor=monitor_config['index'],
@@ -360,6 +360,20 @@ def run_oneocr(ocr_config: OCRConfig, rectangles):
360
360
  done = True
361
361
 
362
362
 
363
+
364
+ def add_ss_hotkey():
365
+ import keyboard
366
+ cropper = ScreenCropper()
367
+ filtering = TextFiltering()
368
+ def capture():
369
+ print("Taking screenshot...")
370
+ img = cropper.run()
371
+ do_second_ocr("", datetime.now(), img, filtering)
372
+
373
+ keyboard.add_hotkey('ctrl+shift+g', capture)
374
+ print("Press Ctrl+Shift+G to take a screenshot.")
375
+
376
+
363
377
  def get_window(window_name):
364
378
  import pygetwindow as gw
365
379
  try:
@@ -388,50 +402,48 @@ def set_force_stable_hotkey():
388
402
  print("Press Ctrl+Shift+F to toggle force stable mode.")
389
403
 
390
404
  if __name__ == "__main__":
391
- global ocr1, ocr2, twopassocr, language, ssonly
405
+ global ocr1, ocr2, twopassocr, language, ss_clipboard, ss, ocr_config
392
406
  import sys
393
407
 
394
- args = sys.argv[1:]
395
- if len(args) >= 4:
396
- language = args[0]
397
- ocr1 = args[1]
398
- ocr2 = args[2]
399
- twopassocr = bool(int(args[3]))
400
- elif len(args) == 3:
401
- language = args[0]
402
- ocr1 = args[1]
403
- ocr2 = args[2]
404
- twopassocr = True
405
- elif len(args) == 2:
406
- language = args[0]
407
- ocr1 = args[1]
408
- ocr2 = None
409
- twopassocr = False
410
- else:
411
- language = "ja"
412
- ocr1 = "oneocr"
413
- ocr2 = "glens"
414
- twopassocr = True
415
-
416
- ssonly = "--ssonly" in args
408
+ import argparse
409
+
410
+ parser = argparse.ArgumentParser(description="OCR Configuration")
411
+ parser.add_argument("--language", type=str, default="ja", help="Language for OCR (default: ja)")
412
+ parser.add_argument("--ocr1", type=str, default="oneocr", help="Primary OCR engine (default: oneocr)")
413
+ parser.add_argument("--ocr2", type=str, default="glens", help="Secondary OCR engine (default: glens)")
414
+ parser.add_argument("--twopassocr", type=int, choices=[0, 1], default=1, help="Enable two-pass OCR (default: 1)")
415
+ parser.add_argument("--ssonly", action="store_true", help="Use screenshot-only mode")
416
+ parser.add_argument("--clipboard", action="store_true", help="Use clipboard for input")
417
+ parser.add_argument("--window", type=str, help="Specify the window name for OCR")
418
+
419
+ args = parser.parse_args()
420
+
421
+ language = args.language
422
+ ocr1 = args.ocr1
423
+ ocr2 = args.ocr2 if args.ocr2 else None
424
+ twopassocr = bool(args.twopassocr)
425
+ ssonly = args.ssonly
426
+ ss_clipboard = args.clipboard
427
+ window_name = args.window
417
428
  logger.info(f"Received arguments: ocr1={ocr1}, ocr2={ocr2}, twopassocr={twopassocr}")
418
429
  # set_force_stable_hotkey()
419
- global ocr_config
420
- ocr_config: OCRConfig = get_ocr_config()
421
430
  set_dpi_awareness()
422
- if ocr_config:
423
- if ocr_config.window:
424
- start_time = time.time()
425
- while time.time() - start_time < 30:
426
- if get_window(ocr_config.window):
427
- break
428
- logger.info(f"Window: {ocr_config.window} Could not be found, retrying in 1 second...")
429
- time.sleep(1)
430
- else:
431
- logger.error(f"Window '{ocr_config.window}' not found within 30 seconds.")
432
- sys.exit(1)
433
- logger.info(f"Starting OCR with configuration: Window: {ocr_config.window}, Rectangles: {ocr_config.rectangles}, Engine 1: {ocr1}, Engine 2: {ocr2}, Two-pass OCR: {twopassocr}")
434
- if ocr_config or ssonly:
431
+ ocr_config = None
432
+ if not ssonly:
433
+ ocr_config: OCRConfig = get_ocr_config(window=window_name)
434
+ if ocr_config:
435
+ if ocr_config.window:
436
+ start_time = time.time()
437
+ while time.time() - start_time < 30:
438
+ if get_window(ocr_config.window):
439
+ break
440
+ logger.info(f"Window: {ocr_config.window} Could not be found, retrying in 1 second...")
441
+ time.sleep(1)
442
+ else:
443
+ logger.error(f"Window '{ocr_config.window}' not found within 30 seconds.")
444
+ sys.exit(1)
445
+ logger.info(f"Starting OCR with configuration: Window: {ocr_config.window}, Rectangles: {ocr_config.rectangles}, Engine 1: {ocr1}, Engine 2: {ocr2}, Two-pass OCR: {twopassocr}")
446
+ if ssonly or ocr_config:
435
447
  rectangles = ocr_config.rectangles if ocr_config and ocr_config.rectangles else []
436
448
  oneocr_threads = []
437
449
  ocr_thread = threading.Thread(target=run_oneocr, args=(ocr_config,rectangles ), daemon=True)
@@ -439,8 +451,9 @@ if __name__ == "__main__":
439
451
  if not ssonly:
440
452
  worker_thread = threading.Thread(target=process_task_queue, daemon=True)
441
453
  worker_thread.start()
442
- websocket_server_thread = WebsocketServerThread(read=True)
443
- websocket_server_thread.start()
454
+ websocket_server_thread = WebsocketServerThread(read=True)
455
+ websocket_server_thread.start()
456
+ add_ss_hotkey()
444
457
  try:
445
458
  while not done:
446
459
  time.sleep(1)
@@ -0,0 +1,132 @@
1
+ import tkinter as tk
2
+ from tkinter import Canvas
3
+ from PIL import Image, ImageTk
4
+ import mss
5
+ import mss.tools
6
+ import io
7
+ import sys
8
+ import ctypes
9
+
10
+ if sys.platform == "win32":
11
+ try:
12
+ ctypes.windll.shcore.SetProcessDpiAwareness(1)
13
+ except AttributeError:
14
+ ctypes.windll.user32.SetProcessDPIAware()
15
+
16
+ class ScreenCropper:
17
+ def __init__(self):
18
+ self.root = None
19
+ self.canvas = None
20
+ self.captured_image = None
21
+ self.tk_image = None
22
+ self.start_x = None
23
+ self.start_y = None
24
+ self.end_x = None
25
+ self.end_y = None
26
+ self.rect_id = None
27
+ self.cropped_image = None
28
+ self.monitor_geometry = None
29
+
30
+ def grab_all_monitors(self):
31
+ try:
32
+ with mss.mss() as sct:
33
+ all_monitors_bbox = sct.monitors[0]
34
+ self.monitor_geometry = {
35
+ 'left': all_monitors_bbox['left'],
36
+ 'top': all_monitors_bbox['top'],
37
+ 'width': all_monitors_bbox['width'],
38
+ 'height': all_monitors_bbox['height']
39
+ }
40
+ sct_grab = sct.grab(all_monitors_bbox)
41
+
42
+ img_bytes = mss.tools.to_png(sct_grab.rgb, sct_grab.size)
43
+ self.captured_image = Image.open(io.BytesIO(img_bytes))
44
+
45
+ print("All monitors captured successfully.")
46
+ except Exception as e:
47
+ print(f"An error occurred during screen capture: {e}")
48
+ self.captured_image = None
49
+ self.monitor_geometry = None
50
+
51
+ def _on_button_press(self, event):
52
+ self.start_x = self.end_x = event.x
53
+ self.start_y = self.end_y = event.y
54
+
55
+ if self.rect_id:
56
+ self.canvas.delete(self.rect_id)
57
+ self.rect_id = self.canvas.create_rectangle(self.start_x, self.start_y,
58
+ self.end_x, self.end_y,
59
+ outline="red", width=2)
60
+
61
+ def _on_mouse_drag(self, event):
62
+ self.end_x = event.x
63
+ self.end_y = event.y
64
+ self.canvas.coords(self.rect_id, self.start_x, self.start_y,
65
+ self.end_x, self.end_y)
66
+
67
+ def _on_button_release(self, event):
68
+ self.end_x = event.x
69
+ self.end_y = event.y
70
+
71
+ x1 = min(self.start_x, self.end_x)
72
+ y1 = min(self.start_y, self.end_y)
73
+ x2 = max(self.start_x, self.end_x)
74
+ y2 = max(self.start_y, self.end_y)
75
+
76
+ if (x2 - x1) > 0 and (y2 - y1) > 0:
77
+ self.cropped_image = self.captured_image.crop((x1, y1, x2, y2))
78
+ print(f"Selection made: ({x1}, {y1}) to ({x2}, {y2})")
79
+ else:
80
+ print("No valid selection made (area was too small).")
81
+ self.cropped_image = None
82
+
83
+ self.root.destroy()
84
+
85
+ def show_image_and_select_box(self):
86
+ if self.captured_image is None or self.monitor_geometry is None:
87
+ print("No image or monitor geometry to display. Capture all monitors first.")
88
+ return
89
+
90
+ self.root = tk.Tk()
91
+ self.root.attributes('-topmost', True)
92
+ self.root.overrideredirect(True)
93
+
94
+ window_width = self.monitor_geometry['width']
95
+ window_height = self.monitor_geometry['height']
96
+ window_x = self.monitor_geometry['left']
97
+ window_y = self.monitor_geometry['top']
98
+
99
+ self.root.geometry(f"{window_width}x{window_height}+{window_x}+{window_y}")
100
+
101
+ self.tk_image = ImageTk.PhotoImage(self.captured_image)
102
+
103
+ self.canvas = Canvas(self.root, cursor="cross", highlightthickness=0)
104
+ self.canvas.pack(fill=tk.BOTH, expand=True)
105
+
106
+ self.canvas.create_image(0, 0, anchor="nw", image=self.tk_image)
107
+
108
+ self.canvas.bind("<Button-1>", self._on_button_press)
109
+ self.canvas.bind("<B1-Motion>", self._on_mouse_drag)
110
+ self.canvas.bind("<ButtonRelease-1>", self._on_button_release)
111
+
112
+ self.root.mainloop()
113
+
114
+ def get_cropped_image(self):
115
+ return self.cropped_image
116
+
117
+ def run(self):
118
+ self.grab_all_monitors()
119
+ if self.captured_image and self.monitor_geometry:
120
+ self.show_image_and_select_box()
121
+ return self.get_cropped_image()
122
+ return None
123
+
124
+ if __name__ == "__main__":
125
+ cropper = ScreenCropper()
126
+ cropped_img = cropper.run()
127
+
128
+ if cropped_img:
129
+ print("Image cropped successfully. Displaying cropped image...")
130
+ cropped_img.show()
131
+ else:
132
+ print("No image was cropped.")
@@ -500,6 +500,32 @@ def convert_audio_to_wav(input_audio, output_wav):
500
500
  logger.debug(" ".join(command))
501
501
  subprocess.run(command)
502
502
 
503
+ def convert_audio_to_wav_lossless(input_audio):
504
+ output_wav = make_unique_file_name(
505
+ os.path.join(configuration.get_temporary_directory(), "output.wav")
506
+ )
507
+ command = ffmpeg_base_command_list + [
508
+ "-i", input_audio,
509
+ output_wav
510
+ ]
511
+ logger.debug(" ".join(command))
512
+ subprocess.run(command)
513
+ return output_wav
514
+
515
+ def convert_audio_to_mp3(input_audio):
516
+ output_mp3 = make_unique_file_name(
517
+ os.path.join(configuration.get_temporary_directory(), "output.mp3")
518
+ )
519
+ command = ffmpeg_base_command_list + [
520
+ "-i", input_audio,
521
+ "-codec:a", "libmp3lame",
522
+ "-qscale:a", "2", # Quality scale for MP3
523
+ output_mp3
524
+ ]
525
+ logger.debug(" ".join(command))
526
+ subprocess.run(command)
527
+ return output_mp3
528
+
503
529
 
504
530
  # Trim the audio using FFmpeg based on detected speech timestamps
505
531
  def trim_audio(input_audio, start_time, end_time, output_audio):
@@ -0,0 +1,135 @@
1
+ import os
2
+ import shutil
3
+ import subprocess
4
+ import threading
5
+
6
+ import simpleaudio as sa
7
+
8
+ from GameSentenceMiner import anki
9
+ from GameSentenceMiner.util import ffmpeg, notification
10
+ from GameSentenceMiner.util.configuration import gsm_state, logger, get_config, get_temporary_directory
11
+ from GameSentenceMiner.util.ffmpeg import get_video_timings
12
+ from GameSentenceMiner.util.text_log import GameLine
13
+
14
+
15
+ def handle_texthooker_button(video_path='', get_audio_from_video=None):
16
+ try:
17
+ if gsm_state.line_for_audio:
18
+ line: GameLine = gsm_state.line_for_audio
19
+ gsm_state.line_for_audio = None
20
+ if line == gsm_state.previous_line_for_audio:
21
+ logger.info("Line is the same as the last one, skipping processing.")
22
+ if get_config().advanced.audio_player_path:
23
+ play_audio_in_external(gsm_state.previous_audio)
24
+ elif get_config().advanced.video_player_path:
25
+ play_video_in_external(line, gsm_state.previous_audio)
26
+ else:
27
+ play_obj = gsm_state.previous_audio.play()
28
+ play_obj.wait_done()
29
+ return
30
+ gsm_state.previous_line_for_audio = line
31
+ if get_config().advanced.audio_player_path:
32
+ audio = get_audio_from_video(line, line.next.time if line.next else None, video_path,
33
+ temporary=True)
34
+ play_audio_in_external(audio)
35
+ gsm_state.previous_audio = audio
36
+ elif get_config().advanced.video_player_path:
37
+ new_video_path = play_video_in_external(line, video_path)
38
+ gsm_state.previous_audio = new_video_path
39
+ gsm_state.previous_replay = new_video_path
40
+ else:
41
+ audio = get_audio_from_video(line, line.next.time if line.next else None, video_path,
42
+ temporary=True)
43
+ wave_obj = sa.WaveObject.from_wave_file(audio)
44
+ play_obj = wave_obj.play()
45
+ play_obj.wait_done()
46
+ gsm_state.previous_audio = wave_obj
47
+ return
48
+ if gsm_state.line_for_screenshot:
49
+ line: GameLine = gsm_state.line_for_screenshot
50
+ gsm_state.line_for_screenshot = None
51
+ gsm_state.previous_line_for_screenshot = line
52
+ screenshot = ffmpeg.get_screenshot_for_line(video_path, line, True)
53
+ if gsm_state.anki_note_for_screenshot:
54
+ gsm_state.anki_note_for_screenshot = None
55
+ encoded_image = ffmpeg.process_image(screenshot)
56
+ if get_config().anki.update_anki and get_config().screenshot.screenshot_hotkey_updates_anki:
57
+ last_note = anki.get_last_anki_card()
58
+ if last_note:
59
+ anki.add_image_to_card(last_note, encoded_image)
60
+ notification.send_screenshot_updated(last_note.get_field(get_config().anki.word_field))
61
+ if get_config().features.open_anki_edit:
62
+ notification.open_anki_card(last_note.noteId)
63
+ else:
64
+ notification.send_screenshot_saved(encoded_image)
65
+ else:
66
+ notification.send_screenshot_saved(encoded_image)
67
+ else:
68
+ os.startfile(screenshot)
69
+ return
70
+ except Exception as e:
71
+ logger.error(f"Error Playing Audio/Video: {e}")
72
+ logger.debug(f"Error Playing Audio/Video: {e}", exc_info=True)
73
+ return
74
+ finally:
75
+ if video_path and get_config().paths.remove_video and os.path.exists(video_path):
76
+ os.remove(video_path)
77
+
78
+
79
+ def play_audio_in_external(filepath):
80
+ exe = get_config().advanced.audio_player_path
81
+
82
+ filepath = os.path.normpath(filepath)
83
+
84
+ command = [exe, "--no-video", filepath]
85
+
86
+ try:
87
+ subprocess.Popen(command)
88
+ print(f"Opened {filepath} in {exe}.")
89
+ except Exception as e:
90
+ print(f"An error occurred: {e}")
91
+
92
+
93
+ def play_video_in_external(line, filepath):
94
+ def move_video_when_closed(p, fp):
95
+ p.wait()
96
+ os.remove(fp)
97
+
98
+ shutil.move(filepath, get_temporary_directory())
99
+ new_filepath = os.path.join(get_temporary_directory(), os.path.basename(filepath))
100
+
101
+ command = [get_config().advanced.video_player_path]
102
+
103
+ start, _, _, _ = get_video_timings(new_filepath, line)
104
+
105
+ if start:
106
+ if "vlc" in get_config().advanced.video_player_path:
107
+ command.extend(["--start-time", convert_to_vlc_seconds(start), '--one-instance'])
108
+ else:
109
+ command.extend(["--start", convert_to_vlc_seconds(start)])
110
+ command.append(os.path.normpath(new_filepath))
111
+
112
+ logger.info(" ".join(command))
113
+
114
+
115
+
116
+ try:
117
+ proc = subprocess.Popen(command)
118
+ print(f"Opened {filepath} in {get_config().advanced.video_player_path}.")
119
+ threading.Thread(target=move_video_when_closed, args=(proc, filepath)).start()
120
+ except FileNotFoundError:
121
+ print("VLC not found. Make sure it's installed and in your PATH.")
122
+ except Exception as e:
123
+ print(f"An error occurred: {e}")
124
+ return new_filepath
125
+
126
+
127
+ def convert_to_vlc_seconds(time_str):
128
+ """Converts HH:MM:SS.milliseconds to VLC-compatible seconds."""
129
+ try:
130
+ hours, minutes, seconds_ms = time_str.split(":")
131
+ seconds, milliseconds = seconds_ms.split(".")
132
+ total_seconds = (int(hours) * 3600) + (int(minutes) * 60) + int(seconds) + (int(milliseconds) / 1000.0)
133
+ return str(total_seconds)
134
+ except ValueError:
135
+ return "Invalid time format"
@@ -16,6 +16,7 @@ from flask import request, jsonify, send_from_directory
16
16
  import webbrowser
17
17
  from GameSentenceMiner import obs
18
18
  from GameSentenceMiner.util.configuration import logger, get_config, DB_PATH, gsm_state, gsm_status
19
+ from GameSentenceMiner.web.service import handle_texthooker_button
19
20
 
20
21
  port = get_config().general.texthooker_port
21
22
  url = f"http://localhost:{port}"
@@ -252,6 +253,7 @@ async def add_event_to_texthooker(line: GameLine):
252
253
  'sentence': line.text,
253
254
  'data': new_event.to_serializable()
254
255
  })
256
+ await plaintext_websocket_server_thread.send_text(line.text)
255
257
 
256
258
 
257
259
  @app.route('/update_checkbox', methods=['POST'])
@@ -274,7 +276,7 @@ def get_screenshot():
274
276
  return jsonify({'error': 'Missing id'}), 400
275
277
  gsm_state.line_for_screenshot = get_line_by_id(event_id)
276
278
  if gsm_state.previous_line_for_screenshot and gsm_state.line_for_screenshot.id == gsm_state.previous_line_for_screenshot.id:
277
- open(os.path.join(get_config().paths.folder_to_watch, "previous.mkv"), 'a').close()
279
+ handle_texthooker_button()
278
280
  else:
279
281
  obs.save_replay_buffer()
280
282
  return jsonify({}), 200
@@ -288,7 +290,7 @@ def play_audio():
288
290
  return jsonify({'error': 'Missing id'}), 400
289
291
  gsm_state.line_for_audio = get_line_by_id(event_id)
290
292
  if gsm_state.previous_line_for_audio and gsm_state.line_for_audio == gsm_state.previous_line_for_audio:
291
- open(os.path.join(get_config().paths.folder_to_watch, "previous.mkv"), 'a').close()
293
+ handle_texthooker_button()
292
294
  else:
293
295
  obs.save_replay_buffer()
294
296
  return jsonify({}), 200
@@ -362,12 +364,13 @@ paused = False
362
364
 
363
365
 
364
366
  class WebsocketServerThread(threading.Thread):
365
- def __init__(self, read):
367
+ def __init__(self, read, ws_port):
366
368
  super().__init__(daemon=True)
367
369
  self._loop = None
368
370
  self.read = read
369
371
  self.clients = set()
370
372
  self._event = threading.Event()
373
+ self.ws_port = ws_port
371
374
 
372
375
  @property
373
376
  def loop(self):
@@ -400,8 +403,10 @@ class WebsocketServerThread(threading.Thread):
400
403
 
401
404
  async def send_text(self, text):
402
405
  if text:
406
+ if isinstance(text, dict):
407
+ text = json.dumps(text)
403
408
  return asyncio.run_coroutine_threadsafe(
404
- self.send_text_coroutine(json.dumps(text)), self.loop)
409
+ self.send_text_coroutine(text), self.loop)
405
410
 
406
411
  def stop_server(self):
407
412
  self.loop.call_soon_threadsafe(self._stop_event.set)
@@ -415,7 +420,7 @@ class WebsocketServerThread(threading.Thread):
415
420
  try:
416
421
  self.server = start_server = websockets.serve(self.server_handler,
417
422
  "0.0.0.0",
418
- get_config().advanced.texthooker_communication_websocket_port,
423
+ self.ws_port,
419
424
  max_size=1000000000)
420
425
  async with start_server:
421
426
  await stop_event.wait()
@@ -432,15 +437,18 @@ def handle_exit_signal(loop):
432
437
  task.cancel()
433
438
 
434
439
  async def texthooker_page_coro():
435
- global websocket_server_thread
440
+ global websocket_server_thread, plaintext_websocket_server_thread
436
441
  # Run the WebSocket server in the asyncio event loop
437
442
  flask_thread = threading.Thread(target=start_web_server)
438
443
  flask_thread.daemon = True
439
444
  flask_thread.start()
440
445
 
441
- websocket_server_thread = WebsocketServerThread(read=True)
446
+ websocket_server_thread = WebsocketServerThread(read=True, ws_port=get_config().advanced.texthooker_communication_websocket_port)
442
447
  websocket_server_thread.start()
443
448
 
449
+ plaintext_websocket_server_thread = WebsocketServerThread(read=False, ws_port=get_config().advanced.texthooker_communication_websocket_port + 1)
450
+ plaintext_websocket_server_thread.start()
451
+
444
452
  # Keep the main asyncio event loop running (for the WebSocket server)
445
453
 
446
454
  def run_text_hooker_page():
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: GameSentenceMiner
3
- Version: 2.9.18
3
+ Version: 2.9.20
4
4
  Summary: A tool for mining sentences from games.
5
5
  Author-email: Beangate <bpwhelan95@gmail.com>
6
6
  License: MIT License
@@ -37,6 +37,7 @@ Requires-Dist: pygetwindow; sys_platform == "win32"
37
37
  Requires-Dist: flask
38
38
  Requires-Dist: groq
39
39
  Requires-Dist: obsws-python~=1.7.2
40
+ Requires-Dist: simpleaudio
40
41
  Dynamic: license-file
41
42
 
42
43
  # GameSentenceMiner (GSM)
@@ -155,4 +156,4 @@ If you encounter issues, please ask for help in my [Discord](https://discord.gg/
155
156
 
156
157
  ## Donations
157
158
 
158
- If you've found this or any of my other projects helpful, please consider supporting my work through [GitHub Sponsors](https://github.com/sponsors/bpwhelan) or [Ko-fi](https://ko-fi.com/beangate).
159
+ If you've found this or any of my other projects helpful, please consider supporting my work through [GitHub Sponsors](https://github.com/sponsors/bpwhelan), [Ko-fi](https://ko-fi.com/beangate), or [Patreon](https://www.patreon.com/GameSentenceMiner).
@@ -1,8 +1,8 @@
1
1
  GameSentenceMiner/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
2
2
  GameSentenceMiner/anki.py,sha256=hNHBIoJRrsWIhLe0sehOYPXTWzPREeXl4gYCPHUCaiE,16331
3
3
  GameSentenceMiner/config_gui.py,sha256=iAOLD47sQW67kzBcZKSQ0Dwctc1ngZK1lwSVIaLpQPI,83559
4
- GameSentenceMiner/gametext.py,sha256=iO1o2980XBzBc2nsgBVr_ZaKHRLotebVpDhwGqBCK9k,6696
5
- GameSentenceMiner/gsm.py,sha256=MFptrbACty8wP3dCSb8VLSBmoTsiBUvomJgAcIpBZw4,29259
4
+ GameSentenceMiner/gametext.py,sha256=nAwGMQSrmc6sUAw-OAURK2n6MG5Ecm6psJ7YF9q5KTA,6623
5
+ GameSentenceMiner/gsm.py,sha256=b3hOjaPGHn_3_G_0Ro43bX84oLX8SltfKEX_LtujX0U,24127
6
6
  GameSentenceMiner/obs.py,sha256=YG8LwBf9BTsGbROm_Uq6LhFDSrbf3jgogp78rBbJq94,14728
7
7
  GameSentenceMiner/vad.py,sha256=TbP3NVdjfB1TFJeB0QpOXZysgo_UHHKLdx95pYmM0JI,14902
8
8
  GameSentenceMiner/ai/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
@@ -19,7 +19,8 @@ GameSentenceMiner/ocr/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3h
19
19
  GameSentenceMiner/ocr/gsm_ocr_config.py,sha256=fEQ2o2NXksGRHpueO8c4TfAp75GEdAtAr1ngTFOsdpg,2257
20
20
  GameSentenceMiner/ocr/ocrconfig.py,sha256=_tY8mjnzHMJrLS8E5pHqYXZjMuLoGKYgJwdhYgN-ny4,6466
21
21
  GameSentenceMiner/ocr/owocr_area_selector.py,sha256=71trzwz9Isyy-kN9mLS8vIX-giC8Lkin4slLXaxudac,47162
22
- GameSentenceMiner/ocr/owocr_helper.py,sha256=upbuBWKGj6VIg0P5lTopZ8kPPu_ErpdIduM21kpvGmI,18310
22
+ GameSentenceMiner/ocr/owocr_helper.py,sha256=7XpuLT8ezdrEXiODyXgh1X7iNbW7OI-QrSgEmTBMaqQ,19505
23
+ GameSentenceMiner/ocr/ss_picker.py,sha256=943tlCJ8536TyAFJFfRt0OGjWwe7QuT-X2UoLl1LyUY,4469
23
24
  GameSentenceMiner/owocr/owocr/__init__.py,sha256=opjBOyGGyEqZCE6YdZPnyt7nVfiwyELHsXA0jAsjm14,25
24
25
  GameSentenceMiner/owocr/owocr/__main__.py,sha256=XQaqZY99EKoCpU-gWQjNbTs7Kg17HvBVE7JY8LqIE0o,157
25
26
  GameSentenceMiner/owocr/owocr/config.py,sha256=qM7kISHdUhuygGXOxmgU6Ef2nwBShrZtdqu4InDCViE,8103
@@ -30,7 +31,7 @@ GameSentenceMiner/owocr/owocr/screen_coordinate_picker.py,sha256=Na6XStbQBtpQUSd
30
31
  GameSentenceMiner/util/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
31
32
  GameSentenceMiner/util/configuration.py,sha256=Wgr1UAf_JoXBlp9h_f3-d2DAmIWnR1FtCmMX6mCfMNM,27461
32
33
  GameSentenceMiner/util/electron_config.py,sha256=3VmIrcXhC-wIMMc4uqV85NrNenRl4ZUbnQfSjWEwuig,9852
33
- GameSentenceMiner/util/ffmpeg.py,sha256=Z3lD5jd7P59-aNWIQDgovwmjT1kCo4-7DLxoG_VBU4M,22618
34
+ GameSentenceMiner/util/ffmpeg.py,sha256=7ZrgnXRa67IH7EYrc93pagkH_2rsO8-QBCDVrMWSg-Y,23398
34
35
  GameSentenceMiner/util/gsm_utils.py,sha256=Z_Lu4jSIfUaM2VljIJXQkSJD0UsyJ5hMB46H2NS0gZo,8819
35
36
  GameSentenceMiner/util/model.py,sha256=iDtLTfR6D-ZC0gCiDqYno6-gA6Z07PZTM4B5MAA6xZI,5704
36
37
  GameSentenceMiner/util/notification.py,sha256=0OnEYjn3DUEZ6c6OtPjdVZe-DG-QSoMAl9fetjjCvNU,3874
@@ -45,7 +46,8 @@ GameSentenceMiner/util/downloader/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeR
45
46
  GameSentenceMiner/util/downloader/download_tools.py,sha256=mvnOjDHFlV1AbjHaNI7mdnC5_CH5k3N4n1ezqzzbzGA,8139
46
47
  GameSentenceMiner/util/downloader/oneocr_dl.py,sha256=o3ANp5IodEQoQ8GPcJdg9Y8JzA_lictwnebFPwwUZVk,10144
47
48
  GameSentenceMiner/web/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
48
- GameSentenceMiner/web/texthooking_page.py,sha256=d3OiPPgxZ4H5wIh9HrlZutDSiwY3ayvMNrY4c0PdRAE,15158
49
+ GameSentenceMiner/web/service.py,sha256=paRUbmdh6msANHplFX-UAyYWfmYL43ltJJhXeuXXr30,5658
50
+ GameSentenceMiner/web/texthooking_page.py,sha256=v4fh79uAO9jM-ZKoLAlLOBaMTn02GfNGAOuxZUXfs-k,15536
49
51
  GameSentenceMiner/web/static/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
50
52
  GameSentenceMiner/web/static/apple-touch-icon.png,sha256=OcMI8af_68DA_tweOsQ5LytTyMwm7-hPW07IfrOVgEs,46132
51
53
  GameSentenceMiner/web/static/favicon-96x96.png,sha256=lOePzjiKl1JY2J1kT_PMdyEnrlJmi5GWbmXJunM12B4,16502
@@ -59,9 +61,9 @@ GameSentenceMiner/web/templates/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm
59
61
  GameSentenceMiner/web/templates/index.html,sha256=HZKiIjiGJV8PGQ9T2aLDUNSfJn71qOwbYCjbRuSIjpY,213583
60
62
  GameSentenceMiner/web/templates/text_replacements.html,sha256=tV5c8mCaWSt_vKuUpbdbLAzXZ3ATZeDvQ9PnnAfqY0M,8598
61
63
  GameSentenceMiner/web/templates/utility.html,sha256=3flZinKNqUJ7pvrZk6xu__v67z44rXnaK7UTZ303R-8,16946
62
- gamesentenceminer-2.9.18.dist-info/licenses/LICENSE,sha256=OXLcl0T2SZ8Pmy2_dmlvKuetivmyPd5m1q-Gyd-zaYY,35149
63
- gamesentenceminer-2.9.18.dist-info/METADATA,sha256=wgfdfg6X6daqvG3SlEiFlFgU_TVV8QPBpmBl57eV-_4,7221
64
- gamesentenceminer-2.9.18.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
65
- gamesentenceminer-2.9.18.dist-info/entry_points.txt,sha256=2APEP25DbfjSxGeHtwBstMH8mulVhLkqF_b9bqzU6vQ,65
66
- gamesentenceminer-2.9.18.dist-info/top_level.txt,sha256=V1hUY6xVSyUEohb0uDoN4UIE6rUZ_JYx8yMyPGX4PgQ,18
67
- gamesentenceminer-2.9.18.dist-info/RECORD,,
64
+ gamesentenceminer-2.9.20.dist-info/licenses/LICENSE,sha256=OXLcl0T2SZ8Pmy2_dmlvKuetivmyPd5m1q-Gyd-zaYY,35149
65
+ gamesentenceminer-2.9.20.dist-info/METADATA,sha256=XisQQFOoUzR3uLNWSMvQv8fMWuxDt22kXxNBKXsMDJk,7303
66
+ gamesentenceminer-2.9.20.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
67
+ gamesentenceminer-2.9.20.dist-info/entry_points.txt,sha256=2APEP25DbfjSxGeHtwBstMH8mulVhLkqF_b9bqzU6vQ,65
68
+ gamesentenceminer-2.9.20.dist-info/top_level.txt,sha256=V1hUY6xVSyUEohb0uDoN4UIE6rUZ_JYx8yMyPGX4PgQ,18
69
+ gamesentenceminer-2.9.20.dist-info/RECORD,,