Skip to content

Commit 5108f53

Browse files
committed
Add PGS to SRT OCR conversion feature
- Add dropdown menu for PGS subtitle tracks with OCR option - Auto-detect Tesseract OCR on all drives and Windows registry - Add settings panel with dependency status display - Support for converting image-based PGS to editable SRT - Handles language code conversion and environment setup - Includes comprehensive error handling and user guidance
1 parent 250db2f commit 5108f53

File tree

5 files changed

+290
-6
lines changed

5 files changed

+290
-6
lines changed

FastFlix_Windows_OneFile.spec

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,9 @@ all_imports.remove("python-box")
2727
all_imports.append("box")
2828
all_imports.append("iso639")
2929

30+
# Add pgsrip for OCR support
31+
all_imports.extend(["pgsrip", "pytesseract", "cv2", "numpy", "pysrt", "babelfish", "cleanit"])
32+
3033
portable_file = "fastflix\\portable.py"
3134
with open(portable_file, "w") as portable:
3235
portable.write(" ")

fastflix/models/config.py

Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -99,6 +99,77 @@ def where(filename: str, portable_mode=False) -> Path | None:
9999
return None
100100

101101

102+
def find_ocr_tool(name):
103+
"""Find OCR tools (tesseract, mkvmerge, pgsrip) similar to how we find FFmpeg"""
104+
# Check environment variable
105+
if ocr_location := os.getenv(f"FF_{name.upper()}"):
106+
return Path(ocr_location).absolute()
107+
108+
# Check system PATH
109+
if (ocr_location := shutil.which(name)) is not None:
110+
return Path(ocr_location).absolute()
111+
112+
# Special handling for tesseract on Windows (not in PATH by default)
113+
if name == "tesseract" and win_based:
114+
# Check common install locations on all drives
115+
import string
116+
drives = [f"{d}:" for d in string.ascii_uppercase if Path(f"{d}:/").exists()]
117+
118+
for drive in drives:
119+
common_paths = [
120+
Path(f"{drive}/Program Files/Tesseract-OCR/tesseract.exe"),
121+
Path(f"{drive}/Program Files (x86)/Tesseract-OCR/tesseract.exe"),
122+
]
123+
for path in common_paths:
124+
if path.exists():
125+
return path
126+
127+
# Check Windows registry for Tesseract install location
128+
try:
129+
import winreg
130+
# Try HKEY_LOCAL_MACHINE first (system-wide install)
131+
for root_key in [winreg.HKEY_LOCAL_MACHINE, winreg.HKEY_CURRENT_USER]:
132+
try:
133+
key = winreg.OpenKey(root_key, r"SOFTWARE\Tesseract-OCR")
134+
install_path = winreg.QueryValueEx(key, "InstallDir")[0]
135+
winreg.CloseKey(key)
136+
tesseract_exe = Path(install_path) / "tesseract.exe"
137+
if tesseract_exe.exists():
138+
return tesseract_exe
139+
except (FileNotFoundError, OSError):
140+
pass
141+
except ImportError:
142+
pass
143+
144+
# Special handling for mkvmerge on Windows
145+
if name == "mkvmerge" and win_based:
146+
import string
147+
drives = [f"{d}:" for d in string.ascii_uppercase if Path(f"{d}:/").exists()]
148+
149+
for drive in drives:
150+
common_paths = [
151+
Path(f"{drive}/Program Files/MKVToolNix/mkvmerge.exe"),
152+
Path(f"{drive}/Program Files (x86)/MKVToolNix/mkvmerge.exe"),
153+
]
154+
for path in common_paths:
155+
if path.exists():
156+
return path
157+
158+
# Check in FastFlix OCR tools folder
159+
ocr_folder = Path(user_data_dir("FastFlix_OCR", appauthor=False, roaming=True))
160+
if ocr_folder.exists():
161+
for file in ocr_folder.iterdir():
162+
if file.is_file() and file.name.lower() in (name, f"{name}.exe"):
163+
return file
164+
# Check bin subfolder
165+
if (ocr_folder / "bin").exists():
166+
for file in (ocr_folder / "bin").iterdir():
167+
if file.is_file() and file.name.lower() in (name, f"{name}.exe"):
168+
return file
169+
170+
return None
171+
172+
102173
class Config(BaseModel):
103174
version: str = __version__
104175
config_path: Path = Field(default_factory=get_config)
@@ -168,6 +239,13 @@ class Config(BaseModel):
168239

169240
disable_cover_extraction: bool = False
170241

242+
# PGS to SRT OCR Settings
243+
enable_pgs_ocr: bool = False
244+
tesseract_path: Path | None = Field(default_factory=lambda: find_ocr_tool("tesseract"))
245+
mkvmerge_path: Path | None = Field(default_factory=lambda: find_ocr_tool("mkvmerge"))
246+
pgsrip_path: Path | None = Field(default_factory=lambda: find_ocr_tool("pgsrip"))
247+
pgs_ocr_language: str = "eng"
248+
171249
def encoder_opt(self, profile_name, profile_option_name):
172250
encoder_settings = getattr(self.profiles[self.selected_profile], profile_name)
173251
if encoder_settings:

fastflix/widgets/background_tasks.py

Lines changed: 141 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
# -*- coding: utf-8 -*-
33
import logging
44
import os
5+
import shutil
56
from pathlib import Path
67
from subprocess import PIPE, STDOUT, Popen, run, check_output
78
from packaging import version
@@ -46,13 +47,14 @@ def run(self):
4647

4748

4849
class ExtractSubtitleSRT(QtCore.QThread):
49-
def __init__(self, app: FastFlixApp, main, index, signal, language):
50+
def __init__(self, app: FastFlixApp, main, index, signal, language, use_ocr=False):
5051
super().__init__(main)
5152
self.main = main
5253
self.app = app
5354
self.index = index
5455
self.signal = signal
5556
self.language = language
57+
self.use_ocr = use_ocr
5658

5759
def run(self):
5860
subtitle_format = self._get_subtitle_format()
@@ -63,6 +65,9 @@ def run(self):
6365
self.signal.emit()
6466
return
6567

68+
# Flag to track if we need OCR conversion after extraction
69+
should_convert_to_srt = False
70+
6671
if subtitle_format == "srt":
6772
extension = "srt"
6873
output_args = ["-c", "srt", "-f", "srt"]
@@ -75,6 +80,8 @@ def run(self):
7580
elif subtitle_format == "pgs":
7681
extension = "sup"
7782
output_args = ["-c", "copy"]
83+
# If OCR is requested, we'll extract .sup first, then convert after
84+
should_convert_to_srt = self.use_ocr and self.app.fastflix.config.enable_pgs_ocr
7885
else:
7986
self.main.thread_logging_signal.emit(
8087
f"WARNING:{t('Subtitle Track')} {self.index} {t('is not in supported format (SRT, ASS, SSA, PGS), skipping extraction')}: {subtitle_format}"
@@ -115,6 +122,13 @@ def run(self):
115122
)
116123
else:
117124
self.main.thread_logging_signal.emit(f"INFO:{t('Extracted subtitles successfully')}")
125+
126+
# If this is PGS and OCR was requested, convert the .sup to .srt
127+
if subtitle_format == "pgs" and should_convert_to_srt:
128+
if self._convert_sup_to_srt(filename):
129+
self.main.thread_logging_signal.emit(f"INFO:{t('Successfully converted to SRT with OCR')}")
130+
else:
131+
self.main.thread_logging_signal.emit(f"WARNING:{t('OCR conversion failed, kept .sup file')}")
118132
self.signal.emit()
119133

120134
def _get_subtitle_format(self):
@@ -164,6 +178,132 @@ def _get_subtitle_format(self):
164178
)
165179
return None
166180

181+
def _check_pgsrip_dependencies(self) -> bool:
182+
"""Check all required dependencies for pgsrip OCR conversion"""
183+
missing = []
184+
185+
# Check tesseract (auto-detected from PATH or config)
186+
if not self.app.fastflix.config.tesseract_path:
187+
missing.append("tesseract-ocr")
188+
189+
# Check mkvmerge (CRITICAL - required by pgsrip but not documented)
190+
if not self.app.fastflix.config.mkvmerge_path:
191+
missing.append("mkvtoolnix")
192+
193+
# Check pgsrip
194+
if not self.app.fastflix.config.pgsrip_path:
195+
missing.append("pgsrip")
196+
197+
if missing:
198+
self.main.thread_logging_signal.emit(
199+
f"ERROR:{t('Missing dependencies for PGS OCR')}: {', '.join(missing)}\n\n"
200+
f"Install instructions:\n"
201+
f" Windows: Run setup_pgs_ocr_windows.bat in FastFlix folder\n"
202+
f" Linux: sudo apt install tesseract-ocr mkvtoolnix && pip install pgsrip\n"
203+
f" macOS: brew install tesseract mkvtoolnix && pip install pgsrip\n\n"
204+
f"Or download manually:\n"
205+
f" Tesseract: https://github.com/UB-Mannheim/tesseract/wiki\n"
206+
f" MKVToolNix: https://mkvtoolnix.download/downloads.html\n"
207+
f" pgsrip: pip install pgsrip"
208+
)
209+
return False
210+
211+
return True
212+
213+
def _convert_sup_to_srt(self, sup_filepath: str) -> bool:
214+
"""Convert an already-extracted .sup file to .srt using pgsrip OCR
215+
216+
Args:
217+
sup_filepath: Path to the extracted .sup file
218+
219+
Returns:
220+
True if conversion successful, False otherwise
221+
"""
222+
# Check dependencies first
223+
if not self._check_pgsrip_dependencies():
224+
return False
225+
226+
try:
227+
self.main.thread_logging_signal.emit(
228+
f"INFO:{t('Converting .sup to .srt using OCR')} (this may take 3-5 minutes)..."
229+
)
230+
231+
# Convert 3-letter language code to 2-letter for pgsrip
232+
# pgsrip uses 2-letter codes in filenames (e.g., "en" not "eng")
233+
from fastflix.language import Language
234+
try:
235+
lang_2letter = Language(self.language).pt1 # Convert eng -> en
236+
except:
237+
lang_2letter = "en" # Default to English if conversion fails
238+
239+
# Rename .sup file to use 2-letter language code (what pgsrip expects)
240+
sup_path = Path(sup_filepath)
241+
if f".{self.language}." in sup_path.name:
242+
# Replace 3-letter with 2-letter in filename
243+
new_name = sup_path.name.replace(f".{self.language}.", f".{lang_2letter}.")
244+
new_sup_path = sup_path.parent / new_name
245+
sup_path.rename(new_sup_path)
246+
sup_filepath = str(new_sup_path)
247+
248+
# Run pgsrip on the already-extracted .sup file
249+
pgsrip_cmd = str(self.app.fastflix.config.pgsrip_path) if self.app.fastflix.config.pgsrip_path else "pgsrip"
250+
251+
# Set environment variables for pgsrip to find tesseract
252+
import os
253+
env = os.environ.copy()
254+
if self.app.fastflix.config.tesseract_path:
255+
# Add tesseract directory to PATH so pytesseract can find it
256+
tesseract_dir = str(Path(self.app.fastflix.config.tesseract_path).parent)
257+
env['PATH'] = f"{tesseract_dir}{os.pathsep}{env.get('PATH', '')}"
258+
env['TESSERACT_CMD'] = str(self.app.fastflix.config.tesseract_path)
259+
260+
pgsrip_result = run(
261+
[
262+
pgsrip_cmd,
263+
"--language", lang_2letter, # Use 2-letter code (e.g., "en", "es", "fr")
264+
"--force", # Overwrite existing files
265+
sup_filepath
266+
],
267+
capture_output=True,
268+
text=True,
269+
timeout=600, # 10 minute timeout for OCR
270+
env=env # Pass environment with TESSERACT_CMD
271+
)
272+
273+
if pgsrip_result.returncode != 0:
274+
error_msg = pgsrip_result.stderr if pgsrip_result.stderr else pgsrip_result.stdout
275+
raise Exception(f"pgsrip failed with return code {pgsrip_result.returncode}: {error_msg}")
276+
277+
# pgsrip creates .srt file in same directory as .sup file
278+
sup_path = Path(sup_filepath)
279+
expected_srt = sup_path.with_suffix('.srt')
280+
281+
if not expected_srt.exists():
282+
# Look for any .srt file created near the .sup
283+
srt_files = list(sup_path.parent.glob("*.srt"))
284+
if not srt_files:
285+
raise Exception(f"pgsrip completed but no .srt file found in {sup_path.parent}")
286+
expected_srt = srt_files[0]
287+
288+
self.main.thread_logging_signal.emit(
289+
f"INFO:{t('OCR conversion successful')}: {expected_srt.name}"
290+
)
291+
292+
# Optionally delete the .sup file since we have .srt now
293+
try:
294+
sup_path.unlink()
295+
self.main.thread_logging_signal.emit(f"INFO:{t('Removed .sup file, kept .srt')}")
296+
except:
297+
pass
298+
299+
return True
300+
301+
except Exception as err:
302+
self.main.thread_logging_signal.emit(
303+
f"ERROR:{t('OCR conversion failed')}: {err}"
304+
)
305+
return False
306+
167307

168308
class AudioNoramlize(QtCore.QThread):
169309
def __init__(self, app: FastFlixApp, main, audio_type, signal):

fastflix/widgets/panels/subtitle_panel.py

Lines changed: 29 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -106,8 +106,32 @@ def __init__(self, app, parent, index, enabled=True, first=False):
106106
{t("Cannot remove afterwards!")}
107107
"""
108108
)
109-
self.widgets.extract = QtWidgets.QPushButton(t("Extract"))
110-
self.widgets.extract.clicked.connect(self.extract)
109+
110+
# Setup extract button with OCR option for PGS subtitles
111+
if sub_track.subtitle_type == "pgs":
112+
self.widgets.extract = QtWidgets.QPushButton(t("Extract"))
113+
extract_menu = QtWidgets.QMenu(self)
114+
115+
# Always offer .sup extraction (fast, no dependencies)
116+
extract_menu.addAction(t("Extract as .sup (image - fast)"), lambda: self.extract(use_ocr=False))
117+
118+
# Check if OCR dependencies are available
119+
ocr_action = extract_menu.addAction(t("Convert to .srt (OCR - 3-5 min)"), lambda: self.extract(use_ocr=True))
120+
121+
# Enable OCR option only if user enabled it AND dependencies are available
122+
if not self.app.fastflix.config.enable_pgs_ocr:
123+
ocr_action.setEnabled(False)
124+
ocr_action.setToolTip(t("Enable in Settings > 'Enable PGS to SRT OCR conversion'"))
125+
elif not (self.app.fastflix.config.tesseract_path and
126+
self.app.fastflix.config.mkvmerge_path and
127+
self.app.fastflix.config.pgsrip_path):
128+
ocr_action.setEnabled(False)
129+
ocr_action.setToolTip(t("Missing dependencies: tesseract, mkvtoolnix, or pgsrip"))
130+
131+
self.widgets.extract.setMenu(extract_menu)
132+
else:
133+
self.widgets.extract = QtWidgets.QPushButton(t("Extract"))
134+
self.widgets.extract.clicked.connect(self.extract)
111135

112136
self.gif_label = QtWidgets.QLabel(self)
113137
self.movie = QtGui.QMovie(loading_movie)
@@ -167,9 +191,10 @@ def init_move_buttons(self):
167191
layout.addWidget(self.widgets.down_button)
168192
return layout
169193

170-
def extract(self):
194+
def extract(self, use_ocr=False):
171195
worker = ExtractSubtitleSRT(
172-
self.parent.app, self.parent.main, self.index, self.extract_completed_signal, language=self.language
196+
self.parent.app, self.parent.main, self.index, self.extract_completed_signal,
197+
language=self.language, use_ocr=use_ocr
173198
)
174199
worker.start()
175200
self.gif_label.show()

0 commit comments

Comments
 (0)