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
@@ -7,11 +7,14 @@ import sys
7
7
  import time
8
8
  import tkinter as tk
9
9
  from tkinter import filedialog, messagebox, simpledialog, scrolledtext, font
10
+ from PIL import Image, ImageTk
10
11
 
11
12
  import pyperclip
12
13
  import ttkbootstrap as ttk
13
14
 
14
15
  from GameSentenceMiner import obs
16
+ from GameSentenceMiner.ui.anki_confirmation import AnkiConfirmationDialog
17
+ from GameSentenceMiner.ui.screenshot_selector import ScreenshotSelectorDialog
15
18
  from GameSentenceMiner.util import configuration
16
19
  from GameSentenceMiner.util.communication.send import send_restart_signal
17
20
  from GameSentenceMiner.util.configuration import Config, Locale, logger, CommonLanguages, ProfileConfig, General, Paths, \
@@ -40,7 +43,7 @@ def load_localization(locale=Locale.English):
40
43
  try:
41
44
  # Use a path relative to this script file
42
45
  script_dir = os.path.dirname(os.path.abspath(__file__))
43
- lang_file = os.path.join(script_dir, 'locales', f'{locale.value}.json')
46
+ lang_file = os.path.join(script_dir, '..', 'locales', f'{locale.value}.json')
44
47
  with open(lang_file, 'r', encoding='utf-8') as f:
45
48
  return json.load(f)['python']['config']
46
49
  except (FileNotFoundError, json.JSONDecodeError) as e:
@@ -167,7 +170,6 @@ class ResetToDefaultButton(ttk.Button):
167
170
  self.tooltip.destroy()
168
171
  self.tooltip = None
169
172
 
170
-
171
173
  class ConfigApp:
172
174
  def __init__(self, root):
173
175
  self.window = root
@@ -267,6 +269,56 @@ class ConfigApp:
267
269
  def set_test_func(self, func):
268
270
  self.test_func = func
269
271
 
272
+ def show_anki_confirmation_dialog(self, expression, sentence, screenshot_path, audio_path, translation, ss_timestamp):
273
+ """
274
+ Displays a modal dialog for the user to confirm Anki card details and
275
+ choose whether to include audio.
276
+
277
+ Args:
278
+ expression (str): The target word or expression.
279
+ sentence (str): The full sentence.
280
+ screenshot_path (str): The file path to the screenshot image.
281
+ audio_path (str): The file path to the audio clip.
282
+ translation (str): The translation or definition.
283
+
284
+ Returns:
285
+ str: 'voice' if the user chooses to add with voice,
286
+ 'no_voice' if they choose to add without voice,
287
+ or None if they cancel.
288
+ """
289
+ dialog = AnkiConfirmationDialog(self.window,
290
+ self,
291
+ expression=expression,
292
+ sentence=sentence,
293
+ screenshot_path=screenshot_path,
294
+ audio_path=audio_path,
295
+ translation=translation,
296
+ screenshot_timestamp=ss_timestamp)
297
+ return dialog.result
298
+
299
+ def show_screenshot_selector(self, video_path, timestamp, mode='beginning'):
300
+ """
301
+ Displays a modal dialog for the user to select the best screenshot from
302
+ a series of extracted video frames.
303
+
304
+ Args:
305
+ video_path (str): The file path to the source video.
306
+ timestamp (str or float): The timestamp (in seconds) around which to extract frames.
307
+ mode (str): 'beginning', 'middle', or 'end'. Determines the time offset
308
+ and which frame is highlighted as the "golden" frame.
309
+
310
+ Returns:
311
+ str: The file path of the image the user selected, or None if they canceled.
312
+ """
313
+ dialog = ScreenshotSelectorDialog(self.window,
314
+ self,
315
+ video_path=video_path,
316
+ timestamp=str(timestamp), # Ensure it's a string for ffmpeg
317
+ mode=mode)
318
+
319
+ print(dialog.selected_path)
320
+ return dialog.selected_path
321
+
270
322
  def create_vars(self):
271
323
  """
272
324
  Initializes all the tkinter variables used in the configuration GUI.
@@ -290,9 +342,8 @@ class ConfigApp:
290
342
  self.obs_password_value = tk.StringVar(value=self.settings.obs.password)
291
343
  self.obs_open_obs_value = tk.BooleanVar(value=self.settings.obs.open_obs)
292
344
  self.obs_close_obs_value = tk.BooleanVar(value=self.settings.obs.close_obs)
293
- self.obs_get_game_from_scene_name_value = tk.BooleanVar(value=self.settings.obs.get_game_from_scene)
294
345
  self.obs_minimum_replay_size_value = tk.StringVar(value=str(self.settings.obs.minimum_replay_size))
295
- self.obs_turn_off_output_check_value = tk.BooleanVar(value=self.settings.obs.turn_off_output_check)
346
+ self.automatically_manage_replay_buffer_value = tk.BooleanVar(value=self.settings.obs.automatically_manage_replay_buffer)
296
347
 
297
348
  # Paths Settings
298
349
  self.folder_to_watch_value = tk.StringVar(value=self.settings.paths.folder_to_watch)
@@ -306,6 +357,7 @@ class ConfigApp:
306
357
 
307
358
  # Anki Settings
308
359
  self.update_anki_value = tk.BooleanVar(value=self.settings.anki.update_anki)
360
+ self.show_update_confirmation_dialog_value = tk.BooleanVar(value=self.settings.anki.show_update_confirmation_dialog)
309
361
  self.anki_url_value = tk.StringVar(value=self.settings.anki.url)
310
362
  self.sentence_field_value = tk.StringVar(value=self.settings.anki.sentence_field)
311
363
  self.sentence_audio_field_value = tk.StringVar(value=self.settings.anki.sentence_audio_field)
@@ -331,6 +383,7 @@ class ConfigApp:
331
383
  self.open_anki_browser_value = tk.BooleanVar(value=self.settings.features.open_anki_in_browser)
332
384
  self.backfill_audio_value = tk.BooleanVar(value=self.settings.features.backfill_audio)
333
385
  self.browser_query_value = tk.StringVar(value=self.settings.features.browser_query)
386
+ self.generate_longplay_value = tk.BooleanVar(value=self.settings.features.generate_longplay)
334
387
 
335
388
  # Screenshot Settings
336
389
  self.screenshot_enabled_value = tk.BooleanVar(value=self.settings.screenshot.enabled)
@@ -534,6 +587,7 @@ class ConfigApp:
534
587
  ),
535
588
  anki=Anki(
536
589
  update_anki=self.update_anki_value.get(),
590
+ show_update_confirmation_dialog=self.show_update_confirmation_dialog_value.get(),
537
591
  url=self.anki_url_value.get(),
538
592
  sentence_field=self.sentence_field_value.get(),
539
593
  sentence_audio_field=self.sentence_audio_field_value.get(),
@@ -559,6 +613,7 @@ class ConfigApp:
559
613
  open_anki_in_browser=self.open_anki_browser_value.get(),
560
614
  backfill_audio=self.backfill_audio_value.get(),
561
615
  browser_query=self.browser_query_value.get(),
616
+ generate_longplay=self.generate_longplay_value.get(),
562
617
  ),
563
618
  screenshot=Screenshot(
564
619
  enabled=self.screenshot_enabled_value.get(),
@@ -590,9 +645,8 @@ class ConfigApp:
590
645
  host=self.obs_host_value.get(),
591
646
  port=int(self.obs_port_value.get()),
592
647
  password=self.obs_password_value.get(),
593
- get_game_from_scene=self.obs_get_game_from_scene_name_value.get(),
594
648
  minimum_replay_size=int(self.obs_minimum_replay_size_value.get()),
595
- turn_off_output_check=self.obs_turn_off_output_check_value.get()
649
+ automatically_manage_replay_buffer=self.automatically_manage_replay_buffer_value.get()
596
650
  ),
597
651
  hotkeys=Hotkeys(
598
652
  reset_line=self.reset_line_hotkey_value.get(),
@@ -647,8 +701,8 @@ class ConfigApp:
647
701
  monitor_to_capture=self.overlay_monitor.current() if self.monitors else 0,
648
702
  engine=OverlayEngine(self.overlay_engine_value.get()).value if self.overlay_engine_value.get() else OverlayEngine.LENS.value,
649
703
  scan_delay=float(self.scan_delay_value.get()),
650
- periodic=self.periodic_value.get(),
651
- periodic_interval=self.periodic_interval_value.get(),
704
+ periodic=float(self.periodic_value.get()),
705
+ periodic_interval=float(self.periodic_interval_value.get()),
652
706
  )
653
707
  # wip=WIP(
654
708
  # overlay_websocket_port=int(self.overlay_websocket_port_value.get()),
@@ -838,21 +892,6 @@ class ConfigApp:
838
892
  row=self.current_row, column=0)
839
893
  ttk.Combobox(self.general_tab, textvariable=self.native_language_value, values=CommonLanguages.get_all_names_pretty(), state="readonly").grid(row=self.current_row, column=1, sticky='EW', pady=2)
840
894
  self.current_row += 1
841
-
842
- legend_i18n = general_i18n.get('legend', {})
843
- ttk.Label(self.general_tab, text=legend_i18n.get('important', '...'), foreground="dark orange",
844
- font=("Helvetica", 10, "bold")).grid(row=self.current_row, column=0, columnspan=2, sticky='W', pady=2)
845
- self.current_row += 1
846
- ttk.Label(self.general_tab, text=legend_i18n.get('advanced', '...'), foreground="red",
847
- font=("Helvetica", 10, "bold")).grid(row=self.current_row, column=0, columnspan=2, sticky='W', pady=2)
848
- self.current_row += 1
849
- ttk.Label(self.general_tab, text=legend_i18n.get('recommended', '...'), foreground="green",
850
- font=("Helvetica", 10, "bold")).grid(row=self.current_row, column=0, columnspan=2, sticky='W', pady=2)
851
- self.current_row += 1
852
- ttk.Label(self.general_tab,
853
- text=legend_i18n.get('tooltip_info', '...'),
854
- font=("Helvetica", 10, "bold")).grid(row=self.current_row, column=0, columnspan=2, sticky='W', pady=2)
855
- self.current_row += 1
856
895
 
857
896
  if is_beangate:
858
897
  ttk.Button(self.general_tab, text=self.i18n.get('buttons', {}).get('run_function', 'Run Function'), command=self.test_func, bootstyle="info").grid(
@@ -1040,6 +1079,26 @@ class ConfigApp:
1040
1079
  row=self.current_row, column=3, sticky='W', pady=2)
1041
1080
  self.current_row += 1
1042
1081
 
1082
+ # Add Horizontal Separator
1083
+ sep = ttk.Separator(feature_frame, orient='horizontal')
1084
+ sep.grid(row=self.current_row, column=0, columnspan=5, sticky='EW', pady=10)
1085
+ self.current_row += 1
1086
+
1087
+ legend_i18n = general_i18n.get('legend', {})
1088
+ ttk.Label(feature_frame,
1089
+ text=legend_i18n.get('tooltip_info', '...'),
1090
+ font=("Helvetica", 10, "bold")).grid(row=self.current_row, column=0, columnspan=2, sticky='W', pady=2)
1091
+ self.current_row += 1
1092
+ ttk.Label(feature_frame, text=legend_i18n.get('important', '...'), foreground="dark orange",
1093
+ font=("Helvetica", 10, "bold")).grid(row=self.current_row, column=0, columnspan=2, sticky='W', pady=2)
1094
+ self.current_row += 1
1095
+ ttk.Label(feature_frame, text=legend_i18n.get('advanced', '...'), foreground="red",
1096
+ font=("Helvetica", 10, "bold")).grid(row=self.current_row, column=0, columnspan=2, sticky='W', pady=2)
1097
+ self.current_row += 1
1098
+ ttk.Label(feature_frame, text=legend_i18n.get('recommended', '...'), foreground="green",
1099
+ font=("Helvetica", 10, "bold")).grid(row=self.current_row, column=0, columnspan=2, sticky='W', pady=2)
1100
+ self.current_row += 1
1101
+
1043
1102
  # screenshot_i18n = simple_i18n.get('screenshot_enabled', {})
1044
1103
  # HoverInfoLabelWidget(feature_frame, text=screenshot_i18n.get('label', '...'),
1045
1104
  # tooltip=screenshot_i18n.get('tooltip', '...'), row=self.current_row, column=0)
@@ -1318,6 +1377,13 @@ class ConfigApp:
1318
1377
  column=1, sticky='W', pady=2)
1319
1378
  self.current_row += 1
1320
1379
 
1380
+ show_confirmation_i18n = anki_i18n.get('show_update_confirmation_dialog', {})
1381
+ HoverInfoLabelWidget(anki_frame, text=show_confirmation_i18n.get('label', '...'), tooltip=show_confirmation_i18n.get('tooltip', '...'),
1382
+ foreground="red", font=("Helvetica", 10, "bold"), row=self.current_row, column=0)
1383
+ ttk.Checkbutton(anki_frame, variable=self.show_update_confirmation_dialog_value, bootstyle="round-toggle").grid(row=self.current_row,
1384
+ column=1, sticky='W', pady=2)
1385
+ self.current_row += 1
1386
+
1321
1387
  url_i18n = anki_i18n.get('url', {})
1322
1388
  HoverInfoLabelWidget(anki_frame, text=url_i18n.get('label', '...'), tooltip=url_i18n.get('tooltip', '...'),
1323
1389
  foreground="dark orange", font=("Helvetica", 10, "bold"), row=self.current_row, column=0)
@@ -1492,6 +1558,12 @@ class ConfigApp:
1492
1558
  row=self.current_row, column=1, sticky='W', pady=2)
1493
1559
  self.current_row += 1
1494
1560
 
1561
+ HoverInfoLabelWidget(features_frame, text="Generate LongPlay", tooltip="Generate a LongPlay video using OBS recording, and write to a .srt file with all the text coming into gsm. RESTART REQUIRED FOR SETTING TO TAKE EFFECT.",
1562
+ row=self.current_row, column=0)
1563
+ ttk.Checkbutton(features_frame, variable=self.generate_longplay_value, bootstyle="round-toggle").grid(
1564
+ row=self.current_row, column=1, sticky='W', pady=2)
1565
+ self.current_row += 1
1566
+
1495
1567
  self.add_reset_button(features_frame, "features", self.current_row, 0, self.create_features_tab)
1496
1568
 
1497
1569
  for col in range(3):
@@ -1797,13 +1869,6 @@ class ConfigApp:
1797
1869
  ttk.Entry(obs_frame, show="*", textvariable=self.obs_password_value).grid(row=self.current_row, column=1, sticky='EW', pady=2)
1798
1870
  self.current_row += 1
1799
1871
 
1800
- game_scene_i18n = obs_i18n.get('game_from_scene', {})
1801
- HoverInfoLabelWidget(obs_frame, text=game_scene_i18n.get('label', '...'), tooltip=game_scene_i18n.get('tooltip', '...'),
1802
- row=self.current_row, column=0)
1803
- ttk.Checkbutton(obs_frame, variable=self.obs_get_game_from_scene_name_value, bootstyle="round-toggle").grid(
1804
- row=self.current_row, column=1, sticky='W', pady=2)
1805
- self.current_row += 1
1806
-
1807
1872
  min_size_i18n = obs_i18n.get('min_replay_size', {})
1808
1873
  HoverInfoLabelWidget(obs_frame, text=min_size_i18n.get('label', '...'),
1809
1874
  tooltip=min_size_i18n.get('tooltip', '...'),
@@ -1811,13 +1876,13 @@ class ConfigApp:
1811
1876
  ttk.Entry(obs_frame, textvariable=self.obs_minimum_replay_size_value).grid(row=self.current_row, column=1, sticky='EW', pady=2)
1812
1877
  self.current_row += 1
1813
1878
 
1814
- turn_off_output_check_i18n = obs_i18n.get('turn_off_output_check', {})
1815
- HoverInfoLabelWidget(obs_frame, text=turn_off_output_check_i18n.get('label', '...'),
1816
- tooltip=turn_off_output_check_i18n.get('tooltip', '...'),
1817
- row=self.current_row, column=0)
1818
- ttk.Checkbutton(obs_frame, variable=self.obs_turn_off_output_check_value, bootstyle="round-toggle").grid(
1819
- row=self.current_row, column=1, sticky='W', pady=2)
1820
- self.current_row += 1
1879
+ # turn_off_output_check_i18n = obs_i18n.get('turn_off_replay_buffer_management', {})
1880
+ # HoverInfoLabelWidget(obs_frame, text=turn_off_output_check_i18n.get('label', '...'),
1881
+ # tooltip=turn_off_output_check_i18n.get('tooltip', '...'),
1882
+ # row=self.current_row, column=0)
1883
+ # ttk.Checkbutton(obs_frame, variable=self.obs_turn_off_output_check_value, bootstyle="round-toggle").grid(
1884
+ # row=self.current_row, column=1, sticky='W', pady=2)
1885
+ # self.current_row += 1
1821
1886
 
1822
1887
  self.add_reset_button(obs_frame, "obs", self.current_row, 0, self.create_obs_tab)
1823
1888
 
@@ -0,0 +1,215 @@
1
+ import os
2
+ import subprocess
3
+ import json
4
+ import tkinter as tk
5
+ from tkinter import messagebox
6
+ import ttkbootstrap as ttk
7
+ from PIL import Image, ImageTk
8
+
9
+ 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
11
+
12
+
13
+ class ScreenshotSelectorDialog(tk.Toplevel):
14
+ """
15
+ A modal dialog that extracts frames from a video around a specific timestamp
16
+ and allows the user to select the best one.
17
+ """
18
+ def __init__(self, parent, config_app, video_path, timestamp, mode='beginning'):
19
+ super().__init__(parent)
20
+ self.config_app = config_app
21
+
22
+ self.title("Select Screenshot")
23
+ self.configure(bg="black")
24
+ self.selected_path = None # This will store the final result
25
+ self.parent_window = parent # Store a reference to the parent
26
+
27
+ # Handle the user closing the window with the 'X' button
28
+ self.protocol("WM_DELETE_WINDOW", self._on_cancel)
29
+
30
+ # Make the dialog modal
31
+ self.grab_set()
32
+
33
+ # --- Show a loading message while ffmpeg runs ---
34
+ self.loading_label = ttk.Label(
35
+ self,
36
+ text="Extracting frames, please wait...",
37
+ bootstyle="inverse-primary",
38
+ font=("Helvetica", 16)
39
+ )
40
+ self.loading_label.pack(pady=50, padx=50)
41
+ self.update() # Force the UI to update and show the label
42
+
43
+ # --- Run extraction and build the main UI ---
44
+ try:
45
+ image_paths, golden_frame = self._extract_frames(video_path, timestamp, mode)
46
+ self.loading_label.destroy() # Remove the loading message
47
+
48
+ if not image_paths:
49
+ messagebox.showerror("Error", "Failed to extract frames from the video.", parent=self)
50
+ self.destroy()
51
+ return
52
+
53
+ self._build_image_grid(image_paths, golden_frame)
54
+
55
+ except Exception as e:
56
+ logger.error(f"ScreenshotSelector failed: {e}")
57
+ messagebox.showerror("Error", f"An unexpected error occurred: {e}", parent=self)
58
+ self.destroy()
59
+ return
60
+
61
+ # --- Center the dialog and wait for it to close ---
62
+ self._center_window()
63
+ self.attributes('-topmost', True)
64
+ self.wait_window(self)
65
+ # Force always on top to ensure visibility
66
+
67
+ def _extract_frames(self, video_path, timestamp, mode):
68
+ """Extracts frames using ffmpeg. Encapsulated from the original script."""
69
+ temp_dir = os.path.join(
70
+ get_temporary_directory(False),
71
+ "screenshot_frames",
72
+ sanitize_filename(os.path.splitext(os.path.basename(video_path))[0])
73
+ )
74
+ os.makedirs(temp_dir, exist_ok=True)
75
+
76
+ frame_paths = []
77
+ golden_frame = None
78
+ timestamp_number = float(timestamp)
79
+ video_duration = self.get_video_duration(video_path)
80
+
81
+ if mode == 'middle':
82
+ timestamp_number = max(0.0, timestamp_number - 2.5)
83
+ elif mode == 'end':
84
+ timestamp_number = max(0.0, timestamp_number - 5.0)
85
+
86
+ if video_duration is not None and timestamp_number > video_duration:
87
+ logger.warning(f"Timestamp {timestamp_number} exceeds video duration {video_duration}.")
88
+ return [], None
89
+
90
+ try:
91
+ command = ffmpeg_base_command_list + [
92
+ "-y",
93
+ "-ss", str(timestamp_number),
94
+ "-i", video_path,
95
+ "-vf", f"fps=1/{0.25}",
96
+ "-vframes", "20",
97
+ os.path.join(temp_dir, "frame_%02d.png")
98
+ ]
99
+ subprocess.run(command, check=True, capture_output=True, text=True)
100
+
101
+ for i in range(1, 21):
102
+ frame_path = os.path.join(temp_dir, f"frame_{i:02d}.png")
103
+ if os.path.exists(frame_path):
104
+ frame_paths.append(frame_path)
105
+
106
+ if not frame_paths: return [], None
107
+
108
+ if mode == "beginning":
109
+ golden_frame = frame_paths[0] if frame_paths else None
110
+ elif mode == "middle":
111
+ golden_frame = frame_paths[len(frame_paths) // 2] if frame_paths else None
112
+ elif mode == "end":
113
+ golden_frame = frame_paths[-1] if frame_paths else None
114
+
115
+ return frame_paths, golden_frame
116
+
117
+ except subprocess.CalledProcessError as e:
118
+ logger.error(f"Error extracting frames: {e}")
119
+ logger.error(f"FFmpeg command was: {' '.join(command)}")
120
+ logger.error(f"FFmpeg output:\n{e.stderr}")
121
+ return [], None
122
+ except Exception as e:
123
+ logger.error(f"An unexpected error occurred during frame extraction: {e}")
124
+ return [], None
125
+
126
+ def _build_image_grid(self, image_paths, golden_frame):
127
+ """Creates and displays the grid of selectable images."""
128
+ self.images = [] # Keep a reference to images to prevent garbage collection
129
+ max_cols = 5
130
+ for i, path in enumerate(image_paths):
131
+ try:
132
+ img = Image.open(path)
133
+ # Use a larger thumbnail size for better visibility
134
+ # Making this division-based can be risky if images are very small
135
+ # Let's use a fixed thumbnail size for robustness
136
+ img.thumbnail((256, 144))
137
+ img_tk = ImageTk.PhotoImage(img)
138
+ self.images.append(img_tk)
139
+
140
+ is_golden = (path == golden_frame)
141
+ border_width = 4 if is_golden else 2
142
+ border_color = "gold" if is_golden else "grey"
143
+
144
+ # Using a Frame for better border control
145
+ frame = tk.Frame(self, bg=border_color, borderwidth=border_width, relief="solid")
146
+ frame.grid(row=i // max_cols, column=i % max_cols, padx=3, pady=3)
147
+
148
+ label = tk.Label(frame, image=img_tk, borderwidth=0, bg="black")
149
+ label.pack()
150
+
151
+ # Bind the click event to both the frame and the label for better UX
152
+ frame.bind("<Button-1>", lambda e, p=path: self._on_image_click(p))
153
+ label.bind("<Button-1>", lambda e, p=path: self._on_image_click(p))
154
+
155
+ except Exception as e:
156
+ logger.error(f"Could not load image {path}: {e}")
157
+ error_label = ttk.Label(self, text="Load Error", bootstyle="inverse-danger", width=30, anchor="center")
158
+ error_label.grid(row=i // max_cols, column=i % max_cols, padx=3, pady=3, ipadx=10, ipady=50)
159
+
160
+ def _on_image_click(self, path):
161
+ """Handles a user clicking on an image."""
162
+ self.selected_path = path
163
+ self.destroy()
164
+
165
+ def _on_cancel(self):
166
+ """Handles the user closing the window without a selection."""
167
+ self.selected_path = None
168
+ self.destroy()
169
+
170
+ def _center_window(self):
171
+ """
172
+ Smarter centering logic. Centers on the parent if it's visible,
173
+ otherwise centers on the screen.
174
+ """
175
+ self.update_idletasks()
176
+
177
+ parent = self.parent_window
178
+ dialog_width = self.winfo_width()
179
+ dialog_height = self.winfo_height()
180
+
181
+ if parent.state() == 'withdrawn':
182
+ # PARENT IS HIDDEN: Center the dialog on the screen
183
+ screen_width = self.winfo_screenwidth()
184
+ screen_height = self.winfo_screenheight()
185
+ x = (screen_width // 2) - (dialog_width // 2)
186
+ y = (screen_height // 2) - (dialog_height // 2)
187
+ else:
188
+ # PARENT IS VISIBLE: Center relative to the parent window
189
+ self.transient(parent) # Associate dialog with its parent
190
+ parent_x = parent.winfo_x()
191
+ parent_y = parent.winfo_y()
192
+ parent_width = parent.winfo_width()
193
+ parent_height = parent.winfo_height()
194
+ x = parent_x + (parent_width // 2) - (dialog_width // 2)
195
+ y = parent_y + (parent_height // 2) - (dialog_height // 2)
196
+
197
+ self.geometry(f'+{x}+{y}')
198
+
199
+ def get_video_duration(self, file_path):
200
+ try:
201
+ ffprobe_command = [
202
+ f"{get_ffprobe_path()}",
203
+ "-v", "error",
204
+ "-show_entries", "format=duration",
205
+ "-of", "json",
206
+ file_path
207
+ ]
208
+ logger.debug(" ".join(ffprobe_command))
209
+ result = subprocess.run(ffprobe_command, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True, check=True)
210
+ duration_info = json.loads(result.stdout)
211
+ logger.debug(f"Video duration: {duration_info}")
212
+ return float(duration_info["format"]["duration"])
213
+ except (subprocess.CalledProcessError, json.JSONDecodeError, KeyError, FileNotFoundError) as e:
214
+ logger.error(f"Failed to get video duration for {file_path}: {e}")
215
+ return None