pc translate sentences

This commit is contained in:
2026-10-08 10:08:16 +08:00
parent b28f809c46
commit 4746331515
8 changed files with 714 additions and 4 deletions

View File

@@ -16,6 +16,7 @@
Ctrl+↑ / ↓ 快进步长 +1 / -1 秒(macOS 亦支持 Command+↑ / ↓)
, / . 上一句 / 下一句
勾选“按句跳转”后,←/→ 也会自动播放上一句/下一句
C 开 / 关中文字幕
E 讲解当前句子(AI)
M 静音 / 恢复
Ctrl+← / → 上一首 / 下一首(macOS 也支持 Command+← / →)
@@ -225,6 +226,15 @@ def fmt_time(ms):
return f"{minutes:02d}:{seconds:02d}"
def _srt_timestamp(ms):
"""把毫秒格式化为 SRT 时间戳。"""
total = max(0, int(ms))
hours, remainder = divmod(total, 3_600_000)
minutes, remainder = divmod(remainder, 60_000)
seconds, millis = divmod(remainder, 1000)
return f"{hours:02d}:{minutes:02d}:{seconds:02d},{millis:03d}"
class _BaseEngine:
"""播放内核统一接口。"""
@@ -270,6 +280,9 @@ class _BaseEngine:
def embed(self, window_id):
return False
def set_subtitle_file(self, path):
return False
def get_frame(self):
"""返回当前应显示的帧(PIL 图像),没有新帧时返回 None。"""
return None
@@ -371,6 +384,31 @@ class VlcEngine(_BaseEngine):
self.player.set_xwindow(int(window_id))
return True
def set_subtitle_file(self, path):
if not self.is_active():
return False
try:
if not path:
try:
self.player.video_set_spu(-1)
except Exception:
pass
return True
uri = Path(path).resolve().as_uri()
slave_type = getattr(getattr(vlc, "MediaSlaveType", None), "subtitle", 0)
try:
result = self.player.add_slave(slave_type, uri, True)
if result == 0:
return True
except Exception:
pass
setter = getattr(self.player, "video_set_subtitle_file", None)
if setter is None:
return False
return bool(setter(str(path)))
except Exception:
return False
def release(self):
try:
self.player.stop()
@@ -977,6 +1015,7 @@ class MediaPlayerApp(tk.Tk):
self.seek_step = settings["seek_step"]
# Sentence mode is always re-enabled manually after boundaries exist.
self.sentence_mode = tk.BooleanVar(value=False)
self.show_chinese_subtitles = tk.BooleanVar(value=bool(settings.get("show_chinese_subtitles", False)))
self.server_url = settings["server_url"].rstrip("/")
self.auth_token = settings["auth_token"]
self.auth_username = settings["auth_username"]
@@ -999,6 +1038,12 @@ class MediaPlayerApp(tk.Tk):
self._explanation_text = None
self._explanation_debug_var = None
self._explanation_generation = 0
self._translation_generation = 0
self._translation_running = set()
self._translation_loading = False
self._translation_error = ""
self._subtitle_srt_key = None
self._overlay_key = None
self.engine = self._create_engine()
if self.engine is None:
@@ -1097,7 +1142,33 @@ class MediaPlayerApp(tk.Tk):
hint_text = "打开文件开始播放\n(视频将在独立窗口中显示)"
self.video_hint = tk.Label(self.video_frame, text=hint_text, bg="#101010", fg="#8a8a8a")
self.video_hint.place(relx=0.5, rely=0.5, anchor="center")
self.subtitle_overlay = tk.Frame(self.video_frame, bg="#000000", padx=10, pady=8)
self.subtitle_en = tk.Label(
self.subtitle_overlay,
text="",
fg="#ffffff",
bg="#000000",
font=("Helvetica", 16, "bold"),
wraplength=720,
justify="center",
)
self.subtitle_en.pack(fill="x")
self.subtitle_zh = tk.Label(
self.subtitle_overlay,
text="",
fg="#ffd54a",
bg="#000000",
font=("Helvetica", 14),
wraplength=720,
justify="center",
)
self.subtitle_zh.pack(fill="x", pady=(4, 0))
self.video_frame.bind("<Double-Button-1>", self._on_video_double_click)
self.video_frame.bind("<Configure>", self._on_video_resize)
if getattr(self, "video_canvas", None) is not None:
self.video_canvas.bind("<Double-Button-1>", self._on_video_double_click)
for widget in (self.subtitle_overlay, self.subtitle_en, self.subtitle_zh):
widget.bind("<Double-Button-1>", self._on_video_double_click)
side = ttk.Frame(main, width=260)
side.grid(row=0, column=1, sticky="ns")
@@ -1165,6 +1236,15 @@ class MediaPlayerApp(tk.Tk):
# 点击 Checkbutton 后 tk 仍会把焦点给它;把空格键重定向到播放/暂停,
# 避免误改复选按钮状态。
self.sentence_mode_btn.bind("<space>", self._on_space)
self.chinese_subtitle_btn = ttk.Checkbutton(
controls,
text="中文字幕",
variable=self.show_chinese_subtitles,
command=self._on_chinese_subtitles,
takefocus=0,
)
self.chinese_subtitle_btn.pack(side="left", padx=(12, 0))
self.chinese_subtitle_btn.bind("<space>", self._on_space)
self.btn_generate_boundaries = ttk.Button(
controls,
text="生成句边界",
@@ -1230,6 +1310,8 @@ class MediaPlayerApp(tk.Tk):
("<bracketright>", lambda _e: self.step_speed(1)),
("<comma>", lambda _e: self.seek_sentence(-1)),
("<period>", lambda _e: self.seek_sentence(1)),
("<c>", lambda _e: self.toggle_chinese_subtitles()),
("<C>", lambda _e: self.toggle_chinese_subtitles()),
("<e>", lambda _e: self.show_ai_explanation()),
("<E>", lambda _e: self.show_ai_explanation()),
("<a>", lambda _e: self.show_ai_explanation()),
@@ -1324,6 +1406,7 @@ class MediaPlayerApp(tk.Tk):
sentence = self._current_sentence()
text = str(sentence.get("text") or "").strip() if sentence else ""
self.sentence_var.set(text)
self._update_on_screen_subtitles()
playing = self.engine.is_playing()
self.btn_play.config(text="暂停" if playing else "播放")
self.after(33, self._tick)
@@ -1348,6 +1431,8 @@ class MediaPlayerApp(tk.Tk):
self._photo = ImageTk.PhotoImage(img)
canvas.delete("all")
canvas.create_image(width // 2, height // 2, image=self._photo, anchor="center")
if getattr(self, "subtitle_overlay", None) is not None:
self.subtitle_overlay.lift()
def open_files(self):
filetypes = [
@@ -1411,6 +1496,7 @@ class MediaPlayerApp(tk.Tk):
return {
"seek_step": step,
"sentence_mode": bool(data.get("sentence_mode", False)),
"show_chinese_subtitles": bool(data.get("show_chinese_subtitles", False)),
"server_url": server_url,
"auth_token": str(data.get("auth_token") or "").strip(),
"auth_username": str(data.get("auth_username") or "").strip(),
@@ -1424,6 +1510,7 @@ class MediaPlayerApp(tk.Tk):
{
"seek_step": self.seek_step,
"sentence_mode": bool(self.sentence_mode.get()),
"show_chinese_subtitles": bool(self.show_chinese_subtitles.get()),
"server_url": self.server_url,
"auth_token": self.auth_token,
"auth_username": self.auth_username,
@@ -1510,6 +1597,13 @@ class MediaPlayerApp(tk.Tk):
self.status_var.set("请先生成句子边界")
self._load_local_boundaries_async(path)
self._update_sentence_controls()
self._translation_generation += 1
self._translation_loading = False
self._translation_error = ""
self._overlay_key = None
self._subtitle_srt_key = None
self._ensure_chinese_translations()
self.after(400, self._sync_vlc_subtitles)
def toggle_play(self):
if not self.playlist:
@@ -1526,6 +1620,9 @@ class MediaPlayerApp(tk.Tk):
self._seeking = False
self.time_var.set("00:00 / --:--")
self.sentence_var.set("")
self._overlay_key = None
self._subtitle_srt_key = None
self._update_on_screen_subtitles()
self.seekbar.set_frac(0.0)
self.btn_play.config(text="播放")
canvas = getattr(self, "video_canvas", None)
@@ -1707,11 +1804,13 @@ class MediaPlayerApp(tk.Tk):
def _finish_local_boundary_load(self, path, message):
self._local_cache_loading.discard(path)
if path in self._analysis_running or path in self._boundaries:
if path in self._analysis_running:
return
if path == self._current_path():
self.status_var.set(message)
self._update_sentence_controls()
self._ensure_chinese_translations()
self._sync_vlc_subtitles()
def _update_sentence_controls(self):
path = self._current_path()
@@ -1762,6 +1861,22 @@ class MediaPlayerApp(tk.Tk):
count = len(self._boundaries[path]["sentences"])
self.status_var.set(f"按句跳转已开启(识别到 {count} 句)")
def toggle_chinese_subtitles(self):
self.show_chinese_subtitles.set(not self.show_chinese_subtitles.get())
self._on_chinese_subtitles()
def _on_chinese_subtitles(self):
self._save_settings()
if self.show_chinese_subtitles.get():
self._translation_error = ""
self._ensure_chinese_translations()
self.status_var.set("中文字幕已开启")
else:
self.status_var.set("中文字幕已关闭")
self._overlay_key = None
self._update_on_screen_subtitles()
self._sync_vlc_subtitles(force=True)
def generate_sentence_boundaries(self):
path = self._current_path()
if path is None:
@@ -1901,6 +2016,8 @@ class MediaPlayerApp(tk.Tk):
)
self._report_progress(None, None, indeterminate=False)
self._ui_events.put(lambda: self._update_sentence_controls())
self._ui_events.put(self._ensure_chinese_translations)
self._ui_events.put(self._sync_vlc_subtitles)
self._analysis_done.put((path, message))
def _boundary_cache_path(self, media_hash: str) -> Path:
@@ -1920,12 +2037,14 @@ class MediaPlayerApp(tk.Tk):
end = int(item["end_ms"])
if end <= start or start < previous_end:
raise ValueError("invalid cached boundary")
zh_text = item.get("zh_text")
cleaned.append({
"index": index,
"start_ms": start,
"end_ms": end,
"text": (str(item["text"]) if item.get("text") is not None else None),
"language": item.get("language"),
"zh_text": (str(zh_text).strip() if zh_text else None) or None,
})
previous_end = end
if not cleaned:
@@ -2094,6 +2213,368 @@ class MediaPlayerApp(tk.Tk):
detail = str(detail)
return detail if len(detail) <= 300 else detail[:297] + "..."
def _on_video_resize(self, event):
if event.widget is not getattr(self, "video_frame", None):
return
wrap = max(200, int(event.width) - 48)
if getattr(self, "subtitle_en", None) is not None:
self.subtitle_en.configure(wraplength=wrap)
if getattr(self, "subtitle_zh", None) is not None:
self.subtitle_zh.configure(wraplength=wrap)
def _subtitle_sentence(self) -> Optional[Dict[str, Any]]:
sentence = self._current_sentence()
if not sentence:
return None
pos = self.engine.get_time()
if pos < 0:
return None
start_ms = int(sentence.get("start_ms") or 0)
end_ms = int(sentence.get("end_ms") or 0)
if pos + 180 < start_ms or pos > end_ms + 180:
return None
return sentence
def _update_on_screen_subtitles(self):
overlay = getattr(self, "subtitle_overlay", None)
if overlay is None:
return
sentence = self._subtitle_sentence()
english = str(sentence.get("text") or "").strip() if sentence else ""
chinese = ""
if self.show_chinese_subtitles.get() and english:
chinese = str((sentence or {}).get("zh_text") or "").strip()
if not chinese:
if self._translation_error:
chinese = self._translation_error
elif self._translation_loading:
chinese = "正在翻译中文字幕…"
else:
chinese = "暂无中文字幕"
key = (english, chinese, bool(self.show_chinese_subtitles.get()))
if key == self._overlay_key:
return
self._overlay_key = key
try:
self.subtitle_en.configure(text=english)
if chinese:
self.subtitle_zh.configure(text=chinese)
if not self.subtitle_zh.winfo_ismapped():
self.subtitle_zh.pack(fill="x", pady=(4, 0))
elif self.subtitle_zh.winfo_ismapped():
self.subtitle_zh.pack_forget()
if english or chinese:
overlay.place(relx=0.5, rely=0.97, anchor="s", relwidth=0.92)
overlay.lift()
else:
overlay.place_forget()
except tk.TclError:
pass
def _build_srt_content(self, sentences, include_chinese: bool) -> str:
blocks = []
number = 1
for sentence in sentences:
english = str(sentence.get("text") or "").strip()
chinese = str(sentence.get("zh_text") or "").strip() if include_chinese else ""
if not english and not chinese:
continue
lines = []
if english:
lines.append(english)
if chinese:
lines.append(chinese)
start = _srt_timestamp(sentence.get("start_ms") or 0)
end = _srt_timestamp(sentence.get("end_ms") or 0)
blocks.append(f"{number}\n{start} --> {end}\n" + "\n".join(lines))
number += 1
return "\n\n".join(blocks) + ("\n" if blocks else "")
def _sync_vlc_subtitles(self, force=False):
if not isinstance(self.engine, VlcEngine):
return
path = self._current_path()
sentences = (self._boundaries.get(path) or {}).get("sentences") or []
include_chinese = bool(self.show_chinese_subtitles.get())
fingerprint = (
path,
include_chinese,
tuple(
(
sentence.get("index"),
sentence.get("start_ms"),
sentence.get("end_ms"),
sentence.get("text"),
sentence.get("zh_text") if include_chinese else None,
)
for sentence in sentences
),
)
if not force and fingerprint == self._subtitle_srt_key:
return
self._subtitle_srt_key = fingerprint
media_hash = self._media_hashes.get(path) if path else None
if not sentences or not media_hash:
self.engine.set_subtitle_file(None)
return
srt_path = Path.home() / ".mediaplayer_sentence_boundaries" / f"{media_hash}.srt"
try:
srt_path.parent.mkdir(parents=True, exist_ok=True)
content = self._build_srt_content(sentences, include_chinese)
srt_path.write_text("\ufeff" + content, encoding="utf-8")
self.engine.set_subtitle_file(str(srt_path))
except Exception as exc:
debug_log("subtitle_srt_failed", error=str(exc))
def _ensure_chinese_translations(self):
if not self.show_chinese_subtitles.get():
return
path = self._current_path()
if not path:
return
sentences = (self._boundaries.get(path) or {}).get("sentences") or []
if not sentences:
return
missing = [
sentence
for sentence in sentences
if str(sentence.get("text") or "").strip() and not str(sentence.get("zh_text") or "").strip()
]
if not missing:
self._translation_loading = False
self._translation_error = ""
return
media_hash = self._media_hashes.get(path)
if not media_hash:
return
if not self.auth_token:
self._translation_error = "请先登录后再加载中文字幕"
self._overlay_key = None
self._update_on_screen_subtitles()
return
if path in self._translation_running:
return
self._translation_generation += 1
generation = self._translation_generation
self._translation_running.add(path)
self._translation_loading = True
self._translation_error = ""
self._overlay_key = None
self._update_on_screen_subtitles()
expected = [int(sentence["index"]) for sentence in missing]
local_payload = self._local_subtitle_payload(path)
threading.Thread(
target=self._load_translations_worker,
args=(path, media_hash, expected, generation, local_payload),
daemon=True,
).start()
def _local_subtitle_payload(self, path) -> Optional[Dict[str, Any]]:
document = self._boundaries.get(path) or {}
sentences = document.get("sentences") or []
cleaned = []
previous_end = 0
last_end = 0
for expected_index, sentence in enumerate(sentences):
try:
start_ms = max(0, int(sentence.get("start_ms") or 0))
end_ms = int(sentence.get("end_ms") or 0)
except (TypeError, ValueError):
return None
if end_ms <= start_ms or start_ms < previous_end:
return None
item = {
"index": expected_index,
"start_ms": start_ms,
"end_ms": end_ms,
"text": (str(sentence.get("text") or "").strip() or None),
}
language = sentence.get("language")
if language:
item["language"] = str(language)
cleaned.append(item)
previous_end = end_ms
last_end = end_ms
if not cleaned:
return None
try:
duration_ms = int(document.get("duration_ms") or 0)
except (TypeError, ValueError):
duration_ms = 0
if duration_ms < last_end:
duration_ms = last_end
return {
"duration_ms": duration_ms,
"algorithm_version": str(document.get("algorithm_version") or "local-pc"),
"sentences": cleaned,
}
def _load_translations_worker(self, path, media_hash, expected, generation, local_payload=None):
translations = {}
error = ""
complete = False
cancelled = False
try:
deadline = time.monotonic() + 15 * 60
kick_job = True
expected_set = set(expected)
last_count = -1
stalled = 0
while time.monotonic() < deadline:
if generation != self._translation_generation:
cancelled = True
break
payload = self._request_subtitle_translation(media_hash, "GET")
posted = None
if payload is not None:
translations.update(payload.get("translations") or {})
complete = bool(payload.get("complete")) or (
expected_set and expected_set.issubset(translations)
)
self._ui_events.put(
lambda trans=dict(translations), done=complete: self._apply_translations(
path, trans, done, generation=generation
)
)
if complete:
break
if payload.get("status") == "error":
error = str(payload.get("error") or "中文字幕翻译失败")
if len(translations) > last_count:
last_count = len(translations)
stalled = 0
else:
stalled += 1
if payload.get("status") != "running" and stalled >= 2:
kick_job = True
else:
kick_job = True
if kick_job:
posted = self._request_subtitle_translation(media_hash, "POST")
if posted is None and local_payload:
posted = self._request_subtitle_translation(
media_hash, "POST", payload=local_payload
)
kick_job = False
if posted is not None:
translations.update(posted.get("translations") or {})
complete = bool(posted.get("complete")) or (
expected_set and expected_set.issubset(translations)
)
self._ui_events.put(
lambda trans=dict(translations), done=complete: self._apply_translations(
path, trans, done, generation=generation
)
)
if complete:
break
if posted.get("status") == "error":
error = str(posted.get("error") or "中文字幕翻译失败")
if payload is None and posted is None:
error = "无法上传英文字幕到服务器"
break
if complete:
break
time.sleep(1.5)
if not complete and not cancelled:
error = error or "中文字幕翻译超时"
except Exception as exc:
error = str(exc) or exc.__class__.__name__
debug_log(
"subtitle_translation_failed",
media_hash=media_hash,
error_class=exc.__class__.__name__,
error=error,
)
self._ui_events.put(lambda: self._translation_running.discard(path))
if cancelled or generation != self._translation_generation:
return
if not complete:
message = error or "中文字幕翻译失败"
self._ui_events.put(
lambda: self._apply_translations(
path, translations, False, error=message, generation=generation
)
)
def _request_subtitle_translation(self, media_hash, method: str, payload=None):
request_url = self.server_url + f"/api/v1/videos/{media_hash}/subtitle-translation"
headers = {"User-Agent": CLIENT_NAME, "Accept": "application/json"}
if self.auth_token:
headers["Authorization"] = f"Bearer {self.auth_token}"
timeout = (15, 30 if method == "POST" else 15)
request_kwargs = {
"method": method,
"url": request_url,
"headers": headers,
"timeout": timeout,
}
if payload is not None:
request_kwargs["json"] = payload
response = requests.request(**request_kwargs)
if response.status_code == 401:
raise RuntimeError("请先在“服务…”中登录")
if response.status_code == 404:
return None
if response.status_code not in range(200, 300):
raise RuntimeError(
f"中文字幕接口返回 {response.status_code}:{self._server_error_detail(response)}"
)
data = response.json()
items = data.get("translations") or []
translations = {}
for item in items:
try:
index = int(item.get("index"))
except (TypeError, ValueError, AttributeError):
continue
zh_text = str(item.get("zh_text") or "").strip()
if zh_text:
translations[index] = zh_text
return {
"translations": translations,
"complete": bool(data.get("complete")),
"status": str(data.get("status") or ""),
"error": str(data.get("error") or ""),
}
def _apply_translations(self, path, translations, complete, error="", generation=None):
if generation is not None and generation != self._translation_generation:
return
document = self._boundaries.get(path)
if not document:
return
sentences = document.get("sentences") or []
changed = False
for sentence in sentences:
try:
index = int(sentence.get("index"))
except (TypeError, ValueError):
continue
zh_text = str(translations.get(index) or "").strip()
if zh_text and sentence.get("zh_text") != zh_text:
sentence["zh_text"] = zh_text
changed = True
if changed:
media_hash = self._media_hashes.get(path)
if media_hash:
try:
self._save_cached_boundaries(media_hash, document)
except Exception:
pass
self._sync_vlc_subtitles(force=True)
if complete:
self._translation_loading = False
self._translation_error = ""
if path == self._current_path() and self.show_chinese_subtitles.get():
self.status_var.set("已加载中文字幕")
elif error:
self._translation_loading = False
self._translation_error = error
self._overlay_key = None
if path == self._current_path():
self._update_on_screen_subtitles()
def _current_sentence(self) -> Optional[Dict[str, Any]]:
path = self._current_path()
if path is None:
@@ -2753,6 +3234,7 @@ class MediaPlayerApp(tk.Tk):
self.status_var.set("播放结束")
def _on_close(self):
self._translation_generation += 1
self._save_playlist()
self.engine.release()
self.destroy()