GameSentenceMiner 2.19.5__py3-none-any.whl → 2.19.7__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/anki.py +12 -3
- GameSentenceMiner/gsm.py +52 -10
- GameSentenceMiner/locales/en_us.json +4 -0
- GameSentenceMiner/locales/ja_jp.json +4 -0
- GameSentenceMiner/locales/zh_cn.json +4 -0
- GameSentenceMiner/obs.py +154 -18
- GameSentenceMiner/ocr/owocr_area_selector.py +5 -2
- GameSentenceMiner/ocr/owocr_helper.py +66 -17
- GameSentenceMiner/owocr/owocr/run.py +5 -2
- GameSentenceMiner/ui/config_gui.py +21 -0
- GameSentenceMiner/ui/screenshot_selector.py +29 -7
- GameSentenceMiner/util/configuration.py +29 -2
- GameSentenceMiner/util/ffmpeg.py +272 -17
- GameSentenceMiner/web/gsm_websocket.py +8 -6
- GameSentenceMiner/web/templates/index.html +17 -12
- GameSentenceMiner/web/texthooking_page.py +27 -22
- {gamesentenceminer-2.19.5.dist-info → gamesentenceminer-2.19.7.dist-info}/METADATA +1 -1
- {gamesentenceminer-2.19.5.dist-info → gamesentenceminer-2.19.7.dist-info}/RECORD +22 -22
- {gamesentenceminer-2.19.5.dist-info → gamesentenceminer-2.19.7.dist-info}/WHEEL +0 -0
- {gamesentenceminer-2.19.5.dist-info → gamesentenceminer-2.19.7.dist-info}/entry_points.txt +0 -0
- {gamesentenceminer-2.19.5.dist-info → gamesentenceminer-2.19.7.dist-info}/licenses/LICENSE +0 -0
- {gamesentenceminer-2.19.5.dist-info → gamesentenceminer-2.19.7.dist-info}/top_level.txt +0 -0
|
@@ -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
|
|
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
|
-
|
|
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}")
|
GameSentenceMiner/util/ffmpeg.py
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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(" "
|
|
251
|
-
result = subprocess.run(ffmpeg_command)
|
|
252
|
-
if result.returncode
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
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
|
-
|
|
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(
|
|
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(
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
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
|
|
@@ -106,11 +108,11 @@ websocket_server_thread.start()
|
|
|
106
108
|
plaintext_websocket_server_thread = None
|
|
107
109
|
if get_config().advanced.plaintext_websocket_port:
|
|
108
110
|
plaintext_websocket_server_thread = WebsocketServerThread(
|
|
109
|
-
read=
|
|
111
|
+
read=True, get_ws_port_func=lambda: get_config().get_field_value('advanced', 'plaintext_websocket_port'))
|
|
110
112
|
plaintext_websocket_server_thread.start()
|
|
111
113
|
|
|
112
114
|
overlay_server_thread = WebsocketServerThread(
|
|
113
|
-
read=
|
|
115
|
+
read=True, get_ws_port_func=lambda: get_config().get_field_value('overlay', 'websocket_port'))
|
|
114
116
|
overlay_server_thread.start()
|
|
115
117
|
|
|
116
118
|
websocket_server_threads = [
|