From 532d9900ba843410f13b9ffab6e8e196135e83ac Mon Sep 17 00:00:00 2001 From: Shuming Liu Date: Wed, 21 Oct 2026 19:39:04 +0800 Subject: [PATCH] repaire sentences --- README.md | 1 + media_player.py | 593 +++++++++++++++++++++++--- sentence_api/README.md | 1 + sentence_api/main.py | 30 ++ sentence_api/models.py | 17 + sentence_api/processing.py | 182 +++++++- sentence_api/repository.py | 47 ++ sentence_api/static/admin.css | 4 +- sentence_api/static/admin.html | 7 +- sentence_api/static/admin.js | 82 +++- sentence_api/tests/test_api.py | 136 ++++++ sentence_api/tests/test_processing.py | 176 ++++++++ sentence_api/translation_jobs.py | 17 + 13 files changed, 1233 insertions(+), 60 deletions(-) diff --git a/README.md b/README.md index 02982af..3f7693c 100644 --- a/README.md +++ b/README.md @@ -16,6 +16,7 @@ pip 安装依赖后即可直接播放视频。 缓存结果;生成过程有进度条。 - 画面字幕:句边界就绪后,当前句会叠在视频画面底部。可勾选“中文字幕” (快捷键 `C`)显示或关闭中文翻译;翻译由服务端生成并缓存。 +- 导出字幕:句边界就绪后可点“导出字幕”,默认保存为 UTF-8(带 BOM)的 `.ass`(中英同一条字幕避免重叠),也可另存 `.srt`。 - AI 讲解:播放中按 `E` 或点击“AI讲解 (E)”,由服务端 AI 讲解当前句子。 - 服务配置:点击“服务…”可填写服务器地址,或使用账号密码登录后保存令牌。 - 调试日志:点击“调试日志”可查看/复制请求状态、耗时和错误摘要;日志保存在 diff --git a/media_player.py b/media_player.py index 4adb109..34032a3 100644 --- a/media_player.py +++ b/media_player.py @@ -18,6 +18,7 @@ 勾选“按句跳转”后,←/→ 也会自动播放上一句/下一句 C 开 / 关中文字幕 E 讲解当前句子(AI) + 导出字幕 将当前中英文字幕保存为 SRT 文件 M 静音 / 恢复 Ctrl+← / → 上一首 / 下一首(macOS 也支持 Command+← / →) N / P 下一首 / 上一首 @@ -50,9 +51,9 @@ import tkinter as tk from tkinter import filedialog, messagebox, ttk try: - from PIL import Image as PILImage, ImageTk + from PIL import Image as PILImage, ImageDraw, ImageFont, ImageTk except ImportError: - PILImage = ImageTk = None + PILImage = ImageDraw = ImageFont = ImageTk = None try: import vlc @@ -80,6 +81,9 @@ SUPPORTED_EXTS = AUDIO_EXTS | VIDEO_EXTS LOOP_LABELS = {"off": "循环: 关", "one": "循环: 单曲", "list": "循环: 列表"} SPEED_PRESETS = [0.5, 0.75, 1.0, 1.25, 1.5, 2.0] +SUBTITLE_EN_FONT_SIZE = 32 +SUBTITLE_ZH_FONT_SIZE = 28 +SUBTITLE_BG_ALPHA = 51 # 20% opaque black SEEK_STEP = 10 SEEK_STEP_BIG = 60 DEFAULT_SERVER_URL = "https://videoservice.d1kt.cn" @@ -235,6 +239,26 @@ def _srt_timestamp(ms): return f"{hours:02d}:{minutes:02d}:{seconds:02d},{millis:03d}" +def _ass_timestamp(ms): + """把毫秒格式化为 ASS 时间戳 H:MM:SS.cs。""" + 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}:{minutes:02d}:{seconds:02d}.{millis // 10:02d}" + + +def _escape_ass_text(text: str) -> str: + return ( + str(text) + .replace("\\", "\\\\") + .replace("{", "\\{") + .replace("}", "\\}") + .replace("\n", "\\N") + .replace("\r", "") + ) + + class _BaseEngine: """播放内核统一接口。""" @@ -287,6 +311,9 @@ class _BaseEngine: """返回当前应显示的帧(PIL 图像),没有新帧时返回 None。""" return None + def set_display_size(self, width, height): + return None + def poll_events(self): events = [] while True: @@ -306,7 +333,14 @@ class VlcEngine(_BaseEngine): def __init__(self): super().__init__() - args = ["--no-video-title-show", "--quiet"] + args = [ + "--no-video-title-show", + "--quiet", + "--sub-text-scale=200", + "--freetype-background-opacity=51", + "--freetype-background-color=0x000000", + "--freetype-opacity=255", + ] if IS_MAC: args.append("--vout=macosx") self.instance = vlc.Instance(args) @@ -550,8 +584,11 @@ class AvEngine(_BaseEngine): self._video_stream = None self._audio_stream = None self._video_deque = collections.deque() - self._video_deque_max = 6 - self._audio_queue = queue.Queue(maxsize=64) + self._video_deque_max = 12 + self._pending_video = collections.deque() + self._audio_queue = queue.Queue(maxsize=128) + self._display_size = None + self._prerolling = False self._audio_leftover = None self._audio_stream_obj = None self._audio_ok = False @@ -571,9 +608,31 @@ class AvEngine(_BaseEngine): self._clock_started = time.monotonic() self._clock_running = False + def _open_container(self, path): + try: + from av.codec.hwaccel import HWAccel, hwdevices_available + available = set(hwdevices_available() or []) + if IS_MAC: + order = ("videotoolbox",) + elif IS_WIN: + order = ("d3d11va", "dxva2", "cuda") + else: + order = ("vaapi", "cuda") + for device in order: + if device not in available: + continue + try: + hw = HWAccel(device_type=device, allow_software_fallback=True) + return self._av.open(str(path), hwaccel=hw) + except Exception: + continue + except Exception: + pass + return self._av.open(str(path)) + def play(self, path): self.stop() - container = self._av.open(str(path)) + container = self._open_container(path) self._container = container videos = [s for s in container.streams if s.type == "video"] if videos: @@ -585,6 +644,15 @@ class AvEngine(_BaseEngine): ) else: self._video_stream = None + if self._video_stream is not None: + try: + self._video_stream.thread_type = "AUTO" + except Exception: + pass + try: + self._video_stream.codec_context.thread_count = 0 + except Exception: + pass self._audio_stream = next((s for s in container.streams if s.type == "audio"), None) self._gen += 1 self._eof = False @@ -594,22 +662,28 @@ class AvEngine(_BaseEngine): self._resampler = None self._audio_ok = False self._audio_broken = False + self._prerolling = True + self._pending_video.clear() if self._audio_stream is not None: try: - self._audio_stream_obj = self._sd.OutputStream( - samplerate=self.AUDIO_RATE, - channels=2, - dtype="float32", - callback=self._audio_callback, - ) - self._audio_stream_obj.start() + kwargs = { + "samplerate": self.AUDIO_RATE, + "channels": 2, + "dtype": "float32", + "callback": self._audio_callback, + "blocksize": 2048, + } + try: + self._audio_stream_obj = self._sd.OutputStream(latency="high", **kwargs) + except TypeError: + self._audio_stream_obj = self._sd.OutputStream(**kwargs) self._audio_ok = True except Exception: self._audio_stream_obj = None self._audio_written = 0 self._clock_base = 0.0 self._clock_started = time.monotonic() - self._clock_running = self._audio_stream_obj is None + self._clock_running = False self._stop_flag = threading.Event() self._thread = threading.Thread(target=self._decode_loop, daemon=True) self._thread.start() @@ -657,13 +731,14 @@ class AvEngine(_BaseEngine): self._drain_queue(self._audio_queue) self._audio_leftover = None self._audio_written = int(target * self.AUDIO_RATE) + self._pending_video.clear() + self._prerolling = True self._clock_base = target self._clock_started = time.monotonic() self._ended = False if self._audio_stream_obj is not None: try: self._audio_stream_obj.stop() - self._audio_stream_obj.start() except Exception: pass if self._thread is None or not self._thread.is_alive(): @@ -677,11 +752,22 @@ class AvEngine(_BaseEngine): def get_time(self): if not self.is_active(): return -1 - if self._audio_stream_obj is not None: + if self._prerolling: + return int(self._seek_after * 1000.0) + if self._audio_stream_obj is not None and not self._prerolling: with self._lock: return int(self._audio_written * 1000.0 / self.AUDIO_RATE) return int(self._noaudio_pos() * 1000.0) + def set_display_size(self, width, height): + try: + width = int(width) + height = int(height) + except (TypeError, ValueError): + return + if width >= 2 and height >= 2: + self._display_size = (width, height) + def _disable_audio(self, reason=""): """音频不可用时降级为无声播放,视频播放不受影响。""" stream = self._audio_stream_obj @@ -735,7 +821,7 @@ class AvEngine(_BaseEngine): return False def get_frame(self): - if not self.is_active(): + if not self.is_active() or self._prerolling: return None pos = self.get_time() / 1000.0 latest = None @@ -745,12 +831,78 @@ class AvEngine(_BaseEngine): if gen != self._gen: self._video_deque.popleft() continue - if secs > pos + 0.05: + if secs > pos + 0.02: break latest = img self._video_deque.popleft() return latest + def _video_space(self): + with self._lock: + return len(self._video_deque) < self._video_deque_max + + def _enqueue_video_frame(self, gen, secs, frame): + if gen != self._gen: + return + img = self._frame_to_image(frame) + if img is None: + return + with self._lock: + if gen != self._gen: + return + if len(self._video_deque) >= self._video_deque_max: + return + self._video_deque.append((gen, secs, img)) + + def _frame_to_image(self, frame): + try: + fw = int(getattr(frame, "width", 0) or 0) + fh = int(getattr(frame, "height", 0) or 0) + if fw < 1 or fh < 1: + return None + tw, th = fw, fh + display = self._display_size + if display: + dw, dh = display + if dw >= 2 and dh >= 2: + scale = min(dw / fw, dh / fh, 1.0) + tw = max(2, int(fw * scale) // 2 * 2) + th = max(2, int(fh * scale) // 2 * 2) + if tw > 1920: + th = max(2, int(th * 1920 / tw) // 2 * 2) + tw = 1920 + fmt = getattr(getattr(frame, "format", None), "name", "") or "" + if tw != fw or th != fh or fmt != "rgb24": + frame = frame.reformat(width=tw, height=th, format="rgb24") + return frame.to_image() + except Exception: + try: + return frame.to_image() + except Exception: + return None + + def _maybe_finish_preroll(self): + if not self._prerolling or self._stop_flag.is_set() or self._paused: + return + with self._lock: + video_ready = self._video_stream is None or len(self._video_deque) >= 6 + audio_ready = (not self._audio_ok) or self._audio_queue.qsize() >= 12 + if not video_ready or not audio_ready: + return + self._prerolling = False + self._clock_base = self._seek_after + self._clock_started = time.monotonic() + self._audio_written = int(self._seek_after * self.AUDIO_RATE) + if self._audio_stream_obj is not None: + try: + self._audio_stream_obj.start() + except Exception: + self._clock_running = True + return + self._clock_running = False + else: + self._clock_running = True + def poll_events(self): events = super().poll_events() if self._audio_broken and self._audio_stream_obj is not None: @@ -770,6 +922,37 @@ class AvEngine(_BaseEngine): def release(self): self.stop() + def _decode_video_packet(self, packet, gen): + video = self._video_stream + if video is None: + return + try: + frames = packet.decode() + except Exception: + return + for frame in frames: + if self._stop_flag.is_set() or gen != self._gen: + return + if frame.pts is None: + continue + secs = float(frame.pts * video.time_base) + if secs + 0.05 < self._seek_after: + continue + while not self._stop_flag.is_set() and gen == self._gen: + if self._paused: + time.sleep(0.01) + continue + if self._video_space(): + self._enqueue_video_frame(gen, secs, frame) + break + time.sleep(0.003) + + def _drain_pending_video(self, gen): + while self._pending_video and not self._stop_flag.is_set() and gen == self._gen: + if not self._video_space(): + return + self._decode_video_packet(self._pending_video.popleft(), gen) + def _decode_loop(self): try: container = self._container @@ -778,10 +961,15 @@ class AvEngine(_BaseEngine): streams = [s for s in (video, audio) if s is not None] demux = container.demux(*streams) while not self._stop_flag.is_set(): + if self._paused and self._seek_request is None: + time.sleep(0.02) + continue if self._seek_request is not None: with self._lock: target = self._seek_request self._seek_request = None + self._pending_video.clear() + self._prerolling = True seek_stream = video if video is not None else audio if seek_stream is not None: offset = int(round(target / float(seek_stream.time_base))) @@ -793,9 +981,19 @@ class AvEngine(_BaseEngine): self._resampler = None continue gen = self._gen + self._maybe_finish_preroll() + audio_low = self._audio_ok and self._audio_queue.qsize() < 12 + if not audio_low: + self._drain_pending_video(gen) try: packet = next(demux) except StopIteration: + while self._pending_video and not self._stop_flag.is_set() and gen == self._gen: + if self._video_space(): + self._decode_video_packet(self._pending_video.popleft(), gen) + else: + time.sleep(0.005) + self._maybe_finish_preroll() self._eof = True break except Exception: @@ -804,28 +1002,22 @@ class AvEngine(_BaseEngine): if self._stop_flag.is_set(): break if packet.stream.type == "video" and video is not None and packet.size: - try: - frames = packet.decode() - except Exception: - continue - for frame in frames: - if self._stop_flag.is_set(): - break - if frame.pts is None: - continue - secs = float(frame.pts * video.time_base) - if secs + 0.05 < self._seek_after: - continue - try: - img = frame.to_image() - except Exception: - continue - while not self._stop_flag.is_set(): - with self._lock: - if len(self._video_deque) < self._video_deque_max: - self._video_deque.append((gen, secs, img)) - break - time.sleep(0.005) + if self._video_space() and not self._pending_video: + self._decode_video_packet(packet, gen) + elif len(self._pending_video) < 90: + self._pending_video.append(packet) + else: + while ( + not self._stop_flag.is_set() + and gen == self._gen + and len(self._pending_video) >= 90 + ): + if self._video_space(): + self._decode_video_packet(self._pending_video.popleft(), gen) + else: + time.sleep(0.005) + if not self._stop_flag.is_set() and gen == self._gen: + self._pending_video.append(packet) elif packet.stream.type == "audio" and audio is not None and packet.size: if not self._audio_ok: continue @@ -835,6 +1027,7 @@ class AvEngine(_BaseEngine): self._disable_audio("音频解码出错(%s)" % exc) if self._resampler is not None and not self._stop_flag.is_set(): self._flush_resampler() + self._maybe_finish_preroll() except Exception as exc: self._eof = True print("解码线程异常退出:", exc) @@ -1044,6 +1237,14 @@ class MediaPlayerApp(tk.Tk): self._translation_error = "" self._subtitle_srt_key = None self._overlay_key = None + self._subtitle_english = "" + self._subtitle_chinese = "" + self._last_video_frame = None + self._subtitle_fonts = {} + self._canvas_image_id = None + self._subtitle_bar_cache = None + self._last_play_label = None + self._last_time_label = None self.engine = self._create_engine() if self.engine is None: @@ -1148,7 +1349,7 @@ class MediaPlayerApp(tk.Tk): text="", fg="#ffffff", bg="#000000", - font=("Helvetica", 16, "bold"), + font=("Helvetica", SUBTITLE_EN_FONT_SIZE, "bold"), wraplength=720, justify="center", ) @@ -1158,7 +1359,7 @@ class MediaPlayerApp(tk.Tk): text="", fg="#ffd54a", bg="#000000", - font=("Helvetica", 14), + font=("Helvetica", SUBTITLE_ZH_FONT_SIZE), wraplength=720, justify="center", ) @@ -1259,6 +1460,15 @@ class MediaPlayerApp(tk.Tk): takefocus=0, ) self.btn_ai_explain.pack(side="left", padx=(12, 0)) + self.btn_export_subtitles = ttk.Button( + controls, + text="导出字幕", + command=self.export_subtitles, + takefocus=0, + state="disabled", + ) + self.btn_export_subtitles.pack(side="left", padx=(12, 0)) + self.btn_export_subtitles.bind("", self._on_space) self.sentence_var = tk.StringVar(value="") self.sentence_label = ttk.Label( controls, @@ -1394,24 +1604,37 @@ class MediaPlayerApp(tk.Tk): self._on_media_end() elif event == "error": self.status_var.set("无法播放该文件,请检查文件格式或 VLC 是否已安装") + canvas = getattr(self, "video_canvas", None) + if canvas is not None: + setter = getattr(self.engine, "set_display_size", None) + if setter is not None: + setter(canvas.winfo_width(), canvas.winfo_height()) img = self.engine.get_frame() if img is not None: self._show_frame(img) pos = self.engine.get_time() length = self.engine.get_length() if pos >= 0 and not self._seeking: - self.time_var.set(f"{fmt_time(pos)} / {fmt_time(length)}") + time_label = f"{fmt_time(pos)} / {fmt_time(length)}" + if time_label != self._last_time_label: + self._last_time_label = time_label + self.time_var.set(time_label) if length > 0: self.seekbar.set_frac(pos / length) sentence = self._current_sentence() - text = str(sentence.get("text") or "").strip() if sentence else "" - self.sentence_var.set(text) + sentence_text = str(sentence.get("text") or "").strip() if sentence else "" + if sentence_text != self.sentence_var.get(): + self.sentence_var.set(sentence_text) self._update_on_screen_subtitles() playing = self.engine.is_playing() - self.btn_play.config(text="暂停" if playing else "播放") - self.after(33, self._tick) + play_label = "暂停" if playing else "播放" + if play_label != self._last_play_label: + self._last_play_label = play_label + self.btn_play.config(text=play_label) + software_video = isinstance(self.engine, AvEngine) and self.engine.is_playing() + self.after(16 if software_video else 50, self._tick) - def _show_frame(self, img): + def _show_frame(self, img, store=True): if ImageTk is None: return canvas = getattr(self, "video_canvas", None) @@ -1428,11 +1651,17 @@ class MediaPlayerApp(tk.Tk): size = (max(1, int(iw * scale)), max(1, int(ih * scale))) if size != img.size: img = img.resize(size, PILImage.Resampling.BILINEAR) - 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() + if store: + self._last_video_frame = img + display = self._compose_subtitles(img) + self._photo = ImageTk.PhotoImage(display) + x = width // 2 + y = height // 2 + if self._canvas_image_id is None: + self._canvas_image_id = canvas.create_image(x, y, image=self._photo, anchor="center") + else: + canvas.coords(self._canvas_image_id, x, y) + canvas.itemconfig(self._canvas_image_id, image=self._photo) def open_files(self): filetypes = [ @@ -1629,6 +1858,11 @@ class MediaPlayerApp(tk.Tk): if canvas is not None: canvas.delete("all") self._photo = None + self._canvas_image_id = None + self._last_video_frame = None + self._subtitle_bar_cache = None + self._subtitle_english = "" + self._subtitle_chinese = "" self.video_hint.place(relx=0.5, rely=0.5, anchor="center") self._update_sentence_controls() self._update_status() @@ -1826,6 +2060,9 @@ class MediaPlayerApp(tk.Tk): state="normal", text="重新生成" if has_boundaries else "生成句边界", ) + export_btn = getattr(self, "btn_export_subtitles", None) + if export_btn is not None: + export_btn.config(state="normal" if has_boundaries else "disabled") def _set_progress(self, value=None, message=None, indeterminate=False): if not hasattr(self, "boundary_progress"): @@ -1877,6 +2114,68 @@ class MediaPlayerApp(tk.Tk): self._update_on_screen_subtitles() self._sync_vlc_subtitles(force=True) + def export_subtitles(self): + path = self._current_path() + if path is None: + messagebox.showinfo("导出字幕", "请先打开并选择一个媒体文件。") + return + sentences = (self._boundaries.get(path) or {}).get("sentences") or [] + if not sentences: + messagebox.showinfo("导出字幕", "请先生成句边界后再导出字幕。") + return + english_count = sum(1 for sentence in sentences if str(sentence.get("text") or "").strip()) + chinese_count = sum(1 for sentence in sentences if str(sentence.get("zh_text") or "").strip()) + if english_count == 0 and chinese_count == 0: + messagebox.showinfo("导出字幕", "当前句边界没有可导出的字幕文本。") + return + include_chinese = chinese_count > 0 + if not include_chinese: + if not messagebox.askyesno( + "导出字幕", + "还没有中文翻译,是否只导出英文字幕?\n可先打开“中文字幕”等待翻译完成后再导出双语字幕。", + ): + return + media = Path(path) + suffix = "en-zh" if include_chinese else "en" + default_name = f"{media.stem}.{suffix}.ass" + destination = filedialog.asksaveasfilename( + title="导出中英文字幕", + defaultextension=".ass", + initialdir=str(media.parent), + initialfile=default_name, + filetypes=[ + ("ASS 字幕(推荐,QQPlayer 更清晰)", "*.ass"), + ("SubRip 字幕", "*.srt"), + ("所有文件", "*"), + ], + ) + if not destination: + return + dest = Path(destination) + if dest.suffix.lower() == ".srt": + content = self._build_srt_content(sentences, include_chinese=include_chinese) + else: + if dest.suffix.lower() not in {".ass", ".ssa"}: + dest = dest.with_suffix(".ass") + content = self._build_ass_content( + sentences, + include_chinese=include_chinese, + title=media.stem, + ) + if not content.strip(): + messagebox.showinfo("导出字幕", "没有可写入字幕文件的内容。") + return + try: + dest.write_text(content, encoding="utf-8-sig") + except Exception as exc: + messagebox.showerror("导出字幕", f"保存失败:{exc}") + return + destination = str(dest) + note = "" + if include_chinese and chinese_count < english_count: + note = f"(其中 {chinese_count}/{english_count} 句已有中文)" + self.status_var.set(f"字幕已导出:{Path(destination).name}{note}") + def generate_sentence_boundaries(self): path = self._current_path() if path is None: @@ -2213,6 +2512,139 @@ class MediaPlayerApp(tk.Tk): detail = str(detail) return detail if len(detail) <= 300 else detail[:297] + "..." + def _subtitle_font(self, size, bold=False): + key = (size, bold) + cached = self._subtitle_fonts.get(key) + if cached is not None: + return cached + if ImageFont is None: + return None + candidates = [ + "/System/Library/Fonts/PingFang.ttc", + "/System/Library/Fonts/STHeiti Light.ttc", + "/System/Library/Fonts/Hiragino Sans GB.ttc", + "/Library/Fonts/Arial Unicode.ttf", + "C:/Windows/Fonts/msyhbd.ttc" if bold else "C:/Windows/Fonts/msyh.ttc", + "C:/Windows/Fonts/msyh.ttc", + "/usr/share/fonts/opentype/noto/NotoSansCJK-Regular.ttc", + "/usr/share/fonts/truetype/noto/NotoSansCJK-Regular.ttc", + "/usr/share/fonts/truetype/wqy/wqy-microhei.ttc", + ] + font = None + for path in candidates: + if not path or not Path(path).exists(): + continue + try: + font = ImageFont.truetype(path, size=size) + break + except Exception: + continue + if font is None: + try: + font = ImageFont.load_default() + except Exception: + font = None + self._subtitle_fonts[key] = font + return font + + @staticmethod + def _wrap_subtitle_text(draw, text, font, max_width): + if not text: + return [] + lines = [] + for paragraph in str(text).splitlines() or [text]: + current = "" + for char in paragraph: + trial = current + char + bbox = draw.textbbox((0, 0), trial, font=font, stroke_width=2) + if bbox[2] - bbox[0] <= max_width or not current: + current = trial + else: + lines.append(current) + current = char + if current: + lines.append(current) + return lines + + def _compose_subtitles(self, img): + english = getattr(self, "_subtitle_english", "") or "" + chinese = getattr(self, "_subtitle_chinese", "") or "" + if (not english and not chinese) or PILImage is None or ImageDraw is None: + return img + overlay, bar_top = self._subtitle_overlay_bar(english, chinese, img.width, img.height) + if overlay is None: + return img + rgb = img if img.mode == "RGB" else img.convert("RGB") + out = rgb.copy() + region = out.crop((0, bar_top, rgb.width, rgb.height)).convert("RGBA") + composed = PILImage.alpha_composite(region, overlay) + out.paste(composed.convert("RGB"), (0, bar_top)) + return out + + def _subtitle_overlay_bar(self, english, chinese, width, height): + key = (english, chinese, width, height) + cached = self._subtitle_bar_cache + if cached and cached[0] == key: + return cached[1], cached[2] + en_font = self._subtitle_font(SUBTITLE_EN_FONT_SIZE, bold=True) + zh_font = self._subtitle_font(SUBTITLE_ZH_FONT_SIZE, bold=False) + if en_font is None or zh_font is None: + return None, 0 + measure = PILImage.new("RGBA", (max(1, width), 8), (0, 0, 0, 0)) + draw = ImageDraw.Draw(measure) + max_width = max(40, int(width * 0.90)) + en_lines = self._wrap_subtitle_text(draw, english, en_font, max_width) + zh_lines = self._wrap_subtitle_text(draw, chinese, zh_font, max_width) + if not en_lines and not zh_lines: + self._subtitle_bar_cache = (key, None, 0) + return None, 0 + + def line_height(line, font): + bbox = draw.textbbox((0, 0), line, font=font, stroke_width=2) + return max(1, bbox[3] - bbox[1]) + + gap = 8 + padding_y = 14 + content_h = 0 + for line in en_lines: + content_h += line_height(line, en_font) + gap + for line in zh_lines: + content_h += line_height(line, zh_font) + gap + if content_h: + content_h -= gap + bar_h = min(height, content_h + padding_y * 2) + bar_top = max(0, height - bar_h - max(8, int(height * 0.03))) + overlay = PILImage.new("RGBA", (width, height - bar_top), (0, 0, 0, 0)) + draw = ImageDraw.Draw(overlay) + draw.rectangle((0, 0, width, overlay.height), fill=(0, 0, 0, SUBTITLE_BG_ALPHA)) + y = padding_y + for line in en_lines: + bbox = draw.textbbox((0, 0), line, font=en_font, stroke_width=2) + x = max(0, (width - (bbox[2] - bbox[0])) // 2) + draw.text( + (x, y), + line, + font=en_font, + fill=(255, 255, 255, 255), + stroke_width=2, + stroke_fill=(0, 0, 0, 255), + ) + y += line_height(line, en_font) + gap + for line in zh_lines: + bbox = draw.textbbox((0, 0), line, font=zh_font, stroke_width=2) + x = max(0, (width - (bbox[2] - bbox[0])) // 2) + draw.text( + (x, y), + line, + font=zh_font, + fill=(255, 213, 74, 255), + stroke_width=2, + stroke_fill=(0, 0, 0, 255), + ) + y += line_height(line, zh_font) + gap + self._subtitle_bar_cache = (key, overlay, bar_top) + return overlay, bar_top + def _on_video_resize(self, event): if event.widget is not getattr(self, "video_frame", None): return @@ -2251,10 +2683,22 @@ class MediaPlayerApp(tk.Tk): chinese = "正在翻译中文字幕…" else: chinese = "暂无中文字幕" + self._subtitle_english = english + self._subtitle_chinese = chinese key = (english, chinese, bool(self.show_chinese_subtitles.get())) - if key == self._overlay_key: - return + changed = key != self._overlay_key self._overlay_key = key + canvas = getattr(self, "video_canvas", None) + if canvas is not None and self._last_video_frame is not None: + try: + overlay.place_forget() + except tk.TclError: + pass + if changed: + self._show_frame(self._last_video_frame, store=False) + return + if not changed: + return try: self.subtitle_en.configure(text=english) if chinese: @@ -2290,6 +2734,45 @@ class MediaPlayerApp(tk.Tk): number += 1 return "\n\n".join(blocks) + ("\n" if blocks else "") + def _build_ass_content(self, sentences, include_chinese: bool, title: str = "") -> str: + events = [] + 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 + start = _ass_timestamp(sentence.get("start_ms") or 0) + end = _ass_timestamp(sentence.get("end_ms") or 0) + if english and chinese: + text = "{{\\an2\\fnMicrosoft YaHei\\b1\\fs60}}{}\\N{{\\b0\\fs48}}{}".format( + _escape_ass_text(english), + _escape_ass_text(chinese), + ) + elif english: + text = "{{\\an2\\fnMicrosoft YaHei\\b1\\fs60}}{}".format(_escape_ass_text(english)) + else: + text = "{{\\an2\\fnMicrosoft YaHei\\b0\\fs48}}{}".format(_escape_ass_text(chinese)) + events.append("Dialogue: 0,{},{},Default,,0,0,0,,{}".format(start, end, text)) + safe_title = (title or "Subtitles").replace("\r", " ").replace("\n", " ") + header_lines = [ + "[Script Info]", + "Title: " + safe_title, + "ScriptType: v4.00+", + "WrapStyle: 0", + "ScaledBorderAndShadow: yes", + "Collisions: Normal", + "PlayResX: 1920", + "PlayResY: 1080", + "", + "[V4+ Styles]", + "Format: Name, Fontname, Fontsize, PrimaryColour, SecondaryColour, OutlineColour, BackColour, Bold, Italic, Underline, StrikeOut, ScaleX, ScaleY, Spacing, Angle, BorderStyle, Outline, Shadow, Alignment, MarginL, MarginR, MarginV, Encoding", + "Style: Default,Microsoft YaHei,52,&H00FFFFFF,&H000000FF,&H00000000,&H00000000,-1,0,0,0,100,100,0,0,1,3,0,2,80,80,50,1", + "", + "[Events]", + "Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text", + ] + return "\r\n".join(header_lines + events) + "\r\n" + def _sync_vlc_subtitles(self, force=False): if not isinstance(self.engine, VlcEngine): return diff --git a/sentence_api/README.md b/sentence_api/README.md index 501ef8a..c4aa92b 100644 --- a/sentence_api/README.md +++ b/sentence_api/README.md @@ -59,6 +59,7 @@ POST /api/v1/videos/{sha256}/sentences/{index}/assessments POST /api/v1/admin/videos PUT /api/v1/admin/videos/raw POST /api/v1/admin/videos/{sha256}/process +POST /api/v1/admin/videos/{sha256}/repair-gaps GET /api/v1/admin/videos/{sha256}/subtitle-translation POST /api/v1/admin/videos/{sha256}/subtitle-translation PUT /api/v1/admin/videos/{sha256}/sentences/{index} diff --git a/sentence_api/main.py b/sentence_api/main.py index 25ac8b6..c5c3619 100644 --- a/sentence_api/main.py +++ b/sentence_api/main.py @@ -43,6 +43,8 @@ from .models import ( CourseSummary, CourseTitleUpdate, SentenceBoundaryAdjust, + SentenceGapRepairRequest, + SentenceGapRepairResponse, SentenceBoundary, SentenceBoundaryDocument, SentenceExplanationRequest, @@ -688,6 +690,34 @@ def create_app( background_tasks.add_task(_process_safely, processor, video_hash) return {"video_hash": video_hash.lower(), "status": "processing_queued"} + @application.post( + "/api/v1/admin/videos/{video_hash}/repair-gaps", + response_model=SentenceGapRepairResponse, + dependencies=[Depends(require_admin)], + ) + async def repair_sentence_gaps( + payload: Optional[SentenceGapRepairRequest] = None, + video_hash: str = SHA256_PATH, + ) -> SentenceGapRepairResponse: + request = payload or SentenceGapRepairRequest() + try: + result = await run_in_threadpool( + processor.repair_gaps, + video_hash, + request.min_gap_ms, + ) + except KeyError: + raise HTTPException(status_code=404, detail="Video was not found.") from None + except FileNotFoundError as exc: + raise HTTPException(status_code=404, detail=str(exc)) from exc + except ValueError as exc: + raise HTTPException(status_code=400, detail=str(exc)) from exc + except RuntimeError as exc: + raise HTTPException(status_code=503, detail=str(exc)) from exc + if result["inserted_count"]: + application.state.subtitle_translations.start_missing(video_hash) + return SentenceGapRepairResponse(video_hash=video_hash.lower(), **result) + @application.get( "/api/v1/admin/videos/{video_hash}/subtitle-translation", response_model=AdminSubtitleTranslationResponse, diff --git a/sentence_api/models.py b/sentence_api/models.py index b428897..afba7ba 100644 --- a/sentence_api/models.py +++ b/sentence_api/models.py @@ -185,6 +185,23 @@ class SentenceBoundaryAdjust(BaseModel): delta_ms: int = Field(ge=-10_000, le=10_000) +class SentenceGapRepairRequest(BaseModel): + model_config = ConfigDict(extra="forbid") + + min_gap_ms: int = Field(default=8000, ge=2000, le=180_000) + + +class SentenceGapRepairResponse(BaseModel): + model_config = ConfigDict(extra="forbid") + + video_hash: str + min_gap_ms: int = Field(ge=1) + gap_count: int = Field(ge=0) + transcribed_gaps: int = Field(ge=0) + inserted_count: int = Field(ge=0) + sentence_count: int = Field(ge=0) + + class TextSubstitution(BaseModel): expected: str actual: str diff --git a/sentence_api/processing.py b/sentence_api/processing.py index eb1e00e..a70a58a 100644 --- a/sentence_api/processing.py +++ b/sentence_api/processing.py @@ -4,7 +4,7 @@ import subprocess import tempfile import uuid from pathlib import Path -from typing import Any, Dict, List, Optional +from typing import Any, Dict, List, Optional, Sequence, Tuple from .audio_metrics import ( AudioAnalysisError, @@ -29,6 +29,10 @@ from .transcription import ( logger = logging.getLogger(__name__) MOSS_ALGORITHM_VERSION = "moss-punctuation-v4" SILENCE_ALGORITHM_VERSION = "silence-rms-v1" +GAP_REPAIR_MIN_MS = 8_000 +GAP_REPAIR_INSET_MS = 150 +GAP_REPAIR_MIN_SPEECH_MS = 400 +GAP_REPAIR_MIN_SENTENCE_MS = 100 _CUES_ID = b"\x1e\x53\xb6\x80" _CLUSTER_ID = b"\x1f\x43\xb6\x75" _HEADER_SCAN_SIZE = 64 * 1024 @@ -173,6 +177,106 @@ class VideoProcessor: ) self.repository.mark_oss_uploaded(document.video_hash, remote_url) + def repair_gaps( + self, + video_hash: str, + min_gap_ms: int = GAP_REPAIR_MIN_MS, + ) -> Dict[str, int]: + """Re-transcribe long unsentenced gaps without replacing existing cues.""" + if min_gap_ms < 1: + raise ValueError("min_gap_ms must be positive") + if not self.transcriber.available: + raise RuntimeError("MOSS transcription is not configured on this server.") + video = self.repository.get_video(video_hash) + if video is None: + raise KeyError(video_hash) + document = self.repository.get_document(video_hash) + if document is None or not document.sentences: + raise ValueError("Sentence boundaries are not available for this video.") + media_path = self.settings.videos_dir / video["stored_filename"] + if not media_path.is_file(): + raise FileNotFoundError(f"Stored video is missing: {media_path.name}") + + gaps = find_sentence_gaps(document, min_gap_ms) + inserted: List[SentenceBoundary] = [] + transcribed_gaps = 0 + if gaps: + language = video.get("language") + with tempfile.TemporaryDirectory(prefix="gap-repair-") as temp_dir: + for gap_index, (raw_start, raw_end) in enumerate(gaps): + clipped = _inset_gap(raw_start, raw_end, document.duration_ms) + if clipped is None: + continue + start_ms, end_ms = clipped + chunk_path = Path(temp_dir) / f"gap-{gap_index}.wav" + try: + extract_audio_segment( + media_path, + chunk_path, + start_ms=start_ms, + duration_ms=end_ms - start_ms, + ) + samples, sample_rate = decode_audio_mono(chunk_path) + metrics = analyze_samples(samples, sample_rate) + except (AudioAnalysisError, RuntimeError) as exc: + logger.info( + "Skipping sentence gap %s-%sms: %s", + start_ms, + end_ms, + exc, + ) + continue + if metrics.speech_duration_ms < GAP_REPAIR_MIN_SPEECH_MS: + continue + try: + transcript = self.transcriber.transcribe(chunk_path, language) + except Exception as exc: + logger.warning( + "Gap transcription failed for %s %s-%sms: %s", + video_hash, + start_ms, + end_ms, + exc, + ) + continue + gap_document = document_from_transcript( + video_hash=video_hash, + duration_ms=end_ms - start_ms, + transcript=transcript, + language=language, + audio_path=chunk_path, + end_padding_ms=self.settings.moss_end_padding_ms, + ) + shifted = _shift_gap_sentences( + gap_document.sentences, + offset_ms=start_ms, + limit_end_ms=end_ms, + ) + if not shifted: + continue + transcribed_gaps += 1 + inserted.extend(shifted) + + if not inserted: + return { + "min_gap_ms": min_gap_ms, + "gap_count": len(gaps), + "transcribed_gaps": transcribed_gaps, + "inserted_count": 0, + "sentence_count": len(document.sentences), + } + + merged = _merge_sentence_lists(document.sentences, inserted) + updated = document.model_copy(update={"sentences": merged}) + saved = self.repository.replace_sentences(updated) + return { + "min_gap_ms": min_gap_ms, + "gap_count": len(gaps), + "transcribed_gaps": transcribed_gaps, + "inserted_count": len(saved.sentences) - len(document.sentences), + "sentence_count": len(saved.sentences), + } + def extract_audio(media_path: Path, output_path: Path) -> None: ffmpeg = shutil.which("ffmpeg") @@ -241,6 +345,82 @@ def extract_audio_segment( raise RuntimeError(f"Could not extract sentence audio: {message[-2000:]}") +def find_sentence_gaps( + document: SentenceBoundaryDocument, + min_gap_ms: int = GAP_REPAIR_MIN_MS, +) -> List[Tuple[int, int]]: + """Return [start_ms, end_ms) ranges that are missing sentence coverage.""" + if min_gap_ms < 1: + raise ValueError("min_gap_ms must be positive") + gaps: List[Tuple[int, int]] = [] + cursor = 0 + for sentence in document.sentences: + if sentence.start_ms - cursor >= min_gap_ms: + gaps.append((cursor, sentence.start_ms)) + cursor = max(cursor, sentence.end_ms) + if document.duration_ms - cursor >= min_gap_ms: + gaps.append((cursor, document.duration_ms)) + return gaps + + +def _inset_gap( + start_ms: int, + end_ms: int, + duration_ms: int, + inset_ms: int = GAP_REPAIR_INSET_MS, +) -> Optional[Tuple[int, int]]: + start = start_ms + (inset_ms if start_ms > 0 else 0) + end = end_ms - (inset_ms if end_ms < duration_ms else 0) + if end - start < GAP_REPAIR_MIN_SPEECH_MS: + return None + return start, end + + +def _shift_gap_sentences( + sentences: Sequence[SentenceBoundary], + *, + offset_ms: int, + limit_end_ms: int, +) -> List[SentenceBoundary]: + shifted: List[SentenceBoundary] = [] + previous_end = offset_ms + for sentence in sentences: + start_ms = max(previous_end, sentence.start_ms + offset_ms) + end_ms = min(limit_end_ms, sentence.end_ms + offset_ms) + text = (sentence.text or "").strip() + if not text or end_ms - start_ms < GAP_REPAIR_MIN_SENTENCE_MS: + continue + shifted.append( + sentence.model_copy( + update={ + "index": len(shifted), + "start_ms": start_ms, + "end_ms": end_ms, + "text": text, + "zh_text": None, + } + ) + ) + previous_end = end_ms + return shifted + + +def _merge_sentence_lists( + existing: Sequence[SentenceBoundary], + inserted: Sequence[SentenceBoundary], +) -> List[SentenceBoundary]: + combined = [*existing, *inserted] + combined.sort(key=lambda sentence: (sentence.start_ms, sentence.end_ms)) + merged: List[SentenceBoundary] = [] + previous_end = 0 + for sentence in combined: + if sentence.start_ms < previous_end: + continue + merged.append(sentence.model_copy(update={"index": len(merged)})) + previous_end = sentence.end_ms + return merged + + def split_sentence_text( *, transcriber: Transcriber, diff --git a/sentence_api/repository.py b/sentence_api/repository.py index c251f0d..c79f999 100644 --- a/sentence_api/repository.py +++ b/sentence_api/repository.py @@ -544,6 +544,53 @@ class VideoRepository: sentences=[_sentence_from_row(row) for row in rows], ) + def replace_sentences( + self, + document: SentenceBoundaryDocument, + ) -> SentenceBoundaryDocument: + """Replace stored sentences while preserving video status and translations.""" + normalized_hash = normalize_video_hash(document.video_hash) + with self._connect() as connection: + existing = connection.execute( + "SELECT video_hash FROM videos WHERE video_hash = ?", + (normalized_hash,), + ).fetchone() + if existing is None: + raise KeyError(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 (?, ?, ?, ?, ?, ?, ?, ?) + """, + [ + ( + normalized_hash, + sentence.index, + sentence.start_ms, + sentence.end_ms, + sentence.text, + sentence.language, + sentence.zh_text, + sentence.reference_speech_duration_ms, + ) + for sentence in document.sentences + ], + ) + connection.execute( + "UPDATE videos SET updated_at = ? WHERE video_hash = ?", + (utc_now(), normalized_hash), + ) + saved = self.get_document(normalized_hash) + if saved is None: + raise KeyError(normalized_hash) + return saved + def update_sentence_text( self, video_hash: str, diff --git a/sentence_api/static/admin.css b/sentence_api/static/admin.css index a998f18..532e8eb 100644 --- a/sentence_api/static/admin.css +++ b/sentence_api/static/admin.css @@ -81,7 +81,7 @@ table { width: 100%; border-collapse: collapse; font-size: 13px; } th, td { padding: 10px 12px; text-align: left; border-bottom: 1px solid #e3e7ea; vertical-align: middle; } th { position: sticky; top: 0; z-index: 1; color: #44515b; background: #edf1f3; font-weight: 600; } tr:last-child td { border-bottom: 0; } -.actions-column { min-width: 280px; } +.actions-column { min-width: 340px; } .actions { display: flex; gap: 6px; white-space: nowrap; } .actions button, .actions a { min-height: 30px; padding: 5px 9px; font-size: 12px; } .actions a { display: inline-flex; align-items: center; border: 1px solid #aab4bc; border-radius: 4px; color: #26323c; text-decoration: none; } @@ -103,6 +103,8 @@ dialog { dialog::backdrop { background: rgb(20 25 28 / 55%); } .dialog-heading { padding: 16px 18px; display: flex; justify-content: space-between; align-items: center; border-bottom: 1px solid #dce1e5; } .dialog-heading span { font-size: 12px; color: #68747d; } +.dialog-heading-actions { display: flex; align-items: center; gap: 8px; } +.gap-row td { background: #fff8ee; color: #9a5b12; font-style: italic; } .sentence-scroll { max-height: calc(100vh - 120px); overflow: auto; } .sentence-scroll textarea { width: min(600px, 45vw); min-width: 260px; min-height: 58px; resize: vertical; padding: 7px; border: 1px solid #b8c0c7; border-radius: 3px; } .sentence-scroll select { min-width: 90px; } diff --git a/sentence_api/static/admin.html b/sentence_api/static/admin.html index 8c0b388..c7d637e 100644 --- a/sentence_api/static/admin.html +++ b/sentence_api/static/admin.html @@ -122,7 +122,10 @@

句子文本

- +
+ + +
@@ -134,6 +137,6 @@ - + diff --git a/sentence_api/static/admin.js b/sentence_api/static/admin.js index e65c3a7..234f2ba 100644 --- a/sentence_api/static/admin.js +++ b/sentence_api/static/admin.js @@ -1,8 +1,11 @@ +const GAP_REPAIR_MIN_MS = 8000; + const state = { adminKey: localStorage.getItem("oralTrainerAdminKey") || "", videos: [], users: [], retranslateVideo: null, + sentenceVideo: null, }; const elements = { @@ -23,6 +26,7 @@ const elements = { sentenceMeta: document.querySelector("#sentence-meta"), sentenceRows: document.querySelector("#sentence-rows"), closeDialog: document.querySelector("#close-dialog"), + repairGaps: document.querySelector("#repair-gaps"), retranslateDialog: document.querySelector("#retranslate-dialog"), retranslateTitle: document.querySelector("#retranslate-title"), retranslateMeta: document.querySelector("#retranslate-meta"), @@ -45,6 +49,9 @@ elements.refresh.addEventListener("click", () => { loadUsers(); }); elements.closeDialog.addEventListener("click", () => elements.dialog.close()); +elements.repairGaps.addEventListener("click", () => { + if (state.sentenceVideo) repairGaps(state.sentenceVideo, { reloadSentences: true }); +}); elements.closeRetranslateDialog.addEventListener("click", () => elements.retranslateDialog.close()); elements.retranslateForm.addEventListener("submit", submitRetranslate); elements.uploadForm.addEventListener("submit", uploadVideo); @@ -192,6 +199,7 @@ function actionCell(video) { actions.append(play); actions.append(button("改标题", () => renameVideo(video))); actions.append(button("句子", () => openSentences(video))); + actions.append(button("修复空白", () => repairGaps(video))); actions.append(button("重新翻译", () => openRetranslate(video))); actions.append(button("重新处理", () => reprocess(video.video_hash))); const remove = button("删除", () => deleteVideo(video)); @@ -282,19 +290,61 @@ function readXhrError(request) { async function openSentences(video) { try { const detail = await api(`/api/v1/videos/${video.video_hash}`); + state.sentenceVideo = video; elements.sentenceTitle.textContent = detail.video.title; const sentences = detail.boundaries?.sentences || []; - elements.sentenceMeta.textContent = `${sentences.length} 句 / ${detail.video.video_hash}`; + const durationMs = detail.video.duration_ms || 0; + const gaps = collectSentenceGaps(sentences, durationMs); + const gapLabel = gaps.length ? ` / ${gaps.length} 段空白待修复` : ""; + elements.sentenceMeta.textContent = `${sentences.length} 句${gapLabel} / ${detail.video.video_hash}`; + elements.repairGaps.disabled = gaps.length === 0; elements.sentenceRows.replaceChildren(); + let gapIndex = 0; for (const sentence of sentences) { + if (gapIndex < gaps.length && gaps[gapIndex].end_ms === sentence.start_ms) { + elements.sentenceRows.append(gapRow(gaps[gapIndex])); + gapIndex += 1; + } elements.sentenceRows.append(sentenceRow(video, sentence, sentences.length)); } + if (gapIndex < gaps.length) { + elements.sentenceRows.append(gapRow(gaps[gapIndex])); + } if (!elements.dialog.open) elements.dialog.showModal(); } catch (error) { elements.serviceState.textContent = `句子加载失败: ${error.message}`; } } +function collectSentenceGaps(sentences, durationMs, minGapMs = GAP_REPAIR_MIN_MS) { + const gaps = []; + let cursor = 0; + for (const sentence of sentences) { + if (sentence.start_ms - cursor >= minGapMs) { + gaps.push({ start_ms: cursor, end_ms: sentence.start_ms }); + } + cursor = Math.max(cursor, sentence.end_ms); + } + const endMs = durationMs || cursor; + if (endMs - cursor >= minGapMs) { + gaps.push({ start_ms: cursor, end_ms: endMs }); + } + return gaps; +} + +function gapRow(gap) { + const row = document.createElement("tr"); + row.className = "gap-row"; + row.append( + textCell(""), + textCell(`${formatDuration(gap.start_ms)} — ${formatDuration(gap.end_ms)}`), + textCell(`未识别空白 ${formatDuration(gap.end_ms - gap.start_ms)}`), + textCell(""), + textCell(""), + ); + return row; +} + function sentenceRow(video, sentence, totalCount) { const row = document.createElement("tr"); const textArea = document.createElement("textarea"); @@ -499,6 +549,36 @@ async function reprocess(videoHash) { } } +async function repairGaps(video, options = {}) { + const confirmed = window.confirm( + `将对“${video.title}”中间隔超过 8 秒的空白重新识别。现有句子会保留,可能需要几分钟。继续?`, + ); + if (!confirmed) return; + const button = elements.repairGaps; + if (button) button.disabled = true; + elements.serviceState.textContent = "正在重新识别空白段…"; + try { + const payload = await api(`/api/v1/admin/videos/${video.video_hash}/repair-gaps`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ min_gap_ms: GAP_REPAIR_MIN_MS }), + }); + if (payload.inserted_count) { + elements.serviceState.textContent = `空白修复完成:补了 ${payload.inserted_count} 句(${payload.transcribed_gaps}/${payload.gap_count} 段)`; + } else if (payload.gap_count) { + elements.serviceState.textContent = `发现 ${payload.gap_count} 段空白,但没有识别到新句子`; + } else { + elements.serviceState.textContent = "没有发现需要修复的空白段"; + } + await loadVideos(); + if (options.reloadSentences) await openSentences(video); + } catch (error) { + elements.serviceState.textContent = `空白修复失败: ${error.message}`; + } finally { + if (button && !options.reloadSentences) button.disabled = false; + } +} + async function deleteVideo(video) { if (!window.confirm(`删除“${video.title}”?`)) return; try { diff --git a/sentence_api/tests/test_api.py b/sentence_api/tests/test_api.py index b9976da..44a7864 100644 --- a/sentence_api/tests/test_api.py +++ b/sentence_api/tests/test_api.py @@ -1029,3 +1029,139 @@ def test_admin_can_update_course_title(tmp_path): json={"title": "Missing course"}, ) assert missing_response.status_code == 404 + + + +def test_admin_can_repair_sentence_gaps(tmp_path, monkeypatch): + settings = replace(Settings.from_env(), data_dir=tmp_path / "data") + settings.ensure_directories() + repository = VideoRepository(settings.database_path) + repository.upsert_upload( + video_hash=VIDEO_HASH, + title="Repair gaps lesson", + filename="lesson.mp4", + stored_filename=f"{VIDEO_HASH}.mp4", + content_type="video/mp4", + size_bytes=5, + language="en", + ) + (settings.videos_dir / f"{VIDEO_HASH}.mp4").write_bytes(b"fake-video") + repository.save_processing_result( + SentenceBoundaryDocument( + video_hash=VIDEO_HASH, + duration_ms=18000, + algorithm_version="test", + sentences=[ + SentenceBoundary( + index=0, + start_ms=0, + end_ms=2000, + text="Hello.", + language="en", + reference_speech_duration_ms=1800, + ), + SentenceBoundary( + index=1, + start_ms=15000, + end_ms=18000, + text="Goodbye.", + language="en", + reference_speech_duration_ms=2500, + ), + ], + ), + "Hello. Goodbye.", + ) + repository.save_sentence_translations(VIDEO_HASH, {0: "你好。", 1: "再见。"}) + + from sentence_api.transcription import Transcript, TranscriptionSegment, _wav_bytes + import numpy as np + + class GapTranscriber: + available = True + + def transcribe(self, audio_path, language=None): + return Transcript( + text="The missing sentence.", + segments=[ + TranscriptionSegment( + start_seconds=1.0, + end_seconds=5.0, + text="The missing sentence.", + ) + ], + ) + + def fake_extract(media_path, output_path, *, start_ms, duration_ms): + sample_rate = 16000 + t = np.arange(int(max(1.0, duration_ms / 1000) * sample_rate)) / sample_rate + samples = (0.25 * np.sin(2 * np.pi * 220 * t)).astype(np.float32) + output_path.write_bytes(_wav_bytes(samples, sample_rate)) + + monkeypatch.setattr("sentence_api.processing.extract_audio_segment", fake_extract) + client = TestClient( + create_app( + BoundaryStore(tmp_path / "boundaries.json"), + settings=settings, + repository=repository, + transcriber=GapTranscriber(), + ) + ) + + response = client.post(f"/api/v1/admin/videos/{VIDEO_HASH}/repair-gaps") + + assert response.status_code == 200, response.text + payload = response.json() + assert payload["gap_count"] == 1 + assert payload["inserted_count"] == 1 + assert payload["sentence_count"] == 3 + document = repository.get_document(VIDEO_HASH) + assert [item.text for item in document.sentences] == [ + "Hello.", + "The missing sentence.", + "Goodbye.", + ] + assert [item.zh_text for item in document.sentences] == ["你好。", None, "再见。"] + + +def test_admin_gap_repair_requires_transcriber(tmp_path): + settings = replace(Settings.from_env(), data_dir=tmp_path / "data") + settings.ensure_directories() + repository = VideoRepository(settings.database_path) + repository.upsert_upload( + video_hash=VIDEO_HASH, + title="Repair gaps lesson", + filename="lesson.mp4", + stored_filename=f"{VIDEO_HASH}.mp4", + content_type="video/mp4", + size_bytes=5, + language="en", + ) + (settings.videos_dir / f"{VIDEO_HASH}.mp4").write_bytes(b"fake-video") + repository.save_processing_result( + SentenceBoundaryDocument( + video_hash=VIDEO_HASH, + duration_ms=18000, + algorithm_version="test", + sentences=[ + SentenceBoundary(index=0, start_ms=0, end_ms=2000, text="Hello."), + SentenceBoundary(index=1, start_ms=15000, end_ms=18000, text="Goodbye."), + ], + ), + None, + ) + + class UnavailableTranscriber: + available = False + + client = TestClient( + create_app( + BoundaryStore(tmp_path / "boundaries.json"), + settings=settings, + repository=repository, + transcriber=UnavailableTranscriber(), + ) + ) + + response = client.post(f"/api/v1/admin/videos/{VIDEO_HASH}/repair-gaps") + assert response.status_code == 503 diff --git a/sentence_api/tests/test_processing.py b/sentence_api/tests/test_processing.py index 21ad3ed..dcf6ca6 100644 --- a/sentence_api/tests/test_processing.py +++ b/sentence_api/tests/test_processing.py @@ -4,6 +4,7 @@ from sentence_api.processing import ( MOSS_ALGORITHM_VERSION, VideoProcessor, document_from_transcript, + find_sentence_gaps, split_sentence_text, ) from sentence_api.models import SentenceBoundary, SentenceBoundaryDocument @@ -381,3 +382,178 @@ def test_mark_processing_clears_previous_sentence_data(tmp_path): assert document.sentences == [] assert repository.get_moss_segments(VIDEO_HASH) == [] + + + +def test_find_sentence_gaps_detects_leading_middle_and_trailing(): + document = SentenceBoundaryDocument( + video_hash=VIDEO_HASH, + duration_ms=60000, + algorithm_version="test", + sentences=[ + SentenceBoundary(index=0, start_ms=10000, end_ms=12000, text="A"), + SentenceBoundary(index=1, start_ms=25000, end_ms=27000, text="B"), + ], + ) + + assert find_sentence_gaps(document, min_gap_ms=8000) == [ + (0, 10000), + (12000, 25000), + (27000, 60000), + ] + + +def test_find_sentence_gaps_ignores_short_pauses(): + document = SentenceBoundaryDocument( + video_hash=VIDEO_HASH, + duration_ms=9000, + algorithm_version="test", + sentences=[ + SentenceBoundary(index=0, start_ms=0, end_ms=4000, text="A"), + SentenceBoundary(index=1, start_ms=4500, end_ms=9000, text="B"), + ], + ) + + assert find_sentence_gaps(document) == [] + + +def test_repair_gaps_inserts_transcribed_sentences_and_keeps_existing(tmp_path, monkeypatch): + from dataclasses import replace + + from sentence_api.config import Settings + + settings = replace(Settings.from_env(), data_dir=tmp_path / "data") + settings.ensure_directories() + repository = VideoRepository(settings.database_path) + repository.upsert_upload( + video_hash=VIDEO_HASH, + title="Gap repair lesson", + filename="lesson.mp4", + stored_filename=f"{VIDEO_HASH}.mp4", + content_type="video/mp4", + size_bytes=5, + language="en", + ) + (settings.videos_dir / f"{VIDEO_HASH}.mp4").write_bytes(b"fake-video") + repository.save_processing_result( + SentenceBoundaryDocument( + video_hash=VIDEO_HASH, + duration_ms=18000, + algorithm_version="test", + sentences=[ + SentenceBoundary( + index=0, + start_ms=0, + end_ms=2000, + text="Hello.", + language="en", + reference_speech_duration_ms=1800, + ), + SentenceBoundary( + index=1, + start_ms=15000, + end_ms=18000, + text="Goodbye.", + language="en", + reference_speech_duration_ms=2500, + ), + ], + ), + "Hello. Goodbye.", + ) + repository.save_sentence_translations(VIDEO_HASH, {0: "你好。", 1: "再见。"}) + + class GapTranscriber: + available = True + calls = 0 + + def transcribe(self, audio_path, language=None): + self.calls += 1 + return Transcript( + text="The missing sentence.", + segments=[ + TranscriptionSegment( + start_seconds=1.0, + end_seconds=5.0, + text="The missing sentence.", + ) + ], + ) + + def fake_extract(media_path, output_path, *, start_ms, duration_ms): + output_path.write_bytes(_wav_bytes(_speech(max(1.0, duration_ms / 1000)), SAMPLE_RATE)) + + monkeypatch.setattr("sentence_api.processing.extract_audio_segment", fake_extract) + transcriber = GapTranscriber() + result = VideoProcessor(settings, repository, transcriber).repair_gaps(VIDEO_HASH) + + assert result["gap_count"] == 1 + assert result["transcribed_gaps"] == 1 + assert result["inserted_count"] == 1 + assert transcriber.calls == 1 + document = repository.get_document(VIDEO_HASH) + assert [sentence.text for sentence in document.sentences] == [ + "Hello.", + "The missing sentence.", + "Goodbye.", + ] + assert document.sentences[0].zh_text == "你好。" + assert document.sentences[1].zh_text is None + assert document.sentences[2].zh_text == "再见。" + assert document.sentences[0].end_ms <= document.sentences[1].start_ms + assert document.sentences[1].end_ms <= document.sentences[2].start_ms + assert document.sentences[1].start_ms >= 2000 + assert document.sentences[1].end_ms <= 15000 + + +def test_repair_gaps_skips_silent_gaps(tmp_path, monkeypatch): + from dataclasses import replace + + from sentence_api.config import Settings + + settings = replace(Settings.from_env(), data_dir=tmp_path / "data") + settings.ensure_directories() + repository = VideoRepository(settings.database_path) + repository.upsert_upload( + video_hash=VIDEO_HASH, + title="Silent gap lesson", + filename="lesson.mp4", + stored_filename=f"{VIDEO_HASH}.mp4", + content_type="video/mp4", + size_bytes=5, + language="en", + ) + (settings.videos_dir / f"{VIDEO_HASH}.mp4").write_bytes(b"fake-video") + repository.save_processing_result( + SentenceBoundaryDocument( + video_hash=VIDEO_HASH, + duration_ms=18000, + algorithm_version="test", + sentences=[ + SentenceBoundary(index=0, start_ms=0, end_ms=2000, text="Hello."), + SentenceBoundary(index=1, start_ms=15000, end_ms=18000, text="Goodbye."), + ], + ), + None, + ) + + class TrackingTranscriber: + available = True + calls = 0 + + def transcribe(self, audio_path, language=None): + self.calls += 1 + return Transcript(text="hallucinated", segments=[]) + + def fake_extract(media_path, output_path, *, start_ms, duration_ms): + output_path.write_bytes(_wav_bytes(_silence(max(1.0, duration_ms / 1000)), SAMPLE_RATE)) + + monkeypatch.setattr("sentence_api.processing.extract_audio_segment", fake_extract) + transcriber = TrackingTranscriber() + result = VideoProcessor(settings, repository, transcriber).repair_gaps(VIDEO_HASH) + + assert result["gap_count"] == 1 + assert result["inserted_count"] == 0 + assert transcriber.calls == 0 + document = repository.get_document(VIDEO_HASH) + assert [sentence.text for sentence in document.sentences] == ["Hello.", "Goodbye."] diff --git a/sentence_api/translation_jobs.py b/sentence_api/translation_jobs.py index 383dd58..3c47691 100644 --- a/sentence_api/translation_jobs.py +++ b/sentence_api/translation_jobs.py @@ -116,6 +116,23 @@ class SubtitleTranslationService: except (KeyError, ValueError, AITutorError): logger.exception("Could not restart subtitle translation: video=%s", video_hash) + def start_missing(self, video_hash: str) -> None: + """Translate sentences that still lack Chinese text, keeping existing ones.""" + if not getattr(self.ai_tutor, "available", False): + return + document = self.repository.get_document(video_hash) + if document is None: + return + if self.snapshot(document).complete: + return + try: + self._start_if_needed(video_hash) + except (KeyError, ValueError, AITutorError): + logger.exception( + "Could not start subtitle translation after gap repair: video=%s", + video_hash, + ) + def admin_snapshot(self, video_hash: str) -> AdminSubtitleTranslationResponse | None: current = self.current(video_hash) if current is None: