pc translate sentences
This commit is contained in:
@@ -14,6 +14,8 @@ pip 安装依赖后即可直接播放视频。
|
|||||||
视频的句边界;有则直接取回,没有时才本地提取音频并上传。生成完成后才能
|
视频的句边界;有则直接取回,没有时才本地提取音频并上传。生成完成后才能
|
||||||
勾选“按句跳转”,之后 `, / .` 或 `← / →` 可跳句。PC 会按文件 SHA-256
|
勾选“按句跳转”,之后 `, / .` 或 `← / →` 可跳句。PC 会按文件 SHA-256
|
||||||
缓存结果;生成过程有进度条。
|
缓存结果;生成过程有进度条。
|
||||||
|
- 画面字幕:句边界就绪后,当前句会叠在视频画面底部。可勾选“中文字幕”
|
||||||
|
(快捷键 `C`)显示或关闭中文翻译;翻译由服务端生成并缓存。
|
||||||
- AI 讲解:播放中按 `E` 或点击“AI讲解 (E)”,由服务端 AI 讲解当前句子。
|
- AI 讲解:播放中按 `E` 或点击“AI讲解 (E)”,由服务端 AI 讲解当前句子。
|
||||||
- 服务配置:点击“服务…”可填写服务器地址,或使用账号密码登录后保存令牌。
|
- 服务配置:点击“服务…”可填写服务器地址,或使用账号密码登录后保存令牌。
|
||||||
- 调试日志:点击“调试日志”可查看/复制请求状态、耗时和错误摘要;日志保存在
|
- 调试日志:点击“调试日志”可查看/复制请求状态、耗时和错误摘要;日志保存在
|
||||||
@@ -83,6 +85,7 @@ pip install -r requirements.txt
|
|||||||
| `N` / `P` | 下一首 / 上一首 |
|
| `N` / `P` | 下一首 / 上一首 |
|
||||||
| `[` / `]` | 减速 / 加速(0.5x - 2.0x) |
|
| `[` / `]` | 减速 / 加速(0.5x - 2.0x) |
|
||||||
| `L` | 切换循环模式(关 / 单曲 / 列表) |
|
| `L` | 切换循环模式(关 / 单曲 / 列表) |
|
||||||
|
| `C` | 开 / 关中文字幕 |
|
||||||
| 双击画面 | 进入 / 退出全屏 |
|
| 双击画面 | 进入 / 退出全屏 |
|
||||||
| `F` | 视频全屏切换 |
|
| `F` | 视频全屏切换 |
|
||||||
| `Esc` | 退出全屏 |
|
| `Esc` | 退出全屏 |
|
||||||
@@ -105,7 +108,8 @@ pip install -r requirements.txt
|
|||||||
静音阈值会根据每个文件的背景噪声自动调整,带环境噪音的录音也能正常分句。
|
静音阈值会根据每个文件的背景噪声自动调整,带环境噪音的录音也能正常分句。
|
||||||
分析在后台进行,首次播放某文件时状态栏会提示“正在分析句边界…”,完成后
|
分析在后台进行,首次播放某文件时状态栏会提示“正在分析句边界…”,完成后
|
||||||
自动缓存;无法识别(如纯音乐、无音频轨)时会提示并回退为普通步进跳转。
|
自动缓存;无法识别(如纯音乐、无音频轨)时会提示并回退为普通步进跳转。
|
||||||
“按句跳转”的开关状态同样会自动保存。
|
“按句跳转”和“中文字幕”的开关状态同样会自动保存。句边界就绪后,原文会叠在
|
||||||
|
视频画面底部;打开中文字幕时会向服务器请求翻译并一并显示。
|
||||||
|
|
||||||
## 常见问题
|
## 常见问题
|
||||||
|
|
||||||
|
|||||||
484
media_player.py
484
media_player.py
@@ -16,6 +16,7 @@
|
|||||||
Ctrl+↑ / ↓ 快进步长 +1 / -1 秒(macOS 亦支持 Command+↑ / ↓)
|
Ctrl+↑ / ↓ 快进步长 +1 / -1 秒(macOS 亦支持 Command+↑ / ↓)
|
||||||
, / . 上一句 / 下一句
|
, / . 上一句 / 下一句
|
||||||
勾选“按句跳转”后,←/→ 也会自动播放上一句/下一句
|
勾选“按句跳转”后,←/→ 也会自动播放上一句/下一句
|
||||||
|
C 开 / 关中文字幕
|
||||||
E 讲解当前句子(AI)
|
E 讲解当前句子(AI)
|
||||||
M 静音 / 恢复
|
M 静音 / 恢复
|
||||||
Ctrl+← / → 上一首 / 下一首(macOS 也支持 Command+← / →)
|
Ctrl+← / → 上一首 / 下一首(macOS 也支持 Command+← / →)
|
||||||
@@ -225,6 +226,15 @@ def fmt_time(ms):
|
|||||||
return f"{minutes:02d}:{seconds:02d}"
|
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:
|
class _BaseEngine:
|
||||||
"""播放内核统一接口。"""
|
"""播放内核统一接口。"""
|
||||||
|
|
||||||
@@ -270,6 +280,9 @@ class _BaseEngine:
|
|||||||
def embed(self, window_id):
|
def embed(self, window_id):
|
||||||
return False
|
return False
|
||||||
|
|
||||||
|
def set_subtitle_file(self, path):
|
||||||
|
return False
|
||||||
|
|
||||||
def get_frame(self):
|
def get_frame(self):
|
||||||
"""返回当前应显示的帧(PIL 图像),没有新帧时返回 None。"""
|
"""返回当前应显示的帧(PIL 图像),没有新帧时返回 None。"""
|
||||||
return None
|
return None
|
||||||
@@ -371,6 +384,31 @@ class VlcEngine(_BaseEngine):
|
|||||||
self.player.set_xwindow(int(window_id))
|
self.player.set_xwindow(int(window_id))
|
||||||
return True
|
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):
|
def release(self):
|
||||||
try:
|
try:
|
||||||
self.player.stop()
|
self.player.stop()
|
||||||
@@ -977,6 +1015,7 @@ class MediaPlayerApp(tk.Tk):
|
|||||||
self.seek_step = settings["seek_step"]
|
self.seek_step = settings["seek_step"]
|
||||||
# Sentence mode is always re-enabled manually after boundaries exist.
|
# Sentence mode is always re-enabled manually after boundaries exist.
|
||||||
self.sentence_mode = tk.BooleanVar(value=False)
|
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.server_url = settings["server_url"].rstrip("/")
|
||||||
self.auth_token = settings["auth_token"]
|
self.auth_token = settings["auth_token"]
|
||||||
self.auth_username = settings["auth_username"]
|
self.auth_username = settings["auth_username"]
|
||||||
@@ -999,6 +1038,12 @@ class MediaPlayerApp(tk.Tk):
|
|||||||
self._explanation_text = None
|
self._explanation_text = None
|
||||||
self._explanation_debug_var = None
|
self._explanation_debug_var = None
|
||||||
self._explanation_generation = 0
|
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()
|
self.engine = self._create_engine()
|
||||||
if self.engine is None:
|
if self.engine is None:
|
||||||
@@ -1097,7 +1142,33 @@ class MediaPlayerApp(tk.Tk):
|
|||||||
hint_text = "打开文件开始播放\n(视频将在独立窗口中显示)"
|
hint_text = "打开文件开始播放\n(视频将在独立窗口中显示)"
|
||||||
self.video_hint = tk.Label(self.video_frame, text=hint_text, bg="#101010", fg="#8a8a8a")
|
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.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("<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 = ttk.Frame(main, width=260)
|
||||||
side.grid(row=0, column=1, sticky="ns")
|
side.grid(row=0, column=1, sticky="ns")
|
||||||
@@ -1165,6 +1236,15 @@ class MediaPlayerApp(tk.Tk):
|
|||||||
# 点击 Checkbutton 后 tk 仍会把焦点给它;把空格键重定向到播放/暂停,
|
# 点击 Checkbutton 后 tk 仍会把焦点给它;把空格键重定向到播放/暂停,
|
||||||
# 避免误改复选按钮状态。
|
# 避免误改复选按钮状态。
|
||||||
self.sentence_mode_btn.bind("<space>", self._on_space)
|
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(
|
self.btn_generate_boundaries = ttk.Button(
|
||||||
controls,
|
controls,
|
||||||
text="生成句边界",
|
text="生成句边界",
|
||||||
@@ -1230,6 +1310,8 @@ class MediaPlayerApp(tk.Tk):
|
|||||||
("<bracketright>", lambda _e: self.step_speed(1)),
|
("<bracketright>", lambda _e: self.step_speed(1)),
|
||||||
("<comma>", lambda _e: self.seek_sentence(-1)),
|
("<comma>", lambda _e: self.seek_sentence(-1)),
|
||||||
("<period>", 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()),
|
||||||
("<E>", lambda _e: self.show_ai_explanation()),
|
("<E>", lambda _e: self.show_ai_explanation()),
|
||||||
("<a>", 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()
|
sentence = self._current_sentence()
|
||||||
text = str(sentence.get("text") or "").strip() if sentence else ""
|
text = str(sentence.get("text") or "").strip() if sentence else ""
|
||||||
self.sentence_var.set(text)
|
self.sentence_var.set(text)
|
||||||
|
self._update_on_screen_subtitles()
|
||||||
playing = self.engine.is_playing()
|
playing = self.engine.is_playing()
|
||||||
self.btn_play.config(text="暂停" if playing else "播放")
|
self.btn_play.config(text="暂停" if playing else "播放")
|
||||||
self.after(33, self._tick)
|
self.after(33, self._tick)
|
||||||
@@ -1348,6 +1431,8 @@ class MediaPlayerApp(tk.Tk):
|
|||||||
self._photo = ImageTk.PhotoImage(img)
|
self._photo = ImageTk.PhotoImage(img)
|
||||||
canvas.delete("all")
|
canvas.delete("all")
|
||||||
canvas.create_image(width // 2, height // 2, image=self._photo, anchor="center")
|
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):
|
def open_files(self):
|
||||||
filetypes = [
|
filetypes = [
|
||||||
@@ -1411,6 +1496,7 @@ class MediaPlayerApp(tk.Tk):
|
|||||||
return {
|
return {
|
||||||
"seek_step": step,
|
"seek_step": step,
|
||||||
"sentence_mode": bool(data.get("sentence_mode", False)),
|
"sentence_mode": bool(data.get("sentence_mode", False)),
|
||||||
|
"show_chinese_subtitles": bool(data.get("show_chinese_subtitles", False)),
|
||||||
"server_url": server_url,
|
"server_url": server_url,
|
||||||
"auth_token": str(data.get("auth_token") or "").strip(),
|
"auth_token": str(data.get("auth_token") or "").strip(),
|
||||||
"auth_username": str(data.get("auth_username") or "").strip(),
|
"auth_username": str(data.get("auth_username") or "").strip(),
|
||||||
@@ -1424,6 +1510,7 @@ class MediaPlayerApp(tk.Tk):
|
|||||||
{
|
{
|
||||||
"seek_step": self.seek_step,
|
"seek_step": self.seek_step,
|
||||||
"sentence_mode": bool(self.sentence_mode.get()),
|
"sentence_mode": bool(self.sentence_mode.get()),
|
||||||
|
"show_chinese_subtitles": bool(self.show_chinese_subtitles.get()),
|
||||||
"server_url": self.server_url,
|
"server_url": self.server_url,
|
||||||
"auth_token": self.auth_token,
|
"auth_token": self.auth_token,
|
||||||
"auth_username": self.auth_username,
|
"auth_username": self.auth_username,
|
||||||
@@ -1510,6 +1597,13 @@ class MediaPlayerApp(tk.Tk):
|
|||||||
self.status_var.set("请先生成句子边界")
|
self.status_var.set("请先生成句子边界")
|
||||||
self._load_local_boundaries_async(path)
|
self._load_local_boundaries_async(path)
|
||||||
self._update_sentence_controls()
|
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):
|
def toggle_play(self):
|
||||||
if not self.playlist:
|
if not self.playlist:
|
||||||
@@ -1526,6 +1620,9 @@ class MediaPlayerApp(tk.Tk):
|
|||||||
self._seeking = False
|
self._seeking = False
|
||||||
self.time_var.set("00:00 / --:--")
|
self.time_var.set("00:00 / --:--")
|
||||||
self.sentence_var.set("")
|
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.seekbar.set_frac(0.0)
|
||||||
self.btn_play.config(text="播放")
|
self.btn_play.config(text="播放")
|
||||||
canvas = getattr(self, "video_canvas", None)
|
canvas = getattr(self, "video_canvas", None)
|
||||||
@@ -1707,11 +1804,13 @@ class MediaPlayerApp(tk.Tk):
|
|||||||
|
|
||||||
def _finish_local_boundary_load(self, path, message):
|
def _finish_local_boundary_load(self, path, message):
|
||||||
self._local_cache_loading.discard(path)
|
self._local_cache_loading.discard(path)
|
||||||
if path in self._analysis_running or path in self._boundaries:
|
if path in self._analysis_running:
|
||||||
return
|
return
|
||||||
if path == self._current_path():
|
if path == self._current_path():
|
||||||
self.status_var.set(message)
|
self.status_var.set(message)
|
||||||
self._update_sentence_controls()
|
self._update_sentence_controls()
|
||||||
|
self._ensure_chinese_translations()
|
||||||
|
self._sync_vlc_subtitles()
|
||||||
|
|
||||||
def _update_sentence_controls(self):
|
def _update_sentence_controls(self):
|
||||||
path = self._current_path()
|
path = self._current_path()
|
||||||
@@ -1762,6 +1861,22 @@ class MediaPlayerApp(tk.Tk):
|
|||||||
count = len(self._boundaries[path]["sentences"])
|
count = len(self._boundaries[path]["sentences"])
|
||||||
self.status_var.set(f"按句跳转已开启(识别到 {count} 句)")
|
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):
|
def generate_sentence_boundaries(self):
|
||||||
path = self._current_path()
|
path = self._current_path()
|
||||||
if path is None:
|
if path is None:
|
||||||
@@ -1901,6 +2016,8 @@ class MediaPlayerApp(tk.Tk):
|
|||||||
)
|
)
|
||||||
self._report_progress(None, None, indeterminate=False)
|
self._report_progress(None, None, indeterminate=False)
|
||||||
self._ui_events.put(lambda: self._update_sentence_controls())
|
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))
|
self._analysis_done.put((path, message))
|
||||||
|
|
||||||
def _boundary_cache_path(self, media_hash: str) -> Path:
|
def _boundary_cache_path(self, media_hash: str) -> Path:
|
||||||
@@ -1920,12 +2037,14 @@ class MediaPlayerApp(tk.Tk):
|
|||||||
end = int(item["end_ms"])
|
end = int(item["end_ms"])
|
||||||
if end <= start or start < previous_end:
|
if end <= start or start < previous_end:
|
||||||
raise ValueError("invalid cached boundary")
|
raise ValueError("invalid cached boundary")
|
||||||
|
zh_text = item.get("zh_text")
|
||||||
cleaned.append({
|
cleaned.append({
|
||||||
"index": index,
|
"index": index,
|
||||||
"start_ms": start,
|
"start_ms": start,
|
||||||
"end_ms": end,
|
"end_ms": end,
|
||||||
"text": (str(item["text"]) if item.get("text") is not None else None),
|
"text": (str(item["text"]) if item.get("text") is not None else None),
|
||||||
"language": item.get("language"),
|
"language": item.get("language"),
|
||||||
|
"zh_text": (str(zh_text).strip() if zh_text else None) or None,
|
||||||
})
|
})
|
||||||
previous_end = end
|
previous_end = end
|
||||||
if not cleaned:
|
if not cleaned:
|
||||||
@@ -2094,6 +2213,368 @@ class MediaPlayerApp(tk.Tk):
|
|||||||
detail = str(detail)
|
detail = str(detail)
|
||||||
return detail if len(detail) <= 300 else detail[:297] + "..."
|
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]]:
|
def _current_sentence(self) -> Optional[Dict[str, Any]]:
|
||||||
path = self._current_path()
|
path = self._current_path()
|
||||||
if path is None:
|
if path is None:
|
||||||
@@ -2753,6 +3234,7 @@ class MediaPlayerApp(tk.Tk):
|
|||||||
self.status_var.set("播放结束")
|
self.status_var.set("播放结束")
|
||||||
|
|
||||||
def _on_close(self):
|
def _on_close(self):
|
||||||
|
self._translation_generation += 1
|
||||||
self._save_playlist()
|
self._save_playlist()
|
||||||
self.engine.release()
|
self.engine.release()
|
||||||
self.destroy()
|
self.destroy()
|
||||||
|
|||||||
@@ -111,6 +111,12 @@ several windows in parallel, retries and splits a window if the model response i
|
|||||||
invalid, and writes each finished window immediately so later viewers can reuse
|
invalid, and writes each finished window immediately so later viewers can reuse
|
||||||
it. English sentence edits clear the saved Chinese captions for that course.
|
it. English sentence edits clear the saved Chinese captions for that course.
|
||||||
|
|
||||||
|
Desktop playback of a local file can `POST` the same endpoint with the locally
|
||||||
|
generated English cues (`duration_ms`, `algorithm_version`, `sentences`). If that
|
||||||
|
SHA-256 is not already a course, the server stores the English subtitles and
|
||||||
|
translates them; later `GET`/`POST` calls poll and reuse the saved Chinese
|
||||||
|
captions. Official course sentences are never overwritten by a local upload.
|
||||||
|
|
||||||
## Assessment
|
## Assessment
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ from fastapi import (
|
|||||||
Request,
|
Request,
|
||||||
UploadFile,
|
UploadFile,
|
||||||
)
|
)
|
||||||
|
from pydantic import ValidationError
|
||||||
from fastapi.responses import FileResponse, RedirectResponse, Response
|
from fastapi.responses import FileResponse, RedirectResponse, Response
|
||||||
from fastapi.staticfiles import StaticFiles
|
from fastapi.staticfiles import StaticFiles
|
||||||
from starlette.concurrency import run_in_threadpool
|
from starlette.concurrency import run_in_threadpool
|
||||||
@@ -47,6 +48,7 @@ from .models import (
|
|||||||
SentenceExplanationRequest,
|
SentenceExplanationRequest,
|
||||||
SentenceExplanationResponse,
|
SentenceExplanationResponse,
|
||||||
AdminSubtitleTranslationResponse,
|
AdminSubtitleTranslationResponse,
|
||||||
|
LocalSubtitleTranslationRequest,
|
||||||
SubtitleRetranslateRequest,
|
SubtitleRetranslateRequest,
|
||||||
SubtitleTranslationResponse,
|
SubtitleTranslationResponse,
|
||||||
PasswordResetRequest,
|
PasswordResetRequest,
|
||||||
@@ -364,12 +366,28 @@ def create_app(
|
|||||||
"/api/v1/videos/{video_hash}/subtitle-translation",
|
"/api/v1/videos/{video_hash}/subtitle-translation",
|
||||||
response_model=SubtitleTranslationResponse,
|
response_model=SubtitleTranslationResponse,
|
||||||
)
|
)
|
||||||
def translate_subtitles(
|
async def translate_subtitles(
|
||||||
|
request: Request,
|
||||||
video_hash: str = SHA256_PATH,
|
video_hash: str = SHA256_PATH,
|
||||||
user: AuthenticatedUser = Depends(current_user),
|
user: AuthenticatedUser = Depends(current_user),
|
||||||
) -> SubtitleTranslationResponse:
|
) -> SubtitleTranslationResponse:
|
||||||
del user
|
del user
|
||||||
|
raw = await request.body()
|
||||||
|
local_document = None
|
||||||
|
if raw.strip():
|
||||||
|
try:
|
||||||
|
payload = LocalSubtitleTranslationRequest.model_validate_json(raw)
|
||||||
|
local_document = SentenceBoundaryDocument(
|
||||||
|
video_hash=video_hash,
|
||||||
|
duration_ms=payload.duration_ms,
|
||||||
|
algorithm_version=payload.algorithm_version,
|
||||||
|
sentences=payload.sentences,
|
||||||
|
)
|
||||||
|
except ValidationError as exc:
|
||||||
|
raise HTTPException(status_code=422, detail=exc.errors()) from exc
|
||||||
try:
|
try:
|
||||||
|
if local_document is not None:
|
||||||
|
return application.state.subtitle_translations.ensure_from_local(local_document)
|
||||||
return application.state.subtitle_translations.ensure(video_hash)
|
return application.state.subtitle_translations.ensure(video_hash)
|
||||||
except KeyError:
|
except KeyError:
|
||||||
raise HTTPException(status_code=404, detail="Course is not available.") from None
|
raise HTTPException(status_code=404, detail="Course is not available.") from None
|
||||||
|
|||||||
@@ -52,7 +52,7 @@ class SentenceBoundaryDocument(BaseModel):
|
|||||||
return self
|
return self
|
||||||
|
|
||||||
|
|
||||||
VideoStatus = Literal["uploaded", "processing", "ready", "failed"]
|
VideoStatus = Literal["uploaded", "processing", "ready", "failed", "local_subtitles"]
|
||||||
|
|
||||||
|
|
||||||
class VideoSummary(BaseModel):
|
class VideoSummary(BaseModel):
|
||||||
@@ -144,6 +144,16 @@ class SubtitleTranslationResponse(BaseModel):
|
|||||||
error: Optional[str] = None
|
error: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
|
class LocalSubtitleTranslationRequest(BaseModel):
|
||||||
|
"""Upload locally generated English subtitles so the server can translate them."""
|
||||||
|
|
||||||
|
model_config = ConfigDict(extra="forbid")
|
||||||
|
|
||||||
|
duration_ms: int = Field(gt=0)
|
||||||
|
algorithm_version: str = Field(default="local-pc", min_length=1, max_length=64)
|
||||||
|
sentences: List[SentenceBoundary] = Field(min_length=1)
|
||||||
|
|
||||||
|
|
||||||
class TranslationTerm(BaseModel):
|
class TranslationTerm(BaseModel):
|
||||||
model_config = ConfigDict(extra="forbid")
|
model_config = ConfigDict(extra="forbid")
|
||||||
|
|
||||||
|
|||||||
@@ -355,6 +355,98 @@ class VideoRepository:
|
|||||||
],
|
],
|
||||||
)
|
)
|
||||||
|
|
||||||
|
def import_local_subtitle_document(
|
||||||
|
self,
|
||||||
|
document: SentenceBoundaryDocument,
|
||||||
|
) -> SentenceBoundaryDocument:
|
||||||
|
"""Save locally generated English subtitles for translation.
|
||||||
|
|
||||||
|
Official course videos are left unchanged. A previous local-only
|
||||||
|
import is replaced when the English cues changed.
|
||||||
|
"""
|
||||||
|
normalized_hash = normalize_video_hash(document.video_hash)
|
||||||
|
document = document.model_copy(update={"video_hash": normalized_hash})
|
||||||
|
existing_video = self.get_video(normalized_hash)
|
||||||
|
existing_document = self.get_document(normalized_hash)
|
||||||
|
if existing_video is not None and existing_video.get("status") != "local_subtitles":
|
||||||
|
if existing_document is None:
|
||||||
|
raise KeyError(normalized_hash)
|
||||||
|
return existing_document
|
||||||
|
if existing_document is not None and _english_fingerprint(existing_document) == _english_fingerprint(document):
|
||||||
|
return existing_document
|
||||||
|
|
||||||
|
now = utc_now()
|
||||||
|
language = next((sentence.language for sentence in document.sentences if sentence.language), None)
|
||||||
|
with self._connect() as connection:
|
||||||
|
if existing_video is None:
|
||||||
|
connection.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO videos (
|
||||||
|
video_hash, title, filename, stored_filename, content_type,
|
||||||
|
size_bytes, duration_ms, language, status, error_message,
|
||||||
|
algorithm_version, transcription, remote_url, created_at, updated_at
|
||||||
|
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, 'local_subtitles', NULL, ?, NULL, NULL, ?, ?)
|
||||||
|
""",
|
||||||
|
(
|
||||||
|
normalized_hash,
|
||||||
|
"Local subtitles",
|
||||||
|
"local-subtitles.json",
|
||||||
|
"",
|
||||||
|
"application/json",
|
||||||
|
0,
|
||||||
|
document.duration_ms,
|
||||||
|
language,
|
||||||
|
document.algorithm_version,
|
||||||
|
now,
|
||||||
|
now,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
connection.execute(
|
||||||
|
"""
|
||||||
|
UPDATE videos
|
||||||
|
SET duration_ms = ?, language = COALESCE(?, language),
|
||||||
|
algorithm_version = ?, status = 'local_subtitles',
|
||||||
|
error_message = NULL, updated_at = ?
|
||||||
|
WHERE video_hash = ?
|
||||||
|
""",
|
||||||
|
(
|
||||||
|
document.duration_ms,
|
||||||
|
language,
|
||||||
|
document.algorithm_version,
|
||||||
|
now,
|
||||||
|
normalized_hash,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
connection.execute(
|
||||||
|
"DELETE FROM sentences WHERE video_hash = ?",
|
||||||
|
(normalized_hash,),
|
||||||
|
)
|
||||||
|
connection.executemany(
|
||||||
|
"""
|
||||||
|
INSERT INTO sentences (
|
||||||
|
video_hash, sentence_index, start_ms, end_ms, text,
|
||||||
|
language, zh_text, reference_speech_duration_ms
|
||||||
|
) VALUES (?, ?, ?, ?, ?, ?, NULL, ?)
|
||||||
|
""",
|
||||||
|
[
|
||||||
|
(
|
||||||
|
normalized_hash,
|
||||||
|
sentence.index,
|
||||||
|
sentence.start_ms,
|
||||||
|
sentence.end_ms,
|
||||||
|
sentence.text,
|
||||||
|
sentence.language,
|
||||||
|
sentence.reference_speech_duration_ms,
|
||||||
|
)
|
||||||
|
for sentence in document.sentences
|
||||||
|
],
|
||||||
|
)
|
||||||
|
imported = self.get_document(normalized_hash)
|
||||||
|
if imported is None:
|
||||||
|
raise KeyError(normalized_hash)
|
||||||
|
return imported
|
||||||
|
|
||||||
def mark_oss_uploaded(self, video_hash: str, remote_url: str) -> None:
|
def mark_oss_uploaded(self, video_hash: str, remote_url: str) -> None:
|
||||||
with self._connect() as connection:
|
with self._connect() as connection:
|
||||||
connection.execute(
|
connection.execute(
|
||||||
@@ -389,6 +481,7 @@ class VideoRepository:
|
|||||||
SELECT v.*, COUNT(s.sentence_index) AS sentence_count
|
SELECT v.*, COUNT(s.sentence_index) AS sentence_count
|
||||||
FROM videos v
|
FROM videos v
|
||||||
LEFT JOIN sentences s ON s.video_hash = v.video_hash
|
LEFT JOIN sentences s ON s.video_hash = v.video_hash
|
||||||
|
WHERE v.status != 'local_subtitles'
|
||||||
GROUP BY v.video_hash
|
GROUP BY v.video_hash
|
||||||
ORDER BY v.created_at DESC
|
ORDER BY v.created_at DESC
|
||||||
"""
|
"""
|
||||||
@@ -1316,6 +1409,13 @@ class VideoRepository:
|
|||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
def _english_fingerprint(document: SentenceBoundaryDocument) -> tuple:
|
||||||
|
return tuple(
|
||||||
|
(sentence.index, sentence.start_ms, sentence.end_ms, (sentence.text or "").strip())
|
||||||
|
for sentence in document.sentences
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def _sentence_from_row(row: sqlite3.Row) -> SentenceBoundary:
|
def _sentence_from_row(row: sqlite3.Row) -> SentenceBoundary:
|
||||||
keys = set(row.keys())
|
keys = set(row.keys())
|
||||||
zh_text = row["zh_text"] if "zh_text" in keys else None
|
zh_text = row["zh_text"] if "zh_text" in keys else None
|
||||||
|
|||||||
@@ -248,6 +248,91 @@ def test_subtitle_translation_requires_login(tmp_path):
|
|||||||
assert response.status_code == 401
|
assert response.status_code == 401
|
||||||
|
|
||||||
|
|
||||||
|
LOCAL_SUBTITLE_HASH = "b" * 64
|
||||||
|
|
||||||
|
|
||||||
|
def _stub_translator(monkeypatch, client, requested=None):
|
||||||
|
requested = requested if requested is not None else []
|
||||||
|
|
||||||
|
def translate_sentences(sentences, **kwargs):
|
||||||
|
requested.append((sentences, kwargs))
|
||||||
|
return {index: f"中文{index}" for index, _text in sentences}
|
||||||
|
|
||||||
|
class StubAITutor:
|
||||||
|
available = True
|
||||||
|
|
||||||
|
stub = StubAITutor()
|
||||||
|
stub.translate_sentences = translate_sentences
|
||||||
|
monkeypatch.setattr(client.app.state, "ai_tutor", stub)
|
||||||
|
return requested
|
||||||
|
|
||||||
|
|
||||||
|
def test_local_english_subtitles_can_be_uploaded_for_translation(tmp_path, monkeypatch):
|
||||||
|
client = make_client(tmp_path)
|
||||||
|
requested = _stub_translator(monkeypatch, client)
|
||||||
|
|
||||||
|
missing = client.post(f"/api/v1/videos/{LOCAL_SUBTITLE_HASH}/subtitle-translation")
|
||||||
|
assert missing.status_code == 404
|
||||||
|
|
||||||
|
response = client.post(
|
||||||
|
f"/api/v1/videos/{LOCAL_SUBTITLE_HASH}/subtitle-translation",
|
||||||
|
json={
|
||||||
|
"duration_ms": 9000,
|
||||||
|
"algorithm_version": "local-pc",
|
||||||
|
"sentences": [
|
||||||
|
{"index": 0, "start_ms": 0, "end_ms": 4000, "text": "Hello.", "language": "en"},
|
||||||
|
{"index": 1, "start_ms": 4000, "end_ms": 9000, "text": "Nice to meet you.", "language": "en"},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
)
|
||||||
|
assert response.status_code == 200, response.text
|
||||||
|
if not response.json().get("complete"):
|
||||||
|
response = wait_for_subtitle_translation(client, LOCAL_SUBTITLE_HASH)
|
||||||
|
payload = response.json()
|
||||||
|
assert payload["complete"] is True
|
||||||
|
assert payload["translations"] == [
|
||||||
|
{"index": 0, "zh_text": "中文0"},
|
||||||
|
{"index": 1, "zh_text": "中文1"},
|
||||||
|
]
|
||||||
|
assert requested[0][0] == [(0, "Hello."), (1, "Nice to meet you.")]
|
||||||
|
|
||||||
|
stored = client.get(f"/api/v1/videos/{LOCAL_SUBTITLE_HASH}/subtitle-translation")
|
||||||
|
assert stored.status_code == 200
|
||||||
|
assert stored.json()["translations"] == payload["translations"]
|
||||||
|
|
||||||
|
courses = client.get("/api/v1/courses")
|
||||||
|
assert courses.status_code == 200
|
||||||
|
assert all(item["video_hash"] != LOCAL_SUBTITLE_HASH for item in courses.json())
|
||||||
|
|
||||||
|
videos = client.get("/api/v1/videos")
|
||||||
|
assert videos.status_code == 200
|
||||||
|
assert all(item["video_hash"] != LOCAL_SUBTITLE_HASH for item in videos.json()["videos"])
|
||||||
|
|
||||||
|
|
||||||
|
def test_local_subtitle_upload_does_not_overwrite_course_sentences(tmp_path, monkeypatch):
|
||||||
|
client = make_client(tmp_path)
|
||||||
|
_ready_video(client.app.state.video_repository)
|
||||||
|
requested = _stub_translator(monkeypatch, client)
|
||||||
|
|
||||||
|
response = client.post(
|
||||||
|
f"/api/v1/videos/{VIDEO_HASH}/subtitle-translation",
|
||||||
|
json={
|
||||||
|
"duration_ms": 9000,
|
||||||
|
"algorithm_version": "local-pc",
|
||||||
|
"sentences": [
|
||||||
|
{"index": 0, "start_ms": 0, "end_ms": 4000, "text": "Changed.", "language": "en"},
|
||||||
|
{"index": 1, "start_ms": 4000, "end_ms": 9000, "text": "Also changed.", "language": "en"},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
)
|
||||||
|
assert response.status_code == 200, response.text
|
||||||
|
if not response.json().get("complete"):
|
||||||
|
response = wait_for_subtitle_translation(client, VIDEO_HASH)
|
||||||
|
assert requested[0][0] == [(0, "Hello."), (1, "Nice to meet you.")]
|
||||||
|
document = client.app.state.video_repository.get_document(VIDEO_HASH)
|
||||||
|
assert [sentence.text for sentence in document.sentences] == ["Hello.", "Nice to meet you."]
|
||||||
|
|
||||||
|
|
||||||
def test_subtitle_translation_uses_full_document_and_is_cached(tmp_path, monkeypatch):
|
def test_subtitle_translation_uses_full_document_and_is_cached(tmp_path, monkeypatch):
|
||||||
client = make_client(tmp_path)
|
client = make_client(tmp_path)
|
||||||
_ready_video(client.app.state.video_repository)
|
_ready_video(client.app.state.video_repository)
|
||||||
|
|||||||
@@ -144,6 +144,11 @@ class SubtitleTranslationService:
|
|||||||
payload["terms"] = terms
|
payload["terms"] = terms
|
||||||
return AdminSubtitleTranslationResponse(**payload)
|
return AdminSubtitleTranslationResponse(**payload)
|
||||||
|
|
||||||
|
def ensure_from_local(self, document: SentenceBoundaryDocument) -> SubtitleTranslationResponse:
|
||||||
|
"""Accept locally generated English subtitles, then translate like a course."""
|
||||||
|
self.repository.import_local_subtitle_document(document)
|
||||||
|
return self.ensure(document.video_hash)
|
||||||
|
|
||||||
def ensure(self, video_hash: str) -> SubtitleTranslationResponse:
|
def ensure(self, video_hash: str) -> SubtitleTranslationResponse:
|
||||||
if not getattr(self.ai_tutor, "available", False):
|
if not getattr(self.ai_tutor, "available", False):
|
||||||
raise AITutorError("AI translation is not configured.")
|
raise AITutorError("AI translation is not configured.")
|
||||||
|
|||||||
Reference in New Issue
Block a user