GameSentenceMiner 2.14.7__py3-none-any.whl → 2.14.9__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/config_gui.py +19 -10
- GameSentenceMiner/gsm.py +68 -8
- GameSentenceMiner/locales/en_us.json +4 -0
- GameSentenceMiner/locales/ja_jp.json +4 -0
- GameSentenceMiner/locales/zh_cn.json +4 -0
- GameSentenceMiner/obs.py +12 -8
- {gamesentenceminer-2.14.7.dist-info → gamesentenceminer-2.14.9.dist-info}/METADATA +1 -2
- gamesentenceminer-2.14.9.dist-info/RECORD +24 -0
- GameSentenceMiner/ai/__init__.py +0 -0
- GameSentenceMiner/ai/ai_prompting.py +0 -473
- GameSentenceMiner/ocr/__init__.py +0 -0
- GameSentenceMiner/ocr/gsm_ocr_config.py +0 -174
- GameSentenceMiner/ocr/ocrconfig.py +0 -129
- GameSentenceMiner/ocr/owocr_area_selector.py +0 -629
- GameSentenceMiner/ocr/owocr_helper.py +0 -638
- GameSentenceMiner/ocr/ss_picker.py +0 -140
- GameSentenceMiner/owocr/owocr/__init__.py +0 -1
- GameSentenceMiner/owocr/owocr/__main__.py +0 -9
- GameSentenceMiner/owocr/owocr/config.py +0 -148
- GameSentenceMiner/owocr/owocr/lens_betterproto.py +0 -1238
- GameSentenceMiner/owocr/owocr/ocr.py +0 -1691
- GameSentenceMiner/owocr/owocr/run.py +0 -1817
- GameSentenceMiner/owocr/owocr/screen_coordinate_picker.py +0 -109
- GameSentenceMiner/tools/__init__.py +0 -0
- GameSentenceMiner/tools/audio_offset_selector.py +0 -215
- GameSentenceMiner/tools/ss_selector.py +0 -135
- GameSentenceMiner/tools/window_transparency.py +0 -214
- GameSentenceMiner/util/__init__.py +0 -0
- GameSentenceMiner/util/communication/__init__.py +0 -22
- GameSentenceMiner/util/communication/send.py +0 -7
- GameSentenceMiner/util/communication/websocket.py +0 -94
- GameSentenceMiner/util/configuration.py +0 -1198
- GameSentenceMiner/util/db.py +0 -408
- GameSentenceMiner/util/downloader/Untitled_json.py +0 -472
- GameSentenceMiner/util/downloader/__init__.py +0 -0
- GameSentenceMiner/util/downloader/download_tools.py +0 -194
- GameSentenceMiner/util/downloader/oneocr_dl.py +0 -250
- GameSentenceMiner/util/electron_config.py +0 -259
- GameSentenceMiner/util/ffmpeg.py +0 -571
- GameSentenceMiner/util/get_overlay_coords.py +0 -366
- GameSentenceMiner/util/gsm_utils.py +0 -323
- GameSentenceMiner/util/model.py +0 -206
- GameSentenceMiner/util/notification.py +0 -147
- GameSentenceMiner/util/text_log.py +0 -214
- GameSentenceMiner/web/__init__.py +0 -0
- GameSentenceMiner/web/service.py +0 -132
- GameSentenceMiner/web/static/__init__.py +0 -0
- GameSentenceMiner/web/static/apple-touch-icon.png +0 -0
- GameSentenceMiner/web/static/favicon-96x96.png +0 -0
- GameSentenceMiner/web/static/favicon.ico +0 -0
- GameSentenceMiner/web/static/favicon.svg +0 -3
- GameSentenceMiner/web/static/site.webmanifest +0 -21
- GameSentenceMiner/web/static/style.css +0 -292
- GameSentenceMiner/web/static/web-app-manifest-192x192.png +0 -0
- GameSentenceMiner/web/static/web-app-manifest-512x512.png +0 -0
- GameSentenceMiner/web/templates/__init__.py +0 -0
- GameSentenceMiner/web/templates/index.html +0 -50
- GameSentenceMiner/web/templates/text_replacements.html +0 -238
- GameSentenceMiner/web/templates/utility.html +0 -483
- GameSentenceMiner/web/texthooking_page.py +0 -584
- GameSentenceMiner/wip/__init___.py +0 -0
- gamesentenceminer-2.14.7.dist-info/RECORD +0 -77
- {gamesentenceminer-2.14.7.dist-info → gamesentenceminer-2.14.9.dist-info}/WHEEL +0 -0
- {gamesentenceminer-2.14.7.dist-info → gamesentenceminer-2.14.9.dist-info}/entry_points.txt +0 -0
- {gamesentenceminer-2.14.7.dist-info → gamesentenceminer-2.14.9.dist-info}/licenses/LICENSE +0 -0
- {gamesentenceminer-2.14.7.dist-info → gamesentenceminer-2.14.9.dist-info}/top_level.txt +0 -0
GameSentenceMiner/util/ffmpeg.py
DELETED
@@ -1,571 +0,0 @@
|
|
1
|
-
import subprocess
|
2
|
-
import tempfile
|
3
|
-
import time
|
4
|
-
from pathlib import Path
|
5
|
-
|
6
|
-
from GameSentenceMiner import obs
|
7
|
-
from GameSentenceMiner.util.gsm_utils import make_unique_file_name, get_file_modification_time
|
8
|
-
from GameSentenceMiner.util import configuration
|
9
|
-
from GameSentenceMiner.util.configuration import *
|
10
|
-
from GameSentenceMiner.util.model import VADResult
|
11
|
-
from GameSentenceMiner.util.text_log import initial_time
|
12
|
-
|
13
|
-
|
14
|
-
def get_ffmpeg_path():
|
15
|
-
return os.path.join(get_app_directory(), "ffmpeg", "ffmpeg.exe") if is_windows() else "ffmpeg"
|
16
|
-
|
17
|
-
def get_ffprobe_path():
|
18
|
-
return os.path.join(get_app_directory(), "ffmpeg", "ffprobe.exe") if is_windows() else "ffprobe"
|
19
|
-
|
20
|
-
ffmpeg_base_command_list = [get_ffmpeg_path(), "-hide_banner", "-loglevel", "error", '-nostdin']
|
21
|
-
|
22
|
-
supported_formats = {
|
23
|
-
'opus': 'libopus',
|
24
|
-
'mp3': 'libmp3lame',
|
25
|
-
'ogg': 'libvorbis',
|
26
|
-
'aac': 'aac',
|
27
|
-
'm4a': 'aac',
|
28
|
-
}
|
29
|
-
|
30
|
-
def call_frame_extractor(video_path, timestamp):
|
31
|
-
"""
|
32
|
-
Calls the video frame extractor script and captures the output.
|
33
|
-
|
34
|
-
Args:
|
35
|
-
video_path (str): Path to the video file.
|
36
|
-
timestamp (str): Timestamp string (HH:MM:SS).
|
37
|
-
|
38
|
-
Returns:
|
39
|
-
str: The path of the selected image, or None on error.
|
40
|
-
"""
|
41
|
-
try:
|
42
|
-
logger.info(' '.join([sys.executable, "-m", "GameSentenceMiner.tools.ss_selector", video_path, str(timestamp)]))
|
43
|
-
|
44
|
-
# Run the script using subprocess.run()
|
45
|
-
result = subprocess.run(
|
46
|
-
[sys.executable, "-m", "GameSentenceMiner.tools.ss_selector", video_path, str(timestamp), get_config().screenshot.screenshot_timing_setting], # Use sys.executable
|
47
|
-
capture_output=True,
|
48
|
-
text=True, # Get output as text
|
49
|
-
check=False # Raise an exception for non-zero exit codes
|
50
|
-
)
|
51
|
-
if result.returncode != 0:
|
52
|
-
logger.error(f"Script failed with return code: {result.returncode}")
|
53
|
-
return None
|
54
|
-
logger.info(result)
|
55
|
-
# Print the standard output
|
56
|
-
logger.info(f"Frame extractor script output: {result.stdout.strip()}")
|
57
|
-
return result.stdout.strip() # Return the output
|
58
|
-
|
59
|
-
except subprocess.CalledProcessError as e:
|
60
|
-
logger.error(f"Error calling script: {e}")
|
61
|
-
logger.error(f"Script output (stderr): {e.stderr.strip()}")
|
62
|
-
return None
|
63
|
-
except Exception as e:
|
64
|
-
logger.error(f"An unexpected error occurred: {e}")
|
65
|
-
return None
|
66
|
-
|
67
|
-
def get_screenshot(video_file, screenshot_timing, try_selector=False):
|
68
|
-
screenshot_timing = screenshot_timing if screenshot_timing else 1
|
69
|
-
if try_selector:
|
70
|
-
filepath = call_frame_extractor(video_path=video_file, timestamp=screenshot_timing)
|
71
|
-
output = process_image(filepath)
|
72
|
-
if output:
|
73
|
-
return output
|
74
|
-
else:
|
75
|
-
logger.error("Frame extractor script failed to run or returned no output, defaulting")
|
76
|
-
output_image = make_unique_file_name(os.path.join(
|
77
|
-
get_temporary_directory(), f"{obs.get_current_game(sanitize=True)}.{get_config().screenshot.extension}"))
|
78
|
-
# FFmpeg command to extract the last frame of the video
|
79
|
-
ffmpeg_command = ffmpeg_base_command_list + [
|
80
|
-
"-ss", f"{screenshot_timing}",
|
81
|
-
"-i", f"{video_file}",
|
82
|
-
"-vframes", "1" # Extract only one frame
|
83
|
-
]
|
84
|
-
|
85
|
-
if get_config().screenshot.custom_ffmpeg_settings:
|
86
|
-
ffmpeg_command.extend(get_config().screenshot.custom_ffmpeg_settings.replace("\"", "").split(" "))
|
87
|
-
else:
|
88
|
-
ffmpeg_command.extend(["-compression_level", "6", "-q:v", get_config().screenshot.quality])
|
89
|
-
|
90
|
-
if get_config().screenshot.width or get_config().screenshot.height:
|
91
|
-
ffmpeg_command.extend(
|
92
|
-
["-vf", f"scale={get_config().screenshot.width or -1}:{get_config().screenshot.height or -1}"])
|
93
|
-
|
94
|
-
ffmpeg_command.append(f"{output_image}")
|
95
|
-
|
96
|
-
logger.debug(f"FFMPEG SS Command: {ffmpeg_command}")
|
97
|
-
|
98
|
-
try:
|
99
|
-
for i in range(3):
|
100
|
-
logger.debug(" ".join(ffmpeg_command))
|
101
|
-
result = subprocess.run(ffmpeg_command)
|
102
|
-
if result.returncode != 0 and i < 2:
|
103
|
-
raise RuntimeError(f"FFmpeg command failed with return code {result.returncode}")
|
104
|
-
else:
|
105
|
-
break
|
106
|
-
except Exception as e:
|
107
|
-
logger.error(f"Error running FFmpeg command: {e}. Defaulting to standard PNG.")
|
108
|
-
output_image = make_unique_file_name(os.path.join(
|
109
|
-
get_temporary_directory(),
|
110
|
-
f"{obs.get_current_game(sanitize=True)}.png"))
|
111
|
-
ffmpeg_command = ffmpeg_base_command_list + [
|
112
|
-
"-ss", f"{screenshot_timing}",
|
113
|
-
"-i", video_file,
|
114
|
-
"-vframes", "1",
|
115
|
-
output_image
|
116
|
-
]
|
117
|
-
subprocess.run(ffmpeg_command)
|
118
|
-
|
119
|
-
logger.debug(f"Screenshot saved to: {output_image}")
|
120
|
-
|
121
|
-
return output_image
|
122
|
-
|
123
|
-
def get_screenshot_for_line(video_file, game_line, try_selector=False):
|
124
|
-
return get_screenshot(video_file, get_screenshot_time(video_file, game_line), try_selector)
|
125
|
-
|
126
|
-
|
127
|
-
def get_screenshot_time(video_path, game_line, default_beginning=False, vad_result=None, doing_multi_line=False, previous_line=False, anki_card_creation_time=0):
|
128
|
-
if game_line:
|
129
|
-
line_time = game_line.time
|
130
|
-
else:
|
131
|
-
# Assuming initial_time is defined elsewhere if game_line is None
|
132
|
-
line_time = initial_time
|
133
|
-
if previous_line:
|
134
|
-
logger.debug(f"Calculating screenshot time for previous line: {str(game_line.text)}")
|
135
|
-
else:
|
136
|
-
logger.debug("Calculating screenshot time for line: " + str(game_line.text))
|
137
|
-
|
138
|
-
file_length = get_video_duration(video_path)
|
139
|
-
if anki_card_creation_time:
|
140
|
-
file_mod_time = anki_card_creation_time
|
141
|
-
else:
|
142
|
-
file_mod_time = get_file_modification_time(video_path)
|
143
|
-
|
144
|
-
# Calculate when the line occurred within the video file (seconds from start)
|
145
|
-
time_delta = file_mod_time - line_time
|
146
|
-
line_timestamp_in_video = file_length - time_delta.total_seconds()
|
147
|
-
screenshot_offset = get_config().screenshot.seconds_after_line
|
148
|
-
|
149
|
-
# Calculate screenshot time from the beginning by adding the offset
|
150
|
-
# if vad_result and vad_result.success and not doing_multi_line:
|
151
|
-
# screenshot_time_from_beginning = line_timestamp_in_video + vad_result.end - 1
|
152
|
-
# logger.info(f"Using VAD result {vad_result} for screenshot time: {screenshot_time_from_beginning} seconds from beginning of replay")
|
153
|
-
if get_config().screenshot.screenshot_timing_setting == "beginning":
|
154
|
-
screenshot_time_from_beginning = line_timestamp_in_video + screenshot_offset
|
155
|
-
logger.debug(f"Using 'beginning' setting for screenshot time: {screenshot_time_from_beginning} seconds from beginning of replay")
|
156
|
-
elif get_config().screenshot.screenshot_timing_setting == "middle":
|
157
|
-
if game_line.next:
|
158
|
-
screenshot_time_from_beginning = line_timestamp_in_video + ((game_line.next.time - game_line.time).total_seconds() / 2) + screenshot_offset
|
159
|
-
else:
|
160
|
-
screenshot_time_from_beginning = (file_length - ((file_length - line_timestamp_in_video) / 2)) + screenshot_offset
|
161
|
-
logger.debug(f"Using 'middle' setting for screenshot time: {screenshot_time_from_beginning} seconds from beginning of replay")
|
162
|
-
elif get_config().screenshot.screenshot_timing_setting == "end":
|
163
|
-
if game_line.next:
|
164
|
-
screenshot_time_from_beginning = line_timestamp_in_video + (game_line.next.time - game_line.time).total_seconds() - screenshot_offset
|
165
|
-
else:
|
166
|
-
screenshot_time_from_beginning = file_length - screenshot_offset
|
167
|
-
logger.debug(f"Using 'end' setting for screenshot time: {screenshot_time_from_beginning} seconds from beginning of replay")
|
168
|
-
else:
|
169
|
-
logger.error(f"Invalid screenshot timing setting: {get_config().screenshot.screenshot_timing_setting}")
|
170
|
-
screenshot_time_from_beginning = line_timestamp_in_video + screenshot_offset
|
171
|
-
|
172
|
-
# Check if the calculated time is out of bounds
|
173
|
-
if screenshot_time_from_beginning < 0 or screenshot_time_from_beginning > file_length:
|
174
|
-
logger.error(
|
175
|
-
f"Calculated screenshot time ({screenshot_time_from_beginning:.2f}s) is out of bounds for video (length {file_length:.2f}s)."
|
176
|
-
)
|
177
|
-
if default_beginning:
|
178
|
-
return 1.0
|
179
|
-
return file_length - screenshot_offset
|
180
|
-
|
181
|
-
# Return the calculated time from the beginning
|
182
|
-
return screenshot_time_from_beginning
|
183
|
-
|
184
|
-
|
185
|
-
def process_image(image_file):
|
186
|
-
output_image = make_unique_file_name(
|
187
|
-
os.path.join(get_temporary_directory(), f"{obs.get_current_game(sanitize=True)}.{get_config().screenshot.extension}"))
|
188
|
-
|
189
|
-
# FFmpeg command to process the input image
|
190
|
-
ffmpeg_command = ffmpeg_base_command_list + [
|
191
|
-
"-i", image_file
|
192
|
-
]
|
193
|
-
|
194
|
-
if get_config().screenshot.custom_ffmpeg_settings:
|
195
|
-
ffmpeg_command.extend(get_config().screenshot.custom_ffmpeg_settings.split(" "))
|
196
|
-
else:
|
197
|
-
ffmpeg_command.extend(["-compression_level", "6", "-q:v", get_config().screenshot.quality])
|
198
|
-
|
199
|
-
if get_config().screenshot.width or get_config().screenshot.height:
|
200
|
-
ffmpeg_command.extend(
|
201
|
-
["-vf", f"scale={get_config().screenshot.width or -1}:{get_config().screenshot.height or -1}"])
|
202
|
-
|
203
|
-
ffmpeg_command.append(output_image)
|
204
|
-
logger.debug(ffmpeg_command)
|
205
|
-
logger.debug(" ".join(ffmpeg_command))
|
206
|
-
# Run the command
|
207
|
-
try:
|
208
|
-
for i in range(3):
|
209
|
-
logger.debug(" ".join(ffmpeg_command))
|
210
|
-
result = subprocess.run(ffmpeg_command)
|
211
|
-
if result.returncode != 0 and i < 2:
|
212
|
-
raise RuntimeError(f"FFmpeg command failed with return code {result.returncode}")
|
213
|
-
else:
|
214
|
-
break
|
215
|
-
except Exception as e:
|
216
|
-
logger.error(f"Error re-encoding screenshot: {e}. Defaulting to standard PNG.")
|
217
|
-
output_image = make_unique_file_name(os.path.join(get_temporary_directory(), f"{obs.get_current_game(sanitize=True)}.png"))
|
218
|
-
shutil.move(image_file, output_image)
|
219
|
-
|
220
|
-
logger.info(f"Processed image saved to: {output_image}")
|
221
|
-
|
222
|
-
return output_image
|
223
|
-
|
224
|
-
|
225
|
-
def get_audio_codec(video_path):
|
226
|
-
command = [
|
227
|
-
f"{get_ffprobe_path()}",
|
228
|
-
"-v", "error",
|
229
|
-
"-select_streams", "a:0",
|
230
|
-
"-show_entries", "stream=codec_name",
|
231
|
-
"-of", "json",
|
232
|
-
video_path
|
233
|
-
]
|
234
|
-
|
235
|
-
logger.debug(" ".join(command))
|
236
|
-
# Run the command and capture the output
|
237
|
-
result = subprocess.run(command, capture_output=True, text=True)
|
238
|
-
|
239
|
-
# Parse the JSON output
|
240
|
-
try:
|
241
|
-
output = json.loads(result.stdout)
|
242
|
-
codec_name = output['streams'][0]['codec_name']
|
243
|
-
return codec_name
|
244
|
-
except (json.JSONDecodeError, KeyError, IndexError):
|
245
|
-
logger.error("Failed to get codec information. Re-encoding Anyways")
|
246
|
-
return None
|
247
|
-
|
248
|
-
|
249
|
-
def get_audio_and_trim(video_path, game_line, next_line_time, anki_card_creation_time):
|
250
|
-
codec = get_audio_codec(video_path)
|
251
|
-
|
252
|
-
if codec == get_config().audio.extension:
|
253
|
-
codec_command = ['-c:a', 'copy']
|
254
|
-
logger.debug(f"Extracting {get_config().audio.extension} from video")
|
255
|
-
else:
|
256
|
-
codec_command = ["-c:a", f"{supported_formats[get_config().audio.extension]}"]
|
257
|
-
logger.debug(f"Re-encoding {codec} to {get_config().audio.extension}")
|
258
|
-
|
259
|
-
untrimmed_audio = tempfile.NamedTemporaryFile(dir=configuration.get_temporary_directory(),
|
260
|
-
suffix=f"_untrimmed.{get_config().audio.extension}").name
|
261
|
-
|
262
|
-
command = ffmpeg_base_command_list + [
|
263
|
-
"-i", video_path,
|
264
|
-
"-map", "0:a"] + codec_command + [
|
265
|
-
untrimmed_audio
|
266
|
-
]
|
267
|
-
|
268
|
-
# FFmpeg command to extract OR re-encode the audio
|
269
|
-
# command = f"{ffmpeg_base_command} -i \"{video_path}\" -map 0:a {codec_command} \"{untrimmed_audio}\""
|
270
|
-
logger.debug("Doing initial audio extraction")
|
271
|
-
logger.debug(" ".join(command))
|
272
|
-
|
273
|
-
subprocess.run(command)
|
274
|
-
|
275
|
-
return trim_audio_based_on_last_line(untrimmed_audio, video_path, game_line, next_line_time, anki_card_creation_time)
|
276
|
-
|
277
|
-
|
278
|
-
def get_video_duration(file_path):
|
279
|
-
ffprobe_command = [
|
280
|
-
f"{get_ffprobe_path()}",
|
281
|
-
"-v", "error",
|
282
|
-
"-show_entries", "format=duration",
|
283
|
-
"-of", "json",
|
284
|
-
file_path
|
285
|
-
]
|
286
|
-
logger.debug(" ".join(ffprobe_command))
|
287
|
-
result = subprocess.run(ffprobe_command, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
|
288
|
-
duration_info = json.loads(result.stdout)
|
289
|
-
logger.debug(f"Video duration: {duration_info}")
|
290
|
-
return float(duration_info["format"]["duration"]) # Return the duration in seconds
|
291
|
-
|
292
|
-
|
293
|
-
def trim_audio_based_on_last_line(untrimmed_audio, video_path, game_line, next_line, anki_card_creation_time):
|
294
|
-
trimmed_audio = tempfile.NamedTemporaryFile(dir=configuration.get_temporary_directory(),
|
295
|
-
suffix=f".{get_config().audio.extension}").name
|
296
|
-
start_trim_time, total_seconds, total_seconds_after_offset, file_length = get_video_timings(video_path, game_line, anki_card_creation_time)
|
297
|
-
end_trim_time = 0
|
298
|
-
end_trim_seconds = 0
|
299
|
-
|
300
|
-
ffmpeg_command = ffmpeg_base_command_list + [
|
301
|
-
"-i", untrimmed_audio,
|
302
|
-
"-ss", str(start_trim_time)]
|
303
|
-
if next_line and next_line > game_line.time:
|
304
|
-
end_trim_seconds = total_seconds + (next_line - game_line.time).total_seconds() + get_config().audio.pre_vad_end_offset
|
305
|
-
end_trim_time = f"{end_trim_seconds:.3f}"
|
306
|
-
ffmpeg_command.extend(['-to', end_trim_time])
|
307
|
-
logger.debug(
|
308
|
-
f"Looks Like this is mining from History, or Multiple Lines were selected Trimming end of audio to {end_trim_time} seconds")
|
309
|
-
elif get_config().audio.pre_vad_end_offset and get_config().audio.pre_vad_end_offset < 0:
|
310
|
-
end_trim_seconds = file_length + get_config().audio.pre_vad_end_offset
|
311
|
-
ffmpeg_command.extend(['-to', str(end_trim_seconds)])
|
312
|
-
logger.debug(f"Trimming end of audio to {end_trim_seconds} seconds due to pre-vad end offset")
|
313
|
-
|
314
|
-
ffmpeg_command.extend([
|
315
|
-
"-c", "copy", # Using copy to avoid re-encoding, adjust if needed
|
316
|
-
trimmed_audio
|
317
|
-
])
|
318
|
-
|
319
|
-
logger.debug(" ".join(ffmpeg_command))
|
320
|
-
subprocess.run(ffmpeg_command)
|
321
|
-
gsm_state.previous_trim_args = (untrimmed_audio, start_trim_time, end_trim_seconds)
|
322
|
-
|
323
|
-
logger.debug(f"{total_seconds_after_offset} trimmed off of beginning")
|
324
|
-
|
325
|
-
if end_trim_seconds:
|
326
|
-
logger.info(f"Audio Extracted and trimmed to {start_trim_time} seconds with end time {end_trim_seconds} seconds")
|
327
|
-
else:
|
328
|
-
logger.info(f"Audio Extracted and trimmed to {start_trim_time} seconds")
|
329
|
-
|
330
|
-
|
331
|
-
logger.debug(f"Audio trimmed and saved to {trimmed_audio}")
|
332
|
-
return trimmed_audio, start_trim_time, end_trim_seconds
|
333
|
-
|
334
|
-
def get_video_timings(video_path, game_line, anki_card_creation_time=None):
|
335
|
-
if anki_card_creation_time:
|
336
|
-
file_mod_time = anki_card_creation_time
|
337
|
-
else:
|
338
|
-
file_mod_time = get_file_modification_time(video_path)
|
339
|
-
file_length = get_video_duration(video_path)
|
340
|
-
time_delta = file_mod_time - game_line.time
|
341
|
-
# Convert time_delta to FFmpeg-friendly format (HH:MM:SS.milliseconds)
|
342
|
-
total_seconds = file_length - time_delta.total_seconds()
|
343
|
-
total_seconds_after_offset = total_seconds + get_config().audio.beginning_offset
|
344
|
-
if total_seconds < 0 or total_seconds >= file_length:
|
345
|
-
logger.error("Line mined is outside of the replay buffer! Defaulting to the beginning of the replay buffer. ")
|
346
|
-
logger.info("Recommend either increasing replay buffer length in OBS Settings or mining faster.")
|
347
|
-
return 0, 0, 0, file_length
|
348
|
-
|
349
|
-
return total_seconds_after_offset, total_seconds, total_seconds_after_offset, file_length
|
350
|
-
|
351
|
-
|
352
|
-
def reencode_file_with_user_config(input_file, final_output_audio, user_ffmpeg_options):
|
353
|
-
logger.debug(f"Re-encode running with settings: {user_ffmpeg_options}")
|
354
|
-
temp_file = create_temp_file_with_same_name(input_file)
|
355
|
-
command = ffmpeg_base_command_list + [
|
356
|
-
"-i", input_file,
|
357
|
-
"-map", "0:a"
|
358
|
-
] + user_ffmpeg_options.replace("\"", "").split(" ") + [
|
359
|
-
temp_file
|
360
|
-
]
|
361
|
-
|
362
|
-
logger.debug(" ".join(command))
|
363
|
-
process = subprocess.run(command)
|
364
|
-
|
365
|
-
if process.returncode != 0:
|
366
|
-
logger.error("Re-encode failed, using original audio")
|
367
|
-
return
|
368
|
-
|
369
|
-
replace_file_with_retry(temp_file, final_output_audio)
|
370
|
-
|
371
|
-
|
372
|
-
def create_temp_file_with_same_name(input_file: str):
|
373
|
-
path = Path(input_file)
|
374
|
-
return str(path.with_name(f"{path.stem}_temp{path.suffix}"))
|
375
|
-
|
376
|
-
|
377
|
-
def replace_file_with_retry(temp_file, input_file, retries=5, delay=1):
|
378
|
-
for attempt in range(retries):
|
379
|
-
try:
|
380
|
-
shutil.move(temp_file, input_file)
|
381
|
-
return
|
382
|
-
except OSError as e:
|
383
|
-
if attempt < retries - 1:
|
384
|
-
logger.warning(f"Attempt {attempt + 1}: File still in use. Retrying in {delay} seconds...")
|
385
|
-
time.sleep(delay)
|
386
|
-
else:
|
387
|
-
logger.error(f"Failed to replace the file after {retries} attempts. Error: {e}")
|
388
|
-
raise
|
389
|
-
|
390
|
-
|
391
|
-
def trim_audio_by_end_time(input_audio, end_time, output_audio):
|
392
|
-
command = ffmpeg_base_command_list + [
|
393
|
-
"-i", input_audio,
|
394
|
-
"-to", str(end_time),
|
395
|
-
"-c", "copy",
|
396
|
-
output_audio
|
397
|
-
]
|
398
|
-
logger.debug(" ".join(command))
|
399
|
-
subprocess.run(command)
|
400
|
-
|
401
|
-
|
402
|
-
def convert_audio_to_wav(input_audio, output_wav):
|
403
|
-
command = ffmpeg_base_command_list + [
|
404
|
-
"-i", input_audio,
|
405
|
-
"-ar", "16000", # Resample to 16kHz
|
406
|
-
"-ac", "1", # Convert to mono
|
407
|
-
"-af", "afftdn,dialoguenhance" if not is_linux() else "afftdn",
|
408
|
-
output_wav
|
409
|
-
]
|
410
|
-
logger.debug(" ".join(command))
|
411
|
-
subprocess.run(command)
|
412
|
-
|
413
|
-
def convert_audio_to_wav_lossless(input_audio):
|
414
|
-
output_wav = make_unique_file_name(
|
415
|
-
os.path.join(configuration.get_temporary_directory(), "output.wav")
|
416
|
-
)
|
417
|
-
command = ffmpeg_base_command_list + [
|
418
|
-
"-i", input_audio,
|
419
|
-
output_wav
|
420
|
-
]
|
421
|
-
logger.debug(" ".join(command))
|
422
|
-
subprocess.run(command)
|
423
|
-
return output_wav
|
424
|
-
|
425
|
-
def convert_audio_to_mp3(input_audio):
|
426
|
-
output_mp3 = make_unique_file_name(
|
427
|
-
os.path.join(configuration.get_temporary_directory(), "output.mp3")
|
428
|
-
)
|
429
|
-
command = ffmpeg_base_command_list + [
|
430
|
-
"-i", input_audio,
|
431
|
-
"-codec:a", "libmp3lame",
|
432
|
-
"-qscale:a", "2", # Quality scale for MP3
|
433
|
-
output_mp3
|
434
|
-
]
|
435
|
-
logger.debug(" ".join(command))
|
436
|
-
subprocess.run(command)
|
437
|
-
return output_mp3
|
438
|
-
|
439
|
-
|
440
|
-
# Trim the audio using FFmpeg based on detected speech timestamps
|
441
|
-
def trim_audio(input_audio, start_time, end_time=0, output_audio=None, trim_beginning=False, fade_in_duration=0.05,
|
442
|
-
fade_out_duration=0.05):
|
443
|
-
command = ffmpeg_base_command_list.copy()
|
444
|
-
|
445
|
-
command.extend(['-i', input_audio])
|
446
|
-
|
447
|
-
if trim_beginning and start_time > 0:
|
448
|
-
logger.debug(f"trimming beginning to {start_time}")
|
449
|
-
command.extend(['-ss', f"{start_time:.2f}"])
|
450
|
-
|
451
|
-
fade_filter = []
|
452
|
-
if fade_in_duration > 0:
|
453
|
-
fade_filter.append(f'afade=t=in:d={fade_in_duration}')
|
454
|
-
if fade_out_duration > 0:
|
455
|
-
fade_filter.append(f'afade=t=out:st={end_time - fade_out_duration:.2f}:d={fade_out_duration}')
|
456
|
-
# fade_filter.append(f'afade=t=out:d={fade_out_duration}')
|
457
|
-
|
458
|
-
if end_time > 0:
|
459
|
-
command.extend([
|
460
|
-
'-to', f"{end_time:.2f}",
|
461
|
-
])
|
462
|
-
|
463
|
-
if fade_filter:
|
464
|
-
command.extend(['-af', f'afade=t=in:d={fade_in_duration},afade=t=out:st={end_time - fade_out_duration:.2f}:d={fade_out_duration}'])
|
465
|
-
command.extend(['-c:a', supported_formats[get_config().audio.extension]])
|
466
|
-
else:
|
467
|
-
command.extend(['-c', 'copy'])
|
468
|
-
|
469
|
-
command.append(output_audio)
|
470
|
-
|
471
|
-
logger.debug(" ".join(command))
|
472
|
-
|
473
|
-
try:
|
474
|
-
subprocess.run(command, check=True)
|
475
|
-
except subprocess.CalledProcessError as e:
|
476
|
-
logger.error(f"FFmpeg command failed with error: {e}")
|
477
|
-
logger.error(f"Command: {' '.join(command)}")
|
478
|
-
except FileNotFoundError:
|
479
|
-
logger.error("FFmpeg not found. Please ensure FFmpeg is installed and in your PATH.")
|
480
|
-
|
481
|
-
|
482
|
-
def combine_audio_files(audio_files, output_file):
|
483
|
-
if not audio_files:
|
484
|
-
logger.error("No audio files provided for combination.")
|
485
|
-
return
|
486
|
-
|
487
|
-
command = ffmpeg_base_command_list + [
|
488
|
-
"-i", "concat:" + "|".join(audio_files),
|
489
|
-
"-c", "copy",
|
490
|
-
output_file
|
491
|
-
]
|
492
|
-
|
493
|
-
logger.debug("Combining audio files with command: " + " ".join(command))
|
494
|
-
|
495
|
-
subprocess.run(command)
|
496
|
-
|
497
|
-
|
498
|
-
def trim_replay_for_gameline(video_path, start_time, end_time, accurate=False):
|
499
|
-
"""
|
500
|
-
Trims the video replay based on the start and end times.
|
501
|
-
|
502
|
-
Offers two modes:
|
503
|
-
1. Fast (default): Uses stream copy. Very fast, no quality loss, but may not be
|
504
|
-
frame-accurate (cut starts at the keyframe before start_time).
|
505
|
-
2. Accurate: Re-encodes the video. Slower, but provides frame-perfect cuts.
|
506
|
-
|
507
|
-
:param video_path: Path to the video file.
|
508
|
-
:param start_time: Start time in seconds.
|
509
|
-
:param end_time: End time in seconds.
|
510
|
-
:param accurate: If True, re-encodes for frame-perfect trimming. Defaults to False.
|
511
|
-
:return: Path to the trimmed video file.
|
512
|
-
"""
|
513
|
-
output_name = f"trimmed_{Path(video_path).stem}.mp4"
|
514
|
-
trimmed_video = make_unique_file_name(
|
515
|
-
os.path.join(configuration.get_temporary_directory(), output_name))
|
516
|
-
|
517
|
-
# We use input seeking for accuracy, as it's faster when re-encoding.
|
518
|
-
# We place -ss before -i for fast seeking.
|
519
|
-
command = ffmpeg_base_command_list + [
|
520
|
-
"-ss", str(start_time),
|
521
|
-
"-i", video_path,
|
522
|
-
]
|
523
|
-
|
524
|
-
# The duration is now more reliable to calculate
|
525
|
-
duration = end_time - start_time
|
526
|
-
if duration > 0:
|
527
|
-
command.extend(["-t", str(duration)])
|
528
|
-
|
529
|
-
if accurate:
|
530
|
-
# Re-encode. Slower but frame-accurate.
|
531
|
-
# You can specify encoding parameters here if needed, e.g., -crf 23
|
532
|
-
command.extend(["-c:v", "libx264", "-preset", "veryfast", "-c:a", "aac"])
|
533
|
-
log_msg = f"Accurately trimming video (re-encoding) {video_path}"
|
534
|
-
else:
|
535
|
-
# Stream copy. Fast but not frame-accurate.
|
536
|
-
command.extend(["-c:v", "copy", "-c:a", "copy"])
|
537
|
-
log_msg = f"Fast trimming video (stream copy) {video_path}"
|
538
|
-
|
539
|
-
command.append(trimmed_video)
|
540
|
-
|
541
|
-
video_length = get_video_duration(video_path)
|
542
|
-
logger.info(f"{log_msg} of length {video_length} from {start_time} to {end_time} seconds.")
|
543
|
-
logger.debug(" ".join(command))
|
544
|
-
|
545
|
-
subprocess.run(command)
|
546
|
-
|
547
|
-
return trimmed_video
|
548
|
-
|
549
|
-
|
550
|
-
def is_video_big_enough(file_path, min_size_kb=250):
|
551
|
-
try:
|
552
|
-
file_size = os.path.getsize(file_path) # Size in bytes
|
553
|
-
file_size_kb = file_size / 1024 # Convert to KB
|
554
|
-
return file_size_kb >= min_size_kb
|
555
|
-
except FileNotFoundError:
|
556
|
-
logger.error("File not found!")
|
557
|
-
return False
|
558
|
-
except Exception as e:
|
559
|
-
logger.error(f"Error: {e}")
|
560
|
-
return False
|
561
|
-
|
562
|
-
|
563
|
-
def get_audio_length(path):
|
564
|
-
result = subprocess.run(
|
565
|
-
[get_ffprobe_path(), "-v", "error", "-show_entries", "format=duration", "-of",
|
566
|
-
"default=noprint_wrappers=1:nokey=1", path],
|
567
|
-
stdout=subprocess.PIPE,
|
568
|
-
stderr=subprocess.PIPE,
|
569
|
-
text=True
|
570
|
-
)
|
571
|
-
return float(result.stdout.strip())
|