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.
- GameSentenceMiner/ai/ai_prompting.py +51 -51
- GameSentenceMiner/anki.py +236 -152
- GameSentenceMiner/gametext.py +7 -4
- GameSentenceMiner/gsm.py +49 -10
- GameSentenceMiner/locales/en_us.json +7 -3
- GameSentenceMiner/locales/ja_jp.json +8 -4
- GameSentenceMiner/locales/zh_cn.json +8 -4
- GameSentenceMiner/obs.py +238 -59
- GameSentenceMiner/ocr/owocr_helper.py +1 -1
- GameSentenceMiner/tools/ss_selector.py +7 -8
- GameSentenceMiner/ui/__init__.py +0 -0
- GameSentenceMiner/ui/anki_confirmation.py +187 -0
- GameSentenceMiner/{config_gui.py → ui/config_gui.py} +102 -37
- GameSentenceMiner/ui/screenshot_selector.py +215 -0
- GameSentenceMiner/util/configuration.py +124 -22
- GameSentenceMiner/util/db.py +22 -13
- GameSentenceMiner/util/downloader/download_tools.py +2 -2
- GameSentenceMiner/util/ffmpeg.py +24 -30
- GameSentenceMiner/util/get_overlay_coords.py +34 -34
- GameSentenceMiner/util/gsm_utils.py +31 -1
- GameSentenceMiner/util/text_log.py +11 -9
- GameSentenceMiner/vad.py +31 -12
- GameSentenceMiner/web/database_api.py +742 -123
- GameSentenceMiner/web/static/css/dashboard-shared.css +241 -0
- GameSentenceMiner/web/static/css/kanji-grid.css +94 -2
- GameSentenceMiner/web/static/css/overview.css +850 -0
- GameSentenceMiner/web/static/css/popups-shared.css +126 -0
- GameSentenceMiner/web/static/css/shared.css +97 -0
- GameSentenceMiner/web/static/css/stats.css +192 -597
- GameSentenceMiner/web/static/js/anki_stats.js +6 -4
- GameSentenceMiner/web/static/js/database.js +209 -5
- GameSentenceMiner/web/static/js/goals.js +610 -0
- GameSentenceMiner/web/static/js/kanji-grid.js +267 -4
- GameSentenceMiner/web/static/js/overview.js +1176 -0
- GameSentenceMiner/web/static/js/shared.js +25 -0
- GameSentenceMiner/web/static/js/stats.js +154 -1459
- GameSentenceMiner/web/stats.py +2 -2
- GameSentenceMiner/web/templates/anki_stats.html +5 -0
- GameSentenceMiner/web/templates/components/navigation.html +3 -1
- GameSentenceMiner/web/templates/database.html +73 -1
- GameSentenceMiner/web/templates/goals.html +376 -0
- GameSentenceMiner/web/templates/index.html +13 -11
- GameSentenceMiner/web/templates/overview.html +416 -0
- GameSentenceMiner/web/templates/stats.html +46 -251
- GameSentenceMiner/web/texthooking_page.py +18 -0
- {gamesentenceminer-2.17.6.dist-info → gamesentenceminer-2.18.0.dist-info}/METADATA +5 -1
- {gamesentenceminer-2.17.6.dist-info → gamesentenceminer-2.18.0.dist-info}/RECORD +51 -41
- {gamesentenceminer-2.17.6.dist-info → gamesentenceminer-2.18.0.dist-info}/WHEEL +0 -0
- {gamesentenceminer-2.17.6.dist-info → gamesentenceminer-2.18.0.dist-info}/entry_points.txt +0 -0
- {gamesentenceminer-2.17.6.dist-info → gamesentenceminer-2.18.0.dist-info}/licenses/LICENSE +0 -0
- {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.
|
|
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
|
-
|
|
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('
|
|
1815
|
-
HoverInfoLabelWidget(obs_frame, text=turn_off_output_check_i18n.get('label', '...'),
|
|
1816
|
-
|
|
1817
|
-
|
|
1818
|
-
ttk.Checkbutton(obs_frame, variable=self.obs_turn_off_output_check_value, bootstyle="round-toggle").grid(
|
|
1819
|
-
|
|
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
|