GameSentenceMiner 2.17.6__py3-none-any.whl → 2.18.0__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.
Files changed (51) hide show
  1. GameSentenceMiner/ai/ai_prompting.py +51 -51
  2. GameSentenceMiner/anki.py +236 -152
  3. GameSentenceMiner/gametext.py +7 -4
  4. GameSentenceMiner/gsm.py +49 -10
  5. GameSentenceMiner/locales/en_us.json +7 -3
  6. GameSentenceMiner/locales/ja_jp.json +8 -4
  7. GameSentenceMiner/locales/zh_cn.json +8 -4
  8. GameSentenceMiner/obs.py +238 -59
  9. GameSentenceMiner/ocr/owocr_helper.py +1 -1
  10. GameSentenceMiner/tools/ss_selector.py +7 -8
  11. GameSentenceMiner/ui/__init__.py +0 -0
  12. GameSentenceMiner/ui/anki_confirmation.py +187 -0
  13. GameSentenceMiner/{config_gui.py → ui/config_gui.py} +102 -37
  14. GameSentenceMiner/ui/screenshot_selector.py +215 -0
  15. GameSentenceMiner/util/configuration.py +124 -22
  16. GameSentenceMiner/util/db.py +22 -13
  17. GameSentenceMiner/util/downloader/download_tools.py +2 -2
  18. GameSentenceMiner/util/ffmpeg.py +24 -30
  19. GameSentenceMiner/util/get_overlay_coords.py +34 -34
  20. GameSentenceMiner/util/gsm_utils.py +31 -1
  21. GameSentenceMiner/util/text_log.py +11 -9
  22. GameSentenceMiner/vad.py +31 -12
  23. GameSentenceMiner/web/database_api.py +742 -123
  24. GameSentenceMiner/web/static/css/dashboard-shared.css +241 -0
  25. GameSentenceMiner/web/static/css/kanji-grid.css +94 -2
  26. GameSentenceMiner/web/static/css/overview.css +850 -0
  27. GameSentenceMiner/web/static/css/popups-shared.css +126 -0
  28. GameSentenceMiner/web/static/css/shared.css +97 -0
  29. GameSentenceMiner/web/static/css/stats.css +192 -597
  30. GameSentenceMiner/web/static/js/anki_stats.js +6 -4
  31. GameSentenceMiner/web/static/js/database.js +209 -5
  32. GameSentenceMiner/web/static/js/goals.js +610 -0
  33. GameSentenceMiner/web/static/js/kanji-grid.js +267 -4
  34. GameSentenceMiner/web/static/js/overview.js +1176 -0
  35. GameSentenceMiner/web/static/js/shared.js +25 -0
  36. GameSentenceMiner/web/static/js/stats.js +154 -1459
  37. GameSentenceMiner/web/stats.py +2 -2
  38. GameSentenceMiner/web/templates/anki_stats.html +5 -0
  39. GameSentenceMiner/web/templates/components/navigation.html +3 -1
  40. GameSentenceMiner/web/templates/database.html +73 -1
  41. GameSentenceMiner/web/templates/goals.html +376 -0
  42. GameSentenceMiner/web/templates/index.html +13 -11
  43. GameSentenceMiner/web/templates/overview.html +416 -0
  44. GameSentenceMiner/web/templates/stats.html +46 -251
  45. GameSentenceMiner/web/texthooking_page.py +18 -0
  46. {gamesentenceminer-2.17.6.dist-info → gamesentenceminer-2.18.0.dist-info}/METADATA +5 -1
  47. {gamesentenceminer-2.17.6.dist-info → gamesentenceminer-2.18.0.dist-info}/RECORD +51 -41
  48. {gamesentenceminer-2.17.6.dist-info → gamesentenceminer-2.18.0.dist-info}/WHEEL +0 -0
  49. {gamesentenceminer-2.17.6.dist-info → gamesentenceminer-2.18.0.dist-info}/entry_points.txt +0 -0
  50. {gamesentenceminer-2.17.6.dist-info → gamesentenceminer-2.18.0.dist-info}/licenses/LICENSE +0 -0
  51. {gamesentenceminer-2.17.6.dist-info → gamesentenceminer-2.18.0.dist-info}/top_level.txt +0 -0
GameSentenceMiner/obs.py CHANGED
@@ -1,4 +1,5 @@
1
1
  import asyncio
2
+ import datetime
2
3
  import json
3
4
  import os.path
4
5
  import subprocess
@@ -11,12 +12,11 @@ import shutil
11
12
  import psutil
12
13
 
13
14
  import obsws_python as obs
15
+ import numpy as np
14
16
 
15
17
  from GameSentenceMiner.util import configuration
16
18
  from GameSentenceMiner.util.configuration import get_app_directory, get_config, get_master_config, is_windows, save_full_config, reload_config, logger, gsm_status, gsm_state
17
- from GameSentenceMiner.util.gsm_utils import sanitize_filename, make_unique_file_name
18
- import tkinter as tk
19
- from tkinter import messagebox
19
+ from GameSentenceMiner.util.gsm_utils import sanitize_filename, make_unique_file_name, make_unique_temp_file
20
20
 
21
21
  connection_pool: 'OBSConnectionPool' = None
22
22
  event_client: obs.EventClient = None
@@ -75,7 +75,7 @@ class OBSConnectionPool:
75
75
  self._clients[index] = obs.ReqClient(**self.connection_kwargs)
76
76
 
77
77
  @contextlib.contextmanager
78
- def get_client(self):
78
+ def get_client(self) -> obs.ReqClient:
79
79
  """A context manager to safely get a client from the pool."""
80
80
  with self._idx_lock:
81
81
  idx = self._next_idx
@@ -106,37 +106,114 @@ class OBSConnectionManager(threading.Thread):
106
106
  super().__init__()
107
107
  self.daemon = True
108
108
  self.running = True
109
+ self.should_check_output = check_output
109
110
  self.check_connection_interval = 1
110
- self.said_no_to_replay_buffer = False
111
111
  self.counter = 0
112
- self.check_output = check_output
112
+ self.last_replay_buffer_status = None
113
+ self.no_output_timestamp = None
114
+ self.NO_OUTPUT_SHUTDOWN_SECONDS = 300
115
+ self.last_errors = []
116
+ self.previous_image = None
117
+
118
+ def _check_obs_connection(self):
119
+ try:
120
+ client = connection_pool.get_healthcheck_client() if connection_pool else None
121
+ if client and not connecting:
122
+ client.get_version()
123
+ gsm_status.obs_connected = True
124
+ return True
125
+ else:
126
+ raise ConnectionError("Healthcheck client not available or connection in progress")
127
+ except Exception as e:
128
+ logger.debug(f"OBS WebSocket not connected. Attempting to reconnect... {e}")
129
+ gsm_status.obs_connected = False
130
+ asyncio.run(connect_to_obs())
131
+ return False
132
+
133
+ def check_replay_buffer_enabled(self):
134
+ if not self.should_check_output:
135
+ return True, ""
136
+ output = get_replay_buffer_output()
137
+ if not output:
138
+ return False, "Replay Buffer output not found in OBS. Please enable Replay Buffer In OBS Settings -> Output -> Replay Buffer. I recommend 300 seconds (5 minutes) or higher."
139
+ return True, ""
140
+
141
+ def _manage_replay_buffer_and_utils(self):
142
+ errors = []
143
+
144
+ if not self.should_check_output:
145
+ return errors
146
+
147
+ set_fit_to_screen_for_scene_items(get_current_scene())
148
+
149
+ if not get_config().obs.automatically_manage_replay_buffer:
150
+ errors.append("Automatic Replay Buffer management is disabled in GSM settings.")
151
+ return errors
152
+
153
+ replay_buffer_enabled, error_message = self.check_replay_buffer_enabled()
154
+
155
+ if not replay_buffer_enabled:
156
+ errors.append(error_message)
157
+ return errors
158
+
159
+ current_status = get_replay_buffer_status()
160
+
161
+ if self.last_replay_buffer_status is None:
162
+ self.last_replay_buffer_status = current_status
163
+ return errors
164
+
165
+ if current_status != self.last_replay_buffer_status:
166
+ self.last_replay_buffer_status = current_status
167
+ self.no_output_timestamp = None
168
+ return errors
169
+
170
+ img = get_screenshot_PIL(compression=100, img_format='jpg', width=1280, height=720)
171
+ has_changed = self.has_image_changed(img) if img else True
172
+
173
+ if not has_changed:
174
+ self.no_output_timestamp = None
175
+ if not current_status:
176
+ start_replay_buffer()
177
+ self.last_replay_buffer_status = True
178
+ else: # is_empty
179
+ if current_status:
180
+ if self.no_output_timestamp is None:
181
+ self.no_output_timestamp = time.time()
182
+ elif time.time() - self.no_output_timestamp >= self.NO_OUTPUT_SHUTDOWN_SECONDS:
183
+ stop_replay_buffer()
184
+ self.last_replay_buffer_status = False
185
+ self.no_output_timestamp = None
186
+
187
+ def has_image_changed(self, img):
188
+ if self.previous_image is None:
189
+ self.previous_image = np.array(img)
190
+ return True
191
+ try:
192
+ img1_np = np.array(img) if not isinstance(img, np.ndarray) else img
193
+ img2_np = self.previous_image
194
+ self.previous_image = img1_np
195
+ except Exception:
196
+ logger.warning("Failed to convert images to numpy arrays for comparison.")
197
+ return False
198
+
199
+ return (img1_np.shape == img2_np.shape) and np.array_equal(img1_np, img2_np)
113
200
 
114
201
  def run(self):
202
+ time.sleep(5) # Initial delay to allow OBS to start
115
203
  while self.running:
116
204
  time.sleep(self.check_connection_interval)
117
- try:
118
- client = connection_pool.get_healthcheck_client() if connection_pool else None
119
- if client and not connecting:
120
- client.get_version()
121
- else:
122
- raise ConnectionError("Healthcheck client not healthy or not initialized")
123
- except Exception as e:
124
- logger.info(f"OBS WebSocket not connected. Attempting to reconnect... {e}")
125
- gsm_status.obs_connected = False
126
- asyncio.run(connect_to_obs())
205
+
206
+ if not self._check_obs_connection():
207
+ continue
208
+
127
209
  if self.counter % 5 == 0:
128
210
  try:
129
- set_fit_to_screen_for_scene_items(get_current_scene())
130
- if get_config().obs.turn_off_output_check and self.check_output:
131
- replay_buffer_status = get_replay_buffer_status()
132
- if replay_buffer_status and self.said_no_to_replay_buffer:
133
- self.said_no_to_replay_buffer = False
134
- self.counter = 0
135
- if gsm_status.obs_connected and not replay_buffer_status and not self.said_no_to_replay_buffer:
136
- try:
137
- self.check_output()
138
- except Exception:
139
- pass
211
+ errors = self._manage_replay_buffer_and_utils()
212
+ if errors != self.last_errors:
213
+ if errors:
214
+ for error in errors:
215
+ logger.error(f"OBS Health Check: {error}")
216
+ self.last_errors = errors
140
217
  except Exception as e:
141
218
  logger.error(f"Error when running Extra Utils in OBS Health Check, Keeping ConnectionManager Alive: {e}")
142
219
  self.counter += 1
@@ -144,28 +221,6 @@ class OBSConnectionManager(threading.Thread):
144
221
  def stop(self):
145
222
  self.running = False
146
223
 
147
- def check_output(self):
148
- img = get_screenshot_PIL(compression=100, img_format='jpg', width=1280, height=720)
149
- extrema = img.getextrema()
150
- if isinstance(extrema[0], tuple):
151
- is_empty = all(e[0] == e[1] for e in extrema)
152
- else:
153
- is_empty = extrema[0] == extrema[1]
154
- if is_empty:
155
- return
156
- else:
157
- root = tk.Tk()
158
- root.attributes('-topmost', True)
159
- root.withdraw()
160
- root.deiconify()
161
- result = messagebox.askyesno("GSM - Replay Buffer", "The replay buffer is not running, but there seems to be output in OBS. Do you want to start it? (If you click 'No', you won't be asked until you either restart GSM or start/stop replay buffer manually.)")
162
- root.destroy()
163
- if not result:
164
- self.said_no_to_replay_buffer = True
165
- self.counter = 0
166
- return
167
- start_replay_buffer()
168
-
169
224
  def get_obs_path():
170
225
  return os.path.join(configuration.get_app_directory(), 'obs-studio/bin/64bit/obs64.exe')
171
226
 
@@ -232,6 +287,7 @@ async def wait_for_obs_connected():
232
287
  for _ in range(10):
233
288
  try:
234
289
  with connection_pool.get_client() as client:
290
+ client: obs.ReqClient
235
291
  response = client.get_version()
236
292
  if response:
237
293
  return True
@@ -313,6 +369,8 @@ async def connect_to_obs(retry=5, connections=2, check_output=False):
313
369
  obs_connection_manager = OBSConnectionManager(check_output=check_output)
314
370
  obs_connection_manager.start()
315
371
  update_current_game()
372
+ if get_config().features.generate_longplay and check_output:
373
+ start_recording(True)
316
374
  break # Exit the loop once connected
317
375
  except Exception as e:
318
376
  if retry <= 0:
@@ -368,8 +426,8 @@ def do_obs_call(method_name: str, from_dict=None, retry=3, **kwargs):
368
426
  def toggle_replay_buffer():
369
427
  try:
370
428
  with connection_pool.get_client() as client:
371
- response = client.toggle_replay_buffer()
372
- if response:
429
+ client: obs.ReqClient
430
+ client.toggle_replay_buffer()
373
431
  logger.info("Replay buffer Toggled.")
374
432
  except Exception as e:
375
433
  logger.error(f"Error toggling buffer: {e}")
@@ -377,8 +435,10 @@ def toggle_replay_buffer():
377
435
  def start_replay_buffer():
378
436
  try:
379
437
  with connection_pool.get_client() as client:
380
- response = client.start_replay_buffer()
381
- if response and response.ok:
438
+ client: obs.ReqClient
439
+ client.start_replay_buffer()
440
+ if get_config().features.generate_longplay:
441
+ start_recording(True)
382
442
  logger.info("Replay buffer started.")
383
443
  except Exception as e:
384
444
  logger.error(f"Error starting replay buffer: {e}")
@@ -394,8 +454,10 @@ def get_replay_buffer_status():
394
454
  def stop_replay_buffer():
395
455
  try:
396
456
  with connection_pool.get_client() as client:
397
- response = client.stop_replay_buffer()
398
- if response and response.ok:
457
+ client: obs.ReqClient
458
+ client.stop_replay_buffer()
459
+ if get_config().features.generate_longplay:
460
+ stop_recording()
399
461
  logger.info("Replay buffer stopped.")
400
462
  except Exception as e:
401
463
  logger.warning(f"Error stopping replay buffer: {e}")
@@ -403,15 +465,49 @@ def stop_replay_buffer():
403
465
  def save_replay_buffer():
404
466
  try:
405
467
  with connection_pool.get_client() as client:
406
- response = client.save_replay_buffer()
407
- if response and response.ok:
468
+ client: obs.ReqClient
469
+ client.save_replay_buffer()
408
470
  logger.info("Replay buffer saved. If your log stops here, make sure your obs output path matches \"Path To Watch\" in GSM settings.")
409
471
  except Exception as e:
410
472
  raise Exception(f"Error saving replay buffer: {e}")
411
473
 
474
+ def start_recording(longplay=False):
475
+ try:
476
+ with connection_pool.get_client() as client:
477
+ client: obs.ReqClient
478
+ if longplay:
479
+ gsm_state.recording_started_time = datetime.datetime.now()
480
+ gsm_state.current_srt = make_unique_temp_file(f"{get_current_game(sanitize=True)}.srt")
481
+ gsm_state.srt_index = 1
482
+ client.start_record()
483
+ logger.info("Recording started.")
484
+ except Exception as e:
485
+ logger.error(f"Error starting recording: {e}")
486
+ return None
487
+
488
+ def stop_recording():
489
+ try:
490
+ with connection_pool.get_client() as client:
491
+ client: obs.ReqClient
492
+ client.stop_record()
493
+ logger.info("Recording stopped.")
494
+ except Exception as e:
495
+ logger.error(f"Error stopping recording: {e}")
496
+
497
+ def get_last_recording_filename():
498
+ try:
499
+ with connection_pool.get_client() as client:
500
+ client: obs.ReqClient
501
+ response = client.get_record_status()
502
+ return response.recording_filename if response else ''
503
+ except Exception as e:
504
+ logger.error(f"Error getting last recording filename: {e}")
505
+ return ''
506
+
412
507
  def get_current_scene():
413
508
  try:
414
509
  with connection_pool.get_client() as client:
510
+ client: obs.ReqClient
415
511
  response = client.get_current_program_scene()
416
512
  return response.scene_name if response else ''
417
513
  except Exception as e:
@@ -421,6 +517,7 @@ def get_current_scene():
421
517
  def get_source_from_scene(scene_name):
422
518
  try:
423
519
  with connection_pool.get_client() as client:
520
+ client: obs.ReqClient
424
521
  response = client.get_scene_item_list(name=scene_name)
425
522
  return response.scene_items[0] if response and response.scene_items else ''
426
523
  except Exception as e:
@@ -436,15 +533,80 @@ def get_active_source():
436
533
  def get_record_directory():
437
534
  try:
438
535
  with connection_pool.get_client() as client:
536
+ client: obs.ReqClient
439
537
  response = client.get_record_directory()
440
538
  return response.record_directory if response else ''
441
539
  except Exception as e:
442
540
  logger.error(f"Error getting recording folder: {e}")
443
541
  return ''
542
+
543
+ def get_replay_buffer_max_time_seconds():
544
+ """
545
+ Gets the configured maximum replay buffer time in seconds using the v5 protocol.
546
+ """
547
+ try:
548
+ # Assumes a connection_pool object that provides a connected client
549
+ with connection_pool.get_client() as client:
550
+ client: obs.ReqClient
551
+ # For v5, we get settings for the 'replay_buffer' output
552
+ response = client.get_output_settings(name='Replay Buffer')
553
+
554
+ print(response.output_settings)
555
+
556
+ # The response object contains a dict of the actual settings
557
+ if response:
558
+ # The key for replay buffer length in seconds is 'max_time_sec'
559
+ settings = response.output_settings
560
+ if settings and 'max_time_sec' in settings:
561
+ return settings['max_time_sec']
562
+ else:
563
+ logger.warning("Replay buffer settings received, but 'max_time_sec' key was not found.")
564
+ return 0
565
+ else:
566
+ logger.warning(f"get_output_settings for replay_buffer failed: {response.status}")
567
+ return 0
568
+ except Exception as e:
569
+ logger.error(f"Exception while fetching replay buffer settings: {e}")
570
+ return 0
571
+
572
+ def enable_replay_buffer():
573
+ try:
574
+ with connection_pool.get_client() as client:
575
+ client: obs.ReqClient
576
+ response = client.set_output_settings(name='Replay Buffer', settings={'outputFlags': {'OBS_OUTPUT_AUDIO': True, 'OBS_OUTPUT_ENCODED': True, 'OBS_OUTPUT_MULTI_TRACK': True, 'OBS_OUTPUT_SERVICE': False, 'OBS_OUTPUT_VIDEO': True}})
577
+ if response and response.ok:
578
+ logger.info("Replay buffer enabled.")
579
+ return True
580
+ else:
581
+ logger.error(f"Failed to enable replay buffer: {response.status if response else 'No response'}")
582
+ return False
583
+ except Exception as e:
584
+ logger.error(f"Error enabling replay buffer: {e}")
585
+ return False
586
+
587
+ def get_output_list():
588
+ try:
589
+ with connection_pool.get_client() as client:
590
+ client: obs.ReqClient
591
+ response = client.get_output_list()
592
+ return response.outputs if response else None
593
+ except Exception as e:
594
+ logger.error(f"Error getting output list: {e}")
595
+ return None
596
+
597
+ def get_replay_buffer_output():
598
+ outputs = get_output_list()
599
+ if not outputs:
600
+ return None
601
+ for output in outputs:
602
+ if output.get('outputKind') == 'replay_buffer':
603
+ return output
604
+ return None
444
605
 
445
606
  def get_obs_scenes():
446
607
  try:
447
608
  with connection_pool.get_client() as client:
609
+ client: obs.ReqClient
448
610
  response = client.get_scene_list()
449
611
  return response.scenes if response else None
450
612
  except Exception as e:
@@ -526,6 +688,7 @@ def get_screenshot_PIL(source_name=None, compression=75, img_format='png', width
526
688
  return None
527
689
  while True:
528
690
  with connection_pool.get_client() as client:
691
+ client: obs.ReqClient
529
692
  response = client.get_source_screenshot(name=source_name, img_format=img_format, quality=compression, width=width, height=height)
530
693
  try:
531
694
  response.image_data = response.image_data.split(',', 1)[-1] # Remove data:image/png;base64, prefix if present
@@ -567,6 +730,7 @@ def set_fit_to_screen_for_scene_items(scene_name: str):
567
730
 
568
731
  try:
569
732
  with connection_pool.get_client() as client:
733
+ client: obs.ReqClient
570
734
  # 1. Get the canvas (base) resolution from OBS video settings
571
735
  video_settings = client.get_video_settings()
572
736
  if not hasattr(video_settings, 'base_width') or not hasattr(video_settings, 'base_height'):
@@ -686,6 +850,7 @@ def main():
686
850
 
687
851
  def create_scene():
688
852
  with connection_pool.get_client() as client:
853
+ client: obs.ReqClient
689
854
  # Extract fields from request_json
690
855
  request_json = r'{"sceneName":"SILENT HILL f","inputName":"SILENT HILL f - Capture","inputKind":"window_capture","inputSettings":{"mode":"window","window":"SILENT HILL f :UnrealWindow:SHf-Win64-Shipping.exe","capture_audio":true,"cursor":false,"method":"2"}}'
691
856
  request_dict = json.loads(request_json)
@@ -701,7 +866,21 @@ def create_scene():
701
866
  if __name__ == '__main__':
702
867
  logging.basicConfig(level=logging.INFO)
703
868
  connect_to_obs_sync()
704
- img = get_screenshot_PIL(source_name='Display Capture 2', compression=100, img_format='jpg', width=2560, height=1440)
705
- img.show()
869
+
870
+ save_replay_buffer()
871
+ # img = get_screenshot_PIL(source_name='Display Capture 2', compression=100, img_format='jpg', width=2560, height=1440)
872
+ # img.show()
873
+ # output_list = get_output_list()
874
+ # print(output_list)
875
+
876
+ # response = enable_replay_buffer()
877
+ # print(response)
878
+
879
+ # response = get_replay_buffer_max_time_seconds()
880
+ # # response is dataclass with attributes, print attributes
881
+ # print(response)
882
+
883
+ # response = enable_replay_buffer()
884
+ # print(response)
706
885
  # # set_fit_to_screen_for_scene_items(get_current_scene())
707
886
  # create_scene()
@@ -38,7 +38,7 @@ logger.setLevel(logging.DEBUG)
38
38
  # Create a file handler for logging
39
39
  log_file = os.path.join(get_app_directory(), "logs", "ocr_log.txt")
40
40
  os.makedirs(os.path.join(get_app_directory(), "logs"), exist_ok=True)
41
- file_handler = RotatingFileHandler(log_file, maxBytes=1024 * 1024, backupCount=5, encoding='utf-8')
41
+ file_handler = RotatingFileHandler(log_file, maxBytes=1024 * 1024, backupCount=2, encoding='utf-8')
42
42
  file_handler.setLevel(logging.DEBUG)
43
43
  # Create a formatter and set it for the handler
44
44
  formatter = logging.Formatter('%(asctime)s - %(levelname)s - %(message)s')
@@ -1,22 +1,21 @@
1
-
1
+ # TODO REMOVE THIS, DEPRECATED
2
2
 
3
3
  import os
4
4
  import sys
5
5
  import subprocess
6
6
 
7
+ import tkinter as tk
8
+ from PIL import Image, ImageTk
9
+ from GameSentenceMiner.util.gsm_utils import sanitize_filename
10
+ from GameSentenceMiner.util.configuration import get_temporary_directory, logger, ffmpeg_base_command_list
11
+ from GameSentenceMiner.util import ffmpeg
12
+
7
13
  # Suppress stdout and stderr during imports
8
14
  sys_stdout = sys.stdout
9
15
  sys_stderr = sys.stderr
10
16
  sys.stdout = open(os.devnull, 'w')
11
17
  sys.stderr = open(os.devnull, 'w')
12
18
 
13
- import tkinter as tk
14
- from PIL import Image, ImageTk
15
- from GameSentenceMiner.util.gsm_utils import sanitize_filename
16
- from GameSentenceMiner.util.configuration import get_temporary_directory, logger
17
- from GameSentenceMiner.util.ffmpeg import ffmpeg_base_command_list
18
- from GameSentenceMiner.util import ffmpeg
19
-
20
19
  def extract_frames(video_path, timestamp, temp_dir, mode):
21
20
  frame_paths = []
22
21
  timestamp_number = float(timestamp)
File without changes
@@ -0,0 +1,187 @@
1
+ import tkinter as tk
2
+ from tkinter import scrolledtext
3
+ from PIL import Image, ImageTk
4
+
5
+ import ttkbootstrap as ttk
6
+ from GameSentenceMiner.util.configuration import get_config, logger, gsm_state
7
+
8
+ import platform
9
+ import subprocess
10
+ import os
11
+
12
+ class AnkiConfirmationDialog(tk.Toplevel):
13
+ """
14
+ A modal dialog to confirm Anki card details and choose an audio option.
15
+ """
16
+ def __init__(self, parent, config_app, expression, sentence, screenshot_path, audio_path, translation, screenshot_timestamp):
17
+ super().__init__(parent)
18
+ self.config_app = config_app
19
+ self.screenshot_timestamp = screenshot_timestamp
20
+
21
+ # Initialize screenshot_path here, will be updated by button if needed
22
+ self.screenshot_path = screenshot_path
23
+
24
+ self.title("Confirm Anki Card Details")
25
+ self.result = None # This will store the user's choice
26
+
27
+ # This makes the dialog block interaction with other windows.
28
+ self.grab_set()
29
+
30
+ # --- Create and lay out widgets ---
31
+ self._create_widgets(expression, sentence, screenshot_path, audio_path, translation)
32
+
33
+ # --- Smarter Centering Logic ---
34
+ self.update_idletasks()
35
+
36
+ if parent.state() == 'withdrawn':
37
+ screen_width = self.winfo_screenwidth()
38
+ screen_height = self.winfo_screenheight()
39
+ dialog_width = self.winfo_width()
40
+ dialog_height = self.winfo_height()
41
+ x = (screen_width // 2) - (dialog_width // 2)
42
+ y = (screen_height // 2) - (dialog_height // 2)
43
+ self.geometry(f'+{x}+{y}')
44
+ else:
45
+ self.transient(parent)
46
+ parent_x = parent.winfo_x()
47
+ parent_y = parent.winfo_y()
48
+ parent_width = parent.winfo_width()
49
+ parent_height = parent.winfo_height()
50
+ dialog_width = self.winfo_width()
51
+ dialog_height = self.winfo_height()
52
+ x = parent_x + (parent_width // 2) - (dialog_width // 2)
53
+ y = parent_y + (parent_height // 2) - (dialog_height // 2)
54
+ self.geometry(f'+{x}+{y}')
55
+
56
+ self.protocol("WM_DELETE_WINDOW", self._on_cancel)
57
+ self.attributes('-topmost', True)
58
+ self.wait_window(self)
59
+
60
+ def _create_widgets(self, expression, sentence, screenshot_path, audio_path, translation):
61
+ main_frame = ttk.Frame(self, padding=20)
62
+ main_frame.pack(expand=True, fill="both")
63
+
64
+ row = 0
65
+
66
+ # Expression
67
+ ttk.Label(main_frame, text=f"{get_config().anki.word_field}:", font=("-weight bold")).grid(row=row, column=0, sticky="ne", padx=5, pady=2)
68
+ ttk.Label(main_frame, text=expression, wraplength=400, justify="left").grid(row=row, column=1, sticky="w", padx=5, pady=2)
69
+ row += 1
70
+
71
+ # Sentence
72
+ ttk.Label(main_frame, text=f"{get_config().anki.sentence_field}:", font=("-weight bold")).grid(row=row, column=0, sticky="ne", padx=5, pady=2)
73
+ sentence_text = scrolledtext.ScrolledText(main_frame, height=4, width=50, wrap=tk.WORD)
74
+ sentence_text.insert(tk.END, sentence)
75
+ sentence_text.grid(row=row, column=1, sticky="w", padx=5, pady=2)
76
+ self.sentence_text = sentence_text
77
+ row += 1
78
+
79
+ # Translation
80
+ ttk.Label(main_frame, text=f"{get_config().ai.anki_field}:", font=("-weight bold")).grid(row=row, column=0, sticky="ne", padx=5, pady=2)
81
+ translation_text = scrolledtext.ScrolledText(main_frame, height=4, width=50, wrap=tk.WORD)
82
+ translation_text.insert(tk.END, translation)
83
+ translation_text.grid(row=row, column=1, sticky="w", padx=5, pady=2)
84
+ self.translation_text = translation_text
85
+ row += 1
86
+
87
+ # Screenshot
88
+ ttk.Label(main_frame, text=f"{get_config().anki.picture_field}:", font=("-weight bold")).grid(row=row, column=0, sticky="ne", padx=5, pady=2)
89
+
90
+ # <<< CHANGED: Step 1 - Create and store the label for the image
91
+ self.image_label = ttk.Label(main_frame)
92
+ self.image_label.grid(row=row, column=1, sticky="w", padx=5, pady=2)
93
+
94
+ try:
95
+ img = Image.open(screenshot_path)
96
+ img.thumbnail((400, 300))
97
+ self.photo_image = ImageTk.PhotoImage(img)
98
+ # Configure the label we just created
99
+ self.image_label.config(image=self.photo_image)
100
+ # Keep a reference on the widget itself to prevent garbage collection!
101
+ self.image_label.image = self.photo_image
102
+ except Exception as e:
103
+ # Configure the label to show an error message
104
+ self.image_label.config(text=f"Could not load image:\n{screenshot_path}\n{e}", foreground="red")
105
+
106
+ # Open Screenshot Selector button
107
+ ttk.Button(main_frame, text="Open Screenshot Selector", command=self._get_different_screenshot).grid(row=row, column=2, sticky="w", padx=5, pady=2)
108
+
109
+ row += 1
110
+
111
+ # Audio Path
112
+ ttk.Label(main_frame, text="Audio Path:", font=("-weight bold")).grid(row=row, column=0, sticky="ne", padx=5, pady=2)
113
+ ttk.Label(main_frame, text=audio_path if audio_path else "No Audio", wraplength=400, justify="left").grid(row=row, column=1, sticky="w", padx=5, pady=2)
114
+ if audio_path and os.path.isfile(audio_path):
115
+ ttk.Button(main_frame, text="Play Audio", command=lambda: self._play_audio(audio_path)).grid(row=row, column=2, sticky="w", padx=5, pady=2)
116
+
117
+ row += 1
118
+
119
+ # Action Buttons
120
+ button_frame = ttk.Frame(main_frame)
121
+ button_frame.grid(row=row, column=0, columnspan=2, pady=15)
122
+ if audio_path and os.path.isfile(audio_path):
123
+ ttk.Button(button_frame, text="Voice", command=self._on_voice, bootstyle="success").pack(side="left", padx=10)
124
+ ttk.Button(button_frame, text="NO Voice", command=self._on_no_voice, bootstyle="danger").pack(side="left", padx=10)
125
+ else:
126
+ ttk.Button(button_frame, text="Confirm", command=self._on_no_voice, bootstyle="primary").pack(side="left", padx=10)
127
+
128
+
129
+ def _get_different_screenshot(self):
130
+ video_path = gsm_state.current_replay
131
+ new_screenshot_path = self.config_app.show_screenshot_selector(
132
+ video_path, self.screenshot_timestamp, mode=get_config().screenshot.screenshot_timing_setting
133
+ )
134
+
135
+ # If the user cancels the selector, it might return None or an empty string
136
+ if not new_screenshot_path:
137
+ return
138
+
139
+ self.screenshot_path = new_screenshot_path # Update the path to be returned later
140
+
141
+ try:
142
+ img = Image.open(self.screenshot_path)
143
+ img.thumbnail((400, 300))
144
+ # Create the new image object
145
+ self.photo_image = ImageTk.PhotoImage(img)
146
+
147
+ # <<< CHANGED: Step 2 - Update the label with the new image
148
+ self.image_label.config(image=self.photo_image, text="") # Clear any previous error text
149
+
150
+ # This is crucial! Keep a reference to the new image object on the widget
151
+ # itself, so it doesn't get garbage-collected.
152
+ self.image_label.image = self.photo_image
153
+
154
+ except Exception as e:
155
+ # Handle cases where the newly selected file is invalid
156
+ self.image_label.config(image=None, text=f"Could not load new image:\n{e}", foreground="red")
157
+ self.image_label.image = None # Clear old image reference
158
+
159
+
160
+ def _play_audio(self, audio_path):
161
+ if not os.path.isfile(audio_path):
162
+ print(f"Audio file does not exist: {audio_path}")
163
+ return
164
+ try:
165
+ if platform.system() == "Windows":
166
+ os.startfile(audio_path)
167
+ elif platform.system() == "Darwin":
168
+ subprocess.run(["open", audio_path])
169
+ else:
170
+ subprocess.run(["xdg-open", audio_path])
171
+ except Exception as e:
172
+ print(f"Failed to play audio: {e}")
173
+
174
+ def _on_voice(self):
175
+ # The screenshot_path is now correctly updated if the user chose a new one
176
+ self.result = (True, self.sentence_text.get("1.0", tk.END).strip(), self.translation_text.get("1.0", tk.END).strip(), self.screenshot_path)
177
+ self.destroy()
178
+
179
+ def _on_no_voice(self):
180
+ self.result = (False, self.sentence_text.get("1.0", tk.END).strip(), self.translation_text.get("1.0", tk.END).strip(), self.screenshot_path)
181
+ self.destroy()
182
+
183
+ def _on_cancel(self):
184
+ # We block the cancel button, but if you wanted to enable it:
185
+ # self.result = None
186
+ # self.destroy()
187
+ pass