From f10915e02065ddbb1c723522711748801032a582 Mon Sep 17 00:00:00 2001 From: Shuming Liu Date: Thu, 10 Sep 2026 09:37:01 +0800 Subject: [PATCH] fix a bug --- README.md | 7 +- media_player.py | 265 ++++++++++++++++++++++++++++++++++++++++-------- 2 files changed, 225 insertions(+), 47 deletions(-) diff --git a/README.md b/README.md index 472c5d7..dc61a70 100644 --- a/README.md +++ b/README.md @@ -10,9 +10,10 @@ pip 安装依赖后即可直接播放视频。 - 播放 / 暂停 / 停止、上一首 / 下一首 - 前进 / 后退(步长可自定义)与进度条点击拖动定位 - 快进步长可在界面底部直接填写(1-600 秒),自动保存、下次启动自动加载 -- 按句跳转:`, / .` 直接跳转上一句/下一句;勾选“按句跳转”后 `← / →` 也可 - 跳句。句边界通过线上服务生成,PC 仅上传本地提取的音频轨,服务器处理完成 - 后返回边界且不保存音频;结果按文件 SHA-256 缓存在本机。 +- 按句跳转:先点“生成句边界”。生成时会先按 SHA-256 查询线上是否已有同一 + 视频的句边界;有则直接取回,没有时才本地提取音频并上传。生成完成后才能 + 勾选“按句跳转”,之后 `, / .` 或 `← / →` 可跳句。PC 会按文件 SHA-256 + 缓存结果;生成过程有进度条。 - AI 讲解:播放中按 `E` 或点击“AI讲解 (E)”,由服务端 AI 讲解当前句子。 - 服务配置:点击“服务…”可填写服务器地址,或使用账号密码登录后保存令牌。 - 音量调节、静音,0.5x - 2.0x 变速播放 diff --git a/media_player.py b/media_player.py index bdda96e..7200d60 100644 --- a/media_player.py +++ b/media_player.py @@ -81,15 +81,20 @@ DEFAULT_SERVER_URL = "https://videoservice.d1kt.cn" CLIENT_NAME = "MediaPlayer/1.0 (Desktop)" -def sha256_file(path: Path) -> str: +def sha256_file(path: Path, progress_callback=None) -> str: digest = hashlib.sha256() + total_size = max(1, path.stat().st_size) + processed = 0 with path.open("rb") as media_file: for chunk in iter(lambda: media_file.read(1024 * 1024), b""): digest.update(chunk) + processed += len(chunk) + if progress_callback: + progress_callback(min(1.0, processed / total_size)) return digest.hexdigest() -def extract_audio_to_wav(path: Path, output_path: Path) -> None: +def extract_audio_to_wav(path: Path, output_path: Path, progress_callback=None) -> None: """Extract one mono 16 kHz audio track before uploading it to the server.""" import av @@ -97,12 +102,17 @@ def extract_audio_to_wav(path: Path, output_path: Path) -> None: source_stream = next((s for s in source.streams if s.type == "audio"), None) if source_stream is None: raise RuntimeError("文件中没有音频轨") + total_seconds = source.duration / 1_000_000 if source.duration and source.duration > 0 else 0.0 + output_seconds = 0.0 resampler = av.AudioResampler(format="s16", layout="mono", rate=16_000) with av.open(str(output_path), "w", format="wav") as target: target_stream = target.add_stream("pcm_s16le", rate=16_000, layout="mono") for packet in source.demux(source_stream): for frame in packet.decode(): for resampled in resampler.resample(frame): + output_seconds += len(resampled.samples) / resampled.sample_rate + if progress_callback and total_seconds > 0: + progress_callback(min(1.0, output_seconds / total_seconds)) for encoded in target_stream.encode(resampled): target.mux(encoded) for resampled in resampler.resample(None): @@ -873,7 +883,8 @@ class MediaPlayerApp(tk.Tk): self.rate = 1.0 settings = self._load_settings() self.seek_step = settings["seek_step"] - self.sentence_mode = tk.BooleanVar(value=settings["sentence_mode"]) + # Sentence mode is always re-enabled manually after boundaries exist. + self.sentence_mode = tk.BooleanVar(value=False) self.server_url = settings["server_url"].rstrip("/") self.auth_token = settings["auth_token"] self.loop_mode = "list" @@ -887,6 +898,8 @@ class MediaPlayerApp(tk.Tk): self._analysis_running = set() self._analysis_done = queue.Queue() self._ui_events = queue.Queue() + self._controls_media_path = None + self._generation_token = 0 self._explanation_window = None self._explanation_generation = 0 @@ -1042,6 +1055,13 @@ class MediaPlayerApp(tk.Tk): command=self._on_sentence_mode, ) self.sentence_mode_btn.pack(side="left", padx=(16, 0)) + self.btn_generate_boundaries = ttk.Button( + controls, + text="生成句边界", + command=self.generate_sentence_boundaries, + takefocus=0, + ) + self.btn_generate_boundaries.pack(side="left", padx=(12, 0)) self.btn_ai_explain = ttk.Button( controls, text="AI讲解 (E)", @@ -1057,6 +1077,8 @@ class MediaPlayerApp(tk.Tk): width=32, ) self.sentence_label.pack(side="left", padx=(10, 0)) + self.boundary_progress = ttk.Progressbar(bottom, mode="determinate", maximum=100) + self.boundary_progress.pack(fill="x", pady=(5, 0)) self.seek_step_spin.bind("", self._on_seek_step_return) self.seek_step_spin.bind("", lambda _e: self._commit_seek_step()) ttk.Label(controls, textvariable=self.status_var, anchor="e").pack(side="right", fill="x", expand=True) @@ -1153,17 +1175,20 @@ class MediaPlayerApp(tk.Tk): def _tick(self): if self.engine is not None: + current_path = self._current_path() + if current_path != self._controls_media_path: + self._update_sentence_controls() while True: try: done_path, message = self._analysis_done.get_nowait() except queue.Empty: break - if done_path == self._current_path() and self.sentence_mode.get(): - boundaries = self._boundaries.get(done_path) - if boundaries: - self.status_var.set(message or f"句边界已就绪:共 {len(boundaries['sentences'])} 句") - else: - self.status_var.set(message or "该文件无法识别句子边界,按句跳转不可用") + boundaries = self._boundaries.get(done_path) + self.status_var.set(message or ( + f"句边界已就绪:共 {len((boundaries or {}).get('sentences', []))} 句" + )) + if done_path == self._current_path(): + self._update_sentence_controls() while True: try: callback = self._ui_events.get_nowait() @@ -1364,8 +1389,10 @@ class MediaPlayerApp(tk.Tk): self.title(f"{Path(path).name} — 媒体播放器") self._update_status() self._save_playlist() - if self.sentence_mode.get(): - self._ensure_sentence_boundaries(path) + if self.sentence_mode.get() and not (self._boundaries.get(path) or {}).get("sentences"): + self.sentence_mode.set(False) + self.status_var.set("请先生成句子边界") + self._update_sentence_controls() def toggle_play(self): if not self.playlist: @@ -1389,6 +1416,7 @@ class MediaPlayerApp(tk.Tk): canvas.delete("all") self._photo = None self.video_hint.place(relx=0.5, rely=0.5, anchor="center") + self._update_sentence_controls() self._update_status() def next_track(self): @@ -1444,47 +1472,135 @@ class MediaPlayerApp(tk.Tk): return self.playlist[self.current_index] return None + def _update_sentence_controls(self): + path = self._current_path() + self._controls_media_path = path + has_boundaries = bool((self._boundaries.get(path) or {}).get("sentences")) + generating = bool(self._analysis_running) + if not path: + self.btn_generate_boundaries.config(state="disabled", text="生成句边界") + elif generating: + self.btn_generate_boundaries.config(state="disabled", text="生成中…") + else: + self.btn_generate_boundaries.config( + state="normal", + text="重新生成" if has_boundaries else "生成句边界", + ) + + def _set_progress(self, value=None, message=None, indeterminate=False): + if not hasattr(self, "boundary_progress"): + return + if indeterminate: + self.boundary_progress.configure(mode="indeterminate") + self.boundary_progress.start(14) + else: + self.boundary_progress.stop() + self.boundary_progress.configure(mode="determinate") + if value is not None: + self.boundary_progress.configure(value=max(0, min(100, float(value)))) + if message is not None: + self.status_var.set(message) + + def _report_progress(self, value, message=None, indeterminate=False): + self._ui_events.put( + lambda: self._set_progress(value, message, indeterminate) + ) + def _on_sentence_mode(self): + path = self._current_path() + has_boundaries = bool((self._boundaries.get(path) or {}).get("sentences")) + if self.sentence_mode.get() and not has_boundaries: + self.sentence_mode.set(False) + self.status_var.set("请先生成句子边界") + self._save_settings() + return self._save_settings() if not self.sentence_mode.get(): self.status_var.set("按句跳转已关闭") return + count = len(self._boundaries[path]["sentences"]) + self.status_var.set(f"按句跳转已开启(识别到 {count} 句)") + + def generate_sentence_boundaries(self): path = self._current_path() if path is None: - self.status_var.set("按句跳转已开启(请先添加并播放文件)") + messagebox.showinfo("生成句边界", "请先打开并选择一个媒体文件。") return - if path not in self._boundaries: - self._ensure_sentence_boundaries(path) - self.status_var.set("正在分析句边界…") - elif self._boundaries[path]: - count = len(self._boundaries[path]["sentences"]) - self.status_var.set(f"按句跳转已开启(识别到 {count} 句)") - else: - self.status_var.set("该文件无法识别句子边界,按句跳转不可用") - - def _ensure_sentence_boundaries(self, path): - if path in self._boundaries or path in self._analysis_running: + if self._analysis_running: + messagebox.showinfo("生成句边界", "当前正在生成句边界,请稍候。") return if not self.server_url: - self._boundaries[path] = {"sentences": []} - self._analysis_done.put((path, "请先在“服务…”中配置服务器地址")) + messagebox.showwarning("生成句边界", "请先在“服务…”中配置服务器地址。") return + if (self._boundaries.get(path) or {}).get("sentences"): + if not messagebox.askyesno("生成句边界", "当前文件已有句边界,确定要重新生成吗?"): + return + self.sentence_mode.set(False) + self._save_settings() self._analysis_running.add(path) - self.status_var.set("正在从服务器生成句边界…") - threading.Thread(target=self._analyze_sentences, args=(path,), daemon=True).start() + self._generation_token += 1 + token = self._generation_token + self._set_progress(0, "正在准备生成句边界…") + self._update_sentence_controls() + threading.Thread( + target=self._analyze_sentences, + args=(path, token), + daemon=True, + ).start() - def _analyze_sentences(self, path): + def _analyze_sentences(self, path, token): error = "" document = None + source = "generated" audio_path = None + media_hash = None try: - media_hash = sha256_file(Path(path)) + self._report_progress(4, "正在计算文件哈希…") + media_hash = sha256_file( + Path(path), + progress_callback=lambda ratio: self._report_progress( + 4 + ratio * 12, + "正在计算文件哈希…", + ), + ) self._media_hashes[path] = media_hash - document = self._load_cached_boundaries(media_hash) - if document is None: - audio_path = self._make_audio_upload(path) - document = self._generate_remote_boundaries(audio_path) + + self._report_progress(18, "正在检查线上是否已有句边界…") + remote_document = self._fetch_remote_boundaries(media_hash) + if remote_document and remote_document.get("sentences"): + document = remote_document + source = "server" self._save_cached_boundaries(media_hash, document) + else: + document = self._load_cached_boundaries(media_hash) + if document and document.get("sentences"): + source = "local" + else: + self._report_progress(25, "线上没有句边界,正在准备音频…") + audio_path = self._make_audio_upload( + path, + progress_callback=lambda ratio: self._report_progress( + 25 + ratio * 20, + "正在提取音频轨…", + ), + ) + document = self._generate_remote_boundaries( + audio_path, + progress_callback=lambda ratio: ( + self._report_progress( + 85, + "服务器正在生成句边界…", + indeterminate=True, + ) + if ratio < 0 + else self._report_progress( + 45 + ratio * 35, + f"正在上传音频… {ratio * 100:.0f}%", + ) + ), + ) + self._save_cached_boundaries(media_hash, document) + source = "generated" except Exception as exc: error = str(exc) or exc.__class__.__name__ finally: @@ -1493,14 +1609,25 @@ class MediaPlayerApp(tk.Tk): Path(audio_path).unlink(missing_ok=True) except OSError: pass + self._analysis_running.discard(path) if document is not None and document.get("sentences"): self._boundaries[path] = document - message = error or f"句边界已生成:共 {len(document['sentences'])} 句,已保存到本地" - self._analysis_done.put((path, message)) + count = len(document["sentences"]) + if source == "server": + message = f"已取回线上句边界:共 {count} 句,并保存到本地" + elif source == "local": + message = f"已加载本地句边界缓存:共 {count} 句" + else: + message = f"句边界已生成:共 {count} 句,已保存到本地" + self._report_progress(100, message) else: self._boundaries[path] = {"sentences": []} - self._analysis_done.put((path, error or "服务器未能识别句子边界")) + message = error or "服务器未能识别句子边界" + self._report_progress(0, message) + self._report_progress(None, None, indeterminate=False) + self._ui_events.put(lambda: self._update_sentence_controls()) + self._analysis_done.put((path, message)) def _boundary_cache_path(self, media_hash: str) -> Path: return Path.home() / ".mediaplayer_sentence_boundaries" / f"{media_hash}.json" @@ -1546,28 +1673,78 @@ class MediaPlayerApp(tk.Tk): temporary.write_text(json.dumps(payload, ensure_ascii=False, indent=2), encoding="utf-8") temporary.replace(path) - def _make_audio_upload(self, path): + def _make_audio_upload(self, path, progress_callback=None): source = Path(path) if source.suffix.lower() in AUDIO_EXTS: + if progress_callback: + progress_callback(1.0) return source handle = tempfile.NamedTemporaryFile(prefix="mediaplayer-audio-", suffix=".wav", delete=False) output = Path(handle.name) handle.close() - extract_audio_to_wav(source, output) + extract_audio_to_wav(source, output, progress_callback) return output - def _generate_remote_boundaries(self, audio_path) -> Dict[str, Any]: - filename = Path(audio_path).name - content_type = "audio/wav" if filename.lower().endswith(".wav") else "application/octet-stream" - headers = {"User-Agent": CLIENT_NAME} + def _fetch_remote_boundaries(self, media_hash: str) -> Optional[Dict[str, Any]]: + headers = {"User-Agent": CLIENT_NAME, "Accept": "application/json"} if self.auth_token: headers["Authorization"] = f"Bearer {self.auth_token}" + response = requests.get( + self.server_url + f"/api/v1/videos/{media_hash}/sentence-boundaries", + headers=headers, + timeout=(15, 30), + ) + if response.status_code == 404: + return None + if response.status_code == 401: + raise RuntimeError("请先在“服务…”中登录") + if response.status_code not in range(200, 300): + raise RuntimeError( + f"线上句边界查询返回 {response.status_code}:{self._server_error_detail(response)}" + ) + data = response.json() + if not data.get("sentences"): + return None + return data + + def _generate_remote_boundaries(self, audio_path, progress_callback=None) -> Dict[str, Any]: + filename = "audio" + Path(audio_path).suffix.lower() + content_type = "audio/wav" if filename.lower().endswith(".wav") else "application/octet-stream" + total_size = max(1, Path(audio_path).stat().st_size) + boundary = f"mediaplayer-{os.urandom(12).hex()}" + headers = { + "User-Agent": CLIENT_NAME, + "Content-Type": f"multipart/form-data; boundary={boundary}", + } + if self.auth_token: + headers["Authorization"] = f"Bearer {self.auth_token}" + + def multipart_chunks(): + prefix = ( + f"--{boundary}\r\n" + f'Content-Disposition: form-data; name="file"; filename="{filename}"\r\n' + f"Content-Type: {content_type}\r\n\r\n" + ).encode("utf-8") + yield prefix + processed = 0 + with Path(audio_path).open("rb") as audio_file: + while True: + chunk = audio_file.read(256 * 1024) + if not chunk: + break + yield chunk + processed += len(chunk) + if progress_callback: + progress_callback(min(1.0, processed / total_size)) + if progress_callback: + progress_callback(-1.0) + yield f"\r\n--{boundary}--\r\n".encode("utf-8") + with Path(audio_path).open("rb") as audio_file: response = requests.post( self.server_url + "/api/v1/audio/sentence-boundaries", headers=headers, - files={"file": (filename, audio_file, content_type)}, - data={"language": ""}, + data=multipart_chunks(), timeout=(15, 3600), ) if response.status_code == 401: