GameSentenceMiner 2.19.6__py3-none-any.whl → 2.19.8__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.

Potentially problematic release.


This version of GameSentenceMiner might be problematic. Click here for more details.

GameSentenceMiner/anki.py CHANGED
@@ -28,7 +28,7 @@ import re
28
28
  import platform
29
29
  import sys
30
30
 
31
- from dataclasses import dataclass
31
+ from dataclasses import dataclass, field
32
32
  from typing import Dict, Any, List
33
33
 
34
34
  # Global variables to track state
@@ -58,6 +58,8 @@ class MediaAssets:
58
58
  final_prev_screenshot_path: str = ''
59
59
  final_video_path: str = ''
60
60
 
61
+ extra_tags: List[str] = field(default_factory=list)
62
+
61
63
 
62
64
  def _determine_update_conditions(last_note: 'AnkiCard') -> (bool, bool):
63
65
  """Determine if audio and picture fields should be updated."""
@@ -83,7 +85,10 @@ def _generate_media_files(reuse_audio: bool, game_line: 'GameLine', video_path:
83
85
  assets.screenshot_in_anki = anki_result.screenshot_in_anki
84
86
  assets.prev_screenshot_in_anki = anki_result.prev_screenshot_in_anki
85
87
  assets.video_in_anki = anki_result.video_in_anki
88
+ assets.extra_tags = anki_result.extra_tags
86
89
  return assets
90
+
91
+ assets.extra_tags = []
87
92
 
88
93
  # --- Generate new media files ---
89
94
  if config.anki.picture_field and config.screenshot.enabled:
@@ -242,7 +247,7 @@ def update_anki_card(last_note: 'AnkiCard', note=None, audio_path='', video_path
242
247
 
243
248
  # Add NSFW tag if checkbox was selected
244
249
  if add_nsfw_tag:
245
- tags.append("NSFW")
250
+ assets.extra_tags.append("NSFW")
246
251
 
247
252
  # 5. If creating new media, store files in Anki's collection. Then update note fields.
248
253
  if not use_existing_files:
@@ -266,6 +271,9 @@ def update_anki_card(last_note: 'AnkiCard', note=None, audio_path='', video_path
266
271
  if config.audio.external_tool and config.audio.external_tool_enabled:
267
272
  anki_media_audio_path = os.path.join(config.audio.anki_media_collection, assets.audio_in_anki)
268
273
  open_audio_in_external(anki_media_audio_path)
274
+
275
+ for extra_tag in assets.extra_tags:
276
+ tags.append(extra_tag)
269
277
 
270
278
  # 6. Asynchronously update the note in Anki
271
279
  run_new_thread(lambda: check_and_update_note(last_note, note, tags))
@@ -284,7 +292,8 @@ def update_anki_card(last_note: 'AnkiCard', note=None, audio_path='', video_path
284
292
  multi_line=bool(selected_lines and len(selected_lines) > 1),
285
293
  video_in_anki=assets.video_in_anki or '',
286
294
  word_path=word_path,
287
- word=tango
295
+ word=tango,
296
+ extra_tags=assets.extra_tags
288
297
  )
289
298
 
290
299
  # 9. Update the local application database with final paths
@@ -358,6 +358,10 @@
358
358
  "hotkey_updates_anki": {
359
359
  "label": "Screenshot Hotkey Updates Anki:",
360
360
  "tooltip": "Enable to allow Screenshot hotkey/button to update the latest anki card."
361
+ },
362
+ "trim_black_bars": {
363
+ "label": "Trim Black Bars:",
364
+ "tooltip": "Automatically trim black bars from screenshots. Useful for games with letterboxing/pillarboxing (e.g., 4:3 games on 16:9 displays)."
361
365
  }
362
366
  },
363
367
  "audio": {
@@ -357,6 +357,10 @@
357
357
  "hotkey_updates_anki": {
358
358
  "label": "ホットキーでAnkiを更新:",
359
359
  "tooltip": "撮影ホットキーで最新のAnkiカードを更新できるようにします。"
360
+ },
361
+ "trim_black_bars": {
362
+ "label": "黒帯をトリミング:",
363
+ "tooltip": "スクリーンショットから黒帯を自動的にトリミングします。レターボックス/ピラーボックスのあるゲーム(16:9ディスプレイ上の4:3ゲームなど)に便利です。"
360
364
  }
361
365
  },
362
366
  "audio": {
@@ -358,6 +358,10 @@
358
358
  "hotkey_updates_anki": {
359
359
  "label": "截图热键更新 Anki:",
360
360
  "tooltip": "允许截图热键/按钮更新最新的 Anki 卡片。"
361
+ },
362
+ "trim_black_bars": {
363
+ "label": "裁剪黑边:",
364
+ "tooltip": "自动裁剪截图中的黑边。适用于有信箱/柱状框的游戏(例如在 16:9 显示器上的 4:3 游戏)。"
361
365
  }
362
366
  },
363
367
  "audio": {
GameSentenceMiner/obs.py CHANGED
@@ -565,6 +565,24 @@ def get_active_source():
565
565
  return None
566
566
  return get_source_from_scene(current_game)
567
567
 
568
+ def get_active_video_sources():
569
+ current_game = get_current_game()
570
+ if not current_game:
571
+ return None
572
+ scene_items_response = []
573
+ try:
574
+ with connection_pool.get_client() as client:
575
+ client: obs.ReqClient
576
+ response = client.get_scene_item_list(name=current_game)
577
+ scene_items_response = response.scene_items if response else []
578
+ except Exception as e:
579
+ logger.error(f"Error getting scene items for active video source: {e}")
580
+ return None
581
+ if not scene_items_response:
582
+ return None
583
+ video_sources = ['window_capture', 'game_capture', 'monitor_capture']
584
+ return [item for item in scene_items_response if item.get('inputKind') in video_sources]
585
+
568
586
  def get_record_directory():
569
587
  try:
570
588
  with connection_pool.get_client() as client:
@@ -709,32 +727,142 @@ def get_screenshot_base64(compression=75, width=None, height=None):
709
727
  return None
710
728
 
711
729
 
712
- def get_screenshot_PIL(source_name=None, compression=75, img_format='png', width=None, height=None, retry=3):
730
+ def get_screenshot_PIL_from_source(source_name, compression=75, img_format='png', width=None, height=None, retry=3):
731
+ """
732
+ Get a PIL Image screenshot from a specific OBS source.
733
+
734
+ Args:
735
+ source_name: The name of the OBS source to capture
736
+ compression: Image quality (0-100)
737
+ img_format: Image format ('png' or 'jpg')
738
+ width: Optional width to resize
739
+ height: Optional height to resize
740
+ retry: Number of retry attempts
741
+
742
+ Returns:
743
+ PIL.Image or None if failed
744
+ """
713
745
  import io
714
746
  import base64
715
747
  from PIL import Image
748
+
716
749
  if not source_name:
717
- source_name = get_active_source().get('sourceName', None)
718
- if not source_name:
719
- logger.error("No active source found in the current scene.")
750
+ logger.error("No source name provided.")
720
751
  return None
721
- while True:
722
- with connection_pool.get_client() as client:
723
- client: obs.ReqClient
724
- response = client.get_source_screenshot(name=source_name, img_format=img_format, quality=compression, width=width, height=height)
752
+
753
+ for attempt in range(retry):
725
754
  try:
726
- response.image_data = response.image_data.split(',', 1)[-1] # Remove data:image/png;base64, prefix if present
755
+ with connection_pool.get_client() as client:
756
+ client: obs.ReqClient
757
+ response = client.get_source_screenshot(name=source_name, img_format=img_format, quality=compression, width=width, height=height)
758
+
759
+ if response and hasattr(response, 'image_data') and response.image_data:
760
+ image_data = response.image_data.split(',', 1)[-1] # Remove data:image/png;base64, prefix if present
761
+ image_data = base64.b64decode(image_data)
762
+ img = Image.open(io.BytesIO(image_data)).convert("RGBA")
763
+ return img
727
764
  except AttributeError:
728
- retry -= 1
729
- if retry <= 0:
730
- logger.error(f"Error getting screenshot: {response}")
765
+ if attempt >= retry - 1:
766
+ logger.error(f"Error getting screenshot from source '{source_name}': Invalid response")
731
767
  return None
768
+ time.sleep(0.1)
769
+ except Exception as e:
770
+ logger.error(f"Error getting screenshot from source '{source_name}': {e}")
771
+ return None
772
+
773
+ return None
774
+
775
+
776
+ def get_best_source_for_screenshot():
777
+ """
778
+ Get the best available video source dict based on priority and image validation.
779
+
780
+ Priority order: window_capture > game_capture > monitor_capture
781
+
782
+ Returns:
783
+ The source dict of the best available source, or None if no valid source found.
784
+ """
785
+ return get_screenshot_PIL(return_source_dict=True)
786
+
787
+
788
+ def get_screenshot_PIL(source_name=None, compression=75, img_format='png', width=None, height=None, retry=3, return_source_dict=False):
789
+ """
790
+ Get a PIL Image screenshot. If no source_name is provided, automatically selects
791
+ the best available source based on priority and validates it has actual image data.
792
+
793
+ Priority order: window_capture > game_capture > monitor_capture
794
+
795
+ Args:
796
+ source_name: Optional specific OBS source name. If None, auto-selects best source.
797
+ compression: Image quality (0-100)
798
+ img_format: Image format ('png' or 'jpg')
799
+ width: Optional width to resize
800
+ height: Optional height to resize
801
+ retry: Number of retry attempts
802
+ return_source_dict: If True, returns only the source dict. If False, returns only the PIL.Image.
803
+
804
+ Returns:
805
+ PIL.Image if return_source_dict=False, or source dict if return_source_dict=True.
806
+ Returns None if failed.
807
+ """
808
+ import io
809
+ import base64
810
+ from PIL import Image
811
+
812
+ # If source_name is provided, use it directly
813
+ if source_name:
814
+ if return_source_dict:
815
+ # Need to find the source dict for this source_name
816
+ current_sources = get_active_video_sources()
817
+ if current_sources:
818
+ for src in current_sources:
819
+ if src.get('sourceName') == source_name:
820
+ return src
821
+ return None
822
+ img = get_screenshot_PIL_from_source(source_name, compression, img_format, width, height, retry)
823
+ return img
824
+
825
+ # Get all available video sources
826
+ current_sources = get_active_video_sources()
827
+ if not current_sources:
828
+ logger.error("No active video sources found in the current scene.")
829
+ return None
830
+
831
+ # Priority: window_capture (0) > game_capture (1) > monitor_capture (2)
832
+ priority_map = {'window_capture': 0, 'game_capture': 1, 'monitor_capture': 2}
833
+
834
+ # Sort sources by priority
835
+ sorted_sources = sorted(
836
+ current_sources,
837
+ key=lambda x: priority_map.get(x.get('inputKind'), 999)
838
+ )
839
+
840
+ # Try each source in priority order
841
+ for source in sorted_sources:
842
+ found_source_name = source.get('sourceName')
843
+ if not found_source_name:
732
844
  continue
733
- if response and response.image_data:
734
- image_data = response.image_data.split(',', 1)[-1] # Remove data:image/png;base64, prefix if present
735
- image_data = base64.b64decode(image_data)
736
- img = Image.open(io.BytesIO(image_data)).convert("RGBA")
737
- return img
845
+
846
+ img = get_screenshot_PIL_from_source(found_source_name, compression, img_format, width, height, retry)
847
+
848
+ if img:
849
+ # Validate that the image has actual content (not completely empty/black)
850
+ try:
851
+ extrema = img.getextrema()
852
+ if isinstance(extrema[0], tuple):
853
+ is_empty = all(e[0] == e[1] for e in extrema)
854
+ else:
855
+ is_empty = extrema[0] == extrema[1]
856
+
857
+ if not is_empty:
858
+ return source if return_source_dict else img
859
+ else:
860
+ logger.debug(f"Source '{found_source_name}' returned an empty image, trying next source")
861
+ except Exception as e:
862
+ logger.warning(f"Failed to validate image from source '{found_source_name}': {e}")
863
+ # If validation fails, still return the image as it might be valid
864
+ return source if return_source_dict else img
865
+
738
866
  return None
739
867
 
740
868
 
@@ -915,6 +1043,13 @@ def create_scene():
915
1043
  if __name__ == '__main__':
916
1044
  logging.basicConfig(level=logging.INFO)
917
1045
  connect_to_obs_sync()
1046
+ try:
1047
+ with connection_pool.get_client() as client:
1048
+ client: obs.ReqClient
1049
+ resp = client.get_scene_item_list(get_current_scene())
1050
+ print(resp.scene_items)
1051
+ except Exception as e:
1052
+ print(f"Error: {e}")
918
1053
 
919
1054
  # outputs = get_output_list()
920
1055
  # print(outputs)
@@ -54,8 +54,11 @@ class ScreenSelector:
54
54
  raise RuntimeError("mss is required for screen selection.")
55
55
 
56
56
  if self.use_obs_screenshot:
57
- print("Using OBS screenshot as target.")
58
- self.screenshot_img = obs.get_screenshot_PIL(compression=75)
57
+ sources = obs.get_active_video_sources()
58
+ best_source = obs.get_best_source_for_screenshot()
59
+ if len(sources) > 1:
60
+ logger.warning(f"Warning: Multiple active video sources found in OBS. Using '{best_source.get('sourceName')}' for screenshot. Please ensure only one source is active for best results.")
61
+ self.screenshot_img = obs.get_screenshot_PIL(compression=100, img_format='jpg')
59
62
  # print(screenshot_base64)
60
63
  if not self.screenshot_img:
61
64
  raise RuntimeError("Failed to get OBS screenshot.")
@@ -391,7 +391,7 @@ def text_callback(text, orig_text, time, img=None, came_from_ss=False, filtering
391
391
  stable_time = last_meiki_crop_time
392
392
  previous_img_local = previous_img
393
393
  pre_crop_image = previous_img_local
394
- ocr2_image = get_ocr2_image(crop_coords, og_image=previous_img_local, ocr2_engine=get_ocr_ocr2())
394
+ ocr2_image = get_ocr2_image(crop_coords, og_image=previous_img_local, ocr2_engine=get_ocr_ocr2(), extra_padding=10)
395
395
  # Use the earlier timestamp for when the stable crop started if available
396
396
  # ocr2_image.show()
397
397
  second_ocr_queue.put((text, stable_time, ocr2_image, filtering, pre_crop_image))
@@ -482,22 +482,54 @@ done = False
482
482
  # Create a queue for tasks
483
483
  second_ocr_queue = queue.Queue()
484
484
 
485
- def get_ocr2_image(crop_coords, og_image: Image.Image, ocr2_engine=None):
485
+ def get_ocr2_image(crop_coords, og_image: Image.Image, ocr2_engine=None, extra_padding=0):
486
486
  """
487
487
  Returns the image to use for the second OCR pass, cropping and scaling as needed.
488
488
  Logic is unchanged, but code is refactored for clarity and maintainability.
489
489
  """
490
490
  def return_original_image():
491
+ """Return a (possibly cropped) PIL.Image based on the original image and padding."""
491
492
  logger.debug("Returning original image for OCR2 (no cropping or optimization).")
493
+ # Convert bytes to PIL.Image if necessary
494
+ img = og_image
495
+ if isinstance(og_image, (bytes, bytearray)):
496
+ try:
497
+ img = Image.open(io.BytesIO(og_image)).convert('RGB')
498
+ except Exception:
499
+ # If conversion fails, just return og_image as-is
500
+ return og_image
501
+
492
502
  if not crop_coords or not get_ocr_optimize_second_scan():
493
- return og_image
503
+ return img
504
+
494
505
  x1, y1, x2, y2 = crop_coords
495
- x1 = min(max(0, x1), og_image.width)
496
- y1 = min(max(0, y1), og_image.height)
497
- x2 = min(max(0, x2), og_image.width)
498
- y2 = min(max(0, y2), og_image.height)
499
- og_image.save(os.path.join(get_temporary_directory(), "pre_oneocrcrop.png"))
500
- return og_image.crop((x1, y1, x2, y2))
506
+ # Apply integer padding (can be negative to shrink)
507
+ pad = int(extra_padding or 0)
508
+ x1 = x1 - pad
509
+ y1 = y1 - pad
510
+ x2 = x2 + pad
511
+ y2 = y2 + pad
512
+
513
+ # Clamp coordinates to image bounds
514
+ x1 = min(max(0, int(x1)), img.width)
515
+ y1 = min(max(0, int(y1)), img.height)
516
+ x2 = min(max(0, int(x2)), img.width)
517
+ y2 = min(max(0, int(y2)), img.height)
518
+
519
+ # Ensure at least a 1-pixel width/height
520
+ if x2 <= x1:
521
+ x2 = min(img.width, x1 + 1)
522
+ x1 = max(0, x2 - 1)
523
+ if y2 <= y1:
524
+ y2 = min(img.height, y1 + 1)
525
+ y1 = max(0, y2 - 1)
526
+
527
+ try:
528
+ img.save(os.path.join(get_temporary_directory(), "pre_oneocrcrop.png"))
529
+ except Exception:
530
+ # don't fail just because we couldn't save a debug image
531
+ logger.debug("Could not save pre_oneocrcrop.png for debugging")
532
+ return img.crop((x1, y1, x2, y2))
501
533
 
502
534
  # TODO Get rid of this check, and just always convert to full res
503
535
  LOCAL_OCR_ENGINES = ['easyocr', 'oneocr', 'rapidocr', 'mangaocr', 'winrtocr']
@@ -541,16 +573,33 @@ def get_ocr2_image(crop_coords, og_image: Image.Image, ocr2_engine=None):
541
573
  x2 = int(crop_coords[2] * width_ratio)
542
574
  y2 = int(crop_coords[3] * height_ratio)
543
575
 
576
+ # Scale padding separately for X and Y
577
+ pad_x = int(round((extra_padding or 0) * width_ratio))
578
+ pad_y = int(round((extra_padding or 0) * height_ratio))
579
+
580
+ x1 = x1 - pad_x
581
+ y1 = y1 - pad_y
582
+ x2 = x2 + pad_x
583
+ y2 = y2 + pad_y
584
+
544
585
  # Clamp coordinates to image bounds
545
- x1 = min(max(0, x1), img.width)
546
- y1 = min(max(0, y1), img.height)
547
- x2 = min(max(0, x2), img.width)
548
- y2 = min(max(0, y2), img.height)
549
-
586
+ x1 = min(max(0, int(x1)), img.width)
587
+ y1 = min(max(0, int(y1)), img.height)
588
+ x2 = min(max(0, int(x2)), img.width)
589
+ y2 = min(max(0, int(y2)), img.height)
590
+
591
+ # Ensure at least a 1-pixel width/height
592
+ if x2 <= x1:
593
+ x2 = min(img.width, x1 + 1)
594
+ x1 = max(0, x2 - 1)
595
+ if y2 <= y1:
596
+ y2 = min(img.height, y1 + 1)
597
+ y1 = max(0, y2 - 1)
598
+
550
599
  logger.debug(f"Scaled crop coordinates: {(x1, y1, x2, y2)}")
551
-
600
+
552
601
  img = run.apply_ocr_config_to_image(img, ocr_config_local, is_secondary=False)
553
-
602
+
554
603
  ret = img.crop((x1, y1, x2, y2))
555
604
  return ret
556
605
 
@@ -763,7 +812,7 @@ if __name__ == "__main__":
763
812
  try:
764
813
  while not done:
765
814
  time.sleep(1)
766
- except KeyboardInterrupt as e:
815
+ except KeyboardInterrupt:
767
816
  pass
768
817
  else:
769
818
  print("Failed to load OCR configuration. Please check the logs.")
@@ -1038,7 +1038,8 @@ class OBSScreenshotThread(threading.Thread):
1038
1038
  def init_config(self, source=None, scene=None):
1039
1039
  import GameSentenceMiner.obs as obs
1040
1040
  obs.update_current_game()
1041
- self.current_source = source if source else obs.get_active_source()
1041
+ current_sources = obs.get_active_video_sources()
1042
+ self.current_source = source if source else obs.get_best_source_for_screenshot()
1042
1043
  logger.debug(f"Current OBS source: {self.current_source}")
1043
1044
  self.source_width = self.current_source.get(
1044
1045
  "sceneItemTransform").get("sourceWidth") or self.width
@@ -1056,6 +1057,8 @@ class OBSScreenshotThread(threading.Thread):
1056
1057
  f"Using source dimensions: {self.width}x{self.height}")
1057
1058
  self.current_source_name = self.current_source.get(
1058
1059
  "sourceName") or None
1060
+ if len(current_sources) > 1:
1061
+ logger.error(f"Multiple active video sources found in OBS. Using {self.current_source_name} for Screenshot. Please ensure only one source is active for best results.")
1059
1062
  self.current_scene = scene if scene else obs.get_current_game()
1060
1063
  self.ocr_config = get_scene_ocr_config(refresh=True)
1061
1064
  if not self.ocr_config:
@@ -1394,7 +1397,7 @@ def process_and_write_results(img_or_path, write_to=None, last_result=None, filt
1394
1397
  if res:
1395
1398
  if 'provider' in text:
1396
1399
  if write_to == 'callback':
1397
- logger.opt(ansi=True).info(f"{len(text['boxes'])} text boxes recognized using Meiki:")
1400
+ logger.opt(ansi=True).info(f"{len(text['boxes'])} text boxes recognized in {end_time - start_time:0.03f}s using Meiki:")
1398
1401
  txt_callback('', '', ocr_start_time,
1399
1402
  img_or_path, is_second_ocr, filtering, text.get('crop_coords', None), meiki_boxes=text.get('boxes', []))
1400
1403
  return str(text), str(text)
@@ -449,6 +449,7 @@ class ConfigApp:
449
449
  self.screenshot_timing_value = tk.StringVar(value=self.settings.screenshot.screenshot_timing_setting)
450
450
  self.use_screenshot_selector_value = tk.BooleanVar(value=self.settings.screenshot.use_screenshot_selector)
451
451
  self.animated_screenshot_value = tk.BooleanVar(value=self.settings.screenshot.animated)
452
+ self.trim_black_bars_value = tk.BooleanVar(value=self.settings.screenshot.trim_black_bars_wip)
452
453
 
453
454
  # Audio Settings
454
455
  self.audio_enabled_value = tk.BooleanVar(value=self.settings.audio.enabled)
@@ -703,6 +704,7 @@ class ConfigApp:
703
704
  seconds_after_line=float(self.seconds_after_line_value.get()) if self.seconds_after_line_value.get() else 0.0,
704
705
  screenshot_timing_setting=self.screenshot_timing_value.get(),
705
706
  use_screenshot_selector=self.use_screenshot_selector_value.get(),
707
+ trim_black_bars_wip=self.trim_black_bars_value.get(),
706
708
  ),
707
709
  audio=Audio(
708
710
  enabled=self.audio_enabled_value.get(),
@@ -771,6 +773,7 @@ class ConfigApp:
771
773
  use_canned_context_prompt=self.use_canned_context_prompt_value.get(),
772
774
  custom_prompt=self.custom_prompt.get("1.0", tk.END).strip(),
773
775
  dialogue_context_length=int(self.ai_dialogue_context_length_value.get()),
776
+ custom_texthooker_prompt=self.custom_texthooker_prompt.get("1.0", tk.END).strip(),
774
777
  ),
775
778
  overlay=Overlay(
776
779
  websocket_port=int(self.overlay_websocket_port_value.get()),
@@ -1765,6 +1768,14 @@ class ConfigApp:
1765
1768
  row=self.current_row, column=1, sticky='W', pady=2)
1766
1769
  self.current_row += 1
1767
1770
 
1771
+ trim_black_bars_i18n = ss_i18n.get('trim_black_bars', {})
1772
+ HoverInfoLabelWidget(screenshot_frame, text=trim_black_bars_i18n.get('label', '...'),
1773
+ tooltip=trim_black_bars_i18n.get('tooltip', '...'),
1774
+ row=self.current_row, column=0)
1775
+ ttk.Checkbutton(screenshot_frame, variable=self.trim_black_bars_value, bootstyle="round-toggle").grid(
1776
+ row=self.current_row, column=1, sticky='W', pady=2)
1777
+ self.current_row += 1
1778
+
1768
1779
  self.add_reset_button(screenshot_frame, "screenshot", self.current_row, 0, self.create_screenshot_tab)
1769
1780
 
1770
1781
  for col in range(3):
@@ -2299,6 +2310,16 @@ class ConfigApp:
2299
2310
  self.custom_prompt.insert(tk.END, self.settings.ai.custom_prompt)
2300
2311
  self.custom_prompt.grid(row=self.current_row, column=1, sticky='EW', pady=2)
2301
2312
  self.current_row += 1
2313
+
2314
+ custom_texthooker_prompt_i18n = ai_i18n.get('custom_texthooker_prompt', {})
2315
+ HoverInfoLabelWidget(ai_frame, text=custom_texthooker_prompt_i18n.get('label', 'Custom Texthooker Prompt:'), tooltip=custom_texthooker_prompt_i18n.get('tooltip', 'Custom Prompt to use for Texthooker Translate Button.'),
2316
+ row=self.current_row, column=0)
2317
+ self.custom_texthooker_prompt = scrolledtext.ScrolledText(ai_frame, width=50, height=5, font=("TkDefaultFont", 9),
2318
+ relief="solid", borderwidth=1,
2319
+ highlightbackground=ttk.Style().colors.border)
2320
+ self.custom_texthooker_prompt.insert(tk.END, self.settings.ai.custom_texthooker_prompt)
2321
+ self.custom_texthooker_prompt.grid(row=self.current_row, column=1, sticky='EW', pady=2)
2322
+ self.current_row += 1
2302
2323
 
2303
2324
  self.add_reset_button(ai_frame, "ai", self.current_row, 0, self.create_ai_tab)
2304
2325
 
@@ -1,4 +1,6 @@
1
+ import math
1
2
  import os
3
+ import re
2
4
  import subprocess
3
5
  import json
4
6
  import tkinter as tk
@@ -6,8 +8,9 @@ from tkinter import messagebox
6
8
  import ttkbootstrap as ttk
7
9
  from PIL import Image, ImageTk
8
10
 
11
+ from GameSentenceMiner.util import ffmpeg
9
12
  from GameSentenceMiner.util.gsm_utils import sanitize_filename
10
- from GameSentenceMiner.util.configuration import get_temporary_directory, logger, ffmpeg_base_command_list, get_ffprobe_path
13
+ from GameSentenceMiner.util.configuration import get_config, get_temporary_directory, logger, ffmpeg_base_command_list, get_ffprobe_path, ffmpeg_base_command_list_info
11
14
 
12
15
 
13
16
  class ScreenshotSelectorDialog(tk.Toplevel):
@@ -65,7 +68,7 @@ class ScreenshotSelectorDialog(tk.Toplevel):
65
68
  # Force always on top to ensure visibility
66
69
 
67
70
  def _extract_frames(self, video_path, timestamp, mode):
68
- """Extracts frames using ffmpeg. Encapsulated from the original script."""
71
+ """Extracts frames using ffmpeg, with automatic black bar removal."""
69
72
  temp_dir = os.path.join(
70
73
  get_temporary_directory(False),
71
74
  "screenshot_frames",
@@ -87,17 +90,36 @@ class ScreenshotSelectorDialog(tk.Toplevel):
87
90
  logger.warning(f"Timestamp {timestamp_number} exceeds video duration {video_duration}.")
88
91
  return [], None
89
92
 
93
+ video_filters = []
94
+
95
+ if get_config().screenshot.trim_black_bars_wip:
96
+ crop_filter = ffmpeg.find_black_bars(video_path, timestamp_number)
97
+ if crop_filter:
98
+ video_filters.append(crop_filter)
99
+
100
+ # Always add the frame extraction filter
101
+ video_filters.append(f"fps=1/{0.25}")
102
+
90
103
  try:
104
+ # Build the final command for frame extraction
91
105
  command = ffmpeg_base_command_list + [
92
- "-y",
106
+ "-y", # Overwrite output files without asking
93
107
  "-ss", str(timestamp_number),
94
- "-i", video_path,
95
- "-vf", f"fps=1/{0.25}",
108
+ "-i", video_path
109
+ ]
110
+
111
+ # Chain all collected filters (crop and fps) together with a comma
112
+ command.extend(["-vf", ",".join(video_filters)])
113
+
114
+ command.extend([
96
115
  "-vframes", "20",
97
116
  os.path.join(temp_dir, "frame_%02d.png")
98
- ]
117
+ ])
118
+
119
+ logger.debug(f"Executing frame extraction command: {' '.join(command)}")
99
120
  subprocess.run(command, check=True, capture_output=True, text=True)
100
121
 
122
+ # The rest of your logic remains the same
101
123
  for i in range(1, 21):
102
124
  frame_path = os.path.join(temp_dir, f"frame_{i:02d}.png")
103
125
  if os.path.exists(frame_path):
@@ -122,7 +144,7 @@ class ScreenshotSelectorDialog(tk.Toplevel):
122
144
  except Exception as e:
123
145
  logger.error(f"An unexpected error occurred during frame extraction: {e}")
124
146
  return [], None
125
-
147
+
126
148
  def _build_image_grid(self, image_paths, golden_frame):
127
149
  """Creates and displays the grid of selectable images."""
128
150
  self.images = [] # Keep a reference to images to prevent garbage collection
@@ -12,7 +12,7 @@ from logging.handlers import RotatingFileHandler
12
12
  from os.path import expanduser
13
13
  from sys import platform
14
14
  import time
15
- from typing import List, Dict
15
+ from typing import Any, List, Dict
16
16
  import sys
17
17
  from enum import Enum
18
18
 
@@ -59,6 +59,28 @@ supported_formats = {
59
59
  'm4a': 'aac',
60
60
  }
61
61
 
62
+ KNOWN_ASPECT_RATIOS = [
63
+ # --- Classic / Legacy ---
64
+ {"name": "4:3 (SD / Retro Games)", "ratio": 4 / 3},
65
+ {"name": "5:4 (Old PC Monitors)", "ratio": 5 / 4},
66
+ {"name": "3:2 (Handheld / GBA / DS / DSLR)", "ratio": 3 / 2},
67
+
68
+ # --- Modern Displays ---
69
+ {"name": "16:10 (PC Widescreen)", "ratio": 16 / 10},
70
+ {"name": "16:9 (Standard HD / 1080p / 4K)", "ratio": 16 / 9},
71
+ {"name": "18:9 (Mobile / Some Modern Laptops)", "ratio": 18 / 9},
72
+ {"name": "19.5:9 (Modern Smartphones)", "ratio": 19.5 / 9},
73
+ {"name": "21:9 (UltraWide)", "ratio": 21 / 9},
74
+ {"name": "24:10 (UltraWide+)", "ratio": 24 / 10},
75
+ {"name": "32:9 (Super UltraWide)", "ratio": 32 / 9},
76
+
77
+ # --- Vertical / Mobile ---
78
+ {"name": "9:16 (Portrait Mode)", "ratio": 9 / 16},
79
+ {"name": "3:4 (Portrait 4:3)", "ratio": 3 / 4},
80
+ {"name": "1:1 (Square / UI Capture)", "ratio": 1 / 1},
81
+ ]
82
+
83
+ KNOWN_ASPECT_RATIOS_DICT = {item["name"]: item["ratio"] for item in KNOWN_ASPECT_RATIOS}
62
84
 
63
85
  def is_linux():
64
86
  return platform == 'linux'
@@ -490,6 +512,7 @@ class Screenshot:
490
512
  use_new_screenshot_logic: bool = False
491
513
  screenshot_timing_setting: str = 'beginning' # 'middle', 'end'
492
514
  use_screenshot_selector: bool = False
515
+ trim_black_bars_wip: bool = True
493
516
 
494
517
  def __post_init__(self):
495
518
  if not self.screenshot_timing_setting and self.use_beginning_of_line_as_screenshot:
@@ -632,6 +655,7 @@ class Ai:
632
655
  use_canned_translation_prompt: bool = True
633
656
  use_canned_context_prompt: bool = False
634
657
  custom_prompt: str = ''
658
+ custom_texthooker_prompt: str = ''
635
659
  dialogue_context_length: int = 10
636
660
 
637
661
  def __post_init__(self):
@@ -1321,10 +1345,11 @@ class AnkiUpdateResult:
1321
1345
  video_in_anki: str = ''
1322
1346
  word_path: str = ''
1323
1347
  word: str = ''
1348
+ extra_tags: List[str] = field(default_factory=list)
1324
1349
 
1325
1350
  @staticmethod
1326
1351
  def failure():
1327
- return AnkiUpdateResult(success=False, audio_in_anki='', screenshot_in_anki='', prev_screenshot_in_anki='', sentence_in_anki='', multi_line=False, video_in_anki='', word_path='', word='')
1352
+ return AnkiUpdateResult(success=False, audio_in_anki='', screenshot_in_anki='', prev_screenshot_in_anki='', sentence_in_anki='', multi_line=False, video_in_anki='', word_path='', word='', extra_tags=[])
1328
1353
 
1329
1354
 
1330
1355
  @dataclass_json
@@ -1376,6 +1401,8 @@ def get_ffprobe_path():
1376
1401
 
1377
1402
  ffmpeg_base_command_list = [get_ffmpeg_path(), "-hide_banner", "-loglevel", "error", '-nostdin']
1378
1403
 
1404
+ ffmpeg_base_command_list_info = [get_ffmpeg_path(), "-hide_banner", "-loglevel", "info", '-nostdin']
1405
+
1379
1406
 
1380
1407
  # logger.debug(f"Running in development mode: {is_dev}")
1381
1408
  # logger.debug(f"Running on Beangate's PC: {is_beangate}")
@@ -1,5 +1,6 @@
1
1
  import json
2
2
  import os
3
+ import re
3
4
  import subprocess
4
5
  import sys
5
6
  import tempfile
@@ -12,8 +13,8 @@ import shutil
12
13
 
13
14
  from GameSentenceMiner import obs
14
15
  from GameSentenceMiner.ui.config_gui import ConfigApp
15
- from GameSentenceMiner.util.configuration import ffmpeg_base_command_list, get_ffprobe_path, logger, get_config, \
16
- get_temporary_directory, gsm_state, is_linux
16
+ from GameSentenceMiner.util.configuration import ffmpeg_base_command_list, get_ffprobe_path, get_master_config, logger, get_config, \
17
+ get_temporary_directory, gsm_state, is_linux, ffmpeg_base_command_list_info, KNOWN_ASPECT_RATIOS
17
18
  from GameSentenceMiner.util.gsm_utils import make_unique_file_name, get_file_modification_time
18
19
  from GameSentenceMiner.util import configuration
19
20
  from GameSentenceMiner.util.text_log import initial_time
@@ -223,53 +224,307 @@ def get_screenshot(video_file, screenshot_timing, try_selector=False):
223
224
  return output
224
225
  else:
225
226
  logger.error("Frame extractor script failed to run or returned no output, defaulting")
227
+
226
228
  output_image = make_unique_file_name(os.path.join(
227
229
  get_temporary_directory(), f"{obs.get_current_game(sanitize=True)}.{get_config().screenshot.extension}"))
228
- # FFmpeg command to extract the last frame of the video
230
+
231
+ # Base command for extracting the frame
229
232
  ffmpeg_command = ffmpeg_base_command_list + [
230
233
  "-ss", f"{screenshot_timing}",
231
234
  "-i", f"{video_file}",
232
235
  "-vframes", "1" # Extract only one frame
233
236
  ]
237
+
238
+ video_filters = []
239
+
240
+ if get_config().screenshot.trim_black_bars_wip:
241
+ crop_filter = find_black_bars(video_file, screenshot_timing)
242
+ if crop_filter:
243
+ video_filters.append(crop_filter)
244
+
245
+ if get_config().screenshot.width or get_config().screenshot.height:
246
+ # Add scaling to the filter chain
247
+ scale_filter = f"scale={get_config().screenshot.width or -1}:{get_config().screenshot.height or -1}"
248
+ video_filters.append(scale_filter)
249
+
250
+ # If we have any filters (crop, scale, etc.), chain them together with commas
251
+ if video_filters:
252
+ ffmpeg_command.extend(["-vf", ",".join(video_filters)])
234
253
 
235
254
  if get_config().screenshot.custom_ffmpeg_settings:
236
255
  ffmpeg_command.extend(get_config().screenshot.custom_ffmpeg_settings.replace("\"", "").split(" "))
237
256
  else:
238
- ffmpeg_command.extend(["-compression_level", "6", "-q:v", get_config().screenshot.quality])
239
-
240
- if get_config().screenshot.width or get_config().screenshot.height:
241
- ffmpeg_command.extend(
242
- ["-vf", f"scale={get_config().screenshot.width or -1}:{get_config().screenshot.height or -1}"])
257
+ # Ensure quality settings are strings
258
+ ffmpeg_command.extend(["-compression_level", "6", "-q:v", str(get_config().screenshot.quality)])
243
259
 
244
260
  ffmpeg_command.append(f"{output_image}")
245
261
 
246
- logger.debug(f"FFMPEG SS Command: {ffmpeg_command}")
262
+ logger.debug(f"FFMPEG SS Command: {' '.join(map(str, ffmpeg_command))}")
247
263
 
248
264
  try:
265
+ # Changed the retry loop to be more robust
249
266
  for i in range(3):
250
- logger.debug(" ".join(ffmpeg_command))
251
- result = subprocess.run(ffmpeg_command)
252
- if result.returncode != 0 and i < 2:
253
- raise RuntimeError(f"FFmpeg command failed with return code {result.returncode}")
254
- else:
255
- break
267
+ logger.debug("Executing FFmpeg command...")
268
+ result = subprocess.run(ffmpeg_command, capture_output=True, text=True)
269
+ if result.returncode == 0:
270
+ break # Success!
271
+ logger.warning(f"FFmpeg attempt {i+1} failed. Stderr: {result.stderr}")
272
+ if i == 2: # Last attempt failed
273
+ raise RuntimeError(f"FFmpeg command failed after 3 attempts. Stderr: {result.stderr}")
256
274
  except Exception as e:
257
275
  logger.error(f"Error running FFmpeg command: {e}. Defaulting to standard PNG.")
258
276
  output_image = make_unique_file_name(os.path.join(
259
277
  get_temporary_directory(),
260
278
  f"{obs.get_current_game(sanitize=True)}.png"))
261
- ffmpeg_command = ffmpeg_base_command_list + [
279
+ # Fallback command without any complex filters
280
+ fallback_command = ffmpeg_base_command_list + [
262
281
  "-ss", f"{screenshot_timing}",
263
282
  "-i", video_file,
264
283
  "-vframes", "1",
265
284
  output_image
266
285
  ]
267
- subprocess.run(ffmpeg_command)
286
+ subprocess.run(fallback_command)
268
287
 
269
288
  logger.debug(f"Screenshot saved to: {output_image}")
270
289
 
271
290
  return output_image
272
291
 
292
+ def get_video_dimensions(video_file):
293
+ """Get the width and height of a video file."""
294
+ try:
295
+ ffprobe_command = [
296
+ get_ffprobe_path(),
297
+ "-v", "error",
298
+ "-select_streams", "v:0",
299
+ "-show_entries", "stream=width,height",
300
+ "-of", "json",
301
+ video_file
302
+ ]
303
+
304
+ result = subprocess.run(
305
+ ffprobe_command,
306
+ capture_output=True,
307
+ text=True,
308
+ check=True
309
+ )
310
+
311
+ output = json.loads(result.stdout)
312
+ width = output['streams'][0]['width']
313
+ height = output['streams'][0]['height']
314
+ return width, height
315
+ except Exception as e:
316
+ logger.error(f"Error getting video dimensions: {e}")
317
+ return None, None
318
+
319
+ # How close the detected ratio needs to be to a known ratio to snap (e.g., 0.05 = 5%)
320
+ RATIO_TOLERANCE = 0.05
321
+
322
+ def _calculate_target_crop(orig_width, orig_height, target_ratio):
323
+ """
324
+ Calculates the new dimensions and offsets for a target aspect ratio.
325
+
326
+ Returns: A tuple (new_width, new_height, x_offset, y_offset)
327
+ """
328
+ orig_ratio = orig_width / orig_height
329
+
330
+ if abs(orig_ratio - target_ratio) < 0.01: # Already at the target ratio
331
+ return orig_width, orig_height, 0, 0
332
+
333
+ if orig_ratio > target_ratio:
334
+ # Original is wider than target (pillarbox scenario)
335
+ # Keep original height, calculate new width
336
+ new_width = round(orig_height * target_ratio)
337
+ new_height = orig_height
338
+ x_offset = round((orig_width - new_width) / 2)
339
+ y_offset = 0
340
+ else:
341
+ # Original is narrower than target (letterbox scenario)
342
+ # Keep original width, calculate new height
343
+ new_width = orig_width
344
+ new_height = round(orig_width / target_ratio)
345
+ x_offset = 0
346
+ y_offset = round((orig_height - new_height) / 2)
347
+
348
+ # Ensure dimensions are even for compatibility
349
+ new_width = new_width if new_width % 2 == 0 else new_width - 1
350
+ new_height = new_height if new_height % 2 == 0 else new_height - 1
351
+ x_offset = x_offset if x_offset % 2 == 0 else x_offset - 1
352
+ y_offset = y_offset if y_offset % 2 == 0 else y_offset - 1
353
+
354
+ return new_width, new_height, x_offset, y_offset
355
+
356
+
357
+ def find_black_bars_with_ratio_snapping(video_file, screenshot_timing):
358
+ logger.info("Attempting to detect black bars with aspect ratio snapping...")
359
+ crop_filter = None
360
+ try:
361
+ orig_width, orig_height = get_video_dimensions(video_file)
362
+ if not orig_width or not orig_height:
363
+ logger.warning("Could not determine video dimensions. Skipping black bar detection.")
364
+ return None
365
+
366
+ orig_aspect = orig_width / orig_height
367
+ logger.debug(f"Original video dimensions: {orig_width}x{orig_height} (Ratio: {orig_aspect:.3f})")
368
+
369
+ cropdetect_command = ffmpeg_base_command_list_info + [
370
+ "-i", video_file,
371
+ "-ss", f"{screenshot_timing}",
372
+ "-t", "5", # Analyze for 5 seconds
373
+ "-vf", "cropdetect=limit=16", # limit=16 for near-black detection, 24 is too aggressive
374
+ "-f", "null", "-"
375
+ ]
376
+
377
+ result = subprocess.run(
378
+ cropdetect_command,
379
+ capture_output=True,
380
+ text=True,
381
+ check=False
382
+ )
383
+
384
+ crop_lines = re.findall(r"crop=\d+:\d+:\d+:\d+", result.stderr)
385
+ if not crop_lines:
386
+ logger.info("cropdetect did not find any black bars to remove.")
387
+ return None
388
+
389
+ last_crop_params = crop_lines[-1]
390
+ match = re.match(r"crop=(\d+):(\d+):(\d+):(\d+)", last_crop_params)
391
+ if not match:
392
+ logger.warning(f"Could not parse cropdetect output: {last_crop_params}")
393
+ return None
394
+
395
+ detected_width = int(match.group(1))
396
+ detected_height = int(match.group(2))
397
+
398
+ if detected_width == orig_width and detected_height == orig_height:
399
+ logger.info("cropdetect suggests no cropping is needed.")
400
+ return None
401
+
402
+ detected_aspect = detected_width / detected_height
403
+ logger.debug(f"cropdetect suggests crop to: {detected_width}x{detected_height} (Ratio: {detected_aspect:.3f})")
404
+
405
+ best_match = None
406
+ min_diff = float('inf')
407
+
408
+ for known in KNOWN_ASPECT_RATIOS:
409
+ diff = abs(detected_aspect - known["ratio"]) / known["ratio"]
410
+ if diff < min_diff:
411
+ min_diff = diff
412
+ best_match = known
413
+
414
+ get_master_config().scenes_info
415
+
416
+ if best_match and min_diff <= RATIO_TOLERANCE:
417
+ target_name = best_match["name"]
418
+ target_ratio = best_match["ratio"]
419
+ logger.info(
420
+ f"Detected ratio ({detected_aspect:.3f}) is close to {target_name} ({target_ratio:.3f}). "
421
+ f"Snapping to the standard ratio."
422
+ )
423
+
424
+ crop_width, crop_height, crop_x, crop_y = _calculate_target_crop(
425
+ orig_width, orig_height, target_ratio
426
+ )
427
+
428
+ area_ratio = (crop_width * crop_height) / (orig_width * orig_height)
429
+ if area_ratio < 0.50:
430
+ logger.warning(
431
+ f"Calculated crop would remove too much video ({1 - area_ratio:.1%}). "
432
+ "Skipping crop to avoid false detection."
433
+ )
434
+ return None
435
+
436
+ crop_filter = f"crop={crop_width}:{crop_height}:{crop_x}:{crop_y}"
437
+ logger.info(f"Applying snapped aspect ratio filter: {crop_filter}")
438
+
439
+ else:
440
+ logger.info(
441
+ f"Detected crop ratio ({detected_aspect:.3f}) is not close enough to any known standard. "
442
+ "Skipping crop to avoid non-standard results."
443
+ )
444
+ return None
445
+
446
+ except Exception as e:
447
+ logger.error(f"Error during black bar detection: {e}. Proceeding without cropping.")
448
+
449
+ return crop_filter
450
+
451
+ def find_black_bars(video_file, screenshot_timing):
452
+ logger.info("Attempting to detect black bars...")
453
+ crop_filter = None
454
+ try:
455
+ # Get original video dimensions
456
+ orig_width, orig_height = get_video_dimensions(video_file)
457
+ if not orig_width or not orig_height:
458
+ logger.warning("Could not determine video dimensions. Skipping black bar detection.")
459
+ return None
460
+
461
+ logger.debug(f"Original video dimensions: {orig_width}x{orig_height}")
462
+
463
+ cropdetect_command = ffmpeg_base_command_list_info + [
464
+ "-i", video_file,
465
+ "-ss", f"{screenshot_timing}", # Start near the screenshot time
466
+ "-t", "1", # Analyze for 1 second
467
+ "-vf", "cropdetect=limit=16", # limit=0 means true black only, round=2 for even dimensions
468
+ "-f", "null", "-" # Discard video output
469
+ ]
470
+
471
+ result = subprocess.run(
472
+ cropdetect_command,
473
+ capture_output=True,
474
+ text=True,
475
+ check=False
476
+ )
477
+
478
+ crop_lines = re.findall(r"crop=\d+:\d+:\d+:\d+", result.stderr)
479
+ if crop_lines:
480
+ crop_params = crop_lines[-1]
481
+ print(crop_params)
482
+ # Parse crop parameters: crop=width:height:x:y
483
+ match = re.match(r"crop=(\d+):(\d+):(\d+):(\d+)", crop_params)
484
+ if match:
485
+ crop_width = int(match.group(1))
486
+ crop_height = int(match.group(2))
487
+
488
+ # Calculate what percentage of the original video would remain
489
+ area_ratio = (crop_width * crop_height) / (orig_width * orig_height)
490
+
491
+ # Calculate aspect ratios
492
+ orig_aspect = orig_width / orig_height
493
+ crop_aspect = crop_width / crop_height
494
+ aspect_diff = abs(orig_aspect - crop_aspect) / orig_aspect
495
+
496
+ logger.debug(f"Crop would be {crop_width}x{crop_height} ({area_ratio:.1%} of original area)")
497
+ logger.debug(f"Original aspect ratio: {orig_aspect:.3f}, Crop aspect ratio: {crop_aspect:.3f}, Difference: {aspect_diff:.1%}")
498
+
499
+ # Safeguards:
500
+ # 1. Crop must retain at least 25% of the original video area
501
+ # 2. Aspect ratio must not change by more than 30%
502
+ if area_ratio < 0.25:
503
+ logger.warning(f"Crop would remove too much of the video ({area_ratio:.1%} remaining). Skipping crop to avoid false detection.")
504
+ return None
505
+
506
+ if aspect_diff > 0.30:
507
+ for ratio in KNOWN_ASPECT_RATIOS:
508
+ known_ratio = ratio["ratio"]
509
+ known_diff = abs(crop_aspect - known_ratio) / known_ratio
510
+ if known_diff < RATIO_TOLERANCE:
511
+ logger.info(f"Crop aspect ratio ({crop_aspect:.3f}) is close to known ratio {ratio['name']} ({known_ratio:.3f}). Accepting crop.")
512
+ break
513
+ else:
514
+ logger.warning(f"Crop would significantly change aspect ratio ({aspect_diff:.1%} difference). Skipping crop to avoid false detection.")
515
+ return None
516
+
517
+ crop_filter = crop_params
518
+ logger.info(f"Detected valid black bars. Applying filter: {crop_filter}")
519
+ else:
520
+ logger.warning("Could not parse crop parameters.")
521
+ else:
522
+ logger.debug("cropdetect did not find any black bars to remove.")
523
+
524
+ except Exception as e:
525
+ logger.error(f"Error during black bar detection: {e}. Proceeding without cropping.")
526
+ return crop_filter
527
+
273
528
  def get_screenshot_for_line(video_file, game_line, try_selector=False):
274
529
  return get_screenshot(video_file, get_screenshot_time(video_file, game_line), try_selector)
275
530
 
@@ -77,10 +77,12 @@ class WebsocketServerThread(threading.Thread):
77
77
  self._event.set()
78
78
  while True:
79
79
  try:
80
- self.server = start_server = websockets.serve(self.server_handler,
81
- get_config().advanced.localhost_bind_address,
82
- self.get_ws_port_func(),
83
- max_size=1000000000)
80
+ self.server = start_server = websockets.serve(
81
+ self.server_handler,
82
+ get_config().advanced.localhost_bind_address,
83
+ self.get_ws_port_func(),
84
+ max_size=1000000000,
85
+ )
84
86
  async with start_server:
85
87
  await stop_event.wait()
86
88
  return
@@ -235,19 +235,23 @@ def translate_line():
235
235
  if event_id is None:
236
236
  return jsonify({'error': 'Missing id'}), 400
237
237
 
238
- prompt = f"""
239
- **Professional Game Localization Task**
238
+
239
+ if get_config().ai.custom_texthooker_prompt:
240
+ prompt = get_config().ai.custom_texthooker_prompt.strip()
241
+ else:
242
+ prompt = f"""
243
+ **Professional Game Localization Task**
240
244
 
241
- **Task Directive:**
242
- Translate ONLY the provided line of game dialogue specified below into natural-sounding, context-aware {get_config().general.get_native_language_name()}. The translation must preserve the original tone and intent of the source.
245
+ **Task Directive:**
246
+ Translate ONLY the provided line of game dialogue specified below into natural-sounding, context-aware {get_config().general.get_native_language_name()}. The translation must preserve the original tone and intent of the source.
243
247
 
244
- **Output Requirements:**
245
- - Provide only the single, best {get_config().general.get_native_language_name()} translation.
246
- - Use expletives if they are natural for the context and enhance the translation's impact, but do not over-exaggerate.
247
- - Do not include notes, alternatives, explanations, or any other surrounding text. Absolutely nothing but the translated line.
248
+ **Output Requirements:**
249
+ - Provide only the single, best {get_config().general.get_native_language_name()} translation.
250
+ - Use expletives if they are natural for the context and enhance the translation's impact, but do not over-exaggerate.
251
+ - Do not include notes, alternatives, explanations, or any other surrounding text. Absolutely nothing but the translated line.
248
252
 
249
- **Line to Translate:**
250
- """
253
+ **Line to Translate:**
254
+ """
251
255
 
252
256
  if not get_config().ai.is_configured():
253
257
  return jsonify({'error': 'AI translation is not properly configured. Please check your settings in the "AI" Tab.'}), 400
@@ -277,19 +281,20 @@ def translate_multiple():
277
281
 
278
282
  language = get_config().general.get_native_language_name() if get_config().general.native_language else "English"
279
283
 
284
+
280
285
  translate_multiple_lines_prompt = f"""
281
- **Professional Game Localization Task**
282
- Translate the following lines of game dialogue into natural-sounding, context-aware {language}:
283
-
284
- **Output Requirements**
285
- - Maintain the original tone and style of the dialogue.
286
- - Ensure that the translation is contextually appropriate for the game.
287
- - Pay attention to character names and any specific terminology used in the game.
288
- - Maintain Formatting and newline structure of the given lines. It should be very human readable as a dialogue.
289
- - Do not include any notes, alternatives, explanations, or any other surrounding text. Absolutely nothing but the translated lines.
290
-
291
- **Lines to Translate:**
292
- """
286
+ **Professional Game Localization Task**
287
+ Translate the following lines of game dialogue into natural-sounding, context-aware {language}:
288
+
289
+ **Output Requirements**
290
+ - Maintain the original tone and style of the dialogue.
291
+ - Ensure that the translation is contextually appropriate for the game.
292
+ - Pay attention to character names and any specific terminology used in the game.
293
+ - Maintain Formatting and newline structure of the given lines. It should be very human readable as a dialogue.
294
+ - Do not include any notes, alternatives, explanations, or any other surrounding text. Absolutely nothing but the translated lines.
295
+
296
+ **Lines to Translate:**
297
+ """
293
298
 
294
299
  translation = get_ai_prompt_result(get_all_lines(), text,
295
300
  lines[0], get_current_game(), custom_prompt=translate_multiple_lines_prompt)
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: GameSentenceMiner
3
- Version: 2.19.6
3
+ Version: 2.19.8
4
4
  Summary: A tool for mining sentences from games. Update: Dependencies, replay buffer based line searching, and bug fixes.
5
5
  Author-email: Beangate <bpwhelan95@gmail.com>
6
6
  License: MIT License
@@ -1,8 +1,8 @@
1
1
  GameSentenceMiner/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
2
- GameSentenceMiner/anki.py,sha256=jySFPzDYz0vItb12kwZ-rm9WmtxO8Kr41wK1JdwRnU4,29638
2
+ GameSentenceMiner/anki.py,sha256=5hp5WsNp7spazjQF9rZrNzOoQ2ZPFIl2sTdU5oDSWRE,29924
3
3
  GameSentenceMiner/gametext.py,sha256=4PPm7QSWDmvsyooVjFANkd1Vnoy5ixbGRMHfYfhwGs0,13320
4
4
  GameSentenceMiner/gsm.py,sha256=dg41VMDLzR3U8-Xm1oHGfU2JKL8cyH-WacqY6tLrWyM,36164
5
- GameSentenceMiner/obs.py,sha256=QLeJujJk48L0mphmPHa0hE46P09qQx34z41lla1ud-o,37977
5
+ GameSentenceMiner/obs.py,sha256=TuTTHJ09S4uEwh0dJx4B5LnQlL-sTIKj8Xap5YsZ3KU,43160
6
6
  GameSentenceMiner/vad.py,sha256=iMSsoUZ7-aNoWKzDKfOHdB3Zk5U2hV7x5hqTny6rj08,21501
7
7
  GameSentenceMiner/ai/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
8
8
  GameSentenceMiner/ai/ai_prompting.py,sha256=mq9Odv_FpohXagU-OoSZbLWttdrEl1M1NiqnodeUpD8,29126
@@ -14,21 +14,21 @@ GameSentenceMiner/assets/icon32.png,sha256=Kww0hU_qke9_22wBuO_Nq0Dv2SfnOLwMhCyGg
14
14
  GameSentenceMiner/assets/icon512.png,sha256=HxUj2GHjyQsk8NV433256UxU9phPhtjCY-YB_7W4sqs,192487
15
15
  GameSentenceMiner/assets/icon64.png,sha256=N8xgdZXvhqVQP9QUK3wX5iqxX9LxHljD7c-Bmgim6tM,9301
16
16
  GameSentenceMiner/assets/pickaxe.png,sha256=VfIGyXyIZdzEnVcc4PmG3wszPMO1W4KCT7Q_nFK6eSE,1403829
17
- GameSentenceMiner/locales/en_us.json,sha256=0NZRbq0zr-kCpKDAa2HJUhgOj2pmEIHgwGq-jgTcZ3s,28747
18
- GameSentenceMiner/locales/ja_jp.json,sha256=huwh6rRAsJmQ5pUXr7gIG5EnpCsF-d_JvOhFg8KxiB8,30786
19
- GameSentenceMiner/locales/zh_cn.json,sha256=soD29x8PefZ5M4t70nB61WeSkKdinP_ZbCLVb-toajw,26625
17
+ GameSentenceMiner/locales/en_us.json,sha256=V79_n1GGqDXOwTnNXPtsfD0Zvs4apMJTlZGUAGjnlBs,28989
18
+ GameSentenceMiner/locales/ja_jp.json,sha256=N_T6VtU8la2PAefUeZN3_sM88qxBIR4SPXA7ngRV4mk,31118
19
+ GameSentenceMiner/locales/zh_cn.json,sha256=Dx-CbeUa2hGfRl1st1O0j-jOCnITQ87tjQYZxdSUPOE,26853
20
20
  GameSentenceMiner/ocr/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
21
21
  GameSentenceMiner/ocr/gsm_ocr_config.py,sha256=Ov04c-nKzh3sADxO-5JyZWVe4DlrHM9edM9tc7-97Jo,5970
22
22
  GameSentenceMiner/ocr/ocrconfig.py,sha256=_tY8mjnzHMJrLS8E5pHqYXZjMuLoGKYgJwdhYgN-ny4,6466
23
- GameSentenceMiner/ocr/owocr_area_selector.py,sha256=4MjItlaZ78Smxa3uxMxbjU0n2z_IBTG-iBpDB9COSL8,29270
24
- GameSentenceMiner/ocr/owocr_helper.py,sha256=MZFKA252lQE1M39tUTtccX3vLaPRJrWfBzWvfxNq3B8,35310
23
+ GameSentenceMiner/ocr/owocr_area_selector.py,sha256=1-PlCOpam8-D4tGQVuhxeVVQc9sy5Mfyvcyqgj2Vqyw,29587
24
+ GameSentenceMiner/ocr/owocr_helper.py,sha256=JljPwcMLqu6KJz3Q9unKLXAVNzSwZkhvJohJ0NccW-o,36879
25
25
  GameSentenceMiner/ocr/ss_picker.py,sha256=0IhxUdaKruFpZyBL-8SpxWg7bPrlGpy3lhTcMMZ5rwo,5224
26
26
  GameSentenceMiner/owocr/owocr/__init__.py,sha256=87hfN5u_PbL_onLfMACbc0F5j4KyIK9lKnRCj6oZgR0,49
27
27
  GameSentenceMiner/owocr/owocr/__main__.py,sha256=XQaqZY99EKoCpU-gWQjNbTs7Kg17HvBVE7JY8LqIE0o,157
28
28
  GameSentenceMiner/owocr/owocr/config.py,sha256=qM7kISHdUhuygGXOxmgU6Ef2nwBShrZtdqu4InDCViE,8103
29
29
  GameSentenceMiner/owocr/owocr/lens_betterproto.py,sha256=oNoISsPilVVRBBPVDtb4-roJtAhp8ZAuFTci3TGXtMc,39141
30
30
  GameSentenceMiner/owocr/owocr/ocr.py,sha256=yVrLr8nNgvLRB-pPvkyhw07zkAiWrCf85SvgfQBquEk,95309
31
- GameSentenceMiner/owocr/owocr/run.py,sha256=y90fHSbbjH4BeMlxH_xjKU3uJzfJgdKo6nUqwNcdUJs,82455
31
+ GameSentenceMiner/owocr/owocr/run.py,sha256=4fyA6r4LLOJWIehwNHPfnZOb_2ebDGtS9i5bhZ3DsNo,82779
32
32
  GameSentenceMiner/owocr/owocr/screen_coordinate_picker.py,sha256=Na6XStbQBtpQUSdbN3QhEswtKuU1JjReFk_K8t5ezQE,3395
33
33
  GameSentenceMiner/tools/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
34
34
  GameSentenceMiner/tools/audio_offset_selector.py,sha256=8Stk3BP-XVIuzRv9nl9Eqd2D-1yD3JrgU-CamBywJmY,8542
@@ -37,15 +37,15 @@ GameSentenceMiner/tools/ss_selector.py,sha256=ob2oJdiYreDMMau7CvsglpnhZ1CDnJqop3
37
37
  GameSentenceMiner/tools/window_transparency.py,sha256=GtbxbmZg0-UYPXhfHff-7IKZyY2DKe4B9GdyovfmpeM,8166
38
38
  GameSentenceMiner/ui/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
39
39
  GameSentenceMiner/ui/anki_confirmation.py,sha256=krrT3q3anTtXNTPHz5ahXSd4genEnEvS07v1JYftBFg,15174
40
- GameSentenceMiner/ui/config_gui.py,sha256=JHXlD6CE7o4YH1M85JSvGqc2-pNfuAyLgkztUn6ho1w,158268
40
+ GameSentenceMiner/ui/config_gui.py,sha256=EpxWuwrTBKOrsg4kf9w_iT5oybhtanlFG6XtH98W9Ng,159987
41
41
  GameSentenceMiner/ui/furigana_filter_preview.py,sha256=DAT2-j6vSDHr9ufk6PiaLikEsbIp56B_OHIEeYLMwlk,17135
42
- GameSentenceMiner/ui/screenshot_selector.py,sha256=7QvDhOMpA0ej8x_lYtu6fhmrWbM1GCg-dps3XVWwk1Q,8234
42
+ GameSentenceMiner/ui/screenshot_selector.py,sha256=MnR1MZWRUeHXCFTHc5ACK3WS08f9MUK5fJ6IQEGdCEY,9127
43
43
  GameSentenceMiner/util/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
44
44
  GameSentenceMiner/util/audio_player.py,sha256=-yFsf0qoTSS1ga5rCmEJZJGUSJzXCvfZHY3t0NxycDk,7896
45
- GameSentenceMiner/util/configuration.py,sha256=0E_LGBZL6K_P2oIHo-nI5OA6SPDk9SXdTf5ycF1-VeQ,47579
45
+ GameSentenceMiner/util/configuration.py,sha256=X6AFueDXi-HabDCGf8u44Z1rPO8XR6Wal3SvGz7hcbI,48806
46
46
  GameSentenceMiner/util/db.py,sha256=iCHUzlgOJgNjQ5-oDa7gnDWmzdlEryOzbXfn9ToQPfY,33034
47
47
  GameSentenceMiner/util/electron_config.py,sha256=KfeJToeFFVw0IR5MKa-gBzpzaGrU-lyJbR9z-sDEHYU,8767
48
- GameSentenceMiner/util/ffmpeg.py,sha256=cAzztfY36Xf2WvsJDjavoiMOvA9ac2GVdCrSB4LzHk4,29007
48
+ GameSentenceMiner/util/ffmpeg.py,sha256=iJAo2GUF0B4SDhG9rp-xaA94VSAc00aY1SyRRZ-i_1w,39730
49
49
  GameSentenceMiner/util/games_table.py,sha256=VM68MAsdyE6tpdwM4bDSk67qioBOvsEO8-TpnRmUnSo,12003
50
50
  GameSentenceMiner/util/get_overlay_coords.py,sha256=jQ0hcrEh9CfvjlBRJez3Ly-er4MjBWC2zirA-hYz5hQ,26462
51
51
  GameSentenceMiner/util/gsm_utils.py,sha256=mASECTmN10c2yPL4NEfLg0Y0YWwFso1i6r_hhJPR3MY,10974
@@ -65,10 +65,10 @@ GameSentenceMiner/web/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3h
65
65
  GameSentenceMiner/web/anki_api_endpoints.py,sha256=r30OTT3YVfgbF6aJ-EGWZLF-j2D9L63jLkRXMycU0p8,23681
66
66
  GameSentenceMiner/web/database_api.py,sha256=wJGFwrPbB7qQMIqwg0w6hn_henFhjAUCIpwjhdUNMGU,89903
67
67
  GameSentenceMiner/web/events.py,sha256=RJ8tIK8WUn7Fbgny23UJWrZ1SlhYzzT5p55E1uXRwDs,2747
68
- GameSentenceMiner/web/gsm_websocket.py,sha256=B0VKpxmsRu0WRh5nFWlpDPBQ6-K2ed7TEIa0O6YWeoo,4166
68
+ GameSentenceMiner/web/gsm_websocket.py,sha256=jX1JS89FHIwGK7sJIYwu9-drE1_0YIsqGNLo9o3GybQ,4087
69
69
  GameSentenceMiner/web/service.py,sha256=6cgUmDgtp3ZKzuPFszowjPoq-BDtC1bS3ux6sykeaqo,6662
70
70
  GameSentenceMiner/web/stats.py,sha256=LYMhekifcQo-cbfy2--b6vycKcu8RAoTnQA4TefcS6U,29037
71
- GameSentenceMiner/web/texthooking_page.py,sha256=jnEBnxDj37BEbi1AGsiEk3GNOqBLsd9znIKC1OuO8jM,15068
71
+ GameSentenceMiner/web/texthooking_page.py,sha256=IJNvprv3crWzYE9pWQMI30XPTkdwOdhanBgnIqAWdYQ,15283
72
72
  GameSentenceMiner/web/static/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
73
73
  GameSentenceMiner/web/static/apple-touch-icon.png,sha256=OcMI8af_68DA_tweOsQ5LytTyMwm7-hPW07IfrOVgEs,46132
74
74
  GameSentenceMiner/web/static/favicon-96x96.png,sha256=lOePzjiKl1JY2J1kT_PMdyEnrlJmi5GWbmXJunM12B4,16502
@@ -135,9 +135,9 @@ GameSentenceMiner/web/templates/components/kanji_grid/thousand_character_classic
135
135
  GameSentenceMiner/web/templates/components/kanji_grid/wanikani_levels.json,sha256=8wjnnaYQqmho6t5tMxrIAc03512A2tYhQh5dfsQnfAM,11372
136
136
  GameSentenceMiner/web/templates/components/kanji_grid/words_hk_frequency_list.json,sha256=wRkqZNPzz6DT9OTPHpXwfqW96Qb96stCQNNgOL-ZdKk,17535
137
137
  GameSentenceMiner/wip/__init___.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
138
- gamesentenceminer-2.19.6.dist-info/licenses/LICENSE,sha256=OXLcl0T2SZ8Pmy2_dmlvKuetivmyPd5m1q-Gyd-zaYY,35149
139
- gamesentenceminer-2.19.6.dist-info/METADATA,sha256=UsYLvq5EK3i2B84SK2JvJ9tyKLXFa0KsZY7Jnl2JAsg,8180
140
- gamesentenceminer-2.19.6.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
141
- gamesentenceminer-2.19.6.dist-info/entry_points.txt,sha256=2APEP25DbfjSxGeHtwBstMH8mulVhLkqF_b9bqzU6vQ,65
142
- gamesentenceminer-2.19.6.dist-info/top_level.txt,sha256=V1hUY6xVSyUEohb0uDoN4UIE6rUZ_JYx8yMyPGX4PgQ,18
143
- gamesentenceminer-2.19.6.dist-info/RECORD,,
138
+ gamesentenceminer-2.19.8.dist-info/licenses/LICENSE,sha256=OXLcl0T2SZ8Pmy2_dmlvKuetivmyPd5m1q-Gyd-zaYY,35149
139
+ gamesentenceminer-2.19.8.dist-info/METADATA,sha256=4M3TSSE1ZAO0GVyam1krpDM7tZkvxMyE5KsMzNUGBYM,8180
140
+ gamesentenceminer-2.19.8.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
141
+ gamesentenceminer-2.19.8.dist-info/entry_points.txt,sha256=2APEP25DbfjSxGeHtwBstMH8mulVhLkqF_b9bqzU6vQ,65
142
+ gamesentenceminer-2.19.8.dist-info/top_level.txt,sha256=V1hUY6xVSyUEohb0uDoN4UIE6rUZ_JYx8yMyPGX4PgQ,18
143
+ gamesentenceminer-2.19.8.dist-info/RECORD,,