GameSentenceMiner 2.14.16__py3-none-any.whl → 2.14.17__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 +1 -1
- GameSentenceMiner/anki.py +107 -60
- GameSentenceMiner/config_gui.py +25 -7
- GameSentenceMiner/gsm.py +2 -1
- GameSentenceMiner/locales/en_us.json +8 -0
- GameSentenceMiner/locales/ja_jp.json +8 -0
- GameSentenceMiner/locales/zh_cn.json +8 -0
- GameSentenceMiner/obs.py +72 -10
- GameSentenceMiner/util/configuration.py +4 -2
- GameSentenceMiner/util/ffmpeg.py +153 -0
- GameSentenceMiner/web/templates/index.html +11 -11
- {gamesentenceminer-2.14.16.dist-info → gamesentenceminer-2.14.17.dist-info}/METADATA +1 -1
- {gamesentenceminer-2.14.16.dist-info → gamesentenceminer-2.14.17.dist-info}/RECORD +17 -17
- {gamesentenceminer-2.14.16.dist-info → gamesentenceminer-2.14.17.dist-info}/WHEEL +0 -0
- {gamesentenceminer-2.14.16.dist-info → gamesentenceminer-2.14.17.dist-info}/entry_points.txt +0 -0
- {gamesentenceminer-2.14.16.dist-info → gamesentenceminer-2.14.17.dist-info}/licenses/LICENSE +0 -0
- {gamesentenceminer-2.14.16.dist-info → gamesentenceminer-2.14.17.dist-info}/top_level.txt +0 -0
GameSentenceMiner/util/ffmpeg.py
CHANGED
@@ -27,6 +27,148 @@ supported_formats = {
|
|
27
27
|
'm4a': 'aac',
|
28
28
|
}
|
29
29
|
|
30
|
+
import subprocess
|
31
|
+
from pathlib import Path
|
32
|
+
import shutil
|
33
|
+
|
34
|
+
|
35
|
+
def video_to_anim(
|
36
|
+
input_path: str | Path,
|
37
|
+
output_path: str | Path = None,
|
38
|
+
codec: str = "webp", # "webp" or "avif" (ignored if audio=True)
|
39
|
+
start: str = None, # e.g. "00:00:12.5"
|
40
|
+
duration: float = None, # seconds
|
41
|
+
fps: int = 12,
|
42
|
+
max_width: int = 960,
|
43
|
+
max_height: int = None,
|
44
|
+
quality: int = 65, # 0..100, for avif: 0 (lossless) to 63 (worst), for webm: CRF value
|
45
|
+
compression_level: int = 6, # for webp: 0..6 (ignored for audio)
|
46
|
+
preset: str = "picture", # for webp (ignored for audio)
|
47
|
+
loop: int = 0, # for webp: 0=infinite (ignored for audio)
|
48
|
+
crop: str = None, # e.g. "1280:720:0:140"
|
49
|
+
extra_vf: list[str] = None,
|
50
|
+
audio: bool = None # whether to include audio, outputs WebM with VP9/Opus
|
51
|
+
) -> Path:
|
52
|
+
"""Convert video to efficient animated WebP/AVIF or WebM with audio using ffmpeg.
|
53
|
+
|
54
|
+
When audio=True, outputs WebM format with VP9 video codec and Opus audio codec.
|
55
|
+
The codec parameter is ignored when audio=True.
|
56
|
+
"""
|
57
|
+
|
58
|
+
if shutil.which("ffmpeg") is None:
|
59
|
+
raise RuntimeError("ffmpeg not found on PATH.")
|
60
|
+
|
61
|
+
codec = codec.lower()
|
62
|
+
if codec not in {"webp", "avif"}:
|
63
|
+
raise ValueError("codec must be 'webp' or 'avif'")
|
64
|
+
|
65
|
+
input_path = Path(input_path)
|
66
|
+
if not input_path.exists():
|
67
|
+
raise FileNotFoundError(f"Input not found: {input_path}")
|
68
|
+
|
69
|
+
# Default output path
|
70
|
+
if output_path:
|
71
|
+
output_path = Path(output_path)
|
72
|
+
else:
|
73
|
+
if audio:
|
74
|
+
ext = ".webm"
|
75
|
+
else:
|
76
|
+
ext = ".webp" if codec == "webp" else ".avif"
|
77
|
+
output_path = input_path.with_suffix(ext)
|
78
|
+
|
79
|
+
# Ensure correct extension
|
80
|
+
if audio:
|
81
|
+
correct_ext = ".webm"
|
82
|
+
else:
|
83
|
+
correct_ext = ".webp" if codec == "webp" else ".avif"
|
84
|
+
if output_path.suffix.lower() != correct_ext:
|
85
|
+
output_path = output_path.with_suffix(correct_ext)
|
86
|
+
|
87
|
+
# Build filter chain
|
88
|
+
vf_parts = []
|
89
|
+
if fps:
|
90
|
+
vf_parts.append(f"fps={fps}")
|
91
|
+
if crop:
|
92
|
+
vf_parts.append(f"crop={crop}")
|
93
|
+
if max_width and max_height:
|
94
|
+
vf_parts.append(f"scale='min({max_width},iw)':min({max_height},ih):force_original_aspect_ratio=decrease")
|
95
|
+
elif max_width:
|
96
|
+
vf_parts.append(f"scale={max_width}:-1")
|
97
|
+
elif max_height:
|
98
|
+
vf_parts.append(f"scale=-1:{max_height}")
|
99
|
+
vf_parts.append("pad=ceil(iw/2)*2:ceil(ih/2)*2") # ensure even dimensions
|
100
|
+
if extra_vf:
|
101
|
+
vf_parts.extend(extra_vf)
|
102
|
+
|
103
|
+
# ffmpeg command base
|
104
|
+
cmd = ffmpeg_base_command_list.copy()
|
105
|
+
if start:
|
106
|
+
cmd += ["-ss", str(start)]
|
107
|
+
cmd += ["-i", str(input_path)]
|
108
|
+
if duration:
|
109
|
+
cmd += ["-t", str(duration)]
|
110
|
+
|
111
|
+
# Add video filters
|
112
|
+
cmd += ["-vf", ",".join(vf_parts)]
|
113
|
+
|
114
|
+
# Only add -an (no audio) if we're not including audio
|
115
|
+
if not audio:
|
116
|
+
cmd += ["-an"]
|
117
|
+
|
118
|
+
# Codec-specific settings
|
119
|
+
if audio:
|
120
|
+
# For WebM with audio, use VP9 for video and Opus for audio
|
121
|
+
# For WebM with audio, use AV1 (AVIF) for video and Opus for audio
|
122
|
+
cmd += [
|
123
|
+
"-c:v", "libaom-av1", # AV1 codec (used for AVIF images, but supported in WebM video)
|
124
|
+
"-crf", str(quality), # AV1 CRF scale (0 lossless - 63 worst)
|
125
|
+
"-pix_fmt", "yuv420p", # yuv420p for compatibility
|
126
|
+
"-cpu-used", "6", # speed/quality trade-off
|
127
|
+
"-c:a", "libopus", # use opus codec for audio
|
128
|
+
"-b:a", "128k", # audio bitrate
|
129
|
+
"-f", "webm", # output format webm
|
130
|
+
]
|
131
|
+
elif codec == "webp":
|
132
|
+
cmd += [
|
133
|
+
"-c:v", "libwebp",
|
134
|
+
"-lossless", "0",
|
135
|
+
"-q:v", str(quality),
|
136
|
+
"-compression_level", str(compression_level),
|
137
|
+
"-preset", preset,
|
138
|
+
"-loop", str(loop),
|
139
|
+
"-threads", "0",
|
140
|
+
]
|
141
|
+
elif codec == "avif":
|
142
|
+
cmd += [
|
143
|
+
"-c:v", "libaom-av1",
|
144
|
+
"-cpu-used", "6", # speed/quality trade-off
|
145
|
+
"-crf", str(quality), # AV1 CRF scale (0 lossless - 63 worst)
|
146
|
+
"-pix_fmt", "yuv420p", # yuv420p for better compatibility
|
147
|
+
]
|
148
|
+
|
149
|
+
cmd.append(str(output_path))
|
150
|
+
|
151
|
+
subprocess.run(cmd, check=True)
|
152
|
+
return str(output_path)
|
153
|
+
|
154
|
+
def video_to_animation_with_start_end(video_path: str | Path, start: float, end: float, **kwargs) -> Path:
|
155
|
+
"""Convert video to animation using start and end time strings."""
|
156
|
+
from datetime import datetime, timedelta
|
157
|
+
|
158
|
+
if end < start:
|
159
|
+
raise ValueError("end time must be after start time")
|
160
|
+
duration = end - start
|
161
|
+
|
162
|
+
return video_to_anim(
|
163
|
+
input_path=video_path,
|
164
|
+
start=start,
|
165
|
+
duration=duration,
|
166
|
+
**kwargs
|
167
|
+
)
|
168
|
+
|
169
|
+
|
170
|
+
# video_to_anim(r"C:\Users\Beangate\Videos\GSM\Output\ゴシップ\trimmed_GSM 2025-08-14 21-57-08_2025-08-14-21-57-12-654.mp4", codec="avif", quality=30, fps=30)
|
171
|
+
|
30
172
|
def call_frame_extractor(video_path, timestamp):
|
31
173
|
"""
|
32
174
|
Calls the video frame extractor script and captures the output.
|
@@ -64,6 +206,17 @@ def call_frame_extractor(video_path, timestamp):
|
|
64
206
|
logger.error(f"An unexpected error occurred: {e}")
|
65
207
|
return None
|
66
208
|
|
209
|
+
# def get_animated_screenshot(video_file, screenshot_timing, vad_start, vad_end):
|
210
|
+
# screenshot_timing = screenshot_timing if screenshot_timing else 1
|
211
|
+
# animated_ss = video_to_animation_with_start_end(video_file, screenshot_timing + vad_start, screenshot_timing + vad_end)
|
212
|
+
# return animated_ss
|
213
|
+
|
214
|
+
def get_anki_compatible_video(video_file, screenshot_timing, vad_start, vad_end, **kwargs):
|
215
|
+
screenshot_timing = screenshot_timing if screenshot_timing else 1
|
216
|
+
animated_ss = video_to_animation_with_start_end(video_file, screenshot_timing + vad_start, screenshot_timing + vad_end, **kwargs)
|
217
|
+
return animated_ss
|
218
|
+
|
219
|
+
|
67
220
|
def get_screenshot(video_file, screenshot_timing, try_selector=False):
|
68
221
|
screenshot_timing = screenshot_timing if screenshot_timing else 1
|
69
222
|
if try_selector:
|